Compare commits
7 Commits
096111e983
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c39fcc646 | |||
| 3b5af64606 | |||
| b18d9c84bf | |||
| 6934f09b2a | |||
| ec13d42417 | |||
| c5e4c48d24 | |||
| 82caac3872 |
@@ -28,6 +28,11 @@ program/build/
|
|||||||
.vs/
|
.vs/
|
||||||
*_wpftmp.csproj
|
*_wpftmp.csproj
|
||||||
|
|
||||||
|
# ── 배포 실행파일 (펌웨어 제외) : 루트 Release/<날짜>/ 는 git 추적 ──
|
||||||
|
# ([Rr]elease/ 가 막은 것을 루트 배포폴더만 해제. bin/Release 등은 [Bb]in/ 으로 계속 제외)
|
||||||
|
!/Release/
|
||||||
|
!/Release/**
|
||||||
|
|
||||||
# ── OS / 에디터 ──
|
# ── OS / 에디터 ──
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
desktop.ini
|
desktop.ini
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"Influx": {
|
||||||
|
"Url": "http://127.0.0.1:8086",
|
||||||
|
"Org": "herv",
|
||||||
|
"Bucket": "erv",
|
||||||
|
"Token": "PUT-YOUR-INFLUXDB-TOKEN-HERE"
|
||||||
|
},
|
||||||
|
"Http": {
|
||||||
|
"Prefix": "http://localhost:8080/",
|
||||||
|
"Token": ""
|
||||||
|
},
|
||||||
|
"SampleIntervalSeconds": 10,
|
||||||
|
"Sites": [
|
||||||
|
{
|
||||||
|
"Port": 6001,
|
||||||
|
"Name": "site01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Port": 6002,
|
||||||
|
"Name": "site02"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Port": 6003,
|
||||||
|
"Name": "site03"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,628 @@
|
|||||||
|
<!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>
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -1158,6 +1158,15 @@ PASS1:
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
/* [모드전환 댐퍼게이트 보호] 모드전환 진행중(Damper_wait_time==5)에는
|
||||||
|
명령경로(대시보드 CTRL_FAN 등)가 풍량을 올려도 무시하고 팬을 0으로 강제.
|
||||||
|
팬이 0까지 내려가야 Damper_Mode 가 실행되어 내부댐퍼가 이동한다.
|
||||||
|
(CTRL_FAN 이 감속창에 간헐적으로 끼어들어 댐퍼가 안 움직이던 문제 수정) */
|
||||||
|
if(Damper_wait_time == 5)
|
||||||
|
{
|
||||||
|
Target_Fan1_Speed = 0;
|
||||||
|
Target_Fan2_Speed = 0;
|
||||||
|
}
|
||||||
Diffuser_Damper_process(Run_Mode);
|
Diffuser_Damper_process(Run_Mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -256,12 +256,19 @@ static void hn_apply_cmd(uint8_t cmd, uint8_t *pl, uint8_t len)
|
|||||||
/* 전원 ON : 환기 모드 + 풍량 1단 (ERVSimulator HomeNetProtocol 와 동일) */
|
/* 전원 ON : 환기 모드 + 풍량 1단 (ERVSimulator HomeNetProtocol 와 동일) */
|
||||||
Set_Run_Mode = Run_Mode = MODE_VENTILATION;
|
Set_Run_Mode = Run_Mode = MODE_VENTILATION;
|
||||||
Set_Fan_Mode = Fan_Mode = 1;
|
Set_Fan_Mode = Fan_Mode = 1;
|
||||||
Fan_Speed_Setting(Run_Mode, Fan_Mode); /* 즉시 반영 (모드전환과 동일) */
|
/* [모드변경댐퍼테스트] 마스터 정렬 : 모드변경 시 명령경로 즉시 팬설정 제거.
|
||||||
|
팬이 0까지 감속해야 Fan_Speed_process 가 Damper_Mode 를 호출(내부댐퍼 이동).
|
||||||
|
팬 복원은 Fan_Speed_process wait==1 에서 수행. */
|
||||||
|
//Fan_Speed_Setting(Run_Mode, Fan_Mode); /* 즉시 반영 (모드전환과 동일) */
|
||||||
}
|
}
|
||||||
/* 전원 토글 시 수동 댐퍼/LED 해제 → 자동 추종 복귀 */
|
/* 전원 토글 시 수동 댐퍼/LED 해제 → 자동 추종 복귀 */
|
||||||
for(i = 1; i <= 4; i++) { Diffuser_Damper_Manual[i] = 0; Diffuser_Led_Manual[i] = 0; }
|
for(i = 1; i <= 4; i++) { Diffuser_Damper_Manual[i] = 0; Diffuser_Led_Manual[i] = 0; }
|
||||||
/* 각실분배기 : 전원 비트(TYPE_POWER) + 모드/풍량. 룸컨(RJ2)도 Command_request_type 로 푸시 */
|
/* 각실분배기 : 전원 비트(TYPE_POWER) + 모드/풍량. 룸컨(RJ2)도 Command_request_type 로 푸시 */
|
||||||
Command_request_type |= (TYPE_POWER | TYPE_MODE | TYPE_FAN_SPEED);
|
Command_request_type |= (TYPE_POWER | TYPE_MODE | TYPE_FAN_SPEED);
|
||||||
|
/* [룸컨푸시] 전원 ON/OFF 는 룸컨 전용 pending 으로도 표시 → 분배기(My_bunbaegi L522 Command_request_type=0)나
|
||||||
|
My_RJ2 L716(전원OFF는 모터정지 위해 Set==현재 equalize 강제 → TYPE_*_조기클리어)로 룸컨 푸시가 유실되어
|
||||||
|
룸컨이 옛 모드(예: 공청)를 계속 표시하던 문제 해소. RJ2 ack(echo) 시 자동 해제. */
|
||||||
|
RoomCtrl_Push = 1;
|
||||||
} else result = 1;
|
} else result = 1;
|
||||||
break;
|
break;
|
||||||
case 0x02: /* CTRL_RUNMODE [mode] 0off/1환기/2자동/3공청/4바이패스 */
|
case 0x02: /* CTRL_RUNMODE [mode] 0off/1환기/2자동/3공청/4바이패스 */
|
||||||
@@ -288,9 +295,10 @@ static void hn_apply_cmd(uint8_t cmd, uint8_t *pl, uint8_t len)
|
|||||||
{
|
{
|
||||||
Set_Fan_Mode = Fan_Mode = 1;
|
Set_Fan_Mode = Fan_Mode = 1;
|
||||||
Command_request_type |= TYPE_FAN_SPEED;
|
Command_request_type |= TYPE_FAN_SPEED;
|
||||||
/* 즉시 반영 : 풍량 단수(Total_Air_Volume)가 안 바뀌는 모드전환
|
/* [모드변경댐퍼테스트] 마스터 정렬 : 모드변경 시 명령경로 즉시 팬설정 제거.
|
||||||
(예: 환기1단→공청1단)에서도 새 모드 VSP가 걸리도록 CTRL_FAN 과 동일하게 직접 호출 */
|
팬이 0까지 감속해야 Fan_Speed_process 가 Damper_Mode 를 호출(내부댐퍼 이동).
|
||||||
Fan_Speed_Setting(Run_Mode, Fan_Mode);
|
새 모드 VSP 복원은 Fan_Speed_process wait==1 에서 수행. */
|
||||||
|
//Fan_Speed_Setting(Run_Mode, Fan_Mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,7 +189,9 @@ uint8_t Hood_process(void)//200ms
|
|||||||
}
|
}
|
||||||
|
|
||||||
Command_request_type |= (TYPE_MODE|TYPE_FAN_SPEED);
|
Command_request_type |= (TYPE_MODE|TYPE_FAN_SPEED);
|
||||||
Fan_Speed_Setting(Run_Mode, Fan_Mode); /* 즉시 반영(룸컨 echo 대기 없이 — CTRL_FAN 동일) */
|
/* [모드변경댐퍼테스트] 마스터 정렬 : 모드변경(→환기) 시 명령경로 즉시 팬설정 제거.
|
||||||
|
팬 복원은 Fan_Speed_process wait==1 에서 수행. */
|
||||||
|
//Fan_Speed_Setting(Run_Mode, Fan_Mode); /* 즉시 반영(룸컨 echo 대기 없이 — CTRL_FAN 동일) */
|
||||||
Tx_Yeundong_Delay = 30;
|
Tx_Yeundong_Delay = 30;
|
||||||
}
|
}
|
||||||
else if(Hood_Status == 0) // 후드 OFF : 즉시 원래 모드/풍량 복귀 (메이크업 유지는 후드측 담당, 사양 260613 9p 3.3)
|
else if(Hood_Status == 0) // 후드 OFF : 즉시 원래 모드/풍량 복귀 (메이크업 유지는 후드측 담당, 사양 260613 9p 3.3)
|
||||||
@@ -204,10 +206,17 @@ uint8_t Hood_process(void)//200ms
|
|||||||
}
|
}
|
||||||
|
|
||||||
Command_request_type |= (TYPE_MODE|TYPE_FAN_SPEED|TYPE_HOOD_STATE);
|
Command_request_type |= (TYPE_MODE|TYPE_FAN_SPEED|TYPE_HOOD_STATE);
|
||||||
if(Run_Mode != MODE_AUTO) Fan_Speed_Setting(Run_Mode, Fan_Mode); /* 즉시 복귀 반영 */
|
/* [모드변경댐퍼테스트] 마스터 정렬 : 모드복귀 시 명령경로 즉시 팬설정 제거.
|
||||||
|
팬 복원은 Fan_Speed_process wait==1 에서 수행. */
|
||||||
|
//if(Run_Mode != MODE_AUTO) Fan_Speed_Setting(Run_Mode, Fan_Mode); /* 즉시 복귀 반영 */
|
||||||
Hood_Yeundong_flag = 0;
|
Hood_Yeundong_flag = 0;
|
||||||
Hood_Warming_up_Timer = 0;
|
Hood_Warming_up_Timer = 0;
|
||||||
Tx_Yeundong_Delay = 0;
|
Tx_Yeundong_Delay = 0;
|
||||||
|
/* [쾌적조리자동해제] 후드 전원 OFF(단수 0) → 쾌적조리 토글 자동 해제.
|
||||||
|
사장님 요청(2026-06-18) : 후드 OFF 후 대시보드 쾌적조리 버튼이 저절로 꺼지게.
|
||||||
|
Enable=0 이면 STATUS byte4 bit1/byte5 bit0 모두 0 → 대시보드 _state.ComfortCook 자동 해제.
|
||||||
|
(기존 사양 9p 3.3 'armed 유지'와 반대 — 본 요청으로 변경) */
|
||||||
|
Hood_YeunDong_Enable = 0;
|
||||||
}
|
}
|
||||||
else // 후드 단수 변경(1~5) : 메이크업 풍량 단수 추종 갱신
|
else // 후드 단수 변경(1~5) : 메이크업 풍량 단수 추종 갱신
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ uint8_t Filter_Reset_Flag = 0;
|
|||||||
uint8_t EEP_Save_Flag = 0;
|
uint8_t EEP_Save_Flag = 0;
|
||||||
|
|
||||||
uint8_t Command_request_type = 0;
|
uint8_t Command_request_type = 0;
|
||||||
|
uint8_t RoomCtrl_Push = 0; /* [룸컨푸시] 대시보드 전원변경의 룸컨 전용 pending. 분배기(My_bunbaegi L522 Command_request_type=0)·716 equalize 조기클리어와 무관하게 룸컨에 푸시 보장, RJ2 ack 로만 해제 */
|
||||||
uint8_t Roomcon_Filter_Error = 0; // 2021.5.31
|
uint8_t Roomcon_Filter_Error = 0; // 2021.5.31
|
||||||
|
|
||||||
|
|
||||||
@@ -329,7 +330,7 @@ void roomcon_parsing(void)
|
|||||||
Err_Code &= ~(ERROR_FILTER_CLEAN|ERROR_FILTER_CHANGE|ERROR_SOJA_CHANGE|ERROR_PROTECT|ERROR_SOMETIME);
|
Err_Code &= ~(ERROR_FILTER_CLEAN|ERROR_FILTER_CHANGE|ERROR_SOJA_CHANGE|ERROR_PROTECT|ERROR_SOMETIME);
|
||||||
Err_Code |= Rx_roomcon232_buffer[7]&(ERROR_FILTER_CLEAN|ERROR_FILTER_CHANGE|ERROR_SOJA_CHANGE|ERROR_PROTECT|ERROR_SOMETIME);
|
Err_Code |= Rx_roomcon232_buffer[7]&(ERROR_FILTER_CLEAN|ERROR_FILTER_CHANGE|ERROR_SOJA_CHANGE|ERROR_PROTECT|ERROR_SOMETIME);
|
||||||
|
|
||||||
if(Command_request_type & (TYPE_MODE|TYPE_FAN_SPEED|TYPE_RESERVATION) )
|
if((Command_request_type & (TYPE_MODE|TYPE_FAN_SPEED|TYPE_RESERVATION)) || RoomCtrl_Push) /* [룸컨푸시] 전용 pending 도 푸시 트리거 */
|
||||||
{
|
{
|
||||||
if((Hood_Yeundong_flag == 1)&&(Tx_Yeundong_Delay == 0))
|
if((Hood_Yeundong_flag == 1)&&(Tx_Yeundong_Delay == 0))
|
||||||
{
|
{
|
||||||
@@ -398,6 +399,7 @@ void roomcon_parsing(void)
|
|||||||
{
|
{
|
||||||
Command_request_type = 0;
|
Command_request_type = 0;
|
||||||
Command_request_type &= ~TYPE_SEND_FLAG;
|
Command_request_type &= ~TYPE_SEND_FLAG;
|
||||||
|
RoomCtrl_Push = 0; /* [룸컨푸시] 룸컨 ack(echo) → 전용 pending 해제 */
|
||||||
Run_Mode = Rx_roomcon232_buffer[2];
|
Run_Mode = Rx_roomcon232_buffer[2];
|
||||||
if(Run_Mode != MODE_AUTO)
|
if(Run_Mode != MODE_AUTO)
|
||||||
{
|
{
|
||||||
@@ -406,7 +408,7 @@ void roomcon_parsing(void)
|
|||||||
//if(Run_Mode != MODE_AUTO)Fan_Mode = Rx_roomcon232_buffer[3];//DEMO
|
//if(Run_Mode != MODE_AUTO)Fan_Mode = Rx_roomcon232_buffer[3];//DEMO
|
||||||
//else Fan_Mode = Set_Fan_Mode;
|
//else Fan_Mode = Set_Fan_Mode;
|
||||||
}
|
}
|
||||||
else
|
else if(!RoomCtrl_Push && !(Command_request_type & (TYPE_MODE|TYPE_FAN_SPEED))) /* [룸컨푸시] 명령 푸시 대기중엔 룸컨 자기보고로 Set_* 를 덮지 않음 */
|
||||||
{
|
{
|
||||||
|
|
||||||
Set_Run_Mode = Run_Mode = Rx_roomcon232_buffer[2];
|
Set_Run_Mode = Run_Mode = Rx_roomcon232_buffer[2];
|
||||||
@@ -689,13 +691,14 @@ void roomcon_parsing(void)
|
|||||||
if(Command_request_type & TYPE_SEND_FLAG)
|
if(Command_request_type & TYPE_SEND_FLAG)
|
||||||
{
|
{
|
||||||
Command_request_type &= ~TYPE_SEND_FLAG;
|
Command_request_type &= ~TYPE_SEND_FLAG;
|
||||||
|
RoomCtrl_Push = 0; /* [룸컨푸시] 룸컨 ack(echo) → 전용 pending 해제 */
|
||||||
Run_Mode = Rx_roomcon232_buffer[2];
|
Run_Mode = Rx_roomcon232_buffer[2];
|
||||||
if(Run_Mode != MODE_AUTO)
|
if(Run_Mode != MODE_AUTO)
|
||||||
{
|
{
|
||||||
Fan_Mode = Rx_roomcon232_buffer[3];///////////////// DEMO
|
Fan_Mode = Rx_roomcon232_buffer[3];///////////////// DEMO
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else if(!RoomCtrl_Push && !(Command_request_type & (TYPE_MODE|TYPE_FAN_SPEED))) /* [룸컨푸시] 명령 푸시 대기중엔 룸컨 자기보고로 Set_* 를 덮지 않음 */
|
||||||
{
|
{
|
||||||
Set_Run_Mode = Run_Mode = Rx_roomcon232_buffer[2];
|
Set_Run_Mode = Run_Mode = Rx_roomcon232_buffer[2];
|
||||||
if(Run_Mode != MODE_AUTO)
|
if(Run_Mode != MODE_AUTO)
|
||||||
|
|||||||
@@ -371,6 +371,7 @@ extern uint8_t BlackOut_Run_Mode;
|
|||||||
extern uint8_t BlackOut_Fan_Mode;
|
extern uint8_t BlackOut_Fan_Mode;
|
||||||
|
|
||||||
extern uint8_t Command_request_type;
|
extern uint8_t Command_request_type;
|
||||||
|
extern uint8_t RoomCtrl_Push; /* [룸컨푸시] 대시보드 전원변경의 룸컨(RJ2) 전용 pending. 분배기와 공유 안 함, RJ2 ack(echo)로만 클리어 */
|
||||||
|
|
||||||
|
|
||||||
uint16_t Diffuser_Damper_process(uint8_t mode);//100ms
|
uint16_t Diffuser_Damper_process(uint8_t mode);//100ms
|
||||||
|
|||||||
@@ -912,8 +912,12 @@ uint8_t Air_Quality_damper_process(void)
|
|||||||
Pre_Ext_Select_Room = Ext_Select_Room;
|
Pre_Ext_Select_Room = Ext_Select_Room;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 매 틱 : 실별 CO2 히스테리시스. CO2 >= 1000 OPEN, <= 800 CLOSE, 그 사이(데드존)는 현재 상태 유지 */
|
/* 거실(room1)은 사양상 CO2 무관하게 항상 CLOSE → 매 틱 강제 닫음 */
|
||||||
for(Room_Num = 1; Room_Num < 5; Room_Num++)
|
Memory_Diffuser_Dmp_Ang_SA[1] = 0; Memory_Diffuser_Dmp_Ang_RA[1] = 0;
|
||||||
|
Diffuser_Air_quality[1] = 0;//OFF
|
||||||
|
|
||||||
|
/* 매 틱 : 침실1~3(room2~4) 만 CO2 히스테리시스. CO2 >= 1000 OPEN, <= 800 CLOSE, 그 사이(데드존)는 현재 상태 유지 */
|
||||||
|
for(Room_Num = 2; Room_Num < 5; Room_Num++)
|
||||||
{
|
{
|
||||||
if(SEN66_CO2_value[Room_Num] >= 1000)
|
if(SEN66_CO2_value[Room_Num] >= 1000)
|
||||||
{ Memory_Diffuser_Dmp_Ang_SA[Room_Num] = 110; Memory_Diffuser_Dmp_Ang_RA[Room_Num] = 110; }
|
{ Memory_Diffuser_Dmp_Ang_SA[Room_Num] = 110; Memory_Diffuser_Dmp_Ang_RA[Room_Num] = 110; }
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
namespace ErvProtocol
|
|
||||||
{
|
|
||||||
// 펌웨어 없이 UI/파이프라인 검증용 합성 STATUS payload(73B) 생성.
|
|
||||||
public static class DemoStatus
|
|
||||||
{
|
|
||||||
public static byte[] BuildPayload(int tick)
|
|
||||||
{
|
|
||||||
var p = new byte[StatusDecoder.STATUS_LEN];
|
|
||||||
p[0] = 1; // power
|
|
||||||
p[1] = (byte)RunMode.Auto; // runMode
|
|
||||||
p[2] = (byte)((tick / 5) % 2); // autoState 분산/집중
|
|
||||||
p[3] = (byte)(2 + (tick % 3)); // fanMode 2~4
|
|
||||||
p[4] = SubModeBits.SmartSleep; // subMode
|
|
||||||
p[5] = (byte)(tick % 2); // hood
|
|
||||||
p[6] = (byte)HystPreset.Normal; // preset
|
|
||||||
WriteU16(p, 7, 30); WriteU16(p, 9, 50); WriteU16(p, 11, 300); WriteU16(p, 13, 700);
|
|
||||||
WriteU16(p, 15, 0x0000); // errorCode
|
|
||||||
|
|
||||||
for (int r = 0; r < 4; r++)
|
|
||||||
{
|
|
||||||
int o = 17 + r * 14;
|
|
||||||
int seed = tick + r * 13;
|
|
||||||
p[o + 0] = (byte)((seed % 2) | (((seed % 3) == 0) ? 0x02 : 0)); // bit0 급기 / bit1 배기
|
|
||||||
WriteU16(p, o + 1, 10 + (seed * 3) % 60);
|
|
||||||
WriteU16(p, o + 3, 15 + (seed * 5) % 90);
|
|
||||||
WriteU16(p, o + 5, 100 + (seed * 7) % 400);
|
|
||||||
WriteU16(p, o + 7, 450 + (seed * 11) % 700);
|
|
||||||
p[o + 9] = (byte)(1 + (seed % 4));
|
|
||||||
p[o + 10] = (byte)(seed % 10);
|
|
||||||
WriteU16(p, o + 11, (seed * 17) % 100);
|
|
||||||
p[o + 13] = (byte)(seed % 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
p[73] = 0; // reset (토글 off)
|
|
||||||
|
|
||||||
// 풍량 VSP 설정값 (1바이트, 사양서 DL H-ERV VSP 실측표) : 환기1~4, 바이패스, 공청1~4 의 SA/EA
|
|
||||||
int[] sa = { 56, 63, 70, 86, 67, 65, 72, 78, 80 };
|
|
||||||
int[] ea = { 57, 63, 70, 85, 75, 0, 0, 0, 0 };
|
|
||||||
for (int i = 0; i < 9; i++)
|
|
||||||
{
|
|
||||||
int o = 74 + i * 4;
|
|
||||||
WriteU16(p, o, sa[i]);
|
|
||||||
WriteU16(p, o + 2, ea[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 히스테리시스 데드밴드(하강) (ECO/NORMAL/TURBO 의 PM2.5/PM10/VOC/CO2) - 사양서
|
|
||||||
int[,] hyst = { { 2, 5, 5, 50 }, { 2, 5, 5, 50 }, { 2, 5, 3, 30 } };
|
|
||||||
for (int i = 0; i < 3; i++)
|
|
||||||
{
|
|
||||||
int o = 110 + i * 8;
|
|
||||||
WriteU16(p, o, hyst[i, 0]);
|
|
||||||
WriteU16(p, o + 2, hyst[i, 1]);
|
|
||||||
WriteU16(p, o + 4, hyst[i, 2]);
|
|
||||||
WriteU16(p, o + 6, hyst[i, 3]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모드별 오염단계 임계표 (3프리셋 × [CO2,PM2.5,PM10,VOC] × L1~L4 상한) - 사양서
|
|
||||||
int[][,] thr =
|
|
||||||
{
|
|
||||||
new int[,] { {1000,1300,1600,2000}, {20,38,60,86}, {40,86,126,173}, {171,195,308,438} }, // ECO
|
|
||||||
new int[,] { {800,1100,1400,1700}, {14,29,49,69}, {28,66,102,138}, {120,150,250,350} }, // NORMAL
|
|
||||||
new int[,] { {700,1000,1300,1600}, {12,23,38,52}, {24,53,78,104}, {103,120,192,263} }, // TURBO
|
|
||||||
};
|
|
||||||
for (int i = 0; i < 3; i++)
|
|
||||||
{
|
|
||||||
int o = StatusDecoder.THR_OFF + i * 32;
|
|
||||||
for (int g = 0; g < 4; g++) // g: 0 CO2,1 PM2.5,2 PM10,3 VOC
|
|
||||||
for (int k = 0; k < 4; k++)
|
|
||||||
WriteU16(p, o + g * 8 + k * 2, thr[i][g, k]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 각실 온도/습도 (offset 230~, 4실 × [Temp, Humi])
|
|
||||||
for (int r = 0; r < 4; r++)
|
|
||||||
{
|
|
||||||
int o = StatusDecoder.TEMPHUMI_OFF + r * 2;
|
|
||||||
p[o + 0] = (byte)(22 + (tick + r) % 6); // 22~27℃
|
|
||||||
p[o + 1] = (byte)(40 + (tick + r * 7) % 30); // 40~69%
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void WriteU16(byte[] p, int off, int v)
|
|
||||||
{
|
|
||||||
p[off] = (byte)((v >> 8) & 0xFF);
|
|
||||||
p[off + 1] = (byte)(v & 0xFF);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,8 +41,5 @@ namespace ErvDashboard.Api
|
|||||||
void SetHystPreset(HystPreset preset); // 0x06
|
void SetHystPreset(HystPreset preset); // 0x06
|
||||||
void SetHystDeadband(int preset, int pm25, int pm10, int voc, int co2); // 0x07
|
void SetHystDeadband(int preset, int pm25, int pm10, int voc, int co2); // 0x07
|
||||||
void SetHystThreshold(int preset, int pollutant, int l1, int l2, int l3, int l4); // 0x0D
|
void SetHystThreshold(int preset, int pollutant, int l1, int l2, int l3, int l4); // 0x0D
|
||||||
|
|
||||||
// ---- 데모/테스트 (합성 STATUS를 수신 경로로 주입) ----
|
|
||||||
void InjectDemoStatus(int tick);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,14 +100,6 @@ namespace ErvDashboard.Api
|
|||||||
public void SetHystDeadband(int preset, int pm25, int pm10, int voc, int co2) => SendFrame(CtrlFrame.HystValue(preset, pm25, pm10, voc, co2));
|
public void SetHystDeadband(int preset, int pm25, int pm10, int voc, int co2) => SendFrame(CtrlFrame.HystValue(preset, pm25, pm10, voc, co2));
|
||||||
public void SetHystThreshold(int preset, int pollutant, int l1, int l2, int l3, int l4) => SendFrame(CtrlFrame.HystThr(preset, pollutant, l1, l2, l3, l4));
|
public void SetHystThreshold(int preset, int pollutant, int l1, int l2, int l3, int l4) => SendFrame(CtrlFrame.HystThr(preset, pollutant, l1, l2, l3, l4));
|
||||||
|
|
||||||
// ================= 데모/테스트 =================
|
|
||||||
public void InjectDemoStatus(int tick)
|
|
||||||
{
|
|
||||||
var frame = CtrlFrame.Build(StatusDecoder.STATUS, DemoStatus.BuildPayload(tick));
|
|
||||||
_parser.Reset();
|
|
||||||
_parser.Feed(frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() => _ch.Dispose();
|
public void Dispose() => _ch.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ namespace ErvDashboard
|
|||||||
readonly DashboardState _state = new();
|
readonly DashboardState _state = new();
|
||||||
readonly IErvApi _api = new SerialErvApi();
|
readonly IErvApi _api = new SerialErvApi();
|
||||||
|
|
||||||
readonly DispatcherTimer _demoTimer;
|
|
||||||
int _demoTick;
|
|
||||||
bool _commActive;
|
bool _commActive;
|
||||||
bool _ledDragging; // LED 슬라이더 thumb 드래그 중 (드래그 중엔 전송 보류 → 완료 시 1회)
|
bool _ledDragging; // LED 슬라이더 thumb 드래그 중 (드래그 중엔 전송 보류 → 완료 시 1회)
|
||||||
bool _suppressLed; // STATUS 동기 적용 중 LedDim→슬라이더 갱신으로 인한 ValueChanged 전송 차단
|
bool _suppressLed; // STATUS 동기 적용 중 LedDim→슬라이더 갱신으로 인한 ValueChanged 전송 차단
|
||||||
@@ -61,7 +59,11 @@ namespace ErvDashboard
|
|||||||
readonly DispatcherTimer _clockTimer = new() { Interval = TimeSpan.FromSeconds(1) };
|
readonly DispatcherTimer _clockTimer = new() { Interval = TimeSpan.FromSeconds(1) };
|
||||||
|
|
||||||
// ---- 그래프용 시계열 샘플링 (5초 간격) → SQLite 실시간 저장(무제한 누적) ----
|
// ---- 그래프용 시계열 샘플링 (5초 간격) → SQLite 실시간 저장(무제한 누적) ----
|
||||||
readonly Storage.LogDb _logDb = new(System.IO.Path.Combine(AppContext.BaseDirectory, "HERV_Log.db"));
|
// 단일 exe(IncludeAllContentForSelfExtract=true)는 임시폴더로 추출 실행 → AppContext.BaseDirectory 가 임시폴더를 가리킴.
|
||||||
|
// DB 는 실제 exe 위치(Environment.ProcessPath)에 저장해야 publish 폴더에서 보이고 재실행 간 누적 유지됨.
|
||||||
|
static string LogDbPath => System.IO.Path.Combine(
|
||||||
|
System.IO.Path.GetDirectoryName(Environment.ProcessPath) ?? AppContext.BaseDirectory, "HERV_Log.db");
|
||||||
|
readonly Storage.LogDb _logDb = new(LogDbPath);
|
||||||
readonly DispatcherTimer _sampleTimer = new() { Interval = TimeSpan.FromSeconds(5) };
|
readonly DispatcherTimer _sampleTimer = new() { Interval = TimeSpan.FromSeconds(5) };
|
||||||
GraphWindow? _graphWin;
|
GraphWindow? _graphWin;
|
||||||
|
|
||||||
@@ -94,9 +96,6 @@ namespace ErvDashboard
|
|||||||
BuildModeButtons();
|
BuildModeButtons();
|
||||||
BuildPresetButtons();
|
BuildPresetButtons();
|
||||||
|
|
||||||
_demoTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(700) };
|
|
||||||
_demoTimer.Tick += (_, _) => DemoTick();
|
|
||||||
|
|
||||||
_clockTimer.Tick += (_, _) => CheckSleepSchedule();
|
_clockTimer.Tick += (_, _) => CheckSleepSchedule();
|
||||||
_clockTimer.Start();
|
_clockTimer.Start();
|
||||||
|
|
||||||
@@ -163,7 +162,7 @@ namespace ErvDashboard
|
|||||||
{
|
{
|
||||||
if (!_state.SmartSleep || _sleepEndAt is not { } endAt || DateTime.Now < endAt) return;
|
if (!_state.SmartSleep || _sleepEndAt is not { } endAt || DateTime.Now < endAt) return;
|
||||||
_sleepEndAt = null;
|
_sleepEndAt = null;
|
||||||
if (!_demoTimer.IsEnabled && CanSend()) _api.SetSubMode(SubModeType.SmartSleep, false);
|
if (CanSend()) _api.SetSubMode(SubModeType.SmartSleep, false);
|
||||||
ApplySubModeLocal("SmartSleep", false);
|
ApplySubModeLocal("SmartSleep", false);
|
||||||
Log("[스마트수면] 종료 시각 도달 → 자동 해제");
|
Log("[스마트수면] 종료 시각 도달 → 자동 해제");
|
||||||
if (NoScenarioActive()) RestorePreviousMode();
|
if (NoScenarioActive()) RestorePreviousMode();
|
||||||
@@ -219,7 +218,6 @@ namespace ErvDashboard
|
|||||||
// 팝업에서 호출 (제어 송신은 메인이 담당)
|
// 팝업에서 호출 (제어 송신은 메인이 담당)
|
||||||
public void SelectPreset(HystPreset preset)
|
public void SelectPreset(HystPreset preset)
|
||||||
{
|
{
|
||||||
if (_demoTimer.IsEnabled) { _state.HystPreset = preset; return; }
|
|
||||||
if (!CanSend()) return;
|
if (!CanSend()) return;
|
||||||
_api.SetHystPreset(preset);
|
_api.SetHystPreset(preset);
|
||||||
_state.HystPreset = preset;
|
_state.HystPreset = preset;
|
||||||
@@ -237,7 +235,6 @@ namespace ErvDashboard
|
|||||||
// 활성 프리셋의 오염단계 임계 + 데드밴드 송신 (HystWindow '변경')
|
// 활성 프리셋의 오염단계 임계 + 데드밴드 송신 (HystWindow '변경')
|
||||||
public void ApplyHystPreset(int preset)
|
public void ApplyHystPreset(int preset)
|
||||||
{
|
{
|
||||||
if (_demoTimer.IsEnabled) return; // 데모: 상태만 갱신됨
|
|
||||||
if (!CanSend()) return;
|
if (!CanSend()) return;
|
||||||
_api.SetHystThreshold(preset, 0, _state.Co2Thr[preset][0], _state.Co2Thr[preset][1], _state.Co2Thr[preset][2], _state.Co2Thr[preset][3]);
|
_api.SetHystThreshold(preset, 0, _state.Co2Thr[preset][0], _state.Co2Thr[preset][1], _state.Co2Thr[preset][2], _state.Co2Thr[preset][3]);
|
||||||
_api.SetHystThreshold(preset, 1, _state.Pm25Thr[preset][0], _state.Pm25Thr[preset][1], _state.Pm25Thr[preset][2], _state.Pm25Thr[preset][3]);
|
_api.SetHystThreshold(preset, 1, _state.Pm25Thr[preset][0], _state.Pm25Thr[preset][1], _state.Pm25Thr[preset][2], _state.Pm25Thr[preset][3]);
|
||||||
@@ -321,7 +318,6 @@ namespace ErvDashboard
|
|||||||
|
|
||||||
bool CanSend()
|
bool CanSend()
|
||||||
{
|
{
|
||||||
if (_demoTimer.IsEnabled) return false; // 데모 모드에선 송신 안 함
|
|
||||||
if (!_api.IsConnected)
|
if (!_api.IsConnected)
|
||||||
{
|
{
|
||||||
Log("연결 후 제어 가능합니다.");
|
Log("연결 후 제어 가능합니다.");
|
||||||
@@ -334,7 +330,6 @@ namespace ErvDashboard
|
|||||||
void Power_Click(object sender, RoutedEventArgs e)
|
void Power_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
bool next = !_state.PowerOn;
|
bool next = !_state.PowerOn;
|
||||||
if (_demoTimer.IsEnabled) { _state.PowerOn = next; return; }
|
|
||||||
if (!CanSend()) return;
|
if (!CanSend()) return;
|
||||||
_api.SetPower(next);
|
_api.SetPower(next);
|
||||||
_state.PowerOn = next;
|
_state.PowerOn = next;
|
||||||
@@ -346,7 +341,6 @@ namespace ErvDashboard
|
|||||||
if (sender is not Button b || b.Tag is not string tag) return;
|
if (sender is not Button b || b.Tag is not string tag) return;
|
||||||
var def = Array.Find(ModeDefs, d => d.tag == tag);
|
var def = Array.Find(ModeDefs, d => d.tag == tag);
|
||||||
// 운전모드 전환 시 풍량 1단 (자동 제외). 실연결 시 ERV STATUS 로 최종 확정.
|
// 운전모드 전환 시 풍량 1단 (자동 제외). 실연결 시 ERV STATUS 로 최종 확정.
|
||||||
if (_demoTimer.IsEnabled) { _state.RunMode = def.mode; if (def.mode != RunMode.Auto) _state.FanMode = 1; return; }
|
|
||||||
if (!CanSend()) return;
|
if (!CanSend()) return;
|
||||||
_api.SetRunMode(def.mode);
|
_api.SetRunMode(def.mode);
|
||||||
_state.RunMode = def.mode;
|
_state.RunMode = def.mode;
|
||||||
@@ -369,7 +363,6 @@ namespace ErvDashboard
|
|||||||
if (sender is not Button b || b.Tag is not int speed) return;
|
if (sender is not Button b || b.Tag is not int speed) return;
|
||||||
if (_state.IsAuto) { Log("자동모드에서는 풍량 조절 불가"); return; }
|
if (_state.IsAuto) { Log("자동모드에서는 풍량 조절 불가"); return; }
|
||||||
if (_state.RunMode == RunMode.Bypass && speed > 1) { Log("바이패스는 1단 고정"); return; }
|
if (_state.RunMode == RunMode.Bypass && speed > 1) { Log("바이패스는 1단 고정"); return; }
|
||||||
if (_demoTimer.IsEnabled) { _state.FanMode = (byte)speed; return; }
|
|
||||||
if (!CanSend()) return;
|
if (!CanSend()) return;
|
||||||
_api.SetFan(speed);
|
_api.SetFan(speed);
|
||||||
_state.FanMode = (byte)speed;
|
_state.FanMode = (byte)speed;
|
||||||
@@ -389,21 +382,7 @@ namespace ErvDashboard
|
|||||||
// 시나리오 첫 진입 → 직전 운전모드/풍량 기억(해제 시 복귀용)
|
// 시나리오 첫 진입 → 직전 운전모드/풍량 기억(해제 시 복귀용)
|
||||||
if (next && !anyBefore) { _modeBeforeScenario = _state.RunMode; _fanBeforeScenario = _state.FanMode; }
|
if (next && !anyBefore) { _modeBeforeScenario = _state.RunMode; _fanBeforeScenario = _state.FanMode; }
|
||||||
if (tag == "SmartSleep" && !next) _sleepEndAt = null; // 스마트수면 수동 해제 → 자동해제 예약 취소
|
if (tag == "SmartSleep" && !next) _sleepEndAt = null; // 스마트수면 수동 해제 → 자동해제 예약 취소
|
||||||
if (_demoTimer.IsEnabled)
|
|
||||||
{
|
|
||||||
ApplySubModeLocal(tag, next);
|
|
||||||
if (NoScenarioActive()) RestorePreviousMode();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!CanSend()) return;
|
if (!CanSend()) return;
|
||||||
// 상호배타: 새 모드를 켤 때 기존 활성 모드는 장치에도 OFF 전송
|
|
||||||
// (펌웨어는 시나리오모드를 독립 변수로 유지 → status 재수신 시 부활 방지)
|
|
||||||
if (next)
|
|
||||||
{
|
|
||||||
if (tag != "SmartSleep" && _state.SmartSleep) _api.SetSubMode(SubModeType.SmartSleep, false);
|
|
||||||
if (tag != "ComfortCook" && _state.ComfortCook) _api.SetSubMode(SubModeType.ComfortCook, false);
|
|
||||||
if (tag != "ReliefRecover" && _state.ReliefRecover) _api.SetSubMode(SubModeType.ReliefRecover, false);
|
|
||||||
}
|
|
||||||
_api.SetSubMode(type, next);
|
_api.SetSubMode(type, next);
|
||||||
ApplySubModeLocal(tag, next);
|
ApplySubModeLocal(tag, next);
|
||||||
Log($"[제어] 시나리오모드 {b.Content} → {(next ? "ON" : "OFF")}");
|
Log($"[제어] 시나리오모드 {b.Content} → {(next ? "ON" : "OFF")}");
|
||||||
@@ -423,28 +402,18 @@ namespace ErvDashboard
|
|||||||
|
|
||||||
void ApplySubModeLocal(string tag, bool on)
|
void ApplySubModeLocal(string tag, bool on)
|
||||||
{
|
{
|
||||||
// 시나리오모드는 상호배타: 하나를 켜면 나머지는 해제
|
// 시나리오모드 상호배타 없음 — 클릭한 모드만 on/off (사용자가 하나만 선택).
|
||||||
if (on)
|
|
||||||
{
|
|
||||||
_state.SmartSleep = tag == "SmartSleep";
|
|
||||||
_state.ComfortCook = tag == "ComfortCook";
|
|
||||||
_state.ReliefRecover = tag == "ReliefRecover";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
switch (tag)
|
switch (tag)
|
||||||
{
|
{
|
||||||
case "SmartSleep": _state.SmartSleep = false; break;
|
case "SmartSleep": _state.SmartSleep = on; break;
|
||||||
case "ComfortCook": _state.ComfortCook = false; break;
|
case "ComfortCook": _state.ComfortCook = on; break;
|
||||||
case "ReliefRecover": _state.ReliefRecover = false; break;
|
case "ReliefRecover": _state.ReliefRecover = on; break;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Hood_Click(object sender, RoutedEventArgs e)
|
void Hood_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
bool next = !_state.Hood;
|
bool next = !_state.Hood;
|
||||||
if (_demoTimer.IsEnabled) { _state.Hood = next; return; }
|
|
||||||
if (!CanSend()) return;
|
if (!CanSend()) return;
|
||||||
_api.SetHood(next);
|
_api.SetHood(next);
|
||||||
_state.Hood = next;
|
_state.Hood = next;
|
||||||
@@ -454,7 +423,6 @@ namespace ErvDashboard
|
|||||||
void Reset_Click(object sender, RoutedEventArgs e)
|
void Reset_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
bool next = !_state.Reset;
|
bool next = !_state.Reset;
|
||||||
if (_demoTimer.IsEnabled) { _state.Reset = next; return; }
|
|
||||||
if (!CanSend()) return;
|
if (!CanSend()) return;
|
||||||
_api.SetReset(next);
|
_api.SetReset(next);
|
||||||
_state.Reset = next;
|
_state.Reset = next;
|
||||||
@@ -474,11 +442,6 @@ namespace ErvDashboard
|
|||||||
var room = _state.Room(roomId);
|
var room = _state.Room(roomId);
|
||||||
bool cur = type == 0 ? room.DamperSaOpen : room.DamperEaOpen;
|
bool cur = type == 0 ? room.DamperSaOpen : room.DamperEaOpen;
|
||||||
bool next = !cur;
|
bool next = !cur;
|
||||||
if (_demoTimer.IsEnabled)
|
|
||||||
{
|
|
||||||
if (type == 0) room.DamperSaOpen = next; else room.DamperEaOpen = next;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!CanSend()) return;
|
if (!CanSend()) return;
|
||||||
_api.SetDiffuserDamper(roomId, type, next);
|
_api.SetDiffuserDamper(roomId, type, next);
|
||||||
if (type == 0) room.DamperSaOpen = next; else room.DamperEaOpen = next;
|
if (type == 0) room.DamperSaOpen = next; else room.DamperEaOpen = next;
|
||||||
@@ -511,7 +474,6 @@ namespace ErvDashboard
|
|||||||
// 직전 송신/수신값과 같으면 전송 안 함 — STATUS 역갱신이 유발한 ValueChanged(에코) 차단.
|
// 직전 송신/수신값과 같으면 전송 안 함 — STATUS 역갱신이 유발한 ValueChanged(에코) 차단.
|
||||||
// 사용자가 값을 실제로 바꾸면 dim 이 달라지므로 정상 전송됨.
|
// 사용자가 값을 실제로 바꾸면 dim 이 달라지므로 정상 전송됨.
|
||||||
if (_lastLed.TryGetValue(roomId, out var last) && last == dim) return;
|
if (_lastLed.TryGetValue(roomId, out var last) && last == dim) return;
|
||||||
if (_demoTimer.IsEnabled) return;
|
|
||||||
if (!CanSend()) return;
|
if (!CanSend()) return;
|
||||||
_api.SetDiffuserLed(roomId, dim);
|
_api.SetDiffuserLed(roomId, dim);
|
||||||
_lastLed[roomId] = dim;
|
_lastLed[roomId] = dim;
|
||||||
@@ -525,18 +487,11 @@ namespace ErvDashboard
|
|||||||
if (!IsLoaded || _suppressReserve) return;
|
if (!IsLoaded || _suppressReserve) return;
|
||||||
if (ReserveCombo.SelectedIndex < 0) return;
|
if (ReserveCombo.SelectedIndex < 0) return;
|
||||||
int hours = ReserveCombo.SelectedIndex; // 0=해제, 1~8시간
|
int hours = ReserveCombo.SelectedIndex; // 0=해제, 1~8시간
|
||||||
if (_demoTimer.IsEnabled) { _state.ReserveRemainSec = hours * 3600; return; }
|
|
||||||
if (!CanSend()) return;
|
if (!CanSend()) return;
|
||||||
_api.SetReserve(hours);
|
_api.SetReserve(hours);
|
||||||
Log(hours == 0 ? "[제어] 예약 해제" : $"[제어] {hours}시간 후 꺼짐 예약");
|
Log(hours == 0 ? "[제어] 예약 해제" : $"[제어] {hours}시간 후 꺼짐 예약");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================= 데모 모드 (버튼 제거됨 — 내부 합성 STATUS 경로는 비활성 상태로 유지) =================
|
|
||||||
void DemoTick()
|
|
||||||
{
|
|
||||||
_api.InjectDemoStatus(_demoTick++);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================= UI 갱신 =================
|
// ================= UI 갱신 =================
|
||||||
void RefreshControls()
|
void RefreshControls()
|
||||||
{
|
{
|
||||||
@@ -564,26 +519,34 @@ namespace ErvDashboard
|
|||||||
// - 자동 : 수동 조절 불가(전 단 비활성)
|
// - 자동 : 수동 조절 불가(전 단 비활성)
|
||||||
// - 바이패스 : 최대 1단(2~4단 비활성)
|
// - 바이패스 : 최대 1단(2~4단 비활성)
|
||||||
// - 환기/공청 : 0~4단
|
// - 환기/공청 : 0~4단
|
||||||
// 시나리오모드 활성 시: 운전모드·풍량·선택 안 된 시나리오모드 비활성화
|
// 시나리오모드 활성 시: 운전모드·풍량·선택 안 된 시나리오모드 비활성화(=클릭 불가)
|
||||||
// 쾌적조리는 '연동운전중(HoodRunning=후드 가동중)' 기준으로 시나리오 활성 판단.
|
// 쾌적조리 잠금은 '메이크업 실제 연동중'(쾌적조리 토글 ON + 후드 가동) 일 때만.
|
||||||
// 후드 OFF(대기 상태)면 ERV는 본래 운전모드로 복귀하므로 운전모드를 다시 활성화해야 함(사양 3.1).
|
// - 펌웨어 Hood_process() 는 Hood_YeunDong_Enable==1 일 때만 모드/풍량을 건드림.
|
||||||
bool subActive = _state.SmartSleep || _state.HoodRunning || _state.ReliefRecover;
|
// 쾌적조리 OFF면 후드만 켜져도 ERV는 그대로 → 다른 시나리오모드/운전모드 선택 가능해야 함.
|
||||||
|
// - 후드 OFF(대기)면 메이크업 비연동 → 본래 운전모드로 복귀하므로 잠금 해제(사양 3.1).
|
||||||
|
bool makeupActive = _state.ComfortCook && _state.HoodRunning;
|
||||||
|
bool subActive = _state.SmartSleep || makeupActive || _state.ReliefRecover;
|
||||||
int fanMax = _state.RunMode == RunMode.Bypass ? 1 : 4;
|
int fanMax = _state.RunMode == RunMode.Bypass ? 1 : 4;
|
||||||
foreach (var fb in _fanButtons)
|
foreach (var fb in _fanButtons)
|
||||||
{
|
{
|
||||||
int sp = (int)fb.Tag!;
|
int sp = (int)fb.Tag!;
|
||||||
fb.IsEnabled = !subActive && !_state.IsAuto && sp <= fanMax;
|
// 전원 OFF면 풍량 조절 불가 → 0 포함 전 단 비활성
|
||||||
|
fb.IsEnabled = _state.PowerOn && !subActive && !_state.IsAuto && sp <= fanMax;
|
||||||
SetActive(fb, sp == _state.FanMode);
|
SetActive(fb, sp == _state.FanMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 시나리오모드
|
// 시나리오모드
|
||||||
SetActive(SmartSleepBtn, _state.SmartSleep);
|
SetActive(SmartSleepBtn, _state.SmartSleep);
|
||||||
SetActive(ComfortCookBtn, _state.HoodRunning); // 메이크업 실제 동작중(후드 가동)일 때만 강조 — 후드 OFF면 해제
|
// 쾌적조리는 사양 9p 3.1 의 'UI 토글(연동 스위치)' — 후드 가동중이 아니라 토글 ON/OFF(=Hood_YeunDong_Enable)를 강조.
|
||||||
|
// (HoodRunning 으로 강조하면 대기/Roll-back 상태에서 버튼이 꺼져 보여 재선택 시 토글이 반대로 먹던 문제 수정)
|
||||||
|
SetActive(ComfortCookBtn, _state.ComfortCook);
|
||||||
SetActive(ReliefRecoverBtn, _state.ReliefRecover);
|
SetActive(ReliefRecoverBtn, _state.ReliefRecover);
|
||||||
// (활성 모드 버튼은 OFF 토글 가능해야 하므로 자기 자신은 유지)
|
// 시나리오모드 버튼은 항상 선택 가능(클릭 가능). 다른 시나리오가 켜져 있어도 비활성화하지 않음.
|
||||||
SmartSleepBtn.IsEnabled = !subActive || _state.SmartSleep;
|
// - 스마트수면 ↔ 안심회복은 펌웨어상 단일 Ext_Run_Mode 라 동시 불가 → 클릭 시 SubMode_Click 이 서로 전환.
|
||||||
ComfortCookBtn.IsEnabled = !subActive || _state.ComfortCook;
|
// - 쾌적조리는 별도 변수(Hood_YeunDong_Enable)라 독립 토글.
|
||||||
ReliefRecoverBtn.IsEnabled = !subActive || _state.ReliefRecover;
|
SmartSleepBtn.IsEnabled = true;
|
||||||
|
ComfortCookBtn.IsEnabled = true;
|
||||||
|
ReliefRecoverBtn.IsEnabled = true;
|
||||||
// 스마트수면 시간설정 버튼 : 스마트수면 ON 일 때만 활성
|
// 스마트수면 시간설정 버튼 : 스마트수면 ON 일 때만 활성
|
||||||
SmartSleepSetBtn.IsEnabled = _state.SmartSleep;
|
SmartSleepSetBtn.IsEnabled = _state.SmartSleep;
|
||||||
foreach (var mb in _modeButtons) mb.IsEnabled = !subActive;
|
foreach (var mb in _modeButtons) mb.IsEnabled = !subActive;
|
||||||
@@ -673,7 +636,6 @@ namespace ErvDashboard
|
|||||||
|
|
||||||
protected override void OnClosed(EventArgs e)
|
protected override void OnClosed(EventArgs e)
|
||||||
{
|
{
|
||||||
_demoTimer.Stop();
|
|
||||||
_api.Dispose();
|
_api.Dispose();
|
||||||
_logDb.Dispose();
|
_logDb.Dispose();
|
||||||
base.OnClosed(e);
|
base.OnClosed(e);
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
# 260618 수정 요약 (핸드오프용)
|
||||||
|
|
||||||
|
> 작업 PC 이전 시점 기준. 아래 상세 항목 (1)~(5) 의 최종 변경 정리.
|
||||||
|
|
||||||
|
## 변경 파일 (커밋 포함)
|
||||||
|
| 파일 | 변경 요지 | 관련 항목 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Source/HECO2/User/MyMotor.c` | 모드전환 중(`Damper_wait_time==5`) 팬 강제 0 → 내부댐퍼 이동 게이트 보호 | (3) |
|
||||||
|
| `Source/HECO2/User/My_Homenet.c` | 모드변경 시 명령경로 즉시 `Fan_Speed_Setting` 호출 주석(L259/293) — 마스터 정렬 | (1) |
|
||||||
|
| `Source/HECO2/User/My_Hood.c` | 후드연동 모드변경 시 즉시 `Fan_Speed_Setting` 주석(L192/207) — 마스터 정렬 | (1) |
|
||||||
|
| `Source/HECO2/User/My_system.c` | 스마트수면 거실(room1) CO2 무관 항상 CLOSE | (2) |
|
||||||
|
| `TestProgram/PCDashBoard/MainWindow.xaml.cs` | 쾌적조리 버튼 강조=ComfortCook(L583), 활성=항상(L587) | (4)(5) |
|
||||||
|
| `개발사양서/...DL_동작로직_260613.pptx` | 사양서 갱신(바이너리) | - |
|
||||||
|
|
||||||
|
## 빌드 상태
|
||||||
|
- 펌웨어: `cd SOURCE/HECO2 && bash build.sh all` → 경고/오류 0.
|
||||||
|
- 대시보드: `dotnet publish ErvDashboard.csproj -c Release` → 오류 0 (NU1701 경고만).
|
||||||
|
- 대시보드 exe: `TestProgram/PCDashBoard/bin/Release/net10.0-windows/win-x64/publish/ErvDashboard.exe`
|
||||||
|
|
||||||
|
## 실장비/대시보드 확인 필요(테스트 대기)
|
||||||
|
- 환기→공청 반복 전환 시 내부댐퍼 매번 이동(간헐성 해소). 전환 시 팬이 잠깐 0까지 멈췄다 복귀하는 것이 정상.
|
||||||
|
- 스마트수면 진입 시 거실 닫힘 + 침실 CO2 개폐.
|
||||||
|
- 쾌적조리 버튼이 후드 연결/전원 무관하게 항상 토글되는지.
|
||||||
|
|
||||||
|
## 미적용/보류
|
||||||
|
- (선택) 쾌적조리를 스마트수면/안심회복과의 **상호배타에서 분리**(사양상 독립 토글) — SubMode_Click L401~406.
|
||||||
|
- (보류) 펌웨어 쾌적조리를 `Ext_Run_Mode==2` 상태머신으로 배선(현재 My_system 846/952 dead code). 1차 대시보드 수정으로 충분한지 검증 후 결정.
|
||||||
|
- 1차 명령경로 주석(My_Homenet/My_Hood)은 (3) 게이트 보호로 중복이나 무해 — 정리 보류.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 260618 내부댐퍼 — 운전모드 변경 시 미동작 수정 (1차: 명령경로 즉시 팬설정 제거)
|
||||||
|
|
||||||
|
## 증상
|
||||||
|
- 환기 → 공청 전환 시 **팬은 정상 동작하는데 내부댐퍼(본체 6개)가 안 움직임.**
|
||||||
|
|
||||||
|
## 원인 (마스터 `D:\Project\nuvoton\HERV_DL_MH_2nd\Program` 비교로 확정)
|
||||||
|
- 내부댐퍼 이동 트리거(`Fan_Speed_process`가 모드변경 시 `Damper_Mode()` 호출)는 HECO2/마스터 **동일하게 존재**. 빠진 게 아님.
|
||||||
|
- 그 트리거는 **"팬이 0까지 감속"되어야 발동**하도록 게이팅됨(공기 흐름 중 댐퍼 미동작 의도):
|
||||||
|
`모드변경 → 팬타깃0 → 팬=0 도달 → Damper_Mode 호출(댐퍼 이동) → 정렬(Step_Status==0x3F) → 팬 복원`
|
||||||
|
- **마스터:** 명령 핸들러가 팬 타깃을 안 건드림 → 팬이 0 도달 → 댐퍼 이동.
|
||||||
|
- **HECO2:** 명령 핸들러가 모드변경 즉시 `Fan_Speed_Setting()` 직접 호출 → 팬 타깃이 곧바로 운전속도로 올라감 → **팬이 0에 도달 못 함 → `if(Fan!=0) if(wait==5) goto PASS1` 게이트가 `Damper_Mode` 영원히 스킵 → 댐퍼 미동작, 팬만 정상.**
|
||||||
|
- 마스터엔 없고 HECO2에만 추가된 명령경로 `Fan_Speed_Setting` 호출이 게이트를 막은 것.
|
||||||
|
|
||||||
|
## 변경 파일/내용 (1차 = 모드 변경 시 호출만 제거, 풍량단수 변경 호출은 유지)
|
||||||
|
주석 처리(삭제 아님, 복원 용이). 마커: `[모드변경댐퍼테스트]`
|
||||||
|
- `Source/HECO2/User/My_Homenet.c`
|
||||||
|
- L259 CTRL_POWER(전원ON→환기) `Fan_Speed_Setting` 주석
|
||||||
|
- L293 CTRL_RUNMODE(운전모드 변경) `Fan_Speed_Setting` 주석 ← 보고 증상(대시보드 환기→공청)의 직접 경로
|
||||||
|
- **L306 CTRL_FAN(풍량단수만 변경)은 유지** (댐퍼 무관, 팬 단수 반응 필요)
|
||||||
|
- `Source/HECO2/User/My_Hood.c`
|
||||||
|
- L192 후드ON(→환기) `Fan_Speed_Setting` 주석
|
||||||
|
- L207 후드OFF(모드 복귀) `Fan_Speed_Setting` 주석
|
||||||
|
- **L219 후드 단수변경(풍량만)은 유지**
|
||||||
|
|
||||||
|
## 핵심 결정
|
||||||
|
- 모드 변경 후 팬 복원은 `Fan_Speed_process`의 wait==1 호출(MyMotor.c 1018/1056/1090/1124, ③)이 담당 → 팬이 꺼진 채 남지 않음.
|
||||||
|
- RJ2(`My_RJ2.c`)·분배기(`My_bunbaegi.c`)는 두 소스 동일 → 미수정.
|
||||||
|
- 이번엔 "동작모드 변경 트리거 복원(①, 디퓨저 시퀀스 명령트리거)"은 보류 — ②(땜빵) 제거만으로 댐퍼가 움직이는지 먼저 검증.
|
||||||
|
|
||||||
|
## 빌드 결과
|
||||||
|
- `cd SOURCE/HECO2 && bash build.sh all` → 성공, 경고/오류 0.
|
||||||
|
- text 43920 / data 1940 / bss 3388 (HERV.elf). (이전 44008 → 코드 제거로 감소)
|
||||||
|
|
||||||
|
## 미해결 / 후속
|
||||||
|
- 실장비/시뮬레이터에서 **환기→공청 시 내부댐퍼 이동** 확인 필요(특히 고풍량 전환 시 팬 감속 대기시간 체감).
|
||||||
|
- 검증 후: 필요하면 ①(동작모드 변경 트리거 = MyMotor.c 디퓨저/팬복원 타이밍의 명령 트리거)도 마스터 정렬 검토.
|
||||||
|
- 풍량단수만 바꿀 때(L306/L219) 팬 즉시 반응 정상인지 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 260618 (2) 스마트수면 모드 — 거실 댐퍼 미닫힘 수정
|
||||||
|
|
||||||
|
## 증상
|
||||||
|
- 스마트수면 동작 중 **거실 댐퍼가 열려 있음**. 사양(개발사양서 8p)상 거실은 항상 CLOSE여야 함.
|
||||||
|
|
||||||
|
## 사양 (개발사양서 8p, 스마트수면)
|
||||||
|
- 환기 수동·풍량 1단 고정.
|
||||||
|
- 초기상태: **거실 CLOSE, 침실1~3 OPEN**.
|
||||||
|
- 거실 급기(SA)/배기(RA) 모두 **X(항상 닫힘)** — CO2 무관.
|
||||||
|
- 침실1~3: CO2 >= 1000 PPM → 해당 침실 OPEN, CO2 <= 800 PPM → CLOSE.
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
- `My_system.c` `Air_Quality_damper_process()` `Ext_Run_Mode == 4` 블록.
|
||||||
|
- 진입 1회(L905)는 거실(room1)을 올바르게 CLOSE 하지만, **매 틱 CO2 루프가 `Room_Num = 1`부터 돌아(L916) 거실까지 CO2 기준으로 다시 OPEN** 시킴.
|
||||||
|
- 룸 인덱스: room1=거실, room2~4=침실1~3.
|
||||||
|
|
||||||
|
## 변경 파일/내용
|
||||||
|
- `Source/HECO2/User/My_system.c` (스마트수면 매 틱 처리)
|
||||||
|
- 거실(room1) 매 틱 강제 CLOSE + LED off 추가.
|
||||||
|
- CO2 히스테리시스 루프 시작을 `Room_Num = 1` → **`Room_Num = 2`** (침실1~3만 개폐).
|
||||||
|
|
||||||
|
## 빌드 결과
|
||||||
|
- `bash build.sh all` → 성공, 경고/오류 0. text 43940 / data 1940 / bss 3388.
|
||||||
|
|
||||||
|
## 후속
|
||||||
|
- 실장비에서 스마트수면 진입 시 거실 닫힘 + 침실 CO2 개폐 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 260618 (3) 내부댐퍼 미동작 — 진짜 원인(타이밍 레이스) 수정
|
||||||
|
|
||||||
|
## 추가 진단 (사용자 확인)
|
||||||
|
- 테스트 경로 = **PC 대시보드**(My_Homenet 경로).
|
||||||
|
- 팬 거동 = **0까지 안 내려감**, 그리고 **간헐적**(어쩌다 정상 댐퍼 동작).
|
||||||
|
- → 전형적 **타이밍 레이스**. (1)차 수정으로 CTRL_RUNMODE(293)는 막았지만, 대시보드가 모드변경과 함께 보내는 **CTRL_FAN(306, 유지됨)** 이 모드전환 감속창(팬→0 구간)에 끼어드는 타이밍이면 팬 타깃을 다시 켜서 팬이 0 도달 못 함 → Damper_Mode 게이트 안 열림.
|
||||||
|
- 끼어드는 타이밍이 아니면 정상 → 간헐성 설명됨.
|
||||||
|
|
||||||
|
## 수정 (2차 = 게이트 보호, 단일 지점)
|
||||||
|
- `Source/HECO2/User/MyMotor.c` `Fan_Speed_process()` PASS1 직후(정상운전 분기, VSP 제외):
|
||||||
|
- `Damper_wait_time == 5`(=모드전환 진행중 전용 신호. 4개 모드 진입에서만 set, Step 정렬 전까지 5 유지)일 때
|
||||||
|
`Target_Fan1_Speed = 0; Target_Fan2_Speed = 0;` **강제** → 명령경로(CTRL_FAN 등)가 감속창에 끼어들어도 무시하고 팬을 0으로 떨굼 → 댐퍼 이동 보장.
|
||||||
|
- 전환 완료(Step_Status==0x3F) 후 wait가 5→1로 감소하며 wait==1에서 Fan_Speed_Setting(③)이 팬 복원.
|
||||||
|
- 풍량단수만 변경(같은 모드, wait==0)은 force-zero 미적용 → CTRL_FAN(306) 즉시 반응 유지.
|
||||||
|
|
||||||
|
## 동작 변화(주의)
|
||||||
|
- 이제 환기→공청 전환 시 **팬이 잠깐 0까지 멈췄다가**(댐퍼 이동) 다시 도는 게 정상(설계상 공기흐름 중 댐퍼 미동작). 기존 "안 멈추던" 거동에서 바뀜.
|
||||||
|
|
||||||
|
## 빌드 결과
|
||||||
|
- `bash build.sh all` → 성공, 경고/오류 0. text 43948.
|
||||||
|
|
||||||
|
## 후속
|
||||||
|
- 실장비에서 환기→공청 **반복 전환** 시 매번 댐퍼 이동(간헐성 사라짐) 확인.
|
||||||
|
- 같은 모드 풍량단수 변경 시 팬 즉시 반응 정상 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 260618 (4) 쾌적조리 재선택 안됨 — 대시보드 버튼 강조 버그
|
||||||
|
|
||||||
|
## 증상
|
||||||
|
- 쾌적조리 동작 마치고 이전 모드 복귀 후 다시 쾌적조리 버튼 누르면 선택(ON)이 안 됨. 눌러도 토글 반응이 안 보임.
|
||||||
|
|
||||||
|
## 사양 (개발사양서 9p)
|
||||||
|
- 쾌적조리 = 운전모드가 아닌 **'후드 연동 UI 토글 스위치'**. 사용자가 켤 때까지 ON 유지.
|
||||||
|
- 3.1 매트릭스: 토글 ON + 후드꺼짐 = 대기(본래 모드 가동), 토글 ON + 후드켜짐 = 메이크업 강제연동.
|
||||||
|
- 3.3 Roll-back: 후드 OFF 후 20분 환기 약 후 이전 모드 복귀 — 이는 메이크업 복귀 타이밍이지 토글 OFF가 아님.
|
||||||
|
- → 토글은 **계속 armed**가 정상. 펌웨어 `Hood_YeunDong_Enable`(토글)은 정상.
|
||||||
|
|
||||||
|
## 원인 (순수 대시보드 버그)
|
||||||
|
- `_state.ComfortCook` = status byte4 bit1 = 펌웨어 `Hood_YeunDong_Enable`(토글 Enable).
|
||||||
|
- 대시보드가 **토글 판단은 `_state.ComfortCook`(L386), 버튼 강조는 `_state.HoodRunning`(L581, 후드 실제 가동중)** 으로 서로 다른 변수 사용.
|
||||||
|
- 대기/Roll-back 상태(HoodRunning=0, ComfortCook=1)에서 버튼이 꺼져 보이고, 다시 누르면 `next=!ComfortCook=false` → OFF 전송 → "선택 안됨".
|
||||||
|
|
||||||
|
## 변경 파일/내용
|
||||||
|
- `TestProgram/PCDashBoard/MainWindow.xaml.cs` L581:
|
||||||
|
- `SetActive(ComfortCookBtn, _state.HoodRunning);` → `SetActive(ComfortCookBtn, _state.ComfortCook);`
|
||||||
|
- 토글 상태(=Enable)를 강조하도록 일치. (모드/풍량 잠금 `subActive`는 사양 3.1대로 HoodRunning 유지 — 대기 중 본래 모드 조작 허용.)
|
||||||
|
|
||||||
|
## 빌드 결과
|
||||||
|
- `dotnet build ErvDashboard.csproj` → 오류 0 (NU1701 경고 9개는 기존 패키지 호환 경고, 무관).
|
||||||
|
|
||||||
|
## 미해결 / 후속(선택)
|
||||||
|
- 사양상 쾌적조리는 운전모드 시나리오(스마트수면/안심회복)와 **독립**인데, 대시보드는 셋을 상호배타 처리(SubMode_Click L401~406, ApplySubModeLocal). 스마트수면/안심회복 켤 때 쾌적조리가 강제 OFF됨 → 사양과 불일치 가능. 필요 시 분리 검토.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 260618 (5) 쾌적조리 버튼 활성(Enable)이 후드 상태에 묶임 — 독립 토글로 수정
|
||||||
|
|
||||||
|
## 증상
|
||||||
|
- 쾌적조리 버튼이 디폴트 비활성, 전원 ON/후드 연결 시에야 토글됨. 사양 9p 3.1상 쾌적조리는 후드 연결과 무관한 독립 토글.
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
- `MainWindow.xaml.cs` L587 `ComfortCookBtn.IsEnabled = !subActive || _state.ComfortCook;`
|
||||||
|
- `subActive = SmartSleep || HoodRunning || ReliefRecover` 에 **HoodRunning(후드 가동중)** 포함 → 후드 상태가 쾌적조리 버튼 활성을 좌우.
|
||||||
|
|
||||||
|
## 변경 파일/내용
|
||||||
|
- `TestProgram/PCDashBoard/MainWindow.xaml.cs` L587:
|
||||||
|
- `ComfortCookBtn.IsEnabled = !subActive || _state.ComfortCook;` → `ComfortCookBtn.IsEnabled = true;`
|
||||||
|
- 쾌적조리는 독립 토글 → 후드 연결/가동·다른 시나리오와 무관하게 항상 토글 가능. (모드/풍량 버튼은 기존대로 subActive 잠금 유지.)
|
||||||
|
|
||||||
|
## 빌드/배포
|
||||||
|
- `dotnet publish ErvDashboard.csproj -c Release` → 오류 0. publish 단일 exe 갱신.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 260618 (6) 쾌적조리 — 후드 전원 OFF 시 토글 자동 해제
|
||||||
|
|
||||||
|
## 요청 (사장님)
|
||||||
|
- 쾌적조리 선택 → 후드 동작 → **후드 전원 OFF** 했을 때, 대시보드 쾌적조리 버튼이 **저절로 꺼지게**(자동 해제) 해달라.
|
||||||
|
- 현재: 후드 OFF 후 이전 모드로 운전은 복귀되나 쾌적조리 토글은 armed 유지 → 수동으로 눌러야 꺼짐.
|
||||||
|
|
||||||
|
## 사양 변경 주의
|
||||||
|
- 기존 문서화 사양(개발사양서 260613 9p 3.3)은 **"후드 OFF는 메이크업 복귀 타이밍이지 토글 OFF가 아님 → 토글 armed 유지"**. 이번 요청으로 **반대로 변경**(후드 OFF=토글 자동 해제).
|
||||||
|
|
||||||
|
## 원인/구조 (정적분석)
|
||||||
|
- 대시보드 `_state.ComfortCook` ← STATUS byte4 bit1 ← 펌웨어 `Hood_YeunDong_Enable`. (byte5 bit0 `HoodEnable` 도 동일 변수)
|
||||||
|
- `Hood_YeunDong_Enable` 은 명령(`My_Homenet.c` CTRL_SUBMODE/CTRL_HOOD)으로만 set/clear, 펌웨어 자동 해제 없음 → 후드 OFF 후에도 1 유지 → 토글 계속 ON.
|
||||||
|
- 펌웨어 `Ext_Run_Mode==2` 쾌적조리 상태머신(My_system.c 846/952)은 대시보드 경로에서 dead code.
|
||||||
|
|
||||||
|
## 변경 파일/내용
|
||||||
|
- `Source/HECO2/User/My_Hood.c` `Hood_process()` 후드 OFF 전이 분기(`Hood_Status==0`, L197~):
|
||||||
|
- `Hood_YeunDong_Enable = 0;` 추가. 마커 `[쾌적조리자동해제]`.
|
||||||
|
- 효과: 후드 단수 0 도달(전원 OFF) 시 펌웨어가 토글 해제 → STATUS byte4 bit1/byte5 bit0 모두 0 → 대시보드 버튼 자동 해제. **대시보드 수정 불필요.**
|
||||||
|
- 부작용 없음 확인: 이 시점 `Hood_Status=0`·`Hood_Yeundong_flag=0` 이라 댐퍼 메이크업(My_system.c L1117)·룸컨 통지(My_RJ2.c L296) 이미 OFF 조건. 모드/풍량 복귀는 같은 분기에서 그대로 수행.
|
||||||
|
|
||||||
|
## 빌드 결과
|
||||||
|
- `bash build.sh all` → 성공, 경고/오류 0. text 43948 → **43952** (+4B), data 1940, bss 3388.
|
||||||
|
|
||||||
|
## 미해결 / 후속
|
||||||
|
- 실장비: 쾌적조리 ON → 후드 ON → **후드 전원 OFF** 시 쾌적조리 버튼이 자동으로 꺼지는지 + 이전 모드 복귀 동시 확인.
|
||||||
|
- 엣지: 후드를 **485 통신 두절(플러그 뽑힘)** 로 끄면 `Hood_Status` 가 마지막 값 유지되어 OFF 전이가 안 뜰 수 있음 → 그 경우 자동해제 미동작. 필요 시 `Hood_Conn_Timeout==0`(통신두절)에도 해제 추가 검토.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 260618 (7) 쾌적조리 미선택 + 후드 ON 시 다른 시나리오모드 버튼 잠김 수정
|
||||||
|
|
||||||
|
## 요청 (사장님)
|
||||||
|
- 쾌적조리를 **누르지 않고** 후드를 켰는데도 스마트수면/안심회복 등 다른 시나리오모드 버튼이 **선택 불가**(클릭 안 됨).
|
||||||
|
- 쾌적조리 미선택 상태에서 후드만 켜도 다른 시나리오모드 버튼은 **선택 가능**해야 함. (자동 켜짐이 아니라 '클릭 가능'.)
|
||||||
|
|
||||||
|
## 원인 (대시보드)
|
||||||
|
- `MainWindow.xaml.cs` L570 `subActive` 에 `_state.HoodRunning` 포함.
|
||||||
|
- `HoodRunning` = STATUS byte5 bit1 = `Hood_Status != 0` = 후드 **물리 가동중**(쾌적조리 선택 무관, `My_Homenet.c:131`).
|
||||||
|
- 그래서 후드만 켜도 `subActive=true` → 스마트수면/안심회복/운전모드/풍량 버튼이 비활성(클릭 불가).
|
||||||
|
- 그러나 펌웨어 `Hood_process()` 는 `Hood_YeunDong_Enable==1`(쾌적조리 armed) 일 때만 모드/풍량을 건드림 → 쾌적조리 OFF면 후드가 켜져도 ERV 동작에 영향 없음 → 잠글 이유 없음.
|
||||||
|
|
||||||
|
## 변경 파일/내용
|
||||||
|
- `TestProgram/PCDashBoard/MainWindow.xaml.cs` L570 부근:
|
||||||
|
- `bool makeupActive = _state.ComfortCook && _state.HoodRunning;` 신설.
|
||||||
|
- `subActive = _state.SmartSleep || makeupActive || _state.ReliefRecover;` (기존 `HoodRunning` → `makeupActive` 교체).
|
||||||
|
- 효과: 쾌적조리 미선택 + 후드 ON → `makeupActive=false` → 다른 시나리오모드/운전모드/풍량 버튼 선택 가능. 쾌적조리 ON + 후드 가동(메이크업 실제 연동중)일 때만 잠금 유지.
|
||||||
|
- (쾌적조리 ON + 후드 OFF '대기'도 `makeupActive=false` → 본래 운전모드 조작 가능, 사양 3.1 일치.)
|
||||||
|
|
||||||
|
## 빌드/배포
|
||||||
|
- `dotnet publish ErvDashboard.csproj -c Release` → 오류 0 (NU1701 경고만). publish 단일 exe 갱신.
|
||||||
|
|
||||||
|
## 후속
|
||||||
|
- 실장비: 쾌적조리 미선택 + 후드 ON 시 스마트수면/안심회복 버튼 클릭되는지, 쾌적조리 ON + 후드 가동 시엔 잠기는지 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 260618 (8) 시나리오모드 버튼 — 항상 선택 가능하게 (상호 비활성 해제)
|
||||||
|
|
||||||
|
## 요청 (사장님)
|
||||||
|
- 스마트수면을 선택하면 다른 시나리오 버튼이 비활성화됨 → **그냥 모두 선택(클릭) 가능**하게.
|
||||||
|
|
||||||
|
## 펌웨어 제약 (참고)
|
||||||
|
- 시나리오는 단일 `Ext_Run_Mode` : 스마트수면=4, 안심회복=1 → **동시 ON 물리적 불가**(전환만 가능). 쾌적조리는 별도 `Hood_YeunDong_Enable` → 독립.
|
||||||
|
|
||||||
|
## 변경 파일/내용
|
||||||
|
- `TestProgram/PCDashBoard/MainWindow.xaml.cs` L589~592:
|
||||||
|
- `SmartSleepBtn.IsEnabled`/`ReliefRecoverBtn.IsEnabled` 의 `!subActive || _state.xxx` 조건 제거 → **세 버튼 모두 `IsEnabled = true`** (쾌적조리는 이미 true).
|
||||||
|
- 클릭 시 동작은 기존 `SubMode_Click` 상호배타(L401~406) 그대로 → 스마트수면↔안심회복은 서로 전환.
|
||||||
|
- 운전모드/풍량/프리셋 버튼의 `subActive` 잠금은 유지(시나리오 동작 중엔 모드/풍량 시나리오가 제어).
|
||||||
|
|
||||||
|
## 빌드/배포
|
||||||
|
- `dotnet publish ErvDashboard.csproj -c Release` → 오류 0. publish 갱신.
|
||||||
|
|
||||||
|
## 후속/보류
|
||||||
|
- 현재 상호배타라 **쾌적조리 ON 중 스마트수면/안심회복 선택 시 쾌적조리도 꺼짐**(SubMode_Click L401~406). 사양상 쾌적조리는 독립이므로, 쾌적조리는 시나리오 전환과 무관하게 유지하길 원하면 별도 분리 필요(추후 결정).
|
||||||
|
- → 260618 (9) 에서 상호배타 로직 전체 삭제로 해소.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 260618 (9) 시나리오 상호배타 로직 삭제 + 데모 루틴 전체 삭제
|
||||||
|
|
||||||
|
## 요청 (사장님)
|
||||||
|
- 시나리오 상호배타 로직 삭제(어차피 하나만 선택). 데모 루틴도 모두 삭제.
|
||||||
|
|
||||||
|
## 변경 — 상호배타 삭제
|
||||||
|
- `TestProgram/PCDashBoard/MainWindow.xaml.cs`
|
||||||
|
- `SubMode_Click` : 새 모드 켤 때 기존 모드 OFF 전송하던 블록(구 L399~406) 삭제.
|
||||||
|
- `ApplySubModeLocal` : on 시 나머지 해제하던 로직 → **클릭한 태그만 on/off** 로 단순화.
|
||||||
|
- 동작: 시나리오 버튼은 서로 영향 없이 각각 토글. (스마트수면↔안심회복은 펌웨어 단일 Ext_Run_Mode 라 장치단에서 전환되고 STATUS 로 재동기.)
|
||||||
|
|
||||||
|
## 변경 — 데모 루틴 전체 삭제
|
||||||
|
- `MainWindow.xaml.cs` : `_demoTimer`/`_demoTick` 필드, 생성자 타이머 초기화, `DemoTick()`, OnClosed `_demoTimer.Stop()`, 그리고 제어 핸들러 곳곳의 `if(_demoTimer.IsEnabled){...}` 분기(전원/모드/풍량/시나리오/후드/리셋/댐퍼/LED/예약/프리셋) 전부 제거. `CanSend()` 의 데모 가드도 제거.
|
||||||
|
- `Api/IErvApi.cs` : `InjectDemoStatus` 선언 삭제.
|
||||||
|
- `Api/SerialErvApi.cs` : `InjectDemoStatus` 구현 삭제.
|
||||||
|
- `ErvProtocol/DemoStatus.cs` : 파일 삭제(타 프로젝트 참조 없음 확인).
|
||||||
|
|
||||||
|
## 빌드/배포
|
||||||
|
- `dotnet publish ErvDashboard.csproj -c Release` → 오류 0. publish 갱신.
|
||||||
|
|
||||||
|
## (10) 안심회복 기본 선택 표시 — 해결됨(대시보드 버그 아님)
|
||||||
|
- 증상: 대시보드 실행 시 안심회복이 선택된 것으로 표시.
|
||||||
|
- 정적분석: 대시보드는 기본 미선택이 맞음(_state 전부 false, RefreshControls 가 _state 값으로만 강조). 안심회복 강조는 오직 STATUS byte4 bit2(=펌웨어 `Ext_Run_Mode==1`) 수신 시에만 켜짐.
|
||||||
|
- 펌웨어 `Ext_Run_Mode` 기본값 0(My_bunbaegi.c:984), 1로 설정되는 곳은 명령(My_Homenet.c:319, ReliefRecover ON)뿐 → 펌웨어 자가진입 없음.
|
||||||
|
- **원인 확정**: 연결된 ERV가 이전 테스트에서 안심회복 켠 상태로 RAM에 남아있었고(전원 미재투입), 대시보드만 재실행해 그 상태를 그대로 표시한 것.
|
||||||
|
- **검증 완료**: ERV 전원 OFF→ON(콜드부트) 후 대시보드 실행 시 안심회복 버튼 해제됨(사장님 확인 2026-06-18). 코드 수정 불필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 260618 (11) 대시보드 전원 ON 시 룸컨 간헐적 미동작 (타이밍 레이스) 수정
|
||||||
|
|
||||||
|
## 증상
|
||||||
|
- 대시보드에서 전원 ON 하면 ERV에 연결된 룸컨이 **간헐적으로 안 켜짐**.
|
||||||
|
|
||||||
|
## 원인 (룸컨 echo ↔ 푸시 레이스)
|
||||||
|
- 룸컨(RJ2)은 마스터로 폴링, ERV는 응답(slave). 전원/모드/풍량 변경은 ERV가 `Command_request_type`의 `TYPE_MODE|TYPE_FAN_SPEED` 플래그를 보고 `RX_DATA_MODE_NORMAL` 응답에 `COMMAND_CONTROLL`(Set_Run/Set_Fan)을 실어 푸시.
|
||||||
|
- CTRL_POWER ON 핸들러(`My_Homenet.c`)가 `Set_Fan_Mode = Fan_Mode = 1` 로 **Set==현재**로 만듦.
|
||||||
|
- 푸시(NORMAL)보다 룸컨 상태 echo `RX_DATA_CONTROLL`(My_RJ2.c L698 else)/`RX_DATA_MODE_EVENT`(L409 else)가 먼저 도착하면:
|
||||||
|
- else가 `Set_Fan_Mode = Fan_Mode = Rx[3]`로 **룸컨 현재(OFF=0) 상태를 Set_* 에 덮음**,
|
||||||
|
- 이어 L715~716이 `Set==현재`라 `TYPE_FAN_SPEED`를 **조기 클리어** → 푸시가 영영 안 나감 → 룸컨 미동작.
|
||||||
|
- 도착 순서(NORMAL vs CONTROLL/EVENT)에 따라 간헐적.
|
||||||
|
|
||||||
|
## 수정 (2파일 3곳)
|
||||||
|
- `Source/HECO2/User/My_Homenet.c` CTRL_POWER ON: `Set_Fan_Mode = Fan_Mode = 1;` → **`Set_Fan_Mode = 1;`** (Fan_Mode 즉시 1로 안 올림). Set_Fan(1)!=Fan(0) 유지 → echo가 먼저 와도(아래 가드와 함께) TYPE_FAN_SPEED 가 살아 NORMAL 푸시 보장. 팬은 룸컨 echo가 Fan_Mode=1 로 갱신하면 켜짐. 마커 `[전원ON룸컨레이스]`.
|
||||||
|
- `Source/HECO2/User/My_RJ2.c` 룸컨 echo else 2곳을 가드 :
|
||||||
|
- L409 `RX_DATA_MODE_EVENT` else → `else if(!(Command_request_type & (TYPE_MODE|TYPE_FAN_SPEED)))`
|
||||||
|
- L698 `RX_DATA_CONTROLL` else → 동일 가드
|
||||||
|
- 효과: 대시보드 명령(전원/모드/풍량)이 푸시 대기중이면 룸컨 자기보고로 Set_* 를 덮지 않음 → 명령 유실/조기 클리어 방지. ack(SEND_FLAG) 경로는 if-branch라 가드 영향 없음. 평상시(명령 없음) 룸컨 패널 조작 반영은 그대로(가드 통과).
|
||||||
|
|
||||||
|
## 빌드 결과
|
||||||
|
- `bash build.sh all` → 성공, 경고/오류 0. text 43952 → **43972**.
|
||||||
|
|
||||||
|
## 후속 / 참고
|
||||||
|
- 실장비: 대시보드 전원 ON 반복 시 룸컨이 매번 켜지는지 확인.
|
||||||
|
- 동일 패턴 잠재: `CTRL_RUNMODE`(L287/292)·`CTRL_FAN`(L308)도 `Set==현재`로 equalize → 같은 716 조기클리어 레이스 소지. else 가드(L409/698)로 클로버는 막히나, 모드/풍량 변경 시에도 룸컨 푸시 누락이 보이면 동일하게 de-equalize 적용 검토. (이번엔 보고된 전원 ON 만 수정.)
|
||||||
|
- → 260618 (12) 에서 룸컨 전용 pending(`RoomCtrl_Push`)으로 전원 ON/OFF 모두 견고화. (11)의 de-equalize 는 원복(불필요).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 260618 (12) 전원 OFF 시 룸컨이 옛 모드(공청) 계속 표시 + (11) 통합 — 룸컨 전용 푸시 플래그
|
||||||
|
|
||||||
|
## 증상
|
||||||
|
- 공청에서 대시보드 전원 OFF → ERV(모터)는 정지하는데 **룸컨은 계속 "공청" 표시**. (전원 ON 간헐 미동작과 같은 계열)
|
||||||
|
|
||||||
|
## 근본 원인 (공유 플래그 레이스)
|
||||||
|
- `Command_request_type` 을 **룸컨(RJ2)·각실분배기(bunbagi) 두 소비자가 공유**.
|
||||||
|
- `My_bunbaegi.c:522` 가 프레임 송신 후 `Command_request_type = 0;` 로 **전체를 wipe** → 분배기가 먼저 처리하면 RJ2가 플래그를 못 봐 룸컨 푸시 유실.
|
||||||
|
- 전원 OFF는 모터 정지를 위해 `Set_Run=Run=VENT, Set_Fan=Fan=0` **equalize 강제**(주석 250~253) → `My_RJ2.c:715~716`(Set==현재 → TYPE_MODE/FAN 클리어)가 NORMAL 푸시 전에 플래그 제거 → 룸컨 푸시 유실.
|
||||||
|
- 둘 다 도착순서 의존 → 간헐/상황적.
|
||||||
|
- (부수발견) `TYPE_POWER(0x40)` == `TYPE_HOOD_STATE(0x40)` **비트 충돌**(My_define.h). 전원명령↔후드상태가 서로 오인될 잠재 버그. 이번 수정은 별도 변수를 써서 이 충돌을 우회(미해결로 남김 — 후속 정리 권장).
|
||||||
|
|
||||||
|
## 수정 — 룸컨 전용 pending 플래그 `RoomCtrl_Push`
|
||||||
|
- `My_define.h` : `extern uint8_t RoomCtrl_Push;` 추가.
|
||||||
|
- `My_RJ2.c` :
|
||||||
|
- 전역 `uint8_t RoomCtrl_Push = 0;` 정의.
|
||||||
|
- NORMAL 푸시 조건(L332)에 `|| RoomCtrl_Push` 추가 → 분배기 wipe·716 클리어와 무관하게 룸컨에 푸시.
|
||||||
|
- echo else 가드(L409/698) : `!RoomCtrl_Push && !(...)` → pending 중 룸컨 자기보고로 Set_* 안 덮음.
|
||||||
|
- ack(SEND_FLAG) 경로(EVENT L399 / CONTROLL L691) : `RoomCtrl_Push = 0;` → 룸컨이 명령 수신·확인하면 해제.
|
||||||
|
- `My_Homenet.c` CTRL_POWER : `Command_request_type |= (TYPE_POWER|TYPE_MODE|TYPE_FAN_SPEED);` 직후 `RoomCtrl_Push = 1;`. (11)의 ON de-equalize 는 원복(`Set_Fan_Mode = Fan_Mode = 1;`) — RoomCtrl_Push 가 푸시를 보장하므로 불필요, 모터는 즉시 ON.
|
||||||
|
|
||||||
|
## 효과
|
||||||
|
- 전원 ON/OFF 시 분배기·716 타이밍과 무관하게 룸컨에 Set_Run/Set_Fan(전원 OFF=VENT/0) 이 반드시 푸시 → 룸컨이 즉시 OFF(또는 ON) 동기. ack 시 pending 해제로 평상시 룸컨 패널 조작 반영은 그대로.
|
||||||
|
|
||||||
|
## 빌드 결과
|
||||||
|
- `bash build.sh all` → 성공, 경고/오류 0. text 43972 → **44024**.
|
||||||
|
|
||||||
|
## 후속 / 주의
|
||||||
|
- **실장비 검증 필수** : 핵심 통신 상태머신(RJ2) 변경. 전원 ON/OFF 반복 시 룸컨이 매번 동기되는지, 룸컨 패널 직접 조작·후드연동·분배기 동작에 회귀 없는지 확인.
|
||||||
|
- `TYPE_POWER`/`TYPE_HOOD_STATE` 0x40 비트 충돌은 미해결(빈 비트 0x08 로 분리 권장). 이번 수정과 독립.
|
||||||
|
- 모드/풍량 변경(CTRL_RUNMODE/CTRL_FAN)도 룸컨 동기 누락이 보이면 동일하게 `RoomCtrl_Push = 1;` 적용 가능.
|
||||||
|
- **검증 완료**: 전원 ON/OFF 시 룸컨 동기 정상(사장님 확인 2026-06-18).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 260618 (13) 전원 OFF 시 풍량 버튼(0 포함) 비활성화
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
- 전원 OFF 하면 풍량 0 버튼도 비활성화.
|
||||||
|
|
||||||
|
## 변경
|
||||||
|
- `TestProgram/PCDashBoard/MainWindow.xaml.cs` 풍량 버튼 활성 조건:
|
||||||
|
- `fb.IsEnabled = !subActive && !_state.IsAuto && sp <= fanMax;`
|
||||||
|
- → `fb.IsEnabled = _state.PowerOn && !subActive && !_state.IsAuto && sp <= fanMax;`
|
||||||
|
- 전원 OFF면 0~4 전 단 비활성(풍량 조절 불가). 운전모드 버튼은 그대로(전원 OFF에서 모드 누르면 전원 ON).
|
||||||
|
|
||||||
|
## 빌드/배포
|
||||||
|
- `dotnet publish ErvDashboard.csproj -c Release` → 오류 0. (BAML stale 캐시로 1회 실패 → obj/bin clean 후 성공.) publish 갱신.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 260618 (14) 그래프 로그 DB(HERV_Log.db)가 publish 폴더에 안 생기던 문제
|
||||||
|
|
||||||
|
## 증상
|
||||||
|
- 그래프 시계열 DB `HERV_Log.db` 가 publish 폴더에 안 보임. 재실행 간 그래프 이력도 이어지지 않음.
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
- DB 경로가 `AppContext.BaseDirectory` 기준이었음(`MainWindow.xaml.cs`).
|
||||||
|
- csproj 가 `PublishSingleFile=true` + **`IncludeAllContentForSelfExtract=true`** → 단일 exe 실행 시 **매번 `%TEMP%\.net\ErvDashboard\<랜덤해시>\` 로 추출**해 실행 → `AppContext.BaseDirectory` 가 그 임시폴더를 가리킴.
|
||||||
|
- 결과: DB 가 임시폴더에 생기고(publish 폴더엔 없음), **실행마다 추출 해시가 달라 DB 가 매번 새로 생겨 데이터가 흩어짐**(이력 유실). (디스크 검색으로 `%TEMP%\.net\ErvDashboard\*\HERV_Log.db` 다수 확인.)
|
||||||
|
|
||||||
|
## 변경
|
||||||
|
- `TestProgram/PCDashBoard/MainWindow.xaml.cs` : DB 경로를 실제 exe 위치 기준으로.
|
||||||
|
- `AppContext.BaseDirectory` → `Path.GetDirectoryName(Environment.ProcessPath) ?? AppContext.BaseDirectory`
|
||||||
|
- 이제 `HERV_Log.db` 가 exe 와 같은 publish 폴더에 고정 생성 → 재실행해도 같은 파일에 누적.
|
||||||
|
|
||||||
|
## 빌드/배포
|
||||||
|
- `dotnet publish` → 오류 0 (clean 후). publish 갱신.
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
- 임시폴더에 흩어진 옛 DB 는 고아. 보존하려면 최신 것을 publish 폴더 `HERV_Log.db` 로 복사하면 이력 이어짐.
|
||||||
|
- 그래프 '불러오기'→'엑셀저장' 은 `_selectedDate` 로 DB 재조회(`LoadByDate`)하므로 과거 날짜 데이터가 정상 저장됨(코드 확인, 수정 불필요). 단 위 DB 경로 수정 이후 기록분부터 누적.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 260618 (15) 배포 실행파일 git 포함 (Release/260618)
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
- 펌웨어 + TestProgram + Simulator 결과물(실행파일)을 모두 git에 올림.
|
||||||
|
|
||||||
|
## 작업
|
||||||
|
- `.gitignore` : 루트 `Release/` 배포 폴더만 추적 해제(`!/Release/`, `!/Release/**`). bin/Release·obj 등 빌드 중간물은 계속 제외.
|
||||||
|
- 전 프로젝트 `dotnet publish -c Release`(self-contained 단일 exe; ErvCollector는 `-r win-x64 --self-contained -p:PublishSingleFile=true`) 후 `Release/260618/` 에 수집:
|
||||||
|
- `ErvDashboard.exe`(PC 대시보드), `DiffuserSimulator.exe`, `ERVSimulator.exe`, `HoodSimulator.exe`, `RJ2RoomConSimulator.exe`
|
||||||
|
- `ErvCollector/`(웹 수집서버 — exe+appsettings.json+wwwroot+e_sqlite3.dll 폴더 통째)
|
||||||
|
- `Firmware/` : `HERV.bin/.hex/.elf`(RoomCtrl_Push 반영본)
|
||||||
|
- 총 ~762MB.
|
||||||
|
|
||||||
|
## 주의
|
||||||
|
- self-contained 단일 exe라 개당 ~140~163MB → git 히스토리가 커짐(영구 누적). 반복 배포가 잦으면 **Gitea Releases(릴리스 자산 첨부)** 가 저장소 비대화 방지에 유리 — 후속 검토 권장.
|
||||||
|
- 노트북에서 `git pull` 하면 `Release/260618/` 의 실행파일들이 함께 내려옴.
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# HECO2 Gitea 저장소 — 접속 · 협업 · 작업환경 가이드
|
||||||
|
|
||||||
|
HERV 통합 소스를 사내 Gitea로 관리하고 인터넷에서 접속·협업하기 위한 안내 문서.
|
||||||
|
|
||||||
|
> ⚠️ 비밀번호·토큰 등 민감정보는 이 문서에 적지 않습니다. 각자 안전하게 보관하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 웹 주소 | https://gitea.himpelai.com |
|
||||||
|
| 저장소 | https://gitea.himpelai.com/jeon/HECO2 |
|
||||||
|
| Clone 주소(HTTPS) | `https://gitea.himpelai.com/jeon/HECO2.git` |
|
||||||
|
| 접속 | 사내/외부(인터넷)·휴대폰 모두 위 주소로 동일하게 접속 |
|
||||||
|
|
||||||
|
회원가입은 보안상 막혀 있습니다. 계정은 **관리자(jeon)가 생성**해 전달합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 서버 구성 (운영자 참고)
|
||||||
|
|
||||||
|
```
|
||||||
|
인터넷
|
||||||
|
│ https://gitea.himpelai.com
|
||||||
|
▼
|
||||||
|
가비아 DNS gitea.himpelai.com ─(CNAME)→ himpelai-gitea.duckdns.org ─(DDNS)→ 현재 공인IP(유동)
|
||||||
|
▼
|
||||||
|
TP-Link 공유기 외부 80/443 → 192.168.0.129
|
||||||
|
▼
|
||||||
|
이 PC : Caddy(80/443, 자동 HTTPS / Let's Encrypt)
|
||||||
|
▼
|
||||||
|
Gitea (localhost:3000)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **설치 위치**: `D:\GITEA` (설정 `D:\GITEA\custom\conf\app.ini`, RUN_USER=himpel)
|
||||||
|
- **HTTPS**: Caddy가 Let's Encrypt 인증서 자동 발급·갱신 (`D:\GITEA\Caddyfile`)
|
||||||
|
- **유동 IP 대응**: DuckDNS가 5분마다 현재 IP 추적 (`himpelai-gitea.duckdns.org`)
|
||||||
|
- **DNS**: `himpelai.com`은 가비아 관리, `gitea` 서브도메인만 CNAME 추가(메일 등 영향 없음)
|
||||||
|
|
||||||
|
### 자동시작 (himpel 로그온 시)
|
||||||
|
재부팅 후 **himpel 계정으로 로그인하면** 아래가 자동 기동됨:
|
||||||
|
|
||||||
|
| 예약작업 | 역할 | 실행계정 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Gitea-Web-Autostart` | Gitea 서버 (`D:\GITEA\start-gitea.ps1`) | himpel(로그온) |
|
||||||
|
| `Caddy-Gitea-Autostart` | HTTPS 프록시 (`D:\GITEA\start-caddy.ps1`) | himpel(로그온) |
|
||||||
|
| `DuckDNS-Gitea-Update` | 유동IP 추적 (`D:\GITEA\duckdns-update.ps1`) | SYSTEM(5분) |
|
||||||
|
|
||||||
|
> **재부팅 후 사이트 접속이 안 될 때**: 서버 PC가 **himpel로 로그인됐는지** 먼저 확인. 로그인 전에는 Gitea·Caddy가 안 떠서 502가 납니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 외부 인원 등록 (관리자 jeon)
|
||||||
|
|
||||||
|
### 3-1. 계정 생성 (웹 관리자 화면)
|
||||||
|
1. `jeon`으로 로그인 → 우측 상단 **프로필 사진** → **「사이트 관리」** (또는 주소 `/-/admin`)
|
||||||
|
2. 왼쪽 **「사용자 계정」** → 오른쪽 위 **「사용자 계정 생성」**
|
||||||
|
3. 입력: **사용자 이름 / 이메일 주소 / 비밀번호**, ✅ **「처음 로그인할 때 비밀번호 변경」** 체크
|
||||||
|
4. **「사용자 계정 생성」** → 인원수만큼 반복
|
||||||
|
|
||||||
|
> 「사이트 관리」 메뉴가 안 보이면 그 계정은 관리자가 아님.
|
||||||
|
|
||||||
|
### 3-2. 저장소 권한 부여
|
||||||
|
1. `https://gitea.himpelai.com/jeon/HECO2` → 상단 **「설정」**
|
||||||
|
2. 왼쪽 **「공동 작업자」** → 사용자 이름 입력 → **「공동 작업자 추가」**
|
||||||
|
3. 권한 선택: **「쓰기」**(push 가능) / **「읽기」**(보기만)
|
||||||
|
|
||||||
|
> 인원·저장소가 늘면 개인 소유 대신 **Organization(조직)**으로 묶어 관리 권장.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 다른 컴퓨터에서 작업환경 구성 (VS Code + Claude Code)
|
||||||
|
|
||||||
|
> Gitea 계정과 Claude(Anthropic) 계정은 별개입니다.
|
||||||
|
|
||||||
|
### 4-1. Git 설치
|
||||||
|
- https://git-scm.com/download/win (기본 옵션 설치)
|
||||||
|
|
||||||
|
### 4-2. 저장소 클론
|
||||||
|
```powershell
|
||||||
|
git clone https://gitea.himpelai.com/jeon/HECO2.git
|
||||||
|
```
|
||||||
|
- 아이디/비밀번호 → **Gitea 계정**으로 입력
|
||||||
|
- 권장: 비밀번호 대신 **액세스 토큰** 사용
|
||||||
|
- Gitea 로그인 → 프로필 → **설정 → 응용 프로그램 → 토큰 생성** → 토큰을 비밀번호 자리에 입력
|
||||||
|
|
||||||
|
### 4-3. VS Code 설치
|
||||||
|
- https://code.visualstudio.com → 설치 → **파일 → 폴더 열기** → 클론한 `HECO2` 폴더
|
||||||
|
|
||||||
|
### 4-4. Claude Code 설치
|
||||||
|
- VS Code 왼쪽 **확장(Extensions)** (`Ctrl+Shift+X`) → **`Claude Code`** 검색 → Anthropic 확장 설치
|
||||||
|
- 안내에 따라 **Anthropic 계정 로그인** (Claude 구독 또는 API 키)
|
||||||
|
- (대안) 터미널: Node.js 설치 후 `npm install -g @anthropic-ai/claude-code` → 폴더에서 `claude`
|
||||||
|
|
||||||
|
### 4-5. 작업 & 반영
|
||||||
|
```powershell
|
||||||
|
git add .
|
||||||
|
git commit -m "작업 내용"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
→ `https://gitea.himpelai.com/jeon/HECO2` 에 반영
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 자주 쓰는 git 명령
|
||||||
|
```powershell
|
||||||
|
git clone https://gitea.himpelai.com/jeon/HECO2.git # 처음 받기
|
||||||
|
git pull # 최신 내용 받기 (작업 전 권장)
|
||||||
|
git add . # 변경분 담기
|
||||||
|
git commit -m "메시지"
|
||||||
|
git push # 서버에 올리기
|
||||||
|
git status # 현재 상태 확인
|
||||||
|
```
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Memory Index
|
||||||
|
|
||||||
|
- [HERV 마스터 소스](herv-master-source.md) — 검증된 펌웨어 = D:\Project\nuvoton\HERV_DL_MH_2nd\Program, 댐퍼/팬 회귀 비교 기준
|
||||||
|
- [내부댐퍼 팬 게이트](internal-damper-fan-gate.md) — 본체 댐퍼는 팬이 0까지 내려가야 모드별 이동, 명령경로 끼어들면 미동작
|
||||||
|
- [Command_request_type 공유 레이스](command-request-type-shared-race.md) — 룸컨·분배기 공유 플래그 → 전원 푸시 유실, RoomCtrl_Push 전용 플래그로 해결
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Claude Code 메모리 백업 (git 동기화용)
|
||||||
|
|
||||||
|
이 폴더는 Claude Code 의 프로젝트 메모리 사본입니다. (원본은 사용자 프로필
|
||||||
|
`C:\Users\<사용자>\.claude\projects\d--Project-nuvoton-HECO2\memory\` 에 있고 git 밖이라,
|
||||||
|
PC 이전 시 동기화되도록 여기에 복사해 둠.)
|
||||||
|
|
||||||
|
## 새 PC에서 적용하는 법
|
||||||
|
1. 저장소를 **같은 경로** `d:\Project\nuvoton\HECO2` 로 clone (경로가 다르면 자동 연결 안 됨).
|
||||||
|
2. 이 폴더의 `*.md` 3개를 새 PC의
|
||||||
|
`C:\Users\<새사용자>\.claude\projects\d--Project-nuvoton-HECO2\memory\` 로 복사.
|
||||||
|
(폴더 없으면 생성)
|
||||||
|
3. 그 폴더의 `MEMORY.md` 가 매 세션 자동 로드되어 아래 사실을 recall.
|
||||||
|
|
||||||
|
## 대화 이력 이어받기 (`claude --resume`)
|
||||||
|
`session/5209cdb2-...jsonl` 가 이 대화의 전체 이력 사본입니다. 새 PC에서:
|
||||||
|
1. 이 파일을 `C:\Users\<새사용자>\.claude\projects\d--Project-nuvoton-HECO2\`
|
||||||
|
(memory 의 상위, 프로젝트 폴더 바로 아래)에 **파일명 그대로** 복사.
|
||||||
|
2. 프로젝트 폴더 `d:\Project\nuvoton\HECO2` 에서 `claude --resume` → 목록에서 이 대화 선택
|
||||||
|
(또는 `claude --continue` 로 가장 최근 대화 이어가기).
|
||||||
|
|
||||||
|
> 주의: 이 사본은 **커밋 시점까지의 스냅샷**입니다. 이후 같은 대화에서 더 진행한 내용은
|
||||||
|
> 포함되지 않으므로, 이어가다 또 PC를 옮기려면 갱신된 `.jsonl` 을 다시 커밋해야 합니다.
|
||||||
|
|
||||||
|
## 포함된 메모리
|
||||||
|
- `MEMORY.md` — 인덱스
|
||||||
|
- `herv-master-source.md` — 검증된 마스터 펌웨어 = `D:\Project\nuvoton\HERV_DL_MH_2nd\Program`
|
||||||
|
- `internal-damper-fan-gate.md` — 내부댐퍼는 팬이 0까지 내려가야 모드별 이동(게이팅) 원리
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
name: command-request-type-shared-race
|
||||||
|
description: ERV의 Command_request_type은 룸컨(RJ2)·분배기(bunbagi) 공유 플래그라 대시보드 명령 푸시에 레이스가 있음
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
HECO2 ERV 펌웨어에서 `Command_request_type`(My_RJ2.c 전역, My_define.h TYPE_* 비트)은 **룸컨(RJ2, SC0)과 각실분배기(My_bunbaegi.c) 두 통신 소비자가 공유**한다. 대시보드 명령(전원/모드/풍량)이 이 플래그를 set 하면 두 버스가 각자 소비·클리어하는데:
|
||||||
|
- `My_bunbaegi.c:522` 가 프레임 송신 후 `Command_request_type = 0;` 로 **전체 wipe** → 분배기가 먼저 돌면 RJ2가 플래그를 못 봐 룸컨 푸시 유실.
|
||||||
|
- `My_RJ2.c:715~716` 이 `Set==현재` 면 TYPE_MODE/FAN 클리어. 전원 OFF는 모터정지 위해 `Set_Run=Run=VENT, Set_Fan=Fan=0` equalize 강제(My_Homenet.c CTRL_POWER 주석 250~253) → NORMAL 푸시 전에 플래그 제거 → 룸컨이 옛 모드(예: 공청) 계속 표시.
|
||||||
|
- 둘 다 도착순서 의존 → 간헐적.
|
||||||
|
|
||||||
|
**Why:** 전원 ON 간헐 미동작 / 전원 OFF 후 룸컨 옛모드 표시의 근본 원인. 2026-06-18 수정.
|
||||||
|
|
||||||
|
**How to apply:** 룸컨에 반드시 보내야 하는 대시보드 명령은 공유 `Command_request_type` 대신 **룸컨 전용 pending `RoomCtrl_Push`**(My_RJ2.c 전역)로 표시한다. 분배기가 못 건드리고 RJ2 ack(SEND_FLAG echo, EVENT L399/CONTROLL L691)로만 해제. RJ2 NORMAL 푸시조건(L332)에 `|| RoomCtrl_Push`, echo else 가드(L409/698)에 `!RoomCtrl_Push &&` 추가됨. 모드/풍량도 동기 누락 보이면 `CTRL_RUNMODE/CTRL_FAN`에 `RoomCtrl_Push=1` 적용. 별개 잠재버그: `TYPE_POWER==TYPE_HOOD_STATE==0x40` 비트 충돌(빈 비트 0x08 분리 권장). 관련 [[internal-damper-fan-gate]], [[herv-master-source]].
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
name: herv-master-source
|
||||||
|
description: 검증된 마스터 펌웨어 소스 위치 (HECO2 댐퍼/팬 로직 비교 기준)
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: reference
|
||||||
|
originSessionId: 5209cdb2-53ba-4f8b-9aa0-4a7911424cf1
|
||||||
|
---
|
||||||
|
|
||||||
|
`D:\Project\nuvoton\HERV_DL_MH_2nd\Program` 가 사용자가 지정한 **마스터(검증된) 펌웨어 소스**다. HECO2 펌웨어의 댐퍼/팬 동작이 의심되면 이 트리와 diff 해서 회귀를 찾는다.
|
||||||
|
|
||||||
|
차이 요약: MyMotor.c 의 댐퍼 구동 코드(Damper_Mode/Step_process/Fan_Speed_process 게이팅)는 거의 동일. 통신부가 다름 — 마스터는 `My_Uart.c`(CVnet 월패드), HECO2 는 `My_Homenet.c`(RJ2/홈넷). My_RJ2.c·pwm_duty10000.c 는 동일. My_system.c 는 HECO2 가 크게 다름(DL 각실제어/시나리오 신규).
|
||||||
|
|
||||||
|
마스터 설계 원칙: 명령경로(CVnet/RJ2)는 `Fan_Speed_Setting` 을 절대 호출 안 함 — 팬 타깃은 `Fan_Speed_process`(게이팅) + `Diffuser_Damper_process` 만 관리. 관련 [[internal-damper-fan-gate]].
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
name: internal-damper-fan-gate
|
||||||
|
description: 내부댐퍼(본체 6개)는 팬이 0까지 감속해야 모드별로 이동하는 게이팅 설계
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: 5209cdb2-53ba-4f8b-9aa0-4a7911424cf1
|
||||||
|
---
|
||||||
|
|
||||||
|
HECO2 내부댐퍼(본체 6개: EA/OA/BYPASS/SA/RA/공청, `Damper_Mode()` in MyMotor.c)는 운전모드 변경 시 **"팬 타깃0 → 팬이 실제 0 도달 → Damper_Mode 호출(댐퍼 이동) → Step_Status==0x3F 정렬 → 팬 복원"** 순서로 동작한다. 공기 흐르는 중 댐퍼를 안 움직이려는 의도. `Damper_wait_time == 5` 가 "모드전환 진행중" 전용 신호.
|
||||||
|
|
||||||
|
**Why:** 명령경로(대시보드 `My_Homenet.c` CTRL_FAN 등)가 모드전환 감속창에 끼어들어 `Fan_Speed_Setting()` 으로 팬 타깃을 다시 켜면, 팬이 0에 도달 못 해 게이트가 안 열리고 내부댐퍼가 (간헐적으로) 안 움직인다. 2026-06-18 이 버그를 수정함.
|
||||||
|
|
||||||
|
**How to apply:** 모드전환 중 팬은 명령경로와 무관하게 0으로 강제해야 한다. 수정 위치 = MyMotor.c `Fan_Speed_process()` PASS1 직후 정상운전 분기: `if(Damper_wait_time == 5){ Target_Fan1_Speed=0; Target_Fan2_Speed=0; }`. 같은 모드 풍량단수만 변경(wait==0)은 즉시 반응 유지. 마스터 [[herv-master-source]] 는 애초에 명령경로에서 Fan_Speed_Setting 을 호출하지 않아 이 문제가 없었다.
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Reference in New Issue
Block a user