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>
This commit is contained in:
2026-06-15 21:44:23 +09:00
commit 5a96a696b1
265 changed files with 76458 additions and 0 deletions
+300
View File
@@ -0,0 +1,300 @@
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 = "연결";
}
});
}
}
}