From a7a5415716b0fcba90218172f9baaed2a68ea23d Mon Sep 17 00:00:00 2001 From: Jeykco Date: Sun, 5 Jul 2026 15:33:56 -0500 Subject: [PATCH 1/2] feat(jwplc): add Modbus TCP device discovery --- .gitignore | 18 ++ resources/sources/Baremetal/Baremetal.ino | 47 ++++- resources/sources/Baremetal/ModbusSlave.cpp | 132 ++++++++++-- resources/sources/Baremetal/ModbusSlave.h | 9 +- .../shared/compile/steps/modbus-defines.ts | 9 +- src/backend/shared/hardware/debug-spec.ts | 23 +- .../shared/network/jwplc-device-discovery.ts | 131 ++++++++++++ .../editor/device/configuration/board.tsx | 4 +- .../vendor-screen/layouts/form-layout.tsx | 199 +++++++++++++++++- .../components/_organisms/debugger/index.tsx | 56 ++++- src/main/modules/ipc/main.ts | 23 +- src/main/modules/ipc/renderer.ts | 17 +- .../adapters/editor/device-adapter.ts | 4 + .../shared/ports/debug-spec-types.ts | 1 + src/middleware/shared/ports/device-port.ts | 20 ++ 15 files changed, 645 insertions(+), 48 deletions(-) create mode 100644 src/backend/shared/network/jwplc-device-discovery.ts diff --git a/.gitignore b/.gitignore index ff41e9cd4..f2c37c7eb 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,21 @@ resources/strucpp/ playwright-report /blob-report/ /playwright/.cache/ + +# --------------------------------------------------------------------------- +# JWPLC local analysis / search dumps +# --------------------------------------------------------------------------- +jwplc-*-search.txt +jwplc-*search*.txt +*-jwplc-search.txt +*jwplc*search*.txt + +# Local downloaded CI/log artifacts +logs_*.zip +logs_*/ +*.log.zip + +# Temporary local notes generated during debugging +*_search.txt +*_debug_search.txt +*_local_search.txt diff --git a/resources/sources/Baremetal/Baremetal.ino b/resources/sources/Baremetal/Baremetal.ino index 126e6478e..0029fadb9 100644 --- a/resources/sources/Baremetal/Baremetal.ino +++ b/resources/sources/Baremetal/Baremetal.ino @@ -30,6 +30,19 @@ #include "defines.h" #include "arduino_runtime_glue.h" +#if defined(JWPLC_BASIC) + +#include + +#ifndef JWPLC_RS485_RX_PIN +#define JWPLC_RS485_RX_PIN 16 +#endif + +#ifndef JWPLC_RS485_TX_PIN +#define JWPLC_RS485_TX_PIN 17 +#endif +#endif + #ifdef MODBUS_ENABLED #include "ModbusSlave.h" #endif @@ -148,11 +161,21 @@ void setup() { if (pinMask_AOUT[i] == MBSERIAL_TXPIN) pinMask_AOUT[i] = 255; } - MBSERIAL_IFACE.begin(MBSERIAL_BAUD); - mbconfig_serial_iface(&MBSERIAL_IFACE, MBSERIAL_BAUD, MBSERIAL_TXPIN); + #if defined(JWPLC_BASIC) && defined(MBSERIAL_IFACE_IS_SERIAL2) + JWPLC_RS485.begin(MBSERIAL_BAUD); + mbconfig_serial_iface(&JWPLC_RS485, MBSERIAL_BAUD, MBSERIAL_TXPIN); + #else + MBSERIAL_IFACE.begin(MBSERIAL_BAUD); + mbconfig_serial_iface(&MBSERIAL_IFACE, MBSERIAL_BAUD, MBSERIAL_TXPIN); + #endif #else - MBSERIAL_IFACE.begin(MBSERIAL_BAUD); - mbconfig_serial_iface(&MBSERIAL_IFACE, MBSERIAL_BAUD, -1); + #if defined(JWPLC_BASIC) && defined(MBSERIAL_IFACE_IS_SERIAL2) + JWPLC_RS485.begin(MBSERIAL_BAUD); + mbconfig_serial_iface(&JWPLC_RS485, MBSERIAL_BAUD, -1); + #else + MBSERIAL_IFACE.begin(MBSERIAL_BAUD); + mbconfig_serial_iface(&MBSERIAL_IFACE, MBSERIAL_BAUD, -1); + #endif #endif modbus.slaveid = MBSERIAL_SLAVE; #endif @@ -164,16 +187,22 @@ void setup() uint8_t gateway[] = { MBTCP_GATEWAY }; uint8_t subnet[] = { MBTCP_SUBNET }; + #if defined(JWPLC_BASIC) + uint8_t *mbtcpMac = (sizeof(mac) / sizeof(uint8_t) >= 6) ? mac : NULL; + #else + uint8_t *mbtcpMac = mac; + #endif + if (sizeof(ip)/sizeof(uint8_t) < 4) - mbconfig_ethernet_iface(mac, NULL, NULL, NULL, NULL); + mbconfig_ethernet_iface(mbtcpMac, NULL, NULL, NULL, NULL); else if (sizeof(dns)/sizeof(uint8_t) < 4) - mbconfig_ethernet_iface(mac, ip, NULL, NULL, NULL); + mbconfig_ethernet_iface(mbtcpMac, ip, NULL, NULL, NULL); else if (sizeof(gateway)/sizeof(uint8_t) < 4) - mbconfig_ethernet_iface(mac, ip, dns, NULL, NULL); + mbconfig_ethernet_iface(mbtcpMac, ip, dns, NULL, NULL); else if (sizeof(subnet)/sizeof(uint8_t) < 4) - mbconfig_ethernet_iface(mac, ip, dns, gateway, NULL); + mbconfig_ethernet_iface(mbtcpMac, ip, dns, gateway, NULL); else - mbconfig_ethernet_iface(mac, ip, dns, gateway, subnet); + mbconfig_ethernet_iface(mbtcpMac, ip, dns, gateway, subnet); #endif init_mbregs(MAX_ANALOG_OUTPUT + MAX_MEMORY_WORD, MAX_MEMORY_DWORD, MAX_MEMORY_LWORD, MAX_DIGITAL_OUTPUT, MAX_ANALOG_INPUT, MAX_DIGITAL_INPUT); diff --git a/resources/sources/Baremetal/ModbusSlave.cpp b/resources/sources/Baremetal/ModbusSlave.cpp index 80d113e9d..fc3afc3a5 100644 --- a/resources/sources/Baremetal/ModbusSlave.cpp +++ b/resources/sources/Baremetal/ModbusSlave.cpp @@ -12,6 +12,10 @@ Copyright (C) 2022 OpenPLC - Thiago Alves // of the precompiled OpenPLCUserLib archive built with -std=gnu++17. #include "arduino_runtime_glue.h" +#if defined(JWPLC_BASIC) +#include +#endif + //Global Modbus vars struct MBinfo modbus; uint8_t mb_frame[MAX_MB_FRAME]; @@ -22,9 +26,12 @@ uint16_t mb_t15; // inter character time out uint16_t mb_t35; // frame delay #ifdef MBTCP_ETHERNET -#ifdef BOARD_ESP32 +#if defined(JWPLC_BASIC) + EthernetServer mb_server(502); + static bool jwplc_mbtcp_ready = false; +#elif defined(BOARD_ESP32) WiFiServer mb_server(502); - WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; + WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; #else EthernetServer mb_server(502); #endif @@ -167,8 +174,80 @@ void mbconfig_serial_iface(Stream* port, long baud, int txPin) void mbconfig_ethernet_iface(uint8_t *mac, uint8_t *ip, uint8_t *dns, uint8_t *gateway, uint8_t *subnet) { #ifdef MBTCP_ETHERNET - #ifdef BOARD_ESP32 + #if defined(JWPLC_BASIC) + // JWPLC Basic usa W5500 por SPI compartido. No usar ETH.begin() ni + // Ethernet.begin() directo: JWPLC_Ethernet inicializa CS, SPI, + // timeouts, DHCP/IP estatica, link y estado del W5500. + jwplc_mbtcp_ready = false; + + bool ethOk = false; + + #if defined(JWPLC_MBTCP_DEBUG) + Serial.begin(115200); + delay(300); + Serial.println(); + Serial.println("[JWPLC][MBTCP] Starting Ethernet..."); + #endif + + if (ip == NULL) + { + #if defined(JWPLC_MBTCP_DEBUG) + Serial.println("[JWPLC][MBTCP] Mode: DHCP"); + #endif + JWPLC_Ethernet.useDHCP(); + + // Si el MAC viene vacío desde el VPP, usa uno local por defecto. + uint8_t defaultMac[] = { 0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01 }; + ethOk = JWPLC_Ethernet.begin(mac != NULL ? mac : defaultMac); + } + else + { + #if defined(JWPLC_MBTCP_DEBUG) + Serial.println("[JWPLC][MBTCP] Mode: static IP"); + #endif + IPAddress localIp(ip); + IPAddress dnsIp = (dns != NULL) ? IPAddress(dns) : localIp; + IPAddress gatewayIp = (gateway != NULL) ? IPAddress(gateway) : localIp; + IPAddress subnetIp = (subnet != NULL) ? IPAddress(subnet) : IPAddress(255, 255, 255, 0); + + uint8_t defaultMac[] = { 0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01 }; + JWPLC_Ethernet.setMac(mac != NULL ? mac : defaultMac); + ethOk = JWPLC_Ethernet.begin(localIp, dnsIp, gatewayIp, subnetIp); + } + + IPAddress assignedIp = JWPLC_Ethernet.localIP(); + bool hasIp = (assignedIp != IPAddress(0, 0, 0, 0)); + + #if defined(JWPLC_MBTCP_DEBUG) + Serial.print("[JWPLC][MBTCP] ethOk: "); + Serial.println(ethOk ? "true" : "false"); + Serial.print("[JWPLC][MBTCP] IP: "); + Serial.println(assignedIp); + Serial.print("[JWPLC][MBTCP] Status: "); + Serial.println(JWPLC_Ethernet.statusString()); + #endif + + if (ethOk || hasIp) + { + mb_server.begin(); + jwplc_mbtcp_ready = true; + #if defined(JWPLC_MBTCP_DEBUG) + Serial.println("[JWPLC][MBTCP] Server started on port 502"); + #endif + } + else + { + jwplc_mbtcp_ready = false; + #if defined(JWPLC_MBTCP_DEBUG) + Serial.println("[JWPLC][MBTCP] Server NOT started"); + #endif + } + + return; + + #elif defined(BOARD_ESP32) + ETH.begin(); if (ip != NULL && subnet != NULL && gateway != NULL) @@ -196,6 +275,7 @@ void mbconfig_ethernet_iface(uint8_t *mac, uint8_t *ip, uint8_t *dns, uint8_t *g // } #endif + #ifdef MBTCP_WIFI #if defined(BOARD_ESP8266) || defined(BOARD_ESP32) if (ip != NULL && gateway != NULL && subnet != NULL && dns != NULL) @@ -239,23 +319,47 @@ void mbconfig_ethernet_iface(uint8_t *mac, uint8_t *ip, uint8_t *dns, uint8_t *g void mbtask() { - #ifdef MBTCP +#ifdef MBTCP +#if defined(JWPLC_BASIC) && defined(MBTCP_ETHERNET) + if (jwplc_mbtcp_ready) + { handle_tcp(); - #endif - #ifdef MBSERIAL - handle_serial(); - #endif + + static uint32_t lastMaintainMs = 0; + uint32_t nowMs = millis(); + if ((uint32_t)(nowMs - lastMaintainMs) >= 1000UL) + { + JWPLC_Ethernet.maintain(); + lastMaintainMs = nowMs; + } + } + + // Cede tiempo al scheduler ESP32 para no afectar TFT, E/S, RTC, Ethernet tick, etc. + delay(1); +#else + handle_tcp(); +#endif +#endif + +#ifdef MBSERIAL + handle_serial(); +#endif } #ifdef MBTCP void handle_tcp() { #ifdef MBTCP_ETHERNET - #ifdef BOARD_ESP32 - WiFiClient client = mb_server.available(); - #else - EthernetClient client = mb_server.available(); - #endif + #if defined(JWPLC_BASIC) + if (!jwplc_mbtcp_ready) + return; + + EthernetClient client = mb_server.available(); + #elif defined(BOARD_ESP32) + WiFiClient client = mb_server.available(); + #else + EthernetClient client = mb_server.available(); + #endif #endif #if defined(MBTCP_WIFI) && !defined(BOARD_ESP8266) && !defined(BOARD_ESP32) @@ -264,7 +368,7 @@ void handle_tcp() //ESP and Portenta boards have a slightly different implementation of the WiFi/Ethernet API - therefore their specific //code lies below - #if (defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA)) || defined(BOARD_PICOW) && (defined(MBTCP_WIFI) || defined(MBTCP_ETHERNET)) + #if !defined(JWPLC_BASIC) && ((defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA)) || (defined(BOARD_PICOW) && (defined(MBTCP_WIFI) || defined(MBTCP_ETHERNET)))) #if defined(BOARD_PORTENTA) || defined(BOARD_PICOW) || (defined(BOARD_ESP32) && defined(MBTCP_ETHERNET)) diff --git a/resources/sources/Baremetal/ModbusSlave.h b/resources/sources/Baremetal/ModbusSlave.h index 47236126d..30a13f023 100644 --- a/resources/sources/Baremetal/ModbusSlave.h +++ b/resources/sources/Baremetal/ModbusSlave.h @@ -30,7 +30,10 @@ Copyright (C) 2022 OpenPLC - Thiago Alves //Platform specific defines and includes #ifdef MBTCP_ETHERNET #include -#ifdef BOARD_ESP32 +#if defined(JWPLC_BASIC) + // JWPLC Basic usa W5500 por SPI. No usar ETH.h / ETH.begin(). + #include +#elif defined(BOARD_ESP32) // I²C-address of Ethernet PHY (0 or 1 for LAN8720, 31 for TLK110) #define ETH_PHY_ADDR 0 // DEFAULT VALUE IS 0 YOU CAN OMIT IT // Type of the Ethernet PHY (LAN8720 or TLK110) @@ -131,7 +134,9 @@ extern uint16_t mb_t15; // inter character time out extern uint16_t mb_t35; // frame delay #ifdef MBTCP_ETHERNET -#ifdef BOARD_ESP32 +#if defined(JWPLC_BASIC) + extern EthernetServer mb_server; +#elif defined(BOARD_ESP32) extern WiFiServer mb_server; #else extern EthernetServer mb_server; diff --git a/src/backend/shared/compile/steps/modbus-defines.ts b/src/backend/shared/compile/steps/modbus-defines.ts index 693eb0b96..22d6ddb4e 100644 --- a/src/backend/shared/compile/steps/modbus-defines.ts +++ b/src/backend/shared/compile/steps/modbus-defines.ts @@ -95,7 +95,8 @@ const RTU_DEFAULTS = { const TCP_DEFAULTS = { tcp_interface: 'Ethernet' as const, -} + tcp_mac_address: 'DE:AD:BE:EF:00:01', +} as const /** * Build the `//Comms Configuration` block. Returns an empty string when @@ -126,6 +127,9 @@ export function generateModbusDefines(state: VppModbusScreenState): string { const baud = rtu.rtu_baud_rate ?? RTU_DEFAULTS.rtu_baud_rate const slave = typeof rtu.rtu_slave_id === 'number' ? rtu.rtu_slave_id : RTU_DEFAULTS.rtu_slave_id lines.push(`#define MBSERIAL_IFACE ${iface}`) + if (iface === 'Serial2') { + lines.push('#define MBSERIAL_IFACE_IS_SERIAL2') + } lines.push(`#define MBSERIAL_BAUD ${baud}`) lines.push(`#define MBSERIAL_SLAVE ${slave}`) if (rtu.enable_rs485_en_pin === true && rtu.rtu_rs485_en_pin) { @@ -146,7 +150,8 @@ export function generateModbusDefines(state: VppModbusScreenState): string { // falls back to the DHCP/NULL path. Wi-Fi mode ignores these args // inside `mbconfig_ethernet_iface` (see `ModbusSlave.cpp:199-225`), // so the placeholder values are harmless there too. - const macLiteral = tcp.tcp_mac_address ? formatMacForDefine(tcp.tcp_mac_address) : '0' + const rawMac = (tcp.tcp_mac_address ?? TCP_DEFAULTS.tcp_mac_address).trim() + const macLiteral = rawMac ? formatMacForDefine(rawMac) : formatMacForDefine(TCP_DEFAULTS.tcp_mac_address) lines.push(`#define MBTCP_MAC ${macLiteral}`) const dhcpOn = tcp.enable_dhcp === true diff --git a/src/backend/shared/hardware/debug-spec.ts b/src/backend/shared/hardware/debug-spec.ts index fabd68fab..a0c3da0cc 100644 --- a/src/backend/shared/hardware/debug-spec.ts +++ b/src/backend/shared/hardware/debug-spec.ts @@ -130,16 +130,31 @@ function lookupRef(path: string, state: DebugResolverState): unknown { return cursor } +function isBlank(value: unknown): boolean { + return value === undefined || value === null || (typeof value === 'string' && value.trim() === '') +} + function evaluateRef(ref: DebugRef, state: DebugResolverState): unknown { - const raw = lookupRef(ref.$ref, state) - const resolved = raw === undefined ? ref.default : raw - if (resolved === undefined) return undefined + let resolved = lookupRef(ref.$ref, state) + + if (isBlank(resolved) && ref.fallback) { + resolved = lookupRef(ref.fallback, state) + } + + if (isBlank(resolved) && ref.default !== undefined) { + resolved = ref.default + } + + if (isBlank(resolved)) return undefined + if (ref.as === 'number') { const n = typeof resolved === 'number' ? resolved : Number(resolved) return Number.isFinite(n) ? n : undefined } - if (ref.as === 'boolean') return Boolean(resolved) + + if (ref.as === 'boolean') return resolved === true || resolved === 'true' if (ref.as === 'string') return String(resolved) + return resolved } diff --git a/src/backend/shared/network/jwplc-device-discovery.ts b/src/backend/shared/network/jwplc-device-discovery.ts new file mode 100644 index 000000000..0f7bd2276 --- /dev/null +++ b/src/backend/shared/network/jwplc-device-discovery.ts @@ -0,0 +1,131 @@ +import net from 'node:net' +import { networkInterfaces } from 'node:os' + +export type JwplcDiscoveredDevice = { + ipAddress: string + port: number + label: string + interfaceName: string + sourceAddress: string +} + +export type JwplcDeviceDiscoveryOptions = { + port?: number + timeoutMs?: number + concurrency?: number +} + +const DEFAULT_PORT = 502 +const DEFAULT_TIMEOUT_MS = 250 +const DEFAULT_CONCURRENCY = 64 + +const ipToLong = (ip: string) => + ip.split('.').reduce((acc, octet) => ((acc << 8) + Number(octet)) >>> 0, 0) + +const longToIp = (value: number) => + [ + (value >>> 24) & 255, + (value >>> 16) & 255, + (value >>> 8) & 255, + value & 255, + ].join('.') + +const isIpv4 = (family: string | number) => family === 'IPv4' || family === 4 + +const getScanTargets = () => { + const targets: Array<{ + ipAddress: string + interfaceName: string + sourceAddress: string + }> = [] + + const interfaces = networkInterfaces() + + for (const [interfaceName, entries] of Object.entries(interfaces)) { + for (const entry of entries ?? []) { + if (!isIpv4(entry.family) || entry.internal) continue + + const sourceAddress = entry.address + const sourceLong = ipToLong(sourceAddress) + + // Fase 1: escaneo seguro de /24 local. + // Evita barridos enormes si Windows reporta una máscara más amplia. + const networkBase = sourceLong & ipToLong('255.255.255.0') + + for (let host = 1; host <= 254; host++) { + const ipAddress = longToIp((networkBase + host) >>> 0) + if (ipAddress === sourceAddress) continue + + targets.push({ + ipAddress, + interfaceName, + sourceAddress, + }) + } + } + } + + return targets +} + +const testTcpPort = (ipAddress: string, port: number, timeoutMs: number) => + new Promise((resolve) => { + const socket = new net.Socket() + let done = false + + const finish = (result: boolean) => { + if (done) return + done = true + socket.removeAllListeners() + socket.destroy() + resolve(result) + } + + socket.setTimeout(timeoutMs) + socket.once('connect', () => finish(true)) + socket.once('timeout', () => finish(false)) + socket.once('error', () => finish(false)) + + socket.connect(port, ipAddress) + }) + +export const discoverJwplcDevices = async ( + options: JwplcDeviceDiscoveryOptions = {}, +): Promise => { + const port = options.port ?? DEFAULT_PORT + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY + + const targets = getScanTargets() + const found: JwplcDiscoveredDevice[] = [] + + let index = 0 + + const worker = async () => { + while (index < targets.length) { + const target = targets[index++] + if (!target) continue + + const reachable = await testTcpPort(target.ipAddress, port, timeoutMs) + + if (reachable) { + found.push({ + ipAddress: target.ipAddress, + port, + label: `JWPLC / Modbus TCP - ${target.ipAddress}:${port}`, + interfaceName: target.interfaceName, + sourceAddress: target.sourceAddress, + }) + } + } + } + + const workers = Array.from( + { length: Math.min(concurrency, Math.max(targets.length, 1)) }, + () => worker(), + ) + + await Promise.all(workers) + + return found.sort((a, b) => a.ipAddress.localeCompare(b.ipAddress)) +} 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 1a951788d..17e51d60b 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -648,7 +648,9 @@ const Board = memo(function () { viewportRef={communicationSelectRef} > {availableCommunicationPorts.map((port) => { - const displayName = port.name?.trim() || port.address + const portAddress = port.address?.trim() + const portName = port.name?.trim() + const displayName = portName && portName !== portAddress ? `${portAddress} (${portName})` : portAddress return ( - // Honored by text-like inputs (text, password, ip-address, mac-address). - // Mirrors the VPP screen schema's optional field props — empty strings - // are skipped so HTML5 placeholder/maxLength/pattern stay unset when - // the screen author didn't supply them. placeholder?: string + fallbackLabel?: string maxLength?: number validation?: string - // Optional conditional-visibility clause (VPP screen schema). Fields - // without it always render; see `evalVisible`. visible?: VisibleCondition } @@ -43,6 +42,7 @@ const TEXT_INPUT_CLASS = // the screen author didn't ship a regex. const IPV4_PATTERN = '^(\\d{1,3}\\.){3}\\d{1,3}$' const MAC_PATTERN = '^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$' +const COMMUNICATION_PORT_FALLBACK_VALUE = '__use_communication_port__' type FormLayoutProps = { section: ScreenSection @@ -75,6 +75,24 @@ function FieldHelpIcon({ text }: { text: string }) { function FormLayout({ section }: FormLayoutProps) { const fields = (section.fields ?? []) as FieldDef[] + const device = useDevice() + const availableCommunicationPorts = boardSelectors.useAvailableCommunicationPorts() + const setAvailableOptions = boardSelectors.useSetAvailableOptions() + const portsReqIdRef = useRef(0) + const [isRefreshingPorts, setIsRefreshingPorts] = useState(false) + + const [isDiscoveringJwplc, setIsDiscoveringJwplc] = useState(false) + const [jwplcDiscoveryResults, setJwplcDiscoveryResults] = useState< + Array<{ + ipAddress: string + port: number + label: string + interfaceName: string + sourceAddress: string + }> + >([]) + const [jwplcDiscoveryError, setJwplcDiscoveryError] = useState(null) + const vendorScreenData = useOpenPLCStore((s) => s.deviceDefinitions.configuration.vendorScreenData) const setVendorScreenData = useOpenPLCStore((s) => s.deviceActions.setVendorScreenData) // Single-source-of-truth for the per-section storage key — see @@ -97,6 +115,59 @@ function FormLayout({ section }: FormLayoutProps) { setVendorScreenData(persistenceKey, { ...storedValues, [id]: value }) } + const refreshCommunicationPorts = useCallback( + async (e?: MouseEvent) => { + e?.preventDefault() + if (isRefreshingPorts) return + + try { + setIsRefreshingPorts(true) + + portsReqIdRef.current += 1 + const currentReqId = portsReqIdRef.current + + const ports = await device.refreshCommunicationPorts() + + if (currentReqId === portsReqIdRef.current) { + setAvailableOptions({ availableCommunicationPorts: ports }) + } + } catch (error: unknown) { + console.error(error) + } finally { + setIsRefreshingPorts(false) + } + }, + [device, setAvailableOptions, isRefreshingPorts], + ) + + const discoverJwplcDevices = useCallback( + async (e?: MouseEvent) => { + e?.preventDefault() + if (isDiscoveringJwplc) return + + try { + setIsDiscoveringJwplc(true) + setJwplcDiscoveryError(null) + setJwplcDiscoveryResults([]) + + const result = await device.discoverJwplcDevices() + + if (!result.success) { + setJwplcDiscoveryError(result.error ?? 'No se pudo buscar dispositivos JWPLC.') + return + } + + setJwplcDiscoveryResults(result.devices ?? []) + } catch (error: unknown) { + console.error(error) + setJwplcDiscoveryError(error instanceof Error ? error.message : 'Error buscando dispositivos JWPLC.') + } finally { + setIsDiscoveringJwplc(false) + } + }, + [device, isDiscoveringJwplc], + ) + return (
@@ -147,6 +218,122 @@ function FormLayout({ section }: FormLayoutProps) { {field.unit} )}
+ + ) : field.type === 'jwplc-device-discovery' ? ( +
+
+ +
+ + {jwplcDiscoveryError && ( + {jwplcDiscoveryError} + )} + + {jwplcDiscoveryResults.length > 0 && ( +
+ {jwplcDiscoveryResults.map((foundDevice) => ( +
+ + {foundDevice.ipAddress}:{foundDevice.port} · {foundDevice.interfaceName} + + + +
+ ))} +
+ )} + + {!isDiscoveringJwplc && jwplcDiscoveryResults.length === 0 && !jwplcDiscoveryError && ( + + Busca equipos con Modbus TCP abierto en puerto 502. + + )} +
+ + ) : field.type === 'communication-port' ? ( +
+ + + +
+ ) : field.type === 'select' ? ( - updateField(field.id, v === COMMUNICATION_PORT_FALLBACK_VALUE ? '' : v) - } + onValueChange={(v) => updateField(field.id, v === COMMUNICATION_PORT_FALLBACK_VALUE ? '' : v)} > - ) : field.type === 'select' ? (