From 384dd7de6fb915ef06d2839c3b76623a2a72060d Mon Sep 17 00:00:00 2001 From: bless Date: Tue, 12 May 2026 16:41:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20odo=20meter=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/Controller.java | 35 +++- .../model/register/RegisterDTO.java | 14 +- .../model/register/enums/OdoDirSource.java | 17 ++ .../model/register/enums/OdoSpeedSource.java | 18 ++ .../model/register/sebDTO/OdoSpeedField.java | 20 +++ .../model/register/sebDTO/OdsDirField.java | 21 +++ .../service/SettingService.java | 19 ++ src/main/resources/static/css/style.css | 42 ++--- src/main/resources/static/index.html | 164 +++++------------- src/main/resources/static/js/api.js | 16 +- src/main/resources/static/js/main.js | 28 ++- src/main/resources/static/js/navigation.js | 58 ++++++- src/main/resources/static/js/protocol/can.js | 97 ++++++++--- .../resources/static/js/protocol/modbus.js | 102 ++++++++--- .../resources/static/js/protocol/odometer.js | 66 +++++++ .../resources/static/js/protocol/opcua.js | 93 +++++++--- .../static/js/protocol/protocolData.js | 111 +++++++++++- .../static/js/protocol/protocolValidation.js | 127 +++++++------- src/main/resources/static/js/state.js | 1 + 19 files changed, 739 insertions(+), 310 deletions(-) create mode 100644 src/main/java/org/mobidgim/mobidigimproject/model/register/enums/OdoDirSource.java create mode 100644 src/main/java/org/mobidgim/mobidigimproject/model/register/enums/OdoSpeedSource.java create mode 100644 src/main/java/org/mobidgim/mobidigimproject/model/register/sebDTO/OdoSpeedField.java create mode 100644 src/main/java/org/mobidgim/mobidigimproject/model/register/sebDTO/OdsDirField.java create mode 100644 src/main/resources/static/js/protocol/odometer.js diff --git a/src/main/java/org/mobidgim/mobidigimproject/controller/Controller.java b/src/main/java/org/mobidgim/mobidigimproject/controller/Controller.java index 73aeedc..35e0684 100644 --- a/src/main/java/org/mobidgim/mobidigimproject/controller/Controller.java +++ b/src/main/java/org/mobidgim/mobidigimproject/controller/Controller.java @@ -8,6 +8,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.io.IOException; +import java.util.Map; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -18,6 +19,7 @@ import org.springframework.http.MediaType; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.HashMap; @RestController @@ -27,13 +29,26 @@ public class Controller { private SettingService settingService; + private Map buildResponse(boolean success, String message) { + Map response = new HashMap<>(); + response.put("success", success); + response.put("message", message); + return response; + } + + private Map buildResponse(boolean success, String message, Object data) { + Map response = buildResponse(success, message); + response.put("data", data); + return response; + } + @PostMapping("/device") - public ResponseEntity settingDevice(@RequestBody DeviceDTO request) throws IOException { - try{ + public ResponseEntity> settingDevice(@RequestBody DeviceDTO request) { + try { settingService.saveFileWithDevice(request); - return ResponseEntity.ok("success"); - }catch(IOException e){ - return ResponseEntity.ok("save failed"); + return ResponseEntity.ok(buildResponse(true, "Device settings saved successfully")); + } catch (IOException e) { + return ResponseEntity.internalServerError().body(buildResponse(false, "Device settings save failed")); } } @@ -46,9 +61,13 @@ public class Controller { } @PostMapping("/protocol") - public ResponseEntity settingRegister(@RequestBody RegisterDTO request)throws IOException{ - settingService.saveFileWithRegister(request); - return ResponseEntity.ok(request); + public ResponseEntity> settingRegister(@RequestBody RegisterDTO request) { + try { + RegisterDTO saved = settingService.saveFileWithRegister(request); + return ResponseEntity.ok(buildResponse(true, "Protocol settings saved successfully", saved)); + } catch (IOException e) { + return ResponseEntity.internalServerError().body(buildResponse(false, "Protocol settings save failed")); + } } @GetMapping("/get-protocol") diff --git a/src/main/java/org/mobidgim/mobidigimproject/model/register/RegisterDTO.java b/src/main/java/org/mobidgim/mobidigimproject/model/register/RegisterDTO.java index 8c334bc..868640b 100644 --- a/src/main/java/org/mobidgim/mobidigimproject/model/register/RegisterDTO.java +++ b/src/main/java/org/mobidgim/mobidigimproject/model/register/RegisterDTO.java @@ -8,10 +8,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import lombok.Getter; import lombok.Setter; import org.mobidgim.mobidigimproject.model.register.enums.*; -import org.mobidgim.mobidigimproject.model.register.sebDTO.BoolToString; -import org.mobidgim.mobidigimproject.model.register.sebDTO.CanField; -import org.mobidgim.mobidigimproject.model.register.sebDTO.ModbusField; -import org.mobidgim.mobidigimproject.model.register.sebDTO.OpcUaField; +import org.mobidgim.mobidigimproject.model.register.sebDTO.*; import java.util.List; @@ -61,6 +58,9 @@ public class RegisterDTO { @JsonDeserialize(using = MeidDeserializer.class) private String meid; + @JsonProperty("odo_Meter") + private String odoMeter; + @JsonProperty("MODBUS") private List MODBUS; @@ -71,4 +71,10 @@ public class RegisterDTO { @JsonProperty("CAN") private List CAN; + @JsonProperty("odo_speed") + private OdoSpeedField odometerSpeed; + + @JsonProperty("odo_direction") + private OdsDirField odometerDir; + } diff --git a/src/main/java/org/mobidgim/mobidigimproject/model/register/enums/OdoDirSource.java b/src/main/java/org/mobidgim/mobidigimproject/model/register/enums/OdoDirSource.java new file mode 100644 index 0000000..293c20c --- /dev/null +++ b/src/main/java/org/mobidgim/mobidigimproject/model/register/enums/OdoDirSource.java @@ -0,0 +1,17 @@ +package org.mobidgim.mobidigimproject.model.register.enums; + +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum OdoDirSource { + CAN("can"), + HW("hw"); + + private final String odoDirSource; + + @JsonValue + public String getOdoSpeedSource() { + return odoDirSource; + } +} diff --git a/src/main/java/org/mobidgim/mobidigimproject/model/register/enums/OdoSpeedSource.java b/src/main/java/org/mobidgim/mobidigimproject/model/register/enums/OdoSpeedSource.java new file mode 100644 index 0000000..6975b40 --- /dev/null +++ b/src/main/java/org/mobidgim/mobidigimproject/model/register/enums/OdoSpeedSource.java @@ -0,0 +1,18 @@ +package org.mobidgim.mobidigimproject.model.register.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum OdoSpeedSource { + CAN("can"), + HW("hw"); + + private final String odoSpeedSource; + + @JsonValue + public String getOdoSpeedSource() { + return odoSpeedSource; + } +} diff --git a/src/main/java/org/mobidgim/mobidigimproject/model/register/sebDTO/OdoSpeedField.java b/src/main/java/org/mobidgim/mobidigimproject/model/register/sebDTO/OdoSpeedField.java new file mode 100644 index 0000000..b1e38f2 --- /dev/null +++ b/src/main/java/org/mobidgim/mobidigimproject/model/register/sebDTO/OdoSpeedField.java @@ -0,0 +1,20 @@ +package org.mobidgim.mobidigimproject.model.register.sebDTO; + +import lombok.Getter; +import lombok.Setter; +import org.mobidgim.mobidigimproject.model.register.enums.OdoSpeedSource; + +@Getter +@Setter +public class OdoSpeedField { + + private OdoSpeedSource odoSpeedSource; + + private String id; + + private int shift; + + private String mask; + + private String expr; +} diff --git a/src/main/java/org/mobidgim/mobidigimproject/model/register/sebDTO/OdsDirField.java b/src/main/java/org/mobidgim/mobidigimproject/model/register/sebDTO/OdsDirField.java new file mode 100644 index 0000000..177a6f0 --- /dev/null +++ b/src/main/java/org/mobidgim/mobidigimproject/model/register/sebDTO/OdsDirField.java @@ -0,0 +1,21 @@ +package org.mobidgim.mobidigimproject.model.register.sebDTO; + +import lombok.Getter; +import lombok.Setter; +import org.mobidgim.mobidigimproject.model.register.enums.OdoDirSource; +import org.mobidgim.mobidigimproject.model.register.enums.OdoSpeedSource; + +@Getter +@Setter +public class OdsDirField { + + private OdoDirSource odoSpeedSource; + + private String id; + + private int shift; + + private String mask; + + private String expr; +} diff --git a/src/main/java/org/mobidgim/mobidigimproject/service/SettingService.java b/src/main/java/org/mobidgim/mobidigimproject/service/SettingService.java index c8a8994..9bea2c9 100644 --- a/src/main/java/org/mobidgim/mobidigimproject/service/SettingService.java +++ b/src/main/java/org/mobidgim/mobidigimproject/service/SettingService.java @@ -48,6 +48,13 @@ public class SettingService { row.setKey(KEY_DEVICE); row.setValue(json); boardConfigRepository.save(row); // upsert (PK=`key`) + + String savedValue = boardConfigRepository.findById(KEY_DEVICE) + .map(BoardConfig::getValue) + .orElse(null); + if (!json.equals(savedValue)) { + throw new IOException("Device config verification failed after save"); + } } catch (Exception e) { throw new IOException("Failed to save device_config to DB", e); } @@ -90,6 +97,10 @@ public class SettingService { if (request.getProtocol() != Protocol.OPC_UA) request.setOPC_UA(null); if (request.getProtocol() != Protocol.MODBUS) request.setMODBUS(null); if (request.getCanInput() != CanInput.ON) request.setCAN(null); + if (!"on".equalsIgnoreCase(request.getOdoMeter())) { + request.setOdometerSpeed(null); + request.setOdometerDir(null); + } try { String json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(request); @@ -97,6 +108,14 @@ public class SettingService { row.setKey(KEY_PROTOCOL); row.setValue(json); boardConfigRepository.save(row); + + String savedValue = boardConfigRepository.findById(KEY_PROTOCOL) + .map(BoardConfig::getValue) + .orElse(null); + if (!json.equals(savedValue)) { + throw new IOException("Protocol config verification failed after save"); + } + return request; } catch (Exception e) { throw new IOException("Failed to save protocol_config to DB", e); diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css index 38a104f..2dff5fc 100644 --- a/src/main/resources/static/css/style.css +++ b/src/main/resources/static/css/style.css @@ -287,13 +287,8 @@ details.nav { #page-modbus th, #page-modbus td {border: 1px solid #bbb;padding: 6px 8px;text-align: left;background: #fff;} #page-modbus th {background: #e5e5e5;font-weight: 500;} #page-modbus tbody td {height: 34px;vertical-align: middle;} -#page-modbus .modbus-input-area {display: grid;grid-template-columns: 200px 200px 200px 200px;gap: 20px 20px;margin: 24px auto 16px auto;background: #eaeaea;padding: 16px;border-radius: 4px;margin-left: auto;margin-right: auto;} -#page-modbus .input-row {display: contents;} -#page-modbus .modbus-input-area label.nowrap {white-space: nowrap;} -#page-modbus .modbus-input-area label {min-width: auto;color: #444;white-space: normal;} -#page-modbus .modbus-input-area input {background: #e0e0e0;border: none;border-radius: 2px;padding: 4px 8px;min-width: auto;} -#page-modbus .modbus-actions {display: flex;gap: 24px;justify-content: center;margin: 16px 0 0 0;} -#page-modbus .modbus-actions button {background: #d3d3d3;border: 1px solid #aaa;border-radius: 2px;padding: 8px 24px;font-size: 16px;cursor: pointer;} +#page-modbus td input, +#page-modbus td select {width: 100%;border: 1px solid #c7c7c7;border-radius: 2px;padding: 6px 8px;background: #fdfdfd;font: inherit;box-sizing: border-box;} /* 하단 버튼 영역 */ #modbus-bottom-actions {display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} #modbus-bottom-actions button {background: #e0e0e0;border: 1px solid #aaa;border-radius: 2px;padding: 10px 18px;font-size: 16px;min-width: 140px;cursor: pointer;} @@ -304,13 +299,8 @@ details.nav { #page-opcua th, #page-opcua td {border: 1px solid #bbb;padding: 6px 8px;text-align: left;background: #fff;} #page-opcua th {background: #e5e5e5;font-weight: 500;} #page-opcua tbody td {height: 34px;vertical-align: middle;} -#page-opcua .opcua-input-area {display: grid;grid-template-columns: 200px 200px 200px 200px;gap: 20px 20px;margin: 24px auto 16px auto;background: #eaeaea;padding: 16px;border-radius: 4px;margin-left: auto;margin-right: auto;} -#page-opcua .input-row {display: contents;} -#page-opcua .opcua-input-area label.nowrap {white-space: nowrap;} -#page-opcua .opcua-input-area label {min-width: auto;color: #444;white-space: normal;} -#page-opcua .opcua-input-area input {background: #e0e0e0;border: none;border-radius: 2px;padding: 4px 8px;min-width: auto;} -#page-opcua .opcua-actions {display: flex;gap: 24px;justify-content: center;margin: 16px 0 0 0;} -#page-opcua .opcua-actions button {background: #d3d3d3;border: 1px solid #aaa;border-radius: 2px;padding: 8px 24px;font-size: 16px;cursor: pointer;} +#page-opcua td input, +#page-opcua td select {width: 100%;border: 1px solid #c7c7c7;border-radius: 2px;padding: 6px 8px;background: #fdfdfd;font: inherit;box-sizing: border-box;} #opcUa-bottom-actions {display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} #opcUa-bottom-actions button {background: #e0e0e0;border: 1px solid #aaa;border-radius: 2px;padding: 10px 18px;font-size: 16px;min-width: 140px;cursor: pointer;} @@ -321,16 +311,26 @@ details.nav { #page-can th {background: #e5e5e5;font-weight: 500;} #page-can tbody td {height: 34px;vertical-align: middle;} #page-can tbody td > span:empty::before {content: "\00a0";} -#page-can .can-input-area {display: grid;grid-template-columns: 200px 200px 200px 200px;gap: 20px 20px;margin: 24px auto 16px auto;background: #eaeaea;padding: 16px;border-radius: 4px;margin-left: auto;margin-right: auto;} -#page-can .input-row {display: contents;} -#page-can .can-input-area label.nowrap {white-space: nowrap;} -#page-can .can-input-area label {min-width: auto;color: #444;white-space: normal;} -#page-can .can-input-area input {background: #e0e0e0;border: none;border-radius: 2px;padding: 4px 8px;min-width: auto;} -#page-can .can-actions {display: flex;gap: 24px;justify-content: center;margin: 16px 0 0 0;} -#page-can .can-actions button {background: #d3d3d3;border: 1px solid #aaa;border-radius: 2px;padding: 8px 24px;font-size: 16px;cursor: pointer;} +#page-can td input, +#page-can td select {width: 100%;border: 1px solid #c7c7c7;border-radius: 2px;padding: 6px 8px;background: #fdfdfd;font: inherit;box-sizing: border-box;} #can-bottom-actions {display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} #can-bottom-actions button {background: #e0e0e0;border: 1px solid #aaa;border-radius: 2px;padding: 10px 18px;font-size: 16px;min-width: 140px;cursor: pointer;} +.inline-table-actions {display: flex;justify-content: flex-end;margin-top: 16px;} +.inline-table-actions button, +.inline-delete-btn {background: #e0e0e0;border: 1px solid #aaa;border-radius: 2px;padding: 8px 16px;font-size: 14px;cursor: pointer;} + +/* Odometer page styles */ +#page-odometer .card {background: #f7f7f7;border: 1px solid #bbb;box-shadow: none;padding: 24px;border-radius: 4px;display: flex;flex-direction: column;} +#page-odometer table {width: 100%;border-collapse: collapse;background: #fff;border: 1px solid #bbb;} +#page-odometer th, #page-odometer td {border: 1px solid #bbb;padding: 6px 8px;text-align: left;background: #fff;} +#page-odometer th {background: #e5e5e5;font-weight: 500;} +#page-odometer tbody td {height: 34px;vertical-align: middle;} +#page-odometer td input, +#page-odometer td select {width: 100%;padding: 4px 6px;border: 1px solid #aaa;border-radius: 4px;font-size: 14px;background: #fff;box-sizing: border-box;} +#odometer-bottom-actions {display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} +#odometer-bottom-actions button {background: #e0e0e0;border: 1px solid #aaa;border-radius: 2px;padding: 10px 18px;font-size: 16px;min-width: 140px;cursor: pointer;} + .selected-row {background: #d0e6ff !important;} .selected-row > td {background: #d0e6ff !important;} diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 24018b7..31ac8cc 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -26,6 +26,7 @@ + @@ -380,17 +381,6 @@ - - - - - - + + diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js index 12ab0d7..20ae88d 100644 --- a/src/main/resources/static/js/api.js +++ b/src/main/resources/static/js/api.js @@ -19,7 +19,21 @@ async function postJson(url, payload) { body: JSON.stringify(payload), }); - return response; + const contentType = response.headers.get('content-type') || ''; + let data = null; + + if (contentType.includes('application/json')) { + data = await response.json(); + } else { + const text = await response.text(); + data = text ? { message: text } : null; + } + + return { + ok: response.ok, + status: response.status, + data, + }; } export function loadDeviceConfig() { diff --git a/src/main/resources/static/js/main.js b/src/main/resources/static/js/main.js index e55bbb7..51882f3 100644 --- a/src/main/resources/static/js/main.js +++ b/src/main/resources/static/js/main.js @@ -5,9 +5,10 @@ import { bindDeviceFileActions, bindProtocolFileActions } from './device/deviceF import { createSsidController } from './device/ssid.js'; import { createCanController } from './protocol/can.js'; import { createModbusController } from './protocol/modbus.js'; +import { createOdometerController } from './protocol/odometer.js'; import { createOpcUaController } from './protocol/opcua.js'; import { createProtocolDataController } from './protocol/protocolData.js'; -import { isValidRegisterConfig } from './protocol/protocolValidation.js'; +import { getRegisterConfigValidationError, isValidRegisterConfig } from './protocol/protocolValidation.js'; import { byId, queryAll } from './utils/dom.js'; async function initializeApp() { @@ -25,6 +26,7 @@ async function initializeApp() { modbusController: createModbusController(), opcUaController: createOpcUaController(), canController: createCanController(), + odometerController: createOdometerController(), }); navigation.initialize(); @@ -39,8 +41,9 @@ async function initializeApp() { bindProtocolFileActions({ onLoadLocal: (data) => { - if (!isValidRegisterConfig(data)) { - alert('The contents of the selected file are not in a valid protocol setting file format.'); + const validationError = getRegisterConfigValidationError(data); + if (validationError) { + alert(`The contents of the selected file are not valid.\n${validationError}`); return; } @@ -56,8 +59,9 @@ async function initializeApp() { protocolController.collectProtocolData(), ); - if (!isValidRegisterConfig(protocolPayload)) { - alert('Register settings are currently invalid. Cannot be saved.'); + const validationError = getRegisterConfigValidationError(protocolPayload); + if (validationError) { + alert(`Register settings are currently invalid.\n${validationError}`); return; } @@ -67,13 +71,21 @@ async function initializeApp() { saveProtocolConfig(protocolPayload), ]); - if (deviceResponse.ok && protocolResponse.ok) { + const isDeviceSaved = deviceResponse.ok && deviceResponse.data?.success === true; + const isProtocolSaved = protocolResponse.ok && protocolResponse.data?.success === true; + + if (isDeviceSaved && isProtocolSaved) { alert('saved successfully'); return; } - alert('save failure'); - console.error('device response:', deviceResponse.status, 'protocol response:', protocolResponse.status); + const failureMessages = [ + !isDeviceSaved ? (deviceResponse.data?.message || `Device save failed (${deviceResponse.status})`) : null, + !isProtocolSaved ? (protocolResponse.data?.message || `Protocol save failed (${protocolResponse.status})`) : null, + ].filter(Boolean); + + alert(failureMessages.join('\n') || 'save failure'); + console.error('device response:', deviceResponse, 'protocol response:', protocolResponse); } catch (error) { console.error('Error during transfer:', error); alert('save failure'); diff --git a/src/main/resources/static/js/navigation.js b/src/main/resources/static/js/navigation.js index ece1325..882f0aa 100644 --- a/src/main/resources/static/js/navigation.js +++ b/src/main/resources/static/js/navigation.js @@ -10,10 +10,14 @@ export function createNavigation() { modbus: byId('page-modbus'), opcua: byId('page-opcua'), can: byId('page-can'), + odometer: byId('page-odometer'), }; const titleElement = byId('page-title'); const sidebarButtons = queryAll('.nav-items button'); + const protocolSelect = byId('reg_protocol'); + const canInputSelect = byId('reg_can_input'); + const odometerSelect = byId('OdoMeter'); function showPage(pageName) { Object.values(pages).forEach((page) => { @@ -30,27 +34,61 @@ export function createNavigation() { titleElement.textContent = sourceButton?.textContent ?? pageName; } - function toggleProtocolMenus(protocolValue, canInputValue) { + function toggleProtocolMenus(protocolValue, canInputValue, odometerValue) { const modbusSidebarBtn = byId('modbusSidebarBtn'); const opcUaSidebarBtn = byId('opcUaSidebarBtn'); const canSidebarBtn = byId('canSidebarBtn'); + const odometerSidebarBtn = byId('odometerSidebarBtn'); + const normalizedProtocol = String(protocolValue ?? '').trim().toUpperCase(); + const normalizedCanInput = String(canInputValue ?? '').trim().toLowerCase(); + const normalizedOdometer = String(odometerValue ?? '').trim().toLowerCase(); + const visibleProtocolPages = new Set(); if (modbusSidebarBtn) { - modbusSidebarBtn.style.display = protocolValue === 'MODBUS' ? 'block' : 'none'; + const isVisible = normalizedProtocol === 'MODBUS'; + modbusSidebarBtn.style.display = isVisible ? 'block' : 'none'; + if (isVisible) { + visibleProtocolPages.add('modbus'); + } } if (opcUaSidebarBtn) { - opcUaSidebarBtn.style.display = protocolValue === 'OPC_UA' ? 'block' : 'none'; + const isVisible = normalizedProtocol === 'OPC_UA'; + opcUaSidebarBtn.style.display = isVisible ? 'block' : 'none'; + if (isVisible) { + visibleProtocolPages.add('opcua'); + } } if (canSidebarBtn) { - canSidebarBtn.style.display = canInputValue === 'on' ? 'block' : 'none'; + const isVisible = normalizedCanInput === 'on'; + canSidebarBtn.style.display = isVisible ? 'block' : 'none'; + if (isVisible) { + visibleProtocolPages.add('can'); + } + } + + if (odometerSidebarBtn) { + const isVisible = normalizedOdometer === 'on'; + odometerSidebarBtn.style.display = isVisible ? 'block' : 'none'; + if (isVisible) { + visibleProtocolPages.add('odometer'); + } } const protocolDetails = queryAll('details.nav')[1]; if (protocolDetails) { protocolDetails.open = true; } + + const hiddenActivePage = ['modbus', 'opcua', 'can', 'odometer'].find((pageName) => { + const page = pages[pageName]; + return page && page.style.display !== 'none' && !visibleProtocolPages.has(pageName); + }); + + if (hiddenActivePage) { + showPage('reg'); + } } function initialize() { @@ -58,7 +96,19 @@ export function createNavigation() { button.addEventListener('click', () => showPage(button.dataset.page)); }); + const syncFromInputs = () => toggleProtocolMenus( + protocolSelect?.value, + canInputSelect?.value, + odometerSelect?.value, + ); + + [protocolSelect, canInputSelect, odometerSelect].forEach((element) => { + element?.addEventListener('change', syncFromInputs); + element?.addEventListener('input', syncFromInputs); + }); + showPage('ssid'); + syncFromInputs(); } return { diff --git a/src/main/resources/static/js/protocol/can.js b/src/main/resources/static/js/protocol/can.js index da68174..c003013 100644 --- a/src/main/resources/static/js/protocol/can.js +++ b/src/main/resources/static/js/protocol/can.js @@ -1,30 +1,73 @@ -import { byId, createTableEditor } from '../utils/dom.js'; +import { byId } from '../utils/dom.js'; + +function buildRow(data = {}) { + return ` + + + + + + + + + + + + + `; +} export function createCanController() { - return createTableEditor({ - listElement: byId('can-field-list'), - templateElement: byId('can-row-template'), - selectors: { - field: '.can-field-display', - id: '.can-id-display', - odt: '.can-odt-display', - dv: '.can-dv-display', - shift: '.can-shift-display', - expr: '.can-expr-display', - mask: '.can-mask-display', - }, - inputIds: { - field: 'can_field_input', - id: 'can_id_input', - odt: 'can_odt_input', - dv: 'can_dv_input', - shift: 'can_shift_input', - expr: 'can_expr_input', - mask: 'can_mask_input', - }, - addButtonId: 'can_add_btn', - modifyButtonId: 'can_modify_btn', - deleteButtonId: 'can_delete_btn', - emptyInputSelector: '.can-input-area input, .can-input-area select', - }); + const listElement = byId('can-field-list'); + const addButton = byId('can_add_row_btn'); + + function renderRows(rows = []) { + const sourceRows = rows.length ? rows : [{}]; + listElement.innerHTML = sourceRows.map((row) => buildRow(row)).join(''); + } + + function serializeRows(requiredKey) { + return Array.from(listElement.querySelectorAll('tr')) + .map((row) => ({ + field: row.querySelector('.can-field-input')?.value?.trim() ?? '', + id: row.querySelector('.can-id-input')?.value?.trim() ?? '', + odt: row.querySelector('.can-odt-input')?.value?.trim() ?? '', + dv: row.querySelector('.can-dv-input')?.value?.trim() ?? '', + shift: row.querySelector('.can-shift-input')?.value?.trim() ?? '', + expr: row.querySelector('.can-expr-input')?.value?.trim() ?? '', + mask: row.querySelector('.can-mask-input')?.value?.trim() ?? '', + })) + .filter((row) => row[requiredKey]); + } + + function addRow() { + listElement.insertAdjacentHTML('beforeend', buildRow()); + } + + function initialize() { + renderRows(); + + addButton?.addEventListener('click', addRow); + listElement.addEventListener('click', (event) => { + if (!event.target.classList.contains('inline-delete-btn')) { + return; + } + + event.target.closest('tr')?.remove(); + if (!listElement.children.length) { + renderRows(); + } + }); + } + + return { + initialize, + renderRows, + serializeRows, + }; } diff --git a/src/main/resources/static/js/protocol/modbus.js b/src/main/resources/static/js/protocol/modbus.js index 037f713..c871bba 100644 --- a/src/main/resources/static/js/protocol/modbus.js +++ b/src/main/resources/static/js/protocol/modbus.js @@ -1,28 +1,80 @@ -import { byId, createTableEditor } from '../utils/dom.js'; +import { byId } from '../utils/dom.js'; + +function buildRow(data = {}) { + return ` + + + + + + + + + + + + + + `; +} export function createModbusController() { - return createTableEditor({ - listElement: byId('modbus-field-list'), - templateElement: byId('modbus-row-template'), - selectors: { - field: '.field-display', - addr: '.addr-display', - idt: '.idt-display', - odt: '.odt-display', - dv: '.dv-display', - expr: '.expr-display', - }, - inputIds: { - field: 'modbus_field_input', - addr: 'modbus_addr_input', - idt: 'modbus_idt_input', - odt: 'modbus_odt_input', - dv: 'modbus_dv_input', - expr: 'modbus_expr_input', - }, - addButtonId: 'modbus_add_btn', - modifyButtonId: 'modbus_modify_btn', - deleteButtonId: 'modbus_delete_btn', - emptyInputSelector: '.modbus-input-area input, .modbus-input-area select', - }); + const listElement = byId('modbus-field-list'); + const addButton = byId('modbus_add_row_btn'); + + function renderRows(rows = []) { + const sourceRows = rows.length ? rows : [{}]; + listElement.innerHTML = sourceRows.map((row) => buildRow(row)).join(''); + } + + function serializeRows(requiredKey) { + return Array.from(listElement.querySelectorAll('tr')) + .map((row) => ({ + field: row.querySelector('.modbus-field-input')?.value?.trim() ?? '', + addr: row.querySelector('.modbus-addr-input')?.value?.trim() ?? '', + idt: row.querySelector('.modbus-idt-input')?.value?.trim() ?? '', + odt: row.querySelector('.modbus-odt-input')?.value?.trim() ?? '', + dv: row.querySelector('.modbus-dv-input')?.value?.trim() ?? '', + expr: row.querySelector('.modbus-expr-input')?.value?.trim() ?? '', + })) + .filter((row) => row[requiredKey]); + } + + function addRow() { + listElement.insertAdjacentHTML('beforeend', buildRow()); + } + + function initialize() { + renderRows(); + + addButton?.addEventListener('click', addRow); + listElement.addEventListener('click', (event) => { + if (!event.target.classList.contains('inline-delete-btn')) { + return; + } + + event.target.closest('tr')?.remove(); + if (!listElement.children.length) { + renderRows(); + } + }); + } + + return { + initialize, + renderRows, + serializeRows, + }; } diff --git a/src/main/resources/static/js/protocol/odometer.js b/src/main/resources/static/js/protocol/odometer.js new file mode 100644 index 0000000..fa7e603 --- /dev/null +++ b/src/main/resources/static/js/protocol/odometer.js @@ -0,0 +1,66 @@ +import { byId } from '../utils/dom.js'; + +function buildRow(target, data = {}) { + const safeData = data && typeof data === 'object' ? data : {}; + + return ` + + ${target} + + + + + + + + + `; +} + +export function createOdometerController() { + const listElement = byId('odometer-field-list'); + + function renderRows(data = {}) { + const safeData = data && typeof data === 'object' ? data : {}; + + listElement.innerHTML = [ + buildRow('speed', safeData.odo_speed), + buildRow('direction', safeData.odo_direction), + ].join(''); + } + + function readRow(target) { + const row = listElement.querySelector(`tr[data-target="${target}"]`); + if (!row) { + return null; + } + + return { + odo_speed_source: row.querySelector('.odometer-source-input')?.value?.trim() ?? '', + id: row.querySelector('.odometer-id-input')?.value?.trim() ?? '', + shift: row.querySelector('.odometer-shift-input')?.value?.trim() ?? '', + expr: row.querySelector('.odometer-expr-input')?.value?.trim() ?? '', + mask: row.querySelector('.odometer-mask-input')?.value?.trim() ?? '', + }; + } + + function collectData() { + return { + odo_speed: readRow('speed'), + odo_direction: readRow('direction'), + }; + } + + function initialize() { + renderRows(); + } + + return { + collectData, + initialize, + renderRows, + }; +} diff --git a/src/main/resources/static/js/protocol/opcua.js b/src/main/resources/static/js/protocol/opcua.js index a1c0d5b..79c338a 100644 --- a/src/main/resources/static/js/protocol/opcua.js +++ b/src/main/resources/static/js/protocol/opcua.js @@ -1,28 +1,71 @@ -import { byId, createTableEditor } from '../utils/dom.js'; +import { byId } from '../utils/dom.js'; + +function buildRow(data = {}) { + return ` + + + + + + + + + + + + `; +} export function createOpcUaController() { - return createTableEditor({ - listElement: byId('opcua-field-list'), - templateElement: byId('opcua-row-template'), - selectors: { - field: '.opcua-field-display', - addr: '.opcua-addr-display', - ns: '.opcua-ns-display', - odt: '.opcua-odt-display', - dv: '.opcua-dv-display', - expr: '.opcua-expr-display', - }, - inputIds: { - field: 'opcua_field_input', - addr: 'opcua_addr_input', - ns: 'opcua_ns_input', - odt: 'opcua_odt_input', - dv: 'opcua_dv_input', - expr: 'opcua_expr_input', - }, - addButtonId: 'opcua_add_btn', - modifyButtonId: 'opcua_modify_btn', - deleteButtonId: 'opcua_delete_btn', - emptyInputSelector: '.opcua-input-area input, .opcua-input-area select', - }); + const listElement = byId('opcua-field-list'); + const addButton = byId('opcua_add_row_btn'); + + function renderRows(rows = []) { + const sourceRows = rows.length ? rows : [{}]; + listElement.innerHTML = sourceRows.map((row) => buildRow(row)).join(''); + } + + function serializeRows(requiredKey) { + return Array.from(listElement.querySelectorAll('tr')) + .map((row) => ({ + field: row.querySelector('.opcua-field-input')?.value?.trim() ?? '', + addr: row.querySelector('.opcua-addr-input')?.value?.trim() ?? '', + ns: row.querySelector('.opcua-ns-input')?.value?.trim() ?? '', + odt: row.querySelector('.opcua-odt-input')?.value?.trim() ?? '', + dv: row.querySelector('.opcua-dv-input')?.value?.trim() ?? '', + expr: row.querySelector('.opcua-expr-input')?.value?.trim() ?? '', + })) + .filter((row) => row[requiredKey]); + } + + function addRow() { + listElement.insertAdjacentHTML('beforeend', buildRow()); + } + + function initialize() { + renderRows(); + + addButton?.addEventListener('click', addRow); + listElement.addEventListener('click', (event) => { + if (!event.target.classList.contains('inline-delete-btn')) { + return; + } + + event.target.closest('tr')?.remove(); + if (!listElement.children.length) { + renderRows(); + } + }); + } + + return { + initialize, + renderRows, + serializeRows, + }; } diff --git a/src/main/resources/static/js/protocol/protocolData.js b/src/main/resources/static/js/protocol/protocolData.js index a68ae33..5ec435e 100644 --- a/src/main/resources/static/js/protocol/protocolData.js +++ b/src/main/resources/static/js/protocol/protocolData.js @@ -5,6 +5,45 @@ export const normProtocol = (value) => String(value ?? '').trim().replace(/[-\s] export const getOpcArr = (data) => data?.OPC_UA ?? data?.['OPC-UA'] ?? []; const checkboxIds = ['ai0', 'ai1', 'ai2', 'ai3', 'di0', 'di1', 'di2', 'di3']; +const toLower = (value) => String(value ?? '').trim().toLowerCase(); +const toUpper = (value) => String(value ?? '').trim().toUpperCase(); + +function normalizeFourByteValue(value) { + const normalized = toLower(value).replace(/[_\-\s]+/g, ''); + + switch (normalized) { + case 'little': + return 'little'; + case 'littleswap': + return 'littleSwap'; + case 'bigswap': + return 'bigSwap'; + case 'big': + return 'big'; + default: + return String(value ?? '').trim(); + } +} + +function normalizeValueForElement(htmlId, value) { + switch (htmlId) { + case 'reg_protocol': + return normProtocol(value); + case 'reg_can_input': + case 'reg_dr_on_off': + case 'OdoMeter': + case 'reg_two_byte_order': + return toLower(value); + case 'reg_four_byte_order': + return normalizeFourByteValue(value); + case 'reg_device_type': + case 'reg_equipment': + case 'reg_speed_data_source': + return toUpper(value); + default: + return value ?? ''; + } +} export function normalizeRegisterForPost(register) { const normalized = JSON.parse(JSON.stringify(register || {})); @@ -18,6 +57,14 @@ export function normalizeRegisterForPost(register) { normalized.can_input = String(normalized.can_input).trim().toUpperCase(); } + if (normalized.OdoMeter) { + normalized.OdoMeter = String(normalized.OdoMeter).trim().toLowerCase(); + } + + if (normalized.odo_Meter) { + normalized.odo_Meter = String(normalized.odo_Meter).trim().toLowerCase(); + } + if (normalized.MEID !== undefined && normalized.MEID !== null) { const meid = String(normalized.MEID).trim(); normalized.MEID = /^\d+$/.test(meid) ? Number(meid) : meid; @@ -43,17 +90,50 @@ export function normalizeRegisterForPost(register) { return normalized; } -export function createProtocolDataController({ navigation, modbusController, opcUaController, canController }) { +export function createProtocolDataController({ + navigation, + modbusController, + opcUaController, + canController, + odometerController, +}) { const protocolSelect = byId('reg_protocol'); const canInputSelect = byId('reg_can_input'); + const drOnOffSelect = byId('reg_dr_on_off'); + const odometerSelect = byId('OdoMeter'); function syncProtocolMenus() { - navigation.toggleProtocolMenus(protocolSelect?.value, canInputSelect?.value); + navigation.toggleProtocolMenus(protocolSelect?.value, canInputSelect?.value, odometerSelect?.value); + } + + function syncProtocolMenusDeferred() { + syncProtocolMenus(); + window.requestAnimationFrame(syncProtocolMenus); + window.setTimeout(syncProtocolMenus, 0); + window.setTimeout(syncProtocolMenus, 50); + } + + function syncOdometerAvailability() { + if (!odometerSelect) { + return; + } + + const isDrEnabled = toLower(drOnOffSelect?.value) === 'on'; + odometerSelect.disabled = !isDrEnabled; + + if (!isDrEnabled) { + odometerSelect.value = 'off'; + } } function bindEvents() { + drOnOffSelect?.addEventListener('change', () => { + syncOdometerAvailability(); + syncProtocolMenus(); + }); protocolSelect?.addEventListener('change', syncProtocolMenus); canInputSelect?.addEventListener('change', syncProtocolMenus); + odometerSelect?.addEventListener('change', syncProtocolMenus); } function collectProtocolData() { @@ -77,6 +157,20 @@ export function createProtocolDataController({ navigation, modbusController, opc register.OPC_UA = opcUaController.serializeRows('field'); register.CAN = canController.serializeRows('field'); + const odometerData = odometerController.collectData(); + + if (toLower(register.dr_on) !== 'on') { + register.odo_Meter = 'off'; + register.odo_speed = null; + register.odo_direction = null; + } else if (String(register.odo_Meter ?? '').toLowerCase() === 'on') { + register.odo_speed = odometerData.odo_speed; + register.odo_direction = odometerData.odo_direction; + } else { + register.odo_speed = null; + register.odo_direction = null; + } + return register; } @@ -98,7 +192,7 @@ export function createProtocolDataController({ navigation, modbusController, opc if (element.type === 'checkbox') { element.checked = value === '1' || value === true; } else { - element.value = value ?? ''; + element.value = normalizeValueForElement(htmlId, value); } return; } @@ -126,15 +220,22 @@ export function createProtocolDataController({ navigation, modbusController, opc modbusController.renderRows(Array.isArray(register.MODBUS) ? register.MODBUS : []); opcUaController.renderRows(Array.isArray(getOpcArr(register)) ? getOpcArr(register) : []); canController.renderRows(Array.isArray(register.CAN) ? register.CAN : []); - syncProtocolMenus(); + odometerController.renderRows({ + odo_speed: register.odo_speed ?? null, + odo_direction: register.odo_direction ?? null, + }); + syncOdometerAvailability(); + syncProtocolMenusDeferred(); } function initialize() { modbusController.initialize(); opcUaController.initialize(); canController.initialize(); + odometerController.initialize(); bindEvents(); - syncProtocolMenus(); + syncOdometerAvailability(); + syncProtocolMenusDeferred(); } return { diff --git a/src/main/resources/static/js/protocol/protocolValidation.js b/src/main/resources/static/js/protocol/protocolValidation.js index b033ae2..3670552 100644 --- a/src/main/resources/static/js/protocol/protocolValidation.js +++ b/src/main/resources/static/js/protocol/protocolValidation.js @@ -1,103 +1,106 @@ import { getOpcArr, normProtocol } from './protocolData.js'; -export function isValidRegisterConfig(data) { - if (typeof data !== 'object' || data === null) { - console.error('Validation Error: Data is not an object or is null.'); +function hasAnyValue(item, keys) { + if (!item || typeof item !== 'object') { return false; } + return keys.some((key) => String(item[key] ?? '').trim() !== ''); +} + +export function getRegisterConfigValidationError(data) { + if (typeof data !== 'object' || data === null) { + return 'Data is not an object or is null.'; + } + if (typeof data.protocol === 'string') { data.protocol = normProtocol(data.protocol); } - const requiredFields = [ - 'protocol', - 'analog_input_level', - 'can_input', - 'dev_type', - 'dr_on', - 'equipment', - 'two_byte_order', - 'four_byte_order', - 'speed_data', - 'version', - 'equipment_id', - 'MEID', - ]; - - for (const field of requiredFields) { - if (!Object.prototype.hasOwnProperty.call(data, field)) { - console.error(`Validation Error: Missing required top-level field: '${field}'`); - return false; - } + const validProtocols = ['MODBUS', 'OPC_UA', 'CAN', 'NONE']; + if (data.protocol !== undefined && data.protocol !== '' && (typeof data.protocol !== 'string' || !validProtocols.includes(data.protocol))) { + return `Invalid protocol '${data.protocol}'.`; } - const validProtocols = ['MODBUS', 'OPC_UA', 'CAN', 'NONE']; - if (typeof data.protocol !== 'string' || !validProtocols.includes(data.protocol)) { - console.error(`Validation Error: Invalid protocol '${data.protocol}'`); - return false; + if (data.analog_input_level !== undefined + && data.analog_input_level !== null + && typeof data.analog_input_level !== 'string' + && typeof data.analog_input_level !== 'number') { + return "'analog_input_level' is not a string or number."; } - if (typeof data.analog_input_level !== 'string' && typeof data.analog_input_level !== 'number') { - console.error("Validation Error: 'analog_input_level' is not a string or number."); - return false; + if (String(data.dr_on ?? '').toLowerCase() !== 'on' && String(data.odo_Meter ?? '').toLowerCase() === 'on') { + return "'odo_Meter' cannot be on when 'dr_on' is off."; } if (data.protocol === 'MODBUS') { - if (!Array.isArray(data.MODBUS)) { - console.error("Validation Error: 'MODBUS' must be an array when protocol is MODBUS."); - return false; - } + for (const [index, item] of (Array.isArray(data.MODBUS) ? data.MODBUS : []).entries()) { + if (!hasAnyValue(item, ['field', 'idt', 'addr', 'odt', 'dv', 'expr'])) { + continue; + } - for (const [index, item] of data.MODBUS.entries()) { for (const key of ['field', 'idt', 'addr', 'odt', 'dv']) { - if (!item || String(item[key] ?? '').trim() === '') { - console.error(`Validation Error: MODBUS row ${index} missing '${key}'.`); - return false; + if (String(item?.[key] ?? '').trim() === '') { + return `MODBUS row ${index + 1} missing '${key}'.`; } } } - } else if (Array.isArray(data.MODBUS) && data.MODBUS.length > 0) { - console.error("Validation Error: 'MODBUS' must be empty when protocol is not MODBUS."); - return false; } if (data.protocol === 'OPC_UA') { - const opcRows = getOpcArr(data); - if (!Array.isArray(opcRows)) { - console.error("Validation Error: 'OPC_UA' must be an array when protocol is OPC_UA."); - return false; - } + for (const [index, item] of getOpcArr(data).entries()) { + if (!hasAnyValue(item, ['field', 'ns', 'addr', 'odt', 'dv', 'expr'])) { + continue; + } - for (const [index, item] of opcRows.entries()) { for (const key of ['field', 'ns', 'addr', 'odt', 'dv']) { - if (!item || String(item[key] ?? '').trim() === '') { - console.error(`Validation Error: OPC_UA row ${index} missing '${key}'.`); - return false; + if (String(item?.[key] ?? '').trim() === '') { + return `OPC_UA row ${index + 1} missing '${key}'.`; } } } } else if (data.OPC_UA && !Array.isArray(data.OPC_UA)) { - console.error("Validation Error: 'OPC_UA' must be an array if present."); - return false; + return "'OPC_UA' must be an array if present."; } if (String(data.can_input).toLowerCase() === 'on') { - if (!Array.isArray(data.CAN)) { - console.error("Validation Error: 'CAN' is required when can_input is on."); - return false; - } + for (const [index, item] of (Array.isArray(data.CAN) ? data.CAN : []).entries()) { + if (!hasAnyValue(item, ['field', 'id', 'odt', 'dv', 'shift', 'expr', 'mask'])) { + continue; + } - for (const [index, item] of data.CAN.entries()) { for (const key of ['field', 'id', 'odt', 'dv']) { - if (!item || String(item[key] ?? '').trim() === '') { - console.error(`Validation Error: CAN row ${index} missing '${key}'.`); - return false; + if (String(item?.[key] ?? '').trim() === '') { + return `CAN row ${index + 1} missing '${key}'.`; + } + } + } + } + + if (String(data.odo_Meter ?? '').toLowerCase() === 'on') { + const speed = data.odo_speed; + const direction = data.odo_direction; + + for (const [label, item] of [['odo_speed', speed], ['odo_direction', direction]]) { + if (!hasAnyValue(item, ['odo_speed_source', 'id', 'shift', 'expr', 'mask'])) { + continue; + } + + for (const key of ['odo_speed_source', 'id', 'shift']) { + if (String(item[key] ?? '').trim() === '') { + return `${label} missing '${key}'.`; } } } - } else if (Array.isArray(data.CAN) && data.CAN.length > 0) { - console.error("Validation Error: 'CAN' must be empty when can_input is off."); + } + + return null; +} + +export function isValidRegisterConfig(data) { + const error = getRegisterConfigValidationError(data); + if (error) { + console.error(`Validation Error: ${error}`); return false; } diff --git a/src/main/resources/static/js/state.js b/src/main/resources/static/js/state.js index 8a26a2c..64da6c7 100644 --- a/src/main/resources/static/js/state.js +++ b/src/main/resources/static/js/state.js @@ -55,6 +55,7 @@ export const REGISTER_ID_MAP = { reg_version: 'version', reg_equipment_id: 'equipment_id', reg_meid: 'MEID', + OdoMeter: 'odo_Meter', }; const appState = {