Files
HECO2/TestProgram/WebDashBoard/ErvWebDashboard/index.html
T
jeon 096111e983 feat: 06-17 신규 작업본 반영 (개발사양서/기능검토/승인원/Source 등 추가)
.claude/ 제외(.gitignore 추가). 기존 초기커밋(5a96a69) 위에 신규·수정·이동분 커밋.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 07:54:58 +09:00

629 lines
39 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HuevenEco DL 각실제어 모니터링·제어</title>
<style>
:root{
--bg:#F4F6FB; --card:#FFFFFF; --border:#E3E7EF; --text:#1F2733; --text2:#8A93A6;
--accent:#3B82F6; --accent-soft:#E7F0FF; --good:#22C55E; --warn:#F59E0B; --bad:#EF4444; --track:#EDEFF4;
}
*{box-sizing:border-box;}
body{margin:0;background:var(--bg);color:var(--text);font-family:"Segoe UI","Malgun Gothic",sans-serif;font-size:14px;}
.wrap{max-width:1360px;margin:0 auto;padding:16px;}
.topbar{display:flex;align-items:center;justify-content:space-between;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:14px 18px;margin-bottom:12px;}
.title{font-size:20px;font-weight:700;}
.subtitle{font-size:12px;color:var(--text2);margin-top:2px;}
.meta{font-size:12px;color:var(--text2);text-align:right;line-height:1.5;}
.modechip{display:inline-block;border-radius:6px;padding:2px 8px;font-size:11px;font-weight:700;color:#fff;margin-left:8px;}
.tabs{display:flex;gap:8px;margin-bottom:12px;align-items:center;}
.tab{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:9px 18px;cursor:pointer;font-weight:600;color:var(--text);}
.tab:hover{background:var(--accent-soft);}
.tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
.tab .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:7px;background:var(--good);vertical-align:middle;}
.tab .dot.off{background:var(--bad);}
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:12px;}
.card-title{font-size:13px;font-weight:600;color:var(--text2);margin-bottom:12px;}
.tiles{display:grid;grid-template-columns:repeat(7,1fr);gap:10px;}
.tile{background:var(--track);border-radius:10px;padding:12px;text-align:center;}
.tile .label{font-size:12px;color:var(--text2);}
.tile .value{font-size:20px;font-weight:700;margin-top:4px;}
.ctrl-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;}
.ctrl-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;}
.ctrl-row .lbl{width:84px;color:var(--text2);font-size:13px;}
.btn{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:7px 13px;cursor:pointer;font-size:13px;color:var(--text);}
.btn:hover{background:var(--accent-soft);}
.btn.active{background:var(--accent);color:#fff;border-color:var(--accent);}
.btn.on{background:var(--good);color:#fff;border-color:var(--good);}
.btn.off{background:var(--card);color:var(--text2);}
.btn:disabled{opacity:.4;cursor:not-allowed;}
.autocard{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;}
.autobox{background:var(--track);border-radius:10px;padding:10px;text-align:center;}
.autobox .rn{font-size:12px;color:var(--text2);}
.autobox .sc{font-size:22px;font-weight:700;}
.autobox .vol{font-size:12px;color:var(--text2);margin-top:2px;}
.rooms{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;}
.room{border:1px solid var(--border);border-radius:12px;padding:14px;}
.room-head{display:flex;align-items:center;justify-content:space-between;}
.room-name{font-size:16px;font-weight:700;}
.aq{display:flex;align-items:center;gap:6px;font-weight:600;font-size:13px;}
.aq .led{width:14px;height:14px;border-radius:50%;}
.sensors{display:grid;grid-template-columns:1fr 1fr;gap:6px 12px;margin-top:12px;}
.sensor{display:flex;justify-content:space-between;}
.sensor .k{color:var(--text2);}
.sensor .v{font-weight:700;}
.ledrow{display:flex;justify-content:space-between;font-size:12px;color:var(--text2);margin-top:10px;}
input[type=range]{width:100%;}
.vsp-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;}
.vsp-row{display:flex;align-items:center;gap:6px;background:var(--track);border-radius:8px;padding:8px 10px;}
.vsp-row .vl{width:60px;font-weight:600;font-size:13px;}
.vsp-row .u{color:var(--text2);font-size:12px;}
.vsp-row input{width:56px;border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:13px;text-align:right;}
.hyst-grid{display:grid;grid-template-columns:90px repeat(4,1fr);gap:6px 10px;align-items:center;max-width:620px;}
.hyst-grid .hh{font-size:12px;color:var(--text2);font-weight:600;text-align:center;}
.hyst-grid .pl{font-weight:700;font-size:13px;}
.hyst-grid input{width:100%;border:1px solid var(--border);border-radius:6px;padding:5px 6px;font-size:13px;text-align:right;}
.thr-grid{display:grid;grid-template-columns:70px repeat(4,1fr);gap:5px 8px;align-items:center;margin-top:8px;}
.thr-grid .hh{font-size:11px;color:var(--text2);font-weight:600;text-align:center;}
.thr-grid .pl{font-weight:700;font-size:12px;}
.thr-grid input{width:100%;border:1px solid var(--border);border-radius:6px;padding:4px 5px;font-size:12px;text-align:right;}
.modal-bg{position:fixed;inset:0;background:rgba(15,23,42,.4);display:none;align-items:flex-start;justify-content:center;z-index:100;}
.modal-bg.show{display:flex;}
.modal{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:18px;margin-top:48px;width:92%;max-width:760px;max-height:86vh;overflow:auto;box-shadow:0 12px 40px rgba(0,0,0,.22);}
.modal.modal-wide{max-width:1180px;}
.modal-h{display:flex;justify-content:space-between;align-items:center;font-size:15px;font-weight:700;margin-bottom:14px;}
.footer{font-size:12px;color:var(--text2);text-align:center;margin-top:8px;}
.tokenbox{font-size:12px;border:1px solid var(--border);border-radius:8px;padding:6px 8px;width:130px;}
/* 그래프 */
.graph-wrap{display:grid;grid-template-columns:200px 1fr;gap:12px;}
.graph-side{border:1px solid var(--border);border-radius:8px;padding:8px;max-height:420px;overflow:auto;}
.graph-side .grp{font-weight:700;font-size:12px;margin:8px 0 3px;}
.graph-side label{display:block;font-size:12px;padding:2px 0;cursor:pointer;}
.graph-side label input{margin-right:5px;}
.graph-main canvas{width:100%;height:380px;display:block;border:1px solid var(--border);border-radius:8px;}
.glegend{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;color:var(--text2);margin-top:6px;}
.glegend span{display:inline-flex;align-items:center;gap:4px;}
.glegend i{width:11px;height:3px;border-radius:2px;display:inline-block;}
.gtoolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;}
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<div>
<div class="title">HuevenEco DL 각실제어 모니터링·제어 <span class="modechip" id="modeChip"></span></div>
<div class="subtitle">EW11(RS-485↔WiFi) ↔ 미니PC 수집/제어 서버 · PC 대시보드 기능 통합</div>
</div>
<div style="display:flex;align-items:center;gap:12px;">
<input class="tokenbox" id="tokenBox" placeholder="제어 토큰(선택)">
<button class="btn" id="demoBtn" style="display:none">데모 일시정지</button>
<div class="meta">만든이 : 전경선<br>만든날 : 2026.06.3</div>
</div>
</div>
<div class="tabs" id="tabs"></div>
<div class="card">
<div class="card-title">현장 개요</div>
<div class="tiles" id="tiles"></div>
</div>
<!-- ===== 제어 ===== -->
<div class="card">
<div class="card-title">ERV 제어 (원격)</div>
<div class="ctrl-grid">
<div>
<div class="ctrl-row"><span class="lbl">전원</span><button class="btn" id="cPower" onclick="togglePower()">OFF</button>
<span class="lbl" style="width:auto;margin-left:8px;">ERV 리셋</span><button class="btn" id="cReset" onclick="toggleReset()">OFF</button></div>
<div class="ctrl-row"><span class="lbl">운전모드</span><span id="cModes"></span></div>
<div class="ctrl-row"><span class="lbl">풍량</span><span id="cFans"></span>
<span class="lbl" style="width:auto;margin-left:8px;">자동 프리셋</span><span id="cPresetsMain"></span></div>
<div class="ctrl-row"><span class="lbl">(꺼짐)예약</span>
<select id="cReserve" class="btn" onchange="setReserve(this.value)">
<option value="0">해제</option><option value="1">1시간</option><option value="2">2시간</option>
<option value="3">3시간</option><option value="4">4시간</option><option value="5">5시간</option>
<option value="6">6시간</option><option value="7">7시간</option><option value="8">8시간</option>
</select>
<span id="cReserveTxt" style="color:var(--text2);margin-left:6px;font-size:12px;"></span></div>
</div>
<div>
<div class="ctrl-row"><span class="lbl">부가모드</span><span id="cSubs"></span></div>
<div class="ctrl-row"><span class="lbl">연동후드</span><button class="btn" id="cHood" onclick="toggleHood()">OFF</button>
<span id="cHoodConn" style="font-size:12px;margin-left:8px;"></span></div>
<div class="ctrl-row"><span class="lbl">스마트수면<br>시간설정</span>
<input type="time" id="slStart" class="tokenbox" style="width:110px" value="23:00">~
<input type="time" id="slEnd" class="tokenbox" style="width:110px" value="07:00">
<label style="font-size:12px;display:flex;align-items:center;gap:4px;"><input type="checkbox" id="slEnable" onchange="renderSleep()">예약</label>
<span id="slTxt" style="font-size:12px;color:var(--text2);"></span></div>
<div class="ctrl-row"><span class="lbl">설정</span>
<button class="btn" onclick="openModal('hyst')">공기질 히스테리시스 ▸</button>
<button class="btn" onclick="openModal('vsp')">풍량 VSP ▸</button>
</div>
</div>
</div>
</div>
<!-- ===== 자동운전 상태 ===== -->
<div class="card">
<div class="card-title">자동운전 상태 (표시 전용) <span id="autoSummary" style="font-weight:400;margin-left:8px;"></span></div>
<div class="autocard" id="autoCard"></div>
</div>
<div class="card">
<div class="card-title">각실 모니터링 · 제어</div>
<div class="rooms" id="rooms"></div>
</div>
<!-- ===== 로그 그래프 (별도 창) ===== -->
<div class="card">
<div class="card-title">로그 그래프</div>
<button class="btn" onclick="openModal('graph')">📂 로그 그래프 — 날짜별 불러오기 ▸</button>
<span style="color:var(--text2);font-size:12px;margin-left:8px;">날짜 선택 → 불러오기 → 좌측 시리즈 선택/해제 → (필요 시) 엑셀 저장</span>
</div>
<div class="footer" id="footNote"></div>
<!-- ===== 팝업: 공기질 히스테리시스 / 풍량 VSP ===== -->
<div class="modal-bg" id="modalBg" onclick="closeModalBg(event)">
<div class="modal" id="modalHyst" style="display:none">
<div class="modal-h"><span>공기질 히스테리시스 (ECO / NORMAL / TURBO)</span>
<button class="btn" onclick="closeModal()">닫기</button></div>
<div class="ctrl-row"><span class="lbl">프리셋</span><span id="cPresets"></span></div>
<div class="ctrl-row" style="gap:8px;margin-top:2px">
<button class="btn" onclick="hystReadErv()">읽어오기</button>
<button class="btn" onclick="hystPreset()">프리셋(기본값)</button>
</div>
<div style="font-weight:700;font-size:13px;margin:6px 0 2px">데드밴드(하강)</div>
<div id="hystGrid"></div>
<button class="btn" style="margin-top:10px" onclick="applyHyst()">데드밴드 변경</button>
<div style="font-weight:700;font-size:13px;margin:16px 0 2px">오염단계 임계 (L1~L4 상한)</div>
<div id="thrGrid"></div>
<button class="btn" style="margin-top:10px" onclick="applyThr()">임계 변경</button>
</div>
<div class="modal" id="modalVsp" style="display:none">
<div class="modal-h"><span>풍량 VSP 제어 · 상태 (SA 급기 / EA 배기)</span>
<button class="btn" onclick="closeModal()">닫기</button></div>
<div class="vsp-grid" id="vspGrid"></div>
<div style="margin-top:10px;display:flex;gap:8px">
<button class="btn" onclick="vspReadErv()">읽어오기</button>
<button class="btn" onclick="vspPreset()">프리셋(기본값)</button>
<button class="btn" onclick="vspApply()">변경</button>
</div>
</div>
<div class="modal modal-wide" id="modalGraph" style="display:none">
<div class="modal-h"><span>로그 그래프 (가로 시간 · 세로 댐퍼/센서/모드)</span>
<button class="btn" onclick="closeModal()">닫기</button></div>
<div class="gtoolbar">
<span style="color:var(--text2)">날짜</span>
<input type="date" id="gDate" class="tokenbox" style="width:150px">
<button class="btn" onclick="loadGraphClick()">📂 불러오기</button>
<button class="btn" onclick="gSelectAll(true)">전체선택</button>
<button class="btn" onclick="gSelectAll(false)">전체해제</button>
<button class="btn" onclick="exportCsv()">📊 엑셀(CSV) 저장</button>
<span id="gCount" style="color:var(--text2);font-size:12px"></span>
</div>
<div class="graph-wrap">
<div class="graph-side" id="gSide"></div>
<div class="graph-main">
<canvas id="gChart" width="1000" height="380"></canvas>
<div class="glegend" id="gLegend"></div>
</div>
</div>
</div>
</div>
</div>
<script>
const MODE = location.protocol.startsWith("http") ? "server" : "demo";
const SITES = ["site01","site02","site03"];
const SITE_LABEL = {site01:"현장1",site02:"현장2",site03:"현장3"};
const ROOMS = ["거실","침실1","침실2","침실3"];
const ROOM_COLOR = ["#3B82F6","#22C55E","#F59E0B","#8B5CF6"];
const RUNMODE = {0:"OFF",1:"환기",2:"자동",3:"공청",4:"바이패스"};
const MODE_BTNS = [[1,"환기"],[2,"자동"],[3,"공청"],[4,"바이패스"]];
const SUB_BTNS = [[1,"수면",1],[2,"조리",2],[3,"회복",4]];
const PRESET_BTNS = [[0,"ECO"],[1,"NORMAL"],[2,"TURBO"]];
const AUTOSTATE = {0:"분산",1:"집중"};
const PRESET = {0:"ECO",1:"NORMAL",2:"TURBO"};
const AQ = {1:{t:"매우나쁨",c:"#EF4444"},2:{t:"나쁨",c:"#F59E0B"},3:{t:"보통",c:"#22C55E"},4:{t:"좋음",c:"#3B82F6"}};
const VSP_LABELS = ["환기1","환기2","환기3","환기4","바이패스","공청1","공청2","공청3","공청4"];
const VSP_GROUP = [0,0,0,0,1,2,2,2,2];
const VSP_INDEX = [1,2,3,4,1,1,2,3,4];
const VSP_DEMO = [[56,57],[63,63],[70,70],[86,85],[67,75],[65,0],[72,0],[78,0],[80,0]];
const HYST_PRESETS = ["ECO","NORMAL","TURBO"];
const HYST_FIELDS = ["pm25","pm10","voc","co2"];
const POLL = ["co2","pm25","pm10","voc"]; // 임계 오염원 순서(서버 thr 키)
const HYST_DEMO = [{pm25:20,pm10:40,voc:250,co2:600},{pm25:30,pm10:50,voc:300,co2:700},{pm25:40,pm10:70,voc:400,co2:900}];
const THR_DEMO = [
{co2:[1000,1300,1600,2000],pm25:[20,38,60,86],pm10:[40,86,126,173],voc:[171,195,308,438]},
{co2:[800,1100,1400,1700],pm25:[14,29,49,69],pm10:[28,66,102,138],voc:[120,150,250,350]},
{co2:[700,1000,1300,1600],pm25:[12,23,38,52],pm10:[24,53,78,104],voc:[103,120,192,263]}];
// ===== 사양 기본값 (모달 '프리셋' 버튼) : VSP=개발사양서 p.12(휴벤 ECO2/좌타입=펌웨어) / 데드밴드·임계=p.10 =====
const VSP_DEFAULT = [[56,57],[63,63],[70,70],[86,85],[67,75],[65,0],[72,0],[78,0],[80,0]];
const DB_DEFAULT = [{pm25:2,pm10:5,voc:5,co2:50},{pm25:2,pm10:5,voc:5,co2:50},{pm25:2,pm10:5,voc:3,co2:30}];
const THR_DEFAULT = THR_DEMO.map(t=>({co2:[...t.co2],pm25:[...t.pm25],pm10:[...t.pm10],voc:[...t.voc]}));
const HIST=60;
let current="site01", demoOn=true, tick=0;
const state={};
SITES.forEach((s,si)=>{ state[s]={
online:false,
g:{power:1,run_mode:2,auto_state:0,fan_mode:2,sub_mode:0,hood:0,hyst_preset:1,error_code:0,reset:0,reserve_remain:0},
rooms:Array.from({length:4},()=>({damper_sa:0,damper_ea:0,pm25:0,pm10:0,voc:0,co2:0,air_quality:3,led_dim:0,load_score:0,final_volume:0,temp:0,humi:0})),
vsp:VSP_DEMO.map(([sa,ea])=>({sa,ea})),
hyst:HYST_DEMO.map(h=>({...h})),
thr:THR_DEMO.map(t=>({co2:[...t.co2],pm25:[...t.pm25],pm10:[...t.pm10],voc:[...t.voc]})),
seed:si*100 }; });
// 모달 편집 버퍼 (폴링이 덮어쓰지 않음). null=ERV(state)값 표시. 읽어오기/프리셋/편집 시 채움, 변경/닫기 시 비움.
let bufVsp=null, bufHyst=null, bufThr=null;
function copyVsp(v){ return (v||[]).map(x=>({sa:x.sa,ea:x.ea})); }
function copyHyst(h){ return (h||[]).map(x=>({pm25:x.pm25,pm10:x.pm10,voc:x.voc,co2:x.co2})); }
function copyThr(t){ return (t||[]).map(x=>({co2:[...x.co2],pm25:[...x.pm25],pm10:[...x.pm10],voc:[...x.voc]})); }
// ===== 데모 생성기 =====
function genSensors(s,t){
const st=state[s]; st.online=true;
st.g.auto_state=Math.floor(t/8)%2;
for(let r=0;r<4;r++){
const seed=t+r*13+st.seed, rm=st.rooms[r];
rm.pm25=10+(seed*3+Math.round(8*Math.sin(t/6+r)))%60;
rm.pm10=15+(seed*5)%90; rm.voc=100+(seed*7)%400;
rm.co2=450+(seed*11)%700+Math.round(60*Math.sin(t/5+r));
rm.air_quality=1+(seed%4); rm.load_score=(seed*4)%5; rm.final_volume=seed%5;
rm.temp=22+(seed)%6; rm.humi=40+(seed*7)%30;
}
}
// ===== 제어 전송 =====
function applyLocal(site,action,a){
const st=state[site],g=st.g;
if(action==="power")g.power=a.value;
else if(action==="runmode")g.run_mode=a.value;
else if(action==="fan")g.fan_mode=a.value;
else if(action==="hood")g.hood=a.value?(g.hood|1):(g.hood&~1);
else if(action==="preset")g.hyst_preset=a.value;
else if(action==="submode"){const bit=a.type===1?1:a.type===2?2:4;g.sub_mode=a.value?(g.sub_mode|bit):(g.sub_mode&~bit);}
else if(action==="damper")st.rooms[a.room-1][a.type===1?"damper_ea":"damper_sa"]=a.value;
else if(action==="led")st.rooms[a.room-1].led_dim=a.value;
else if(action==="reset")g.reset=a.value;
else if(action==="reserve")g.reserve_remain=a.value*3600;
else if(action==="vsp")st.vsp[a._idx]={sa:a.sa,ea:a.ea};
else if(action==="hyst")st.hyst[a.preset]={pm25:a.pm25,pm10:a.pm10,voc:a.voc,co2:a.co2};
else if(action==="hystthr")st.thr[a.preset][a._poll]=[a.l1,a.l2,a.l3,a.l4];
}
async function ctl(action,a={}){
applyLocal(current,action,a);
renderTiles(); renderControls(); renderAuto(); renderHyst(); renderThr(); renderVsp(); renderRooms();
if(MODE==="server"){
try{
const r=await fetch("/api/control",{method:"POST",
headers:{"Content-Type":"application/json","X-Auth-Token":document.getElementById("tokenBox").value||""},
body:JSON.stringify(Object.assign({site:current,action},a))});
if(!r.ok) flash(`제어 실패 (${r.status})`);
}catch(e){ flash("제어 전송 오류: "+e.message); }
}
}
function flash(msg){ const f=document.getElementById("footNote"); f.textContent=msg; f.style.color="var(--bad)"; setTimeout(setFoot,2500); }
function okMsg(msg){ const f=document.getElementById("footNote"); f.textContent=msg; f.style.color="var(--good)"; setTimeout(setFoot,2200); }
// ===== 제어 핸들러 =====
function togglePower(){ ctl("power",{value:state[current].g.power?0:1}); }
function toggleHood(){ ctl("hood",{value:(state[current].g.hood&1)?0:1}); }
function setMode(m){ ctl("runmode",{value:m}); }
function setFan(s){ const g=state[current].g; if(g.run_mode===2)return; if(g.run_mode===4&&s>1)return; ctl("fan",{value:s}); }
function toggleSub(type,bit){
const g=state[current].g, orig=g.sub_mode, on=(orig&bit)?0:1;
if(on){
if(type!==1 && (orig&1)) ctl("submode",{type:1,value:0});
if(type!==2 && (orig&2)) ctl("submode",{type:2,value:0});
if(type!==3 && (orig&4)) ctl("submode",{type:3,value:0});
}
ctl("submode",{type,value:on});
}
function setPreset(p){ ctl("preset",{value:p}); }
function setDamperSa(room){ if(state[current].g.run_mode===2)return; const cur=state[current].rooms[room-1].damper_sa; ctl("damper",{room,type:0,value:cur?0:1}); }
function setDamperEa(room){ if(state[current].g.run_mode===2)return; const cur=state[current].rooms[room-1].damper_ea; ctl("damper",{room,type:1,value:cur?0:1}); }
function setLed(room,val){ ctl("led",{room,value:parseInt(val)}); }
function setReserve(h){ ctl("reserve",{value:parseInt(h)}); }
function toggleReset(){ ctl("reset",{value:state[current].g.reset?0:1}); }
function setHyst(pi,field,val){ if(!bufHyst)bufHyst=copyHyst(state[current].hyst); bufHyst[pi][field]=parseInt(val)||0; }
function applyHyst(){ const hb=bufHyst||state[current].hyst;
for(let pi=0;pi<3;pi++){ const v=hb[pi]; ctl("hyst",{preset:pi,pm25:v.pm25,pm10:v.pm10,voc:v.voc,co2:v.co2}); }
bufHyst=null; okMsg("데드밴드 전송 완료"); }
function setThr(pi,poll,li,val){ if(!bufThr)bufThr=copyThr(state[current].thr); bufThr[pi][poll][li]=parseInt(val)||0; }
function applyThr(){ const tb=bufThr||state[current].thr;
for(let pi=0;pi<3;pi++) POLL.forEach((poll,pp)=>{ const v=tb[pi][poll];
ctl("hystthr",{preset:pi,pollutant:pp,l1:v[0],l2:v[1],l3:v[2],l4:v[3],_poll:poll}); });
bufThr=null; okMsg("임계 전송 완료"); }
// 히스테리시스 읽어오기/프리셋 (편집은 '데드밴드 변경'/'임계 변경'으로 전송)
function hystReadErv(){ bufHyst=copyHyst(state[current].hyst); bufThr=copyThr(state[current].thr); renderHyst(); renderThr(); okMsg("ERV값 불러옴"); }
function hystPreset(){ bufHyst=DB_DEFAULT.map(h=>({...h})); bufThr=copyThr(THR_DEFAULT); renderHyst(); renderThr(); okMsg("사양 기본값 불러옴 — '변경'으로 전송"); }
// ===== 팝업 =====
function openModal(which){
document.getElementById("modalHyst").style.display = which==="hyst"?"block":"none";
document.getElementById("modalVsp").style.display = which==="vsp"?"block":"none";
document.getElementById("modalGraph").style.display = which==="graph"?"block":"none";
document.getElementById("modalBg").classList.add("show");
if(which==="vsp") bufVsp=copyVsp(state[current].vsp); // 열 때 ERV 현재값
if(which==="hyst"){ bufHyst=copyHyst(state[current].hyst); bufThr=copyThr(state[current].thr); }
renderControls(); renderHyst(); renderThr(); renderVsp();
if(which==="graph"){ renderSide(); loadGraph(); } // 열 때 현재 로드날짜로 갱신
}
function graphOpen(){ return document.getElementById("modalBg").classList.contains("show")
&& document.getElementById("modalGraph").style.display==="block"; }
function closeModal(){ document.getElementById("modalBg").classList.remove("show"); bufVsp=bufHyst=bufThr=null; }
function closeModalBg(e){ if(e.target.id==="modalBg") closeModal(); }
function setVsp(i,field,val){
if(!bufVsp) bufVsp=copyVsp(state[current].vsp);
bufVsp[i][field]=parseInt(val)||0;
}
function vspReadErv(){ bufVsp=copyVsp(state[current].vsp); renderVsp(); okMsg("ERV값 불러옴"); }
function vspPreset(){ bufVsp=VSP_DEFAULT.map(([sa,ea])=>({sa,ea})); renderVsp(); okMsg("사양 기본값 불러옴 — '변경'으로 전송"); }
function vspApply(){ const b=bufVsp||state[current].vsp;
for(let i=0;i<9;i++) ctl("vsp",{group:VSP_GROUP[i],index:VSP_INDEX[i],sa:b[i].sa,ea:b[i].ea,_idx:i});
bufVsp=null; renderVsp(); okMsg("VSP 전송 완료"); }
// ===== 스마트수면 시간설정 (브라우저 스케줄, 현재 현장에 적용) =====
let _slLast=-1;
function hm2min(v){ const [h,m]=(v||"0:0").split(":").map(Number); return (h*60+m)||0; }
function renderSleep(){
const en=document.getElementById("slEnable").checked;
const s=document.getElementById("slStart").value, e=document.getElementById("slEnd").value;
document.getElementById("slTxt").textContent = en?`예약 ON (${s}~${e}, 현재 현장)`:"예약 OFF";
}
function sleepTick(){
if(!document.getElementById("slEnable").checked) return;
const now=new Date(), cur=now.getHours()*60+now.getMinutes();
const s=hm2min(document.getElementById("slStart").value), e=hm2min(document.getElementById("slEnd").value);
const inWin = s<e ? (cur>=s&&cur<e) : (cur>=s||cur<e); // 자정 넘김 지원
const want = inWin?1:0;
if(want!==_slLast){ _slLast=want; toggleSubForce(1, want); }
}
function toggleSubForce(type,on){ // 스케줄용: 상태와 무관하게 지정값 전송
if(on){ const g=state[current].g; if(g.sub_mode&2) ctl("submode",{type:2,value:0}); if(g.sub_mode&4) ctl("submode",{type:3,value:0}); }
ctl("submode",{type,value:on});
}
// ===== 렌더 =====
function renderTabs(){
const el=document.getElementById("tabs"); el.innerHTML="";
SITES.forEach(s=>{ const d=document.createElement("div");
d.className="tab"+(s===current?" active":"");
d.innerHTML=`<span class="dot ${state[s].online?"":"off"}"></span>${SITE_LABEL[s]}`;
d.onclick=()=>{current=s;renderAll();if(graphOpen())loadGraph();}; el.appendChild(d); });
}
function tile(label,value,color){return `<div class="tile"><div class="label">${label}</div><div class="value" ${color?`style="color:${color}"`:""}>${value}</div></div>`;}
function renderTiles(){
const g=state[current].g;
const err=g.error_code===0?"정상":("0x"+g.error_code.toString(16).toUpperCase().padStart(4,"0"));
document.getElementById("tiles").innerHTML=
tile("전원",g.power?"ON":"OFF",g.power?"var(--good)":"var(--text2)")+
tile("운전모드",RUNMODE[g.run_mode])+
tile("풍량",g.run_mode===2?`자동(${g.fan_mode})`:g.fan_mode)+
tile("자동상태",AUTOSTATE[g.auto_state])+
tile("연동후드",(g.hood&1)?"ON":"OFF",(g.hood&1)?"var(--good)":"var(--text2)")+
tile("히스테리시스",PRESET[g.hyst_preset])+
tile("에러",err,g.error_code?"var(--bad)":"var(--text2)");
}
function renderControls(){
const g=state[current].g;
const p=document.getElementById("cPower"); p.textContent=g.power?"ON":"OFF"; p.className="btn "+(g.power?"on":"off");
const rs=document.getElementById("cReset"); rs.textContent=g.reset?"ON":"OFF"; rs.className="btn "+(g.reset?"on":"off");
const h=document.getElementById("cHood"); h.textContent=(g.hood&1)?"ON":"OFF"; h.className="btn "+((g.hood&1)?"on":"off");
const hc=document.getElementById("cHoodConn");
if(g.hood&1){ const conn=(g.hood&4); hc.textContent=conn?"후드 연결":"후드 연결 안됨"; hc.style.color=conn?"var(--good)":"var(--bad)"; }
else hc.textContent="";
document.getElementById("cModes").innerHTML=MODE_BTNS.map(([m,l])=>
`<button class="btn ${g.run_mode===m?"active":""}" onclick="setMode(${m})">${l}</button>`).join("");
const fanMax = g.run_mode===4?1:4;
document.getElementById("cFans").innerHTML=[0,1,2,3,4].map(s=>
`<button class="btn ${g.fan_mode===s?"active":""}" ${(g.run_mode===2||s>fanMax)?"disabled":""} onclick="setFan(${s})">${s}</button>`).join("");
const presetMain=document.getElementById("cPresetsMain");
if(presetMain) presetMain.innerHTML=PRESET_BTNS.map(([v,l])=>
`<button class="btn ${g.hyst_preset===v?"active":""}" ${g.run_mode!==2?"disabled":""} onclick="setPreset(${v})">${l}</button>`).join("");
const sec=g.reserve_remain||0, rsv=document.getElementById("cReserve"), rt=document.getElementById("cReserveTxt");
if(rt) rt.textContent = sec>0 ? `꺼짐까지 ${Math.floor(sec/3600)}:${String(Math.floor(sec%3600/60)).padStart(2,"0")}:${String(sec%60).padStart(2,"0")}` : "예약 없음";
if(rsv && sec===0 && rsv.value!=="0") rsv.value="0";
document.getElementById("cSubs").innerHTML=SUB_BTNS.map(([t,l,bit])=>
`<button class="btn ${(g.sub_mode&bit)?"active":""}" onclick="toggleSub(${t},${bit})">${l}</button>`).join("");
const cp=document.getElementById("cPresets");
if(cp) cp.innerHTML=PRESET_BTNS.map(([v,l])=>
`<button class="btn ${g.hyst_preset===v?"active":""}" onclick="setPreset(${v})">${l}</button>`).join("");
}
function renderAuto(){
const st=state[current], g=st.g;
const total=st.rooms.reduce((a,rm)=>a+(rm.load_score||0),0);
document.getElementById("autoSummary").innerHTML = g.run_mode===2
? `동작: <b>${AUTOSTATE[g.auto_state]}</b> · 합산부하 ${total} · ${g.fan_mode}`
: "(자동모드 아님)";
document.getElementById("autoCard").innerHTML = st.rooms.map((rm,i)=>
`<div class="autobox"><div class="rn">${ROOMS[i]}</div><div class="sc">${rm.load_score||0}</div><div class="vol">풍량 ${rm.final_volume||0}</div></div>`).join("");
}
function renderRooms(){
const st=state[current], el=document.getElementById("rooms"); el.innerHTML="";
const isAuto = st.g.run_mode===2;
st.rooms.forEach((rm,i)=>{ const aq=AQ[rm.air_quality]||AQ[3]; const room=i+1;
el.insertAdjacentHTML("beforeend",`
<div class="room">
<div class="room-head"><div class="room-name">${ROOMS[i]}</div>
<div class="aq"><span class="led" style="background:${aq.c}"></span>${aq.t}</div></div>
<div class="ctrl-row" style="margin-top:10px;">
<span class="lbl" style="width:auto">급기댐퍼</span>
<button class="btn ${rm.damper_sa?"on":"off"}" ${isAuto?"disabled":""} onclick="setDamperSa(${room})">${rm.damper_sa?"열림":"닫힘"}</button>
<span class="lbl" style="width:auto;margin-left:10px;">배기댐퍼</span>
<button class="btn ${rm.damper_ea?"on":"off"}" ${isAuto?"disabled":""} onclick="setDamperEa(${room})">${rm.damper_ea?"열림":"닫힘"}</button>
</div>
<div class="sensors">
<div class="sensor"><span class="k">PM2.5</span><span class="v">${rm.pm25}</span></div>
<div class="sensor"><span class="k">PM10</span><span class="v">${rm.pm10}</span></div>
<div class="sensor"><span class="k">VOC</span><span class="v">${rm.voc}</span></div>
<div class="sensor"><span class="k">CO2</span><span class="v">${rm.co2}</span></div>
<div class="sensor"><span class="k">TEMP</span><span class="v">${rm.temp}℃</span></div>
<div class="sensor"><span class="k">HUMI.</span><span class="v">${rm.humi}%</span></div>
</div>
<div class="ledrow"><span>LED 디밍 (모든 모드)</span><span>${rm.led_dim} / 9 · 풍량 ${rm.final_volume} · 부하 ${rm.load_score}</span></div>
<input type="range" min="0" max="9" value="${rm.led_dim}" oninput="setLed(${room},this.value)">
</div>`);
});
}
function renderHyst(){
const grid=document.getElementById("hystGrid");
if(document.activeElement && grid.contains(document.activeElement)) return;
const hb=bufHyst||state[current].hyst;
let h='<div class="hyst-grid"><span class="hh"></span><span class="hh">PM2.5</span><span class="hh">PM10</span><span class="hh">VOC</span><span class="hh">CO2</span>';
HYST_PRESETS.forEach((name,pi)=>{ const v=(hb&&hb[pi])||{pm25:0,pm10:0,voc:0,co2:0};
h+=`<span class="pl">${name}</span>`+HYST_FIELDS.map(f=>`<input type="number" min="0" value="${v[f]}" onchange="setHyst(${pi},'${f}',this.value)">`).join(""); });
grid.innerHTML=h+"</div>";
}
function renderThr(){
const grid=document.getElementById("thrGrid");
if(document.activeElement && grid.contains(document.activeElement)) return;
const tb=bufThr||state[current].thr;
let h='<div class="thr-grid"><span class="hh"></span><span class="hh">L1</span><span class="hh">L2</span><span class="hh">L3</span><span class="hh">L4</span>';
HYST_PRESETS.forEach((name,pi)=>{
POLL.forEach(poll=>{ const v=(tb&&tb[pi]&&tb[pi][poll])||[0,0,0,0];
h+=`<span class="pl">${name}·${poll.toUpperCase()}</span>`+
[0,1,2,3].map(li=>`<input type="number" min="0" value="${v[li]}" onchange="setThr(${pi},'${poll}',${li},this.value)">`).join("");
});
});
grid.innerHTML=h+"</div>";
}
function renderVsp(){
const grid=document.getElementById("vspGrid");
if(document.activeElement && grid.contains(document.activeElement)) return;
const arr=bufVsp||state[current].vsp;
grid.innerHTML = VSP_LABELS.map((lab,i)=>{ const v=(arr&&arr[i])||{sa:0,ea:0};
return `<div class="vsp-row"><span class="vl">${lab}</span>`+
`<span class="u">SA</span><input type="number" min="0" value="${v.sa}" onchange="setVsp(${i},'sa',this.value)">`+
`<span class="u">EA</span><input type="number" min="0" value="${v.ea}" onchange="setVsp(${i},'ea',this.value)"></div>`;
}).join("");
}
function renderAll(){renderTabs();renderTiles();renderControls();renderAuto();renderHyst();renderThr();renderVsp();renderRooms();renderSleep();}
// ===== 로그 그래프 =====
let GDEF=[], GON=[], GDATA=[], _loadedDate=todayStr();
function buildDefs(){
const c=["#3B82F6","#EF4444","#22C55E","#8B5CF6","#F59E0B","#06B6D4","#EC4899","#64748B","#84CC16"];
const defs=[
{g:"운전",l:"운전모드",f:s=>s.run_mode},
{g:"운전",l:"자동-집중",f:s=>s.auto_mode===1?1:0},
{g:"운전",l:"자동-분산",f:s=>s.auto_mode===2?1:0},
{g:"운전",l:"프리셋-ECO",f:s=>s.hyst_preset===0?1:0},
{g:"운전",l:"프리셋-NORMAL",f:s=>s.hyst_preset===1?1:0},
{g:"운전",l:"프리셋-TURBO",f:s=>s.hyst_preset===2?1:0},
{g:"운전",l:"풍량",f:s=>s.fan_mode},
{g:"운전",l:"전원",f:s=>s.power},
{g:"시나리오",l:"스마트수면",f:s=>s.sleep},
{g:"시나리오",l:"쾌적조리",f:s=>s.cook},
{g:"시나리오",l:"안심회복",f:s=>s.recover},
];
ROOMS.forEach((nm,r)=>{
[["CO2","co2"],["PM2.5","pm25"],["PM10","pm10"],["VOC","voc"],["온도","temp"],["습도","humi"],["LED","led"],["부하","level"],
["급기댐퍼","damper_sa"],["배기댐퍼","damper_ra"]].forEach(([lab,key])=>{
defs.push({g:nm,l:`${nm} ${lab}`,f:s=>(s.rooms[r]?s.rooms[r][key]:0)});
});
});
defs.forEach((d,i)=>d.color=c[i%c.length]);
GDEF=defs; GON=defs.map(_=>true);
}
function renderSide(){
const el=document.getElementById("gSide"); let h=""; let grp=null;
GDEF.forEach((d,i)=>{ if(d.g!==grp){grp=d.g; h+=`<div class="grp">${grp}</div>`;}
const lab=d.l.startsWith(d.g+" ")?d.l.slice(d.g.length+1):d.l;
h+=`<label><input type="checkbox" ${GON[i]?"checked":""} onchange="GON[${i}]=this.checked;drawGraph()">${lab}</label>`; });
el.innerHTML=h;
}
function gSelectAll(v){ GON=GON.map(_=>v); renderSide(); drawGraph(); }
// "불러오기" 클릭 : 선택한 날짜를 확정 로드. 오늘이면 이후 실시간 갱신, 과거면 정적 유지.
function loadGraphClick(){ _loadedDate = document.getElementById("gDate").value || todayStr(); loadGraph(); }
async function loadGraph(){
const dt=_loadedDate;
if(MODE==="server"){
try{ GDATA=await (await fetch(`/api/history?site=${current}&date=${dt}`)).json(); }
catch(e){ GDATA=[]; }
}else{ GDATA=demoHistory(dt); }
const live = dt===todayStr();
document.getElementById("gCount").textContent=`${dt} · ${GDATA.length}개 (5초)${live?" · 실시간":" · 과거(정적)"}`;
drawGraph();
}
function drawGraph(){
const cv=document.getElementById("gChart"),ctx=cv.getContext("2d"),W=cv.width,H=cv.height,pad=34;
ctx.clearRect(0,0,W,H);
const on=GDEF.map((d,i)=>GON[i]?i:-1).filter(i=>i>=0);
let max=-Infinity,min=Infinity;
GDATA.forEach(s=>on.forEach(i=>{const v=GDEF[i].f(s); if(v>max)max=v; if(v<min)min=v;}));
if(!isFinite(max)){max=1;min=0;} if(max===min)max=min+1; const range=max-min;
ctx.strokeStyle="#EDEFF4";ctx.fillStyle="#8A93A6";ctx.font="10px sans-serif";ctx.lineWidth=1;
for(let g=0;g<=4;g++){const y=pad+(H-2*pad)*g/4;ctx.beginPath();ctx.moveTo(pad,y);ctx.lineTo(W-6,y);ctx.stroke();
ctx.fillText(Math.round(max-range*g/4),2,y+3);}
const n=GDATA.length;
if(n>1) for(let g=0;g<=4;g++){const idx=Math.round((n-1)*g/4);const x=pad+(W-pad-6)*(idx/(n-1));
ctx.fillText(GDATA[idx].t,x-18,H-8);}
on.forEach(i=>{ const d=GDEF[i]; ctx.strokeStyle=d.color;ctx.lineWidth=1.6;ctx.beginPath();
GDATA.forEach((s,k)=>{const x=pad+(W-pad-6)*(n>1?k/(n-1):0),y=pad+(H-2*pad)*(1-(d.f(s)-min)/range);k?ctx.lineTo(x,y):ctx.moveTo(x,y);});ctx.stroke(); });
document.getElementById("gLegend").innerHTML=on.map(i=>`<span><i style="background:${GDEF[i].color}"></i>${GDEF[i].l}</span>`).join("");
}
function exportCsv(){
if(!GDATA.length){ flash("저장할 데이터가 없습니다."); return; }
const head=["날짜","시간","전원","운전모드","자동상태","풍량","스마트수면","쾌적조리","안심회복","프리셋"];
ROOMS.forEach(nm=>head.push(`${nm}_급기댐퍼`,`${nm}_배기댐퍼`,`${nm}_CO2`,`${nm}_PM2.5`,`${nm}_PM10`,`${nm}_VOC`,`${nm}_온도`,`${nm}_습도`,`${nm}_LED`,`${nm}_부하`));
const mn=["OFF","환기","자동","공청","바이패스"], an=["","집중","분산"], pn=["ECO","NORMAL","TURBO"];
const lines=[head.join(",")];
GDATA.forEach(s=>{ const row=[s.date,s.t,s.power,mn[s.run_mode]||s.run_mode,an[s.auto_mode]||"",s.fan_mode,s.sleep,s.cook,s.recover,pn[s.hyst_preset]||s.hyst_preset];
s.rooms.forEach(rm=>row.push(rm.damper_sa,rm.damper_ra,rm.co2,rm.pm25,rm.pm10,rm.voc,rm.temp,rm.humi,rm.led,rm.level));
lines.push(row.join(",")); });
const blob=new Blob([""+lines.join("\r\n")],{type:"text/csv;charset=utf-8"});
const a=document.createElement("a"); a.href=URL.createObjectURL(blob);
a.download=`HERV_${current}_${_loadedDate}.csv`; a.click();
}
function todayStr(){ const d=new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; }
function demoHistory(){ const arr=[]; for(let k=0;k<120;k++){ const t=k*5; const hh=String(Math.floor(t/3600)).padStart(2,"0"),mm=String(Math.floor(t%3600/60)).padStart(2,"0"),ss=String(t%60).padStart(2,"0");
arr.push({t:`${hh}:${mm}:${ss}`,date:todayStr(),power:1,run_mode:2,auto_mode:(k%16<8?2:1),fan_mode:k%5,sleep:0,cook:0,recover:0,hyst_preset:1,
rooms:Array.from({length:4},(_,r)=>({damper_sa:k%2,damper_ra:(k+1)%2,co2:500+(k*7+r*50)%900,pm25:10+(k*3+r*5)%60,pm10:15+(k*5)%90,voc:100+(k*7)%400,temp:23+r,humi:45+r*3,led:9,level:(k+r)%5}))}); }
return arr; }
// ===== 서버 폴링 =====
async function poll(){
try{
const j=await (await fetch("/api/latest")).json();
for(const s of SITES){ const d=j[s]; if(!d)continue; state[s].online=d.online;
if(d.g){ state[s].g=d.g; state[s].rooms=d.rooms; if(d.vsp)state[s].vsp=d.vsp; if(d.hyst)state[s].hyst=d.hyst; if(d.thr)state[s].thr=d.thr; } }
renderAll();
}catch(e){}
}
function setFoot(){
const f=document.getElementById("footNote"); f.style.color="var(--text2)";
f.textContent = MODE==="server"
? "서버 연동 모드: /api/latest 폴링 · 제어는 /api/control · 그래프는 /api/history(SQLite 누적) 로 동작합니다."
: "데모 모드(파일 직접 열기): 제어/그래프는 화면에만 반영됩니다. 실제는 미니PC 서버(http) 접속 시 동작.";
}
// ===== 시작 =====
buildDefs(); renderSide(); setFoot();
document.getElementById("gDate").value=todayStr();
document.getElementById("slStart").onchange=renderSleep; document.getElementById("slEnd").onchange=renderSleep;
const chip=document.getElementById("modeChip");
if(MODE==="server"){
chip.textContent="서버연동"; chip.style.background="var(--good)";
poll(); setInterval(poll,1000);
// 그래프 창이 열려있고 오늘을 보고 있을 때만 실시간 갱신
setInterval(()=>{ if(graphOpen() && _loadedDate===todayStr()) loadGraph(); },10000);
setInterval(sleepTick,20000);
}else{
chip.textContent="데모"; chip.style.background="var(--warn)";
document.getElementById("demoBtn").style.display="";
document.getElementById("demoBtn").onclick=function(){demoOn=!demoOn;this.textContent=demoOn?"데모 일시정지":"데모 시작";};
for(let i=0;i<HIST;i++){tick++;SITES.forEach(s=>genSensors(s,tick));}
renderAll();
setInterval(()=>{ if(demoOn){tick++;SITES.forEach(s=>genSensors(s,tick));renderTiles();renderAuto();renderRooms();} },1000);
}
</script>
</body>
</html>