using System; using System.IO; using System.Text.RegularExpressions; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; using System.Windows.Shapes; using Microsoft.Win32; namespace DiffuserSimulator { public partial class MainWindow : Window { private readonly SlaveProtocol _slave = new(); // 각실 패널(레이아웃은 RoomPanel.xaml — 디자이너 편집). 컨트롤은 internal 필드로 직접 접근. private readonly RoomPanel[] _ui = new RoomPanel[5]; private bool _updating; private bool _himpel; // 제품 모드 : false=DL / true=힘펠 (전역) // 자동변경 : 거실→방1→방2→방3 순서로 30초마다 오염레벨 0→1→2→3→4 자동 적용 private readonly System.Windows.Threading.DispatcherTimer _autoTimer = new() { Interval = TimeSpan.FromSeconds(30) }; private int _autoStep; // 0..19 (room = step/5, level = step%5) private bool _autoRunning; private int _autoPreset = 1; // 자동변경 시 적용할 프리셋모드 (0 ECO/1 NORMAL/2 TURBO, 기본 NORMAL) private static readonly string[] RoomNames = { "거실", "방 1", "방 2", "방 3", "방 4" }; private static readonly Color[] RoomColors = { Color.FromRgb(0x7D,0xCF,0xFF), Color.FromRgb(0x9E,0xCE,0x6A), Color.FromRgb(0xE0,0xAF,0x68), Color.FromRgb(0xBB,0x9A,0xF7), Color.FromRgb(0xF7,0x76,0x8E) }; // 프리셋 값 — 히스테리시스 프리셋별 임계 밴드(CLAUDE.md)의 '중앙값'. // 선택한 프리셋모드에 맞춰 좋음=L0 / 보통=L1 / 나쁨=L2 / 매우나쁨=L3 / 최악(빨강)=L4 로 정확히 분류되도록 함. // [프리셋 0 ECO / 1 NORMAL / 2 TURBO / 3 힘펠][레벨 0~4] — index 4 = L4(임계 상한 초과, ERV 부하점수 4) // 힘펠 사양(룸컨 COLOR) : CO2 0-700/701-1000/1001-1500/1501↑, PM2.5 0-15/16-35/36-75/76↑, TVOC 0-100/101-400/401-1000/1001↑ // (힘펠은 PM10/VOC 임계가 99999 캡이라 Band 분류상 L4 도달 불가 → 4단계는 ECO/NORMAL/TURBO 용) private static readonly int[][] PrePM25 = { new[]{10,30,50,75,95}, new[]{7,22,40,60,80}, new[]{6,18,31,45,60}, new[]{7,25,55,90,110} }; private static readonly int[][] PrePM10 = { new[]{20,63,106,150,185}, new[]{14,47,85,120,150}, new[]{12,39,66,91,115}, new[]{0,0,0,0,0} }; private static readonly int[][] PreCO2 = { new[]{500,1150,1450,1800,2100}, new[]{400,950,1250,1550,1850}, new[]{350,850,1150,1450,1750}, new[]{350,850,1250,1750,1700} }; private static readonly int[][] PreVOC = { new[]{85,183,252,370,460}, new[]{60,135,200,300,400}, new[]{52,112,156,228,290}, new[]{17,115,270,408,500} }; private static readonly int[][] PreTVOC = { new[]{50,250,700,1500,1800}, new[]{50,250,700,1500,1800}, new[]{50,250,700,1500,1800}, new[]{50,250,700,1500,1800} }; // 분류용 상한 임계 [프리셋][L1~L3] (그 이상 = 매우나쁨) — ECO/NORMAL/TURBO 는 ErvState 와 동일, 힘펠은 룸컨 사양 private static readonly int[][] ThrCO2 = { new[]{1000,1300,1600,2000}, new[]{800,1100,1400,1700}, new[]{700,1000,1300,1600}, new[]{700,1000,1500,99999} }; private static readonly int[][] ThrPM25 = { new[]{20,38,60,86}, new[]{14,29,49,69}, new[]{12,23,38,52}, new[]{15,35,75,99999} }; private static readonly int[][] ThrPM10 = { new[]{40,86,126,173}, new[]{28,66,102,138}, new[]{24,53,78,104}, new[]{99999,99999,99999,99999} }; private static readonly int[][] ThrVOC = { new[]{171,195,308,438}, new[]{120,150,250,350}, new[]{103,120,192,263}, new[]{99999,99999,99999,99999} }; private static readonly byte[] PreStatus = { 0x04, 0x03, 0x02, 0x01, 0x01 }; /* L4 도 매우나쁨(0x01) */ private const int PresetNoSensor = 5; /* level 5 = 센서없음 (L0~3 + L4 최악) */ // 힘펠 제품 모드 : 공기질 레벨(0 좋음 / 1 보통 / 2 나쁨 / 3 매우나쁨, 4 최악) → 댐퍼 각도 자동 private static readonly byte[] HimpelDamperAngle = { 0, 50, 65, 110, 110 }; // 실별 선택 상태 : 프리셋모드(0 ECO/1 NORMAL/2 TURBO, 기본 NORMAL) / 공기질 레벨(0~3 or 센서없음, 기본 보통) private readonly int[] _roomPreset = { 1, 1, 1, 1, 1 }; private readonly int[] _roomQuality = { 1, 1, 1, 1, 1 }; private static int Band(int v, int[] t) => v <= t[0] ? 0 : v <= t[1] ? 1 : v <= t[2] ? 2 : v <= t[3] ? 3 : 4; public MainWindow() { InitializeComponent(); _slave.LogMessage += OnLog; _slave.MasterPacketReceived += OnMasterPacket; _slave.SlavePacketReceived += OnSlavePacket; _slave.ResponseSent += OnResponseSent; _slave.MasterPollSent += OnMasterPollSent; _slave.ConnectionChanged += OnConnectionChanged; BuildRoomPanels(); RefreshPorts(); ApplySlaveUi(); // 슬레이브 전용 UI 상태(각도 readonly 등) // 자동변경 프리셋모드 선택 버튼 (ECO/NORMAL/TURBO) RbAutoEco.Checked += (s, e) => SetAutoPreset(0); RbAutoNorm.Checked += (s, e) => SetAutoPreset(1); RbAutoTurbo.Checked += (s, e) => SetAutoPreset(2); _autoTimer.Tick += AutoTick; Closed += (_, _) => { _autoTimer.Stop(); _slave.Dispose(); }; } // ========== 5개 방 패널 생성 (레이아웃=RoomPanel.xaml, 동작=여기서 연결) ========== private void BuildRoomPanels() { for (int i = 0; i < 5; i++) { int idx = i; var u = new RoomPanel(); u.SetHeader(RoomNames[i], RoomColors[i]); _ui[i] = u; roomGrid.Children.Add(u); // ---- 헤더 활성 체크 ---- u.ChkEnabled.Checked += (s, e) => { var room = _slave.Rooms[idx]; // 처음 상태 reset: damper 0, LED 0, 센서 보통 preset, 양쪽 toggle OFF room.Enabled = true; room.DamperAngleSA = 0; room.DamperAngleEA = 0; room.LedBrightness = 0; room.PollSA = true; // Enabled 면 SA/RA 모두 응답 room.PollRA = true; // UI 동기화 (event re-entrant 차단) _updating = true; _ui[idx].TbPM25.Text = "25"; _ui[idx].TbPM10.Text = "30"; _ui[idx].TbCO2.Text = "850"; _ui[idx].TbVOC.Text = "115"; _ui[idx].TbTVOC.Text = "250"; _ui[idx].TbTemp.Text = "25"; _ui[idx].TbHumidity.Text = "50"; _ui[idx].TbSAAngle.Text = "0"; _ui[idx].TbEAAngle.Text = "0"; _ui[idx].SldLed.Value = 0; _ui[idx].TxtLedVal.Text = "0 (OFF)"; _ui[idx].TglSA.IsChecked = false; _ui[idx].TglEA.IsChecked = false; _ui[idx].RbNormal.IsChecked = true; // 거실(idx 0) : 거실2(ID2 0x00 = RA2/SA2)도 함께 활성·초기화 if (idx == 0) { var r2 = _slave.Rooms[SlaveProtocol.LivingRoom2Index]; r2.Enabled = true; r2.PollSA = true; r2.PollRA = true; r2.DamperAngleSA = 0; r2.DamperAngleEA = 0; _ui[0].TbEAAngle2.Text = "0"; _ui[0].TbSAAngle2.Text = "0"; } _updating = false; SyncRoomFromUI(idx); }; u.ChkEnabled.Unchecked += (s, e) => { _slave.Rooms[idx].Enabled = false; if (idx == 0) _slave.Rooms[SlaveProtocol.LivingRoom2Index].Enabled = false; }; _slave.Rooms[i].Enabled = (i == 0); // ---- 배기(RA) 디퓨저 ---- // Slave 모드: ON → master 의 RA polling 에 응답 / OFF → 무응답 // Master 모드: ON → RA 폴링 송신 / OFF → skip u.TglEA.Checked += (s, e) => { if (!_updating) _slave.Rooms[idx].PollRA = true; }; u.TglEA.Unchecked += (s, e) => { if (!_updating) _slave.Rooms[idx].PollRA = false; }; u.TbEAAngle.TextChanged += (s, e) => { if (_slave.Mode == SimMode.Master && byte.TryParse(u.TbEAAngle.Text, out byte v)) _slave.Rooms[idx].DamperAngleEA = (byte)Math.Min(v, (byte)0xB4); }; // 수동 닫기 (RA) — Slave 모드에서 마스터 개방명령 무시하고 닫힘 유지 u.ChkCloseRA.Checked += (s, e) => { if (_updating) return; _slave.Rooms[idx].ManualCloseRA = true; _slave.Rooms[idx].DamperAngleEA = 0; if (idx == 0) { var r2 = _slave.Rooms[SlaveProtocol.LivingRoom2Index]; r2.ManualCloseRA = true; r2.DamperAngleEA = 0; } RefreshAngleUI(idx); }; u.ChkCloseRA.Unchecked += (s, e) => { if (_updating) return; _slave.Rooms[idx].ManualCloseRA = false; if (idx == 0) _slave.Rooms[SlaveProtocol.LivingRoom2Index].ManualCloseRA = false; }; // ---- 공기질 센서값 ---- u.TbPM25.PreviewTextInput += NumericOnly; u.TbPM10.PreviewTextInput += NumericOnly; u.TbCO2.PreviewTextInput += NumericOnly; u.TbVOC.PreviewTextInput += NumericOnly; u.TbTVOC.PreviewTextInput += NumericOnly; u.TbTemp.PreviewTextInput += NumericOnly; u.TbHumidity.PreviewTextInput += NumericOnly; u.TbPM25.TextChanged += (s, e) => { if (!_updating) SyncRoomFromUI(idx); }; u.TbPM10.TextChanged += (s, e) => { if (!_updating) SyncRoomFromUI(idx); }; u.TbCO2.TextChanged += (s, e) => { if (!_updating) SyncRoomFromUI(idx); }; u.TbVOC.TextChanged += (s, e) => { if (!_updating) SyncRoomFromUI(idx); }; u.TbTVOC.TextChanged += (s, e) => { if (!_updating) SyncRoomFromUI(idx); }; u.TbTemp.TextChanged += (s, e) => { if (!_updating) SyncRoomFromUI(idx); }; u.TbHumidity.TextChanged += (s, e) => { if (!_updating) SyncRoomFromUI(idx); }; // 프리셋 (좋음/보통/나쁨/매우나쁨/최악/센서없음) u.RbGood.Checked += (s, e) => ApplyPreset(idx, 0); u.RbNormal.Checked += (s, e) => ApplyPreset(idx, 1); u.RbBad.Checked += (s, e) => ApplyPreset(idx, 2); u.RbVeryBad.Checked += (s, e) => ApplyPreset(idx, 3); u.RbWorst.Checked += (s, e) => ApplyPreset(idx, 4); u.RbNoSensor.Checked += (s, e) => ApplyPreset(idx, PresetNoSensor); // 프리셋모드 (ECO/NORMAL/TURBO/힘펠) u.RbEco.Checked += (s, e) => ApplyHystPreset(idx, 0); u.RbNorm.Checked += (s, e) => ApplyHystPreset(idx, 1); u.RbTurbo.Checked += (s, e) => ApplyHystPreset(idx, 2); // LED 슬라이더 + 수동 제어 u.SldLed.ValueChanged += (s, e) => { int v = (int)_ui[idx].SldLed.Value; _ui[idx].TxtLedVal.Text = v == 0 ? "0 (OFF)" : $"{v}단"; // Master 모드 또는 LED 수동 제어 시 슬라이더 값을 LED 밝기로 적용 if (_slave.Mode == SimMode.Master || _slave.Rooms[idx].ManualLed) _slave.Rooms[idx].LedBrightness = (byte)v; }; u.ChkLedManual.Checked += (s, e) => { if (_updating) return; _slave.Rooms[idx].ManualLed = true; _ui[idx].SldLed.IsEnabled = true; _slave.Rooms[idx].LedBrightness = (byte)_ui[idx].SldLed.Value; }; u.ChkLedManual.Unchecked += (s, e) => { if (_updating) return; _slave.Rooms[idx].ManualLed = false; // 수동 해제 시 슬라이더는 다시 마스터 명령 추종(Slave 모드면 읽기전용) _ui[idx].SldLed.IsEnabled = _slave.Mode == SimMode.Master; }; // ---- 급기(SA) 디퓨저 ---- u.TglSA.Checked += (s, e) => { if (!_updating) _slave.Rooms[idx].PollSA = true; }; u.TglSA.Unchecked += (s, e) => { if (!_updating) _slave.Rooms[idx].PollSA = false; }; u.TbSAAngle.TextChanged += (s, e) => { if (_slave.Mode == SimMode.Master && byte.TryParse(u.TbSAAngle.Text, out byte v)) _slave.Rooms[idx].DamperAngleSA = (byte)Math.Min(v, (byte)0xB4); }; u.ChkCloseSA.Checked += (s, e) => { if (_updating) return; _slave.Rooms[idx].ManualCloseSA = true; _slave.Rooms[idx].DamperAngleSA = 0; if (idx == 0) { var r2 = _slave.Rooms[SlaveProtocol.LivingRoom2Index]; r2.ManualCloseSA = true; r2.DamperAngleSA = 0; } RefreshAngleUI(idx); }; u.ChkCloseSA.Unchecked += (s, e) => { if (_updating) return; _slave.Rooms[idx].ManualCloseSA = false; if (idx == 0) _slave.Rooms[SlaveProtocol.LivingRoom2Index].ManualCloseSA = false; }; // ===== 거실 전용 : DL/힘펠 제품 모드 + RA2/SA2 (거실2 = ID2 0x00) ===== if (idx != 0) { // 방1~4 : RA2/SA2 자리는 비워두되 공간은 유지(Hidden) → 거실과 세로 정렬 u.GridEA2.Visibility = Visibility.Hidden; u.GridSA2.Visibility = Visibility.Hidden; } if (idx == 0) { u.GridEA2.Visibility = Visibility.Visible; u.GridSA2.Visibility = Visibility.Visible; u.TxtEALabel.Text = "RA1 각도"; u.TxtSALabel.Text = "SA1 각도"; u.TbEAAngle2.PreviewTextInput += NumericOnly; u.TbSAAngle2.PreviewTextInput += NumericOnly; u.TbEAAngle2.TextChanged += (s, e) => { if (_slave.Mode == SimMode.Master && byte.TryParse(u.TbEAAngle2.Text, out byte v)) _slave.Rooms[SlaveProtocol.LivingRoom2Index].DamperAngleEA = (byte)Math.Min(v, (byte)0xB4); }; u.TbSAAngle2.TextChanged += (s, e) => { if (_slave.Mode == SimMode.Master && byte.TryParse(u.TbSAAngle2.Text, out byte v)) _slave.Rooms[SlaveProtocol.LivingRoom2Index].DamperAngleSA = (byte)Math.Min(v, (byte)0xB4); }; } // Slave 모드 기본 : 댐퍼 토글/각도/LED 는 읽기전용(마스터 명령 표시용) u.TglSA.IsEnabled = false; u.TglEA.IsEnabled = false; u.TbSAAngle.IsReadOnly = true; u.TbEAAngle.IsReadOnly = true; u.TbSAAngle2.IsReadOnly = true; u.TbEAAngle2.IsReadOnly = true; u.SldLed.IsEnabled = false; // 초기 동기화 SyncRoomFromUI(i); } // ---- 초기값 : 거실, 방1~방3 활성(응답) + 센서 '좋음'. 댐퍼는 닫힘(각도0=토글OFF) ---- for (int i = 0; i < 4; i++) { _ui[i].ChkEnabled.IsChecked = true; // Enabled → SA/RA 응답 _ui[i].RbGood.IsChecked = true; // 공기질 '좋음' preset } _ui[4].RbGood.IsChecked = true; // 방4 기본 '좋음' (Enabled 는 제품모드가 제어) // 제품 모드 기본 = DL (전역) — LED 디밍 활성(거실·방1~3), RA2 비활성, 방4 비활성 ApplyProductMode(false); } // ========== 제품 모드(DL/힘펠) 전역 토글 ========== private void ProductMode_Click(object s, RoutedEventArgs e) => ApplyProductMode(!_himpel); // 전역 적용 // DL : byte24~25=VOC, LED 디밍 활성(거실·방1~3), RA2(거실 배기) 비활성, 방4 비활성화 // 힘펠 : byte24~25=TVOC, LED 디밍 비활성(전체), RA2 활성, 방4 활성화 private void ApplyProductMode(bool himpel) { _himpel = himpel; if (btnProductMode != null) btnProductMode.Content = himpel ? "힘펠" : "DL"; // 송신 모드(byte24/25 VOC vs TVOC) — 모든 방 + 거실2 for (int i = 0; i < 5; i++) _slave.Rooms[i].Himpel = himpel; var r2 = _slave.Rooms[SlaveProtocol.LivingRoom2Index]; r2.Himpel = himpel; r2.RaActive = himpel; // 거실 RA2 = 힘펠일 때만 응답 // 거실 RA2 입력 활성/비활성 _ui[0].GridEA2.IsEnabled = himpel; // 공기질 센서 입력 : 힘펠=TVOC 활성/VOC 비활성, DL=VOC 활성/TVOC 비활성 (전체 방) // 프리셋모드(ECO/NORMAL/TURBO) : DL=활성 / 힘펠=비활성 (전체 방) for (int i = 0; i < 5; i++) { _ui[i].TbTVOC.IsEnabled = himpel; _ui[i].TbVOC.IsEnabled = !himpel; _ui[i].RbEco.IsEnabled = !himpel; _ui[i].RbNorm.IsEnabled = !himpel; _ui[i].RbTurbo.IsEnabled = !himpel; } // 자동변경 프리셋모드 선택 버튼 : DL=활성 / 힘펠=비활성 if (RbAutoEco != null) { RbAutoEco.IsEnabled = !himpel; RbAutoNorm.IsEnabled = !himpel; RbAutoTurbo.IsEnabled = !himpel; } // LED 디밍 : DL=활성 / 힘펠=비활성 — 거실(0)·방1~3(1~3) for (int i = 0; i < 4; i++) SetLedDimming(i, enabled: !himpel); // 방4(idx 4) : DL=비활성화 / 힘펠=활성화(센서 기본 '좋음') SetRoomActive(4, active: himpel); if (himpel) _ui[4].RbGood.IsChecked = true; // 힘펠 전환 시 현재 공기질에 맞춰 댐퍼 각도 즉시 반영 if (himpel) for (int i = 0; i < 5; i++) SyncRoomFromUI(i); } // LED 디밍 수동 제어 활성/비활성 (방 1개) private void SetLedDimming(int idx, bool enabled) { var u = _ui[idx]; if (enabled) { u.ChkLedManual.IsEnabled = true; u.SldLed.IsEnabled = _slave.Mode == SimMode.Master || u.ChkLedManual.IsChecked == true; } else { _updating = true; u.ChkLedManual.IsChecked = false; _updating = false; _slave.Rooms[idx].ManualLed = false; u.ChkLedManual.IsEnabled = false; u.SldLed.IsEnabled = _slave.Mode == SimMode.Master; } } // 방 전체 활성/비활성 — 비활성 시 응답 중지(Enabled off) + 패널 잠금 private void SetRoomActive(int idx, bool active) { var u = _ui[idx]; if (u.ChkEnabled.IsChecked != active) u.ChkEnabled.IsChecked = active; // Checked/Unchecked 핸들러가 Rooms[idx].Enabled 처리 u.IsEnabled = active; // 패널 잠금/해제 } // ========== UI → RoomSimData 즉시 동기화 ========== private void SyncRoomFromUI(int idx) { var room = _slave.Rooms[idx]; var u = _ui[idx]; if (u == null) return; // 센서값만 UI에서 동기화 (제어값은 마스터에서만 변경) int.TryParse(u.TbPM10?.Text, out int pm10); room.PM10 = pm10; int.TryParse(u.TbTemp?.Text, out int temp); room.Temperature = temp; int.TryParse(u.TbHumidity?.Text, out int hum); room.Humidity = hum; int.TryParse(u.TbPM25?.Text, out int pm25); room.PM25 = pm25; int.TryParse(u.TbCO2?.Text, out int co2); room.CO2 = co2; int.TryParse(u.TbTVOC?.Text, out int tvoc); room.TVOC = tvoc; int.TryParse(u.TbVOC?.Text, out int voc); room.VOC = voc; // 공기질 상태 자동 계산 — 선택한 프리셋모드(ECO/NORMAL/TURBO)의 임계 밴드로 int p = _roomPreset[idx]; int worst = Math.Max( Math.Max(Band(pm25, ThrPM25[p]), Band(co2, ThrCO2[p])), Math.Max(Band(voc, ThrVOC[p]), Band(pm10, ThrPM10[p]))); room.AirQualityStatus = PreStatus[worst]; // 프리셋 라디오 버튼 동기화 (RbNoSensor 체크 상태면 skip — 사용자 선택 보존). if (u.RbGood != null && (u.RbNoSensor == null || u.RbNoSensor.IsChecked != true)) { _updating = true; switch (worst) { case 0: u.RbGood.IsChecked = true; break; case 1: u.RbNormal.IsChecked = true; break; case 2: u.RbBad.IsChecked = true; break; case 3: u.RbVeryBad.IsChecked = true; break; case 4: u.RbWorst.IsChecked = true; break; } _updating = false; } // 힘펠 제품 모드 : 공기질 레벨에 따라 댐퍼 각도 자동 (이미지 사양 0/50/65/110) if (_himpel) ApplyHimpelDamper(idx, worst); } // 힘펠 모드 자동 댐퍼 — 공기질 레벨(0~3) → 각도. SA/RA 동시 적용, 수동닫기 우선. private void ApplyHimpelDamper(int idx, int level) { byte ang = HimpelDamperAngle[level]; var room = _slave.Rooms[idx]; if (!room.ManualCloseSA) room.DamperAngleSA = ang; if (!room.ManualCloseRA) room.DamperAngleEA = ang; _updating = true; var u = _ui[idx]; u.TbSAAngle.Text = room.DamperAngleSA.ToString(); u.TbEAAngle.Text = room.DamperAngleEA.ToString(); u.TglSA.IsChecked = room.DamperAngleSA > 0; u.TglEA.IsChecked = room.DamperAngleEA > 0; // 거실(0) : 거실2(RA2/SA2)도 동일 적용 — RA2 는 힘펠일 때만 활성 if (idx == 0) { var r2 = _slave.Rooms[SlaveProtocol.LivingRoom2Index]; if (!r2.ManualCloseSA) r2.DamperAngleSA = ang; if (r2.RaActive && !r2.ManualCloseRA) r2.DamperAngleEA = ang; u.TbSAAngle2.Text = r2.DamperAngleSA.ToString(); u.TbEAAngle2.Text = r2.DamperAngleEA.ToString(); } _updating = false; } // ========== 프리셋 적용 ========== // level 0~4: 좋음 / 보통 / 나쁨 / 매우나쁨 / 최악(빨강) (Pre*[프리셋모드] 배열 lookup) // level 5 : 센서없음 — 모든 sensor TextBox 0 private void ApplyPreset(int idx, int level) { if (_updating) return; _roomQuality[idx] = level; _updating = true; var u = _ui[idx]; int p = _roomPreset[idx]; if (level == PresetNoSensor) { if (u?.TbPM25 != null) u.TbPM25.Text = "0"; if (u?.TbPM10 != null) u.TbPM10.Text = "0"; if (u?.TbCO2 != null) u.TbCO2.Text = "0"; if (u?.TbVOC != null) u.TbVOC.Text = "0"; if (u?.TbTVOC != null) u.TbTVOC.Text = "0"; if (u?.TbTemp != null) u.TbTemp.Text = "0"; if (u?.TbHumidity != null) u.TbHumidity.Text = "0"; } else { if (u?.TbPM25 != null) u.TbPM25.Text = PrePM25[p][level].ToString(); if (u?.TbPM10 != null) u.TbPM10.Text = PrePM10[p][level].ToString(); if (u?.TbCO2 != null) u.TbCO2.Text = PreCO2[p][level].ToString(); if (u?.TbTVOC != null) u.TbTVOC.Text = PreTVOC[p][level].ToString(); if (u?.TbVOC != null) u.TbVOC.Text = PreVOC[p][level].ToString(); } _updating = false; SyncRoomFromUI(idx); } // 프리셋모드(ECO/NORMAL/TURBO) 변경 → 선택 밴드로 현재 공기질 프리셋 재적용 private void ApplyHystPreset(int idx, int preset) { if (_updating) return; _roomPreset[idx] = preset; // 센서없음(5)은 값 0 유지, 그 외 좋음/보통/나쁨/매우나쁨/최악은 새 밴드 중앙값으로 재적용 if (_roomQuality[idx] != PresetNoSensor) ApplyPreset(idx, _roomQuality[idx]); else SyncRoomFromUI(idx); } // ========== UI 헬퍼 ========== // 수동 닫기 등으로 댐퍼 각도가 바뀐 즉시 UI 표시 갱신 private void RefreshAngleUI(int idx) { _updating = true; _ui[idx].TbSAAngle.Text = _slave.Rooms[idx].DamperAngleSA.ToString(); _ui[idx].TbEAAngle.Text = _slave.Rooms[idx].DamperAngleEA.ToString(); _ui[idx].TglSA.IsChecked = _slave.Rooms[idx].DamperAngleSA > 0; _ui[idx].TglEA.IsChecked = _slave.Rooms[idx].DamperAngleEA > 0; _updating = false; } // 숫자만 입력 허용 private void NumericOnly(object sender, TextCompositionEventArgs e) { e.Handled = !Regex.IsMatch(e.Text, @"^[0-9]$"); } // ========== 연결 ========== private void RefreshPorts() { cmbPort.Items.Clear(); foreach (var p in _slave.GetAvailablePorts()) cmbPort.Items.Add(p); if (cmbPort.Items.Count > 0) cmbPort.SelectedIndex = 0; } private void RefreshPorts_Click(object s, RoutedEventArgs e) => RefreshPorts(); private void Connect_Click(object s, RoutedEventArgs e) { if (_slave.IsConnected) { _slave.Disconnect(); btnConnect.Content = "연결"; ResetAllRooms(); // 연결해제 시 체크박스 / toggle / damper 초기화 } else { if (cmbPort.SelectedItem == null) { MessageBox.Show("COM 포트를 선택하세요."); return; } if (_slave.Connect(cmbPort.SelectedItem.ToString()!)) btnConnect.Content = "연결 해제"; } } /// /// 연결해제 시 호출 — 모든 방의 Enabled / Poll toggle OFF, damper 각도 0. /// _updating 플래그로 toggle 이벤트 chain 회피. /// private void ResetAllRooms() { _updating = true; try { for (int i = 0; i < 5; i++) { var room = _slave.Rooms[i]; room.Enabled = false; room.PollSA = false; room.PollRA = false; room.DamperAngleSA = 0; room.DamperAngleEA = 0; var u = _ui[i]; u.ChkEnabled.IsChecked = false; u.TglSA.IsChecked = false; u.TglEA.IsChecked = false; u.TbSAAngle.Text = "0"; u.TbEAAngle.Text = "0"; } // 거실2 (RA2/SA2) var r2 = _slave.Rooms[SlaveProtocol.LivingRoom2Index]; r2.Enabled = false; r2.PollSA = false; r2.PollRA = false; r2.DamperAngleSA = 0; r2.DamperAngleEA = 0; _ui[0].TbSAAngle2.Text = "0"; _ui[0].TbEAAngle2.Text = "0"; } finally { _updating = false; } } private void Start_Click(object s, RoutedEventArgs e) { if (!_slave.IsConnected) return; int interval = int.Parse(((ComboBoxItem)cmbInterval.SelectedItem).Content.ToString()!); _slave.StartResponding(interval); // 슬레이브 전용 btnStart.IsEnabled = false; btnStop.IsEnabled = true; } private void Stop_Click(object s, RoutedEventArgs e) { _slave.StopResponding(); btnStart.IsEnabled = true; btnStop.IsEnabled = false; } // ========== 슬레이브 전용 UI 상태 ========== // 각도 필드는 readonly(ERV가 댐퍼 제어), LED 슬라이더는 LED 수동제어 시에만 활성. private void ApplySlaveUi() { if (_ui == null || _ui[0] == null) return; for (int i = 0; i < 5; i++) { var u = _ui[i]; if (u == null) continue; u.TglSA.IsEnabled = true; u.TglEA.IsEnabled = true; u.TbSAAngle.IsReadOnly = true; u.TbEAAngle.IsReadOnly = true; u.TbSAAngle2.IsReadOnly = true; u.TbEAAngle2.IsReadOnly = true; u.SldLed.IsEnabled = u.ChkLedManual.IsChecked == true; } } // ========== 자동변경 : 거실→방1→방2→방3, 각 방 오염레벨 0~4를 30초 단위로 ========== private void AutoChange_Click(object s, RoutedEventArgs e) { if (_autoRunning) { _autoTimer.Stop(); _autoRunning = false; btnAutoChange.Content = "센서값 자동 변경"; OnLog("[자동변경] 중지"); return; } // 거실~방3(0~3) 활성화 (이미 켜져 있으면 무시) 후 선택 프리셋모드 밴드의 전체 0(좋음)에서 시작 for (int i = 0; i <= 3; i++) if (_ui[i].ChkEnabled.IsChecked != true) _ui[i].ChkEnabled.IsChecked = true; ApplyAutoPresetToRooms(); // 선택한 ECO/NORMAL/TURBO 밴드 적용 for (int r = 0; r <= 3; r++) ApplyPreset(r, 0); _autoStep = 0; _autoRunning = true; btnAutoChange.Content = "자동 변경 중지"; OnLog($"[자동변경] 시작({AutoPresetName(_autoPreset)}) — 전체 0에서 30초 대기 후 방1→방2→방3→거실 순 누적(0→4)"); _autoTimer.Start(); // 즉시 적용하지 않음 → 초기 0 0 0 0 을 30초 유지 후 첫 변경 } private static string AutoPresetName(int p) => p == 0 ? "ECO" : p == 2 ? "TURBO" : "NORMAL"; // 자동변경 프리셋모드 선택 → 실행 중이면 즉시 현재 방들에 새 밴드 재적용 private void SetAutoPreset(int preset) { _autoPreset = preset; if (_autoRunning) ApplyAutoPresetToRooms(); } // 선택한 자동변경 프리셋모드를 거실~방3(0~3) 패널에 반영 → 각 방의 센서 밴드(_roomPreset) 갱신 private void ApplyAutoPresetToRooms() { for (int i = 0; i <= 3; i++) { switch (_autoPreset) { case 0: _ui[i].RbEco.IsChecked = true; break; case 1: _ui[i].RbNorm.IsChecked = true; break; case 2: _ui[i].RbTurbo.IsChecked = true; break; } } } // 레벨 스윕(누적) : 매 30초 한 방씩 현재 레벨로 올림(방1→방2→방3→거실). // 한 바퀴(4방) 다 올리면 레벨+1. 앞서 올린 방은 값 유지(누적). 전체 4 도달 후 0으로 리셋 반복. private static readonly int[] AutoOrder = { 1, 2, 3, 0 }; // 방1, 방2, 방3, 거실 private void AutoTick(object? sender, EventArgs e) { if (_autoStep >= 16) // 4레벨 × 4방 완료 → 전체 0 리셋 후 새 사이클 { _autoStep = 0; for (int r = 0; r <= 3; r++) ApplyPreset(r, 0); OnLog("[자동변경] 사이클 완료 — 전체 0 리셋 후 반복"); } int level = _autoStep / 4 + 1; // 1~4 int room = AutoOrder[_autoStep % 4]; // 방1→방2→방3→거실 ApplyPreset(room, level); // 누적: 다른 방은 건드리지 않음 OnLog($"[자동변경] {RoomNames[room]} 오염레벨 {level}"); _autoStep++; } // ========== 마스터 패킷 수신 ========== private void OnMasterPacket(byte[] data, byte id2) { Dispatcher.Invoke(() => { int ri = SlaveProtocol.Id2ToIndex(id2); if (ri < 0) return; bool secondary = (id2 == 0); // 거실2 → 거실 패널의 RA2/SA2 var u = _ui[secondary ? 0 : ri]; u.RxCount++; u.TxtRxCount.Text = $"수신: {u.RxCount}"; // 마스터 제어 명령 → UI 동기화 (시각 만, PollSA/PollRA 변경 안 함) _updating = true; var room = _slave.Rooms[ri]; if (secondary) { // 거실2 : RA2/SA2 각도만 표시 u.TbSAAngle2.Text = room.DamperAngleSA.ToString(); u.TbEAAngle2.Text = room.DamperAngleEA.ToString(); _updating = false; return; } // LED — 수동 제어 중이면 슬라이더(사용자값) 보존 if (!room.ManualLed) { u.SldLed.Value = Math.Min(room.LedBrightness, (byte)9); u.TxtLedVal.Text = room.LedBrightness == 0 ? "0 (OFF)" : $"{room.LedBrightness}단"; } // 급기/배기 각도 + 댐퍼 토글(열림/닫힘) — 각도 연동 (Slave 모드, 마스터 명령 표시) u.TbSAAngle.Text = room.DamperAngleSA.ToString(); u.TbEAAngle.Text = room.DamperAngleEA.ToString(); u.TglSA.IsChecked = room.DamperAngleSA > 0; u.TglEA.IsChecked = room.DamperAngleEA > 0; // TglSA/TglEA visual 은 user 의 toggle 클릭으로만 변경 — master 응답 gate 역할. // 이전엔 damper 값에 따라 auto-sync 했으나, master polling 이 매 cycle 마다 // toggle 을 강제 ON 시키면서 user OFF 가 즉시 덮어쓰이는 문제 발생 → 제거. // 공기질 프리셋은 master 가 보내지 않음 (byte 9 = 0) — 사용자 선택 보존. _updating = false; }); } // ========== Slave 응답 수신 (Master Mode) ========== private void OnSlavePacket(byte[] data, byte id1, byte id2) { Dispatcher.Invoke(() => { int ri = SlaveProtocol.Id2ToIndex(id2); if (ri < 0) return; bool secondary = (id2 == 0); // 거실2 → 거실 패널의 RA2/SA2 var u = _ui[secondary ? 0 : ri]; var room = _slave.Rooms[ri]; u.RxCount++; u.TxtRxCount.Text = $"수신: {u.RxCount} (ID1=0x{id1:X2})"; _updating = true; if (secondary) { // 거실2 : RA2/SA2 각도만 표시 (센서는 거실 패널 공용 표시 유지) u.TbSAAngle2.Text = room.DamperAngleSA.ToString(); u.TbEAAngle2.Text = room.DamperAngleEA.ToString(); _updating = false; return; } // SEN66 값 UI 갱신 (STM32 slave 가 보낸 값) u.TbPM10.Text = room.PM10.ToString(); u.TbPM25.Text = room.PM25.ToString(); u.TbTemp.Text = room.Temperature.ToString(); u.TbHumidity.Text = room.Humidity.ToString(); u.TbCO2.Text = room.CO2.ToString(); u.TbVOC.Text = room.VOC.ToString(); u.TbTVOC.Text = room.TVOC.ToString(); _updating = false; }); } // ========== Master Polling 송신 콜백 (Master Mode) ========== private void OnMasterPollSent(byte id1, byte id2) { Dispatcher.Invoke(() => { int ri = SlaveProtocol.Id2ToIndex(id2); if (ri < 0) return; int panel = (id2 == 0) ? 0 : ri; // 거실2 → 거실 패널 표시 _ui[panel].TxtStatus.Text = $"→ Poll ID1=0x{id1:X2}"; _ui[panel].TxtStatus.Foreground = new SolidColorBrush(Color.FromRgb(0x7D, 0xCF, 0xFF)); }); } private void OnResponseSent(byte id2, bool responded) { Dispatcher.Invoke(() => { int ri = SlaveProtocol.Id2ToIndex(id2); if (ri < 0) return; var u = _ui[(id2 == 0) ? 0 : ri]; // 거실2 → 거실 패널 표시 if (responded) { u.TxtStatus.Text = "● 응답"; u.TxtStatus.Foreground = new SolidColorBrush(Color.FromRgb(0x9E, 0xCE, 0x6A)); } else { u.TxtStatus.Text = "✗ 무응답"; u.TxtStatus.Foreground = Brushes.Gray; } }); } // ========== 로그 ========== private void OnLog(string msg) { Dispatcher.Invoke(() => { txtLog.AppendText(msg + Environment.NewLine); if (txtLog.LineCount > 500) { var lines = txtLog.Text.Split(Environment.NewLine); txtLog.Text = string.Join(Environment.NewLine, lines[^300..]); } txtLog.ScrollToEnd(); }); } private void ClearLog_Click(object s, RoutedEventArgs e) => txtLog.Clear(); private void SaveLog_Click(object s, RoutedEventArgs e) { var dlg = new SaveFileDialog { Filter = "텍스트 파일 (*.txt)|*.txt", FileName = $"SimLog_{DateTime.Now:yyyyMMdd_HHmmss}.txt" }; if (dlg.ShowDialog() == true) { try { string h = $"========================================\r\n 디퓨져 시뮬레이터 통신 로그\r\n 저장 일시: {DateTime.Now:yyyy-MM-dd HH:mm:ss}\r\n========================================\r\n\r\n"; File.WriteAllText(dlg.FileName, h + txtLog.Text); MessageBox.Show($"저장 완료: {dlg.FileName}"); } catch (Exception ex) { MessageBox.Show($"저장 실패: {ex.Message}"); } } } private void OnConnectionChanged(bool connected) { Dispatcher.Invoke(() => { if (connected) { statusLed.Fill = new SolidColorBrush(Color.FromRgb(0x9E, 0xCE, 0x6A)); txtStatus.Text = "연결됨"; btnStart.IsEnabled = true; btnConnect.Content = "연결 해제"; } else { statusLed.Fill = new SolidColorBrush(Color.FromRgb(0xF7, 0x76, 0x8E)); txtStatus.Text = "미연결"; btnStart.IsEnabled = false; btnStop.IsEnabled = false; btnConnect.Content = "연결"; } }); } } }