Compare commits

..

7 Commits

Author SHA1 Message Date
jeon 0c39fcc646 docs: 클로드 메모리/대화이력 동기화 (260618 세션)
- doc/claude-memory/session: 이번 세션 transcript(d2979ea2) 사본 추가 (claude --resume 용)
- 메모리 .md (command-request-type-shared-race 등)는 b18d9c8 에 이미 포함

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:02:12 +09:00
jeon 3b5af64606 build: 260618 배포 실행파일 추가 (Release/260618)
- .gitignore: 루트 Release/ 배포 폴더 추적 해제 (빌드 중간물은 계속 제외)
- Release/260618/: 전 프로젝트 publish 결과 수집 (~762MB)
  - ErvDashboard.exe / DiffuserSimulator.exe / ERVSimulator.exe
  - HoodSimulator.exe / RJ2RoomConSimulator.exe
  - ErvCollector/ (웹 수집서버 폴더 통째: exe+appsettings+wwwroot)
  - Firmware/ HERV.bin/.hex/.elf (RoomCtrl_Push 반영본)
- 모두 self-contained 단일 exe (노트북에 .NET 미설치여도 실행)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:56:45 +09:00
jeon b18d9c84bf fix: 260618 룸컨 동기·쾌적조리·시나리오·그래프DB 등 수정
펌웨어(SOURCE/HECO2/User):
- My_Hood.c: 후드 전원 OFF 시 쾌적조리 토글 자동 해제 (6)
- My_RJ2.c/My_Homenet.c/My_define.h: 룸컨 전용 pending(RoomCtrl_Push) 도입
  → 전원 ON 간헐 미동작·전원 OFF 후 룸컨 옛모드(공청) 표시 해소 (11)(12)
  (공유 Command_request_type 레이스 + 716 equalize 조기클리어 + 분배기 wipe 회피)

대시보드(TestProgram/PCDashBoard):
- 쾌적조리 미선택+후드 ON 시 다른 시나리오 버튼 선택 가능 (7)
- 시나리오 버튼 항상 선택 가능 + 상호배타 로직 삭제 (8)(9)
- 데모 루틴 전체 삭제 (DemoStatus.cs 포함) (9)
- 전원 OFF 시 풍량 버튼(0 포함) 비활성 (13)
- 그래프 DB(HERV_Log.db)를 임시폴더가 아닌 exe 폴더에 저장 (14)

문서/메모리: doc/260618_*.md 정리, command-request-type-shared-race 메모리 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:41:34 +09:00
jeon 6934f09b2a docs: 대화 이력(transcript) 사본 추가 — claude --resume 용
PC 이전 후 이 대화를 이어받을 수 있도록 세션 .jsonl 스냅샷 보관.
적용법 doc/claude-memory/README.md 참고(프로필 프로젝트 폴더에 동일 파일명 복사 → claude --resume).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:54:43 +09:00
jeon ec13d42417 docs: Claude Code 프로젝트 메모리 백업(git 동기화용) 추가
PC 이전 시 메모리가 따라오도록 ~/.claude memory 사본을 doc/claude-memory 에 보관.
새 PC 적용법은 doc/claude-memory/README.md 참고.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:52:45 +09:00
jeon c5e4c48d24 fix: 260618 내부댐퍼·스마트수면·쾌적조리 동작 수정
- 내부댐퍼: 모드전환 중 명령경로(CTRL_FAN)가 팬 감속에 끼어들어 댐퍼가
  간헐적으로 안 움직이던 문제 수정 (Fan_Speed_process 게이트 보호)
- 명령경로 즉시 팬설정(My_Homenet/My_Hood) 모드변경분 주석 — 마스터 정렬
- 스마트수면: 거실(room1) CO2 무관 항상 CLOSE (사양 8p)
- 대시보드: 쾌적조리 버튼 강조=ComfortCook(연동 Enable),
  활성=후드/시나리오 무관 항상 토글 (사양 9p 3.1 독립 토글)
- doc/260618 수정 정리 + 개발사양서 갱신

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:48:41 +09:00
jeon 82caac3872 docs: Gitea 접속·협업·작업환경 가이드 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:30:38 +09:00
35 changed files with 5749 additions and 177 deletions
+5
View File
@@ -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.
+9
View File
@@ -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);
} }
+12 -4
View File
@@ -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);
} }
} }
} }
+11 -2
View File
@@ -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) : 메이크업 풍량 단수 추종 갱신
{ {
+6 -3
View File
@@ -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)
+1
View File
@@ -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
+6 -2
View File
@@ -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; }
-88
View File
@@ -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);
}
}
}
-3
View File
@@ -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();
} }
} }
+29 -67
View File
@@ -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) switch (tag)
{ {
_state.SmartSleep = tag == "SmartSleep"; case "SmartSleep": _state.SmartSleep = on; break;
_state.ComfortCook = tag == "ComfortCook"; case "ComfortCook": _state.ComfortCook = on; break;
_state.ReliefRecover = tag == "ReliefRecover"; case "ReliefRecover": _state.ReliefRecover = on; break;
}
else
{
switch (tag)
{
case "SmartSleep": _state.SmartSleep = false; break;
case "ComfortCook": _state.ComfortCook = false; break;
case "ReliefRecover": _state.ReliefRecover = false; 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 # 현재 상태 확인
```
+5
View File
@@ -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 전용 플래그로 해결
+27
View File
@@ -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]].
+14
View File
@@ -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