From 230d962f5cc1839b60324982c319b882616a9701 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:23:40 -0300 Subject: [PATCH 01/52] fix(transpiler): sync WS1 pin-typing surface from web Port the byte-identical shared transpiler changes from openplc-web's ws1-corpus-divergences work: graph-based ComputeConnectionTypes pin-type inference (new walker/connection-types.ts), blockOverloads / isOfType helpers, CR-aware pySplitLines, and the ld/fbd typeContext plumbing. Supersedes the TO_ name-suffix conversion stopgap in pou-graphical with the connection-driven mechanism (190/190 oracle parity). Keeps backend/shared byte-identical with web. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../st-transpiler/emit/pou-graphical.ts | 224 ++++++++----- .../st-transpiler/helpers/block-library.ts | 5 + .../st-transpiler/helpers/text-helpers.ts | 11 +- .../st-transpiler/helpers/type-hierarchy.ts | 11 + .../st-transpiler/walker/connection-types.ts | 312 ++++++++++++++++++ .../transpilers/st-transpiler/walker/fbd.ts | 5 +- .../transpilers/st-transpiler/walker/ld.ts | 11 +- 7 files changed, 480 insertions(+), 99 deletions(-) create mode 100644 src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts diff --git a/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts b/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts index 0540334e7..992c0092d 100644 --- a/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts +++ b/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts @@ -10,11 +10,13 @@ */ import { PLC_BASE_TYPES } from '../helpers/base-types' -import { resolveBlockType } from '../helpers/block-library' +import { type BlockInfos, blockOverloads } from '../helpers/block-library' import type { ProgramChunk } from '../helpers/program' import { computePouName } from '../helpers/text-helpers' +import { isOfType } from '../helpers/type-hierarchy' import { varTypeNames } from '../helpers/type-text' import type { TranspilePou, TranspileProject, TranspileVariable, TranspileVariableClass } from '../types' +import type { TypeContext } from '../walker/connection-types' import { emitFbdBody } from '../walker/fbd' import type { SyntheticVar } from '../walker/ld' import { emitLdBody } from '../walker/ld' @@ -24,40 +26,9 @@ import { computeValue } from './value' interface InterfaceEntry { keyword: string vars: TranspileVariable[] + located?: boolean } -/** - * Destination types of the IEC 61131-3 polymorphic conversion family - * (`TO_BOOL`, `TO_INT`, `TO_UINT`, …). Hard-coded here rather than - * derived at runtime from the catalog so any future addition is visible - * in code review. Kept in sync with `data/std_block_catalog.json` — any - * `_TO_` entry in the catalog implies `TO_` is a valid - * polymorphic conversion target. - */ -const TO_CONVERSION_TARGETS: ReadonlySet = new Set([ - 'BCD', - 'BOOL', - 'BYTE', - 'DATE', - 'DINT', - 'DT', - 'DWORD', - 'INT', - 'LINT', - 'LREAL', - 'LWORD', - 'REAL', - 'SINT', - 'STRING', - 'TIME', - 'TOD', - 'UDINT', - 'UINT', - 'ULINT', - 'USINT', - 'WORD', -]) - /* ─────────────────────────── public entry ───────────────────────────────── */ /** @@ -78,7 +49,9 @@ export function generateGraphicalPou(pou: TranspilePou, project: TranspileProjec if (pou.body.language !== 'ld' && pou.body.language !== 'fbd') { throw new Error(`generateGraphicalPou called with non-graphical body: ${pou.body.language}`) } - const emitted = pou.body.language === 'ld' ? emitLdBody(pou.body.value) : emitFbdBody(pou.body.value) + const typeContext = buildTypeContext(pou, project) + const emitted = + pou.body.language === 'ld' ? emitLdBody(pou.body.value, typeContext) : emitFbdBody(pou.body.value, typeContext) // Compose the final POU chunk stream now that the walker has // emitted the body bytes + any synthetic vars. @@ -92,45 +65,7 @@ export function generateGraphicalPou(pou: TranspilePou, project: TranspileProjec } program.push(['\n', []]) - // Resolve `ANY` placeholders in the synthesised function-output - // temps: - // 1. User-defined project functions → declared `interface.returnType`. - // 2. Standard catalog functions (ADD, MUL, NOT, AND, …) → catalog's - // formal output `type`. Generic groups (`ANY_BIT`, `ANY_NUM`, - // …) collapse to `BOOL`, which matches the corpus where these - // operators are always Boolean rung logic. A future - // computeConnectionTypes port will narrow these properly. - // 3. Polymorphic IEC 61131-3 type-conversion functions of the - // form `TO_` (TO_INT, TO_UINT, TO_REAL, …) — the - // catalog enumerates the source-specific variants - // (`BOOL_TO_UINT`, `INT_TO_UINT`, …) but NOT the generic - // `TO_` family, so resolveBlockType returns null for - // them. Without this case the synthetic var stayed at - // `ANY` and strucpp rejected the program with - // "Undefined type 'ANY' in PROGRAM" — fixed here by reading - // the destination type directly from the function name. - const resolvedSyntheticVars = emitted.syntheticVars.map((sv) => { - if (sv.type !== 'ANY' || sv.originBlockTypeName === undefined) return sv - const referenced = project.pous.find((p) => p.name === sv.originBlockTypeName) - if (referenced && referenced.pouType === 'function' && referenced.interface.returnType) { - return { ...sv, type: referenced.interface.returnType } - } - const stdResolved = resolveBlockType(sv.originBlockTypeName) - if (stdResolved) { - const outPort = stdResolved.infos.outputs.find((o) => o.name === sv.originFormalParameter) - if (outPort) { - const collapsed = outPort.type.startsWith('ANY') ? 'BOOL' : outPort.type - return { ...sv, type: collapsed } - } - } - const polymorphicMatch = sv.originBlockTypeName.match(/^TO_([A-Z]+)$/) - if (polymorphicMatch && TO_CONVERSION_TARGETS.has(polymorphicMatch[1])) { - return { ...sv, type: polymorphicMatch[1] } - } - return sv - }) - - const iface = computeInterface(pou.interface?.variables ?? [], resolvedSyntheticVars) + const iface = computeInterface(pou.interface?.variables ?? [], emitted.syntheticVars) for (const entry of iface) { const variableType = locationCategory(entry.keyword) program.push([` ${entry.keyword}`, []]) @@ -166,6 +101,118 @@ export function generateGraphicalPou(pou: TranspilePou, project: TranspileProjec /* ────────────────────────── helpers ─────────────────────────────────────── */ +// GetVariableType + GetBlockType ports (PLCGenerator.py:786-817, PLCControler.py:1288-1335) +function buildTypeContext(pou: TranspilePou, project: TranspileProject): TypeContext { + const interfaceTypes = new Map() + for (const v of pou.interface?.variables ?? []) { + interfaceTypes.set(v.name, getTypeAsText(v)) + } + + const normalizeReturnType = (rt: string): string => (PLC_BASE_TYPES.has(rt.toUpperCase()) ? rt.toUpperCase() : rt) + + const projectBlockInfos = (typeName: string): BlockInfos | null => { + const p = project.pous.find((x) => x.name === typeName) + if (p === undefined) return null + const inputs: BlockInfos['inputs'] = [] + const outputs: BlockInfos['outputs'] = [] + for (const v of p.interface?.variables ?? []) { + const io = { name: v.name, type: getTypeAsText(v), qualifier: 'none' as const } + if (v.class === 'input' || v.class === 'inOut') inputs.push(io) + if (v.class === 'output' || v.class === 'inOut') outputs.push(io) + } + if (p.pouType === 'function') { + outputs.push({ + name: 'OUT', + type: normalizeReturnType(p.interface.returnType ?? 'BOOL'), + qualifier: 'none', + }) + } + return { + name: typeName, + type: p.pouType === 'function' ? 'function' : 'functionBlock', + extensible: false, + inputs, + outputs, + comment: '', + usage: '', + } + } + + const resolveBlock: TypeContext['resolveBlock'] = (typeName, inputs) => { + const entries = blockOverloads(typeName) + if (inputs === 'undefined') { + if (entries.length > 1) return null + if (entries.length === 1) return entries[0] + } else { + for (const entry of entries) { + const formals = entry.inputs.map((i) => i.type) + const n = Math.min(inputs.length, formals.length) + let ok = true + for (let i = 0; i < n; i++) { + if (inputs[i] !== 'ANY' && !isOfType(inputs[i], formals[i])) { + ok = false + break + } + } + if (ok) return entry + } + } + const projectInfos = projectBlockInfos(typeName) + if (projectInfos === null) return null + if (inputs === 'undefined') return projectInfos + const declared = projectInfos.inputs.map((i) => i.type) + return inputs.length === declared.length && inputs.every((t, i) => t === declared[i]) ? projectInfos : null + } + + // member lookup uses the first overload even when ambiguous (python inputs=None branch) + const memberBlockInfos = (typeName: string): BlockInfos | null => { + const entries = blockOverloads(typeName) + if (entries.length > 0) return entries[0] + return projectBlockInfos(typeName) + } + + const variableType: TypeContext['variableType'] = (expression) => { + const parts = expression.split('.') + let name = parts.shift() ?? '' + let current: string | null = null + if (pou.pouType === 'function' && name === pou.name && pou.interface.returnType !== undefined) { + current = normalizeReturnType(pou.interface.returnType) + } else { + current = interfaceTypes.get(name) ?? null + } + while (current !== null && parts.length > 0) { + const block = memberBlockInfos(current) + if (block !== null) { + name = parts.shift() ?? '' + current = null + for (const io of [...block.inputs, ...block.outputs]) { + if (io.name === name) { + current = io.type + break + } + } + } else { + const dt = project.dataTypes.find((d) => d.name === current) + if (dt !== undefined && dt.derivation === 'structure') { + name = parts.shift() ?? '' + current = null + for (const el of dt.variable) { + if (el.name === name) { + current = getTypeAsText(el) + break + } + } + } else { + break + } + } + } + return current + } + + return { variableType, resolveBlock } +} + function computeInterface(variables: TranspileVariable[], syntheticVars: SyntheticVar[]): InterfaceEntry[] { const classToKeyword: Record = { input: varTypeNames.inputVars, @@ -183,27 +230,32 @@ function computeInterface(variables: TranspileVariable[], syntheticVars: Synthet bucket.push(v) grouped.set(keyword, bucket) } - // Append synthesised trigger vars + function-call output temps to - // the trailing VAR (local) bucket so they appear after the user's - // declared locals — same order the python oracle produces. + // python splits each varlist into an unlocated block then a located one (DIV-03) + const out: InterfaceEntry[] = [] + for (const [keyword, vars] of grouped) { + const unlocated = vars.filter((v) => !v.location) + const located = vars.filter((v) => v.location) + if (unlocated.length > 0) out.push({ keyword, vars: unlocated }) + if (located.length > 0) out.push({ keyword, vars: located, located: true }) + } if (syntheticVars.length > 0) { - const localKeyword = varTypeNames.localVars - const localBucket = grouped.get(localKeyword) ?? [] - for (const sv of syntheticVars) { + const synth: TranspileVariable[] = syntheticVars.map((sv) => { const isElementary = PLC_BASE_TYPES.has(sv.type.toUpperCase()) - localBucket.push({ + return { name: sv.name, type: isElementary ? { definition: 'base-type', value: sv.type.toUpperCase() } : { definition: 'derived', value: sv.type }, class: 'local', - }) + } + }) + const last = out[out.length - 1] + // python reuses the trailing block only when it is a plain unlocated VAR (DIV-16) + if (last !== undefined && last.keyword === varTypeNames.localVars && !last.located) { + last.vars.push(...synth) + } else { + out.push({ keyword: varTypeNames.localVars, vars: synth }) } - grouped.set(localKeyword, localBucket) - } - const out: InterfaceEntry[] = [] - for (const [keyword, vars] of grouped) { - out.push({ keyword, vars }) } return out } diff --git a/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts b/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts index e98d51ae6..f944d1707 100644 --- a/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts +++ b/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts @@ -51,6 +51,11 @@ const CATALOG: ReadonlyMap = (() => { return map })() +/** All catalog overloads for a name, signatures intact (GetBlockType needs them). */ +export function blockOverloads(typename: string): readonly BlockInfos[] { + return (CATALOG.get(typename) ?? []).map((e) => cloneBlockInfos(e.infos)) +} + /** * Look the block name up in the standard catalog. Single match → * return its infos. Multiple matches → return the first entry with diff --git a/src/backend/shared/transpilers/st-transpiler/helpers/text-helpers.ts b/src/backend/shared/transpilers/st-transpiler/helpers/text-helpers.ts index 256758f01..87c6fdbf9 100644 --- a/src/backend/shared/transpilers/st-transpiler/helpers/text-helpers.ts +++ b/src/backend/shared/transpilers/st-transpiler/helpers/text-helpers.ts @@ -46,16 +46,11 @@ export function reIndentText(text: string, nbSpaces: number): string { return compute } -/** - * Mirror of Python's `str.splitlines()` for the line separators we encounter. - * - * Key difference from `String.prototype.split('\n')`: Python drops the final - * empty element when the string ends with a separator. PLCOpen-loaded text - * is normalized to `\n` by lxml, so we only need to handle that form here. - */ +// Mirrors str.splitlines(): consumes CR forms (RF body text is raw editor +// bytes, no lxml EOL normalization) and drops the trailing empty element. function pySplitLines(text: string): string[] { if (text.length === 0) return [] - const lines = text.split('\n') + const lines = text.split(/\r\n|\r|\n/) if (lines[lines.length - 1] === '') lines.pop() return lines } diff --git a/src/backend/shared/transpilers/st-transpiler/helpers/type-hierarchy.ts b/src/backend/shared/transpilers/st-transpiler/helpers/type-hierarchy.ts index 7f93cbc32..566107144 100644 --- a/src/backend/shared/transpilers/st-transpiler/helpers/type-hierarchy.ts +++ b/src/backend/shared/transpilers/st-transpiler/helpers/type-hierarchy.ts @@ -59,3 +59,14 @@ export const TypeHierarchy: Readonly> = { LWORD: 'ANY_NBIT', // WSTRING intentionally absent — matches Python's `# TODO` comment. } + +// IsOfType (structures.py:36-48): soft-false for unknown/user types (DIVERGENCES.md D3) +export function isOfType(child: string, ancestor: string): boolean { + if (child === ancestor) return true + let current = TypeHierarchy[child] + while (current !== undefined && current !== null) { + if (current === ancestor) return true + current = TypeHierarchy[current] + } + return false +} diff --git a/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts b/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts new file mode 100644 index 000000000..ea8f832bb --- /dev/null +++ b/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts @@ -0,0 +1,312 @@ +/** + * Pin-type inference — port of python's ComputeConnectionTypes + + * ComputeBlockInputTypes (PLCGenerator.py:971-1241). + * + * Two passes over the flattened body graph. Pass 1 seeds concrete + * types from declared variables, literals, contacts/coils/rails and + * single-signature blocks; blocks whose signature is ambiguous are + * deferred. Pass 2 resolves deferred blocks by overload match against + * the now-known input types (permissive all-ANY synthesis when + * unknown), then unifies each ANY-class pin group with any concrete + * connected type. Pins known to share a type but still untyped live + * in `related` groups that collapse when one member gets typed. + */ + +import type { BlockInfos } from '../helpers/block-library' +import { + asBlockData, + asConnectorData, + asContinuationData, + asVariableData, + asVariableExpressionData, + type BlockData, +} from './narrow' +import type { RFBody, RFEdge, RFNode } from './types' + +export interface TypeContext { + /** Declared/dot-path/literal type of an expression, or null. */ + variableType(expression: string): string | null + /** GetBlockType port: 'undefined' = unambiguous-only lookup. */ + resolveBlock(typeName: string, inputs: readonly string[] | 'undefined'): BlockInfos | null +} + +export function pinIn(nodeId: string, handle = ''): string { + return `in:${nodeId}:${handle}` +} + +export function pinOut(nodeId: string, handle = ''): string { + return `out:${nodeId}:${handle}` +} + +const LITERAL_TYPES: Record = { + T: 'TIME', + D: 'DATE', + TOD: 'TIME_OF_DAY', + DT: 'DATE_AND_TIME', + '2': null, + '8': null, + '16': null, +} + +/** Literal-prefix typing from ComputeConnectionTypes (PLCGenerator.py:1001-1010). */ +export function literalType(expression: string): string | null { + const parts = expression.split('#') + if (parts.length > 1) { + const prefix = parts[0].toUpperCase() + return prefix in LITERAL_TYPES ? LITERAL_TYPES[prefix] : prefix + } + if (expression.startsWith("'")) return 'STRING' + if (expression.startsWith('"')) return 'WSTRING' + return null +} + +interface Graph { + byId: Map + incoming: Map +} + +class PinTypes { + types = new Map() + related: string[][] = [] + + extractRelated(pin: string): string[] { + for (let i = 0; i < this.related.length; i++) { + if (this.related[i].includes(pin)) return this.related.splice(i, 1)[0] + } + return [pin] + } + + assign(pins: readonly string[], type: string): void { + for (const pin of pins) this.types.set(pin, type) + } +} + +export function computeConnectionTypes(body: RFBody, ctx: TypeContext): Map { + const graph: Graph = { byId: new Map(), incoming: new Map() } + const nodes: RFNode[] = [] + for (const rung of body.rungs) { + for (const node of rung.nodes) { + graph.byId.set(node.id, node) + graph.incoming.set(node.id, []) + nodes.push(node) + } + for (const edge of rung.edges) { + graph.incoming.get(edge.target)?.push(edge) + } + } + + const pt = new PinTypes() + const deferred: { node: RFNode; data: BlockData }[] = [] + + for (const node of nodes) { + switch (node.type) { + case 'variable': + case 'input-variable': + case 'output-variable': + case 'inout-variable': { + const data = asVariableData(node.data) + if (data === null) break + const expr = asVariableExpressionData(node.data)?.expression ?? data.variable + const varType = ctx.variableType(expr) ?? literalType(expr) + if (varType === null) break + if (data.variant === 'input' || data.variant === 'inout') { + pt.assign(pt.extractRelated(pinOut(node.id)), varType) + } + if (data.variant === 'output' || data.variant === 'inout') { + pt.types.set(pinIn(node.id), varType) + const source = sourcePin(graph, node.id) + if (source !== null && !pt.types.has(source)) { + pt.assign(pt.extractRelated(source), varType) + } + } + break + } + case 'contact': + case 'coil': + case 'parallel': { + pt.assign(pt.extractRelated(pinOut(node.id)), 'BOOL') + pt.types.set(pinIn(node.id), 'BOOL') + for (const edge of graph.incoming.get(node.id) ?? []) { + const source = edgeSourcePin(graph, edge) + if (!pt.types.has(source)) pt.assign(pt.extractRelated(source), 'BOOL') + } + break + } + case 'powerRail': { + pt.assign(pt.extractRelated(pinOut(node.id)), 'BOOL') + pt.types.set(pinIn(node.id), 'BOOL') + for (const edge of graph.incoming.get(node.id) ?? []) { + const source = edgeSourcePin(graph, edge) + if (!pt.types.has(source)) pt.assign(pt.extractRelated(source), 'BOOL') + } + break + } + case 'continuation': { + const name = asContinuationData(node.data)?.name + if (name === undefined) break + let connector: RFNode | null = null + for (const candidate of nodes) { + if (candidate.type === 'connector' && asConnectorData(candidate.data)?.name === name) { + connector = candidate + break + } + } + if (connector === null) break + const undefinedPins = [pinOut(node.id), pinIn(connector.id)] + const source = sourcePin(graph, connector.id) + if (source !== null) undefinedPins.push(source) + let varType = 'ANY' + const related: string[] = [] + for (const pin of undefinedPins) { + const known = pt.types.get(pin) + if (known !== undefined) varType = known + else related.push(...pt.extractRelated(pin)) + } + if (varType.startsWith('ANY') && related.length > 0) pt.related.push(related) + else pt.assign(related, varType) + break + } + case 'block': { + const data = asBlockData(node.data) + if (data === null) break + const infos = ctx.resolveBlock(data.typeName, 'undefined') + if (infos !== null) { + computeBlockInputTypes(graph, pt, node, data, infos) + } else { + for (const handle of wiredInputHandles(graph, node, data)) { + const pin = pinIn(node.id, handle) + const source = sourcePin(graph, node.id, handle) + if (source === null) continue + const sourceType = pt.types.get(source) + if (sourceType !== undefined) { + pt.types.set(pin, sourceType) + } else { + const related = pt.extractRelated(source) + related.push(pin) + pt.related.push(related) + } + } + deferred.push({ node, data }) + } + break + } + } + } + + for (const { node, data } of deferred) { + const inputs = wiredInputHandles(graph, node, data) + .filter((h) => h !== 'EN') + .map((h) => pt.types.get(pinIn(node.id, h)) ?? 'ANY') + const infos = ctx.resolveBlock(data.typeName, inputs) ?? synthesizePermissiveBlockInfos(graph, node, data) + computeBlockInputTypes(graph, pt, node, data, infos) + } + + return pt.types +} + +/** ComputeBlockInputTypes (PLCGenerator.py:1183-1241). */ +function computeBlockInputTypes(graph: Graph, pt: PinTypes, node: RFNode, data: BlockData, infos: BlockInfos): void { + const undefinedGroups = new Map() + const bucket = (anyClass: string, pin: string) => { + const group = undefinedGroups.get(anyClass) ?? [] + group.push(pin) + undefinedGroups.set(anyClass, group) + } + + for (const out of outputHandles(data)) { + const pin = pinOut(node.id, out) + if (out === 'ENO') { + pt.assign(pt.extractRelated(pin), 'BOOL') + continue + } + for (const formal of infos.outputs) { + if (formal.name !== out) continue + if (formal.type.startsWith('ANY')) bucket(formal.type, pin) + else if (!pt.types.has(pin)) pt.assign(pt.extractRelated(pin), formal.type) + } + } + + for (const handle of wiredInputHandles(graph, node, data)) { + const pin = pinIn(node.id, handle) + if (handle === 'EN') { + pt.assign(pt.extractRelated(pin), 'BOOL') + continue + } + for (const formal of infos.inputs) { + if (formal.name !== handle) continue + const source = sourcePin(graph, node.id, handle) + if (formal.type.startsWith('ANY')) { + bucket(formal.type, pin) + if (source !== null) bucket(formal.type, source) + } else { + pt.types.set(pin, formal.type) + if (source !== null && !pt.types.has(source)) { + pt.assign(pt.extractRelated(source), formal.type) + } + } + } + } + + for (const [anyClass, pins] of undefinedGroups) { + let varType = anyClass + const related: string[] = [] + for (const pin of pins) { + const known = pt.types.get(pin) + if (known !== undefined && !known.startsWith('ANY')) varType = known + else related.push(...pt.extractRelated(pin)) + } + if (varType.startsWith('ANY') && related.length > 0) pt.related.push(related) + else pt.assign(related, varType) + } +} + +/** SynthesizePermissiveBlockInfos (PLCGenerator.py:732-763). */ +function synthesizePermissiveBlockInfos(graph: Graph, node: RFNode, data: BlockData): BlockInfos { + return { + name: data.typeName, + type: data.blockKind === 'function-block-instance' ? 'functionBlock' : 'function', + extensible: false, + inputs: wiredInputHandles(graph, node, data) + .filter((h) => h !== 'EN') + .map((h) => ({ name: h, type: 'ANY', qualifier: 'none' as const })), + outputs: outputHandles(data) + .filter((h) => h !== 'ENO') + .map((h) => ({ name: h, type: 'ANY', qualifier: 'none' as const })), + comment: '', + usage: '', + } +} + +// python only ever sees serialized (wired) input pins; EN first when wired +function wiredInputHandles(graph: Graph, node: RFNode, data: BlockData): string[] { + const wired = new Set((graph.incoming.get(node.id) ?? []).map((e) => e.targetHandle ?? '')) + const handles: string[] = [] + if (wired.has('EN')) handles.push('EN') + for (const name of data.inputs) { + if (wired.has(name)) handles.push(name) + } + return handles +} + +function outputHandles(data: BlockData): string[] { + const handles = [...data.outputs] + if (data.executionControl && !handles.includes('ENO')) handles.push('ENO') + return handles +} + +function sourcePin(graph: Graph, nodeId: string, handle?: string): string | null { + for (const edge of graph.incoming.get(nodeId) ?? []) { + if (handle === undefined || (edge.targetHandle ?? '') === handle) { + return edgeSourcePin(graph, edge) + } + } + return null +} + +function edgeSourcePin(graph: Graph, edge: RFEdge): string { + const source = graph.byId.get(edge.source) + if (source !== undefined && source.type === 'block') { + return pinOut(edge.source, edge.sourceHandle ?? '') + } + return pinOut(edge.source) +} diff --git a/src/backend/shared/transpilers/st-transpiler/walker/fbd.ts b/src/backend/shared/transpilers/st-transpiler/walker/fbd.ts index 82c9b481b..62c48792f 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/fbd.ts +++ b/src/backend/shared/transpilers/st-transpiler/walker/fbd.ts @@ -14,6 +14,7 @@ * sink sort) all live in the LD walker — they're no-ops for LD * bodies that don't exercise them. */ +import type { TypeContext } from './connection-types' import { emitLdBody, type EmitResult } from './ld' import type { RFRung } from './types' @@ -24,6 +25,6 @@ export interface RFFbdBody { rung: RFRung } -export function emitFbdBody(body: RFFbdBody): EmitResult { - return emitLdBody({ rungs: [body.rung] }) +export function emitFbdBody(body: RFFbdBody, typeContext?: TypeContext): EmitResult { + return emitLdBody({ rungs: [body.rung] }, typeContext) } diff --git a/src/backend/shared/transpilers/st-transpiler/walker/ld.ts b/src/backend/shared/transpilers/st-transpiler/walker/ld.ts index fe2c3a6b4..72c57d54c 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/ld.ts +++ b/src/backend/shared/transpilers/st-transpiler/walker/ld.ts @@ -25,6 +25,7 @@ import { type ProgramChunk, TRUE_NODE, } from '../core/path-tree' +import { computeConnectionTypes, pinOut, type TypeContext } from './connection-types' import { asBlockData, asCoilData, @@ -102,6 +103,8 @@ interface WalkerState { * in the same coordinate space the python oracle sees. */ yOffset: Map warnings: string[] + /** Pin types from computeConnectionTypes; empty without a TypeContext. */ + connTypes: Map } function rungHeight(rung: RFRung): number { @@ -152,7 +155,7 @@ function isVariableNode(node: RFNode): boolean { /* ─────────────────────────── public entry ───────────────────────────────── */ -export function emitLdBody(body: RFBody): EmitResult { +export function emitLdBody(body: RFBody, typeContext?: TypeContext): EmitResult { // POU name doesn't influence body emission today (it's only the // first element of the `Location` tuples used for source-map back- // references). Hard-code a sentinel; the orchestrator can pass a @@ -172,6 +175,7 @@ export function emitLdBody(body: RFBody): EmitResult { connectorExprs: new Map(), yOffset: new Map(), warnings: [], + connTypes: typeContext ? computeConnectionTypes(body, typeContext) : new Map(), } // Index every node + edge from every rung up front. Sinks are then @@ -678,7 +682,8 @@ function emitFunctionCall(state: WalkerState, node: RFNode, data: BlockData): vo const info: Location = [state.tagName, 'block', locId(node)] const wiredInputs = data.inputs.filter((name) => firstIncomingForHandle(state, node.id, name) !== undefined) - const allInputConnected = wiredInputs.length === data.inputs.length + // python only ever sees wired pins for extensible blocks (DIV-18) + const allInputConnected = data.extensible || wiredInputs.length === data.inputs.length const useNamedArgs = data.outputs.length > 1 || !allInputConnected const recurseOrdered = data.executionOrder > 0 @@ -692,7 +697,7 @@ function emitFunctionCall(state: WalkerState, node: RFNode, data: BlockData): vo for (let i = 0; i < data.outputs.length; i++) { const out = data.outputs[i] const tempName = `_TMP_${data.typeName}${data.numericId}_${out}` - const tempType = out === 'ENO' ? 'BOOL' : 'ANY' + const tempType = state.connTypes.get(pinOut(node.id, out)) ?? (out === 'ENO' ? 'BOOL' : 'ANY') state.functionTempVars.push({ name: tempName, type: tempType, From 6aa335b22cd37cd3190d7572a42ad354cd2b9680 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:23:40 -0300 Subject: [PATCH 02/52] feat(transpiler): run both engines and log output comparison Mirror web's comparison feature on the editor side. transpileToSt (the platform port) and compileForDebugger now run BOTH the legacy xml2st subprocess and the new in-process TS transpiler on every compile, log a comparison verdict, and use the old engine's output as authoritative unless OPENPLC_USE_NEW_TRANSPILER flips it. The non-authoritative engine's failure is non-fatal. Adds an editor-local compare helper mirroring web's compare-transpiler-output. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/compiler/compiler-module.ts | 103 ++++++----- .../compiler/editor-compiler-platform-port.ts | 169 +++++++++++------- .../compare-transpiler-output.ts | 78 ++++++++ 3 files changed, 241 insertions(+), 109 deletions(-) create mode 100644 src/backend/editor/services/transpiler-comparison/compare-transpiler-output.ts diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 08f376ef1..efd5dab2e 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -82,6 +82,7 @@ type ProjectDataWithCppPous = PLCProjectData & { const POST_BUILD_START_TIMEOUT_MS = 5000 const POST_BUILD_START_POLL_INTERVAL_MS = 150 +import { compareTranspilerOutput } from '@root/backend/editor/services/transpiler-comparison/compare-transpiler-output' import { assertPathContained } from '@root/backend/editor/utils/path-containment' import { getRuntimeHttpsOptions } from '@root/backend/editor/utils/runtime-https-config' import { isNewTranspilerEnabled } from '@root/backend/editor/utils/transpiler-mode' @@ -2882,55 +2883,24 @@ class CompilerModule { return } - if (isNewTranspilerEnabled()) { - // JSON → ST in-process via `st-transpiler`. Mirrors what - // `editor-compiler-platform-port.transpileToSt` does for the - // shared pipeline path, scoped down to the debug compile here. - try { - const ir = fromSchemaShape(projectData as unknown as SchemaProjectData) - const result = runJsonTranspiler(ir) - if (result.programSt === null || result.errors.length > 0) { - const message = result.errors.join('\n') || 'Failed to generate Structured Text' - _mainProcessPort.postMessage({ - logLevel: 'error', - message: `${message}\nStopping debug compilation process.`, - }) - _mainProcessPort.close() - return - } - for (const warning of result.warnings) { - _mainProcessPort.postMessage({ logLevel: 'info', message: warning }) - } - await mkdir(sourceTargetFolderPath, { recursive: true }) - const programStPath = join(sourceTargetFolderPath, 'program.st') - await writeFile(programStPath, result.programSt, 'utf-8') - _mainProcessPort.postMessage({ logLevel: 'info', message: `ST file generated at: ${programStPath}` }) - } catch (error) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: `Error transpiling JSON to ST: ${getErrorMessage(error)}\nStopping debug compilation process.`, - }) - _mainProcessPort.close() - return - } - } else { + // Project -> ST. Run BOTH engines on every debug compile so their + // outputs can be compared while the new in-process TS transpiler is + // validated against the legacy xml2st subprocess. The OLD engine is + // authoritative unless OPENPLC_USE_NEW_TRANSPILER flips it; the + // comparison verdict is posted either way. Only the legacy path writes + // files, so running them in parallel is safe — the authoritative ST is + // written to program.st last so downstream STruC++ reads the chosen + // output. + type DebugStResult = { ok: true; programSt: string } | { ok: false; programSt?: undefined; error: string } + + const runDebugLegacy = async (): Promise => { try { const generateXMLResult = await this.handleGenerateXMLfromJSON(sourceTargetFolderPath, projectData) _mainProcessPort.postMessage({ logLevel: 'info', message: `Generated XML from JSON at: ${generateXMLResult.data?.xmlPath as string}`, }) - } catch (error) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: `Error generating XML from JSON: ${error as string}\nStopping debug compilation process.`, - }) - _mainProcessPort.close() - return - } - - const generatedXMLFilePath = join(sourceTargetFolderPath, 'plc.xml') - try { + const generatedXMLFilePath = join(sourceTargetFolderPath, 'plc.xml') await this.handleTranspileXMLtoST( generatedXMLFilePath, (data, logLevel) => { @@ -2938,16 +2908,51 @@ class CompilerModule { }, ['--keep-structs'], ) + const programSt = await readFile(join(sourceTargetFolderPath, 'program.st'), 'utf-8') + return { ok: true, programSt } } catch (error) { - _mainProcessPort.postMessage({ - logLevel: 'error', - message: `Error transpiling XML to ST: ${error as string}\nStopping debug compilation process.`, - }) - _mainProcessPort.close() - return + return { ok: false, error: `xml2st failed: ${getErrorMessage(error)}` } } } + const runDebugInProcess = (): DebugStResult => { + try { + const ir = fromSchemaShape(projectData as unknown as SchemaProjectData) + const result = runJsonTranspiler(ir) + if (result.programSt === null || result.errors.length > 0) { + return { ok: false, error: result.errors.join('\n') || 'Failed to generate Structured Text' } + } + return { ok: true, programSt: result.programSt } + } catch (error) { + return { ok: false, error: `st-transpiler failed: ${getErrorMessage(error)}` } + } + } + + await mkdir(sourceTargetFolderPath, { recursive: true }) + const [oldStResult, newStResult] = await Promise.all([runDebugLegacy(), Promise.resolve(runDebugInProcess())]) + + const debugComparison = compareTranspilerOutput( + oldStResult.ok ? oldStResult.programSt : undefined, + newStResult.ok ? newStResult.programSt : undefined, + ) + _mainProcessPort.postMessage({ + logLevel: debugComparison.status === 'identical' ? 'info' : 'warning', + message: `Transpiler comparison: ${debugComparison.message}`, + }) + + const authoritativeSt = isNewTranspilerEnabled() ? newStResult : oldStResult + if (!authoritativeSt.ok) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `${authoritativeSt.error}\nStopping debug compilation process.`, + }) + _mainProcessPort.close() + return + } + const programStPath = join(sourceTargetFolderPath, 'program.st') + await writeFile(programStPath, authoritativeSt.programSt, 'utf-8') + _mainProcessPort.postMessage({ logLevel: 'info', message: `ST file generated at: ${programStPath}` }) + try { await this.copyStaticFiles(compilationPath, boardRuntime) _mainProcessPort.postMessage({ logLevel: 'info', message: 'Static files copied successfully.' }) diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index fe2b39aeb..63571c012 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -30,6 +30,11 @@ * `middleware/adapters/web/`. */ +import { + compareTranspilerOutput, + TRANSPILER_LABEL_NEW, + TRANSPILER_LABEL_OLD, +} from '@root/backend/editor/services/transpiler-comparison/compare-transpiler-output' import { isNewTranspilerEnabled } from '@root/backend/editor/utils/transpiler-mode' import { deployRuntimeProgram } from '@root/backend/shared/library/deploy-runtime-program' import { probeRuntimeVersion } from '@root/backend/shared/library/probe-runtime-version' @@ -147,10 +152,90 @@ export interface EditorCompilerPlatformPortContext { * arrays, device context) and shims them onto the existing * handlers' filesystem-and-subprocess shape. */ +/** + * Log how the old and new transpiler outputs compare. Emits a single + * info line when they match and a warning when they differ or when either + * engine failed (which makes a content comparison impossible). + */ +function logEditorTranspilerComparison( + oldResult: TranspileToStResult, + newResult: TranspileToStResult, + log: PlatformLog, +): void { + const oldSt = oldResult.ok ? oldResult.programSt : undefined + const newSt = newResult.ok ? newResult.programSt : undefined + const comparison = compareTranspilerOutput(oldSt, newSt) + + log(`Transpiler comparison: ${comparison.message}`, comparison.status === 'identical' ? 'info' : 'warning') + + if (comparison.status === 'incomparable') { + if (typeof oldSt !== 'string' && oldResult.errors?.length) { + log(`Transpiler comparison: ${TRANSPILER_LABEL_OLD} errors: ${oldResult.errors.map((e) => e.message).join('; ')}`, 'warning') + } + if (typeof newSt !== 'string' && newResult.errors?.length) { + log(`Transpiler comparison: ${TRANSPILER_LABEL_NEW} errors: ${newResult.errors.map((e) => e.message).join('; ')}`, 'warning') + } + } +} + export function createEditorCompilerPlatformPort( handlers: EditorCompilerHandlers, context: EditorCompilerPlatformPortContext, ): CompilerPlatformPort { + /** + * Legacy transpile: serialise the project to IEC 61131-3 XML, write it + * to `/plc.xml`, run the bundled `xml2st` subprocess, + * then read `program.st` back. Streams the subprocess's own output to + * `log`; folds failures into the result so the caller can run it + * alongside the in-process engine for comparison. + */ + const runLegacyTranspile = async (args: TranspileToStArgs, log: PlatformLog): Promise => { + const xmlResult = XmlGenerator(args.projectData as never, 'old-editor') + if (!xmlResult.ok || !xmlResult.data) { + return { ok: false, errors: [{ message: `XML generation failed: ${xmlResult.message}`, line: 0, column: 0, severity: 'error' }] } + } + const xmlPath = join(context.sourceTargetFolderPath, 'plc.xml') + try { + await fs.mkdir(dirname(xmlPath), { recursive: true }) + await fs.writeFile(xmlPath, xmlResult.data, 'utf-8') + await handlers.handleTranspileXMLtoST( + xmlPath, + (chunk, level) => { + const message = typeof chunk === 'string' ? chunk : chunk.toString() + log(message, level ?? 'info') + }, + ['--keep-structs'], + ) + const programStPath = join(context.sourceTargetFolderPath, 'program.st') + const programSt = await fs.readFile(programStPath, 'utf-8') + return { ok: true, programSt } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { ok: false, errors: [{ message: `xml2st failed: ${message}`, line: 0, column: 0, severity: 'error' }] } + } + } + + /** + * New transpile: project the schema-shape payload via `fromSchemaShape` + * and run the in-process JSON transpiler. Folds failures into the result + * (see `runLegacyTranspile`). The editor's IPC payload is schema-shape; + * the double cast bridges the declared-vs-runtime mismatch. + */ + const runInProcessTranspile = (args: TranspileToStArgs): TranspileToStResult => { + try { + const ir = fromSchemaShape(args.projectData as unknown as SchemaProjectData) + const result = runJsonTranspiler(ir) + if (result.programSt === null || result.errors.length > 0) { + const message = result.errors.join('\n') || 'Failed to generate Structured Text' + return { ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] } + } + return { ok: true, programSt: result.programSt } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { ok: false, errors: [{ message: `st-transpiler failed: ${message}`, line: 0, column: 0, severity: 'error' }] } + } + } + return { /** * Node's `crypto.createHash('md5')` produces the canonical MD5 @@ -163,72 +248,36 @@ export function createEditorCompilerPlatformPort( }, /** - * Transpile the project IR to Structured Text. The toggle — - * `OPENPLC_USE_NEW_TRANSPILER` via `isNewTranspilerEnabled()` — selects - * between the in-process JSON-fed transpiler (new path, opt-in) - * and the bundled `xml2st` subprocess (legacy path, default). + * Transpile the project IR to Structured Text. Runs BOTH engines on + * every call so their outputs can be compared while the new in-process + * TS transpiler is validated against the legacy `xml2st` subprocess: * - * The legacy branch reproduces the pre-Phase-2 behaviour: - * serialise the project to IEC 61131-3 XML via the shared - * `XmlGenerator`, materialise it to `/plc.xml`, - * run `handleTranspileXMLtoST` (which spawns the bundled `xml2st` - * binary), then read `program.st` back from disk. + * - old: serialise to IEC 61131-3 XML via `XmlGenerator`, write it to + * `/plc.xml`, run the bundled `xml2st` binary, + * read `program.st` back. + * - new: run the in-process JSON transpiler + * (`backend/shared/transpilers/st-transpiler/`). + * + * The comparison verdict is logged on every compile. The OLD engine is + * authoritative unless `OPENPLC_USE_NEW_TRANSPILER` flips authority to + * the new one; the non-authoritative engine's failures are non-fatal and + * downgraded to comparison warnings. */ async transpileToSt(args: TranspileToStArgs, log: PlatformLog): Promise { - if (isNewTranspilerEnabled()) { - try { - // Editor IPC delivers the schema-shape project data - // (discriminated-union POUs + singular `configuration`). - // The port's declared `projectData` type is port-shape, but - // the pipeline reaches us with the editor's schema-shape IPC - // payload (matching `compileProgram`'s actual contract). The - // double cast bridges the static mismatch without serialising - // through `unknown` at runtime. - const ir = fromSchemaShape(args.projectData as unknown as SchemaProjectData) - const result = runJsonTranspiler(ir) - if (result.programSt === null || result.errors.length > 0) { - const message = result.errors.join('\n') || 'Failed to generate Structured Text' - log(message, 'error') - return { ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] } - } - for (const warning of result.warnings) { - log(warning, 'info') - } - return { ok: true, programSt: result.programSt } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - log(`st-transpiler failed: ${message}`, 'error') - return { ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] } - } - } - - const xmlResult = XmlGenerator(args.projectData as never, 'old-editor') - if (!xmlResult.ok || !xmlResult.data) { - log(`XML generation failed: ${xmlResult.message}`, 'error') - return { ok: false, errors: [{ message: xmlResult.message, line: 0, column: 0, severity: 'error' }] } - } - const xmlPath = join(context.sourceTargetFolderPath, 'plc.xml') - try { - await fs.mkdir(dirname(xmlPath), { recursive: true }) - await fs.writeFile(xmlPath, xmlResult.data, 'utf-8') + const useNew = isNewTranspilerEnabled() + const [oldResult, newResult] = await Promise.all([ + runLegacyTranspile(args, log), + Promise.resolve(runInProcessTranspile(args)), + ]) - await handlers.handleTranspileXMLtoST( - xmlPath, - (chunk, level) => { - const message = typeof chunk === 'string' ? chunk : chunk.toString() - log(message, level ?? 'info') - }, - ['--keep-structs'], - ) + logEditorTranspilerComparison(oldResult, newResult, log) - const programStPath = join(context.sourceTargetFolderPath, 'program.st') - const programSt = await fs.readFile(programStPath, 'utf-8') - return { ok: true, programSt } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - log(`xml2st failed: ${message}`, 'error') - return { ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] } + const authoritative = useNew ? newResult : oldResult + if (!authoritative.ok) { + const message = authoritative.errors?.map((e) => e.message).join('\n') || 'Failed to generate Structured Text' + log(message, 'error') } + return authoritative }, /** diff --git a/src/backend/editor/services/transpiler-comparison/compare-transpiler-output.ts b/src/backend/editor/services/transpiler-comparison/compare-transpiler-output.ts new file mode 100644 index 000000000..41f236f16 --- /dev/null +++ b/src/backend/editor/services/transpiler-comparison/compare-transpiler-output.ts @@ -0,0 +1,78 @@ +/** + * Pure comparison of the two transpiler engines' Structured Text output. + * + * Both editor transpile paths (the shared compile pipeline via the platform + * port and the debug compile in `compiler-module`) run the legacy `xml2st` + * subprocess and the new in-process TS engine on every compile while the new + * engine is being validated. This helper turns the two ST strings into a + * single comparison verdict + human-readable summary that each call site + * logs through its own channel. + * + * Mirrors web's `middleware/adapters/web/services/st-transpiler/compare-transpiler-output.ts` + * (the only difference is the old-engine label — editor runs xml2st locally, + * web calls the remote compile service). + */ + +export const TRANSPILER_LABEL_OLD = 'old (xml2st)' +export const TRANSPILER_LABEL_NEW = 'new (in-process TS)' + +export interface TranspilerComparison { + /** + * `identical` — both engines produced byte-equal ST. + * `different` — both succeeded but the ST diverges. + * `incomparable` — at least one engine failed, so there is nothing to diff. + */ + status: 'identical' | 'different' | 'incomparable' + /** Human-readable one-line summary suitable for a log message. */ + message: string +} + +/** + * Compare the ST emitted by each engine. Pass `undefined` for an engine + * that failed to produce output — the verdict is then `incomparable`. + */ +export function compareTranspilerOutput(oldSt: string | undefined, newSt: string | undefined): TranspilerComparison { + if (typeof oldSt !== 'string' || typeof newSt !== 'string') { + return { + status: 'incomparable', + message: + `comparison skipped — ${TRANSPILER_LABEL_OLD}: ${typeof oldSt === 'string' ? 'ok' : 'failed'}, ` + + `${TRANSPILER_LABEL_NEW}: ${typeof newSt === 'string' ? 'ok' : 'failed'}`, + } + } + + if (oldSt === newSt) { + return { status: 'identical', message: `outputs identical (${oldSt.length} chars)` } + } + + return { status: 'different', message: `outputs DIFFER — ${describeStDifference(oldSt, newSt)}` } +} + +/** + * Concise summary of where two ST outputs diverge: char and line counts + * for each engine plus the first differing line shown verbatim. + */ +function describeStDifference(oldSt: string, newSt: string): string { + const oldLines = oldSt.split('\n') + const newLines = newSt.split('\n') + const lineCount = Math.max(oldLines.length, newLines.length) + + let firstDiff = -1 + for (let i = 0; i < lineCount; i++) { + if (oldLines[i] !== newLines[i]) { + firstDiff = i + break + } + } + + const parts = [ + `${TRANSPILER_LABEL_OLD} ${oldSt.length} chars / ${oldLines.length} lines`, + `${TRANSPILER_LABEL_NEW} ${newSt.length} chars / ${newLines.length} lines`, + ] + if (firstDiff >= 0) { + parts.push(`first difference at line ${firstDiff + 1}`) + parts.push(`old: ${JSON.stringify(oldLines[firstDiff] ?? '')}`) + parts.push(`new: ${JSON.stringify(newLines[firstDiff] ?? '')}`) + } + return parts.join('; ') +} From 7581d2b5699752b8a92f1a0a74570fd421ca1578 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:05:24 -0300 Subject: [PATCH 03/52] feat(transpiler): library-agnostic block resolution from project variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-identical mirror of openplc-web (PR #528) — keeps the shared backend/shared/ surface in sync (ci-sync). See the web commit for rationale: the transpiler now resolves block signatures from each placed block's node.data.variant instead of the bundled std_block_catalog.json (deleted), and adds __XWORD to the type lattice and base types. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../st-transpiler/data/std_block_catalog.json | 15855 ---------------- .../st-transpiler/emit/pou-graphical.ts | 74 +- .../st-transpiler/helpers/base-types.ts | 3 + .../st-transpiler/helpers/block-library.ts | 107 +- .../st-transpiler/helpers/type-hierarchy.ts | 3 + .../st-transpiler/walker/connection-types.ts | 11 +- 6 files changed, 94 insertions(+), 15959 deletions(-) delete mode 100644 src/backend/shared/transpilers/st-transpiler/data/std_block_catalog.json diff --git a/src/backend/shared/transpilers/st-transpiler/data/std_block_catalog.json b/src/backend/shared/transpilers/st-transpiler/data/std_block_catalog.json deleted file mode 100644 index daf0e5474..000000000 --- a/src/backend/shared/transpilers/st-transpiler/data/std_block_catalog.json +++ /dev/null @@ -1,15855 +0,0 @@ -{ - "SR": [ - { - "section": "Standard function blocks", - "infos": { - "name": "SR", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "S1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "R", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q1", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "The SR bistable is a latch where the Set dominates.", - "usage": "\n (BOOL:S1, BOOL:R) => (BOOL:Q1)" - } - } - ], - "RS": [ - { - "section": "Standard function blocks", - "infos": { - "name": "RS", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "S", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "R1", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q1", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "The RS bistable is a latch where the Reset dominates.", - "usage": "\n (BOOL:S, BOOL:R1) => (BOOL:Q1)" - } - } - ], - "SEMA": [ - { - "section": "Standard function blocks", - "infos": { - "name": "SEMA", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CLAIM", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "RELEASE", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "BUSY", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "The semaphore provides a mechanism to allow software elements mutually exclusive access to certain resources.", - "usage": "\n (BOOL:CLAIM, BOOL:RELEASE) => (BOOL:BUSY)" - } - } - ], - "R_TRIG": [ - { - "section": "Standard function blocks", - "infos": { - "name": "R_TRIG", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CLK", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "The output produces a single pulse when a rising edge is detected.", - "usage": "\n (BOOL:CLK) => (BOOL:Q)" - } - } - ], - "F_TRIG": [ - { - "section": "Standard function blocks", - "infos": { - "name": "F_TRIG", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CLK", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "The output produces a single pulse when a falling edge is detected.", - "usage": "\n (BOOL:CLK) => (BOOL:Q)" - } - } - ], - "CTU": [ - { - "section": "Standard function blocks", - "infos": { - "name": "CTU", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CU", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "R", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PV", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "CV", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "The up-counter can be used to signal when a count has reached a maximum value.", - "usage": "\n (BOOL:CU, BOOL:R, INT:PV) => (BOOL:Q, INT:CV)" - } - } - ], - "CTU_DINT": [ - { - "section": "Standard function blocks", - "infos": { - "name": "CTU_DINT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CU", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "R", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PV", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "CV", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "The up-counter can be used to signal when a count has reached a maximum value.", - "usage": "\n (BOOL:CU, BOOL:R, DINT:PV) => (BOOL:Q, DINT:CV)" - } - } - ], - "CTU_LINT": [ - { - "section": "Standard function blocks", - "infos": { - "name": "CTU_LINT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CU", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "R", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PV", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "CV", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "The up-counter can be used to signal when a count has reached a maximum value.", - "usage": "\n (BOOL:CU, BOOL:R, LINT:PV) => (BOOL:Q, LINT:CV)" - } - } - ], - "CTU_UDINT": [ - { - "section": "Standard function blocks", - "infos": { - "name": "CTU_UDINT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CU", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "R", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PV", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "CV", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "The up-counter can be used to signal when a count has reached a maximum value.", - "usage": "\n (BOOL:CU, BOOL:R, UDINT:PV) => (BOOL:Q, UDINT:CV)" - } - } - ], - "CTU_ULINT": [ - { - "section": "Standard function blocks", - "infos": { - "name": "CTU_ULINT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CU", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "R", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PV", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "CV", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "The up-counter can be used to signal when a count has reached a maximum value.", - "usage": "\n (BOOL:CU, BOOL:R, ULINT:PV) => (BOOL:Q, ULINT:CV)" - } - } - ], - "CTD": [ - { - "section": "Standard function blocks", - "infos": { - "name": "CTD", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CD", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "LD", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PV", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "CV", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "The down-counter can be used to signal when a count has reached zero, on counting down from a preset value.", - "usage": "\n (BOOL:CD, BOOL:LD, INT:PV) => (BOOL:Q, INT:CV)" - } - } - ], - "CTD_DINT": [ - { - "section": "Standard function blocks", - "infos": { - "name": "CTD_DINT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CD", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "LD", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PV", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "CV", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "The down-counter can be used to signal when a count has reached zero, on counting down from a preset value.", - "usage": "\n (BOOL:CD, BOOL:LD, DINT:PV) => (BOOL:Q, DINT:CV)" - } - } - ], - "CTD_LINT": [ - { - "section": "Standard function blocks", - "infos": { - "name": "CTD_LINT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CD", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "LD", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PV", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "CV", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "The down-counter can be used to signal when a count has reached zero, on counting down from a preset value.", - "usage": "\n (BOOL:CD, BOOL:LD, LINT:PV) => (BOOL:Q, LINT:CV)" - } - } - ], - "CTD_UDINT": [ - { - "section": "Standard function blocks", - "infos": { - "name": "CTD_UDINT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CD", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "LD", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PV", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "CV", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "The down-counter can be used to signal when a count has reached zero, on counting down from a preset value.", - "usage": "\n (BOOL:CD, BOOL:LD, UDINT:PV) => (BOOL:Q, UDINT:CV)" - } - } - ], - "CTD_ULINT": [ - { - "section": "Standard function blocks", - "infos": { - "name": "CTD_ULINT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CD", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "LD", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PV", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "CV", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "The down-counter can be used to signal when a count has reached zero, on counting down from a preset value.", - "usage": "\n (BOOL:CD, BOOL:LD, ULINT:PV) => (BOOL:Q, ULINT:CV)" - } - } - ], - "CTUD": [ - { - "section": "Standard function blocks", - "infos": { - "name": "CTUD", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CU", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "CD", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "R", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "LD", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PV", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "QU", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "QD", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "CV", - "type": "INT", - "qualifier": "none" - }, - { - "name": "CD_T", - "type": "R_TRIG", - "qualifier": "none" - }, - { - "name": "CU_T", - "type": "R_TRIG", - "qualifier": "none" - } - ], - "comment": "The up-down counter has two inputs CU and CD. It can be used to both count up on one input and down on the other.", - "usage": "\n (BOOL:CU, BOOL:CD, BOOL:R, BOOL:LD, INT:PV) => (BOOL:QU, BOOL:QD, INT:CV, R_TRIG:CD_T, R_TRIG:CU_T)" - } - } - ], - "CTUD_DINT": [ - { - "section": "Standard function blocks", - "infos": { - "name": "CTUD_DINT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CU", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "CD", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "R", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "LD", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PV", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "QU", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "QD", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "CV", - "type": "DINT", - "qualifier": "none" - }, - { - "name": "CD_T", - "type": "R_TRIG", - "qualifier": "none" - }, - { - "name": "CU_T", - "type": "R_TRIG", - "qualifier": "none" - } - ], - "comment": "The up-down counter has two inputs CU and CD. It can be used to both count up on one input and down on the other.", - "usage": "\n (BOOL:CU, BOOL:CD, BOOL:R, BOOL:LD, DINT:PV) => (BOOL:QU, BOOL:QD, DINT:CV, R_TRIG:CD_T, R_TRIG:CU_T)" - } - } - ], - "CTUD_LINT": [ - { - "section": "Standard function blocks", - "infos": { - "name": "CTUD_LINT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CU", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "CD", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "R", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "LD", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PV", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "QU", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "QD", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "CV", - "type": "LINT", - "qualifier": "none" - }, - { - "name": "CD_T", - "type": "R_TRIG", - "qualifier": "none" - }, - { - "name": "CU_T", - "type": "R_TRIG", - "qualifier": "none" - } - ], - "comment": "The up-down counter has two inputs CU and CD. It can be used to both count up on one input and down on the other.", - "usage": "\n (BOOL:CU, BOOL:CD, BOOL:R, BOOL:LD, LINT:PV) => (BOOL:QU, BOOL:QD, LINT:CV, R_TRIG:CD_T, R_TRIG:CU_T)" - } - } - ], - "CTUD_UDINT": [ - { - "section": "Standard function blocks", - "infos": { - "name": "CTUD_UDINT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CU", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "CD", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "R", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "LD", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PV", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "QU", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "QD", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "CV", - "type": "UDINT", - "qualifier": "none" - }, - { - "name": "CD_T", - "type": "R_TRIG", - "qualifier": "none" - }, - { - "name": "CU_T", - "type": "R_TRIG", - "qualifier": "none" - } - ], - "comment": "The up-down counter has two inputs CU and CD. It can be used to both count up on one input and down on the other.", - "usage": "\n (BOOL:CU, BOOL:CD, BOOL:R, BOOL:LD, UDINT:PV) => (BOOL:QU, BOOL:QD, UDINT:CV, R_TRIG:CD_T, R_TRIG:CU_T)" - } - } - ], - "CTUD_ULINT": [ - { - "section": "Standard function blocks", - "infos": { - "name": "CTUD_ULINT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CU", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "CD", - "type": "BOOL", - "qualifier": "rising" - }, - { - "name": "R", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "LD", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PV", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "QU", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "QD", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "CV", - "type": "ULINT", - "qualifier": "none" - }, - { - "name": "CD_T", - "type": "R_TRIG", - "qualifier": "none" - }, - { - "name": "CU_T", - "type": "R_TRIG", - "qualifier": "none" - } - ], - "comment": "The up-down counter has two inputs CU and CD. It can be used to both count up on one input and down on the other.", - "usage": "\n (BOOL:CU, BOOL:CD, BOOL:R, BOOL:LD, ULINT:PV) => (BOOL:QU, BOOL:QD, ULINT:CV, R_TRIG:CD_T, R_TRIG:CU_T)" - } - } - ], - "TP": [ - { - "section": "Standard function blocks", - "infos": { - "name": "TP", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PT", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "ET", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "The pulse timer can be used to generate output pulses of a given time duration.", - "usage": "\n (BOOL:IN, TIME:PT) => (BOOL:Q, TIME:ET)" - } - } - ], - "TON": [ - { - "section": "Standard function blocks", - "infos": { - "name": "TON", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PT", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "ET", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "The on-delay timer can be used to delay setting an output true, for fixed period after an input becomes true.", - "usage": "\n (BOOL:IN, TIME:PT) => (BOOL:Q, TIME:ET)" - } - } - ], - "TOF": [ - { - "section": "Standard function blocks", - "infos": { - "name": "TOF", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PT", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "ET", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "The off-delay timer can be used to delay setting an output false, for fixed period after input goes false.", - "usage": "\n (BOOL:IN, TIME:PT) => (BOOL:Q, TIME:ET)" - } - } - ], - "RTC": [ - { - "section": "Additional function blocks", - "infos": { - "name": "RTC", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PDT", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "CDT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "The real time clock has many uses including time stamping, setting dates and times of day in batch reports, in alarm messages and so on.", - "usage": "\n (BOOL:IN, DT:PDT) => (BOOL:Q, DT:CDT)" - } - } - ], - "INTEGRAL": [ - { - "section": "Additional function blocks", - "infos": { - "name": "INTEGRAL", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "RUN", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "R1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "XIN", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "X0", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "CYCLE", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "XOUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "The integral function block integrates the value of input XIN over time.", - "usage": "\n (BOOL:RUN, BOOL:R1, REAL:XIN, REAL:X0, TIME:CYCLE) => (BOOL:Q, REAL:XOUT)" - } - } - ], - "DERIVATIVE": [ - { - "section": "Additional function blocks", - "infos": { - "name": "DERIVATIVE", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "RUN", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "XIN", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "CYCLE", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "XOUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "The derivative function block produces an output XOUT proportional to the rate of change of the input XIN.", - "usage": "\n (BOOL:RUN, REAL:XIN, TIME:CYCLE) => (REAL:XOUT)" - } - } - ], - "PID": [ - { - "section": "Additional function blocks", - "infos": { - "name": "PID", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "AUTO", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PV", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "SP", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "X0", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "KP", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "TR", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "TD", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "CYCLE", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "XOUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "The PID (proportional, Integral, Derivative) function block provides the classical three term controller for closed loop control.", - "usage": "\n (BOOL:AUTO, REAL:PV, REAL:SP, REAL:X0, REAL:KP, REAL:TR, REAL:TD, TIME:CYCLE) => (REAL:XOUT)" - } - } - ], - "RAMP": [ - { - "section": "Additional function blocks", - "infos": { - "name": "RAMP", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "RUN", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "X0", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "X1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "TR", - "type": "TIME", - "qualifier": "none" - }, - { - "name": "CYCLE", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "BUSY", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "XOUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "The RAMP function block is modelled on example given in the standard.", - "usage": "\n (BOOL:RUN, REAL:X0, REAL:X1, TIME:TR, TIME:CYCLE) => (BOOL:BUSY, REAL:XOUT)" - } - } - ], - "HYSTERESIS": [ - { - "section": "Additional function blocks", - "infos": { - "name": "HYSTERESIS", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "XIN1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "XIN2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "EPS", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "Q", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "The hysteresis function block provides a hysteresis boolean output driven by the difference of two floating point (REAL) inputs XIN1 and XIN2.", - "usage": "\n (REAL:XIN1, REAL:XIN2, REAL:EPS) => (BOOL:Q)" - } - } - ], - "DS18B20": [ - { - "section": "Arduino", - "infos": { - "name": "DS18B20", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "PIN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Reads temperature from one DS18B20 one-wire sensor connected to the pin specified in PIN", - "usage": "\n (SINT:PIN) => (REAL:OUT)" - } - } - ], - "DS18B20_2_OUT": [ - { - "section": "Arduino", - "infos": { - "name": "DS18B20_2_OUT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "PIN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT_0", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OUT_1", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Reads temperature from two DS18B20 one-wire sensors. Both sensors must be on the same bus connected to the pin specified in PIN", - "usage": "\n (SINT:PIN) => (REAL:OUT_0, REAL:OUT_1)" - } - } - ], - "DS18B20_3_OUT": [ - { - "section": "Arduino", - "infos": { - "name": "DS18B20_3_OUT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "PIN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT_0", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OUT_1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OUT_2", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Reads temperature from three DS18B20 one-wire sensors. All sensors must be on the same bus connected to the pin specified in PIN", - "usage": "\n (SINT:PIN) => (REAL:OUT_0, REAL:OUT_1, REAL:OUT_2)" - } - } - ], - "DS18B20_4_OUT": [ - { - "section": "Arduino", - "infos": { - "name": "DS18B20_4_OUT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "PIN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT_0", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OUT_1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OUT_2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OUT_3", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Reads temperature from four DS18B20 one-wire sensors. All sensors must be on the same bus connected to the pin specified in PIN", - "usage": "\n (SINT:PIN) => (REAL:OUT_0, REAL:OUT_1, REAL:OUT_2, REAL:OUT_3)" - } - } - ], - "DS18B20_5_OUT": [ - { - "section": "Arduino", - "infos": { - "name": "DS18B20_5_OUT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "PIN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT_0", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OUT_1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OUT_2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OUT_3", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OUT_4", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Reads temperature from five DS18B20 one-wire sensors. All sensors must be on the same bus connected to the pin specified in PIN", - "usage": "\n (SINT:PIN) => (REAL:OUT_0, REAL:OUT_1, REAL:OUT_2, REAL:OUT_3, REAL:OUT_4)" - } - } - ], - "CLOUD_ADD_BOOL": [ - { - "section": "Arduino", - "infos": { - "name": "CLOUD_ADD_BOOL", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "VAR_NAME", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "BOOL_VAR", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [], - "comment": "Add a BOOL variable to sync with the Arduino Cloud. VAR_NAME must have the same name as the variable set up in the Arduino IoT Cloud", - "usage": "\n (STRING:VAR_NAME, BOOL:BOOL_VAR) => ()" - } - } - ], - "CLOUD_ADD_DINT": [ - { - "section": "Arduino", - "infos": { - "name": "CLOUD_ADD_DINT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "VAR_NAME", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "DINT_VAR", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [], - "comment": "Add an DINT variable (Arduino int) to sync with the Arduino Cloud. VAR_NAME must have the same name as the variable set up in the Arduino IoT Cloud", - "usage": "\n (STRING:VAR_NAME, DINT:DINT_VAR) => ()" - } - } - ], - "CLOUD_ADD_REAL": [ - { - "section": "Arduino", - "infos": { - "name": "CLOUD_ADD_REAL", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "VAR_NAME", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "REAL_VAR", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [], - "comment": "Add a REAL variable (Arduino float) to sync with the Arduino Cloud. VAR_NAME must have the same name as the variable set up in the Arduino IoT Cloud", - "usage": "\n (STRING:VAR_NAME, REAL:REAL_VAR) => ()" - } - } - ], - "CLOUD_BEGIN": [ - { - "section": "Arduino", - "infos": { - "name": "CLOUD_BEGIN", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "THING_ID", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "SSID", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "PASS", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [], - "comment": "Setup and initialize Arduino Cloud communication. Must be called before adding any variables (properties).", - "usage": "\n (STRING:THING_ID, STRING:SSID, STRING:PASS) => ()" - } - } - ], - "PWM_CONTROLLER": [ - { - "section": "Arduino", - "infos": { - "name": "PWM_CONTROLLER", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CHANNEL", - "type": "SINT", - "qualifier": "none" - }, - { - "name": "FREQ", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "DUTY", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "SUCCESS", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Configures the CPU internal PWM peripheral to generate a PWM signal through hardware. If the CPU does not have a PWM peripheral, compiling this block will result in a compilation error. CHANNEL is the PWM channel number. For most Arduino boards that number is the pin number for the PWM capable pin. FREQ is the desired PWM frequency in Hz. DUTY is the PWM duty cycle (between 0 and 100).", - "usage": "\n (SINT:CHANNEL, REAL:FREQ, REAL:DUTY) => (BOOL:SUCCESS)" - } - } - ], - "ARDUINOCAN_CONF": [ - { - "section": "Arduino", - "infos": { - "name": "ARDUINOCAN_CONF", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "EN_PIN", - "type": "WORD", - "qualifier": "none" - }, - { - "name": "BR", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "DONE", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "", - "usage": "\n (WORD:EN_PIN, LINT:BR) => (BOOL:DONE)" - } - } - ], - "ARDUINOCAN_WRITE": [ - { - "section": "Arduino", - "infos": { - "name": "ARDUINOCAN_WRITE", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "ID", - "type": "DWORD", - "qualifier": "none" - }, - { - "name": "D0", - "type": "USINT", - "qualifier": "none" - }, - { - "name": "D1", - "type": "USINT", - "qualifier": "none" - }, - { - "name": "D2", - "type": "USINT", - "qualifier": "none" - }, - { - "name": "D3", - "type": "USINT", - "qualifier": "none" - }, - { - "name": "D4", - "type": "USINT", - "qualifier": "none" - }, - { - "name": "D5", - "type": "USINT", - "qualifier": "none" - }, - { - "name": "D6", - "type": "USINT", - "qualifier": "none" - }, - { - "name": "D7", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "DONE", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "", - "usage": "\n (DWORD:ID, USINT:D0, USINT:D1, USINT:D2, USINT:D3, USINT:D4, USINT:D5, USINT:D6, USINT:D7) => (BOOL:DONE)" - } - } - ], - "ARDUINOCAN_WRITE_WORD": [ - { - "section": "Arduino", - "infos": { - "name": "ARDUINOCAN_WRITE_WORD", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "ID", - "type": "DWORD", - "qualifier": "none" - }, - { - "name": "DATA", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "DONE", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "", - "usage": "\n (DWORD:ID, LWORD:DATA) => (BOOL:DONE)" - } - } - ], - "ARDUINOCAN_READ": [ - { - "section": "Arduino", - "infos": { - "name": "ARDUINOCAN_READ", - "type": "functionBlock", - "extensible": false, - "inputs": [], - "outputs": [ - { - "name": "DATA", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "CAN READ", - "usage": "\n () => (LWORD:DATA)" - } - } - ], - "STM32CAN_CONF": [ - { - "section": "Microver", - "infos": { - "name": "STM32CAN_CONF", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CONF", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "BR", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "DONE", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "", - "usage": "\n (BOOL:CONF, LINT:BR) => (BOOL:DONE)" - } - } - ], - "STM32CAN_WRITE": [ - { - "section": "Microver", - "infos": { - "name": "STM32CAN_WRITE", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "EN_PIN", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "CH", - "type": "USINT", - "qualifier": "none" - }, - { - "name": "ID", - "type": "DWORD", - "qualifier": "none" - }, - { - "name": "D0", - "type": "BYTE", - "qualifier": "none" - }, - { - "name": "D1", - "type": "BYTE", - "qualifier": "none" - }, - { - "name": "D2", - "type": "BYTE", - "qualifier": "none" - }, - { - "name": "D3", - "type": "BYTE", - "qualifier": "none" - }, - { - "name": "D4", - "type": "BYTE", - "qualifier": "none" - }, - { - "name": "D5", - "type": "BYTE", - "qualifier": "none" - }, - { - "name": "D6", - "type": "BYTE", - "qualifier": "none" - }, - { - "name": "D7", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "DONE", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "", - "usage": "\n (BOOL:EN_PIN, USINT:CH, DWORD:ID, BYTE:D0, BYTE:D1, BYTE:D2, BYTE:D3, BYTE:D4, BYTE:D5, BYTE:D6, BYTE:D7) => (BOOL:DONE)" - } - } - ], - "STM32CAN_READ": [ - { - "section": "Microver", - "infos": { - "name": "STM32CAN_READ", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "EN_PIN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "DONE", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "ID", - "type": "DWORD", - "qualifier": "none" - }, - { - "name": "D0", - "type": "BYTE", - "qualifier": "none" - }, - { - "name": "D1", - "type": "BYTE", - "qualifier": "none" - }, - { - "name": "D2", - "type": "BYTE", - "qualifier": "none" - }, - { - "name": "D3", - "type": "BYTE", - "qualifier": "none" - }, - { - "name": "D4", - "type": "BYTE", - "qualifier": "none" - }, - { - "name": "D5", - "type": "BYTE", - "qualifier": "none" - }, - { - "name": "D6", - "type": "BYTE", - "qualifier": "none" - }, - { - "name": "D7", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "CAN READ", - "usage": "\n (BOOL:EN_PIN) => (BOOL:DONE, DWORD:ID, BYTE:D0, BYTE:D1, BYTE:D2, BYTE:D3, BYTE:D4, BYTE:D5, BYTE:D6, BYTE:D7)" - } - } - ], - "TCP_CONNECT": [ - { - "section": "Communication", - "infos": { - "name": "TCP_CONNECT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CONNECT", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "IP_ADDRESS", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "PORT", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "SOCKET_ID", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Connect to a remote TCP server when CONNECT is TRUE. Upon success, this block returns the connection ID on SOCKET_ID. If SOCKET_ID is less than zero, then the connection was not successfull", - "usage": "\n (BOOL:CONNECT, STRING:IP_ADDRESS, INT:PORT) => (INT:SOCKET_ID)" - } - } - ], - "TCP_SEND": [ - { - "section": "Communication", - "infos": { - "name": "TCP_SEND", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "SEND", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "SOCKET_ID", - "type": "INT", - "qualifier": "none" - }, - { - "name": "MSG", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "BYTES_SENT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Send a message to a remote device using TCP/IP when SEND is TRUE. SOCKET_ID must receive a connection ID from a successfull connection using the TCP_Connect block. BYTES_SENT returns the number of bytes sent to the remote device. If BYTES_SENT is less than zero then an error occurred while trying to send the message", - "usage": "\n (BOOL:SEND, INT:SOCKET_ID, STRING:MSG) => (INT:BYTES_SENT)" - } - } - ], - "TCP_RECEIVE": [ - { - "section": "Communication", - "infos": { - "name": "TCP_RECEIVE", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "RECEIVE", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "SOCKET_ID", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "BYTES_RECEIVED", - "type": "INT", - "qualifier": "none" - }, - { - "name": "MSG", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Send a message to a remote device using TCP/IP when SEND is TRUE. SOCKET_ID must receive a connection ID from a successfull connection using the TCP_Connect block. BYTES_RECEIVED returns the number of bytes received from the remote device. MSG is a String containing the message received", - "usage": "\n (BOOL:RECEIVE, INT:SOCKET_ID) => (INT:BYTES_RECEIVED, STRING:MSG)" - } - } - ], - "TCP_CLOSE": [ - { - "section": "Communication", - "infos": { - "name": "TCP_CLOSE", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CLOSE", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "SOCKET_ID", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "SUCCESS", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Close the TCP connection with the remote server. If SUCCESS is less than zero, then the connection was not successfully closed, or the connection does not exist anymore.", - "usage": "\n (BOOL:CLOSE, INT:SOCKET_ID) => (INT:SUCCESS)" - } - } - ], - "P1AM_INIT": [ - { - "section": "P1AM Modules", - "infos": { - "name": "P1AM_INIT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "INIT", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "SUCCESS", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Initialize P1AM Modules and return the number of initialized modules on SUCCESS. If SUCCESS is zero, an error has occurred, or there aren't any modules on the bus", - "usage": "\n (BOOL:INIT) => (SINT:SUCCESS)" - } - } - ], - "P1_16CDR": [ - { - "section": "P1AM Modules", - "infos": { - "name": "P1_16CDR", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "SLOT", - "type": "SINT", - "qualifier": "none" - }, - { - "name": "O1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O5", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O6", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O7", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O8", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "I1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I5", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I6", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I7", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I8", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Get all inputs and update all outputs from P1-16CDR module. Also works with P1-15CDD1 and P1-15CDD2", - "usage": "\n (SINT:SLOT, BOOL:O1, BOOL:O2, BOOL:O3, BOOL:O4, BOOL:O5, BOOL:O6, BOOL:O7, BOOL:O8) => (BOOL:I1, BOOL:I2, BOOL:I3, BOOL:I4, BOOL:I5, BOOL:I6, BOOL:I7, BOOL:I8)" - } - } - ], - "P1_08N": [ - { - "section": "P1AM Modules", - "infos": { - "name": "P1_08N", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "SLOT", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "I1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I5", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I6", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I7", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I8", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Get all inputs from P1-08Nxx modules. Compatible with P1-08NA, P1-08ND3, P1-08NE3 and P1-08SIM", - "usage": "\n (SINT:SLOT) => (BOOL:I1, BOOL:I2, BOOL:I3, BOOL:I4, BOOL:I5, BOOL:I6, BOOL:I7, BOOL:I8)" - } - } - ], - "P1_16N": [ - { - "section": "P1AM Modules", - "infos": { - "name": "P1_16N", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "SLOT", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "I1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I5", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I6", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I7", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I8", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I9", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I10", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I11", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I12", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I13", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I14", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I15", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I16", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Get all inputs from P1-16Nxx modules. Compatible with P1-16ND3 and P1-16NE3", - "usage": "\n (SINT:SLOT) => (BOOL:I1, BOOL:I2, BOOL:I3, BOOL:I4, BOOL:I5, BOOL:I6, BOOL:I7, BOOL:I8, BOOL:I9, BOOL:I10, BOOL:I11, BOOL:I12, BOOL:I13, BOOL:I14, BOOL:I15, BOOL:I16)" - } - } - ], - "P1_08T": [ - { - "section": "P1AM Modules", - "infos": { - "name": "P1_08T", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "SLOT", - "type": "SINT", - "qualifier": "none" - }, - { - "name": "O1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O5", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O6", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O7", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O8", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [], - "comment": "Set all outputs on P1-08Txx modules. Compatible with P1-08TA, P1-08TD1, P1-08TD2 and P1-08TRS", - "usage": "\n (SINT:SLOT, BOOL:O1, BOOL:O2, BOOL:O3, BOOL:O4, BOOL:O5, BOOL:O6, BOOL:O7, BOOL:O8) => ()" - } - } - ], - "P1_16TR": [ - { - "section": "P1AM Modules", - "infos": { - "name": "P1_16TR", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "SLOT", - "type": "SINT", - "qualifier": "none" - }, - { - "name": "O1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O5", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O6", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O7", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O8", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O9", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O10", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O11", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O12", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O13", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O14", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O15", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O16", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [], - "comment": "Set all outputs on P1-16TR modules. Also compatible with P1-15TD1 and P1-15TD2", - "usage": "\n (SINT:SLOT, BOOL:O1, BOOL:O2, BOOL:O3, BOOL:O4, BOOL:O5, BOOL:O6, BOOL:O7, BOOL:O8, BOOL:O9, BOOL:O10, BOOL:O11, BOOL:O12, BOOL:O13, BOOL:O14, BOOL:O15, BOOL:O16) => ()" - } - } - ], - "P1_04AD": [ - { - "section": "P1AM Modules", - "infos": { - "name": "P1_04AD", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "SLOT", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "I1", - "type": "UINT", - "qualifier": "none" - }, - { - "name": "I2", - "type": "UINT", - "qualifier": "none" - }, - { - "name": "I3", - "type": "UINT", - "qualifier": "none" - }, - { - "name": "I4", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Get all analog inputs from P1-04ADxx modules. Compatible with P1-04AD, P1-04ADL-1 and P1-04ADL-2", - "usage": "\n (SINT:SLOT) => (UINT:I1, UINT:I2, UINT:I3, UINT:I4)" - } - } - ], - "MQTT_RECEIVE": [ - { - "section": "MQTT", - "infos": { - "name": "MQTT_RECEIVE", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "RECEIVE", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "TOPIC", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "RECEIVED", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "MESSAGE", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Receive MQTT messages for a particular TOPIC when RECEIVE is active. You must subscribe to a topic first before you can start receiving messages for that particular topic. Once a message is received, RECEIVED output is triggered, and MESSAGE will contain the received message as a STRING.", - "usage": "\n (BOOL:RECEIVE, STRING:TOPIC) => (BOOL:RECEIVED, STRING:MESSAGE)" - } - } - ], - "MQTT_SEND": [ - { - "section": "MQTT", - "infos": { - "name": "MQTT_SEND", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "SEND", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "TOPIC", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "MESSAGE", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "SUCCESS", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Sends a MESSAGE to a particular TOPIC when SEND input is triggered. Keep in mind that SEND is not configured as a rising edge input, which means that MQTT_SEND will continuously send messages every scan cycle while SEND is TRUE. If the message was sent without errors, SUCCESS will be TRUE.", - "usage": "\n (BOOL:SEND, STRING:TOPIC, STRING:MESSAGE) => (BOOL:SUCCESS)" - } - } - ], - "MQTT_CONNECT": [ - { - "section": "MQTT", - "infos": { - "name": "MQTT_CONNECT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CONNECT", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "BROKER", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "PORT", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "SUCCESS", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Connect to a BROKER at a given PORT when CONNECT is triggered. If a successfull connection is made, SUCCESS is set to TRUE", - "usage": "\n (BOOL:CONNECT, STRING:BROKER, UINT:PORT) => (BOOL:SUCCESS)" - } - } - ], - "MQTT_CONNECT_AUTH": [ - { - "section": "MQTT", - "infos": { - "name": "MQTT_CONNECT_AUTH", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "CONNECT", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "BROKER", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "PORT", - "type": "UINT", - "qualifier": "none" - }, - { - "name": "USER", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "PASSWORD", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "SUCCESS", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Connect to an authenticated BROKER at a given PORT using the credentials from USER and PASSWORD when CONNECT is triggered. If a successfull connection is made, SUCCESS is set to TRUE", - "usage": "\n (BOOL:CONNECT, STRING:BROKER, UINT:PORT, STRING:USER, STRING:PASSWORD) => (BOOL:SUCCESS)" - } - } - ], - "MQTT_SUBSCRIBE": [ - { - "section": "MQTT", - "infos": { - "name": "MQTT_SUBSCRIBE", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "SUBSCRIBE", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "TOPIC", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "SUCCESS", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Subscribe to a given TOPIC when SUBSCRIBE input is triggered. Upon a successfull subscription, SUCCESS is set to TRUE. Keep in mind that once you subscribe to a topic, OpenPLC will start receiving messages sent to that topic and storing them in a message pool. You must use the MQTT_RECEIVE block to retrieve messages from the pool and free up space to receive more messages. The maximum pool size is currently limited to 10 messages. If you let messages accumulate in the pool you will start loosing messages once the pool is full.", - "usage": "\n (BOOL:SUBSCRIBE, STRING:TOPIC) => (BOOL:SUCCESS)" - } - } - ], - "MQTT_UNSUBSCRIBE": [ - { - "section": "MQTT", - "infos": { - "name": "MQTT_UNSUBSCRIBE", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "UNSUBSCRIBE", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "TOPIC", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "SUCCESS", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Unsubscribe to a given TOPIC when UNSUBSCRIBE input is triggered. Upon a successfull unsubscription, SUCCESS is set to TRUE. Keep in mind that once you unsubscribe to a topic, OpenPLC will stop storing messages sent to that topic in the message pool. However, messages received previously and not captured with a MQTT_RECEIVE block will remain in the pool using up pool space.", - "usage": "\n (BOOL:UNSUBSCRIBE, STRING:TOPIC) => (BOOL:SUCCESS)" - } - } - ], - "MQTT_DISCONNECT": [ - { - "section": "MQTT", - "infos": { - "name": "MQTT_DISCONNECT", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "DISCONNECT", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "SUCCESS", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Disconnects from the current broker when DISCONNECT is set to TRUE. Upon a successfull disconnection, SUCCESS is set to TRUE.", - "usage": "\n (BOOL:DISCONNECT) => (BOOL:SUCCESS)" - } - } - ], - "SM_8RELAY": [ - { - "section": "Sequent Microsystems Modules", - "infos": { - "name": "SM_8RELAY", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "STACK", - "type": "SINT", - "qualifier": "none" - }, - { - "name": "O1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O5", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O6", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O7", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O8", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [], - "comment": "Update all outputs from 8-relays card", - "usage": "\n (SINT:STACK, BOOL:O1, BOOL:O2, BOOL:O3, BOOL:O4, BOOL:O5, BOOL:O6, BOOL:O7, BOOL:O8) => ()" - } - } - ], - "SM_16RELAY": [ - { - "section": "Sequent Microsystems Modules", - "infos": { - "name": "SM_16RELAY", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "STACK", - "type": "SINT", - "qualifier": "none" - }, - { - "name": "O1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O5", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O6", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O7", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O8", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O9", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O10", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O11", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O12", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O13", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O14", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O15", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "O16", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [], - "comment": "Update all outputs from 16-relays card", - "usage": "\n (SINT:STACK, BOOL:O1, BOOL:O2, BOOL:O3, BOOL:O4, BOOL:O5, BOOL:O6, BOOL:O7, BOOL:O8, BOOL:O9, BOOL:O10, BOOL:O11, BOOL:O12, BOOL:O13, BOOL:O14, BOOL:O15, BOOL:O16) => ()" - } - } - ], - "SM_8DIN": [ - { - "section": "Sequent Microsystems Modules", - "infos": { - "name": "SM_8DIN", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "STACK", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "I1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I5", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I6", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I7", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I8", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Get all inputs from Sequent microsystems 8 HV Inputs modules", - "usage": "\n (SINT:STACK) => (BOOL:I1, BOOL:I2, BOOL:I3, BOOL:I4, BOOL:I5, BOOL:I6, BOOL:I7, BOOL:I8)" - } - } - ], - "SM_16DIN": [ - { - "section": "Sequent Microsystems Modules", - "infos": { - "name": "SM_16DIN", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "STACK", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "I1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I5", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I6", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I7", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I8", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I9", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I10", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I11", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I12", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I13", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I14", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I15", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I16", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Get all inputs from Sequent Microsystems 16 digital inputs modules.", - "usage": "\n (SINT:STACK) => (BOOL:I1, BOOL:I2, BOOL:I3, BOOL:I4, BOOL:I5, BOOL:I6, BOOL:I7, BOOL:I8, BOOL:I9, BOOL:I10, BOOL:I11, BOOL:I12, BOOL:I13, BOOL:I14, BOOL:I15, BOOL:I16)" - } - } - ], - "SM_4REL4IN": [ - { - "section": "Sequent Microsystems Modules", - "infos": { - "name": "SM_4REL4IN", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "STACK", - "type": "SINT", - "qualifier": "none" - }, - { - "name": "RELAY1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "RELAY2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "RELAY3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "RELAY4", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OPTO1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "OPTO2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "OPTO3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "OPTO4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "AC_OPTO1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "AC_OPTO2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "AC_OPTO3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "AC_OPTO4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "PWM1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "PWM2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "PWM3", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "PWM4", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "FREQ1", - "type": "UINT", - "qualifier": "none" - }, - { - "name": "FREQ2", - "type": "UINT", - "qualifier": "none" - }, - { - "name": "FREQ3", - "type": "UINT", - "qualifier": "none" - }, - { - "name": "FREQ4", - "type": "UINT", - "qualifier": "none" - }, - { - "name": "BUTTON", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Get all inputs from and set all outputs to SM_4REL4IN modules", - "usage": "\n (SINT:STACK, BOOL:RELAY1, BOOL:RELAY2, BOOL:RELAY3, BOOL:RELAY4) => (BOOL:OPTO1, BOOL:OPTO2, BOOL:OPTO3, BOOL:OPTO4, BOOL:AC_OPTO1, BOOL:AC_OPTO2, BOOL:AC_OPTO3, BOOL:AC_OPTO4, REAL:PWM1, REAL:PWM2, REAL:PWM3, REAL:PWM4, UINT:FREQ1, UINT:FREQ2, UINT:FREQ3, UINT:FREQ4, BOOL:BUTTON)" - } - } - ], - "SM_INDUSTRIAL": [ - { - "section": "Sequent Microsystems Modules", - "infos": { - "name": "SM_INDUSTRIAL", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "STACK", - "type": "SINT", - "qualifier": "none" - }, - { - "name": "LED1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "LED2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "LED3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "LED4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "Q0_10V1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "Q0_10V2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "Q0_10V3", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "Q0_10V4", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "Q4_20MA1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "Q4_20MA2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "Q4_20MA3", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "Q4_20MA4", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "QOD1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "QOD2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "QOD3", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "QOD4", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OPTO1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "OPTO2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "OPTO3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "OPTO4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "I0_10V1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "I0_10V2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "I0_10V3", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "I0_10V4", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "I4_20MA1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "I4_20MA2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "I4_20MA3", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "I4_20MA4", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OWB_T1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OWB_T2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OWB_T3", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OWB_T4", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Get all inpust and set all outputs of a Sequent Microsystems Industrial Automation card", - "usage": "\n (SINT:STACK, BOOL:LED1, BOOL:LED2, BOOL:LED3, BOOL:LED4, REAL:Q0_10V1, REAL:Q0_10V2, REAL:Q0_10V3, REAL:Q0_10V4, REAL:Q4_20MA1, REAL:Q4_20MA2, REAL:Q4_20MA3, REAL:Q4_20MA4, REAL:QOD1, REAL:QOD2, REAL:QOD3, REAL:QOD4) => (BOOL:OPTO1, BOOL:OPTO2, BOOL:OPTO3, BOOL:OPTO4, REAL:I0_10V1, REAL:I0_10V2, REAL:I0_10V3, REAL:I0_10V4, REAL:I4_20MA1, REAL:I4_20MA2, REAL:I4_20MA3, REAL:I4_20MA4, REAL:OWB_T1, REAL:OWB_T2, REAL:OWB_T3, REAL:OWB_T4)" - } - } - ], - "SM_RTD": [ - { - "section": "Sequent Microsystems Modules", - "infos": { - "name": "SM_RTD", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "STACK", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "TEMP1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "TEMP2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "TEMP3", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "TEMP4", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "TEMP5", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "TEMP6", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "TEMP7", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "TEMP8", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Get all temperature inputs from SM_RTD module as REAL values in deg Celsious", - "usage": "\n (SINT:STACK) => (REAL:TEMP1, REAL:TEMP2, REAL:TEMP3, REAL:TEMP4, REAL:TEMP5, REAL:TEMP6, REAL:TEMP7, REAL:TEMP8)" - } - } - ], - "SM_BAS": [ - { - "section": "Sequent Microsystems Modules", - "infos": { - "name": "SM_BAS", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "STACK", - "type": "SINT", - "qualifier": "none" - }, - { - "name": "TRIAC1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "TRIAC2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "TRIAC3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "TRIAC4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "LED1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "LED2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "LED3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "LED4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "IN1_T", - "type": "UINT", - "qualifier": "none" - }, - { - "name": "IN2_T", - "type": "UINT", - "qualifier": "none" - }, - { - "name": "IN3_T", - "type": "UINT", - "qualifier": "none" - }, - { - "name": "IN4_T", - "type": "UINT", - "qualifier": "none" - }, - { - "name": "IN5_T", - "type": "UINT", - "qualifier": "none" - }, - { - "name": "IN6_T", - "type": "UINT", - "qualifier": "none" - }, - { - "name": "IN7_T", - "type": "UINT", - "qualifier": "none" - }, - { - "name": "IN8_T", - "type": "UINT", - "qualifier": "none" - }, - { - "name": "Q0_10V1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "Q0_10V2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "Q0_10V3", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "Q0_10V4", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "UNIV1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "UNIV2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "UNIV3", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "UNIV4", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "UNIV5", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "UNIV6", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "UNIV7", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "UNIV8", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "DRY_C1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "DRY_C2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "DRY_C3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "DRY_C4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "DRY_C5", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "DRY_C6", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "DRY_C7", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "DRY_C8", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "OWB_T1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OWB_T2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OWB_T3", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OWB_T4", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Get all inpust and set all outputs of a Sequent Microsystems Building Automation card", - "usage": "\n (SINT:STACK, BOOL:TRIAC1, BOOL:TRIAC2, BOOL:TRIAC3, BOOL:TRIAC4, BOOL:LED1, BOOL:LED2, BOOL:LED3, BOOL:LED4, UINT:IN1_T, UINT:IN2_T, UINT:IN3_T, UINT:IN4_T, UINT:IN5_T, UINT:IN6_T, UINT:IN7_T, UINT:IN8_T, REAL:Q0_10V1, REAL:Q0_10V2, REAL:Q0_10V3, REAL:Q0_10V4) => (REAL:UNIV1, REAL:UNIV2, REAL:UNIV3, REAL:UNIV4, REAL:UNIV5, REAL:UNIV6, REAL:UNIV7, REAL:UNIV8, BOOL:DRY_C1, BOOL:DRY_C2, BOOL:DRY_C3, BOOL:DRY_C4, BOOL:DRY_C5, BOOL:DRY_C6, BOOL:DRY_C7, BOOL:DRY_C8, REAL:OWB_T1, REAL:OWB_T2, REAL:OWB_T3, REAL:OWB_T4)" - } - } - ], - "SM_HOME": [ - { - "section": "Sequent Microsystems Modules", - "infos": { - "name": "SM_HOME", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "STACK", - "type": "SINT", - "qualifier": "none" - }, - { - "name": "RELAY1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "RELAY2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "RELAY3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "RELAY4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "RELAY5", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "RELAY6", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "RELAY7", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "RELAY8", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "Q0_10V1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "Q0_10V2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "Q0_10V3", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "Q0_10V4", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "QOD1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "QOD2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "QOD3", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "QOD4", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OPTO1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "OPTO2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "OPTO3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "OPTO4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "OPTO5", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "OPTO6", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "OPTO7", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "OPTO8", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "ADC1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "ADC2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "ADC3", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "ADC4", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "ADC5", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "ADC6", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "ADC7", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "ADC8", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OWB_T1", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OWB_T2", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OWB_T3", - "type": "REAL", - "qualifier": "none" - }, - { - "name": "OWB_T4", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Get all inpust and set all outputs of a Sequent Microsystems Home Automation card", - "usage": "\n (SINT:STACK, BOOL:RELAY1, BOOL:RELAY2, BOOL:RELAY3, BOOL:RELAY4, BOOL:RELAY5, BOOL:RELAY6, BOOL:RELAY7, BOOL:RELAY8, REAL:Q0_10V1, REAL:Q0_10V2, REAL:Q0_10V3, REAL:Q0_10V4, REAL:QOD1, REAL:QOD2, REAL:QOD3, REAL:QOD4) => (BOOL:OPTO1, BOOL:OPTO2, BOOL:OPTO3, BOOL:OPTO4, BOOL:OPTO5, BOOL:OPTO6, BOOL:OPTO7, BOOL:OPTO8, REAL:ADC1, REAL:ADC2, REAL:ADC3, REAL:ADC4, REAL:ADC5, REAL:ADC6, REAL:ADC7, REAL:ADC8, REAL:OWB_T1, REAL:OWB_T2, REAL:OWB_T3, REAL:OWB_T4)" - } - } - ], - "SM_8MOSFET": [ - { - "section": "Sequent Microsystems Modules", - "infos": { - "name": "SM_8MOSFET", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "STACK", - "type": "SINT", - "qualifier": "none" - }, - { - "name": "MOS1", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "MOS2", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "MOS3", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "MOS4", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "MOS5", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "MOS6", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "MOS7", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "MOS8", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [], - "comment": "Update all outputs from 8-mosfets card", - "usage": "\n (SINT:STACK, BOOL:MOS1, BOOL:MOS2, BOOL:MOS3, BOOL:MOS4, BOOL:MOS5, BOOL:MOS6, BOOL:MOS7, BOOL:MOS8) => ()" - } - } - ], - "ADC_CONFIG": [ - { - "section": "Jaguar", - "infos": { - "name": "ADC_CONFIG", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "ADC_CH", - "type": "SINT", - "qualifier": "none" - }, - { - "name": "ADC_TYPE", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "SUCCESS", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Configures the analog channel inputs on the Jaguar board. ADC_CH must be beween 0 - 3. ADC_TYPE must be between 0 - 3, where 0 = unipolar 10V, 1 = bipolar 10V, 2 = unipolar 5V, and 3 = bipolar 5V. Upon successfull configuration of the ADC, SUCCESS is set to TRUE.", - "usage": "\n (SINT:ADC_CH, SINT:ADC_TYPE) => (BOOL:SUCCESS)" - } - } - ], - "ROTARY_SWITCH": [ - { - "section": "SL-RP4", - "infos": { - "name": "ROTARY_SWITCH", - "type": "functionBlock", - "extensible": false, - "inputs": [ - { - "name": "READ", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "ERROR", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Reads the rotary switch position on SL-RP4 when the READ input is triggered. If ERROR is TRUE then an error occurred while trying to read the rotary switch. If ERROR is FALSE, the switch position value will be available on output OUT", - "usage": "\n (BOOL:READ) => (BOOL:ERROR, INT:OUT)" - } - } - ], - "BOOL_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (SINT:OUT)" - } - } - ], - "BOOL_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (INT:OUT)" - } - } - ], - "BOOL_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (DINT:OUT)" - } - } - ], - "BOOL_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (LINT:OUT)" - } - } - ], - "BOOL_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (USINT:OUT)" - } - } - ], - "BOOL_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (UINT:OUT)" - } - } - ], - "BOOL_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (UDINT:OUT)" - } - } - ], - "BOOL_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (ULINT:OUT)" - } - } - ], - "BOOL_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (REAL:OUT)" - } - } - ], - "BOOL_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (LREAL:OUT)" - } - } - ], - "BOOL_TO_TIME": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (TIME:OUT)" - } - } - ], - "BOOL_TO_DATE": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DATE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (DATE:OUT)" - } - } - ], - "BOOL_TO_TOD": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (TOD:OUT)" - } - } - ], - "BOOL_TO_DT": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_DT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (DT:OUT)" - } - } - ], - "BOOL_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (STRING:OUT)" - } - } - ], - "BOOL_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (BYTE:OUT)" - } - } - ], - "BOOL_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (WORD:OUT)" - } - } - ], - "BOOL_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (DWORD:OUT)" - } - } - ], - "BOOL_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "BOOL_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BOOL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BOOL:IN) => (LWORD:OUT)" - } - } - ], - "SINT_TO_BOOL": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_BOOL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (BOOL:OUT)" - } - } - ], - "SINT_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (INT:OUT)" - } - } - ], - "SINT_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (DINT:OUT)" - } - } - ], - "SINT_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (LINT:OUT)" - } - } - ], - "SINT_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (USINT:OUT)" - } - } - ], - "SINT_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (UINT:OUT)" - } - } - ], - "SINT_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (UDINT:OUT)" - } - } - ], - "SINT_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (ULINT:OUT)" - } - } - ], - "SINT_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (REAL:OUT)" - } - } - ], - "SINT_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (LREAL:OUT)" - } - } - ], - "SINT_TO_TIME": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (TIME:OUT)" - } - } - ], - "SINT_TO_DATE": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DATE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (DATE:OUT)" - } - } - ], - "SINT_TO_TOD": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (TOD:OUT)" - } - } - ], - "SINT_TO_DT": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_DT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (DT:OUT)" - } - } - ], - "SINT_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (STRING:OUT)" - } - } - ], - "SINT_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (BYTE:OUT)" - } - } - ], - "SINT_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (WORD:OUT)" - } - } - ], - "SINT_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (DWORD:OUT)" - } - } - ], - "SINT_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "SINT_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "SINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (SINT:IN) => (LWORD:OUT)" - } - } - ], - "INT_TO_BOOL": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_BOOL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (BOOL:OUT)" - } - } - ], - "INT_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (SINT:OUT)" - } - } - ], - "INT_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (DINT:OUT)" - } - } - ], - "INT_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (LINT:OUT)" - } - } - ], - "INT_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (USINT:OUT)" - } - } - ], - "INT_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (UINT:OUT)" - } - } - ], - "INT_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (UDINT:OUT)" - } - } - ], - "INT_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (ULINT:OUT)" - } - } - ], - "INT_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (REAL:OUT)" - } - } - ], - "INT_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (LREAL:OUT)" - } - } - ], - "INT_TO_TIME": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (TIME:OUT)" - } - } - ], - "INT_TO_DATE": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DATE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (DATE:OUT)" - } - } - ], - "INT_TO_TOD": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (TOD:OUT)" - } - } - ], - "INT_TO_DT": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_DT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (DT:OUT)" - } - } - ], - "INT_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (STRING:OUT)" - } - } - ], - "INT_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (BYTE:OUT)" - } - } - ], - "INT_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (WORD:OUT)" - } - } - ], - "INT_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (DWORD:OUT)" - } - } - ], - "INT_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "INT_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (INT:IN) => (LWORD:OUT)" - } - } - ], - "DINT_TO_BOOL": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_BOOL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (BOOL:OUT)" - } - } - ], - "DINT_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (SINT:OUT)" - } - } - ], - "DINT_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (INT:OUT)" - } - } - ], - "DINT_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (LINT:OUT)" - } - } - ], - "DINT_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (USINT:OUT)" - } - } - ], - "DINT_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (UINT:OUT)" - } - } - ], - "DINT_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (UDINT:OUT)" - } - } - ], - "DINT_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (ULINT:OUT)" - } - } - ], - "DINT_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (REAL:OUT)" - } - } - ], - "DINT_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (LREAL:OUT)" - } - } - ], - "DINT_TO_TIME": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (TIME:OUT)" - } - } - ], - "DINT_TO_DATE": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DATE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (DATE:OUT)" - } - } - ], - "DINT_TO_TOD": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (TOD:OUT)" - } - } - ], - "DINT_TO_DT": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_DT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (DT:OUT)" - } - } - ], - "DINT_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (STRING:OUT)" - } - } - ], - "DINT_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (BYTE:OUT)" - } - } - ], - "DINT_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (WORD:OUT)" - } - } - ], - "DINT_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (DWORD:OUT)" - } - } - ], - "DINT_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "DINT_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DINT:IN) => (LWORD:OUT)" - } - } - ], - "LINT_TO_BOOL": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_BOOL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (BOOL:OUT)" - } - } - ], - "LINT_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (SINT:OUT)" - } - } - ], - "LINT_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (INT:OUT)" - } - } - ], - "LINT_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (DINT:OUT)" - } - } - ], - "LINT_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (USINT:OUT)" - } - } - ], - "LINT_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (UINT:OUT)" - } - } - ], - "LINT_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (UDINT:OUT)" - } - } - ], - "LINT_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (ULINT:OUT)" - } - } - ], - "LINT_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (REAL:OUT)" - } - } - ], - "LINT_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (LREAL:OUT)" - } - } - ], - "LINT_TO_TIME": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (TIME:OUT)" - } - } - ], - "LINT_TO_DATE": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DATE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (DATE:OUT)" - } - } - ], - "LINT_TO_TOD": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (TOD:OUT)" - } - } - ], - "LINT_TO_DT": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_DT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (DT:OUT)" - } - } - ], - "LINT_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (STRING:OUT)" - } - } - ], - "LINT_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (BYTE:OUT)" - } - } - ], - "LINT_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (WORD:OUT)" - } - } - ], - "LINT_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (DWORD:OUT)" - } - } - ], - "LINT_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "LINT_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LINT:IN) => (LWORD:OUT)" - } - } - ], - "USINT_TO_BOOL": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_BOOL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (BOOL:OUT)" - } - } - ], - "USINT_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (SINT:OUT)" - } - } - ], - "USINT_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (INT:OUT)" - } - } - ], - "USINT_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (DINT:OUT)" - } - } - ], - "USINT_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (LINT:OUT)" - } - } - ], - "USINT_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (UINT:OUT)" - } - } - ], - "USINT_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (UDINT:OUT)" - } - } - ], - "USINT_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (ULINT:OUT)" - } - } - ], - "USINT_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (REAL:OUT)" - } - } - ], - "USINT_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (LREAL:OUT)" - } - } - ], - "USINT_TO_TIME": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (TIME:OUT)" - } - } - ], - "USINT_TO_DATE": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DATE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (DATE:OUT)" - } - } - ], - "USINT_TO_TOD": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (TOD:OUT)" - } - } - ], - "USINT_TO_DT": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_DT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (DT:OUT)" - } - } - ], - "USINT_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (STRING:OUT)" - } - } - ], - "USINT_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (BYTE:OUT)" - } - } - ], - "USINT_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (WORD:OUT)" - } - } - ], - "USINT_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (DWORD:OUT)" - } - } - ], - "USINT_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (USINT:IN) => (LWORD:OUT)" - } - } - ], - "UINT_TO_BOOL": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_BOOL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (BOOL:OUT)" - } - } - ], - "UINT_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (SINT:OUT)" - } - } - ], - "UINT_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (INT:OUT)" - } - } - ], - "UINT_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (DINT:OUT)" - } - } - ], - "UINT_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (LINT:OUT)" - } - } - ], - "UINT_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (USINT:OUT)" - } - } - ], - "UINT_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (UDINT:OUT)" - } - } - ], - "UINT_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (ULINT:OUT)" - } - } - ], - "UINT_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (REAL:OUT)" - } - } - ], - "UINT_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (LREAL:OUT)" - } - } - ], - "UINT_TO_TIME": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (TIME:OUT)" - } - } - ], - "UINT_TO_DATE": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DATE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (DATE:OUT)" - } - } - ], - "UINT_TO_TOD": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (TOD:OUT)" - } - } - ], - "UINT_TO_DT": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_DT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (DT:OUT)" - } - } - ], - "UINT_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (STRING:OUT)" - } - } - ], - "UINT_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (BYTE:OUT)" - } - } - ], - "UINT_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (WORD:OUT)" - } - } - ], - "UINT_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (DWORD:OUT)" - } - } - ], - "UINT_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UINT:IN) => (LWORD:OUT)" - } - } - ], - "UDINT_TO_BOOL": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_BOOL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (BOOL:OUT)" - } - } - ], - "UDINT_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (SINT:OUT)" - } - } - ], - "UDINT_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (INT:OUT)" - } - } - ], - "UDINT_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (DINT:OUT)" - } - } - ], - "UDINT_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (LINT:OUT)" - } - } - ], - "UDINT_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (USINT:OUT)" - } - } - ], - "UDINT_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (UINT:OUT)" - } - } - ], - "UDINT_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (ULINT:OUT)" - } - } - ], - "UDINT_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (REAL:OUT)" - } - } - ], - "UDINT_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (LREAL:OUT)" - } - } - ], - "UDINT_TO_TIME": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (TIME:OUT)" - } - } - ], - "UDINT_TO_DATE": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DATE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (DATE:OUT)" - } - } - ], - "UDINT_TO_TOD": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (TOD:OUT)" - } - } - ], - "UDINT_TO_DT": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_DT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (DT:OUT)" - } - } - ], - "UDINT_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (STRING:OUT)" - } - } - ], - "UDINT_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (BYTE:OUT)" - } - } - ], - "UDINT_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (WORD:OUT)" - } - } - ], - "UDINT_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (DWORD:OUT)" - } - } - ], - "UDINT_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (UDINT:IN) => (LWORD:OUT)" - } - } - ], - "ULINT_TO_BOOL": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_BOOL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (BOOL:OUT)" - } - } - ], - "ULINT_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (SINT:OUT)" - } - } - ], - "ULINT_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (INT:OUT)" - } - } - ], - "ULINT_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (DINT:OUT)" - } - } - ], - "ULINT_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (LINT:OUT)" - } - } - ], - "ULINT_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (USINT:OUT)" - } - } - ], - "ULINT_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (UINT:OUT)" - } - } - ], - "ULINT_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (UDINT:OUT)" - } - } - ], - "ULINT_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (REAL:OUT)" - } - } - ], - "ULINT_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (LREAL:OUT)" - } - } - ], - "ULINT_TO_TIME": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (TIME:OUT)" - } - } - ], - "ULINT_TO_DATE": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DATE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (DATE:OUT)" - } - } - ], - "ULINT_TO_TOD": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (TOD:OUT)" - } - } - ], - "ULINT_TO_DT": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_DT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (DT:OUT)" - } - } - ], - "ULINT_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (STRING:OUT)" - } - } - ], - "ULINT_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (BYTE:OUT)" - } - } - ], - "ULINT_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (WORD:OUT)" - } - } - ], - "ULINT_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (DWORD:OUT)" - } - } - ], - "ULINT_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (ULINT:IN) => (LWORD:OUT)" - } - } - ], - "REAL_TO_BOOL": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_BOOL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (BOOL:OUT)" - } - } - ], - "REAL_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (SINT:OUT)" - } - } - ], - "REAL_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (INT:OUT)" - } - } - ], - "REAL_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (DINT:OUT)" - } - } - ], - "REAL_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (LINT:OUT)" - } - } - ], - "REAL_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (USINT:OUT)" - } - } - ], - "REAL_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (UINT:OUT)" - } - } - ], - "REAL_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (UDINT:OUT)" - } - } - ], - "REAL_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (ULINT:OUT)" - } - } - ], - "REAL_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (LREAL:OUT)" - } - } - ], - "REAL_TO_TIME": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (TIME:OUT)" - } - } - ], - "REAL_TO_DATE": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DATE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (DATE:OUT)" - } - } - ], - "REAL_TO_TOD": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (TOD:OUT)" - } - } - ], - "REAL_TO_DT": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_DT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (DT:OUT)" - } - } - ], - "REAL_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (STRING:OUT)" - } - } - ], - "REAL_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (BYTE:OUT)" - } - } - ], - "REAL_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (WORD:OUT)" - } - } - ], - "REAL_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (DWORD:OUT)" - } - } - ], - "REAL_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "REAL_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (REAL:IN) => (LWORD:OUT)" - } - } - ], - "LREAL_TO_BOOL": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_BOOL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (BOOL:OUT)" - } - } - ], - "LREAL_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (SINT:OUT)" - } - } - ], - "LREAL_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (INT:OUT)" - } - } - ], - "LREAL_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (DINT:OUT)" - } - } - ], - "LREAL_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (LINT:OUT)" - } - } - ], - "LREAL_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (USINT:OUT)" - } - } - ], - "LREAL_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (UINT:OUT)" - } - } - ], - "LREAL_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (UDINT:OUT)" - } - } - ], - "LREAL_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (ULINT:OUT)" - } - } - ], - "LREAL_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (REAL:OUT)" - } - } - ], - "LREAL_TO_TIME": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (TIME:OUT)" - } - } - ], - "LREAL_TO_DATE": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DATE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (DATE:OUT)" - } - } - ], - "LREAL_TO_TOD": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (TOD:OUT)" - } - } - ], - "LREAL_TO_DT": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_DT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (DT:OUT)" - } - } - ], - "LREAL_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (STRING:OUT)" - } - } - ], - "LREAL_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (BYTE:OUT)" - } - } - ], - "LREAL_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (WORD:OUT)" - } - } - ], - "LREAL_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (DWORD:OUT)" - } - } - ], - "LREAL_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "LREAL_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LREAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LREAL:IN) => (LWORD:OUT)" - } - } - ], - "TIME_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "TIME_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TIME:IN) => (SINT:OUT)" - } - } - ], - "TIME_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "TIME_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TIME:IN) => (INT:OUT)" - } - } - ], - "TIME_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "TIME_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TIME:IN) => (DINT:OUT)" - } - } - ], - "TIME_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "TIME_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TIME:IN) => (LINT:OUT)" - } - } - ], - "TIME_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "TIME_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TIME:IN) => (USINT:OUT)" - } - } - ], - "TIME_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "TIME_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TIME:IN) => (UINT:OUT)" - } - } - ], - "TIME_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "TIME_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TIME:IN) => (UDINT:OUT)" - } - } - ], - "TIME_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "TIME_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TIME:IN) => (ULINT:OUT)" - } - } - ], - "TIME_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "TIME_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TIME:IN) => (REAL:OUT)" - } - } - ], - "TIME_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "TIME_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TIME:IN) => (LREAL:OUT)" - } - } - ], - "TIME_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "TIME_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TIME:IN) => (STRING:OUT)" - } - } - ], - "TIME_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "TIME_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TIME:IN) => (BYTE:OUT)" - } - } - ], - "TIME_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "TIME_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TIME:IN) => (WORD:OUT)" - } - } - ], - "TIME_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "TIME_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TIME:IN) => (DWORD:OUT)" - } - } - ], - "TIME_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "TIME_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TIME:IN) => (LWORD:OUT)" - } - } - ], - "DATE_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DATE_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DATE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DATE:IN) => (SINT:OUT)" - } - } - ], - "DATE_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "DATE_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DATE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DATE:IN) => (INT:OUT)" - } - } - ], - "DATE_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DATE_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DATE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DATE:IN) => (DINT:OUT)" - } - } - ], - "DATE_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DATE_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DATE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DATE:IN) => (LINT:OUT)" - } - } - ], - "DATE_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DATE_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DATE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DATE:IN) => (USINT:OUT)" - } - } - ], - "DATE_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DATE_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DATE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DATE:IN) => (UINT:OUT)" - } - } - ], - "DATE_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DATE_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DATE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DATE:IN) => (UDINT:OUT)" - } - } - ], - "DATE_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DATE_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DATE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DATE:IN) => (ULINT:OUT)" - } - } - ], - "DATE_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "DATE_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DATE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DATE:IN) => (REAL:OUT)" - } - } - ], - "DATE_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "DATE_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DATE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DATE:IN) => (LREAL:OUT)" - } - } - ], - "DATE_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "DATE_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DATE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DATE:IN) => (STRING:OUT)" - } - } - ], - "DATE_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "DATE_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DATE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DATE:IN) => (BYTE:OUT)" - } - } - ], - "DATE_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "DATE_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DATE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DATE:IN) => (WORD:OUT)" - } - } - ], - "DATE_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "DATE_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DATE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DATE:IN) => (DWORD:OUT)" - } - } - ], - "DATE_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "DATE_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DATE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DATE:IN) => (LWORD:OUT)" - } - } - ], - "TOD_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "TOD_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TOD:IN) => (SINT:OUT)" - } - } - ], - "TOD_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "TOD_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TOD:IN) => (INT:OUT)" - } - } - ], - "TOD_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "TOD_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TOD:IN) => (DINT:OUT)" - } - } - ], - "TOD_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "TOD_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TOD:IN) => (LINT:OUT)" - } - } - ], - "TOD_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "TOD_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TOD:IN) => (USINT:OUT)" - } - } - ], - "TOD_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "TOD_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TOD:IN) => (UINT:OUT)" - } - } - ], - "TOD_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "TOD_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TOD:IN) => (UDINT:OUT)" - } - } - ], - "TOD_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "TOD_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TOD:IN) => (ULINT:OUT)" - } - } - ], - "TOD_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "TOD_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TOD:IN) => (REAL:OUT)" - } - } - ], - "TOD_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "TOD_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TOD:IN) => (LREAL:OUT)" - } - } - ], - "TOD_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "TOD_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TOD:IN) => (STRING:OUT)" - } - } - ], - "TOD_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "TOD_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TOD:IN) => (BYTE:OUT)" - } - } - ], - "TOD_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "TOD_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TOD:IN) => (WORD:OUT)" - } - } - ], - "TOD_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "TOD_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TOD:IN) => (DWORD:OUT)" - } - } - ], - "TOD_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "TOD_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (TOD:IN) => (LWORD:OUT)" - } - } - ], - "DT_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DT_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DT:IN) => (SINT:OUT)" - } - } - ], - "DT_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "DT_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DT:IN) => (INT:OUT)" - } - } - ], - "DT_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DT_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DT:IN) => (DINT:OUT)" - } - } - ], - "DT_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DT_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DT:IN) => (LINT:OUT)" - } - } - ], - "DT_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DT_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DT:IN) => (USINT:OUT)" - } - } - ], - "DT_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DT_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DT:IN) => (UINT:OUT)" - } - } - ], - "DT_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DT_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DT:IN) => (UDINT:OUT)" - } - } - ], - "DT_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DT_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DT:IN) => (ULINT:OUT)" - } - } - ], - "DT_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "DT_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DT:IN) => (REAL:OUT)" - } - } - ], - "DT_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "DT_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DT:IN) => (LREAL:OUT)" - } - } - ], - "DT_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "DT_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DT:IN) => (STRING:OUT)" - } - } - ], - "DT_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "DT_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DT:IN) => (BYTE:OUT)" - } - } - ], - "DT_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "DT_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DT:IN) => (WORD:OUT)" - } - } - ], - "DT_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "DT_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DT:IN) => (DWORD:OUT)" - } - } - ], - "DT_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "DT_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DT:IN) => (LWORD:OUT)" - } - } - ], - "STRING_TO_BOOL": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_BOOL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (BOOL:OUT)" - } - } - ], - "STRING_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (SINT:OUT)" - } - } - ], - "STRING_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (INT:OUT)" - } - } - ], - "STRING_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (DINT:OUT)" - } - } - ], - "STRING_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (LINT:OUT)" - } - } - ], - "STRING_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (USINT:OUT)" - } - } - ], - "STRING_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (UINT:OUT)" - } - } - ], - "STRING_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (UDINT:OUT)" - } - } - ], - "STRING_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (ULINT:OUT)" - } - } - ], - "STRING_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (REAL:OUT)" - } - } - ], - "STRING_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (LREAL:OUT)" - } - } - ], - "STRING_TO_TIME": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (TIME:OUT)" - } - } - ], - "STRING_TO_DATE": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DATE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (DATE:OUT)" - } - } - ], - "STRING_TO_TOD": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (TOD:OUT)" - } - } - ], - "STRING_TO_DT": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_DT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (DT:OUT)" - } - } - ], - "STRING_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (BYTE:OUT)" - } - } - ], - "STRING_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (WORD:OUT)" - } - } - ], - "STRING_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (DWORD:OUT)" - } - } - ], - "STRING_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "STRING_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (STRING:IN) => (LWORD:OUT)" - } - } - ], - "BYTE_TO_BOOL": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_BOOL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (BOOL:OUT)" - } - } - ], - "BYTE_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (SINT:OUT)" - } - } - ], - "BYTE_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (INT:OUT)" - } - } - ], - "BYTE_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (DINT:OUT)" - } - } - ], - "BYTE_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (LINT:OUT)" - } - } - ], - "BYTE_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (USINT:OUT)" - } - } - ], - "BYTE_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (UINT:OUT)" - } - } - ], - "BYTE_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (UDINT:OUT)" - } - } - ], - "BYTE_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (ULINT:OUT)" - } - } - ], - "BYTE_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (REAL:OUT)" - } - } - ], - "BYTE_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (LREAL:OUT)" - } - } - ], - "BYTE_TO_TIME": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (TIME:OUT)" - } - } - ], - "BYTE_TO_DATE": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DATE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (DATE:OUT)" - } - } - ], - "BYTE_TO_TOD": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (TOD:OUT)" - } - } - ], - "BYTE_TO_DT": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_DT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (DT:OUT)" - } - } - ], - "BYTE_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (STRING:OUT)" - } - } - ], - "BYTE_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (WORD:OUT)" - } - } - ], - "BYTE_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (DWORD:OUT)" - } - } - ], - "BYTE_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "BYTE_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (BYTE:IN) => (LWORD:OUT)" - } - } - ], - "WORD_TO_BOOL": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_BOOL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (BOOL:OUT)" - } - } - ], - "WORD_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (SINT:OUT)" - } - } - ], - "WORD_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (INT:OUT)" - } - } - ], - "WORD_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (DINT:OUT)" - } - } - ], - "WORD_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (LINT:OUT)" - } - } - ], - "WORD_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (USINT:OUT)" - } - } - ], - "WORD_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (UINT:OUT)" - } - } - ], - "WORD_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (UDINT:OUT)" - } - } - ], - "WORD_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (ULINT:OUT)" - } - } - ], - "WORD_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (REAL:OUT)" - } - } - ], - "WORD_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (LREAL:OUT)" - } - } - ], - "WORD_TO_TIME": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (TIME:OUT)" - } - } - ], - "WORD_TO_DATE": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DATE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (DATE:OUT)" - } - } - ], - "WORD_TO_TOD": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (TOD:OUT)" - } - } - ], - "WORD_TO_DT": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_DT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (DT:OUT)" - } - } - ], - "WORD_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (STRING:OUT)" - } - } - ], - "WORD_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (BYTE:OUT)" - } - } - ], - "WORD_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (DWORD:OUT)" - } - } - ], - "WORD_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "WORD_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (WORD:IN) => (LWORD:OUT)" - } - } - ], - "DWORD_TO_BOOL": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_BOOL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (BOOL:OUT)" - } - } - ], - "DWORD_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (SINT:OUT)" - } - } - ], - "DWORD_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (INT:OUT)" - } - } - ], - "DWORD_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (DINT:OUT)" - } - } - ], - "DWORD_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (LINT:OUT)" - } - } - ], - "DWORD_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (USINT:OUT)" - } - } - ], - "DWORD_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (UINT:OUT)" - } - } - ], - "DWORD_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (UDINT:OUT)" - } - } - ], - "DWORD_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (ULINT:OUT)" - } - } - ], - "DWORD_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (REAL:OUT)" - } - } - ], - "DWORD_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (LREAL:OUT)" - } - } - ], - "DWORD_TO_TIME": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (TIME:OUT)" - } - } - ], - "DWORD_TO_DATE": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DATE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (DATE:OUT)" - } - } - ], - "DWORD_TO_TOD": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (TOD:OUT)" - } - } - ], - "DWORD_TO_DT": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_DT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (DT:OUT)" - } - } - ], - "DWORD_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (STRING:OUT)" - } - } - ], - "DWORD_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (BYTE:OUT)" - } - } - ], - "DWORD_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (WORD:OUT)" - } - } - ], - "DWORD_TO_LWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "DWORD_TO_LWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (DWORD:IN) => (LWORD:OUT)" - } - } - ], - "LWORD_TO_BOOL": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_BOOL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (BOOL:OUT)" - } - } - ], - "LWORD_TO_SINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_SINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "SINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (SINT:OUT)" - } - } - ], - "LWORD_TO_INT": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_INT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (INT:OUT)" - } - } - ], - "LWORD_TO_DINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_DINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (DINT:OUT)" - } - } - ], - "LWORD_TO_LINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_LINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (LINT:OUT)" - } - } - ], - "LWORD_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (USINT:OUT)" - } - } - ], - "LWORD_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (UINT:OUT)" - } - } - ], - "LWORD_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (UDINT:OUT)" - } - } - ], - "LWORD_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (ULINT:OUT)" - } - } - ], - "LWORD_TO_REAL": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_REAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "REAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (REAL:OUT)" - } - } - ], - "LWORD_TO_LREAL": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_LREAL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LREAL", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (LREAL:OUT)" - } - } - ], - "LWORD_TO_TIME": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (TIME:OUT)" - } - } - ], - "LWORD_TO_DATE": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DATE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (DATE:OUT)" - } - } - ], - "LWORD_TO_TOD": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (TOD:OUT)" - } - } - ], - "LWORD_TO_DT": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_DT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (DT:OUT)" - } - } - ], - "LWORD_TO_STRING": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_STRING", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (STRING:OUT)" - } - } - ], - "LWORD_TO_BYTE": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_BYTE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (BYTE:OUT)" - } - } - ], - "LWORD_TO_WORD": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_WORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (WORD:OUT)" - } - } - ], - "LWORD_TO_DWORD": [ - { - "section": "Type conversion", - "infos": { - "name": "LWORD_TO_DWORD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Data type conversion", - "usage": "\n (LWORD:IN) => (DWORD:OUT)" - } - } - ], - "TRUNC": [ - { - "section": "Type conversion", - "infos": { - "name": "TRUNC", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_INT", - "qualifier": "none" - } - ], - "comment": "Rounding up/down", - "usage": "\n (ANY_REAL:IN) => (ANY_INT:OUT)" - } - } - ], - "BCD_TO_USINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BCD_TO_USINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "BYTE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "USINT", - "qualifier": "none" - } - ], - "comment": "Conversion from BCD", - "usage": "\n (BYTE:IN) => (USINT:OUT)" - } - } - ], - "BCD_TO_UINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BCD_TO_UINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "WORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UINT", - "qualifier": "none" - } - ], - "comment": "Conversion from BCD", - "usage": "\n (WORD:IN) => (UINT:OUT)" - } - } - ], - "BCD_TO_UDINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BCD_TO_UDINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "UDINT", - "qualifier": "none" - } - ], - "comment": "Conversion from BCD", - "usage": "\n (DWORD:IN) => (UDINT:OUT)" - } - } - ], - "BCD_TO_ULINT": [ - { - "section": "Type conversion", - "infos": { - "name": "BCD_TO_ULINT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "LWORD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ULINT", - "qualifier": "none" - } - ], - "comment": "Conversion from BCD", - "usage": "\n (LWORD:IN) => (ULINT:OUT)" - } - } - ], - "USINT_TO_BCD": [ - { - "section": "Type conversion", - "infos": { - "name": "USINT_TO_BCD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "USINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BYTE", - "qualifier": "none" - } - ], - "comment": "Conversion to BCD", - "usage": "\n (USINT:IN) => (BYTE:OUT)" - } - } - ], - "UINT_TO_BCD": [ - { - "section": "Type conversion", - "infos": { - "name": "UINT_TO_BCD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "WORD", - "qualifier": "none" - } - ], - "comment": "Conversion to BCD", - "usage": "\n (UINT:IN) => (WORD:OUT)" - } - } - ], - "UDINT_TO_BCD": [ - { - "section": "Type conversion", - "infos": { - "name": "UDINT_TO_BCD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "UDINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DWORD", - "qualifier": "none" - } - ], - "comment": "Conversion to BCD", - "usage": "\n (UDINT:IN) => (DWORD:OUT)" - } - } - ], - "ULINT_TO_BCD": [ - { - "section": "Type conversion", - "infos": { - "name": "ULINT_TO_BCD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ULINT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "LWORD", - "qualifier": "none" - } - ], - "comment": "Conversion to BCD", - "usage": "\n (ULINT:IN) => (LWORD:OUT)" - } - } - ], - "DATE_AND_TIME_TO_TIME_OF_DAY": [ - { - "section": "Type conversion", - "infos": { - "name": "DATE_AND_TIME_TO_TIME_OF_DAY", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Conversion to time-of-day", - "usage": "\n (DT:IN) => (TOD:OUT)" - } - } - ], - "DATE_AND_TIME_TO_DATE": [ - { - "section": "Type conversion", - "infos": { - "name": "DATE_AND_TIME_TO_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DATE", - "qualifier": "none" - } - ], - "comment": "Conversion to date", - "usage": "\n (DT:IN) => (DATE:OUT)" - } - } - ], - "ABS": [ - { - "section": "Numerical", - "infos": { - "name": "ABS", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY_NUM", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_NUM", - "qualifier": "none" - } - ], - "comment": "Absolute number", - "usage": "\n (ANY_NUM:IN) => (ANY_NUM:OUT)" - } - } - ], - "SQRT": [ - { - "section": "Numerical", - "infos": { - "name": "SQRT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "comment": "Square root (base 2)", - "usage": "\n (ANY_REAL:IN) => (ANY_REAL:OUT)" - } - } - ], - "LN": [ - { - "section": "Numerical", - "infos": { - "name": "LN", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "comment": "Natural logarithm", - "usage": "\n (ANY_REAL:IN) => (ANY_REAL:OUT)" - } - } - ], - "LOG": [ - { - "section": "Numerical", - "infos": { - "name": "LOG", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "comment": "Logarithm to base 10", - "usage": "\n (ANY_REAL:IN) => (ANY_REAL:OUT)" - } - } - ], - "EXP": [ - { - "section": "Numerical", - "infos": { - "name": "EXP", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "comment": "Exponentiation", - "usage": "\n (ANY_REAL:IN) => (ANY_REAL:OUT)" - } - } - ], - "SIN": [ - { - "section": "Numerical", - "infos": { - "name": "SIN", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "comment": "Sine", - "usage": "\n (ANY_REAL:IN) => (ANY_REAL:OUT)" - } - } - ], - "COS": [ - { - "section": "Numerical", - "infos": { - "name": "COS", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "comment": "Cosine", - "usage": "\n (ANY_REAL:IN) => (ANY_REAL:OUT)" - } - } - ], - "TAN": [ - { - "section": "Numerical", - "infos": { - "name": "TAN", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "comment": "Tangent", - "usage": "\n (ANY_REAL:IN) => (ANY_REAL:OUT)" - } - } - ], - "ASIN": [ - { - "section": "Numerical", - "infos": { - "name": "ASIN", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "comment": "Arc sine", - "usage": "\n (ANY_REAL:IN) => (ANY_REAL:OUT)" - } - } - ], - "ACOS": [ - { - "section": "Numerical", - "infos": { - "name": "ACOS", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "comment": "Arc cosine", - "usage": "\n (ANY_REAL:IN) => (ANY_REAL:OUT)" - } - } - ], - "ATAN": [ - { - "section": "Numerical", - "infos": { - "name": "ATAN", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "comment": "Arc tangent", - "usage": "\n (ANY_REAL:IN) => (ANY_REAL:OUT)" - } - } - ], - "ADD": [ - { - "section": "Arithmetic", - "infos": { - "name": "ADD", - "type": "function", - "extensible": true, - "inputs": [ - { - "name": "IN1", - "type": "ANY_NUM", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY_NUM", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_NUM", - "qualifier": "none" - } - ], - "comment": "Addition", - "usage": "\n (ANY_NUM:IN1, ANY_NUM:IN2) => (ANY_NUM:OUT)" - } - }, - { - "section": "Time", - "infos": { - "name": "ADD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "TIME", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Time addition", - "usage": "\n (TIME:IN1, TIME:IN2) => (TIME:OUT)" - } - }, - { - "section": "Time", - "infos": { - "name": "ADD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "TOD", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Time-of-day addition", - "usage": "\n (TOD:IN1, TIME:IN2) => (TOD:OUT)" - } - }, - { - "section": "Time", - "infos": { - "name": "ADD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "DT", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Date addition", - "usage": "\n (DT:IN1, TIME:IN2) => (DT:OUT)" - } - } - ], - "MUL": [ - { - "section": "Arithmetic", - "infos": { - "name": "MUL", - "type": "function", - "extensible": true, - "inputs": [ - { - "name": "IN1", - "type": "ANY_NUM", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY_NUM", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_NUM", - "qualifier": "none" - } - ], - "comment": "Multiplication", - "usage": "\n (ANY_NUM:IN1, ANY_NUM:IN2) => (ANY_NUM:OUT)" - } - }, - { - "section": "Time", - "infos": { - "name": "MUL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "TIME", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY_NUM", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Time multiplication", - "usage": "\n (TIME:IN1, ANY_NUM:IN2) => (TIME:OUT)" - } - } - ], - "SUB": [ - { - "section": "Arithmetic", - "infos": { - "name": "SUB", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "ANY_NUM", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY_NUM", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_NUM", - "qualifier": "none" - } - ], - "comment": "Subtraction", - "usage": "\n (ANY_NUM:IN1, ANY_NUM:IN2) => (ANY_NUM:OUT)" - } - }, - { - "section": "Time", - "infos": { - "name": "SUB", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "TIME", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Time subtraction", - "usage": "\n (TIME:IN1, TIME:IN2) => (TIME:OUT)" - } - }, - { - "section": "Time", - "infos": { - "name": "SUB", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "DATE", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "DATE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Date subtraction", - "usage": "\n (DATE:IN1, DATE:IN2) => (TIME:OUT)" - } - }, - { - "section": "Time", - "infos": { - "name": "SUB", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "TOD", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Time-of-day subtraction", - "usage": "\n (TOD:IN1, TIME:IN2) => (TOD:OUT)" - } - }, - { - "section": "Time", - "infos": { - "name": "SUB", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "TOD", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Time-of-day subtraction", - "usage": "\n (TOD:IN1, TOD:IN2) => (TIME:OUT)" - } - }, - { - "section": "Time", - "infos": { - "name": "SUB", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "DT", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Date and time subtraction", - "usage": "\n (DT:IN1, TIME:IN2) => (DT:OUT)" - } - }, - { - "section": "Time", - "infos": { - "name": "SUB", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "DT", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Date and time subtraction", - "usage": "\n (DT:IN1, DT:IN2) => (TIME:OUT)" - } - } - ], - "DIV": [ - { - "section": "Arithmetic", - "infos": { - "name": "DIV", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "ANY_NUM", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY_NUM", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_NUM", - "qualifier": "none" - } - ], - "comment": "Division", - "usage": "\n (ANY_NUM:IN1, ANY_NUM:IN2) => (ANY_NUM:OUT)" - } - }, - { - "section": "Time", - "infos": { - "name": "DIV", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "TIME", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY_NUM", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Time division", - "usage": "\n (TIME:IN1, ANY_NUM:IN2) => (TIME:OUT)" - } - } - ], - "MOD": [ - { - "section": "Arithmetic", - "infos": { - "name": "MOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "ANY_INT", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY_INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_INT", - "qualifier": "none" - } - ], - "comment": "Remainder (modulo)", - "usage": "\n (ANY_INT:IN1, ANY_INT:IN2) => (ANY_INT:OUT)" - } - } - ], - "EXPT": [ - { - "section": "Arithmetic", - "infos": { - "name": "EXPT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "ANY_REAL", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY_NUM", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_REAL", - "qualifier": "none" - } - ], - "comment": "Exponent", - "usage": "\n (ANY_REAL:IN1, ANY_NUM:IN2) => (ANY_REAL:OUT)" - } - } - ], - "MOVE": [ - { - "section": "Arithmetic", - "infos": { - "name": "MOVE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY", - "qualifier": "none" - } - ], - "comment": "Assignment", - "usage": "\n (ANY:IN) => (ANY:OUT)" - } - } - ], - "ADD_TIME": [ - { - "section": "Time", - "infos": { - "name": "ADD_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "TIME", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Time addition", - "usage": "\n (TIME:IN1, TIME:IN2) => (TIME:OUT)" - } - } - ], - "ADD_TOD_TIME": [ - { - "section": "Time", - "infos": { - "name": "ADD_TOD_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "TOD", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Time-of-day addition", - "usage": "\n (TOD:IN1, TIME:IN2) => (TOD:OUT)" - } - } - ], - "ADD_DT_TIME": [ - { - "section": "Time", - "infos": { - "name": "ADD_DT_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "DT", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Date addition", - "usage": "\n (DT:IN1, TIME:IN2) => (DT:OUT)" - } - } - ], - "MULTIME": [ - { - "section": "Time", - "infos": { - "name": "MULTIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "TIME", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY_NUM", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Time multiplication", - "usage": "\n (TIME:IN1, ANY_NUM:IN2) => (TIME:OUT)" - } - } - ], - "SUB_TIME": [ - { - "section": "Time", - "infos": { - "name": "SUB_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "TIME", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Time subtraction", - "usage": "\n (TIME:IN1, TIME:IN2) => (TIME:OUT)" - } - } - ], - "SUB_DATE_DATE": [ - { - "section": "Time", - "infos": { - "name": "SUB_DATE_DATE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "DATE", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "DATE", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Date subtraction", - "usage": "\n (DATE:IN1, DATE:IN2) => (TIME:OUT)" - } - } - ], - "SUB_TOD_TIME": [ - { - "section": "Time", - "infos": { - "name": "SUB_TOD_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "TOD", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TOD", - "qualifier": "none" - } - ], - "comment": "Time-of-day subtraction", - "usage": "\n (TOD:IN1, TIME:IN2) => (TOD:OUT)" - } - } - ], - "SUB_TOD_TOD": [ - { - "section": "Time", - "infos": { - "name": "SUB_TOD_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "TOD", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Time-of-day subtraction", - "usage": "\n (TOD:IN1, TOD:IN2) => (TIME:OUT)" - } - } - ], - "SUB_DT_TIME": [ - { - "section": "Time", - "infos": { - "name": "SUB_DT_TIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "DT", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "TIME", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Date and time subtraction", - "usage": "\n (DT:IN1, TIME:IN2) => (DT:OUT)" - } - } - ], - "SUB_DT_DT": [ - { - "section": "Time", - "infos": { - "name": "SUB_DT_DT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "DT", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "DT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Date and time subtraction", - "usage": "\n (DT:IN1, DT:IN2) => (TIME:OUT)" - } - } - ], - "DIVTIME": [ - { - "section": "Time", - "infos": { - "name": "DIVTIME", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "TIME", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY_NUM", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "TIME", - "qualifier": "none" - } - ], - "comment": "Time division", - "usage": "\n (TIME:IN1, ANY_NUM:IN2) => (TIME:OUT)" - } - } - ], - "SHL": [ - { - "section": "Bit-shift", - "infos": { - "name": "SHL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY_BIT", - "qualifier": "none" - }, - { - "name": "N", - "type": "ANY_INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_BIT", - "qualifier": "none" - } - ], - "comment": "Shift left", - "usage": "\n (ANY_BIT:IN, ANY_INT:N) => (ANY_BIT:OUT)" - } - } - ], - "SHR": [ - { - "section": "Bit-shift", - "infos": { - "name": "SHR", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY_BIT", - "qualifier": "none" - }, - { - "name": "N", - "type": "ANY_INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_BIT", - "qualifier": "none" - } - ], - "comment": "Shift right", - "usage": "\n (ANY_BIT:IN, ANY_INT:N) => (ANY_BIT:OUT)" - } - } - ], - "ROR": [ - { - "section": "Bit-shift", - "infos": { - "name": "ROR", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY_NBIT", - "qualifier": "none" - }, - { - "name": "N", - "type": "ANY_INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_NBIT", - "qualifier": "none" - } - ], - "comment": "Rotate right", - "usage": "\n (ANY_NBIT:IN, ANY_INT:N) => (ANY_NBIT:OUT)" - } - } - ], - "ROL": [ - { - "section": "Bit-shift", - "infos": { - "name": "ROL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY_NBIT", - "qualifier": "none" - }, - { - "name": "N", - "type": "ANY_INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_NBIT", - "qualifier": "none" - } - ], - "comment": "Rotate left", - "usage": "\n (ANY_NBIT:IN, ANY_INT:N) => (ANY_NBIT:OUT)" - } - } - ], - "AND": [ - { - "section": "Bitwise", - "infos": { - "name": "AND", - "type": "function", - "extensible": true, - "inputs": [ - { - "name": "IN1", - "type": "ANY_BIT", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY_BIT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_BIT", - "qualifier": "none" - } - ], - "comment": "Bitwise AND", - "usage": "\n (ANY_BIT:IN1, ANY_BIT:IN2) => (ANY_BIT:OUT)" - } - } - ], - "OR": [ - { - "section": "Bitwise", - "infos": { - "name": "OR", - "type": "function", - "extensible": true, - "inputs": [ - { - "name": "IN1", - "type": "ANY_BIT", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY_BIT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_BIT", - "qualifier": "none" - } - ], - "comment": "Bitwise OR", - "usage": "\n (ANY_BIT:IN1, ANY_BIT:IN2) => (ANY_BIT:OUT)" - } - } - ], - "XOR": [ - { - "section": "Bitwise", - "infos": { - "name": "XOR", - "type": "function", - "extensible": true, - "inputs": [ - { - "name": "IN1", - "type": "ANY_BIT", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY_BIT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_BIT", - "qualifier": "none" - } - ], - "comment": "Bitwise XOR", - "usage": "\n (ANY_BIT:IN1, ANY_BIT:IN2) => (ANY_BIT:OUT)" - } - } - ], - "NOT": [ - { - "section": "Bitwise", - "infos": { - "name": "NOT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "ANY_BIT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY_BIT", - "qualifier": "none" - } - ], - "comment": "Bitwise inverting", - "usage": "\n (ANY_BIT:IN) => (ANY_BIT:OUT)" - } - } - ], - "SEL": [ - { - "section": "Selection", - "infos": { - "name": "SEL", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "G", - "type": "BOOL", - "qualifier": "none" - }, - { - "name": "IN0", - "type": "ANY", - "qualifier": "none" - }, - { - "name": "IN1", - "type": "ANY", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY", - "qualifier": "none" - } - ], - "comment": "Binary selection (1 of 2)", - "usage": "\n (BOOL:G, ANY:IN0, ANY:IN1) => (ANY:OUT)" - } - } - ], - "MAX": [ - { - "section": "Selection", - "infos": { - "name": "MAX", - "type": "function", - "extensible": true, - "inputs": [ - { - "name": "IN1", - "type": "ANY", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY", - "qualifier": "none" - } - ], - "comment": "Maximum", - "usage": "\n (ANY:IN1, ANY:IN2) => (ANY:OUT)" - } - } - ], - "MIN": [ - { - "section": "Selection", - "infos": { - "name": "MIN", - "type": "function", - "extensible": true, - "inputs": [ - { - "name": "IN1", - "type": "ANY", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY", - "qualifier": "none" - } - ], - "comment": "Minimum", - "usage": "\n (ANY:IN1, ANY:IN2) => (ANY:OUT)" - } - } - ], - "LIMIT": [ - { - "section": "Selection", - "infos": { - "name": "LIMIT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "MN", - "type": "ANY", - "qualifier": "none" - }, - { - "name": "IN", - "type": "ANY", - "qualifier": "none" - }, - { - "name": "MX", - "type": "ANY", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY", - "qualifier": "none" - } - ], - "comment": "Limitation", - "usage": "\n (ANY:MN, ANY:IN, ANY:MX) => (ANY:OUT)" - } - } - ], - "MUX": [ - { - "section": "Selection", - "infos": { - "name": "MUX", - "type": "function", - "extensible": true, - "inputs": [ - { - "name": "K", - "type": "ANY_INT", - "qualifier": "none" - }, - { - "name": "IN0", - "type": "ANY", - "qualifier": "none" - }, - { - "name": "IN1", - "type": "ANY", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "ANY", - "qualifier": "none" - } - ], - "comment": "Multiplexer (select 1 of N)", - "usage": "\n (ANY_INT:K, ANY:IN0, ANY:IN1) => (ANY:OUT)" - } - } - ], - "GT": [ - { - "section": "Comparison", - "infos": { - "name": "GT", - "type": "function", - "extensible": true, - "inputs": [ - { - "name": "IN1", - "type": "ANY", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Greater than", - "usage": "\n (ANY:IN1, ANY:IN2) => (BOOL:OUT)" - } - } - ], - "GE": [ - { - "section": "Comparison", - "infos": { - "name": "GE", - "type": "function", - "extensible": true, - "inputs": [ - { - "name": "IN1", - "type": "ANY", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Greater than or equal to", - "usage": "\n (ANY:IN1, ANY:IN2) => (BOOL:OUT)" - } - } - ], - "EQ": [ - { - "section": "Comparison", - "infos": { - "name": "EQ", - "type": "function", - "extensible": true, - "inputs": [ - { - "name": "IN1", - "type": "ANY", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Equal to", - "usage": "\n (ANY:IN1, ANY:IN2) => (BOOL:OUT)" - } - } - ], - "LT": [ - { - "section": "Comparison", - "infos": { - "name": "LT", - "type": "function", - "extensible": true, - "inputs": [ - { - "name": "IN1", - "type": "ANY", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Less than", - "usage": "\n (ANY:IN1, ANY:IN2) => (BOOL:OUT)" - } - } - ], - "LE": [ - { - "section": "Comparison", - "infos": { - "name": "LE", - "type": "function", - "extensible": true, - "inputs": [ - { - "name": "IN1", - "type": "ANY", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Less than or equal to", - "usage": "\n (ANY:IN1, ANY:IN2) => (BOOL:OUT)" - } - } - ], - "NE": [ - { - "section": "Comparison", - "infos": { - "name": "NE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "ANY", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "ANY", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "BOOL", - "qualifier": "none" - } - ], - "comment": "Not equal to", - "usage": "\n (ANY:IN1, ANY:IN2) => (BOOL:OUT)" - } - } - ], - "LEN": [ - { - "section": "Character string", - "infos": { - "name": "LEN", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Length of string", - "usage": "\n (STRING:IN) => (INT:OUT)" - } - } - ], - "LEFT": [ - { - "section": "Character string", - "infos": { - "name": "LEFT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "L", - "type": "ANY_INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "string left of", - "usage": "\n (STRING:IN, ANY_INT:L) => (STRING:OUT)" - } - } - ], - "RIGHT": [ - { - "section": "Character string", - "infos": { - "name": "RIGHT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "L", - "type": "ANY_INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "string right of", - "usage": "\n (STRING:IN, ANY_INT:L) => (STRING:OUT)" - } - } - ], - "MID": [ - { - "section": "Character string", - "infos": { - "name": "MID", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "L", - "type": "ANY_INT", - "qualifier": "none" - }, - { - "name": "P", - "type": "ANY_INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "string from the middle", - "usage": "\n (STRING:IN, ANY_INT:L, ANY_INT:P) => (STRING:OUT)" - } - } - ], - "CONCAT": [ - { - "section": "Character string", - "infos": { - "name": "CONCAT", - "type": "function", - "extensible": true, - "inputs": [ - { - "name": "IN1", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Concatenation", - "usage": "\n (STRING:IN1, STRING:IN2) => (STRING:OUT)" - } - } - ], - "CONCAT_DATE_TOD": [ - { - "section": "Character string", - "infos": { - "name": "CONCAT_DATE_TOD", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "DATE", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "TOD", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "DT", - "qualifier": "none" - } - ], - "comment": "Time concatenation", - "usage": "\n (DATE:IN1, TOD:IN2) => (DT:OUT)" - } - } - ], - "INSERT": [ - { - "section": "Character string", - "infos": { - "name": "INSERT", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "P", - "type": "ANY_INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Insertion (into)", - "usage": "\n (STRING:IN1, STRING:IN2, ANY_INT:P) => (STRING:OUT)" - } - } - ], - "DELETE": [ - { - "section": "Character string", - "infos": { - "name": "DELETE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "L", - "type": "ANY_INT", - "qualifier": "none" - }, - { - "name": "P", - "type": "ANY_INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Deletion (within)", - "usage": "\n (STRING:IN, ANY_INT:L, ANY_INT:P) => (STRING:OUT)" - } - } - ], - "REPLACE": [ - { - "section": "Character string", - "infos": { - "name": "REPLACE", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "L", - "type": "ANY_INT", - "qualifier": "none" - }, - { - "name": "P", - "type": "ANY_INT", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "STRING", - "qualifier": "none" - } - ], - "comment": "Replacement (within)", - "usage": "\n (STRING:IN1, STRING:IN2, ANY_INT:L, ANY_INT:P) => (STRING:OUT)" - } - } - ], - "FIND": [ - { - "section": "Character string", - "infos": { - "name": "FIND", - "type": "function", - "extensible": false, - "inputs": [ - { - "name": "IN1", - "type": "STRING", - "qualifier": "none" - }, - { - "name": "IN2", - "type": "STRING", - "qualifier": "none" - } - ], - "outputs": [ - { - "name": "OUT", - "type": "INT", - "qualifier": "none" - } - ], - "comment": "Find position", - "usage": "\n (STRING:IN1, STRING:IN2) => (INT:OUT)" - } - } - ] -} diff --git a/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts b/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts index 992c0092d..642032161 100644 --- a/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts +++ b/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts @@ -10,16 +10,16 @@ */ import { PLC_BASE_TYPES } from '../helpers/base-types' -import { type BlockInfos, blockOverloads } from '../helpers/block-library' +import { type BlockInfos, blockInfosFromVariant } from '../helpers/block-library' import type { ProgramChunk } from '../helpers/program' import { computePouName } from '../helpers/text-helpers' -import { isOfType } from '../helpers/type-hierarchy' import { varTypeNames } from '../helpers/type-text' import type { TranspilePou, TranspileProject, TranspileVariable, TranspileVariableClass } from '../types' import type { TypeContext } from '../walker/connection-types' -import { emitFbdBody } from '../walker/fbd' +import { emitFbdBody, type RFFbdBody } from '../walker/fbd' import type { SyntheticVar } from '../walker/ld' import { emitLdBody } from '../walker/ld' +import type { RFBody } from '../walker/types' import { declaredTypeName, getTypeAsText } from './type-text' import { computeValue } from './value' @@ -101,6 +101,35 @@ export function generateGraphicalPou(pou: TranspilePou, project: TranspileProjec /* ────────────────────────── helpers ─────────────────────────────────────── */ +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +// Block signatures from every graphical block instance's variant — the +// co-located equivalent of xml2st's embedded payload. Deduped +// by name; user POUs are excluded (they resolve from their own interface). +function collectBlockSignatures(project: TranspileProject): Map { + const userPouNames = new Set(project.pous.map((p) => p.name)) + const registry = new Map() + const visit = (nodes: unknown): void => { + if (!Array.isArray(nodes)) return + for (const node of nodes) { + if (!isRecord(node) || node.type !== 'block' || !isRecord(node.data)) continue + const infos = blockInfosFromVariant(node.data.variant) + if (infos === null || userPouNames.has(infos.name) || registry.has(infos.name)) continue + registry.set(infos.name, infos) + } + } + for (const pou of project.pous) { + if (pou.body.language === 'ld') { + for (const rung of (pou.body.value as RFBody).rungs) visit(rung.nodes) + } else if (pou.body.language === 'fbd') { + visit((pou.body.value as RFFbdBody).rung.nodes) + } + } + return registry +} + // GetVariableType + GetBlockType ports (PLCGenerator.py:786-817, PLCControler.py:1288-1335) function buildTypeContext(pou: TranspilePou, project: TranspileProject): TypeContext { const interfaceTypes = new Map() @@ -138,38 +167,15 @@ function buildTypeContext(pou: TranspilePou, project: TranspileProject): TypeCon } } - const resolveBlock: TypeContext['resolveBlock'] = (typeName, inputs) => { - const entries = blockOverloads(typeName) - if (inputs === 'undefined') { - if (entries.length > 1) return null - if (entries.length === 1) return entries[0] - } else { - for (const entry of entries) { - const formals = entry.inputs.map((i) => i.type) - const n = Math.min(inputs.length, formals.length) - let ok = true - for (let i = 0; i < n; i++) { - if (inputs[i] !== 'ANY' && !isOfType(inputs[i], formals[i])) { - ok = false - break - } - } - if (ok) return entry - } - } - const projectInfos = projectBlockInfos(typeName) - if (projectInfos === null) return null - if (inputs === 'undefined') return projectInfos - const declared = projectInfos.inputs.map((i) => i.type) - return inputs.length === declared.length && inputs.every((t, i) => t === declared[i]) ? projectInfos : null - } + const libraryBlocks = collectBlockSignatures(project) - // member lookup uses the first overload even when ambiguous (python inputs=None branch) - const memberBlockInfos = (typeName: string): BlockInfos | null => { - const entries = blockOverloads(typeName) - if (entries.length > 0) return entries[0] - return projectBlockInfos(typeName) - } + // Library blocks (single generic signature each) resolve from the project's + // own variants; user-defined POUs resolve from their interface. Generic + // types stay verbatim — connection-type unification concretizes them. + const resolveBlock = (typeName: string): BlockInfos | null => + libraryBlocks.get(typeName) ?? projectBlockInfos(typeName) + + const memberBlockInfos = resolveBlock const variableType: TypeContext['variableType'] = (expression) => { const parts = expression.split('.') diff --git a/src/backend/shared/transpilers/st-transpiler/helpers/base-types.ts b/src/backend/shared/transpilers/st-transpiler/helpers/base-types.ts index 8f5aa670b..713712d34 100644 --- a/src/backend/shared/transpilers/st-transpiler/helpers/base-types.ts +++ b/src/backend/shared/transpilers/st-transpiler/helpers/base-types.ts @@ -7,6 +7,9 @@ * * `WSTRING` is intentionally absent — matches python's `# TODO` * comment at `definitions.py:118`. + * + * `__XWORD` is the platform-width address type carried by strucpp library + * block signatures; declarable and emitted verbatim. */ export const PLC_BASE_TYPES: ReadonlySet = new Set([ diff --git a/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts b/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts index f944d1707..fae41f247 100644 --- a/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts +++ b/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts @@ -1,22 +1,16 @@ /** - * Standard block-library resolution against the pre-built catalog. + * Block-library signatures, sourced from the project's own block variants. * - * The full python pipeline resolves block types from three sources — - * TC6 function-block library XMLs, `iec_std.csv` overloads, and - * project-local POUs. The first two are baked into - * `data/std_block_catalog.json` at build time - * (`tools/build_std_catalog.py`); the third is intentionally dropped - * here because the only caller (`emit/pou-graphical.ts`) resolves - * project POUs separately via `project.pous.find(...)`. - * - * Overload behaviour mirrors the python oracle's display mode: when - * a name has multiple catalog entries (ADD, GT, …), the result has - * all I/O collapsed to `'ANY'`. The wrap then narrows via its own - * type-resolution pass. + * openplc-web is co-located with strucpp and the user's installed libraries, + * so every placed block already carries its full typed signature in + * `node.data.variant` (the editor stamps it from the library on placement and + * `restamp-library-variants` keeps it fresh). The transpiler resolves block + * types from those variants — the same source `collect-library-blocks.ts` + * feeds the Python oracle as the embedded `` payload — instead + * of bundling a separate catalog. Unknown blocks degrade to permissive + * synthesis in `connection-types.ts`. */ -import stdCatalog from '../data/std_block_catalog.json' - export interface BlockIO { name: string type: string @@ -33,61 +27,48 @@ export interface BlockInfos { usage: string } -export interface BlockResolution { - source: 'standard' - infos: BlockInfos -} - -interface CatalogEntry { - section: string - infos: BlockInfos -} - -const CATALOG: ReadonlyMap = (() => { - const map = new Map() - for (const [name, entries] of Object.entries(stdCatalog as Record)) { - map.set(name, entries) - } - return map -})() - -/** All catalog overloads for a name, signatures intact (GetBlockType needs them). */ -export function blockOverloads(typename: string): readonly BlockInfos[] { - return (CATALOG.get(typename) ?? []).map((e) => cloneBlockInfos(e.infos)) +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) } /** - * Look the block name up in the standard catalog. Single match → - * return its infos. Multiple matches → return the first entry with - * all I/O types collapsed to `'ANY'` (the wrap re-narrows). No - * match → `null`. + * Build a block signature from a placed block's `node.data.variant`. + * + * Mirrors `collect-library-blocks.ts` / xml2st's `_pou_to_block_infos`: + * EN/ENO are implicit control pins (dropped); inOut params appear on both + * sides; a function's return is already a class-`output` variable named `OUT`. + * Generic IEC meta-types (`ANY`, `ANY_NUM`, …) are kept verbatim and resolved + * from the wired connections during type inference. */ -export function resolveBlockType(typename: string): BlockResolution | null { - const entries = CATALOG.get(typename) ?? [] - let result: BlockInfos | null = null - for (const entry of entries) { - if (result !== null) return { source: 'standard', infos: collapseToAny(result) } - result = cloneBlockInfos(entry.infos) - } - return result === null ? null : { source: 'standard', infos: result } -} +export function blockInfosFromVariant(variant: unknown): BlockInfos | null { + if (!isRecord(variant)) return null + const name = typeof variant.name === 'string' ? variant.name : null + if (name === null) return null -function cloneBlockInfos(infos: BlockInfos): BlockInfos { - return { - name: infos.name, - type: infos.type, - extensible: infos.extensible, - inputs: infos.inputs.map((i) => ({ ...i })), - outputs: infos.outputs.map((o) => ({ ...o })), - comment: infos.comment, - usage: infos.usage, + const inputs: BlockIO[] = [] + const outputs: BlockIO[] = [] + const variables = Array.isArray(variant.variables) ? variant.variables : [] + for (const v of variables) { + if (!isRecord(v)) continue + const vName = typeof v.name === 'string' ? v.name : null + if (vName === null || vName === 'EN' || vName === 'ENO') continue + const type = isRecord(v.type) && typeof v.type.value === 'string' ? v.type.value : 'ANY' + const io: BlockIO = { name: vName, type, qualifier: 'none' } + if (v.class === 'input') inputs.push(io) + else if (v.class === 'output') outputs.push(io) + else if (v.class === 'inOut' || v.class === 'inout') { + inputs.push(io) + outputs.push(io) + } } -} -function collapseToAny(infos: BlockInfos): BlockInfos { return { - ...infos, - inputs: infos.inputs.map((i) => ({ ...i, type: 'ANY' })), - outputs: infos.outputs.map((o) => ({ ...o, type: 'ANY' })), + name, + type: variant.type === 'function-block' ? 'functionBlock' : 'function', + extensible: variant.extensible === true, + inputs, + outputs, + comment: '', + usage: '', } } diff --git a/src/backend/shared/transpilers/st-transpiler/helpers/type-hierarchy.ts b/src/backend/shared/transpilers/st-transpiler/helpers/type-hierarchy.ts index 566107144..964fe5e84 100644 --- a/src/backend/shared/transpilers/st-transpiler/helpers/type-hierarchy.ts +++ b/src/backend/shared/transpilers/st-transpiler/helpers/type-hierarchy.ts @@ -57,6 +57,9 @@ export const TypeHierarchy: Readonly> = { WORD: 'ANY_NBIT', DWORD: 'ANY_NBIT', LWORD: 'ANY_NBIT', + // Platform-width address type (strucpp/CODESYS parity); strucpp resolves the + // concrete width per target. + __XWORD: 'ANY_NBIT', // WSTRING intentionally absent — matches Python's `# TODO` comment. } diff --git a/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts b/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts index ea8f832bb..53ee65ed7 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts +++ b/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts @@ -26,8 +26,8 @@ import type { RFBody, RFEdge, RFNode } from './types' export interface TypeContext { /** Declared/dot-path/literal type of an expression, or null. */ variableType(expression: string): string | null - /** GetBlockType port: 'undefined' = unambiguous-only lookup. */ - resolveBlock(typeName: string, inputs: readonly string[] | 'undefined'): BlockInfos | null + /** Block signature by type name (single generic signature), or null. */ + resolveBlock(typeName: string): BlockInfos | null } export function pinIn(nodeId: string, handle = ''): string { @@ -169,7 +169,7 @@ export function computeConnectionTypes(body: RFBody, ctx: TypeContext): Map h !== 'EN') - .map((h) => pt.types.get(pinIn(node.id, h)) ?? 'ANY') - const infos = ctx.resolveBlock(data.typeName, inputs) ?? synthesizePermissiveBlockInfos(graph, node, data) + const infos = ctx.resolveBlock(data.typeName) ?? synthesizePermissiveBlockInfos(graph, node, data) computeBlockInputTypes(graph, pt, node, data, infos) } From a9e5234b725631018de7e0323acb047d549155cc Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:56:46 -0300 Subject: [PATCH 04/52] refactor(transpiler): retire xml2st, make in-process TS transpiler sole engine The in-process JSON->ST transpiler is byte-identical to xml2st across the parity corpus, so drop the dual-run + comparison and the OPENPLC_USE_NEW_TRANSPILER flag, and stop bundling/downloading the xml2st binary. Tasks now emit in priority order to match the old XML generator's output (D1). XmlGenerator is kept for the "Export Project as XML" feature only. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 7 +- binary-versions.json | 4 - scripts/download-binaries.ts | 201 +----------------- .../editor/compiler/compiler-module.ts | 142 +------------ .../compiler/desktop-library-build-port.ts | 110 ++-------- .../compiler/editor-compiler-platform-port.ts | 117 ++-------- .../compare-transpiler-output.ts | 78 ------- src/backend/editor/utils/transpiler-mode.ts | 19 -- .../shared/compile/__tests__/pipeline.test.ts | 56 ++--- .../library/__tests__/build-pipeline.test.ts | 29 +-- .../library-build-orchestrator.test.ts | 16 +- .../st-transpiler/emit/configuration.ts | 9 +- .../shared/ports/compiler-platform-port.ts | 44 ++-- 13 files changed, 106 insertions(+), 726 deletions(-) delete mode 100644 src/backend/editor/services/transpiler-comparison/compare-transpiler-output.ts delete mode 100644 src/backend/editor/utils/transpiler-mode.ts diff --git a/CLAUDE.md b/CLAUDE.md index 78a71638d..a177f94a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,13 +205,18 @@ Flow state is stored per-POU in dedicated slices (`ladder`, `fbd`). Flows must b Orchestrated by `CompilerModule` (`src/backend/editor/compiler/compiler-module.ts`): ``` -PLCProjectData -> Preprocess POUs -> XML Generation -> xml2st -> iec2c -> C code +PLCProjectData -> Preprocess POUs -> ST transpiler (in-process) -> strucpp -> C++ code | defines.h (pins, Modbus, MD5) | Arduino CLI / openplc-compiler -> firmware ``` +Structured Text is generated in-process by the TS transpiler +(`src/backend/shared/transpilers/st-transpiler/`) on both editor and web — the +legacy `xml2st` binary path has been retired. `XmlGenerator` is kept only for +the "Export Project as XML" feature. + Platform-specific binaries in `/resources/bin/[platform]/[arch]/`. Board configs in `/resources/sources/boards/hals.json`. ### Debugging diff --git a/binary-versions.json b/binary-versions.json index 287b451cc..023fd0d81 100644 --- a/binary-versions.json +++ b/binary-versions.json @@ -1,8 +1,4 @@ { - "xml2st": { - "version": "v4.0.7", - "repository": "Autonomy-Logic/xml2st" - }, "strucpp": { "version": "v0.5.9", "repository": "Autonomy-Logic/STruCpp" diff --git a/scripts/download-binaries.ts b/scripts/download-binaries.ts index b0318c183..321641d5b 100644 --- a/scripts/download-binaries.ts +++ b/scripts/download-binaries.ts @@ -1,10 +1,10 @@ /** - * Download external tool binaries (xml2st, strucpp) from GitHub Releases. + * Download external tool binaries (strucpp) from GitHub Releases. * * Usage: - * ts-node scripts/download-binaries.ts [--platform ] [--arch ] [--force] + * ts-node scripts/download-binaries.ts [--force] * - * Defaults to the current platform/arch. Use --force to re-download even if cached. + * Use --force to re-install even if the pinned version is already present. */ import { execSync } from 'child_process' @@ -21,19 +21,9 @@ interface ToolEntry { } interface BinaryVersions { - xml2st: ToolEntry strucpp: ToolEntry } -interface CacheMetadata { - xml2st: string - platform: string - arch: string -} - -type Platform = 'darwin' | 'linux' | 'win32' -type Arch = 'x64' | 'arm64' - // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- @@ -42,76 +32,18 @@ const ROOT_DIR = path.resolve(__dirname, '..') const VERSIONS_FILE = path.join(ROOT_DIR, 'binary-versions.json') const RESOURCES_DIR = path.join(ROOT_DIR, 'resources') -function binDir(platform: Platform, arch: Arch): string { - return path.join(RESOURCES_DIR, 'bin', platform, arch) -} - -function cacheFile(platform: Platform, arch: Arch): string { - return path.join(binDir(platform, arch), '.binary-metadata.json') -} - // --------------------------------------------------------------------------- // CLI argument parsing // --------------------------------------------------------------------------- -function parseArgs(): { platform: Platform; arch: Arch; force: boolean } { - const args = process.argv.slice(2) - let platform = process.platform as string - let arch = process.arch as string - let force = false - - for (let i = 0; i < args.length; i++) { - if (args[i] === '--platform' && args[i + 1]) { - platform = args[++i] - } else if (args[i] === '--arch' && args[i + 1]) { - arch = args[++i] - } else if (args[i] === '--force') { - force = true - } - } - - if (!['darwin', 'linux', 'win32'].includes(platform)) { - console.error(`Unsupported platform: ${platform}`) - process.exit(1) - } - if (!['x64', 'arm64'].includes(arch)) { - console.error(`Unsupported architecture: ${arch}`) - process.exit(1) - } - - return { platform: platform as Platform, arch: arch as Arch, force } +function parseArgs(): { force: boolean } { + return { force: process.argv.slice(2).includes('--force') } } // --------------------------------------------------------------------------- // Cache check // --------------------------------------------------------------------------- -function getCachedMetadata(platform: Platform, arch: Arch): CacheMetadata | null { - const file = cacheFile(platform, arch) - if (!fs.existsSync(file)) return null - - try { - return JSON.parse(fs.readFileSync(file, 'utf-8')) as CacheMetadata - } catch { - return null - } -} - -function needsXml2st(versions: BinaryVersions, cached: CacheMetadata | null, platform: Platform, arch: Arch): boolean { - const dir = binDir(platform, arch) - const isWindows = platform === 'win32' - const isDarwin = platform === 'darwin' - - const xml2stPath = isDarwin - ? path.join(dir, 'xml2st', 'xml2st') - : path.join(dir, isWindows ? 'xml2st.exe' : 'xml2st') - - if (!fs.existsSync(xml2stPath)) return true - if (!cached || cached.xml2st !== versions.xml2st.version) return true - - return false -} - function needsStrucpp(versions: BinaryVersions): boolean { // strucpp is `npm install`-ed into `node_modules/`, so npm's own // `package.json` is the canonical source of truth for what's @@ -130,16 +62,6 @@ function needsStrucpp(versions: BinaryVersions): boolean { return false } -function writeCache(versions: BinaryVersions, platform: Platform, arch: Arch): void { - const data: CacheMetadata = { - xml2st: versions.xml2st.version, - platform, - arch, - } - fs.mkdirSync(path.dirname(cacheFile(platform, arch)), { recursive: true }) - fs.writeFileSync(cacheFile(platform, arch), JSON.stringify(data, null, 2) + '\n') -} - // --------------------------------------------------------------------------- // Download helpers // --------------------------------------------------------------------------- @@ -153,97 +75,12 @@ async function downloadToFile(url: string, dest: string): Promise { fs.writeFileSync(dest, new Uint8Array(arrayBuffer)) } -function extractTarGz(archive: string, destDir: string): void { - fs.mkdirSync(destDir, { recursive: true }) - execSync(`tar xzf "${archive}" -C "${destDir}"`, { stdio: 'pipe' }) -} - -function extractZip(archive: string, destDir: string): void { - fs.mkdirSync(destDir, { recursive: true }) - execSync(`tar xf "${archive}" -C "${destDir}"`, { stdio: 'pipe' }) -} - function rmrf(p: string): void { if (fs.existsSync(p)) { fs.rmSync(p, { recursive: true, force: true }) } } -function copyRecursive(src: string, dest: string): void { - fs.mkdirSync(dest, { recursive: true }) - for (const entry of fs.readdirSync(src, { withFileTypes: true })) { - const srcPath = path.join(src, entry.name) - const destPath = path.join(dest, entry.name) - if (entry.isSymbolicLink()) { - const linkTarget = fs.readlinkSync(srcPath) - if (fs.existsSync(destPath)) fs.rmSync(destPath, { force: true }) - fs.symlinkSync(linkTarget, destPath) - } else if (entry.isDirectory()) { - copyRecursive(srcPath, destPath) - } else { - fs.copyFileSync(srcPath, destPath) - } - } -} - -// --------------------------------------------------------------------------- -// xml2st download and extraction -// --------------------------------------------------------------------------- - -async function downloadXml2st( - tool: ToolEntry, - platform: Platform, - arch: Arch, - targetBinDir: string, -): Promise { - const isWindows = platform === 'win32' - const isDarwin = platform === 'darwin' - const ext = isWindows ? 'zip' : 'tar.gz' - const url = `https://github.com/${tool.repository}/releases/download/${tool.version}/xml2st-${platform}-${arch}.${ext}` - - console.log(` Downloading xml2st ${tool.version} for ${platform}-${arch}...`) - const tmpDir = fs.mkdtempSync(path.join(RESOURCES_DIR, '.tmp-xml2st-')) - - try { - const archivePath = path.join(tmpDir, `xml2st.${ext}`) - await downloadToFile(url, archivePath) - - const extractDir = path.join(tmpDir, 'extracted') - if (isWindows) { - extractZip(archivePath, extractDir) - } else { - extractTarGz(archivePath, extractDir) - } - - // Archive contains xml2st/ directory - const extractedToolDir = path.join(extractDir, 'xml2st') - - if (isDarwin) { - // macOS: xml2st is a directory with _internal/ — copy as-is - const destDir = path.join(targetBinDir, 'xml2st') - rmrf(destDir) - copyRecursive(extractedToolDir, destDir) - fs.chmodSync(path.join(destDir, 'xml2st'), 0o755) - } else { - // Linux/Windows: single executable - const exeName = isWindows ? 'xml2st.exe' : 'xml2st' - const srcFile = path.join(extractedToolDir, exeName) - const destFile = path.join(targetBinDir, exeName) - rmrf(destFile) - // Also remove any leftover directory from a previous macOS-style install - rmrf(path.join(targetBinDir, 'xml2st')) - fs.copyFileSync(srcFile, destFile) - if (!isWindows) { - fs.chmodSync(destFile, 0o755) - } - } - - console.log(` xml2st ${tool.version} installed.`) - } finally { - rmrf(tmpDir) - } -} - // --------------------------------------------------------------------------- // strucpp download and extraction // --------------------------------------------------------------------------- @@ -254,6 +91,7 @@ async function downloadStrucpp(tool: ToolEntry): Promise { const url = `https://github.com/${tool.repository}/releases/download/${tool.version}/strucpp-${version}.tgz` console.log(` Downloading strucpp ${tool.version}...`) + fs.mkdirSync(RESOURCES_DIR, { recursive: true }) const tmpDir = fs.mkdtempSync(path.join(RESOURCES_DIR, '.tmp-strucpp-')) try { @@ -289,7 +127,7 @@ async function downloadStrucpp(tool: ToolEntry): Promise { // --------------------------------------------------------------------------- async function main(): Promise { - const { platform, arch, force } = parseArgs() + const { force } = parseArgs() if (!fs.existsSync(VERSIONS_FILE)) { console.error(`binary-versions.json not found at ${VERSIONS_FILE}`) @@ -298,34 +136,15 @@ async function main(): Promise { const versions: BinaryVersions = JSON.parse(fs.readFileSync(VERSIONS_FILE, 'utf-8')) - console.log(`[download-binaries] platform=${platform} arch=${arch} force=${force}`) - - const targetBinDir = binDir(platform, arch) - fs.mkdirSync(targetBinDir, { recursive: true }) - - const cached = force ? null : getCachedMetadata(platform, arch) - const downloadXml2stNeeded = force || needsXml2st(versions, cached, platform, arch) - const downloadStrucppNeeded = force || needsStrucpp(versions) - - if (!downloadXml2stNeeded && !downloadStrucppNeeded) { - console.log(`[download-binaries] All tools up to date, skipping.`) - return - } - - if (downloadXml2stNeeded) { - await downloadXml2st(versions.xml2st, platform, arch, targetBinDir) - } else { - console.log(` xml2st ${versions.xml2st.version} already installed, skipping.`) - } + console.log(`[download-binaries] force=${force}`) - // strucpp is platform-independent — only download once regardless of platform/arch - if (downloadStrucppNeeded) { + // strucpp is platform-independent — installed into node_modules. + if (force || needsStrucpp(versions)) { await downloadStrucpp(versions.strucpp) } else { console.log(` strucpp ${versions.strucpp.version} already installed, skipping.`) } - writeCache(versions, platform, arch) console.log(`[download-binaries] Done.`) } diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index efd5dab2e..1ec28c550 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -82,10 +82,8 @@ type ProjectDataWithCppPous = PLCProjectData & { const POST_BUILD_START_TIMEOUT_MS = 5000 const POST_BUILD_START_POLL_INTERVAL_MS = 150 -import { compareTranspilerOutput } from '@root/backend/editor/services/transpiler-comparison/compare-transpiler-output' import { assertPathContained } from '@root/backend/editor/utils/path-containment' import { getRuntimeHttpsOptions } from '@root/backend/editor/utils/runtime-https-config' -import { isNewTranspilerEnabled } from '@root/backend/editor/utils/transpiler-mode' import { runCompilePipeline } from '@root/backend/shared/compile/pipeline' import { mergeStrucppRuntimeIntoSkeleton } from '@root/backend/shared/compile/steps/merge-strucpp-runtime-into-skeleton' import { readHalsFile } from '@root/backend/shared/firmware/hals-loader' @@ -169,8 +167,6 @@ class CompilerModule { arduinoCliConfigurationFilePath: string arduinoCliBaseParameters: string[] - xml2stBinaryPath: string - strucppRuntimeDir: string // Memoised arduino-cli `--show-properties=expanded` output keyed by FQBN. @@ -225,8 +221,6 @@ class CompilerModule { // INFO: We use this approach because some commands can receive additional parameters as a string array. this.arduinoCliBaseParameters = ['--config-file', this.arduinoCliConfigurationFilePath] - this.xml2stBinaryPath = this.#constructXml2stBinaryPath() - this.strucppRuntimeDir = this.#constructStrucppRuntimeDir() } @@ -353,10 +347,6 @@ class CompilerModule { return join(this.binaryDirectoryPath, 'arduino-cli') } - #constructXml2stBinaryPath(): string { - return join(this.binaryDirectoryPath, 'xml2st', CompilerModule.HOST_PLATFORM === 'darwin' ? 'xml2st' : '') - } - #constructStrucppRuntimeDir(): string { // strucpp's runtime headers (`src/runtime/include/`) live in two // places depending on whether we're running dev or a packaged app: @@ -435,14 +425,6 @@ class CompilerModule { return spawn(arduinoCliBinaryPath, args) } - #executeXml2st(args: string[]) { - let xml2stBinaryPath = this.xml2stBinaryPath - if (CompilerModule.HOST_PLATFORM === 'win32') { - xml2stBinaryPath += '.exe' - } - return spawn(xml2stBinaryPath, args) - } - // ############################################################################ // =========================== Public methods ================================= // ############################################################################ @@ -844,64 +826,6 @@ class CompilerModule { // +++++++++++++++++++++++++++ Compilation Methods +++++++++++++++++++++++++++++ - async handleGenerateXMLfromJSON(sourceTargetFolderPath: string, jsonData: PLCProjectData) { - return new Promise>((resolve, reject) => { - const { data: xmlData } = XmlGenerator(jsonData as Parameters[0], 'old-editor') - if (typeof xmlData !== 'string') { - reject(new Error('XML data is not a string')) - return - } - - const xmlCreationResult = CreateXMLFile(sourceTargetFolderPath, xmlData, 'plc') - - if (xmlCreationResult.success) { - resolve({ success: true, data: { xmlPath: sourceTargetFolderPath, xmlContent: xmlData } }) - } else { - reject(new Error('Failed to create XML file')) - } - }) - } - - async handleTranspileXMLtoST( - generatedXMLFilePath: string, - handleOutputData: (chunk: Buffer | string, logLevel?: 'info' | 'error') => void, - extraXml2stArgs: readonly string[], - ) { - return new Promise>((resolve, reject) => { - // `extraXml2stArgs` comes from the shared pipeline's - // `TranspileXmlToStArgs.xml2stArgs` — the single source of truth - // for xml2st flag semantics across editor and web. Editor passes - // them through verbatim (trusted local binary); web's adapter - // filters against its known-args allowlist before sending to the - // compile-service. Strucpp targets currently pass - // `['--keep-structs']` (native STRUCT declarations vs matiec's - // legacy struct→FB rewrite); future flags appear here as the - // pipeline opts into them. - const executeCommand = this.#executeXml2st(['--generate-st', generatedXMLFilePath, ...extraXml2stArgs]) - - let stderrData = '' - - // INFO: We use the xml2st command to transpile the XML file to ST. - executeCommand.stdout?.on('data', (data: Buffer) => { - handleOutputData(data) - }) - executeCommand.stderr?.on('data', (data: Buffer) => { - stderrData += data.toString() - }) - - executeCommand.on('close', (code) => { - if (code === 0) { - handleOutputData(`ST file generated at: ${generatedXMLFilePath.replace('plc.xml', 'program.st')}`, 'info') - resolve({ - success: true, - }) - } else { - reject(new Error(`xml2st process exited with code ${code}\n${stderrData}`)) - } - }) - }) - } - async handleCompileSTtoCpp( sourceTargetFolderPath: string, handleOutputData: (chunk: Buffer | string, logLevel?: 'info' | 'error', compileError?: StrucppCompileError) => void, @@ -2671,7 +2595,6 @@ class CompilerModule { // --- Build the editor's CompilerPlatformPort implementation --- const platformPort = createEditorCompilerPlatformPort( { - handleTranspileXMLtoST: this.handleTranspileXMLtoST.bind(this), handleCompileArduinoProgram: this.handleCompileArduinoProgram.bind(this), handleUploadProgram: this.handleUploadProgram.bind(this), handleCoreInstallation: this.handleCoreInstallation.bind(this), @@ -2883,38 +2806,10 @@ class CompilerModule { return } - // Project -> ST. Run BOTH engines on every debug compile so their - // outputs can be compared while the new in-process TS transpiler is - // validated against the legacy xml2st subprocess. The OLD engine is - // authoritative unless OPENPLC_USE_NEW_TRANSPILER flips it; the - // comparison verdict is posted either way. Only the legacy path writes - // files, so running them in parallel is safe — the authoritative ST is - // written to program.st last so downstream STruC++ reads the chosen - // output. + // Project -> ST via the in-process transpiler. The resulting program.st + // is what STruC++ reads downstream. type DebugStResult = { ok: true; programSt: string } | { ok: false; programSt?: undefined; error: string } - const runDebugLegacy = async (): Promise => { - try { - const generateXMLResult = await this.handleGenerateXMLfromJSON(sourceTargetFolderPath, projectData) - _mainProcessPort.postMessage({ - logLevel: 'info', - message: `Generated XML from JSON at: ${generateXMLResult.data?.xmlPath as string}`, - }) - const generatedXMLFilePath = join(sourceTargetFolderPath, 'plc.xml') - await this.handleTranspileXMLtoST( - generatedXMLFilePath, - (data, logLevel) => { - _mainProcessPort.postMessage({ logLevel, message: data }) - }, - ['--keep-structs'], - ) - const programSt = await readFile(join(sourceTargetFolderPath, 'program.st'), 'utf-8') - return { ok: true, programSt } - } catch (error) { - return { ok: false, error: `xml2st failed: ${getErrorMessage(error)}` } - } - } - const runDebugInProcess = (): DebugStResult => { try { const ir = fromSchemaShape(projectData as unknown as SchemaProjectData) @@ -2929,28 +2824,17 @@ class CompilerModule { } await mkdir(sourceTargetFolderPath, { recursive: true }) - const [oldStResult, newStResult] = await Promise.all([runDebugLegacy(), Promise.resolve(runDebugInProcess())]) - - const debugComparison = compareTranspilerOutput( - oldStResult.ok ? oldStResult.programSt : undefined, - newStResult.ok ? newStResult.programSt : undefined, - ) - _mainProcessPort.postMessage({ - logLevel: debugComparison.status === 'identical' ? 'info' : 'warning', - message: `Transpiler comparison: ${debugComparison.message}`, - }) - - const authoritativeSt = isNewTranspilerEnabled() ? newStResult : oldStResult - if (!authoritativeSt.ok) { + const stResult = runDebugInProcess() + if (!stResult.ok) { _mainProcessPort.postMessage({ logLevel: 'error', - message: `${authoritativeSt.error}\nStopping debug compilation process.`, + message: `${stResult.error}\nStopping debug compilation process.`, }) _mainProcessPort.close() return } const programStPath = join(sourceTargetFolderPath, 'program.st') - await writeFile(programStPath, authoritativeSt.programSt, 'utf-8') + await writeFile(programStPath, stResult.programSt, 'utf-8') _mainProcessPort.postMessage({ logLevel: 'info', message: `ST file generated at: ${programStPath}` }) try { @@ -3058,15 +2942,10 @@ class CompilerModule { * 1. Read `/library.json` from disk (the manifest * tab's surgical save has already written the live buffer * ahead of this call — Phase 5). - * 2. `prepareXmlForLibraryBuild` validates the manifest and - * emits the XML xml2st consumes. Manifest errors fail fast - * here without spawning xml2st. - * 3. Persist plc.xml under `/build/library/src/` - * and run the existing `handleTranspileXMLtoST` helper so - * this path shares the xml2st spawn / error-handling code - * the program build already uses. - * 4. Read xml2st's program.st back and hand it + - * knownPous + manifest to `libraryBuildFromTranspiledSt`, + * 2. Validate the manifest. Manifest errors fail fast here. + * 3-4. Transpile the library's POUs to ST via the in-process + * transpiler (through the desktop library build port) and hand + * the ST + knownPous + manifest to `libraryBuildFromTranspiledSt`, * which drops the synthetic stub and calls strucpp's * `compileStlib`. * 5. Write the archive (same `JSON.stringify(archive, null, 2)` @@ -3103,7 +2982,6 @@ class CompilerModule { // glue the library build needs — every stage decision lives in // the shared orchestrator from here on. const libraryPort = createDesktopLibraryBuildPort({ - transpileXmlToSt: (xmlPath, log, extraArgs) => this.handleTranspileXMLtoST(xmlPath, log, extraArgs), loadEnabledArchives: (names) => mainProcessBridge.loadEnabledArchives(names), runVerificationCompile: ({ projectPath: p, verifyProjectData: v, emit }) => this.runVerificationCompile(p, v as PLCProjectData, mainProcessBridge, (message, logLevel) => diff --git a/src/backend/editor/compiler/desktop-library-build-port.ts b/src/backend/editor/compiler/desktop-library-build-port.ts index 6e074e9d7..d1afb3504 100644 --- a/src/backend/editor/compiler/desktop-library-build-port.ts +++ b/src/backend/editor/compiler/desktop-library-build-port.ts @@ -7,10 +7,7 @@ * `runLibraryBuildPipeline` cannot perform itself: * * - MD5 hashing (Node `crypto`) - * - ST transpilation through either backend, selected via - * `isNewTranspilerEnabled()` — the in-process JSON-fed - * transpiler when on, the bundled `xml2st` subprocess (default) - * when off + * - ST transpilation via the in-process JSON-fed transpiler * - read / write / delete project files on the local disk * - resolve library-name → `.stlib` archive via the main-process bridge * - drive a verification compile through the editor's existing @@ -23,19 +20,16 @@ * shared with the web port impl. */ -import { createHash, randomUUID } from 'node:crypto' +import { createHash } from 'node:crypto' import * as fs from 'node:fs/promises' -import * as os from 'node:os' import * as path from 'node:path' import { assertPathContained } from '@root/backend/editor/utils/path-containment' -import { isNewTranspilerEnabled } from '@root/backend/editor/utils/transpiler-mode' import { fromSchemaShape, type SchemaProjectData, transpileToSt as runJsonTranspiler, } from '@root/backend/shared/transpilers/st-transpiler' -import { XmlGenerator } from '@root/backend/shared/utils/PLC/xml-generator' import type { TranspileToStArgs, TranspileToStResult } from '@root/middleware/shared/ports/compiler-platform-port' import type { LibraryBuildPort } from '@root/middleware/shared/ports/library-build-port' @@ -45,22 +39,6 @@ import type { LibraryBuildPort } from '@root/middleware/shared/ports/library-bui * surface stays narrow + unit-testable. */ export interface DesktopLibraryBuildPortDeps { - /** - * Spawn the bundled `xml2st` binary on the given input path. The - * binary writes `program.st` next to its input; the port reads it - * back from disk after the spawn resolves. Matches the existing - * `CompilerModule.handleTranspileXMLtoST` signature so the adapter - * can pass it through verbatim without a wrapper. - * - * Only invoked by the legacy transpile path (selected when - * `isNewTranspilerEnabled()` returns false). - */ - transpileXmlToSt( - xmlPath: string, - log: (chunk: Buffer | string, level?: 'info' | 'error') => void, - extraArgs: readonly string[], - ): Promise - /** * Resolve the names of project-enabled libraries to their parsed * `.stlib` archives. Bundled IEC standard set is included @@ -93,77 +71,31 @@ export function createDesktopLibraryBuildPort(deps: DesktopLibraryBuildPortDeps) return Promise.resolve(createHash('md5').update(input).digest('hex')) }, - async transpileToSt( + transpileToSt( args: TranspileToStArgs, log: (message: string, level: 'info' | 'warning' | 'error') => void, ): Promise { - if (isNewTranspilerEnabled()) { - try { - // Editor library builds receive the same schema-shape - // project data as `compileProgram` (see `transpileToSt` on - // `editor-compiler-platform-port` for the IPC-shape note). - // The double cast bridges the port's declared port-shape type - // and the actual schema-shape payload at the boundary. - const ir = fromSchemaShape(args.projectData as unknown as SchemaProjectData) - const result = runJsonTranspiler(ir) - if (result.programSt === null || result.errors.length > 0) { - const message = result.errors.join('\n') || 'transpile-from-json failed' - log(message, 'error') - return { ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] } - } - for (const warning of result.warnings) { - log(warning, 'info') - } - return { ok: true, programSt: result.programSt } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - log(`transpile-from-json failed: ${message}`, 'error') - return { ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] } - } - } - - // Legacy path: serialise the project to PLCOpen XML and spawn - // the bundled `xml2st` binary on a temp file. Lifts the - // pre-Phase-2 implementation byte-for-byte; the only delta is - // the leading `XmlGenerator` call because the shared pipeline - // no longer hands the port a pre-built XML payload. - const xmlResult = XmlGenerator(args.projectData as never, 'old-editor') - if (!xmlResult.ok || !xmlResult.data) { - log(`XML generation failed: ${xmlResult.message}`, 'error') - return { ok: false, errors: [{ message: xmlResult.message, line: 0, column: 0, severity: 'error' }] } - } - // xml2st takes a file path on stdin, so materialise the - // in-memory XML to a unique temp file before spawning. - // Lives in `os.tmpdir()` because the user-visible `plc.xml` - // is written separately by the orchestrator via - // writeBuildFile — the intermediate here exists only for the - // subprocess. - const sessionDir = path.join(os.tmpdir(), `openplc-lib-xml2st-${randomUUID()}`) try { - await fs.mkdir(sessionDir, { recursive: true }) - const xmlPath = path.join(sessionDir, 'plc.xml') - const programStPath = path.join(sessionDir, 'program.st') - await fs.writeFile(xmlPath, xmlResult.data, 'utf-8') - - await deps.transpileXmlToSt( - xmlPath, - (chunk, level) => log(typeof chunk === 'string' ? chunk : chunk.toString(), level ?? 'info'), - ['--keep-structs'], - ) - - const programSt = await fs.readFile(programStPath, 'utf-8') - return { ok: true, programSt } + // Editor library builds receive the same schema-shape + // project data as `compileProgram` (see `transpileToSt` on + // `editor-compiler-platform-port` for the IPC-shape note). + // The double cast bridges the port's declared port-shape type + // and the actual schema-shape payload at the boundary. + const ir = fromSchemaShape(args.projectData as unknown as SchemaProjectData) + const result = runJsonTranspiler(ir) + if (result.programSt === null || result.errors.length > 0) { + const message = result.errors.join('\n') || 'transpile-from-json failed' + log(message, 'error') + return Promise.resolve({ ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] }) + } + for (const warning of result.warnings) { + log(warning, 'info') + } + return Promise.resolve({ ok: true, programSt: result.programSt }) } catch (error) { const message = error instanceof Error ? error.message : String(error) - log(`xml2st failed: ${message}`, 'error') - return { ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] } - } finally { - // Best-effort cleanup. Leaks aren't fatal (os.tmpdir is - // the OS's responsibility) but tidying after ourselves - // keeps the dev disk clean. - await fs.rm(sessionDir, { recursive: true, force: true }).catch(() => { - /* swallow — the temp dir is the OS's to GC */ - }) + log(`transpile-from-json failed: ${message}`, 'error') + return Promise.resolve({ ok: false, errors: [{ message, line: 0, column: 0, severity: 'error' }] }) } }, diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index 63571c012..6cad9b304 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -16,26 +16,14 @@ * - Translates the handler's return value back into the port's * canonical result shape * - * `transpileToSt` selects between two backends at runtime via - * `isNewTranspilerEnabled()` (env: `OPENPLC_USE_NEW_TRANSPILER`): - * the in-process JSON-fed transpiler - * (`backend/shared/transpilers/st-transpiler/`) when the flag is on, - * or the bundled `xml2st` subprocess (default) — serialising the - * project IR via `XmlGenerator` and running it through - * `handleTranspileXMLtoST` on disk, the same way the editor handled - * compilation before Phase 2. + * `transpileToSt` runs the in-process JSON-fed transpiler + * (`backend/shared/transpilers/st-transpiler/`). * * This module is editor-only (lives under `backend/editor/`); the * web platform implements the same port interface separately under * `middleware/adapters/web/`. */ -import { - compareTranspilerOutput, - TRANSPILER_LABEL_NEW, - TRANSPILER_LABEL_OLD, -} from '@root/backend/editor/services/transpiler-comparison/compare-transpiler-output' -import { isNewTranspilerEnabled } from '@root/backend/editor/utils/transpiler-mode' import { deployRuntimeProgram } from '@root/backend/shared/library/deploy-runtime-program' import { probeRuntimeVersion } from '@root/backend/shared/library/probe-runtime-version' import { @@ -43,7 +31,6 @@ import { type SchemaProjectData, transpileToSt as runJsonTranspiler, } from '@root/backend/shared/transpilers/st-transpiler' -import { XmlGenerator } from '@root/backend/shared/utils/PLC/xml-generator' import type { CheckRuntimeVersionArgs, CheckRuntimeVersionResult, @@ -76,7 +63,6 @@ import type { CompilerModule } from './compiler-module' * file-watching, etc.). */ export interface EditorCompilerHandlers { - handleTranspileXMLtoST: CompilerModule['handleTranspileXMLtoST'] handleCompileArduinoProgram: CompilerModule['handleCompileArduinoProgram'] handleUploadProgram: CompilerModule['handleUploadProgram'] handleCoreInstallation: CompilerModule['handleCoreInstallation'] @@ -152,74 +138,15 @@ export interface EditorCompilerPlatformPortContext { * arrays, device context) and shims them onto the existing * handlers' filesystem-and-subprocess shape. */ -/** - * Log how the old and new transpiler outputs compare. Emits a single - * info line when they match and a warning when they differ or when either - * engine failed (which makes a content comparison impossible). - */ -function logEditorTranspilerComparison( - oldResult: TranspileToStResult, - newResult: TranspileToStResult, - log: PlatformLog, -): void { - const oldSt = oldResult.ok ? oldResult.programSt : undefined - const newSt = newResult.ok ? newResult.programSt : undefined - const comparison = compareTranspilerOutput(oldSt, newSt) - - log(`Transpiler comparison: ${comparison.message}`, comparison.status === 'identical' ? 'info' : 'warning') - - if (comparison.status === 'incomparable') { - if (typeof oldSt !== 'string' && oldResult.errors?.length) { - log(`Transpiler comparison: ${TRANSPILER_LABEL_OLD} errors: ${oldResult.errors.map((e) => e.message).join('; ')}`, 'warning') - } - if (typeof newSt !== 'string' && newResult.errors?.length) { - log(`Transpiler comparison: ${TRANSPILER_LABEL_NEW} errors: ${newResult.errors.map((e) => e.message).join('; ')}`, 'warning') - } - } -} - export function createEditorCompilerPlatformPort( handlers: EditorCompilerHandlers, context: EditorCompilerPlatformPortContext, ): CompilerPlatformPort { /** - * Legacy transpile: serialise the project to IEC 61131-3 XML, write it - * to `/plc.xml`, run the bundled `xml2st` subprocess, - * then read `program.st` back. Streams the subprocess's own output to - * `log`; folds failures into the result so the caller can run it - * alongside the in-process engine for comparison. - */ - const runLegacyTranspile = async (args: TranspileToStArgs, log: PlatformLog): Promise => { - const xmlResult = XmlGenerator(args.projectData as never, 'old-editor') - if (!xmlResult.ok || !xmlResult.data) { - return { ok: false, errors: [{ message: `XML generation failed: ${xmlResult.message}`, line: 0, column: 0, severity: 'error' }] } - } - const xmlPath = join(context.sourceTargetFolderPath, 'plc.xml') - try { - await fs.mkdir(dirname(xmlPath), { recursive: true }) - await fs.writeFile(xmlPath, xmlResult.data, 'utf-8') - await handlers.handleTranspileXMLtoST( - xmlPath, - (chunk, level) => { - const message = typeof chunk === 'string' ? chunk : chunk.toString() - log(message, level ?? 'info') - }, - ['--keep-structs'], - ) - const programStPath = join(context.sourceTargetFolderPath, 'program.st') - const programSt = await fs.readFile(programStPath, 'utf-8') - return { ok: true, programSt } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { ok: false, errors: [{ message: `xml2st failed: ${message}`, line: 0, column: 0, severity: 'error' }] } - } - } - - /** - * New transpile: project the schema-shape payload via `fromSchemaShape` - * and run the in-process JSON transpiler. Folds failures into the result - * (see `runLegacyTranspile`). The editor's IPC payload is schema-shape; - * the double cast bridges the declared-vs-runtime mismatch. + * Transpile: project the schema-shape payload via `fromSchemaShape` + * and run the in-process JSON transpiler. Folds failures into the result. + * The editor's IPC payload is schema-shape; the double cast bridges the + * declared-vs-runtime mismatch. */ const runInProcessTranspile = (args: TranspileToStArgs): TranspileToStResult => { try { @@ -248,36 +175,16 @@ export function createEditorCompilerPlatformPort( }, /** - * Transpile the project IR to Structured Text. Runs BOTH engines on - * every call so their outputs can be compared while the new in-process - * TS transpiler is validated against the legacy `xml2st` subprocess: - * - * - old: serialise to IEC 61131-3 XML via `XmlGenerator`, write it to - * `/plc.xml`, run the bundled `xml2st` binary, - * read `program.st` back. - * - new: run the in-process JSON transpiler - * (`backend/shared/transpilers/st-transpiler/`). - * - * The comparison verdict is logged on every compile. The OLD engine is - * authoritative unless `OPENPLC_USE_NEW_TRANSPILER` flips authority to - * the new one; the non-authoritative engine's failures are non-fatal and - * downgraded to comparison warnings. + * Transpile the project IR to Structured Text via the in-process JSON + * transpiler (`backend/shared/transpilers/st-transpiler/`). */ async transpileToSt(args: TranspileToStArgs, log: PlatformLog): Promise { - const useNew = isNewTranspilerEnabled() - const [oldResult, newResult] = await Promise.all([ - runLegacyTranspile(args, log), - Promise.resolve(runInProcessTranspile(args)), - ]) - - logEditorTranspilerComparison(oldResult, newResult, log) - - const authoritative = useNew ? newResult : oldResult - if (!authoritative.ok) { - const message = authoritative.errors?.map((e) => e.message).join('\n') || 'Failed to generate Structured Text' + const result = runInProcessTranspile(args) + if (!result.ok) { + const message = result.errors?.map((e) => e.message).join('\n') || 'Failed to generate Structured Text' log(message, 'error') } - return authoritative + return result }, /** diff --git a/src/backend/editor/services/transpiler-comparison/compare-transpiler-output.ts b/src/backend/editor/services/transpiler-comparison/compare-transpiler-output.ts deleted file mode 100644 index 41f236f16..000000000 --- a/src/backend/editor/services/transpiler-comparison/compare-transpiler-output.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Pure comparison of the two transpiler engines' Structured Text output. - * - * Both editor transpile paths (the shared compile pipeline via the platform - * port and the debug compile in `compiler-module`) run the legacy `xml2st` - * subprocess and the new in-process TS engine on every compile while the new - * engine is being validated. This helper turns the two ST strings into a - * single comparison verdict + human-readable summary that each call site - * logs through its own channel. - * - * Mirrors web's `middleware/adapters/web/services/st-transpiler/compare-transpiler-output.ts` - * (the only difference is the old-engine label — editor runs xml2st locally, - * web calls the remote compile service). - */ - -export const TRANSPILER_LABEL_OLD = 'old (xml2st)' -export const TRANSPILER_LABEL_NEW = 'new (in-process TS)' - -export interface TranspilerComparison { - /** - * `identical` — both engines produced byte-equal ST. - * `different` — both succeeded but the ST diverges. - * `incomparable` — at least one engine failed, so there is nothing to diff. - */ - status: 'identical' | 'different' | 'incomparable' - /** Human-readable one-line summary suitable for a log message. */ - message: string -} - -/** - * Compare the ST emitted by each engine. Pass `undefined` for an engine - * that failed to produce output — the verdict is then `incomparable`. - */ -export function compareTranspilerOutput(oldSt: string | undefined, newSt: string | undefined): TranspilerComparison { - if (typeof oldSt !== 'string' || typeof newSt !== 'string') { - return { - status: 'incomparable', - message: - `comparison skipped — ${TRANSPILER_LABEL_OLD}: ${typeof oldSt === 'string' ? 'ok' : 'failed'}, ` + - `${TRANSPILER_LABEL_NEW}: ${typeof newSt === 'string' ? 'ok' : 'failed'}`, - } - } - - if (oldSt === newSt) { - return { status: 'identical', message: `outputs identical (${oldSt.length} chars)` } - } - - return { status: 'different', message: `outputs DIFFER — ${describeStDifference(oldSt, newSt)}` } -} - -/** - * Concise summary of where two ST outputs diverge: char and line counts - * for each engine plus the first differing line shown verbatim. - */ -function describeStDifference(oldSt: string, newSt: string): string { - const oldLines = oldSt.split('\n') - const newLines = newSt.split('\n') - const lineCount = Math.max(oldLines.length, newLines.length) - - let firstDiff = -1 - for (let i = 0; i < lineCount; i++) { - if (oldLines[i] !== newLines[i]) { - firstDiff = i - break - } - } - - const parts = [ - `${TRANSPILER_LABEL_OLD} ${oldSt.length} chars / ${oldLines.length} lines`, - `${TRANSPILER_LABEL_NEW} ${newSt.length} chars / ${newLines.length} lines`, - ] - if (firstDiff >= 0) { - parts.push(`first difference at line ${firstDiff + 1}`) - parts.push(`old: ${JSON.stringify(oldLines[firstDiff] ?? '')}`) - parts.push(`new: ${JSON.stringify(newLines[firstDiff] ?? '')}`) - } - return parts.join('; ') -} diff --git a/src/backend/editor/utils/transpiler-mode.ts b/src/backend/editor/utils/transpiler-mode.ts deleted file mode 100644 index 172425990..000000000 --- a/src/backend/editor/utils/transpiler-mode.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Build-time toggle that selects between the new in-process JSON → - * Structured Text transpiler and the legacy bundled `xml2st` - * subprocess. Default is `false` (legacy xml2st) — flip with - * `OPENPLC_USE_NEW_TRANSPILER=1` to opt into the JSON-fed transpiler. - * - * Lives in `backend/editor/utils/` so every editor-side transpilation - * call site (`editor-compiler-platform-port.transpileToSt`, - * `desktop-library-build-port.transpileToSt`, - * `CompilerModule.compileForDebugger`) reads the same flag. - * - * Named without the `use` prefix on purpose: `react-hooks/rules-of-hooks` - * treats any `use*` call inside a non-component / non-hook function as - * a violation. - */ -export function isNewTranspilerEnabled(): boolean { - const v = process.env.OPENPLC_USE_NEW_TRANSPILER - return v === '1' || v === 'true' -} diff --git a/src/backend/shared/compile/__tests__/pipeline.test.ts b/src/backend/shared/compile/__tests__/pipeline.test.ts index 1c8cf9877..88a13a1d3 100644 --- a/src/backend/shared/compile/__tests__/pipeline.test.ts +++ b/src/backend/shared/compile/__tests__/pipeline.test.ts @@ -5,7 +5,7 @@ * methods. Each branch (simulator / runtime v4 / runtime v3 / * arduino-direct, with `compileOnly` variants for each) is exercised * here by mocking the port + the heavy shared dependencies - * (`runProgramBuildPipeline`, `XmlGenerator`). + * (`runProgramBuildPipeline`). * The actual content-authoring steps (defines, confs, composers) are * covered by their own unit tests; this suite focuses on the * orchestration — call ordering, branch dispatch, error propagation, @@ -21,9 +21,6 @@ import type { // Mocks for heavy shared deps. Use `jest.fn()` so individual tests // can override `.mockReturnValueOnce` / `.mockResolvedValueOnce`. -jest.mock('../../utils/PLC/xml-generator', () => ({ - XmlGenerator: jest.fn(), -})) jest.mock('../../library/program-build-pipeline', () => ({ runProgramBuildPipeline: jest.fn(), })) @@ -59,14 +56,11 @@ jest.mock('../steps/generate-confs', () => ({ })), })) -import { XmlGenerator } from '../../utils/PLC/xml-generator' import { runProgramBuildPipeline } from '../../library/program-build-pipeline' import { isStrucppCompatibleRuntime } from '../../firmware/runtime-version-gate' import { generateRuntimeConfs } from '../steps/generate-confs' import { runCompilePipeline, type RunCompilePipelineArgs, type PipelineProgressEvent } from '../pipeline' - -const mockedXmlGen = XmlGenerator as jest.MockedFunction const mockedConfs = generateRuntimeConfs as jest.MockedFunction const mockedStrucpp = runProgramBuildPipeline as jest.MockedFunction const mockedVersionGate = isStrucppCompatibleRuntime as jest.MockedFunction @@ -78,7 +72,7 @@ const mockedVersionGate = isStrucppCompatibleRuntime as jest.MockedFunction = {}): jest.Mocked { return { computeMd5: jest.fn().mockResolvedValue('a'.repeat(32)), - transpileXmlToSt: jest.fn().mockResolvedValue({ ok: true, programSt: 'PROGRAM main\nEND_PROGRAM' }), + transpileToSt: jest.fn().mockResolvedValue({ ok: true, programSt: 'PROGRAM main\nEND_PROGRAM' }), installArduinoCore: jest.fn().mockResolvedValue({ ok: true }), installArduinoLib: jest.fn().mockResolvedValue({ ok: true }), compileArduino: jest.fn().mockResolvedValue({ ok: true, binary: new Uint8Array([1, 2, 3]) }), @@ -136,8 +130,6 @@ function captureEvents() { beforeEach(() => { jest.clearAllMocks() - // Default-mock: XML generation succeeds. - mockedXmlGen.mockReturnValue({ ok: true, data: '', message: 'ok' } as never) // Default-mock: strucpp succeeds with empty file map. mockedStrucpp.mockReturnValue({ success: true, @@ -166,14 +158,11 @@ describe('runCompilePipeline — simulator path', () => { expect(result.success).toBe(true) expect(result.binary).toBeInstanceOf(Uint8Array) expect(result.uploaded).toBe(false) - expect(port.transpileXmlToSt).toHaveBeenCalledTimes(1) - // The pipeline owns xml2st flag semantics: every strucpp target - // gets `xml2stArgs: ['--keep-structs']`. Regression guard for - // the editor/web STRUCT drift bug — see compiler-platform-port.ts - // comment. Future flags get added to this array, not to a - // typed boolean on the port (the port stays format-agnostic). - expect(port.transpileXmlToSt).toHaveBeenCalledWith( - expect.objectContaining({ xml2stArgs: ['--keep-structs'] }), + expect(port.transpileToSt).toHaveBeenCalledTimes(1) + // The pipeline hands the in-process transpiler the project IR plus + // a log callback — no XML / xml2st flags flow through anymore. + expect(port.transpileToSt).toHaveBeenCalledWith( + expect.objectContaining({ projectData: expect.anything() }), expect.any(Function), ) expect(port.compileArduino).toHaveBeenCalledTimes(1) @@ -272,9 +261,8 @@ describe('runCompilePipeline — blank FBD variable guard', () => { const result = await runCompilePipeline(makeArgs({ projectData }), port, emit) expect(result.success).toBe(false) - // Validation runs before XML generation / xml2st. - expect(mockedXmlGen).not.toHaveBeenCalled() - expect(port.transpileXmlToSt).not.toHaveBeenCalled() + // Validation runs before transpilation. + expect(port.transpileToSt).not.toHaveBeenCalled() // The user-facing error names the POU and the kind of block. const validateError = events.find((e) => e.stage === 'validate' && e.level === 'error') expect(validateError?.message).toContain('POU "main"') @@ -771,19 +759,9 @@ describe('runCompilePipeline — boardEntry shape variants', () => { // --------------------------------------------------------------------------- describe('runCompilePipeline — failure propagation', () => { - it('returns success=false when XmlGenerator reports failure', async () => { - mockedXmlGen.mockReturnValueOnce({ ok: false, data: undefined, message: 'malformed pou' } as never) - const port = makePort() - const { events, emit } = captureEvents() - const result = await runCompilePipeline(makeArgs(), port, emit) - expect(result.success).toBe(false) - expect(events.some((e) => e.stage === 'xml' && /malformed pou/.test(e.message))).toBe(true) - expect(port.transpileXmlToSt).not.toHaveBeenCalled() - }) - - it('returns success=false when transpileXmlToSt reports failure', async () => { + it('returns success=false when transpileToSt reports failure', async () => { const port = makePort({ - transpileXmlToSt: jest.fn().mockResolvedValue({ + transpileToSt: jest.fn().mockResolvedValue({ ok: false, errors: [{ message: 'bad xml', line: 1, column: 1, severity: 'error' }], }), @@ -945,7 +923,7 @@ describe('runCompilePipeline — side effects', () => { it('emits per-error events with structured compileError payloads on transpile failure', async () => { const port = makePort({ - transpileXmlToSt: jest.fn().mockResolvedValue({ + transpileToSt: jest.fn().mockResolvedValue({ ok: false, errors: [ { message: 'bad syntax', line: 5, column: 3, severity: 'error' }, @@ -1048,16 +1026,16 @@ describe('runCompilePipeline — side effects', () => { // ports with `vi.fn()` never invoke the callback, leaving the // lambda body uncovered — this test pins the wiring explicitly. const port = makePort({ - transpileXmlToSt: jest.fn().mockImplementation(async (_args, log) => { - log('xml2st spawned subprocess', 'info') - log('xml2st: parsed 5 POUs', 'info') + transpileToSt: jest.fn().mockImplementation(async (_args, log) => { + log('transpiler started', 'info') + log('transpiler: parsed 5 POUs', 'info') return { ok: true, programSt: 'PROGRAM main\nEND_PROGRAM' } }), }) const { events, emit } = captureEvents() await runCompilePipeline(makeArgs(), port, emit) const stEvents = events.filter((e) => e.stage === 'st') - expect(stEvents.some((e) => e.message === 'xml2st spawned subprocess' && e.level === 'info')).toBe(true) - expect(stEvents.some((e) => e.message === 'xml2st: parsed 5 POUs' && e.level === 'info')).toBe(true) + expect(stEvents.some((e) => e.message === 'transpiler started' && e.level === 'info')).toBe(true) + expect(stEvents.some((e) => e.message === 'transpiler: parsed 5 POUs' && e.level === 'info')).toBe(true) }) }) diff --git a/src/backend/shared/library/__tests__/build-pipeline.test.ts b/src/backend/shared/library/__tests__/build-pipeline.test.ts index 1222dd6b0..50bde6fe9 100644 --- a/src/backend/shared/library/__tests__/build-pipeline.test.ts +++ b/src/backend/shared/library/__tests__/build-pipeline.test.ts @@ -299,33 +299,10 @@ describe('prepareXmlForLibraryBuild', () => { expect(bulletCount).toBeGreaterThanOrEqual(3) }) - it('returns a structured error when XML generation fails', () => { - mockXmlGenerator.mockReturnValue({ ok: false, message: 'no main pou', data: undefined }) + it('returns projectData + knownPous (including stub) + parsed manifest on success', () => { const result = prepareXmlForLibraryBuild(makeLibraryProject(), VALID_MANIFEST_JSON) - expect('error' in result).toBe(true) - if (!('error' in result)) return - expect(result.error).toContain('no main pou') - }) - - it('falls back to "unknown error" when XmlGenerator omits a message', () => { - mockXmlGenerator.mockReturnValue({ ok: false, data: undefined }) - const result = prepareXmlForLibraryBuild(makeLibraryProject(), VALID_MANIFEST_JSON) - if (!('error' in result)) throw new Error('expected error') - expect(result.error).toContain('unknown error') - }) - - it('treats ok=true but empty data as an error', () => { - mockXmlGenerator.mockReturnValue({ ok: true, message: 'XML generated', data: '' }) - const result = prepareXmlForLibraryBuild(makeLibraryProject(), VALID_MANIFEST_JSON) - expect('error' in result).toBe(true) - }) - - it('returns xml + knownPous (including stub) + parsed manifest on success', () => { - mockXmlGenerator.mockReturnValue({ ok: true, message: 'XML generated', data: '' }) - const result = prepareXmlForLibraryBuild(makeLibraryProject(), VALID_MANIFEST_JSON) - expect('xml' in result).toBe(true) - if (!('xml' in result)) return - expect(result.xml).toBe('') + expect('error' in result).toBe(false) + if ('error' in result) return expect(result.manifest.name).toBe('demo_lib') // POUs from the project + the stub program diff --git a/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts b/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts index 0e2073c8c..c700cfa55 100644 --- a/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts +++ b/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts @@ -70,9 +70,9 @@ function makePort(): PortHarness { // distinguishable. return `md5-${input.length}-${input.charCodeAt(0) ?? 0}` }, - async transpileXmlToSt({ xml }) { - if (harness.throwOn.transpileXmlToSt) throw harness.throwOn.transpileXmlToSt - return { ok: true, programSt: `PROGRAM main\n(* from xml: ${xml.length} bytes *)\nEND_PROGRAM\n` } + async transpileToSt() { + if (harness.throwOn.transpileToSt) throw harness.throwOn.transpileToSt + return { ok: true, programSt: `PROGRAM main\n(* transpiled *)\nEND_PROGRAM\n` } }, async readBuildFile(_projectPath: string, relPath: string) { if (harness.throwOn.readBuildFile) throw harness.throwOn.readBuildFile @@ -165,7 +165,7 @@ describe('runLibraryBuildPipeline', () => { expect.arrayContaining([ 'Starting library build...', 'Manifest OK — building "lib" v0.1.0.', - 'Compiling file plc.xml', + 'Transpiling project to Structured Text', 'Verifying with OpenPLC Simulator (avr-gcc)...', 'Compiling library archive...', 'Library built successfully: build/lib.stlib', @@ -247,7 +247,7 @@ describe('runLibraryBuildPipeline', () => { expect(aux.dependencyRefs).toEqual([{ name: 'oscat-basic', version: '1.0.0' }]) }) - it('aborts before xml2st when the project enables an unresolved library', async () => { + it('aborts before transpilation when the project enables an unresolved library', async () => { const harness = makePort() harness.missing = ['ghost-lib'] const { events, emit } = captureEvents() @@ -277,8 +277,8 @@ describe('runLibraryBuildPipeline', () => { const harness = makePort() // Pre-seed the cache. computeMd5 in the harness is deterministic // off program.st length + first char; the orchestrator's value - // will match this when the same xml2st output replays. - const programSt = `PROGRAM main\n(* from xml: 24 bytes *)\nEND_PROGRAM\n` + // will match this when the same transpiler output replays. + const programSt = `PROGRAM main\n(* transpiled *)\nEND_PROGRAM\n` const expectedMd5 = `md5-${programSt.length}-${programSt.charCodeAt(0)}` harness.files.set('build/.verify-cache-library.json', JSON.stringify({ md5: expectedMd5, success: true })) const { events, emit } = captureEvents() @@ -300,7 +300,7 @@ describe('runLibraryBuildPipeline', () => { it('cleanBuild forces a fresh verification regardless of cache', async () => { const harness = makePort() - const programSt = `PROGRAM main\n(* from xml: 24 bytes *)\nEND_PROGRAM\n` + const programSt = `PROGRAM main\n(* transpiled *)\nEND_PROGRAM\n` const expectedMd5 = `md5-${programSt.length}-${programSt.charCodeAt(0)}` harness.files.set('build/.verify-cache-library.json', JSON.stringify({ md5: expectedMd5, success: true })) const { emit } = captureEvents() diff --git a/src/backend/shared/transpilers/st-transpiler/emit/configuration.ts b/src/backend/shared/transpilers/st-transpiler/emit/configuration.ts index f2e89f665..3d5cb5c4c 100644 --- a/src/backend/shared/transpilers/st-transpiler/emit/configuration.ts +++ b/src/backend/shared/transpilers/st-transpiler/emit/configuration.ts @@ -32,6 +32,11 @@ export function generateConfigurations(project: TranspileProject): ProgramChunk[ const out: ProgramChunk[] = [] + // Tasks emit in ascending-priority order (stable), matching the old + // editor XML generator's `[...tasks].sort((a,b)=>a.priority-b.priority)`. + // Instance (PROGRAM) lines are grouped by this same task order below. + const sortedTasks = [...project.configuration.tasks].sort((a, b) => a.priority - b.priority) + out.push(['\nCONFIGURATION ', []]) out.push([CONFIG_NAME, [configTagname, 'name']]) out.push(['\n', []]) @@ -57,7 +62,7 @@ export function generateConfigurations(project: TranspileProject): ProgramChunk[ // current shape. // Tasks. - project.configuration.tasks.forEach((task, taskNumber) => { + sortedTasks.forEach((task, taskNumber) => { out.push([' TASK ', []]) out.push([task.name, [resourceTagname, 'task', taskNumber, 'name']]) out.push(['(', []]) @@ -90,7 +95,7 @@ export function generateConfigurations(project: TranspileProject): ProgramChunk[ // iteration order, then instance order within each task), then the // task-less instances directly under the resource. let instanceNumber = 0 - for (const task of project.configuration.tasks) { + for (const task of sortedTasks) { for (const instance of project.configuration.instances) { if (instance.task !== task.name) continue out.push([' PROGRAM ', []]) diff --git a/src/middleware/shared/ports/compiler-platform-port.ts b/src/middleware/shared/ports/compiler-platform-port.ts index cb032097c..0f8237c60 100644 --- a/src/middleware/shared/ports/compiler-platform-port.ts +++ b/src/middleware/shared/ports/compiler-platform-port.ts @@ -2,17 +2,17 @@ * Thin platform-bridge for the shared compile pipeline. * * The shared compile pipeline orchestrator (`backend/shared/compile/ - * pipeline.ts`) drives the full editor-canonical build flow — XML - * generation, ST transpile, strucpp compile, conf authoring, defines - * authoring, firmware-bundle composition, arduino-cli invocation, - * runtime upload. Every step is shared and pure EXCEPT for three - * places that genuinely depend on the platform: + * pipeline.ts`) drives the full editor-canonical build flow — ST + * transpile, strucpp compile, conf authoring, defines authoring, + * firmware-bundle composition, arduino-cli invocation, runtime + * upload. ST transpilation runs in-process on both platforms (the + * shared `st-transpiler/`); the only places that genuinely depend on + * the platform are: * - * 1. `xml2st` transpile — editor spawns the bundled binary, - * web HTTP-POSTs to the centralised compiler-service backend. - * 2. `arduino-cli` compile — editor spawns arduino-cli, web POSTs - * to the same backend (which spawns it server-side). - * 3. Runtime upload — editor HTTPS-POSTs to the device's + * 1. `arduino-cli` compile — editor spawns arduino-cli, web POSTs + * to the centralised compiler-service backend (which spawns it + * server-side). + * 2. Runtime upload — editor HTTPS-POSTs to the device's * `/api/upload`, web pipes through the orchestrator * (WebRTC data channel with HTTP fallback). * @@ -80,33 +80,13 @@ export type PlatformDeviceContext = // Per-method I/O contracts // --------------------------------------------------------------------------- -/** `xml2st` input: a single XML string (the IEC 61131-3 PLC XML the - * shared `XmlGenerator` produces) plus an array of extra CLI tokens - * to append to the xml2st invocation. Defined here (not on either - * adapter) because xml2st flag drift was the root cause of the - * initial cross-platform STRUCT bug — editor's local xml2st passed - * `--keep-structs` but the web's compile-service `/generate-st` - * endpoint hardcoded an unflagged invocation, so structs declared - * in the project compiled fine on the desktop and blew up on the - * web with `Undefined type 'MY_STRUCT'` errors out of strucpp. - * Pushing the flag set into a single shared field means any future - * xml2st option is a one-line pipeline change with no per-platform - * drift possible. - * - * Editor's adapter passes `xml2stArgs` verbatim to the local - * binary (trusted). Web's adapter filters against its own - * known-args allowlist before forwarding to the compile-service - * `/generate-st` endpoint, logging a warning for anything it - * doesn't recognise (defence in depth — service has its own - * allowlist too). */ /** * Input to the in-process JSON → ST transpiler. Each adapter * projects this into the transpiler's `TranspileProject` IR with * its own helper — editor: `fromSchemaShape` (IPC schema-shape); * web: `fromPortShape` after a port→schema conversion at the - * adapter boundary. Replaces the legacy XML-fed surface that - * routed through a bundled `xml2st` binary; transpilation now - * runs in-process against the JSON IR. + * adapter boundary. Transpilation runs in-process against the + * JSON IR on both platforms. * * The declared type is port-shape because that's the renderer * store's shape; pipeline callers that hold schema-shape data From c82dbda55c4020586192ec027e487fe74a687851 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:16:49 -0300 Subject: [PATCH 05/52] fix(transpiler): drop redundant body-value casts in pou-graphical (lint) The discriminated `pou.body` union already narrows `value` on `language`, so `as RFBody` / `as RFFbdBody` trip `no-unnecessary-type-assertion` and fail the lint check (pre-existing on this branch). Remove the casts and the now-unused type imports. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../shared/transpilers/st-transpiler/emit/pou-graphical.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts b/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts index 642032161..d5cc9b2ba 100644 --- a/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts +++ b/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts @@ -16,10 +16,9 @@ import { computePouName } from '../helpers/text-helpers' import { varTypeNames } from '../helpers/type-text' import type { TranspilePou, TranspileProject, TranspileVariable, TranspileVariableClass } from '../types' import type { TypeContext } from '../walker/connection-types' -import { emitFbdBody, type RFFbdBody } from '../walker/fbd' +import { emitFbdBody } from '../walker/fbd' import type { SyntheticVar } from '../walker/ld' import { emitLdBody } from '../walker/ld' -import type { RFBody } from '../walker/types' import { declaredTypeName, getTypeAsText } from './type-text' import { computeValue } from './value' @@ -122,9 +121,9 @@ function collectBlockSignatures(project: TranspileProject): Map Date: Tue, 23 Jun 2026 19:23:42 -0300 Subject: [PATCH 06/52] style: prettier-format editor-compiler-platform-port (format check) Wrap an over-length return statement so the Format Check passes (it only started running now that the lint gate is green again). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/backend/editor/compiler/editor-compiler-platform-port.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index 6cad9b304..4e83778da 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -159,7 +159,10 @@ export function createEditorCompilerPlatformPort( return { ok: true, programSt: result.programSt } } catch (error) { const message = error instanceof Error ? error.message : String(error) - return { ok: false, errors: [{ message: `st-transpiler failed: ${message}`, line: 0, column: 0, severity: 'error' }] } + return { + ok: false, + errors: [{ message: `st-transpiler failed: ${message}`, line: 0, column: 0, severity: 'error' }], + } } } From bcf0f2a2cde45b0ceea6a7d26f0753785e704eb8 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:17:24 -0300 Subject: [PATCH 07/52] fix(transpiler): LD coil grouping (#836) + chained-block output ordering (#830) (#891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(transpiler): emit parallel set/reset coils under one shared IF Parallel coils branch off the same energization path, so the rung condition must be evaluated once and applied to every branch. A separate IF per coil re-evaluates the condition between assignments, which is wrong when the condition reads a coil an earlier branch just set (e.g. NOT(coils1) ... coils1 := TRUE flips coils2's condition). xml2st has the same bug; fixed here on the in-process engine. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(transpiler): group non-adjacent parallel coils; dedup identical paths Extends the parallel-coil grouping to the issue #836 topologies: SET/RESET coils sharing one source now collapse into a single IF even when a merge-fed coil sorts between them, so a contact feeding multiple coils is evaluated once instead of re-read between coil assignments. Also dedups byte-identical parallel paths (X OR X -> X) so the merged condition reads cleanly. Sequential coils (distinct sources) keep separate IFs by design. Deliberate divergence from xml2st (same class of fix as D1). Zero output change across the 193-project LD corpus; covered by a 3-topology regression test matching the maintainer's expected output. Closes #836 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(transpiler): emit block output writes right after the block call Chained block calls in a rung emitted all calls first, then all output assignments — so a downstream block read a variable before the upstream block's write was emitted (Output stayed 0). Couple each output-write sink (coil / output / inOut variable fed solely by a block) to its block: emit it immediately after the actual call, eager or lazy, via emitBlockConsumers (reusing the coil-grouping sweep). Robust to blocks pulled through a later block's EN expression, which the sink-slot approach missed. Deliberate divergence from xml2st (same class as #836). 10/193 LD corpus fixtures corrected (incl. real 4- and 5-block chains), 183 unchanged; #836 grouping + 301 integration tests still pass. Closes #830 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- docs/transpiler/block-output-ordering-830.md | 58 +++++ docs/transpiler/coil-reevaluation-836.md | 61 ++++++ .../block-output-ordering-830.test.ts | 203 ++++++++++++++++++ .../__tests__/coil-grouping-836.test.ts | 145 +++++++++++++ .../st-transpiler/core/path-tree.ts | 23 +- .../transpilers/st-transpiler/walker/ld.ts | 151 ++++++++++++- 6 files changed, 637 insertions(+), 4 deletions(-) create mode 100644 docs/transpiler/block-output-ordering-830.md create mode 100644 docs/transpiler/coil-reevaluation-836.md create mode 100644 src/backend/shared/transpilers/st-transpiler/__tests__/block-output-ordering-830.test.ts create mode 100644 src/backend/shared/transpilers/st-transpiler/__tests__/coil-grouping-836.test.ts diff --git a/docs/transpiler/block-output-ordering-830.md b/docs/transpiler/block-output-ordering-830.md new file mode 100644 index 000000000..442fb5b01 --- /dev/null +++ b/docs/transpiler/block-output-ordering-830.md @@ -0,0 +1,58 @@ +# Chained block output ordering in LD → ST (issue #830) + +Status: **resolved** on `fix/parity/parallel-coil-single-if` (folded into PR #891 / web #543). +Applied identically in both repos across the byte-identical +`src/backend/shared/transpilers/st-transpiler/` surface. + +## The bug + +When a rung chains function/FB calls, the generated ST emitted **all the block calls +first, then all the output assignments afterward**, so a later block read a variable whose +write had not been emitted yet. Reporter's case: + +```st +_TMP_MOD6363443_OUT := MOD(EN := TRUE, IN1 := Input, IN2 := 86400, ENO => _TMP_MOD6363443_ENO); +_TMP_DIV4651235_OUT := DIV(EN := _TMP_MOD6363443_ENO, IN1 := Output, IN2 := 60, ENO => _TMP_DIV4651235_ENO); -- reads Output before it is set +IF _TMP_MOD6363443_ENO THEN Output := _TMP_MOD6363443_OUT; END_IF; +IF _TMP_DIV4651235_ENO THEN Output := _TMP_DIV4651235_OUT; END_IF; +``` +`DIV` reads `Output` while it is still stale → `Output` is always 0. + +## Root cause + +Both the TS walker and the python oracle (`PLCGenerator.py:1339-1359`, `ComputeProgram`) emit +instances as *all execution-ordered (eo>0) instances first, then eo=0 instances by position*. +When the editor numbers the blocks but leaves their output variables at executionOrderId 0, +every block call emits in the ordered pass and every assignment in the unordered pass. A +shared bug with xml2st; regressed from v1.x; FBD escapes it via interleaved execution orders. +The same hazard applies to **coils** fed by a block (confirmed). + +## The fix + +An output-write sink (coil / output / inOut variable) fed solely by a block is that block's +output binding, so it must emit immediately after the block's **actual call** — eager or lazy. +Implemented at the emission level in `walker/ld.ts`: + +- `blockFedSink` identifies such sinks (single incoming edge from a block). +- `emitLdBody` indexes them per block into `state.consumersByBlock`. +- `emitFunctionCall` / `emitFunctionBlockCall` call `emitBlockConsumers` right after pushing + the call, which emits the block's consumers through the existing coil-grouping sweep (so the + #836 grouping still applies to multiple SET/RESET coils off one block). +- `state.emittedSinks` makes emission idempotent; the main sink sweep skips what coupling + already emitted. + +Emitting at the *call* (not the sink slot) is what makes it robust to blocks pulled lazily +through a later block's `EN` expression (e.g. `GT.EN := MUL.ENO OR SUB.ENO`), where the call +lands mid-walk. The earlier sink-list-reorder approach failed that case. + +This is a deliberate divergence from xml2st (same class as #836 / the D1 task-sort fix). + +## Verification + +- Regression test `st-transpiler/__tests__/block-output-ordering-830.test.ts` — the reported + MOD→DIV chain, the eager-pull (`GT.EN`) shape, and a block-fed coil; exact-ST assertions. +- Corpus: 10 of 193 LD fixtures changed, **all corrections** — each block's output write moved + to immediately follow its call. Notably `edge__matiasacuna_..._sc_freidora` (a 4-block + `INT_TO_REAL→SUB→MUL→DIV` chain) and `edge__frontadomarcositsu_...` (a 5-block chain) were + fully broken and are now correct. The other 183 fixtures are unchanged. +- #836 coil-grouping regression test still passes; 301 compile/library integration tests pass. diff --git a/docs/transpiler/coil-reevaluation-836.md b/docs/transpiler/coil-reevaluation-836.md new file mode 100644 index 000000000..5286b4552 --- /dev/null +++ b/docs/transpiler/coil-reevaluation-836.md @@ -0,0 +1,61 @@ +# Coil condition re-evaluation in LD → ST (issue #836) + +Status: **resolved** on `fix/parity/parallel-coil-single-if` (folded into PR #891 / web #543, +which become the complete "coil grouping" fix). Applied identically in both repos +(openplc-editor + openplc-web) across the byte-identical +`src/backend/shared/transpilers/st-transpiler/` surface; verified with +`scripts/compare-surfaces.py`. + +## The bug + +The LD walker emitted one `IF THEN := …; END_IF;` per coil, re-reading +the condition's variables each time. When a coil writes a variable that a later-emitted +coil's condition reads, the later coil sees the mutated value. The reporter's case: a +`FirstScan` contact feeding `Output1(S)`, a `FirstScan(R)` reset, and `Output2(S)` — with +`FirstScan` initially TRUE, the reset clears it before `Output2`'s `IF` re-reads it, so +`Output2` never sets. + +The old `xml2st` engine has the same bug, so this fix is a deliberate divergence from the +oracle (consistent with the D1-fix precedent on the parity branch). + +## Expected behaviour (from the maintainer, three topologies) + +| Topology | Expected ST | +|---|---| +| **S1** `FirstScan → [Output1(S) ∥ Output2(S)] → FirstScan(R)` | `Output1`+`Output2` in one `IF`; reset in a separate `IF` | +| **S2** `FirstScan → [Output1(S) ∥ FirstScan(R) ∥ Output2(S)]` | all three assignments in one `IF` | +| **S3** sequential `FirstScan → Output1(S) → FirstScan(R) → Output2(S)` | three separate `IF`s (Output2 stays unset) | + +The rule: SET/RESET coils that branch off the **same source** (parallel rails) share one +energization and collapse into a single `IF`; coils with **distinct sources** (sequential) +stay as separate `IF`s. Sequential coils intentionally keep the re-evaluation semantics. + +## The fix (two edits) + +1. **`walker/ld.ts` — `emitSinksWithCoilGrouping`**: collapse same-source SET/RESET coils + into one `IF`, gathering them **even when not adjacent** in emission order. A coil fed by + their merge (sharing neither source) can sort between two parallel branches by position; + the branches still group, emitted at the first branch's slot, with the merge-fed coil + following after (its dataflow order). The group key is the coil's sorted immediate + incoming source-node ids (`setResetCoilGroupKey`); the group emits via `emitCoilGroup`. +2. **`core/path-tree.ts` — `factorizePaths`**: drop byte-identical duplicate paths + (`X OR X` → `X`) before factoring. Two parallel branches tracing to the same contact + would otherwise render the reset condition as `FirstScan AND (TRUE OR TRUE)` (the TS + port's factorization of two identical single-leaf paths; xml2st renders the same + topology as `FirstScan OR FirstScan`). Dedup keys on the full repr — text **and** source + locations — so two *distinct* contacts on the same variable still survive as separate OR + terms. No behavioural change; cosmetic only. + +This supersedes the earlier "Approach A" (condition snapshotting into temps), which was +dropped once the maintainer's expected outputs showed the intended solution is grouping — +and that sequential coils (S3) should *not* be "fixed" by a snapshot. + +## Verification + +- Unit regression test (byte-identical both repos): + `st-transpiler/__tests__/coil-grouping-836.test.ts` — reconstructs all three topologies + and asserts exact ST. All three now match the maintainer's expected output. +- Corpus: ran the 193-project LD corpus (`~/src/xml2st/fixtures/golden/real_projects_json`) + through `emitLdBody` before/after. **0 fixtures changed** for the grouping extension and + **0** for the dedup — S1's topology (parallel branches merging into a downstream coil) + does not occur in the corpus, which is why it was a blind spot. diff --git a/src/backend/shared/transpilers/st-transpiler/__tests__/block-output-ordering-830.test.ts b/src/backend/shared/transpilers/st-transpiler/__tests__/block-output-ordering-830.test.ts new file mode 100644 index 000000000..8f0d6b29a --- /dev/null +++ b/src/backend/shared/transpilers/st-transpiler/__tests__/block-output-ordering-830.test.ts @@ -0,0 +1,203 @@ +import { emitLdBody } from '@root/backend/shared/transpilers/st-transpiler/walker/ld' +import type { RFBody, RFEdge, RFNode } from '@root/backend/shared/transpilers/st-transpiler/walker/types' + +// Regression coverage for issue #830 — when function calls are chained in +// a rung, each block's output assignment must emit immediately after the +// call, so a downstream block reads the written value and not a stale one. + +let edgeId = 0 +const e = (s: string, t: string, sh: string | null, th: string | null): RFEdge => ({ + id: `e${edgeId++}`, + source: s, + target: t, + sourceHandle: sh, + targetHandle: th, +}) +const rail = (id: string, variant: 'left' | 'right', x: number): RFNode => ({ + id, + type: 'powerRail', + position: { x, y: 30 }, + data: { variant }, +}) +const inVar = (id: string, name: string, x: number, y: number): RFNode => ({ + id, + type: 'variable', + position: { x, y }, + data: { variant: 'input', variable: { name }, executionOrder: 0, numericId: id }, +}) +const outVar = (id: string, name: string, x: number, y: number): RFNode => ({ + id, + type: 'variable', + position: { x, y }, + data: { variant: 'output', variable: { name }, executionOrder: 0, numericId: id }, +}) +const coil = (id: string, name: string, x: number, y: number): RFNode => ({ + id, + type: 'coil', + position: { x, y }, + data: { variant: 'default', variable: { name }, executionOrder: 0, numericId: id }, +}) +const fblock = (id: string, name: string, nid: string, eo: number, x: number, y: number, outType = 'DINT'): RFNode => ({ + id, + type: 'block', + position: { x, y }, + data: { + numericId: nid, + executionOrder: eo, + executionControl: true, + variant: { + name, + type: 'function', + extensible: false, + variables: [ + { name: 'EN', class: 'input', type: { definition: 'generic-type', value: 'BOOL' } }, + { name: 'ENO', class: 'output', type: { definition: 'generic-type', value: 'BOOL' } }, + { name: 'OUT', class: 'output', type: { definition: 'base-type', value: outType } }, + { name: 'IN1', class: 'input', type: { definition: 'base-type', value: 'DINT' } }, + { name: 'IN2', class: 'input', type: { definition: 'base-type', value: 'DINT' } }, + ], + }, + }, +}) + +const call = (lhs: string, rhs: string): string => ` ${lhs} := ${rhs};\n` +const enoIf = (eno: string, v: string, val: string): string => ` IF ${eno} THEN\n ${v} := ${val};\n END_IF;\n` +const asg = (v: string, val: string): string => ` ${v} := ${val};\n` + +describe('issue 830 — chained block output assignments emit before downstream calls', () => { + it('writes a block output before the next block reads it (reported MOD->DIV chain)', () => { + // MOD(EN:=TRUE) -> Output ; DIV(EN:=MOD.ENO, IN1:=Output) -> Output. Blocks numbered, writes at eo 0. + const body: RFBody = { + rungs: [ + { + reactFlowViewport: [800, 300], + nodes: [ + rail('L', 'left', 0), + inVar('901', 'Input', 20, 80), + inVar('902', '86400', 20, 120), + fblock('MOD', 'MOD', '6363443', 1, 150, 30), + outVar('903', 'Output', 320, 30), + inVar('904', 'Output', 20, 200), + inVar('905', '60', 20, 240), + fblock('DIV', 'DIV', '4651235', 2, 450, 30), + outVar('906', 'Output', 620, 30), + rail('R', 'right', 760), + ], + edges: [ + e('L', 'MOD', 'left-rail', 'EN'), + e('901', 'MOD', null, 'IN1'), + e('902', 'MOD', null, 'IN2'), + e('MOD', '903', 'OUT', null), + e('MOD', 'DIV', 'ENO', 'EN'), + e('904', 'DIV', null, 'IN1'), + e('905', 'DIV', null, 'IN2'), + e('DIV', '906', 'OUT', null), + ], + }, + ], + } + const { bodySt, warnings } = emitLdBody(body) + expect(warnings).toEqual([]) + expect(bodySt).toBe( + '\n' + + call('_TMP_MOD6363443_OUT', 'MOD(EN := TRUE, IN1 := Input, IN2 := 86400, ENO => _TMP_MOD6363443_ENO)') + + enoIf('_TMP_MOD6363443_ENO', 'Output', '_TMP_MOD6363443_OUT') + + call( + '_TMP_DIV4651235_OUT', + 'DIV(EN := _TMP_MOD6363443_ENO, IN1 := Output, IN2 := 60, ENO => _TMP_DIV4651235_ENO)', + ) + + enoIf('_TMP_DIV4651235_ENO', 'Output', '_TMP_DIV4651235_OUT'), + ) + }) + + it('interleaves output writes for blocks pulled eagerly through a later block EN', () => { + // MUL -> producto ; SUB(IN1:=producto) -> dif ; GT(EN := MUL.ENO OR SUB.ENO) -> flag. + // No execution order: GT.EN pulls MUL and SUB; their writes must still land between the calls. + const body: RFBody = { + rungs: [ + { + reactFlowViewport: [800, 300], + nodes: [ + rail('L', 'left', 0), + inVar('901', 'valor', 20, 80), + inVar('902', 'k', 20, 120), + fblock('MUL', 'MUL', '111', 0, 150, 30), + outVar('903', 'producto', 320, 200), + inVar('904', 'producto', 20, 160), + inVar('905', 'k2', 20, 200), + fblock('SUB', 'SUB', '222', 0, 150, 130), + outVar('906', 'dif', 320, 260), + fblock('GT', 'GT', '333', 0, 500, 30, 'BOOL'), + outVar('907', 'flag', 660, 30), + rail('R', 'right', 760), + ], + edges: [ + e('L', 'MUL', 'left-rail', 'EN'), + e('901', 'MUL', null, 'IN1'), + e('902', 'MUL', null, 'IN2'), + e('MUL', '903', 'OUT', null), + e('904', 'SUB', null, 'IN1'), + e('905', 'SUB', null, 'IN2'), + e('SUB', '906', 'OUT', null), + e('MUL', 'GT', 'ENO', 'EN'), + e('SUB', 'GT', 'ENO', 'EN'), + e('GT', '907', 'OUT', null), + ], + }, + ], + } + const { bodySt, warnings } = emitLdBody(body) + expect(warnings).toEqual([]) + expect(bodySt).toBe( + '\n' + + call('_TMP_MUL111_OUT', 'MUL(EN := TRUE, IN1 := valor, IN2 := k, ENO => _TMP_MUL111_ENO)') + + enoIf('_TMP_MUL111_ENO', 'producto', '_TMP_MUL111_OUT') + + call('_TMP_SUB222_OUT', 'SUB(IN1 := producto, IN2 := k2, ENO => _TMP_SUB222_ENO)') + + asg('dif', '_TMP_SUB222_OUT') + + call('_TMP_GT333_OUT', 'GT(EN := _TMP_MUL111_ENO OR _TMP_SUB222_ENO, ENO => _TMP_GT333_ENO)') + + enoIf('_TMP_GT333_ENO', 'flag', '_TMP_GT333_OUT'), + ) + }) + + it('writes a block-fed coil before the next block reads it', () => { + // GEA -> coil Flag ; GEB(EN:=GEA.ENO, IN1:=Flag) -> coil Result. + const body: RFBody = { + rungs: [ + { + reactFlowViewport: [800, 300], + nodes: [ + rail('L', 'left', 0), + inVar('901', 'Input', 20, 80), + inVar('902', '86400', 20, 120), + fblock('GEA', 'GE', '111', 1, 150, 30, 'BOOL'), + coil('cFlag', 'Flag', 320, 30), + inVar('904', 'Flag', 20, 200), + inVar('905', '60', 20, 240), + fblock('GEB', 'GE', '222', 2, 450, 30, 'BOOL'), + coil('cRes', 'Result', 620, 30), + rail('R', 'right', 760), + ], + edges: [ + e('L', 'GEA', 'left-rail', 'EN'), + e('901', 'GEA', null, 'IN1'), + e('902', 'GEA', null, 'IN2'), + e('GEA', 'cFlag', 'OUT', null), + e('GEA', 'GEB', 'ENO', 'EN'), + e('904', 'GEB', null, 'IN1'), + e('905', 'GEB', null, 'IN2'), + e('GEB', 'cRes', 'OUT', null), + ], + }, + ], + } + const { bodySt, warnings } = emitLdBody(body) + expect(warnings).toEqual([]) + expect(bodySt).toBe( + '\n' + + call('_TMP_GE111_OUT', 'GE(EN := TRUE, IN1 := Input, IN2 := 86400, ENO => _TMP_GE111_ENO)') + + asg('Flag', '_TMP_GE111_OUT') + + call('_TMP_GE222_OUT', 'GE(EN := _TMP_GE111_ENO, IN1 := Flag, IN2 := 60, ENO => _TMP_GE222_ENO)') + + asg('Result', '_TMP_GE222_OUT'), + ) + }) +}) diff --git a/src/backend/shared/transpilers/st-transpiler/__tests__/coil-grouping-836.test.ts b/src/backend/shared/transpilers/st-transpiler/__tests__/coil-grouping-836.test.ts new file mode 100644 index 000000000..c99aa94b8 --- /dev/null +++ b/src/backend/shared/transpilers/st-transpiler/__tests__/coil-grouping-836.test.ts @@ -0,0 +1,145 @@ +import { emitLdBody } from '@root/backend/shared/transpilers/st-transpiler/walker/ld' +import type { RFBody, RFEdge, RFNode } from '@root/backend/shared/transpilers/st-transpiler/walker/types' + +// Regression coverage for issue #836 — a contact feeding multiple coils +// must be evaluated once per energization path, not re-read between coil +// assignments. SET/RESET coils that branch off the same source collapse +// into one IF even when a merge-fed coil sorts between them; sequential +// coils (distinct sources) stay as separate IFs. + +let edgeId = 0 +const e = (source: string, target: string): RFEdge => ({ id: `e${edgeId++}`, source, target }) +const rail = (id: string, variant: 'left' | 'right', x: number): RFNode => ({ + id, + type: 'powerRail', + position: { x, y: 30 }, + data: { variant }, +}) +const contact = (id: string, name: string, x: number, nid: string): RFNode => ({ + id, + type: 'contact', + position: { x, y: 38 }, + data: { variant: 'default', variable: { name }, numericId: nid }, +}) +const coil = (id: string, name: string, variant: 'set' | 'reset', x: number, y: number, nid: string): RFNode => ({ + id, + type: 'coil', + position: { x, y }, + data: { variant, variable: { name }, executionOrder: 0, numericId: nid }, +}) +const par = (id: string, side: 'open' | 'close', x: number): RFNode => ({ + id, + type: 'parallel', + position: { x, y: 49 }, + data: { type: side }, +}) + +// One emitted `IF THEN END_IF;` block, base indent 2. +const block = (cond: string, ...assigns: string[]): string => + ` IF ${cond} THEN\n${assigns.map((a) => ` ${a}\n`).join('')} END_IF;\n` + +describe('issue 836 — contact evaluated once across multiple coils', () => { + it('groups parallel outputs even when the reset coil sorts between them', () => { + // FirstScan -> [Output1(S) || Output2(S)] -> FirstScan(R) + const body: RFBody = { + rungs: [ + { + reactFlowViewport: [700, 300], + nodes: [ + rail('L', 'left', 0), + contact('C', 'FirstScan', 68, '10'), + par('PO', 'open', 251), + coil('K1', 'Output1', 'set', 300, 38, '20'), + coil('K2', 'Output2', 'set', 300, 130, '21'), + par('PC', 'close', 373), + coil('KR', 'FirstScan', 'reset', 500, 38, '22'), + rail('R', 'right', 600), + ], + edges: [ + e('L', 'C'), + e('C', 'PO'), + e('PO', 'K1'), + e('PO', 'K2'), + e('K1', 'PC'), + e('K2', 'PC'), + e('PC', 'KR'), + e('KR', 'R'), + ], + }, + ], + } + const { bodySt, warnings } = emitLdBody(body) + expect(warnings).toEqual([]) + expect(bodySt).toBe( + '\n' + + block('FirstScan', 'Output1 := TRUE; (*set*)', 'Output2 := TRUE; (*set*)') + + block('FirstScan', 'FirstScan := FALSE; (*reset*)'), + ) + }) + + it('groups three coils sharing one source into a single IF', () => { + // FirstScan -> [Output1(S) || FirstScan(R) || Output2(S)] + const body: RFBody = { + rungs: [ + { + reactFlowViewport: [600, 300], + nodes: [ + rail('L', 'left', 0), + contact('C', 'FirstScan', 68, '10'), + par('PO', 'open', 251), + coil('K1', 'Output1', 'set', 300, 38, '20'), + coil('KR', 'FirstScan', 'reset', 300, 130, '22'), + coil('K2', 'Output2', 'set', 300, 220, '21'), + par('PC', 'close', 400), + rail('R', 'right', 500), + ], + edges: [ + e('L', 'C'), + e('C', 'PO'), + e('PO', 'K1'), + e('PO', 'KR'), + e('PO', 'K2'), + e('K1', 'PC'), + e('KR', 'PC'), + e('K2', 'PC'), + e('PC', 'R'), + ], + }, + ], + } + const { bodySt, warnings } = emitLdBody(body) + expect(warnings).toEqual([]) + expect(bodySt).toBe( + '\n' + + block('FirstScan', 'Output1 := TRUE; (*set*)', 'FirstScan := FALSE; (*reset*)', 'Output2 := TRUE; (*set*)'), + ) + }) + + it('keeps sequential coils (distinct sources) as separate IFs', () => { + // FirstScan -> Output1(S) -> FirstScan(R) -> Output2(S) + const body: RFBody = { + rungs: [ + { + reactFlowViewport: [700, 100], + nodes: [ + rail('L', 'left', 0), + contact('C', 'FirstScan', 68, '10'), + coil('K1', 'Output1', 'set', 200, 38, '20'), + coil('KR', 'FirstScan', 'reset', 350, 38, '22'), + coil('K2', 'Output2', 'set', 500, 38, '21'), + rail('R', 'right', 650), + ], + edges: [e('L', 'C'), e('C', 'K1'), e('K1', 'KR'), e('KR', 'K2'), e('K2', 'R')], + }, + ], + } + const { bodySt, warnings } = emitLdBody(body) + expect(warnings).toEqual([]) + expect(bodySt).toBe( + '\n' + + block('FirstScan', 'Output1 := TRUE; (*set*)') + + block('FirstScan', 'FirstScan := FALSE; (*reset*)') + + block('FirstScan', 'Output2 := TRUE; (*set*)'), + ) + }) +}) diff --git a/src/backend/shared/transpilers/st-transpiler/core/path-tree.ts b/src/backend/shared/transpilers/st-transpiler/core/path-tree.ts index f76337e7f..09c1535ca 100644 --- a/src/backend/shared/transpilers/st-transpiler/core/path-tree.ts +++ b/src/backend/shared/transpilers/st-transpiler/core/path-tree.ts @@ -110,7 +110,9 @@ export function pythonReprString(s: string): string { /** * Boolean simplification — `(A AND B) OR (A AND C)` → `A AND (B OR C)`. * - * Strategy mirrors `FactorizePaths` (PLCGenerator.py:1429): + * Strategy mirrors `FactorizePaths` (PLCGenerator.py:1429), with one + * deliberate divergence — duplicate-path dedup (see below): + * 0. Drop byte-identical duplicate paths (`X OR X` → `X`). * 1. Sort the paths by their Python `repr`. * 2. For each pair, find common head/tail prefixes/suffixes between * adjacent AND nodes. @@ -119,8 +121,25 @@ export function pythonReprString(s: string): string { export function factorizePaths(paths: readonly PathNode[]): PathNode[] { if (paths.length <= 1) return [...paths] + // Collapse byte-identical parallel paths (`X OR X` = `X`). The + // python oracle keeps duplicates (parallel coils feeding a merged + // sink emit `FirstScan OR FirstScan`); we drop them, a deliberate + // divergence that yields the cleaner condition with no behavioural + // change. Paths are equal only when their full repr — text AND + // source locations — matches, so two distinct contacts on the same + // variable still survive as separate OR terms. + const seenRepr = new Set() + const unique: PathNode[] = [] + for (const p of paths) { + const k = pythonReprNode(p) + if (seenRepr.has(k)) continue + seenRepr.add(k) + unique.push(p) + } + if (unique.length <= 1) return unique + // Sort by stable repr key — same order Python produces. - const sorted = pythonStableSort(paths) + const sorted = pythonStableSort(unique) // Walk sorted list, group adjacent paths sharing head AND. const out: PathNode[] = [] diff --git a/src/backend/shared/transpilers/st-transpiler/walker/ld.ts b/src/backend/shared/transpilers/st-transpiler/walker/ld.ts index 72c57d54c..7b1133241 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/ld.ts +++ b/src/backend/shared/transpilers/st-transpiler/walker/ld.ts @@ -93,6 +93,13 @@ interface WalkerState { originFormalParameter: string }[] emittedBlocks: Set + /** Output-write sinks (coil / output / inOut variable) keyed by the + * block that feeds them — emitted right after the block's call so a + * downstream block reads the written value, not a stale one (#830). */ + consumersByBlock: Map + /** Sinks already emitted — by block coupling or the main sweep — so + * the other path skips them. */ + emittedSinks: Set /** Connector expressions cached by name, consumed by continuation * visits later in the same rung (FBD-only). */ connectorExprs: Map @@ -172,6 +179,8 @@ export function emitLdBody(body: RFBody, typeContext?: TypeContext): EmitResult triggerVars: [], functionTempVars: [], emittedBlocks: new Set(), + consumersByBlock: new Map(), + emittedSinks: new Set(), connectorExprs: new Map(), yOffset: new Map(), warnings: [], @@ -250,8 +259,24 @@ export function emitLdBody(body: RFBody, typeContext?: TypeContext): EmitResult ordered.sort((a, b) => nodeExecutionOrder(a) - nodeExecutionOrder(b)) others.sort((a, b) => compareNodePosition(state, a, b)) - for (const sink of ordered) emitSink(state, sink) - for (const sink of others) emitSink(state, sink) + // Index each block's output-write sinks (coils / output / inOut + // variables fed solely by that block). These are emitted right after + // the block's call by `emitBlockConsumers` — wherever the call lands, + // eager or lazy — and skipped in the sweep below. Otherwise a block + // numbered ahead of its output variable (write left at + // executionOrderId 0) emits its call in the ordered pass while the + // assignment waits for the unordered pass, so a later block in the + // rung reads the variable before it is written (#830). + for (const node of [...ordered, ...others]) { + const blockId = blockFedSink(state, node) + if (blockId === null) continue + const list = state.consumersByBlock.get(blockId) ?? [] + list.push(node) + state.consumersByBlock.set(blockId, list) + } + + emitSinksWithCoilGrouping(state, ordered) + emitSinksWithCoilGrouping(state, others) const bodySt = '\n' + state.program.map((c) => c[0]).join('') const syntheticVars: SyntheticVar[] = [ @@ -279,6 +304,23 @@ function compareNodePosition(state: WalkerState, a: RFNode, b: RFNode): number { return ax - bx } +/** + * If `node` is an output-write sink (coil / output- or inOut-variable) + * whose value comes solely from a single block, return that block's id. + * Such a sink is the block's output binding — it must emit right after + * the block's call so downstream blocks read the written value, not a + * stale one (#830). Returns null for anything else (multi-source + * sinks, non-block sources, blocks, connectors). + */ +function blockFedSink(state: WalkerState, node: RFNode): string | null { + if (node.type !== 'coil' && !isVariableNode(node)) return null + const edges = state.incoming.get(node.id) ?? [] + if (edges.length !== 1) return null + const src = state.byId.get(edges[0].source) + if (src === undefined || src.type !== 'block') return null + return src.id +} + function emitSink(state: WalkerState, node: RFNode): void { // Top-level sink walks ALWAYS use order=false — the sink's own // executionOrderId doesn't propagate into its upstream walks @@ -297,8 +339,111 @@ function emitSink(state: WalkerState, node: RFNode): void { if (node.type === 'connector') return emitConnectorNode(state, node) } +/** + * Emit sinks in order, collapsing SET/RESET coils that branch off the + * SAME upstream source into a single `IF`. Parallel coils share one + * energization path, so the rung condition must be evaluated ONCE and + * applied to every branch — emitting a separate `IF` per coil + * re-evaluates the condition between assignments, which is wrong when + * the condition reads a coil an earlier branch just set + * (e.g. `IF NOT(coils1)... coils1 := TRUE`). + * + * Same-source coils are gathered even when they are NOT adjacent in + * emission order: a coil fed by their merge (so it shares neither + * source) can sort between two parallel branches by position, yet the + * branches must still collapse into one `IF`. The group is emitted at + * the first branch's slot; the intervening sink (and any other + * downstream work) follows after — which is also its dataflow order, + * since a merge-fed sink is downstream of the branches it consumes. + */ +function emitSinksWithCoilGrouping(state: WalkerState, sinks: RFNode[]): void { + for (let i = 0; i < sinks.length; i++) { + if (state.emittedSinks.has(sinks[i].id)) continue + const key = setResetCoilGroupKey(state, sinks[i]) + if (key === null) { + state.emittedSinks.add(sinks[i].id) + emitSink(state, sinks[i]) + continue + } + const group: RFNode[] = [sinks[i]] + for (let j = i + 1; j < sinks.length; j++) { + if (state.emittedSinks.has(sinks[j].id)) continue + if (setResetCoilGroupKey(state, sinks[j]) === key) group.push(sinks[j]) + } + for (const g of group) state.emittedSinks.add(g.id) + if (group.length === 1) emitCoilNode(state, sinks[i]) + else emitCoilGroup(state, group) + } +} + +/** + * Emit a block's output-write sinks (coils / output / inOut variables + * fed solely by it) right after its call — wherever that call lands. + * Reuses the coil-grouping sweep so parallel SET/RESET coils off the + * same block still collapse into one `IF`. Idempotent via + * `state.emittedSinks`, so the main sweep skips what is emitted here. + */ +function emitBlockConsumers(state: WalkerState, blockId: string): void { + const consumers = state.consumersByBlock.get(blockId) + if (consumers === undefined) return + emitSinksWithCoilGrouping(state, consumers) +} + +/** + * Group identity for a SET/RESET coil: the sorted set of its upstream + * source node ids. Two coils with the same key branch off the same + * point (parallel rails) and therefore share a condition. Returns + * null for anything not groupable (non-coils, plain/negated/edge + * coils, or an unconnected coil). + */ +function setResetCoilGroupKey(state: WalkerState, node: RFNode): string | null { + if (node.type !== 'coil') return null + const data = asCoilData(node.data) + if (data === null || (data.variant !== 'set' && data.variant !== 'reset')) return null + const sources = (state.incoming.get(node.id) ?? []).map((e) => e.source).sort() + if (sources.length === 0) return null + return sources.join('|') +} + /* ─────────────────────────── coil emission ──────────────────────────────── */ +/** + * Emit a group of parallel SET/RESET coils under one shared `IF`. + * The condition is computed once from the first coil's upstream (all + * coils in the group share it by construction), then each branch's + * assignment is written inside the single `THEN` body. + */ +function emitCoilGroup(state: WalkerState, coils: RFNode[]): void { + const first = coils[0] + const paths = pathsFromIncoming(state, first.id, /*order=*/ false) + if (paths.length === 0) { + // Shared upstream resolved to nothing — fall back to per-coil + // emission so each surfaces its own "must be connected" warning. + for (const coil of coils) emitCoilNode(state, coil) + return + } + const expr = pathsToChunks(paths) + const firstData = asCoilData(first.data) + const firstInfo: Location = [state.tagName, 'coil', locId(first)] + + state.program.push([`${state.currentIndent}IF `, [...firstInfo, firstData?.variant ?? 'set']]) + for (const chunk of expr) state.program.push(chunk) + state.program.push([' THEN\n', []]) + + const inner = state.currentIndent + ' ' + for (const coil of coils) { + const data = asCoilData(coil.data) + if (data === null) continue + const storage = data.variant === 'reset' ? 'reset' : 'set' + const value = storage === 'set' ? 'TRUE' : 'FALSE' + const info: Location = [state.tagName, 'coil', locId(coil)] + state.program.push([inner, []]) + state.program.push([data.variable, [...info, 'reference']]) + state.program.push([` := ${value}; (*${storage}*)\n`, []]) + } + state.program.push([`${state.currentIndent}END_IF;\n`, []]) +} + function emitCoilNode(state: WalkerState, node: RFNode): void { const data = asCoilData(node.data) if (data === null) { @@ -674,6 +819,7 @@ function emitFunctionBlockCall(state: WalkerState, node: RFNode, data: BlockData for (const chunk of parts[i]) state.program.push(chunk) } state.program.push([');\n', []]) + emitBlockConsumers(state, node.id) } function emitFunctionCall(state: WalkerState, node: RFNode, data: BlockData): void { @@ -728,6 +874,7 @@ function emitFunctionCall(state: WalkerState, node: RFNode, data: BlockData): vo } state.program.push([');\n', []]) void primaryFormal + emitBlockConsumers(state, node.id) } function buildInputArgs( From 78862acf102f097f02b1ef7844a67b6eaca4c4d6 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:26:37 -0300 Subject: [PATCH 08/52] fix(compiler): close debug port on ST write failure; drop needless async MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review on #885: - compileForDebugger ran mkdir/writeFile for program.st outside any try, so a disk/permission failure rejected without posting the stop message or closing _mainProcessPort (hung debugger). Wrap them in try/catch matching the sibling steps. - Drop async from transpileToSt/computeMd5 (no await; require-await warnings), returning Promise.resolve — matches the sibling library-build port. Editor-only (backend/editor); no shared-surface change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/compiler/compiler-module.ts | 21 +++++++++++++------ .../compiler/editor-compiler-platform-port.ts | 8 +++---- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index db07f986e..7f93baf91 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -2846,18 +2846,27 @@ class CompilerModule { } } - await mkdir(sourceTargetFolderPath, { recursive: true }) - const stResult = runDebugInProcess() - if (!stResult.ok) { + const programStPath = join(sourceTargetFolderPath, 'program.st') + try { + await mkdir(sourceTargetFolderPath, { recursive: true }) + const stResult = runDebugInProcess() + if (!stResult.ok) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `${stResult.error}\nStopping debug compilation process.`, + }) + _mainProcessPort.close() + return + } + await writeFile(programStPath, stResult.programSt, 'utf-8') + } catch (error) { _mainProcessPort.postMessage({ logLevel: 'error', - message: `${stResult.error}\nStopping debug compilation process.`, + message: `Error writing ST file: ${getErrorMessage(error)}\nStopping debug compilation process.`, }) _mainProcessPort.close() return } - const programStPath = join(sourceTargetFolderPath, 'program.st') - await writeFile(programStPath, stResult.programSt, 'utf-8') _mainProcessPort.postMessage({ logLevel: 'info', message: `ST file generated at: ${programStPath}` }) try { diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index 5ae89ecfa..a96dbf61d 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -173,21 +173,21 @@ export function createEditorCompilerPlatformPort( * adapter computes the same hash via `spark-md5`; both outputs * are byte-identical. */ - async computeMd5(input: string): Promise { - return createHash('md5').update(input).digest('hex') + computeMd5(input: string): Promise { + return Promise.resolve(createHash('md5').update(input).digest('hex')) }, /** * Transpile the project IR to Structured Text via the in-process JSON * transpiler (`backend/shared/transpilers/st-transpiler/`). */ - async transpileToSt(args: TranspileToStArgs, log: PlatformLog): Promise { + transpileToSt(args: TranspileToStArgs, log: PlatformLog): Promise { const result = runInProcessTranspile(args) if (!result.ok) { const message = result.errors?.map((e) => e.message).join('\n') || 'Failed to generate Structured Text' log(message, 'error') } - return result + return Promise.resolve(result) }, /** From ed0fe8cac6b8e286e9b467446e5c39d25175cfc4 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 6 Jul 2026 02:51:48 -0400 Subject: [PATCH 09/52] feat(softmotion): CiA 402 SoftMotion axis support (mirror of openplc-web) Byte-identical mirror of the shared SoftMotion surface from openplc-web (PR to be released together): - cia402.ts: recognize CiA 402 drives + resolve objects to IEC addresses - generate-softmotion.ts: compile-time AXIS_REF_SM3 globals + PDO scalars + per-scan SM_Drive_GenericDS402 bridge + VAR_EXTERNAL injection - enrichDeviceData tags recognized drives; open-plc/esi-types carry the Cia402AxisConfig; preprocessPous runs the codegen in every compile path - shared tests (cia402, generate-softmotion, preprocess-pous) pass under the editor's jest - binary-versions: strucpp v0.5.13 -> v0.5.14 (ships the SM3 library + FB inout copy-back + composite shared globals) The editor loads plcopen-softmotion.stlib via its bundled-libs directory scan (no bundled-stlibs.ts change needed). Requires strucpp release v0.5.14 (STruCpp PR #194). Co-Authored-By: Claude Opus 4.8 (1M context) --- binary-versions.json | 2 +- .../shared/ethercat/__tests__/cia402.test.ts | 93 ++ .../__tests__/fixtures/cia402-servo-esi.xml | 1398 +++++++++++++++++ .../__tests__/generate-softmotion.test.ts | 249 +++ src/backend/shared/ethercat/cia402.ts | 134 ++ .../shared/ethercat/enrich-device-data.ts | 5 + .../shared/ethercat/generate-softmotion.ts | 231 +++ src/backend/shared/types/PLC/open-plc.ts | 17 + .../PLC/__tests__/preprocess-pous.test.ts | 47 + .../shared/utils/PLC/preprocess-pous.ts | 9 + src/middleware/shared/ports/esi-types.ts | 20 + 11 files changed, 2204 insertions(+), 1 deletion(-) create mode 100644 src/backend/shared/ethercat/__tests__/cia402.test.ts create mode 100644 src/backend/shared/ethercat/__tests__/fixtures/cia402-servo-esi.xml create mode 100644 src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts create mode 100644 src/backend/shared/ethercat/cia402.ts create mode 100644 src/backend/shared/ethercat/generate-softmotion.ts diff --git a/binary-versions.json b/binary-versions.json index a0d6978b0..6dd46a282 100644 --- a/binary-versions.json +++ b/binary-versions.json @@ -4,7 +4,7 @@ "repository": "Autonomy-Logic/xml2st" }, "strucpp": { - "version": "v0.5.13", + "version": "v0.5.14", "repository": "Autonomy-Logic/STruCpp" } } diff --git a/src/backend/shared/ethercat/__tests__/cia402.test.ts b/src/backend/shared/ethercat/__tests__/cia402.test.ts new file mode 100644 index 000000000..fceccc2c8 --- /dev/null +++ b/src/backend/shared/ethercat/__tests__/cia402.test.ts @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + + +import { + CIA402_OBJECTS, + DEFAULT_CIA402_AXIS_CONFIG, + isCia402Drive, + normalizeObjectIndex, + resolveCia402Objects, +} from '../cia402' +import { enrichDeviceData } from '../enrich-device-data' +import { parseESIDeviceFull } from '../esi-parser-main' + +const ESI_XML = readFileSync( + resolve(__dirname, 'fixtures/cia402-servo-esi.xml'), + 'utf-8', +) + +function loadDevice() { + const result = parseESIDeviceFull(ESI_XML, 0) + expect(result.success).toBe(true) + expect(result.device).toBeDefined() + return result.device! +} + +describe('cia402 recognition', () => { + it('normalizes object indices in every ESI notation', () => { + expect(normalizeObjectIndex('#x6040')).toBe(0x6040) + expect(normalizeObjectIndex('0x6040')).toBe(0x6040) + expect(normalizeObjectIndex('6040')).toBe(0x6040) + expect(normalizeObjectIndex(0x6040)).toBe(0x6040) + expect(normalizeObjectIndex('bogus')).toBe(-1) + }) + + it('recognizes a real CiA 402 servo ESI as a SoftMotion drive', () => { + const device = loadDevice() + expect(isCia402Drive(device)).toBe(true) + }) + + it('does not recognize a device lacking the mandatory objects', () => { + const device = loadDevice() + // strip Controlword (0x6040) from the RxPDOs → no longer a valid axis + const stripped = { + ...device, + rxPdo: device.rxPdo.map((p) => ({ + ...p, + entries: p.entries.filter((e) => normalizeObjectIndex(e.index) !== 0x6040), + })), + } + expect(isCia402Drive(stripped)).toBe(false) + }) + + it('resolves CiA 402 objects to editor-allocated IEC addresses', () => { + const device = loadDevice() + const enriched = enrichDeviceData(device) + const resolved = resolveCia402Objects(enriched.channelInfo, enriched.channelMappings) + + const byRole = Object.fromEntries(resolved.map((r) => [r.role, r])) + // The real fixture maps: 0x6040 Controlword, 0x607A Target position (out); + // 0x6041 Statusword, 0x6064 Position actual (in). + expect(byRole.controlWord).toBeDefined() + expect(byRole.statusWord).toBeDefined() + expect(byRole.targetPosition).toBeDefined() + expect(byRole.positionActual).toBeDefined() + + // Controlword is a 16-bit output → %Q word address, WORD/UINT type. + expect(byRole.controlWord.iecLocation).toMatch(/^%Q/) + expect(byRole.statusWord.iecLocation).toMatch(/^%I/) + // Every resolved object carries a concrete address + IEC type. + for (const r of resolved) { + expect(r.iecLocation).toMatch(/^%[IQ]/) + expect(r.iecType).toMatch(/\S/) + } + }) + + it('omits CiA 402 objects that have no channel mapping', () => { + const device = loadDevice() + const enriched = enrichDeviceData(device) + // Channel info present, but no address mappings → nothing resolves. + expect(resolveCia402Objects(enriched.channelInfo, [])).toEqual([]) + }) + + it('defines the single-axis CiA 402 object set including mandatory objects', () => { + // Controlword + Statusword must be in the object table. + const indices = CIA402_OBJECTS.map((o) => o.index) + expect(indices).toContain(0x6040) + expect(indices).toContain(0x6041) + expect(DEFAULT_CIA402_AXIS_CONFIG.enabled).toBe(true) + expect(DEFAULT_CIA402_AXIS_CONFIG.scaleFactor).toBe(1) + }) +}) diff --git a/src/backend/shared/ethercat/__tests__/fixtures/cia402-servo-esi.xml b/src/backend/shared/ethercat/__tests__/fixtures/cia402-servo-esi.xml new file mode 100644 index 000000000..d30146a95 --- /dev/null +++ b/src/backend/shared/ethercat/__tests__/fixtures/cia402-servo-esi.xml @@ -0,0 +1,1398 @@ + + + + #x00000B95 + oss + + + + + groupType + groupName + + + + + cia402_id + cia402_drive_name + groupType + + 402 + 2 + + + + DT1018 + 144 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + 1 + Vendor ID + UDINT + 32 + 16 + + ro + + + + 2 + Product Code + UDINT + 32 + 48 + + ro + + + + 3 + Revision Number + UDINT + 32 + 80 + + ro + + + + 4 + Serial Number + UDINT + 32 + 112 + + ro + + + + + DT10F1 + 64 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + 1 + Local Error Reaction + UDINT + 32 + 16 + + rw + + + + 2 + SyncErrorCounterLimit + UINT + 16 + 48 + + rw + + + + + DT1600 + 80 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + 1 + Control Word + UDINT + 32 + 16 + + ro + + + + 2 + Target position + UDINT + 32 + 48 + + ro + + + + + DT1A00 + 80 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + 1 + Status Word + UDINT + 32 + 16 + + ro + + + + 2 + Position actual + UDINT + 32 + 48 + + ro + + + + + DT1C00ARR + USINT + 32 + + 1 + 4 + + + + DT1C00 + 48 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + Elements + DT1C00ARR + 32 + 16 + + ro + + + + + DT1C12ARR + UINT + 16 + + 1 + 1 + + + + DT1C12 + 32 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + Elements + DT1C12ARR + 16 + 16 + + ro + + + + + DT1C13ARR + UINT + 16 + + 1 + 1 + + + + DT1C13 + 32 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + Elements + DT1C13ARR + 16 + 16 + + ro + + + + + DT1C32 + 488 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + 1 + Sync mode + UINT + 16 + 16 + + rw + + + + 2 + CycleTime + UDINT + 32 + 32 + + ro + + + + 3 + ShiftTime + UDINT + 32 + 64 + + ro + + + + 4 + Sync modes supported + UINT + 16 + 96 + + ro + + + + 5 + Minimum Cycle Time + UDINT + 32 + 112 + + ro + + + + 6 + Calc and Copy Time + UDINT + 32 + 144 + + ro + + + + 7 + Minimum Delay Time + UDINT + 32 + 176 + + ro + + + + 8 + GetCycleTime + UINT + 16 + 208 + + rw + + + + 9 + DelayTime + UDINT + 32 + 224 + + ro + + + + 10 + Sync0CycleTime + UDINT + 32 + 256 + + ro + + + + 11 + SM event missed counter + UINT + 16 + 288 + + ro + + + + 12 + CycleTimeTooSmallCnt + UINT + 16 + 304 + + ro + + + + 13 + Shift too short counter + UINT + 16 + 320 + + ro + + + + 14 + RxPDOToggleFailed + UINT + 16 + 336 + + ro + + + + 15 + Minimum Cycle Distance + UDINT + 32 + 352 + + ro + + + + 16 + Maximum Cycle Distance + UDINT + 32 + 384 + + ro + + + + 17 + Minimum SM Sync Distance + UDINT + 32 + 416 + + ro + + + + 18 + Maximum SM Sync Distance + UDINT + 32 + 448 + + ro + + + + 32 + SyncError + BOOL + 1 + 480 + + ro + + + + + DT1C33 + 488 + + 0 + Max SubIndex + USINT + 8 + 0 + + ro + + + + 1 + Sync mode + UINT + 16 + 16 + + rw + + + + 2 + CycleTime + UDINT + 32 + 32 + + ro + + + + 3 + ShiftTime + UDINT + 32 + 64 + + ro + + + + 4 + Sync modes supported + UINT + 16 + 96 + + ro + + + + 5 + Minimum Cycle Time + UDINT + 32 + 112 + + ro + + + + 6 + Calc and Copy Time + UDINT + 32 + 144 + + ro + + + + 7 + Minimum Delay Time + UDINT + 32 + 176 + + ro + + + + 8 + GetCycleTime + UINT + 16 + 208 + + rw + + + + 9 + DelayTime + UDINT + 32 + 224 + + ro + + + + 10 + Sync0CycleTime + UDINT + 32 + 256 + + ro + + + + 11 + SM event missed counter + UINT + 16 + 288 + + ro + + + + 12 + CycleTimeTooSmallCnt + UINT + 16 + 304 + + ro + + + + 13 + Shift too short counter + UINT + 16 + 320 + + ro + + + + 14 + RxPDOToggleFailed + UINT + 16 + 336 + + ro + + + + 15 + Minimum Cycle Distance + UDINT + 32 + 352 + + ro + + + + 16 + Maximum Cycle Distance + UDINT + 32 + 384 + + ro + + + + 17 + Minimum SM Sync Distance + UDINT + 32 + 416 + + ro + + + + 18 + Maximum SM Sync Distance + UDINT + 32 + 448 + + ro + + + + 32 + SyncError + BOOL + 1 + 480 + + ro + + + + + SINT + 8 + + + BOOL + 1 + + + STRING(3) + 24 + + + UDINT + 32 + + + UINT + 16 + + + USINT + 8 + + + STRING(9) + 72 + + + + + #x1000 + Device Type + UDINT + 32 + + #x00020192 + + + ro + m + + + + #x1001 + Error register + USINT + 8 + + 0 + + + ro + + + + #x1008 + Device Name + STRING(9) + 72 + + cia402_id + + + ro + + + + #x1009 + Hardware Version + STRING(3) + 24 + + 1.0 + + + ro + o + + + + #x100A + Software Version + STRING(3) + 24 + + 1.0 + + + ro + + + + #x1018 + Identity Object + DT1018 + 144 + + + Max SubIndex + + 4 + + + + Vendor ID + + #x00000B95 + + + + Product Code + + #x00020192 + + + + Revision Number + + 42 + + + + Serial Number + + #x00000000 + + + + + ro + + + + #x10F1 + ErrorSettings + DT10F1 + 64 + + + Max SubIndex + + 2 + + + + Local Error Reaction + + 0 + + + + SyncErrorCounterLimit + + 200 + + + + + ro + + + + #x1600 + Control Position + DT1600 + 80 + + + Max SubIndex + + 2 + + + + Control Word + + #x60400010 + + + + Target position + + #x607A0020 + + + + + ro + + + + #x1A00 + Status Position + DT1A00 + 80 + + + Max SubIndex + + 2 + + + + Status Word + + #x60410010 + + + + Position actual + + #x60640020 + + + + + ro + + + + #x1C00 + Sync Manager Communication Type + DT1C00 + 48 + + + Max SubIndex + + 4 + + + + Communications Type SM0 + + 1 + + + + Communications Type SM1 + + 2 + + + + Communications Type SM2 + + 3 + + + + Communications Type SM3 + + 4 + + + + + ro + + + + #x1C12 + Sync Manager 2 PDO Assignment + DT1C12 + 32 + + + Max SubIndex + + 1 + + + + PDO Mapping + + #x1600 + + + + + ro + + + + #x1C13 + Sync Manager 3 PDO Assignment + DT1C13 + 32 + + + Max SubIndex + + 1 + + + + PDO Mapping + + #x1A00 + + + + + ro + + + + #x1C32 + Sync Manager 2 Parameters + DT1C32 + 488 + + + Max SubIndex + + 32 + + + + Sync mode + + 1 + + + + CycleTime + + 0 + + + + ShiftTime + + 0 + + + + Sync modes supported + + 6 + + + + Minimum Cycle Time + + 125000 + + + + Calc and Copy Time + + 0 + + + + Minimum Delay Time + + 0 + + + + GetCycleTime + + 0 + + + + DelayTime + + 0 + + + + Sync0CycleTime + + 0 + + + + SM event missed counter + + 0 + + + + CycleTimeTooSmallCnt + + 0 + + + + Shift too short counter + + 0 + + + + RxPDOToggleFailed + + 0 + + + + Minimum Cycle Distance + + 0 + + + + Maximum Cycle Distance + + 0 + + + + Minimum SM Sync Distance + + 0 + + + + Maximum SM Sync Distance + + 0 + + + + SyncError + + 0 + + + + + ro + + + + #x1C33 + Sync Manager 3 Parameters + DT1C33 + 488 + + + Max SubIndex + + 32 + + + + Sync mode + + 1 + + + + CycleTime + + 0 + + + + ShiftTime + + 0 + + + + Sync modes supported + + 6 + + + + Minimum Cycle Time + + 125000 + + + + Calc and Copy Time + + 0 + + + + Minimum Delay Time + + 0 + + + + GetCycleTime + + 0 + + + + DelayTime + + 0 + + + + Sync0CycleTime + + 0 + + + + SM event missed counter + + 0 + + + + CycleTimeTooSmallCnt + + 0 + + + + Shift too short counter + + 0 + + + + RxPDOToggleFailed + + 0 + + + + Minimum Cycle Distance + + 0 + + + + Maximum Cycle Distance + + 0 + + + + Minimum SM Sync Distance + + 0 + + + + Maximum SM Sync Distance + + 0 + + + + SyncError + + 0 + + + + + ro + + + + #x6040 + Control Word + UINT + 16 + + 0 + + + ro + R + + + + #x6041 + Status Word + UINT + 16 + + 0 + + + ro + T + + + + #x6060 + Modes of operation + SINT + 8 + + 8 + + + rw + + + + #x6061 + Mode of operation display + SINT + 8 + + 8 + + + ro + + + + #x6064 + Position actual + UDINT + 32 + + 0 + + + ro + T + + + + #x607A + Target position + UDINT + 32 + + 0 + + + ro + R + + + + #x6502 + Supported drive modes + UDINT + 32 + + 128 + + + ro + + + + + + Outputs + Inputs + MBoxState + MBoxOut + MBoxIn + Outputs + Inputs + + #x1600 + Control Position + + #x6040 + #x0 + 16 + Control Word + UINT + + + #x607a + #x0 + 32 + Target position + UDINT + + + + #x1A00 + Status Position + + #x6041 + #x0 + 16 + Status Word + UINT + + + #x6064 + #x0 + 32 + Position actual + UDINT + + + + + + + 2048 + 800603440A000000 + 0010000200120002 + + + + + \ No newline at end of file diff --git a/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts new file mode 100644 index 000000000..d1f7c3d35 --- /dev/null +++ b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import type { ConfiguredEtherCATDevice } from '@root/middleware/shared/ports/esi-types' +import type { PLCProjectData } from '@root/middleware/shared/ports/types' + +import { enrichDeviceData } from '../enrich-device-data' +import { parseESIDeviceFull } from '../esi-parser-main' +import { + generateSoftMotionArtifacts, + SM3_BRIDGE_INSTANCE_NAME, + SM3_BRIDGE_POU_NAME, + sanitizeAxisName, +} from '../generate-softmotion' + +const ESI_XML = readFileSync(resolve(__dirname, 'fixtures/cia402-servo-esi.xml'), 'utf-8') + +function makeDevice(name: string): ConfiguredEtherCATDevice { + const parsed = parseESIDeviceFull(ESI_XML, 0) + const enriched = enrichDeviceData(parsed.device!) + return { + id: 'dev-1', + name, + esiDeviceRef: { repositoryItemId: 'repo-1', deviceIndex: 0 }, + vendorId: '0x0', + productCode: '0x0', + revisionNo: '0x0', + addedFrom: 'repository', + config: {} as ConfiguredEtherCATDevice['config'], + ...enriched, + } +} + +function makeProject(devices: ConfiguredEtherCATDevice[]): PLCProjectData { + return { + dataTypes: [], + pous: [ + { + name: 'main', + pouType: 'program', + interface: { variables: [] }, + body: { language: 'st', value: '' }, + }, + ], + configurations: { + resource: { + tasks: [{ name: 'task0', triggering: 'Cyclic', interval: 'T#20ms', priority: 1 }], + instances: [{ name: 'instance0', task: 'task0', program: 'main' }], + globalVariables: [], + }, + }, + remoteDevices: [ + { name: 'ethercat-bus', protocol: 'ethercat', ethercatConfig: { devices } }, + ], + } +} + +describe('generateSoftMotionArtifacts', () => { + it('sanitizes device names into valid IEC identifiers', () => { + expect(sanitizeAxisName('X_Axis')).toBe('X_Axis') + expect(sanitizeAxisName('My Axis 01')).toBe('My_Axis_01') + expect(sanitizeAxisName('9drive')).toBe('_9drive') + }) + + it('is a no-op when there are no CiA 402 axes', () => { + const project = makeProject([]) + expect(generateSoftMotionArtifacts(project)).toBe(project) + }) + + it('is a no-op when the CiA 402 device is disabled', () => { + const dev = makeDevice('X_Axis') + dev.cia402 = { ...dev.cia402!, enabled: false } + const project = makeProject([dev]) + expect(generateSoftMotionArtifacts(project)).toBe(project) + }) + + it('generates the AXIS_REF_SM3 global named after the device', () => { + const project = makeProject([makeDevice('X_Axis')]) + const out = generateSoftMotionArtifacts(project) + const globals = out.configurations.resource.globalVariables + const axis = globals.find((g) => g.name === 'X_Axis') + expect(axis).toBeDefined() + expect(axis!.type).toEqual({ definition: 'derived', value: 'AXIS_REF_SM3' }) + expect(axis!.location).toBe('') + }) + + it('generates located scalar globals bound to the drive PDO addresses', () => { + const project = makeProject([makeDevice('X_Axis')]) + const globals = generateSoftMotionArtifacts(project).configurations.resource.globalVariables + const ctrl = globals.find((g) => g.name === 'X_Axis_controlWord') + const status = globals.find((g) => g.name === 'X_Axis_statusWord') + const target = globals.find((g) => g.name === 'X_Axis_targetPosition') + expect(ctrl?.type.value).toBe('uint') + expect(ctrl?.location).toMatch(/^%Q/) + expect(status?.type.value).toBe('uint') + expect(status?.location).toMatch(/^%I/) + expect(target?.type.value).toBe('dint') // forced to DINT for bridge compatibility + expect(target?.location).toMatch(/^%Q/) + }) + + it('generates a bridge program with an SM_Drive instance and pin bindings', () => { + const project = makeProject([makeDevice('X_Axis')]) + const out = generateSoftMotionArtifacts(project) + const bridge = out.pous.find((p) => p.name === SM3_BRIDGE_POU_NAME) + expect(bridge).toBeDefined() + expect(bridge!.pouType).toBe('program') + const fbVar = bridge!.interface!.variables.find((v) => v.name === 'X_Axis_drive') + expect(fbVar!.type).toEqual({ definition: 'derived', value: 'SM_Drive_GenericDS402' }) + const body = bridge!.body.value as string + // input pins bound with :=, output pins captured with => + expect(body).toContain('Axis := X_Axis') + expect(body).toContain('wStatusWord := X_Axis_statusWord') + expect(body).toContain('diActualPosition := X_Axis_positionActual') + expect(body).toContain('wControlWord => X_Axis_controlWord') + expect(body).toContain('diTargetPosition => X_Axis_targetPosition') + // scaling applied + expect(body).toContain('X_Axis.fScalefactor :=') + }) + + it('injects VAR_EXTERNAL for the axis into a user program that references it', () => { + const project = makeProject([makeDevice('X_Axis')]) + project.pous[0].body = { language: 'st', value: 'pwr(Axis := X_Axis, Enable := TRUE);' } + const out = generateSoftMotionArtifacts(project) + const main = out.pous.find((p) => p.name === 'main')! + const ext = main.interface!.variables.find((v) => v.name === 'X_Axis') + expect(ext).toBeDefined() + expect(ext!.class).toBe('external') + expect(ext!.type).toEqual({ definition: 'derived', value: 'AXIS_REF_SM3' }) + }) + + it('does not double-declare an axis the user already declared', () => { + const project = makeProject([makeDevice('X_Axis')]) + project.pous[0].interface = { + variables: [ + { name: 'X_Axis', class: 'external', type: { definition: 'derived', value: 'AXIS_REF_SM3' }, location: '', documentation: '' }, + ], + } + project.pous[0].body = { language: 'st', value: 'pwr(Axis := X_Axis);' } + const out = generateSoftMotionArtifacts(project) + const main = out.pous.find((p) => p.name === 'main')! + expect(main.interface!.variables.filter((v) => v.name === 'X_Axis')).toHaveLength(1) + }) + + it('detects axis references in graphical (non-string) POU bodies', () => { + const project = makeProject([makeDevice('X_Axis')]) + // A function POU is left untouched; a graphical program referencing the axis gets the external. + project.pous[0].body = { language: 'fbd', value: { rung: { nodes: [{ variable: 'X_Axis' }] } } } as never + const out = generateSoftMotionArtifacts(project) + const main = out.pous.find((p) => p.name === 'main')! + expect(main.interface!.variables.some((v) => v.name === 'X_Axis' && v.class === 'external')).toBe(true) + }) + + it('leaves non-program POUs untouched', () => { + const project = makeProject([makeDevice('X_Axis')]) + project.pous.push({ + name: 'helper', + pouType: 'function-block', + interface: { variables: [] }, + body: { language: 'st', value: 'x := X_Axis.fActPosition;' }, + }) + const out = generateSoftMotionArtifacts(project) + const helper = out.pous.find((p) => p.name === 'helper')! + expect(helper.interface!.variables.some((v) => v.name === 'X_Axis')).toBe(false) + }) + + it('runs the bridge first each scan (instance unshifted to the front)', () => { + const project = makeProject([makeDevice('X_Axis')]) + const instances = generateSoftMotionArtifacts(project).configurations.resource.instances + expect(instances[0].name).toBe(SM3_BRIDGE_INSTANCE_NAME) + expect(instances[0].program).toBe(SM3_BRIDGE_POU_NAME) + expect(instances[0].task).toBe('task0') + expect(instances.map((i) => i.name)).toContain('instance0') + }) + + it('honors configured scaling in the generated bridge', () => { + const dev = makeDevice('X_Axis') + dev.cia402 = { enabled: true, scaleNum: 1, scaleDenom: 1, scaleFactor: 1000 } + const out = generateSoftMotionArtifacts(makeProject([dev])) + const body = out.pous.find((p) => p.name === SM3_BRIDGE_POU_NAME)!.body.value as string + expect(body).toContain('X_Axis.fScalefactor := 1000.0;') + }) + + describe('edge cases', () => { + it('ignores non-ethercat remote devices', () => { + const project = makeProject([]) + project.remoteDevices = [{ name: 'mb', protocol: 'modbus-tcp' }] + expect(generateSoftMotionArtifacts(project)).toBe(project) + }) + + it('handles a remote device with no ethercatConfig', () => { + const project = makeProject([]) + project.remoteDevices = [{ name: 'ec', protocol: 'ethercat' }] + expect(generateSoftMotionArtifacts(project)).toBe(project) + }) + + it('skips an enabled device whose mandatory objects are unmapped', () => { + const dev = makeDevice('X_Axis') + dev.channelMappings = [] // nothing resolves -> no controlWord/statusWord + const project = makeProject([dev]) + expect(generateSoftMotionArtifacts(project)).toBe(project) + }) + + it('skips a device whose channelInfo is absent', () => { + const dev = makeDevice('X_Axis') + dev.channelInfo = undefined + const project = makeProject([dev]) + expect(generateSoftMotionArtifacts(project)).toBe(project) + }) + + it('deduplicates axes that sanitize to the same identifier', () => { + const a = makeDevice('X Axis') + const b = makeDevice('X_Axis') // both -> X_Axis + a.id = 'a' + b.id = 'b' + const out = generateSoftMotionArtifacts(makeProject([a, b])) + const axisGlobals = out.configurations.resource.globalVariables.filter( + (g) => g.name === 'X_Axis', + ) + expect(axisGlobals).toHaveLength(1) + }) + + it('handles a project with no remoteDevices field', () => { + const project = makeProject([]) + delete project.remoteDevices + expect(generateSoftMotionArtifacts(project)).toBe(project) + }) + + it('emits fractional scale factors verbatim', () => { + const dev = makeDevice('X_Axis') + dev.cia402 = { enabled: true, scaleNum: 1, scaleDenom: 1, scaleFactor: 0.5 } + const out = generateSoftMotionArtifacts(makeProject([dev])) + const body = out.pous.find((p) => p.name === SM3_BRIDGE_POU_NAME)!.body.value as string + expect(body).toContain('X_Axis.fScalefactor := 0.5;') + }) + + it('creates a fallback cyclic task when the resource has none', () => { + const project = makeProject([makeDevice('X_Axis')]) + project.configurations.resource.tasks = [] + project.configurations.resource.instances = [] + const out = generateSoftMotionArtifacts(project) + expect(out.configurations.resource.tasks).toHaveLength(1) + expect(out.configurations.resource.tasks[0].triggering).toBe('Cyclic') + expect(out.configurations.resource.instances[0].task).toBe( + out.configurations.resource.tasks[0].name, + ) + }) + }) +}) diff --git a/src/backend/shared/ethercat/cia402.ts b/src/backend/shared/ethercat/cia402.ts new file mode 100644 index 000000000..b360d47e0 --- /dev/null +++ b/src/backend/shared/ethercat/cia402.ts @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * CiA 402 (DS402) SoftMotion axis recognition & mapping. + * + * Detects whether an EtherCAT device is a CiA 402 servo drive and resolves its + * standard CiA 402 objects (Controlword, Statusword, Target/Actual position, + * …) to the editor-allocated IEC located addresses. This is the bridge between + * a generic EtherCAT slave and the strucpp AXIS_REF_SM3 / MC_* motion library: + * a recognized drive becomes a SoftMotion axis whose name is used directly in + * MC_*(Axis := ) calls, with the glue generated at compile time + * (see generate-softmotion.ts). + */ + +import type { + Cia402AxisConfig, + ESIDevice, + ESIPdo, + EtherCATChannelMapping, + PersistedChannelInfo, +} from '@root/middleware/shared/ports/esi-types' + +export type { Cia402AxisConfig } + +/** A CiA 402 object role used by the AXIS_REF_SM3 drive bridge. */ +export type Cia402Role = + | 'controlWord' + | 'modesOfOperation' + | 'targetPosition' + | 'profileVelocity' + | 'targetVelocity' + | 'targetTorque' + | 'statusWord' + | 'modesDisplay' + | 'positionActual' + | 'velocityActual' + | 'torqueActual' + +interface Cia402ObjectDef { + role: Cia402Role + index: number + direction: 'output' | 'input' +} + +/** + * The single-axis CiA 402 object dictionary entries the drive bridge maps. + * Controlword/Statusword are mandatory; the rest are optional and only wired + * when the drive exposes them as PDOs. + */ +export const CIA402_OBJECTS: readonly Cia402ObjectDef[] = [ + { role: 'controlWord', index: 0x6040, direction: 'output' }, + { role: 'modesOfOperation', index: 0x6060, direction: 'output' }, + { role: 'targetPosition', index: 0x607a, direction: 'output' }, + { role: 'profileVelocity', index: 0x6081, direction: 'output' }, + { role: 'targetVelocity', index: 0x60ff, direction: 'output' }, + { role: 'targetTorque', index: 0x6071, direction: 'output' }, + { role: 'statusWord', index: 0x6041, direction: 'input' }, + { role: 'modesDisplay', index: 0x6061, direction: 'input' }, + { role: 'positionActual', index: 0x6064, direction: 'input' }, + { role: 'velocityActual', index: 0x606c, direction: 'input' }, + { role: 'torqueActual', index: 0x6077, direction: 'input' }, +] + +/** The CiA 402 objects that MUST be present for a device to be an axis. */ +const MANDATORY_INDICES = [0x6040, 0x6041] as const + +/** + * Default axis config for a newly-recognized drive (1:1 scaling). See the + * Cia402AxisConfig type in the esi-types ports module. + */ +export const DEFAULT_CIA402_AXIS_CONFIG: Cia402AxisConfig = { + enabled: true, + scaleNum: 1, + scaleDenom: 1, + scaleFactor: 1, +} + +/** Parse a hex object index in any ESI form (`#x6040`, `0x6040`, `6040`, `24640`). */ +export function normalizeObjectIndex(raw: string | number): number { + if (typeof raw === 'number') return raw + const s = raw.trim().replace(/^#x/i, '').replace(/^0x/i, '') + // ESI indices are hex; a bare token like "6040" is hex, not decimal. Reject + // anything that isn't a pure hex string (parseInt would leniently read a + // leading hex prefix like "b" out of "bogus"). + if (!/^[0-9a-f]+$/i.test(s)) return -1 + return parseInt(s, 16) +} + +function pdosContainIndex(pdos: ESIPdo[], index: number): boolean { + return pdos.some((pdo) => pdo.entries.some((e) => normalizeObjectIndex(e.index) === index)) +} + +/** + * True when the device exposes the mandatory CiA 402 objects as PDOs + * (Controlword out, Statusword in) — i.e. it can be driven as a SoftMotion axis. + */ +export function isCia402Drive(device: ESIDevice): boolean { + return ( + pdosContainIndex(device.rxPdo, MANDATORY_INDICES[0]) && + pdosContainIndex(device.txPdo, MANDATORY_INDICES[1]) + ) +} + +/** A resolved CiA 402 object: its role, IEC located address, and IEC type. */ +export interface ResolvedCia402Object { + role: Cia402Role + index: number + iecLocation: string + iecType: string +} + +/** + * Resolve the CiA 402 objects present on a device to their editor-allocated IEC + * located addresses, by joining channel metadata (which carries the object + * index) with the channel mappings (which carry the allocated address) on + * `channelId`. Objects without a mapping (unassigned) are omitted. + */ +export function resolveCia402Objects( + channelInfo: PersistedChannelInfo[], + mappings: EtherCATChannelMapping[], +): ResolvedCia402Object[] { + const locationByChannel = new Map(mappings.map((m) => [m.channelId, m.iecLocation])) + const resolved: ResolvedCia402Object[] = [] + for (const def of CIA402_OBJECTS) { + const info = channelInfo.find( + (c) => normalizeObjectIndex(c.entryIndex) === def.index && c.direction === def.direction, + ) + if (!info) continue + const iecLocation = locationByChannel.get(info.channelId) + if (!iecLocation) continue + resolved.push({ role: def.role, index: def.index, iecLocation, iecType: info.iecType }) + } + return resolved +} diff --git a/src/backend/shared/ethercat/enrich-device-data.ts b/src/backend/shared/ethercat/enrich-device-data.ts index 7d04d2eab..7926238f0 100644 --- a/src/backend/shared/ethercat/enrich-device-data.ts +++ b/src/backend/shared/ethercat/enrich-device-data.ts @@ -15,6 +15,7 @@ import type { SDOConfigurationEntry, } from '@root/middleware/shared/ports/esi-types' +import { type Cia402AxisConfig, DEFAULT_CIA402_AXIS_CONFIG, isCia402Drive } from './cia402' import { esiTypeToIecType, generateDefaultChannelMappings, pdoToChannels } from './esi-parser' import { extractDefaultSdoConfigurations } from './sdo-config-defaults' @@ -113,6 +114,7 @@ export function enrichDeviceData( slaveType: string sdoConfigurations?: SDOConfigurationEntry[] channelMappings: EtherCATChannelMapping[] + cia402?: Cia402AxisConfig } { return { channelInfo: buildChannelInfo(device), @@ -121,5 +123,8 @@ export function enrichDeviceData( slaveType: deriveSlaveType(device), sdoConfigurations: device.coeObjects?.length ? extractDefaultSdoConfigurations(device.coeObjects) : undefined, channelMappings: generateDefaultChannelMappings(pdoToChannels(device), usedAddresses), + // A CiA 402 servo is auto-recognized as a SoftMotion axis; the user can + // disable/tune it in the device's Axis configuration. + cia402: isCia402Drive(device) ? { ...DEFAULT_CIA402_AXIS_CONFIG } : undefined, } } diff --git a/src/backend/shared/ethercat/generate-softmotion.ts b/src/backend/shared/ethercat/generate-softmotion.ts new file mode 100644 index 000000000..06568034e --- /dev/null +++ b/src/backend/shared/ethercat/generate-softmotion.ts @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * Compile-time SoftMotion code generation. + * + * Turns each CiA 402 EtherCAT drive (recognized by cia402.ts, opted-in via its + * Cia402AxisConfig) into the ST glue that lets CODESYS-style application code + * run unmodified: + * + * - an AXIS_REF_SM3 global named after the device (so `MC_Power(Axis := X_Axis)` + * resolves directly to the drive), + * - a located scalar global per mapped CiA 402 PDO object, bound to the + * editor-allocated %I/%Q address, + * - a per-scan `__sm3_bridge` PROGRAM (run first each cycle) that calls + * SM_Drive_GenericDS402 to marshal the PDO image <-> the axis and apply the + * configured scaling. + * + * The user never maps an address: the EtherCAT device's name IS the axis name. + * Called from preprocessPous so every compile path (build/download/deploy/debug) + * gets the generated artifacts. Pure: returns a new PLCProjectData, never + * mutates the input. + */ + +import type { PLCInstance, PLCPou, PLCProjectData, PLCTask, PLCVariable } from '@root/middleware/shared/ports/types' + +import type { Cia402Role } from './cia402' +import { resolveCia402Objects } from './cia402' + +export const SM3_BRIDGE_POU_NAME = '__sm3_bridge' +export const SM3_BRIDGE_INSTANCE_NAME = '__sm3_bridge_inst' +const SM3_FALLBACK_TASK: PLCTask = { + name: '__sm3_task', + triggering: 'Cyclic', + interval: 'T#10ms', + priority: 0, +} + +/** + * Maps a CiA 402 object role to the SM_Drive_GenericDS402 pin it binds and the + * IEC type the located scalar is declared with. The scalar type is fixed to the + * bridge pin's type (not the ESI-declared type) so the generated FB call is + * always type-correct — the PDO byte width is identical either way (a 32-bit + * position reads the same 4 bytes as DINT or UDINT at the same %QD address). + * `pinKind` decides `:=` (FB input, drive feedback) vs `=>` (FB output, command). + */ +interface RoleBinding { + pin: string + pinKind: 'input' | 'output' + iecType: string +} +const ROLE_BINDINGS: Record = { + controlWord: { pin: 'wControlWord', pinKind: 'output', iecType: 'UINT' }, + modesOfOperation: { pin: 'siModes', pinKind: 'output', iecType: 'SINT' }, + targetPosition: { pin: 'diTargetPosition', pinKind: 'output', iecType: 'DINT' }, + profileVelocity: { pin: 'udiProfileVelocity', pinKind: 'output', iecType: 'UDINT' }, + targetVelocity: { pin: 'diTargetVelocity', pinKind: 'output', iecType: 'DINT' }, + targetTorque: { pin: 'iTargetTorque', pinKind: 'output', iecType: 'INT' }, + statusWord: { pin: 'wStatusWord', pinKind: 'input', iecType: 'UINT' }, + modesDisplay: { pin: 'siModesDisplay', pinKind: 'input', iecType: 'SINT' }, + positionActual: { pin: 'diActualPosition', pinKind: 'input', iecType: 'DINT' }, + velocityActual: { pin: 'diActualVelocity', pinKind: 'input', iecType: 'DINT' }, + torqueActual: { pin: 'iActualTorque', pinKind: 'input', iecType: 'INT' }, +} + +/** Sanitize a device name into a valid IEC 61131-3 identifier. */ +export function sanitizeAxisName(name: string): string { + let s = name.replace(/[^A-Za-z0-9_]/g, '_') + if (!/^[A-Za-z_]/.test(s)) s = `_${s}` + return s +} + +function lrealLiteral(n: number): string { + return Number.isInteger(n) ? `${n}.0` : `${n}` +} + +function global(name: string, typeValue: string, definition: 'base-type' | 'derived', location: string): PLCVariable { + return { name, type: { definition, value: typeValue }, location, documentation: '' } +} + +/** A VAR_EXTERNAL declaration referencing a configuration global. */ +function external(name: string, typeValue: string, definition: 'base-type' | 'derived'): PLCVariable { + return { name, class: 'external', type: { definition, value: typeValue }, location: '', documentation: '' } +} + +/** A POU-local variable (e.g. the bridge FB instance). */ +function local(name: string, typeValue: string, definition: 'base-type' | 'derived'): PLCVariable { + return { name, class: 'local', type: { definition, value: typeValue }, location: '', documentation: '' } +} + +/** True when a POU body (ST text or a serialized graphical body) references `identifier`. */ +function bodyReferences(bodyValue: unknown, identifier: string): boolean { + const text = typeof bodyValue === 'string' ? bodyValue : JSON.stringify(bodyValue ?? '') + return new RegExp(`\\b${identifier}\\b`).test(text) +} + +interface AxisPlan { + axisName: string + scaleNum: number + scaleDenom: number + scaleFactor: number + objects: { role: Cia402Role; scalarName: string; iecLocation: string; binding: RoleBinding }[] +} + +/** Collect every opted-in, resolvable CiA 402 axis in the project. */ +function collectAxes(project: PLCProjectData): AxisPlan[] { + const plans: AxisPlan[] = [] + const seen = new Set() + for (const rd of project.remoteDevices ?? []) { + if (rd.protocol !== 'ethercat') continue + for (const dev of rd.ethercatConfig?.devices ?? []) { + if (!dev.cia402?.enabled) continue + const resolved = resolveCia402Objects(dev.channelInfo ?? [], dev.channelMappings) + const hasControl = resolved.some((o) => o.role === 'controlWord') + const hasStatus = resolved.some((o) => o.role === 'statusWord') + // A drive can only be an axis if both mandatory objects are mapped. + if (!hasControl || !hasStatus) continue + + const axisName = sanitizeAxisName(dev.name) + // Skip a duplicate sanitized name to avoid emitting two globals with the + // same identifier (later devices lose — surfaced by compile if referenced). + if (seen.has(axisName.toUpperCase())) continue + seen.add(axisName.toUpperCase()) + + plans.push({ + axisName, + scaleNum: dev.cia402.scaleNum, + scaleDenom: dev.cia402.scaleDenom, + scaleFactor: dev.cia402.scaleFactor, + objects: resolved.map((o) => ({ + role: o.role, + scalarName: `${axisName}_${o.role}`, + iecLocation: o.iecLocation, + binding: ROLE_BINDINGS[o.role], + })), + }) + } + } + return plans +} + +/** + * Inject generated SoftMotion globals + the per-scan bridge program for every + * CiA 402 axis in the project. No-op (returns the input) when there are none. + */ +export function generateSoftMotionArtifacts(project: PLCProjectData): PLCProjectData { + const axes = collectAxes(project) + if (axes.length === 0) return project + + const newGlobals: PLCVariable[] = [] + const bridgeVars: PLCVariable[] = [] + const bodyLines: string[] = [] + + for (const axis of axes) { + // AXIS_REF_SM3 instance (the name used in MC_*(Axis := ...)) — a config + // global; the bridge reaches it via VAR_EXTERNAL. + newGlobals.push(global(axis.axisName, 'AXIS_REF_SM3', 'derived', '')) + bridgeVars.push(external(axis.axisName, 'AXIS_REF_SM3', 'derived')) + + bodyLines.push(`(* ---- SoftMotion axis ${axis.axisName} ---- *)`) + // Apply configured scaling each scan (device config is authoritative). + bodyLines.push(`${axis.axisName}.iRatioTechUnitsNum := DINT#${Math.trunc(axis.scaleNum)};`) + bodyLines.push(`${axis.axisName}.dwRatioTechUnitsDenom := DWORD#${Math.trunc(axis.scaleDenom)};`) + bodyLines.push(`${axis.axisName}.fScalefactor := ${lrealLiteral(axis.scaleFactor)};`) + + const inBinds: string[] = [] + const outBinds: string[] = [] + for (const obj of axis.objects) { + const iecType = obj.binding.iecType.toLowerCase() + // located scalar global bound to the drive PDO address... + newGlobals.push(global(obj.scalarName, iecType, 'base-type', obj.iecLocation)) + // ...and the bridge's VAR_EXTERNAL view of it. + bridgeVars.push(external(obj.scalarName, iecType, 'base-type')) + if (obj.binding.pinKind === 'input') inBinds.push(`${obj.binding.pin} := ${obj.scalarName}`) + else outBinds.push(`${obj.binding.pin} => ${obj.scalarName}`) + } + + const fbInstance = `${axis.axisName}_drive` + bridgeVars.push(local(fbInstance, 'SM_Drive_GenericDS402', 'derived')) + const binds = [`Axis := ${axis.axisName}`, ...inBinds, 'bOnline := TRUE', ...outBinds] + bodyLines.push(`${fbInstance}(`) + bodyLines.push(`\t${binds.join(',\n\t')});`) + } + + const bridgePou: PLCPou = { + name: SM3_BRIDGE_POU_NAME, + pouType: 'program', + interface: { variables: bridgeVars }, + body: { language: 'st', value: bodyLines.join('\n') }, + documentation: 'Auto-generated SoftMotion drive bridge — do not edit; regenerated each compile.', + } + + // Inject a VAR_EXTERNAL for each axis into user programs that reference it, so + // `MC_*(Axis := X_Axis)` resolves without the user declaring the global. + const patchedPous = project.pous.map((pou) => { + if (pou.pouType !== 'program') return pou + const declared = new Set((pou.interface?.variables ?? []).map((v) => v.name.toUpperCase())) + const toAdd = axes + .filter((a) => !declared.has(a.axisName.toUpperCase()) && bodyReferences(pou.body.value, a.axisName)) + .map((a) => external(a.axisName, 'AXIS_REF_SM3', 'derived')) + if (toAdd.length === 0) return pou + return { + ...pou, + interface: { ...pou.interface, variables: [...(pou.interface?.variables ?? []), ...toAdd] }, + } + }) + + const resource = project.configurations.resource + // Ensure a task exists to run the bridge, then attach the bridge instance at + // the FRONT of the instance list so it runs before user POUs each scan + // (fresh PDO feedback in, commands out). + const tasks = resource.tasks.length > 0 ? resource.tasks : [SM3_FALLBACK_TASK] + const bridgeInstance: PLCInstance = { + name: SM3_BRIDGE_INSTANCE_NAME, + task: tasks[0].name, + program: SM3_BRIDGE_POU_NAME, + } + + return { + ...project, + pous: [...patchedPous, bridgePou], + configurations: { + ...project.configurations, + resource: { + ...resource, + tasks, + globalVariables: [...resource.globalVariables, ...newGlobals], + instances: [bridgeInstance, ...resource.instances], + }, + }, + } +} diff --git a/src/backend/shared/types/PLC/open-plc.ts b/src/backend/shared/types/PLC/open-plc.ts index 4a5fa1b57..31c808344 100644 --- a/src/backend/shared/types/PLC/open-plc.ts +++ b/src/backend/shared/types/PLC/open-plc.ts @@ -705,6 +705,21 @@ const SDOConfigurationEntrySchema = z.object({ objectName: z.string(), }) +/** + * CiA 402 SoftMotion axis configuration on a recognized EtherCAT drive. When + * present and enabled, the device is treated as a SoftMotion axis: at compile + * time the editor generates an AXIS_REF_SM3 global (named after the device), + * located scalar globals bound to the drive's CiA 402 PDO addresses, and a + * per-scan SM_Drive_GenericDS402 bridge. Scaling mirrors the AXIS_REF_SM3 + * fields (increments per unit = scaleFactor * scaleNum / scaleDenom). + */ +const Cia402AxisConfigSchema = z.object({ + enabled: z.boolean(), + scaleNum: z.number(), + scaleDenom: z.number(), + scaleFactor: z.number(), +}) + const ConfiguredEtherCATDeviceSchema = z.object({ id: z.string(), position: z.number().optional(), @@ -721,6 +736,8 @@ const ConfiguredEtherCATDeviceSchema = z.object({ txPdos: z.array(PersistedPdoSchema).optional(), slaveType: z.string().optional(), sdoConfigurations: z.array(SDOConfigurationEntrySchema).optional(), + /** Present when this drive is a CiA 402 SoftMotion axis (see schema above). */ + cia402: Cia402AxisConfigSchema.optional(), }) const EtherCATMasterConfigSchema = z.object({ diff --git a/src/backend/shared/utils/PLC/__tests__/preprocess-pous.test.ts b/src/backend/shared/utils/PLC/__tests__/preprocess-pous.test.ts index 109380992..7fab4705b 100644 --- a/src/backend/shared/utils/PLC/__tests__/preprocess-pous.test.ts +++ b/src/backend/shared/utils/PLC/__tests__/preprocess-pous.test.ts @@ -310,4 +310,51 @@ describe('preprocessPous — mixed', () => { const { projectData } = preprocessPous(project, false, logger.log) expect(projectData.originalCppPous).toBeUndefined() }) + + it('generates SoftMotion axis artifacts for a CiA 402 EtherCAT drive', () => { + const project: PLCProjectData = { + ...makeProjectData([makeStPou('main', 'pwr(Axis := X_Axis, Enable := TRUE);')]), + remoteDevices: [ + { + name: 'ethercat-bus', + protocol: 'ethercat', + ethercatConfig: { + devices: [ + { + id: 'd1', + name: 'X_Axis', + esiDeviceRef: { repositoryItemId: 'r', deviceIndex: 0 }, + vendorId: '0x0', + productCode: '0x0', + revisionNo: '0x0', + addedFrom: 'repository', + config: {}, + cia402: { enabled: true, scaleNum: 1, scaleDenom: 1, scaleFactor: 1 }, + channelInfo: [ + { channelId: 'c1', name: 'Controlword', direction: 'output', pdoIndex: '0x1600', entryIndex: '0x6040', entrySubIndex: '0x0', dataType: 'UINT', bitLen: 16, iecType: 'UINT' }, + { channelId: 'c2', name: 'Statusword', direction: 'input', pdoIndex: '0x1A00', entryIndex: '0x6041', entrySubIndex: '0x0', dataType: 'UINT', bitLen: 16, iecType: 'UINT' }, + ], + channelMappings: [ + { channelId: 'c1', iecLocation: '%QW0' }, + { channelId: 'c2', iecLocation: '%IW0' }, + ], + }, + ], + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + } + const logger = collectLog() + const { projectData } = preprocessPous(project, false, logger.log) + // Bridge program + axis global generated; VAR_EXTERNAL injected into main. + expect(projectData.pous.some((p) => p.name === '__sm3_bridge')).toBe(true) + expect( + projectData.configurations.resource.globalVariables.some( + (g) => g.name === 'X_Axis' && g.type.value === 'AXIS_REF_SM3', + ), + ).toBe(true) + const main = projectData.pous.find((p) => p.name === 'main')! + expect(main.interface!.variables.some((v) => v.name === 'X_Axis' && v.class === 'external')).toBe(true) + }) }) diff --git a/src/backend/shared/utils/PLC/preprocess-pous.ts b/src/backend/shared/utils/PLC/preprocess-pous.ts index bced661cf..3e2760274 100644 --- a/src/backend/shared/utils/PLC/preprocess-pous.ts +++ b/src/backend/shared/utils/PLC/preprocess-pous.ts @@ -5,6 +5,7 @@ import { addPythonLocalVariables } from '../../../../frontend/utils/python/addPy import { generateSTCode } from '../../../../frontend/utils/python/generateSTCode' import { injectPythonCode } from '../../../../frontend/utils/python/injectPythonCode' import type { PLCPou, PLCProjectData, PLCVariable } from '../../../../middleware/shared/ports/types' +import { generateSoftMotionArtifacts } from '../../ethercat/generate-softmotion' type CppPouData = { name: string @@ -174,6 +175,14 @@ function preprocessPous(projectData: PLCProjectData, isSimulator: boolean, log: log('info', `Successfully processed ${cppPous.length} C/C++ POU(s)`) } + // --- SoftMotion: generate AXIS_REF_SM3 globals + PDO scalars + drive bridge + // for CiA 402 EtherCAT axes (no-op when the project has none). --- + const withMotion = generateSoftMotionArtifacts(processedProjectData) as ProjectDataWithCpp + if (withMotion !== processedProjectData) { + processedProjectData = withMotion + log('info', 'Generated SoftMotion axis bindings for CiA 402 drive(s)') + } + return { projectData: processedProjectData as ProjectDataWithCpp, validationFailed: false } } diff --git a/src/middleware/shared/ports/esi-types.ts b/src/middleware/shared/ports/esi-types.ts index fbc4f01e7..4de4fc9aa 100644 --- a/src/middleware/shared/ports/esi-types.ts +++ b/src/middleware/shared/ports/esi-types.ts @@ -493,6 +493,26 @@ export interface ConfiguredEtherCATDevice { slaveType?: string /** SDO startup parameters extracted from CoE Object Dictionary */ sdoConfigurations?: SDOConfigurationEntry[] + /** CiA 402 SoftMotion axis configuration (present when recognized as a drive) */ + cia402?: Cia402AxisConfig +} + +/** + * Per-axis CiA 402 SoftMotion configuration persisted on a recognized drive. + * Mirrors the AXIS_REF_SM3 scaling fields; increments-per-unit is derived as + * scaleFactor * scaleNum / scaleDenom. When `enabled`, the compile step + * generates the AXIS_REF_SM3 global, located PDO scalars, and the per-scan + * SM_Drive_GenericDS402 bridge for this device. + */ +export interface Cia402AxisConfig { + /** TRUE = treat this EtherCAT device as a SoftMotion axis. */ + enabled: boolean + /** iRatioTechUnitsNum (CODESYS param 1052). */ + scaleNum: number + /** dwRatioTechUnitsDenom (CODESYS param 1051). */ + scaleDenom: number + /** fScalefactor (CODESYS param 1054) — increments per user unit. */ + scaleFactor: number } // ===================== PER-SLAVE CONFIGURATION ===================== From f4a934957ea5c6618389264ae3740f9ea0000990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Mon, 6 Jul 2026 09:22:48 -0300 Subject: [PATCH 10/52] fix: keep sidebar size on context switch and hide stray activity-bar divider The Explorer and Source Control contexts each mounted their own ResizablePanel (different ids), so every context switch made react-resizable-panels rebuild the layout from defaultSize and re-normalize it (the group's defaultSizes sum to less than 100), inflating the captured getSize() value on every switch until the 32% fixed point. The left slot is now a single always-mounted ResizablePanel owned by the workspace screen; only its children swap, so the width is preserved natively and the size-capture plumbing is gone. The activity-bar divider under the context buttons was rendered unconditionally, leaving a stray separator on desktop where the buttons are hidden (no version control capability). It now renders only when at least one context button is present. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015fpthmAmQoNX8WMYyb68Qi --- .../source-control/source-control-panel.tsx | 123 ++++++++---------- .../components/_organisms/explorer/index.tsx | 23 +--- .../workspace-activity-bar/index.tsx | 2 +- src/frontend/screens/workspace-screen.tsx | 51 ++++---- 4 files changed, 83 insertions(+), 116 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/source-control/source-control-panel.tsx b/src/frontend/components/_features/[workspace]/source-control/source-control-panel.tsx index 32cb0cb3c..c89985c45 100644 --- a/src/frontend/components/_features/[workspace]/source-control/source-control-panel.tsx +++ b/src/frontend/components/_features/[workspace]/source-control/source-control-panel.tsx @@ -1,94 +1,79 @@ import { GitBranch } from 'lucide-react' -import { LegacyRef, useCallback, useState } from 'react' -import { ImperativePanelHandle } from 'react-resizable-panels' +import { useCallback, useState } from 'react' import { useOpenPLCStore } from '../../../../store' import { cn } from '../../../../utils/cn' -import { ResizablePanel } from '../../../_organisms/panel' import { ChangesSection } from './changes-section' import { HistorySection } from './history-section' import { StashSection } from './stash-section' type SourceControlPanelProps = { - collapse: LegacyRef | undefined - defaultSize?: number projectId: string } type ActiveView = 'changes' | 'history' | 'stash' -const SourceControlPanel = ({ collapse, defaultSize = 16, projectId }: SourceControlPanelProps) => { +const SourceControlPanel = ({ projectId }: SourceControlPanelProps) => { const [activeView, setActiveView] = useState('changes') const pendingChangesCount = useOpenPLCStore(useCallback((s) => s.versionControl.pendingChangesCount, [])) return ( - -
- {/* Header */} -
-
- - - Source Control - -
+
+ {/* Header */} +
+
+ + + Source Control +
+
- {/* Tab switcher */} -
- - - -
+ {/* Tab switcher */} +
+ + + +
- {/* Content */} -
- {activeView === 'changes' && } - {activeView === 'stash' && } - {activeView === 'history' && } -
+ {/* Content */} +
+ {activeView === 'changes' && } + {activeView === 'stash' && } + {activeView === 'history' && }
- +
) } diff --git a/src/frontend/components/_organisms/explorer/index.tsx b/src/frontend/components/_organisms/explorer/index.tsx index e2c74e36b..2a0f26fb8 100644 --- a/src/frontend/components/_organisms/explorer/index.tsx +++ b/src/frontend/components/_organisms/explorer/index.tsx @@ -1,5 +1,4 @@ -import { LegacyRef, ReactElement, useMemo, useState } from 'react' -import { ImperativePanelHandle } from 'react-resizable-panels' +import { ReactElement, useMemo, useState } from 'react' import { useOpenPLCStore } from '../../../store' import type { BlockVariant } from '../../_atoms/graphical-editor/types/block' @@ -9,12 +8,7 @@ import { Info } from './info' import { Library } from './library' import { Project } from './project' -type ExplorerProps = { - collapse: LegacyRef | undefined - defaultSize?: number -} - -const Explorer = ({ collapse, defaultSize = 16 }: ExplorerProps): ReactElement => { +const Explorer = (): ReactElement => { const { editor, project: { @@ -96,16 +90,7 @@ const Explorer = ({ collapse, defaultSize = 16 }: ExplorerProps): ReactElement = }, [selectedFileKey, system, pous]) return ( - + <> @@ -126,7 +111,7 @@ const Explorer = ({ collapse, defaultSize = 16 }: ExplorerProps): ReactElement = - + ) } diff --git a/src/frontend/components/_organisms/workspace-activity-bar/index.tsx b/src/frontend/components/_organisms/workspace-activity-bar/index.tsx index d26e1923f..4b4c61172 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/index.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/index.tsx @@ -87,7 +87,7 @@ export const WorkspaceActivityBar = ({ defaultActivityBar, explorer, sourceContr )} - + {(explorer || sourceControl) && }
diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index ae2fda3fe..aacaf8977 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -146,8 +146,6 @@ const WorkspaceScreen = () => { // for now — git-on-library is plausible but out of scope and // would re-introduce the same UI churn we just removed. const hasVersionControl = capabilities.hasVersionControl && projectCaps.hasVersionControl - const sourceControlPanelRef = useRef(null) - const [leftPanelSize, setLeftPanelSize] = useState(16) // Start global runtime polling for status and logs useRuntimePolling() @@ -311,7 +309,7 @@ const WorkspaceScreen = () => { } & ImperativePanelHandle const panelRef = useRef(null) - const explorerPanelRef = useRef(null) + const leftPanelRef = useRef(null) const workspacePanelRef = useRef(null) const consolePanelRef = useRef(null) const [activeTab, setActiveTab] = useState('console') @@ -348,7 +346,7 @@ const WorkspaceScreen = () => { useEffect(() => { const action = isCollapsed ? 'collapse' : 'expand' - ;[explorerPanelRef, workspacePanelRef, consolePanelRef, sourceControlPanelRef].forEach((ref) => { + ;[leftPanelRef, workspacePanelRef, consolePanelRef].forEach((ref) => { if (ref.current && typeof ref.current[action] === 'function') { ref.current[action]() } @@ -457,13 +455,7 @@ const WorkspaceScreen = () => { hasVersionControl ? { isActive: activePanel === 'explorer', - onClick: () => { - if (activePanel !== 'explorer') { - const size = sourceControlPanelRef.current?.getSize() - if (size) setLeftPanelSize(size) - } - setActivePanel('explorer') - }, + onClick: () => setActivePanel('explorer'), } : undefined } @@ -472,13 +464,7 @@ const WorkspaceScreen = () => { ? { isActive: activePanel === 'source-control', pendingCount: pendingChangesCount, - onClick: () => { - if (activePanel !== 'source-control') { - const size = explorerPanelRef.current?.getSize() - if (size) setLeftPanelSize(size) - } - setActivePanel('source-control') - }, + onClick: () => setActivePanel('source-control'), } : undefined } @@ -490,15 +476,26 @@ const WorkspaceScreen = () => { direction='horizontal' className='relative flex h-full w-full' > - {hasVersionControl && activePanel === 'source-control' ? ( - - ) : ( - - )} + {/* The left panel must stay mounted across context switches: swapping + the ResizablePanel itself makes react-resizable-panels rebuild the + layout from defaultSize and re-normalize, growing the sidebar on + every switch. Only the children swap. */} + + {hasVersionControl && activePanel === 'source-control' ? ( + + ) : ( + + )} + Date: Tue, 7 Jul 2026 09:10:56 -0300 Subject: [PATCH 11/52] fix: eliminate phantom source-control diffs in graphical editors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unmodified LD/FBD POUs showed as Modified in Source Control, drag-away-and-restore produced permanent phantom diffs, and every project open silently corrupted linked variable pins. - Persist the raw ladder/FBD flow (Zod-validated, not Zod-stripped) so unedited POUs round-trip byte-identical against HEAD - Make focus/blur drag-lock updates transient so clicking a node never dirties the flow - Canonicalize rung serialization: strip hasDivergence render state, normalize draggable, derive reactFlowViewport from content bounds - Reuse variable-pin node identities across layout rebuilds so drag-away-and-restore keeps ids, numericIds, and edge order stable - Validate variable pins against their block pin type — fixes a pre-existing bug that flagged every linked pin wrongVariable and rewrote its payload as broken-* on each project open and drag-stop (stale flags now self-heal) - Derive changed-rung indexes from a semantic node/edge diff instead of byte equality (openplc-web only) - Fix stale diff-view HEAD cache: prune entries per-path on save, refetch per-file instead of per-map, and resolve commit-with-diff-open to the new HEAD Note: projects saved with the previous serialization show a one-time Modified diff per graphical POU on first save; committing it once migrates the file. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WYE8gdREZQNYQeLfFuuZ9T --- .../_atoms/graphical-editor/ladder/block.tsx | 14 ++ .../_atoms/graphical-editor/ladder/coil.tsx | 8 + .../graphical-editor/ladder/contact.tsx | 8 + .../[workspace]/editor/diff-viewer/index.tsx | 43 +++- .../editor/graphical/FBD/index.tsx | 14 +- .../editor/graphical/ladder/index.tsx | 13 +- .../ladder-utils/elements/diagram/index.ts | 15 +- .../elements/drag-n-drop/handlers.ts | 14 +- .../rung/ladder-utils/elements/index.ts | 5 + .../__tests__/variable-block.test.ts | 160 ++++++++++++++ .../elements/variable-block/index.ts | 57 ++++- .../store/__tests__/ladder-slice.test.ts | 29 +++ .../__tests__/version-control-slice.test.ts | 23 ++ src/frontend/store/slices/ladder/slice.ts | 4 +- src/frontend/store/slices/ladder/types.ts | 5 + .../store/slices/version-control/slice.ts | 10 + .../store/slices/version-control/types.ts | 14 +- .../utils/__tests__/save-project.test.ts | 199 ++++++++++++++++++ .../sync-nodes-with-variables.test.ts | 144 ++++++++----- .../graphical/sync-nodes-with-variables.ts | 20 ++ src/frontend/utils/save-project.ts | 77 ++++++- 21 files changed, 780 insertions(+), 96 deletions(-) create mode 100644 src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/__tests__/variable-block.test.ts diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx index 17df159ce..3d1df1696 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx @@ -556,6 +556,16 @@ export const Block = (block: BlockProps) => { return } + // Blur with an unchanged name is not an edit — skip the write so merely + // clicking in and out of a block never marks the POU as modified. The + // autocomplete's explicit create action (createIfNotFound) still proceeds. + if ( + !createIfNotFound && + variableNameToSubmit === (node.data as { variable?: { name?: string } }).variable?.name + ) { + return + } + const blockType = (node.data as BlockNodeData).variant.name const findMatchingVariable = () => @@ -873,6 +883,8 @@ export const Block = (block: BlockProps) => { onFocus={(e) => { e.target.select() if (!node || !rung) return + // Drag-lock while typing is UI state, not an edit — transient + // so focusing the input never marks the flow as modified. updateNode({ editorName: pouName, nodeId: node.id, @@ -881,6 +893,7 @@ export const Block = (block: BlockProps) => { ...node, draggable: false, }, + transient: true, }) return }} @@ -894,6 +907,7 @@ export const Block = (block: BlockProps) => { ...node, draggable: node.data.draggable as boolean, }, + transient: true, }) return }} diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx index 4da8b8cbe..17ee21089 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx @@ -155,6 +155,10 @@ export const Coil = (block: CoilProps) => { }) if (!rung || !node) return + // Blur with an unchanged name is not an edit — skip the write so merely + // clicking in and out of a coil never marks the POU as modified. + if (variableNameToSubmit === (node.data as { variable?: { name?: string } }).variable?.name) return + // Persist whatever the user typed; the validation effect resolves and // type-checks it against the full project scope and drives the red state. updateNode({ @@ -215,6 +219,8 @@ export const Coil = (block: CoilProps) => { nodeId: id ?? '', }) if (!node || !rung) return + // Drag-lock while typing is UI state, not an edit — transient + // so focusing the input never marks the flow as modified. updateNode({ editorName: pouName, nodeId: node.id, @@ -223,6 +229,7 @@ export const Coil = (block: CoilProps) => { ...node, draggable: false, }, + transient: true, }) return }} @@ -239,6 +246,7 @@ export const Coil = (block: CoilProps) => { ...node, draggable: node.data.draggable as boolean, }, + transient: true, }) return }} diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx index 950502495..42aefd1f1 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx @@ -156,6 +156,10 @@ export const Contact = (block: ContactProps) => { }) if (!rung || !node) return + // Blur with an unchanged name is not an edit — skip the write so merely + // clicking in and out of a contact never marks the POU as modified. + if (variableNameToSubmit === (node.data as { variable?: { name?: string } }).variable?.name) return + // Persist whatever the user typed; the validation effect resolves and // type-checks it against the full project scope and drives the red state. updateNode({ @@ -216,6 +220,8 @@ export const Contact = (block: ContactProps) => { nodeId: id ?? '', }) if (!node || !rung) return + // Drag-lock while typing is UI state, not an edit — transient + // so focusing the input never marks the flow as modified. updateNode({ editorName: pouName, nodeId: node.id, @@ -224,6 +230,7 @@ export const Contact = (block: ContactProps) => { ...node, draggable: false, }, + transient: true, }) return }} @@ -240,6 +247,7 @@ export const Contact = (block: ContactProps) => { ...node, draggable: node.data.draggable as boolean, }, + transient: true, }) return }} diff --git a/src/frontend/components/_features/[workspace]/editor/diff-viewer/index.tsx b/src/frontend/components/_features/[workspace]/editor/diff-viewer/index.tsx index ed998f0b5..93b4ee2fb 100644 --- a/src/frontend/components/_features/[workspace]/editor/diff-viewer/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/diff-viewer/index.tsx @@ -13,7 +13,11 @@ * serialized form for files edited this session — keeping the diff live. * * The HEAD `before` map is cached in the version-control slice (`headContent`) - * and invalidated on project load / commit / in-place reload. + * and invalidated on project load / commit / in-place reload, plus pruned + * per-path on save (`recordSavedFiles`). The fetch below refires whenever the + * open path has no cached entry, so a viewer that stayed mounted across a + * commit (which empties the pending set) picks up the new HEAD on the next + * change instead of diffing against a stale snapshot. */ import { useEffect, useMemo } from 'react' @@ -61,28 +65,51 @@ export function DiffViewerEditor() { const filePath = editor.type === 'diff-viewer' ? editor.meta.filePath : '' + // The cached snapshot serves this diff only when it has an entry for the + // open path. A missing entry means the cache predates the change being + // viewed (e.g. it was rebuilt while a commit had emptied the pending set), + // so it must be refetched — rendering it would diff against a wrong HEAD. + const headReady = headContent !== null && (!filePath || headContent[filePath] !== undefined) + // Lazily fetch the HEAD content of all pending files via the backend's // content-bearing /changes call (authoritative against the real HEAD), and - // cache the `before` map. Invalidated on load / commit / reload. + // cache the `before` map. Invalidated on load / commit / reload and pruned + // per-path on save. useEffect(() => { - if (headContent !== null || !projectId || !versionControl) return + if (headReady || !projectId || !versionControl) return let cancelled = false + // Snapshot the working-tree bytes before fetching: if `filePath` turns + // out to have no pending change, its working tree equals HEAD, so these + // bytes ARE its HEAD content. Caching them keeps `headReady` from + // refetching in a loop and gives later diffs the correct original side. + let workingTreeSnapshot = '' + if (filePath) { + try { + workingTreeSnapshot = buildAllProjectFileContents()[filePath] ?? '' + } catch { + workingTreeSnapshot = '' + } + } void (async () => { try { const { changes } = await versionControl.getChanges(projectId, undefined, true) const map: Record = {} for (const c of changes) map[c.path] = c.before ?? '' + if (filePath && map[filePath] === undefined) map[filePath] = workingTreeSnapshot if (!cancelled) setHeadContent(map) } catch { - if (!cancelled) setHeadContent({}) + // Cache an (empty) entry for the open path even on failure so + // `headReady` doesn't retry in a tight loop; the next mount or path + // change triggers a fresh attempt. + if (!cancelled) setHeadContent(filePath ? { [filePath]: '' } : {}) } })() return () => { cancelled = true } - }, [headContent, projectId, versionControl, setHeadContent]) + }, [headReady, filePath, projectId, versionControl, setHeadContent]) - const original = headContent && filePath ? (headContent[filePath] ?? '') : '' + const original = headReady && headContent && filePath ? (headContent[filePath] ?? '') : '' // The working-tree side: raw loaded bytes for files untouched this session, // freshly serialized for edited ones. Recomputed when `project` changes. @@ -111,7 +138,7 @@ export function DiffViewerEditor() {

{filePath}

- {headContent !== null && ( + {headReady && (
- {headContent === null ? ( + {!headReady ? (
diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx index 1ac4e5960..ced257015 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx @@ -82,19 +82,27 @@ export default function FbdEditor() { }, [flow?.rung.nodes, userLibraries, pous]) /** - * Update the flow state to project JSON + * Update the flow state to project JSON. + * + * Validate the flow with Zod but persist the raw object (minus the + * transient `updated` flag). Using the parsed result would silently strip + * every field not declared in `zodFBDFlowSchema` and reorder keys to + * schema order, which makes `serializeGraphicalPouToString` produce + * byte-drift vs. the loaded disk copy — surfacing as phantom "Modified" + * entries in Source Control for POUs the user never edited. */ useEffect(() => { - if (!flowUpdated) return + if (!flowUpdated || !flow) return const flowSchema = zodFBDFlowSchema.safeParse(flow) if (!flowSchema.success) return + const { updated: _updated, ...flowBody } = flow updatePou({ name: pouName, content: { language: 'fbd', - value: flowSchema.data, + value: structuredClone(flowBody), }, }) diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx index cf816d9c7..80ba8a89b 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx @@ -92,18 +92,27 @@ export default function LadderEditor() { /** * Update the flow state to project JSON. + * + * Validate the flow with Zod but persist the raw object (minus the + * transient `updated` flag). Using the parsed result would silently strip + * every field not declared in `zodLadderFlowSchema` (e.g. `handleBranches`, + * `positionAbsolute`, `zIndex`) and reorder keys to schema order, which + * makes `serializeGraphicalPouToString` produce byte-drift vs. the loaded + * disk copy — surfacing as phantom "Modified" entries in Source Control + * for POUs the user never edited. */ useEffect(() => { - if (!flowUpdated) return + if (!flowUpdated || !flow) return const flowSchema = zodLadderFlowSchema.safeParse(flow) if (!flowSchema.success) return + const { updated: _updated, ...flowBody } = flow updatePou({ name: pouName, content: { language: 'ld', - value: flowSchema.data, + value: structuredClone(flowBody), }, }) diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts index 343ec4774..fc4884733 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts @@ -396,6 +396,10 @@ const applyDynamicBlockHandleOffsets = (rung: RungLadderState): Node[] => { export const updateDiagramElementsPosition = ( rung: RungLadderState, defaultBounds: [number, number], + /** Fallback identity source for the variable-node rebuild — pass the + * pre-strip node set when `rung.nodes` had its variable nodes removed + * earlier in the pipeline (see updateVariableBlockPosition). */ + previousNodes?: Node[], ): { nodes: Node[]; edges: Edge[] } => { // Pre-expand block dimensions BEFORE the main layout loop. findAllParallelsDepthAndNodes // and the parallel-path Y formulas both read block.height; if blocks haven't been @@ -689,10 +693,13 @@ export const updateDiagramElementsPosition = ( defaultBounds, ) - const variablesNodes = updateVariableBlockPosition({ - ...rung, - nodes: changedRailNodes, - }) + const variablesNodes = updateVariableBlockPosition( + { + ...rung, + nodes: changedRailNodes, + }, + previousNodes, + ) return { nodes: variablesNodes.nodes, edges: variablesNodes.edges } } diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/handlers.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/handlers.ts index 2c3186cee..56646d35b 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/handlers.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/handlers.ts @@ -178,6 +178,10 @@ function layoutAndReturn( ...(handleBranches && { handleBranches }), }, oldStateRung.defaultBounds as [number, number], + // `nodes` may have had its variable nodes stripped by prepareDropState; + // the pre-drop rung still holds them, so pass it as the identity source + // to keep variable-node ids stable across the rebuild. + oldStateRung.nodes, ) return { nodes: diagramNodes, edges: diagramEdges, handleBranches } } @@ -252,10 +256,13 @@ export function handleMainParallel(ctx: DropContext, sourceIsBranch: boolean): D ctx.node, ) - // Remove the copycat node + // Remove the copycat node. `parallelNodes` derives from preparedNodes + // (variables stripped) — pass the pre-drop rung as identity source so the + // variable-node rebuild keeps stable ids. const { nodes: cleanedNodes, edges: cleanedEdges } = removeElement( { ...ctx.rung, nodes: parallelNodes, edges: parallelEdges }, ctx.copycatNode, + ctx.oldStateRung.nodes, ) return { nodes: cleanedNodes, edges: cleanedEdges } @@ -380,10 +387,13 @@ export function handleMainSerial(ctx: DropContext, sourceIsBranch: boolean): Dro ctx.node, ) - // Remove the copycat node + // Remove the copycat node. `serialNodes` derives from preparedNodes + // (variables stripped) — pass the pre-drop rung as identity source so the + // variable-node rebuild keeps stable ids. const { nodes: cleanedNodes, edges: cleanedEdges } = removeElement( { ...ctx.rung, nodes: serialNodes, edges: serialEdges }, ctx.copycatNode, + ctx.oldStateRung.nodes, ) return { nodes: cleanedNodes, edges: cleanedEdges } diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/index.ts index 5fa9cdffe..23ad364c1 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/index.ts @@ -208,6 +208,10 @@ export const addNewElement = ( export const removeElement = ( rung: RungLadderState, element: Node, + /** Fallback identity source for the variable-node rebuild — pass the + * pre-strip node set when `rung.nodes` had its variable nodes removed + * earlier in the pipeline (see updateVariableBlockPosition). */ + previousNodes?: Node[], ): { nodes: Node[]; edges: Edge[]; handleBranches?: HandleBranch[] } => { let newNodes: Node[] let newEdges: Edge[] @@ -317,6 +321,7 @@ export const removeElement = ( ...(handleBranches && { handleBranches }), }, rung.defaultBounds as [number, number], + previousNodes, ) newNodes = updatedDiagramNodes newEdges = updatedDiagramEdges diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/__tests__/variable-block.test.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/__tests__/variable-block.test.ts new file mode 100644 index 000000000..ff9a38a82 --- /dev/null +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/__tests__/variable-block.test.ts @@ -0,0 +1,160 @@ +/** + * updateVariableBlockPosition destroys and rebuilds every variable node on + * each layout pass. These tests pin the identity-reuse contract: a rebuilt + * variable node for an unchanged attachment point (block id + handle id + + * side) must keep its previous `id` and `data.numericId`, so a net-identical + * graph stays byte-identical when serialized (no phantom source-control + * modifications after drag-away-and-back). + */ +import type { RungLadderState } from '@root/frontend/store/slices' +import type { Node } from '@xyflow/react' + +import { updateVariableBlockPosition } from '../index' + +const makeHandle = (id: string, x: number, y: number) => ({ + id, + glbPosition: { x, y }, +}) + +const makeBlockNode = (): Node => + ({ + id: 'BLOCK_1', + type: 'block', + position: { x: 200, y: 40 }, + data: { + variant: { name: 'CTU', type: 'function-block', variables: [] }, + inputHandles: [makeHandle('EN', 200, 50), makeHandle('CU', 200, 70), makeHandle('PV', 200, 90)], + outputHandles: [makeHandle('ENO', 300, 50), makeHandle('CV', 300, 70)], + connectedVariables: [], + }, + }) as unknown as Node + +const makeVariableNode = (id: string, handleId: string, variant: 'input' | 'output', numericId: number): Node => + ({ + id, + type: 'variable', + position: { x: 0, y: 0 }, + data: { + variant, + block: { id: 'BLOCK_1', handleId }, + numericId, + }, + }) as unknown as Node + +const makeRung = (nodes: Node[]): RungLadderState => + ({ + id: 'rung-1', + comment: '', + defaultBounds: [800, 200], + reactFlowViewport: [800, 200], + selectedNodes: [], + nodes, + edges: [], + }) as unknown as RungLadderState + +describe('updateVariableBlockPosition identity reuse', () => { + it('reuses id and numericId for unchanged attachment points', () => { + const rung = makeRung([ + makeBlockNode(), + makeVariableNode('VARIABLE_prev_cu', 'CU', 'input', 111), + makeVariableNode('VARIABLE_prev_pv', 'PV', 'input', 222), + makeVariableNode('VARIABLE_prev_cv', 'CV', 'output', 333), + ]) + + const { nodes } = updateVariableBlockPosition(rung) + const variables = nodes.filter((n) => n.type === 'variable') + + expect(variables).toHaveLength(3) + const byHandle = new Map( + variables.map((n) => [(n.data as { block: { handleId: string } }).block.handleId, n]), + ) + expect(byHandle.get('CU')?.id).toBe('VARIABLE_prev_cu') + expect((byHandle.get('CU')?.data as { numericId: number }).numericId).toBe(111) + expect(byHandle.get('PV')?.id).toBe('VARIABLE_prev_pv') + expect((byHandle.get('PV')?.data as { numericId: number }).numericId).toBe(222) + expect(byHandle.get('CV')?.id).toBe('VARIABLE_prev_cv') + expect((byHandle.get('CV')?.data as { numericId: number }).numericId).toBe(333) + }) + + it('is idempotent: a second rebuild produces identical variable identities', () => { + const rung = makeRung([makeBlockNode()]) + + const first = updateVariableBlockPosition(rung) + const second = updateVariableBlockPosition(makeRung(first.nodes)) + + const identity = (ns: Node[]) => + ns + .filter((n) => n.type === 'variable') + .map((n) => `${n.id}::${(n.data as { numericId: number }).numericId}`) + .sort() + expect(identity(second.nodes)).toEqual(identity(first.nodes)) + }) + + it('mints fresh ids for attachment points with no previous variable node', () => { + const rung = makeRung([makeBlockNode(), makeVariableNode('VARIABLE_prev_cu', 'CU', 'input', 111)]) + + const { nodes } = updateVariableBlockPosition(rung) + const variables = nodes.filter((n) => n.type === 'variable') + + expect(variables).toHaveLength(3) + const cu = variables.find((n) => (n.data as { block: { handleId: string } }).block.handleId === 'CU') + const pv = variables.find((n) => (n.data as { block: { handleId: string } }).block.handleId === 'PV') + expect(cu?.id).toBe('VARIABLE_prev_cu') + expect(pv?.id).toMatch(/^VARIABLE_/) + expect(pv?.id).not.toBe('VARIABLE_prev_pv') + }) + + it('reuses identities from previousNodes when the rung was stripped upstream', () => { + // Simulates the drag-drop pipeline: prepareDropState removes variable + // nodes before layout, so the rung reaching the rebuild has none — the + // pre-drop node set is passed separately as the identity source. + const strippedRung = makeRung([makeBlockNode()]) + const preDropNodes = [ + makeBlockNode(), + makeVariableNode('VARIABLE_prev_cu', 'CU', 'input', 111), + makeVariableNode('VARIABLE_prev_pv', 'PV', 'input', 222), + makeVariableNode('VARIABLE_prev_cv', 'CV', 'output', 333), + ] + + const { nodes } = updateVariableBlockPosition(strippedRung, preDropNodes) + const variables = nodes.filter((n) => n.type === 'variable') + + expect(variables).toHaveLength(3) + const byHandle = new Map( + variables.map((n) => [(n.data as { block: { handleId: string } }).block.handleId, n]), + ) + expect(byHandle.get('CU')?.id).toBe('VARIABLE_prev_cu') + expect(byHandle.get('PV')?.id).toBe('VARIABLE_prev_pv') + expect(byHandle.get('CV')?.id).toBe('VARIABLE_prev_cv') + expect((byHandle.get('CV')?.data as { numericId: number }).numericId).toBe(333) + }) + + it('prefers identities from the rung itself over previousNodes', () => { + const rung = makeRung([makeBlockNode(), makeVariableNode('VARIABLE_current_cu', 'CU', 'input', 999)]) + const preDropNodes = [makeVariableNode('VARIABLE_stale_cu', 'CU', 'input', 111)] + + const { nodes } = updateVariableBlockPosition(rung, preDropNodes) + const cu = nodes.find( + (n) => n.type === 'variable' && (n.data as { block: { handleId: string } }).block.handleId === 'CU', + ) + + expect(cu?.id).toBe('VARIABLE_current_cu') + expect((cu?.data as { numericId: number }).numericId).toBe(999) + }) + + it('ignores previous variable nodes with incomplete identity data', () => { + const brokenVariable = { + id: 'VARIABLE_broken', + type: 'variable', + position: { x: 0, y: 0 }, + data: { variant: 'input', block: { id: 'BLOCK_1' } }, + } as unknown as Node + + const rung = makeRung([makeBlockNode(), brokenVariable]) + const { nodes } = updateVariableBlockPosition(rung) + const variables = nodes.filter((n) => n.type === 'variable') + + expect(variables).toHaveLength(3) + expect(variables.every((n) => n.id !== 'VARIABLE_broken')).toBe(true) + }) +}) diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts index 81929ceb2..92c900959 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts @@ -10,7 +10,42 @@ import { BlockNode, BlockVariant } from '../../../../../../../_atoms/graphical-e import { buildEdge } from '../../edges' import { hasBranchOnHandle } from '../handle-branch' -export const renderVariableBlock = (rung: RungLadderState, block: Node) => { +/** + * Identity of a rebuilt variable node, keyed by its attachment point + * (block id + handle id + input/output side). The layout pass destroys and + * rebuilds every variable node — reusing the previous node's `id` and + * `data.numericId` keeps a net-identical graph byte-identical on disk, so + * dragging a block away and back doesn't leave a phantom "Modified" file. + */ +type VariableIdentity = { id: string; numericId: number | string } + +const variableIdentityKey = (blockId: string, handleId: string, variant: 'input' | 'output') => + `${blockId}::${handleId}::${variant}` + +/** Collect the identities of a rung's existing variable nodes before a rebuild. */ +const collectVariableIdentities = (nodes: Node[]): Map => { + const identities = new Map() + for (const node of nodes) { + if (node.type !== 'variable') continue + const data = node.data as { + variant?: 'input' | 'output' + block?: { id?: string; handleId?: string } + numericId?: number | string + } + if (!data?.block?.id || !data.block.handleId || !data.variant || data.numericId === undefined) continue + identities.set(variableIdentityKey(data.block.id, data.block.handleId, data.variant), { + id: node.id, + numericId: data.numericId, + }) + } + return identities +} + +export const renderVariableBlock = ( + rung: RungLadderState, + block: Node, + previousIdentities?: Map, +) => { const variableElements: Node[] = [] const variableEdges: Edge[] = [] const variableElementStyle = defaultCustomNodesStyles.variable @@ -50,8 +85,9 @@ export const renderVariableBlock = (rung: RungLadderStat if (variable.name === inputHandle.id) variableType = variable }) + const previous = previousIdentities?.get(variableIdentityKey(blockElement.id, inputHandle.id as string, 'input')) const variableElement = nodesBuilder.variable({ - id: newGraphicalEditorNodeID('variable'), + id: previous?.id ?? newGraphicalEditorNodeID('variable'), posX: inputHandle.glbPosition.x - (variableElementStyle.width + variableElementStyle.gap), posY: inputHandle.glbPosition.y - variableElementStyle.handle.y, handleX: inputHandle.glbPosition.x - variableElementStyle.gap, @@ -64,6 +100,7 @@ export const renderVariableBlock = (rung: RungLadderStat }, variable: connectedVariable ? connectedVariable.variable : undefined, }) + if (previous) (variableElement.data as { numericId: number | string }).numericId = previous.numericId const variableEdge = buildEdge(variableElement.id, blockElement.id, { sourceHandle: 'output', targetHandle: inputHandle.id, @@ -92,8 +129,9 @@ export const renderVariableBlock = (rung: RungLadderStat if (variable.name === outputHandle.id) variableType = variable }) + const previous = previousIdentities?.get(variableIdentityKey(blockElement.id, outputHandle.id as string, 'output')) const variableElement = nodesBuilder.variable({ - id: newGraphicalEditorNodeID('variable'), + id: previous?.id ?? newGraphicalEditorNodeID('variable'), posX: outputHandle.glbPosition.x + variableElementStyle.gap, posY: outputHandle.glbPosition.y - variableElementStyle.handle.y, handleX: outputHandle.glbPosition.x + variableElementStyle.gap, @@ -106,6 +144,7 @@ export const renderVariableBlock = (rung: RungLadderStat }, variable: connectedVariable ? connectedVariable.variable : undefined, }) + if (previous) (variableElement.data as { numericId: number | string }).numericId = previous.numericId const variableEdge = buildEdge(blockElement.id, variableElement.id, { sourceHandle: outputHandle.id, targetHandle: 'input', @@ -126,10 +165,19 @@ export const removeVariableBlock = (rung: RungLadderState) => { return { nodes: newNodes, edges: newEdges } } -export const updateVariableBlockPosition = (rung: RungLadderState) => { +export const updateVariableBlockPosition = (rung: RungLadderState, previousNodes?: Node[]) => { let newNodes = [...rung.nodes] let newEdges = [...rung.edges] + // Remember each variable node's identity before the rebuild so unchanged + // attachment points keep their ids (and therefore their edge ids). + // `previousNodes` is a fallback identity source for pipelines that strip + // variable nodes before layout (the drag-drop flow removes them in + // `prepareDropState`) — entries from `rung.nodes` win when both exist. + const previousIdentities = collectVariableIdentities( + previousNodes ? [...previousNodes, ...rung.nodes] : rung.nodes, + ) + const { nodes: removedVariableNodes, edges: removedVariableEdges } = removeVariableBlock(rung) newNodes = removedVariableNodes newEdges = removedVariableEdges @@ -144,6 +192,7 @@ export const updateVariableBlockPosition = (rung: RungLadderState) => { edges: newEdges, }, blockElement, + previousIdentities, ) newNodes = nodes newEdges = edges diff --git a/src/frontend/store/__tests__/ladder-slice.test.ts b/src/frontend/store/__tests__/ladder-slice.test.ts index 9d5d076e4..49b3e62f6 100644 --- a/src/frontend/store/__tests__/ladder-slice.test.ts +++ b/src/frontend/store/__tests__/ladder-slice.test.ts @@ -493,6 +493,35 @@ describe('createLadderFlowSlice', () => { expect(store.getState().ladderFlows[0].rungs[0].nodes).toHaveLength(2) }) + it('updateNode marks the flow as updated', () => { + const rung = makeRung({ nodes: [makeNode({ id: 'n1' })] }) + seedFlowWithRung(store, 'editor-1', rung) + store.getState().ladderFlowActions.setFlowUpdated({ editorName: 'editor-1', updated: false }) + + store + .getState() + .ladderFlowActions.updateNode({ editorName: 'editor-1', rungId: 'rung-1', nodeId: 'n1', node: makeNode({ id: 'n1' }) }) + + expect(store.getState().ladderFlows[0].updated).toBe(true) + }) + + it('updateNode with transient replaces the node without marking the flow as updated', () => { + const rung = makeRung({ nodes: [makeNode({ id: 'n1', data: { label: 'old' } })] }) + seedFlowWithRung(store, 'editor-1', rung) + store.getState().ladderFlowActions.setFlowUpdated({ editorName: 'editor-1', updated: false }) + + store.getState().ladderFlowActions.updateNode({ + editorName: 'editor-1', + rungId: 'rung-1', + nodeId: 'n1', + node: makeNode({ id: 'n1', data: { label: 'new' } }), + transient: true, + }) + + expect(store.getState().ladderFlows[0].rungs[0].nodes.find((n) => n.id === 'n1')?.data.label).toBe('new') + expect(store.getState().ladderFlows[0].updated).toBe(false) + }) + // ------------------------------------------------------------------------- // addNode // ------------------------------------------------------------------------- diff --git a/src/frontend/store/__tests__/version-control-slice.test.ts b/src/frontend/store/__tests__/version-control-slice.test.ts index 876f79111..c89fc4e3c 100644 --- a/src/frontend/store/__tests__/version-control-slice.test.ts +++ b/src/frontend/store/__tests__/version-control-slice.test.ts @@ -139,6 +139,29 @@ describe('createVersionControlSlice', () => { expect(vc().changedPaths).toContain('tracked.st') expect(vc().changedPaths).not.toContain('session.st') }) + + it('prunes saved and deleted paths from the cached HEAD snapshot', () => { + actions().initBaseline({ initialPending: [], baselineContent: { 'gone.st': 'g' } }) + actions().setHeadContent({ 'saved.st': 'old-head', 'gone.st': 'old-head', 'untouched.st': 'head' }) + + actions().recordSavedFiles({ + saved: [{ path: 'saved.st', content: 'new' }], + deleted: ['gone.st'], + }) + + expect(vc().headContent).toEqual({ 'untouched.st': 'head' }) + }) + + it('leaves the HEAD snapshot null when it was never fetched', () => { + actions().initBaseline({ initialPending: [], baselineContent: {} }) + + actions().recordSavedFiles({ + saved: [{ path: 'saved.st', content: 'new' }], + deleted: [], + }) + + expect(vc().headContent).toBeNull() + }) }) it('commitBaseline refreshes baseline, clears pending, and invalidates HEAD', () => { diff --git a/src/frontend/store/slices/ladder/slice.ts b/src/frontend/store/slices/ladder/slice.ts index 983ad059d..a5ce132a6 100644 --- a/src/frontend/store/slices/ladder/slice.ts +++ b/src/frontend/store/slices/ladder/slice.ts @@ -294,7 +294,7 @@ export const createLadderFlowSlice: StateCreator { const flow = ladderFlows.find((flow) => flow.name === editorName) @@ -307,7 +307,7 @@ export const createLadderFlowSlice: StateCreator void addNode: ({ node, rungId, editorName }: { node: Node; rungId: string; editorName: string }) => void removeNodes: ({ nodes, rungId, editorName }: { nodes: Node[]; rungId: string; editorName: string }) => void diff --git a/src/frontend/store/slices/version-control/slice.ts b/src/frontend/store/slices/version-control/slice.ts index e4cc235e7..173308ee7 100644 --- a/src/frontend/store/slices/version-control/slice.ts +++ b/src/frontend/store/slices/version-control/slice.ts @@ -96,6 +96,16 @@ const createVersionControlSlice: StateCreator [e.path, e.status])) const changedSet = new Set(draft.versionControl.changedPaths) + // Drop the cached HEAD snapshot for every path this save touched. + // A save doesn't move HEAD, but the cached entry may predate it + // wrongly (e.g. a fetch that raced a commit), and the diff view + // can't tell a stale entry from a fresh one — pruning forces it to + // refetch authoritative content the next time the path is viewed. + if (draft.versionControl.headContent) { + for (const { path } of saved) delete draft.versionControl.headContent[path] + for (const path of deleted) delete draft.versionControl.headContent[path] + } + for (const { path, content } of saved) { // Track S3's current state for this path so future "clean" // saves echo back what's now in S3 (instead of the original diff --git a/src/frontend/store/slices/version-control/types.ts b/src/frontend/store/slices/version-control/types.ts index 9a3e71b74..e823de507 100644 --- a/src/frontend/store/slices/version-control/types.ts +++ b/src/frontend/store/slices/version-control/types.ts @@ -58,10 +58,11 @@ export type VersionControlState = { * `/changes?includeContent=true` `before` field (authoritative against the * real HEAD). `null` until lazily fetched by the diff view; reset to `null` * on project load / commit / in-place reload so it refetches against the - * current HEAD. Unlike `baselineContent` (which reflects the loaded working - * tree and so already includes pre-existing pending changes), this is the - * committed content, so it can show a diff for changes made before the - * current session. + * current HEAD, and pruned per-path by `recordSavedFiles` so a re-edited + * file never diffs against a stale snapshot. Unlike `baselineContent` + * (which reflects the loaded working tree and so already includes + * pre-existing pending changes), this is the committed content, so it can + * show a diff for changes made before the current session. */ headContent: Record | null } @@ -96,7 +97,10 @@ export type VersionControlActions = { * Update `changedPaths` based on what the save just sent. Files matching * baseline are removed; files differing are added; deletions are folded * back into initialPending or changedPaths depending on their original - * status (see slice implementation for the case-by-case logic). + * status (see slice implementation for the case-by-case logic). Also prunes + * the saved/deleted paths from the cached `headContent` snapshot so the + * diff view refetches their HEAD side instead of trusting a possibly-stale + * entry. */ recordSavedFiles: (args: { saved: SavedFileRecord[]; deleted: string[] }) => void /** diff --git a/src/frontend/utils/__tests__/save-project.test.ts b/src/frontend/utils/__tests__/save-project.test.ts index 37b315f62..40d41ff0e 100644 --- a/src/frontend/utils/__tests__/save-project.test.ts +++ b/src/frontend/utils/__tests__/save-project.test.ts @@ -96,6 +96,205 @@ describe('sanitizePou', () => { }) }) +// --------------------------------------------------------------------------- +// graphical node sanitization (via sanitizePou → stripGraphicalSelections) +// --------------------------------------------------------------------------- + +describe('graphical node sanitization', () => { + const makeLdPou = (nodes: unknown[], rungExtras?: Record): PLCPou => + ({ + name: 'MyLadder', + pouType: 'program', + interface: { variables: [] }, + body: { + language: 'ld', + value: { + name: 'MyLadder', + rungs: [{ id: 'rung-1', nodes, edges: [], selectedNodes: [{ id: 'ghost' }], ...rungExtras }], + }, + }, + documentation: '', + }) as unknown as PLCPou + + it('clears selection state and resets selectedNodes on LD rungs', () => { + const pou = makeLdPou([{ id: 'n1', selected: true, dragging: true, draggable: true, data: { draggable: true } }]) + const value = sanitizePou(pou, undefined).body.value as { rungs: { selectedNodes: unknown[]; nodes: Record[] }[] } + expect(value.rungs[0].selectedNodes).toEqual([]) + expect(value.rungs[0].nodes[0].selected).toBe(false) + expect(value.rungs[0].nodes[0].dragging).toBe(false) + }) + + it('strips the hasDivergence render decoration from LD node data', () => { + const pou = makeLdPou([{ id: 'n1', draggable: true, data: { draggable: true, hasDivergence: false, variable: { name: 'X' } } }]) + const value = sanitizePou(pou, undefined).body.value as { rungs: { nodes: { data: Record }[] }[] } + expect('hasDivergence' in value.rungs[0].nodes[0].data).toBe(false) + expect(value.rungs[0].nodes[0].data.variable).toEqual({ name: 'X' }) + }) + + it('normalizes top-level draggable to the design-time data.draggable', () => { + const pou = makeLdPou([ + { id: 'locked', draggable: false, data: { draggable: true } }, + { id: 'rail', draggable: true, data: { draggable: false } }, + { id: 'no-data' }, + ]) + const value = sanitizePou(pou, undefined).body.value as { rungs: { nodes: Record[] }[] } + expect(value.rungs[0].nodes[0].draggable).toBe(true) + expect(value.rungs[0].nodes[1].draggable).toBe(false) + expect(value.rungs[0].nodes[2].draggable).toBe(false) + expect('data' in value.rungs[0].nodes[2]).toBe(false) + }) + + it('leaves non-array LD rung nodes untouched', () => { + const pou = makeLdPou([], {}) + ;(pou.body.value as { rungs: Record[] }).rungs[0].nodes = 'not-an-array' + const value = sanitizePou(pou, undefined).body.value as { rungs: { nodes: unknown }[] } + expect(value.rungs[0].nodes).toBe('not-an-array') + }) + + it('sanitizes FBD rung nodes the same way', () => { + const pou = { + name: 'MyFbd', + pouType: 'program', + interface: { variables: [] }, + body: { + language: 'fbd', + value: { + name: 'MyFbd', + rung: { + nodes: [{ id: 'n1', selected: true, dragging: true, draggable: false, data: { draggable: true, hasDivergence: true } }], + edges: [], + selectedNodes: [{ id: 'ghost' }], + }, + }, + }, + documentation: '', + } as unknown as PLCPou + const value = sanitizePou(pou, undefined).body.value as { + rung: { selectedNodes: unknown[]; nodes: { selected: boolean; dragging: boolean; draggable: boolean; data: Record }[] } + } + expect(value.rung.selectedNodes).toEqual([]) + expect(value.rung.nodes[0].selected).toBe(false) + expect(value.rung.nodes[0].dragging).toBe(false) + expect(value.rung.nodes[0].draggable).toBe(true) + expect('hasDivergence' in value.rung.nodes[0].data).toBe(false) + }) + + it('returns the POU unchanged when the graphical body value is missing', () => { + const pou = { + name: 'Empty', + pouType: 'program', + interface: { variables: [] }, + body: { language: 'ld', value: undefined }, + documentation: '', + } as unknown as PLCPou + expect(sanitizePou(pou, undefined)).toBe(pou) + }) + + it('preserves LD rung edge order (layout algorithms may be order-sensitive)', () => { + const edges = [ + { id: 'e_charlie', source: 'c', target: 'd' }, + { id: 'e_alpha', source: 'a', target: 'b' }, + { id: 'e_bravo', source: 'b', target: 'c' }, + ] + const pou = makeLdPou([], { edges }) + const value = sanitizePou(pou, undefined).body.value as { rungs: { edges: { id: string }[] }[] } + expect(value.rungs[0].edges.map((e) => e.id)).toEqual(['e_charlie', 'e_alpha', 'e_bravo']) + }) + + it('canonicalizes reactFlowViewport from node content bounds', () => { + const pou = makeLdPou( + [{ id: 'n1', position: { x: 200, y: 10 }, width: 100, height: 60, draggable: true, data: { draggable: true } }], + { defaultBounds: [100, 50], reactFlowViewport: [999, 999] }, + ) + const value = sanitizePou(pou, undefined).body.value as { rungs: { reactFlowViewport: number[] }[] } + // bounds include the synthetic 150x40 origin node: maxX=300, maxY=70 -> [300, 70+20] + expect(value.rungs[0].reactFlowViewport).toEqual([300, 90]) + }) + + it('floors the canonical viewport at defaultBounds', () => { + const pou = makeLdPou( + [{ id: 'n1', position: { x: 10, y: 10 }, width: 10, height: 10, draggable: true, data: { draggable: true } }], + { defaultBounds: [800, 200], reactFlowViewport: [123, 456] }, + ) + const value = sanitizePou(pou, undefined).body.value as { rungs: { reactFlowViewport: number[] }[] } + expect(value.rungs[0].reactFlowViewport).toEqual([800, 220]) + }) + + it('prefers measured dimensions over declared width/height for the viewport', () => { + const pou = makeLdPou( + [ + { + id: 'n1', + position: { x: 400, y: 0 }, + width: 999, + height: 999, + measured: { width: 50, height: 30 }, + draggable: true, + data: { draggable: true }, + }, + ], + { defaultBounds: [10, 10], reactFlowViewport: [1, 1] }, + ) + const value = sanitizePou(pou, undefined).body.value as { rungs: { reactFlowViewport: number[] }[] } + expect(value.rungs[0].reactFlowViewport).toEqual([450, 60]) + }) + + it('passes the stored viewport through when the rung has no defaultBounds', () => { + const pou = makeLdPou([], { reactFlowViewport: [111, 222] }) + const value = sanitizePou(pou, undefined).body.value as { rungs: { reactFlowViewport: number[] }[] } + expect(value.rungs[0].reactFlowViewport).toEqual([111, 222]) + }) + + it('expands the viewport for negative positions and tolerates nodes without geometry', () => { + const pou = makeLdPou( + [ + { id: 'neg', position: { x: -50, y: -30 }, width: 10, height: 10, draggable: true, data: { draggable: true } }, + { id: 'bare', draggable: true, data: { draggable: true } }, + ], + { defaultBounds: [10, 10], reactFlowViewport: [1, 1] }, + ) + const value = sanitizePou(pou, undefined).body.value as { rungs: { reactFlowViewport: number[] }[] } + // minX=-50, minY=-30; max bounds stay at the synthetic 150x40 -> [200, 70+20] + expect(value.rungs[0].reactFlowViewport).toEqual([200, 90]) + }) + + it('treats non-numeric defaultBounds entries as zero for the viewport floor', () => { + const pou = makeLdPou( + [{ id: 'n1', position: { x: 0, y: 0 }, width: 10, height: 10, draggable: true, data: { draggable: true } }], + { defaultBounds: ['not-a-number', null], reactFlowViewport: [9, 9] }, + ) + const value = sanitizePou(pou, undefined).body.value as { rungs: { reactFlowViewport: number[] }[] } + // Floors collapse to 0 -> pure content bounds (synthetic origin node 150x40). + expect(value.rungs[0].reactFlowViewport).toEqual([150, 60]) + }) + + it('leaves non-array FBD rung nodes untouched', () => { + const pou = { + name: 'MyFbd', + pouType: 'program', + interface: { variables: [] }, + body: { + language: 'fbd', + value: { name: 'MyFbd', rung: { nodes: 'not-an-array', edges: [], selectedNodes: [] } }, + }, + documentation: '', + } as unknown as PLCPou + const value = sanitizePou(pou, undefined).body.value as { rung: { nodes: unknown } } + expect(value.rung.nodes).toBe('not-an-array') + }) + + it('returns the POU unchanged when an LD body has no rungs array', () => { + const pou = { + name: 'NoRungs', + pouType: 'program', + interface: { variables: [] }, + body: { language: 'ld', value: { name: 'NoRungs' } }, + documentation: '', + } as unknown as PLCPou + expect(sanitizePou(pou, undefined)).toBe(pou) + }) +}) + // --------------------------------------------------------------------------- // collectDebugVariables // --------------------------------------------------------------------------- diff --git a/src/frontend/utils/graphical/__tests__/sync-nodes-with-variables.test.ts b/src/frontend/utils/graphical/__tests__/sync-nodes-with-variables.test.ts index 2960d73d4..fa5e133f5 100644 --- a/src/frontend/utils/graphical/__tests__/sync-nodes-with-variables.test.ts +++ b/src/frontend/utils/graphical/__tests__/sync-nodes-with-variables.test.ts @@ -151,7 +151,7 @@ describe('syncNodesWithVariables', () => { expect(updateNode).toHaveBeenCalledWith(expect.objectContaining({ editorName: 'editor1' })) }) - it('keeps a variable node marked as wrongVariable when type cannot be resolved', () => { + it('skips variable-pin nodes whose pin type cannot be resolved (never judges against \'\')', () => { const updateNode = vi.fn() const variable = makeVariable('myVar', 'BOOL') const node = makeNode('n1', 'variable', { name: 'myVar' } as Partial, { wrongVariable: true }) @@ -164,8 +164,43 @@ describe('syncNodesWithVariables', () => { ] as unknown as Parameters[1] syncNodesWithVariables([variable], ladderFlows, updateNode) - // Node type 'variable' has no expected type (getBlockExpectedType returns ''), - // so the type comparison always fails and the node stays marked as wrong. + // No data.block.variableType -> expected type unknown -> don't judge. + // (The old behavior compared against '' and flagged every linked pin.) + expect(updateNode).not.toHaveBeenCalled() + }) + + const pinExtra = (pinType: string) => ({ + variant: 'input', + block: { + id: 'B1', + handleId: 'CU', + variableType: { name: 'CU', class: 'input', type: { definition: 'base-type', value: pinType } }, + }, + }) + + it('accepts a variable-pin node whose variable matches the pin type', () => { + const updateNode = vi.fn() + const variable = makeVariable('reset_in', 'BOOL') + const node = makeNode('n1', 'variable', { name: 'reset_in' } as Partial, pinExtra('BOOL')) + + const ladderFlows = [ + { name: 'editor1', rungs: [{ id: 'r1', nodes: [node], edges: [] }] }, + ] as unknown as Parameters[1] + + syncNodesWithVariables([variable], ladderFlows, updateNode) + expect(updateNode).not.toHaveBeenCalled() + }) + + it('flags a variable-pin node whose variable mismatches the pin type', () => { + const updateNode = vi.fn() + const variable = makeVariable('reset_in', 'INT') + const node = makeNode('n1', 'variable', { name: 'reset_in' } as Partial, pinExtra('BOOL')) + + const ladderFlows = [ + { name: 'editor1', rungs: [{ id: 'r1', nodes: [node], edges: [] }] }, + ] as unknown as Parameters[1] + + syncNodesWithVariables([variable], ladderFlows, updateNode) expect(updateNode).toHaveBeenCalledWith( expect.objectContaining({ node: expect.objectContaining({ @@ -178,6 +213,33 @@ describe('syncNodesWithVariables', () => { ) }) + it('clears a stale wrongVariable flag on a variable-pin node once the pin type matches', () => { + const updateNode = vi.fn() + const variable = makeVariable('reset_in', 'BOOL') + const node = makeNode( + 'n1', + 'variable', + { name: 'reset_in' } as Partial, + { ...pinExtra('BOOL'), wrongVariable: true }, + ) + + const ladderFlows = [ + { name: 'editor1', rungs: [{ id: 'r1', nodes: [node], edges: [] }] }, + ] as unknown as Parameters[1] + + syncNodesWithVariables([variable], ladderFlows, updateNode) + expect(updateNode).toHaveBeenCalledWith( + expect.objectContaining({ + node: expect.objectContaining({ + data: expect.objectContaining({ + variable, + wrongVariable: false, + }), + }), + }), + ) + }) + it('does not update a block node when nothing changed', () => { const updateNode = vi.fn() const variable = makeVariable('myVar', 'BOOL', 'base-type', '1') @@ -198,7 +260,7 @@ describe('syncNodesWithVariables', () => { expect(updateNode).not.toHaveBeenCalled() }) - it('marks a variable node as wrong when it has no expected type to compare', () => { + it('skips a variable node when it has no expected type to compare', () => { const updateNode = vi.fn() const variable = makeVariable('myVar', 'BOOL', 'base-type', '1') const node = makeNode('n1', 'variable', { @@ -215,21 +277,12 @@ describe('syncNodesWithVariables', () => { ] as unknown as Parameters[1] syncNodesWithVariables([variable], ladderFlows, updateNode) - // Node type 'variable' has no expected type (getBlockExpectedType returns ''), - // so the type comparison always fails and the node is flagged as wrong. - expect(updateNode).toHaveBeenCalledWith( - expect.objectContaining({ - node: expect.objectContaining({ - data: expect.objectContaining({ - variable: { ...variable, id: 'broken-n1' }, - wrongVariable: true, - }), - }), - }), - ) + // Unresolvable expected type -> the node is not judged (the old behavior + // compared against '' and flagged every linked pin as broken). + expect(updateNode).not.toHaveBeenCalled() }) - it('marks a block node as wrongVariable when variant has no name', () => { + it('skips a block node when its variant has no name (no expected type)', () => { const updateNode = vi.fn() const variable = makeVariable('myVar', 'INT') const node = makeNode('n1', 'block', { @@ -246,13 +299,7 @@ describe('syncNodesWithVariables', () => { ] as unknown as Parameters[1] syncNodesWithVariables([variable], ladderFlows, updateNode) - expect(updateNode).toHaveBeenCalledWith( - expect.objectContaining({ - node: expect.objectContaining({ - data: expect.objectContaining({ wrongVariable: true }), - }), - }), - ) + expect(updateNode).not.toHaveBeenCalled() }) it('clears wrongVariable when types now match (line 77)', () => { @@ -328,7 +375,7 @@ describe('syncNodesWithVariables', () => { }) describe('syncNodesWithVariablesFBD', () => { - it('marks a variable node as wrong when type cannot be resolved', () => { + it('skips a variable node when its type cannot be resolved', () => { const updateNode = vi.fn() const variable = makeVariable('myVar', 'INT', 'base-type', '2') const node = makeNode('n1', 'input-variable', { @@ -345,20 +392,9 @@ describe('syncNodesWithVariablesFBD', () => { ] as unknown as Parameters[1] syncNodesWithVariablesFBD([variable], fbdFlows, updateNode) - // Node type 'input-variable' has no expected type (getBlockExpectedType returns ''), - // so the type comparison always fails and the node is marked as wrong with a broken id. - expect(updateNode).toHaveBeenCalledWith( - expect.objectContaining({ - editorName: 'fbd1', - nodeId: 'n1', - node: expect.objectContaining({ - data: expect.objectContaining({ - variable: { ...variable, id: 'broken-n1' }, - wrongVariable: true, - }), - }), - }), - ) + // Unresolvable expected type -> not judged (the old behavior compared + // against '' and falsely flagged linked FBD variable nodes as broken). + expect(updateNode).not.toHaveBeenCalled() }) it('does not update when node has no variable', () => { @@ -429,11 +465,17 @@ describe('syncNodesWithVariablesFBD', () => { it('filters flows by editorName', () => { const updateNode = vi.fn() const variable = makeVariable('myVar', 'INT', 'base-type', '2') - const node = makeNode('n1', 'input-variable', { - name: 'myVar', - id: '1', - type: { definition: 'base-type', value: 'BOOL' } as PLCVariable['type'], - }) + // Block with a resolvable variant type that mismatches -> triggers an update. + const node = makeNode( + 'n1', + 'block', + { + name: 'myVar', + id: '1', + type: { definition: 'base-type', value: 'BOOL' } as PLCVariable['type'], + }, + { variant: { name: 'BOOL' } }, + ) const fbdFlows = [ { name: 'fbd1', rung: { nodes: [node], edges: [] } }, @@ -459,18 +501,8 @@ describe('syncNodesWithVariablesFBD', () => { >[1] syncNodesWithVariablesFBD([variable], fbdFlows, updateNode) - // Node type 'input-variable' has no expected type (getBlockExpectedType returns ''), - // so the type comparison always fails and the node is flagged as wrong. - expect(updateNode).toHaveBeenCalledWith( - expect.objectContaining({ - node: expect.objectContaining({ - data: expect.objectContaining({ - variable: { ...variable, id: 'broken-n1' }, - wrongVariable: true, - }), - }), - }), - ) + // Unresolvable expected type -> not judged (no false "broken" flags). + expect(updateNode).not.toHaveBeenCalled() }) it('clears wrongVariable on FBD node when types now match (line 136)', () => { diff --git a/src/frontend/utils/graphical/sync-nodes-with-variables.ts b/src/frontend/utils/graphical/sync-nodes-with-variables.ts index 165cb6748..0d7496197 100644 --- a/src/frontend/utils/graphical/sync-nodes-with-variables.ts +++ b/src/frontend/utils/graphical/sync-nodes-with-variables.ts @@ -23,6 +23,18 @@ const getBlockExpectedType = (node: Node): string => { return 'BOOL' } + // Variable-pin nodes (block-pin connectors): the expected type is the pin's + // declared type, carried in data.block.variableType. Their `variant` is the + // string 'input'/'output', so the generic variant branch below would yield + // '' and validateVariableType(x, '') is always false — which used to flag + // every LINKED pin as wrongVariable on each relink pass (project open, + // drag-stop), swapping its payload for a broken one and dirtying the POU. + if (node.type === 'variable') { + const pinType = (node.data as { block?: { variableType?: { type?: { value?: string } } } }).block?.variableType + ?.type?.value + return typeof pinType === 'string' ? pinType.trim().toUpperCase() : '' + } + if (variant && typeof variant.name === 'string') { return variant.name.trim().toUpperCase() } @@ -58,6 +70,10 @@ export const syncNodesWithVariables = ( const expectedType = getBlockExpectedType(node) + // Unknown expectation — don't judge. sameType(x, '') is always false, + // so flagging here would mark perfectly valid links as broken. + if (!expectedType) return + const isTheSameType = sameType(target.type.value, expectedType) if (!isTheSameType) { @@ -118,6 +134,10 @@ export const syncNodesWithVariablesFBD = ( const expectedType = getBlockExpectedType(node) + // Unknown expectation — don't judge. sameType(x, '') is always false, + // so flagging here would mark perfectly valid links as broken. + if (!expectedType) return + const isTheSameType = sameType(target.type.value, expectedType) if (!isTheSameType) { diff --git a/src/frontend/utils/save-project.ts b/src/frontend/utils/save-project.ts index 8ee01c450..2ed6066a2 100644 --- a/src/frontend/utils/save-project.ts +++ b/src/frontend/utils/save-project.ts @@ -57,6 +57,70 @@ export function sanitizePou(pou: PLCPou, editor: EditorLike | undefined): PLCPou return stripGraphicalSelections(next) } +/** + * Canonicalize one graphical node for persistence. Beyond clearing selection + * state, this neutralizes runtime UI state that would otherwise byte-drift + * the serialized file against HEAD without any semantic change: + * + * - `data.hasDivergence` is a render-time decoration (library-divergence + * tooltip) that can leak into the store via rungLocal-derived writes. + * - top-level `draggable` is toggled at runtime (drag-lock while a variable + * input is focused); `data.draggable` is the design-time value set by the + * node builders, so persist that instead. + */ +function sanitizeGraphicalNode(node: Record): Record { + const data = node.data as Record | undefined + const { hasDivergence: _hasDivergence, ...cleanData } = data ?? {} + return { + ...node, + ...(data !== undefined ? { data: cleanData } : {}), + selected: false, + dragging: false, + draggable: Boolean(cleanData.draggable), + } +} + +/** + * Deterministic `reactFlowViewport` from content, mirroring the formula in + * RungBody.updateReactFlowPanelExtent: bounds over all nodes plus the + * synthetic 150×40 origin node, floored at `defaultBounds`, +20px height + * padding. The runtime writes measured, timing-dependent values into + * `reactFlowViewport` (window size, drag history), so persisting the raw + * value makes byte stability depend on UI state. Serializing a pure function + * of content keeps unchanged rungs byte-identical. Returns the existing value + * when the rung lacks the inputs (e.g. FBD rungs have no defaultBounds). + */ +function canonicalRungViewport(rung: Record): unknown { + const nodes = rung.nodes + const defaultBounds = rung.defaultBounds + if (!Array.isArray(nodes) || !Array.isArray(defaultBounds)) return rung.reactFlowViewport + + let minX = 0 + let minY = 0 + let maxX = 150 + let maxY = 40 + for (const n of nodes as Array>) { + const pos = n.position as { x?: number; y?: number } | undefined + const measured = n.measured as { width?: number; height?: number } | undefined + const x = pos?.x ?? 0 + const y = pos?.y ?? 0 + const w = measured?.width ?? (n.width as number | undefined) ?? 0 + const h = measured?.height ?? (n.height as number | undefined) ?? 0 + if (x < minX) minX = x + if (y < minY) minY = y + if (x + w > maxX) maxX = x + w + if (y + h > maxY) maxY = y + h + } + + let width = maxX - minX + let height = maxY - minY + const defaultWidth = Number(defaultBounds[0]) || 0 + const defaultHeight = Number(defaultBounds[1]) || 0 + if (width < defaultWidth) width = defaultWidth + if (height < defaultHeight) height = defaultHeight + return [width, height + 20] +} + function stripGraphicalSelections(pou: PLCPou): PLCPou { const lang = pou.body.language if (lang !== 'ld' && lang !== 'fbd') return pou @@ -74,12 +138,9 @@ function stripGraphicalSelections(pou: PLCPou): PLCPou { rungs: (body.rungs as Array>).map((rung) => ({ ...rung, selectedNodes: [], + reactFlowViewport: canonicalRungViewport(rung), nodes: Array.isArray(rung.nodes) - ? (rung.nodes as Array>).map((n) => ({ - ...n, - selected: false, - dragging: false, - })) + ? (rung.nodes as Array>).map(sanitizeGraphicalNode) : rung.nodes, })), }, @@ -99,11 +160,7 @@ function stripGraphicalSelections(pou: PLCPou): PLCPou { ...rung, selectedNodes: [], nodes: Array.isArray(rung.nodes) - ? (rung.nodes as Array>).map((n) => ({ - ...n, - selected: false, - dragging: false, - })) + ? (rung.nodes as Array>).map(sanitizeGraphicalNode) : rung.nodes, }, }, From 13465baa1e785308f8b82c8a01c8cd5a001dec71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Tue, 7 Jul 2026 09:20:00 -0300 Subject: [PATCH 12/52] style: apply prettier formatting Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WYE8gdREZQNYQeLfFuuZ9T --- .../_atoms/graphical-editor/ladder/block.tsx | 5 +--- .../__tests__/variable-block.test.ts | 8 ++--- .../elements/variable-block/index.ts | 4 +-- .../store/__tests__/ladder-slice.test.ts | 9 ++++-- .../utils/__tests__/save-project.test.ts | 23 +++++++++++--- .../sync-nodes-with-variables.test.ts | 30 +++++++++---------- 6 files changed, 43 insertions(+), 36 deletions(-) diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx index 3d1df1696..96251e637 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx @@ -559,10 +559,7 @@ export const Block = (block: BlockProps) => { // Blur with an unchanged name is not an edit — skip the write so merely // clicking in and out of a block never marks the POU as modified. The // autocomplete's explicit create action (createIfNotFound) still proceeds. - if ( - !createIfNotFound && - variableNameToSubmit === (node.data as { variable?: { name?: string } }).variable?.name - ) { + if (!createIfNotFound && variableNameToSubmit === (node.data as { variable?: { name?: string } }).variable?.name) { return } diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/__tests__/variable-block.test.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/__tests__/variable-block.test.ts index ff9a38a82..64d9916b5 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/__tests__/variable-block.test.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/__tests__/variable-block.test.ts @@ -65,9 +65,7 @@ describe('updateVariableBlockPosition identity reuse', () => { const variables = nodes.filter((n) => n.type === 'variable') expect(variables).toHaveLength(3) - const byHandle = new Map( - variables.map((n) => [(n.data as { block: { handleId: string } }).block.handleId, n]), - ) + const byHandle = new Map(variables.map((n) => [(n.data as { block: { handleId: string } }).block.handleId, n])) expect(byHandle.get('CU')?.id).toBe('VARIABLE_prev_cu') expect((byHandle.get('CU')?.data as { numericId: number }).numericId).toBe(111) expect(byHandle.get('PV')?.id).toBe('VARIABLE_prev_pv') @@ -120,9 +118,7 @@ describe('updateVariableBlockPosition identity reuse', () => { const variables = nodes.filter((n) => n.type === 'variable') expect(variables).toHaveLength(3) - const byHandle = new Map( - variables.map((n) => [(n.data as { block: { handleId: string } }).block.handleId, n]), - ) + const byHandle = new Map(variables.map((n) => [(n.data as { block: { handleId: string } }).block.handleId, n])) expect(byHandle.get('CU')?.id).toBe('VARIABLE_prev_cu') expect(byHandle.get('PV')?.id).toBe('VARIABLE_prev_pv') expect(byHandle.get('CV')?.id).toBe('VARIABLE_prev_cv') diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts index 92c900959..e63922e8d 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts @@ -174,9 +174,7 @@ export const updateVariableBlockPosition = (rung: RungLadderState, previousNodes // `previousNodes` is a fallback identity source for pipelines that strip // variable nodes before layout (the drag-drop flow removes them in // `prepareDropState`) — entries from `rung.nodes` win when both exist. - const previousIdentities = collectVariableIdentities( - previousNodes ? [...previousNodes, ...rung.nodes] : rung.nodes, - ) + const previousIdentities = collectVariableIdentities(previousNodes ? [...previousNodes, ...rung.nodes] : rung.nodes) const { nodes: removedVariableNodes, edges: removedVariableEdges } = removeVariableBlock(rung) newNodes = removedVariableNodes diff --git a/src/frontend/store/__tests__/ladder-slice.test.ts b/src/frontend/store/__tests__/ladder-slice.test.ts index 49b3e62f6..f8151b97e 100644 --- a/src/frontend/store/__tests__/ladder-slice.test.ts +++ b/src/frontend/store/__tests__/ladder-slice.test.ts @@ -498,9 +498,12 @@ describe('createLadderFlowSlice', () => { seedFlowWithRung(store, 'editor-1', rung) store.getState().ladderFlowActions.setFlowUpdated({ editorName: 'editor-1', updated: false }) - store - .getState() - .ladderFlowActions.updateNode({ editorName: 'editor-1', rungId: 'rung-1', nodeId: 'n1', node: makeNode({ id: 'n1' }) }) + store.getState().ladderFlowActions.updateNode({ + editorName: 'editor-1', + rungId: 'rung-1', + nodeId: 'n1', + node: makeNode({ id: 'n1' }), + }) expect(store.getState().ladderFlows[0].updated).toBe(true) }) diff --git a/src/frontend/utils/__tests__/save-project.test.ts b/src/frontend/utils/__tests__/save-project.test.ts index 40d41ff0e..288aaf54d 100644 --- a/src/frontend/utils/__tests__/save-project.test.ts +++ b/src/frontend/utils/__tests__/save-project.test.ts @@ -118,14 +118,18 @@ describe('graphical node sanitization', () => { it('clears selection state and resets selectedNodes on LD rungs', () => { const pou = makeLdPou([{ id: 'n1', selected: true, dragging: true, draggable: true, data: { draggable: true } }]) - const value = sanitizePou(pou, undefined).body.value as { rungs: { selectedNodes: unknown[]; nodes: Record[] }[] } + const value = sanitizePou(pou, undefined).body.value as { + rungs: { selectedNodes: unknown[]; nodes: Record[] }[] + } expect(value.rungs[0].selectedNodes).toEqual([]) expect(value.rungs[0].nodes[0].selected).toBe(false) expect(value.rungs[0].nodes[0].dragging).toBe(false) }) it('strips the hasDivergence render decoration from LD node data', () => { - const pou = makeLdPou([{ id: 'n1', draggable: true, data: { draggable: true, hasDivergence: false, variable: { name: 'X' } } }]) + const pou = makeLdPou([ + { id: 'n1', draggable: true, data: { draggable: true, hasDivergence: false, variable: { name: 'X' } } }, + ]) const value = sanitizePou(pou, undefined).body.value as { rungs: { nodes: { data: Record }[] }[] } expect('hasDivergence' in value.rungs[0].nodes[0].data).toBe(false) expect(value.rungs[0].nodes[0].data.variable).toEqual({ name: 'X' }) @@ -161,7 +165,15 @@ describe('graphical node sanitization', () => { value: { name: 'MyFbd', rung: { - nodes: [{ id: 'n1', selected: true, dragging: true, draggable: false, data: { draggable: true, hasDivergence: true } }], + nodes: [ + { + id: 'n1', + selected: true, + dragging: true, + draggable: false, + data: { draggable: true, hasDivergence: true }, + }, + ], edges: [], selectedNodes: [{ id: 'ghost' }], }, @@ -170,7 +182,10 @@ describe('graphical node sanitization', () => { documentation: '', } as unknown as PLCPou const value = sanitizePou(pou, undefined).body.value as { - rung: { selectedNodes: unknown[]; nodes: { selected: boolean; dragging: boolean; draggable: boolean; data: Record }[] } + rung: { + selectedNodes: unknown[] + nodes: { selected: boolean; dragging: boolean; draggable: boolean; data: Record }[] + } } expect(value.rung.selectedNodes).toEqual([]) expect(value.rung.nodes[0].selected).toBe(false) diff --git a/src/frontend/utils/graphical/__tests__/sync-nodes-with-variables.test.ts b/src/frontend/utils/graphical/__tests__/sync-nodes-with-variables.test.ts index fa5e133f5..f5aa28d92 100644 --- a/src/frontend/utils/graphical/__tests__/sync-nodes-with-variables.test.ts +++ b/src/frontend/utils/graphical/__tests__/sync-nodes-with-variables.test.ts @@ -151,7 +151,7 @@ describe('syncNodesWithVariables', () => { expect(updateNode).toHaveBeenCalledWith(expect.objectContaining({ editorName: 'editor1' })) }) - it('skips variable-pin nodes whose pin type cannot be resolved (never judges against \'\')', () => { + it("skips variable-pin nodes whose pin type cannot be resolved (never judges against '')", () => { const updateNode = vi.fn() const variable = makeVariable('myVar', 'BOOL') const node = makeNode('n1', 'variable', { name: 'myVar' } as Partial, { wrongVariable: true }) @@ -183,9 +183,9 @@ describe('syncNodesWithVariables', () => { const variable = makeVariable('reset_in', 'BOOL') const node = makeNode('n1', 'variable', { name: 'reset_in' } as Partial, pinExtra('BOOL')) - const ladderFlows = [ - { name: 'editor1', rungs: [{ id: 'r1', nodes: [node], edges: [] }] }, - ] as unknown as Parameters[1] + const ladderFlows = [{ name: 'editor1', rungs: [{ id: 'r1', nodes: [node], edges: [] }] }] as unknown as Parameters< + typeof syncNodesWithVariables + >[1] syncNodesWithVariables([variable], ladderFlows, updateNode) expect(updateNode).not.toHaveBeenCalled() @@ -196,9 +196,9 @@ describe('syncNodesWithVariables', () => { const variable = makeVariable('reset_in', 'INT') const node = makeNode('n1', 'variable', { name: 'reset_in' } as Partial, pinExtra('BOOL')) - const ladderFlows = [ - { name: 'editor1', rungs: [{ id: 'r1', nodes: [node], edges: [] }] }, - ] as unknown as Parameters[1] + const ladderFlows = [{ name: 'editor1', rungs: [{ id: 'r1', nodes: [node], edges: [] }] }] as unknown as Parameters< + typeof syncNodesWithVariables + >[1] syncNodesWithVariables([variable], ladderFlows, updateNode) expect(updateNode).toHaveBeenCalledWith( @@ -216,16 +216,14 @@ describe('syncNodesWithVariables', () => { it('clears a stale wrongVariable flag on a variable-pin node once the pin type matches', () => { const updateNode = vi.fn() const variable = makeVariable('reset_in', 'BOOL') - const node = makeNode( - 'n1', - 'variable', - { name: 'reset_in' } as Partial, - { ...pinExtra('BOOL'), wrongVariable: true }, - ) + const node = makeNode('n1', 'variable', { name: 'reset_in' } as Partial, { + ...pinExtra('BOOL'), + wrongVariable: true, + }) - const ladderFlows = [ - { name: 'editor1', rungs: [{ id: 'r1', nodes: [node], edges: [] }] }, - ] as unknown as Parameters[1] + const ladderFlows = [{ name: 'editor1', rungs: [{ id: 'r1', nodes: [node], edges: [] }] }] as unknown as Parameters< + typeof syncNodesWithVariables + >[1] syncNodesWithVariables([variable], ladderFlows, updateNode) expect(updateNode).toHaveBeenCalledWith( From 365e09985dbd486e34f58ec4460ea872db88e548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Tue, 7 Jul 2026 11:22:25 -0300 Subject: [PATCH 13/52] fix: address CodeRabbit review findings - Merge instead of replace headContent on diff fetch failure so a transient error does not evict other files' cached HEAD entries - Fetch node/rung fresh inside block.tsx focus/blur handlers (matches contact/coil) instead of closing over render-scope values - Rename loop-local previousNodes to previousLinkedNodes in diagram/index.ts to stop shadowing the identity-source parameter Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WYE8gdREZQNYQeLfFuuZ9T --- .../_atoms/graphical-editor/ladder/block.tsx | 6 ++++++ .../[workspace]/editor/diff-viewer/index.tsx | 5 +++-- .../rung/ladder-utils/elements/diagram/index.ts | 15 +++++++++------ .../store/__tests__/version-control-slice.test.ts | 13 +++++++++++++ .../store/slices/version-control/slice.ts | 7 +++++++ .../store/slices/version-control/types.ts | 2 ++ 6 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx index 96251e637..0e6553ee3 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx @@ -879,6 +879,9 @@ export const Block = (block: BlockProps) => { handleSubmit={() => handleSubmitBlockVariableOnTextareaBlur(blockVariableValue, false)} onFocus={(e) => { e.target.select() + const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { + nodeId: id, + }) if (!node || !rung) return // Drag-lock while typing is UI state, not an edit — transient // so focusing the input never marks the flow as modified. @@ -895,6 +898,9 @@ export const Block = (block: BlockProps) => { return }} onBlur={() => { + const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { + nodeId: id, + }) if (!node || !rung) return updateNode({ editorName: pouName, diff --git a/src/frontend/components/_features/[workspace]/editor/diff-viewer/index.tsx b/src/frontend/components/_features/[workspace]/editor/diff-viewer/index.tsx index 93b4ee2fb..c34c610dc 100644 --- a/src/frontend/components/_features/[workspace]/editor/diff-viewer/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/diff-viewer/index.tsx @@ -62,6 +62,7 @@ export function DiffViewerEditor() { // of each diff. `null` = not yet loaded → fetched lazily below. const headContent = useOpenPLCStore((s) => s.versionControl.headContent) const setHeadContent = useOpenPLCStore((s) => s.versionControlActions.setHeadContent) + const mergeHeadContent = useOpenPLCStore((s) => s.versionControlActions.mergeHeadContent) const filePath = editor.type === 'diff-viewer' ? editor.meta.filePath : '' @@ -101,13 +102,13 @@ export function DiffViewerEditor() { // Cache an (empty) entry for the open path even on failure so // `headReady` doesn't retry in a tight loop; the next mount or path // change triggers a fresh attempt. - if (!cancelled) setHeadContent(filePath ? { [filePath]: '' } : {}) + if (!cancelled) mergeHeadContent(filePath ? { [filePath]: '' } : {}) } })() return () => { cancelled = true } - }, [headReady, filePath, projectId, versionControl, setHeadContent]) + }, [headReady, filePath, projectId, versionControl, setHeadContent, mergeHeadContent]) const original = headReady && headContent && filePath ? (headContent[filePath] ?? '') : '' diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts index fc4884733..125c60f77 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts @@ -454,14 +454,17 @@ export const updateDiagramElementsPosition = ( /** * Find the previous nodes and edges of the current node */ - const { nodes: previousNodes, edges: previousEdges } = getPreviousElementsByEdge({ ...rung, nodes: newNodes }, node) - if (!previousNodes || !previousEdges) return { nodes: rung.nodes, edges: rung.edges } + const { nodes: previousLinkedNodes, edges: previousEdges } = getPreviousElementsByEdge( + { ...rung, nodes: newNodes }, + node, + ) + if (!previousLinkedNodes || !previousEdges) return { nodes: rung.nodes, edges: rung.edges } - if (previousNodes.all.length === 1) { + if (previousLinkedNodes.all.length === 1) { /** * Nodes that only have one edge connecting to them */ - const previousNode = previousNodes.all[0] + const previousNode = previousLinkedNodes.all[0] if ( isNodeOfType(previousNode, 'parallel') && (previousNode as ParallelNode).data.type === 'open' && @@ -486,8 +489,8 @@ export const updateDiagramElementsPosition = ( * This is used to calculate the position of the new node */ let acc = newNodePosition - for (let j = 0; j < previousNodes.all.length; j++) { - const previousNode = previousNodes.all[j] + for (let j = 0; j < previousLinkedNodes.all.length; j++) { + const previousNode = previousLinkedNodes.all[j] const position = getNodePositionBasedOnPreviousNode(previousNode, node, 'serial') acc = { posX: Math.max(acc.posX, position.posX), diff --git a/src/frontend/store/__tests__/version-control-slice.test.ts b/src/frontend/store/__tests__/version-control-slice.test.ts index c89fc4e3c..93d2a87a5 100644 --- a/src/frontend/store/__tests__/version-control-slice.test.ts +++ b/src/frontend/store/__tests__/version-control-slice.test.ts @@ -55,6 +55,19 @@ describe('createVersionControlSlice', () => { }) }) + describe('mergeHeadContent', () => { + it('creates the snapshot from null', () => { + actions().mergeHeadContent({ 'a.st': 'head-a' }) + expect(vc().headContent).toEqual({ 'a.st': 'head-a' }) + }) + + it('merges entries without dropping the rest of the map', () => { + actions().setHeadContent({ 'a.st': 'head-a', 'b.st': 'head-b' }) + actions().mergeHeadContent({ 'b.st': 'updated', 'c.st': 'head-c' }) + expect(vc().headContent).toEqual({ 'a.st': 'head-a', 'b.st': 'updated', 'c.st': 'head-c' }) + }) + }) + it('initBaseline resets the cached HEAD snapshot to null', () => { actions().setHeadContent({ 'a.st': 'x' }) actions().initBaseline({ diff --git a/src/frontend/store/slices/version-control/slice.ts b/src/frontend/store/slices/version-control/slice.ts index 173308ee7..4b4590606 100644 --- a/src/frontend/store/slices/version-control/slice.ts +++ b/src/frontend/store/slices/version-control/slice.ts @@ -58,6 +58,13 @@ const createVersionControlSlice: StateCreator) => + setState( + produce((draft) => { + draft.versionControl.headContent = { ...(draft.versionControl.headContent ?? {}), ...entries } + }), + ), + initBaseline: ({ initialPending, baselineContent, rawLoadedContent, loadedSerialized }) => setState( produce((draft) => { diff --git a/src/frontend/store/slices/version-control/types.ts b/src/frontend/store/slices/version-control/types.ts index e823de507..8bb0b0e97 100644 --- a/src/frontend/store/slices/version-control/types.ts +++ b/src/frontend/store/slices/version-control/types.ts @@ -76,6 +76,8 @@ export type VersionControlActions = { /** Set (or clear, with `null`) the lazily-fetched HEAD snapshot used as the * "original" side of source-control diffs. */ setHeadContent: (content: Record | null) => void + /** Merge entries into the HEAD snapshot without dropping the rest of the map (creates it when `null`). */ + mergeHeadContent: (entries: Record) => void /** * Snapshot baseline + initial pending at the last "in-sync" point * (project load, after restore, after discard). From 7752bd2aaa43808ea0c0552499b481a0b5335567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Wed, 8 Jul 2026 16:21:32 -0300 Subject: [PATCH 14/52] fix: prevent located interface-class variables from corrupting POU variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A variable with an interface class (input/output/inOut/external/temp) and a physical location ("AT") is invalid IEC 61131-3. The editor let users create one anyway — the location cell's selected branch skipped the read-only guard — saved it, and on reopen the ST parser rejected the declaration, silently discarding every variable of the POU (GitHub issue #904). - variables-table: honor the read-only rule in the location cell's selected branch so a location can't be attached to an interface-class variable - store validation: reject a location update on an interface-class variable, clear the location when the class changes to one, and strip it on create, so non-table callers keep the same invariant - project load: push a warning (surfaced in the console panel) when a POU fails to parse instead of silently loading it with no variables - parser: include the offending line and a repair hint in the located-declaration error so the code-view repair path is actionable Fixes #904 (DOPE-445) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FsPXB6bqvFU4RLBQ6snwDp --- .../__tests__/parse-project-files.test.ts | 36 +++++++++++++++++ .../shared/utils/parse-project-files.ts | 17 ++++++-- .../variables-table/editable-cell.tsx | 7 +++- .../project-validation-variables.test.ts | 39 ++++++++++++++++++- .../slices/project/validation/variables.ts | 34 +++++++++++++++- .../generate-iec-string-to-variables.test.ts | 7 ++++ .../utils/generate-iec-string-to-variables.ts | 21 ++++++++-- 7 files changed, 149 insertions(+), 12 deletions(-) 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 c93a423f8..dfbd21cdb 100644 --- a/src/backend/shared/utils/__tests__/parse-project-files.test.ts +++ b/src/backend/shared/utils/__tests__/parse-project-files.test.ts @@ -212,6 +212,42 @@ describe('parseProjectFiles — fallback POU creation', () => { consoleSpy.mockRestore() }) + it('warns and preserves declarations when a PROGRAM has a located interface-class variable (issue #904)', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const pouFiles: RawProjectFile[] = [ + { + relativePath: 'pous/programs/main.st', + content: + 'PROGRAM main\nVAR_OUTPUT\n Q1 : BOOL AT %QX0.0;\nEND_VAR\nVAR\n latch : RS;\nEND_VAR\n\n\nEND_PROGRAM', + }, + ] + const result = parseProjectFiles('/p', makeProjectJson(), makeDeviceConfig(), makePinMapping(), pouFiles, [], []) + // Falls back: the structured variable list is empty, but the raw + // declarations survive in variablesText for in-app repair. + expect(result.projectData.pous).toHaveLength(1) + expect(result.projectData.pous[0].interface?.variables).toEqual([]) + expect(result.projectData.pous[0].variablesText).toContain('AT %QX0.0') + // The failure is surfaced: names the POU and file, states the offending + // rule, and points at the repair path. + expect(result.warnings).toBeDefined() + const warning = result.warnings!.find((w) => w.includes('pous/programs/main.st')) + expect(warning).toContain('POU "main"') + expect(warning).toMatch(/Location \("AT"\) is not allowed for variables of class "OUTPUT"/) + expect(warning).toContain('code view') + consoleSpy.mockRestore() + }) + + it('warns with a partial-data message when a graphical POU fails to parse', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const pouFiles: RawProjectFile[] = [ + { relativePath: 'pous/programs/FbdPou.fbd', content: 'PROGRAM FbdPou\nnot valid json\nEND_PROGRAM' }, + ] + const result = parseProjectFiles('/p', makeProjectJson(), makeDeviceConfig(), makePinMapping(), pouFiles, [], []) + expect(result.warnings).toBeDefined() + expect(result.warnings!.some((w) => w.includes('FbdPou') && w.includes('partial data'))).toBe(true) + consoleSpy.mockRestore() + }) + it('fallback extracts documentation from (* ... *) comments', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const pouFiles: RawProjectFile[] = [ diff --git a/src/backend/shared/utils/parse-project-files.ts b/src/backend/shared/utils/parse-project-files.ts index 646885ec5..d4bff5f87 100644 --- a/src/backend/shared/utils/parse-project-files.ts +++ b/src/backend/shared/utils/parse-project-files.ts @@ -257,7 +257,7 @@ function foldLegacyVariableAliases(value: unknown): unknown { return value } -function parsePouFile(file: RawProjectFile): (PLCPou & { variablesText?: string }) | null { +function parsePouFile(file: RawProjectFile, warnings: string[]): (PLCPou & { variablesText?: string }) | null { const ext = file.relativePath.split('.').pop()?.toLowerCase() /* istanbul ignore if -- defensive: parseProjectFiles upstream only forwards files whose extension matched the POU file glob; an extension-less file path can never reach here */ @@ -303,9 +303,20 @@ function parsePouFile(file: RawProjectFile): (PLCPou & { variablesText?: string } } catch (err) { console.error(`[parseProjectFiles] Failed to parse POU: ${file.relativePath}`, err) + const pouName = getBaseNameFromPath(file.relativePath) + const reason = + err instanceof Error ? err.message : /* istanbul ignore next -- every parser throw site uses Error */ String(err) + // Surface the failure on project open (the console panel shows these + // warnings) instead of silently loading the POU with no variables — + // GitHub issue #904. For textual POUs the raw declarations survive in + // `variablesText`, so point the user at the in-app repair path. + warnings.push( + language === 'st' || language === 'il' + ? `POU "${pouName}" (${file.relativePath}) could not be fully parsed: ${reason} Its variable declarations were preserved as raw text — open the POU's variables editor in code view, fix the declaration, and save.` + : `POU "${pouName}" (${file.relativePath}) could not be fully parsed and was loaded with partial data: ${reason}`, + ) // Fallback: preserve as much data as possible try { - const pouName = getBaseNameFromPath(file.relativePath) return createFallbackPou(file.content, language, pouType, pouName) } catch (fallbackErr) { /* istanbul ignore next -- defensive: createFallbackPou itself is non-throwing for any @@ -465,7 +476,7 @@ export function parseProjectFiles( // Parse POU files const pous: (PLCPou & { variablesText?: string })[] = [] for (const file of filteredPouFiles) { - const pou = parsePouFile(file) + const pou = parsePouFile(file, warnings) if (pou) { // Ensure all POUs have a name (derive from filename if missing) if (!pou.name) { diff --git a/src/frontend/components/_molecules/variables-table/editable-cell.tsx b/src/frontend/components/_molecules/variables-table/editable-cell.tsx index 570620081..100462652 100644 --- a/src/frontend/components/_molecules/variables-table/editable-cell.tsx +++ b/src/frontend/components/_molecules/variables-table/editable-cell.tsx @@ -568,7 +568,12 @@ const EditableLocationCell = ({ /> ) : null - return selected ? ( + // The read-only rule must gate the selected branch too: without the + // `isEditable()` check the combobox rendered fully interactive on any + // selected row, letting the user attach a location to an interface-class + // (input/output/inOut/external/temp) variable — an invalid declaration + // that broke the project on reopen (GitHub issue #904). + return selected && isEditable() ? (
{warningGlyph} { // =========================================================================== describe('createVariableValidation', () => { + it('strips the location when creating an interface-class variable (issue #904)', () => { + const result = createVariableValidation([], makeVariable('Q1', 'BOOL', '%QX0.0', 'output')) + expect(result).toEqual({ name: 'Q1', location: '' }) + }) + it('returns unchanged name and location when no conflicts', () => { const variable = makeVariable('NewVar', 'INT', '') const result = createVariableValidation([], variable) @@ -410,10 +415,19 @@ describe('createVariableValidation', () => { describe('updateVariableValidation', () => { const existingVars = [makeVariable('Var1', 'INT', '%QW0'), makeVariable('Var2', 'BOOL', '%QX0.0')] - it('returns ok: true when updating class only', () => { + it('clears an existing location when class changes to an interface class (issue #904)', () => { + // Var1 holds location '%QW0'; a located VAR_OUTPUT entry is invalid IEC + // and would fail to parse on project reopen, so the class change must + // clear the location in the same update. const result = updateVariableValidation(existingVars, { class: 'output' }, existingVars[0]) expect(result.ok).toBe(true) - expect(result.data).toEqual({ class: 'output' }) + expect(result.data).toEqual({ class: 'output', location: '' }) + }) + + it('keeps the location when class changes to local', () => { + const result = updateVariableValidation(existingVars, { class: 'local' }, existingVars[0]) + expect(result.ok).toBe(true) + expect(result.data).toEqual({ class: 'local' }) }) // -- Name validation -- @@ -441,6 +455,27 @@ describe('updateVariableValidation', () => { }) // -- Location validation -- + it('rejects a location on an interface-class variable (issue #904)', () => { + const outputVar = makeVariable('OutVar', 'BOOL', '', 'output') + const result = updateVariableValidation([outputVar], { location: '%QX0.1' }, outputVar) + expect(result.ok).toBe(false) + expect(result.title).toContain('Location is not allowed') + expect(result.message).toContain('OUTPUT') + }) + + it('rejects a combined update setting an interface class and a location together', () => { + const localVar = makeVariable('LVar', 'BOOL', '') + const result = updateVariableValidation([localVar], { class: 'input', location: '%IX0.0' }, localVar) + expect(result.ok).toBe(false) + expect(result.message).toContain('INPUT') + }) + + it('accepts a location in a combined update that sets class local', () => { + const inputVar = makeVariable('IVar', 'BOOL', '', 'input') + const result = updateVariableValidation([], { class: 'local', location: '%QX0.0' }, inputVar) + expect(result.ok).toBe(true) + }) + it('returns error when location already exists', () => { const result = updateVariableValidation(existingVars, { location: '%QW0' }, existingVars[1]) expect(result.ok).toBe(false) diff --git a/src/frontend/store/slices/project/validation/variables.ts b/src/frontend/store/slices/project/validation/variables.ts index 79acb2dff..b8baf4ee2 100644 --- a/src/frontend/store/slices/project/validation/variables.ts +++ b/src/frontend/store/slices/project/validation/variables.ts @@ -1,4 +1,5 @@ import type { PLCVariable } from '../../../../../middleware/shared/ports/types' +import { DISALLOWED_LOCATION_CLASSES } from '../../../../utils/generate-iec-string-to-variables' import { BOOL_LOCATION_REGEX, DWORD_LOCATION_REGEX, @@ -308,7 +309,12 @@ const createVariableValidation = ( variables: PLCVariable[], variable: PLCVariable, ): { name: string; location: string } => { - const { name: variableName, location: variableLocation } = variable + const { name: variableName } = variable + // Interface-class variables cannot carry a physical location — the ST + // parser rejects such declarations when the project is reopened + // (GitHub issue #904). Strip the location instead of rejecting so + // creation flows that clone an existing row as a template still succeed. + const variableLocation = DISALLOWED_LOCATION_CLASSES.includes(variable.class) ? '' : variable.location const response = { name: variableName, location: variableLocation } if (checkIfVariableExists(variables, variableName)) { @@ -351,7 +357,16 @@ const updateVariableValidation = ( ) => { let response: ProjectResponse = { ok: true } - if (dataToBeUpdated.class) response.data = { class: dataToBeUpdated.class } + if (dataToBeUpdated.class) { + // Switching to an interface class makes an existing physical location + // invalid IEC — the saved declaration would fail to parse on reopen + // (GitHub issue #904) — so clear the location in the same update. + // Enforced here (not only in the table UI) so every caller keeps the + // invariant. + response.data = DISALLOWED_LOCATION_CLASSES.includes(dataToBeUpdated.class) + ? { class: dataToBeUpdated.class, location: '' } + : { class: dataToBeUpdated.class } + } if (dataToBeUpdated.name || dataToBeUpdated.name === '') { const { name } = dataToBeUpdated @@ -385,6 +400,21 @@ const updateVariableValidation = ( if (dataToBeUpdated.location) { const { location } = dataToBeUpdated + + // A physical location is only valid on `local` (VAR) and `global` + // (VAR_GLOBAL) declarations — mirrors the parser rule that makes a + // located interface-class variable un-parseable on project reopen + // (GitHub issue #904). + const effectiveClass = dataToBeUpdated.class ?? variableToUpdate.class + if (effectiveClass && DISALLOWED_LOCATION_CLASSES.includes(effectiveClass)) { + response = { + ok: false, + title: 'Location is not allowed.', + message: `Variables of class "${effectiveClass.toUpperCase()}" cannot have a physical location ("AT"). Use class LOCAL for located variables.`, + } + return response + } + // Exclude the variable being updated so re-setting its own // location (e.g. re-picking the same address to refresh a // renamed alias) doesn't trip the uniqueness check on itself. diff --git a/src/frontend/utils/__tests__/generate-iec-string-to-variables.test.ts b/src/frontend/utils/__tests__/generate-iec-string-to-variables.test.ts index c8754e9c1..feda16195 100644 --- a/src/frontend/utils/__tests__/generate-iec-string-to-variables.test.ts +++ b/src/frontend/utils/__tests__/generate-iec-string-to-variables.test.ts @@ -191,6 +191,13 @@ describe('parseIecStringToVariables', () => { expect(() => parseIecStringToVariables(input)).toThrow(/Location.*not allowed.*OUTPUT/) }) + it('includes the offending line and a repair hint in the located-class error (issue #904)', () => { + const input = 'VAR_OUTPUT\n actuator AT %QX0.0 : BOOL;\nEND_VAR' + expect(() => parseIecStringToVariables(input)).toThrow( + 'Syntax error on line 2: "actuator AT %QX0.0 : BOOL;". Location ("AT") is not allowed for variables of class "OUTPUT". Move "actuator" to a VAR block (class LOCAL) or remove the "AT %QX0.0" clause.', + ) + }) + it('throws when location is used with inOut class', () => { const input = 'VAR_IN_OUT\n x AT %MW0 : INT;\nEND_VAR' expect(() => parseIecStringToVariables(input)).toThrow(/Location.*not allowed.*INOUT/) diff --git a/src/frontend/utils/generate-iec-string-to-variables.ts b/src/frontend/utils/generate-iec-string-to-variables.ts index bbcb35bb3..53915b241 100644 --- a/src/frontend/utils/generate-iec-string-to-variables.ts +++ b/src/frontend/utils/generate-iec-string-to-variables.ts @@ -12,6 +12,21 @@ const varBlockToClass: Record = { VAR_GLOBAL: 'global', } +/** + * Classes whose declarations cannot carry a physical location ("AT"). + * IEC 61131-3 only allows located declarations in VAR and VAR_GLOBAL + * blocks — interface sections describe the call contract, not hardware. + * Shared with the store-level variable validation so edit time and load + * time enforce the same rule (GitHub issue #904). + */ +export const DISALLOWED_LOCATION_CLASSES: ReadonlyArray = [ + 'input', + 'output', + 'inOut', + 'external', + 'temp', +] + // Primary format: name : type AT location := initialValue ; (* documentation *) const lineRegex = // eslint-disable-next-line no-useless-escape @@ -120,11 +135,9 @@ export const parseIecStringToVariables = ( const { name, location, type, initialValue, documentation } = match.groups - const disallowedLocationClasses: Array = ['input', 'output', 'inOut', 'external', 'temp'] - - if (location && disallowedLocationClasses.includes(currentClass)) { + if (location && DISALLOWED_LOCATION_CLASSES.includes(currentClass)) { throw new Error( - `Syntax error on line ${lineNumber}: Location ("AT") is not allowed for variables of class "${currentClass.toUpperCase()}".`, + `Syntax error on line ${lineNumber}: "${line}". Location ("AT") is not allowed for variables of class "${currentClass.toUpperCase()}". Move "${name}" to a VAR block (class LOCAL) or remove the "AT ${location}" clause.`, ) } From 97540fee6b484e4c35dd37d7ee2bda8dc4539000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Wed, 8 Jul 2026 16:38:05 -0300 Subject: [PATCH 15/52] fix: load picked project after unsaved-changes prompt in File -> Open When a project with unsaved changes was open and the user triggered File -> Open, the save-changes modal's 'open-project' case discarded the picker result, so the selected project never loaded. Dispatch handleOpenProjectResponse with the result, mirroring the accelerator handler and start-screen flows (DOPE-447). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DQFVaMMwCZ77YYJKHSaWRM --- .../_organisms/modals/save-changes-modal.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/frontend/components/_organisms/modals/save-changes-modal.tsx b/src/frontend/components/_organisms/modals/save-changes-modal.tsx index b8e99804a..9b06ffca7 100644 --- a/src/frontend/components/_organisms/modals/save-changes-modal.tsx +++ b/src/frontend/components/_organisms/modals/save-changes-modal.tsx @@ -43,7 +43,7 @@ const SaveChangesModal = ({ isOpen, validationContext, onAfterAction, ...rest }: const capabilities = useCapabilities() const { - sharedWorkspaceActions: { clearStatesOnCloseProject }, + sharedWorkspaceActions: { clearStatesOnCloseProject, handleOpenProjectResponse }, } = useOpenPLCStore() const clearAndClose = () => { @@ -64,9 +64,13 @@ const SaveChangesModal = ({ isOpen, validationContext, onAfterAction, ...rest }: clearAndClose() openModal('create-project', null) return - case 'open-project': - await projectPort.openProject() + case 'open-project': { + const result = await projectPort.openProject() + if (result.success && result.data) { + handleOpenProjectResponse(result.data) + } return + } case 'open-recent-project': case 'open-project-by-path': // Execute the deferred action (e.g., re-open the recent project) From 0646fb0f406b89b1530503518d884c2418b7eaa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Wed, 8 Jul 2026 16:48:15 -0300 Subject: [PATCH 16/52] fix: deliver preserved variable text to the auto-opened POU's code view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code-mode pass in handleOpenProjectResponse ran after the auto-open block had already added and activated a default table-mode model for the same POU (it prefers "main" — exactly the POU most likely to carry the unparseable variables). addModel no-ops on duplicates and setEditor early-returns when the name matches the active editor, so the preserved raw text never reached the model and the code view showed an empty VAR/END_VAR skeleton instead of the declarations to repair. Route the raw text through editorActions.updateModelVariablesForName, which updates whichever object holds the POU — the active editor or the stored model. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FsPXB6bqvFU4RLBQ6snwDp --- .../store/__tests__/shared-slice.test.ts | 32 +++++++++++++++++++ src/frontend/store/slices/shared/slice.ts | 18 ++++++++--- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index b8d013888..e0ced0349 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -1902,6 +1902,38 @@ describe('createSharedSlice', () => { } }) + it('delivers the raw variable text to the auto-opened main POU (issue #904)', () => { + // The reporter's exact scenario: "main" itself carries the + // unparseable variables. The auto-open block adds and activates a + // default table-mode model for it BEFORE the code-mode pass runs — + // addModel no-ops on the duplicate and setEditor early-returns on + // the active editor, so the raw text must flow through + // updateModelVariablesForName to reach the active editor. + const rawText = 'VAR_OUTPUT\n Q1 : BOOL AT %QX0.0;\nEND_VAR' + const data = makeMinimalProjectResponse() + const unparseableMain = { + name: 'main', + pouType: 'program' as const, + interface: { variables: [] as PLCVariable[] }, + body: { language: 'st' as const, value: '' }, + documentation: '', + variablesText: rawText, + } + data.projectData.pous.length = 0 + data.projectData.pous.push(unparseableMain) + + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(data) + + // main was auto-opened and is the active editor + const state = store.getState() + expect(state.editor.meta.name).toBe('main') + // The active editor must show the preserved declarations in code view + expect('variable' in state.editor && state.editor.variable).toEqual({ + display: 'code', + code: rawText, + }) + }) + it('does not create code-mode model for POU with variables (non-empty)', () => { const data = makeMinimalProjectResponse() // main has variables, so no variablesText processing should occur diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index e21eea76c..175936271 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -843,11 +843,19 @@ const createSharedSlice: StateCreator = (s model.variable = { display: 'code', code: pouWithText.variablesText } } getState().editorActions.addModel(model) - // If this is the active editor (main POU), update it too - /* istanbul ignore next -- setEditor early-returns when name matches current editor */ - if (getState().editor.meta.name === pou.name) { - getState().editorActions.setEditor(model) - } + // The auto-open block above may already have added and activated a + // default table-mode model for this POU (it prefers "main" — exactly + // the POU most likely to carry the unparseable variables). `addModel` + // no-ops on duplicates and `setEditor` early-returns when the name + // matches the active editor, so neither can deliver the raw text to + // an existing model — the code view would show an empty skeleton + // instead of the preserved declarations. `updateModelVariablesForName` + // updates whichever object holds the POU: the active editor or the + // stored model. + getState().editorActions.updateModelVariablesForName(pou.name, { + display: 'code', + code: pouWithText.variablesText, + }) } }) From c3522bc8f3cd2315a413df07981234b528f9f881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Wed, 8 Jul 2026 16:57:46 -0300 Subject: [PATCH 17/52] fix: clear stale fallback variablesText when code view is committed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback POU for unparseable variables carries the raw declarations in a top-level variablesText field, which the save serializer treats as authoritative over the structured variables. The commit-to-table guard looked for the field inside pou.interface (the old backend location), so it never cleared it — after repairing the declarations in code view and switching to table view, any table-mode save wrote the stale broken text back to disk, resurrecting the corruption on reopen. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FsPXB6bqvFU4RLBQ6snwDp --- src/frontend/components/_organisms/variables-editor/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/components/_organisms/variables-editor/index.tsx b/src/frontend/components/_organisms/variables-editor/index.tsx index 9b2b70ea8..eddca458d 100644 --- a/src/frontend/components/_organisms/variables-editor/index.tsx +++ b/src/frontend/components/_organisms/variables-editor/index.tsx @@ -961,7 +961,7 @@ const VariablesEditor = ({ name: propName, isActive: _isActive = true }: Variabl setParseError(null) handleFileAndWorkspaceSavedState(editor.meta.name) - if (freshPou && freshPou.interface && 'variablesText' in freshPou.interface) { + if (freshPou && 'variablesText' in freshPou) { clearPouVariablesText(editor.meta.name) } From 6a28927e0ea776b53ce575dd9be7d06386321f49 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 9 Jul 2026 22:14:51 -0400 Subject: [PATCH 18/52] feat(softmotion): device-tree icon + CiA 402 axis config screen (mirror of openplc-web) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-identical mirror of the openplc-web SoftMotion axis UI: - SoftMotion icon (teal rotary-motion tile) marking a recognized CiA 402 SoftMotion drive in the project tree. - Cia402AxisTab: CODESYS-style axis config — enable toggle, increments↔units scaling, the CiA 402 object→IEC-address mapping table, and a real-time feedback panel. The device name is the axis name used in MC_*(Axis := …). - project tree + explorer wire the `softMotionDrive` leaf lang when cia402.enabled; the EtherCAT device editor gains a "SoftMotion Axis" tab. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../assets/icons/interface/SoftMotion.tsx | 46 ++++ .../ethercat/components/cia402-axis-tab.tsx | 198 ++++++++++++++++++ .../ethercat/ethercat-device-editor.tsx | 35 +++- .../_molecules/project-tree/index.tsx | 5 + .../_organisms/explorer/project.tsx | 2 +- 5 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 src/frontend/assets/icons/interface/SoftMotion.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/device/ethercat/components/cia402-axis-tab.tsx diff --git a/src/frontend/assets/icons/interface/SoftMotion.tsx b/src/frontend/assets/icons/interface/SoftMotion.tsx new file mode 100644 index 000000000..074af025c --- /dev/null +++ b/src/frontend/assets/icons/interface/SoftMotion.tsx @@ -0,0 +1,46 @@ +import { ComponentProps } from 'react' + +import { cn } from '../../../utils/cn' + +type ISoftMotionIconProps = ComponentProps<'svg'> & { + size?: 'sm' | 'md' | 'lg' +} + +const sizeClasses = { + sm: 'w-5 h-5', + md: 'w-8 h-8', + lg: 'w-12 h-12', +} + +/** + * SoftMotion (CiA 402 servo axis) icon — a rounded device tile with a rotary + * motion glyph (a circular arrow around a hub), in teal to distinguish a + * recognized SoftMotion drive from a plain EtherCAT slave in the project tree. + */ +export const SoftMotionIcon = (props: ISoftMotionIconProps) => { + const { className, size = 'sm', ...res } = props + return ( + + + {/* rotary arc suggesting axis rotation */} + + {/* arrowhead closing the arc */} + + {/* motor hub */} + + + ) +} diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/cia402-axis-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/cia402-axis-tab.tsx new file mode 100644 index 000000000..ee3fe65e4 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/cia402-axis-tab.tsx @@ -0,0 +1,198 @@ +import { type Cia402Role, resolveCia402Objects } from '@root/backend/shared/ethercat/cia402' +import { Checkbox } from '@root/frontend/components/_atoms/checkbox' +import { InputWithRef } from '@root/frontend/components/_atoms/input' +import type { Cia402AxisConfig, ConfiguredEtherCATDevice } from '@root/middleware/shared/ports/esi-types' +import { useMemo } from 'react' + +const inputClassName = + 'h-[26px] w-28 rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs text-neutral-700 outline-none focus:border-brand-medium-dark dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-300' + +/** Human labels for the CiA 402 object roles, in a sensible display order. */ +const ROLE_LABELS: Array<{ role: Cia402Role; label: string }> = [ + { role: 'controlWord', label: 'Control Word (0x6040)' }, + { role: 'statusWord', label: 'Status Word (0x6041)' }, + { role: 'modesOfOperation', label: 'Modes of Operation (0x6060)' }, + { role: 'modesDisplay', label: 'Modes Display (0x6061)' }, + { role: 'targetPosition', label: 'Target Position (0x607A)' }, + { role: 'positionActual', label: 'Position Actual (0x6064)' }, + { role: 'profileVelocity', label: 'Profile Velocity (0x6081)' }, + { role: 'targetVelocity', label: 'Target Velocity (0x60FF)' }, + { role: 'velocityActual', label: 'Velocity Actual (0x606C)' }, + { role: 'targetTorque', label: 'Target Torque (0x6071)' }, + { role: 'torqueActual', label: 'Torque Actual (0x6077)' }, +] + +/** Feedback signals shown in the live-values panel (drive → controller). */ +const FEEDBACK_SIGNALS: Array<{ role: Cia402Role; label: string; unit: string }> = [ + { role: 'positionActual', label: 'Actual Position', unit: 'u' }, + { role: 'velocityActual', label: 'Actual Velocity', unit: 'u/s' }, + { role: 'torqueActual', label: 'Actual Torque', unit: '' }, + { role: 'statusWord', label: 'Status Word', unit: '' }, +] + +function parseFloatInput(value: string): number | undefined { + const n = Number(value) + return Number.isFinite(n) ? n : undefined +} + +function parseIntInput(value: string, min: number): number | undefined { + const n = parseInt(value, 10) + return Number.isNaN(n) || n < min ? undefined : n +} + +export type Cia402AxisTabProps = { + device: ConfiguredEtherCATDevice + /** Merge-updates the device's CiA 402 axis config in the store. */ + onUpdate: (patch: Partial) => void +} + +/** + * SoftMotion axis (CiA 402) configuration + live-feedback screen — the OpenPLC + * analogue of the CODESYS CiA 402 device editor. Lets the user tune the + * increments↔units scaling used by SM_Drive_GenericDS402, shows how the drive's + * CiA 402 objects map to IEC located addresses, and (when a PLC is connected) + * displays real-time axis feedback. The device name is the axis name used in + * MC_*(Axis := ). + */ +export const Cia402AxisTab = ({ device, onUpdate }: Cia402AxisTabProps) => { + const cia402: Cia402AxisConfig = device.cia402 ?? { + enabled: false, + scaleNum: 1, + scaleDenom: 1, + scaleFactor: 1, + } + + const resolved = useMemo( + () => resolveCia402Objects(device.channelInfo ?? [], device.channelMappings), + [device.channelInfo, device.channelMappings], + ) + const locationByRole = useMemo(() => { + const m = new Map() + for (const o of resolved) m.set(o.role, { iecLocation: o.iecLocation, iecType: o.iecType }) + return m + }, [resolved]) + + const denom = cia402.scaleDenom === 0 ? 1 : cia402.scaleDenom + const incPerUnit = cia402.scaleFactor * (cia402.scaleNum / denom) + + return ( +
+ {/* Enable */} + + +

+ Referenced in application code as{' '} + + {device.name} + {' '} + — e.g. MC_Power(Axis := {device.name}, Enable := TRUE). +

+ + {/* Scaling */} +
+
+ Scaling (increments ↔ technical units) +
+
+ + + +
+ Increments per unit + + {Number.isFinite(incPerUnit) ? incPerUnit : '—'} + +
+
+
+ + {/* CiA 402 object → IEC address mapping */} +
+
CiA 402 Object Mapping
+
+ + + + + + + + + + {ROLE_LABELS.map(({ role, label }) => { + const m = locationByRole.get(role) + return ( + + + + + + ) + })} + +
ObjectIEC AddressType
{label}{m?.iecLocation ?? '—'}{m?.iecType ?? '—'}
+
+
+ + {/* Real-time feedback */} +
+
Real-time Feedback
+
+ {FEEDBACK_SIGNALS.map(({ role, label, unit }) => { + const mapped = locationByRole.has(role) + return ( +
+ {label} + + {mapped ? `— ${unit}` : 'n/a'} + +
+ ) + })} +
+

+ Live values appear here when connected to a running PLC and monitoring is active. +

+
+
+ ) +} diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx index 2877a856e..016abdd97 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx @@ -3,6 +3,7 @@ import { useDeviceConfiguration } from '@root/frontend/hooks/use-device-configur import { useOpenPLCStore } from '@root/frontend/store' import { cn } from '@root/frontend/utils/cn' import type { + Cia402AxisConfig, ConfiguredEtherCATDevice, EnrichDeviceData, ESIDeviceSummary, @@ -16,13 +17,14 @@ import { buildAddressPool } from '@root/middleware/shared/utils/iec-address' import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Cia402AxisTab } from './components/cia402-axis-tab' import { ChannelMappingsSection, DeviceConfigurationForm, SdoParametersSection, } from './components/device-configuration-form' -type DeviceDetailTab = 'info' | 'configuration' | 'startup-params' | 'channel-mappings' +type DeviceDetailTab = 'info' | 'configuration' | 'startup-params' | 'channel-mappings' | 'axis' const TabItem = ({ value, label, isActive }: { value: string; label: string; isActive: boolean }) => ( ) => { + syncDevicesToStore( + configuredDevices.map((d) => { + if (d.id !== deviceId) return d + const base: Cia402AxisConfig = d.cia402 ?? { + enabled: false, + scaleNum: 1, + scaleDenom: 1, + scaleFactor: 1, + } + return { ...d, cia402: { ...base, ...patch } } + }), + ) + }, + [configuredDevices, deviceId, syncDevicesToStore], + ) + // Load ESI repository. Resets and reloads whenever `projectPath` changes // so switching projects picks up the new project's repository instead of // serving the prior one from the stale "already loaded" flag. @@ -283,11 +303,24 @@ const EtherCATDeviceEditor = ({ busName: propBusName, deviceId: propDeviceId }: > + {device.cia402 && } + {/* SoftMotion Axis (CiA 402) Tab */} + {device.cia402 && ( + +
+ +
+
+ )} + {/* Device Info Tab */} & { | 'remoteDevice' | 'vendorScreen' | 'ethercatDevice' + | 'softMotionDrive' | 'libraryManifest' leafType: WorkspaceProjectTreeLeafType label?: string @@ -486,6 +488,9 @@ const LeafSources = { remoteDevice: { LeafIcon: RemoteDeviceIcon }, vendorScreen: { LeafIcon: ConfigIcon }, ethercatDevice: { LeafIcon: DeviceTransferIcon }, + // A recognized CiA 402 SoftMotion drive gets a distinct rotary-axis icon so + // it reads as an axis (usable in MC_* blocks), not a plain EtherCAT slave. + softMotionDrive: { LeafIcon: SoftMotionIcon }, // Library manifest gets its own document-with-bookmark icon so // the explorer leaf, the workspace tab, and the breadcrumb all // render the same glyph — the manifest is the user's entry point diff --git a/src/frontend/components/_organisms/explorer/project.tsx b/src/frontend/components/_organisms/explorer/project.tsx index 0a2b6cb42..f25786f77 100644 --- a/src/frontend/components/_organisms/explorer/project.tsx +++ b/src/frontend/components/_organisms/explorer/project.tsx @@ -445,7 +445,7 @@ const Project = () => { {device.ethercatConfig?.devices?.map((child) => ( Date: Thu, 9 Jul 2026 23:41:40 -0400 Subject: [PATCH 19/52] fix(softmotion): axis rename, valid names, simplified axis tab, hide channel mappings (mirror of openplc-web) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-identical mirror of the openplc-web SoftMotion UX fixes: 1. Rename works for SoftMotion drives (isEthercatDevice now covers the softMotionDrive leaf lang). 2. Drive names are enforced to valid IEC identifiers — sanitized at add-time, validated on rename (isValidIecIdentifier), since the name is the axis variable in generated code. 3. Channel Mappings tab hidden for SoftMotion drives (PDO mappings are internal); editor defaults to the SoftMotion Axis tab. 4. SoftMotion Axis tab simplified (dropped the enable toggle and the application-code reference blurb). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/generate-softmotion.test.ts | 30 ++++++++---- .../shared/ethercat/generate-softmotion.ts | 10 ++++ .../ethercat/components/cia402-axis-tab.tsx | 15 ------ .../ethercat/ethercat-device-editor.tsx | 48 ++++++++++++------- .../editor/device/ethercat/index.tsx | 10 +++- .../_molecules/project-tree/index.tsx | 4 +- .../store/__tests__/shared-slice.test.ts | 12 +++++ src/frontend/store/slices/shared/slice.ts | 10 ++++ 8 files changed, 95 insertions(+), 44 deletions(-) diff --git a/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts index d1f7c3d35..a218ca9e9 100644 --- a/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts +++ b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts @@ -9,6 +9,7 @@ import { enrichDeviceData } from '../enrich-device-data' import { parseESIDeviceFull } from '../esi-parser-main' import { generateSoftMotionArtifacts, + isValidIecIdentifier, SM3_BRIDGE_INSTANCE_NAME, SM3_BRIDGE_POU_NAME, sanitizeAxisName, @@ -50,9 +51,7 @@ function makeProject(devices: ConfiguredEtherCATDevice[]): PLCProjectData { globalVariables: [], }, }, - remoteDevices: [ - { name: 'ethercat-bus', protocol: 'ethercat', ethercatConfig: { devices } }, - ], + remoteDevices: [{ name: 'ethercat-bus', protocol: 'ethercat', ethercatConfig: { devices } }], } } @@ -63,6 +62,15 @@ describe('generateSoftMotionArtifacts', () => { expect(sanitizeAxisName('9drive')).toBe('_9drive') }) + it('validates IEC identifiers', () => { + expect(isValidIecIdentifier('X_Axis')).toBe(true) + expect(isValidIecIdentifier('_axis1')).toBe(true) + expect(isValidIecIdentifier('ASDA-A2-E')).toBe(false) + expect(isValidIecIdentifier('My Axis')).toBe(false) + expect(isValidIecIdentifier('9drive')).toBe(false) + expect(isValidIecIdentifier('')).toBe(false) + }) + it('is a no-op when there are no CiA 402 axes', () => { const project = makeProject([]) expect(generateSoftMotionArtifacts(project)).toBe(project) @@ -133,7 +141,13 @@ describe('generateSoftMotionArtifacts', () => { const project = makeProject([makeDevice('X_Axis')]) project.pous[0].interface = { variables: [ - { name: 'X_Axis', class: 'external', type: { definition: 'derived', value: 'AXIS_REF_SM3' }, location: '', documentation: '' }, + { + name: 'X_Axis', + class: 'external', + type: { definition: 'derived', value: 'AXIS_REF_SM3' }, + location: '', + documentation: '', + }, ], } project.pous[0].body = { language: 'st', value: 'pwr(Axis := X_Axis);' } @@ -214,9 +228,7 @@ describe('generateSoftMotionArtifacts', () => { a.id = 'a' b.id = 'b' const out = generateSoftMotionArtifacts(makeProject([a, b])) - const axisGlobals = out.configurations.resource.globalVariables.filter( - (g) => g.name === 'X_Axis', - ) + const axisGlobals = out.configurations.resource.globalVariables.filter((g) => g.name === 'X_Axis') expect(axisGlobals).toHaveLength(1) }) @@ -241,9 +253,7 @@ describe('generateSoftMotionArtifacts', () => { const out = generateSoftMotionArtifacts(project) expect(out.configurations.resource.tasks).toHaveLength(1) expect(out.configurations.resource.tasks[0].triggering).toBe('Cyclic') - expect(out.configurations.resource.instances[0].task).toBe( - out.configurations.resource.tasks[0].name, - ) + expect(out.configurations.resource.instances[0].task).toBe(out.configurations.resource.tasks[0].name) }) }) }) diff --git a/src/backend/shared/ethercat/generate-softmotion.ts b/src/backend/shared/ethercat/generate-softmotion.ts index 06568034e..fd2d550b9 100644 --- a/src/backend/shared/ethercat/generate-softmotion.ts +++ b/src/backend/shared/ethercat/generate-softmotion.ts @@ -69,6 +69,16 @@ export function sanitizeAxisName(name: string): string { return s } +/** + * True when `name` is already a valid IEC 61131-3 identifier — a letter or + * underscore followed by letters, digits, or underscores. A SoftMotion drive's + * name IS the axis variable name used in `MC_*(Axis := )`, so it must + * satisfy this (no spaces, hyphens, or leading digits). + */ +export function isValidIecIdentifier(name: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) +} + function lrealLiteral(n: number): string { return Number.isInteger(n) ? `${n}.0` : `${n}` } diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/cia402-axis-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/cia402-axis-tab.tsx index ee3fe65e4..6d5827e21 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/cia402-axis-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/cia402-axis-tab.tsx @@ -1,5 +1,4 @@ import { type Cia402Role, resolveCia402Objects } from '@root/backend/shared/ethercat/cia402' -import { Checkbox } from '@root/frontend/components/_atoms/checkbox' import { InputWithRef } from '@root/frontend/components/_atoms/input' import type { Cia402AxisConfig, ConfiguredEtherCATDevice } from '@root/middleware/shared/ports/esi-types' import { useMemo } from 'react' @@ -77,20 +76,6 @@ export const Cia402AxisTab = ({ device, onUpdate }: Cia402AxisTabProps) => { return (
- {/* Enable */} - - -

- Referenced in application code as{' '} - - {device.name} - {' '} - — e.g. MC_Power(Axis := {device.name}, Enable := TRUE). -

- {/* Scaling */}
diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx index 016abdd97..f4e411d25 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/ethercat-device-editor.tsx @@ -104,6 +104,17 @@ const EtherCATDeviceEditor = ({ busName: propBusName, deviceId: propDeviceId }: ) }, [remoteDevice]) + // A recognized CiA 402 SoftMotion drive: its PDO channel mappings are + // generated and consumed internally by the drive bridge, so the raw + // Channel Mappings tab is hidden and the SoftMotion Axis tab leads. + const isSoftMotion = !!device?.cia402?.enabled + + // Channel Mappings is hidden for SoftMotion drives; if it was the active + // tab (the default), fall through to the SoftMotion Axis tab. + useEffect(() => { + if (isSoftMotion && activeTab === 'channel-mappings') setActiveTab('axis') + }, [isSoftMotion, activeTab]) + // Pool of every claim from producers active on the current target. // EtherCAT is sharing the image table with VPP and Modbus TCP on // Runtime v4, so all three feed into the pool — but capability @@ -302,7 +313,9 @@ const EtherCATDeviceEditor = ({ busName: propBusName, deviceId: propDeviceId }: className='flex min-h-0 flex-1 flex-col overflow-hidden' > - + {!isSoftMotion && ( + + )} {device.cia402 && } @@ -398,21 +411,24 @@ const EtherCATDeviceEditor = ({ busName: propBusName, deviceId: propDeviceId }:
- {/* Channel Mappings Tab */} - -
- -
-
+ {/* Channel Mappings Tab — hidden for SoftMotion drives (their PDO + mappings are generated and consumed internally by the bridge). */} + {!isSoftMotion && ( + +
+ +
+
+ )}
) diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx index 91a4b4a6f..1b6e71a49 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -2,6 +2,7 @@ import * as Tabs from '@radix-ui/react-tabs' import { createDefaultSlaveConfig } from '@root/backend/shared/ethercat/device-config-defaults' import { matchDevicesToRepository } from '@root/backend/shared/ethercat/device-matcher' import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' +import { sanitizeAxisName } from '@root/backend/shared/ethercat/generate-softmotion' import type { EtherCATMasterConfig } from '@root/backend/shared/types/PLC/open-plc' import { Modal, ModalContent, ModalTitle } from '@root/frontend/components/_molecules/modal' import { useOpenPLCStore } from '@root/frontend/store' @@ -450,7 +451,9 @@ const EtherCATEditor = () => { for (const m of enriched.channelMappings ?? []) usedAddresses.add(m.iecLocation) } - const baseName = getShortDeviceName(bestMatch.esiDevice) + // SoftMotion drive names become axis variable names — keep them valid. + const rawName = getShortDeviceName(bestMatch.esiDevice) + const baseName = enriched.cia402?.enabled ? sanitizeAxisName(rawName) : rawName const uniqueName = generateUniqueSlaveName(baseName, takenNames) takenNames.add(uniqueName) @@ -523,7 +526,10 @@ const EtherCATEditor = () => { const nextPosition = configuredDevices.length > 0 ? Math.max(...configuredDevices.map((d) => d.position ?? 0)) + 1 : 1 - const baseName = getShortDeviceName(device) + // A SoftMotion drive's name becomes the axis variable name in generated + // code, so it must be a valid IEC identifier from the start. + const rawName = getShortDeviceName(device) + const baseName = enriched.cia402?.enabled ? sanitizeAxisName(rawName) : rawName const uniqueName = generateUniqueSlaveName(baseName, collectAllSlaveNames(project.data.remoteDevices)) const newDevice: ConfiguredEtherCATDevice = { diff --git a/src/frontend/components/_molecules/project-tree/index.tsx b/src/frontend/components/_molecules/project-tree/index.tsx index 91297cdea..66e99ce5a 100644 --- a/src/frontend/components/_molecules/project-tree/index.tsx +++ b/src/frontend/components/_molecules/project-tree/index.tsx @@ -533,7 +533,9 @@ const ProjectTreeLeaf = ({ const isDatatype = useMemo(() => leafLang === 'arr' || leafLang === 'enum' || leafLang === 'str', [leafLang]) const isServer = useMemo(() => leafLang === 'server', [leafLang]) const isRemoteDevice = useMemo(() => leafLang === 'remoteDevice', [leafLang]) - const isEthercatDevice = useMemo(() => leafLang === 'ethercatDevice', [leafLang]) + // A SoftMotion drive is an EtherCAT child device too (cia402.enabled) — it + // shares every EtherCAT device action (rename/delete), just a distinct icon. + const isEthercatDevice = useMemo(() => leafLang === 'ethercatDevice' || leafLang === 'softMotionDrive', [leafLang]) const { LeafIcon } = LeafSources[leafLang] const { file: associatedFile } = getFile({ name: label || '' }) diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index b8d013888..3a44478e8 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -932,6 +932,18 @@ describe('createSharedSlice', () => { const result = store.getState().ethercatDeviceActions.rename('bus1', 'missing-slave', 'X') expect(result).toEqual({ ok: false, message: 'EtherCAT device not found' }) }) + + it('rejects an invalid IEC identifier for a SoftMotion (CiA 402) drive', () => { + addEthercatBus('bus3', [ + { id: 'axis-1', name: 'X_Axis', cia402: { enabled: true, scaleNum: 1, scaleDenom: 1, scaleFactor: 1 } }, + ] as never) + const bad = store.getState().ethercatDeviceActions.rename('bus3', 'axis-1', 'ASDA-A2-E') + expect(bad.ok).toBe(false) + expect(bad.message).toContain('valid axis name') + // A valid identifier is accepted. + const good = store.getState().ethercatDeviceActions.rename('bus3', 'axis-1', 'Y_Axis') + expect(good.ok).toBe(true) + }) }) }) diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index e21eea76c..488f1ae21 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -1,6 +1,7 @@ import { produce } from 'immer' import { StateCreator } from 'zustand' +import { isValidIecIdentifier } from '../../../../backend/shared/ethercat/generate-softmotion' import type { PLCVariable } from '../../../../middleware/shared/ports/types' import { parseIecStringToVariables } from '../../../utils/generate-iec-string-to-variables' import { generateIecVariablesToString } from '../../../utils/generate-iec-variables-to-string' @@ -393,6 +394,15 @@ const createSharedSlice: StateCreator = (s if (!device) return { ok: false, message: 'EtherCAT device not found' } const oldName = device.name + // A SoftMotion drive's name IS the axis variable name emitted into + // generated code, so it must be a valid IEC identifier (no spaces, + // hyphens, or leading digits). + if (device.cia402?.enabled && !isValidIecIdentifier(newName)) { + return { + ok: false, + message: `"${newName}" is not a valid axis name. Use letters, digits, and underscores, starting with a letter or underscore.`, + } + } // Only *rejecting* enforcement of slave-name uniqueness — scan-bus add // auto-suffixes instead. Tabs/editor/file slices are name-keyed and break // silently on duplicates, so new write paths must replicate one strategy. From c2aa65eadc9304d4c3b699451cd6b9fe1ad385df Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 10 Jul 2026 08:35:18 -0400 Subject: [PATCH 20/52] feat(softmotion): ST LSP recognizes generated axis globals (mirror of openplc-web) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-identical mirror. The LSP now synthesizes a `VAR_GLOBAL : AXIS_REF_SM3` document and injects the same axis VAR_EXTERNAL the compiler generates (via the shared injectAxisExternals, now covering function blocks too), so editor code that names a SoftMotion axis — `MC_Power(Axis := X_Axis)` — resolves instead of flagging the axis as undeclared. Also fixes the compile gap where axis references inside function blocks didn't get their VAR_EXTERNAL. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/generate-softmotion.test.ts | 72 ++++++++++++++++- .../shared/ethercat/generate-softmotion.ts | 79 +++++++++++++++---- src/frontend/services/st-lsp/project-sync.ts | 69 ++++++++++++++-- src/frontend/services/st-lsp/types.ts | 9 +++ 4 files changed, 204 insertions(+), 25 deletions(-) diff --git a/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts index a218ca9e9..b1aad441a 100644 --- a/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts +++ b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts @@ -9,10 +9,13 @@ import { enrichDeviceData } from '../enrich-device-data' import { parseESIDeviceFull } from '../esi-parser-main' import { generateSoftMotionArtifacts, + injectAxisExternals, isValidIecIdentifier, + serializeSoftMotionAxisGlobalsToST, SM3_BRIDGE_INSTANCE_NAME, SM3_BRIDGE_POU_NAME, sanitizeAxisName, + softMotionAxisNames, } from '../generate-softmotion' const ESI_XML = readFileSync(resolve(__dirname, 'fixtures/cia402-servo-esi.xml'), 'utf-8') @@ -76,6 +79,52 @@ describe('generateSoftMotionArtifacts', () => { expect(generateSoftMotionArtifacts(project)).toBe(project) }) + describe('softMotionAxisNames', () => { + it('returns sanitized names of enabled axes', () => { + expect(softMotionAxisNames(makeProject([makeDevice('My Axis')]))).toEqual(['My_Axis']) + }) + it('returns [] when there are no axes', () => { + expect(softMotionAxisNames(makeProject([]))).toEqual([]) + }) + }) + + describe('injectAxisExternals', () => { + const prog = (value: string) => + ({ name: 'p', pouType: 'program', interface: { variables: [] }, body: { language: 'st', value } }) as never + + it('adds the external to a program that references the axis', () => { + const out = injectAxisExternals(prog('pwr(Axis := Ax);'), ['Ax']) + expect(out.interface!.variables.some((v) => v.name === 'Ax' && v.class === 'external')).toBe(true) + }) + it('leaves a POU that does not reference any axis unchanged', () => { + const pou = prog('y := 1;') + expect(injectAxisExternals(pou, ['Ax'])).toBe(pou) + }) + it('skips a function POU', () => { + const fn = { + name: 'f', + pouType: 'function', + interface: { variables: [] }, + body: { language: 'st', value: 'x := Ax;' }, + } as never + expect(injectAxisExternals(fn, ['Ax'])).toBe(fn) + }) + }) + + describe('serializeSoftMotionAxisGlobalsToST', () => { + it('returns empty string when there are no axes', () => { + expect(serializeSoftMotionAxisGlobalsToST(makeProject([]))).toBe('') + }) + + it('declares each axis as a VAR_GLOBAL of type AXIS_REF_SM3', () => { + const st = serializeSoftMotionAxisGlobalsToST(makeProject([makeDevice('X_Axis')])) + expect(st).toContain('VAR_GLOBAL') + expect(st).toContain('X_Axis : AXIS_REF_SM3;') + expect(st).toContain('END_VAR') + expect(st).toContain('CONFIGURATION') + }) + }) + it('is a no-op when the CiA 402 device is disabled', () => { const dev = makeDevice('X_Axis') dev.cia402 = { ...dev.cia402!, enabled: false } @@ -165,17 +214,32 @@ describe('generateSoftMotionArtifacts', () => { expect(main.interface!.variables.some((v) => v.name === 'X_Axis' && v.class === 'external')).toBe(true) }) - it('leaves non-program POUs untouched', () => { + it('injects the axis external into a function block that references it', () => { const project = makeProject([makeDevice('X_Axis')]) project.pous.push({ - name: 'helper', + name: 'MotionFB', pouType: 'function-block', interface: { variables: [] }, body: { language: 'st', value: 'x := X_Axis.fActPosition;' }, }) const out = generateSoftMotionArtifacts(project) - const helper = out.pous.find((p) => p.name === 'helper')! - expect(helper.interface!.variables.some((v) => v.name === 'X_Axis')).toBe(false) + const fb = out.pous.find((p) => p.name === 'MotionFB')! + const ext = fb.interface!.variables.find((v) => v.name === 'X_Axis') + expect(ext?.class).toBe('external') + expect(ext?.type).toEqual({ definition: 'derived', value: 'AXIS_REF_SM3' }) + }) + + it('leaves functions untouched (they cannot hold VAR_EXTERNAL)', () => { + const project = makeProject([makeDevice('X_Axis')]) + project.pous.push({ + name: 'helperFn', + pouType: 'function', + interface: { variables: [] }, + body: { language: 'st', value: 'x := X_Axis.fActPosition;' }, + }) + const out = generateSoftMotionArtifacts(project) + const fn = out.pous.find((p) => p.name === 'helperFn')! + expect(fn.interface!.variables.some((v) => v.name === 'X_Axis')).toBe(false) }) it('runs the bridge first each scan (instance unshifted to the front)', () => { diff --git a/src/backend/shared/ethercat/generate-softmotion.ts b/src/backend/shared/ethercat/generate-softmotion.ts index fd2d550b9..cec4ae9c9 100644 --- a/src/backend/shared/ethercat/generate-softmotion.ts +++ b/src/backend/shared/ethercat/generate-softmotion.ts @@ -148,6 +148,66 @@ function collectAxes(project: PLCProjectData): AxisPlan[] { return plans } +/** Sanitized names of every enabled, resolvable CiA 402 axis in the project. */ +export function softMotionAxisNames(project: PLCProjectData): string[] { + return collectAxes(project).map((a) => a.axisName) +} + +/** POU types that may access a SoftMotion axis global via VAR_EXTERNAL. Functions + * are stateless and can't hold VAR_EXTERNAL, so they're excluded. */ +const AXIS_EXTERNAL_POU_TYPES = new Set(['program', 'function-block']) + +/** + * Inject a `VAR_EXTERNAL : AXIS_REF_SM3` into `pou` for every axis in + * `axisNames` its body references but hasn't already declared — so + * `MC_*(Axis := )` resolves without the user declaring the global. + * Returns the POU unchanged when nothing applies. Programs and function blocks + * only: strucpp requires a VAR_EXTERNAL to touch a global, and both POU kinds + * support it (a function can't). Shared by the compiler and the language server + * so the editor sees exactly what the compiler generates. + */ +export function injectAxisExternals(pou: PLCPou, axisNames: string[]): PLCPou { + if (!AXIS_EXTERNAL_POU_TYPES.has(pou.pouType)) return pou + const declared = new Set((pou.interface?.variables ?? []).map((v) => v.name.toUpperCase())) + const toAdd = axisNames + .filter((name) => !declared.has(name.toUpperCase()) && bodyReferences(pou.body.value, name)) + .map((name) => external(name, 'AXIS_REF_SM3', 'derived')) + if (toAdd.length === 0) return pou + return { + ...pou, + interface: { ...pou.interface, variables: [...(pou.interface?.variables ?? []), ...toAdd] }, + } +} + +/** + * Serialize the SoftMotion axis globals as a standalone ST configuration for the + * language server. Each CiA 402 drive becomes a `VAR_GLOBAL : AXIS_REF_SM3` + * so editor code referencing the axis (e.g. `MC_Power(Axis := X_Axis)`) resolves + * against the same public axis the compiler generates — without the user + * declaring anything. Returns '' when the project has no axes. + * + * Only the axis references are declared (not the located PDO scalar globals), + * because those are internal to the generated drive bridge and never named in + * user code. `AXIS_REF_SM3` itself comes from the bundled plcopen-softmotion + * stlib the LSP already ingests. + */ +export function serializeSoftMotionAxisGlobalsToST(project: PLCProjectData): string { + const axes = collectAxes(project) + if (axes.length === 0) return '' + + const decls = axes.map((a) => ` ${a.axisName} : AXIS_REF_SM3;`).join('\n') + return [ + 'CONFIGURATION __softmotion_axes__', + 'VAR_GLOBAL', + decls, + 'END_VAR', + 'RESOURCE __softmotion_res__ ON PLC', + 'END_RESOURCE', + 'END_CONFIGURATION', + '', + ].join('\n') +} + /** * Inject generated SoftMotion globals + the per-scan bridge program for every * CiA 402 axis in the project. No-op (returns the input) when there are none. @@ -199,20 +259,11 @@ export function generateSoftMotionArtifacts(project: PLCProjectData): PLCProject documentation: 'Auto-generated SoftMotion drive bridge — do not edit; regenerated each compile.', } - // Inject a VAR_EXTERNAL for each axis into user programs that reference it, so - // `MC_*(Axis := X_Axis)` resolves without the user declaring the global. - const patchedPous = project.pous.map((pou) => { - if (pou.pouType !== 'program') return pou - const declared = new Set((pou.interface?.variables ?? []).map((v) => v.name.toUpperCase())) - const toAdd = axes - .filter((a) => !declared.has(a.axisName.toUpperCase()) && bodyReferences(pou.body.value, a.axisName)) - .map((a) => external(a.axisName, 'AXIS_REF_SM3', 'derived')) - if (toAdd.length === 0) return pou - return { - ...pou, - interface: { ...pou.interface, variables: [...(pou.interface?.variables ?? []), ...toAdd] }, - } - }) + // Inject a VAR_EXTERNAL for each axis into user programs and function blocks + // that reference it, so `MC_*(Axis := X_Axis)` resolves without the user + // declaring the global. + const axisNames = axes.map((a) => a.axisName) + const patchedPous = project.pous.map((pou) => injectAxisExternals(pou, axisNames)) const resource = project.configurations.resource // Ensure a task exists to run the bridge, then attach the bridge instance at diff --git a/src/frontend/services/st-lsp/project-sync.ts b/src/frontend/services/st-lsp/project-sync.ts index 6ac456e87..4ed1ac1e5 100644 --- a/src/frontend/services/st-lsp/project-sync.ts +++ b/src/frontend/services/st-lsp/project-sync.ts @@ -25,12 +25,17 @@ * `refreshStlibs()` on the service. */ -import type { PLCDataType, PLCPou } from '../../../middleware/shared/ports/types' +import { + injectAxisExternals, + serializeSoftMotionAxisGlobalsToST, + softMotionAxisNames, +} from '../../../backend/shared/ethercat/generate-softmotion' +import type { PLCDataType, PLCPou, PLCRemoteDevice } from '../../../middleware/shared/ports/types' import { openPLCStoreBase } from '../../store' import { serializeDataTypesToST } from '../../utils/PLC/data-type-serializer' import { serializePouSignatureToSTWithBodyOffset } from '../../utils/PLC/pou-signature-serializer' import { deleteBodyLineOffset, setBodyLineOffset } from '../lsp-shared/body-offsets' -import { DATA_TYPES_URI, pouUri, type StLspService, stubUri } from './types' +import { DATA_TYPES_URI, pouUri, SOFTMOTION_GLOBALS_URI, type StLspService, stubUri } from './types' /** * Determines whether a POU's source goes through the live-body @@ -102,20 +107,55 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { snapshot.contentByUri.set(DATA_TYPES_URI, nextText) } + // Single fixed-URI document carrying `VAR_GLOBAL : AXIS_REF_SM3` for + // every recognized CiA 402 drive, so editor code that names an axis resolves + // it (the LSP analyses every open document together, and AXIS_REF_SM3 comes + // from the bundled stlib). Mirrors reconcileDataTypes. + function reconcileSoftMotionGlobals(remoteDevices: PLCRemoteDevice[] | undefined): void { + if (disposed) return + const nextText = serializeSoftMotionAxisGlobalsToST({ remoteDevices } as never) + const previousText = snapshot.contentByUri.get(SOFTMOTION_GLOBALS_URI) + if (nextText.length === 0) { + if (previousText !== undefined) { + service.closeDocument(SOFTMOTION_GLOBALS_URI) + snapshot.contentByUri.delete(SOFTMOTION_GLOBALS_URI) + } + return + } + if (previousText === undefined) { + service.openDocument(SOFTMOTION_GLOBALS_URI, nextText) + } else if (previousText !== nextText) { + snapshot.version += 1 + service.changeDocument(SOFTMOTION_GLOBALS_URI, nextText, snapshot.version) + } + snapshot.contentByUri.set(SOFTMOTION_GLOBALS_URI, nextText) + } + function reconcile(pous: PLCPou[]): void { if (disposed) return const seenNames = new Set() const seenUris = new Set() - // The data-types document survives every POU reconcile — mark its - // URI as seen so the catch-all cleanup loop below doesn't close it. + // The data-types and SoftMotion-globals documents survive every POU + // reconcile — mark their URIs as seen so the catch-all cleanup loop below + // doesn't drop them. seenUris.add(DATA_TYPES_URI) + seenUris.add(SOFTMOTION_GLOBALS_URI) + + // Inject the same axis VAR_EXTERNALs the compiler generates, so a POU that + // names an axis (`MC_*(Axis := X_Axis)`) resolves it in the editor exactly + // as it will at compile time. Derived from the live remote-device set. + const axisNames = softMotionAxisNames({ + remoteDevices: openPLCStoreBase.getState().project.data.remoteDevices, + } as never) for (const pou of pous) { seenNames.add(pou.name) const nextUri = uriForPou(pou) const previousUri = snapshot.uriByName.get(pou.name) - const { text: nextText, bodyLineOffset } = serializePouSignatureToSTWithBodyOffset(pou) + const { text: nextText, bodyLineOffset } = serializePouSignatureToSTWithBodyOffset( + injectAxisExternals(pou, axisNames), + ) // POU name unchanged but URI switched (body language change). // Send didClose for the previous URI before didOpen on the new. @@ -175,17 +215,31 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { (state) => state.project.data.dataTypes, (dataTypes) => reconcileDataTypes(dataTypes), ) + // SoftMotion axis globals derive from the EtherCAT remote devices — adding, + // renaming, or enabling a CiA 402 drive must refresh the synthesized globals + // doc so editor code resolves the new axis without a POU edit. + const unsubscribeRemoteDevices = openPLCStoreBase.subscribe( + (state) => state.project.data.remoteDevices, + (remoteDevices) => { + reconcileSoftMotionGlobals(remoteDevices) + // Axis set changed → re-publish POUs so their injected VAR_EXTERNALs + // pick up added/removed/renamed axes without waiting on a POU edit. + reconcile(openPLCStoreBase.getState().project.data.pous) + }, + ) // Initial reconcile against whatever is already in the store. The - // data types load first so any POU that references them resolves - // on the first didOpen, not on a follow-up didChange. + // data types and axis globals load first so any POU that references + // them resolves on the first didOpen, not on a follow-up didChange. reconcileDataTypes(openPLCStoreBase.getState().project.data.dataTypes) + reconcileSoftMotionGlobals(openPLCStoreBase.getState().project.data.remoteDevices) reconcile(openPLCStoreBase.getState().project.data.pous) return { resync() { if (disposed) return reconcileDataTypes(openPLCStoreBase.getState().project.data.dataTypes) + reconcileSoftMotionGlobals(openPLCStoreBase.getState().project.data.remoteDevices) reconcile(openPLCStoreBase.getState().project.data.pous) }, forceResync() { @@ -204,6 +258,7 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { disposed = true unsubscribePous() unsubscribeDataTypes() + unsubscribeRemoteDevices() // Close every doc we'd opened so the worker stays consistent // if the service is restarted in the same session. for (const uri of snapshot.contentByUri.keys()) { diff --git a/src/frontend/services/st-lsp/types.ts b/src/frontend/services/st-lsp/types.ts index a5a6e721c..f8454fa30 100644 --- a/src/frontend/services/st-lsp/types.ts +++ b/src/frontend/services/st-lsp/types.ts @@ -40,6 +40,15 @@ export const POUVARS_URI_AUTHORITY = 'pouvars' */ export const DATA_TYPES_URI = 'inmemory://datatypes/__project__.st' +/** + * URI for the synthesized SoftMotion axis globals document — a + * `VAR_GLOBAL : AXIS_REF_SM3` per recognized CiA 402 drive, so editor + * code referencing an axis (`MC_Power(Axis := X_Axis)`) resolves against the + * same public axis the compiler generates, without the user declaring it. + * Single fixed URI per session; the axis set is project-global. + */ +export const SOFTMOTION_GLOBALS_URI = 'inmemory://softmotion/__axes__.st' + /** Public service the rest of the renderer talks to. */ export interface StLspService { /** From 5a6c023f4ec28e64d05847df7af2e0977887d3ef Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 14 Jul 2026 12:42:10 -0400 Subject: [PATCH 21/52] refactor(softmotion): axis as ambient global for the LSP; no VAR_EXTERNAL injection (mirror of openplc-web) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-identical mirror. The LSP now surfaces each axis as a bare top-level VAR_GLOBAL block (ambient global — resolves with no VAR_EXTERNAL), POUs are serialised verbatim (no injected declarations shifting line numbers, so go-to-definition stays correct), and go-to-definition on an axis redirects to the owning drive's device editor. VAR_EXTERNAL injection remains compile-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/generate-softmotion.test.ts | 17 ++++- .../shared/ethercat/generate-softmotion.ts | 28 ++++---- .../goto-definition-redirect.test.ts | 69 +++++++++++++++++++ .../st-lsp/goto-definition-redirect.ts | 61 +++++++++++++++- src/frontend/services/st-lsp/project-sync.ts | 28 ++------ 5 files changed, 164 insertions(+), 39 deletions(-) diff --git a/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts index b1aad441a..3bfbe0d2d 100644 --- a/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts +++ b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts @@ -116,12 +116,23 @@ describe('generateSoftMotionArtifacts', () => { expect(serializeSoftMotionAxisGlobalsToST(makeProject([]))).toBe('') }) - it('declares each axis as a VAR_GLOBAL of type AXIS_REF_SM3', () => { + it('declares each axis as a bare top-level VAR_GLOBAL of type AXIS_REF_SM3', () => { const st = serializeSoftMotionAxisGlobalsToST(makeProject([makeDevice('X_Axis')])) - expect(st).toContain('VAR_GLOBAL') expect(st).toContain('X_Axis : AXIS_REF_SM3;') + // Bare top-level block (ambient global) — NOT wrapped in a CONFIGURATION, + // so the axis resolves without VAR_EXTERNAL. + expect(st.startsWith('VAR_GLOBAL')).toBe(true) expect(st).toContain('END_VAR') - expect(st).toContain('CONFIGURATION') + expect(st).not.toContain('CONFIGURATION') + }) + + it('lists axes in softMotionAxisNames order (line N+1 = axis N)', () => { + const project = makeProject([makeDevice('X_Axis')]) + const st = serializeSoftMotionAxisGlobalsToST(project) + const names = softMotionAxisNames(project) + const lines = st.split('\n') + // line 0 = VAR_GLOBAL, line 1 = first axis + expect(lines[1]).toContain(names[0]) }) }) diff --git a/src/backend/shared/ethercat/generate-softmotion.ts b/src/backend/shared/ethercat/generate-softmotion.ts index cec4ae9c9..3e87b5ca6 100644 --- a/src/backend/shared/ethercat/generate-softmotion.ts +++ b/src/backend/shared/ethercat/generate-softmotion.ts @@ -186,26 +186,26 @@ export function injectAxisExternals(pou: PLCPou, axisNames: string[]): PLCPou { * against the same public axis the compiler generates — without the user * declaring anything. Returns '' when the project has no axes. * - * Only the axis references are declared (not the located PDO scalar globals), - * because those are internal to the generated drive bridge and never named in - * user code. `AXIS_REF_SM3` itself comes from the bundled plcopen-softmotion - * stlib the LSP already ingests. + * Emitted as a **bare top-level `VAR_GLOBAL` block** (not wrapped in a + * CONFIGURATION): strucpp registers top-level global blocks into the ambient + * global scope, so a POU can reference the axis directly — no `VAR_EXTERNAL` + * needed, which is what keeps the editor documents byte-for-byte what the user + * wrote (no injected declarations shifting line numbers). Only the axis + * references are declared (not the located PDO scalar globals) — those are + * internal to the generated drive bridge and never named in user code. + * `AXIS_REF_SM3` itself comes from the bundled plcopen-softmotion stlib the LSP + * already ingests. + * + * The declaration order matches `softMotionAxisNames`, so line N+1 of this + * document (line 0 is `VAR_GLOBAL`) is axis N — the go-to-definition redirect + * relies on that to map a click back to its drive. */ export function serializeSoftMotionAxisGlobalsToST(project: PLCProjectData): string { const axes = collectAxes(project) if (axes.length === 0) return '' const decls = axes.map((a) => ` ${a.axisName} : AXIS_REF_SM3;`).join('\n') - return [ - 'CONFIGURATION __softmotion_axes__', - 'VAR_GLOBAL', - decls, - 'END_VAR', - 'RESOURCE __softmotion_res__ ON PLC', - 'END_RESOURCE', - 'END_CONFIGURATION', - '', - ].join('\n') + return ['VAR_GLOBAL', decls, 'END_VAR', ''].join('\n') } /** 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 b53e0f14d..cc960ef63 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 @@ -35,6 +35,7 @@ function setProjectPous(pous: PLCPou[]) { ...s.project.data, pous, dataTypes: [], + remoteDevices: [], }, }, editor: { type: 'available', meta: { name: 'available' } }, @@ -186,4 +187,72 @@ describe('redirectDefinitionToStore', () => { expect(state.editor.variable.display).toBe('table') } }) + + it('redirects a SoftMotion axis global to the owning drive editor', () => { + // Minimal CiA 402 drive: controlWord (0x6040 out) + statusWord (0x6041 in) + // mapped, so collectAxes/softMotionAxisNames recognise it as an axis. + openPLCStoreBase.setState((s) => ({ + ...s, + project: { + ...s.project, + data: { + ...s.project.data, + pous: [], + dataTypes: [], + remoteDevices: [ + { + name: 'eth', + protocol: 'ethercat', + ethercatConfig: { + devices: [ + { + id: 'd1', + name: 'My_Axis', + cia402: { enabled: true, scaleNum: 1, scaleDenom: 1, scaleFactor: 1 }, + channelInfo: [ + { channelId: 'c1', entryIndex: '0x6040', direction: 'output', iecType: 'UINT' }, + { channelId: 'c2', entryIndex: '0x6041', direction: 'input', iecType: 'UINT' }, + ], + channelMappings: [ + { channelId: 'c1', iecLocation: '%QW0', alias: '' }, + { channelId: 'c2', iecLocation: '%IW0', alias: '' }, + ], + }, + ], + }, + }, + ], + } as never, + }, + editor: { type: 'available', meta: { name: 'available' } }, + editors: [], + tabs: [], + selectedTab: null, + })) + + // Line 0 of the globals doc is `VAR_GLOBAL`; line 1 is the first axis. + const handled = redirectDefinitionToStore({ + uri: 'inmemory://softmotion/__axes__.st', + range: { start: { line: 1, character: 2 }, end: { line: 1, character: 9 } }, + }) + + expect(handled).toBe(true) + const state = openPLCStoreBase.getState() + expect(state.editor.type).toBe('plc-ethercat-device') + expect(state.editor.meta.name).toBe('My_Axis') + if (state.editor.type === 'plc-ethercat-device') { + expect(state.editor.meta.busName).toBe('eth') + expect(state.editor.meta.deviceId).toBe('d1') + } + }) + + it('returns false for a SoftMotion globals line with no matching axis', () => { + setProjectPous([]) + expect( + redirectDefinitionToStore({ + uri: 'inmemory://softmotion/__axes__.st', + range: { start: { line: 1, character: 0 }, end: { line: 1, character: 0 } }, + }), + ).toBe(false) + }) }) diff --git a/src/frontend/services/st-lsp/goto-definition-redirect.ts b/src/frontend/services/st-lsp/goto-definition-redirect.ts index 01d0f65c6..c3c3a3e91 100644 --- a/src/frontend/services/st-lsp/goto-definition-redirect.ts +++ b/src/frontend/services/st-lsp/goto-definition-redirect.ts @@ -35,13 +35,14 @@ import type { Location, LocationLink } from 'vscode-languageserver-protocol' +import { sanitizeAxisName, softMotionAxisNames } from '../../../backend/shared/ethercat/generate-softmotion' import type { PLCDataType } from '../../../middleware/shared/ports/types' import { openPLCStoreBase } from '../../store' import { CreateEditorObjectFromTab } from '../../store/slices/tabs/utils' import { serializeDataTypesToLines } 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 } from './types' +import { DATA_TYPES_URI, parsePouUri, SOFTMOTION_GLOBALS_URI } from './types' /** * Map an LSP line in the synthesised datatypes document to the @@ -102,9 +103,67 @@ function openDataTypeEditor(dataType: PLCDataType): boolean { 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 + * axis to its drive configuration screen instead of the synthesised globals doc. + */ +function openDeviceEditor(name: string, busName: string, deviceId: string): boolean { + const tabProps: Parameters[0] = { + name, + path: `/devices/remote/${busName}/devices/${deviceId}`, + elementType: { type: 'ethercat-device', busName, deviceId }, + } + const { + editorActions: { setEditor, addModel, getEditorFromEditors }, + tabsActions: { updateTabs, setSelectedTab }, + } = openPLCStoreBase.getState() + updateTabs(tabProps) + const existing = getEditorFromEditors(name) + if (existing) { + addModel(existing) + setEditor(existing) + } else { + const model = CreateEditorObjectFromTab(tabProps) + addModel(model) + setEditor(model) + } + setSelectedTab(name) + return true +} + +/** + * Redirect a go-to-definition landing in the synthesised SoftMotion axis-globals + * document to the drive that owns the axis. The document is a bare `VAR_GLOBAL` + * block: line 0 is `VAR_GLOBAL`, line N (1-based) is axis N-1 — the same order + * as `softMotionAxisNames`. Returns false when the line doesn't map to an axis + * or no drive matches (caller falls back to Monaco's default). + */ +function redirectSoftMotionAxis(lspLine: number): boolean { + if (lspLine < 1) return false + const data = openPLCStoreBase.getState().project.data + const axisName = softMotionAxisNames(data)[lspLine - 1] + if (!axisName) return false + for (const rd of data.remoteDevices ?? []) { + if (rd.protocol !== 'ethercat') continue + for (const dev of rd.ethercatConfig?.devices ?? []) { + if (sanitizeAxisName(dev.name) === axisName) { + return openDeviceEditor(dev.name, rd.name, dev.id) + } + } + } + return false +} + export function redirectDefinitionToStore(loc: Location | LocationLink): boolean { const target = normaliseLocation(loc) + // SoftMotion axis globals doc → open the owning drive's config screen rather + // than the synthesised (non-editable) global declaration. + if (target.uri === SOFTMOTION_GLOBALS_URI) { + return redirectSoftMotionAxis(target.lineLsp) + } + // Datatypes URI → open the matching data-type editor tab. The LSP // emits this URI for every reference into the synthesised // TYPE…END_TYPE block (enum members, struct fields, array element diff --git a/src/frontend/services/st-lsp/project-sync.ts b/src/frontend/services/st-lsp/project-sync.ts index 4ed1ac1e5..d259308e0 100644 --- a/src/frontend/services/st-lsp/project-sync.ts +++ b/src/frontend/services/st-lsp/project-sync.ts @@ -25,11 +25,7 @@ * `refreshStlibs()` on the service. */ -import { - injectAxisExternals, - serializeSoftMotionAxisGlobalsToST, - softMotionAxisNames, -} from '../../../backend/shared/ethercat/generate-softmotion' +import { serializeSoftMotionAxisGlobalsToST } from '../../../backend/shared/ethercat/generate-softmotion' import type { PLCDataType, PLCPou, PLCRemoteDevice } from '../../../middleware/shared/ports/types' import { openPLCStoreBase } from '../../store' import { serializeDataTypesToST } from '../../utils/PLC/data-type-serializer' @@ -142,20 +138,15 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { seenUris.add(DATA_TYPES_URI) seenUris.add(SOFTMOTION_GLOBALS_URI) - // Inject the same axis VAR_EXTERNALs the compiler generates, so a POU that - // names an axis (`MC_*(Axis := X_Axis)`) resolves it in the editor exactly - // as it will at compile time. Derived from the live remote-device set. - const axisNames = softMotionAxisNames({ - remoteDevices: openPLCStoreBase.getState().project.data.remoteDevices, - } as never) - for (const pou of pous) { seenNames.add(pou.name) const nextUri = uriForPou(pou) const previousUri = snapshot.uriByName.get(pou.name) - const { text: nextText, bodyLineOffset } = serializePouSignatureToSTWithBodyOffset( - injectAxisExternals(pou, axisNames), - ) + // POUs are serialised verbatim — no axis VAR_EXTERNAL injection here. + // Axes are surfaced as ambient globals (SOFTMOTION_GLOBALS_URI), so the + // editor documents keep the exact line layout the user wrote and + // go-to-definition line mapping stays correct. + const { text: nextText, bodyLineOffset } = serializePouSignatureToSTWithBodyOffset(pou) // POU name unchanged but URI switched (body language change). // Send didClose for the previous URI before didOpen on the new. @@ -220,12 +211,7 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { // doc so editor code resolves the new axis without a POU edit. const unsubscribeRemoteDevices = openPLCStoreBase.subscribe( (state) => state.project.data.remoteDevices, - (remoteDevices) => { - reconcileSoftMotionGlobals(remoteDevices) - // Axis set changed → re-publish POUs so their injected VAR_EXTERNALs - // pick up added/removed/renamed axes without waiting on a POU edit. - reconcile(openPLCStoreBase.getState().project.data.pous) - }, + (remoteDevices) => reconcileSoftMotionGlobals(remoteDevices), ) // Initial reconcile against whatever is already in the store. The From 26d4894c364cb61afa7373b155e73358e9898371 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 14 Jul 2026 14:13:53 -0400 Subject: [PATCH 22/52] fix(lsp): sync resource globals so user VAR_EXTERNAL declarations resolve (mirror of openplc-web) Byte-identical mirror. The LSP now sends the project's configuration-level globals as a CONFIGURATION VAR_GLOBAL doc (the level the compiler emits at, and the only form strucpp matches a VAR_EXTERNAL against), so a POU's VAR_EXTERNAL no longer falsely errors. Extracted a shared reconcileSyntheticDoc engine used by all three synthesized docs (data types, resource globals, softmotion axes), and go-to-definition on a user global opens the Resource editor. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../goto-definition-redirect.test.ts | 12 +++ .../st-lsp/goto-definition-redirect.ts | 37 +++++++- src/frontend/services/st-lsp/project-sync.ts | 90 +++++++++++-------- src/frontend/services/st-lsp/types.ts | 8 ++ .../resource-globals-serializer.test.ts | 38 ++++++++ .../utils/PLC/resource-globals-serializer.ts | 39 ++++++++ 6 files changed, 184 insertions(+), 40 deletions(-) create mode 100644 src/frontend/utils/PLC/__tests__/resource-globals-serializer.test.ts create mode 100644 src/frontend/utils/PLC/resource-globals-serializer.ts 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 cc960ef63..4d06cbd12 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 @@ -246,6 +246,18 @@ describe('redirectDefinitionToStore', () => { } }) + it('redirects a resource-global to the Resource editor', () => { + setProjectPous([]) + const handled = redirectDefinitionToStore({ + uri: 'inmemory://globals/__resource__.st', + range: { start: { line: 2, character: 4 }, end: { line: 2, character: 15 } }, + }) + expect(handled).toBe(true) + const state = openPLCStoreBase.getState() + expect(state.editor.type).toBe('plc-resource') + expect(state.editor.meta.name).toBe('Resource') + }) + it('returns false for a SoftMotion globals line with no matching axis', () => { setProjectPous([]) expect( diff --git a/src/frontend/services/st-lsp/goto-definition-redirect.ts b/src/frontend/services/st-lsp/goto-definition-redirect.ts index c3c3a3e91..5e7364940 100644 --- a/src/frontend/services/st-lsp/goto-definition-redirect.ts +++ b/src/frontend/services/st-lsp/goto-definition-redirect.ts @@ -42,7 +42,7 @@ import { CreateEditorObjectFromTab } from '../../store/slices/tabs/utils' import { serializeDataTypesToLines } 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, SOFTMOTION_GLOBALS_URI } from './types' +import { DATA_TYPES_URI, parsePouUri, RESOURCE_GLOBALS_URI, SOFTMOTION_GLOBALS_URI } from './types' /** * Map an LSP line in the synthesised datatypes document to the @@ -155,9 +155,44 @@ function redirectSoftMotionAxis(lspLine: number): boolean { return false } +/** + * Open the Resource editor (where configuration-level globals are declared), + * mirroring the project-tree click path. Used to redirect go-to-definition on a + * user global to the globals table instead of the synthesised globals doc. + */ +function openResourceEditor(): boolean { + const tabProps: Parameters[0] = { + name: 'Resource', + path: '/data/configuration/resource', + elementType: { type: 'resource' }, + } + const { + editorActions: { setEditor, addModel, getEditorFromEditors }, + tabsActions: { updateTabs, setSelectedTab }, + } = openPLCStoreBase.getState() + updateTabs(tabProps) + const existing = getEditorFromEditors('Resource') + if (existing) { + addModel(existing) + setEditor(existing) + } else { + const model = CreateEditorObjectFromTab(tabProps) + addModel(model) + setEditor(model) + } + setSelectedTab('Resource') + return true +} + export function redirectDefinitionToStore(loc: Location | LocationLink): boolean { const target = normaliseLocation(loc) + // Resource-globals doc → open the Resource editor (globals table) rather than + // the synthesised (non-editable) CONFIGURATION declaration. + if (target.uri === RESOURCE_GLOBALS_URI) { + return openResourceEditor() + } + // SoftMotion axis globals doc → open the owning drive's config screen rather // than the synthesised (non-editable) global declaration. if (target.uri === SOFTMOTION_GLOBALS_URI) { diff --git a/src/frontend/services/st-lsp/project-sync.ts b/src/frontend/services/st-lsp/project-sync.ts index d259308e0..dd7e1e692 100644 --- a/src/frontend/services/st-lsp/project-sync.ts +++ b/src/frontend/services/st-lsp/project-sync.ts @@ -26,12 +26,20 @@ */ import { serializeSoftMotionAxisGlobalsToST } from '../../../backend/shared/ethercat/generate-softmotion' -import type { PLCDataType, PLCPou, PLCRemoteDevice } from '../../../middleware/shared/ports/types' +import type { PLCDataType, PLCPou, PLCRemoteDevice, PLCVariable } from '../../../middleware/shared/ports/types' import { openPLCStoreBase } from '../../store' import { serializeDataTypesToST } from '../../utils/PLC/data-type-serializer' import { serializePouSignatureToSTWithBodyOffset } from '../../utils/PLC/pou-signature-serializer' +import { serializeResourceGlobalsToST } from '../../utils/PLC/resource-globals-serializer' import { deleteBodyLineOffset, setBodyLineOffset } from '../lsp-shared/body-offsets' -import { DATA_TYPES_URI, pouUri, SOFTMOTION_GLOBALS_URI, type StLspService, stubUri } from './types' +import { + DATA_TYPES_URI, + pouUri, + RESOURCE_GLOBALS_URI, + SOFTMOTION_GLOBALS_URI, + type StLspService, + stubUri, +} from './types' /** * Determines whether a POU's source goes through the live-body @@ -79,52 +87,46 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { const snapshot = emptySnapshot() let disposed = false - function reconcileDataTypes(dataTypes: PLCDataType[]): void { + // Reconcile a single fixed-URI synthesized document (data types, resource + // globals, softmotion axes …) against the worker: open on first non-empty + // text, didChange on a text change, didClose when it becomes empty. Centralised + // so every synthesized doc shares one diff engine instead of copy-pasting it. + function reconcileSyntheticDoc(uri: string, nextText: string): void { if (disposed) return - // Single fixed-URI document that carries the whole TYPE block. - // Empty `dataTypes` (or types that all serialise to nothing) → - // close any previously-open document so strucpp doesn't keep a - // stale set around. - const nextText = serializeDataTypesToST(dataTypes) - const previousText = snapshot.contentByUri.get(DATA_TYPES_URI) + const previousText = snapshot.contentByUri.get(uri) if (nextText.length === 0) { if (previousText !== undefined) { - service.closeDocument(DATA_TYPES_URI) - snapshot.contentByUri.delete(DATA_TYPES_URI) + service.closeDocument(uri) + snapshot.contentByUri.delete(uri) } return } if (previousText === undefined) { - service.openDocument(DATA_TYPES_URI, nextText) + service.openDocument(uri, nextText) } else if (previousText !== nextText) { snapshot.version += 1 - service.changeDocument(DATA_TYPES_URI, nextText, snapshot.version) + service.changeDocument(uri, nextText, snapshot.version) } - snapshot.contentByUri.set(DATA_TYPES_URI, nextText) + snapshot.contentByUri.set(uri, nextText) + } + + // The whole `TYPE … END_TYPE` block, so any POU that references a user data + // type resolves it. + function reconcileDataTypes(dataTypes: PLCDataType[]): void { + reconcileSyntheticDoc(DATA_TYPES_URI, serializeDataTypesToST(dataTypes)) + } + + // The project's configuration-level `VAR_GLOBAL`s wrapped in a CONFIGURATION, + // so a POU's `VAR_EXTERNAL` resolves against a matching global. + function reconcileResourceGlobals(globals: PLCVariable[]): void { + reconcileSyntheticDoc(RESOURCE_GLOBALS_URI, serializeResourceGlobalsToST(globals)) } - // Single fixed-URI document carrying `VAR_GLOBAL : AXIS_REF_SM3` for - // every recognized CiA 402 drive, so editor code that names an axis resolves - // it (the LSP analyses every open document together, and AXIS_REF_SM3 comes - // from the bundled stlib). Mirrors reconcileDataTypes. + // A `VAR_GLOBAL : AXIS_REF_SM3` per recognized CiA 402 drive, so editor + // code that names an axis resolves it (AXIS_REF_SM3 comes from the bundled + // stlib the LSP already ingests). function reconcileSoftMotionGlobals(remoteDevices: PLCRemoteDevice[] | undefined): void { - if (disposed) return - const nextText = serializeSoftMotionAxisGlobalsToST({ remoteDevices } as never) - const previousText = snapshot.contentByUri.get(SOFTMOTION_GLOBALS_URI) - if (nextText.length === 0) { - if (previousText !== undefined) { - service.closeDocument(SOFTMOTION_GLOBALS_URI) - snapshot.contentByUri.delete(SOFTMOTION_GLOBALS_URI) - } - return - } - if (previousText === undefined) { - service.openDocument(SOFTMOTION_GLOBALS_URI, nextText) - } else if (previousText !== nextText) { - snapshot.version += 1 - service.changeDocument(SOFTMOTION_GLOBALS_URI, nextText, snapshot.version) - } - snapshot.contentByUri.set(SOFTMOTION_GLOBALS_URI, nextText) + reconcileSyntheticDoc(SOFTMOTION_GLOBALS_URI, serializeSoftMotionAxisGlobalsToST({ remoteDevices } as never)) } function reconcile(pous: PLCPou[]): void { @@ -132,10 +134,11 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { const seenNames = new Set() const seenUris = new Set() - // The data-types and SoftMotion-globals documents survive every POU - // reconcile — mark their URIs as seen so the catch-all cleanup loop below - // doesn't drop them. + // The synthesized documents (data types, resource globals, SoftMotion axes) + // survive every POU reconcile — mark their URIs as seen so the catch-all + // cleanup loop below doesn't drop them. seenUris.add(DATA_TYPES_URI) + seenUris.add(RESOURCE_GLOBALS_URI) seenUris.add(SOFTMOTION_GLOBALS_URI) for (const pou of pous) { @@ -206,6 +209,12 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { (state) => state.project.data.dataTypes, (dataTypes) => reconcileDataTypes(dataTypes), ) + // Resource globals live under the configuration and change independently of + // POUs, so a POU's VAR_EXTERNAL resolves without waiting on a POU edit. + const unsubscribeResourceGlobals = openPLCStoreBase.subscribe( + (state) => state.project.data.configurations.resource.globalVariables, + (globals) => reconcileResourceGlobals(globals), + ) // SoftMotion axis globals derive from the EtherCAT remote devices — adding, // renaming, or enabling a CiA 402 drive must refresh the synthesized globals // doc so editor code resolves the new axis without a POU edit. @@ -215,9 +224,10 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { ) // Initial reconcile against whatever is already in the store. The - // data types and axis globals load first so any POU that references + // synthesized globals/types load first so any POU that references // them resolves on the first didOpen, not on a follow-up didChange. reconcileDataTypes(openPLCStoreBase.getState().project.data.dataTypes) + reconcileResourceGlobals(openPLCStoreBase.getState().project.data.configurations.resource.globalVariables) reconcileSoftMotionGlobals(openPLCStoreBase.getState().project.data.remoteDevices) reconcile(openPLCStoreBase.getState().project.data.pous) @@ -225,6 +235,7 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { resync() { if (disposed) return reconcileDataTypes(openPLCStoreBase.getState().project.data.dataTypes) + reconcileResourceGlobals(openPLCStoreBase.getState().project.data.configurations.resource.globalVariables) reconcileSoftMotionGlobals(openPLCStoreBase.getState().project.data.remoteDevices) reconcile(openPLCStoreBase.getState().project.data.pous) }, @@ -244,6 +255,7 @@ export function attachProjectSync(service: StLspService): ProjectSyncHandle { disposed = true unsubscribePous() unsubscribeDataTypes() + unsubscribeResourceGlobals() unsubscribeRemoteDevices() // Close every doc we'd opened so the worker stays consistent // if the service is restarted in the same session. diff --git a/src/frontend/services/st-lsp/types.ts b/src/frontend/services/st-lsp/types.ts index f8454fa30..ee506b6d5 100644 --- a/src/frontend/services/st-lsp/types.ts +++ b/src/frontend/services/st-lsp/types.ts @@ -49,6 +49,14 @@ export const DATA_TYPES_URI = 'inmemory://datatypes/__project__.st' */ export const SOFTMOTION_GLOBALS_URI = 'inmemory://softmotion/__axes__.st' +/** + * URI for the synthesized resource-globals document — the project's + * configuration-level `VAR_GLOBAL`s wrapped in a `CONFIGURATION` block, so a + * POU's `VAR_EXTERNAL` resolves against a matching global (strucpp requires a + * configuration-scoped global for that). Single fixed URI per session. + */ +export const RESOURCE_GLOBALS_URI = 'inmemory://globals/__resource__.st' + /** Public service the rest of the renderer talks to. */ export interface StLspService { /** diff --git a/src/frontend/utils/PLC/__tests__/resource-globals-serializer.test.ts b/src/frontend/utils/PLC/__tests__/resource-globals-serializer.test.ts new file mode 100644 index 000000000..d7bc53a2c --- /dev/null +++ b/src/frontend/utils/PLC/__tests__/resource-globals-serializer.test.ts @@ -0,0 +1,38 @@ +import type { PLCVariable } from '../../../../middleware/shared/ports/types' +import { serializeResourceGlobalsToST } from '../resource-globals-serializer' + +function global(name: string, typeValue: string, extra: Partial = {}): PLCVariable { + return { name, type: { definition: 'base-type', value: typeValue }, location: '', documentation: '', ...extra } +} + +describe('serializeResourceGlobalsToST', () => { + it('returns empty string when there are no globals', () => { + expect(serializeResourceGlobalsToST([])).toBe('') + }) + + it('wraps the globals in a CONFIGURATION so VAR_EXTERNAL resolves', () => { + const st = serializeResourceGlobalsToST([global('test_global', 'DINT')]) + expect(st).toContain('CONFIGURATION') + expect(st).toContain('VAR_GLOBAL') + expect(st).toContain('test_global : DINT;') + expect(st).toContain('END_VAR') + expect(st).toContain('RESOURCE') + expect(st).toContain('END_CONFIGURATION') + // Globals must be at the CONFIGURATION level, before the RESOURCE block. + expect(st.indexOf('VAR_GLOBAL')).toBeLessThan(st.indexOf('RESOURCE')) + }) + + it('emits a single VAR_GLOBAL block even if a stored global carries a stray class', () => { + const st = serializeResourceGlobalsToST([global('a', 'INT', { class: 'external' }), global('b', 'BOOL')]) + // Both land in one VAR_GLOBAL block; no VAR_EXTERNAL leaks in. + expect(st).not.toContain('VAR_EXTERNAL') + expect((st.match(/VAR_GLOBAL/g) ?? []).length).toBe(1) + expect(st).toContain('a : INT;') + expect(st).toContain('b : BOOL;') + }) + + it('preserves location and initial value', () => { + const st = serializeResourceGlobalsToST([global('counter', 'INT', { location: '%MW0', initialValue: '5' })]) + expect(st).toContain('counter : INT AT %MW0 := 5;') + }) +}) diff --git a/src/frontend/utils/PLC/resource-globals-serializer.ts b/src/frontend/utils/PLC/resource-globals-serializer.ts new file mode 100644 index 000000000..f6b6c39eb --- /dev/null +++ b/src/frontend/utils/PLC/resource-globals-serializer.ts @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +import type { PLCVariable } from '../../../middleware/shared/ports/types' +import { generateIecVariablesToString } from '../generate-iec-variables-to-string' + +const GLOBALS_CONFIG_NAME = '__globals_cfg__' +const GLOBALS_RESOURCE_NAME = '__globals_res__' + +/** + * Serialize the project's resource-level global variables as a standalone + * `CONFIGURATION` document for the language server, so a POU's `VAR_EXTERNAL` + * resolves against a matching `VAR_GLOBAL` instead of being flagged as having no + * global declaration. + * + * Two things pin the shape: + * - strucpp only matches a `VAR_EXTERNAL` against a global declared inside a + * `CONFIGURATION` — a bare top-level `VAR_GLOBAL` block does NOT satisfy it. + * - the compiler itself emits user globals at the `CONFIGURATION` level (see + * `st-transpiler/emit/configuration.ts`), so validating against this shape + * matches exactly what gets generated. + * + * The `VAR_GLOBAL` block is produced by `generateIecVariablesToString` — the + * same formatter the variables editor uses — so declarations stay in lockstep + * with how variables render everywhere else. Returns '' when there are none. + */ +export function serializeResourceGlobalsToST(globals: PLCVariable[]): string { + if (!globals || globals.length === 0) return '' + // Force the global class so the shared formatter always emits a single + // VAR_GLOBAL block, regardless of any stray class on the stored variable. + const varBlock = generateIecVariablesToString(globals.map((g) => ({ ...g, class: 'global' }))) + return [ + `CONFIGURATION ${GLOBALS_CONFIG_NAME}`, + varBlock, + ` RESOURCE ${GLOBALS_RESOURCE_NAME} ON PLC`, + ' END_RESOURCE', + 'END_CONFIGURATION', + '', + ].join('\n') +} From cfb045aa09ac7d7c8e95df607fb3895c6f52c204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Tue, 14 Jul 2026 15:49:01 -0300 Subject: [PATCH 23/52] perf(graphical-editor): migrate to narrow zustand selectors (DOPE-487) Replace all 25 bare useOpenPLCStore() whole-store subscriptions across the graphical editor (ladder/FBD node atoms, RungBody, RungHeader, Rung, LadderEditor, FBDBody, element-editor modals, autocompletes, useCopyPaste, usePouSnapshot) with: - narrowest-path selectors for render reads (pou-scoped find() selectors for flows, specific modal keys, primitives where possible) - useOpenPLCStore.getState() at event time for handler-only reads, which removes re-render fan-out and stale-closure risk at once (the onDrop dep-array workarounds for freshly installed libraries are now moot) - stable action-group selects Store writes, flow persistence, and the compile pipeline input are untouched; save-without-edit produces a zero source-control diff. Live validation at 73 mounted rung instances (vs 2026-07-10 baseline): typing is now ~2 commits/keystroke local to the focused node (baseline: 26-42 whole-editor commits/sec with ONE rung), idle is a flat 0 commits/5s (baseline: sporadic 62-345 spikes), modal open/close no longer re-renders the canvases (worst task 61ms vs a 470ms full-canvas pass). Root cause C1 of DOPE-446. Fixes typing/idle/modal re-render storms; rung-duplication scaling (C5) is DOPE-489, memo barriers are DOPE-488. Mirror of the openplc-web commit (src/frontend byte-identical). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01S3FiQAY3P1gJDnGhuHbAab --- .../fbd/autocomplete/index.tsx | 12 ++-- .../_atoms/graphical-editor/fbd/block.tsx | 55 ++++++++-------- .../_atoms/graphical-editor/fbd/comment.tsx | 16 ++--- .../graphical-editor/fbd/connection.tsx | 13 ++-- .../_atoms/graphical-editor/fbd/variable.tsx | 18 +++--- .../ladder/autocomplete/index.tsx | 37 ++++++----- .../_atoms/graphical-editor/ladder/block.tsx | 53 ++++++++-------- .../_atoms/graphical-editor/ladder/coil.tsx | 18 +++--- .../graphical-editor/ladder/contact.tsx | 18 +++--- .../graphical-editor/ladder/variable.tsx | 14 ++--- .../graphical/elements/fbd/block/index.tsx | 18 +++--- .../graphical/elements/fbd/block/library.tsx | 9 +-- .../graphical/elements/ladder/block/index.tsx | 18 +++--- .../elements/ladder/block/library.tsx | 9 +-- .../graphical/elements/ladder/coil/index.tsx | 13 ++-- .../elements/ladder/contact/index.tsx | 13 ++-- .../editor/graphical/ladder/index.tsx | 51 +++++++-------- .../fbd/fbd-utils/useCopyPaste.ts | 2 +- .../_molecules/graphical-editor/fbd/index.tsx | 44 ++++++------- .../graphical-editor/ladder/rung/body.tsx | 63 +++++++++---------- .../graphical-editor/ladder/rung/header.tsx | 6 +- .../graphical-editor/ladder/rung/index.tsx | 15 ++--- src/frontend/hooks/use-pou-snapshot.ts | 13 ++-- 23 files changed, 237 insertions(+), 291 deletions(-) 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 8a1aacabb..ed5144210 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx @@ -31,14 +31,10 @@ type FBDBlockAutoCompleteProps = ComponentPropsWithRef<'div'> & { const FBDBlockAutoComplete = forwardRef( ({ block: unknownBlock, isOpen, setIsOpen, keyPressed, valueToSearch }: FBDBlockAutoCompleteProps, ref) => { const pouName = useBoundPou() - const { - project: { - data: { pous }, - }, - projectActions: { createVariable }, - fbdFlows, - fbdFlowActions: { updateNode, addNode }, - } = useOpenPLCStore() + const pous = useOpenPLCStore((state) => state.project.data.pous) + const createVariable = useOpenPLCStore((state) => state.projectActions.createVariable) + const fbdFlows = useOpenPLCStore((state) => state.fbdFlows) + const { updateNode, addNode } = useOpenPLCStore((state) => state.fbdFlowActions) const block = unknownBlock as Node & { positionAbsoluteX?: number; positionAbsoluteY?: number } const { edges, rung } = useMemo(() => { diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx index baf847233..b662b4ad7 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx @@ -49,18 +49,10 @@ export const BlockNodeElement = ({ }) => { const pouName = useBoundPou() const editor = useBoundEditorModel() - const { - editorActions: { updateModelVariables, updateModelFBD }, - libraries, - fbdFlows, - fbdFlowActions: { setNodes, setEdges }, - project, - project: { - data: { pous }, - }, - projectActions: { updateVariable, deleteVariable }, - snapshotActions: { pushToHistory }, - } = useOpenPLCStore() + const { updateModelVariables, updateModelFBD } = useOpenPLCStore((state) => state.editorActions) + const { setNodes, setEdges } = useOpenPLCStore((state) => state.fbdFlowActions) + const { updateVariable, deleteVariable } = useOpenPLCStore((state) => state.projectActions) + const pushToHistory = useOpenPLCStore((state) => state.snapshotActions.pushToHistory) const { name: blockName, @@ -82,9 +74,6 @@ export const BlockNodeElement = ({ const inputNameRef = useRef(null) const [inputNameFocus, setInputNameFocus] = useState(true) - const { pou, rung, node, variables, edges } = getFBDPouVariablesRungNodeAndEdges(pouName, pous, fbdFlows, { - nodeId: nodeId ?? '', - }) /** * useEffect to focus the name input when the correct block type is selected */ @@ -119,6 +108,16 @@ export const BlockNodeElement = ({ return } + const { project, libraries, fbdFlows } = useOpenPLCStore.getState() + const { pou, rung, node, variables, edges } = getFBDPouVariablesRungNodeAndEdges( + pouName, + project.data.pous, + fbdFlows, + { + nodeId: nodeId ?? '', + }, + ) + const libraryBlock = libraries.system.flatMap((block) => block.pous).find((pou) => pou.name === blockNameValue) if (!libraryBlock) { @@ -331,17 +330,13 @@ export const BlockNodeElement = ({ export const Block = (block: BlockProps) => { const { data, dragging, height, width, selected, id } = block const pouName = useBoundPou() - const { - project, - project: { - data: { pous }, - }, - projectActions: { createVariable }, - snapshotActions: { pushToHistory }, - libraries: { user: userLibraries }, - fbdFlows, - fbdFlowActions: { updateNode, setNodes, setEdges }, - } = useOpenPLCStore() + const pous = useOpenPLCStore((state) => state.project.data.pous) + const createVariable = useOpenPLCStore((state) => state.projectActions.createVariable) + const pushToHistory = useOpenPLCStore((state) => state.snapshotActions.pushToHistory) + const { updateNode, setNodes, setEdges } = useOpenPLCStore((state) => state.fbdFlowActions) + // Pou-scoped subscription: immer's structural sharing keeps this flow's + // identity stable when other POUs' flows (or unrelated slices) change. + const flow = useOpenPLCStore((state) => state.fbdFlows.find((f) => f.name === pouName)) const { type: blockType } = (data.variant as BlockVariant) ?? DEFAULT_BLOCK_TYPE const documentation = getBlockDocumentation(data.variant as BlockVariant) @@ -349,7 +344,7 @@ export const Block = (block: BlockProps) => { const [wrongVariable, setWrongVariable] = useState(false) const [hoveringBlock, setHoveringBlock] = useState(false) - const { rung, node, variables } = getFBDPouVariablesRungNodeAndEdges(pouName, pous, fbdFlows, { + const { rung, node, variables } = getFBDPouVariablesRungNodeAndEdges(pouName, pous, flow ? [flow] : [], { nodeId: id ?? '', }) @@ -456,6 +451,7 @@ export const Block = (block: BlockProps) => { return } + const { fbdFlows } = useOpenPLCStore.getState() const { rung, node, variables } = getFBDPouVariablesRungNodeAndEdges(pouName, pous, fbdFlows, { nodeId: id, }) @@ -508,7 +504,7 @@ export const Block = (block: BlockProps) => { if (matchingVariable) { variableToLink = matchingVariable } else if (createIfNotFound) { - const pouData = project.data.pous.find((p) => p.name === pouName) + const pouData = pous.find((p) => p.name === pouName) pushToHistory(pouName, { variables: pouData?.interface?.variables ?? [], body: pouData?.body.value, @@ -550,6 +546,7 @@ export const Block = (block: BlockProps) => { } const handleUpdateDivergence = () => { + const { fbdFlows, libraries } = useOpenPLCStore.getState() const { rung, node, pou } = getFBDPouVariablesRungNodeAndEdges(pouName, pous, fbdFlows, { nodeId: id, }) @@ -558,7 +555,7 @@ export const Block = (block: BlockProps) => { const variant = (node.data as BlockNodeData)?.variant if (!variant) return - const libMatch = userLibraries.find((lib) => lib.name === variant.name && lib.type === variant.type) + const libMatch = libraries.user.find((lib) => lib.name === variant.name && lib.type === variant.type) if (!libMatch) return const libPou = pous.find((pou) => pou.name === libMatch.name) diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/comment.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/comment.tsx index cd6c23176..8760887e9 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/comment.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/comment.tsx @@ -12,14 +12,8 @@ import { CommentNode, CommentProps } from './utils/types' const CommentElement = (block: CommentProps) => { const { id, selected, data, width, height } = block const pouName = useBoundPou() - const { - editorActions: { updateModelFBD }, - fbdFlows, - fbdFlowActions: { updateNode }, - project: { - data: { pous }, - }, - } = useOpenPLCStore() + const updateModelFBD = useOpenPLCStore((state) => state.editorActions.updateModelFBD) + const updateNode = useOpenPLCStore((state) => state.fbdFlowActions.updateNode) const blockRef = useRef(null) const inputVariableRef = useRef< @@ -48,7 +42,8 @@ const CommentElement = (block: CommentProps) => { return } - const { node: commentaryBlock } = getFBDPouVariablesRungNodeAndEdges(pouName, pous, fbdFlows, { + const { project, fbdFlows } = useOpenPLCStore.getState() + const { node: commentaryBlock } = getFBDPouVariablesRungNodeAndEdges(pouName, project.data.pous, fbdFlows, { nodeId: id, }) if (!commentaryBlock) return @@ -98,7 +93,8 @@ const CommentElement = (block: CommentProps) => { }, [commentFocused]) const handleSubmitCommentaryValueOnTextareaBlur = () => { - const { node: commentaryBlock } = getFBDPouVariablesRungNodeAndEdges(pouName, pous, fbdFlows, { + const { project, fbdFlows } = useOpenPLCStore.getState() + const { node: commentaryBlock } = getFBDPouVariablesRungNodeAndEdges(pouName, project.data.pous, fbdFlows, { nodeId: id, }) if (!commentaryBlock) return diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/connection.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/connection.tsx index ab52cce01..cebc84e18 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/connection.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/connection.tsx @@ -19,14 +19,9 @@ import { getFBDPouVariablesRungNodeAndEdges } from './utils/utils' const ConnectionElement = (block: ConnectionProps) => { const { id, data, selected, type } = block const pouName = useBoundPou() - const { - editorActions: { updateModelFBD }, - fbdFlows, - fbdFlowActions: { updateNode }, - project: { - data: { pous }, - }, - } = useOpenPLCStore() + const updateModelFBD = useOpenPLCStore((state) => state.editorActions.updateModelFBD) + const updateNode = useOpenPLCStore((state) => state.fbdFlowActions.updateNode) + const pous = useOpenPLCStore((state) => state.project.data.pous) const inputConnectionRef = useRef< HTMLTextAreaElement & { @@ -66,6 +61,7 @@ const ConnectionElement = (block: ConnectionProps) => { * Update inputError state when the variable is updated */ useEffect(() => { + const { fbdFlows } = useOpenPLCStore.getState() const { rung, node: connectionNode } = getFBDPouVariablesRungNodeAndEdges(pouName, pous, fbdFlows, { nodeId: id, }) @@ -95,6 +91,7 @@ const ConnectionElement = (block: ConnectionProps) => { const handleSubmitConnectionValueOnTextareaBlur = (connectionName?: string) => { const connectionNameToSubmit = connectionName || connectionValue + const { fbdFlows } = useOpenPLCStore.getState() const { pou, rung, diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx index c556fcb38..6e88a9704 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx @@ -41,14 +41,9 @@ import { getFBDPouVariablesRungNodeAndEdges } from './utils/utils' const VariableElement = (block: VariableProps) => { const { id, data, selected } = block const pouName = useBoundPou() - const { - editorActions: { updateModelFBD }, - fbdFlows, - fbdFlowActions: { updateNode }, - project: { - data: { pous }, - }, - } = useOpenPLCStore() + const updateModelFBD = useOpenPLCStore((state) => state.editorActions.updateModelFBD) + const updateNode = useOpenPLCStore((state) => state.fbdFlowActions.updateNode) + const pous = useOpenPLCStore((state) => state.project.data.pous) const debugger_ = useDebugger() const isDebuggerVisible = useIsDebuggerVisible() @@ -87,7 +82,7 @@ const VariableElement = (block: VariableProps) => { /** * Get the connection type */ - const flow = useMemo(() => fbdFlows.find((flow) => flow.name === pouName), [fbdFlows, pouName]) + const flow = useOpenPLCStore((state) => state.fbdFlows.find((flow) => flow.name === pouName)) const connections = useMemo(() => { const rung = flow?.rung @@ -277,7 +272,7 @@ const VariableElement = (block: VariableProps) => { const getVariableType = (): string | undefined => { if (!data.variable || !data.variable.name) return undefined - const { pou } = getFBDPouVariablesRungNodeAndEdges(pouName, pous, fbdFlows, { nodeId: id }) + const pou = pous.find((pou) => pou.name === pouName) if (!pou) return undefined const variable = (pou.interface?.variables ?? []).find( (v: PLCVariable) => v.name.toLowerCase() === data.variable.name.toLowerCase(), @@ -421,7 +416,8 @@ const VariableElement = (block: VariableProps) => { const handleSubmitVariableValueOnTextareaBlur = (variableName?: string) => { const variableNameToSubmit = variableName || variableValue - const { pou, rung, node } = getFBDPouVariablesRungNodeAndEdges(pouName, pous, fbdFlows, { + const { project, fbdFlows } = useOpenPLCStore.getState() + const { pou, rung, node } = getFBDPouVariablesRungNodeAndEdges(pouName, project.data.pous, fbdFlows, { nodeId: id, }) if (!pou || !rung || !node) return 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 64dedd360..5134e741c 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx @@ -54,14 +54,9 @@ const VariablesBlockAutoComplete = forwardRef { const pouName = useBoundPou() - const { - project: { - data: { pous }, - }, - projectActions: { createVariable }, - ladderFlows, - ladderFlowActions: { updateNode }, - } = useOpenPLCStore() + const pous = useOpenPLCStore((state) => state.project.data.pous) + const createVariable = useOpenPLCStore((state) => state.projectActions.createVariable) + const updateNode = useOpenPLCStore((state) => state.ladderFlowActions.updateNode) const expectedType = expectedTypeForBlock(block, blockType) @@ -84,9 +79,15 @@ const VariablesBlockAutoComplete = forwardRef { - const { rung, node: variableNode } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { - nodeId: (block as Node).id, - }) + const { project, ladderFlows } = useOpenPLCStore.getState() + const { rung, node: variableNode } = getLadderPouVariablesRungNodeAndEdges( + pouName, + project.data.pous, + ladderFlows, + { + nodeId: (block as Node).id, + }, + ) if (!rung || !variableNode) return updateNode({ @@ -143,13 +144,19 @@ const VariablesBlockAutoComplete = forwardRef { + const { project, ladderFlows } = useOpenPLCStore.getState() if (!variableName.trim()) { // For variable nodes on block handles, clearing the name resets the variable // so that a branch (contacts/coils) can be placed on the handle instead. if (blockType === 'variable') { - const { rung, node: variableNode } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { - nodeId: (block as Node).id, - }) + const { rung, node: variableNode } = getLadderPouVariablesRungNodeAndEdges( + pouName, + project.data.pous, + ladderFlows, + { + nodeId: (block as Node).id, + }, + ) if (rung && variableNode) { updateNode({ editorName: pouName, @@ -175,7 +182,7 @@ const VariablesBlockAutoComplete = forwardRef).id, }) if (!rung || !node) return diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx index 0e6553ee3..fca8f8ac1 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx @@ -59,17 +59,10 @@ export const BlockNodeElement = ({ }) => { const pouName = useBoundPou() const editor = useBoundEditorModel() - const { - editorActions: { updateModelVariables }, - libraries, - ladderFlows, - ladderFlowActions: { setNodes, setEdges, setHandleBranches }, - project: { - data: { pous }, - }, - projectActions: { updateVariable, deleteVariable }, - snapshotActions: { pushToHistory }, - } = useOpenPLCStore() + const updateModelVariables = useOpenPLCStore((state) => state.editorActions.updateModelVariables) + const { setNodes, setEdges, setHandleBranches } = useOpenPLCStore((state) => state.ladderFlowActions) + const { updateVariable, deleteVariable } = useOpenPLCStore((state) => state.projectActions) + const pushToHistory = useOpenPLCStore((state) => state.snapshotActions.pushToHistory) const { name: blockName, @@ -166,6 +159,8 @@ export const BlockNodeElement = ({ return } + const { project, libraries, ladderFlows } = useOpenPLCStore.getState() + const pous = project.data.pous const libraryBlock = resolveLibraryBlock(blockNameValue, libraries, pous) if (!libraryBlock) { @@ -405,16 +400,15 @@ export const Block = (block: BlockProps) => { const { data, dragging, height, width, selected, id } = block const pouName = useBoundPou() + const pous = useOpenPLCStore((state) => state.project.data.pous) + const createVariable = useOpenPLCStore((state) => state.projectActions.createVariable) + const pushToHistory = useOpenPLCStore((state) => state.snapshotActions.pushToHistory) const { - project: { - data: { pous }, - }, - projectActions: { createVariable }, - snapshotActions: { pushToHistory }, - libraries: { user: userLibraries }, - ladderFlows, - ladderFlowActions: { updateNode, setNodes, setEdges, setHandleBranches: setHandleBranchesBlock }, - } = useOpenPLCStore() + updateNode, + setNodes, + setEdges, + setHandleBranches: setHandleBranchesBlock, + } = useOpenPLCStore((state) => state.ladderFlowActions) const { type: blockType } = (data.variant as BlockVariant) ?? DEFAULT_BLOCK_TYPE const documentation = getBlockDocumentation(data.variant as newBlockVariant) @@ -422,10 +416,6 @@ export const Block = (block: BlockProps) => { const [wrongVariable, setWrongVariable] = useState(false) const [hoveringBlock, setHoveringBlock] = useState(false) - const { variables, rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { - nodeId: id, - }) - const connectedOutputNames = useMemo(() => { const names = new Set() if (Array.isArray(data.connectedVariables)) { @@ -461,6 +451,10 @@ export const Block = (block: BlockProps) => { switch (blockType) { case 'function-block': { if (!data.variable || data.variable.name === '') { + const { project, ladderFlows } = useOpenPLCStore.getState() + const { variables } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { + nodeId: id, + }) const { name, number } = checkVariableName(variables.all, (data.variant as BlockVariant).name.toUpperCase()) handleSubmitBlockVariableOnTextareaBlur(`${name}${number}`, true) @@ -485,6 +479,7 @@ export const Block = (block: BlockProps) => { return } + const { ladderFlows } = useOpenPLCStore.getState() const { variables: freshVariables, rung: freshRung, @@ -551,6 +546,11 @@ export const Block = (block: BlockProps) => { return } + const { ladderFlows } = useOpenPLCStore.getState() + const { variables, rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { + nodeId: id, + }) + if (!rung || !node) { toast({ title: 'Error', description: 'Could not find the related rung or node', variant: 'fail' }) return @@ -641,6 +641,7 @@ export const Block = (block: BlockProps) => { } const handleUpdateDivergence = () => { + const { ladderFlows, libraries } = useOpenPLCStore.getState() const { variables, rung, node, edges } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { nodeId: id, }) @@ -650,7 +651,7 @@ export const Block = (block: BlockProps) => { const variant = (node.data as BlockNodeData)?.variant if (!variant) return - const libMatch = userLibraries.find((lib) => lib.name === variant.name && lib.type === variant.type) + const libMatch = libraries.user.find((lib) => lib.name === variant.name && lib.type === variant.type) if (!libMatch) return const libPou = pous.find((pou) => pou.name === libMatch.name) @@ -879,6 +880,7 @@ export const Block = (block: BlockProps) => { handleSubmit={() => handleSubmitBlockVariableOnTextareaBlur(blockVariableValue, false)} onFocus={(e) => { e.target.select() + const { ladderFlows } = useOpenPLCStore.getState() const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { nodeId: id, }) @@ -898,6 +900,7 @@ export const Block = (block: BlockProps) => { return }} onBlur={() => { + const { ladderFlows } = useOpenPLCStore.getState() const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { nodeId: id, }) diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx index 17ee21089..ead8ec18f 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx @@ -21,13 +21,8 @@ export type { CoilNode } from './utils/types' export const Coil = (block: CoilProps) => { const { selected, data, id } = block const pouName = useBoundPou() - const { - project: { - data: { pous }, - }, - ladderFlows, - ladderFlowActions: { updateNode }, - } = useOpenPLCStore() + const pous = useOpenPLCStore((state) => state.project.data.pous) + const updateNode = useOpenPLCStore((state) => state.ladderFlowActions.updateNode) const debugger_ = useDebugger() const isDebuggerVisible = useIsDebuggerVisible() @@ -149,7 +144,8 @@ export const Coil = (block: CoilProps) => { */ const handleSubmitCoilVariableOnTextareaBlur = (variableName?: string) => { const variableNameToSubmit = variableName || coilVariableValue - const { rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { + const { project, ladderFlows } = useOpenPLCStore.getState() + const { rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { nodeId: id, variableName: variableNameToSubmit, }) @@ -215,7 +211,8 @@ export const Coil = (block: CoilProps) => { readOnly={isDebuggerVisible} onFocus={(e) => { e.target.select() - const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { + const { project, ladderFlows } = useOpenPLCStore.getState() + const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { nodeId: id ?? '', }) if (!node || !rung) return @@ -234,7 +231,8 @@ export const Coil = (block: CoilProps) => { return }} onBlur={() => { - const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { + const { project, ladderFlows } = useOpenPLCStore.getState() + const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { nodeId: id ?? '', }) if (!node || !rung) return diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx index 42aefd1f1..84bbf1e32 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx @@ -21,13 +21,8 @@ export type { ContactNode } from './utils/types' export const Contact = (block: ContactProps) => { const { selected, data, id } = block const pouName = useBoundPou() - const { - project: { - data: { pous }, - }, - ladderFlows, - ladderFlowActions: { updateNode }, - } = useOpenPLCStore() + const pous = useOpenPLCStore((state) => state.project.data.pous) + const updateNode = useOpenPLCStore((state) => state.ladderFlowActions.updateNode) const debugger_ = useDebugger() const isDebuggerVisible = useIsDebuggerVisible() @@ -150,7 +145,8 @@ export const Contact = (block: ContactProps) => { */ const handleSubmitContactVariableOnTextareaBlur = (variableName?: string) => { const variableNameToSubmit = variableName || contactVariableValue - const { rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { + const { project, ladderFlows } = useOpenPLCStore.getState() + const { rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { nodeId: id, variableName: variableNameToSubmit, }) @@ -216,7 +212,8 @@ export const Contact = (block: ContactProps) => { readOnly={isDebuggerVisible} onFocus={(e) => { e.target.select() - const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { + const { project, ladderFlows } = useOpenPLCStore.getState() + const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { nodeId: id ?? '', }) if (!node || !rung) return @@ -235,7 +232,8 @@ export const Contact = (block: ContactProps) => { return }} onBlur={() => { - const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { + const { project, ladderFlows } = useOpenPLCStore.getState() + const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { nodeId: id ?? '', }) if (!node || !rung) return diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx index a71e3f0d3..6b6bd03bf 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx @@ -34,13 +34,8 @@ import { BlockNodeData, BlockVariant, LadderBlockConnectedVariables, VariableNod const VariableElement = (block: VariableProps) => { const { id, data } = block const pouName = useBoundPou() - const { - project: { - data: { pous }, - }, - ladderFlows, - ladderFlowActions: { updateNode }, - } = useOpenPLCStore() + const pous = useOpenPLCStore((state) => state.project.data.pous) + const updateNode = useOpenPLCStore((state) => state.ladderFlowActions.updateNode) const debugger_ = useDebugger() const isDebuggerVisible = useIsDebuggerVisible() const getCompositeKey = useDebugCompositeKey() @@ -165,7 +160,8 @@ const VariableElement = (block: VariableProps) => { const handleSubmitVariableValueOnTextareaBlur = (currentValue?: string) => { const variableNameToSubmit = currentValue ?? variableValue - const { pou, rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { + const { project, ladderFlows } = useOpenPLCStore.getState() + const { pou, rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { nodeId: id, }) if (!pou || !rung || !node) return @@ -238,7 +234,7 @@ const VariableElement = (block: VariableProps) => { const getVariableType = (): string | undefined => { if (!data.variable || !data.variable.name) return undefined - const { pou } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { nodeId: id }) + const pou = pous.find((pou) => pou.name === pouName) if (!pou) return undefined const variable = (pou.interface?.variables ?? []).find( (v) => v.name.toLowerCase() === data.variable.name.toLowerCase(), diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/elements/fbd/block/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/elements/fbd/block/index.tsx index afc756fd9..4866ae3a2 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/elements/fbd/block/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/elements/fbd/block/index.tsx @@ -64,17 +64,12 @@ const searchLibraryByPouName = ( const BlockElement = ({ isOpen, onClose, selectedNode }: BlockElementProps) => { const pouName = useBoundPou() const editor = useBoundEditorModel() - const { - editorActions: { updateModelVariables }, - fbdFlows, - fbdFlowActions: { setNodes, setEdges }, - project: { - data: { pous }, - }, - projectActions: { updateVariable, deleteVariable }, - libraries, - modalActions: { onOpenChange }, - } = useOpenPLCStore() + const updateModelVariables = useOpenPLCStore((state) => state.editorActions.updateModelVariables) + const { setNodes, setEdges } = useOpenPLCStore((state) => state.fbdFlowActions) + const pous = useOpenPLCStore((state) => state.project.data.pous) + const { updateVariable, deleteVariable } = useOpenPLCStore((state) => state.projectActions) + const libraries = useOpenPLCStore((state) => state.libraries) + const onOpenChange = useOpenPLCStore((state) => state.modalActions.onOpenChange) const maxInputs = 20 @@ -362,6 +357,7 @@ const BlockElement = ({ isOpen, onClose, selectedNode }: Block executionOrder: Number(formState.executionOrder), } + const { fbdFlows } = useOpenPLCStore.getState() const { rung, edges, variables } = getFBDPouVariablesRungNodeAndEdges(pouName, pous, fbdFlows, { nodeId: selectedNode.id, }) diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/elements/fbd/block/library.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/elements/fbd/block/library.tsx index 1a724ef0b..8019f3879 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/elements/fbd/block/library.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/elements/fbd/block/library.tsx @@ -16,12 +16,9 @@ export const ModalBlockLibrary = ({ setSelectedFileKey: (string: string) => void }) => { const pouName = useBoundPou() - const { - project: { - data: { pous }, - }, - libraries: { system, user }, - } = useOpenPLCStore() + const pous = useOpenPLCStore((state) => state.project.data.pous) + const system = useOpenPLCStore((state) => state.libraries.system) + const user = useOpenPLCStore((state) => state.libraries.user) // Scope the visible system pool to bundled (canonical) + // project-enabled libraries — same gate the explorer's library // tree uses, so the picker shows exactly what the project can diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/block/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/block/index.tsx index b9c3bdc28..025fa8ab1 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/block/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/block/index.tsx @@ -63,17 +63,12 @@ const searchLibraryByPouName = (libraries: LibraryState['libraries'], pous: PLCP const BlockElement = ({ isOpen, onClose, selectedNode }: BlockElementProps) => { const pouName = useBoundPou() const editor = useBoundEditorModel() - const { - editorActions: { updateModelVariables }, - ladderFlows, - ladderFlowActions: { setNodes, setEdges, setHandleBranches }, - project: { - data: { pous }, - }, - projectActions: { updateVariable, deleteVariable }, - libraries, - modalActions: { onOpenChange }, - } = useOpenPLCStore() + const updateModelVariables = useOpenPLCStore((state) => state.editorActions.updateModelVariables) + const { setNodes, setEdges, setHandleBranches } = useOpenPLCStore((state) => state.ladderFlowActions) + const pous = useOpenPLCStore((state) => state.project.data.pous) + const { updateVariable, deleteVariable } = useOpenPLCStore((state) => state.projectActions) + const libraries = useOpenPLCStore((state) => state.libraries) + const onOpenChange = useOpenPLCStore((state) => state.modalActions.onOpenChange) const maxInputs = 20 @@ -372,6 +367,7 @@ const BlockElement = ({ isOpen, onClose, selectedNode }: Block executionOrder: Number(formState.executionOrder), } + const { ladderFlows } = useOpenPLCStore.getState() const { rung, edges, variables } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { nodeId: selectedNode.id, }) diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/block/library.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/block/library.tsx index 970d4690d..2fe2c34a0 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/block/library.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/block/library.tsx @@ -16,12 +16,9 @@ export const ModalBlockLibrary = ({ setSelectedFileKey: (string: string) => void }) => { const pouName = useBoundPou() - const { - project: { - data: { pous }, - }, - libraries: { system, user }, - } = useOpenPLCStore() + const pous = useOpenPLCStore((state) => state.project.data.pous) + const system = useOpenPLCStore((state) => state.libraries.system) + const user = useOpenPLCStore((state) => state.libraries.user) // Scope the visible system pool to bundled (canonical) + // project-enabled libraries — same gate the FBD picker and the // explorer's library tree use. diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/coil/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/coil/index.tsx index c1896cc65..cd869be87 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/coil/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/coil/index.tsx @@ -15,14 +15,8 @@ type CoilElementProps = { const CoilElement = ({ isOpen, onClose, node }: CoilElementProps) => { const pouName = useBoundPou() - const { - ladderFlows, - project: { - data: { pous }, - }, - ladderFlowActions: { updateNode }, - modalActions: { onOpenChange }, - } = useOpenPLCStore() + const updateNode = useOpenPLCStore((state) => state.ladderFlowActions.updateNode) + const onOpenChange = useOpenPLCStore((state) => state.modalActions.onOpenChange) const [selectedModifier, setSelectedModifier] = useState(node?.data.variant as string) const coilModifiers = Object.entries(DEFAULT_COIL_TYPES).map(([label, coil]) => ({ @@ -42,7 +36,8 @@ const CoilElement = ({ isOpen, onClose, node }: CoilElementProps) => { } const handleConfirmAlteration = () => { - const { rung } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { + const { project, ladderFlows } = useOpenPLCStore.getState() + const { rung } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { nodeId: node.id, }) if (!rung) return diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/contact/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/contact/index.tsx index d51129064..23979488b 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/contact/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/contact/index.tsx @@ -15,14 +15,8 @@ type ContactElementProps = { const ContactElement = ({ isOpen, onClose, node }: ContactElementProps) => { const pouName = useBoundPou() - const { - ladderFlows, - project: { - data: { pous }, - }, - ladderFlowActions: { updateNode }, - modalActions: { onOpenChange }, - } = useOpenPLCStore() + const updateNode = useOpenPLCStore((state) => state.ladderFlowActions.updateNode) + const onOpenChange = useOpenPLCStore((state) => state.modalActions.onOpenChange) const [selectedModifier, setSelectedModifier] = useState(node?.data.variant as string) const contactModifiers = Object.entries(DEFAULT_CONTACT_TYPES).map(([label, contact]) => ({ @@ -42,7 +36,8 @@ const ContactElement = ({ isOpen, onClose, node }: ContactElementProps) => { } const handleConfirmAlteration = () => { - const { rung } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { + const { project, ladderFlows } = useOpenPLCStore.getState() + const { rung } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { nodeId: node.id, }) if (!rung) return diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx index 80ba8a89b..ef547e198 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx @@ -40,21 +40,23 @@ export default function LadderEditor() { // in the wrapper one level up. Mirrors `FbdEditor` — see that // file for the multi-mount rationale. const pouName = useBoundPou() - const { - ladderFlows, - ladderFlowActions, - searchNodePosition, - modals, - project: { - data: { pous }, - }, - projectActions: { updatePou }, - modalActions: { closeModal }, - sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, - snapshotActions: { pushToHistory }, - libraries: { user: userLibraries }, - workspace: { isDebuggerVisible }, - } = useOpenPLCStore() + // Pou-scoped subscription: immer's structural sharing keeps this flow's + // identity stable when other POUs' flows (or unrelated slices) change. + const flow = useOpenPLCStore((state) => state.ladderFlows.find((f) => f.name === pouName)) + const ladderFlowActions = useOpenPLCStore((state) => state.ladderFlowActions) + const searchNodePosition = useOpenPLCStore((state) => state.searchNodePosition) + const blockElementModal = useOpenPLCStore((state) => state.modals['block-ladder-element']) + const contactElementModal = useOpenPLCStore((state) => state.modals['contact-ladder-element']) + const coilElementModal = useOpenPLCStore((state) => state.modals['coil-ladder-element']) + const pous = useOpenPLCStore((state) => state.project.data.pous) + const updatePou = useOpenPLCStore((state) => state.projectActions.updatePou) + const closeModal = useOpenPLCStore((state) => state.modalActions.closeModal) + const handleFileAndWorkspaceSavedState = useOpenPLCStore( + (state) => state.sharedWorkspaceActions.handleFileAndWorkspaceSavedState, + ) + const pushToHistory = useOpenPLCStore((state) => state.snapshotActions.pushToHistory) + const userLibraries = useOpenPLCStore((state) => state.libraries.user) + const isDebuggerVisible = useOpenPLCStore((state) => state.workspace.isDebuggerVisible) const captureSnapshot = useCallback( (pouName: string): PouHistorySnapshot | null => { @@ -71,7 +73,6 @@ export default function LadderEditor() { const updateModelLadder = ladderSelectors.useUpdateModelLadder() - const flow = ladderFlows.find((flow) => flow.name === pouName) const rungs = flow?.rungs || [] const flowUpdated = flow?.updated || false @@ -308,25 +309,25 @@ export default function LadderEditor() { - {modals['block-ladder-element']?.open && ( + {blockElementModal?.open && ( } - isOpen={modals['block-ladder-element'].open} + selectedNode={blockElementModal.data as BlockNode} + isOpen={blockElementModal.open} /> )} - {modals['contact-ladder-element']?.open && ( + {contactElementModal?.open && ( )} - {modals['coil-ladder-element']?.open && ( + {coilElementModal?.open && ( )} diff --git a/src/frontend/components/_molecules/graphical-editor/fbd/fbd-utils/useCopyPaste.ts b/src/frontend/components/_molecules/graphical-editor/fbd/fbd-utils/useCopyPaste.ts index 8235dc9e7..35b4f308f 100644 --- a/src/frontend/components/_molecules/graphical-editor/fbd/fbd-utils/useCopyPaste.ts +++ b/src/frontend/components/_molecules/graphical-editor/fbd/fbd-utils/useCopyPaste.ts @@ -39,7 +39,7 @@ export const useFBDClipboard = ({ }) => { const pouName = useBoundPou() const isActive = useIsGraphicalEditorActive() - const { fbdFlowActions } = useOpenPLCStore() + const fbdFlowActions = useOpenPLCStore((state) => state.fbdFlowActions) // True when the clipboard event originated from a DOM node inside // this FBD instance's viewport. Used to scope **copy** and **cut** diff --git a/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx b/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx index ef3081e46..b923c0fab 100644 --- a/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx +++ b/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx @@ -51,21 +51,18 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } // tab store mutations don't fire effects against the wrong flow. const pouName = useBoundPou() const editor = useBoundEditorModel() - const { - editorActions: { updateModelVariables }, - fbdFlowActions, - libraries, - project, - projectActions: { deleteVariable }, - modals, - modalActions: { closeModal, openModal }, - } = useOpenPLCStore() + const updateModelVariables = useOpenPLCStore((state) => state.editorActions.updateModelVariables) + const fbdFlowActions = useOpenPLCStore((state) => state.fbdFlowActions) + const deleteVariable = useOpenPLCStore((state) => state.projectActions.deleteVariable) + const { closeModal, openModal } = useOpenPLCStore((state) => state.modalActions) + const blockElementModal = useOpenPLCStore((state) => state.modals['block-fbd-element']) + const pous = useOpenPLCStore((state) => state.project.data.pous) + const resourceInstances = useOpenPLCStore((state) => state.project.data.configurations.resource.instances) const isDebuggerVisible = useIsDebuggerVisible() const debugVariableValues = useDebugBoolValuesMap() const debugForcedVariables = useDebugForcedVariablesMap() const { captureAndPush } = usePouSnapshot() - const { pous } = project.data const pouRef = pous.find((pou) => pou.name === pouName) const getCompositeKey = useDebugCompositeKey() const [rungLocal, setRungLocal] = useState(rung) @@ -141,8 +138,7 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } if (!sourceHandle) return undefined if (pouRef?.pouType !== 'function-block') { - const instances = project.data.configurations.resource.instances - const programInstance = instances.find((inst: { program: string }) => inst.program === pouName) + const programInstance = resourceInstances.find((inst: { program: string }) => inst.program === pouName) if (!programInstance) return undefined } @@ -251,7 +247,7 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } debugForcedVariables, pouName, pouRef?.interface?.variables, - project.data.configurations.resource.instances, + resourceInstances, ]) const styledNodes = useMemo(() => { @@ -371,6 +367,7 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } ) => { captureAndPush(pouName) + const { libraries } = useOpenPLCStore.getState() let pouLibrary = undefined if (library) { const [blockLibraryType, blockLibrary, pouName] = library.split('/') @@ -678,16 +675,11 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } handleAddElementByDropping(position, blockType as CustomFbdNodeTypes, library) }, - // `libraries.system`, `libraries.user`, and `pous` aren't read - // directly in onDrop's body — `handleAddElementByDropping` closes - // over all three. Omitting any one means the memoized callback - // keeps a reference to the pre-update handler, so a freshly - // installed system library / freshly created user FB / freshly - // saved POU stays invisible until something else forces a re-bind - // (full project save resets `rung`, which is why "Save Project" - // used to mask this — and why catalog-installed libs threw - // "block type ... does not exist" on first drop). - [rung, reactFlowInstance, libraries.system, libraries.user, pous], + // `handleAddElementByDropping` reads `libraries` via getState() at call + // time (never stale) and `pous` via subscription, so unlike the previous + // whole-store version this callback no longer needs library deps to + // re-bind for freshly installed libraries. + [rung, reactFlowInstance, pous], ) /** @@ -790,11 +782,11 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } }, }} /> - {modals['block-fbd-element']?.open && ( + {blockElementModal?.open && ( } - isOpen={modals['block-fbd-element'].open} + selectedNode={blockElementModal.data as BlockNode} + isOpen={blockElementModal.open} /> )} diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx index 3fc082af2..c0bf5a53a 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx @@ -73,22 +73,18 @@ const EDGE_COLOR_TRUE = '#00FF00' export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActive = false }: RungBodyProps) => { const pouName = useBoundPou() const editor = useBoundEditorModel() - const { - ladderFlowActions, - ladderFlows, - libraries, - editorActions: { updateModelVariables }, - project, - projectActions: { deleteVariable }, - modalActions: { openModal }, - searchQuery, - searchActions: { setSearchNodePosition }, - } = useOpenPLCStore() + const ladderFlowActions = useOpenPLCStore((state) => state.ladderFlowActions) + const updateModelVariables = useOpenPLCStore((state) => state.editorActions.updateModelVariables) + const deleteVariable = useOpenPLCStore((state) => state.projectActions.deleteVariable) + const openModal = useOpenPLCStore((state) => state.modalActions.openModal) + const searchQuery = useOpenPLCStore((state) => state.searchQuery) + const setSearchNodePosition = useOpenPLCStore((state) => state.searchActions.setSearchNodePosition) + const pous = useOpenPLCStore((state) => state.project.data.pous) + const resourceInstances = useOpenPLCStore((state) => state.project.data.configurations.resource.instances) const isDebuggerVisible = useIsDebuggerVisible() const debugVariableValues = useDebugBoolValuesMap() const { captureAndPush } = usePouSnapshot() - const { pous } = project.data const pouRef = pous.find((pou) => pou.name === pouName) const getCompositeKey = useDebugCompositeKey() const nodeTypes = useMemo(() => customNodeTypes, []) @@ -188,8 +184,7 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi if (!sourceHandle) return undefined if (pouRef?.pouType !== 'function-block') { - const instances = project.data.configurations.resource.instances - const programInstance = instances.find((inst) => inst.program === pouName) + const programInstance = resourceInstances.find((inst) => inst.program === pouName) if (!programInstance) return undefined } @@ -274,7 +269,16 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi return edge }) - }, [rungLocal.edges, rungLocal.nodes, isDebuggerVisible, debugVariableValues, pouName, project, getCompositeKey]) + }, [ + rungLocal.edges, + rungLocal.nodes, + isDebuggerVisible, + debugVariableValues, + pouName, + pouRef, + resourceInstances, + getCompositeKey, + ]) const styledNodes = useMemo(() => { const baseNodes = !isDebuggerVisible @@ -352,7 +356,8 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi isDebuggerActive, debugVariableValues, pouName, - project, + pouRef, + resourceInstances, getCompositeKey, ]) @@ -451,6 +456,7 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi * Add a new node to the rung */ const handleAddNode = (newNodeType: string = 'mockNode', blockType: string | undefined) => { + const { libraries, ladderFlows } = useOpenPLCStore.getState() let pouLibrary = undefined if (blockType) { const [blockLibraryType, blockLibrary, pouName] = blockType.split('/') @@ -533,6 +539,7 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi * Remove some nodes from the rung */ const handleRemoveNode = (nodes: FlowNode[]) => { + const { ladderFlows } = useOpenPLCStore.getState() const { nodes: newNodes, edges: newEdges, handleBranches } = removeElements({ ...rungLocal }, nodes) captureAndPush(pouName) @@ -642,6 +649,7 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi * Handle the stop of a node drag */ const handleNodeDragStop = (node: FlowNode) => { + const { ladderFlows } = useOpenPLCStore.getState() const result = onElementDrop(rungLocal, rung, node) captureAndPush(pouName) @@ -859,24 +867,11 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi // Then add the node to the rung handleAddNode(blockType, library) }, - // `libraries.system`, `libraries.user`, and `pous` aren't read - // directly here — `handleAddNode` closes over all three. Omitting - // any of them means the memoized callback keeps a reference to the - // pre-update handler, so a freshly installed system library / - // freshly created user FB / freshly saved POU stays invisible - // until something else forces a re-bind (matches the FBD onDrop - // dep set; same failure mode: catalog-installed libs threw - // "block type ... does not exist" on first drop). - [ - rung, - rungLocal, - setReactFlowPanelExtent, - reactFlowPanelExtent, - isDebuggerActive, - libraries.system, - libraries.user, - pous, - ], + // `handleAddNode` reads `libraries`/`ladderFlows` via getState() at call + // time (never stale) and `pous` via subscription, so unlike the previous + // whole-store version this callback no longer needs library/pou deps to + // re-bind for freshly installed libraries. + [rung, rungLocal, setReactFlowPanelExtent, reactFlowPanelExtent, isDebuggerActive, pous], ) return ( diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/header.tsx b/src/frontend/components/_molecules/graphical-editor/ladder/rung/header.tsx index e43cfe28c..101e978c7 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/header.tsx +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/header.tsx @@ -26,10 +26,8 @@ type RungHeaderProps = { export const RungHeader = ({ rung, isOpen, draggableHandleProps, className, onClick }: RungHeaderProps) => { const editorName = useBoundPou() - const { - ladderFlowActions: { addComment, duplicateRung }, - modalActions: { openModal }, - } = useOpenPLCStore() + const { addComment, duplicateRung } = useOpenPLCStore((state) => state.ladderFlowActions) + const openModal = useOpenPLCStore((state) => state.modalActions.openModal) const containerRef = useRef(null) const textAreaRef = useRef(null) diff --git a/src/frontend/components/_organisms/graphical-editor/ladder/rung/index.tsx b/src/frontend/components/_organisms/graphical-editor/ladder/rung/index.tsx index 05ed0934c..1e1452747 100644 --- a/src/frontend/components/_organisms/graphical-editor/ladder/rung/index.tsx +++ b/src/frontend/components/_organisms/graphical-editor/ladder/rung/index.tsx @@ -22,15 +22,16 @@ type RungProps = { } export const Rung = ({ className, index, id, rung, nodeDivergences, isDebuggerActive }: RungProps) => { - const { - ladderFlows, - editorActions: { updateModelLadder, getIsRungOpen }, - } = useOpenPLCStore() + // Primitive selector: this per-rung component only needs the rung count of + // its own flow (for the rounded-corner styling), so subscribe to just that. + const rungsCount = useOpenPLCStore( + (state) => state.ladderFlows.find((flow) => flow.rungs.some((r) => r.id === rung.id))?.rungs.length ?? 0, + ) + const { updateModelLadder, getIsRungOpen } = useOpenPLCStore((state) => state.editorActions) const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id }) const [isOpen, setIsOpen] = useState(true) - const flow = ladderFlows.find((flow) => flow.rungs.some((r) => r.id === rung.id)) || { rungs: [] } const handleOpenSection = () => { setIsOpen(!isOpen) @@ -69,14 +70,14 @@ export const Rung = ({ className, index, id, rung, nodeDivergences, isDebuggerAc draggableHandleProps={isDebuggerActive ? undefined : listeners} className={cn('border border-transparent', { 'rounded-t-lg': index === 0, - 'rounded-b-lg': index === flow.rungs.length - 1 && !isOpen, + 'rounded-b-lg': index === rungsCount - 1 && !isOpen, })} /> {getIsRungOpen({ rungId: rung.id }) && ( state.snapshotActions) const captureAndPush = useCallback( (pouName: string) => { + const { project, ladderFlows, fbdFlows } = useOpenPLCStore.getState() const pou = project.data.pous.find((p) => p.name === pouName) if (!pou) return @@ -31,7 +30,7 @@ export function usePouSnapshot() { globalVariables: JSON.parse(JSON.stringify(project.data.configurations.resource.globalVariables)), }) }, - [project.data.pous, project.data.configurations.resource.globalVariables, ladderFlows, fbdFlows, pushToHistory], + [pushToHistory], ) return { captureAndPush, undo, redo } From cc34abe43e17bdf7c6eadfc14c964b66da0a7f39 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 16 Jul 2026 10:55:02 -0400 Subject: [PATCH 24/52] fix(debugger): resolve VAR_EXTERNAL globals to one canonical watch An FB/program VAR_EXTERNAL is a reference to a single CONFIGURATION VAR_GLOBAL, but the debugger keyed each reference per-POU/instance. A global watched from a function block therefore showed `-` (no value) and appeared as duplicate rows (e.g. `test_global` + `main.MANUAL_OVERRIDE0.test_global`). Give every external reference one canonical, POU-independent identity (`Config0:`, displayed `Config0.`) across all four paths that treated externals per-POU: - debug tree: surface FB VAR_EXTERNAL members (findFunctionBlockExternalVariables) and resolve them (plus program externals) to the canonical global key; drop the now-redundant external special-case in buildDebugTree. - poller: poll external watches by the canonical key, instance-independent. - watch panel (allDebugVariables): canonical key/display + dedup by key. - store: sync the debug (watch) flag across the global definition and every VAR_EXTERNAL reference so the debug icon toggles everywhere at once. Externals now resolve to the shared global's value and dedup to a single `Config0.` entry regardless of where they are referenced. Validated in-browser (simulator) + web vitest / editor jest (544 each), 100% coverage on gated files. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/frontend/screens/workspace-screen.tsx | 110 ++++++++++-------- .../store/__tests__/project-slice.test.ts | 52 +++++++++ src/frontend/store/slices/project/slice.ts | 35 ++++++ .../__tests__/debug-polling-filter.test.ts | 23 ++++ .../__tests__/debug-tree-builder.test.ts | 6 +- .../__tests__/debug-tree-traversal.test.ts | 28 ++++- .../__tests__/debug-variable-finder.test.ts | 13 +++ .../utils/__tests__/debugger-session.test.ts | 20 ++-- .../utils/__tests__/pou-helpers.test.ts | 49 +++++++- src/frontend/utils/debug-polling-filter.ts | 16 ++- src/frontend/utils/debug-tree-builder.ts | 40 +------ src/frontend/utils/debug-tree-traversal.ts | 29 ++++- src/frontend/utils/debug-variable-finder.ts | 19 +++ src/frontend/utils/pou-helpers.ts | 30 ++++- 14 files changed, 372 insertions(+), 98 deletions(-) diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index ae2fda3fe..158f57d0f 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -55,6 +55,7 @@ import { useRuntimePolling } from '../hooks/use-runtime-polling' import { forceDebugVariable, releaseDebugVariable } from '../services/debug-force-variable' import { useOpenPLCStore } from '../store' import { cn } from '../utils/cn' +import { buildGlobalCompositeKey, GLOBAL_CONFIG_NAME } from '../utils/debug-variable-finder' import { toast } from '../utils/toast' const WorkspaceScreen = () => { @@ -153,59 +154,74 @@ const WorkspaceScreen = () => { useRuntimePolling() // Build debug variables from POUs with debug=true - const allDebugVariables = useMemo( - () => - pous.flatMap((pou) => { - const variables = pou.interface?.variables ?? [] - return variables - .filter((v) => v.debug === true) - .map((v) => { - let typeValue = '' - if (v.type.definition === 'base-type') { - typeValue = v.type.value - } else if (v.type.definition === 'user-data-type') { - typeValue = v.type.value - } else if (v.type.definition === 'array') { - typeValue = v.type.value - } else if (v.type.definition === 'derived') { - typeValue = v.type.value - } - + const allDebugVariables = useMemo(() => { + const rows = pous.flatMap((pou) => { + const variables = pou.interface?.variables ?? [] + return variables + .filter((v) => v.debug === true) + .map((v) => { + let typeValue = '' + if (v.type.definition === 'base-type') { + typeValue = v.type.value + } else if (v.type.definition === 'user-data-type') { + typeValue = v.type.value + } else if (v.type.definition === 'array') { + typeValue = v.type.value + } else if (v.type.definition === 'derived') { + typeValue = v.type.value + } + + let compositeKey: string + let displayName: string + let rowPouName = pou.name + if (v.class === 'external') { + // A VAR_EXTERNAL points at one shared global. Give it the canonical, + // POU/instance-independent identity used by the debug tree + poller so + // it resolves the global's value and every reference collapses to a + // single `Config0.` watch (deduped below). + compositeKey = buildGlobalCompositeKey(v.name) + displayName = `${GLOBAL_CONFIG_NAME}.${v.name}` + rowPouName = GLOBAL_CONFIG_NAME + } else if (pou.pouType === 'function-block') { // For function block POUs, transform the key to use instance context - let compositeKey: string - let displayName: string - if (pou.pouType === 'function-block') { - const fbTypeKey = pou.name.toUpperCase() - const selectedKey = fbSelectedInstance.get(fbTypeKey) - const instances = fbDebugInstances.get(fbTypeKey) ?? [] - const selectedInstance = instances.find((inst) => inst.key === selectedKey) - - if (selectedInstance) { - compositeKey = `${selectedInstance.programName}:${selectedInstance.fbVariableName}.${v.name}` - displayName = `${selectedInstance.programName}.${selectedInstance.fbVariableName}.${v.name}` - } else { - compositeKey = `${pou.name}:${v.name}` - displayName = v.name - } + const fbTypeKey = pou.name.toUpperCase() + const selectedKey = fbSelectedInstance.get(fbTypeKey) + const instances = fbDebugInstances.get(fbTypeKey) ?? [] + const selectedInstance = instances.find((inst) => inst.key === selectedKey) + + if (selectedInstance) { + compositeKey = `${selectedInstance.programName}:${selectedInstance.fbVariableName}.${v.name}` + displayName = `${selectedInstance.programName}.${selectedInstance.fbVariableName}.${v.name}` } else { compositeKey = `${pou.name}:${v.name}` displayName = v.name } + } else { + compositeKey = `${pou.name}:${v.name}` + displayName = v.name + } + + const variableValue = debugBoolValues.get(compositeKey) ?? debugNonBoolValues.get(compositeKey) + const displayValue = variableValue !== undefined ? variableValue : '-' + + return { + pouName: rowPouName, + name: displayName, + type: typeValue, + value: displayValue, + compositeKey, + } + }) + }) - const variableValue = debugBoolValues.get(compositeKey) ?? debugNonBoolValues.get(compositeKey) - const displayValue = variableValue !== undefined ? variableValue : '-' - - return { - pouName: pou.name, - name: displayName, - type: typeValue, - value: displayValue, - compositeKey, - } - }) - }), - [pous, debugBoolValues, debugNonBoolValues, fbSelectedInstance, fbDebugInstances], - ) + // Collapse duplicates by composite key — a global referenced via VAR_EXTERNAL + // from several POUs (and mirrored by the debug-flag sync) yields one row. + const byKey = new Map() + for (const row of rows) { + if (!byKey.has(row.compositeKey)) byKey.set(row.compositeKey, row) + } + return Array.from(byKey.values()) + }, [pous, debugBoolValues, debugNonBoolValues, fbSelectedInstance, fbDebugInstances]) // Deduplicate names with POU prefix when conflicts exist const debugVariables = useMemo(() => { diff --git a/src/frontend/store/__tests__/project-slice.test.ts b/src/frontend/store/__tests__/project-slice.test.ts index be7f4e262..045a13ee2 100644 --- a/src/frontend/store/__tests__/project-slice.test.ts +++ b/src/frontend/store/__tests__/project-slice.test.ts @@ -842,6 +842,58 @@ describe('createProjectSlice', () => { expect(result.ok).toBe(false) }) + it('syncs a VAR_EXTERNAL debug toggle to the global and every other reference', () => { + // One global, referenced via VAR_EXTERNAL from a program and an FB. + seedGlobals(store, [makeVariable('test_global', 'global')]) + seedPou(store, makePou('Main', 'program', [makeVariable('test_global', 'external')])) + seedPou(store, makePou('Mover', 'function-block', [makeVariable('test_global', 'external')])) + + store.getState().projectActions.updateVariable({ + scope: 'local', + associatedPou: 'Main', + variableId: 'test_global', + data: { debug: true }, + }) + + const st = store.getState().project + expect(st.data.configurations.resource.globalVariables[0].debug).toBe(true) + for (const pou of st.data.pous) { + expect(pou.interface?.variables[0].debug).toBe(true) + } + }) + + it('syncs a global debug toggle down to every VAR_EXTERNAL reference (case-insensitive)', () => { + seedGlobals(store, [makeVariable('Test_Global', 'global')]) + seedPou(store, makePou('Main', 'program', [makeVariable('TEST_GLOBAL', 'external')])) + + store.getState().projectActions.updateVariable({ + scope: 'global', + variableId: 'Test_Global', + data: { debug: true }, + }) + + const st = store.getState().project + expect(st.data.configurations.resource.globalVariables[0].debug).toBe(true) + expect(st.data.pous[0].interface?.variables[0].debug).toBe(true) + }) + + it('does not propagate a local (non-external) debug toggle to a same-named global', () => { + seedGlobals(store, [makeVariable('x', 'global')]) + seedPou(store, makePou('Main', 'program', [makeVariable('x', 'local')])) + + store.getState().projectActions.updateVariable({ + scope: 'local', + associatedPou: 'Main', + variableId: 'x', + data: { debug: true }, + }) + + const st = store.getState().project + expect(st.data.pous[0].interface?.variables[0].debug).toBe(true) + // The unrelated same-named global must stay untouched. + expect(st.data.configurations.resource.globalVariables[0].debug).toBeFalsy() + }) + it('stores the location binding verbatim (alias name or literal) and never auto-adopts', () => { // Single-field model: `location` is the binding. A manual literal is // stored verbatim; an alias name is stored verbatim (NOT auto-resolved diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 67c1949aa..a6c93f670 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -369,6 +369,31 @@ function applyVppEntries( }) } +/** + * Mirror a shared global's debug (watch) flag across every reference to it. + * + * A VAR_EXTERNAL is a pointer to one CONFIGURATION VAR_GLOBAL, so "watch this + * global in the debugger" is a property of the global, not of any single + * reference. Toggling the debug icon on the global's own definition or on any + * POU's VAR_EXTERNAL therefore updates them all in lockstep — so every table + * shows the same icon state and the global polls/displays as one entity + * (matching the canonical Config0: key used by the debug tree and poller). + * + * Mutates the produce draft in place. Case-insensitive on the global name, + * consistent with global-variable lookup elsewhere in this slice. + */ +function syncGlobalDebugFlag(slice: ProjectSlice, globalName: string, debug: boolean): void { + const lower = globalName.toLowerCase() + for (const g of slice.project.data.configurations.resource.globalVariables) { + if (g.name.toLowerCase() === lower) g.debug = debug + } + for (const pou of slice.project.data.pous) { + for (const v of pou.interface?.variables ?? []) { + if (v.class === 'external' && v.name.toLowerCase() === lower) v.debug = debug + } + } +} + const reconcileVariablesText = ( pouName: string | undefined, getState: ProjectGetState, @@ -772,6 +797,16 @@ const createProjectSlice: StateCreator = ...(validationResponse.data ? validationResponse.data : {}), } response.data = variables[found.index] + + // A shared global's watch flag belongs to the global, not to any one + // reference — keep the global's definition and every VAR_EXTERNAL to it + // in sync so the debug icon toggles everywhere at once. + if (updates.debug !== undefined) { + const target = variables[found.index] + if (scope === 'global' || target.class === 'external') { + syncGlobalDebugFlag(slice, target.name, updates.debug) + } + } }), ) if (scope === 'local' && response.ok) regenerateVariablesText(associatedPou, getState) diff --git a/src/frontend/utils/__tests__/debug-polling-filter.test.ts b/src/frontend/utils/__tests__/debug-polling-filter.test.ts index bf3e58c99..8e7d1eedb 100644 --- a/src/frontend/utils/__tests__/debug-polling-filter.test.ts +++ b/src/frontend/utils/__tests__/debug-polling-filter.test.ts @@ -202,6 +202,29 @@ describe('buildActiveIndexSet', () => { const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) }) + + it('polls a program VAR_EXTERNAL watch by its canonical global key', () => { + const pou = makePou('Main', 'program', [makeVariable('SHARED', 'external', true)]) + // The debug tree exposes the shared global under Config0:SHARED. + const allLeaves = new Map([[42, [{ compositeKey: 'Config0:SHARED', type: 'INT' }]]]) + const state = makeState({ pous: [pou] }) + + const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) + expect(activeIndexes).toEqual([42]) + }) + + it('polls an FB VAR_EXTERNAL watch even with no instance selected (globals are instance-independent)', () => { + const fbPou = makePou('MyFB', 'function-block', [ + makeVariable('SHARED', 'external', true), + makeVariable('OUT', 'output', true), + ]) + const allLeaves = new Map([[7, [{ compositeKey: 'Config0:SHARED', type: 'INT' }]]]) + const state = makeState({ pous: [fbPou] }) + + // OUT (local) is skipped for want of an instance; the external still polls. + const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) + expect(activeIndexes).toEqual([7]) + }) }) describe('forced variables', () => { diff --git a/src/frontend/utils/__tests__/debug-tree-builder.test.ts b/src/frontend/utils/__tests__/debug-tree-builder.test.ts index f2ba59f0b..e24edd52b 100644 --- a/src/frontend/utils/__tests__/debug-tree-builder.test.ts +++ b/src/frontend/utils/__tests__/debug-tree-builder.test.ts @@ -121,15 +121,17 @@ describe('buildDebugTree', () => { }) describe('external variables', () => { - it('builds a leaf node for an external base-type variable using prefix', () => { + it('builds a leaf node for an external base-type variable with a canonical global key', () => { const variable = makeBaseVariable('GLOBAL_FLAG', 'BOOL', 'external') const debugVars = [makeDebugVar('GLOBAL_FLAG', 'BOOL_ENUM', 5)] const projectData = { dataTypes: [], pous: [] } const node = buildDebugTree(variable, 'Main', INSTANCE_NAME, debugVars, projectData, SYSTEM_LIBS) + // fullPath resolves to the shared global; compositeKey is POU-independent + // (Config0:*) so the same global watched from any POU dedups to one entry. expect(node.fullPath).toBe('GLOBAL_FLAG') - expect(node.compositeKey).toBe('Main:GLOBAL_FLAG') + expect(node.compositeKey).toBe('Config0:GLOBAL_FLAG') expect(node.debugIndex).toBe(5) expect(node.isComplex).toBe(false) }) diff --git a/src/frontend/utils/__tests__/debug-tree-traversal.test.ts b/src/frontend/utils/__tests__/debug-tree-traversal.test.ts index 4a63d00b9..37c86601a 100644 --- a/src/frontend/utils/__tests__/debug-tree-traversal.test.ts +++ b/src/frontend/utils/__tests__/debug-tree-traversal.test.ts @@ -156,7 +156,7 @@ describe('traverseVariable', () => { }) describe('external variables', () => { - it('uses prefix for external base-type variables', () => { + it('resolves external base-type variables to the global path + canonical key', () => { const variable = makeBaseVariable('GLOBAL_FLAG', 'BOOL', 'external') const debugVars = [makeDebugVar('GLOBAL_FLAG', 'BOOL_ENUM', 5)] const ctx = makeContext({ debugVariables: debugVars }) @@ -165,6 +165,32 @@ describe('traverseVariable', () => { expect(result.fullPath).toBe('GLOBAL_FLAG') expect(result.debugIndex).toBe(5) + // POU-independent key so every reference dedups to one watch. + expect(result.compositeKey).toBe('Config0:GLOBAL_FLAG') + }) + + it('surfaces a function block VAR_EXTERNAL as a canonical global child', () => { + // A user FB that references a global via VAR_EXTERNAL. The global lives at + // its bare name; the FB member must resolve there (not to an instance + // path) so it shows the shared value and dedups across every reference. + const customFb = makePou('MyFb', 'function-block', [ + makeBaseVariable('LOCAL_X', 'INT', 'local'), + makeBaseVariable('SHARED', 'INT', 'external'), + ]) + const variable = makeDerivedVariable('inst', 'MyFb') + const debugVars = [makeDebugVar('INSTANCE0.INST.LOCAL_X', 'INT_ENUM', 3), makeDebugVar('SHARED', 'INT_ENUM', 42)] + const ctx = makeContext({ debugVariables: debugVars, projectPous: [customFb] }) + + const result = traverseVariable(variable, ctx, simpleVisitor) + + const shared = result.children!.find((c) => c.name === 'SHARED') + expect(shared).toBeDefined() + expect(shared!.fullPath).toBe('SHARED') + expect(shared!.compositeKey).toBe('Config0:SHARED') + expect(shared!.debugIndex).toBe(42) + // Local member keeps its instance-scoped path. + const local = result.children!.find((c) => c.name === 'LOCAL_X') + expect(local!.compositeKey).toBe('Main:inst.LOCAL_X') }) }) diff --git a/src/frontend/utils/__tests__/debug-variable-finder.test.ts b/src/frontend/utils/__tests__/debug-variable-finder.test.ts index c521c6c6c..73ad91803 100644 --- a/src/frontend/utils/__tests__/debug-variable-finder.test.ts +++ b/src/frontend/utils/__tests__/debug-variable-finder.test.ts @@ -3,7 +3,9 @@ import { appendToDebugPath, buildDebugPath, buildDebugPathPrefix, + buildGlobalCompositeKey, buildGlobalDebugPath, + GLOBAL_CONFIG_NAME, findDebugVariable, findDebugVariableForField, findDebugVariableWithFallback, @@ -89,6 +91,17 @@ describe('buildDebugPath', () => { }) }) +describe('buildGlobalCompositeKey', () => { + it('qualifies the global with the config name, preserving declared case', () => { + expect(buildGlobalCompositeKey('test_global')).toBe(`${GLOBAL_CONFIG_NAME}:test_global`) + }) + + it('produces the same key regardless of the referencing POU (dedup)', () => { + // Every reference to a global maps here, so all references collapse to one key. + expect(buildGlobalCompositeKey('MY_GLOBAL')).toBe(buildGlobalCompositeKey('MY_GLOBAL')) + }) +}) + describe('buildGlobalDebugPath', () => { it('returns uppercased name', () => { expect(buildGlobalDebugPath('MY_GLOBAL')).toBe('MY_GLOBAL') diff --git a/src/frontend/utils/__tests__/debugger-session.test.ts b/src/frontend/utils/__tests__/debugger-session.test.ts index 2463f564f..bd88262c2 100644 --- a/src/frontend/utils/__tests__/debugger-session.test.ts +++ b/src/frontend/utils/__tests__/debugger-session.test.ts @@ -315,16 +315,20 @@ describe('deriveVariableIndexMap', () => { const { indexMap } = derive([pou], instances, map) - expect(indexMap.get('Main:START_PB')).toBe(addr(0, 0)) + // Externals resolve under the canonical, POU-independent global key. + expect(indexMap.get('Config0:START_PB')).toBe(addr(0, 0)) expect(indexMap.get('Main:LOCAL_X')).toBe(addr(0, 1)) - // The instance-prefixed path must NOT resolve the global. + // Neither the instance-prefixed nor the program-scoped path resolves the + // global — every reference dedups to the one canonical key. expect(indexMap.has('INSTANCE0.START_PB')).toBe(false) + expect(indexMap.has('Main:START_PB')).toBe(false) }) - it('maps a global shared by two programs to the same index under both keys', () => { + it('maps a global shared by two programs to one canonical global key', () => { // The regression this whole refactor targets: a VAR_GLOBAL referenced (as - // VAR_EXTERNAL) by two programs must resolve to ONE address under BOTH - // composite keys, so force + value display work on every referencing POU. + // VAR_EXTERNAL) by two programs must resolve to ONE address so force + value + // display work on every referencing POU. Both references now collapse onto + // the single canonical `Config0:*` key. const main = makePou('Main', 'program', [makeBaseVariable('START_PB', 'BOOL', 'external')]) const another = makePou('Another', 'program', [makeBaseVariable('START_PB', 'BOOL', 'external')]) const instances = [makeInstance('INSTANCE0', 'Main'), makeInstance('INSTANCE1', 'Another')] @@ -332,8 +336,10 @@ describe('deriveVariableIndexMap', () => { const { indexMap } = derive([main, another], instances, map) - expect(indexMap.get('Main:START_PB')).toBe(addr(0, 0)) - expect(indexMap.get('Another:START_PB')).toBe(addr(0, 0)) + expect(indexMap.get('Config0:START_PB')).toBe(addr(0, 0)) + // Per-program keys no longer exist — that collapse IS the dedup. + expect(indexMap.has('Main:START_PB')).toBe(false) + expect(indexMap.has('Another:START_PB')).toBe(false) }) it('falls back to raw debug path for unmatched leaves (nested fields)', () => { diff --git a/src/frontend/utils/__tests__/pou-helpers.test.ts b/src/frontend/utils/__tests__/pou-helpers.test.ts index 5b7a879b0..a33829b8b 100644 --- a/src/frontend/utils/__tests__/pou-helpers.test.ts +++ b/src/frontend/utils/__tests__/pou-helpers.test.ts @@ -1,6 +1,7 @@ -import type { PLCDataType, PLCPou } from '../../../middleware/shared/ports/types' +import type { PLCDataType, PLCPou, PLCVariable } from '../../../middleware/shared/ports/types' import { openPLCStoreBase } from '../../store' import { + findFunctionBlockExternalVariables, findFunctionBlockVariables, findLeafVariables, findStructureVariables, @@ -205,6 +206,52 @@ describe('findFunctionBlockVariables', () => { }) }) +// --------------------------------------------------------------------------- +// findFunctionBlockExternalVariables +// --------------------------------------------------------------------------- + +describe('findFunctionBlockExternalVariables', () => { + const makeVar = (name: string, cls: PLCVariable['class']): PLCVariable => ({ + name, + class: cls, + type: { definition: 'base-type', value: 'INT' }, + location: '', + documentation: '', + }) + + const fbWith = (vars: PLCVariable[]): PLCPou => ({ + name: 'MyFB', + pouType: 'function-block', + interface: { variables: vars }, + body: { language: 'st', value: '' }, + }) + + it('returns only the VAR_EXTERNAL members of a user FB', () => { + const fb = fbWith([ + makeVar('IN', 'input'), + makeVar('G1', 'external'), + makeVar('S', 'local'), + makeVar('G2', 'external'), + ]) + const names = findFunctionBlockExternalVariables('MyFB', [fb]).map((v) => v.name) + expect(names).toEqual(['G1', 'G2']) + }) + + it('returns [] for a user FB with no externals', () => { + const fb = fbWith([makeVar('IN', 'input'), makeVar('S', 'local')]) + expect(findFunctionBlockExternalVariables('MyFB', [fb])).toEqual([]) + }) + + it('returns [] for an unknown FB type', () => { + expect(findFunctionBlockExternalVariables('NoSuchFB', [])).toEqual([]) + }) + + it('returns [] for a library FB (black box — not searched in project POUs)', () => { + // SR is a system-library FB; its externals are not project-visible. + expect(findFunctionBlockExternalVariables('SR', [])).toEqual([]) + }) +}) + // --------------------------------------------------------------------------- // isFunctionBlockType // --------------------------------------------------------------------------- diff --git a/src/frontend/utils/debug-polling-filter.ts b/src/frontend/utils/debug-polling-filter.ts index 71ccdaaa0..7192cc4fe 100644 --- a/src/frontend/utils/debug-polling-filter.ts +++ b/src/frontend/utils/debug-polling-filter.ts @@ -17,6 +17,7 @@ */ import type { FbInstanceInfo, PLCPou } from '../../middleware/shared/ports/types' +import { buildGlobalCompositeKey } from './debug-variable-finder' /** * Minimal state shape required by the debug polling filter. @@ -93,14 +94,25 @@ export function buildActiveIndexSet( const watched = variables.filter((v) => v.debug === true) if (watched.length === 0) continue + // VAR_EXTERNAL watches resolve to a shared global by its canonical, + // POU-independent key (Config0:) — matching the debug tree — so a + // global watched from a program body, a function block, or any nesting polls + // the one global address regardless of POU kind or selected FB instance. + for (const v of watched) { + if (v.class === 'external') activeKeys.add(buildGlobalCompositeKey(v.name)) + } + + const localWatched = watched.filter((v) => v.class !== 'external') + if (localWatched.length === 0) continue + if (pou.pouType === 'function-block') { const resolved = resolveFbInstance(pou.name, fbSelectedInstance, fbDebugInstances) if (!resolved) continue - for (const v of watched) { + for (const v of localWatched) { activeKeys.add(`${resolved.programName}:${resolved.fbVariableName}.${v.name}`) } } else { - for (const v of watched) { + for (const v of localWatched) { activeKeys.add(`${pou.name}:${v.name}`) } } diff --git a/src/frontend/utils/debug-tree-builder.ts b/src/frontend/utils/debug-tree-builder.ts index 67be33a35..3b80180c3 100644 --- a/src/frontend/utils/debug-tree-builder.ts +++ b/src/frontend/utils/debug-tree-builder.ts @@ -10,7 +10,7 @@ import type { DebugTreeNode, PLCPou, PLCVariable } from '../../middleware/shared import type { DebugVariableEntry } from './debug-parser' import type { DebugNodeVisitor, TraversalContext } from './debug-tree-traversal' import { traverseVariable } from './debug-tree-traversal' -import { buildGlobalDebugPath, buildVariableDebugPath } from './debug-variable-finder' +import { buildVariableDebugPath } from './debug-variable-finder' /** * Project data shape expected by the debug tree builder. @@ -135,39 +135,11 @@ export function buildDebugTree( projectData: DebugProjectData, systemLibraries: SystemLibrary[], ): DebugTreeNode { - // Handle external variables specially - they use global path - // For external variables, we need to adjust the traversal - if (variable.class === 'external') { - // External variables use CONFIG0__ prefix instead of RES0__INSTANCE - // Create a modified variable traversal for external variables - const fullPath = buildGlobalDebugPath(variable.name) - const compositeKey = `${pouName}:${variable.name}` - - if (variable.type.definition === 'base-type') { - const debugVar = debugVariables.find((dv) => dv.name === fullPath) - const node: DebugTreeNode = { - name: variable.name, - fullPath, - compositeKey, - type: variable.type.value.toUpperCase(), - isComplex: false, - debugIndex: debugVar?.index, - } - - /* istanbul ignore next -- dev-only: guarded by DEBUG_TREE_LOGGING constant */ - if (DEBUG_TREE_LOGGING) { - console.groupCollapsed(`Debug Tree for ${variable.name} (external)`) - logDebugTree(node) - console.groupEnd() - } - - return node - } - - // For complex external variables, use the standard traversal - // but we need to handle this specially since external vars use CONFIG0__ prefix - // The shared traversal handles external class automatically - } + // External (VAR_EXTERNAL) variables are handled uniformly by the shared + // traversal: it resolves the `external` class to the global address + // (buildVariableDebugPath) and the canonical `Config0:` composite key, + // so a global watched from a program body, a function block, or any nesting + // dedups to one entry. No special-casing needed here. // Create traversal context const context: TraversalContext = { diff --git a/src/frontend/utils/debug-tree-traversal.ts b/src/frontend/utils/debug-tree-traversal.ts index 3b26d5f1f..6014b07bc 100644 --- a/src/frontend/utils/debug-tree-traversal.ts +++ b/src/frontend/utils/debug-tree-traversal.ts @@ -9,8 +9,18 @@ import type { SystemLibrary } from '../../middleware/shared/ports/library-types' import type { PLCDataType, PLCPou, PLCVariable } from '../../middleware/shared/ports/types' import type { DebugVariableEntry } from './debug-parser' -import { buildVariableDebugPath, findDebugVariable, findDebugVariableForField } from './debug-variable-finder' -import { findFunctionBlockVariables, findStructureVariables, normalizeTypeString } from './pou-helpers' +import { + buildGlobalCompositeKey, + buildVariableDebugPath, + findDebugVariable, + findDebugVariableForField, +} from './debug-variable-finder' +import { + findFunctionBlockExternalVariables, + findFunctionBlockVariables, + findStructureVariables, + normalizeTypeString, +} from './pou-helpers' /** * Pick the right type name for a leaf. The debug-map records the IEC base @@ -247,6 +257,16 @@ function traverseNestedNode( } } + // VAR_EXTERNAL members reference config-scoped globals, not instance state, + // so findFunctionBlockVariables omits them. Surface them here so a global can + // be watched from inside any FB: traverseVariable resolves the `external` + // class to the shared global's address and the canonical `Config0:` + // key, so this node dedups with the same global watched from the program body + // or any other FB — regardless of how deep this instance is nested. + for (const ext of findFunctionBlockExternalVariables(typeName, projectPous)) { + children.push(traverseVariable(ext, context, visitor)) + } + return visitor.visitComplex(name, fullPath, compositeKey, typeName, children) } else if (typeDefinition === 'user-data-type') { // Structure type — STruC++ emits struct fields as `PARENT.FIELD` @@ -398,7 +418,10 @@ function traverseNestedNode( */ export function traverseVariable(variable: PLCVariable, context: TraversalContext, visitor: DebugNodeVisitor): T { const { debugVariables, projectPous, pouName, instanceName, systemLibraries } = context - const compositeKey = `${pouName}:${variable.name}` + // A VAR_EXTERNAL reference gets a canonical, POU-independent key so every + // reference to the same global dedups to one watch displayed as `Config0.`. + const compositeKey = + variable.class === 'external' ? buildGlobalCompositeKey(variable.name) : `${pouName}:${variable.name}` // Build the base path (single rule — see buildVariableDebugPath) const fullPath = buildVariableDebugPath(variable.class === 'external', instanceName, variable.name) diff --git a/src/frontend/utils/debug-variable-finder.ts b/src/frontend/utils/debug-variable-finder.ts index 1228fb326..70a319226 100644 --- a/src/frontend/utils/debug-variable-finder.ts +++ b/src/frontend/utils/debug-variable-finder.ts @@ -94,6 +94,25 @@ export function buildGlobalDebugPath(variablePath: string): string { return variablePath.toUpperCase() } +/** + * The configuration name STruC++ emits for the generated CONFIGURATION/RESOURCE + * (hardcoded in parse-resource-configuration-to-string.ts). Used only to label + * global (VAR_EXTERNAL) watches in the debugger so they read as `Config0.`. + */ +export const GLOBAL_CONFIG_NAME = 'Config0' + +/** + * Canonical composite key for a global referenced through VAR_EXTERNAL. It is + * deliberately independent of the referencing POU or FB instance, so every + * reference to the same global — a program body, any function block, any depth + * of nesting — dedups to a single debugger watch that displays as + * `Config0.`. The value still resolves via buildGlobalDebugPath (the bare + * uppercase name in debug-map.json); this only governs identity/display. + */ +export function buildGlobalCompositeKey(variableName: string): string { + return `${GLOBAL_CONFIG_NAME}:${variableName}` +} + /** * The single path rule for a program variable: a shared global (VAR_EXTERNAL → * CONFIGURATION VAR_GLOBAL) addresses by its bare name; everything else is diff --git a/src/frontend/utils/pou-helpers.ts b/src/frontend/utils/pou-helpers.ts index c1969088f..9e2aebe29 100644 --- a/src/frontend/utils/pou-helpers.ts +++ b/src/frontend/utils/pou-helpers.ts @@ -4,7 +4,7 @@ */ import type { SystemLibrary } from '../../middleware/shared/ports/library-types' -import type { PLCDataType, PLCPou } from '../../middleware/shared/ports/types' +import type { PLCDataType, PLCPou, PLCVariable } from '../../middleware/shared/ports/types' /** * Variable definition from a POU or library FB. @@ -135,6 +135,34 @@ export const findFunctionBlockVariables = ( return null } +/** + * Return the VAR_EXTERNAL members of a user-defined function block. + * + * findFunctionBlockVariables deliberately drops externals (they point at + * config-scoped globals, not instance state, and must not appear in the OPC-UA + * picker as instance members). The debugger, however, wants to surface them so + * a global can be watched from inside any FB — resolved to the shared global's + * address, not a per-instance copy. This companion returns exactly those + * members so the debug tree can emit canonical global nodes for them. + * + * Library FBs are black boxes (their externals aren't in debug-map.json), so + * only project POUs are searched; returns [] when the FB is a library type, + * unknown, or declares no externals. + * + * Returns full PLCVariable objects (not the narrowed PouVariable view) so the + * debug traversal can hand them straight to traverseVariable. + */ +export const findFunctionBlockExternalVariables = (typeName: string, projectPous: PLCPou[]): PLCVariable[] => { + const typeNameUpper = typeName.toUpperCase() + const customFB = projectPous.find( + (pou) => normalizeTypeString(pou.pouType) === 'functionblock' && pou.name.toUpperCase() === typeNameUpper, + ) + if (customFB && customFB.pouType === 'function-block') { + return (customFB.interface?.variables ?? []).filter((v) => v.class === 'external') + } + return [] +} + /** * Check if a type name is a function block (library or project). */ From 431c7bf116e4f4120ffe58a3848aa8da64882e96 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 16 Jul 2026 11:37:09 -0400 Subject: [PATCH 25/52] chore(release): bump strucpp to v0.6.0 Co-Authored-By: Claude Opus 4.8 (1M context) --- binary-versions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/binary-versions.json b/binary-versions.json index 6dd46a282..3991c80f2 100644 --- a/binary-versions.json +++ b/binary-versions.json @@ -4,7 +4,7 @@ "repository": "Autonomy-Logic/xml2st" }, "strucpp": { - "version": "v0.5.14", + "version": "v0.6.0", "repository": "Autonomy-Logic/STruCpp" } } From 678c4142a86082dcbacb32519084e3c41a885897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Thu, 16 Jul 2026 15:29:31 -0300 Subject: [PATCH 26/52] perf(graphical-editor): memoize node components and stabilize ReactFlow props (DOPE-488) React.memo on all custom LD/FBD node components and the Rung organism; identity-stable ReactFlow props (hoisted option objects, stable-callback handlers, ReactFlowPanel deleteKeyCode/children); memoized ladder library-divergence scan and graphical-editor context value. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N6gbZbAZWewQuuQLfZZL7i --- .../_atoms/graphical-editor/fbd/block.tsx | 9 +- .../graphical-editor/fbd/connection.tsx | 6 +- .../_atoms/graphical-editor/fbd/variable.tsx | 6 +- .../_atoms/graphical-editor/ladder/block.tsx | 9 +- .../_atoms/graphical-editor/ladder/coil.tsx | 8 +- .../graphical-editor/ladder/contact.tsx | 8 +- .../graphical-editor/ladder/mock-node.tsx | 8 +- .../graphical-editor/ladder/parallel.tsx | 8 +- .../graphical-editor/ladder/placeholder.tsx | 8 +- .../graphical-editor/ladder/power-rail.tsx | 8 +- .../graphical-editor/ladder/variable.tsx | 6 +- .../components/_atoms/react-flow/index.tsx | 35 +- .../editor/graphical/FBD/index.tsx | 6 +- .../editor/graphical/active-context.tsx | 5 +- .../editor/graphical/ladder/index.tsx | 119 +++--- .../_molecules/graphical-editor/fbd/index.tsx | 295 +++++++-------- .../graphical-editor/ladder/rung/body.tsx | 346 +++++++++--------- .../graphical-editor/ladder/rung/index.tsx | 8 +- src/frontend/hooks/use-stable-callback.ts | 18 + 19 files changed, 480 insertions(+), 436 deletions(-) create mode 100644 src/frontend/hooks/use-stable-callback.ts diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx index b662b4ad7..57475b02c 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx @@ -1,4 +1,4 @@ -import { FocusEvent, useEffect, useMemo, useRef, useState } from 'react' +import { FocusEvent, memo, useEffect, useMemo, useRef, useState } from 'react' import { v4 as uuidv4 } from 'uuid' import type { PLCVariable } from '../../../../../middleware/shared/ports/types' @@ -327,7 +327,7 @@ export const BlockNodeElement = ({ ) } -export const Block = (block: BlockProps) => { +const Block = (block: BlockProps) => { const { data, dragging, height, width, selected, id } = block const pouName = useBoundPou() const pous = useOpenPLCStore((state) => state.project.data.pous) @@ -816,3 +816,8 @@ export const Block = (block: BlockProps) => { ) } + +// Cast keeps the generic call signature `memo` would otherwise widen away. +const exportBlock = memo(Block) as typeof Block + +export { exportBlock as Block } diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/connection.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/connection.tsx index cebc84e18..cb6943e85 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/connection.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/connection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import { useOpenPLCStore } from '../../../../store' import { cn } from '../../../../utils/cn' @@ -251,4 +251,6 @@ const ConnectionElement = (block: ConnectionProps) => { ) } -export { ConnectionElement } +const exportConnectionElement = memo(ConnectionElement) + +export { exportConnectionElement as ConnectionElement } diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx index 6e88a9704..3f1a02777 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx @@ -1,5 +1,5 @@ import * as Popover from '@radix-ui/react-popover' -import { useEffect, useMemo, useRef, useState } from 'react' +import { memo, useEffect, useMemo, useRef, useState } from 'react' import { PLCVariable } from '../../../../../middleware/shared/ports/types' import { useDebugger } from '../../../../../middleware/shared/providers' @@ -696,4 +696,6 @@ const VariableElement = (block: VariableProps) => { ) } -export { VariableElement } +const exportVariableElement = memo(VariableElement) + +export { exportVariableElement as VariableElement } diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx index fca8f8ac1..807d02436 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx @@ -1,4 +1,4 @@ -import { FocusEvent, useEffect, useMemo, useRef, useState } from 'react' +import { FocusEvent, memo, useEffect, useMemo, useRef, useState } from 'react' import { v4 as uuidv4 } from 'uuid' import type { PLCVariable } from '../../../../../middleware/shared/ports' @@ -396,7 +396,7 @@ export const BlockNodeElement = ({ ) } -export const Block = (block: BlockProps) => { +const Block = (block: BlockProps) => { const { data, dragging, height, width, selected, id } = block const pouName = useBoundPou() @@ -944,3 +944,8 @@ export const Block = (block: BlockProps) => { ) } + +// Cast keeps the generic call signature `memo` would otherwise widen away. +const exportBlock = memo(Block) as typeof Block + +export { exportBlock as Block } diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx index ead8ec18f..8bfd18e48 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx @@ -1,5 +1,5 @@ import * as Popover from '@radix-ui/react-popover' -import { useEffect, useRef, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import { useDebugger } from '../../../../../middleware/shared/providers' import { useDebugCompositeKey } from '../../../../hooks/use-debug-composite-key' @@ -18,7 +18,7 @@ import type { CoilProps } from './utils/types' export type { CoilNode } from './utils/types' -export const Coil = (block: CoilProps) => { +const Coil = (block: CoilProps) => { const { selected, data, id } = block const pouName = useBoundPou() const pous = useOpenPLCStore((state) => state.project.data.pous) @@ -332,3 +332,7 @@ export const Coil = (block: CoilProps) => { ) } + +const exportCoil = memo(Coil) + +export { exportCoil as Coil } diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx index 84bbf1e32..dcc5c3df8 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx @@ -1,5 +1,5 @@ import * as Popover from '@radix-ui/react-popover' -import { useEffect, useRef, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import { useDebugger } from '../../../../../middleware/shared/providers' import { useDebugCompositeKey } from '../../../../hooks/use-debug-composite-key' @@ -18,7 +18,7 @@ import type { ContactProps } from './utils/types' export type { ContactNode } from './utils/types' -export const Contact = (block: ContactProps) => { +const Contact = (block: ContactProps) => { const { selected, data, id } = block const pouName = useBoundPou() const pous = useOpenPLCStore((state) => state.project.data.pous) @@ -333,3 +333,7 @@ export const Contact = (block: ContactProps) => { ) } + +const exportContact = memo(Contact) + +export { exportContact as Contact } diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/mock-node.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/mock-node.tsx index efd2cb708..c976bfb14 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/mock-node.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/mock-node.tsx @@ -1,7 +1,9 @@ +import { memo } from 'react' + import { CustomHandle } from './handle' import { MockNodeProps } from './utils/types' -export const MockNode = ({ data }: MockNodeProps) => { +const MockNode = ({ data }: MockNodeProps) => { return ( <>
@@ -13,3 +15,7 @@ export const MockNode = ({ data }: MockNodeProps) => { ) } + +const exportMockNode = memo(MockNode) + +export { exportMockNode as MockNode } diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/parallel.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/parallel.tsx index ec52da42d..2bd87fc3d 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/parallel.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/parallel.tsx @@ -1,9 +1,11 @@ +import { memo } from 'react' + import { cn } from '../../../../utils/cn' import { CustomHandle } from './handle' import { DEFAULT_PARALLEL_HEIGHT, DEFAULT_PARALLEL_WIDTH } from './utils/constants' import type { ParallelProps } from './utils/types' -export const Parallel = ({ selected, data }: ParallelProps) => { +const Parallel = ({ selected, data }: ParallelProps) => { return ( <>
{ ) } + +const exportParallel = memo(Parallel) + +export { exportParallel as Parallel } diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/placeholder.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/placeholder.tsx index 06c787b3d..d11323a2c 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/placeholder.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/placeholder.tsx @@ -1,10 +1,12 @@ +import { memo } from 'react' + import { PlaceholderNodeFilled } from '../../../../assets/icons/flow/Placeholder' import { cn } from '../../../../utils/cn' import { CustomHandle } from './handle' import { DEFAULT_PLACEHOLDER_HEIGHT, DEFAULT_PLACEHOLDER_WIDTH } from './utils/constants' import { PlaceholderProps } from './utils/types' -export const Placeholder = ({ selected, data }: PlaceholderProps) => { +const Placeholder = ({ selected, data }: PlaceholderProps) => { return ( <> { ) } + +const exportPlaceholder = memo(Placeholder) + +export { exportPlaceholder as Placeholder } diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/power-rail.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/power-rail.tsx index 52edab3a8..5a604a1c7 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/power-rail.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/power-rail.tsx @@ -1,11 +1,11 @@ import { useUpdateNodeInternals } from '@xyflow/react' -import { useEffect, useMemo } from 'react' +import { memo, useEffect, useMemo } from 'react' import { CustomHandle } from './handle' import { DEFAULT_POWER_RAIL_HEIGHT, DEFAULT_POWER_RAIL_WIDTH } from './utils/constants' import { PowerRailProps } from './utils/types' -export const PowerRail = ({ id, data }: PowerRailProps) => { +const PowerRail = ({ id, data }: PowerRailProps) => { const updateNodeInternals = useUpdateNodeInternals() // Calculate dynamic height to cover all handles (including branch handles) @@ -43,3 +43,7 @@ export const PowerRail = ({ id, data }: PowerRailProps) => { ) } + +const exportPowerRail = memo(PowerRail) + +export { exportPowerRail as PowerRail } diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx index 6b6bd03bf..9cbaf03a7 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx @@ -1,5 +1,5 @@ import * as Popover from '@radix-ui/react-popover' -import { useEffect, useRef, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import { PLCVariable } from '../../../../../middleware/shared/ports' import { useDebugger } from '../../../../../middleware/shared/providers' @@ -551,4 +551,6 @@ const VariableElement = (block: VariableProps) => { ) } -export { VariableElement } +const exportVariableElement = memo(VariableElement) + +export { exportVariableElement as VariableElement } diff --git a/src/frontend/components/_atoms/react-flow/index.tsx b/src/frontend/components/_atoms/react-flow/index.tsx index ccb9f75c3..ff066c3da 100644 --- a/src/frontend/components/_atoms/react-flow/index.tsx +++ b/src/frontend/components/_atoms/react-flow/index.tsx @@ -2,7 +2,7 @@ import './style.css' import type { BackgroundProps, ControlProps, ReactFlowProps } from '@xyflow/react' import { Background, Controls, ReactFlow } from '@xyflow/react' -import { PropsWithChildren } from 'react' +import { PropsWithChildren, useMemo } from 'react' import { cn } from '../../../utils/cn' @@ -14,6 +14,8 @@ type ReactFlowPanelProps = PropsWithChildren & { viewportConfig?: ReactFlowProps } +const DEFAULT_DELETE_KEY_CODES = ['Delete', 'Backspace'] + export const ReactFlowPanel = ({ children, background, @@ -22,20 +24,27 @@ export const ReactFlowPanel = ({ controlsConfig, viewportConfig, }: ReactFlowPanelProps) => { - const getDeleteKeyCodes = () => { - if (!viewportConfig?.deleteKeyCode) return ['Delete', 'Backspace'] - return viewportConfig.deleteKeyCode - } + const deleteKeyCodes = viewportConfig?.deleteKeyCode ? viewportConfig.deleteKeyCode : DEFAULT_DELETE_KEY_CODES + + // Stable children identity — inline JSX would break FlowRenderer's memo. + const flowChildren = useMemo( + () => ( + <> + {background && } + {controls && ( + + {controlsConfig?.children} + + )} + {children} + + ), + [background, backgroundConfig, controls, controlsConfig, children], + ) return ( - - {background && } - {controls && ( - - {controlsConfig?.children} - - )} - {children} + + {flowChildren} ) } diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx index ced257015..66b56bf8f 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx @@ -7,6 +7,8 @@ import { BlockVariant } from '../../../../../_atoms/graphical-editor/types/block import { FBDBody } from '../../../../../_molecules/graphical-editor/fbd' import { useBoundPou } from '../active-context' +const EMPTY_DIVERGENCES: string[] = [] + export default function FbdEditor() { // Bound POU comes from the `GraphicalEditorActiveProvider` set up // in the wrapper one level up. With multi-mount, every open FBD @@ -28,7 +30,7 @@ export default function FbdEditor() { const flowUpdated = flow?.updated || false const nodeDivergences = useMemo(() => { - if (!flow) return [] + if (!flow) return EMPTY_DIVERGENCES const divergences = [] @@ -78,7 +80,7 @@ export default function FbdEditor() { } } - return divergences + return divergences.length > 0 ? divergences : EMPTY_DIVERGENCES }, [flow?.rung.nodes, userLibraries, pous]) /** diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/active-context.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/active-context.tsx index 9b716601a..cdf750de1 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/active-context.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/active-context.tsx @@ -27,7 +27,7 @@ * isolated stories — under a real provider both fields are set. */ -import { createContext, type ReactNode, useContext } from 'react' +import { createContext, type ReactNode, useContext, useMemo } from 'react' import { useOpenPLCStore } from '../../../../../store' import type { EditorModel } from '../../../../../store/slices/editor' @@ -49,7 +49,8 @@ export function GraphicalEditorActiveProvider({ isActive: boolean children: ReactNode }) { - return {children} + const value = useMemo(() => ({ pouName, isActive }), [pouName, isActive]) + return {children} } /** diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx index ef547e198..d9f3379b5 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx @@ -15,7 +15,7 @@ import { import { restrictToParentElement } from '@dnd-kit/modifiers' import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' import * as Portal from '@radix-ui/react-portal' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { v4 as uuidv4 } from 'uuid' @@ -35,6 +35,8 @@ import BlockElement from '../elements/ladder/block' import CoilElement from '../elements/ladder/coil' import ContactElement from '../elements/ladder/contact' +const EMPTY_DIVERGENCES: string[] = [] + export default function LadderEditor() { // Bound POU comes from the `GraphicalEditorActiveProvider` set up // in the wrapper one level up. Mirrors `FbdEditor` — see that @@ -78,7 +80,63 @@ export default function LadderEditor() { const [activeId, setActiveId] = useState(null) const [activeItem, setActiveItem] = useState(null) - const nodeDivergences = getLibraryDivergences() + + const nodeDivergences = useMemo(() => { + if (!flow) return EMPTY_DIVERGENCES + + const divergences = [] + + for (const rung of flow.rungs) { + for (const node of rung.nodes) { + const variant = (node.data as BlockNodeData)?.variant + if (!variant) continue + + const libMatch = userLibraries.find((lib) => lib.name === variant.name && lib.type === variant.type) + if (!libMatch) continue + + const originalPou = pous.find((pou) => pou.name === libMatch.name) + if (!originalPou) continue + + const originalVariables = originalPou.interface?.variables ?? [] + const originalInOut = originalVariables.filter((variable) => + ['input', 'output', 'inOut'].includes(variable.class || ''), + ) + + const currentVariables = variant.variables.filter( + (variable) => + ['input', 'output', 'inOut'].includes(variable.class || '') && + !['OUT', 'EN', 'ENO'].includes(variable.name), + ) + + const formatVariable = (variable: { + name: string + class?: string + type: { definition: string; value: string } + }) => `${variable.name}|${variable.class}|${variable.type.definition}|${variable.type.value?.toLowerCase()}` + + if (originalPou.pouType === 'function') { + const outVariable = variant.variables.find((v) => v.name === 'OUT') + const outType = outVariable?.type?.value?.toUpperCase() + const returnType = originalPou.interface?.returnType?.toUpperCase() + if (!outType || !returnType || outType !== returnType) { + divergences.push(`${rung.id}:${node.id}`) + continue + } + } + + const currentMap = new Map(currentVariables.map((variable) => [formatVariable(variable), true])) + const hasDivergence = + originalInOut?.length !== currentVariables.length || + !originalInOut?.every((variable) => currentMap.has(formatVariable(variable))) + + if (hasDivergence) { + divergences.push(`${rung.id}:${node.id}`) + } + } + } + + return divergences.length > 0 ? divergences : EMPTY_DIVERGENCES + }, [flow?.rungs, userLibraries, pous]) const scrollableRef = useRef(null) useEffect(() => { @@ -216,63 +274,6 @@ export default function LadderEditor() { closeModal() } - function getLibraryDivergences() { - if (!flow) return [] - - const divergences = [] - - for (const rung of flow.rungs) { - for (const node of rung.nodes) { - const variant = (node.data as BlockNodeData)?.variant - if (!variant) continue - - const libMatch = userLibraries.find((lib) => lib.name === variant.name && lib.type === variant.type) - if (!libMatch) continue - - const originalPou = pous.find((pou) => pou.name === libMatch.name) - if (!originalPou) continue - - const originalVariables = originalPou.interface?.variables ?? [] - const originalInOut = originalVariables.filter((variable) => - ['input', 'output', 'inOut'].includes(variable.class || ''), - ) - - const currentVariables = variant.variables.filter( - (variable) => - ['input', 'output', 'inOut'].includes(variable.class || '') && - !['OUT', 'EN', 'ENO'].includes(variable.name), - ) - - const formatVariable = (variable: { - name: string - class?: string - type: { definition: string; value: string } - }) => `${variable.name}|${variable.class}|${variable.type.definition}|${variable.type.value?.toLowerCase()}` - - if (originalPou.pouType === 'function') { - const outVariable = variant.variables.find((v) => v.name === 'OUT') - const outType = outVariable?.type?.value?.toUpperCase() - const returnType = originalPou.interface?.returnType?.toUpperCase() - if (!outType || !returnType || outType !== returnType) { - divergences.push(`${rung.id}:${node.id}`) - continue - } - } - - const currentMap = new Map(currentVariables.map((variable) => [formatVariable(variable), true])) - const hasDivergence = - originalInOut?.length !== currentVariables.length || - !originalInOut?.every((variable) => currentMap.has(formatVariable(variable))) - - if (hasDivergence) { - divergences.push(`${rung.id}:${node.id}`) - } - } - } - - return divergences - } - return (
diff --git a/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx b/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx index b923c0fab..2c5eb38bf 100644 --- a/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx +++ b/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx @@ -3,17 +3,18 @@ import { applyEdgeChanges, applyNodeChanges, Connection, + DefaultEdgeOptions, Edge as FlowEdge, Node as FlowNode, OnEdgesChange, - OnNodeDrag, OnNodesChange, ReactFlowInstance, SelectionMode, + SnapGrid, XYPosition, } from '@xyflow/react' import { debounce, isEqual } from 'lodash' -import { DragEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { DragEvent, MouseEvent, useEffect, useMemo, useRef, useState } from 'react' import { useDebugCompositeKey } from '../../../../hooks/use-debug-composite-key' import { @@ -22,6 +23,7 @@ import { useIsDebuggerVisible, } from '../../../../hooks/use-debug-value' import { usePouSnapshot } from '../../../../hooks/use-pou-snapshot' +import { useStableCallback } from '../../../../hooks/use-stable-callback' import { useOpenPLCStore } from '../../../../store' import type { FBDRungState } from '../../../../store/slices/fbd' import { getFbdBlockType, isFbdBlockDrag } from '../../../../utils/graphical/drag-detection' @@ -45,6 +47,13 @@ interface FBDProps { const EDGE_COLOR_TRUE = '#00FF00' +const DEFAULT_EDGE_OPTIONS: DefaultEdgeOptions = { + type: 'smoothstep', +} +const SNAP_GRID: SnapGrid = [16, 16] +const PRO_OPTIONS = { hideAttribution: true } +const CONTROLS_CONFIG = { showInteractive: false } + export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false }: FBDProps) => { // Bound POU + editor model — every multi-mounted FBDBody reads // its OWN POU from the `GraphicalEditorActiveProvider` so cross- @@ -516,171 +525,146 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } * This function is called every time the nodes change * It is used to update the local rung state */ - const onNodesChange: OnNodesChange = useCallback( - (changes) => { - setRungLocal((newRung) => { - let nodes = newRung.nodes - let selectedNodes: FlowNode[] = newRung.nodes.filter((node) => node.selected) - - changes.forEach((change) => { - switch (change.type) { - case 'select': { - const node = newRung.nodes.find((n) => n.id === change.id) as FlowNode - if (change.selected) { - selectedNodes.push(node) - return - } - selectedNodes = selectedNodes.filter((n) => n.id !== change.id) + const onNodesChange: OnNodesChange = useStableCallback((changes) => { + setRungLocal((newRung) => { + let nodes = newRung.nodes + let selectedNodes: FlowNode[] = newRung.nodes.filter((node) => node.selected) + + changes.forEach((change) => { + switch (change.type) { + case 'select': { + const node = newRung.nodes.find((n) => n.id === change.id) as FlowNode + if (change.selected) { + selectedNodes.push(node) return } + selectedNodes = selectedNodes.filter((n) => n.id !== change.id) + return + } - case 'dimensions': { - if (change.resizing) - nodes = newRung.nodes.map((n) => { - if (n.id === change.id) { - return { - ...n, + case 'dimensions': { + if (change.resizing) + nodes = newRung.nodes.map((n) => { + if (n.id === change.id) { + return { + ...n, + width: change.dimensions?.width, + height: change.dimensions?.height, + measured: { width: change.dimensions?.width, height: change.dimensions?.height, - measured: { - width: change.dimensions?.width, - height: change.dimensions?.height, - }, - } + }, } - return n - }) - return - } + } + return n + }) + return } - }) - - return { - ...newRung, - nodes: applyNodeChanges(changes, nodes), - selectedNodes: selectedNodes, } }) - }, - [rungLocal, dragging], - ) - const onEdgesChange: OnEdgesChange = useCallback( - (changes) => { - setRungLocal((rung) => ({ - ...rung, - edges: applyEdgeChanges(changes, rung.edges), - })) - }, - [rungLocal, dragging], - ) + return { + ...newRung, + nodes: applyNodeChanges(changes, nodes), + selectedNodes: selectedNodes, + } + }) + }) - const onNodeDragStart = useCallback(() => { + const onEdgesChange: OnEdgesChange = useStableCallback((changes) => { + setRungLocal((rung) => ({ + ...rung, + edges: applyEdgeChanges(changes, rung.edges), + })) + }) + + const onNodeDragStart = useStableCallback(() => { captureAndPush(pouName) setDragging(true) - }, [rungLocal, dragging, captureAndPush, pouName]) + }) /** * When the node drag stops, update the fbd rung state */ - const onNodeDragStop: OnNodeDrag = useCallback( - (_e, _node, nodes) => { - setDragging(false) - fbdFlowActions.setRung({ - editorName: pouName, - rung: { - ...rungLocal, - nodes: rungLocal.nodes.map((node) => nodes.find((n) => n.id === node.id) ?? node), - edges: rungLocal.edges, - }, - }) - }, - [rungLocal, dragging], - ) + const onNodeDragStop = useStableCallback((_e: MouseEvent, _node: FlowNode, nodes: FlowNode[]) => { + setDragging(false) + fbdFlowActions.setRung({ + editorName: pouName, + rung: { + ...rungLocal, + nodes: rungLocal.nodes.map((node) => nodes.find((n) => n.id === node.id) ?? node), + edges: rungLocal.edges, + }, + }) + }) /** * Handle the drag enter of the viewport * This function is called when a dragged element enters the viewport */ - const onDragEnterViewport = useCallback( - (event) => { - event.preventDefault() - // Check if the dragged element is not an FBD block (cross-browser compatible) - if (!isFbdBlockDrag(event.dataTransfer)) { - return - } - }, - [reactFlowViewportRef], - ) + const onDragEnterViewport = useStableCallback((event: DragEvent) => { + event.preventDefault() + // Check if the dragged element is not an FBD block (cross-browser compatible) + if (!isFbdBlockDrag(event.dataTransfer)) { + return + } + }) /** * Handle the drag leave of the viewport * This function is called when a dragged element leaves the viewport */ - const onDragLeaveViewport = useCallback( - (event) => { - // Check if the dragged element is a child of the flow viewport - const { relatedTarget } = event - if ( - !reactFlowViewportRef.current || - !relatedTarget || - reactFlowViewportRef.current.contains(relatedTarget as Node) - ) { - return - } - }, - [reactFlowViewportRef], - ) + const onDragLeaveViewport = useStableCallback((event: DragEvent) => { + // Check if the dragged element is a child of the flow viewport + const { relatedTarget } = event + if ( + !reactFlowViewportRef.current || + !relatedTarget || + reactFlowViewportRef.current.contains(relatedTarget as Node) + ) { + return + } + }) /** * Handle the drag over of the viewport * This function is called when a dragged element is over the viewport */ - const onDragOver = useCallback( - (event) => { - if (!reactFlowInstance) return - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - }, - [reactFlowInstance], - ) + const onDragOver = useStableCallback((event: DragEvent) => { + if (!reactFlowInstance) return + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + }) /** * Handle the drop of the viewport * This function is called when a dragged element is dropped in the viewport */ - const onDrop = useCallback( - (event) => { - event.preventDefault() - // Check if there is an FBD block in the dragged data (cross-browser compatible) - const blockType = getFbdBlockType(event.dataTransfer) + const onDrop = useStableCallback((event: DragEvent) => { + event.preventDefault() + // Check if there is an FBD block in the dragged data (cross-browser compatible) + const blockType = getFbdBlockType(event.dataTransfer) - if (!blockType || !Object.keys(customNodeTypes).includes(blockType)) { - return - } + if (!blockType || !Object.keys(customNodeTypes).includes(blockType)) { + return + } - // Check if there is a library in the dragged data - const library = - event.dataTransfer.getData('application/library') === '' - ? undefined - : event.dataTransfer.getData('application/library') - - const position = reactFlowInstance?.screenToFlowPosition({ - x: event.clientX, - y: event.clientY, - }) ?? { - x: 0, - y: 0, - } + // Check if there is a library in the dragged data + const library = + event.dataTransfer.getData('application/library') === '' + ? undefined + : event.dataTransfer.getData('application/library') + + const position = reactFlowInstance?.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }) ?? { + x: 0, + y: 0, + } - handleAddElementByDropping(position, blockType as CustomFbdNodeTypes, library) - }, - // `handleAddElementByDropping` reads `libraries` via getState() at call - // time (never stale) and `pous` via subscription, so unlike the previous - // whole-store version this callback no longer needs library deps to - // re-bind for freshly installed libraries. - [rung, reactFlowInstance, pous], - ) + handleAddElementByDropping(position, blockType as CustomFbdNodeTypes, library) + }) /** * Handle the double click of a node @@ -692,6 +676,23 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } openModal(modalToOpen, node) } + const onDelete = useStableCallback(({ nodes, edges }: { nodes: FlowNode[]; edges: FlowEdge[] }) => { + handleOnDelete(nodes, edges) + }) + const onConnect = useStableCallback((connection: Connection) => { + handleOnConnect(connection) + }) + const onNodeDoubleClick = useStableCallback((_event: MouseEvent, node: FlowNode) => { + handleNodeDoubleClick(node) + }) + + // Per-POU pattern id. Without this, every SVG + // shares the library default id="pattern"; SVG ids are document-scoped, so + // opening a second FBD POU makes its resolve + // against the first instance's pattern and the grid disappears on the + // second editor. Scoping by POU name keeps each grid independent. + const backgroundConfig = useMemo(() => ({ id: `fbd-bg-${pouName}` }), [pouName]) + /** * Handle the close of the modal */ @@ -717,17 +718,9 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } SVG - // shares the library default id="pattern"; SVG ids - // are document-scoped, so opening a second FBD POU makes its - // resolve against the first - // instance's pattern and the grid disappears on the second - // editor. Scoping by POU name keeps each grid independent. - backgroundConfig={{ id: `fbd-bg-${pouName}` }} + backgroundConfig={backgroundConfig} controls={true} - controlsConfig={{ - showInteractive: false, - }} + controlsConfig={CONTROLS_CONFIG} viewportConfig={{ onInit: setReactFlowInstance, @@ -735,29 +728,15 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } nodes: styledNodes, edges: styledEdges, - defaultEdgeOptions: { - type: 'smoothstep', - }, + defaultEdgeOptions: DEFAULT_EDGE_OPTIONS, nodesDraggable: !isDebuggerActive, nodesConnectable: !isDebuggerActive, elementsSelectable: true, - onDelete: isDebuggerActive - ? undefined - : ({ nodes, edges }) => { - handleOnDelete(nodes, edges) - }, - onConnect: isDebuggerActive - ? undefined - : (connection) => { - handleOnConnect(connection) - }, - onNodeDoubleClick: isDebuggerActive - ? undefined - : (_event, node) => { - handleNodeDoubleClick(node) - }, + onDelete: isDebuggerActive ? undefined : onDelete, + onConnect: isDebuggerActive ? undefined : onConnect, + onNodeDoubleClick: isDebuggerActive ? undefined : onNodeDoubleClick, onDragEnter: isDebuggerActive ? undefined : onDragEnterViewport, onDragLeave: isDebuggerActive ? undefined : onDragLeaveViewport, @@ -774,12 +753,10 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } preventScrolling: canZoom, panOnDrag: canPan, - snapGrid: [16, 16], + snapGrid: SNAP_GRID, snapToGrid: true, - proOptions: { - hideAttribution: true, - }, + proOptions: PRO_OPTIONS, }} /> {blockElementModal?.open && ( diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx index c0bf5a53a..6d57adb9e 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx @@ -1,12 +1,19 @@ -import type { CoordinateExtent, Node as FlowNode, OnNodesChange, ReactFlowInstance } from '@xyflow/react' +import type { + CoordinateExtent, + DefaultEdgeOptions, + Node as FlowNode, + OnNodesChange, + ReactFlowInstance, +} from '@xyflow/react' import { applyNodeChanges, getNodesBounds } from '@xyflow/react' import { differenceWith, isEqual, parseInt } from 'lodash' -import { DragEventHandler, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { DragEvent, MouseEvent, useEffect, useMemo, useRef, useState } from 'react' import type { PLCVariable } from '../../../../../../middleware/shared/ports/types' import { useDebugCompositeKey } from '../../../../../hooks/use-debug-composite-key' import { useDebugBoolValuesMap, useIsDebuggerVisible } from '../../../../../hooks/use-debug-value' import { usePouSnapshot } from '../../../../../hooks/use-pou-snapshot' +import { useStableCallback } from '../../../../../hooks/use-stable-callback' import { useOpenPLCStore } from '../../../../../store' import type { RungLadderState } from '../../../../../store/slices/ladder' import { cn } from '../../../../../utils/cn' @@ -70,6 +77,14 @@ type RungBodyProps = { const EDGE_COLOR_TRUE = '#00FF00' +const DEFAULT_EDGE_OPTIONS: DefaultEdgeOptions = { + deletable: false, + selectable: false, + type: 'smoothstep', +} +const PRO_OPTIONS = { hideAttribution: true } +const NOOP = () => {} + export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActive = false }: RungBodyProps) => { const pouName = useBoundPou() const editor = useBoundEditorModel() @@ -687,192 +702,189 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi openModal(modalToOpen, node) } + const onNodesDelete = useStableCallback((nodes: FlowNode[]) => { + handleRemoveNode(nodes) + }) + const onNodeDragStart = useStableCallback((_event: MouseEvent, node: FlowNode) => { + handleNodeStartDrag(node) + }) + const onNodeDrag = useStableCallback((event: MouseEvent) => { + handleNodeDrag(event) + }) + const onNodeDragStop = useStableCallback((_event: MouseEvent, node: FlowNode) => { + handleNodeDragStop(node) + }) + const onNodeDoubleClick = useStableCallback((_event: MouseEvent, node: FlowNode) => { + handleNodeDoubleClick(node) + }) + /** * Handle the change of the nodes * This function is called every time the nodes change * It is used to update the local rung state */ - const onNodesChange: OnNodesChange = useCallback( - (changes) => { - let selectedNodes: FlowNode[] = rungLocal.nodes.filter((node) => node.selected) - changes.forEach((change) => { - switch (change.type) { - case 'select': { - const node = rungLocal.nodes.find((n) => n.id === change.id) as FlowNode - if (change.selected) { - selectedNodes.push(node) - return - } - - selectedNodes = selectedNodes.filter((n) => n.id !== change.id) - return - } - case 'add': { - selectedNodes = [] - return - } - case 'remove': { - selectedNodes = selectedNodes.filter((n) => n.id !== change.id) + const onNodesChange: OnNodesChange = useStableCallback((changes) => { + let selectedNodes: FlowNode[] = rungLocal.nodes.filter((node) => node.selected) + changes.forEach((change) => { + switch (change.type) { + case 'select': { + const node = rungLocal.nodes.find((n) => n.id === change.id) as FlowNode + if (change.selected) { + selectedNodes.push(node) return } + + selectedNodes = selectedNodes.filter((n) => n.id !== change.id) + return } - }) + case 'add': { + selectedNodes = [] + return + } + case 'remove': { + selectedNodes = selectedNodes.filter((n) => n.id !== change.id) + return + } + } + }) - setRungLocal((rung) => ({ - ...rung, - nodes: applyNodeChanges(changes, rungLocal.nodes), - selectedNodes: selectedNodes, - })) - }, - [rungLocal, rung, dragging], - ) + setRungLocal((rung) => ({ + ...rung, + nodes: applyNodeChanges(changes, rungLocal.nodes), + selectedNodes: selectedNodes, + })) + }) /** * Handle the drag enter of the viewport * This function is called when a dragged element enters the viewport */ - const onDragEnterViewport = useCallback( - (event) => { - if (isDebuggerActive) return - // Check recursively if the drag event is coming from within the ladder area - if (isDragEventFromWithinLadderArea(event.relatedTarget, reactFlowViewportRef.current)) { - return - } + const onDragEnterViewport = useStableCallback((event: DragEvent) => { + if (isDebuggerActive) return + // Check recursively if the drag event is coming from within the ladder area + if (isDragEventFromWithinLadderArea(event.relatedTarget, reactFlowViewportRef.current)) { + return + } - // Only expand rung once when drag first enters (not on every placeholder hover) - const isFirstDragEnter = !dragging - if (isFirstDragEnter) { - setDragging(true) - setReactFlowPanelExtent((extent) => [extent[0], [extent[1][0], extent[1][1] + 50]]) - } + // Only expand rung once when drag first enters (not on every placeholder hover) + const isFirstDragEnter = !dragging + if (isFirstDragEnter) { + setDragging(true) + setReactFlowPanelExtent((extent) => [extent[0], [extent[1][0], extent[1][1] + 50]]) + } - event.preventDefault() - // Check if the dragged element is not a ladder block (cross-browser compatible) - if (!isLadderBlockDrag(event.dataTransfer)) { - return - } + event.preventDefault() + // Check if the dragged element is not a ladder block (cross-browser compatible) + if (!isLadderBlockDrag(event.dataTransfer)) { + return + } - // Only render placeholders once on first enter (avoid re-rendering on every hover) - if (isFirstDragEnter) { - const copyRungLocal = { ...rungLocal } - const nodes = renderPlaceholderElements(copyRungLocal) - setRungLocal((rung) => ({ ...rung, nodes })) - } - }, - [rung, rungLocal, setReactFlowPanelExtent, reactFlowPanelExtent, dragging, isDebuggerActive], - ) + // Only render placeholders once on first enter (avoid re-rendering on every hover) + if (isFirstDragEnter) { + const copyRungLocal = { ...rungLocal } + const nodes = renderPlaceholderElements(copyRungLocal) + setRungLocal((rung) => ({ ...rung, nodes })) + } + }) /** * Handle the drag leave of the viewport * This function is called when a dragged element leaves the viewport */ - const onDragLeaveViewport = useCallback( - (event) => { - if (isDebuggerActive) return - // Check if the dragged element is a child of the flow viewport - if (isDragEventFromWithinLadderArea(event.relatedTarget, reactFlowViewportRef.current)) { - return - } + const onDragLeaveViewport = useStableCallback((event: DragEvent) => { + if (isDebuggerActive) return + // Check if the dragged element is a child of the flow viewport + if (isDragEventFromWithinLadderArea(event.relatedTarget, reactFlowViewportRef.current)) { + return + } - // Safari/WebKit quirk: When placeholders appear under cursor, Safari fires dragLeave - // with relatedTarget as null. We need to distinguish between: - // 1. Spurious dragLeave (hovering over placeholders) - cursor still inside rung - // 2. Real dragLeave (actually leaving) - cursor outside rung - if (!event.relatedTarget && dragging && reactFlowViewportRef.current) { - const rect = reactFlowViewportRef.current.getBoundingClientRect() - const isInsideBounds = - event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom - - // If cursor is still inside rung bounds, it's a spurious event - keep placeholders - if (isInsideBounds) { - return - } - // If cursor is outside bounds, it's a real leave - cleanup immediately + // Safari/WebKit quirk: When placeholders appear under cursor, Safari fires dragLeave + // with relatedTarget as null. We need to distinguish between: + // 1. Spurious dragLeave (hovering over placeholders) - cursor still inside rung + // 2. Real dragLeave (actually leaving) - cursor outside rung + if (!event.relatedTarget && dragging && reactFlowViewportRef.current) { + const rect = reactFlowViewportRef.current.getBoundingClientRect() + const isInsideBounds = + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + + // If cursor is still inside rung bounds, it's a spurious event - keep placeholders + if (isInsideBounds) { + return } + // If cursor is outside bounds, it's a real leave - cleanup immediately + } - setDragging(false) - setReactFlowPanelExtent((extent) => [extent[0], [extent[1][0], extent[1][1] - 50]]) + setDragging(false) + setReactFlowPanelExtent((extent) => [extent[0], [extent[1][0], extent[1][1] - 50]]) - // If it is, remove the placeholder elements` - const nodes = removePlaceholderElements(rungLocal.nodes) - setRungLocal((rung) => ({ ...rung, nodes })) - }, - [rung, rungLocal, setReactFlowPanelExtent, reactFlowPanelExtent, dragging, isDebuggerActive], - ) + // If it is, remove the placeholder elements` + const nodes = removePlaceholderElements(rungLocal.nodes) + setRungLocal((rung) => ({ ...rung, nodes })) + }) /** * Handle the drag over of the viewport * This function is called when a dragged element is over the viewport */ - const onDragOver = useCallback( - (event) => { - if (isDebuggerActive) return - if (!reactFlowInstance) return + const onDragOver = useStableCallback((event: DragEvent) => { + if (isDebuggerActive) return + if (!reactFlowInstance) return - event.preventDefault() - event.dataTransfer.dropEffect = 'move' + event.preventDefault() + event.dataTransfer.dropEffect = 'move' - const closestPlaceholder = searchNearestPlaceholder(rungLocal, reactFlowInstance, { - x: event.clientX, - y: event.clientY, - }) - if (!closestPlaceholder) return - - setRungLocal((rung) => ({ - ...rung, - nodes: rung.nodes.map((node) => { - if (node.id === closestPlaceholder.id) { - return { - ...node, - selected: true, - } - } + const closestPlaceholder = searchNearestPlaceholder(rungLocal, reactFlowInstance, { + x: event.clientX, + y: event.clientY, + }) + if (!closestPlaceholder) return + + setRungLocal((rung) => ({ + ...rung, + nodes: rung.nodes.map((node) => { + if (node.id === closestPlaceholder.id) { return { ...node, - selected: false, + selected: true, } - }), - })) - }, - [rung, rungLocal, isDebuggerActive], - ) + } + return { + ...node, + selected: false, + } + }), + })) + }) /** * Handle the drop of the viewport * This function is called when a dragged element is dropped in the viewport */ - const onDrop = useCallback( - (event) => { - if (isDebuggerActive) return - setDragging(false) - setReactFlowPanelExtent((extent) => [extent[0], [extent[1][0], extent[1][1] - 50]]) - - event.preventDefault() - // Check if there is a ladder block in the dragged data (cross-browser compatible) - const blockType = getLadderBlockType(event.dataTransfer) - if (!blockType) { - setRungLocal(rung) - return - } + const onDrop = useStableCallback((event: DragEvent) => { + if (isDebuggerActive) return + setDragging(false) + setReactFlowPanelExtent((extent) => [extent[0], [extent[1][0], extent[1][1] - 50]]) - // Check if there is a library in the dragged data - const library = - event.dataTransfer.getData('application/library') === '' - ? undefined - : event.dataTransfer.getData('application/library') - - // Then add the node to the rung - handleAddNode(blockType, library) - }, - // `handleAddNode` reads `libraries`/`ladderFlows` via getState() at call - // time (never stale) and `pous` via subscription, so unlike the previous - // whole-store version this callback no longer needs library/pou deps to - // re-bind for freshly installed libraries. - [rung, rungLocal, setReactFlowPanelExtent, reactFlowPanelExtent, isDebuggerActive, pous], - ) + event.preventDefault() + // Check if there is a ladder block in the dragged data (cross-browser compatible) + const blockType = getLadderBlockType(event.dataTransfer) + if (!blockType) { + setRungLocal(rung) + return + } + + // Check if there is a library in the dragged data + const library = + event.dataTransfer.getData('application/library') === '' + ? undefined + : event.dataTransfer.getData('application/library') + + // Then add the node to the rung + handleAddNode(blockType, library) + }) return (
{} : undefined, - onNodesDelete: isDebuggerActive - ? undefined - : (nodes) => { - handleRemoveNode(nodes) - }, - onNodeDragStart: isDebuggerActive - ? undefined - : (_event, node) => { - handleNodeStartDrag(node) - }, - onNodeDrag: isDebuggerActive - ? undefined - : (event) => { - handleNodeDrag(event) - }, - onNodeDragStop: isDebuggerActive - ? undefined - : (_event, node) => { - handleNodeDragStop(node) - }, - onNodeDoubleClick: isDebuggerActive - ? undefined - : (_event, node) => { - handleNodeDoubleClick(node) - }, + onNodeClick: isDebuggerActive ? NOOP : undefined, + onNodesDelete: isDebuggerActive ? undefined : onNodesDelete, + onNodeDragStart: isDebuggerActive ? undefined : onNodeDragStart, + onNodeDrag: isDebuggerActive ? undefined : onNodeDrag, + onNodeDragStop: isDebuggerActive ? undefined : onNodeDragStop, + onNodeDoubleClick: isDebuggerActive ? undefined : onNodeDoubleClick, onDragEnter: onDragEnterViewport, onDragLeave: onDragLeaveViewport, @@ -952,9 +940,7 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi preventScrolling: false, nodeDragThreshold: 25, - proOptions: { - hideAttribution: true, - }, + proOptions: PRO_OPTIONS, }} />
diff --git a/src/frontend/components/_organisms/graphical-editor/ladder/rung/index.tsx b/src/frontend/components/_organisms/graphical-editor/ladder/rung/index.tsx index 1e1452747..4e1e4c440 100644 --- a/src/frontend/components/_organisms/graphical-editor/ladder/rung/index.tsx +++ b/src/frontend/components/_organisms/graphical-editor/ladder/rung/index.tsx @@ -4,7 +4,7 @@ */ import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' -import { useEffect, useState } from 'react' +import { memo, useEffect, useState } from 'react' import { useOpenPLCStore } from '../../../../../store' import type { RungLadderState } from '../../../../../store/slices/ladder' @@ -21,7 +21,7 @@ type RungProps = { isDebuggerActive?: boolean } -export const Rung = ({ className, index, id, rung, nodeDivergences, isDebuggerActive }: RungProps) => { +const Rung = ({ className, index, id, rung, nodeDivergences, isDebuggerActive }: RungProps) => { // Primitive selector: this per-rung component only needs the rung count of // its own flow (for the rounded-corner styling), so subscribe to just that. const rungsCount = useOpenPLCStore( @@ -86,3 +86,7 @@ export const Rung = ({ className, index, id, rung, nodeDivergences, isDebuggerAc
) } + +const exportRung = memo(Rung) + +export { exportRung as Rung } diff --git a/src/frontend/hooks/use-stable-callback.ts b/src/frontend/hooks/use-stable-callback.ts new file mode 100644 index 000000000..1dc1070b4 --- /dev/null +++ b/src/frontend/hooks/use-stable-callback.ts @@ -0,0 +1,18 @@ +import { useCallback, useInsertionEffect, useRef } from 'react' + +/** + * Returns a stable-identity function that always invokes the latest `fn`, + * for handlers passed to identity-sensitive consumers (e.g. ReactFlow props). + * Must not be called during render — the ref is synced in an effect. + */ +export function useStableCallback( + fn: (...args: Args) => Return, +): (...args: Args) => Return { + const fnRef = useRef(fn) + + useInsertionEffect(() => { + fnRef.current = fn + }) + + return useCallback((...args: Args) => fnRef.current(...args), []) +} From acf9fd50aa91e88c49c74e08cc9ae0bc4a0026e7 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:19:46 -0300 Subject: [PATCH 27/52] fix(architecture): document backend-shared exception for SoftMotion axis helpers (#937) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(architecture): document backend-shared exception for SoftMotion axis helpers PR #928 introduced imports of generate-softmotion.ts's pure axis-naming helpers (sanitizeAxisName, softMotionAxisNames, serializeSoftMotionAxisGlobalsToST, isValidIecIdentifier) from three services/store files, violating the services/store -> backend-shared layer rule and failing `validate:arch` on development for every PR since. Documented as KNOWN_EXCEPTIONS entries, mirroring the existing ladder-slice/fbd-slice precedent: the pure discovery logic isn't duplicated, only reached across a layer boundary those files can't move. Co-Authored-By: Claude Sonnet 5 * Revert "fix(architecture): document backend-shared exception for SoftMotion axis helpers" This reverts commit b1a60bfd13900b6d361a6f704c439bae34727996. * fix(architecture): relocate SoftMotion axis-naming to middleware/shared/utils PR #928 introduced imports of generate-softmotion.ts's pure axis-naming helpers (sanitizeAxisName, softMotionAxisNames, serializeSoftMotionAxisGlobalsToST, isValidIecIdentifier) and cia402.ts directly from three frontend/services/ and frontend/store/ files, plus two frontend/components/ call sites (missed by the checker's @root/ alias blind spot) — all violating the services/store/components -> backend-shared layer rule and failing validate:arch on development for every PR since. Rather than special-casing the violation with a KNOWN_EXCEPTIONS entry, relocated the pure logic to middleware/shared/utils/ethercat/ — the existing home for domain logic reachable from every layer including the compile pipeline (same relationship target-capabilities/ and library/ already have to backend/shared/compile/pipeline.ts). backend/shared/ethercat/ generate-softmotion.ts now only owns the actual codegen (generateSoftMotionArtifacts/injectAxisExternals), importing collectAxes from the new utils module. Also fixed a pre-existing prettier violation from the same PR (preprocess-pous.test.ts) that was failing the format check on development. (openplc-web's matching softmotion-e2e.test.ts fix does not apply here — that test is web-adapter-specific and doesn't exist in this repo.) Co-Authored-By: Claude Sonnet 5 * fix(sync): exclude test files from shared-surface comparison compare-surfaces.py required byte-identical test files across both repos, same as program files. This is stricter than necessary and already broken in practice: openplc-web's backend/shared/ethercat/__tests__/softmotion-e2e.test.ts (added by #928) is genuinely web-adapter-specific (imports middleware/adapters/web/transpile-from-port + bundled-stlibs, neither of which exists in this repo) and has never had an editor counterpart — this has been failing the required Shared Surface Sync check on every PR since #928 merged, independent of anything in this branch. Test files are allowed to diverge (platform-specific mocks, adapter-only e2e suites) without indicating a real program-file drift — the same exclusion the architecture validator (__architecture__/validate.ts) already applies to its own scan. Now only program files are compared. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- scripts/compare-surfaces.py | 20 ++- .../__tests__/generate-softmotion.test.ts | 53 ------- .../shared/ethercat/enrich-device-data.ts | 6 +- .../shared/ethercat/generate-softmotion.ts | 134 +---------------- .../PLC/__tests__/preprocess-pous.test.ts | 24 ++- .../ethercat/components/cia402-axis-tab.tsx | 2 +- .../editor/device/ethercat/index.tsx | 2 +- .../st-lsp/goto-definition-redirect.ts | 2 +- src/frontend/services/st-lsp/project-sync.ts | 2 +- src/frontend/store/slices/shared/slice.ts | 2 +- .../utils}/ethercat/__tests__/cia402.test.ts | 7 +- .../__tests__/softmotion-axis-naming.test.ts | 111 ++++++++++++++ .../shared/utils}/ethercat/cia402.ts | 5 +- src/middleware/shared/utils/ethercat/index.ts | 20 +++ .../utils/ethercat/softmotion-axis-naming.ts | 139 ++++++++++++++++++ 15 files changed, 329 insertions(+), 200 deletions(-) rename src/{backend/shared => middleware/shared/utils}/ethercat/__tests__/cia402.test.ts (91%) create mode 100644 src/middleware/shared/utils/ethercat/__tests__/softmotion-axis-naming.test.ts rename src/{backend/shared => middleware/shared/utils}/ethercat/cia402.ts (97%) create mode 100644 src/middleware/shared/utils/ethercat/index.ts create mode 100644 src/middleware/shared/utils/ethercat/softmotion-axis-naming.ts diff --git a/scripts/compare-surfaces.py b/scripts/compare-surfaces.py index 508d6b9a6..c3b1465f3 100644 --- a/scripts/compare-surfaces.py +++ b/scripts/compare-surfaces.py @@ -13,7 +13,10 @@ - bare-metal-runtime (editor: resources/sources/{arduino,Baremetal}; web: src/assets/firmware/{arduino,Baremetal}) -Every file in these surfaces must be byte-identical across both repos. +Every program file in these surfaces must be byte-identical across both +repos. Test files (`__tests__/` directories, `*.test.ts(x)`, `*.spec.ts(x)`) +are excluded — they're allowed to diverge (platform-specific mocks, adapter- +only e2e suites) without indicating a real program-file drift. Exit code 0 = all identical, 1 = differences found. """ @@ -63,13 +66,24 @@ def hash_file(path: Path) -> str: return h.hexdigest() +def is_test_file(path: Path) -> bool: + """True for test files/directories — same exclusion the architecture + validator (__architecture__/validate.ts) already applies to its own scan. + Test files are allowed to diverge between repos (platform-specific mocks, + adapter-only e2e suites) without indicating a real program-file drift.""" + if "__tests__" in path.parts: + return True + name = path.name + return any(name.endswith(suffix) for suffix in (".test.ts", ".test.tsx", ".spec.ts", ".spec.tsx")) + + def collect_hashes(root: Path, surface: str) -> dict[str, str]: base = root / surface if not base.exists(): return {} result = {} for path in sorted(base.rglob("*")): - if path.is_file(): + if path.is_file() and not is_test_file(path): rel = str(path.relative_to(root)) result[rel] = hash_file(path) return result @@ -108,7 +122,7 @@ def collect_all_hashes(base: Path, exts: list[str] | None = None) -> dict[str, s return result ext_set = set(exts) if exts else None for path in sorted(base.rglob("*")): - if path.is_file() and (ext_set is None or path.suffix in ext_set): + if path.is_file() and not is_test_file(path) and (ext_set is None or path.suffix in ext_set): result[str(path.relative_to(base))] = hash_file(path) return result diff --git a/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts index 3bfbe0d2d..f3cdb4637 100644 --- a/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts +++ b/src/backend/shared/ethercat/__tests__/generate-softmotion.test.ts @@ -10,12 +10,8 @@ import { parseESIDeviceFull } from '../esi-parser-main' import { generateSoftMotionArtifacts, injectAxisExternals, - isValidIecIdentifier, - serializeSoftMotionAxisGlobalsToST, SM3_BRIDGE_INSTANCE_NAME, SM3_BRIDGE_POU_NAME, - sanitizeAxisName, - softMotionAxisNames, } from '../generate-softmotion' const ESI_XML = readFileSync(resolve(__dirname, 'fixtures/cia402-servo-esi.xml'), 'utf-8') @@ -59,35 +55,11 @@ function makeProject(devices: ConfiguredEtherCATDevice[]): PLCProjectData { } describe('generateSoftMotionArtifacts', () => { - it('sanitizes device names into valid IEC identifiers', () => { - expect(sanitizeAxisName('X_Axis')).toBe('X_Axis') - expect(sanitizeAxisName('My Axis 01')).toBe('My_Axis_01') - expect(sanitizeAxisName('9drive')).toBe('_9drive') - }) - - it('validates IEC identifiers', () => { - expect(isValidIecIdentifier('X_Axis')).toBe(true) - expect(isValidIecIdentifier('_axis1')).toBe(true) - expect(isValidIecIdentifier('ASDA-A2-E')).toBe(false) - expect(isValidIecIdentifier('My Axis')).toBe(false) - expect(isValidIecIdentifier('9drive')).toBe(false) - expect(isValidIecIdentifier('')).toBe(false) - }) - it('is a no-op when there are no CiA 402 axes', () => { const project = makeProject([]) expect(generateSoftMotionArtifacts(project)).toBe(project) }) - describe('softMotionAxisNames', () => { - it('returns sanitized names of enabled axes', () => { - expect(softMotionAxisNames(makeProject([makeDevice('My Axis')]))).toEqual(['My_Axis']) - }) - it('returns [] when there are no axes', () => { - expect(softMotionAxisNames(makeProject([]))).toEqual([]) - }) - }) - describe('injectAxisExternals', () => { const prog = (value: string) => ({ name: 'p', pouType: 'program', interface: { variables: [] }, body: { language: 'st', value } }) as never @@ -111,31 +83,6 @@ describe('generateSoftMotionArtifacts', () => { }) }) - describe('serializeSoftMotionAxisGlobalsToST', () => { - it('returns empty string when there are no axes', () => { - expect(serializeSoftMotionAxisGlobalsToST(makeProject([]))).toBe('') - }) - - it('declares each axis as a bare top-level VAR_GLOBAL of type AXIS_REF_SM3', () => { - const st = serializeSoftMotionAxisGlobalsToST(makeProject([makeDevice('X_Axis')])) - expect(st).toContain('X_Axis : AXIS_REF_SM3;') - // Bare top-level block (ambient global) — NOT wrapped in a CONFIGURATION, - // so the axis resolves without VAR_EXTERNAL. - expect(st.startsWith('VAR_GLOBAL')).toBe(true) - expect(st).toContain('END_VAR') - expect(st).not.toContain('CONFIGURATION') - }) - - it('lists axes in softMotionAxisNames order (line N+1 = axis N)', () => { - const project = makeProject([makeDevice('X_Axis')]) - const st = serializeSoftMotionAxisGlobalsToST(project) - const names = softMotionAxisNames(project) - const lines = st.split('\n') - // line 0 = VAR_GLOBAL, line 1 = first axis - expect(lines[1]).toContain(names[0]) - }) - }) - it('is a no-op when the CiA 402 device is disabled', () => { const dev = makeDevice('X_Axis') dev.cia402 = { ...dev.cia402!, enabled: false } diff --git a/src/backend/shared/ethercat/enrich-device-data.ts b/src/backend/shared/ethercat/enrich-device-data.ts index 7926238f0..2751b0116 100644 --- a/src/backend/shared/ethercat/enrich-device-data.ts +++ b/src/backend/shared/ethercat/enrich-device-data.ts @@ -14,8 +14,12 @@ import type { PersistedPdoEntry, SDOConfigurationEntry, } from '@root/middleware/shared/ports/esi-types' +import { + type Cia402AxisConfig, + DEFAULT_CIA402_AXIS_CONFIG, + isCia402Drive, +} from '@root/middleware/shared/utils/ethercat' -import { type Cia402AxisConfig, DEFAULT_CIA402_AXIS_CONFIG, isCia402Drive } from './cia402' import { esiTypeToIecType, generateDefaultChannelMappings, pdoToChannels } from './esi-parser' import { extractDefaultSdoConfigurations } from './sdo-config-defaults' diff --git a/src/backend/shared/ethercat/generate-softmotion.ts b/src/backend/shared/ethercat/generate-softmotion.ts index 3e87b5ca6..5d2465fb5 100644 --- a/src/backend/shared/ethercat/generate-softmotion.ts +++ b/src/backend/shared/ethercat/generate-softmotion.ts @@ -3,9 +3,12 @@ /** * Compile-time SoftMotion code generation. * - * Turns each CiA 402 EtherCAT drive (recognized by cia402.ts, opted-in via its + * Turns each CiA 402 EtherCAT drive (recognized by + * middleware/shared/utils/ethercat/cia402.ts, opted-in via its * Cia402AxisConfig) into the ST glue that lets CODESYS-style application code - * run unmodified: + * run unmodified. Axis discovery/naming (`collectAxes`, `sanitizeAxisName`, …) + * lives in middleware/shared/utils/ethercat/softmotion-axis-naming.ts — this + * file only owns the codegen that the discovery feeds into: * * - an AXIS_REF_SM3 global named after the device (so `MC_Power(Axis := X_Axis)` * resolves directly to the drive), @@ -22,9 +25,7 @@ */ import type { PLCInstance, PLCPou, PLCProjectData, PLCTask, PLCVariable } from '@root/middleware/shared/ports/types' - -import type { Cia402Role } from './cia402' -import { resolveCia402Objects } from './cia402' +import { collectAxes } from '@root/middleware/shared/utils/ethercat' export const SM3_BRIDGE_POU_NAME = '__sm3_bridge' export const SM3_BRIDGE_INSTANCE_NAME = '__sm3_bridge_inst' @@ -35,50 +36,6 @@ const SM3_FALLBACK_TASK: PLCTask = { priority: 0, } -/** - * Maps a CiA 402 object role to the SM_Drive_GenericDS402 pin it binds and the - * IEC type the located scalar is declared with. The scalar type is fixed to the - * bridge pin's type (not the ESI-declared type) so the generated FB call is - * always type-correct — the PDO byte width is identical either way (a 32-bit - * position reads the same 4 bytes as DINT or UDINT at the same %QD address). - * `pinKind` decides `:=` (FB input, drive feedback) vs `=>` (FB output, command). - */ -interface RoleBinding { - pin: string - pinKind: 'input' | 'output' - iecType: string -} -const ROLE_BINDINGS: Record = { - controlWord: { pin: 'wControlWord', pinKind: 'output', iecType: 'UINT' }, - modesOfOperation: { pin: 'siModes', pinKind: 'output', iecType: 'SINT' }, - targetPosition: { pin: 'diTargetPosition', pinKind: 'output', iecType: 'DINT' }, - profileVelocity: { pin: 'udiProfileVelocity', pinKind: 'output', iecType: 'UDINT' }, - targetVelocity: { pin: 'diTargetVelocity', pinKind: 'output', iecType: 'DINT' }, - targetTorque: { pin: 'iTargetTorque', pinKind: 'output', iecType: 'INT' }, - statusWord: { pin: 'wStatusWord', pinKind: 'input', iecType: 'UINT' }, - modesDisplay: { pin: 'siModesDisplay', pinKind: 'input', iecType: 'SINT' }, - positionActual: { pin: 'diActualPosition', pinKind: 'input', iecType: 'DINT' }, - velocityActual: { pin: 'diActualVelocity', pinKind: 'input', iecType: 'DINT' }, - torqueActual: { pin: 'iActualTorque', pinKind: 'input', iecType: 'INT' }, -} - -/** Sanitize a device name into a valid IEC 61131-3 identifier. */ -export function sanitizeAxisName(name: string): string { - let s = name.replace(/[^A-Za-z0-9_]/g, '_') - if (!/^[A-Za-z_]/.test(s)) s = `_${s}` - return s -} - -/** - * True when `name` is already a valid IEC 61131-3 identifier — a letter or - * underscore followed by letters, digits, or underscores. A SoftMotion drive's - * name IS the axis variable name used in `MC_*(Axis := )`, so it must - * satisfy this (no spaces, hyphens, or leading digits). - */ -export function isValidIecIdentifier(name: string): boolean { - return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) -} - function lrealLiteral(n: number): string { return Number.isInteger(n) ? `${n}.0` : `${n}` } @@ -103,56 +60,6 @@ function bodyReferences(bodyValue: unknown, identifier: string): boolean { return new RegExp(`\\b${identifier}\\b`).test(text) } -interface AxisPlan { - axisName: string - scaleNum: number - scaleDenom: number - scaleFactor: number - objects: { role: Cia402Role; scalarName: string; iecLocation: string; binding: RoleBinding }[] -} - -/** Collect every opted-in, resolvable CiA 402 axis in the project. */ -function collectAxes(project: PLCProjectData): AxisPlan[] { - const plans: AxisPlan[] = [] - const seen = new Set() - for (const rd of project.remoteDevices ?? []) { - if (rd.protocol !== 'ethercat') continue - for (const dev of rd.ethercatConfig?.devices ?? []) { - if (!dev.cia402?.enabled) continue - const resolved = resolveCia402Objects(dev.channelInfo ?? [], dev.channelMappings) - const hasControl = resolved.some((o) => o.role === 'controlWord') - const hasStatus = resolved.some((o) => o.role === 'statusWord') - // A drive can only be an axis if both mandatory objects are mapped. - if (!hasControl || !hasStatus) continue - - const axisName = sanitizeAxisName(dev.name) - // Skip a duplicate sanitized name to avoid emitting two globals with the - // same identifier (later devices lose — surfaced by compile if referenced). - if (seen.has(axisName.toUpperCase())) continue - seen.add(axisName.toUpperCase()) - - plans.push({ - axisName, - scaleNum: dev.cia402.scaleNum, - scaleDenom: dev.cia402.scaleDenom, - scaleFactor: dev.cia402.scaleFactor, - objects: resolved.map((o) => ({ - role: o.role, - scalarName: `${axisName}_${o.role}`, - iecLocation: o.iecLocation, - binding: ROLE_BINDINGS[o.role], - })), - }) - } - } - return plans -} - -/** Sanitized names of every enabled, resolvable CiA 402 axis in the project. */ -export function softMotionAxisNames(project: PLCProjectData): string[] { - return collectAxes(project).map((a) => a.axisName) -} - /** POU types that may access a SoftMotion axis global via VAR_EXTERNAL. Functions * are stateless and can't hold VAR_EXTERNAL, so they're excluded. */ const AXIS_EXTERNAL_POU_TYPES = new Set(['program', 'function-block']) @@ -179,35 +86,6 @@ export function injectAxisExternals(pou: PLCPou, axisNames: string[]): PLCPou { } } -/** - * Serialize the SoftMotion axis globals as a standalone ST configuration for the - * language server. Each CiA 402 drive becomes a `VAR_GLOBAL : AXIS_REF_SM3` - * so editor code referencing the axis (e.g. `MC_Power(Axis := X_Axis)`) resolves - * against the same public axis the compiler generates — without the user - * declaring anything. Returns '' when the project has no axes. - * - * Emitted as a **bare top-level `VAR_GLOBAL` block** (not wrapped in a - * CONFIGURATION): strucpp registers top-level global blocks into the ambient - * global scope, so a POU can reference the axis directly — no `VAR_EXTERNAL` - * needed, which is what keeps the editor documents byte-for-byte what the user - * wrote (no injected declarations shifting line numbers). Only the axis - * references are declared (not the located PDO scalar globals) — those are - * internal to the generated drive bridge and never named in user code. - * `AXIS_REF_SM3` itself comes from the bundled plcopen-softmotion stlib the LSP - * already ingests. - * - * The declaration order matches `softMotionAxisNames`, so line N+1 of this - * document (line 0 is `VAR_GLOBAL`) is axis N — the go-to-definition redirect - * relies on that to map a click back to its drive. - */ -export function serializeSoftMotionAxisGlobalsToST(project: PLCProjectData): string { - const axes = collectAxes(project) - if (axes.length === 0) return '' - - const decls = axes.map((a) => ` ${a.axisName} : AXIS_REF_SM3;`).join('\n') - return ['VAR_GLOBAL', decls, 'END_VAR', ''].join('\n') -} - /** * Inject generated SoftMotion globals + the per-scan bridge program for every * CiA 402 axis in the project. No-op (returns the input) when there are none. diff --git a/src/backend/shared/utils/PLC/__tests__/preprocess-pous.test.ts b/src/backend/shared/utils/PLC/__tests__/preprocess-pous.test.ts index 7fab4705b..8fbe49400 100644 --- a/src/backend/shared/utils/PLC/__tests__/preprocess-pous.test.ts +++ b/src/backend/shared/utils/PLC/__tests__/preprocess-pous.test.ts @@ -331,8 +331,28 @@ describe('preprocessPous — mixed', () => { config: {}, cia402: { enabled: true, scaleNum: 1, scaleDenom: 1, scaleFactor: 1 }, channelInfo: [ - { channelId: 'c1', name: 'Controlword', direction: 'output', pdoIndex: '0x1600', entryIndex: '0x6040', entrySubIndex: '0x0', dataType: 'UINT', bitLen: 16, iecType: 'UINT' }, - { channelId: 'c2', name: 'Statusword', direction: 'input', pdoIndex: '0x1A00', entryIndex: '0x6041', entrySubIndex: '0x0', dataType: 'UINT', bitLen: 16, iecType: 'UINT' }, + { + channelId: 'c1', + name: 'Controlword', + direction: 'output', + pdoIndex: '0x1600', + entryIndex: '0x6040', + entrySubIndex: '0x0', + dataType: 'UINT', + bitLen: 16, + iecType: 'UINT', + }, + { + channelId: 'c2', + name: 'Statusword', + direction: 'input', + pdoIndex: '0x1A00', + entryIndex: '0x6041', + entrySubIndex: '0x0', + dataType: 'UINT', + bitLen: 16, + iecType: 'UINT', + }, ], channelMappings: [ { channelId: 'c1', iecLocation: '%QW0' }, diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/cia402-axis-tab.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/cia402-axis-tab.tsx index 6d5827e21..67536becf 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/cia402-axis-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/cia402-axis-tab.tsx @@ -1,6 +1,6 @@ -import { type Cia402Role, resolveCia402Objects } from '@root/backend/shared/ethercat/cia402' import { InputWithRef } from '@root/frontend/components/_atoms/input' import type { Cia402AxisConfig, ConfiguredEtherCATDevice } from '@root/middleware/shared/ports/esi-types' +import { type Cia402Role, resolveCia402Objects } from '@root/middleware/shared/utils/ethercat' import { useMemo } from 'react' const inputClassName = diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx index 1b6e71a49..942b6cafa 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/index.tsx @@ -2,7 +2,6 @@ import * as Tabs from '@radix-ui/react-tabs' import { createDefaultSlaveConfig } from '@root/backend/shared/ethercat/device-config-defaults' import { matchDevicesToRepository } from '@root/backend/shared/ethercat/device-matcher' import { enrichDeviceData } from '@root/backend/shared/ethercat/enrich-device-data' -import { sanitizeAxisName } from '@root/backend/shared/ethercat/generate-softmotion' import type { EtherCATMasterConfig } from '@root/backend/shared/types/PLC/open-plc' import { Modal, ModalContent, ModalTitle } from '@root/frontend/components/_molecules/modal' import { useOpenPLCStore } from '@root/frontend/store' @@ -18,6 +17,7 @@ import type { } from '@root/middleware/shared/ports/esi-types' import type { EtherCATDevice, NetworkInterface } from '@root/middleware/shared/ports/ethercat-types' import { useEsi, useRuntime } from '@root/middleware/shared/providers/platform-context' +import { sanitizeAxisName } from '@root/middleware/shared/utils/ethercat' import { buildAddressPool } from '@root/middleware/shared/utils/iec-address' import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' diff --git a/src/frontend/services/st-lsp/goto-definition-redirect.ts b/src/frontend/services/st-lsp/goto-definition-redirect.ts index 5e7364940..7697f2e9e 100644 --- a/src/frontend/services/st-lsp/goto-definition-redirect.ts +++ b/src/frontend/services/st-lsp/goto-definition-redirect.ts @@ -35,8 +35,8 @@ import type { Location, LocationLink } from 'vscode-languageserver-protocol' -import { sanitizeAxisName, softMotionAxisNames } from '../../../backend/shared/ethercat/generate-softmotion' 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' diff --git a/src/frontend/services/st-lsp/project-sync.ts b/src/frontend/services/st-lsp/project-sync.ts index dd7e1e692..51ee261e7 100644 --- a/src/frontend/services/st-lsp/project-sync.ts +++ b/src/frontend/services/st-lsp/project-sync.ts @@ -25,8 +25,8 @@ * `refreshStlibs()` on the service. */ -import { serializeSoftMotionAxisGlobalsToST } from '../../../backend/shared/ethercat/generate-softmotion' import type { PLCDataType, PLCPou, PLCRemoteDevice, PLCVariable } from '../../../middleware/shared/ports/types' +import { serializeSoftMotionAxisGlobalsToST } from '../../../middleware/shared/utils/ethercat' import { openPLCStoreBase } from '../../store' import { serializeDataTypesToST } from '../../utils/PLC/data-type-serializer' import { serializePouSignatureToSTWithBodyOffset } from '../../utils/PLC/pou-signature-serializer' diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 43d03155c..70a6c96af 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -1,8 +1,8 @@ import { produce } from 'immer' import { StateCreator } from 'zustand' -import { isValidIecIdentifier } from '../../../../backend/shared/ethercat/generate-softmotion' import type { PLCVariable } from '../../../../middleware/shared/ports/types' +import { isValidIecIdentifier } from '../../../../middleware/shared/utils/ethercat' 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' diff --git a/src/backend/shared/ethercat/__tests__/cia402.test.ts b/src/middleware/shared/utils/ethercat/__tests__/cia402.test.ts similarity index 91% rename from src/backend/shared/ethercat/__tests__/cia402.test.ts rename to src/middleware/shared/utils/ethercat/__tests__/cia402.test.ts index fceccc2c8..89ab3fd0d 100644 --- a/src/backend/shared/ethercat/__tests__/cia402.test.ts +++ b/src/middleware/shared/utils/ethercat/__tests__/cia402.test.ts @@ -2,7 +2,8 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' - +import { enrichDeviceData } from '../../../../../backend/shared/ethercat/enrich-device-data' +import { parseESIDeviceFull } from '../../../../../backend/shared/ethercat/esi-parser-main' import { CIA402_OBJECTS, DEFAULT_CIA402_AXIS_CONFIG, @@ -10,11 +11,9 @@ import { normalizeObjectIndex, resolveCia402Objects, } from '../cia402' -import { enrichDeviceData } from '../enrich-device-data' -import { parseESIDeviceFull } from '../esi-parser-main' const ESI_XML = readFileSync( - resolve(__dirname, 'fixtures/cia402-servo-esi.xml'), + resolve(__dirname, '../../../../../backend/shared/ethercat/__tests__/fixtures/cia402-servo-esi.xml'), 'utf-8', ) diff --git a/src/middleware/shared/utils/ethercat/__tests__/softmotion-axis-naming.test.ts b/src/middleware/shared/utils/ethercat/__tests__/softmotion-axis-naming.test.ts new file mode 100644 index 000000000..1a9d33b1c --- /dev/null +++ b/src/middleware/shared/utils/ethercat/__tests__/softmotion-axis-naming.test.ts @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import type { ConfiguredEtherCATDevice } from '@root/middleware/shared/ports/esi-types' +import type { PLCProjectData } from '@root/middleware/shared/ports/types' + +import { enrichDeviceData } from '../../../../../backend/shared/ethercat/enrich-device-data' +import { parseESIDeviceFull } from '../../../../../backend/shared/ethercat/esi-parser-main' +import { + isValidIecIdentifier, + sanitizeAxisName, + serializeSoftMotionAxisGlobalsToST, + softMotionAxisNames, +} from '../softmotion-axis-naming' + +const ESI_XML = readFileSync( + resolve(__dirname, '../../../../../backend/shared/ethercat/__tests__/fixtures/cia402-servo-esi.xml'), + 'utf-8', +) + +function makeDevice(name: string): ConfiguredEtherCATDevice { + const parsed = parseESIDeviceFull(ESI_XML, 0) + const enriched = enrichDeviceData(parsed.device!) + return { + id: 'dev-1', + name, + esiDeviceRef: { repositoryItemId: 'repo-1', deviceIndex: 0 }, + vendorId: '0x0', + productCode: '0x0', + revisionNo: '0x0', + addedFrom: 'repository', + config: {} as ConfiguredEtherCATDevice['config'], + ...enriched, + } +} + +function makeProject(devices: ConfiguredEtherCATDevice[]): PLCProjectData { + return { + dataTypes: [], + pous: [ + { + name: 'main', + pouType: 'program', + interface: { variables: [] }, + body: { language: 'st', value: '' }, + }, + ], + configurations: { + resource: { + tasks: [{ name: 'task0', triggering: 'Cyclic', interval: 'T#20ms', priority: 1 }], + instances: [{ name: 'instance0', task: 'task0', program: 'main' }], + globalVariables: [], + }, + }, + remoteDevices: [{ name: 'ethercat-bus', protocol: 'ethercat', ethercatConfig: { devices } }], + } +} + +describe('sanitizeAxisName', () => { + it('sanitizes device names into valid IEC identifiers', () => { + expect(sanitizeAxisName('X_Axis')).toBe('X_Axis') + expect(sanitizeAxisName('My Axis 01')).toBe('My_Axis_01') + expect(sanitizeAxisName('9drive')).toBe('_9drive') + }) +}) + +describe('isValidIecIdentifier', () => { + it('validates IEC identifiers', () => { + expect(isValidIecIdentifier('X_Axis')).toBe(true) + expect(isValidIecIdentifier('_axis1')).toBe(true) + expect(isValidIecIdentifier('ASDA-A2-E')).toBe(false) + expect(isValidIecIdentifier('My Axis')).toBe(false) + expect(isValidIecIdentifier('9drive')).toBe(false) + expect(isValidIecIdentifier('')).toBe(false) + }) +}) + +describe('softMotionAxisNames', () => { + it('returns sanitized names of enabled axes', () => { + expect(softMotionAxisNames(makeProject([makeDevice('My Axis')]))).toEqual(['My_Axis']) + }) + it('returns [] when there are no axes', () => { + expect(softMotionAxisNames(makeProject([]))).toEqual([]) + }) +}) + +describe('serializeSoftMotionAxisGlobalsToST', () => { + it('returns empty string when there are no axes', () => { + expect(serializeSoftMotionAxisGlobalsToST(makeProject([]))).toBe('') + }) + + it('declares each axis as a bare top-level VAR_GLOBAL of type AXIS_REF_SM3', () => { + const st = serializeSoftMotionAxisGlobalsToST(makeProject([makeDevice('X_Axis')])) + expect(st).toContain('X_Axis : AXIS_REF_SM3;') + // Bare top-level block (ambient global) — NOT wrapped in a CONFIGURATION, + // so the axis resolves without VAR_EXTERNAL. + expect(st.startsWith('VAR_GLOBAL')).toBe(true) + expect(st).toContain('END_VAR') + expect(st).not.toContain('CONFIGURATION') + }) + + it('lists axes in softMotionAxisNames order (line N+1 = axis N)', () => { + const project = makeProject([makeDevice('X_Axis')]) + const st = serializeSoftMotionAxisGlobalsToST(project) + const names = softMotionAxisNames(project) + const lines = st.split('\n') + // line 0 = VAR_GLOBAL, line 1 = first axis + expect(lines[1]).toContain(names[0]) + }) +}) diff --git a/src/backend/shared/ethercat/cia402.ts b/src/middleware/shared/utils/ethercat/cia402.ts similarity index 97% rename from src/backend/shared/ethercat/cia402.ts rename to src/middleware/shared/utils/ethercat/cia402.ts index b360d47e0..e37f47ee9 100644 --- a/src/backend/shared/ethercat/cia402.ts +++ b/src/middleware/shared/utils/ethercat/cia402.ts @@ -95,10 +95,7 @@ function pdosContainIndex(pdos: ESIPdo[], index: number): boolean { * (Controlword out, Statusword in) — i.e. it can be driven as a SoftMotion axis. */ export function isCia402Drive(device: ESIDevice): boolean { - return ( - pdosContainIndex(device.rxPdo, MANDATORY_INDICES[0]) && - pdosContainIndex(device.txPdo, MANDATORY_INDICES[1]) - ) + return pdosContainIndex(device.rxPdo, MANDATORY_INDICES[0]) && pdosContainIndex(device.txPdo, MANDATORY_INDICES[1]) } /** A resolved CiA 402 object: its role, IEC located address, and IEC type. */ diff --git a/src/middleware/shared/utils/ethercat/index.ts b/src/middleware/shared/utils/ethercat/index.ts new file mode 100644 index 000000000..da37cdab6 --- /dev/null +++ b/src/middleware/shared/utils/ethercat/index.ts @@ -0,0 +1,20 @@ +export { + CIA402_OBJECTS, + type Cia402AxisConfig, + type Cia402Role, + DEFAULT_CIA402_AXIS_CONFIG, + isCia402Drive, + normalizeObjectIndex, + resolveCia402Objects, + type ResolvedCia402Object, +} from './cia402' +export { + type AxisPlan, + collectAxes, + isValidIecIdentifier, + ROLE_BINDINGS, + type RoleBinding, + sanitizeAxisName, + serializeSoftMotionAxisGlobalsToST, + softMotionAxisNames, +} from './softmotion-axis-naming' diff --git a/src/middleware/shared/utils/ethercat/softmotion-axis-naming.ts b/src/middleware/shared/utils/ethercat/softmotion-axis-naming.ts new file mode 100644 index 000000000..9cbbf5007 --- /dev/null +++ b/src/middleware/shared/utils/ethercat/softmotion-axis-naming.ts @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Autonomy / OpenPLC Project +/** + * SoftMotion axis discovery & naming — the pure, platform-agnostic half of + * compile-time SoftMotion code generation (see + * `backend/shared/ethercat/generate-softmotion.ts` for the codegen itself). + * + * Lives here rather than alongside the codegen because it's also needed by + * the ST LSP (go-to-definition, ambient axis globals) and the store's device + * rename validation — none of which may import `backend/shared/` directly. + */ + +import type { PLCProjectData } from '@root/middleware/shared/ports/types' + +import type { Cia402Role } from './cia402' +import { resolveCia402Objects } from './cia402' + +/** + * Maps a CiA 402 object role to the SM_Drive_GenericDS402 pin it binds and the + * IEC type the located scalar is declared with. The scalar type is fixed to the + * bridge pin's type (not the ESI-declared type) so the generated FB call is + * always type-correct — the PDO byte width is identical either way (a 32-bit + * position reads the same 4 bytes as DINT or UDINT at the same %QD address). + * `pinKind` decides `:=` (FB input, drive feedback) vs `=>` (FB output, command). + */ +export interface RoleBinding { + pin: string + pinKind: 'input' | 'output' + iecType: string +} +export const ROLE_BINDINGS: Record = { + controlWord: { pin: 'wControlWord', pinKind: 'output', iecType: 'UINT' }, + modesOfOperation: { pin: 'siModes', pinKind: 'output', iecType: 'SINT' }, + targetPosition: { pin: 'diTargetPosition', pinKind: 'output', iecType: 'DINT' }, + profileVelocity: { pin: 'udiProfileVelocity', pinKind: 'output', iecType: 'UDINT' }, + targetVelocity: { pin: 'diTargetVelocity', pinKind: 'output', iecType: 'DINT' }, + targetTorque: { pin: 'iTargetTorque', pinKind: 'output', iecType: 'INT' }, + statusWord: { pin: 'wStatusWord', pinKind: 'input', iecType: 'UINT' }, + modesDisplay: { pin: 'siModesDisplay', pinKind: 'input', iecType: 'SINT' }, + positionActual: { pin: 'diActualPosition', pinKind: 'input', iecType: 'DINT' }, + velocityActual: { pin: 'diActualVelocity', pinKind: 'input', iecType: 'DINT' }, + torqueActual: { pin: 'iActualTorque', pinKind: 'input', iecType: 'INT' }, +} + +/** Sanitize a device name into a valid IEC 61131-3 identifier. */ +export function sanitizeAxisName(name: string): string { + let s = name.replace(/[^A-Za-z0-9_]/g, '_') + if (!/^[A-Za-z_]/.test(s)) s = `_${s}` + return s +} + +/** + * True when `name` is already a valid IEC 61131-3 identifier — a letter or + * underscore followed by letters, digits, or underscores. A SoftMotion drive's + * name IS the axis variable name used in `MC_*(Axis := )`, so it must + * satisfy this (no spaces, hyphens, or leading digits). + */ +export function isValidIecIdentifier(name: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) +} + +export interface AxisPlan { + axisName: string + scaleNum: number + scaleDenom: number + scaleFactor: number + objects: { role: Cia402Role; scalarName: string; iecLocation: string; binding: RoleBinding }[] +} + +/** Collect every opted-in, resolvable CiA 402 axis in the project. */ +export function collectAxes(project: PLCProjectData): AxisPlan[] { + const plans: AxisPlan[] = [] + const seen = new Set() + for (const rd of project.remoteDevices ?? []) { + if (rd.protocol !== 'ethercat') continue + for (const dev of rd.ethercatConfig?.devices ?? []) { + if (!dev.cia402?.enabled) continue + const resolved = resolveCia402Objects(dev.channelInfo ?? [], dev.channelMappings) + const hasControl = resolved.some((o) => o.role === 'controlWord') + const hasStatus = resolved.some((o) => o.role === 'statusWord') + // A drive can only be an axis if both mandatory objects are mapped. + if (!hasControl || !hasStatus) continue + + const axisName = sanitizeAxisName(dev.name) + // Skip a duplicate sanitized name to avoid emitting two globals with the + // same identifier (later devices lose — surfaced by compile if referenced). + if (seen.has(axisName.toUpperCase())) continue + seen.add(axisName.toUpperCase()) + + plans.push({ + axisName, + scaleNum: dev.cia402.scaleNum, + scaleDenom: dev.cia402.scaleDenom, + scaleFactor: dev.cia402.scaleFactor, + objects: resolved.map((o) => ({ + role: o.role, + scalarName: `${axisName}_${o.role}`, + iecLocation: o.iecLocation, + binding: ROLE_BINDINGS[o.role], + })), + }) + } + } + return plans +} + +/** Sanitized names of every enabled, resolvable CiA 402 axis in the project. */ +export function softMotionAxisNames(project: PLCProjectData): string[] { + return collectAxes(project).map((a) => a.axisName) +} + +/** + * Serialize the SoftMotion axis globals as a standalone ST configuration for the + * language server. Each CiA 402 drive becomes a `VAR_GLOBAL : AXIS_REF_SM3` + * so editor code referencing the axis (e.g. `MC_Power(Axis := X_Axis)`) resolves + * against the same public axis the compiler generates — without the user + * declaring anything. Returns '' when the project has no axes. + * + * Emitted as a **bare top-level `VAR_GLOBAL` block** (not wrapped in a + * CONFIGURATION): strucpp registers top-level global blocks into the ambient + * global scope, so a POU can reference the axis directly — no `VAR_EXTERNAL` + * needed, which is what keeps the editor documents byte-for-byte what the user + * wrote (no injected declarations shifting line numbers). Only the axis + * references are declared (not the located PDO scalar globals) — those are + * internal to the generated drive bridge and never named in user code. + * `AXIS_REF_SM3` itself comes from the bundled plcopen-softmotion stlib the LSP + * already ingests. + * + * The declaration order matches `softMotionAxisNames`, so line N+1 of this + * document (line 0 is `VAR_GLOBAL`) is axis N — the go-to-definition redirect + * relies on that to map a click back to its drive. + */ +export function serializeSoftMotionAxisGlobalsToST(project: PLCProjectData): string { + const axes = collectAxes(project) + if (axes.length === 0) return '' + + const decls = axes.map((a) => ` ${a.axisName} : AXIS_REF_SM3;`).join('\n') + return ['VAR_GLOBAL', decls, 'END_VAR', ''].join('\n') +} From 04fcf18db7573a9257943b6a4f354b19d1cb01a5 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 17 Jul 2026 08:29:12 -0400 Subject: [PATCH 28/52] fix(naming): validate element names as IEC identifiers + robust basename on load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent invalid POU / data-type / server / remote-device names (spaces, slashes, backslashes, reserved words) from reaching the file-path and parser layers — the origin of the "deleting function" corruption: a spaced FUNCTION name failed to parse, the name was rebuilt from the file path, and that path was injected back into the on-disk POU path, producing doubling (pous\functions\pous\functions\Name), orphaned files, and a missed delete on Windows. - shared/slice.ts: reject non-IEC-identifier names in renameElement (covers all rename types) and in every create/duplicate action, reusing isLegalIdentifier (the same primitive used for variable-name validation). - parse-project-files.ts: getBaseNameFromPath splits on both separators ([\\/]), matching detectPouTypeFromPath, so a parse-failure fallback yields the bare basename instead of the whole pous\functions\Name path on Windows. Together: bad names can no longer be created or renamed, and any residual parse failure (variables, imports, parser gaps) degrades to a clean basename rather than corrupting the name/path/deletion. Validated: web vitest shared-slice (151) + editor jest shared-slice & parse-project-files (205); tsc + eslint clean in both repos. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/parse-project-files.test.ts | 18 +++++++++ .../shared/utils/parse-project-files.ts | 7 +++- .../store/__tests__/shared-slice.test.ts | 40 +++++++++++++++++++ src/frontend/store/slices/shared/slice.ts | 35 ++++++++++++++++ 4 files changed, 99 insertions(+), 1 deletion(-) 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 dfbd21cdb..929a0f8b0 100644 --- a/src/backend/shared/utils/__tests__/parse-project-files.test.ts +++ b/src/backend/shared/utils/__tests__/parse-project-files.test.ts @@ -212,6 +212,24 @@ describe('parseProjectFiles — fallback POU creation', () => { consoleSpy.mockRestore() }) + it('derives the fallback name from the basename even for Windows backslash paths', () => { + // The desktop reader builds relativePaths with path.join → backslashes on + // Windows. A parse failure must still yield the bare basename, not the whole + // `pous\functions\...` path (the origin of the deleting-function corruption). + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const pouFiles: RawProjectFile[] = [ + { + relativePath: 'pous\\functions\\Siren_FC.st', + // Missing END_FUNCTION / return type → parser throws → fallback. + content: 'FUNCTION Siren_FC\nVAR_INPUT\n x : INT;\nEND_VAR\nbody', + }, + ] + const result = parseProjectFiles('/p', makeProjectJson(), makeDeviceConfig(), makePinMapping(), pouFiles, [], []) + expect(result.projectData.pous).toHaveLength(1) + expect(result.projectData.pous[0].name).toBe('Siren_FC') + consoleSpy.mockRestore() + }) + it('warns and preserves declarations when a PROGRAM has a located interface-class variable (issue #904)', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const pouFiles: RawProjectFile[] = [ diff --git a/src/backend/shared/utils/parse-project-files.ts b/src/backend/shared/utils/parse-project-files.ts index d4bff5f87..0564d583d 100644 --- a/src/backend/shared/utils/parse-project-files.ts +++ b/src/backend/shared/utils/parse-project-files.ts @@ -111,11 +111,16 @@ function getLanguageFromExt(relativePath: string): string | null { /** * Extract the base filename without extension from a relative path. + * + * Splits on BOTH separators: the desktop reader builds relative paths with + * `path.join`, which emits backslashes on Windows, so a `/`-only split would + * return the whole `pous\functions\Name` path as the "basename" — the origin of + * the POU name→path corruption in the "deleting function" bug. */ function getBaseNameFromPath(relativePath: string): string { return ( relativePath - .split('/') + .split(/[\\/]/) .pop() ?.replace(/\.\w+$/, '') ?? 'unknown' ) diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index ea20dba41..920b0d3da 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -119,6 +119,12 @@ describe('createSharedSlice', () => { expect(result.message).toBe('POU already exists') }) + it('rejects a POU name that is not a valid IEC identifier', () => { + const result = store.getState().pouActions.create({ type: 'program', name: 'Siren FC', language: 'st' }) + expect(result.ok).toBe(false) + expect(result.message).toContain("'Siren FC'") + }) + it('creates multiple POUs', () => { store.getState().pouActions.create({ type: 'program', name: 'Prog1', language: 'st' }) store.getState().pouActions.create({ type: 'function', name: 'Func1', language: 'il' }) @@ -230,6 +236,11 @@ describe('createSharedSlice', () => { expect(result.message).toBe('POU name already exists') }) + it('rejects renaming to a name with path separators (the deleting-function bug)', () => { + const result = store.getState().pouActions.rename('OldName', 'pous\\functions\\Siren FC') + expect(result.ok).toBe(false) + }) + it('updates editor name if current editor matches old name', () => { // OldName is the current editor expect(store.getState().editor.meta.name).toBe('OldName') @@ -273,6 +284,11 @@ describe('createSharedSlice', () => { expect(result.message).toBe('POU name already exists') }) + it('rejects duplicating to an invalid IEC identifier', () => { + const result = store.getState().pouActions.duplicate('Source', 'bad name') + expect(result.ok).toBe(false) + }) + it('duplicates a function and preserves returnType', () => { // Create a function source store.getState().pouActions.create({ type: 'function', name: 'FuncSrc', language: 'st' }) @@ -446,6 +462,11 @@ describe('createSharedSlice', () => { expect(result.ok).toBe(false) expect(result.message).toBe('Data type already exists') }) + + it('rejects a data type name that is not a valid IEC identifier', () => { + const result = store.getState().datatypeActions.create({ name: 'bad name', derivation: 'array' }) + expect(result.ok).toBe(false) + }) }) // ----------------------------------------------------------------------- @@ -567,6 +588,11 @@ describe('createSharedSlice', () => { expect(result.ok).toBe(false) expect(result.message).toBe('Data type name already exists') }) + + it('rejects duplicating to an invalid IEC identifier', () => { + const result = store.getState().datatypeActions.duplicate('SourceDT', 'bad name') + expect(result.ok).toBe(false) + }) }) }) @@ -590,6 +616,13 @@ describe('createSharedSlice', () => { // ----------------------------------------------------------------------- // deleteRequest // ----------------------------------------------------------------------- + describe('create', () => { + it('rejects a server name that is not a valid IEC identifier', () => { + const result = store.getState().serverActions.create({ name: 'bad name', protocol: 'modbus-tcp' }) + expect(result.ok).toBe(false) + }) + }) + describe('deleteRequest', () => { it('opens the confirm-delete-element modal with server elementType', () => { store.getState().serverActions.deleteRequest('Server1') @@ -694,6 +727,13 @@ describe('createSharedSlice', () => { // ----------------------------------------------------------------------- // deleteRequest // ----------------------------------------------------------------------- + describe('create', () => { + it('rejects a remote device name that is not a valid IEC identifier', () => { + const result = store.getState().remoteDeviceActions.create({ name: 'bad name', protocol: 'modbus-tcp' }) + expect(result.ok).toBe(false) + }) + }) + describe('deleteRequest', () => { it('opens the confirm-delete-element modal with remote-device elementType', () => { store.getState().remoteDeviceActions.deleteRequest('Device1') diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 70a6c96af..9f42025fd 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -6,6 +6,7 @@ import { isValidIecIdentifier } from '../../../../middleware/shared/utils/etherc 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 { isLegalIdentifier } from '../../../utils/keywords' import { restampFlowLibraryVariants } from '../../../utils/PLC/restamp-library-variants' import { collectAllSlaveNames } from '../../../utils/unique-slave-name' import type { FBDFlowType } from '../fbd' @@ -43,6 +44,19 @@ function deleteElement( return { ok: true as const } } +/** + * Reject element names that aren't valid IEC 61131-3 identifiers before they + * reach the file-path / parser layer. A name containing a space, slash, or + * backslash would otherwise be written straight into the on-disk POU path + * (`pous//.st`) and — on any parse failure — read back as the + * path itself, corrupting the name and orphaning files (the "deleting function" + * bug). Reuses the same primitive as variable-name validation for consistency. + */ +function validateElementName(name: string): { ok: true } | { ok: false; message: string } { + const [legal, reason] = isLegalIdentifier(name) + return legal ? { ok: true } : { ok: false, message: `'${name}' ${reason}` } +} + function renameElement( state: SharedRootState, oldName: string, @@ -50,6 +64,9 @@ function renameElement( updateInProject: (oldName: string, newName: string) => { ok: boolean; message?: string } | void, afterRename?: (oldName: string, newName: string) => void, ) { + const nameCheck = validateElementName(newName) + if (!nameCheck.ok) return { ok: false as const, message: nameCheck.message } + const result = updateInProject(oldName, newName) if (result && !result.ok) return { ok: false as const, message: result.message } @@ -82,6 +99,9 @@ const createSharedSlice: StateCreator = (s const existing = state.project.data.pous.find((p) => p.name === name) if (existing) return { ok: false, message: 'POU already exists' } + const nameCheck = validateElementName(name) + if (!nameCheck.ok) return nameCheck + const pouDto = createPouObject({ type, name, language }) const result = state.projectActions.createPou(pouDto) /* istanbul ignore next -- defensive: shared slice already validates name uniqueness */ @@ -155,6 +175,9 @@ const createSharedSlice: StateCreator = (s const existing = state.project.data.pous.find((p) => p.name === newName) if (existing) return { ok: false, message: 'POU name already exists' } + const nameCheck = validateElementName(newName) + if (!nameCheck.ok) return nameCheck + // Create a copy of the POU with the new name const language = sourcePou.body.language as 'il' | 'st' | 'ld' | 'sfc' | 'fbd' | 'python' | 'cpp' const pouDto = createPouObject({ type: sourcePou.pouType, name: newName, language }) @@ -203,6 +226,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 nameCheck = validateElementName(name) + if (!nameCheck.ok) return nameCheck + const datatype = createDatatypeObject({ name, derivation }) const result = state.projectActions.createDatatype({ data: datatype }) /* istanbul ignore next -- defensive: shared slice already validates name uniqueness */ @@ -254,6 +280,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 nameCheck = validateElementName(newName) + if (!nameCheck.ok) return nameCheck + const copy = { ...source, name: newName } const result = state.projectActions.createDatatype({ data: copy }) /* istanbul ignore next -- defensive: shared slice already validates name uniqueness */ @@ -274,6 +303,9 @@ const createSharedSlice: StateCreator = (s const servers = state.project.data.servers ?? [] if (servers.some((s) => s.name === name)) return { ok: false, message: 'Server already exists' } + const nameCheck = validateElementName(name) + if (!nameCheck.ok) return nameCheck + const result = state.projectActions.createServer({ data: { name, protocol } }) /* istanbul ignore next -- defensive: shared slice already validates name uniqueness */ if (!result.ok) return { ok: false, message: result.message } @@ -308,6 +340,9 @@ const createSharedSlice: StateCreator = (s const devices = state.project.data.remoteDevices ?? [] if (devices.some((d) => d.name === name)) return { ok: false, message: 'Remote device already exists' } + const nameCheck = validateElementName(name) + if (!nameCheck.ok) return nameCheck + const result = state.projectActions.createRemoteDevice({ data: { name, protocol } }) /* istanbul ignore next -- defensive: shared slice already validates name uniqueness */ if (!result.ok) return { ok: false, message: result.message } From ae1e53c721bc3e64b48c812ccf1821d6e6939fec Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 17 Jul 2026 10:59:07 -0400 Subject: [PATCH 29/52] fix(project): persist structural changes only on save (unify web + desktop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete/rename/duplicate of POUs, data types, servers, and remote devices were inconsistently auto-saving the whole project — delete + duplicate always, rename only on web (hasVersionControl gate). On desktop this defeated the soft-delete / undo model: the deletion persisted immediately and exiting never prompted to save. Introduced in PR #742 (version control): the desktop gate was added to rename but never back-ported to delete/duplicate. Make every structural mutation behave identically on both editors — mutate the in-memory project, queue file removals in pendingDeletions, and flag the workspace dirty. Nothing is written to disk until the user saves. - delete-confirmation-modal: drop the needsPersist / executeSaveProject auto-save. - project-tree: drop the rename + duplicate auto-save (all sites) and the now-unused version-control/project-port plumbing. - shared/slice.ts: deleteElement + renameElement + both duplicate actions now mark the workspace dirty (editingState = "unsaved"), matching create. Follow-up (separate branch): web exit-interception (in-app back -> save dialog + beforeunload backstop) so an unsaved web session warns before navigating away. Validated: web vitest + editor jest shared-slice (148 each); tsc + eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../_molecules/project-tree/index.tsx | 77 ++++--------------- .../modals/delete-confirmation-modal.tsx | 23 ++---- .../store/__tests__/shared-slice.test.ts | 24 ++++++ src/frontend/store/slices/shared/slice.ts | 18 +++++ 4 files changed, 62 insertions(+), 80 deletions(-) diff --git a/src/frontend/components/_molecules/project-tree/index.tsx b/src/frontend/components/_molecules/project-tree/index.tsx index 66e99ce5a..73fb7f2bf 100644 --- a/src/frontend/components/_molecules/project-tree/index.tsx +++ b/src/frontend/components/_molecules/project-tree/index.tsx @@ -1,7 +1,6 @@ import * as Popover from '@radix-ui/react-popover' import { ComponentPropsWithoutRef, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useCapabilities, useProject } from '../../../../middleware/shared/providers' import { ArrowIcon } from '../../../assets/icons/interface/Arrow' import { CloseIcon } from '../../../assets/icons/interface/Close' import { ConfigIcon } from '../../../assets/icons/interface/Config' @@ -31,7 +30,6 @@ import { ServerIcon } from '../../../assets/icons/project/Server' import { SFCIcon } from '../../../assets/icons/project/SFC' import { STIcon } from '../../../assets/icons/project/ST' import { StructureIcon } from '../../../assets/icons/project/Structure' -import { executeSaveProject } from '../../../services/save-actions' import { useOpenPLCStore } from '../../../store' import { WorkspaceProjectTreeLeafType } from '../../../store/slices/workspace/types' import { cn } from '../../../utils/cn' @@ -266,9 +264,6 @@ const ProjectTreeExpandableLeaf = ({ remoteDeviceActions: { deleteRequest: deleteRemoteDeviceRequest, rename: renameRemoteDevice }, fileActions: { getFile }, } = useOpenPLCStore() - const projectPort = useProject() - const capabilities = useCapabilities() - const { hasVersionControl } = capabilities const [isExpanded, setIsExpanded] = useState(true) const [isEditing, setIsEditing] = useState(false) @@ -287,7 +282,7 @@ const ProjectTreeExpandableLeaf = ({ setSelectedProjectTreeLeaf({ label, type: leafType }) } - const handleRenameFile = async (renamed: string) => { + const handleRenameFile = (renamed: string) => { setIsEditing(false) if (!renamed || !label) return if (renamed === label) return @@ -296,15 +291,8 @@ const ProjectTreeExpandableLeaf = ({ setNewLabel(label || '') return } - // Only auto-persist on platforms that track per-file changes — otherwise - // the user's first action on a fresh project triggers a full save with - // no version-control benefit. Local editor users keep the existing - // "save on Ctrl+S" mental model. - if (hasVersionControl) { - // Persist immediately so refresh doesn't show the old name (rename - // queues the old path's deletion in `pendingDeletions`, save propagates). - await executeSaveProject(projectPort, capabilities) - } + // Soft, unsaved change: renameElement flags the workspace dirty; the rename + // persists on the next save, consistently on web and desktop. } const handleDeleteFile = () => { @@ -519,9 +507,6 @@ const ProjectTreeLeaf = ({ ethercatDeviceActions: { delete: deleteEthercatDevice, rename: renameEthercatDevice }, fileActions: { getFile }, } = useOpenPLCStore() - const projectPort = useProject() - const capabilities = useCapabilities() - const { hasVersionControl } = capabilities const [isEditing, setIsEditing] = useState(false) const [newLabel, setNewLabel] = useState(label || '') @@ -557,7 +542,7 @@ const ProjectTreeLeaf = ({ setSelectedProjectTreeLeaf({ label, type: leafType }) } - const handleRenameFile = async (newLabel: string) => { + const handleRenameFile = (newLabel: string) => { setIsEditing(false) if (!isAPou && !isDatatype && !isServer && !isRemoteDevice && !isEthercatDevice) { @@ -578,60 +563,33 @@ const ProjectTreeLeaf = ({ return } - // No-op: user blurred or hit Enter without changing anything. Skip the - // auto-save below so we don't persist a phantom rename event. + // No-op: user blurred or hit Enter without changing anything. if (newLabel === label) return - // Auto-save on rename only matters on platforms that track per-file - // changes (web). Local editor users would otherwise eat a full project - // save on every rename with no version-control payoff — so gate the - // persist behind the capability and let the editor follow the regular - // Ctrl+S flow. - const persist = async () => { - if (hasVersionControl) await executeSaveProject(projectPort, capabilities) - } - + // Renames are soft, unsaved changes: renameElement flags the workspace + // dirty and queues the old path in `pendingDeletions`. Nothing is written + // to disk until the user saves — identical on web and desktop. if (isAPou) { const res = renamePou(label, newLabel) - if (!res.ok) { - setNewLabel(label || '') - return - } - // Persist immediately: rename creates a new file in S3 and removes - // the old, plus updates the badge correctly via pendingDeletions. - await persist() + if (!res.ok) setNewLabel(label || '') return } if (isDatatype) { const res = renameDatatype(label, newLabel) - if (!res.ok) { - setNewLabel(label || '') - return - } - // Datatype lives inside project.json — saving rewrites it with the - // renamed entry. No separate file deletion needed. - await persist() + if (!res.ok) setNewLabel(label || '') return } if (isServer) { const res = renameServer(label, newLabel) - if (!res.ok) { - setNewLabel(label || '') - return - } - await persist() + if (!res.ok) setNewLabel(label || '') return } if (isRemoteDevice) { const res = renameRemoteDevice(label, newLabel) - if (!res.ok) { - setNewLabel(label || '') - return - } - await persist() + if (!res.ok) setNewLabel(label || '') return } @@ -648,7 +606,7 @@ const ProjectTreeLeaf = ({ } } - const handleDuplicateFile = async () => { + const handleDuplicateFile = () => { if (!isAPou && !isDatatype) { toast({ title: 'Error', @@ -667,20 +625,15 @@ const ProjectTreeLeaf = ({ return } + // Duplicating is a soft, unsaved change: the shared duplicate actions flag + // the new element dirty; it persists on the next save, like create. if (isAPou) { duplicatePou(label, `${label}_copy`) - // Persist the new POU file to S3 immediately. Without this, the duplicate - // exists only in editor memory and disappears on refresh — same class of - // bug as the delete flow we fixed in delete-confirmation-modal. - await executeSaveProject(projectPort, capabilities) return } if (isDatatype) { duplicateDatatype(label, `${label}_copy`) - // Datatypes live inside project.json; saving the project rewrites it - // with the new datatype included. - await executeSaveProject(projectPort, capabilities) return } diff --git a/src/frontend/components/_organisms/modals/delete-confirmation-modal.tsx b/src/frontend/components/_organisms/modals/delete-confirmation-modal.tsx index ed33c5050..3b78e4dab 100644 --- a/src/frontend/components/_organisms/modals/delete-confirmation-modal.tsx +++ b/src/frontend/components/_organisms/modals/delete-confirmation-modal.tsx @@ -1,7 +1,5 @@ import type { PLCVariable } from '../../../../middleware/shared/ports/types' -import { useCapabilities, useProject } from '../../../../middleware/shared/providers' import { WarningIcon } from '../../../assets/icons/interface/Warning' -import { executeSaveProject } from '../../../services/save-actions' import { useOpenPLCStore } from '../../../store' import type { RungLadderState } from '../../../store/slices/ladder' import { BasicNodeData } from '../../_atoms/graphical-editor/ladder/utils/types' @@ -67,8 +65,6 @@ function resolveDeleteTarget( const ConfirmDeleteElementModal = ({ rung, isOpen, ...rest }: ConfirmDeleteElementProps) => { const store = useOpenPLCStore() - const projectPort = useProject() - const capabilities = useCapabilities() const { editor, project: { @@ -147,8 +143,7 @@ const ConfirmDeleteElementModal = ({ rung, isOpen, ...rest }: ConfirmDeleteEleme }) } - const handleDeleteElement = async (): Promise => { - let needsPersist = false + const handleDeleteElement = (): void => { try { // Ladder rung deletion: takes precedence when a rung is provided. // Rung deletes are an in-editor edit, not a file-level deletion — they @@ -175,7 +170,6 @@ const ConfirmDeleteElementModal = ({ rung, isOpen, ...rest }: ConfirmDeleteEleme description: `POU "${name}" was successfully deleted.`, variant: 'default', }) - needsPersist = true break case 'datatype': deleteDatatypeAction(name) @@ -194,7 +188,6 @@ const ConfirmDeleteElementModal = ({ rung, isOpen, ...rest }: ConfirmDeleteEleme description: `Server "${name}" was successfully deleted.`, variant: 'default', }) - needsPersist = true break case 'remote-device': deleteRemoteDeviceAction(name) @@ -203,7 +196,6 @@ const ConfirmDeleteElementModal = ({ rung, isOpen, ...rest }: ConfirmDeleteEleme description: `Remote device "${name}" was successfully deleted.`, variant: 'default', }) - needsPersist = true break default: throw new Error('Unknown element type') @@ -216,16 +208,11 @@ const ConfirmDeleteElementModal = ({ rung, isOpen, ...rest }: ConfirmDeleteEleme }) } + // Deletions are soft and consistent across web and desktop: the element is + // removed from the in-memory project, its file removal is queued in + // `pendingDeletions`, and the workspace is flagged dirty (see deleteElement). + // Nothing is written to disk until the user saves. closeModal() - - // For element types that map to a file in S3 (POU, server, remote device), - // trigger a full project save so the deletion is actually persisted. - // Otherwise the file stays in S3 and reappears on refresh — only the - // local UI state had been updated. executeSaveProject surfaces its own - // success/failure toasts. - if (needsPersist) { - await executeSaveProject(projectPort, capabilities) - } } const handleCloseModal = () => { diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index ea20dba41..6882ab496 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -180,6 +180,12 @@ describe('createSharedSlice', () => { expect(state.libraries.user).toHaveLength(0) }) + it('flags the workspace dirty after delete (persist only on save)', () => { + store.getState().workspaceActions.setEditingState('saved') + store.getState().pouActions.delete('Main') + expect(store.getState().workspace.editingState).toBe('unsaved') + }) + it('clears editor if current editor matches deleted POU', () => { // Main should be the current editor after create expect(store.getState().editor.meta.name).toBe('Main') @@ -223,6 +229,12 @@ describe('createSharedSlice', () => { expect(state.libraries.user[0].name).toBe('NewName') }) + it('flags the workspace dirty after rename (persist only on save)', () => { + store.getState().workspaceActions.setEditingState('saved') + store.getState().pouActions.rename('OldName', 'NewName') + expect(store.getState().workspace.editingState).toBe('unsaved') + }) + it('returns error when new name already exists', () => { store.getState().pouActions.create({ type: 'function', name: 'Existing', language: 'st' }) const result = store.getState().pouActions.rename('OldName', 'Existing') @@ -260,6 +272,12 @@ describe('createSharedSlice', () => { expect(state.files['Copy'].isNew).toBe(true) }) + it('flags the workspace dirty after duplicate (persist only on save)', () => { + store.getState().workspaceActions.setEditingState('saved') + store.getState().pouActions.duplicate('Source', 'Copy') + expect(store.getState().workspace.editingState).toBe('unsaved') + }) + it('returns error when source POU does not exist', () => { const result = store.getState().pouActions.duplicate('NonExistent', 'Copy') expect(result.ok).toBe(false) @@ -555,6 +573,12 @@ describe('createSharedSlice', () => { expect(state.files['CopyDT']).toBeDefined() }) + it('flags the workspace dirty after duplicate (persist only on save)', () => { + store.getState().workspaceActions.setEditingState('saved') + store.getState().datatypeActions.duplicate('SourceDT', 'CopyDT') + expect(store.getState().workspace.editingState).toBe('unsaved') + }) + it('returns error when source data type does not exist', () => { const result = store.getState().datatypeActions.duplicate('NonExistent', 'Copy') expect(result.ok).toBe(false) diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 70a6c96af..5fe4d4eb5 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -40,6 +40,13 @@ function deleteElement( if (currentEditor.type !== 'available' && currentEditor.meta.name === name) { state.editorActions.clearEditor() } + + // A delete is an unsaved structural change (the file removal is queued in + // `pendingDeletions`). Flag the workspace dirty so it persists ONLY on the + // next save — identical on web and desktop. The file entry is already gone, + // so mark the workspace directly rather than via a file-scoped helper. + state.workspaceActions.setEditingState('unsaved') + return { ok: true as const } } @@ -70,6 +77,11 @@ function renameElement( afterRename?.(oldName, newName) + // A rename is an unsaved structural change — flag it dirty (the renamed file + // now lives under `newName`) so it persists ONLY on the next save, identical + // on web and desktop. + state.sharedWorkspaceActions.handleFileAndWorkspaceSavedState(newName) + return { ok: true as const } } @@ -193,6 +205,9 @@ const createSharedSlice: StateCreator = (s state.editorActions.addModel(editorModel) state.fileActions.addFile({ name: newName, type: sourcePou.pouType, filePath: newName, isNew: true }) + // Persist only on save: flag the new POU dirty instead of auto-saving. + state.sharedWorkspaceActions.handleFileAndWorkspaceSavedState(newName) + return { ok: true } }, }, @@ -263,6 +278,9 @@ const createSharedSlice: StateCreator = (s state.editorActions.addModel(editorModel) state.fileActions.addFile({ name: newName, type: 'data-type', filePath: newName, isNew: true }) + // Persist only on save: flag the new datatype dirty instead of auto-saving. + state.sharedWorkspaceActions.handleFileAndWorkspaceSavedState(newName) + return { ok: true } }, }, From c8120813f8345d5ead6a9ec4f21719bfdf6b75f4 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 17 Jul 2026 15:33:35 -0400 Subject: [PATCH 30/52] feat: web unsaved-changes beforeunload guard (shared accelerator-handler parity) Mirror of the openplc-web exit-guard change: a web-only beforeunload handler warns on tab close / refresh when the workspace is dirty, reading the live store value to avoid double-prompting on intentional in-app exits. Gated on !isNativeApplication, so it is inert on the desktop app; shipped to keep the shared accelerator-handler byte-identical across repos. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../_templates/accelerator-handler.tsx | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/frontend/components/_templates/accelerator-handler.tsx b/src/frontend/components/_templates/accelerator-handler.tsx index 0fb1aa25b..1d5136a1e 100644 --- a/src/frontend/components/_templates/accelerator-handler.tsx +++ b/src/frontend/components/_templates/accelerator-handler.tsx @@ -9,7 +9,7 @@ import { useWindow, } from '../../../middleware/shared/providers' import { executeSaveActiveFile, executeSaveProject } from '../../services/save-actions' -import { useOpenPLCStore } from '../../store' +import { openPLCStoreBase, useOpenPLCStore } from '../../store' import type { ModalTypes } from '../../store/slices/modal' import { toast } from '../_features/[app]/toast/use-toast' @@ -404,6 +404,35 @@ const AcceleratorHandler = () => { windowPort, ]) + /** + * beforeunload warning on web (tab close / refresh / hard navigation away). + * + * Browsers only permit the generic native "Leave site?" prompt here — no + * custom dialog and no async save — so this is the best-effort backstop for + * raw browser exits. In-app navigation (the sidebar back button) still gets + * the real save-changes dialog via closeProject(). Desktop is handled by the + * Electron close lifecycle in the effect above; this one is web-only. + */ + useEffect(() => { + if (capabilities.isNativeApplication) return + + const handler = (e: BeforeUnloadEvent) => { + // Read the live store value, not the React closure: an intentional in-app + // exit (save-changes modal → clearAndClose sets editingState) updates the + // Zustand store synchronously right before navigating, so reading it here + // avoids double-prompting (custom dialog + generic prompt) on that path. + if (openPLCStoreBase.getState().workspace.editingState !== 'unsaved') return + // Setting returnValue (and calling preventDefault) is what triggers the + // browser's generic unsaved-changes prompt; the string is ignored by + // modern browsers. + e.preventDefault() + e.returnValue = '' + } + + window.addEventListener('beforeunload', handler) + return () => window.removeEventListener('beforeunload', handler) + }, [capabilities.isNativeApplication]) + return <> } From 3aa662b787d8becb1fa9ddd678de186103844d81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Fri, 17 Jul 2026 17:24:55 -0300 Subject: [PATCH 31/52] perf(graphical-editor): scope ladder selection to the affected rung (DOPE-489) Replace full-array rebuilds in the ladder/FBD selection and addNode actions with conditional in-place draft writes so immer preserves the identity of untouched rungs and nodes; skip value-equal viewport writes; read onNodesChange state from the functional updater. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N6gbZbAZWewQuuQLfZZL7i --- .../graphical-editor/ladder/rung/body.tsx | 62 +++++++------ .../store/__tests__/fbd-slice.test.ts | 15 ++++ .../store/__tests__/ladder-slice.test.ts | 48 ++++++++++ src/frontend/store/slices/fbd/slice.ts | 76 +++++----------- src/frontend/store/slices/ladder/slice.ts | 90 +++++++------------ 5 files changed, 151 insertions(+), 140 deletions(-) diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx index 6d57adb9e..4cc95fd7f 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx @@ -140,10 +140,14 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi if (bounds.width < defaultWidth) bounds.width = defaultWidth if (bounds.height < defaultHeight) bounds.height = defaultHeight - setReactFlowPanelExtent([ - [0, 0], - [bounds.width, bounds.height + 20], - ]) + setReactFlowPanelExtent((prev) => + prev[1][0] === bounds.width && prev[1][1] === bounds.height + 20 + ? prev + : [ + [0, 0], + [bounds.width, bounds.height + 20], + ], + ) ladderFlowActions.updateReactFlowViewport({ editorName: pouName, rungId: rungLocal.id, @@ -724,35 +728,37 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi * It is used to update the local rung state */ const onNodesChange: OnNodesChange = useStableCallback((changes) => { - let selectedNodes: FlowNode[] = rungLocal.nodes.filter((node) => node.selected) - changes.forEach((change) => { - switch (change.type) { - case 'select': { - const node = rungLocal.nodes.find((n) => n.id === change.id) as FlowNode - if (change.selected) { - selectedNodes.push(node) + setRungLocal((rung) => { + let selectedNodes: FlowNode[] = rung.nodes.filter((node) => node.selected) + changes.forEach((change) => { + switch (change.type) { + case 'select': { + const node = rung.nodes.find((n) => n.id === change.id) as FlowNode + if (change.selected) { + selectedNodes.push(node) + return + } + + selectedNodes = selectedNodes.filter((n) => n.id !== change.id) + return + } + case 'add': { + selectedNodes = [] + return + } + case 'remove': { + selectedNodes = selectedNodes.filter((n) => n.id !== change.id) return } - - selectedNodes = selectedNodes.filter((n) => n.id !== change.id) - return - } - case 'add': { - selectedNodes = [] - return - } - case 'remove': { - selectedNodes = selectedNodes.filter((n) => n.id !== change.id) - return } + }) + + return { + ...rung, + nodes: applyNodeChanges(changes, rung.nodes), + selectedNodes: selectedNodes, } }) - - setRungLocal((rung) => ({ - ...rung, - nodes: applyNodeChanges(changes, rungLocal.nodes), - selectedNodes: selectedNodes, - })) }) /** diff --git a/src/frontend/store/__tests__/fbd-slice.test.ts b/src/frontend/store/__tests__/fbd-slice.test.ts index c2270d759..f243fd091 100644 --- a/src/frontend/store/__tests__/fbd-slice.test.ts +++ b/src/frontend/store/__tests__/fbd-slice.test.ts @@ -398,6 +398,21 @@ describe('createFBDFlowSlice', () => { expect(flow.rung.selectedNodes[0].id).toBe('n2') }) + it('setSelectedNodes keeps unchanged nodes identity-stable on repeated selection', () => { + store.getState().fbdFlowActions.startFBDRung({ editorName: 'editor-1' }) + const n1 = makeNode({ id: 'n1', data: { draggable: true } }) + const n2 = makeNode({ id: 'n2', data: { draggable: true } }) + store.getState().fbdFlowActions.setNodes({ editorName: 'editor-1', nodes: [n1, n2] }) + + // First call normalizes selected flags on both nodes. + store.getState().fbdFlowActions.setSelectedNodes({ editorName: 'editor-1', nodes: [n1] }) + const nodesAfterFirst = store.getState().fbdFlows[0].rung.nodes + + // A repeated identical selection must not rebuild the nodes array. + store.getState().fbdFlowActions.setSelectedNodes({ editorName: 'editor-1', nodes: [n1] }) + expect(store.getState().fbdFlows[0].rung.nodes).toBe(nodesAfterFirst) + }) + it('removeSelectedNode does nothing for nonexistent editor', () => { store.getState().fbdFlowActions.removeSelectedNode({ editorName: 'nonexistent', node: makeNode() }) expect(store.getState().fbdFlows).toEqual([]) diff --git a/src/frontend/store/__tests__/ladder-slice.test.ts b/src/frontend/store/__tests__/ladder-slice.test.ts index f8151b97e..51ee515f2 100644 --- a/src/frontend/store/__tests__/ladder-slice.test.ts +++ b/src/frontend/store/__tests__/ladder-slice.test.ts @@ -689,6 +689,41 @@ describe('createLadderFlowSlice', () => { expect(flow.rungs[1].selectedNodes).toEqual([]) }) + it('setSelectedNodes keeps untouched sibling rungs and unchanged nodes identity-stable', () => { + const n1 = makeNode({ id: 'n1', data: { draggable: true } }) + store.getState().ladderFlowActions.addLadderFlow( + makeFlow({ + name: 'editor-1', + rungs: [makeRung({ id: 'rung-1', nodes: [n1] }), makeRung({ id: 'rung-2' })], + }), + ) + + // First call normalizes selected/draggable flags everywhere. + store.getState().ladderFlowActions.setSelectedNodes({ editorName: 'editor-1', rungId: 'rung-1', nodes: [n1] }) + const flowAfterFirst = store.getState().ladderFlows[0] + const siblingRung = flowAfterFirst.rungs[1] + const targetNodes = flowAfterFirst.rungs[0].nodes + + // A repeated selection must not rebuild the sibling rung or the target rung's nodes. + store.getState().ladderFlowActions.setSelectedNodes({ editorName: 'editor-1', rungId: 'rung-1', nodes: [n1] }) + const flowAfterSecond = store.getState().ladderFlows[0] + expect(flowAfterSecond.rungs[1]).toBe(siblingRung) + expect(flowAfterSecond.rungs[0].nodes).toBe(targetNodes) + }) + + it('addNode leaves already-deselected sibling nodes identity-stable', () => { + const existing = { ...makeNode({ id: 'n1' }), selected: false } + seedFlowWithRung(store, 'editor-1', makeRung({ nodes: [existing] })) + + store + .getState() + .ladderFlowActions.addNode({ editorName: 'editor-1', rungId: 'rung-1', node: makeNode({ id: 'n2' }) }) + + const updatedRung = store.getState().ladderFlows[0].rungs[0] + expect(updatedRung.nodes.find((n) => n.id === 'n1')).toBe(existing) + expect(updatedRung.nodes.find((n) => n.id === 'n2')?.selected).toBe(true) + }) + // ------------------------------------------------------------------------- // setEdges // ------------------------------------------------------------------------- @@ -770,6 +805,19 @@ describe('createLadderFlowSlice', () => { expect(store.getState().ladderFlows[0].rungs[0].reactFlowViewport).toEqual([800, 200]) }) + it('updateReactFlowViewport skips value-equal writes to keep rung identity stable', () => { + seedFlowWithRung(store) + const rungBefore = store.getState().ladderFlows[0].rungs[0] + + store.getState().ladderFlowActions.updateReactFlowViewport({ + editorName: 'editor-1', + rungId: 'rung-1', + reactFlowViewport: [800, 200], + }) + + expect(store.getState().ladderFlows[0].rungs[0]).toBe(rungBefore) + }) + // ------------------------------------------------------------------------- // setFlowUpdated // ------------------------------------------------------------------------- diff --git a/src/frontend/store/slices/fbd/slice.ts b/src/frontend/store/slices/fbd/slice.ts index ae6defa17..2d1f97094 100644 --- a/src/frontend/store/slices/fbd/slice.ts +++ b/src/frontend/store/slices/fbd/slice.ts @@ -1,4 +1,4 @@ -import { addEdge, Node } from '@xyflow/react' +import { addEdge } from '@xyflow/react' import { produce } from 'immer' import { StateCreator } from 'zustand' @@ -155,24 +155,14 @@ export const createFBDFlowSlice: StateCreator flow.name === editorName) if (!flow) return - flow.rung.nodes.push(node) - - const selectedNodes: Node[] = [] - flow.rung.nodes = flow.rung.nodes.map((n) => { - if (n.id === node.id) { - selectedNodes.push(n) - return { - ...n, - selected: true, - } - } - return { - ...n, - selected: false, - } - }) + // Conditional in-place writes: immer only copies nodes whose values + // actually change, so unchanged siblings keep their identity. + for (const n of flow.rung.nodes) { + if (n.selected !== false) n.selected = false + } + flow.rung.nodes.push({ ...node, selected: true }) - flow.rung.selectedNodes = selectedNodes + flow.rung.selectedNodes = [node] flow.updated = true }), ) @@ -205,16 +195,12 @@ export const createFBDFlowSlice: StateCreator { - if (n.id === node.id) { - return { - ...n, - selected: true, - draggable: (node.data as { draggable?: boolean }).draggable, - } - } - return n - }) + const target = flow.rung.nodes.find((n) => n.id === node.id) + if (target) { + const draggable = (node.data as { draggable?: boolean }).draggable + if (target.selected !== true) target.selected = true + if (target.draggable !== draggable) target.draggable = draggable + } }), ) }, @@ -227,15 +213,8 @@ export const createFBDFlowSlice: StateCreator n.id !== node.id) flow.rung.selectedNodes = selectedNodes - flow.rung.nodes = flow.rung.nodes.map((n) => { - if (n.id === node.id) { - return { - ...n, - selected: false, - } - } - return n - }) + const target = flow.rung.nodes.find((n) => n.id === node.id) + if (target && target.selected !== false) target.selected = false }), ) }, @@ -246,23 +225,16 @@ export const createFBDFlowSlice: StateCreator { - if (selectedNodes.find((n) => n.id === node.id)) { - return { - ...node, - selected: true, - draggable: (node.data as { draggable?: boolean }).draggable, - } - } - return { - ...node, - selected: false, - draggable: (node.data as { draggable?: boolean }).draggable, - } - }) + // Conditional in-place writes: immer only copies nodes whose values + // actually change, so unchanged siblings keep their identity. + for (const node of flow.rung.nodes) { + const isSelected = selectedNodes.some((n) => n.id === node.id) + const draggable = (node.data as { draggable?: boolean }).draggable + if (node.selected !== isSelected) node.selected = isSelected + if (node.draggable !== draggable) node.draggable = draggable + } }), ) }, diff --git a/src/frontend/store/slices/ladder/slice.ts b/src/frontend/store/slices/ladder/slice.ts index a5ce132a6..a5aa1059e 100644 --- a/src/frontend/store/slices/ladder/slice.ts +++ b/src/frontend/store/slices/ladder/slice.ts @@ -320,19 +320,12 @@ export const createLadderFlowSlice: StateCreator rung.id === rungId) if (!rung) return - rung.nodes.push(node) - rung.nodes = rung.nodes.map((n) => { - if (n.id === node.id) { - return { - ...n, - selected: true, - } - } - return { - ...n, - selected: false, - } - }) + // Conditional in-place writes: immer only copies nodes whose values + // actually change, so unchanged siblings keep their identity. + for (const n of rung.nodes) { + if (n.selected !== false) n.selected = false + } + rung.nodes.push({ ...node, selected: true }) flow.updated = true }), @@ -365,59 +358,28 @@ export const createLadderFlowSlice: StateCreator 1) { - rung.nodes = rung.nodes.map((node) => { - if (selectedNodes.find((n) => n.id === node.id)) { - return { - ...node, - selected: true, - draggable: false, - } - } - return { - ...node, - selected: false, - draggable: false, - } - }) - } else { - rung.nodes = rung.nodes.map((node) => { - if (selectedNodes.find((n) => n.id === node.id)) { - return { - ...node, - selected: true, - draggable: (node.data as { draggable?: boolean }).draggable, - } - } - return { - ...node, - selected: false, - draggable: (node.data as { draggable?: boolean }).draggable, - } - }) + // Conditional in-place writes: immer only copies nodes whose values + // actually change, so unchanged siblings (and untouched rungs below) + // keep their identity instead of the previous all-rungs rebuild. + const multiSelect = selectedNodes.length > 1 + for (const node of rung.nodes) { + const isSelected = selectedNodes.some((n) => n.id === node.id) + const draggable = multiSelect ? false : (node.data as { draggable?: boolean }).draggable + if (node.selected !== isSelected) node.selected = isSelected + if (node.draggable !== draggable) node.draggable = draggable } if (selectedNodes.length > 0) { - flow.rungs = flow.rungs.map((r) => { - const changedRung = r.id === rungId - - if (changedRung) { - return { ...rung } - } else { - return { - ...r, - selectedNodes: [], - nodes: r.nodes.map((node) => ({ - ...node, - selected: false, - draggable: false, - })), - } + for (const r of flow.rungs) { + if (r.id === rungId) continue + if (!r.selectedNodes || r.selectedNodes.length > 0) r.selectedNodes = [] + for (const node of r.nodes) { + if (node.selected !== false) node.selected = false + if (node.draggable !== false) node.draggable = false } - }) + } } }), ) @@ -496,6 +458,14 @@ export const createLadderFlowSlice: StateCreator rung.id === rungId) if (!rung) return + // Skip value-equal writes so RungBody's bounds effect doesn't churn + // the rung's identity every time it recomputes the same extent. + if ( + rung.reactFlowViewport?.[0] === reactFlowViewport[0] && + rung.reactFlowViewport?.[1] === reactFlowViewport[1] + ) + return + rung.reactFlowViewport = reactFlowViewport }), ) From 5f9b636d2cd5715c84b9874429075ed4bd189823 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Mon, 20 Jul 2026 15:01:31 -0300 Subject: [PATCH 32/52] fix(hardware): informative serial-port names + macOS cu. canonicalization (#947) Restore informative Arduino-IDE-style serial-port names (regressed in 4.2.3, #843): arduino-cli board list identifies boards via VID/PID; serialport supplies the port set + manufacturer fallback; macOS tty. paths canonicalized to cu.. Editor-only, no shared surface changed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/serial-port-list.test.ts | 140 ++++++++++++++++++ .../editor/hardware/hardware-module.ts | 79 ++++++++-- .../editor/hardware/serial-port-list.ts | 82 ++++++++++ 3 files changed, 290 insertions(+), 11 deletions(-) create mode 100644 src/backend/editor/hardware/__tests__/serial-port-list.test.ts create mode 100644 src/backend/editor/hardware/serial-port-list.ts diff --git a/src/backend/editor/hardware/__tests__/serial-port-list.test.ts b/src/backend/editor/hardware/__tests__/serial-port-list.test.ts new file mode 100644 index 000000000..be8c99782 --- /dev/null +++ b/src/backend/editor/hardware/__tests__/serial-port-list.test.ts @@ -0,0 +1,140 @@ +import { mergeSerialPortList, toCalloutPath } from '../serial-port-list' + +const boardMap = (entries: Array<[string, string | undefined]>) => new Map(entries) + +describe('toCalloutPath', () => { + it('rewrites a macOS dial-in (tty.) node to its call-out (cu.) node', () => { + expect(toCalloutPath('/dev/tty.usbmodem11301')).toBe('/dev/cu.usbmodem11301') + }) + + it('leaves an already-call-out (cu.) path unchanged', () => { + expect(toCalloutPath('/dev/cu.usbmodem11301')).toBe('/dev/cu.usbmodem11301') + }) + + it('leaves Linux paths unchanged (no dotted tty. prefix)', () => { + expect(toCalloutPath('/dev/ttyUSB0')).toBe('/dev/ttyUSB0') + expect(toCalloutPath('/dev/ttyACM0')).toBe('/dev/ttyACM0') + }) + + it('leaves Windows COM paths unchanged', () => { + expect(toCalloutPath('COM3')).toBe('COM3') + }) +}) + +describe('mergeSerialPortList', () => { + it('labels a port with the arduino-cli board name when identified', () => { + 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' }, + ]) + }) + + 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' }, + ]) + }) + + it('uses the bare path when neither board nor manufacturer is known', () => { + const boards = boardMap([]) + const manufacturers = boardMap([['/dev/ttyUSB0', undefined]]) + + expect(mergeSerialPortList(boards, manufacturers)).toEqual([{ name: '/dev/ttyUSB0', 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' }]) + }) + + it('unions both scans, keeps serialport ordering, and dedupes by path', () => { + // COM3/COM4 come from serialport; arduino-cli enriches COM4 and adds COM9. + const manufacturers = boardMap([ + ['COM3', 'FTDI'], + ['COM4', undefined], + ]) + const boards = boardMap([ + ['COM4', 'Arduino Mega'], + ['COM9', 'Arduino Nano'], + ]) + + expect(mergeSerialPortList(boards, manufacturers)).toEqual([ + { name: 'COM3 (FTDI)', address: 'COM3' }, + { name: 'COM4 (Arduino Mega)', address: 'COM4' }, + { name: 'COM9 (Arduino Nano)', address: 'COM9' }, + ]) + }) + + it('returns an empty list when both scans are empty', () => { + expect(mergeSerialPortList(boardMap([]), boardMap([]))).toEqual([]) + }) + + describe('macOS tty./cu. canonicalization', () => { + it('collapses the tty. (serialport) and cu. (arduino-cli) nodes of one device into a single cu. entry', () => { + const manufacturers = boardMap([['/dev/tty.usbmodem11301', 'Arduino']]) + const boards = boardMap([['/dev/cu.usbmodem11301', 'Opta']]) + + expect(mergeSerialPortList(boards, manufacturers)).toEqual([ + { name: '/dev/cu.usbmodem11301 (Opta)', address: '/dev/cu.usbmodem11301' }, + ]) + }) + + it('canonicalizes a tty.-only device (from serialport) to its cu. node', () => { + const manufacturers = boardMap([['/dev/tty.usbserial-99', 'FTDI']]) + + expect(mergeSerialPortList(boardMap([]), manufacturers)).toEqual([ + { name: '/dev/cu.usbserial-99 (FTDI)', address: '/dev/cu.usbserial-99' }, + ]) + }) + + it('reproduces the reported duplicate-ports scenario as a single deduped, cu.-based list', () => { + // serialport reports tty.* with manufacturers; arduino-cli reports cu.* with board id. + const manufacturers = boardMap([ + ['/dev/tty.debug-console', undefined], + ['/dev/tty.Bluetooth-Incoming-Port', undefined], + ['/dev/tty.usbserial-1140', 'Prolific Technology Inc.'], + ['/dev/tty.usbmodem11301', 'Arduino'], + ]) + const boards = boardMap([ + ['/dev/cu.debug-console', undefined], + ['/dev/cu.Bluetooth-Incoming-Port', undefined], + ['/dev/cu.usbserial-1140', undefined], + ['/dev/cu.usbmodem11301', 'Opta'], + ]) + + 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' }, + ]) + }) + + it('does not merge Linux tty paths (no dotted tty./cu. prefix)', () => { + const manufacturers = boardMap([ + ['/dev/ttyUSB0', 'FTDI'], + ['/dev/ttyACM0', undefined], + ]) + 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' }, + ]) + }) + }) +}) diff --git a/src/backend/editor/hardware/hardware-module.ts b/src/backend/editor/hardware/hardware-module.ts index c51b361d2..ea312170a 100644 --- a/src/backend/editor/hardware/hardware-module.ts +++ b/src/backend/editor/hardware/hardware-module.ts @@ -1,6 +1,8 @@ +import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { join, resolve as pathResolve, sep as pathSep } from 'node:path' +import { promisify } from 'node:util' import { app as electronApp } from 'electron' import { produce } from 'immer' @@ -12,8 +14,11 @@ 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 type { AvailableBoards, HalsFile, SerialPort } from './types' +const execFileAsync = promisify(execFile) + // interface MethodsResult { // success: boolean // data?: T @@ -94,22 +99,74 @@ class HardwareModule { // ++ ============================= Getters ================================ ++ async getAvailableSerialPorts(): Promise { - // Native `serialport` package replaces the legacy `xml2st - // --list-ports` subprocess (xml2st was retired when the JSON - // transpiler landed in-process; see - // `editor-compiler-platform-port.transpileToSt`). `NodeSerialPort.list()` - // returns each port's `path` plus optional vendor metadata; map - // it onto the `{name, address}` shape the renderer expects. + // Two independent, best-effort scans merged by device path. The path is + // always the primary, unique label (mirrors the Arduino IDE); the + // parenthetical descriptor is the arduino-cli-identified board name when + // known, falling back to `serialport`'s manufacturer/vendor string. See + // `mergeSerialPortList` for the labelling rules. Running both scans is + // cheap here: the list is static after build and only re-scanned on an + // explicit user refresh. + const [boardNamesByPath, manufacturersByPath] = await Promise.all([ + this.#identifyBoardsByPath(), + this.#listSerialPortManufacturers(), + ]) + return mergeSerialPortList(boardNamesByPath, manufacturersByPath) + } + + /** + * `serialport` enumeration → `path → manufacturer`. This is the reliable, + * instant, cross-platform source for the *set* of ports; arduino-cli only + * enriches it. Best-effort: any failure yields an empty map (never throws) + * so arduino-cli-discovered ports still come through. + */ + async #listSerialPortManufacturers(): Promise> { try { const ports = await NodeSerialPort.list() - return ports.map((port) => ({ - name: port.manufacturer ?? port.path, - address: port.path, - })) + return new Map(ports.map((port) => [port.path, port.manufacturer])) } catch (error: unknown) { logger.error(`Failed to enumerate serial ports: ${String(error)}`) - return [] + return new Map() + } + } + + /** + * `arduino-cli board list --format json` → `path → board name`. arduino-cli + * matches each port's USB VID/PID against the installed cores' `boards.txt` + * — the exact identification the Arduino IDE surfaces (e.g. `Arduino Uno`, + * `Opta`). A detected-but-unmatched port maps to `undefined` (it will fall + * back to the manufacturer descriptor). Best-effort: a missing binary, no + * installed cores, a spawn error, or malformed JSON all yield an empty map + * so plain `serialport` enumeration still works. Reuses the same binary and + * `--config-file` as compile/upload, so it sees the same installed cores. + */ + async #identifyBoardsByPath(): Promise> { + const boardNamesByPath = new Map() + try { + let binaryPath = this.arduinoCliBinaryPath + if (HardwareModule.HOST_PLATFORM === 'win32') binaryPath += '.exe' + + const { stdout } = await execFileAsync( + binaryPath, + ['board', 'list', '--format', 'json', ...this.arduinoCliBaseParameters], + { timeout: 15_000, maxBuffer: 16 * 1024 * 1024 }, + ) + + const parsed = JSON.parse(stdout) as { + detected_ports?: Array<{ + matching_boards?: Array<{ name?: string }> + port?: { address?: string } + }> + } + + for (const detected of parsed.detected_ports ?? []) { + const address = detected.port?.address + if (!address) continue + boardNamesByPath.set(address, detected.matching_boards?.[0]?.name) + } + } catch (error: unknown) { + logger.warn(`arduino-cli board list failed; serial ports will show without board names: ${String(error)}`) } + return boardNamesByPath } /** diff --git a/src/backend/editor/hardware/serial-port-list.ts b/src/backend/editor/hardware/serial-port-list.ts new file mode 100644 index 000000000..3a925a310 --- /dev/null +++ b/src/backend/editor/hardware/serial-port-list.ts @@ -0,0 +1,82 @@ +import type { SerialPort } from './types' + +/** + * Canonicalize a serial-port path to the macOS call-out (`/dev/cu.*`) node. + * + * macOS exposes each serial device as a paired dial-in node (`/dev/tty.*`) + * and call-out node (`/dev/cu.*`) that differ only by that prefix. + * `serialport`'s native binding hardcodes the dial-in (`tty.*`) name + * (`@serialport/bindings-cpp` `darwin_list.cpp` reads `kIODialinDeviceKey`), + * but callers must use the call-out (`cu.*`) node to actually talk to a + * device — and that is the name arduino-cli and the Arduino IDE report. So + * we rewrite `tty.` → `cu.` at the source: both scans then agree on one path + * and no cross-node reconciliation is needed. IOKit always publishes both + * nodes for a serial service, so the rewritten path is guaranteed to exist. + * + * The pattern is macOS-specific (dotted prefix): Linux (`/dev/ttyUSB0`, + * `/dev/ttyACM0`) and Windows (`COM3`) paths don't match and pass through + * unchanged. Already-`cu.*` paths are left as-is. + */ +export function toCalloutPath(address: string): string { + return address.replace(/^\/dev\/tty\./, '/dev/cu.') +} + +/** Re-key a scan's map onto canonical call-out paths, keeping the first defined descriptor per device. */ +function toCalloutMap(byPath: Map): Map { + const result = new Map() + for (const [address, descriptor] of byPath) { + const key = toCalloutPath(address) + // `existing || descriptor` keeps the first non-empty descriptor seen for + // a device (e.g. if it somehow surfaced under both nodes). + result.set(key, result.get(key) || descriptor) + } + return result +} + +/** + * Merge the two independent serial-port scans into the `{ name, address }` + * shape the renderer's communication-port dropdown expects. + * + * The device path (`address`) is ALWAYS the primary, guaranteed-unique + * label — this mirrors the Arduino IDE, which keys every entry on the + * port path (`COM3`, `/dev/ttyACM0`, `/dev/cu.usbmodem…`) and never + * collapses ports to a shared vendor string. A descriptor is appended + * in parentheses when available, in order of usefulness: + * + * 1. the arduino-cli-identified board name (from the connected core's + * `boards.txt` VID/PID — e.g. `COM3 (Arduino Uno)`), else + * 2. the OS manufacturer/vendor string reported by `serialport` + * (e.g. `COM6 (com0com - serial port emulator)`), else + * 3. nothing — just the bare path. + * + * Both scans are first canonicalized to the macOS call-out node + * (`toCalloutPath`), so a device is keyed identically regardless of which + * scan reported it; the merge is then a plain union deduped by path. + * `serialport` provides the reliable, instant set of ports and is folded + * in first so its ordering is preserved; arduino-cli only enriches those + * entries and may contribute additional ports it discovered on its own. + * + * @param boardNamesByPath path → arduino-cli board name (`undefined` when + * the port was detected but no board matched) + * @param manufacturersByPath path → `serialport` manufacturer/vendor string + */ +export function mergeSerialPortList( + boardNamesByPath: Map, + manufacturersByPath: Map, +): SerialPort[] { + const boardNames = toCalloutMap(boardNamesByPath) + const manufacturers = toCalloutMap(manufacturersByPath) + + // serialport ordering first, then any arduino-cli-only ports. A Set keeps + // insertion order and dedupes ports seen by both scans. + 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) + return { + name: descriptor ? `${address} (${descriptor})` : address, + address, + } + }) +} From 72adc40567f4587cc7d25a121df762fec998df48 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:59:19 -0300 Subject: [PATCH 33/52] chore: repo setup for Claude workflows and doc cleanup (#913) * docs: declare Jira project in CLAUDE.md Ticket-based workflows need the tracker and project key documented in-repo. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019thCMmxxVfACL7dBodtZB9 * docs: document git workflow and branch naming in CLAUDE.md The convention existed only in branch history; agents and new contributors could not reproduce it from docs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019thCMmxxVfACL7dBodtZB9 * docs: drop archived and superseded docs, fix Node version docs/outdated was archived in April with no inbound references; docs/ports was a design snapshot superseded by src/middleware/shared/ ports; CONTRIBUTING.md was an empty stub; engines requires Node >=22. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019thCMmxxVfACL7dBodtZB9 * docs: sync architecture docs with the post-reorg source layout EtherCAT and debugger docs still referenced the pre-reorg src/main and src/renderer trees and a deleted IEC system-task helper; the pipeline diagram claimed iec2c where STruC++ compiles; README gains the real test commands. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019thCMmxxVfACL7dBodtZB9 * chore: add CONTRIBUTING, PR/issue templates and shared Claude config Contribution rules, the PR checklist, and the tracker pointer existed only in scattered or personal files; this surfaces them where GitHub and the review tooling actually read them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019thCMmxxVfACL7dBodtZB9 * docs: add language best practices to CLAUDE.md and review guidelines Conventions covered style but not the practices that prevent real bugs: type assertions and floating promises in TS, unchecked buffers and scan-cycle blocking in C, broad excepts and event-loop blocking in Python. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019thCMmxxVfACL7dBodtZB9 * docs: address PR 913 review feedback - Narrow .claude/settings.json Bash allowlist to specific subcommands - CONTRIBUTING.md: branch-naming escape hatch for external contributors - CLAUDE.md: Git Workflow defers to CONTRIBUTING.md; add named-exports rule - review-guidelines: dedupe any-clause, split named-exports bullet - strucpp overview: separate local iec2c removal from Runtime v3 MatIEC path - ethercat doc: add taskPriority to state-tree masterConfig Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LRdcPv4CCxEKR3N8nYKYp6 --------- Co-authored-by: Claude Fable 5 --- .claude/review-guidelines.md | 11 + .claude/settings.json | 19 + CLAUDE.md | 47 +- CONTRIBUTING.md | 35 + README.md | 10 +- docs/debugger-scalability-analysis.md | 12 +- docs/ethercat-architecture.md | 150 +- .../outdated/ARDUINO_UNO_Q_BINARY_SIZE_FIX.md | 137 -- docs/outdated/HEADLESS_SETUP.md | 357 ---- docs/outdated/dead-code-inventory.md | 69 - .../debugger-opcua-shared-utilities.md | 544 ------ docs/outdated/external-binaries-strategy.md | 383 ---- docs/outdated/name-type-linking-design.md | 1608 ----------------- .../01-design-overview.md | 414 ----- .../02-ui-screen-specifications.md | 818 --------- .../03-json-configuration-mapping.md | 959 ---------- .../04-implementation-phases.md | 973 ---------- .../opcua-server-configuration/README.md | 39 - docs/outdated/s7comm-server-implementation.md | 811 --------- .../unified-frontend-serialization.md | 115 -- docs/outdated/variable-id-audit.md | 918 ---------- docs/ports/accelerator-port.ts | 58 - docs/ports/compiler-port.ts | 88 - docs/ports/debugger-port.ts | 92 - docs/ports/device-port.ts | 61 - docs/ports/index.ts | 129 -- docs/ports/platform-capabilities.ts | 127 -- docs/ports/project-port.ts | 152 -- docs/ports/runtime-port.ts | 136 -- docs/ports/simulator-port.ts | 64 - docs/ports/system-port.ts | 54 - docs/ports/theme-port.ts | 38 - docs/ports/types.ts | 305 ---- docs/ports/window-port.ts | 64 - docs/strucpp-migration/00-overview.md | 15 +- 35 files changed, 192 insertions(+), 9620 deletions(-) create mode 100644 .claude/review-guidelines.md create mode 100644 .claude/settings.json delete mode 100644 docs/outdated/ARDUINO_UNO_Q_BINARY_SIZE_FIX.md delete mode 100644 docs/outdated/HEADLESS_SETUP.md delete mode 100644 docs/outdated/dead-code-inventory.md delete mode 100644 docs/outdated/debugger-opcua-shared-utilities.md delete mode 100644 docs/outdated/external-binaries-strategy.md delete mode 100644 docs/outdated/name-type-linking-design.md delete mode 100644 docs/outdated/opcua-server-configuration/01-design-overview.md delete mode 100644 docs/outdated/opcua-server-configuration/02-ui-screen-specifications.md delete mode 100644 docs/outdated/opcua-server-configuration/03-json-configuration-mapping.md delete mode 100644 docs/outdated/opcua-server-configuration/04-implementation-phases.md delete mode 100644 docs/outdated/opcua-server-configuration/README.md delete mode 100644 docs/outdated/s7comm-server-implementation.md delete mode 100644 docs/outdated/unified-frontend-serialization.md delete mode 100644 docs/outdated/variable-id-audit.md delete mode 100644 docs/ports/accelerator-port.ts delete mode 100644 docs/ports/compiler-port.ts delete mode 100644 docs/ports/debugger-port.ts delete mode 100644 docs/ports/device-port.ts delete mode 100644 docs/ports/index.ts delete mode 100644 docs/ports/platform-capabilities.ts delete mode 100644 docs/ports/project-port.ts delete mode 100644 docs/ports/runtime-port.ts delete mode 100644 docs/ports/simulator-port.ts delete mode 100644 docs/ports/system-port.ts delete mode 100644 docs/ports/theme-port.ts delete mode 100644 docs/ports/types.ts delete mode 100644 docs/ports/window-port.ts diff --git a/.claude/review-guidelines.md b/.claude/review-guidelines.md new file mode 100644 index 000000000..9cb8ce451 --- /dev/null +++ b/.claude/review-guidelines.md @@ -0,0 +1,11 @@ +# Review Guidelines + +Order of concern: correctness, architecture, tests, style. + +- Layer dependencies respect ports and adapters: frontend, backend, and middleware only communicate through the ports in `src/middleware/shared/ports/`. `npm run validate:arch` must pass. +- DTOs cross layer boundaries, never domain entities. +- 100%-coverage directories stay at 100%; new behavior comes with tests (Jest for logic, Playwright for user flows). +- TypeScript Best Practices in CLAUDE.md apply to every diff (no `any`, no `as`, no `!`, no floating promises, boundary validation over casting). +- Named exports over default exports. +- No emojis in code, comments, or docs. +- Docs updated when documented behavior changes (README, CLAUDE.md, docs/). diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..c19b49e15 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run:*)", + "Bash(npm test:*)", + "Bash(npx tsx:*)", + "Bash(npx playwright test:*)", + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git show:*)", + "Bash(git branch:*)", + "Bash(gh pr view:*)", + "Bash(gh pr list:*)", + "Bash(gh issue view:*)", + "Bash(gh issue list:*)" + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 516aa0764..2c20af8e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ src/ ├── main/ # Electron main process (Node.js) ├── frontend/ # React UI layer (renderer process) │ ├── components/ # Atomic Design: _atoms, _molecules, _organisms, _features, _templates -│ ├── store/ # Zustand store (19 slices) +│ ├── store/ # Zustand store (18 slices) │ ├── hooks/ # Custom React hooks │ ├── services/ # Business logic and side effects │ ├── utils/ # Domain utilities (PLC, graphical, debug, formatters) @@ -47,7 +47,7 @@ src/ │ ├── locales/ # i18next translations │ └── assets/ # Images, icons ├── backend/ -│ ├── editor/ # Main process modules (compiler, hardware, modbus, websocket, services) +│ ├── editor/ # Main process modules (compiler, hardware, modbus, ethercat, library-manager, services) │ └── shared/ # Platform-agnostic utilities (XML generation, project parsing, simulator) ├── middleware/ # Ports & Adapters layer │ ├── shared/ @@ -131,7 +131,7 @@ Main and renderer processes communicate through typed IPC bridges: ### State Management (Zustand) -Single store composed of 19 slices (`src/frontend/store/`), accessed via auto-generated selector hooks: +Single store composed of 18 slices (`src/frontend/store/`), accessed via auto-generated selector hooks: ```typescript import { useOpenPLCStore } from '@root/frontend/store' @@ -160,7 +160,7 @@ const createPou = useOpenPLCStore((s) => s.projectActions.createPou) | `console` | Log output | | `library` | System + user function block libraries | | `file` | File save states (dirty tracking) | -| `ai`, `clipboard`, `history`, `modal`, `search`, `shared`, `version-control`, `webrtc` | Supporting features | +| `ai`, `history`, `modal`, `readme`, `search`, `shared`, `version-control`, `webrtc` | Supporting features | **Conventions:** - Actions are grouped under a `*Actions` namespace (e.g., `projectActions`, `deviceActions`) @@ -205,14 +205,14 @@ Flow state is stored per-POU in dedicated slices (`ladder`, `fbd`). Flows must b Orchestrated by `CompilerModule` (`src/backend/editor/compiler/compiler-module.ts`): ``` -PLCProjectData -> Preprocess POUs -> XML Generation -> xml2st -> iec2c -> C code - | - defines.h (pins, Modbus, MD5) - | - Arduino CLI / openplc-compiler -> firmware +PLCProjectData -> Preprocess POUs -> XML Generation -> xml2st -> STruC++ compile() -> C++ code + | + defines.h (pins, Modbus, MD5) + | + Arduino CLI / openplc-compiler -> firmware ``` -Platform-specific binaries in `/resources/bin/[platform]/[arch]/`. Board configs in `/resources/sources/boards/hals.json`. +Platform-specific binaries in `/resources/bin/[platform]/[arch]/`. Board configs in `src/backend/shared/firmware/hals.json`. ### Debugging @@ -241,9 +241,22 @@ When adding new code to covered directories, you must add corresponding tests to - ESLint flat config (`eslint.config.mjs`) with TypeScript strict type checking - Prettier: 120 char width, no semicolons, single quotes, trailing commas - Import sorting enforced via `simple-import-sort` plugin -- Pre-commit hooks via Husky run lint-staged on `./src/**/*` +- Pre-commit hooks via Husky run lint-staged on `./src/**/*.{ts,tsx}` - Path alias: `@root/*` -> `./src/*` +### TypeScript Best Practices + +- No type assertions: `as` hides real type errors — fix the type at the source or narrow with type guards. `as const` is fine; `as unknown as T` is forbidden. +- No non-null assertions (`!`): handle the undefined case or narrow explicitly. +- For truly unknown data use `unknown` and narrow before use — never `any`. +- No `@ts-ignore`/`@ts-expect-error` without a one-line justification. +- Validate external data at the boundary (IPC payloads, project files, downloaded binaries metadata) with schemas (zod) or type guards instead of casting. +- No floating promises: `await` or handle rejection explicitly — async errors must not disappear. +- Prefer `??` over `||` for defaults when `0`, `''`, or `false` are valid values. +- Model variant states as discriminated unions; make `switch` exhaustive with a `never` check. +- Named exports over default exports. +- Zustand state changes only through slice actions — never mutate store values from components. + ## Key Technologies - **Electron 35** / **React 18** / **TypeScript** (target ES2022) @@ -311,7 +324,7 @@ on its `main` push. (Ideally `package.json.version` should be derived from ### IEC address allocation + alias registry -Located in `src/backend/shared/utils/iec-address/` (byte-identical on +Located in `src/middleware/shared/utils/iec-address/` (byte-identical on openplc-web). Pure functions, no IPC, no electron coupling. - **Address pool** (`address-pool.ts`): producer-only, target-scoped @@ -353,7 +366,15 @@ new alias. ## Environment -- **Node.js:** >= 20.x < 24 +- **Node.js:** >= 22.x < 24 - **Dev server port:** 1313 - **Supported platforms:** macOS, Windows, Linux (x64 & ARM64) - **Binaries:** Auto-downloaded via `scripts/download-binaries.ts` during `npm install` + +## Git Workflow + +Follow the Workflow section in CONTRIBUTING.md (base branch, branch naming, Conventional Commits). `` maps from the Jira issue type: Story → `feature`, Bug → `bugfix`, Task → `task`, Improvement → `improvement`. + +## Issue Tracker + +Jira, project key `DOPE`. Fetch and update tickets via the Atlassian MCP tools. Reference the ticket key (`DOPE-`) in branch names and PR descriptions. GitHub Issues (`.github/ISSUE_TEMPLATE/`) receives external bug reports; planned work lives in Jira. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e69de29bb..95d7883fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -0,0 +1,35 @@ +# Contributing + +## Setup + +Requires Node.js >= 22 < 24. See README.md for the full step by step. + +```bash +npm install +npm run dev # dev server on port 1313 +``` + +## Workflow + +1. Internal work is tracked in Jira, project DOPE (internal tracker). External contributors: open a GitHub issue using the provided templates. +2. Branch from `development`, named `/DOPE--` (``: feature, bugfix, task, improvement). Maintenance without a ticket uses `chore/`, `ci/`, `docs/`. External contributors without Jira access: use the GitHub issue number instead (`/gh--`); a maintainer files the DOPE ticket when needed. +3. Commit style: Conventional Commits, concise, focused on why. +4. Open a PR targeting `development` and fill in the PR template. + +## Before pushing + +```bash +npm run test # unit tests (Jest, with coverage) +npm run test:e2e # end-to-end (Playwright, requires a build) +npm run validate:arch # architecture layer dependencies +``` + +Lint and format run on commit via Husky. + +## Docs + +If your change alters documented behavior (commands, endpoints, env vars, architecture, setup steps), update the affected docs (README, CLAUDE.md, docs/) in the same PR. + +## Review + +PRs are reviewed against `.claude/review-guidelines.md`. diff --git a/README.md b/README.md index 8861e05c6..fa6e00a9e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ In order to run the development version, clone the repository, and install depen You'll need the following tools: - [Git](https://git-scm.com/) -- [NodeJS](https://nodejs.org/en/download/), version `>=20 <24` +- [NodeJS](https://nodejs.org/en/download/), version `>=22 <24` ### Step by step @@ -38,6 +38,14 @@ npm install npm run dev ``` +### Running tests + +```bash +npm run test # Unit tests (Jest, with coverage) +npm run test:watch # Unit tests in watch mode (no coverage) +npm run test:e2e # End-to-end tests (Playwright, builds the app first) +``` + ## Releasing a New Version The project uses GitHub Actions to automatically build and release new versions for all supported platforms (macOS, Windows, and Linux) in both x64 and ARM64 architectures. diff --git a/docs/debugger-scalability-analysis.md b/docs/debugger-scalability-analysis.md index 5e7338c71..e668bccf1 100644 --- a/docs/debugger-scalability-analysis.md +++ b/docs/debugger-scalability-analysis.md @@ -1193,9 +1193,9 @@ Implement **Strategy F** (lazy registration) only if needed for extreme-scale pr ### openplc-editor | File | Changes | |------|---------| -| `src/main/modules/compiler/compiler-module.ts` | Pass target platform to xml2st | -| `src/utils/debug-parser.ts` | Support JSON manifest parsing | -| `src/renderer/utils/debugger-session.ts` | Array index handling, protocol v2 | -| `src/main/modules/modbus/modbus-client.ts` | Array element request method | -| `src/main/modules/websocket/websocket-debug-client.ts` | Array element request method | -| `src/renderer/screens/workspace-screen.tsx` | Array polling in debug loop | +| `src/backend/editor/compiler/compiler-module.ts` | Pass target platform to xml2st | +| `src/frontend/utils/debug-parser.ts` | Support JSON manifest parsing | +| `src/frontend/utils/debugger-session.ts` | Array index handling, protocol v2 | +| `src/backend/editor/modbus/modbus-client.ts` | Array element request method | +| `src/backend/shared/debug/websocket-debug-transport.ts` | Array element request method | +| `src/frontend/screens/workspace-screen.tsx` | Array polling in debug loop | diff --git a/docs/ethercat-architecture.md b/docs/ethercat-architecture.md index e9e66e4e2..dcd09d423 100644 --- a/docs/ethercat-architecture.md +++ b/docs/ethercat-architecture.md @@ -27,7 +27,7 @@ Network scan → Match against repo → Add to project → Configure → Generat ## 2. Type System -### Core Types (`src/types/ethercat/esi-types.ts`) +### Core Types (`src/middleware/shared/ports/esi-types.ts`) | Type | Purpose | |------|---------| @@ -42,7 +42,7 @@ Network scan → Match against repo → Add to project → Configure → Generat | `ESIDeviceRef` | Pointer to a device in the repository (`repositoryItemId` + `deviceIndex`) | | `ScannedDeviceMatch` | A scanned device paired with its ESI matches (exact/partial/none) | -### Discovery Types (`src/types/ethercat/index.ts`) +### Discovery Types (`src/middleware/shared/ports/ethercat-types.ts`) | Type | Purpose | |------|---------| @@ -51,7 +51,7 @@ Network scan → Match against repo → Add to project → Configure → Generat | `NetworkInterface` | Adapter for scanning (name, description) | | `EtherCATRuntimeStatusResponse` | Runtime state machine (masters/slaves with states, WKC) | -### Project Persistence (`src/types/PLC/open-plc.ts`) +### Project Persistence (`src/backend/shared/types/PLC/open-plc.ts`) ```typescript PLCRemoteDevice { @@ -61,7 +61,7 @@ PLCRemoteDevice { } EthercatConfig { - masterConfig?: EtherCATMasterConfig // interface, cycleTimeUs, watchdogTimeoutCycles + masterConfig?: EtherCATMasterConfig // networkInterface, cycleTimeUs, watchdogTimeoutCycles, taskPriority devices: ConfiguredEtherCATDevice[] } ``` @@ -82,7 +82,7 @@ Stored in `project.json` under `data.remoteDevices[]`. └── ... ``` -### ESI Service (`src/main/services/esi-service/index.ts`) +### ESI Service (`src/backend/editor/ethercat/esi-service.ts`) Runs in the **main process**. Key methods: @@ -95,7 +95,7 @@ Runs in the **main process**. Key methods: | `clearRepository(projectPath)` | Delete all ESI files and index | | `migrateRepositoryToV2(projectPath)` | Convert v1 (metadata-only) → v2 (with summaries) | -### ESI Parser (`src/main/services/esi-service/esi-parser-main.ts`) +### ESI Parser (`src/backend/shared/ethercat/esi-parser-main.ts`) Two parsing modes: @@ -132,36 +132,19 @@ Defined in `src/main/modules/ipc/main.ts` (handlers) and `src/main/modules/ipc/r ## 4. Remote Device (Bus) Creation -### Store Action (`src/renderer/store/slices/project/slice.ts`) +### Store Action (`src/frontend/store/slices/project/slice.ts`) `createRemoteDevice(device: PLCRemoteDevice)`: -1. Validates name doesn't conflict with POUs/datatypes +1. Validates the name is not already used by another remote device 2. Pushes to `project.data.remoteDevices[]` -3. **Auto-creates a system task** for EtherCAT: -```typescript -{ - name: "EtherCAT_", // ethercatTaskName() - triggering: 'Cyclic', - interval: "T#1ms", // cycleTimeUsToIecInterval(1000) - priority: 1, - isSystemTask: true, - associatedDevice: deviceName, -} -``` +No IEC task is created: the EtherCAT bus is driven by a dedicated thread inside the +runtime plugin. Bus timing lives entirely in `masterConfig` (`cycleTimeUs`, `taskPriority`). Related actions: -- `deleteRemoteDevice(name)` — removes device + associated system task -- `updateRemoteDeviceName(oldName, newName)` — renames both device and task -- `updateEthercatConfig(deviceName, ethercatConfig)` — updates config and **syncs cycle time to task interval** - -### Task Helpers (`src/utils/ethercat/ethercat-task-helpers.ts`) - -| Function | Output | -|----------|--------| -| `ethercatTaskName("Master1")` | `"EtherCAT_Master1"` | -| `cycleTimeUsToIecInterval(1000)` | `"T#1ms"` | -| `cycleTimeUsToIecInterval(500)` | `"T#500us"` | +- `deleteRemoteDevice(name)` — removes the device +- `updateRemoteDeviceName(oldName, newName)` — renames the device +- `updateEthercatConfig(deviceName, ethercatConfig)` — replaces the device's EtherCAT config --- @@ -209,7 +192,7 @@ window.bridge.etherCATScan(ipAddress, jwtToken, { interface, timeout_ms }) ## 6. Device Matching -**File:** `src/utils/ethercat/device-matcher.ts` +**File:** `src/backend/shared/ethercat/device-matcher.ts` `matchDevicesToRepository(scannedDevices, repository)` → `ScannedDeviceMatch[]` @@ -256,7 +239,7 @@ Two paths: - Assigns next available position - Calls `syncDevicesToStore()` -### Device Enrichment (`src/utils/ethercat/enrich-device-data.ts`) +### Device Enrichment (`src/backend/shared/ethercat/enrich-device-data.ts`) `enrichDeviceData(esiDevice)` extracts fields spread into `ConfiguredEtherCATDevice`: @@ -267,7 +250,7 @@ Two paths: | `slaveType: string` | `deriveSlaveType()` — heuristic: `digital_input`, `analog_output`, `coupler`, etc. | | `sdoConfigurations: SDOConfigurationEntry[]` | `extractDefaultSdoConfigurations()` — RW objects in 0x2000+ range | -### Default Slave Config (`src/utils/ethercat/device-config-defaults.ts`) +### Default Slave Config (`src/backend/shared/ethercat/device-config-defaults.ts`) `createDefaultSlaveConfig()` returns: ```typescript @@ -287,7 +270,7 @@ Two paths: ### Editor Components ``` -src/renderer/components/_features/[workspace]/editor/device/ethercat/ +src/frontend/components/_features/[workspace]/editor/device/ethercat/ ├── index.tsx # Bus-level editor (3 tabs: Network, Repository, Advanced) ├── ethercat-device-editor.tsx # Per-device editor (4 tabs below) └── components/ @@ -313,7 +296,7 @@ src/renderer/components/_features/[workspace]/editor/device/ethercat/ 3. **Startup Parameters** — `SdoParametersSection`: editable CoE SDO entries 4. **Channel Mappings** — `ChannelMappingsSection`: IEC 61131-3 located variable assignments -### Channel Mapping Utilities (`src/utils/ethercat/esi-parser.ts`) +### Channel Mapping Utilities (`src/backend/shared/ethercat/esi-parser.ts`) | Function | Purpose | |----------|---------| @@ -330,7 +313,7 @@ IEC location format: Example: BOOL input at byte 0, bit 2 → %IX0.2 ``` -### SDO Extraction (`src/utils/ethercat/sdo-config-defaults.ts`) +### SDO Extraction (`src/backend/shared/ethercat/sdo-config-defaults.ts`) `extractDefaultSdoConfigurations(coeObjects)` → `SDOConfigurationEntry[]` @@ -338,7 +321,7 @@ IEC location format: - Excludes system objects (0x0000–0x1FFF) - For complex objects: extracts RW sub-items with default values -### Device Configuration Hook (`src/renderer/hooks/use-device-configuration.ts`) +### Device Configuration Hook (`src/frontend/hooks/use-device-configuration.ts`) `useDeviceConfiguration({ device, projectPath, ... })` provides: - Lazy-loads full ESI device on first render @@ -352,12 +335,12 @@ IEC location format: ### Zustand Store Slices -The EtherCAT state lives primarily in the **Project Slice** (`src/renderer/store/slices/project/slice.ts`): +The EtherCAT state lives primarily in the **Project Slice** (`src/frontend/store/slices/project/slice.ts`): ``` project.data.remoteDevices[] → PLCRemoteDevice[] └── ethercatConfig - ├── masterConfig: { networkInterface, cycleTimeUs, watchdogTimeoutCycles } + ├── masterConfig: { networkInterface, cycleTimeUs, watchdogTimeoutCycles, taskPriority } └── devices: ConfiguredEtherCATDevice[] ``` @@ -365,16 +348,16 @@ Key actions on the project slice: | Action | Purpose | |--------|---------| -| `createRemoteDevice()` | Add bus + auto-create system task | -| `deleteRemoteDevice()` | Remove bus + delete system task | -| `updateEthercatConfig()` | Update master config and/or device list, sync task interval | -| `updateRemoteDeviceName()` | Rename bus + associated task | +| `createRemoteDevice()` | Add bus | +| `deleteRemoteDevice()` | Remove bus | +| `updateEthercatConfig()` | Update master config and/or device list | +| `updateRemoteDeviceName()` | Rename bus | The **Editor Slice** manages the active editor model: - Bus editor: `type: 'plc-remote-device'`, `meta: { name, protocol: 'ethercat' }` - Device editor: `type: 'plc-ethercat-device'`, `meta: { name, busName, deviceId }` -### EtherCAT Editor State (`src/renderer/components/.../ethercat/index.tsx`) +### EtherCAT Editor State (`src/frontend/components/.../ethercat/index.tsx`) Local component state in `EtherCATEditor`: @@ -395,7 +378,7 @@ masterConfig = remoteDevice.ethercatConfig.masterConfig ## 10. JSON Configuration Generation for Runtime -### Generator (`src/utils/ethercat/generate-ethercat-config.ts`) +### Generator (`src/backend/shared/ethercat/generate-ethercat-config.ts`) `generateEthercatConfig(remoteDevices[])` → JSON string or `null` @@ -410,8 +393,7 @@ interface RuntimeRootEntry { interface: string // "eth0" cycle_time_us: number watchdog_timeout_cycles: number - task_name?: string // "EtherCAT_Master1" - task_cycle_time_us?: number + task_priority?: number // SCHED_FIFO priority of the bus thread (default 90) } slaves: RuntimeSlave[] diagnostics: { @@ -452,14 +434,13 @@ Channel type derivation: `deriveChannelType(direction, bitLen)` → `"digital_in SDO value parsing: `parseNumericValue(str)` handles decimal, hex (`0xFF`, `#xFF`), float, negative. -### Compiler Integration (`src/main/modules/compiler/compiler-module.ts`) +### Compiler Integration (`src/backend/shared/compile/steps/generate-confs.ts`) -`handleGenerateEthercatConfig(sourceTargetFolderPath, projectData, handleOutputData)`: +`generateRuntimeConfs()`: -1. Calls `generateEthercatConfig(projectData.remoteDevices)` -2. Creates `conf/` directory in firmware build folder -3. Writes `conf/ethercat.json` -4. Part of the larger build pipeline alongside `modbus-master.json`, `s7comm.json`, `opcua.json` +1. Calls `generateEthercatConfig(remoteDevices)` and validates the result with `validateEthercatConfig()` +2. Returns the JSON alongside the other runtime configs (Modbus slave/master, S7comm, OPC UA) +3. The bundle composer (`src/middleware/shared/utils/library/compose-runtime-v4-bundle.ts`) writes it to `conf/ethercat.json`, next to `conf/modbus_master.json`, `conf/s7comm.json`, `conf/opcua.json` --- @@ -468,7 +449,7 @@ SDO value parsing: `parseNumericValue(str)` handles decimal, hex (`0xFF`, `#xFF` ``` 1. CREATE BUS UI: Add Remote Device → protocol: ethercat - Store: createRemoteDevice() → remoteDevices[] + system task + Store: createRemoteDevice() → remoteDevices[] 2. UPLOAD ESI FILES UI: Repository tab → drag & drop XML @@ -487,7 +468,7 @@ SDO value parsing: `parseNumericValue(str)` handles decimal, hex (`0xFF`, `#xFF` 4. ENRICH & STORE IPC: esiLoadDeviceFull() → parseESIDeviceFull() Util: enrichDeviceData() → channelInfo, PDOs, slaveType, SDOs - Store: updateEthercatConfig() → devices[] + sync task interval + Store: updateEthercatConfig() → devices[] 5. CONFIGURE DEVICE UI: Click device in tree → EtherCATDeviceEditor @@ -507,59 +488,58 @@ SDO value parsing: `parseNumericValue(str)` handles decimal, hex (`0xFF`, `#xFF` ### Types | File | Contents | |------|----------| -| `src/types/ethercat/esi-types.ts` | ESIDevice, ConfiguredEtherCATDevice, channels, PDOs, SDOs | -| `src/types/ethercat/index.ts` | EtherCATDevice, scan/status responses, NetworkInterface | -| `src/types/PLC/open-plc.ts` | Zod schemas: EthercatConfig, EtherCATMasterConfig, PLCRemoteDevice | +| `src/middleware/shared/ports/esi-types.ts` | ESIDevice, ConfiguredEtherCATDevice, channels, PDOs, SDOs | +| `src/middleware/shared/ports/ethercat-types.ts` | EtherCATDevice, scan/status responses, NetworkInterface | +| `src/backend/shared/types/PLC/open-plc.ts` | Zod schemas: EthercatConfig, EtherCATMasterConfig, PLCRemoteDevice | ### Main Process | File | Contents | |------|----------| -| `src/main/services/esi-service/index.ts` | ESI file persistence and repository management | -| `src/main/services/esi-service/esi-parser-main.ts` | XML parsing (light and full modes) | +| `src/backend/editor/ethercat/esi-service.ts` | ESI file persistence and repository management | +| `src/backend/shared/ethercat/esi-parser-main.ts` | XML parsing (light and full modes) | | `src/main/modules/ipc/main.ts` | IPC handlers for scan, status, ESI operations | | `src/main/modules/ipc/renderer.ts` | Renderer-side IPC bridge (`window.bridge.*`) | -| `src/main/modules/compiler/compiler-module.ts` | Firmware build: writes `conf/ethercat.json` | +| `src/backend/shared/compile/steps/generate-confs.ts` | Build pipeline: generates the `conf/ethercat.json` payload | -### Renderer — Utilities +### Shared Utilities | File | Contents | |------|----------| -| `src/utils/ethercat/device-matcher.ts` | Match scanned devices against ESI repository | -| `src/utils/ethercat/enrich-device-data.ts` | Extract persistable data from full ESI device | -| `src/utils/ethercat/esi-parser.ts` | pdoToChannels, generateIecLocation, default mappings | -| `src/utils/ethercat/device-config-defaults.ts` | DEFAULT_SLAVE_CONFIG | -| `src/utils/ethercat/sdo-config-defaults.ts` | Extract default SDO configurations from CoE | -| `src/utils/ethercat/ethercat-task-helpers.ts` | Task naming, cycle time conversion | -| `src/utils/ethercat/generate-ethercat-config.ts` | Generate runtime JSON from project state | +| `src/backend/shared/ethercat/device-matcher.ts` | Match scanned devices against ESI repository | +| `src/backend/shared/ethercat/enrich-device-data.ts` | Extract persistable data from full ESI device | +| `src/backend/shared/ethercat/esi-parser.ts` | pdoToChannels, generateIecLocation, default mappings | +| `src/backend/shared/ethercat/device-config-defaults.ts` | DEFAULT_SLAVE_CONFIG | +| `src/backend/shared/ethercat/sdo-config-defaults.ts` | Extract default SDO configurations from CoE | +| `src/backend/shared/ethercat/generate-ethercat-config.ts` | Generate runtime JSON from project state | +| `src/backend/shared/ethercat/validate-ethercat-config.ts` | Validate generated runtime JSON | ### Renderer — Components | File | Contents | |------|----------| -| `src/renderer/components/.../ethercat/index.tsx` | Bus-level editor (Network, Repository, Advanced tabs) | -| `src/renderer/components/.../ethercat/ethercat-device-editor.tsx` | Per-device editor (Info, Config, SDO, Channels tabs) | -| `src/renderer/components/.../ethercat/components/scan-bus-tab.tsx` | Scan UI + configured devices list with +/- | -| `src/renderer/components/.../ethercat/components/device-browser-modal.tsx` | Browse ESI repo to add devices | -| `src/renderer/components/.../ethercat/components/device-configuration-form.tsx` | Slave config forms | -| `src/renderer/components/.../ethercat/components/channel-mapping-table.tsx` | IEC variable mapping | -| `src/renderer/components/.../ethercat/components/sdo-parameters-table.tsx` | CoE SDO editing | +| `src/frontend/components/.../ethercat/index.tsx` | Bus-level editor (Network, Repository, Advanced tabs) | +| `src/frontend/components/.../ethercat/ethercat-device-editor.tsx` | Per-device editor (Info, Config, SDO, Channels tabs) | +| `src/frontend/components/.../ethercat/components/scan-bus-tab.tsx` | Scan UI + configured devices list with +/- | +| `src/frontend/components/.../ethercat/components/device-browser-modal.tsx` | Browse ESI repo to add devices | +| `src/frontend/components/.../ethercat/components/device-configuration-form.tsx` | Slave config forms | +| `src/frontend/components/.../ethercat/components/channel-mapping-table.tsx` | IEC variable mapping | +| `src/frontend/components/.../ethercat/components/sdo-parameters-table.tsx` | CoE SDO editing | ### Renderer — Store | File | Contents | |------|----------| -| `src/renderer/store/slices/project/slice.ts` | createRemoteDevice, updateEthercatConfig, delete, rename | -| `src/renderer/store/slices/editor/types.ts` | Editor model schema (plc-ethercat-device variant) | -| `src/renderer/store/slices/tabs/utils.ts` | CreateEtherCATDeviceEditor | -| `src/renderer/store/slices/shared/index.ts` | openFile, closeFile, forceCloseFile, deleteEthercatDevice | -| `src/renderer/hooks/use-device-configuration.ts` | Lazy-load full device, manage channels/SDOs | +| `src/frontend/store/slices/project/slice.ts` | createRemoteDevice, updateEthercatConfig, delete, rename | +| `src/frontend/store/slices/editor/types.ts` | Editor model schema (plc-ethercat-device variant) | +| `src/frontend/store/slices/tabs/utils.ts` | CreateEtherCATDeviceEditor | +| `src/frontend/store/slices/shared/slice.ts` | closeFile, forceCloseFile, ethercatDeviceActions.delete | +| `src/frontend/hooks/use-device-configuration.ts` | Lazy-load full device, manage channels/SDOs | --- ## 13. Constraints & Notes 1. **ESI parsing is CPU-bound** — runs in main process. Sequential uploads recommended for UI responsiveness. -2. **Cycle time ↔ task sync** — `updateEthercatConfig()` auto-updates the associated system task interval. +2. **No IEC task** — the EtherCAT bus is driven by a dedicated thread inside the runtime plugin; timing comes from `masterConfig` (`cycleTimeUs`, `taskPriority`). 3. **Address uniqueness** — IEC addresses must be unique across all remote devices (Modbus + EtherCAT). `usedAddresses` is tracked when generating mappings. 4. **CoE SDO range** — only objects in 0x2000+ are user-configurable. System objects (0x0000–0x1FFF) are runtime-managed. 5. **PDO padding** — entries with `index: "0x0000"` are padding: excluded from channel lists but preserved in persisted PDOs for correct byte offsets. -6. **System tasks** — auto-created on device creation, auto-deleted on removal. Marked with `isSystemTask: true`. -7. **Repository v2 migration** — old v1 (metadata-only) auto-migrates to v2 (with device summaries) on first load. -8. **Editor caching** — editor models are cached by `meta.name`. When removing a device, its tab/editor must be explicitly closed to avoid stale `deviceId` references on re-add. +6. **Repository v2 migration** — old v1 (metadata-only) auto-migrates to v2 (with device summaries) on first load. +7. **Editor caching** — editor models are cached by `meta.name`. When removing a device, its tab/editor must be explicitly closed to avoid stale `deviceId` references on re-add. diff --git a/docs/outdated/ARDUINO_UNO_Q_BINARY_SIZE_FIX.md b/docs/outdated/ARDUINO_UNO_Q_BINARY_SIZE_FIX.md deleted file mode 100644 index d2701cc9b..000000000 --- a/docs/outdated/ARDUINO_UNO_Q_BINARY_SIZE_FIX.md +++ /dev/null @@ -1,137 +0,0 @@ -# Arduino Uno Q Binary Size Optimization - -## Problem Discovery - -### Symptoms -OpenPLC programs compiled for Arduino Uno Q would upload successfully but fail to execute - no LED blinking, no serial output, despite the same code working on Arduino Mega and the same board working with Arduino IDE sketches. - -### Root Cause Analysis - -Through extensive debugging using OpenOCD via ADB shell, we traced the failure to the Zephyr LLEXT (Linkable Loadable Extensions) loader. The exact failure point was identified: - -``` -llext_load() returns 0xfffffff4 (-12 = ENOMEM) -``` - -The Arduino Uno Q uses a dual-processor architecture: -- **Qualcomm QRB2210** - Linux MPU (host) -- **STM32U585** - Zephyr MCU (runs Arduino sketches via LLEXT) - -The Zephyr firmware is configured with `CONFIG_LLEXT_HEAP_SIZE=128` (128KB). The OpenPLC-generated binary was **241KB** - nearly twice the heap limit. - -### Binary Size Comparison - -| Binary | Size | -|--------|------| -| Simple Arduino IDE sketch | ~25 KB | -| OpenPLC blink program | ~241 KB | -| LLEXT heap limit | 128 KB | - -## Root Cause: Code Duplication - -Analysis with `arm-zephyr-eabi-nm` revealed the problem: - -1. **582 IEC function blocks** were compiled into the binary -2. Each function appeared **4 times** at different addresses -3. Functions like `TON_init__`, `TON_body__`, `SM_16DIN_init__` were duplicated - -The duplication occurred because: -1. `iec_std_FB.h` defines all IEC function blocks as `static` functions -2. This header is included by 4 different `.c` files: `Res0.c`, `Config0.c`, `glueVars.c`, and `POUS.c` (via `POUS.h`) -3. Since `static` functions have internal linkage, each translation unit gets its own copy -4. The linker cannot deduplicate them - -Additionally, hardware-specific modules (P1AM, Click PLC SM_CARDS, etc.) were unconditionally included even when not needed. - -## Solution - -### 1. Conditional Module Includes (`iec_std_FB.h`) - -Added `#ifdef` guards around hardware-specific function block includes: - -```c -#ifdef USE_P1AM_BLOCKS -#include "p1am_FB.h" -#endif - -#ifdef USE_SM_BLOCKS -#include "sm_cards.h" -#endif - -#ifdef USE_MQTT_BLOCKS -#include "MQTT.h" -#endif -// etc. -``` - -### 2. Single-Compilation Function Definitions (`iec_std_FB.h`) - -Restructured the header to compile function bodies only once: - -```c -#ifndef IEC_STD_FB_IMPL -// Extern declarations for when implementations are not included -extern void TON_init__(TON *data__, BOOL retain); -extern void TON_body__(TON *data__); -// ... all other function declarations -#else -// Function definitions - compiled only once -void TON_init__(TON *data__, BOOL retain) { - // implementation -} -// ... all other function definitions -#endif -``` - -### 3. Template Update (`glueVars.c.j2`) - -Modified the glueVars.c template to define `IEC_STD_FB_IMPL`: - -```c -// Include IEC function block implementations in this file only. -// Other files get only type definitions to avoid code duplication. -#define IEC_STD_FB_IMPL -#include "iec_std_lib.h" -``` - -This ensures only `glueVars.c` compiles the function bodies, while other files get extern declarations. - -## Results - -| Stage | Total Size | .text Section | Function Count | -|-------|-----------|---------------|----------------| -| Original | 241 KB | 233 KB | 582 (4× duplicates) | -| After module guards | 140 KB | 132 KB | 374 | -| **After deduplication** | **56 KB** | **49 KB** | **98 (unique)** | - -**77% size reduction** - well under the 128KB LLEXT heap limit. - -## Files Modified - -### openplc-editor -- `resources/sources/MatIEC/lib/iec_std_FB.h` - Added conditional compilation guards and extern declarations - -### xml2st -- `templates/glueVars.c.j2` - Added `#define IEC_STD_FB_IMPL` - -## Why Arduino Mega Worked - -The Arduino Mega (8-bit AVR) uses a different build process: -- AVR-GCC performs more aggressive dead code elimination -- The `.hex` file contains only final linked code after unused symbols are stripped -- No dynamic loading - code is flashed directly to MCU - -The Zephyr LLEXT system loads the entire relocatable ELF into heap memory for dynamic linking, making binary size critical. - -## Testing - -After applying these fixes: -1. Binary size reduced to 56KB (well under 128KB limit) -2. LED blinks correctly on Arduino Uno Q -3. No regressions observed on other Arduino boards - -## Future Considerations - -1. Consider making the `USE_*_BLOCKS` macros automatically defined based on PLC program analysis -2. Further optimization could include dead code elimination for unused standard function blocks -3. The same optimization benefits all Arduino targets, not just Uno Q diff --git a/docs/outdated/HEADLESS_SETUP.md b/docs/outdated/HEADLESS_SETUP.md deleted file mode 100644 index 63383d2ef..000000000 --- a/docs/outdated/HEADLESS_SETUP.md +++ /dev/null @@ -1,357 +0,0 @@ -# OpenPLC Editor Headless Setup Guide - -This guide explains how to run the OpenPLC Editor GUI application in a headless Linux environment using the automated setup script. - -## Overview - -The `setup-headless-vnc.sh` script automates the complete setup of a headless environment for running the OpenPLC Editor with browser-based GUI access through noVNC. This is particularly useful for: - -- Running the OpenPLC Editor on headless servers -- Automating GUI testing in CI/CD pipelines -- Remote access to the editor through a web browser -- Development and testing in containerized environments - -## Architecture - -The setup creates the following service stack: - -``` -┌─────────────────────────────────────────────┐ -│ Web Browser (http://localhost:8080) │ -│ User interacts with GUI via noVNC │ -└────────────────┬────────────────────────────┘ - │ -┌────────────────▼────────────────────────────┐ -│ noVNC (WebSocket to VNC proxy) │ -│ Port 8080 │ -└────────────────┬────────────────────────────┘ - │ -┌────────────────▼────────────────────────────┐ -│ x11vnc (VNC Server) │ -│ Port 5900 │ -└────────────────┬────────────────────────────┘ - │ -┌────────────────▼────────────────────────────┐ -│ Xvfb (Virtual X11 Display) │ -│ Display :99 │ -│ ┌──────────────────────────────────────┐ │ -│ │ fluxbox (Window Manager) │ │ -│ │ ┌────────────────────────────────┐ │ │ -│ │ │ OpenPLC Editor (Electron App) │ │ │ -│ │ └────────────────────────────────┘ │ │ -│ └──────────────────────────────────────┘ │ -└─────────────────────────────────────────────┘ -``` - -## Prerequisites - -The script will verify that the following dependencies are installed: - -### System Packages -- `xvfb` - Virtual X11 display server -- `x11vnc` - VNC server for X11 -- `fluxbox` - Lightweight window manager -- `scrot` - Screenshot utility -- `libfuse2` - Required for running AppImage files -- `python3` - Python runtime - -### Node.js Dependencies -- `node` - Node.js runtime -- `npm` - Node package manager - -### Additional Tools -- `noVNC` - Web-based VNC client (cloned to `~/noVNC`) -- `websockify` - WebSocket to TCP proxy (in `~/noVNC/utils/websockify`) - -### Installing Dependencies - -On Ubuntu/Debian systems: - -```bash -# Install system packages -sudo apt-get update -sudo apt-get install -y xvfb x11vnc fluxbox scrot libfuse2 python3 - -# Install Node.js (if not already installed) -# Option 1: Using nvm (recommended) -curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash -source ~/.bashrc -nvm install --lts - -# Option 2: Using package manager -sudo apt-get install -y nodejs npm - -# Clone noVNC and websockify -git clone https://github.com/novnc/noVNC.git ~/noVNC -git clone https://github.com/novnc/websockify ~/noVNC/utils/websockify -``` - -## Usage - -### Basic Usage - -Run the script with the path to your openplc-editor repository: - -```bash -./setup-headless-vnc.sh /path/to/openplc-editor -``` - -### Advanced Options - -The script supports several command-line options to customize the setup: - -```bash -./setup-headless-vnc.sh [options] - -Options: - -d, --display NUM Display number (default: 99) - -v, --vnc-port PORT VNC port (default: 5900) - -n, --novnc-port PORT noVNC web port (default: 8080) - -r, --resolution RES Screen resolution (default: 1920x1080x24) - -h, --help Show help message - -Examples: - # Use custom display and ports - ./setup-headless-vnc.sh ~/repos/openplc-editor --display 98 --vnc-port 5901 - - # Use custom resolution - ./setup-headless-vnc.sh ~/repos/openplc-editor --resolution 1280x720x24 - - # Combine multiple options - ./setup-headless-vnc.sh ~/repos/openplc-editor \ - --display 100 \ - --vnc-port 5902 \ - --novnc-port 8081 -``` - -## Accessing the GUI - -After running the script, you can access the OpenPLC Editor GUI in two ways: - -### 1. Web Browser (Recommended) - -Open your web browser and navigate to: -``` -http://localhost:8080/vnc.html -``` - -Click the "Connect" button to start interacting with the GUI. This method works from any device that can access your server, including mobile devices (when properly exposed). - -### 2. VNC Client - -Use any VNC client to connect to: -``` -localhost:5900 -``` - -No password is required for the connection. - -## What the Script Does - -1. **Dependency Check**: Verifies all required dependencies are installed -2. **Repository Validation**: Confirms the provided path contains a valid openplc-editor repository -3. **Service Cleanup**: Stops any existing instances of the services -4. **Xvfb Startup**: Launches the virtual X11 display server -5. **Window Manager**: Starts fluxbox for window management -6. **VNC Server**: Starts x11vnc to expose the display via VNC protocol -7. **noVNC Proxy**: Launches the WebSocket-to-VNC proxy for browser access -8. **Build Check**: Verifies the application is built or builds it if needed -9. **AppImage Extraction**: Extracts the AppImage for direct execution -10. **Application Launch**: Starts the OpenPLC Editor in the virtual display -11. **Status Report**: Provides a summary of all running services and access methods - -## Service Management - -### Viewing Service Status - -After running the script, it will display PIDs for all services: - -``` -Service Status: - • Xvfb: Display :99 (PID: 1234) - • fluxbox: Window manager (PID: 1235) - • x11vnc: Port 5900 (PID: 1236) - • noVNC: Port 8080 (PID: 1237) - • OpenPLC Editor: Running (PID: 1238) -``` - -### Viewing Logs - -Logs are written to `/tmp/` for easy debugging: - -```bash -# View x11vnc logs -tail -f /tmp/x11vnc.log - -# View noVNC logs -tail -f /tmp/novnc.log - -# View OpenPLC Editor logs -tail -f /tmp/openplc-editor.log -``` - -### Stopping Services - -To stop all services, run the commands provided in the script output: - -```bash -pkill -f 'Xvfb :99' -pkill -f 'x11vnc.*-display :99' -pkill -f 'websockify.*8080' -pkill -f 'fluxbox.*-display :99' -pkill -f 'open-plc-editor' -``` - -Or stop individual services by their PIDs: - -```bash -kill -``` - -## Troubleshooting - -### Script Reports Missing Dependencies - -If the script reports missing dependencies, install them using the commands provided in the error messages. - -### Port Already in Use - -If you get errors about ports already being in use, either: -1. Stop the existing services using the commands above -2. Use different ports with the `--vnc-port` and `--novnc-port` options - -### Display Already in Use - -If display :99 is already in use, specify a different display number: -```bash -./setup-headless-vnc.sh /path/to/repo --display 100 -``` - -### Application Fails to Start - -Check the application logs: -```bash -tail -50 /tmp/openplc-editor.log -``` - -Common issues: -- **Missing node_modules**: The script will automatically run `npm install` if needed -- **Build artifacts missing**: The script will run `npm run package` to build the AppImage -- **Permission errors**: Ensure you have read/write access to the repository directory - -### noVNC Connection Issues - -1. Verify x11vnc is running: -```bash -ps aux | grep x11vnc -``` - -2. Check x11vnc logs: -```bash -cat /tmp/x11vnc.log -``` - -3. Verify noVNC is running: -```bash -ps aux | grep websockify -``` - -### Browser Shows Black Screen - -This usually means the OpenPLC Editor hasn't started yet. Wait a few seconds and refresh the browser. Check the application logs if the issue persists. - -## Remote Access - -### From Another Machine on the Same Network - -If you want to access the GUI from another machine on your local network: - -```bash -# On the server, expose the noVNC port -# Replace with your server's IP address -# Users can then access http://:8080/vnc.html -``` - -Note: This is insecure as noVNC runs without authentication. Only use on trusted networks. - -### Via SSH Tunnel (Secure) - -For secure remote access over the internet: - -```bash -# On your local machine -ssh -L 8080:localhost:8080 user@server-address - -# Then access http://localhost:8080/vnc.html in your local browser -``` - -## CI/CD Integration - -This setup is ideal for automated testing in CI/CD pipelines: - -```yaml -# Example GitHub Actions workflow -- name: Setup Headless Environment - run: | - sudo apt-get install -y xvfb x11vnc fluxbox scrot libfuse2 - git clone https://github.com/novnc/noVNC.git ~/noVNC - git clone https://github.com/novnc/websockify ~/noVNC/utils/websockify - -- name: Start OpenPLC Editor - run: | - chmod +x setup-headless-vnc.sh - ./setup-headless-vnc.sh $(pwd) - -- name: Run GUI Tests - run: | - # Your test commands here - python test_gui.py -``` - -## Performance Considerations - -- **Display Resolution**: Lower resolutions use less CPU and bandwidth. Use `--resolution 1280x720x24` for better performance if high resolution isn't needed. -- **Color Depth**: The 24 in `1920x1080x24` represents color depth. Reducing this can improve performance on slower connections. -- **Network Bandwidth**: noVNC streams the display over WebSockets. On slow connections, you may experience lag. - -## Security Considerations - -⚠️ **Important Security Notes**: - -1. **No Authentication**: The default setup has no authentication on the VNC server. Do not expose these ports to untrusted networks. -2. **Unencrypted Connection**: The VNC connection is not encrypted by default. Use SSH tunnels for remote access. -3. **Localhost Only**: x11vnc is configured to listen on localhost only (`-listen localhost`), which is secure by default. -4. **Production Use**: For production environments, consider adding authentication and SSL/TLS encryption. - -## Known Limitations - -### Mouse Click Automation - -Electron applications (including OpenPLC Editor) implement security features that block synthetic mouse input from tools like `xdotool` and `PyAutoGUI`. This is intentional security to prevent UI automation attacks. - -**Workaround**: Use the browser-based noVNC interface where genuine user input events are respected. For automated testing, consider: -- Keyboard navigation (`Tab`, `Enter`, arrow keys work reliably) -- Browser automation tools that can interact with the noVNC canvas -- Chrome DevTools Protocol (if the app is launched with `--remote-debugging-port`) - -### Mobile Device Limitations - -While noVNC works on mobile browsers, the touch interface may not be as responsive as on desktop browsers. For best experience, use a desktop or laptop computer. - -## Additional Resources - -- [noVNC Project](https://github.com/novnc/noVNC) -- [x11vnc Documentation](https://github.com/LibVNC/x11vnc) -- [Xvfb Manual](https://www.x.org/releases/X11R7.6/doc/man/man1/Xvfb.1.xhtml) -- [OpenPLC Editor Repository](https://github.com/Autonomy-Logic/openplc-editor) - -## Support - -For issues with: -- **This script**: Open an issue in the openplc-editor repository -- **OpenPLC Editor**: Check the main repository documentation -- **noVNC/VNC**: Refer to the respective project documentation - -## License - -This script is part of the OpenPLC Editor project and follows the same license. diff --git a/docs/outdated/dead-code-inventory.md b/docs/outdated/dead-code-inventory.md deleted file mode 100644 index 803a9315d..000000000 --- a/docs/outdated/dead-code-inventory.md +++ /dev/null @@ -1,69 +0,0 @@ -# Dead Code Inventory - -Audit date: 2026-03-17 -Branch: `fix/step-31-final-adjustments` - -This document tracks dead code identified during a code quality audit. -Items here are documented for later cleanup — they are not blocking any work. - ---- - -## Unused Exported Function - -### `getVariableSize()` — `src/frontend/utils/variable-sizes.ts:101-146` - -Exported function that takes a full `PLCVariable` object and returns byte size. -Superseded by `getTypeSizeByName()` (line 152) which takes just a type name string. - -- Never imported by any other file in the codebase. -- Not called internally within the file. -- The sibling function `getTypeSizeByName` covers the same logic and is actively used. - -**Action:** Remove the function. No callers to update. - ---- - -## Unnecessary Export Keyword - -### `parseVariableValue()` — `src/frontend/utils/variable-sizes.ts:189-258` - -Exported but only called internally by `parseValueByTypeName()` (line 277). -No external file imports `parseVariableValue` directly. - -**Action:** Remove the `export` keyword. The function itself is live code (used by `parseValueByTypeName` which is consumed by `useDebugPolling.ts`). - ---- - -## Commented-Out Imports - -These are leftover commented imports that should be removed: - -| File | Line | Content | -|------|------|---------| -| `src/main/modules/preload/preload.ts` | 1 | `// import './splash-screen/index'` | -| `src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts` | 4 | `// import type { VariableNode } from '...'` | -| `src/types/common/project.ts` | 1, 3 | `// import { z } from 'zod'`, `// import { CONSTANTS, formatDate } from '@/utils'` | -| `src/frontend/utils/PLC/xml-generator/codesys/pou-xml.ts` | 1 | `// import { PLCVariable } from '@root/types/PLC'` | -| `src/frontend/components/_organisms/global-variables-editor/index.tsx` | 1 | `// import * as PrimitiveSwitch from '@radix-ui/react-switch'` | - -**Action:** Delete each commented line. - ---- - -## TODO/FIXME Comments (Incomplete Implementations) - -These are not dead code but flag missing validation logic: - -| File | Description | -|------|-------------| -| `src/types/PLC/open-plc.ts:165-168` | Task name uniqueness — needs homologation | -| `src/types/PLC/open-plc.ts:234-236` | Schema validation TODOs | -| `src/types/PLC/units/task.ts` | Task interval regex validation missing | -| `src/types/PLC/units/instance.ts` | Instance task/program validation missing | -| `src/types/PLC/units/library.ts` | Library validation TODOs | -| `src/backend/editor/hardware/hardware-module.ts` | TODO comment | -| `src/backend/editor/compiler/compiler-module.ts` | TODO comment | -| `src/backend/editor/services/project-service/utils/read-project.ts` | TODO comment | -| `src/main/main.ts` | Multiple TODO comments | - -**Action:** Track as separate backlog items, not dead code cleanup. diff --git a/docs/outdated/debugger-opcua-shared-utilities.md b/docs/outdated/debugger-opcua-shared-utilities.md deleted file mode 100644 index 285b589b6..000000000 --- a/docs/outdated/debugger-opcua-shared-utilities.md +++ /dev/null @@ -1,544 +0,0 @@ -# Debugger and OPC-UA Shared Utilities Refactoring - -## Overview - -This document outlines the plan to refactor the OpenPLC Editor codebase to eliminate code duplication between the **debugger** and **OPC-UA** subsystems. Both systems need to resolve debug variable indices from the `debug.c` file generated during compilation, but currently they use separate implementations with duplicated logic. - -## Problem Statement - -### Current State - -The debugger and OPC-UA configuration generator both need to: - -1. Parse `debug.c` to extract variable entries with their indices -2. Build debug paths in specific formats (`RES0__INSTANCE.VAR`, `CONFIG0__GLOBAL`, etc.) -3. Match PLC variables to debug entries to resolve indices -4. Handle complex types (function blocks, structures, arrays) - -However, these functionalities are implemented **twice**: - -| Functionality | Debugger Implementation | OPC-UA Implementation | -|--------------|------------------------|----------------------| -| Parse debug.c | `renderer/utils/parse-debug-file.ts` | `utils/debug-variable-finder.ts` | -| Build paths | `renderer/utils/debug-tree-builder.ts` | `utils/debug-variable-finder.ts` | -| Find indices | `parse-debug-file.ts:matchVariableWithDebugEntry()` | `debug-variable-finder.ts:findVariableIndex()` | -| FB/struct lookup | Manual in `debug-tree-builder.ts` | `utils/pou-helpers.ts` | -| Tree traversal | `debug-tree-builder.ts` | `opcua/resolve-indices.ts` | - -### Why This Is a Problem - -1. **Bug Duplication**: A fix in one system may not be applied to the other -2. **Inconsistent Behavior**: Subtle differences in path building can cause one system to work while the other fails -3. **Maintenance Burden**: Changes to debug.c format require updates in multiple places -4. **Testing Overhead**: Both implementations need separate test coverage - -### Recent Example - -During OPC-UA development, shared utilities were created (`debug-variable-finder.ts`, `pou-helpers.ts`) but the debugger was not updated to use them. This led to confusion when debugging issues because the systems used different code paths. - -## Architecture Analysis - -### Debug Path Formats in debug.c - -The IEC 61131-3 compiler generates `debug.c` with variable paths in these formats: - -```c -// Global variables -{ &(CONFIG0__GLOBAL_VAR), INT_ENUM } - -// Program variables (simple) -{ &(RES0__INSTANCE0.MOTOR_SPEED), INT_ENUM } - -// Function block instance variables -{ &(RES0__INSTANCE0.TON0.Q), BOOL_ENUM } -{ &(RES0__INSTANCE0.TON0.ET), TIME_ENUM } - -// Nested FB variables -{ &(RES0__INSTANCE0.CONTROLLER0.INNER_FB0.VALUE), REAL_ENUM } - -// Structure fields (note the .value. segment) -{ &(RES0__INSTANCE0.MY_STRUCT.value.FIELD1), INT_ENUM } - -// Array elements (note .value.table[i]) -{ &(RES0__INSTANCE0.MY_ARRAY.value.table[0]), INT_ENUM } -{ &(RES0__INSTANCE0.MY_ARRAY.value.table[1]), INT_ENUM } -``` - -Key observations: -- Instance names come from Resources configuration, NOT POU names -- FB variables use direct dot notation (no `.value.`) -- Structure fields require `.value.` before field name -- Arrays require `.value.table[i]` syntax - -### Current Debugger Files - -``` -src/renderer/ -├── utils/ -│ ├── parse-debug-file.ts # Parses debug.c, matchVariableWithDebugEntry() -│ └── debug-tree-builder.ts # Builds UI tree, has own path building -├── components/_organisms/ -│ └── workspace-activity-bar/ -│ └── default.tsx # Initializes debugger, calls parsing -└── screens/ - └── workspace-screen.tsx # Polling loop, builds paths inline -``` - -### Current OPC-UA Files - -``` -src/utils/ -├── debug-variable-finder.ts # Path building, variable lookup (SHARED) -├── pou-helpers.ts # FB/struct lookup (SHARED) -└── opcua/ - ├── resolve-indices.ts # Uses shared utilities - ├── generate-opcua-config.ts # Generates JSON config - └── types.ts # Type definitions -``` - -### Duplication Details - -#### 1. Debug File Parsing - -**Debugger** (`parse-debug-file.ts`): -```typescript -export function parseDebugFile(content: string): ParsedDebugData { - const variables: DebugVariable[] = [] - const debugVarsMatch = content.match(/debug_vars\[\]\s*=\s*\{([\s\S]*?)\};/) - // ... parsing logic - return { variables, totalCount } -} -``` - -**Shared** (`debug-variable-finder.ts`): -```typescript -export function parseDebugVariables(content: string): DebugVariableEntry[] { - const variables: DebugVariableEntry[] = [] - const debugVarsMatch = content.match(/debug_vars\[\]\s*=\s*\{([\s\S]*?)\};/) - // ... nearly identical parsing logic - return variables -} -``` - -#### 2. Path Building - -**Debugger** (`debug-tree-builder.ts`): -```typescript -function buildVariableBasePath(variableName: string, instanceName: string, variableClass?: string): string { - if (variableClass === 'external') { - return `CONFIG0__${variableNameUpper}` - } - return `RES0__${instanceNameUpper}.${variableNameUpper}` -} -``` - -**Shared** (`debug-variable-finder.ts`): -```typescript -export function buildDebugPath(instanceName: string, variablePath: string, options = {}): string { - // More comprehensive implementation handling all path formats -} - -export function buildGlobalDebugPath(variablePath: string): string { - return `CONFIG0__${variablePath.toUpperCase()}` -} -``` - -#### 3. Index Lookup - -**Debugger** (`parse-debug-file.ts`): -```typescript -export function matchVariableWithDebugEntry( - pouVariableName: string, - instanceName: string, - debugVariables: DebugVariable[], - variableClass?: string, -): number | null { - const expectedPath = `RES0__${instanceNameUpper}.${variableNameUpper}` - const match = debugVariables.find((dv) => dv.name === expectedPath) - return match ? match.index : null -} -``` - -**Shared** (`debug-variable-finder.ts`): -```typescript -export function findVariableIndex( - instanceName: string, - variablePath: string, - debugVariables: DebugVariableEntry[], - options = {}, -): number | null { - const debugPath = buildDebugPath(instanceName, variablePath, options) - const match = findDebugVariable(debugVariables, debugPath) - return match ? match.index : null -} -``` - -## Refactoring Plan - -The refactoring will be done in 6 phases, each building on the previous. This incremental approach minimizes risk and allows testing at each stage. - -### Phase 1: Consolidate Debug File Parsing - -**Goal**: Single source of truth for parsing `debug.c` - -**Rationale**: The parsing logic is nearly identical in both implementations. Having a single parser ensures consistent behavior and makes it easier to handle any future changes to the debug.c format. - -**Changes**: - -1. Create canonical parser in `src/utils/debug-parser.ts`: - ```typescript - // src/utils/debug-parser.ts - export interface DebugVariableEntry { - name: string // Full path (e.g., "RES0__INSTANCE0.VAR") - type: string // Type enum (e.g., "INT_ENUM") - index: number // Array index in debug_vars[] - } - - export function parseDebugVariables(content: string): DebugVariableEntry[] - ``` - -2. Update `renderer/utils/parse-debug-file.ts` to delegate: - ```typescript - // Re-export from shared module - export { parseDebugVariables as parseDebugFile } from '@root/utils/debug-parser' - - // Keep DebugVariable interface for backwards compatibility - export type DebugVariable = DebugVariableEntry - ``` - -3. Update `utils/debug-variable-finder.ts` to import from `debug-parser.ts` - -**Files Modified**: -- Create: `src/utils/debug-parser.ts` -- Modify: `src/renderer/utils/parse-debug-file.ts` -- Modify: `src/utils/debug-variable-finder.ts` - -**Testing**: -- Existing debugger tests should pass unchanged -- Existing OPC-UA tests should pass unchanged -- Add shared parser unit tests - ---- - -### Phase 2: Unify Path Building - -**Goal**: Single implementation for all debug path formats - -**Rationale**: Path building is where subtle bugs can occur. The shared `buildDebugPath()` already handles more cases than the debugger's `buildVariableBasePath()`. Unifying ensures both systems handle all path formats correctly. - -**Changes**: - -1. Ensure `buildDebugPath()` handles all cases: - - Simple variables: `RES0__INSTANCE.VAR` - - FB instance variables: `RES0__INSTANCE.FB.VAR` - - Nested FB variables: `RES0__INSTANCE.FB1.FB2.VAR` - - Structure fields: `RES0__INSTANCE.STRUCT.value.FIELD` - - Array elements: `RES0__INSTANCE.ARR.value.table[i]` - - Global variables: `CONFIG0__VAR` - -2. Update `debug-tree-builder.ts` to use shared path builder: - ```typescript - // Before - const fullPath = buildVariableBasePath(variable.name, instanceName, variable.class) - - // After - import { buildDebugPath, buildGlobalDebugPath } from '@root/utils/debug-variable-finder' - const fullPath = variable.class === 'external' - ? buildGlobalDebugPath(variable.name) - : buildDebugPath(instanceName, variable.name) - ``` - -3. Keep `buildVariableBasePath()` as deprecated wrapper during transition - -**Files Modified**: -- Modify: `src/utils/debug-variable-finder.ts` (ensure all path formats) -- Modify: `src/renderer/utils/debug-tree-builder.ts` - -**Testing**: -- Test all path format variations -- Verify debugger tree building still works -- Test with nested FBs, arrays of structs, etc. - ---- - -### Phase 3: Unify Variable Index Lookup - -**Goal**: Single function for finding variable indices - -**Rationale**: Index lookup depends on correct path building. By this phase, paths are unified, so lookup can also be unified. - -**Changes**: - -1. Update `workspace-activity-bar/default.tsx` to use shared utilities: - ```typescript - // Before - import { parseDebugFile, matchVariableWithDebugEntry } from '../utils/parse-debug-file' - const index = matchVariableWithDebugEntry(v.name, instance.name, parsed.variables, v.class) - - // After - import { parseDebugVariables } from '@root/utils/debug-parser' - import { findVariableIndex, findGlobalVariableIndex } from '@root/utils/debug-variable-finder' - const index = v.class === 'external' - ? findGlobalVariableIndex(v.name, debugVariables) - : findVariableIndex(instance.name, v.name, debugVariables) - ``` - -2. Deprecate `matchVariableWithDebugEntry()` and `matchGlobalVariableWithDebugEntry()` - -**Files Modified**: -- Modify: `src/renderer/components/_organisms/workspace-activity-bar/default.tsx` -- Modify: `src/renderer/utils/parse-debug-file.ts` (mark functions deprecated) - -**Testing**: -- Verify debugger initialization works correctly -- Test with various variable types (local, external, FB instances) -- Ensure index map is populated correctly - ---- - -### Phase 4: Unify Tree Building - -**Goal**: Shared tree/leaf variable traversal for both debugger and OPC-UA - -**Rationale**: Both systems need to traverse complex types (FBs, structs, arrays) to find leaf variables. The traversal logic is complex and having it in one place reduces bugs. - -**Changes**: - -1. Create shared tree traversal with visitor pattern: - ```typescript - // src/utils/debug-tree-traversal.ts - - export interface DebugNodeVisitor { - visitLeaf(path: string, compositeKey: string, type: string, index: number | undefined): T - visitComplex(path: string, compositeKey: string, type: string, children: T[]): T - visitArray(path: string, compositeKey: string, elementType: string, indices: [number, number], children: T[]): T - } - - export function traverseVariable( - variable: PouVariable, - pouName: string, - instanceName: string, - debugVariables: DebugVariableEntry[], - projectPous: PLCPou[], - dataTypes: PLCDataType[], - visitor: DebugNodeVisitor - ): T - ``` - -2. Debugger implements visitor for `DebugTreeNode`: - ```typescript - const debuggerVisitor: DebugNodeVisitor = { - visitLeaf: (path, key, type, index) => ({ - name: path.split('.').pop()!, - fullPath: path, - compositeKey: key, - type, - isComplex: false, - debugIndex: index - }), - // ... other methods - } - ``` - -3. OPC-UA implements visitor for `ResolvedField[]`: - ```typescript - const opcuaVisitor: DebugNodeVisitor = { - visitLeaf: (path, key, type, index) => [{ - name: path, - datatype: type, - index: index!, - // ... - }], - // ... other methods - } - ``` - -**Files Created**: -- `src/utils/debug-tree-traversal.ts` - -**Files Modified**: -- Modify: `src/renderer/utils/debug-tree-builder.ts` (use shared traversal) -- Modify: `src/utils/opcua/resolve-indices.ts` (use shared traversal) - -**Testing**: -- Test with deeply nested FBs -- Test with arrays of structures -- Test with mixed complex types -- Verify both debugger and OPC-UA produce correct results - ---- - -### Phase 5: Update Debugger Polling - -**Goal**: Consistent index lookup in polling code - -**Rationale**: The polling loop in `workspace-screen.tsx` builds paths inline. Using shared utilities ensures consistency with initialization. - -**Changes**: - -1. Replace inline path construction: - ```typescript - // Before - const debugPath = `RES0__${programInstance.name.toUpperCase()}.${fbInstance.name.toUpperCase()}.${fbVar.name.toUpperCase()}` - - // After - import { buildDebugPath } from '@root/utils/debug-variable-finder' - const debugPath = buildDebugPath(programInstance.name, `${fbInstance.name}.${fbVar.name}`) - ``` - -2. Use `findInstanceName()` instead of manual instance lookup - -3. Centralize FB variable iteration logic - -**Files Modified**: -- Modify: `src/renderer/screens/workspace-screen.tsx` - -**Testing**: -- Test debugger polling with real hardware -- Verify all variable types update correctly -- Test force variable functionality - ---- - -### Phase 6: Remove Deprecated Code - -**Goal**: Clean up duplicate implementations after verification - -**Prerequisites**: -- All phases 1-5 completed -- Debugger verified working with real hardware -- OPC-UA verified working with real hardware -- No regressions in functionality -- All tests passing - -**Removals**: - -1. **`src/renderer/utils/parse-debug-file.ts`**: - - Remove `parseDebugFile()` implementation body (keep as re-export) - - Remove `matchVariableWithDebugEntry()` function - - Remove `matchGlobalVariableWithDebugEntry()` function - - Remove `parsePathComponents()` helper - - Keep file with re-exports for backwards compatibility - -2. **`src/renderer/utils/debug-tree-builder.ts`**: - - Remove `buildVariableBasePath()` function - - Remove `normalizeTypeString()` (use from `pou-helpers.ts`) - - Remove duplicate FB/struct lookup code - - Update to be thin wrapper around shared traversal - -3. **`src/renderer/screens/workspace-screen.tsx`**: - - Remove any remaining inline path building - -4. **Update all imports** to point to canonical shared locations - -**Final File Structure**: - -``` -src/ -├── utils/ -│ ├── debug-parser.ts # CANONICAL: Parse debug.c -│ ├── debug-variable-finder.ts # CANONICAL: Path building, index lookup -│ ├── debug-tree-traversal.ts # CANONICAL: Tree traversal (NEW) -│ ├── pou-helpers.ts # CANONICAL: FB/struct lookup -│ └── opcua/ -│ ├── resolve-indices.ts # Uses shared utilities -│ ├── generate-opcua-config.ts -│ └── types.ts -├── renderer/ -│ ├── utils/ -│ │ ├── parse-debug-file.ts # Re-exports only (backwards compat) -│ │ └── debug-tree-builder.ts # UI wrapper using shared traversal -│ ├── components/_organisms/ -│ │ └── workspace-activity-bar/ -│ │ └── default.tsx # Uses shared utilities -│ └── screens/ -│ └── workspace-screen.tsx # Uses shared utilities -└── types/ - └── debugger.ts # Shared debug node types -``` - -**Testing**: -- Full regression test suite -- Manual testing with various PLC programs -- Test edge cases (empty programs, complex nesting) - -## Migration Strategy - -### Backwards Compatibility - -During the transition, we maintain backwards compatibility by: - -1. **Re-exporting**: Old import paths continue to work -2. **Wrapper functions**: Deprecated functions delegate to new implementations -3. **Type aliases**: Old type names map to new types - -Example: -```typescript -// src/renderer/utils/parse-debug-file.ts (during transition) - -// Re-export new parser with old name -export { parseDebugVariables as parseDebugFile } from '@root/utils/debug-parser' - -// Type alias for backwards compatibility -export type { DebugVariableEntry as DebugVariable } from '@root/utils/debug-parser' - -// Deprecated wrapper (to be removed in Phase 6) -/** @deprecated Use findVariableIndex from debug-variable-finder instead */ -export function matchVariableWithDebugEntry(...) { - console.warn('matchVariableWithDebugEntry is deprecated') - return findVariableIndex(...) -} -``` - -### Testing Strategy - -Each phase includes specific testing requirements: - -1. **Unit Tests**: Test shared utilities in isolation -2. **Integration Tests**: Test debugger and OPC-UA with shared code -3. **Hardware Tests**: Verify with actual PLC hardware -4. **Regression Tests**: Ensure no existing functionality breaks - -### Rollback Plan - -If issues are discovered: - -1. Each phase can be reverted independently -2. Deprecated wrappers provide fallback during transition -3. Git branches allow easy rollback to previous state - -## Timeline Estimate - -| Phase | Complexity | Dependencies | -|-------|------------|--------------| -| Phase 1 | Low | None | -| Phase 2 | Medium | Phase 1 | -| Phase 3 | Medium | Phase 2 | -| Phase 4 | High | Phase 2, 3 | -| Phase 5 | Medium | Phase 2 | -| Phase 6 | Low | All above + verification | - -Recommended order: 1 → 2 → 3 → 5 → 4 → 6 - -(Phase 5 can be done before Phase 4 as it only requires path building) - -## Success Criteria - -The refactoring is complete when: - -1. ✅ Single debug.c parser used by both systems -2. ✅ Single path building implementation -3. ✅ Single index lookup implementation -4. ✅ Shared tree traversal logic -5. ✅ No duplicate implementations remain -6. ✅ All tests pass -7. ✅ Debugger works correctly with hardware -8. ✅ OPC-UA works correctly with hardware -9. ✅ Code coverage maintained or improved - -## References - -- `src/utils/debug-variable-finder.ts` - Current shared path building -- `src/utils/pou-helpers.ts` - Current shared POU helpers -- `src/renderer/utils/debug-tree-builder.ts` - Current debugger tree builder -- `src/utils/opcua/resolve-indices.ts` - Current OPC-UA index resolution diff --git a/docs/outdated/external-binaries-strategy.md b/docs/outdated/external-binaries-strategy.md deleted file mode 100644 index 3dc928216..000000000 --- a/docs/outdated/external-binaries-strategy.md +++ /dev/null @@ -1,383 +0,0 @@ -# External Binaries Strategy - -> **Purpose**: Guide the migration of pre-built binaries (xml2st, matiec, arduino-cli) out of the -> openplc-editor git repository, replacing them with versioned downloads from GitHub Releases. - ---- - -## Table of Contents - -1. [Problem Statement](#problem-statement) -2. [Current State](#current-state) -3. [Target Architecture](#target-architecture) -4. [Phase 1 — Tagged Releases on xml2st and matiec](#phase-1--tagged-releases-on-xml2st-and-matiec) -5. [Phase 2 — Download Script in openplc-editor](#phase-2--download-script-in-openplc-editor) -6. [Phase 3 — Update openplc-editor CI/CD](#phase-3--update-openplc-editor-cicd) -7. [Phase 4 — Remove Binaries from Git](#phase-4--remove-binaries-from-git) -8. [Alternatives Considered](#alternatives-considered) -9. [Key Design Decisions](#key-design-decisions) -10. [Implementation Checklist](#implementation-checklist) - ---- - -## Problem Statement - -The openplc-editor repository contains **~350 MB** of pre-built binaries committed directly to git -under `resources/bin/`. Every time xml2st or matiec is updated, new binaries are committed, bloating -the repository history. There is no version tracking — it is impossible to know which version of -xml2st or matiec is bundled at any given commit. - -## Current State - -### Repository layout - -``` -resources/ -├── bin/ -│ ├── darwin/ -│ │ ├── x64/ (68 MB) — arduino-cli, iec2c, xml2st/ -│ │ └── arm64/ (66 MB) — arduino-cli, iec2c, xml2st/ -│ ├── linux/ -│ │ ├── x64/ (57 MB) — arduino-cli, iec2c, xml2st -│ │ └── arm64/ (33 MB) — arduino-cli only (incomplete) -│ └── win32/ -│ ├── x64/ (63 MB) — arduino-cli.exe, iec2c.exe, xml2st.exe -│ └── arm64/ (63 MB) — arduino-cli.exe, iec2c.exe, xml2st.exe -└── sources/ - └── MatIEC/lib/ — matiec IEC standard library files (596 KB) -``` - -### Tool summary - -| Tool | Repository | Language | CI/CD | Tagged Releases | Extra Files | -|---|---|---|---|---|---| -| xml2st | Autonomy-Logic/xml2st | Python (PyInstaller) | Build workflow for 6 platforms | None | plcopen/, templates/ (bundled by PyInstaller) | -| matiec (iec2c) | Autonomy-Logic/matiec | C++ (Bison/Flex/Autotools) | None | None | lib/ dir (596 KB) with .txt + C headers | -| arduino-cli | arduino/arduino-cli | Go | Official | Yes (official releases) | None | - -### How binaries are consumed - -The `CompilerModule` class (`src/main/modules/compiler/compiler-module.ts`) resolves binary paths -at runtime: - -``` -Development: {cwd}/resources/bin/{platform}/{arch}/{binary} -Production: {resourcesPath}/bin/{binary} -``` - -`electron-builder.json` copies the platform-specific `resources/bin/{platform}/{arch}` directory -into the packaged app as `extraResources`. - -The MatIEC standard library is consumed from `resources/sources/MatIEC/lib/` by the compilation -pipeline. - -### Compilation pipeline - -``` -XML Project File → xml2st → ST File → iec2c → C/C++ Files → arduino-cli → Board Binary -``` - ---- - -## Target Architecture - -``` -openplc-editor/ -├── binary-versions.json ← Single source of truth for tool versions -├── scripts/ -│ └── download-binaries.ts ← Downloads release artifacts from GitHub -├── resources/ -│ ├── bin/ ← .gitignored, populated by download script -│ │ └── {platform}/{arch}/ -│ └── sources/ -│ └── MatIEC/lib/ ← .gitignored, populated by download script -``` - -Version bumps become a one-line diff in `binary-versions.json`. - ---- - -## Phase 1 — Tagged Releases on xml2st and matiec - -### 1.1 xml2st (Autonomy-Logic/xml2st) - -**Status**: Already has a CI build workflow for all 6 platforms. Needs a release workflow. - -**Tasks**: - -- [ ] Add a release workflow (`.github/workflows/release.yml`) triggered on tag push (`v*`) -- [ ] Standardize artifact naming: `xml2st-{platform}-{arch}.tar.gz` - - `xml2st-darwin-arm64.tar.gz` - - `xml2st-darwin-x64.tar.gz` - - `xml2st-linux-arm64.tar.gz` - - `xml2st-linux-x64.tar.gz` - - `xml2st-win32-arm64.zip` - - `xml2st-win32-x64.zip` -- [ ] On macOS, the artifact is a **directory** (xml2st/ with _internal/ containing Python - runtime). The tarball must preserve this directory structure. -- [ ] On Linux and Windows, the artifact is a **single executable** (`-F` flag in PyInstaller). -- [ ] Create a GitHub Release with all 6 artifacts attached -- [ ] Tag the current development branch as `v1.0.0` - -### 1.2 matiec (Autonomy-Logic/matiec) - -**Status**: Has no CI/CD at all. Needs a complete pipeline. - -**Tasks**: - -- [ ] Create `.github/workflows/release.yml` triggered on tag push (`v*`) -- [ ] Build matrix covering 6 platform/arch combinations: - - `macos-15` (arm64), `macos-13` (x64) - - `ubuntu-22.04` (x64), `ubuntu-22.04-arm` (arm64) - - `windows-latest` (x64), Windows ARM64 (cross-compile or native runner) -- [ ] Build dependencies per platform: - - **Linux**: `sudo apt-get install -y build-essential bison flex autoconf automake` - - **macOS**: `brew install bison flex autoconf automake` (or use Xcode tools) - - **Windows**: MinGW or MSYS2 with bison, flex, autoconf, automake -- [ ] Build steps: `autoreconf -i && ./configure && make` -- [ ] Package output as `matiec-{platform}-{arch}.tar.gz` containing: - - `iec2c` binary (or `iec2c.exe` on Windows) - - `lib/` directory (all .txt files + C/ subdirectory with headers) -- [ ] Create a GitHub Release with all 6 artifacts attached -- [ ] Tag the current development branch as `v1.0.0` - -**Note on Windows ARM64**: matiec uses autotools which may need MSYS2 or cross-compilation. If a -native Windows ARM64 runner is not available, cross-compile from x64 or use the x64 binary (runs -via emulation on Windows ARM64). Evaluate feasibility during implementation. - ---- - -## Phase 2 — Download Script in openplc-editor - -### 2.1 Version manifest: `binary-versions.json` - -Create at repository root: - -```json -{ - "xml2st": { - "version": "v1.0.0", - "repository": "Autonomy-Logic/xml2st" - }, - "matiec": { - "version": "v1.0.0", - "repository": "Autonomy-Logic/matiec" - }, - "arduino-cli": { - "version": "1.1.1", - "repository": "arduino/arduino-cli" - } -} -``` - -### 2.2 Download script: `scripts/download-binaries.ts` - -**Responsibilities**: - -1. Read `binary-versions.json` -2. Determine current platform and architecture (or accept `--platform` / `--arch` overrides) -3. Construct GitHub Release download URLs: - - xml2st: `https://github.com/Autonomy-Logic/xml2st/releases/download/{tag}/xml2st-{platform}-{arch}.tar.gz` - - matiec: `https://github.com/Autonomy-Logic/matiec/releases/download/{tag}/matiec-{platform}-{arch}.tar.gz` - - arduino-cli: `https://github.com/arduino/arduino-cli/releases/download/v{version}/arduino-cli_{version}_{os}_{arch}.tar.gz` -4. Download and extract to `resources/bin/{platform}/{arch}/` -5. Copy matiec `lib/` to `resources/sources/MatIEC/lib/` -6. Write a `.binary-metadata.json` inside `resources/bin/` recording downloaded versions (for cache - checking) - -**Behavioral rules**: - -- **Cache check**: If binaries already exist and `.binary-metadata.json` matches - `binary-versions.json`, skip download. Use `--force` to override. -- **Dev mode**: Downloads only the current platform's binaries (not all 6). -- **CI mode**: Accepts `--platform` and `--arch` flags for cross-platform builds. -- **Error handling**: Clear error messages if GitHub is unreachable or a release artifact is missing. - -### 2.3 npm integration - -```jsonc -// package.json -{ - "scripts": { - "setup:binaries": "ts-node scripts/download-binaries.ts", - "postinstall": "ts-node scripts/download-binaries.ts && ts-node scripts/check-native-dep.js && electron-builder install-app-deps" - } -} -``` - -Running `npm install` will automatically fetch the correct binaries for the developer's platform. - -### 2.4 Platform / architecture mapping - -The download script must map Node.js `process.platform` and `process.arch` values to artifact -naming conventions. Each tool may use slightly different naming: - -| Node.js | xml2st artifact | matiec artifact | arduino-cli artifact | -|---|---|---|---| -| darwin/arm64 | xml2st-darwin-arm64 | matiec-darwin-arm64 | arduino-cli_{v}_macOS_ARM64 | -| darwin/x64 | xml2st-darwin-x64 | matiec-darwin-x64 | arduino-cli_{v}_macOS_64bit | -| linux/arm64 | xml2st-linux-arm64 | matiec-linux-arm64 | arduino-cli_{v}_Linux_ARM64 | -| linux/x64 | xml2st-linux-x64 | matiec-linux-x64 | arduino-cli_{v}_Linux_64bit | -| win32/arm64 | xml2st-win32-arm64 | matiec-win32-arm64 | arduino-cli_{v}_Windows_ARM64 | -| win32/x64 | xml2st-win32-x64 | matiec-win32-x64 | arduino-cli_{v}_Windows_64bit | - ---- - -## Phase 3 — Update openplc-editor CI/CD - -### 3.1 Modify `release.yml` - -Add a binary download step **before** the build step in each platform job: - -```yaml -- name: Download tool binaries - run: npx ts-node scripts/download-binaries.ts -``` - -For cross-architecture builds (e.g., Windows ARM64 on x64 runner): - -```yaml -- name: Download tool binaries (ARM64) - run: npx ts-node scripts/download-binaries.ts --platform win32 --arch arm64 -``` - -### 3.2 macOS builds - -The macOS job builds **both** x64 and arm64 in a single run. The download script must be called -twice (once per architecture), or accept a `--all-arch` flag to download both: - -```yaml -- name: Download binaries for x64 - run: npx ts-node scripts/download-binaries.ts --arch x64 - -- name: Download binaries for arm64 - run: npx ts-node scripts/download-binaries.ts --arch arm64 -``` - -### 3.3 Caching (optional optimization) - -Use GitHub Actions cache to avoid re-downloading binaries across CI runs when versions haven't -changed: - -```yaml -- name: Cache tool binaries - uses: actions/cache@v4 - with: - path: resources/bin - key: binaries-${{ runner.os }}-${{ hashFiles('binary-versions.json') }} -``` - ---- - -## Phase 4 — Remove Binaries from Git - -### 4.1 Gitignore - -Add to `.gitignore`: - -```gitignore -# External tool binaries (downloaded by scripts/download-binaries.ts) -resources/bin/ -resources/sources/MatIEC/lib/ -``` - -### 4.2 Remove tracked files - -```bash -git rm -r --cached resources/bin/ -git rm -r --cached resources/sources/MatIEC/lib/ -git commit -m "chore: remove pre-built binaries from git tracking" -``` - -### 4.3 (Optional) Rewrite git history - -The binary blobs will remain in git history forever unless history is rewritten. This is a -**destructive operation** that requires coordination with all contributors: - -```bash -# Using git-filter-repo (recommended over BFG) -git filter-repo --path resources/bin/ --invert-paths -``` - -**Recommendation**: Do this in a separate effort after the new system is proven stable. It requires -force-pushing and all contributors to re-clone. - ---- - -## Alternatives Considered - -| Alternative | Why Not | -|---|---| -| **Git submodules** | Still requires building from source after clone. Doesn't solve the pre-built binary distribution problem. | -| **Git LFS** | Binaries still stored (in LFS server); versioning is no better; adds LFS dependency for all developers. | -| **npm packages** | xml2st and matiec aren't JavaScript. Publishing native binaries as npm packages is non-standard and awkward. | -| **Build from source on npm install** | Requires bison/flex/autoconf for matiec and Python/PyInstaller for xml2st on every dev machine. Too fragile, too many dependencies. | -| **Docker-based builds** | Overkill for a desktop Electron app's development workflow. | -| **Vendored tarballs** | Still bloats the repo, just with compressed files instead of raw binaries. | - ---- - -## Key Design Decisions - -### 1. `binary-versions.json` as single source of truth - -When upgrading xml2st, change one line in this file. The PR diff clearly shows "upgraded xml2st -from v1.2.0 to v1.3.0" — no binary blobs, clear audit trail. - -### 2. Download on `postinstall` - -Makes `npm install` self-sufficient — a new developer clones the repo, runs `npm install`, and -has everything needed to develop. Includes a cache check to avoid redundant downloads. - -### 3. arduino-cli from official releases - -Arduino CLI already has proper GitHub Releases with pre-built binaries for every platform. No -changes needed to the Arduino project — just download their official artifacts. - -### 4. matiec lib/ bundled in release tarball - -The matiec release artifact includes both the `iec2c` binary and the `lib/` directory. The download -script extracts `iec2c` to `resources/bin/` and `lib/` to `resources/sources/MatIEC/lib/`. This -keeps them versioned together (they must always match). - -### 5. Platform-specific downloads in development - -A developer on macOS ARM only downloads ~70 MB (darwin-arm64 binaries) instead of ~350 MB (all -platforms). CI downloads only what it needs for the target platform. - ---- - -## Implementation Checklist - -Phases should be executed in order. Each phase can be merged independently. - -### Phase 1: Release Pipelines - -- [ ] **1.1** Create xml2st release workflow with standardized artifact names -- [ ] **1.2** Tag xml2st `v1.0.0` -- [ ] **1.3** Create matiec CI/CD build workflow for all 6 platforms -- [ ] **1.4** Create matiec release workflow with standardized artifact names -- [ ] **1.5** Tag matiec `v1.0.0` -- [ ] **1.6** Verify all 12 release artifacts (6 per tool) download and work correctly - -### Phase 2: Download Script - -- [ ] **2.1** Create `binary-versions.json` in openplc-editor repo root -- [ ] **2.2** Implement `scripts/download-binaries.ts` -- [ ] **2.3** Test on all 3 platforms (macOS, Linux, Windows) -- [ ] **2.4** Hook into `postinstall` in package.json -- [ ] **2.5** Update CLAUDE.md / README with new setup instructions - -### Phase 3: CI/CD Integration - -- [ ] **3.1** Update `release.yml` to use download script instead of committed binaries -- [ ] **3.2** Handle cross-architecture builds (macOS dual-arch, Windows ARM64) -- [ ] **3.3** Add GitHub Actions caching for binaries -- [ ] **3.4** Run a full release build and verify all 6 distribution packages work - -### Phase 4: Cleanup - -- [ ] **4.1** Add `resources/bin/` and `resources/sources/MatIEC/lib/` to `.gitignore` -- [ ] **4.2** Remove binaries from git tracking (`git rm --cached`) -- [ ] **4.3** (Optional) Rewrite git history to purge old binary blobs -- [ ] **4.4** Update contributor documentation diff --git a/docs/outdated/name-type-linking-design.md b/docs/outdated/name-type-linking-design.md deleted file mode 100644 index 047332850..000000000 --- a/docs/outdated/name-type-linking-design.md +++ /dev/null @@ -1,1608 +0,0 @@ -# Name+Type-Based Variable Linking Design - -## Executive Summary - -This document defines the technical design for migrating from UUID-based variable linking to name+type-based linking in the OpenPLC Editor, following IEC 61131-3 standards. The design addresses all six core linking rules specified in the migration plan and provides a clear implementation path for Phase 2. - -**Design Principles:** -- Case-insensitive name matching (IEC 61131-3 standard) -- Type compatibility validation -- Automatic variable creation for Function Blocks -- User-controlled reference updates on rename -- Broken link detection on type changes -- Backward compatibility with existing projects - ---- - -## 1. Core Linking Rules - -The new system must satisfy these requirements: - -### Rule 1: Element-Variable Association -Elements (contacts, coils, function block instances, variable boxes) must be associated with a variable. - -### Rule 2: Name+Type Matching -Variables are linked to elements based on **case-insensitive name AND type matching** (IEC 61131-3 standard). - -### Rule 3: Function Block Instantiation -When a Function Block is added, a variable is automatically created (Function Blocks must be instantiated). - -### Rule 4: Automatic Cleanup -If a Function Block is deleted and no other Function Block uses its variable, the variable should be deleted. - -### Rule 5: Rename Propagation -Variable renaming should prompt user to update references: -- If declined: links break -- If accepted: find-and-replace all references - -### Rule 6: Type Change Validation -Variable type changes should recheck all elements using that variable - break links if type no longer matches. - ---- - -## 2. Composite Key Data Structure - -### 2.1 Variable Reference Type - -```typescript -/** - * Composite key for identifying a variable reference in the system. - * Replaces the previous UUID-based identification. - */ -export type VariableReference = { - /** POU name where the variable is defined (for scoping) */ - pouName: string - - /** Variable name (case-insensitive matching) */ - variableName: string - - /** Variable type for compatibility checking */ - variableType: PLCVariable['type'] -} - -/** - * Normalized form for lookups and comparisons. - * Names are converted to lowercase for case-insensitive matching. - */ -export type NormalizedVariableReference = { - pouName: string // lowercase - variableName: string // lowercase - variableType: PLCVariable['type'] -} -``` - -### 2.2 Variable Scope Handling - -```typescript -/** - * Scope determines where to search for variables. - */ -export type VariableScope = 'local' | 'global' - -/** - * Extended reference that includes scope information. - */ -export type ScopedVariableReference = VariableReference & { - scope: VariableScope -} -``` - -### 2.3 Block-Variable Connection - -```typescript -/** - * Replaces the current LadderBlockConnectedVariables type. - * Removes handleTableId (ID-based) and uses name+type instead. - */ -export type BlockConnectedVariable = { - /** Handle name on the block (e.g., "EN", "IN1", "OUT") */ - handleName: string - - /** Direction of the connection */ - type: 'input' | 'output' - - /** Reference to the connected variable (name+type) */ - variableRef: VariableReference | null - - /** Cached variable data for performance (updated on sync) */ - variable: PLCVariable | undefined -} - -export type BlockConnectedVariables = BlockConnectedVariable[] -``` - -### 2.4 Element Variable Association - -```typescript -/** - * Replaces the current variable field in node data. - * Used for contacts, coils, and block instances. - */ -export type ElementVariableAssociation = { - /** Variable reference (name+type) */ - ref: VariableReference - - /** Cached variable data (updated on sync) */ - variable: PLCVariable | undefined - - /** Validation state */ - isValid: boolean - validationError?: string -} -``` - ---- - -## 3. Type Compatibility System - -### 3.1 Type Comparison Logic - -```typescript -/** - * Type compatibility result. - */ -export type TypeCompatibility = { - isCompatible: boolean - reason?: string -} - -/** - * Checks if a variable type is compatible with an expected type. - * Handles base types, derived types, user-defined types, and arrays. - */ -export function checkTypeCompatibility( - variableType: PLCVariable['type'], - expectedType: PLCVariable['type'] | string, - dataTypes: PLCDataType[], -): TypeCompatibility { - // Case 1: Expected type is a string (simple type name) - if (typeof expectedType === 'string') { - return checkSimpleTypeCompatibility(variableType, expectedType, dataTypes) - } - - // Case 2: Expected type is a full type object - return checkComplexTypeCompatibility(variableType, expectedType, dataTypes) -} - -/** - * Simple type compatibility (e.g., "BOOL", "INT", "MyFunctionBlock") - */ -function checkSimpleTypeCompatibility( - variableType: PLCVariable['type'], - expectedTypeName: string, - dataTypes: PLCDataType[], -): TypeCompatibility { - const varTypeName = getTypeName(variableType) - const expectedNormalized = expectedTypeName.toUpperCase() - const varNormalized = varTypeName.toUpperCase() - - // Direct match - if (varNormalized === expectedNormalized) { - return { isCompatible: true } - } - - // Check if expected type is a user-defined type - const userType = dataTypes.find(dt => dt.name.toUpperCase() === expectedNormalized) - if (userType) { - // For structures and enums, check if variable type matches - if (variableType.definition === 'user-data-type' && - variableType.value.toUpperCase() === expectedNormalized) { - return { isCompatible: true } - } - } - - return { - isCompatible: false, - reason: `Type mismatch: expected ${expectedTypeName}, got ${varTypeName}` - } -} - -/** - * Complex type compatibility (full type objects) - */ -function checkComplexTypeCompatibility( - variableType: PLCVariable['type'], - expectedType: PLCVariable['type'], - dataTypes: PLCDataType[], -): TypeCompatibility { - // Definition must match - if (variableType.definition !== expectedType.definition) { - return { - isCompatible: false, - reason: `Definition mismatch: expected ${expectedType.definition}, got ${variableType.definition}` - } - } - - // Value must match (case-insensitive) - const varValue = variableType.value.toUpperCase() - const expectedValue = expectedType.value.toUpperCase() - - if (varValue !== expectedValue) { - return { - isCompatible: false, - reason: `Type mismatch: expected ${expectedType.value}, got ${variableType.value}` - } - } - - // For arrays, check dimensions - if (variableType.definition === 'array' && expectedType.definition === 'array') { - // Array dimension checking would go here - // For now, we consider arrays compatible if base types match - } - - return { isCompatible: true } -} - -/** - * Extract type name from a type object. - */ -function getTypeName(type: PLCVariable['type']): string { - return type.value -} -``` - -### 3.2 Contact/Coil Type Restrictions - -```typescript -/** - * Contacts and coils require BOOL type. - */ -export function validateContactCoilType(variableType: PLCVariable['type']): TypeCompatibility { - const typeName = getTypeName(variableType).toUpperCase() - - if (typeName === 'BOOL') { - return { isCompatible: true } - } - - return { - isCompatible: false, - reason: 'Contacts and coils require BOOL type variables' - } -} -``` - -### 3.3 Block Input/Output Type Validation - -```typescript -/** - * Validates that a variable matches a block's input/output type. - */ -export function validateBlockConnectionType( - variableType: PLCVariable['type'], - blockVariableType: BlockVariant['variables'][0]['type'], - dataTypes: PLCDataType[], -): TypeCompatibility { - const expectedType = { - definition: blockVariableType.definition as PLCVariable['type']['definition'], - value: blockVariableType.value, - } - - return checkComplexTypeCompatibility(variableType, expectedType, dataTypes) -} -``` - ---- - -## 4. Variable Lookup System - -### 4.1 Lookup by Name+Type - -```typescript -/** - * Finds a variable by name and type within a scope. - * Replaces getVariableBasedOnRowIdOrVariableId. - */ -export function findVariableByNameAndType( - variables: PLCVariable[], - variableName: string, - expectedType?: PLCVariable['type'] | string, - dataTypes?: PLCDataType[], -): PLCVariable | null { - const normalizedName = variableName.toLowerCase() - - // Find all variables with matching name (case-insensitive) - const matchingVariables = variables.filter( - v => v.name.toLowerCase() === normalizedName - ) - - // If no type specified, return first match - if (!expectedType) { - return matchingVariables[0] || null - } - - // If type specified, find compatible match - for (const variable of matchingVariables) { - const compatibility = checkTypeCompatibility( - variable.type, - expectedType, - dataTypes || [] - ) - - if (compatibility.isCompatible) { - return variable - } - } - - return null -} - -/** - * Finds a variable by reference (name+type+scope). - */ -export function findVariableByReference( - ref: VariableReference, - projectData: PLCProjectData, -): PLCVariable | null { - // Determine scope - const pou = projectData.pous.find(p => p.data.name === ref.pouName) - - if (!pou) { - return null - } - - // Search in local variables - const localVar = findVariableByNameAndType( - pou.data.variables, - ref.variableName, - ref.variableType, - projectData.dataTypes - ) - - if (localVar) { - return localVar - } - - // Search in global variables - return findVariableByNameAndType( - projectData.configuration.resource.globalVariables, - ref.variableName, - ref.variableType, - projectData.dataTypes - ) -} -``` - -### 4.2 Scoped Lookup - -```typescript -/** - * Finds a variable with explicit scope control. - */ -export function findVariableInScope( - variableName: string, - scope: VariableScope, - pouName: string, - projectData: PLCProjectData, - expectedType?: PLCVariable['type'] | string, -): PLCVariable | null { - if (scope === 'global') { - return findVariableByNameAndType( - projectData.configuration.resource.globalVariables, - variableName, - expectedType, - projectData.dataTypes - ) - } - - // Local scope - const pou = projectData.pous.find(p => p.data.name === pouName) - if (!pou) { - return null - } - - return findVariableByNameAndType( - pou.data.variables, - variableName, - expectedType, - projectData.dataTypes - ) -} -``` - -### 4.3 Uniqueness Validation - -```typescript -/** - * Checks if a variable name is unique within its scope. - * IEC 61131-3 requires case-insensitive uniqueness. - */ -export function isVariableNameUnique( - variableName: string, - scope: VariableScope, - pouName: string, - projectData: PLCProjectData, - excludeVariable?: PLCVariable, -): boolean { - const normalizedName = variableName.toLowerCase() - - const variables = scope === 'global' - ? projectData.configuration.resource.globalVariables - : projectData.pous.find(p => p.data.name === pouName)?.data.variables || [] - - const conflicts = variables.filter(v => - v.name.toLowerCase() === normalizedName && - v !== excludeVariable - ) - - return conflicts.length === 0 -} -``` - ---- - -## 5. Reference Validation and Broken Link Detection - -### 5.1 Validation Result Type - -```typescript -/** - * Result of validating a variable reference. - */ -export type ValidationResult = { - isValid: boolean - variable?: PLCVariable - error?: string - errorType?: 'not-found' | 'type-mismatch' | 'scope-error' -} -``` - -### 5.2 Reference Validation - -```typescript -/** - * Validates a variable reference and returns the variable if valid. - */ -export function validateVariableReference( - ref: VariableReference, - projectData: PLCProjectData, -): ValidationResult { - const variable = findVariableByReference(ref, projectData) - - if (!variable) { - return { - isValid: false, - error: `Variable '${ref.variableName}' not found in POU '${ref.pouName}'`, - errorType: 'not-found' - } - } - - // Check type compatibility - const compatibility = checkTypeCompatibility( - variable.type, - ref.variableType, - projectData.dataTypes - ) - - if (!compatibility.isCompatible) { - return { - isValid: false, - variable, - error: compatibility.reason, - errorType: 'type-mismatch' - } - } - - return { - isValid: true, - variable - } -} -``` - -### 5.3 Broken Link Detection - -```typescript -/** - * Finds all broken references in a POU's graphical diagram. - */ -export function findBrokenReferences( - pouName: string, - projectData: PLCProjectData, - ladderFlows: LadderFlowState['ladderFlows'], -): BrokenReference[] { - const brokenRefs: BrokenReference[] = [] - - const flow = ladderFlows.find(f => f.name === pouName) - if (!flow) return brokenRefs - - flow.rungs.forEach(rung => { - rung.nodes.forEach(node => { - // Check contacts and coils - if (node.type === 'contact' || node.type === 'coil') { - const data = node.data as { variable?: ElementVariableAssociation } - if (data.variable?.ref) { - const validation = validateVariableReference(data.variable.ref, projectData) - if (!validation.isValid) { - brokenRefs.push({ - nodeId: node.id, - rungId: rung.id, - elementType: node.type, - ref: data.variable.ref, - error: validation.error!, - errorType: validation.errorType! - }) - } - } - } - - // Check blocks - if (node.type === 'block') { - const data = node.data as BlockNodeData - - // Check instance variable - if (data.variable?.ref) { - const validation = validateVariableReference(data.variable.ref, projectData) - if (!validation.isValid) { - brokenRefs.push({ - nodeId: node.id, - rungId: rung.id, - elementType: 'block-instance', - ref: data.variable.ref, - error: validation.error!, - errorType: validation.errorType! - }) - } - } - - // Check connected variables - data.connectedVariables?.forEach(conn => { - if (conn.variableRef) { - const validation = validateVariableReference(conn.variableRef, projectData) - if (!validation.isValid) { - brokenRefs.push({ - nodeId: node.id, - rungId: rung.id, - elementType: 'block-connection', - handleName: conn.handleName, - ref: conn.variableRef, - error: validation.error!, - errorType: validation.errorType! - }) - } - } - }) - } - }) - }) - - return brokenRefs -} - -export type BrokenReference = { - nodeId: string - rungId: string - elementType: 'contact' | 'coil' | 'block-instance' | 'block-connection' - handleName?: string - ref: VariableReference - error: string - errorType: 'not-found' | 'type-mismatch' | 'scope-error' -} -``` - ---- - -## 6. Variable Lifecycle Management - -### 6.1 Automatic Function Block Variable Creation - -```typescript -/** - * Creates a variable for a function block instance. - * Implements Rule 3: Function Blocks must be instantiated. - */ -export function createFunctionBlockVariable( - blockName: string, - blockType: string, - pouName: string, - projectData: PLCProjectData, -): { variable: PLCVariable; name: string } { - // Generate unique name - const baseName = blockType.toUpperCase() - let counter = 0 - let variableName = baseName - - while (!isVariableNameUnique(variableName, 'local', pouName, projectData)) { - counter++ - variableName = `${baseName}${counter}` - } - - // Create variable - const variable: PLCVariable = { - name: variableName, - class: 'local', - type: { - definition: 'derived', - value: blockType - }, - location: '', - documentation: `Instance of ${blockType}`, - debug: false - } - - return { variable, name: variableName } -} -``` - -### 6.2 Automatic Cleanup on Function Block Deletion - -```typescript -/** - * Checks if a function block variable is still in use. - * Implements Rule 4: Delete unused FB variables. - */ -export function isFunctionBlockVariableInUse( - variableName: string, - pouName: string, - ladderFlows: LadderFlowState['ladderFlows'], - fbdFlows: FBDFlowState['fbdFlows'], -): boolean { - // Check ladder flows - const ladderFlow = ladderFlows.find(f => f.name === pouName) - if (ladderFlow) { - for (const rung of ladderFlow.rungs) { - for (const node of rung.nodes) { - if (node.type === 'block') { - const data = node.data as BlockNodeData - if (data.variable?.ref?.variableName.toLowerCase() === variableName.toLowerCase()) { - return true - } - } - } - } - } - - // Check FBD flows - const fbdFlow = fbdFlows.find(f => f.name === pouName) - if (fbdFlow) { - for (const node of fbdFlow.rung.nodes) { - if (node.type === 'block') { - const data = node.data as BlockNodeData - if (data.variable?.ref?.variableName.toLowerCase() === variableName.toLowerCase()) { - return true - } - } - } - } - - return false -} - -/** - * Deletes a function block variable if no longer in use. - */ -export function cleanupFunctionBlockVariable( - variableName: string, - pouName: string, - projectData: PLCProjectData, - ladderFlows: LadderFlowState['ladderFlows'], - fbdFlows: FBDFlowState['fbdFlows'], -): boolean { - if (isFunctionBlockVariableInUse(variableName, pouName, ladderFlows, fbdFlows)) { - return false - } - - // Delete the variable - const pou = projectData.pous.find(p => p.data.name === pouName) - if (!pou) return false - - const index = pou.data.variables.findIndex( - v => v.name.toLowerCase() === variableName.toLowerCase() - ) - - if (index !== -1) { - pou.data.variables.splice(index, 1) - return true - } - - return false -} -``` - ---- - -## 7. Variable Rename Handling - -### 7.1 Find References - -```typescript -/** - * Finds all references to a variable by name (case-insensitive). - * Implements Rule 5: Rename propagation. - */ -export function findVariableReferences( - variableName: string, - pouName: string, - ladderFlows: LadderFlowState['ladderFlows'], - fbdFlows: FBDFlowState['fbdFlows'], -): VariableReferenceLocation[] { - const normalizedName = variableName.toLowerCase() - const references: VariableReferenceLocation[] = [] - - // Search ladder flows - const ladderFlow = ladderFlows.find(f => f.name === pouName) - if (ladderFlow) { - ladderFlow.rungs.forEach(rung => { - rung.nodes.forEach(node => { - if (node.type === 'contact' || node.type === 'coil') { - const data = node.data as { variable?: ElementVariableAssociation } - if (data.variable?.ref?.variableName.toLowerCase() === normalizedName) { - references.push({ - type: 'ladder', - nodeId: node.id, - rungId: rung.id, - elementType: node.type, - currentRef: data.variable.ref - }) - } - } - - if (node.type === 'block') { - const data = node.data as BlockNodeData - - // Check instance variable - if (data.variable?.ref?.variableName.toLowerCase() === normalizedName) { - references.push({ - type: 'ladder', - nodeId: node.id, - rungId: rung.id, - elementType: 'block-instance', - currentRef: data.variable.ref - }) - } - - // Check connected variables - data.connectedVariables?.forEach((conn, index) => { - if (conn.variableRef?.variableName.toLowerCase() === normalizedName) { - references.push({ - type: 'ladder', - nodeId: node.id, - rungId: rung.id, - elementType: 'block-connection', - connectionIndex: index, - currentRef: conn.variableRef - }) - } - }) - } - }) - }) - } - - // Search FBD flows (similar pattern) - const fbdFlow = fbdFlows.find(f => f.name === pouName) - if (fbdFlow) { - // Similar logic for FBD nodes... - } - - return references -} - -export type VariableReferenceLocation = { - type: 'ladder' | 'fbd' - nodeId: string - rungId?: string // ladder only - elementType: 'contact' | 'coil' | 'block-instance' | 'block-connection' | 'variable' - connectionIndex?: number // for block connections - currentRef: VariableReference -} -``` - -### 7.2 Propagate Rename - -```typescript -/** - * Updates all references to use the new variable name. - */ -export function propagateVariableRename( - oldName: string, - newName: string, - pouName: string, - references: VariableReferenceLocation[], - ladderFlows: LadderFlowState['ladderFlows'], - fbdFlows: FBDFlowState['fbdFlows'], - ladderFlowActions: any, - fbdFlowActions: any, -): void { - references.forEach(ref => { - if (ref.type === 'ladder') { - const flow = ladderFlows.find(f => f.name === pouName) - if (!flow) return - - const rung = flow.rungs.find(r => r.id === ref.rungId) - if (!rung) return - - const node = rung.nodes.find(n => n.id === ref.nodeId) - if (!node) return - - // Update the reference - const updatedRef: VariableReference = { - ...ref.currentRef, - variableName: newName - } - - if (ref.elementType === 'contact' || ref.elementType === 'coil') { - const data = node.data as { variable?: ElementVariableAssociation } - if (data.variable) { - data.variable.ref = updatedRef - } - } else if (ref.elementType === 'block-instance') { - const data = node.data as BlockNodeData - if (data.variable) { - data.variable.ref = updatedRef - } - } else if (ref.elementType === 'block-connection' && ref.connectionIndex !== undefined) { - const data = node.data as BlockNodeData - if (data.connectedVariables[ref.connectionIndex]) { - data.connectedVariables[ref.connectionIndex].variableRef = updatedRef - } - } - - // Update the node in the flow - ladderFlowActions.updateNode({ - editorName: pouName, - rungId: ref.rungId!, - nodeId: ref.nodeId, - node - }) - } - - // Similar logic for FBD... - }) -} -``` - -### 7.3 Break References on Declined Rename - -```typescript -/** - * Marks references as broken when user declines rename propagation. - */ -export function breakVariableReferences( - references: VariableReferenceLocation[], - pouName: string, - ladderFlows: LadderFlowState['ladderFlows'], - fbdFlows: FBDFlowState['fbdFlows'], - ladderFlowActions: any, - fbdFlowActions: any, -): void { - references.forEach(ref => { - if (ref.type === 'ladder') { - const flow = ladderFlows.find(f => f.name === pouName) - if (!flow) return - - const rung = flow.rungs.find(r => r.id === ref.rungId) - if (!rung) return - - const node = rung.nodes.find(n => n.id === ref.nodeId) - if (!node) return - - // Mark as invalid - if (ref.elementType === 'contact' || ref.elementType === 'coil') { - const data = node.data as { variable?: ElementVariableAssociation } - if (data.variable) { - data.variable.isValid = false - data.variable.validationError = 'Variable renamed - reference not updated' - } - } else if (ref.elementType === 'block-instance') { - const data = node.data as BlockNodeData - if (data.variable) { - data.variable.isValid = false - data.variable.validationError = 'Variable renamed - reference not updated' - } - } else if (ref.elementType === 'block-connection' && ref.connectionIndex !== undefined) { - const data = node.data as BlockNodeData - const conn = data.connectedVariables[ref.connectionIndex] - if (conn) { - conn.variable = undefined - // Keep the ref but it will fail validation - } - } - - // Update the node in the flow - ladderFlowActions.updateNode({ - editorName: pouName, - rungId: ref.rungId!, - nodeId: ref.nodeId, - node - }) - } - - // Similar logic for FBD... - }) -} -``` - ---- - -## 8. Type Change Handling - -### 8.1 Detect Type Change Impact - -```typescript -/** - * Finds all references that will be broken by a type change. - * Implements Rule 6: Type change validation. - */ -export function findReferencesAffectedByTypeChange( - variableName: string, - oldType: PLCVariable['type'], - newType: PLCVariable['type'], - pouName: string, - projectData: PLCProjectData, - ladderFlows: LadderFlowState['ladderFlows'], - fbdFlows: FBDFlowState['fbdFlows'], -): VariableReferenceLocation[] { - const allReferences = findVariableReferences(variableName, pouName, ladderFlows, fbdFlows) - const affectedReferences: VariableReferenceLocation[] = [] - - allReferences.forEach(ref => { - // Check if new type is compatible with the reference's expected type - const compatibility = checkTypeCompatibility( - newType, - ref.currentRef.variableType, - projectData.dataTypes - ) - - if (!compatibility.isCompatible) { - affectedReferences.push(ref) - } - }) - - return affectedReferences -} -``` - -### 8.2 Break References on Type Change - -```typescript -/** - * Breaks references that are no longer type-compatible. - */ -export function breakReferencesOnTypeChange( - affectedReferences: VariableReferenceLocation[], - pouName: string, - ladderFlows: LadderFlowState['ladderFlows'], - fbdFlows: FBDFlowState['fbdFlows'], - ladderFlowActions: any, - fbdFlowActions: any, -): void { - affectedReferences.forEach(ref => { - if (ref.type === 'ladder') { - const flow = ladderFlows.find(f => f.name === pouName) - if (!flow) return - - const rung = flow.rungs.find(r => r.id === ref.rungId) - if (!rung) return - - const node = rung.nodes.find(n => n.id === ref.nodeId) - if (!node) return - - // Mark as type mismatch - if (ref.elementType === 'contact' || ref.elementType === 'coil') { - const data = node.data as { variable?: ElementVariableAssociation } - if (data.variable) { - data.variable.isValid = false - data.variable.validationError = 'Variable type changed - no longer compatible' - } - } else if (ref.elementType === 'block-instance') { - const data = node.data as BlockNodeData - if (data.variable) { - data.variable.isValid = false - data.variable.validationError = 'Variable type changed - no longer compatible' - } - } else if (ref.elementType === 'block-connection' && ref.connectionIndex !== undefined) { - const data = node.data as BlockNodeData - const conn = data.connectedVariables[ref.connectionIndex] - if (conn) { - conn.variable = undefined - // Ref remains but will fail validation - } - } - - // Update the node in the flow - ladderFlowActions.updateNode({ - editorName: pouName, - rungId: ref.rungId!, - nodeId: ref.nodeId, - node - }) - } - - // Similar logic for FBD... - }) -} -``` - ---- - -## 9. Migration Strategy - -### 9.1 Project Version Detection - -```typescript -/** - * Detects if a project uses the old ID-based system. - */ -export function isLegacyProject(projectData: PLCProjectData): boolean { - // Check if any variables have IDs - const hasVariableIds = projectData.pous.some(pou => - pou.data.variables.some(v => v.id !== undefined) - ) - - const hasGlobalIds = projectData.configuration.resource.globalVariables.some( - v => v.id !== undefined - ) - - return hasVariableIds || hasGlobalIds -} -``` - -### 9.2 Migration Algorithm - -```typescript -/** - * Migrates a project from ID-based to name+type-based linking. - */ -export function migrateProjectToNameTypeLinking( - projectData: PLCProjectData, - ladderFlows: LadderFlowState['ladderFlows'], - fbdFlows: FBDFlowState['fbdFlows'], -): MigrationResult { - const result: MigrationResult = { - success: true, - migratedNodes: 0, - errors: [] - } - - // Step 1: Migrate ladder flows - ladderFlows.forEach(flow => { - const pou = projectData.pous.find(p => p.data.name === flow.name) - if (!pou) return - - flow.rungs.forEach(rung => { - rung.nodes.forEach(node => { - try { - if (node.type === 'contact' || node.type === 'coil') { - migrateContactCoilNode(node, pou, projectData) - result.migratedNodes++ - } else if (node.type === 'block') { - migrateBlockNode(node, pou, projectData) - result.migratedNodes++ - } else if (node.type === 'variable') { - migrateVariableNode(node, pou, projectData) - result.migratedNodes++ - } - } catch (error) { - result.errors.push({ - nodeId: node.id, - rungId: rung.id, - error: error instanceof Error ? error.message : String(error) - }) - } - }) - }) - }) - - // Step 2: Migrate FBD flows (similar pattern) - fbdFlows.forEach(flow => { - // Similar logic... - }) - - // Step 3: Remove IDs from variables - projectData.pous.forEach(pou => { - pou.data.variables.forEach(v => { - delete v.id - }) - }) - - projectData.configuration.resource.globalVariables.forEach(v => { - delete v.id - }) - - result.success = result.errors.length === 0 - return result -} - -function migrateContactCoilNode( - node: Node, - pou: PLCPou, - projectData: PLCProjectData -): void { - const data = node.data as any - - // Old format: { variable: { id?: string, name: string } } - // New format: { variable: ElementVariableAssociation } - - if (!data.variable || !data.variable.name) { - throw new Error('Node has no variable') - } - - const variableName = data.variable.name - - // Find the variable by name - let variable = findVariableByNameAndType( - pou.data.variables, - variableName - ) - - if (!variable) { - variable = findVariableByNameAndType( - projectData.configuration.resource.globalVariables, - variableName - ) - } - - if (!variable) { - throw new Error(`Variable '${variableName}' not found`) - } - - // Create new reference - const ref: VariableReference = { - pouName: pou.data.name, - variableName: variable.name, - variableType: variable.type - } - - // Validate type (contacts/coils need BOOL) - const validation = validateContactCoilType(variable.type) - - // Update node data - data.variable = { - ref, - variable, - isValid: validation.isCompatible, - validationError: validation.reason - } -} - -function migrateBlockNode( - node: Node, - pou: PLCPou, - projectData: PLCProjectData -): void { - const data = node.data as any - - // Migrate instance variable - if (data.variable && data.variable.name) { - const variableName = data.variable.name - - let variable = findVariableByNameAndType( - pou.data.variables, - variableName - ) - - if (!variable) { - variable = findVariableByNameAndType( - projectData.configuration.resource.globalVariables, - variableName - ) - } - - if (variable) { - const ref: VariableReference = { - pouName: pou.data.name, - variableName: variable.name, - variableType: variable.type - } - - data.variable = { - ref, - variable, - isValid: true - } - } - } - - // Migrate connected variables - if (data.connectedVariables && Array.isArray(data.connectedVariables)) { - const newConnectedVariables: BlockConnectedVariable[] = [] - - data.connectedVariables.forEach((conn: any) => { - if (!conn.variable || !conn.variable.name) { - newConnectedVariables.push({ - handleName: conn.handleId || conn.handleName, - type: conn.type, - variableRef: null, - variable: undefined - }) - return - } - - const variableName = conn.variable.name - - let variable = findVariableByNameAndType( - pou.data.variables, - variableName - ) - - if (!variable) { - variable = findVariableByNameAndType( - projectData.configuration.resource.globalVariables, - variableName - ) - } - - if (variable) { - const ref: VariableReference = { - pouName: pou.data.name, - variableName: variable.name, - variableType: variable.type - } - - newConnectedVariables.push({ - handleName: conn.handleId || conn.handleName, - type: conn.type, - variableRef: ref, - variable - }) - } else { - newConnectedVariables.push({ - handleName: conn.handleId || conn.handleName, - type: conn.type, - variableRef: null, - variable: undefined - }) - } - }) - - data.connectedVariables = newConnectedVariables - } -} - -function migrateVariableNode( - node: Node, - pou: PLCPou, - projectData: PLCProjectData -): void { - // Similar to migrateContactCoilNode - migrateContactCoilNode(node, pou, projectData) -} - -export type MigrationResult = { - success: boolean - migratedNodes: number - errors: Array<{ - nodeId: string - rungId?: string - error: string - }> -} -``` - -### 9.3 Backward Compatibility - -```typescript -/** - * Ensures new projects work with old editor versions (graceful degradation). - */ -export function ensureBackwardCompatibility(projectData: PLCProjectData): void { - // Generate IDs for all variables that don't have them - // This allows old editor versions to open new projects - - projectData.pous.forEach(pou => { - pou.data.variables.forEach(v => { - if (!v.id) { - v.id = crypto.randomUUID() - } - }) - }) - - projectData.configuration.resource.globalVariables.forEach(v => { - if (!v.id) { - v.id = crypto.randomUUID() - } - }) -} -``` - ---- - -## 10. Implementation Phases - -### Phase 2A: Core Infrastructure (Week 1-2) -1. Implement new type definitions (VariableReference, ElementVariableAssociation, etc.) -2. Implement type compatibility system -3. Implement new lookup functions (findVariableByNameAndType, etc.) -4. Add unit tests for core functions - -### Phase 2B: Graphical Editor Updates (Week 3-4) -1. Update Ladder editor components (contact, coil, block, variable) -2. Update FBD editor components -3. Replace handleTableId with name+type references -4. Update autocomplete components - -### Phase 2C: Variable Management (Week 5) -1. Update variable creation/deletion logic -2. Implement automatic FB variable creation -3. Implement automatic FB variable cleanup -4. Update variable renaming with propagation - -### Phase 2D: Validation and Synchronization (Week 6) -1. Implement reference validation -2. Implement broken link detection -3. Implement type change handling -4. Add validation UI indicators - -### Phase 2E: Migration and Testing (Week 7-8) -1. Implement project migration algorithm -2. Add migration UI/prompts -3. Comprehensive testing (unit, integration, E2E) -4. Performance testing and optimization - -### Phase 2F: Documentation and Rollout (Week 9) -1. Update user documentation -2. Create migration guide -3. Beta testing with real projects -4. Final release - ---- - -## 11. Testing Strategy - -### 11.1 Unit Tests - -```typescript -describe('Type Compatibility', () => { - it('should match identical base types', () => { - const result = checkTypeCompatibility( - { definition: 'base-type', value: 'BOOL' }, - { definition: 'base-type', value: 'BOOL' }, - [] - ) - expect(result.isCompatible).toBe(true) - }) - - it('should be case-insensitive', () => { - const result = checkTypeCompatibility( - { definition: 'base-type', value: 'bool' }, - { definition: 'base-type', value: 'BOOL' }, - [] - ) - expect(result.isCompatible).toBe(true) - }) - - it('should reject type mismatches', () => { - const result = checkTypeCompatibility( - { definition: 'base-type', value: 'INT' }, - { definition: 'base-type', value: 'BOOL' }, - [] - ) - expect(result.isCompatible).toBe(false) - expect(result.reason).toContain('Type mismatch') - }) -}) - -describe('Variable Lookup', () => { - it('should find variable by name (case-insensitive)', () => { - const variables: PLCVariable[] = [ - { name: 'MyVar', type: { definition: 'base-type', value: 'INT' }, ... } - ] - - const result = findVariableByNameAndType(variables, 'myvar') - expect(result).toBeDefined() - expect(result?.name).toBe('MyVar') - }) - - it('should return null for non-existent variable', () => { - const result = findVariableByNameAndType([], 'NonExistent') - expect(result).toBeNull() - }) -}) -``` - -### 11.2 Integration Tests - -```typescript -describe('Variable Rename Propagation', () => { - it('should update all references when user accepts', async () => { - // Setup: Create project with variable and references - // Action: Rename variable with propagation - // Assert: All references updated - }) - - it('should break references when user declines', async () => { - // Setup: Create project with variable and references - // Action: Rename variable without propagation - // Assert: References marked as broken - }) -}) - -describe('Function Block Lifecycle', () => { - it('should create variable when FB added', () => { - // Setup: Add function block to diagram - // Assert: Variable created in table - }) - - it('should delete variable when last FB removed', () => { - // Setup: Add FB, then remove it - // Assert: Variable deleted from table - }) - - it('should keep variable when other FBs use it', () => { - // Setup: Add two FBs with same variable, remove one - // Assert: Variable still exists - }) -}) -``` - -### 11.3 Migration Tests - -```typescript -describe('Project Migration', () => { - it('should migrate ID-based project to name+type', () => { - // Setup: Load legacy project with IDs - // Action: Run migration - // Assert: All nodes migrated, IDs removed - }) - - it('should handle missing variables gracefully', () => { - // Setup: Project with broken ID references - // Action: Run migration - // Assert: Errors reported, other nodes migrated - }) -}) -``` - ---- - -## 12. Performance Considerations - -### 12.1 Caching Strategy - -```typescript -/** - * Cache for variable lookups to avoid repeated searches. - */ -class VariableLookupCache { - private cache: Map = new Map() - - getCacheKey(name: string, pouName: string, scope: VariableScope): string { - return `${scope}:${pouName}:${name.toLowerCase()}` - } - - get(name: string, pouName: string, scope: VariableScope): PLCVariable | null | undefined { - return this.cache.get(this.getCacheKey(name, pouName, scope)) - } - - set(name: string, pouName: string, scope: VariableScope, variable: PLCVariable | null): void { - this.cache.set(this.getCacheKey(name, pouName, scope), variable) - } - - invalidate(): void { - this.cache.clear() - } - - invalidateScope(pouName: string, scope: VariableScope): void { - const prefix = `${scope}:${pouName}:` - for (const key of this.cache.keys()) { - if (key.startsWith(prefix)) { - this.cache.delete(key) - } - } - } -} -``` - -### 12.2 Batch Validation - -```typescript -/** - * Validates multiple references in a single pass. - */ -export function batchValidateReferences( - refs: VariableReference[], - projectData: PLCProjectData, - cache?: VariableLookupCache -): Map { - const results = new Map() - - refs.forEach(ref => { - const result = validateVariableReference(ref, projectData) - results.set(ref, result) - }) - - return results -} -``` - -### 12.3 Lazy Validation - -```typescript -/** - * Only validate references when needed (on save, compile, or user request). - * Don't validate on every keystroke. - */ -export function scheduleValidation( - pouName: string, - debounceMs: number = 500 -): void { - // Debounce validation to avoid excessive checks - // Implementation would use a debounce utility -} -``` - ---- - -## 13. UI/UX Considerations - -### 13.1 Visual Indicators - -- **Valid reference:** Normal appearance -- **Broken reference (not found):** Red border, red text -- **Type mismatch:** Yellow border, warning icon -- **Pending validation:** Gray/dimmed appearance - -### 13.2 Error Messages - -```typescript -export const ERROR_MESSAGES = { - VARIABLE_NOT_FOUND: (name: string) => - `Variable '${name}' not found. Check the variable table.`, - - TYPE_MISMATCH: (expected: string, actual: string) => - `Type mismatch: expected ${expected}, got ${actual}`, - - CONTACT_COIL_BOOL_ONLY: () => - `Contacts and coils require BOOL type variables`, - - FB_MUST_BE_INSTANTIATED: (blockType: string) => - `Function block '${blockType}' must be instantiated with a variable`, -} -``` - -### 13.3 Rename Dialog - -``` -┌─────────────────────────────────────────────┐ -│ Rename Variable │ -├─────────────────────────────────────────────┤ -│ │ -│ You renamed 'OldName' to 'NewName'. │ -│ │ -│ This variable is used in 5 locations. │ -│ │ -│ Do you want to update all references? │ -│ │ -│ ○ Yes, rename all references │ -│ ○ No, keep old references (will break) │ -│ │ -│ [Cancel] [Apply] │ -└─────────────────────────────────────────────┘ -``` - ---- - -## 14. Summary - -This design provides a complete specification for migrating from ID-based to name+type-based variable linking. Key features: - -**Strengths:** -- ✅ Follows IEC 61131-3 standards (case-insensitive names) -- ✅ Type-safe with comprehensive validation -- ✅ Handles all 6 core linking rules -- ✅ Backward compatible with migration path -- ✅ Performance-optimized with caching -- ✅ Clear error handling and user feedback - -**Implementation Complexity:** -- **High:** Touches many parts of the codebase -- **Mitigated by:** Phased approach, comprehensive testing - -**Risk Mitigation:** -- Migration algorithm preserves data -- Validation catches broken references -- Backward compatibility maintained -- Extensive test coverage planned - -**Next Steps:** -- Review and approve design -- Begin Phase 2A implementation -- Set up CI/CD for automated testing -- Create feature branch for development - ---- - -**Document Version:** 1.0 -**Date:** 2025-10-22 -**Author:** Devin (AI Assistant) -**Status:** Complete - Ready for Review diff --git a/docs/outdated/opcua-server-configuration/01-design-overview.md b/docs/outdated/opcua-server-configuration/01-design-overview.md deleted file mode 100644 index 7edaa6da3..000000000 --- a/docs/outdated/opcua-server-configuration/01-design-overview.md +++ /dev/null @@ -1,414 +0,0 @@ -# OPC-UA Server Configuration - Design Overview - -## 1. Architecture Overview - -### 1.1 System Context - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ OpenPLC Editor │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ OPC-UA Configuration UI │ │ -│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │ -│ │ │ General │ │ Security │ │ Users │ │ Address Space │ │ │ -│ │ │ Settings │ │ Profiles │ │ │ │ (Variables) │ │ │ -│ │ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ Project State (Zustand Store) │ │ -│ │ - OPC-UA config stored WITHOUT indices │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ (on compile) │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ Compiler Module │ │ -│ │ 1. xml2st generates debug.c (with variable indices) │ │ -│ │ 2. Parse debug.c to get index mapping │ │ -│ │ 3. Resolve variable references → indices │ │ -│ │ 4. Generate opcua.json with resolved indices │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ OpenPLC Runtime │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ OPC-UA Plugin │ │ -│ │ Reads opcua.json configuration │ │ -│ │ Exposes variables via OPC-UA protocol │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 1.2 Configuration Flow - -The OPC-UA configuration follows a two-phase approach: - -#### Phase 1: Configuration Time (No Compilation Required) - -1. User opens OPC-UA server configuration from the Servers panel -2. User configures server settings, security profiles, users -3. User selects variables from the project's POU tree structure -4. User configures OPC-UA properties for each selected variable (node ID, permissions, etc.) -5. Configuration is saved in the project file **without variable indices** - -#### Phase 2: Compilation Time (Index Resolution) - -1. User compiles the project -2. xml2st generates `debug.c` with all variable indices -3. Compiler parses `debug.c` using existing `parseDebugFile()` function -4. Compiler matches user's selected variables to their debug indices -5. Compiler generates `opcua.json` with fully resolved indices -6. `opcua.json` is included in the compiled project package - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Configuration Flow Diagram │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌────────────────────┐ ┌────────────────────┐ │ -│ │ Project POUs │ │ OPC-UA Config UI │ │ -│ │ (Programs, FBs, │ ───► │ (Select Variables │ │ -│ │ Global Variables) │ │ Configure Props) │ │ -│ └────────────────────┘ └─────────┬──────────┘ │ -│ │ │ -│ ▼ │ -│ ┌────────────────────┐ │ -│ │ Project File │ │ -│ │ (Config WITHOUT │ │ -│ │ indices) │ │ -│ └─────────┬──────────┘ │ -│ │ │ -│ ▼ [User clicks Compile] │ -│ ┌────────────────────┐ │ -│ │ xml2st │ │ -│ │ (Generates debug.c)│ │ -│ └─────────┬──────────┘ │ -│ │ │ -│ ▼ │ -│ ┌────────────────────┐ ┌────────────────────┐ │ -│ │ parseDebugFile() │ ◄─── │ debug.c │ │ -│ │ (Extract indices) │ │ (Variable indices) │ │ -│ └─────────┬──────────┘ └────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌────────────────────┐ ┌────────────────────┐ │ -│ │ Resolve indices │ ───► │ opcua.json │ │ -│ │ for each variable │ │ (WITH indices) │ │ -│ └────────────────────┘ └────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -## 2. Integration with Existing Systems - -### 2.1 Debugger Integration - -The OPC-UA plugin uses the **same debug variable indices** as the OpenPLC debugger. This means: - -- We reuse `parseDebugFile()` from `src/renderer/utils/parse-debug-file.ts` -- We reuse the path matching logic from the debugger implementation -- Variable indices are consistent between debugger and OPC-UA access - -### 2.2 Project Structure Integration - -OPC-UA configuration follows the same patterns as Modbus and S7Comm: - -- Configuration stored in `project.data.servers[]` array -- Protocol discriminated by `server.protocol === 'opcua'` -- Configuration in `server.opcuaServerConfig` object - -### 2.3 Compiler Integration - -The compiler module generates `opcua.json` similar to how it generates `modbus_slave.json` and `s7comm.json`: - -```typescript -// In compiler-module.ts -async handleGenerateOpcUaConfig(sourceTargetFolderPath, projectData) { - const opcuaConfig = generateOpcUaConfig( - projectData.servers, - parsedDebugVariables // From debug.c parsing - ) - if (opcuaConfig) { - await writeFile( - join(sourceTargetFolderPath, 'conf', 'opcua.json'), - opcuaConfig - ) - } -} -``` - -## 3. Data Model - -### 3.1 Editor Data Model (Stored in Project - No Indices) - -```typescript -interface OpcUaServerConfig { - server: { - enabled: boolean - name: string - applicationUri: string - productUri: string - bindAddress: string - port: number - endpointPath: string - } - - securityProfiles: OpcUaSecurityProfile[] - - security: { - serverCertificateStrategy: 'auto_self_signed' | 'custom' - serverCertificateCustom: string | null - serverPrivateKeyCustom: string | null - trustedClientCertificates: OpcUaTrustedCertificate[] - } - - users: OpcUaUser[] - - cycleTimeMs: number - - addressSpace: { - namespaceUri: string - nodes: OpcUaNodeConfig[] // Hierarchical tree of selected nodes - } -} - -// Variable/Node configuration WITHOUT index -interface OpcUaNodeConfig { - id: string // UUID - - // Reference to PLC variable (resolved to index at compile time) - pouName: string // "main", "GVL", "FB_Motor" - variablePath: string // "MOTOR_SPEED", "TON0.ET", "SENSOR.temperature" - variableType: string // IEC type: "INT", "BOOL", "REAL", etc. - - // OPC-UA node configuration - nodeId: string // "PLC.Main.MotorSpeed" - browseName: string - displayName: string - description: string - initialValue: any - permissions: { - viewer: 'r' | 'w' | 'rw' - operator: 'r' | 'w' | 'rw' - engineer: 'r' | 'w' | 'rw' - } - - // For complex types - nodeType: 'variable' | 'structure' | 'array' - children?: OpcUaNodeConfig[] // For structures: field configs - arrayLength?: number // For arrays: element count -} -``` - -### 3.2 Runtime Data Model (Generated JSON - With Indices) - -The runtime expects a JSON array with the following structure: - -```typescript -interface OpcUaRuntimeConfig { - name: string // "opcua_server" - protocol: "OPC-UA" - config: { - server: { - name: string - application_uri: string - product_uri: string - endpoint_url: string // Full URL: opc.tcp://host:port/path - security_profiles: RuntimeSecurityProfile[] - } - security: { - server_certificate_strategy: string - server_certificate_custom: string | null - server_private_key_custom: string | null - trusted_client_certificates: { id: string; pem: string }[] - } - users: RuntimeUser[] - cycle_time_ms: number - address_space: { - namespace_uri: string - variables: RuntimeVariable[] // With resolved indices - structures: RuntimeStructure[] // With resolved field indices - arrays: RuntimeArray[] // With resolved start indices - } - } -} -``` - -## 4. Variable Hierarchy Support - -### 4.1 Hierarchical Variable Structure - -PLC programs can have deeply nested variable structures: - -``` -Program: main -├── MOTOR_SPEED (INT) → Simple variable -├── IS_RUNNING (BOOL) → Simple variable -├── MOTOR_CONTROLLER (FB_MotorControl) → Function Block instance -│ ├── ENABLE (BOOL) → FB input -│ ├── SPEED_SETPOINT (INT) → FB input -│ ├── ACTUAL_SPEED (INT) → FB output -│ └── PID_CTRL (FB_PID) → Nested FB -│ ├── KP (REAL) -│ ├── KI (REAL) -│ └── OUTPUT (REAL) -├── SENSOR_DATA (T_SensorData) → Structure -│ ├── temperature (REAL) → Struct field -│ ├── pressure (REAL) → Struct field -│ └── status (T_Status) → Nested structure -│ ├── is_valid (BOOL) -│ └── error_code (INT) -├── TEMPERATURES (ARRAY[1..10] OF REAL) → Array -└── SENSOR_ARRAY (ARRAY[1..5] OF T_SensorData) → Array of structures - └── [1] (T_SensorData) - ├── temperature (REAL) - └── ... -``` - -### 4.2 Debug Path Format - -The debugger uses specific path formats for each variable type: - -| Type | Debug Path Format | Example | -|------|-------------------|---------| -| Simple | `RES0__INSTANCE.VARNAME` | `RES0__INSTANCE0.MOTOR_SPEED` | -| FB Variable | `RES0__INSTANCE.FBVAR.FIELD` | `RES0__INSTANCE0.MOTOR_CONTROLLER.ENABLE` | -| Nested FB | `RES0__INSTANCE.FB1.FB2.FIELD` | `RES0__INSTANCE0.MOTOR_CONTROLLER.PID_CTRL.KP` | -| Struct Field | `RES0__INSTANCE.VAR.value.FIELD` | `RES0__INSTANCE0.SENSOR_DATA.value.temperature` | -| Nested Struct | `RES0__INSTANCE.VAR.value.NESTED.value.FIELD` | `RES0__INSTANCE0.SENSOR_DATA.value.status.value.is_valid` | -| Array Element | `RES0__INSTANCE.VAR.value.table[i]` | `RES0__INSTANCE0.TEMPERATURES.value.table[0]` | -| Global | `CONFIG0__VARNAME` | `CONFIG0__SYSTEM_STATE` | - -### 4.3 Index Resolution at Compile Time - -```typescript -const resolveVariableIndex = ( - pouName: string, - variablePath: string, - debugVariables: DebugVariable[] -): number => { - // Build the debug path based on variable type - const debugPath = buildDebugPath(pouName, variablePath) - - // Find matching entry in parsed debug.c - const debugVar = debugVariables.find(dv => - dv.name.toUpperCase() === debugPath.toUpperCase() - ) - - if (!debugVar) { - throw new Error(`Cannot resolve index for ${pouName}:${variablePath}`) - } - - return debugVar.index -} - -const buildDebugPath = (pouName: string, variablePath: string): string => { - // Handle global variables - if (pouName === 'GVL' || pouName === 'CONFIG') { - return `CONFIG0__${variablePath.toUpperCase()}` - } - - // Handle program/FB instance variables - // The instance name format depends on resource configuration - return `RES0__INSTANCE0.${variablePath.toUpperCase()}` -} -``` - -## 5. Security Model - -### 5.1 Security Profiles - -OPC-UA supports multiple security profiles that can be enabled simultaneously: - -| Profile | Security Policy | Security Mode | Auth Methods | -|---------|-----------------|---------------|--------------| -| Insecure | None | None | Anonymous | -| Signed | Basic256Sha256 | Sign | Username, Certificate | -| Encrypted | Basic256Sha256 | SignAndEncrypt | Username, Certificate | - -### 5.2 User Roles and Permissions - -Three user roles with different access levels: - -| Role | Description | Default Variable Access | -|------|-------------|------------------------| -| Viewer | Read-only monitoring | Read only | -| Operator | Can modify operational variables | Read/Write (configurable) | -| Engineer | Full administrative access | Read/Write | - -Each variable can have custom permissions per role. - -## 6. File Structure - -``` -src/ -├── types/PLC/ -│ └── open-plc.ts # Add OPC-UA type schemas -├── utils/ -│ └── opcua/ -│ ├── generate-opcua-config.ts # JSON generator with index resolution -│ ├── bcrypt-utils.ts # Password hashing utilities -│ └── index.ts -├── renderer/ -│ ├── utils/ -│ │ └── parse-debug-file.ts # REUSE for index resolution -│ ├── store/slices/project/ -│ │ └── slice.ts # Add OPC-UA actions -│ └── components/_features/[workspace]/editor/ -│ └── server/ -│ └── opcua-server/ -│ ├── index.tsx # Main tabbed component -│ ├── tabs/ -│ │ ├── general-settings.tsx -│ │ ├── security-profiles.tsx -│ │ ├── certificates.tsx -│ │ ├── users.tsx -│ │ └── address-space.tsx -│ └── components/ -│ ├── variable-tree.tsx -│ ├── variable-config-modal.tsx -│ └── ... -└── main/modules/compiler/ - └── compiler-module.ts # Add OPC-UA JSON generation -``` - -## 7. Key Design Decisions - -### 7.1 Index Resolution at Compile Time - -**Decision**: Store variable references (pouName + variablePath) in project, resolve indices only during compilation. - -**Rationale**: -- Users can configure OPC-UA without building first -- Indices may change when program is modified -- Consistent with how debugger works - -### 7.2 Reuse Debugger Infrastructure - -**Decision**: Reuse `parseDebugFile()` and path matching logic from debugger. - -**Rationale**: -- OPC-UA plugin uses same debug indices as debugger -- Proven, tested code -- Maintains consistency - -### 7.3 Tabbed UI Interface - -**Decision**: Use tabbed interface instead of single scrollable page. - -**Rationale**: -- OPC-UA configuration is significantly more complex than Modbus/S7Comm -- Tabs help organize related settings -- Reduces cognitive load on users - -### 7.4 Tree-Based Variable Selection - -**Decision**: Display all project variables in a tree structure, allowing selection of individual leaves or entire branches (structures/arrays). - -**Rationale**: -- Supports deeply nested hierarchies (FB inside FB inside Program) -- Intuitive selection of complex types -- Consistent with how POUs are structured diff --git a/docs/outdated/opcua-server-configuration/02-ui-screen-specifications.md b/docs/outdated/opcua-server-configuration/02-ui-screen-specifications.md deleted file mode 100644 index 353ed3fb8..000000000 --- a/docs/outdated/opcua-server-configuration/02-ui-screen-specifications.md +++ /dev/null @@ -1,818 +0,0 @@ -# OPC-UA Server Configuration - UI Screen Specifications - -## 1. Overall Layout - -The OPC-UA server configuration uses a **tabbed interface** with 5 main tabs: - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ OPC-UA Server Configuration [X] Close │ -├─────────────────────────────────────────────────────────────────────────┤ -│ ┌──────────┬──────────┬──────────┬──────────┬───────────────────────┐ │ -│ │ General │ Security │ Users │ Certifi- │ Address Space │ │ -│ │ Settings │ Profiles │ │ cates │ │ │ -│ └──────────┴──────────┴──────────┴──────────┴───────────────────────┘ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ │ │ -│ │ [Active Tab Content] │ │ -│ │ │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 2. Tab 1: General Settings - -### 2.1 Layout - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ General Settings │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Enable OPC-UA Server [Toggle: Off] │ -│ │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ Server Identity │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ │ -│ Server Name │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ OpenPLC OPC UA Server │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ Application URI │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ urn:openplc:opcua:server │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ (Unique identifier for this application instance) │ -│ │ -│ Product URI │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ urn:openplc:runtime │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ (Identifier for the product type) │ -│ │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ Network Configuration │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ │ -│ Bind Address Port │ -│ ┌─────────────────────────┐ ┌─────────────────────────┐ │ -│ │ 0.0.0.0 [▼]│ │ 4840 │ │ -│ └─────────────────────────┘ └─────────────────────────┘ │ -│ (0.0.0.0 = all interfaces) (Default: 4840) │ -│ │ -│ Endpoint Path │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ /openplc/opcua │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ Full Endpoint URL: │ -│ opc.tcp://0.0.0.0:4840/openplc/opcua │ -│ │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ Performance │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ │ -│ Synchronization Cycle Time (ms) │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 100 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ (Lower values = faster updates, higher CPU usage. Range: 10-10000) │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 2.2 Fields Specification - -| Field | Type | Default | Validation | Description | -|-------|------|---------|------------|-------------| -| enabled | boolean | false | - | Master switch for OPC-UA server | -| name | string | "OpenPLC OPC UA Server" | max 128 chars | Human-readable server name | -| applicationUri | string | "urn:openplc:opcua:server" | valid URI | Unique application identifier | -| productUri | string | "urn:openplc:runtime" | valid URI | Product type identifier | -| bindAddress | string | "0.0.0.0" | valid IP | Network interface to bind | -| port | number | 4840 | 1-65535 | TCP port number | -| endpointPath | string | "/openplc/opcua" | starts with / | URL path component | -| cycleTimeMs | number | 100 | 10-10000 | Sync interval in milliseconds | - ---- - -## 3. Tab 2: Security Profiles - -### 3.1 Layout - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Security Profiles │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Configure which security profiles clients can use to connect. │ -│ At least one profile must be enabled. │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ [+] Add Security Profile │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ [✓] Insecure (No Security) [Edit] [Del] │ │ -│ │ Policy: None | Mode: None │ │ -│ │ Authentication: Anonymous │ │ -│ │ ⚠ Warning: No encryption or authentication. Use only for │ │ -│ │ development/testing. │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ [ ] Signed [Edit] [Del] │ │ -│ │ Policy: Basic256Sha256 | Mode: Sign │ │ -│ │ Authentication: Username, Certificate │ │ -│ │ Messages are signed but not encrypted. │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ [ ] Signed & Encrypted [Edit] [Del] │ │ -│ │ Policy: Basic256Sha256 | Mode: SignAndEncrypt │ │ -│ │ Authentication: Username, Certificate │ │ -│ │ Full security: messages are signed and encrypted. │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ───────────────────────────────────────────────────────────────────── │ -│ Note: Security profiles with Certificate authentication require │ -│ trusted client certificates to be configured in the Certificates tab. │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 3.2 Add/Edit Security Profile Modal - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Add Security Profile [X] │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Profile Name │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ signed_encrypted │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ (Unique identifier for this profile) │ -│ │ -│ Enabled [Toggle: On] │ -│ │ -│ ───────────────────────────────────────────────────────────────────── │ -│ Security Settings │ -│ ───────────────────────────────────────────────────────────────────── │ -│ │ -│ Security Policy │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ Basic256Sha256 [▼] │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ Options: None, Basic128Rsa15, Basic256, Basic256Sha256 │ -│ │ -│ Security Mode │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ SignAndEncrypt [▼] │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ Options: None, Sign, SignAndEncrypt │ -│ │ -│ ───────────────────────────────────────────────────────────────────── │ -│ Authentication Methods │ -│ ───────────────────────────────────────────────────────────────────── │ -│ │ -│ [ ] Anonymous │ -│ (Only available with Security Policy: None) │ -│ │ -│ [✓] Username / Password │ -│ (Users must be configured in the Users tab) │ -│ │ -│ [✓] Certificate │ -│ (Client certificates must be added to trusted list) │ -│ │ -│ ───────────────────────────────────────────────────────────────────── │ -│ │ -│ [Cancel] [Save Profile] │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 3.3 Validation Rules - -- At least one security profile must be enabled -- "Anonymous" authentication only available when Security Policy = "None" -- Security Mode "None" requires Security Policy "None" -- Profile names must be unique - ---- - -## 4. Tab 3: Users - -### 4.1 Layout - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ User Accounts │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Configure user accounts for OPC-UA client authentication. │ -│ At least one user is required when Username authentication is enabled. │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ [+] Add User │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 👤 viewer [Edit] [Del] │ │ -│ │ Type: Password Authentication │ │ -│ │ Role: Viewer (Read-only access) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 👤 operator [Edit] [Del] │ │ -│ │ Type: Password Authentication │ │ -│ │ Role: Operator (Read/Write per variable permissions) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 👤 engineer [Edit] [Del] │ │ -│ │ Type: Password Authentication │ │ -│ │ Role: Engineer (Full access) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 🔐 engineer_cert [Edit] [Del] │ │ -│ │ Type: Certificate Authentication │ │ -│ │ Certificate: engineer_client │ │ -│ │ Role: Engineer (Full access) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ───────────────────────────────────────────────────────────────────── │ -│ Role Descriptions: │ -│ • Viewer: Can only read variable values (monitoring) │ -│ • Operator: Can read/write based on per-variable permissions │ -│ • Engineer: Full administrative access to all variables │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 4.2 Add/Edit User Modal - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Add User [X] │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Authentication Type │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ Password [▼] │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ Options: Password, Certificate │ -│ │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ Password Authentication (visible when type=Password)│ -│ ═══════════════════════════════════════════════════════════════════ │ -│ │ -│ Username │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ operator │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ Password │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ •••••••• [👁]│ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ Confirm Password │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ •••••••• [👁]│ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ Certificate Authentication (visible when type=Certificate)│ -│ ═══════════════════════════════════════════════════════════════════ │ -│ │ -│ Client Certificate │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ engineer_client [▼] │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ (Select from trusted certificates configured in Certificates tab) │ -│ │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ User Role │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ │ -│ Role │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ Operator [▼] │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ Options: Viewer, Operator, Engineer │ -│ │ -│ Role Permissions: │ -│ • Viewer: Read-only access to all variables │ -│ • Operator: Read/Write based on per-variable permission settings │ -│ • Engineer: Full administrative access │ -│ │ -│ ───────────────────────────────────────────────────────────────────── │ -│ │ -│ [Cancel] [Save User] │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 4.3 Password Handling - -Passwords are hashed using bcrypt before storage: - -```typescript -import bcrypt from 'bcryptjs' - -const hashPassword = async (password: string): Promise => { - const salt = await bcrypt.genSalt(12) - return bcrypt.hash(password, salt) -} -``` - -The plain text password is **never** stored in the project file. - ---- - -## 5. Tab 4: Certificates - -### 5.1 Layout - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Certificate Management │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ Server Certificate │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ │ -│ Certificate Strategy │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ Auto-generate Self-Signed [▼] │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ Options: Auto-generate Self-Signed, Use Custom Certificate │ -│ │ -│ ───────────────────────────────────────────────────────────────────── │ -│ Custom Certificate (visible when strategy=custom) │ -│ ───────────────────────────────────────────────────────────────────── │ -│ │ -│ Server Certificate (PEM format) │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ -----BEGIN CERTIFICATE----- │ │ -│ │ MIIEpDCCAowCCQC7... │ │ -│ │ │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ [Browse File...] │ -│ │ -│ Server Private Key (PEM format) │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ -----BEGIN PRIVATE KEY----- │ │ -│ │ MIIEvgIBADANBg... │ │ -│ │ │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ [Browse File...] │ -│ │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ Trusted Client Certificates │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ │ -│ Client certificates that are trusted for certificate authentication. │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ [+] Add Trusted Certificate │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 📜 engineer_client [View] [Del] │ │ -│ │ Subject: CN=Engineer Client, O=MyCompany │ │ -│ │ Valid: 2024-01-01 to 2025-12-31 │ │ -│ │ Fingerprint: A1:B2:C3:D4:E5:F6:... │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 📜 scada_client [View] [Del] │ │ -│ │ Subject: CN=SCADA System, O=Industrial Corp │ │ -│ │ Valid: 2024-06-01 to 2026-06-01 │ │ -│ │ Fingerprint: 12:34:56:78:9A:BC:... │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 5.2 Add Trusted Certificate Modal - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Add Trusted Client Certificate [X] │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Certificate ID │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ engineer_client │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ (Unique identifier used to reference this certificate in user config) │ -│ │ -│ Certificate (PEM format) │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ -----BEGIN CERTIFICATE----- │ │ -│ │ MIIEpDCCAowCCQC7... │ │ -│ │ │ │ -│ │ │ │ -│ │ │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ [Browse File...] │ -│ │ -│ ───────────────────────────────────────────────────────────────────── │ -│ Certificate Details (parsed from PEM) │ -│ ───────────────────────────────────────────────────────────────────── │ -│ │ -│ Subject: CN=Engineer Client, O=MyCompany │ -│ Issuer: CN=My CA, O=MyCompany │ -│ Valid From: 2024-01-01 00:00:00 UTC │ -│ Valid To: 2025-12-31 23:59:59 UTC │ -│ Fingerprint: A1:B2:C3:D4:E5:F6:78:90:AB:CD:EF:12:34:56:78:90 │ -│ │ -│ ───────────────────────────────────────────────────────────────────── │ -│ │ -│ [Cancel] [Add Certificate] │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 6. Tab 5: Address Space (Variable Selection) - -### 6.1 Overview - -The Address Space tab allows users to select which PLC variables to expose via OPC-UA. It displays **all project variables in a hierarchical tree structure**, supporting: - -- Simple variables (INT, BOOL, REAL, etc.) -- Function Block instances with their internal variables -- Nested Function Blocks (FB inside FB inside Program) -- Structures with fields -- Nested Structures (struct inside struct) -- Arrays of any type -- Arrays of structures - -### 6.2 Main Layout - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Address Space Configuration │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Namespace URI │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ urn:openplc:opcua:namespace │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ───────────────────────────────────────────────────────────────────── │ -│ │ -│ Select variables to expose via OPC-UA. You can select individual │ -│ variables (leaves) or entire structures/arrays/function blocks │ -│ (branches). Variable indices are resolved automatically during │ -│ project compilation. │ -│ │ -│ ┌────────────────────────────────┬────────────────────────────────┐ │ -│ │ Available PLC Variables │ Selected for OPC-UA │ │ -│ │ (from project) │ │ │ -│ ├────────────────────────────────┼────────────────────────────────┤ │ -│ │ ▼ 📁 main (Program) │ ▼ PLC.Main.MotorSpeed │ │ -│ │ ├── ☐ MOTOR_SPEED (INT) │ │ main:MOTOR_SPEED │ │ -│ │ ├── ☐ IS_RUNNING (BOOL) │ │ Type: INT │ │ -│ │ ├── ▶ ☐ TON0 (TON) │ │ Perms: V:r O:rw E:rw │ │ -│ │ ├── ▶ ☐ MOTOR_CTRL (FB_Mot) │ ├────────────────────────────│ │ -│ │ ├── ▶ ☐ SENSOR (T_Sensor) │ ▼ PLC.Main.MOTOR_CTRL │ │ -│ │ └── ▶ ☐ TEMPS (ARRAY[1..10])│ │ main:MOTOR_CTRL (FB) │ │ -│ │ ▼ 📁 GVL (Global Variables) │ │ [Entire FB selected] │ │ -│ │ ├── ☐ SYSTEM_STATE (INT) │ ├── ENABLE (BOOL) │ │ -│ │ └── ☐ ERROR_CODE (DINT) │ ├── SPEED_SP (INT) │ │ -│ │ ► 📁 FB_MotorControl (FB) │ └── PID_CTRL.OUTPUT (REAL) │ │ -│ │ ► 📁 FB_PID (FB) │ ─ PLC.GVL.SystemState │ │ -│ │ │ │ GVL:SYSTEM_STATE │ │ -│ │ │ │ Type: INT │ │ -│ │ │ │ │ -│ ├────────────────────────────────┼────────────────────────────────┤ │ -│ │ [Filter: ________] │ │ │ -│ │ │ [↑] [↓] [Edit] [Remove] │ │ -│ │ [Add Selected →] │ │ │ -│ └────────────────────────────────┴────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 6.3 Variable Tree Structure - -The left panel shows all project variables in a tree: - -``` -▼ 📁 main (Program) - ├── MOTOR_SPEED (INT) ← Simple variable (leaf) - ├── IS_RUNNING (BOOL) ← Simple variable (leaf) - ├── ▼ TON0 (TON) ← Standard FB instance - │ ├── IN (BOOL) ← FB input - │ ├── PT (TIME) ← FB input - │ ├── Q (BOOL) ← FB output - │ └── ET (TIME) ← FB output - ├── ▼ MOTOR_CTRL (FB_MotorControl) ← Custom FB instance - │ ├── ENABLE (BOOL) ← FB variable - │ ├── SPEED_SETPOINT (INT) ← FB variable - │ ├── ACTUAL_SPEED (INT) ← FB variable - │ └── ▼ PID_CTRL (FB_PID) ← Nested FB (2 levels deep) - │ ├── KP (REAL) - │ ├── KI (REAL) - │ ├── KD (REAL) - │ └── OUTPUT (REAL) - ├── ▼ SENSOR (T_SensorData) ← Structure instance - │ ├── temperature (REAL) ← Struct field - │ ├── pressure (REAL) ← Struct field - │ └── ▼ status (T_Status) ← Nested structure - │ ├── is_valid (BOOL) - │ └── error_code (INT) - ├── ▼ TEMPS (ARRAY[1..10] OF REAL) ← Array - │ ├── [1] (REAL) - │ ├── [2] (REAL) - │ └── ... (10 elements) - └── ▼ SENSORS (ARRAY[1..5] OF T_SensorData) ← Array of structures - ├── ▼ [1] (T_SensorData) - │ ├── temperature (REAL) - │ ├── pressure (REAL) - │ └── ▼ status (T_Status) - │ └── ... - └── ... (5 elements) - -▼ 📁 GVL (Global Variables) - ├── SYSTEM_STATE (INT) - ├── ERROR_CODE (DINT) - └── ▼ CONFIG (T_SystemConfig) - └── ... - -► 📁 FB_MotorControl (Function Block Definition) - └── (Shows FB type definition - variables not directly selectable) - -► 📁 FB_PID (Function Block Definition) - └── (Shows FB type definition) -``` - -### 6.4 Selection Behavior - -Users can select: - -1. **Individual Leaves**: Select a single simple variable -2. **Entire Branches**: Select a structure, array, or FB to include all children - -``` -Selection Examples: - -☐ MOTOR_SPEED (INT) → Selects only this variable -☑ MOTOR_CTRL (FB_MotorControl) → Selects entire FB with all nested variables - ☑ ENABLE (BOOL) (auto-selected as part of parent) - ☑ SPEED_SETPOINT (INT) (auto-selected as part of parent) - ☑ PID_CTRL (FB_PID) (auto-selected as part of parent) - ☑ KP (REAL) (auto-selected, nested) - ☑ OUTPUT (REAL) (auto-selected, nested) - -Partial selection is NOT allowed - either select the parent or individual children -``` - -### 6.5 Edit Variable/Node Modal - -When editing a selected variable/node: - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Configure OPC-UA Node [X] │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ PLC Variable: main:MOTOR_SPEED (INT) │ -│ │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ OPC-UA Node Configuration │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ │ -│ Node ID │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ PLC.Main.MotorSpeed │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ (Unique identifier in OPC-UA address space) │ -│ │ -│ Browse Name │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ MotorSpeed │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ Display Name │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ Motor Speed │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ Description │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ Current motor speed in RPM │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ Initial Value │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 0 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ Access Permissions │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ │ -│ Role Access Level │ -│ ───────────────────────────────────────────────────────────────────── │ -│ Viewer [Read Only ▼] │ -│ Operator [Read/Write ▼] │ -│ Engineer [Read/Write ▼] │ -│ │ -│ Options: Read Only, Write Only, Read/Write │ -│ │ -│ ───────────────────────────────────────────────────────────────────── │ -│ │ -│ [Cancel] [Save] │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 6.6 Edit Structure/FB Modal - -When editing a structure or function block (branch selection): - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Configure OPC-UA Structure Node [X] │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ PLC Variable: main:SENSOR (T_SensorData) │ -│ Type: Structure │ -│ │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ Structure Node Configuration │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ │ -│ Node ID │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ PLC.Main.Sensor │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ Browse Name │ Display Name │ -│ ┌────────────────────┼────────────────────────────────────────────┐ │ -│ │ Sensor │ Sensor Data │ │ -│ └────────────────────┴────────────────────────────────────────────┘ │ -│ │ -│ Description │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ Main sensor data structure │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ Field Configuration │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ Field │ Type │ Initial │ Viewer │ Operator │ Engr │ │ -│ ├─────────────────┼───────┼─────────┼────────┼──────────┼───────┤ │ -│ │ temperature │ REAL │ 0.0 │ r │ r │ rw │ │ -│ │ pressure │ REAL │ 0.0 │ r │ r │ rw │ │ -│ │ status.is_valid │ BOOL │ false │ r │ r │ r │ │ -│ │ status.error_co │ INT │ 0 │ r │ r │ rw │ │ -│ └─────────────────┴───────┴─────────┴────────┴──────────┴───────┘ │ -│ │ -│ [Edit Field Permissions...] │ -│ │ -│ ───────────────────────────────────────────────────────────────────── │ -│ │ -│ [Cancel] [Save] │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 6.7 Edit Array Modal - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Configure OPC-UA Array Node [X] │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ PLC Variable: main:TEMPS (ARRAY[1..10] OF REAL) │ -│ Type: Array │ -│ │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ Array Node Configuration │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ │ -│ Node ID │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ PLC.Main.Temperatures │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ Browse Name │ Display Name │ -│ ┌────────────────────┼────────────────────────────────────────────┐ │ -│ │ Temperatures │ Temperature Readings │ │ -│ └────────────────────┴────────────────────────────────────────────┘ │ -│ │ -│ Description │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ Array of temperature sensor values │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ───────────────────────────────────────────────────────────────────── │ -│ Array Properties │ -│ ───────────────────────────────────────────────────────────────────── │ -│ │ -│ Element Type: REAL │ -│ Array Length: 10 (indices 1 to 10) │ -│ Initial Value: 0.0 (applied to all elements) │ -│ │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ Access Permissions (applies to entire array) │ -│ ═══════════════════════════════════════════════════════════════════ │ -│ │ -│ Viewer: [Read Only ▼] │ -│ Operator: [Read/Write ▼] │ -│ Engineer: [Read/Write ▼] │ -│ │ -│ ───────────────────────────────────────────────────────────────────── │ -│ │ -│ [Cancel] [Save] │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 6.8 Deep Nesting Example - -For deeply nested structures (FB inside FB, struct inside struct), the tree expands fully: - -``` -▼ 📁 main (Program) - └── ▼ PROCESS_CTRL (FB_ProcessController) - ├── ENABLE (BOOL) - ├── MODE (INT) - └── ▼ HEATER_CTRL (FB_HeaterController) - ├── SETPOINT (REAL) - ├── ACTUAL (REAL) - └── ▼ PID (FB_PID) - ├── KP (REAL) - ├── KI (REAL) - ├── KD (REAL) - ├── ERROR (REAL) - └── OUTPUT (REAL) - -When user selects PROCESS_CTRL, all nested items are included. -The variablePath for deepest item would be: - "PROCESS_CTRL.HEATER_CTRL.PID.OUTPUT" -``` - ---- - -## 7. Responsive Design Considerations - -### 7.1 Minimum Window Size - -- Minimum width: 800px -- Minimum height: 600px - -### 7.2 Panel Resizing - -The Address Space tab should support resizable panels: -- Left panel (Available Variables): min 250px, max 50% -- Right panel (Selected Variables): min 250px, remaining space - -### 7.3 Tree Virtualization - -For projects with many variables, implement virtualized tree rendering: -- Use react-window or similar for large lists -- Lazy-load children when expanding nodes -- Show loading indicator while expanding complex FBs - ---- - -## 8. Accessibility - -### 8.1 Keyboard Navigation - -- Tab: Move between panels and controls -- Arrow keys: Navigate tree structure -- Space: Toggle checkbox selection -- Enter: Expand/collapse tree node or open edit modal - -### 8.2 Screen Reader Support - -- Proper ARIA labels on all interactive elements -- Tree structure uses role="tree" and role="treeitem" -- Selection state announced: "Selected" / "Not selected" - -### 8.3 Color Contrast - -- All text meets WCAG 2.1 AA contrast requirements -- Icons have sufficient contrast or text alternatives -- Error states use both color and icon indicators diff --git a/docs/outdated/opcua-server-configuration/03-json-configuration-mapping.md b/docs/outdated/opcua-server-configuration/03-json-configuration-mapping.md deleted file mode 100644 index 23a3412b0..000000000 --- a/docs/outdated/opcua-server-configuration/03-json-configuration-mapping.md +++ /dev/null @@ -1,959 +0,0 @@ -# OPC-UA Server Configuration - JSON Configuration Mapping - -This document describes how the editor configuration maps to the runtime JSON format expected by the OPC-UA plugin. - -## 1. Overview - -The OPC-UA configuration undergoes transformation during compilation: - -``` -┌─────────────────────────┐ ┌─────────────────────────┐ -│ Editor Format │ │ Runtime Format │ -│ (Project File) │ ──► │ (opcua.json) │ -├─────────────────────────┤ ├─────────────────────────┤ -│ • camelCase properties │ │ • snake_case properties │ -│ • Variable references │ │ • Resolved indices │ -│ • No indices │ │ • Full variable info │ -│ • TypeScript types │ │ • JSON structure │ -└─────────────────────────┘ └─────────────────────────┘ -``` - -## 2. Editor Data Model (Stored in Project) - -### 2.1 Complete Type Definitions - -```typescript -// ═══════════════════════════════════════════════════════════════════════ -// Security Profile -// ═══════════════════════════════════════════════════════════════════════ - -interface OpcUaSecurityProfile { - id: string // UUID - name: string // Profile identifier (e.g., "insecure", "signed") - enabled: boolean - securityPolicy: 'None' | 'Basic128Rsa15' | 'Basic256' | 'Basic256Sha256' - securityMode: 'None' | 'Sign' | 'SignAndEncrypt' - authMethods: ('Anonymous' | 'Username' | 'Certificate')[] -} - -// ═══════════════════════════════════════════════════════════════════════ -// Trusted Certificate -// ═══════════════════════════════════════════════════════════════════════ - -interface OpcUaTrustedCertificate { - id: string // Certificate identifier (referenced by users) - pem: string // PEM-encoded certificate content - // Derived fields for display (parsed from PEM) - subject?: string - validFrom?: string - validTo?: string - fingerprint?: string -} - -// ═══════════════════════════════════════════════════════════════════════ -// User Account -// ═══════════════════════════════════════════════════════════════════════ - -interface OpcUaUser { - id: string // UUID - type: 'password' | 'certificate' - // For password auth - username: string | null - passwordHash: string | null // bcrypt hash (never plain text) - // For certificate auth - certificateId: string | null // References trustedCertificate.id - // Common - role: 'viewer' | 'operator' | 'engineer' -} - -// ═══════════════════════════════════════════════════════════════════════ -// Permissions -// ═══════════════════════════════════════════════════════════════════════ - -interface OpcUaPermissions { - viewer: 'r' | 'w' | 'rw' - operator: 'r' | 'w' | 'rw' - engineer: 'r' | 'w' | 'rw' -} - -// ═══════════════════════════════════════════════════════════════════════ -// Address Space Node (Variable/Structure/Array) -// ═══════════════════════════════════════════════════════════════════════ - -interface OpcUaNodeConfig { - id: string // UUID for tracking - - // PLC variable reference (resolved to index at compile time) - pouName: string // "main", "GVL", "FB_Motor" - variablePath: string // "MOTOR_SPEED", "CTRL.PID.OUTPUT" - variableType: string // IEC type - - // OPC-UA configuration - nodeId: string // OPC-UA node identifier - browseName: string - displayName: string - description: string - initialValue: boolean | number | string - permissions: OpcUaPermissions - - // Type classification - nodeType: 'variable' | 'structure' | 'array' - - // For structures: nested field configurations - fields?: OpcUaFieldConfig[] - - // For arrays: element count (derived from type) - arrayLength?: number - elementType?: string -} - -interface OpcUaFieldConfig { - fieldPath: string // "temperature", "status.is_valid" - displayName: string - initialValue: boolean | number | string - permissions: OpcUaPermissions -} - -// ═══════════════════════════════════════════════════════════════════════ -// Complete Server Configuration -// ═══════════════════════════════════════════════════════════════════════ - -interface OpcUaServerConfig { - server: { - enabled: boolean - name: string - applicationUri: string - productUri: string - bindAddress: string - port: number - endpointPath: string - } - - securityProfiles: OpcUaSecurityProfile[] - - security: { - serverCertificateStrategy: 'auto_self_signed' | 'custom' - serverCertificateCustom: string | null - serverPrivateKeyCustom: string | null - trustedClientCertificates: OpcUaTrustedCertificate[] - } - - users: OpcUaUser[] - - cycleTimeMs: number - - addressSpace: { - namespaceUri: string - nodes: OpcUaNodeConfig[] - } -} -``` - -## 3. Runtime JSON Format (Generated) - -### 3.1 Complete Structure - -The runtime expects a JSON array containing plugin configurations: - -```json -[ - { - "name": "opcua_server", - "protocol": "OPC-UA", - "config": { - "server": { - "name": "string", - "application_uri": "string", - "product_uri": "string", - "endpoint_url": "string", - "security_profiles": [ - { - "name": "string", - "enabled": true, - "security_policy": "string", - "security_mode": "string", - "auth_methods": ["string"] - } - ] - }, - "security": { - "server_certificate_strategy": "string", - "server_certificate_custom": "string|null", - "server_private_key_custom": "string|null", - "trusted_client_certificates": [ - { - "id": "string", - "pem": "string" - } - ] - }, - "users": [ - { - "type": "string", - "username": "string|null", - "password_hash": "string|null", - "certificate_id": "string|null", - "role": "string" - } - ], - "cycle_time_ms": 100, - "address_space": { - "namespace_uri": "string", - "variables": [...], - "structures": [...], - "arrays": [...] - } - } - } -] -``` - -### 3.2 Address Space Variable Format - -```json -{ - "node_id": "PLC.Main.MotorSpeed", - "browse_name": "MotorSpeed", - "display_name": "Motor Speed", - "datatype": "INT", - "initial_value": 0, - "description": "Current motor speed in RPM", - "index": 5, - "permissions": { - "viewer": "r", - "operator": "rw", - "engineer": "rw" - } -} -``` - -### 3.3 Address Space Structure Format - -```json -{ - "node_id": "PLC.Main.Sensor", - "browse_name": "Sensor", - "display_name": "Sensor Data", - "description": "Main sensor data structure", - "fields": [ - { - "name": "temperature", - "datatype": "REAL", - "initial_value": 0.0, - "index": 10, - "permissions": { - "viewer": "r", - "operator": "r", - "engineer": "rw" - } - }, - { - "name": "pressure", - "datatype": "REAL", - "initial_value": 0.0, - "index": 11, - "permissions": { - "viewer": "r", - "operator": "r", - "engineer": "rw" - } - }, - { - "name": "status.is_valid", - "datatype": "BOOL", - "initial_value": false, - "index": 12, - "permissions": { - "viewer": "r", - "operator": "r", - "engineer": "r" - } - } - ] -} -``` - -### 3.4 Address Space Array Format - -```json -{ - "node_id": "PLC.Main.Temperatures", - "browse_name": "Temperatures", - "display_name": "Temperature Readings", - "datatype": "REAL", - "length": 10, - "initial_value": 0.0, - "index": 20, - "permissions": { - "viewer": "r", - "operator": "rw", - "engineer": "rw" - } -} -``` - -## 4. Field Mapping Reference - -### 4.1 Server Settings - -| Editor Field | Runtime JSON Field | Transformation | -|--------------|-------------------|----------------| -| server.enabled | (not in JSON) | Only generate JSON if enabled | -| server.name | config.server.name | Direct copy | -| server.applicationUri | config.server.application_uri | camelCase → snake_case | -| server.productUri | config.server.product_uri | camelCase → snake_case | -| server.bindAddress | config.server.endpoint_url | Combined into URL | -| server.port | config.server.endpoint_url | Combined into URL | -| server.endpointPath | config.server.endpoint_url | Combined into URL | - -**Endpoint URL Construction:** -```typescript -const endpointUrl = `opc.tcp://${bindAddress}:${port}${endpointPath}` -// Example: opc.tcp://0.0.0.0:4840/openplc/opcua -``` - -### 4.2 Security Profiles - -| Editor Field | Runtime JSON Field | Transformation | -|--------------|-------------------|----------------| -| securityProfile.name | security_profiles[].name | Direct copy | -| securityProfile.enabled | security_profiles[].enabled | Direct copy | -| securityProfile.securityPolicy | security_profiles[].security_policy | camelCase → snake_case | -| securityProfile.securityMode | security_profiles[].security_mode | camelCase → snake_case | -| securityProfile.authMethods | security_profiles[].auth_methods | camelCase → snake_case | - -**Note:** Only enabled profiles are included in the output. - -### 4.3 Security Settings - -| Editor Field | Runtime JSON Field | Transformation | -|--------------|-------------------|----------------| -| security.serverCertificateStrategy | security.server_certificate_strategy | camelCase → snake_case | -| security.serverCertificateCustom | security.server_certificate_custom | camelCase → snake_case | -| security.serverPrivateKeyCustom | security.server_private_key_custom | camelCase → snake_case | -| security.trustedClientCertificates | security.trusted_client_certificates | Map to {id, pem} only | - -### 4.4 Users - -| Editor Field | Runtime JSON Field | Transformation | -|--------------|-------------------|----------------| -| user.type | users[].type | Direct copy | -| user.username | users[].username | Direct copy | -| user.passwordHash | users[].password_hash | camelCase → snake_case | -| user.certificateId | users[].certificate_id | camelCase → snake_case | -| user.role | users[].role | Direct copy | - -### 4.5 Address Space Variables - -| Editor Field | Runtime JSON Field | Transformation | -|--------------|-------------------|----------------| -| node.nodeId | variables[].node_id | camelCase → snake_case | -| node.browseName | variables[].browse_name | camelCase → snake_case | -| node.displayName | variables[].display_name | camelCase → snake_case | -| node.variableType | variables[].datatype | Renamed | -| node.initialValue | variables[].initial_value | camelCase → snake_case | -| node.description | variables[].description | Direct copy | -| (resolved) | variables[].index | **Resolved from debug.c** | -| node.permissions | variables[].permissions | Direct copy | - -## 5. Index Resolution Process - -### 5.1 Overview - -Variable indices are resolved during compilation by matching the editor's variable references to entries in the generated `debug.c` file. - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Index Resolution Flow │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Editor Config debug.c Runtime JSON │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ pouName: │ │ debug_vars[]│ │ index: 5 │ │ -│ │ "main" │ Match │ = { │ Copy │ datatype: │ │ -│ │ variablePath│ ──────────► │ [5] VAR │ ──────► │ "INT" │ │ -│ │ "SPEED" │ │ ... │ │ │ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 5.2 Path Building Rules - -Different variable types require different debug path formats: - -#### Simple Variables (Program/FB Instance) - -```typescript -// Editor: pouName="main", variablePath="MOTOR_SPEED" -// Debug path: RES0__INSTANCE0.MOTOR_SPEED -const buildSimplePath = (pouName: string, varPath: string): string => { - return `RES0__INSTANCE0.${varPath.toUpperCase()}` -} -``` - -#### Global Variables - -```typescript -// Editor: pouName="GVL", variablePath="SYSTEM_STATE" -// Debug path: CONFIG0__SYSTEM_STATE -const buildGlobalPath = (varPath: string): string => { - return `CONFIG0__${varPath.toUpperCase()}` -} -``` - -#### Structure Fields - -```typescript -// Editor: pouName="main", variablePath="SENSOR.temperature" -// Debug path: RES0__INSTANCE0.SENSOR.value.TEMPERATURE -const buildStructFieldPath = (pouName: string, varPath: string): string => { - const parts = varPath.split('.') - const structVar = parts[0] - const fieldPath = parts.slice(1).join('.value.') - return `RES0__INSTANCE0.${structVar.toUpperCase()}.value.${fieldPath.toUpperCase()}` -} -``` - -#### Nested Structure Fields - -```typescript -// Editor: pouName="main", variablePath="SENSOR.status.is_valid" -// Debug path: RES0__INSTANCE0.SENSOR.value.STATUS.value.IS_VALID -const buildNestedStructPath = (pouName: string, varPath: string): string => { - const parts = varPath.split('.') - let path = `RES0__INSTANCE0.${parts[0].toUpperCase()}` - for (let i = 1; i < parts.length; i++) { - path += `.value.${parts[i].toUpperCase()}` - } - return path -} -``` - -#### Function Block Variables - -```typescript -// Editor: pouName="main", variablePath="TON0.ET" -// Debug path: RES0__INSTANCE0.TON0.ET -const buildFBVarPath = (pouName: string, varPath: string): string => { - return `RES0__INSTANCE0.${varPath.toUpperCase()}` -} -``` - -#### Nested Function Block Variables - -```typescript -// Editor: pouName="main", variablePath="MOTOR_CTRL.PID.OUTPUT" -// Debug path: RES0__INSTANCE0.MOTOR_CTRL.PID.OUTPUT -const buildNestedFBPath = (pouName: string, varPath: string): string => { - return `RES0__INSTANCE0.${varPath.toUpperCase()}` -} -``` - -#### Array Elements - -```typescript -// Editor: pouName="main", variablePath="TEMPS" -// Debug path for element [0]: RES0__INSTANCE0.TEMPS.value.table[0] -// Index is the starting index; elements are sequential -const buildArrayPath = (pouName: string, varPath: string, elementIndex: number): string => { - return `RES0__INSTANCE0.${varPath.toUpperCase()}.value.table[${elementIndex}]` -} -``` - -### 5.3 Resolution Algorithm - -```typescript -import { parseDebugFile, DebugVariable } from '@root/renderer/utils/parse-debug-file' - -interface ResolvedVariable { - nodeId: string - browseName: string - displayName: string - datatype: string - initialValue: any - description: string - index: number - permissions: OpcUaPermissions -} - -const resolveVariableIndex = ( - node: OpcUaNodeConfig, - debugVariables: DebugVariable[] -): number => { - // Build the expected debug path based on variable location - let debugPath: string - - if (node.pouName === 'GVL' || node.pouName === 'CONFIG') { - // Global variable - debugPath = `CONFIG0__${node.variablePath.toUpperCase()}` - } else { - // Instance variable (program or FB) - // Handle nested paths by checking for struct/array patterns - debugPath = buildInstancePath(node.pouName, node.variablePath) - } - - // Find matching entry in debug.c - const match = debugVariables.find( - dv => dv.name.toUpperCase() === debugPath.toUpperCase() - ) - - if (!match) { - throw new Error( - `Cannot resolve index for variable: ${node.pouName}:${node.variablePath}\n` + - `Expected debug path: ${debugPath}` - ) - } - - return match.index -} - -const buildInstancePath = (pouName: string, variablePath: string): string => { - // For struct fields, insert ".value." before each field access - // For FB variables, use direct dot notation - // This logic should match the debugger's path building - - const parts = variablePath.split('.') - - // Check if this involves struct fields (need .value. insertion) - // This requires type information to determine correctly - // For now, use the same logic as the debugger - - return `RES0__INSTANCE0.${variablePath.toUpperCase()}` -} -``` - -### 5.4 Structure Index Resolution - -For structures, each field gets its own index: - -```typescript -const resolveStructureIndices = ( - node: OpcUaNodeConfig, - debugVariables: DebugVariable[] -): RuntimeStructure => { - const fields = node.fields!.map(field => { - const fieldPath = `${node.variablePath}.${field.fieldPath}` - const debugPath = buildStructFieldDebugPath(node.pouName, fieldPath) - - const match = debugVariables.find( - dv => dv.name.toUpperCase() === debugPath.toUpperCase() - ) - - if (!match) { - throw new Error(`Cannot resolve index for field: ${fieldPath}`) - } - - return { - name: field.fieldPath, - datatype: getFieldType(node.variableType, field.fieldPath), - initial_value: field.initialValue, - index: match.index, - permissions: field.permissions, - } - }) - - return { - node_id: node.nodeId, - browse_name: node.browseName, - display_name: node.displayName, - description: node.description, - fields, - } -} -``` - -### 5.5 Array Index Resolution - -For arrays, only the starting index is stored; elements are sequential: - -```typescript -const resolveArrayIndex = ( - node: OpcUaNodeConfig, - debugVariables: DebugVariable[] -): RuntimeArray => { - // Find the first element's index - const firstElementPath = buildArrayElementPath( - node.pouName, - node.variablePath, - 0 // First element (C-style index) - ) - - const match = debugVariables.find( - dv => dv.name.toUpperCase() === firstElementPath.toUpperCase() - ) - - if (!match) { - throw new Error(`Cannot resolve index for array: ${node.variablePath}`) - } - - return { - node_id: node.nodeId, - browse_name: node.browseName, - display_name: node.displayName, - datatype: node.elementType!, - length: node.arrayLength!, - initial_value: node.initialValue, - index: match.index, // Starting index - permissions: node.permissions, - } -} -``` - -## 6. Complete Generator Implementation - -```typescript -// src/utils/opcua/generate-opcua-config.ts - -import { parseDebugFile, DebugVariable } from '@root/renderer/utils/parse-debug-file' -import type { PLCServer, OpcUaServerConfig, OpcUaNodeConfig } from '@root/types/PLC/open-plc' - -interface RuntimeConfig { - name: string - protocol: 'OPC-UA' - config: { - server: RuntimeServerConfig - security: RuntimeSecurityConfig - users: RuntimeUser[] - cycle_time_ms: number - address_space: RuntimeAddressSpace - } -} - -export const generateOpcUaConfig = ( - servers: PLCServer[] | undefined, - debugFileContent: string -): string | null => { - // 1. Find OPC-UA server configuration - if (!servers || servers.length === 0) return null - - const opcuaServer = servers.find( - s => s.protocol === 'opcua' && s.opcuaServerConfig?.server.enabled - ) - - if (!opcuaServer?.opcuaServerConfig) return null - - const config = opcuaServer.opcuaServerConfig - - // 2. Parse debug.c to get variable indices - const parsed = parseDebugFile(debugFileContent) - const debugVariables = parsed.variables - - // 3. Build runtime configuration - const runtimeConfig: RuntimeConfig = { - name: 'opcua_server', - protocol: 'OPC-UA', - config: { - server: buildServerConfig(config), - security: buildSecurityConfig(config), - users: buildUsersConfig(config), - cycle_time_ms: config.cycleTimeMs, - address_space: buildAddressSpace(config, debugVariables), - }, - } - - // 4. Return as JSON string - return JSON.stringify([runtimeConfig], null, 2) -} - -const buildServerConfig = (config: OpcUaServerConfig): RuntimeServerConfig => { - const { server, securityProfiles } = config - - return { - name: server.name, - application_uri: server.applicationUri, - product_uri: server.productUri, - endpoint_url: `opc.tcp://${server.bindAddress}:${server.port}${server.endpointPath}`, - security_profiles: securityProfiles - .filter(sp => sp.enabled) - .map(sp => ({ - name: sp.name, - enabled: sp.enabled, - security_policy: sp.securityPolicy, - security_mode: sp.securityMode, - auth_methods: sp.authMethods, - })), - } -} - -const buildSecurityConfig = (config: OpcUaServerConfig): RuntimeSecurityConfig => { - const { security } = config - - return { - server_certificate_strategy: security.serverCertificateStrategy, - server_certificate_custom: security.serverCertificateCustom, - server_private_key_custom: security.serverPrivateKeyCustom, - trusted_client_certificates: security.trustedClientCertificates.map(cert => ({ - id: cert.id, - pem: cert.pem, - })), - } -} - -const buildUsersConfig = (config: OpcUaServerConfig): RuntimeUser[] => { - return config.users.map(user => ({ - type: user.type, - username: user.username, - password_hash: user.passwordHash, - certificate_id: user.certificateId, - role: user.role, - })) -} - -const buildAddressSpace = ( - config: OpcUaServerConfig, - debugVariables: DebugVariable[] -): RuntimeAddressSpace => { - const variables: RuntimeVariable[] = [] - const structures: RuntimeStructure[] = [] - const arrays: RuntimeArray[] = [] - - for (const node of config.addressSpace.nodes) { - switch (node.nodeType) { - case 'variable': - variables.push(resolveVariable(node, debugVariables)) - break - case 'structure': - structures.push(resolveStructure(node, debugVariables)) - break - case 'array': - arrays.push(resolveArray(node, debugVariables)) - break - } - } - - return { - namespace_uri: config.addressSpace.namespaceUri, - variables, - structures, - arrays, - } -} - -// ... resolution functions as defined in section 5 -``` - -## 7. Example Transformation - -### 7.1 Editor Configuration (Stored in Project) - -```json -{ - "server": { - "enabled": true, - "name": "OpenPLC OPC UA Server", - "applicationUri": "urn:openplc:opcua:server", - "productUri": "urn:openplc:runtime", - "bindAddress": "0.0.0.0", - "port": 4840, - "endpointPath": "/openplc/opcua" - }, - "securityProfiles": [ - { - "id": "uuid-1", - "name": "insecure", - "enabled": true, - "securityPolicy": "None", - "securityMode": "None", - "authMethods": ["Anonymous"] - } - ], - "security": { - "serverCertificateStrategy": "auto_self_signed", - "serverCertificateCustom": null, - "serverPrivateKeyCustom": null, - "trustedClientCertificates": [] - }, - "users": [ - { - "id": "uuid-2", - "type": "password", - "username": "engineer", - "passwordHash": "$2b$12$...", - "certificateId": null, - "role": "engineer" - } - ], - "cycleTimeMs": 100, - "addressSpace": { - "namespaceUri": "urn:openplc:opcua:namespace", - "nodes": [ - { - "id": "uuid-3", - "pouName": "main", - "variablePath": "MOTOR_SPEED", - "variableType": "INT", - "nodeId": "PLC.Main.MotorSpeed", - "browseName": "MotorSpeed", - "displayName": "Motor Speed", - "description": "Current motor speed", - "initialValue": 0, - "permissions": { - "viewer": "r", - "operator": "rw", - "engineer": "rw" - }, - "nodeType": "variable" - } - ] - } -} -``` - -### 7.2 Generated Runtime JSON (opcua.json) - -```json -[ - { - "name": "opcua_server", - "protocol": "OPC-UA", - "config": { - "server": { - "name": "OpenPLC OPC UA Server", - "application_uri": "urn:openplc:opcua:server", - "product_uri": "urn:openplc:runtime", - "endpoint_url": "opc.tcp://0.0.0.0:4840/openplc/opcua", - "security_profiles": [ - { - "name": "insecure", - "enabled": true, - "security_policy": "None", - "security_mode": "None", - "auth_methods": ["Anonymous"] - } - ] - }, - "security": { - "server_certificate_strategy": "auto_self_signed", - "server_certificate_custom": null, - "server_private_key_custom": null, - "trusted_client_certificates": [] - }, - "users": [ - { - "type": "password", - "username": "engineer", - "password_hash": "$2b$12$...", - "certificate_id": null, - "role": "engineer" - } - ], - "cycle_time_ms": 100, - "address_space": { - "namespace_uri": "urn:openplc:opcua:namespace", - "variables": [ - { - "node_id": "PLC.Main.MotorSpeed", - "browse_name": "MotorSpeed", - "display_name": "Motor Speed", - "datatype": "INT", - "initial_value": 0, - "description": "Current motor speed", - "index": 5, - "permissions": { - "viewer": "r", - "operator": "rw", - "engineer": "rw" - } - } - ], - "structures": [], - "arrays": [] - } - } - } -] -``` - -## 8. Error Handling - -### 8.1 Index Resolution Errors - -When a variable cannot be resolved: - -```typescript -class OpcUaConfigError extends Error { - constructor( - public readonly variableRef: string, - public readonly expectedPath: string, - public readonly message: string - ) { - super(message) - this.name = 'OpcUaConfigError' - } -} - -// Usage: -if (!match) { - throw new OpcUaConfigError( - `${node.pouName}:${node.variablePath}`, - debugPath, - `Cannot resolve OPC-UA variable index.\n` + - `Variable: ${node.pouName}:${node.variablePath}\n` + - `Expected debug path: ${debugPath}\n` + - `This may happen if the PLC program was modified after configuring OPC-UA.\n` + - `Please verify the variable exists in the program.` - ) -} -``` - -### 8.2 Validation Before Generation - -```typescript -const validateAddressSpace = ( - nodes: OpcUaNodeConfig[], - debugVariables: DebugVariable[] -): { valid: boolean; errors: string[] } => { - const errors: string[] = [] - - for (const node of nodes) { - try { - resolveVariableIndex(node, debugVariables) - } catch (e) { - errors.push((e as Error).message) - } - } - - return { - valid: errors.length === 0, - errors, - } -} -``` - -## 9. Integration with Compiler - -The OPC-UA JSON generation is called as part of the compilation process: - -```typescript -// In compiler-module.ts - -async handleGenerateOpcUaConfig( - sourceTargetFolderPath: string, - projectData: PLCProjectData -): Promise { - // Check if OPC-UA is enabled - const opcuaServer = projectData.servers?.find( - s => s.protocol === 'opcua' && s.opcuaServerConfig?.server.enabled - ) - - if (!opcuaServer) { - // OPC-UA not configured, skip - return - } - - // Read the debug.c file generated by xml2st - const debugCPath = join(sourceTargetFolderPath, 'debug.c') - const debugContent = await readFile(debugCPath, 'utf-8') - - // Generate the OPC-UA configuration - const opcuaJson = generateOpcUaConfig(projectData.servers, debugContent) - - if (opcuaJson) { - // Write to conf/opcua.json - const confDir = join(sourceTargetFolderPath, 'conf') - await mkdir(confDir, { recursive: true }) - await writeFile(join(confDir, 'opcua.json'), opcuaJson, 'utf-8') - } -} -``` diff --git a/docs/outdated/opcua-server-configuration/04-implementation-phases.md b/docs/outdated/opcua-server-configuration/04-implementation-phases.md deleted file mode 100644 index 192e21c4d..000000000 --- a/docs/outdated/opcua-server-configuration/04-implementation-phases.md +++ /dev/null @@ -1,973 +0,0 @@ -# OPC-UA Server Configuration - Implementation Phases - -This document outlines a phased approach to implementing the OPC-UA server configuration feature in OpenPLC Editor. - -## Overview - -The implementation is divided into 6 phases, designed to deliver incremental functionality while managing complexity: - -| Phase | Focus | Deliverable | -|-------|-------|-------------| -| 1 | Foundation & Types | Type definitions, project integration, basic UI shell | -| 2 | General Settings & Security Profiles | First two tabs fully functional | -| 3 | Users & Certificates | Authentication and certificate management | -| 4 | Address Space - Basic Variables | Variable tree and simple variable selection | -| 5 | Address Space - Complex Types | Structures, arrays, nested FBs | -| 6 | Compiler Integration & Testing | JSON generation, end-to-end testing | - ---- - -## Phase 1: Foundation & Type Definitions - -### Objective -Establish the foundational type system and basic UI infrastructure for OPC-UA configuration. - -### Deliverables - -#### 1.1 Type Definitions - -**File:** `src/types/PLC/open-plc.ts` - -Add Zod schemas for all OPC-UA configuration types: - -```typescript -// Security Profile Schema -const OpcUaSecurityProfileSchema = z.object({ - id: z.string().uuid(), - name: z.string().min(1).max(64), - enabled: z.boolean(), - securityPolicy: z.enum(['None', 'Basic128Rsa15', 'Basic256', 'Basic256Sha256']), - securityMode: z.enum(['None', 'Sign', 'SignAndEncrypt']), - authMethods: z.array(z.enum(['Anonymous', 'Username', 'Certificate'])).min(1), -}) - -// Trusted Certificate Schema -const OpcUaTrustedCertificateSchema = z.object({ - id: z.string().min(1).max(64), - pem: z.string(), - subject: z.string().optional(), - validFrom: z.string().optional(), - validTo: z.string().optional(), - fingerprint: z.string().optional(), -}) - -// User Schema -const OpcUaUserSchema = z.object({ - id: z.string().uuid(), - type: z.enum(['password', 'certificate']), - username: z.string().nullable(), - passwordHash: z.string().nullable(), - certificateId: z.string().nullable(), - role: z.enum(['viewer', 'operator', 'engineer']), -}) - -// Permissions Schema -const OpcUaPermissionsSchema = z.object({ - viewer: z.enum(['r', 'w', 'rw']).default('r'), - operator: z.enum(['r', 'w', 'rw']).default('r'), - engineer: z.enum(['r', 'w', 'rw']).default('rw'), -}) - -// Field Configuration Schema (for structures) -const OpcUaFieldConfigSchema = z.object({ - fieldPath: z.string(), - displayName: z.string(), - initialValue: z.union([z.boolean(), z.number(), z.string()]), - permissions: OpcUaPermissionsSchema, -}) - -// Node Configuration Schema -const OpcUaNodeConfigSchema = z.object({ - id: z.string().uuid(), - pouName: z.string(), - variablePath: z.string(), - variableType: z.string(), - nodeId: z.string(), - browseName: z.string(), - displayName: z.string(), - description: z.string().default(''), - initialValue: z.union([z.boolean(), z.number(), z.string()]), - permissions: OpcUaPermissionsSchema, - nodeType: z.enum(['variable', 'structure', 'array']), - fields: z.array(OpcUaFieldConfigSchema).optional(), - arrayLength: z.number().optional(), - elementType: z.string().optional(), -}) - -// Complete Server Configuration Schema -const OpcUaServerConfigSchema = z.object({ - server: z.object({ - enabled: z.boolean().default(false), - name: z.string().max(128).default('OpenPLC OPC UA Server'), - applicationUri: z.string().default('urn:openplc:opcua:server'), - productUri: z.string().default('urn:openplc:runtime'), - bindAddress: z.string().default('0.0.0.0'), - port: z.number().int().min(1).max(65535).default(4840), - endpointPath: z.string().default('/openplc/opcua'), - }), - securityProfiles: z.array(OpcUaSecurityProfileSchema).default([]), - security: z.object({ - serverCertificateStrategy: z.enum(['auto_self_signed', 'custom']).default('auto_self_signed'), - serverCertificateCustom: z.string().nullable().default(null), - serverPrivateKeyCustom: z.string().nullable().default(null), - trustedClientCertificates: z.array(OpcUaTrustedCertificateSchema).default([]), - }), - users: z.array(OpcUaUserSchema).default([]), - cycleTimeMs: z.number().int().min(10).max(10000).default(100), - addressSpace: z.object({ - namespaceUri: z.string().default('urn:openplc:opcua:namespace'), - nodes: z.array(OpcUaNodeConfigSchema).default([]), - }), -}) -``` - -#### 1.2 Protocol Extension - -**File:** `src/types/PLC/open-plc.ts` - -Add 'opcua' to the PLCServer protocol union: - -```typescript -const PLCServerSchema = z.object({ - name: z.string(), - protocol: z.enum(['modbus-tcp', 's7comm', 'ethernet-ip', 'opcua']), - modbusSlaveConfig: ModbusSlaveConfigSchema.optional(), - s7commSlaveConfig: S7CommSlaveConfigSchema.optional(), - opcuaServerConfig: OpcUaServerConfigSchema.optional(), // Add this -}) -``` - -#### 1.3 Project Slice Actions - -**File:** `src/renderer/store/slices/project/slice.ts` - -Add basic CRUD actions for OPC-UA configuration: - -```typescript -// OPC-UA Server Actions -createOpcUaServer: (serverName: string) => void -updateOpcUaServerSettings: (serverName: string, settings: Partial) => void -deleteOpcUaServer: (serverName: string) => void -``` - -#### 1.4 UI Shell Component - -**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx` - -Create the basic tabbed UI structure: - -```tsx -const OpcUaServerEditor = () => { - const [activeTab, setActiveTab] = useState('general') - - const tabs = [ - { id: 'general', label: 'General Settings' }, - { id: 'security', label: 'Security Profiles' }, - { id: 'users', label: 'Users' }, - { id: 'certificates', label: 'Certificates' }, - { id: 'address-space', label: 'Address Space' }, - ] - - return ( -
- -
- {activeTab === 'general' && } - {activeTab === 'security' && } - {activeTab === 'users' && } - {activeTab === 'certificates' && } - {activeTab === 'address-space' && } -
-
- ) -} -``` - -### Acceptance Criteria - -- [ ] All Zod schemas defined and exported -- [ ] PLCServer type updated with opcuaServerConfig -- [ ] Basic project slice actions implemented -- [ ] Tabbed UI component renders with placeholder content -- [ ] OPC-UA server can be created/deleted from Servers panel - ---- - -## Phase 2: General Settings & Security Profiles - -### Objective -Implement the first two configuration tabs with full functionality. - -### Deliverables - -#### 2.1 General Settings Tab - -**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/tabs/general-settings.tsx` - -Implement all fields: -- Enable toggle -- Server name -- Application URI -- Product URI -- Bind address (dropdown with common options) -- Port number -- Endpoint path -- Cycle time with validation - -```tsx -const GeneralSettingsTab = () => { - const { project, projectActions } = useOpenPLCStore() - const opcuaConfig = getOpcUaConfig(project) - - const handleUpdateServer = (updates: Partial) => { - projectActions.updateOpcUaServerSettings(serverName, updates) - } - - return ( -
- {/* Enable Toggle */} -
- - handleUpdateServer({ enabled })} - /> -
- - {/* Server Identity Section */} -
-

Server Identity

- {/* Form fields */} -
- - {/* Network Configuration Section */} -
-

Network Configuration

- {/* Form fields with endpoint URL preview */} -
- - {/* Performance Section */} -
-

Performance

- {/* Cycle time input */} -
-
- ) -} -``` - -#### 2.2 Security Profiles Tab - -**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/tabs/security-profiles.tsx` - -Implement: -- List of security profiles with enable/disable toggle -- Add/Edit/Delete profile functionality -- Profile modal with all settings -- Validation rules (Anonymous only with None policy, etc.) - -#### 2.3 Security Profile Modal - -**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx` - -Implement modal with: -- Profile name input -- Enable toggle -- Security policy dropdown -- Security mode dropdown -- Authentication methods checkboxes -- Validation feedback - -#### 2.4 Project Slice Actions - -Add actions for security profile management: - -```typescript -addOpcUaSecurityProfile: (serverName: string, profile: OpcUaSecurityProfile) => void -updateOpcUaSecurityProfile: (serverName: string, profileId: string, updates: Partial) => void -removeOpcUaSecurityProfile: (serverName: string, profileId: string) => void -``` - -### Acceptance Criteria - -- [ ] General settings tab saves all fields correctly -- [ ] Endpoint URL preview updates dynamically -- [ ] Cycle time validates range (10-10000) -- [ ] Security profiles can be added/edited/removed -- [ ] At least one profile must be enabled (validation) -- [ ] Anonymous auth only available with None policy -- [ ] Profile list shows summary of each profile - ---- - -## Phase 3: Users & Certificates - -### Objective -Implement user authentication and certificate management. - -### Deliverables - -#### 3.1 Users Tab - -**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/tabs/users.tsx` - -Implement: -- User list with role badges -- Add/Edit/Delete user functionality -- User type toggle (password/certificate) -- Password validation and hashing - -#### 3.2 User Modal - -**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx` - -Implement: -- Authentication type selector -- Password fields with strength indicator -- Certificate selector (from trusted certificates) -- Role selector with descriptions - -#### 3.3 Password Hashing Utility - -**File:** `src/utils/opcua/bcrypt-utils.ts` - -```typescript -import bcrypt from 'bcryptjs' - -export const hashPassword = async (password: string): Promise => { - const salt = await bcrypt.genSalt(12) - return bcrypt.hash(password, salt) -} - -export const verifyPassword = async ( - password: string, - hash: string -): Promise => { - return bcrypt.compare(password, hash) -} -``` - -#### 3.4 Certificates Tab - -**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/tabs/certificates.tsx` - -Implement: -- Server certificate strategy selector -- Custom certificate upload (when strategy = custom) -- Private key upload -- Trusted client certificates list -- Certificate details display - -#### 3.5 Certificate Parsing Utility - -**File:** `src/utils/opcua/certificate-utils.ts` - -Use a library like `node-forge` to parse PEM certificates: - -```typescript -import forge from 'node-forge' - -export interface CertificateInfo { - subject: string - issuer: string - validFrom: Date - validTo: Date - fingerprint: string -} - -export const parsePemCertificate = (pem: string): CertificateInfo => { - const cert = forge.pki.certificateFromPem(pem) - return { - subject: cert.subject.getField('CN')?.value || 'Unknown', - issuer: cert.issuer.getField('CN')?.value || 'Unknown', - validFrom: cert.validity.notBefore, - validTo: cert.validity.notAfter, - fingerprint: forge.md.sha256 - .create() - .update(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes()) - .digest() - .toHex(), - } -} -``` - -#### 3.6 Add Trusted Certificate Modal - -**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx` - -Implement: -- Certificate ID input -- PEM content text area -- File browse button -- Certificate details preview (parsed from PEM) - -### Acceptance Criteria - -- [ ] Password users can be created with bcrypt hashing -- [ ] Certificate users can be created referencing trusted certs -- [ ] At least one user required when Username auth enabled -- [ ] Server certificate strategy works (auto/custom) -- [ ] Custom certificates can be uploaded via file or paste -- [ ] Trusted certificates show parsed details (subject, validity) -- [ ] Certificate IDs are unique - ---- - -## Phase 4: Address Space - Basic Variables - -### Objective -Implement the variable tree and basic variable selection functionality. - -### Deliverables - -#### 4.1 Address Space Tab - -**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/tabs/address-space.tsx` - -Implement: -- Namespace URI input -- Split-pane layout (available variables | selected variables) -- Add/Remove buttons -- Filter input for variable search - -#### 4.2 Variable Tree Component - -**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-tree.tsx` - -Build the hierarchical variable tree from project POUs: - -```tsx -interface TreeNode { - id: string - name: string - type: 'program' | 'function_block' | 'global' | 'variable' | 'structure' | 'array' - variableType?: string // IEC type for variables - children?: TreeNode[] - pouName: string - variablePath: string - isSelectable: boolean - isExpanded?: boolean -} - -const VariableTree = ({ - nodes, - selectedIds, - onSelect, - onExpand, -}: VariableTreeProps) => { - const renderNode = (node: TreeNode, depth: number) => ( -
-
- {node.children && ( - onExpand(node.id)} - /> - )} - {node.isSelectable && ( - onSelect(node)} - /> - )} - - {node.name} - {node.variableType && ( - ({node.variableType}) - )} -
- {node.isExpanded && node.children?.map(child => - renderNode(child, depth + 1) - )} -
- ) - - return
{nodes.map(node => renderNode(node, 0))}
-} -``` - -#### 4.3 Variable Extraction from Project - -**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/hooks/use-project-variables.ts` - -Extract variables from project POUs: - -```typescript -const useProjectVariables = (): TreeNode[] => { - const { project } = useOpenPLCStore() - - return useMemo(() => { - const nodes: TreeNode[] = [] - - // Programs and Function Block instances - for (const pou of project.data.pous) { - if (pou.type === 'program') { - nodes.push(buildProgramNode(pou, project.data)) - } - } - - // Global Variables - if (project.data.globalVariables?.length) { - nodes.push(buildGlobalVariablesNode(project.data.globalVariables)) - } - - return nodes - }, [project.data]) -} - -const buildProgramNode = (pou: POU, projectData: PLCProjectData): TreeNode => { - return { - id: `pou-${pou.data.name}`, - name: pou.data.name, - type: 'program', - pouName: pou.data.name, - variablePath: '', - isSelectable: false, - children: pou.data.variables.map(v => - buildVariableNode(v, pou.data.name, projectData) - ), - } -} -``` - -#### 4.4 Simple Variable Configuration Modal - -**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx` - -Implement modal for configuring selected variables: -- Node ID (auto-generated, editable) -- Browse name -- Display name -- Description -- Initial value (type-appropriate input) -- Permissions matrix - -#### 4.5 Selected Variables List - -**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/selected-variables-list.tsx` - -Display selected variables with: -- Node summary (node ID, type, permissions) -- Edit/Remove buttons -- Drag-and-drop reordering - -### Acceptance Criteria - -- [ ] Variable tree displays all program variables -- [ ] Tree supports expand/collapse for nested items -- [ ] Simple variables (INT, BOOL, REAL, etc.) can be selected -- [ ] Selected variables appear in right panel -- [ ] Variables can be configured with OPC-UA properties -- [ ] Node IDs are auto-generated but editable -- [ ] Permissions can be set per variable -- [ ] Variables can be removed from selection - ---- - -## Phase 5: Address Space - Complex Types - -### Objective -Add support for structures, arrays, and deeply nested function blocks. - -### Deliverables - -#### 5.1 Structure Support - -Extend the variable tree to show structure fields: - -```typescript -const buildStructureNode = ( - variable: Variable, - pouName: string, - structType: StructType -): TreeNode => { - return { - id: `${pouName}-${variable.name}`, - name: variable.name, - type: 'structure', - variableType: variable.type.value, - pouName, - variablePath: variable.name, - isSelectable: true, // Can select entire structure - children: structType.variable.map(field => ({ - id: `${pouName}-${variable.name}-${field.name}`, - name: field.name, - type: 'variable', - variableType: field.type.value, - pouName, - variablePath: `${variable.name}.${field.name}`, - isSelectable: true, // Or select individual fields - })), - } -} -``` - -#### 5.2 Structure Configuration Modal - -**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/structure-config-modal.tsx` - -Implement modal for structure configuration: -- Structure-level properties (node ID, browse name, etc.) -- Field table with individual permissions -- Nested structure handling - -#### 5.3 Array Support - -Extend the variable tree for arrays: - -```typescript -const buildArrayNode = ( - variable: Variable, - pouName: string, - arrayDimension: string // e.g., "1..10" -): TreeNode => { - const [min, max] = arrayDimension.split('..').map(Number) - const length = max - min + 1 - - return { - id: `${pouName}-${variable.name}`, - name: variable.name, - type: 'array', - variableType: `ARRAY[${arrayDimension}] OF ${elementType}`, - pouName, - variablePath: variable.name, - isSelectable: true, - children: Array.from({ length }, (_, i) => ({ - id: `${pouName}-${variable.name}-${min + i}`, - name: `[${min + i}]`, - type: 'variable', - variableType: elementType, - pouName, - variablePath: `${variable.name}[${min + i}]`, - isSelectable: true, - })), - } -} -``` - -#### 5.4 Array Configuration Modal - -**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/array-config-modal.tsx` - -Implement modal for array configuration: -- Array-level properties -- Element type display -- Length display -- Permissions (apply to all elements) - -#### 5.5 Nested Function Block Support - -Handle deeply nested FB instances: - -```typescript -const buildFunctionBlockNode = ( - variable: Variable, - pouName: string, - fbType: FunctionBlock, - projectData: PLCProjectData, - currentPath: string = '' -): TreeNode => { - const variablePath = currentPath - ? `${currentPath}.${variable.name}` - : variable.name - - return { - id: `${pouName}-${variablePath}`, - name: variable.name, - type: 'function_block', - variableType: variable.type.value, - pouName, - variablePath, - isSelectable: true, // Select entire FB - children: fbType.variable.map(fbVar => { - // Check if this FB variable is itself an FB - const nestedFbType = findFunctionBlock(fbVar.type.value, projectData) - - if (nestedFbType) { - // Recursive: nested FB - return buildFunctionBlockNode( - fbVar, - pouName, - nestedFbType, - projectData, - variablePath - ) - } - - // Leaf: simple variable - return { - id: `${pouName}-${variablePath}-${fbVar.name}`, - name: fbVar.name, - type: 'variable', - variableType: fbVar.type.value, - pouName, - variablePath: `${variablePath}.${fbVar.name}`, - isSelectable: true, - } - }), - } -} -``` - -#### 5.6 Selection Behavior for Complex Types - -Implement selection logic: - -```typescript -const handleSelectNode = (node: TreeNode) => { - if (node.type === 'variable') { - // Simple toggle - toggleSelection(node.id) - } else { - // Complex type: select/deselect all children - const childIds = getAllChildIds(node) - if (allSelected(childIds)) { - deselectAll(childIds) - } else { - selectAll(childIds) - } - } -} -``` - -#### 5.7 Deeply Nested Path Display - -Show full path in selected variables list: - -``` -main:PROCESS_CTRL.HEATER.PID.OUTPUT - └── FB ──┘ └─ FB ─┘ └─ var -``` - -### Acceptance Criteria - -- [ ] Structures display with expandable fields -- [ ] Entire structure or individual fields can be selected -- [ ] Arrays display with expandable elements -- [ ] Entire array can be selected (not individual elements for now) -- [ ] Nested FBs display correctly (FB inside FB inside FB) -- [ ] All levels of nesting are navigable -- [ ] Selection of parent auto-selects all children -- [ ] Path display shows full hierarchy - ---- - -## Phase 6: Compiler Integration & Testing - -### Objective -Integrate OPC-UA JSON generation into the compiler and perform end-to-end testing. - -### Deliverables - -#### 6.1 JSON Generator Implementation - -**File:** `src/utils/opcua/generate-opcua-config.ts` - -Implement the complete generator as documented in `03-json-configuration-mapping.md`: - -```typescript -export const generateOpcUaConfig = ( - servers: PLCServer[] | undefined, - debugFileContent: string -): string | null => { - // Implementation as documented -} -``` - -#### 6.2 Index Resolution Implementation - -**File:** `src/utils/opcua/resolve-indices.ts` - -Implement index resolution logic: - -```typescript -export const resolveVariableIndex = ( - pouName: string, - variablePath: string, - debugVariables: DebugVariable[] -): number => { - // Build debug path based on variable type - // Match against parsed debug.c - // Return index or throw error -} -``` - -#### 6.3 Compiler Module Integration - -**File:** `src/main/modules/compiler/compiler-module.ts` - -Add OPC-UA configuration generation to the compilation pipeline: - -```typescript -import { generateOpcUaConfig } from '@root/utils/opcua' - -// In the compile method, after debug.c is generated: -async handleGenerateOpcUaConfig( - sourceTargetFolderPath: string, - projectData: PLCProjectData -): Promise { - const opcuaServer = projectData.servers?.find( - s => s.protocol === 'opcua' && s.opcuaServerConfig?.server.enabled - ) - - if (!opcuaServer) return - - const debugCPath = join(sourceTargetFolderPath, 'debug.c') - const debugContent = await readFile(debugCPath, 'utf-8') - - const opcuaJson = generateOpcUaConfig(projectData.servers, debugContent) - - if (opcuaJson) { - const confDir = join(sourceTargetFolderPath, 'conf') - await mkdir(confDir, { recursive: true }) - await writeFile(join(confDir, 'opcua.json'), opcuaJson, 'utf-8') - } -} -``` - -#### 6.4 Error Handling & User Feedback - -Implement error handling for common issues: - -- Variable not found in debug.c -- Invalid configuration (no enabled profiles, no users, etc.) -- Circular references in FB structures - -```typescript -export class OpcUaConfigError extends Error { - constructor( - public code: 'VARIABLE_NOT_FOUND' | 'INVALID_CONFIG' | 'CIRCULAR_REF', - public details: Record, - message: string - ) { - super(message) - } -} -``` - -#### 6.5 Unit Tests - -**File:** `src/utils/opcua/__tests__/generate-opcua-config.test.ts` - -Write comprehensive tests: - -```typescript -describe('generateOpcUaConfig', () => { - it('returns null when OPC-UA not configured', () => { - // Test - }) - - it('generates valid JSON for simple variables', () => { - // Test - }) - - it('resolves structure field indices correctly', () => { - // Test - }) - - it('resolves array start indices correctly', () => { - // Test - }) - - it('handles nested FB paths correctly', () => { - // Test - }) - - it('throws error for unresolvable variables', () => { - // Test - }) -}) -``` - -#### 6.6 Integration Tests - -**File:** `e2e/opcua-configuration.spec.ts` - -Write end-to-end tests: - -```typescript -test('OPC-UA server can be configured and compiled', async ({ page }) => { - // 1. Create new project - // 2. Add program with variables - // 3. Configure OPC-UA server - // 4. Select variables for address space - // 5. Compile project - // 6. Verify opcua.json is generated correctly -}) -``` - -#### 6.7 Documentation Updates - -Update user documentation: -- How to configure OPC-UA server -- Security best practices -- Troubleshooting guide - -### Acceptance Criteria - -- [ ] JSON generator produces valid output -- [ ] Index resolution works for all variable types -- [ ] Compiler generates opcua.json when OPC-UA enabled -- [ ] Errors provide helpful messages -- [ ] Unit tests pass with >80% coverage -- [ ] E2E tests pass -- [ ] Generated JSON validated against runtime plugin - ---- - -## Summary Timeline - -| Phase | Description | Dependencies | -|-------|-------------|--------------| -| 1 | Foundation & Types | None | -| 2 | General Settings & Security | Phase 1 | -| 3 | Users & Certificates | Phase 2 | -| 4 | Address Space - Basic | Phase 1 | -| 5 | Address Space - Complex | Phase 4 | -| 6 | Compiler Integration | Phases 1-5 | - -**Parallel Work Opportunities:** -- Phases 2-3 (Security/Users) can be developed in parallel with Phases 4-5 (Address Space) -- Unit tests can be written alongside each phase - ---- - -## Risk Mitigation - -### Technical Risks - -| Risk | Mitigation | -|------|------------| -| Complex nested FB paths | Reuse debugger's proven path building logic | -| Password security | Use bcryptjs, never store plain text | -| Certificate parsing errors | Validate PEM format before storing | -| Large variable trees | Implement tree virtualization | - -### Integration Risks - -| Risk | Mitigation | -|------|------------| -| Index resolution failures | Comprehensive error messages with expected path | -| Runtime incompatibility | Test against actual OPC-UA plugin during development | -| Project file migration | Provide default values for new fields | - ---- - -## Dependencies - -### External Libraries - -| Library | Purpose | Phase | -|---------|---------|-------| -| bcryptjs | Password hashing | 3 | -| node-forge | Certificate parsing | 3 | -| react-window | Tree virtualization | 4 | -| uuid | Generate unique IDs | 1 | - -### Internal Dependencies - -| Component | Purpose | Phase | -|-----------|---------|-------| -| parseDebugFile | Index resolution | 6 | -| Project store | State management | 1 | -| Compiler module | JSON generation | 6 | diff --git a/docs/outdated/opcua-server-configuration/README.md b/docs/outdated/opcua-server-configuration/README.md deleted file mode 100644 index b6902db6c..000000000 --- a/docs/outdated/opcua-server-configuration/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# OPC-UA Server Configuration - -This folder contains design documentation for the OPC-UA server configuration feature in OpenPLC Editor. - -## Overview - -The OPC-UA server configuration allows users to configure an OPC-UA server that runs on the OpenPLC Runtime. This feature enables industrial clients to access PLC variables through the OPC-UA protocol, which is significantly more complex than the existing Modbus and S7Comm protocols. - -## Key Features - -- **Full Variable Access**: Unlike Modbus/S7Comm which only access I/O image tables, OPC-UA can expose any PLC program variable -- **Security Profiles**: Multiple security configurations (None, Sign, SignAndEncrypt) -- **Certificate Management**: Server and client certificate handling -- **User Authentication**: Password-based and certificate-based authentication -- **Role-Based Access Control**: Viewer, Operator, and Engineer roles with per-variable permissions -- **Complex Data Types**: Support for structures, arrays, and nested function blocks - -## Documentation Structure - -1. **[Design Overview](./01-design-overview.md)** - High-level architecture, configuration flow, and integration points -2. **[UI Screen Specifications](./02-ui-screen-specifications.md)** - Detailed UI designs for each configuration tab -3. **[JSON Configuration Mapping](./03-json-configuration-mapping.md)** - How editor configuration maps to runtime JSON format -4. **[Implementation Phases](./04-implementation-phases.md)** - Phased implementation plan with deliverables - -## Related Documentation - -- OpenPLC Runtime OPC-UA Plugin: See the `RTOP-100-OPC-UA` branch of openplc-runtime -- Existing protocol implementations: Modbus and S7Comm configurations in openplc-editor - -## Key Differences from Modbus/S7Comm - -| Aspect | Modbus/S7Comm | OPC-UA | -|--------|---------------|--------| -| Variable Access | I/O Image Tables only | Any PLC variable | -| Security | Basic or none | Multiple security profiles | -| Authentication | None | Anonymous, Username/Password, Certificate | -| Access Control | None | Role-based per-variable permissions | -| Data Types | Simple registers/coils | Variables, Structures, Arrays | -| Configuration Complexity | Low-Medium | High | diff --git a/docs/outdated/s7comm-server-implementation.md b/docs/outdated/s7comm-server-implementation.md deleted file mode 100644 index 118fa6fd0..000000000 --- a/docs/outdated/s7comm-server-implementation.md +++ /dev/null @@ -1,811 +0,0 @@ -# S7Comm Server Configuration Implementation Plan - -This document outlines the implementation plan for adding Siemens S7Comm server configuration support to the OpenPLC Editor. The implementation follows the existing Modbus server pattern and integrates with the S7Comm plugin on the OpenPLC Runtime. - -## Table of Contents - -- [Overview](#overview) -- [S7Comm Plugin Configuration Reference](#s7comm-plugin-configuration-reference) -- [Implementation Phases](#implementation-phases) - - [Phase 1: Type Definitions](#phase-1-type-definitions) - - [Phase 2: State Management](#phase-2-state-management) - - [Phase 3: Configuration Generation](#phase-3-configuration-generation) - - [Phase 4: Compiler Integration](#phase-4-compiler-integration) - - [Phase 5: UI Components](#phase-5-ui-components) - - [Phase 6: Enable S7Comm in UI](#phase-6-enable-s7comm-in-ui) -- [Files Summary](#files-summary) -- [Testing Checklist](#testing-checklist) - ---- - -## Overview - -The S7Comm plugin allows OpenPLC Runtime to act as a Siemens S7 protocol server, enabling communication with Siemens HMIs and SCADA systems. The editor needs to provide a configuration interface that generates the JSON configuration file expected by the runtime plugin. - -### Architecture Flow - -``` -User UI (Editor) - ↓ createServer('s7comm') -ProjectSlice (Zustand State) - ↓ stores s7commSlaveConfig -S7CommServerEditor (React Component) - ↓ updateS7CommServerConfig() -ProjectSlice - ↓ updates configuration -Compiler.compileProgram() - ↓ calls handleGenerateS7CommConfig() -generateS7CommConfig() - ↓ converts to runtime format -File: conf/s7comm.json - ↓ sent to runtime via upload -Runtime S7Comm Plugin - ↓ loads configuration -S7 Server starts on configured port -``` - ---- - -## S7Comm Plugin Configuration Reference - -The runtime S7Comm plugin expects a JSON configuration file with the following structure: - -### Server Configuration - -| Field | Type | Required | Default | Range | Description | -|-------|------|----------|---------|-------|-------------| -| `enabled` | boolean | No | `true` | - | Enable/disable the S7 server | -| `bind_address` | string | No | `"0.0.0.0"` | Valid IP | Network interface to bind | -| `port` | integer | No | `102` | 1-65535 | TCP port (102 is standard S7) | -| `max_clients` | integer | No | `32` | 1-1024 | Maximum simultaneous connections | -| `work_interval_ms` | integer | No | `100` | 1-10000 | Worker thread polling interval | -| `send_timeout_ms` | integer | No | `3000` | 100-60000 | Socket send timeout | -| `recv_timeout_ms` | integer | No | `3000` | 100-60000 | Socket receive timeout | -| `ping_timeout_ms` | integer | No | `10000` | 1000-300000 | Keep-alive timeout | -| `pdu_size` | integer | No | `480` | 240-960 | Maximum PDU size per S7 spec | - -> **Note:** Port 102 requires root/administrator privileges on most systems. - -### PLC Identity Configuration - -These values are returned in S7 SZL (System Status List) queries for HMI compatibility: - -| Field | Type | Required | Default | Max Length | Description | -|-------|------|----------|---------|------------|-------------| -| `name` | string | No | `"OpenPLC Runtime"` | 64 chars | PLC display name | -| `module_type` | string | No | `"CPU 315-2 PN/DP"` | 64 chars | CPU type string | -| `serial_number` | string | No | `"S C-XXXXXXXXX"` | 64 chars | Serial number | -| `copyright` | string | No | `"OpenPLC Project"` | 64 chars | Copyright string | -| `module_name` | string | No | `"OpenPLC"` | 64 chars | Module name | - -### Data Blocks Configuration - -Data blocks map S7 protocol DB areas to OpenPLC buffers. Maximum 64 data blocks supported. - -```json -{ - "db_number": 1, - "description": "Digital Inputs (%IX)", - "size_bytes": 128, - "mapping": { - "type": "bool_input", - "start_buffer": 0, - "bit_addressing": true - } -} -``` - -#### Data Block Fields - -| Field | Type | Required | Constraints | Description | -|-------|------|----------|-------------|-------------| -| `db_number` | integer | **Yes** | 1-65535, unique | S7 DB number | -| `description` | string | No | Max 128 chars | Human-readable description | -| `size_bytes` | integer | **Yes** | 1-65536 | DB size in bytes | -| `mapping.type` | string | **Yes** | See type list | OpenPLC buffer type | -| `mapping.start_buffer` | integer | No | 0-1023 | Starting buffer index | -| `mapping.bit_addressing` | boolean | No | - | Enable bit-level access | - -#### Supported Buffer Types - -| Type | IEC Address | Element Size | Direction | Description | -|------|-------------|--------------|-----------|-------------| -| `bool_input` | %IX | 1 bit | Read | Digital inputs | -| `bool_output` | %QX | 1 bit | Read/Write | Digital outputs | -| `bool_memory` | %MX | 1 bit | Read/Write | Internal markers | -| `byte_input` | %IB | 1 byte | Read | Byte inputs | -| `byte_output` | %QB | 1 byte | Read/Write | Byte outputs | -| `int_input` | %IW | 2 bytes | Read | Word inputs (UINT) | -| `int_output` | %QW | 2 bytes | Read/Write | Word outputs (UINT) | -| `int_memory` | %MW | 2 bytes | Read/Write | Word memory (UINT) | -| `dint_input` | %ID | 4 bytes | Read | Double word inputs (UDINT) | -| `dint_output` | %QD | 4 bytes | Read/Write | Double word outputs (UDINT) | -| `dint_memory` | %MD | 4 bytes | Read/Write | Double word memory (UDINT) | -| `lint_input` | %IL | 8 bytes | Read | Long word inputs (ULINT) | -| `lint_output` | %QL | 8 bytes | Read/Write | Long word outputs (ULINT) | -| `lint_memory` | %ML | 8 bytes | Read/Write | Long word memory (ULINT) | - -### System Areas Configuration - -System areas provide standard S7 address space mapping (PE, PA, MK): - -| Area | S7 Code | S7 Address | OpenPLC Default | Description | -|------|---------|------------|-----------------|-------------| -| `pe_area` | 0x81 | I | %IX | Process inputs | -| `pa_area` | 0x82 | Q | %QX | Process outputs | -| `mk_area` | 0x83 | M | %MX | Markers/flags | - -### Logging Configuration - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `log_connections` | boolean | `true` | Log client connect/disconnect | -| `log_data_access` | boolean | `false` | Log read/write operations (verbose) | -| `log_errors` | boolean | `true` | Log errors and warnings | - ---- - -## Implementation Phases - -### Phase 1: Type Definitions - -**File:** `src/types/PLC/open-plc.ts` - -Add Zod schemas for S7Comm configuration validation and type inference. - -#### New Schemas to Add - -```typescript -// S7Comm Buffer Type Enumeration -const S7CommBufferTypeSchema = z.enum([ - 'bool_input', - 'bool_output', - 'bool_memory', - 'byte_input', - 'byte_output', - 'int_input', - 'int_output', - 'int_memory', - 'dint_input', - 'dint_output', - 'dint_memory', - 'lint_input', - 'lint_output', - 'lint_memory', -]) - -// S7Comm Server Configuration Schema -const S7CommServerSettingsSchema = z.object({ - enabled: z.boolean().default(true), - bindAddress: z.string().default('0.0.0.0'), - port: z.number().min(1).max(65535).default(102), - maxClients: z.number().min(1).max(1024).default(32), - workIntervalMs: z.number().min(1).max(10000).default(100), - sendTimeoutMs: z.number().min(100).max(60000).default(3000), - recvTimeoutMs: z.number().min(100).max(60000).default(3000), - pingTimeoutMs: z.number().min(1000).max(300000).default(10000), - pduSize: z.number().min(240).max(960).default(480), -}) - -// PLC Identity Schema -const S7CommPlcIdentitySchema = z.object({ - name: z.string().max(64).default('OpenPLC Runtime'), - moduleType: z.string().max(64).default('CPU 315-2 PN/DP'), - serialNumber: z.string().max(64).default('S C-OPENPLC01'), - copyright: z.string().max(64).default('OpenPLC Project'), - moduleName: z.string().max(64).default('OpenPLC'), -}) - -// Buffer Mapping Schema -const S7CommBufferMappingSchema = z.object({ - type: S7CommBufferTypeSchema, - startBuffer: z.number().min(0).max(1023).default(0), - bitAddressing: z.boolean().default(false), -}) - -// Data Block Schema -const S7CommDataBlockSchema = z.object({ - dbNumber: z.number().min(1).max(65535), - description: z.string().max(128).default(''), - sizeBytes: z.number().min(1).max(65536), - mapping: S7CommBufferMappingSchema, -}) - -// System Area Schema -const S7CommSystemAreaSchema = z.object({ - enabled: z.boolean().default(false), - sizeBytes: z.number().min(1).max(65536).default(128), - mapping: S7CommBufferMappingSchema.optional(), -}) - -// System Areas Container Schema -const S7CommSystemAreasSchema = z.object({ - peArea: S7CommSystemAreaSchema.optional(), - paArea: S7CommSystemAreaSchema.optional(), - mkArea: S7CommSystemAreaSchema.optional(), -}) - -// Logging Schema -const S7CommLoggingSchema = z.object({ - logConnections: z.boolean().default(true), - logDataAccess: z.boolean().default(false), - logErrors: z.boolean().default(true), -}) - -// Complete S7Comm Slave Config Schema -const S7CommSlaveConfigSchema = z.object({ - server: S7CommServerSettingsSchema, - plcIdentity: S7CommPlcIdentitySchema.optional(), - dataBlocks: z.array(S7CommDataBlockSchema).max(64).default([]), - systemAreas: S7CommSystemAreasSchema.optional(), - logging: S7CommLoggingSchema.optional(), -}) -``` - -#### Update PLCServerSchema - -```typescript -const PLCServerSchema = z.object({ - name: z.string(), - protocol: PLCServerProtocolSchema, - modbusSlaveConfig: ModbusSlaveConfigSchema.optional(), - s7commSlaveConfig: S7CommSlaveConfigSchema.optional(), // Add this line -}) -``` - -#### Export Types - -```typescript -export type S7CommBufferType = z.infer -export type S7CommServerSettings = z.infer -export type S7CommPlcIdentity = z.infer -export type S7CommBufferMapping = z.infer -export type S7CommDataBlock = z.infer -export type S7CommSystemArea = z.infer -export type S7CommSystemAreas = z.infer -export type S7CommLogging = z.infer -export type S7CommSlaveConfig = z.infer -``` - ---- - -### Phase 2: State Management - -**File:** `src/renderer/store/slices/project/slice.ts` - -Add Zustand actions for S7Comm configuration management. - -#### New Actions - -```typescript -// Update S7Comm server settings (enabled, port, timeouts, etc.) -updateS7CommServerSettings: ( - serverName: string, - settings: Partial -) => void - -// Update PLC identity configuration -updateS7CommPlcIdentity: ( - serverName: string, - identity: Partial -) => void - -// Add a new data block -addS7CommDataBlock: ( - serverName: string, - dataBlock: S7CommDataBlock -) => void - -// Update an existing data block -updateS7CommDataBlock: ( - serverName: string, - dbNumber: number, - updates: Partial -) => void - -// Remove a data block -removeS7CommDataBlock: ( - serverName: string, - dbNumber: number -) => void - -// Update system area configuration -updateS7CommSystemArea: ( - serverName: string, - area: 'peArea' | 'paArea' | 'mkArea', - config: Partial -) => void - -// Update logging configuration -updateS7CommLogging: ( - serverName: string, - logging: Partial -) => void -``` - -#### Modify createServer Action - -Update the `createServer` action to initialize default S7Comm configuration: - -```typescript -createServer: (request) => { - // ... existing validation ... - - const newServer: PLCServer = { - name: request.name, - protocol: request.protocol, - // Initialize config based on protocol - ...(request.protocol === 'modbus-tcp' && { - modbusSlaveConfig: { - enabled: false, - networkInterface: '0.0.0.0', - port: 502, - }, - }), - ...(request.protocol === 's7comm' && { - s7commSlaveConfig: { - server: { - enabled: false, - bindAddress: '0.0.0.0', - port: 102, - maxClients: 32, - workIntervalMs: 100, - sendTimeoutMs: 3000, - recvTimeoutMs: 3000, - pingTimeoutMs: 10000, - pduSize: 480, - }, - dataBlocks: [], - }, - }), - } - - // ... rest of implementation ... -} -``` - -#### Default Data Blocks - -Consider providing a helper function to add common default data blocks: - -```typescript -const DEFAULT_S7COMM_DATA_BLOCKS: S7CommDataBlock[] = [ - { - dbNumber: 1, - description: 'Digital Inputs (%IX)', - sizeBytes: 128, - mapping: { type: 'bool_input', startBuffer: 0, bitAddressing: true }, - }, - { - dbNumber: 2, - description: 'Digital Outputs (%QX)', - sizeBytes: 128, - mapping: { type: 'bool_output', startBuffer: 0, bitAddressing: true }, - }, - { - dbNumber: 10, - description: 'Analog Inputs (%IW)', - sizeBytes: 2048, - mapping: { type: 'int_input', startBuffer: 0, bitAddressing: false }, - }, - { - dbNumber: 20, - description: 'Analog Outputs (%QW)', - sizeBytes: 2048, - mapping: { type: 'int_output', startBuffer: 0, bitAddressing: false }, - }, -] -``` - ---- - -### Phase 3: Configuration Generation - -**New File:** `src/utils/s7comm/generate-s7comm-config.ts` - -Create the JSON configuration generator that converts editor state to runtime format. - -```typescript -import type { PLCServer, S7CommSlaveConfig } from '@root/types/PLC/open-plc' - -/** - * Generates the S7Comm configuration JSON for the runtime plugin. - * Converts camelCase properties to snake_case expected by the C plugin. - * - * @param servers - Array of configured PLC servers - * @returns JSON string for s7comm.json or null if no enabled S7Comm server - */ -export function generateS7CommConfig(servers: PLCServer[]): string | null { - const s7commServer = servers.find( - (server) => - server.protocol === 's7comm' && - server.s7commSlaveConfig?.server.enabled - ) - - if (!s7commServer?.s7commSlaveConfig) { - return null - } - - const config = s7commServer.s7commSlaveConfig - - const runtimeConfig = { - server: { - enabled: config.server.enabled, - bind_address: config.server.bindAddress, - port: config.server.port, - max_clients: config.server.maxClients, - work_interval_ms: config.server.workIntervalMs, - send_timeout_ms: config.server.sendTimeoutMs, - recv_timeout_ms: config.server.recvTimeoutMs, - ping_timeout_ms: config.server.pingTimeoutMs, - pdu_size: config.server.pduSize, - }, - - ...(config.plcIdentity && { - plc_identity: { - name: config.plcIdentity.name, - module_type: config.plcIdentity.moduleType, - serial_number: config.plcIdentity.serialNumber, - copyright: config.plcIdentity.copyright, - module_name: config.plcIdentity.moduleName, - }, - }), - - data_blocks: config.dataBlocks.map((db) => ({ - db_number: db.dbNumber, - description: db.description, - size_bytes: db.sizeBytes, - mapping: { - type: db.mapping.type, - start_buffer: db.mapping.startBuffer, - bit_addressing: db.mapping.bitAddressing, - }, - })), - - ...(config.systemAreas && { - system_areas: { - ...(config.systemAreas.peArea && { - pe_area: { - enabled: config.systemAreas.peArea.enabled, - size_bytes: config.systemAreas.peArea.sizeBytes, - ...(config.systemAreas.peArea.mapping && { - mapping: { - type: config.systemAreas.peArea.mapping.type, - start_buffer: config.systemAreas.peArea.mapping.startBuffer, - }, - }), - }, - }), - ...(config.systemAreas.paArea && { - pa_area: { - enabled: config.systemAreas.paArea.enabled, - size_bytes: config.systemAreas.paArea.sizeBytes, - ...(config.systemAreas.paArea.mapping && { - mapping: { - type: config.systemAreas.paArea.mapping.type, - start_buffer: config.systemAreas.paArea.mapping.startBuffer, - }, - }), - }, - }), - ...(config.systemAreas.mkArea && { - mk_area: { - enabled: config.systemAreas.mkArea.enabled, - size_bytes: config.systemAreas.mkArea.sizeBytes, - ...(config.systemAreas.mkArea.mapping && { - mapping: { - type: config.systemAreas.mkArea.mapping.type, - start_buffer: config.systemAreas.mkArea.mapping.startBuffer, - }, - }), - }, - }), - }, - }), - - ...(config.logging && { - logging: { - log_connections: config.logging.logConnections, - log_data_access: config.logging.logDataAccess, - log_errors: config.logging.logErrors, - }, - }), - } - - return JSON.stringify(runtimeConfig, null, 2) -} -``` - -**New File:** `src/utils/s7comm/index.ts` - -```typescript -export { generateS7CommConfig } from './generate-s7comm-config' -``` - ---- - -### Phase 4: Compiler Integration - -**File:** `src/main/modules/compiler/compiler-module.ts` - -Add the S7Comm configuration file generation to the compilation pipeline. - -#### Add Import - -```typescript -import { generateS7CommConfig } from '@utils/s7comm' -``` - -#### Add New Method - -```typescript -/** - * Generates the S7Comm server configuration file for the runtime plugin. - * Creates conf/s7comm.json if an S7Comm server is enabled. - */ -async handleGenerateS7CommConfig( - sourceTargetFolderPath: string, - projectData: ProjectState['data'], - handleOutputData: HandleOutputDataCallback, -): Promise { - const s7commConfig = generateS7CommConfig(projectData.servers) - - if (s7commConfig) { - const confFolderPath = join(sourceTargetFolderPath, 'conf') - await mkdir(confFolderPath, { recursive: true }) - - const configFilePath = join(confFolderPath, 's7comm.json') - await writeFile(configFilePath, s7commConfig, 'utf-8') - - handleOutputData('Generated conf/s7comm.json', 'info') - } -} -``` - -#### Update Compilation Pipeline - -Find the location where `handleGenerateModbusSlaveConfig` is called and add the S7Comm generation alongside it: - -```typescript -// Generate protocol configurations -await this.handleGenerateModbusSlaveConfig(sourceTargetFolderPath, projectData, handleOutputData) -await this.handleGenerateModbusMasterConfig(sourceTargetFolderPath, projectData, handleOutputData) -await this.handleGenerateS7CommConfig(sourceTargetFolderPath, projectData, handleOutputData) // Add this -``` - ---- - -### Phase 5: UI Components - -Create the S7Comm server editor UI components. - -**New Directory:** `src/renderer/components/_features/[workspace]/editor/server/s7comm-server/` - -#### Component Structure - -``` -s7comm-server/ -├── index.tsx # Main S7Comm server editor -├── server-config-section.tsx # Server network settings -├── plc-identity-section.tsx # PLC identity configuration -├── data-blocks-section.tsx # Data blocks list -├── data-block-modal.tsx # Modal for add/edit data block -├── system-areas-section.tsx # PE/PA/MK area configuration -└── logging-section.tsx # Logging toggles -``` - -#### Main Editor Component (`index.tsx`) - -```tsx -import { useOpenPLCStore } from '@root/renderer/store' -import { ServerConfigSection } from './server-config-section' -import { PlcIdentitySection } from './plc-identity-section' -import { DataBlocksSection } from './data-blocks-section' -import { SystemAreasSection } from './system-areas-section' -import { LoggingSection } from './logging-section' - -type S7CommServerEditorProps = { - serverName: string -} - -export const S7CommServerEditor = ({ serverName }: S7CommServerEditorProps) => { - const { servers } = useOpenPLCStore.useSelector('project').data - const server = servers.find((s) => s.name === serverName) - - if (!server || server.protocol !== 's7comm' || !server.s7commSlaveConfig) { - return
S7Comm server configuration not found
- } - - const config = server.s7commSlaveConfig - - return ( -
- - - - - -
- ) -} -``` - -#### Server Configuration Section (`server-config-section.tsx`) - -UI elements: -- Enable/disable toggle switch -- Bind address dropdown (`0.0.0.0`, `127.0.0.1`) -- Port input with validation (1-65535, note about port 102) -- Max clients input (1-1024) -- Collapsible "Advanced Settings" accordion: - - Work interval (ms) - - Send timeout (ms) - - Receive timeout (ms) - - Ping timeout (ms) - - PDU size slider (240-960) - -#### PLC Identity Section (`plc-identity-section.tsx`) - -Collapsible accordion with: -- Name input (max 64 chars) -- Module type input (max 64 chars) -- Serial number input (max 64 chars) -- Copyright input (max 64 chars) -- Module name input (max 64 chars) - -Pre-populated with defaults for convenience. - -#### Data Blocks Section (`data-blocks-section.tsx`) - -Main configuration area: -- Table with columns: DB Number, Description, Size (bytes), Mapping Type, Actions -- "Add Data Block" button -- Edit/Delete icons per row -- Empty state message when no blocks configured -- Option to "Add Default Blocks" for quick setup - -#### Data Block Modal (`data-block-modal.tsx`) - -Modal dialog for creating/editing data blocks: -- DB Number input (1-65535, unique validation) -- Description input (optional, max 128 chars) -- Size in bytes input (1-65536) -- Mapping type dropdown (all 15 buffer types) -- Start buffer input (0-1023) -- Bit addressing toggle (only shown for bool types) - -Validation: -- DB number must be unique -- Size must be positive -- Buffer range must not exceed limits - -#### System Areas Section (`system-areas-section.tsx`) - -Collapsible accordion with three sub-sections: -- **PE Area (Process Inputs):** Enable toggle, size, mapping config -- **PA Area (Process Outputs):** Enable toggle, size, mapping config -- **MK Area (Markers):** Enable toggle, size, mapping config - -Each area shows relevant defaults when enabled. - -#### Logging Section (`logging-section.tsx`) - -Simple toggle switches: -- Log connections (default: on) -- Log data access (default: off, with "verbose" warning) -- Log errors (default: on) - ---- - -### Phase 6: Enable S7Comm in UI - -#### Update Server Creation Modal - -**File:** `src/renderer/components/_features/[workspace]/create-element/element-card/index.tsx` - -Change the disabled flag for S7Comm: - -```typescript -const ServerProtocolSources = [ - { value: 'modbus-tcp', label: 'Modbus/TCP', disabled: false }, - { value: 's7comm', label: 'Siemens S7comm', disabled: false }, // Changed from true - { value: 'ethernet-ip', label: 'EtherNet/IP', disabled: true }, -] as const -``` - -#### Update Server Editor Export - -**File:** `src/renderer/components/_features/[workspace]/editor/server/index.ts` - -```typescript -export { ModbusServerEditor } from './modbus-server' -export { S7CommServerEditor } from './s7comm-server' -``` - -#### Update Editor Tab Routing - -Find where server tabs are rendered (likely in the workspace editor area) and add routing for S7Comm: - -```tsx -{server.protocol === 'modbus-tcp' && ( - -)} -{server.protocol === 's7comm' && ( - -)} -``` - ---- - -## Files Summary - -### Files to Create - -| File | Purpose | -|------|---------| -| `src/utils/s7comm/generate-s7comm-config.ts` | JSON configuration generator | -| `src/utils/s7comm/index.ts` | Utils barrel export | -| `src/renderer/components/_features/[workspace]/editor/server/s7comm-server/index.tsx` | Main editor component | -| `src/renderer/components/_features/[workspace]/editor/server/s7comm-server/server-config-section.tsx` | Server settings UI | -| `src/renderer/components/_features/[workspace]/editor/server/s7comm-server/plc-identity-section.tsx` | PLC identity UI | -| `src/renderer/components/_features/[workspace]/editor/server/s7comm-server/data-blocks-section.tsx` | Data blocks list UI | -| `src/renderer/components/_features/[workspace]/editor/server/s7comm-server/data-block-modal.tsx` | Data block add/edit modal | -| `src/renderer/components/_features/[workspace]/editor/server/s7comm-server/system-areas-section.tsx` | System areas UI | -| `src/renderer/components/_features/[workspace]/editor/server/s7comm-server/logging-section.tsx` | Logging toggles UI | - -### Files to Modify - -| File | Changes | -|------|---------| -| `src/types/PLC/open-plc.ts` | Add S7Comm Zod schemas and types | -| `src/renderer/store/slices/project/slice.ts` | Add S7Comm state actions | -| `src/main/modules/compiler/compiler-module.ts` | Add S7Comm config generation | -| `src/renderer/components/_features/[workspace]/create-element/element-card/index.tsx` | Enable S7Comm option | -| `src/renderer/components/_features/[workspace]/editor/server/index.ts` | Export S7CommServerEditor | - ---- - -## Testing Checklist - -### Unit Tests - -- [ ] S7Comm Zod schema validation -- [ ] `generateS7CommConfig` function with various configurations -- [ ] State actions for all S7Comm operations -- [ ] Data block uniqueness validation - -### Integration Tests - -- [ ] Create S7Comm server via UI -- [ ] Edit server configuration -- [ ] Add/edit/delete data blocks -- [ ] Configuration persists across sessions -- [ ] JSON file generated during compilation - -### Manual Testing - -- [ ] S7Comm option visible and enabled in server creation modal -- [ ] Server configuration section renders correctly -- [ ] All input fields validate properly -- [ ] Data block modal opens/closes correctly -- [ ] Table updates when data blocks change -- [ ] Advanced settings accordion collapses/expands -- [ ] Generated JSON matches runtime expectations -- [ ] Runtime successfully loads generated configuration - -### Edge Cases - -- [ ] Empty data blocks array -- [ ] Maximum 64 data blocks -- [ ] Duplicate DB number validation -- [ ] Port 102 privilege warning -- [ ] Buffer range overflow validation -- [ ] Very long description strings (128 char limit) - ---- - -## References - -- [OpenPLC Runtime S7Comm Plugin Documentation](../../../openplc-runtime/docs/) -- [S7Comm Protocol Specification](https://snap7.sourceforge.net/) -- [Existing Modbus Server Implementation](../src/renderer/components/_features/[workspace]/editor/server/modbus-server/) diff --git a/docs/outdated/unified-frontend-serialization.md b/docs/outdated/unified-frontend-serialization.md deleted file mode 100644 index 9809225b6..000000000 --- a/docs/outdated/unified-frontend-serialization.md +++ /dev/null @@ -1,115 +0,0 @@ -# Unified Frontend Parsing & Serialization: Load + Save - -## Context - -The frontend/backend separation should follow a clean principle: **the backend is a file I/O layer** (read/write raw files), and the **frontend owns all parsing and serialization**. This makes the frontend code identical for Electron and web, and eliminates double-parsing. - -Currently: -- **Load**: Backend reads files, parses POUs, converts to IPC format → adapter converts to flat format → frontend re-parses variables for type reclassification (double-parsing) -- **Save**: Frontend sends structured objects → adapter converts to IPC format → backend re-serializes POUs to text (double-serialization) -- **3 duplicate `executeSave` functions** exist across components - -### Shared Utilities (already exist, will be reused) -- `src/frontend/utils/PLC/pou-text-parser.ts` — parsers for all POU languages (already used by backend via `@root/`) -- `src/frontend/utils/PLC/pou-text-serializer.ts` — serializers for all POU languages (already used by backend via `@root/`) -- `src/frontend/utils/PLC/pou-file-extensions.ts` — extension/folder/keyword mappings -- `src/frontend/utils/save-project.ts` — `sanitizePou`, `prepareSavePayload`, `collectDebugVariables` -- `src/frontend/utils/generate-iec-variables-to-string.ts` — variable serialization -- `src/frontend/utils/generate-iec-string-to-variables.ts` — variable parsing with project context - ---- - -## Phase 0: Eliminate executeSave Duplication - -### Step 0.1: Create `src/frontend/utils/save-actions.ts` -Shared `executeSaveProject(projectPort)` function that reads state, prepares payload, calls port, updates state. Replaces 3 inline copies. - -### Step 0.2: Export `sanitizePou` from `save-project.ts` -Change from private to exported. Needed by single-file save. - -### Step 0.3: Replace 3 executeSave duplicates -- `accelerator-handler.tsx`, `file.tsx` (menu), `default.tsx` (activity bar) - -### Step 0.4: Replace save logic in 2 modals -- `save-changes-modal.tsx`, `save-changes-file-modal.tsx` - ---- - -## Phase 1: Single-File Save with Frontend Serialization - -### Step 1.1: Fix backend saveFile for raw strings -**File:** `src/backend/editor/services/project-service/index.ts` - -Add `typeof content === 'string'` branch — write string directly without `JSON.stringify`. - -### Step 1.2: Add `executeSaveActiveFile` to `save-actions.ts` -For POUs: `sanitizePou()` → `serializePouToText()` → compute path → `projectPort.saveFile(path, text)`. -For device/data-type/resource/server: appropriate JSON serialization → `projectPort.saveFile()`. -Also updates `project.json` for debug variables. - -### Step 1.3: Wire Ctrl+S to `executeSaveActiveFile` -- Ctrl+S → `executeSaveActiveFile(projectPort)` (single file) -- Ctrl+Shift+S → `executeSaveProject(projectPort)` (full project) -- File menu: separate "Save" and "Save Project" items - ---- - -## Phase 2: Load with Frontend Parsing - -### Current load flow (to be simplified): -``` -Backend: read disk → parse POUs → IPC format → Adapter: convert to flat → Frontend: re-parse variables -``` - -### Target load flow: -``` -Backend: read disk → raw file contents → Adapter: pass through → Frontend: parse everything once -``` - -### Step 2.1: Add `readProjectFiles` to ProjectPort interface -**File:** `src/middleware/shared/ports/project-port.ts` - -New port method that returns raw file contents as strings. - -### Step 2.2: Implement Electron adapter for `readProjectFiles` -New IPC call that reads all project files and returns raw strings without parsing. - -### Step 2.3: Create frontend project parser utility -**File:** `src/frontend/utils/parse-project-files.ts` - -Pure function that takes raw file contents and produces `OpenProjectResponseData`: -1. Parse `projectJson` string -2. For each POU file: detect language/type from path, call appropriate parser -3. Parse variables with full project context (single pass) -4. Parse device config and pin mapping -5. Return data ready for `handleOpenProjectResponse` - -### Step 2.4: Update `openProject`/`openProjectByPath` flow -Adapter calls `readProjectFiles` + `parseProjectFiles` internally. Port contract stays the same. - -### Step 2.5: Backend simplification -Add raw file read IPC handler. Existing `openProject` remains for backward compatibility. - ---- - -## Phase 3: Full Project Save with Frontend Serialization (future PR) - -Migrate `executeSaveProject` to serialize all files on the frontend before sending to the backend. - ---- - -## Architecture After All Phases - -``` -LOAD: - Backend: fs.readFile → raw strings → IPC → Adapter: parseProjectFiles() → ProjectResponse → Store - -SAVE (single file): - Frontend: serializePouToText() → text string → projectPort.saveFile(path, text) → Backend: fs.writeFile - -SAVE (full project): - Frontend: serialize all files → projectPort.saveFiles([{path, content}]) → Backend: fs.writeFile each -``` - -Frontend owns: parsing, serialization, type classification, variable reclassification -Backend owns: file I/O, directory management, file watching, IPC transport diff --git a/docs/outdated/variable-id-audit.md b/docs/outdated/variable-id-audit.md deleted file mode 100644 index 6fd022558..000000000 --- a/docs/outdated/variable-id-audit.md +++ /dev/null @@ -1,918 +0,0 @@ -# Variable ID Audit - OpenPLC Editor - -## Executive Summary - -This document provides a comprehensive audit of all locations where variable IDs are currently used for linking variables to graphical elements in the OpenPLC Editor codebase. The audit was conducted as part of Phase 1 of the migration from UUID-based variable linking to name+type-based linking following IEC 61131-3 standards. - -**Key Findings:** -- Variable IDs are optional in the schema (`z.string().optional()`) -- IDs are generated using `crypto.randomUUID()` during variable creation -- Primary usage is in graphical editors (Ladder, FBD) for block-variable linking -- XML generation already uses variable names, not IDs -- A dual lookup system exists (by row index OR by variable ID) -- Broken links are created with synthetic IDs (`broken-${nodeId}`) - ---- - -## 1. Variable Schema Definitions - -### 1.1 PLCVariableSchema -**File:** `src/types/PLC/open-plc.ts` -**Lines:** 120-160 - -**Description:** Core variable type definition that includes an optional `id` field. - -```typescript -const PLCVariableSchema = z.object({ - id: z.string().optional(), // Line 121 - name: z.string(), - class: z.enum(['input', 'output', 'inOut', 'external', 'local', 'temp', 'global']).optional(), - type: z.discriminatedUnion('definition', [...]), - location: z.string(), - initialValue: z.string().or(z.null()).optional(), - documentation: z.string(), - debug: z.boolean(), -}) -``` - -**Usage Type:** Schema Definition -**Classification:** Data Structure -**Notes:** -- ID field is **optional**, indicating the system was designed to potentially work without IDs -- No validation or uniqueness constraints on the ID field -- ID is not required for variable creation - -### 1.2 PLCStructureVariableSchema -**File:** `src/types/PLC/open-plc.ts` -**Lines:** 55-100 - -**Description:** Variable definition within structure data types, also includes optional `id` field. - -```typescript -const PLCStructureVariableSchema = z.object({ - id: z.string().optional(), // Line 56 - name: z.string(), - type: z.discriminatedUnion('definition', [...]), - initialValue: z.object({...}).optional(), -}) -``` - -**Usage Type:** Schema Definition -**Classification:** Data Structure -**Notes:** -- Used for variables within user-defined structure types -- Same optional ID pattern as PLCVariableSchema - ---- - -## 2. Variable Creation with UUID Generation - -### 2.1 Local Variable Creation -**File:** `src/renderer/store/slices/project/slice.ts` -**Lines:** 276-280 - -**Description:** UUID generation during local variable creation in POUs. - -```typescript -variableToBeCreated.data = { - ...variableToBeCreated.data, - ...createVariableValidation(pou.data.variables, variableToBeCreated.data), - id: variableToBeCreated.data.id ? variableToBeCreated.data.id : crypto.randomUUID(), // Line 279 -} -``` - -**Usage Type:** Creation -**Classification:** ID Generation -**Fallback Mechanism:** Only generates UUID if `id` is not already present -**Notes:** -- Conditional generation: uses existing ID if present, otherwise generates new UUID -- Occurs in `createVariable` action for local scope -- Global variables do NOT receive IDs during creation (lines 250-265) - -### 2.2 Autocomplete Variable Creation (Ladder) -**File:** `src/renderer/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx` -**Lines:** 169-185 - -**Description:** UUID generation when creating variables from autocomplete in ladder editor. - -```typescript -const res = createVariable({ - data: { - id: uuidv4(), // Line 171 (using uuid library, equivalent to crypto.randomUUID()) - name: variableName, - type: {...}, - class: 'local', - location: '', - documentation: '', - debug: false, - }, - scope: 'local', - associatedPou: editor.meta.name, -}) -``` - -**Usage Type:** Creation -**Classification:** ID Generation -**Notes:** -- Uses `uuid` library's `v4()` function -- Always generates new ID for autocomplete-created variables -- Similar pattern exists in FBD autocomplete - -### 2.3 Additional UUID Generation Locations -**Files with `crypto.randomUUID()` usage:** -- `src/renderer/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx` -- `src/renderer/store/slices/fbd/utils/index.ts` -- `src/renderer/store/slices/ladder/utils/index.ts` -- `src/utils/python/addPythonLocalVariables.ts` -- `src/utils/cpp/addCppLocalVariables.ts` - -**Usage Type:** Creation -**Classification:** ID Generation - ---- - -## 3. Variable Lookup Utilities - -### 3.1 Dual Lookup Function -**File:** `src/renderer/store/slices/project/utils/index.ts` -**Lines:** 3-26 - -**Description:** Core utility function that supports looking up variables by EITHER row index OR variable ID. - -```typescript -export const getVariableBasedOnRowIdOrVariableId = ( - variables: PLCVariable[] | Omit[], - rowId?: number, - variableId?: string, -): PLCVariable | Omit | null => { - // Priority 1: Lookup by row index - if (rowId !== undefined) { - const variable = variables[rowId] // Line 9 - if (!variable) return null - return variable - } - - // Priority 2: Lookup by variable ID - if (variableId) { - const variable = variables.find((variable) => variable.id === variableId) // Line 17 - if (!variable) return null - return variable - } - - console.error('variableId or rowId not given') - return null -} -``` - -**Usage Type:** Lookup -**Classification:** Utility Function -**Fallback Mechanism:** Row index takes priority over variable ID -**Notes:** -- **Critical finding:** Row index lookup is prioritized over ID lookup -- This suggests the system was designed to work primarily with positional references -- ID lookup is a fallback mechanism -- Used extensively throughout the codebase (20+ call sites) - -### 3.2 Lookup Usage in Project Actions -**File:** `src/renderer/store/slices/project/slice.ts` -**Multiple locations:** - -#### Update Variable (Global) -**Lines:** 320-324 -```typescript -const variableToUpdate = getVariableBasedOnRowIdOrVariableId( - project.data.configuration.resource.globalVariables, - dataToBeUpdated.rowId, - dataToBeUpdated.data.id, -) -``` - -#### Update Variable (Local) -**Lines:** 347-351 -```typescript -const variableToUpdate = getVariableBasedOnRowIdOrVariableId( - pou.data.variables, - dataToBeUpdated.rowId, - dataToBeUpdated.variableId, -) -``` - -#### Delete Variable -**Lines:** 462-466, 482-486 -```typescript -const variable = getVariableBasedOnRowIdOrVariableId( - project.data.configuration.resource.globalVariables, - variableToBeDeleted.rowId, - variableToBeDeleted.variableId, -) -``` - -#### Rearrange Variables -**Lines:** 510-514, 530 -```typescript -const variableToBeRemoved = getVariableBasedOnRowIdOrVariableId( - project.data.configuration.resource.globalVariables, - rowId, - variableId, -) -``` - -**Usage Type:** Lookup -**Classification:** State Management -**Notes:** -- All CRUD operations use this dual lookup pattern -- Row index is always provided when available -- Variable ID is used as fallback - ---- - -## 4. Graphical Editor Block-Variable Linking - -### 4.1 Block Node Data Structure -**File:** `src/renderer/components/_atoms/graphical-editor/ladder/block.tsx` -**Lines:** 34-48 - -**Description:** Data structure for blocks that includes `handleTableId` for tracking variable connections. - -```typescript -export type LadderBlockConnectedVariables = { - handleId: string - handleTableId?: string // Line 36 - References variable ID - type: 'input' | 'output' - variable: PLCVariable | undefined -}[] - -export type BlockNodeData = BasicNodeData & { - variant: T - executionControl: boolean - lockExecutionControl: boolean - connectedVariables: LadderBlockConnectedVariables // Line 45 - variable: { id?: string; name: string } | PLCVariable // Line 46 - hasDivergence?: boolean -} -``` - -**Usage Type:** Linking -**Classification:** Data Structure -**Notes:** -- `handleTableId` stores the variable ID from the block variant's variable definition -- Used to track which variables are connected to which block handles -- The `variable` field can be either a full PLCVariable or a minimal object with optional ID - -### 4.2 Variable Connection in Autocomplete -**File:** `src/renderer/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx` -**Lines:** 124-137 - -**Description:** Populates `handleTableId` when connecting variables to blocks via autocomplete. - -```typescript -const connectedVariables: LadderBlockConnectedVariables = [ - ...(relatedBlock.data as BlockNodeData).connectedVariables.filter( - (v) => v.type !== variableNode.data.variant || v.handleId !== (variableNode as VariableNode).data.block.handleId, - ), - { - handleId: (variableNode as VariableNode).data.block.handleId, - handleTableId: (relatedBlock.data as BlockNodeData).variant.variables.find( - (v) => v.name === (variableNode as VariableNode).data.block.handleId, - )?.id, // Line 131-133 - Looks up ID from block variant definition - type: (variableNode as VariableNode).data.variant, - variable: variable, - }, -] -``` - -**Usage Type:** Linking -**Classification:** Connection Management -**Notes:** -- `handleTableId` is populated by finding the matching variable in the block variant's definition -- This creates a link between the block handle and the variable table -- Used for multi-input/output blocks - -### 4.3 Block Divergence Detection -**File:** `src/renderer/components/_atoms/graphical-editor/ladder/block.tsx` -**Lines:** 627-773 (handleUpdateDivergence function) - -**Description:** Uses `handleTableId` to maintain connections during library updates. - -```typescript -const handleUpdateDivergence = () => { - const { variables, rung, node, edges } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, { - nodeId: id, - }) - if (!rung || !node) return - - // ... divergence detection logic ... - - const connectedVariables: LadderBlockConnectedVariables = [] - data.connectedVariables.forEach((connectedVariable) => { - const newVariable = newNodeVariables.find( - (v) => v.id === connectedVariable.handleTableId, // Line 714 - Uses handleTableId to find matching variable - ) - if (newVariable) { - connectedVariables.push({ - handleId: newVariable.name, - handleTableId: newVariable.id, - type: connectedVariable.type, - variable: connectedVariable.variable, - }) - } - }) - // ... update node with new connections ... -} -``` - -**Usage Type:** Linking -**Classification:** Divergence Management -**Notes:** -- **Critical for library updates:** When a function block definition changes, this maintains variable connections -- Matches old connections to new block definition using `handleTableId` -- This is a key area where ID-based linking is actively used - -### 4.4 Variable Node ID Comparison -**File:** `src/renderer/components/_atoms/graphical-editor/ladder/block.tsx` -**Lines:** 479-496 - -**Description:** Compares variable IDs to detect when variable table changes affect blocks. - -```typescript -const variable = variables.selected -if (!variable) { - setWrongVariable(true) - return -} - -if ((node.data as BasicNodeData).variable.id === variable.id) { // Line 479 - ID comparison - if ((node.data as BasicNodeData).variable.name !== variable.name) { - updateNode({ - editorName: editor.meta.name, - rungId: rung.id, - nodeId: node.id, - node: { - ...node, - data: { - ...node.data, - variable, - }, - }, - }) - setWrongVariable(false) - return - } -} -``` - -**Usage Type:** Linking -**Classification:** Synchronization -**Notes:** -- Detects when a variable's name changes but ID remains the same -- Updates block to reflect new variable name -- Relies on ID stability across renames - ---- - -## 5. Variable Component Connection - -### 5.1 Variable Node Update on Table Changes -**File:** `src/renderer/components/_atoms/graphical-editor/ladder/variable.tsx` -**Lines:** 143-213 - -**Description:** Monitors variable table changes and updates variable nodes using ID comparison. - -```typescript -useEffect(() => { - const { - node: variableNode, - rung, - variables, - } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, { - nodeId: id, - variableName: variableValue, - }) - if (!rung || !variableNode) return - - const variable = variables.selected - if (!variable || !inputVariableRef) { - setIsAVariable(false) - } else { - // if the variable is not the same as the one in the node, update the node - if (variable.id !== (variableNode as VariableNode).data.variable.id) { // Line 159 - ID comparison - updateNode({...}) - updateRelatedNode(rung, variableNode as VariableNode, variable) - } - - // if the variable is the same as the one in the node, update the node - if ( - variable.id === (variableNode as VariableNode).data.variable.id && // Line 177 - ID comparison - variable.name !== (variableNode as VariableNode).data.variable.name - ) { - updateNode({...}) - updateRelatedNode(rung, variableNode as VariableNode, variable) - } - // ... validation logic ... - } -}, [pous]) -``` - -**Usage Type:** Linking -**Classification:** Synchronization -**Notes:** -- Uses ID to detect when variable has changed -- Handles both variable replacement and variable rename scenarios -- Updates both the variable node and related block nodes - -### 5.2 Update Related Block Node -**File:** `src/renderer/components/_atoms/graphical-editor/ladder/variable.tsx` -**Lines:** 95-128 - -**Description:** Updates block's `connectedVariables` array when variable changes. - -```typescript -const updateRelatedNode = (rung: RungLadderState, variableNode: VariableNode, variable: PLCVariable) => { - const relatedBlock = rung.nodes.find((node) => node.id === variableNode.data.block.id) - if (!relatedBlock) { - setInputError(true) - return - } - - const connectedVariables: LadderBlockConnectedVariables = [ - ...(relatedBlock.data as BlockNodeData).connectedVariables.filter( - (v) => v.type !== variableNode.data.variant || v.handleId !== variableNode.data.block.handleId, - ), - { - handleId: variableNode.data.block.handleId, - handleTableId: (relatedBlock.data as BlockNodeData).variant.variables.find( - (v) => v.name === variableNode.data.block.handleId, - )?.id, // Line 108-110 - Populates handleTableId - type: variableNode.data.variant, - variable, - }, - ] - - updateNode({...}) -} -``` - -**Usage Type:** Linking -**Classification:** Connection Management -**Notes:** -- Maintains `handleTableId` when updating connections -- Ensures block knows which variable is connected to which handle - ---- - -## 6. Variable Renaming Logic - -### 6.1 Rename with Broken Link Creation -**File:** `src/renderer/components/_molecules/variables-table/editable-cell.tsx` -**Lines:** 124-265 (EditableNameCell onBlur) - -**Description:** When user declines to propagate rename, creates broken links with synthetic IDs. - -```typescript -const onBlur = async () => { - // ... validation ... - - /* 1 ▸ which blocks use the variable? */ - const nodesUsingVarLadder = findNodesUsingVariable(ladderFlows, oldName) - const nodesUsingVarFbd = findNodesUsingVariableFbd(fbdFlows, oldName) - - let shouldPropagate = true - if (nodesUsingVarLadder.length || nodesUsingVarFbd.length) { - shouldPropagate = await askRenameBlocks() - } - - /* 2 ▸ IF NOT propagating, break the link before renaming */ - if (nodesUsingVarLadder.length && !shouldPropagate && language === 'ld') { - addSnapshot(editor.meta.name) - - nodesUsingVarLadder.forEach(({ rungId, nodeId }) => { - const { rung, node } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, { nodeId }) - if (!rung || !node) return - - const variableClone = { - ...(node.data as { variable: PLCVariable }).variable, - id: `broken-${nodeId}`, // Line 159 - Creates synthetic broken ID - name: oldName, - } - - updateNode({ - editorName: editor.meta.name, - rungId, - nodeId, - node: { - ...node, - data: { - ...node.data, - variable: variableClone, - wrongVariable: true, - }, - }, - }) - }) - } - // ... similar logic for FBD ... -} -``` - -**Usage Type:** Linking -**Classification:** Broken Link Management -**Notes:** -- **Synthetic ID pattern:** `broken-${nodeId}` indicates a deliberately broken link -- Preserves old variable name in the node -- Sets `wrongVariable: true` flag for visual indication -- Same pattern used for FBD (lines 179-208) - -### 6.2 Name-Based Variable Finding -**File:** `src/renderer/components/_molecules/variables-table/editable-cell.tsx` -**Lines:** 90-106, 108-122 - -**Description:** Finds nodes using variables by **name** (case-insensitive), not ID. - -```typescript -const findNodesUsingVariable = (ladderFlows: LadderFlowState['ladderFlows'], varName: string) => { - const lower = varName.toLowerCase() - const matches: { rungId: string; nodeId: string }[] = [] - - ladderFlows.forEach((flow) => - flow.rungs.forEach((rung) => - rung.nodes.forEach((node) => { - const data = (node.data as { variable?: PLCVariable | { name?: string } }).variable - if (data?.name?.toLowerCase() === lower) { // Line 98 - Name-based comparison - matches.push({ rungId: rung.id, nodeId: node.id }) - } - }), - ), - ) - - return matches -} -``` - -**Usage Type:** Lookup -**Classification:** Name-Based Search -**Notes:** -- **Critical finding:** Variable rename detection uses NAME matching, not ID matching -- Case-insensitive comparison -- This suggests the system already has name-based lookup infrastructure - -### 6.3 Propagate Rename to Blocks -**File:** `src/renderer/components/_molecules/variables-table/editable-cell.tsx` -**Lines:** 220-239 - -**Description:** When user accepts propagation, updates variable name in all nodes. - -```typescript -/* 4 ▸ If the user said YES, propagate the change to the blocks */ -if (nodesUsingVarLadder.length && shouldPropagate && language === 'ld') { - nodesUsingVarLadder.forEach(({ rungId, nodeId }) => { - const { rung, node } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, { nodeId }) - if (!rung || !node) return - - updateNode({ - editorName: editor.meta.name, - rungId, - nodeId, - node: { - ...node, - data: { - ...node.data, - variable: { ...(node.data as { variable: PLCVariable }).variable, name: newName }, // Line 233 - Updates name - wrongVariable: false, - }, - }, - }) - }) -} -``` - -**Usage Type:** Linking -**Classification:** Synchronization -**Notes:** -- Updates only the name field, preserves other variable properties including ID -- Clears `wrongVariable` flag - ---- - -## 7. XML Serialization - -### 7.1 Ladder XML Generation - Contacts -**File:** `src/utils/PLC/xml-generator/codesys/language/ladder-xml.ts` -**Lines:** 337-383 - -**Description:** XML generation uses variable **names**, not IDs. - -```typescript -const contactToXML = ( - contact: ContactNode, - rung: RungLadderState, - offsetY: number = 0, - leftRailId: string, -): ContactLadderXML => { - // ... connection logic ... - return { - '@localId': contact.data.numericId, - '@negated': contact.data.variant === 'negated', - // ... other properties ... - variable: contact.data.variable && contact.data.variable.name !== '' ? contact.data.variable.name : 'A', // Line 381 - Uses NAME - } -} -``` - -**Usage Type:** Serialization -**Classification:** XML Export -**Notes:** -- **Critical finding:** XML output uses variable names, not IDs -- IDs are never serialized to XML -- This validates that name-based linking is architecturally sound for compilation - -### 7.2 Ladder XML Generation - Coils -**File:** `src/utils/PLC/xml-generator/codesys/language/ladder-xml.ts` -**Lines:** 385-427 - -**Description:** Coil XML generation also uses variable names. - -```typescript -const coilToXml = (coil: CoilNode, rung: RungLadderState, offsetY: number = 0, leftRailId: string): CoilLadderXML => { - // ... connection logic ... - return { - '@localId': coil.data.numericId, - // ... other properties ... - variable: coil.data.variable && coil.data.variable.name !== '' ? coil.data.variable.name : 'A', // Line 425 - Uses NAME - } -} -``` - -**Usage Type:** Serialization -**Classification:** XML Export - -### 7.3 Ladder XML Generation - Blocks -**File:** `src/utils/PLC/xml-generator/codesys/language/ladder-xml.ts` -**Lines:** 429-524 - -**Description:** Block XML generation uses variable names for input/output variables. - -```typescript -const blockToXml = ( - block: BlockNode, - rung: RungLadderState, - offsetY: number = 0, - leftRailId: string, -): BlockLadderXML => { - // ... connection logic ... - - const inputVariables = block.data.variant.variables - .filter((v) => v.class === 'input' || v.class === 'inOut') - .map((variable, index) => { - const variableNode = rung.nodes.find( - (node) => - node.type === 'variable' && - (node as VariableNode).data.variant === 'input' && - (node as VariableNode).data.block.handleId === variable.name, - ) as VariableNode | undefined - - return { - '@formalParameter': variable.name, // Line 476 - Uses variable NAME from block definition - '#text': variableNode?.data.variable.name ?? '', // Line 477 - Uses connected variable NAME - } - }) - - const outputVariable = block.data.variant.variables - .filter((v) => v.class === 'output') - .map((variable) => { - // ... similar pattern ... - return { - '@formalParameter': variable.name, // Line 504 - Uses NAME - '#text': variableNode?.data.variable.name ?? '', // Line 505 - Uses NAME - } - }) - - return { - '@localId': block.data.numericId, - '@typeName': block.data.variant.name, - '@instanceName': block.data.variable && 'name' in block.data.variable ? block.data.variable.name : '', // Line 508 - Uses NAME - // ... other properties ... - inputVariables, - outputVariables: outputVariable, - } -} -``` - -**Usage Type:** Serialization -**Classification:** XML Export -**Notes:** -- Block instance name uses variable name -- Input/output variable connections use names -- No IDs in XML output - ---- - -## 8. Project Persistence - -### 8.1 Project Save -**File:** `src/main/services/project-service/index.ts` -**Lines:** 307-310 (approximate, based on onboarding guide) - -**Description:** Projects are saved as JSON with variable IDs serialized. - -**Usage Type:** Persistence -**Classification:** File I/O -**Notes:** -- Variables with IDs will have those IDs persisted to JSON -- Variables without IDs will be saved without ID field (since it's optional) -- No special handling or transformation of IDs during save - -### 8.2 Project Load -**File:** `src/main/services/project-service/index.ts` - -**Description:** Projects are loaded from JSON, IDs are preserved if present. - -**Usage Type:** Persistence -**Classification:** File I/O -**Notes:** -- Existing projects may have variables with or without IDs -- Schema validation allows both cases (ID is optional) -- No migration or ID generation on load - ---- - -## 9. Additional ID Usage Patterns - -### 9.1 Variable Search in Graphical Editors -**File:** `src/renderer/components/_atoms/graphical-editor/ladder/utils/utils.ts` -**Estimated lines:** Multiple locations (4 occurrences of `variable.id`) - -**Description:** Utility functions for finding variables in ladder diagrams. - -**Usage Type:** Lookup -**Classification:** Utility Function -**Notes:** -- Used for variable selection and highlighting -- Likely uses ID for quick lookups - -### 9.2 Autocomplete Variable Selection -**File:** `src/renderer/components/_atoms/graphical-editor/autocomplete/index.tsx` -**Estimated lines:** Multiple locations (2 occurrences of `variable.id`) - -**Description:** Autocomplete component uses IDs for variable selection. - -**Usage Type:** Lookup -**Classification:** UI Component -**Notes:** -- Dropdown selection likely uses ID as key -- Falls back to name if ID not present - -### 9.3 FBD Editor Usage -**Files:** -- `src/renderer/components/_atoms/graphical-editor/fbd/block.tsx` (1 occurrence) -- `src/renderer/components/_atoms/graphical-editor/fbd/variable.tsx` (2 occurrences) -- `src/renderer/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx` (3 occurrences) -- `src/renderer/components/_atoms/graphical-editor/fbd/utils/utils.ts` (4 occurrences) - -**Description:** FBD editor has similar ID usage patterns as Ladder editor. - -**Usage Type:** Linking, Lookup -**Classification:** Graphical Editor -**Notes:** -- Same patterns as Ladder: block-variable linking, autocomplete, synchronization -- Should be migrated in parallel with Ladder editor - -### 9.4 Variables Editor -**File:** `src/renderer/components/_organisms/variables-editor/index.tsx` -**Estimated lines:** 1 occurrence of `variable.id` - -**Description:** Variables table editor uses IDs for row identification. - -**Usage Type:** Lookup -**Classification:** UI Component -**Notes:** -- Likely uses ID as React key for table rows -- May need to switch to using row index or composite key - ---- - -## 10. Summary of ID Usage by Category - -### 10.1 By Usage Type - -| Usage Type | Count | Critical? | Notes | -|------------|-------|-----------|-------| -| Schema Definition | 2 | No | Optional field, no constraints | -| Creation/Generation | 8+ | No | Conditional, only if not present | -| Lookup | 15+ | **Yes** | Dual system with row index fallback | -| Linking | 10+ | **Yes** | Block-variable connections, divergence | -| Synchronization | 5+ | **Yes** | Variable table changes → diagram updates | -| Serialization | 0 | No | XML uses names, not IDs | -| Persistence | 2 | No | Pass-through, no special handling | - -### 10.2 By File Category - -| Category | File Count | ID Usage Intensity | -|----------|------------|-------------------| -| Type Definitions | 1 | Low (schema only) | -| State Management | 2 | High (CRUD operations) | -| Graphical Editors (Ladder) | 8+ | **Very High** (linking, sync) | -| Graphical Editors (FBD) | 5+ | **Very High** (linking, sync) | -| UI Components | 5+ | Medium (display, selection) | -| Utilities | 3+ | Medium (lookup helpers) | -| XML Generation | 1 | **None** (uses names) | -| Services | 1 | Low (pass-through) | - -### 10.3 Critical Migration Areas - -**High Priority (Core Functionality):** -1. **Block-variable linking** (`handleTableId` mechanism) -2. **Variable lookup utilities** (dual row/ID system) -3. **Divergence detection** (library update handling) -4. **Variable synchronization** (table ↔ diagram) - -**Medium Priority (User Experience):** -5. **Autocomplete** (variable selection) -6. **Variable renaming** (broken link creation) -7. **Variables table** (row identification) - -**Low Priority (Already Name-Based):** -8. **XML generation** (already uses names) -9. **Project persistence** (pass-through) - ---- - -## 11. Fallback Mechanisms Already in Place - -### 11.1 Row Index Priority -The `getVariableBasedOnRowIdOrVariableId` function prioritizes row index over ID, suggesting the system was designed to work primarily with positional references. - -### 11.2 Optional ID Field -The schema defines ID as optional, indicating the system was architected to potentially work without IDs. - -### 11.3 Name-Based Rename Detection -Variable rename detection uses name matching (case-insensitive), not ID matching, showing existing name-based infrastructure. - -### 11.4 XML Uses Names -The fact that XML generation uses names exclusively validates that name-based linking is architecturally sound for the compilation pipeline. - ---- - -## 12. Recommendations for Phase 2 - -Based on this audit, the following areas should be prioritized in Phase 2 (Implementation): - -1. **Replace `handleTableId` with name+type composite key** in block-variable linking -2. **Refactor `getVariableBasedOnRowIdOrVariableId`** to use name+type lookup -3. **Update divergence detection** to match variables by name+type instead of ID -4. **Modify synchronization logic** to use name+type comparison -5. **Update autocomplete** to use name+type for variable selection -6. **Revise broken link creation** to use name+type instead of synthetic IDs -7. **Add migration logic** for existing projects with IDs - ---- - -## Appendix A: Files with Variable ID Usage - -Complete list of files containing `variable.id` references (20 files total): - -1. `src/types/PLC/open-plc.ts` (schema definitions) -2. `src/renderer/store/slices/project/slice.ts` (CRUD operations) -3. `src/renderer/store/slices/project/utils/index.ts` (lookup utility) -4. `src/renderer/components/_atoms/graphical-editor/ladder/block.tsx` (linking) -5. `src/renderer/components/_atoms/graphical-editor/ladder/variable.tsx` (synchronization) -6. `src/renderer/components/_atoms/graphical-editor/ladder/contact.tsx` (linking) -7. `src/renderer/components/_atoms/graphical-editor/ladder/coil.tsx` (linking) -8. `src/renderer/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx` (creation) -9. `src/renderer/components/_atoms/graphical-editor/ladder/utils/utils.ts` (utilities) -10. `src/renderer/components/_atoms/graphical-editor/fbd/block.tsx` (linking) -11. `src/renderer/components/_atoms/graphical-editor/fbd/variable.tsx` (synchronization) -12. `src/renderer/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx` (creation) -13. `src/renderer/components/_atoms/graphical-editor/fbd/utils/utils.ts` (utilities) -14. `src/renderer/components/_atoms/graphical-editor/autocomplete/index.tsx` (UI) -15. `src/renderer/components/_molecules/variables-table/editable-cell.tsx` (renaming) -16. `src/renderer/components/_molecules/graphical-editor/ladder/rung/body.tsx` (diagram) -17. `src/renderer/components/_molecules/graphical-editor/fbd/index.tsx` (diagram) -18. `src/renderer/components/_organisms/variables-editor/index.tsx` (table) -19. `src/renderer/components/_organisms/workspace-activity-bar/ladder-toolbox.tsx` (UI) -20. `src/renderer/components/_organisms/modals/delete-confirmation-modal.tsx` (UI) - ---- - -## Appendix B: Files with UUID Generation - -Complete list of files containing `crypto.randomUUID()` or `uuidv4()` (12 files total): - -1. `src/renderer/store/slices/project/slice.ts` -2. `src/renderer/store/slices/fbd/utils/index.ts` -3. `src/renderer/store/slices/ladder/utils/index.ts` -4. `src/renderer/components/_atoms/graphical-editor/ladder/block.tsx` -5. `src/renderer/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx` -6. `src/renderer/components/_atoms/graphical-editor/fbd/block.tsx` -7. `src/renderer/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx` -8. `src/renderer/components/_organisms/workspace-activity-bar/default.tsx` -9. `src/renderer/components/_features/[workspace]/editor/monaco/index.tsx` -10. `src/renderer/screens/workspace-screen.tsx` -11. `src/utils/python/addPythonLocalVariables.ts` -12. `src/utils/cpp/addCppLocalVariables.ts` - ---- - -**Document Version:** 1.0 -**Date:** 2025-10-22 -**Author:** Devin (AI Assistant) -**Status:** Complete diff --git a/docs/ports/accelerator-port.ts b/docs/ports/accelerator-port.ts deleted file mode 100644 index 650e25cfe..000000000 --- a/docs/ports/accelerator-port.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * AcceleratorPort — Abstracts keyboard shortcuts and application-level actions. - * - * Editor adapter: Registers IPC event listeners for Electron menu accelerators. - * Each accelerator fires an IPC event from the main process menu. - * Web adapter: Registers browser keyboard event listeners (e.g., Ctrl+S, Ctrl+Z). - * Shortcuts handled via useSaveShortcut, useUndoRedoShortcut hooks. - * - * This port provides a uniform way for the shared UI to subscribe to - * application-level actions without knowing the platform mechanism. - * - * ## Editor IPC methods replaced: - * - window.bridge.createProjectAccelerator() - * - window.bridge.handleOpenProjectRequest() - * - window.bridge.openRecentAccelerator() - * - window.bridge.saveProjectAccelerator() - * - window.bridge.saveFileAccelerator() - * - window.bridge.closeProjectAccelerator() - * - window.bridge.closeTabAccelerator() - * - window.bridge.deleteFileAccelerator() - * - window.bridge.exportProjectRequest() - * - window.bridge.findInProjectAccelerator() - * - window.bridge.handleUndoRequest() - * - window.bridge.handleRedoRequest() - * - window.bridge.switchPerspective() - * - window.bridge.aboutAccelerator() - * - window.bridge.aboutModalAccelerator() - * - All corresponding remove*Listener() methods - * - * ## Web equivalents: - * - useSaveShortcut hook (Ctrl+S) - * - useUndoRedoShortcut hook (Ctrl+Z, Ctrl+Shift+Z) - * - Browser keyboard event listeners - */ - -import type { Unsubscribe } from './types' - -export interface AcceleratorPort { - // --- Project actions --- - onCreateProject(callback: () => void): Unsubscribe - onOpenProject(callback: () => void): Unsubscribe - onOpenRecent(callback: () => void): Unsubscribe - onSaveProject(callback: () => void): Unsubscribe - onSaveFile(callback: () => void): Unsubscribe - onCloseProject(callback: () => void): Unsubscribe - onExportProject(callback: () => void): Unsubscribe - - // --- Editor actions --- - onCloseTab(callback: () => void): Unsubscribe - onDeleteFile(callback: () => void): Unsubscribe - onFindInProject(callback: () => void): Unsubscribe - onUndo(callback: () => void): Unsubscribe - onRedo(callback: () => void): Unsubscribe - - // --- View actions --- - onSwitchPerspective(callback: () => void): Unsubscribe - onAbout(callback: () => void): Unsubscribe -} diff --git a/docs/ports/compiler-port.ts b/docs/ports/compiler-port.ts deleted file mode 100644 index 09f1c816c..000000000 --- a/docs/ports/compiler-port.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * CompilerPort — Abstracts the PLC compilation pipeline. - * - * Editor adapter: Delegates to main process via IPC (local binaries: xml2st, iec2c, arduino-cli). - * Web adapter: Delegates to remote API at compile.getedge.me (callGenerateSt, callCompileSt, etc.). - * - * The UI only knows "compile this project" and receives progress events. - * The adapter decides whether to call local binaries or remote HTTP endpoints. - * - * ## Editor IPC methods replaced: - * - window.bridge.runCompileProgram() - * - window.bridge.runDebugCompilation() - * - window.bridge.exportProjectXml() - * - window.bridge.createBuildDirectory() - * - window.bridge.createXmlFileToBuild() - * - window.bridge.compileRequest() - * - window.bridge.generateCFilesRequest() - * - window.bridge.setupCompilerEnvironment() - * - * ## Web service methods replaced: - * - callGenerateSt() - * - callCompileSt() - * - callGenerateDebug() - * - callGenerateGlueVars() - * - callCompileArduino() - */ - -import type { - CompileProgressEvent, - CompileResult, - DebugCompileResult, - PLCProjectData, - Result, - Unsubscribe, -} from './types' - -export interface CompileProgramArgs { - projectData: PLCProjectData - boardTarget: string - projectPath: string - communicationPort?: string - compileOnly?: boolean -} - -export interface DebugCompileArgs { - projectData: PLCProjectData - boardTarget: string - projectPath: string -} - -export interface ExportXmlArgs { - projectData: PLCProjectData - projectPath: string - format: 'old-editor' | 'codesys' -} - -export interface CompilerPort { - /** - * Run the full compilation pipeline (XML -> ST -> C -> binary). - * Emits progress events for UI feedback. - */ - compileProgram( - args: CompileProgramArgs, - onProgress: (event: CompileProgressEvent) => void, - ): Promise - - /** - * Run a debug-mode compilation that produces debug symbols. - * Used before connecting the debugger to verify program integrity. - */ - compileForDebug( - args: DebugCompileArgs, - onProgress: (event: CompileProgressEvent) => void, - ): Promise - - /** - * Export the project as IEC 61131-3 XML. - * Editor: writes XML file to disk. - * Web: returns XML string (or triggers download). - */ - exportProjectXml(args: ExportXmlArgs): Promise> - - /** - * Subscribe to compilation output logs (stdout/stderr streaming). - * Returns unsubscribe function. - */ - onCompileOutput?(callback: (line: string) => void): Unsubscribe -} diff --git a/docs/ports/debugger-port.ts b/docs/ports/debugger-port.ts deleted file mode 100644 index cccf85eae..000000000 --- a/docs/ports/debugger-port.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * DebuggerPort — Abstracts the debug protocol for reading/writing PLC variables. - * - * Editor adapter: Routes through IPC to main process Modbus clients - * (TCP, RTU, WebSocket, or simulator virtual serial port). - * Web adapter: Routes through DebugBridge singleton which delegates to - * WebRTCTransport, ModbusRtuTransport (simulator), or HTTP fallback. - * - * The underlying protocol is always Modbus PDU with custom function codes - * (0x41-0x45) for debug operations. The port hides transport specifics. - * - * ## Editor IPC methods replaced: - * - window.bridge.debuggerConnect() - * - window.bridge.debuggerDisconnect() - * - window.bridge.debuggerGetVariablesList() - * - window.bridge.debuggerSetVariable() - * - window.bridge.debuggerVerifyMd5() - * - window.bridge.debuggerReadProgramStMd5() - * - window.bridge.readDebugFile() - * - * ## Web service methods replaced: - * - debugBridge.connect() - * - debugBridge.disconnect() - * - debugBridge.getVariablesList() - * - debugBridge.setVariable() - * - debugBridge.verifyMd5() - * - debugBridge.onStopped() - * - DebugTransport interface implementations - */ - -import type { - DebugSetResult, - DebugVariableResult, - Md5VerifyResult, - Unsubscribe, -} from './types' - -export interface DebuggerPort { - /** - * Connect to a debug target. - * The adapter resolves the actual transport (TCP, RTU, WebSocket, WebRTC, simulator) - * based on the current device configuration and platform capabilities. - */ - connect(): Promise<{ success: boolean; error?: string }> - - /** Disconnect from the current debug target. */ - disconnect(): Promise<{ success: boolean }> - - /** - * Read variable values by their debug indexes (batched). - * Returns tick count, last index processed, and raw data array. - * `needsReconnect` signals the UI to re-establish the connection. - */ - getVariablesList(indexes: number[]): Promise - - /** - * Write or force a variable value. - * @param index — Variable debug index - * @param force — If true, the value is forced (overrides PLC logic) - * @param valueBuffer — Raw value bytes (Uint8Array) - */ - setVariable(index: number, force: boolean, valueBuffer?: Uint8Array): Promise - - /** - * Verify that the running program matches the expected MD5 hash. - * Used to detect program mismatch before starting a debug session. - */ - verifyMd5(expectedMd5: string): Promise - - /** - * Read the MD5 hash of the compiled ST program from the debug artifacts. - * Editor: reads from disk via IPC. - * Web: reads from compiler API response or cached artifacts. - */ - readProgramMd5(projectPath: string, boardTarget: string): Promise<{ success: boolean; md5?: string; error?: string }> - - /** - * Read the debug file content (variable mapping produced by compiler). - * Editor: reads .dbg file from disk. - * Web: reads from compiler API response. - */ - readDebugFile(projectPath: string, boardTarget: string): Promise<{ success: boolean; content?: string; error?: string }> - - /** - * Subscribe to debugger disconnection events (e.g., simulator stopped, connection lost). - * Returns unsubscribe function. - */ - onDisconnected(callback: () => void): Unsubscribe - - /** Check if the debugger is currently connected. */ - isConnected(): boolean -} diff --git a/docs/ports/device-port.ts b/docs/ports/device-port.ts deleted file mode 100644 index 4ceb9e0b6..000000000 --- a/docs/ports/device-port.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * DevicePort — Abstracts hardware/board discovery and configuration. - * - * Editor adapter: Delegates to main process which reads HAL JSON files from - * resources/bin/ and queries arduino-cli for installed cores. - * Board preview images loaded from local filesystem. - * Web adapter: Reads board definitions from bundled hals.json asset. - * Orchestrator/agent list fetched from backend API. - * Board preview images loaded from bundled assets. - * - * ## Editor IPC methods replaced: - * - window.bridge.getAvailableBoards() - * - window.bridge.getAvailableCommunicationPorts() - * - window.bridge.refreshAvailableBoards() - * - window.bridge.refreshCommunicationPorts() - * - window.bridge.getPreviewImage() - * - * ## Web service methods replaced: - * - Board data from bundled hals.json - * - Orchestrator list from orchestrator API - * - getDeviceStatus() - */ - -import type { BoardInfo, CommunicationPort } from './types' - -export interface DevicePort { - /** - * Get all available boards with their hardware specs and pin configurations. - * Editor: reads from local HAL files + arduino-cli board list. - * Web: reads from bundled hals.json + orchestrator device list. - */ - getAvailableBoards(): Promise> - - /** - * Get available serial/communication ports. - * Editor: queries local system serial ports. - * Web: may not be applicable locally (ports come from remote device). - */ - getCommunicationPorts(): Promise - - /** - * Refresh the board list (e.g., after installing a new arduino core). - * Editor: re-scans arduino-cli and HAL files. - * Web: re-fetches from backend/orchestrator. - */ - refreshBoards(): Promise> - - /** - * Refresh communication ports list. - * Editor: re-scans local system serial ports. - * Web: re-queries orchestrator for available ports. - */ - refreshCommunicationPorts(): Promise - - /** - * Get a board preview image for display in the UI. - * Editor: loads image from local resources/ directory, returns base64 or file path. - * Web: returns URL to bundled image asset. - */ - getPreviewImage(imageName: string): Promise -} diff --git a/docs/ports/index.ts b/docs/ports/index.ts deleted file mode 100644 index d8089765a..000000000 --- a/docs/ports/index.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Port Interfaces — Contracts that decouple the shared UI from platform-specific implementations. - * - * Architecture: - * - * Shared UI (React components, hooks, store) - * │ - * │ depends on - * ▼ - * Port Interfaces (this package) - * │ - * │ implemented by - * ▼ - * ┌─────────────────┬──────────────────┐ - * │ Editor Adapters │ Web Adapters │ - * │ (Electron IPC) │ (HTTP/WebRTC) │ - * └─────────────────┴──────────────────┘ - * - * Usage: - * The PlatformProvider React context (see platform-provider.ts) supplies - * concrete port implementations to the component tree. Components access - * ports via usePlatform() hook: - * - * const { compiler, runtime, debugger } = usePlatform() - * await compiler.compileProgram(args, onProgress) - * - * 10 Port Interfaces: - * 1. CompilerPort — PLC compilation pipeline - * 2. RuntimePort — Remote PLC runtime communication - * 3. DebuggerPort — Debug protocol (variable read/write) - * 4. SimulatorPort — Built-in AVR simulator - * 5. ProjectPort — Project lifecycle (create, open, save, POU management) - * 6. DevicePort — Board/hardware discovery - * 7. SystemPort — Platform services (store, links, logging) - * 8. WindowPort — Native window management - * 9. AcceleratorPort — Keyboard shortcuts - * 10. ThemePort — Theme detection and switching - * - * Plus: - * - PlatformCapabilities — Feature toggle flags - * - Shared domain types — PLCVariable, BoardInfo, TimingStats, etc. - */ - -// --- Port interfaces --- -export type { CompilerPort } from './compiler-port' -export type { RuntimePort } from './runtime-port' -export type { DebuggerPort } from './debugger-port' -export type { SimulatorPort } from './simulator-port' -export type { ProjectPort } from './project-port' -export type { DevicePort } from './device-port' -export type { SystemPort } from './system-port' -export type { WindowPort } from './window-port' -export type { AcceleratorPort } from './accelerator-port' -export type { ThemePort } from './theme-port' - -// --- Feature toggles --- -export type { PlatformCapabilities } from './platform-capabilities' -export { EDITOR_CAPABILITIES, WEB_CAPABILITIES } from './platform-capabilities' - -// --- Shared domain types --- -export type { - // Result wrappers - Result, - Unsubscribe, - // PLC - PLCLanguage, - PLCExtendedLanguage, - PouType, - VariableClass, - PLCVariable, - PLCVariableType, - PLCTask, - PLCInstance, - PLCDataType, - PLCBody, - PLCPou, - PLCProjectData, - ProjectMeta, - // Device - CompilerType, - BoardInfo, - CommunicationPort, - SerialPort, - PinType, - DevicePin, - DeviceConfiguration, - ModbusRTUConfig, - ModbusTCPConfig, - // Runtime - PlcStatus, - TimingStats, - RuntimeLogLevel, - RuntimeLogEntry, - // Debugger - DebugVariableResult, - DebugSetResult, - Md5VerifyResult, - // Compiler - CompileProgressEvent, - CompileResult, - DebugCompileResult, - // Console - LogObject, - // System - Platform, - Architecture, - SystemInfo, - RecentProject, -} from './types' - -// --- Port parameter/result types --- -export type { CompileProgramArgs, DebugCompileArgs, ExportXmlArgs } from './compiler-port' -export type { - LoginParams, - LoginResult, - CreateUserParams, - UsersInfoResult, - RuntimeStatusResult, - CompilationStatusResult, - RuntimeLogsResult, -} from './runtime-port' -export type { - CreateProjectParams, - ProjectResponse, - SaveProjectParams, - CreatePouParams, - RenamePouParams, -} from './project-port' -export type { ThemeVariant } from './theme-port' diff --git a/docs/ports/platform-capabilities.ts b/docs/ports/platform-capabilities.ts deleted file mode 100644 index 46eba23a0..000000000 --- a/docs/ports/platform-capabilities.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * PlatformCapabilities — Feature toggles for platform-specific UI behavior. - * - * The shared UI uses these flags to conditionally render or enable features - * that differ between the Electron editor and the web application. - * This replaces branching on platform detection and keeps the UI code clean. - * - * Each adapter provides its own PlatformCapabilities instance: - * - Editor: native window controls, local filesystem, no auth, etc. - * - Web: browser-based, cloud auth, WebRTC, orchestrator management, etc. - */ - -export interface PlatformCapabilities { - // --- Window & Chrome --- - - /** True if the app has native window controls (minimize, maximize, close buttons in title bar). */ - hasNativeWindowControls: boolean - - /** True if the app has a native application menu (Electron menu bar). */ - hasNativeMenu: boolean - - /** True if the app supports native file dialogs (open, save, pick directory). */ - hasNativeFileDialogs: boolean - - // --- Authentication --- - - /** True if the app requires user authentication to access the workspace. */ - hasAuthentication: boolean - - // --- Device & Hardware --- - - /** True if the app can detect local serial/communication ports. */ - hasLocalSerialPorts: boolean - - /** True if the app supports orchestrator-managed devices (cloud device fleet). */ - hasOrchestratorDevices: boolean - - /** True if the app supports WebRTC connections to runtime devices. */ - hasWebRTC: boolean - - // --- Simulator --- - - /** True if the simulator runs in the same process (web: in-browser, editor: main process). */ - hasInProcessSimulator: boolean - - // --- Project Management --- - - /** True if the app supports a local filesystem project structure (directories, files). */ - hasLocalFilesystem: boolean - - /** True if the app supports exporting projects as XML files (Codesys, old-editor formats). */ - hasProjectExport: boolean - - /** True if the app supports the "About" dialog. */ - hasAboutDialog: boolean - - // --- Editor Features --- - - /** True if the app has a Python LSP (language server protocol) for code completion. */ - hasPythonLSP: boolean - - /** True if the app supports undo/redo history tracking. */ - hasUndoRedoHistory: boolean - - /** True if the app can watch files for external changes. */ - hasFileWatcher: boolean - - // --- AI Features --- - - /** True if the app has AI-assisted coding (inline completions, chat panel, telemetry). */ - hasAIAssistant: boolean - - // --- Runtime Connection --- - - /** True if the runtime connection goes through an orchestrator/agent proxy. */ - hasProxiedRuntimeConnection: boolean - - /** - * True if the app can upload compiled programs directly to the runtime. - * Web: uploads zip via API. Editor: runtime compiles from uploaded source. - */ - hasDirectProgramUpload: boolean -} - -// --------------------------------------------------------------------------- -// Default capability profiles -// --------------------------------------------------------------------------- - -export const EDITOR_CAPABILITIES: PlatformCapabilities = { - hasNativeWindowControls: true, - hasNativeMenu: true, - hasNativeFileDialogs: true, - hasAuthentication: false, - hasLocalSerialPorts: true, - hasOrchestratorDevices: false, - hasWebRTC: false, - hasInProcessSimulator: true, - hasLocalFilesystem: true, - hasProjectExport: true, - hasAboutDialog: true, - hasPythonLSP: true, - hasUndoRedoHistory: true, - hasFileWatcher: true, - hasAIAssistant: false, - hasProxiedRuntimeConnection: false, - hasDirectProgramUpload: false, -} - -export const WEB_CAPABILITIES: PlatformCapabilities = { - hasNativeWindowControls: false, - hasNativeMenu: false, - hasNativeFileDialogs: false, - hasAuthentication: true, - hasLocalSerialPorts: false, - hasOrchestratorDevices: true, - hasWebRTC: true, - hasInProcessSimulator: true, - hasLocalFilesystem: false, - hasProjectExport: false, - hasAboutDialog: false, - hasPythonLSP: false, - hasUndoRedoHistory: false, - hasFileWatcher: false, - hasAIAssistant: true, - hasProxiedRuntimeConnection: true, - hasDirectProgramUpload: true, -} diff --git a/docs/ports/project-port.ts b/docs/ports/project-port.ts deleted file mode 100644 index 64585857c..000000000 --- a/docs/ports/project-port.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * ProjectPort — Abstracts project lifecycle operations (create, open, save, POU management). - * - * Editor adapter: Delegates to main process project-service and pou-service via IPC. - * Files stored on local filesystem. Recent projects tracked in electron-store. - * Web adapter: Delegates to project-api.ts and in-memory state. - * Projects stored on backend API. Recent projects tracked in cookies/localStorage. - * - * ## Editor IPC methods replaced: - * - window.bridge.createProject() - * - window.bridge.openProject() - * - window.bridge.openProjectByPath() - * - window.bridge.saveProject() - * - window.bridge.saveFile() - * - window.bridge.createPouFile() - * - window.bridge.deletePouFile() - * - window.bridge.renamePouFile() - * - window.bridge.pathPicker() - * - window.bridge.retrieveRecent() - * - window.bridge.getRecent() - * - window.bridge.fileWatchStart() - * - window.bridge.fileWatchStop() - * - window.bridge.fileWatchStopAll() - * - window.bridge.fileReadContent() - * - window.bridge.onFileExternalChange() - * - * ## Web service methods replaced: - * - fetchProjectFromApi() - * - Project state in Zustand store - */ - -import type { - DeviceConfiguration, - DevicePin, - PLCProjectData, - ProjectMeta, - RecentProject, - Unsubscribe, -} from './types' - -export interface CreateProjectParams { - name: string - type: 'plc-project' | 'plc-library' - path?: string -} - -export interface ProjectResponse { - success: boolean - data?: { - meta: ProjectMeta - projectData: PLCProjectData - deviceConfiguration?: DeviceConfiguration - devicePinMapping?: DevicePin[] - } - error?: { - title: string - description: string - } -} - -export interface SaveProjectParams { - projectPath: string - projectData: PLCProjectData - deviceConfiguration: DeviceConfiguration - devicePinMapping: DevicePin[] -} - -export interface CreatePouParams { - name: string - pouType: PouType - language: string - filePath?: string -} - -export interface RenamePouParams { - filePath: string - newFileName: string - fileContent?: unknown -} - -import type { PouType } from './types' - -export interface ProjectPort { - /** Create a new project. */ - createProject(params: CreateProjectParams): Promise - - /** - * Open a project via platform file picker. - * Editor: shows native file dialog. - * Web: may show file input or project list. - */ - openProject(): Promise - - /** Open a project by its path or identifier. */ - openProjectByPath(projectPath: string): Promise - - /** Save the entire project (all files, configuration, pin mapping). */ - saveProject(params: SaveProjectParams): Promise<{ success: boolean; error?: string }> - - /** - * Save a single file within the project. - * Editor: writes to disk. - * Web: updates in-memory state and/or syncs to backend. - */ - saveFile(filePath: string, content: unknown): Promise<{ success: boolean; error?: string }> - - /** Create a new POU file. */ - createPou(params: CreatePouParams): Promise<{ success: boolean; data?: unknown; error?: string }> - - /** Delete a POU file. */ - deletePou(filePath: string): Promise<{ success: boolean; error?: string }> - - /** Rename a POU file. */ - renamePou(params: RenamePouParams): Promise<{ success: boolean; data?: unknown; error?: string }> - - /** - * Pick a filesystem path (for project location). - * Editor: shows native directory picker dialog. - * Web: may not be applicable (returns pre-configured path). - */ - pickPath(): Promise<{ success: boolean; path?: string; error?: { title: string; description: string } }> - - /** Get list of recently opened projects. */ - getRecentProjects(): Promise - - /** - * Read a file's content by path. - * Editor: reads from local filesystem via IPC. - * Web: reads from in-memory project state or API. - */ - readFileContent(filePath: string): Promise<{ success: boolean; content?: string; error?: string }> - - /** - * Start watching a file for external changes. - * Editor: uses fs.watch via IPC. - * Web: no-op (files don't change externally in browser). - */ - watchFile?(filePath: string): Promise<{ success: boolean; error?: string }> - - /** Stop watching a file. */ - unwatchFile?(filePath: string): Promise<{ success: boolean }> - - /** Stop watching all files. */ - unwatchAll?(): Promise<{ success: boolean }> - - /** - * Subscribe to external file change events. - * Editor: fires when file changes on disk outside the editor. - * Web: not applicable (never fires). - */ - onFileExternalChange?(callback: (filePath: string) => void): Unsubscribe -} diff --git a/docs/ports/runtime-port.ts b/docs/ports/runtime-port.ts deleted file mode 100644 index e6ab3f6f0..000000000 --- a/docs/ports/runtime-port.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * RuntimePort — Abstracts communication with a remote OpenPLC runtime device. - * - * Editor adapter: Proxies HTTP calls through main process IPC - * (window.bridge.runtimeLogin, runtimeGetStatus, etc.). - * Web adapter: Calls runtime-api.ts which routes through orchestrator/agent proxy, - * with WebRTC-first strategy and HTTP fallback. - * - * Connection details (IP address, JWT token, agentId, deviceId) are managed - * internally by each adapter. The UI only passes logical identifiers - * when needed, and the adapter resolves them to transport-specific params. - * - * ## Editor IPC methods replaced: - * - window.bridge.runtimeLogin() - * - window.bridge.runtimeCreateUser() - * - window.bridge.runtimeGetUsersInfo() - * - window.bridge.runtimeGetStatus() - * - window.bridge.runtimeStartPlc() - * - window.bridge.runtimeStopPlc() - * - window.bridge.runtimeGetLogs() - * - window.bridge.runtimeGetSerialPorts() - * - window.bridge.runtimeGetCompilationStatus() - * - window.bridge.runtimeClearCredentials() - * - window.bridge.onRuntimeTokenRefreshed() - * - * ## Web service methods replaced: - * - runtimeLogin() - * - runtimeCreateUser() - * - runtimeGetUsersInfo() - * - runtimeGetStatus() - * - runtimeStartPlc() - * - runtimeStopPlc() - * - runtimeGetLogs() - * - runtimeGetSerialPorts() - * - runtimeGetCompilationStatus() - * - runtimeUploadProgram() - * - runtimeLogout() - */ - -import type { - PlcStatus, - RuntimeLogEntry, - SerialPort, - TimingStats, - Unsubscribe, -} from './types' - -export interface LoginParams { - username: string - password: string -} - -export interface LoginResult { - success: boolean - accessToken?: string - error?: string -} - -export interface CreateUserParams { - username: string - password: string -} - -export interface UsersInfoResult { - hasUsers: boolean - runtimeVersion?: string - error?: string -} - -export interface RuntimeStatusResult { - success: boolean - status?: PlcStatus | string - timingStats?: TimingStats - error?: string -} - -export interface CompilationStatusResult { - success: boolean - data?: { - status: string - logs: string[] - exit_code: number | null - } - error?: string -} - -export interface RuntimeLogsResult { - success: boolean - logs?: string | RuntimeLogEntry[] - error?: string -} - -export interface RuntimePort { - /** Authenticate with the runtime. Returns JWT on success. */ - login(params: LoginParams): Promise - - /** Create a new user on the runtime (first-time setup). */ - createUser(params: CreateUserParams): Promise<{ success: boolean; error?: string }> - - /** Check if the runtime has users and get its version. */ - getUsersInfo(): Promise - - /** Get current PLC runtime status with optional timing statistics. */ - getStatus(includeStats?: boolean): Promise - - /** Start the PLC program on the runtime. */ - startPlc(): Promise<{ success: boolean; error?: string }> - - /** Stop the PLC program on the runtime. */ - stopPlc(): Promise<{ success: boolean; error?: string }> - - /** Get runtime logs, optionally filtered by minimum log ID. */ - getLogs(minId?: number): Promise - - /** Get serial ports available on the runtime device. */ - getSerialPorts(): Promise<{ success: boolean; ports?: SerialPort[]; error?: string }> - - /** Get the status of an ongoing compilation on the runtime. */ - getCompilationStatus(): Promise - - /** Clear stored credentials (logout). */ - clearCredentials(): Promise<{ success: boolean }> - - /** - * Upload a compiled program to the runtime. - * Web adapter: sends zip as base64. - * Editor adapter: sends via file path or streamed content. - */ - uploadProgram?(programData: string | ArrayBuffer): Promise<{ success: boolean; error?: string }> - - /** - * Subscribe to token refresh events (e.g., JWT auto-renewal). - * Returns unsubscribe function. - */ - onTokenRefreshed?(callback: (newToken: string) => void): Unsubscribe -} diff --git a/docs/ports/simulator-port.ts b/docs/ports/simulator-port.ts deleted file mode 100644 index 9ab166aed..000000000 --- a/docs/ports/simulator-port.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * SimulatorPort — Abstracts the built-in AVR simulator (ATmega2560 via avr8js). - * - * Editor adapter: Delegates to main process SimulatorModule via IPC. - * Firmware loaded from disk path. USART0 bridged to ModbusRtuClient. - * Web adapter: Delegates to in-browser SimulatorService singleton. - * Firmware loaded from hex string content (from compiler API). - * Uses browser-compatible Uint8Array-based Modbus RTU client. - * - * Both adapters share the same underlying avr8js@0.20.0 emulator, but differ - * in how firmware is loaded and how the virtual serial port is bridged. - * - * ## Editor IPC methods replaced: - * - window.bridge.simulatorLoadFirmware(hexPath) - * - window.bridge.simulatorStop() - * - window.bridge.simulatorIsRunning() - * - window.bridge.onSimulatorStopped() - * - * ## Web service methods replaced: - * - simulatorService.loadAndRun(hexContent) - * - simulatorService.stop() - * - simulatorService.isRunning() - * - simulatorService.onStopped() - * - simulatorService.connectDebugger() - * - simulatorService.disconnectDebugger() - */ - -import type { Unsubscribe } from './types' - -export interface SimulatorPort { - /** - * Load firmware and start the simulator. - * Editor: receives a file path to the .hex file on disk. - * Web: receives the hex content as a string. - * The adapter handles the difference. - */ - loadFirmware(hexPathOrContent: string): Promise<{ success: boolean; error?: string }> - - /** Stop the simulator. */ - stop(): Promise<{ success: boolean }> - - /** Check if the simulator is currently running. */ - isRunning(): boolean - - /** - * Subscribe to simulator stop events (crash, user stop, or program exit). - * Returns unsubscribe function. - */ - onStopped(callback: () => void): Unsubscribe - - /** - * Connect the debugger to the running simulator. - * Web adapter: initializes virtual serial port and Modbus RTU client. - * Editor adapter: already connected via main process; this is a no-op or - * triggers debuggerConnect with 'simulator' type. - */ - connectDebugger(): Promise - - /** - * Disconnect the debugger from the simulator. - * Cleans up virtual serial port and Modbus client state. - */ - disconnectDebugger(): void -} diff --git a/docs/ports/system-port.ts b/docs/ports/system-port.ts deleted file mode 100644 index d1eeae688..000000000 --- a/docs/ports/system-port.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * SystemPort — Abstracts application-level platform services. - * - * Editor adapter: Delegates to Electron APIs (electron-store, shell, app). - * Web adapter: Uses browser APIs (localStorage, window.open, navigator). - * - * ## Editor IPC methods replaced: - * - window.bridge.getSystemInfo() - * - window.bridge.getStoreValue() - * - window.bridge.setStoreValue() - * - window.bridge.openExternalLinkAccelerator() - * - window.bridge.log() - * - * ## Web equivalents: - * - navigator.userAgent / window.matchMedia for system info - * - localStorage for persistent key-value storage - * - window.open() for external links - * - console.log/error for logging - */ - -import type { SystemInfo } from './types' - -export interface SystemPort { - /** Get platform info (OS, architecture, dark mode preference, window state). */ - getSystemInfo(): Promise - - /** - * Get a persistent key-value store value. - * Editor: reads from electron-store. - * Web: reads from localStorage. - */ - getStoreValue(key: string): Promise - - /** - * Set a persistent key-value store value. - * Editor: writes to electron-store. - * Web: writes to localStorage. - */ - setStoreValue(key: string, value: string): void - - /** - * Open an external URL in the default browser. - * Editor: uses shell.openExternal(). - * Web: uses window.open(). - */ - openExternalLink(url: string): Promise<{ success: boolean }> - - /** - * Log a message (for diagnostics, not UI console). - * Editor: logs via main process logger. - * Web: logs via console or remote logging service. - */ - log(level: 'info' | 'error', message: string): void -} diff --git a/docs/ports/theme-port.ts b/docs/ports/theme-port.ts deleted file mode 100644 index f41361248..000000000 --- a/docs/ports/theme-port.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * ThemePort — Abstracts theme detection and switching. - * - * Editor adapter: Listens for IPC theme-update events from main process. - * Main process detects OS theme changes via nativeTheme API. - * Web adapter: Uses window.matchMedia('(prefers-color-scheme: dark)') listener. - * May also read theme preference from localStorage. - * - * ## Editor IPC methods replaced: - * - window.bridge.handleUpdateTheme() - * - window.bridge.winHandleUpdateTheme() - * - * ## Web equivalents: - * - matchMedia listener - * - localStorage theme preference - * - theme.ts utility - */ - -import type { Unsubscribe } from './types' - -export type ThemeVariant = 'light' | 'dark' - -export interface ThemePort { - /** Get the current active theme. */ - getCurrentTheme(): ThemeVariant - - /** Set the theme explicitly (persists the preference). */ - setTheme(theme: ThemeVariant): void - - /** Toggle between light and dark themes. */ - toggleTheme(): void - - /** - * Subscribe to theme change events (OS-level or user-initiated). - * Returns unsubscribe function. - */ - onThemeChanged(callback: (theme: ThemeVariant) => void): Unsubscribe -} diff --git a/docs/ports/types.ts b/docs/ports/types.ts deleted file mode 100644 index 97f817d43..000000000 --- a/docs/ports/types.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** - * Shared domain types used by port interfaces. - * - * These types are platform-agnostic and represent the domain concepts - * shared between openplc-editor (Electron) and openplc-web (Browser). - * Inner layers (domain/application) depend on these; outer layers (adapters) - * implement the ports that use them. - */ - -// --------------------------------------------------------------------------- -// Result wrappers -// --------------------------------------------------------------------------- - -/** Standard result for operations that can fail */ -export type Result = { success: true } & T | { success: false; error: string } - -/** Unsubscribe function returned by event subscriptions */ -export type Unsubscribe = () => void - -// --------------------------------------------------------------------------- -// PLC Languages & POU -// --------------------------------------------------------------------------- - -export type PLCLanguage = 'IL' | 'ST' | 'LD' | 'FBD' | 'SFC' - -/** Extended languages supported by function blocks */ -export type PLCExtendedLanguage = PLCLanguage | 'python' | 'cpp' - -export type PouType = 'program' | 'function' | 'function-block' - -export type VariableClass = 'input' | 'output' | 'inOut' | 'external' | 'local' | 'temp' | 'global' - -export type VariableTypeDefinition = 'base-type' | 'user-data-type' | 'array' | 'derived' - -export interface PLCVariableType { - definition: VariableTypeDefinition - value: string - data?: { - baseType: string - dimensions: Array<{ dimension: string }> - } -} - -export interface PLCVariable { - name: string - class?: VariableClass - type: PLCVariableType - location: string - initialValue?: string | null - documentation: string - debug?: boolean -} - -export interface PLCTask { - name: string - triggering: 'Cyclic' | 'Interrupt' - interval: string - priority: number -} - -export interface PLCInstance { - name: string - taskName: string - pouName: string -} - -export type PLCDataType = - | { name: string; derivation: 'structure'; variable: PLCVariable[] } - | { - name: string - derivation: 'enumerated' - initialValue?: string - values: Array<{ description: string }> - } - | { - name: string - derivation: 'array' - baseType: string - initialValue?: string - dimensions: Array<{ dimension: string }> - } - -export interface PLCBody { - language: PLCExtendedLanguage | 'il' | 'st' | 'ld' | 'fbd' | 'sfc' | 'python' | 'cpp' - value: unknown -} - -export interface PLCPou { - name: string - pouType: PouType - interface?: { - returnType?: string - variables: PLCVariable[] - } - body: PLCBody - documentation?: string -} - -// --------------------------------------------------------------------------- -// Project -// --------------------------------------------------------------------------- - -export interface PLCProjectData { - dataTypes: PLCDataType[] - pous: PLCPou[] - configurations: { - resource: { - tasks: PLCTask[] - instances: PLCInstance[] - globalVariables: PLCVariable[] - } - } -} - -export interface ProjectMeta { - name: string - type: 'plc-project' | 'plc-library' - path: string -} - -// --------------------------------------------------------------------------- -// Device & Board -// --------------------------------------------------------------------------- - -export type CompilerType = 'arduino-cli' | 'openplc-compiler' | 'simulator' - -export interface BoardInfo { - compiler: CompilerType | string - core: string - preview: string - specs: Record - coreVersion?: string - pins?: { - defaultAin?: string[] - defaultAout?: string[] - defaultDin?: string[] - defaultDout?: string[] - } -} - -export interface CommunicationPort { - name: string - address: string -} - -export interface SerialPort { - device: string - description?: string -} - -export type PinType = 'digitalInput' | 'digitalOutput' | 'analogInput' | 'analogOutput' - -export interface DevicePin { - pin: string - pinType: PinType - address: string - name?: string -} - -export interface ModbusRTUConfig { - rtuInterface: string - rtuBaudRate: string - rtuSlaveId: number | null - rtuRS485ENPin: string | null -} - -export interface ModbusTCPConfig { - tcpInterface: string - tcpMacAddress: string | null - tcpWifiSSID?: string | null - tcpWifiPassword?: string | null - tcpStaticHostConfiguration: { - ipAddress: string - dns: string - gateway: string - subnet: string - } -} - -export interface DeviceConfiguration { - deviceBoard: string - communicationPort: string - runtimeIpAddress?: string - compileOnly: boolean - communicationConfiguration: { - modbusRTU: ModbusRTUConfig - modbusTCP: ModbusTCPConfig - communicationPreferences: { - enabledRTU: boolean - enabledTCP: boolean - enabledDHCP: boolean - } - } -} - -// --------------------------------------------------------------------------- -// Runtime -// --------------------------------------------------------------------------- - -export type PlcStatus = 'INIT' | 'RUNNING' | 'STOPPED' | 'ERROR' | 'EMPTY' | 'UNKNOWN' - -export interface TimingStats { - scan_count: number - scan_time_min: number | null - scan_time_max: number | null - scan_time_avg: number | null - cycle_time_min: number | null - cycle_time_max: number | null - cycle_time_avg: number | null - cycle_latency_min: number | null - cycle_latency_max: number | null - cycle_latency_avg: number | null - overruns: number -} - -export type RuntimeLogLevel = 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' - -export interface RuntimeLogEntry { - id: number | null - timestamp: string - level: RuntimeLogLevel - message: string -} - -// --------------------------------------------------------------------------- -// Debugger -// --------------------------------------------------------------------------- - -export interface DebugVariableResult { - success: boolean - tick?: number - lastIndex?: number - data?: number[] - error?: string - needsReconnect?: boolean -} - -export interface DebugSetResult { - success: boolean - error?: string -} - -export interface Md5VerifyResult { - success: boolean - match?: boolean - targetMd5?: string - error?: string -} - -// --------------------------------------------------------------------------- -// Compiler -// --------------------------------------------------------------------------- - -export interface CompileProgressEvent { - stage: 'xml' | 'st' | 'c' | 'glue' | 'arduino' | 'done' | 'error' - message: string - progress?: number -} - -export interface CompileResult { - success: boolean - message?: string - hexPath?: string - error?: string -} - -export interface DebugCompileResult { - success: boolean - debugContent?: string - md5?: string - error?: string -} - -// --------------------------------------------------------------------------- -// Console -// --------------------------------------------------------------------------- - -export interface LogObject { - id: string - level?: 'debug' | 'info' | 'warning' | 'error' - message: string - tstamp?: Date -} - -// --------------------------------------------------------------------------- -// System -// --------------------------------------------------------------------------- - -export type Platform = 'linux' | 'darwin' | 'win32' | '' - -export type Architecture = 'x64' | 'arm' | '' - -export interface SystemInfo { - OS: Platform - architecture: Architecture - prefersDarkMode: boolean - isWindowMaximized: boolean -} - -export interface RecentProject { - name: string - path: string - lastOpenedAt: string - createdAt: string -} diff --git a/docs/ports/window-port.ts b/docs/ports/window-port.ts deleted file mode 100644 index c5c47ecc0..000000000 --- a/docs/ports/window-port.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * WindowPort — Abstracts native window management. - * - * Editor adapter: Delegates to Electron BrowserWindow APIs via IPC. - * Web adapter: No-op implementation (browser tabs managed by the browser itself). - * Some methods may map to browser equivalents where applicable. - * - * ## Editor IPC methods replaced: - * - window.bridge.minimizeWindow() - * - window.bridge.maximizeWindow() - * - window.bridge.closeWindow() - * - window.bridge.hideWindow() - * - window.bridge.reloadWindow() - * - window.bridge.handleCloseOrHideWindow() - * - window.bridge.handleQuitApp() - * - window.bridge.rebuildMenu() - * - window.bridge.isMaximizedWindow() - * - window.bridge.windowIsClosing() - * - window.bridge.darwinAppIsClosing() - * - * ## Web equivalents: - * - Most methods are no-ops in the browser - * - reload -> window.location.reload() - * - close -> handled by browser tab close (beforeunload event) - */ - -import type { Unsubscribe } from './types' - -export interface WindowPort { - /** Minimize the application window. No-op on web. */ - minimize(): void - - /** Maximize/restore the application window. No-op on web. */ - maximize(): void - - /** Close the application window. Web: triggers beforeunload. */ - close(): void - - /** Hide the application window (minimize to tray). No-op on web. */ - hide(): void - - /** Reload the application. Web: window.location.reload(). */ - reload(): void - - /** Quit the application entirely. No-op on web. */ - quit(): void - - /** Rebuild the native application menu. No-op on web. */ - rebuildMenu(): void - - /** - * Subscribe to window close/quit requests. - * Editor: fires when user clicks close button or uses Cmd+Q. - * Web: fires on beforeunload event. - */ - onCloseRequested(callback: () => void): Unsubscribe - - /** - * Subscribe to window maximize/restore toggle events. - * Editor: fires when window state changes. - * Web: no-op (never fires). - */ - onMaximizedChanged?(callback: (isMaximized: boolean) => void): Unsubscribe -} diff --git a/docs/strucpp-migration/00-overview.md b/docs/strucpp-migration/00-overview.md index ba6c6b1bb..5004d73ed 100644 --- a/docs/strucpp-migration/00-overview.md +++ b/docs/strucpp-migration/00-overview.md @@ -1,8 +1,18 @@ # STruC++ Migration: Overview +> **Status.** STruC++ is the editor's ST compiler today: it is pinned in +> `binary-versions.json` (v0.5.x) and installed as an npm tarball by +> `scripts/download-binaries.ts`; `compiler-module.ts` compiles ST to C++ with it. +> The editor no longer ships or invokes a local `iec2c` binary; remaining +> `iec2c`/`matiec` mentions in `src/` are comments. MatIEC itself is not gone: +> on the Runtime v3 path the editor uploads plain `program.st` and the runtime +> recompiles it on-device with MatIEC +> (`src/backend/shared/compile/pipeline.ts`). The OPC UA plugin migration +> (Phase 9) remains paused. + ## Problem Statement -The OpenPLC Editor uses MatIEC (`iec2c`) to compile IEC 61131-3 Structured Text into C code, +The OpenPLC Editor used MatIEC (`iec2c`) to compile IEC 61131-3 Structured Text into C code, and `xml2st` to generate debug infrastructure. This pipeline has two critical issues: 1. **MatIEC is unmaintained**: The compiler is old, barely maintained, and has poor scalability. @@ -14,7 +24,8 @@ and `xml2st` to generate debug infrastructure. This pipeline has two critical is ## Solution Replace `iec2c` (MatIEC) with **STruC++**, a modern IEC 61131-3 Structured Text to C++17 compiler -written in TypeScript. STruC++ is located at `~/Documents/Code/strucpp`. +written in TypeScript. STruC++ lives in the `Autonomy-Logic/STruCpp` repository and is +distributed as an npm tarball pinned in `binary-versions.json`. STruC++ solves both problems: - It is actively developed with full CODESYS compatibility and OOP support From c577fa732628334ec179a38f7651b0fe75af4875 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:32:18 -0300 Subject: [PATCH 34/52] feat(plcopen): PLCopen XML import parser + import/export UI (NODE-111, NODE-112) (#936) * feat(plcopen): port PLCopen XML import parser and wire import/export UI (NODE-111, NODE-112) - Port the PLCopen XML->project parser (textual POUs, dataTypes, task/instance config, FBD and LD graphical bodies) from autonomy-node's git history into the shared frontend/utils/PLC/xml-parser/ tree, retargeted to this app's native xyflow-based project model. - Wire "Export as PLCopen XML" (previously a disabled stub) and a new "Import PLCopen XML" menu item, backed by new ProjectPort methods (pickPlcopenImportFile, exportPlcopenFile) and a confirm-overwrite modal. - Editor adapter: implement the two new port methods via Electron IPC (native open/save file dialogs filtered to .xml). - Fix a pre-existing export bug where an unnamed ladder variable node was dropped from generated XML while a still referenced it. - Exclude three new shared test files from this repo's Jest run (testPathIgnorePatterns) that rely on vi.mock with a relative module path, a pre-existing Jest/vi-shim limitation already worked around for notify-no-write-permission.test.ts; these run correctly under web's Vitest. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(plcopen): register confirm-plcopen-import in ALL_MODAL_TYPES closeModal() only resets modals listed in ALL_MODAL_TYPES, so the File -> Import PLCopen XML confirm dialog stayed open after a successful import since its type was missing from that list. Co-Authored-By: Claude Sonnet 5 * fix(plcopen): sort imports to satisfy simple-import-sort lint rule Co-Authored-By: Claude Sonnet 5 * style: reformat with prettier after merging development Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- jest.config.json | 8 +- src/__architecture__/validate.ts | 7 + .../utils/__tests__/path-picker.test.ts | 132 ++++ src/backend/editor/utils/path-picker.ts | 64 +- .../_molecules/menu-bar/menus/file.tsx | 16 +- .../confirm-plcopen-import-modal.test.tsx | 65 ++ .../modals/confirm-plcopen-import-modal.tsx | 70 +++ .../components/_templates/app-layout.tsx | 4 + src/frontend/locales/en/menu.json | 1 + .../services/__tests__/export-actions.test.ts | 175 ++++++ .../services/__tests__/import-actions.test.ts | 159 +++++ src/frontend/services/export-actions.ts | 137 +++++ src/frontend/services/import-actions.ts | 65 ++ src/frontend/store/slices/modal/slice.ts | 1 + src/frontend/store/slices/modal/types.ts | 4 + .../PLC/build-plcopen-project-response.ts | 40 ++ .../language/__tests__/ladder-xml.test.ts | 5 +- .../old-editor/language/ladder-xml.ts | 4 +- .../__tests__/data-type-xml.test.ts | 82 +++ .../__tests__/instances-xml.test.ts | 57 ++ .../__tests__/parse-plcopen-xml.test.ts | 570 +++++++++++++++++ .../__tests__/parse-xml-document.test.ts | 19 + .../PLC/xml-parser/__tests__/pou-xml.test.ts | 144 +++++ .../PLC/xml-parser/__tests__/type-xml.test.ts | 76 +++ .../xml-parser/__tests__/variable-xml.test.ts | 59 ++ .../PLC/xml-parser/__tests__/xml-node.test.ts | 41 ++ .../utils/PLC/xml-parser/data-type-xml.ts | 57 ++ src/frontend/utils/PLC/xml-parser/index.ts | 52 ++ .../utils/PLC/xml-parser/instances-xml.ts | 47 ++ .../language/__tests__/fbd-xml.test.ts | 241 ++++++++ .../language/__tests__/geometry.test.ts | 42 ++ .../language/__tests__/ladder-xml.test.ts | 308 +++++++++ .../utils/PLC/xml-parser/language/fbd-xml.ts | 398 ++++++++++++ .../utils/PLC/xml-parser/language/geometry.ts | 53 ++ .../PLC/xml-parser/language/ladder-xml.ts | 582 ++++++++++++++++++ .../PLC/xml-parser/parse-xml-document.ts | 34 + src/frontend/utils/PLC/xml-parser/pou-xml.ts | 120 ++++ src/frontend/utils/PLC/xml-parser/type-xml.ts | 48 ++ .../utils/PLC/xml-parser/variable-xml.ts | 45 ++ src/frontend/utils/PLC/xml-parser/xml-node.ts | 17 + .../__tests__/iec-types-registry.test.ts | 38 +- src/frontend/utils/iec-types-registry.ts | 24 + src/main/modules/ipc/main.ts | 39 +- src/main/modules/ipc/renderer.ts | 10 + .../editor/__tests__/project-adapter.test.ts | 58 ++ .../adapters/editor/project-adapter.ts | 16 + .../shared/ports/platform-capabilities.ts | 9 +- src/middleware/shared/ports/project-port.ts | 32 + 48 files changed, 4263 insertions(+), 12 deletions(-) create mode 100644 src/backend/editor/utils/__tests__/path-picker.test.ts create mode 100644 src/frontend/components/_organisms/modals/__tests__/confirm-plcopen-import-modal.test.tsx create mode 100644 src/frontend/components/_organisms/modals/confirm-plcopen-import-modal.tsx create mode 100644 src/frontend/services/__tests__/export-actions.test.ts create mode 100644 src/frontend/services/__tests__/import-actions.test.ts create mode 100644 src/frontend/services/export-actions.ts create mode 100644 src/frontend/services/import-actions.ts create mode 100644 src/frontend/utils/PLC/build-plcopen-project-response.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/data-type-xml.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/instances-xml.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/parse-plcopen-xml.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/parse-xml-document.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/pou-xml.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/type-xml.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/variable-xml.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/xml-node.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/data-type-xml.ts create mode 100644 src/frontend/utils/PLC/xml-parser/index.ts create mode 100644 src/frontend/utils/PLC/xml-parser/instances-xml.ts create mode 100644 src/frontend/utils/PLC/xml-parser/language/__tests__/fbd-xml.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/language/__tests__/geometry.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/language/__tests__/ladder-xml.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/language/fbd-xml.ts create mode 100644 src/frontend/utils/PLC/xml-parser/language/geometry.ts create mode 100644 src/frontend/utils/PLC/xml-parser/language/ladder-xml.ts create mode 100644 src/frontend/utils/PLC/xml-parser/parse-xml-document.ts create mode 100644 src/frontend/utils/PLC/xml-parser/pou-xml.ts create mode 100644 src/frontend/utils/PLC/xml-parser/type-xml.ts create mode 100644 src/frontend/utils/PLC/xml-parser/variable-xml.ts create mode 100644 src/frontend/utils/PLC/xml-parser/xml-node.ts diff --git a/jest.config.json b/jest.config.json index 482c4ef06..7572446f6 100644 --- a/jest.config.json +++ b/jest.config.json @@ -15,7 +15,13 @@ "url": "http://localhost/" }, "testMatch": ["/src/**/?(*.)+(spec|test).(ts|tsx)", "/src/**/__tests__/**/*.(ts|tsx)"], - "testPathIgnorePatterns": ["/node_modules/", "src/frontend/utils/__tests__/notify-no-write-permission.test.ts"], + "testPathIgnorePatterns": [ + "/node_modules/", + "src/frontend/utils/__tests__/notify-no-write-permission.test.ts", + "src/frontend/services/__tests__/export-actions.test.ts", + "src/frontend/services/__tests__/import-actions.test.ts", + "src/frontend/components/_organisms/modals/__tests__/confirm-plcopen-import-modal.test.tsx" + ], "transformIgnorePatterns": ["node_modules/(?!strucpp)"], "transform": { "\\.(ts|tsx|js|jsx)$": [ diff --git a/src/__architecture__/validate.ts b/src/__architecture__/validate.ts index e498384df..8070dc4b8 100644 --- a/src/__architecture__/validate.ts +++ b/src/__architecture__/validate.ts @@ -279,6 +279,13 @@ 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'], + // 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 + // frontend-reachable layer re-exports this function today; the + // conversion logic itself stays local (mirrors compiler-adapter.ts's + // portToSchemaProjectData) and has no other backend-shared dependency. + 'frontend/services/export-actions.ts': ['backend-shared'], } // --------------------------------------------------------------------------- diff --git a/src/backend/editor/utils/__tests__/path-picker.test.ts b/src/backend/editor/utils/__tests__/path-picker.test.ts new file mode 100644 index 000000000..782975165 --- /dev/null +++ b/src/backend/editor/utils/__tests__/path-picker.test.ts @@ -0,0 +1,132 @@ +/** + * `getPlcopenImportFilePath` / `getPlcopenExportSavePath` — focused tests. + * + * Electron's `dialog` is mocked (no real native dialog in Jest); the + * filesystem is real (per-test temp dir) so read/write failure paths + * are exercised against actual I/O rather than stubbed error shapes. + */ + +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +const showOpenDialogMock = jest.fn() +const showSaveDialogMock = jest.fn() + +jest.mock('electron', () => ({ + BrowserWindow: class {}, + dialog: { + showOpenDialog: (...args: unknown[]) => showOpenDialogMock(...args), + showSaveDialog: (...args: unknown[]) => showSaveDialogMock(...args), + }, +})) + +import { getPlcopenExportSavePath, getPlcopenImportFilePath } from '../path-picker' + +describe('getPlcopenImportFilePath', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'plcopen-import-')) + jest.clearAllMocks() + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('reads and returns the selected file content', async () => { + const filePath = join(dir, 'program.xml') + writeFileSync(filePath, '') + showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [filePath] }) + + const result = await getPlcopenImportFilePath({} as never) + + expect(showOpenDialogMock).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + title: 'Select a PLCopen XML file to import', + properties: ['openFile'], + filters: [{ name: 'PLCopen XML', extensions: ['xml'] }], + }), + ) + expect(result).toEqual({ success: true, content: '' }) + }) + + it('returns a canceled error when the user dismisses the dialog', async () => { + showOpenDialogMock.mockResolvedValue({ canceled: true, filePaths: [] }) + + const result = await getPlcopenImportFilePath({} as never) + + expect(result).toEqual({ + success: false, + error: { title: 'Operation canceled', description: 'Operation canceled by the user.' }, + }) + }) + + it('returns a read error when the selected file cannot be read', async () => { + const missingPath = join(dir, 'missing.xml') + showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [missingPath] }) + + const result = await getPlcopenImportFilePath({} as never) + + expect(result).toEqual({ + success: false, + error: { title: 'Error reading file', description: 'Failed to read the selected PLCopen XML file.' }, + }) + }) +}) + +describe('getPlcopenExportSavePath', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'plcopen-export-')) + jest.clearAllMocks() + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('writes the XML content to the chosen path', async () => { + const filePath = join(dir, 'exported.xml') + showSaveDialogMock.mockResolvedValue({ canceled: false, filePath }) + + const result = await getPlcopenExportSavePath({} as never, 'exported.xml', '') + + expect(showSaveDialogMock).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + title: 'Export PLCopen XML', + defaultPath: 'exported.xml', + filters: [{ name: 'PLCopen XML', extensions: ['xml'] }], + }), + ) + expect(result).toEqual({ success: true }) + expect(readFileSync(filePath, 'utf-8')).toBe('') + }) + + it('returns a canceled error when the user dismisses the dialog', async () => { + showSaveDialogMock.mockResolvedValue({ canceled: true, filePath: undefined }) + + const result = await getPlcopenExportSavePath({} as never, 'exported.xml', '') + + expect(result).toEqual({ + success: false, + error: { title: 'Operation canceled', description: 'Operation canceled by the user.' }, + }) + }) + + it('returns a write error when the target path cannot be written', async () => { + const badPath = join(dir, 'nonexistent-subdir', 'exported.xml') + showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: badPath }) + + const result = await getPlcopenExportSavePath({} as never, 'exported.xml', '') + + expect(result).toEqual({ + success: false, + error: { title: 'Error writing file', description: 'Failed to write the PLCopen XML file.' }, + }) + }) +}) diff --git a/src/backend/editor/utils/path-picker.ts b/src/backend/editor/utils/path-picker.ts index 85dc985f0..81b52500a 100644 --- a/src/backend/editor/utils/path-picker.ts +++ b/src/backend/editor/utils/path-picker.ts @@ -74,4 +74,66 @@ const getOpenProjectPath = async (serviceManager: GetProjectPathProps) => { } } -export { getOpenProjectPath, getProjectPath } +const getPlcopenImportFilePath = async (serviceManager: GetProjectPathProps) => { + const { canceled, filePaths } = await dialog.showOpenDialog(serviceManager, { + title: 'Select a PLCopen XML file to import', + properties: ['openFile'], + filters: [{ name: 'PLCopen XML', extensions: ['xml'] }], + }) + if (canceled) { + return { + success: false, + error: { + title: 'Operation canceled', + description: 'Operation canceled by the user.', + }, + } + } + + const [filePath] = filePaths + + try { + const content = await promises.readFile(filePath, 'utf-8') + return { success: true, content } + } catch { + return { + success: false, + error: { + title: 'Error reading file', + description: 'Failed to read the selected PLCopen XML file.', + }, + } + } +} + +const getPlcopenExportSavePath = async (serviceManager: GetProjectPathProps, defaultFileName: string, xml: string) => { + const { canceled, filePath } = await dialog.showSaveDialog(serviceManager, { + title: 'Export PLCopen XML', + defaultPath: defaultFileName, + filters: [{ name: 'PLCopen XML', extensions: ['xml'] }], + }) + if (canceled || !filePath) { + return { + success: false, + error: { + title: 'Operation canceled', + description: 'Operation canceled by the user.', + }, + } + } + + try { + await promises.writeFile(filePath, xml, 'utf-8') + return { success: true } + } catch { + return { + success: false, + error: { + title: 'Error writing file', + description: 'Failed to write the PLCopen XML file.', + }, + } + } +} + +export { getOpenProjectPath, getPlcopenExportSavePath, getPlcopenImportFilePath, getProjectPath } diff --git a/src/frontend/components/_molecules/menu-bar/menus/file.tsx b/src/frontend/components/_molecules/menu-bar/menus/file.tsx index 63bc54de1..3eb585307 100644 --- a/src/frontend/components/_molecules/menu-bar/menus/file.tsx +++ b/src/frontend/components/_molecules/menu-bar/menus/file.tsx @@ -4,6 +4,7 @@ import { useEffect } from 'react' import { useCapabilities, useProject } from '../../../../../middleware/shared/providers' import { useHandleRemoveTab } from '../../../../hooks/use-remove-tab' import { i18n } from '../../../../locales/i18n' +import { executeExportPlcopen } from '../../../../services/export-actions' import { executeSaveActiveFile, executeSaveProject } from '../../../../services/save-actions' import { useOpenPLCStore } from '../../../../store' import { MenuClasses } from '../constants' @@ -83,12 +84,19 @@ export const FileMenu = () => { )} - {capabilities.hasProjectExport && ( + {(capabilities.hasProjectExport || capabilities.hasProjectImport) && ( <> - - {i18n.t('menu:file.submenu.exportToPLCOpenXml')} - + {capabilities.hasProjectExport && ( + void executeExportPlcopen(projectPort)}> + {i18n.t('menu:file.submenu.exportToPLCOpenXml')} + + )} + {capabilities.hasProjectImport && ( + openModal('confirm-plcopen-import')}> + {i18n.t('menu:file.submenu.importFromPLCOpenXml')} + + )} )} diff --git a/src/frontend/components/_organisms/modals/__tests__/confirm-plcopen-import-modal.test.tsx b/src/frontend/components/_organisms/modals/__tests__/confirm-plcopen-import-modal.test.tsx new file mode 100644 index 000000000..b7ce102a2 --- /dev/null +++ b/src/frontend/components/_organisms/modals/__tests__/confirm-plcopen-import-modal.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen } from '@testing-library/react' + +const mockProjectPort = { id: 'fake-project-port' } +vi.mock('../../../../../middleware/shared/providers', () => ({ + useProject: () => mockProjectPort, +})) + +const mockExecuteImportPlcopen = vi.fn() +vi.mock('../../../../services/import-actions', () => ({ + executeImportPlcopen: (...args: unknown[]) => mockExecuteImportPlcopen(...args), +})) + +const closeModal = vi.fn() +const onOpenChange = vi.fn() +vi.mock('../../../../store', () => ({ + useOpenPLCStore: () => ({ + modalActions: { onOpenChange, closeModal }, + }), +})) + +import { ConfirmPlcopenImportModal } from '../confirm-plcopen-import-modal' + +describe('ConfirmPlcopenImportModal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteImportPlcopen.mockResolvedValue({ success: true }) + }) + + it('renders the overwrite warning copy when open', () => { + render() + expect(screen.getByText('Import PLCopen XML?')).toBeTruthy() + expect( + screen.getByText( + 'Importing a PLCopen XML file will overwrite the entire currently open project. This cannot be undone.', + ), + ).toBeTruthy() + }) + + it('renders nothing visible when closed', () => { + render() + expect(screen.queryByText('Import PLCopen XML?')).toBeNull() + }) + + it('calls executeImportPlcopen with the project port and closes the modal on confirm', async () => { + render() + + fireEvent.click(screen.getByText('Import PLCopen XML')) + + // Flush the async handler. + await Promise.resolve() + await Promise.resolve() + + expect(mockExecuteImportPlcopen).toHaveBeenCalledWith(mockProjectPort) + expect(closeModal).toHaveBeenCalledTimes(1) + }) + + it('closes without importing on cancel', () => { + render() + + fireEvent.click(screen.getByText('Cancel')) + + expect(mockExecuteImportPlcopen).not.toHaveBeenCalled() + expect(closeModal).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/frontend/components/_organisms/modals/confirm-plcopen-import-modal.tsx b/src/frontend/components/_organisms/modals/confirm-plcopen-import-modal.tsx new file mode 100644 index 000000000..e1613ebed --- /dev/null +++ b/src/frontend/components/_organisms/modals/confirm-plcopen-import-modal.tsx @@ -0,0 +1,70 @@ +/** + * Confirmation modal for File → "Import PLCopen XML". + * + * Distinct from `confirm-delete-project` in that it carries no data + * payload — it acts on whatever project is currently open (an in-place + * content overwrite), not a specific record picked from a list. + */ + +import { useProject } from '../../../../middleware/shared/providers' +import { WarningIcon } from '../../../assets/icons/interface/Warning' +import { executeImportPlcopen } from '../../../services/import-actions' +import { useOpenPLCStore } from '../../../store' +import { Modal, ModalContent } from '../../_molecules/modal' + +type ConfirmPlcopenImportModalProps = { + isOpen: boolean +} + +const ConfirmPlcopenImportModal = ({ isOpen, ...rest }: ConfirmPlcopenImportModalProps) => { + const projectPort = useProject() + const { + modalActions: { onOpenChange, closeModal }, + } = useOpenPLCStore() + + const handleConfirm = async () => { + await executeImportPlcopen(projectPort) + closeModal() + } + + return ( + { + if (!open) closeModal() + onOpenChange('confirm-plcopen-import', open) + }} + {...rest} + > + +
+ +
+

+ Import PLCopen XML? +

+

+ Importing a PLCopen XML file will overwrite the entire currently open project. This cannot be undone. +

+
+
+ + +
+
+
+
+ ) +} + +export { ConfirmPlcopenImportModal } diff --git a/src/frontend/components/_templates/app-layout.tsx b/src/frontend/components/_templates/app-layout.tsx index d91213b37..1f696ed8f 100644 --- a/src/frontend/components/_templates/app-layout.tsx +++ b/src/frontend/components/_templates/app-layout.tsx @@ -12,6 +12,7 @@ import AboutModal from '../_organisms/about-modal' import { RuntimeCreateUserModal, RuntimeDiscoverDevicesModal, RuntimeLoginModal } from '../_organisms/modals' 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 { DebuggerMessageModal } from '../_organisms/modals/debugger-message-modal' import { ConfirmDeleteElementModal } from '../_organisms/modals/delete-confirmation-modal' import { MissingLibrariesModal } from '../_organisms/modals/missing-libraries-modal' @@ -126,6 +127,9 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { {modals?.['confirm-delete-project']?.open === true && ( )} + {modals?.['confirm-plcopen-import']?.open === true && ( + + )} {modals?.['quit-application']?.open === true && ( )} diff --git a/src/frontend/locales/en/menu.json b/src/frontend/locales/en/menu.json index 426289e70..c5304ffc3 100644 --- a/src/frontend/locales/en/menu.json +++ b/src/frontend/locales/en/menu.json @@ -22,6 +22,7 @@ "closeTab": "Close Tab", "closeProject": "Close Project", "exportToPLCOpenXml": "Export to PLCOpen XML", + "importFromPLCOpenXml": "Import PLCopen XML", "exportToCodesysXml": "Export to CODESYS XML", "pageSetup": "Page Setup", "preview": "Preview", diff --git a/src/frontend/services/__tests__/export-actions.test.ts b/src/frontend/services/__tests__/export-actions.test.ts new file mode 100644 index 000000000..9500a5386 --- /dev/null +++ b/src/frontend/services/__tests__/export-actions.test.ts @@ -0,0 +1,175 @@ +/** + * export-actions.ts test file + * + * `executeExportPlcopen` reads `openPLCStoreBase.getState()`, converts the + * flat store project shape into `XmlGenerator`'s schema shape, and calls + * `projectPort.exportPlcopenFile`. All three collaborators are mocked so the + * test exercises only the conversion + orchestration logic in this file. + */ + +import type { ProjectPort } from '../../../middleware/shared/ports/project-port' +import type { PLCProjectData } from '../../../middleware/shared/ports/types' + +const mockXmlGenerator = vi.fn() +vi.mock('../../../backend/shared/utils/PLC/xml-generator', () => ({ + XmlGenerator: (...args: unknown[]) => mockXmlGenerator(...args), +})) + +const mockGetState = vi.fn() +vi.mock('../../store', () => ({ + openPLCStoreBase: { + getState: () => mockGetState(), + }, +})) + +const mockToast = vi.fn() +vi.mock('../../utils/toast', () => ({ + toast: (...args: unknown[]) => mockToast(...args), +})) + +import { executeExportPlcopen } from '../export-actions' + +function makeProjectData(overrides?: Partial): PLCProjectData { + return { + dataTypes: [], + pous: [ + { + name: 'main', + pouType: 'program', + body: { language: 'st', value: 'a := 1;' }, + interface: { variables: [] }, + documentation: '', + }, + ], + configurations: { resource: { tasks: [], instances: [], globalVariables: [] } }, + ...overrides, + } +} + +function makeState(projectData: PLCProjectData, projectName = 'MyProject') { + return { + project: { + meta: { name: projectName, type: 'plc-project' as const, path: 'proj-1' }, + data: projectData, + }, + } +} + +function makeProjectPort(overrides?: Partial): ProjectPort { + return { + exportPlcopenFile: vi.fn().mockResolvedValue({ success: true }), + pickPlcopenImportFile: vi.fn(), + ...overrides, + } as unknown as ProjectPort +} + +beforeEach(() => { + vi.clearAllMocks() + mockGetState.mockReturnValue(makeState(makeProjectData())) +}) + +describe('executeExportPlcopen', () => { + it('converts the flat project data into schema shape and passes it to XmlGenerator', async () => { + mockXmlGenerator.mockReturnValue({ ok: true, message: 'ok', data: '' }) + const projectPort = makeProjectPort() + + const result = await executeExportPlcopen(projectPort) + + expect(result).toEqual({ success: true }) + expect(mockXmlGenerator).toHaveBeenCalledTimes(1) + const [schemaData, dialect] = mockXmlGenerator.mock.calls[0] + expect(dialect).toBe('old-editor') + expect(schemaData.pous).toEqual([ + { + type: 'program', + data: { + language: 'st', + name: 'main', + variables: [], + body: { language: 'st', value: 'a := 1;' }, + documentation: '', + }, + }, + ]) + expect(schemaData.configuration).toEqual({ + resource: { tasks: [], instances: [], globalVariables: [] }, + }) + }) + + it('maps function and function-block POUs to their discriminated schema shapes', async () => { + mockXmlGenerator.mockReturnValue({ ok: true, message: 'ok', data: '' }) + const projectData = makeProjectData({ + pous: [ + { + name: 'AddOne', + pouType: 'function', + body: { language: 'st', value: 'AddOne := IN + 1;' }, + interface: { returnType: 'INT', variables: [] }, + documentation: 'doc', + }, + { + name: 'Counter', + pouType: 'function-block', + body: { language: 'st', value: '' }, + interface: { variables: [] }, + }, + ], + }) + mockGetState.mockReturnValue(makeState(projectData)) + + await executeExportPlcopen(makeProjectPort()) + + const [schemaData] = mockXmlGenerator.mock.calls[0] + expect(schemaData.pous[0]).toMatchObject({ type: 'function', data: { name: 'AddOne', returnType: 'INT' } }) + expect(schemaData.pous[1]).toMatchObject({ type: 'function-block', data: { name: 'Counter' } }) + }) + + it('calls exportPlcopenFile with the project name and generated XML, and toasts success', async () => { + mockXmlGenerator.mockReturnValue({ ok: true, message: 'ok', data: '' }) + mockGetState.mockReturnValue(makeState(makeProjectData(), 'Widgets')) + const exportPlcopenFile = vi.fn().mockResolvedValue({ success: true }) + const projectPort = makeProjectPort({ exportPlcopenFile }) + + const result = await executeExportPlcopen(projectPort) + + expect(result).toEqual({ success: true }) + expect(exportPlcopenFile).toHaveBeenCalledWith('Widgets.xml', '') + expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: 'default' })) + }) + + it('toasts a failure and returns success:false when XmlGenerator fails', async () => { + mockXmlGenerator.mockReturnValue({ ok: false, message: 'Main POU not found.' }) + const projectPort = makeProjectPort() + + const result = await executeExportPlcopen(projectPort) + + expect(result).toEqual({ success: false }) + expect(projectPort.exportPlcopenFile).not.toHaveBeenCalled() + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'fail', description: 'Main POU not found.' }), + ) + }) + + it('toasts a failure and returns success:false when the platform port fails to save the file', async () => { + mockXmlGenerator.mockReturnValue({ ok: true, message: 'ok', data: '' }) + const exportPlcopenFile = vi.fn().mockResolvedValue({ success: false, error: 'disk full' }) + const projectPort = makeProjectPort({ exportPlcopenFile }) + + const result = await executeExportPlcopen(projectPort) + + expect(result).toEqual({ success: false }) + expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: 'fail', description: 'disk full' })) + }) + + it('catches unexpected exceptions and toasts a generic failure', async () => { + mockXmlGenerator.mockImplementation(() => { + throw new Error('boom') + }) + const projectPort = makeProjectPort() + + const result = await executeExportPlcopen(projectPort) + + expect(result).toEqual({ success: false }) + expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: 'fail', description: 'boom' })) + }) +}) diff --git a/src/frontend/services/__tests__/import-actions.test.ts b/src/frontend/services/__tests__/import-actions.test.ts new file mode 100644 index 000000000..6be48002a --- /dev/null +++ b/src/frontend/services/__tests__/import-actions.test.ts @@ -0,0 +1,159 @@ +/** + * import-actions.ts test file + * + * `executeImportPlcopen` picks a file via the platform port, parses it with + * `parsePlcopenXml`, and hands the result to the store's + * `handleOpenProjectResponse` action. All collaborators are mocked so the + * test exercises only the orchestration logic in this file. + */ + +import type { ProjectPort } from '../../../middleware/shared/ports/project-port' + +const mockParsePlcopenXml = vi.fn() +vi.mock('../../utils/PLC/xml-parser', () => ({ + parsePlcopenXml: (...args: unknown[]) => mockParsePlcopenXml(...args), +})) + +const mockGetState = vi.fn() +vi.mock('../../store', () => ({ + openPLCStoreBase: { + getState: () => mockGetState(), + }, +})) + +const mockToast = vi.fn() +vi.mock('../../utils/toast', () => ({ + toast: (...args: unknown[]) => mockToast(...args), +})) + +import { executeImportPlcopen } from '../import-actions' + +function makeProjectPort(overrides?: Partial): ProjectPort { + return { + pickPlcopenImportFile: vi.fn().mockResolvedValue({ success: true, content: '' }), + exportPlcopenFile: vi.fn(), + ...overrides, + } as unknown as ProjectPort +} + +const handleOpenProjectResponse = vi.fn() + +beforeEach(() => { + vi.clearAllMocks() + mockGetState.mockReturnValue({ + project: { meta: { name: 'Old', type: 'plc-project', path: 'proj-1' } }, + sharedWorkspaceActions: { handleOpenProjectResponse }, + }) + mockParsePlcopenXml.mockReturnValue({ + projectData: { + dataTypes: [], + pous: [], + configurations: { resource: { tasks: [], instances: [], globalVariables: [] } }, + }, + warnings: [], + projectName: 'Imported', + }) +}) + +describe('executeImportPlcopen', () => { + it('returns success:false silently when the picker is cancelled', async () => { + const projectPort = makeProjectPort({ + pickPlcopenImportFile: vi.fn().mockResolvedValue({ success: false }), + }) + + const result = await executeImportPlcopen(projectPort) + + expect(result).toEqual({ success: false }) + expect(handleOpenProjectResponse).not.toHaveBeenCalled() + expect(mockToast).not.toHaveBeenCalled() + }) + + it('returns success:false silently when the picker succeeds but has no content', async () => { + const projectPort = makeProjectPort({ + pickPlcopenImportFile: vi.fn().mockResolvedValue({ success: true }), + }) + + const result = await executeImportPlcopen(projectPort) + + expect(result).toEqual({ success: false }) + expect(mockToast).not.toHaveBeenCalled() + }) + + it('parses the picked content and overwrites the open project in-place, preserving path', async () => { + const projectPort = makeProjectPort() + + const result = await executeImportPlcopen(projectPort) + + expect(result).toEqual({ success: true }) + expect(mockParsePlcopenXml).toHaveBeenCalledWith('') + expect(handleOpenProjectResponse).toHaveBeenCalledWith({ + meta: { name: 'Imported', type: 'plc-project', path: 'proj-1' }, + projectData: { + dataTypes: [], + pous: [], + configurations: { resource: { tasks: [], instances: [], globalVariables: [] } }, + }, + warnings: [], + }) + expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: 'default' })) + }) + + it('falls back to "Imported Project" when the XML carries no project name', async () => { + mockParsePlcopenXml.mockReturnValue({ + projectData: { + dataTypes: [], + pous: [], + configurations: { resource: { tasks: [], instances: [], globalVariables: [] } }, + }, + warnings: [], + projectName: '', + }) + const projectPort = makeProjectPort() + + await executeImportPlcopen(projectPort) + + expect(handleOpenProjectResponse).toHaveBeenCalledWith( + expect.objectContaining({ meta: expect.objectContaining({ name: 'Imported Project' }) }), + ) + }) + + it('logs warnings to console and shows a warning toast mentioning the count', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockParsePlcopenXml.mockReturnValue({ + projectData: { + dataTypes: [], + pous: [], + configurations: { resource: { tasks: [], instances: [], globalVariables: [] } }, + }, + warnings: ['SFC body dropped', 'Unknown dialect element'], + projectName: 'Imported', + }) + const projectPort = makeProjectPort() + + const result = await executeImportPlcopen(projectPort) + + expect(result).toEqual({ success: true }) + expect(consoleWarnSpy).toHaveBeenCalledTimes(2) + expect(consoleWarnSpy).toHaveBeenCalledWith('[PLCopen import] SFC body dropped') + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'warn', description: expect.stringContaining('2 warning(s)') }), + ) + + consoleWarnSpy.mockRestore() + }) + + it('catches parse exceptions and toasts a failure without touching the store', async () => { + mockParsePlcopenXml.mockImplementation(() => { + throw new Error('Invalid PLCopen XML: missing root element') + }) + const projectPort = makeProjectPort() + + const result = await executeImportPlcopen(projectPort) + + expect(result).toEqual({ success: false }) + expect(handleOpenProjectResponse).not.toHaveBeenCalled() + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'fail', description: 'Invalid PLCopen XML: missing root element' }), + ) + }) +}) diff --git a/src/frontend/services/export-actions.ts b/src/frontend/services/export-actions.ts new file mode 100644 index 000000000..fa57755ca --- /dev/null +++ b/src/frontend/services/export-actions.ts @@ -0,0 +1,137 @@ +/** + * Export the live project as PLCopen XML. + * + * Converts the store's flat port-shape `PLCProjectData` + * (`middleware/shared/ports/types.ts`) into the discriminated-union schema + * shape `XmlGenerator` consumes (`middleware/shared/ports/open-plc-types.ts` + * — nested `{type,data:{...}}` POUs, singular `configuration`), generates the + * XML, and hands it to the platform port for persistence (native save dialog + * on desktop, browser download on web). + * + * The conversion mirrors the web compiler adapter's `portToSchemaProjectData` + * (`middleware/adapters/web/compiler-adapter.ts`) but is kept local and + * stripped of compile-only concerns (`originalCppPous` and other preprocessor + * sidecars) since this is a plain export, not a compile. + */ + +import { XmlGenerator } from '../../backend/shared/utils/PLC/xml-generator' +import type { PLCProjectData as SchemaPLCProjectData } from '../../middleware/shared/ports/open-plc-types' +import type { ProjectPort } from '../../middleware/shared/ports/project-port' +import type { PLCProjectData, PouLanguage } from '../../middleware/shared/ports/types' +import { openPLCStoreBase } from '../store' +import { toast } from '../utils/toast' + +/** + * Convert the store's flat port-shape project data into the nested + * schema shape `XmlGenerator` expects. See file header for context. + * + * The port-shape `PLCBody.language` field carries a broader union + * (upper- and lower-case variants, see `types.ts`) than the schema + * shape expects — at runtime it's always the lowercase `PouLanguage` + * form every other consumer (save/serialize) already assumes, so the + * narrowing cast below is the same invariant the rest of the codebase + * relies on, not a new assumption. + */ +function portToSchemaProjectData(input: PLCProjectData): SchemaPLCProjectData { + const pous = input.pous.map((pou) => { + const variables = pou.interface?.variables ?? [] + const language = pou.body.language as PouLanguage + if (pou.pouType === 'function') { + return { + type: 'function' as const, + data: { + language, + name: pou.name, + returnType: pou.interface?.returnType ?? 'BOOL', + variables, + body: pou.body, + documentation: pou.documentation ?? '', + }, + } + } + if (pou.pouType === 'function-block') { + return { + type: 'function-block' as const, + data: { + language, + name: pou.name, + variables, + body: pou.body, + documentation: pou.documentation ?? '', + }, + } + } + return { + type: 'program' as const, + data: { + language, + name: pou.name, + variables, + body: pou.body, + documentation: pou.documentation ?? '', + }, + } + }) + + return { + pous, + dataTypes: input.dataTypes, + configuration: { + resource: { + tasks: input.configurations.resource.tasks, + instances: input.configurations.resource.instances, + globalVariables: input.configurations.resource.globalVariables, + }, + }, + servers: input.servers ?? [], + remoteDevices: input.remoteDevices ?? [], + } as SchemaPLCProjectData +} + +/** + * Export the currently open project as a PLCopen XML file. + * Equivalent to File → "Export to PLCOpen XML". + */ +export async function executeExportPlcopen(projectPort: ProjectPort): Promise<{ success: boolean }> { + const state = openPLCStoreBase.getState() + + try { + const schemaData = portToSchemaProjectData(state.project.data) + const xmlResult = XmlGenerator(schemaData, 'old-editor') + + if (!xmlResult.ok || !xmlResult.data) { + toast({ + title: 'Error exporting PLCopen XML', + description: xmlResult.message || 'Failed to generate the PLCopen XML.', + variant: 'fail', + }) + return { success: false } + } + + const fileName = `${state.project.meta.name}.xml` + const exportResult = await projectPort.exportPlcopenFile(fileName, xmlResult.data) + + if (!exportResult.success) { + toast({ + title: 'Error exporting PLCopen XML', + description: exportResult.error ?? 'Failed to save the exported file.', + variant: 'fail', + }) + return { success: false } + } + + toast({ + title: 'Project exported', + description: `"${fileName}" was exported successfully.`, + variant: 'default', + }) + return { success: true } + } catch (err) { + toast({ + title: 'Error exporting PLCopen XML', + description: err instanceof Error ? err.message : 'An unexpected error occurred while exporting.', + variant: 'fail', + }) + return { success: false } + } +} diff --git a/src/frontend/services/import-actions.ts b/src/frontend/services/import-actions.ts new file mode 100644 index 000000000..e5a102e62 --- /dev/null +++ b/src/frontend/services/import-actions.ts @@ -0,0 +1,65 @@ +/** + * Import a PLCopen XML file, overwriting the currently open project in-place. + * + * This is a content replacement, not a project switch: the existing + * project's `path` (and file-storage identity) is preserved, only the + * in-memory project data is replaced with what the XML parses to. + */ + +import type { ProjectPort } from '../../middleware/shared/ports/project-port' +import { openPLCStoreBase } from '../store' +import type { OpenProjectResponseData } from '../store/slices/shared/types' +import { buildProjectResponseFromPlcopenParse } from '../utils/PLC/build-plcopen-project-response' +import { parsePlcopenXml } from '../utils/PLC/xml-parser' +import { toast } from '../utils/toast' + +/** + * Pick a PLCopen XML file via the platform port, parse it, and overwrite + * the currently open project with the result. Equivalent to File → + * "Import PLCopen XML" (routed through the confirm-overwrite modal). + */ +export async function executeImportPlcopen(projectPort: ProjectPort): Promise<{ success: boolean }> { + const picked = await projectPort.pickPlcopenImportFile() + if (!picked.success || !picked.content) { + // User cancelled the picker (or the platform failed silently) — no + // error toast, this isn't a failure the user needs to be told about. + return { success: false } + } + + const state = openPLCStoreBase.getState() + + try { + const parseResult = parsePlcopenXml(picked.content) + const { warnings } = parseResult + + const data: OpenProjectResponseData = buildProjectResponseFromPlcopenParse(parseResult, state.project.meta.path) + + state.sharedWorkspaceActions.handleOpenProjectResponse(data) + + if (warnings.length > 0) { + for (const warning of warnings) { + console.warn(`[PLCopen import] ${warning}`) + } + toast({ + title: 'Project imported', + description: `Imported with ${warnings.length} warning(s), see console.`, + variant: 'warn', + }) + } else { + toast({ + title: 'Project imported', + description: 'The PLCopen XML file was imported successfully.', + variant: 'default', + }) + } + + return { success: true } + } catch (err) { + toast({ + title: 'Error importing PLCopen XML', + description: err instanceof Error ? err.message : 'Failed to parse the selected file.', + variant: 'fail', + }) + return { success: false } + } +} diff --git a/src/frontend/store/slices/modal/slice.ts b/src/frontend/store/slices/modal/slice.ts index bb89930c9..cecff18e4 100644 --- a/src/frontend/store/slices/modal/slice.ts +++ b/src/frontend/store/slices/modal/slice.ts @@ -27,6 +27,7 @@ const ALL_MODAL_TYPES: ModalTypes[] = [ 'public-catalog-browser', 'confirm-install-libraries', 'project-readme', + 'confirm-plcopen-import', ] function createDefaultModals() { diff --git a/src/frontend/store/slices/modal/types.ts b/src/frontend/store/slices/modal/types.ts index 92a25e842..84a34b138 100644 --- a/src/frontend/store/slices/modal/types.ts +++ b/src/frontend/store/slices/modal/types.ts @@ -38,6 +38,10 @@ export type ModalTypes = * commit-message override. Available only when the project port * exposes the README slot (web adapter against the Edge API). */ | 'project-readme' + /** Confirm-overwrite gate for the File → "Import PLCopen XML" menu + * item. Acts on whatever project is currently open — no targeted + * data payload (unlike `confirm-delete-project`). */ + | 'confirm-plcopen-import' export type ModalsState = Record diff --git a/src/frontend/utils/PLC/build-plcopen-project-response.ts b/src/frontend/utils/PLC/build-plcopen-project-response.ts new file mode 100644 index 000000000..7826ac122 --- /dev/null +++ b/src/frontend/utils/PLC/build-plcopen-project-response.ts @@ -0,0 +1,40 @@ +/** + * Shared mapping from a `parsePlcopenXml` result to the `{ meta, projectData, + * warnings }` shape both PLCopen-import call sites need: + * - `import-actions.ts`'s `executeImportPlcopen` (interactive File → Import) + * - `project-adapter.ts`'s `openProjectByPath` (pending-import auto-convert) + * + * Pure and framework-agnostic so it can be reached from both the frontend + * service and the middleware adapter without either importing the other. + */ + +import type { ProjectMeta } from '../../../middleware/shared/ports/types' +import type { PlcopenParseResult } from './xml-parser' + +export interface PlcopenProjectResponseData { + meta: ProjectMeta + projectData: PlcopenParseResult['projectData'] + warnings: string[] +} + +/** + * Build the `{ meta, projectData, warnings }` triple from a PLCopen parse + * result. Name precedence: the XML's own `` (when + * non-empty), then `fallbackName`, then the generic `'Imported Project'` + * default — same precedence the interactive import flow already used. + */ +export function buildProjectResponseFromPlcopenParse( + parseResult: PlcopenParseResult, + path: string, + fallbackName?: string, +): PlcopenProjectResponseData { + return { + meta: { + name: parseResult.projectName || fallbackName || 'Imported Project', + type: 'plc-project', + path, + }, + projectData: parseResult.projectData, + warnings: parseResult.warnings, + } +} diff --git a/src/frontend/utils/PLC/xml-generator/old-editor/language/__tests__/ladder-xml.test.ts b/src/frontend/utils/PLC/xml-generator/old-editor/language/__tests__/ladder-xml.test.ts index c3e715a7c..5e218be61 100644 --- a/src/frontend/utils/PLC/xml-generator/old-editor/language/__tests__/ladder-xml.test.ts +++ b/src/frontend/utils/PLC/xml-generator/old-editor/language/__tests__/ladder-xml.test.ts @@ -269,7 +269,7 @@ describe('ladderToXml (old-editor)', () => { expect(result.body.LD.coil[0].variable).toBe('A') }) - it('skips variable nodes with empty name', () => { + it('still emits variable nodes with empty name (E1 fix — connections may already reference their localId)', () => { const rung = makeRung({ nodes: [ makeLeftRail() as unknown as Node, @@ -298,7 +298,8 @@ describe('ladderToXml (old-editor)', () => { ], }) const result = ladderToXml([rung]) - expect(result.body.LD.inVariable).toHaveLength(0) + expect(result.body.LD.inVariable).toHaveLength(1) + expect(result.body.LD.inVariable[0]['@localId']).toBe('30') expect(result.body.LD.outVariable).toHaveLength(0) }) diff --git a/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts b/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts index c20709a65..096a16eae 100644 --- a/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts @@ -613,7 +613,9 @@ const ladderToXml = (rungs: RungLadderState[]) => { ladderXML.body.LD.block.push(blockToXml(node as BlockNode, rung, offsetY)) break case 'variable': - if ((node as VariableNode).data.variable.name === '') return + // Always emit — even with an empty name. A elsewhere + // in the XML may already reference this node's numericId; skipping + // it here would leave that connection's @refLocalId dangling. if ((node as VariableNode).data.variant === 'input') ladderXML.body.LD.inVariable.push(inVariableToXML(node as VariableNode, offsetY)) if ((node as VariableNode).data.variant === 'output') diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/data-type-xml.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/data-type-xml.test.ts new file mode 100644 index 000000000..fa85a621a --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/__tests__/data-type-xml.test.ts @@ -0,0 +1,82 @@ +import { parseDataTypesXml } from '../data-type-xml' + +describe('parseDataTypesXml', () => { + it('parses a structure derivation', () => { + const result = parseDataTypesXml([ + { + '@name': 'MyStruct', + baseType: { + struct: { + variable: [ + { '@name': 'a', type: { BOOL: '' } }, + { '@name': 'b', type: { INT: '' }, initialValue: { simpleValue: { '@value': '5' } } }, + ], + }, + }, + }, + ]) + expect(result).toEqual([ + { + name: 'MyStruct', + derivation: 'structure', + variable: [ + { name: 'a', type: { definition: 'base-type', value: 'BOOL' }, initialValue: undefined }, + { name: 'b', type: { definition: 'base-type', value: 'INT' }, initialValue: { simpleValue: { value: '5' } } }, + ], + }, + ]) + }) + + it('parses an enumerated derivation with an initial value', () => { + const result = parseDataTypesXml({ + '@name': 'MyEnum', + baseType: { enum: { values: { value: [{ '@name': 'RED' }, { '@name': 'GREEN' }] } } }, + initialValue: { simpleValue: { '@value': 'RED' } }, + }) + expect(result).toEqual([ + { + name: 'MyEnum', + derivation: 'enumerated', + initialValue: 'RED', + values: [{ description: 'RED' }, { description: 'GREEN' }], + }, + ]) + }) + + it('parses an enumerated derivation without an initial value', () => { + const result = parseDataTypesXml({ + '@name': 'MyEnum2', + baseType: { enum: { values: { value: [{ '@name': 'A' }] } } }, + }) + expect(result[0].derivation).toBe('enumerated') + expect((result[0] as { initialValue?: string }).initialValue).toBeUndefined() + }) + + it('parses an array derivation', () => { + const result = parseDataTypesXml({ + '@name': 'MyArray', + baseType: { + array: { + dimension: [{ '@lower': '0', '@upper': '9' }], + baseType: { INT: '' }, + }, + }, + initialValue: { simpleValue: { '@value': '0' } }, + }) + expect(result).toEqual([ + { + name: 'MyArray', + derivation: 'array', + baseType: { definition: 'base-type', value: 'INT' }, + initialValue: '0', + dimensions: [{ dimension: '0..9' }], + }, + ]) + }) + + it('throws for an unrecognized derivation', () => { + expect(() => parseDataTypesXml({ '@name': 'Bad', baseType: {} })).toThrow( + 'Unrecognized dataType derivation for "Bad"', + ) + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/instances-xml.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/instances-xml.test.ts new file mode 100644 index 000000000..e39975264 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/__tests__/instances-xml.test.ts @@ -0,0 +1,57 @@ +import { parseConfigurationXml } from '../instances-xml' + +describe('parseConfigurationXml', () => { + it('parses a Cyclic task with instances', () => { + const result = parseConfigurationXml({ + configurations: { + configuration: { + resource: { + task: [ + { + '@name': 'task0', + '@priority': '0', + '@interval': 'T#20ms', + pouInstance: [{ '@name': 'inst0', '@typeName': 'main' }], + }, + ], + }, + }, + }, + }) + expect(result.resource.tasks).toEqual([{ name: 'task0', triggering: 'Cyclic', interval: 'T#20ms', priority: 0 }]) + expect(result.resource.instances).toEqual([{ name: 'inst0', task: 'task0', program: 'main' }]) + }) + + it('parses an Interrupt task (no @interval) with empty interval', () => { + const result = parseConfigurationXml({ + configurations: { configuration: { resource: { task: { '@name': 'irq', '@priority': '1' } } } }, + }) + expect(result.resource.tasks).toEqual([{ name: 'irq', triggering: 'Interrupt', interval: '', priority: 1 }]) + }) + + it('parses global variables', () => { + const result = parseConfigurationXml({ + configurations: { + configuration: { + resource: {}, + globalVars: { variable: [{ '@name': 'gvar', type: { BOOL: '' } }] }, + }, + }, + }) + expect(result.resource.globalVariables).toEqual([ + { + name: 'gvar', + class: 'global', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + initialValue: null, + documentation: '', + }, + ]) + }) + + it('defaults to empty tasks/instances/globalVariables when absent', () => { + const result = parseConfigurationXml({}) + expect(result).toEqual({ resource: { tasks: [], instances: [], globalVariables: [] } }) + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/parse-plcopen-xml.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/parse-plcopen-xml.test.ts new file mode 100644 index 000000000..1a41f2a29 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/__tests__/parse-plcopen-xml.test.ts @@ -0,0 +1,570 @@ +import { XmlGenerator } from '../../../../../backend/shared/utils/PLC/xml-generator' +import type { PLCProjectData } from '../../../../../middleware/shared/ports/open-plc-types' +import { parsePlcopenXml } from '../index' + +// --------------------------------------------------------------------------- +// Round-trip fixture: one program per language (ST, IL, LD, FBD), one data +// type per derivation, plus a task/instance/global-variable configuration. +// Built directly against the nested `PLCPou` shape `XmlGenerator` consumes +// (middleware/shared/ports/open-plc-types.ts) — the flat shape produced by +// `parsePlcopenXml` is a different (newer) representation, so equivalence is +// asserted field-by-field below rather than via a single deep-equal. +// --------------------------------------------------------------------------- + +const outHandle = { + id: 'output-variable', + type: 'source' as const, + position: 'right' as const, + glbPosition: { x: 80, y: 15 }, + relPosition: { x: 80, y: 15 }, +} +const inHandle = { + id: 'input-variable', + type: 'target' as const, + position: 'left' as const, + glbPosition: { x: 200, y: 15 }, + relPosition: { x: 0, y: 15 }, +} + +const fbdRung = { + comment: '', + selectedNodes: [], + nodes: [ + { + id: 'iv1', + type: 'input-variable', + position: { x: 0, y: 0 }, + width: 80, + height: 30, + data: { + numericId: '1', + executionOrder: 0, + negated: false, + variable: { name: 'X1' }, + handles: [outHandle], + inputHandles: [], + outputHandles: [outHandle], + outputConnector: outHandle, + draggable: true, + selectable: true, + deletable: true, + variant: 'input-variable', + }, + }, + { + id: 'ov1', + type: 'output-variable', + position: { x: 200, y: 0 }, + width: 80, + height: 30, + data: { + numericId: '2', + executionOrder: 1, + negated: false, + variable: { name: 'Y1' }, + handles: [inHandle], + inputHandles: [inHandle], + outputHandles: [], + inputConnector: inHandle, + draggable: true, + selectable: true, + deletable: true, + variant: 'output-variable', + }, + }, + ], + edges: [ + { + id: 'e1', + source: 'iv1', + sourceHandle: 'output-variable', + target: 'ov1', + targetHandle: 'input-variable', + type: 'smoothstep', + }, + ], +} + +const railOutHandle = { + id: 'left-rail', + type: 'source' as const, + position: 'right' as const, + glbPosition: { x: 20, y: 20 }, + relPosition: { x: 20, y: 20 }, +} +const contactInHandle = { + id: 'input', + type: 'target' as const, + position: 'left' as const, + glbPosition: { x: 50, y: 20 }, + relPosition: { x: 0, y: 20 }, +} +const contactOutHandle = { + id: 'output', + type: 'source' as const, + position: 'right' as const, + glbPosition: { x: 90, y: 20 }, + relPosition: { x: 40, y: 20 }, +} +const coilInHandle = { + id: 'input', + type: 'target' as const, + position: 'left' as const, + glbPosition: { x: 100, y: 20 }, + relPosition: { x: 0, y: 20 }, +} +const coilOutHandle = { + id: 'output', + type: 'source' as const, + position: 'right' as const, + glbPosition: { x: 140, y: 20 }, + relPosition: { x: 40, y: 20 }, +} +const railInHandle = { + id: 'right-rail', + type: 'target' as const, + position: 'left' as const, + glbPosition: { x: 150, y: 20 }, + relPosition: { x: 0, y: 20 }, +} + +const ladderRung = { + id: 'rung-0', + comment: '', + defaultBounds: [0, 0, 170, 40], + reactFlowViewport: [170, 40], + selectedNodes: [], + nodes: [ + { + id: 'lr1', + type: 'powerRail', + position: { x: 0, y: 0 }, + width: 20, + height: 40, + data: { + numericId: '1', + variable: { name: '' }, + executionOrder: 0, + handles: [railOutHandle], + inputHandles: [], + outputHandles: [railOutHandle], + outputConnector: railOutHandle, + draggable: true, + selectable: true, + deletable: true, + variant: 'left', + }, + }, + { + id: 'c1', + type: 'contact', + position: { x: 50, y: 0 }, + width: 40, + height: 40, + data: { + numericId: '2', + variable: { name: 'X1' }, + executionOrder: 0, + handles: [contactInHandle, contactOutHandle], + inputHandles: [contactInHandle], + outputHandles: [contactOutHandle], + inputConnector: contactInHandle, + outputConnector: contactOutHandle, + draggable: true, + selectable: true, + deletable: true, + variant: 'default', + }, + }, + { + id: 'co1', + type: 'coil', + position: { x: 100, y: 0 }, + width: 40, + height: 40, + data: { + numericId: '3', + variable: { name: 'Y1' }, + executionOrder: 0, + handles: [coilInHandle, coilOutHandle], + inputHandles: [coilInHandle], + outputHandles: [coilOutHandle], + inputConnector: coilInHandle, + outputConnector: coilOutHandle, + draggable: true, + selectable: true, + deletable: true, + variant: 'default', + }, + }, + { + id: 'rr1', + type: 'powerRail', + position: { x: 150, y: 0 }, + width: 20, + height: 40, + data: { + numericId: '4', + variable: { name: '' }, + executionOrder: 0, + handles: [railInHandle], + inputHandles: [railInHandle], + outputHandles: [], + inputConnector: railInHandle, + draggable: true, + selectable: true, + deletable: true, + variant: 'right', + }, + }, + ], + edges: [ + { id: 'e1', source: 'lr1', sourceHandle: 'left-rail', target: 'c1', targetHandle: 'input', type: 'smoothstep' }, + { id: 'e2', source: 'c1', sourceHandle: 'output', target: 'co1', targetHandle: 'input', type: 'smoothstep' }, + { id: 'e3', source: 'co1', sourceHandle: 'output', target: 'rr1', targetHandle: 'right-rail', type: 'smoothstep' }, + ], +} + +function makeFixture(): PLCProjectData { + return { + dataTypes: [ + { + name: 'MyStruct', + derivation: 'structure', + variable: [{ name: 'flag', type: { definition: 'base-type', value: 'BOOL' } }], + }, + { + name: 'MyEnum', + derivation: 'enumerated', + initialValue: 'RED', + values: [{ description: 'RED' }, { description: 'GREEN' }], + }, + { + name: 'MyArray', + derivation: 'array', + baseType: { definition: 'base-type', value: 'INT' }, + initialValue: '0', + dimensions: [{ dimension: '0..9' }], + }, + ], + pous: [ + { + type: 'program', + data: { + name: 'mainSt', + language: 'st', + variables: [ + { + name: 'a', + class: 'input', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + documentation: '', + }, + ], + body: { language: 'st', value: 'a := TRUE;' }, + documentation: 'ST program', + }, + }, + { + type: 'function-block', + data: { + name: 'mainIl', + language: 'il', + variables: [ + { + name: 'b', + class: 'local', + type: { definition: 'base-type', value: 'INT' }, + location: '', + documentation: '', + }, + ], + body: { language: 'il', value: 'LD 1' }, + documentation: '', + }, + }, + { + type: 'program', + data: { + name: 'mainLd', + language: 'ld', + variables: [ + { + name: 'X1', + class: 'input', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + documentation: '', + }, + { + name: 'Y1', + class: 'output', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + documentation: '', + }, + ], + body: { language: 'ld', value: { name: 'mainLd', rungs: [ladderRung] } }, + documentation: '', + }, + }, + { + type: 'program', + data: { + name: 'mainFbd', + language: 'fbd', + variables: [ + { + name: 'X1', + class: 'input', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + documentation: '', + }, + { + name: 'Y1', + class: 'output', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + documentation: '', + }, + ], + body: { language: 'fbd', value: { name: 'mainFbd', rung: fbdRung } }, + documentation: '', + }, + }, + ], + configuration: { + resource: { + tasks: [{ name: 'task0', triggering: 'Cyclic', interval: 'T#20ms', priority: 0 }], + instances: [{ name: 'inst0', task: 'task0', program: 'mainSt' }], + globalVariables: [ + { + name: 'gvar', + class: 'global', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + documentation: '', + }, + ], + }, + }, + } as unknown as PLCProjectData +} + +describe('parsePlcopenXml — round trip against XmlGenerator (old-editor)', () => { + const fixture = makeFixture() + const generated = XmlGenerator(fixture, 'old-editor') + const result = parsePlcopenXml(generated.data as string) + + it('generates successfully and produces no parse warnings', () => { + expect(generated.ok).toBe(true) + expect(result.warnings).toEqual([]) + }) + + it('recovers the hardcoded project name the generator always writes', () => { + expect(result.projectName).toBe('Unnamed') + }) + + it('recovers all three data type derivations', () => { + const byName = Object.fromEntries(result.projectData.dataTypes.map((d) => [d.name, d])) + expect(byName.MyStruct).toEqual({ + name: 'MyStruct', + derivation: 'structure', + variable: [{ name: 'flag', type: { definition: 'base-type', value: 'BOOL' }, initialValue: undefined }], + }) + expect(byName.MyEnum).toEqual({ + name: 'MyEnum', + derivation: 'enumerated', + initialValue: 'RED', + values: [{ description: 'RED' }, { description: 'GREEN' }], + }) + expect(byName.MyArray).toEqual({ + name: 'MyArray', + derivation: 'array', + baseType: { definition: 'base-type', value: 'INT' }, + initialValue: '0', + dimensions: [{ dimension: '0..9' }], + }) + }) + + it('recovers the ST program verbatim', () => { + const pou = result.projectData.pous.find((p) => p.name === 'mainSt') + expect(pou?.pouType).toBe('program') + expect(pou?.documentation).toBe('ST program') + expect(pou?.interface?.variables).toEqual([ + { + name: 'a', + class: 'input', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + initialValue: null, + documentation: '', + }, + ]) + expect(pou?.body).toEqual({ language: 'st', value: 'a := TRUE;' }) + }) + + it('recovers the IL function-block verbatim', () => { + const pou = result.projectData.pous.find((p) => p.name === 'mainIl') + expect(pou?.pouType).toBe('function-block') + expect(pou?.body).toEqual({ language: 'il', value: 'LD 1' }) + }) + + it('recovers the LD program rung: power rails, contact, coil, and their wiring', () => { + const pou = result.projectData.pous.find((p) => p.name === 'mainLd') + expect(pou?.body.language).toBe('ld') + const ldBody = pou?.body.value as { + name: string + updated: boolean + rungs: Array<{ nodes: unknown[]; edges: unknown[] }> + } + expect(ldBody.name).toBe('mainLd') + expect(ldBody.updated).toBe(false) + expect(ldBody.rungs).toHaveLength(1) + const rung = ldBody.rungs[0] + // Node order follows the raw XML's element-type grouping (leftPowerRail, + // rightPowerRail, contact, coil), not rung/visual position. + expect(rung.nodes.map((n) => (n as { id: string }).id).sort()).toEqual( + ['LEFT-POWER-RAIL-1', 'RIGHT-POWER-RAIL-4', 'CONTACT-2', 'COIL-3'].sort(), + ) + expect(rung.edges).toHaveLength(3) + const nodesById = new Map( + (rung.nodes as Array<{ id: string; data: { variable: { name: string } } }>).map((n) => [n.id, n]), + ) + const contactNode = nodesById.get('CONTACT-2') as { data: { variable: { name: string } } } + const coilNode = nodesById.get('COIL-3') as { data: { variable: { name: string } } } + expect(contactNode.data.variable).toEqual({ name: 'X1' }) + expect(coilNode.data.variable).toEqual({ name: 'Y1' }) + }) + + it('recovers the FBD program rung: input/output variable nodes and their edge', () => { + const pou = result.projectData.pous.find((p) => p.name === 'mainFbd') + expect(pou?.body.language).toBe('fbd') + const fbdBody = pou?.body.value as { + name: string + updated: boolean + rung: { nodes: unknown[]; edges: unknown[] } + } + expect(fbdBody.name).toBe('mainFbd') + expect(fbdBody.updated).toBe(false) + expect(fbdBody.rung.nodes).toHaveLength(2) + expect(fbdBody.rung.edges).toHaveLength(1) + const nodes = fbdBody.rung.nodes as Array<{ id: string; data: { variable: { name: string } } }> + expect(nodes.find((n) => n.id === 'INPUT-VARIABLE-1')?.data.variable).toEqual({ name: 'X1' }) + expect(nodes.find((n) => n.id === 'OUTPUT-VARIABLE-2')?.data.variable).toEqual({ name: 'Y1' }) + }) + + it('recovers the task/instance/global-variable configuration', () => { + const { resource } = result.projectData.configurations + expect(resource.tasks).toEqual([{ name: 'task0', triggering: 'Cyclic', interval: 'T#20ms', priority: 0 }]) + expect(resource.instances).toEqual([{ name: 'inst0', task: 'task0', program: 'mainSt' }]) + expect(resource.globalVariables).toEqual([ + { + name: 'gvar', + class: 'global', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + initialValue: null, + documentation: '', + }, + ]) + }) +}) + +describe('parsePlcopenXml — dialect scope', () => { + const baseXml = (bodyXml: string) => ` + + + + + + + + + ${bodyXml} + + + + + + + + + + +` + + it('produces a warning (does not throw) for an SFC body', () => { + const result = parsePlcopenXml(baseXml('')) + expect(result.projectData.pous).toEqual([]) + expect(result.warnings).toEqual([ + 'POU "unsupported": Sequential Function Chart is not supported by the importer, skipped', + ]) + }) + + it('produces a warning (does not throw) for a body shape outside the old-editor dialect', () => { + // Simulates a codesys-dialect (or otherwise foreign) body: none of ST/IL/LD/FBD/SFC. + const result = parsePlcopenXml(baseXml('')) + expect(result.projectData.pous).toEqual([]) + expect(result.warnings).toEqual(['POU "unsupported": no recognized body language found, skipped']) + }) + + it('recovers the real project name when the XML carries one', () => { + const result = parsePlcopenXml(baseXml('')) + expect(result.projectName).toBe('Test Project') + }) + + it('defaults projectName to "" when has no name attribute', () => { + const xml = `` + const result = parsePlcopenXml(xml) + expect(result.projectName).toBe('') + }) +}) + +describe('parsePlcopenXml — malformed connection reference', () => { + it('produces a warning (does not crash) for a that does not exist', () => { + const xml = ` + + + + + + + + + + + + + + + + + Y1 + + + + + + + + + + + + + +` + + expect(() => parsePlcopenXml(xml)).not.toThrow() + const result = parsePlcopenXml(xml) + const pou = result.projectData.pous.find((p) => p.name === 'dangling') + const fbdBody = pou?.body.value as { rung: { edges: unknown[] } } + expect(fbdBody.rung.edges).toEqual([]) + expect(result.warnings).toEqual([ + 'POU "dangling": FBD connection references unknown localId "doesnotexist", skipped', + ]) + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/parse-xml-document.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/parse-xml-document.test.ts new file mode 100644 index 000000000..3ed9a0036 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/__tests__/parse-xml-document.test.ts @@ -0,0 +1,19 @@ +import { parseXmlDocument } from '../parse-xml-document' + +describe('parseXmlDocument', () => { + it('parses a minimal project document', () => { + const xml = `` + const result = parseXmlDocument(xml) + expect(result).toHaveProperty('contentHeader') + }) + + it('throws when the root element is missing', () => { + expect(() => parseXmlDocument('')).toThrow( + 'Invalid PLCopen XML: missing root element', + ) + }) + + it('throws for empty/garbage input', () => { + expect(() => parseXmlDocument('')).toThrow('Invalid PLCopen XML: missing root element') + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/pou-xml.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/pou-xml.test.ts new file mode 100644 index 000000000..84d00e0db --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/__tests__/pou-xml.test.ts @@ -0,0 +1,144 @@ +import { parseInterfaceXml, parsePousXml } from '../pou-xml' + +describe('parseInterfaceXml', () => { + it('returns an empty variables array with no returnType when interface is empty', () => { + expect(parseInterfaceXml({})).toEqual({ variables: [] }) + }) + + it('categorizes variables by group', () => { + const result = parseInterfaceXml({ + inputVars: { variable: [{ '@name': 'a', type: { BOOL: '' } }] }, + outputVars: { variable: [{ '@name': 'b', type: { BOOL: '' } }] }, + inOutVars: { variable: [{ '@name': 'c', type: { BOOL: '' } }] }, + externalVars: { variable: [{ '@name': 'd', type: { BOOL: '' } }] }, + localVars: { variable: [{ '@name': 'e', type: { BOOL: '' } }] }, + tempVars: { variable: [{ '@name': 'f', type: { BOOL: '' } }] }, + }) + expect(result.variables.map((v) => [v.name, v.class])).toEqual([ + ['a', 'input'], + ['b', 'output'], + ['c', 'inOut'], + ['d', 'external'], + ['e', 'local'], + ['f', 'temp'], + ]) + }) + + it('parses a base-type returnType', () => { + const result = parseInterfaceXml({ returnType: { INT: '' } }) + expect(result.returnType).toBe('INT') + }) + + it('parses a derived returnType', () => { + const result = parseInterfaceXml({ returnType: { derived: { '@name': 'MyType' } } }) + expect(result.returnType).toBe('MyType') + }) +}) + +describe('parsePousXml', () => { + it('parses an ST program with documentation', () => { + const { pous, warnings } = parsePousXml([ + { + '@name': 'main', + '@pouType': 'program', + interface: {}, + documentation: { 'xhtml:p': 'A program' }, + body: { ST: { 'xhtml:p': 'x := 1;' } }, + }, + ]) + expect(warnings).toEqual([]) + expect(pous).toEqual([ + { + name: 'main', + pouType: 'program', + interface: { variables: [] }, + body: { language: 'st', value: 'x := 1;' }, + documentation: 'A program', + }, + ]) + }) + + it('parses an IL function-block', () => { + const { pous } = parsePousXml({ + '@name': 'fb1', + '@pouType': 'functionBlock', + interface: {}, + documentation: { 'xhtml:p': ' ' }, + body: { IL: { 'xhtml:p': 'LD 1' } }, + }) + expect(pous[0].pouType).toBe('function-block') + expect(pous[0].body).toEqual({ language: 'il', value: 'LD 1' }) + expect(pous[0].documentation).toBe('') + }) + + it('parses a function with a returnType', () => { + const { pous } = parsePousXml({ + '@name': 'f1', + '@pouType': 'function', + interface: { returnType: { BOOL: '' } }, + documentation: '', + body: { ST: { 'xhtml:p': 'f1 := TRUE;' } }, + }) + expect(pous[0].interface?.returnType).toBe('BOOL') + }) + + it('parses an LD body', () => { + const { pous, warnings } = parsePousXml({ + '@name': 'ld1', + '@pouType': 'program', + interface: {}, + documentation: '', + body: { LD: {} }, + }) + expect(warnings).toEqual([]) + expect(pous[0].body.language).toBe('ld') + expect(pous[0].body.value).toEqual({ name: 'ld1', updated: false, rungs: [] }) + }) + + it('parses an FBD body', () => { + const { pous, warnings } = parsePousXml({ + '@name': 'fbd1', + '@pouType': 'program', + interface: {}, + documentation: '', + body: { FBD: {} }, + }) + expect(warnings).toEqual([]) + expect(pous[0].body.language).toBe('fbd') + expect(pous[0].body.value).toEqual({ + name: 'fbd1', + updated: false, + rung: { comment: '', nodes: [], edges: [], selectedNodes: [] }, + }) + }) + + it('warns and skips an SFC body', () => { + const { pous, warnings } = parsePousXml({ + '@name': 'sfc1', + '@pouType': 'program', + interface: {}, + documentation: '', + body: { SFC: {} }, + }) + expect(pous).toEqual([]) + expect(warnings).toEqual(['POU "sfc1": Sequential Function Chart is not supported by the importer, skipped']) + }) + + it('warns and skips a POU with an unrecognized pouType', () => { + const { pous, warnings } = parsePousXml({ '@name': 'bad1', '@pouType': 'weird', body: {} }) + expect(pous).toEqual([]) + expect(warnings).toEqual(['POU "bad1": unrecognized pouType "weird", skipped']) + }) + + it('warns and skips a POU with no recognized body language', () => { + const { pous, warnings } = parsePousXml({ + '@name': 'nobody', + '@pouType': 'program', + interface: {}, + documentation: '', + body: {}, + }) + expect(pous).toEqual([]) + expect(warnings).toEqual(['POU "nobody": no recognized body language found, skipped']) + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/type-xml.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/type-xml.test.ts new file mode 100644 index 000000000..1a992c59d --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/__tests__/type-xml.test.ts @@ -0,0 +1,76 @@ +import { parseBaseTypeLeaf, parseDimensionsXml, parseTypeXml } from '../type-xml' + +describe('parseTypeXml', () => { + it('parses a base-type element (uppercase tag)', () => { + expect(parseTypeXml({ INT: '' })).toEqual({ definition: 'base-type', value: 'INT' }) + }) + + it('parses a base-type element (lowercase string/wstring tag)', () => { + expect(parseTypeXml({ string: '' })).toEqual({ definition: 'base-type', value: 'STRING' }) + }) + + it('falls back to the raw tag when it is not a recognized IEC element', () => { + expect(parseTypeXml({ CustomTag: '' })).toEqual({ definition: 'base-type', value: 'CustomTag' }) + }) + + it('parses a derived element', () => { + expect(parseTypeXml({ derived: { '@name': 'MyType' } })).toEqual({ definition: 'derived', value: 'MyType' }) + }) + + it('parses an array element with a base-type element type', () => { + const result = parseTypeXml({ + array: { + dimension: [{ '@lower': '0', '@upper': '9' }], + baseType: { INT: '' }, + }, + }) + expect(result).toEqual({ + definition: 'array', + value: 'ARRAY[0..9] OF INT', + data: { baseType: { definition: 'base-type', value: 'INT' }, dimensions: [{ dimension: '0..9' }] }, + }) + }) + + it('parses an array element with a derived element type', () => { + const result = parseTypeXml({ + array: { + dimension: [{ '@lower': '0', '@upper': '3' }], + baseType: { derived: { '@name': 'MyStruct' } }, + }, + }) + expect(result.data?.baseType).toEqual({ definition: 'user-data-type', value: 'MyStruct' }) + }) + + it('throws when the type element is empty', () => { + expect(() => parseTypeXml({})).toThrow('Variable type element is empty') + }) +}) + +describe('parseBaseTypeLeaf', () => { + it('parses a derived leaf', () => { + expect(parseBaseTypeLeaf({ derived: { '@name': 'Foo' } })).toEqual({ definition: 'user-data-type', value: 'Foo' }) + }) + + it('parses a base-type leaf', () => { + expect(parseBaseTypeLeaf({ BOOL: '' })).toEqual({ definition: 'base-type', value: 'BOOL' }) + }) + + it('throws when the leaf has no recognizable base type', () => { + expect(() => parseBaseTypeLeaf({})).toThrow('Type element has no recognizable base type') + }) +}) + +describe('parseDimensionsXml', () => { + it('parses a single dimension object (not yet an array)', () => { + expect(parseDimensionsXml({ '@lower': '1', '@upper': '5' })).toEqual([{ dimension: '1..5' }]) + }) + + it('parses multiple dimensions', () => { + expect( + parseDimensionsXml([ + { '@lower': '0', '@upper': '1' }, + { '@lower': '2', '@upper': '3' }, + ]), + ).toEqual([{ dimension: '0..1' }, { dimension: '2..3' }]) + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/variable-xml.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/variable-xml.test.ts new file mode 100644 index 000000000..4383757c3 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/__tests__/variable-xml.test.ts @@ -0,0 +1,59 @@ +import { extractXhtmlText, parseDocumentationXml, parseVariableXml } from '../variable-xml' + +describe('extractXhtmlText', () => { + it('reads a plain string xhtml:p (no attributes)', () => { + expect(extractXhtmlText({ 'xhtml:p': 'hello' })).toBe('hello') + }) + + it('reads the $ text node when xhtml:p has attributes', () => { + expect(extractXhtmlText({ 'xhtml:p': { $: 'hello' } })).toBe('hello') + }) + + it('returns "" when there is no xhtml:p', () => { + expect(extractXhtmlText({})).toBe('') + }) + + it('returns "" when the $ text node is missing/non-string', () => { + expect(extractXhtmlText({ 'xhtml:p': {} })).toBe('') + }) +}) + +describe('parseDocumentationXml', () => { + it('un-placeholders the single-space convention back to ""', () => { + expect(parseDocumentationXml({ 'xhtml:p': ' ' })).toBe('') + }) + + it('passes through real documentation text', () => { + expect(parseDocumentationXml({ 'xhtml:p': 'A comment' })).toBe('A comment') + }) +}) + +describe('parseVariableXml', () => { + it('parses a fully-populated variable', () => { + const result = parseVariableXml( + { + '@name': 'x', + '@address': '%IX0.0', + type: { BOOL: '' }, + initialValue: { simpleValue: { '@value': 'TRUE' } }, + documentation: { 'xhtml:p': 'A var' }, + }, + 'input', + ) + expect(result).toEqual({ + name: 'x', + class: 'input', + type: { definition: 'base-type', value: 'BOOL' }, + location: '%IX0.0', + initialValue: 'TRUE', + documentation: 'A var', + }) + }) + + it('defaults name to "", location to "", initialValue to null when absent', () => { + const result = parseVariableXml({ type: { INT: '' } }, 'local') + expect(result.name).toBe('') + expect(result.location).toBe('') + expect(result.initialValue).toBeNull() + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/xml-node.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/xml-node.test.ts new file mode 100644 index 000000000..84ed03916 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/__tests__/xml-node.test.ts @@ -0,0 +1,41 @@ +import { asArray, asRecord, asString } from '../xml-node' + +describe('asRecord', () => { + it('returns the object when given an object', () => { + expect(asRecord({ a: 1 })).toEqual({ a: 1 }) + }) + + it('returns {} for non-object values', () => { + expect(asRecord('')).toEqual({}) + expect(asRecord(null)).toEqual({}) + expect(asRecord(undefined)).toEqual({}) + expect(asRecord(42)).toEqual({}) + }) +}) + +describe('asArray', () => { + it('returns [] for undefined', () => { + expect(asArray(undefined)).toEqual([]) + }) + + it('wraps a bare value in an array', () => { + expect(asArray({ a: 1 })).toEqual([{ a: 1 }]) + }) + + it('returns the array unchanged when already an array', () => { + expect(asArray([1, 2, 3])).toEqual([1, 2, 3]) + }) +}) + +describe('asString', () => { + it('returns the string when given a string', () => { + expect(asString('hello')).toBe('hello') + }) + + it('returns "" for non-string values', () => { + expect(asString(42)).toBe('') + expect(asString(undefined)).toBe('') + expect(asString(null)).toBe('') + expect(asString({})).toBe('') + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/data-type-xml.ts b/src/frontend/utils/PLC/xml-parser/data-type-xml.ts new file mode 100644 index 000000000..fcb8b13b1 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/data-type-xml.ts @@ -0,0 +1,57 @@ +import type { PLCDataType, PLCStructureVariable } from '../../../../middleware/shared/ports/types' +import { parseBaseTypeLeaf, parseDimensionsXml, parseTypeXml } from './type-xml' +import { asArray, asRecord, asString } from './xml-node' + +function parseSimpleInitialValue(xml: unknown): string | undefined { + const simpleValue = asRecord(asRecord(xml).simpleValue) + const value = simpleValue['@value'] + return typeof value === 'string' ? value : undefined +} + +function parseStructInitialValue(xml: unknown): { simpleValue: { value: string } } | undefined { + const value = parseSimpleInitialValue(xml) + return value === undefined ? undefined : { simpleValue: { value } } +} + +// Reverse of `oldEditorParseDataTypesToXML` (xml-generator/old-editor/data-type-xml.ts). +export function parseDataTypesXml(dataTypeXml: unknown): PLCDataType[] { + return asArray(dataTypeXml).map((entryRaw) => { + const entry = asRecord(entryRaw) + const name = asString(entry['@name']) + const baseType = asRecord(entry.baseType) + + if ('struct' in baseType) { + const structXml = asRecord(baseType.struct) + const variable: PLCStructureVariable[] = asArray(structXml.variable).map((vRaw) => { + const v = asRecord(vRaw) + return { + name: asString(v['@name']), + type: parseTypeXml(v.type), + initialValue: parseStructInitialValue(v.initialValue), + } + }) + return { name, derivation: 'structure', variable } + } + + if ('enum' in baseType) { + const valuesXml = asRecord(asRecord(baseType.enum).values) + const values = asArray(valuesXml.value).map((v) => ({ description: asString(asRecord(v)['@name']) })) + return { name, derivation: 'enumerated', initialValue: parseSimpleInitialValue(entry.initialValue), values } + } + + if ('array' in baseType) { + const arrayXml = asRecord(baseType.array) + const elementBaseType = parseBaseTypeLeaf(arrayXml.baseType) + const dimensions = parseDimensionsXml(arrayXml.dimension) + return { + name, + derivation: 'array', + baseType: elementBaseType, + initialValue: parseSimpleInitialValue(entry.initialValue), + dimensions, + } + } + + throw new Error(`Unrecognized dataType derivation for "${name}"`) + }) +} diff --git a/src/frontend/utils/PLC/xml-parser/index.ts b/src/frontend/utils/PLC/xml-parser/index.ts new file mode 100644 index 000000000..079bc802e --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/index.ts @@ -0,0 +1,52 @@ +import type { PLCDataType, PLCInstance, PLCPou, PLCTask, PLCVariable } from '../../../../middleware/shared/ports/types' +import { parseDataTypesXml } from './data-type-xml' +import { parseConfigurationXml } from './instances-xml' +import { parseXmlDocument } from './parse-xml-document' +import { parsePousXml } from './pou-xml' +import { asRecord, asString } from './xml-node' + +// Structurally matches `ParsedProjectData['projectData']` +// (backend/shared/utils/parse-project-files.ts) minus the fields PLCopen XML +// has no representation for (servers, remoteDevices, libraryManifest, +// debugVariables) — `libraries` is filled with `[]` since it's a required +// field on that target shape and bundled/canonical libraries are always-on +// regardless of an explicit enablement list. +export interface ParsedPlcopenProjectData { + dataTypes: PLCDataType[] + pous: PLCPou[] + configurations: { + resource: { + tasks: PLCTask[] + instances: PLCInstance[] + globalVariables: PLCVariable[] + } + } + libraries: { name: string; version: string }[] +} + +export interface PlcopenParseResult { + projectData: ParsedPlcopenProjectData + warnings: string[] + // — the generator never sets this to anything + // but the hardcoded 'Unnamed' (it takes no project-name input), so this is + // only ever meaningful for XML from another tool (e.g. u-Create). '' when + // absent; callers decide what placeholder to fall back to. + projectName: string +} + +// Parses PLCopen TC6-0201 XML into the same project-data shape +// `XmlGenerator` consumes — the inverse of that pipeline +// (xml-generator/old-editor/*.ts). Only the `old-editor` dialect shape is +// handled today: SFC bodies and anything the codesys dialect emits surface +// as non-fatal warnings rather than being parsed. +export function parsePlcopenXml(xml: string): PlcopenParseResult { + const project = parseXmlDocument(xml) + const types = asRecord(project.types) + + const dataTypes = parseDataTypesXml(asRecord(types.dataTypes).dataType) + const { pous, warnings } = parsePousXml(asRecord(types.pous).pou) + const configurations = parseConfigurationXml(project.instances) + const projectName = asString(asRecord(project.contentHeader)['@name']) + + return { projectData: { dataTypes, pous, configurations, libraries: [] }, warnings, projectName } +} diff --git a/src/frontend/utils/PLC/xml-parser/instances-xml.ts b/src/frontend/utils/PLC/xml-parser/instances-xml.ts new file mode 100644 index 000000000..131da3e7f --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/instances-xml.ts @@ -0,0 +1,47 @@ +import type { PLCInstance, PLCTask, PLCVariable } from '../../../../middleware/shared/ports/types' +import { parseVariableXml } from './variable-xml' +import { asArray, asRecord, asString } from './xml-node' + +export interface ParsedConfiguration { + resource: { + tasks: PLCTask[] + instances: PLCInstance[] + globalVariables: PLCVariable[] + } +} + +// Reverse of `oldEditorInstanceToXml` (xml-generator/old-editor/instances-xml.ts). +// An Interrupt task has no `@interval` in the XML (only Cyclic tasks do), so +// its original interval value can't be recovered here — it comes back as ''. +export function parseConfigurationXml(instancesXml: unknown): ParsedConfiguration { + const configuration = asRecord(asRecord(asRecord(instancesXml).configurations).configuration) + const resource = asRecord(configuration.resource) + + const tasks: PLCTask[] = [] + const instances: PLCInstance[] = [] + + for (const taskXmlRaw of asArray(resource.task)) { + const taskXml = asRecord(taskXmlRaw) + const name = asString(taskXml['@name']) + const interval = taskXml['@interval'] + const triggering: 'Cyclic' | 'Interrupt' = typeof interval === 'string' ? 'Cyclic' : 'Interrupt' + + tasks.push({ + name, + triggering, + interval: typeof interval === 'string' ? interval : '', + priority: Number(asString(taskXml['@priority'])), + }) + + for (const poRaw of asArray(taskXml.pouInstance)) { + const po = asRecord(poRaw) + instances.push({ name: asString(po['@name']), task: name, program: asString(po['@typeName']) }) + } + } + + const globalVariables: PLCVariable[] = asArray(asRecord(configuration.globalVars).variable).map((v) => + parseVariableXml(v, 'global'), + ) + + return { resource: { tasks, instances, globalVariables } } +} diff --git a/src/frontend/utils/PLC/xml-parser/language/__tests__/fbd-xml.test.ts b/src/frontend/utils/PLC/xml-parser/language/__tests__/fbd-xml.test.ts new file mode 100644 index 000000000..dac33eb43 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/language/__tests__/fbd-xml.test.ts @@ -0,0 +1,241 @@ +import { BlockNode } from '@root/frontend/components/_atoms/graphical-editor/fbd/block' +import type { VariableNode } from '@root/frontend/components/_atoms/graphical-editor/fbd/utils/types' +import type { BlockVariant } from '@root/frontend/components/_atoms/graphical-editor/types/block' + +import { parseFbdXml } from '../fbd-xml' + +describe('parseFbdXml', () => { + it('returns an empty rung for an empty FBD body', () => { + const { body, warnings } = parseFbdXml('empty', {}) + expect(warnings).toEqual([]) + expect(body).toEqual({ + name: 'empty', + updated: false, + rung: { comment: '', nodes: [], edges: [], selectedNodes: [] }, + }) + }) + + it('parses an input-variable node', () => { + const { body } = parseFbdXml('p', { + inVariable: [ + { + '@localId': '1', + '@executionOrderId': '0', + '@width': '80', + '@height': '30', + '@negated': 'false', + position: { '@x': '0', '@y': '0' }, + connectionPointOut: { relPosition: { '@x': '80', '@y': '15' } }, + expression: 'X1', + }, + ], + }) + const node = body.rung.nodes[0] as VariableNode + expect(node.id).toBe('INPUT-VARIABLE-1') + expect(node.type).toBe('input-variable') + expect(node.data.variable).toEqual({ name: 'X1' }) + expect(node.data.negated).toBe(false) + expect(node.data.outputHandles[0].id).toBe('output-variable') + }) + + it('parses an output-variable node and resolves its connection into an edge', () => { + const { body, warnings } = parseFbdXml('p', { + inVariable: [ + { + '@localId': '1', + '@executionOrderId': '0', + '@width': '80', + '@height': '30', + '@negated': 'false', + position: { '@x': '0', '@y': '0' }, + connectionPointOut: { relPosition: { '@x': '80', '@y': '15' } }, + expression: 'X1', + }, + ], + outVariable: [ + { + '@localId': '2', + '@executionOrderId': '1', + '@width': '80', + '@height': '30', + '@negated': 'true', + position: { '@x': '200', '@y': '0' }, + connectionPointIn: { + relPosition: { '@x': '0', '@y': '15' }, + connection: [{ '@refLocalId': '1' }], + }, + expression: 'Y1', + }, + ], + }) + expect(warnings).toEqual([]) + expect(body.rung.nodes).toHaveLength(2) + expect(body.rung.edges).toEqual([ + { + id: 'xy-edge__INPUT-VARIABLE-1output-variable-OUTPUT-VARIABLE-2input-variable', + source: 'INPUT-VARIABLE-1', + sourceHandle: 'output-variable', + target: 'OUTPUT-VARIABLE-2', + targetHandle: 'input-variable', + type: 'smoothstep', + }, + ]) + const outNode = body.rung.nodes[1] + expect(outNode.data.negated).toBe(true) + }) + + it('parses a block with a function-block instance name and deduped input handles', () => { + const { body } = parseFbdXml('p', { + block: [ + { + '@localId': '3', + '@typeName': 'TON', + '@instanceName': 'ton1', + '@executionOrderId': '2', + '@width': '100', + '@height': '60', + position: { '@x': '50', '@y': '50' }, + inputVariables: { + variable: [ + { + '@formalParameter': 'IN', + connectionPointIn: { relPosition: { '@x': '0', '@y': '10' }, connection: [{ '@refLocalId': '1' }] }, + }, + { + // Same formalParameter, second incoming edge — must be deduped to one handle. + '@formalParameter': 'IN', + connectionPointIn: { relPosition: { '@x': '0', '@y': '10' }, connection: [{ '@refLocalId': '2' }] }, + }, + ], + }, + outputVariables: { + variable: [{ '@formalParameter': 'Q', connectionPointOut: { relPosition: { '@x': '100', '@y': '10' } } }], + }, + }, + ], + }) + const node = body.rung.nodes[0] as BlockNode + expect(node.id).toBe('BLOCK-3') + expect(node.data.inputHandles).toHaveLength(1) + expect(node.data.variable).toEqual({ name: 'ton1' }) + expect(node.data.variant.type).toBe('function-block') + }) + + it('parses a block that is a plain function call (no @instanceName)', () => { + const { body } = parseFbdXml('p', { + block: [ + { + '@localId': '4', + '@typeName': 'ADD', + '@executionOrderId': '0', + '@width': '60', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + inputVariables: '', + outputVariables: '', + }, + ], + }) + const node = body.rung.nodes[0] as BlockNode + expect(node.data.variable).toEqual({ name: 'ADD' }) + expect(node.data.variant.type).toBe('function') + }) + + it('parses a connector/continuation pair', () => { + const { body } = parseFbdXml('p', { + connector: [ + { + '@name': 'c1', + '@localId': '5', + '@width': '40', + '@height': '20', + position: { '@x': '0', '@y': '0' }, + connectionPointIn: { relPosition: { '@x': '0', '@y': '10' }, connection: [{ '@refLocalId': '1' }] }, + }, + ], + continuation: [ + { + '@name': 'c1', + '@localId': '6', + '@width': '40', + '@height': '20', + position: { '@x': '100', '@y': '0' }, + connectionPointOut: { relPosition: { '@x': '40', '@y': '10' } }, + }, + ], + inVariable: [ + { + '@localId': '1', + '@executionOrderId': '0', + '@width': '80', + '@height': '30', + '@negated': 'false', + position: { '@x': '0', '@y': '0' }, + connectionPointOut: { relPosition: { '@x': '80', '@y': '15' } }, + expression: 'X1', + }, + ], + }) + const connector = body.rung.nodes.find((n) => n.type === 'connector') + const continuation = body.rung.nodes.find((n) => n.type === 'continuation') + expect(connector?.data.variable).toEqual({ name: 'c1' }) + expect(continuation?.data.variable).toEqual({ name: 'c1' }) + }) + + it('un-placeholders "No comment provided" back to an empty string', () => { + const { body } = parseFbdXml('p', { + comment: [ + { + '@localId': '7', + '@width': '100', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + content: { 'xhtml:p': 'No comment provided' }, + }, + ], + }) + expect(body.rung.nodes[0].data.content).toBe('') + }) + + it('keeps real comment text', () => { + const { body } = parseFbdXml('p', { + comment: [ + { + '@localId': '8', + '@width': '100', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + content: { 'xhtml:p': 'Real comment' }, + }, + ], + }) + expect(body.rung.nodes[0].data.content).toBe('Real comment') + }) + + it('warns (non-fatally) about inOutVariable nodes', () => { + const { warnings } = parseFbdXml('p', { inOutVariable: [{}] }) + expect(warnings).toEqual(['POU "p": 1 FBD inOutVariable node(s) are not supported, skipped']) + }) + + it('warns (non-fatally) about a dangling connection reference', () => { + const { body, warnings } = parseFbdXml('p', { + outVariable: [ + { + '@localId': '9', + '@executionOrderId': '0', + '@width': '80', + '@height': '30', + '@negated': 'false', + position: { '@x': '0', '@y': '0' }, + connectionPointIn: { + relPosition: { '@x': '0', '@y': '15' }, + connection: [{ '@refLocalId': 'doesnotexist' }], + }, + expression: 'Y1', + }, + ], + }) + expect(body.rung.edges).toEqual([]) + expect(warnings).toEqual(['POU "p": FBD connection references unknown localId "doesnotexist", skipped']) + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/language/__tests__/geometry.test.ts b/src/frontend/utils/PLC/xml-parser/language/__tests__/geometry.test.ts new file mode 100644 index 000000000..9a06cbbca --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/language/__tests__/geometry.test.ts @@ -0,0 +1,42 @@ +import { Position } from '@xyflow/react' + +import { makeHandle, parsePositionXml, toNumber } from '../geometry' + +describe('toNumber', () => { + it('parses a numeric string', () => { + expect(toNumber('42')).toBe(42) + }) + + it('falls back to 0 by default for non-numeric input', () => { + expect(toNumber('not-a-number')).toBe(0) + }) + + it('falls back to a custom fallback value for genuinely non-numeric input', () => { + // Number('') is 0 (finite), not NaN — the fallback only kicks in for a + // non-empty, non-numeric string, matching the reference's own behavior. + expect(toNumber('not-a-number', -1)).toBe(-1) + }) +}) + +describe('parsePositionXml', () => { + it('parses @x/@y attributes', () => { + expect(parsePositionXml({ '@x': '10', '@y': '20' })).toEqual({ x: 10, y: 20 }) + }) + + it('defaults to {x:0,y:0} when absent', () => { + expect(parsePositionXml({})).toEqual({ x: 0, y: 0 }) + }) +}) + +describe('makeHandle', () => { + it('builds a handle with glbPosition = nodePosition + relPosition', () => { + const handle = makeHandle('input', 'target', Position.Left, { x: 100, y: 50 }, { '@x': '5', '@y': '10' }) + expect(handle).toEqual({ + id: 'input', + type: 'target', + position: Position.Left, + relPosition: { x: 5, y: 10 }, + glbPosition: { x: 105, y: 60 }, + }) + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/language/__tests__/ladder-xml.test.ts b/src/frontend/utils/PLC/xml-parser/language/__tests__/ladder-xml.test.ts new file mode 100644 index 000000000..4aa7d400c --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/language/__tests__/ladder-xml.test.ts @@ -0,0 +1,308 @@ +import type { BlockNode, BlockVariant } from '@root/frontend/components/_atoms/graphical-editor/ladder/utils/types' + +import { parseLadderXml } from '../ladder-xml' + +describe('parseLadderXml', () => { + it('returns no rungs for an empty LD body', () => { + const { body, warnings } = parseLadderXml('empty', {}) + expect(warnings).toEqual([]) + expect(body).toEqual({ name: 'empty', updated: false, rungs: [] }) + }) + + it('reconstructs a single rung: left rail -> contact -> coil -> right rail', () => { + const { body, warnings } = parseLadderXml('rung1', { + leftPowerRail: [ + { + '@localId': '1', + '@width': '20', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + connectionPointOut: { relPosition: { '@x': '20', '@y': '20' } }, + }, + ], + contact: [ + { + '@localId': '2', + '@negated': 'false', + '@width': '40', + '@height': '40', + position: { '@x': '50', '@y': '0' }, + connectionPointIn: { + relPosition: { '@x': '0', '@y': '20' }, + connection: [{ '@refLocalId': '1', '@formalParameter': 'left-rail' }], + }, + connectionPointOut: { relPosition: { '@x': '40', '@y': '20' } }, + variable: ['X1'], + }, + ], + coil: [ + { + '@localId': '3', + '@negated': 'false', + '@width': '40', + '@height': '40', + position: { '@x': '100', '@y': '0' }, + connectionPointIn: { + relPosition: { '@x': '0', '@y': '20' }, + connection: [{ '@refLocalId': '2', '@formalParameter': 'output' }], + }, + connectionPointOut: { relPosition: { '@x': '40', '@y': '20' } }, + variable: ['Y1'], + }, + ], + rightPowerRail: [ + { + '@localId': '4', + '@width': '20', + '@height': '40', + position: { '@x': '150', '@y': '0' }, + connectionPointIn: { + relPosition: { '@x': '0', '@y': '20' }, + connection: [{ '@refLocalId': '3', '@formalParameter': 'output' }], + }, + }, + ], + }) + + expect(warnings).toEqual([]) + expect(body.rungs).toHaveLength(1) + const rung = body.rungs[0] + // Node order within a rung follows the raw XML's element-type grouping + // (leftPowerRail, rightPowerRail, contact, coil, ...) — see + // parseLadderXml's node-collection loop — not rung/visual position. + expect(rung.nodes.map((n) => n.id)).toEqual(['LEFT-POWER-RAIL-1', 'RIGHT-POWER-RAIL-4', 'CONTACT-2', 'COIL-3']) + // Edge order follows pendingEdges collection order (grouped by the + // consuming node's XML element type), not visual left-to-right order — + // compare as a set of {source,target} pairs instead of an exact sequence. + expect(rung.edges).toHaveLength(3) + expect(rung.edges.map((e) => `${e.source}->${e.target}`).sort()).toEqual( + ['LEFT-POWER-RAIL-1->CONTACT-2', 'CONTACT-2->COIL-3', 'COIL-3->RIGHT-POWER-RAIL-4'].sort(), + ) + expect(rung.edges.every((e) => e.type === 'smoothstep')).toBe(true) + expect((rung.nodes[2].data as { variable: { name: string } }).variable).toEqual({ name: 'X1' }) + expect((rung.nodes[3].data as { variable: { name: string } }).variable).toEqual({ name: 'Y1' }) + expect(rung.defaultBounds).toEqual([0, 0, 170, 40]) + expect(rung.reactFlowViewport).toEqual([170, 40]) + }) + + it('partitions disconnected nodes into separate rungs', () => { + const { body } = parseLadderXml('tworungs', { + leftPowerRail: [ + { + '@localId': '1', + '@width': '20', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + connectionPointOut: { relPosition: { '@x': '20', '@y': '20' } }, + }, + { + '@localId': '2', + '@width': '20', + '@height': '40', + position: { '@x': '0', '@y': '100' }, + connectionPointOut: { relPosition: { '@x': '20', '@y': '20' } }, + }, + ], + }) + expect(body.rungs).toHaveLength(2) + }) + + it('parses coil variants: negated, rising edge, falling edge, set, reset', () => { + const makeCoil = (localId: string, attrs: Record) => ({ + '@localId': localId, + '@width': '40', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + connectionPointIn: { relPosition: { '@x': '0', '@y': '20' } }, + connectionPointOut: { relPosition: { '@x': '40', '@y': '20' } }, + variable: ['Y'], + ...attrs, + }) + const { body } = parseLadderXml('p', { + coil: [ + makeCoil('1', { '@negated': 'true' }), + makeCoil('2', { '@edge': 'rising' }), + makeCoil('3', { '@edge': 'falling' }), + makeCoil('4', { '@storage': 'set' }), + makeCoil('5', { '@storage': 'reset' }), + makeCoil('6', {}), + ], + }) + const variants = body.rungs.flatMap((r) => r.nodes).map((n) => (n.data as { variant: string }).variant) + expect(variants).toEqual(['negated', 'risingEdge', 'fallingEdge', 'set', 'reset', 'default']) + }) + + it('parses contact variants: negated, rising edge, falling edge, default', () => { + const makeContact = (localId: string, attrs: Record) => ({ + '@localId': localId, + '@width': '40', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + connectionPointIn: { relPosition: { '@x': '0', '@y': '20' } }, + connectionPointOut: { relPosition: { '@x': '40', '@y': '20' } }, + variable: ['X'], + ...attrs, + }) + const { body } = parseLadderXml('p', { + contact: [ + makeContact('1', { '@negated': 'true' }), + makeContact('2', { '@edge': 'rising' }), + makeContact('3', { '@edge': 'falling' }), + makeContact('4', {}), + ], + }) + const variants = body.rungs.flatMap((r) => r.nodes).map((n) => (n.data as { variant: string }).variant) + expect(variants).toEqual(['negated', 'risingEdge', 'fallingEdge', 'default']) + }) + + it('parses a function-block instance and a plain function call', () => { + const { body } = parseLadderXml('p', { + block: [ + { + '@localId': '1', + '@typeName': 'TON', + '@instanceName': 'ton1', + '@executionOrderId': '0', + '@width': '100', + '@height': '60', + position: { '@x': '0', '@y': '0' }, + inputVariables: { + variable: [{ '@formalParameter': 'IN', connectionPointIn: { relPosition: { '@x': '0', '@y': '10' } } }], + }, + outputVariables: { + // Unnamed return pin — formalParameter="" maps to the 'OUT' sentinel handle id. + variable: [{ '@formalParameter': '', connectionPointOut: { relPosition: { '@x': '100', '@y': '10' } } }], + }, + }, + ], + }) + const node = body.rungs[0].nodes[0] as BlockNode + expect(node.data.variable).toEqual({ name: 'ton1' }) + expect(node.data.outputHandles[0].id).toBe('OUT') + expect(node.data.variant.type).toBe('function-block') + }) + + it("resolves a pending edge into a block's non-main input pin", () => { + const { body, warnings } = parseLadderXml('p', { + contact: [ + { + '@localId': '1', + '@width': '40', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + connectionPointIn: { relPosition: { '@x': '0', '@y': '20' } }, + connectionPointOut: { relPosition: { '@x': '40', '@y': '20' } }, + variable: ['X1'], + }, + ], + block: [ + { + '@localId': '2', + '@typeName': 'CTU', + '@executionOrderId': '0', + '@width': '100', + '@height': '60', + position: { '@x': '50', '@y': '0' }, + inputVariables: { + variable: [ + { + '@formalParameter': 'PV', + connectionPointIn: { + relPosition: { '@x': '0', '@y': '30' }, + connection: [{ '@refLocalId': '1', '@formalParameter': 'output' }], + }, + }, + ], + }, + outputVariables: '', + }, + ], + }) + expect(warnings).toEqual([]) + const blockNode = body.rungs[0].nodes.find((n) => n.id === 'BLOCK-2') as BlockNode | undefined + expect(blockNode?.data.inputHandles[0].id).toBe('PV') + expect(body.rungs[0].edges).toContainEqual( + expect.objectContaining({ source: 'CONTACT-1', sourceHandle: 'output', target: 'BLOCK-2', targetHandle: 'PV' }), + ) + }) + + it('parses inVariable/outVariable leaf nodes and resolves the block-fed edge', () => { + const { body, warnings } = parseLadderXml('p', { + block: [ + { + '@localId': '1', + '@typeName': 'ADD', + '@executionOrderId': '0', + '@width': '100', + '@height': '60', + position: { '@x': '0', '@y': '0' }, + inputVariables: '', + outputVariables: { + variable: [{ '@formalParameter': 'OUT', connectionPointOut: { relPosition: { '@x': '100', '@y': '10' } } }], + }, + }, + ], + inVariable: [ + { + '@localId': '2', + '@width': '80', + '@height': '30', + position: { '@x': '0', '@y': '100' }, + connectionPointOut: { relPosition: { '@x': '80', '@y': '15' } }, + expression: 'LIT1', + }, + ], + outVariable: [ + { + '@localId': '3', + '@width': '80', + '@height': '30', + position: { '@x': '200', '@y': '0' }, + connectionPointIn: { + relPosition: { '@x': '0', '@y': '15' }, + connection: [{ '@refLocalId': '1', '@formalParameter': 'OUT' }], + }, + expression: 'RESULT', + }, + ], + }) + expect(warnings).toEqual([]) + // The unconnected inVariable literal forms its own rung (no edge ties it + // to the block/outVariable component) — search across all rungs. + const allNodes = body.rungs.flatMap((r) => r.nodes) + const outVarNode = allNodes.find((n) => n.id === 'OUTPUT-VARIABLE-3') + expect(outVarNode?.data.block).toEqual({ + id: '', + handleId: 'OUT', + variableType: { name: '', class: '', type: { definition: 'base-type', value: '' } }, + }) + const inVarNode = allNodes.find((n) => n.id === 'INPUT-VARIABLE-2') + expect(inVarNode?.data.variable).toEqual({ name: 'LIT1' }) + }) + + it('warns (non-fatally) about inOutVariable nodes', () => { + const { warnings } = parseLadderXml('p', { inOutVariable: [{}] }) + expect(warnings).toEqual(['POU "p": 1 LD inOutVariable node(s) are not supported, skipped']) + }) + + it('warns (non-fatally) about a dangling connection reference', () => { + const { body, warnings } = parseLadderXml('p', { + coil: [ + { + '@localId': '1', + '@width': '40', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + connectionPointIn: { + relPosition: { '@x': '0', '@y': '20' }, + connection: [{ '@refLocalId': 'doesnotexist', '@formalParameter': 'left-rail' }], + }, + connectionPointOut: { relPosition: { '@x': '40', '@y': '20' } }, + variable: ['Y'], + }, + ], + }) + expect(body.rungs[0].edges).toEqual([]) + expect(warnings).toEqual(['POU "p": LD connection references unknown localId "doesnotexist", skipped']) + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/language/fbd-xml.ts b/src/frontend/utils/PLC/xml-parser/language/fbd-xml.ts new file mode 100644 index 000000000..028652536 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/language/fbd-xml.ts @@ -0,0 +1,398 @@ +import { BlockNode } from '@root/frontend/components/_atoms/graphical-editor/fbd/block' +import { + CommentNode, + ConnectionNode, + VariableNode, +} from '@root/frontend/components/_atoms/graphical-editor/fbd/utils/types' +import { BlockVariant } from '@root/frontend/components/_atoms/graphical-editor/types/block' +import { FBDFlowType } from '@root/frontend/store/slices' +import { Edge, Position } from '@xyflow/react' + +import { extractXhtmlText } from '../variable-xml' +import { asArray, asRecord, asString } from '../xml-node' +import { makeHandle, parsePositionXml, toNumber } from './geometry' + +type FbdNode = BlockNode | CommentNode | ConnectionNode | VariableNode + +// Reverse of xml-generator/old-editor/language/fbd-xml.ts. Greenfield (no +// PLCopen import reference existed anywhere before this) — reconstructed +// purely from reading that generator and its zod schema. +// +// Leaf (non-block) FBD nodes have exactly one handle each, and the XML never +// names it directly (no @formalParameter on a leaf node's own element — only +// on a 's reference to a *block's* pin). The sentinel handle ids +// below are confirmed for input-variable/output-variable (an edge's +// sourceHandle into a block is literally "output-variable" when its source +// is an input-variable node); applied by analogy to connector (sink, like +// output-variable) and continuation (source, like input-variable) since +// neither has been directly observed — flagged as an unverified assumption, +// consistent with this parser's provisional old-editor-dialect scope (see +// xml-parser/index.ts). +const LEAF_INPUT_HANDLE_ID = 'input-variable' +const LEAF_OUTPUT_HANDLE_ID = 'output-variable' + +// A block's (or output-variable's/connector's) may reference a +// node that appears later in the XML, so all nodes are built first and +// edges are resolved in a second pass against this pending list. +interface PendingEdge { + targetNumericId: string + targetHandle: string + sourceRefLocalId: string + sourceFormalParameter?: string +} + +function parseConnectionXml(connXml: unknown, targetNumericId: string, targetHandle: string): PendingEdge { + const conn = asRecord(connXml) + const formalParameter = conn['@formalParameter'] + return { + targetNumericId, + targetHandle, + sourceRefLocalId: asString(conn['@refLocalId']), + sourceFormalParameter: typeof formalParameter === 'string' ? formalParameter : undefined, + } +} + +// Reverse of blockToXml. Each +// entry becomes one input handle "X" (deduped — the generator can emit +// several sibling entries sharing the same @formalParameter when +// that pin has multiple incoming edges, rather than one with a +// multi-item array) plus one pending edge per +// child. A pin with zero incoming edges has no element at all, so +// it can't be recovered here — this parser's block only has the handles the +// XML actually mentions. +function parseBlockXml(entry: Record): { node: BlockNode; pendingEdges: PendingEdge[] } { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const instanceName = entry['@instanceName'] + const isFunctionBlock = typeof instanceName === 'string' + const typeName = asString(entry['@typeName']) + + const inputHandles: BlockNode['data']['inputHandles'] = [] + const seenInputHandleIds = new Set() + const pendingEdges: PendingEdge[] = [] + + for (const varRaw of asArray(asRecord(entry.inputVariables).variable)) { + const v = asRecord(varRaw) + const formalParameter = asString(v['@formalParameter']) + const connIn = asRecord(v.connectionPointIn) + + if (!seenInputHandleIds.has(formalParameter)) { + seenInputHandleIds.add(formalParameter) + inputHandles.push(makeHandle(formalParameter, 'target', Position.Left, position, connIn.relPosition)) + } + for (const connRaw of asArray(connIn.connection)) { + pendingEdges.push(parseConnectionXml(connRaw, numericId, formalParameter)) + } + } + + const outputHandles: BlockNode['data']['outputHandles'] = asArray( + asRecord(entry.outputVariables).variable, + ).map((varRaw) => { + const v = asRecord(varRaw) + const formalParameter = asString(v['@formalParameter']) + const connOut = asRecord(v.connectionPointOut) + return makeHandle(formalParameter, 'source', Position.Right, position, connOut.relPosition) + }) + + // Only a function-block instance carries a name of its own (@instanceName); + // a plain function call has none in the XML (only @typeName, the callee's + // name). `variable.name` is required by the domain type even so — fall + // back to typeName as an honest, non-empty placeholder the generator never + // actually reads back for a non-function-block (see blockToXml's + // `variant.type === 'function-block' ? ... : undefined` guard). + const variableName = isFunctionBlock ? asString(instanceName) : typeName + + const node: BlockNode = { + id: `BLOCK-${numericId}`, + type: 'block', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [...inputHandles, ...outputHandles], + inputHandles, + outputHandles, + // Blocks address pins by name (formalParameter), not a single + // primary connector — never read for a 'block' node by the + // generator's FBD reader, kept `undefined` to satisfy the shared + // BasicNodeData shape. + inputConnector: undefined, + outputConnector: undefined, + numericId, + executionOrder: toNumber(entry['@executionOrderId']), + variable: { name: variableName }, + draggable: true, + selectable: true, + deletable: true, + // Full class/type per pin can't be recovered from the FBD XML alone + // (it only ever names pins, never their IEC variable class/type) — + // an honest, documented gap rather than a guess. `body`/`language` are + // part of `BlockVariant`'s library-POU shape (ports/block-types.ts) + // but never read back by the generator for a placed instance — + // harmless placeholders. + variant: { + name: typeName, + type: isFunctionBlock ? 'function-block' : 'function', + language: 'st', + variables: [], + documentation: '', + body: '', + extensible: false, + }, + executionControl: false, + }, + } + + return { node, pendingEdges } +} + +function parseInVariableXml(entry: Record): VariableNode { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const outputHandle = makeHandle( + LEAF_OUTPUT_HANDLE_ID, + 'source', + Position.Right, + position, + asRecord(entry.connectionPointOut).relPosition, + ) + + return { + id: `INPUT-VARIABLE-${numericId}`, + type: 'input-variable', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [outputHandle], + inputHandles: [], + outputHandles: [outputHandle], + inputConnector: undefined, + outputConnector: outputHandle, + numericId, + executionOrder: toNumber(entry['@executionOrderId']), + variable: { name: asString(entry.expression) }, + draggable: true, + selectable: true, + deletable: true, + variant: 'input-variable', + negated: asString(entry['@negated']) === 'true', + }, + } +} + +function parseOutVariableXml(entry: Record): { node: VariableNode; pendingEdges: PendingEdge[] } { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const connIn = asRecord(entry.connectionPointIn) + const inputHandle = makeHandle(LEAF_INPUT_HANDLE_ID, 'target', Position.Left, position, connIn.relPosition) + const pendingEdges = asArray(connIn.connection).map((connRaw) => + parseConnectionXml(connRaw, numericId, LEAF_INPUT_HANDLE_ID), + ) + + const node: VariableNode = { + id: `OUTPUT-VARIABLE-${numericId}`, + type: 'output-variable', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [inputHandle], + inputHandles: [inputHandle], + outputHandles: [], + inputConnector: inputHandle, + outputConnector: undefined, + numericId, + executionOrder: toNumber(entry['@executionOrderId']), + variable: { name: asString(entry.expression) }, + draggable: true, + selectable: true, + deletable: true, + variant: 'output-variable', + negated: asString(entry['@negated']) === 'true', + }, + } + + return { node, pendingEdges } +} + +// connector is a sink (only connectionPointIn, like output-variable) despite +// the name; continuation is the source it (cosmetically) pairs with. Neither +// carries an executionOrderId/negated in the XML — the generator never +// reads or writes them for these two types. +function parseConnectorXml(entry: Record): { node: ConnectionNode; pendingEdges: PendingEdge[] } { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const connIn = asRecord(entry.connectionPointIn) + const inputHandle = makeHandle(LEAF_INPUT_HANDLE_ID, 'target', Position.Left, position, connIn.relPosition) + const pendingEdges = asArray(connIn.connection).map((connRaw) => + parseConnectionXml(connRaw, numericId, LEAF_INPUT_HANDLE_ID), + ) + + const node: ConnectionNode = { + id: `CONNECTOR-${numericId}`, + type: 'connector', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [inputHandle], + inputHandles: [inputHandle], + outputHandles: [], + inputConnector: inputHandle, + outputConnector: undefined, + numericId, + executionOrder: 0, + variable: { name: asString(entry['@name']) }, + draggable: true, + selectable: true, + deletable: true, + variant: 'connector', + }, + } + + return { node, pendingEdges } +} + +function parseContinuationXml(entry: Record): ConnectionNode { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const outputHandle = makeHandle( + LEAF_OUTPUT_HANDLE_ID, + 'source', + Position.Right, + position, + asRecord(entry.connectionPointOut).relPosition, + ) + + return { + id: `CONTINUATION-${numericId}`, + type: 'continuation', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [outputHandle], + inputHandles: [], + outputHandles: [outputHandle], + inputConnector: undefined, + outputConnector: outputHandle, + numericId, + executionOrder: 0, + variable: { name: asString(entry['@name']) }, + draggable: true, + selectable: true, + deletable: true, + variant: 'continuation', + }, + } +} + +// The generator writes the literal placeholder 'No comment provided' for an +// empty comment (commentToXml) — reverse it, mirroring parseDocumentationXml's +// ' ' -> '' un-placeholder-ing for the same reason (can't otherwise tell +// "left blank" from "typed the placeholder text"). +function parseCommentXml(entry: Record): CommentNode { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const content = extractXhtmlText(entry.content) + + return { + id: `COMMENT-${numericId}`, + type: 'comment', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + deletable: true, + draggable: true, + selectable: true, + numericId, + content: content === 'No comment provided' ? '' : content, + }, + } +} + +export function parseFbdXml(pouName: string, fbdXml: unknown): { body: FBDFlowType; warnings: string[] } { + const fbd = asRecord(fbdXml) + const warnings: string[] = [] + const nodes: FbdNode[] = [] + const nodeIdByNumericId = new Map() + const pendingEdges: PendingEdge[] = [] + + for (const entry of asArray(fbd.block)) { + const { node, pendingEdges: edges } = parseBlockXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + pendingEdges.push(...edges) + } + for (const entry of asArray(fbd.inVariable)) { + const node = parseInVariableXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + } + for (const entry of asArray(fbd.outVariable)) { + const { node, pendingEdges: edges } = parseOutVariableXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + pendingEdges.push(...edges) + } + for (const entry of asArray(fbd.connector)) { + const { node, pendingEdges: edges } = parseConnectorXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + pendingEdges.push(...edges) + } + for (const entry of asArray(fbd.continuation)) { + const node = parseContinuationXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + } + for (const entry of asArray(fbd.comment)) { + nodes.push(parseCommentXml(asRecord(entry))) + } + + // inOutVariable is a confirmed dead branch in the generator (fbdToXml's + // switch has no case for it, so it's never emitted) — if XML from a + // different tool populates it, surface as a warning rather than guessing + // at semantics with zero forward-generation precedent. + const inOutCount = asArray(fbd.inOutVariable).length + if (inOutCount > 0) { + warnings.push(`POU "${pouName}": ${inOutCount} FBD inOutVariable node(s) are not supported, skipped`) + } + + const edges: Edge[] = [] + for (const pending of pendingEdges) { + const targetNodeId = nodeIdByNumericId.get(pending.targetNumericId) + const sourceNodeId = nodeIdByNumericId.get(pending.sourceRefLocalId) + if (!targetNodeId || !sourceNodeId) { + warnings.push( + `POU "${pouName}": FBD connection references unknown localId "${pending.sourceRefLocalId}", skipped`, + ) + continue + } + const sourceHandle = pending.sourceFormalParameter ?? LEAF_OUTPUT_HANDLE_ID + edges.push({ + id: `xy-edge__${sourceNodeId}${sourceHandle}-${targetNodeId}${pending.targetHandle}`, + source: sourceNodeId, + sourceHandle, + target: targetNodeId, + targetHandle: pending.targetHandle, + type: 'smoothstep', + }) + } + + return { body: { name: pouName, updated: false, rung: { comment: '', nodes, edges, selectedNodes: [] } }, warnings } +} diff --git a/src/frontend/utils/PLC/xml-parser/language/geometry.ts b/src/frontend/utils/PLC/xml-parser/language/geometry.ts new file mode 100644 index 000000000..bd3477df4 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/language/geometry.ts @@ -0,0 +1,53 @@ +import { Position } from '@xyflow/react' + +import { asRecord, asString } from '../xml-node' + +export type XyPosition = { x: number; y: number } + +// Shared by every graphical-body parser (fbd-xml.ts, ladder-xml.ts): position/ +// dimension attributes are always numeric strings (parseAttributeValue is off +// project-wide, see parse-xml-document.ts), so every numeric read goes +// through this. +export function toNumber(value: unknown, fallback = 0): number { + const n = Number(asString(value)) + return Number.isFinite(n) ? n : fallback +} + +export function parsePositionXml(xml: unknown): XyPosition { + const rec = asRecord(xml) + return { x: toNumber(rec['@x']), y: toNumber(rec['@y']) } +} + +// Structural shape of `CustomHandleProps` (fbd/handle.tsx, ladder/handle.tsx) +// minus the framework-optional fields — kept local instead of importing +// either component's type so this helper stays usable from both languages. +// `Position` is a real TS enum (not a string-literal union), so callers pass +// `Position.Left`/`Position.Right`, not plain strings. +export interface HandleGeometry { + id: string + type: 'source' | 'target' + position: Position + glbPosition: XyPosition + relPosition: XyPosition +} + +// glbPosition (absolute canvas coordinates) never appears in PLCopen XML — +// only relPosition (offset from the node's own position) does. Reconstructed +// as node.position + relPosition; not necessarily byte-identical to what the +// original editor computed, but internally consistent for a fresh import. +export function makeHandle( + id: string, + kind: 'source' | 'target', + side: Position, + nodePosition: XyPosition, + relPositionXml: unknown, +): HandleGeometry { + const relPosition = parsePositionXml(relPositionXml) + return { + id, + type: kind, + position: side, + relPosition, + glbPosition: { x: nodePosition.x + relPosition.x, y: nodePosition.y + relPosition.y }, + } +} diff --git a/src/frontend/utils/PLC/xml-parser/language/ladder-xml.ts b/src/frontend/utils/PLC/xml-parser/language/ladder-xml.ts new file mode 100644 index 000000000..b0799239d --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/language/ladder-xml.ts @@ -0,0 +1,582 @@ +import { + BlockNode, + BlockVariant, + CoilNode, + ContactNode, + PowerRailNode, + VariableNode, +} from '@root/frontend/components/_atoms/graphical-editor/ladder/utils/types' +import { LadderFlowType } from '@root/frontend/store/slices' +import { Edge, Position } from '@xyflow/react' + +import { asArray, asRecord, asString } from '../xml-node' +import { makeHandle, parsePositionXml, toNumber } from './geometry' + +type LadderParsedNode = PowerRailNode | ContactNode | CoilNode | BlockNode | VariableNode + +// Reverse of xml-generator/old-editor/language/ladder-xml.ts. Greenfield (no +// PLCopen import reference existed anywhere before this) — reconstructed by +// reading that generator's findConnections/blockToXml/etc. in full. +// +// Handle ids are literal and stable in this dialect (unlike FBD's invented +// sentinels): power rails use "left-rail"/"right-rail", contacts/coils/leaf +// variable nodes use "input"/"output", blocks use their formal parameter +// names — confirmed directly from the generator (leftRailToXML/ +// contactToXML/coilToXml never derive these from anything else). +const RAIL_OUTPUT_HANDLE = 'left-rail' +const RAIL_INPUT_HANDLE = 'right-rail' +const LEAF_INPUT_HANDLE = 'input' +const LEAF_OUTPUT_HANDLE = 'output' + +// A plain function's single unnamed return pin has the domain handle id +// 'OUT', which the generator's findConnections collapses to an empty +// `@formalParameter` string on export (`sourceHandle === 'OUT' ? '' : ...`, +// ladder-xml.ts) — reversed here. `@formalParameter` is otherwise always +// present on a built by findConnections (rightPowerRail/ +// contact/coil/block); it is omitted entirely only on the one bespoke path +// where a block's input pin is wired directly to a named node +// (blockToXml's "connected to an existing variable node" branch) — that +// case has no attribute to read at all, so its source handle defaults to +// the leaf output handle below. +const UNNAMED_FUNCTION_RETURN_HANDLE = 'OUT' + +// A contact's/coil's own `Name` text child shares its +// tag name with the interface/block-pin `` LISTS the shared +// parser config (parse-xml-document.ts) always force-arrays — so it arrives +// here wrapped in a one-item array, not a plain string. Unwrap defensively. +function parseBoundVariableName(value: unknown): string { + // Array.isArray narrows `unknown` to `any[]`, not `unknown[]` — re-widen + // explicitly so the extracted element stays type-safe. + const first: unknown = Array.isArray(value) ? (value as unknown[])[0] : value + return asString(first) +} + +// A block's (or contact/coil/rail's) may reference a node that +// appears later in the XML, so all nodes are built first and edges are +// resolved in a second pass against this pending list. +interface PendingEdge { + targetNumericId: string + targetHandle: string + sourceRefLocalId: string + sourceFormalParameter: string | undefined +} + +function parseConnectionXml(connXml: unknown, targetNumericId: string, targetHandle: string): PendingEdge { + const conn = asRecord(connXml) + const hasFormalParameter = '@formalParameter' in conn + const raw = asString(conn['@formalParameter']) + return { + targetNumericId, + targetHandle, + sourceRefLocalId: asString(conn['@refLocalId']), + sourceFormalParameter: hasFormalParameter ? (raw === '' ? UNNAMED_FUNCTION_RETURN_HANDLE : raw) : undefined, + } +} + +function parseLeftRailXml(entry: Record): PowerRailNode { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const outputHandle = makeHandle( + RAIL_OUTPUT_HANDLE, + 'source', + Position.Right, + position, + asRecord(entry.connectionPointOut).relPosition, + ) + + return { + id: `LEFT-POWER-RAIL-${numericId}`, + type: 'powerRail', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [outputHandle], + inputHandles: [], + outputHandles: [outputHandle], + inputConnector: undefined, + outputConnector: outputHandle, + numericId, + variable: { name: '' }, + executionOrder: 0, + draggable: true, + selectable: true, + deletable: true, + variant: 'left', + }, + } +} + +function parseRightRailXml(entry: Record): { node: PowerRailNode; pendingEdges: PendingEdge[] } { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const connIn = asRecord(entry.connectionPointIn) + const inputHandle = makeHandle(RAIL_INPUT_HANDLE, 'target', Position.Left, position, connIn.relPosition) + const pendingEdges = asArray(connIn.connection).map((connRaw) => + parseConnectionXml(connRaw, numericId, RAIL_INPUT_HANDLE), + ) + + const node: PowerRailNode = { + id: `RIGHT-POWER-RAIL-${numericId}`, + type: 'powerRail', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [inputHandle], + inputHandles: [inputHandle], + outputHandles: [], + inputConnector: inputHandle, + outputConnector: undefined, + numericId, + variable: { name: '' }, + executionOrder: 0, + draggable: true, + selectable: true, + deletable: true, + variant: 'right', + }, + } + + return { node, pendingEdges } +} + +// @negated/@edge(/@storage for coils) are independent XML attributes mapped +// onto one mutually-exclusive domain variant enum; the generator only ever +// emits one of them at a time (its own ternary chains enforce that), but +// nothing in the XML shape prevents a foreign document from setting more +// than one — priority storage > negated > edge is an arbitrary, documented +// call for that (currently unseen-in-fixtures) case. +function parseCoilVariant( + entry: Record, +): 'default' | 'negated' | 'risingEdge' | 'fallingEdge' | 'set' | 'reset' { + const storage = entry['@storage'] + if (storage === 'set') return 'set' + if (storage === 'reset') return 'reset' + if (asString(entry['@negated']) === 'true') return 'negated' + if (entry['@edge'] === 'rising') return 'risingEdge' + if (entry['@edge'] === 'falling') return 'fallingEdge' + return 'default' +} + +function parseContactVariant(entry: Record): 'default' | 'negated' | 'risingEdge' | 'fallingEdge' { + if (asString(entry['@negated']) === 'true') return 'negated' + if (entry['@edge'] === 'rising') return 'risingEdge' + if (entry['@edge'] === 'falling') return 'fallingEdge' + return 'default' +} + +function parseContactXml(entry: Record): { node: ContactNode; pendingEdges: PendingEdge[] } { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const connIn = asRecord(entry.connectionPointIn) + const inputHandle = makeHandle(LEAF_INPUT_HANDLE, 'target', Position.Left, position, connIn.relPosition) + const outputHandle = makeHandle( + LEAF_OUTPUT_HANDLE, + 'source', + Position.Right, + position, + asRecord(entry.connectionPointOut).relPosition, + ) + const pendingEdges = asArray(connIn.connection).map((connRaw) => + parseConnectionXml(connRaw, numericId, LEAF_INPUT_HANDLE), + ) + + const node: ContactNode = { + id: `CONTACT-${numericId}`, + type: 'contact', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [inputHandle, outputHandle], + inputHandles: [inputHandle], + outputHandles: [outputHandle], + inputConnector: inputHandle, + outputConnector: outputHandle, + numericId, + variable: { name: parseBoundVariableName(entry.variable) }, + executionOrder: 0, + draggable: true, + selectable: true, + deletable: true, + variant: parseContactVariant(entry), + }, + } + + return { node, pendingEdges } +} + +function parseCoilXml(entry: Record): { node: CoilNode; pendingEdges: PendingEdge[] } { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const connIn = asRecord(entry.connectionPointIn) + const inputHandle = makeHandle(LEAF_INPUT_HANDLE, 'target', Position.Left, position, connIn.relPosition) + const outputHandle = makeHandle( + LEAF_OUTPUT_HANDLE, + 'source', + Position.Right, + position, + asRecord(entry.connectionPointOut).relPosition, + ) + const pendingEdges = asArray(connIn.connection).map((connRaw) => + parseConnectionXml(connRaw, numericId, LEAF_INPUT_HANDLE), + ) + + const node: CoilNode = { + id: `COIL-${numericId}`, + type: 'coil', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [inputHandle, outputHandle], + inputHandles: [inputHandle], + outputHandles: [outputHandle], + inputConnector: inputHandle, + outputConnector: outputHandle, + numericId, + variable: { name: parseBoundVariableName(entry.variable) }, + executionOrder: 0, + draggable: true, + selectable: true, + deletable: true, + variant: parseCoilVariant(entry), + }, + } + + return { node, pendingEdges } +} + +// One per declared pin (never duplicated +// per-edge the way FBD's block inputs are — findConnections nests every +// matching inside that single variable's connectionPointIn), +// so — unlike fbd-xml.ts — no formalParameter-grouping/dedup is needed here. +function parseBlockXml(entry: Record): { node: BlockNode; pendingEdges: PendingEdge[] } { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const instanceName = entry['@instanceName'] + const isFunctionBlock = typeof instanceName === 'string' + const typeName = asString(entry['@typeName']) + + const inputHandles: BlockNode['data']['inputHandles'] = [] + const pendingEdges: PendingEdge[] = [] + + for (const varRaw of asArray(asRecord(entry.inputVariables).variable)) { + const v = asRecord(varRaw) + const formalParameter = asString(v['@formalParameter']) + const connIn = asRecord(v.connectionPointIn) + inputHandles.push(makeHandle(formalParameter, 'target', Position.Left, position, connIn.relPosition)) + for (const connRaw of asArray(connIn.connection)) { + pendingEdges.push(parseConnectionXml(connRaw, numericId, formalParameter)) + } + } + + // A plain function's unnamed return pin is declared here as formalParameter="" + // (see UNNAMED_FUNCTION_RETURN_HANDLE) — translate its own handle id the + // same way other nodes' connections referencing it will expect. + const outputHandles: BlockNode['data']['outputHandles'] = asArray( + asRecord(entry.outputVariables).variable, + ).map((varRaw) => { + const v = asRecord(varRaw) + const raw = asString(v['@formalParameter']) + const handleId = raw === '' ? UNNAMED_FUNCTION_RETURN_HANDLE : raw + const connOut = asRecord(v.connectionPointOut) + return makeHandle(handleId, 'source', Position.Right, position, connOut.relPosition) + }) + + const variableName = isFunctionBlock ? asString(instanceName) : typeName + + const node: BlockNode = { + id: `BLOCK-${numericId}`, + type: 'block', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [...inputHandles, ...outputHandles], + inputHandles, + outputHandles, + inputConnector: inputHandles[0], + outputConnector: outputHandles[0], + numericId, + variable: { name: variableName }, + executionOrder: toNumber(entry['@executionOrderId']), + draggable: true, + selectable: true, + deletable: true, + // Full class/type per pin can't be recovered from the LD XML alone + // (it only ever names pins, never their IEC class/type) — an honest + // documented gap, same as the FBD importer's block variant. + variant: { + name: typeName, + type: isFunctionBlock ? 'function-block' : 'function', + variables: [], + documentation: '', + extensible: false, + }, + executionControl: false, + lockExecutionControl: false, + connectedVariables: [], + }, + } + + return { node, pendingEdges } +} + +function parseInVariableXml(entry: Record): VariableNode { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const outputHandle = makeHandle( + LEAF_OUTPUT_HANDLE, + 'source', + Position.Right, + position, + asRecord(entry.connectionPointOut).relPosition, + ) + + return { + id: `INPUT-VARIABLE-${numericId}`, + type: 'variable', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [outputHandle], + inputHandles: [], + outputHandles: [outputHandle], + inputConnector: undefined, + outputConnector: outputHandle, + numericId, + variable: { name: asString(entry.expression) }, + executionOrder: 0, + draggable: true, + selectable: true, + deletable: true, + variant: 'input', + // Which block/pin this literal feeds can't be recovered here (only + // the block's own entry names its source by + // refLocalId, not the reverse) — left as an honest placeholder; the + // edge built from that block's connection is the source of truth. + block: { + id: '', + handleId: '', + variableType: { name: '', class: '', type: { definition: 'base-type', value: '' } }, + }, + }, + } +} + +function parseOutVariableXml(entry: Record): { node: VariableNode; pendingEdges: PendingEdge[] } { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const connIn = asRecord(entry.connectionPointIn) + const inputHandle = makeHandle(LEAF_INPUT_HANDLE, 'target', Position.Left, position, connIn.relPosition) + const connections = asArray(connIn.connection) + const pendingEdges = connections.map((connRaw) => parseConnectionXml(connRaw, numericId, LEAF_INPUT_HANDLE)) + + // outVariableToXML always emits exactly one connection, built directly + // from data.block.{id,handleId} rather than through findConnections — the + // one place the generator trusts that bookkeeping over the edge graph. + // Reversed here: refLocalId/formalParameter identify the source block by + // numericId, but `block.id` wants the block's own xyflow id, which isn't + // known until the second pass — left blank and not otherwise relied upon + // (the edge itself is the source of truth for wiring). + const firstConnection = asRecord(connections[0]) + const blockHandleId = asString(firstConnection['@formalParameter']) + + return { + node: { + id: `OUTPUT-VARIABLE-${numericId}`, + type: 'variable', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [inputHandle], + inputHandles: [inputHandle], + outputHandles: [], + inputConnector: inputHandle, + outputConnector: undefined, + numericId, + variable: { name: asString(entry.expression) }, + executionOrder: 0, + draggable: true, + selectable: true, + deletable: true, + variant: 'output', + block: { + id: '', + handleId: blockHandleId, + variableType: { name: '', class: '', type: { definition: 'base-type', value: '' } }, + }, + }, + }, + pendingEdges, + } +} + +// Simple union-find for grouping the flat XML's nodes back into rungs (see +// parseLadderXml below for why this is necessary rather than a positional +// grouping). +class UnionFind { + private readonly parent = new Map() + + find(x: string): string { + if (!this.parent.has(x)) this.parent.set(x, x) + let root = x + while (this.parent.get(root) !== root) root = this.parent.get(root) as string + let cur = x + while (this.parent.get(cur) !== root) { + const next = this.parent.get(cur) as string + this.parent.set(cur, root) + cur = next + } + return root + } + + union(a: string, b: string): void { + const ra = this.find(a) + const rb = this.find(b) + if (ra !== rb) this.parent.set(ra, rb) + } +} + +export function parseLadderXml(pouName: string, ldXml: unknown): { body: LadderFlowType; warnings: string[] } { + const ld = asRecord(ldXml) + const warnings: string[] = [] + const nodes: LadderParsedNode[] = [] + const nodeIdByNumericId = new Map() + const pendingEdges: PendingEdge[] = [] + + for (const entry of asArray(ld.leftPowerRail)) { + const node = parseLeftRailXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + } + for (const entry of asArray(ld.rightPowerRail)) { + const { node, pendingEdges: edges } = parseRightRailXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + pendingEdges.push(...edges) + } + for (const entry of asArray(ld.contact)) { + const { node, pendingEdges: edges } = parseContactXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + pendingEdges.push(...edges) + } + for (const entry of asArray(ld.coil)) { + const { node, pendingEdges: edges } = parseCoilXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + pendingEdges.push(...edges) + } + for (const entry of asArray(ld.block)) { + const { node, pendingEdges: edges } = parseBlockXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + pendingEdges.push(...edges) + } + for (const entry of asArray(ld.inVariable)) { + const node = parseInVariableXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + } + for (const entry of asArray(ld.outVariable)) { + const { node, pendingEdges: edges } = parseOutVariableXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + pendingEdges.push(...edges) + } + + const inOutCount = asArray(ld.inOutVariable).length + if (inOutCount > 0) { + warnings.push(`POU "${pouName}": ${inOutCount} LD inOutVariable node(s) are not supported, skipped`) + } + + const edges: Edge[] = [] + const forest = new UnionFind() + for (const node of nodes) forest.find(node.id) + + for (const pending of pendingEdges) { + const targetNodeId = nodeIdByNumericId.get(pending.targetNumericId) + const sourceNodeId = nodeIdByNumericId.get(pending.sourceRefLocalId) + if (!targetNodeId || !sourceNodeId) { + warnings.push(`POU "${pouName}": LD connection references unknown localId "${pending.sourceRefLocalId}", skipped`) + continue + } + const sourceHandle = pending.sourceFormalParameter ?? LEAF_OUTPUT_HANDLE + edges.push({ + id: `xy-edge__${sourceNodeId}${sourceHandle}-${targetNodeId}${pending.targetHandle}`, + source: sourceNodeId, + sourceHandle, + target: targetNodeId, + targetHandle: pending.targetHandle, + type: 'smoothstep', + }) + forest.union(sourceNodeId, targetNodeId) + } + + // Rungs aren't wrapped by any XML element in this dialect — all rungs + // flatten into one shared (see ladderToXml) and are only + // reconstructable by tracing which nodes are connected to each other. + // Rungs never cross-connect, so a connected-component partition of the + // node/edge graph recovers them, without needing the array-position + // pairing the generator's own output happens to preserve. + const componentOrder: string[] = [] + const componentNodes = new Map() + for (const node of nodes) { + const root = forest.find(node.id) + const group = componentNodes.get(root) + if (group) { + group.push(node) + } else { + componentNodes.set(root, [node]) + componentOrder.push(root) + } + } + + // Rung stacking (offsetY in the generator) bakes a cumulative Y shift into + // every node's position; re-basing each rung to a local origin would need + // to rebuild every node-data variant's handles generically, which TS can't + // do without a type assertion across this discriminated union — kept as + // absolute coordinates instead (still internally consistent per rung; a + // reopened diagram just starts further down the canvas for later rungs). + const rungs: LadderFlowType['rungs'] = componentOrder.map((root, index) => { + const rungNodeIds = new Set(componentNodes.get(root)?.map((n) => n.id)) + const rungEdges = edges.filter((e) => rungNodeIds.has(e.source) && rungNodeIds.has(e.target)) + const rungNodes = componentNodes.get(root) ?? [] + + const minX = Math.min(...rungNodes.map((n) => n.position.x)) + const minY = Math.min(...rungNodes.map((n) => n.position.y)) + const maxX = Math.max(...rungNodes.map((n) => n.position.x + (n.width ?? 0))) + const maxY = Math.max(...rungNodes.map((n) => n.position.y + (n.height ?? 0))) + + return { + id: `rung-${index}`, + comment: '', + defaultBounds: [minX, minY, maxX, maxY], + reactFlowViewport: [maxX - minX, maxY - minY], + selectedNodes: [], + nodes: rungNodes, + edges: rungEdges, + } + }) + + return { body: { name: pouName, updated: false, rungs }, warnings } +} diff --git a/src/frontend/utils/PLC/xml-parser/parse-xml-document.ts b/src/frontend/utils/PLC/xml-parser/parse-xml-document.ts new file mode 100644 index 000000000..01d36bbd2 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/parse-xml-document.ts @@ -0,0 +1,34 @@ +import { XMLParser } from 'fast-xml-parser' + +import { asRecord } from './xml-node' + +// Elements the old-editor generator always emits as a list, even with 0 or 1 +// items (see xml-generator/old-editor/*.ts) — fast-xml-parser otherwise +// collapses a single child into a bare object, which would break every +// downstream `.map()`/`.forEach()` over "the list of X". +const ARRAY_TAGS = new Set(['dataType', 'pou', 'task', 'pouInstance', 'variable', 'dimension', 'value']) + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@', + textNodeName: '$', + parseTagValue: false, + parseAttributeValue: false, + isArray: (name) => ARRAY_TAGS.has(name), +}) + +// Parses raw PLCopen XML text into the untyped object tree fast-xml-parser +// produces. Deliberately returns `Record`, not a typed +// shape — the vendored xml-types zod schemas (xml-generator/old-editor/*) +// are known to be narrower than what the generator itself can emit (e.g. +// `derived`/`array` variable types aren't in every schema), so validating +// against them here would silently strip data. Downstream parser modules +// narrow field by field instead. +export function parseXmlDocument(xml: string): Record { + const result = asRecord(parser.parse(xml)) + const project = asRecord(result.project) + if (Object.keys(project).length === 0) { + throw new Error('Invalid PLCopen XML: missing root element') + } + return project +} diff --git a/src/frontend/utils/PLC/xml-parser/pou-xml.ts b/src/frontend/utils/PLC/xml-parser/pou-xml.ts new file mode 100644 index 000000000..d8b9b74b2 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/pou-xml.ts @@ -0,0 +1,120 @@ +import type { PLCPou, PLCVariable, PouType, VariableClass } from '../../../../middleware/shared/ports/types' +import { lookupBaseTypeByXmlElement } from '../../iec-types-registry' +import { parseFbdXml } from './language/fbd-xml' +import { parseLadderXml } from './language/ladder-xml' +import { extractXhtmlText, parseDocumentationXml, parseVariableXml } from './variable-xml' +import { asArray, asRecord, asString } from './xml-node' + +const VAR_GROUP_TO_CLASS: Record = { + inputVars: 'input', + outputVars: 'output', + inOutVars: 'inOut', + externalVars: 'external', + localVars: 'local', + tempVars: 'temp', +} + +const POU_TYPE_FROM_XML: Record = { + program: 'program', + function: 'function', + functionBlock: 'function-block', +} + +// Reverse of `oldEditorParseInterface` (xml-generator/old-editor/pou-xml.ts). +export function parseInterfaceXml(interfaceXml: unknown): { variables: PLCVariable[]; returnType?: string } { + const iface = asRecord(interfaceXml) + const variables: PLCVariable[] = [] + + for (const [group, variableClass] of Object.entries(VAR_GROUP_TO_CLASS)) { + const groupXml = asRecord(iface[group]) + for (const varXml of asArray(groupXml.variable)) { + variables.push(parseVariableXml(varXml, variableClass)) + } + } + + if (!iface.returnType) return { variables } + + const returnTypeXml = asRecord(iface.returnType) + if ('derived' in returnTypeXml) { + return { variables, returnType: asString(asRecord(returnTypeXml.derived)['@name']) } + } + const tag = Object.keys(returnTypeXml)[0] + return { variables, returnType: tag !== undefined ? (lookupBaseTypeByXmlElement(tag)?.name ?? tag) : undefined } +} + +// Reverse of `oldEditorParsePousToXML`. ST/IL/LD/FBD bodies all parse in +// full; SFC and codesys-dialect bodies are surfaced as a non-fatal warning +// and the POU is skipped — this importer's scope is the old-editor dialect +// only (see xml-parser/index.ts). +export function parsePousXml(pouXml: unknown): { pous: PLCPou[]; warnings: string[] } { + const pous: PLCPou[] = [] + const warnings: string[] = [] + + for (const entryRaw of asArray(pouXml)) { + const entry = asRecord(entryRaw) + const name = asString(entry['@name']) + const pouTypeXml = asString(entry['@pouType']) + const type = POU_TYPE_FROM_XML[pouTypeXml] + if (!type) { + warnings.push(`POU "${name}": unrecognized pouType "${pouTypeXml}", skipped`) + continue + } + + const body = asRecord(entry.body) + const { variables, returnType } = parseInterfaceXml(entry.interface) + const documentation = parseDocumentationXml(entry.documentation) + const pouInterface = { variables, ...(returnType !== undefined ? { returnType } : {}) } + + if (body.ST !== undefined) { + pous.push({ + name, + pouType: type, + interface: pouInterface, + body: { language: 'st', value: extractXhtmlText(body.ST) }, + documentation, + }) + continue + } + if (body.IL !== undefined) { + pous.push({ + name, + pouType: type, + interface: pouInterface, + body: { language: 'il', value: extractXhtmlText(body.IL) }, + documentation, + }) + continue + } + if (body.LD !== undefined) { + const { body: ldBody, warnings: ldWarnings } = parseLadderXml(name, body.LD) + warnings.push(...ldWarnings) + pous.push({ + name, + pouType: type, + interface: pouInterface, + body: { language: 'ld', value: ldBody }, + documentation, + }) + continue + } + if (body.FBD !== undefined) { + const { body: fbdBody, warnings: fbdWarnings } = parseFbdXml(name, body.FBD) + warnings.push(...fbdWarnings) + pous.push({ + name, + pouType: type, + interface: pouInterface, + body: { language: 'fbd', value: fbdBody }, + documentation, + }) + continue + } + if (body.SFC !== undefined) { + warnings.push(`POU "${name}": Sequential Function Chart is not supported by the importer, skipped`) + continue + } + warnings.push(`POU "${name}": no recognized body language found, skipped`) + } + + return { pous, warnings } +} diff --git a/src/frontend/utils/PLC/xml-parser/type-xml.ts b/src/frontend/utils/PLC/xml-parser/type-xml.ts new file mode 100644 index 000000000..225c7d0e7 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/type-xml.ts @@ -0,0 +1,48 @@ +import type { PLCVariableType } from '../../../../middleware/shared/ports/types' +import { lookupBaseTypeByXmlElement } from '../../iec-types-registry' +import { asArray, asRecord, asString } from './xml-node' + +type LeafBaseType = { definition: 'base-type' | 'user-data-type'; value: string } + +// Reverse of `convertTypeToXml` (xml-generator/old-editor/type-xml.ts): a +// PLCopen ``/`` element has exactly one child key, which is +// either a recognised IEC base-type tag, `derived` (user type/FB reference), +// or `array` (nested dimensions + element base type). +function parseBaseTypeLeaf(baseTypeXml: unknown): LeafBaseType { + const rec = asRecord(baseTypeXml) + if ('derived' in rec) { + return { definition: 'user-data-type', value: asString(asRecord(rec.derived)['@name']) } + } + const tag = Object.keys(rec)[0] + if (tag === undefined) throw new Error('Type element has no recognizable base type') + return { definition: 'base-type', value: lookupBaseTypeByXmlElement(tag)?.name ?? tag } +} + +function parseDimensionsXml(dimensionXml: unknown): Array<{ dimension: string }> { + return asArray(dimensionXml).map((d) => { + const dim = asRecord(d) + return { dimension: `${asString(dim['@lower'])}..${asString(dim['@upper'])}` } + }) +} + +export function parseTypeXml(typeXml: unknown): PLCVariableType { + const type = asRecord(typeXml) + + if ('array' in type) { + const arrayXml = asRecord(type.array) + const baseType = parseBaseTypeLeaf(arrayXml.baseType) + const dimensions = parseDimensionsXml(arrayXml.dimension) + const value = `ARRAY[${dimensions.map((d) => d.dimension).join(',')}] OF ${baseType.value}` + return { definition: 'array', value, data: { baseType, dimensions } } + } + + if ('derived' in type) { + return { definition: 'derived', value: asString(asRecord(type.derived)['@name']) } + } + + const tag = Object.keys(type)[0] + if (tag === undefined) throw new Error('Variable type element is empty') + return { definition: 'base-type', value: lookupBaseTypeByXmlElement(tag)?.name ?? tag } +} + +export { parseBaseTypeLeaf, parseDimensionsXml } diff --git a/src/frontend/utils/PLC/xml-parser/variable-xml.ts b/src/frontend/utils/PLC/xml-parser/variable-xml.ts new file mode 100644 index 000000000..a55273ca6 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/variable-xml.ts @@ -0,0 +1,45 @@ +import type { PLCVariable, VariableClass } from '../../../../middleware/shared/ports/types' +import { parseTypeXml } from './type-xml' +import { asRecord } from './xml-node' + +// A bare `text` (no attributes) parses to a plain string; +// fast-xml-parser only wraps it in `{ $: text }` when attributes are present. +function extractXhtmlText(xml: unknown): string { + const rec = asRecord(xml) + const p = rec['xhtml:p'] + if (typeof p === 'string') return p + const text = asRecord(p).$ + return typeof text === 'string' ? text : '' +} + +// The generator writes a literal single space for an empty documentation +// string (`value === '' ? ' ' : value`, see oldEditorParseInterface/ +// oldEditorParsePousToXML) — reverse that placeholder back to ''. ST/IL body +// text has no such placeholder, so callers reading a body use +// `extractXhtmlText` directly instead of this function. +export function parseDocumentationXml(xml: unknown): string { + const text = extractXhtmlText(xml) + return text === ' ' ? '' : text +} + +export { extractXhtmlText } + +// Reverse of the `VariableXML` shape built in oldEditorParseInterface / +// oldEditorInstanceToXml — shared by POU interface variables and +// configuration global variables (only `class` differs by call site). +export function parseVariableXml(varXml: unknown, variableClass: VariableClass): PLCVariable { + const v = asRecord(varXml) + const initialValueXml = asRecord(v.initialValue) + const simpleValue = asRecord(initialValueXml.simpleValue) + const initialValue = typeof simpleValue['@value'] === 'string' ? simpleValue['@value'] : null + const location = v['@address'] + + return { + name: typeof v['@name'] === 'string' ? v['@name'] : '', + class: variableClass, + type: parseTypeXml(v.type), + location: typeof location === 'string' ? location : '', + initialValue, + documentation: parseDocumentationXml(v.documentation), + } +} diff --git a/src/frontend/utils/PLC/xml-parser/xml-node.ts b/src/frontend/utils/PLC/xml-parser/xml-node.ts new file mode 100644 index 000000000..001ad34e3 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/xml-node.ts @@ -0,0 +1,17 @@ +// Small defensive helpers for navigating fast-xml-parser's untyped output. +// The parser config (parse-xml-document.ts) forces known repeating elements +// into arrays, but leaf/absent values still arrive as `unknown` — e.g. an +// empty element (``) parses to `''`, not `{}` or `undefined`. + +export function asRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {} +} + +export function asArray(value: T | T[] | undefined): T[] { + if (value === undefined) return [] + return Array.isArray(value) ? value : [value] +} + +export function asString(value: unknown): string { + return typeof value === 'string' ? value : '' +} diff --git a/src/frontend/utils/__tests__/iec-types-registry.test.ts b/src/frontend/utils/__tests__/iec-types-registry.test.ts index 048643f2a..329453b4b 100644 --- a/src/frontend/utils/__tests__/iec-types-registry.test.ts +++ b/src/frontend/utils/__tests__/iec-types-registry.test.ts @@ -1,4 +1,10 @@ -import { BASE_TYPE_NAMES, IEC_BASE_TYPES, isBaseTypeName, lookupBaseType } from '../iec-types-registry' +import { + BASE_TYPE_NAMES, + IEC_BASE_TYPES, + isBaseTypeName, + lookupBaseType, + lookupBaseTypeByXmlElement, +} from '../iec-types-registry' describe('iec-types-registry', () => { describe('IEC_BASE_TYPES', () => { @@ -81,6 +87,36 @@ describe('iec-types-registry', () => { }) }) + describe('lookupBaseTypeByXmlElement', () => { + it('resolves the standard uppercase element names', () => { + expect(lookupBaseTypeByXmlElement('BOOL')?.name).toBe('BOOL') + expect(lookupBaseTypeByXmlElement('INT')?.name).toBe('INT') + expect(lookupBaseTypeByXmlElement('REAL')?.name).toBe('REAL') + }) + + it('resolves the lowercase string/wstring element names', () => { + expect(lookupBaseTypeByXmlElement('string')?.name).toBe('STRING') + expect(lookupBaseTypeByXmlElement('wstring')?.name).toBe('WSTRING') + }) + + it('is case-sensitive (does not match on the wrong case)', () => { + expect(lookupBaseTypeByXmlElement('STRING')).toBeUndefined() + expect(lookupBaseTypeByXmlElement('WSTRING')).toBeUndefined() + expect(lookupBaseTypeByXmlElement('bool')).toBeUndefined() + }) + + it('returns undefined for unknown element names', () => { + expect(lookupBaseTypeByXmlElement('derived')).toBeUndefined() + expect(lookupBaseTypeByXmlElement('')).toBeUndefined() + }) + + it("round-trips every registry entry's own xml.elementName", () => { + for (const t of IEC_BASE_TYPES) { + expect(lookupBaseTypeByXmlElement(t.xml.elementName)).toBe(t) + } + }) + }) + describe('BASE_TYPE_NAMES', () => { it('exposes canonical names only (no aliases)', () => { expect(BASE_TYPE_NAMES).toContain('TOD') diff --git a/src/frontend/utils/iec-types-registry.ts b/src/frontend/utils/iec-types-registry.ts index b08fadb11..0868eb0fd 100644 --- a/src/frontend/utils/iec-types-registry.ts +++ b/src/frontend/utils/iec-types-registry.ts @@ -119,3 +119,27 @@ export function isBaseTypeName(name: string): boolean { * spelling we want to see in UI dropdowns and emit in PLCopen XML. */ export const BASE_TYPE_NAMES: readonly string[] = IEC_BASE_TYPES.map((t) => t.name) + +/** + * Reverse index of {@link IEC_BASE_TYPES}, keyed by the literal PLCopen XML + * element name (`t.xml.elementName` — e.g. `BOOL`, `string`), for the + * PLCopen XML importer (frontend/utils/PLC/xml-parser/type-xml.ts). Element + * names are unique across the registry (case-sensitive, mixed-case by + * design — see `baseTypeTag`), so no collision handling is needed. + */ +const XML_ELEMENT_INDEX: ReadonlyMap = (() => { + const m = new Map() + for (const t of IEC_BASE_TYPES) m.set(t.xml.elementName, t) + return m +})() + +/** + * Resolve a PLCopen XML element name (e.g. `BOOL`, `string`) back to its + * IEC type metadata. Case-sensitive — unlike `lookupBaseType`, callers here + * already have the exact tag fast-xml-parser handed them, and PLCopen XML + * element names are case-significant (`` vs `` are not + * interchangeable — xml2st rejects the latter). + */ +export function lookupBaseTypeByXmlElement(elementName: string): IECTypeMetadata | undefined { + return XML_ELEMENT_INDEX.get(elementName) +} diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 4bad31169..3f12df229 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -42,7 +42,12 @@ 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 { getOpenProjectPath, getProjectPath } from '../../../backend/editor/utils' +import { + getOpenProjectPath, + getPlcopenExportSavePath, + getPlcopenImportFilePath, + getProjectPath, +} from '../../../backend/editor/utils' 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' @@ -816,6 +821,8 @@ class MainProcessBridge implements MainIpcModule { this.registerHandle('project:save-file', this.handleFileSave) this.registerHandle('project:open-by-path', this.handleProjectOpenByPath) this.registerHandle('project:read-files', this.handleReadProjectFiles) + this.registerHandle('project:pick-plcopen-import-file', this.handlePickPlcopenImportFile) + this.registerHandle('project:export-plcopen-file', this.handleExportPlcopenFile) // Pou-related handlers this.registerHandle('pou:create', this.handleCreatePouFile) @@ -1043,6 +1050,36 @@ class MainProcessBridge implements MainIpcModule { } } + handlePickPlcopenImportFile = async (_event: IpcMainInvokeEvent) => { + const windowManager = this.mainWindow + try { + if (windowManager) { + const res = await getPlcopenImportFilePath(windowManager) + return res + } + logger.error('Window object not defined') + return { success: false, error: { title: 'Internal error', description: 'Window object not defined' } } + } catch (error) { + logger.error('Error picking PLCopen import file: ' + getErrorMessage(error)) + return { success: false, error: { title: 'Internal error', description: getErrorMessage(error) } } + } + } + + handleExportPlcopenFile = async (_event: IpcMainInvokeEvent, defaultFileName: string, xml: string) => { + const windowManager = this.mainWindow + try { + if (windowManager) { + const res = await getPlcopenExportSavePath(windowManager, defaultFileName, xml) + return res + } + logger.error('Window object not defined') + return { success: false, error: { title: 'Internal error', description: 'Window object not defined' } } + } catch (error) { + logger.error('Error exporting PLCopen file: ' + getErrorMessage(error)) + return { success: false, error: { title: 'Internal error', description: getErrorMessage(error) } } + } + } + // Pou-related handlers handleCreatePouFile = async (_event: IpcMainInvokeEvent, props: CreatePouFileProps) => { try { diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 395d24d3e..67393124e 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -73,6 +73,16 @@ const rendererProcessBridge = { openPathPicker: (): Promise<{ success: boolean; error?: { title: string; description: string }; path?: string }> => ipcRenderer.invoke('project:open-path-picker'), readProjectFiles: (projectPath: string): Promise => ipcRenderer.invoke('project:read-files', projectPath), + pickPlcopenImportFile: (): Promise<{ + success: boolean + content?: string + error?: { title: string; description: string } + }> => ipcRenderer.invoke('project:pick-plcopen-import-file'), + exportPlcopenFile: ( + defaultFileName: string, + xml: string, + ): Promise<{ success: boolean; error?: { title: string; description: string } }> => + ipcRenderer.invoke('project:export-plcopen-file', defaultFileName, xml), removeCloseProjectListener: () => ipcRenderer.removeAllListeners('workspace:close-project-accelerator'), removeCloseTabListener: () => ipcRenderer.removeAllListeners('workspace:close-tab-accelerator'), removeCreateProjectAccelerator: () => ipcRenderer.removeAllListeners('project:create-accelerator'), diff --git a/src/middleware/adapters/editor/__tests__/project-adapter.test.ts b/src/middleware/adapters/editor/__tests__/project-adapter.test.ts index 532a35db8..58e54eebe 100644 --- a/src/middleware/adapters/editor/__tests__/project-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/project-adapter.test.ts @@ -121,6 +121,8 @@ beforeEach(() => { onFileExternalChange: jest.fn().mockImplementation((_cb: unknown) => { return () => {} }), + pickPlcopenImportFile: jest.fn().mockResolvedValue({ success: true, content: '' }), + exportPlcopenFile: jest.fn().mockResolvedValue({ success: true }), } as unknown as typeof window.bridge }) @@ -552,6 +554,62 @@ describe('createEditorProjectAdapter', () => { }) }) + describe('pickPlcopenImportFile', () => { + it('delegates to window.bridge.pickPlcopenImportFile and returns content', async () => { + const result = await adapter.pickPlcopenImportFile() + + expect(window.bridge.pickPlcopenImportFile).toHaveBeenCalledTimes(1) + expect(result).toEqual({ success: true, content: '' }) + }) + + it('flattens the error object to a string on failure', async () => { + ;(window.bridge.pickPlcopenImportFile as jest.Mock).mockResolvedValue({ + success: false, + error: { title: 'Operation canceled', description: 'Operation canceled by the user.' }, + }) + + const result = await adapter.pickPlcopenImportFile() + + expect(result).toEqual({ success: false, error: 'Operation canceled by the user.' }) + }) + + it('returns undefined error when the bridge reports failure without an error object', async () => { + ;(window.bridge.pickPlcopenImportFile as jest.Mock).mockResolvedValue({ success: false }) + + const result = await adapter.pickPlcopenImportFile() + + expect(result).toEqual({ success: false, error: undefined }) + }) + }) + + describe('exportPlcopenFile', () => { + it('delegates to window.bridge.exportPlcopenFile with the file name and xml content', async () => { + const result = await adapter.exportPlcopenFile('my-project.xml', '') + + expect(window.bridge.exportPlcopenFile).toHaveBeenCalledWith('my-project.xml', '') + expect(result).toEqual({ success: true }) + }) + + it('flattens the error object to a string on failure', async () => { + ;(window.bridge.exportPlcopenFile as jest.Mock).mockResolvedValue({ + success: false, + error: { title: 'Error writing file', description: 'Failed to write the PLCopen XML file.' }, + }) + + const result = await adapter.exportPlcopenFile('my-project.xml', '') + + expect(result).toEqual({ success: false, error: 'Failed to write the PLCopen XML file.' }) + }) + + it('returns undefined error when the bridge reports failure without an error object', async () => { + ;(window.bridge.exportPlcopenFile as jest.Mock).mockResolvedValue({ success: false }) + + const result = await adapter.exportPlcopenFile('my-project.xml', '') + + expect(result).toEqual({ success: false, error: undefined }) + }) + }) + describe('onFileExternalChange', () => { it('subscribes via window.bridge.onFileExternalChange and returns unsubscribe', () => { const callback = jest.fn() diff --git a/src/middleware/adapters/editor/project-adapter.ts b/src/middleware/adapters/editor/project-adapter.ts index 2420eee8d..a6caf6c30 100644 --- a/src/middleware/adapters/editor/project-adapter.ts +++ b/src/middleware/adapters/editor/project-adapter.ts @@ -332,6 +332,22 @@ export function createEditorProjectAdapter(): ProjectPort { callback(data.filePath) }) }, + + async pickPlcopenImportFile(): Promise<{ success: boolean; content?: string; error?: string }> { + const response = await window.bridge.pickPlcopenImportFile() + if (!response.success) { + return { success: false, error: response.error?.description } + } + return { success: true, content: response.content } + }, + + async exportPlcopenFile(defaultFileName: string, xml: string): Promise<{ success: boolean; error?: string }> { + const response = await window.bridge.exportPlcopenFile(defaultFileName, xml) + if (!response.success) { + return { success: false, error: response.error?.description } + } + return { success: true } + }, } } diff --git a/src/middleware/shared/ports/platform-capabilities.ts b/src/middleware/shared/ports/platform-capabilities.ts index b3036e01b..95bd82bd4 100644 --- a/src/middleware/shared/ports/platform-capabilities.ts +++ b/src/middleware/shared/ports/platform-capabilities.ts @@ -48,6 +48,9 @@ export interface PlatformCapabilities { /** True if the app supports exporting projects as XML files (Codesys, old-editor formats). */ hasProjectExport: boolean + /** True if the app supports importing a project from a PLCopen XML file. */ + hasProjectImport: boolean + /** True if the app supports version control (branches, commits, change tracking). */ hasVersionControl: boolean @@ -121,6 +124,7 @@ export const EDITOR_CAPABILITIES: PlatformCapabilities = { hasInProcessSimulator: true, hasLocalFilesystem: true, hasProjectExport: true, + hasProjectImport: true, hasVersionControl: false, hasAboutDialog: true, hasPythonLSP: true, @@ -147,7 +151,10 @@ export const WEB_CAPABILITIES: PlatformCapabilities = { hasWebRTC: true, hasInProcessSimulator: true, hasLocalFilesystem: false, - hasProjectExport: false, + // Browser-download implementation makes export just as viable on web + // as on desktop — no reason to keep this gated off. + hasProjectExport: true, + hasProjectImport: true, hasVersionControl: true, hasAboutDialog: true, // `monaco-pyright-lsp` ships its own ESM worker via diff --git a/src/middleware/shared/ports/project-port.ts b/src/middleware/shared/ports/project-port.ts index 5a80ff9d5..b52eecca3 100644 --- a/src/middleware/shared/ports/project-port.ts +++ b/src/middleware/shared/ports/project-port.ts @@ -76,6 +76,16 @@ export interface ProjectResponse { * expose READMEs (desktop editor, dev:local). */ readme?: string | null + /** + * Signals this response was just converted from a pending raw PLCopen + * import (Node's `plcopen-pending-import.xml` marker) rather than + * loaded from a normal `project.json`. Set only by the adapter branch + * that runs `parsePlcopenXml` in place of `parseProjectFiles`. The + * caller should persist immediately (`saveProject`) so the marker gets + * pruned server-side — Node's save endpoint deletes any file not in + * the incoming payload. Absent ⇒ ordinary open/import, no auto-save. + */ + wasPendingPlcopenImport?: boolean } error?: { title: string @@ -169,6 +179,14 @@ export interface RawProjectFiles { /** See {@link ProjectResponse.data.readme}. Carried through the * raw layer for the same reason as `canEdit`. */ readme?: string | null + /** + * Raw PLCopen XML content when the project directory is a bare + * pending-import marker (Node's `plcopen-pending-import.xml`) instead + * of a normal project — `apiFilesToRaw` surfaces the envelope's + * `'plcopen-pending-import.xml'` key here. `undefined` is the + * "not pending" case (normal project, has `project.json`). + */ + pendingPlcopenSource?: string } error?: { title: string; description: string } } @@ -312,4 +330,18 @@ export interface ProjectPort { migrated?: boolean error?: string }> + + /** + * Pick a PLCopen XML file to import and read its contents. + * Editor: native open-file dialog filtered to .xml. + * Web: hidden . + */ + pickPlcopenImportFile(): Promise<{ success: boolean; content?: string; error?: string }> + + /** + * Persist generated PLCopen XML content as a file the user can access. + * Editor: native save-file dialog, writes to disk. + * Web: triggers a browser download of the blob. + */ + exportPlcopenFile(defaultFileName: string, xml: string): Promise<{ success: boolean; error?: string }> } From b3b7d7cbeb340ee2c6809af70911515cfa98222b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Tue, 21 Jul 2026 15:16:18 -0300 Subject: [PATCH 35/52] perf(graphical-editor): remove edit hot-path deep clones + debounce flow write-back (DOPE-490) Every graphical edit deep-cloned the whole flow twice (JSON round-trip undo snapshot + structuredClone write-back), producing 150-200MB of transient garbage per edit burst on large projects. - new store/slices/shared/flow-writeback.ts: per-POU debounced (200ms) flow -> pou.body write-back; persists the raw flow by reference (immer copy-on-write makes sharing safe); flush/cancel entry points - save paths flush pending write-backs before serializing, so a save landing inside the debounce window persists the fresh body - undo/redo + snapshot capture hold plain references (no JSON clones) and flush the POU first so history never pairs a stale body with a fresh flow; project open cancels stale timers - ladder editor's local flow-less captureSnapshot replaced with the shared hook: undo of rung add/reorder now restores the canvas Mirror of the openplc-web PR for DOPE-490. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017RGT8nUsyY26HXFSuTBFLz --- .../editor/graphical/FBD/index.tsx | 41 +-- .../editor/graphical/ladder/index.tsx | 68 ++--- src/frontend/hooks/use-pou-snapshot.ts | 24 +- src/frontend/services/save-actions.ts | 7 + .../store/__tests__/flow-writeback.test.ts | 248 ++++++++++++++++++ .../store/slices/shared/flow-writeback.ts | 96 +++++++ src/frontend/store/slices/shared/slice.ts | 52 ++-- 7 files changed, 417 insertions(+), 119 deletions(-) create mode 100644 src/frontend/store/__tests__/flow-writeback.test.ts create mode 100644 src/frontend/store/slices/shared/flow-writeback.ts diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx index 66b56bf8f..d8ac7033b 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo } from 'react' import { useOpenPLCStore } from '../../../../../../store' -import { zodFBDFlowSchema } from '../../../../../../store/slices/fbd' +import { scheduleFlowWriteBack } from '../../../../../../store/slices/shared/flow-writeback' import { BlockNodeData } from '../../../../../_atoms/graphical-editor/fbd/block' import { BlockVariant } from '../../../../../_atoms/graphical-editor/types/block' import { FBDBody } from '../../../../../_molecules/graphical-editor/fbd' @@ -19,11 +19,6 @@ export default function FbdEditor() { const fbdFlows = useOpenPLCStore((state) => state.fbdFlows) const pous = useOpenPLCStore((state) => state.project.data.pous) const userLibraries = useOpenPLCStore((state) => state.libraries.user) - const fbdFlowActions = useOpenPLCStore((state) => state.fbdFlowActions) - const updatePou = useOpenPLCStore((state) => state.projectActions.updatePou) - const handleFileAndWorkspaceSavedState = useOpenPLCStore( - (state) => state.sharedWorkspaceActions.handleFileAndWorkspaceSavedState, - ) const isDebuggerVisible = useOpenPLCStore((state) => state.workspace.isDebuggerVisible) const flow = fbdFlows.find((flow) => flow.name === pouName) @@ -84,35 +79,15 @@ export default function FbdEditor() { }, [flow?.rung.nodes, userLibraries, pous]) /** - * Update the flow state to project JSON. - * - * Validate the flow with Zod but persist the raw object (minus the - * transient `updated` flag). Using the parsed result would silently strip - * every field not declared in `zodFBDFlowSchema` and reorder keys to - * schema order, which makes `serializeGraphicalPouToString` produce - * byte-drift vs. the loaded disk copy — surfacing as phantom "Modified" - * entries in Source Control for POUs the user never edited. + * Queue the flow → project JSON write-back. The scheduler debounces it + * (edits inside the window coalesce), persists the raw flow object, and + * clears the `updated` flag; save paths flush it so a save landing inside + * the window still serializes the fresh body. Validation and the DOPE-477 + * raw-object policy live in store/slices/shared/flow-writeback.ts. */ useEffect(() => { - if (!flowUpdated || !flow) return - - const flowSchema = zodFBDFlowSchema.safeParse(flow) - if (!flowSchema.success) return - - const { updated: _updated, ...flowBody } = flow - updatePou({ - name: pouName, - content: { - language: 'fbd', - value: structuredClone(flowBody), - }, - }) - - fbdFlowActions.setFlowUpdated({ editorName: pouName, updated: false }) - - if (!isDebuggerVisible) { - handleFileAndWorkspaceSavedState(pouName) - } + if (!flowUpdated) return + scheduleFlowWriteBack(useOpenPLCStore.getState, pouName, 'fbd') }, [flowUpdated]) return ( diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx index d9f3379b5..a3fc5661b 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx @@ -15,14 +15,15 @@ import { import { restrictToParentElement } from '@dnd-kit/modifiers' import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' import * as Portal from '@radix-ui/react-portal' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { v4 as uuidv4 } from 'uuid' +import { usePouSnapshot } from '../../../../../../hooks/use-pou-snapshot' import { ladderSelectors } from '../../../../../../hooks/use-store-selectors' -import { openPLCStoreBase, useOpenPLCStore } from '../../../../../../store' -import { RungLadderState, zodLadderFlowSchema } from '../../../../../../store/slices/ladder' -import type { PouHistorySnapshot } from '../../../../../../store/slices/shared/types' +import { useOpenPLCStore } from '../../../../../../store' +import { RungLadderState } from '../../../../../../store/slices/ladder' +import { scheduleFlowWriteBack } from '../../../../../../store/slices/shared/flow-writeback' import { cn } from '../../../../../../utils/cn' import { BlockNode, BlockNodeData } from '../../../../../_atoms/graphical-editor/ladder/block' import { CoilNode } from '../../../../../_atoms/graphical-editor/ladder/coil' @@ -51,27 +52,11 @@ export default function LadderEditor() { const contactElementModal = useOpenPLCStore((state) => state.modals['contact-ladder-element']) const coilElementModal = useOpenPLCStore((state) => state.modals['coil-ladder-element']) const pous = useOpenPLCStore((state) => state.project.data.pous) - const updatePou = useOpenPLCStore((state) => state.projectActions.updatePou) const closeModal = useOpenPLCStore((state) => state.modalActions.closeModal) - const handleFileAndWorkspaceSavedState = useOpenPLCStore( - (state) => state.sharedWorkspaceActions.handleFileAndWorkspaceSavedState, - ) - const pushToHistory = useOpenPLCStore((state) => state.snapshotActions.pushToHistory) const userLibraries = useOpenPLCStore((state) => state.libraries.user) const isDebuggerVisible = useOpenPLCStore((state) => state.workspace.isDebuggerVisible) - const captureSnapshot = useCallback( - (pouName: string): PouHistorySnapshot | null => { - const pou = pous.find((p) => p.name === pouName) - if (!pou) return null - return { - variables: pou.interface?.variables ?? [], - body: pou.body.value, - globalVariables: openPLCStoreBase.getState().project.data.configurations.resource.globalVariables, - } - }, - [pous], - ) + const { captureAndPush } = usePouSnapshot() const updateModelLadder = ladderSelectors.useUpdateModelLadder() @@ -150,36 +135,15 @@ export default function LadderEditor() { }, [searchNodePosition]) /** - * Update the flow state to project JSON. - * - * Validate the flow with Zod but persist the raw object (minus the - * transient `updated` flag). Using the parsed result would silently strip - * every field not declared in `zodLadderFlowSchema` (e.g. `handleBranches`, - * `positionAbsolute`, `zIndex`) and reorder keys to schema order, which - * makes `serializeGraphicalPouToString` produce byte-drift vs. the loaded - * disk copy — surfacing as phantom "Modified" entries in Source Control - * for POUs the user never edited. + * Queue the flow → project JSON write-back. The scheduler debounces it + * (edits inside the window coalesce), persists the raw flow object, and + * clears the `updated` flag; save paths flush it so a save landing inside + * the window still serializes the fresh body. Validation and the DOPE-477 + * raw-object policy live in store/slices/shared/flow-writeback.ts. */ useEffect(() => { - if (!flowUpdated || !flow) return - - const flowSchema = zodLadderFlowSchema.safeParse(flow) - if (!flowSchema.success) return - - const { updated: _updated, ...flowBody } = flow - updatePou({ - name: pouName, - content: { - language: 'ld', - value: structuredClone(flowBody), - }, - }) - - ladderFlowActions.setFlowUpdated({ editorName: pouName, updated: false }) - - if (!isDebuggerVisible) { - handleFileAndWorkspaceSavedState(pouName) - } + if (!flowUpdated) return + scheduleFlowWriteBack(useOpenPLCStore.getState, pouName, 'ld') }, [flowUpdated]) const getRungPos = (rungId: UniqueIdentifier) => rungs.findIndex((rung) => rung.id === rungId) @@ -195,8 +159,7 @@ export default function LadderEditor() { const handleAddNewRung = () => { if (isDebuggerVisible) return - const snapshot = captureSnapshot(pouName) - if (snapshot) pushToHistory(pouName, snapshot) + captureAndPush(pouName) const defaultViewport: [number, number] = [300, 100] @@ -255,8 +218,7 @@ export default function LadderEditor() { auxRungs.splice(destinationIndex, 0, removed) try { - const snapshot = captureSnapshot(pouName) - if (snapshot) pushToHistory(pouName, snapshot) + captureAndPush(pouName) ladderFlowActions.setRungs({ editorName: pouName, rungs: auxRungs }) } catch (error) { console.error('Failed to update rungs:', error) diff --git a/src/frontend/hooks/use-pou-snapshot.ts b/src/frontend/hooks/use-pou-snapshot.ts index 2a3cc49ad..1ba92a930 100644 --- a/src/frontend/hooks/use-pou-snapshot.ts +++ b/src/frontend/hooks/use-pou-snapshot.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' import { useOpenPLCStore } from '../store' +import { flushFlowWriteBacks } from '../store/slices/shared/flow-writeback' /** * Convenience hook wrapping snapshotActions.pushToHistory(). @@ -9,25 +10,32 @@ import { useOpenPLCStore } from '../store' * * State is read via getState() at capture time (not subscribed): the hook * never re-renders its consumers and `captureAndPush` keeps a stable identity. + * + * Snapshots hold plain references into the store state — no deep clone. The + * store is immer-managed (frozen, copy-on-write), so later edits produce new + * objects and can never reach a captured snapshot. The previous JSON + * round-trips cloned the full body, both flows and all globals on every + * capture (~150-200 MB of transient garbage per edit burst on large + * projects). */ export function usePouSnapshot() { const { pushToHistory, undo, redo } = useOpenPLCStore((state) => state.snapshotActions) 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) const { project, ladderFlows, fbdFlows } = useOpenPLCStore.getState() const pou = project.data.pous.find((p) => p.name === pouName) if (!pou) return - const ladderFlow = ladderFlows.find((f) => f.name === pouName) - const fbdFlow = fbdFlows.find((f) => f.name === pouName) - pushToHistory(pouName, { - variables: JSON.parse(JSON.stringify(pou.interface?.variables ?? [])), - body: JSON.parse(JSON.stringify(pou.body.value)), - ladderFlow: ladderFlow ? JSON.parse(JSON.stringify(ladderFlow)) : undefined, - fbdFlow: fbdFlow ? JSON.parse(JSON.stringify(fbdFlow)) : undefined, - globalVariables: JSON.parse(JSON.stringify(project.data.configurations.resource.globalVariables)), + variables: pou.interface?.variables ?? [], + body: pou.body.value, + ladderFlow: ladderFlows.find((f) => f.name === pouName), + fbdFlow: fbdFlows.find((f) => f.name === pouName), + globalVariables: project.data.configurations.resource.globalVariables, }) }, [pushToHistory], diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index 90e06f876..def00f254 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -16,6 +16,7 @@ import type { ProjectPort, RawProjectFile, WriteProjectFiles } from '../../middl import type { 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 { 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' @@ -321,6 +322,10 @@ export async function executeSaveProject( projectPort: ProjectPort, capabilities: PlatformCapabilities, ): 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) 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 @@ -491,6 +496,8 @@ export async function executeSaveFile( projectPort: ProjectPort, capabilities: PlatformCapabilities, ): Promise<{ success: boolean }> { + // See executeSaveProject — same pending write-back flush requirement. + flushFlowWriteBacks(openPLCStoreBase.getState) const state = openPLCStoreBase.getState() // See executeSaveProject for rationale — same persist gate. if (!state.workspace.canEdit) { diff --git a/src/frontend/store/__tests__/flow-writeback.test.ts b/src/frontend/store/__tests__/flow-writeback.test.ts new file mode 100644 index 000000000..649595c8c --- /dev/null +++ b/src/frontend/store/__tests__/flow-writeback.test.ts @@ -0,0 +1,248 @@ +import { createStore } from 'zustand/vanilla' + +import { createAISlice } from '../slices/ai' +import { createConsoleSlice } from '../slices/console/slice' +import { createDeviceSlice } from '../slices/device/slice' +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' +import { createProjectSlice } from '../slices/project/slice' +import { createSearchSlice } from '../slices/search/slice' +import { + cancelFlowWriteBacks, + FLOW_WRITEBACK_DEBOUNCE_MS, + flushFlowWriteBacks, + scheduleFlowWriteBack, +} from '../slices/shared/flow-writeback' +import { createSharedSlice } from '../slices/shared/slice' +import type { SharedRootState } from '../slices/shared/types' +import { createTabsSlice } from '../slices/tabs/slice' +import { createVersionControlSlice } from '../slices/version-control/slice' +import { createWorkspaceSlice } from '../slices/workspace/slice' + +function makeStore() { + return createStore()((...args) => ({ + ...createProjectSlice(...args), + ...createFileSlice(...args), + ...createEditorSlice(...args), + ...createTabsSlice(...args), + ...createLibrarySlice(...args), + ...createWorkspaceSlice(...args), + ...createModalSlice(...args), + ...createSearchSlice(...args), + ...createConsoleSlice(...args), + ...createDeviceSlice(...args), + ...createFBDFlowSlice(...args), + ...createLadderFlowSlice(...args), + ...createHistorySlice(...args), + ...createVersionControlSlice(...args), + ...createAISlice(...args), + ...createSharedSlice(...args), + })) +} + +describe('flow write-back scheduler', () => { + let store: ReturnType + + const getState = () => store.getState() + + const ladderBody = (pouName: string) => + store.getState().project.data.pous.find((p) => p.name === pouName)?.body.value as LadderFlowType | undefined + + /** Create an LD program and give its flow one (valid) rung, marked updated. */ + const makeDirtyLadderPou = (name: string) => { + store.getState().pouActions.create({ type: 'program', name, language: 'ld' }) + store.getState().ladderFlowActions.startLadderRung({ + editorName: name, + rungId: `rung_${name}_1`, + defaultBounds: [300, 100], + reactFlowViewport: [300, 100], + }) + store.getState().ladderFlowActions.setFlowUpdated({ editorName: name, updated: true }) + } + + beforeEach(() => { + vi.useFakeTimers() + store = makeStore() + }) + + afterEach(() => { + cancelFlowWriteBacks() + vi.useRealTimers() + }) + + describe('scheduleFlowWriteBack', () => { + it('marks the file dirty immediately but defers the body write-back', () => { + makeDirtyLadderPou('Main') + store.getState().fileActions.updateFile({ name: 'Main', saved: true }) + + scheduleFlowWriteBack(getState, 'Main', 'ld') + + expect(store.getState().files['Main']?.saved).toBe(false) + expect(ladderBody('Main')?.rungs).toHaveLength(0) + + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) + + expect(ladderBody('Main')?.rungs).toHaveLength(1) + expect(store.getState().ladderFlows.find((f) => f.name === 'Main')?.updated).toBe(false) + }) + + it('persists the flow without the transient updated flag, sharing structure with the flow slice', () => { + makeDirtyLadderPou('Main') + + scheduleFlowWriteBack(getState, 'Main', 'ld') + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) + + const body = ladderBody('Main') + const flow = store.getState().ladderFlows.find((f) => f.name === 'Main') + expect(body && 'updated' in body).toBe(false) + // By-reference persistence: the project copy and the live flow share + // the (immutable) rung objects instead of deep-cloning them. + expect(body?.rungs[0]).toBe(flow?.rungs[0]) + }) + + it('does not mark the file dirty while the debugger is visible', () => { + makeDirtyLadderPou('Main') + store.getState().fileActions.updateFile({ name: 'Main', saved: true }) + store.getState().workspaceActions.setDebuggerVisible(true) + + scheduleFlowWriteBack(getState, 'Main', 'ld') + + expect(store.getState().files['Main']?.saved).toBe(true) + }) + + it('coalesces edits into a single pending timer (no debounce reset)', () => { + makeDirtyLadderPou('Main') + + scheduleFlowWriteBack(getState, 'Main', 'ld') + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS / 2) + + // A second edit inside the window re-schedules: the first timer stands. + store.getState().ladderFlowActions.startLadderRung({ + editorName: 'Main', + rungId: 'rung_Main_2', + defaultBounds: [300, 100], + reactFlowViewport: [300, 100], + }) + scheduleFlowWriteBack(getState, 'Main', 'ld') + + // Fires DEBOUNCE_MS after the FIRST schedule and picks up both edits. + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS / 2) + expect(ladderBody('Main')?.rungs).toHaveLength(2) + }) + + it('writes back FBD flows through the same scheduler', () => { + store.getState().pouActions.create({ type: 'program', name: 'FbdMain', language: 'fbd' }) + store.getState().fbdFlowActions.setFlowUpdated({ editorName: 'FbdMain', updated: true }) + + scheduleFlowWriteBack(getState, 'FbdMain', 'fbd') + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) + + const body = store.getState().project.data.pous.find((p) => p.name === 'FbdMain')?.body.value as + | { rung?: unknown; updated?: unknown } + | undefined + expect(body?.rung).toBeDefined() + expect(body && 'updated' in body).toBe(false) + expect(store.getState().fbdFlows.find((f) => f.name === 'FbdMain')?.updated).toBe(false) + }) + + it('skips execution when the flow is gone or no longer marked updated', () => { + makeDirtyLadderPou('Main') + scheduleFlowWriteBack(getState, 'Ghost', 'ld') + scheduleFlowWriteBack(getState, 'Main', 'ld') + store.getState().ladderFlowActions.setFlowUpdated({ editorName: 'Main', updated: false }) + + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) + + expect(ladderBody('Main')?.rungs).toHaveLength(0) + }) + + it('refuses to persist a flow that fails schema validation', () => { + makeDirtyLadderPou('Main') + // Replace the flow with a malformed one (rung missing bounds/viewport). + // addLadderFlow resets `updated` on store, so re-flag it afterwards. + store.getState().ladderFlowActions.addLadderFlow({ + name: 'Main', + updated: true, + rungs: [{ id: 'r1', nodes: [], edges: [] }], + } as unknown as LadderFlowType) + store.getState().ladderFlowActions.setFlowUpdated({ editorName: 'Main', updated: true }) + + 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) + }) + }) + + describe('flushFlowWriteBacks', () => { + it('runs a pending write-back immediately', () => { + makeDirtyLadderPou('Main') + scheduleFlowWriteBack(getState, 'Main', 'ld') + + flushFlowWriteBacks(getState) + + expect(ladderBody('Main')?.rungs).toHaveLength(1) + // The timer was cancelled along with the flush — nothing fires later. + const bodyAfterFlush = ladderBody('Main') + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) + expect(ladderBody('Main')).toBe(bodyAfterFlush) + }) + + it('scopes the flush to one POU when a name is given', () => { + makeDirtyLadderPou('A') + makeDirtyLadderPou('B') + scheduleFlowWriteBack(getState, 'A', 'ld') + scheduleFlowWriteBack(getState, 'B', 'ld') + + flushFlowWriteBacks(getState, 'A') + + expect(ladderBody('A')?.rungs).toHaveLength(1) + expect(ladderBody('B')?.rungs).toHaveLength(0) + + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) + expect(ladderBody('B')?.rungs).toHaveLength(1) + }) + + it('is a no-op when nothing is pending', () => { + makeDirtyLadderPou('Main') + const bodyBefore = ladderBody('Main') + + flushFlowWriteBacks(getState) + + expect(ladderBody('Main')).toBe(bodyBefore) + }) + }) + + describe('cancelFlowWriteBacks', () => { + it('drops pending write-backs without running them', () => { + makeDirtyLadderPou('Main') + scheduleFlowWriteBack(getState, 'Main', 'ld') + + cancelFlowWriteBacks() + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) + + expect(ladderBody('Main')?.rungs).toHaveLength(0) + expect(store.getState().ladderFlows.find((f) => f.name === 'Main')?.updated).toBe(true) + }) + + it('scopes the cancel to one POU when a name is given', () => { + makeDirtyLadderPou('A') + makeDirtyLadderPou('B') + scheduleFlowWriteBack(getState, 'A', 'ld') + scheduleFlowWriteBack(getState, 'B', 'ld') + + cancelFlowWriteBacks('A') + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) + + expect(ladderBody('A')?.rungs).toHaveLength(0) + expect(ladderBody('B')?.rungs).toHaveLength(1) + }) + }) +}) diff --git a/src/frontend/store/slices/shared/flow-writeback.ts b/src/frontend/store/slices/shared/flow-writeback.ts new file mode 100644 index 000000000..194b04e32 --- /dev/null +++ b/src/frontend/store/slices/shared/flow-writeback.ts @@ -0,0 +1,96 @@ +import { zodFBDFlowSchema } from '../fbd' +import { zodLadderFlowSchema } from '../ladder' +import type { SharedRootState } from './types' + +/** + * Debounced write-back of graphical flow state (ladder / FBD) into + * `project.data.pous[].body.value`. + * + * Every graphical edit used to trigger an immediate whole-flow + * `structuredClone` into the project slice (plus a monaco-model-sync sweep + * per write). Edits inside the debounce window now coalesce into a single + * write-back that persists the flow **by reference** — the store is + * immer-managed (frozen, copy-on-write), so the project copy and the live + * flow safely share structure. + * + * A pending timer means `pou.body.value` is momentarily stale, so the rest + * of the app must cooperate: + * - save paths call `flushFlowWriteBacks` before serializing, so a save + * landing inside the debounce window still persists the fresh body; + * - undo/redo and snapshot capture flush the affected POU so a history + * entry never pairs a stale body with a fresh flow; + * - project open cancels pending timers outright — a write-back scheduled + * against the previous project must not fire into the new one. + * + * Execution re-reads the store at fire time and is guarded on + * `flow.updated`, so a flush after save/undo already cleared the flag is a + * no-op. + */ + +export const FLOW_WRITEBACK_DEBOUNCE_MS = 200 + +type FlowLanguage = 'ld' | 'fbd' +type GetWriteBackState = () => SharedRootState + +const pendingWriteBacks = new Map }>() + +function runWriteBack(getState: GetWriteBackState, pouName: string, language: FlowLanguage): void { + 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 + + // Validate with zod but persist the raw object (minus the transient + // `updated` flag). Using the parsed result would silently strip every + // field not declared in the schema and reorder keys to schema order, + // 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 { 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 }) +} + +/** + * Mark the POU dirty now and queue its write-back. Edits landing while a + * timer is pending coalesce into it — the executor reads the store at fire + * time, so it always persists the latest flow. + */ +export function scheduleFlowWriteBack(getState: GetWriteBackState, pouName: string, language: FlowLanguage): void { + const state = getState() + // Dirty-marking keeps its immediate, per-edit timing; only the expensive + // updatePou is deferred. The debugger drives node values through the same + // flow state without making the file unsaved, hence the gate. + if (!state.workspace.isDebuggerVisible) { + state.sharedWorkspaceActions.handleFileAndWorkspaceSavedState(pouName) + } + if (pendingWriteBacks.has(pouName)) return + const timer = setTimeout(() => { + pendingWriteBacks.delete(pouName) + runWriteBack(getState, pouName, language) + }, FLOW_WRITEBACK_DEBOUNCE_MS) + 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) + } +} + +/** Drop pending write-backs without running them (project open). */ +export function cancelFlowWriteBacks(pouName?: string): void { + for (const [name, pending] of [...pendingWriteBacks]) { + if (pouName !== undefined && name !== pouName) continue + clearTimeout(pending.timer) + pendingWriteBacks.delete(name) + } +} diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 27933fd89..3d1219d18 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -1,7 +1,6 @@ import { produce } from 'immer' import { StateCreator } from 'zustand' -import type { PLCVariable } from '../../../../middleware/shared/ports/types' import { isValidIecIdentifier } from '../../../../middleware/shared/utils/ethercat' import { parseIecStringToVariables } from '../../../utils/generate-iec-string-to-variables' import { generateIecVariablesToString } from '../../../utils/generate-iec-variables-to-string' @@ -11,7 +10,6 @@ import { restampFlowLibraryVariants } from '../../../utils/PLC/restamp-library-v import { collectAllSlaveNames } from '../../../utils/unique-slave-name' import type { FBDFlowType } from '../fbd' import type { FileSliceDataObject } from '../file' -import type { HistorySnapshot } from '../history' import type { LadderFlowType } from '../ladder' import type { TabsProps } from '../tabs' import { @@ -20,7 +18,8 @@ import { CreateServerEditor, LIBRARY_MANIFEST_TAB_NAME, } from '../tabs/utils' -import type { SharedRootState, SharedSlice } from './types' +import { cancelFlowWriteBacks, flushFlowWriteBacks } from './flow-writeback' +import type { PouHistorySnapshot, SharedRootState, SharedSlice } from './types' import { createDatatypeObject, createEditorObjectForDatatype, createEditorObjectForPou, createPouObject } from './utils' const MAX_HISTORY_SIZE = 50 @@ -587,6 +586,10 @@ const createSharedSlice: StateCreator = (s }, handleOpenProjectResponse: (data) => { + // A write-back scheduled against the previous project must not fire + // into the one being opened (project load flips `updated` flags as a + // side effect, which would let a stale timer persist a fresh flow). + cancelFlowWriteBacks() getState().sharedWorkspaceActions.clearStatesOnCloseProject() getState().workspaceActions.setEditingState('saved') // Any in-place reload (branch switch, restore, discard, stash) can move @@ -987,6 +990,9 @@ 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) const state = getState() const history = state.undoRedo[pouName] if (!history || history.past.length === 0) return @@ -995,17 +1001,15 @@ const createSharedSlice: StateCreator = (s const pou = state.project.data.pous.find((p) => p.name === pouName) if (!pou) return - // Save current state to future (deep copy to avoid mutation) - const ladderFlow = state.ladderFlows.find((f) => f.name === pouName) - const fbdFlow = state.fbdFlows.find((f) => f.name === pouName) - const currentSnapshot: HistorySnapshot = { - variables: JSON.parse(JSON.stringify(pou.interface?.variables ?? [])) as PLCVariable[], - body: JSON.parse(JSON.stringify(pou.body.value)) as unknown, - ladderFlow: ladderFlow ? JSON.parse(JSON.stringify(ladderFlow)) : undefined, - fbdFlow: fbdFlow ? JSON.parse(JSON.stringify(fbdFlow)) : undefined, - globalVariables: JSON.parse( - JSON.stringify(state.project.data.configurations.resource.globalVariables), - ) as PLCVariable[], + // 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, } setState( @@ -1044,6 +1048,8 @@ const createSharedSlice: StateCreator = (s }, redo: (pouName) => { + // See undo — same pending write-back consistency requirement. + flushFlowWriteBacks(getState, pouName) const state = getState() const history = state.undoRedo[pouName] if (!history || history.future.length === 0) return @@ -1052,17 +1058,13 @@ const createSharedSlice: StateCreator = (s const pou = state.project.data.pous.find((p) => p.name === pouName) if (!pou) return - // Save current state to past (deep copy to avoid mutation) - const ladderFlow = state.ladderFlows.find((f) => f.name === pouName) - const fbdFlow = state.fbdFlows.find((f) => f.name === pouName) - const currentSnapshot: HistorySnapshot = { - variables: JSON.parse(JSON.stringify(pou.interface?.variables ?? [])) as PLCVariable[], - body: JSON.parse(JSON.stringify(pou.body.value)) as unknown, - ladderFlow: ladderFlow ? JSON.parse(JSON.stringify(ladderFlow)) : undefined, - fbdFlow: fbdFlow ? JSON.parse(JSON.stringify(fbdFlow)) : undefined, - globalVariables: JSON.parse( - JSON.stringify(state.project.data.configurations.resource.globalVariables), - ) as PLCVariable[], + // 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, } setState( From a4fd37639167333659463420de176f57dca5614c Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:22:01 -0300 Subject: [PATCH 36/52] fix(review): mirror web PR #528 review fixes (shared surface sync) connection-types powerRail fold + dead deferred-resolve removal + EN dedup guard; drop unused isOfType; remove write-only SyntheticVar origin fields; block-library isRecord export + function-block-instance mapping; pou-graphical shared isRecord + first-wins dedup note. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KNxKRqXuzVYSvfmUsvg2Ly --- .../st-transpiler/emit/pou-graphical.ts | 10 +++---- .../st-transpiler/helpers/block-library.ts | 5 ++-- .../st-transpiler/helpers/type-hierarchy.ts | 26 +++---------------- .../st-transpiler/walker/connection-types.ts | 20 +++++--------- .../transpilers/st-transpiler/walker/ld.ts | 24 ++++++----------- 5 files changed, 25 insertions(+), 60 deletions(-) diff --git a/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts b/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts index d5cc9b2ba..101d34a41 100644 --- a/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts +++ b/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts @@ -10,7 +10,7 @@ */ import { PLC_BASE_TYPES } from '../helpers/base-types' -import { type BlockInfos, blockInfosFromVariant } from '../helpers/block-library' +import { type BlockInfos, blockInfosFromVariant, isRecord } from '../helpers/block-library' import type { ProgramChunk } from '../helpers/program' import { computePouName } from '../helpers/text-helpers' import { varTypeNames } from '../helpers/type-text' @@ -100,13 +100,11 @@ export function generateGraphicalPou(pou: TranspilePou, project: TranspileProjec /* ────────────────────────── helpers ─────────────────────────────────────── */ -function isRecord(v: unknown): v is Record { - return typeof v === 'object' && v !== null && !Array.isArray(v) -} - // Block signatures from every graphical block instance's variant — the // co-located equivalent of xml2st's embedded payload. Deduped -// by name; user POUs are excluded (they resolve from their own interface). +// by name, first instance wins — deliberately mirroring the oracle's +// dedup; user POUs are excluded (they resolve from their +// own interface). function collectBlockSignatures(project: TranspileProject): Map { const userPouNames = new Set(project.pous.map((p) => p.name)) const registry = new Map() diff --git a/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts b/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts index fae41f247..ce485b1d6 100644 --- a/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts +++ b/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts @@ -27,7 +27,7 @@ export interface BlockInfos { usage: string } -function isRecord(v: unknown): v is Record { +export function isRecord(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v) } @@ -64,7 +64,8 @@ export function blockInfosFromVariant(variant: unknown): BlockInfos | null { return { name, - type: variant.type === 'function-block' ? 'functionBlock' : 'function', + type: + variant.type === 'function-block' || variant.type === 'function-block-instance' ? 'functionBlock' : 'function', extensible: variant.extensible === true, inputs, outputs, diff --git a/src/backend/shared/transpilers/st-transpiler/helpers/type-hierarchy.ts b/src/backend/shared/transpilers/st-transpiler/helpers/type-hierarchy.ts index 964fe5e84..ad239c084 100644 --- a/src/backend/shared/transpilers/st-transpiler/helpers/type-hierarchy.ts +++ b/src/backend/shared/transpilers/st-transpiler/helpers/type-hierarchy.ts @@ -1,22 +1,15 @@ /** - * IEC 61131-3 type hierarchy + compatibility predicate. + * IEC 61131-3 type hierarchy. * - * Mirrors ``plcopen/definitions.py:84-119`` (`TypeHierarchy_list`), - * ``plcopen/structures.py:36-48`` (`IsOfType`), and + * Mirrors ``plcopen/definitions.py:84-119`` (`TypeHierarchy_list`) and * ``plcopen/structures.py:51-59`` (`GetSubTypes`). * * The hierarchy is a tree rooted at `"ANY"` with each concrete IEC type * (`"INT"`, `"BOOL"`, `"REAL"`, …) attached under its ANY-prefixed - * meta-parent. `isOfType(child, ancestor)` is true when walking parent - * pointers from `child` eventually reaches `ancestor`. - * - * This is used by `GetBlockType`'s overload resolution: given a call like - * `ADD(myInt, myInt)` with signature `(ANY_NUM, ANY_NUM) -> ANY_NUM`, each - * input is type-checked via `isOfType("INT", "ANY_NUM")`. + * meta-parent. * * Note on WSTRING: Python's hierarchy comments-out `("WSTRING", "ANY_STRING")` - * with a TODO. We preserve that — WSTRING returns false for any IsOfType - * lookup against ancestors except itself. + * with a TODO. We preserve that — WSTRING is absent from the table. */ /** @@ -62,14 +55,3 @@ export const TypeHierarchy: Readonly> = { __XWORD: 'ANY_NBIT', // WSTRING intentionally absent — matches Python's `# TODO` comment. } - -// IsOfType (structures.py:36-48): soft-false for unknown/user types (DIVERGENCES.md D3) -export function isOfType(child: string, ancestor: string): boolean { - if (child === ancestor) return true - let current = TypeHierarchy[child] - while (current !== undefined && current !== null) { - if (current === ancestor) return true - current = TypeHierarchy[current] - } - return false -} diff --git a/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts b/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts index 53ee65ed7..ce67686b7 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts +++ b/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts @@ -4,10 +4,9 @@ * * Two passes over the flattened body graph. Pass 1 seeds concrete * types from declared variables, literals, contacts/coils/rails and - * single-signature blocks; blocks whose signature is ambiguous are - * deferred. Pass 2 resolves deferred blocks by overload match against - * the now-known input types (permissive all-ANY synthesis when - * unknown), then unifies each ANY-class pin group with any concrete + * resolvable blocks; blocks with no known signature are deferred. + * Pass 2 gives each deferred block a permissive all-ANY synthesized + * signature and unifies each ANY-class pin group with any concrete * connected type. Pins known to share a type but still untyped live * in `related` groups that collapse when one member gets typed. */ @@ -123,15 +122,7 @@ export function computeConnectionTypes(body: RFBody, ctx: TypeContext): Map_`) - * — `type` is `'BOOL'` for `ENO`, otherwise the literal string - * `'ANY'`. When `'ANY'`, the caller resolves it against the - * standard block catalog or the project's POU table using - * `originBlockTypeName` + `originFormalParameter`. + * — `type` is `'BOOL'` for `ENO`, otherwise the graph-inferred + * pin type from `connection-types.ts` (literal `'ANY'` when no + * concrete type reaches the pin, matching Python). */ export interface SyntheticVar { name: string type: string - originBlockTypeName?: string - originFormalParameter?: string } export interface EmitResult { @@ -82,15 +78,11 @@ interface WalkerState { declaredVars: Set triggerVars: { name: string; type: 'R_TRIG' | 'F_TRIG' }[] /** `_TMP__` temps synthesised by - * function-call emission. `originBlockTypeName` + - * `originFormalParameter` let the caller resolve `'ANY'` types - * against the standard block catalog or a project POU's declared - * `returnType` after the walk completes. */ + * function-call emission. Types come from the pre-computed + * connection-type inference (`'ANY'` terminal fallback). */ functionTempVars: { name: string type: string - originBlockTypeName: string - originFormalParameter: string }[] emittedBlocks: Set /** Output-write sinks (coil / output / inOut variable) keyed by the @@ -315,6 +307,8 @@ function compareNodePosition(state: WalkerState, a: RFNode, b: RFNode): number { function blockFedSink(state: WalkerState, node: RFNode): string | null { if (node.type !== 'coil' && !isVariableNode(node)) return null const edges = state.incoming.get(node.id) ?? [] + // Only direct block->sink edges couple; sinks fed through a + // passthrough node or with extra wires stay on the positional sweep. if (edges.length !== 1) return null const src = state.byId.get(edges[0].source) if (src === undefined || src.type !== 'block') return null @@ -847,8 +841,6 @@ function emitFunctionCall(state: WalkerState, node: RFNode, data: BlockData): vo state.functionTempVars.push({ name: tempName, type: tempType, - originBlockTypeName: data.typeName, - originFormalParameter: out, }) const isPrimary = data.outputs.length === 1 || out === '' || out === 'OUT' if (isPrimary && primaryName === null) { From 5ebab225c03e6eba857367b2fa37773f7589f332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Tue, 21 Jul 2026 15:23:24 -0300 Subject: [PATCH 37/52] style: prettier line-wrap in flow-writeback.ts Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017RGT8nUsyY26HXFSuTBFLz --- src/frontend/store/slices/shared/flow-writeback.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/frontend/store/slices/shared/flow-writeback.ts b/src/frontend/store/slices/shared/flow-writeback.ts index 194b04e32..07e3cd491 100644 --- a/src/frontend/store/slices/shared/flow-writeback.ts +++ b/src/frontend/store/slices/shared/flow-writeback.ts @@ -37,7 +37,9 @@ const pendingWriteBacks = new Map f.name === pouName) : state.fbdFlows.find((f) => f.name === pouName) + language === 'ld' + ? state.ladderFlows.find((f) => f.name === pouName) + : state.fbdFlows.find((f) => f.name === pouName) if (!flow?.updated) return // Validate with zod but persist the raw object (minus the transient From 4eda412f3c424123210e8d68278c0e845b11f22e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Tue, 21 Jul 2026 17:27:44 -0300 Subject: [PATCH 38/52] perf(graphical-editor): scope per-edit scans + batch hot-path store writes (DOPE-491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - syncNodesWithVariables(FBD): batched updateNodes callback (one store commit per sweep) + optional rungId scoping; ladder add/drag-stop are rung-scoped, remove stays flow-scoped (block removal can delete variables referenced by other rungs); variable-table edits, project open and save relink keep the full sweep - getFBDPouVariablesRungNodeAndEdges: WeakMap-cached per-rung lookups (node-by-id, edges-by-source/target) + variable name index — per-node render cost drops from O(nodes+edges) to O(1) amortized - ladder/FBD debug styling: pure state computation with adjacency maps, deps narrowed to pouType + hasProgramInstance, content-stable guard so polls that don't change a rung keep styledNodes/styledEdges identity - FBD mouse tracking: state -> refs read at paste time; no re-renders on canvas mouse travel - debug poll: single setDebugValues commit per poll cycle (replaces setDebugBoolValues + setDebugNonBoolValues) Mirror of openplc-web fix/dope-491-hot-path-scans Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017RGT8nUsyY26HXFSuTBFLz --- .../graphical-editor/fbd/utils/utils.ts | 133 ++++-- .../fbd/fbd-utils/useCopyPaste.ts | 18 +- .../_molecules/graphical-editor/fbd/index.tsx | 308 +++++++------ .../graphical-editor/ladder/rung/body.tsx | 428 ++++++++++-------- .../variables-table/selectable-cell.tsx | 8 +- .../_organisms/variables-editor/index.tsx | 8 +- src/frontend/hooks/use-content-stable.ts | 24 + src/frontend/hooks/useDebugPolling.ts | 7 +- src/frontend/services/save-actions.ts | 6 +- .../store/__tests__/fbd-slice.test.ts | 36 ++ .../store/__tests__/ladder-slice.test.ts | 46 ++ .../store/__tests__/workspace-slice.test.ts | 40 +- src/frontend/store/slices/fbd/slice.ts | 16 + src/frontend/store/slices/fbd/types.ts | 2 + src/frontend/store/slices/ladder/slice.ts | 19 + src/frontend/store/slices/ladder/types.ts | 2 + src/frontend/store/slices/shared/slice.ts | 8 +- src/frontend/store/slices/workspace/slice.ts | 12 +- src/frontend/store/slices/workspace/types.ts | 4 +- .../sync-nodes-with-variables.test.ts | 242 ++++++---- .../graphical/sync-nodes-with-variables.ts | 189 ++++---- 21 files changed, 965 insertions(+), 591 deletions(-) create mode 100644 src/frontend/hooks/use-content-stable.ts diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/utils/utils.ts b/src/frontend/components/_atoms/graphical-editor/fbd/utils/utils.ts index 535694e28..2ca409646 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/utils/utils.ts +++ b/src/frontend/components/_atoms/graphical-editor/fbd/utils/utils.ts @@ -11,6 +11,102 @@ import { buildHandle } from '../handle' import { DEFAULT_BLOCK_CONNECTOR_Y, DEFAULT_BLOCK_CONNECTOR_Y_OFFSET, DEFAULT_BLOCK_WIDTH } from './constants' import type { BasicNodeData } from './types' +type FBDRung = FBDFlowType['rung'] +type FBDRungNode = FBDRung['nodes'][0] +type FBDRungEdge = FBDRung['edges'][0] + +type RungLookups = { + nodeById: Map + edgesBySource: Map + edgesByTarget: Map +} + +// Per-rung lookup tables, cached on the rung's (immutable) identity. This +// util runs during every FBD node render; the previous linear scans made a +// render pass O(nodes × (nodes + edges)). Immer replaces the rung object on +// any change, so a stale entry can never be served. +const rungLookupsCache = new WeakMap() + +const getRungLookups = (rung: FBDRung): RungLookups => { + let lookups = rungLookupsCache.get(rung) + if (!lookups) { + lookups = { + nodeById: new Map(), + edgesBySource: new Map(), + edgesByTarget: new Map(), + } + for (const node of rung.nodes) { + if (!lookups.nodeById.has(node.id)) lookups.nodeById.set(node.id, node) + } + for (const edge of rung.edges) { + const bySource = lookups.edgesBySource.get(edge.source) + if (bySource) bySource.push(edge) + else lookups.edgesBySource.set(edge.source, [edge]) + const byTarget = lookups.edgesByTarget.get(edge.target) + if (byTarget) byTarget.push(edge) + else lookups.edgesByTarget.set(edge.target, [edge]) + } + rungLookupsCache.set(rung, lookups) + } + return lookups +} + +// Variable names are unique per POU (case-insensitive, enforced by the +// variables table), so a first-wins lowercase index matches `find` exactly. +const variablesByNameCache = new WeakMap>() + +const getVariablesByName = (variables: PLCVariable[]): Map => { + let byName = variablesByNameCache.get(variables) + if (!byName) { + byName = new Map() + for (const variable of variables) { + const key = variable.name.toLowerCase() + if (!byName.has(key)) byName.set(key, variable) + } + variablesByNameCache.set(variables, byName) + } + return byName +} + +const EMPTY_EDGES: FBDRungEdge[] = [] + +const selectNodeVariable = ( + node: FBDRungNode, + variables: PLCVariable[], + variableName: string | undefined, +): PLCVariable | undefined => { + const byName = getVariablesByName(variables) + + const findByNodeVarOrFallback = (): PLCVariable | undefined => { + const nodeVarName = (node.data as BasicNodeData).variable.name + if (nodeVarName !== undefined) return byName.get(nodeVarName.toLowerCase()) + if (variableName === undefined) return undefined + const candidate = byName.get(variableName.toLowerCase()) + return candidate?.name === variableName ? candidate : undefined + } + + switch (node.type as keyof typeof customNodeTypes) { + case 'block': { + const nodeVarName = (node.data as BasicNodeData).variable.name + return nodeVarName !== undefined ? byName.get(nodeVarName.toLowerCase()) : undefined + } + case 'connector': + case 'continuation': + case 'comment': + return undefined + case 'input-variable': + case 'output-variable': + case 'inout-variable': + // Variable nodes - allow all types including derived (user-defined types) + return findByNodeVarOrFallback() + default: { + // Other node types - only allow base types (not derived/user-defined) + const candidate = findByNodeVarOrFallback() + return candidate && candidate.type.definition !== 'derived' ? candidate : undefined + } + } +} + // `pouName` is the bound POU for the caller's editor instance (from // `useBoundPou()` under multi-mount, or the active editor's name for // legacy single-mount call sites). Taking it as a string instead of @@ -34,38 +130,11 @@ export const getFBDPouVariablesRungNodeAndEdges = ( } => { const pou = pous.find((pou) => pou.name === pouName) const rung = fbdFlows.find((flow) => flow.name === pouName)?.rung - const node = rung?.nodes.find((node) => node.id === data.nodeId) + const lookups = rung ? getRungLookups(rung) : undefined + const node = lookups?.nodeById.get(data.nodeId) const variables: PLCVariable[] = pou?.interface?.variables ?? [] - let variable = variables.find((variable) => { - if (!node) return undefined - switch (node.type as keyof typeof customNodeTypes) { - case 'block': - return ( - (node.data as BasicNodeData).variable.name !== undefined && - (node.data as BasicNodeData).variable.name.toLowerCase() === variable.name.toLowerCase() - ) - case 'connector': - case 'continuation': - return undefined - case 'comment': - return undefined - case 'input-variable': - case 'output-variable': - case 'inout-variable': - // Variable nodes - allow all types including derived (user-defined types) - return (node.data as BasicNodeData).variable.name !== undefined - ? variable.name.toLowerCase() === (node.data as BasicNodeData).variable.name.toLowerCase() - : variable.name === data.variableName - default: - // Other node types - only allow base types (not derived/user-defined) - return ( - ((node.data as BasicNodeData).variable.name !== undefined - ? variable.name.toLowerCase() === (node.data as BasicNodeData).variable.name.toLowerCase() - : variable.name === data.variableName) && variable.type.definition !== 'derived' - ) - } - }) + let variable = node && variables.length > 0 ? selectNodeVariable(node, variables, data.variableName) : undefined // Fallback: try to resolve as array element access (e.g. "Sensor[0]") if (!variable && node) { @@ -88,8 +157,8 @@ export const getFBDPouVariablesRungNodeAndEdges = ( } } - const edgesThatNodeIsSource = rung?.edges.filter((edge) => edge.source === data.nodeId) - const edgesThatNodeIsTarget = rung?.edges.filter((edge) => edge.target === data.nodeId) + const edgesThatNodeIsSource = lookups ? (lookups.edgesBySource.get(data.nodeId) ?? EMPTY_EDGES) : undefined + const edgesThatNodeIsTarget = lookups ? (lookups.edgesByTarget.get(data.nodeId) ?? EMPTY_EDGES) : undefined return { pou, diff --git a/src/frontend/components/_molecules/graphical-editor/fbd/fbd-utils/useCopyPaste.ts b/src/frontend/components/_molecules/graphical-editor/fbd/fbd-utils/useCopyPaste.ts index 35b4f308f..360e063d5 100644 --- a/src/frontend/components/_molecules/graphical-editor/fbd/fbd-utils/useCopyPaste.ts +++ b/src/frontend/components/_molecules/graphical-editor/fbd/fbd-utils/useCopyPaste.ts @@ -13,15 +13,20 @@ import { } from '../../../../_features/[workspace]/editor/graphical/active-context' export const useFBDClipboard = ({ - mousePosition, - insideViewport, + mousePositionRef, + insideViewportRef, reactFlowInstance, rung, viewportRef, handleDeleteNodes, }: { - mousePosition: { x: number; y: number } - insideViewport: boolean + /** + * Refs, not values: mouse position and hover state change on every pointer + * move, and they're only read at paste time — passing them as refs keeps + * the FBD container from re-rendering while the mouse travels the canvas. + */ + mousePositionRef: RefObject<{ x: number; y: number }> + insideViewportRef: RefObject reactFlowInstance: ReactFlowInstance | null rung: FBDRungState /** @@ -172,8 +177,9 @@ export const useFBDClipboard = ({ return } + const mousePosition = mousePositionRef.current ?? { x: 0, y: 0 } const nodePosition: XYPosition = reactFlowInstance - ? insideViewport + ? insideViewportRef.current ? reactFlowInstance.screenToFlowPosition({ x: mousePosition.x, y: mousePosition.y, @@ -214,7 +220,7 @@ export const useFBDClipboard = ({ }) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [isActive, insideViewport, mousePosition, reactFlowInstance, fbdFlowActions, rung], + [isActive, reactFlowInstance, fbdFlowActions, rung], ) useEffect(() => { diff --git a/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx b/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx index 2c5eb38bf..4739e3bd8 100644 --- a/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx +++ b/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx @@ -16,6 +16,8 @@ import { import { debounce, isEqual } from 'lodash' import { DragEvent, MouseEvent, useEffect, useMemo, useRef, useState } from 'react' +import type { PLCVariable } from '../../../../../middleware/shared/ports/types' +import { mapsEqual, useContentStable } from '../../../../hooks/use-content-stable' import { useDebugCompositeKey } from '../../../../hooks/use-debug-composite-key' import { useDebugBoolValuesMap, @@ -54,65 +56,38 @@ const SNAP_GRID: SnapGrid = [16, 16] const PRO_OPTIONS = { hideAttribution: true } const CONTROLS_CONFIG = { showInteractive: false } -export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false }: FBDProps) => { - // Bound POU + editor model — every multi-mounted FBDBody reads - // its OWN POU from the `GraphicalEditorActiveProvider` so cross- - // tab store mutations don't fire effects against the wrong flow. - const pouName = useBoundPou() - const editor = useBoundEditorModel() - const updateModelVariables = useOpenPLCStore((state) => state.editorActions.updateModelVariables) - const fbdFlowActions = useOpenPLCStore((state) => state.fbdFlowActions) - const deleteVariable = useOpenPLCStore((state) => state.projectActions.deleteVariable) - const { closeModal, openModal } = useOpenPLCStore((state) => state.modalActions) - const blockElementModal = useOpenPLCStore((state) => state.modals['block-fbd-element']) - const pous = useOpenPLCStore((state) => state.project.data.pous) - const resourceInstances = useOpenPLCStore((state) => state.project.data.configurations.resource.instances) - const isDebuggerVisible = useIsDebuggerVisible() - const debugVariableValues = useDebugBoolValuesMap() - const debugForcedVariables = useDebugForcedVariablesMap() - const { captureAndPush } = usePouSnapshot() - - const pouRef = pous.find((pou) => pou.name === pouName) - const getCompositeKey = useDebugCompositeKey() - const [rungLocal, setRungLocal] = useState(rung) - const [dragging, setDragging] = useState(false) - - const [reactFlowInstance, setReactFlowInstance] = useState(null) - const reactFlowViewportRef = useRef(null) - - const [insideViewport, setInsideViewport] = useState(false) - const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }) - useFBDClipboard({ - mousePosition, - insideViewport, - reactFlowInstance, - rung, - viewportRef: reactFlowViewportRef, - handleDeleteNodes: (nodes, edges) => { - handleOnDelete(nodes, edges) - }, - }) +// --- Debug edge coloring --- - const nodeTypes = useMemo(() => customNodeTypes, []) - const canZoom = useMemo(() => { - if (editor.type === 'plc-graphical' && editor.graphical.language === 'fbd') { - return editor.graphical.canEditorZoom - } - return false - }, [editor]) - const canPan = useMemo(() => { - if (editor.type === 'plc-graphical' && editor.graphical.language === 'fbd') { - return editor.graphical.canEditorPan - } - return false - }, [editor]) +type FBDDebugContext = { + isFunctionBlockPou: boolean + hasProgramInstance: boolean + getCompositeKey: (variableName: string) => string + boolValues: Map + forcedValues: Map + pouVariables: PLCVariable[] | undefined +} - // --- Debug edge coloring and node lockdown --- +const computeFBDEdgeStates = ( + nodes: FBDRungState['nodes'], + edges: FBDRungState['edges'], + ctx: FBDDebugContext, +): Map => { + const nodeById = new Map(nodes.map((node) => [node.id, node])) + const edgeById = new Map(edges.map((edge) => [edge.id, edge])) + const edgesByTarget = new Map() + for (const edge of edges) { + const list = edgesByTarget.get(edge.target) + if (list) list.push(edge) + else edgesByTarget.set(edge.target, [edge]) + } + const variablesByName = new Map() + for (const variable of ctx.pouVariables ?? []) { + const key = variable.name.toLowerCase() + if (!variablesByName.has(key)) variablesByName.set(key, variable) + } const getNodeOutputState = (nodeId: string, sourceHandle: string | null | undefined): boolean | undefined => { - if (!isDebuggerVisible) return undefined - - const node = rungLocal.nodes.find((n) => n.id === nodeId) + const node = nodeById.get(nodeId) if (!node) return undefined if (node.type === 'input-variable' || node.type === 'output-variable' || node.type === 'inout-variable') { @@ -120,23 +95,19 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } const variableName = variableData.variable?.name if (!variableName) return undefined - if (!pouRef) return undefined - const variable = (pouRef.interface?.variables ?? []).find( - (v) => v.name.toLowerCase() === variableName.toLowerCase(), - ) + const variable = variablesByName.get(variableName.toLowerCase()) if (!variable || variable.type.value.toUpperCase() !== 'BOOL') return undefined - const compositeKey = getCompositeKey(variableName) + const compositeKey = ctx.getCompositeKey(variableName) - if (debugForcedVariables.has(compositeKey)) { - return debugForcedVariables.get(compositeKey) + if (ctx.forcedValues.has(compositeKey)) { + return ctx.forcedValues.get(compositeKey) } - const value = debugVariableValues.get(compositeKey) + const value = ctx.boolValues.get(compositeKey) if (value === undefined) return undefined - const isTrue = value === '1' || value.toUpperCase() === 'TRUE' - return isTrue + return value === '1' || value.toUpperCase() === 'TRUE' } if (node.type === 'block') { @@ -146,10 +117,7 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } } if (!sourceHandle) return undefined - if (pouRef?.pouType !== 'function-block') { - const programInstance = resourceInstances.find((inst: { program: string }) => inst.program === pouName) - if (!programInstance) return undefined - } + if (!ctx.isFunctionBlockPou && !ctx.hasProgramInstance) return undefined const outputVariable = blockData.variant?.variables.find((v) => v.name === sourceHandle) if (!outputVariable || outputVariable.type.value.toUpperCase() !== 'BOOL') return undefined @@ -158,27 +126,23 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } const blockVariableName = blockData.variable?.name if (!blockVariableName) return undefined - const outputVariableName = `${blockVariableName}.${sourceHandle}` - const compositeKey = getCompositeKey(outputVariableName) - const value = debugVariableValues.get(compositeKey) + const compositeKey = ctx.getCompositeKey(`${blockVariableName}.${sourceHandle}`) + const value = ctx.boolValues.get(compositeKey) if (value === undefined) return undefined - const isTrue = value === '1' || value.toUpperCase() === 'TRUE' - return isTrue + return value === '1' || value.toUpperCase() === 'TRUE' } else if (blockData.variant?.type === 'function') { const blockName = blockData.variant.name.toUpperCase() const numericId = (node.data as { numericId?: string }).numericId if (!numericId) return undefined - const tempVarName = `_TMP_${blockName}${numericId}_${sourceHandle.toUpperCase()}` - const compositeKey = getCompositeKey(tempVarName) - const value = debugVariableValues.get(compositeKey) + const compositeKey = ctx.getCompositeKey(`_TMP_${blockName}${numericId}_${sourceHandle.toUpperCase()}`) + const value = ctx.boolValues.get(compositeKey) if (value === undefined) return undefined - const isTrue = value === '1' || value.toUpperCase() === 'TRUE' - return isTrue + return value === '1' || value.toUpperCase() === 'TRUE' } return undefined @@ -187,59 +151,152 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } return undefined } - const styledEdges = useMemo(() => { - if (!isDebuggerVisible) { - return rungLocal.edges + const isPassThroughNode = (node: FBDRungState['nodes'][number]): boolean => { + return node.type === 'connector' || node.type === 'continuation' + } + + const edgeStates = new Map() + + const determineEdgeState = (edgeId: string, visited: Set): boolean => { + if (edgeStates.has(edgeId)) { + return edgeStates.get(edgeId)! } - const edgeStateMap = new Map() + if (visited.has(edgeId)) { + return false + } + visited.add(edgeId) - const isPassThroughNode = (node: (typeof rungLocal.nodes)[number]): boolean => { - return node.type === 'connector' || node.type === 'continuation' + const edge = edgeById.get(edgeId) + if (!edge) { + visited.delete(edgeId) + return false } - const determineEdgeState = (edgeId: string, visited: Set = new Set()): boolean => { - if (edgeStateMap.has(edgeId)) { - return edgeStateMap.get(edgeId)! - } + const sourceNode = nodeById.get(edge.source) + if (!sourceNode) { + visited.delete(edgeId) + return false + } - if (visited.has(edgeId)) { - return false - } - visited.add(edgeId) + const incomingEdges = edgesByTarget.get(edge.source) ?? [] + const isInputGreen = incomingEdges.some((incomingEdge) => determineEdgeState(incomingEdge.id, visited)) - const edge = rungLocal.edges.find((e) => e.id === edgeId) - if (!edge) { - visited.delete(edgeId) - return false - } + const sourceOutputState = getNodeOutputState(edge.source, edge.sourceHandle) - const sourceNode = rungLocal.nodes.find((n) => n.id === edge.source) - if (!sourceNode) { - visited.delete(edgeId) - return false - } + const isGreen = isPassThroughNode(sourceNode) ? isInputGreen : sourceOutputState === true + + edgeStates.set(edgeId, isGreen) + visited.delete(edgeId) + return isGreen + } - const incomingEdges = rungLocal.edges.filter((e) => e.target === edge.source) - const isInputGreen = incomingEdges.some((incomingEdge) => determineEdgeState(incomingEdge.id, visited)) + edges.forEach((edge) => { + determineEdgeState(edge.id, new Set()) + }) - const sourceOutputState = getNodeOutputState(edge.source, edge.sourceHandle) + return edgeStates +} - const isGreen = isPassThroughNode(sourceNode) ? isInputGreen : sourceOutputState === true +const fbdEdgeStatesEqual = (previous: Map | null, next: Map | null): boolean => + previous !== null && next !== null && mapsEqual(previous, next) - edgeStateMap.set(edgeId, isGreen) - visited.delete(edgeId) - return isGreen +export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false }: FBDProps) => { + // Bound POU + editor model — every multi-mounted FBDBody reads + // its OWN POU from the `GraphicalEditorActiveProvider` so cross- + // tab store mutations don't fire effects against the wrong flow. + const pouName = useBoundPou() + const editor = useBoundEditorModel() + const updateModelVariables = useOpenPLCStore((state) => state.editorActions.updateModelVariables) + const fbdFlowActions = useOpenPLCStore((state) => state.fbdFlowActions) + const deleteVariable = useOpenPLCStore((state) => state.projectActions.deleteVariable) + const { closeModal, openModal } = useOpenPLCStore((state) => state.modalActions) + const blockElementModal = useOpenPLCStore((state) => state.modals['block-fbd-element']) + const pous = useOpenPLCStore((state) => state.project.data.pous) + const hasProgramInstance = useOpenPLCStore((state) => + state.project.data.configurations.resource.instances.some((instance) => instance.program === pouName), + ) + const isDebuggerVisible = useIsDebuggerVisible() + const debugVariableValues = useDebugBoolValuesMap() + const debugForcedVariables = useDebugForcedVariablesMap() + const { captureAndPush } = usePouSnapshot() + + const pouRef = pous.find((pou) => pou.name === pouName) + const getCompositeKey = useDebugCompositeKey() + const [rungLocal, setRungLocal] = useState(rung) + const [dragging, setDragging] = useState(false) + + const [reactFlowInstance, setReactFlowInstance] = useState(null) + const reactFlowViewportRef = useRef(null) + + // Refs, not state: the values are only read inside the paste handler, and + // state here re-rendered the whole FBDBody on every pointer move over the + // canvas (~75 commits/s of pure overhead while idle). + const insideViewportRef = useRef(false) + const mousePositionRef = useRef({ x: 0, y: 0 }) + useFBDClipboard({ + mousePositionRef, + insideViewportRef, + reactFlowInstance, + rung, + viewportRef: reactFlowViewportRef, + handleDeleteNodes: (nodes, edges) => { + handleOnDelete(nodes, edges) + }, + }) + + const nodeTypes = useMemo(() => customNodeTypes, []) + const canZoom = useMemo(() => { + if (editor.type === 'plc-graphical' && editor.graphical.language === 'fbd') { + return editor.graphical.canEditorZoom + } + return false + }, [editor]) + const canPan = useMemo(() => { + if (editor.type === 'plc-graphical' && editor.graphical.language === 'fbd') { + return editor.graphical.canEditorPan } + return false + }, [editor]) - rungLocal.edges.forEach((edge) => { - determineEdgeState(edge.id, new Set()) - }) + // --- Debug edge coloring and node lockdown --- - return rungLocal.edges.map((edge) => { - const isGreen = edgeStateMap.get(edge.id) + const debugEdgeStates = useMemo( + () => + isDebuggerVisible + ? computeFBDEdgeStates(rungLocal.nodes, rungLocal.edges, { + isFunctionBlockPou: pouRef?.pouType === 'function-block', + hasProgramInstance, + getCompositeKey, + boolValues: debugVariableValues, + forcedValues: debugForcedVariables, + pouVariables: pouRef?.interface?.variables, + }) + : null, + [ + isDebuggerVisible, + rungLocal.nodes, + rungLocal.edges, + pouRef?.pouType, + hasProgramInstance, + getCompositeKey, + debugVariableValues, + debugForcedVariables, + pouRef?.interface?.variables, + ], + ) + + // Identity-stable across polls that didn't change this flow's edge states, + // so styledEdges keeps its identity and the canvas skips re-render. + const stableDebugEdgeStates = useContentStable(debugEdgeStates, fbdEdgeStatesEqual) + + const styledEdges = useMemo(() => { + if (!stableDebugEdgeStates) { + return rungLocal.edges + } - if (isGreen === true) { + return rungLocal.edges.map((edge) => { + if (stableDebugEdgeStates.get(edge.id) === true) { return { ...edge, style: { stroke: EDGE_COLOR_TRUE, strokeWidth: 2 }, @@ -248,16 +305,7 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } return edge }) - }, [ - rungLocal.edges, - rungLocal.nodes, - isDebuggerVisible, - debugVariableValues, - debugForcedVariables, - pouName, - pouRef?.interface?.variables, - resourceInstances, - ]) + }, [rungLocal.edges, stableDebugEdgeStates]) const styledNodes = useMemo(() => { if (isDebuggerActive) { @@ -705,14 +753,14 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } className='h-full w-full rounded-lg border p-1 dark:border-neutral-800' ref={reactFlowViewportRef} onMouseEnter={() => { - setInsideViewport(true) + insideViewportRef.current = true }} onMouseLeave={() => { - setInsideViewport(false) - setMousePosition({ x: 0, y: 0 }) + insideViewportRef.current = false + mousePositionRef.current = { x: 0, y: 0 } }} onMouseMove={(event) => { - setMousePosition({ x: event.clientX, y: event.clientY }) + mousePositionRef.current = { x: event.clientX, y: event.clientY } }} > {} -export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActive = false }: RungBodyProps) => { - const pouName = useBoundPou() - const editor = useBoundEditorModel() - const ladderFlowActions = useOpenPLCStore((state) => state.ladderFlowActions) - const updateModelVariables = useOpenPLCStore((state) => state.editorActions.updateModelVariables) - const deleteVariable = useOpenPLCStore((state) => state.projectActions.deleteVariable) - const openModal = useOpenPLCStore((state) => state.modalActions.openModal) - const searchQuery = useOpenPLCStore((state) => state.searchQuery) - const setSearchNodePosition = useOpenPLCStore((state) => state.searchActions.setSearchNodePosition) - const pous = useOpenPLCStore((state) => state.project.data.pous) - const resourceInstances = useOpenPLCStore((state) => state.project.data.configurations.resource.instances) - const isDebuggerVisible = useIsDebuggerVisible() - const debugVariableValues = useDebugBoolValuesMap() - - const { captureAndPush } = usePouSnapshot() - const pouRef = pous.find((pou) => pou.name === pouName) - const getCompositeKey = useDebugCompositeKey() - const nodeTypes = useMemo(() => customNodeTypes, []) - - const [rungLocal, setRungLocal] = useState(rung) - const [dragging, setDragging] = useState(false) - - const [reactFlowInstance, setReactFlowInstance] = useState(null) - const reactFlowViewportRef = useRef(null) - - /** - * -- Which means, by default, the flow panel extent is: - * minX: 0 | minY: 0 - * maxX: 1530 | maxY: 200 - */ - const [reactFlowPanelExtent, setReactFlowPanelExtent] = useState([ - [0, 0], - (rung?.reactFlowViewport as [number, number]) ?? [1530, 200], - ]) +// --- Debug edge coloring --- - /** - * Update flow panel extent based on the bounds of the nodes - * To make the getNodesBounds function work, the nodes must have width and height properties set in the node data - * This useEffect will run every time the nodes array changes (i.e. when a node is added or removed) - */ - const updateReactFlowPanelExtent = (rung: RungLadderState) => { - const zeroPositionNode: FlowNode = { - id: '-1', - position: { x: 0, y: 0 }, - data: { label: 'Node 0' }, - width: 150, - height: 40, - } - const bounds = getNodesBounds([zeroPositionNode, ...rung.nodes]) - const [defaultWidth, defaultHeight] = rung.defaultBounds +type LadderDebugContext = { + isFunctionBlockPou: boolean + hasProgramInstance: boolean + getCompositeKey: (variableName: string) => string + boolValues: Map +} - // If the bounds are less than the default extent, set the panel extent to the default extent - if (bounds.width < defaultWidth) bounds.width = defaultWidth - if (bounds.height < defaultHeight) bounds.height = defaultHeight +type RungDebugStates = { + edgeStates: Map + nodeInputStates: Map +} - setReactFlowPanelExtent((prev) => - prev[1][0] === bounds.width && prev[1][1] === bounds.height + 20 - ? prev - : [ - [0, 0], - [bounds.width, bounds.height + 20], - ], - ) - ladderFlowActions.updateReactFlowViewport({ - editorName: pouName, - rungId: rungLocal.id, - reactFlowViewport: [bounds.width, bounds.height + 20], - }) +const computeRungDebugStates = ( + nodes: RungLadderState['nodes'], + edges: RungLadderState['edges'], + ctx: LadderDebugContext, +): RungDebugStates => { + const nodeById = new Map(nodes.map((node) => [node.id, node])) + const edgeById = new Map(edges.map((edge) => [edge.id, edge])) + const edgesByTarget = new Map() + for (const edge of edges) { + const list = edgesByTarget.get(edge.target) + if (list) list.push(edge) + else edgesByTarget.set(edge.target, [edge]) } - // --- Debug edge coloring and node lockdown --- - const getNodeOutputState = ( nodeId: string, sourceHandle: string | null | undefined, isInputGreen: boolean, ): boolean | undefined => { - if (!isDebuggerVisible) return undefined - - const node = rungLocal.nodes.find((n) => n.id === nodeId) + const node = nodeById.get(nodeId) if (!node) return undefined if (node.type === 'powerRail') { @@ -180,12 +135,12 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi const variableName = contactData.variable?.name if (!variableName) return undefined - const compositeKey = getCompositeKey(variableName) - const value = debugVariableValues.get(compositeKey) + const compositeKey = ctx.getCompositeKey(variableName) + const value = ctx.boolValues.get(compositeKey) if (value === undefined) return undefined const isTrue = value === '1' || value.toUpperCase() === 'TRUE' - const contactState = (node.data as { variant: 'open' | 'negated' }).variant === 'negated' ? !isTrue : isTrue + const contactState = contactData.variant === 'negated' ? !isTrue : isTrue return isInputGreen && contactState } @@ -202,36 +157,29 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi } if (!sourceHandle) return undefined - if (pouRef?.pouType !== 'function-block') { - const programInstance = resourceInstances.find((inst) => inst.program === pouName) - if (!programInstance) return undefined - } + if (!ctx.isFunctionBlockPou && !ctx.hasProgramInstance) return undefined if (blockData.variant?.type === 'function-block') { const blockVariableName = blockData.variable?.name if (!blockVariableName) return undefined - const outputVariableName = `${blockVariableName}.${sourceHandle}` - const compositeKey = getCompositeKey(outputVariableName) - const value = debugVariableValues.get(compositeKey) + const compositeKey = ctx.getCompositeKey(`${blockVariableName}.${sourceHandle}`) + const value = ctx.boolValues.get(compositeKey) if (value === undefined) return undefined - const isTrue = value === '1' || value.toUpperCase() === 'TRUE' - return isTrue + return value === '1' || value.toUpperCase() === 'TRUE' } else if (blockData.variant?.type === 'function') { const blockName = blockData.variant.name.toUpperCase() const numericId = blockData.numericId if (!numericId) return undefined - const tempVarName = `_TMP_${blockName}${numericId}_${sourceHandle.toUpperCase()}` - const compositeKey = getCompositeKey(tempVarName) - const value = debugVariableValues.get(compositeKey) + const compositeKey = ctx.getCompositeKey(`_TMP_${blockName}${numericId}_${sourceHandle.toUpperCase()}`) + const value = ctx.boolValues.get(compositeKey) if (value === undefined) return undefined - const isTrue = value === '1' || value.toUpperCase() === 'TRUE' - return isTrue + return value === '1' || value.toUpperCase() === 'TRUE' } return undefined @@ -240,46 +188,189 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi return undefined } - const styledEdges = useMemo(() => { - if (!isDebuggerVisible) { - return rungLocal.edges + const edgeStates = new Map() + + const determineEdgeState = (edgeId: string): boolean => { + if (edgeStates.has(edgeId)) { + return edgeStates.get(edgeId)! } - const edgeStateMap = new Map() + const edge = edgeById.get(edgeId) + if (!edge) return false - const determineEdgeState = (edgeId: string): boolean => { - if (edgeStateMap.has(edgeId)) { - return edgeStateMap.get(edgeId)! - } + const incomingEdges = edgesByTarget.get(edge.source) ?? [] - const edge = rungLocal.edges.find((e) => e.id === edgeId) - if (!edge) return false + let isInputGreen = false + if (incomingEdges.length === 0) { + const sourceNode = nodeById.get(edge.source) + isInputGreen = sourceNode?.type === 'powerRail' && (sourceNode.data as { variant: string }).variant === 'left' + } else { + isInputGreen = incomingEdges.some((incomingEdge) => determineEdgeState(incomingEdge.id)) + } - const incomingEdges = rungLocal.edges.filter((e) => e.target === edge.source) + const sourceOutputState = getNodeOutputState(edge.source, edge.sourceHandle, isInputGreen) - let isInputGreen = false - if (incomingEdges.length === 0) { - const sourceNode = rungLocal.nodes.find((n) => n.id === edge.source) - isInputGreen = sourceNode?.type === 'powerRail' && (sourceNode.data as { variant: string }).variant === 'left' - } else { - isInputGreen = incomingEdges.some((incomingEdge) => determineEdgeState(incomingEdge.id)) - } + const isGreen = sourceOutputState === true + edgeStates.set(edgeId, isGreen) + return isGreen + } - const sourceOutputState = getNodeOutputState(edge.source, edge.sourceHandle, isInputGreen) + edges.forEach((edge) => { + determineEdgeState(edge.id) + }) + + const nodeInputStates = new Map() + + const determineNodeInputState = (nodeId: string): boolean => { + if (nodeInputStates.has(nodeId)) { + return nodeInputStates.get(nodeId)! + } + + const node = nodeById.get(nodeId) + if (!node) return false - const isGreen = sourceOutputState === true - edgeStateMap.set(edgeId, isGreen) - return isGreen + if (node.type === 'powerRail' && (node.data as { variant: string }).variant === 'left') { + nodeInputStates.set(nodeId, true) + return true } - rungLocal.edges.forEach((edge) => { - determineEdgeState(edge.id) + const incomingEdges = edgesByTarget.get(nodeId) ?? [] + + if (incomingEdges.length === 0) { + nodeInputStates.set(nodeId, false) + return false + } + + const hasGreenInput = incomingEdges.some((incomingEdge) => { + const sourceInputGreen = determineNodeInputState(incomingEdge.source) + const sourceOutputGreen = getNodeOutputState(incomingEdge.source, incomingEdge.sourceHandle, sourceInputGreen) + return sourceOutputGreen === true }) - return rungLocal.edges.map((edge) => { - const isGreen = edgeStateMap.get(edge.id) + nodeInputStates.set(nodeId, hasGreenInput) + return hasGreenInput + } + + nodes.forEach((node) => { + determineNodeInputState(node.id) + }) + + return { edgeStates, nodeInputStates } +} + +const rungDebugStatesEqual = (previous: RungDebugStates | null, next: RungDebugStates | null): boolean => + previous !== null && + next !== null && + mapsEqual(previous.edgeStates, next.edgeStates) && + mapsEqual(previous.nodeInputStates, next.nodeInputStates) + +export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActive = false }: RungBodyProps) => { + const pouName = useBoundPou() + const editor = useBoundEditorModel() + const ladderFlowActions = useOpenPLCStore((state) => state.ladderFlowActions) + const updateModelVariables = useOpenPLCStore((state) => state.editorActions.updateModelVariables) + const deleteVariable = useOpenPLCStore((state) => state.projectActions.deleteVariable) + const openModal = useOpenPLCStore((state) => state.modalActions.openModal) + const searchQuery = useOpenPLCStore((state) => state.searchQuery) + const setSearchNodePosition = useOpenPLCStore((state) => state.searchActions.setSearchNodePosition) + const pous = useOpenPLCStore((state) => state.project.data.pous) + const hasProgramInstance = useOpenPLCStore((state) => + state.project.data.configurations.resource.instances.some((instance) => instance.program === pouName), + ) + const isDebuggerVisible = useIsDebuggerVisible() + const debugVariableValues = useDebugBoolValuesMap() + + const { captureAndPush } = usePouSnapshot() + const pouRef = pous.find((pou) => pou.name === pouName) + const getCompositeKey = useDebugCompositeKey() + const nodeTypes = useMemo(() => customNodeTypes, []) + + const [rungLocal, setRungLocal] = useState(rung) + const [dragging, setDragging] = useState(false) + + const [reactFlowInstance, setReactFlowInstance] = useState(null) + const reactFlowViewportRef = useRef(null) + + /** + * -- Which means, by default, the flow panel extent is: + * minX: 0 | minY: 0 + * maxX: 1530 | maxY: 200 + */ + const [reactFlowPanelExtent, setReactFlowPanelExtent] = useState([ + [0, 0], + (rung?.reactFlowViewport as [number, number]) ?? [1530, 200], + ]) - if (isGreen === true) { + /** + * Update flow panel extent based on the bounds of the nodes + * To make the getNodesBounds function work, the nodes must have width and height properties set in the node data + * This useEffect will run every time the nodes array changes (i.e. when a node is added or removed) + */ + const updateReactFlowPanelExtent = (rung: RungLadderState) => { + const zeroPositionNode: FlowNode = { + id: '-1', + position: { x: 0, y: 0 }, + data: { label: 'Node 0' }, + width: 150, + height: 40, + } + const bounds = getNodesBounds([zeroPositionNode, ...rung.nodes]) + const [defaultWidth, defaultHeight] = rung.defaultBounds + + // If the bounds are less than the default extent, set the panel extent to the default extent + if (bounds.width < defaultWidth) bounds.width = defaultWidth + if (bounds.height < defaultHeight) bounds.height = defaultHeight + + setReactFlowPanelExtent((prev) => + prev[1][0] === bounds.width && prev[1][1] === bounds.height + 20 + ? prev + : [ + [0, 0], + [bounds.width, bounds.height + 20], + ], + ) + ladderFlowActions.updateReactFlowViewport({ + editorName: pouName, + rungId: rungLocal.id, + reactFlowViewport: [bounds.width, bounds.height + 20], + }) + } + + // --- Debug edge coloring and node lockdown --- + + const debugStates = useMemo( + () => + isDebuggerVisible + ? computeRungDebugStates(rungLocal.nodes, rungLocal.edges, { + isFunctionBlockPou: pouRef?.pouType === 'function-block', + hasProgramInstance, + getCompositeKey, + boolValues: debugVariableValues, + }) + : null, + [ + isDebuggerVisible, + rungLocal.nodes, + rungLocal.edges, + pouRef?.pouType, + hasProgramInstance, + getCompositeKey, + debugVariableValues, + ], + ) + + // Identity-stable across polls that didn't change this rung's states, so + // the styled arrays below keep their identity and the rung skips re-render. + const stableDebugStates = useContentStable(debugStates, rungDebugStatesEqual) + + const styledEdges = useMemo(() => { + if (!stableDebugStates) { + return rungLocal.edges + } + + const { edgeStates } = stableDebugStates + return rungLocal.edges.map((edge) => { + if (edgeStates.get(edge.id) === true) { return { ...edge, style: { stroke: EDGE_COLOR_TRUE, strokeWidth: 2 }, @@ -288,75 +379,24 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi return edge }) - }, [ - rungLocal.edges, - rungLocal.nodes, - isDebuggerVisible, - debugVariableValues, - pouName, - pouRef, - resourceInstances, - getCompositeKey, - ]) + }, [rungLocal.edges, stableDebugStates]) const styledNodes = useMemo(() => { - const baseNodes = !isDebuggerVisible + const baseNodes = !stableDebugStates ? rungLocal.nodes - : (() => { - const nodeInputStateMap = new Map() - - const determineNodeInputState = (nodeId: string): boolean => { - if (nodeInputStateMap.has(nodeId)) { - return nodeInputStateMap.get(nodeId)! - } - - const node = rungLocal.nodes.find((n) => n.id === nodeId) - if (!node) return false - - if (node.type === 'powerRail' && (node.data as { variant: string }).variant === 'left') { - nodeInputStateMap.set(nodeId, true) - return true + : rungLocal.nodes.map((node) => { + if (node.type === 'parallel') { + const isFlowActive = stableDebugStates.nodeInputStates.get(node.id) || false + return { + ...node, + data: { + ...node.data, + isFlowActive, + }, } - - const incomingEdges = rungLocal.edges.filter((e) => e.target === nodeId) - - if (incomingEdges.length === 0) { - nodeInputStateMap.set(nodeId, false) - return false - } - - const hasGreenInput = incomingEdges.some((incomingEdge) => { - const sourceInputGreen = determineNodeInputState(incomingEdge.source) - const sourceOutputGreen = getNodeOutputState( - incomingEdge.source, - incomingEdge.sourceHandle, - sourceInputGreen, - ) - return sourceOutputGreen === true - }) - - nodeInputStateMap.set(nodeId, hasGreenInput) - return hasGreenInput } - - rungLocal.nodes.forEach((node) => { - determineNodeInputState(node.id) - }) - - return rungLocal.nodes.map((node) => { - if (node.type === 'parallel') { - const isFlowActive = nodeInputStateMap.get(node.id) || false - return { - ...node, - data: { - ...node.data, - isFlowActive, - }, - } - } - return node - }) - })() + return node + }) if (isDebuggerActive) { return baseNodes.map((node) => ({ @@ -368,17 +408,7 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi } return baseNodes - }, [ - rungLocal.edges, - rungLocal.nodes, - isDebuggerVisible, - isDebuggerActive, - debugVariableValues, - pouName, - pouRef, - resourceInstances, - getCompositeKey, - ]) + }, [rungLocal.nodes, stableDebugStates, isDebuggerActive]) /** * Update the local rung state when the rung state changes @@ -550,7 +580,13 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi }) if (pouRef) { - syncNodesWithVariables(pouRef.interface?.variables ?? [], ladderFlows, ladderFlowActions.updateNode, pouName) + syncNodesWithVariables( + pouRef.interface?.variables ?? [], + ladderFlows, + ladderFlowActions.updateNodes, + pouName, + rungLocal.id, + ) } } @@ -625,8 +661,10 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi }) } + // Flow-scoped (no rungId): removing a block can delete its backing + // variable, which may strand bindings in this POU's other rungs. if (pouRef) { - syncNodesWithVariables(pouRef.interface?.variables ?? [], ladderFlows, ladderFlowActions.updateNode, pouName) + syncNodesWithVariables(pouRef.interface?.variables ?? [], ladderFlows, ladderFlowActions.updateNodes, pouName) } } @@ -685,7 +723,13 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi } if (pouRef) { - syncNodesWithVariables(pouRef.interface?.variables ?? [], ladderFlows, ladderFlowActions.updateNode, pouName) + syncNodesWithVariables( + pouRef.interface?.variables ?? [], + ladderFlows, + ladderFlowActions.updateNodes, + pouName, + rungLocal.id, + ) } } diff --git a/src/frontend/components/_molecules/variables-table/selectable-cell.tsx b/src/frontend/components/_molecules/variables-table/selectable-cell.tsx index af865d8bd..3e51d1408 100644 --- a/src/frontend/components/_molecules/variables-table/selectable-cell.tsx +++ b/src/frontend/components/_molecules/variables-table/selectable-cell.tsx @@ -52,8 +52,8 @@ const SelectableTypeCell = ({ project: { data: { dataTypes }, }, - ladderFlowActions: { updateNode }, - fbdFlowActions: { updateNode: updateFBDNode }, + ladderFlowActions: { updateNodes }, + fbdFlowActions: { updateNodes: updateFBDNodes }, libraries: sliceLibraries, workspace: { isDebuggerVisible }, } = useOpenPLCStore() @@ -187,11 +187,11 @@ const SelectableTypeCell = ({ const newVars = pou?.interface?.variables ?? [] if (language === 'fbd') { - syncNodesWithVariablesFBD(newVars, freshFBDFlows, updateFBDNode, editor.meta.name) + syncNodesWithVariablesFBD(newVars, freshFBDFlows, updateFBDNodes, editor.meta.name) } if (language === 'ld') { - syncNodesWithVariables(newVars, freshLadderFlows, updateNode, editor.meta.name) + syncNodesWithVariables(newVars, freshLadderFlows, updateNodes, editor.meta.name) } setCellValue(value) diff --git a/src/frontend/components/_organisms/variables-editor/index.tsx b/src/frontend/components/_organisms/variables-editor/index.tsx index eddca458d..e3b650e59 100644 --- a/src/frontend/components/_organisms/variables-editor/index.tsx +++ b/src/frontend/components/_organisms/variables-editor/index.tsx @@ -68,9 +68,9 @@ const VariablesEditor = ({ name: propName, isActive: _isActive = true }: Variabl const editor = useOpenPLCStore((s) => selectEditorForPou(s, propName)) const { ladderFlows, - ladderFlowActions: { updateNode }, + ladderFlowActions: { updateNode, updateNodes }, fbdFlows, - fbdFlowActions: { updateNode: updateFBDNode }, + fbdFlowActions: { updateNode: updateFBDNode, updateNodes: updateFBDNodes }, workspace: { systemConfigs: { shouldUseDarkMode }, isDebuggerVisible, @@ -924,11 +924,11 @@ const VariablesEditor = ({ name: propName, isActive: _isActive = true }: Variabl const freshVariables = freshPou?.interface?.variables ?? [] if (language === 'ld') { - syncNodesWithVariablesUtil(freshVariables, freshLadderFlows, updateNode) + syncNodesWithVariablesUtil(freshVariables, freshLadderFlows, updateNodes) } if (language === 'fbd') { - syncNodesWithVariablesFBDUtil(freshVariables, freshFBDFlows, updateFBDNode) + syncNodesWithVariablesFBDUtil(freshVariables, freshFBDFlows, updateFBDNodes) } for (const pair of renamedPairsToPropagate) { diff --git a/src/frontend/hooks/use-content-stable.ts b/src/frontend/hooks/use-content-stable.ts new file mode 100644 index 000000000..5f3fb7b10 --- /dev/null +++ b/src/frontend/hooks/use-content-stable.ts @@ -0,0 +1,24 @@ +import { useRef } from 'react' + +export const mapsEqual = (a: Map, b: Map): boolean => { + if (a === b) return true + if (a.size !== b.size) return false + for (const [key, value] of a) { + if (!b.has(key) || b.get(key) !== value) return false + } + return true +} + +/** + * Returns the previous instance while `isEqual` says the content is + * unchanged, so downstream memos keyed on the value skip recomputation when + * a producer replaces it with an equivalent one (e.g. debug-poll Maps that + * didn't touch this consumer's entries). + */ +export function useContentStable(value: T, isEqual: (previous: T, next: T) => boolean): T { + const ref = useRef(value) + if (ref.current !== value && !isEqual(ref.current, value)) { + ref.current = value + } + return ref.current +} diff --git a/src/frontend/hooks/useDebugPolling.ts b/src/frontend/hooks/useDebugPolling.ts index 31ed497b4..08f263b81 100644 --- a/src/frontend/hooks/useDebugPolling.ts +++ b/src/frontend/hooks/useDebugPolling.ts @@ -390,9 +390,10 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void itemsProcessed = positionsConsumed } - // Only write to store when values actually changed - if (changedBool.size > 0) workspaceActions.setDebugBoolValues(changedBool) - if (changedNonBool.size > 0) workspaceActions.setDebugNonBoolValues(changedNonBool) + // Only write to store when values actually changed — one commit per poll cycle + if (changedBool.size > 0 || changedNonBool.size > 0) { + workspaceActions.setDebugValues({ boolValues: changedBool, nonBoolValues: changedNonBool }) + } } // Advance offset for next poll cycle (wraps around) diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index def00f254..5daf9e5e7 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -757,14 +757,14 @@ export async function reloadPouFromDisk(pouName: string, projectPort: ProjectPor if (language === 'ld') { const pouFlows = openPLCStoreBase.getState().ladderFlows.filter((f) => f.name === pouName) if (pouFlows.length > 0) { - syncNodesWithVariables(reparsedVars, pouFlows, openPLCStoreBase.getState().ladderFlowActions.updateNode) + syncNodesWithVariables(reparsedVars, pouFlows, openPLCStoreBase.getState().ladderFlowActions.updateNodes) } - // Reset flow updated flag (syncNodesWithVariables triggers updateNode which sets updated=true) + // Reset flow updated flag (syncNodesWithVariables triggers updateNodes which sets updated=true) openPLCStoreBase.getState().ladderFlowActions.setFlowUpdated({ editorName: pouName, updated: false }) } else if (language === 'fbd') { const pouFlows = openPLCStoreBase.getState().fbdFlows.filter((f) => f.name === pouName) if (pouFlows.length > 0) { - syncNodesWithVariablesFBD(reparsedVars, pouFlows, openPLCStoreBase.getState().fbdFlowActions.updateNode) + syncNodesWithVariablesFBD(reparsedVars, pouFlows, openPLCStoreBase.getState().fbdFlowActions.updateNodes) } openPLCStoreBase.getState().fbdFlowActions.setFlowUpdated({ editorName: pouName, updated: false }) } diff --git a/src/frontend/store/__tests__/fbd-slice.test.ts b/src/frontend/store/__tests__/fbd-slice.test.ts index f243fd091..64ba093d5 100644 --- a/src/frontend/store/__tests__/fbd-slice.test.ts +++ b/src/frontend/store/__tests__/fbd-slice.test.ts @@ -264,6 +264,42 @@ describe('createFBDFlowSlice', () => { expect(store.getState().fbdFlows[0].rung.nodes[0].id).toBe('n1') }) + // ------------------------------------------------------------------------- + // updateNodes + // ------------------------------------------------------------------------- + it('updateNodes applies a batch of node replacements and marks the flow as updated', () => { + store.getState().fbdFlowActions.startFBDRung({ editorName: 'editor-1' }) + store.getState().fbdFlowActions.setNodes({ + editorName: 'editor-1', + nodes: [makeNode({ id: 'n1', data: { label: 'old-1' } }), makeNode({ id: 'n2', data: { label: 'old-2' } })], + }) + + store.getState().fbdFlowActions.updateNodes([ + { editorName: 'editor-1', nodeId: 'n1', node: makeNode({ id: 'n1', data: { label: 'new-1' } }) }, + { editorName: 'editor-1', nodeId: 'n2', node: makeNode({ id: 'n2', data: { label: 'new-2' } }) }, + ]) + + const nodes = store.getState().fbdFlows[0].rung.nodes + expect(nodes.find((n) => n.id === 'n1')?.data.label).toBe('new-1') + expect(nodes.find((n) => n.id === 'n2')?.data.label).toBe('new-2') + expect(store.getState().fbdFlows[0].updated).toBe(true) + }) + + it('updateNodes skips entries whose editor or node does not exist', () => { + store.getState().fbdFlowActions.startFBDRung({ editorName: 'editor-1' }) + store.getState().fbdFlowActions.setNodes({ + editorName: 'editor-1', + nodes: [makeNode({ id: 'n1', data: { label: 'old' } })], + }) + + store.getState().fbdFlowActions.updateNodes([ + { editorName: 'missing-editor', nodeId: 'n1', node: makeNode({ id: 'n1' }) }, + { editorName: 'editor-1', nodeId: 'missing', node: makeNode({ id: 'missing' }) }, + ]) + + expect(store.getState().fbdFlows[0].rung.nodes.find((n) => n.id === 'n1')?.data.label).toBe('old') + }) + // ------------------------------------------------------------------------- // addNode // ------------------------------------------------------------------------- diff --git a/src/frontend/store/__tests__/ladder-slice.test.ts b/src/frontend/store/__tests__/ladder-slice.test.ts index 51ee515f2..4f94e159b 100644 --- a/src/frontend/store/__tests__/ladder-slice.test.ts +++ b/src/frontend/store/__tests__/ladder-slice.test.ts @@ -525,6 +525,52 @@ describe('createLadderFlowSlice', () => { expect(store.getState().ladderFlows[0].updated).toBe(false) }) + // ------------------------------------------------------------------------- + // updateNodes + // ------------------------------------------------------------------------- + it('updateNodes applies a batch of node replacements and marks the flow as updated', () => { + const rung = makeRung({ + nodes: [makeNode({ id: 'n1', data: { label: 'old-1' } }), makeNode({ id: 'n2', data: { label: 'old-2' } })], + }) + seedFlowWithRung(store, 'editor-1', rung) + store.getState().ladderFlowActions.setFlowUpdated({ editorName: 'editor-1', updated: false }) + + store.getState().ladderFlowActions.updateNodes([ + { + editorName: 'editor-1', + rungId: 'rung-1', + nodeId: 'n1', + node: makeNode({ id: 'n1', data: { label: 'new-1' } }), + }, + { + editorName: 'editor-1', + rungId: 'rung-1', + nodeId: 'n2', + node: makeNode({ id: 'n2', data: { label: 'new-2' } }), + }, + ]) + + const nodes = store.getState().ladderFlows[0].rungs[0].nodes + expect(nodes.find((n) => n.id === 'n1')?.data.label).toBe('new-1') + expect(nodes.find((n) => n.id === 'n2')?.data.label).toBe('new-2') + expect(store.getState().ladderFlows[0].updated).toBe(true) + }) + + it('updateNodes skips entries whose editor, rung or node does not exist', () => { + const rung = makeRung({ nodes: [makeNode({ id: 'n1', data: { label: 'old' } })] }) + seedFlowWithRung(store, 'editor-1', rung) + store.getState().ladderFlowActions.setFlowUpdated({ editorName: 'editor-1', updated: false }) + + store.getState().ladderFlowActions.updateNodes([ + { editorName: 'missing-editor', rungId: 'rung-1', nodeId: 'n1', node: makeNode({ id: 'n1' }) }, + { editorName: 'editor-1', rungId: 'missing-rung', nodeId: 'n1', node: makeNode({ id: 'n1' }) }, + { editorName: 'editor-1', rungId: 'rung-1', nodeId: 'missing', node: makeNode({ id: 'missing' }) }, + ]) + + expect(store.getState().ladderFlows[0].rungs[0].nodes.find((n) => n.id === 'n1')?.data.label).toBe('old') + expect(store.getState().ladderFlows[0].updated).toBe(false) + }) + // ------------------------------------------------------------------------- // addNode // ------------------------------------------------------------------------- diff --git a/src/frontend/store/__tests__/workspace-slice.test.ts b/src/frontend/store/__tests__/workspace-slice.test.ts index 976a6461b..d8fedc7f0 100644 --- a/src/frontend/store/__tests__/workspace-slice.test.ts +++ b/src/frontend/store/__tests__/workspace-slice.test.ts @@ -311,26 +311,34 @@ describe('createWorkspaceSlice', () => { expect(store.getState().workspace.debugVariableIndexes).toEqual(indexes) }) - it('setDebugBoolValues merges values into existing map', () => { - const initial = new Map([['var1', 'TRUE']]) - store.getState().workspaceActions.setDebugBoolValues(initial) + it('setDebugValues merges bool and non-bool values into their maps in one commit', () => { + store.getState().workspaceActions.setDebugValues({ + boolValues: new Map([['var1', 'TRUE']]), + nonBoolValues: new Map([['var2', '42']]), + }) expect(store.getState().workspace.debugBoolValues.get('var1')).toBe('TRUE') + expect(store.getState().workspace.debugNonBoolValues.get('var2')).toBe('42') - const update = new Map([['var2', 'FALSE']]) - store.getState().workspaceActions.setDebugBoolValues(update) + store.getState().workspaceActions.setDebugValues({ + boolValues: new Map([['var3', 'FALSE']]), + nonBoolValues: new Map([['var4', '3.14']]), + }) expect(store.getState().workspace.debugBoolValues.get('var1')).toBe('TRUE') - expect(store.getState().workspace.debugBoolValues.get('var2')).toBe('FALSE') + expect(store.getState().workspace.debugBoolValues.get('var3')).toBe('FALSE') + expect(store.getState().workspace.debugNonBoolValues.get('var2')).toBe('42') + expect(store.getState().workspace.debugNonBoolValues.get('var4')).toBe('3.14') }) - it('setDebugNonBoolValues merges values into existing map', () => { - const initial = new Map([['var1', '42']]) - store.getState().workspaceActions.setDebugNonBoolValues(initial) - expect(store.getState().workspace.debugNonBoolValues.get('var1')).toBe('42') + it('setDebugValues tolerates missing maps', () => { + store.getState().workspaceActions.setDebugValues({ boolValues: new Map([['var1', 'TRUE']]) }) + expect(store.getState().workspace.debugBoolValues.get('var1')).toBe('TRUE') - const update = new Map([['var2', '3.14']]) - store.getState().workspaceActions.setDebugNonBoolValues(update) - expect(store.getState().workspace.debugNonBoolValues.get('var1')).toBe('42') - expect(store.getState().workspace.debugNonBoolValues.get('var2')).toBe('3.14') + store.getState().workspaceActions.setDebugValues({ nonBoolValues: new Map([['var2', '42']]) }) + expect(store.getState().workspace.debugNonBoolValues.get('var2')).toBe('42') + + store.getState().workspaceActions.setDebugValues({}) + expect(store.getState().workspace.debugBoolValues.get('var1')).toBe('TRUE') + expect(store.getState().workspace.debugNonBoolValues.get('var2')).toBe('42') }) it('setDebugForcedVariables', () => { @@ -444,7 +452,7 @@ describe('createWorkspaceSlice', () => { store.getState().workspaceActions.setDebuggerTargetIp('192.168.0.1') store.getState().workspaceActions.setDebugCContent('code') store.getState().workspaceActions.setDebugVariableIndexes(new Map([['x', 1]])) - store.getState().workspaceActions.setDebugBoolValues(new Map([['x', 'true']])) + store.getState().workspaceActions.setDebugValues({ boolValues: new Map([['x', 'true']]) }) store.getState().workspaceActions.setDebugForcedVariables(new Map([['x', true]])) store.getState().workspaceActions.setDebugTick(100) store @@ -531,7 +539,7 @@ describe('createWorkspaceSlice', () => { it('removeDebugVariable removes from all relevant maps', () => { const key = 'PROGRAM0::myVar' store.getState().workspaceActions.setDebugVariableIndexes(new Map([[key, 5]])) - store.getState().workspaceActions.setDebugNonBoolValues(new Map([[key, '42']])) + store.getState().workspaceActions.setDebugValues({ nonBoolValues: new Map([[key, '42']]) }) store.getState().workspaceActions.setDebugForcedVariables(new Map([[key, true]])) store .getState() diff --git a/src/frontend/store/slices/fbd/slice.ts b/src/frontend/store/slices/fbd/slice.ts index 2d1f97094..4119bd7bc 100644 --- a/src/frontend/store/slices/fbd/slice.ts +++ b/src/frontend/store/slices/fbd/slice.ts @@ -149,6 +149,22 @@ export const createFBDFlowSlice: StateCreator { + for (const { editorName, node, nodeId } of updates) { + const flow = fbdFlows.find((flow) => flow.name === editorName) + if (!flow) continue + + const nodeIndex = flow.rung.nodes.findIndex((n) => n.id === nodeId) + if (nodeIndex === -1) continue + + flow.rung.nodes[nodeIndex] = node + flow.updated = true + } + }), + ) + }, addNode({ editorName, node }) { setState( produce(({ fbdFlows }: FBDFlowState) => { diff --git a/src/frontend/store/slices/fbd/types.ts b/src/frontend/store/slices/fbd/types.ts index 38d3f5848..9bd515b84 100644 --- a/src/frontend/store/slices/fbd/types.ts +++ b/src/frontend/store/slices/fbd/types.ts @@ -61,6 +61,8 @@ type FBDFlowActions = { setNodes: ({ nodes, editorName }: { nodes: Node[]; editorName: string }) => void updateNode: ({ node, nodeId, editorName }: { node: Node; nodeId: string; editorName: string }) => void + /** Batched updateNode: applies every update in a single store commit. */ + updateNodes: (updates: { node: Node; nodeId: string; editorName: string }[]) => void addNode: ({ node, editorName }: { node: Node; editorName: string }) => void removeNodes: ({ nodes, editorName }: { nodes: Node[]; editorName: string }) => void diff --git a/src/frontend/store/slices/ladder/slice.ts b/src/frontend/store/slices/ladder/slice.ts index a5aa1059e..c58259f5d 100644 --- a/src/frontend/store/slices/ladder/slice.ts +++ b/src/frontend/store/slices/ladder/slice.ts @@ -311,6 +311,25 @@ export const createLadderFlowSlice: StateCreator { + for (const { editorName, node, nodeId, rungId } of updates) { + const flow = ladderFlows.find((flow) => flow.name === editorName) + if (!flow) continue + + const rung = flow.rungs.find((rung) => rung.id === rungId) + if (!rung) continue + + const nodeIndex = rung.nodes.findIndex((n) => n.id === nodeId) + if (nodeIndex === -1) continue + + rung.nodes[nodeIndex] = node + flow.updated = true + } + }), + ) + }, addNode({ editorName, node, rungId }) { setState( produce(({ ladderFlows }: LadderFlowState) => { diff --git a/src/frontend/store/slices/ladder/types.ts b/src/frontend/store/slices/ladder/types.ts index e90d99f5b..4e5dedfd7 100644 --- a/src/frontend/store/slices/ladder/types.ts +++ b/src/frontend/store/slices/ladder/types.ts @@ -109,6 +109,8 @@ type LadderFlowActions = { * focusing/blurring an element never dirties the POU. */ transient?: boolean }) => void + /** Batched updateNode: applies every update in a single store commit. */ + updateNodes: (updates: { node: Node; nodeId: string; rungId: string; editorName: string }[]) => void addNode: ({ node, rungId, editorName }: { node: Node; rungId: string; editorName: string }) => void removeNodes: ({ nodes, rungId, editorName }: { nodes: Node[]; rungId: string; editorName: string }) => void setSelectedNodes: ({ nodes, rungId, editorName }: { nodes: Node[]; rungId: string; editorName: string }) => void diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 3d1219d18..016c920bb 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -732,8 +732,8 @@ const createSharedSlice: StateCreator = (s const freshLadderFlows = freshState.ladderFlows const freshFBDFlows = freshState.fbdFlows const freshPous = freshState.project.data.pous - const updateLadderNode = freshState.ladderFlowActions.updateNode - const updateFBDNode = freshState.fbdFlowActions.updateNode + const updateLadderNodes = freshState.ladderFlowActions.updateNodes + const updateFBDNodes = freshState.fbdFlowActions.updateNodes try { ladderPous.forEach((pou) => { @@ -743,7 +743,7 @@ const createSharedSlice: StateCreator = (s const pouFlow = freshLadderFlows.filter((flow) => flow.name === pou.name) /* istanbul ignore next -- defensive: flow always exists since we just added it */ if (pouFlow.length > 0) { - syncNodesWithVariables(freshPou.interface?.variables ?? [], pouFlow, updateLadderNode) + syncNodesWithVariables(freshPou.interface?.variables ?? [], pouFlow, updateLadderNodes) } } }) @@ -755,7 +755,7 @@ const createSharedSlice: StateCreator = (s const pouFlow = freshFBDFlows.filter((flow) => flow.name === pou.name) /* istanbul ignore next -- defensive: flow always exists since we just added it */ if (pouFlow.length > 0) { - syncNodesWithVariablesFBD(freshPou.interface?.variables ?? [], pouFlow, updateFBDNode) + syncNodesWithVariablesFBD(freshPou.interface?.variables ?? [], pouFlow, updateFBDNodes) } } }) diff --git a/src/frontend/store/slices/workspace/slice.ts b/src/frontend/store/slices/workspace/slice.ts index 2aeb3d2d1..86f132c1a 100644 --- a/src/frontend/store/slices/workspace/slice.ts +++ b/src/frontend/store/slices/workspace/slice.ts @@ -283,19 +283,13 @@ const createWorkspaceSlice: StateCreator }), ) }, - setDebugBoolValues: (values: Map) => { + setDebugValues: (values: { boolValues?: Map; nonBoolValues?: Map }) => { setState( produce(({ workspace }: WorkspaceSlice) => { - for (const [key, val] of values) { + for (const [key, val] of values.boolValues ?? []) { workspace.debugBoolValues.set(key, val) } - }), - ) - }, - setDebugNonBoolValues: (values: Map) => { - setState( - produce(({ workspace }: WorkspaceSlice) => { - for (const [key, val] of values) { + for (const [key, val] of values.nonBoolValues ?? []) { workspace.debugNonBoolValues.set(key, val) } }), diff --git a/src/frontend/store/slices/workspace/types.ts b/src/frontend/store/slices/workspace/types.ts index f325db5bd..a9e12ef62 100644 --- a/src/frontend/store/slices/workspace/types.ts +++ b/src/frontend/store/slices/workspace/types.ts @@ -171,8 +171,8 @@ export type WorkspaceActions = { setDebuggerTargetIp: (targetIp: string | null) => void setDebugCContent: (content: string | null) => void setDebugVariableIndexes: (indexes: Map) => void - setDebugBoolValues: (values: Map) => void - setDebugNonBoolValues: (values: Map) => void + /** Merges polled values into both maps in a single store commit per poll cycle. */ + setDebugValues: (values: { boolValues?: Map; nonBoolValues?: Map }) => void setDebugForcedVariables: (forced: Map) => void setDebugTick: (tick: number) => void setDebugVariableTree: (tree: Map) => void diff --git a/src/frontend/utils/graphical/__tests__/sync-nodes-with-variables.test.ts b/src/frontend/utils/graphical/__tests__/sync-nodes-with-variables.test.ts index f5aa28d92..1c4d2b2f8 100644 --- a/src/frontend/utils/graphical/__tests__/sync-nodes-with-variables.test.ts +++ b/src/frontend/utils/graphical/__tests__/sync-nodes-with-variables.test.ts @@ -27,7 +27,7 @@ const makeNode = ( describe('syncNodesWithVariables', () => { it('updates a contact node when the variable type no longer matches BOOL', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'INT') const node = makeNode('n1', 'contact', { name: 'myVar', @@ -42,10 +42,10 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) + syncNodesWithVariables([variable], ladderFlows, updateNodes) // When type mismatches, variable id is set to "broken-" to mark it as broken. - expect(updateNode).toHaveBeenCalledWith( + expect(updateNodes).toHaveBeenCalledWith([ expect.objectContaining({ editorName: 'editor1', rungId: 'r1', @@ -57,11 +57,11 @@ describe('syncNodesWithVariables', () => { }), }), }), - ) + ]) }) it('does not update a contact node when only the variable id changes but types still match', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'BOOL', 'base-type', '2') const node = makeNode('n1', 'contact', { name: 'myVar', @@ -76,17 +76,17 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) + syncNodesWithVariables([variable], ladderFlows, updateNodes) // Types match and wrongVariable is not set, so no update is needed. - expect(updateNode).not.toHaveBeenCalled() + expect(updateNodes).not.toHaveBeenCalled() }) it('treats a POINTER TO variable as compatible with a ULINT expected type', () => { // The sync path now delegates to the shared validateVariableType, so a // POINTER TO INT variable on a ULINT-typed block pin (e.g. ADR output) // must NOT be flagged as wrong. - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myPtr', 'POINTER TO INT', 'user-data-type') const node = makeNode('n1', 'block', { name: 'myPtr' } as Partial, { variant: { name: 'ULINT' }, @@ -99,13 +99,13 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) + syncNodesWithVariables([variable], ladderFlows, updateNodes) // Types are considered compatible and wrongVariable is not set, so no update. - expect(updateNode).not.toHaveBeenCalled() + expect(updateNodes).not.toHaveBeenCalled() }) it('does not update when node has no variable', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const node = makeNode('n1', 'contact') const ladderFlows = [ { @@ -114,12 +114,12 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([makeVariable('x')], ladderFlows, updateNode) - expect(updateNode).not.toHaveBeenCalled() + syncNodesWithVariables([makeVariable('x')], ladderFlows, updateNodes) + expect(updateNodes).not.toHaveBeenCalled() }) it('does not update when variable is not found in newVars', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const node = makeNode('n1', 'contact', { name: 'missing' } as Partial) const ladderFlows = [ { @@ -128,12 +128,12 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([makeVariable('other')], ladderFlows, updateNode) - expect(updateNode).not.toHaveBeenCalled() + syncNodesWithVariables([makeVariable('other')], ladderFlows, updateNodes) + expect(updateNodes).not.toHaveBeenCalled() }) it('filters flows by editorName when provided', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'INT') const node = makeNode('n1', 'contact', { name: 'myVar', @@ -146,13 +146,66 @@ describe('syncNodesWithVariables', () => { { name: 'editor2', rungs: [{ id: 'r2', nodes: [node], edges: [] }] }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode, 'editor1') - expect(updateNode).toHaveBeenCalledTimes(1) - expect(updateNode).toHaveBeenCalledWith(expect.objectContaining({ editorName: 'editor1' })) + syncNodesWithVariables([variable], ladderFlows, updateNodes, 'editor1') + expect(updateNodes).toHaveBeenCalledTimes(1) + expect(updateNodes).toHaveBeenCalledWith([expect.objectContaining({ editorName: 'editor1' })]) + }) + + it('scopes the sweep to a single rung when rungId is provided', () => { + const updateNodes = vi.fn() + const variable = makeVariable('myVar', 'INT') + const staleContact = (id: string) => + makeNode(id, 'contact', { + name: 'myVar', + id: '1', + type: { definition: 'base-type', value: 'BOOL' } as PLCVariable['type'], + }) + + const ladderFlows = [ + { + name: 'editor1', + rungs: [ + { id: 'r1', nodes: [staleContact('n1')], edges: [] }, + { id: 'r2', nodes: [staleContact('n2')], edges: [] }, + ], + }, + ] as unknown as Parameters[1] + + syncNodesWithVariables([variable], ladderFlows, updateNodes, 'editor1', 'r2') + expect(updateNodes).toHaveBeenCalledTimes(1) + expect(updateNodes).toHaveBeenCalledWith([expect.objectContaining({ rungId: 'r2', nodeId: 'n2' })]) + }) + + it('batches corrections across rungs into a single call', () => { + const updateNodes = vi.fn() + const variable = makeVariable('myVar', 'INT') + const staleContact = (id: string) => + makeNode(id, 'contact', { + name: 'myVar', + id: '1', + type: { definition: 'base-type', value: 'BOOL' } as PLCVariable['type'], + }) + + const ladderFlows = [ + { + name: 'editor1', + rungs: [ + { id: 'r1', nodes: [staleContact('n1')], edges: [] }, + { id: 'r2', nodes: [staleContact('n2')], edges: [] }, + ], + }, + ] as unknown as Parameters[1] + + syncNodesWithVariables([variable], ladderFlows, updateNodes) + expect(updateNodes).toHaveBeenCalledTimes(1) + expect(updateNodes).toHaveBeenCalledWith([ + expect.objectContaining({ rungId: 'r1', nodeId: 'n1' }), + expect.objectContaining({ rungId: 'r2', nodeId: 'n2' }), + ]) }) it("skips variable-pin nodes whose pin type cannot be resolved (never judges against '')", () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'BOOL') const node = makeNode('n1', 'variable', { name: 'myVar' } as Partial, { wrongVariable: true }) @@ -163,10 +216,10 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) + syncNodesWithVariables([variable], ladderFlows, updateNodes) // No data.block.variableType -> expected type unknown -> don't judge. // (The old behavior compared against '' and flagged every linked pin.) - expect(updateNode).not.toHaveBeenCalled() + expect(updateNodes).not.toHaveBeenCalled() }) const pinExtra = (pinType: string) => ({ @@ -179,7 +232,7 @@ describe('syncNodesWithVariables', () => { }) it('accepts a variable-pin node whose variable matches the pin type', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('reset_in', 'BOOL') const node = makeNode('n1', 'variable', { name: 'reset_in' } as Partial, pinExtra('BOOL')) @@ -187,12 +240,12 @@ describe('syncNodesWithVariables', () => { typeof syncNodesWithVariables >[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) - expect(updateNode).not.toHaveBeenCalled() + syncNodesWithVariables([variable], ladderFlows, updateNodes) + expect(updateNodes).not.toHaveBeenCalled() }) it('flags a variable-pin node whose variable mismatches the pin type', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('reset_in', 'INT') const node = makeNode('n1', 'variable', { name: 'reset_in' } as Partial, pinExtra('BOOL')) @@ -200,8 +253,8 @@ describe('syncNodesWithVariables', () => { typeof syncNodesWithVariables >[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) - expect(updateNode).toHaveBeenCalledWith( + syncNodesWithVariables([variable], ladderFlows, updateNodes) + expect(updateNodes).toHaveBeenCalledWith([ expect.objectContaining({ node: expect.objectContaining({ data: expect.objectContaining({ @@ -210,11 +263,11 @@ describe('syncNodesWithVariables', () => { }), }), }), - ) + ]) }) it('clears a stale wrongVariable flag on a variable-pin node once the pin type matches', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('reset_in', 'BOOL') const node = makeNode('n1', 'variable', { name: 'reset_in' } as Partial, { ...pinExtra('BOOL'), @@ -225,8 +278,8 @@ describe('syncNodesWithVariables', () => { typeof syncNodesWithVariables >[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) - expect(updateNode).toHaveBeenCalledWith( + syncNodesWithVariables([variable], ladderFlows, updateNodes) + expect(updateNodes).toHaveBeenCalledWith([ expect.objectContaining({ node: expect.objectContaining({ data: expect.objectContaining({ @@ -235,11 +288,11 @@ describe('syncNodesWithVariables', () => { }), }), }), - ) + ]) }) it('does not update a block node when nothing changed', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'BOOL', 'base-type', '1') const node = makeNode('n1', 'contact', { name: 'myVar', @@ -254,12 +307,12 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) - expect(updateNode).not.toHaveBeenCalled() + syncNodesWithVariables([variable], ladderFlows, updateNodes) + expect(updateNodes).not.toHaveBeenCalled() }) it('skips a variable node when it has no expected type to compare', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'BOOL', 'base-type', '1') const node = makeNode('n1', 'variable', { name: 'myVar', @@ -274,14 +327,14 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) + syncNodesWithVariables([variable], ladderFlows, updateNodes) // Unresolvable expected type -> the node is not judged (the old behavior // compared against '' and flagged every linked pin as broken). - expect(updateNode).not.toHaveBeenCalled() + expect(updateNodes).not.toHaveBeenCalled() }) it('skips a block node when its variant has no name (no expected type)', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'INT') const node = makeNode('n1', 'block', { name: 'myVar', @@ -296,12 +349,12 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) - expect(updateNode).not.toHaveBeenCalled() + syncNodesWithVariables([variable], ladderFlows, updateNodes) + expect(updateNodes).not.toHaveBeenCalled() }) it('clears wrongVariable when types now match (line 77)', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'BOOL', 'base-type', '2') const node = makeNode( 'n1', @@ -321,11 +374,11 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) + syncNodesWithVariables([variable], ladderFlows, updateNodes) // Types match (BOOL === BOOL for contact), but wrongVariable was true, // so it should be cleared. - expect(updateNode).toHaveBeenCalledWith( + expect(updateNodes).toHaveBeenCalledWith([ expect.objectContaining({ editorName: 'editor1', rungId: 'r1', @@ -337,11 +390,11 @@ describe('syncNodesWithVariables', () => { }), }), }), - ) + ]) }) it('marks a block node with matching variant as wrongVariable when type mismatches', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'INT') const node = makeNode( 'n1', @@ -361,20 +414,20 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) - expect(updateNode).toHaveBeenCalledWith( + syncNodesWithVariables([variable], ladderFlows, updateNodes) + expect(updateNodes).toHaveBeenCalledWith([ expect.objectContaining({ node: expect.objectContaining({ data: expect.objectContaining({ wrongVariable: true }), }), }), - ) + ]) }) }) describe('syncNodesWithVariablesFBD', () => { it('skips a variable node when its type cannot be resolved', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'INT', 'base-type', '2') const node = makeNode('n1', 'input-variable', { name: 'myVar', @@ -389,36 +442,36 @@ describe('syncNodesWithVariablesFBD', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariablesFBD([variable], fbdFlows, updateNode) + syncNodesWithVariablesFBD([variable], fbdFlows, updateNodes) // Unresolvable expected type -> not judged (the old behavior compared // against '' and falsely flagged linked FBD variable nodes as broken). - expect(updateNode).not.toHaveBeenCalled() + expect(updateNodes).not.toHaveBeenCalled() }) it('does not update when node has no variable', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const node = makeNode('n1', 'block') const fbdFlows = [{ name: 'fbd1', rung: { nodes: [node], edges: [] } }] as unknown as Parameters< typeof syncNodesWithVariablesFBD >[1] - syncNodesWithVariablesFBD([makeVariable('x')], fbdFlows, updateNode) - expect(updateNode).not.toHaveBeenCalled() + syncNodesWithVariablesFBD([makeVariable('x')], fbdFlows, updateNodes) + expect(updateNodes).not.toHaveBeenCalled() }) it('does not update when variable not found', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const node = makeNode('n1', 'input-variable', { name: 'missing' } as Partial) const fbdFlows = [{ name: 'fbd1', rung: { nodes: [node], edges: [] } }] as unknown as Parameters< typeof syncNodesWithVariablesFBD >[1] - syncNodesWithVariablesFBD([makeVariable('other')], fbdFlows, updateNode) - expect(updateNode).not.toHaveBeenCalled() + syncNodesWithVariablesFBD([makeVariable('other')], fbdFlows, updateNodes) + expect(updateNodes).not.toHaveBeenCalled() }) it('marks a block node as wrongVariable when type mismatches', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'INT') const node = makeNode( 'n1', @@ -435,18 +488,18 @@ describe('syncNodesWithVariablesFBD', () => { typeof syncNodesWithVariablesFBD >[1] - syncNodesWithVariablesFBD([variable], fbdFlows, updateNode) - expect(updateNode).toHaveBeenCalledWith( + syncNodesWithVariablesFBD([variable], fbdFlows, updateNodes) + expect(updateNodes).toHaveBeenCalledWith([ expect.objectContaining({ node: expect.objectContaining({ data: expect.objectContaining({ wrongVariable: true }), }), }), - ) + ]) }) it('does not update a block node when only variable id changes but types still match', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const oldVariable = makeVariable('myVar', 'BOOL', 'base-type', '1') const newVariable = makeVariable('myVar', 'BOOL', 'base-type', '2') const node = makeNode('n1', 'block', oldVariable, { variant: { name: 'BOOL' } }) @@ -455,13 +508,13 @@ describe('syncNodesWithVariablesFBD', () => { typeof syncNodesWithVariablesFBD >[1] - syncNodesWithVariablesFBD([newVariable], fbdFlows, updateNode) + syncNodesWithVariablesFBD([newVariable], fbdFlows, updateNodes) // Types match and wrongVariable is not set, so no update is needed. - expect(updateNode).not.toHaveBeenCalled() + expect(updateNodes).not.toHaveBeenCalled() }) it('filters flows by editorName', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'INT', 'base-type', '2') // Block with a resolvable variant type that mismatches -> triggers an update. const node = makeNode( @@ -480,13 +533,40 @@ describe('syncNodesWithVariablesFBD', () => { { name: 'fbd2', rung: { nodes: [node], edges: [] } }, ] as unknown as Parameters[1] - syncNodesWithVariablesFBD([variable], fbdFlows, updateNode, 'fbd1') - expect(updateNode).toHaveBeenCalledTimes(1) - expect(updateNode).toHaveBeenCalledWith(expect.objectContaining({ editorName: 'fbd1' })) + syncNodesWithVariablesFBD([variable], fbdFlows, updateNodes, 'fbd1') + expect(updateNodes).toHaveBeenCalledTimes(1) + expect(updateNodes).toHaveBeenCalledWith([expect.objectContaining({ editorName: 'fbd1' })]) + }) + + it('batches corrections across nodes into a single call', () => { + const updateNodes = vi.fn() + const variable = makeVariable('myVar', 'INT') + const staleBlock = (id: string) => + makeNode( + id, + 'block', + { + name: 'myVar', + id: '1', + type: { definition: 'base-type', value: 'BOOL' } as PLCVariable['type'], + }, + { variant: { name: 'BOOL' } }, + ) + + const fbdFlows = [ + { name: 'fbd1', rung: { nodes: [staleBlock('n1'), staleBlock('n2')], edges: [] } }, + ] as unknown as Parameters[1] + + syncNodesWithVariablesFBD([variable], fbdFlows, updateNodes) + expect(updateNodes).toHaveBeenCalledTimes(1) + expect(updateNodes).toHaveBeenCalledWith([ + expect.objectContaining({ nodeId: 'n1' }), + expect.objectContaining({ nodeId: 'n2' }), + ]) }) it('marks an input-variable node as wrong when it has no expected type to compare', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'BOOL', 'base-type', '1') const node = makeNode('n1', 'input-variable', { name: 'myVar', @@ -498,13 +578,13 @@ describe('syncNodesWithVariablesFBD', () => { typeof syncNodesWithVariablesFBD >[1] - syncNodesWithVariablesFBD([variable], fbdFlows, updateNode) + syncNodesWithVariablesFBD([variable], fbdFlows, updateNodes) // Unresolvable expected type -> not judged (no false "broken" flags). - expect(updateNode).not.toHaveBeenCalled() + expect(updateNodes).not.toHaveBeenCalled() }) it('clears wrongVariable on FBD node when types now match (line 136)', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'BOOL', 'base-type', '2') const node: Node = { id: 'n1', @@ -525,11 +605,11 @@ describe('syncNodesWithVariablesFBD', () => { typeof syncNodesWithVariablesFBD >[1] - syncNodesWithVariablesFBD([variable], fbdFlows, updateNode) + syncNodesWithVariablesFBD([variable], fbdFlows, updateNodes) // Types match (BOOL === BOOL for block with variant name BOOL), // but wrongVariable was true, so it should be cleared. - expect(updateNode).toHaveBeenCalledWith( + expect(updateNodes).toHaveBeenCalledWith([ expect.objectContaining({ editorName: 'fbd1', nodeId: 'n1', @@ -540,11 +620,11 @@ describe('syncNodesWithVariablesFBD', () => { }), }), }), - ) + ]) }) it('does not update a non-variable block node when nothing changed', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'BOOL', 'base-type', '1') const node: Node = { id: 'n1', @@ -560,7 +640,7 @@ describe('syncNodesWithVariablesFBD', () => { typeof syncNodesWithVariablesFBD >[1] - syncNodesWithVariablesFBD([variable], fbdFlows, updateNode) - expect(updateNode).not.toHaveBeenCalled() + syncNodesWithVariablesFBD([variable], fbdFlows, updateNodes) + expect(updateNodes).not.toHaveBeenCalled() }) }) diff --git a/src/frontend/utils/graphical/sync-nodes-with-variables.ts b/src/frontend/utils/graphical/sync-nodes-with-variables.ts index 0d7496197..5a2b992a7 100644 --- a/src/frontend/utils/graphical/sync-nodes-with-variables.ts +++ b/src/frontend/utils/graphical/sync-nodes-with-variables.ts @@ -3,14 +3,21 @@ import { Node } from '@xyflow/react' import type { PLCVariable } from '../../../middleware/shared/ports/types' import { validateVariableType } from '../PLC/validate-variable-type' -type UpdateLadderNodeFn = (params: { +export type LadderNodeUpdate = { editorName: string rungId: string nodeId: string node: import('@xyflow/react').Node -}) => void +} + +export type FBDNodeUpdate = { editorName: string; nodeId: string; node: import('@xyflow/react').Node } + +// Batched: called at most once per sync pass with every corrected node, so +// the store applies the whole pass as a single commit instead of one +// produce() write per mismatched node. +type UpdateLadderNodesFn = (updates: LadderNodeUpdate[]) => void -type UpdateFBDNodeFn = (params: { editorName: string; nodeId: string; node: import('@xyflow/react').Node }) => void +type UpdateFBDNodesFn = (updates: FBDNodeUpdate[]) => void type LadderRung = { id: string; nodes: import('@xyflow/react').Node[] } type LadderFlow = { name: string; rungs: LadderRung[] } @@ -49,128 +56,100 @@ const getBlockExpectedType = (node: Node): string => { const sameType = (firstType: string, secondType: string) => validateVariableType(firstType.toString().trim(), secondType.toString().trim()).isValid +const getNodeCorrection = (node: Node, newVars: PLCVariable[]): { data: Node['data'] } | undefined => { + const nodeVar = (node.data as { variable?: PLCVariable }).variable + + if (!nodeVar) return undefined + + const target = newVars.find((v) => v.name.toLowerCase() === nodeVar.name.toLowerCase()) + + if (!target) return undefined + + const expectedType = getBlockExpectedType(node) + + // Unknown expectation — don't judge. sameType(x, '') is always false, + // so flagging here would mark perfectly valid links as broken. + if (!expectedType) return undefined + + const isTheSameType = sameType(target.type.value, expectedType) + + if (!isTheSameType) { + return { + data: { + ...node.data, + variable: { ...target, id: `broken-${node.id}` }, + wrongVariable: true, + }, + } + } + + if ((node.data as { wrongVariable?: PLCVariable }).wrongVariable) { + return { + data: { + ...node.data, + variable: target, + wrongVariable: false, + }, + } + } + + return undefined +} + +// `rungId` scopes an element-level edit (add/remove/drag-stop) to the rung it +// touched. Variable-table edits (rename/retype/delete) must NOT pass it — the +// full sweep is what auto-corrects stale bindings in the other rungs. export const syncNodesWithVariables = ( newVars: PLCVariable[], ladderFlows: LadderFlow[], - updateNode: UpdateLadderNodeFn, + updateNodes: UpdateLadderNodesFn, editorName?: string, + rungId?: string, ) => { const flowsToSync = editorName ? ladderFlows.filter((flow) => flow.name === editorName) : ladderFlows + const updates: LadderNodeUpdate[] = [] - flowsToSync.forEach((flow) => - flow.rungs.forEach((rung) => + flowsToSync.forEach((flow) => { + const rungsToSync = rungId ? flow.rungs.filter((rung) => rung.id === rungId) : flow.rungs + rungsToSync.forEach((rung) => rung.nodes.forEach((node) => { - const nodeVar = (node.data as { variable?: PLCVariable }).variable - - if (!nodeVar) return - - const target = newVars.find((v) => v.name.toLowerCase() === nodeVar.name.toLowerCase()) - - if (!target) return - - const expectedType = getBlockExpectedType(node) - - // Unknown expectation — don't judge. sameType(x, '') is always false, - // so flagging here would mark perfectly valid links as broken. - if (!expectedType) return - - const isTheSameType = sameType(target.type.value, expectedType) - - if (!isTheSameType) { - updateNode({ - editorName: flow.name, - rungId: rung.id, - nodeId: node.id, - node: { - ...node, - data: { - ...node.data, - variable: { ...target, id: `broken-${node.id}` }, - wrongVariable: true, - }, - }, - }) - - return - } - - if ((node.data as { wrongVariable?: PLCVariable }).wrongVariable) { - updateNode({ - editorName: flow.name, - rungId: rung.id, - nodeId: node.id, - node: { - ...node, - data: { - ...node.data, - variable: target, - wrongVariable: false, - }, - }, - }) - } + const correction = getNodeCorrection(node, newVars) + if (!correction) return + + updates.push({ + editorName: flow.name, + rungId: rung.id, + nodeId: node.id, + node: { ...node, ...correction }, + }) }), - ), - ) + ) + }) + + if (updates.length > 0) updateNodes(updates) } export const syncNodesWithVariablesFBD = ( newVars: PLCVariable[], fbdFlows: FBDFlow[], - updateNode: UpdateFBDNodeFn, + updateNodes: UpdateFBDNodesFn, editorName?: string, ) => { const flowsToSync = editorName ? fbdFlows.filter((flow) => flow.name === editorName) : fbdFlows + const updates: FBDNodeUpdate[] = [] flowsToSync.forEach((flow) => flow.rung.nodes.forEach((node) => { - const nodeVar = (node.data as { variable?: PLCVariable }).variable - - if (!nodeVar) return - - const target = newVars.find((v) => v.name.toLowerCase() === nodeVar.name.toLowerCase()) - - if (!target) return - - const expectedType = getBlockExpectedType(node) - - // Unknown expectation — don't judge. sameType(x, '') is always false, - // so flagging here would mark perfectly valid links as broken. - if (!expectedType) return - - const isTheSameType = sameType(target.type.value, expectedType) - - if (!isTheSameType) { - updateNode({ - editorName: flow.name, - nodeId: node.id, - node: { - ...node, - data: { - ...node.data, - variable: { ...target, id: `broken-${node.id}` }, - wrongVariable: true, - }, - }, - }) - - return - } - - if ((node.data as { wrongVariable?: PLCVariable }).wrongVariable) { - updateNode({ - editorName: flow.name, - nodeId: node.id, - node: { - ...node, - data: { - ...node.data, - variable: target, - wrongVariable: false, - }, - }, - }) - } + const correction = getNodeCorrection(node, newVars) + if (!correction) return + + updates.push({ + editorName: flow.name, + nodeId: node.id, + node: { ...node, ...correction }, + }) }), ) + + if (updates.length > 0) updateNodes(updates) } From 01b84a3c3b83013867dc4f14816533841ea72d17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Tue, 21 Jul 2026 17:45:37 -0300 Subject: [PATCH 39/52] test: strengthen FBD updateNodes dirty-state assertions + cover use-content-stable Addresses CodeRabbit review: reset the FBD flow's updated flag before each updateNodes batch so the assertions actually prove the action's behavior, and add unit tests locking in the useContentStable/mapsEqual identity-stability contract. Mirror of openplc-web fix/dope-491-hot-path-scans Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017RGT8nUsyY26HXFSuTBFLz --- .../__tests__/use-content-stable.test.ts | 98 +++++++++++++++++++ .../store/__tests__/fbd-slice.test.ts | 3 + 2 files changed, 101 insertions(+) create mode 100644 src/frontend/hooks/__tests__/use-content-stable.test.ts diff --git a/src/frontend/hooks/__tests__/use-content-stable.test.ts b/src/frontend/hooks/__tests__/use-content-stable.test.ts new file mode 100644 index 000000000..0eb629a0a --- /dev/null +++ b/src/frontend/hooks/__tests__/use-content-stable.test.ts @@ -0,0 +1,98 @@ +import { renderHook } from '@testing-library/react' + +import { mapsEqual, useContentStable } from '../use-content-stable' + +describe('mapsEqual', () => { + it('returns true for the same reference', () => { + const map = new Map([['a', 1]]) + expect(mapsEqual(map, map)).toBe(true) + }) + + it('returns true for different instances with equal content', () => { + expect( + mapsEqual( + new Map([ + ['a', 1], + ['b', 2], + ]), + new Map([ + ['b', 2], + ['a', 1], + ]), + ), + ).toBe(true) + }) + + it('returns false when sizes differ', () => { + expect(mapsEqual(new Map([['a', 1]]), new Map())).toBe(false) + }) + + it('returns false when a value differs', () => { + expect(mapsEqual(new Map([['a', 1]]), new Map([['a', 2]]))).toBe(false) + }) + + it('returns false when a key is missing', () => { + expect(mapsEqual(new Map([['a', 1]]), new Map([['b', 1]]))).toBe(false) + }) + + it('distinguishes a missing key from an undefined value', () => { + expect(mapsEqual(new Map([['a', undefined]]), new Map([['b', undefined]]))).toBe(false) + }) +}) + +describe('useContentStable', () => { + type States = Map | null + const statesEqual = (a: States, b: States) => a !== null && b !== null && mapsEqual(a, b) + + const render = (initial: States) => + renderHook(({ value }: { value: States }) => useContentStable(value, statesEqual), { + initialProps: { value: initial }, + }) + + it('keeps the previous reference when content is equal', () => { + const first = new Map([['edge-1', true]]) + const { result, rerender } = render(first) + + rerender({ value: new Map([['edge-1', true]]) }) + + expect(result.current).toBe(first) + }) + + it('swaps to the new reference when content differs', () => { + const first = new Map([['edge-1', true]]) + const { result, rerender } = render(first) + + const changed = new Map([['edge-1', false]]) + rerender({ value: changed }) + + expect(result.current).toBe(changed) + }) + + it('transitions from a value to null (equality returns false for null)', () => { + const first = new Map([['edge-1', true]]) + const { result, rerender } = render(first) + + rerender({ value: null }) + + expect(result.current).toBeNull() + }) + + it('transitions from null to a value', () => { + const { result, rerender } = render(null) + + const next = new Map([['edge-1', true]]) + rerender({ value: next }) + + expect(result.current).toBe(next) + }) + + it('keeps the stable reference across multiple equal-content updates', () => { + const first = new Map([['edge-1', true]]) + const { result, rerender } = render(first) + + rerender({ value: new Map([['edge-1', true]]) }) + rerender({ value: new Map([['edge-1', true]]) }) + + expect(result.current).toBe(first) + }) +}) diff --git a/src/frontend/store/__tests__/fbd-slice.test.ts b/src/frontend/store/__tests__/fbd-slice.test.ts index 64ba093d5..61d4596e0 100644 --- a/src/frontend/store/__tests__/fbd-slice.test.ts +++ b/src/frontend/store/__tests__/fbd-slice.test.ts @@ -273,6 +273,7 @@ describe('createFBDFlowSlice', () => { editorName: 'editor-1', nodes: [makeNode({ id: 'n1', data: { label: 'old-1' } }), makeNode({ id: 'n2', data: { label: 'old-2' } })], }) + store.getState().fbdFlowActions.setFlowUpdated({ editorName: 'editor-1', updated: false }) store.getState().fbdFlowActions.updateNodes([ { editorName: 'editor-1', nodeId: 'n1', node: makeNode({ id: 'n1', data: { label: 'new-1' } }) }, @@ -291,6 +292,7 @@ describe('createFBDFlowSlice', () => { editorName: 'editor-1', nodes: [makeNode({ id: 'n1', data: { label: 'old' } })], }) + store.getState().fbdFlowActions.setFlowUpdated({ editorName: 'editor-1', updated: false }) store.getState().fbdFlowActions.updateNodes([ { editorName: 'missing-editor', nodeId: 'n1', node: makeNode({ id: 'n1' }) }, @@ -298,6 +300,7 @@ describe('createFBDFlowSlice', () => { ]) expect(store.getState().fbdFlows[0].rung.nodes.find((n) => n.id === 'n1')?.data.label).toBe('old') + expect(store.getState().fbdFlows[0].updated).toBe(false) }) // ------------------------------------------------------------------------- From e48914d1f521dcdea58d5dab1521de92fbb35399 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Wed, 22 Jul 2026 07:16:18 -0300 Subject: [PATCH 40/52] fix(graphical): dotted refs as constants + resolve globals/struct-members in LSP scope (#950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(graphical): bind dotted box refs as constants + resolve globals in LSP scope query Byte-identical companion to the openplc-editor PR (shared frontend surface). 1. Dotted / non-identifier box entries (member access like some_global_complex.structureVar, typed literals like T#500ms, reserved words) are bound to the block verbatim as a constant/reference instead of being routed to createVariable, whose isLegalIdentifier check rejects them with an Illegal Variable Name toast and discards the entry. New variable NAMES still reject those characters. Applied to all four box-commit sites (fbd/ladder autocomplete + block). 2. serializePouScopeForQuery re-emits the POU's VAR_EXTERNAL variables as plain VAR in the throwaway scope-query doc, so strucpp resolves globals and their struct/array members without a matching VAR_GLOBAL — restoring LSP autocomplete + validation for global variables in LD and FBD. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(graphical): accept LSP Field kind so struct members autocomplete/validate Struct-typed variables (local or via a global VAR_EXTERNAL) never offered their members in the LD/FBD variable boxes, e.g. `test_complex.structureBool` for a BOOL box. Root cause: strucpp returns STRUCT members with CompletionItemKind.Field (5), while graphical-scope only accepted Variable (6) — the kind FUNCTION_BLOCK instance members come back as. So struct fields were dropped by the kind filter before the type filter ran, in both the completion and validation paths. Accept Field (5) alongside Variable (6) via isValueCompletionKind at all three call sites (completion, member-drill, type resolution). Verified live in the browser: `.` now lists all fields in an ANY box and narrows to the type-compatible field (structureBool) in a BOOL box. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../fbd/autocomplete/index.tsx | 12 +++++++- .../_atoms/graphical-editor/fbd/block.tsx | 8 ++++++ .../ladder/autocomplete/index.tsx | 17 ++++++++++- .../_atoms/graphical-editor/ladder/block.tsx | 8 ++++++ src/frontend/services/graphical-scope.ts | 19 ++++++++++--- .../pou-signature-serializer.test.ts | 28 +++++++++++++++++++ .../utils/PLC/pou-signature-serializer.ts | 12 +++++++- 7 files changed, 97 insertions(+), 7 deletions(-) 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 ed5144210..92d4dc968 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx @@ -10,7 +10,7 @@ import { } from '../../../../../services/graphical-scope' import { useOpenPLCStore } from '../../../../../store' import { cn } from '../../../../../utils/cn' -import { getLiteralType } from '../../../../../utils/keywords' +import { getLiteralType, isLegalIdentifier } from '../../../../../utils/keywords' 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' @@ -130,6 +130,16 @@ const FBDBlockAutoComplete = forwardRef(block: BlockProps) => { if (matchingVariable) { variableToLink = matchingVariable } else if (createIfNotFound) { + // An entry that can't be a new variable NAME — a member/array reference, + // a typed literal (`T#500ms`), a reserved word — is bound to the block + // verbatim as a constant/reference instead of erroring. + if (!isLegalIdentifier(variableNameToSubmit)[0]) { + updateNodeVariable({ name: variableNameToSubmit }) + return + } const pouData = pous.find((p) => p.name === pouName) pushToHistory(pouName, { variables: pouData?.interface?.variables ?? [], 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 5134e741c..4a87655e0 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx @@ -11,7 +11,7 @@ import { } from '../../../../../services/graphical-scope' import { useOpenPLCStore } from '../../../../../store' import { cn } from '../../../../../utils/cn' -import { getLiteralType } from '../../../../../utils/keywords' +import { getLiteralType, isLegalIdentifier } from '../../../../../utils/keywords' import { toast } from '../../../../_features/[app]/toast/use-toast' import { useBoundPou } from '../../../../_features/[workspace]/editor/graphical/active-context' import { GraphicalEditorAutocomplete } from '../../autocomplete' @@ -187,6 +187,21 @@ const VariablesBlockAutoComplete = forwardRef(block: BlockProps) => { if (matchingVariable) { variableToLink = matchingVariable } else if (createIfNotFound) { + // An entry that can't be a new variable NAME — a member/array reference, + // a typed literal (`T#500ms`), a reserved word — is bound to the block + // verbatim as a constant/reference instead of erroring. + if (!isLegalIdentifier(variableNameToSubmit)[0]) { + updateNodeVariable({ name: variableNameToSubmit }) + return + } const project = useOpenPLCStore.getState().project const currentPou = project.data.pous.find((p) => p.name === pouName) pushToHistory(pouName, { diff --git a/src/frontend/services/graphical-scope.ts b/src/frontend/services/graphical-scope.ts index 17d634a6e..da7c79f68 100644 --- a/src/frontend/services/graphical-scope.ts +++ b/src/frontend/services/graphical-scope.ts @@ -23,8 +23,17 @@ import type { PLCVariable } from '../../middleware/shared/ports/types' import { getVariableRestrictionType, validateVariableType } from '../utils/PLC/validate-variable-type' import { getScopedQueryApi } from './st-lsp' -/** LSP `CompletionItemKind.Variable` — strucpp's kind for in-scope variables and instance/struct members. */ +/** + * LSP `CompletionItemKind`s that denote a value symbol bindable to a box. + * strucpp emits `Variable` (6) for in-scope variables and FUNCTION_BLOCK + * instance members (`TON0.Q`), but `Field` (5) for STRUCT members + * (`my_struct.field`). Both must be accepted, or struct-member access never + * autocompletes or validates. + */ const LSP_KIND_VARIABLE = 6 +const LSP_KIND_FIELD = 5 +const isValueCompletionKind = (kind: number | undefined): boolean => + kind === LSP_KIND_VARIABLE || kind === LSP_KIND_FIELD /** Max instance/struct variables to drill into when a type-filtered search has no direct hits. */ const SCOPE_EXPAND_LIMIT = 8 @@ -92,7 +101,7 @@ export async function getScopeCompletions( const { anchor, segment } = splitExpression(value) const items = await api.completeInScope(pouName, anchor) const needle = segment.toLowerCase() - const matching = items.filter((item) => item.kind === LSP_KIND_VARIABLE && item.label.toLowerCase().includes(needle)) + const matching = items.filter((item) => isValueCompletionKind(item.kind) && item.label.toLowerCase().includes(needle)) const direct = matching .filter((item) => { @@ -120,7 +129,7 @@ export async function getScopeCompletions( const memberAnchor = `${anchor}${instance.label}.` const members = await api.completeInScope(pouName, memberAnchor) return members - .filter((m) => m.kind === LSP_KIND_VARIABLE && m.type && validateVariableType(m.type, expectedType).isValid) + .filter((m) => isValueCompletionKind(m.kind) && m.type && validateVariableType(m.type, expectedType).isValid) .map((m) => ({ label: `${instance.label}.${m.label}`, insertText: memberAnchor + m.label, type: m.type })) }), ) @@ -146,7 +155,9 @@ export async function resolveScopeExpressionType(pouName: string, expression: st if (items.length === 0) return { status: 'unavailable' } const { name, indexed } = stripSubscript(segment) - const match = items.find((item) => item.kind === LSP_KIND_VARIABLE && item.label.toLowerCase() === name.toLowerCase()) + const match = items.find( + (item) => isValueCompletionKind(item.kind) && item.label.toLowerCase() === name.toLowerCase(), + ) if (!match || !match.type) return { status: 'unknown' } if (indexed) { diff --git a/src/frontend/utils/PLC/__tests__/pou-signature-serializer.test.ts b/src/frontend/utils/PLC/__tests__/pou-signature-serializer.test.ts index bf9d4728c..51870235a 100644 --- a/src/frontend/utils/PLC/__tests__/pou-signature-serializer.test.ts +++ b/src/frontend/utils/PLC/__tests__/pou-signature-serializer.test.ts @@ -185,6 +185,34 @@ describe('serializePouSignatureToST', () => { expect(text.split('\n')[position.line]).toBe('in1.') }) + it('re-emits external variables as plain VAR so globals resolve in the throwaway doc', () => { + const pou = makePou({ + name: 'Main', + pouType: 'program', + interface: { + variables: [ + { + id: 'g1', + name: 'some_global_complex', + class: 'external', + type: { definition: 'derived', value: 'testing_arr' }, + documentation: '', + debug: false, + location: '', + }, + ], + }, + body: { language: 'fbd', value: {} as never }, + }) + const { text } = serializePouScopeForQuery(pou, 'some_global_complex.') + // The external is rewritten to a self-contained local VAR (type inline) + // so strucpp resolves the global + its struct/array members without a + // matching VAR_GLOBAL in this throwaway document. + expect(text).toContain('some_global_complex : testing_arr;') + expect(text).not.toContain('VAR_EXTERNAL') + expect(text).toContain('some_global_complex.') + }) + it('keeps the function return type while swapping only the name', () => { const pou = makePou({ name: 'AbsInt', diff --git a/src/frontend/utils/PLC/pou-signature-serializer.ts b/src/frontend/utils/PLC/pou-signature-serializer.ts index aa1cef02b..f361ae99b 100644 --- a/src/frontend/utils/PLC/pou-signature-serializer.ts +++ b/src/frontend/utils/PLC/pou-signature-serializer.ts @@ -139,7 +139,17 @@ export function serializePouScopeForQuery( pou.pouType === 'function' && pou.interface?.returnType ? `${startKeyword} ${name} : ${pou.interface.returnType}` : `${startKeyword} ${name}` - const variables = generateIecVariablesToString(pou.interface?.variables ?? []) + // External variables (`VAR_EXTERNAL`) reference resource globals declared in a + // separate CONFIGURATION document. This throwaway query doc isn't part of that + // configuration, so strucpp can't resolve a bare `VAR_EXTERNAL` here — the + // global and its struct/array members would come back unknown (the box shows + // yellow, no autocomplete). Re-emit externals as plain `VAR`: they carry their + // real type inline, so the symbol and its members resolve self-containedly. + // Scope-query-only — the POU's real stub keeps `VAR_EXTERNAL`. + const scopeVariables = (pou.interface?.variables ?? []).map((variable) => + variable.class === 'external' ? { ...variable, class: 'local' as const } : variable, + ) + const variables = generateIecVariablesToString(scopeVariables) const prefix = `${declaration}\n${variables}\n` // `prefix` ends with '\n', so split length - 1 is the 0-indexed line // the body expression sits on. From a41c8d876c24ea62c9f3cb966c397da5f46edb3a Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Wed, 22 Jul 2026 17:07:25 -0300 Subject: [PATCH 41/52] chore: remove xml2st references + fix autocomplete box sizing (#951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: remove xml2st references + fix autocomplete box sizing xml2st cleanup (the tool was retired; the in-process ST transpiler replaced it): - Drop stale build/CI/config references (ci-build step name, ci-unit-tests comment, .gitattributes entry, devcontainer binary check). - Rewrite every source/test comment that named xml2st to reference the ST transpiler / STruC++ as appropriate (no behavior change). - Rename the exported PLCopen namespace openplc.org/xml2st/library-blocks -> openplc.org/library-blocks (nothing reads it back on import; xml2st was its only consumer). plc.xml generation itself is unchanged — it is the PLCopen-conversion / Export-as-XML flow. Autocomplete box sizing: - The graphical (LD/FBD) variable-box autocomplete had a fixed w-36 width, so long suggestions (e.g. struct member refs like some_global_complex.structureVar) were cropped. It now grows to fit content up to max-w-[16rem] and wraps long names (break-all) to multiple lines so the full text is always visible. Byte-identical across the shared frontend surface (compare-surfaces: match, 0 diffs). tsc/lint/prettier clean; changed test suites pass; autocomplete sizing verified live in the browser. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(graphical): reserve scrollbar gutter in autocomplete so widest name never wraps When the suggestion list overflows and the vertical scrollbar appears, it stole horizontal space the content-based auto-size didn't reserve, wrapping the last character of the widest name onto a new line. Add `scrollbar-gutter: stable` to the scroll container so the reserved gutter is included in the auto-size. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(graphical): stop autocomplete rows collapsing/overlapping in Safari Safari-specific rendering of the LD/FBD variable-box dropdown: - `w-max` pins the popover to the widest item's full single-line width (clamped by min/max). Safari's shrink-to-fit otherwise collapsed the width toward min-content when items use `break-all`, wrapping names unnecessarily. - `shrink-0` on the item rows, the "Add variable"/new-block rows, and the variables container. Safari vertically shrinks flex-column children, so a wrapped name's second line overflowed its box and rendered on top of the "Add variable" button below. Keeping full height pushes siblings down instead. Chrome was unaffected (sizes to max-content and doesn't shrink these rows); verified no Chrome regression. Safari behavior to be confirmed by the reporter. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(graphical): use break-words (not break-all) so Safari sizes the dropdown Root cause of the Safari-only cropping: `word-break: break-all` collapses WebKit's max-content computation, so the auto-sized dropdown came out too narrow and wrapped names (even short ones), and the wrapped line overlapped "Add variable". `overflow-wrap: break-word` (Tailwind `break-words`) does NOT collapse max-content — it still breaks over-long names — so the popover sizes to the full name (clamped to [9rem, 16rem]) in both Chrome and Safari. Dropped the now-unneeded `w-max` (its interaction with min-width under WebKit was part of the problem); Radix's `min-width: max-content` wrapper drives the sizing. `shrink-0` rows retained so a genuinely long name that wraps still pushes "Add variable" down instead of overlapping it. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .devcontainer/post-create.sh | 6 --- .gitattributes | 1 - .github/workflows/ci-build.yml | 2 +- .github/workflows/ci-unit-tests.yml | 2 +- .../editor/compiler/compiler-module.ts | 2 +- src/backend/editor/utils/xml-manager.ts | 4 +- .../__tests__/pipeline-runtime-v3.test.ts | 2 +- .../shared/compile/__tests__/pipeline.test.ts | 2 +- .../validate-empty-variables.test.ts | 2 +- src/backend/shared/compile/pipeline.ts | 13 +++---- .../shared/compile/steps/generate-defines.ts | 2 +- .../compile/steps/validate-empty-variables.ts | 2 +- .../library/__tests__/build-pipeline.test.ts | 6 +-- .../__tests__/program-build-helpers.test.ts | 2 +- src/backend/shared/library/build-pipeline.ts | 31 ++++++++-------- .../shared/library/program-build-helpers.ts | 2 +- .../shared/library/program-build-pipeline.ts | 2 +- .../st-transpiler/core/path-tree.ts | 2 +- .../st-transpiler/emit/pou-graphical.ts | 2 +- .../st-transpiler/helpers/block-library.ts | 2 +- .../transpilers/st-transpiler/walker/ld.ts | 2 +- .../__tests__/collect-library-blocks.test.ts | 4 +- .../PLC/__tests__/split-program-st.test.ts | 10 ++--- .../utils/PLC/collect-library-blocks.ts | 26 ++++++------- .../shared/utils/PLC/split-program-st.ts | 12 +++--- src/backend/shared/utils/PLC/xml-generator.ts | 2 +- .../graphical-editor/autocomplete/index.tsx | 37 +++++++++++++++---- .../hooks/use-navigate-to-compile-error.ts | 3 +- .../pou-signature-serializer.test.ts | 2 +- .../utils/PLC/xml-generator/base-type-tag.ts | 2 +- .../codesys/__tests__/data-type-xml.test.ts | 2 +- .../__tests__/data-type-xml.test.ts | 2 +- .../old-editor/__tests__/type-xml.test.ts | 2 +- .../generate-iec-variables-to-string.test.ts | 6 +-- src/frontend/utils/debugger-session.ts | 2 +- .../utils/generate-iec-variables-to-string.ts | 6 +-- src/frontend/utils/iec-types-registry.ts | 2 +- src/middleware/shared/ports/compiler-port.ts | 6 +-- .../library/compose-runtime-v4-bundle.ts | 4 +- 39 files changed, 117 insertions(+), 104 deletions(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index eb3559a83..987214821 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -39,12 +39,6 @@ else echo " WARNING: matiec binary not found at $BIN_DIR/iec2c" fi -if [ -f "$BIN_DIR/xml2st" ]; then - echo " xml2st binary ($NODE_ARCH): OK" -else - echo " WARNING: xml2st binary not found at $BIN_DIR/xml2st" -fi - # Fix Electron sandbox for container (chrome-sandbox needs SUID root + mode 4755) CHROME_SANDBOX="node_modules/electron/dist/chrome-sandbox" if [ -f "$CHROME_SANDBOX" ]; then diff --git a/.gitattributes b/.gitattributes index a9cb934ad..1aa57117b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,5 +15,4 @@ # Identification for binary files used as compilers/transpilers ########################## iec2c binary -xml2st binary resources/bin/**/* binary \ No newline at end of file diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 63f9ce50b..4a8f9734d 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -20,7 +20,7 @@ jobs: - name: Install dependencies run: npm ci --ignore-scripts - - name: Download strucpp + xml2st binaries + - name: Download strucpp binaries # tsc needs the strucpp package to resolve the types imported # from `backend/shared/library/strucpp-runtime.ts` and friends. # Run the binary downloader directly (skipping the rest of diff --git a/.github/workflows/ci-unit-tests.yml b/.github/workflows/ci-unit-tests.yml index c19cebca2..ef15d6f76 100644 --- a/.github/workflows/ci-unit-tests.yml +++ b/.github/workflows/ci-unit-tests.yml @@ -23,7 +23,7 @@ jobs: # --ignore-scripts skips the postinstall, so strucpp (a GitHub-release # package, not an npm dep) is never fetched — yet the TS sources import # `strucpp/libs/iec-types.json` and its runtime headers. Install just - # strucpp (no xml2st platform binary, no native rebuild) so the suites + # strucpp (no native rebuild) so the suites # can load and the coverage gate actually runs. - name: Install strucpp run: npm run setup:strucpp diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index f2ee0bbc2..6a3ae3cdb 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -2316,7 +2316,7 @@ class CompilerModule { * Main compile entry point. Drives the full Step 0-13 flow * through the shared `runCompilePipeline` orchestrator * (`backend/shared/compile/pipeline.ts`); platform-specific bits - * (xml2st spawn, arduino-cli spawn, runtime upload) are abstracted + * (arduino-cli spawn, runtime upload) are abstracted * behind `EditorCompilerPlatformPort`. Single source of truth * for compile behaviour shared with openplc-web. */ diff --git a/src/backend/editor/utils/xml-manager.ts b/src/backend/editor/utils/xml-manager.ts index fa2facb6b..1b81ac30a 100644 --- a/src/backend/editor/utils/xml-manager.ts +++ b/src/backend/editor/utils/xml-manager.ts @@ -4,11 +4,11 @@ import { join } from 'path' /** * Create an xml file with the given params. Synchronous on * purpose, same reason `CreateJSONFile` is: every caller chains - * the next step (running xml2st on the file) immediately after, + * the next step (reading the file) immediately after, * and the previous async-fire-and-forget form returned success * before libuv had actually flushed the bytes — a fast subsequent * spawn could read 0 bytes. The compile pipeline never observed - * this in practice because xml2st spawns slowly enough that the + * this in practice because the read happened slowly enough that the * write usually won the race, but the API contract has always * been wrong. * diff --git a/src/backend/shared/compile/__tests__/pipeline-runtime-v3.test.ts b/src/backend/shared/compile/__tests__/pipeline-runtime-v3.test.ts index e06bb12a8..2d6308f7c 100644 --- a/src/backend/shared/compile/__tests__/pipeline-runtime-v3.test.ts +++ b/src/backend/shared/compile/__tests__/pipeline-runtime-v3.test.ts @@ -10,7 +10,7 @@ * core). These tests lock that ordering in. * * Kept in a separate file from `pipeline.test.ts` (which is stale from - * the xml2st→JSON-transpiler migration and references the removed + * the XML→JSON-transpiler migration and references the removed * `transpileXmlToSt` port method) so the v3 coverage compiles + runs * against the current `transpileToSt` contract. */ diff --git a/src/backend/shared/compile/__tests__/pipeline.test.ts b/src/backend/shared/compile/__tests__/pipeline.test.ts index afb213764..06f2f3353 100644 --- a/src/backend/shared/compile/__tests__/pipeline.test.ts +++ b/src/backend/shared/compile/__tests__/pipeline.test.ts @@ -161,7 +161,7 @@ describe('runCompilePipeline — simulator path', () => { expect(result.uploaded).toBe(false) expect(port.transpileToSt).toHaveBeenCalledTimes(1) // The pipeline hands the in-process transpiler the project IR plus - // a log callback — no XML / xml2st flags flow through anymore. + // a log callback — no XML / transpiler flags flow through anymore. expect(port.transpileToSt).toHaveBeenCalledWith( expect.objectContaining({ projectData: expect.anything() }), expect.any(Function), diff --git a/src/backend/shared/compile/__tests__/validate-empty-variables.test.ts b/src/backend/shared/compile/__tests__/validate-empty-variables.test.ts index 79be89510..b58d4b644 100644 --- a/src/backend/shared/compile/__tests__/validate-empty-variables.test.ts +++ b/src/backend/shared/compile/__tests__/validate-empty-variables.test.ts @@ -2,7 +2,7 @@ * Tests for the pre-compile blank-FBD-variable guard. * * An unnamed FBD input/output variable block becomes an empty - * `` in the PLCopen XML, which crashes xml2st with + * `` in the PLCopen XML, which the compiler rejects with * `'NoneType' object has no attribute 'split'`. These tests pin the * detector that lets the pipeline bail with a clear message — naming * what the block is wired to, or its position when it is wired to diff --git a/src/backend/shared/compile/pipeline.ts b/src/backend/shared/compile/pipeline.ts index 4e5d3fbec..c3922edf1 100644 --- a/src/backend/shared/compile/pipeline.ts +++ b/src/backend/shared/compile/pipeline.ts @@ -4,7 +4,7 @@ * Single source of truth for the full compile flow (Steps 0–13 in * the editor's canonical pipeline). Editor and web both drive this * function through a `CompilerPlatformPort`; the platform port - * abstracts the three places where platform truly differs (xml2st + * abstracts the three places where platform truly differs (ST transpiler * transport, arduino-cli transport, runtime upload transport). * Everything else — preprocessing, XML generation, strucpp compile, * conf authoring, defines authoring, bundle composition, ordering, @@ -238,7 +238,7 @@ export interface RunCompilePipelineArgs { export interface RunCompilePipelineResult { success: boolean - /** Structured strucpp + xml2st diagnostics from this run. Carries + /** Structured strucpp diagnostics from this run. Carries * the per-error events the renderer's navigation keys off. */ errors?: StructuredCompileError[] /** Compiled firmware bytes when the pipeline reached the @@ -385,10 +385,9 @@ async function runCompilePipelineInner( // --------------------------------------------------------------------- // Step 0b: Reject blank FBD variable blocks before XML generation. // - // An unnamed FBD in/out variable serialises to an empty - // ``, which makes xml2st crash with the opaque - // `'NoneType' object has no attribute 'split'`. Catch it here and - // tell the user exactly which POU to fix. + // An unnamed FBD in/out variable has no expression for the ST + // transpiler to emit, producing invalid code downstream. Catch it + // here and tell the user exactly which POU to fix. // --------------------------------------------------------------------- const emptyVariables = findEmptyFbdVariables(processedData) if (emptyVariables.length > 0) { @@ -411,7 +410,7 @@ async function runCompilePipelineInner( // so this hop never builds PLCOpen XML. Native STRUCT declarations // are the only emission mode the transpiler supports — the legacy // matiec struct→FB rewrite isn't ported, so there are no - // equivalents of the old `xml2stArgs` flags. + // equivalents of the old struct-rewrite flags. // --------------------------------------------------------------------- emit({ stage: 'st', message: 'Generating Structured Text...', level: 'info' }) // The pipeline carries the editor's schema-shape `PLCProjectData`, diff --git a/src/backend/shared/compile/steps/generate-defines.ts b/src/backend/shared/compile/steps/generate-defines.ts index 5820ac593..ecddca33e 100644 --- a/src/backend/shared/compile/steps/generate-defines.ts +++ b/src/backend/shared/compile/steps/generate-defines.ts @@ -48,7 +48,7 @@ export interface GenerateDefinesInput { * category (`DIN` / `AIN` / `DOUT` / `AOUT`) plus matching * count defines (`NUM_DISCRETE_INPUT` etc.). */ devicePinMapping: DevicePin[] - /** Concatenated ST program content (the output of xml2st). + /** Concatenated ST program content (the output of the ST transpiler). * Scanned with `String.prototype.includes` for the marker * function-block names that toggle the Arduino-library * `USE_*_BLOCK` defines. The set of marker strings here is diff --git a/src/backend/shared/compile/steps/validate-empty-variables.ts b/src/backend/shared/compile/steps/validate-empty-variables.ts index 97b6876e0..0d6f7f95a 100644 --- a/src/backend/shared/compile/steps/validate-empty-variables.ts +++ b/src/backend/shared/compile/steps/validate-empty-variables.ts @@ -4,7 +4,7 @@ import { PLCProjectData } from '../../types/PLC/open-plc' * An FBD variable block (input or output) whose name is blank. * * Such a block serialises to an empty `` in the PLCopen - * XML, which makes xml2st abort the whole compile with the opaque + * XML, which would make the compiler abort the whole compile with the opaque * `'NoneType' object has no attribute 'split'` error. We catch it * before XML generation and report it in terms the user can act on: * what the block is wired to, falling back to its canvas position when diff --git a/src/backend/shared/library/__tests__/build-pipeline.test.ts b/src/backend/shared/library/__tests__/build-pipeline.test.ts index fa0f37d12..178c37655 100644 --- a/src/backend/shared/library/__tests__/build-pipeline.test.ts +++ b/src/backend/shared/library/__tests__/build-pipeline.test.ts @@ -2,7 +2,7 @@ * Tests for the library build pipeline. * * `prepareXmlForLibraryBuild` no longer generates PLCopen XML — the - * old xml2st flow was replaced by an in-process JSON → ST transpiler. + * the legacy XML→ST flow was replaced by an in-process JSON → ST transpiler. * The function now only validates the manifest and returns the stubbed * project data (plus the POU inventory the splitter needs); the actual * transpile happens later via `LibraryBuildPort.transpileToSt`. @@ -422,7 +422,7 @@ describe('libraryBuildFromTranspiledSt', () => { it('drops `_config.st` so strucpp does not error on the stub configuration', () => { // The stub program (which the splitter recognises and the - // pipeline drops) is referenced by xml2st's emitted + // pipeline drops) is referenced by the transpiler's emitted // CONFIGURATION block. Leaving `_config.st` in the strucpp // inputs makes strucpp emit "Unknown program type 'MAIN'" // diagnostics because the stub source isn't there anymore. @@ -637,7 +637,7 @@ describe('libraryBuildFromTranspiledSt', () => { expect(res.success).toBe(true) }) - it('matches POU docs case-insensitively (xml2st upper-cases identifiers)', () => { + it('matches POU docs case-insensitively (the transpiler upper-cases identifiers)', () => { const archive = { manifest: { name: 'demo_lib', diff --git a/src/backend/shared/library/__tests__/program-build-helpers.test.ts b/src/backend/shared/library/__tests__/program-build-helpers.test.ts index 44bdf3684..4341fc156 100644 --- a/src/backend/shared/library/__tests__/program-build-helpers.test.ts +++ b/src/backend/shared/library/__tests__/program-build-helpers.test.ts @@ -235,7 +235,7 @@ describe('enrichErrorWithPouContext', () => { }) it('skips blank separator lines between END_VAR and the body', () => { - // The ST generators (`pou-text-serializer.ts` and xml2st on the + // The ST generators (`pou-text-serializer.ts` and the ST transpiler on the // compile path) insert blank lines after END_VAR for readability. // Those blanks live in the per-POU file the splitter feeds // strucpp but NOT in `pou.body.value`, which is what the body diff --git a/src/backend/shared/library/build-pipeline.ts b/src/backend/shared/library/build-pipeline.ts index 622eff878..8d026e6f0 100644 --- a/src/backend/shared/library/build-pipeline.ts +++ b/src/backend/shared/library/build-pipeline.ts @@ -7,15 +7,14 @@ * 1. `prepareXmlForLibraryBuild(project, manifest)` — synthesizes * a stub main program / task / instance into a transient * PLCProject (the on-disk project remains untouched) and runs - * the canonical XmlGenerator on it. xml2st rejects programless + * the canonical XmlGenerator on it. the ST transpiler rejects programless * projects, so the stub is mandatory; the stub's POU body is * intentionally non-empty (`LocalVar := 3;` against a single - * INT local) because some xml2st codepaths also reject empty + * INT local) because some ST-transpiler codepaths also reject empty * program bodies. * - * 2. *(caller runs xml2st on the resulting plc.xml — Electron - * spawns a local binary, web backend posts to its xml2st - * service — produces `program.st`.)* + * 2. *(the in-process ST transpiler runs on the project and + * produces `program.st`.)* * * 3. `libraryBuildFromTranspiledSt(programSt, knownPous, manifest)` * — splits `program.st` per-POU via the shared splitter, drops @@ -74,8 +73,8 @@ export interface LibraryBuildManifest { * console can render through the existing diagnostic pipeline. * * Strucpp itself validates manifests during compile, but doing it - * here lets the build fail BEFORE running xml2st when the manifest - * is obviously broken — saves a slow xml2st spawn on every + * here lets the build fail early when the manifest + * is obviously broken — saves wasted transpile work on every * mis-edited save. */ function parseLibraryManifest(json: string): ManifestParseResult { @@ -128,7 +127,7 @@ function parseLibraryManifest(json: string): ManifestParseResult { } // --------------------------------------------------------------------------- -// Stub program — makes xml2st accept a programless library project +// Stub program — makes the ST transpiler accept a programless library project // --------------------------------------------------------------------------- /** @@ -147,14 +146,14 @@ const STUB_INSTANCE_NAME = '__openplc_library_stub_instance__' /** * Build a transient PLCProject with a stub main program added on * top of the library's POUs / data types. The stub is what - * satisfies xml2st (and strucpp's main-program assumption later in + * satisfies the ST transpiler (and strucpp's main-program assumption later in * the verification path). Caller drops the stub's per-POU output * before handing the remaining sources to compileStlib. * - * The stub's body is non-empty (`LocalVar := 3;`) because xml2st + * The stub's body is non-empty (`LocalVar := 3;`) because the ST transpiler * has been observed to reject programs with completely empty bodies * — a single trivial assignment + a single INT local is the smallest - * shape that gets accepted across every xml2st version. + * shape that gets accepted across transpiler versions. */ function stubProgramFor(project: PLCProject): PLCProject { return { @@ -204,14 +203,14 @@ function stubProgramFor(project: PLCProject): PLCProject { * Synthetic filename the splitter emits for the stub program. The * splitter writes its file keys using the caller-side POU name * verbatim (case preserved), so this matches what `splitProgramSt` - * returns regardless of how xml2st upper-cases identifiers in the + * returns regardless of how the transpiler upper-cases identifiers in the * monolithic ST output. Caller drops this entry before feeding the * rest to compileStlib. */ const STUB_SPLIT_FILENAME = `${STUB_PROGRAM_NAME}.st` // --------------------------------------------------------------------------- -// Stage 1: pre-xml2st (pure) +// Stage 1: pre-transpile (pure) // --------------------------------------------------------------------------- export interface PrepareXmlResult { @@ -257,7 +256,7 @@ export function prepareXmlForLibraryBuild(project: PLCProject, manifestJson: str } // --------------------------------------------------------------------------- -// Stage 2: post-xml2st (pure) +// Stage 2: post-transpile (pure) // --------------------------------------------------------------------------- export interface LibraryBuildResult { @@ -337,7 +336,7 @@ export interface LibraryBuildAux { } /** - * Stage 2. Given xml2st's monolithic `program.st`, the POU + * Stage 2. Given the transpiler's monolithic `program.st`, the POU * inventory from Stage 1, and the parsed manifest: split program.st * per-POU, drop the stub, hand the remaining sources to strucpp's * compileStlib. @@ -369,7 +368,7 @@ export function libraryBuildFromTranspiledSt( // // - The stub program's `.st` file (the library doesn't ship // the stub). - // - `_config.st` (xml2st's CONFIGURATION block references the + // - `_config.st` (the transpiler's CONFIGURATION block references the // stub program, which we've just removed — leaving it in // causes strucpp to emit "Unknown program type 'MAIN'" // diagnostics). Libraries don't carry configurations diff --git a/src/backend/shared/library/program-build-helpers.ts b/src/backend/shared/library/program-build-helpers.ts index 84ad74fd2..0a03a0e56 100644 --- a/src/backend/shared/library/program-build-helpers.ts +++ b/src/backend/shared/library/program-build-helpers.ts @@ -119,7 +119,7 @@ export function enrichErrorWithPouContext( if (/^\s*END_VAR\b/i.test(lines[i])) lastEndVar = i + 1 // 1-indexed } // Body starts after the last END_VAR, but the ST generator - // (`pou-text-serializer.ts` and xml2st on the compile path) + // (`pou-text-serializer.ts` and the ST transpiler on the compile path) // inserts blank separator lines between END_VAR and the body // content. Those blank lines exist in the per-POU file the // splitter handed strucpp but NOT in `pou.body.value`, which is diff --git a/src/backend/shared/library/program-build-pipeline.ts b/src/backend/shared/library/program-build-pipeline.ts index d72429616..4b2e554b8 100644 --- a/src/backend/shared/library/program-build-pipeline.ts +++ b/src/backend/shared/library/program-build-pipeline.ts @@ -28,7 +28,7 @@ * pre-computes the hash and passes it in; strucpp embeds it * into the debug map for stale-layout detection. * - * - No external-process orchestration — `xml2st` (XML→ST) and + * - No external-process orchestration — the ST transpiler and * `arduino-cli` (firmware compile) stay in the platform-specific * orchestrator that wraps this pipeline. This module is purely * about the strucpp invocation slice. diff --git a/src/backend/shared/transpilers/st-transpiler/core/path-tree.ts b/src/backend/shared/transpilers/st-transpiler/core/path-tree.ts index 09c1535ca..a56be025a 100644 --- a/src/backend/shared/transpilers/st-transpiler/core/path-tree.ts +++ b/src/backend/shared/transpilers/st-transpiler/core/path-tree.ts @@ -41,7 +41,7 @@ export function leafNode(chunks: ProgramChunk[]): PathNode { /** * Stable structural key for a PathNode — used by `factorizePaths` to * detect common terms. Identical to the Python `repr` output the - * original xml2st pipeline keys on. + * original PLCopen pipeline keys on. */ export function pythonReprNode(node: PathNode): string { switch (node.kind) { diff --git a/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts b/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts index 101d34a41..1f2ab278a 100644 --- a/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts +++ b/src/backend/shared/transpilers/st-transpiler/emit/pou-graphical.ts @@ -101,7 +101,7 @@ export function generateGraphicalPou(pou: TranspilePou, project: TranspileProjec /* ────────────────────────── helpers ─────────────────────────────────────── */ // Block signatures from every graphical block instance's variant — the -// co-located equivalent of xml2st's embedded payload. Deduped +// co-located equivalent of the embedded payload. Deduped // by name, first instance wins — deliberately mirroring the oracle's // dedup; user POUs are excluded (they resolve from their // own interface). diff --git a/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts b/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts index ce485b1d6..537f5b920 100644 --- a/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts +++ b/src/backend/shared/transpilers/st-transpiler/helpers/block-library.ts @@ -34,7 +34,7 @@ export function isRecord(v: unknown): v is Record { /** * Build a block signature from a placed block's `node.data.variant`. * - * Mirrors `collect-library-blocks.ts` / xml2st's `_pou_to_block_infos`: + * Mirrors `collect-library-blocks.ts`'s block-info derivation: * EN/ENO are implicit control pins (dropped); inOut params appear on both * sides; a function's return is already a class-`output` variable named `OUT`. * Generic IEC meta-types (`ANY`, `ANY_NUM`, …) are kept verbatim and resolved diff --git a/src/backend/shared/transpilers/st-transpiler/walker/ld.ts b/src/backend/shared/transpilers/st-transpiler/walker/ld.ts index 76aa1807d..57669b916 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/ld.ts +++ b/src/backend/shared/transpilers/st-transpiler/walker/ld.ts @@ -3,7 +3,7 @@ * * Walks `RFBody.rungs[*].nodes/edges` directly — no PLCOpen * intermediate. Output must match the python oracle - * (`xml2st.py --keep-structs --no-complex-parser`) byte-for-byte; + * byte-for-byte against the python oracle; * `tests/per_case.test.ts` is the per-case validation loop, and * `tests/golden_react_flow.test.ts` covers the larger harvested * corpus once it lands. diff --git a/src/backend/shared/utils/PLC/__tests__/collect-library-blocks.test.ts b/src/backend/shared/utils/PLC/__tests__/collect-library-blocks.test.ts index a6949a688..879f64f49 100644 --- a/src/backend/shared/utils/PLC/__tests__/collect-library-blocks.test.ts +++ b/src/backend/shared/utils/PLC/__tests__/collect-library-blocks.test.ts @@ -1,6 +1,6 @@ /** * Tests for collectLibraryBlocks — the pure project→ collector that - * embeds used library-block signatures for the xml2st transpiler. + * embeds used library-block signatures. */ import type { PLCProjectData } from '@root/middleware/shared/ports/open-plc-types' @@ -75,7 +75,7 @@ describe('collectLibraryBlocks', () => { ]) const result = collectLibraryBlocks(project) as any - expect(result.data['@name']).toBe('openplc.org/xml2st/library-blocks') + expect(result.data['@name']).toBe('openplc.org/library-blocks') const pous = result.data.libraryBlocks.pou expect(pous).toHaveLength(1) expect(pous[0]).toMatchObject({ diff --git a/src/backend/shared/utils/PLC/__tests__/split-program-st.test.ts b/src/backend/shared/utils/PLC/__tests__/split-program-st.test.ts index 14e2af57f..bc64d7aaf 100644 --- a/src/backend/shared/utils/PLC/__tests__/split-program-st.test.ts +++ b/src/backend/shared/utils/PLC/__tests__/split-program-st.test.ts @@ -49,8 +49,8 @@ describe('splitProgramSt', () => { expect(result!.files.get('Main.st')).toBe(source) }) - it('matches POU names case-insensitively (xml2st may upper-case)', () => { - // The editor's project model has the user-typed casing; xml2st + it('matches POU names case-insensitively (the transpiler may upper-case)', () => { + // The editor's project model has the user-typed casing; the transpiler // sometimes upper-cases identifiers. The splitter must handle // either direction. const source = 'PROGRAM MAIN\n VAR x : INT; END_VAR\n x := 1;\nEND_PROGRAM\n' @@ -186,7 +186,7 @@ describe('splitProgramSt', () => { expect(result!.files.has('State_Display.st')).toBe(false) }) - it('emits `.st` for ST and graphical POUs (xml2st renders them as ST)', () => { + it('emits `.st` for ST and graphical POUs (the transpiler renders them as ST)', () => { const source = 'PROGRAM Main_LD\n VAR x : INT; END_VAR\n x := 1;\nEND_PROGRAM\n' + 'PROGRAM Main_ST\n VAR y : INT; END_VAR\n y := 2;\nEND_PROGRAM\n' @@ -243,9 +243,9 @@ describe('splitProgramSt', () => { }) }) - describe('realistic xml2st-shaped output', () => { + describe('realistic transpiler-shaped output', () => { it('handles a multi-POU + TYPE + CONFIGURATION program', () => { - // Mimics the shape xml2st emits for a typical project. + // Mimics the shape the transpiler emits for a typical project. const source = `TYPE TrafficState : (RED, YELLOW, GREEN); END_TYPE diff --git a/src/backend/shared/utils/PLC/collect-library-blocks.ts b/src/backend/shared/utils/PLC/collect-library-blocks.ts index 13dbe2ff6..e204ca496 100644 --- a/src/backend/shared/utils/PLC/collect-library-blocks.ts +++ b/src/backend/shared/utils/PLC/collect-library-blocks.ts @@ -2,15 +2,15 @@ import type { PLCProjectData } from '@root/middleware/shared/ports/open-plc-type /** * Collect the signatures of every *library* block a project uses and emit them - * as a PLCopen `` payload that the xml2st transpiler reads. + * as a PLCopen `` payload embedded in the exported project XML. * - * Why: xml2st emits a local temporary per FUNCTION output and must declare it - * with a concrete type. It infers that type from the wired connections, but - * cannot when a function has no typed pin to borrow from (e.g. a nullary - * `CURRENT_DT` whose output is unconnected) — it then falls back to the illegal - * type `ANY`, which STruC++ rejects. Rather than make xml2st carry a block - * library (which would diverge from the real STruC++ library), we hand it the - * exact signatures the project uses, embedded in the project file itself. + * Why: an ST generator emits a local temporary per FUNCTION output and must + * declare it with a concrete type. It infers that type from the wired + * connections, but cannot when a function has no typed pin to borrow from + * (e.g. a nullary `CURRENT_DT` whose output is unconnected) — it would then + * fall back to the illegal type `ANY`, which STruC++ rejects. Embedding the + * exact signatures the project uses avoids carrying a separate block library + * that could diverge from the real STruC++ library. * * Every graphical block instance already carries its full typed signature in * `node.data.variant` (the editor stamps it from the library on placement), so @@ -19,10 +19,10 @@ import type { PLCProjectData } from '@root/middleware/shared/ports/open-plc-type * identical across the desktop and web builds. * * Output shape feeds xmlbuilder2 (see XmlGenerator); it is inserted as - * `/` after ``. Contract: xml2st/docs/library-blocks.md. + * `/` after ``. */ -const DATA_NAME = 'openplc.org/xml2st/library-blocks' +const DATA_NAME = 'openplc.org/library-blocks' type XmlElement = Record @@ -41,7 +41,7 @@ type BlockVariant = { variables: VariantVariable[] } -/** ``, ``, ... — xml2st reads the (upper-cased) tag name. */ +/** ``, ``, ... — the consumer reads the (upper-cased) tag name. */ const typeElement = (variable: VariantVariable): XmlElement => ({ [variable.type.value]: '' }) const variableElement = (variable: VariantVariable): XmlElement => ({ @@ -78,7 +78,7 @@ const collectBlockVariants = (project: PLCProjectData): BlockVariant[] => { const variantToPou = (variant: BlockVariant): XmlElement => { const isFunctionBlock = variant.type === 'function-block' - // EN/ENO are implicit control pins; xml2st adds them itself. + // EN/ENO are implicit control pins; the ST generator adds them itself. const vars = variant.variables.filter((v) => v.name !== 'EN' && v.name !== 'ENO') const inputs = vars.filter((v) => v.class === 'input') const inouts = vars.filter((v) => v.class === 'inOut') @@ -113,7 +113,7 @@ const variantToPou = (variant: BlockVariant): XmlElement => { * project references no library blocks (e.g. text-only POUs). * * User-defined POUs are excluded: their definitions already travel in - * `` and xml2st resolves them directly. + * `` and the generator resolves them directly. */ export const collectLibraryBlocks = (project: PLCProjectData): XmlElement | null => { const userPouNames = new Set(project.pous.map((pou) => pou.data?.name)) diff --git a/src/backend/shared/utils/PLC/split-program-st.ts b/src/backend/shared/utils/PLC/split-program-st.ts index eb5f9bf54..b282bf054 100644 --- a/src/backend/shared/utils/PLC/split-program-st.ts +++ b/src/backend/shared/utils/PLC/split-program-st.ts @@ -1,14 +1,14 @@ /** - * Split the monolithic `program.st` produced by xml2st into one + * Split the monolithic `program.st` produced by the ST transpiler into one * synthetic source file per POU, plus auxiliary files for the project- * level sections (`_types.st`, `_globals.st`, `_config.st`). The * editor feeds the result to strucpp via `additionalSources`, so error * reports come back with `error.file === '.st'` instead of a * generic `program.st` line. * - * Why post-process here instead of changing xml2st: xml2st is being - * abandoned, and Runtime v3 (MatIEC era) ingests the monolithic - * `program.st` verbatim — splitting upstream would break that target. + * Why post-process here rather than upstream: Runtime v3 (MatIEC era) + * ingests the monolithic `program.st` verbatim — splitting upstream + * would break that target. * The editor already knows the project's POU list (it produced the XML * the splitter consumes), so the operation is name-anchored and * deterministic, not a generic ST parse. @@ -22,7 +22,7 @@ export interface KnownPou { /** POU name as the user knows it. Strucpp uppercases internally; we - * match case-insensitively against xml2st output. */ + * match case-insensitively against the transpiler output. */ name: string kind: 'PROGRAM' | 'FUNCTION' | 'FUNCTION_BLOCK' /** @@ -62,7 +62,7 @@ interface RangeMatch { * Find the line index (1-indexed, inclusive) where a POU header for * `(name, kind)` starts in `lines`. Returns -1 when no match is found. * - * The header pattern is intentionally tight: xml2st always emits the + * The header pattern is intentionally tight: the transpiler always emits the * keyword at column 0 followed by the POU name and either a colon * (functions return-type), whitespace, or end-of-line. This rules out * matches on identifiers that contain the POU name as a substring diff --git a/src/backend/shared/utils/PLC/xml-generator.ts b/src/backend/shared/utils/PLC/xml-generator.ts index 211b0c794..a9c3e573a 100644 --- a/src/backend/shared/utils/PLC/xml-generator.ts +++ b/src/backend/shared/utils/PLC/xml-generator.ts @@ -71,7 +71,7 @@ const XmlGenerator = ( } /** - * Embed the signatures of every library block the project uses, so xml2st + * Embed the signatures of every library block the project uses, so an ST generator * can type the temporaries it generates for FUNCTION outputs without * carrying a block library of its own. Added last so it serialises after * , as the PLCopen schema requires for . diff --git a/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx b/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx index 5a2400ccc..0ff9b9174 100644 --- a/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx @@ -230,7 +230,13 @@ export const GraphicalEditorAutocomplete = forwardRef {selectableValues.length > 0 && ( {variables && variables.length > 0 && ( <> -
-
+
+ {/* `scrollbar-gutter: stable` reserves the scrollbar's + width so the content-based auto-size accounts for it — + otherwise, when the list overflows and the scrollbar + appears, it steals horizontal space and the widest name + wraps its last character. */} +
{variables.map((variable) => (
- {variable.name} + {variable.name}
))}
@@ -288,7 +311,7 @@ export const GraphicalEditorAutocomplete = forwardRef
{ body: { language: 'fbd', value: {} as never }, }) const result = serializePouSignatureToST(pou) - // Block keywords carry the xml2st-parity 2-space indent. + // Block keywords carry the legacy-parity 2-space indent. expect(result).toContain(' VAR\n END_VAR') }) diff --git a/src/frontend/utils/PLC/xml-generator/base-type-tag.ts b/src/frontend/utils/PLC/xml-generator/base-type-tag.ts index a54653704..cfc8a18a9 100644 --- a/src/frontend/utils/PLC/xml-generator/base-type-tag.ts +++ b/src/frontend/utils/PLC/xml-generator/base-type-tag.ts @@ -6,7 +6,7 @@ import { lookupBaseType } from '../../iec-types-registry' * For PLCopen TC6 elementaryTypes (closed `` in the XSD), use * the canonical XML element name from strucpp's iec-types registry — * mixed-case on purpose: `` / `` are lowercase, - * everything else uppercase. xml2st (MatIEC's TC6 schema validator) + * everything else uppercase. MatIEC's TC6 schema validator * rejects `` outright with the same "expected one of (BOOL, * BYTE, ...)" error users see when a case is wrong. * diff --git a/src/frontend/utils/PLC/xml-generator/codesys/__tests__/data-type-xml.test.ts b/src/frontend/utils/PLC/xml-generator/codesys/__tests__/data-type-xml.test.ts index f982cfbb2..f90b6bb47 100644 --- a/src/frontend/utils/PLC/xml-generator/codesys/__tests__/data-type-xml.test.ts +++ b/src/frontend/utils/PLC/xml-generator/codesys/__tests__/data-type-xml.test.ts @@ -377,7 +377,7 @@ describe('codeSysParseDataTypesToXML', () => { // `initialValue: { simpleValue: { value: '' } }` wrapper instead // of `undefined`. The XML emitter must treat the empty inner // value the same as absence — otherwise it emits - // `` which xml2st turns into a stray `:= ` + // `` which the ST generator turns into a stray `:= ` // in the ST output, breaking compilation. it('omits initialValue for an array struct variable whose inner value is empty', () => { const xml = makeBaseXml() diff --git a/src/frontend/utils/PLC/xml-generator/old-editor/__tests__/data-type-xml.test.ts b/src/frontend/utils/PLC/xml-generator/old-editor/__tests__/data-type-xml.test.ts index bc7110171..b228f3de2 100644 --- a/src/frontend/utils/PLC/xml-generator/old-editor/__tests__/data-type-xml.test.ts +++ b/src/frontend/utils/PLC/xml-generator/old-editor/__tests__/data-type-xml.test.ts @@ -338,7 +338,7 @@ describe('oldEditorParseDataTypesToXML', () => { // Regression: struct creation seeds new variables with an // `initialValue: { simpleValue: { value: '' } }` wrapper instead // of `undefined`. The XML emitter must treat the empty inner - // value the same as absence — otherwise xml2st turns it into + // value the same as absence — otherwise the ST generator turns it into // a stray `:= ` in the ST output and breaks compilation. it('omits initialValue for an array struct variable whose inner value is empty', () => { const xml = makeBaseXml() diff --git a/src/frontend/utils/PLC/xml-generator/old-editor/__tests__/type-xml.test.ts b/src/frontend/utils/PLC/xml-generator/old-editor/__tests__/type-xml.test.ts index 10790213c..fcfaeb5c2 100644 --- a/src/frontend/utils/PLC/xml-generator/old-editor/__tests__/type-xml.test.ts +++ b/src/frontend/utils/PLC/xml-generator/old-editor/__tests__/type-xml.test.ts @@ -16,7 +16,7 @@ describe('convertTypeToXml', () => { // Regression: project data canonicalizes base types to uppercase // (baseTypes constant emits 'STRING'). The xml emitter must still - // produce — xml2st rejects outright. + // produce — STruC++ rejects outright. it('converts uppercase STRING base-type to lowercase tag', () => { const result = convertTypeToXml({ definition: 'base-type', value: 'STRING' }) expect(result).toEqual({ string: '' }) diff --git a/src/frontend/utils/__tests__/generate-iec-variables-to-string.test.ts b/src/frontend/utils/__tests__/generate-iec-variables-to-string.test.ts index d281437c5..9c4ef4d29 100644 --- a/src/frontend/utils/__tests__/generate-iec-variables-to-string.test.ts +++ b/src/frontend/utils/__tests__/generate-iec-variables-to-string.test.ts @@ -11,7 +11,7 @@ const makeVariable = (overrides: Partial & Pick { expect(result.split('VAR_INPUT').length).toBe(2) // one occurrence = 2 parts }) - it('emits consecutive var-class blocks with no blank line between them (xml2st parity)', () => { - // xml2st walks `self.Interface` and emits each var-class block + it('emits consecutive var-class blocks with no blank line between them (legacy parity)', () => { + // the legacy generator walks `self.Interface` and emits each var-class block // back-to-back, immediately closing one END_VAR before opening the // next header. The vars-text editor needs to mirror that exactly // so a manual edit of the text view doesn't get re-formatted into diff --git a/src/frontend/utils/debugger-session.ts b/src/frontend/utils/debugger-session.ts index d77fa1a4d..505a7f486 100644 --- a/src/frontend/utils/debugger-session.ts +++ b/src/frontend/utils/debugger-session.ts @@ -29,7 +29,7 @@ import { buildDebugPathPrefix, findInstanceName, type PLCInstanceMapping } from * * Plain progress messages get split on newlines into one log entry * per line — keeps the existing scroll/wrap/copy behaviour intact for - * the long Arduino-CLI / xml2st outputs. + * the long Arduino-CLI / compiler outputs. * * Events that carry a structured `compileError` are emitted as a * single multi-line entry instead, with the structured field attached. diff --git a/src/frontend/utils/generate-iec-variables-to-string.ts b/src/frontend/utils/generate-iec-variables-to-string.ts index 24fa192a9..3463bb43c 100644 --- a/src/frontend/utils/generate-iec-variables-to-string.ts +++ b/src/frontend/utils/generate-iec-variables-to-string.ts @@ -10,8 +10,8 @@ const classToVarBlock: Record = { temp: 'VAR_TEMP', } -// Indentation mirrors xml2st's `PLCGenerator.PouProgramGenerator.GenerateProgram` -// output (see `~/Documents/Code/xml2st/PLCGenerator.py:2414-2478`): two +// Indentation mirrors the legacy PLCGenerator's `PouProgramGenerator.GenerateProgram` +// output: two // spaces before the var-block keywords, four spaces before each // declaration line. Matching that format keeps the editor's // variables-text view byte-identical to the per-POU `.st` file the @@ -72,7 +72,7 @@ export const generateIecVariablesToString = (variables: PLCVariable[]): string = textualDeclaration += line + '\n' }) - // xml2st emits consecutive var-class blocks back-to-back with no + // the legacy generator emits consecutive var-class blocks back-to-back with no // blank line between them — ` END_VAR\n VAR_INPUT\n...`. Drop // the prior `END_VAR\n\n` that left a separator behind. textualDeclaration += `${VAR_BLOCK_INDENT}END_VAR\n` diff --git a/src/frontend/utils/iec-types-registry.ts b/src/frontend/utils/iec-types-registry.ts index 0868eb0fd..30574982d 100644 --- a/src/frontend/utils/iec-types-registry.ts +++ b/src/frontend/utils/iec-types-registry.ts @@ -138,7 +138,7 @@ const XML_ELEMENT_INDEX: ReadonlyMap = (() => { * IEC type metadata. Case-sensitive — unlike `lookupBaseType`, callers here * already have the exact tag fast-xml-parser handed them, and PLCopen XML * element names are case-significant (`` vs `` are not - * interchangeable — xml2st rejects the latter). + * interchangeable — STruC++ rejects the latter). */ export function lookupBaseTypeByXmlElement(elementName: string): IECTypeMetadata | undefined { return XML_ELEMENT_INDEX.get(elementName) diff --git a/src/middleware/shared/ports/compiler-port.ts b/src/middleware/shared/ports/compiler-port.ts index d1af6230a..375cbe618 100644 --- a/src/middleware/shared/ports/compiler-port.ts +++ b/src/middleware/shared/ports/compiler-port.ts @@ -1,7 +1,7 @@ /** * CompilerPort — Abstracts the PLC compilation pipeline. * - * Editor adapter: Delegates to main process via IPC (local tools: xml2st, STruC++, arduino-cli). + * Editor adapter: Delegates to main process via IPC (local tools: STruC++, arduino-cli). * Web adapter: Delegates to remote API at compile.getedge.me (callGenerateSt, callCompileSt, etc.). * * The UI only knows "compile this project" and receives progress events. @@ -80,7 +80,7 @@ export interface CompileLibraryArgs { /** * Skip the verification-result cache for this run. The MD5 cache * normally short-circuits the slow simulator-target verification - * when the program.st coming out of xml2st hasn't changed since + * when the program.st coming out of the ST transpiler hasn't changed since * the last successful (or failed) verify; `cleanBuild: true` * forces a fresh compile. * @@ -115,7 +115,7 @@ export interface CompilerPort { /** * Build a `.stlib` archive from a Library Project on disk. - * Editor: validates the manifest, runs the local xml2st binary, + * Editor: validates the manifest, runs the in-process ST transpiler, * pipes the result through strucpp's library compiler, and * writes the archive to `/build/.stlib`. * Web (future): posts the project + manifest to a remote service diff --git a/src/middleware/shared/utils/library/compose-runtime-v4-bundle.ts b/src/middleware/shared/utils/library/compose-runtime-v4-bundle.ts index bb8942c5b..310d83d18 100644 --- a/src/middleware/shared/utils/library/compose-runtime-v4-bundle.ts +++ b/src/middleware/shared/utils/library/compose-runtime-v4-bundle.ts @@ -45,7 +45,7 @@ */ export interface ComposeRuntimeV4BundleInput { - /** Concatenated ST program emitted by xml2st. */ + /** Concatenated ST program emitted by the ST transpiler. */ programSt: string /** MD5 of `programSt` — written to `defines.h` so the v4 runtime * shim (`runtime_v4_entry.cpp`) can report it via FC 0x45. */ @@ -100,7 +100,7 @@ export interface ComposeRuntimeV4BundleInput { export function composeRuntimeV4Bundle(input: ComposeRuntimeV4BundleInput): Record { const files: Record = {} - // 1. Concatenated ST program (xml2st output) + // 1. Concatenated ST program (ST transpiler output) files['program.st'] = input.programSt // 2. Strucpp emitted artefacts at the zip root From 9e6e025d45cd6d5bac3b17bc374d90d2a7e69b89 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Wed, 22 Jul 2026 17:35:14 -0300 Subject: [PATCH 42/52] fix(resources): cascade-delete global variable used as VAR_EXTERNAL (#952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a global variable that any POU declares as VAR_EXTERNAL was silently refused by the store guard, but the global-variables editor swallowed the failure response — the delete button just did nothing, with no warning or message (odd UX). Now the editor detects the referencing POUs up front and shows a confirmation modal naming them. On confirm, deleteVariable is called with force: true, which removes the global AND the matching VAR_EXTERNAL declaration from every referencing POU. Graphical nodes that still reference the deleted global go unresolved for the user to fix (we only remove the external declarations, not diagram nodes). - project slice: deleteVariable gains a force flag; without it, global deletion blocked by external refs returns the referencing POU names in data.referencingPous; with it, cascades the external-decl removal. - global-variables editor: surfaces the block as a confirmation modal instead of a silent no-op. - tests: assert referencingPous is reported (unforced) and that force cascades the removal across every referencing POU while retaining unrelated locals. Co-authored-by: Claude Opus 4.8 (1M context) --- .../global-variables-editor/index.tsx | 93 ++++++++++++++++--- .../store/__tests__/project-slice.test.ts | 50 ++++++++++ src/frontend/store/slices/project/slice.ts | 74 ++++++++++----- src/frontend/store/slices/project/types.ts | 8 ++ 4 files changed, 187 insertions(+), 38 deletions(-) diff --git a/src/frontend/components/_organisms/global-variables-editor/index.tsx b/src/frontend/components/_organisms/global-variables-editor/index.tsx index 5a587c094..7e6674a44 100644 --- a/src/frontend/components/_organisms/global-variables-editor/index.tsx +++ b/src/frontend/components/_organisms/global-variables-editor/index.tsx @@ -1,7 +1,7 @@ // import * as PrimitiveSwitch from '@radix-ui/react-switch' import { useCallback, useEffect, useRef, useState } from 'react' -import type { PLCGlobalVariable } from '../../../../middleware/shared/ports/types' +import type { PLCGlobalVariable, PLCVariable } from '../../../../middleware/shared/ports/types' import { CodeIcon } from '../../../assets/icons/interface/CodeIcon' import { MinusIcon } from '../../../assets/icons/interface/Minus' import { PlusIcon } from '../../../assets/icons/interface/Plus' @@ -15,6 +15,7 @@ import { generateIecVariablesToString } from '../../../utils/generate-iec-variab import TableActions from '../../_atoms/table-actions' import { toast } from '../../_features/[app]/toast/use-toast' import { GlobalVariablesTable } from '../../_molecules/global-variables-table' +import { Modal, ModalContent, ModalTitle } from '../../_molecules/modal' import { VariablesCodeEditor } from '../variables-code-editor' const GlobalVariablesEditor = () => { @@ -63,6 +64,14 @@ const GlobalVariablesEditor = () => { const [editorCode, setEditorCode] = useState(() => generateIecVariablesToString(tableData)) const [parseError, setParseError] = useState(null) + // Pending confirmation when the global being deleted is used as `VAR_EXTERNAL` + // in one or more POUs — deleting it will also remove those external declarations. + const [pendingGlobalDelete, setPendingGlobalDelete] = useState<{ + variable: PLCVariable + selectedRow: number + pous: string[] + } | null>(null) + const [editorVariables, setEditorVariables] = useState({ display: 'table', selectedRow: ROWS_NOT_SELECTED.toString(), @@ -267,28 +276,53 @@ const GlobalVariablesEditor = () => { handleFileAndWorkspaceSavedState('Resource') } - const handleRemoveVariable = () => { - if (editorVariables.display === 'code') return - + const performGlobalDelete = (variableToDelete: PLCVariable, selectedRow: number, force: boolean) => { pushToHistory(editor.meta.name) + removeDebugVariable(`resource:${variableToDelete.name}`) - const selectedRow = parseInt(editorVariables.selectedRow) - const variableToDelete = globalVariables.filter((v) => v.name)[selectedRow] - if (variableToDelete) { - removeDebugVariable(`resource:${variableToDelete.name}`) + const result = deleteVariable({ scope: 'global', variableName: variableToDelete.name, force }) + if (!result.ok) { + toast({ title: result.title ?? 'Error', description: result.message, variant: 'fail' }) + return } - deleteVariable({ scope: 'global', rowId: selectedRow }) const variables = globalVariables.filter((variable) => variable.name) if (selectedRow === variables.length - 1) { - updateModelVariables({ - display: 'table', - selectedRow: selectedRow - 1, - }) + updateModelVariables({ display: 'table', selectedRow: selectedRow - 1 }) } handleFileAndWorkspaceSavedState('Resource') } + const handleRemoveVariable = () => { + if (editorVariables.display === 'code') return + + const selectedRow = parseInt(editorVariables.selectedRow) + const variableToDelete = globalVariables.filter((v) => v.name)[selectedRow] + if (!variableToDelete) return + + // If any POU declares this global as VAR_EXTERNAL, confirm the cascade first. + const referencingPous = snapshotPous + .filter((pou) => + pou.interface?.variables?.some( + (v) => v.class === 'external' && v.name.toLowerCase() === variableToDelete.name.toLowerCase(), + ), + ) + .map((pou) => pou.name) + + if (referencingPous.length > 0) { + setPendingGlobalDelete({ variable: variableToDelete, selectedRow, pous: referencingPous }) + return + } + + performGlobalDelete(variableToDelete, selectedRow, false) + } + + const confirmGlobalDelete = () => { + if (!pendingGlobalDelete) return + performGlobalDelete(pendingGlobalDelete.variable, pendingGlobalDelete.selectedRow, true) + setPendingGlobalDelete(null) + } + const handleRowClick = (row: HTMLTableRowElement) => { updateModelVariables({ display: 'table', @@ -427,6 +461,39 @@ const GlobalVariablesEditor = () => { {parseError &&

Erro: {parseError}

}
)} + + { + if (!open) setPendingGlobalDelete(null) + }} + > + + Delete global variable? +

+ {pendingGlobalDelete?.variable.name} is used as an external variable in{' '} + {pendingGlobalDelete?.pous.join(', ')}. Deleting it will also remove{' '} + {pendingGlobalDelete && pendingGlobalDelete.pous.length > 1 + ? 'those external references' + : 'that external reference'} + . Continue? +

+
+ + +
+
+
) } diff --git a/src/frontend/store/__tests__/project-slice.test.ts b/src/frontend/store/__tests__/project-slice.test.ts index 045a13ee2..be3d99f72 100644 --- a/src/frontend/store/__tests__/project-slice.test.ts +++ b/src/frontend/store/__tests__/project-slice.test.ts @@ -3260,6 +3260,56 @@ describe('createProjectSlice', () => { expect(result.ok).toBe(false) expect(result.title).toBe('Cannot Delete Global Variable') expect(result.message).toContain('Consumer') + expect((result.data as { referencingPous: string[] }).referencingPous).toEqual(['Consumer']) + // Not forced → nothing was deleted. + expect( + store.getState().project.data.configurations.resource.globalVariables.some((v) => v.name === 'SharedVar'), + ).toBe(true) + }) + + it('deleteVariable global with force cascades: removes the global + external refs from every POU', () => { + store.getState().projectActions.createVariable({ scope: 'global', data: makeVariable('SharedVar', 'global') }) + for (const name of ['Consumer', 'Consumer2']) { + seedPou(store, { + ...makePou(name, 'program'), + interface: { + variables: [ + { + name: 'SharedVar', + class: 'external', + type: { definition: 'base-type', value: 'INT' }, + location: '', + documentation: '', + }, + { + name: 'keep_me', + class: 'local', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + documentation: '', + }, + ], + }, + }) + } + + const result = store.getState().projectActions.deleteVariable({ + scope: 'global', + variableName: 'SharedVar', + force: true, + }) + + expect(result.ok).toBe(true) + // The global itself is gone. + expect( + store.getState().project.data.configurations.resource.globalVariables.some((v) => v.name === 'SharedVar'), + ).toBe(false) + // The external declaration is removed from every referencing POU; unrelated locals stay. + for (const name of ['Consumer', 'Consumer2']) { + const vars = store.getState().project.data.pous.find((p) => p.name === name)?.interface?.variables ?? [] + expect(vars.some((v) => v.name === 'SharedVar')).toBe(false) + expect(vars.some((v) => v.name === 'keep_me')).toBe(true) + } }) it('updateOpcUaServerConfig with top-level updates (cycleTimeMs)', () => { diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index a6c93f670..87c226667 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -822,36 +822,43 @@ const createProjectSlice: StateCreator = const found = getVariableBasedOnRowIdOrVariableId(variables, rowId, variableId) return found?.variable }, - deleteVariable: ({ scope, associatedPou, rowId, variableId, variableName }) => { + deleteVariable: ({ scope, associatedPou, rowId, variableId, variableName, force }) => { if (scope === 'local') { const reconcile = reconcileVariablesText(associatedPou, getState, setState) if (!reconcile.ok) return reconcile } + // Deleting a global that POUs declare as `VAR_EXTERNAL` must handle those + // references too. Without `force`, refuse and report the referencing POUs + // (the editor surfaces a confirmation). With `force`, cascade-delete: drop + // the global AND the matching external declaration from every such POU. + let cascade: { globalName: string; pous: string[] } | null = null if (scope === 'global') { const state = getState() const globalVars = state.project.data.configurations.resource.globalVariables - - let variableToDelete: PLCVariable | undefined - if (variableName) { - variableToDelete = globalVars.find((v) => v.name.toLowerCase() === variableName.toLowerCase()) - } else { - variableToDelete = getVariableBasedOnRowIdOrVariableId(globalVars, rowId, variableId)?.variable - } + const variableToDelete = variableName + ? globalVars.find((v) => v.name.toLowerCase() === variableName.toLowerCase()) + : getVariableBasedOnRowIdOrVariableId(globalVars, rowId, variableId)?.variable if (variableToDelete) { - const externalReferences = state.project.data.pous.filter((pou) => - pou.interface?.variables?.some( - (v) => v.class === 'external' && v.name.toLowerCase() === variableToDelete.name.toLowerCase(), - ), - ) - - if (externalReferences.length > 0) { - const pouNames = externalReferences.map((pou) => pou.name).join(', ') - return fail( - `The global variable "${variableToDelete.name}" is referenced by external variables in the following POUs: ${pouNames}. Please remove these references before deleting the global variable.`, - 'Cannot Delete Global Variable', + const referencingPous = state.project.data.pous + .filter((pou) => + pou.interface?.variables?.some( + (v) => v.class === 'external' && v.name.toLowerCase() === variableToDelete.name.toLowerCase(), + ), ) + .map((pou) => pou.name) + + if (referencingPous.length > 0) { + if (!force) { + return { + ok: false, + title: 'Cannot Delete Global Variable', + message: `The global variable "${variableToDelete.name}" is used as an external variable in the following POUs: ${referencingPous.join(', ')}.`, + data: { referencingPous }, + } + } + cascade = { globalName: variableToDelete.name, pous: referencingPous } } } } @@ -875,17 +882,34 @@ const createProjectSlice: StateCreator = return } variables.splice(idx, 1) - return + } else { + const found = getVariableBasedOnRowIdOrVariableId(variables, rowId, variableId) + if (!found) { + response = fail('Variable not found') + return + } + variables.splice(found.index, 1) } - const found = getVariableBasedOnRowIdOrVariableId(variables, rowId, variableId) - if (!found) { - response = fail('Variable not found') - return + + // Cascade: remove the matching VAR_EXTERNAL from every referencing POU. + if (cascade) { + const { globalName, pous } = cascade + for (const pouName of pous) { + const pouVars = slice.project.data.pous.find((p) => p.name === pouName)?.interface?.variables + const extIdx = + pouVars?.findIndex( + (v) => v.class === 'external' && v.name.toLowerCase() === globalName.toLowerCase(), + ) ?? -1 + if (pouVars && extIdx !== -1) pouVars.splice(extIdx, 1) + } } - variables.splice(found.index, 1) }), ) if (scope === 'local' && response.ok) regenerateVariablesText(associatedPou, getState) + // Refresh the vars-text of every POU whose external declaration we removed. + if (response.ok && cascade) { + for (const pouName of cascade.pous) regenerateVariablesText(pouName, getState) + } return response }, rearrangeVariables: ({ scope, associatedPou, rowId, variableId, newIndex }) => { diff --git a/src/frontend/store/slices/project/types.ts b/src/frontend/store/slices/project/types.ts index d6981cdae..e9518b207 100644 --- a/src/frontend/store/slices/project/types.ts +++ b/src/frontend/store/slices/project/types.ts @@ -137,6 +137,14 @@ export type ProjectActions = { rowId?: number variableId?: string variableName?: string + /** + * Global scope only. When a global is referenced as `VAR_EXTERNAL` by any + * POU, deletion is refused by default and the response carries the + * referencing POU names in `data.referencingPous`. Pass `force: true` to + * cascade-delete: remove the global AND the matching external declaration + * from every referencing POU. + */ + force?: boolean }) => ProjectResponse rearrangeVariables: (args: { scope: 'global' | 'local' From 53e01f21e1cac49fb915c84cd68e46d1916ebda8 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Wed, 22 Jul 2026 17:59:59 -0400 Subject: [PATCH 43/52] feat: runtime User Management screen with RBAC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "User Management" screen under the project tree's Device folder, shown only while connected to a runtime. Lets an admin list, create, edit, and delete runtime accounts; a normal user can edit only its own account. - Shared frontend (editor + web, byte-identical): new `plc-user-management` editor variant, gated Device-tree leaf + Users icon, and a single-table screen with per-row edit/delete icons (OPC-UA style). - Reusable RuntimeUserModal (create | edit | bootstrap). The first-user dialog is refactored to use it. Edit mode shows a masked password placeholder and only sends a password when the field is actually edited (dirty-tracked) — an untouched form never resets a password. Editing your own password requires the current password. - RuntimePort gains listUsers / whoAmI / updateUser / deleteUser and a role on createUser; editor adapter + IPC channels implement them. create-user is sent unauthenticated for first-user bootstrap and authenticated (admin) afterwards. - UI role-gates actions (admin sees create/delete + role selector); the runtime remains the real authorization boundary. - Tests: adapter methods (100%), tabs factory, and the modal's dirty-password / current-password rules. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/frontend/assets/icons/project/Users.tsx | 38 +++ .../editor/user-management/index.tsx | 259 ++++++++++++++++ .../_molecules/project-tree/index.tsx | 5 +- .../_organisms/explorer/project.tsx | 19 ++ .../__tests__/runtime-user-modal.test.tsx | 158 ++++++++++ .../modals/runtime-create-user-modal.tsx | 189 +++--------- .../_organisms/modals/runtime-user-modal.tsx | 292 ++++++++++++++++++ src/frontend/screens/workspace-screen.tsx | 2 + .../store/__tests__/tabs-utils.test.ts | 7 + src/frontend/store/slices/editor/types.ts | 8 + src/frontend/store/slices/tabs/types.ts | 1 + src/frontend/store/slices/tabs/utils.ts | 8 + src/frontend/store/slices/workspace/types.ts | 1 + src/main/modules/ipc/main.ts | 137 +++++++- src/main/modules/ipc/renderer.ts | 20 +- .../editor/__tests__/runtime-adapter.test.ts | 102 +++++- .../adapters/editor/runtime-adapter.ts | 41 ++- src/middleware/shared/ports/runtime-port.ts | 52 ++++ 18 files changed, 1178 insertions(+), 161 deletions(-) create mode 100644 src/frontend/assets/icons/project/Users.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/user-management/index.tsx create mode 100644 src/frontend/components/_organisms/modals/__tests__/runtime-user-modal.test.tsx create mode 100644 src/frontend/components/_organisms/modals/runtime-user-modal.tsx diff --git a/src/frontend/assets/icons/project/Users.tsx b/src/frontend/assets/icons/project/Users.tsx new file mode 100644 index 000000000..18ea4c13f --- /dev/null +++ b/src/frontend/assets/icons/project/Users.tsx @@ -0,0 +1,38 @@ +import { ComponentProps } from 'react' + +import { cn } from '../../../utils/cn' + +type IUsersIconProps = ComponentProps<'svg'> & { + size?: 'sm' | 'md' | 'lg' +} + +const sizeClasses = { + sm: 'w-5 h-5', + md: 'w-6 h-6', + lg: 'w-12 h-12', +} + +export const UsersIcon = (props: IUsersIconProps) => { + const { className, size = 'sm', ...res } = props + return ( + + + + + + + ) +} diff --git a/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx b/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx new file mode 100644 index 000000000..111cc3c1b --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx @@ -0,0 +1,259 @@ +import { PencilIcon } from '@root/frontend/assets/icons/interface/Pencil' +import { PlusIcon } from '@root/frontend/assets/icons/interface/Plus' +import { RefreshIcon } from '@root/frontend/assets/icons/interface/Refresh' +import { TrashCanIcon } from '@root/frontend/assets/icons/interface/TrashCan' +import { toast } from '@root/frontend/components/_features/[app]/toast/use-toast' +import { Modal, ModalContent, ModalTitle } from '@root/frontend/components/_molecules/modal' +import { RuntimeUserModal, type RuntimeUserModalSubmit } from '@root/frontend/components/_organisms/modals/runtime-user-modal' +import { useOpenPLCStore } from '@root/frontend/store' +import type { RuntimeUser, UpdateUserParams } from '@root/middleware/shared/ports/runtime-port' +import { useRuntime } from '@root/middleware/shared/providers' +import { useCallback, useEffect, useState } from 'react' + +type EditTarget = { user: RuntimeUser; isSelf: boolean } + +const UserManagementEditor = () => { + const runtime = useRuntime() + const connectionStatus = useOpenPLCStore((s) => s.runtimeConnection.connectionStatus) + + const [users, setUsers] = useState([]) + const [currentUser, setCurrentUser] = useState(null) + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + + const [createOpen, setCreateOpen] = useState(false) + const [editTarget, setEditTarget] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) + const [deleting, setDeleting] = useState(false) + + const isAdmin = currentUser?.role === 'admin' + + const refresh = useCallback(async () => { + setLoading(true) + setLoadError(null) + const [listResult, meResult] = await Promise.all([runtime.listUsers(), runtime.whoAmI()]) + if (!listResult.success) { + setLoadError(listResult.error || 'Failed to load users') + setUsers([]) + } else { + setUsers(listResult.users ?? []) + } + if (meResult.success && meResult.user) { + setCurrentUser(meResult.user) + } + setLoading(false) + }, [runtime]) + + useEffect(() => { + // Reload whenever the screen mounts or the connection is (re)established. + if (connectionStatus === 'connected') { + void refresh() + } + }, [connectionStatus, refresh]) + + const handleCreate = async ({ username, password, role }: RuntimeUserModalSubmit): Promise => { + if (!password) return 'Password is required' + const result = await runtime.createUser({ username, password, role }) + if (!result.success) return result.error || 'Failed to create user' + toast({ title: 'User created', description: `"${username}" was created.`, variant: 'default' }) + void refresh() + return null + } + + const handleEdit = async (values: RuntimeUserModalSubmit): Promise => { + if (!editTarget) return 'No user selected' + const params: UpdateUserParams = {} + if (values.usernameChanged) params.username = values.username + if (values.passwordChanged) { + params.password = values.password + if (values.currentPassword) params.currentPassword = values.currentPassword + } + if (values.roleChanged) params.role = values.role + const result = await runtime.updateUser(editTarget.user.id, params) + if (!result.success) return result.error || 'Failed to update user' + toast({ title: 'User updated', description: `"${values.username}" was updated.`, variant: 'default' }) + void refresh() + return null + } + + const handleDelete = async () => { + if (!deleteTarget) return + setDeleting(true) + const result = await runtime.deleteUser(deleteTarget.id) + setDeleting(false) + if (!result.success) { + toast({ title: 'Delete failed', description: result.error || 'Failed to delete user', variant: 'fail' }) + return + } + toast({ title: 'User deleted', description: `"${deleteTarget.username}" was deleted.`, variant: 'default' }) + setDeleteTarget(null) + void refresh() + } + + const canEditRow = (user: RuntimeUser) => isAdmin || user.id === currentUser?.id + const canDeleteRow = (user: RuntimeUser) => isAdmin && user.id !== currentUser?.id + + return ( +
+
+
+

User Management

+

+ Manage the accounts that can log in to this runtime. +

+
+
+ + {isAdmin && ( + + )} +
+
+ + {loading ? ( +

Loading users…

+ ) : loadError ? ( +

{loadError}

+ ) : ( +
+ + + + + + + + + + {users.map((user) => { + const isSelf = user.id === currentUser?.id + return ( + + + + + + ) + })} + +
UsernameRole + Actions +
+ {user.username} + {isSelf && (you)} + {user.role} +
+ {canEditRow(user) && ( + + )} + {canDeleteRow(user) && ( + + )} +
+
+ {users.length === 0 &&

No users found.

} +
+ )} + + {/* Create modal (admin only) */} + {isAdmin && ( + + )} + + {/* Edit modal */} + {editTarget && ( + { + if (!open) setEditTarget(null) + }} + mode='edit' + title={editTarget.isSelf ? 'Edit your account' : `Edit user — ${editTarget.user.username}`} + submitLabel='Save' + initialUsername={editTarget.user.username} + initialRole={editTarget.user.role} + // Only admins can change roles, and never their own (prevents self-lockout); + // the runtime enforces this too. + showRole={isAdmin && !editTarget.isSelf} + requireCurrentPassword={editTarget.isSelf} + onSubmit={handleEdit} + /> + )} + + {/* Delete confirmation */} + { + if (!open) setDeleteTarget(null) + }} + > + + Delete user +

+ "{deleteTarget?.username}" will no longer be able to log in to the runtime. This cannot be undone. +

+
+ + +
+
+
+
+ ) +} + +export { UserManagementEditor } diff --git a/src/frontend/components/_molecules/project-tree/index.tsx b/src/frontend/components/_molecules/project-tree/index.tsx index 73fb7f2bf..a78f114c2 100644 --- a/src/frontend/components/_molecules/project-tree/index.tsx +++ b/src/frontend/components/_molecules/project-tree/index.tsx @@ -30,6 +30,7 @@ import { ServerIcon } from '../../../assets/icons/project/Server' import { SFCIcon } from '../../../assets/icons/project/SFC' import { STIcon } from '../../../assets/icons/project/ST' import { StructureIcon } from '../../../assets/icons/project/Structure' +import { UsersIcon } from '../../../assets/icons/project/Users' import { useOpenPLCStore } from '../../../store' import { WorkspaceProjectTreeLeafType } from '../../../store/slices/workspace/types' import { cn } from '../../../utils/cn' @@ -451,6 +452,7 @@ type IProjectTreeLeafProps = ComponentPropsWithoutRef<'li'> & { | 'ethercatDevice' | 'softMotionDrive' | 'libraryManifest' + | 'userManagement' leafType: WorkspaceProjectTreeLeafType label?: string busName?: string @@ -484,6 +486,7 @@ const LeafSources = { // render the same glyph — the manifest is the user's entry point // into a library project, so it earns a dedicated mark. libraryManifest: { LeafIcon: LibraryManifestIcon }, + userManagement: { LeafIcon: UsersIcon }, } const ProjectTreeLeaf = ({ leafLang, @@ -763,7 +766,7 @@ const ProjectTreeLeaf = ({ )} - {leafLang === 'devPin' || leafLang === 'devConfig' ? null : ( + {leafLang === 'devPin' || leafLang === 'devConfig' || leafLang === 'userManagement' ? null : ( { // endpoint requires edit access, so gate the affordance here too. const canEdit = useOpenPLCStore((s) => s.workspace.canEdit) + // Runtime User Management is only meaningful while connected to a runtime + // (it reads/writes the runtime's account list over the authenticated API). + const runtimeConnected = useOpenPLCStore((s) => s.runtimeConnection.connectionStatus === 'connected') + // Per-project-type capability matrix — drives which branches // render. Library projects only show Functions / Function Blocks / // Data Types plus the manifest tab; Programs / Resource / Devices / @@ -374,6 +378,21 @@ const Project = () => { } /> )} + {runtimeConnected && ( + + handleCreateTab({ + name: 'User Management', + path: `/device/user-management`, + elementType: { type: 'user-management' }, + }) + } + /> + )} )} diff --git a/src/frontend/components/_organisms/modals/__tests__/runtime-user-modal.test.tsx b/src/frontend/components/_organisms/modals/__tests__/runtime-user-modal.test.tsx new file mode 100644 index 000000000..671733d87 --- /dev/null +++ b/src/frontend/components/_organisms/modals/__tests__/runtime-user-modal.test.tsx @@ -0,0 +1,158 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' + +import { RuntimeUserModal, type RuntimeUserModalSubmit } from '../runtime-user-modal' + +// Plain typed closures (instead of vi.fn generics) so the same test file is +// type-correct under both the editor's jest and the web's vitest runner. +let submitCalls: RuntimeUserModalSubmit[] +let submitReturn: string | null +let openChanges: boolean[] + +const onSubmit = (values: RuntimeUserModalSubmit): Promise => { + submitCalls.push(values) + return Promise.resolve(submitReturn) +} +const onOpenChange = (open: boolean) => { + openChanges.push(open) +} + +beforeEach(() => { + submitCalls = [] + submitReturn = null + openChanges = [] +}) + +describe('RuntimeUserModal — password dirty tracking', () => { + it('does NOT report a password change when the password field is untouched (edit mode)', async () => { + render( + , + ) + + // Change only the username; leave the pre-filled password placeholder alone. + fireEvent.change(screen.getByPlaceholderText('Enter username'), { target: { value: 'bobby' } }) + fireEvent.click(screen.getByText('Save')) + + await waitFor(() => expect(submitCalls).toHaveLength(1)) + expect(submitCalls[0].usernameChanged).toBe(true) + expect(submitCalls[0].passwordChanged).toBe(false) + expect(submitCalls[0].password).toBeUndefined() + }) + + it('reports a password change once the field is edited (edit mode)', async () => { + render( + , + ) + + const pass = screen.getByPlaceholderText('Enter password') + const confirm = screen.getByPlaceholderText('Confirm password') + // Focus clears the masked placeholder from both fields, then type a new one. + fireEvent.focus(pass) + fireEvent.change(pass, { target: { value: 'new-secret' } }) + fireEvent.change(confirm, { target: { value: 'new-secret' } }) + fireEvent.click(screen.getByText('Save')) + + await waitFor(() => expect(submitCalls).toHaveLength(1)) + expect(submitCalls[0].passwordChanged).toBe(true) + expect(submitCalls[0].password).toBe('new-secret') + }) + + it('blocks submit when nothing changed (edit mode)', async () => { + render( + , + ) + fireEvent.click(screen.getByText('Save')) + await screen.findByText('No changes to save') + expect(submitCalls).toHaveLength(0) + }) + + it('requires the current password to change your own password (self edit)', async () => { + render( + , + ) + + const pass = screen.getByPlaceholderText('Enter password') + fireEvent.focus(pass) + fireEvent.change(pass, { target: { value: 'new-secret' } }) + fireEvent.change(screen.getByPlaceholderText('Confirm password'), { target: { value: 'new-secret' } }) + fireEvent.click(screen.getByText('Save')) + + await screen.findByText('Your current password is required to change the password') + expect(submitCalls).toHaveLength(0) + }) + + it('rejects mismatched passwords', async () => { + render( + , + ) + fireEvent.change(screen.getByPlaceholderText('Enter username'), { target: { value: 'bob' } }) + fireEvent.change(screen.getByPlaceholderText('Enter password'), { target: { value: 'aaa' } }) + fireEvent.change(screen.getByPlaceholderText('Confirm password'), { target: { value: 'bbb' } }) + fireEvent.click(screen.getByText('Create')) + + await screen.findByText('Passwords do not match') + expect(submitCalls).toHaveLength(0) + }) + + it('creates a user with the required fields and closes on success (create mode)', async () => { + render( + , + ) + fireEvent.change(screen.getByPlaceholderText('Enter username'), { target: { value: 'bob' } }) + fireEvent.change(screen.getByPlaceholderText('Enter password'), { target: { value: 'secret' } }) + fireEvent.change(screen.getByPlaceholderText('Confirm password'), { target: { value: 'secret' } }) + fireEvent.click(screen.getByText('Create')) + + await waitFor(() => expect(submitCalls).toHaveLength(1)) + expect(submitCalls[0]).toMatchObject({ username: 'bob', password: 'secret', role: 'user', passwordChanged: true }) + await waitFor(() => expect(openChanges).toContain(false)) + }) +}) diff --git a/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx b/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx index 9585bc2c5..d1eae9087 100644 --- a/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx +++ b/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx @@ -1,174 +1,59 @@ -import { useState } from 'react' - import { useRuntime } from '../../../../middleware/shared/providers' import { useOpenPLCStore } from '../../../store' import { getErrorMessage } from '../../../utils/get-error-message' -import { Label } from '../../_atoms/label' -import { Modal, ModalContent, ModalTitle } from '../../_molecules/modal' - +import { RuntimeUserModal, type RuntimeUserModalSubmit } from './runtime-user-modal' + +/** + * First-user bootstrap dialog: shown when connecting to a runtime that has no + * accounts yet. Reuses the shared RuntimeUserModal form and, on success, also + * logs in as the new user and marks the connection as established (the runtime + * always makes this first account an admin). + */ const RuntimeCreateUserModal = () => { - const { modals, modalActions, deviceActions, runtimeConnection } = useOpenPLCStore() + const { modals, modalActions, deviceActions } = useOpenPLCStore() const runtime = useRuntime() - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') - const [error, setError] = useState('') - const [isLoading, setIsLoading] = useState(false) const isOpen = modals['runtime-create-user']?.open || false - // Build a stable unique suffix for input ids to prevent browser autofill - const deviceId = runtimeConnection.selectedDevice?.deviceId || 'default' - - const handleCreateUser = async () => { - setError('') - - if (!username || !password) { - setError('Username and password are required') - return - } - - if (password !== confirmPassword) { - setError('Passwords do not match') - return - } - - setIsLoading(true) + const handleSubmit = async ({ username, password }: RuntimeUserModalSubmit): Promise => { + if (!password) return 'Password is required' try { const result = await runtime.createUser({ username, password }) - - if (result.success) { - const loginResult = await runtime.login({ username, password }) - if (loginResult.success && loginResult.accessToken) { - deviceActions.setRuntimeJwtToken(loginResult.accessToken) - deviceActions.setRuntimeConnectionStatus('connected') - deviceActions.setStoredCredentials({ username, password }) - modalActions.closeModal() - setUsername('') - setPassword('') - setConfirmPassword('') - } else { - setError('User created but login failed: ' + (loginResult.error || 'Unknown error')) - } - } else { - setError('Failed to create user: ' + (result.error || 'Unknown error')) + if (!result.success) { + return 'Failed to create user: ' + (result.error || 'Unknown error') } + const loginResult = await runtime.login({ username, password }) + if (loginResult.success && loginResult.accessToken) { + deviceActions.setRuntimeJwtToken(loginResult.accessToken) + deviceActions.setRuntimeConnectionStatus('connected') + deviceActions.setStoredCredentials({ username, password }) + return null + } + return 'User created but login failed: ' + (loginResult.error || 'Unknown error') } catch (err) { - setError('Error: ' + getErrorMessage(err)) - } finally { - setIsLoading(false) + return 'Error: ' + getErrorMessage(err) } } - const handleCancel = () => { - modalActions.closeModal() - deviceActions.setRuntimeConnectionStatus('disconnected') - setUsername('') - setPassword('') - setConfirmPassword('') - setError('') + const handleOpenChange = (open: boolean) => { + if (!open) { + // Cancelling the first-user setup abandons the connection attempt. + if (isOpen) deviceActions.setRuntimeConnectionStatus('disconnected') + modalActions.closeModal() + } + modalActions.onOpenChange('runtime-create-user', open) } return ( - { - if (!open) { - handleCancel() - } - modalActions.onOpenChange('runtime-create-user', open) - }} - > - - Create First User - -

- This OpenPLC Runtime has no users registered. Please create the first user account. -

- -
{ - e.preventDefault() - void handleCreateUser() - }} - className='flex w-full flex-col gap-4' - > -
- - setUsername(e.target.value)} - placeholder='Enter username' - className='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' - disabled={isLoading} - /> -
- -
- - setPassword(e.target.value)} - placeholder='Enter password' - className='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' - disabled={isLoading} - /> -
- -
- - setConfirmPassword(e.target.value)} - placeholder='Confirm password' - className='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' - disabled={isLoading} - /> -
- - {error &&

{error}

} - -
- - -
-
-
-
+ onOpenChange={handleOpenChange} + mode='bootstrap' + title='Create First User' + description='This OpenPLC Runtime has no users registered. Please create the first user account.' + submitLabel='Create User' + onSubmit={handleSubmit} + /> ) } diff --git a/src/frontend/components/_organisms/modals/runtime-user-modal.tsx b/src/frontend/components/_organisms/modals/runtime-user-modal.tsx new file mode 100644 index 000000000..1bef6f7b5 --- /dev/null +++ b/src/frontend/components/_organisms/modals/runtime-user-modal.tsx @@ -0,0 +1,292 @@ +import { useEffect, useState } from 'react' + +import type { RuntimeUserRole } from '../../../../middleware/shared/ports/runtime-port' +import { Label } from '../../_atoms/label' +import { Modal, ModalContent, ModalTitle } from '../../_molecules/modal' + +/** + * Normalized result of the form. `*Changed` flags let the caller send only the + * fields the user actually touched — critically, `password` is present ONLY + * when the password field was genuinely edited, so an untouched form never + * resets a password. + */ +export interface RuntimeUserModalSubmit { + username: string + role?: RuntimeUserRole + password?: string + currentPassword?: string + usernameChanged: boolean + passwordChanged: boolean + roleChanged: boolean +} + +interface RuntimeUserModalProps { + open: boolean + onOpenChange: (open: boolean) => void + /** bootstrap = first-user setup, create = admin adding a user, edit = modify existing. */ + mode: 'bootstrap' | 'create' | 'edit' + title: string + description?: string + submitLabel: string + /** Prefill (edit mode). */ + initialUsername?: string + initialRole?: RuntimeUserRole + /** Show the Role selector (admin managing accounts). */ + showRole?: boolean + /** Editing your OWN account: a password change must be confirmed with the + * current password (blocks a stolen session from silently resetting it). */ + requireCurrentPassword?: boolean + /** Returns an error message to display, or null on success (which closes the modal). */ + onSubmit: (values: RuntimeUserModalSubmit) => Promise +} + +// Eight bullets shown in a password field in edit mode so it *looks* populated +// without ever holding (or submitting) a real password. Cleared on first edit. +const PASSWORD_PLACEHOLDER = '••••••••' + +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' + +const RuntimeUserModal = (props: RuntimeUserModalProps) => { + const { + open, + onOpenChange, + mode, + title, + description, + submitLabel, + initialUsername = '', + initialRole = 'user', + showRole = false, + requireCurrentPassword = false, + onSubmit, + } = props + + const isEdit = mode === 'edit' + + const [username, setUsername] = useState(initialUsername) + const [role, setRole] = useState(initialRole) + // In edit mode the password fields start as a masked placeholder; a real + // change is only registered once the user focuses and types (passwordTouched). + const [password, setPassword] = useState(isEdit ? PASSWORD_PLACEHOLDER : '') + const [confirmPassword, setConfirmPassword] = useState(isEdit ? PASSWORD_PLACEHOLDER : '') + const [currentPassword, setCurrentPassword] = useState('') + const [passwordTouched, setPasswordTouched] = useState(false) + const [error, setError] = useState('') + const [isLoading, setIsLoading] = useState(false) + + // Reset every field when the modal (re)opens, so a reused instance never + // leaks state between the account it was last opened for and the next. + useEffect(() => { + if (open) { + setUsername(initialUsername) + setRole(initialRole) + setPassword(isEdit ? PASSWORD_PLACEHOLDER : '') + setConfirmPassword(isEdit ? PASSWORD_PLACEHOLDER : '') + setCurrentPassword('') + setPasswordTouched(false) + setError('') + setIsLoading(false) + } + }, [open, initialUsername, initialRole, isEdit]) + + // First interaction with the password fields (edit mode) clears the masked + // placeholder from BOTH inputs so the placeholder can never be submitted. + const beginPasswordEdit = () => { + if (isEdit && !passwordTouched) { + setPassword('') + setConfirmPassword('') + setPasswordTouched(true) + } + } + + const handleSubmit = async () => { + setError('') + + // A password counts as changed only when actually edited to a non-empty value. + const passwordChanged = isEdit ? passwordTouched && password.length > 0 : true + const usernameTrimmed = username.trim() + const usernameChanged = isEdit ? usernameTrimmed !== initialUsername : true + const roleChanged = showRole ? role !== initialRole : false + + if (!usernameTrimmed) { + setError('Username is required') + return + } + + if (passwordChanged) { + if (!password) { + setError('Password is required') + return + } + if (password !== confirmPassword) { + setError('Passwords do not match') + return + } + if (requireCurrentPassword && !currentPassword) { + setError('Your current password is required to change the password') + return + } + } + + if (isEdit && !usernameChanged && !passwordChanged && !roleChanged) { + setError('No changes to save') + return + } + + setIsLoading(true) + try { + const result = await onSubmit({ + username: usernameTrimmed, + role: showRole ? role : undefined, + password: passwordChanged ? password : undefined, + currentPassword: passwordChanged && requireCurrentPassword ? currentPassword : undefined, + usernameChanged, + passwordChanged, + roleChanged, + }) + if (result) { + setError(result) + } else { + onOpenChange(false) + } + } finally { + setIsLoading(false) + } + } + + return ( + + + {title} + + {description && ( +

{description}

+ )} + +
{ + e.preventDefault() + void handleSubmit() + }} + className='flex w-full flex-col gap-4' + > +
+ + setUsername(e.target.value)} + placeholder='Enter username' + className={inputClass} + disabled={isLoading} + /> +
+ + {showRole && ( +
+ + +
+ )} + + {isEdit && requireCurrentPassword && passwordTouched && ( +
+ + setCurrentPassword(e.target.value)} + placeholder='Enter your current password' + className={inputClass} + disabled={isLoading} + /> +
+ )} + +
+ + setPassword(e.target.value)} + placeholder='Enter password' + className={inputClass} + disabled={isLoading} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder='Confirm password' + className={inputClass} + disabled={isLoading} + /> +
+ + {error &&

{error}

} + +
+ + +
+
+
+
+ ) +} + +export { RuntimeUserModal } diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index f8d94ec97..8bad1b9f9 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -29,6 +29,7 @@ import { ResourcesEditor } from '../components/_features/[workspace]/editor/reso import { ModbusServerEditor } from '../components/_features/[workspace]/editor/server/modbus-server' import { OpcUaServerEditor } from '../components/_features/[workspace]/editor/server/opcua-server' import { S7CommServerEditor } from '../components/_features/[workspace]/editor/server/s7comm-server' +import { UserManagementEditor } from '../components/_features/[workspace]/editor/user-management' import { VendorScreenEditor } from '../components/_features/[workspace]/editor/vendor-screen' import { Search } from '../components/_features/[workspace]/search' import { SourceControlPanel } from '../components/_features/[workspace]/source-control' @@ -580,6 +581,7 @@ const WorkspaceScreen = () => { {editor['type'] === 'plc-vendor-screen' && } {editor['type'] === 'plc-package-manager' && } {editor['type'] === 'plc-library-manager' && } + {editor['type'] === 'plc-user-management' && } {editor['type'] === 'plc-library-manifest' && } {editor['type'] === 'diff-viewer' && } diff --git a/src/frontend/store/__tests__/tabs-utils.test.ts b/src/frontend/store/__tests__/tabs-utils.test.ts index 96806562e..085be585b 100644 --- a/src/frontend/store/__tests__/tabs-utils.test.ts +++ b/src/frontend/store/__tests__/tabs-utils.test.ts @@ -291,6 +291,13 @@ describe('tabs/utils', () => { expect(result.type).toBe('plc-server') }) + it('creates editor from user-management tab', () => { + const tab: TabsProps = { name: 'User Management', elementType: { type: 'user-management' } } + const result = CreateEditorObjectFromTab(tab) + expect(result.type).toBe('plc-user-management') + expect(result.meta.name).toBe('User Management') + }) + it('creates editor from diff-viewer tab', () => { const tab: TabsProps = { name: 'Diff: devices/configuration.json', diff --git a/src/frontend/store/slices/editor/types.ts b/src/frontend/store/slices/editor/types.ts index dc146aba0..42ad52b75 100644 --- a/src/frontend/store/slices/editor/types.ts +++ b/src/frontend/store/slices/editor/types.ts @@ -180,6 +180,14 @@ export type EditorModel = EditorModelBase & name: string } } + | { + /** Runtime User Management screen. A device-scoped singleton shown + * under the Device tree branch while connected to a runtime. */ + type: 'plc-user-management' + meta: { + name: string + } + } | { /** The Library Project's manifest tab — Monaco-wrapped * `library.json` at the project root. Only ever opened diff --git a/src/frontend/store/slices/tabs/types.ts b/src/frontend/store/slices/tabs/types.ts index 53608ad8c..5c3b3bced 100644 --- a/src/frontend/store/slices/tabs/types.ts +++ b/src/frontend/store/slices/tabs/types.ts @@ -18,6 +18,7 @@ export type TabsProps = { | { type: 'package-manager' } | { type: 'library-manager' } | { type: 'library-manifest' } + | { type: 'user-management' } | { type: 'ethercat-device'; busName: string; deviceId: string } | { type: 'diff-viewer'; filePath: string } configuration?: Record diff --git a/src/frontend/store/slices/tabs/utils.ts b/src/frontend/store/slices/tabs/utils.ts index 5e60d201d..bb04f5098 100644 --- a/src/frontend/store/slices/tabs/utils.ts +++ b/src/frontend/store/slices/tabs/utils.ts @@ -127,6 +127,11 @@ const CreateLibraryManagerEditor = (name = 'Library Manager'): EditorModel => ({ meta: { name }, }) +const CreateUserManagementEditor = (name = 'User Management'): EditorModel => ({ + type: 'plc-user-management', + meta: { name }, +}) + /** Canonical tab name + factory for the Library Project's manifest * editor. Display label (also the file-slice key the dirty * tracker + save flow look up under); intentionally NOT the on- @@ -177,6 +182,8 @@ const CreateEditorObjectFromTab = (tab: TabsProps): EditorModel => { return CreateLibraryManagerEditor(name) case 'library-manifest': return CreateLibraryManifestEditor(name) + case 'user-management': + return CreateUserManagementEditor(name) case 'diff-viewer': return CreateDiffViewerEditor(name, elementType.filePath) } @@ -196,6 +203,7 @@ export { CreateRemoteDeviceEditor, CreateResourceEditor, CreateServerEditor, + CreateUserManagementEditor, CreateVendorScreenEditor, LIBRARY_MANIFEST_TAB_NAME, } diff --git a/src/frontend/store/slices/workspace/types.ts b/src/frontend/store/slices/workspace/types.ts index a9e12ef62..17b0f4c64 100644 --- a/src/frontend/store/slices/workspace/types.ts +++ b/src/frontend/store/slices/workspace/types.ts @@ -39,6 +39,7 @@ export type WorkspaceProjectTreeLeafType = | 'package-manager' | 'library-manager' | 'library-manifest' + | 'user-management' | 'ethercat-device' | null diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 3f12df229..561cbb9d2 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -21,6 +21,7 @@ import type { ListPublicLibrariesArgs, ListPublicLibrariesResponse, } from '@root/middleware/shared/ports/public-catalog-types' +import type { RuntimeUser, RuntimeUserRole, UpdateUserParams } from '@root/middleware/shared/ports/runtime-port' 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' @@ -214,22 +215,80 @@ class MainProcessBridge implements MainIpcModule { ipAddress: string, username: string, password: string, + role?: RuntimeUserRole, ) => { try { + // `role` is only honoured by the runtime for authenticated (admin) creation; + // the unauthenticated first-user bootstrap always becomes an admin regardless. + const body: { username: string; password: string; role?: RuntimeUserRole } = { username, password } + if (role) body.role = role + const payload = JSON.stringify(body) + + // First-user bootstrap runs before any login (no token yet) and the + // runtime allows it unauthenticated. Once a session exists this is an + // admin adding an account, which the runtime requires to be authenticated + // — route it through the token authority so an expired token is refreshed. + if (this.tokens.hasToken()) { + const res = await this.makeRuntimeApiPostRequest(ipAddress, '/api/create-user', payload, () => undefined) + return res.success ? { success: true } : { success: false, error: res.error } + } + const res = await this.httpRequest({ method: 'POST', url: this.runtimeUrl(ipAddress, '/api/create-user'), - body: JSON.stringify({ username, password, role: 'user' }), + body: payload, }) - if (res.statusCode === 201) { - return { success: true } - } + if (res.statusCode === 201) return { success: true } return { success: false, error: res.data } } catch (error) { return { success: false, error: getErrorMessage(error) } } } + handleRuntimeListUsers = async (_event: IpcMainInvokeEvent, ipAddress: string) => { + const res = await this.makeRuntimeApiRequest( + ipAddress, + '/api/get-users-info', + (data) => JSON.parse(data) as RuntimeUser[], + ) + return res.success ? { success: true, users: res.data } : { success: false, error: res.error } + } + + handleRuntimeWhoAmI = async (_event: IpcMainInvokeEvent, ipAddress: string) => { + const res = await this.makeRuntimeApiRequest( + ipAddress, + '/api/whoami', + (data) => JSON.parse(data) as RuntimeUser, + ) + return res.success ? { success: true, user: res.data } : { success: false, error: res.error } + } + + handleRuntimeUpdateUser = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + userId: number, + params: UpdateUserParams, + ) => { + // The runtime expects snake_case `current_password`; only send provided fields. + const body: Record = {} + if (params.username !== undefined) body.username = params.username + if (params.password !== undefined) body.password = params.password + if (params.currentPassword !== undefined) body.current_password = params.currentPassword + if (params.role !== undefined) body.role = params.role + const res = await this.makeRuntimeApiMutation( + 'PUT', + ipAddress, + `/api/update-user/${userId}`, + JSON.stringify(body), + ) + return res.success ? { success: true } : { success: false, error: res.error } + } + + handleRuntimeDeleteUser = async (_event: IpcMainInvokeEvent, ipAddress: string, userId: number) => { + const res = await this.makeRuntimeApiMutation('DELETE', ipAddress, `/api/delete-user/${userId}`) + return res.success ? { success: true } : { success: false, error: res.error } + } + private async performAuthentication( ipAddress: string, username: string, @@ -402,6 +461,72 @@ class MainProcessBridge implements MainIpcModule { .then(stripStatus) } + /** + * Authenticated PUT/DELETE against the runtime API, going through the token + * authority. Unlike the GET/POST helpers this retries only on 401 (a genuine + * expired token): the user-management endpoints use 403 as a legitimate + * business response (e.g. "current password incorrect", "admin required"), + * so retrying on 403 would trigger a pointless re-authentication. Any 2xx is + * success; the raw body is returned so callers can surface error messages. + */ + private makeRuntimeApiMutation( + method: 'PUT' | 'DELETE', + ipAddress: string, + endpoint: string, + body?: string, + ): Promise<{ success: true; data: string } | { success: false; error: string }> { + type R = { success: true; data: string } | { success: false; error: string; statusCode?: number } + + const doRequest = (token: string): Promise => + new Promise((resolve) => { + const headers: Record = { Authorization: `Bearer ${token}` } + if (body !== undefined) { + headers['Content-Type'] = 'application/json' + headers['Content-Length'] = Buffer.byteLength(body) + } + const req = https.request( + { + hostname: ipAddress, + port: this.RUNTIME_API_PORT, + path: endpoint, + method, + headers, + ...getRuntimeHttpsOptions(), + }, + (res: IncomingMessage) => { + let data = '' + res.on('data', (chunk: Buffer) => { + data += chunk.toString() + }) + res.on('end', () => { + const statusCode = res.statusCode ?? 0 + if (statusCode >= 200 && statusCode < 300) { + resolve({ success: true, data }) + } else { + resolve({ success: false, error: data || `Unexpected status: ${statusCode}`, statusCode }) + } + }) + }, + ) + req.setTimeout(this.RUNTIME_CONNECTION_TIMEOUT_MS, () => { + req.destroy() + resolve({ success: false, error: 'Connection timeout' }) + }) + req.on('error', (error: Error) => { + resolve({ success: false, error: error.message }) + }) + if (body !== undefined) req.write(body) + req.end() + }) + + return this.tokens + .withAuth( + (token) => doRequest(token), + (r) => !r.success && r.statusCode === 401, + ) + .then((r) => (r.success ? { success: true, data: r.data } : { success: false, error: r.error })) + } + /** * Upload a compiled program (multipart) to the runtime, going through the * token authority so an expired token is transparently refreshed and the @@ -903,6 +1028,10 @@ class MainProcessBridge implements MainIpcModule { // ===================== RUNTIME API ===================== this.registerHandle('runtime:get-users-info', this.handleRuntimeGetUsersInfo) this.registerHandle('runtime:create-user', this.handleRuntimeCreateUser) + this.registerHandle('runtime:list-users', this.handleRuntimeListUsers) + this.registerHandle('runtime:whoami', this.handleRuntimeWhoAmI) + this.registerHandle('runtime:update-user', this.handleRuntimeUpdateUser) + this.registerHandle('runtime:delete-user', this.handleRuntimeDeleteUser) this.registerHandle('runtime:login', this.handleRuntimeLogin) this.registerHandle('runtime:get-status', this.handleRuntimeGetStatus) this.registerHandle('runtime:start-plc', this.handleRuntimeStartPlc) diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 67393124e..3fb915a2f 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -15,6 +15,12 @@ import type { ListPublicLibrariesArgs, ListPublicLibrariesResponse, } from '@root/middleware/shared/ports/public-catalog-types' +import type { + ListUsersResult, + RuntimeUserRole, + UpdateUserParams, + WhoAmIResult, +} from '@root/middleware/shared/ports/runtime-port' 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' @@ -455,8 +461,20 @@ const rendererProcessBridge = { ipAddress: string, username: string, password: string, + role?: RuntimeUserRole, + ): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('runtime:create-user', ipAddress, username, password, role), + runtimeListUsers: (ipAddress: string): Promise => + ipcRenderer.invoke('runtime:list-users', ipAddress), + runtimeWhoAmI: (ipAddress: string): Promise => ipcRenderer.invoke('runtime:whoami', ipAddress), + runtimeUpdateUser: ( + ipAddress: string, + userId: number, + params: UpdateUserParams, ): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('runtime:create-user', ipAddress, username, password), + ipcRenderer.invoke('runtime:update-user', ipAddress, userId, params), + runtimeDeleteUser: (ipAddress: string, userId: number): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('runtime:delete-user', ipAddress, userId), runtimeLogin: ( ipAddress: string, username: string, diff --git a/src/middleware/adapters/editor/__tests__/runtime-adapter.test.ts b/src/middleware/adapters/editor/__tests__/runtime-adapter.test.ts index 6090ac0eb..d8c47cb74 100644 --- a/src/middleware/adapters/editor/__tests__/runtime-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/runtime-adapter.test.ts @@ -11,6 +11,13 @@ beforeEach(() => { runtimeLogin: jest.fn().mockResolvedValue({ success: true, accessToken: 'jwt-token-123' }), runtimeCreateUser: jest.fn().mockResolvedValue({ success: true }), runtimeGetUsersInfo: jest.fn().mockResolvedValue({ hasUsers: true, runtimeVersion: '4.0.0' }), + runtimeListUsers: jest.fn().mockResolvedValue({ + success: true, + users: [{ id: 1, username: 'admin', role: 'admin' }], + }), + runtimeWhoAmI: jest.fn().mockResolvedValue({ success: true, user: { id: 1, username: 'admin', role: 'admin' } }), + runtimeUpdateUser: jest.fn().mockResolvedValue({ success: true }), + runtimeDeleteUser: jest.fn().mockResolvedValue({ success: true }), runtimeGetStatus: jest.fn().mockResolvedValue({ success: true, status: 'RUNNING' }), runtimeStartPlc: jest.fn().mockResolvedValue({ success: true }), runtimeStopPlc: jest.fn().mockResolvedValue({ success: true }), @@ -83,13 +90,19 @@ describe('login', () => { // --------------------------------------------------------------------------- describe('createUser', () => { - it('delegates to bridge with IP and credentials', async () => { + it('delegates to bridge with IP and credentials (no role forwards undefined)', async () => { const result = await adapter.createUser({ username: 'newuser', password: 'pass123' }) - expect(window.bridge.runtimeCreateUser).toHaveBeenCalledWith('192.168.1.100', 'newuser', 'pass123') + expect(window.bridge.runtimeCreateUser).toHaveBeenCalledWith('192.168.1.100', 'newuser', 'pass123', undefined) expect(result).toEqual({ success: true }) }) + it('forwards the role when provided', async () => { + await adapter.createUser({ username: 'newuser', password: 'pass123', role: 'admin' }) + + expect(window.bridge.runtimeCreateUser).toHaveBeenCalledWith('192.168.1.100', 'newuser', 'pass123', 'admin') + }) + it('returns error when no IP configured', async () => { mockIpAddress = '' const result = await adapter.createUser({ username: 'newuser', password: 'pass123' }) @@ -105,6 +118,91 @@ describe('createUser', () => { }) }) +// --------------------------------------------------------------------------- +// listUsers / whoAmI / updateUser / deleteUser +// --------------------------------------------------------------------------- + +describe('listUsers', () => { + it('delegates to bridge with IP', async () => { + const result = await adapter.listUsers() + expect(window.bridge.runtimeListUsers).toHaveBeenCalledWith('192.168.1.100') + expect(result).toEqual({ success: true, users: [{ id: 1, username: 'admin', role: 'admin' }] }) + }) + + it('returns error when no IP configured', async () => { + mockIpAddress = '' + const result = await adapter.listUsers() + expect(result).toEqual({ success: false, error: 'No runtime IP address configured' }) + }) + + it('catches bridge errors', async () => { + ;(window.bridge.runtimeListUsers as jest.Mock).mockRejectedValue(new Error('list failed')) + const result = await adapter.listUsers() + expect(result).toEqual({ success: false, error: 'list failed' }) + }) +}) + +describe('whoAmI', () => { + it('delegates to bridge with IP', async () => { + const result = await adapter.whoAmI() + expect(window.bridge.runtimeWhoAmI).toHaveBeenCalledWith('192.168.1.100') + expect(result).toEqual({ success: true, user: { id: 1, username: 'admin', role: 'admin' } }) + }) + + it('returns error when no IP configured', async () => { + mockIpAddress = '' + const result = await adapter.whoAmI() + expect(result).toEqual({ success: false, error: 'No runtime IP address configured' }) + }) + + it('catches bridge errors', async () => { + ;(window.bridge.runtimeWhoAmI as jest.Mock).mockRejectedValue(new Error('whoami failed')) + const result = await adapter.whoAmI() + expect(result).toEqual({ success: false, error: 'whoami failed' }) + }) +}) + +describe('updateUser', () => { + it('delegates to bridge with IP, id and params', async () => { + const params = { username: 'bobby', password: 'np', currentPassword: 'op', role: 'user' as const } + const result = await adapter.updateUser(7, params) + expect(window.bridge.runtimeUpdateUser).toHaveBeenCalledWith('192.168.1.100', 7, params) + expect(result).toEqual({ success: true }) + }) + + it('returns error when no IP configured', async () => { + mockIpAddress = '' + const result = await adapter.updateUser(7, { username: 'x' }) + expect(result).toEqual({ success: false, error: 'No runtime IP address configured' }) + }) + + it('catches bridge errors', async () => { + ;(window.bridge.runtimeUpdateUser as jest.Mock).mockRejectedValue(new Error('update failed')) + const result = await adapter.updateUser(7, { username: 'x' }) + expect(result).toEqual({ success: false, error: 'update failed' }) + }) +}) + +describe('deleteUser', () => { + it('delegates to bridge with IP and id', async () => { + const result = await adapter.deleteUser(9) + expect(window.bridge.runtimeDeleteUser).toHaveBeenCalledWith('192.168.1.100', 9) + expect(result).toEqual({ success: true }) + }) + + it('returns error when no IP configured', async () => { + mockIpAddress = '' + const result = await adapter.deleteUser(9) + expect(result).toEqual({ success: false, error: 'No runtime IP address configured' }) + }) + + it('catches bridge errors', async () => { + ;(window.bridge.runtimeDeleteUser as jest.Mock).mockRejectedValue(new Error('delete failed')) + const result = await adapter.deleteUser(9) + expect(result).toEqual({ success: false, error: 'delete failed' }) + }) +}) + // --------------------------------------------------------------------------- // getUsersInfo // --------------------------------------------------------------------------- diff --git a/src/middleware/adapters/editor/runtime-adapter.ts b/src/middleware/adapters/editor/runtime-adapter.ts index c2e953f84..711c211c9 100644 --- a/src/middleware/adapters/editor/runtime-adapter.ts +++ b/src/middleware/adapters/editor/runtime-adapter.ts @@ -21,12 +21,15 @@ import type { DiscoverDevicesOptions, DiscoverDevicesResult, DiscoveredRuntimeDevice, + ListUsersResult, LoginParams, LoginResult, RuntimeLogsResult, RuntimePort, RuntimeStatusResult, + UpdateUserParams, UsersInfoResult, + WhoAmIResult, } from '../../shared/ports/runtime-port' import type { SerialPort, Unsubscribe } from '../../shared/ports/types' @@ -62,7 +65,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async createUser(params) { try { const ip = requireIp() - return await window.bridge.runtimeCreateUser(ip, params.username, params.password) + return await window.bridge.runtimeCreateUser(ip, params.username, params.password, params.role) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -77,6 +80,42 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP } }, + async listUsers(): Promise { + try { + const ip = requireIp() + return await window.bridge.runtimeListUsers(ip) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + + async whoAmI(): Promise { + try { + const ip = requireIp() + return await window.bridge.runtimeWhoAmI(ip) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + + async updateUser(userId: number, params: UpdateUserParams) { + try { + const ip = requireIp() + return await window.bridge.runtimeUpdateUser(ip, userId, params) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + + async deleteUser(userId: number) { + try { + const ip = requireIp() + return await window.bridge.runtimeDeleteUser(ip, userId) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + async getStatus(includeStats?: boolean): Promise { try { const ip = requireIp() diff --git a/src/middleware/shared/ports/runtime-port.ts b/src/middleware/shared/ports/runtime-port.ts index e48ebefa8..12d3e51b1 100644 --- a/src/middleware/shared/ports/runtime-port.ts +++ b/src/middleware/shared/ports/runtime-port.ts @@ -61,9 +61,18 @@ export interface LoginResult { error?: string } +/** + * RBAC role for a runtime account. `admin` may manage every account; + * `user` may edit only its own account and cannot create/delete users. + */ +export type RuntimeUserRole = 'admin' | 'user' + export interface CreateUserParams { username: string password: string + /** Role for the new account. Ignored for the unauthenticated first-user + * bootstrap (the runtime always makes the first user an admin). */ + role?: RuntimeUserRole } export interface UsersInfoResult { @@ -72,6 +81,37 @@ export interface UsersInfoResult { error?: string } +/** A user account as reported by the runtime. */ +export interface RuntimeUser { + id: number + username: string + role: RuntimeUserRole +} + +export interface ListUsersResult { + success: boolean + users?: RuntimeUser[] + error?: string +} + +export interface WhoAmIResult { + success: boolean + user?: RuntimeUser + error?: string +} + +/** + * Fields to change on an existing account. Only the provided fields are + * applied. `currentPassword` is required by the runtime when changing your + * OWN password (not when an admin resets another user's password). + */ +export interface UpdateUserParams { + username?: string + password?: string + currentPassword?: string + role?: RuntimeUserRole +} + export interface RuntimeStatusResult { success: boolean status?: PlcStatus | (string & {}) @@ -133,6 +173,18 @@ export interface RuntimePort { /** Check if the runtime has users and get its version. */ getUsersInfo(): Promise + /** List all user accounts on the runtime (requires authentication). */ + listUsers(): Promise + + /** Return the currently authenticated account (id, username, role). */ + whoAmI(): Promise + + /** Update an account's username, password and/or role. */ + updateUser(userId: number, params: UpdateUserParams): Promise<{ success: boolean; error?: string }> + + /** Delete an account by id (admin only; cannot delete your own account). */ + deleteUser(userId: number): Promise<{ success: boolean; error?: string }> + /** Get current PLC runtime status with optional timing statistics. */ getStatus(includeStats?: boolean): Promise From a2717952edd534b0bc62ae6afc78feba1e3cafc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Thu, 23 Jul 2026 12:56:00 -0300 Subject: [PATCH 44/52] fix(lsp): resolve connection and message types from one vscode-jsonrpc copy (DOPE-505) The LSP transport built its connection from 'vscode-jsonrpc/browser' (direct dep, 8.2.1) while message types (InitializeRequest, DidOpen...) came from 'vscode-languageserver-protocol' with its own nested 8.2.0. ParameterStructures is compared by identity, so under a real pnpm node_modules layout the initialize handshake threw "Unknown parameter structure byName" and crashed project load. Import the transport through 'vscode-languageserver-protocol/browser' so the connection and the message types share one vscode-jsonrpc copy in every layout (npm, pnpm, bundled). Also clears the 14 dual-MessageConnection tsc errors. Mirror of the openplc-web commit (shared src/frontend). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Zbz8hMi93XvA1RenC9bXN --- src/frontend/services/lsp-shared/transport.ts | 6 +++++- src/frontend/services/python-lsp/index.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/frontend/services/lsp-shared/transport.ts b/src/frontend/services/lsp-shared/transport.ts index bb1754a8d..c30cbb170 100644 --- a/src/frontend/services/lsp-shared/transport.ts +++ b/src/frontend/services/lsp-shared/transport.ts @@ -26,7 +26,11 @@ import { BrowserMessageWriter, createMessageConnection, type MessageConnection, -} from 'vscode-jsonrpc/browser' + // Import via the protocol package so the connection and the message types + // (InitializeRequest, DidOpen…, ParameterStructures) share ONE vscode-jsonrpc + // copy — a second copy fails ParameterStructures identity checks at runtime + // ("Unknown parameter structure byName"). See DOPE-505. +} from 'vscode-languageserver-protocol/browser' export interface LspTransport { /** JSON-RPC connection. Caller is responsible for `listen()`. */ diff --git a/src/frontend/services/python-lsp/index.ts b/src/frontend/services/python-lsp/index.ts index a472b88b7..858c04004 100644 --- a/src/frontend/services/python-lsp/index.ts +++ b/src/frontend/services/python-lsp/index.ts @@ -62,7 +62,7 @@ export function startPythonLsp(opts: PythonLspStartOptions): PythonLspService { // workspace members get `publishDiagnostics` — without these // notifications the document buffer is populated but never // enters the analysis queue. - let pyrightConnection: import('vscode-jsonrpc/browser').MessageConnection | null = null + let pyrightConnection: import('vscode-languageserver-protocol/browser').MessageConnection | null = null // Per-URI registry, keyed by Monaco model URI. Holds the // preamble Pyright sees concatenated with the user body, the LSP From d891c29a0600b9742d2164dd4d5db7281c820635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Thu, 23 Jul 2026 14:49:02 -0300 Subject: [PATCH 45/52] chore(deps): upgrade @xyflow/react 12.0.1 -> 12.11.2 (DOPE-492) Same-major bump (11 minor releases) bringing measured perf wins on ladder/FBD hot paths: skip-measurement rendering for predefined dimensions (12.8.5), faster node/edge insertion (12.9), FlowRenderer re-render eliminations (12.10), XYDrag only for draggable nodes + imperative viewport (12.11.2). Prerequisite for the ladder single-instance-per-POU refactor. @xyflow/system 0.0.35 -> 0.0.79 rides along. Two type-level breaks fixed in shared frontend: - HandleProps.id widened to string|null in system 0.0.79 - pinned id?: string in CustomHandleProps (ladder + fbd handle.tsx) and typed buildHandle returns; our handles always carry explicit ids. - OnNodeDrag event is now native MouseEvent|TouchEvent - drag handlers retyped in ladder rung body.tsx and fbd index.tsx; handleNodeDrag guards non-mouse events. One behavior fix: removed nodeExtent from the ladder ReactFlowPanel. Node positions belong to the ladder layout engine; xyflow >=12.9 no longer recomputes drag-clamped positionAbsolute when the extent later grows, so a mid-drag clamp against the stale extent froze nodes at wrong positions (rendered corruption + "Maximum update depth" crash on drag-out-of-parallel). 12.0.1 masked this by unconditionally re-clamping all nodes on every extent change. translateExtent (viewport lock) stays. Mirror of the openplc-web commit (shared src/frontend byte-identical). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Zbz8hMi93XvA1RenC9bXN --- package-lock.json | 46 ++++++++++++------- package.json | 2 +- .../_atoms/graphical-editor/fbd/handle.tsx | 6 ++- .../_atoms/graphical-editor/ladder/handle.tsx | 6 ++- .../_molecules/graphical-editor/fbd/index.tsx | 2 +- .../graphical-editor/ladder/rung/body.tsx | 14 +++--- 6 files changed, 48 insertions(+), 28 deletions(-) diff --git a/package-lock.json b/package-lock.json index bd4b6038f..c072c4bb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-plc-editor", - "version": "4.2.6", + "version": "4.2.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-plc-editor", - "version": "4.2.6", + "version": "4.2.8", "hasInstallScript": true, "license": "GPL-3.0", "dependencies": { @@ -33,7 +33,7 @@ "@tailwindcss/forms": "^0.5.10", "@tanstack/react-query": "^5.90.21", "@tanstack/react-table": "^8.21.2", - "@xyflow/react": "^12.0.1", + "@xyflow/react": "^12.11.2", "auto-zustand-selectors-hook": "^2.0.0", "avr8js": "0.20.0", "axios": "^1.13.6", @@ -9816,15 +9816,15 @@ } }, "node_modules/@types/d3-selection": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.10.tgz", - "integrity": "sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==", + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", "license": "MIT" }, "node_modules/@types/d3-transition": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.8.tgz", - "integrity": "sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ==", + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -11049,17 +11049,28 @@ "license": "Apache-2.0" }, "node_modules/@xyflow/react": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.0.1.tgz", - "integrity": "sha512-iGh/nO7key0sVH0c8TW2qvLNU0akJ20Mi3LPUF2pymhRqerrBk0EJhPLXRThbYWy4pNWUnkhpBLB0/gr884qnw==", + "version": "12.11.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", + "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.35", + "@xyflow/system": "0.0.79", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", "react": ">=17", "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, "node_modules/@xyflow/react/node_modules/zustand": { @@ -11091,15 +11102,18 @@ } }, "node_modules/@xyflow/system": { - "version": "0.0.35", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.35.tgz", - "integrity": "sha512-QaUkahvmMs2gY2ykxUfjs5CbkXzU5fQNtmoQQ6HmHoAr8n2D7UyLO/UEXlke2jxuCDuiwpXhrzn4DmffVJd2qA==", + "version": "0.0.79", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", + "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", + "license": "MIT", "dependencies": { "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } diff --git a/package.json b/package.json index 8582d6414..86a9cee4e 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@tailwindcss/forms": "^0.5.10", "@tanstack/react-query": "^5.90.21", "@tanstack/react-table": "^8.21.2", - "@xyflow/react": "^12.0.1", + "@xyflow/react": "^12.11.2", "auto-zustand-selectors-hook": "^2.0.0", "avr8js": "0.20.0", "axios": "^1.13.6", diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/handle.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/handle.tsx index 090edb5a9..d5830878f 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/handle.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/handle.tsx @@ -3,6 +3,8 @@ import { Handle, HandleProps } from '@xyflow/react' import { cn } from '../../../../utils/cn' export type CustomHandleProps = HandleProps & { + /** FBD handles always carry an explicit id — strip the `null` xyflow ≥12.11 allows */ + id?: string glbPosition: { x: number y: number @@ -39,7 +41,7 @@ export const CustomHandle = ({ ) } -type BuildHandleProps = HandleProps & { +type BuildHandleProps = Omit & { glbX: number glbY: number relX: number @@ -53,7 +55,7 @@ type BuildHandleProps = HandleProps & { * @param relY: number - The y coordinate of the handle based on the relative position (inside the node) * @returns CustomHandleProps */ -export const buildHandle = ({ glbX, glbY, relX, relY, ...rest }: BuildHandleProps) => { +export const buildHandle = ({ glbX, glbY, relX, relY, ...rest }: BuildHandleProps): CustomHandleProps => { return { glbPosition: { x: glbX, diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/handle.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/handle.tsx index e007d643f..13fc00359 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/handle.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/handle.tsx @@ -3,6 +3,8 @@ import { Handle, HandleProps } from '@xyflow/react' import { cn } from '../../../../utils/cn' export type CustomHandleProps = HandleProps & { + /** ladder handles always carry an explicit id — strip the `null` xyflow ≥12.11 allows */ + id?: string glbPosition: { x: number y: number @@ -37,7 +39,7 @@ export const CustomHandle = ({ ) } -type BuildHandleProps = HandleProps & { +type BuildHandleProps = Omit & { glbX: number glbY: number relX: number @@ -51,7 +53,7 @@ type BuildHandleProps = HandleProps & { * @param relY: number - The y coordinate of the handle based on the relative position (inside the node) * @returns CustomHandleProps */ -export const buildHandle = ({ glbX, glbY, relX, relY, ...rest }: BuildHandleProps) => { +export const buildHandle = ({ glbX, glbY, relX, relY, ...rest }: BuildHandleProps): CustomHandleProps => { return { glbPosition: { x: glbX, diff --git a/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx b/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx index 4739e3bd8..7eb24d3e7 100644 --- a/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx +++ b/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx @@ -634,7 +634,7 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } /** * When the node drag stops, update the fbd rung state */ - const onNodeDragStop = useStableCallback((_e: MouseEvent, _node: FlowNode, nodes: FlowNode[]) => { + const onNodeDragStop = useStableCallback((_e: globalThis.MouseEvent | globalThis.TouchEvent, _node: FlowNode, nodes: FlowNode[]) => { setDragging(false) fbdFlowActions.setRung({ editorName: pouName, diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx index 97236fbbc..e801d179a 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx @@ -680,8 +680,8 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi /** * Handle the drag of a node */ - const handleNodeDrag = (event: MouseEvent) => { - if (!reactFlowInstance) return + const handleNodeDrag = (event: globalThis.MouseEvent | globalThis.TouchEvent) => { + if (!reactFlowInstance || !('clientX' in event)) return const closestPlaceholder = onElementDragOver(rungLocal, reactFlowInstance, { x: event.clientX, y: event.clientY }) if (!closestPlaceholder) return @@ -753,13 +753,13 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi const onNodesDelete = useStableCallback((nodes: FlowNode[]) => { handleRemoveNode(nodes) }) - const onNodeDragStart = useStableCallback((_event: MouseEvent, node: FlowNode) => { + const onNodeDragStart = useStableCallback((_event: globalThis.MouseEvent | globalThis.TouchEvent, node: FlowNode) => { handleNodeStartDrag(node) }) - const onNodeDrag = useStableCallback((event: MouseEvent) => { + const onNodeDrag = useStableCallback((event: globalThis.MouseEvent | globalThis.TouchEvent) => { handleNodeDrag(event) }) - const onNodeDragStop = useStableCallback((_event: MouseEvent, node: FlowNode) => { + const onNodeDragStop = useStableCallback((_event: globalThis.MouseEvent | globalThis.TouchEvent, node: FlowNode) => { handleNodeDragStop(node) }) const onNodeDoubleClick = useStableCallback((_event: MouseEvent, node: FlowNode) => { @@ -978,7 +978,9 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi onDragOver: onDragOver, onDrop: onDrop, - nodeExtent: reactFlowPanelExtent, + // No nodeExtent here: node positions belong to the ladder layout engine. xyflow ≥12.9 + // stopped recomputing drag-clamped positionAbsolute when the extent later grows, so a + // mid-drag clamp against the stale extent would freeze nodes at wrong positions (DOPE-492). translateExtent: reactFlowPanelExtent, panActivationKeyCode: null, panOnDrag: false, From 7e193ad22b8396b3156896e91a7a73c8cfb449f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Thu, 23 Jul 2026 14:56:08 -0300 Subject: [PATCH 46/52] style(fbd): wrap onNodeDragStop callback per prettier Mirror of the openplc-web commit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Zbz8hMi93XvA1RenC9bXN --- .../_molecules/graphical-editor/fbd/index.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx b/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx index 7eb24d3e7..7d53d1b7f 100644 --- a/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx +++ b/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx @@ -634,17 +634,19 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } /** * When the node drag stops, update the fbd rung state */ - const onNodeDragStop = useStableCallback((_e: globalThis.MouseEvent | globalThis.TouchEvent, _node: FlowNode, nodes: FlowNode[]) => { - setDragging(false) - fbdFlowActions.setRung({ - editorName: pouName, - rung: { - ...rungLocal, - nodes: rungLocal.nodes.map((node) => nodes.find((n) => n.id === node.id) ?? node), - edges: rungLocal.edges, - }, - }) - }) + const onNodeDragStop = useStableCallback( + (_e: globalThis.MouseEvent | globalThis.TouchEvent, _node: FlowNode, nodes: FlowNode[]) => { + setDragging(false) + fbdFlowActions.setRung({ + editorName: pouName, + rung: { + ...rungLocal, + nodes: rungLocal.nodes.map((node) => nodes.find((n) => n.id === node.id) ?? node), + edges: rungLocal.edges, + }, + }) + }, + ) /** * Handle the drag enter of the viewport From 68b3021e56a80f3f76892bc14acc69c04dbf6863 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 23 Jul 2026 14:37:08 -0400 Subject: [PATCH 47/52] fix(user-management): connect after first user, version gate, UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on the User Management feature: 1. First-user setup now stays connected — the bootstrap dialog no longer tears down the just-established session when it closes on success. 4. Authenticated create-user accepts the runtime's 201 (was treated as an error, so the dialog showed the raw response and stayed open). 5. The self-edit "Current password" field now renders last (after Confirm password) and only once the password is actually being changed, so it doesn't shove the field the user just clicked. 7. Gate the User Management tree leaf on runtime version ≥ v4.1.9 (isUserManagementCapableRuntime); the connected runtime version is now stored in runtimeConnection and set on connect. Also: edit-icon tooltip now reads "Edit user" (icon no longer swallows the button title), "New User" label centered, and changing your own password signs you out to force a fresh login with the new credentials. Tests: version-gate helper, device-slice runtimeVersion, existing modal and adapter suites updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/runtime-version-gate.test.ts | 32 +++++++++++++++ .../shared/firmware/runtime-version-gate.ts | 20 +++++++++ .../editor/device/configuration/board.tsx | 5 +++ .../editor/user-management/index.tsx | 28 +++++++++++-- .../_organisms/explorer/project.tsx | 8 +++- .../modals/runtime-create-user-modal.tsx | 10 +++-- .../_organisms/modals/runtime-user-modal.tsx | 41 ++++++++++--------- .../store/__tests__/device-slice.test.ts | 20 +++++++++ .../store/__tests__/device-types.test.ts | 3 ++ src/frontend/store/slices/device/slice.ts | 10 +++++ src/frontend/store/slices/device/types.ts | 5 +++ src/frontend/utils/device.ts | 6 ++- src/main/modules/ipc/main.ts | 7 ++-- 13 files changed, 163 insertions(+), 32 deletions(-) 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 1686f416a..3f8733187 100644 --- a/src/backend/shared/firmware/__tests__/runtime-version-gate.test.ts +++ b/src/backend/shared/firmware/__tests__/runtime-version-gate.test.ts @@ -1,10 +1,42 @@ import { describeIncompatibleRuntime, isStrucppCompatibleRuntime, + isUserManagementCapableRuntime, MIN_STRUCPP_RUNTIME_VERSION, + MIN_USER_MANAGEMENT_RUNTIME_VERSION, parseRuntimeVersion, } from '../runtime-version-gate' +describe('isUserManagementCapableRuntime', () => { + it('is exposed with the documented minimum version', () => { + expect(MIN_USER_MANAGEMENT_RUNTIME_VERSION).toBe('4.1.9') + }) + + it('accepts v4.1.9 and newer', () => { + expect(isUserManagementCapableRuntime('v4.1.9')).toBe(true) + expect(isUserManagementCapableRuntime('4.1.10')).toBe(true) + expect(isUserManagementCapableRuntime('v4.2.0')).toBe(true) + expect(isUserManagementCapableRuntime('v5.0.0')).toBe(true) + }) + + it('accepts a pre-release on the target patch', () => { + expect(isUserManagementCapableRuntime('v4.1.9-rc.1')).toBe(true) + }) + + it('rejects versions older than 4.1.9', () => { + expect(isUserManagementCapableRuntime('v4.1.8')).toBe(false) + expect(isUserManagementCapableRuntime('v4.0.9')).toBe(false) + expect(isUserManagementCapableRuntime('v3.9.9')).toBe(false) + }) + + it('rejects unparseable / legacy version strings', () => { + expect(isUserManagementCapableRuntime('v4')).toBe(false) + expect(isUserManagementCapableRuntime('dev')).toBe(false) + expect(isUserManagementCapableRuntime(null)).toBe(false) + expect(isUserManagementCapableRuntime(undefined)).toBe(false) + }) +}) + describe('parseRuntimeVersion', () => { it('parses tagged release versions (with and without leading v)', () => { expect(parseRuntimeVersion('v4.1.0')).toEqual({ major: 4, minor: 1, patch: 0 }) diff --git a/src/backend/shared/firmware/runtime-version-gate.ts b/src/backend/shared/firmware/runtime-version-gate.ts index ca96ca6ca..179010a1a 100644 --- a/src/backend/shared/firmware/runtime-version-gate.ts +++ b/src/backend/shared/firmware/runtime-version-gate.ts @@ -75,6 +75,26 @@ export function isStrucppCompatibleRuntime(raw: string | null | undefined): bool return v.minor >= 1 } +/** Minimum runtime version that ships the user-management API + * (roles, whoami, unified update-user, delete/last-admin guards). */ +export const MIN_USER_MANAGEMENT_RUNTIME_VERSION = '4.1.9' + +/** + * Returns true iff the runtime version string represents a runtime + * that ships the user-management API (≥ 4.1.9). Older runtimes lack + * `whoami` / `update-user` and the RBAC guards, so the editor hides + * the User Management screen for them. Pre-release tags on the target + * patch (e.g. `v4.1.9-rc.1`) count as capable, matching the strucpp + * 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 +} + /** * Human-readable explanation suitable for surfacing as an error * when the gate rejects a runtime. The reported version (or 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 193e09453..ca74ca98e 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -60,6 +60,7 @@ const Board = memo(function () { const setRuntimeIpAddress = useOpenPLCStore((state) => state.deviceActions.setRuntimeIpAddress) const setRuntimeConnectionStatus = useOpenPLCStore((state) => state.deviceActions.setRuntimeConnectionStatus) const setRuntimeJwtToken = useOpenPLCStore((state) => state.deviceActions.setRuntimeJwtToken) + const setRuntimeVersion = useOpenPLCStore((state) => state.deviceActions.setRuntimeVersion) const openModal = useOpenPLCStore((state) => state.modalActions.openModal) const plcStatus = useOpenPLCStore((state): RuntimeConnection['plcStatus'] => state.runtimeConnection.plcStatus) const timingStats = useOpenPLCStore((state): TimingStats | null => state.runtimeConnection.timingStats) @@ -365,6 +366,10 @@ const Board = memo(function () { return } + // Remember the runtime version so version-gated UI (e.g. User + // Management) can react to it for the lifetime of the connection. + setRuntimeVersion(result.runtimeVersion ?? null) + // Validate runtime version matches the selected board target const versionValidation = validateRuntimeVersion(deviceBoard, result.runtimeVersion) diff --git a/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx b/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx index 111cc3c1b..7c23d5af6 100644 --- a/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx @@ -15,6 +15,8 @@ type EditTarget = { user: RuntimeUser; isSelf: boolean } const UserManagementEditor = () => { const runtime = useRuntime() const connectionStatus = useOpenPLCStore((s) => s.runtimeConnection.connectionStatus) + const setRuntimeConnectionStatus = useOpenPLCStore((s) => s.deviceActions.setRuntimeConnectionStatus) + const setRuntimeJwtToken = useOpenPLCStore((s) => s.deviceActions.setRuntimeJwtToken) const [users, setUsers] = useState([]) const [currentUser, setCurrentUser] = useState(null) @@ -69,8 +71,24 @@ const UserManagementEditor = () => { if (values.currentPassword) params.currentPassword = values.currentPassword } if (values.roleChanged) params.role = values.role + const changingOwnPassword = editTarget.isSelf && values.passwordChanged const result = await runtime.updateUser(editTarget.user.id, params) if (!result.success) return result.error || 'Failed to update user' + + if (changingOwnPassword) { + // The runtime invalidates your token when you change your own password, + // so drop the local session and force a fresh login with the new one. + await runtime.clearCredentials() + setRuntimeJwtToken(null) + setRuntimeConnectionStatus('disconnected') + toast({ + title: 'Password changed', + description: 'You have been signed out. Reconnect with your new password.', + variant: 'default', + }) + return null + } + toast({ title: 'User updated', description: `"${values.username}" was updated.`, variant: 'default' }) void refresh() return null @@ -115,10 +133,10 @@ const UserManagementEditor = () => { )}
@@ -162,7 +180,9 @@ const UserManagementEditor = () => { onClick={() => setEditTarget({ user, isSelf })} className='flex h-7 w-7 items-center justify-center rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-850' > - + {/* pointer-events-none so the button's `title` tooltip wins + over the icon SVG's own ("Pencil Icon"). */} + <PencilIcon className='pointer-events-none h-4 w-4' /> </button> )} {canDeleteRow(user) && ( @@ -172,7 +192,7 @@ const UserManagementEditor = () => { onClick={() => setDeleteTarget(user)} className='flex h-7 w-7 items-center justify-center rounded-md text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950' > - <TrashCanIcon className='h-4 w-4 stroke-current' /> + <TrashCanIcon className='pointer-events-none h-4 w-4 stroke-current' /> </button> )} </div> diff --git a/src/frontend/components/_organisms/explorer/project.tsx b/src/frontend/components/_organisms/explorer/project.tsx index 484ae5736..82aa9982e 100644 --- a/src/frontend/components/_organisms/explorer/project.tsx +++ b/src/frontend/components/_organisms/explorer/project.tsx @@ -7,6 +7,7 @@ import { useOpenPLCStore } from '../../../store' import { extractSearchQuery } from '../../../store/slices/search/utils' import type { TabsProps } from '../../../store/slices/tabs' import { CreateEditorObjectFromTab, LIBRARY_MANIFEST_TAB_NAME } from '../../../store/slices/tabs/utils' +import { isUserManagementCapableRuntime } from '../../../utils/device' import { useToast } from '../../_features/[app]/toast/use-toast' import { CreatePLCElement } from '../../_features/[workspace]/create-element' import { @@ -57,8 +58,11 @@ const Project = () => { const canEdit = useOpenPLCStore((s) => s.workspace.canEdit) // Runtime User Management is only meaningful while connected to a runtime - // (it reads/writes the runtime's account list over the authenticated API). + // (it reads/writes the runtime's account list over the authenticated API) + // AND only exists on runtimes ≥ v4.1.9 — older runtimes lack the endpoints. const runtimeConnected = useOpenPLCStore((s) => s.runtimeConnection.connectionStatus === 'connected') + const runtimeVersion = useOpenPLCStore((s) => s.runtimeConnection.runtimeVersion) + const showUserManagement = runtimeConnected && isUserManagementCapableRuntime(runtimeVersion) // Per-project-type capability matrix — drives which branches // render. Library projects only show Functions / Function Blocks / @@ -378,7 +382,7 @@ const Project = () => { } /> )} - {runtimeConnected && ( + {showUserManagement && ( <ProjectTreeLeaf key='User Management' leafLang='userManagement' diff --git a/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx b/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx index d1eae9087..afa138fa1 100644 --- a/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx +++ b/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx @@ -10,7 +10,7 @@ import { RuntimeUserModal, type RuntimeUserModalSubmit } from './runtime-user-mo * always makes this first account an admin). */ const RuntimeCreateUserModal = () => { - const { modals, modalActions, deviceActions } = useOpenPLCStore() + const { modals, modalActions, deviceActions, runtimeConnection } = useOpenPLCStore() const runtime = useRuntime() const isOpen = modals['runtime-create-user']?.open || false @@ -37,8 +37,12 @@ const RuntimeCreateUserModal = () => { const handleOpenChange = (open: boolean) => { if (!open) { - // Cancelling the first-user setup abandons the connection attempt. - if (isOpen) deviceActions.setRuntimeConnectionStatus('disconnected') + // On success `handleSubmit` has already logged in and set the status to + // 'connected'; closing then must NOT tear that down. Only a genuine + // cancel (still connecting/disconnected) abandons the connection attempt. + if (isOpen && runtimeConnection.connectionStatus !== 'connected') { + deviceActions.setRuntimeConnectionStatus('disconnected') + } modalActions.closeModal() } modalActions.onOpenChange('runtime-create-user', open) diff --git a/src/frontend/components/_organisms/modals/runtime-user-modal.tsx b/src/frontend/components/_organisms/modals/runtime-user-modal.tsx index 1bef6f7b5..098ac7571 100644 --- a/src/frontend/components/_organisms/modals/runtime-user-modal.tsx +++ b/src/frontend/components/_organisms/modals/runtime-user-modal.tsx @@ -209,25 +209,6 @@ const RuntimeUserModal = (props: RuntimeUserModalProps) => { </div> )} - {isEdit && requireCurrentPassword && passwordTouched && ( - <div> - <Label htmlFor='runtime-user-current-pass' className='mb-2 block text-sm'> - Current password - </Label> - <input - id='runtime-user-current-pass' - name='runtime-user-current-pass' - type='password' - autoComplete='off' - value={currentPassword} - onChange={(e) => setCurrentPassword(e.target.value)} - placeholder='Enter your current password' - className={inputClass} - disabled={isLoading} - /> - </div> - )} - <div> <Label htmlFor='runtime-user-pass' className='mb-2 block text-sm'> {isEdit ? 'New password' : 'Password'} @@ -264,6 +245,28 @@ const RuntimeUserModal = (props: RuntimeUserModalProps) => { /> </div> + {/* Rendered last, and only once the password is actually being + changed, so adding it doesn't shove the field the user just + clicked (the new-password field) down the form. */} + {isEdit && requireCurrentPassword && passwordTouched && ( + <div> + <Label htmlFor='runtime-user-current-pass' className='mb-2 block text-sm'> + Current password + </Label> + <input + id='runtime-user-current-pass' + name='runtime-user-current-pass' + type='password' + autoComplete='off' + value={currentPassword} + onChange={(e) => setCurrentPassword(e.target.value)} + placeholder='Enter your current password' + className={inputClass} + disabled={isLoading} + /> + </div> + )} + {error && <p className='text-sm text-red-600 dark:text-red-400'>{error}</p>} <div className='mt-2 flex gap-3'> diff --git a/src/frontend/store/__tests__/device-slice.test.ts b/src/frontend/store/__tests__/device-slice.test.ts index 6c3d34140..d92bc5ee4 100644 --- a/src/frontend/store/__tests__/device-slice.test.ts +++ b/src/frontend/store/__tests__/device-slice.test.ts @@ -1219,6 +1219,24 @@ describe('createDeviceSlice', () => { }) }) + // ----------------------------------------------------------------------- + // setRuntimeVersion + // ----------------------------------------------------------------------- + describe('setRuntimeVersion', () => { + it('stores the runtime version', () => { + const store = makeStore() + store.getState().deviceActions.setRuntimeVersion('v4.1.9') + expect(store.getState().runtimeConnection.runtimeVersion).toBe('v4.1.9') + }) + + it('clears the runtime version with null', () => { + const store = makeStore() + store.getState().deviceActions.setRuntimeVersion('v4.1.9') + store.getState().deviceActions.setRuntimeVersion(null) + expect(store.getState().runtimeConnection.runtimeVersion).toBeNull() + }) + }) + // ----------------------------------------------------------------------- // setPlcRuntimeStatus // ----------------------------------------------------------------------- @@ -1401,6 +1419,7 @@ describe('createDeviceSlice', () => { store.getState().deviceActions.setIncludeTimingStatsInPolling(true) store.getState().deviceActions.setEthercatStatus(makeEthercatStatus()) store.getState().deviceActions.setIncludeEthercatStatsInPolling(true) + store.getState().deviceActions.setRuntimeVersion('v4.1.9') store.getState().deviceActions.clearRuntimeConnection() const rc = store.getState().runtimeConnection @@ -1408,6 +1427,7 @@ describe('createDeviceSlice', () => { expect(rc.connectionStatus).toBe('disconnected') expect(rc.plcStatus).toBeNull() expect(rc.ipAddress).toBeNull() + expect(rc.runtimeVersion).toBeNull() expect(rc.selectedDevice).toBeNull() expect(rc.storedCredentials).toBeNull() expect(rc.timingStats).toBeNull() diff --git a/src/frontend/store/__tests__/device-types.test.ts b/src/frontend/store/__tests__/device-types.test.ts index a1ed67098..41de632aa 100644 --- a/src/frontend/store/__tests__/device-types.test.ts +++ b/src/frontend/store/__tests__/device-types.test.ts @@ -96,6 +96,7 @@ describe('Device slice types', () => { connectionStatus: 'disconnected', plcStatus: null, ipAddress: null, + runtimeVersion: null, selectedDevice: null, storedCredentials: null, timingStats: null, @@ -137,6 +138,7 @@ describe('Device slice types', () => { connectionStatus: 'connected', plcStatus: 'RUNNING', ipAddress: '192.168.1.1', + runtimeVersion: 'v4.1.9', selectedDevice: { orchestratorId: 'o', orchestratorAgentId: 'a', @@ -177,6 +179,7 @@ describe('Device slice types', () => { connectionStatus: 'disconnected', plcStatus: null, ipAddress: null, + runtimeVersion: null, selectedDevice: null, storedCredentials: null, timingStats: null, diff --git a/src/frontend/store/slices/device/slice.ts b/src/frontend/store/slices/device/slice.ts index 6206d7b26..3740ab094 100644 --- a/src/frontend/store/slices/device/slice.ts +++ b/src/frontend/store/slices/device/slice.ts @@ -51,6 +51,7 @@ const createDeviceSlice: StateCreator<DeviceSliceRoot, [], [], DeviceSlice> = (s connectionStatus: 'disconnected', plcStatus: null, ipAddress: null, + runtimeVersion: null, selectedDevice: null, storedCredentials: null, timingStats: null, @@ -118,6 +119,7 @@ const createDeviceSlice: StateCreator<DeviceSliceRoot, [], [], DeviceSlice> = (s runtimeConnection.connectionStatus = 'disconnected' runtimeConnection.plcStatus = null runtimeConnection.ipAddress = null + runtimeConnection.runtimeVersion = null runtimeConnection.selectedDevice = null runtimeConnection.storedCredentials = null runtimeConnection.timingStats = null @@ -445,6 +447,13 @@ const createDeviceSlice: StateCreator<DeviceSliceRoot, [], [], DeviceSlice> = (s }), ) }, + setRuntimeVersion: (version): void => { + setState( + produce(({ runtimeConnection }: DeviceSlice) => { + runtimeConnection.runtimeVersion = version + }), + ) + }, setPlcRuntimeStatus: (status): void => { setState( produce(({ runtimeConnection }: DeviceSlice) => { @@ -508,6 +517,7 @@ const createDeviceSlice: StateCreator<DeviceSliceRoot, [], [], DeviceSlice> = (s runtimeConnection.connectionStatus = 'disconnected' runtimeConnection.plcStatus = null runtimeConnection.ipAddress = null + runtimeConnection.runtimeVersion = null runtimeConnection.selectedDevice = null runtimeConnection.storedCredentials = null runtimeConnection.timingStats = null diff --git a/src/frontend/store/slices/device/types.ts b/src/frontend/store/slices/device/types.ts index 6275ae351..5a035b325 100644 --- a/src/frontend/store/slices/device/types.ts +++ b/src/frontend/store/slices/device/types.ts @@ -64,6 +64,10 @@ export type RuntimeConnection = { connectionStatus: ConnectionStatus plcStatus: PlcStatus | null ipAddress: string | null + /** Version string reported by the connected runtime (from + * get-users-info / the X-OpenPLC-Runtime-Version header), or null + * when unknown. Gates version-dependent UI like User Management. */ + runtimeVersion: string | null selectedDevice: SelectedDevice | null storedCredentials: StoredCredentials | null timingStats: TimingStats | null @@ -144,6 +148,7 @@ export type DeviceActions = { setRuntimeIpAddress: (ipAddress: string) => void setRuntimeJwtToken: (token: string | null) => void setRuntimeConnectionStatus: (status: ConnectionStatus) => void + setRuntimeVersion: (version: string | null) => void setPlcRuntimeStatus: (status: PlcStatus | null) => void setSelectedDevice: (device: SelectedDevice | null) => void setStoredCredentials: (credentials: StoredCredentials | null) => void diff --git a/src/frontend/utils/device.ts b/src/frontend/utils/device.ts index 16d5d7187..9ab92049d 100644 --- a/src/frontend/utils/device.ts +++ b/src/frontend/utils/device.ts @@ -11,9 +11,13 @@ * cleaner "does this target support <feature>?". */ -import { parseRuntimeVersion } from '@root/backend/shared/firmware/runtime-version-gate' +import { isUserManagementCapableRuntime, parseRuntimeVersion } from '@root/backend/shared/firmware/runtime-version-gate' import { type BoardInfoLike, resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' +// Re-exported so component/store layers (which may not import backend-shared +// directly) can gate UI on runtime capability through the utils layer. +export { isUserManagementCapableRuntime } + /** * Minimal board info shape used by device utility functions. * Compatible with both BoardInfo from ports and store slice types. diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 561cbb9d2..cd63382d2 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -227,9 +227,10 @@ class MainProcessBridge implements MainIpcModule { // First-user bootstrap runs before any login (no token yet) and the // runtime allows it unauthenticated. Once a session exists this is an // admin adding an account, which the runtime requires to be authenticated - // — route it through the token authority so an expired token is refreshed. + // — route it through the token authority (mutation helper accepts the + // runtime's 201 Created and refreshes an expired token). if (this.tokens.hasToken()) { - const res = await this.makeRuntimeApiPostRequest(ipAddress, '/api/create-user', payload, () => undefined) + const res = await this.makeRuntimeApiMutation('POST', ipAddress, '/api/create-user', payload) return res.success ? { success: true } : { success: false, error: res.error } } @@ -470,7 +471,7 @@ class MainProcessBridge implements MainIpcModule { * success; the raw body is returned so callers can surface error messages. */ private makeRuntimeApiMutation( - method: 'PUT' | 'DELETE', + method: 'POST' | 'PUT' | 'DELETE', ipAddress: string, endpoint: string, body?: string, From 66db0d80e8b94376fcee2c4d3e0b60d2e84d8247 Mon Sep 17 00:00:00 2001 From: Thiago Alves <thiagoralves@gmail.com> Date: Thu, 23 Jul 2026 14:45:13 -0400 Subject: [PATCH 48/52] fix(user-management): keep connection after first-user creation The bootstrap dialog was still disconnecting on the success close. Reading runtimeConnection.connectionStatus in the close handler was stale: handleSubmit sets it to 'connected', but RuntimeUserModal then calls onOpenChange(false) synchronously before a re-render, so the handler saw the old 'connecting' value and reverted to 'disconnected'. Track success with a ref instead (set in handleSubmit, reset when the dialog opens) so the close handler reliably distinguishes a successful connect from a genuine cancel. Restores the pre-refactor behavior where the success path closed via a controlled prop change that never triggered the cancel logic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../modals/runtime-create-user-modal.tsx | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx b/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx index afa138fa1..ed78e2098 100644 --- a/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx +++ b/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx @@ -1,3 +1,5 @@ +import { useEffect, useRef } from 'react' + import { useRuntime } from '../../../../middleware/shared/providers' import { useOpenPLCStore } from '../../../store' import { getErrorMessage } from '../../../utils/get-error-message' @@ -10,11 +12,22 @@ import { RuntimeUserModal, type RuntimeUserModalSubmit } from './runtime-user-mo * always makes this first account an admin). */ const RuntimeCreateUserModal = () => { - const { modals, modalActions, deviceActions, runtimeConnection } = useOpenPLCStore() + const { modals, modalActions, deviceActions } = useOpenPLCStore() const runtime = useRuntime() const isOpen = modals['runtime-create-user']?.open || false + // Tracks whether this run successfully created + logged in. A ref (not the + // store's connectionStatus) because handleSubmit sets the status and the + // modal then calls onOpenChange(false) synchronously — before a re-render — + // so reading connectionStatus from the render closure would be stale and the + // close handler would wrongly tear down the just-established connection. + const succeededRef = useRef(false) + + useEffect(() => { + if (isOpen) succeededRef.current = false + }, [isOpen]) + const handleSubmit = async ({ username, password }: RuntimeUserModalSubmit): Promise<string | null> => { if (!password) return 'Password is required' try { @@ -27,6 +40,7 @@ const RuntimeCreateUserModal = () => { deviceActions.setRuntimeJwtToken(loginResult.accessToken) deviceActions.setRuntimeConnectionStatus('connected') deviceActions.setStoredCredentials({ username, password }) + succeededRef.current = true return null } return 'User created but login failed: ' + (loginResult.error || 'Unknown error') @@ -37,10 +51,9 @@ const RuntimeCreateUserModal = () => { const handleOpenChange = (open: boolean) => { if (!open) { - // On success `handleSubmit` has already logged in and set the status to - // 'connected'; closing then must NOT tear that down. Only a genuine - // cancel (still connecting/disconnected) abandons the connection attempt. - if (isOpen && runtimeConnection.connectionStatus !== 'connected') { + // Only a genuine cancel (no successful login this run) abandons the + // connection attempt; a success close must keep the 'connected' status. + if (isOpen && !succeededRef.current) { deviceActions.setRuntimeConnectionStatus('disconnected') } modalActions.closeModal() From 8cc96f6ec767f2c2617327bb544c933866545955 Mon Sep 17 00:00:00 2001 From: Thiago Alves <thiagoralves@gmail.com> Date: Thu, 23 Jul 2026 15:54:02 -0400 Subject: [PATCH 49/52] fix(user-management): center New User button + no crash when logged out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 3: the PlusIcon strokes `inherit` with no stroke color set, so it rendered invisibly — its empty box pushed the "New User" label off-center. Give it `stroke-current` (white on the brand button) so it shows as a proper "+ New User". Bug 6 follow-up: changing your own password signs you out, but the User Management tab stayed open and any action then crashed with "users.map is not a function". Two guards: - When not connected, render a neutral placeholder instead of the table and actions (which would hit the runtime unauthenticated). - listUsers coerces a non-array payload to [] (the runtime returns an existence-only {"msg":"Users found"} object when the token is invalid), in the editor adapter and the screen, so the table can never receive a non-array. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../editor/user-management/index.tsx | 27 ++++++++++++++++--- src/main/modules/ipc/main.ts | 12 +++++---- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx b/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx index 7c23d5af6..c8a69c09f 100644 --- a/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx @@ -38,7 +38,10 @@ const UserManagementEditor = () => { setLoadError(listResult.error || 'Failed to load users') setUsers([]) } else { - setUsers(listResult.users ?? []) + // Guard against a non-array payload (e.g. the runtime's existence-only + // {"msg":"Users found"} reply when the token is no longer valid), which + // would otherwise crash the table on `users.map`. + setUsers(Array.isArray(listResult.users) ? listResult.users : []) } if (meResult.success && meResult.user) { setCurrentUser(meResult.user) @@ -111,6 +114,20 @@ const UserManagementEditor = () => { const canEditRow = (user: RuntimeUser) => isAdmin || user.id === currentUser?.id const canDeleteRow = (user: RuntimeUser) => isAdmin && user.id !== currentUser?.id + // When not connected (e.g. after changing your own password signs you out), + // show a neutral placeholder instead of the table + actions — those would + // hit the runtime unauthenticated and, worse, could render a non-array list. + if (connectionStatus !== 'connected') { + return ( + <div className='flex h-full w-full select-none flex-col items-center justify-center gap-2 p-8 text-center'> + <h2 className='text-lg font-semibold text-neutral-1000 dark:text-white'>User Management</h2> + <p className='text-sm text-neutral-500 dark:text-neutral-400'> + You are not connected to a runtime. Connect to the runtime to manage its users. + </p> + </div> + ) + } + return ( <div className='flex h-full w-full select-none flex-col overflow-auto p-8'> <div className='mb-6 flex items-start justify-between'> @@ -133,10 +150,12 @@ const UserManagementEditor = () => { <button type='button' onClick={() => setCreateOpen(true)} - className='flex h-9 items-center justify-center gap-2 rounded-md bg-brand px-3 text-sm font-medium leading-none text-white hover:bg-brand-medium-dark' + className='flex h-9 items-center justify-center gap-2 rounded-md bg-brand px-3 text-sm font-medium text-white hover:bg-brand-medium-dark' > - <PlusIcon className='h-4 w-4' /> - <span>New User</span> + {/* PlusIcon strokes `inherit`; without a stroke color it renders + invisibly and its empty box pushed the label off-center. */} + <PlusIcon className='h-4 w-4 stroke-current' /> + <span className='leading-none'>New User</span> </button> )} </div> diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index cd63382d2..3e892772a 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -247,11 +247,13 @@ class MainProcessBridge implements MainIpcModule { } handleRuntimeListUsers = async (_event: IpcMainInvokeEvent, ipAddress: string) => { - const res = await this.makeRuntimeApiRequest<RuntimeUser[]>( - ipAddress, - '/api/get-users-info', - (data) => JSON.parse(data) as RuntimeUser[], - ) + const res = await this.makeRuntimeApiRequest<RuntimeUser[]>(ipAddress, '/api/get-users-info', (data) => { + // With a valid admin token this is a user array; without one the runtime + // answers the existence-only {"msg":"Users found"} object — coerce that + // (or any non-array) to an empty list so the caller never gets a non-array. + const parsed: unknown = JSON.parse(data) + return Array.isArray(parsed) ? (parsed as RuntimeUser[]) : [] + }) return res.success ? { success: true, users: res.data } : { success: false, error: res.error } } From f482aad5adcc027679badabc692817abb9620e71 Mon Sep 17 00:00:00 2001 From: Thiago Alves <thiagoralves@gmail.com> Date: Thu, 23 Jul 2026 18:25:09 -0400 Subject: [PATCH 50/52] fix(user-management): use the Users icon on the tab (not the IL default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab atom maps fileDerivation.type to an icon and defaults to the IL icon when the type isn't handled — so the User Management tab showed the IL glyph. Add a 'user-management' case using the same UsersIcon as the project-tree leaf. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/frontend/components/_atoms/tab/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/frontend/components/_atoms/tab/index.tsx b/src/frontend/components/_atoms/tab/index.tsx index d40d6473b..43affc54d 100644 --- a/src/frontend/components/_atoms/tab/index.tsx +++ b/src/frontend/components/_atoms/tab/index.tsx @@ -21,6 +21,7 @@ import { ServerIcon } from '../../../assets/icons/project/Server' import { SFCIcon } from '../../../assets/icons/project/SFC' import { STIcon } from '../../../assets/icons/project/ST' import { StructureIcon } from '../../../assets/icons/project/Structure' +import { UsersIcon } from '../../../assets/icons/project/Users' import { useOpenPLCStore } from '../../../store' import type { TabsProps } from '../../../store/slices/tabs' import { cn } from '../../../utils/cn' @@ -56,6 +57,7 @@ const TabIcons: Record<string, React.ReactNode> = { 'ethercat-device': <DeviceTransferIcon className='h-4 w-4 flex-shrink-0' />, 'library-manager': <LibraryIcon className='h-4 w-4 flex-shrink-0' />, 'library-manifest': <LibraryManifestIcon className='h-4 w-4 flex-shrink-0' />, + 'user-management': <UsersIcon className='h-4 w-4 flex-shrink-0' />, 'diff-viewer': <GitCompare className='h-4 w-4 flex-shrink-0 text-[#0464FB]' />, } @@ -87,6 +89,7 @@ const Tab = (props: ITabProps) => { | 'ethercat-device' | 'library-manager' | 'library-manifest' + | 'user-management' | 'diff-viewer' = 'il' if (fileDerivation?.type === 'data-type' || fileDerivation?.type === 'device') { @@ -123,6 +126,9 @@ const Tab = (props: ITabProps) => { if (fileDerivation?.type === 'library-manifest') { languageOrDerivation = 'library-manifest' } + if (fileDerivation?.type === 'user-management') { + languageOrDerivation = 'user-management' + } if (fileDerivation?.type === 'diff-viewer') { languageOrDerivation = 'diff-viewer' } From 526e470b3ee2e572c763e5b2350a7be167ec2325 Mon Sep 17 00:00:00 2001 From: Thiago Alves <thiagoralves@gmail.com> Date: Thu, 23 Jul 2026 18:40:08 -0400 Subject: [PATCH 51/52] style: prettier formatting for user-management screen + ipc main Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../[workspace]/editor/user-management/index.tsx | 10 +++++----- src/main/modules/ipc/main.ts | 7 +------ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx b/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx index c8a69c09f..69730f843 100644 --- a/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx @@ -4,7 +4,10 @@ import { RefreshIcon } from '@root/frontend/assets/icons/interface/Refresh' import { TrashCanIcon } from '@root/frontend/assets/icons/interface/TrashCan' import { toast } from '@root/frontend/components/_features/[app]/toast/use-toast' import { Modal, ModalContent, ModalTitle } from '@root/frontend/components/_molecules/modal' -import { RuntimeUserModal, type RuntimeUserModalSubmit } from '@root/frontend/components/_organisms/modals/runtime-user-modal' +import { + RuntimeUserModal, + type RuntimeUserModalSubmit, +} from '@root/frontend/components/_organisms/modals/runtime-user-modal' import { useOpenPLCStore } from '@root/frontend/store' import type { RuntimeUser, UpdateUserParams } from '@root/middleware/shared/ports/runtime-port' import { useRuntime } from '@root/middleware/shared/providers' @@ -181,10 +184,7 @@ const UserManagementEditor = () => { {users.map((user) => { const isSelf = user.id === currentUser?.id return ( - <tr - key={user.id} - className='border-b border-neutral-100 last:border-b-0 dark:border-neutral-850' - > + <tr key={user.id} className='border-b border-neutral-100 last:border-b-0 dark:border-neutral-850'> <td className='px-4 py-2 text-neutral-850 dark:text-neutral-200'> {user.username} {isSelf && <span className='ml-2 text-xs text-neutral-400'>(you)</span>} diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 3e892772a..e0f8cc87b 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -278,12 +278,7 @@ class MainProcessBridge implements MainIpcModule { if (params.password !== undefined) body.password = params.password if (params.currentPassword !== undefined) body.current_password = params.currentPassword if (params.role !== undefined) body.role = params.role - const res = await this.makeRuntimeApiMutation( - 'PUT', - ipAddress, - `/api/update-user/${userId}`, - JSON.stringify(body), - ) + const res = await this.makeRuntimeApiMutation('PUT', ipAddress, `/api/update-user/${userId}`, JSON.stringify(body)) return res.success ? { success: true } : { success: false, error: res.error } } From 217cf1266c49a2e46e920575a957985bbfe6978e Mon Sep 17 00:00:00 2001 From: Thiago Alves <thiagoralves@gmail.com> Date: Thu, 23 Jul 2026 22:13:39 -0400 Subject: [PATCH 52/52] chore(release): bump version to 4.2.9 Bump APP_VERSION (the single source of truth the About dialog renders, shared byte-for-byte with openplc-web) AND package.json.version together so the desktop binary and the About dialog agree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- package.json | 2 +- src/frontend/data/constants/app-version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 86a9cee4e..b50ae32f1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "open-plc-editor", "description": "OpenPLC Editor - IDE capable of creating programs for the OpenPLC Runtime", - "version": "4.2.8", + "version": "4.2.9", "license": "GPL-3.0", "author": { "name": "Autonomy Logic" diff --git a/src/frontend/data/constants/app-version.ts b/src/frontend/data/constants/app-version.ts index f310ebf7d..661f491e9 100644 --- a/src/frontend/data/constants/app-version.ts +++ b/src/frontend/data/constants/app-version.ts @@ -18,4 +18,4 @@ * compares a per-deploy `BUILD_ID` (git commit SHA), not this version, so a * stale tab is detected on every deploy even without a version bump. */ -export const APP_VERSION = '4.2.8' +export const APP_VERSION = '4.2.9'