diff --git a/docs/jwplc/JWPLC_EXPANSION_BACKPLANE_ARCHITECTURE.md b/docs/jwplc/JWPLC_EXPANSION_BACKPLANE_ARCHITECTURE.md new file mode 100644 index 000000000..b348fae94 --- /dev/null +++ b/docs/jwplc/JWPLC_EXPANSION_BACKPLANE_ARCHITECTURE.md @@ -0,0 +1,257 @@ +# JWPLC Expansion Backplane Architecture + +## Estado + +Diseño inicial. + +Este documento registra la decisión de usar una interfaz tipo Backplane Configuration para la futura configuración de módulos de expansión JWPLC dentro de OpenPLC Editor - JWPLC Edition. + +## Decisión principal + +La configuración final de módulos JWPLC no debe quedar expuesta únicamente como Remote Devices genéricos. + +Remote Devices seguirá siendo la capa técnica interna para generar configuración Modbus Master, pero la experiencia de usuario final debe ser una pantalla tipo backplane o rack de módulos. + +## Enfoque visual deseado + +El usuario debería ver una configuración similar a: + +Slot 1: +JWPLC Basic v2.0.0 +Controlador principal + +Slot 2: +JWPLC Expansion DI16 +16 entradas digitales +Slave ID 2 + +Slot 3: +JWPLC Expansion DO16 +16 salidas digitales +Slave ID 3 + +Slot 4: +JWPLC Expansion AI8 +8 entradas analógicas +Slave ID 4 + +## Justificación + +La configuración manual mediante Remote Devices es técnicamente correcta, pero poco amigable para usuarios finales. + +Remote Devices requiere conocer detalles como: + +- Function Code. +- Offset. +- Length. +- Cycle Time. +- Slave ID. +- Transporte Modbus. +- Direcciones IEC. + +Para un ecosistema JWPLC, el flujo debe ser más cercano a fabricantes industriales: + +1. Seleccionar controlador principal. +2. Agregar módulos de expansión. +3. Configurar dirección o Slave ID. +4. Ver canales disponibles. +5. Asignar alias. +6. Programar Ladder usando nombres claros. + +## Relación con Remote Devices + +Remote Devices queda como capa técnica. + +La pantalla JWPLC Backplane debe generar o sincronizar internamente: + +- Remote Devices. +- Configuración Modbus TCP o RTU. +- IO Groups. +- IEC addresses. +- Aliases. +- Cycle time. +- Slave ID. + +El usuario avanzado podrá seguir usando Remote Devices directamente, pero el flujo recomendado para módulos JWPLC será Backplane Configuration. + +## Primera prueba PoC + +Antes de crear módulos JWPLC dedicados, se usará un segundo JWPLC Basic como módulo remoto. + +Topología inicial: + +PC con OpenPLC Editor - JWPLC Edition +| +USB para programación/debug +| +JWPLC Basic Maestro +| +Ethernet o RS-485 usando Modbus +| +JWPLC Basic Esclavo + +## Roles + +### JWPLC Basic Maestro + +- Ejecuta el programa principal. +- Actúa como Modbus Master/Client. +- Lee entradas remotas. +- Escribe salidas remotas. + +### JWPLC Basic Esclavo + +- Actúa como dispositivo remoto. +- Expone entradas y salidas. +- Simula el comportamiento de una futura expansión JWPLC. + +## Fase 1: Remote I/O manual + +Objetivo: + +Validar que el flujo técnico funcione usando Remote Devices. + +Acciones: + +- Habilitar Remote Devices para targets JWPLC. +- Crear un Remote Device Modbus. +- Agregar preset JWPLC Basic Remote I/O. +- Compilar. +- Subir al JWPLC maestro. +- Probar comunicación contra el JWPLC esclavo. + +Criterio de éxito: + +- El maestro compila con configuración Modbus Master. +- El maestro puede leer entradas remotas. +- El maestro puede escribir salidas remotas. +- El debugger permite observar variables asociadas. + +## Fase 2: Backplane visual JWPLC + +Objetivo: + +Crear una pantalla propia para configurar módulos JWPLC usando el layout module-slots existente. + +Pantalla propuesta: + +JWPLC Backplane Configuration + +Slots iniciales: + +- Slot 1: JWPLC Basic v2.0.0 +- Slot 2: JWPLC Basic Remote I/O +- Slot 3+: Reservado para futuras expansiones + +Módulos iniciales: + +- JWPLC Basic Remote I/O +- JWPLC Expansion DI16 +- JWPLC Expansion DO16 +- JWPLC Expansion 8DI/8DO +- JWPLC Expansion AI8 +- JWPLC Expansion AO4 +- JWPLC Expansion Relay8 + +## Fase 3: Descriptor de módulos JWPLC + +Cada módulo debe tener un descriptor declarativo. + +Campos propuestos: + +- id +- name +- model +- description +- image +- transport +- defaultSlaveId +- defaultBaudRate +- channels +- modbusGroups +- defaultCycleTime +- allowedSlotRange + +Ejemplo conceptual: + +id: jwplc-expansion-di16 +name: JWPLC Expansion DI16 +transport: modbus-rtu +defaultSlaveId: 2 +channels: + - I1_0 digitalInput + - I1_1 digitalInput + - ... +modbusGroups: + - FC2 offset 0 length 16 + +## Fase 4: Generación automática + +Desde la pantalla Backplane se debe generar automáticamente: + +- Remote Device por módulo. +- IO Groups por función. +- Direcciones IEC. +- Alias iniciales opcionales. +- Configuración Modbus Master. + +Ejemplo: + +Módulo: JWPLC Expansion DI16 +Slave ID: 2 + +Genera: + +Remote Device: +JWPLC_EXP_DI16_01 + +Transport: +Modbus RTU + +IO Group: +Read Discrete Inputs +FC2 +Offset 0 +Length 16 +Cycle Time 100 ms + +## Fase 5: Diagnóstico + +La pantalla debe mostrar estado de módulos: + +- Configurado. +- Sin respuesta. +- Respondiendo. +- Error de timeout. +- Slave ID duplicado. +- Módulo detectado diferente al configurado. + +## Decisiones pendientes + +- Definir si el bus principal de expansión será Modbus RTU, Modbus TCP o ambos. +- Definir mapa Modbus final por familia de módulos. +- Definir si cada módulo tendrá firmware propio con discovery. +- Definir método de asignación de Slave ID. +- Definir si el Backplane podrá modificar parámetros del módulo. +- Definir si habrá modo avanzado para editar Remote Devices generados. + +## No incluido todavía + +- No se implementa discovery RS-485. +- No se modifica IP o Slave ID desde el editor. +- No se define todavía protocolo final de módulos. +- No se elimina Remote Devices. +- No se asume OTA. +- No se modifica el package Arduino base. + +## Conclusión + +El flujo recomendado para JWPLC Expansion será: + +Backplane Configuration como interfaz de producto. + +Remote Devices como backend técnico. + +Modbus Master como mecanismo inicial de comunicación. + +Esta arquitectura permite empezar con un JWPLC Basic como esclavo remoto y evolucionar luego hacia módulos JWPLC Expansion dedicados. diff --git a/docs/jwplc/JWPLC_REMOTE_IO_POC.md b/docs/jwplc/JWPLC_REMOTE_IO_POC.md new file mode 100644 index 000000000..32df418c7 --- /dev/null +++ b/docs/jwplc/JWPLC_REMOTE_IO_POC.md @@ -0,0 +1,170 @@ +# JWPLC Remote I/O PoC + +## Estado + +Etapa inicial de desarrollo. + +## Objetivo + +Validar el uso de un JWPLC Basic como controlador maestro y un segundo JWPLC Basic como dispositivo remoto/esclavo, simulando el comportamiento futuro de módulos de expansión JWPLC. + +## Topología inicial + +PC / OpenPLC Editor - JWPLC Edition + | + | USB para programación/debug + | +JWPLC Basic Maestro + | + | Ethernet / Modbus TCP + | +JWPLC Basic Esclavo + +## Roles + +### JWPLC Basic Maestro + +- Ejecuta el programa principal OpenPLC. +- Actúa como Modbus Master/Client. +- Lee entradas remotas del JWPLC esclavo. +- Escribe salidas remotas del JWPLC esclavo. + +### JWPLC Basic Esclavo + +- Ejecuta un programa OpenPLC mínimo o runtime Modbus Server. +- Expone entradas y salidas mediante Modbus TCP. +- Representa una futura expansión JWPLC. + +## Primera prueba + +### Maestro lee entradas del esclavo + +El maestro debe leer: + +- I0_0..I0_7 del esclavo. + +Estas señales serán mapeadas como variables remotas internas del programa del maestro. + +### Maestro escribe salidas del esclavo + +El maestro debe escribir: + +- Q0_0..Q0_7 del esclavo. + +Estas salidas serán controladas desde variables internas del programa del maestro. + +## Transporte inicial + +Primera fase: + +- Modbus TCP sobre Ethernet W5500. + +Motivo: + +- Discovery nativo JWPLC ya validado. +- DHCP/IP estática ya validado. +- Menor fricción que RS-485 para la primera prueba. +- Permite enfocarse en Remote Devices y mapeo I/O. + +Segunda fase: + +- Modbus RTU sobre RS-485. + +Motivo: + +- Será el escenario más cercano a módulos de expansión físicos. + +## Hipótesis de mapeo inicial + +| Función | Modbus | Dirección | Longitud | Uso | +|---|---:|---:|---:|---| +| Leer entradas digitales del esclavo | FC2 | 0 | 8 | I0_0..I0_7 | +| Escribir salidas digitales del esclavo | FC15 | 0 | 8 | Q0_0..Q0_7 | +| Leer estado de salidas del esclavo | FC1 | 0 | 8 | Estado Q0_0..Q0_7 | + +## Configuración inicial del JWPLC Basic Esclavo + +- Board: JWPLC BASIC [2.0.0]. +- Modbus TCP: habilitado. +- Interface: Ethernet W5500. +- DHCP: habilitado inicialmente. +- Puerto Modbus TCP: 502. +- Slave ID: 1. + +## Configuración inicial del JWPLC Basic Maestro + +- Board: JWPLC BASIC [2.0.0]. +- Programa principal OpenPLC. +- Remote Device configurado como Modbus TCP. +- Host: IP detectada del JWPLC esclavo. +- Puerto: 502. +- Slave ID: 1. + +## IO Groups propuestos para el maestro + +### Grupo 1: Remote Digital Inputs + +- Function Code: FC2 - Read Discrete Inputs. +- Offset: 0. +- Length: 8. +- Cycle Time: 50 ms o 100 ms. +- Uso: leer entradas digitales remotas del esclavo. + +### Grupo 2: Remote Digital Outputs + +- Function Code: FC15 - Write Multiple Coils. +- Offset: 0. +- Length: 8. +- Cycle Time: 50 ms o 100 ms. +- Uso: escribir salidas digitales remotas del esclavo. + +### Grupo 3: Remote Output Feedback + +- Function Code: FC1 - Read Coils. +- Offset: 0. +- Length: 8. +- Cycle Time: 100 ms. +- Uso: leer estado actual de salidas remotas. + +## Criterio de éxito + +- [ ] El maestro configura al esclavo como Remote Device. +- [ ] El maestro compila con configuración Modbus Master. +- [ ] El esclavo responde por Modbus TCP. +- [ ] El maestro lee 8 entradas digitales remotas. +- [ ] El maestro escribe 8 salidas digitales remotas. +- [ ] El debugger permite observar variables locales y remotas. +- [ ] El flujo puede documentarse para futuras expansiones JWPLC. + +## No incluido en esta fase + +- No se implementa discovery RS-485. +- No se cambia la IP del esclavo desde el editor. +- No se crea todavía catálogo final de módulos. +- No se modifica el package Arduino base. +- No se asume OTA. +- No se define aún el protocolo final de módulos de expansión. + +## Evolución esperada + +Esta prueba debe servir como base para una futura función del editor: + +- Add JWPLC Remote I/O Device. +- Add JWPLC Expansion Module. +- Generación automática de Remote Devices. +- Generación automática de IO Groups. +- Mapeo automático de variables IEC. +- Diagnóstico de módulos remotos. + +## Resultado esperado de producto + +El usuario no debería configurar manualmente cada función Modbus. + +El flujo ideal futuro sería: + +1. Seleccionar JWPLC Basic como controlador principal. +2. Agregar un módulo remoto JWPLC. +3. Elegir transporte: Modbus TCP o Modbus RTU. +4. Asignar IP o Slave ID. +5. Generar automáticamente el mapa de entradas/salidas. +6. Programar en Ladder usando nombres claros. diff --git a/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx b/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx index 5a2400ccc..6ed706e78 100644 --- a/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/autocomplete/index.tsx @@ -22,6 +22,9 @@ export type GraphicalEditorAutocompleteProps = ComponentPropsWithRef<'div'> & { } } } + keepOpenForElementId?: string + keepOpenForSelector?: string + onBeforeSubmit?: ({ variable }: { variable: { id: string; name: string } }) => void } export const GraphicalEditorAutocomplete = forwardRef( @@ -36,6 +39,9 @@ export const GraphicalEditorAutocomplete = forwardRef { @@ -91,9 +97,26 @@ export const GraphicalEditorAutocomplete = forwardRef { + const shouldKeepOpenForOutsideTarget = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) return false + if (keepOpenForElementId && target.id === keepOpenForElementId) return true + if (keepOpenForSelector && target.closest(keepOpenForSelector)) return true + return false + } + + const handleOutsideInteraction = (event: { target: EventTarget | null; preventDefault: () => void }) => { + if (shouldKeepOpenForOutsideTarget(event.target)) { + if (setIsOpen) setIsOpen(true) + return + } + closeModal() + } + + const submitAutocompletion = ({ variable }: { variable: { id: string; name: string } }) => { + onBeforeSubmit?.({ variable }) submit({ variable }) + closeModal() } /** @@ -237,9 +260,9 @@ export const GraphicalEditorAutocomplete = forwardRef e.preventDefault()} onCloseAutoFocus={closeModal} onEscapeKeyDown={closeModal} - onPointerDownOutside={closeModal} - onFocusOutside={closeModal} - onInteractOutside={closeModal} + onPointerDownOutside={handleOutsideInteraction} + onFocusOutside={handleOutsideInteraction} + onInteractOutside={handleOutsideInteraction} onFocus={(e) => { if (focusEvent) focusEvent(e) setAutocompleteFocus(true) @@ -260,12 +283,9 @@ export const GraphicalEditorAutocomplete = forwardRef e.preventDefault()} - onClick={() => { + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() submitAutocompletion({ variable: { id: variable.id ?? '', @@ -297,14 +317,15 @@ export const GraphicalEditorAutocomplete = forwardRef e.preventDefault()} - onClick={() => + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() submitAutocompletion({ variable: newBlock.canCreate ? selectableValues[selectableValues.length - 2].variable : selectableValues[selectableValues.length - 1].variable, }) - } + }} >
Add variable
@@ -323,8 +344,9 @@ export const GraphicalEditorAutocomplete = forwardRef e.preventDefault()} - onClick={() => { + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() if (newBlock.options) { submitAutocompletion({ variable: selectableValues[selectableValues.length - 1].variable }) } diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx index 64dedd360..3d6a08b1a 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx @@ -1,5 +1,5 @@ import { Node } from '@xyflow/react' -import { ComponentPropsWithRef, forwardRef, useEffect, useState } from 'react' +import { ComponentPropsWithRef, forwardRef, useEffect, useMemo, useState } from 'react' import { v4 as uuidv4 } from 'uuid' import { PLCVariable } from '../../../../../../middleware/shared/ports' @@ -12,6 +12,7 @@ import { import { useOpenPLCStore } from '../../../../../store' import { cn } from '../../../../../utils/cn' import { getLiteralType } from '../../../../../utils/keywords' +import { validateVariableType } from '../../../../../utils/PLC/validate-variable-type' import { toast } from '../../../../_features/[app]/toast/use-toast' import { useBoundPou } from '../../../../_features/[workspace]/editor/graphical/active-context' import { GraphicalEditorAutocomplete } from '../../autocomplete' @@ -25,6 +26,9 @@ type VariablesBlockAutoCompleteProps = ComponentPropsWithRef<'div'> & { setIsOpen?: (isOpen: boolean) => void keyPressed?: string valueToSearch: string + keepOpenForElementId?: string + keepOpenForSelector?: string + onBeforeSubmit?: (variableName: string) => void } /** @@ -50,7 +54,17 @@ const expectedTypeForBlock = ( const VariablesBlockAutoComplete = forwardRef( ( - { block, blockType = 'other', isOpen, setIsOpen, keyPressed, valueToSearch }: VariablesBlockAutoCompleteProps, + { + block, + blockType = 'other', + isOpen, + setIsOpen, + keyPressed, + valueToSearch, + keepOpenForElementId, + keepOpenForSelector, + onBeforeSubmit, + }: VariablesBlockAutoCompleteProps, ref, ) => { const pouName = useBoundPou() @@ -83,6 +97,42 @@ const VariablesBlockAutoComplete = forwardRef(() => { + if (blockType === 'block') return [] + + const needle = valueToSearch.toLowerCase() + const variables = pous.find((pou) => pou.name === pouName)?.interface?.variables ?? [] + + return variables + .filter((variable) => variable.name.toLowerCase().includes(needle)) + .filter((variable) => { + if (!expectedType) return true + const variableType = variable.type?.value + if (!variableType) return false + return validateVariableType(variableType, expectedType).isValid + }) + .map((variable) => ({ + label: variable.name, + insertText: variable.name, + type: variable.type?.value, + })) + }, [blockType, expectedType, pous, pouName, valueToSearch]) + + const mergedCandidates = useMemo(() => { + const byInsertText = new Map() + + for (const candidate of candidates) { + byInsertText.set(candidate.insertText.toLowerCase(), candidate) + } + + for (const candidate of localVariableCandidates) { + const key = candidate.insertText.toLowerCase() + if (!byInsertText.has(key)) byInsertText.set(key, candidate) + } + + return [...byInsertText.values()] + }, [candidates, localVariableCandidates]) + const submitVariableToBlock = (variable: PLCVariable) => { const { rung, node: variableNode } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { nodeId: (block as Node).id, @@ -237,7 +287,7 @@ const VariablesBlockAutoComplete = forwardRef c.insertText.toLowerCase() === variable.name.toLowerCase()) + const candidate = mergedCandidates.find((c) => c.insertText.toLowerCase() === variable.name.toLowerCase()) if (!candidate) return submitVariableToBlock(scopeCompletionToVariable(candidate)) @@ -251,8 +301,11 @@ const VariablesBlockAutoComplete = forwardRef ({ id: c.insertText, name: c.insertText }))} + variables={mergedCandidates.map((c) => ({ id: c.insertText, name: c.insertText }))} submit={submit} + keepOpenForElementId={keepOpenForElementId} + keepOpenForSelector={keepOpenForSelector} + onBeforeSubmit={({ variable }) => onBeforeSubmit?.(variable.name)} /> ) }, diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx index 4da8b8cbe..5020cc302 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx @@ -8,6 +8,7 @@ import { forceDebugVariable, releaseDebugVariable } from '../../../../services/d import { isExpressionValidForType } from '../../../../services/graphical-scope' import { useOpenPLCStore } from '../../../../store' import { cn } from '../../../../utils/cn' +import { validateVariableType } from '../../../../utils/PLC/validate-variable-type' import { useBoundPou } from '../../../_features/[workspace]/editor/graphical/active-context' import { HighlightedTextArea } from '../../highlighted-textarea' import { VariablesBlockAutoComplete } from './autocomplete' @@ -55,33 +56,41 @@ export const Coil = (block: CoilProps) => { } >(null) + const skipNextVariableBlurSubmitRef = useRef(false) + const [openAutocomplete, setOpenAutocomplete] = useState(false) const [keyPressedAtTextarea, setKeyPressedAtTextarea] = useState('') - useEffect(() => { - if (inputVariableRef.current && inputWrapperRef.current) { - inputWrapperRef.current.style.top = inputVariableRef.current.scrollHeight >= 24 ? '-24px' : '-20px' + const variableEditorElementId = 'coil-variable-input-' + id + + const openVariableAutocomplete = () => { + if (typeof window !== 'undefined') { + window.dispatchEvent( + new CustomEvent('openplc:ladder-variable-autocomplete-open', { + detail: { id: variableEditorElementId }, + }), + ) } - }, [coilVariableValue]) - /** - * useEffect to sync coilVariableValue with data.variable.name when it changes externally - * (e.g., from variable rename propagation) - */ + setOpenAutocomplete(true) + setKeyPressedAtTextarea('') + } + useEffect(() => { - if (data.variable && data.variable.name !== '') { - setCoilVariableValue(data.variable.name) + const handleAutocompleteOpen = (event: Event) => { + const openedId = (event as CustomEvent<{ id: string }>).detail?.id + if (openedId !== variableEditorElementId) { + setOpenAutocomplete(false) + setKeyPressedAtTextarea('') + } } - }, [data.variable.name]) - /** - * useEffect to focus the variable input when the block is selected - */ - useEffect(() => { - if (inputVariableRef.current && selected) { - inputVariableRef.current.focus() + window.addEventListener('openplc:ladder-variable-autocomplete-open', handleAutocompleteOpen) + + return () => { + window.removeEventListener('openplc:ladder-variable-autocomplete-open', handleAutocompleteOpen) } - }, []) + }, [variableEditorElementId]) /** * Validate the coil's variable against the full project scope via the @@ -92,6 +101,15 @@ export const Coil = (block: CoilProps) => { */ useEffect(() => { const name = data.variable?.name?.trim() ?? '' + const localVariable = pous + .find((pou) => pou.name === pouName) + ?.interface?.variables?.find((variable) => variable.name.toLowerCase() === name.toLowerCase()) + + if (localVariable?.type?.value && validateVariableType(localVariable.type.value, 'BOOL').isValid) { + setWrongVariable(false) + return + } + let cancelled = false void isExpressionValidForType(pouName, name, 'BOOL').then((valid) => { if (!cancelled) setWrongVariable(!valid) @@ -148,6 +166,11 @@ export const Coil = (block: CoilProps) => { * Handle with the variable input onBlur event */ const handleSubmitCoilVariableOnTextareaBlur = (variableName?: string) => { + if (skipNextVariableBlurSubmitRef.current) { + skipNextVariableBlurSubmitRef.current = false + return + } + const variableNameToSubmit = variableName || coilVariableValue const { rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { nodeId: id, @@ -173,7 +196,7 @@ export const Coil = (block: CoilProps) => { const onChangeHandler = () => { if (!openAutocomplete) { - setOpenAutocomplete(true) + openVariableAutocomplete() } } @@ -194,7 +217,23 @@ export const Coil = (block: CoilProps) => { onClick={isDebuggerVisible ? handleClick : undefined} > {coil.svg(wrongVariable, debuggerFillColor)} -
+
{ + event.stopPropagation() + openVariableAutocomplete() + }} + onClickCapture={(event) => { + event.stopPropagation() + openVariableAutocomplete() + }} + onDoubleClickCapture={(event) => { + event.stopPropagation() + openVariableAutocomplete() + }} + > { readOnly={isDebuggerVisible} onFocus={(e) => { e.target.select() + openVariableAutocomplete() const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { nodeId: id ?? '', }) @@ -266,6 +306,12 @@ export const Coil = (block: CoilProps) => { isOpen={openAutocomplete} setIsOpen={(value) => setOpenAutocomplete(value)} keyPressed={keyPressedAtTextarea} + keepOpenForSelector="[data-ladder-variable-editor='true']" + onBeforeSubmit={(variableName) => { + skipNextVariableBlurSubmitRef.current = true + setCoilVariableValue(variableName) + }} + keepOpenForElementId={`coil-variable-input-${id}`} />
diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx index 950502495..6c3c04f97 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx @@ -8,6 +8,7 @@ import { forceDebugVariable, releaseDebugVariable } from '../../../../services/d import { isExpressionValidForType } from '../../../../services/graphical-scope' import { useOpenPLCStore } from '../../../../store' import { cn } from '../../../../utils/cn' +import { validateVariableType } from '../../../../utils/PLC/validate-variable-type' import { useBoundPou } from '../../../_features/[workspace]/editor/graphical/active-context' import { HighlightedTextArea } from '../../highlighted-textarea' import { VariablesBlockAutoComplete } from './autocomplete' @@ -55,34 +56,41 @@ export const Contact = (block: ContactProps) => { } >(null) + const skipNextVariableBlurSubmitRef = useRef(false) + const [openAutocomplete, setOpenAutocomplete] = useState(false) const [keyPressedAtTextarea, setKeyPressedAtTextarea] = useState('') - useEffect(() => { - if (inputVariableRef.current && inputWrapperRef.current) { - // top - inputWrapperRef.current.style.top = inputVariableRef.current.scrollHeight >= 24 ? '-24px' : '-20px' + const variableEditorElementId = 'contact-variable-input-' + id + + const openVariableAutocomplete = () => { + if (typeof window !== 'undefined') { + window.dispatchEvent( + new CustomEvent('openplc:ladder-variable-autocomplete-open', { + detail: { id: variableEditorElementId }, + }), + ) } - }, [contactVariableValue]) - /** - * useEffect to sync contactVariableValue with data.variable.name when it changes externally - * (e.g., from variable rename propagation) - */ + setOpenAutocomplete(true) + setKeyPressedAtTextarea('') + } + useEffect(() => { - if (data.variable && data.variable.name !== '') { - setContactVariableValue(data.variable.name) + const handleAutocompleteOpen = (event: Event) => { + const openedId = (event as CustomEvent<{ id: string }>).detail?.id + if (openedId !== variableEditorElementId) { + setOpenAutocomplete(false) + setKeyPressedAtTextarea('') + } } - }, [data.variable.name]) - /** - * useEffect to focus the variable input when the block is selected - */ - useEffect(() => { - if (inputVariableRef.current && selected) { - inputVariableRef.current.focus() + window.addEventListener('openplc:ladder-variable-autocomplete-open', handleAutocompleteOpen) + + return () => { + window.removeEventListener('openplc:ladder-variable-autocomplete-open', handleAutocompleteOpen) } - }, []) + }, [variableEditorElementId]) /** * Validate the contact's variable against the full project scope via the @@ -93,6 +101,15 @@ export const Contact = (block: ContactProps) => { */ useEffect(() => { const name = data.variable?.name?.trim() ?? '' + const localVariable = pous + .find((pou) => pou.name === pouName) + ?.interface?.variables?.find((variable) => variable.name.toLowerCase() === name.toLowerCase()) + + if (localVariable?.type?.value && validateVariableType(localVariable.type.value, 'BOOL').isValid) { + setWrongVariable(false) + return + } + let cancelled = false void isExpressionValidForType(pouName, name, 'BOOL').then((valid) => { if (!cancelled) setWrongVariable(!valid) @@ -149,6 +166,11 @@ export const Contact = (block: ContactProps) => { * Handle with the variable input onBlur event */ const handleSubmitContactVariableOnTextareaBlur = (variableName?: string) => { + if (skipNextVariableBlurSubmitRef.current) { + skipNextVariableBlurSubmitRef.current = false + return + } + const variableNameToSubmit = variableName || contactVariableValue const { rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { nodeId: id, @@ -174,7 +196,7 @@ export const Contact = (block: ContactProps) => { const onChangeHandler = () => { if (!openAutocomplete) { - setOpenAutocomplete(true) + openVariableAutocomplete() } } @@ -195,7 +217,23 @@ export const Contact = (block: ContactProps) => { onClick={isDebuggerVisible ? handleClick : undefined} > {contact.svg(wrongVariable, debuggerStrokeColor)} -
+
{ + event.stopPropagation() + openVariableAutocomplete() + }} + onClickCapture={(event) => { + event.stopPropagation() + openVariableAutocomplete() + }} + onDoubleClickCapture={(event) => { + event.stopPropagation() + openVariableAutocomplete() + }} + > { readOnly={isDebuggerVisible} onFocus={(e) => { e.target.select() + openVariableAutocomplete() const { node, rung } = getLadderPouVariablesRungNodeAndEdges(pouName, pous, ladderFlows, { nodeId: id ?? '', }) @@ -267,6 +306,12 @@ export const Contact = (block: ContactProps) => { isOpen={openAutocomplete} setIsOpen={(value) => setOpenAutocomplete(value)} keyPressed={keyPressedAtTextarea} + keepOpenForSelector="[data-ladder-variable-editor='true']" + onBeforeSubmit={(variableName) => { + skipNextVariableBlurSubmitRef.current = true + setContactVariableValue(variableName) + }} + keepOpenForElementId={`contact-variable-input-${id}`} />
diff --git a/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx b/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx index b09eff4d9..b6fa7ae32 100644 --- a/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx +++ b/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx @@ -136,6 +136,9 @@ const ElementCard = (props: ElementCardProps): ReactNode => { const isArduinoTarget = checkIsArduinoTarget(currentBoardInfo) const isSimulator = isSimulatorTarget(currentBoardInfo) const isRuntimeV4 = isOpenPLCRuntimeV4Target(deviceBoard, currentBoardInfo) + const boardInfoText = currentBoardInfo ? JSON.stringify(currentBoardInfo).toLowerCase() : '' + const isJwplcTarget = deviceBoard.toLowerCase().includes('jwplc') || boardInfoText.includes('jwplc') + const supportsRemoteDevices = isRuntimeV4 || isSimulator || isJwplcTarget const handleCreatePou: SubmitHandler = (data) => { const pouWasCreated = create(data) @@ -511,13 +514,13 @@ const ElementCard = (props: ElementCardProps): ReactNode => {
- {!(isRuntimeV4 || isSimulator) ? ( + {!supportsRemoteDevices ? (

- Remote device configuration is only available for OpenPLC Runtime v4 targets. + Remote device configuration is available for OpenPLC Runtime v4, Simulator and JWPLC targets.

- Please select OpenPLC Runtime v4 in the Device Configuration to enable this feature. + Please select a compatible target in the Device Configuration to enable this feature.

) : ( 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 3000d5585..4718e4ef7 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 @@ -74,6 +74,42 @@ const DATA_BITS_OPTIONS = [ { value: '8', label: '8' }, ] +type RemoteIOPresetGroup = { + name: string + functionCode: '1' | '2' | '3' | '4' | '5' | '6' | '15' | '16' + cycleTime: number + offset: string + length: number + errorHandling: 'keep-last-value' | 'set-to-zero' +} + +const JWPLC_BASIC_REMOTE_IO_PRESET_GROUPS: RemoteIOPresetGroup[] = [ + { + name: 'JWPLC Remote Digital Inputs', + functionCode: '2', + cycleTime: 100, + offset: '0', + length: 8, + errorHandling: 'keep-last-value', + }, + { + name: 'JWPLC Remote Digital Outputs', + functionCode: '15', + cycleTime: 100, + offset: '0', + length: 8, + errorHandling: 'keep-last-value', + }, + { + name: 'JWPLC Remote Output Feedback', + functionCode: '1', + cycleTime: 250, + offset: '0', + length: 8, + errorHandling: 'keep-last-value', + }, +] + // Slave ID validation ranges per transport type const SLAVE_ID_TCP_MIN = 0 const SLAVE_ID_TCP_MAX = 255 @@ -699,6 +735,42 @@ const RemoteDeviceEditor = () => { setIsModalOpen(true) }, []) + const hasJwplcBasicRemoteIoPreset = useMemo( + () => JWPLC_BASIC_REMOTE_IO_PRESET_GROUPS.some((preset) => ioGroups.some((group) => group.name === preset.name)), + [ioGroups], + ) + + const handleAddJwplcBasicRemoteIoPreset = useCallback(() => { + const existingPresetGroups = JWPLC_BASIC_REMOTE_IO_PRESET_GROUPS.filter((preset) => + ioGroups.some((group) => group.name === preset.name), + ) + + if (existingPresetGroups.length > 0) { + consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'warning', + message: `JWPLC Basic Remote I/O preset was not added because these groups already exist: ${existingPresetGroups + .map((group) => group.name) + .join(', ')}.`, + }) + return + } + + for (const group of JWPLC_BASIC_REMOTE_IO_PRESET_GROUPS) { + projectActions.addIOGroup(deviceName, { + id: uuidv4(), + ...group, + }) + } + + sharedWorkspaceActions.handleFileAndWorkspaceSavedState(deviceName) + consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'info', + message: 'JWPLC Basic Remote I/O preset added: 8 remote inputs, 8 remote outputs and output feedback.', + }) + }, [consoleActions, deviceName, ioGroups, projectActions, sharedWorkspaceActions]) + const handleOpenEditModal = useCallback( (groupId: string) => { const group = ioGroups.find((g) => g.id === groupId) @@ -943,20 +1015,36 @@ const RemoteDeviceEditor = () => {

IO Tag Mapping

- , - id: 'add-io-group-button', - }, - ]} - buttonProps={{ - className: - 'rounded-md p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed', - }} - /> +
+ + + , + id: 'add-io-group-button', + }, + ]} + buttonProps={{ + className: + 'rounded-md p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed', + }} + /> +
diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx index 3fc082af2..7caa3fc4a 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx @@ -98,6 +98,7 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi const [reactFlowInstance, setReactFlowInstance] = useState(null) const reactFlowViewportRef = useRef(null) + const lastNodeClickRef = useRef<{ nodeId: string; clickedAt: number } | null>(null) /** * -- Which means, by default, the flow panel extent is: @@ -662,10 +663,11 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi } } - /** - * Handle the double click of a node - */ // - const handleNodeDoubleClick = (node: FlowNode) => { + const isLadderVariableEditorTarget = (target: EventTarget | null): boolean => { + return target instanceof HTMLElement && Boolean(target.closest("[data-ladder-variable-editor='true']")) + } + + const handleNodeEditRequest = (node: FlowNode) => { const modalToOpen = node.type === 'block' ? 'block-ladder-element' @@ -679,6 +681,23 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi openModal(modalToOpen, node) } + const handleNodeClick = (event: MouseEvent, node: FlowNode) => { + if (isLadderVariableEditorTarget(event.target)) return + + const clickedAt = window.performance.now() + const previousClick = lastNodeClickRef.current + + lastNodeClickRef.current = { + nodeId: node.id, + clickedAt, + } + + if (previousClick?.nodeId === node.id && clickedAt - previousClick.clickedAt <= 750) { + lastNodeClickRef.current = null + handleNodeEditRequest(node) + } + } + /** * Handle the change of the nodes * This function is called every time the nodes change @@ -913,7 +932,11 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi onInit: setReactFlowInstance, onNodesChange: onNodesChange, - onNodeClick: isDebuggerActive ? () => {} : undefined, + onNodeClick: isDebuggerActive + ? () => {} + : (event, node) => { + handleNodeClick(event, node) + }, onNodesDelete: isDebuggerActive ? undefined : (nodes) => { @@ -934,11 +957,7 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi : (_event, node) => { handleNodeDragStop(node) }, - onNodeDoubleClick: isDebuggerActive - ? undefined - : (_event, node) => { - handleNodeDoubleClick(node) - }, + onNodeDoubleClick: undefined, onDragEnter: onDragEnterViewport, onDragLeave: onDragLeaveViewport,