685 lines
30 KiB
JavaScript
685 lines
30 KiB
JavaScript
// Real-time HMI Websocket Client & REST API Manager
|
|
let socket = null;
|
|
let currentSelectedEquipment = null;
|
|
let currentSelectedPrefix = "line1"; // 기본 프리픽스 설정
|
|
let currentActiveGroup = "A"; // 현재 선택된 제어 그룹 ('A' 또는 'B')
|
|
let lastReceivedStatusData = null; // 가장 최근 수신한 실시간 상태 정보 캐시
|
|
const pendingControlCommands = new Map();
|
|
let equipmentControlsLocked = true;
|
|
|
|
const BACKEND_URL = `${window.location.protocol}//${window.location.host}`;
|
|
const WS_PROTOCOL = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const WS_URL = `${WS_PROTOCOL}//${window.location.host}/ws/equipment`;
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
loadEquipmentCards();
|
|
connectWebsocket();
|
|
initHangerPopoverOutsideClick();
|
|
});
|
|
|
|
// Websocket 연결 및 자동 재접속 로직
|
|
function connectWebsocket() {
|
|
const wsStatusBadge = document.getElementById("hmi-websocket-status");
|
|
socket = new WebSocket(WS_URL);
|
|
|
|
socket.onopen = () => {
|
|
if (wsStatusBadge) {
|
|
wsStatusBadge.textContent = "실시간 연결";
|
|
wsStatusBadge.className = "badge badge-online";
|
|
}
|
|
};
|
|
|
|
socket.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.type === "equipment_update" && currentSelectedEquipment) {
|
|
// 선택한 설비의 업데이트 정보인 경우 HMI 갱신 수행
|
|
if (String(msg.equipment_id) === String(currentSelectedEquipment.id)) {
|
|
lastReceivedStatusData = msg.data;
|
|
updateHMILiveDisplay(msg.data);
|
|
}
|
|
} else if (msg.type === "command_ack") {
|
|
handleCommandAck(msg);
|
|
}
|
|
} catch (err) {
|
|
console.error("Websocket parsing error:", err);
|
|
}
|
|
};
|
|
|
|
socket.onclose = () => {
|
|
if (wsStatusBadge) {
|
|
wsStatusBadge.textContent = "연결 안됨 (재접속중...)";
|
|
wsStatusBadge.className = "badge badge-offline";
|
|
}
|
|
setTimeout(connectWebsocket, 3000); // 3초 후 자동 재연결 시도
|
|
};
|
|
|
|
socket.onerror = (err) => {
|
|
console.error("Websocket error:", err);
|
|
};
|
|
}
|
|
|
|
function handleCommandAck(message) {
|
|
if (!message.command_id || !["PLC_APPLIED", "FAILED", "TIMEOUT", "BUSY"].includes(message.status)) return;
|
|
|
|
const label = pendingControlCommands.get(message.command_id) || "PLC 제어 명령";
|
|
pendingControlCommands.delete(message.command_id);
|
|
|
|
if (message.status === "PLC_APPLIED") {
|
|
// alert(`${label}이(가) PLC에 정상 적용되었습니다.`);
|
|
} else if (message.status === "TIMEOUT") {
|
|
// alert(`${label} 적용 확인 시간이 초과되었습니다.`);
|
|
} else if (message.status === "BUSY") {
|
|
// alert(`${label} 요청 실패: 게이트웨이가 이전 제어 명령을 처리 중입니다 (바쁨).`);
|
|
} else {
|
|
// alert(`${label} 적용에 실패했습니다.`);
|
|
}
|
|
}
|
|
|
|
function setEquipmentControlsLocked(locked) {
|
|
equipmentControlsLocked = locked;
|
|
const overlay = document.getElementById("initialization-overlay");
|
|
if (!overlay) return;
|
|
overlay.classList.toggle("active", locked);
|
|
overlay.setAttribute("aria-hidden", String(!locked));
|
|
}
|
|
|
|
let cachedEquipments = [];
|
|
|
|
// 설비 카드 목록 로드
|
|
async function loadEquipmentCards() {
|
|
const container = document.getElementById("equipment-cards-container");
|
|
try {
|
|
const response = await fetch(`${BACKEND_URL}/api/equipment`);
|
|
if (response.ok) {
|
|
const list = await response.json();
|
|
cachedEquipments = list;
|
|
container.innerHTML = ""; // 초기화
|
|
|
|
if (currentSelectedEquipment) {
|
|
// 상세페이지 뷰 모드
|
|
container.style.display = "none";
|
|
document.getElementById("hmi-detail-panel").style.display = "block";
|
|
|
|
// 페이지 헤더 타이틀 업데이트
|
|
document.getElementById("main-page-title").innerText = "실시간 HMI 제어";
|
|
document.getElementById("main-page-desc").style.display = "none";
|
|
return;
|
|
}
|
|
|
|
// 목록 뷰 모드
|
|
container.style.display = "grid";
|
|
document.getElementById("hmi-detail-panel").style.display = "none";
|
|
|
|
// 페이지 헤더 타이틀 업데이트
|
|
document.getElementById("main-page-title").innerText = "설비 리스트";
|
|
document.getElementById("main-page-desc").innerText = "등록된 설비 목록 및 상세 사양 정보를 확인할 수 있습니다.";
|
|
document.getElementById("main-page-desc").style.display = "block";
|
|
|
|
if (list.length === 0) {
|
|
container.innerHTML = `<div style="grid-column: 1/-1; text-align:center; padding: 40px; color: var(--text-muted);">등록된 설비가 없습니다.</div>`;
|
|
return;
|
|
}
|
|
|
|
list.forEach(eq => {
|
|
const isOnline = eq.status === "running" || eq.is_online;
|
|
const card = document.createElement("div");
|
|
card.className = `card equip-card`;
|
|
card.onclick = () => selectEquipment(eq);
|
|
|
|
card.innerHTML = `
|
|
<div class="equip-card-header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px;">
|
|
<div>
|
|
<span class="equip-title" style="font-size:16px; font-weight:700;">${eq.name || eq.equipment_id}</span>
|
|
<div class="equip-type" style="font-size:11px; color:var(--text-muted); margin-top:2px;">타입: ${eq.type || "N/A"}</div>
|
|
</div>
|
|
<span class="badge ${isOnline ? 'badge-online' : 'badge-offline'}">${isOnline ? 'ONLINE' : 'OFFLINE'}</span>
|
|
</div>
|
|
<div style="font-size:11px; color:var(--text-muted); margin-bottom: 12px;">IP Address: ${eq.ip_address || "0.0.0.0"}</div>
|
|
<div class="equip-specs" style="display:grid; grid-template-columns: 1fr 1fr; gap:6px 12px; font-size:12px; color:var(--text-secondary); border-top:1px solid var(--border-color); padding-top:12px;">
|
|
<div><span style="color:var(--text-muted);">보빈 수:</span> ${eq.yarn_bobbin_count || 0}개</div>
|
|
<div><span style="color:var(--text-muted);">건조기 타입:</span> ${eq.dryer_type || "N/A"}</div>
|
|
<div><span style="color:var(--text-muted);">소비 전력:</span> ${eq.power_consumption || 0} kW</div>
|
|
<div><span style="color:var(--text-muted);">PLC 연결상태:</span> <span style="font-weight:600; color:${eq.device_state === 'ONLINE' ? 'var(--accent-green)' : 'var(--text-muted)'};">${eq.device_state || 'OFFLINE'}</span></div>
|
|
<div style="grid-column:1/-1;"><span style="color:var(--text-muted);">최대 속도:</span> Cut: ${eq.max_cutting_speed || 0} / Feed: ${eq.max_feed_speed || 0}</div>
|
|
</div>
|
|
`;
|
|
container.appendChild(card);
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to load equipment list:", e);
|
|
}
|
|
}
|
|
|
|
// 전체 목록으로 돌아가기
|
|
function goBackToEquipmentList() {
|
|
currentSelectedEquipment = null;
|
|
document.getElementById("hmi-detail-panel").style.display = "none";
|
|
document.getElementById("equipment-cards-container").style.display = "grid";
|
|
loadEquipmentCards();
|
|
}
|
|
|
|
// 설비 선택 및 HMI 렌더링
|
|
async function selectEquipment(eq) {
|
|
currentSelectedEquipment = eq;
|
|
currentSelectedPrefix = eq.line_prefix || "line1";
|
|
|
|
// 목록 화면 숨김 & HMI 디테일 표출
|
|
document.getElementById("equipment-cards-container").style.display = "none";
|
|
document.getElementById("hmi-detail-panel").style.display = "block";
|
|
|
|
// 페이지 헤더 타이틀 업데이트
|
|
document.getElementById("main-page-title").innerText = "실시간 HMI 제어";
|
|
document.getElementById("main-page-desc").style.display = "none";
|
|
|
|
// 초기 그룹은 'A'로 설정 및 탭 활성화
|
|
setHmiActiveGroup('A');
|
|
|
|
// DB에서 실시간 상세 상태 데이터 가져와 UI 동기화
|
|
try {
|
|
const response = await fetch(`${BACKEND_URL}/api/equipment/${eq.id}/status`);
|
|
if (response.ok) {
|
|
const statusData = await response.json();
|
|
lastReceivedStatusData = statusData;
|
|
updateHMILiveDisplay(statusData);
|
|
}
|
|
} catch (e) {
|
|
console.error("실시간 설비 상태 로드 실패:", e);
|
|
}
|
|
}
|
|
|
|
// A그룹 / B그룹 제어 전환 설정
|
|
function setHmiActiveGroup(groupChar) {
|
|
currentActiveGroup = groupChar;
|
|
|
|
// 각 그룹 카드의 제목 span 태그를 찾아 뒤에 A 또는 B 추가
|
|
document.querySelectorAll('.hmi-group-title > span:first-child').forEach(span => {
|
|
if (!span.dataset.baseTitle) {
|
|
span.dataset.baseTitle = span.textContent.trim();
|
|
}
|
|
span.textContent = `${span.dataset.baseTitle} ${groupChar}`;
|
|
});
|
|
|
|
// 건조부 히터 번호 변경 (A그룹: H1~H5, B그룹: H6~H10)
|
|
for (let h = 1; h <= 5; h++) {
|
|
const btn = document.getElementById(`btn-dryer-h${h}`);
|
|
if (btn) {
|
|
const heaterNum = groupChar === 'A' ? h : (h + 5);
|
|
const lampSpan = btn.querySelector('.hmi-lamp');
|
|
btn.innerHTML = `H${heaterNum} `;
|
|
if (lampSpan) {
|
|
btn.appendChild(lampSpan);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 탭 UI 교체
|
|
const tabA = document.getElementById("btn-tab-a");
|
|
const tabB = document.getElementById("btn-tab-b");
|
|
|
|
if (groupChar === 'A') {
|
|
tabA.classList.add("active");
|
|
tabB.classList.remove("active");
|
|
} else {
|
|
tabB.classList.add("active");
|
|
tabA.classList.remove("active");
|
|
}
|
|
|
|
// 행거 포트 그리드 재렌더링
|
|
renderHangerPorts();
|
|
|
|
// 팝오버 닫기
|
|
hideHangerPopover();
|
|
|
|
// 기존 캐싱 데이터가 존재한다면 즉시 새로운 오프셋 기준으로 리렌더링
|
|
if (lastReceivedStatusData) {
|
|
updateHMILiveDisplay(lastReceivedStatusData);
|
|
}
|
|
}
|
|
|
|
// 6그룹: 행거부 20개 포트 콤팩트 그리드 렌더링
|
|
function renderHangerPorts() {
|
|
const container = document.getElementById("hanger-ports-container");
|
|
container.innerHTML = "";
|
|
|
|
const startPortNum = currentActiveGroup === 'A' ? 1 : 21;
|
|
const endPortNum = currentActiveGroup === 'A' ? 20 : 40;
|
|
|
|
for (let p = startPortNum; p <= endPortNum; p++) {
|
|
const cell = document.createElement("div");
|
|
const paddedNum = String(p).padStart(2, '0');
|
|
cell.className = "port-cell port-idle";
|
|
cell.id = `port-cell-${p}`;
|
|
cell.innerText = `P${paddedNum}`;
|
|
cell.onclick = (e) => {
|
|
e.stopPropagation();
|
|
showHangerPopover(p, cell);
|
|
};
|
|
container.appendChild(cell);
|
|
}
|
|
}
|
|
|
|
// 행거 포트 클릭시 공용 제어 팝오버 표시
|
|
function showHangerPopover(portNum, cellElement) {
|
|
const popover = document.getElementById("hanger-popover");
|
|
const title = document.getElementById("popover-title");
|
|
|
|
const paddedNum = String(portNum).padStart(2, '0');
|
|
title.innerText = `P${paddedNum} 제어`;
|
|
|
|
// 팝오버 위치를 클릭된 셀 뒤로 가져다 붙임
|
|
popover.style.display = "flex";
|
|
|
|
// 1행인지 2행 이하인지 구분하여 위/아래 배치 결정 (그리드가 5열이므로 index 5(6번째 포트)부터 2행)
|
|
const startPortNum = currentActiveGroup === 'A' ? 1 : 21;
|
|
const relativeIndex = portNum - startPortNum;
|
|
|
|
if (relativeIndex >= 5) {
|
|
// 2행 이하: 셀 위에 배치 (위로 열림)
|
|
popover.style.top = `${cellElement.offsetTop - popover.offsetHeight - 6}px`;
|
|
} else {
|
|
// 1행: 셀 아래에 배치 (아래로 열림)
|
|
popover.style.top = `${cellElement.offsetTop + cellElement.offsetHeight + 6}px`;
|
|
}
|
|
|
|
popover.style.left = `${cellElement.offsetLeft + (cellElement.offsetWidth / 2) - 65}px`;
|
|
|
|
// 제어 버튼 바인딩 (PLC 쓰기 비트 인덱스 계산)
|
|
const btnAutoDown = document.getElementById("btn-popover-auto-down");
|
|
const btnManualDown = document.getElementById("btn-popover-manual-down");
|
|
const btnManualUp = document.getElementById("btn-popover-manual-up");
|
|
|
|
let autoDownBitId, manualDownBitId, manualUpBitId;
|
|
|
|
if (currentActiveGroup === 'A') {
|
|
autoDownBitId = 68 + (portNum - 1); // 68 ~ 87
|
|
manualDownBitId = 108 + (portNum - 1); // 108 ~ 127
|
|
manualUpBitId = 148 + (portNum - 1); // 148 ~ 167
|
|
} else {
|
|
autoDownBitId = 88 + (portNum - 21); // 88 ~ 107
|
|
manualDownBitId = 128 + (portNum - 21); // 128 ~ 147
|
|
manualUpBitId = 168 + (portNum - 21); // 168 ~ 187
|
|
}
|
|
|
|
btnAutoDown.onclick = () => { sendHmiBitDirect(autoDownBitId); hideHangerPopover(); };
|
|
btnManualDown.onclick = () => { sendHmiBitDirect(manualDownBitId); hideHangerPopover(); };
|
|
btnManualUp.onclick = () => { sendHmiBitDirect(manualUpBitId); hideHangerPopover(); };
|
|
}
|
|
|
|
function hideHangerPopover() {
|
|
const popover = document.getElementById("hanger-popover");
|
|
if (popover) popover.style.display = "none";
|
|
}
|
|
|
|
function initHangerPopoverOutsideClick() {
|
|
document.addEventListener("click", () => {
|
|
hideHangerPopover();
|
|
});
|
|
const popover = document.getElementById("hanger-popover");
|
|
if (popover) {
|
|
popover.addEventListener("click", (e) => {
|
|
e.stopPropagation(); // 팝오버 내부 클릭은 닫히지 않음
|
|
});
|
|
}
|
|
}
|
|
|
|
// 실시간 수신 데이터의 A/B 오프셋을 판단하여 UI 상태 갱신
|
|
function updateHMILiveDisplay(data) {
|
|
if (!data) return;
|
|
setEquipmentControlsLocked(data.sync_state === "SYNCING" || data.device_state !== "ONLINE");
|
|
|
|
const mRead = typeof data.m_read_raw === 'string' ? JSON.parse(data.m_read_raw || '{}') : (data.m_read_raw || {});
|
|
|
|
// A/B 그룹에 따른 읽기 비트 오프셋 설정
|
|
const isGroupB = (currentActiveGroup === 'B');
|
|
const mOffset = isGroupB ? 19 : 0;
|
|
const dOffset = isGroupB ? 5 : 0;
|
|
|
|
// ----------------------------------------------------
|
|
// 1그룹: 활성화 및 리셋 비트 상태 동기화 (m_read_raw)
|
|
// ----------------------------------------------------
|
|
// 안전 인터락 상태 (A-1) - 정상=녹색, 이상=적색
|
|
setInterlockLamp("lamp-estop", mRead[String(1 + mOffset)], 1);
|
|
setInterlockLamp("lamp-door-left", mRead[String(2 + mOffset)], 1);
|
|
setInterlockLamp("lamp-door-right", mRead[String(3 + mOffset)], 1);
|
|
setInterlockLamp("lamp-water-level", mRead[String(4 + mOffset)], 0);
|
|
setInterlockLamp("lamp-heater-interlock", mRead[String(5 + mOffset)], 1);
|
|
|
|
setLampStatus("lamp-group-active-on", mRead[String(6 + mOffset)]); // 활성화 ON
|
|
setLampStatus("lamp-group-active-off", mRead[String(7 + mOffset)], true); // 활성화 OFF (반전/빨간색)
|
|
|
|
// 함침&행거 ON/OFF (2그룹 & 6그룹 동시 업데이트)
|
|
setLampStatus("lamp-impreg-hanger-on-g2", mRead[String(11 + mOffset)]);
|
|
setLampStatus("lamp-impreg-hanger-off-g2", mRead[String(12 + mOffset)], true);
|
|
setLampStatus("lamp-impreg-hanger-on-g6", mRead[String(11 + mOffset)]);
|
|
setLampStatus("lamp-impreg-hanger-off-g6", mRead[String(12 + mOffset)], true);
|
|
|
|
setLampStatus("lamp-dryer-on", mRead[String(13 + mOffset)]); // 건조 ON
|
|
setLampStatus("lamp-dryer-off", mRead[String(14 + mOffset)], true);
|
|
setLampStatus("lamp-cutter-on", mRead[String(15 + mOffset)]); // 커팅 ON
|
|
setLampStatus("lamp-cutter-off", mRead[String(16 + mOffset)], true);
|
|
setLampStatus("lamp-transfer-on", mRead[String(17 + mOffset)]); // 이송 ON
|
|
setLampStatus("lamp-transfer-off", mRead[String(18 + mOffset)], true);
|
|
|
|
setLampStatus("lamp-group-stop", mRead[String(8 + mOffset)], true); // 전체정지
|
|
setLampStatus("lamp-group-pause", mRead[String(9 + mOffset)], true); // 일시정지
|
|
setLampStatus("lamp-group-restart", mRead[String(10 + mOffset)]); // 재시작
|
|
setLampStatus("lamp-interlock-reset", mRead[String(0 + mOffset)]); // 인터락 리셋
|
|
|
|
// ----------------------------------------------------
|
|
// 2그룹: 함침부 작동 상태 및 수치 동기화
|
|
// ----------------------------------------------------
|
|
const impregAutoOffset = isGroupB ? 42 : 38;
|
|
const impregManualOffset = isGroupB ? 43 : 39;
|
|
const pumpCircOffset = isGroupB ? 44 : 40;
|
|
const pumpDrainOffset = isGroupB ? 45 : 41;
|
|
|
|
setLampStatus("lamp-impreg-auto", mRead[String(impregAutoOffset)]);
|
|
setLampStatus("lamp-impreg-manual", mRead[String(impregManualOffset)]);
|
|
setLampStatus("lamp-pump-circ", mRead[String(pumpCircOffset)]);
|
|
setLampStatus("lamp-pump-drain", mRead[String(pumpDrainOffset)]);
|
|
|
|
// 순환펌프 재작동 시간 (D0 / D5)
|
|
const circTimeVal = data[`d_read_g${0 + dOffset}`] !== undefined ? data[`d_read_g${0 + dOffset}`] : 0;
|
|
document.getElementById("val-pump-circ-time").innerHTML = `${circTimeVal} <span style="font-size:11px; font-weight:normal; color:var(--text-secondary);">초</span>`;
|
|
|
|
// ----------------------------------------------------
|
|
// 3그룹: 건조기 작동 상태 동기화
|
|
// ----------------------------------------------------
|
|
const dryerAutoOffset = isGroupB ? 56 : 46;
|
|
const dryerManualOffset = isGroupB ? 57 : 47;
|
|
const fanTripOffset = isGroupB ? 58 : 48;
|
|
const heaterReqOffset = isGroupB ? 59 : 49;
|
|
const fanActiveOffset = isGroupB ? 60 : 50;
|
|
const heaterStartOffset = isGroupB ? 61 : 51;
|
|
|
|
setLampStatus("lamp-dryer-auto", mRead[String(dryerAutoOffset)]);
|
|
setLampStatus("lamp-dryer-manual", mRead[String(dryerManualOffset)]);
|
|
setLampStatus("lamp-dryer-fan", mRead[String(fanActiveOffset)]);
|
|
|
|
// 히터 1~5 (A그룹: 51~55, B그룹: 61~65)
|
|
for (let h = 1; h <= 5; h++) {
|
|
setLampStatus(`lamp-dryer-h${h}`, mRead[String(heaterStartOffset + (h - 1))]);
|
|
}
|
|
|
|
setLampStatus("lamp-fan-trip", mRead[String(fanTripOffset)], true); // 송풍기 트립 (알람이므로 빨간색)
|
|
setLampStatus("lamp-heater-req", mRead[String(heaterReqOffset)]);
|
|
|
|
// ----------------------------------------------------
|
|
// 4그룹: 커팅부 작동 상태 및 수치 동기화
|
|
// ----------------------------------------------------
|
|
const cutterActiveOffset = isGroupB ? 68 : 66;
|
|
const cutterStartOffset = isGroupB ? 69 : 67;
|
|
|
|
setLampStatus("lamp-cutter-motor-active", mRead[String(cutterActiveOffset)]);
|
|
setLampStatus("lamp-cutter-motor-start", mRead[String(cutterStartOffset)]);
|
|
|
|
// 커팅속도 (D1 / D6) - 소수점 1째자리 포맷팅 및 Hz 단위 적용
|
|
const rawCutterSpeed = data[`d_read_g${1 + dOffset}`] !== undefined ? data[`d_read_g${1 + dOffset}`] : 0;
|
|
const cutterSpeedVal = Number(rawCutterSpeed).toFixed(1);
|
|
document.getElementById("val-cutter-speed").innerHTML = `${cutterSpeedVal} <span style="font-size:11px; font-weight:normal; color:var(--text-secondary);">Hz</span>`;
|
|
|
|
// ----------------------------------------------------
|
|
// 5그룹: 이송 작동 상태 및 수치 동기화
|
|
// ----------------------------------------------------
|
|
const transAutoOffset = isGroupB ? 79 : 70;
|
|
const transManualOffset = isGroupB ? 80 : 71;
|
|
const transAutoStartOffset = isGroupB ? 81 : 72;
|
|
const transAutoStopOffset = isGroupB ? 82 : 73;
|
|
const transFwdOffset = isGroupB ? 83 : 74;
|
|
const transRevOffset = isGroupB ? 84 : 75;
|
|
const transStopOffset = isGroupB ? 85 : 76;
|
|
const transZeroOffset = isGroupB ? 86 : 77;
|
|
const transErrOffset = isGroupB ? 87 : 78;
|
|
|
|
setLampStatus("lamp-transfer-auto", mRead[String(transAutoOffset)]);
|
|
setLampStatus("lamp-transfer-manual", mRead[String(transManualOffset)]);
|
|
setLampStatus("lamp-transfer-auto-start", mRead[String(transAutoStartOffset)]);
|
|
setLampStatus("lamp-transfer-auto-stop", mRead[String(transAutoStopOffset)], true);
|
|
setLampStatus("lamp-transfer-fwd", mRead[String(transFwdOffset)]);
|
|
setLampStatus("lamp-transfer-rev", mRead[String(transRevOffset)]);
|
|
setLampStatus("lamp-transfer-stop", mRead[String(transStopOffset)], true);
|
|
setLampStatus("lamp-transfer-zero", mRead[String(transZeroOffset)]);
|
|
setLampStatus("lamp-transfer-err-reset", mRead[String(transErrOffset)]);
|
|
|
|
// 이송 데이터 (D2, D3, D4 / D7, D8, D9)
|
|
const transTargetPos = data[`d_read_g${2 + dOffset}`] !== undefined ? data[`d_read_g${2 + dOffset}`] : 0;
|
|
const transCurPos = data[`d_read_g${3 + dOffset}`] !== undefined ? data[`d_read_g${3 + dOffset}`] : 0;
|
|
const transTargetSpeed = data[`d_read_g${4 + dOffset}`] !== undefined ? data[`d_read_g${4 + dOffset}`] : 0;
|
|
|
|
// 자동 목표 이동량(그룹 2/7)은 REAL 소수 1자리, 수동 목표 속도(그룹 4/9)는 REAL 정수 표기
|
|
document.getElementById("val-transfer-target-pos").innerText = Number(transTargetPos).toFixed(1);
|
|
document.getElementById("val-transfer-cur-pos").innerHTML = `${Number(transCurPos).toFixed(1)} <span style="font-size:11px; font-weight:normal; color:var(--text-secondary);">mm/s</span>`;
|
|
document.getElementById("val-transfer-target-speed").innerText = Number(transTargetSpeed).toFixed(0);
|
|
|
|
// ----------------------------------------------------
|
|
// 6그룹: 행거부 작동 (20개 포트 상태 셀 리프레시)
|
|
// ----------------------------------------------------
|
|
const startPortNum = isGroupB ? 21 : 1;
|
|
const endPortNum = isGroupB ? 40 : 20;
|
|
|
|
for (let p = startPortNum; p <= endPortNum; p++) {
|
|
const cell = document.getElementById(`port-cell-${p}`);
|
|
if (!cell) continue;
|
|
|
|
let autoDownBitId, manualDownBitId, manualUpBitId;
|
|
|
|
if (!isGroupB) {
|
|
// A그룹 포트 읽기 비트 매핑
|
|
autoDownBitId = 88 + (p - 1); // 88 ~ 107
|
|
manualDownBitId = 128 + (p - 1); // 128 ~ 147
|
|
manualUpBitId = 168 + (p - 1); // 168 ~ 187 (168:자동상승, 169~187:수동상승)
|
|
} else {
|
|
// B그룹 포트 읽기 비트 매핑
|
|
autoDownBitId = 108 + (p - 21); // 108 ~ 127
|
|
manualDownBitId = 148 + (p - 21); // 148 ~ 167
|
|
manualUpBitId = 188 + (p - 21); // 188 ~ 207
|
|
}
|
|
|
|
// CSS 스타일 초기화 후 상태에 따라 네온 효과 부여
|
|
cell.className = "port-cell";
|
|
|
|
if (mRead[String(autoDownBitId)] === 1) {
|
|
cell.classList.add("port-active-auto-down");
|
|
} else if (mRead[String(manualDownBitId)] === 1) {
|
|
cell.classList.add("port-active-manual-down");
|
|
} else if (mRead[String(manualUpBitId)] === 1) {
|
|
cell.classList.add("port-active-manual-up");
|
|
} else {
|
|
cell.classList.add("port-idle");
|
|
}
|
|
}
|
|
}
|
|
|
|
// 램프 인디케이터 상태 변경 유틸리티 함수
|
|
function setLampStatus(elementId, value, isRed = false) {
|
|
const el = document.getElementById(elementId);
|
|
if (!el) return;
|
|
|
|
const activeClass = isRed ? "active-red" : "active";
|
|
|
|
// 버튼 바인딩용 클래스 처리 (상위 부모가 btn-hmi인 경우 버튼 테두리 색상 처리 포함)
|
|
const btnParent = el.closest(".btn-hmi");
|
|
|
|
if (value === 1 || value === true || String(value).toLowerCase() === "true" || String(value) === "1") {
|
|
el.classList.add("active");
|
|
if (btnParent) btnParent.classList.add(activeClass);
|
|
} else {
|
|
el.classList.remove("active");
|
|
if (btnParent) {
|
|
btnParent.classList.remove("active");
|
|
btnParent.classList.remove("active-red");
|
|
}
|
|
}
|
|
}
|
|
|
|
// 안전 인터락 전용 상태 갱신 함수 (정상 = 녹색(active), 이상 = 적색(active-red))
|
|
function setInterlockLamp(elementId, value, normalValue) {
|
|
const el = document.getElementById(elementId);
|
|
if (!el) return;
|
|
|
|
// value와 normalValue 값을 비교 (타입 무관하게 스트링 및 boolean 유연하게 비교)
|
|
const isNormal = (String(value) === String(normalValue) ||
|
|
(value === true && normalValue === 1) ||
|
|
(value === false && normalValue === 0));
|
|
|
|
if (isNormal) {
|
|
el.classList.add("active");
|
|
el.classList.remove("active-red");
|
|
} else {
|
|
el.classList.add("active-red");
|
|
el.classList.remove("active");
|
|
}
|
|
}
|
|
|
|
// HMI 비트 제어 패킷 전송 (A/B 오프셋 적용하여 자동 제어)
|
|
async function sendHmiBit(baseBitId, actionName) {
|
|
if (!currentSelectedEquipment) return;
|
|
|
|
// A/B 그룹별 PLC 쓰기 오프셋 계산
|
|
const isGroupB = (currentActiveGroup === 'B');
|
|
let actualBitId = baseBitId;
|
|
|
|
// 그룹별 매핑 규정 오프셋 적용
|
|
if (isGroupB) {
|
|
if (baseBitId >= 0 && baseBitId <= 11) {
|
|
actualBitId = baseBitId + 12; // 1그룹: Offset 12 (12~23)
|
|
} else if (baseBitId >= 24 && baseBitId <= 27) {
|
|
actualBitId = baseBitId + 4; // 2그룹: Offset 4 (28~31)
|
|
} else if (baseBitId >= 32 && baseBitId <= 39) {
|
|
actualBitId = baseBitId + 8; // 3그룹: Offset 8 (40~47)
|
|
} else if (baseBitId === 48) {
|
|
actualBitId = baseBitId + 1; // 4그룹: Offset 1 (49)
|
|
} else if (baseBitId >= 50 && baseBitId <= 58) {
|
|
actualBitId = baseBitId + 9; // 5그룹: Offset 9 (59~67)
|
|
}
|
|
}
|
|
|
|
await sendHmiBitDirect(actualBitId);
|
|
}
|
|
|
|
// 실제 HTTP 통신으로 비트 명령 전송
|
|
async function sendHmiBitDirect(bitId) {
|
|
if (!currentSelectedEquipment || equipmentControlsLocked) return;
|
|
|
|
try {
|
|
const payload = [[bitId, 1]]; // 제어 패킷 [[ID, 1]] (모멘터리 비트)
|
|
const response = await fetch(`${BACKEND_URL}/api/equipment/${currentSelectedEquipment.id}/control/m`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (!response.ok) {
|
|
console.error(`Control failed for bit ${bitId}`);
|
|
} else {
|
|
const result = await response.json();
|
|
pendingControlCommands.set(result.command_id, `M${bitId} 비트 제어`);
|
|
}
|
|
} catch (e) {
|
|
console.error("비트 전송 실패:", e);
|
|
}
|
|
}
|
|
|
|
// HMI 설정 레지스터 전송 (D영역 설정)
|
|
async function sendHmiRegister(baseRegId, inputElementId) {
|
|
if (!currentSelectedEquipment || equipmentControlsLocked) return;
|
|
|
|
const inputElement = document.getElementById(inputElementId);
|
|
if (!inputElement) return;
|
|
|
|
const rawVal = parseFloat(inputElement.value);
|
|
if (!Number.isFinite(rawVal)) return;
|
|
|
|
// A/B 그룹에 따른 쓰기 레지스터 ID 오프셋 계산 (A: D0, D1 / B: D2, D3)
|
|
const isGroupB = (currentActiveGroup === 'B');
|
|
let actualRegId = baseRegId;
|
|
if (isGroupB) {
|
|
actualRegId = baseRegId + 2; // Offset = 2
|
|
}
|
|
|
|
try {
|
|
const payload = [[actualRegId, rawVal]];
|
|
const response = await fetch(`${BACKEND_URL}/api/equipment/${currentSelectedEquipment.id}/control/d`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
pendingControlCommands.set(result.command_id, `D${actualRegId} 설정`);
|
|
inputElement.value = ""; // 입력창 초기화 (접수 팝업은 조작 편의를 위해 표시하지 않음)
|
|
} else {
|
|
alert("전송 실패");
|
|
}
|
|
} catch (e) {
|
|
console.error("수치 전송 실패:", e);
|
|
}
|
|
}
|
|
|
|
|
|
// 모달 다이얼로그 제어
|
|
function openAddModal() {
|
|
document.getElementById("add-equipment-modal").style.display = "flex";
|
|
}
|
|
|
|
function closeAddModal() {
|
|
document.getElementById("add-equipment-modal").style.display = "none";
|
|
document.getElementById("add-equipment-form").reset();
|
|
}
|
|
|
|
// 새 설비 저장
|
|
async function saveEquipment(event) {
|
|
event.preventDefault();
|
|
|
|
const eqId = document.getElementById("eq-id").value;
|
|
const name = document.getElementById("eq-name").value;
|
|
const type = document.getElementById("eq-type").value;
|
|
const ip = document.getElementById("eq-ip").value;
|
|
|
|
const payload = {
|
|
equipment_id: eqId,
|
|
name: name,
|
|
type: type,
|
|
ip_address: ip,
|
|
line_prefix: "line1" // 기본 기본라인 할당
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`${BACKEND_URL}/api/equipment`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (response.ok) {
|
|
closeAddModal();
|
|
loadEquipmentCards();
|
|
} else {
|
|
alert("중복된 ID이거나 등록 정보가 올바르지 않습니다.");
|
|
}
|
|
} catch (e) {
|
|
console.error("Save equipment error:", e);
|
|
}
|
|
}
|
|
|
|
// 수동 Full-Sync 초기화 요청 전송
|
|
async function triggerManualSync() {
|
|
if (!currentSelectedEquipment) return;
|
|
if (!confirm("게이트웨이의 내부 캐시를 초기화하고 PLC로부터 전체 데이터를 강제 수신(Full-Sync)하시겠습니까?")) return;
|
|
|
|
try {
|
|
const response = await fetch(`${BACKEND_URL}/api/equipment/${currentSelectedEquipment.id}/sync`, {
|
|
method: 'POST'
|
|
});
|
|
if (response.ok) {
|
|
setEquipmentControlsLocked(true);
|
|
alert("Full-Sync 초기화 명령이 전송되었습니다. 게이트웨이가 즉시 전체 상태를 갱신합니다.");
|
|
} else {
|
|
alert("초기화 명령 전송 실패");
|
|
}
|
|
} catch (e) {
|
|
console.error("Manual sync request failed:", e);
|
|
alert("서버 통신 오류가 발생했습니다.");
|
|
}
|
|
}
|