## 후속
- 실장비: 쾌적조리 미선택 + 후드 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 수정 요약 (핸드오프용)

> 작업 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` → 독립.

+## 변경 파일/내용

+  - `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). 사양상 쾌적조리는 독립이므로, 쾌적조리는 시나리오 전환과 무관하게 유지하길 원하면 별도 분리 필요(추후 결정).


그리고, 시나리오모드 버튼을 수정해 줘. 예를 들어 스마트수면을 선택하면 지금은 다른 버튼이 비활성화 되는데..그냥 모두 선택할 수 있게 해 줘.


수정 완료했습니다.

## 변경 요약 — 시나리오 버튼 항상 선택 가능

**수정 파일:** [MainWindow.xaml.cs:589](TestProgram/PCDashBoard/MainWindow.xaml.cs#L589) (대시보드)

```csharp
// 변경 전
SmartSleepBtn.IsEnabled    = !subActive || _state.SmartSleep;
ComfortCookBtn.IsEnabled   = true;
ReliefRecoverBtn.IsEnabled = !subActive || _state.ReliefRecover;

// 변경 후 — 세 버튼 모두 항상 클릭 가능
SmartSleepBtn.IsEnabled    = true;
ComfortCookBtn.IsEnabled   = true;
ReliefRecoverBtn.IsEnabled = true;
```

**동작:**
- 스마트수면/쾌적조리/안심회복 버튼이 **서로 비활성화되지 않고 항상 클릭 가능**.
- 스마트수면 ↔ 안심회복은 클릭하면 **전환**(펌웨어 단일 `Ext_Run_Mode`라 동시 불가).
- 운전모드/풍량 버튼은 기존대로 시나리오 동작 중 잠금 유지(시나리오가 모드/풍량을 제어하므로).

**빌드:** dotnet publish 오류 0, 단일 exe 갱신.

⚠ **한 가지 확인할 점:** 지금은 상호배타 로직(`SubMode_Click`)이 남아 있어, **쾌적조리가 켜진 상태에서 스마트수면이나 안심회복을 누르면 쾌적조리도 같이 꺼집니다.** 사양상 쾌적조리는 독립 토글이라, "쾌적조리는 시나리오와 무관하게 계속 유지"되길 원하시면 거기서 쾌적조리만 빼는 추가 수정이 필요합니다.

쾌적조리를 시나리오 전환과 분리할까요, 아니면 지금 상태(전환 시 쾌적조리도 꺼짐)로 둘까요?


상호베타 로직은 삭제해..어차피 하나만 선택할꺼야..
그리고, 프로그램 실행하니 안심회복이 선택된걸로 되어 있어..기본값은 모두 선택이 안된거야.


두 가지 모두 처리하겠습니다. 먼저 상호배타 로직을 제거하고, 안심회복이 기본 선택돼 보이는 원인을 찾겠습니다. `SetActive` 구현과 XAML 버튼 정의를 확인합니다.


164:                                <Button x:Name="SmartSleepBtn" Content="스마트수면" Style="{StaticResource FlatButton}" Click="SubMode_Click" Tag="SmartSleep"/>
165:                                <Button x:Name="ComfortCookBtn" Content="쾌적조리" Style="{StaticResource FlatButton}" Click="SubMode_Click" Tag="ComfortCook"/>
166:                                <Button x:Name="ReliefRecoverBtn" Content="안심회복" Style="{StaticResource FlatButton}" Click="SubMode_Click" Tag="ReliefRecover"/>


626:        void SetActive(Button b, bool active)
627-        {
628-            b.Background = active ? Accent : CardBgBr;
629-            b.Foreground = active ? Brushes.White : TextPrimaryBr;
630-            b.BorderBrush = active ? Accent : CardBorderBr;
631-        }
632-
633-        // ================= 로그 =================
634-        void LogStatusSnapshot()
635-        {
636-            \ 사양서 로그 항목: 운전모드/풍량/연동, 자동상태(분산/집중), 각실 부하점수·풍량, 프리셋, 히스테리시스값, 각실 댐퍼/센서/공기질/LED
637-            var sb = new StringBuilder();
638-            sb.Append($"STATUS pwr={(_state.PowerOn ? "ON" : "OFF")} mode={_state.RunMode} fan={_state.FanMode} ");


SetActive는 `_state.ReliefRecover` 값으로만 강조를 칠합니다. 초기값은 false인데 안심회복만 선택돼 보인다면 어딘가 startup에서 값이 설정되거나 데모/초기화가 작동하는 겁니다. 원인을 찾고, 상호배타 로직도 함께 보겠습니다.


1	using System.Globalization;
2	using System.IO;
3	using System.Text;
4	using System.Windows;
5	using System.Windows.Controls;
6	using System.Windows.Controls.Primitives;
7	using System.Windows.Data;
8	using System.Windows.Media;
9	using System.Windows.Shapes;
10	using System.Windows.Threading;
11	using ErvDashboard.Api;
12	using ErvDashboard.Model;
13	using ErvProtocol;
14	using Microsoft.Win32;
15
16	namespace ErvDashboard
17	{
18	    public partial class MainWindow : Window
19	    {
20	        const int BaudRate = 115200;
21
22	        readonly DashboardState _state = new();
23	        readonly IErvApi _api = new SerialErvApi();
24
25	        readonly DispatcherTimer _demoTimer;
26	        int _demoTick;
27	        bool _commActive;
28	        bool _ledDragging;   // LED 슬라이더 thumb 드래그 중 (드래그 중엔 전송 보류 → 완료 시 1회)
29	        bool _suppressLed;   // STATUS 동기 적용 중 LedDim→슬라이더 갱신으로 인한 ValueChanged 전송 차단
30	        readonly Dictionary<int, int> _lastLed = new();  // roomId→마지막 송신/수신 LED. 동일값 재전송(에코) 차단
31
32	        static readonly Brush Accent       = Brush2("#3B82F6");
33	        static readonly Brush AccentSoftBr  = Brush2("#E7F0FF");
34	        static readonly Brush CardBgBr      = Brush2("#FFFFFF");
35	        static readonly Brush CardBorderBr  = Brush2("#E3E7EF");
36	        static readonly Brush TextPrimaryBr = Brush2("#1F2733");
37	        static readonly Brush GoodBr        = Brush2("#22C55E");
38	        static readonly Brush BadBr         = Brush2("#EF4444");
39
40	        static Brush Brush2(string hex) => (Brush)new BrushConverter().ConvertFromString(hex)!;
41
42	        // 운전모드 버튼 정의
43	        static readonly (string tag, string label, RunMode mode)[] ModeDefs =
44	        {
45	            ("Vent",     "환기",   RunMode.Vent),
46	            ("Auto",     "자동",   RunMode.Auto),
47	            ("AirClean", "공청",   RunMode.AirClean),
48	            ("Bypass",   "바이패스", RunMode.Bypass),
49	        };
50
51	        readonly List<Button> _fanButtons = new();
52	        readonly List<Button> _modeButtons = new();
53	        readonly List<Button> _presetButtons = new();
54
55	        HystWindow? _hystWin;
56	        VspWindow? _vspWin;
57	        SmartSleepWindow? _sleepWin;
58	        RunMode _modeBeforeScenario = RunMode.Vent;   // 시나리오모드 첫 진입 직전 운전모드(해제 시 복귀)
59	        byte _fanBeforeScenario = 1;                  // 시나리오모드 첫 진입 직전 풍량
60	        DateTime? _sleepEndAt;                         // 스마트수면 자동 해제 예정 시각(대시보드 전용)
61	        readonly DispatcherTimer _clockTimer = new() { Interval = TimeSpan.FromSeconds(1) };
62
63	        // ---- 그래프용 시계열 샘플링 (5초 간격) → SQLite 실시간 저장(무제한 누적) ----
64	        readonly Storage.LogDb _logDb = new(System.IO.Path.Combine(AppContext.BaseDirectory, "HERV_Log.db"));
65	        readonly DispatcherTimer _sampleTimer = new() { Interval = TimeSpan.FromSeconds(5) };
66	        GraphWindow? _graphWin;
67
68	        public MainWindow()
69	        {
70	            InitializeComponent();
71	            DataContext = _state;
72
73	            RoomItems.ItemsSource = _state.Rooms;
74
75	            _api.Log += Log;
76	            _api.ConnectionChanged += b => Dispatcher.BeginInvoke(() => OnConnectionChanged(b));
77	            _api.StatusReceived += rec => Dispatcher.BeginInvoke(() =>
78	            {
79	                // STATUS 역갱신이 슬라이더를 움직여 ValueChanged→재전송(에코)되는 것을 2중으로 차단:
80	                //  1) _suppressLed : Apply 동기 실행 중 발생하는 ValueChanged 차단 (바인딩이 동기 갱신될 때)

using System.Globalization;
using System.IO;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
using System.Windows.Media;
using System.Windows.Shapes;
using System.Windows.Threading;
using ErvDashboard.Api;
using ErvDashboard.Model;
using ErvProtocol;
using Microsoft.Win32;

namespace ErvDashboard
{
    public partial class MainWindow : Window
    {
        const int BaudRate = 115200;

        readonly DashboardState _state = new();
        readonly IErvApi _api = new SerialErvApi();

        readonly DispatcherTimer _demoTimer;
        int _demoTick;
        bool _commActive;
        bool _ledDragging;   // LED 슬라이더 thumb 드래그 중 (드래그 중엔 전송 보류 → 완료 시 1회)
        bool _suppressLed;   // STATUS 동기 적용 중 LedDim→슬라이더 갱신으로 인한 ValueChanged 전송 차단
        readonly Dictionary<int, int> _lastLed = new();  // roomId→마지막 송신/수신 LED. 동일값 재전송(에코) 차단

        static readonly Brush Accent       = Brush2("#3B82F6");
        static readonly Brush AccentSoftBr  = Brush2("#E7F0FF");
        static readonly Brush CardBgBr      = Brush2("#FFFFFF");
        static readonly Brush CardBorderBr  = Brush2("#E3E7EF");
        static readonly Brush TextPrimaryBr = Brush2("#1F2733");
        static readonly Brush GoodBr        = Brush2("#22C55E");
        static readonly Brush BadBr         = Brush2("#EF4444");

        static Brush Brush2(string hex) => (Brush)new BrushConverter().ConvertFromString(hex)!;

        // 운전모드 버튼 정의
        static readonly (string tag, string label, RunMode mode)[] ModeDefs =
        {
            ("Vent",     "환기",   RunMode.Vent),
            ("Auto",     "자동",   RunMode.Auto),
            ("AirClean", "공청",   RunMode.AirClean),
            ("Bypass",   "바이패스", RunMode.Bypass),
        };

        readonly List<Button> _fanButtons = new();
        readonly List<Button> _modeButtons = new();
        readonly List<Button> _presetButtons = new();

        HystWindow? _hystWin;
        VspWindow? _vspWin;
        SmartSleepWindow? _sleepWin;
        RunMode _modeBeforeScenario = RunMode.Vent;   // 시나리오모드 첫 진입 직전 운전모드(해제 시 복귀)
        byte _fanBeforeScenario = 1;                  // 시나리오모드 첫 진입 직전 풍량
        DateTime? _sleepEndAt;                         // 스마트수면 자동 해제 예정 시각(대시보드 전용)
        readonly DispatcherTimer _clockTimer = new() { Interval = TimeSpan.FromSeconds(1) };

        // ---- 그래프용 시계열 샘플링 (5초 간격) → SQLite 실시간 저장(무제한 누적) ----
        readonly Storage.LogDb _logDb = new(System.IO.Path.Combine(AppContext.BaseDirectory, "HERV_Log.db"));
        readonly DispatcherTimer _sampleTimer = new() { Interval = TimeSpan.FromSeconds(5) };
        GraphWindow? _graphWin;

        public MainWindow()
        {
            InitializeComponent();
            DataContext = _state;

            RoomItems.ItemsSource = _state.Rooms;

            _api.Log += Log;
            _api.ConnectionChanged += b => Dispatcher.BeginInvoke(() => OnConnectionChanged(b));
            _api.StatusReceived += rec => Dispatcher.BeginInvoke(() =>
            {
                // STATUS 역갱신이 슬라이더를 움직여 ValueChanged→재전송(에코)되는 것을 2중으로 차단:
                //  1) _suppressLed : Apply 동기 실행 중 발생하는 ValueChanged 차단 (바인딩이 동기 갱신될 때)


상호베타 로직은 삭제해..어차피 하나만 선택할꺼야.. 그리고, 프로그램 실행하니 안심회복이 선택된걸로 되어 있어..기본값은 모두 선택이 안된거야.


80	                //  1) _suppressLed : Apply 동기 실행 중 발생하는 ValueChanged 차단 (바인딩이 동기 갱신될 때)
81	                //  2) _lastLed    : Apply 후(또는 비동기 바인딩 갱신 시) 같은 값 재전송 차단 (디스패처 타이밍 무관)
82	                _suppressLed = true;
83	                StatusMapper.Apply(rec, _state);
84	                _suppressLed = false;
85	                foreach (var r in _state.Rooms) _lastLed[r.RoomId] = r.LedDim;
86	                LogStatusSnapshot();
87	            });
88
89	            _state.PropertyChanged += (_, _) => Dispatcher.BeginInvoke(RefreshControls);
90	            foreach (var room in _state.Rooms)
91	                room.PropertyChanged += (_, _) => Dispatcher.BeginInvoke(RefreshControls);
92
93	            BuildFanButtons();
94	            BuildModeButtons();
95	            BuildPresetButtons();
96
97	            _demoTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(700) };
98	            _demoTimer.Tick += (_, _) => DemoTick();
99
100	            _clockTimer.Tick += (_, _) => CheckSleepSchedule();
101	            _clockTimer.Start();
102
103	            _sampleTimer.Tick += (_, _) => TakeSample();
104	            _sampleTimer.Start();
105
106	            RefreshPortsList();
107	            RefreshControls();
108	        }
109
110	        // ================= 버튼 목록 등록 (UI 는 MainWindow.xaml 에 정의) =================
111	        // 강조/활성 갱신(RefreshControls)을 위해 XAML 버튼을 리스트로 모은다. 순서는 ModeDefs 와 동일.
112	        void BuildFanButtons()
113	        {
114	            _fanButtons.AddRange(new[] { Fan0, Fan1, Fan2, Fan3, Fan4 });
115	        }
116
117	        void BuildModeButtons()
118	        {
119	            _modeButtons.AddRange(new[] { ModeVent, ModeAuto, ModeAir, ModeBypass });
120	        }
121
122	        // 자동모드 프리셋(ECO/NORMAL/TURBO). 순서는 PresetPanel 의 버튼 순서와 동일.
123	        void BuildPresetButtons()
124	        {
125	            _presetButtons.AddRange(new[] { PresetEco, PresetNormal, PresetTurbo });
126	        }
127
128	        // ================= 설정 팝업 =================
129	        void OpenHyst_Click(object sender, RoutedEventArgs e)
130	        {
131	            if (_hystWin == null) { _hystWin = new HystWindow(this, _state) { Owner = this }; _hystWin.Closed += (_, _) => _hystWin = null; _hystWin.Show(); }
132	            else _hystWin.Activate();
133	        }
134
135	        void OpenVsp_Click(object sender, RoutedEventArgs e)
136	        {
137	            if (_vspWin == null) { _vspWin = new VspWindow(this, _state) { Owner = this }; _vspWin.Closed += (_, _) => _vspWin = null; _vspWin.Show(); }
138	            else _vspWin.Activate();
139	        }
140
141	        void OpenSmartSleep_Click(object sender, RoutedEventArgs e)
142	        {
143	            if (_sleepWin == null) { _sleepWin = new SmartSleepWindow(this, _state) { Owner = this }; _sleepWin.Closed += (_, _) => _sleepWin = null; _sleepWin.Show(); }
144	            else _sleepWin.Activate();
145	        }
146
147	        // 스마트수면 시간설정 적용(팝업 호출) : 설정한 종료 시각에 자동 해제하도록 예약(대시보드 전용).
148	        public void ApplySmartSleep()
149	        {
150	            _sleepEndAt = NextOccurrence(_state.SleepEndMin);
151	            Log($"[스마트수면] 시간설정 {_state.SleepSummary} → 종료 {_sleepEndAt:MM-dd HH:mm} 자동 해제 예약");
152	        }
153
154	        static DateTime NextOccurrence(int min)
155	        {
156	            var now = DateTime.Now;
157	            var t = now.Date.AddMinutes(((min % 1440) + 1440) % 1440);
158	            return t > now ? t : t.AddDays(1);
159	        }
160
161	        // 1초 주기 : 스마트수면 종료 시각 도달 시 자동 해제 + 이전 운전모드 복귀(기존 명령만 사용)
162	        void CheckSleepSchedule()
163	        {
164	            if (!_state.SmartSleep || _sleepEndAt is not { } endAt || DateTime.Now < endAt) return;
165	            _sleepEndAt = null;
166	            if (!_demoTimer.IsEnabled && CanSend()) _api.SetSubMode(SubModeType.SmartSleep, false);
167	            ApplySubModeLocal("SmartSleep", false);
168	            Log("[스마트수면] 종료 시각 도달 → 자동 해제");
169	            if (NoScenarioActive()) RestorePreviousMode();

                //  1) _suppressLed : Apply 동기 실행 중 발생하는 ValueChanged 차단 (바인딩이 동기 갱신될 때)
                //  2) _lastLed    : Apply 후(또는 비동기 바인딩 갱신 시) 같은 값 재전송 차단 (디스패처 타이밍 무관)
                _suppressLed = true;
                StatusMapper.Apply(rec, _state);
                _suppressLed = false;
                foreach (var r in _state.Rooms) _lastLed[r.RoomId] = r.LedDim;
                LogStatusSnapshot();
            });

            _state.PropertyChanged += (_, _) => Dispatcher.BeginInvoke(RefreshControls);
            foreach (var room in _state.Rooms)
                room.PropertyChanged += (_, _) => Dispatcher.BeginInvoke(RefreshControls);

            BuildFanButtons();
            BuildModeButtons();
            BuildPresetButtons();

            _demoTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(700) };
            _demoTimer.Tick += (_, _) => DemoTick();

            _clockTimer.Tick += (_, _) => CheckSleepSchedule();
            _clockTimer.Start();

            _sampleTimer.Tick += (_, _) => TakeSample();
            _sampleTimer.Start();

            RefreshPortsList();
            RefreshControls();
        }

        // ================= 버튼 목록 등록 (UI 는 MainWindow.xaml 에 정의) =================
        // 강조/활성 갱신(RefreshControls)을 위해 XAML 버튼을 리스트로 모은다. 순서는 ModeDefs 와 동일.
        void BuildFanButtons()
        {
            _fanButtons.AddRange(new[] { Fan0, Fan1, Fan2, Fan3, Fan4 });
        }

        void BuildModeButtons()
        {
            _modeButtons.AddRange(new[] { ModeVent, ModeAuto, ModeAir, ModeBypass });
        }

        // 자동모드 프리셋(ECO/NORMAL/TURBO). 순서는 PresetPanel 의 버튼 순서와 동일.
        void BuildPresetButtons()
        {
            _presetButtons.AddRange(new[] { PresetEco, PresetNormal, PresetTurbo });
        }

        // ================= 설정 팝업 =================
        void OpenHyst_Click(object sender, RoutedEventArgs e)
        {
            if (_hystWin == null) { _hystWin = new HystWindow(this, _state) { Owner = this }; _hystWin.Closed += (_, _) => _hystWin = null; _hystWin.Show(); }
            else _hystWin.Activate();
        }

        void OpenVsp_Click(object sender, RoutedEventArgs e)
        {
            if (_vspWin == null) { _vspWin = new VspWindow(this, _state) { Owner = this }; _vspWin.Closed += (_, _) => _vspWin = null; _vspWin.Show(); }
            else _vspWin.Activate();
        }

        void OpenSmartSleep_Click(object sender, RoutedEventArgs e)
        {
            if (_sleepWin == null) { _sleepWin = new SmartSleepWindow(this, _state) { Owner = this }; _sleepWin.Closed += (_, _) => _sleepWin = null; _sleepWin.Show(); }
            else _sleepWin.Activate();
        }

        // 스마트수면 시간설정 적용(팝업 호출) : 설정한 종료 시각에 자동 해제하도록 예약(대시보드 전용).
        public void ApplySmartSleep()
        {
            _sleepEndAt = NextOccurrence(_state.SleepEndMin);
            Log($"[스마트수면] 시간설정 {_state.SleepSummary} → 종료 {_sleepEndAt:MM-dd HH:mm} 자동 해제 예약");
        }

        static DateTime NextOccurrence(int min)
        {
            var now = DateTime.Now;
            var t = now.Date.AddMinutes(((min % 1440) + 1440) % 1440);
            return t > now ? t : t.AddDays(1);
        }

        // 1초 주기 : 스마트수면 종료 시각 도달 시 자동 해제 + 이전 운전모드 복귀(기존 명령만 사용)
        void CheckSleepSchedule()
        {
            if (!_state.SmartSleep || _sleepEndAt is not { } endAt || DateTime.Now < endAt) return;
            _sleepEndAt = null;
            if (!_demoTimer.IsEnabled && CanSend()) _api.SetSubMode(SubModeType.SmartSleep, false);
            ApplySubModeLocal("SmartSleep", false);
            Log("[스마트수면] 종료 시각 도달 → 자동 해제");
            if (NoScenarioActive()) RestorePreviousMode();


데모 타이머는 startup에서 시작 안 되고, 생성자 끝에서 `RefreshControls()`가 호출됩니다. `_state`는 전부 false라 안심회복도 비활성이어야 정상입니다. XAML 쪽 기본 스타일이나 버튼 정의를 보겠습니다.


155	                            <StackPanel x:Name="PresetPanel" Orientation="Horizontal" Margin="0,0,0,12">
156	                                <Button x:Name="PresetEco"    Content="절전 (ECO)"    Tag="Eco"    Width="116" Style="{StaticResource FlatButton}" Click="Preset_Click"/>
157	                                <Button x:Name="PresetNormal" Content="표준 (NORMAL)" Tag="Normal" Width="116" Style="{StaticResource FlatButton}" Click="Preset_Click"/>
158	                                <Button x:Name="PresetTurbo"  Content="쾌속 (TURBO)"  Tag="Turbo"  Width="116" Style="{StaticResource FlatButton}" Click="Preset_Click"/>
159	                            </StackPanel>
160
161	                            <!-- 시나리오모드 + 후드 연동 + 설정 (한 줄 배치) -->
162	                            <TextBlock Text="시나리오모드" Style="{StaticResource FieldLabel}" Margin="0,0,0,4"/>
163	                            <StackPanel Orientation="Horizontal" Margin="0,0,0,12">
164	                                <Button x:Name="SmartSleepBtn" Content="스마트수면" Style="{StaticResource FlatButton}" Click="SubMode_Click" Tag="SmartSleep"/>
165	                                <Button x:Name="ComfortCookBtn" Content="쾌적조리" Style="{StaticResource FlatButton}" Click="SubMode_Click" Tag="ComfortCook"/>
166	                                <Button x:Name="ReliefRecoverBtn" Content="안심회복" Style="{StaticResource FlatButton}" Click="SubMode_Click" Tag="ReliefRecover"/>
167
168	                                <!-- 후드 연동 -->
169	                                <Border Width="1" Background="{StaticResource CardBorder}" Margin="14,2,14,2"/>

                            <StackPanel x:Name="PresetPanel" Orientation="Horizontal" Margin="0,0,0,12">
                                <Button x:Name="PresetEco"    Content="절전 (ECO)"    Tag="Eco"    Width="116" Style="{StaticResource FlatButton}" Click="Preset_Click"/>
                                <Button x:Name="PresetNormal" Content="표준 (NORMAL)" Tag="Normal" Width="116" Style="{StaticResource FlatButton}" Click="Preset_Click"/>
                                <Button x:Name="PresetTurbo"  Content="쾌속 (TURBO)"  Tag="Turbo"  Width="116" Style="{StaticResource FlatButton}" Click="Preset_Click"/>
                            </StackPanel>

                            <!-- 시나리오모드 + 후드 연동 + 설정 (한 줄 배치) -->
                            <TextBlock Text="시나리오모드" Style="{StaticResource FieldLabel}" Margin="0,0,0,4"/>
                            <StackPanel Orientation="Horizontal" Margin="0,0,0,12">
                                <Button x:Name="SmartSleepBtn" Content="스마트수면" Style="{StaticResource FlatButton}" Click="SubMode_Click" Tag="SmartSleep"/>
                                <Button x:Name="ComfortCookBtn" Content="쾌적조리" Style="{StaticResource FlatButton}" Click="SubMode_Click" Tag="ComfortCook"/>
                                <Button x:Name="ReliefRecoverBtn" Content="안심회복" Style="{StaticResource FlatButton}" Click="SubMode_Click" Tag="ReliefRecover"/>

                                <!-- 후드 연동 -->
                                <Border Width="1" Background="{StaticResource CardBorder}" Margin="14,2,14,2"/>


TestProgram\PCDashBoard\GraphWindow.xaml:20:                <Button Content="📂 불러오기" Style="{StaticResource FlatButton}" Click="Load_Click" Padding="10,5" Margin="6,0,0,0"/>
TestProgram\PCDashBoard\GraphWindow.xaml-21-                <TextBlock x:Name="CountText" Text="" VerticalAlignment="Center" Margin="12,0,0,0" Foreground="{StaticResource TextSecondary}"/>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\GraphWindow.xaml-22-            </StackPanel>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\GraphWindow.xaml-23-            <StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
TestProgram\PCDashBoard\GraphWindow.xaml:24:                <Button Content="🔄 새로고침" Style="{StaticResource FlatButton}" Click="Refresh_Click" Padding="10,5" Margin="0,0,6,0"/>
TestProgram\PCDashBoard\GraphWindow.xaml:25:                <Button Content="📊 엑셀 저장" Style="{StaticResource FlatButton}" Click="Excel_Click" Padding="10,5" Margin="0,0,6,0"/>
TestProgram\PCDashBoard\GraphWindow.xaml:26:                <Button Content="닫기" Style="{StaticResource FlatButton}" Click="Close_Click" Padding="10,5"/>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\GraphWindow.xaml-27-            </StackPanel>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\GraphWindow.xaml-28-        </Grid>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\GraphWindow.xaml-29-
TestProgram\PCDashBoard\GraphWindow.xaml-30-        <!-- 본문 : 좌 체크박스 / 우 차트 -->
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\GraphWindow.xaml-31-        <Grid Grid.Row="1">
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\GraphWindow.xaml-32-            <Grid.ColumnDefinitions>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\GraphWindow.xaml-33-                <ColumnDefinition Width="150"/>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\GraphWindow.xaml-34-                <ColumnDefinition Width="*"/>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\GraphWindow.xaml-35-            </Grid.ColumnDefinitions>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\GraphWindow.xaml-36-            <Border Grid.Column="0" Background="{StaticResource CardBg}" BorderBrush="{StaticResource CardBorder}" BorderThickness="1" CornerRadius="8" Margin="0,0,8,0">
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\GraphWindow.xaml-37-                <ScrollViewer VerticalScrollBarVisibility="Auto" Padding="8">
TestProgram\PCDashBoard\GraphWindow.xaml-38-                    <StackPanel x:Name="CheckPanel"/>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\GraphWindow.xaml-39-                </ScrollViewer>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\GraphWindow.xaml-40-            </Border>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\GraphWindow.xaml-41-            <Border Grid.Column="1" Background="{StaticResource CardBg}" BorderBrush="{StaticResource CardBorder}" BorderThickness="1" CornerRadius="8" Padding="6">
TestProgram\PCDashBoard\GraphWindow.xaml-42-                <lvc:CartesianChart x:Name="Chart" LegendPosition="Bottom"/>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\GraphWindow.xaml-43-            </Border>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\GraphWindow.xaml-44-        </Grid>
--
TestProgram\PCDashBoard\App.xaml:66:        <Style x:Key="FlatButton" TargetType="Button">
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\App.xaml-67-            <Setter Property="Background" Value="{StaticResource CardBg}"/>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\App.xaml-68-            <Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\App.xaml-69-            <Setter Property="BorderBrush" Value="{StaticResource CardBorder}"/>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\App.xaml-70-            <Setter Property="BorderThickness" Value="1"/>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\App.xaml-71-            <Setter Property="Padding" Value="14,7"/>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\App.xaml-72-            <Setter Property="Margin" Value="3"/>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\App.xaml-73-            <Setter Property="FontSize" Value="13"/>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\App.xaml-74-            <Setter Property="Cursor" Value="Hand"/>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\App.xaml-75-            <Setter Property="Template">
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\App.xaml-76-                <Setter.Value>
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\App.xaml-77-                    <ControlTemplate TargetType="Button">
TestProgram\PCDashBoard\App.xaml-78-                        <Border x:Name="bd" Background="{TemplateBinding Background}"
d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\App.xaml-79-                                BorderBrush="{TemplateBinding BorderBrush}"

[Showing results with pagination = limit: 40]


FlatButton 기본 배경은 CardBg(흰색=비활성)이라 XAML 기본값 문제도 아닙니다. `RefreshControls` 메서드 전체를 처음부터 봐서 안심회복 강조가 어디서 켜지는지(혹은 조기 return 등) 확인하겠습니다.


541:        void RefreshControls()

541:        void RefreshControls()


데모 루틴도 모두 삭제해


데모 루틴 삭제 요청도 접수했습니다. 데모 코드가 안심회복 기본선택 문제와 연관됐을 수 있으니 함께 봅니다. `RefreshControls`와 데모 관련 코드를 모두 확인하겠습니다.


541	        void RefreshControls()
542	        {
543	            // 전원
544	            SetToggle(PowerBtn, _state.PowerOn, "ON", "OFF");
545	            // ERV 리셋
546	            SetToggle(ResetBtn, _state.Reset, "ON", "OFF");
547	            // 연동후드 + 통신연결 상태 텍스트
548	            SetToggle(HoodBtn, _state.Hood, "ON", "OFF");
549	            if (_state.Hood)
550	            {
551	                HoodConnText.Text = _state.HoodConnected ? "후드 연결" : "후드 연결 안됨";
552	                HoodConnText.Foreground = _state.HoodConnected ? GoodBr : BadBr;
553	            }
554	            else
555	            {
556	                HoodConnText.Text = "";
557	            }
558
559	            // 운전모드 강조
560	            for (int i = 0; i < _modeButtons.Count; i++)

        void RefreshControls()
        {
            // 전원
            SetToggle(PowerBtn, _state.PowerOn, "ON", "OFF");
            // ERV 리셋
            SetToggle(ResetBtn, _state.Reset, "ON", "OFF");
            // 연동후드 + 통신연결 상태 텍스트
            SetToggle(HoodBtn, _state.Hood, "ON", "OFF");
            if (_state.Hood)
            {
                HoodConnText.Text = _state.HoodConnected ? "후드 연결" : "후드 연결 안됨";
                HoodConnText.Foreground = _state.HoodConnected ? GoodBr : BadBr;
            }
            else
            {
                HoodConnText.Text = "";
            }

            // 운전모드 강조
            for (int i = 0; i < _modeButtons.Count; i++)


TestProgram\PCDashBoard\Api\IErvApi.cs:46:        void InjectDemoStatus(int tick);
TestProgram\PCDashBoard\Api\SerialErvApi.cs:104:        public void InjectDemoStatus(int tick)
TestProgram\PCDashBoard\Api\SerialErvApi.cs:106:            var frame = CtrlFrame.Build(StatusDecoder.STATUS, DemoStatus.BuildPayload(tick));
TestProgram\PCDashBoard\MainWindow.xaml.cs:25:        readonly DispatcherTimer _demoTimer;
TestProgram\PCDashBoard\MainWindow.xaml.cs:26:        int _demoTick;
TestProgram\PCDashBoard\MainWindow.xaml.cs:97:            _demoTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(700) };
TestProgram\PCDashBoard\MainWindow.xaml.cs:98:            _demoTimer.Tick += (_, _) => DemoTick();
TestProgram\PCDashBoard\MainWindow.xaml.cs:166:            if (!_demoTimer.IsEnabled && CanSend()) _api.SetSubMode(SubModeType.SmartSleep, false);
TestProgram\PCDashBoard\MainWindow.xaml.cs:222:            if (_demoTimer.IsEnabled) { _state.HystPreset = preset; return; }
TestProgram\PCDashBoard\MainWindow.xaml.cs:240:            if (_demoTimer.IsEnabled) return;   // 데모: 상태만 갱신됨
TestProgram\PCDashBoard\MainWindow.xaml.cs:324:            if (_demoTimer.IsEnabled) return false;      // 데모 모드에선 송신 안 함
TestProgram\PCDashBoard\MainWindow.xaml.cs:337:            if (_demoTimer.IsEnabled) { _state.PowerOn = next; return; }
TestProgram\PCDashBoard\MainWindow.xaml.cs:349:            if (_demoTimer.IsEnabled) { _state.RunMode = def.mode; if (def.mode != RunMode.Auto) _state.FanMode = 1; return; }
TestProgram\PCDashBoard\MainWindow.xaml.cs:372:            if (_demoTimer.IsEnabled) { _state.FanMode = (byte)speed; return; }
TestProgram\PCDashBoard\MainWindow.xaml.cs:392:            if (_demoTimer.IsEnabled)
TestProgram\PCDashBoard\MainWindow.xaml.cs:447:            if (_demoTimer.IsEnabled) { _state.Hood = next; return; }
TestProgram\PCDashBoard\MainWindow.xaml.cs:457:            if (_demoTimer.IsEnabled) { _state.Reset = next; return; }
TestProgram\PCDashBoard\MainWindow.xaml.cs:477:            if (_demoTimer.IsEnabled)
TestProgram\PCDashBoard\MainWindow.xaml.cs:514:            if (_demoTimer.IsEnabled) return;
TestProgram\PCDashBoard\MainWindow.xaml.cs:528:            if (_demoTimer.IsEnabled) { _state.ReserveRemainSec = hours * 3600; return; }
TestProgram\PCDashBoard\MainWindow.xaml.cs:535:        void DemoTick()
TestProgram\PCDashBoard\MainWindow.xaml.cs:537:            _api.InjectDemoStatus(_demoTick++);
TestProgram\PCDashBoard\MainWindow.xaml.cs:683:            _demoTimer.Stop();


데모는 `_demoTimer.IsEnabled`로 게이트되는데 startup에서 Start가 안 보입니다. 데모 시작 지점과 `DemoStatus` 페이로드(안심회복 기본선택과 연관 가능)를 확인하겠습니다.


TestProgram\PCDashBoard\MainWindow.xaml.cs:240:            if (_demoTimer.IsEnabled) return;   // 데모: 상태만 갱신됨
TestProgram\PCDashBoard\MainWindow.xaml.cs:324:            if (_demoTimer.IsEnabled) return false;      // 데모 모드에선 송신 안 함
TestProgram\PCDashBoard\MainWindow.xaml.cs:534:        // ================= 데모 모드 (버튼 제거됨 — 내부 합성 STATUS 경로는 비활성 상태로 유지) =================
TestProgram\PCDashBoard\Api\SerialErvApi.cs:103:        // ================= 데모/테스트 =================
TestProgram\PCDashBoard\Api\IErvApi.cs:45:        // ---- 데모/테스트 (합성 STATUS를 수신 경로로 주입) ----


670	                FileName = $"ERV_Log_{DateTime.Now:yyyyMMdd_HHmmss}.txt",
671	            };
672	            if (dlg.ShowDialog() != true) return;
673	            try
674	            {
675	                File.WriteAllText(dlg.FileName, LogList.Text, Encoding.UTF8);
676	                Log($"로그 저장 완료: {dlg.FileName}");
677	            }
678	            catch (Exception ex) { Log($"로그 저장 실패: {ex.Message}"); }
679	        }
680
681	        protected override void OnClosed(EventArgs e)
682	        {
683	            _demoTimer.Stop();
684	            _api.Dispose();
685	            _logDb.Dispose();
686	            base.OnClosed(e);
687	        }
688	    }
689
690	    // ================= 컨버터 =================
691	    public class AirQualityToBrushConverter : IValueConverter
692	    {
693	        static readonly Brush Red    = (Brush)new BrushConverter().ConvertFromString("#EF4444")!;
694	        static readonly Brush Orange = (Brush)new BrushConverter().ConvertFromString("#F59E0B")!;
695	        static readonly Brush Green  = (Brush)new BrushConverter().ConvertFromString("#22C55E")!;
696	        static readonly Brush Blue   = (Brush)new BrushConverter().ConvertFromString("#3B82F6")!;
697	        static readonly Brush Gray   = (Brush)new BrushConverter().ConvertFromString("#CBD2DE")!;
698
699	        public object Convert(object value, Type t, object p, CultureInfo c) =>

                FileName = $"ERV_Log_{DateTime.Now:yyyyMMdd_HHmmss}.txt",
            };
            if (dlg.ShowDialog() != true) return;
            try
            {
                File.WriteAllText(dlg.FileName, LogList.Text, Encoding.UTF8);
                Log($"로그 저장 완료: {dlg.FileName}");
            }
            catch (Exception ex) { Log($"로그 저장 실패: {ex.Message}"); }
        }

        protected override void OnClosed(EventArgs e)
        {
            _demoTimer.Stop();
            _api.Dispose();
            _logDb.Dispose();
            base.OnClosed(e);
        }
    }

    // ================= 컨버터 =================
    public class AirQualityToBrushConverter : IValueConverter
    {
        static readonly Brush Red    = (Brush)new BrushConverter().ConvertFromString("#EF4444")!;
        static readonly Brush Orange = (Brush)new BrushConverter().ConvertFromString("#F59E0B")!;
        static readonly Brush Green  = (Brush)new BrushConverter().ConvertFromString("#22C55E")!;
        static readonly Brush Blue   = (Brush)new BrushConverter().ConvertFromString("#3B82F6")!;
        static readonly Brush Gray   = (Brush)new BrushConverter().ConvertFromString("#CBD2DE")!;

        public object Convert(object value, Type t, object p, CultureInfo c) =>


데모는 이미 버튼이 제거돼 있고 `.Start()`가 없어 **실행되지 않는 죽은 코드**입니다(IsEnabled 항상 false). 따라서 안심회복 기본선택과는 무관 — 데모 삭제는 순수 정리 작업입니다.

안심회복 기본선택 원인을 찾기 위해 SubMode 비트 매핑(펌웨어 byte4 ↔ 대시보드 SubModeBits)을 대조하겠습니다.


1	namespace ErvProtocol
2	{
3	    // 운전모드 (PC_ERV_Protocol.md 3.1)
4	    public enum RunMode : byte
5	    {
6	        Off      = 0x00,
7	        Vent     = 0x01,  // 환기
8	        Auto     = 0x02,  // 자동
9	        AirClean = 0x03,  // 공청
10	        Bypass   = 0x04,  // 바이패스
11	    }
12
13	    // 자동운전 상태 (3.4)
14	    public enum AutoState : byte
15	    {
16	        Distribute = 0x00,  // 분산
17	        Focus      = 0x01,  // 집중
18	    }
19
20	    // 히스테리시스 프리셋
21	    public enum HystPreset : byte
22	    {
23	        Eco    = 0x00,
24	        Normal = 0x01,
25	        Turbo  = 0x02,
26	    }
27
28	    // 공기질 상태 (3.3) - 판단 기준은 협의 예정
29	    public enum AirQuality : byte
30	    {
31	        VeryBad = 0x01,  // 매우나쁨 - 빨강
32	        Bad     = 0x02,  // 나쁨    - 주황
33	        Normal  = 0x03,  // 보통    - 초록
34	        Good    = 0x04,  // 좋음    - 파랑
35	    }
36
37	    // 부가모드 type (CTRL_SUBMODE)
38	    public enum SubModeType : byte
39	    {
40	        SmartSleep    = 0x01,  // 스마트수면
41	        ComfortCook   = 0x02,  // 쾌적조리
42	        ReliefRecover = 0x03,  // 안심회복
43	    }
44
45	    public static class SubModeBits
46	    {
47	        public const byte SmartSleep    = 0x01;
48	        public const byte ComfortCook   = 0x02;
49	        public const byte ReliefRecover = 0x04;
50	    }
51	}
52

namespace ErvProtocol
{
    // 운전모드 (PC_ERV_Protocol.md 3.1)
    public enum RunMode : byte
    {
        Off      = 0x00,
        Vent     = 0x01,  // 환기
        Auto     = 0x02,  // 자동
        AirClean = 0x03,  // 공청
        Bypass   = 0x04,  // 바이패스
    }

    // 자동운전 상태 (3.4)
    public enum AutoState : byte
    {
        Distribute = 0x00,  // 분산
        Focus      = 0x01,  // 집중
    }

    // 히스테리시스 프리셋
    public enum HystPreset : byte
    {
        Eco    = 0x00,
        Normal = 0x01,
        Turbo  = 0x02,
    }

    // 공기질 상태 (3.3) - 판단 기준은 협의 예정
    public enum AirQuality : byte
    {
        VeryBad = 0x01,  // 매우나쁨 - 빨강
        Bad     = 0x02,  // 나쁨    - 주황
        Normal  = 0x03,  // 보통    - 초록
        Good    = 0x04,  // 좋음    - 파랑
    }

    // 부가모드 type (CTRL_SUBMODE)
    public enum SubModeType : byte
    {
        SmartSleep    = 0x01,  // 스마트수면
        ComfortCook   = 0x02,  // 쾌적조리
        ReliefRecover = 0x03,  // 안심회복
    }

    public static class SubModeBits
    {
        public const byte SmartSleep    = 0x01;
        public const byte ComfortCook   = 0x02;
        public const byte ReliefRecover = 0x04;
    }
}


118	    uint16_t i;
119
120	    for(i = 0; i < HN_STATUS_LEN; i++) p[i] = 0;
121
122	    /* --- 글로벌 0~16 --- */
123	    p[0] = Power_On;
124	    p[1] = hn_runmode_code();
125	    p[2] = Auto_Concentrate;                 /* 0 분산 / 1 집중 */
126	    p[3] = Fan_Mode;
127	    p[4] = (uint8_t)(((Ext_Run_Mode == 4) ? 0x01 : 0)    /* 스마트수면 */
128	                   | (Hood_YeunDong_Enable ? 0x02 : 0)   /* 쾌적조리   */
129	                   | ((Ext_Run_Mode == 1) ? 0x04 : 0));  /* 안심회복   */
130	    /* bit0 연동Enable / bit1 연동운전중 / bit2 후드 통신연결(폴 응답 생존) */
131	    p[5] = (uint8_t)((Hood_YeunDong_Enable ? 0x01 : 0) | ((Hood_Status != 0) ? 0x02 : 0) | ((Hood_Conn_Timeout != 0) ? 0x04 : 0));
132	    p[6] = Hyst_Preset;
133	    hn_w16(&p[7],  Pm25_Db[Hyst_Preset]);
134	    hn_w16(&p[9],  Pm10_Db[Hyst_Preset]);
135	    hn_w16(&p[11], Voc_Db[Hyst_Preset]);

    uint16_t i;

    for(i = 0; i < HN_STATUS_LEN; i++) p[i] = 0;

    /* --- 글로벌 0~16 --- */
    p[0] = Power_On;
    p[1] = hn_runmode_code();
    p[2] = Auto_Concentrate;                 /* 0 분산 / 1 집중 */
    p[3] = Fan_Mode;
    p[4] = (uint8_t)(((Ext_Run_Mode == 4) ? 0x01 : 0)    /* 스마트수면 */
                   | (Hood_YeunDong_Enable ? 0x02 : 0)   /* 쾌적조리   */
                   | ((Ext_Run_Mode == 1) ? 0x04 : 0));  /* 안심회복   */
    /* bit0 연동Enable / bit1 연동운전중 / bit2 후드 통신연결(폴 응답 생존) */
    p[5] = (uint8_t)((Hood_YeunDong_Enable ? 0x01 : 0) | ((Hood_Status != 0) ? 0x02 : 0) | ((Hood_Conn_Timeout != 0) ? 0x04 : 0));
    p[6] = Hyst_Preset;
    hn_w16(&p[7],  Pm25_Db[Hyst_Preset]);
    hn_w16(&p[9],  Pm10_Db[Hyst_Preset]);
    hn_w16(&p[11], Voc_Db[Hyst_Preset]);


비트 매핑은 펌웨어 ↔ 대시보드 일치합니다 (안심회복 = byte4 bit2 = `Ext_Run_Mode==1`). 즉 대시보드가 안심회복을 켜 보이는 건 **장치가 `Ext_Run_Mode==1`을 보고**하기 때문입니다. 펌웨어에서 `Ext_Run_Mode` 기본값/초기화를 확인하겠습니다.


SOURCE\HECO2\User\My_Homenet.c:127:    p[4] = (uint8_t)(((Ext_Run_Mode == 4) ? 0x01 : 0)    /* 스마트수면 */
SOURCE\HECO2\User\My_Homenet.c:129:                   | ((Ext_Run_Mode == 1) ? 0x04 : 0));  /* 안심회복   */
SOURCE\HECO2\User\My_Homenet.c:317:                if(pl[0] == 1)      Ext_Run_Mode = pl[1] ? 4 : 0;
SOURCE\HECO2\User\My_Homenet.c:319:                else if(pl[0] == 3){ Ext_Run_Mode = pl[1] ? 1 : 0; Ext_Select_Room = 2; }
SOURCE\HECO2\User\My_bunbaegi.c:984:uint8_t Ext_Run_Mode = 0;
SOURCE\HECO2\User\My_system.c:680:uint8_t Pre_Ext_Run_Mode = 0;
SOURCE\HECO2\User\My_system.c:681:uint16_t Ext_Run_Mode_Off_Delay = 0;
SOURCE\HECO2\User\My_system.c:802:        Ext_Run_Mode_Off_Delay = 0;
SOURCE\HECO2\User\My_system.c:806:    else if((Ext_Run_Mode == 1)&&(Pre_Ext_Run_Mode != 1)) //안심회복 모드
SOURCE\HECO2\User\My_system.c:842:        Pre_Ext_Run_Mode = Ext_Run_Mode ;
SOURCE\HECO2\User\My_system.c:846:    else if((Ext_Run_Mode == 2)&&(Pre_Ext_Run_Mode != 2)) //쾌적조리 En
SOURCE\HECO2\User\My_system.c:849:        Pre_Ext_Run_Mode = Ext_Run_Mode ;
SOURCE\HECO2\User\My_system.c:852:    else if((Ext_Run_Mode == 3)&&(Pre_Ext_Run_Mode != 3)) //집중청정 모드
SOURCE\HECO2\User\My_system.c:889:        Pre_Ext_Run_Mode = Ext_Run_Mode ;
SOURCE\HECO2\User\My_system.c:893:    else if(Ext_Run_Mode == 4) //스마트 수면 모드 (사양서 8p) : 환기 수동·1단 고정, 실별 CO2 기준 댐퍼 개폐
SOURCE\HECO2\User\My_system.c:895:        if(Pre_Ext_Run_Mode != 4)   /* 진입 1회 : 모드/풍량 + 초기상태(거실 CLOSE, 침실1~3 OPEN) */
SOURCE\HECO2\User\My_system.c:911:            Pre_Ext_Run_Mode = Ext_Run_Mode;
SOURCE\HECO2\User\My_system.c:934:        Ext_Run_Mode_Off_Delay = 0;
SOURCE\HECO2\User\My_system.c:938:    else if((Ext_Run_Mode == 0)&&((Pre_Ext_Run_Mode == 1)||(Pre_Ext_Run_Mode == 3)||(Pre_Ext_Run_Mode == 4)))
SOURCE\HECO2\User\My_system.c:947:            Pre_Ext_Run_Mode = Ext_Run_Mode ;
SOURCE\HECO2\User\My_system.c:950:        Ext_Run_Mode_Off_Delay = 0;
SOURCE\HECO2\User\My_system.c:952:    else if((Ext_Run_Mode == 0)&&(Pre_Ext_Run_Mode == 2))// 쾌적조리(후드연동) 토글 OFF -> 연동 없음(사양 260613 9p 3.1)
SOURCE\HECO2\User\My_system.c:964:        Pre_Ext_Run_Mode = Ext_Run_Mode ;
SOURCE\HECO2\User\My_system.c:968:    else if((Ext_Run_Mode != 0)&&(Ext_Run_Mode != 2))
SOURCE\HECO2\User\My_system.c:970:        Pre_Ext_Run_Mode = Ext_Run_Mode ;
SOURCE\HECO2\User\My_system.c:1153:    if(Ext_Run_Mode == 4)
SOURCE\HECO2\User\My_RJ2.c:440:                        Ext_Run_Mode = 0;
SOURCE\HECO2\User\My_RJ2.c:441:                        Ext_Run_Mode_Off_Delay = 0;
SOURCE\HECO2\User\My_RJ2.c:442:                        Pre_Ext_Run_Mode = Ext_Run_Mode ;


`Ext_Run_Mode` 기본값은 0(My_bunbaegi.c:984)이라 펌웨어가 안심회복으로 시작하진 않습니다. 데모도 안 돕니다. 그렇다면 대시보드 측 시각 문제일 수 있으니 FlatButton 템플릿 트리거(포커스/디폴트 강조)를 확인하겠습니다.


66	        <Style x:Key="FlatButton" TargetType="Button">
67	            <Setter Property="Background" Value="{StaticResource CardBg}"/>
68	            <Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
69	            <Setter Property="BorderBrush" Value="{StaticResource CardBorder}"/>
70	            <Setter Property="BorderThickness" Value="1"/>
71	            <Setter Property="Padding" Value="14,7"/>
72	            <Setter Property="Margin" Value="3"/>
73	            <Setter Property="FontSize" Value="13"/>
74	            <Setter Property="Cursor" Value="Hand"/>
75	            <Setter Property="Template">
76	                <Setter.Value>
77	                    <ControlTemplate TargetType="Button">
78	                        <Border x:Name="bd" Background="{TemplateBinding Background}"
79	                                BorderBrush="{TemplateBinding BorderBrush}"
80	                                BorderThickness="{TemplateBinding BorderThickness}"
81	                                CornerRadius="8">
82	                            <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
83	                        </Border>
84	                        <ControlTemplate.Triggers>
85	                            <Trigger Property="IsMouseOver" Value="True">
86	                                <Setter TargetName="bd" Property="Background" Value="{StaticResource AccentSoft}"/>
87	                            </Trigger>
88	                            <Trigger Property="IsEnabled" Value="False">
89	                                <Setter Property="Opacity" Value="0.45"/>
90	                            </Trigger>
91	                        </ControlTemplate.Triggers>
92	                    </ControlTemplate>
93	                </Setter.Value>
94	            </Setter>
95	        </Style>
96
97	        <!-- 콤보박스 (간단 Flat) -->
98	        <Style TargetType="ComboBox">
99	            <Setter Property="Padding" Value="8,5"/>
100	            <Setter Property="FontSize" Value="13"/>
101	            <Setter Property="BorderBrush" Value="{StaticResource CardBorder}"/>
102	            <Setter Property="Background" Value="{StaticResource CardBg}"/>
103	            <Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
104	        </Style>
105	    </Application.Resources>
106	</Application>
107

        <Style x:Key="FlatButton" TargetType="Button">
            <Setter Property="Background" Value="{StaticResource CardBg}"/>
            <Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
            <Setter Property="BorderBrush" Value="{StaticResource CardBorder}"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="Padding" Value="14,7"/>
            <Setter Property="Margin" Value="3"/>
            <Setter Property="FontSize" Value="13"/>
            <Setter Property="Cursor" Value="Hand"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="Button">
                        <Border x:Name="bd" Background="{TemplateBinding Background}"
                                BorderBrush="{TemplateBinding BorderBrush}"
                                BorderThickness="{TemplateBinding BorderThickness}"
                                CornerRadius="8">
                            <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
                        </Border>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsMouseOver" Value="True">
                                <Setter TargetName="bd" Property="Background" Value="{StaticResource AccentSoft}"/>
                            </Trigger>
                            <Trigger Property="IsEnabled" Value="False">
                                <Setter Property="Opacity" Value="0.45"/>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>

        <!-- 콤보박스 (간단 Flat) -->
        <Style TargetType="ComboBox">
            <Setter Property="Padding" Value="8,5"/>
            <Setter Property="FontSize" Value="13"/>
            <Setter Property="BorderBrush" Value="{StaticResource CardBorder}"/>
            <Setter Property="Background" Value="{StaticResource CardBg}"/>
            <Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
        </Style>
    </Application.Resources>
</Application>


FlatButton 템플릿에도 포커스/디폴트 강조 트리거가 없습니다(IsMouseOver/IsEnabled만). 결론: 안심회복 강조는 오직 `SetActive(ReliefRecoverBtn, true)` = `_state.ReliefRecover=true` = STATUS byte4 bit2 = 장치가 `Ext_Run_Mode==1` 보고일 때만 켜집니다.

펌웨어 기본값은 0이므로, 대시보드가 시작 시 상태를 저장/복원하거나 연결 시 무언가 전송하는지 확인하겠습니다.


76:            _api.ConnectionChanged += b => Dispatcher.BeginInvoke(() => OnConnectionChanged(b));
166:            if (!_demoTimer.IsEnabled && CanSend()) _api.SetSubMode(SubModeType.SmartSleep, false);
282:        void OnConnectionChanged(bool connected)
403:                if (tag != "SmartSleep"    && _state.SmartSleep)    _api.SetSubMode(SubModeType.SmartSleep,    false);
404:                if (tag != "ComfortCook"   && _state.ComfortCook)   _api.SetSubMode(SubModeType.ComfortCook,   false);
405:                if (tag != "ReliefRecover" && _state.ReliefRecover) _api.SetSubMode(SubModeType.ReliefRecover, false);
407:            _api.SetSubMode(type, next);


282	        void OnConnectionChanged(bool connected)
283	        {
284	            ConnLed.Fill = connected ? GoodBr : BadBr;
285	            ConnText.Text = connected ? "연결됨" : "미연결";
286	            ConnectBtn.IsEnabled = !connected;
287	            DisconnectBtn.IsEnabled = connected;
288	            if (connected)
289	            {
290	                _commActive = true;               // 연결 즉시 제어/통신 활성
291	                _api.RequestStatus();           // 최초 STATUS 요청
292	            }
293	            else
294	            {
295	                _commActive = false;
296	            }
297	            StartBtn.IsEnabled = connected && !_commActive;
298	            StopBtn.IsEnabled = connected && _commActive;
299	        }
300
301	        void StartComm_Click(object sender, RoutedEventArgs e)
302	        {
303	            if (!_api.IsConnected) return;
304	            _commActive = true;
305	            StartBtn.IsEnabled = false;
306	            StopBtn.IsEnabled = true;

        void OnConnectionChanged(bool connected)
        {
            ConnLed.Fill = connected ? GoodBr : BadBr;
            ConnText.Text = connected ? "연결됨" : "미연결";
            ConnectBtn.IsEnabled = !connected;
            DisconnectBtn.IsEnabled = connected;
            if (connected)
            {
                _commActive = true;               // 연결 즉시 제어/통신 활성
                _api.RequestStatus();           // 최초 STATUS 요청
            }
            else
            {
                _commActive = false;
            }
            StartBtn.IsEnabled = connected && !_commActive;
            StopBtn.IsEnabled = connected && _commActive;
        }

        void StartComm_Click(object sender, RoutedEventArgs e)
        {
            if (!_api.IsConnected) return;
            _commActive = true;
            StartBtn.IsEnabled = false;
            StopBtn.IsEnabled = true;


No files found


OnConnectionChanged는 STATUS 요청만 하고 relief를 보내지 않습니다. 시뮬레이터 프로젝트도 TestProgram에 없습니다. 데모 페이로드가 안심회복을 켜는지(데모 삭제로 #2가 해결될 수도) 확인하겠습니다.


1	namespace ErvProtocol
2	{
3	    // 펌웨어 없이 UI/파이프라인 검증용 합성 STATUS payload(73B) 생성.
4	    public static class DemoStatus
5	    {
6	        public static byte[] BuildPayload(int tick)
7	        {
8	            var p = new byte[StatusDecoder.STATUS_LEN];
9	            p[0] = 1;                                    // power
10	            p[1] = (byte)RunMode.Auto;                   // runMode
11	            p[2] = (byte)((tick / 5) % 2);               // autoState 분산/집중
12	            p[3] = (byte)(2 + (tick % 3));               // fanMode 2~4
13	            p[4] = SubModeBits.SmartSleep;               // subMode
14	            p[5] = (byte)(tick % 2);                     // hood
15	            p[6] = (byte)HystPreset.Normal;              // preset
16	            WriteU16(p, 7, 30); WriteU16(p, 9, 50); WriteU16(p, 11, 300); WriteU16(p, 13, 700);
17	            WriteU16(p, 15, 0x0000);                     // errorCode
18
19	            for (int r = 0; r < 4; r++)
20	            {
21	                int o = 17 + r * 14;
22	                int seed = tick + r * 13;
23	                p[o + 0] = (byte)((seed % 2) | (((seed % 3) == 0) ? 0x02 : 0));   // bit0 급기 / bit1 배기
24	                WriteU16(p, o + 1, 10 + (seed * 3) % 60);
25	                WriteU16(p, o + 3, 15 + (seed * 5) % 90);
26	                WriteU16(p, o + 5, 100 + (seed * 7) % 400);
27	                WriteU16(p, o + 7, 450 + (seed * 11) % 700);
28	                p[o + 9] = (byte)(1 + (seed % 4));
29	                p[o + 10] = (byte)(seed % 10);
30	                WriteU16(p, o + 11, (seed * 17) % 100);
31	                p[o + 13] = (byte)(seed % 5);
32	            }
33
34	            p[73] = 0;   // reset (토글 off)
35
36	            // 풍량 VSP 설정값 (1바이트, 사양서 DL H-ERV VSP 실측표) : 환기1~4, 바이패스, 공청1~4 의 SA/EA
37	            int[] sa = { 56, 63, 70, 86, 67, 65, 72, 78, 80 };
38	            int[] ea = { 57, 63, 70, 85, 75,  0,  0,  0,  0 };
39	            for (int i = 0; i < 9; i++)
40	            {
41	                int o = 74 + i * 4;
42	                WriteU16(p, o, sa[i]);
43	                WriteU16(p, o + 2, ea[i]);
44	            }
45
46	            // 히스테리시스 데드밴드(하강) (ECO/NORMAL/TURBO 의 PM2.5/PM10/VOC/CO2) - 사양서
47	            int[,] hyst = { { 2, 5, 5, 50 }, { 2, 5, 5, 50 }, { 2, 5, 3, 30 } };
48	            for (int i = 0; i < 3; i++)
49	            {
50	                int o = 110 + i * 8;
51	                WriteU16(p, o,     hyst[i, 0]);
52	                WriteU16(p, o + 2, hyst[i, 1]);
53	                WriteU16(p, o + 4, hyst[i, 2]);
54	                WriteU16(p, o + 6, hyst[i, 3]);
55	            }
56
57	            // 모드별 오염단계 임계표 (3프리셋 × [CO2,PM2.5,PM10,VOC] × L1~L4 상한) - 사양서
58	            int[][,] thr =
59	            {
60	                new int[,] { {1000,1300,1600,2000}, {20,38,60,86},  {40,86,126,173},  {171,195,308,438} }, // ECO
61	                new int[,] { {800,1100,1400,1700},  {14,29,49,69},  {28,66,102,138},  {120,150,250,350} }, // NORMAL
62	                new int[,] { {700,1000,1300,1600},  {12,23,38,52},  {24,53,78,104},   {103,120,192,263} }, // TURBO
63	            };
64	            for (int i = 0; i < 3; i++)
65	            {
66	                int o = StatusDecoder.THR_OFF + i * 32;
67	                for (int g = 0; g < 4; g++)        // g: 0 CO2,1 PM2.5,2 PM10,3 VOC
68	                    for (int k = 0; k < 4; k++)
69	                        WriteU16(p, o + g * 8 + k * 2, thr[i][g, k]);
70	            }
71
72	            // 각실 온도/습도 (offset 230~, 4실 × [Temp, Humi])
73	            for (int r = 0; r < 4; r++)
74	            {
75	                int o = StatusDecoder.TEMPHUMI_OFF + r * 2;
76	                p[o + 0] = (byte)(22 + (tick + r) % 6);   // 22~27℃
77	                p[o + 1] = (byte)(40 + (tick + r * 7) % 30); // 40~69%
78	            }
79	            return p;
80	        }
81
82	        static void WriteU16(byte[] p, int off, int v)
83	        {
84	            p[off] = (byte)((v >> 8) & 0xFF);
85	            p[off + 1] = (byte)(v & 0xFF);
86	        }
87	    }
88	}
89

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);
        }
    }
}


데모 페이로드는 `SmartSleep`을 켜지(안심회복 아님), 게다가 데모는 실행되지 않으므로 **#2(안심회복 기본선택)와 무관**합니다. 안심회복은 연결된 장치가 `Ext_Run_Mode==1`을 보고하기 때문입니다(펌웨어 기본 0, 명령으로만 1).

이제 요청 3건 중 명확한 2건(상호배타 삭제, 데모 삭제)을 먼저 처리하겠습니다. 데모 분기들이 흩어져 있어 해당 구간을 읽어옵니다.


215	            if (_graphWin == null) { _graphWin = new GraphWindow(names.ToArray(), _logDb) { Owner = this }; _graphWin.Closed += (_, _) => _graphWin = null; _graphWin.Show(); }
216	            else _graphWin.Activate();
217	        }
218
219	        // 팝업에서 호출 (제어 송신은 메인이 담당)
220	        public void SelectPreset(HystPreset preset)
221	        {
222	            if (_demoTimer.IsEnabled) { _state.HystPreset = preset; return; }
223	            if (!CanSend()) return;
224	            _api.SetHystPreset(preset);
225	            _state.HystPreset = preset;
226	            Log($"[제어] 히스테리시스 프리셋 → {preset}");
227	        }
228
229	        public void ApplyHyst()
230	        {
231	            if (!CanSend()) return;
232	            foreach (var h in _state.HystTable)
233	                _api.SetHystDeadband(h.Preset, h.Pm25, h.Pm10, h.Voc, h.Co2);
234	            Log($"[제어] 히스테리시스 프리셋 {_state.HystTable.Count}개 값 변경");
235	        }
236
237	        // 활성 프리셋의 오염단계 임계 + 데드밴드 송신 (HystWindow '변경')
238	        public void ApplyHystPreset(int preset)
239	        {
240	            if (_demoTimer.IsEnabled) return;   // 데모: 상태만 갱신됨
241	            if (!CanSend()) return;
242	            _api.SetHystThreshold(preset, 0, _state.Co2Thr[preset][0], _state.Co2Thr[preset][1], _state.Co2Thr[preset][2], _state.Co2Thr[preset][3]);
243	            _api.SetHystThreshold(preset, 1, _state.Pm25Thr[preset][0], _state.Pm25Thr[preset][1], _state.Pm25Thr[preset][2], _state.Pm25Thr[preset][3]);
244	            _api.SetHystThreshold(preset, 2, _state.Pm10Thr[preset][0], _state.Pm10Thr[preset][1], _state.Pm10Thr[preset][2], _state.Pm10Thr[preset][3]);
245	            _api.SetHystThreshold(preset, 3, _state.VocThr[preset][0], _state.VocThr[preset][1], _state.VocThr[preset][2], _state.VocThr[preset][3]);
246	            var h = _state.HystTable[preset];
247	            _api.SetHystDeadband(preset, h.Pm25, h.Pm10, h.Voc, h.Co2);
248	            Log($"[제어] 히스테리시스 프리셋 {(HystPreset)preset} 임계/데드밴드 적용");
249	        }
250
251	        public void ApplyVsp()
252	        {
253	            if (!CanSend()) return;
254	            foreach (var v in _state.Vsp)
255	                _api.SetVsp(v.Group, v.Index, Math.Clamp(v.Sa, 0, 255), Math.Clamp(v.Ea, 0, 255));   // VSP 1바이트
256	            Log($"[제어] 풍량 VSP {_state.Vsp.Count}개 적용");
257	        }
258
259	        // ================= 통신 =================

            if (_graphWin == null) { _graphWin = new GraphWindow(names.ToArray(), _logDb) { Owner = this }; _graphWin.Closed += (_, _) => _graphWin = null; _graphWin.Show(); }
            else _graphWin.Activate();
        }

        // 팝업에서 호출 (제어 송신은 메인이 담당)
        public void SelectPreset(HystPreset preset)
        {
            if (_demoTimer.IsEnabled) { _state.HystPreset = preset; return; }
            if (!CanSend()) return;
            _api.SetHystPreset(preset);
            _state.HystPreset = preset;
            Log($"[제어] 히스테리시스 프리셋 → {preset}");
        }

        public void ApplyHyst()
        {
            if (!CanSend()) return;
            foreach (var h in _state.HystTable)
                _api.SetHystDeadband(h.Preset, h.Pm25, h.Pm10, h.Voc, h.Co2);
            Log($"[제어] 히스테리시스 프리셋 {_state.HystTable.Count}개 값 변경");
        }

        // 활성 프리셋의 오염단계 임계 + 데드밴드 송신 (HystWindow '변경')
        public void ApplyHystPreset(int preset)
        {
            if (_demoTimer.IsEnabled) 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, 1, _state.Pm25Thr[preset][0], _state.Pm25Thr[preset][1], _state.Pm25Thr[preset][2], _state.Pm25Thr[preset][3]);
            _api.SetHystThreshold(preset, 2, _state.Pm10Thr[preset][0], _state.Pm10Thr[preset][1], _state.Pm10Thr[preset][2], _state.Pm10Thr[preset][3]);
            _api.SetHystThreshold(preset, 3, _state.VocThr[preset][0], _state.VocThr[preset][1], _state.VocThr[preset][2], _state.VocThr[preset][3]);
            var h = _state.HystTable[preset];
            _api.SetHystDeadband(preset, h.Pm25, h.Pm10, h.Voc, h.Co2);
            Log($"[제어] 히스테리시스 프리셋 {(HystPreset)preset} 임계/데드밴드 적용");
        }

        public void ApplyVsp()
        {
            if (!CanSend()) return;
            foreach (var v in _state.Vsp)
                _api.SetVsp(v.Group, v.Index, Math.Clamp(v.Sa, 0, 255), Math.Clamp(v.Ea, 0, 255));   // VSP 1바이트
            Log($"[제어] 풍량 VSP {_state.Vsp.Count}개 적용");
        }

        // ================= 통신 =================


데모 분기가 여러 파일에 흩어져 있어 정확히 제거하려면 나머지 구간을 모두 봐야 합니다. 남은 구간을 읽겠습니다.


318	            StopBtn.IsEnabled = false;
319	            Log("통신 중지");
320	        }
321
322	        bool CanSend()
323	        {
324	            if (_demoTimer.IsEnabled) return false;      // 데모 모드에선 송신 안 함
325	            if (!_api.IsConnected)
326	            {
327	                Log("연결 후 제어 가능합니다.");
328	                return false;
329	            }
330	            return true;
331	        }
332
333	        // ================= ERV 제어 =================
334	        void Power_Click(object sender, RoutedEventArgs e)
335	        {
336	            bool next = !_state.PowerOn;
337	            if (_demoTimer.IsEnabled) { _state.PowerOn = next; return; }
338	            if (!CanSend()) return;
339	            _api.SetPower(next);
340	            _state.PowerOn = next;
341	            Log($"[제어] 전원 → {(next ? "ON" : "OFF")}");
342	        }
343
344	        void Mode_Click(object sender, RoutedEventArgs e)
345	        {
346	            if (sender is not Button b || b.Tag is not string tag) return;
347	            var def = Array.Find(ModeDefs, d => d.tag == tag);
348	            // 운전모드 전환 시 풍량 1단 (자동 제외). 실연결 시 ERV STATUS 로 최종 확정.
349	            if (_demoTimer.IsEnabled) { _state.RunMode = def.mode; if (def.mode != RunMode.Auto) _state.FanMode = 1; return; }
350	            if (!CanSend()) return;
351	            _api.SetRunMode(def.mode);
352	            _state.RunMode = def.mode;
353	            if (def.mode != RunMode.Auto) _state.FanMode = 1;
354	            Log($"[제어] 운전모드 → {def.label}");
355	        }
356
357	        // 자동모드 프리셋 선택 : 선택 프리셋의 임계(=공기질 판정 기준)로 전환.
358	        // 버튼은 자동모드에서만 활성(RefreshControls). 송신/상태갱신은 SelectPreset 이 담당.
359	        void Preset_Click(object sender, RoutedEventArgs e)
360	        {
361	            if (sender is not Button b || b.Tag is not string tag) return;
362	            if (!_state.IsAuto) { Log("프리셋은 자동모드에서만 선택할 수 있습니다."); return; }
363	            if (!Enum.TryParse<HystPreset>(tag, out var preset)) return;
364	            SelectPreset(preset);
365	        }
366
367	        void Fan_Click(object sender, RoutedEventArgs e)
368	        {
369	            if (sender is not Button b || b.Tag is not int speed) return;
370	            if (_state.IsAuto) { Log("자동모드에서는 풍량 조절 불가"); return; }
371	            if (_state.RunMode == RunMode.Bypass && speed > 1) { Log("바이패스는 1단 고정"); return; }
372	            if (_demoTimer.IsEnabled) { _state.FanMode = (byte)speed; return; }
373	            if (!CanSend()) return;
374	            _api.SetFan(speed);
375	            _state.FanMode = (byte)speed;
376	            Log($"[제어] 풍량 → {speed}");
377	        }
378
379	        void SubMode_Click(object sender, RoutedEventArgs e)
380	        {
381	            if (sender is not Button b || b.Tag is not string tag) return;
382	            bool anyBefore = _state.SmartSleep || _state.ComfortCook || _state.ReliefRecover;
383	            (SubModeType type, bool next) = tag switch
384	            {
385	                "SmartSleep"    => (SubModeType.SmartSleep,    !_state.SmartSleep),
386	                "ComfortCook"   => (SubModeType.ComfortCook,   !_state.ComfortCook),
387	                _               => (SubModeType.ReliefRecover, !_state.ReliefRecover),
388	            };
389	            // 시나리오 첫 진입 → 직전 운전모드/풍량 기억(해제 시 복귀용)
390	            if (next && !anyBefore) { _modeBeforeScenario = _state.RunMode; _fanBeforeScenario = _state.FanMode; }
391	            if (tag == "SmartSleep" && !next) _sleepEndAt = null;   // 스마트수면 수동 해제 → 자동해제 예약 취소
392	            if (_demoTimer.IsEnabled)
393	            {
394	                ApplySubModeLocal(tag, next);
395	                if (NoScenarioActive()) RestorePreviousMode();
396	                return;
397	            }
398	            if (!CanSend()) return;
399	            // 상호배타: 새 모드를 켤 때 기존 활성 모드는 장치에도 OFF 전송
400	            // (펌웨어는 시나리오모드를 독립 변수로 유지 → status 재수신 시 부활 방지)
401	            if (next)
402	            {
403	                if (tag != "SmartSleep"    && _state.SmartSleep)    _api.SetSubMode(SubModeType.SmartSleep,    false);
404	                if (tag != "ComfortCook"   && _state.ComfortCook)   _api.SetSubMode(SubModeType.ComfortCook,   false);
405	                if (tag != "ReliefRecover" && _state.ReliefRecover) _api.SetSubMode(SubModeType.ReliefRecover, false);
406	            }
407	            _api.SetSubMode(type, next);
408	            ApplySubModeLocal(tag, next);
409	            Log($"[제어] 시나리오모드 {b.Content} → {(next ? "ON" : "OFF")}");
410	            if (NoScenarioActive()) RestorePreviousMode();
411	        }
412
413	        bool NoScenarioActive() => !_state.SmartSleep && !_state.ComfortCook && !_state.ReliefRecover;
414
415	        // 시나리오모드 해제 → 진입 직전 운전모드/풍량으로 동작 복귀(이전모드로 동작).
416	        // 실연결 시 ERV(펌웨어/시뮬)도 자체 복원하므로 로컬은 즉시 반영하고 STATUS 로 재동기화.
417	        void RestorePreviousMode()

            StopBtn.IsEnabled = false;
            Log("통신 중지");
        }

        bool CanSend()
        {
            if (_demoTimer.IsEnabled) return false;      // 데모 모드에선 송신 안 함
            if (!_api.IsConnected)
            {
                Log("연결 후 제어 가능합니다.");
                return false;
            }
            return true;
        }

        // ================= ERV 제어 =================
        void Power_Click(object sender, RoutedEventArgs e)
        {
            bool next = !_state.PowerOn;
            if (_demoTimer.IsEnabled) { _state.PowerOn = next; return; }
            if (!CanSend()) return;
            _api.SetPower(next);
            _state.PowerOn = next;
            Log($"[제어] 전원 → {(next ? "ON" : "OFF")}");
        }

        void Mode_Click(object sender, RoutedEventArgs e)
        {
            if (sender is not Button b || b.Tag is not string tag) return;
            var def = Array.Find(ModeDefs, d => d.tag == tag);
            // 운전모드 전환 시 풍량 1단 (자동 제외). 실연결 시 ERV STATUS 로 최종 확정.
            if (_demoTimer.IsEnabled) { _state.RunMode = def.mode; if (def.mode != RunMode.Auto) _state.FanMode = 1; return; }
            if (!CanSend()) return;
            _api.SetRunMode(def.mode);
            _state.RunMode = def.mode;
            if (def.mode != RunMode.Auto) _state.FanMode = 1;
            Log($"[제어] 운전모드 → {def.label}");
        }

        // 자동모드 프리셋 선택 : 선택 프리셋의 임계(=공기질 판정 기준)로 전환.
        // 버튼은 자동모드에서만 활성(RefreshControls). 송신/상태갱신은 SelectPreset 이 담당.
        void Preset_Click(object sender, RoutedEventArgs e)
        {
            if (sender is not Button b || b.Tag is not string tag) return;
            if (!_state.IsAuto) { Log("프리셋은 자동모드에서만 선택할 수 있습니다."); return; }
            if (!Enum.TryParse<HystPreset>(tag, out var preset)) return;
            SelectPreset(preset);
        }

        void Fan_Click(object sender, RoutedEventArgs e)
        {
            if (sender is not Button b || b.Tag is not int speed) return;
            if (_state.IsAuto) { Log("자동모드에서는 풍량 조절 불가"); return; }
            if (_state.RunMode == RunMode.Bypass && speed > 1) { Log("바이패스는 1단 고정"); return; }
            if (_demoTimer.IsEnabled) { _state.FanMode = (byte)speed; return; }
            if (!CanSend()) return;
            _api.SetFan(speed);
            _state.FanMode = (byte)speed;
            Log($"[제어] 풍량 → {speed}");
        }

        void SubMode_Click(object sender, RoutedEventArgs e)
        {
            if (sender is not Button b || b.Tag is not string tag) return;
            bool anyBefore = _state.SmartSleep || _state.ComfortCook || _state.ReliefRecover;
            (SubModeType type, bool next) = tag switch
            {
                "SmartSleep"    => (SubModeType.SmartSleep,    !_state.SmartSleep),
                "ComfortCook"   => (SubModeType.ComfortCook,   !_state.ComfortCook),
                _               => (SubModeType.ReliefRecover, !_state.ReliefRecover),
            };
            // 시나리오 첫 진입 → 직전 운전모드/풍량 기억(해제 시 복귀용)
            if (next && !anyBefore) { _modeBeforeScenario = _state.RunMode; _fanBeforeScenario = _state.FanMode; }
            if (tag == "SmartSleep" && !next) _sleepEndAt = null;   // 스마트수면 수동 해제 → 자동해제 예약 취소
            if (_demoTimer.IsEnabled)
            {
                ApplySubModeLocal(tag, next);
                if (NoScenarioActive()) RestorePreviousMode();
                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);
            ApplySubModeLocal(tag, next);
            Log($"[제어] 시나리오모드 {b.Content} → {(next ? "ON" : "OFF")}");
            if (NoScenarioActive()) RestorePreviousMode();
        }

        bool NoScenarioActive() => !_state.SmartSleep && !_state.ComfortCook && !_state.ReliefRecover;

        // 시나리오모드 해제 → 진입 직전 운전모드/풍량으로 동작 복귀(이전모드로 동작).
        // 실연결 시 ERV(펌웨어/시뮬)도 자체 복원하므로 로컬은 즉시 반영하고 STATUS 로 재동기화.
        void RestorePreviousMode()


444	        void Hood_Click(object sender, RoutedEventArgs e)
445	        {
446	            bool next = !_state.Hood;
447	            if (_demoTimer.IsEnabled) { _state.Hood = next; return; }
448	            if (!CanSend()) return;
449	            _api.SetHood(next);
450	            _state.Hood = next;
451	            Log($"[제어] 연동후드 → {(next ? "ON" : "OFF")}");
452	        }
453
454	        void Reset_Click(object sender, RoutedEventArgs e)
455	        {
456	            bool next = !_state.Reset;
457	            if (_demoTimer.IsEnabled) { _state.Reset = next; return; }
458	            if (!CanSend()) return;
459	            _api.SetReset(next);
460	            _state.Reset = next;
461	            Log($"[제어] ERV 리셋 → {(next ? "ON" : "OFF")}");
462	        }
463
464	        // ================= 각실 제어 =================
465	        // 급기(SA) 댐퍼 토글
466	        void DamperSa_Click(object sender, RoutedEventArgs e) => DamperToggle(sender, type: 0);
467	        // 배기(EA) 댐퍼 토글
468	        void DamperEa_Click(object sender, RoutedEventArgs e) => DamperToggle(sender, type: 1);
469
470	        // type : 0=급기(SA) / 1=배기(EA)
471	        void DamperToggle(object sender, int type)
472	        {
473	            if (sender is not Button b || b.Tag is not int roomId) return;
474	            var room = _state.Room(roomId);
475	            bool cur  = type == 0 ? room.DamperSaOpen : room.DamperEaOpen;
476	            bool next = !cur;
477	            if (_demoTimer.IsEnabled)
478	            {
479	                if (type == 0) room.DamperSaOpen = next; else room.DamperEaOpen = next;
480	                return;
481	            }
482	            if (!CanSend()) return;
483	            _api.SetDiffuserDamper(roomId, type, next);
484	            if (type == 0) room.DamperSaOpen = next; else room.DamperEaOpen = next;
485	            Log($"[제어] {room.Name} {(type == 0 ? "급기" : "배기")}댐퍼 → {(next ? "열림" : "닫힘")}");
486	        }
487
488	        // LED 디밍 전송 경로 (드래그/클릭/키보드 모두 지원)
489	        //   - thumb 드래그 : 중간값 전송 보류(_ledDragging) → DragCompleted 에서 최종값 1회 전송
490	        //   - 트랙 클릭/키보드 : ValueChanged 에서 즉시 전송
491	        //   - STATUS 역갱신(_suppressLed) 으로 인한 변경은 전송 안 함
492	        void Led_DragStarted(object sender, DragStartedEventArgs e) => _ledDragging = true;
493
494	        void Led_DragCompleted(object sender, DragCompletedEventArgs e)
495	        {
496	            _ledDragging = false;
497	            if (sender is Slider s) SendLed(s);
498	        }
499
500	        void Led_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
501	        {
502	            if (_ledDragging) return;   // 드래그 중 중간값은 DragCompleted 에서 처리
503	            if (sender is Slider s) SendLed(s);
504	        }
505
506	        void SendLed(Slider s)
507	        {
508	            if (s.Tag is not int roomId) return;
509	            if (_suppressLed) return;   // STATUS 동기 적용 중 echo 차단
510	            int dim = (int)s.Value;
511	            // 직전 송신/수신값과 같으면 전송 안 함 — STATUS 역갱신이 유발한 ValueChanged(에코) 차단.
512	            // 사용자가 값을 실제로 바꾸면 dim 이 달라지므로 정상 전송됨.
513	            if (_lastLed.TryGetValue(roomId, out var last) && last == dim) return;
514	            if (_demoTimer.IsEnabled) return;
515	            if (!CanSend()) return;
516	            _api.SetDiffuserLed(roomId, dim);
517	            _lastLed[roomId] = dim;
518	            Log($"[제어] {_state.Room(roomId).Name} LED 디밍 → {dim}");
519	        }
520
521	        // ================= (꺼짐)예약 =================
522	        bool _suppressReserve;   // 상태→콤보 동기화 중 Reserve_Changed 재진입 차단
523	        void Reserve_Changed(object sender, SelectionChangedEventArgs e)
524	        {
525	            if (!IsLoaded || _suppressReserve) return;
526	            if (ReserveCombo.SelectedIndex < 0) return;
527	            int hours = ReserveCombo.SelectedIndex;   // 0=해제, 1~8시간
528	            if (_demoTimer.IsEnabled) { _state.ReserveRemainSec = hours * 3600; return; }
529	            if (!CanSend()) return;
530	            _api.SetReserve(hours);
531	            Log(hours == 0 ? "[제어] 예약 해제" : $"[제어] {hours}시간 후 꺼짐 예약");
532	        }
533
534	        // ================= 데모 모드 (버튼 제거됨 — 내부 합성 STATUS 경로는 비활성 상태로 유지) =================
535	        void DemoTick()
536	        {
537	            _api.InjectDemoStatus(_demoTick++);
538	        }

        void Hood_Click(object sender, RoutedEventArgs e)
        {
            bool next = !_state.Hood;
            if (_demoTimer.IsEnabled) { _state.Hood = next; return; }
            if (!CanSend()) return;
            _api.SetHood(next);
            _state.Hood = next;
            Log($"[제어] 연동후드 → {(next ? "ON" : "OFF")}");
        }

        void Reset_Click(object sender, RoutedEventArgs e)
        {
            bool next = !_state.Reset;
            if (_demoTimer.IsEnabled) { _state.Reset = next; return; }
            if (!CanSend()) return;
            _api.SetReset(next);
            _state.Reset = next;
            Log($"[제어] ERV 리셋 → {(next ? "ON" : "OFF")}");
        }

        // ================= 각실 제어 =================
        // 급기(SA) 댐퍼 토글
        void DamperSa_Click(object sender, RoutedEventArgs e) => DamperToggle(sender, type: 0);
        // 배기(EA) 댐퍼 토글
        void DamperEa_Click(object sender, RoutedEventArgs e) => DamperToggle(sender, type: 1);

        // type : 0=급기(SA) / 1=배기(EA)
        void DamperToggle(object sender, int type)
        {
            if (sender is not Button b || b.Tag is not int roomId) return;
            var room = _state.Room(roomId);
            bool cur  = type == 0 ? room.DamperSaOpen : room.DamperEaOpen;
            bool next = !cur;
            if (_demoTimer.IsEnabled)
            {
                if (type == 0) room.DamperSaOpen = next; else room.DamperEaOpen = next;
                return;
            }
            if (!CanSend()) return;
            _api.SetDiffuserDamper(roomId, type, next);
            if (type == 0) room.DamperSaOpen = next; else room.DamperEaOpen = next;
            Log($"[제어] {room.Name} {(type == 0 ? "급기" : "배기")}댐퍼 → {(next ? "열림" : "닫힘")}");
        }

        // LED 디밍 전송 경로 (드래그/클릭/키보드 모두 지원)
        //   - thumb 드래그 : 중간값 전송 보류(_ledDragging) → DragCompleted 에서 최종값 1회 전송
        //   - 트랙 클릭/키보드 : ValueChanged 에서 즉시 전송
        //   - STATUS 역갱신(_suppressLed) 으로 인한 변경은 전송 안 함
        void Led_DragStarted(object sender, DragStartedEventArgs e) => _ledDragging = true;

        void Led_DragCompleted(object sender, DragCompletedEventArgs e)
        {
            _ledDragging = false;
            if (sender is Slider s) SendLed(s);
        }

        void Led_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
        {
            if (_ledDragging) return;   // 드래그 중 중간값은 DragCompleted 에서 처리
            if (sender is Slider s) SendLed(s);
        }

        void SendLed(Slider s)
        {
            if (s.Tag is not int roomId) return;
            if (_suppressLed) return;   // STATUS 동기 적용 중 echo 차단
            int dim = (int)s.Value;
            // 직전 송신/수신값과 같으면 전송 안 함 — STATUS 역갱신이 유발한 ValueChanged(에코) 차단.
            // 사용자가 값을 실제로 바꾸면 dim 이 달라지므로 정상 전송됨.
            if (_lastLed.TryGetValue(roomId, out var last) && last == dim) return;
            if (_demoTimer.IsEnabled) return;
            if (!CanSend()) return;
            _api.SetDiffuserLed(roomId, dim);
            _lastLed[roomId] = dim;
            Log($"[제어] {_state.Room(roomId).Name} LED 디밍 → {dim}");
        }

        // ================= (꺼짐)예약 =================
        bool _suppressReserve;   // 상태→콤보 동기화 중 Reserve_Changed 재진입 차단
        void Reserve_Changed(object sender, SelectionChangedEventArgs e)
        {
            if (!IsLoaded || _suppressReserve) return;
            if (ReserveCombo.SelectedIndex < 0) return;
            int hours = ReserveCombo.SelectedIndex;   // 0=해제, 1~8시간
            if (_demoTimer.IsEnabled) { _state.ReserveRemainSec = hours * 3600; return; }
            if (!CanSend()) return;
            _api.SetReserve(hours);
            Log(hours == 0 ? "[제어] 예약 해제" : $"[제어] {hours}시간 후 꺼짐 예약");
        }

        // ================= 데모 모드 (버튼 제거됨 — 내부 합성 STATUS 경로는 비활성 상태로 유지) =================
        void DemoTick()
        {
            _api.InjectDemoStatus(_demoTick++);
        }


모든 데모 위치를 파악했습니다. 공유 라이브러리(ErvProtocol)의 `DemoStatus`가 다른 프로젝트(WebDashboard 등)에서도 쓰이는지 확인하고, API 쪽 데모 메서드를 봅니다.


98	        public void SetVsp(int group, int index, int sa, int ea) => SendFrame(CtrlFrame.Vsp(group, index, sa, ea));
99	        public void SetHystPreset(HystPreset preset)   => SendFrame(CtrlFrame.Preset((int)preset));
100	        public void SetHystDeadband(int preset, int pm25, int pm10, int voc, int co2) => SendFrame(CtrlFrame.HystValue(preset, pm25, pm10, voc, co2));
101	        public void SetHystThreshold(int preset, int pollutant, int l1, int l2, int l3, int l4) => SendFrame(CtrlFrame.HystThr(preset, pollutant, l1, l2, l3, l4));
102
103	        // ================= 데모/테스트 =================
104	        public void InjectDemoStatus(int tick)
105	        {
106	            var frame = CtrlFrame.Build(StatusDecoder.STATUS, DemoStatus.BuildPayload(tick));
107	            _parser.Reset();
108	            _parser.Feed(frame);
109	        }
110
111	        public void Dispose() => _ch.Dispose();
112	    }
113	}
114

        public void SetVsp(int group, int index, int sa, int ea) => SendFrame(CtrlFrame.Vsp(group, index, sa, ea));
        public void SetHystPreset(HystPreset preset)   => SendFrame(CtrlFrame.Preset((int)preset));
        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 InjectDemoStatus(int tick)
        {
            var frame = CtrlFrame.Build(StatusDecoder.STATUS, DemoStatus.BuildPayload(tick));
            _parser.Reset();
            _parser.Feed(frame);
        }

        public void Dispose() => _ch.Dispose();
    }
}


TestProgram\PCDashBoard\Api\SerialErvApi.cs:104:        public void InjectDemoStatus(int tick)
TestProgram\PCDashBoard\Api\SerialErvApi.cs:106:            var frame = CtrlFrame.Build(StatusDecoder.STATUS, DemoStatus.BuildPayload(tick));
TestProgram\PCDashBoard\Api\IErvApi.cs:46:        void InjectDemoStatus(int tick);
TestProgram\PCDashBoard\MainWindow.xaml.cs:537:            _api.InjectDemoStatus(_demoTick++);
TestProgram\ErvProtocol\DemoStatus.cs:4:    public static class DemoStatus


`DemoStatus`는 `SerialErvApi.InjectDemoStatus`에서만 사용되고 다른 프로젝트 참조가 없어 안전하게 제거 가능합니다. IErvApi의 정확한 텍스트를 확인하고 모든 편집을 진행하겠습니다.


40	        // ---- 히스테리시스 ----
41	        void SetHystPreset(HystPreset preset);                                          // 0x06
42	        void SetHystDeadband(int preset, int pm25, int pm10, int voc, int co2);         // 0x07
43	        void SetHystThreshold(int preset, int pollutant, int l1, int l2, int l3, int l4); // 0x0D
44
45	        // ---- 데모/테스트 (합성 STATUS를 수신 경로로 주입) ----
46	        void InjectDemoStatus(int tick);
47	    }
48	}
49

        // ---- 히스테리시스 ----
        void SetHystPreset(HystPreset preset);                                          // 0x06
        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

        // ---- 데모/테스트 (합성 STATUS를 수신 경로로 주입) ----
        void InjectDemoStatus(int tick);
    }
}


모든 위치를 확인했습니다. `DemoStatus`는 다른 프로젝트 참조가 없어 파일째 삭제 가능합니다. 이제 편집을 실행합니다 — 상호배타 제거(#1)와 데모 제거(#3)를 함께 적용합니다.


Edit

        readonly DispatcherTimer _demoTimer;
        int _demoTick;
        bool _commActive;


The file d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\MainWindow.xaml.cs has been updated successfully. (file state is current in your context — no need to Read it back)

        readonly DispatcherTimer _demoTimer;
        int _demoTick;
        bool _commActive;

readonly IErvApi _api = new SerialErvApi();

-        readonly DispatcherTimer _demoTimer;

bool _ledDragging;   // LED 슬라이더 thumb 드래그 중 (드래그 중엔 전송 보류 → 완료 시 1회)

bool _suppressLed;   // STATUS 동기 적용 중 LedDim→슬라이더 갱신으로 인한 ValueChanged 전송 차단


            BuildPresetButtons();

            _demoTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(700) };
            _demoTimer.Tick += (_, _) => DemoTick();

            _clockTimer.Tick += (_, _) => CheckSleepSchedule();

            BuildPresetButtons();

            _clockTimer.Tick += (_, _) => CheckSleepSchedule();


            BuildPresetButtons();

            _demoTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(700) };
            _demoTimer.Tick += (_, _) => DemoTick();

            _clockTimer.Tick += (_, _) => CheckSleepSchedule();

            BuildPresetButtons();

            _clockTimer.Tick += (_, _) => CheckSleepSchedule();

-            _demoTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(700) };

-            _demoTimer.Tick += (_, _) => DemoTick();


if (!_demoTimer.IsEnabled && CanSend()) _api.SetSubMode(SubModeType.SmartSleep, false);

if (CanSend()) _api.SetSubMode(SubModeType.SmartSleep, false);


if (!_state.SmartSleep || _sleepEndAt is not { } endAt || DateTime.Now < endAt) return;

-            if (!_demoTimer.IsEnabled && CanSend()) _api.SetSubMode(SubModeType.SmartSleep, false);

+            if (CanSend()) _api.SetSubMode(SubModeType.SmartSleep, false);

Log("[스마트수면] 종료 시각 도달 → 자동 해제");


            if (_demoTimer.IsEnabled) { _state.HystPreset = preset; return; }
            if (!CanSend()) return;


            if (_demoTimer.IsEnabled) { _state.HystPreset = preset; return; }
            if (!CanSend()) return;

// 팝업에서 호출 (제어 송신은 메인이 담당)

public void SelectPreset(HystPreset preset)

-            if (_demoTimer.IsEnabled) { _state.HystPreset = preset; return; }


            if (_demoTimer.IsEnabled) return;   // 데모: 상태만 갱신됨
            if (!CanSend()) return;


            if (_demoTimer.IsEnabled) return;   // 데모: 상태만 갱신됨
            if (!CanSend()) return;

// 활성 프리셋의 오염단계 임계 + 데드밴드 송신 (HystWindow '변경')

-            if (_demoTimer.IsEnabled) return;   // 데모: 상태만 갱신됨

_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]);


            if (_demoTimer.IsEnabled) return false;      // 데모 모드에선 송신 안 함
            if (!_api.IsConnected)


            if (_demoTimer.IsEnabled) return false;      // 데모 모드에선 송신 안 함
            if (!_api.IsConnected)

-            if (_demoTimer.IsEnabled) return false;      // 데모 모드에선 송신 안 함

Log("연결 후 제어 가능합니다.");


            if (_demoTimer.IsEnabled) { _state.PowerOn = next; return; }
            if (!CanSend()) return;


            if (_demoTimer.IsEnabled) { _state.PowerOn = next; return; }
            if (!CanSend()) return;

void Power_Click(object sender, RoutedEventArgs e)

-            if (_demoTimer.IsEnabled) { _state.PowerOn = next; return; }


            if (_demoTimer.IsEnabled) { _state.RunMode = def.mode; if (def.mode != RunMode.Auto) _state.FanMode = 1; return; }
            if (!CanSend()) return;


            if (_demoTimer.IsEnabled) { _state.RunMode = def.mode; if (def.mode != RunMode.Auto) _state.FanMode = 1; return; }
            if (!CanSend()) return;

if (sender is not Button b || b.Tag is not string tag) return;

var def = Array.Find(ModeDefs, d => d.tag == tag);

// 운전모드 전환 시 풍량 1단 (자동 제외). 실연결 시 ERV STATUS 로 최종 확정.

-            if (_demoTimer.IsEnabled) { _state.RunMode = def.mode; if (def.mode != RunMode.Auto) _state.FanMode = 1; return; }


            if (_demoTimer.IsEnabled) { _state.FanMode = (byte)speed; return; }
            if (!CanSend()) return;


            if (_demoTimer.IsEnabled) { _state.FanMode = (byte)speed; return; }
            if (!CanSend()) return;

if (sender is not Button b || b.Tag is not int speed) return;

if (_state.IsAuto) { Log("자동모드에서는 풍량 조절 불가"); return; }

if (_state.RunMode == RunMode.Bypass && speed > 1) { Log("바이패스는 1단 고정"); return; }

-            if (_demoTimer.IsEnabled) { _state.FanMode = (byte)speed; return; }


            if (_demoTimer.IsEnabled)
            {
                ApplySubModeLocal(tag, next);
                if (NoScenarioActive()) RestorePreviousMode();
                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);

            if (!CanSend()) return;
            _api.SetSubMode(type, next);