chore: HERV 통합 저장소 재초기화 커밋

손상된 .git 히스토리(missing tree)로 재초기화 후 작업트리 전체 커밋.
.claude/ 만 제외(로컬 에이전트 설정). 구 저장소 백업(.git_corrupt_backup/) 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jeon
2026-06-16 09:29:03 +09:00
commit a502322188
630 changed files with 65126 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
<Application x:Class="HoodSimulator.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<Color x:Key="PrimaryBg">#1E1E2E</Color>
<Color x:Key="SecondaryBg">#2B2B3D</Color>
<Color x:Key="CardBg">#313147</Color>
<Color x:Key="AccentBlue">#7AA2F7</Color>
<Color x:Key="AccentGreen">#9ECE6A</Color>
<Color x:Key="AccentRed">#F7768E</Color>
<Color x:Key="AccentYellow">#E0AF68</Color>
<Color x:Key="AccentCyan">#7DCFFF</Color>
<Color x:Key="AccentPurple">#BB9AF7</Color>
<Color x:Key="TextPrimary">#C0CAF5</Color>
<Color x:Key="TextSecondary">#565F89</Color>
<Color x:Key="BorderColor">#3B3B55</Color>
<SolidColorBrush x:Key="PrimaryBgBrush" Color="{StaticResource PrimaryBg}"/>
<SolidColorBrush x:Key="SecondaryBgBrush" Color="{StaticResource SecondaryBg}"/>
<SolidColorBrush x:Key="CardBgBrush" Color="{StaticResource CardBg}"/>
<SolidColorBrush x:Key="AccentBlueBrush" Color="{StaticResource AccentBlue}"/>
<SolidColorBrush x:Key="AccentGreenBrush" Color="{StaticResource AccentGreen}"/>
<SolidColorBrush x:Key="AccentRedBrush" Color="{StaticResource AccentRed}"/>
<SolidColorBrush x:Key="AccentYellowBrush" Color="{StaticResource AccentYellow}"/>
<SolidColorBrush x:Key="AccentCyanBrush" Color="{StaticResource AccentCyan}"/>
<SolidColorBrush x:Key="AccentPurpleBrush" Color="{StaticResource AccentPurple}"/>
<SolidColorBrush x:Key="TextPrimaryBrush" Color="{StaticResource TextPrimary}"/>
<SolidColorBrush x:Key="TextSecondaryBrush" Color="{StaticResource TextSecondary}"/>
<SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}"/>
<Style x:Key="ModernButton" TargetType="Button">
<Setter Property="Background" Value="{StaticResource AccentBlueBrush}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="18,8"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="border" Background="{TemplateBinding Background}"
CornerRadius="6" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Opacity" Value="0.85"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="border" Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ToggleSwitch" TargetType="ToggleButton">
<Setter Property="Width" Value="56"/>
<Setter Property="Height" Value="28"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Grid>
<Border x:Name="track" CornerRadius="14" Background="#3B3B55" Width="56" Height="28"/>
<Border x:Name="thumb" CornerRadius="11" Background="#565F89" Width="22" Height="22"
HorizontalAlignment="Left" Margin="3,0,0,0"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="track" Property="Background" Value="{StaticResource AccentGreenBrush}"/>
<Setter TargetName="thumb" Property="Background" Value="White"/>
<Setter TargetName="thumb" Property="HorizontalAlignment" Value="Right"/>
<Setter TargetName="thumb" Property="Margin" Value="0,0,3,0"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ModernComboBox" TargetType="ComboBox">
<Setter Property="Background" Value="{StaticResource CardBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,5"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="ItemContainerStyle">
<Setter.Value>
<Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="Black"/>
</Style>
</Setter.Value>
</Setter>
</Style>
</Application.Resources>
</Application>
+8
View File
@@ -0,0 +1,8 @@
using System.Windows;
namespace HoodSimulator
{
public partial class App : Application
{
}
}
+180
View File
@@ -0,0 +1,180 @@
using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;
namespace HoodSimulator
{
// 후드메인(Slave) 시뮬레이터 프로토콜
// 규격 : Protocol/HOOD/주신전자_protocol_hood_전열교환기_Rev1.3_20241125.xlsx
// - 9바이트 고정, 115200 8N1, 폴링주기 100~500ms, 응답 50ms 이내
// - CS = Preamble~CS직전(byte0~7) 전체 XOR
// Master(전열교환기) → Slave(후드) : AA 21 ID MODE FAN 연동EN 연동운전중 ERROR CS
// Slave(후드) → Master(전열교환기) : AA 11 ID FANSTATUS LIGHTSTATUS 00 연동CMD ERROR CS
// 시뮬레이터는 Slave 역할 — 마스터 폴 수신 시 현재 후드 상태로 응답.
public class HoodProtocol : IDisposable
{
public const byte PREAMBLE = 0xAA;
public const byte MS_MASTER = 0x21;
public const byte MS_SLAVE = 0x11;
public const byte HOOD_ID = 0x01;
public const int FRAME_LEN = 9;
SerialPort? _port;
CancellationTokenSource? _cts;
readonly object _lock = new();
bool _disposed;
bool _responding;
// ---- 후드 상태 (UI 제어) ----
public bool PowerOn; // 전원 on/off
public byte FanStage; // 풍량 0(꺼짐)~5
public bool Light; // 조명 on/off
public byte ErrorCode; // ERROR : 0 정상 / 1 FAN 에러 / 2 기타 에러
public event Action<byte, byte, byte, byte>? MasterPacketReceived; // mode, fan, en, run
public event Action<byte[]>? ResponseSent; // 송신한 9바이트 응답
public event Action<string>? LogMessage;
public event Action<bool>? ConnectionChanged;
public bool IsConnected => _port?.IsOpen == true;
public bool IsResponding => _responding;
public static byte Xor(byte[] d, int start, int len)
{
byte x = 0;
for (int i = 0; i < len; i++) x ^= d[start + i];
return x;
}
public string[] GetAvailablePorts() => SerialPort.GetPortNames();
public bool Connect(string portName)
{
try
{
Disconnect();
_port = new SerialPort(portName)
{
BaudRate = 115200, DataBits = 8,
StopBits = StopBits.One, Parity = Parity.None,
ReadTimeout = 100, WriteTimeout = 500
};
_port.Open();
Log($"[연결] {portName} (115200, 8N1)");
ConnectionChanged?.Invoke(true);
return true;
}
catch (Exception ex)
{
Log($"[오류] 연결 실패: {ex.Message}");
ConnectionChanged?.Invoke(false);
return false;
}
}
public void Disconnect()
{
StopResponding();
try { if (_port?.IsOpen == true) { _port.Close(); Log("[연결 해제]"); } } catch { }
_port?.Dispose();
_port = null;
ConnectionChanged?.Invoke(false);
}
public void StartResponding()
{
StopResponding();
_responding = true;
_cts = new CancellationTokenSource();
var token = _cts.Token;
Task.Run(() =>
{
while (!token.IsCancellationRequested)
{
try
{
if (_port?.IsOpen != true) { Thread.Sleep(50); continue; }
if (_port.BytesToRead < 1) { Thread.Sleep(3); continue; }
byte b = (byte)_port.ReadByte();
if (b != PREAMBLE) continue;
byte[] rx = new byte[FRAME_LEN];
rx[0] = PREAMBLE;
int total = 1, remain = FRAME_LEN - 1, retries = 100;
while (remain > 0 && retries-- > 0)
{
if (_port.BytesToRead > 0)
{ int r = _port.Read(rx, total, remain); total += r; remain -= r; }
else Thread.Sleep(2);
}
if (total < FRAME_LEN) continue;
// 마스터 프레임만 처리
if (rx[1] != MS_MASTER) continue;
if (rx[2] != HOOD_ID) continue;
byte cs = Xor(rx, 0, 8);
if (cs != rx[8]) { Log($"[CS오류] 수신:0x{rx[8]:X2} 계산:0x{cs:X2} {BitConverter.ToString(rx)}"); continue; }
byte mode = rx[3], fan = rx[4], en = rx[5], run = rx[6];
Log($"[RX] {BitConverter.ToString(rx)} MODE={mode} FAN={fan} 연동EN={en} 연동운전={run}");
MasterPacketReceived?.Invoke(mode, fan, en, run);
// 응답 전송 (현재 후드 상태)
byte[] tx = BuildResponse();
lock (_lock) { _port?.Write(tx, 0, tx.Length); }
Log($"[TX 응답] {BitConverter.ToString(tx)}");
ResponseSent?.Invoke(tx);
}
catch (TimeoutException) { }
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
if (!token.IsCancellationRequested) Log($"[오류] {ex.Message}");
Thread.Sleep(100);
}
}
}, token);
Log("[통신 시작] 마스터 응답 모드");
}
public void StopResponding()
{
_responding = false;
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
}
public byte[] BuildResponse()
{
byte fanStatus = PowerOn ? FanStage : (byte)0; // 후드 FAN STATUS 0~5
byte lightStatus = (byte)((PowerOn && Light) ? 1 : 0); // 후드 LIGHT STATUS
byte cmd = (byte)(PowerOn ? 1 : 0); // 연동 CMD : 0 꺼짐 / 1 켜짐
byte[] p = new byte[FRAME_LEN];
p[0] = PREAMBLE;
p[1] = MS_SLAVE;
p[2] = HOOD_ID;
p[3] = fanStatus;
p[4] = lightStatus;
p[5] = 0x00;
p[6] = cmd;
p[7] = ErrorCode; // ERROR : 0 정상 / 1 FAN / 2 기타
p[8] = Xor(p, 0, 8); // CS
return p;
}
void Log(string msg) => LogMessage?.Invoke($"[{DateTime.Now:HH:mm:ss.fff}] {msg}");
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Disconnect();
GC.SuppressFinalize(this);
}
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<RootNamespace>HoodSimulator</RootNamespace>
<AssemblyName>HoodSimulator</AssemblyName>
<StartupObject>HoodSimulator.App</StartupObject>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.IO.Ports" Version="10.0.5" />
</ItemGroup>
</Project>
+163
View File
@@ -0,0 +1,163 @@
<Window x:Class="HoodSimulator.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="후드 시뮬레이터 - Hood Simulator"
Width="500" Height="820"
MinWidth="460" MinHeight="700"
Background="{StaticResource PrimaryBgBrush}"
WindowStartupLocation="CenterScreen">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <!-- 0: 연결 -->
<RowDefinition Height="Auto"/> <!-- 1: 후드 제어 -->
<RowDefinition Height="Auto"/> <!-- 2: 통신 상태 -->
<RowDefinition Height="*"/> <!-- 3: 로그 -->
</Grid.RowDefinitions>
<!-- Row 0: 연결 설정 (2줄) -->
<Border Grid.Row="0" Background="{StaticResource SecondaryBgBrush}"
CornerRadius="10" Padding="14,10" Margin="0,0,0,8">
<StackPanel>
<DockPanel Margin="0,0,0,8">
<TextBlock Text="후드 시뮬레이터" FontSize="17" FontWeight="Bold"
Foreground="{StaticResource AccentCyanBrush}" VerticalAlignment="Center"/>
<StackPanel DockPanel.Dock="Right" VerticalAlignment="Center" HorizontalAlignment="Right">
<TextBlock Text="만든이 : 전경선" Foreground="{StaticResource TextSecondaryBrush}" FontSize="10" TextAlignment="Right"/>
<TextBlock Text="수정일 : 2026.06.07" Foreground="{StaticResource TextSecondaryBrush}" FontSize="10" TextAlignment="Right"/>
</StackPanel>
</DockPanel>
<StackPanel Orientation="Horizontal">
<ComboBox x:Name="cmbPort" Width="92" Style="{StaticResource ModernComboBox}"
VerticalAlignment="Center" Margin="0,0,4,0"/>
<Button Content="⟳" Width="28" Height="28" FontSize="13"
Style="{StaticResource ModernButton}" Click="RefreshPorts_Click"
Background="{StaticResource CardBgBrush}" Margin="0,0,8,0" Padding="0"/>
<Ellipse x:Name="statusLed" Width="10" Height="10" Fill="#F7768E" Margin="0,0,5,0" VerticalAlignment="Center"/>
<TextBlock x:Name="txtStatus" Text="미연결" Foreground="{StaticResource TextSecondaryBrush}"
FontSize="12" VerticalAlignment="Center" Margin="0,0,8,0"/>
<Button x:Name="btnConnect" Content="연결" Style="{StaticResource ModernButton}"
Click="Connect_Click" Margin="0,0,4,0" Padding="12,6"/>
<Button x:Name="btnStart" Content="시작" Style="{StaticResource ModernButton}"
Background="{StaticResource AccentGreenBrush}" Click="Start_Click"
IsEnabled="False" Margin="0,0,4,0" Padding="12,6"/>
<Button x:Name="btnStop" Content="중지" Style="{StaticResource ModernButton}"
Background="{StaticResource AccentRedBrush}" Click="Stop_Click"
IsEnabled="False" Padding="12,6"/>
</StackPanel>
</StackPanel>
</Border>
<!-- Row 1: 후드 제어 -->
<Border Grid.Row="1" Background="{StaticResource SecondaryBgBrush}" CornerRadius="10" Padding="16" Margin="0,0,0,8">
<StackPanel>
<TextBlock Text="후드 제어" FontSize="14" FontWeight="Bold" Foreground="{StaticResource TextPrimaryBrush}" Margin="0,0,0,14"/>
<!-- 전원 -->
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<TextBlock Text="전원" Width="56" FontSize="13" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center"/>
<ToggleButton x:Name="tglPower" Style="{StaticResource ToggleSwitch}" Click="Power_Click" VerticalAlignment="Center"/>
<TextBlock x:Name="txtPower" Text="OFF" FontSize="13" FontWeight="Bold" Margin="12,0,0,0"
Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center"/>
<TextBlock x:Name="txtMakeup" Text="" FontSize="13" FontWeight="Bold" Margin="10,0,0,0"
Foreground="{StaticResource AccentCyanBrush}" VerticalAlignment="Center"/>
</StackPanel>
<!-- 풍량 -->
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<TextBlock Text="풍량" Width="56" FontSize="13" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center"/>
<StackPanel Orientation="Horizontal" x:Name="FanPanel">
<Button Content="0" Tag="0" Click="Fan_Click" Style="{StaticResource ModernButton}" Margin="0,0,4,0" MinWidth="40" Padding="0,7" Background="{StaticResource CardBgBrush}" ToolTip="꺼짐"/>
<Button Content="1" Tag="1" Click="Fan_Click" Style="{StaticResource ModernButton}" Margin="0,0,4,0" MinWidth="40" Padding="0,7" Background="{StaticResource CardBgBrush}"/>
<Button Content="2" Tag="2" Click="Fan_Click" Style="{StaticResource ModernButton}" Margin="0,0,4,0" MinWidth="40" Padding="0,7" Background="{StaticResource CardBgBrush}"/>
<Button Content="3" Tag="3" Click="Fan_Click" Style="{StaticResource ModernButton}" Margin="0,0,4,0" MinWidth="40" Padding="0,7" Background="{StaticResource CardBgBrush}"/>
<Button Content="4" Tag="4" Click="Fan_Click" Style="{StaticResource ModernButton}" Margin="0,0,4,0" MinWidth="40" Padding="0,7" Background="{StaticResource CardBgBrush}"/>
<Button Content="5" Tag="5" Click="Fan_Click" Style="{StaticResource ModernButton}" Margin="0,0,0,0" MinWidth="40" Padding="0,7" Background="{StaticResource CardBgBrush}"/>
</StackPanel>
</StackPanel>
<!-- 조명 -->
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<TextBlock Text="조명" Width="56" FontSize="13" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center"/>
<ToggleButton x:Name="tglLight" Style="{StaticResource ToggleSwitch}" Click="Light_Click" VerticalAlignment="Center"/>
<TextBlock x:Name="txtLight" Text="OFF" FontSize="13" FontWeight="Bold" Margin="12,0,0,0"
Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center"/>
</StackPanel>
<!-- 에러코드 (체크 선택하여 발생) -->
<StackPanel Orientation="Horizontal">
<TextBlock Text="에러코드" Width="56" FontSize="13" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center"/>
<Border x:Name="ErrCard_Fan" Tag="1" MouseDown="ErrorCard_Click" Cursor="Hand" CornerRadius="6" Padding="8,5" Margin="0,0,6,0"
Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" ToolTip="FAN 에러 (ERROR=1)">
<StackPanel Orientation="Horizontal">
<Ellipse x:Name="ErrLed_Fan" Width="10" Height="10" Fill="#3B3B55" VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="FAN 에러" FontSize="12" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}"/>
</StackPanel>
</Border>
<Border x:Name="ErrCard_Etc" Tag="2" MouseDown="ErrorCard_Click" Cursor="Hand" CornerRadius="6" Padding="8,5"
Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" ToolTip="기타 에러 (ERROR=2)">
<StackPanel Orientation="Horizontal">
<Ellipse x:Name="ErrLed_Etc" Width="10" Height="10" Fill="#3B3B55" VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="기타 에러" FontSize="12" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}"/>
</StackPanel>
</Border>
</StackPanel>
</StackPanel>
</Border>
<!-- Row 2: 통신 상태 -->
<Border Grid.Row="2" Background="{StaticResource SecondaryBgBrush}" CornerRadius="10" Padding="16" Margin="0,0,0,8">
<StackPanel>
<DockPanel Margin="0,0,0,12">
<TextBlock Text="통신 상태" FontSize="14" FontWeight="Bold" Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock x:Name="txtRxCount" DockPanel.Dock="Right" Text="수신: 0" FontSize="12"
Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center"/>
</DockPanel>
<TextBlock Text="◇ 마스터 수신 명령 (전열교환기 → 후드)" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource AccentCyanBrush}" Margin="0,0,0,6"/>
<Border Background="{StaticResource CardBgBrush}" CornerRadius="6" Padding="12,8" Margin="0,0,0,12"
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1">
<StackPanel>
<DockPanel Margin="0,0,0,3"><TextBlock Text="운전모드 (MODE)" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"/><TextBlock x:Name="txtRxMode" DockPanel.Dock="Right" Text="-" FontSize="12" FontWeight="Bold" Foreground="{StaticResource TextPrimaryBrush}"/></DockPanel>
<DockPanel Margin="0,0,0,3"><TextBlock Text="전열교환기 풍량 (FAN)" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"/><TextBlock x:Name="txtRxFan" DockPanel.Dock="Right" Text="-" FontSize="12" FontWeight="Bold" Foreground="{StaticResource TextPrimaryBrush}"/></DockPanel>
<DockPanel Margin="0,0,0,3"><TextBlock Text="연동 Enable" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"/><TextBlock x:Name="txtRxEn" DockPanel.Dock="Right" Text="-" FontSize="12" FontWeight="Bold" Foreground="{StaticResource TextPrimaryBrush}"/></DockPanel>
<DockPanel><TextBlock Text="연동 운전중" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"/><TextBlock x:Name="txtRxRun" DockPanel.Dock="Right" Text="-" FontSize="12" FontWeight="Bold" Foreground="{StaticResource TextPrimaryBrush}"/></DockPanel>
</StackPanel>
</Border>
<TextBlock Text="◇ 후드 응답 송신 (후드 → 전열교환기)" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource AccentGreenBrush}" Margin="0,0,0,6"/>
<Border Background="{StaticResource CardBgBrush}" CornerRadius="6" Padding="12,8"
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1">
<StackPanel>
<DockPanel Margin="0,0,0,3"><TextBlock Text="후드 FAN STATUS" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"/><TextBlock x:Name="txtTxFan" DockPanel.Dock="Right" Text="-" FontSize="12" FontWeight="Bold" Foreground="{StaticResource AccentGreenBrush}"/></DockPanel>
<DockPanel Margin="0,0,0,3"><TextBlock Text="후드 LIGHT STATUS" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"/><TextBlock x:Name="txtTxLight" DockPanel.Dock="Right" Text="-" FontSize="12" FontWeight="Bold" Foreground="{StaticResource AccentGreenBrush}"/></DockPanel>
<DockPanel Margin="0,0,0,3"><TextBlock Text="연동 CMD" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"/><TextBlock x:Name="txtTxCmd" DockPanel.Dock="Right" Text="-" FontSize="12" FontWeight="Bold" Foreground="{StaticResource AccentGreenBrush}"/></DockPanel>
<DockPanel><TextBlock Text="ERROR" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"/><TextBlock x:Name="txtTxError" DockPanel.Dock="Right" Text="-" FontSize="12" FontWeight="Bold" Foreground="{StaticResource AccentGreenBrush}"/></DockPanel>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- Row 3: 통신 로그 -->
<Border Grid.Row="3" Background="{StaticResource SecondaryBgBrush}" CornerRadius="10" Padding="12">
<DockPanel>
<Grid DockPanel.Dock="Top" Margin="0,0,0,5">
<TextBlock Text="통신 로그" FontSize="12" FontWeight="Bold" Foreground="{StaticResource TextPrimaryBrush}"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="저장" Style="{StaticResource ModernButton}" Background="{StaticResource AccentBlueBrush}"
Padding="10,3" FontSize="11" Click="SaveLog_Click" Margin="0,0,6,0"/>
<Button Content="지우기" Style="{StaticResource ModernButton}" Background="{StaticResource CardBgBrush}"
Padding="10,3" FontSize="11" Click="ClearLog_Click"/>
</StackPanel>
</Grid>
<TextBox x:Name="txtLog" IsReadOnly="True" Background="{StaticResource CardBgBrush}"
Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" FontFamily="Consolas" FontSize="10"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
TextWrapping="NoWrap" Padding="6"/>
</DockPanel>
</Border>
</Grid>
</Window>
+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 = "연결";
}
});
}
}
}
+56
View File
@@ -0,0 +1,56 @@
# HoodSimulator 사양서
후드메인(Hood) 장치를 모사하는 C# WPF 시뮬레이터. ERVSimulator(전열교환기, Master)와 RS485로
통신하여 후드 상태를 응답한다. 스타일은 DiffuserSimulator와 동일(Tokyo Night 다크 테마).
## 1. 통신
- 프로토콜 : Protocol/HOOD/주신전자_protocol_hood_전열교환기_Rev1.3_20241125.xlsx
- 역할 : Slave (마스터 폴 수신 → 현재 후드 상태로 응답)
- 포트 : 115200 8N1, 9바이트 고정, 폴링주기 100~500ms, 응답 50ms 이내
- 체크섬(CS) : Preamble~CS직전(byte0~7) 전체 XOR
### 프레임 구조
- Master(전열교환기) → Slave(후드) : `AA 21 ID MODE FAN 연동EN 연동운전중 ERROR CS`
- Slave(후드) → Master(전열교환기) : `AA 11 ID FANSTATUS LIGHTSTATUS 00 연동CMD ERROR CS`
- Preamble 0xAA / M·S 0x21(Master)·0x11(Slave) / ID 0x01 고정
## 2. UI 기능
### 통신포트 설정
- COM 포트 선택 + 새로고침(⟳)
- 연결 / 연결 해제, 통신 시작(시작) / 통신 중지(중지)
- 연결 상태 LED(녹색 연결됨 / 빨강 미연결)
### 전원 on / off
- 전원 ON → 풍량 자동 1단
- 전원 OFF → 풍량 0
### 풍량 0(꺼짐) / 1 / 2 / 3 / 4 / 5
- 버튼 선택, 선택 단수 강조
### 조명 on / off
### 에러코드 (체크 선택하여 발생)
- FAN 에러(ERROR=1) / 기타 에러(ERROR=2) 토글 카드 (LED 표시)
- 둘 다 선택 시 FAN 에러(1) 우선 송신
- **에러 발생 시 전원 OFF / 풍량 0 / 조명 OFF로 강제 전환** 후 상태값 전송
### 통신 상태 표시
- 마스터 수신 명령 : MODE, 전열교환기 FAN, 연동 Enable, 연동 운전중
- 후드 응답 송신 : 후드 FAN STATUS, LIGHT STATUS, 연동 CMD, ERROR
- 수신 카운트
### 통신 로그
- 송수신 패킷 hex 로그, 저장 / 지우기
## 3. 응답 상태값 산출 규칙
| 응답 필드 | 값 |
|---|---|
| FAN STATUS (byte3) | 전원 ON 시 풍량 단수(0~5), OFF면 0 |
| LIGHT STATUS (byte4) | 전원 ON & 조명 ON → 1, 아니면 0 |
| 연동 CMD (byte6) | 전원 ON → 1(켜짐), OFF → 0(꺼짐) |
| ERROR (byte7) | 0 정상 / 1 FAN 에러 / 2 기타 에러 |
## 4. ERVSimulator 연동
- ERVSimulator는 Master로서 후드를 200ms 주기 폴 → 응답 수신
- ERV 측 "후드연동" 버튼 : 미연결(기본색) / 통신중 정상(녹색) / 통신중 에러(빨강 + 에러명)