using System; using System.IO; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using Microsoft.Win32; namespace HoodSimulator { public partial class MainWindow : Window { readonly HoodProtocol _hood = new(); int _rxCount; // 조리 종료 후 메이크업 유지(잔여 냄새 배출) — 후드측이 담당. 유지중에는 ERV 에 계속 '켜짐' 보고, // 종료 시점에 OFF 전송 → ERV 가 원래 모드/풍량으로 복귀. (사양 260613 9p 3.3) readonly System.Windows.Threading.DispatcherTimer _makeupTimer = new() { Interval = TimeSpan.FromSeconds(1) }; const int MakeupHoldSec = 10; // 메이크업 유지 시간 (10초) int _makeupRemainSec; static readonly Brush AccentCyan = (Brush)new BrushConverter().ConvertFromString("#7DCFFF")!; static readonly Brush AccentGreen = (Brush)new BrushConverter().ConvertFromString("#9ECE6A")!; static readonly Brush AccentRed = (Brush)new BrushConverter().ConvertFromString("#F7768E")!; static readonly Brush CardBg = (Brush)new BrushConverter().ConvertFromString("#313147")!; static readonly Brush TextPrimary = (Brush)new BrushConverter().ConvertFromString("#C0CAF5")!; static readonly Brush TextSecondary = (Brush)new BrushConverter().ConvertFromString("#565F89")!; public MainWindow() { InitializeComponent(); _hood.LogMessage += OnLog; _hood.ConnectionChanged += OnConnectionChanged; _hood.MasterPacketReceived += OnMasterPacket; _hood.ResponseSent += OnResponseSent; _makeupTimer.Tick += MakeupTick; RefreshPorts(); UpdateFanButtons(); Closed += (_, _) => { _makeupTimer.Stop(); _hood.Dispose(); }; } // ========== 연결 ========== void RefreshPorts() { cmbPort.Items.Clear(); foreach (var p in _hood.GetAvailablePorts()) cmbPort.Items.Add(p); if (cmbPort.Items.Count > 0) cmbPort.SelectedIndex = 0; } void RefreshPorts_Click(object s, RoutedEventArgs e) => RefreshPorts(); void Connect_Click(object s, RoutedEventArgs e) { if (_hood.IsConnected) { _hood.Disconnect(); btnConnect.Content = "연결"; } else { if (cmbPort.SelectedItem == null) { MessageBox.Show("COM 포트를 선택하세요."); return; } if (_hood.Connect(cmbPort.SelectedItem.ToString()!)) btnConnect.Content = "연결 해제"; } } void Start_Click(object s, RoutedEventArgs e) { if (!_hood.IsConnected) return; _hood.StartResponding(); btnStart.IsEnabled = false; btnStop.IsEnabled = true; } void Stop_Click(object s, RoutedEventArgs e) { _hood.StopResponding(); btnStart.IsEnabled = true; btnStop.IsEnabled = false; } // ========== 후드 제어 ========== void Power_Click(object s, RoutedEventArgs e) { if (tglPower.IsChecked == true) { // 켜기 : 진행중인 메이크업 유지 취소 후 즉시 ON (풍량 1) StopMakeupHold(); _hood.PowerOn = true; _hood.FanStage = 1; txtPower.Text = "ON"; txtPower.Foreground = AccentGreen; UpdateFanButtons(); OnLog("[제어] 전원 → ON (풍량 1)"); } else { // 끄기 : OFF 표시 + 옆에 메이크업 유지(1분) 카운트다운 시작. 그동안 ERV엔 계속 '켜짐' 보고. // 유지 종료 시 후드 OFF 전송 → ERV 가 원래 모드/풍량으로 복귀. txtPower.Text = "OFF"; txtPower.Foreground = TextSecondary; if (_hood.PowerOn && _makeupRemainSec == 0) { _makeupRemainSec = MakeupHoldSec; _makeupTimer.Start(); txtMakeup.Text = $"메이크업 {_makeupRemainSec}s"; OnLog($"[제어] 전원 OFF 요청 → 메이크업 에어 {MakeupHoldSec}s 유지 (ERV엔 계속 켜짐 보고)"); } } } // 메이크업 유지 카운트다운 (1초). 0이 되면 실제 OFF 전송. void MakeupTick(object? s, EventArgs e) { _makeupRemainSec--; if (_makeupRemainSec > 0) { txtMakeup.Text = $"메이크업 {_makeupRemainSec}s"; } else { StopMakeupHold(); _hood.PowerOn = false; _hood.FanStage = 0; UpdateFanButtons(); OnLog("[제어] 메이크업 유지 종료 → 후드 OFF 전송 (ERV 원래 모드/풍량 복귀)"); } } void StopMakeupHold() { _makeupTimer.Stop(); _makeupRemainSec = 0; txtMakeup.Text = ""; } void Fan_Click(object s, RoutedEventArgs e) { if (s is Button b && b.Tag is string tag && byte.TryParse(tag, out var f)) { _hood.FanStage = f; UpdateFanButtons(); OnLog($"[제어] 풍량 → {f}{(f == 0 ? " (꺼짐)" : "단")}"); } } void Light_Click(object s, RoutedEventArgs e) { _hood.Light = tglLight.IsChecked == true; txtLight.Text = _hood.Light ? "ON" : "OFF"; txtLight.Foreground = _hood.Light ? AccentGreen : TextSecondary; OnLog($"[제어] 조명 → {(_hood.Light ? "ON" : "OFF")}"); } // 에러코드 토글 (FAN 에러=1 / 기타 에러=2). 둘 다 켜지면 FAN(1) 우선 송신. bool _errFan, _errEtc; void ErrorCard_Click(object s, System.Windows.Input.MouseButtonEventArgs e) { if (s is not Border b || b.Tag is not string tag) return; if (tag == "1") _errFan = !_errFan; else if (tag == "2") _errEtc = !_errEtc; _hood.ErrorCode = _errFan ? (byte)1 : _errEtc ? (byte)2 : (byte)0; UpdateErrorCards(); OnLog($"[제어] 에러코드 → {ErrorName(_hood.ErrorCode)} (ERROR={_hood.ErrorCode})"); // 에러 발생 시 전원 OFF / 풍량 0 / 조명 OFF (다음 응답에 반영되어 전송) if (_hood.ErrorCode != 0) { StopMakeupHold(); // 진행중인 메이크업 유지 즉시 취소 _hood.PowerOn = false; _hood.FanStage = 0; _hood.Light = false; tglPower.IsChecked = false; txtPower.Text = "OFF"; txtPower.Foreground = TextSecondary; tglLight.IsChecked = false; txtLight.Text = "OFF"; txtLight.Foreground = TextSecondary; UpdateFanButtons(); OnLog("[제어] 에러 발생 → 전원 OFF / 풍량 0 / 조명 OFF"); } } void UpdateErrorCards() { UpdateErrLed(ErrCard_Fan, ErrLed_Fan, _errFan); UpdateErrLed(ErrCard_Etc, ErrLed_Etc, _errEtc); } static void UpdateErrLed(Border card, System.Windows.Shapes.Ellipse led, bool on) { led.Fill = on ? AccentRed : (Brush)new BrushConverter().ConvertFromString("#3B3B55")!; card.BorderBrush = on ? AccentRed : (Brush)new BrushConverter().ConvertFromString("#3B3B55")!; card.BorderThickness = new Thickness(on ? 2 : 1); } static string ErrorName(byte e) => e switch { 1 => "FAN 에러", 2 => "기타 에러", _ => "정상" }; void UpdateFanButtons() { foreach (var child in FanPanel.Children) { if (child is Button btn && btn.Tag is string tag && byte.TryParse(tag, out var f)) { bool active = f == _hood.FanStage; btn.Background = active ? AccentCyan : CardBg; btn.Foreground = active ? Brushes.Black : TextPrimary; } } } // ========== 마스터 패킷 수신 ========== void OnMasterPacket(byte mode, byte fan, byte en, byte run) { Dispatcher.Invoke(() => { _rxCount++; txtRxCount.Text = $"수신: {_rxCount}"; txtRxMode.Text = $"{mode} ({ModeName(mode)})"; txtRxFan.Text = fan == 0 ? "0 (OFF)" : $"{fan}단"; txtRxEn.Text = en != 0 ? "Enable" : "Disable"; txtRxRun.Text = run != 0 ? "운전중" : "정지"; }); } // ========== 응답 송신 ========== void OnResponseSent(byte[] tx) { Dispatcher.Invoke(() => { txtTxFan.Text = tx[3] == 0 ? "0 (OFF)" : $"{tx[3]}단"; txtTxLight.Text = tx[4] != 0 ? "ON" : "OFF"; txtTxCmd.Text = tx[6] != 0 ? "1 (켜짐)" : "0 (꺼짐)"; txtTxError.Text = $"{tx[7]} ({ErrorName(tx[7])})"; }); } static string ModeName(byte m) => m switch { 0 => "OFF", 1 => "환기", 2 => "자동", 3 => "공청", 4 => "바이패스", 5 => "기타", _ => "?" }; // ========== 로그 ========== 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(); }); } void ClearLog_Click(object s, RoutedEventArgs e) => txtLog.Clear(); void SaveLog_Click(object s, RoutedEventArgs e) { var dlg = new SaveFileDialog { Filter = "텍스트 파일 (*.txt)|*.txt", FileName = $"HoodSimLog_{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}"); } } } void OnConnectionChanged(bool connected) { Dispatcher.Invoke(() => { if (connected) { statusLed.Fill = AccentGreen; txtStatus.Text = "연결됨"; btnStart.IsEnabled = true; btnConnect.Content = "연결 해제"; } else { statusLed.Fill = AccentRed; txtStatus.Text = "미연결"; btnStart.IsEnabled = false; btnStop.IsEnabled = false; btnConnect.Content = "연결"; } }); } } }