diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 61e4ab068..3cc7f2c52 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -10,6 +10,7 @@ import { join, resolve as pathResolve, sep as pathSep } from 'node:path' import { resolveTrustedKeysArtifact } from '@root/backend/shared/compile/steps/generate-trusted-keys' import type { VppModbusScreenState } from '@root/backend/shared/compile/steps/modbus-defines' +import { vppStateFromModbusSlaveConfig } from '@root/frontend/utils/modbus/serial-link-config' import { resolveBoardSelection } from '@root/backend/shared/compile/steps/resolve-board-selection' import { execRecipeArgv, substitutePlaceholders, tokenizeRecipe } from './recipe-exec' @@ -2953,21 +2954,34 @@ class CompilerModule { let vppModbusState: VppModbusScreenState | undefined if (boardRuntime !== 'simulator' && boardRuntime !== 'openplc-compiler') { const devicesConfigurationFilePath = join(normalizedProjectPath, 'devices', 'configuration.json') - try { - const deviceConfig = await CompilerModule.readJSONFile(devicesConfigurationFilePath) - const vendorScreenData = deviceConfig.vendorScreenData ?? {} - vppModbusState = { - serial: vendorScreenData['serial'] as VppModbusScreenState['serial'], - network: vendorScreenData['network'] as VppModbusScreenState['network'], - modbus_rtu: vendorScreenData['modbus_rtu'] as VppModbusScreenState['modbus_rtu'], - modbus_tcp: vendorScreenData['modbus_tcp'] as VppModbusScreenState['modbus_tcp'], + // DOPE-442 moved this configuration onto the project's Modbus server. A + // server that carries it wins; a project that has not been edited since + // the move still has only the VPP screen state, and compiles from it + // exactly as before. Nothing is rewritten either way — the old keys stay + // on disk, so rolling the editor back keeps working. + const unifiedModbus = projectData.servers?.find( + (server) => + server.protocol === 'modbus-tcp' && (server.modbusSlaveConfig?.rtu || server.modbusSlaveConfig?.tcpLink), + )?.modbusSlaveConfig + + if (unifiedModbus) { + vppModbusState = vppStateFromModbusSlaveConfig(unifiedModbus) + } else + try { + const deviceConfig = await CompilerModule.readJSONFile(devicesConfigurationFilePath) + const vendorScreenData = deviceConfig.vendorScreenData ?? {} + vppModbusState = { + serial: vendorScreenData['serial'] as VppModbusScreenState['serial'], + network: vendorScreenData['network'] as VppModbusScreenState['network'], + modbus_rtu: vendorScreenData['modbus_rtu'] as VppModbusScreenState['modbus_rtu'], + modbus_tcp: vendorScreenData['modbus_tcp'] as VppModbusScreenState['modbus_tcp'], + } + } catch { + // No configuration.json — leave undefined so the shared + // pipeline skips the Modbus block entirely (matches the + // pre-VPP behaviour for boards that never had a comms + // config persisted). } - } catch { - // No configuration.json — leave undefined so the shared - // pipeline skips the Modbus block entirely (matches the - // pre-VPP behaviour for boards that never had a comms - // config persisted). - } } // For Arduino VPP targets with a modular backplane, bake the diff --git a/src/backend/shared/compile/__tests__/modbus-defines-unified.test.ts b/src/backend/shared/compile/__tests__/modbus-defines-unified.test.ts new file mode 100644 index 000000000..96da7ec41 --- /dev/null +++ b/src/backend/shared/compile/__tests__/modbus-defines-unified.test.ts @@ -0,0 +1,169 @@ +/** + * Equivalence between the retired VPP Modbus screen and the unified server + * configuration that replaced it (DOPE-442). + * + * The firmware contract is a set of macro names `ModbusSlave.cpp` reads, so the + * only question that matters about the move is whether the same project emits + * the same macros. Every case here feeds one legacy `vendorScreenData` state + * through both paths — straight into the emitter, and migrated into the new + * model and back out through the adapter — and asserts the outputs match. + * + * The same comparison covers the debug baud and the debug slave id, where a + * divergence would not break the build at all: it would make Connect report + * "No Firmware Detected" on a healthy board. + */ + +import type { VppModbusScreenState } from '../../../../frontend/utils/modbus/serial-link-config' +import { + migrateVendorScreenModbus, + vppStateFromModbusSlaveConfig, +} from '../../../../frontend/utils/modbus/serial-link-config' +import { generateModbusDefines, resolveDebugBaud, resolveDebugSlave } from '../steps/modbus-defines' + +/** Legacy screen states, one per branch the emitter can take. */ +const LEGACY_STATES: Record> = { + 'rtu on the default port': { + modbus_rtu: { + enabled: true, + rtu_interface: 'Serial', + rtu_baud_rate: '19200', + rtu_slave_id: 7, + enable_rs485_en_pin: false, + }, + }, + 'rtu on a secondary port': { + modbus_rtu: { enabled: true, rtu_interface: 'Serial2', rtu_baud_rate: '57600', rtu_slave_id: 3 }, + }, + 'rtu with an rs485 driver-enable pin': { + modbus_rtu: { + enabled: true, + rtu_interface: 'Serial', + rtu_baud_rate: '115200', + rtu_slave_id: 1, + enable_rs485_en_pin: true, + rtu_rs485_en_pin: 'D5', + }, + }, + 'rtu asking for rs485 without naming a pin': { + modbus_rtu: { enabled: true, enable_rs485_en_pin: true }, + }, + 'rtu toggled on and nothing else touched': { + modbus_rtu: { enabled: true }, + }, + 'rtu left off but configured': { + modbus_rtu: { enabled: false, rtu_interface: 'Serial1', rtu_baud_rate: '9600', rtu_slave_id: 42 }, + }, + 'tcp over ethernet with a static host': { + modbus_tcp: { + enabled: true, + tcp_interface: 'Ethernet', + tcp_mac_address: 'de:ad:be:ef:fe:ed', + enable_dhcp: false, + ip_address: '192.168.0.50', + gateway: '192.168.0.1', + subnet: '255.255.255.0', + dns: '8.8.8.8', + }, + }, + 'tcp over ethernet on dhcp': { + modbus_tcp: { enabled: true, tcp_interface: 'Ethernet', enable_dhcp: true }, + }, + 'tcp over wifi': { + modbus_tcp: { + enabled: true, + tcp_interface: 'Wi-Fi', + tcp_wifi_ssid: 'plant-floor', + tcp_wifi_password: 'hunter2', + enable_dhcp: true, + }, + }, + 'tcp toggled on and nothing else touched': { + modbus_tcp: { enabled: true }, + }, + 'both transports on': { + modbus_rtu: { enabled: true, rtu_interface: 'Serial', rtu_baud_rate: '115200', rtu_slave_id: 2 }, + modbus_tcp: { enabled: true, tcp_interface: 'Wi-Fi', tcp_wifi_ssid: 'shopfloor', enable_dhcp: true }, + }, + 'neither transport on': { + modbus_rtu: { enabled: false }, + modbus_tcp: { enabled: false }, + }, + // A hand-edited project can name a static host without stating the flag that + // selects it. The emitter reads a missing `enable_dhcp` as "not DHCP", so the + // migration has to reach the same conclusion or the address is dropped. + 'static host with no dhcp flag at all': { + modbus_tcp: { + enabled: true, + tcp_interface: 'Ethernet', + ip_address: '10.0.0.7', + gateway: '10.0.0.1', + subnet: '255.255.255.0', + }, + }, +} + +const throughNewModel = (legacy: Record): VppModbusScreenState => { + const migrated = migrateVendorScreenModbus(legacy) + if (!migrated) throw new Error('nothing migrated') + return vppStateFromModbusSlaveConfig({ + enabled: true, + networkInterface: '0.0.0.0', + port: 502, + rtu: migrated.rtu, + tcpLink: migrated.tcpLink, + }) +} + +describe.each(Object.entries(LEGACY_STATES))('%s', (_name, legacy) => { + const legacyState = legacy as VppModbusScreenState + const unifiedState = throughNewModel(legacy) + + it('emits the same defines.h block', () => { + expect(generateModbusDefines(unifiedState)).toBe(generateModbusDefines(legacyState)) + }) + + it('resolves the same debug baud', () => { + expect(resolveDebugBaud(unifiedState)).toBe(resolveDebugBaud(legacyState)) + }) + + it('resolves the same debug slave id', () => { + expect(resolveDebugSlave(unifiedState)).toBe(resolveDebugSlave(legacyState)) + }) +}) + +describe('vppStateFromModbusSlaveConfig', () => { + it('describes no transport when the server has neither block', () => { + expect(vppStateFromModbusSlaveConfig(undefined)).toEqual({}) + expect(vppStateFromModbusSlaveConfig({ enabled: true, networkInterface: '0.0.0.0', port: 502 })).toEqual({}) + // An absent block means "this target has no serial slave", which is not the + // same as one configured and switched off — so nothing is emitted for it. + expect(generateModbusDefines(vppStateFromModbusSlaveConfig(undefined))).toBe('') + }) + + it('maps the medium onto the label the emitter switches on', () => { + const link = { + enabled: true, + medium: 'wifi' as const, + macAddress: '', + wifiSsid: 'floor', + wifiPassword: 'pw', + useDhcp: true, + ipAddress: '', + gateway: '', + subnet: '', + dns: '', + } + const state = vppStateFromModbusSlaveConfig({ + enabled: true, + networkInterface: '0.0.0.0', + port: 502, + tcpLink: link, + }) + + expect(state.modbus_tcp?.tcp_interface).toBe('Wi-Fi') + expect(generateModbusDefines(state)).toContain('#define MBTCP_WIFI') + expect( + generateModbusDefines({ ...state, modbus_tcp: { ...state.modbus_tcp, tcp_interface: 'Ethernet' } }), + ).toContain('#define MBTCP_ETHERNET') + }) +}) diff --git a/src/backend/shared/compile/steps/modbus-defines.ts b/src/backend/shared/compile/steps/modbus-defines.ts index 7921c5022..4d4cab3cd 100644 --- a/src/backend/shared/compile/steps/modbus-defines.ts +++ b/src/backend/shared/compile/steps/modbus-defines.ts @@ -22,59 +22,9 @@ * for fishing `modbus_rtu` and `modbus_tcp` out of `vendorScreenData`. */ -/** - * Subset of the persisted screen state this emitter reads. Mirrors the - * field IDs declared in `screens/modbus.json` — keep in sync if the - * VPP screen field set evolves. - */ -export interface VppModbusScreenState { - /** Phase 2 Serial section — always-on serial baud (debugger + RTU on the - * default port). */ - serial?: { - baud_rate?: string - } - /** Phase 2 Network section — Ethernet/Wi-Fi config lifted out of modbus_tcp. */ - network?: { - enabled?: boolean - interface?: 'Ethernet' | 'Wi-Fi' - mac_address?: string - wifi_ssid?: string - wifi_password?: string - enable_dhcp?: boolean - ip_address?: string - gateway?: string - subnet?: string - dns?: string - } - modbus_rtu?: { - enabled?: boolean - /** Phase 2: chosen serial port. Legacy projects use `rtu_interface`. */ - serial_port?: string - rtu_interface?: string - /** Phase 2: baud for RTU on a secondary port. On the default port the - * Serial section's baud is used. Legacy projects use `rtu_baud_rate`. */ - baud_rate?: string - rtu_baud_rate?: string - rtu_slave_id?: number - enable_rs485_en_pin?: boolean - rtu_rs485_en_pin?: string - } - modbus_tcp?: { - enabled?: boolean - unit_id?: number - // Legacy network fields (pre-Phase-2 projects still on the old screen). - // Read as a fallback when the `network` section is absent. - tcp_interface?: 'Ethernet' | 'Wi-Fi' - tcp_mac_address?: string - tcp_wifi_ssid?: string - tcp_wifi_password?: string - enable_dhcp?: boolean - ip_address?: string - gateway?: string - subnet?: string - dns?: string - } -} +import type { VppModbusScreenState } from '../../../../frontend/utils/modbus/serial-link-config' + +export type { VppModbusScreenState } /** Baud the always-on debugger falls back to when nothing else says otherwise. */ export const DEFAULT_DEBUG_BAUD = '115200' diff --git a/src/backend/shared/types/PLC/open-plc.ts b/src/backend/shared/types/PLC/open-plc.ts index 25b4ef8b3..e66969161 100644 --- a/src/backend/shared/types/PLC/open-plc.ts +++ b/src/backend/shared/types/PLC/open-plc.ts @@ -299,11 +299,44 @@ const ModbusSlaveBufferMappingSchema = z.object({ }) type ModbusSlaveBufferMapping = z.infer +// Bare-metal blocks of a Modbus server (DOPE-442). Mirrors ModbusRtuConfig / +// ModbusTcpLinkConfig in `middleware/shared/ports/types`; they have to agree, +// because this schema is what validates the project file and `z.object` drops +// keys it does not declare. +const ModbusSerialPortSchema = z.enum(['Serial', 'Serial1', 'Serial2', 'Serial3']) +const ModbusBaudRateSchema = z.enum(['9600', '14400', '19200', '38400', '57600', '115200']) + +const ModbusRtuConfigSchema = z.object({ + enabled: z.boolean(), + serialPort: ModbusSerialPortSchema, + baudRate: ModbusBaudRateSchema, + slaveId: z.number(), + useRs485EnPin: z.boolean(), + rs485EnPin: z.string(), +}) +type ModbusRtuConfig = z.infer + +const ModbusTcpLinkConfigSchema = z.object({ + enabled: z.boolean(), + medium: z.enum(['ethernet', 'wifi']), + macAddress: z.string(), + wifiSsid: z.string(), + wifiPassword: z.string(), + useDhcp: z.boolean(), + ipAddress: z.string(), + gateway: z.string(), + subnet: z.string(), + dns: z.string(), +}) +type ModbusTcpLinkConfig = z.infer + const ModbusSlaveConfigSchema = z.object({ enabled: z.boolean(), networkInterface: z.string(), port: z.number(), bufferMapping: ModbusSlaveBufferMappingSchema.optional(), + rtu: ModbusRtuConfigSchema.optional(), + tcpLink: ModbusTcpLinkConfigSchema.optional(), }) type ModbusSlaveConfig = z.infer @@ -902,9 +935,11 @@ export { ModbusIOGroupSchema, ModbusIOPointSchema, ModbusParitySchema, + ModbusRtuConfigSchema, ModbusSlaveBufferMappingSchema, ModbusSlaveConfigSchema, ModbusTcpConfigSchema, + ModbusTcpLinkConfigSchema, ModbusTransportTypeSchema, OpcUaAddressSpaceConfigSchema, OpcUaAuthMethodSchema, @@ -966,9 +1001,11 @@ export type { ModbusIOGroup, ModbusIOPoint, ModbusParity, + ModbusRtuConfig, ModbusSlaveBufferMapping, ModbusSlaveConfig, ModbusTcpConfig, + ModbusTcpLinkConfig, ModbusTransportType, OpcUaAddressSpaceConfig, OpcUaAuthMethod, diff --git a/src/frontend/components/_features/[workspace]/editor/server/modbus-server/bare-metal-sections.tsx b/src/frontend/components/_features/[workspace]/editor/server/modbus-server/bare-metal-sections.tsx new file mode 100644 index 000000000..b56495ed8 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/server/modbus-server/bare-metal-sections.tsx @@ -0,0 +1,385 @@ +/** + * The two Modbus sections a bare-metal target owns: the serial (RTU) slave and + * the network link underneath Modbus TCP. + * + * Until DOPE-442 these were a screen definition shipped by nine VPP packages + * and rendered by the generic vendor-screen renderer, which meant a project + * configured Modbus in two unrelated places depending on its target. They now + * live here, beside the buffer mapping, so there is one Modbus screen. + * + * Every control stays mounted on targets that have no serial slave and no link + * of their own — a runtime takes both from its host OS — and renders disabled + * instead. Mounting them conditionally is what makes a form jump a section + * down the page when the target changes, which is precisely what the old + * screen's `visible` rules did. + */ + +import type { ModbusRtuConfig, ModbusTcpLinkConfig } from '@root/middleware/shared/ports/types' +import { useEffect, useState } from 'react' + +import { useOpenPLCStore } from '../../../../../../store' +import { cn } from '../../../../../../utils/cn' +import { BAUD_RATES, clampSlaveId, SERIAL_PORTS } from '../../../../../../utils/modbus/serial-link-config' +import { InputWithRef } from '../../../../../_atoms/input' +import { Label } from '../../../../../_atoms/label' +import { Select, SelectContent, SelectItem, SelectTrigger } from '../../../../../_atoms/select' + +const inputStyles = + 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption !text-xs font-medium text-neutral-850 outline-none focus:border-brand-medium-dark disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300' + +interface RowProps { + label: string + hint?: string + disabled: boolean + children: React.ReactNode +} + +const Row = ({ label, hint, disabled, children }: RowProps) => ( +
+ +
{children}
+ {hint && {hint}} +
+) + +interface ToggleRowProps { + label: string + hint?: string + checked: boolean + disabled: boolean + onChange: (checked: boolean) => void +} + +const ToggleRow = ({ label, hint, checked, disabled, onChange }: ToggleRowProps) => ( + +