From 6cc419ed8e5de42c83efa91b184f732924181155 Mon Sep 17 00:00:00 2001 From: Manuele Conti Date: Tue, 24 Mar 2026 18:06:55 +0100 Subject: [PATCH 01/35] Guard system info requests during window teardown Fix a race where the renderer could request system:get-system-info while the Electron BrowserWindow was already being destroyed. The IPC handler previously called isMaximized() on a stale BrowserWindow reference, which raised "Object has been destroyed" and surfaced as an unhandled promise rejection in development logs. This change guards the main-process handler with isDestroyed() before reading window state, and wraps the renderer initialization request in try/catch so startup or teardown races do not produce unhandled rejections. --- src/main/modules/ipc/main.ts | 4 ++- .../components/_templates/app-layout.tsx | 28 +++++++++++-------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index ae73dde85..709edc3d2 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -751,11 +751,13 @@ class MainProcessBridge implements MainIpcModule { nativeTheme.themeSource = savedTheme } + const isWindowMaximized = this.mainWindow && !this.mainWindow.isDestroyed() ? this.mainWindow.isMaximized() : false + return { OS: platform, architecture: 'x64', prefersDarkMode: nativeTheme.shouldUseDarkColors, - isWindowMaximized: this.mainWindow?.isMaximized(), + isWindowMaximized, } } handleStoreRetrieveRecent = async () => { diff --git a/src/renderer/components/_templates/app-layout.tsx b/src/renderer/components/_templates/app-layout.tsx index 9bff6771b..0febce45c 100644 --- a/src/renderer/components/_templates/app-layout.tsx +++ b/src/renderer/components/_templates/app-layout.tsx @@ -28,22 +28,26 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { useEffect(() => { const getUserSystemProps = async () => { - const { OS, architecture, prefersDarkMode, isWindowMaximized } = await window.bridge.getSystemInfo() - const recent = await window.bridge.retrieveRecent() + try { + const { OS, architecture, prefersDarkMode, isWindowMaximized } = await window.bridge.getSystemInfo() + const recent = await window.bridge.retrieveRecent() - setRecent(recent) - setSystemConfigs({ - OS, - arch: architecture, - shouldUseDarkMode: prefersDarkMode, - isWindowMaximized, - }) - if (OS === 'darwin' || OS === 'win32') { - setIsLinux(false) + setRecent(recent) + setSystemConfigs({ + OS, + arch: architecture, + shouldUseDarkMode: prefersDarkMode, + isWindowMaximized, + }) + if (OS === 'darwin' || OS === 'win32') { + setIsLinux(false) + } + } catch (error) { + console.error('Failed to read system info during app layout initialization:', error) } } void getUserSystemProps() - }, [setSystemConfigs]) + }, [setRecent, setSystemConfigs]) return ( <> From 0e8677d22b266106239e2269064b3bc0c8cdb229 Mon Sep 17 00:00:00 2001 From: Manuele Conti Date: Tue, 24 Mar 2026 22:03:16 +0100 Subject: [PATCH 02/35] Harden dev menu rebuild and extension startup paths Guard development-only menu and devtools initialization paths against BrowserWindow teardown races. This avoids Object has been destroyed errors when the menu is rebuilt during window shutdown, adds an explicit catch for asynchronous menu rebuild failures, and downgrades optional React DevTools installation failures to a compact warning instead of a noisy stack trace during development startup. --- src/main/main.ts | 10 +++++++--- src/main/menu.ts | 21 ++++++++++++++++++++- src/main/modules/ipc/main.ts | 6 +++++- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index a448eb19e..a3041ecdc 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -92,12 +92,16 @@ const installExtensions = async () => { const forceDownload = !!process.env.UPGRADE_EXTENSIONS const extensions = ['REACT_DEVELOPER_TOOLS'] - return installer - .default( + try { + return await installer.default( extensions.map((name) => installer[name as keyof typeof Installer]), forceDownload, ) - .catch(console.log) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.warn(`Skipping development extension installation: ${message}`) + return [] + } } const createMainWindow = async () => { diff --git a/src/main/menu.ts b/src/main/menu.ts index f2bde2c96..28f3aae80 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -32,7 +32,19 @@ export default class MenuBuilder { this.projectService = new ProjectService(mainWindow) } + private hasLiveWindow(): boolean { + return !this.mainWindow.isDestroyed() + } + + private getFallbackMenu(): Menu { + return Menu.getApplicationMenu() ?? Menu.buildFromTemplate([]) + } + async buildMenu(): Promise { + if (!this.hasLiveWindow()) { + return this.getFallbackMenu() + } + if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') { this.setupDevelopmentEnvironment() } @@ -129,13 +141,18 @@ export default class MenuBuilder { */ setupDevelopmentEnvironment(): void { + if (!this.hasLiveWindow()) return + this.mainWindow.webContents.on('context-menu', (_, props) => { + if (!this.hasLiveWindow()) return + const { x, y } = props Menu.buildFromTemplate([ { label: 'Inspect element', click: () => { + if (!this.hasLiveWindow()) return this.mainWindow.webContents.inspectElement(x, y) }, }, @@ -147,7 +164,9 @@ export default class MenuBuilder { const newTheme = nativeTheme.shouldUseDarkColors ? 'light' : 'dark' nativeTheme.themeSource = newTheme store.set('theme', newTheme) - this.mainWindow.webContents.send('system:update-theme') + if (this.hasLiveWindow()) { + this.mainWindow.webContents.send('system:update-theme') + } void this.buildMenu() } diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index ae73dde85..2cf1d4a46 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -840,7 +840,11 @@ class MainProcessBridge implements MainIpcModule { this.simulatorModule.stop() this.mainWindow?.webContents.reload() } - handleWindowRebuildMenu = () => void this.menuBuilder.buildMenu() + handleWindowRebuildMenu = () => { + void this.menuBuilder.buildMenu().catch((error) => { + console.error('Error rebuilding application menu:', error) + }) + } // Hardware handlers handleHardwareGetAvailableCommunicationPorts = async () => this.hardwareModule.getAvailableSerialPorts() From 33a8b2fc0b61f55649e396a7efa9f0f9aa7389eb Mon Sep 17 00:00:00 2001 From: Manuele Conti Date: Mon, 6 Apr 2026 20:46:26 +0200 Subject: [PATCH 03/35] decouple app layout init reads --- src/renderer/components/_templates/app-layout.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/_templates/app-layout.tsx b/src/renderer/components/_templates/app-layout.tsx index 0febce45c..802e80a16 100644 --- a/src/renderer/components/_templates/app-layout.tsx +++ b/src/renderer/components/_templates/app-layout.tsx @@ -30,9 +30,6 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { const getUserSystemProps = async () => { try { const { OS, architecture, prefersDarkMode, isWindowMaximized } = await window.bridge.getSystemInfo() - const recent = await window.bridge.retrieveRecent() - - setRecent(recent) setSystemConfigs({ OS, arch: architecture, @@ -45,6 +42,13 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { } catch (error) { console.error('Failed to read system info during app layout initialization:', error) } + + try { + const recent = await window.bridge.retrieveRecent() + setRecent(recent) + } catch (error) { + console.error('Failed to read recent projects during app layout initialization:', error) + } } void getUserSystemProps() }, [setRecent, setSystemConfigs]) From c8a326ca426a1e61c59dc2808862280e39145a97 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Wed, 24 Jun 2026 18:52:20 -0400 Subject: [PATCH 04/35] fix(package-manager): re-verify installed VPP signatures at startup Signature verification previously ran only in `importFromFile`; the persisted package store was not re-checked afterwards. Add `PackageManagerModule.verifyInstalledSignatures()`, run once at `app.whenReady()` before the first window. It re-verifies every registry-listed package against TRUSTED_PACKAGE_KEYS and removes any whose signature does not validate (directory + registry entry), logging a warning per removal. Out-of-tree registry paths are de-listed without touching disk. No-op when REQUIRE_SIGNATURE is false. Scope: VPP packages only (userData/packages). User-installed libraries (userData/libraries, LibraryManagerModule) are unsigned by design and are not affected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../verify-installed-signatures.test.ts | 215 ++++++++++++++++++ .../package-manager/package-manager-module.ts | 68 ++++++ src/main/main.ts | 12 + 3 files changed, 295 insertions(+) create mode 100644 src/backend/editor/package-manager/__tests__/verify-installed-signatures.test.ts diff --git a/src/backend/editor/package-manager/__tests__/verify-installed-signatures.test.ts b/src/backend/editor/package-manager/__tests__/verify-installed-signatures.test.ts new file mode 100644 index 000000000..a5783a4d7 --- /dev/null +++ b/src/backend/editor/package-manager/__tests__/verify-installed-signatures.test.ts @@ -0,0 +1,215 @@ +import { createHash, sign as cryptoSign } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { app } from 'electron' + +import { canonicalize, SIGNATURE_FILENAME } from '../../../shared/utils/vpp/verify-package-signature' + +// Heavy/side-effecting deps that the module pulls in transitively but this +// suite has no use for. Mocking them keeps `import { PackageManagerModule }` +// free of winston file transports and extract-zip's ESM entry point. +jest.mock('electron', () => ({ app: { getPath: jest.fn(() => '/mock/path') } })) +jest.mock('extract-zip', () => ({ __esModule: true, default: jest.fn() })) +jest.mock('../../services/logger-service', () => ({ + logger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() }, +})) + +// Replace the real trusted-key store with a freshly generated test keypair so +// fixtures can be signed with a private key we actually hold. The factory may +// not reference outer-scope variables, so it generates the pair inline and +// re-exports the private PEM for the test to sign with. +jest.mock('../../../shared/utils/vpp/trusted-keys', () => { + const { generateKeyPairSync } = jest.requireActual('node:crypto') + const { publicKey, privateKey } = generateKeyPairSync('ed25519') + return { + TRUSTED_PACKAGE_KEYS: { 'test-key': publicKey.export({ type: 'spki', format: 'pem' }).toString() }, + __TEST_PRIVATE_PEM: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + } +}) + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const PRIVATE_PEM: string = (require('../../../shared/utils/vpp/trusted-keys') as { __TEST_PRIVATE_PEM: string }) + .__TEST_PRIVATE_PEM + +import { PackageManagerModule } from '../package-manager-module' + +const KEY_ID = 'test-key' + +const sha256 = (s: string): string => + createHash('sha256') + .update(Uint8Array.from(Buffer.from(s, 'utf-8'))) + .digest('hex') + +const DEFAULT_FILES: Record = { + 'manifest.json': '{"formatVersion":"1.0"}', + 'hal/arduino/hal.cpp': 'void hardwareInit() {}', +} + +interface FixtureOpts { + files?: Record + /** Override fields on the signed payload (e.g. a foreign keyId). */ + payloadOverride?: Record + /** Mutate a file's bytes AFTER signing, to simulate tampering. */ + tamperFile?: { rel: string; content: string } + /** Skip writing signature.json entirely (a side-loaded package). */ + omitSignature?: boolean +} + +describe('PackageManagerModule.verifyInstalledSignatures', () => { + let userDataDir: string + let packagesDir: string + + beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'pkg-sweep-')) + packagesDir = join(userDataDir, 'packages') + ;(app.getPath as jest.Mock).mockReturnValue(userDataDir) + }) + + afterEach(() => { + jest.clearAllMocks() + rmSync(userDataDir, { recursive: true, force: true }) + }) + + /** Write a package directory under packagesDir and register it. */ + function installFixture(packageId: string, opts: FixtureOpts = {}): string { + const dir = join(packagesDir, packageId) + const files = opts.files ?? DEFAULT_FILES + const fileHashes: Record = {} + for (const [rel, content] of Object.entries(files)) { + const full = join(dir, rel) + mkdirSync(dirname(full), { recursive: true }) + writeFileSync(full, content) + fileHashes[rel] = sha256(content) + } + + if (!opts.omitSignature) { + const payload = { + formatVersion: '1.0', + alg: 'ed25519', + keyId: KEY_ID, + packageId, + version: '1.0.0', + signedAt: '2026-06-01T00:00:00.000Z', + files: fileHashes, + ...opts.payloadOverride, + } + const signature = cryptoSign( + null, + Uint8Array.from(Buffer.from(canonicalize(payload), 'utf-8')), + PRIVATE_PEM, + ).toString('base64') + writeFileSync(join(dir, SIGNATURE_FILENAME), JSON.stringify({ ...payload, signature }, null, 2)) + } + + if (opts.tamperFile) { + writeFileSync(join(dir, opts.tamperFile.rel), opts.tamperFile.content) + } + + return dir + } + + /** Write registry.json with the given packageId -> path entries. */ + function writeRegistry(entries: Record): void { + mkdirSync(packagesDir, { recursive: true }) + const packages: Record = {} + for (const [id, e] of Object.entries(entries)) { + packages[id] = { version: '1.0.0', installedAt: '2026-06-01T00:00:00.000Z', path: e.path, devices: [] } + } + writeFileSync(join(packagesDir, 'registry.json'), JSON.stringify({ formatVersion: '1.0', packages }, null, 2)) + } + + function readRegistryIds(): string[] { + const raw = JSON.parse(require('node:fs').readFileSync(join(packagesDir, 'registry.json'), 'utf-8')) as { + packages: Record + } + return Object.keys(raw.packages) + } + + it('keeps a package that is validly signed by a trusted key', () => { + const dir = installFixture('com.test.valid') + writeRegistry({ 'com.test.valid': { path: dir } }) + + const warn = jest.fn() + const removed = new PackageManagerModule().verifyInstalledSignatures(warn) + + expect(removed).toEqual([]) + expect(warn).not.toHaveBeenCalled() + expect(existsSync(dir)).toBe(true) + expect(readRegistryIds()).toEqual(['com.test.valid']) + }) + + it('removes a package that has no signature.json', () => { + const dir = installFixture('com.test.unsigned', { omitSignature: true }) + writeRegistry({ 'com.test.unsigned': { path: dir } }) + + const warn = jest.fn() + const removed = new PackageManagerModule().verifyInstalledSignatures(warn) + + expect(removed).toEqual(['com.test.unsigned']) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('com.test.unsigned')) + expect(existsSync(dir)).toBe(false) + expect(readRegistryIds()).toEqual([]) + }) + + it('removes a package whose signature names an untrusted key', () => { + // keyId is not present in TRUSTED_PACKAGE_KEYS. + const dir = installFixture('com.test.selfsigned', { payloadOverride: { keyId: 'untrusted-key' } }) + writeRegistry({ 'com.test.selfsigned': { path: dir } }) + + const removed = new PackageManagerModule().verifyInstalledSignatures(jest.fn()) + + expect(removed).toEqual(['com.test.selfsigned']) + expect(existsSync(dir)).toBe(false) + }) + + it('removes a package whose files were tampered after signing', () => { + const dir = installFixture('com.test.tampered', { + tamperFile: { rel: 'hal/arduino/hal.cpp', content: 'void hardwareInit() { /* altered */ }' }, + }) + writeRegistry({ 'com.test.tampered': { path: dir } }) + + const removed = new PackageManagerModule().verifyInstalledSignatures(jest.fn()) + + expect(removed).toEqual(['com.test.tampered']) + expect(existsSync(dir)).toBe(false) + }) + + it('drops a stale registry entry whose directory is missing', () => { + writeRegistry({ 'com.test.ghost': { path: join(packagesDir, 'com.test.ghost') } }) + + const removed = new PackageManagerModule().verifyInstalledSignatures(jest.fn()) + + expect(removed).toEqual(['com.test.ghost']) + expect(readRegistryIds()).toEqual([]) + }) + + it('de-lists an out-of-tree registry path without touching disk', () => { + // Registry path outside packagesDir: the entry is dropped and files there + // are left untouched. + const outside = mkdtempSync(join(tmpdir(), 'outside-')) + writeFileSync(join(outside, 'keepme.txt'), 'data') + writeRegistry({ 'com.test.escape': { path: outside } }) + + const removed = new PackageManagerModule().verifyInstalledSignatures(jest.fn()) + + expect(removed).toEqual(['com.test.escape']) + expect(readRegistryIds()).toEqual([]) + expect(existsSync(join(outside, 'keepme.txt'))).toBe(true) + rmSync(outside, { recursive: true, force: true }) + }) + + it('removes only the invalid package in a mixed registry', () => { + const good = installFixture('com.test.good') + const bad = installFixture('com.test.bad', { omitSignature: true }) + writeRegistry({ 'com.test.good': { path: good }, 'com.test.bad': { path: bad } }) + + const removed = new PackageManagerModule().verifyInstalledSignatures(jest.fn()) + + expect(removed).toEqual(['com.test.bad']) + expect(existsSync(good)).toBe(true) + expect(existsSync(bad)).toBe(false) + expect(readRegistryIds()).toEqual(['com.test.good']) + }) +}) diff --git a/src/backend/editor/package-manager/package-manager-module.ts b/src/backend/editor/package-manager/package-manager-module.ts index 0a1cc0b6e..7319b5837 100644 --- a/src/backend/editor/package-manager/package-manager-module.ts +++ b/src/backend/editor/package-manager/package-manager-module.ts @@ -7,6 +7,7 @@ import { PackageManifestSchema } from '../../../middleware/shared/ports/package- import { validatePathId } from '../../shared/utils/path-safety' import { TRUSTED_PACKAGE_KEYS } from '../../shared/utils/vpp/trusted-keys' import { verifyPackageSignature } from '../../shared/utils/vpp/verify-package-signature' +import { logger } from '../services/logger-service' import { assertPathContained } from '../utils/path-containment' import type { ImportResult, InstalledPackage, PackageManifest, PackageRegistry } from './types' @@ -132,6 +133,73 @@ class PackageManagerModule { } } + /** + * Re-verify every registry-listed package against the trusted keys and remove + * any whose signature does not validate, returning the ids removed. For each + * failing entry the package directory is deleted (when its recorded path + * resolves inside packagesDir) and the registry entry is dropped, emitting a + * warning per removal. No-op when REQUIRE_SIGNATURE is false. Directories with + * no registry entry are not listed and are left as-is. + */ + verifyInstalledSignatures(warn: (message: string) => void = (m) => logger.warn(m)): string[] { + if (!REQUIRE_SIGNATURE) return [] + + const registry = this.readRegistry() + const removed: string[] = [] + let mutated = false + + for (const [packageId, info] of Object.entries(registry.packages)) { + const reason = this.signatureRejectionReason(packageId, info?.path) + if (!reason) continue + + // Drop the registry entry; delete on-disk contents only when the recorded + // path resolves inside packagesDir. + try { + assertPathContained(this.packagesDir, info.path, 'registry package path') + if (existsSync(info.path)) { + rmSync(info.path, { recursive: true, force: true }) + } + } catch { + // Out-of-tree or unusable path: leave disk untouched, just de-list. + } + + delete registry.packages[packageId] + mutated = true + removed.push(packageId) + warn(`VPP package "${packageId}" was removed at startup due to an invalid or missing signature: ${reason}`) + } + + if (mutated) this.writeRegistry(registry) + return removed + } + + /** + * Returns null when the installed package recorded at `packagePath` is + * genuinely signed by a trusted key, or a short human-readable reason when it + * is not (bad id shape, path escaping packagesDir, missing files, or any + * failure surfaced by `verifyPackageSignature`). + */ + private signatureRejectionReason(packageId: string, packagePath: string | undefined): string | null { + try { + validatePathId(packageId, 'registry package id') + } catch { + return 'invalid package id' + } + if (typeof packagePath !== 'string' || packagePath.length === 0) { + return 'registry entry has no package path' + } + try { + assertPathContained(this.packagesDir, packagePath, 'registry package path') + } catch { + return 'package path resolves outside the packages directory' + } + if (!existsSync(packagePath)) { + return 'package files are missing' + } + const verification = verifyPackageSignature(packagePath, TRUSTED_PACKAGE_KEYS) + return verification.valid ? null : (verification.error ?? 'invalid signature') + } + listInstalled(): InstalledPackage[] { const registry = this.readRegistry() return Object.entries(registry.packages).map(([packageId, info]) => ({ diff --git a/src/main/main.ts b/src/main/main.ts index 45550e1c9..b685c6c4d 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -18,6 +18,7 @@ import { CompilerModule } from '../backend/editor/compiler' // TODO: Refactor this type declaration import { MainIpcModuleConstructor } from '../backend/editor/contracts/types/modules/ipc/main' import { HardwareModule } from '../backend/editor/hardware' +import { PackageManagerModule } from '../backend/editor/package-manager' import { logger, PouService, ProjectService, UserService } from '../backend/editor/services' import { resolveHtmlPath } from '../backend/editor/utils' import { getErrorMessage } from '../frontend/utils/get-error-message' @@ -403,6 +404,17 @@ app.on('second-instance', () => { app .whenReady() .then(() => { + // Re-verify installed VPP package signatures once per launch, before the + // first window reads them; removes any package whose signature no longer + // validates. + try { + const removed = new PackageManagerModule().verifyInstalledSignatures() + if (removed.length > 0) { + logger.warn(`Removed ${removed.length} untrusted VPP package(s) at startup: ${removed.join(', ')}`) + } + } catch (err) { + logger.error('VPP package signature sweep failed: ' + getErrorMessage(err)) + } void createMainWindow() // Handle the app activation event; app.on('activate', () => { From c6d455fa9c73cb2f319a1b57c2ea4dca8848712c Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Wed, 24 Jun 2026 21:59:05 -0400 Subject: [PATCH 05/35] feat(runtime-auth): make the editor main process the single token authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the web change: the editor now centralizes JWT management in the main process via the shared RuntimeTokenManager, so every runtime call self-heals on expiry — including project upload, which previously did a raw HTTPS POST with no refresh (the reason a long session could keep polling status while uploads 401'd). - main process owns the token + credentials via the shared manager; login sets the session, clearCredentials clears it, and a single onTokenChanged subscription broadcasts runtime:token-refreshed to the renderer. - makeRuntimeApiRequest / makeRuntimeApiPostRequest now run through tokens.withAuth (replacing the bespoke attemptTokenRefresh + per-call broadcast). - New makeRuntimeApiUpload on the bridge does the multipart upload through tokens.withAuth; the upload pipeline (editor-compiler-platform-port) routes to it and the old refresh-less CompilerModule.sendRuntimeUpload is removed. - Shared, byte-identical pieces with web: RuntimeTokenManager (+tests), RuntimePort.getAccessToken, and use-runtime-polling adopting refreshed tokens into the store connection flag. - IPC handlers still accept (but ignore) the legacy jwtToken arg; the param is removed from the signatures in the follow-up cleanup. Validated the runtime auth contract against a live SLM-RP4: TTL 900s, expiry 401. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor-compiler-platform-port.test.ts | 8 +- .../editor/compiler/compiler-module.ts | 88 ++---- .../compiler/editor-compiler-platform-port.ts | 33 +-- src/frontend/hooks/use-runtime-polling.ts | 14 + src/main/modules/ipc/main.ts | 204 ++++++++------ src/middleware/shared/ports/runtime-port.ts | 8 + .../__tests__/runtime-token-manager.test.ts | 257 ++++++++++++++++++ .../runtime-auth/runtime-token-manager.ts | 143 ++++++++++ 8 files changed, 588 insertions(+), 167 deletions(-) create mode 100644 src/middleware/shared/runtime-auth/__tests__/runtime-token-manager.test.ts create mode 100644 src/middleware/shared/runtime-auth/runtime-token-manager.ts diff --git a/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts b/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts index 590b5a0a7..1de587f46 100644 --- a/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts +++ b/src/backend/editor/compiler/__tests__/editor-compiler-platform-port.test.ts @@ -149,9 +149,9 @@ describe('createEditorCompilerPlatformPort', () => { cleanBuild: false, mainProcessBridge: { makeRuntimeApiRequest: jest.fn(), + makeRuntimeApiUpload: jest.fn(), }, compressSourceFolder: jest.fn(), - sendRuntimeUpload: jest.fn(), pollTimeoutMs: 1000, pollIntervalMs: 10, startTimeoutMs: 1000, @@ -321,7 +321,7 @@ describe('createEditorCompilerPlatformPort', () => { })) as unknown as EditorCompilerPlatformPortContext['mainProcessBridge']['makeRuntimeApiRequest'] const port = createEditorCompilerPlatformPort( makeHandlers(), - makeContext({ mainProcessBridge: { makeRuntimeApiRequest } }), + makeContext({ mainProcessBridge: { makeRuntimeApiRequest, makeRuntimeApiUpload: jest.fn() } }), ) const result = await port.checkRuntimeVersion( { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, @@ -338,7 +338,7 @@ describe('createEditorCompilerPlatformPort', () => { const log = jest.fn() const port = createEditorCompilerPlatformPort( makeHandlers(), - makeContext({ mainProcessBridge: { makeRuntimeApiRequest } }), + makeContext({ mainProcessBridge: { makeRuntimeApiRequest, makeRuntimeApiUpload: jest.fn() } }), ) const result = await port.checkRuntimeVersion( { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, @@ -355,7 +355,7 @@ describe('createEditorCompilerPlatformPort', () => { const log = jest.fn() const port = createEditorCompilerPlatformPort( makeHandlers(), - makeContext({ mainProcessBridge: { makeRuntimeApiRequest } }), + makeContext({ mainProcessBridge: { makeRuntimeApiRequest, makeRuntimeApiUpload: jest.fn() } }), ) const result = await port.checkRuntimeVersion( { context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } }, diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index fe5d59831..636cca3b0 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -54,6 +54,16 @@ type LibraryCompileBridge = { endpoint: string, responseParser?: (data: string) => T, ) => Promise<{ success: true; data?: T } | { success: false; error: string }> + // Required to satisfy compileProgram's bridge contract; never invoked on the + // library path (it compiles with runtimeIpAddress=null, so no upload runs). + makeRuntimeApiUpload: (opts: { + ipAddress: string + fileBuffer: Buffer + filename: string + contentType: string + cleanBuild: boolean + onUploadAccepted?: (responseBody: string) => void + }) => Promise<{ success: true; data: string } | { success: false; error: string }> loadEnabledArchives: (enabledNames: string[]) => { archives: unknown[]; missing: string[] } } @@ -1903,73 +1913,8 @@ class CompilerModule { }) } - /** - * Send a compiled program file to the runtime's `/api/upload-file` - * over HTTPS via a multipart/form-data POST. Pure transport — no - * polling, no PLC start, no UI logging. Used as the `uploadProgram` - * callback fed to the shared `deployRuntimeProgram` orchestrator. - * - * v3 callers pass `program.st` + `text/plain`; v4 callers pass the - * compiled zip + `application/zip`. `cleanBuild` toggles the - * `?clean=1` flag the runtime honours by wiping `build/` and ccache - * before compiling. - */ - private async sendRuntimeUpload(opts: { - hostname: string - jwtToken: string - filename: string - contentType: string - fileBuffer: Buffer - cleanBuild: boolean - onUploadAccepted?: (responseBody: string) => void - }): Promise<{ success: boolean; error?: string }> { - const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2) - const header = Buffer.from( - `--${boundary}\r\n` + - `Content-Disposition: form-data; name="file"; filename="${opts.filename}"\r\n` + - `Content-Type: ${opts.contentType}\r\n\r\n`, - ) - const footer = Buffer.from(`\r\n--${boundary}--\r\n`) - const body = Buffer.concat([header, opts.fileBuffer, footer] as unknown as ReadonlyArray) - - return new Promise<{ success: boolean; error?: string }>((resolve) => { - const req = https.request( - { - hostname: opts.hostname, - port: 8443, - path: opts.cleanBuild ? '/api/upload-file?clean=1' : '/api/upload-file', - method: 'POST', - headers: { - 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'Content-Length': body.length, - Authorization: `Bearer ${opts.jwtToken}`, - }, - ...getRuntimeHttpsOptions(), - } as https.RequestOptions, - (res: IncomingMessage) => { - let data = '' - res.on('data', (chunk: Buffer) => { - data += chunk.toString() - }) - res.on('end', () => { - if (res.statusCode === 200) { - opts.onUploadAccepted?.(data) - resolve({ success: true }) - } else { - resolve({ success: false, error: data || `HTTP ${res.statusCode}` }) - } - }) - }, - ) - req.setTimeout(300_000, () => { - req.destroy() - resolve({ success: false, error: 'Upload request timed out after 5 minutes' }) - }) - req.on('error', (err: Error) => resolve({ success: false, error: err.message })) - req.write(body) - req.end() - }) - } + // Runtime upload moved to MainProcessBridge.makeRuntimeApiUpload so it shares + // the single token authority (transparent refresh + retry on an expired JWT). // !! Deprecated: This method is a outdated implementation and should be removed. async createXmlFile( @@ -2461,6 +2406,14 @@ class CompilerModule { endpoint: string, responseParser?: (data: string) => T, ) => Promise<{ success: true; data?: T } | { success: false; error: string }> + makeRuntimeApiUpload: (opts: { + ipAddress: string + fileBuffer: Buffer + filename: string + contentType: string + cleanBuild: boolean + onUploadAccepted?: (responseBody: string) => void + }) => Promise<{ success: true; data: string } | { success: false; error: string }> /** * Resolve a list of project-enabled library names to parsed * `.stlib` archives. Bundled libraries are always-on and @@ -2710,7 +2663,6 @@ class CompilerModule { cleanBuild: cleanBuild ?? false, mainProcessBridge, compressSourceFolder: (folderPath: string) => this.compressSourceFolder(folderPath), - sendRuntimeUpload: (opts) => this.sendRuntimeUpload(opts), pollTimeoutMs: CompilerModule.COMPILATION_STATUS_TIMEOUT_MS, pollIntervalMs: CompilerModule.COMPILATION_STATUS_POLL_INTERVAL_MS, startTimeoutMs: POST_BUILD_START_TIMEOUT_MS, diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index e5e184d8a..1e23873cb 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -110,24 +110,23 @@ export interface EditorCompilerPlatformPortContext { endpoint: string, responseParser?: (data: string) => T, ) => Promise<{ success: true; data?: T } | { success: false; error: string }> + /** Upload the runtime-v4 program bundle. Owns token refresh internally + * (via the token authority), so the upload self-heals on expiry like every + * other runtime call. */ + makeRuntimeApiUpload: (opts: { + ipAddress: string + fileBuffer: Buffer + filename: string + contentType: string + cleanBuild: boolean + onUploadAccepted?: (responseBody: string) => void + }) => Promise<{ success: true; data: string } | { success: false; error: string }> } /** Compress the source folder into the runtime v4 upload zip. * Delegated through context so the port adapter doesn't pull * in the `archiver`-dependent compressSourceFolder method (which * has its own private state on CompilerModule). */ compressSourceFolder: (folderPath: string) => Promise - /** Send the upload request to a runtime device. Wraps - * CompilerModule.sendRuntimeUpload with the right multipart - * payload structure. */ - sendRuntimeUpload: (opts: { - hostname: string - jwtToken: string - filename: string - contentType: string - fileBuffer: Buffer - cleanBuild: boolean - onUploadAccepted?: (responseBody: string) => void - }) => Promise<{ success: boolean; error?: string }> /** Timeout for the post-upload compile-status poll. */ pollTimeoutMs: number /** Interval for the post-upload compile-status poll. */ @@ -384,9 +383,8 @@ export function createEditorCompilerPlatformPort( const deployOutcome = await deployRuntimeProgram({ uploadProgram: () => - context.sendRuntimeUpload({ - hostname: deviceContext.ip, - jwtToken: deviceContext.jwt, + context.mainProcessBridge.makeRuntimeApiUpload({ + ipAddress: deviceContext.ip, filename: 'program.zip', contentType: 'application/zip', fileBuffer, @@ -486,9 +484,8 @@ export function createEditorCompilerPlatformPort( const fileBuffer = Buffer.from(args.programSt, 'utf-8') const deployOutcome = await deployRuntimeProgram({ uploadProgram: () => - context.sendRuntimeUpload({ - hostname: deviceContext.ip, - jwtToken: deviceContext.jwt, + context.mainProcessBridge.makeRuntimeApiUpload({ + ipAddress: deviceContext.ip, filename: 'program.st', contentType: 'text/plain', fileBuffer, diff --git a/src/frontend/hooks/use-runtime-polling.ts b/src/frontend/hooks/use-runtime-polling.ts index 06d93f727..294722135 100644 --- a/src/frontend/hooks/use-runtime-polling.ts +++ b/src/frontend/hooks/use-runtime-polling.ts @@ -171,6 +171,20 @@ export const useRuntimePolling = () => { } }, [runtime, handleConnectionLost, setPlcRuntimeStatus, setTimingStats, setEthercatStatus]) + // Keep the store's connection token in lock-step with the platform's token + // authority. When the authority transparently refreshes an expired token + // (editor main process, or the web adapter's RuntimeTokenManager), it emits + // onTokenRefreshed; adopting it here means every store-reading consumer — the + // compile/upload pipeline, the connection-status UI — uses the live token + // instead of the one captured at login. Without this, an upload kicked off + // after the token aged out would use a stale token and 401. + useEffect(() => { + const unsubscribe = runtime.onTokenRefreshed?.((newToken) => { + useOpenPLCStore.getState().deviceActions.setRuntimeJwtToken(newToken) + }) + return unsubscribe + }, [runtime]) + useEffect(() => { const { workspaceActions } = useOpenPLCStore.getState() diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index b1b5c4a9f..596cc64b7 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 { 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' import { randomUUID } from 'crypto' @@ -65,8 +66,20 @@ class MainProcessBridge implements MainIpcModule { private debuggerRtuBaudRate: number | null = null private debuggerRtuSlaveId: number | null = null private debuggerJwtToken: string | null = null - private runtimeCredentials: { ipAddress: string; username: string; password: string } | null = null - private tokenRefreshInFlight: Promise<{ success: boolean; accessToken?: string; error?: string }> | null = null + // Address of the runtime this session is authenticated against. Captured at + // login so the token authority can re-authenticate against the same device. + private runtimeIp: string | null = null + // Single token authority for the editor: owns the access token + credentials + // and the refresh/retry-on-401 logic, shared byte-for-byte with the web app. + // Every runtime HTTP call (GET, POST, and the project upload) goes through it, + // so they all self-heal identically when the 15-min JWT expires. + private tokens = createRuntimeTokenManager({ + login: async (credentials) => { + if (!this.runtimeIp) return { success: false, error: 'No runtime address configured' } + const result = await this.performAuthentication(this.runtimeIp, credentials.username, credentials.password) + return { success: result.success, token: result.accessToken, error: result.error } + }, + }) // Current project root path used to validate file-watcher IPC calls private currentProjectPath: string | null = null // File watchers for auto-reload functionality (using watchFile for better macOS compatibility) @@ -103,6 +116,12 @@ class MainProcessBridge implements MainIpcModule { this.pouService = pouService this.compilerModule = compilerModule this.hardwareModule = hardwareModule + + // When the token authority transparently refreshes an expired token, push + // the fresh token to the renderer so its store connection flag tracks it. + this.tokens.onTokenChanged((newToken) => { + this.mainWindow?.webContents?.send('runtime:token-refreshed', newToken) + }) } // ===================== RUNTIME API HANDLERS ===================== @@ -234,29 +253,14 @@ class MainProcessBridge implements MainIpcModule { handleRuntimeLogin = async (_event: IpcMainInvokeEvent, ipAddress: string, username: string, password: string) => { const result = await this.performAuthentication(ipAddress, username, password) if (result.success && result.accessToken) { - this.runtimeCredentials = { ipAddress, username, password } + // Hand the session to the token authority so it can transparently + // re-authenticate against this device when the token expires. + this.runtimeIp = ipAddress + this.tokens.setSession(result.accessToken, { username, password }) } return result } - private async attemptTokenRefresh(): Promise<{ success: boolean; accessToken?: string; error?: string }> { - if (this.tokenRefreshInFlight) { - return this.tokenRefreshInFlight - } - - if (!this.runtimeCredentials) { - return { success: false, error: 'No stored credentials available for token refresh' } - } - - const { ipAddress, username, password } = this.runtimeCredentials - - this.tokenRefreshInFlight = this.performAuthentication(ipAddress, username, password).finally(() => { - this.tokenRefreshInFlight = null - }) - - return this.tokenRefreshInFlight - } - private isTokenExpiredError(statusCode: number | undefined, errorMessage: string): boolean { if (statusCode === 401 || statusCode === 403) { return true @@ -286,50 +290,28 @@ class MainProcessBridge implements MainIpcModule { async makeRuntimeApiRequest( ipAddress: string, - jwtToken: string, + _jwtToken: string, endpoint: string, responseParser?: (data: string) => T, ): Promise<{ success: true; data?: T } | { success: false; error: string }> { - try { - const url = this.runtimeUrl(ipAddress, endpoint) - const res = await this.httpRequest({ - method: 'GET', - url, - headers: { Authorization: `Bearer ${jwtToken}` }, - }) - - if (res.statusCode === 200) { - return this.parseApiResponse(res.data, responseParser) - } - - if (!this.isTokenExpiredError(res.statusCode, res.data)) { - return { success: false, error: res.data } - } - - // Attempt token refresh and retry - const refreshResult = await this.attemptTokenRefresh() - if (!refreshResult.success || !refreshResult.accessToken) { - return { - success: false, - error: refreshResult.error ? `Token refresh failed: ${refreshResult.error}` : res.data, + // The token authority owns the live token + refresh; the legacy `_jwtToken` + // arg from the renderer is ignored (kept for signature compatibility and + // removed in the follow-up cleanup). + type Raw = { success: true; data?: T } | { success: false; error: string; statusCode?: number } + const url = this.runtimeUrl(ipAddress, endpoint) + const result = await this.tokens.withAuth( + async (token) => { + try { + const res = await this.httpRequest({ method: 'GET', url, headers: { Authorization: `Bearer ${token}` } }) + if (res.statusCode === 200) return this.parseApiResponse(res.data, responseParser) + return { success: false, error: res.data, statusCode: res.statusCode } + } catch (error) { + return { success: false, error: getErrorMessage(error) } } - } - - this.mainWindow?.webContents?.send('runtime:token-refreshed', refreshResult.accessToken) - - const retryRes = await this.httpRequest({ - method: 'GET', - url, - headers: { Authorization: `Bearer ${refreshResult.accessToken}` }, - }) - - if (retryRes.statusCode === 200) { - return this.parseApiResponse(retryRes.data, responseParser) - } - return { success: false, error: retryRes.data } - } catch (error) { - return { success: false, error: getErrorMessage(error) } - } + }, + (r) => !r.success && this.isTokenExpiredError(r.statusCode, r.error), + ) + return result.success ? result : { success: false, error: result.error } } /** @@ -348,12 +330,13 @@ class MainProcessBridge implements MainIpcModule { */ makeRuntimeApiPostRequest( ipAddress: string, - jwtToken: string, + _jwtToken: string, endpoint: string, body: string, responseParser: (data: string) => T, timeoutMs?: number, ): Promise<{ success: true; data: T } | { success: false; error: string }> { + // Token + refresh owned by the authority; `_jwtToken` is ignored. type PostResult = { success: true; data: T } | { success: false; error: string; statusCode?: number } const doRequest = (token: string): Promise => { @@ -410,21 +393,87 @@ class MainProcessBridge implements MainIpcModule { const stripStatus = (r: PostResult): { success: true; data: T } | { success: false; error: string } => r.success ? r : { success: false, error: r.error } - return doRequest(jwtToken).then((result) => { - const statusCode = !result.success ? result.statusCode : undefined - if (!result.success && this.isTokenExpiredError(statusCode, result.error)) { - return this.attemptTokenRefresh().then((refreshResult) => { - if (refreshResult.success && refreshResult.accessToken) { - if (this.mainWindow && this.mainWindow.webContents) { - this.mainWindow.webContents.send('runtime:token-refreshed', refreshResult.accessToken) - } - return doRequest(refreshResult.accessToken).then(stripStatus) - } - return { success: false as const, error: `Token refresh failed: ${refreshResult.error || 'Unknown error'}` } + return this.tokens + .withAuth( + (token) => doRequest(token), + (r) => !r.success && this.isTokenExpiredError(r.statusCode, r.error), + ) + .then(stripStatus) + } + + /** + * Upload a compiled program (multipart) to the runtime, going through the + * token authority so an expired token is transparently refreshed and the + * upload retried — the same self-healing every other runtime call gets. This + * is the path that previously had no refresh, so a long session's upload 401'd + * while status polling kept working. + */ + makeRuntimeApiUpload(opts: { + ipAddress: string + fileBuffer: Buffer + filename: string + contentType: string + cleanBuild: boolean + onUploadAccepted?: (responseBody: string) => void + }): Promise<{ success: true; data: string } | { success: false; error: string }> { + type UploadResult = { success: true; data: string } | { success: false; error: string; statusCode?: number } + const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2) + const header = Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${opts.filename}"\r\n` + + `Content-Type: ${opts.contentType}\r\n\r\n`, + ) + const footer = Buffer.from(`\r\n--${boundary}--\r\n`) + const reqBody = Buffer.concat([header, opts.fileBuffer, footer] as unknown as ReadonlyArray) + const path = opts.cleanBuild ? '/api/upload-file?clean=1' : '/api/upload-file' + + const doRequest = (token: string): Promise => + new Promise((resolve) => { + const req = https.request( + { + hostname: opts.ipAddress, + port: this.RUNTIME_API_PORT, + path, + method: 'POST', + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': reqBody.length, + Authorization: `Bearer ${token}`, + }, + ...getRuntimeHttpsOptions(), + } as https.RequestOptions, + (res: IncomingMessage) => { + let data = '' + res.on('data', (chunk: Buffer) => { + data += chunk.toString() + }) + res.on('end', () => { + if (res.statusCode === 200) resolve({ success: true, data }) + else resolve({ success: false, error: data || `HTTP ${res.statusCode}`, statusCode: res.statusCode }) + }) + }, + ) + req.setTimeout(300_000, () => { + req.destroy() + resolve({ success: false, error: 'Upload request timed out after 5 minutes' }) }) - } - return stripStatus(result) - }) + req.on('error', (err: Error) => resolve({ success: false, error: err.message })) + req.write(reqBody) + req.end() + }) + + return this.tokens + .withAuth( + (token) => doRequest(token), + (r) => !r.success && this.isTokenExpiredError(r.statusCode, r.error), + ) + .then((result) => { + if (result.success) { + opts.onUploadAccepted?.(result.data) + return { success: true as const, data: result.data } + } + return { success: false as const, error: result.error } + }) } handleRuntimeGetStatus = async ( @@ -573,7 +622,8 @@ class MainProcessBridge implements MainIpcModule { } handleRuntimeClearCredentials = (_event: IpcMainInvokeEvent) => { - this.runtimeCredentials = null + this.tokens.clear() + this.runtimeIp = null return { success: true } } diff --git a/src/middleware/shared/ports/runtime-port.ts b/src/middleware/shared/ports/runtime-port.ts index a4f5c3deb..e48ebefa8 100644 --- a/src/middleware/shared/ports/runtime-port.ts +++ b/src/middleware/shared/ports/runtime-port.ts @@ -180,6 +180,14 @@ export interface RuntimePort { */ onTokenRefreshed?(callback: (newToken: string) => void): Unsubscribe + /** + * Current runtime access token held by the platform's token authority, or + * null when not authenticated. Exposed so non-RuntimePort callers (e.g. the + * compile/upload pipeline) can read the always-fresh token from the single + * authority instead of a separately-tracked copy. + */ + getAccessToken?(): string | null + // --- LAN discovery (UDP broadcast) --- /** diff --git a/src/middleware/shared/runtime-auth/__tests__/runtime-token-manager.test.ts b/src/middleware/shared/runtime-auth/__tests__/runtime-token-manager.test.ts new file mode 100644 index 000000000..ecb4988b0 --- /dev/null +++ b/src/middleware/shared/runtime-auth/__tests__/runtime-token-manager.test.ts @@ -0,0 +1,257 @@ +import { createRuntimeTokenManager, type TokenLoginTransport } from '../runtime-token-manager' + +const CREDS = { username: 'admin', password: 'openplc' } + +/** + * A controllable login transport. Each call to `login` resolves with the next + * queued result (or a default success), and records the credentials it saw. + */ +function makeTransport(results?: Array<{ success: boolean; token?: string; error?: string }>) { + const queue = [...(results ?? [])] + const calls: Array<{ username: string; password: string }> = [] + let deferredResolvers: Array<(v: { success: boolean; token?: string }) => void> = [] + const transport: TokenLoginTransport & { + calls: typeof calls + resolveNext: (v: { success: boolean; token?: string }) => void + pending: number + } = { + calls, + get pending() { + return deferredResolvers.length + }, + resolveNext(v) { + const r = deferredResolvers.shift() + if (r) r(v) + }, + login(credentials) { + calls.push({ ...credentials }) + if (queue.length > 0) return Promise.resolve(queue.shift()!) + // No queued result → return a promise the test resolves manually (for + // single-flight timing tests). + return new Promise((resolve) => { + deferredResolvers.push(resolve) + }) + }, + } + return transport +} + +describe('createRuntimeTokenManager', () => { + describe('initial state', () => { + it('starts with no token', () => { + const m = createRuntimeTokenManager(makeTransport()) + expect(m.getToken()).toBeNull() + expect(m.hasToken()).toBe(false) + }) + }) + + describe('setSession / clear', () => { + it('adopts a token and reports hasToken', () => { + const m = createRuntimeTokenManager(makeTransport()) + m.setSession('tok-1', CREDS) + expect(m.getToken()).toBe('tok-1') + expect(m.hasToken()).toBe(true) + }) + + it('clear forgets the token and credentials (so refresh can no longer run)', async () => { + const m = createRuntimeTokenManager(makeTransport([{ success: true, token: 'x' }])) + m.setSession('tok-1', CREDS) + m.clear() + expect(m.getToken()).toBeNull() + expect(m.hasToken()).toBe(false) + // No credentials left → refresh is a no-op. + expect(await m.refresh()).toBe(false) + }) + + it('treats an empty-string token as not-held', () => { + const m = createRuntimeTokenManager(makeTransport()) + m.setSession('', CREDS) + expect(m.hasToken()).toBe(false) + }) + }) + + describe('refresh', () => { + it('returns false when there are no stored credentials', async () => { + const t = makeTransport() + const m = createRuntimeTokenManager(t) + expect(await m.refresh()).toBe(false) + expect(t.calls).toHaveLength(0) + }) + + it('re-authenticates with stored credentials and adopts the new token', async () => { + const t = makeTransport([{ success: true, token: 'tok-2' }]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + expect(await m.refresh()).toBe(true) + expect(m.getToken()).toBe('tok-2') + expect(t.calls).toEqual([CREDS]) + }) + + it('returns false and keeps the old token when the runtime rejects re-login', async () => { + const t = makeTransport([{ success: false, error: 'bad creds' }]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + expect(await m.refresh()).toBe(false) + expect(m.getToken()).toBe('tok-1') + }) + + it('returns false when login succeeds but yields no token', async () => { + const t = makeTransport([{ success: true }]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + expect(await m.refresh()).toBe(false) + expect(m.getToken()).toBe('tok-1') + }) + + it('treats a thrown transport error as a failed refresh', async () => { + const t: TokenLoginTransport = { + login: () => Promise.reject(new Error('network down')), + } + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + expect(await m.refresh()).toBe(false) + expect(m.getToken()).toBe('tok-1') + }) + + it('is single-flight: concurrent refreshes share one login call', async () => { + const t = makeTransport() // no queued results → manual resolution + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + + const a = m.refresh() + const b = m.refresh() + // Both joined the same in-flight login. + expect(t.pending).toBe(1) + + t.resolveNext({ success: true, token: 'tok-2' }) + expect(await a).toBe(true) + expect(await b).toBe(true) + expect(t.calls).toHaveLength(1) + expect(m.getToken()).toBe('tok-2') + }) + + it('allows a fresh refresh after a previous one settled', async () => { + const t = makeTransport([ + { success: true, token: 'tok-2' }, + { success: true, token: 'tok-3' }, + ]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + expect(await m.refresh()).toBe(true) + expect(await m.refresh()).toBe(true) + expect(t.calls).toHaveLength(2) + expect(m.getToken()).toBe('tok-3') + }) + }) + + describe('withAuth', () => { + it('runs the operation with the current token and returns its result when authorized', async () => { + const m = createRuntimeTokenManager(makeTransport()) + m.setSession('tok-1', CREDS) + const seen: string[] = [] + const result = await m.withAuth( + (token) => { + seen.push(token) + return Promise.resolve({ code: 200 }) + }, + (r) => r.code === 401, + ) + expect(result).toEqual({ code: 200 }) + expect(seen).toEqual(['tok-1']) + }) + + it('refreshes and retries once with the new token on an unauthorized result', async () => { + const t = makeTransport([{ success: true, token: 'tok-2' }]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + const seen: string[] = [] + const result = await m.withAuth( + (token) => { + seen.push(token) + return Promise.resolve({ code: token === 'tok-2' ? 200 : 401 }) + }, + (r) => r.code === 401, + ) + expect(result).toEqual({ code: 200 }) + expect(seen).toEqual(['tok-1', 'tok-2']) // first attempt, then retry with fresh token + }) + + it('returns the unauthorized result without retrying when refresh fails', async () => { + const t = makeTransport([{ success: false }]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + let attempts = 0 + const result = await m.withAuth( + () => { + attempts += 1 + return Promise.resolve({ code: 401 }) + }, + (r) => r.code === 401, + ) + expect(result).toEqual({ code: 401 }) + expect(attempts).toBe(1) // no retry because refresh failed + }) + + it('does not retry when the result is authorized even if a refresh would succeed', async () => { + const t = makeTransport([{ success: true, token: 'tok-2' }]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + const result = await m.withAuth( + () => Promise.resolve({ code: 200 }), + (r) => r.code === 401, + ) + expect(result).toEqual({ code: 200 }) + expect(t.calls).toHaveLength(0) // refresh never called + }) + + it('passes an empty string to the operation when there is no token yet', async () => { + const m = createRuntimeTokenManager(makeTransport()) + const seen: string[] = [] + await m.withAuth( + (token) => { + seen.push(token) + return Promise.resolve({ code: 200 }) + }, + (r) => r.code === 401, + ) + expect(seen).toEqual(['']) + }) + }) + + describe('onTokenChanged', () => { + it('notifies subscribers with the fresh token on refresh', async () => { + const t = makeTransport([{ success: true, token: 'tok-2' }]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + const seen: string[] = [] + m.onTokenChanged((tok) => seen.push(tok)) + await m.refresh() + expect(seen).toEqual(['tok-2']) + }) + + it('stops notifying after unsubscribe', async () => { + const t = makeTransport([ + { success: true, token: 'tok-2' }, + { success: true, token: 'tok-3' }, + ]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + const seen: string[] = [] + const unsubscribe = m.onTokenChanged((tok) => seen.push(tok)) + await m.refresh() + unsubscribe() + await m.refresh() + expect(seen).toEqual(['tok-2']) // second refresh not observed + }) + + it('does not notify when a refresh fails', async () => { + const t = makeTransport([{ success: false }]) + const m = createRuntimeTokenManager(t) + m.setSession('tok-1', CREDS) + const seen: string[] = [] + m.onTokenChanged((tok) => seen.push(tok)) + await m.refresh() + expect(seen).toEqual([]) + }) + }) +}) diff --git a/src/middleware/shared/runtime-auth/runtime-token-manager.ts b/src/middleware/shared/runtime-auth/runtime-token-manager.ts new file mode 100644 index 000000000..4c7885841 --- /dev/null +++ b/src/middleware/shared/runtime-auth/runtime-token-manager.ts @@ -0,0 +1,143 @@ +/** + * RuntimeTokenManager — the single, platform-agnostic authority for a runtime + * session's JWT. + * + * The OpenPLC runtime issues short-lived access tokens (≈15 min) with no + * server-side sliding expiration, so a long-but-active session will otherwise + * have its token age out mid-use. Historically each call path (status polling, + * start/stop, project upload, EtherCAT) tracked and refreshed the token on its + * own — or not at all — which is why stats could keep working while an upload + * silently 401'd. This manager centralizes all of that so every runtime call + * shares one token, one credential set, and one refresh path. + * + * It is intentionally dependency-free pure TS: the same code runs in the web + * renderer adapter AND in the editor's Electron main process. Only the + * `login` transport differs per platform (orchestrator POST vs. direct HTTPS), + * which is injected. + * + * Behavior must be IDENTICAL on both platforms — this file is part of the + * byte-identical shared surface between openplc-web and openplc-editor. + */ + +export interface RuntimeCredentials { + username: string + password: string +} + +export interface TokenLoginResult { + success: boolean + token?: string + error?: string +} + +export interface TokenLoginTransport { + /** + * Platform-specific authentication: POST the credentials to the runtime and + * resolve with a fresh access token. The manager calls this for the initial + * refresh wiring and for every re-authentication; it never inspects how the + * request is transported. + */ + login(credentials: RuntimeCredentials): Promise +} + +export interface RuntimeTokenManager { + /** The current access token, or null when not authenticated. */ + getToken(): string | null + /** Whether a usable token is currently held. */ + hasToken(): boolean + /** Adopt a token + the credentials that produced it (called on login). */ + setSession(token: string, credentials: RuntimeCredentials): void + /** Forget the token and credentials (called on logout). */ + clear(): void + /** + * Re-authenticate with the stored credentials and adopt the fresh token. + * Resolves false when there is nothing to re-authenticate with or the runtime + * rejects the re-login. Concurrent calls share a single in-flight request + * (single-flight) so a burst of expired calls triggers exactly one re-login. + */ + refresh(): Promise + /** + * Run an authenticated operation with the current token. If the result is + * unauthorized, refresh once and retry exactly once with the new token. + * `isUnauthorized` lets the caller classify its own transport's result shape + * (HTTP 401/403, etc.) without this module knowing the transport. + */ + withAuth(operation: (token: string) => Promise, isUnauthorized: (result: T) => boolean): Promise + /** + * Subscribe to token changes (every successful refresh). Returns an + * unsubscribe function. Used to keep mirrors (e.g. the store connection flag) + * in sync. + */ + onTokenChanged(callback: (newToken: string) => void): () => void +} + +export function createRuntimeTokenManager(transport: TokenLoginTransport): RuntimeTokenManager { + let token: string | null = null + let credentials: RuntimeCredentials | null = null + let refreshInFlight: Promise | null = null + const subscribers = new Set<(newToken: string) => void>() + + function getToken(): string | null { + return token + } + + function hasToken(): boolean { + return token !== null && token !== '' + } + + function setSession(newToken: string, newCredentials: RuntimeCredentials): void { + token = newToken + credentials = newCredentials + } + + function clear(): void { + token = null + credentials = null + } + + function refresh(): Promise { + // Single-flight: a second caller that arrives while a re-login is in + // progress joins the same promise instead of firing another login. + if (refreshInFlight) return refreshInFlight + if (!credentials) return Promise.resolve(false) + + const pending = transport + .login(credentials) + .then((result) => { + if (result.success && result.token) { + token = result.token + const fresh = result.token + subscribers.forEach((cb) => cb(fresh)) + return true + } + return false + }) + .catch(() => false) + .finally(() => { + refreshInFlight = null + }) + + refreshInFlight = pending + return pending + } + + async function withAuth( + operation: (token: string) => Promise, + isUnauthorized: (result: T) => boolean, + ): Promise { + const result = await operation(token ?? '') + if (isUnauthorized(result) && (await refresh())) { + return operation(token ?? '') + } + return result + } + + function onTokenChanged(callback: (newToken: string) => void): () => void { + subscribers.add(callback) + return () => { + subscribers.delete(callback) + } + } + + return { getToken, hasToken, setSession, clear, refresh, withAuth, onTokenChanged } +} From 43fdd2c278ba676d03e6844c431e6418c05a838f Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Wed, 24 Jun 2026 22:16:17 -0400 Subject: [PATCH 06/35] refactor(runtime-auth): drop the now-inert jwtToken from the editor IPC surface With the main process as the token authority, the renderer no longer holds or passes a token. Removes jwtToken from the runtime + EtherCAT IPC handlers, the renderer bridge signatures, and the editor adapter (which now tracks only a loggedIn flag for isReadyForDebug); makeRuntimeApiRequest/Post drop the param too. The debugger's separate connectionParams.jwtToken is untouched. deviceContext.jwt is kept solely as the 'are we logged in' gate for the upload phase; the compile pipeline no longer threads it into runtime calls. Brings the rewritten editor runtime-adapter to 100% coverage (adds the previously-untested EtherCAT delegators). tsc --build, eslint, prettier clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/compiler/compiler-module.ts | 2 - .../compiler/editor-compiler-platform-port.ts | 8 +- src/main/modules/ipc/main.ts | 44 ++----- src/main/modules/ipc/renderer.ts | 41 +++---- .../editor/__tests__/runtime-adapter.test.ts | 109 ++++++++++++++---- .../adapters/editor/runtime-adapter.ts | 46 ++++---- 6 files changed, 135 insertions(+), 115 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 636cca3b0..c8b914fc9 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -50,7 +50,6 @@ import type { KnownPou } from '@root/backend/shared/utils/PLC/split-program-st' type LibraryCompileBridge = { makeRuntimeApiRequest: ( ipAddress: string, - jwtToken: string, endpoint: string, responseParser?: (data: string) => T, ) => Promise<{ success: true; data?: T } | { success: false; error: string }> @@ -2402,7 +2401,6 @@ class CompilerModule { mainProcessBridge: { makeRuntimeApiRequest: ( ipAddress: string, - jwtToken: string, endpoint: string, responseParser?: (data: string) => T, ) => Promise<{ success: true; data?: T } | { success: false; error: string }> diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index 1e23873cb..5dc1a2116 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -106,7 +106,6 @@ export interface EditorCompilerPlatformPortContext { mainProcessBridge: { makeRuntimeApiRequest: ( ipAddress: string, - jwtToken: string, endpoint: string, responseParser?: (data: string) => T, ) => Promise<{ success: true; data?: T } | { success: false; error: string }> @@ -403,7 +402,7 @@ export function createEditorCompilerPlatformPort( status: string logs: string[] exit_code: number | null - }>(deviceContext.ip, deviceContext.jwt, '/api/compilation-status', (data: string) => { + }>(deviceContext.ip, '/api/compilation-status', (data: string) => { return JSON.parse(data) as { status: string; logs: string[]; exit_code: number | null } }) if (!result.success) return { success: false, error: result.error } @@ -412,7 +411,6 @@ export function createEditorCompilerPlatformPort( fetchStartResponse: async () => { const result = await context.mainProcessBridge.makeRuntimeApiRequest( deviceContext.ip, - deviceContext.jwt, '/api/start-plc', (data: string) => { const parsed = JSON.parse(data) as { status?: string } @@ -504,7 +502,7 @@ export function createEditorCompilerPlatformPort( status: string logs: string[] exit_code: number | null - }>(deviceContext.ip, deviceContext.jwt, '/api/compilation-status', (data: string) => { + }>(deviceContext.ip, '/api/compilation-status', (data: string) => { return JSON.parse(data) as { status: string; logs: string[]; exit_code: number | null } }) if (!result.success) return { success: false, error: result.error } @@ -513,7 +511,6 @@ export function createEditorCompilerPlatformPort( fetchStartResponse: async () => { const result = await context.mainProcessBridge.makeRuntimeApiRequest( deviceContext.ip, - deviceContext.jwt, '/api/start-plc', (data: string) => { const parsed = JSON.parse(data) as { status?: string } @@ -553,7 +550,6 @@ export function createEditorCompilerPlatformPort( fetchVersion: async () => { const result = await context.mainProcessBridge.makeRuntimeApiRequest<{ version: string }>( deviceContext.ip, - '', // unauthenticated probe '/api/version', (data: string) => JSON.parse(data) as { version: string }, ) diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 596cc64b7..fe535a899 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -290,13 +290,10 @@ class MainProcessBridge implements MainIpcModule { async makeRuntimeApiRequest( ipAddress: string, - _jwtToken: string, endpoint: string, responseParser?: (data: string) => T, ): Promise<{ success: true; data?: T } | { success: false; error: string }> { - // The token authority owns the live token + refresh; the legacy `_jwtToken` - // arg from the renderer is ignored (kept for signature compatibility and - // removed in the follow-up cleanup). + // The token authority owns the live token + refresh. type Raw = { success: true; data?: T } | { success: false; error: string; statusCode?: number } const url = this.runtimeUrl(ipAddress, endpoint) const result = await this.tokens.withAuth( @@ -330,13 +327,12 @@ class MainProcessBridge implements MainIpcModule { */ makeRuntimeApiPostRequest( ipAddress: string, - _jwtToken: string, endpoint: string, body: string, responseParser: (data: string) => T, timeoutMs?: number, ): Promise<{ success: true; data: T } | { success: false; error: string }> { - // Token + refresh owned by the authority; `_jwtToken` is ignored. + // Token + refresh owned by the authority. type PostResult = { success: true; data: T } | { success: false; error: string; statusCode?: number } const doRequest = (token: string): Promise => { @@ -476,12 +472,7 @@ class MainProcessBridge implements MainIpcModule { }) } - handleRuntimeGetStatus = async ( - _event: IpcMainInvokeEvent, - ipAddress: string, - jwtToken: string, - includeStats?: boolean, - ) => { + handleRuntimeGetStatus = async (_event: IpcMainInvokeEvent, ipAddress: string, includeStats?: boolean) => { try { // Build the endpoint path with optional include_stats query parameter const endpoint = includeStats ? '/api/status?include_stats=true' : '/api/status' @@ -517,7 +508,7 @@ class MainProcessBridge implements MainIpcModule { const result = await this.makeRuntimeApiRequest<{ status: string timing_stats?: TimingStatsResponse - }>(ipAddress, jwtToken, endpoint, (data: string) => { + }>(ipAddress, endpoint, (data: string) => { const response = JSON.parse(data) as { status: string timing_stats?: TimingStatsResponse @@ -554,7 +545,7 @@ class MainProcessBridge implements MainIpcModule { } } - handleRuntimeStartPlc = async (_event: IpcMainInvokeEvent, ipAddress: string, jwtToken: string) => { + handleRuntimeStartPlc = async (_event: IpcMainInvokeEvent, ipAddress: string) => { try { // Parse the body so the renderer can drive a retry-on-BUSY // loop around `COMMAND:BUSY` replies (the runtime answers BUSY @@ -562,7 +553,6 @@ class MainProcessBridge implements MainIpcModule { // upload). See `backend/shared/library/start-plc-after-build.ts`. const result = await this.makeRuntimeApiRequest<{ status?: string }>( ipAddress, - jwtToken, '/api/start-plc', (data: string) => JSON.parse(data) as { status?: string }, ) @@ -574,19 +564,18 @@ class MainProcessBridge implements MainIpcModule { } } - handleRuntimeStopPlc = async (_event: IpcMainInvokeEvent, ipAddress: string, jwtToken: string) => { + handleRuntimeStopPlc = async (_event: IpcMainInvokeEvent, ipAddress: string) => { try { - return await this.makeRuntimeApiRequest(ipAddress, jwtToken, '/api/stop-plc') + return await this.makeRuntimeApiRequest(ipAddress, '/api/stop-plc') } catch (error) { return { success: false, error: getErrorMessage(error) } } } - handleRuntimeGetCompilationStatus = async (_event: IpcMainInvokeEvent, ipAddress: string, jwtToken: string) => { + handleRuntimeGetCompilationStatus = async (_event: IpcMainInvokeEvent, ipAddress: string) => { try { const result = await this.makeRuntimeApiRequest<{ status: string; logs: string[]; exit_code: number | null }>( ipAddress, - jwtToken, '/api/compilation-status', (data: string) => { const response = JSON.parse(data) as { status: string; logs: string[]; exit_code: number | null } @@ -599,12 +588,11 @@ class MainProcessBridge implements MainIpcModule { } } - handleRuntimeGetLogs = async (_event: IpcMainInvokeEvent, ipAddress: string, jwtToken: string, minId?: number) => { + handleRuntimeGetLogs = async (_event: IpcMainInvokeEvent, ipAddress: string, minId?: number) => { try { const endpoint = minId !== undefined ? `/api/runtime-logs?id=${minId}` : '/api/runtime-logs' const result = await this.makeRuntimeApiRequest( ipAddress, - jwtToken, endpoint, (data: string) => { const response = JSON.parse(data) as { 'runtime-logs': string | RuntimeLogEntry[] } @@ -768,12 +756,10 @@ class MainProcessBridge implements MainIpcModule { handleRuntimeGetSerialPorts = async ( _event: IpcMainInvokeEvent, ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean; ports?: Array<{ device: string; description?: string }>; error?: string }> => { try { const result = await this.makeRuntimeApiRequest<{ ports: Array<{ device: string; description?: string }> }>( ipAddress, - jwtToken, '/api/serial-ports', (data: string) => { const response = JSON.parse(data) as { @@ -1976,12 +1962,10 @@ class MainProcessBridge implements MainIpcModule { handleEtherCATGetInterfaces = async ( _event: IpcMainInvokeEvent, ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean; data?: NetworkInterface[]; error?: string }> => { try { const result = await this.makeRuntimeApiRequest<{ interfaces: NetworkInterface[] }>( ipAddress, - jwtToken, '/api/discovery/interfaces', (data: string) => { const response = JSON.parse(data) as { status: string; interfaces: NetworkInterface[] } @@ -2001,12 +1985,10 @@ class MainProcessBridge implements MainIpcModule { handleEtherCATGetStatus = async ( _event: IpcMainInvokeEvent, ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean; data?: EtherCATServiceStatusResponse; error?: string }> => { try { const result = await this.makeRuntimeApiRequest( ipAddress, - jwtToken, '/api/discovery/ethercat/status', (data: string) => { const parsed = JSON.parse(data) as unknown @@ -2034,7 +2016,6 @@ class MainProcessBridge implements MainIpcModule { handleEtherCATScan = async ( _event: IpcMainInvokeEvent, ipAddress: string, - jwtToken: string, scanRequest: EtherCATScanRequest, ): Promise<{ success: boolean; data?: EtherCATScanResponse; error?: string }> => { try { @@ -2047,7 +2028,6 @@ class MainProcessBridge implements MainIpcModule { const result = await this.makeRuntimeApiPostRequest( ipAddress, - jwtToken, '/api/plugin-command', postData, (data: string) => { @@ -2076,7 +2056,6 @@ class MainProcessBridge implements MainIpcModule { handleEtherCATTest = async ( _event: IpcMainInvokeEvent, ipAddress: string, - jwtToken: string, testRequest: EtherCATTestRequest, ): Promise<{ success: boolean; data?: EtherCATTestResponse; error?: string }> => { try { @@ -2085,7 +2064,6 @@ class MainProcessBridge implements MainIpcModule { const result = await this.makeRuntimeApiPostRequest( ipAddress, - jwtToken, '/api/discovery/ethercat/test', postData, (data: string) => JSON.parse(data) as EtherCATTestResponse, @@ -2104,7 +2082,6 @@ class MainProcessBridge implements MainIpcModule { handleEtherCATValidate = async ( _event: IpcMainInvokeEvent, ipAddress: string, - jwtToken: string, validateRequest: EtherCATValidateRequest, ): Promise<{ success: boolean; data?: EtherCATValidateResponse; error?: string }> => { try { @@ -2112,7 +2089,6 @@ class MainProcessBridge implements MainIpcModule { const result = await this.makeRuntimeApiPostRequest( ipAddress, - jwtToken, '/api/discovery/ethercat/validate', postData, (data: string) => JSON.parse(data) as EtherCATValidateResponse, @@ -2130,7 +2106,6 @@ class MainProcessBridge implements MainIpcModule { handleEtherCATGetRuntimeStatus = async ( _event: IpcMainInvokeEvent, ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean; data?: EtherCATRuntimeStatusResponse; error?: string }> => { try { const postData = JSON.stringify({ @@ -2140,7 +2115,6 @@ class MainProcessBridge implements MainIpcModule { const result = await this.makeRuntimeApiPostRequest( ipAddress, - jwtToken, '/api/plugin-command', postData, (data: string) => { diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 1017eeb69..058663db4 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -454,7 +454,6 @@ const rendererProcessBridge = { ipcRenderer.invoke('runtime:login', ipAddress, username, password), runtimeGetStatus: ( ipAddress: string, - jwtToken: string, includeStats?: boolean, ): Promise<{ success: boolean @@ -476,34 +475,28 @@ const rendererProcessBridge = { }> } error?: string - }> => ipcRenderer.invoke('runtime:get-status', ipAddress, jwtToken, includeStats), - runtimeStartPlc: ( - ipAddress: string, - jwtToken: string, - ): Promise<{ success: boolean; error?: string; status?: string }> => - ipcRenderer.invoke('runtime:start-plc', ipAddress, jwtToken), - runtimeStopPlc: (ipAddress: string, jwtToken: string): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('runtime:stop-plc', ipAddress, jwtToken), + }> => ipcRenderer.invoke('runtime:get-status', ipAddress, includeStats), + runtimeStartPlc: (ipAddress: string): Promise<{ success: boolean; error?: string; status?: string }> => + ipcRenderer.invoke('runtime:start-plc', ipAddress), + runtimeStopPlc: (ipAddress: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('runtime:stop-plc', ipAddress), runtimeGetCompilationStatus: ( ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean data?: { status: string; logs: string[]; exit_code: number | null } error?: string - }> => ipcRenderer.invoke('runtime:get-compilation-status', ipAddress, jwtToken), + }> => ipcRenderer.invoke('runtime:get-compilation-status', ipAddress), runtimeGetLogs: ( ipAddress: string, - jwtToken: string, minId?: number, ): Promise<{ success: boolean; logs?: string | RuntimeLogEntry[]; error?: string }> => - ipcRenderer.invoke('runtime:get-logs', ipAddress, jwtToken, minId), + ipcRenderer.invoke('runtime:get-logs', ipAddress, minId), runtimeClearCredentials: (): Promise<{ success: boolean }> => ipcRenderer.invoke('runtime:clear-credentials'), runtimeGetSerialPorts: ( ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean; ports?: Array<{ device: string; description?: string }>; error?: string }> => - ipcRenderer.invoke('runtime:get-serial-ports', ipAddress, jwtToken), + ipcRenderer.invoke('runtime:get-serial-ports', ipAddress), runtimeDiscoverDevices: (opts?: { durationMs?: number }): Promise<{ success: boolean; devices?: DiscoveredRuntimeDevice[]; error?: string }> => @@ -520,42 +513,36 @@ const rendererProcessBridge = { // ===================== ETHERCAT DISCOVERY METHODS ===================== etherCATGetInterfaces: ( ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean; data?: NetworkInterface[]; error?: string }> => - ipcRenderer.invoke('ethercat:get-interfaces', ipAddress, jwtToken), + ipcRenderer.invoke('ethercat:get-interfaces', ipAddress), etherCATGetStatus: ( ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean; data?: EtherCATServiceStatusResponse; error?: string }> => - ipcRenderer.invoke('ethercat:get-status', ipAddress, jwtToken), + ipcRenderer.invoke('ethercat:get-status', ipAddress), etherCATScan: ( ipAddress: string, - jwtToken: string, scanRequest: EtherCATScanRequest, ): Promise<{ success: boolean; data?: EtherCATScanResponse; error?: string }> => - ipcRenderer.invoke('ethercat:scan', ipAddress, jwtToken, scanRequest), + ipcRenderer.invoke('ethercat:scan', ipAddress, scanRequest), etherCATTest: ( ipAddress: string, - jwtToken: string, testRequest: EtherCATTestRequest, ): Promise<{ success: boolean; data?: EtherCATTestResponse; error?: string }> => - ipcRenderer.invoke('ethercat:test', ipAddress, jwtToken, testRequest), + ipcRenderer.invoke('ethercat:test', ipAddress, testRequest), etherCATValidate: ( ipAddress: string, - jwtToken: string, validateRequest: EtherCATValidateRequest, ): Promise<{ success: boolean; data?: EtherCATValidateResponse; error?: string }> => - ipcRenderer.invoke('ethercat:validate', ipAddress, jwtToken, validateRequest), + ipcRenderer.invoke('ethercat:validate', ipAddress, validateRequest), etherCATGetRuntimeStatus: ( ipAddress: string, - jwtToken: string, ): Promise<{ success: boolean; data?: EtherCATRuntimeStatusResponse; error?: string }> => - ipcRenderer.invoke('ethercat:get-runtime-status', ipAddress, jwtToken), + ipcRenderer.invoke('ethercat:get-runtime-status', ipAddress), // ===================== ESI REPOSITORY METHODS ===================== esiLoadRepositoryIndex: ( diff --git a/src/middleware/adapters/editor/__tests__/runtime-adapter.test.ts b/src/middleware/adapters/editor/__tests__/runtime-adapter.test.ts index 9079e525a..6090ac0eb 100644 --- a/src/middleware/adapters/editor/__tests__/runtime-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/runtime-adapter.test.ts @@ -24,6 +24,12 @@ beforeEach(() => { onRuntimeTokenRefreshed: jest.fn().mockImplementation(() => jest.fn()), runtimeDiscoverDevices: jest.fn().mockResolvedValue({ success: true, devices: [] }), onRuntimeDeviceDiscovered: jest.fn().mockImplementation(() => jest.fn()), + etherCATGetInterfaces: jest.fn().mockResolvedValue({ success: true, data: [] }), + etherCATGetStatus: jest.fn().mockResolvedValue({ success: true, data: {} }), + etherCATScan: jest.fn().mockResolvedValue({ success: true, data: {} }), + etherCATTest: jest.fn().mockResolvedValue({ success: true, data: {} }), + etherCATValidate: jest.fn().mockResolvedValue({ success: true, data: {} }), + etherCATGetRuntimeStatus: jest.fn().mockResolvedValue({ success: true, data: {} }), } as unknown as typeof window.bridge adapter = createEditorRuntimeAdapter(() => mockIpAddress) @@ -41,20 +47,19 @@ describe('login', () => { expect(result).toEqual({ success: true, accessToken: 'jwt-token-123' }) }) - it('stores JWT token internally on success', async () => { + it('marks the session active on success (token lives in main)', async () => { await adapter.login({ username: 'admin', password: 'secret' }) - - // Subsequent calls should use the stored token + // The renderer no longer passes a token; main owns it. Login flips the + // session-active flag the debugger readiness check relies on. + expect(adapter.isReadyForDebug!()).toBe(true) await adapter.getStatus() - expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', 'jwt-token-123', undefined) + expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', undefined) }) - it('does not store token on failure', async () => { + it('does not mark the session active on failure', async () => { ;(window.bridge.runtimeLogin as jest.Mock).mockResolvedValue({ success: false, error: 'Bad password' }) await adapter.login({ username: 'admin', password: 'wrong' }) - - await adapter.getStatus() - expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', '', undefined) + expect(adapter.isReadyForDebug!()).toBe(false) }) it('returns error when no IP configured', async () => { @@ -136,14 +141,14 @@ describe('getStatus', () => { await adapter.login({ username: 'admin', password: 'secret' }) const result = await adapter.getStatus(true) - expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', 'jwt-token-123', true) + expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', true) expect(result).toEqual({ success: true, status: 'RUNNING' }) }) it('passes undefined for includeStats when omitted', async () => { await adapter.getStatus() - expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', '', undefined) + expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', undefined) }) it('returns error when no IP configured', async () => { @@ -170,7 +175,7 @@ describe('startPlc', () => { await adapter.login({ username: 'admin', password: 'secret' }) const result = await adapter.startPlc() - expect(window.bridge.runtimeStartPlc).toHaveBeenCalledWith('192.168.1.100', 'jwt-token-123') + expect(window.bridge.runtimeStartPlc).toHaveBeenCalledWith('192.168.1.100') expect(result).toEqual({ success: true }) }) @@ -198,7 +203,7 @@ describe('stopPlc', () => { await adapter.login({ username: 'admin', password: 'secret' }) const result = await adapter.stopPlc() - expect(window.bridge.runtimeStopPlc).toHaveBeenCalledWith('192.168.1.100', 'jwt-token-123') + expect(window.bridge.runtimeStopPlc).toHaveBeenCalledWith('192.168.1.100') expect(result).toEqual({ success: true }) }) @@ -226,14 +231,14 @@ describe('getLogs', () => { await adapter.login({ username: 'admin', password: 'secret' }) const result = await adapter.getLogs(42) - expect(window.bridge.runtimeGetLogs).toHaveBeenCalledWith('192.168.1.100', 'jwt-token-123', 42) + expect(window.bridge.runtimeGetLogs).toHaveBeenCalledWith('192.168.1.100', 42) expect(result).toEqual({ success: true, logs: [] }) }) it('passes undefined minId when omitted', async () => { await adapter.getLogs() - expect(window.bridge.runtimeGetLogs).toHaveBeenCalledWith('192.168.1.100', '', undefined) + expect(window.bridge.runtimeGetLogs).toHaveBeenCalledWith('192.168.1.100', undefined) }) it('returns error when no IP configured', async () => { @@ -263,7 +268,7 @@ describe('getSerialPorts', () => { await adapter.login({ username: 'admin', password: 'secret' }) const result = await adapter.getSerialPorts() - expect(window.bridge.runtimeGetSerialPorts).toHaveBeenCalledWith('192.168.1.100', 'jwt-token-123') + expect(window.bridge.runtimeGetSerialPorts).toHaveBeenCalledWith('192.168.1.100') expect(result).toEqual({ success: true, ports }) }) @@ -291,7 +296,7 @@ describe('getCompilationStatus', () => { await adapter.login({ username: 'admin', password: 'secret' }) const result = await adapter.getCompilationStatus() - expect(window.bridge.runtimeGetCompilationStatus).toHaveBeenCalledWith('192.168.1.100', 'jwt-token-123') + expect(window.bridge.runtimeGetCompilationStatus).toHaveBeenCalledWith('192.168.1.100') expect(result).toEqual({ success: true, data: { status: 'SUCCESS', logs: [], exit_code: 0 } }) }) @@ -324,7 +329,7 @@ describe('clearCredentials', () => { // Subsequent calls should use empty token await adapter.getStatus() - expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', '', undefined) + expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', undefined) }) }) @@ -344,7 +349,9 @@ describe('onTokenRefreshed', () => { expect(unsub).toBe(unsubscribe) }) - it('updates internal JWT when token is refreshed', async () => { + it('forwards the refreshed token from main to the callback', () => { + // The token lives in the main process now; the adapter only relays the + // refresh notification so the renderer store flag can track it. let bridgeHandler: ((_event: unknown, newToken: string) => void) | null = null ;(window.bridge.onRuntimeTokenRefreshed as jest.Mock).mockImplementation( (handler: (_event: unknown, newToken: string) => void) => { @@ -356,14 +363,9 @@ describe('onTokenRefreshed', () => { const callback = jest.fn() adapter.onTokenRefreshed!(callback) - // Simulate token refresh from main process bridgeHandler!({}, 'new-token-456') expect(callback).toHaveBeenCalledWith('new-token-456') - - // Subsequent calls should use the refreshed token - await adapter.getStatus() - expect(window.bridge.runtimeGetStatus).toHaveBeenCalledWith('192.168.1.100', 'new-token-456', undefined) }) }) @@ -436,6 +438,67 @@ describe('onDeviceDiscovered', () => { }) }) +// --------------------------------------------------------------------------- +// EtherCAT discovery methods — thin bridge delegators (no token; main owns it) +// --------------------------------------------------------------------------- + +describe('EtherCAT discovery methods', () => { + const cases: Array<{ + name: string + bridge: keyof typeof window.bridge + invoke: (a: RuntimePort) => Promise + expectArgs: unknown[] + }> = [ + { + name: 'getNetworkInterfaces', + bridge: 'etherCATGetInterfaces', + invoke: (a) => a.getNetworkInterfaces!(), + expectArgs: ['192.168.1.100'], + }, + { + name: 'getEthercatServiceStatus', + bridge: 'etherCATGetStatus', + invoke: (a) => a.getEthercatServiceStatus!(), + expectArgs: ['192.168.1.100'], + }, + { + name: 'scanEthercatDevices', + bridge: 'etherCATScan', + invoke: (a) => a.scanEthercatDevices!({ interface: 'eth0' } as never), + expectArgs: ['192.168.1.100', { interface: 'eth0' }], + }, + { + name: 'testEthercatConnection', + bridge: 'etherCATTest', + invoke: (a) => a.testEthercatConnection!({ slave: 1 } as never), + expectArgs: ['192.168.1.100', { slave: 1 }], + }, + { + name: 'validateEthercatConfig', + bridge: 'etherCATValidate', + invoke: (a) => a.validateEthercatConfig!({ config: {} } as never), + expectArgs: ['192.168.1.100', { config: {} }], + }, + { + name: 'getEthercatRuntimeStatus', + bridge: 'etherCATGetRuntimeStatus', + invoke: (a) => a.getEthercatRuntimeStatus!(), + expectArgs: ['192.168.1.100'], + }, + ] + + it.each(cases)('$name delegates to the bridge without a token', async ({ bridge, invoke, expectArgs }) => { + await invoke(adapter) + expect(window.bridge[bridge]).toHaveBeenCalledWith(...expectArgs) + }) + + it.each(cases)('$name surfaces a bridge error', async ({ bridge, invoke }) => { + ;(window.bridge[bridge] as jest.Mock).mockRejectedValueOnce(new Error('boom')) + const result = (await invoke(adapter)) as { success: boolean; error?: string } + expect(result).toEqual({ success: false, error: 'boom' }) + }) +}) + describe('isReadyForDebug', () => { it('returns false when no IP and no token', () => { mockIpAddress = '' diff --git a/src/middleware/adapters/editor/runtime-adapter.ts b/src/middleware/adapters/editor/runtime-adapter.ts index 04beacfad..c2e953f84 100644 --- a/src/middleware/adapters/editor/runtime-adapter.ts +++ b/src/middleware/adapters/editor/runtime-adapter.ts @@ -8,8 +8,11 @@ * Connection context: * - IP address: provided via getIpAddress() callback injected at creation. * Set by the store/UI when the user configures the device. - * - JWT token: managed internally. Stored after successful login(), - * updated on token-refresh events, cleared on clearCredentials(). + * - JWT token: owned entirely by the main process (the single token + * authority). The renderer no longer holds or passes the token — main + * injects it into every runtime call and refreshes it on expiry, then + * notifies the renderer via onTokenRefreshed. This adapter only tracks + * whether a session is active (for isReadyForDebug). */ import { getErrorMessage } from '../../../frontend/utils/get-error-message' @@ -28,7 +31,9 @@ import type { import type { SerialPort, Unsubscribe } from '../../shared/ports/types' export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimePort { - let jwtToken = '' + // Whether a runtime session is active. The token itself lives in the main + // process; this only gates isReadyForDebug. + let loggedIn = false function requireIp(): string { const ip = getIpAddress() @@ -38,7 +43,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP return { isReadyForDebug() { - return getIpAddress() !== '' && jwtToken !== '' + return getIpAddress() !== '' && loggedIn }, async login(params: LoginParams): Promise { @@ -46,7 +51,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP const ip = requireIp() const result = await window.bridge.runtimeLogin(ip, params.username, params.password) if (result.success && result.accessToken) { - jwtToken = result.accessToken + loggedIn = true } return result } catch (err) { @@ -75,7 +80,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async getStatus(includeStats?: boolean): Promise { try { const ip = requireIp() - return await window.bridge.runtimeGetStatus(ip, jwtToken, includeStats) + return await window.bridge.runtimeGetStatus(ip, includeStats) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -84,7 +89,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async startPlc() { try { const ip = requireIp() - return await window.bridge.runtimeStartPlc(ip, jwtToken) + return await window.bridge.runtimeStartPlc(ip) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -93,7 +98,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async stopPlc() { try { const ip = requireIp() - return await window.bridge.runtimeStopPlc(ip, jwtToken) + return await window.bridge.runtimeStopPlc(ip) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -102,7 +107,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async getLogs(minId?: number): Promise { try { const ip = requireIp() - return await window.bridge.runtimeGetLogs(ip, jwtToken, minId) + return await window.bridge.runtimeGetLogs(ip, minId) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -111,7 +116,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async getSerialPorts(): Promise<{ success: boolean; ports?: SerialPort[]; error?: string }> { try { const ip = requireIp() - return await window.bridge.runtimeGetSerialPorts(ip, jwtToken) + return await window.bridge.runtimeGetSerialPorts(ip) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -120,22 +125,19 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async getCompilationStatus(): Promise { try { const ip = requireIp() - return await window.bridge.runtimeGetCompilationStatus(ip, jwtToken) + return await window.bridge.runtimeGetCompilationStatus(ip) } catch (err) { return { success: false, error: getErrorMessage(err) } } }, async clearCredentials() { - jwtToken = '' + loggedIn = false return window.bridge.runtimeClearCredentials() }, onTokenRefreshed(callback: (newToken: string) => void): Unsubscribe { - const handler = (_event: unknown, newToken: string) => { - jwtToken = newToken - callback(newToken) - } + const handler = (_event: unknown, newToken: string) => callback(newToken) return window.bridge.onRuntimeTokenRefreshed(handler) }, @@ -144,7 +146,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async getNetworkInterfaces() { try { const ip = requireIp() - return await window.bridge.etherCATGetInterfaces(ip, jwtToken) + return await window.bridge.etherCATGetInterfaces(ip) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -153,7 +155,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async getEthercatServiceStatus() { try { const ip = requireIp() - return await window.bridge.etherCATGetStatus(ip, jwtToken) + return await window.bridge.etherCATGetStatus(ip) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -162,7 +164,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async scanEthercatDevices(request) { try { const ip = requireIp() - return await window.bridge.etherCATScan(ip, jwtToken, request) + return await window.bridge.etherCATScan(ip, request) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -171,7 +173,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async testEthercatConnection(request) { try { const ip = requireIp() - return await window.bridge.etherCATTest(ip, jwtToken, request) + return await window.bridge.etherCATTest(ip, request) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -180,7 +182,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async validateEthercatConfig(request) { try { const ip = requireIp() - return await window.bridge.etherCATValidate(ip, jwtToken, request) + return await window.bridge.etherCATValidate(ip, request) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -189,7 +191,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async getEthercatRuntimeStatus() { try { const ip = requireIp() - return await window.bridge.etherCATGetRuntimeStatus(ip, jwtToken) + return await window.bridge.etherCATGetRuntimeStatus(ip) } catch (err) { return { success: false, error: getErrorMessage(err) } } From b38a5a3055fde08e7846b87a315cca327d12d7e3 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Wed, 24 Jun 2026 22:22:51 -0400 Subject: [PATCH 07/35] chore(arch): map middleware/shared/runtime-auth to the utils layer Keeps the editor layer validator aligned with web's classification of the shared RuntimeTokenManager directory. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__architecture__/validate.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/__architecture__/validate.ts b/src/__architecture__/validate.ts index 437418d2e..e498384df 100644 --- a/src/__architecture__/validate.ts +++ b/src/__architecture__/validate.ts @@ -155,6 +155,10 @@ function getLayer(filePath: string): LayerName | null { // openplc-web parity explicit (this folder is byte-identical // between repos). if (rel.startsWith('middleware/shared/utils/')) return 'utils' + // Shared runtime-auth (RuntimeTokenManager) is pure, dependency-free logic + // reachable from adapters/backend/main on both platforms — same `utils` rule + // set, byte-identical between repos. + if (rel.startsWith('middleware/shared/runtime-auth/')) return 'utils' if (rel.match(/^middleware\/adapters\/[^/]+\/components\//)) return 'adapter-components' if (rel.startsWith('middleware/adapters/')) return 'adapters' From 1bc576ed0cc80c39ea3dcac3e45e9e6170e8cde2 Mon Sep 17 00:00:00 2001 From: Manuele Conti Date: Thu, 25 Jun 2026 23:13:47 +0200 Subject: [PATCH 08/35] fix: remove stale renderer app layout --- .../components/_templates/app-layout.tsx | 97 ------------------- 1 file changed, 97 deletions(-) delete mode 100644 src/renderer/components/_templates/app-layout.tsx diff --git a/src/renderer/components/_templates/app-layout.tsx b/src/renderer/components/_templates/app-layout.tsx deleted file mode 100644 index 802e80a16..000000000 --- a/src/renderer/components/_templates/app-layout.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { - ConfirmDeleteModalProps, - SaveChangeModalProps, - SaveChangesFileModalData, -} from '@root/renderer/components/_organisms/modals' -import { TitleBar } from '@root/renderer/components/_organisms/title-bar' -import { useOpenPLCStore } from '@root/renderer/store' -import { cn } from '@root/utils' -import { ComponentPropsWithoutRef, ReactNode, useEffect, useState } from 'react' - -import Toaster from '../_features/[app]/toast/toaster' -import { ProjectModal } from '../_features/[start]/new-project/project-modal' -import { - ConfirmDeleteElementModal, - QuitApplicationModal, - SaveChangesFileModal, - SaveChangesModal, -} from '../_organisms/modals' -import { AcceleratorHandler } from './accelerator-handler' - -type AppLayoutProps = ComponentPropsWithoutRef<'main'> -const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { - const [isLinux, setIsLinux] = useState(true) - const { - modals, - workspaceActions: { setSystemConfigs, setRecent }, - } = useOpenPLCStore() - - useEffect(() => { - const getUserSystemProps = async () => { - try { - const { OS, architecture, prefersDarkMode, isWindowMaximized } = await window.bridge.getSystemInfo() - setSystemConfigs({ - OS, - arch: architecture, - shouldUseDarkMode: prefersDarkMode, - isWindowMaximized, - }) - if (OS === 'darwin' || OS === 'win32') { - setIsLinux(false) - } - } catch (error) { - console.error('Failed to read system info during app layout initialization:', error) - } - - try { - const recent = await window.bridge.retrieveRecent() - setRecent(recent) - } catch (error) { - console.error('Failed to read recent projects during app layout initialization:', error) - } - } - void getUserSystemProps() - }, [setRecent, setSystemConfigs]) - - return ( - <> - {!isLinux && } -
- {children} - - {modals?.['create-project']?.open === true && } - {modals?.['save-changes-project']?.open === true && ( - - )} - {modals?.['save-changes-file']?.open === true && ( - - )} - {modals?.['quit-application']?.open === true && ( - - )} - {modals?.['confirm-delete-element']?.open === true && ( - - )} - -
- - ) -} - -export { AppLayout } From ccf45bcaddb604371d978479da5e3391c0ee6914 Mon Sep 17 00:00:00 2001 From: Manuele Conti Date: Thu, 25 Jun 2026 23:29:37 +0200 Subject: [PATCH 09/35] test: cover destroyed window system info guard --- src/main/modules/ipc/main.spec.ts | 104 ++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/main/modules/ipc/main.spec.ts diff --git a/src/main/modules/ipc/main.spec.ts b/src/main/modules/ipc/main.spec.ts new file mode 100644 index 000000000..ab6f8d685 --- /dev/null +++ b/src/main/modules/ipc/main.spec.ts @@ -0,0 +1,104 @@ +import MainProcessBridge from './main' + +jest.mock('electron', () => ({ + app: { getPath: jest.fn(() => '/tmp') }, + dialog: {}, + nativeTheme: { + shouldUseDarkColors: false, + themeSource: 'system', + }, + shell: { openExternal: jest.fn() }, +})) + +jest.mock('@root/backend/editor/ethercat', () => ({ + ESIService: jest.fn(), +})) + +jest.mock('@root/backend/editor/library-manager/desktop-catalog-transport', () => ({ + createDesktopCatalogTransport: jest.fn(() => ({})), +})) + +jest.mock('@root/backend/editor/utils/runtime-https-config', () => ({ + getRuntimeHttpsOptions: jest.fn(() => ({})), +})) + +jest.mock('@root/backend/shared/ethercat/esi-parser-main', () => ({ + parseESIDeviceFull: jest.fn(), +})) + +jest.mock('@root/backend/shared/library/public-catalog-client', () => ({ + listPublicLibraries: jest.fn(), +})) + +jest.mock('../../../backend/editor/library-manager', () => ({ + LibraryManagerModule: jest.fn(() => ({ + loadEnabledArchives: jest.fn(() => ({ archives: [], missing: [] })), + })), +})) + +jest.mock('../../../backend/editor/package-manager', () => ({ + PackageManagerModule: jest.fn(() => ({})), +})) + +jest.mock('../../../backend/editor/services', () => ({ + logger: { + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + }, +})) + +jest.mock('../../../backend/editor/utils', () => ({ + getOpenProjectPath: jest.fn(), + getProjectPath: jest.fn(), +})) + +jest.mock('../../../backend/shared/simulator/simulator-module', () => ({ + SimulatorModule: jest.fn(() => ({ stop: jest.fn() })), +})) + +const createBridge = (mainWindow: { isDestroyed: jest.Mock; isMaximized: jest.Mock }) => + new MainProcessBridge({ + ipcMain: {}, + mainWindow, + projectService: {}, + store: { get: jest.fn(() => undefined) }, + menuBuilder: {}, + pouService: {}, + compilerModule: {}, + hardwareModule: {}, + } as never) + +describe('MainProcessBridge.handleGetSystemInfo', () => { + it('does not read maximized state from a destroyed window', () => { + const mainWindow = { + isDestroyed: jest.fn(() => true), + isMaximized: jest.fn(() => true), + } + const bridge = createBridge(mainWindow) + + expect(bridge.handleGetSystemInfo()).toEqual( + expect.objectContaining({ + isWindowMaximized: false, + }), + ) + expect(mainWindow.isDestroyed).toHaveBeenCalledTimes(1) + expect(mainWindow.isMaximized).not.toHaveBeenCalled() + }) + + it('reads maximized state from a live window', () => { + const mainWindow = { + isDestroyed: jest.fn(() => false), + isMaximized: jest.fn(() => true), + } + const bridge = createBridge(mainWindow) + + expect(bridge.handleGetSystemInfo()).toEqual( + expect.objectContaining({ + isWindowMaximized: true, + }), + ) + expect(mainWindow.isDestroyed).toHaveBeenCalledTimes(1) + expect(mainWindow.isMaximized).toHaveBeenCalledTimes(1) + }) +}) From 96dc46e80fb9c386ba0dbd9b58d681db34ad7d0e Mon Sep 17 00:00:00 2001 From: Manuele Conti Date: Tue, 30 Jun 2026 21:45:28 +0200 Subject: [PATCH 10/35] style: format dev menu handler --- src/main/menu.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/menu.ts b/src/main/menu.ts index e7cfa050a..a2b952338 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -19,10 +19,7 @@ interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions { export default class MenuBuilder { private mainWindow: BrowserWindow private projectService: ProjectService - private readonly handleDevelopmentContextMenu = ( - _: Electron.Event, - props: Electron.ContextMenuParams, - ): void => { + private readonly handleDevelopmentContextMenu = (_: Electron.Event, props: Electron.ContextMenuParams): void => { if (!this.hasLiveWindow()) return const { x, y } = props From 540866caad1fda5d29eb54853e6c57fe21fbd1ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Wed, 1 Jul 2026 17:32:14 -0300 Subject: [PATCH 11/35] fix(ui): center activity-bar icons by hiding the sidebar scrollbar The .sidebar-scroll rail reserved scrollbar space on its left edge only (scrollbar-gutter: stable + direction: rtl), shifting every icon right of center: +3px in Chromium, +9px in Safari, which reserves the full ~16px native scrollbar width regardless of the ::-webkit-scrollbar customization. Items outside the scroll container (exit button, logo) stayed centered, making the offset visible. Hide the scrollbar from layout entirely (scrollbar-width: none + ::-webkit-scrollbar display: none) so the icon column centers in the full 56px rail; scrolling still works via wheel/trackpad. Also remove the nineties-theme rules that compensated for the old gutter. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0118h3QKR2X7kPt2G2LMWjmL --- src/backend/shared/styles/globals.css | 44 +++++---------------------- 1 file changed, 7 insertions(+), 37 deletions(-) diff --git a/src/backend/shared/styles/globals.css b/src/backend/shared/styles/globals.css index f8b4bad6f..affb8c9ba 100644 --- a/src/backend/shared/styles/globals.css +++ b/src/backend/shared/styles/globals.css @@ -224,33 +224,18 @@ } } +/* Sidebar scrollbar: hidden from layout so the icon column stays centered in + the 56px rail. Native scrollbars/gutters reserve asymmetric space on one + edge (6px in Chromium, the full ~16px native width in Safari regardless of + the ::-webkit-scrollbar width) and shift every icon off-center. The rail + still scrolls via wheel/trackpad/keyboard. */ .sidebar-scroll { overflow-y: auto; - scrollbar-gutter: stable; - direction: rtl; -} - -.sidebar-scroll > * { - direction: ltr; + scrollbar-width: none; } .sidebar-scroll::-webkit-scrollbar { - width: 6px; - background-color: transparent; -} - -.sidebar-scroll::-webkit-scrollbar-thumb { - background-color: transparent; - border-radius: 4px; - transition: background-color 0.2s; -} - -.sidebar-scroll:hover::-webkit-scrollbar-thumb { - background-color: rgba(180, 208, 254, 0.3); -} - -.sidebar-scroll:hover::-webkit-scrollbar-thumb:hover { - background-color: rgba(180, 208, 254, 0.6); + display: none; } .apexcharts-yaxis-label tspan, @@ -442,21 +427,6 @@ width: 32px !important; height: 32px !important; } -/* Left-align the button COLUMNS (the container divs) so the squares hug the - left and aren't clipped by the scrollbar gutter — but DON'T touch the - buttons' own `items-center`, or the icon stops being centred inside each. */ -.nineties .w-14.bg-brand-dark .sidebar-scroll, -.nineties .w-14.bg-brand-dark div[class*='items-center'] { - align-items: flex-start !important; -} -.nineties .w-14.bg-brand-dark .sidebar-scroll { - padding-left: 2px !important; -} -/* Keep the rail's own scrollbar thin — the global chunky 16px bar otherwise - eats most of the 56px rail and crops the 42px buttons. */ -.nineties .w-14.bg-brand-dark .sidebar-scroll::-webkit-scrollbar { - width: 6px !important; -} /* Darken stroke-based custom rail icons (e.g. the Exit/back arrow, a pale brand-blue that disappeared on silver). */ .nineties .w-14.bg-brand-dark svg:not([class*='lucide']):not(.retro-icon) { From 68a2ad3100e86b74849629b95b039d6abfc039c1 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Wed, 1 Jul 2026 18:09:11 -0400 Subject: [PATCH 12/35] fix(package-manager): move VPP signature re-check to project open Run the installed-package signature sweep when a project opens instead of once at app startup, and surface each removal as a WARNING in the console panel. verifyInstalledSignatures() is exposed through the PackagePort (editor adapter -> packages:verify-signatures IPC); workspace-screen calls it on project load and logs removed ids. On removal the main process emits packages:boards-updated so the device list refreshes via the existing subscription. Removes the app.whenReady() startup sweep. Platforms without a local package store (web) don't wire a `packages` port, so the shared open-project effect is a no-op there. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/frontend/screens/workspace-screen.tsx | 26 +++++++++++++++++++ src/main/main.ts | 12 --------- src/main/modules/ipc/main.ts | 11 ++++++++ src/main/modules/ipc/renderer.ts | 1 + .../editor/__tests__/package-adapter.test.ts | 14 ++++++++++ .../adapters/editor/package-adapter.ts | 4 +++ src/middleware/shared/ports/package-port.ts | 10 +++++++ 7 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index 852aaf90d..a34455a1f 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -69,6 +69,7 @@ const WorkspaceScreen = () => { useCallback((s) => s.workspaceActions, []), ) const { setAvailableOptions } = useOpenPLCStore(useCallback((s) => s.deviceActions, [])) + const addLog = useOpenPLCStore(useCallback((s) => s.consoleActions.addLog, [])) // RARE: UI state (changes on user interaction, not during debug polling) const tabs = useOpenPLCStore(useCallback((s) => s.tabs, [])) @@ -406,6 +407,31 @@ const WorkspaceScreen = () => { } }, [packagesPort, device, setAvailableOptions]) + // Desktop security safeguard: whenever a project opens, re-verify the + // signatures of every installed VPP package and drop any that no longer + // validate (a locally-crafted/unsigned .vpp can bypass the signed import + // flow). Each removal is surfaced as a WARNING in the console panel; the + // main process emits `packages:boards-updated` on removal, so the board + // list refreshes via the subscription above. On web `packagesPort` is + // undefined (packages are backend-provided), so this is a no-op. + useEffect(() => { + if (!packagesPort || !projectPath) return + let cancelled = false + void packagesPort.verifyInstalledSignatures().then((removed) => { + if (cancelled) return + for (const packageId of removed) { + addLog({ + id: crypto.randomUUID(), + level: 'warning', + message: `Removed untrusted VPP package "${packageId}": its signature is missing or invalid.`, + }) + } + }) + return () => { + cancelled = true + } + }, [packagesPort, projectPath, addLog]) + return (
diff --git a/src/main/main.ts b/src/main/main.ts index b685c6c4d..45550e1c9 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -18,7 +18,6 @@ import { CompilerModule } from '../backend/editor/compiler' // TODO: Refactor this type declaration import { MainIpcModuleConstructor } from '../backend/editor/contracts/types/modules/ipc/main' import { HardwareModule } from '../backend/editor/hardware' -import { PackageManagerModule } from '../backend/editor/package-manager' import { logger, PouService, ProjectService, UserService } from '../backend/editor/services' import { resolveHtmlPath } from '../backend/editor/utils' import { getErrorMessage } from '../frontend/utils/get-error-message' @@ -404,17 +403,6 @@ app.on('second-instance', () => { app .whenReady() .then(() => { - // Re-verify installed VPP package signatures once per launch, before the - // first window reads them; removes any package whose signature no longer - // validates. - try { - const removed = new PackageManagerModule().verifyInstalledSignatures() - if (removed.length > 0) { - logger.warn(`Removed ${removed.length} untrusted VPP package(s) at startup: ${removed.join(', ')}`) - } - } catch (err) { - logger.error('VPP package signature sweep failed: ' + getErrorMessage(err)) - } void createMainWindow() // Handle the app activation event; app.on('activate', () => { diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index b1b5c4a9f..b2b718d78 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -842,6 +842,7 @@ class MainProcessBridge implements MainIpcModule { this.registerHandle('packages:list-installed', this.handlePackagesListInstalled) this.registerHandle('packages:uninstall', this.handlePackagesUninstall) this.registerHandle('packages:get-manifest', this.handlePackagesGetManifest) + this.registerHandle('packages:verify-signatures', this.handlePackagesVerifySignatures) // ===================== UTILITIES ===================== this.registerHandle('util:get-preview-image', this.handleUtilGetPreviewImage) @@ -1374,6 +1375,16 @@ class MainProcessBridge implements MainIpcModule { } return result } + // Re-verify installed VPP signatures (invoked by the renderer when a project + // opens) and return the ids removed. If anything was dropped, notify the + // renderer so the board/device list refreshes via the existing subscription. + handlePackagesVerifySignatures = async (): Promise => { + const removed = this.packageManagerModule.verifyInstalledSignatures() + if (removed.length > 0) { + this.mainWindow?.webContents.send('packages:boards-updated') + } + return removed + } handlePackagesGetManifest = async (_event: IpcMainInvokeEvent, packageId: string) => this.packageManagerModule.getInstalledPackageManifest(packageId) diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 1017eeb69..8fe73479f 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -365,6 +365,7 @@ const rendererProcessBridge = { uninstallPackage: (packageId: string): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('packages:uninstall', packageId), getPackageManifest: (packageId: string): Promise => ipcRenderer.invoke('packages:get-manifest', packageId), + verifyInstalledPackageSignatures: (): Promise => ipcRenderer.invoke('packages:verify-signatures'), onOpenPackageManager: (callback: () => void) => { const listener = () => callback() ipcRenderer.on('packages:open-manager', listener) diff --git a/src/middleware/adapters/editor/__tests__/package-adapter.test.ts b/src/middleware/adapters/editor/__tests__/package-adapter.test.ts index ec6348e1f..2e28e163e 100644 --- a/src/middleware/adapters/editor/__tests__/package-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/package-adapter.test.ts @@ -50,6 +50,7 @@ beforeEach(() => { getPackageManifest: jest.fn().mockResolvedValue(validManifest), onOpenPackageManager: jest.fn().mockImplementation(() => jest.fn()), onBoardsUpdated: jest.fn().mockImplementation(() => jest.fn()), + verifyInstalledPackageSignatures: jest.fn().mockResolvedValue([]), } as unknown as typeof window.bridge global.fetch = jest.fn() as unknown as typeof fetch @@ -209,4 +210,17 @@ describe('createEditorPackageAdapter', () => { expect(off).toBe(unsubscribe) }) }) + + describe('verifyInstalledSignatures', () => { + it('delegates to the bridge and returns the removed package ids', async () => { + ;(window.bridge.verifyInstalledPackageSignatures as jest.Mock).mockResolvedValue(['com.synergy-logic.slm-rp4']) + const removed = await adapter.verifyInstalledSignatures() + expect(window.bridge.verifyInstalledPackageSignatures).toHaveBeenCalledTimes(1) + expect(removed).toEqual(['com.synergy-logic.slm-rp4']) + }) + + it('returns an empty array when nothing was removed', async () => { + expect(await adapter.verifyInstalledSignatures()).toEqual([]) + }) + }) }) diff --git a/src/middleware/adapters/editor/package-adapter.ts b/src/middleware/adapters/editor/package-adapter.ts index 22e8c9727..7d4672dea 100644 --- a/src/middleware/adapters/editor/package-adapter.ts +++ b/src/middleware/adapters/editor/package-adapter.ts @@ -123,5 +123,9 @@ export function createEditorPackageAdapter(): PackagePort { onBoardsUpdated(callback: () => void): Unsubscribe { return window.bridge.onBoardsUpdated(callback) }, + + verifyInstalledSignatures(): Promise { + return window.bridge.verifyInstalledPackageSignatures() + }, } } diff --git a/src/middleware/shared/ports/package-port.ts b/src/middleware/shared/ports/package-port.ts index ed9d2ac29..c1a62349f 100644 --- a/src/middleware/shared/ports/package-port.ts +++ b/src/middleware/shared/ports/package-port.ts @@ -59,4 +59,14 @@ export interface PackagePort { * Subscribe to board list updates (triggered after package install/uninstall). */ onBoardsUpdated(callback: () => void): Unsubscribe + + /** + * Re-verify the signatures of every installed package and remove any whose + * signature no longer validates, returning the ids removed. A desktop-only + * safeguard: locally-installed `.vpp` files can be added or altered outside + * the signed import flow, so they are re-checked whenever a project opens. + * Platforms where packages are backend-provided (web) don't wire a `packages` + * port and never call this; a no-op shim there returns `[]`. + */ + verifyInstalledSignatures(): Promise } From b81f7ee5ab2ed52c5b34d93a21b3c680e81bd159 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 2 Jul 2026 16:40:58 -0400 Subject: [PATCH 13/35] fix(modbus/opcua): editable IO groups, per-row actions, table contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes three UI/UX issues in the remote-device (Modbus master) and OPC-UA server editors: 1. Editing a Modbus IO Group's size had no effect. `updateIOGroup` only reassigned the group metadata via `Object.assign`, so the underlying `ioPoints` list (and therefore the size shown in the table) never changed. It now regenerates the edited group's I/O points to match the new length / function code, reusing the group's own freed addresses (pool built from every other producer) and preserving each point's alias positionally. The change is localized to the edited group — no project-wide reallocation. 2. Replaced double-click-to-edit on the IO Group table with per-row Edit / Delete icon buttons (matching the OPC-UA Security Profiles pattern) and removed the redundant "-" toolbar button, keeping only "+" to add. 3. Fixed low-contrast table headers on the OPC-UA Security Profiles, Users, and Certificates tables (unreadable in dark mode) by adopting the Modbus IO table's header styling. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/device/remote-device/index.tsx | 85 +++++++++---------- .../components/certificates-tab.tsx | 12 +-- .../components/security-profiles-tab.tsx | 14 +-- .../opcua-server/components/users-tab.tsx | 10 +-- .../store/__tests__/project-slice.test.ts | 55 ++++++++++++ src/frontend/store/slices/project/slice.ts | 65 +++++++++++++- 6 files changed, 176 insertions(+), 65 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/editor/device/remote-device/index.tsx b/src/frontend/components/_features/[workspace]/editor/device/remote-device/index.tsx index 44f087fa1..3000d5585 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/remote-device/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/remote-device/index.tsx @@ -1,10 +1,10 @@ +import { Pencil1Icon, TrashIcon } from '@radix-ui/react-icons' import type { ModbusIOGroup, ModbusIOPoint } from '@root/middleware/shared/ports/types' import { useRuntime } from '@root/middleware/shared/providers/platform-context' import { useCallback, useEffect, useMemo, useState } from 'react' import { v4 as uuidv4 } from 'uuid' import { ArrowIcon } from '../../../../../../assets/icons/interface/Arrow' -import { MinusIcon } from '../../../../../../assets/icons/interface/Minus' import { PlusIcon } from '../../../../../../assets/icons/interface/Plus' import { useOpenPLCStore } from '../../../../../../store' import { cn } from '../../../../../../utils/cn' @@ -348,21 +348,12 @@ type IOGroupRowProps = { ioGroup: ModbusIOGroup isExpanded: boolean onToggleExpand: () => void - isSelected: boolean - onSelect: () => void onEdit: () => void + onDelete: () => void onUpdateAlias: (ioPointId: string, alias: string) => void } -const IOGroupRow = ({ - ioGroup, - isExpanded, - onToggleExpand, - isSelected, - onSelect, - onEdit, - onUpdateAlias, -}: IOGroupRowProps) => { +const IOGroupRow = ({ ioGroup, isExpanded, onToggleExpand, onEdit, onDelete, onUpdateAlias }: IOGroupRowProps) => { const firstIOPoint = ioGroup.ioPoints?.[0] const groupType = firstIOPoint?.type || '-' const groupAddress = firstIOPoint?.iecLocation || '-' @@ -370,22 +361,9 @@ const IOGroupRow = ({ return ( <> - + - + +
+ {isExpanded && (ioGroup.ioPoints ?? []).map((ioPoint: ModbusIOPoint, index: number) => ( @@ -446,6 +446,7 @@ const IOPointRow = ({ ioPoint, offset, onUpdateAlias }: IOPointRowProps) => { className='h-6 w-full rounded border border-neutral-200 bg-white px-1 text-xs text-neutral-700 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300' /> + ) } @@ -490,7 +491,6 @@ const RemoteDeviceEditor = () => { // UI state const [expandedGroups, setExpandedGroups] = useState>(new Set()) - const [selectedGroupId, setSelectedGroupId] = useState(null) const [isModalOpen, setIsModalOpen] = useState(false) const [editingGroup, setEditingGroup] = useState(null) @@ -737,13 +737,13 @@ const RemoteDeviceEditor = () => { [deviceName, projectActions, editingGroup, sharedWorkspaceActions], ) - const handleDeleteIOGroup = useCallback(() => { - if (selectedGroupId) { - projectActions.deleteIOGroup(deviceName, selectedGroupId) - setSelectedGroupId(null) + const handleDeleteIOGroup = useCallback( + (groupId: string) => { + projectActions.deleteIOGroup(deviceName, groupId) sharedWorkspaceActions.handleFileAndWorkspaceSavedState(deviceName) - } - }, [deviceName, selectedGroupId, projectActions, sharedWorkspaceActions]) + }, + [deviceName, projectActions, sharedWorkspaceActions], + ) const handleUpdateAlias = useCallback( (ioGroupId: string, ioPointId: string, alias: string) => { @@ -951,13 +951,6 @@ const RemoteDeviceEditor = () => { icon: , id: 'add-io-group-button', }, - { - ariaLabel: 'Remove IO Group', - onClick: handleDeleteIOGroup, - disabled: !selectedGroupId, - icon: , - id: 'remove-io-group-button', - }, ]} buttonProps={{ className: @@ -983,18 +976,21 @@ const RemoteDeviceEditor = () => { Offset - + Function Code Alias + + Actions + {ioGroups.length === 0 ? ( - + No IO groups configured. Click the + button to add one. @@ -1005,9 +1001,8 @@ const RemoteDeviceEditor = () => { ioGroup={ioGroup} isExpanded={expandedGroups.has(ioGroup.id)} onToggleExpand={() => handleToggleExpand(ioGroup.id)} - isSelected={selectedGroupId === ioGroup.id} - onSelect={() => setSelectedGroupId(ioGroup.id)} onEdit={() => handleOpenEditModal(ioGroup.id)} + onDelete={() => handleDeleteIOGroup(ioGroup.id)} onUpdateAlias={(ioPointId, alias) => handleUpdateAlias(ioGroup.id, ioPointId, alias)} /> )) diff --git a/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/certificates-tab.tsx b/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/certificates-tab.tsx index f2a0b23e4..3fdc2d9ca 100644 --- a/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/certificates-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/certificates-tab.tsx @@ -265,19 +265,19 @@ MIIEvgIBADANBg... ) : (
- + - - + - - - diff --git a/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx b/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx index 9e30b59b4..371f10fe7 100644 --- a/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx @@ -115,20 +115,20 @@ export const SecurityProfilesTab = ({ config, serverName, onConfigChange }: Secu {config.securityProfiles.length > 0 ? (
ID + ID Subject + Valid + Fingerprint + Actions
- + - - - + - - + - diff --git a/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/users-tab.tsx b/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/users-tab.tsx index 9db6c7291..d9f0700d8 100644 --- a/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/users-tab.tsx +++ b/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/users-tab.tsx @@ -125,12 +125,12 @@ export const UsersTab = ({ config, serverName, onConfigChange }: UsersTabProps) ) : (
+ Enabled Name + Name Policy Mode + Mode Authentication + Actions
- + - - - - + + + diff --git a/src/frontend/store/__tests__/project-slice.test.ts b/src/frontend/store/__tests__/project-slice.test.ts index 4a665d862..f0c3d4bbe 100644 --- a/src/frontend/store/__tests__/project-slice.test.ts +++ b/src/frontend/store/__tests__/project-slice.test.ts @@ -2386,6 +2386,61 @@ describe('createProjectSlice', () => { const result = store.getState().projectActions.updateIOGroup('EtherCAT', 'g1', { name: 'x' }) expect(result.ok).toBe(true) }) + + it('regenerates ioPoints when the length grows (edit size)', () => { + seedRemoteDevice(store, makeRemoteDevice('Dev1')) + store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g1', '3', 2)) + expect(store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints).toHaveLength(2) + + store.getState().projectActions.updateIOGroup('Dev1', 'g1', { length: 4 }) + const points = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints! + expect(points).toHaveLength(4) + expect(points.map((p) => p.iecLocation)).toEqual(['%IW0', '%IW1', '%IW2', '%IW3']) + }) + + it('regenerates ioPoints when the length shrinks (edit size)', () => { + seedRemoteDevice(store, makeRemoteDevice('Dev1')) + store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g1', '3', 4)) + store.getState().projectActions.updateIOGroup('Dev1', 'g1', { length: 1 }) + const points = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints! + expect(points).toHaveLength(1) + expect(points[0].iecLocation).toBe('%IW0') + }) + + it('re-derives addresses under the new prefix when the function code changes', () => { + seedRemoteDevice(store, makeRemoteDevice('Dev1')) + store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g1', '3', 2)) // FC3 -> %IW + store.getState().projectActions.updateIOGroup('Dev1', 'g1', { functionCode: '1' }) // FC1 -> %IX + const points = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints! + expect(points.map((p) => p.iecLocation)).toEqual(['%IX0.0', '%IX0.1']) + }) + + it('regenerates only the edited group, leaving sibling groups untouched (no project-wide recompaction)', () => { + seedRemoteDevice(store, makeRemoteDevice('Dev1')) + store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g1', '3', 2)) // %IW0,1 + store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g2', '3', 2)) // %IW2,3 + + // Grow g1. Because this edit is localized (it does NOT recompact the + // whole project), g1 reuses its own freed %IW0/%IW1 and its third + // point takes the next free slot after g2's untouched %IW2/%IW3. + store.getState().projectActions.updateIOGroup('Dev1', 'g1', { length: 3 }) + const groups = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups + expect(groups[0].ioPoints!.map((p) => p.iecLocation)).toEqual(['%IW0', '%IW1', '%IW4']) + expect(groups[1].ioPoints!.map((p) => p.iecLocation)).toEqual(['%IW2', '%IW3']) // sibling untouched + }) + + it('preserves point aliases positionally when regenerating', () => { + seedRemoteDevice(store, makeRemoteDevice('Dev1')) + store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g1', '3', 2)) + const pointId = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints![0].id + store.getState().projectActions.updateIOPointAlias('Dev1', 'g1', pointId, 'Temp') + + store.getState().projectActions.updateIOGroup('Dev1', 'g1', { length: 3 }) + const points = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints! + expect(points).toHaveLength(3) + expect(points[0].alias).toBe('Temp') // survived the reshuffle + expect(points[2].alias).toBe('') // freshly allocated slot + }) }) describe('deleteIOGroup', () => { diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index e64cb8a64..0c9b0bf6c 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -158,6 +158,10 @@ function generateIOPoints( * without needing to rebuild the pool inside the loop. */ pool: Parameters[0], pending: Set, + /* Points that previously occupied this group (edit flow). Their + * aliases are carried over positionally so resizing / editing a + * group doesn't wipe the aliases the user attached to each slot. */ + existingPoints: ModbusIOPoint[] = [], ): ModbusIOPoint[] { const { type, iecPrefix, isBit } = getFunctionCodeInfo(functionCode) const points: ModbusIOPoint[] = [] @@ -165,7 +169,13 @@ function generateIOPoints( for (let i = 0; i < length; i++) { const iecLocation = nextFreeAddress(pool, iecPrefix, isBit, undefined, pending) pending.add(iecLocation) - points.push({ id: `${groupName}_${i}`, name: `${groupName}_${i}`, type, iecLocation, alias: '' }) + points.push({ + id: `${groupName}_${i}`, + name: `${groupName}_${i}`, + type, + iecLocation, + alias: existingPoints[i]?.alias ?? '', + }) } return points @@ -1531,12 +1541,63 @@ const createProjectSlice: StateCreator = return ok() }, updateIOGroup: (deviceName, groupId, updates) => { + // Editing a group's length / function code / name must regenerate + // its I/O points — the previous implementation only `Object.assign`ed + // the metadata, so the point list (and therefore the effective size + // shown in the table) never changed (bug: "size stays as originally + // set"). We regenerate ONLY this group's points here — no + // project-wide reallocation. To let the group reuse its own freed + // addresses, the pool is built from every producer EXCEPT this + // group's current points (all other Modbus groups, pin-mapping, VPP + // and EtherCAT claims are still honoured). Existing aliases are + // carried over positionally. + const live = getState() + const boardInfo = live.deviceAvailableOptions.availableBoards.get( + live.deviceDefinitions.configuration.deviceBoard ?? '', + ) + const ioMapping = + ( + live.deviceDefinitions.configuration.vendorScreenData?.['io-mapping'] as + | { entries?: Array<{ iecAddress: string; alias?: string; slot: number; channelName: string }> } + | undefined + )?.entries ?? [] + + // Clone remoteDevices with the edited group's points cleared so its + // own addresses are free for re-allocation without disturbing the + // rest of the project. + const remoteDevicesForPool = live.project.data.remoteDevices?.map((d) => + d.name !== deviceName || !d.modbusTcpConfig + ? d + : { + ...d, + modbusTcpConfig: { + ...d.modbusTcpConfig, + ioGroups: d.modbusTcpConfig.ioGroups.map((g) => (g.id === groupId ? { ...g, ioPoints: [] } : g)), + }, + }, + ) + + const pool = buildAddressPool( + { + pinMapping: { + pins: live.deviceDefinitions.pinMapping.pinsByBoard[live.deviceDefinitions.configuration.deviceBoard] ?? [], + }, + vendorIoMapping: { entries: ioMapping }, + remoteDevices: remoteDevicesForPool, + }, + resolveTargetCapabilities(boardInfo), + ) + setState( produce((slice: ProjectSlice) => { const device = slice.project.data.remoteDevices?.find((d) => d.name === deviceName) if (!device?.modbusTcpConfig) return const group = device.modbusTcpConfig.ioGroups.find((g) => g.id === groupId) - if (group) Object.assign(group, updates) + if (!group) return + const existingPoints = group.ioPoints ?? [] + Object.assign(group, updates) + const pending = new Set() + group.ioPoints = generateIOPoints(group.functionCode, group.length, group.name, pool, pending, existingPoints) }), ) return ok() From 0ce234193de530802d2914e05153c1a50af8d9bc Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 2 Jul 2026 17:32:19 -0400 Subject: [PATCH 14/35] =?UTF-8?q?feat(iec-address):=20central=20address=20?= =?UTF-8?q?registry=20=E2=80=94=20pure=20core=20(Phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the framework-agnostic core of the central IEC address registry (design in docs/iec-address-registry.md): the single source of truth for consumers, their address assignments, and their aliases. Producers will register CONSUMERS whose CHANNELS carry a stable, address-independent id, so aliases and variable bindings follow a channel as reallocation moves its address. New modules under middleware/shared/utils/iec-address/registry/: - types.ts — Consumer / Channel / AddressClass / IecAddressRegistry - address-space.ts — prefix<->class, bit linearization, parse/format - allocate.ts — deterministic allocator: pinned reserved first, then lowest-free per INDEPENDENT prefix space (no byte/word overlap, matching the OpenPLC runtime); stable order → reproducible, gapless, idempotent - registry.ts — pure ops: create / add / remove / update consumer, setAlias (the single system-wide uniqueness gate), recalculate, addressOf - resolve.ts — buildAliasIndex + resolveLocation for compile time (literal → verbatim, alias → address, orphan → empty) No wiring yet — additive only, nothing else changes. 40 unit tests, 100% coverage on the new modules. Phases 2+ (migrate-on-open, store slice, per-producer adapters, codegen) follow. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/iec-address-registry.md | 213 ++++++++++++++++++ .../registry/__tests__/address-space.test.ts | 66 ++++++ .../registry/__tests__/allocate.test.ts | 111 +++++++++ .../registry/__tests__/registry.test.ts | 153 +++++++++++++ .../registry/__tests__/resolve.test.ts | 76 +++++++ .../iec-address/registry/address-space.ts | 59 +++++ .../utils/iec-address/registry/allocate.ts | 112 +++++++++ .../utils/iec-address/registry/index.ts | 24 ++ .../utils/iec-address/registry/registry.ts | 111 +++++++++ .../utils/iec-address/registry/resolve.ts | 49 ++++ .../utils/iec-address/registry/types.ts | 83 +++++++ 11 files changed, 1057 insertions(+) create mode 100644 docs/iec-address-registry.md create mode 100644 src/middleware/shared/utils/iec-address/registry/__tests__/address-space.test.ts create mode 100644 src/middleware/shared/utils/iec-address/registry/__tests__/allocate.test.ts create mode 100644 src/middleware/shared/utils/iec-address/registry/__tests__/registry.test.ts create mode 100644 src/middleware/shared/utils/iec-address/registry/__tests__/resolve.test.ts create mode 100644 src/middleware/shared/utils/iec-address/registry/address-space.ts create mode 100644 src/middleware/shared/utils/iec-address/registry/allocate.ts create mode 100644 src/middleware/shared/utils/iec-address/registry/index.ts create mode 100644 src/middleware/shared/utils/iec-address/registry/registry.ts create mode 100644 src/middleware/shared/utils/iec-address/registry/resolve.ts create mode 100644 src/middleware/shared/utils/iec-address/registry/types.ts diff --git a/docs/iec-address-registry.md b/docs/iec-address-registry.md new file mode 100644 index 000000000..fd9078f7a --- /dev/null +++ b/docs/iec-address-registry.md @@ -0,0 +1,213 @@ +# Central IEC Address Registry — Architecture + +> Status: **approved design, in implementation** +> Scope: `openplc-editor` + `openplc-web` (shared surface — byte-identical) +> Supersedes the scattered per-producer address allocators and the +> derived `address-pool` / `alias-registry` / `sync-variable-aliases` trio. + +## 1. Motivation + +IEC 61131-3 addresses are a **single, finite, shared resource**. Every +producer — pin mapping, VPP I/O modules, Modbus remote devices, EtherCAT +slaves, and anything added later — draws from the same address spaces +(`%IX`, `%QX`, `%IW`, `%QW`, `%MW`, …). Today each producer: + +- stores its own addresses in its own domain records, +- allocates them with its own copy of the "find next free" loop + (EtherCAT even has a *separate* bit-offset allocator), and +- owns its channels' aliases independently, reconciled to program + variables only opportunistically. + +The result is duplication, drift, and no project-wide gap reclamation +(deleting a consumer leaves holes). Aliases — the thing user code should +depend on — are the most fragmented of all. + +**This design makes a single store the source of truth for consumers, +their address assignments, and their aliases.** Producers become thin +clients that *register channels* and *read addresses back*. User program +variables reference aliases (or manual literal addresses); the compiler +resolves them. + +## 2. Concepts + +- **Consumer** — anything that needs addresses: a VPP module in a slot, a + Modbus IO group, an EtherCAT slave, a pin block. Identified by a stable + `id`, tagged with a `kind`, and given a deterministic `order`. +- **Channel** — one address request inside a consumer. Has a **stable, + address-independent `channelId`**, an address **class**, an optional + **alias**, and an optional **pinned** literal address (fixed hardware). + > The stable `channelId` is the linchpin: aliases and variable bindings + > attach to the channel, **not** to the address, so when reallocation + > moves the channel the alias follows it automatically. +- **Address class** — `{ direction: I|Q|M, size: X|B|W|D|L }`. Maps to a + prefix (`%IX`, `%QB`, `%QW`, …). **Each prefix is an independent linear + space — no byte/word/dword overlap** (matches the OpenPLC runtime, which + uses separate typed buffers per prefix). +- **Assignment** — the derived `(consumerId, channelId) → address` map, + recomputed by `recalculate()`. +- **Alias** — a user-facing name, **unique system-wide**, owned by the + registry and attached to a channel. + +## 3. The registry (source of truth) + +Serialized as a dedicated project section: + +```ts +interface IecAddressRegistry { + consumers: RegistryConsumer[] // the record + assignments: Record // derived cache: key(cid,chid) -> address +} +interface RegistryConsumer { + id: string + kind: 'pin-mapping' | 'vpp-io' | 'modbus-tcp-remote' | 'ethercat' | string + label?: string + order: number // deterministic allocation order + channels: RegistryChannel[] +} +interface RegistryChannel { + channelId: string // stable, address-independent + class: { direction: 'I'|'Q'|'M'; size: 'X'|'B'|'W'|'D'|'L' } + alias?: string // unique system-wide; empty = none + pinned?: string // fixed hardware address → reserved, not allocated +} +``` + +Producers keep only their **domain** config (a Modbus group still has its +function code, cycle time, etc.) plus the `consumerId` they registered +under. They no longer store IEC addresses or aliases. + +## 4. Generic API + +```ts +// pure core (framework-agnostic); a Zustand slice wraps it 1:1 +createRegistry(): IecAddressRegistry +addConsumer(reg, consumer): IecAddressRegistry // append + recalculate +removeConsumer(reg, consumerId): IecAddressRegistry // drop + recalculate (fills gaps) +updateConsumer(reg, consumerId, patch): IecAddressRegistry // change channels/order + recalculate +setAlias(reg, consumerId, channelId, alias): SetAliasResult // system-wide uniqueness gate (the ONE gate) +recalculate(reg): { registry, conflicts } // reassign all — deterministic, gapless, idempotent +// queries +addressOf(reg, consumerId, channelId): string | undefined +buildAliasIndex(reg): Map +resolveLocation(field, aliasIndex): string // compile-time: alias|literal -> address +``` + +`recalculate()` is **idempotent** (same input → same output) so it is safe +to call on every mutation, on project load, and pre-compile. + +## 5. Allocation algorithm + +1. **Reserve** every `pinned` channel at its literal address (pin-mapping, + or any explicitly-fixed channel). Two pinned channels on the same + address are reported as a conflict (first wins). +2. **Allocate** every non-pinned channel, walking consumers in `order` + (ties broken by `id`) and channels in declaration order, taking the + **lowest free index** in that channel's prefix space. +3. Bit prefixes (`%IX`/`%QX`/`%MX`) address as `byte.bit` + (`linear = byte*8 + bit`); all other sizes are index-addressed. + +Determinism (stable order + stable channel order) guarantees reproducible +results across sessions, so a re-open never gratuitously renumbers. + +## 6. Aliases and variable binding + +- **Aliases live only in the registry**, attached to channels. Uniqueness + is enforced in the single `setAlias` gate — no producer can create a + duplicate. +- **A program variable's location field holds either an alias name or a + literal IEC address.** The compiler only understands IEC addresses, so + the editor resolves at compile time (`resolveLocation`): + - **alias** → the alias's current address from the registry; + - **alias no longer exists** → empty location (variable is unlocated — + this already matches today's orphan behavior); + - **literal `%…`** → used **verbatim**. +- **Manual literal addresses are fully manual.** The allocator does **not** + reserve or avoid them. If the user types `%QX0.3` and later reallocation + assigns `%QX0.3` to some channel, that is the user's responsibility — + we honor exactly what they typed. This keeps the manual escape hatch + simple and predictable. +- Because aliases attach to the stable channel, moving `%QX0.3 → %QX0.5` + updates `alias → address` automatically; every variable using that alias + now resolves to `%QX0.5` with no user action. **That is the whole point: + user code is address-agnostic.** + +## 7. Migrate-on-open (existing projects → registry format) + +Old projects store addresses and aliases scattered across producer records +and have no `addressRegistry` section. On project open, when the section is +absent, run a **one-shot pure migration**: + +1. **Build consumers** from legacy producer state, one consumer per natural + unit, preserving channel order: + - `pin-mapping` → one pinned channel per board pin (alias from `pin.alias`). + - `vpp-io` → one consumer per slot/module; channels from its + `io-mapping` entries (class from `entry.iecAddress`, alias from + `entry.alias`). + - `modbus-tcp-remote` → one consumer per IO group; channels from + `ioPoints` (class from function code, alias from `point.alias`). + - `ethercat` → one consumer per slave; channels from `channelMappings` + (alias from `mapping.alias`). + Each channel's **initial `pinned` = its current legacy address**, so the + first `recalculate()` reproduces exactly today's addresses (nothing + moves on open). +2. **Adopt variables onto aliases.** For each program variable whose + `location` matches an aliased channel address, rewrite `location` to the + **alias name** (self-upgrade — today's `adopt`). Variables on a + non-aliased address stay as manual literals; variables already using an + alias are unchanged. +3. **Unpin.** After adoption, clear the temporary `pinned` seeds on the + allocatable (non-hardware) channels so subsequent edits can compact + gaps. Genuine hardware pins (pin-mapping) stay pinned. +4. **Strip** the now-migrated address/alias fields from producer records + and write the `addressRegistry` section. Bump a project schema version + so the migration runs exactly once. + +The migration is a pure function `migrateProjectToRegistry(project)` with +its own exhaustive tests (round-trip: legacy project → registry → +addresses identical to the originals; aliases preserved; variables +adopted). + +## 8. Compilation + +At compile, the pipeline resolves each variable's location through +`resolveLocation(field, buildAliasIndex(registry))` and emits the concrete +`%…` (or an empty location, dropping the `AT %…`, when an alias orphaned). +Literals pass through unchanged. No producer-specific address logic remains +in the emitters. + +## 9. Module layout (shared surface) + +``` +middleware/shared/utils/iec-address/registry/ + types.ts # Consumer, Channel, AddressClass, IecAddressRegistry, reports + address-space.ts # prefix<->class, bit linearization, parse/format, lowest-free + allocate.ts # allocateAddresses(consumers) -> { assignments, conflicts } + registry.ts # createRegistry / add / remove / update / setAlias / recalculate / queries + resolve.ts # buildAliasIndex, resolveLocation (compile-time) + migrate.ts # migrateProjectToRegistry (legacy -> registry) [Phase 2] + index.ts +frontend/store/slices/iec-address/ # Zustand slice wrapping the pure core [Phase 2] +``` + +The pure core carries no framework or Electron coupling and is +byte-identical across both repos. The old `address-pool.ts` / +`alias-registry.ts` / `sync-variable-aliases.ts` are removed once every +producer and the compiler have moved to the registry. + +## 10. Phased delivery + +- **Phase 1** — pure core (§3–§6 minus migration): types, address-space, + allocator, registry ops, `resolveLocation`. Fully unit-tested. No wiring. +- **Phase 2** — `migrateProjectToRegistry` + the Zustand slice + wire into + project load (schema-version gated). +- **Phase 3** — Modbus + EtherCAT register as consumers; delete their + bespoke allocators. **Delivers project-wide gap reclamation (bug #4).** +- **Phase 4** — VPP registers as consumers (module-channel resolver + injected); delete the two allocator effects. +- **Phase 5** — pin-mapping as pinned consumer; unify alias editing on the + single `setAlias` gate. +- **Phase 6** — compiler resolves via the registry; delete the legacy + pool/registry/sync modules and the now-redundant producer fields. + +Each phase ships as a paired editor+web PR keeping the shared surface +byte-identical. diff --git a/src/middleware/shared/utils/iec-address/registry/__tests__/address-space.test.ts b/src/middleware/shared/utils/iec-address/registry/__tests__/address-space.test.ts new file mode 100644 index 000000000..6cf479ae8 --- /dev/null +++ b/src/middleware/shared/utils/iec-address/registry/__tests__/address-space.test.ts @@ -0,0 +1,66 @@ +import { formatAddress, isBitClass, isIecAddress, parseAddress, prefixOf } from '../address-space' +import type { AddressClass } from '../types' + +describe('address-space', () => { + describe('prefixOf / isBitClass', () => { + it('builds the prefix from direction + size', () => { + expect(prefixOf({ direction: 'I', size: 'X' })).toBe('%IX') + expect(prefixOf({ direction: 'Q', size: 'W' })).toBe('%QW') + expect(prefixOf({ direction: 'M', size: 'D' })).toBe('%MD') + }) + + it('flags only X as a bit class', () => { + expect(isBitClass({ direction: 'I', size: 'X' })).toBe(true) + expect(isBitClass({ direction: 'Q', size: 'B' })).toBe(false) + expect(isBitClass({ direction: 'I', size: 'W' })).toBe(false) + }) + }) + + describe('formatAddress', () => { + it('formats bit classes as byte.bit', () => { + const cls: AddressClass = { direction: 'I', size: 'X' } + expect(formatAddress(cls, 0)).toBe('%IX0.0') + expect(formatAddress(cls, 7)).toBe('%IX0.7') + expect(formatAddress(cls, 8)).toBe('%IX1.0') + expect(formatAddress(cls, 11)).toBe('%IX1.3') + }) + + it('formats non-bit classes as a flat index', () => { + expect(formatAddress({ direction: 'Q', size: 'W' }, 0)).toBe('%QW0') + expect(formatAddress({ direction: 'Q', size: 'W' }, 5)).toBe('%QW5') + expect(formatAddress({ direction: 'M', size: 'B' }, 3)).toBe('%MB3') + }) + }) + + describe('parseAddress', () => { + it('parses bit addresses to linear byte*8+bit', () => { + expect(parseAddress('%IX0.0')).toEqual({ cls: { direction: 'I', size: 'X' }, linear: 0 }) + expect(parseAddress('%QX1.3')).toEqual({ cls: { direction: 'Q', size: 'X' }, linear: 11 }) + expect(parseAddress('%MX2.5')).toEqual({ cls: { direction: 'M', size: 'X' }, linear: 21 }) + }) + + it('parses word/byte/dword/lword addresses', () => { + expect(parseAddress('%IW3')).toEqual({ cls: { direction: 'I', size: 'W' }, linear: 3 }) + expect(parseAddress('%QB7')).toEqual({ cls: { direction: 'Q', size: 'B' }, linear: 7 }) + expect(parseAddress('%MD1')).toEqual({ cls: { direction: 'M', size: 'D' }, linear: 1 }) + expect(parseAddress('%IL9')).toEqual({ cls: { direction: 'I', size: 'L' }, linear: 9 }) + }) + + it('returns null for non-addresses', () => { + expect(parseAddress('')).toBeNull() + expect(parseAddress('push_button')).toBeNull() + expect(parseAddress('%ZZ0')).toBeNull() + expect(parseAddress('%IX0')).toBeNull() // bit needs .bit + expect(parseAddress('%IW')).toBeNull() + }) + }) + + describe('isIecAddress', () => { + it('is true only for valid addresses', () => { + expect(isIecAddress('%QX0.1')).toBe(true) + expect(isIecAddress('%IW4')).toBe(true) + expect(isIecAddress('relay')).toBe(false) + expect(isIecAddress('')).toBe(false) + }) + }) +}) diff --git a/src/middleware/shared/utils/iec-address/registry/__tests__/allocate.test.ts b/src/middleware/shared/utils/iec-address/registry/__tests__/allocate.test.ts new file mode 100644 index 000000000..4d2ff3f31 --- /dev/null +++ b/src/middleware/shared/utils/iec-address/registry/__tests__/allocate.test.ts @@ -0,0 +1,111 @@ +import { allocateAddresses, channelKey } from '../allocate' +import type { RegistryConsumer } from '../types' + +const bit = { direction: 'I', size: 'X' } as const +const word = { direction: 'Q', size: 'W' } as const + +function consumer(id: string, order: number, channels: RegistryConsumer['channels']): RegistryConsumer { + return { id, kind: 'test', order, channels } +} + +describe('channelKey', () => { + it('is unambiguous even when ids contain the separator characters', () => { + expect(channelKey('a', 'b')).not.toBe(channelKey('a"', 'b')) + expect(channelKey('a', 'b')).toBe(channelKey('a', 'b')) + }) +}) + +describe('allocateAddresses', () => { + it('allocates lowest-free per prefix, independent spaces', () => { + const { assignments, conflicts } = allocateAddresses([ + consumer('c1', 0, [ + { channelId: 'a', class: bit }, + { channelId: 'b', class: bit }, + { channelId: 'w', class: word }, + ]), + ]) + expect(conflicts).toEqual([]) + expect(assignments[channelKey('c1', 'a')]).toBe('%IX0.0') + expect(assignments[channelKey('c1', 'b')]).toBe('%IX0.1') + // Word space is independent of the bit space. + expect(assignments[channelKey('c1', 'w')]).toBe('%QW0') + }) + + it('allocates across consumers in (order, id) order', () => { + const { assignments } = allocateAddresses([ + consumer('z', 1, [{ channelId: 'a', class: word }]), + consumer('a', 0, [{ channelId: 'a', class: word }]), + consumer('m', 0, [{ channelId: 'a', class: word }]), + ]) + // order 0 first (a before m by id tiebreak), then order 1 (z) + expect(assignments[channelKey('a', 'a')]).toBe('%QW0') + expect(assignments[channelKey('m', 'a')]).toBe('%QW1') + expect(assignments[channelKey('z', 'a')]).toBe('%QW2') + }) + + it('reserves pinned channels and allocates around them', () => { + const { assignments } = allocateAddresses([ + consumer('c1', 0, [ + { channelId: 'p', class: word, pinned: '%QW2' }, + { channelId: 'a', class: word }, + { channelId: 'b', class: word }, + { channelId: 'c', class: word }, + ]), + ]) + expect(assignments[channelKey('c1', 'p')]).toBe('%QW2') + // allocated channels skip the reserved %QW2 + expect(assignments[channelKey('c1', 'a')]).toBe('%QW0') + expect(assignments[channelKey('c1', 'b')]).toBe('%QW1') + expect(assignments[channelKey('c1', 'c')]).toBe('%QW3') + }) + + it('honours an unparseable pinned address verbatim without reserving', () => { + const { assignments, conflicts } = allocateAddresses([ + consumer('c1', 0, [ + { channelId: 'weird', class: word, pinned: 'NOT_AN_ADDRESS' }, + { channelId: 'a', class: word }, + ]), + ]) + expect(conflicts).toEqual([]) + expect(assignments[channelKey('c1', 'weird')]).toBe('NOT_AN_ADDRESS') + expect(assignments[channelKey('c1', 'a')]).toBe('%QW0') + }) + + it('reports pinned-vs-pinned collisions first-wins, including 3-way', () => { + const { assignments, conflicts } = allocateAddresses([ + consumer('c1', 0, [ + { channelId: 'first', class: word, pinned: '%QW5' }, + { channelId: 'second', class: word, pinned: '%QW5' }, + { channelId: 'third', class: word, pinned: '%QW5' }, + ]), + ]) + expect(conflicts).toHaveLength(1) + expect(conflicts[0].address).toBe('%QW5') + expect(conflicts[0].keys).toEqual([ + channelKey('c1', 'first'), + channelKey('c1', 'second'), + channelKey('c1', 'third'), + ]) + // Every channel still records the address it asked for. + expect(assignments[channelKey('c1', 'first')]).toBe('%QW5') + expect(assignments[channelKey('c1', 'third')]).toBe('%QW5') + }) + + it('finds the conflict winner past earlier non-matching reservations', () => { + // A pinned channel at a DIFFERENT address is reserved before the + // colliding pair, so locating the winner must skip it. + const { conflicts } = allocateAddresses([ + consumer('c1', 0, [ + { channelId: 'lead', class: word, pinned: '%QW1' }, + { channelId: 'first', class: word, pinned: '%QW5' }, + { channelId: 'second', class: word, pinned: '%QW5' }, + ]), + ]) + expect(conflicts).toHaveLength(1) + expect(conflicts[0].keys).toEqual([channelKey('c1', 'first'), channelKey('c1', 'second')]) + }) + + it('handles an empty consumer list', () => { + expect(allocateAddresses([])).toEqual({ assignments: {}, conflicts: [] }) + }) +}) diff --git a/src/middleware/shared/utils/iec-address/registry/__tests__/registry.test.ts b/src/middleware/shared/utils/iec-address/registry/__tests__/registry.test.ts new file mode 100644 index 000000000..6ed41062b --- /dev/null +++ b/src/middleware/shared/utils/iec-address/registry/__tests__/registry.test.ts @@ -0,0 +1,153 @@ +import { channelKey } from '../allocate' +import { + addConsumer, + addressOf, + createRegistry, + recalculate, + removeConsumer, + setAlias, + updateConsumer, +} from '../registry' +import type { RegistryConsumer } from '../types' + +const word = { direction: 'Q', size: 'W' } as const + +function consumer(id: string, order: number, channels: RegistryConsumer['channels']): RegistryConsumer { + return { id, kind: 'test', order, channels } +} + +describe('createRegistry', () => { + it('starts empty', () => { + expect(createRegistry()).toEqual({ consumers: [], assignments: {} }) + }) +}) + +describe('recalculate', () => { + it('is idempotent', () => { + const r1 = addConsumer(createRegistry(), consumer('c1', 0, [{ channelId: 'a', class: word }])) + const r2 = recalculate(r1).registry + expect(r2.assignments).toEqual(r1.assignments) + }) + + it('surfaces pinned conflicts', () => { + const reg = addConsumer( + createRegistry(), + consumer('c1', 0, [ + { channelId: 'a', class: word, pinned: '%QW0' }, + { channelId: 'b', class: word, pinned: '%QW0' }, + ]), + ) + expect(recalculate(reg).conflicts).toHaveLength(1) + }) +}) + +describe('addConsumer', () => { + it('appends and allocates', () => { + const reg = addConsumer(createRegistry(), consumer('c1', 0, [{ channelId: 'a', class: word }])) + expect(addressOf(reg, 'c1', 'a')).toBe('%QW0') + }) + + it('replaces a consumer with the same id', () => { + let reg = addConsumer(createRegistry(), consumer('c1', 0, [{ channelId: 'a', class: word }])) + reg = addConsumer(reg, consumer('c1', 0, [{ channelId: 'x', class: word }])) + expect(reg.consumers).toHaveLength(1) + expect(addressOf(reg, 'c1', 'a')).toBeUndefined() + expect(addressOf(reg, 'c1', 'x')).toBe('%QW0') + }) +}) + +describe('removeConsumer', () => { + it('drops the consumer and recompacts survivors into the freed slots', () => { + let reg = createRegistry() + reg = addConsumer(reg, consumer('c1', 0, [{ channelId: 'a', class: word }])) // %QW0 + reg = addConsumer(reg, consumer('c2', 1, [{ channelId: 'a', class: word }])) // %QW1 + expect(addressOf(reg, 'c2', 'a')).toBe('%QW1') + reg = removeConsumer(reg, 'c1') + // c2 slides down into the freed %QW0 — this is the gap reclamation. + expect(addressOf(reg, 'c2', 'a')).toBe('%QW0') + expect(reg.consumers).toHaveLength(1) + }) +}) + +describe('updateConsumer', () => { + it('changes channel count and recomputes', () => { + let reg = addConsumer(createRegistry(), consumer('c1', 0, [{ channelId: 'a', class: word }])) + reg = updateConsumer(reg, 'c1', { + channels: [ + { channelId: 'a', class: word }, + { channelId: 'b', class: word }, + ], + }) + expect(addressOf(reg, 'c1', 'b')).toBe('%QW1') + }) + + it('is a no-op for an unknown consumer', () => { + const reg = addConsumer(createRegistry(), consumer('c1', 0, [{ channelId: 'a', class: word }])) + expect(updateConsumer(reg, 'missing', { label: 'x' })).toBe(reg) + }) +}) + +describe('addressOf', () => { + it('returns undefined for an unknown channel', () => { + const reg = addConsumer(createRegistry(), consumer('c1', 0, [{ channelId: 'a', class: word }])) + expect(addressOf(reg, 'c1', 'nope')).toBeUndefined() + }) +}) + +describe('setAlias', () => { + const base = () => + addConsumer( + createRegistry(), + consumer('c1', 0, [ + { channelId: 'a', class: word }, + { channelId: 'b', class: word }, + ]), + ) + + it('sets an alias (trimmed)', () => { + const res = setAlias(base(), 'c1', 'a', ' push_button ') + expect(res.ok).toBe(true) + if (res.ok) expect(res.registry.consumers[0].channels[0].alias).toBe('push_button') + }) + + it('clears the alias when empty / whitespace', () => { + let reg = base() + reg = (setAlias(reg, 'c1', 'a', 'x') as { ok: true; registry: typeof reg }).registry + const res = setAlias(reg, 'c1', 'a', ' ') + expect(res.ok).toBe(true) + if (res.ok) expect(res.registry.consumers[0].channels[0].alias).toBeUndefined() + }) + + it('rejects a duplicate alias on a different channel', () => { + let reg = base() + reg = (setAlias(reg, 'c1', 'a', 'motor') as { ok: true; registry: typeof reg }).registry + const res = setAlias(reg, 'c1', 'b', 'motor') + expect(res.ok).toBe(false) + if (!res.ok) expect(res.conflict).toEqual({ alias: 'motor', consumerId: 'c1', channelId: 'a' }) + }) + + it('allows re-setting the same alias on the same channel (no-op write)', () => { + let reg = base() + reg = (setAlias(reg, 'c1', 'a', 'motor') as { ok: true; registry: typeof reg }).registry + const res = setAlias(reg, 'c1', 'a', 'motor') + expect(res.ok).toBe(true) + }) + + it('is a benign no-op when the consumer is unknown', () => { + const reg = base() + const res = setAlias(reg, 'missing', 'a', 'x') + expect(res).toEqual({ ok: true, registry: reg }) + }) + + it('is a benign no-op when the channel is unknown', () => { + const reg = base() + const res = setAlias(reg, 'c1', 'missing', 'x') + expect(res).toEqual({ ok: true, registry: reg }) + }) + + it('leaves other consumers untouched when setting an alias', () => { + let reg = addConsumer(base(), consumer('c2', 1, [{ channelId: 'a', class: word }])) + reg = (setAlias(reg, 'c1', 'a', 'motor') as { ok: true; registry: typeof reg }).registry + expect(reg.consumers.find((c) => c.id === 'c2')?.channels[0].alias).toBeUndefined() + }) +}) diff --git a/src/middleware/shared/utils/iec-address/registry/__tests__/resolve.test.ts b/src/middleware/shared/utils/iec-address/registry/__tests__/resolve.test.ts new file mode 100644 index 000000000..ae2ac9e75 --- /dev/null +++ b/src/middleware/shared/utils/iec-address/registry/__tests__/resolve.test.ts @@ -0,0 +1,76 @@ +import { channelKey } from '../allocate' +import { addConsumer, createRegistry, setAlias } from '../registry' +import { buildAliasIndex, isLiteralLocation, resolveLocation } from '../resolve' +import type { IecAddressRegistry, RegistryConsumer } from '../types' + +const word = { direction: 'Q', size: 'W' } as const + +function consumer(id: string, order: number, channels: RegistryConsumer['channels']): RegistryConsumer { + return { id, kind: 'test', order, channels } +} + +describe('buildAliasIndex', () => { + it('maps each aliased channel to its assigned address', () => { + let reg = addConsumer( + createRegistry(), + consumer('c1', 0, [ + { channelId: 'a', class: word }, + { channelId: 'b', class: word }, + ]), + ) + reg = (setAlias(reg, 'c1', 'a', 'motor') as { ok: true; registry: typeof reg }).registry + const index = buildAliasIndex(reg) + expect(index.get('motor')).toBe('%QW0') + expect(index.size).toBe(1) // channel b has no alias → absent + }) + + it('ignores an alias whose channel has no assignment', () => { + // Hand-built registry: alias present but assignments map is empty. + const reg: IecAddressRegistry = { + consumers: [consumer('c1', 0, [{ channelId: 'a', class: word, alias: 'ghost' }])], + assignments: {}, + } + expect(buildAliasIndex(reg).size).toBe(0) + }) + + it('first-wins on duplicate aliases (defensive against hand-edited files)', () => { + const reg: IecAddressRegistry = { + consumers: [ + consumer('c1', 0, [{ channelId: 'a', class: word, alias: 'dup' }]), + consumer('c2', 1, [{ channelId: 'a', class: word, alias: 'dup' }]), + ], + assignments: { + [channelKey('c1', 'a')]: '%QW0', + [channelKey('c2', 'a')]: '%QW1', + }, + } + expect(buildAliasIndex(reg).get('dup')).toBe('%QW0') + }) +}) + +describe('isLiteralLocation', () => { + it('detects % literals vs alias names', () => { + expect(isLiteralLocation('%QX0.0')).toBe(true) + expect(isLiteralLocation('push_button')).toBe(false) + }) +}) + +describe('resolveLocation', () => { + const index = new Map([['motor', '%QW3']]) + + it('returns empty for an empty field', () => { + expect(resolveLocation('', index)).toBe('') + }) + + it('honours literal addresses verbatim', () => { + expect(resolveLocation('%IX2.4', index)).toBe('%IX2.4') + }) + + it('resolves an alias to its current address', () => { + expect(resolveLocation('motor', index)).toBe('%QW3') + }) + + it('returns empty when the alias no longer resolves', () => { + expect(resolveLocation('gone', index)).toBe('') + }) +}) diff --git a/src/middleware/shared/utils/iec-address/registry/address-space.ts b/src/middleware/shared/utils/iec-address/registry/address-space.ts new file mode 100644 index 000000000..79c646ef7 --- /dev/null +++ b/src/middleware/shared/utils/iec-address/registry/address-space.ts @@ -0,0 +1,59 @@ +/** + * Address-space helpers: convert between an `AddressClass`, its IEC prefix + * string, and a linear slot index. Each prefix is an independent linear + * space (no byte/word overlap — see types.ts). + */ + +import type { AddressClass, IecDirection, IecSize } from './types' + +/** IEC prefix for a class, e.g. `{ I, X } → "%IX"`, `{ Q, W } → "%QW"`. */ +export function prefixOf(cls: AddressClass): string { + return `%${cls.direction}${cls.size}` +} + +/** Bit-addressed classes (`%IX` / `%QX` / `%MX`) use `byte.bit` notation. */ +export function isBitClass(cls: AddressClass): boolean { + return cls.size === 'X' +} + +/** Format a linear slot index back into an IEC address for a class. + * Bit classes render as `byte.bit`; all others as a flat index. */ +export function formatAddress(cls: AddressClass, linear: number): string { + const prefix = prefixOf(cls) + if (isBitClass(cls)) return `${prefix}${Math.floor(linear / 8)}.${linear % 8}` + return `${prefix}${linear}` +} + +const BIT_RE = /^%([IQM])X(\d+)\.(\d+)$/ +const WORD_RE = /^%([IQM])([BWDL])(\d+)$/ + +export interface ParsedAddress { + cls: AddressClass + /** Linear slot index within the prefix space (`byte*8 + bit` for bits). */ + linear: number +} + +/** Parse a literal IEC address into its class + linear index, or `null` + * when the string is not a recognised address. */ +export function parseAddress(address: string): ParsedAddress | null { + const bit = BIT_RE.exec(address) + if (bit) { + return { + cls: { direction: bit[1] as IecDirection, size: 'X' }, + linear: Number(bit[2]) * 8 + Number(bit[3]), + } + } + const word = WORD_RE.exec(address) + if (word) { + return { + cls: { direction: word[1] as IecDirection, size: word[2] as IecSize }, + linear: Number(word[3]), + } + } + return null +} + +/** True when the string is a syntactically valid IEC address. */ +export function isIecAddress(value: string): boolean { + return parseAddress(value) !== null +} diff --git a/src/middleware/shared/utils/iec-address/registry/allocate.ts b/src/middleware/shared/utils/iec-address/registry/allocate.ts new file mode 100644 index 000000000..bffd06050 --- /dev/null +++ b/src/middleware/shared/utils/iec-address/registry/allocate.ts @@ -0,0 +1,112 @@ +/** + * Deterministic address allocator. + * + * Given the registered consumers, assigns every channel a concrete IEC + * address. Two passes: + * 1. Reserve `pinned` channels at their literal address (fixed hardware). + * 2. Allocate the rest, lowest-free-index first, per independent prefix + * space. + * + * Order is stable (consumer `order` then `id`, channels in declaration + * order) so the output is reproducible across sessions — re-opening a + * project never gratuitously renumbers. Non-pinned channels never collide + * (they take the lowest FREE slot); only two pinned channels on the same + * literal address produce a conflict, reported first-wins. + */ + +import { formatAddress, parseAddress, prefixOf } from './address-space' +import type { AddressConflict, AllocationResult, RegistryConsumer } from './types' + +/** Stable, unambiguous map key for a channel assignment. JSON-encoding the + * pair means ids may contain any characters without risking a collision. */ +export function channelKey(consumerId: string, channelId: string): string { + return JSON.stringify([consumerId, channelId]) +} + +/** Consumers in deterministic allocation order (does not mutate input). */ +function orderedConsumers(consumers: readonly RegistryConsumer[]): RegistryConsumer[] { + return [...consumers].sort((a, b) => a.order - b.order || a.id.localeCompare(b.id)) +} + +/** Lowest non-negative integer not present in `set`. */ +function lowestFree(set: ReadonlySet): number { + let i = 0 + while (set.has(i)) i++ + return i +} + +/** First assignment key already resolved to `address` (the conflict winner). */ +function firstKeyAt(assignments: Record, address: string): string { + for (const [key, addr] of Object.entries(assignments)) { + if (addr === address) return key + } + /* istanbul ignore next -- unreachable: only called after an address was + already recorded in `assignments` during pass 1 */ + return '' +} + +export function allocateAddresses(consumers: readonly RegistryConsumer[]): AllocationResult { + const ordered = orderedConsumers(consumers) + const assignments: Record = {} + const conflicts: AddressConflict[] = [] + // prefix -> set of claimed linear slot indices + const usedByPrefix = new Map>() + const conflictByAddress = new Map() + + const used = (prefix: string): Set => { + let set = usedByPrefix.get(prefix) + if (!set) { + set = new Set() + usedByPrefix.set(prefix, set) + } + return set + } + + // Pass 1 — reserve pinned (fixed) channels. + for (const consumer of ordered) { + for (const channel of consumer.channels) { + if (!channel.pinned) continue + const key = channelKey(consumer.id, channel.channelId) + const parsed = parseAddress(channel.pinned) + // Unparseable pinned addresses are honoured verbatim but can't take + // part in the linear reservation — record the assignment and move on. + if (!parsed) { + assignments[key] = channel.pinned + continue + } + const set = used(prefixOf(parsed.cls)) + if (set.has(parsed.linear)) { + const existing = conflictByAddress.get(channel.pinned) + if (existing) { + existing.keys.push(key) + } else { + const report: AddressConflict = { + address: channel.pinned, + keys: [firstKeyAt(assignments, channel.pinned), key], + } + conflictByAddress.set(channel.pinned, report) + conflicts.push(report) + } + // Loser still records the (colliding) address it asked for. + assignments[key] = channel.pinned + continue + } + set.add(parsed.linear) + assignments[key] = channel.pinned + } + } + + // Pass 2 — allocate non-pinned channels at the lowest free slot. + for (const consumer of ordered) { + for (const channel of consumer.channels) { + if (channel.pinned) continue + const prefix = prefixOf(channel.class) + const set = used(prefix) + const linear = lowestFree(set) + set.add(linear) + assignments[channelKey(consumer.id, channel.channelId)] = formatAddress(channel.class, linear) + } + } + + return { assignments, conflicts } +} diff --git a/src/middleware/shared/utils/iec-address/registry/index.ts b/src/middleware/shared/utils/iec-address/registry/index.ts new file mode 100644 index 000000000..d1291e148 --- /dev/null +++ b/src/middleware/shared/utils/iec-address/registry/index.ts @@ -0,0 +1,24 @@ +export { formatAddress, isBitClass, isIecAddress, parseAddress, type ParsedAddress, prefixOf } from './address-space' +export { allocateAddresses, channelKey } from './allocate' +export { + addConsumer, + addressOf, + createRegistry, + recalculate, + removeConsumer, + setAlias, + updateConsumer, +} from './registry' +export { buildAliasIndex, isLiteralLocation, resolveLocation } from './resolve' +export type { + AddressClass, + AddressConflict, + AllocationResult, + ConsumerKind, + IecAddressRegistry, + IecDirection, + IecSize, + RegistryChannel, + RegistryConsumer, + SetAliasResult, +} from './types' diff --git a/src/middleware/shared/utils/iec-address/registry/registry.ts b/src/middleware/shared/utils/iec-address/registry/registry.ts new file mode 100644 index 000000000..fbc276196 --- /dev/null +++ b/src/middleware/shared/utils/iec-address/registry/registry.ts @@ -0,0 +1,111 @@ +/** + * Pure registry operations. Every mutator returns a NEW registry (no + * in-place mutation) with `assignments` recomputed, so the Zustand slice + * can wrap these 1:1 and callers always see a coherent, gapless result. + */ + +import { allocateAddresses, channelKey } from './allocate' +import type { AddressConflict, IecAddressRegistry, RegistryChannel, RegistryConsumer, SetAliasResult } from './types' + +export function createRegistry(): IecAddressRegistry { + return { consumers: [], assignments: {} } +} + +/** Reassign every address from the registered consumers. Deterministic, + * gapless, and idempotent (recalculating an unchanged registry is a + * no-op). Returns the new registry plus any pinned-address conflicts. */ +export function recalculate(registry: IecAddressRegistry): { + registry: IecAddressRegistry + conflicts: AddressConflict[] +} { + const { assignments, conflicts } = allocateAddresses(registry.consumers) + return { registry: { consumers: registry.consumers, assignments }, conflicts } +} + +/** Register a new consumer (or replace one with the same id) and recompute. */ +export function addConsumer(registry: IecAddressRegistry, consumer: RegistryConsumer): IecAddressRegistry { + const consumers = registry.consumers.filter((c) => c.id !== consumer.id) + consumers.push(consumer) + return recalculate({ ...registry, consumers }).registry +} + +/** Remove a consumer by id and recompute so survivors reclaim its slots. */ +export function removeConsumer(registry: IecAddressRegistry, consumerId: string): IecAddressRegistry { + const consumers = registry.consumers.filter((c) => c.id !== consumerId) + return recalculate({ ...registry, consumers }).registry +} + +/** Patch a consumer's channels / label / order and recompute. No-op when + * the consumer is unknown. */ +export function updateConsumer( + registry: IecAddressRegistry, + consumerId: string, + patch: Partial>, +): IecAddressRegistry { + let found = false + const consumers = registry.consumers.map((c) => { + if (c.id !== consumerId) return c + found = true + return { ...c, ...patch } + }) + if (!found) return registry + return recalculate({ ...registry, consumers }).registry +} + +/** The resolved address for a channel, or `undefined` when unknown. */ +export function addressOf(registry: IecAddressRegistry, consumerId: string, channelId: string): string | undefined { + return registry.assignments[channelKey(consumerId, channelId)] +} + +/** Find a channel (and its owning consumer) by ids. */ +function findChannel( + registry: IecAddressRegistry, + consumerId: string, + channelId: string, +): { consumer: RegistryConsumer; channel: RegistryChannel } | undefined { + const consumer = registry.consumers.find((c) => c.id === consumerId) + if (!consumer) return undefined + const channel = consumer.channels.find((ch) => ch.channelId === channelId) + if (!channel) return undefined + return { consumer, channel } +} + +/** + * Set (or clear) a channel's alias, enforcing system-wide uniqueness — this + * is the ONE place aliases are validated. An empty / whitespace-only alias + * clears it. Assignments are unaffected (an alias does not change + * allocation), so no recompute is needed. + */ +export function setAlias( + registry: IecAddressRegistry, + consumerId: string, + channelId: string, + alias: string, +): SetAliasResult { + const target = findChannel(registry, consumerId, channelId) + if (!target) return { ok: true, registry } + + const trimmed = alias.trim() + + if (trimmed.length > 0) { + for (const consumer of registry.consumers) { + for (const channel of consumer.channels) { + if (consumer.id === consumerId && channel.channelId === channelId) continue + if (channel.alias === trimmed) { + return { ok: false, conflict: { alias: trimmed, consumerId: consumer.id, channelId: channel.channelId } } + } + } + } + } + + const consumers = registry.consumers.map((c) => { + if (c.id !== consumerId) return c + return { + ...c, + channels: c.channels.map((ch) => + ch.channelId === channelId ? { ...ch, alias: trimmed.length > 0 ? trimmed : undefined } : ch, + ), + } + }) + return { ok: true, registry: { ...registry, consumers } } +} diff --git a/src/middleware/shared/utils/iec-address/registry/resolve.ts b/src/middleware/shared/utils/iec-address/registry/resolve.ts new file mode 100644 index 000000000..2bc68fe3e --- /dev/null +++ b/src/middleware/shared/utils/iec-address/registry/resolve.ts @@ -0,0 +1,49 @@ +/** + * Compile-time resolution of a program variable's location field. + * + * A variable's location holds EITHER an alias name OR a literal IEC address. + * The compiler only understands IEC addresses, so the editor resolves: + * - literal `%…` → used verbatim (manual locations are honoured + * exactly as typed; the allocator neither + * reserves nor avoids them); + * - alias that still exists → the alias's current address; + * - alias that is gone → empty location (variable becomes unlocated). + */ + +import { channelKey } from './allocate' +import type { IecAddressRegistry } from './types' + +/** Build the `alias → assigned address` lookup from the registry. Aliases + * without an assignment (e.g. a channel whose address failed to allocate) + * are omitted. Later duplicate aliases are ignored (first wins) — the + * `setAlias` gate prevents duplicates from being created in the first + * place; this is defensive for hand-edited / migrated projects. */ +export function buildAliasIndex(registry: IecAddressRegistry): Map { + const index = new Map() + for (const consumer of registry.consumers) { + for (const channel of consumer.channels) { + if (!channel.alias) continue + if (index.has(channel.alias)) continue + const address = registry.assignments[channelKey(consumer.id, channel.channelId)] + if (address) index.set(channel.alias, address) + } + } + return index +} + +/** True when the string is a literal IEC location (starts with `%`) rather + * than an alias reference. Aliases can never start with `%`. */ +export function isLiteralLocation(field: string): boolean { + return field.startsWith('%') +} + +/** + * Resolve a variable's location field to the concrete IEC address the + * compiler should emit. Returns `''` for an empty field or an alias that no + * longer resolves (the emitters then drop the `AT %…`). + */ +export function resolveLocation(field: string, aliasIndex: ReadonlyMap): string { + if (!field) return '' + if (isLiteralLocation(field)) return field + return aliasIndex.get(field) ?? '' +} diff --git a/src/middleware/shared/utils/iec-address/registry/types.ts b/src/middleware/shared/utils/iec-address/registry/types.ts new file mode 100644 index 000000000..ffe5c8892 --- /dev/null +++ b/src/middleware/shared/utils/iec-address/registry/types.ts @@ -0,0 +1,83 @@ +/** + * Central IEC address registry — data model. + * + * The registry is the single source of truth for which consumers exist, + * what addresses they are assigned, and which aliases point where. See + * docs/iec-address-registry.md for the full architecture. + * + * Producers (pin mapping, VPP, Modbus, EtherCAT, …) register CONSUMERS. + * Each consumer declares CHANNELS — individual address requests. A + * channel's `channelId` is stable and address-independent: aliases and + * program-variable bindings attach to the channel, so they follow the + * channel as reallocation moves its address. + */ + +/** IEC 61131-3 location area. */ +export type IecDirection = 'I' | 'Q' | 'M' + +/** IEC 61131-3 size prefix. `X` is bit (byte.bit addressed); the rest are + * index-addressed. Each (direction, size) pair is an INDEPENDENT linear + * space — the OpenPLC runtime uses separate typed buffers per prefix, so + * `%IB0` / `%IW0` / `%ID0` do NOT overlap. */ +export type IecSize = 'X' | 'B' | 'W' | 'D' | 'L' + +export interface AddressClass { + direction: IecDirection + size: IecSize +} + +/** A single address request within a consumer. */ +export interface RegistryChannel { + /** Stable, address-independent identity within the owning consumer. + * What an alias / variable binds to, so it survives reallocation. */ + channelId: string + class: AddressClass + /** User-facing alias, unique system-wide. Empty / undefined = none. */ + alias?: string + /** Fixed hardware address (e.g. an Arduino pin). When set the channel is + * RESERVED at this literal address instead of being allocated. */ + pinned?: string +} + +/** Well-known consumer kinds. Left open (`string`) so future producers can + * register without changing the core. */ +export type ConsumerKind = 'pin-mapping' | 'vpp-io' | 'modbus-tcp-remote' | 'ethercat' | (string & {}) + +export interface RegistryConsumer { + id: string + kind: ConsumerKind + label?: string + /** Deterministic allocation order (lower allocates first). Ties are + * broken by `id` so the result is reproducible across sessions. */ + order: number + channels: RegistryChannel[] +} + +/** + * The serialized source of truth. + * + * `assignments` is a derived cache — `key(consumerId, channelId) → address` + * — rebuilt by `recalculate()`. It is persisted so the compiler and UI have + * stable addresses without recomputing, but it is always a pure function of + * `consumers`. + */ +export interface IecAddressRegistry { + consumers: RegistryConsumer[] + assignments: Record +} + +/** Two `pinned` channels resolved to the same literal address. */ +export interface AddressConflict { + address: string + /** Channel keys that claimed the address, in encounter order. First won. */ + keys: string[] +} + +export interface AllocationResult { + assignments: Record + conflicts: AddressConflict[] +} + +export type SetAliasResult = + | { ok: true; registry: IecAddressRegistry } + | { ok: false; conflict: { alias: string; consumerId: string; channelId: string } } From 2143313775b8a71ef7ae9cab4cc22f8a98df39f9 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 2 Jul 2026 21:09:04 -0400 Subject: [PATCH 15/35] feat(iec-address): capability scoping + migrate-on-open transform (Phase 2a) Extends the pure core toward registry-of-record: - Capability scoping: `allocateAddresses` / `recalculate` / consumer mutators accept `activeKinds`. Consumers whose kind is inactive on the current target are excluded from allocation (their channels get no address) while staying registered; the survivors recompact project-wide. This is what makes switching a project from a platform WITH pin mapping / VPP to one WITHOUT recompute addresses correctly. - `migrateToRegistry(PoolInputs)`: pure transform that rebuilds the registry from legacy scattered producer state (pins, VPP io-mapping, Modbus ioPoints, EtherCAT channelMappings + their aliases), seeding each channel `pinned` at its current address so the first recalc reproduces today's layout exactly (nothing moves on open). `unpinAllocatableChannels` then releases those seeds on everything except real hardware pins so later edits / target switches can compact. Additive only. 52 unit tests, 100% coverage on the registry modules. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../registry/__tests__/allocate.test.ts | 17 ++ .../registry/__tests__/migrate.test.ts | 172 ++++++++++++++++++ .../registry/__tests__/registry.test.ts | 15 ++ .../utils/iec-address/registry/allocate.ts | 10 +- .../utils/iec-address/registry/index.ts | 2 + .../utils/iec-address/registry/migrate.ts | 149 +++++++++++++++ .../utils/iec-address/registry/registry.ts | 39 +++- .../utils/iec-address/registry/types.ts | 14 ++ 8 files changed, 406 insertions(+), 12 deletions(-) create mode 100644 src/middleware/shared/utils/iec-address/registry/__tests__/migrate.test.ts create mode 100644 src/middleware/shared/utils/iec-address/registry/migrate.ts diff --git a/src/middleware/shared/utils/iec-address/registry/__tests__/allocate.test.ts b/src/middleware/shared/utils/iec-address/registry/__tests__/allocate.test.ts index 4d2ff3f31..4f623e5df 100644 --- a/src/middleware/shared/utils/iec-address/registry/__tests__/allocate.test.ts +++ b/src/middleware/shared/utils/iec-address/registry/__tests__/allocate.test.ts @@ -105,6 +105,23 @@ describe('allocateAddresses', () => { expect(conflicts[0].keys).toEqual([channelKey('c1', 'first'), channelKey('c1', 'second')]) }) + it('excludes consumers whose kind is inactive and recompacts survivors', () => { + const consumers: RegistryConsumer[] = [ + { id: 'pins', kind: 'pin-mapping', order: 0, channels: [{ channelId: 'a', class: word }] }, + { id: 'mb', kind: 'modbus-tcp-remote', order: 1, channels: [{ channelId: 'a', class: word }] }, + ] + // With pin-mapping inactive (e.g. a Runtime v4 target), only the Modbus + // consumer allocates — and it takes %QW0, not %QW1. + const active = allocateAddresses(consumers, { activeKinds: new Set(['modbus-tcp-remote']) }) + expect(active.assignments[channelKey('pins', 'a')]).toBeUndefined() + expect(active.assignments[channelKey('mb', 'a')]).toBe('%QW0') + + // With both active, pin-mapping (order 0) takes %QW0 and Modbus %QW1. + const both = allocateAddresses(consumers) + expect(both.assignments[channelKey('pins', 'a')]).toBe('%QW0') + expect(both.assignments[channelKey('mb', 'a')]).toBe('%QW1') + }) + it('handles an empty consumer list', () => { expect(allocateAddresses([])).toEqual({ assignments: {}, conflicts: [] }) }) diff --git a/src/middleware/shared/utils/iec-address/registry/__tests__/migrate.test.ts b/src/middleware/shared/utils/iec-address/registry/__tests__/migrate.test.ts new file mode 100644 index 000000000..77b16a2b1 --- /dev/null +++ b/src/middleware/shared/utils/iec-address/registry/__tests__/migrate.test.ts @@ -0,0 +1,172 @@ +import type { PoolInputs } from '../../address-pool' +import { channelKey } from '../allocate' +import { migrateToRegistry, unpinAllocatableChannels } from '../migrate' +import { buildAliasIndex } from '../resolve' + +describe('migrateToRegistry', () => { + it('reproduces legacy addresses exactly across all producers', () => { + const inputs: PoolInputs = { + pinMapping: { pins: [{ address: '%IX0.0', alias: 'button' }, { address: '%QX0.1' }] }, + vendorIoMapping: { + entries: [ + { slot: 2, channelName: 'O1', iecAddress: '%QW3', alias: 'valve' }, + { slot: 1, channelName: 'I1', iecAddress: '%IW0' }, + ], + }, + remoteDevices: [ + { + name: 'plc1', + modbusTcpConfig: { + ioGroups: [{ id: 'g1', ioPoints: [{ id: 'p0', iecLocation: '%IW5', alias: 'temp' }] }], + }, + ethercatConfig: { + devices: [{ name: 's1', channelMappings: [{ channelId: 'c0', iecLocation: '%QW9' }] }], + }, + }, + ], + } + + const reg = migrateToRegistry(inputs) + + expect(reg.assignments[channelKey('pin-mapping', '%IX0.0')]).toBe('%IX0.0') + expect(reg.assignments[channelKey('pin-mapping', '%QX0.1')]).toBe('%QX0.1') + expect(reg.assignments[channelKey('vpp-slot-1', 'I1')]).toBe('%IW0') + expect(reg.assignments[channelKey('vpp-slot-2', 'O1')]).toBe('%QW3') + expect(reg.assignments[channelKey('modbus:plc1:g1', 'p0')]).toBe('%IW5') + expect(reg.assignments[channelKey('ethercat:plc1:s1', 'c0')]).toBe('%QW9') + + // Aliases carried over and resolve to their legacy addresses. + const aliases = buildAliasIndex(reg) + expect(aliases.get('button')).toBe('%IX0.0') + expect(aliases.get('valve')).toBe('%QW3') + expect(aliases.get('temp')).toBe('%IW5') + }) + + it('skips unparseable / missing addresses and empty groups', () => { + const inputs: PoolInputs = { + pinMapping: { pins: [{ address: '' }, { address: 'NOPE' }, { address: '%IX0.0' }] }, + remoteDevices: [ + { name: 'd', modbusTcpConfig: { ioGroups: [{ id: 'empty', ioPoints: [] }] } }, + { deviceName: 'd2', ethercatConfig: { devices: [{ channelMappings: [] }] } }, + ], + } + const reg = migrateToRegistry(inputs) + // Only the valid pin survives; empty modbus/ethercat produce no consumer. + expect(reg.consumers).toHaveLength(1) + expect(reg.consumers[0].id).toBe('pin-mapping') + }) + + it('handles a fully empty project', () => { + expect(migrateToRegistry({}).consumers).toEqual([]) + }) + + it('skips a bad VPP entry and producers with no channel list', () => { + const inputs: PoolInputs = { + vendorIoMapping: { entries: [{ slot: 1, channelName: 'bad', iecAddress: 'NOPE' }] }, + remoteDevices: [ + { name: 'd', modbusTcpConfig: { ioGroups: [{ id: 'g' }] } }, // ioPoints undefined + { name: 'e', ethercatConfig: { devices: [{ name: 's' }] } }, // channelMappings undefined + ], + } + expect(migrateToRegistry(inputs).consumers).toEqual([]) + }) + + it('falls back to a group index when the group has no id', () => { + const inputs: PoolInputs = { + remoteDevices: [{ name: 'd', modbusTcpConfig: { ioGroups: [{ ioPoints: [{ id: 'p', iecLocation: '%IW0' }] }] } }], + } + const reg = migrateToRegistry(inputs) + expect(reg.consumers[0].id).toBe('modbus:d:0') + }) + + it('groups multiple VPP channels under one slot consumer', () => { + const inputs: PoolInputs = { + vendorIoMapping: { + entries: [ + { slot: 1, channelName: 'I1', iecAddress: '%IW0' }, + { slot: 1, channelName: 'I2', iecAddress: '%IW1' }, + ], + }, + } + const reg = migrateToRegistry(inputs) + expect(reg.consumers).toHaveLength(1) + expect(reg.consumers[0].channels.map((c) => c.channelId)).toEqual(['I1', 'I2']) + }) + + it('prefers deviceName, falls back to name then a generic label', () => { + const inputs: PoolInputs = { + remoteDevices: [ + { + deviceName: 'byDeviceName', + modbusTcpConfig: { ioGroups: [{ id: 'g', ioPoints: [{ id: 'p', iecLocation: '%IW0' }] }] }, + }, + { ethercatConfig: { devices: [{ name: 'sl', channelMappings: [{ channelId: 'c', iecLocation: '%QW0' }] }] } }, + ], + } + const reg = migrateToRegistry(inputs) + expect(reg.consumers.some((c) => c.id === 'modbus:byDeviceName:g')).toBe(true) + // No name/deviceName on the ethercat device → generic 'device'. + expect(reg.consumers.some((c) => c.id === 'ethercat:device:sl')).toBe(true) + }) + + it('skips unparseable modbus points and ethercat mappings', () => { + const inputs: PoolInputs = { + remoteDevices: [ + { + name: 'd', + modbusTcpConfig: { + ioGroups: [ + { + id: 'g', + ioPoints: [ + { id: 'bad', iecLocation: 'NOPE' }, + { id: 'ok', iecLocation: '%IW0' }, + ], + }, + ], + }, + ethercatConfig: { + devices: [{ name: 's', channelMappings: [{ channelId: 'bad', iecLocation: 'NOPE' }] }], + }, + }, + ], + } + const reg = migrateToRegistry(inputs) + const modbus = reg.consumers.find((c) => c.kind === 'modbus-tcp-remote')! + expect(modbus.channels.map((c) => c.channelId)).toEqual(['ok']) + // The ethercat slave had only an unparseable mapping → no consumer. + expect(reg.consumers.some((c) => c.kind === 'ethercat')).toBe(false) + }) +}) + +describe('unpinAllocatableChannels', () => { + it('clears pinned on everything except hardware pins', () => { + const inputs: PoolInputs = { + pinMapping: { pins: [{ address: '%IX0.0' }] }, + remoteDevices: [ + { name: 'd', modbusTcpConfig: { ioGroups: [{ id: 'g', ioPoints: [{ id: 'p', iecLocation: '%IW0' }] }] } }, + ], + } + const unpinned = unpinAllocatableChannels(migrateToRegistry(inputs)) + + const pins = unpinned.consumers.find((c) => c.kind === 'pin-mapping')! + const modbus = unpinned.consumers.find((c) => c.kind === 'modbus-tcp-remote')! + expect(pins.channels[0].pinned).toBe('%IX0.0') // hardware pin stays pinned + expect(modbus.channels[0].pinned).toBeUndefined() // freed for compaction + }) + + it('leaves already-unpinned channels untouched', () => { + const reg = { + consumers: [ + { + id: 'x', + kind: 'vpp-io', + order: 0, + channels: [{ channelId: 'a', class: { direction: 'Q', size: 'W' } as const }], + }, + ], + assignments: {}, + } + expect(unpinAllocatableChannels(reg)).toEqual(reg) + }) +}) diff --git a/src/middleware/shared/utils/iec-address/registry/__tests__/registry.test.ts b/src/middleware/shared/utils/iec-address/registry/__tests__/registry.test.ts index 6ed41062b..c19dcecfe 100644 --- a/src/middleware/shared/utils/iec-address/registry/__tests__/registry.test.ts +++ b/src/middleware/shared/utils/iec-address/registry/__tests__/registry.test.ts @@ -29,6 +29,21 @@ describe('recalculate', () => { expect(r2.assignments).toEqual(r1.assignments) }) + it('scopes allocation to active consumer kinds', () => { + let reg = createRegistry() + reg = addConsumer(reg, { id: 'pins', kind: 'pin-mapping', order: 0, channels: [{ channelId: 'a', class: word }] }) + reg = addConsumer(reg, { + id: 'mb', + kind: 'modbus-tcp-remote', + order: 1, + channels: [{ channelId: 'a', class: word }], + }) + // Simulate switching to a target without pin mapping. + const scoped = recalculate(reg, { activeKinds: new Set(['modbus-tcp-remote']) }) + expect(scoped.registry.assignments[channelKey('pins', 'a')]).toBeUndefined() + expect(scoped.registry.assignments[channelKey('mb', 'a')]).toBe('%QW0') + }) + it('surfaces pinned conflicts', () => { const reg = addConsumer( createRegistry(), diff --git a/src/middleware/shared/utils/iec-address/registry/allocate.ts b/src/middleware/shared/utils/iec-address/registry/allocate.ts index bffd06050..d58bccdf5 100644 --- a/src/middleware/shared/utils/iec-address/registry/allocate.ts +++ b/src/middleware/shared/utils/iec-address/registry/allocate.ts @@ -15,7 +15,7 @@ */ import { formatAddress, parseAddress, prefixOf } from './address-space' -import type { AddressConflict, AllocationResult, RegistryConsumer } from './types' +import type { AddressConflict, AllocateOptions, AllocationResult, RegistryConsumer } from './types' /** Stable, unambiguous map key for a channel assignment. JSON-encoding the * pair means ids may contain any characters without risking a collision. */ @@ -45,8 +45,12 @@ function firstKeyAt(assignments: Record, address: string): strin return '' } -export function allocateAddresses(consumers: readonly RegistryConsumer[]): AllocationResult { - const ordered = orderedConsumers(consumers) +export function allocateAddresses( + consumers: readonly RegistryConsumer[], + options: AllocateOptions = {}, +): AllocationResult { + const { activeKinds } = options + const ordered = orderedConsumers(consumers).filter((c) => !activeKinds || activeKinds.has(c.kind)) const assignments: Record = {} const conflicts: AddressConflict[] = [] // prefix -> set of claimed linear slot indices diff --git a/src/middleware/shared/utils/iec-address/registry/index.ts b/src/middleware/shared/utils/iec-address/registry/index.ts index d1291e148..fb33b52da 100644 --- a/src/middleware/shared/utils/iec-address/registry/index.ts +++ b/src/middleware/shared/utils/iec-address/registry/index.ts @@ -1,5 +1,6 @@ export { formatAddress, isBitClass, isIecAddress, parseAddress, type ParsedAddress, prefixOf } from './address-space' export { allocateAddresses, channelKey } from './allocate' +export { migrateToRegistry, unpinAllocatableChannels } from './migrate' export { addConsumer, addressOf, @@ -13,6 +14,7 @@ export { buildAliasIndex, isLiteralLocation, resolveLocation } from './resolve' export type { AddressClass, AddressConflict, + AllocateOptions, AllocationResult, ConsumerKind, IecAddressRegistry, diff --git a/src/middleware/shared/utils/iec-address/registry/migrate.ts b/src/middleware/shared/utils/iec-address/registry/migrate.ts new file mode 100644 index 000000000..cbe3233ad --- /dev/null +++ b/src/middleware/shared/utils/iec-address/registry/migrate.ts @@ -0,0 +1,149 @@ +/** + * Migrate a legacy project's scattered producer addresses into the central + * registry (see docs/iec-address-registry.md §7). + * + * The transform reuses the loosely-typed `PoolInputs` shapes the old + * address pool already consumed — pins, VPP `io-mapping` entries, Modbus + * `ioPoints`, EtherCAT `channelMappings` — so it stays decoupled from the + * full project schema. + * + * Each channel is seeded with `pinned = its current legacy address`, so the + * first `recalculate` reproduces exactly today's addresses (nothing moves on + * open). The store then: + * 1. adopts program variables onto the aliases at these legacy addresses, + * 2. calls `unpinAllocatableChannels` to release the seeds on everything + * except real hardware pins, and + * 3. recalculates (capability-scoped), after which allocatable producers + * compact and alias-bound variables follow their moved addresses. + */ + +import type { PoolInputs } from '../address-pool' +import { parseAddress } from './address-space' +import { recalculate } from './registry' +import type { AddressClass, IecAddressRegistry, RegistryChannel, RegistryConsumer } from './types' + +const PIN_MAPPING_KIND = 'pin-mapping' + +/** Build a channel seeded (pinned) at a legacy address, or `null` when the + * address is not a parseable IEC location (nothing to migrate). */ +function seedChannel( + channelId: string, + address: string | undefined, + alias: string | undefined, +): RegistryChannel | null { + if (!address) return null + const parsed = parseAddress(address) + if (!parsed) return null + const cls: AddressClass = parsed.cls + const channel: RegistryChannel = { channelId, class: cls, pinned: address } + if (alias && alias.length > 0) channel.alias = alias + return channel +} + +/** + * Build the registry from legacy producer state. Consumers are emitted in a + * stable order (pins → VPP slots → Modbus groups → EtherCAT slaves) matching + * the historical reservation order. Addresses are reproduced exactly. + */ +export function migrateToRegistry(inputs: PoolInputs): IecAddressRegistry { + const consumers: RegistryConsumer[] = [] + let order = 0 + + // 1. Pin mapping — one consumer, one channel per pin (fixed hardware). + const pinChannels: RegistryChannel[] = [] + for (const pin of inputs.pinMapping?.pins ?? []) { + const channel = seedChannel(pin.address, pin.address, pin.alias) + if (channel) pinChannels.push(channel) + } + if (pinChannels.length > 0) { + consumers.push({ id: PIN_MAPPING_KIND, kind: PIN_MAPPING_KIND, order: order++, channels: pinChannels }) + } + + // 2. VPP I/O — one consumer per slot; channels keyed by channel name. + const bySlot = new Map() + for (const entry of inputs.vendorIoMapping?.entries ?? []) { + const channel = seedChannel(entry.channelName, entry.iecAddress, entry.alias) + if (!channel) continue + const list = bySlot.get(entry.slot) + if (list) list.push(channel) + else bySlot.set(entry.slot, [channel]) + } + for (const slot of [...bySlot.keys()].sort((a, b) => a - b)) { + consumers.push({ + id: `vpp-slot-${slot}`, + kind: 'vpp-io', + label: `Slot ${slot}`, + order: order++, + channels: bySlot.get(slot)!, + }) + } + + // 3. Modbus TCP remote — one consumer per IO group. + for (const device of inputs.remoteDevices ?? []) { + const deviceName = device.deviceName || device.name || 'device' + const groups = device.modbusTcpConfig?.ioGroups ?? [] + for (let g = 0; g < groups.length; g++) { + const group = groups[g] + const groupId = group.id ?? String(g) + const channels: RegistryChannel[] = [] + for (const point of group.ioPoints ?? []) { + const channel = seedChannel(point.id, point.iecLocation, point.alias) + if (channel) channels.push(channel) + } + if (channels.length > 0) { + consumers.push({ + id: `modbus:${deviceName}:${groupId}`, + kind: 'modbus-tcp-remote', + label: `${deviceName} / ${groupId}`, + order: order++, + channels, + }) + } + } + } + + // 4. EtherCAT — one consumer per slave device. + for (const device of inputs.remoteDevices ?? []) { + const deviceName = device.deviceName || device.name || 'device' + for (const slave of device.ethercatConfig?.devices ?? []) { + const slaveName = slave.name || 'slave' + const channels: RegistryChannel[] = [] + for (const mapping of slave.channelMappings ?? []) { + const channel = seedChannel(mapping.channelId, mapping.iecLocation, mapping.alias) + if (channel) channels.push(channel) + } + if (channels.length > 0) { + consumers.push({ + id: `ethercat:${deviceName}:${slaveName}`, + kind: 'ethercat', + label: `${deviceName} / ${slaveName}`, + order: order++, + channels, + }) + } + } + } + + // Reproduce the legacy addresses exactly (all channels are pinned). + return recalculate({ consumers, assignments: {} }).registry +} + +/** + * Release the migration seeds: clear `pinned` on every channel EXCEPT real + * hardware pins (`pin-mapping`), so a subsequent `recalculate` is free to + * compact allocatable producers. Pure. + */ +export function unpinAllocatableChannels(registry: IecAddressRegistry): IecAddressRegistry { + const consumers = registry.consumers.map((consumer) => { + if (consumer.kind === PIN_MAPPING_KIND) return consumer + return { + ...consumer, + channels: consumer.channels.map((channel) => { + if (channel.pinned === undefined) return channel + const { pinned: _pinned, ...rest } = channel + return rest + }), + } + }) + return { ...registry, consumers } +} diff --git a/src/middleware/shared/utils/iec-address/registry/registry.ts b/src/middleware/shared/utils/iec-address/registry/registry.ts index fbc276196..ad726a4cf 100644 --- a/src/middleware/shared/utils/iec-address/registry/registry.ts +++ b/src/middleware/shared/utils/iec-address/registry/registry.ts @@ -5,7 +5,14 @@ */ import { allocateAddresses, channelKey } from './allocate' -import type { AddressConflict, IecAddressRegistry, RegistryChannel, RegistryConsumer, SetAliasResult } from './types' +import type { + AddressConflict, + AllocateOptions, + IecAddressRegistry, + RegistryChannel, + RegistryConsumer, + SetAliasResult, +} from './types' export function createRegistry(): IecAddressRegistry { return { consumers: [], assignments: {} } @@ -13,26 +20,39 @@ export function createRegistry(): IecAddressRegistry { /** Reassign every address from the registered consumers. Deterministic, * gapless, and idempotent (recalculating an unchanged registry is a - * no-op). Returns the new registry plus any pinned-address conflicts. */ -export function recalculate(registry: IecAddressRegistry): { + * no-op). Pass `options.activeKinds` to scope allocation to the current + * target's capabilities. Returns the new registry plus any pinned-address + * conflicts. */ +export function recalculate( + registry: IecAddressRegistry, + options: AllocateOptions = {}, +): { registry: IecAddressRegistry conflicts: AddressConflict[] } { - const { assignments, conflicts } = allocateAddresses(registry.consumers) + const { assignments, conflicts } = allocateAddresses(registry.consumers, options) return { registry: { consumers: registry.consumers, assignments }, conflicts } } /** Register a new consumer (or replace one with the same id) and recompute. */ -export function addConsumer(registry: IecAddressRegistry, consumer: RegistryConsumer): IecAddressRegistry { +export function addConsumer( + registry: IecAddressRegistry, + consumer: RegistryConsumer, + options: AllocateOptions = {}, +): IecAddressRegistry { const consumers = registry.consumers.filter((c) => c.id !== consumer.id) consumers.push(consumer) - return recalculate({ ...registry, consumers }).registry + return recalculate({ ...registry, consumers }, options).registry } /** Remove a consumer by id and recompute so survivors reclaim its slots. */ -export function removeConsumer(registry: IecAddressRegistry, consumerId: string): IecAddressRegistry { +export function removeConsumer( + registry: IecAddressRegistry, + consumerId: string, + options: AllocateOptions = {}, +): IecAddressRegistry { const consumers = registry.consumers.filter((c) => c.id !== consumerId) - return recalculate({ ...registry, consumers }).registry + return recalculate({ ...registry, consumers }, options).registry } /** Patch a consumer's channels / label / order and recompute. No-op when @@ -41,6 +61,7 @@ export function updateConsumer( registry: IecAddressRegistry, consumerId: string, patch: Partial>, + options: AllocateOptions = {}, ): IecAddressRegistry { let found = false const consumers = registry.consumers.map((c) => { @@ -49,7 +70,7 @@ export function updateConsumer( return { ...c, ...patch } }) if (!found) return registry - return recalculate({ ...registry, consumers }).registry + return recalculate({ ...registry, consumers }, options).registry } /** The resolved address for a channel, or `undefined` when unknown. */ diff --git a/src/middleware/shared/utils/iec-address/registry/types.ts b/src/middleware/shared/utils/iec-address/registry/types.ts index ffe5c8892..273960363 100644 --- a/src/middleware/shared/utils/iec-address/registry/types.ts +++ b/src/middleware/shared/utils/iec-address/registry/types.ts @@ -81,3 +81,17 @@ export interface AllocationResult { export type SetAliasResult = | { ok: true; registry: IecAddressRegistry } | { ok: false; conflict: { alias: string; consumerId: string; channelId: string } } + +export interface AllocateOptions { + /** + * When provided, consumers whose `kind` is NOT in this set are excluded + * from allocation — their channels receive no address. This is how + * target-capability scoping works: a platform without pin mapping or VPP + * I/O simply deactivates those consumer kinds. The consumers stay + * registered (aliases preserved), so switching back to a capable target + * restores them; meanwhile the still-active consumers recompact + * project-wide into the freed space. Omitting the set treats every kind + * as active. + */ + activeKinds?: ReadonlySet +} From 18393987a1a7d9be1d26d132ea6c0e74b84e7bf4 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 2 Jul 2026 21:28:39 -0400 Subject: [PATCH 16/35] feat(iec-address): central recalculateFromLegacy orchestrator (Phase 2b-core) `recalculateFromLegacy(PoolInputs, { activeKinds })` composes the migration + unpin + capability-scoped recalculate into the single project-wide recomputation the store will drive: derive consumers from every live producer, keep hardware pins fixed, and re-pack allocatable producers so freed slots are reclaimed (bug #4) and inactive-kind producers drop out on a target switch. Aliases ride their channels, so variable reconciliation makes program variables follow. Pure + fully tested (55 tests, 100%). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../registry/__tests__/migrate.test.ts | 58 ++++++++++++++++++- .../utils/iec-address/registry/index.ts | 2 +- .../utils/iec-address/registry/migrate.ts | 33 ++++++++++- 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/src/middleware/shared/utils/iec-address/registry/__tests__/migrate.test.ts b/src/middleware/shared/utils/iec-address/registry/__tests__/migrate.test.ts index 77b16a2b1..107ec0d91 100644 --- a/src/middleware/shared/utils/iec-address/registry/__tests__/migrate.test.ts +++ b/src/middleware/shared/utils/iec-address/registry/__tests__/migrate.test.ts @@ -1,6 +1,6 @@ import type { PoolInputs } from '../../address-pool' import { channelKey } from '../allocate' -import { migrateToRegistry, unpinAllocatableChannels } from '../migrate' +import { migrateToRegistry, recalculateFromLegacy, unpinAllocatableChannels } from '../migrate' import { buildAliasIndex } from '../resolve' describe('migrateToRegistry', () => { @@ -139,6 +139,62 @@ describe('migrateToRegistry', () => { }) }) +describe('recalculateFromLegacy', () => { + it('recompacts a gap left by a removed group and keeps aliases following', () => { + // Two modbus groups; the first (%IW0/%IW1) has been removed, leaving the + // survivor at %IW2/%IW3 in the legacy data — a gap. + const inputs: PoolInputs = { + remoteDevices: [ + { + name: 'd', + modbusTcpConfig: { + ioGroups: [ + { + id: 'g2', + ioPoints: [ + { id: 'p0', iecLocation: '%IW2', alias: 'temp' }, + { id: 'p1', iecLocation: '%IW3' }, + ], + }, + ], + }, + }, + ], + } + const { registry } = recalculateFromLegacy(inputs) + // The survivor slides down to %IW0/%IW1 — gap reclaimed. + expect(registry.assignments[channelKey('modbus:d:g2', 'p0')]).toBe('%IW0') + expect(registry.assignments[channelKey('modbus:d:g2', 'p1')]).toBe('%IW1') + // The alias follows its channel to the new address. + expect(buildAliasIndex(registry).get('temp')).toBe('%IW0') + }) + + it('keeps hardware pins fixed while allocatable producers compact', () => { + const inputs: PoolInputs = { + pinMapping: { pins: [{ address: '%IX0.2', alias: 'btn' }] }, + remoteDevices: [ + { name: 'd', modbusTcpConfig: { ioGroups: [{ id: 'g', ioPoints: [{ id: 'p', iecLocation: '%IX0.5' }] }] } }, + ], + } + const { registry } = recalculateFromLegacy(inputs) + expect(registry.assignments[channelKey('pin-mapping', '%IX0.2')]).toBe('%IX0.2') // pinned stays + expect(registry.assignments[channelKey('modbus:d:g', 'p')]).toBe('%IX0.0') // compacts to lowest free + }) + + it('excludes inactive-kind producers under capability scoping', () => { + const inputs: PoolInputs = { + pinMapping: { pins: [{ address: '%QW0' }] }, + remoteDevices: [ + { name: 'd', modbusTcpConfig: { ioGroups: [{ id: 'g', ioPoints: [{ id: 'p', iecLocation: '%QW5' }] }] } }, + ], + } + // Target without pin mapping: pins drop out, modbus compacts to %QW0. + const { registry } = recalculateFromLegacy(inputs, { activeKinds: new Set(['modbus-tcp-remote']) }) + expect(registry.assignments[channelKey('pin-mapping', '%QW0')]).toBeUndefined() + expect(registry.assignments[channelKey('modbus:d:g', 'p')]).toBe('%QW0') + }) +}) + describe('unpinAllocatableChannels', () => { it('clears pinned on everything except hardware pins', () => { const inputs: PoolInputs = { diff --git a/src/middleware/shared/utils/iec-address/registry/index.ts b/src/middleware/shared/utils/iec-address/registry/index.ts index fb33b52da..1504487ad 100644 --- a/src/middleware/shared/utils/iec-address/registry/index.ts +++ b/src/middleware/shared/utils/iec-address/registry/index.ts @@ -1,6 +1,6 @@ export { formatAddress, isBitClass, isIecAddress, parseAddress, type ParsedAddress, prefixOf } from './address-space' export { allocateAddresses, channelKey } from './allocate' -export { migrateToRegistry, unpinAllocatableChannels } from './migrate' +export { migrateToRegistry, recalculateFromLegacy, unpinAllocatableChannels } from './migrate' export { addConsumer, addressOf, diff --git a/src/middleware/shared/utils/iec-address/registry/migrate.ts b/src/middleware/shared/utils/iec-address/registry/migrate.ts index cbe3233ad..071213c59 100644 --- a/src/middleware/shared/utils/iec-address/registry/migrate.ts +++ b/src/middleware/shared/utils/iec-address/registry/migrate.ts @@ -20,7 +20,14 @@ import type { PoolInputs } from '../address-pool' import { parseAddress } from './address-space' import { recalculate } from './registry' -import type { AddressClass, IecAddressRegistry, RegistryChannel, RegistryConsumer } from './types' +import type { + AddressClass, + AddressConflict, + AllocateOptions, + IecAddressRegistry, + RegistryChannel, + RegistryConsumer, +} from './types' const PIN_MAPPING_KIND = 'pin-mapping' @@ -147,3 +154,27 @@ export function unpinAllocatableChannels(registry: IecAddressRegistry): IecAddre }) return { ...registry, consumers } } + +/** + * Full project-wide recalculation from live producer state. This is the + * single entry point the store's `recalculateIecAddresses` action drives: + * + * 1. derive the consumer structure + aliases from the current producers + * (`migrateToRegistry` — everything seeded at its present address), + * 2. release the seeds on allocatable channels (`unpinAllocatableChannels` + * keeps only real hardware pins fixed), + * 3. re-allocate, capability-scoped, so freed slots are reclaimed and + * inactive-kind producers drop out. + * + * The result is the fresh registry (gapless, target-scoped) plus any + * pinned-address conflicts. Aliases ride along on their channels, so the + * caller's variable reconciliation makes program variables follow. + */ +export function recalculateFromLegacy( + inputs: PoolInputs, + options: AllocateOptions = {}, +): { registry: IecAddressRegistry; conflicts: AddressConflict[] } { + const seeded = migrateToRegistry(inputs) + const unpinned = unpinAllocatableChannels(seeded) + return recalculate(unpinned, options) +} From 4aacdfe38193aceef35ab7500be0d5b215798a8f Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 2 Jul 2026 21:34:23 -0400 Subject: [PATCH 17/35] feat(iec-address): scoped unpin + consumer-id builders (Phase 2b-core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `unpinAllocatableChannels(reg, onlyKinds?)` can now release seeds for just a subset of kinds, keeping the others pinned as fixed constraints — used when reallocating only the remote-device producers while treating pins / VPP as fixed (they own their own allocation). - Export `modbusConsumerId` / `ethercatConsumerId` so the upcoming store write-back keys the registry identically to the migration (no drift). 56 tests, 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../registry/__tests__/migrate.test.ts | 19 +++++++++++++ .../utils/iec-address/registry/index.ts | 8 +++++- .../utils/iec-address/registry/migrate.ts | 28 ++++++++++++++----- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/middleware/shared/utils/iec-address/registry/__tests__/migrate.test.ts b/src/middleware/shared/utils/iec-address/registry/__tests__/migrate.test.ts index 107ec0d91..1cb579523 100644 --- a/src/middleware/shared/utils/iec-address/registry/__tests__/migrate.test.ts +++ b/src/middleware/shared/utils/iec-address/registry/__tests__/migrate.test.ts @@ -1,6 +1,7 @@ import type { PoolInputs } from '../../address-pool' import { channelKey } from '../allocate' import { migrateToRegistry, recalculateFromLegacy, unpinAllocatableChannels } from '../migrate' +import { recalculate } from '../registry' import { buildAliasIndex } from '../resolve' describe('migrateToRegistry', () => { @@ -211,6 +212,24 @@ describe('unpinAllocatableChannels', () => { expect(modbus.channels[0].pinned).toBeUndefined() // freed for compaction }) + it('unpins only the requested kinds, keeping others as fixed constraints', () => { + const inputs: PoolInputs = { + pinMapping: { pins: [{ address: '%QW0' }] }, + vendorIoMapping: { entries: [{ slot: 1, channelName: 'a', iecAddress: '%QW1' }] }, + remoteDevices: [ + { name: 'd', modbusTcpConfig: { ioGroups: [{ id: 'g', ioPoints: [{ id: 'p', iecLocation: '%QW5' }] }] } }, + ], + } + // Reallocate ONLY modbus; pins (%QW0) and VPP (%QW1) stay pinned, so the + // modbus point compacts to the next free slot after them: %QW2. + const reg = recalculate( + unpinAllocatableChannels(migrateToRegistry(inputs), new Set(['modbus-tcp-remote'])), + ).registry + expect(reg.assignments[channelKey('pin-mapping', '%QW0')]).toBe('%QW0') + expect(reg.assignments[channelKey('vpp-slot-1', 'a')]).toBe('%QW1') + expect(reg.assignments[channelKey('modbus:d:g', 'p')]).toBe('%QW2') + }) + it('leaves already-unpinned channels untouched', () => { const reg = { consumers: [ diff --git a/src/middleware/shared/utils/iec-address/registry/index.ts b/src/middleware/shared/utils/iec-address/registry/index.ts index 1504487ad..008b5da41 100644 --- a/src/middleware/shared/utils/iec-address/registry/index.ts +++ b/src/middleware/shared/utils/iec-address/registry/index.ts @@ -1,6 +1,12 @@ export { formatAddress, isBitClass, isIecAddress, parseAddress, type ParsedAddress, prefixOf } from './address-space' export { allocateAddresses, channelKey } from './allocate' -export { migrateToRegistry, recalculateFromLegacy, unpinAllocatableChannels } from './migrate' +export { + ethercatConsumerId, + migrateToRegistry, + modbusConsumerId, + recalculateFromLegacy, + unpinAllocatableChannels, +} from './migrate' export { addConsumer, addressOf, diff --git a/src/middleware/shared/utils/iec-address/registry/migrate.ts b/src/middleware/shared/utils/iec-address/registry/migrate.ts index 071213c59..e140cd8ac 100644 --- a/src/middleware/shared/utils/iec-address/registry/migrate.ts +++ b/src/middleware/shared/utils/iec-address/registry/migrate.ts @@ -31,6 +31,12 @@ import type { const PIN_MAPPING_KIND = 'pin-mapping' +/* Consumer-id builders. Exported so the store's address write-back keys the + * registry the exact same way the migration created it (no drift). */ +export const modbusConsumerId = (deviceName: string, groupId: string): string => `modbus:${deviceName}:${groupId}` +export const ethercatConsumerId = (deviceName: string, slaveName: string): string => + `ethercat:${deviceName}:${slaveName}` + /** Build a channel seeded (pinned) at a legacy address, or `null` when the * address is not a parseable IEC location (nothing to migrate). */ function seedChannel( @@ -99,7 +105,7 @@ export function migrateToRegistry(inputs: PoolInputs): IecAddressRegistry { } if (channels.length > 0) { consumers.push({ - id: `modbus:${deviceName}:${groupId}`, + id: modbusConsumerId(deviceName, groupId), kind: 'modbus-tcp-remote', label: `${deviceName} / ${groupId}`, order: order++, @@ -121,7 +127,7 @@ export function migrateToRegistry(inputs: PoolInputs): IecAddressRegistry { } if (channels.length > 0) { consumers.push({ - id: `ethercat:${deviceName}:${slaveName}`, + id: ethercatConsumerId(deviceName, slaveName), kind: 'ethercat', label: `${deviceName} / ${slaveName}`, order: order++, @@ -136,13 +142,21 @@ export function migrateToRegistry(inputs: PoolInputs): IecAddressRegistry { } /** - * Release the migration seeds: clear `pinned` on every channel EXCEPT real - * hardware pins (`pin-mapping`), so a subsequent `recalculate` is free to - * compact allocatable producers. Pure. + * Release the migration seeds so a subsequent `recalculate` can compact. + * + * By default every channel EXCEPT real hardware pins (`pin-mapping`) is + * unpinned. Pass `onlyKinds` to unpin just those consumer kinds and keep + * everything else pinned — used when reallocating a subset of producers + * (e.g. only remote devices) while treating pins and VPP as fixed + * constraints managed by their own allocators. Pure. */ -export function unpinAllocatableChannels(registry: IecAddressRegistry): IecAddressRegistry { +export function unpinAllocatableChannels( + registry: IecAddressRegistry, + onlyKinds?: ReadonlySet, +): IecAddressRegistry { + const shouldUnpin = (kind: string): boolean => (onlyKinds ? onlyKinds.has(kind) : kind !== PIN_MAPPING_KIND) const consumers = registry.consumers.map((consumer) => { - if (consumer.kind === PIN_MAPPING_KIND) return consumer + if (!shouldUnpin(consumer.kind)) return consumer return { ...consumer, channels: consumer.channels.map((channel) => { From dbf72242d26842bb8a8d882063b44bf3cd7074be Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 2 Jul 2026 21:43:00 -0400 Subject: [PATCH 18/35] feat(iec-address): route Modbus addressing through the central registry (Phase 3) Wires the Modbus remote-device producer onto the central IEC address registry, delivering project-wide gap reclamation (bug #4) via the shared core instead of a bespoke allocator. - New `recalculateRemoteDeviceAddresses` project action: derives consumers from live producer state, keeps pin-mapping / VPP / EtherCAT pinned as fixed constraints, re-packs the Modbus producers (capability-scoped so a target switch drops inactive kinds and reclaims their space), writes the compacted addresses back onto the `ioPoints`, and reconciles bound variables via `syncVariableAliases`. - `addIOGroup` / `updateIOGroup` / `deleteIOGroup` / `deleteRemoteDevice` now call it, so removing a group or device slides the survivors down into the freed slots (project-wide, across devices) with no gaps. - EtherCAT stays on its own bit-offset allocator for now (participates as a fixed constraint); it moves onto the registry in a follow-up once its bit packing is verified against the runtime. Updated two tests that had asserted the old gap-preserving / localized behavior to the new project-wide compaction. 266 project-slice tests pass; new code fully covered. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../store/__tests__/project-slice.test.ts | 96 ++++++++++++++-- src/frontend/store/slices/project/slice.ts | 108 ++++++++++++++++++ src/frontend/store/slices/project/types.ts | 11 ++ 3 files changed, 203 insertions(+), 12 deletions(-) diff --git a/src/frontend/store/__tests__/project-slice.test.ts b/src/frontend/store/__tests__/project-slice.test.ts index f0c3d4bbe..330912bf3 100644 --- a/src/frontend/store/__tests__/project-slice.test.ts +++ b/src/frontend/store/__tests__/project-slice.test.ts @@ -2273,8 +2273,8 @@ describe('createProjectSlice', () => { expect(groups[1].ioPoints![1].iecLocation).toBe('%IW3') }) - it('skips bit addresses that are already used (bit-based collision with gaps)', () => { - // Create a device with pre-existing bit addresses that have gaps + it('recompacts bit addresses project-wide, closing a pre-existing gap', () => { + // A pre-existing group carries a manual gap (%IX0.0 then %IX0.2). const device = makeRemoteDevice('Dev1') device.modbusTcpConfig!.ioGroups = [ { @@ -2287,18 +2287,19 @@ describe('createProjectSlice', () => { errorHandling: 'keep-last-value', ioPoints: [ { id: 'p0', name: 'p0', type: 'Digital Input (Coil Status)', iecLocation: '%IX0.0', alias: '' }, - // Skip %IX0.1 (gap) and use %IX0.2 { id: 'p1', name: 'p1', type: 'Digital Input (Coil Status)', iecLocation: '%IX0.2', alias: '' }, ], }, ] seedRemoteDevice(store, device) - // Add a new group; the generator should skip %IX0.0 (used), use %IX0.1 (free), skip %IX0.2 (used), use %IX0.3 + // Adding a group triggers the central recalculation: the whole Modbus + // space re-packs, so the pre-existing group closes its gap + // (%IX0.0/%IX0.1) and the new group packs right after (%IX0.2/%IX0.3). store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g2', '1', 2)) const groups = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups - expect(groups[1].ioPoints![0].iecLocation).toBe('%IX0.1') - expect(groups[1].ioPoints![1].iecLocation).toBe('%IX0.3') + expect(groups[0].ioPoints!.map((p) => p.iecLocation)).toEqual(['%IX0.0', '%IX0.1']) + expect(groups[1].ioPoints!.map((p) => p.iecLocation)).toEqual(['%IX0.2', '%IX0.3']) }) it('ignores VPP claims on a target whose caps do not include vppIo', () => { @@ -2415,18 +2416,17 @@ describe('createProjectSlice', () => { expect(points.map((p) => p.iecLocation)).toEqual(['%IX0.0', '%IX0.1']) }) - it('regenerates only the edited group, leaving sibling groups untouched (no project-wide recompaction)', () => { + it('recompacts sibling groups project-wide after an edit (central registry)', () => { seedRemoteDevice(store, makeRemoteDevice('Dev1')) store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g1', '3', 2)) // %IW0,1 store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g2', '3', 2)) // %IW2,3 - // Grow g1. Because this edit is localized (it does NOT recompact the - // whole project), g1 reuses its own freed %IW0/%IW1 and its third - // point takes the next free slot after g2's untouched %IW2/%IW3. + // Grow g1 to 3 points. The central recalculation re-packs the whole + // Modbus space in group order: g1 → %IW0/1/2, g2 slides to %IW3/4. store.getState().projectActions.updateIOGroup('Dev1', 'g1', { length: 3 }) const groups = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups - expect(groups[0].ioPoints!.map((p) => p.iecLocation)).toEqual(['%IW0', '%IW1', '%IW4']) - expect(groups[1].ioPoints!.map((p) => p.iecLocation)).toEqual(['%IW2', '%IW3']) // sibling untouched + expect(groups[0].ioPoints!.map((p) => p.iecLocation)).toEqual(['%IW0', '%IW1', '%IW2']) + expect(groups[1].ioPoints!.map((p) => p.iecLocation)).toEqual(['%IW3', '%IW4']) }) it('preserves point aliases positionally when regenerating', () => { @@ -2463,6 +2463,78 @@ describe('createProjectSlice', () => { const result = store.getState().projectActions.deleteIOGroup('EtherCAT', 'g1') expect(result.ok).toBe(true) }) + + it('recompacts the surviving groups into the freed slots (bug #4)', () => { + seedRemoteDevice(store, makeRemoteDevice('Dev1')) + store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g1', '3', 2)) // %IW0,1 + store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g2', '3', 2)) // %IW2,3 + store.getState().projectActions.deleteIOGroup('Dev1', 'g1') + const groups = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups + expect(groups).toHaveLength(1) + // g2 slides down into the freed slots — no gap left behind. + expect(groups[0].ioPoints!.map((p) => p.iecLocation)).toEqual(['%IW0', '%IW1']) + }) + }) + + describe('recalculateRemoteDeviceAddresses (central registry)', () => { + beforeEach(() => { + seedRuntimeV4Board(store) + }) + + it('recompacts across devices when a whole device is removed', () => { + seedRemoteDevice(store, makeRemoteDevice('A')) + seedRemoteDevice(store, makeRemoteDevice('B')) + store.getState().projectActions.addIOGroup('A', makeIOGroup('ga', '3', 2)) // %IW0,1 + store.getState().projectActions.addIOGroup('B', makeIOGroup('gb', '3', 2)) // %IW2,3 + store.getState().projectActions.deleteRemoteDevice('A') + const devices = store.getState().project.data.remoteDevices! + expect(devices).toHaveLength(1) + // B's group reclaims device A's freed addresses project-wide. + expect(devices[0].modbusTcpConfig!.ioGroups[0].ioPoints!.map((p) => p.iecLocation)).toEqual(['%IW0', '%IW1']) + }) + + it('is a benign no-op with no remote devices', () => { + const result = store.getState().projectActions.recalculateRemoteDeviceAddresses() + expect(result.ok).toBe(true) + }) + + it('activates pin-mapping / VPP kinds when the target supports them', () => { + // A board that exposes pin mapping AND VPP I/O — exercises those + // capability branches. Modbus still allocates around them. + store.getState().deviceActions.setAvailableOptions({ + availableBoards: new Map([ + [ + 'VPP Board', + { + compiler: 'openplc-compiler', + core: 'rt-v4', + preview: '', + specs: {}, + capabilities: { + pinMapping: true, + vppIo: true, + modbusTcpRemote: true, + ethercat: true, + modbusTcpServer: true, + opcuaServer: true, + s7Server: true, + debuggerTransports: ['websocket'], + pythonFunctionBlocks: true, + arduinoApiCompletions: false, + hasRuntimeStats: true, + isInProcessSimulator: false, + directUsbUpload: false, + }, + }, + ], + ]), + }) + store.getState().deviceActions.setDeviceBoard('VPP Board') + seedRemoteDevice(store, makeRemoteDevice('Dev1')) + store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g1', '3', 2)) + const points = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints! + expect(points.map((p) => p.iecLocation)).toEqual(['%IW0', '%IW1']) + }) }) describe('updateIOPointAlias', () => { diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 0c9b0bf6c..4f3543ed0 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -19,6 +19,15 @@ import { syncVariableAliases as syncVariablesPure, validateAliasEdit, } from '../../../../middleware/shared/utils/iec-address' +import { + channelKey, + type IecAddressRegistry, + migrateToRegistry, + modbusConsumerId, + recalculate as recalculateRegistry, + unpinAllocatableChannels, +} from '../../../../middleware/shared/utils/iec-address/registry' +import type { TargetCapabilities } from '../../../../middleware/shared/utils/target-capabilities' import { resolveTargetCapabilities } from '../../../../middleware/shared/utils/target-capabilities' import { parseIecStringToVariables } from '../../../utils/generate-iec-string-to-variables' import { generateIecVariablesToString } from '../../../utils/generate-iec-variables-to-string' @@ -269,6 +278,80 @@ function resolveAliasForLocation(getState: ProjectGetState, location: string | u type ProjectSetState = StoreApi['setState'] type ProjectGetState = () => ProjectSliceRoot +// --------------------------------------------------------------------------- +// Central IEC address recalculation (remote-device producers) +// --------------------------------------------------------------------------- + +/** Consumer kinds this recalculation is allowed to move. Pin mapping, VPP, + * and EtherCAT own their own allocation, so they are treated as fixed + * constraints here (seeded + kept pinned) — Modbus allocates around them. + * EtherCAT joins this set once its bit-offset allocator is migrated. */ +const REMOTE_DEVICE_KINDS: ReadonlySet = new Set(['modbus-tcp-remote']) + +/** Map the active target's capabilities to the set of consumer kinds that + * participate in allocation. A target without pin mapping / VPP simply + * omits those kinds, so their addresses free up and remote-device + * producers recompact into the space (project-wide recalc on target + * switch). */ +function activeKindsFromCapabilities(caps: TargetCapabilities): Set { + const kinds = new Set() + if (caps.pinMapping) kinds.add('pin-mapping') + if (caps.vppIo) kinds.add('vpp-io') + if (caps.modbusTcpRemote) kinds.add('modbus-tcp-remote') + if (caps.ethercat) kinds.add('ethercat') + return kinds +} + +/** + * Build the capability-scoped registry from live producer state, reallocating + * ONLY the remote-device producers (Modbus + EtherCAT) while treating pins + * and VPP as fixed constraints. This is what closes gaps project-wide when a + * group / device is removed, and what drops inactive producers on a target + * switch. Read live state (never draft proxies) before entering `produce`. + */ +function buildRemoteDeviceRegistry(live: ProjectSliceRoot): IecAddressRegistry { + const board = live.deviceDefinitions.configuration.deviceBoard + const boardInfo = live.deviceAvailableOptions.availableBoards.get(board ?? '') + const ioMapping = + ( + live.deviceDefinitions.configuration.vendorScreenData?.['io-mapping'] as + | { entries?: Array<{ iecAddress: string; alias?: string; slot: number; channelName: string }> } + | undefined + )?.entries ?? [] + const seeded = migrateToRegistry({ + pinMapping: { pins: live.deviceDefinitions.pinMapping.pinsByBoard[board] ?? [] }, + vendorIoMapping: { entries: ioMapping }, + remoteDevices: live.project.data.remoteDevices, + }) + const activeKinds = activeKindsFromCapabilities(resolveTargetCapabilities(boardInfo)) + return recalculateRegistry(unpinAllocatableChannels(seeded, REMOTE_DEVICE_KINDS), { activeKinds }).registry +} + +/** Write the registry's assigned addresses back onto the Modbus producers + * (`ioPoints`). Keyed exactly as `migrateToRegistry` built the consumers, + * so there is no mapping drift. Runs inside `produce` on the draft. + * EtherCAT `channelMappings` are intentionally left to their own allocator + * for now (they still participate as fixed constraints via the pool). */ +function applyRemoteDeviceAddresses( + remoteDevices: ProjectSlice['project']['data']['remoteDevices'], + registry: IecAddressRegistry, +): void { + if (!remoteDevices) return + for (const device of remoteDevices) { + const deviceRef = device.name || 'device' + const groups = device.modbusTcpConfig?.ioGroups + if (!groups) continue + for (let g = 0; g < groups.length; g++) { + const group = groups[g] + const consumerId = modbusConsumerId(deviceRef, group.id ?? String(g)) + for (const point of group.ioPoints ?? []) { + const address = registry.assignments[channelKey(consumerId, point.id)] + if (address) point.iecLocation = address + } + } + } +} + const reconcileVariablesText = ( pouName: string | undefined, getState: ProjectGetState, @@ -1456,6 +1539,9 @@ const createProjectSlice: StateCreator = slice.project.data.remoteDevices = slice.project.data.remoteDevices.filter((d) => d.name !== name) }), ) + // Removing a device frees its addresses — recompact the survivors + // project-wide (bug #4) and let bound variables follow. + getState().projectActions.recalculateRemoteDeviceAddresses() return ok() }, updateRemoteDeviceName: (name, newName) => { @@ -1500,6 +1586,20 @@ const createProjectSlice: StateCreator = ) return ok() }, + recalculateRemoteDeviceAddresses: () => { + // Central, capability-scoped recalculation of the Modbus producers. + // Build the registry from live state before entering produce, write + // the compacted addresses back onto the ioPoints, then reconcile the + // program variables bound to any alias that moved. + const registry = buildRemoteDeviceRegistry(getState()) + setState( + produce((slice: ProjectSlice) => { + applyRemoteDeviceAddresses(slice.project.data.remoteDevices, registry) + }), + ) + getState().projectActions.syncVariableAliases() + return ok() + }, addIOGroup: (deviceName, group) => { // Read producer state from the live store before entering produce // so the pool reflects every active source (pin-mapping, VPP, @@ -1538,6 +1638,11 @@ const createProjectSlice: StateCreator = device.modbusTcpConfig.ioGroups.push({ ...group, ioPoints }) }), ) + // Central recalculation is the authority for final addresses: it + // recompacts all remote-device producers project-wide and reconciles + // bound variables. (The provisional addresses above just seed the + // point structure/classes.) + getState().projectActions.recalculateRemoteDeviceAddresses() return ok() }, updateIOGroup: (deviceName, groupId, updates) => { @@ -1600,6 +1705,7 @@ const createProjectSlice: StateCreator = group.ioPoints = generateIOPoints(group.functionCode, group.length, group.name, pool, pending, existingPoints) }), ) + getState().projectActions.recalculateRemoteDeviceAddresses() return ok() }, deleteIOGroup: (deviceName, groupId) => { @@ -1610,6 +1716,8 @@ const createProjectSlice: StateCreator = device.modbusTcpConfig.ioGroups = device.modbusTcpConfig.ioGroups.filter((g) => g.id !== groupId) }), ) + // Recompact so the groups that followed reclaim the freed addresses. + getState().projectActions.recalculateRemoteDeviceAddresses() return ok() }, updateIOPointAlias: (deviceName, groupId, pointId, alias) => { diff --git a/src/frontend/store/slices/project/types.ts b/src/frontend/store/slices/project/types.ts index 2746c6dc8..7cea4f826 100644 --- a/src/frontend/store/slices/project/types.ts +++ b/src/frontend/store/slices/project/types.ts @@ -165,6 +165,17 @@ export type ProjectActions = { orphaned: number } + /** + * Central, capability-scoped recalculation of the Modbus remote-device + * addresses via the IEC address registry. Derives consumers from live + * producer state, keeps pin-mapping / VPP / EtherCAT pinned as fixed + * constraints, re-packs the Modbus producers (closing gaps left by a + * removed group/device — project-wide), writes the addresses back onto + * the `ioPoints`, and reconciles bound variables. Invoked after every + * Modbus mutation and on target switch. + */ + recalculateRemoteDeviceAddresses: () => ProjectResponse + /** * Cascade-rename every variable's `.alias` field from `oldAlias` to * `newAlias` across all POU-local and global variables. Used by From 186f26ef384a471e02ad59fe28f9578118971818 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 2 Jul 2026 21:45:39 -0400 Subject: [PATCH 19/35] feat(iec-address): recalculate Modbus addresses on target switch (Phase 4) `setDeviceBoard` now triggers the central `recalculateRemoteDeviceAddresses` when the target actually changes. Because the recalculation is capability-scoped, switching from a platform WITH pin mapping / VPP to one WITHOUT frees those addresses and the Modbus producers recompact project-wide into the reclaimed space, with bound variables following. No-op when the board is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/frontend/store/slices/device/slice.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/frontend/store/slices/device/slice.ts b/src/frontend/store/slices/device/slice.ts index b22d85a32..788433389 100644 --- a/src/frontend/store/slices/device/slice.ts +++ b/src/frontend/store/slices/device/slice.ts @@ -368,6 +368,7 @@ const createDeviceSlice: StateCreator = (s return returnMessage }, setDeviceBoard: (deviceBoard): void => { + const previousBoard = getState().deviceDefinitions.configuration.deviceBoard setState( produce(({ deviceDefinitions, deviceUpdated }: DeviceSlice) => { deviceUpdated.updated = true @@ -399,6 +400,13 @@ const createDeviceSlice: StateCreator = (s deviceDefinitions.configuration.deviceBoard = deviceBoard }), ) + // Switching target changes which producer kinds are active — a board + // without pin mapping / VPP frees those addresses. Recompute the + // Modbus addresses project-wide so they reclaim the freed space, and + // reconcile bound variables. (Skipped when the board is unchanged.) + if (previousBoard !== deviceBoard) { + getState().projectActions.recalculateRemoteDeviceAddresses() + } }, setSelectedPlatformOption: (key, value): void => { setState( From 9e08d00e0de035e5b795a984a47696bf4152592b Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Jul 2026 11:39:53 -0400 Subject: [PATCH 20/35] feat(iec-address): session alias-memory primitive (Phase 5 groundwork) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `RegistryChannel.memoryKey` (a stable semantic identity like `moduleId:slot:channel` that outlives the channel's presence) and the pure `restoreAliasesFromMemory(registry, memory)` helper. This backs the requirement that removing a producer (e.g. a VPP module) and re-adding the same one on the same slot restores its aliases within a session — the memory is keyed so "same module, different slot" and "different module, same slot" resolve differently. The memory Record lives in the store (session-scoped, never serialized); this is just the pure restore step. 60 tests, 100% coverage. Additive. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../registry/__tests__/registry.test.ts | 41 ++++++++++++++++++- .../utils/iec-address/registry/index.ts | 1 + .../utils/iec-address/registry/registry.ts | 24 +++++++++++ .../utils/iec-address/registry/types.ts | 11 +++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/middleware/shared/utils/iec-address/registry/__tests__/registry.test.ts b/src/middleware/shared/utils/iec-address/registry/__tests__/registry.test.ts index c19dcecfe..22e0c8dfb 100644 --- a/src/middleware/shared/utils/iec-address/registry/__tests__/registry.test.ts +++ b/src/middleware/shared/utils/iec-address/registry/__tests__/registry.test.ts @@ -5,10 +5,11 @@ import { createRegistry, recalculate, removeConsumer, + restoreAliasesFromMemory, setAlias, updateConsumer, } from '../registry' -import type { RegistryConsumer } from '../types' +import type { IecAddressRegistry, RegistryConsumer } from '../types' const word = { direction: 'Q', size: 'W' } as const @@ -109,6 +110,44 @@ describe('addressOf', () => { }) }) +describe('restoreAliasesFromMemory', () => { + const reg = (): IecAddressRegistry => ({ + consumers: [ + { + id: 'vpp-slot-1', + kind: 'vpp-io', + order: 0, + channels: [ + { channelId: 'DO1', class: word, memoryKey: 'mod-a:1:DO1' }, // no alias + { channelId: 'DO2', class: word, alias: 'kept', memoryKey: 'mod-a:1:DO2' }, // has alias + { channelId: 'DO3', class: word }, // no memoryKey + ], + }, + ], + assignments: {}, + }) + + it('restores an alias for a channel whose memoryKey is remembered', () => { + const out = restoreAliasesFromMemory(reg(), { 'mod-a:1:DO1': 'push_button' }) + expect(out.consumers[0].channels[0].alias).toBe('push_button') + }) + + it('does not overwrite a channel that already has an alias', () => { + const out = restoreAliasesFromMemory(reg(), { 'mod-a:1:DO2': 'other' }) + expect(out.consumers[0].channels[1].alias).toBe('kept') + }) + + it('leaves channels without a memoryKey untouched', () => { + const out = restoreAliasesFromMemory(reg(), { 'mod-a:1:DO1': 'x' }) + expect(out.consumers[0].channels[2].alias).toBeUndefined() + }) + + it('leaves a channel untouched when its memoryKey is not remembered', () => { + const out = restoreAliasesFromMemory(reg(), {}) + expect(out.consumers[0].channels[0].alias).toBeUndefined() + }) +}) + describe('setAlias', () => { const base = () => addConsumer( diff --git a/src/middleware/shared/utils/iec-address/registry/index.ts b/src/middleware/shared/utils/iec-address/registry/index.ts index 008b5da41..0661b62be 100644 --- a/src/middleware/shared/utils/iec-address/registry/index.ts +++ b/src/middleware/shared/utils/iec-address/registry/index.ts @@ -13,6 +13,7 @@ export { createRegistry, recalculate, removeConsumer, + restoreAliasesFromMemory, setAlias, updateConsumer, } from './registry' diff --git a/src/middleware/shared/utils/iec-address/registry/registry.ts b/src/middleware/shared/utils/iec-address/registry/registry.ts index ad726a4cf..37484704b 100644 --- a/src/middleware/shared/utils/iec-address/registry/registry.ts +++ b/src/middleware/shared/utils/iec-address/registry/registry.ts @@ -18,6 +18,30 @@ export function createRegistry(): IecAddressRegistry { return { consumers: [], assignments: {} } } +/** + * Restore aliases from the session alias-memory onto channels that carry a + * `memoryKey` but currently have no alias. This is what brings a module's + * aliases back when it is removed and re-added on the same slot within a + * session — the memory (keyed by `moduleId:slot:channel`) survives the + * channel's absence. Channels that already have an alias, or lack a + * `memoryKey`, are left untouched. Pure; the memory itself lives in the + * store (session-scoped, never serialized). + */ +export function restoreAliasesFromMemory( + registry: IecAddressRegistry, + memory: Readonly>, +): IecAddressRegistry { + const consumers = registry.consumers.map((consumer) => ({ + ...consumer, + channels: consumer.channels.map((channel) => { + if (channel.alias || !channel.memoryKey) return channel + const remembered = memory[channel.memoryKey] + return remembered ? { ...channel, alias: remembered } : channel + }), + })) + return { ...registry, consumers } +} + /** Reassign every address from the registered consumers. Deterministic, * gapless, and idempotent (recalculating an unchanged registry is a * no-op). Pass `options.activeKinds` to scope allocation to the current diff --git a/src/middleware/shared/utils/iec-address/registry/types.ts b/src/middleware/shared/utils/iec-address/registry/types.ts index 273960363..2458551bb 100644 --- a/src/middleware/shared/utils/iec-address/registry/types.ts +++ b/src/middleware/shared/utils/iec-address/registry/types.ts @@ -37,6 +37,17 @@ export interface RegistryChannel { /** Fixed hardware address (e.g. an Arduino pin). When set the channel is * RESERVED at this literal address instead of being allocated. */ pinned?: string + /** + * Stable *semantic* identity that outlives this channel's presence in the + * registry — e.g. `moduleId:slot:channelName` for a VPP channel. When a + * consumer is removed and later re-added with the same semantic identity, + * the session alias-memory restores the alias by this key. Distinct from + * `channelId` (which is only stable *within* a live consumer): the memory + * key also encodes the module/slot so "same module, different slot" and + * "different module, same slot" resolve to different keys. Session-scoped — + * never serialized. + */ + memoryKey?: string } /** Well-known consumer kinds. Left open (`string`) so future producers can From db2fbfd8475b468569ef0bd58947647d42e8cd93 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Jul 2026 12:11:26 -0400 Subject: [PATCH 21/35] feat(iec-address): route VPP I/O through the central registry (Phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VPP now delegates address allocation to the central registry instead of its own per-effect `nextFreeAddress` loop, so VPP + Modbus are packed by one allocator (project-wide, capability-scoped), and adds the session alias-memory that restores a module's aliases on remove→re-add. - Generalized `recalculateRemoteDeviceAddresses` → `recalculateIecAddresses`: reallocates VPP + Modbus (`ALLOCATED_KINDS`), restores aliases from the session memory (`restoreAliasesFromMemory`), and writes addresses+aliases back to VPP `io-mapping` entries (via `setVendorScreenData`) and Modbus `ioPoints`. - Session alias-memory: new `iecAliasMemory` project-slice field (never serialized) + `rememberChannelAlias` action. `migrateToRegistry` now stamps each channel with a stable `memoryKey`; `PoolVppIoInput` carries `moduleId`. - Both VPP layouts: allocator effects build channel structure then call `recalculateIecAddresses`; alias editors record into the session memory. Pins + EtherCAT remain fixed constraints for now (their own commits). 428 store/registry tests pass; tsc, eslint, arch clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../vendor-screen/layouts/io-table-layout.tsx | 16 +- .../layouts/module-slots-layout.tsx | 17 +- .../store/__tests__/project-slice.test.ts | 141 ++++++++++++---- src/frontend/store/slices/device/slice.ts | 2 +- src/frontend/store/slices/project/slice.ts | 153 +++++++++++++----- src/frontend/store/slices/project/types.ts | 32 +++- .../shared/utils/iec-address/address-pool.ts | 4 + .../utils/iec-address/registry/index.ts | 3 + .../utils/iec-address/registry/migrate.ts | 38 ++++- 9 files changed, 312 insertions(+), 94 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/io-table-layout.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/io-table-layout.tsx index 998b4fd85..6535f8420 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/io-table-layout.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/io-table-layout.tsx @@ -10,6 +10,7 @@ import { nextFreeAddress, validateAliasEdit, } from '@root/middleware/shared/utils/iec-address' +import { vppMemoryKey } from '@root/middleware/shared/utils/iec-address/registry' import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -142,11 +143,13 @@ function IoTableLayout({ section, moduleSystem }: IoTableLayoutProps) { } } - setEntries(newEntries) + // Write the freshly-derived channel structure, then let the central + // registry own the FINAL addresses (VPP + Modbus packed together, + // aliases restored from the session memory) and pull its result back + // into local state so the table renders the compacted addresses. setVendorScreenData(persistenceKey, { entries: newEntries }) - // Producer mutation: addresses were just re-allocated for every - // VPP-active slot. Refresh variables bound to those aliases. - useOpenPLCStore.getState().projectActions.syncVariableAliases() + useOpenPLCStore.getState().projectActions.recalculateIecAddresses() + setEntries(getStoreState().storedMapping?.entries ?? newEntries) // eslint-disable-next-line react-hooks/exhaustive-deps }, [slots, formatSelectionKey]) @@ -197,6 +200,11 @@ function IoTableLayout({ section, moduleSystem }: IoTableLayoutProps) { updated[index] = { ...updated[index], alias } setEntries(updated) setVendorScreenData(persistenceKey, { entries: updated }) + // Record in the session alias-memory so the alias returns if this module + // is removed and re-added on the same slot within the session. + useOpenPLCStore + .getState() + .projectActions.rememberChannelAlias(vppMemoryKey(target.moduleId ?? '', target.slot, target.channelName), alias) // Refresh variables against any allocator-driven address shifts. useOpenPLCStore.getState().projectActions.syncVariableAliases() } diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx index c81b292a4..314a1e9e0 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx @@ -23,6 +23,7 @@ import { nextFreeAddress, validateAliasEdit, } from '@root/middleware/shared/utils/iec-address' +import { vppMemoryKey } from '@root/middleware/shared/utils/iec-address/registry' import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -467,10 +468,12 @@ function ModuleSlotsLayout({ section, moduleSystem }: ModuleSlotsLayoutProps) { } } + // Write the derived channel structure, then let the central registry own + // the final addresses (VPP + Modbus packed together, aliases restored + // from the session memory) and reconcile variables. This layout renders + // from the store, so the registry's write-back propagates automatically. setVendorScreenData('io-mapping', { entries: newEntries }) - // Producer mutation: every VPP slot just had its addresses - // re-allocated. Sync variables that were bound to those aliases. - useOpenPLCStore.getState().projectActions.syncVariableAliases() + useOpenPLCStore.getState().projectActions.recalculateIecAddresses() // eslint-disable-next-line react-hooks/exhaustive-deps }, [slots, formatSelectionKey]) @@ -678,13 +681,19 @@ function ModuleSlotsLayout({ section, moduleSystem }: ModuleSlotsLayoutProps) { // call sees variables already pointing at the new alias name and // takes the refresh path (location follows alias) instead of the // orphan path (location cleared, warning glyph rendered). - const oldAlias = currentEntries.find((e) => e.slot === slot && e.channelName === channelName)?.alias ?? '' + const targetEntry = currentEntries.find((e) => e.slot === slot && e.channelName === channelName) + const oldAlias = targetEntry?.alias ?? '' if (oldAlias) { useOpenPLCStore.getState().projectActions.renameAlias(oldAlias, alias) } const entries = currentEntries.map((e) => (e.slot === slot && e.channelName === channelName ? { ...e, alias } : e)) setVendorScreenData('io-mapping', { entries }) + // Record in the session alias-memory so the alias returns if this module + // is removed and re-added on the same slot within the session. + useOpenPLCStore + .getState() + .projectActions.rememberChannelAlias(vppMemoryKey(targetEntry?.moduleId ?? '', slot, channelName), alias) // Refresh variables bound to the (now-renamed) alias against // any address shifts produced by the change. useOpenPLCStore.getState().projectActions.syncVariableAliases() diff --git a/src/frontend/store/__tests__/project-slice.test.ts b/src/frontend/store/__tests__/project-slice.test.ts index 330912bf3..52b80b69a 100644 --- a/src/frontend/store/__tests__/project-slice.test.ts +++ b/src/frontend/store/__tests__/project-slice.test.ts @@ -1,3 +1,4 @@ +import { modbusMemoryKey, vppMemoryKey } from '@root/middleware/shared/utils/iec-address/registry' import { createStore } from 'zustand/vanilla' import type { @@ -285,6 +286,40 @@ function seedRuntimeV4Board(store: ReturnType) { store.getState().deviceActions.setDeviceBoard('OpenPLC Runtime v4') } +/** Seed a target that exposes pin mapping AND VPP I/O (plus Modbus), so the + * central recalculation reallocates VPP + Modbus. */ +function seedVppBoard(store: ReturnType) { + store.getState().deviceActions.setAvailableOptions({ + availableBoards: new Map([ + [ + 'VPP Board', + { + compiler: 'openplc-compiler', + core: 'rt-v4', + preview: '', + specs: {}, + capabilities: { + pinMapping: true, + vppIo: true, + modbusTcpRemote: true, + ethercat: true, + modbusTcpServer: true, + opcuaServer: true, + s7Server: true, + debuggerTransports: ['websocket'], + pythonFunctionBlocks: true, + arduinoApiCompletions: false, + hasRuntimeStats: true, + isInProcessSimulator: false, + directUsbUpload: false, + }, + }, + ], + ]), + }) + store.getState().deviceActions.setDeviceBoard('VPP Board') +} + // =========================================================================== // Tests // =========================================================================== @@ -2476,7 +2511,7 @@ describe('createProjectSlice', () => { }) }) - describe('recalculateRemoteDeviceAddresses (central registry)', () => { + describe('recalculateIecAddresses (central registry)', () => { beforeEach(() => { seedRuntimeV4Board(store) }) @@ -2494,47 +2529,91 @@ describe('createProjectSlice', () => { }) it('is a benign no-op with no remote devices', () => { - const result = store.getState().projectActions.recalculateRemoteDeviceAddresses() + const result = store.getState().projectActions.recalculateIecAddresses() expect(result.ok).toBe(true) }) it('activates pin-mapping / VPP kinds when the target supports them', () => { // A board that exposes pin mapping AND VPP I/O — exercises those // capability branches. Modbus still allocates around them. - store.getState().deviceActions.setAvailableOptions({ - availableBoards: new Map([ - [ - 'VPP Board', - { - compiler: 'openplc-compiler', - core: 'rt-v4', - preview: '', - specs: {}, - capabilities: { - pinMapping: true, - vppIo: true, - modbusTcpRemote: true, - ethercat: true, - modbusTcpServer: true, - opcuaServer: true, - s7Server: true, - debuggerTransports: ['websocket'], - pythonFunctionBlocks: true, - arduinoApiCompletions: false, - hasRuntimeStats: true, - isInProcessSimulator: false, - directUsbUpload: false, - }, - }, - ], - ]), - }) - store.getState().deviceActions.setDeviceBoard('VPP Board') + seedVppBoard(store) seedRemoteDevice(store, makeRemoteDevice('Dev1')) store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g1', '3', 2)) const points = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints! expect(points.map((p) => p.iecLocation)).toEqual(['%IW0', '%IW1']) }) + + it('reallocates and writes back VPP io-mapping entries (compacting a gap)', () => { + seedVppBoard(store) + // A VPP entry sitting at %QX0.5 with a manual gap below it. + store.getState().deviceActions.setVendorScreenData('io-mapping', { + entries: [ + { + slot: 1, + channelName: 'DO1', + channelType: 'coil', + dataType: 'BOOL', + moduleId: 'mod-a', + iecAddress: '%QX0.5', + alias: '', + }, + // An unparseable address is skipped by the migration → left verbatim. + { + slot: 1, + channelName: 'BAD', + channelType: 'coil', + dataType: 'BOOL', + moduleId: 'mod-a', + iecAddress: 'NOPE', + alias: '', + }, + ], + }) + store.getState().projectActions.recalculateIecAddresses() + const entries = ( + store.getState().deviceDefinitions.configuration.vendorScreenData!['io-mapping'] as { + entries: Array<{ iecAddress: string }> + } + ).entries + // The valid VPP channel compacts to the lowest free %QX slot; the + // unmapped entry is returned unchanged. + expect(entries[0].iecAddress).toBe('%QX0.0') + expect(entries[1].iecAddress).toBe('NOPE') + }) + + it('skips devices without a Modbus config during writeback', () => { + seedRemoteDevice(store, { name: 'EtherCAT', protocol: 'ethercat' }) + seedRemoteDevice(store, makeRemoteDevice('Dev1')) + store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g1', '3', 1)) + const result = store.getState().projectActions.recalculateIecAddresses() + expect(result.ok).toBe(true) + expect( + store.getState().project.data.remoteDevices![1].modbusTcpConfig!.ioGroups[0].ioPoints![0].iecLocation, + ).toBe('%IW0') + }) + + it('restores a remembered alias onto a reappeared channel via recalc', () => { + seedRemoteDevice(store, makeRemoteDevice('Dev1')) + store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g1', '3', 1)) + const pointId = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints![0].id + // Simulate the alias having been set earlier (recorded in session memory) + // while the channel is currently alias-less (as after a remove/re-add). + store.getState().projectActions.rememberChannelAlias(modbusMemoryKey('Dev1', 'g1', pointId), 'temp_sensor') + store.getState().projectActions.recalculateIecAddresses() + expect(store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints![0].alias).toBe( + 'temp_sensor', + ) + }) + }) + + describe('rememberChannelAlias', () => { + it('stores an alias under its memory key and clears it when emptied', () => { + const key = vppMemoryKey('mod-a', 1, 'DO1') + store.getState().projectActions.rememberChannelAlias(key, ' relay_1 ') + expect(store.getState().iecAliasMemory[key]).toBe('relay_1') + store.getState().projectActions.rememberChannelAlias(key, ' ') + expect(store.getState().iecAliasMemory[key]).toBeUndefined() + }) }) describe('updateIOPointAlias', () => { diff --git a/src/frontend/store/slices/device/slice.ts b/src/frontend/store/slices/device/slice.ts index 788433389..d3f3abeb2 100644 --- a/src/frontend/store/slices/device/slice.ts +++ b/src/frontend/store/slices/device/slice.ts @@ -405,7 +405,7 @@ const createDeviceSlice: StateCreator = (s // Modbus addresses project-wide so they reclaim the freed space, and // reconcile bound variables. (Skipped when the board is unchanged.) if (previousBoard !== deviceBoard) { - getState().projectActions.recalculateRemoteDeviceAddresses() + getState().projectActions.recalculateIecAddresses() } }, setSelectedPlatformOption: (key, value): void => { diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 4f3543ed0..a42eb7501 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -25,6 +25,7 @@ import { migrateToRegistry, modbusConsumerId, recalculate as recalculateRegistry, + restoreAliasesFromMemory, unpinAllocatableChannels, } from '../../../../middleware/shared/utils/iec-address/registry' import type { TargetCapabilities } from '../../../../middleware/shared/utils/target-capabilities' @@ -279,18 +280,39 @@ type ProjectSetState = StoreApi['setState'] type ProjectGetState = () => ProjectSliceRoot // --------------------------------------------------------------------------- -// Central IEC address recalculation (remote-device producers) +// Central IEC address recalculation (registry-owned) // --------------------------------------------------------------------------- -/** Consumer kinds this recalculation is allowed to move. Pin mapping, VPP, - * and EtherCAT own their own allocation, so they are treated as fixed - * constraints here (seeded + kept pinned) — Modbus allocates around them. - * EtherCAT joins this set once its bit-offset allocator is migrated. */ -const REMOTE_DEVICE_KINDS: ReadonlySet = new Set(['modbus-tcp-remote']) +/** Consumer kinds the central recalculation reallocates. Pin mapping and + * EtherCAT own their own allocation for now, so they are treated as fixed + * constraints here (seeded + kept pinned) — VPP and Modbus allocate around + * them. EtherCAT/pins join this set in their own migration commits. */ +const ALLOCATED_KINDS: ReadonlySet = new Set(['vpp-io', 'modbus-tcp-remote']) + +/** IO-mapping (VPP) entry shape the recalc reads/writes. */ +type VppMappingEntry = { + iecAddress: string + alias?: string + slot: number + channelName: string + moduleId?: string + [key: string]: unknown +} + +/** Live VPP io-mapping entries (empty when the board has no VPP backplane). */ +function readVppEntries(live: ProjectSliceRoot): VppMappingEntry[] { + return ( + ( + live.deviceDefinitions.configuration.vendorScreenData?.['io-mapping'] as + | { entries?: VppMappingEntry[] } + | undefined + )?.entries ?? [] + ) +} /** Map the active target's capabilities to the set of consumer kinds that * participate in allocation. A target without pin mapping / VPP simply - * omits those kinds, so their addresses free up and remote-device + * omits those kinds, so their addresses free up and the still-active * producers recompact into the space (project-wide recalc on target * switch). */ function activeKindsFromCapabilities(caps: TargetCapabilities): Set { @@ -303,38 +325,44 @@ function activeKindsFromCapabilities(caps: TargetCapabilities): Set { } /** - * Build the capability-scoped registry from live producer state, reallocating - * ONLY the remote-device producers (Modbus + EtherCAT) while treating pins - * and VPP as fixed constraints. This is what closes gaps project-wide when a - * group / device is removed, and what drops inactive producers on a target - * switch. Read live state (never draft proxies) before entering `produce`. + * Build the capability-scoped registry from live producer state: derive + * consumers (pins/VPP/Modbus/EtherCAT), restore any aliases held in the + * session memory for channels that reappeared (remove→re-add), then + * reallocate the `ALLOCATED_KINDS` while keeping the rest pinned as fixed + * constraints. Read live state (never draft proxies) before `produce`. */ -function buildRemoteDeviceRegistry(live: ProjectSliceRoot): IecAddressRegistry { +function buildIecRegistry(live: ProjectSliceRoot): IecAddressRegistry { const board = live.deviceDefinitions.configuration.deviceBoard const boardInfo = live.deviceAvailableOptions.availableBoards.get(board ?? '') - const ioMapping = - ( - live.deviceDefinitions.configuration.vendorScreenData?.['io-mapping'] as - | { entries?: Array<{ iecAddress: string; alias?: string; slot: number; channelName: string }> } - | undefined - )?.entries ?? [] const seeded = migrateToRegistry({ pinMapping: { pins: live.deviceDefinitions.pinMapping.pinsByBoard[board] ?? [] }, - vendorIoMapping: { entries: ioMapping }, + vendorIoMapping: { entries: readVppEntries(live) }, remoteDevices: live.project.data.remoteDevices, }) + const restored = restoreAliasesFromMemory(seeded, live.iecAliasMemory ?? {}) const activeKinds = activeKindsFromCapabilities(resolveTargetCapabilities(boardInfo)) - return recalculateRegistry(unpinAllocatableChannels(seeded, REMOTE_DEVICE_KINDS), { activeKinds }).registry + return recalculateRegistry(unpinAllocatableChannels(restored, ALLOCATED_KINDS), { activeKinds }).registry +} + +/** Flatten the registry into a `channelKey -> { address, alias }` index for + * writing results back onto each producer. */ +function indexRegistry(registry: IecAddressRegistry): Map { + const index = new Map() + for (const consumer of registry.consumers) { + for (const channel of consumer.channels) { + const key = channelKey(consumer.id, channel.channelId) + index.set(key, { address: registry.assignments[key], alias: channel.alias ?? '' }) + } + } + return index } -/** Write the registry's assigned addresses back onto the Modbus producers - * (`ioPoints`). Keyed exactly as `migrateToRegistry` built the consumers, - * so there is no mapping drift. Runs inside `produce` on the draft. - * EtherCAT `channelMappings` are intentionally left to their own allocator - * for now (they still participate as fixed constraints via the pool). */ -function applyRemoteDeviceAddresses( +/** Write the registry's addresses + (memory-restored) aliases back onto the + * Modbus producers' `ioPoints`. Keyed exactly as `migrateToRegistry` built + * the consumers, so there is no mapping drift. Runs inside `produce`. */ +function applyModbusAddresses( remoteDevices: ProjectSlice['project']['data']['remoteDevices'], - registry: IecAddressRegistry, + index: ReadonlyMap, ): void { if (!remoteDevices) return for (const device of remoteDevices) { @@ -345,13 +373,28 @@ function applyRemoteDeviceAddresses( const group = groups[g] const consumerId = modbusConsumerId(deviceRef, group.id ?? String(g)) for (const point of group.ioPoints ?? []) { - const address = registry.assignments[channelKey(consumerId, point.id)] - if (address) point.iecLocation = address + const info = index.get(channelKey(consumerId, point.id)) + if (!info) continue + if (info.address) point.iecLocation = info.address + point.alias = info.alias } } } } +/** Rebuild VPP io-mapping entries with the registry's addresses + aliases. + * Pure — returns a new entries array for `setVendorScreenData`. */ +function applyVppEntries( + entries: readonly VppMappingEntry[], + index: ReadonlyMap, +): VppMappingEntry[] { + return entries.map((entry) => { + const info = index.get(channelKey(`vpp-slot-${entry.slot}`, entry.channelName)) + if (!info) return entry + return { ...entry, iecAddress: info.address ?? entry.iecAddress, alias: info.alias } + }) +} + const reconcileVariablesText = ( pouName: string | undefined, getState: ProjectGetState, @@ -426,6 +469,7 @@ const createProjectSlice: StateCreator = }, }, pendingDeletions: [], + iecAliasMemory: {}, projectActions: { // ----------------------------------------------------------------------- @@ -1541,7 +1585,7 @@ const createProjectSlice: StateCreator = ) // Removing a device frees its addresses — recompact the survivors // project-wide (bug #4) and let bound variables follow. - getState().projectActions.recalculateRemoteDeviceAddresses() + getState().projectActions.recalculateIecAddresses() return ok() }, updateRemoteDeviceName: (name, newName) => { @@ -1586,20 +1630,47 @@ const createProjectSlice: StateCreator = ) return ok() }, - recalculateRemoteDeviceAddresses: () => { - // Central, capability-scoped recalculation of the Modbus producers. - // Build the registry from live state before entering produce, write - // the compacted addresses back onto the ioPoints, then reconcile the - // program variables bound to any alias that moved. - const registry = buildRemoteDeviceRegistry(getState()) + recalculateIecAddresses: () => { + // Central, capability-scoped recalculation via the IEC address + // registry. Build the registry from live producer state (VPP + Modbus + // reallocated; pins/EtherCAT held as fixed constraints), restoring any + // aliases the session memory remembers for reappeared channels, then + // write the compacted addresses + aliases back onto every producer and + // reconcile bound variables. + const live = getState() + const registry = buildIecRegistry(live) + const index = indexRegistry(registry) + + // VPP io-mapping lives in device-slice vendorScreenData — write it via + // the device action so the layouts (which render from the store) update. + const vppEntries = readVppEntries(live) + if (vppEntries.length > 0) { + getState().deviceActions.setVendorScreenData('io-mapping', { entries: applyVppEntries(vppEntries, index) }) + } + + // Modbus ioPoints live in project.data — write them on the draft. setState( produce((slice: ProjectSlice) => { - applyRemoteDeviceAddresses(slice.project.data.remoteDevices, registry) + applyModbusAddresses(slice.project.data.remoteDevices, index) }), ) getState().projectActions.syncVariableAliases() return ok() }, + rememberChannelAlias: (memoryKey, alias) => { + // Record (or clear) an alias in the session memory keyed by the + // channel's stable semantic identity, so removing a producer and + // re-adding the same one restores the alias. Session-scoped — never + // serialized. + setState( + produce((slice: ProjectSlice) => { + const trimmed = alias.trim() + if (trimmed.length > 0) slice.iecAliasMemory[memoryKey] = trimmed + else delete slice.iecAliasMemory[memoryKey] + }), + ) + return ok() + }, addIOGroup: (deviceName, group) => { // Read producer state from the live store before entering produce // so the pool reflects every active source (pin-mapping, VPP, @@ -1642,7 +1713,7 @@ const createProjectSlice: StateCreator = // recompacts all remote-device producers project-wide and reconciles // bound variables. (The provisional addresses above just seed the // point structure/classes.) - getState().projectActions.recalculateRemoteDeviceAddresses() + getState().projectActions.recalculateIecAddresses() return ok() }, updateIOGroup: (deviceName, groupId, updates) => { @@ -1705,7 +1776,7 @@ const createProjectSlice: StateCreator = group.ioPoints = generateIOPoints(group.functionCode, group.length, group.name, pool, pending, existingPoints) }), ) - getState().projectActions.recalculateRemoteDeviceAddresses() + getState().projectActions.recalculateIecAddresses() return ok() }, deleteIOGroup: (deviceName, groupId) => { @@ -1717,7 +1788,7 @@ const createProjectSlice: StateCreator = }), ) // Recompact so the groups that followed reclaim the freed addresses. - getState().projectActions.recalculateRemoteDeviceAddresses() + getState().projectActions.recalculateIecAddresses() return ok() }, updateIOPointAlias: (deviceName, groupId, pointId, alias) => { diff --git a/src/frontend/store/slices/project/types.ts b/src/frontend/store/slices/project/types.ts index 7cea4f826..cb8758fdb 100644 --- a/src/frontend/store/slices/project/types.ts +++ b/src/frontend/store/slices/project/types.ts @@ -166,15 +166,23 @@ export type ProjectActions = { } /** - * Central, capability-scoped recalculation of the Modbus remote-device - * addresses via the IEC address registry. Derives consumers from live - * producer state, keeps pin-mapping / VPP / EtherCAT pinned as fixed - * constraints, re-packs the Modbus producers (closing gaps left by a - * removed group/device — project-wide), writes the addresses back onto - * the `ioPoints`, and reconciles bound variables. Invoked after every - * Modbus mutation and on target switch. + * Central, capability-scoped recalculation via the IEC address registry. + * Derives consumers from live producer state, restores aliases the session + * memory remembers for reappeared channels, re-packs the allocatable + * producers (VPP + Modbus today; closing gaps project-wide) while holding + * pins / EtherCAT as fixed constraints, writes the addresses + aliases back + * onto every producer, and reconciles bound variables. Invoked after every + * producer mutation and on target switch. */ - recalculateRemoteDeviceAddresses: () => ProjectResponse + recalculateIecAddresses: () => ProjectResponse + + /** + * Record (or clear, when `alias` is empty) an alias in the session memory + * keyed by a channel's stable semantic identity (see `iecAliasMemory`). + * Called from every producer's alias editor so the alias returns if the + * producer is removed and re-added within the session. + */ + rememberChannelAlias: (memoryKey: string, alias: string) => ProjectResponse /** * Cascade-rename every variable's `.alias` field from `oldAlias` to @@ -311,6 +319,14 @@ export type ProjectSlice = { project: ProjectState /** Relative file paths queued for deletion on next full project save. */ pendingDeletions: string[] + /** + * Session-scoped IEC alias memory: `memoryKey -> alias`, where `memoryKey` + * is a channel's stable semantic identity (`vpp:moduleId:slot:channel`, + * `modbus:device:group:point`, …). Lets a removed producer's aliases return + * when the same one is re-added within a session. NEVER serialized — reset + * on project load; only current addresses/aliases are saved to disk. + */ + iecAliasMemory: Record projectActions: ProjectActions } diff --git a/src/middleware/shared/utils/iec-address/address-pool.ts b/src/middleware/shared/utils/iec-address/address-pool.ts index e8a73e1b4..2a3277275 100644 --- a/src/middleware/shared/utils/iec-address/address-pool.ts +++ b/src/middleware/shared/utils/iec-address/address-pool.ts @@ -101,6 +101,10 @@ export interface PoolVppIoInput { slot: number channelName: string moduleName?: string + /** Stable module identity for the slot. Used only to build the session + * alias-memory key (`vpp:moduleId:slot:channel`); the pool itself + * ignores it. */ + moduleId?: string }> } diff --git a/src/middleware/shared/utils/iec-address/registry/index.ts b/src/middleware/shared/utils/iec-address/registry/index.ts index 0661b62be..018640cd8 100644 --- a/src/middleware/shared/utils/iec-address/registry/index.ts +++ b/src/middleware/shared/utils/iec-address/registry/index.ts @@ -2,10 +2,13 @@ export { formatAddress, isBitClass, isIecAddress, parseAddress, type ParsedAddre export { allocateAddresses, channelKey } from './allocate' export { ethercatConsumerId, + ethercatMemoryKey, migrateToRegistry, modbusConsumerId, + modbusMemoryKey, recalculateFromLegacy, unpinAllocatableChannels, + vppMemoryKey, } from './migrate' export { addConsumer, diff --git a/src/middleware/shared/utils/iec-address/registry/migrate.ts b/src/middleware/shared/utils/iec-address/registry/migrate.ts index e140cd8ac..de585a79a 100644 --- a/src/middleware/shared/utils/iec-address/registry/migrate.ts +++ b/src/middleware/shared/utils/iec-address/registry/migrate.ts @@ -37,18 +37,30 @@ export const modbusConsumerId = (deviceName: string, groupId: string): string => export const ethercatConsumerId = (deviceName: string, slaveName: string): string => `ethercat:${deviceName}:${slaveName}` +/* Session alias-memory key builders. The key is the stable *semantic* + * identity of a channel — it must be identical at the migration site and at + * every alias-edit site so `restoreAliasesFromMemory` matches. Exported so + * producers (e.g. the VPP layouts) record aliases under the same key. */ +export const vppMemoryKey = (moduleId: string, slot: number, channelName: string): string => + `vpp:${moduleId}:${slot}:${channelName}` +export const modbusMemoryKey = (deviceName: string, groupId: string, pointId: string): string => + `modbus:${deviceName}:${groupId}:${pointId}` +export const ethercatMemoryKey = (deviceName: string, slaveName: string, channelId: string): string => + `ethercat:${deviceName}:${slaveName}:${channelId}` + /** Build a channel seeded (pinned) at a legacy address, or `null` when the * address is not a parseable IEC location (nothing to migrate). */ function seedChannel( channelId: string, address: string | undefined, alias: string | undefined, + memoryKey: string, ): RegistryChannel | null { if (!address) return null const parsed = parseAddress(address) if (!parsed) return null const cls: AddressClass = parsed.cls - const channel: RegistryChannel = { channelId, class: cls, pinned: address } + const channel: RegistryChannel = { channelId, class: cls, pinned: address, memoryKey } if (alias && alias.length > 0) channel.alias = alias return channel } @@ -65,7 +77,8 @@ export function migrateToRegistry(inputs: PoolInputs): IecAddressRegistry { // 1. Pin mapping — one consumer, one channel per pin (fixed hardware). const pinChannels: RegistryChannel[] = [] for (const pin of inputs.pinMapping?.pins ?? []) { - const channel = seedChannel(pin.address, pin.address, pin.alias) + // Pins are fixed hardware — the address itself is the stable identity. + const channel = seedChannel(pin.address, pin.address, pin.alias, `pin:${pin.address}`) if (channel) pinChannels.push(channel) } if (pinChannels.length > 0) { @@ -75,7 +88,12 @@ export function migrateToRegistry(inputs: PoolInputs): IecAddressRegistry { // 2. VPP I/O — one consumer per slot; channels keyed by channel name. const bySlot = new Map() for (const entry of inputs.vendorIoMapping?.entries ?? []) { - const channel = seedChannel(entry.channelName, entry.iecAddress, entry.alias) + const channel = seedChannel( + entry.channelName, + entry.iecAddress, + entry.alias, + vppMemoryKey(entry.moduleId ?? '', entry.slot, entry.channelName), + ) if (!channel) continue const list = bySlot.get(entry.slot) if (list) list.push(channel) @@ -100,7 +118,12 @@ export function migrateToRegistry(inputs: PoolInputs): IecAddressRegistry { const groupId = group.id ?? String(g) const channels: RegistryChannel[] = [] for (const point of group.ioPoints ?? []) { - const channel = seedChannel(point.id, point.iecLocation, point.alias) + const channel = seedChannel( + point.id, + point.iecLocation, + point.alias, + modbusMemoryKey(deviceName, groupId, point.id), + ) if (channel) channels.push(channel) } if (channels.length > 0) { @@ -122,7 +145,12 @@ export function migrateToRegistry(inputs: PoolInputs): IecAddressRegistry { const slaveName = slave.name || 'slave' const channels: RegistryChannel[] = [] for (const mapping of slave.channelMappings ?? []) { - const channel = seedChannel(mapping.channelId, mapping.iecLocation, mapping.alias) + const channel = seedChannel( + mapping.channelId, + mapping.iecLocation, + mapping.alias, + ethercatMemoryKey(deviceName, slaveName, mapping.channelId), + ) if (channel) channels.push(channel) } if (channels.length > 0) { From 1014a41d5ec1f3fa0f62f30ee9972d5daf493813 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Jul 2026 12:16:45 -0400 Subject: [PATCH 22/35] feat(iec-address): route EtherCAT onto the central registry (Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EtherCAT channel addresses are now allocated by the central registry alongside VPP + Modbus (independent-prefix model, matching the runtime's separate typed buffers — the esi-parser's cross-width overlap avoidance was over-conservative and its output is now superseded here; the dead allocator is removed in the legacy pass). - `ALLOCATED_KINDS` += `ethercat`; new `applyEthercatAddresses` writes the registry's addresses + aliases back onto each slave's `channelMappings`. - `updateEthercatConfig` triggers `recalculateIecAddresses`, so channel-mapping changes recompact project-wide and bound variables follow. 271 project-slice tests pass; tsc, eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../store/__tests__/project-slice.test.ts | 24 +++++++++++ src/frontend/store/slices/project/slice.ts | 41 ++++++++++++++++--- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/frontend/store/__tests__/project-slice.test.ts b/src/frontend/store/__tests__/project-slice.test.ts index 52b80b69a..063d0d5d4 100644 --- a/src/frontend/store/__tests__/project-slice.test.ts +++ b/src/frontend/store/__tests__/project-slice.test.ts @@ -1,6 +1,8 @@ import { modbusMemoryKey, vppMemoryKey } from '@root/middleware/shared/utils/iec-address/registry' import { createStore } from 'zustand/vanilla' +import type { ConfiguredEtherCATDevice } from '@root/middleware/shared/ports/esi-types' + import type { BoardInfo, ModbusIOGroup, @@ -2026,6 +2028,28 @@ describe('createProjectSlice', () => { expect(result.ok).toBe(true) expect(store.getState().project.data.remoteDevices![0].ethercatConfig).toEqual(cfg) }) + + it('reallocates EtherCAT channel addresses through the central registry', () => { + seedRuntimeV4Board(store) + seedRemoteDevice(store, { name: 'BusA', protocol: 'ethercat' }) + const cfg = { + masterConfig: { networkInterface: 'eth0', cycleTimeUs: 1000, taskPriority: 50 }, + devices: [ + { + id: 's1', + name: 'Slave1', + channelMappings: [ + { channelId: 'c0', iecLocation: '%IW5', alias: '' }, + { channelId: 'c1', iecLocation: '%IW6', alias: '' }, + ], + } as unknown as ConfiguredEtherCATDevice, + ], + } + store.getState().projectActions.updateEthercatConfig('BusA', cfg) + // The central registry compacts the channels to the lowest free %IW slots. + const mappings = store.getState().project.data.remoteDevices![0].ethercatConfig!.devices[0].channelMappings + expect(mappings.map((m) => m.iecLocation)).toEqual(['%IW0', '%IW1']) + }) }) describe('createRemoteDevice', () => { diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index a42eb7501..3e6df6193 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -21,6 +21,7 @@ import { } from '../../../../middleware/shared/utils/iec-address' import { channelKey, + ethercatConsumerId, type IecAddressRegistry, migrateToRegistry, modbusConsumerId, @@ -283,11 +284,11 @@ type ProjectGetState = () => ProjectSliceRoot // Central IEC address recalculation (registry-owned) // --------------------------------------------------------------------------- -/** Consumer kinds the central recalculation reallocates. Pin mapping and - * EtherCAT own their own allocation for now, so they are treated as fixed - * constraints here (seeded + kept pinned) — VPP and Modbus allocate around - * them. EtherCAT/pins join this set in their own migration commits. */ -const ALLOCATED_KINDS: ReadonlySet = new Set(['vpp-io', 'modbus-tcp-remote']) +/** Consumer kinds the central recalculation reallocates. Pin mapping is fixed + * (hardware addresses), so pins are treated as constraints (seeded + kept + * pinned) that VPP / Modbus / EtherCAT allocate around; pins join in their + * own commit. */ +const ALLOCATED_KINDS: ReadonlySet = new Set(['vpp-io', 'modbus-tcp-remote', 'ethercat']) /** IO-mapping (VPP) entry shape the recalc reads/writes. */ type VppMappingEntry = { @@ -382,6 +383,29 @@ function applyModbusAddresses( } } +/** Write the registry's addresses + aliases back onto the EtherCAT producers' + * `channelMappings`. Runs inside `produce`. */ +function applyEthercatAddresses( + remoteDevices: ProjectSlice['project']['data']['remoteDevices'], + index: ReadonlyMap, +): void { + if (!remoteDevices) return + for (const device of remoteDevices) { + const deviceRef = device.name || 'device' + const slaves = device.ethercatConfig?.devices + if (!slaves) continue + for (const slave of slaves) { + const consumerId = ethercatConsumerId(deviceRef, slave.name || 'slave') + for (const mapping of slave.channelMappings ?? []) { + const info = index.get(channelKey(consumerId, mapping.channelId)) + if (!info) continue + if (info.address) mapping.iecLocation = info.address + mapping.alias = info.alias + } + } + } +} + /** Rebuild VPP io-mapping entries with the registry's addresses + aliases. * Pure — returns a new entries array for `setVendorScreenData`. */ function applyVppEntries( @@ -1648,10 +1672,12 @@ const createProjectSlice: StateCreator = getState().deviceActions.setVendorScreenData('io-mapping', { entries: applyVppEntries(vppEntries, index) }) } - // Modbus ioPoints live in project.data — write them on the draft. + // Modbus ioPoints + EtherCAT channelMappings live in project.data — + // write them on the draft. setState( produce((slice: ProjectSlice) => { applyModbusAddresses(slice.project.data.remoteDevices, index) + applyEthercatAddresses(slice.project.data.remoteDevices, index) }), ) getState().projectActions.syncVariableAliases() @@ -1882,6 +1908,9 @@ const createProjectSlice: StateCreator = // directly. No IEC task needs syncing. }), ) + // Channel-mapping changes go through the central registry so EtherCAT + // addresses are packed alongside VPP/Modbus and bound variables follow. + if (response.ok) getState().projectActions.recalculateIecAddresses() return response }, }, From 8db438072ce1867e4be46aad52e9c22806d558c7 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Jul 2026 12:20:28 -0400 Subject: [PATCH 23/35] feat(iec-address): reset session alias-memory per project; pins as constraints (Phase 7) - `clearProjects` now resets the session alias-memory so one project's remembered aliases can't leak into the next (the memory is session-scoped and never serialized; only current addresses/aliases persist to disk). - Documents pins as fixed registry constraints (hardware addresses are never reallocated; aliases persist per-board through the same uniqueness gate) and records the implementation status + deliberately-deferred legacy removals in docs/iec-address-registry.md. 272 project-slice tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/iec-address-registry.md | 30 +++++++++++++++++++ .../store/__tests__/project-slice.test.ts | 6 ++++ src/frontend/store/slices/project/slice.ts | 3 ++ 3 files changed, 39 insertions(+) diff --git a/docs/iec-address-registry.md b/docs/iec-address-registry.md index fd9078f7a..445bc9a42 100644 --- a/docs/iec-address-registry.md +++ b/docs/iec-address-registry.md @@ -211,3 +211,33 @@ producer and the compiler have moved to the registry. Each phase ships as a paired editor+web PR keeping the shared surface byte-identical. + +## 11. Implementation status + +Shipped on `feat/central-iec-address-registry` (editor + web, byte-identical): + +- **Pure core** — types, address-space, allocator (capability-scoped, + gapless, deterministic), registry ops, `resolveLocation`, migration + transform, and the session **alias-memory** (`memoryKey` + + `restoreAliasesFromMemory`, keyed `moduleId:slot:channel`; held in the store, + never serialized, reset on a fresh project). +- **Central action** `recalculateIecAddresses` reallocates **VPP + Modbus + + EtherCAT** together (`ALLOCATED_KINDS`), capability-scoped, restoring aliases + from the session memory, and writes addresses + aliases back to each + producer (VPP `io-mapping`, Modbus `ioPoints`, EtherCAT `channelMappings`). + Invoked from every producer mutation and on target switch. +- **Pins** participate as **fixed constraints** — hardware addresses are never + reallocated; their aliases persist per-board and flow through the same + registry uniqueness gate. Nothing to route. +- **Aliases** resolve to concrete IEC addresses **in the editor** (each + variable's `location` is kept resolved); the compiler/runtime are untouched. + +**Deliberately deferred** (superseded but still present; removal needs the +class-carrying-entry refactor + a `syncVariableAliases` migration, both best +verified in-app): the VPP effects' provisional `nextFreeAddress` seeding +(overwritten by the central recalc), the EtherCAT `esi-parser` overlap +allocator (superseded by the independent-prefix model — confirmed against the +runtime's separate typed buffers), and the legacy `address-pool` / +`alias-registry` read model (still backs `syncVariableAliases` and the +per-producer alias-uniqueness gates — consistent with the registry because +both derive from the same producer state). diff --git a/src/frontend/store/__tests__/project-slice.test.ts b/src/frontend/store/__tests__/project-slice.test.ts index 063d0d5d4..22981deb6 100644 --- a/src/frontend/store/__tests__/project-slice.test.ts +++ b/src/frontend/store/__tests__/project-slice.test.ts @@ -2638,6 +2638,12 @@ describe('createProjectSlice', () => { store.getState().projectActions.rememberChannelAlias(key, ' ') expect(store.getState().iecAliasMemory[key]).toBeUndefined() }) + + it('is reset on a fresh project so aliases do not leak between projects', () => { + store.getState().projectActions.rememberChannelAlias(vppMemoryKey('mod-a', 1, 'DO1'), 'relay_1') + store.getState().projectActions.clearProjects() + expect(store.getState().iecAliasMemory).toEqual({}) + }) }) describe('updateIOPointAlias', () => { diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 3e6df6193..64aa2c04f 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -528,6 +528,9 @@ const createProjectSlice: StateCreator = }, } slice.pendingDeletions = [] + // Session alias-memory is per-project; drop it on a fresh slate so + // one project's remembered aliases can't leak into the next. + slice.iecAliasMemory = {} }), ) }, From 35fae6b6d517160421197c15b5958cc66d185c3b Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Jul 2026 13:09:24 -0400 Subject: [PATCH 24/35] =?UTF-8?q?fix(debugger):=20single-source=20variable?= =?UTF-8?q?=E2=86=92address=20resolution=20for=20shared=20globals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive the composite-key→index map from the debug tree (the one traverseVariable enumeration walk) instead of a parallel inline walk, and fan a polled value out to every composite key that shares an address. Fixes two shared-global bugs surfaced with located VAR_GLOBALs: - forcing a located global from the LD/FBD editor silently no-op'd (the inline index-map walk missed the VAR_EXTERNAL case, so the composite key had no protocol index). - a global referenced by two programs displayed on only one POU (the index→leaf map was 1:1; now 1:many). Also names the single path rule (buildVariableDebugPath); OPC-UA keeps using the shared path primitives + address map. HIL-validated on P1AM-100 + SLM-RP4. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/frontend/hooks/useDebugPolling.ts | 48 ++-- src/frontend/hooks/useDebugSession.ts | 19 +- .../__tests__/debug-polling-filter.test.ts | 206 +++++++++--------- .../utils/__tests__/debugger-session.test.ts | 73 ++++++- src/frontend/utils/debug-polling-filter.ts | 48 ++-- src/frontend/utils/debug-tree-builder.ts | 7 +- src/frontend/utils/debug-tree-traversal.ts | 12 +- src/frontend/utils/debug-variable-finder.ts | 18 +- src/frontend/utils/debugger-session.ts | 109 ++++----- .../utils/opcua/generate-opcua-config.ts | 4 +- 10 files changed, 299 insertions(+), 245 deletions(-) diff --git a/src/frontend/hooks/useDebugPolling.ts b/src/frontend/hooks/useDebugPolling.ts index b963967dc..31ed497b4 100644 --- a/src/frontend/hooks/useDebugPolling.ts +++ b/src/frontend/hooks/useDebugPolling.ts @@ -95,11 +95,19 @@ interface LeafMeta { } /** - * Collect ALL leaf indexes from a tree (ignoring expansion state). - * Used to build the full index→metadata lookup for parsing responses. + * Collect ALL leaf metadata from a tree (ignoring expansion state), grouped by + * debug index. Used to build the full index→metadata lookup for parsing + * responses. + * + * One index can map to MANY composite keys: a shared CONFIGURATION VAR_GLOBAL is + * referenced (as VAR_EXTERNAL) by every program that uses it, so `main:start_pb` + * and `another_test:start_pb` resolve to the same address. The value read from + * that address must fan out to every composite key, or the variable displays on + * only one POU (the last one walked). Hence `LeafMeta[]` per index, not a single + * `LeafMeta`. */ -function collectAllLeafMeta(nodes: DebugTreeNode[]): Map { - const result = new Map() +function collectAllLeafMeta(nodes: DebugTreeNode[]): Map { + const result = new Map() function walk(node: DebugTreeNode) { if (node.isComplex && node.children) { @@ -109,7 +117,9 @@ function collectAllLeafMeta(nodes: DebugTreeNode[]): Map { } else if (node.debugIndex !== undefined) { const meta: LeafMeta = { compositeKey: node.compositeKey, type: node.type } if (node.enumValues) meta.enumValues = node.enumValues - result.set(node.debugIndex, meta) + const existing = result.get(node.debugIndex) + if (existing) existing.push(meta) + else result.set(node.debugIndex, [meta]) } } @@ -158,7 +168,8 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void const batchSizeRef = useRef(TCP_BATCH_SIZE) // Full leaf index→metadata map — computed once when debugger starts. - const allLeavesRef = useRef | null>(null) + // One index → many leaves (a shared global appears under each POU's key). + const allLeavesRef = useRef | null>(null) // Cached active indexes — rebuilt only when invalidation triggers change. const activeIndexesRef = useRef(null) @@ -183,11 +194,13 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void if (allLeavesRef.current) return allLeavesRef.current const allTrees = debugTreesRef.current - const allLeaves = new Map() + const allLeaves = new Map() for (const pouTrees of Object.values(allTrees)) { const leaves = collectAllLeafMeta(pouTrees) - for (const [index, meta] of leaves) { - allLeaves.set(index, meta) + for (const [index, metas] of leaves) { + const existing = allLeaves.get(index) + if (existing) existing.push(...metas) + else allLeaves.set(index, [...metas]) } } @@ -297,8 +310,8 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void } const index = batch[pos] - const meta = allLeaves.get(index) - if (!meta) { + const metas = allLeaves.get(index) + if (!metas || metas.length === 0) { // No leaf metadata — the runtime still consumed the position // (it doesn't know our index→type map). Count it consumed and // press on; the value bytes for this slot are forfeit. @@ -306,6 +319,10 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void continue } + // Every leaf at one index is the same underlying variable/address, so + // they share type/size; parse once off the first, then fan the value + // out to every composite key (a shared global lives under each POU's). + const meta = metas[0] const typeSize = getTypeSizeByName(meta.type) if (bufferOffset + typeSize > responseBuffer.length) { // Response buffer ran out before we reached every position the @@ -334,12 +351,15 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void // Out-of-range falls back to the raw integer. const stored = meta.enumValues !== undefined ? (meta.enumValues[Number(value)] ?? value) : value const current = isBool ? currentBool : currentNonBool - if (current.get(meta.compositeKey) !== stored) { - ;(isBool ? changedBool : changedNonBool).set(meta.compositeKey, stored) + const changed = isBool ? changedBool : changedNonBool + // Fan out to every composite key sharing this address (shared globals). + for (const m of metas) { + if (current.get(m.compositeKey) !== stored) changed.set(m.compositeKey, stored) } bufferOffset += bytesRead } catch { - ;(isBool ? changedBool : changedNonBool).set(meta.compositeKey, 'ERR') + const changed = isBool ? changedBool : changedNonBool + for (const m of metas) changed.set(m.compositeKey, 'ERR') bufferOffset += typeSize } positionsConsumed = pos + 1 diff --git a/src/frontend/hooks/useDebugSession.ts b/src/frontend/hooks/useDebugSession.ts index d10b94458..f309f5fa1 100644 --- a/src/frontend/hooks/useDebugSession.ts +++ b/src/frontend/hooks/useDebugSession.ts @@ -19,8 +19,8 @@ import { parseDebugMap } from '../utils/debug-parser' import { buildDebugVariableTreeMap, buildFbInstanceMap, - buildVariableIndexMap, debugMapToEntries, + deriveVariableIndexMap, } from '../utils/debugger-session' import { encodeForceValue } from '../utils/variable-sizes' @@ -88,7 +88,6 @@ export function useDebugSession(): UseDebugSessionReturn { return { success: false, error } } - const { indexMap, warnings } = buildVariableIndexMap(project.data.pous, instances, debugMap) const entriesForTree = debugMapToEntries(debugMap) logActions.addLog({ id: crypto.randomUUID(), @@ -96,11 +95,10 @@ export function useDebugSession(): UseDebugSessionReturn { message: `Debug map: ${debugMap.leaves.length} leaves across ${debugMap.arrays.length} arrays.`, }) - for (const w of warnings) { - logActions.addLog({ id: crypto.randomUUID(), level: 'warning', message: w }) - } - - // Build debug variable tree + // Build the debug variable tree — the single enumeration walk. The + // composite-key → index map (used by the LD/FBD editors and the poller) + // is derived from this same tree, so every consumer resolves a + // variable's address identically. let treeMap = new Map() const pouTrees: Record = {} try { @@ -120,6 +118,10 @@ export function useDebugSession(): UseDebugSessionReturn { pouTrees[pouName].push(node) } + for (const w of treeResult.warnings) { + logActions.addLog({ id: crypto.randomUUID(), level: 'warning', message: w }) + } + logActions.addLog({ id: crypto.randomUUID(), level: 'info', @@ -135,6 +137,9 @@ export function useDebugSession(): UseDebugSessionReturn { debugTreesRef.current = pouTrees + // Derive the composite-key → packed-address map from the tree leaves. + const indexMap = deriveVariableIndexMap(treeMap, debugMap) + // Build FB instance map const fbDebugInstancesMap = buildFbInstanceMap(project.data.pous, instances) diff --git a/src/frontend/utils/__tests__/debug-polling-filter.test.ts b/src/frontend/utils/__tests__/debug-polling-filter.test.ts index b14f4d13e..bf3e58c99 100644 --- a/src/frontend/utils/__tests__/debug-polling-filter.test.ts +++ b/src/frontend/utils/__tests__/debug-polling-filter.test.ts @@ -103,7 +103,7 @@ function makeState(overrides: { describe('buildActiveIndexSet', () => { it('returns empty indexes when there are no active variables', () => { const state = makeState({}) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes, cacheResult } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) expect(cacheResult).toBeNull() @@ -117,7 +117,7 @@ describe('buildActiveIndexSet', () => { ]) const indexMap = new Map([['Main:SPEED', 10]]) const state = makeState({ pous: [pou], debugVariableIndexes: indexMap }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([10]) @@ -147,7 +147,7 @@ describe('buildActiveIndexSet', () => { fbSelectedInstance: fbSelected, fbDebugInstances: fbInstances, }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([5]) @@ -156,7 +156,7 @@ describe('buildActiveIndexSet', () => { it('skips FB watched variables when no instance is selected', () => { const fbPou = makePou('MyFB', 'function-block', [makeVariable('OUT', 'output', true)]) const state = makeState({ pous: [fbPou] }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -184,7 +184,7 @@ describe('buildActiveIndexSet', () => { fbSelectedInstance: fbSelected, fbDebugInstances: fbInstances, }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -197,7 +197,7 @@ describe('buildActiveIndexSet', () => { body: { language: 'st', value: '' }, } const state = makeState({ pous: [pou] }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -209,7 +209,7 @@ describe('buildActiveIndexSet', () => { const forced = new Map([['Main:FORCED_VAR', { value: 42 }]]) const indexMap = new Map([['Main:FORCED_VAR', 7]]) const state = makeState({ debugForcedVariables: forced, debugVariableIndexes: indexMap }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([7]) @@ -220,7 +220,7 @@ describe('buildActiveIndexSet', () => { it('includes graph-listed variable indexes', () => { const indexMap = new Map([['Main:PLOTTED', 3]]) const state = makeState({ debugGraphList: ['Main:PLOTTED'], debugVariableIndexes: indexMap }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([3]) @@ -240,8 +240,8 @@ describe('buildActiveIndexSet', () => { debugVariableIndexes: indexMap, debugExpandedNodes: expandedNodes, }) - const allLeaves = new Map([ - [11, { compositeKey: 'Main:MyStruct.field1', type: 'INT' }], + const allLeaves = new Map([ + [11, [{ compositeKey: 'Main:MyStruct.field1', type: 'INT' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -259,8 +259,8 @@ describe('buildActiveIndexSet', () => { pous: [pou], debugVariableIndexes: indexMap, }) - const allLeaves = new Map([ - [11, { compositeKey: 'Main:MyStruct.field1', type: 'INT' }], + const allLeaves = new Map([ + [11, [{ compositeKey: 'Main:MyStruct.field1', type: 'INT' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -269,8 +269,8 @@ describe('buildActiveIndexSet', () => { it('skips leaf entry with no colon in compositeKey', () => { const state = makeState({}) - const allLeaves = new Map([ - [99, { compositeKey: 'no-colon-key', type: 'INT' }], + const allLeaves = new Map([ + [99, [{ compositeKey: 'no-colon-key', type: 'INT' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -294,10 +294,10 @@ describe('buildActiveIndexSet', () => { debugVariableIndexes: indexMap, debugExpandedNodes: expandedNodes, }) - const allLeaves = new Map([ - [101, { compositeKey: 'Main:MY_ARRAY[1]', type: 'DINT' }], - [102, { compositeKey: 'Main:MY_ARRAY[2]', type: 'DINT' }], - [103, { compositeKey: 'Main:MY_ARRAY[3]', type: 'DINT' }], + const allLeaves = new Map([ + [101, [{ compositeKey: 'Main:MY_ARRAY[1]', type: 'DINT' }]], + [102, [{ compositeKey: 'Main:MY_ARRAY[2]', type: 'DINT' }]], + [103, [{ compositeKey: 'Main:MY_ARRAY[3]', type: 'DINT' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -315,8 +315,8 @@ describe('buildActiveIndexSet', () => { pous: [pou], debugVariableIndexes: indexMap, }) - const allLeaves = new Map([ - [101, { compositeKey: 'Main:MY_ARRAY[1]', type: 'DINT' }], + const allLeaves = new Map([ + [101, [{ compositeKey: 'Main:MY_ARRAY[1]', type: 'DINT' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -332,9 +332,9 @@ describe('buildActiveIndexSet', () => { ['Main:FB.ARR[1]', 21], ['Main:FB.ARR[2]', 22], ]) - const allLeaves = new Map([ - [21, { compositeKey: 'Main:FB.ARR[1]', type: 'INT' }], - [22, { compositeKey: 'Main:FB.ARR[2]', type: 'INT' }], + const allLeaves = new Map([ + [21, [{ compositeKey: 'Main:FB.ARR[1]', type: 'INT' }]], + [22, [{ compositeKey: 'Main:FB.ARR[2]', type: 'INT' }]], ]) // Only FB expanded — array root is collapsed → elements stay hidden @@ -372,13 +372,13 @@ describe('buildActiveIndexSet', () => { ['Main:FB_ARR[3].Q', 81], ['Main:FB_ARR[3].ET', 82], ]) - const allLeaves = new Map([ - [61, { compositeKey: 'Main:FB_ARR[1].Q', type: 'BOOL' }], - [62, { compositeKey: 'Main:FB_ARR[1].ET', type: 'TIME' }], - [71, { compositeKey: 'Main:FB_ARR[2].Q', type: 'BOOL' }], - [72, { compositeKey: 'Main:FB_ARR[2].ET', type: 'TIME' }], - [81, { compositeKey: 'Main:FB_ARR[3].Q', type: 'BOOL' }], - [82, { compositeKey: 'Main:FB_ARR[3].ET', type: 'TIME' }], + const allLeaves = new Map([ + [61, [{ compositeKey: 'Main:FB_ARR[1].Q', type: 'BOOL' }]], + [62, [{ compositeKey: 'Main:FB_ARR[1].ET', type: 'TIME' }]], + [71, [{ compositeKey: 'Main:FB_ARR[2].Q', type: 'BOOL' }]], + [72, [{ compositeKey: 'Main:FB_ARR[2].ET', type: 'TIME' }]], + [81, [{ compositeKey: 'Main:FB_ARR[3].Q', type: 'BOOL' }]], + [82, [{ compositeKey: 'Main:FB_ARR[3].ET', type: 'TIME' }]], ]) // Array expanded, only element [2] expanded → only [2]'s leaves poll @@ -406,10 +406,10 @@ describe('buildActiveIndexSet', () => { ['Main:BOOLS[1]', 92], ['Main:BOOLS[2]', 93], ]) - const allLeaves = new Map([ - [91, { compositeKey: 'Main:BOOLS[0]', type: 'BOOL' }], - [92, { compositeKey: 'Main:BOOLS[1]', type: 'BOOL' }], - [93, { compositeKey: 'Main:BOOLS[2]', type: 'BOOL' }], + const allLeaves = new Map([ + [91, [{ compositeKey: 'Main:BOOLS[0]', type: 'BOOL' }]], + [92, [{ compositeKey: 'Main:BOOLS[1]', type: 'BOOL' }]], + [93, [{ compositeKey: 'Main:BOOLS[2]', type: 'BOOL' }]], ]) const state = makeState({ pous: [pou], @@ -423,8 +423,8 @@ describe('buildActiveIndexSet', () => { const pou = makePou('Main', 'program', [makeVariable('X', 'local', true)]) const indexMap = new Map([['Main:X', 1]]) const state = makeState({ pous: [pou], debugVariableIndexes: indexMap }) - const allLeaves = new Map([ - [1, { compositeKey: 'Main:X', type: 'INT' }], + const allLeaves = new Map([ + [1, [{ compositeKey: 'Main:X', type: 'INT' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -437,8 +437,8 @@ describe('buildActiveIndexSet', () => { debugGraphList: ['Main:MyStruct.field1'], debugVariableIndexes: new Map([['Main:MyStruct.field1', 11]]), }) - const allLeaves = new Map([ - [11, { compositeKey: 'Main:MyStruct.field1', type: 'INT' }], + const allLeaves = new Map([ + [11, [{ compositeKey: 'Main:MyStruct.field1', type: 'INT' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -449,8 +449,8 @@ describe('buildActiveIndexSet', () => { const state = makeState({ debugExpandedNodes: new Map([['Main:A', true]]), }) - const allLeaves = new Map([ - [20, { compositeKey: 'Main:A.B.C', type: 'INT' }], + const allLeaves = new Map([ + [20, [{ compositeKey: 'Main:A.B.C', type: 'INT' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -473,8 +473,8 @@ describe('buildActiveIndexSet', () => { debugVariableIndexes: indexMap, debugExpandedNodes: expandedNodes, }) - const allLeaves = new Map([ - [11, { compositeKey: 'Main:FB0.Q', type: 'BOOL' }], + const allLeaves = new Map([ + [11, [{ compositeKey: 'Main:FB0.Q', type: 'BOOL' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -499,7 +499,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'st', debugVariableIndexes: indexMap, }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes, cacheResult } = buildActiveIndexSet(state, allLeaves, cached) expect(activeIndexes).toContain(99) @@ -519,7 +519,7 @@ describe('buildActiveIndexSet', () => { editorName: 'Main', editorLanguage: 'st', }) - const allLeaves = new Map() + const allLeaves = new Map() const { cacheResult } = buildActiveIndexSet(state, allLeaves, cached) // Cache should have been replaced @@ -529,7 +529,7 @@ describe('buildActiveIndexSet', () => { it('skips diagram/source scan when currentPou is not found', () => { const state = makeState({ editorName: 'Nonexistent' }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes, cacheResult } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -566,7 +566,7 @@ describe('buildActiveIndexSet', () => { fbSelectedInstance: fbSelected, fbDebugInstances: fbInstances, }) - const allLeaves = new Map() + const allLeaves = new Map() const { cacheResult } = buildActiveIndexSet(state, allLeaves, cached) // Cache should still be valid because fbContextKey matches @@ -579,8 +579,8 @@ describe('buildActiveIndexSet', () => { const pou = makePou('Main', 'program', [makeVariable('A', 'local', true)]) // A is watched but NOT in debugVariableIndexes const state = makeState({ pous: [pou] }) - const allLeaves = new Map([ - [42, { compositeKey: 'Main:A', type: 'INT' }], + const allLeaves = new Map([ + [42, [{ compositeKey: 'Main:A', type: 'INT' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -599,7 +599,7 @@ describe('buildActiveIndexSet', () => { ['Main:B', 20], ]) const state = makeState({ pous: [pou], debugVariableIndexes: indexMap }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([10, 20, 30]) @@ -609,8 +609,8 @@ describe('buildActiveIndexSet', () => { const forced = new Map([['Main:X', { value: 0 }]]) const indexMap = new Map([['Main:X', 5]]) const state = makeState({ debugForcedVariables: forced, debugVariableIndexes: indexMap }) - const allLeaves = new Map([ - [5, { compositeKey: 'Main:X', type: 'INT' }], + const allLeaves = new Map([ + [5, [{ compositeKey: 'Main:X', type: 'INT' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -629,7 +629,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'st', debugVariableIndexes: indexMap, }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toContain(15) @@ -652,8 +652,8 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'st', debugVariableIndexes: indexMap, }) - const allLeaves = new Map([ - [20, { compositeKey: 'Main:MyTimer.ET', type: 'TIME' }], + const allLeaves = new Map([ + [20, [{ compositeKey: 'Main:MyTimer.ET', type: 'TIME' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -670,7 +670,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'st', debugVariableIndexes: indexMap, }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).not.toContain(50) @@ -686,7 +686,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'il', debugVariableIndexes: indexMap, }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toContain(8) @@ -702,7 +702,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'st', debugVariableIndexes: indexMap, }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).not.toContain(8) @@ -718,7 +718,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'st', debugVariableIndexes: indexMap, }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).not.toContain(8) @@ -750,7 +750,7 @@ describe('buildActiveIndexSet', () => { debugVariableIndexes: indexMap, ladderFlows: [ldFlow], }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toContain(1) @@ -775,7 +775,7 @@ describe('buildActiveIndexSet', () => { debugVariableIndexes: indexMap, ladderFlows: [ldFlow], }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toContain(3) @@ -801,7 +801,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'ld', ladderFlows: [ldFlow], }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -831,8 +831,8 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'ld', ladderFlows: [ldFlow], }) - const allLeaves = new Map([ - [50, { compositeKey: 'Main:MyTimer.Q', type: 'BOOL' }], + const allLeaves = new Map([ + [50, [{ compositeKey: 'Main:MyTimer.Q', type: 'BOOL' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -863,8 +863,8 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'ld', ladderFlows: [ldFlow], }) - const allLeaves = new Map([ - [60, { compositeKey: 'Main:_TMP_ADD3_OUT', type: 'INT' }], + const allLeaves = new Map([ + [60, [{ compositeKey: 'Main:_TMP_ADD3_OUT', type: 'INT' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -887,7 +887,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'ld', ladderFlows: [ldFlow], }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -901,7 +901,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'ld', ladderFlows: [{ name: 'OtherPou', rungs: [] }], }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -933,7 +933,7 @@ describe('buildActiveIndexSet', () => { debugVariableIndexes: indexMap, fbdFlows: [fbdFlow], }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([1, 2, 3]) @@ -961,8 +961,8 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'fbd', fbdFlows: [fbdFlow], }) - const allLeaves = new Map([ - [70, { compositeKey: 'Main:Timer1.ET', type: 'TIME' }], + const allLeaves = new Map([ + [70, [{ compositeKey: 'Main:Timer1.ET', type: 'TIME' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -986,7 +986,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'fbd', fbdFlows: [fbdFlow], }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -1000,7 +1000,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'fbd', fbdFlows: [{ name: 'OtherPou', rung: { nodes: [] } }], }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -1028,8 +1028,8 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'fbd', fbdFlows: [fbdFlow], }) - const allLeaves = new Map([ - [80, { compositeKey: 'Main:_TMP_MUL7_OUT', type: 'INT' }], + const allLeaves = new Map([ + [80, [{ compositeKey: 'Main:_TMP_MUL7_OUT', type: 'INT' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -1050,7 +1050,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'fbd', fbdFlows: [fbdFlow], }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -1085,7 +1085,7 @@ describe('buildActiveIndexSet', () => { fbDebugInstances: fbInstances, debugVariableIndexes: indexMap, }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toContain(25) @@ -1100,7 +1100,7 @@ describe('buildActiveIndexSet', () => { editorName: 'MyFB', editorLanguage: 'st', }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -1118,7 +1118,7 @@ describe('buildActiveIndexSet', () => { }) // Remove the language property from meta delete (state as unknown as { editor: { meta: Record } }).editor.meta.language - const allLeaves = new Map() + const allLeaves = new Map() const { cacheResult } = buildActiveIndexSet(state, allLeaves, null) expect(cacheResult?.language).toBe('') @@ -1135,7 +1135,7 @@ describe('buildActiveIndexSet', () => { fbSelectedInstance: fbSelected, fbDebugInstances: new Map(), }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) // No matching instance found in the empty list -> no variables added @@ -1153,7 +1153,7 @@ describe('buildActiveIndexSet', () => { editorName: 'NoInterface', editorLanguage: 'st', }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) // No interface -> ?? [] -> no variables to scan @@ -1174,7 +1174,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'ld', ladderFlows: [{ name: 'Main', rungs: [] }], }) - const allLeaves = new Map() + const allLeaves = new Map() const { cacheResult } = buildActiveIndexSet(state, allLeaves, cached) expect(cacheResult).not.toBe(cached) @@ -1196,7 +1196,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'st', fbSelectedInstance: fbSelected, }) - const allLeaves = new Map() + const allLeaves = new Map() const { cacheResult } = buildActiveIndexSet(state, allLeaves, cached) expect(cacheResult).not.toBe(cached) @@ -1217,8 +1217,8 @@ describe('buildActiveIndexSet', () => { debugVariableIndexes: indexMap, debugExpandedNodes: expandedNodes, }) - const allLeaves = new Map([ - [2, { compositeKey: 'Main:A.B.C', type: 'INT' }], + const allLeaves = new Map([ + [2, [{ compositeKey: 'Main:A.B.C', type: 'INT' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -1240,8 +1240,8 @@ describe('buildActiveIndexSet', () => { debugVariableIndexes: indexMap, debugExpandedNodes: expandedNodes, }) - const allLeaves = new Map([ - [2, { compositeKey: 'Main:A.B.C', type: 'INT' }], + const allLeaves = new Map([ + [2, [{ compositeKey: 'Main:A.B.C', type: 'INT' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -1259,7 +1259,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'python', debugVariableIndexes: indexMap, }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) // Python is not ld/fbd/st/il -> no visible variables collected @@ -1303,7 +1303,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'ld', ladderFlows: [ldFlow], }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) // makeKey returns null for all -> no keys added @@ -1342,7 +1342,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'fbd', fbdFlows: [fbdFlow], }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -1363,7 +1363,7 @@ describe('buildActiveIndexSet', () => { editorName: 'UnresolvedFB', editorLanguage: 'st', }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -1393,8 +1393,8 @@ describe('buildActiveIndexSet', () => { debugVariableIndexes: indexMap, debugExpandedNodes: expandedNodes, }) - const allLeaves = new Map([ - [11, { compositeKey: 'Main:FB0.Q', type: 'BOOL' }], + const allLeaves = new Map([ + [11, [{ compositeKey: 'Main:FB0.Q', type: 'BOOL' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -1419,7 +1419,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'ld', ladderFlows: [ldFlow], }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -1449,7 +1449,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'fbd', fbdFlows: [fbdFlow], }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -1481,8 +1481,8 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'ld', ladderFlows: [ldFlow], }) - const allLeaves = new Map([ - [50, { compositeKey: 'something:Timer1.Q', type: 'BOOL' }], + const allLeaves = new Map([ + [50, [{ compositeKey: 'something:Timer1.Q', type: 'BOOL' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -1508,8 +1508,8 @@ describe('buildActiveIndexSet', () => { editorName: 'UnresolvedFB', editorLanguage: 'st', }) - const allLeaves = new Map([ - [90, { compositeKey: 'something:MyTimer.ET', type: 'TIME' }], + const allLeaves = new Map([ + [90, [{ compositeKey: 'something:MyTimer.ET', type: 'TIME' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) @@ -1538,7 +1538,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'ld', ladderFlows: [ldFlow], }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -1564,7 +1564,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'fbd', fbdFlows: [fbdFlow], }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -1596,7 +1596,7 @@ describe('buildActiveIndexSet', () => { editorLanguage: 'ld', ladderFlows: [ldFlow], }) - const allLeaves = new Map() + const allLeaves = new Map() const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) expect(activeIndexes).toEqual([]) @@ -1629,8 +1629,8 @@ describe('buildActiveIndexSet', () => { ladderFlows: [ldFlow], }) // allLeaves has entries that DON'T match the prefix - const allLeaves = new Map([ - [99, { compositeKey: 'Main:UNRELATED', type: 'INT' }], + const allLeaves = new Map([ + [99, [{ compositeKey: 'Main:UNRELATED', type: 'INT' }]], ]) const { activeIndexes } = buildActiveIndexSet(state, allLeaves, null) diff --git a/src/frontend/utils/__tests__/debugger-session.test.ts b/src/frontend/utils/__tests__/debugger-session.test.ts index c82bafd91..2463f564f 100644 --- a/src/frontend/utils/__tests__/debugger-session.test.ts +++ b/src/frontend/utils/__tests__/debugger-session.test.ts @@ -5,7 +5,8 @@ import { packDebugAddr } from '../debug-parser' import { buildDebugVariableTreeMap, buildFbInstanceMap, - buildVariableIndexMap, + debugMapToEntries, + deriveVariableIndexMap, logCompilerEvent, } from '../debugger-session' @@ -203,7 +204,7 @@ describe('logCompilerEvent', () => { }) // --------------------------------------------------------------------------- -// buildVariableIndexMap +// deriveVariableIndexMap // --------------------------------------------------------------------------- function makeMap(leaves: Array<{ path: string; type: string; size: number }>): DebugMap { @@ -220,7 +221,21 @@ function addr(arrayIdx: number, elemIdx: number): number { return packDebugAddr({ arrayIdx, elemIdx }) } -describe('buildVariableIndexMap', () => { +describe('deriveVariableIndexMap', () => { + // The index map is now DERIVED from the debug tree (the single enumeration + // walk) rather than a parallel walk — so these tests drive the real flow: + // build the tree via buildDebugVariableTreeMap, then derive. + function derive( + pous: PLCPou[], + instances: PLCInstance[], + map: DebugMap, + dataTypes: PLCDataType[] = [], + ): { indexMap: Map; warnings: string[] } { + const entries = debugMapToEntries(map) + const { treeMap, warnings } = buildDebugVariableTreeMap(pous, instances, entries, { dataTypes, pous }, SYSTEM_LIBS) + return { indexMap: deriveVariableIndexMap(treeMap, map), warnings } + } + it('builds index map for simple base-type variables', () => { const pou = makePou('Main', 'program', [makeBaseVariable('SPEED', 'INT'), makeBaseVariable('TEMP', 'REAL')]) const instances = [makeInstance('INSTANCE0', 'Main')] @@ -229,7 +244,7 @@ describe('buildVariableIndexMap', () => { { path: 'INSTANCE0.TEMP', type: 'REAL', size: 4 }, ]) - const { indexMap, warnings } = buildVariableIndexMap([pou], instances, map) + const { indexMap, warnings } = derive([pou], instances, map) expect(indexMap.get('Main:SPEED')).toBe(addr(0, 0)) expect(indexMap.get('Main:TEMP')).toBe(addr(0, 1)) @@ -238,7 +253,7 @@ describe('buildVariableIndexMap', () => { it('warns when no instance is found for a program POU', () => { const pou = makePou('Orphan', 'program', [makeBaseVariable('X', 'INT')]) - const { warnings } = buildVariableIndexMap([pou], [], makeMap([])) + const { warnings } = derive([pou], [], makeMap([])) expect(warnings).toHaveLength(1) expect(warnings[0]).toContain('Orphan') @@ -246,7 +261,7 @@ describe('buildVariableIndexMap', () => { it('skips non-program POUs', () => { const fb = makePou('MyFB', 'function-block', [makeBaseVariable('Q', 'BOOL')]) - const { indexMap } = buildVariableIndexMap([fb], [makeInstance('INSTANCE0', 'Main')], makeMap([])) + const { indexMap } = derive([fb], [makeInstance('INSTANCE0', 'Main')], makeMap([])) expect(indexMap.size).toBe(0) }) @@ -260,7 +275,7 @@ describe('buildVariableIndexMap', () => { { path: 'INSTANCE0.ARR[2]', type: 'INT', size: 2 }, ]) - const { indexMap } = buildVariableIndexMap([pou], instances, map) + const { indexMap } = derive([pou], instances, map) expect(indexMap.get('Main:ARR[0]')).toBe(addr(0, 0)) expect(indexMap.get('Main:ARR[1]')).toBe(addr(0, 1)) @@ -276,19 +291,57 @@ describe('buildVariableIndexMap', () => { { path: 'INSTANCE0.NEG[1]', type: 'BOOL', size: 1 }, ]) - const { indexMap } = buildVariableIndexMap([pou], instances, map) + const { indexMap } = derive([pou], instances, map) expect(indexMap.get('Main:NEG[-1]')).toBe(addr(0, 0)) expect(indexMap.get('Main:NEG[0]')).toBe(addr(0, 1)) expect(indexMap.get('Main:NEG[1]')).toBe(addr(0, 2)) }) + it('resolves VAR_EXTERNAL globals by their bare (unprefixed) debug path', () => { + // A CONFIGURATION VAR_GLOBAL is emitted under its bare uppercase name, not + // the program-instance path. The tree resolves the external via the global + // path, so the composite key gets an index (force/release then work from + // the LD/FBD editors). + const pou = makePou('Main', 'program', [ + makeBaseVariable('START_PB', 'BOOL', 'external'), + makeBaseVariable('LOCAL_X', 'INT'), + ]) + const instances = [makeInstance('INSTANCE0', 'Main')] + const map = makeMap([ + { path: 'START_PB', type: 'BOOL', size: 1 }, + { path: 'INSTANCE0.LOCAL_X', type: 'INT', size: 2 }, + ]) + + const { indexMap } = derive([pou], instances, map) + + expect(indexMap.get('Main: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. + expect(indexMap.has('INSTANCE0.START_PB')).toBe(false) + }) + + it('maps a global shared by two programs to the same index under both keys', () => { + // 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. + 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')] + const map = makeMap([{ path: 'START_PB', type: 'BOOL', size: 1 }]) + + 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)) + }) + it('falls back to raw debug path for unmatched leaves (nested fields)', () => { const pou = makePou('Main', 'program', []) const instances = [makeInstance('INSTANCE0', 'Main')] const map = makeMap([{ path: 'INSTANCE0.FB.FIELD', type: 'INT', size: 2 }]) - const { indexMap } = buildVariableIndexMap([pou], instances, map) + const { indexMap } = derive([pou], instances, map) expect(indexMap.get('INSTANCE0.FB.FIELD')).toBe(addr(0, 0)) }) @@ -310,7 +363,7 @@ describe('buildVariableIndexMap', () => { ], } - const { indexMap } = buildVariableIndexMap([pou], instances, map) + const { indexMap } = derive([pou], instances, map) expect(indexMap.get('Main:X')).toBe(addr(0, 0)) expect(indexMap.get('INSTANCE0.OTHER')).toBe(addr(1, 0)) diff --git a/src/frontend/utils/debug-polling-filter.ts b/src/frontend/utils/debug-polling-filter.ts index b01daf1d6..71ccdaaa0 100644 --- a/src/frontend/utils/debug-polling-filter.ts +++ b/src/frontend/utils/debug-polling-filter.ts @@ -62,13 +62,14 @@ export type VisibleVarsCache = { * Build a sorted array of debug indexes to poll this tick. * * @param state - Current Zustand store snapshot - * @param allLeaves - Full index→{compositeKey,type} map (all debug tree leaves) + * @param allLeaves - Full index→{compositeKey,type}[] map (all debug tree + * leaves; one index maps to many leaves for shared globals) * @param cachedResult - Previous diagram/source scan cache (or null) * @returns activeIndexes (sorted) and updated cache */ export function buildActiveIndexSet( state: DebugPollingState, - allLeaves: Map, + allLeaves: Map, cachedResult: VisibleVarsCache, ): { activeIndexes: number[]; cacheResult: VisibleVarsCache } { const activeKeys = new Set() @@ -126,18 +127,20 @@ export function buildActiveIndexSet( // — and include them if they have a watched/forced/graphed ancestor // AND every node in the hierarchy from that ancestor down is expanded. // ----------------------------------------------------------------------- - for (const [, meta] of allLeaves) { - const key = meta.compositeKey - const colonIdx = key.indexOf(':') - if (colonIdx === -1) continue - const pouName = key.slice(0, colonIdx) - const varName = key.slice(colonIdx + 1) - // Only nested leaves (struct fields, FB fields, array elements) need - // ancestor-resolution; flat leaves are handled by the watched-key path. - if (!varName.includes('.') && !varName.includes('[')) continue - - if (shouldPollNestedVariable(varName, pouName, activeKeys, debugExpandedNodes, debugGraphList)) { - activeKeys.add(key) + for (const [, metas] of allLeaves) { + for (const meta of metas) { + const key = meta.compositeKey + const colonIdx = key.indexOf(':') + if (colonIdx === -1) continue + const pouName = key.slice(0, colonIdx) + const varName = key.slice(colonIdx + 1) + // Only nested leaves (struct fields, FB fields, array elements) need + // ancestor-resolution; flat leaves are handled by the watched-key path. + if (!varName.includes('.') && !varName.includes('[')) continue + + if (shouldPollNestedVariable(varName, pouName, activeKeys, debugExpandedNodes, debugGraphList)) { + activeKeys.add(key) + } } } @@ -179,9 +182,10 @@ export function buildActiveIndexSet( } // Also include by scanning allLeaves (handles cases where debugVariableIndexes - // doesn't have the key but the tree does, e.g. deeply nested fields) - for (const [index, meta] of allLeaves) { - if (activeKeys.has(meta.compositeKey)) { + // doesn't have the key but the tree does, e.g. deeply nested fields). One + // index can carry several keys (shared globals) — active if ANY is active. + for (const [index, metas] of allLeaves) { + if (metas.some((m) => activeKeys.has(m.compositeKey))) { indexSet.add(index) } } @@ -284,7 +288,7 @@ function shouldPollNestedVariable( function computeVisibleVariableKeys( state: DebugPollingState, currentPou: PLCPou, - allLeaves: Map, + allLeaves: Map, ): Set { const keys = new Set() const language = currentPou.body.language @@ -304,9 +308,11 @@ function computeVisibleVariableKeys( // Helper to find all leaf keys matching a prefix in the debug tree const addLeavesWithPrefix = (prefix: string) => { - for (const [, meta] of allLeaves) { - if (meta.compositeKey.startsWith(prefix)) { - keys.add(meta.compositeKey) + for (const [, metas] of allLeaves) { + for (const meta of metas) { + if (meta.compositeKey.startsWith(prefix)) { + keys.add(meta.compositeKey) + } } } } diff --git a/src/frontend/utils/debug-tree-builder.ts b/src/frontend/utils/debug-tree-builder.ts index cc8de5ce1..67be33a35 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 { buildDebugPath, buildGlobalDebugPath } from './debug-variable-finder' +import { buildGlobalDebugPath, buildVariableDebugPath } from './debug-variable-finder' /** * Project data shape expected by the debug tree builder. @@ -198,8 +198,5 @@ export function buildDebugTree( * External variables use CONFIG0__ prefix, local variables use RES0__INSTANCE. prefix. */ export function buildVariableBasePath(variableName: string, instanceName: string, variableClass?: string): string { - if (variableClass === 'external') { - return buildGlobalDebugPath(variableName) - } - return buildDebugPath(instanceName, variableName) + return buildVariableDebugPath(variableClass === 'external', instanceName, variableName) } diff --git a/src/frontend/utils/debug-tree-traversal.ts b/src/frontend/utils/debug-tree-traversal.ts index 71c6a5987..3b26d5f1f 100644 --- a/src/frontend/utils/debug-tree-traversal.ts +++ b/src/frontend/utils/debug-tree-traversal.ts @@ -9,12 +9,7 @@ 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 { - buildDebugPath, - buildGlobalDebugPath, - findDebugVariable, - findDebugVariableForField, -} from './debug-variable-finder' +import { buildVariableDebugPath, findDebugVariable, findDebugVariableForField } from './debug-variable-finder' import { findFunctionBlockVariables, findStructureVariables, normalizeTypeString } from './pou-helpers' /** @@ -405,9 +400,8 @@ export function traverseVariable(variable: PLCVariable, context: TraversalCon const { debugVariables, projectPous, pouName, instanceName, systemLibraries } = context const compositeKey = `${pouName}:${variable.name}` - // Build the base path - const fullPath = - variable.class === 'external' ? buildGlobalDebugPath(variable.name) : buildDebugPath(instanceName, variable.name) + // Build the base path (single rule — see buildVariableDebugPath) + const fullPath = buildVariableDebugPath(variable.class === 'external', instanceName, variable.name) if (variable.type.definition === 'base-type') { const baseType = variable.type.value.toUpperCase() diff --git a/src/frontend/utils/debug-variable-finder.ts b/src/frontend/utils/debug-variable-finder.ts index f7e56fc11..1228fb326 100644 --- a/src/frontend/utils/debug-variable-finder.ts +++ b/src/frontend/utils/debug-variable-finder.ts @@ -86,14 +86,26 @@ export function buildDebugPath( } /** - * Build the debug path for a global variable. STruC++ does not currently - * emit globals into debug-map.json; this helper returns the unprefixed - * name so future support lines up naturally. + * Build the debug path for a global variable. STruC++ emits CONFIGURATION + * VAR_GLOBALs into debug-map.json under their bare (unprefixed) uppercase name, + * so a VAR_EXTERNAL reference resolves to the same address from every program. */ export function buildGlobalDebugPath(variablePath: string): string { return variablePath.toUpperCase() } +/** + * The single path rule for a program variable: a shared global (VAR_EXTERNAL → + * CONFIGURATION VAR_GLOBAL) addresses by its bare name; everything else is + * instance-prefixed. Both the tree traversal and the base-path shim resolve + * through here so the "is this a global?" decision (and thus the address) can't + * drift between them. OPC-UA reaches the same two primitives but derives + * global-ness from its own node model (pouName GVL/CONFIG), not variable class. + */ +export function buildVariableDebugPath(isGlobal: boolean, instanceName: string, variableName: string): string { + return isGlobal ? buildGlobalDebugPath(variableName) : buildDebugPath(instanceName, variableName) +} + /** * Find a debug variable by its expected path (case-insensitive). * diff --git a/src/frontend/utils/debugger-session.ts b/src/frontend/utils/debugger-session.ts index b1adf3503..d77fa1a4d 100644 --- a/src/frontend/utils/debugger-session.ts +++ b/src/frontend/utils/debugger-session.ts @@ -16,7 +16,7 @@ import type { PLCVariable, } from '../../middleware/shared/ports/types' import type { DebugMap, DebugVariableEntry } from './debug-parser' -import { buildLeafPathMap, packDebugAddr } from './debug-parser' +import { packDebugAddr } from './debug-parser' import { buildDebugTree } from './debug-tree-builder' import { buildDebugPathPrefix, findInstanceName, type PLCInstanceMapping } from './debug-variable-finder' @@ -79,88 +79,47 @@ export function logCompilerEvent( } // --------------------------------------------------------------------------- -// 1. buildVariableIndexMap — composite-key -> packed-DebugAddr +// 1. deriveVariableIndexMap — composite-key -> packed-DebugAddr (from the tree) // --------------------------------------------------------------------------- -export interface VariableIndexMapResult { - indexMap: Map - warnings: string[] -} - /** - * Build a composite-key -> packed-DebugAddr map from a DebugMap. + * Derive the composite-key -> packed-DebugAddr map from the debug tree, which + * is the single enumeration walk (`traverseVariable`, via + * `buildDebugVariableTreeMap`). * - * The map addresses variables as (arrayIdx, elemIdx) pairs; we pack those - * into a single number `(arr << 16) | elem` so downstream store types and - * the polling loop see a plain `number` key. + * The tree already resolved every variable's debug address exactly once, + * applying the one path convention (external globals by their bare name, + * program-locals instance-prefixed, `.FIELD` for struct/FB fields, `[idx]` for + * array elements). Flattening its leaves here guarantees the LD/FBD editors, + * the watch panel, and the poller all address a variable identically — there is + * no second walk that can drift. (A divergent inline walk here is exactly how + * force silently no-op'd on located globals: it missed the external case.) * - * Path convention emitted by STruC++: - * INSTANCE_NAME.VAR_NAME (scalar) - * INSTANCE_NAME.VAR_NAME[5] (array element, IEC-indexed) - * INSTANCE_NAME.FB_INST.FIELD (nested struct/FB field) + * Addresses are (arrayIdx, elemIdx) pairs packed into a single number + * `(arr << 16) | elem`. `treeMap` is the flat compositeKey -> node map that + * `buildDebugVariableTreeMap` returns (every node, nested included). */ -export function buildVariableIndexMap(pous: PLCPou[], instances: PLCInstance[], map: DebugMap): VariableIndexMapResult { +export function deriveVariableIndexMap(treeMap: Map, map: DebugMap): Map { const indexMap = new Map() - const warnings: string[] = [] - - // Single source of truth for path → packed-address (case-insensitive). - // Same lookup the OPC-UA resolver uses. - const pathToAddr = buildLeafPathMap(map) - const instanceMappings: PLCInstanceMapping[] = instances.map((inst) => ({ - name: inst.name, - program: inst.program, - })) - - pous.forEach((pou) => { - if (pou.pouType !== 'program') return - - const instanceName = findInstanceName(pou.name, instanceMappings) - if (!instanceName) { - warnings.push(`No instance found for program '${pou.name}', skipping debug variable parsing.`) - return + // Every resolved leaf in the tree, keyed by its composite key. Complex nodes + // (structs / FBs / arrays) carry no address of their own — only their leaves + // do (debugIndex === undefined on the parents). + for (const [compositeKey, node] of treeMap) { + if (node.debugIndex !== undefined) { + indexMap.set(compositeKey, node.debugIndex) } + } - const upperInstance = instanceName.toUpperCase() - const variables = pou.interface?.variables ?? [] - - variables.forEach((v: PLCVariable) => { - if (v.type.definition === 'array' && v.type.data) { - const dimensions = v.type.data.dimensions - if (dimensions.length > 0) { - const dimMatch = dimensions[0].dimension.match(/^(-?\d+)\.\.(-?\d+)$/) - if (dimMatch) { - const startIdx = parseInt(dimMatch[1], 10) - const endIdx = parseInt(dimMatch[2], 10) - for (let i = 0; i <= endIdx - startIdx; i++) { - const iecIdx = startIdx + i - const path = `${upperInstance}.${v.name.toUpperCase()}[${iecIdx}]` - const addr = pathToAddr.get(path) - if (addr !== undefined) { - indexMap.set(`${pou.name}:${v.name}[${iecIdx}]`, addr) - } - } - } - } - } else { - const path = `${upperInstance}.${v.name.toUpperCase()}` - const addr = pathToAddr.get(path) - if (addr !== undefined) { - indexMap.set(`${pou.name}:${v.name}`, addr) - } - } - }) - }) - - // Fallback: also key by the raw debug path so any leaves we didn't cover - // (nested fields, FB internals) remain reachable by path. + // Fallback: also key by the raw debug path so any leaves the tree didn't + // surface (e.g. library-FB internals) remain reachable by path. for (const leaf of map.leaves) { if (!indexMap.has(leaf.path)) { indexMap.set(leaf.path, packDebugAddr(leaf)) } } - return { indexMap, warnings } + return indexMap } /** @@ -183,12 +142,16 @@ export interface DebugVariableTreeMapResult { treeMap: Map trees: DebugTreeNode[] complexCount: number + warnings: string[] } /** * Build a flat compositeKey -> DebugTreeNode map by traversing all program - * POU variables. Pure function — swallows per-variable errors to match - * existing behaviour. + * POU variables. This is the single enumeration walk (`traverseVariable`); + * `deriveVariableIndexMap` and the poller's leaf collection both project off + * its output rather than re-walking. Pure function — swallows per-variable + * errors to match existing behaviour; `warnings` collects programs with no + * instance in Resources (same diagnostic the old index-map walk emitted). */ export function buildDebugVariableTreeMap( pous: PLCPou[], @@ -199,6 +162,7 @@ export function buildDebugVariableTreeMap( ): DebugVariableTreeMapResult { const trees: DebugTreeNode[] = [] const treeMap = new Map() + const warnings: string[] = [] let complexCount = 0 const instanceMappings: PLCInstanceMapping[] = instances.map((inst) => ({ @@ -219,7 +183,10 @@ export function buildDebugVariableTreeMap( if (pou.pouType !== 'program') return const instanceName = findInstanceName(pou.name, instanceMappings) - if (!instanceName) return + if (!instanceName) { + warnings.push(`No instance found for program '${pou.name}', skipping debug variable parsing.`) + return + } const variables = pou.interface?.variables ?? [] variables.forEach((v: PLCVariable) => { @@ -262,7 +229,7 @@ export function buildDebugVariableTreeMap( } }) - return { treeMap, trees, complexCount } + return { treeMap, trees, complexCount, warnings } } // --------------------------------------------------------------------------- diff --git a/src/frontend/utils/opcua/generate-opcua-config.ts b/src/frontend/utils/opcua/generate-opcua-config.ts index d7d0295f0..8d0329e0a 100644 --- a/src/frontend/utils/opcua/generate-opcua-config.ts +++ b/src/frontend/utils/opcua/generate-opcua-config.ts @@ -387,8 +387,8 @@ const buildAddressSpace = ( /** * Parse debug-map.json content into the uppercase-path → packed-addr - * Map the resolver consumes. The same lookup table the debugger's - * buildVariableIndexMap builds — single source of truth. + * Map the resolver consumes. The same path→address lookup table the + * debugger derives its index map from — single source of truth. * * Returns an empty Map on malformed/missing input; caller checks * `addressSpace.nodes.length > 0 && map.size === 0` to distinguish From d145465598c10d90b540942c1fff2cb9a9694298 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Jul 2026 13:09:24 -0400 Subject: [PATCH 25/35] chore: pin strucpp v0.5.13 (shared-global support) 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 879364ceb..a0d6978b0 100644 --- a/binary-versions.json +++ b/binary-versions.json @@ -4,7 +4,7 @@ "repository": "Autonomy-Logic/xml2st" }, "strucpp": { - "version": "v0.5.10", + "version": "v0.5.13", "repository": "Autonomy-Logic/STruCpp" } } From 56edaeebb87677e1cbfc65f48190b0590e6c7f51 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Jul 2026 13:49:03 -0400 Subject: [PATCH 26/35] fix(ci): install strucpp in the unit-tests job (it was never present) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit-tests workflow runs 'npm ci --ignore-scripts', which skips the postinstall that fetches strucpp (a GitHub-release package, not an npm dep). The TS sources import strucpp/libs/iec-types.json and its runtime headers, so every suite failed to load with 'Cannot find module strucpp' — the job has been red on every run since it was added. Add a '--strucpp-only' mode to download-binaries (installs just the platform-independent strucpp package, no xml2st binary / native rebuild) and a 'setup:strucpp' script, then run it after install in the workflow so the suites load and the coverage gate actually executes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci-unit-tests.yml | 8 +++++ package.json | 1 + scripts/download-binaries.ts | 48 +++++++++++++++++------------ 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci-unit-tests.yml b/.github/workflows/ci-unit-tests.yml index efa90bc74..c19cebca2 100644 --- a/.github/workflows/ci-unit-tests.yml +++ b/.github/workflows/ci-unit-tests.yml @@ -20,6 +20,14 @@ jobs: - name: Install dependencies run: npm ci --ignore-scripts + # --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 + # can load and the coverage gate actually runs. + - name: Install strucpp + run: npm run setup:strucpp + - name: Run tests with coverage run: npx jest --config jest.config.json --collectCoverage --ci diff --git a/package.json b/package.json index e003f08bb..3d89bc19d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "format": "cross-env NODE_ENV=development prettier --write \"./src/**/*.{ts,tsx}\"", "postinstall": "ts-node scripts/download-binaries.ts && ts-node scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll", "setup:binaries": "ts-node scripts/download-binaries.ts", + "setup:strucpp": "ts-node scripts/download-binaries.ts --strucpp-only", "package": "ts-node scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", "prestart": "ts-node scripts/download-binaries.ts && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack/webpack.config.main.dev.ts", diff --git a/scripts/download-binaries.ts b/scripts/download-binaries.ts index b0318c183..e7a18860c 100644 --- a/scripts/download-binaries.ts +++ b/scripts/download-binaries.ts @@ -54,11 +54,17 @@ function cacheFile(platform: Platform, arch: Arch): string { // CLI argument parsing // --------------------------------------------------------------------------- -function parseArgs(): { platform: Platform; arch: Arch; force: boolean } { +function parseArgs(): { platform: Platform; arch: Arch; force: boolean; strucppOnly: boolean } { const args = process.argv.slice(2) let platform = process.platform as string let arch = process.arch as string let force = false + // strucpp-only: install just the platform-independent strucpp package (its + // libs/ + runtime headers are imported by the TS sources). Skips the xml2st + // platform binary — used by the unit-test CI job, which runs `npm ci + // --ignore-scripts` (so the postinstall never fetched strucpp) but doesn't + // need the heavyweight platform binaries or a native rebuild. + let strucppOnly = false for (let i = 0; i < args.length; i++) { if (args[i] === '--platform' && args[i + 1]) { @@ -67,6 +73,8 @@ function parseArgs(): { platform: Platform; arch: Arch; force: boolean } { arch = args[++i] } else if (args[i] === '--force') { force = true + } else if (args[i] === '--strucpp-only') { + strucppOnly = true } } @@ -79,7 +87,7 @@ function parseArgs(): { platform: Platform; arch: Arch; force: boolean } { process.exit(1) } - return { platform: platform as Platform, arch: arch as Arch, force } + return { platform: platform as Platform, arch: arch as Arch, force, strucppOnly } } // --------------------------------------------------------------------------- @@ -289,7 +297,7 @@ async function downloadStrucpp(tool: ToolEntry): Promise { // --------------------------------------------------------------------------- async function main(): Promise { - const { platform, arch, force } = parseArgs() + const { platform, arch, force, strucppOnly } = parseArgs() if (!fs.existsSync(VERSIONS_FILE)) { console.error(`binary-versions.json not found at ${VERSIONS_FILE}`) @@ -298,33 +306,33 @@ 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}`) + console.log(`[download-binaries] platform=${platform} arch=${arch} force=${force} strucppOnly=${strucppOnly}`) - 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) + // strucpp is platform-independent (its libs/ + runtime headers are imported + // by the TS sources) — download it whenever it's missing or outdated. + if (force || needsStrucpp(versions)) { + await downloadStrucpp(versions.strucpp) + } else { + console.log(` strucpp ${versions.strucpp.version} already installed, skipping.`) + } - if (!downloadXml2stNeeded && !downloadStrucppNeeded) { - console.log(`[download-binaries] All tools up to date, skipping.`) + // --strucpp-only stops here: the caller (e.g. the unit-test CI job) needs the + // strucpp package but not the platform binary or the platform cache marker. + if (strucppOnly) { + console.log(`[download-binaries] strucpp-only: skipped xml2st platform binary.`) return } - if (downloadXml2stNeeded) { + const targetBinDir = binDir(platform, arch) + fs.mkdirSync(targetBinDir, { recursive: true }) + + const cached = force ? null : getCachedMetadata(platform, arch) + if (force || needsXml2st(versions, cached, platform, arch)) { await downloadXml2st(versions.xml2st, platform, arch, targetBinDir) } else { console.log(` xml2st ${versions.xml2st.version} already installed, skipping.`) } - // strucpp is platform-independent — only download once regardless of platform/arch - if (downloadStrucppNeeded) { - await downloadStrucpp(versions.strucpp) - } else { - console.log(` strucpp ${versions.strucpp.version} already installed, skipping.`) - } - writeCache(versions, platform, arch) console.log(`[download-binaries] Done.`) } From 78a38fb9705528260c978daf5427a5c2b49c1304 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Jul 2026 15:34:07 -0400 Subject: [PATCH 27/35] test(backend/shared): de-XML-ify stale compile/library-build suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These suites predate the XML/xml2st → in-process JSON-transpiler migration and never ran in CI (the unit-tests job was broken), so they drifted: - compile/pipeline.test.ts: rename transpileXmlToSt→transpileToSt, assert the current {projectData} call contract, drop the dead XmlGenerator mock + XML stage test, add a simulator-no-binary edge test. - library/build-pipeline.test.ts: drop the XmlGenerator mock and the removed XML-generation/empty-data paths; assert prepareXmlForLibraryBuild's current {projectData,knownPous,manifest}|{error} contract. - library/library-build-orchestrator.test.ts: fake port uses transpileToSt ({projectData}, log); remove plc.xml intermediates/logs; restore coverage. All three source files back to 100% funcs/lines/statements. 95 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../shared/compile/__tests__/pipeline.test.ts | 60 ++--- .../library/__tests__/build-pipeline.test.ts | 52 ++-- .../library-build-orchestrator.test.ts | 234 ++++++++++++++++-- 3 files changed, 259 insertions(+), 87 deletions(-) diff --git a/src/backend/shared/compile/__tests__/pipeline.test.ts b/src/backend/shared/compile/__tests__/pipeline.test.ts index 1c8cf9877..170260375 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,12 @@ 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 +73,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 +131,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 +159,13 @@ 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 transpiler the (preprocessed) project IR and a + // log callback; the port impl owns xml2st-vs-JSON backend selection and any + // format-specific flags internally (see transpiler-mode.ts). The pipeline + // stays format-agnostic — it only passes { projectData }. + expect(port.transpileToSt).toHaveBeenCalledWith( + expect.objectContaining({ projectData: expect.anything() }), expect.any(Function), ) expect(port.compileArduino).toHaveBeenCalledTimes(1) @@ -272,9 +264,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 the transpile step. + 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 +762,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' }], }), @@ -828,6 +809,17 @@ describe('runCompilePipeline — failure propagation', () => { expect(result.success).toBe(false) }) + it('returns success=false when a simulator build produces no .hex binary', async () => { + // Simulator targets require the .hex artefact in memory (the loader can't + // find it on disk). A compile that reports ok but omits `binary` must fail + // with a precise error rather than silently succeeding. + const port = makePort({ compileArduino: jest.fn().mockResolvedValue({ ok: true }) }) + const { events, emit } = captureEvents() + const result = await runCompilePipeline(makeArgs(), port, emit) + expect(result.success).toBe(false) + expect(events.some((e) => e.stage === 'arduino-compile' && /did not produce a \.hex/.test(e.message))).toBe(true) + }) + it('returns success=false when uploadRuntimeV4 reports failure', async () => { const port = makePort({ uploadRuntimeV4: jest.fn().mockResolvedValue({ ok: false, errors: [] }), @@ -945,7 +937,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,7 +1040,7 @@ 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) => { + transpileToSt: jest.fn().mockImplementation(async (_args, log) => { log('xml2st spawned subprocess', 'info') log('xml2st: parsed 5 POUs', 'info') return { ok: true, programSt: 'PROGRAM main\nEND_PROGRAM' } diff --git a/src/backend/shared/library/__tests__/build-pipeline.test.ts b/src/backend/shared/library/__tests__/build-pipeline.test.ts index 1222dd6b0..fa0f37d12 100644 --- a/src/backend/shared/library/__tests__/build-pipeline.test.ts +++ b/src/backend/shared/library/__tests__/build-pipeline.test.ts @@ -1,9 +1,11 @@ /** * Tests for the library build pipeline. * - * The XmlGenerator is mocked because it depends on the frontend - * xml-generator helpers; we exercise the orchestration here, not - * actual XML serialisation (covered by xml-generator's own tests). + * `prepareXmlForLibraryBuild` no longer generates PLCopen XML — the + * old xml2st 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`. * Strucpp is mocked via the runtime's test escape hatch — the build * pipeline must remain pure (no real strucpp load) for these tests. */ @@ -15,12 +17,6 @@ import type { StrucppRuntime } from '../strucpp-runtime' // Mocks // --------------------------------------------------------------------------- -const mockXmlGenerator = jest.fn() -jest.mock('../../utils/PLC/xml-generator', () => ({ - XmlGenerator: (...args: unknown[]) => mockXmlGenerator(...args), -})) - -// Import after mocks import { __setStrucppRuntimeForTests } from '../strucpp-runtime' import { __TESTING__, @@ -289,7 +285,6 @@ describe('prepareXmlForLibraryBuild', () => { expect('error' in result).toBe(true) if (!('error' in result)) return expect(result.error).toContain('library.json is invalid') - expect(mockXmlGenerator).not.toHaveBeenCalled() }) it('formats multi-line error reports with one bullet per validation issue', () => { @@ -299,35 +294,17 @@ 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 }) - 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 }) + it('returns stubbed projectData + knownPous (including stub) + parsed manifest on success', () => { 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('') + // `error` is the union discriminant — its absence means success. + expect('error' in result).toBe(false) + if ('error' in result) return expect(result.manifest.name).toBe('demo_lib') + // The stubbed project carries the library's POUs plus the + // synthesised `main` program the transpiler requires. + expect(result.projectData.pous.map((p) => p.data.name)).toEqual(['TankController', STUB.STUB_PROGRAM_NAME]) + // POUs from the project + the stub program const names = result.knownPous.map((p) => p.name) expect(names).toEqual(['TankController', STUB.STUB_PROGRAM_NAME]) @@ -337,7 +314,6 @@ describe('prepareXmlForLibraryBuild', () => { }) it('maps each POU type to the correct splitter kind', () => { - mockXmlGenerator.mockReturnValue({ ok: true, data: '' }) const project = makeLibraryProject({ pous: [ { @@ -364,7 +340,7 @@ describe('prepareXmlForLibraryBuild', () => { ], }) const result = prepareXmlForLibraryBuild(project, VALID_MANIFEST_JSON) - if (!('knownPous' in result)) throw new Error('expected success') + if ('error' in result) throw new Error('expected success') const byName = Object.fromEntries(result.knownPous.map((p) => [p.name, p.kind])) expect(byName).toEqual({ Add2: 'FUNCTION', Tank: 'FUNCTION_BLOCK', main: '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..4823c0a5a 100644 --- a/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts +++ b/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts @@ -14,8 +14,13 @@ */ import type { LibraryBuildPort, VerifyCompileArgs } from '../../../../middleware/shared/ports/library-build-port' +import type { TranspileToStArgs, TranspileToStResult } from '../../../../middleware/shared/ports/compiler-platform-port' import type { PLCProjectData } from '../../types/PLC/open-plc' +// ST the fake `transpileToSt` port method emits. Fixed content so the +// verification-cache tests can precompute the harness MD5 off it. +const FAKE_PROGRAM_ST = 'PROGRAM main\n(* transpiled *)\nEND_PROGRAM\n' + // --------------------------------------------------------------------------- // Mocks for the inner shared helpers // --------------------------------------------------------------------------- @@ -48,6 +53,8 @@ interface PortHarness { missing: string[] verifyResult: { success: boolean; message?: string } verifyCalls: VerifyCompileArgs[] + transpileResult: TranspileToStResult + transpileCalls: TranspileToStArgs[] /** Programmable error for whichever method the test wants to fail. */ throwOn: Partial> } @@ -61,6 +68,8 @@ function makePort(): PortHarness { missing: [], verifyResult: { success: true }, verifyCalls: [], + transpileResult: { ok: true, programSt: FAKE_PROGRAM_ST }, + transpileCalls: [], throwOn: {}, } harness.port = { @@ -70,9 +79,12 @@ 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(args: TranspileToStArgs, log) { + if (harness.throwOn.transpileToSt) throw harness.throwOn.transpileToSt + harness.transpileCalls.push(args) + // Exercise the orchestrator's log-forwarding lambda. + log('transpiler: parsing project IR', 'info') + return harness.transpileResult }, async readBuildFile(_projectPath: string, relPath: string) { if (harness.throwOn.readBuildFile) throw harness.throwOn.readBuildFile @@ -122,7 +134,7 @@ beforeEach(() => { mockComposeVerify.mockClear() mockPrepareXml.mockReturnValue({ - xml: '...', + projectData: projectDataEmpty(), knownPous: [], manifest: { name: 'lib', version: '0.1.0', namespace: 'lib', extra: {} }, }) @@ -155,22 +167,23 @@ describe('runLibraryBuildPipeline', () => { expect(harness.files.get('build/lib.stlib')).toMatch(/^\{[\s\S]+\}\n$/) // Verification cache persisted with the MD5 the orchestrator computed. expect(harness.files.has('build/.verify-cache-library.json')).toBe(true) - // Intermediates (plc.xml, program.st) live in memory only — the - // orchestrator does NOT persist them. See the path-constants - // comment in library-build-orchestrator.ts for the rationale. - expect(harness.files.has('build/library/src/plc.xml')).toBe(false) + // Intermediates (program.st) live in memory only — the ST is + // produced in-process by `transpileToSt` and never persisted. + // See the path-constants comment in library-build-orchestrator.ts. expect(harness.files.has('build/library/src/program.st')).toBe(false) // Stage messages flow through in order. expect(events.map((e) => e.message)).toEqual( 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', ]), ) + // The stubbed projectData from Stage 1 is what the transpiler sees. + expect(harness.transpileCalls).toHaveLength(1) }) it('does not call deleteBuildSubtree (intermediates are no longer persisted)', async () => { @@ -247,7 +260,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 the strucpp compile when the project enables an unresolved library', async () => { const harness = makePort() harness.missing = ['ghost-lib'] const { events, emit } = captureEvents() @@ -277,9 +290,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` - const expectedMd5 = `md5-${programSt.length}-${programSt.charCodeAt(0)}` + // will match this when the same transpiler output replays. + const expectedMd5 = `md5-${FAKE_PROGRAM_ST.length}-${FAKE_PROGRAM_ST.charCodeAt(0)}` harness.files.set('build/.verify-cache-library.json', JSON.stringify({ md5: expectedMd5, success: true })) const { events, emit } = captureEvents() @@ -300,8 +312,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 expectedMd5 = `md5-${programSt.length}-${programSt.charCodeAt(0)}` + const expectedMd5 = `md5-${FAKE_PROGRAM_ST.length}-${FAKE_PROGRAM_ST.charCodeAt(0)}` harness.files.set('build/.verify-cache-library.json', JSON.stringify({ md5: expectedMd5, success: true })) const { emit } = captureEvents() @@ -319,6 +330,52 @@ describe('runLibraryBuildPipeline', () => { expect(harness.verifyCalls).toHaveLength(1) }) + it('runs a fresh verification when the cache read throws', async () => { + const harness = makePort() + // Throw only on the cache read; the manifest read (Stage 0) must + // still succeed so we reach the cache-consult path. + const realRead = harness.port.readBuildFile.bind(harness.port) + harness.port.readBuildFile = async (projectPath, relPath) => { + if (relPath === 'build/.verify-cache-library.json') throw new Error('cache read blew up') + return realRead(projectPath, relPath) + } + const { emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + // Cache read failed → treated as a miss → fresh verification runs. + expect(harness.verifyCalls).toHaveLength(1) + }) + + it('runs a fresh verification when the cached file is malformed JSON', async () => { + const harness = makePort() + harness.files.set('build/.verify-cache-library.json', '{ not valid json') + const { emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + // Malformed cache → fall through to a real verification run. + expect(harness.verifyCalls).toHaveLength(1) + }) + it('surfaces a verification failure as a warning but still emits the .stlib', async () => { const harness = makePort() harness.verifyResult = { success: false, message: 'AVR ran out of flash' } @@ -418,4 +475,151 @@ describe('runLibraryBuildPipeline', () => { expect(mockLibraryBuild).not.toHaveBeenCalled() expect(harness.verifyCalls).toHaveLength(0) }) + + it('fails when reading library.json throws an IO error', async () => { + const harness = makePort() + harness.throwOn.readBuildFile = new Error('disk on fire') + const { emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/Could not read library\.json: disk on fire/) + expect(mockPrepareXml).not.toHaveBeenCalled() + }) + + it('fails when the transpiler reports an error', async () => { + const harness = makePort() + harness.transpileResult = { + ok: false, + errors: [{ message: 'unexpected token in POU body', line: 1, column: 1, severity: 'error' }], + } + const { emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/transpile-from-json failed: unexpected token in POU body/) + expect(result.libraryName).toBe('lib') + expect(mockLibraryBuild).not.toHaveBeenCalled() + expect(harness.verifyCalls).toHaveLength(0) + }) + + it('falls back to a generic message when the transpiler returns ok=true but no ST', async () => { + const harness = makePort() + // ok=true with an undefined programSt still short-circuits, and + // with no `errors[]` the orchestrator uses its default message. + harness.transpileResult = { ok: true, programSt: undefined } + const { emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/transpile-from-json failed: transpile-from-json failed/) + }) + + it('treats a thrown verifyCompile as a failed (advisory) verification', async () => { + const harness = makePort() + // A non-Error throwable exercises the `String(error)` fallback in + // `formatError`. + harness.port.verifyCompile = async () => { + throw 'avr-gcc segfaulted' + } + const { events, emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + // Verification failures are advisory — the build still succeeds. + expect(result.success).toBe(true) + expect(result.verification?.success).toBe(false) + expect(result.verification?.message).toBe('avr-gcc segfaulted') + expect(events.some((e) => e.level === 'warning' && /Verification reported issues/.test(e.message))).toBe(true) + }) + + it('warns but still ships the .stlib when the verification cache cannot be written', async () => { + const harness = makePort() + // Fail only the cache write; the .stlib write happens later and + // must still succeed. + const realWrite = harness.port.writeBuildFile.bind(harness.port) + harness.port.writeBuildFile = async (projectPath, relPath, content) => { + if (relPath === 'build/.verify-cache-library.json') throw new Error('cache dir read-only') + return realWrite(projectPath, relPath, content) + } + const { events, emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(true) + expect(harness.files.has('build/lib.stlib')).toBe(true) + expect(events.some((e) => e.level === 'warning' && /Could not write verification cache/.test(e.message))).toBe(true) + }) + + it('fails when the .stlib archive cannot be written', async () => { + const harness = makePort() + harness.port.writeBuildFile = async (_projectPath, relPath) => { + if (relPath === 'build/lib.stlib') throw new Error('out of disk space') + // Let the cache write succeed. + } + const { emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/Could not write lib\.stlib: out of disk space/) + expect(result.libraryName).toBe('lib') + }) }) From 60100bce8ff883303867c5719850a5ace99faf7d Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Jul 2026 15:37:30 -0400 Subject: [PATCH 28/35] feat(iec-address): single-field variable location (alias | address) Distinguish manually-located variables from alias-bound ones by storing the binding itself in `variable.location`: either an alias name OR a literal `%addr`. Aliases resolve to addresses at compile time in the editor (via projectActions.getCompileReadyProjectData); the compiler and runtime never see aliases. A missing alias resolves to an empty location (the variable becomes unlocated). - Remove the separate `alias` field from PLCVariable + the auto-adoption and sync-variable-aliases engine; location is stored verbatim. - Add registry/resolve helpers (buildAliasIndex, resolveLocation, isLiteralLocation) and a pre-compile snapshot transform. - renameAlias cascades onto every bound variable's location; EtherCAT / Modbus / pin / VPP alias edits drive it. - Legacy two-field projects are folded on load (foldLegacyVariableAliases). - Location picker allows a manual %addr even when an alias occupies that address; the cell renders location verbatim (alias name or %addr). - Warn (amber glyph + Radix tooltip) when an alias-bound location no longer resolves (orphaned) OR when a manual %addr collides with an alias in use by another project variable (duplicate-location risk). - Relax variableLocationValidation to accept alias-name locations. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 37 ++- src/backend/shared/types/PLC/open-plc.ts | 16 +- .../__tests__/parse-project-files.test.ts | 62 ++++ .../shared/utils/parse-project-files.ts | 39 ++- .../_atoms/location-warning-glyph/index.tsx | 36 +++ .../editor/device/configuration/board.tsx | 13 +- .../components/pin-mapping-table.tsx | 18 +- .../vendor-screen/layouts/io-table-layout.tsx | 11 +- .../layouts/module-slots-layout.tsx | 22 +- .../ethercat/ethercat-device-editor.tsx | 7 +- .../global-variables-table/editable-cell.tsx | 78 +++-- .../variables-table/editable-cell.tsx | 108 +++---- .../global-variables-editor/index.tsx | 13 +- .../_organisms/variables-editor/index.tsx | 15 +- .../workspace-activity-bar/default.tsx | 18 +- .../hooks/use-project-alias-bindings.ts | 66 ++++ .../store/__tests__/project-slice.test.ts | 188 ++++++++++- .../project-validation-variables.test.ts | 39 ++- .../sync-variable-aliases-action.test.ts | 300 ------------------ src/frontend/store/slices/device/slice.ts | 27 +- src/frontend/store/slices/project/slice.ts | 291 +++++------------ src/frontend/store/slices/project/types.ts | 55 ++-- .../slices/project/validation/variables.ts | 8 +- .../location-dropdown-options.test.ts | 17 +- .../__tests__/remote-device-options.test.ts | 57 +++- .../utils/location-dropdown-options.ts | 8 +- src/frontend/utils/remote-device-options.ts | 13 +- src/middleware/shared/ports/types.ts | 10 +- .../__tests__/alias-registry.test.ts | 47 +-- .../__tests__/sync-variable-aliases.test.ts | 213 ------------- .../utils/iec-address/alias-registry.ts | 34 +- .../shared/utils/iec-address/index.ts | 8 - .../iec-address/sync-variable-aliases.ts | 123 ------- 33 files changed, 795 insertions(+), 1202 deletions(-) create mode 100644 src/frontend/components/_atoms/location-warning-glyph/index.tsx create mode 100644 src/frontend/hooks/use-project-alias-bindings.ts delete mode 100644 src/frontend/store/__tests__/sync-variable-aliases-action.test.ts delete mode 100644 src/middleware/shared/utils/iec-address/__tests__/sync-variable-aliases.test.ts delete mode 100644 src/middleware/shared/utils/iec-address/sync-variable-aliases.ts diff --git a/CLAUDE.md b/CLAUDE.md index 78a71638d..42e2d500d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -304,20 +304,29 @@ openplc-web). Pure functions, no IPC, no electron coupling. replaces the old `generateIecAddress` helper. Pass `alsoUsed` for in-flight allocations within a batch. - **Alias registry** (`alias-registry.ts`): derived index on top of - the pool. `byAlias` / `byAddress` maps plus `duplicateAliases` - (first-wins). Pure function — rebuild on demand, cost is - O(producers). -- **Variable sync** (`sync-variable-aliases.ts`): pure function that - walks variables and either adopts (no alias bound but address - matches), refreshes (alias address moved), or orphans (alias gone). - Called from every producer-mutation site, target switch, - project load, and pre-compile via the - `projectActions.syncVariableAliases()` store action. - -The variable cell renders `alias ?? location` and shows an amber -warning glyph + tooltip when the alias is orphaned. Aliases are -intended to be unique system-wide; the registry's -`isAliasNameAvailable(name, ignoring?)` is the system-wide validator. + the pool. `byAlias` map plus `duplicateAliases` (first-wins). Pure + function — rebuild on demand, cost is O(producers). +- **Compile-time resolution** (`registry/resolve.ts`): a variable's + `location` holds EITHER an alias name OR a literal `%addr` + (single-field model). `buildAliasIndex(registry)` builds the + `alias → address` map; `resolveLocation(field, index)` resolves a + variable's `location` for the compiler: + - literal `%…` → used verbatim (manual locations honoured exactly); + - alias that still exists → its current address; + - alias that is gone → `''` (variable becomes unlocated). + The compiler/runtime never see aliases: the editor resolves them in + a pre-compile snapshot via the + `projectActions.getCompileReadyProjectData()` store action. When a + producer alias is renamed, `projectActions.renameAlias(old, new)` + cascades onto every bound variable's `location`. + +The variable cell renders `location` verbatim — the alias name when +alias-bound, the `%addr` when a manual literal — and shows an amber +warning glyph + tooltip when an alias-bound location no longer resolves +(orphaned). Aliases are intended to be unique system-wide; every +IO-mapping / pin / remote-device editor calls the registry's +`validateAliasEdit(registry, name, ignoring)` gate before persisting a +new alias. ## Environment diff --git a/src/backend/shared/types/PLC/open-plc.ts b/src/backend/shared/types/PLC/open-plc.ts index 15884479b..4a5fa1b57 100644 --- a/src/backend/shared/types/PLC/open-plc.ts +++ b/src/backend/shared/types/PLC/open-plc.ts @@ -140,15 +140,15 @@ const PLCVariableSchema = z.object({ value: z.string(), }), ]), + /** + * The variable's binding. Single-field model: holds EITHER an alias name + * (bound to a producer channel) OR a literal IEC address the user typed + * (`%QX0.0`). Empty = unlocated. Alias→address resolution happens at + * compile time; a manual literal is honoured verbatim. Legacy projects + * that carried a separate `alias` field are migrated on load (the alias + * name is folded into `location`). + */ location: z.string(), - /** Stable alias name the variable is bound to, when present. Looked - * up in the alias registry to refresh `location` whenever the - * underlying producer reassigns the address. Variable cells show - * `alias` when set, falling back to the raw `location` otherwise. - * When the alias goes missing from the registry, the variable is - * "orphaned" — last-known `location` is kept, the cell flags it - * for the user. */ - alias: z.string().optional(), initialValue: z.string().or(z.null()).optional(), documentation: z.string(), debug: z.boolean().optional(), 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 1926d91de..c93a423f8 100644 --- a/src/backend/shared/utils/__tests__/parse-project-files.test.ts +++ b/src/backend/shared/utils/__tests__/parse-project-files.test.ts @@ -73,6 +73,68 @@ describe('parseProjectFiles — basic', () => { }) }) +// --------------------------------------------------------------------------- +// Legacy alias → single-field location migration (foldLegacyVariableAliases) +// --------------------------------------------------------------------------- + +describe('parseProjectFiles — legacy alias migration', () => { + it('folds a JSON POU variable with a non-empty alias into its location', () => { + // Old two-field model: an alias-bound variable stored the resolved + // %address in `location` and the alias name in `alias`. The single- + // field model keeps only the alias name in `location`. + const legacyPou = JSON.stringify({ + type: 'program', + data: { + name: 'Legacy', + variables: [ + { + name: 'sensor', + class: 'local', + type: { definition: 'base-type', value: 'BOOL' }, + location: '%IX0.0', + alias: 'flow_sensor', + documentation: '', + }, + ], + body: { language: 'st', value: '' }, + documentation: '', + }, + }) + const pouFiles: RawProjectFile[] = [{ relativePath: 'pous/programs/Legacy.json', content: legacyPou }] + const result = parseProjectFiles('/p', makeProjectJson(), makeDeviceConfig(), makePinMapping(), pouFiles, [], []) + const variable = result.projectData.pous[0].interface?.variables[0] + // Alias name folded into location; the `alias` field is gone. + expect(variable?.location).toBe('flow_sensor') + expect('alias' in (variable as unknown as Record)).toBe(false) + }) + + it('leaves a manual (empty-alias) JSON POU variable location untouched', () => { + const legacyPou = JSON.stringify({ + type: 'program', + data: { + name: 'Manual', + variables: [ + { + name: 'coil', + class: 'local', + type: { definition: 'base-type', value: 'BOOL' }, + location: '%QX0.0', + alias: '', + documentation: '', + }, + ], + body: { language: 'st', value: '' }, + documentation: '', + }, + }) + const pouFiles: RawProjectFile[] = [{ relativePath: 'pous/programs/Manual.json', content: legacyPou }] + const result = parseProjectFiles('/p', makeProjectJson(), makeDeviceConfig(), makePinMapping(), pouFiles, [], []) + const variable = result.projectData.pous[0].interface?.variables[0] + // Empty alias is not a binding — the manual %address is preserved. + expect(variable?.location).toBe('%QX0.0') + }) +}) + // --------------------------------------------------------------------------- // Line 79 — detectPouTypeFromPath: function-block and function detection // --------------------------------------------------------------------------- diff --git a/src/backend/shared/utils/parse-project-files.ts b/src/backend/shared/utils/parse-project-files.ts index 494923d67..646885ec5 100644 --- a/src/backend/shared/utils/parse-project-files.ts +++ b/src/backend/shared/utils/parse-project-files.ts @@ -222,6 +222,41 @@ function createFallbackPou(content: string, language: string, pouType: string, p * On parse failure, falls back to createFallbackPou which preserves * documentation, raw variable text, and body content. */ +/** + * Migrate legacy variables from the two-field (`location` + `alias`) model to + * the single-field model, where `location` holds the binding itself — the + * alias name for an alias-bound variable, a literal `%addr` for a manual one. + * + * Any object that carries BOTH a string `location` and a non-empty string + * `alias` is a legacy alias-bound PLCVariable: its alias name is folded into + * `location` and the `alias` field dropped. Objects with `alias` but no + * `location` (producer channels: pins, VPP entries, Modbus points, EtherCAT + * mappings) keep their alias untouched. Manual variables (empty alias) keep + * their literal `location`. + * + * Generic, idempotent deep walk: projects already in the single-field form + * (no `alias` on variables) pass through unchanged, so it is safe to run on + * every load. + */ +function foldLegacyVariableAliases(value: unknown): unknown { + if (Array.isArray(value)) return value.map(foldLegacyVariableAliases) + if (value && typeof value === 'object') { + const obj = value as Record + const isLegacyAliasBound = typeof obj.location === 'string' && typeof obj.alias === 'string' && obj.alias.length > 0 + const out: Record = {} + for (const [key, child] of Object.entries(obj)) { + if (isLegacyAliasBound && key === 'alias') continue // fold away + if (isLegacyAliasBound && key === 'location') { + out.location = obj.alias as string + continue + } + out[key] = foldLegacyVariableAliases(child) + } + return out + } + return value +} + function parsePouFile(file: RawProjectFile): (PLCPou & { variablesText?: string }) | null { const ext = file.relativePath.split('.').pop()?.toLowerCase() /* istanbul ignore if -- defensive: parseProjectFiles upstream only forwards files whose @@ -233,7 +268,7 @@ function parsePouFile(file: RawProjectFile): (PLCPou & { variablesText?: string // Legacy JSON format if (ext === 'json') { try { - const parsed = JSON.parse(file.content) as unknown + const parsed = foldLegacyVariableAliases(JSON.parse(file.content)) // JSON POUs may be in the old discriminated union format: { type, data } if (parsed && typeof parsed === 'object' && 'type' in parsed && 'data' in parsed) { const ipcPou = parsed as { type: string; data: Record } @@ -352,7 +387,7 @@ export function parseProjectFiles( // Parse and Zod-validate project.json (matches old backend safeParseProjectFile behavior) let project: { meta?: { name?: string; type?: string }; data?: Record } try { - const raw = projectJson ? (JSON.parse(projectJson) as unknown) : null + const raw = projectJson ? foldLegacyVariableAliases(JSON.parse(projectJson)) : null if (raw) { const result = PLCProjectSchema.safeParse(raw) if (result.success) { diff --git a/src/frontend/components/_atoms/location-warning-glyph/index.tsx b/src/frontend/components/_atoms/location-warning-glyph/index.tsx new file mode 100644 index 000000000..81ffe0d68 --- /dev/null +++ b/src/frontend/components/_atoms/location-warning-glyph/index.tsx @@ -0,0 +1,36 @@ +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../tooltip' + +/** + * Amber warning triangle shown in a variable's location cell, with an + * explanatory tooltip rendered through the app's shared Radix `Tooltip` + * (same look as the sidebar / HAL help-icon tooltips). Used for both the + * orphaned-alias and the manual-address-conflict warnings. + * + * `pointer-events-auto` re-enables hover on the glyph: the display-mode cell + * that hosts it is `pointer-events-none`, so the trigger would otherwise never + * see the pointer. + */ +function LocationWarningGlyph({ label, tooltip }: { label: string; tooltip: string }) { + return ( + + + + + + + + + {tooltip} + + + + ) +} + +export { LocationWarningGlyph } 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 7f49abeb9..193e09453 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -173,15 +173,10 @@ const Board = memo(function () { handleDeviceValueAtFirstRender() }, []) - // Sync alias-bound variables whenever the target changes. Producers - // gate by capability, so switching boards activates / deactivates - // entire I/O sources and the variables bound to their aliases may - // need to refresh or orphan. The effect fires after setDeviceBoard - // commits, regardless of which code path triggered the switch - // (regular pick, Python-warning confirm, V4-features-warning confirm). - useEffect(() => { - useOpenPLCStore.getState().projectActions.syncVariableAliases() - }, [deviceBoard]) + // A target switch no longer needs to touch program variables: their + // `location` holds a stable alias name (resolved at compile) or a literal + // address — neither changes with the board. Producer address recompaction + // for the new target is handled by `setDeviceBoard → recalculateIecAddresses`. useEffect(() => { scrollToSelectedOption(deviceSelectRef, deviceSelectIsOpen) diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/components/pin-mapping-table.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/components/pin-mapping-table.tsx index bcdb5b45e..3206b61a9 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/components/pin-mapping-table.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/components/pin-mapping-table.tsx @@ -85,10 +85,10 @@ const PinMappingTable = ({ pins, selectedRowId, handleRowClick }: PinMappingTabl return { ok: false, title: 'Alias already in use', message: 'Pin alias collides with another producer.' } } - // Phase 2 — cascade rename onto bound variables BEFORE - // mutating the pin, so the subsequent `syncVariableAliases()` - // sees variables pointing at the new alias and refreshes - // locations rather than orphaning them. + // Cascade the rename onto bound variables: any variable whose + // `location` holds the old alias name follows to the new one, so it + // stays located (resolved to the address at compile time) rather than + // orphaning. const oldAlias = currentPin?.alias ?? '' if (oldAlias) { useOpenPLCStore.getState().projectActions.renameAlias(oldAlias, value) @@ -98,12 +98,10 @@ const PinMappingTable = ({ pins, selectedRowId, handleRowClick }: PinMappingTabl const res = updatePin({ [columnId as keyof DevicePin]: value, }) - // Pin alias / address edits are producer mutations — refresh - // variables bound to the affected addresses so the table - // reflects the new bindings without a save/reload. - if (res?.ok && (columnId === 'alias' || columnId === 'address')) { - useOpenPLCStore.getState().projectActions.syncVariableAliases() - } + // No variable re-sync needed: variables bound to this pin hold its alias + // NAME (resolved at compile time), and the rename above already cascaded + // to them via `renameAlias`. Manual literal locations are honoured + // verbatim and are the user's responsibility. return res } diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/io-table-layout.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/io-table-layout.tsx index 6535f8420..eb741df5d 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/io-table-layout.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/io-table-layout.tsx @@ -187,10 +187,9 @@ function IoTableLayout({ section, moduleSystem }: IoTableLayoutProps) { return } - // Phase 2 — cascade rename onto bound variables BEFORE writing - // so the subsequent `syncVariableAliases()` sees variables - // pointing at the new alias and takes the refresh path instead - // of orphan. + // Cascade the rename onto bound variables: any variable whose + // `location` holds the old alias name follows to the new one, so it + // stays located (resolved at compile time) rather than orphaning. const oldAlias = target.alias ?? '' if (oldAlias) { useOpenPLCStore.getState().projectActions.renameAlias(oldAlias, alias) @@ -205,8 +204,8 @@ function IoTableLayout({ section, moduleSystem }: IoTableLayoutProps) { useOpenPLCStore .getState() .projectActions.rememberChannelAlias(vppMemoryKey(target.moduleId ?? '', target.slot, target.channelName), alias) - // Refresh variables against any allocator-driven address shifts. - useOpenPLCStore.getState().projectActions.syncVariableAliases() + // Variables bound to this channel hold its alias NAME (resolved at + // compile); the `renameAlias` above already cascaded any rename to them. } const groups = useMemo(() => { diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx index 314a1e9e0..ab1759ef6 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx @@ -647,10 +647,10 @@ function ModuleSlotsLayout({ section, moduleSystem }: ModuleSlotsLayoutProps) { // from the live state (including the entry being edited, scoped // to the active board's capabilities) and reject the edit if the // new alias is already claimed by a different channel. Without - // this gate, the pool's silent first-wins reservation would - // cause every variable that the user later binds to the losing - // entry to collapse to the winner's address through - // `syncVariableAliases`'s refresh path. + // this gate, the pool's silent first-wins reservation would make + // the losing entry's alias unresolvable, so every variable the + // user later binds to it would silently become unlocated at + // compile time. const boardInfo = state.deviceAvailableOptions.availableBoards.get( state.deviceDefinitions.configuration.deviceBoard ?? '', ) @@ -676,11 +676,10 @@ function ModuleSlotsLayout({ section, moduleSystem }: ModuleSlotsLayoutProps) { return } - // Phase 2 — cascade rename onto bound variables BEFORE writing - // the new entries, so the subsequent `syncVariableAliases()` - // call sees variables already pointing at the new alias name and - // takes the refresh path (location follows alias) instead of the - // orphan path (location cleared, warning glyph rendered). + // Cascade the rename onto bound variables: any variable whose + // `location` holds the old alias name follows to the new one + // (location follows alias), keeping it located instead of orphaning + // it (location cleared, warning glyph rendered). const targetEntry = currentEntries.find((e) => e.slot === slot && e.channelName === channelName) const oldAlias = targetEntry?.alias ?? '' if (oldAlias) { @@ -694,9 +693,8 @@ function ModuleSlotsLayout({ section, moduleSystem }: ModuleSlotsLayoutProps) { useOpenPLCStore .getState() .projectActions.rememberChannelAlias(vppMemoryKey(targetEntry?.moduleId ?? '', slot, channelName), alias) - // Refresh variables bound to the (now-renamed) alias against - // any address shifts produced by the change. - useOpenPLCStore.getState().projectActions.syncVariableAliases() + // Variables bound to this channel hold its alias NAME (resolved at + // compile); the `renameAlias` above already cascaded any rename to them. } /** 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 36f60b4d4..2877a856e 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 @@ -145,12 +145,9 @@ const EtherCATDeviceEditor = ({ busName: propBusName, deviceId: propDeviceId }: const syncDevicesToStore = useCallback( (devices: ConfiguredEtherCATDevice[]) => { + // `updateEthercatConfig` reallocates addresses through the central + // registry and cascades any alias rename onto bound variables' names. projectActions.updateEthercatConfig(busName, { masterConfig, devices }) - // Producer mutation: any change to channelMappings or aliases - // may move addresses or attach/detach aliases. Refresh the - // variables bound to those aliases so the table reflects the - // new bindings without waiting for save/reload. - projectActions.syncVariableAliases() // Mark the slave file dirty (same pattern as other file types) const { sharedWorkspaceActions } = useOpenPLCStore.getState() if (deviceName) { diff --git a/src/frontend/components/_molecules/global-variables-table/editable-cell.tsx b/src/frontend/components/_molecules/global-variables-table/editable-cell.tsx index f25ee7761..c852da748 100644 --- a/src/frontend/components/_molecules/global-variables-table/editable-cell.tsx +++ b/src/frontend/components/_molecules/global-variables-table/editable-cell.tsx @@ -1,5 +1,7 @@ import * as PrimitivePopover from '@radix-ui/react-popover' import { useAliasRegistry } from '@root/frontend/hooks/use-alias-registry' +import { useProjectAliasBindings } from '@root/frontend/hooks/use-project-alias-bindings' +import { isLiteralLocation } from '@root/middleware/shared/utils/iec-address/registry' import type { CellContext, RowData } from '@tanstack/react-table' import { useCallback, useEffect, useRef, useState } from 'react' @@ -18,6 +20,7 @@ import { import { GenericComboboxCell } from '../../_atoms/generic-table-inputs/generic-combobox-cell' import { HighlightedText } from '../../_atoms/highlighted-text' import { InputWithRef } from '../../_atoms/input' +import { LocationWarningGlyph } from '../../_atoms/location-warning-glyph' import { useToast } from '../../_features/[app]/toast/use-toast' import { RenameImpactModal } from '../rename-impact-modal' @@ -291,25 +294,35 @@ const EditableLocationCell = ({ const [cellValue, setCellValue] = useState(initialValue ?? '') - // Alias staleness checks. See the local variables-table cell - // (`_molecules/variables-table/editable-cell.tsx`) for the longer - // explanation — same two flavours of staleness, same exception - // semantics on the location-column short-circuit. - const variableAlias = original?.alias - const variableLocation = original?.location ?? '' + // Orphan check for the single-field location model — see the local + // variables-table cell for the full explanation. `location` is orphaned + // when it holds an alias NAME no active producer declares (unlocated at + // compile). Scoped to the location column. const aliasRegistry = useAliasRegistry() - const isOrphaned = !!variableAlias && !aliasRegistry.byAlias.has(variableAlias) - const isMismatched = - !!variableAlias && aliasRegistry.byAlias.has(variableAlias) - ? aliasRegistry.byAlias.get(variableAlias)?.address !== variableLocation - : false + const isLocationCell = id === 'location' + const locationValue = original?.location ?? '' + const isOrphaned = + isLocationCell && + locationValue.length > 0 && + !isLiteralLocation(locationValue) && + !aliasRegistry.byAlias.has(locationValue) + // Manual-location conflict: a literal `%addr` that collides with an alias a + // variable elsewhere is bound to. IEC located addresses are GLOBAL, so the + // conflicting variable can live in any POU or the global scope — the scan is + // project-wide. The alias resolves to the same `%addr` at compile time, so + // the two variables would occupy one location, which the compiler rejects. + // Clearing the other variable's binding clears this warning. + const aliasBindings = useProjectAliasBindings() + const locationConflict = + isLocationCell && isLiteralLocation(locationValue) + ? aliasBindings.find((binding) => binding.address === locationValue) + : undefined + const isManualConflict = locationConflict !== undefined + const hasLocationWarning = isOrphaned || isManualConflict const onBlur = (value: string) => { - // Short-circuit unchanged-value blurs unless the variable's alias - // is orphaned or mismatched (alias points at a different address - // than the variable's location) — in either case re-picking the - // same address is the user's signal to refresh the alias. - if (value === initialValue && !(isOrphaned || isMismatched)) return + // Short-circuit unchanged-value blurs. + if (value === initialValue) return const res = table.options.meta?.updateData(index, id, value) if (res?.ok) { setCellValue(value) @@ -368,19 +381,18 @@ const EditableLocationCell = ({ ] }, [id, existingPins, remoteIOPoints, vendorIoEntries]) - // Combined display: "alias (address)" stays consistent across - // editable and read-only states so the cell doesn't flip on row - // select. `variableAlias` + `isOrphaned` are defined above the - // `onBlur` so the short-circuit can read them. - const orphanTooltip = isOrphaned - ? `Alias "${variableAlias}" is no longer declared by any active source. Last known address: ${cellValue}` - : undefined - const combinedLabel = variableAlias ? `${variableAlias} (${cellValue})` : cellValue + // Single-field display: show `location` verbatim (alias name when bound, + // literal address when manual). + const warningTooltip = isOrphaned + ? `Alias "${cellValue}" is not declared by any active I/O source — this variable is unlocated at compile time.` + : locationConflict + ? `Address ${cellValue} conflicts with alias "${locationConflict.aliasName}" assigned to "${locationConflict.variableName}". Two variables cannot share a location.` + : undefined return editable ? ( { onBlur(value) }} @@ -391,7 +403,6 @@ const EditableLocationCell = ({ /> ) : (
${cellValue}` : undefined)} className={cn( 'flex w-full flex-1 items-center justify-center gap-1 bg-transparent p-2 text-center outline-none', { @@ -399,18 +410,17 @@ const EditableLocationCell = ({ }, )} > - {isOrphaned && ( - - - + {hasLocationWarning && warningTooltip && ( + )}
diff --git a/src/frontend/components/_molecules/variables-table/editable-cell.tsx b/src/frontend/components/_molecules/variables-table/editable-cell.tsx index 2f3a356e5..0bbdf6e8c 100644 --- a/src/frontend/components/_molecules/variables-table/editable-cell.tsx +++ b/src/frontend/components/_molecules/variables-table/editable-cell.tsx @@ -1,6 +1,8 @@ import * as PrimitivePopover from '@radix-ui/react-popover' import { useAliasRegistry } from '@root/frontend/hooks/use-alias-registry' +import { useProjectAliasBindings } from '@root/frontend/hooks/use-project-alias-bindings' import { useTargetCapabilities } from '@root/frontend/hooks/use-target-capabilities' +import { isLiteralLocation } from '@root/middleware/shared/utils/iec-address/registry' import type { CellContext, RowData } from '@tanstack/react-table' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -19,6 +21,7 @@ import { import { GenericComboboxCell } from '../../_atoms/generic-table-inputs/generic-combobox-cell' import { HighlightedText } from '../../_atoms/highlighted-text' import { InputWithRef } from '../../_atoms/input' +import { LocationWarningGlyph } from '../../_atoms/location-warning-glyph' import { useToast } from '../../_features/[app]/toast/use-toast' import { RenameImpactModal } from '../rename-impact-modal' @@ -471,37 +474,42 @@ const EditableLocationCell = ({ const isEditable = useCallback(isCellEditable, [id, variable, isDebuggerVisible]) - // Alias staleness checks. Lifted above `onBlur` so the short- - // circuit can take them into account. Two flavours of staleness - // both demand that the location-pick re-fires `updateVariable` - // even when the picked value matches the cell's current address: - // - // - `isOrphaned`: the variable's stored alias is no longer in the - // registry's `byAlias` (producer renamed/removed it). - // Re-picking the same address re-runs the auto-adopt and - // refreshes the alias against the live registry. - // - `isMismatched`: the variable carries an alias that points at - // a *different* address than its current location. Happens when - // legacy projects authored before `createVariable`'s auto-adopt - // fix carried a stale alias from one row to the next through the - // "+ button" template spread. Without this exception, clicking - // on the right alias for the current address would no-op because - // the address itself isn't changing — the user would have to - // pick a DIFFERENT alias first, then return to the desired one, - // to force the alias write through. + // Orphan check for the single-field location model. `location` is the + // binding: a literal `%addr` (manual) OR an alias name. It is "orphaned" + // when it holds an alias NAME that no active producer declares — the + // variable resolves to nothing (unlocated) at compile time, so we flag it. + // A literal address is never orphaned; an empty location is simply + // unlocated (no warning). `isLocationCell` scopes the check to the + // location column (the same cell renders name/type/etc.). const aliasRegistry = useAliasRegistry() - const isOrphaned = !!variable?.alias && !aliasRegistry.byAlias.has(variable.alias) - const isMismatched = - !!variable?.alias && aliasRegistry.byAlias.has(variable.alias) - ? aliasRegistry.byAlias.get(variable.alias)?.address !== variable.location - : false + const isLocationCell = id === 'location' + const locationValue = variable?.location ?? '' + const isOrphaned = + isLocationCell && + locationValue.length > 0 && + !isLiteralLocation(locationValue) && + !aliasRegistry.byAlias.has(locationValue) + // Manual-location conflict: a literal `%addr` that collides with an alias a + // variable elsewhere is bound to. IEC located addresses are GLOBAL, so the + // conflicting variable can live in any POU or the global scope — the scan + // is project-wide. The alias resolves to the same `%addr` at compile time, + // so the two variables would occupy one location, which the compiler + // rejects. Clearing the other variable's binding clears this warning. + // Flagged with the same glyph as an orphaned alias; we flag the manual + // entry, not the aliased one (the alias is the canonical binding). + const aliasBindings = useProjectAliasBindings() + const locationConflict = + isLocationCell && isLiteralLocation(locationValue) + ? aliasBindings.find((binding) => binding.address === locationValue) + : undefined + const isManualConflict = locationConflict !== undefined + const hasLocationWarning = isOrphaned || isManualConflict // When the input is blurred, we'll call our table meta's updateData function const onBlur = (value: string) => { // Short-circuit unchanged-value blurs so re-focus doesn't fire a - // gratuitous state update. Exceptions for the location column, - // see the `isOrphaned` / `isMismatched` block above. - if (value === initialValue && !(id === 'location' && (isOrphaned || isMismatched))) return + // gratuitous state update. + if (value === initialValue) return const res = table.options.meta?.updateData(index, id, value) if (res?.ok) { setCellValue(value) @@ -538,21 +546,20 @@ const EditableLocationCell = ({ [id, existingPins, remoteIOPoints, vendorIoEntries, capabilities], ) - // Combined display: when the variable carries an alias, show it - // alongside the raw address as "alias (address)" — same shape - // across selected and unselected states so clicking a row doesn't - // flip the cell's appearance. When there's no alias, only the - // address shows. The combobox `value` stays as the raw address so - // typing / picking still operates on the canonical form. - const orphanTooltip = isOrphaned - ? `Alias "${variable?.alias}" is no longer declared by any active source. Last known address: ${cellValue}` - : undefined - const combinedLabel = variable?.alias ? `${variable.alias} (${cellValue})` : cellValue + // Single-field display: `location` is shown verbatim — the alias name when + // the variable is alias-bound, the literal address when manual. The + // combobox `value` is the same string, so picking an alias option (whose + // value is the alias name) or typing a literal both operate on `location`. + const warningTooltip = isOrphaned + ? `Alias "${cellValue}" is not declared by any active I/O source — this variable is unlocated at compile time.` + : locationConflict + ? `Address ${cellValue} conflicts with alias "${locationConflict.aliasName}" assigned to "${locationConflict.variableName}". Two variables cannot share a location.` + : undefined return selected ? ( { onBlur(value) }} @@ -560,14 +567,12 @@ const EditableLocationCell = ({ selected={selected} openOnSelectedOption canAddACustomOption - // Orphaned/mismatched aliases blank the location (sync clears it for - // compilation) but leave a stale alias label. Keep "Clear" enabled even - // when the location is empty so the user can drop the stale alias. - allowClearWhenEmpty={id === 'location' && (isOrphaned || isMismatched)} + // An orphaned alias name resolves to nothing at compile; keep "Clear" + // enabled even when empty so the user can drop it. + allowClearWhenEmpty={id === 'location' && isOrphaned} /> ) : (
${cellValue}` : undefined)} className={cn( 'flex w-full flex-1 items-center justify-center gap-1 bg-transparent p-2 text-center outline-none', { @@ -576,22 +581,17 @@ const EditableLocationCell = ({ }, )} > - {isOrphaned && ( - - - + {hasLocationWarning && warningTooltip && ( + )}
diff --git a/src/frontend/components/_organisms/global-variables-editor/index.tsx b/src/frontend/components/_organisms/global-variables-editor/index.tsx index ffc828e19..5a587c094 100644 --- a/src/frontend/components/_organisms/global-variables-editor/index.tsx +++ b/src/frontend/components/_organisms/global-variables-editor/index.tsx @@ -236,11 +236,14 @@ const GlobalVariablesEditor = () => { selectedRow === ROWS_NOT_SELECTED ? variables[variables.length - 1] : variables[selectedRow] ) as PLCGlobalVariable - // Don't carry the previous variable's `alias` into the new row. - // See variables-editor/index.tsx for the longer explanation — - // tl;dr: `createVariable` auto-increments `location`, and keeping - // the stale alias would break the alias-↔-location invariant. - const newVarData = { ...variable, alias: undefined, documentation: '' } + // Single-field location: carry a MANUAL literal address forward (it gets + // auto-incremented for a fresh row); an alias binding starts empty so we + // don't point two variables at the same address. See variables-editor. + const newVarData = { + ...variable, + location: variable.location.startsWith('%') ? variable.location : '', + documentation: '', + } if (selectedRow === ROWS_NOT_SELECTED) { createVariable({ scope: 'global', data: newVarData }) diff --git a/src/frontend/components/_organisms/variables-editor/index.tsx b/src/frontend/components/_organisms/variables-editor/index.tsx index 33a713a52..9b2b70ea8 100644 --- a/src/frontend/components/_organisms/variables-editor/index.tsx +++ b/src/frontend/components/_organisms/variables-editor/index.tsx @@ -424,17 +424,14 @@ const VariablesEditor = ({ name: propName, isActive: _isActive = true }: Variabl const variable: PLCVariable = selectedRow === ROWS_NOT_SELECTED ? variables[variables.length - 1] : variables[selectedRow] - // Don't carry the previous variable's `alias` into the new row. - // The slice's `createVariable` auto-increments `location`; if we - // kept the old alias attached, the new variable would claim the - // OLD channel's alias while pointing at the NEW address — - // breaking the alias-↔-location invariant. `createVariable`'s - // auto-adopt path resolves the right alias for the new location - // against the live registry (matching whichever producer-channel - // owns the auto-incremented address). + // Single-field location: only carry a MANUAL literal address forward + // (createVariable auto-increments it for a fresh contiguous row). An + // alias binding must NOT be duplicated onto the new row — that would + // point two variables at the same address and fail compile — so an + // alias-name location starts empty (unlocated) instead. const newVarData = { ...variable, - alias: undefined, + location: variable.location.startsWith('%') ? variable.location : '', class: defaultClass, type: variable.type.definition === 'derived' diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index 8cdfb9448..0f48bd424 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -235,13 +235,12 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa addLog({ id: crypto.randomUUID(), level: 'info', message: 'Build process started' }) - // Pre-compile alias sync: ensure every located variable's - // `location` reflects the latest address its alias points to, - // before we snapshot projectData for the compiler. The compile - // pipeline itself reads `variable.location` verbatim — same - // contract as before, just guaranteed-fresh now. - useOpenPLCStore.getState().projectActions.syncVariableAliases() - const freshProjectData = useOpenPLCStore.getState().project.data + // Compile-time alias resolution: snapshot the project with every + // variable's `location` resolved to a concrete IEC address (alias name + // → current address, literal → verbatim, missing → unlocated). The + // compile pipeline reads `variable.location` verbatim — it never sees + // aliases. + const freshProjectData = useOpenPLCStore.getState().projectActions.getCompileReadyProjectData() try { // Track whether the compile stream already surfaced an error so we @@ -595,9 +594,8 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa if (response === 0) { const runtimeIpAddress = deviceDefinitions.configuration.runtimeIpAddress || null const runtimeJwtToken = useOpenPLCStore.getState().runtimeConnection.jwtToken || null - // See the handleBuild call above — same pre-compile sync pass. - useOpenPLCStore.getState().projectActions.syncVariableAliases() - const freshProjectData = useOpenPLCStore.getState().project.data + // See the handleBuild call above — compile-time alias resolution. + const freshProjectData = useOpenPLCStore.getState().projectActions.getCompileReadyProjectData() const compileResult = await compiler.compileProgram( { projectData: freshProjectData, diff --git a/src/frontend/hooks/use-project-alias-bindings.ts b/src/frontend/hooks/use-project-alias-bindings.ts new file mode 100644 index 000000000..08a72c53c --- /dev/null +++ b/src/frontend/hooks/use-project-alias-bindings.ts @@ -0,0 +1,66 @@ +/** + * Selector hook returning every project variable that is bound to an alias + * which currently resolves to a concrete address, as + * `{ variableName, aliasName, address }` rows. + * + * IEC located addresses are GLOBAL, so a manually located variable can + * collide with an alias-bound variable in ANY POU or the global scope. The + * variable cell uses this list to flag a literal `%addr` that lands on an + * address an alias is already assigned to (see the manual-conflict warning in + * `variables-table` / `global-variables-table` editable cells). + * + * Backed by a module-level single-entry cache — every location cell calls + * this, and the scan is O(project variables); the cache keeps a table full of + * cells from re-scanning the whole project on each row. + */ + +import { useOpenPLCStore } from '@root/frontend/store' +import type { PLCPou, PLCVariable } from '@root/middleware/shared/ports/types' +import type { AliasRegistry } from '@root/middleware/shared/utils/iec-address' +import { isLiteralLocation } from '@root/middleware/shared/utils/iec-address/registry' + +import { useAliasRegistry } from './use-alias-registry' + +export interface AliasBinding { + /** Name of the variable bound to the alias. */ + variableName: string + /** The alias the variable's `location` holds. */ + aliasName: string + /** The address the alias currently resolves to. */ + address: string +} + +interface BindingsCache { + pous: PLCPou[] + globals: PLCVariable[] | undefined + registry: AliasRegistry + bindings: AliasBinding[] +} + +let cache: BindingsCache | null = null + +export function useProjectAliasBindings(): AliasBinding[] { + const pous = useOpenPLCStore((s) => s.project.data.pous) + const globals = useOpenPLCStore((s) => s.project.data.configurations.resource.globalVariables) + const registry = useAliasRegistry() + + if (cache && cache.pous === pous && cache.globals === globals && cache.registry === registry) { + return cache.bindings + } + + const bindings: AliasBinding[] = [] + const collect = (variables: PLCVariable[] | undefined): void => { + for (const variable of variables ?? []) { + // Only alias bindings resolve to an address here; literal `%…` locations + // and empty locations are not aliases. + if (!variable.location || isLiteralLocation(variable.location)) continue + const address = registry.byAlias.get(variable.location)?.address + if (address) bindings.push({ variableName: variable.name, aliasName: variable.location, address }) + } + } + for (const pou of pous) collect(pou.interface?.variables) + collect(globals) + + cache = { pous, globals, registry, bindings } + return bindings +} diff --git a/src/frontend/store/__tests__/project-slice.test.ts b/src/frontend/store/__tests__/project-slice.test.ts index 22981deb6..398db37ad 100644 --- a/src/frontend/store/__tests__/project-slice.test.ts +++ b/src/frontend/store/__tests__/project-slice.test.ts @@ -252,6 +252,28 @@ function seedRemoteDevice(store: ReturnType, device: PLCRemote }) } +/** Seed global variables directly (bypassing createVariable's dedup so the + * `location` binding is stored verbatim for alias-cascade / compile tests). */ +function seedGlobals(store: ReturnType, vars: PLCVariable[]) { + const current = store.getState().project + store.getState().projectActions.setProject({ + ...current, + data: { + ...current.data, + configurations: { + ...current.data.configurations, + resource: { ...current.data.configurations.resource, globalVariables: vars }, + }, + }, + }) +} + +/** A BOOL variable whose `location` is set verbatim — an alias name, a literal + * `%addr`, or empty — for the single-field location model. */ +function locVar(name: string, location: string, cls: PLCVariable['class'] = 'local'): PLCVariable { + return { name, class: cls, type: { definition: 'base-type', value: 'BOOL' }, location, documentation: '' } +} + /** Seed a Runtime v4 target so the cap-gated address pool counts * Modbus claims. Producer-edit actions (addIOGroup, updateIOGroup …) * rely on `caps.modbusTcpRemote = true` to count sibling groups when @@ -820,30 +842,30 @@ describe('createProjectSlice', () => { expect(result.ok).toBe(false) }) - it('clearing the location drops a stale alias', () => { - // Reproduces the orphaned-alias case: a variable that carries a - // location plus an alias no longer backed by any producer (target - // changed / device removed). Clearing the location (location: '') - // must succeed AND drop the stale alias. + 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 + // to an address, NOT auto-adopted from a matching address); clearing + // succeeds. store.getState().projectActions.createVariable({ scope: 'global', data: makeVariable('gx', 'global') }) + store.getState().projectActions.updateVariable({ scope: 'global', variableId: 'gx', data: { location: '%QW0' } }) - // Attach a stale alias (no producer declares it). + expect(store.getState().project.data.configurations.resource.globalVariables[0].location).toBe('%QW0') + + // Bind by alias name — kept verbatim. store .getState() - .projectActions.updateVariable({ scope: 'global', variableId: 'gx', data: { alias: 'StaleAlias' } }) - let v = store.getState().project.data.configurations.resource.globalVariables[0] - expect(v.location).toBe('%QW0') - expect(v.alias).toBe('StaleAlias') + .projectActions.updateVariable({ scope: 'global', variableId: 'gx', data: { location: 'push_button' } }) + expect(store.getState().project.data.configurations.resource.globalVariables[0].location).toBe('push_button') + // Clearing the location. const result = store.getState().projectActions.updateVariable({ scope: 'global', variableId: 'gx', data: { location: '' }, }) expect(result.ok).toBe(true) - v = store.getState().project.data.configurations.resource.globalVariables[0] - expect(v.location).toBe('') - expect(v.alias ?? '').toBe('') + expect(store.getState().project.data.configurations.resource.globalVariables[0].location).toBe('') }) }) @@ -2050,6 +2072,28 @@ describe('createProjectSlice', () => { const mappings = store.getState().project.data.remoteDevices![0].ethercatConfig!.devices[0].channelMappings expect(mappings.map((m) => m.iecLocation)).toEqual(['%IW0', '%IW1']) }) + + it('cascades a channel alias rename onto bound variable locations', () => { + seedRuntimeV4Board(store) + seedRemoteDevice(store, { name: 'BusA', protocol: 'ethercat' }) + const withAlias = (alias: string) => ({ + masterConfig: { networkInterface: 'eth0', cycleTimeUs: 1000, taskPriority: 50 }, + devices: [ + { + id: 's1', + name: 'Slave1', + channelMappings: [{ channelId: 'c0', iecLocation: '%IW0', alias }], + } as unknown as ConfiguredEtherCATDevice, + ], + }) + // First write establishes the channel alias `ec_in`. + store.getState().projectActions.updateEthercatConfig('BusA', withAlias('ec_in')) + // A program variable binds to that alias by name. + seedPou(store, makePou('Prog', 'program', [locVar('reading', 'ec_in')])) + // Rewriting the config with a renamed alias must cascade onto the binding. + store.getState().projectActions.updateEthercatConfig('BusA', withAlias('ec_input')) + expect(store.getState().project.data.pous[0].interface!.variables![0].location).toBe('ec_input') + }) }) describe('createRemoteDevice', () => { @@ -2681,6 +2725,124 @@ describe('createProjectSlice', () => { const result = store.getState().projectActions.updateIOPointAlias('EtherCAT', 'g1', 'p1', 'alias') expect(result.ok).toBe(true) }) + + it('rejects an alias that is already assigned to another channel', () => { + seedRemoteDevice(store, makeRemoteDevice('Dev1')) + store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g1', '3', 2)) + const points = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints! + // Claim "dup" on the first point, then try to reuse it on the second. + store.getState().projectActions.updateIOPointAlias('Dev1', 'g1', points[0].id, 'dup') + const result = store.getState().projectActions.updateIOPointAlias('Dev1', 'g1', points[1].id, 'dup') + expect(result.ok).toBe(false) + expect(result.title).toBe('Alias already in use') + // The rejected write never lands. + expect(store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints![1].alias).toBe('') + }) + + it('cascades a rename onto variables bound to the point’s previous alias', () => { + seedRemoteDevice(store, makeRemoteDevice('Dev1')) + store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g1', '3', 2)) + const pointId = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints![0].id + // First set an alias, then bind a program variable to it by name. + store.getState().projectActions.updateIOPointAlias('Dev1', 'g1', pointId, 'sensor_a') + seedPou(store, makePou('Prog', 'program', [locVar('reading', 'sensor_a')])) + // Renaming the point alias must follow the binding to the new name. + store.getState().projectActions.updateIOPointAlias('Dev1', 'g1', pointId, 'sensor_b') + expect(store.getState().project.data.pous[0].interface!.variables![0].location).toBe('sensor_b') + }) + }) + + describe('renameAlias', () => { + it('cascades onto bound POU and global variable locations, leaving manual literals untouched', () => { + seedPou(store, makePou('Prog', 'program', [locVar('bound', 'relay_1'), locVar('manual', '%QX0.0')])) + seedGlobals(store, [locVar('gBound', 'relay_1', 'global')]) + const result = store.getState().projectActions.renameAlias('relay_1', 'relay_2') + expect(result.renamed).toBe(2) + const vars = store.getState().project.data.pous[0].interface!.variables! + expect(vars[0].location).toBe('relay_2') // alias-bound → follows the rename + expect(vars[1].location).toBe('%QX0.0') // manual literal → untouched + expect(store.getState().project.data.configurations.resource.globalVariables![0].location).toBe('relay_2') + }) + + it('cascades a case-only change (the alias registry is case-sensitive)', () => { + seedPou(store, makePou('Prog', 'program', [locVar('bound', 'Relay')])) + const result = store.getState().projectActions.renameAlias('Relay', 'relay') + expect(result.renamed).toBe(1) + expect(store.getState().project.data.pous[0].interface!.variables![0].location).toBe('relay') + }) + + it('clears bound variable locations when the alias is renamed to empty', () => { + seedPou(store, makePou('Prog', 'program', [locVar('bound', 'relay_1')])) + const result = store.getState().projectActions.renameAlias('relay_1', '') + expect(result.renamed).toBe(1) + expect(store.getState().project.data.pous[0].interface!.variables![0].location).toBe('') + }) + + it('is a no-op when the old alias is empty', () => { + seedPou(store, makePou('Prog', 'program', [locVar('bound', 'relay_1')])) + const result = store.getState().projectActions.renameAlias('', 'relay_2') + expect(result.renamed).toBe(0) + expect(store.getState().project.data.pous[0].interface!.variables![0].location).toBe('relay_1') + }) + + it('is a no-op when the name is unchanged', () => { + seedPou(store, makePou('Prog', 'program', [locVar('bound', 'relay_1')])) + const result = store.getState().projectActions.renameAlias('relay_1', 'relay_1') + expect(result.renamed).toBe(0) + expect(store.getState().project.data.pous[0].interface!.variables![0].location).toBe('relay_1') + }) + }) + + describe('getCompileReadyProjectData', () => { + beforeEach(() => { + seedRuntimeV4Board(store) + }) + + it('resolves alias locations to addresses, honours manual literals, and drops missing aliases', () => { + seedRemoteDevice(store, makeRemoteDevice('Dev1')) + store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g1', '3', 2)) // %IW0, %IW1 + const pointId = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints![0].id + store.getState().projectActions.updateIOPointAlias('Dev1', 'g1', pointId, 'flow') // flow → %IW0 + + seedPou( + store, + makePou('Prog', 'program', [ + locVar('aliasBound', 'flow'), // → %IW0 + locVar('manual', '%QX0.0'), // → %QX0.0 (verbatim) + locVar('ghost', 'no_such_alias'), // → '' (alias gone) + locVar('unlocated', ''), // → '' + ]), + ) + // A POU whose interface carries no `variables` array — the resolver must + // skip it without throwing (early-return guard). + const current = store.getState().project + store.getState().projectActions.setProject({ + ...current, + data: { + ...current.data, + pous: [ + ...current.data.pous, + { + name: 'NoVars', + pouType: 'program', + interface: {}, + body: makeBody(), + documentation: '', + } as unknown as PLCPou, + ], + }, + }) + seedGlobals(store, [locVar('gFlow', 'flow', 'global')]) // → %IW0 + + const data = store.getState().projectActions.getCompileReadyProjectData() + const resolved = data.pous[0].interface!.variables! + expect(resolved.map((v) => v.location)).toEqual(['%IW0', '%QX0.0', '', '']) + expect(data.configurations.resource.globalVariables![0].location).toBe('%IW0') + + // The live store keeps the alias-name form — only the returned snapshot + // is resolved. + expect(store.getState().project.data.pous[0].interface!.variables![0].location).toBe('flow') + }) }) // ------------------------------------------------------------------------- diff --git a/src/frontend/store/__tests__/project-validation-variables.test.ts b/src/frontend/store/__tests__/project-validation-variables.test.ts index 090f9d3a9..a218a2577 100644 --- a/src/frontend/store/__tests__/project-validation-variables.test.ts +++ b/src/frontend/store/__tests__/project-validation-variables.test.ts @@ -491,77 +491,96 @@ describe('updateVariableValidation', () => { }) // -- Location error messages for specific types -- + // A literal `%` address of the wrong width triggers the type-specific + // hint. (A NON-`%` string is now treated as an alias name and accepted; + // see the "accepts a non-% location as an alias name" cases below.) it('returns BOOL location hint', () => { const boolVar = makeVariable('Test', 'BOOL', '') - const result = updateVariableValidation([], { location: 'INVALID' }, boolVar) + const result = updateVariableValidation([], { location: '%MD0' }, boolVar) expect(result.ok).toBe(false) expect(result.message).toContain('%QX') }) it('returns WORD location hint for INT type', () => { const intVar = makeVariable('Test', 'INT', '') - const result = updateVariableValidation([], { location: 'INVALID' }, intVar) + const result = updateVariableValidation([], { location: '%QX0.0' }, intVar) expect(result.ok).toBe(false) expect(result.message).toContain('%QW') }) it('returns WORD location hint for UINT type', () => { const uintVar = makeVariable('Test', 'UINT', '') - const result = updateVariableValidation([], { location: 'INVALID' }, uintVar) + const result = updateVariableValidation([], { location: '%QX0.0' }, uintVar) expect(result.ok).toBe(false) expect(result.message).toContain('%IW') }) it('returns DWORD location hint for DINT type', () => { const dintVar = makeVariable('Test', 'DINT', '') - const result = updateVariableValidation([], { location: 'INVALID' }, dintVar) + const result = updateVariableValidation([], { location: '%QX0.0' }, dintVar) expect(result.ok).toBe(false) expect(result.message).toContain('%MD') }) it('returns DWORD location hint for UDINT type', () => { const udintVar = makeVariable('Test', 'UDINT', '') - const result = updateVariableValidation([], { location: 'INVALID' }, udintVar) + const result = updateVariableValidation([], { location: '%QX0.0' }, udintVar) expect(result.ok).toBe(false) expect(result.message).toContain('%MD') }) it('returns DWORD location hint for REAL type', () => { const realVar = makeVariable('Test', 'REAL', '') - const result = updateVariableValidation([], { location: 'INVALID' }, realVar) + const result = updateVariableValidation([], { location: '%QX0.0' }, realVar) expect(result.ok).toBe(false) expect(result.message).toContain('%MD') }) it('returns LWORD location hint for LINT type', () => { const lintVar = makeVariable('Test', 'LINT', '') - const result = updateVariableValidation([], { location: 'INVALID' }, lintVar) + const result = updateVariableValidation([], { location: '%QX0.0' }, lintVar) expect(result.ok).toBe(false) expect(result.message).toContain('%ML') }) it('returns LWORD location hint for ULINT type', () => { const ulintVar = makeVariable('Test', 'ULINT', '') - const result = updateVariableValidation([], { location: 'INVALID' }, ulintVar) + const result = updateVariableValidation([], { location: '%QX0.0' }, ulintVar) expect(result.ok).toBe(false) expect(result.message).toContain('%ML') }) it('returns LWORD location hint for LREAL type', () => { const lrealVar = makeVariable('Test', 'LREAL', '') - const result = updateVariableValidation([], { location: 'INVALID' }, lrealVar) + const result = updateVariableValidation([], { location: '%QX0.0' }, lrealVar) expect(result.ok).toBe(false) expect(result.message).toContain('%ML') }) it('returns empty message for unknown type location', () => { const unknownVar = makeVariable('Test', 'STRING', '') - const result = updateVariableValidation([], { location: 'INVALID' }, unknownVar) + const result = updateVariableValidation([], { location: '%QX0.0' }, unknownVar) expect(result.ok).toBe(false) // Default case returns empty string for the error message detail expect(result.message).toContain('Please make sure that the location is valid.') }) + // -- Alias-name locations (single-field model) -- + // A non-`%` location is an alias binding; its concrete address (and thus + // its type match) is resolved at compile time, so validation accepts any + // non-empty non-`%` string regardless of the variable's type. + it('accepts a non-% location as an alias name (BOOL)', () => { + const boolVar = makeVariable('Test', 'BOOL', '') + const result = updateVariableValidation([], { location: 'push_button' }, boolVar) + expect(result.ok).toBe(true) + }) + + it('accepts a non-% location as an alias name (unknown/STRING type)', () => { + const stringVar = makeVariable('Test', 'STRING', '') + const result = updateVariableValidation([], { location: 'some_alias' }, stringVar) + expect(result.ok).toBe(true) + }) + // -- BOOL location validation edge cases -- it('rejects BOOL location with bit position > 7', () => { const boolVar = makeVariable('Test', 'BOOL', '') diff --git a/src/frontend/store/__tests__/sync-variable-aliases-action.test.ts b/src/frontend/store/__tests__/sync-variable-aliases-action.test.ts deleted file mode 100644 index bc9312229..000000000 --- a/src/frontend/store/__tests__/sync-variable-aliases-action.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { createStore } from 'zustand/vanilla' - -import type { BoardInfo, PLCPou, PLCRemoteDevice, PLCVariable } from '../../../middleware/shared/ports/types' -import { createConsoleSlice } from '../slices/console' -import { createDeviceSlice } from '../slices/device' -import { createEditorSlice } from '../slices/editor' -import { createLibrarySlice } from '../slices/library' -import { createProjectSlice } from '../slices/project/slice' -import type { ProjectSliceRoot } from '../slices/project/types' - -/** - * Integration tests for projectActions.syncVariableAliases — the - * store-level wiring that the pure `syncVariableAliases` function - * sits behind. Pure-function tests already cover adoption / refresh / - * orphan; this file asserts that the action correctly assembles the - * pool from cross-slice state, honours target capabilities, surfaces - * conflicts, and updates POU + global variables atomically. - */ - -function makeStore() { - return createStore()((...args) => ({ - ...createProjectSlice(...args), - ...createDeviceSlice(...args), - ...createConsoleSlice(...args), - ...createEditorSlice(...args), - ...createLibrarySlice(...args), - })) -} - -const VPP_V4: BoardInfo = { - compiler: 'openplc-compiler', - core: 'rt-v4', - preview: '', - specs: {}, - capabilities: { vppIo: true }, - // Note: the `vpp` metadata block is optional on BoardInfo — only - // its presence matters for the legacy compiler-string fallback. - // The explicit capabilities block above is what drives the - // resolver here. -} - -const ARDUINO_BOARD: BoardInfo = { - compiler: 'arduino-cli', - core: 'avr', - preview: '', - specs: {}, -} - -function variable(name: string, location: string, alias?: string): PLCVariable { - return { - name, - class: 'local', - type: { definition: 'base-type', value: 'BOOL' }, - location, - documentation: '', - ...(alias ? { alias } : {}), - } -} - -function pou(name: string, vars: PLCVariable[]): PLCPou { - return { - name, - pouType: 'program', - interface: { variables: vars }, - body: { language: 'st', value: '' }, - documentation: '', - } -} - -function withVppEntries(entries: Array<{ slot: number; channelName: string; iecAddress: string; alias?: string }>) { - return { - 'io-mapping': { - entries: entries.map((e) => ({ - slot: e.slot, - moduleId: 'm', - moduleName: 'M', - channelName: e.channelName, - channelType: 'digitalOutput', - dataType: 'BOOL', - iecAddress: e.iecAddress, - alias: e.alias ?? '', - })), - }, - } -} - -function seedProject(store: ReturnType, pous: PLCPou[], globals: PLCVariable[] = []) { - const current = store.getState().project - store.setState({ - project: { - ...current, - data: { - ...current.data, - pous, - configurations: { - ...current.data.configurations, - resource: { - ...current.data.configurations.resource, - globalVariables: globals, - }, - }, - }, - }, - }) -} - -function seedRemoteDevices(store: ReturnType, remoteDevices: PLCRemoteDevice[]) { - const current = store.getState().project - store.setState({ - project: { - ...current, - data: { ...current.data, remoteDevices }, - }, - }) -} - -function seedBoard(store: ReturnType, boardName: string, boardInfo: BoardInfo) { - store.getState().deviceActions.setAvailableOptions({ - availableBoards: new Map([[boardName, boardInfo]]), - }) - store.getState().deviceActions.setDeviceBoard(boardName) -} - -function seedVendorScreenData(store: ReturnType, data: Record) { - for (const [k, v] of Object.entries(data)) { - store.getState().deviceActions.setVendorScreenData(k, v as Record) - } -} - -describe('projectActions.syncVariableAliases (store integration)', () => { - it('adopts aliases from VPP entries on first sync (project-load self-upgrade path)', () => { - const store = makeStore() - seedBoard(store, 'SLM-RP4', VPP_V4) - seedVendorScreenData( - store, - withVppEntries([{ slot: 1, channelName: 'DO1', iecAddress: '%QX0.0', alias: 'conveyor_motor' }]), - ) - seedProject(store, [pou('main', [variable('motor', '%QX0.0')])]) - - const report = store.getState().projectActions.syncVariableAliases() - expect(report).toEqual({ adopted: 1, refreshed: 0, orphaned: 0 }) - - const vars = store.getState().project.data.pous[0].interface!.variables - expect(vars[0].alias).toBe('conveyor_motor') - expect(vars[0].location).toBe('%QX0.0') - }) - - it('refreshes the variable location when the alias has moved', () => { - const store = makeStore() - seedBoard(store, 'SLM-RP4', VPP_V4) - // Alias now points to a different address than the variable carries. - seedVendorScreenData( - store, - withVppEntries([{ slot: 3, channelName: 'DO1', iecAddress: '%QX1.5', alias: 'conveyor_motor' }]), - ) - seedProject(store, [pou('main', [variable('motor', '%QX0.0', 'conveyor_motor')])]) - - const report = store.getState().projectActions.syncVariableAliases() - expect(report).toEqual({ adopted: 0, refreshed: 1, orphaned: 0 }) - - const vars = store.getState().project.data.pous[0].interface!.variables - expect(vars[0].location).toBe('%QX1.5') - expect(vars[0].alias).toBe('conveyor_motor') - }) - - it('reports orphans and clears their stale location when the producer no longer exposes the alias', () => { - const store = makeStore() - seedBoard(store, 'SLM-RP4', VPP_V4) - seedVendorScreenData(store, withVppEntries([])) // no VPP entries at all - seedProject(store, [pou('main', [variable('motor', '%QX0.0', 'conveyor_motor')])]) - - const report = store.getState().projectActions.syncVariableAliases() - expect(report.orphaned).toBe(1) - - // Phase 3 contract: orphans keep the alias name (so the UI can - // render the warning glyph + tooltip) but their stale location - // is cleared so the ST / XML emitters don't bake an `AT %…` into - // the compile for an alias whose producer is no longer active. - const vars = store.getState().project.data.pous[0].interface!.variables - expect(vars[0].alias).toBe('conveyor_motor') - expect(vars[0].location).toBe('') - }) - - it('honours target capabilities: switching to an arduino board orphans VPP-bound aliases', () => { - const store = makeStore() - store.getState().deviceActions.setAvailableOptions({ - availableBoards: new Map([ - ['SLM-RP4', VPP_V4], - ['Arduino Mega', ARDUINO_BOARD], - ]), - }) - store.getState().deviceActions.setDeviceBoard('SLM-RP4') - seedVendorScreenData(store, withVppEntries([{ slot: 1, channelName: 'DO1', iecAddress: '%QX0.0', alias: 'motor' }])) - seedProject(store, [pou('main', [variable('motor', '%QX0.0', 'motor')])]) - - // On VPP-capable target the alias resolves. - let report = store.getState().projectActions.syncVariableAliases() - expect(report.orphaned).toBe(0) - - // Switch to Arduino — VPP capability is off, so the registry - // no longer carries the alias. - store.getState().deviceActions.setDeviceBoard('Arduino Mega') - report = store.getState().projectActions.syncVariableAliases() - expect(report.orphaned).toBe(1) - }) - - it('syncs POU-local and global variables in the same call', () => { - const store = makeStore() - seedBoard(store, 'SLM-RP4', VPP_V4) - seedVendorScreenData( - store, - withVppEntries([ - { slot: 1, channelName: 'DO1', iecAddress: '%QX0.0', alias: 'motor' }, - { slot: 1, channelName: 'DO2', iecAddress: '%QX0.1', alias: 'valve' }, - ]), - ) - seedProject(store, [pou('main', [variable('motor_var', '%QX0.0')])], [variable('valve_var', '%QX0.1')]) - - const report = store.getState().projectActions.syncVariableAliases() - expect(report.adopted).toBe(2) - - expect(store.getState().project.data.pous[0].interface!.variables[0].alias).toBe('motor') - expect(store.getState().project.data.configurations.resource.globalVariables[0].alias).toBe('valve') - }) - - it('surfaces pool conflicts via the console slice', () => { - const store = makeStore() - seedBoard(store, 'SLM-RP4', VPP_V4) - seedVendorScreenData(store, withVppEntries([{ slot: 1, channelName: 'DO1', iecAddress: '%QX0.0', alias: 'a' }])) - - // Inject a second producer claiming the same address — would only - // happen if a project file was hand-edited. Both pin-mapping AND - // VPP claim %QX0.0. Pin-mapping wins (reservation pass first); - // VPP entry's source loses → conflict reported. - const remoteDevices: PLCRemoteDevice[] = [ - { - name: 'd', - protocol: 'modbus-tcp', - modbusTcpConfig: { - timeout: 1000, - ioGroups: [ - { - id: 'g', - name: 'g', - functionCode: '5', - cycleTime: 100, - offset: '0', - length: 1, - errorHandling: 'keep-last-value', - ioPoints: [{ id: 'p', name: 'p', type: '', iecLocation: '%QX0.0', alias: 'conflict' }], - }, - ], - }, - }, - ] - seedProject(store, []) - seedRemoteDevices(store, remoteDevices) - - store.getState().projectActions.syncVariableAliases() - const logs = store.getState().logs - expect(logs.some((log) => log.message.includes('Address pool reports'))).toBe(true) - }) - - it('setAvailableOptions auto-syncs aliases once boards land (project-load path)', () => { - const store = makeStore() - // Seed project + VPP data BEFORE the boards land, mimicking the - // real load order: handleOpenProjectResponse populates the project, - // workspace-screen then resolves availableBoards. - seedVendorScreenData(store, withVppEntries([{ slot: 1, channelName: 'DO1', iecAddress: '%QX0.0', alias: 'motor' }])) - seedProject(store, [pou('main', [variable('motor_var', '%QX0.0')])]) - store.getState().deviceActions.setDeviceBoard('SLM-RP4') - - // Pre-sync: no aliases bound because boards haven't landed (caps are empty). - expect(store.getState().project.data.pous[0].interface!.variables[0].alias).toBeUndefined() - - // Boards land — this is the project-load sync point. - store.getState().deviceActions.setAvailableOptions({ - availableBoards: new Map([['SLM-RP4', VPP_V4]]), - }) - - // Alias was adopted by the auto-sync inside setAvailableOptions. - expect(store.getState().project.data.pous[0].interface!.variables[0].alias).toBe('motor') - // And a summary log was emitted. - expect(store.getState().logs.some((l) => l.message.startsWith('Alias sync:'))).toBe(true) - }) - - it('setAvailableOptions skips sync when only ports change (no caps churn)', () => { - const store = makeStore() - seedBoard(store, 'SLM-RP4', VPP_V4) // initial sync runs (no project data — no-op) - seedVendorScreenData(store, withVppEntries([{ slot: 1, channelName: 'DO1', iecAddress: '%QX0.0', alias: 'motor' }])) - seedProject(store, [pou('main', [variable('motor_var', '%QX0.0')])]) - - // Ports-only update — must NOT trigger a sync (would adopt the - // alias prematurely without any cap change to justify it). - store.getState().deviceActions.setAvailableOptions({ - availableCommunicationPorts: [], - }) - expect(store.getState().project.data.pous[0].interface!.variables[0].alias).toBeUndefined() - }) -}) diff --git a/src/frontend/store/slices/device/slice.ts b/src/frontend/store/slices/device/slice.ts index d3f3abeb2..6206d7b26 100644 --- a/src/frontend/store/slices/device/slice.ts +++ b/src/frontend/store/slices/device/slice.ts @@ -72,27 +72,12 @@ const createDeviceSlice: StateCreator = (s }), ) - // availableBoards drives target-capability resolution, which the - // alias registry depends on. This action is the project-load - // sync point: the workspace screen calls us once it finishes - // board discovery, so by the time we run, the active target's - // capabilities resolve correctly. Re-syncing on every subsequent - // boards refresh (e.g. VPP package install) catches capability - // shifts for the active board. - // - // We only sync when `availableBoards` was actually provided — - // ports-only updates (e.g. board.tsx refresh-ports) don't affect - // capabilities and shouldn't churn the alias registry. - if (availableBoards) { - const syncReport = getState().projectActions.syncVariableAliases() - if (syncReport.adopted > 0 || syncReport.refreshed > 0 || syncReport.orphaned > 0) { - getState().consoleActions.addLog({ - id: crypto.randomUUID(), - level: 'info', - message: `Alias sync: adopted=${syncReport.adopted} refreshed=${syncReport.refreshed} orphaned=${syncReport.orphaned}`, - }) - } - } + // Board discovery only affects target-capability resolution; it no + // longer needs to touch program variables. In the single-field model a + // variable's `location` holds either a stable alias name (resolved to an + // address at compile time) or a literal address — neither changes when + // the board list lands. Producer address recompaction on an actual + // target change is handled by `setDeviceBoard → recalculateIecAddresses`. }, setDeviceDefinitions: ({ configuration, pinMapping }): void => { setState( diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 64aa2c04f..c7078a7fa 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -16,16 +16,17 @@ import { buildAliasRegistry, describeSource, nextFreeAddress, - syncVariableAliases as syncVariablesPure, validateAliasEdit, } from '../../../../middleware/shared/utils/iec-address' import { + buildAliasIndex, channelKey, ethercatConsumerId, type IecAddressRegistry, migrateToRegistry, modbusConsumerId, recalculate as recalculateRegistry, + resolveLocation, restoreAliasesFromMemory, unpinAllocatableChannels, } from '../../../../middleware/shared/utils/iec-address/registry' @@ -192,57 +193,6 @@ function generateIOPoints( return points } -// --------------------------------------------------------------------------- -// Alias auto-adopt -// --------------------------------------------------------------------------- - -/** - * Resolve the canonical alias that a variable should carry for its - * current `location`. Builds a fresh pool + registry from the live - * store state, scoped to the active target's capabilities, then - * returns whatever alias the registry attaches to that address (or - * `undefined` when the address isn't aliased, or `location` is empty). - * - * Used by both `createVariable` and `updateVariable` to enforce the - * alias-↔-location invariant: a variable's `alias` field MUST point - * at the same producer-channel its `location` points at. Without - * this, the "+" button (which auto-increments the location of a - * spread-from-previous variable) leaves the OLD alias attached to a - * NEW address — `syncVariableAliases` then collapses every such - * variable back to the OLD alias's canonical address on the next - * refresh pass, producing the duplicate-address compile errors - * reported in v4.2.0. - * - * Returns `undefined` for an empty / missing location so callers can - * distinguish "no alias because the address is unmapped" from "no - * alias because we didn't bother to look". - */ -function resolveAliasForLocation(getState: ProjectGetState, location: string | undefined): string | undefined { - if (!location) return undefined - const live = getState() - const boardInfo = live.deviceAvailableOptions.availableBoards.get( - live.deviceDefinitions.configuration.deviceBoard ?? '', - ) - const ioMapping = - ( - live.deviceDefinitions.configuration.vendorScreenData?.['io-mapping'] as - | { entries?: Array<{ iecAddress: string; alias?: string; slot: number; channelName: string }> } - | undefined - )?.entries ?? [] - const pool = buildAddressPool( - { - pinMapping: { - pins: live.deviceDefinitions.pinMapping.pinsByBoard[live.deviceDefinitions.configuration.deviceBoard] ?? [], - }, - vendorIoMapping: { entries: ioMapping }, - remoteDevices: live.project.data.remoteDevices, - }, - resolveTargetCapabilities(boardInfo), - ) - const registry = buildAliasRegistry(pool) - return registry.byAddress.get(location)?.alias -} - // --------------------------------------------------------------------------- // Variables-text ⇄ variables-table reconcile helpers // --------------------------------------------------------------------------- @@ -711,23 +661,21 @@ const createProjectSlice: StateCreator = if (!reconcile.ok) return reconcile } - // Apply the validator's name + location auto-increment OUTSIDE - // produce so we can then re-resolve the alias against the live - // store state. The new variable's `alias` MUST point at the - // channel its post-increment `location` points at — otherwise - // the "+ button" UI flow (which spreads the previous variable - // as a template) carries a stale alias from the previous row - // forward, breaking the alias-↔-location invariant. The next - // `syncVariableAliases` refresh would then silently collapse - // the new variable back to the stale alias's canonical - // address, producing the duplicate-address compile errors - // reported in v4.2.0 (forum thread "openplc-420-teething-bugs"). + // Apply the validator's name + location auto-increment against the + // live store state. The "+ button" UI flow spreads the previous + // variable as a template, so the validator walks the location forward + // to the next free slot to avoid duplicate-address compile errors + // (forum thread "openplc-420-teething-bugs", v4.2.0). const sourceVariables = scope === 'local' && associatedPou ? (getState().project.data.pous.find((p) => p.name === associatedPou)?.interface?.variables ?? []) : getState().project.data.configurations.resource.globalVariables const validated = createVariableValidation(sourceVariables, data) - data = { ...data, ...validated, alias: resolveAliasForLocation(getState, validated.location) } + // Single-field location model: `location` is the binding itself — an + // alias name OR a literal `%addr`. It is stored verbatim (no + // address→alias auto-adoption); alias→address resolution happens at + // compile time. The legacy `alias` field is unused. + data = { ...data, ...validated } let response: ProjectResponse = { ok: true } setState( @@ -787,20 +735,10 @@ const createProjectSlice: StateCreator = let response: ProjectResponse = { ok: true } - // Auto-adopt path: whenever the location changes, re-resolve - // the alias against the live registry so the variable's alias - // always points at the producer-channel its location points at. - // If the address has an alias, the variable adopts it (cell - // shows the alias name; `syncVariableAliases` will keep the - // location current as the alias moves). If not, the alias - // clears — re-typing a now-orphaned location intentionally - // drops the stale alias label too. Done outside `produce` so - // we read the live store state including pinMapping + caps. - const aliasOverride: { alias: string | undefined } | undefined = - typeof updates.location === 'string' - ? { alias: resolveAliasForLocation(getState, updates.location) } - : undefined - + // Single-field location model: `location` is the binding (alias name + // or literal `%addr`), stored verbatim. No address→alias adoption — + // a manual literal stays manual even if an alias later appears at that + // address; alias→address resolution happens at compile time. setState( produce((slice: ProjectSlice) => { // Resolve the target variables array (local POU or global) @@ -831,7 +769,6 @@ const createProjectSlice: StateCreator = variables[found.index] = { ...variables[found.index], ...updates, - ...(aliasOverride ?? {}), ...(validationResponse.data ? validationResponse.data : {}), } response.data = variables[found.index] @@ -933,113 +870,15 @@ const createProjectSlice: StateCreator = ) }, - syncVariableAliases: () => { - // Build pool + registry once from the live state before entering - // produce so we don't read draft proxies inside the registry - // build. - const live = getState() - const boardInfo = live.deviceAvailableOptions.availableBoards.get( - live.deviceDefinitions.configuration.deviceBoard ?? '', - ) - const ioMapping = - ( - live.deviceDefinitions.configuration.vendorScreenData?.['io-mapping'] as - | { entries?: Array<{ iecAddress: string; alias?: string; slot: number; channelName: string }> } - | undefined - )?.entries ?? [] - const pool = buildAddressPool( - { - pinMapping: { - pins: live.deviceDefinitions.pinMapping.pinsByBoard[live.deviceDefinitions.configuration.deviceBoard] ?? [], - }, - vendorIoMapping: { entries: ioMapping }, - remoteDevices: live.project.data.remoteDevices, - }, - resolveTargetCapabilities(boardInfo), - ) - const registry = buildAliasRegistry(pool) - - // Conflicts are unreachable under normal editor flows because - // the editor always assigns addresses uniquely. They can - // appear when a project file has been hand-edited or migrated - // incorrectly — silent first-wins is the worst failure mode, - // so surface them in the console panel. - if (pool.conflicts.length > 0) { - const sample = pool.conflicts - .slice(0, 5) - .map((c) => `${c.address} (${c.sources.map((s) => s.kind).join(', ')})`) - .join('; ') - const overflow = pool.conflicts.length > 5 ? ` (+${pool.conflicts.length - 5} more)` : '' - live.consoleActions.addLog({ - id: crypto.randomUUID(), - level: 'warning', - message: `Address pool reports ${pool.conflicts.length} conflicting claim(s): ${sample}${overflow}. The first source wins; later ones lose their address binding.`, - }) - } - // Same migration warning, alias side: projects authored before - // the write-time `validateAliasEdit` gate landed may have - // duplicate alias names across producers. The registry - // first-wins on `byAlias`, but every variable bound to the - // losing entry gets quietly collapsed to the winner's address - // through the sync's refresh path. Surface this loudly so the - // user can resolve it (rename one of the duplicates) instead - // of silently inheriting a broken state. - if (registry.duplicateAliases.length > 0) { - const sample = registry.duplicateAliases.slice(0, 5).join(', ') - const overflow = registry.duplicateAliases.length > 5 ? ` (+${registry.duplicateAliases.length - 5} more)` : '' - live.consoleActions.addLog({ - id: crypto.randomUUID(), - level: 'warning', - message: `Alias registry reports ${registry.duplicateAliases.length} duplicate alias name(s): ${sample}${overflow}. Each alias must be unique across all I/O channels — rename the duplicates in the IO mapping screens. Until then, variables bound to the losing entries will resolve to the winning entry's address.`, - }) - } - - let adopted = 0 - let refreshed = 0 - let orphaned = 0 - - setState( - produce((slice: ProjectSlice) => { - for (const pou of slice.project.data.pous) { - /* istanbul ignore if -- PLCPouSchema requires `interface.variables` to be an - array (defaults to []), so every POU sourced from a Zod-validated project - carries the field; defensive against future schema relaxation */ - if (!pou.interface?.variables) continue - const result = syncVariablesPure(pou.interface.variables, registry) - adopted += result.report.adopted.length - refreshed += result.report.refreshed.length - orphaned += result.report.orphaned.length - // Mutate in place to preserve draft semantics. - for (let i = 0; i < result.variables.length; i++) { - pou.interface.variables[i] = result.variables[i] - } - } - - const globals = slice.project.data.configurations.resource.globalVariables - if (globals) { - const result = syncVariablesPure(globals, registry) - adopted += result.report.adopted.length - refreshed += result.report.refreshed.length - orphaned += result.report.orphaned.length - for (let i = 0; i < result.variables.length; i++) { - globals[i] = result.variables[i] - } - } - }), - ) - - return { adopted, refreshed, orphaned } - }, - /** - * Cascade-rename every variable's `.alias` from `oldAlias` to - * `newAlias`. See the type doc in `project/types.ts` for the - * full contract — short version: when the user renames the - * alias on a producer channel (pin mapping, VPP module, Modbus - * TCP, EtherCAT), the bound variables follow so they don't drop - * into the orphan path. Case-insensitive match. A subsequent - * `syncVariableAliases()` then refreshes the variables' - * `.location` against the now-renamed alias's address. + * Cascade-rename every variable whose `location` binds to `oldAlias` + * so it points at `newAlias` instead. See the type doc in + * `project/types.ts` for the full contract — short version: when the + * user renames the alias on a producer channel (pin mapping, VPP + * module, Modbus TCP, EtherCAT), the bound variables follow so they + * don't drop into the orphan (unlocated) path at compile time. + * Case-sensitive match — the alias registry is case-sensitive, so + * `location` must equal the producer alias exactly to resolve. */ renameAlias: (oldAlias, newAlias) => { const trimmedOld = oldAlias?.trim() ?? '' @@ -1048,23 +887,22 @@ const createProjectSlice: StateCreator = // mapping screen on first-time alias write where there's no // prior text to cascade. if (trimmedOld.length === 0) return { renamed: 0 } - // No-op when the rename is a pure case change or an actual - // no-op — saves a render pass and avoids spurious mutation. - if (trimmedOld.toLowerCase() === trimmedNew.toLowerCase()) return { renamed: 0 } + // True no-op only when the name is unchanged. A CASE change must still + // cascade — the alias registry is case-sensitive, so `location` has to + // match the producer alias exactly to resolve at compile time. + if (trimmedOld === trimmedNew) return { renamed: 0 } let renamed = 0 const cascade = (variable: PLCVariable): PLCVariable => { - if (!variable.alias) return variable - if (variable.alias.toLowerCase() !== trimmedOld.toLowerCase()) return variable + // `location` is the binding: a variable bound to this alias holds the + // alias NAME in `location`. Manual literal locations start with `%` + // and never equal a (non-`%`) alias name, so they're left untouched. + if (variable.location !== trimmedOld) return variable renamed += 1 - // When the user clears the alias on the producer side, the - // bound variables should also drop their alias — the next - // `syncVariableAliases()` will then re-evaluate them against - // the live registry (auto-adopt by raw location if the same - // address is still claimed by some other producer, or leave - // them alias-less otherwise). `undefined` rather than '' - // matches the rest of the codebase's "no alias" convention. - return { ...variable, alias: trimmedNew.length > 0 ? trimmedNew : undefined } + // When the producer CLEARS its alias (newAlias empty), the bound + // variable becomes unlocated — `location` clears, matching the + // compile-time "missing alias → empty location" rule. + return { ...variable, location: trimmedNew } } setState( @@ -1662,8 +1500,12 @@ const createProjectSlice: StateCreator = // registry. Build the registry from live producer state (VPP + Modbus // reallocated; pins/EtherCAT held as fixed constraints), restoring any // aliases the session memory remembers for reappeared channels, then - // write the compacted addresses + aliases back onto every producer and - // reconcile bound variables. + // write the compacted addresses + aliases back onto every producer. + // + // Bound variables need no update here: they reference producer aliases + // by NAME (stable), so a moved address is picked up automatically at + // compile time. Only a producer *rename* touches variables — via + // `renameAlias`, called by the alias editors. const live = getState() const registry = buildIecRegistry(live) const index = indexRegistry(registry) @@ -1683,7 +1525,6 @@ const createProjectSlice: StateCreator = applyEthercatAddresses(slice.project.data.remoteDevices, index) }), ) - getState().projectActions.syncVariableAliases() return ok() }, rememberChannelAlias: (memoryKey, alias) => { @@ -1700,6 +1541,24 @@ const createProjectSlice: StateCreator = ) return ok() }, + getCompileReadyProjectData: () => { + // Compile-time alias resolution (editor-side; the compiler/runtime never + // see aliases). Returns a COPY of the project data with every variable's + // `location` resolved: an alias name → its current IEC address, a + // literal `%addr` → verbatim, a missing/orphaned alias → '' (unlocated). + // The store keeps the alias-name form for display; only this snapshot is + // resolved. + const live = getState() + const aliasIndex = buildAliasIndex(buildIecRegistry(live)) + const data = structuredClone(live.project.data) + const resolveAll = (variables: PLCVariable[] | undefined): void => { + if (!variables) return + for (const variable of variables) variable.location = resolveLocation(variable.location, aliasIndex) + } + for (const pou of data.pous) resolveAll(pou.interface?.variables) + resolveAll(data.configurations?.resource?.globalVariables) + return data + }, addIOGroup: (deviceName, group) => { // Read producer state from the live store before entering produce // so the pool reflects every active source (pin-mapping, VPP, @@ -1883,12 +1742,21 @@ const createProjectSlice: StateCreator = if (point) point.alias = alias }), ) - // Producer mutation: refresh variables that were bound to the - // old alias (or that now resolve to the new one). - getState().projectActions.syncVariableAliases() return ok() }, updateEthercatConfig: (deviceName, ethercatConfig) => { + // Capture the previous channel aliases so an alias RENAME cascades onto + // bound variables (whose `location` holds the alias name). Unlike + // pin/VPP/Modbus, EtherCAT rewrites its channel list wholesale, so we + // diff old→new here rather than at a discrete alias editor. + const prevDevice = getState().project.data.remoteDevices?.find((d) => d.name === deviceName) + const prevAliasByChannel = new Map() + for (const slave of prevDevice?.ethercatConfig?.devices ?? []) { + for (const mapping of slave.channelMappings ?? []) { + if (mapping.alias) prevAliasByChannel.set(`${slave.name}:${mapping.channelId}`, mapping.alias) + } + } + let response = ok() setState( produce((slice: ProjectSlice) => { @@ -1911,9 +1779,18 @@ const createProjectSlice: StateCreator = // directly. No IEC task needs syncing. }), ) - // Channel-mapping changes go through the central registry so EtherCAT - // addresses are packed alongside VPP/Modbus and bound variables follow. - if (response.ok) getState().projectActions.recalculateIecAddresses() + if (response.ok) { + // Cascade any alias rename onto bound variable locations. + for (const slave of ethercatConfig.devices ?? []) { + for (const mapping of slave.channelMappings ?? []) { + const old = prevAliasByChannel.get(`${slave.name}:${mapping.channelId}`) + if (old && old !== (mapping.alias ?? '')) getState().projectActions.renameAlias(old, mapping.alias ?? '') + } + } + // Channel-mapping changes go through the central registry so EtherCAT + // addresses are packed alongside VPP/Modbus. + getState().projectActions.recalculateIecAddresses() + } return response }, }, diff --git a/src/frontend/store/slices/project/types.ts b/src/frontend/store/slices/project/types.ts index cb8758fdb..d6981cdae 100644 --- a/src/frontend/store/slices/project/types.ts +++ b/src/frontend/store/slices/project/types.ts @@ -146,25 +146,6 @@ export type ProjectActions = { newIndex: number }) => void - /** - * Re-sync every located variable (POU-local + globals) against the - * current alias registry. Variables auto-adopt new aliases, follow - * existing ones to refreshed addresses, and surface orphans (alias - * the registry no longer knows about). Called from every site that - * mutates an alias-producing source (VPP slot edits, Modbus / EtherCAT - * alias edits, pin renames, target switch, pre-compile) and from - * `deviceActions.setAvailableOptions` once the workspace screen - * finishes board discovery — that's the project-load sync point. - * - * Returns a combined sync report so the caller can log a one-liner - * summary ("Adopted N aliases, refreshed M, K orphaned"). - */ - syncVariableAliases: () => { - adopted: number - refreshed: number - orphaned: number - } - /** * Central, capability-scoped recalculation via the IEC address registry. * Derives consumers from live producer state, restores aliases the session @@ -185,23 +166,27 @@ export type ProjectActions = { rememberChannelAlias: (memoryKey: string, alias: string) => ProjectResponse /** - * Cascade-rename every variable's `.alias` field from `oldAlias` to - * `newAlias` across all POU-local and global variables. Used by - * the IO-mapping screens (pin-mapping, VPP modules, VPP io-table, - * Modbus TCP remote, EtherCAT) when the user renames the alias on - * a producer channel — the rename cascades to bound variables so - * they don't become orphaned just because the alias text moved. - * - * Empty `oldAlias` (channel previously had no alias) is a no-op. - * Empty `newAlias` (user clearing the alias) causes the matching - * variables to drop their alias too; `syncVariableAliases` will - * then re-evaluate them against the live registry (auto-adopt by - * raw location when applicable, otherwise alias-less binding). + * Compile-time alias resolution (editor-side; the compiler/runtime never + * see aliases). Returns a COPY of the project data with every variable's + * `location` resolved to a concrete IEC address: an alias name → its + * current address, a literal `%addr` → verbatim, a missing/orphaned alias + * → '' (unlocated). The store keeps the alias-name form for display. + */ + getCompileReadyProjectData: () => ProjectState['data'] + + /** + * Cascade-rename bound variables' `location` from `oldAlias` to `newAlias` + * across all POU-local and global variables. In the single-field model a + * variable bound to a producer alias holds the alias NAME in `location`; + * when the user renames (or clears) that alias on the producer channel + * (pin mapping, VPP module, Modbus TCP, EtherCAT), the bound variables must + * follow so they keep resolving at compile time. * - * Case-insensitive matching to align with the rest of the IEC - * identifier handling. Callers should follow this with a - * `syncVariableAliases()` to refresh `.location` against the now- - * renamed alias's address. + * Empty `oldAlias` is a no-op (first-time alias write — nothing to cascade). + * Empty `newAlias` (clearing the alias) sets the matching variables' + * `location` to '' — they become unlocated, matching the compile-time + * "missing alias → empty location" rule. Exact (case-sensitive) match, + * since the alias registry that resolves names at compile is case-sensitive. * * Returns the number of variables actually mutated. */ diff --git a/src/frontend/store/slices/project/validation/variables.ts b/src/frontend/store/slices/project/validation/variables.ts index 2f0524894..79acb2dff 100644 --- a/src/frontend/store/slices/project/validation/variables.ts +++ b/src/frontend/store/slices/project/validation/variables.ts @@ -116,9 +116,15 @@ const arrayValidation = ({ value }: { value: string }) => { } /** - * This is a validation to check if the value of the location is valid. + * Validate a variable's `location`. Single-field model: `location` is either + * an alias name, a literal IEC address, or empty. + * - Empty → unlocated, valid. + * - A non-`%` value → an alias name; its concrete address (and therefore + * its type match) is resolved at compile time, so accept it here. + * - A literal `%…` → must match the variable's type's address class. */ const variableLocationValidation = (variableLocation: string, variableType: string) => { + if (variableLocation === '' || !variableLocation.startsWith('%')) return true switch (variableType.toUpperCase()) { case 'BOOL': { const boolMatch = BOOL_LOCATION_REGEX.test(variableLocation) && variableLocation.split('.')[1] <= '7' diff --git a/src/frontend/utils/__tests__/location-dropdown-options.test.ts b/src/frontend/utils/__tests__/location-dropdown-options.test.ts index 4ba6007c0..24ab6471e 100644 --- a/src/frontend/utils/__tests__/location-dropdown-options.test.ts +++ b/src/frontend/utils/__tests__/location-dropdown-options.test.ts @@ -105,9 +105,11 @@ describe('buildLocationDropdownOptions', () => { } // The VPP entry surfaces under whatever group the vendor-io - // builder produces for slot 1; assert via the option value. + // builder produces for slot 1; assert via the option value. Vendor + // IO options carry the ALIAS as their value (resolved to an + // address only at compile time), so we assert the alias here. const allOptionValues = groups.flatMap((g) => g.options.map((o) => o.value)) - expect(allOptionValues).toContain('%QX0.0') + expect(allOptionValues).toContain('slm-rp4-relay-1') }) it('drops remote-device IO points when both `modbusTcpRemote` and `ethercat` are disabled', () => { @@ -121,8 +123,9 @@ describe('buildLocationDropdownOptions', () => { capabilities: ARDUINO_CLI_CAPABILITIES, }) + // Remote IO options carry the alias as their value. const allOptionValues = groups.flatMap((g) => g.options.map((o) => o.value)) - expect(allOptionValues).not.toContain('%IW0') + expect(allOptionValues).not.toContain('flow-sensor-alias') }) it('surfaces remote-device IO points when EITHER `modbusTcpRemote` OR `ethercat` is enabled', () => { @@ -137,7 +140,7 @@ describe('buildLocationDropdownOptions', () => { }) const allOptionValues = groups.flatMap((g) => g.options.map((o) => o.value)) - expect(allOptionValues).toContain('%IW0') + expect(allOptionValues).toContain('flow-sensor-alias') }) it('drops every IO producer when the target has nothing enabled (Runtime v3 baseline)', () => { @@ -169,9 +172,11 @@ describe('buildLocationDropdownOptions', () => { const allOptionValues = groups.flatMap((g) => g.options.map((o) => o.value)) // Pin (Arduino-specific) dropped, vendor (VPP-specific) dropped, - // remote points retained. - expect(allOptionValues).toContain('%IW0') + // remote points retained. Remote/vendor values are aliases; the + // pin value would have been the literal address. + expect(allOptionValues).toContain('flow-sensor-alias') expect(allOptionValues).not.toContain('%QX0.0') + expect(allOptionValues).not.toContain('slm-rp4-relay-1') }) }) diff --git a/src/frontend/utils/__tests__/remote-device-options.test.ts b/src/frontend/utils/__tests__/remote-device-options.test.ts index ec5443057..3c7810e6b 100644 --- a/src/frontend/utils/__tests__/remote-device-options.test.ts +++ b/src/frontend/utils/__tests__/remote-device-options.test.ts @@ -1,5 +1,6 @@ +import type { IoMappingEntry } from '../../../middleware/shared/ports/types' import type { RemoteDeviceIOPoint } from '../remote-device-options' -import { buildRemoteDeviceOptionGroups } from '../remote-device-options' +import { buildRemoteDeviceOptionGroups, buildVendorIoOptionGroups } from '../remote-device-options' function makeIOPoint(overrides: Partial = {}): RemoteDeviceIOPoint { return { @@ -34,8 +35,8 @@ describe('buildRemoteDeviceOptionGroups', () => { { label: 'Remote: Device1', options: [ - { id: 'cell-1-remote-pt-1', value: '%IX0.0', label: '%IX0.0 (Sensor1)' }, - { id: 'cell-1-remote-pt-2', value: '%IX0.1', label: '%IX0.1 (Sensor2)' }, + { id: 'cell-1-remote-pt-1', value: 'Sensor1', label: 'Sensor1 (%IX0.0)' }, + { id: 'cell-1-remote-pt-2', value: 'Sensor2', label: 'Sensor2 (%IX0.1)' }, ], }, ]) @@ -83,8 +84,8 @@ describe('buildRemoteDeviceOptionGroups', () => { const result = buildRemoteDeviceOptionGroups('x', points) expect(result).toHaveLength(1) expect(result[0].options).toHaveLength(2) - expect(result[0].options[0].label).toBe('%IX0.0 (A_first)') - expect(result[0].options[1].label).toBe('%IX0.1 (B_unique)') + expect(result[0].options[0].label).toBe('A_first (%IX0.0)') + expect(result[0].options[1].label).toBe('B_unique (%IX0.1)') }) it('uses cellId in option IDs', () => { @@ -93,3 +94,49 @@ describe('buildRemoteDeviceOptionGroups', () => { expect(result[0].options[0].id).toBe('my-cell-remote-pt-7') }) }) + +function makeVendorEntry(overrides: Partial = {}): IoMappingEntry { + return { + slot: 1, + moduleId: 'mod-a', + moduleName: 'Relay Module', + channelName: 'DO1', + channelType: 'coil', + dataType: 'BOOL', + iecAddress: '%QX0.0', + alias: 'relay_1', + ...overrides, + } +} + +describe('buildVendorIoOptionGroups', () => { + it('binds each option by alias name and labels it with the address for context', () => { + const result = buildVendorIoOptionGroups('cell-1', [makeVendorEntry()]) + expect(result).toEqual([ + { + label: 'Slot 1: Relay Module', + options: [{ id: 'cell-1-vendor-1-DO1', value: 'relay_1', label: 'relay_1 (%QX0.0)' }], + }, + ]) + }) + + it('skips entries without an alias (only aliased vendor channels are bindable)', () => { + const entries = [ + makeVendorEntry({ channelName: 'DO1', alias: '' }), + makeVendorEntry({ channelName: 'DO2', alias: 'relay_2', iecAddress: '%QX0.1' }), + ] + const result = buildVendorIoOptionGroups('cell-1', entries) + expect(result).toHaveLength(1) + expect(result[0].options).toEqual([{ id: 'cell-1-vendor-1-DO2', value: 'relay_2', label: 'relay_2 (%QX0.1)' }]) + }) + + it('dedupes by IEC address (defensive against drifted projects with duplicate addresses)', () => { + const entries = [ + makeVendorEntry({ channelName: 'DO1', alias: 'first', iecAddress: '%QX0.0' }), + makeVendorEntry({ channelName: 'DO2', alias: 'second', iecAddress: '%QX0.0' }), // same address — dropped + ] + const result = buildVendorIoOptionGroups('cell-1', entries) + expect(result[0].options).toHaveLength(1) + expect(result[0].options[0].value).toBe('first') // first-iterated wins + }) +}) diff --git a/src/frontend/utils/location-dropdown-options.ts b/src/frontend/utils/location-dropdown-options.ts index 2804104c0..c70900801 100644 --- a/src/frontend/utils/location-dropdown-options.ts +++ b/src/frontend/utils/location-dropdown-options.ts @@ -24,10 +24,10 @@ * list only **aliased entries**. Their IEC addresses are * allocator-assigned and can shift when the user changes slot * layout, adds modules, etc. Variables that bind to an alias - * survive those shifts (`syncVariableAliases` refreshes their - * `location` via the registry); variables that bound to a raw - * address would silently break. Requiring an alias makes the - * rebind-on-shift contract explicit. + * survive those shifts (the alias name is stored in `location` + * and resolved to the current address at compile time); variables + * that bound to a raw address would silently break. Requiring an + * alias makes the rebind-on-shift contract explicit. * * Do not "fix" the inconsistency by filtering the pin-mapping branch * to aliased pins only — it would force users to write an alias diff --git a/src/frontend/utils/remote-device-options.ts b/src/frontend/utils/remote-device-options.ts index b6cc91cdc..f1767a86a 100644 --- a/src/frontend/utils/remote-device-options.ts +++ b/src/frontend/utils/remote-device-options.ts @@ -74,9 +74,12 @@ export function buildRemoteDeviceOptionGroups(cellId: string, remoteIOPoints: Re } deviceGroup.push({ + // Single-field model: binding a variable to this channel stores the + // ALIAS NAME in `location` (resolved to the address at compile). The + // label shows the address for context. id: `${cellId}-remote-${ioPoint.ioPointId}`, - value: ioPoint.iecLocation, - label: `${ioPoint.iecLocation} (${ioPoint.alias})`, + value: ioPoint.alias, + label: `${ioPoint.alias} (${ioPoint.iecLocation})`, }) } @@ -111,9 +114,11 @@ export function buildVendorIoOptionGroups(cellId: string, entries: IoMappingEntr } group.push({ + // Single-field model: bind by ALIAS NAME (resolved at compile); the + // label shows the address for context. id: `${cellId}-vendor-${entry.slot}-${entry.channelName}`, - value: entry.iecAddress, - label: `${entry.iecAddress} (${entry.alias})`, + value: entry.alias, + label: `${entry.alias} (${entry.iecAddress})`, }) } diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 56ca7be21..f795de683 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -57,12 +57,12 @@ export interface PLCVariable { name: string class?: VariableClass type: PLCVariableType + /** + * The variable's binding — single-field model: an alias name OR a literal + * IEC address (`%QX0.0`). Empty = unlocated. Alias→address resolution + * happens at compile time; a manual literal is honoured verbatim. + */ location: string - /** Stable alias name the variable is bound to, when present. Looked - * up in the alias registry to keep `location` current as the - * producer reassigns the address. Cell renders `alias (address)` - * when set; falls back to raw `location` otherwise. */ - alias?: string initialValue?: string | null documentation: string debug?: boolean diff --git a/src/middleware/shared/utils/iec-address/__tests__/alias-registry.test.ts b/src/middleware/shared/utils/iec-address/__tests__/alias-registry.test.ts index ef8c0aa07..9ae058375 100644 --- a/src/middleware/shared/utils/iec-address/__tests__/alias-registry.test.ts +++ b/src/middleware/shared/utils/iec-address/__tests__/alias-registry.test.ts @@ -1,12 +1,6 @@ import { ARDUINO_CLI_CAPABILITIES, RUNTIME_V4_CAPABILITIES } from '../../target-capabilities' import { buildAddressPool } from '../address-pool' -import { - aliasForAddress, - buildAliasRegistry, - isAliasNameAvailable, - resolveAlias, - validateAliasEdit, -} from '../alias-registry' +import { buildAliasRegistry, isAliasNameAvailable, resolveAlias, validateAliasEdit } from '../alias-registry' const v4 = RUNTIME_V4_CAPABILITIES const arduino = ARDUINO_CLI_CAPABILITIES @@ -24,11 +18,10 @@ describe('buildAliasRegistry', () => { ) const reg = buildAliasRegistry(pool) expect(reg.byAlias.size).toBe(0) - expect(reg.byAddress.size).toBe(0) expect(reg.duplicateAliases).toEqual([]) }) - it('indexes every aliased claim by both alias and address', () => { + it('indexes every aliased claim by alias name', () => { const pool = buildAddressPool( { vendorIoMapping: { @@ -44,10 +37,10 @@ describe('buildAliasRegistry', () => { ) const reg = buildAliasRegistry(pool) expect(reg.byAlias.size).toBe(2) - expect(reg.byAddress.size).toBe(2) expect(reg.byAlias.get('conveyor_motor')?.address).toBe('%QX0.5') - expect(reg.byAddress.get('%IW2')?.alias).toBe('tank_level') - expect(reg.byAddress.has('%QW3')).toBe(false) + expect(reg.byAlias.get('tank_level')?.address).toBe('%IW2') + // The un-aliased claim (%QW3) is absent from the index. + expect([...reg.byAlias.values()].some((e) => e.address === '%QW3')).toBe(false) }) it('records duplicate alias names; first encounter wins in byAlias', () => { @@ -70,9 +63,6 @@ describe('buildAliasRegistry', () => { const reg = buildAliasRegistry(pool) // Pool's reservation pass runs pin-mapping first; that's who wins. expect(reg.byAlias.get('valve')?.address).toBe('%QX0.0') - // Both addresses are still indexed under byAddress (both have aliases attached). - expect(reg.byAddress.get('%QX0.0')?.alias).toBe('valve') - expect(reg.byAddress.get('%MW1')?.alias).toBe('valve') expect(reg.duplicateAliases).toEqual(['valve']) }) @@ -144,33 +134,6 @@ describe('resolveAlias', () => { }) }) -describe('aliasForAddress', () => { - const pool = buildAddressPool( - { - vendorIoMapping: { - entries: [ - { iecAddress: '%IW0', alias: 'pressure', slot: 1, channelName: 'AI1' }, - { iecAddress: '%IW1', slot: 1, channelName: 'AI2' }, - ], - }, - }, - v4WithVpp, - ) - const reg = buildAliasRegistry(pool) - - it('returns the alias for an address that has one (auto-adopt path)', () => { - expect(aliasForAddress(reg, '%IW0')).toBe('pressure') - }) - - it('returns undefined for an address with no alias attached', () => { - expect(aliasForAddress(reg, '%IW1')).toBeUndefined() - }) - - it('returns undefined for an address no producer has claimed', () => { - expect(aliasForAddress(reg, '%IW9')).toBeUndefined() - }) -}) - describe('isAliasNameAvailable', () => { const pool = buildAddressPool( { diff --git a/src/middleware/shared/utils/iec-address/__tests__/sync-variable-aliases.test.ts b/src/middleware/shared/utils/iec-address/__tests__/sync-variable-aliases.test.ts deleted file mode 100644 index 0ae264812..000000000 --- a/src/middleware/shared/utils/iec-address/__tests__/sync-variable-aliases.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { RUNTIME_V4_CAPABILITIES } from '../../target-capabilities' -import { buildAddressPool } from '../address-pool' -import { buildAliasRegistry } from '../alias-registry' -import { syncMadeChanges, syncVariableAliases, type SyncableVariable } from '../sync-variable-aliases' - -const vppCaps = { ...RUNTIME_V4_CAPABILITIES, vppIo: true } - -function buildRegistryFromVpp( - entries: Array<{ iecAddress: string; alias?: string; slot: number; channelName: string }>, -) { - const pool = buildAddressPool({ vendorIoMapping: { entries } }, vppCaps) - return buildAliasRegistry(pool) -} - -const VAR = (overrides: Partial): SyncableVariable => ({ - name: 'v', - location: '', - ...overrides, -}) - -describe('syncVariableAliases', () => { - it('returns variables unchanged and an empty report when none have aliases or matching addresses', () => { - const registry = buildRegistryFromVpp([]) - const vars = [VAR({ name: 'x', location: '%IW0' })] - const result = syncVariableAliases(vars, registry) - expect(result.variables).toEqual(vars) - expect(result.report.adopted).toEqual([]) - expect(result.report.refreshed).toEqual([]) - expect(result.report.orphaned).toEqual([]) - }) - - it("adopts an alias when a variable's location matches a registry entry (self-upgrade)", () => { - const registry = buildRegistryFromVpp([ - { iecAddress: '%QX0.0', alias: 'conveyor_motor', slot: 1, channelName: 'DO1' }, - ]) - const vars = [VAR({ name: 'motor', location: '%QX0.0' })] - const result = syncVariableAliases(vars, registry) - expect(result.variables[0].alias).toBe('conveyor_motor') - expect(result.variables[0].location).toBe('%QX0.0') - expect(result.report.adopted).toEqual([{ varName: 'motor', alias: 'conveyor_motor', address: '%QX0.0' }]) - }) - - it('refreshes location when the alias has moved to a new address', () => { - const registry = buildRegistryFromVpp([ - { iecAddress: '%QX1.5', alias: 'conveyor_motor', slot: 3, channelName: 'DO1' }, - ]) - const vars = [VAR({ name: 'motor', location: '%QX0.0', alias: 'conveyor_motor' })] - const result = syncVariableAliases(vars, registry) - expect(result.variables[0].location).toBe('%QX1.5') - expect(result.variables[0].alias).toBe('conveyor_motor') - expect(result.report.refreshed).toEqual([ - { varName: 'motor', alias: 'conveyor_motor', oldAddress: '%QX0.0', newAddress: '%QX1.5' }, - ]) - }) - - it('leaves location and alias intact when the alias is still bound to the same address', () => { - const registry = buildRegistryFromVpp([{ iecAddress: '%IW3', alias: 'tank_level', slot: 2, channelName: 'AI1' }]) - const vars = [VAR({ name: 'tank', location: '%IW3', alias: 'tank_level' })] - const result = syncVariableAliases(vars, registry) - expect(result.variables).toEqual(vars) - expect(result.report.refreshed).toEqual([]) - }) - - it('orphans clear the stale location but keep the alias for the UI warning glyph', () => { - const registry = buildRegistryFromVpp([]) - const vars = [VAR({ name: 'ghost', location: '%QX2.0', alias: 'removed_module' })] - const result = syncVariableAliases(vars, registry) - // `location` is cleared so the ST / XML emitters don't bake an - // `AT %QX2.0` for an alias whose producer is no longer active. - // `alias` is kept so the variable cell renders the orphan glyph - // and tooltip; the previous address is preserved in the report's - // `lastKnownAddress` field for the tooltip + undo affordance. - expect(result.variables[0].location).toBe('') - expect(result.variables[0].alias).toBe('removed_module') - expect(result.report.orphaned).toEqual([{ varName: 'ghost', alias: 'removed_module', lastKnownAddress: '%QX2.0' }]) - expect(result.report.adopted).toEqual([]) - expect(result.report.refreshed).toEqual([]) - }) - - it('handles a mix of adopted, refreshed, orphaned, and untouched variables in one pass', () => { - const registry = buildRegistryFromVpp([ - { iecAddress: '%IW1', alias: 'pressure', slot: 1, channelName: 'AI1' }, - { iecAddress: '%QX3.2', alias: 'valve_open', slot: 2, channelName: 'DO1' }, - ]) - const vars: SyncableVariable[] = [ - VAR({ name: 'adopted', location: '%IW1' }), - VAR({ name: 'refreshed', location: '%QX0.0', alias: 'valve_open' }), - VAR({ name: 'orphaned', location: '%QW9', alias: 'gone' }), - VAR({ name: 'untouched', location: '%MW100' }), - ] - const result = syncVariableAliases(vars, registry) - expect(result.variables[0]).toMatchObject({ name: 'adopted', alias: 'pressure', location: '%IW1' }) - expect(result.variables[1]).toMatchObject({ name: 'refreshed', alias: 'valve_open', location: '%QX3.2' }) - // Orphaned variables retain their alias (for the UI warning) but - // get their location cleared — see the "orphans clear the stale - // location" case above for the standalone version of this rule. - expect(result.variables[2]).toMatchObject({ name: 'orphaned', alias: 'gone', location: '' }) - expect(result.variables[3]).toMatchObject({ name: 'untouched', location: '%MW100' }) - - expect(result.report.adopted).toHaveLength(1) - expect(result.report.refreshed).toHaveLength(1) - expect(result.report.orphaned).toHaveLength(1) - }) - - it('refresh is producer-agnostic: alias name wins over IEC address for VPP, Modbus, and EtherCAT alike', () => { - // The registry/sync layer doesn't care which producer staked a - // claim — `byAlias` is a flat name->address map. If the alias - // moves, every variable bound to that name follows. Build one - // registry seeded from all three producer kinds and verify a - // refresh fires for each. - const pool = buildAddressPool( - { - // VPP: alias "tank_level" relocated from %IW2 to %IW10. - vendorIoMapping: { - entries: [{ iecAddress: '%IW10', alias: 'tank_level', slot: 1, channelName: 'AI0' }], - }, - remoteDevices: [ - { - name: 'modbus-slave', - // Modbus: alias "temp" relocated from %IW0 to %IW20. - modbusTcpConfig: { - ioGroups: [ - { - id: 'g1', - ioPoints: [{ id: 'p1', iecLocation: '%IW20', alias: 'temp' }], - }, - ], - }, - }, - { - name: 'ec-master', - // EtherCAT: alias "estop" relocated from %IX0.0 to %IX2.3. - ethercatConfig: { - devices: [ - { - name: 'coupler', - channelMappings: [{ channelId: 'ch-0', iecLocation: '%IX2.3', alias: 'estop' }], - }, - ], - }, - }, - ], - }, - { ...RUNTIME_V4_CAPABILITIES, vppIo: true, modbusTcpRemote: true, ethercat: true }, - ) - const registry = buildAliasRegistry(pool) - - const vars: SyncableVariable[] = [ - VAR({ name: 'tank_level_var', location: '%IW2', alias: 'tank_level' }), - VAR({ name: 'temperature', location: '%IW0', alias: 'temp' }), - VAR({ name: 'estop_var', location: '%IX0.0', alias: 'estop' }), - ] - const result = syncVariableAliases(vars, registry) - - expect(result.variables[0]).toMatchObject({ name: 'tank_level_var', alias: 'tank_level', location: '%IW10' }) - expect(result.variables[1]).toMatchObject({ name: 'temperature', alias: 'temp', location: '%IW20' }) - expect(result.variables[2]).toMatchObject({ name: 'estop_var', alias: 'estop', location: '%IX2.3' }) - expect(result.report.refreshed).toHaveLength(3) - expect(result.report.adopted).toEqual([]) - expect(result.report.orphaned).toEqual([]) - }) - - it('preserves carry-through fields (type, class, etc.) on changed variables', () => { - const registry = buildRegistryFromVpp([{ iecAddress: '%QX0.0', alias: 'motor', slot: 1, channelName: 'DO1' }]) - const vars = [ - { - name: 'motor', - location: '%QX0.0', - type: { definition: 'base-type', value: 'BOOL' }, - class: 'local', - documentation: '', - } as SyncableVariable, - ] - const result = syncVariableAliases(vars, registry) - expect(result.variables[0]).toMatchObject({ - alias: 'motor', - type: { definition: 'base-type', value: 'BOOL' }, - class: 'local', - }) - }) -}) - -describe('syncMadeChanges', () => { - it('returns true when adopted is non-empty', () => { - expect( - syncMadeChanges({ adopted: [{ varName: 'v', alias: 'a', address: 'x' }], refreshed: [], orphaned: [] }), - ).toBe(true) - }) - - it('returns true when refreshed is non-empty', () => { - expect( - syncMadeChanges({ - adopted: [], - refreshed: [{ varName: 'v', alias: 'a', oldAddress: 'x', newAddress: 'y' }], - orphaned: [], - }), - ).toBe(true) - }) - - it('returns false when only orphans are reported (no state write needed)', () => { - expect( - syncMadeChanges({ - adopted: [], - refreshed: [], - orphaned: [{ varName: 'v', alias: 'a', lastKnownAddress: 'x' }], - }), - ).toBe(false) - }) - - it('returns false when nothing happened', () => { - expect(syncMadeChanges({ adopted: [], refreshed: [], orphaned: [] })).toBe(false) - }) -}) diff --git a/src/middleware/shared/utils/iec-address/alias-registry.ts b/src/middleware/shared/utils/iec-address/alias-registry.ts index 65d8716b1..2c3fc2794 100644 --- a/src/middleware/shared/utils/iec-address/alias-registry.ts +++ b/src/middleware/shared/utils/iec-address/alias-registry.ts @@ -1,9 +1,7 @@ /** * Alias registry — derived index over the address pool that lets the - * editor answer two questions: - * - * - "Given an alias name, which address does it currently point to?" - * - "Given an address, does it have an alias the user gave it?" + * editor answer: "Given an alias name, which address does it currently + * point to?" * * Built from the pool, so it inherits the pool's target-scoping: only * aliases attached to active producers appear. No new storage, no new @@ -12,10 +10,7 @@ * Uniqueness rule: alias names are intended to be unique system-wide * (across all producers). When the same alias name is declared by two * sources, first-wins (matching pool encounter order) and the conflict - * is recorded in `duplicateAliases`. The expected response is for the - * caller to invoke the address-sync flow to reassign whichever - * variable is now orphaned — see Phase 4 of the alias-source-of-truth - * work for the sync engine. + * is recorded in `duplicateAliases`. */ import type { AddressPool, SourceRef } from './address-pool' @@ -29,10 +24,6 @@ export interface AliasEntry { export interface AliasRegistry { /** Alias name -> the first claim that declared it. */ byAlias: ReadonlyMap - /** Address -> the alias attached to its claim, when present. Every - * address that has an alias appears here; addresses without an - * alias are simply absent. */ - byAddress: ReadonlyMap /** Alias names declared by more than one producer. First entry * in `byAlias` wins; the rest were silently dropped from the * primary index. Reported here so the caller can resync. */ @@ -41,7 +32,6 @@ export interface AliasRegistry { export function buildAliasRegistry(pool: AddressPool): AliasRegistry { const byAlias = new Map() - const byAddress = new Map() const duplicateAliases: string[] = [] for (const claim of pool.byAddress.values()) { @@ -51,8 +41,6 @@ export function buildAliasRegistry(pool: AddressPool): AliasRegistry { address: claim.address, source: claim.source, } - // Pool guarantees per-address uniqueness, so this is safe. - byAddress.set(claim.address, entry) if (byAlias.has(claim.alias)) { if (!duplicateAliases.includes(claim.alias)) { @@ -63,7 +51,7 @@ export function buildAliasRegistry(pool: AddressPool): AliasRegistry { byAlias.set(claim.alias, entry) } - return { byAlias, byAddress, duplicateAliases } + return { byAlias, duplicateAliases } } /** Look up the canonical address for a given alias. Returns undefined @@ -73,14 +61,6 @@ export function resolveAlias(registry: AliasRegistry, alias: string): string | u return registry.byAlias.get(alias)?.address } -/** Look up the alias attached to a given address. Used by the - * variable cell's auto-adopt path: if a variable's raw `location` - * matches a current alias, the variable cell promotes the alias - * name onto its display. */ -export function aliasForAddress(registry: AliasRegistry, address: string): string | undefined { - return registry.byAddress.get(address)?.alias -} - /** True when the alias name is not currently in use by any producer. * Used by the system-wide uniqueness validator (Phase 5) — newly * typed alias names go through this check before being committed. */ @@ -168,9 +148,9 @@ export function describeSource(source: SourceRef): string { * * Every IO-mapping screen / pin-mapping table / remote-device editor * MUST call this before persisting a new alias, otherwise the registry - * silently first-wins on duplicates and the variables bound to the - * losing entry are subsequently collapsed by `syncVariableAliases` to - * the winner's address. See the architectural notes at the top of + * silently first-wins on duplicates and the losing entry's alias becomes + * unresolvable — every variable bound to it silently goes unlocated at + * compile time. See the architectural notes at the top of * `address-pool.ts` for the full reservation chain. */ export function validateAliasEdit( diff --git a/src/middleware/shared/utils/iec-address/index.ts b/src/middleware/shared/utils/iec-address/index.ts index bfd91d716..ef58658a7 100644 --- a/src/middleware/shared/utils/iec-address/index.ts +++ b/src/middleware/shared/utils/iec-address/index.ts @@ -17,7 +17,6 @@ export { export { type AliasEditValidation, type AliasEntry, - aliasForAddress, type AliasRegistry, buildAliasRegistry, describeSource, @@ -25,10 +24,3 @@ export { resolveAlias, validateAliasEdit, } from './alias-registry' -export { - type SyncableVariable, - syncMadeChanges, - type SyncReport, - type SyncResult, - syncVariableAliases, -} from './sync-variable-aliases' diff --git a/src/middleware/shared/utils/iec-address/sync-variable-aliases.ts b/src/middleware/shared/utils/iec-address/sync-variable-aliases.ts deleted file mode 100644 index e054a3cf7..000000000 --- a/src/middleware/shared/utils/iec-address/sync-variable-aliases.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Sync variables against the current alias registry. - * - * Three outcomes per variable: - * - **adopted**: variable had no alias bound but its `location` - * matches an aliased address in the registry. - * Sets `alias` to the registry entry's name. - * Self-upgrade path for older projects on load. - * - **refreshed**: variable already has an alias that the registry - * still knows, but the alias now points to a - * different address than the variable's - * `location`. Rewrites `location` to follow the - * alias — this is what keeps variables coherent - * when a producer reorders, shifts, or - * reallocates its addresses. - * - **orphaned**: variable has an alias the registry no longer - * knows about (its producer was removed, or - * target-switched away). Keeps `alias` so the - * UI can show the orphan warning + tooltip, but - * **clears `location`** so the ST / XML emitters - * don't bake the stale `AT %…` into the compile. - * The user can re-bind via the picker; the - * previous address is preserved in the report's - * `lastKnownAddress` field for an undo / tooltip - * affordance. - * - * Pure function: same inputs always produce the same outputs. - * Safe to call from any context — store actions, the pre-compile - * hook, etc. The caller is responsible for applying the returned - * `variables` array back to the project state. - */ - -import type { AliasRegistry } from './alias-registry' - -/** Minimal PLCVariable shape the sync function reads. Lives in this - * module so backend/shared callers don't need to import from - * `backend/shared/types/PLC/open-plc.ts` (which carries Zod - * baggage). Compatible with `PLCVariable` via structural typing — - * the function preserves every field a concrete subtype carries. */ -export interface SyncableVariable { - name: string - location: string - alias?: string -} - -export interface SyncReport { - adopted: Array<{ varName: string; alias: string; address: string }> - refreshed: Array<{ varName: string; alias: string; oldAddress: string; newAddress: string }> - orphaned: Array<{ varName: string; alias: string; lastKnownAddress: string }> -} - -export interface SyncResult { - variables: V[] - report: SyncReport -} - -export function syncVariableAliases( - variables: readonly V[], - registry: AliasRegistry, -): SyncResult { - const report: SyncReport = { adopted: [], refreshed: [], orphaned: [] } - const next: V[] = [] - - for (const variable of variables) { - if (variable.alias) { - const entry = registry.byAlias.get(variable.alias) - if (!entry) { - report.orphaned.push({ - varName: variable.name, - alias: variable.alias, - lastKnownAddress: variable.location, - }) - // Clear the stale location so the compile step (ST + XML - // emitters) doesn't bake an `AT %…` for an address whose - // producer is no longer active. The alias name is retained - // so the variable cell can render the orphan warning and the - // tooltip can surface the last-known address from the report. - // When the user re-binds, `updateVariable`'s auto-adopt path - // re-attaches the alias against the live registry. - next.push({ ...variable, location: '' }) - continue - } - if (entry.address !== variable.location) { - report.refreshed.push({ - varName: variable.name, - alias: variable.alias, - oldAddress: variable.location, - newAddress: entry.address, - }) - next.push({ ...variable, location: entry.address }) - continue - } - // Alias still bound to the same address — no change. - next.push(variable) - continue - } - - // No alias bound: try to auto-adopt by the variable's current - // `location`. This is the self-upgrade path — applied on project - // load so older projects pick up the aliases their producers - // already declare without any user action. - const aliasEntry = registry.byAddress.get(variable.location) - if (aliasEntry) { - report.adopted.push({ - varName: variable.name, - alias: aliasEntry.alias, - address: aliasEntry.address, - }) - next.push({ ...variable, alias: aliasEntry.alias }) - continue - } - - next.push(variable) - } - - return { variables: next, report } -} - -/** True when the sync produced any change. Callers that don't care - * about diagnostics can use this to skip a no-op state write. */ -export function syncMadeChanges(report: SyncReport): boolean { - return report.adopted.length > 0 || report.refreshed.length > 0 -} From 4bd0d9fbc94feaa7f504803d8714003f8af1a2a6 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Jul 2026 16:47:26 -0400 Subject: [PATCH 29/35] test+docs: board-load alias contract + sync CLAUDE.md Replace the removed syncVariableAliases auto-adoption integration test with `alias-location-on-board-load.test.ts` asserting the single-field contract: board-load (setAvailableOptions) never mutates a variable's location, a manual %addr colliding with an alias stays literal, and an alias-bound location resolves to the producer address (or '') only in the compile-ready snapshot. Also note the manual-conflict warning in the CLAUDE.md alias-registry section. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 6 +- .../alias-location-on-board-load.test.ts | 174 ++++++++++++++++++ 2 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 src/frontend/store/__tests__/alias-location-on-board-load.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 42e2d500d..85425eb33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -323,8 +323,10 @@ openplc-web). Pure functions, no IPC, no electron coupling. The variable cell renders `location` verbatim — the alias name when alias-bound, the `%addr` when a manual literal — and shows an amber warning glyph + tooltip when an alias-bound location no longer resolves -(orphaned). Aliases are intended to be unique system-wide; every -IO-mapping / pin / remote-device editor calls the registry's +(orphaned) or when a manual `%addr` collides with an alias another +project variable is bound to (duplicate-location risk). Aliases are +intended to be unique system-wide; every IO-mapping / pin / +remote-device editor calls the registry's `validateAliasEdit(registry, name, ignoring)` gate before persisting a new alias. diff --git a/src/frontend/store/__tests__/alias-location-on-board-load.test.ts b/src/frontend/store/__tests__/alias-location-on-board-load.test.ts new file mode 100644 index 000000000..2b98281cc --- /dev/null +++ b/src/frontend/store/__tests__/alias-location-on-board-load.test.ts @@ -0,0 +1,174 @@ +import { createStore } from 'zustand/vanilla' + +import type { + BoardInfo, + ModbusIOGroup, + PLCPou, + PLCRemoteDevice, + PLCVariable, +} from '../../../middleware/shared/ports/types' +import { createConsoleSlice } from '../slices/console' +import { createDeviceSlice } from '../slices/device' +import { createEditorSlice } from '../slices/editor' +import { createLibrarySlice } from '../slices/library' +import { createProjectSlice } from '../slices/project/slice' +import type { ProjectSliceRoot } from '../slices/project/types' + +/** + * Board-load contract under the single-field variable-location model. + * + * Replaces the old `syncVariableAliases` auto-adoption integration test: that + * suite asserted boards landing (`setAvailableOptions`) would ADOPT a + * producer's alias onto a variable that happened to sit at the same address. + * The single-field model deliberately drops that behaviour — a manual `%addr` + * is always manual, and an alias binding lives verbatim in `location`, + * resolved to a concrete address only at compile time. This file locks in the + * new contract: + * - board-load (`setAvailableOptions`) never mutates a variable's `location`; + * - a manual literal that collides with an alias's address stays literal; + * - an alias-bound `location` keeps the alias name in the store and resolves + * to the producer's address in the compile-ready snapshot (or to '' when + * the alias is no longer declared). + */ + +function makeStore() { + return createStore()((...args) => ({ + ...createProjectSlice(...args), + ...createDeviceSlice(...args), + ...createConsoleSlice(...args), + ...createEditorSlice(...args), + ...createLibrarySlice(...args), + })) +} + +const RUNTIME_V4: BoardInfo = { + compiler: 'openplc-compiler', + core: 'rt-v4', + preview: '', + specs: {}, + capabilities: { + pinMapping: false, + vppIo: false, + modbusTcpRemote: true, + ethercat: true, + modbusTcpServer: true, + opcuaServer: true, + s7Server: true, + debuggerTransports: ['websocket'], + pythonFunctionBlocks: true, + arduinoApiCompletions: false, + hasRuntimeStats: true, + isInProcessSimulator: false, + directUsbUpload: false, + }, +} + +/** Land the Runtime v4 board — the project-load "boards resolve" moment that + * used to trigger the alias auto-sync. */ +function landBoards(store: ReturnType) { + store.getState().deviceActions.setAvailableOptions({ + availableBoards: new Map([['OpenPLC Runtime v4', RUNTIME_V4]]), + }) + store.getState().deviceActions.setDeviceBoard('OpenPLC Runtime v4') +} + +function makeRemoteDevice(name: string): PLCRemoteDevice { + return { + name, + protocol: 'modbus-tcp', + modbusTcpConfig: { host: '127.0.0.1', port: 502, slaveId: 1, timeout: 1000, ioGroups: [] }, + } +} + +function makeIOGroup(id: string, functionCode: ModbusIOGroup['functionCode'] = '3', length = 2): ModbusIOGroup { + return { + id, + name: `group-${id}`, + functionCode, + cycleTime: 100, + offset: '0', + length, + errorHandling: 'keep-last-value', + ioPoints: [], + } +} + +function intVar(name: string, location: string): PLCVariable { + return { name, class: 'local', type: { definition: 'base-type', value: 'INT' }, location, documentation: '' } +} + +function pou(name: string, vars: PLCVariable[]): PLCPou { + return { + name, + pouType: 'program', + interface: { variables: vars }, + body: { language: 'st', value: '' }, + documentation: '', + } +} + +/** Add a remote device to the store without clobbering the rest of the project. */ +function addRemoteDevice(store: ReturnType, device: PLCRemoteDevice) { + const current = store.getState().project + store.setState({ + project: { ...current, data: { ...current.data, remoteDevices: [...(current.data.remoteDevices ?? []), device] } }, + }) +} + +/** Replace the project's POUs, preserving remoteDevices/config. */ +function setPous(store: ReturnType, pous: PLCPou[]) { + const current = store.getState().project + store.setState({ project: { ...current, data: { ...current.data, pous } } }) +} + +/** Stand up a Modbus remote device whose first point (%IW0) is aliased "flow". */ +function seedAliasedModbusPoint(store: ReturnType): void { + addRemoteDevice(store, makeRemoteDevice('Dev1')) + store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g1', '3', 2)) // %IW0, %IW1 + const pointId = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints![0].id + store.getState().projectActions.updateIOPointAlias('Dev1', 'g1', pointId, 'flow') // flow → %IW0 +} + +describe('alias location on board load (single-field model)', () => { + it('does NOT auto-adopt an alias onto a manually located variable when boards (re-)land', () => { + const store = makeStore() + landBoards(store) + seedAliasedModbusPoint(store) // "flow" is a live alias at %IW0 + setPous(store, [pou('main', [intVar('reading', '%IW0')])]) // variable manually located at that same address + + // Re-land boards: the project-load resolve point that used to auto-sync. + landBoards(store) + + // New contract: the manual literal is untouched — never promoted to "flow". + expect(store.getState().project.data.pous[0].interface!.variables![0].location).toBe('%IW0') + }) + + it('keeps an alias-bound location verbatim in the store and resolves it at compile time', () => { + const store = makeStore() + landBoards(store) + seedAliasedModbusPoint(store) + setPous(store, [pou('main', [intVar('reading', 'flow')])]) // bound by alias name + + landBoards(store) + + // The store holds the alias name verbatim (no mutation on board-load)... + expect(store.getState().project.data.pous[0].interface!.variables![0].location).toBe('flow') + // ...and the compile-ready snapshot resolves it to the producer's address. + const compileReady = store.getState().projectActions.getCompileReadyProjectData() + expect(compileReady.pous[0].interface!.variables![0].location).toBe('%IW0') + }) + + it('resolves an alias-bound location to empty at compile time when no producer declares it', () => { + const store = makeStore() + landBoards(store) + seedAliasedModbusPoint(store) // only "flow" exists; "ghost" does not + setPous(store, [pou('main', [intVar('reading', 'ghost')])]) + + landBoards(store) + + // Store keeps the (orphaned) alias name; compile resolution drops it to unlocated. + expect(store.getState().project.data.pous[0].interface!.variables![0].location).toBe('ghost') + const compileReady = store.getState().projectActions.getCompileReadyProjectData() + expect(compileReady.pous[0].interface!.variables![0].location).toBe('') + }) +}) From a8d49e3bdbab9f3ac28ba25db2a07c66d845107f Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Jul 2026 21:00:55 -0400 Subject: [PATCH 30/35] fix(debug): resolve alias-bound locations before debug compilation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The debug-compile path (handleDebugger) passed raw project data with unresolved alias-name locations to compileForDebug, so the generated _config.st emitted `AT ` (e.g. `start_pb AT in_0`) which STruC++ rejects with `Expected DirectAddress, found identifier`. The build/upload paths already resolve aliases → addresses via getCompileReadyProjectData; wire the same pre-compile snapshot into the debug-compile call. Regression from the single-field variable-location refactor (#915 / #575): resolution was added to build/upload but the debug path was missed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../_organisms/workspace-activity-bar/default.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index 0f48bd424..a11b96cda 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -791,10 +791,14 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa return } - // Debug compilation + // Debug compilation. Resolve alias-bound locations to concrete + // addresses first (same pre-compile snapshot the build/upload paths + // use) — the compiler only understands `%…` literals, not alias names. + const freshProjectData = useOpenPLCStore.getState().projectActions.getCompileReadyProjectData() consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Starting debug compilation...' }) - const debugCompileResult = await compiler.compileForDebug({ projectData, boardTarget, projectPath }, (event) => - logCompilerEvent(event, consoleActions.addLog), + const debugCompileResult = await compiler.compileForDebug( + { projectData: freshProjectData, boardTarget, projectPath }, + (event) => logCompilerEvent(event, consoleActions.addLog), ) if (!debugCompileResult.success) { consoleActions.addLog({ From 090ebd08cac12663d9011235282ace215742e03f Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Jul 2026 23:36:07 -0400 Subject: [PATCH 31/35] fix(alias): keep location-warning glyph on selected rows; orphan (not blank) bound vars when an alias is cleared Two variable-location warning bugs: 1. The location cell rendered the warning glyph only in its display branch, so selecting the row (which swaps to the editable combobox) hid the glyph while the conflict/orphan still stood. Render the glyph in both the selected and display branches, in the program and global variables tables. 2. Clearing an alias at its producer cascaded the empty string onto bound variables via renameAlias, silently wiping their location. Now an empty rename is treated as a deletion: bound variables keep the (now-missing) alias name and surface as orphaned (amber warning), matching the behavior of deleting the whole producer/device. Also commit the VPP io-table / module-slots alias inputs on blur (local state) instead of per keystroke, so a single rename reaches the store rather than one per intermediate string while editing. Bump editor to 4.2.8. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- .../vendor-screen/layouts/io-table-layout.tsx | 36 ++++++++++--- .../layouts/module-slots-layout.tsx | 36 ++++++++++--- .../global-variables-table/editable-cell.tsx | 44 +++++++++------- .../variables-table/editable-cell.tsx | 50 +++++++++++-------- .../store/__tests__/project-slice.test.ts | 9 ++-- src/frontend/store/slices/project/slice.ts | 14 ++++-- 7 files changed, 135 insertions(+), 56 deletions(-) diff --git a/package.json b/package.json index 3d89bc19d..8582d6414 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.7", + "version": "4.2.8", "license": "GPL-3.0", "author": { "name": "Autonomy Logic" diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/io-table-layout.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/io-table-layout.tsx index eb741df5d..fc1b1dc9f 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/io-table-layout.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/io-table-layout.tsx @@ -21,6 +21,33 @@ type IoTableLayoutProps = { moduleSystem: ModuleSystem } +/** + * Alias cell with local state so the value commits on blur (focus change), + * not on every keystroke. Committing per keystroke fires the alias-rename + * cascade for every intermediate string while editing — e.g. clearing "flow" + * would cascade through "flo", "fl", "f" onto bound variables. Committing on + * blur means a single rename (old → final) reaches the store, and clearing the + * field to empty leaves bound variables orphaned rather than partially renamed. + */ +function AliasInputCell({ value, onCommit }: { value: string; onCommit: (next: string) => void }) { + const [local, setLocal] = useState(value) + useEffect(() => { + setLocal(value) + }, [value]) + return ( + setLocal(e.target.value)} + onBlur={() => { + if (local !== value) onCommit(local) + }} + placeholder='Alias...' + className='h-[26px] w-full rounded border border-neutral-100 bg-white px-2 font-caption text-cp-sm text-neutral-850 outline-none placeholder:text-neutral-400 focus:border-brand-medium-dark dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-300 dark:placeholder:text-neutral-600' + /> + ) +} + function IoTableLayout({ section, moduleSystem }: IoTableLayoutProps) { // See `getSectionPersistenceKey` in ../index.tsx — the single // source of truth for the per-section storage key. Falls back to @@ -335,12 +362,9 @@ function IoTableLayout({ section, moduleSystem }: IoTableLayoutProps) { {entry.iecAddress}
diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx index ab1759ef6..36008314e 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/module-slots-layout.tsx @@ -88,6 +88,33 @@ type ConfigScreenDefinition = { // Walk a screen definition and return every field that contributes // to the configuration form. The screen JSON is a vendor artifact — // be tolerant of missing/oddly-shaped fragments rather than crash. +/** + * Alias cell with local state so the value commits on blur (focus change), + * not on every keystroke. Committing per keystroke fires the alias-rename + * cascade for every intermediate string while editing — e.g. clearing "flow" + * would cascade through "flo", "fl", "f" onto bound variables. Committing on + * blur means a single rename (old → final) reaches the store, and clearing the + * field to empty leaves bound variables orphaned rather than partially renamed. + */ +function AliasInputCell({ value, onCommit }: { value: string; onCommit: (next: string) => void }) { + const [local, setLocal] = useState(value) + useEffect(() => { + setLocal(value) + }, [value]) + return ( + setLocal(e.target.value)} + onBlur={() => { + if (local !== value) onCommit(local) + }} + placeholder='Alias...' + className='h-[26px] w-full rounded border border-neutral-100 bg-white px-2 font-caption text-cp-sm text-neutral-850 outline-none placeholder:text-neutral-400 focus:border-brand-medium-dark dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-300 dark:placeholder:text-neutral-600' + /> + ) +} + function collectConfigFields(def: ConfigScreenDefinition | undefined | null): ConfigFieldDef[] { if (!def?.sections) return [] const out: ConfigFieldDef[] = [] @@ -993,12 +1020,9 @@ function ModuleSlotsLayout({ section, moduleSystem }: ModuleSlotsLayoutProps) { {entry.iecAddress} diff --git a/src/frontend/components/_molecules/global-variables-table/editable-cell.tsx b/src/frontend/components/_molecules/global-variables-table/editable-cell.tsx index c852da748..10b66a394 100644 --- a/src/frontend/components/_molecules/global-variables-table/editable-cell.tsx +++ b/src/frontend/components/_molecules/global-variables-table/editable-cell.tsx @@ -389,18 +389,33 @@ const EditableLocationCell = ({ ? `Address ${cellValue} conflicts with alias "${locationConflict.aliasName}" assigned to "${locationConflict.variableName}". Two variables cannot share a location.` : undefined + // The warning glyph must stay visible whether or not the row is selected. + // The editable branch renders a combobox; previously the glyph lived only in + // the display branch, so an active conflict/orphan looked unflagged the + // moment the row was selected. Render it in both branches. + const warningGlyph = + hasLocationWarning && warningTooltip ? ( + + ) : null + return editable ? ( - { - onBlur(value) - }} - selectValues={selectableValues()} - selected={editable} - openOnSelectedOption - canAddACustomOption - /> +
+ {warningGlyph} + { + onBlur(value) + }} + selectValues={selectableValues()} + selected={editable} + openOnSelectedOption + canAddACustomOption + /> +
) : (
- {hasLocationWarning && warningTooltip && ( - - )} + {warningGlyph} + ) : null + return selected ? ( - { - onBlur(value) - }} - selectValues={selectableValues()} - selected={selected} - openOnSelectedOption - canAddACustomOption - // An orphaned alias name resolves to nothing at compile; keep "Clear" - // enabled even when empty so the user can drop it. - allowClearWhenEmpty={id === 'location' && isOrphaned} - /> +
+ {warningGlyph} + { + onBlur(value) + }} + selectValues={selectableValues()} + selected={selected} + openOnSelectedOption + canAddACustomOption + // An orphaned alias name resolves to nothing at compile; keep "Clear" + // enabled even when empty so the user can drop it. + allowClearWhenEmpty={id === 'location' && isOrphaned} + /> +
) : (
- {hasLocationWarning && warningTooltip && ( - - )} + {warningGlyph} { expect(store.getState().project.data.pous[0].interface!.variables![0].location).toBe('relay') }) - it('clears bound variable locations when the alias is renamed to empty', () => { + it('leaves bound variable locations untouched (orphaned) when the alias is cleared', () => { seedPou(store, makePou('Prog', 'program', [locVar('bound', 'relay_1')])) const result = store.getState().projectActions.renameAlias('relay_1', '') - expect(result.renamed).toBe(1) - expect(store.getState().project.data.pous[0].interface!.variables![0].location).toBe('') + // Clearing an alias is a deletion, not a rename: the bound variable keeps + // the now-missing alias name and orphans (surfaces the warning glyph), + // rather than being silently wiped. Same behavior as deleting the device. + expect(result.renamed).toBe(0) + expect(store.getState().project.data.pous[0].interface!.variables![0].location).toBe('relay_1') }) it('is a no-op when the old alias is empty', () => { diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index c7078a7fa..67c1949aa 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -887,6 +887,14 @@ const createProjectSlice: StateCreator = // mapping screen on first-time alias write where there's no // prior text to cascade. if (trimmedOld.length === 0) return { renamed: 0 } + // Clearing an alias at its producer (empty newAlias) is a DELETION, not a + // rename. We deliberately do NOT cascade the empty string onto bound + // variables: they keep the old alias name in `location`, so they surface + // as orphaned (amber warning) and resolve to unlocated at compile time — + // exactly like deleting the whole producer/device. Cascading '' here + // would silently wipe the user's I/O mapping with no trace, which is the + // bug this guard prevents. + if (trimmedNew.length === 0) return { renamed: 0 } // True no-op only when the name is unchanged. A CASE change must still // cascade — the alias registry is case-sensitive, so `location` has to // match the producer alias exactly to resolve at compile time. @@ -899,9 +907,9 @@ const createProjectSlice: StateCreator = // and never equal a (non-`%`) alias name, so they're left untouched. if (variable.location !== trimmedOld) return variable renamed += 1 - // When the producer CLEARS its alias (newAlias empty), the bound - // variable becomes unlocated — `location` clears, matching the - // compile-time "missing alias → empty location" rule. + // A genuine rename (both names non-empty): the bound variable follows + // to the new alias so it stays located. (The empty-newAlias case is + // handled above and never reaches here.) return { ...variable, location: trimmedNew } } From 6ca0fa63c260f5d66cf7b979d5efc8b22b2376b9 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Sat, 4 Jul 2026 16:34:39 -0400 Subject: [PATCH 32/35] fix(ai): mirror shared-surface AI autocomplete fixes from openplc-web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-identical mirror of the shared frontend / middleware changes that land in openplc-web (PR fix/ai-autocomplete-empty-completions), kept in sync by the mirror gate: - monaco editor: `editContext: false` (Safari Tab-accept fix) and `inlineSuggest.experimental.showOnSuggestConflict: 'always'` so AI ghost text renders alongside the STruC++ LSP suggest widget. - ai-port: new `completion_empty` telemetry event name. The empty-completion behavioural fixes themselves live in the web-only AI adapter (context builder + inline completion provider) and the autonomy-edge backend, so no desktop release is required — this only keeps the shared surface identical. Also documents the app-version bump procedure in CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 21 ++++++++++++++++ .../[workspace]/editor/monaco/index.tsx | 24 ++++++++++++++++++- src/middleware/shared/ports/ai-port.ts | 1 + 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 85425eb33..516aa0764 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -263,6 +263,27 @@ When adding new code to covered directories, you must add corresponding tests to ## Important Patterns +### When bumping the app version: +`APP_VERSION` in `src/frontend/data/constants/app-version.ts` is the **single +source of truth** for the human-facing version, shared **byte-for-byte** between +openplc-editor and openplc-web (enforced by the mirror gate / `compare-surfaces.py`). +The About modal renders it directly; the web build writes it into `version.json`. + +**Bump `APP_VERSION` — never `package.json` alone.** Make the identical one-line +edit in BOTH repos, and set `package.json.version` to the same value in both so +they can't drift. Roles: `APP_VERSION` is what the user sees in the About dialog; +`package.json.version` is what electron-builder stamps on the desktop binary and +what the release tag `vX.Y.Z` must match. Bumping only `package.json` leaves the +About dialog stuck on the old version — **this mistake shipped 4.2.7 and 4.2.8 +with About still showing 4.2.6.** If the two ever disagree, `APP_VERSION` is +authoritative; fix it to match. + +Release order: bump `APP_VERSION` + `package.json` (both repos, same value) → PR +to `development` → merge → promote `development`→`main` on both → tag `vX.Y.Z` on +the editor's `main` to trigger the "Build and Release" workflow. Web auto-deploys +on its `main` push. (Ideally `package.json.version` should be derived from +`APP_VERSION` in the release workflow so a single bump can never drift.) + ### When adding a new port: 1. Define the interface in `src/middleware/shared/ports/` 2. Add it to `PlatformPorts` in `src/middleware/shared/providers/types.ts` diff --git a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx index b8b939be7..f17ca00a4 100644 --- a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx @@ -1245,6 +1245,17 @@ void loop() minimap: { enabled: false }, dropIntoEditor: { enabled: true }, readOnly: isDebuggerVisible, + // Force Monaco's classic hidden-
NameTypeRole + NameTypeRole Actions
- handleAliasChange(entry.globalIndex, e.target.value)} - placeholder='Alias...' - className='h-[26px] w-full rounded border border-neutral-100 bg-white px-2 font-caption text-cp-sm text-neutral-850 outline-none placeholder:text-neutral-400 focus:border-brand-medium-dark dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-300 dark:placeholder:text-neutral-600' + handleAliasChange(entry.globalIndex, next)} />
- handleAliasChange(entry.slot, entry.channelName, e.target.value)} - placeholder='Alias...' - className='h-[26px] w-full rounded border border-neutral-100 bg-white px-2 font-caption text-cp-sm text-neutral-850 outline-none placeholder:text-neutral-400 focus:border-brand-medium-dark dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-300 dark:placeholder:text-neutral-600' + handleAliasChange(entry.slot, entry.channelName, next)} />