Files
HECO2/Simulator/ERVSimulator/Program/MainWindow.xaml.cs
T
jeon 5a96a696b1 chore: HERV 통합 저장소 초기 커밋
- 펌웨어(program), C# 대시보드(TestProgram), 시뮬레이터(Simulator),
  프로토콜/문서(Protocol, doc) 전체를 단일 저장소로 통합
- program 폴더의 별도 git 저장소를 제거하고 통합 저장소에 흡수
- 빌드 산출물(program/build, bin/obj, *.o/.elf/.bin/.hex 등) .gitignore 처리
- 사내 Synology NAS Git 원격 연결 예정

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:44:23 +09:00

486 lines
22 KiB
C#

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<RunMode>(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;
}
}