초기화

This commit is contained in:
2026-07-15 18:37:19 +09:00
commit 94abc5461d
1268 changed files with 380198 additions and 0 deletions
+684
View File
@@ -0,0 +1,684 @@
// 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("서버 통신 오류가 발생했습니다.");
}
}