096111e983
.claude/ 제외(.gitignore 추가). 기존 초기커밋(5a96a69) 위에 신규·수정·이동분 커밋.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
629 lines
39 KiB
HTML
629 lines
39 KiB
HTML
<!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>
|