using System; using System.Globalization; using System.Windows; using System.Windows.Controls; using System.Windows.Data; using System.Windows.Media; using System.Windows.Threading; using ERVSimulator.Model; using ERVSimulator.Protocol; namespace ERVSimulator { public partial class MainWindow : Window { readonly ErvState _state = new(); readonly DamperSequencer _seq; readonly SerialChannel _roomConCh = new("RoomCon"); readonly SerialChannel _homeNetCh = new("HomeNet"); readonly SerialChannel _diffuserCh = new("Diffuser"); readonly SerialChannel _hoodCh = new("Hood"); readonly HomeNetProtocol _homeNet; readonly DiffuserMasterProtocol _diffuser; readonly HoodMasterProtocol _hood; readonly RoomConProtocol _roomCon; readonly AutoLogic _autoLogic; readonly DispatcherTimer _uiTick; readonly DispatcherTimer _reserveTick; // Tokyo Night palette refs static readonly Brush ConnectedLed = (Brush)new BrushConverter().ConvertFromString("#9ECE6A")!; static readonly Brush DisconnectedLed = (Brush)new BrushConverter().ConvertFromString("#F7768E")!; 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 AccentYellow = (Brush)new BrushConverter().ConvertFromString("#E0AF68")!; static readonly Brush AccentOrange = (Brush)new BrushConverter().ConvertFromString("#FF9E64")!; static readonly Brush AccentBlue = (Brush)new BrushConverter().ConvertFromString("#7AA2F7")!; static readonly Brush CardBg = (Brush)new BrushConverter().ConvertFromString("#313147")!; static readonly Brush TextSecondary = (Brush)new BrushConverter().ConvertFromString("#565F89")!; static readonly Brush TextPrimary = (Brush)new BrushConverter().ConvertFromString("#C0CAF5")!; static readonly Brush BorderColor = (Brush)new BrushConverter().ConvertFromString("#3B3B55")!; static readonly Brush LedOff = (Brush)new BrushConverter().ConvertFromString("#3B3B55")!; public MainWindow() { InitializeComponent(); _seq = new DamperSequencer(_state); _homeNet = new HomeNetProtocol(_homeNetCh, _state, _seq, Dispatcher); _diffuser = new DiffuserMasterProtocol(_diffuserCh, _state, Dispatcher); _hood = new HoodMasterProtocol(_hoodCh, _state, Dispatcher); _roomCon = new RoomConProtocol(_roomConCh, _state, _seq, Dispatcher); _autoLogic = new AutoLogic(_state, _seq); DamperItems.ItemsSource = _state.BodyDampers; RoomLoadItems.ItemsSource = _state.Rooms; // 자동운전 상태 - 각실 부하점수 _roomConCh.Log += Log; _homeNetCh.Log += Log; _diffuserCh.Log += Log; _hoodCh.Log += Log; _roomConCh.ConnectionChanged += b => Dispatcher.BeginInvoke(() => UpdateChannelLed(RoomConStatus, RoomConStatusText, b)); _homeNetCh.ConnectionChanged += b => Dispatcher.BeginInvoke(() => UpdateChannelLed(HomeNetStatus, HomeNetStatusText, b)); _diffuserCh.ConnectionChanged += b => Dispatcher.BeginInvoke(() => UpdateChannelLed(DiffuserStatus, DiffuserStatusText, b)); _hoodCh.ConnectionChanged += b => Dispatcher.BeginInvoke(() => UpdateChannelLed(HoodStatus, HoodStatusText, b)); _homeNet.PacketReceived += Log; _homeNet.PacketSent += Log; _diffuser.PacketReceived += Log; _diffuser.PacketSent += Log; _hood.PacketReceived += Log; _hood.PacketSent += Log; _roomCon.PacketReceived += Log; _roomCon.PacketSent += Log; _autoLogic.Log += Log; _state.PropertyChanged += (_, e) => Dispatcher.BeginInvoke(() => { UpdateTopState(); if (e.PropertyName == nameof(ErvState.ErrorCode) || e.PropertyName == nameof(ErvState.E02_TempSensor) || e.PropertyName == nameof(ErvState.E09_SaFan) || e.PropertyName == nameof(ErvState.E10_EaFan) || e.PropertyName == nameof(ErvState.COLD_Protect) || e.PropertyName == nameof(ErvState.E07_InternalComm)) { UpdateErrorIndicators(); } }); _uiTick = new DispatcherTimer(DispatcherPriority.Normal) { Interval = TimeSpan.FromMilliseconds(100) }; _uiTick.Tick += (_, _) => UpdateRealtime(); _uiTick.Start(); // (꺼짐)예약 1초 카운트다운 _reserveTick = new DispatcherTimer(DispatcherPriority.Normal) { Interval = TimeSpan.FromSeconds(1) }; _reserveTick.Tick += (_, _) => ReserveTick(); _reserveTick.Start(); RefreshPortsList(); UpdateTopState(); UpdateRealtime(); UpdateErrorIndicators(); } // ---- 시리얼 연결 ---- void RefreshPorts_Click(object sender, RoutedEventArgs e) => RefreshPortsList(); void RefreshPortsList() { var ports = SerialChannel.GetAvailablePorts(); RoomConPortCombo.ItemsSource = ports; HomeNetPortCombo.ItemsSource = ports; DiffuserPortCombo.ItemsSource = ports; HoodPortCombo.ItemsSource = ports; if (ports.Length > 0) { if (RoomConPortCombo.SelectedIndex < 0) RoomConPortCombo.SelectedIndex = 0; if (HomeNetPortCombo.SelectedIndex < 0) HomeNetPortCombo.SelectedIndex = ports.Length > 1 ? 1 : 0; if (DiffuserPortCombo.SelectedIndex < 0) DiffuserPortCombo.SelectedIndex = ports.Length > 2 ? 2 : 0; if (HoodPortCombo.SelectedIndex < 0) HoodPortCombo.SelectedIndex = ports.Length > 3 ? 3 : 0; } } void DiffuserConnect_Click(object sender, RoutedEventArgs e) { if (DiffuserPortCombo.SelectedItem is string p) _diffuserCh.Connect(p, 115200); } void DiffuserDisconnect_Click(object sender, RoutedEventArgs e) => _diffuserCh.Disconnect(); void HoodConnect_Click(object sender, RoutedEventArgs e) { if (HoodPortCombo.SelectedItem is string p) _hoodCh.Connect(p, 115200); } void HoodDisconnect_Click(object sender, RoutedEventArgs e) => _hoodCh.Disconnect(); void RoomConConnect_Click(object sender, RoutedEventArgs e) { if (RoomConPortCombo.SelectedItem is string p) _roomConCh.Connect(p, 9600); } void RoomConDisconnect_Click(object sender, RoutedEventArgs e) => _roomConCh.Disconnect(); void HomeNetConnect_Click(object sender, RoutedEventArgs e) { if (HomeNetPortCombo.SelectedItem is string p) { int baud = 9600; if (HomeNetBaudCombo.SelectedItem is ComboBoxItem item && int.TryParse(item.Content?.ToString(), out var b)) baud = b; _homeNetCh.Connect(p, baud); } } void HomeNetDisconnect_Click(object sender, RoutedEventArgs e) => _homeNetCh.Disconnect(); void UpdateChannelLed(System.Windows.Shapes.Ellipse led, TextBlock text, bool connected) { led.Fill = connected ? ConnectedLed : DisconnectedLed; text.Text = connected ? "연결됨" : "미연결"; text.Foreground = connected ? AccentGreen : TextSecondary; } // ---- 운전 모드 버튼 ---- void ModeButton_Click(object sender, RoutedEventArgs e) { if (sender is Button b && b.Tag is string tag && Enum.TryParse(tag, out var m)) { _state.RunMode = m; _state.SetRunMode = m; // 운전모드 전환 시 풍량 1단 (자동 제외 — 자동은 부하점수로 결정) if (m != RunMode.Auto) _state.FanMode = _state.SetFanMode = 1; _state.PowerOn = true; _seq.NotifyCommandChanged(); Log($"[Manual] Mode → {m}"); } } // ---- 풍량 0~4 ---- void FanButton_Click(object sender, RoutedEventArgs e) { if (sender is Button b && b.Tag is string tag && byte.TryParse(tag, out var f)) { _state.FanMode = _state.SetFanMode = f; if (f > 0) _state.PowerOn = true; _seq.NotifyCommandChanged(); Log($"[Manual] 풍량 → {f}단{(_state.RunMode == RunMode.Auto ? " (자동모드는 곧 부하점수로 재설정됨)" : "")}"); } } // ---- 자동모드 프리셋 (절전/표준/쾌속) : 자동에서만, 공기질 판정 임계 = 선택 프리셋 ---- void PresetButton_Click(object sender, RoutedEventArgs e) { if (_state.RunMode != RunMode.Auto) { Log("프리셋은 자동모드에서만 선택할 수 있습니다."); return; } if (sender is Button b && b.Tag is string tag && byte.TryParse(tag, out var p)) { _state.HystPreset = p; _seq.NotifyCommandChanged(); Log($"[Manual] 프리셋 → {(p == 0 ? "ECO" : p == 1 ? "NORMAL" : "TURBO")}"); } } // ---- 시나리오모드 (스마트수면/쾌적조리/안심회복) ---- void SubModeButton_Click(object sender, RoutedEventArgs e) { if (sender is not Button b || b.Tag is not string tag) return; switch (tag) { case "Sleep": _state.ExtRunMode = _state.SmartSleep ? (byte)0 : (byte)4; break; case "Recovery": _state.ExtRunMode = _state.RecoveryMode ? (byte)0 : (byte)1; break; case "Cook": _state.HoodEnable = !_state.HoodEnable; break; } if (_state.ExtRunMode != 0 || _state.HoodEnable) _state.PowerOn = true; _seq.NotifyCommandChanged(); Log($"[Manual] 시나리오모드 → {_state.SubModeText}"); UpdateTopState(); } // ---- 풍량 VSP 설정 팝업 ---- VspWindow? _vspWin; void OpenVsp_Click(object sender, RoutedEventArgs e) { if (_vspWin == null) { _vspWin = new VspWindow(_state) { Owner = this }; _vspWin.Applied += Log; _vspWin.Closed += (_, _) => _vspWin = null; _vspWin.Show(); } else _vspWin.Activate(); } // ---- 공기질 센서 히스테리시스 팝업 ---- HystWindow? _hystWin; void OpenHyst_Click(object sender, RoutedEventArgs e) { if (_hystWin == null) { _hystWin = new HystWindow(_state) { Owner = this }; _hystWin.Applied += Log; _hystWin.Closed += (_, _) => _hystWin = null; _hystWin.Show(); } else _hystWin.Activate(); } // ---- (꺼짐)예약 0~8시간 ---- bool _suppressReserveCombo; // 상태→콤보 동기화 중 ReserveCombo_Changed 재진입 차단 void ReserveCombo_Changed(object sender, SelectionChangedEventArgs e) { if (_suppressReserveCombo) return; if (ReserveCombo.SelectedIndex < 0) return; int hours = ReserveCombo.SelectedIndex; // 0=해제, 1~8시간 _state.ReserveHours = hours; _state.ReserveRemainSec = hours * 3600; Log(hours == 0 ? "[Manual] 예약 해제" : $"[Manual] {hours}시간 후 꺼짐 예약"); } // ---- 에러 카드 토글 ---- void ErrorCard_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) { if (sender is not Border b || b.Tag is not string tag) return; switch (tag) { case "E02": _state.E02_TempSensor = !_state.E02_TempSensor; break; case "E09": _state.E09_SaFan = !_state.E09_SaFan; break; case "E10": _state.E10_EaFan = !_state.E10_EaFan; break; case "COLD": _state.COLD_Protect = !_state.COLD_Protect; break; case "E07": _state.E07_InternalComm = !_state.E07_InternalComm; break; case "FCLEAN": _state.FilterClean = !_state.FilterClean; break; case "FCHANGE": _state.FilterChange = !_state.FilterChange; break; } Log($"[Manual] ErrorCode → 0x{_state.ErrorCode:X2}"); } // ---- UI 갱신 ---- void UpdateTopState() { // 전원 카드 강조 SetPowerCard(PowerOnCard, _state.PowerOn, AccentGreen); SetPowerCard(PowerOffCard, !_state.PowerOn, AccentRed); // 운전 모드 버튼 강조 foreach (var child in ModePanel.Children) { if (child is Button btn && btn.Tag is string tag) { bool active = tag == _state.RunMode.ToString(); btn.Background = active ? AccentCyan : CardBg; btn.Foreground = active ? Brushes.Black : TextPrimary; } } // 풍량 버튼 강조 foreach (var child in FanPanel.Children) { if (child is Button btn && btn.Tag is string tag) { bool active = tag == _state.FanMode.ToString(); btn.Background = active ? AccentBlue : CardBg; btn.Foreground = active ? Brushes.Black : TextPrimary; } } // 자동모드 프리셋 버튼 : 자동에서만 활성, 활성 프리셋 강조 (기본 표준=NORMAL) bool presetEnabled = _state.RunMode == RunMode.Auto; foreach (var child in PresetPanel.Children) { if (child is Button btn && btn.Tag is string tag) { bool active = presetEnabled && tag == _state.HystPreset.ToString(); btn.IsEnabled = presetEnabled; btn.Background = active ? AccentBlue : CardBg; btn.Foreground = active ? Brushes.Black : TextPrimary; } } // 시나리오모드 버튼 강조 foreach (var child in SubModePanel.Children) { if (child is Button btn && btn.Tag is string tag) { bool active = tag switch { "Sleep" => _state.SmartSleep, "Cook" => _state.CookingMode, "Recovery" => _state.RecoveryMode, _ => false }; btn.Background = active ? AccentOrange : CardBg; btn.Foreground = active ? Brushes.Black : TextPrimary; } } } static void SetPowerCard(Border card, bool active, Brush accent) { if (card.Child is TextBlock tb) { tb.Foreground = active ? Brushes.White : TextSecondary; } card.Background = active ? accent : CardBg; card.BorderBrush = active ? accent : BorderColor; } void UpdateErrorIndicators() { UpdateErrLed(ErrCard_E02, ErrLed_E02, _state.E02_TempSensor, AccentRed); UpdateErrLed(ErrCard_E09, ErrLed_E09, _state.E09_SaFan, AccentRed); UpdateErrLed(ErrCard_E10, ErrLed_E10, _state.E10_EaFan, AccentRed); UpdateErrLed(ErrCard_COLD, ErrLed_COLD, _state.COLD_Protect, AccentBlue); UpdateErrLed(ErrCard_E07, ErrLed_E07, _state.E07_InternalComm, AccentOrange); UpdateErrLed(ErrCard_FCLEAN, ErrLed_FCLEAN, _state.FilterClean, AccentYellow); UpdateErrLed(ErrCard_FCHANGE, ErrLed_FCHANGE, _state.FilterChange, AccentYellow); ErrorCodeHex.Text = $"ErrorCode = 0x{_state.ErrorCode:X2}"; } static void UpdateErrLed(Border card, System.Windows.Shapes.Ellipse led, bool on, Brush onColor) { led.Fill = on ? onColor : LedOff; card.BorderBrush = on ? onColor : BorderColor; card.BorderThickness = new Thickness(on ? 2 : 1); } void UpdateRealtime() { ReserveText.Text = _state.ReserveText; // 대시보드 등에서 CTRL_RESERVE 로 설정된 예약을 콤보에 반영(수신 명령도 ERVSim 에서 확인 가능) if (ReserveCombo.SelectedIndex != _state.ReserveHours && _state.ReserveHours >= 0 && _state.ReserveHours <= 8) { _suppressReserveCombo = true; ReserveCombo.SelectedIndex = _state.ReserveHours; _suppressReserveCombo = false; } AutoStateRun.Text = _state.AutoStateText; // 후드연동 버튼 — 쾌적조리(HoodEnable) ON 시 강조. 단, 통신중 후드 에러는 빨강+에러명 우선. bool hoodOnline = (DateTime.UtcNow - _hood.LastRxUtc) < TimeSpan.FromSeconds(2); if (hoodOnline && _state.HoodError != 0) { HoodLinkBtn.Background = AccentRed; HoodLinkBtn.Foreground = Brushes.Black; HoodLinkBtn.Content = _state.HoodError == 1 ? "후드연동 FAN에러" : "후드연동 기타에러"; } else { HoodLinkBtn.Background = _state.HoodEnable ? AccentOrange : CardBg; HoodLinkBtn.Foreground = _state.HoodEnable ? Brushes.Black : TextPrimary; HoodLinkBtn.Content = "후드연동"; } // 후드 통신 상태 (HoodSimulator 폴 응답 생존) — 후드연동 버튼 옆 표시 HoodCommLed.Fill = hoodOnline ? AccentGreen : AccentRed; HoodCommText.Text = hoodOnline ? "후드 통신 중" : "후드 통신 안됨"; HoodCommText.Foreground = hoodOnline ? AccentGreen : TextSecondary; } // (꺼짐)예약 1초 카운트다운 — 0 도달 시 전원 OFF void ReserveTick() { if (_state.ReserveRemainSec <= 0) return; _state.ReserveRemainSec--; if (_state.ReserveRemainSec == 0) { _state.ReserveHours = 0; _state.PowerOn = false; _state.FanMode = _state.SetFanMode = 0; _seq.NotifyCommandChanged(); if (ReserveCombo.SelectedIndex != 0) ReserveCombo.SelectedIndex = 0; Log("[예약] 예약시간 종료 → 전원 OFF"); } } // ---- 로그 (DiffuserSimulator 와 동일 : 읽기전용 TextBox, 텍스트 드래그 선택/복사 가능) ---- void Log(string msg) { var line = $"[{DateTime.Now:HH:mm:ss.fff}] {msg}"; Dispatcher.BeginInvoke(() => { LogList.AppendText(line + Environment.NewLine); if (LogList.LineCount > 500) { var lines = LogList.Text.Split(Environment.NewLine); LogList.Text = string.Join(Environment.NewLine, lines[^300..]); } LogList.ScrollToEnd(); }); } void ClearLog_Click(object sender, RoutedEventArgs e) => LogList.Clear(); void SaveLog_Click(object sender, RoutedEventArgs e) { var dlg = new Microsoft.Win32.SaveFileDialog { Filter = "텍스트 파일 (*.txt)|*.txt", FileName = $"ErvSimLog_{DateTime.Now:yyyyMMdd_HHmmss}.txt" }; if (dlg.ShowDialog() == true) { try { string h = $"========================================\r\n ERV 시뮬레이터 통신 로그\r\n 저장 일시: {DateTime.Now:yyyy-MM-dd HH:mm:ss}\r\n========================================\r\n\r\n"; System.IO.File.WriteAllText(dlg.FileName, h + LogList.Text); MessageBox.Show($"저장 완료: {dlg.FileName}"); } catch (Exception ex) { MessageBox.Show($"저장 실패: {ex.Message}"); } } } protected override void OnClosed(EventArgs e) { _roomConCh.Dispose(); _homeNetCh.Dispose(); _diffuserCh.Dispose(); _hoodCh.Dispose(); base.OnClosed(e); } } // ---- Converters ---- public class BoolToOpenCloseConverter : IValueConverter { public object Convert(object value, Type t, object p, CultureInfo c) => (value is bool b && b) ? "열림" : "닫힘"; public object ConvertBack(object v, Type t, object p, CultureInfo c) => DependencyProperty.UnsetValue; } public class BoolToBrushConverter : IValueConverter { static readonly Brush Open = (Brush)new BrushConverter().ConvertFromString("#9ECE6A")!; static readonly Brush Close = (Brush)new BrushConverter().ConvertFromString("#F7768E")!; public object Convert(object value, Type t, object p, CultureInfo c) => (value is bool b && b) ? Open : Close; public object ConvertBack(object v, Type t, object p, CultureInfo c) => DependencyProperty.UnsetValue; } public class ColorTagToBrushConverter : IValueConverter { public object Convert(object value, Type t, object p, CultureInfo c) => value switch { "GREEN" => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9ECE6A")!), "YELLOW" => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E0AF68")!), "RED" => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#F7768E")!), "BLACK" => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#1E1E2E")!), "BLUE" => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#7AA2F7")!), "WHITE" => new SolidColorBrush(Colors.WhiteSmoke), _ => new SolidColorBrush(Colors.Gray), }; public object ConvertBack(object v, Type t, object p, CultureInfo c) => DependencyProperty.UnsetValue; } }