fix: 260618 룸컨 동기·쾌적조리·시나리오·그래프DB 등 수정

펌웨어(SOURCE/HECO2/User):
- My_Hood.c: 후드 전원 OFF 시 쾌적조리 토글 자동 해제 (6)
- My_RJ2.c/My_Homenet.c/My_define.h: 룸컨 전용 pending(RoomCtrl_Push) 도입
  → 전원 ON 간헐 미동작·전원 OFF 후 룸컨 옛모드(공청) 표시 해소 (11)(12)
  (공유 Command_request_type 레이스 + 716 equalize 조기클리어 + 분배기 wipe 회피)

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 23:41:34 +09:00
parent 6934f09b2a
commit b18d9c84bf
11 changed files with 271 additions and 168 deletions
-3
View File
@@ -41,8 +41,5 @@ namespace ErvDashboard.Api
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);
}
}
@@ -100,14 +100,6 @@ namespace ErvDashboard.Api
public void SetHystDeadband(int preset, int pm25, int pm10, int voc, int co2) => SendFrame(CtrlFrame.HystValue(preset, pm25, pm10, voc, co2));
public void 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();
}
}
+25 -66
View File
@@ -22,8 +22,6 @@ namespace ErvDashboard
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 전송 차단
@@ -61,7 +59,11 @@ namespace ErvDashboard
readonly DispatcherTimer _clockTimer = new() { Interval = TimeSpan.FromSeconds(1) };
// ---- 그래프용 시계열 샘플링 (5초 간격) → SQLite 실시간 저장(무제한 누적) ----
readonly Storage.LogDb _logDb = new(System.IO.Path.Combine(AppContext.BaseDirectory, "HERV_Log.db"));
// 단일 exe(IncludeAllContentForSelfExtract=true)는 임시폴더로 추출 실행 → AppContext.BaseDirectory 가 임시폴더를 가리킴.
// DB 는 실제 exe 위치(Environment.ProcessPath)에 저장해야 publish 폴더에서 보이고 재실행 간 누적 유지됨.
static string LogDbPath => System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(Environment.ProcessPath) ?? AppContext.BaseDirectory, "HERV_Log.db");
readonly Storage.LogDb _logDb = new(LogDbPath);
readonly DispatcherTimer _sampleTimer = new() { Interval = TimeSpan.FromSeconds(5) };
GraphWindow? _graphWin;
@@ -94,9 +96,6 @@ namespace ErvDashboard
BuildModeButtons();
BuildPresetButtons();
_demoTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(700) };
_demoTimer.Tick += (_, _) => DemoTick();
_clockTimer.Tick += (_, _) => CheckSleepSchedule();
_clockTimer.Start();
@@ -163,7 +162,7 @@ namespace ErvDashboard
{
if (!_state.SmartSleep || _sleepEndAt is not { } endAt || DateTime.Now < endAt) return;
_sleepEndAt = null;
if (!_demoTimer.IsEnabled && CanSend()) _api.SetSubMode(SubModeType.SmartSleep, false);
if (CanSend()) _api.SetSubMode(SubModeType.SmartSleep, false);
ApplySubModeLocal("SmartSleep", false);
Log("[스마트수면] 종료 시각 도달 → 자동 해제");
if (NoScenarioActive()) RestorePreviousMode();
@@ -219,7 +218,6 @@ namespace ErvDashboard
// 팝업에서 호출 (제어 송신은 메인이 담당)
public void SelectPreset(HystPreset preset)
{
if (_demoTimer.IsEnabled) { _state.HystPreset = preset; return; }
if (!CanSend()) return;
_api.SetHystPreset(preset);
_state.HystPreset = preset;
@@ -237,7 +235,6 @@ namespace ErvDashboard
// 활성 프리셋의 오염단계 임계 + 데드밴드 송신 (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]);
@@ -321,7 +318,6 @@ namespace ErvDashboard
bool CanSend()
{
if (_demoTimer.IsEnabled) return false; // 데모 모드에선 송신 안 함
if (!_api.IsConnected)
{
Log("연결 후 제어 가능합니다.");
@@ -334,7 +330,6 @@ namespace ErvDashboard
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;
@@ -346,7 +341,6 @@ namespace ErvDashboard
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;
@@ -369,7 +363,6 @@ namespace ErvDashboard
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;
@@ -389,21 +382,7 @@ namespace ErvDashboard
// 시나리오 첫 진입 → 직전 운전모드/풍량 기억(해제 시 복귀용)
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")}");
@@ -423,28 +402,18 @@ namespace ErvDashboard
void ApplySubModeLocal(string tag, bool on)
{
// 시나리오모드 상호배타: 하나를 켜면 나머지는 해제
if (on)
// 시나리오모드 상호배타 없음 — 클릭한 모드만 on/off (사용자가 하나만 선택).
switch (tag)
{
_state.SmartSleep = tag == "SmartSleep";
_state.ComfortCook = tag == "ComfortCook";
_state.ReliefRecover = tag == "ReliefRecover";
}
else
{
switch (tag)
{
case "SmartSleep": _state.SmartSleep = false; break;
case "ComfortCook": _state.ComfortCook = false; break;
case "ReliefRecover": _state.ReliefRecover = false; break;
}
case "SmartSleep": _state.SmartSleep = on; break;
case "ComfortCook": _state.ComfortCook = on; break;
case "ReliefRecover": _state.ReliefRecover = on; break;
}
}
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;
@@ -454,7 +423,6 @@ namespace ErvDashboard
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;
@@ -474,11 +442,6 @@ namespace ErvDashboard
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;
@@ -511,7 +474,6 @@ namespace ErvDashboard
// 직전 송신/수신값과 같으면 전송 안 함 — 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;
@@ -525,18 +487,11 @@ namespace ErvDashboard
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++);
}
// ================= UI 갱신 =================
void RefreshControls()
{
@@ -564,15 +519,19 @@ namespace ErvDashboard
// - 자동 : 수동 조절 불가(전 단 비활성)
// - 바이패스 : 최대 1단(2~4단 비활성)
// - 환기/공청 : 0~4단
// 시나리오모드 활성 시: 운전모드·풍량·선택 안 된 시나리오모드 비활성화
// 쾌적조리는 '연동운전중(HoodRunning=후드 가동중)' 기준으로 시나리오 활성 판단.
// 후드 OFF(대기 상태)면 ERV는 본래 운전모드로 복귀하므로 운전모드를 다시 활성화해야 함(사양 3.1).
bool subActive = _state.SmartSleep || _state.HoodRunning || _state.ReliefRecover;
// 시나리오모드 활성 시: 운전모드·풍량·선택 안 된 시나리오모드 비활성화(=클릭 불가)
// 쾌적조리 잠금은 '메이크업 실제 연동중'(쾌적조리 토글 ON + 후드 가동) 일 때만.
// - 펌웨어 Hood_process() 는 Hood_YeunDong_Enable==1 일 때만 모드/풍량을 건드림.
// 쾌적조리 OFF면 후드만 켜져도 ERV는 그대로 → 다른 시나리오모드/운전모드 선택 가능해야 함.
// - 후드 OFF(대기)면 메이크업 비연동 → 본래 운전모드로 복귀하므로 잠금 해제(사양 3.1).
bool makeupActive = _state.ComfortCook && _state.HoodRunning;
bool subActive = _state.SmartSleep || makeupActive || _state.ReliefRecover;
int fanMax = _state.RunMode == RunMode.Bypass ? 1 : 4;
foreach (var fb in _fanButtons)
{
int sp = (int)fb.Tag!;
fb.IsEnabled = !subActive && !_state.IsAuto && sp <= fanMax;
// 전원 OFF면 풍량 조절 불가 → 0 포함 전 단 비활성
fb.IsEnabled = _state.PowerOn && !subActive && !_state.IsAuto && sp <= fanMax;
SetActive(fb, sp == _state.FanMode);
}
@@ -582,11 +541,12 @@ namespace ErvDashboard
// (HoodRunning 으로 강조하면 대기/Roll-back 상태에서 버튼이 꺼져 보여 재선택 시 토글이 반대로 먹던 문제 수정)
SetActive(ComfortCookBtn, _state.ComfortCook);
SetActive(ReliefRecoverBtn, _state.ReliefRecover);
// (활성 모드 버튼은 OFF 토글 가능해야 하므로 자기 자신은 유지)
SmartSleepBtn.IsEnabled = !subActive || _state.SmartSleep;
// 쾌적조리는 사양 9p 3.1 의 '독립 토글(연동 스위치)' — 후드 연결/가동·다른 시나리오와 무관하게 항상 토글 가능.
// 시나리오모드 버튼은 항상 선택 가능(클릭 가능). 다른 시나리오가 켜져 있어도 비활성화하지 않음.
// - 스마트수면 ↔ 안심회복은 펌웨어상 단일 Ext_Run_Mode 라 동시 불가 → 클릭 시 SubMode_Click 이 서로 전환.
// - 쾌적조리는 별도 변수(Hood_YeunDong_Enable)라 독립 토글.
SmartSleepBtn.IsEnabled = true;
ComfortCookBtn.IsEnabled = true;
ReliefRecoverBtn.IsEnabled = !subActive || _state.ReliefRecover;
ReliefRecoverBtn.IsEnabled = true;
// 스마트수면 시간설정 버튼 : 스마트수면 ON 일 때만 활성
SmartSleepSetBtn.IsEnabled = _state.SmartSleep;
foreach (var mb in _modeButtons) mb.IsEnabled = !subActive;
@@ -676,7 +636,6 @@ namespace ErvDashboard
protected override void OnClosed(EventArgs e)
{
_demoTimer.Stop();
_api.Dispose();
_logDb.Dispose();
base.OnClosed(e);