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:
@@ -0,0 +1,112 @@
|
||||
<Application x:Class="DiffuserSimulator.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>
|
||||
|
||||
<Style x:Key="ModernTextBox" TargetType="TextBox">
|
||||
<Setter Property="Background" Value="{StaticResource CardBgBrush}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="8,5"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
</Style>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace DiffuserSimulator
|
||||
{
|
||||
public partial class App : Application
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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>DiffuserSimulator</RootNamespace>
|
||||
<AssemblyName>DiffuserSimulator</AssemblyName>
|
||||
<StartupObject>DiffuserSimulator.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>
|
||||
@@ -0,0 +1,111 @@
|
||||
<Window x:Class="DiffuserSimulator.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="디퓨져 시뮬레이터 - Diffuser Simulator"
|
||||
Width="1400" Height="970"
|
||||
MinWidth="1300" MinHeight="930"
|
||||
Background="{StaticResource PrimaryBgBrush}"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<Grid Margin="14">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="120"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Row 0: 연결 설정 -->
|
||||
<Border Grid.Row="0" Background="{StaticResource SecondaryBgBrush}"
|
||||
CornerRadius="10" Padding="18,10" Margin="0,0,0,8">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="디퓨져 시뮬레이터" FontSize="18" FontWeight="Bold"
|
||||
Foreground="{StaticResource AccentCyanBrush}" Margin="0,0,14,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="0,0,20,0">
|
||||
<TextBlock Text="만든이 : 전경선" Foreground="{StaticResource TextSecondaryBrush}" FontSize="10"/>
|
||||
<TextBlock Text="수정일 : 2026.03.28 ~ 2026.06.08" Foreground="{StaticResource TextSecondaryBrush}" FontSize="10"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 제품 모드 전역 선택 : 토글 버튼 (DL ⇄ 힘펠). DL=LED디밍 활성·RA2 비활성·방4 비활성 -->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock Text="제품사양" Foreground="{StaticResource TextPrimaryBrush}"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0" FontSize="12" FontWeight="SemiBold"/>
|
||||
<Button x:Name="btnProductMode" Content="DL" Width="96" Padding="14,7"
|
||||
Style="{StaticResource ModernButton}" Background="{StaticResource AccentBlueBrush}"
|
||||
Click="ProductMode_Click"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="3" Orientation="Horizontal">
|
||||
<TextBlock Text="통신포트" Foreground="{StaticResource TextPrimaryBrush}"
|
||||
VerticalAlignment="Center" Margin="0,0,6,0" FontSize="12" FontWeight="SemiBold"/>
|
||||
<ComboBox x:Name="cmbPort" Width="100" Style="{StaticResource ModernComboBox}"
|
||||
VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<Button Content="⟳" Width="30" Height="30" FontSize="13"
|
||||
Style="{StaticResource ModernButton}" Click="RefreshPorts_Click"
|
||||
Background="{StaticResource CardBgBrush}" Margin="0,0,12,0" Padding="0"/>
|
||||
|
||||
<Button x:Name="btnAutoChange" Content="자동변경" Style="{StaticResource ModernButton}"
|
||||
Background="{StaticResource AccentBlueBrush}" VerticalAlignment="Center"
|
||||
Margin="0,0,12,0" Padding="14,7" FontSize="11" Click="AutoChange_Click"
|
||||
ToolTip="거실→방1~3 순서로 30초마다 오염레벨 0→1→2→3→4 자동 변경"/>
|
||||
|
||||
<TextBlock Text="폴링(ms)" Foreground="{StaticResource TextPrimaryBrush}"
|
||||
VerticalAlignment="Center" Margin="0,0,6,0" FontSize="11" FontWeight="SemiBold"/>
|
||||
<ComboBox x:Name="cmbInterval" Width="75" Style="{StaticResource ModernComboBox}"
|
||||
VerticalAlignment="Center" Margin="0,0,12,0" SelectedIndex="3">
|
||||
<ComboBoxItem Content="200"/>
|
||||
<ComboBoxItem Content="300"/>
|
||||
<ComboBoxItem Content="500"/>
|
||||
<ComboBoxItem Content="1000"/>
|
||||
<ComboBoxItem Content="2000"/>
|
||||
</ComboBox>
|
||||
|
||||
<Ellipse x:Name="statusLed" Width="10" Height="10" Fill="#F7768E" Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="txtStatus" Text="미연결" Foreground="{StaticResource TextSecondaryBrush}"
|
||||
FontSize="12" VerticalAlignment="Center" Margin="0,0,12,0"/>
|
||||
|
||||
<Button x:Name="btnConnect" Content="연결" Style="{StaticResource ModernButton}"
|
||||
Click="Connect_Click" Margin="0,0,6,0" Padding="14,7"/>
|
||||
<Button x:Name="btnStart" Content="통신 시작" Style="{StaticResource ModernButton}"
|
||||
Background="{StaticResource AccentGreenBrush}" Click="Start_Click"
|
||||
IsEnabled="False" Margin="0,0,6,0" Padding="14,7"/>
|
||||
<Button x:Name="btnStop" Content="통신 중지" Style="{StaticResource ModernButton}"
|
||||
Background="{StaticResource AccentRedBrush}" Click="Stop_Click"
|
||||
IsEnabled="False" Padding="14,7"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Row 1: 5개 방 패널 -->
|
||||
<UniformGrid Grid.Row="1" x:Name="roomGrid" Rows="1" Columns="5" Margin="0,0,0,8"/>
|
||||
|
||||
<!-- Row 2: 통신 로그 -->
|
||||
<Border Grid.Row="2" 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>
|
||||
@@ -0,0 +1,811 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace DiffuserSimulator
|
||||
{
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private readonly SlaveProtocol _slave = new();
|
||||
// 각실 패널(레이아웃은 RoomPanel.xaml — 디자이너 편집). 컨트롤은 internal 필드로 직접 접근.
|
||||
private readonly RoomPanel[] _ui = new RoomPanel[5];
|
||||
private bool _updating;
|
||||
private bool _himpel; // 제품 모드 : false=DL / true=힘펠 (전역)
|
||||
|
||||
// 자동변경 : 거실→방1→방2→방3 순서로 30초마다 오염레벨 0→1→2→3→4 자동 적용
|
||||
private readonly System.Windows.Threading.DispatcherTimer _autoTimer =
|
||||
new() { Interval = TimeSpan.FromSeconds(30) };
|
||||
private int _autoStep; // 0..19 (room = step/5, level = step%5)
|
||||
private bool _autoRunning;
|
||||
|
||||
private static readonly string[] RoomNames = { "거실", "방 1", "방 2", "방 3", "방 4" };
|
||||
private static readonly Color[] RoomColors =
|
||||
{
|
||||
Color.FromRgb(0x7D,0xCF,0xFF), Color.FromRgb(0x9E,0xCE,0x6A),
|
||||
Color.FromRgb(0xE0,0xAF,0x68), Color.FromRgb(0xBB,0x9A,0xF7),
|
||||
Color.FromRgb(0xF7,0x76,0x8E)
|
||||
};
|
||||
|
||||
// 프리셋 값 — 히스테리시스 프리셋별 임계 밴드(CLAUDE.md)의 '중앙값'.
|
||||
// 선택한 프리셋모드에 맞춰 좋음=L0 / 보통=L1 / 나쁨=L2 / 매우나쁨=L3 / 최악(빨강)=L4 로 정확히 분류되도록 함.
|
||||
// [프리셋 0 ECO / 1 NORMAL / 2 TURBO / 3 힘펠][레벨 0~4] — index 4 = L4(임계 상한 초과, ERV 부하점수 4)
|
||||
// 힘펠 사양(룸컨 COLOR) : CO2 0-700/701-1000/1001-1500/1501↑, PM2.5 0-15/16-35/36-75/76↑, TVOC 0-100/101-400/401-1000/1001↑
|
||||
// (힘펠은 PM10/VOC 임계가 99999 캡이라 Band 분류상 L4 도달 불가 → 4단계는 ECO/NORMAL/TURBO 용)
|
||||
private static readonly int[][] PrePM25 = { new[]{10,30,50,75,95}, new[]{7,22,40,60,80}, new[]{6,18,31,45,60}, new[]{7,25,55,90,110} };
|
||||
private static readonly int[][] PrePM10 = { new[]{20,63,106,150,185}, new[]{14,47,85,120,150}, new[]{12,39,66,91,115}, new[]{0,0,0,0,0} };
|
||||
private static readonly int[][] PreCO2 = { new[]{500,1150,1450,1800,2100}, new[]{400,850,1150,1450,1700}, new[]{300,700,900,1100,1300}, new[]{350,850,1250,1750,1700} };
|
||||
private static readonly int[][] PreVOC = { new[]{85,183,252,370,460}, new[]{60,135,200,300,400}, new[]{52,112,156,228,290}, new[]{17,115,270,408,500} };
|
||||
private static readonly int[][] PreTVOC = { new[]{50,250,700,1500,1800}, new[]{50,250,700,1500,1800}, new[]{50,250,700,1500,1800}, new[]{50,250,700,1500,1800} };
|
||||
|
||||
// 분류용 상한 임계 [프리셋][L1~L3] (그 이상 = 매우나쁨) — ECO/NORMAL/TURBO 는 ErvState 와 동일, 힘펠은 룸컨 사양
|
||||
private static readonly int[][] ThrCO2 = { new[]{1000,1300,1600,2000}, new[]{700,1000,1300,1600}, new[]{600,800,1000,1200}, new[]{700,1000,1500,99999} };
|
||||
private static readonly int[][] ThrPM25 = { new[]{20,38,60,86}, new[]{14,29,49,69}, new[]{12,23,38,52}, new[]{15,35,75,99999} };
|
||||
private static readonly int[][] ThrPM10 = { new[]{40,86,126,173}, new[]{28,66,102,138}, new[]{24,53,78,104}, new[]{99999,99999,99999,99999} };
|
||||
private static readonly int[][] ThrVOC = { new[]{171,195,308,438}, new[]{120,150,250,350}, new[]{103,120,192,263}, new[]{99999,99999,99999,99999} };
|
||||
|
||||
private static readonly byte[] PreStatus = { 0x04, 0x03, 0x02, 0x01, 0x01 }; /* L4 도 매우나쁨(0x01) */
|
||||
private const int PresetNoSensor = 5; /* level 5 = 센서없음 (L0~3 + L4 최악) */
|
||||
|
||||
// 힘펠 제품 모드 : 공기질 레벨(0 좋음 / 1 보통 / 2 나쁨 / 3 매우나쁨, 4 최악) → 댐퍼 각도 자동
|
||||
private static readonly byte[] HimpelDamperAngle = { 0, 50, 65, 110, 110 };
|
||||
|
||||
// 실별 선택 상태 : 프리셋모드(0 ECO/1 NORMAL/2 TURBO, 기본 NORMAL) / 공기질 레벨(0~3 or 센서없음, 기본 보통)
|
||||
private readonly int[] _roomPreset = { 1, 1, 1, 1, 1 };
|
||||
private readonly int[] _roomQuality = { 1, 1, 1, 1, 1 };
|
||||
|
||||
private static int Band(int v, int[] t) => v <= t[0] ? 0 : v <= t[1] ? 1 : v <= t[2] ? 2 : v <= t[3] ? 3 : 4;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
_slave.LogMessage += OnLog;
|
||||
_slave.MasterPacketReceived += OnMasterPacket;
|
||||
_slave.SlavePacketReceived += OnSlavePacket;
|
||||
_slave.ResponseSent += OnResponseSent;
|
||||
_slave.MasterPollSent += OnMasterPollSent;
|
||||
_slave.ConnectionChanged += OnConnectionChanged;
|
||||
|
||||
BuildRoomPanels();
|
||||
RefreshPorts();
|
||||
ApplySlaveUi(); // 슬레이브 전용 UI 상태(각도 readonly 등)
|
||||
_autoTimer.Tick += AutoTick;
|
||||
Closed += (_, _) => { _autoTimer.Stop(); _slave.Dispose(); };
|
||||
}
|
||||
|
||||
// ========== 5개 방 패널 생성 (레이아웃=RoomPanel.xaml, 동작=여기서 연결) ==========
|
||||
private void BuildRoomPanels()
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
int idx = i;
|
||||
var u = new RoomPanel();
|
||||
u.SetHeader(RoomNames[i], RoomColors[i]);
|
||||
_ui[i] = u;
|
||||
roomGrid.Children.Add(u);
|
||||
|
||||
// ---- 헤더 활성 체크 ----
|
||||
u.ChkEnabled.Checked += (s, e) =>
|
||||
{
|
||||
var room = _slave.Rooms[idx];
|
||||
// 처음 상태 reset: damper 0, LED 0, 센서 보통 preset, 양쪽 toggle OFF
|
||||
room.Enabled = true;
|
||||
room.DamperAngleSA = 0;
|
||||
room.DamperAngleEA = 0;
|
||||
room.LedBrightness = 0;
|
||||
room.PollSA = true; // Enabled 면 SA/RA 모두 응답
|
||||
room.PollRA = true;
|
||||
|
||||
// UI 동기화 (event re-entrant 차단)
|
||||
_updating = true;
|
||||
_ui[idx].TbPM25.Text = "25";
|
||||
_ui[idx].TbPM10.Text = "30";
|
||||
_ui[idx].TbCO2.Text = "850";
|
||||
_ui[idx].TbVOC.Text = "115";
|
||||
_ui[idx].TbTVOC.Text = "250";
|
||||
_ui[idx].TbTemp.Text = "25";
|
||||
_ui[idx].TbHumidity.Text = "50";
|
||||
_ui[idx].TbSAAngle.Text = "0";
|
||||
_ui[idx].TbEAAngle.Text = "0";
|
||||
_ui[idx].SldLed.Value = 0;
|
||||
_ui[idx].TxtLedVal.Text = "0 (OFF)";
|
||||
_ui[idx].TglSA.IsChecked = false;
|
||||
_ui[idx].TglEA.IsChecked = false;
|
||||
_ui[idx].RbNormal.IsChecked = true;
|
||||
|
||||
// 거실(idx 0) : 거실2(ID2 0x00 = RA2/SA2)도 함께 활성·초기화
|
||||
if (idx == 0)
|
||||
{
|
||||
var r2 = _slave.Rooms[SlaveProtocol.LivingRoom2Index];
|
||||
r2.Enabled = true;
|
||||
r2.PollSA = true;
|
||||
r2.PollRA = true;
|
||||
r2.DamperAngleSA = 0;
|
||||
r2.DamperAngleEA = 0;
|
||||
_ui[0].TbEAAngle2.Text = "0";
|
||||
_ui[0].TbSAAngle2.Text = "0";
|
||||
}
|
||||
_updating = false;
|
||||
|
||||
SyncRoomFromUI(idx);
|
||||
};
|
||||
u.ChkEnabled.Unchecked += (s, e) =>
|
||||
{
|
||||
_slave.Rooms[idx].Enabled = false;
|
||||
if (idx == 0) _slave.Rooms[SlaveProtocol.LivingRoom2Index].Enabled = false;
|
||||
};
|
||||
_slave.Rooms[i].Enabled = (i == 0);
|
||||
|
||||
// ---- 배기(RA) 디퓨저 ----
|
||||
// Slave 모드: ON → master 의 RA polling 에 응답 / OFF → 무응답
|
||||
// Master 모드: ON → RA 폴링 송신 / OFF → skip
|
||||
u.TglEA.Checked += (s, e) => { if (!_updating) _slave.Rooms[idx].PollRA = true; };
|
||||
u.TglEA.Unchecked += (s, e) => { if (!_updating) _slave.Rooms[idx].PollRA = false; };
|
||||
u.TbEAAngle.TextChanged += (s, e) =>
|
||||
{
|
||||
if (_slave.Mode == SimMode.Master && byte.TryParse(u.TbEAAngle.Text, out byte v))
|
||||
_slave.Rooms[idx].DamperAngleEA = (byte)Math.Min(v, (byte)0xB4);
|
||||
};
|
||||
// 수동 닫기 (RA) — Slave 모드에서 마스터 개방명령 무시하고 닫힘 유지
|
||||
u.ChkCloseRA.Checked += (s, e) =>
|
||||
{
|
||||
if (_updating) return;
|
||||
_slave.Rooms[idx].ManualCloseRA = true; _slave.Rooms[idx].DamperAngleEA = 0;
|
||||
if (idx == 0) { var r2 = _slave.Rooms[SlaveProtocol.LivingRoom2Index]; r2.ManualCloseRA = true; r2.DamperAngleEA = 0; }
|
||||
RefreshAngleUI(idx);
|
||||
};
|
||||
u.ChkCloseRA.Unchecked += (s, e) =>
|
||||
{
|
||||
if (_updating) return;
|
||||
_slave.Rooms[idx].ManualCloseRA = false;
|
||||
if (idx == 0) _slave.Rooms[SlaveProtocol.LivingRoom2Index].ManualCloseRA = false;
|
||||
};
|
||||
|
||||
// ---- 공기질 센서값 ----
|
||||
u.TbPM25.PreviewTextInput += NumericOnly;
|
||||
u.TbPM10.PreviewTextInput += NumericOnly;
|
||||
u.TbCO2.PreviewTextInput += NumericOnly;
|
||||
u.TbVOC.PreviewTextInput += NumericOnly;
|
||||
u.TbTVOC.PreviewTextInput += NumericOnly;
|
||||
u.TbTemp.PreviewTextInput += NumericOnly;
|
||||
u.TbHumidity.PreviewTextInput += NumericOnly;
|
||||
u.TbPM25.TextChanged += (s, e) => { if (!_updating) SyncRoomFromUI(idx); };
|
||||
u.TbPM10.TextChanged += (s, e) => { if (!_updating) SyncRoomFromUI(idx); };
|
||||
u.TbCO2.TextChanged += (s, e) => { if (!_updating) SyncRoomFromUI(idx); };
|
||||
u.TbVOC.TextChanged += (s, e) => { if (!_updating) SyncRoomFromUI(idx); };
|
||||
u.TbTVOC.TextChanged += (s, e) => { if (!_updating) SyncRoomFromUI(idx); };
|
||||
u.TbTemp.TextChanged += (s, e) => { if (!_updating) SyncRoomFromUI(idx); };
|
||||
u.TbHumidity.TextChanged += (s, e) => { if (!_updating) SyncRoomFromUI(idx); };
|
||||
|
||||
// 프리셋 (좋음/보통/나쁨/매우나쁨/최악/센서없음)
|
||||
u.RbGood.Checked += (s, e) => ApplyPreset(idx, 0);
|
||||
u.RbNormal.Checked += (s, e) => ApplyPreset(idx, 1);
|
||||
u.RbBad.Checked += (s, e) => ApplyPreset(idx, 2);
|
||||
u.RbVeryBad.Checked += (s, e) => ApplyPreset(idx, 3);
|
||||
u.RbWorst.Checked += (s, e) => ApplyPreset(idx, 4);
|
||||
u.RbNoSensor.Checked += (s, e) => ApplyPreset(idx, PresetNoSensor);
|
||||
|
||||
// 프리셋모드 (ECO/NORMAL/TURBO/힘펠)
|
||||
u.RbEco.Checked += (s, e) => ApplyHystPreset(idx, 0);
|
||||
u.RbNorm.Checked += (s, e) => ApplyHystPreset(idx, 1);
|
||||
u.RbTurbo.Checked += (s, e) => ApplyHystPreset(idx, 2);
|
||||
|
||||
// LED 슬라이더 + 수동 제어
|
||||
u.SldLed.ValueChanged += (s, e) =>
|
||||
{
|
||||
int v = (int)_ui[idx].SldLed.Value;
|
||||
_ui[idx].TxtLedVal.Text = v == 0 ? "0 (OFF)" : $"{v}단";
|
||||
// Master 모드 또는 LED 수동 제어 시 슬라이더 값을 LED 밝기로 적용
|
||||
if (_slave.Mode == SimMode.Master || _slave.Rooms[idx].ManualLed)
|
||||
_slave.Rooms[idx].LedBrightness = (byte)v;
|
||||
};
|
||||
u.ChkLedManual.Checked += (s, e) =>
|
||||
{
|
||||
if (_updating) return;
|
||||
_slave.Rooms[idx].ManualLed = true;
|
||||
_ui[idx].SldLed.IsEnabled = true;
|
||||
_slave.Rooms[idx].LedBrightness = (byte)_ui[idx].SldLed.Value;
|
||||
};
|
||||
u.ChkLedManual.Unchecked += (s, e) =>
|
||||
{
|
||||
if (_updating) return;
|
||||
_slave.Rooms[idx].ManualLed = false;
|
||||
// 수동 해제 시 슬라이더는 다시 마스터 명령 추종(Slave 모드면 읽기전용)
|
||||
_ui[idx].SldLed.IsEnabled = _slave.Mode == SimMode.Master;
|
||||
};
|
||||
|
||||
// ---- 급기(SA) 디퓨저 ----
|
||||
u.TglSA.Checked += (s, e) => { if (!_updating) _slave.Rooms[idx].PollSA = true; };
|
||||
u.TglSA.Unchecked += (s, e) => { if (!_updating) _slave.Rooms[idx].PollSA = false; };
|
||||
u.TbSAAngle.TextChanged += (s, e) =>
|
||||
{
|
||||
if (_slave.Mode == SimMode.Master && byte.TryParse(u.TbSAAngle.Text, out byte v))
|
||||
_slave.Rooms[idx].DamperAngleSA = (byte)Math.Min(v, (byte)0xB4);
|
||||
};
|
||||
u.ChkCloseSA.Checked += (s, e) =>
|
||||
{
|
||||
if (_updating) return;
|
||||
_slave.Rooms[idx].ManualCloseSA = true; _slave.Rooms[idx].DamperAngleSA = 0;
|
||||
if (idx == 0) { var r2 = _slave.Rooms[SlaveProtocol.LivingRoom2Index]; r2.ManualCloseSA = true; r2.DamperAngleSA = 0; }
|
||||
RefreshAngleUI(idx);
|
||||
};
|
||||
u.ChkCloseSA.Unchecked += (s, e) =>
|
||||
{
|
||||
if (_updating) return;
|
||||
_slave.Rooms[idx].ManualCloseSA = false;
|
||||
if (idx == 0) _slave.Rooms[SlaveProtocol.LivingRoom2Index].ManualCloseSA = false;
|
||||
};
|
||||
|
||||
// ===== 거실 전용 : DL/힘펠 제품 모드 + RA2/SA2 (거실2 = ID2 0x00) =====
|
||||
if (idx != 0)
|
||||
{
|
||||
// 방1~4 : RA2/SA2 자리는 비워두되 공간은 유지(Hidden) → 거실과 세로 정렬
|
||||
u.GridEA2.Visibility = Visibility.Hidden;
|
||||
u.GridSA2.Visibility = Visibility.Hidden;
|
||||
}
|
||||
|
||||
if (idx == 0)
|
||||
{
|
||||
u.GridEA2.Visibility = Visibility.Visible;
|
||||
u.GridSA2.Visibility = Visibility.Visible;
|
||||
u.TxtEALabel.Text = "RA1 각도";
|
||||
u.TxtSALabel.Text = "SA1 각도";
|
||||
|
||||
u.TbEAAngle2.PreviewTextInput += NumericOnly;
|
||||
u.TbSAAngle2.PreviewTextInput += NumericOnly;
|
||||
u.TbEAAngle2.TextChanged += (s, e) =>
|
||||
{
|
||||
if (_slave.Mode == SimMode.Master && byte.TryParse(u.TbEAAngle2.Text, out byte v))
|
||||
_slave.Rooms[SlaveProtocol.LivingRoom2Index].DamperAngleEA = (byte)Math.Min(v, (byte)0xB4);
|
||||
};
|
||||
u.TbSAAngle2.TextChanged += (s, e) =>
|
||||
{
|
||||
if (_slave.Mode == SimMode.Master && byte.TryParse(u.TbSAAngle2.Text, out byte v))
|
||||
_slave.Rooms[SlaveProtocol.LivingRoom2Index].DamperAngleSA = (byte)Math.Min(v, (byte)0xB4);
|
||||
};
|
||||
}
|
||||
|
||||
// Slave 모드 기본 : 댐퍼 토글/각도/LED 는 읽기전용(마스터 명령 표시용)
|
||||
u.TglSA.IsEnabled = false;
|
||||
u.TglEA.IsEnabled = false;
|
||||
u.TbSAAngle.IsReadOnly = true;
|
||||
u.TbEAAngle.IsReadOnly = true;
|
||||
u.TbSAAngle2.IsReadOnly = true;
|
||||
u.TbEAAngle2.IsReadOnly = true;
|
||||
u.SldLed.IsEnabled = false;
|
||||
|
||||
// 초기 동기화
|
||||
SyncRoomFromUI(i);
|
||||
}
|
||||
|
||||
// ---- 초기값 : 거실, 방1~방3 활성(응답) + 센서 '좋음'. 댐퍼는 닫힘(각도0=토글OFF) ----
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
_ui[i].ChkEnabled.IsChecked = true; // Enabled → SA/RA 응답
|
||||
_ui[i].RbGood.IsChecked = true; // 공기질 '좋음' preset
|
||||
}
|
||||
_ui[4].RbGood.IsChecked = true; // 방4 기본 '좋음' (Enabled 는 제품모드가 제어)
|
||||
|
||||
// 제품 모드 기본 = DL (전역) — LED 디밍 활성(거실·방1~3), RA2 비활성, 방4 비활성
|
||||
ApplyProductMode(false);
|
||||
}
|
||||
|
||||
// ========== 제품 모드(DL/힘펠) 전역 토글 ==========
|
||||
private void ProductMode_Click(object s, RoutedEventArgs e) => ApplyProductMode(!_himpel);
|
||||
|
||||
// 전역 적용
|
||||
// DL : byte24~25=VOC, LED 디밍 활성(거실·방1~3), RA2(거실 배기) 비활성, 방4 비활성화
|
||||
// 힘펠 : byte24~25=TVOC, LED 디밍 비활성(전체), RA2 활성, 방4 활성화
|
||||
private void ApplyProductMode(bool himpel)
|
||||
{
|
||||
_himpel = himpel;
|
||||
if (btnProductMode != null) btnProductMode.Content = himpel ? "힘펠" : "DL";
|
||||
|
||||
// 송신 모드(byte24/25 VOC vs TVOC) — 모든 방 + 거실2
|
||||
for (int i = 0; i < 5; i++) _slave.Rooms[i].Himpel = himpel;
|
||||
var r2 = _slave.Rooms[SlaveProtocol.LivingRoom2Index];
|
||||
r2.Himpel = himpel;
|
||||
r2.RaActive = himpel; // 거실 RA2 = 힘펠일 때만 응답
|
||||
|
||||
// 거실 RA2 입력 활성/비활성
|
||||
_ui[0].GridEA2.IsEnabled = himpel;
|
||||
|
||||
// 공기질 센서 입력 : 힘펠=TVOC 활성/VOC 비활성, DL=VOC 활성/TVOC 비활성 (전체 방)
|
||||
// 프리셋모드(ECO/NORMAL/TURBO) : DL=활성 / 힘펠=비활성 (전체 방)
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
_ui[i].TbTVOC.IsEnabled = himpel;
|
||||
_ui[i].TbVOC.IsEnabled = !himpel;
|
||||
_ui[i].RbEco.IsEnabled = !himpel;
|
||||
_ui[i].RbNorm.IsEnabled = !himpel;
|
||||
_ui[i].RbTurbo.IsEnabled = !himpel;
|
||||
}
|
||||
|
||||
// LED 디밍 : DL=활성 / 힘펠=비활성 — 거실(0)·방1~3(1~3)
|
||||
for (int i = 0; i < 4; i++) SetLedDimming(i, enabled: !himpel);
|
||||
|
||||
// 방4(idx 4) : DL=비활성화 / 힘펠=활성화(센서 기본 '좋음')
|
||||
SetRoomActive(4, active: himpel);
|
||||
if (himpel) _ui[4].RbGood.IsChecked = true;
|
||||
|
||||
// 힘펠 전환 시 현재 공기질에 맞춰 댐퍼 각도 즉시 반영
|
||||
if (himpel)
|
||||
for (int i = 0; i < 5; i++) SyncRoomFromUI(i);
|
||||
}
|
||||
|
||||
// LED 디밍 수동 제어 활성/비활성 (방 1개)
|
||||
private void SetLedDimming(int idx, bool enabled)
|
||||
{
|
||||
var u = _ui[idx];
|
||||
if (enabled)
|
||||
{
|
||||
u.ChkLedManual.IsEnabled = true;
|
||||
u.SldLed.IsEnabled = _slave.Mode == SimMode.Master || u.ChkLedManual.IsChecked == true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_updating = true; u.ChkLedManual.IsChecked = false; _updating = false;
|
||||
_slave.Rooms[idx].ManualLed = false;
|
||||
u.ChkLedManual.IsEnabled = false;
|
||||
u.SldLed.IsEnabled = _slave.Mode == SimMode.Master;
|
||||
}
|
||||
}
|
||||
|
||||
// 방 전체 활성/비활성 — 비활성 시 응답 중지(Enabled off) + 패널 잠금
|
||||
private void SetRoomActive(int idx, bool active)
|
||||
{
|
||||
var u = _ui[idx];
|
||||
if (u.ChkEnabled.IsChecked != active) u.ChkEnabled.IsChecked = active; // Checked/Unchecked 핸들러가 Rooms[idx].Enabled 처리
|
||||
u.IsEnabled = active; // 패널 잠금/해제
|
||||
}
|
||||
|
||||
// ========== UI → RoomSimData 즉시 동기화 ==========
|
||||
private void SyncRoomFromUI(int idx)
|
||||
{
|
||||
var room = _slave.Rooms[idx];
|
||||
var u = _ui[idx];
|
||||
if (u == null) return;
|
||||
|
||||
// 센서값만 UI에서 동기화 (제어값은 마스터에서만 변경)
|
||||
int.TryParse(u.TbPM10?.Text, out int pm10); room.PM10 = pm10;
|
||||
int.TryParse(u.TbTemp?.Text, out int temp); room.Temperature = temp;
|
||||
int.TryParse(u.TbHumidity?.Text, out int hum); room.Humidity = hum;
|
||||
int.TryParse(u.TbPM25?.Text, out int pm25); room.PM25 = pm25;
|
||||
int.TryParse(u.TbCO2?.Text, out int co2); room.CO2 = co2;
|
||||
int.TryParse(u.TbTVOC?.Text, out int tvoc); room.TVOC = tvoc;
|
||||
int.TryParse(u.TbVOC?.Text, out int voc); room.VOC = voc;
|
||||
|
||||
// 공기질 상태 자동 계산 — 선택한 프리셋모드(ECO/NORMAL/TURBO)의 임계 밴드로
|
||||
int p = _roomPreset[idx];
|
||||
int worst = Math.Max(
|
||||
Math.Max(Band(pm25, ThrPM25[p]), Band(co2, ThrCO2[p])),
|
||||
Math.Max(Band(voc, ThrVOC[p]), Band(pm10, ThrPM10[p])));
|
||||
room.AirQualityStatus = PreStatus[worst];
|
||||
|
||||
// 프리셋 라디오 버튼 동기화 (RbNoSensor 체크 상태면 skip — 사용자 선택 보존).
|
||||
if (u.RbGood != null && (u.RbNoSensor == null || u.RbNoSensor.IsChecked != true))
|
||||
{
|
||||
_updating = true;
|
||||
switch (worst)
|
||||
{
|
||||
case 0: u.RbGood.IsChecked = true; break;
|
||||
case 1: u.RbNormal.IsChecked = true; break;
|
||||
case 2: u.RbBad.IsChecked = true; break;
|
||||
case 3: u.RbVeryBad.IsChecked = true; break;
|
||||
case 4: u.RbWorst.IsChecked = true; break;
|
||||
}
|
||||
_updating = false;
|
||||
}
|
||||
|
||||
// 힘펠 제품 모드 : 공기질 레벨에 따라 댐퍼 각도 자동 (이미지 사양 0/50/65/110)
|
||||
if (_himpel) ApplyHimpelDamper(idx, worst);
|
||||
}
|
||||
|
||||
// 힘펠 모드 자동 댐퍼 — 공기질 레벨(0~3) → 각도. SA/RA 동시 적용, 수동닫기 우선.
|
||||
private void ApplyHimpelDamper(int idx, int level)
|
||||
{
|
||||
byte ang = HimpelDamperAngle[level];
|
||||
var room = _slave.Rooms[idx];
|
||||
if (!room.ManualCloseSA) room.DamperAngleSA = ang;
|
||||
if (!room.ManualCloseRA) room.DamperAngleEA = ang;
|
||||
|
||||
_updating = true;
|
||||
var u = _ui[idx];
|
||||
u.TbSAAngle.Text = room.DamperAngleSA.ToString();
|
||||
u.TbEAAngle.Text = room.DamperAngleEA.ToString();
|
||||
u.TglSA.IsChecked = room.DamperAngleSA > 0;
|
||||
u.TglEA.IsChecked = room.DamperAngleEA > 0;
|
||||
|
||||
// 거실(0) : 거실2(RA2/SA2)도 동일 적용 — RA2 는 힘펠일 때만 활성
|
||||
if (idx == 0)
|
||||
{
|
||||
var r2 = _slave.Rooms[SlaveProtocol.LivingRoom2Index];
|
||||
if (!r2.ManualCloseSA) r2.DamperAngleSA = ang;
|
||||
if (r2.RaActive && !r2.ManualCloseRA) r2.DamperAngleEA = ang;
|
||||
u.TbSAAngle2.Text = r2.DamperAngleSA.ToString();
|
||||
u.TbEAAngle2.Text = r2.DamperAngleEA.ToString();
|
||||
}
|
||||
_updating = false;
|
||||
}
|
||||
|
||||
// ========== 프리셋 적용 ==========
|
||||
// level 0~4: 좋음 / 보통 / 나쁨 / 매우나쁨 / 최악(빨강) (Pre*[프리셋모드] 배열 lookup)
|
||||
// level 5 : 센서없음 — 모든 sensor TextBox 0
|
||||
private void ApplyPreset(int idx, int level)
|
||||
{
|
||||
if (_updating) return;
|
||||
_roomQuality[idx] = level;
|
||||
_updating = true;
|
||||
var u = _ui[idx];
|
||||
int p = _roomPreset[idx];
|
||||
if (level == PresetNoSensor)
|
||||
{
|
||||
if (u?.TbPM25 != null) u.TbPM25.Text = "0";
|
||||
if (u?.TbPM10 != null) u.TbPM10.Text = "0";
|
||||
if (u?.TbCO2 != null) u.TbCO2.Text = "0";
|
||||
if (u?.TbVOC != null) u.TbVOC.Text = "0";
|
||||
if (u?.TbTVOC != null) u.TbTVOC.Text = "0";
|
||||
if (u?.TbTemp != null) u.TbTemp.Text = "0";
|
||||
if (u?.TbHumidity != null) u.TbHumidity.Text = "0";
|
||||
}
|
||||
else
|
||||
{
|
||||
if (u?.TbPM25 != null) u.TbPM25.Text = PrePM25[p][level].ToString();
|
||||
if (u?.TbPM10 != null) u.TbPM10.Text = PrePM10[p][level].ToString();
|
||||
if (u?.TbCO2 != null) u.TbCO2.Text = PreCO2[p][level].ToString();
|
||||
if (u?.TbTVOC != null) u.TbTVOC.Text = PreTVOC[p][level].ToString();
|
||||
if (u?.TbVOC != null) u.TbVOC.Text = PreVOC[p][level].ToString();
|
||||
}
|
||||
_updating = false;
|
||||
SyncRoomFromUI(idx);
|
||||
}
|
||||
|
||||
// 프리셋모드(ECO/NORMAL/TURBO) 변경 → 선택 밴드로 현재 공기질 프리셋 재적용
|
||||
private void ApplyHystPreset(int idx, int preset)
|
||||
{
|
||||
if (_updating) return;
|
||||
_roomPreset[idx] = preset;
|
||||
// 센서없음(5)은 값 0 유지, 그 외 좋음/보통/나쁨/매우나쁨/최악은 새 밴드 중앙값으로 재적용
|
||||
if (_roomQuality[idx] != PresetNoSensor)
|
||||
ApplyPreset(idx, _roomQuality[idx]);
|
||||
else
|
||||
SyncRoomFromUI(idx);
|
||||
}
|
||||
|
||||
// ========== UI 헬퍼 ==========
|
||||
// 수동 닫기 등으로 댐퍼 각도가 바뀐 즉시 UI 표시 갱신
|
||||
private void RefreshAngleUI(int idx)
|
||||
{
|
||||
_updating = true;
|
||||
_ui[idx].TbSAAngle.Text = _slave.Rooms[idx].DamperAngleSA.ToString();
|
||||
_ui[idx].TbEAAngle.Text = _slave.Rooms[idx].DamperAngleEA.ToString();
|
||||
_ui[idx].TglSA.IsChecked = _slave.Rooms[idx].DamperAngleSA > 0;
|
||||
_ui[idx].TglEA.IsChecked = _slave.Rooms[idx].DamperAngleEA > 0;
|
||||
_updating = false;
|
||||
}
|
||||
|
||||
// 숫자만 입력 허용
|
||||
private void NumericOnly(object sender, TextCompositionEventArgs e)
|
||||
{
|
||||
e.Handled = !Regex.IsMatch(e.Text, @"^[0-9]$");
|
||||
}
|
||||
|
||||
// ========== 연결 ==========
|
||||
private void RefreshPorts()
|
||||
{
|
||||
cmbPort.Items.Clear();
|
||||
foreach (var p in _slave.GetAvailablePorts()) cmbPort.Items.Add(p);
|
||||
if (cmbPort.Items.Count > 0) cmbPort.SelectedIndex = 0;
|
||||
}
|
||||
private void RefreshPorts_Click(object s, RoutedEventArgs e) => RefreshPorts();
|
||||
|
||||
private void Connect_Click(object s, RoutedEventArgs e)
|
||||
{
|
||||
if (_slave.IsConnected)
|
||||
{
|
||||
_slave.Disconnect();
|
||||
btnConnect.Content = "연결";
|
||||
ResetAllRooms(); // 연결해제 시 체크박스 / toggle / damper 초기화
|
||||
}
|
||||
else
|
||||
{
|
||||
if (cmbPort.SelectedItem == null) { MessageBox.Show("COM 포트를 선택하세요."); return; }
|
||||
if (_slave.Connect(cmbPort.SelectedItem.ToString()!)) btnConnect.Content = "연결 해제";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 연결해제 시 호출 — 모든 방의 Enabled / Poll toggle OFF, damper 각도 0.
|
||||
/// _updating 플래그로 toggle 이벤트 chain 회피.
|
||||
/// </summary>
|
||||
private void ResetAllRooms()
|
||||
{
|
||||
_updating = true;
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var room = _slave.Rooms[i];
|
||||
room.Enabled = false;
|
||||
room.PollSA = false;
|
||||
room.PollRA = false;
|
||||
room.DamperAngleSA = 0;
|
||||
room.DamperAngleEA = 0;
|
||||
|
||||
var u = _ui[i];
|
||||
u.ChkEnabled.IsChecked = false;
|
||||
u.TglSA.IsChecked = false;
|
||||
u.TglEA.IsChecked = false;
|
||||
u.TbSAAngle.Text = "0";
|
||||
u.TbEAAngle.Text = "0";
|
||||
}
|
||||
|
||||
// 거실2 (RA2/SA2)
|
||||
var r2 = _slave.Rooms[SlaveProtocol.LivingRoom2Index];
|
||||
r2.Enabled = false; r2.PollSA = false; r2.PollRA = false;
|
||||
r2.DamperAngleSA = 0; r2.DamperAngleEA = 0;
|
||||
_ui[0].TbSAAngle2.Text = "0";
|
||||
_ui[0].TbEAAngle2.Text = "0";
|
||||
}
|
||||
finally { _updating = false; }
|
||||
}
|
||||
|
||||
private void Start_Click(object s, RoutedEventArgs e)
|
||||
{
|
||||
if (!_slave.IsConnected) return;
|
||||
int interval = int.Parse(((ComboBoxItem)cmbInterval.SelectedItem).Content.ToString()!);
|
||||
_slave.StartResponding(interval); // 슬레이브 전용
|
||||
btnStart.IsEnabled = false;
|
||||
btnStop.IsEnabled = true;
|
||||
}
|
||||
|
||||
private void Stop_Click(object s, RoutedEventArgs e)
|
||||
{
|
||||
_slave.StopResponding();
|
||||
btnStart.IsEnabled = true;
|
||||
btnStop.IsEnabled = false;
|
||||
}
|
||||
|
||||
// ========== 슬레이브 전용 UI 상태 ==========
|
||||
// 각도 필드는 readonly(ERV가 댐퍼 제어), LED 슬라이더는 LED 수동제어 시에만 활성.
|
||||
private void ApplySlaveUi()
|
||||
{
|
||||
if (_ui == null || _ui[0] == null) return;
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var u = _ui[i];
|
||||
if (u == null) continue;
|
||||
u.TglSA.IsEnabled = true;
|
||||
u.TglEA.IsEnabled = true;
|
||||
u.TbSAAngle.IsReadOnly = true;
|
||||
u.TbEAAngle.IsReadOnly = true;
|
||||
u.TbSAAngle2.IsReadOnly = true;
|
||||
u.TbEAAngle2.IsReadOnly = true;
|
||||
u.SldLed.IsEnabled = u.ChkLedManual.IsChecked == true;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 자동변경 : 거실→방1→방2→방3, 각 방 오염레벨 0~4를 30초 단위로 ==========
|
||||
private void AutoChange_Click(object s, RoutedEventArgs e)
|
||||
{
|
||||
if (_autoRunning)
|
||||
{
|
||||
_autoTimer.Stop();
|
||||
_autoRunning = false;
|
||||
btnAutoChange.Content = "자동변경";
|
||||
OnLog("[자동변경] 중지");
|
||||
return;
|
||||
}
|
||||
// 거실~방3(0~3) 활성화 (이미 켜져 있으면 무시) 후 전체 0(좋음)에서 시작
|
||||
for (int i = 0; i <= 3; i++)
|
||||
if (_ui[i].ChkEnabled.IsChecked != true) _ui[i].ChkEnabled.IsChecked = true;
|
||||
for (int r = 0; r <= 3; r++) ApplyPreset(r, 0);
|
||||
|
||||
_autoStep = 0;
|
||||
_autoRunning = true;
|
||||
btnAutoChange.Content = "자동변경 중지";
|
||||
OnLog("[자동변경] 시작 — 전체 0에서 30초 대기 후 방1→방2→방3→거실 순 누적(0→4)");
|
||||
_autoTimer.Start(); // 즉시 적용하지 않음 → 초기 0 0 0 0 을 30초 유지 후 첫 변경
|
||||
}
|
||||
|
||||
// 레벨 스윕(누적) : 매 30초 한 방씩 현재 레벨로 올림(방1→방2→방3→거실).
|
||||
// 한 바퀴(4방) 다 올리면 레벨+1. 앞서 올린 방은 값 유지(누적). 전체 4 도달 후 0으로 리셋 반복.
|
||||
private static readonly int[] AutoOrder = { 1, 2, 3, 0 }; // 방1, 방2, 방3, 거실
|
||||
private void AutoTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_autoStep >= 16) // 4레벨 × 4방 완료 → 전체 0 리셋 후 새 사이클
|
||||
{
|
||||
_autoStep = 0;
|
||||
for (int r = 0; r <= 3; r++) ApplyPreset(r, 0);
|
||||
OnLog("[자동변경] 사이클 완료 — 전체 0 리셋 후 반복");
|
||||
}
|
||||
int level = _autoStep / 4 + 1; // 1~4
|
||||
int room = AutoOrder[_autoStep % 4]; // 방1→방2→방3→거실
|
||||
ApplyPreset(room, level); // 누적: 다른 방은 건드리지 않음
|
||||
OnLog($"[자동변경] {RoomNames[room]} 오염레벨 {level}");
|
||||
_autoStep++;
|
||||
}
|
||||
|
||||
// ========== 마스터 패킷 수신 ==========
|
||||
private void OnMasterPacket(byte[] data, byte id2)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
int ri = SlaveProtocol.Id2ToIndex(id2);
|
||||
if (ri < 0) return;
|
||||
bool secondary = (id2 == 0); // 거실2 → 거실 패널의 RA2/SA2
|
||||
var u = _ui[secondary ? 0 : ri];
|
||||
u.RxCount++;
|
||||
u.TxtRxCount.Text = $"수신: {u.RxCount}";
|
||||
|
||||
// 마스터 제어 명령 → UI 동기화 (시각 만, PollSA/PollRA 변경 안 함)
|
||||
_updating = true;
|
||||
var room = _slave.Rooms[ri];
|
||||
|
||||
if (secondary)
|
||||
{
|
||||
// 거실2 : RA2/SA2 각도만 표시
|
||||
u.TbSAAngle2.Text = room.DamperAngleSA.ToString();
|
||||
u.TbEAAngle2.Text = room.DamperAngleEA.ToString();
|
||||
_updating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// LED — 수동 제어 중이면 슬라이더(사용자값) 보존
|
||||
if (!room.ManualLed)
|
||||
{
|
||||
u.SldLed.Value = Math.Min(room.LedBrightness, (byte)9);
|
||||
u.TxtLedVal.Text = room.LedBrightness == 0 ? "0 (OFF)" : $"{room.LedBrightness}단";
|
||||
}
|
||||
|
||||
// 급기/배기 각도 + 댐퍼 토글(열림/닫힘) — 각도 연동 (Slave 모드, 마스터 명령 표시)
|
||||
u.TbSAAngle.Text = room.DamperAngleSA.ToString();
|
||||
u.TbEAAngle.Text = room.DamperAngleEA.ToString();
|
||||
u.TglSA.IsChecked = room.DamperAngleSA > 0;
|
||||
u.TglEA.IsChecked = room.DamperAngleEA > 0;
|
||||
|
||||
// TglSA/TglEA visual 은 user 의 toggle 클릭으로만 변경 — master 응답 gate 역할.
|
||||
// 이전엔 damper 값에 따라 auto-sync 했으나, master polling 이 매 cycle 마다
|
||||
// toggle 을 강제 ON 시키면서 user OFF 가 즉시 덮어쓰이는 문제 발생 → 제거.
|
||||
|
||||
// 공기질 프리셋은 master 가 보내지 않음 (byte 9 = 0) — 사용자 선택 보존.
|
||||
|
||||
_updating = false;
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Slave 응답 수신 (Master Mode) ==========
|
||||
private void OnSlavePacket(byte[] data, byte id1, byte id2)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
int ri = SlaveProtocol.Id2ToIndex(id2);
|
||||
if (ri < 0) return;
|
||||
bool secondary = (id2 == 0); // 거실2 → 거실 패널의 RA2/SA2
|
||||
var u = _ui[secondary ? 0 : ri];
|
||||
var room = _slave.Rooms[ri];
|
||||
|
||||
u.RxCount++;
|
||||
u.TxtRxCount.Text = $"수신: {u.RxCount} (ID1=0x{id1:X2})";
|
||||
|
||||
_updating = true;
|
||||
if (secondary)
|
||||
{
|
||||
// 거실2 : RA2/SA2 각도만 표시 (센서는 거실 패널 공용 표시 유지)
|
||||
u.TbSAAngle2.Text = room.DamperAngleSA.ToString();
|
||||
u.TbEAAngle2.Text = room.DamperAngleEA.ToString();
|
||||
_updating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// SEN66 값 UI 갱신 (STM32 slave 가 보낸 값)
|
||||
u.TbPM10.Text = room.PM10.ToString();
|
||||
u.TbPM25.Text = room.PM25.ToString();
|
||||
u.TbTemp.Text = room.Temperature.ToString();
|
||||
u.TbHumidity.Text = room.Humidity.ToString();
|
||||
u.TbCO2.Text = room.CO2.ToString();
|
||||
u.TbVOC.Text = room.VOC.ToString();
|
||||
u.TbTVOC.Text = room.TVOC.ToString();
|
||||
_updating = false;
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Master Polling 송신 콜백 (Master Mode) ==========
|
||||
private void OnMasterPollSent(byte id1, byte id2)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
int ri = SlaveProtocol.Id2ToIndex(id2);
|
||||
if (ri < 0) return;
|
||||
int panel = (id2 == 0) ? 0 : ri; // 거실2 → 거실 패널 표시
|
||||
_ui[panel].TxtStatus.Text = $"→ Poll ID1=0x{id1:X2}";
|
||||
_ui[panel].TxtStatus.Foreground = new SolidColorBrush(Color.FromRgb(0x7D, 0xCF, 0xFF));
|
||||
});
|
||||
}
|
||||
|
||||
private void OnResponseSent(byte id2, bool responded)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
int ri = SlaveProtocol.Id2ToIndex(id2);
|
||||
if (ri < 0) return;
|
||||
var u = _ui[(id2 == 0) ? 0 : ri]; // 거실2 → 거실 패널 표시
|
||||
|
||||
if (responded)
|
||||
{
|
||||
u.TxtStatus.Text = "● 응답";
|
||||
u.TxtStatus.Foreground = new SolidColorBrush(Color.FromRgb(0x9E, 0xCE, 0x6A));
|
||||
}
|
||||
else
|
||||
{
|
||||
u.TxtStatus.Text = "✗ 무응답";
|
||||
u.TxtStatus.Foreground = Brushes.Gray;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 로그 ==========
|
||||
private 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();
|
||||
});
|
||||
}
|
||||
|
||||
private void ClearLog_Click(object s, RoutedEventArgs e) => txtLog.Clear();
|
||||
|
||||
private void SaveLog_Click(object s, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new SaveFileDialog
|
||||
{
|
||||
Filter = "텍스트 파일 (*.txt)|*.txt",
|
||||
FileName = $"SimLog_{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}"); }
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnectionChanged(bool connected)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (connected)
|
||||
{
|
||||
statusLed.Fill = new SolidColorBrush(Color.FromRgb(0x9E, 0xCE, 0x6A));
|
||||
txtStatus.Text = "연결됨";
|
||||
btnStart.IsEnabled = true;
|
||||
btnConnect.Content = "연결 해제";
|
||||
}
|
||||
else
|
||||
{
|
||||
statusLed.Fill = new SolidColorBrush(Color.FromRgb(0xF7, 0x76, 0x8E));
|
||||
txtStatus.Text = "미연결";
|
||||
btnStart.IsEnabled = false;
|
||||
btnStop.IsEnabled = false;
|
||||
btnConnect.Content = "연결";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<UserControl x:Class="DiffuserSimulator.RoomPanel"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!-- 디퓨저 각실(방) 1개 패널 — 디자이너에서 이 레이아웃만 수정하면 5실에 모두 반영됨.
|
||||
컨트롤 동작(이벤트)은 MainWindow.BuildRoomPanels 에서 연결한다. -->
|
||||
<Border Background="{StaticResource SecondaryBgBrush}" CornerRadius="8" Padding="10" Margin="3">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
|
||||
<!-- 헤더: 활성 체크 + 색상 + 이름 / 상태 -->
|
||||
<Grid Margin="0,0,0,6">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<CheckBox x:Name="ChkEnabled" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<Ellipse x:Name="HdrColor" Width="8" Height="8" Fill="#7DCFFF"
|
||||
Margin="0,0,5,0" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="HdrName" Text="거실" FontSize="14" FontWeight="Bold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="TxtStatus" Text="대기" FontSize="10" HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center" Foreground="Gray"/>
|
||||
</Grid>
|
||||
|
||||
<TextBlock x:Name="TxtRxCount" Text="수신: 0" FontSize="9"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,4"/>
|
||||
<Separator Background="{StaticResource BorderBrush}" Margin="0,4,0,4"/>
|
||||
|
||||
<!-- 배기(RA) 디퓨저 -->
|
||||
<Grid Margin="0,4,0,2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="배기 댐퍼(열림)" FontSize="11" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<ToggleButton x:Name="TglEA" Grid.Column="1" Style="{StaticResource ToggleSwitch}"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,0,0,3">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="52"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock x:Name="TxtEALabel" Text="RA 각도" FontSize="10" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBox x:Name="TbEAAngle" Grid.Column="1" Text="0" FontSize="11" Padding="4,2"
|
||||
Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
</Grid>
|
||||
<!-- RA2 (거실2 = ID2 0x00) — 거실 전용. 힘펠 모드에서만 활성 -->
|
||||
<Grid x:Name="GridEA2" Margin="0,0,0,3" Visibility="Collapsed">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="52"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="RA2 각도" FontSize="10" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBox x:Name="TbEAAngle2" Grid.Column="1" Text="0" FontSize="11" Padding="4,2"
|
||||
Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
</Grid>
|
||||
<CheckBox x:Name="ChkCloseRA" Content="RA 수동 닫기" FontSize="10"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,2,0,2"/>
|
||||
|
||||
<Separator Background="{StaticResource BorderBrush}" Margin="0,4,0,4"/>
|
||||
|
||||
<!-- 공기질 센서값 -->
|
||||
<TextBlock Text="공기질 센서값" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,2,0,4"/>
|
||||
<Grid Margin="0,0,0,3">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="52"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
|
||||
<TextBlock Text="PM2.5" FontSize="10" VerticalAlignment="Center" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBox x:Name="TbPM25" Grid.Column="1" Text="25" FontSize="11" Padding="4,2"
|
||||
Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,0,0,3">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="52"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
|
||||
<TextBlock Text="PM10" FontSize="10" VerticalAlignment="Center" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBox x:Name="TbPM10" Grid.Column="1" Text="30" FontSize="11" Padding="4,2"
|
||||
Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,0,0,3">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="52"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
|
||||
<TextBlock Text="CO₂" FontSize="10" VerticalAlignment="Center" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBox x:Name="TbCO2" Grid.Column="1" Text="850" FontSize="11" Padding="4,2"
|
||||
Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,0,0,3">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="52"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
|
||||
<TextBlock Text="VOC" FontSize="10" VerticalAlignment="Center" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBox x:Name="TbVOC" Grid.Column="1" Text="115" FontSize="11" Padding="4,2"
|
||||
Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,0,0,3">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="52"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
|
||||
<TextBlock Text="TVOC" FontSize="10" VerticalAlignment="Center" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBox x:Name="TbTVOC" Grid.Column="1" Text="250" FontSize="11" Padding="4,2"
|
||||
Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,0,0,3">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="52"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
|
||||
<TextBlock Text="온도" FontSize="10" VerticalAlignment="Center" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBox x:Name="TbTemp" Grid.Column="1" Text="25" FontSize="11" Padding="4,2"
|
||||
Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,0,0,3">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="52"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
|
||||
<TextBlock Text="습도" FontSize="10" VerticalAlignment="Center" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBox x:Name="TbHumidity" Grid.Column="1" Text="50" FontSize="11" Padding="4,2"
|
||||
Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
</Grid>
|
||||
|
||||
<!-- 공기질 프리셋 (RbNoSensor=센서없음). GroupName 없이 부모 UniformGrid 단위로 그룹화 → 실별 독립 -->
|
||||
<TextBlock Text="프리셋" FontSize="9" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,2,0,4"/>
|
||||
<UniformGrid Columns="2" Margin="0,2,0,4">
|
||||
<RadioButton x:Name="RbGood" Content="좋음" Foreground="DodgerBlue" FontSize="10" FontWeight="SemiBold" Margin="0,0,0,2"/>
|
||||
<RadioButton x:Name="RbNormal" Content="보통" Foreground="LimeGreen" FontSize="10" FontWeight="SemiBold" Margin="0,0,0,2" IsChecked="True"/>
|
||||
<RadioButton x:Name="RbBad" Content="나쁨" Foreground="Orange" FontSize="10" FontWeight="SemiBold" Margin="0,0,0,2"/>
|
||||
<RadioButton x:Name="RbVeryBad" Content="매우나쁨" Foreground="OrangeRed" FontSize="10" FontWeight="SemiBold" Margin="0,0,0,2"/>
|
||||
<RadioButton x:Name="RbWorst" Content="최악" Foreground="Red" FontSize="10" FontWeight="SemiBold" Margin="0,0,0,2"/>
|
||||
<RadioButton x:Name="RbNoSensor" Content="센서없음" Foreground="Gray" FontSize="10" FontWeight="SemiBold" Margin="0,0,0,2"/>
|
||||
</UniformGrid>
|
||||
|
||||
<!-- 프리셋모드 (센서값 밴드 선택). 기본 NORMAL -->
|
||||
<TextBlock Text="프리셋모드" FontSize="9" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,2,0,4"/>
|
||||
<UniformGrid Columns="2" Margin="0,2,0,4">
|
||||
<RadioButton x:Name="RbEco" Content="ECO" Foreground="MediumAquamarine" FontSize="10" FontWeight="SemiBold" Margin="0,0,0,2"/>
|
||||
<RadioButton x:Name="RbNorm" Content="NORMAL" Foreground="LimeGreen" FontSize="10" FontWeight="SemiBold" Margin="0,0,0,2" IsChecked="True"/>
|
||||
<RadioButton x:Name="RbTurbo" Content="TURBO" Foreground="Orange" FontSize="10" FontWeight="SemiBold" Margin="0,0,0,2"/>
|
||||
</UniformGrid>
|
||||
|
||||
<!-- LED -->
|
||||
<Grid Margin="0,4,0,2">
|
||||
<TextBlock Text="LED" FontSize="11" Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<TextBlock x:Name="TxtLedVal" Text="0 (OFF)" FontSize="11" FontWeight="Bold"
|
||||
HorizontalAlignment="Right" Foreground="{StaticResource AccentYellowBrush}"/>
|
||||
</Grid>
|
||||
<Slider x:Name="SldLed" Minimum="0" Maximum="9" IsSnapToTickEnabled="True"
|
||||
TickFrequency="1" TickPlacement="BottomRight" Value="0"/>
|
||||
<CheckBox x:Name="ChkLedManual" Content="LED 디밍 수동 제어" FontSize="10"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,2,0,2"/>
|
||||
|
||||
<Separator Background="{StaticResource BorderBrush}" Margin="0,4,0,4"/>
|
||||
|
||||
<!-- 급기(SA) 디퓨저 -->
|
||||
<Grid Margin="0,4,0,2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="급기 댐퍼(열림)" FontSize="11" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<ToggleButton x:Name="TglSA" Grid.Column="1" Style="{StaticResource ToggleSwitch}"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,0,0,3">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="52"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock x:Name="TxtSALabel" Text="SA 각도" FontSize="10" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBox x:Name="TbSAAngle" Grid.Column="1" Text="0" FontSize="11" Padding="4,2"
|
||||
Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
</Grid>
|
||||
<!-- SA2 (거실2 = ID2 0x00) — 거실 전용 -->
|
||||
<Grid x:Name="GridSA2" Margin="0,0,0,3" Visibility="Collapsed">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="52"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="SA2 각도" FontSize="10" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBox x:Name="TbSAAngle2" Grid.Column="1" Text="0" FontSize="11" Padding="4,2"
|
||||
Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
</Grid>
|
||||
<CheckBox x:Name="ChkCloseSA" Content="SA 수동 닫기" FontSize="10"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,2,0,2"/>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace DiffuserSimulator
|
||||
{
|
||||
// 디퓨저 각실(방) 1개 패널. 레이아웃은 RoomPanel.xaml(디자이너 편집), 동작 연결은 MainWindow 가 담당.
|
||||
// x:Name 컨트롤들은 같은 어셈블리의 MainWindow 에서 internal 필드로 직접 접근한다.
|
||||
public partial class RoomPanel : UserControl
|
||||
{
|
||||
public int RxCount;
|
||||
|
||||
public RoomPanel()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
// 방 이름 + 헤더 색상 설정
|
||||
public void SetHeader(string name, Color color)
|
||||
{
|
||||
HdrName.Text = name;
|
||||
HdrColor.Fill = new SolidColorBrush(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
using System;
|
||||
using System.IO.Ports;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DiffuserSimulator
|
||||
{
|
||||
public class RoomSimData
|
||||
{
|
||||
public byte Id2;
|
||||
public bool Enabled;
|
||||
/* Master 모드 폴링 toggle — 급기/배기 디퓨저 별 활성화 */
|
||||
public bool PollSA = false;
|
||||
public bool PollRA = false;
|
||||
public byte Power = 0x01;
|
||||
public byte RunMode = 0x01;
|
||||
public byte FanSpeed = 0x00;
|
||||
public byte LedBrightness = 0x00;
|
||||
public byte AirQualityStatus = 0x03; // 보통 - green
|
||||
public byte DamperAngleSA = 0;
|
||||
public byte DamperAngleEA = 0;
|
||||
/* 수동 닫기 오버라이드 (Slave) — true면 마스터 개방명령 무시하고 0 유지 */
|
||||
public bool ManualCloseSA = false;
|
||||
public bool ManualCloseRA = false;
|
||||
/* LED 디밍 수동 제어 (Slave) — true면 마스터 LED 명령 무시하고 슬라이더 값 유지 */
|
||||
public bool ManualLed = false;
|
||||
/* 제품 모드 : false=DL(byte24~25 VOC 송신) / true=힘펠(TVOC 송신). RA2 활성에도 영향 (거실 한정) */
|
||||
public bool Himpel = false;
|
||||
/* RA(배기) 디퓨저 응답 활성 — 거실2는 힘펠 모드에서만 RA 동작 (DL 모드는 SA만) */
|
||||
public bool RaActive = true;
|
||||
/* 디폴트: 보통 preset */
|
||||
public int PM10 = 30;
|
||||
public int PM25 = 25;
|
||||
public int PM4 = 20;
|
||||
public int PM1 = 10;
|
||||
public int Humidity = 50;
|
||||
public int Temperature = 25;
|
||||
public int TVOC = 250;
|
||||
public int VOC = 115; /* VOC index (0~500), 보통 preset */
|
||||
public int NOx = 0;
|
||||
public int CO2 = 850;
|
||||
public ushort ErrorCode = 0x0000;
|
||||
public byte VersionMajor = 0x01;
|
||||
public byte VersionMinor = 0x00;
|
||||
}
|
||||
|
||||
public enum SimMode { Slave, Master }
|
||||
|
||||
public class SlaveProtocol : IDisposable
|
||||
{
|
||||
private SerialPort? _serialPort;
|
||||
private CancellationTokenSource? _listenCts;
|
||||
private readonly object _lock = new();
|
||||
private bool _disposed;
|
||||
private bool _responding;
|
||||
|
||||
// Rooms 인덱스 : 0=거실(Id2 1), 1=방1(Id2 2), 2=방2(Id2 3), 3=방3(Id2 4), 4=방4(Id2 5), 5=거실2(Id2 0).
|
||||
// 거실2(RA2/SA2)는 거실 패널이 제어. Rev1.3 : ID2 0x00=거실2, 0x01=거실, 0x02~0x05=방1~4.
|
||||
public RoomSimData[] Rooms = new RoomSimData[6];
|
||||
public const int LivingRoom2Index = 5;
|
||||
public SimMode Mode { get; private set; } = SimMode.Slave;
|
||||
|
||||
public event Action<byte[], byte>? MasterPacketReceived; // Slave mode: master packet received
|
||||
public event Action<byte[], byte, byte>? SlavePacketReceived; // Master mode: slave response received (id1, id2)
|
||||
public event Action<byte, bool>? ResponseSent; // id2, responded
|
||||
public event Action<byte, byte>? MasterPollSent; // Master mode: id1, id2 polled
|
||||
public event Action<string>? LogMessage;
|
||||
public event Action<bool>? ConnectionChanged;
|
||||
|
||||
public bool IsConnected => _serialPort?.IsOpen == true;
|
||||
public bool IsResponding => _responding;
|
||||
|
||||
public SlaveProtocol()
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
Rooms[i] = new RoomSimData { Id2 = (byte)(i + 1) };
|
||||
Rooms[LivingRoom2Index] = new RoomSimData { Id2 = 0x00 }; // 거실2
|
||||
}
|
||||
|
||||
// ID2 → Rooms 인덱스. 거실2(0)=5, 거실(1)=0, 방1~4(2~5)=1~4. 범위 밖이면 -1.
|
||||
public static int Id2ToIndex(byte id2) => id2 == 0 ? LivingRoom2Index : (id2 <= 5 ? id2 - 1 : -1);
|
||||
|
||||
public static ushort CalcCRC16(byte[] data, int length)
|
||||
{
|
||||
ushort crc = 0xFFFF;
|
||||
for (int i = 0; i < length; i++)
|
||||
{
|
||||
crc ^= data[i];
|
||||
for (int j = 0; j < 8; j++)
|
||||
crc = ((crc & 1) != 0) ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
public string[] GetAvailablePorts() => SerialPort.GetPortNames();
|
||||
|
||||
public bool Connect(string portName)
|
||||
{
|
||||
try
|
||||
{
|
||||
Disconnect();
|
||||
_serialPort = new SerialPort(portName)
|
||||
{
|
||||
BaudRate = 115200, DataBits = 8,
|
||||
StopBits = StopBits.One, Parity = Parity.None,
|
||||
ReadTimeout = 100, WriteTimeout = 500
|
||||
};
|
||||
_serialPort.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 (_serialPort?.IsOpen == true) { _serialPort.Close(); Log("[연결 해제]"); } } catch { }
|
||||
_serialPort?.Dispose();
|
||||
_serialPort = null;
|
||||
ConnectionChanged?.Invoke(false);
|
||||
}
|
||||
|
||||
public void StartResponding(int intervalMs = 1000)
|
||||
{
|
||||
StopResponding();
|
||||
Mode = SimMode.Slave;
|
||||
_responding = true;
|
||||
_listenCts = new CancellationTokenSource();
|
||||
var token = _listenCts.Token;
|
||||
|
||||
// 능동 송신 제거됨 — 마스터 polling 수신 시에만 응답.
|
||||
// (이전: 능동 송신 + 마스터 응답 dual mode → STM32 master 와 bus 충돌 우려 + STM32 측 cycle 안에 garbage 유입 가능)
|
||||
|
||||
// 수신 Task: 마스터 폴링이 오면 응답
|
||||
Task.Run(() =>
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_serialPort?.IsOpen != true) { Thread.Sleep(50); continue; }
|
||||
if (_serialPort.BytesToRead < 1) { Thread.Sleep(3); continue; }
|
||||
|
||||
byte b = (byte)_serialPort.ReadByte();
|
||||
if (b != 0xAA) continue;
|
||||
|
||||
byte[] rxBuf = new byte[29];
|
||||
rxBuf[0] = 0xAA;
|
||||
int totalRead = 1, remaining = 28, retries = 100;
|
||||
while (remaining > 0 && retries-- > 0)
|
||||
{
|
||||
if (_serialPort.BytesToRead > 0)
|
||||
{ int r = _serialPort.Read(rxBuf, totalRead, remaining); totalRead += r; remaining -= r; }
|
||||
else Thread.Sleep(2);
|
||||
}
|
||||
if (totalRead < 29) continue;
|
||||
|
||||
// CRC 바이트순서 = lo-first(표준 리틀엔디안). 펌웨어 CRC16()이 표준MODBUS의
|
||||
// 바이트스왑값을 반환 + [27]=icrc>>8 배치 → 두 스왑 상쇄 → 와이어는 [27]=하위,[28]=상위.
|
||||
ushort rxCrc = (ushort)(rxBuf[27] | (rxBuf[28] << 8));
|
||||
ushort calcCrc = CalcCRC16(rxBuf, 27);
|
||||
if (rxCrc != calcCrc) { Log($"[CRC오류] 수신:0x{rxCrc:X4} 계산:0x{calcCrc:X4}"); continue; }
|
||||
if (rxBuf[1] != 0x10) continue;
|
||||
|
||||
byte id2 = rxBuf[3];
|
||||
Log($"[RX] {BitConverter.ToString(rxBuf)}");
|
||||
/* MasterPacketReceived event invoke 는 room.DamperAngleSA/EA + LED 등
|
||||
실제 갱신 이후로 이동 — UI 가 새 값 표시. 이전엔 갱신 전 호출이라
|
||||
UI 가 한 cycle 전 (옛) 값 표시 → 사용자가 0/110 mismatch 보고. */
|
||||
|
||||
int ri = Id2ToIndex(id2);
|
||||
if (ri < 0) continue;
|
||||
var room = Rooms[ri];
|
||||
byte id1 = rxBuf[2];
|
||||
|
||||
// 응답 조건: 방 Enabled 면 SA/RA 모두 응답.
|
||||
// (배기/급기 토글은 댐퍼 열림/닫힘 표시용 — 각도 연동, 응답 게이트 아님)
|
||||
if (!room.Enabled)
|
||||
{
|
||||
ResponseSent?.Invoke(id2, false);
|
||||
continue;
|
||||
}
|
||||
// RA(배기 0x02) 디퓨저 비활성(거실2 DL 모드)이면 RA 폴링 무응답
|
||||
if (id1 == 0x02 && !room.RaActive)
|
||||
{
|
||||
ResponseSent?.Invoke(id2, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Option B 패킷 구분 (250624 dump 패턴 일치):
|
||||
// byte 5 = 0x01 → 명령 (Power ON, state 적용)
|
||||
// byte 5 = 0x00 → 폴링 (상태 조회만, state 무변경)
|
||||
// 폴링에서 byte 10/11/8 = 0 을 그대로 적용하면 댐퍼/LED 가 0 으로 reset 됨.
|
||||
bool isCommand = (rxBuf[5] != 0x00);
|
||||
if (isCommand)
|
||||
{
|
||||
// 마스터 명령 적용 — ID1 별로 해당 type 의 필드만 갱신.
|
||||
// ID1=0x01 (SA): damper SA 만
|
||||
// ID1=0x02 (RA): damper RA + LED + 공통 (Power/RunMode/Fan/Color)
|
||||
if (id1 == 0x01)
|
||||
{
|
||||
room.DamperAngleSA = room.ManualCloseSA ? (byte)0 : rxBuf[10];
|
||||
}
|
||||
else if (id1 == 0x02)
|
||||
{
|
||||
room.DamperAngleEA = room.ManualCloseRA ? (byte)0 : rxBuf[11];
|
||||
// 힘펠 모드는 LED 디밍 미사용 → 마스터 byte 8 무시(갱신 안 함)
|
||||
if (!room.ManualLed && !room.Himpel) room.LedBrightness = rxBuf[8];
|
||||
room.Power = rxBuf[5];
|
||||
room.RunMode = rxBuf[6];
|
||||
room.FanSpeed = rxBuf[7];
|
||||
if (rxBuf[9] != 0) room.AirQualityStatus = rxBuf[9];
|
||||
}
|
||||
}
|
||||
|
||||
/* room 갱신 후 UI 동기화 event. UI 의 TbSAAngle/TbEAAngle/LED 가 새 값 표시. */
|
||||
MasterPacketReceived?.Invoke(rxBuf, id2);
|
||||
|
||||
// 응답 전송
|
||||
byte[] tx = BuildResponse(room, id1, id2);
|
||||
lock (_lock)
|
||||
{
|
||||
_serialPort?.Write(tx, 0, tx.Length);
|
||||
}
|
||||
Log($"[TX 응답] {BitConverter.ToString(tx)}");
|
||||
ResponseSent?.Invoke(id2, true);
|
||||
}
|
||||
catch (TimeoutException) { }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!token.IsCancellationRequested) Log($"[오류] {ex.Message}");
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}, token);
|
||||
Log("[통신 시작] 마스터 응답 모드");
|
||||
}
|
||||
|
||||
public void StopResponding()
|
||||
{
|
||||
_responding = false;
|
||||
_listenCts?.Cancel();
|
||||
_listenCts?.Dispose();
|
||||
_listenCts = null;
|
||||
}
|
||||
|
||||
// ========================= Master Mode =========================
|
||||
// 시뮬레이터가 마스터 역할: enabled 된 room 들을 SA(0x01) + RA(0x02) 로 순회 polling.
|
||||
// STM32 (slave) 는 ID1/ID2 매칭 시 39 byte 응답.
|
||||
public void StartMasterPolling(int intervalMs = 1000)
|
||||
{
|
||||
StopResponding();
|
||||
Mode = SimMode.Master;
|
||||
_responding = true;
|
||||
_listenCts = new CancellationTokenSource();
|
||||
var token = _listenCts.Token;
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
if (_serialPort?.IsOpen != true) { await Task.Delay(100, token); continue; }
|
||||
|
||||
// Active polling slot 수 (Enabled + PollSA/RA 켜진 것)
|
||||
int activeSlots = 0;
|
||||
for (int i = 0; i < Rooms.Length; i++)
|
||||
{
|
||||
if (!Rooms[i].Enabled) continue;
|
||||
if (Rooms[i].PollSA) activeSlots++;
|
||||
if (Rooms[i].PollRA && Rooms[i].RaActive) activeSlots++;
|
||||
}
|
||||
if (activeSlots == 0) { await Task.Delay(intervalMs, token); continue; }
|
||||
|
||||
int slotMs = Math.Max(20, intervalMs / activeSlots);
|
||||
|
||||
// 각 enabled room 에 대해 SA → RA 폴링 (toggle 켜진 것만)
|
||||
foreach (byte id1 in new byte[] { 0x01, 0x02 })
|
||||
{
|
||||
for (int i = 0; i < Rooms.Length && !token.IsCancellationRequested; i++)
|
||||
{
|
||||
var room = Rooms[i];
|
||||
if (!room.Enabled) continue;
|
||||
if (id1 == 0x01 && !room.PollSA) continue;
|
||||
if (id1 == 0x02 && (!room.PollRA || !room.RaActive)) continue;
|
||||
|
||||
byte[] tx = BuildMasterPacket(room, id1);
|
||||
lock (_lock) { _serialPort?.Write(tx, 0, tx.Length); }
|
||||
Log($"[TX-M] ID1=0x{id1:X2} ID2=0x{room.Id2:X2} {BitConverter.ToString(tx)}");
|
||||
MasterPollSent?.Invoke(id1, room.Id2);
|
||||
|
||||
// Slave 응답 대기 (39 byte, 80ms timeout)
|
||||
byte[]? resp = TryReceiveSlaveResponse(80);
|
||||
if (resp != null)
|
||||
{
|
||||
Log($"[RX-S] ID1=0x{resp[2]:X2} ID2=0x{resp[3]:X2} {BitConverter.ToString(resp)}");
|
||||
SlavePacketReceived?.Invoke(resp, resp[2], resp[3]);
|
||||
|
||||
// 받은 SEN66 값을 room 에 갱신 (sensor 만)
|
||||
int ri = Id2ToIndex(resp[3]);
|
||||
if (ri >= 0)
|
||||
{
|
||||
var r = Rooms[ri];
|
||||
r.PM10 = (resp[12] << 8) | resp[13];
|
||||
r.PM4 = (resp[14] << 8) | resp[15];
|
||||
r.PM25 = (resp[16] << 8) | resp[17];
|
||||
r.PM1 = (resp[18] << 8) | resp[19];
|
||||
r.Humidity = (resp[20] << 8) | resp[21];
|
||||
r.Temperature = (resp[22] << 8) | resp[23];
|
||||
r.TVOC = (resp[24] << 8) | resp[25];
|
||||
r.NOx = (resp[26] << 8) | resp[27];
|
||||
r.CO2 = (resp[28] << 8) | resp[29];
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(slotMs, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex) { if (!token.IsCancellationRequested) Log($"[마스터 오류] {ex.Message}"); }
|
||||
}, token);
|
||||
|
||||
Log("[통신 시작] Master Mode — Polling 송신");
|
||||
}
|
||||
|
||||
public byte[] BuildMasterPacket(RoomSimData room, byte id1)
|
||||
{
|
||||
byte[] p = new byte[29];
|
||||
p[0] = 0xAA; p[1] = 0x10; p[2] = id1; p[3] = room.Id2; p[4] = 0x00;
|
||||
// 실제 protocol dump 분석 결과 0x80 Control bit 없음 — raw 값 그대로.
|
||||
p[5] = room.Power;
|
||||
p[6] = room.RunMode;
|
||||
p[7] = room.FanSpeed;
|
||||
// LED 디밍은 RA(0x02) 명령 패킷에만 전송 — SA(0x01)·힘펠은 0
|
||||
p[8] = (id1 == 0x02 && !room.Himpel) ? room.LedBrightness : (byte)0;
|
||||
p[9] = room.AirQualityStatus;
|
||||
p[10] = room.DamperAngleSA;
|
||||
p[11] = room.DamperAngleEA;
|
||||
// byte 12~26: 0 (RPM / Reset / 예약 등 미사용)
|
||||
ushort crc = CalcCRC16(p, 27);
|
||||
// lo-first (표준 리틀엔디안) — 와이어 [27]=하위, [28]=상위
|
||||
p[27] = (byte)(crc & 0xFF);
|
||||
p[28] = (byte)((crc >> 8) & 0xFF);
|
||||
return p;
|
||||
}
|
||||
|
||||
private byte[]? TryReceiveSlaveResponse(int timeoutMs)
|
||||
{
|
||||
if (_serialPort?.IsOpen != true) return null;
|
||||
long deadline = Environment.TickCount64 + timeoutMs;
|
||||
// header 0xAA 대기
|
||||
while (Environment.TickCount64 < deadline)
|
||||
{
|
||||
if (_serialPort.BytesToRead < 1) { Thread.Sleep(2); continue; }
|
||||
byte b = (byte)_serialPort.ReadByte();
|
||||
if (b != 0xAA) continue;
|
||||
|
||||
byte[] buf = new byte[39];
|
||||
buf[0] = 0xAA;
|
||||
int total = 1, remain = 38;
|
||||
while (remain > 0 && Environment.TickCount64 < deadline)
|
||||
{
|
||||
if (_serialPort.BytesToRead > 0)
|
||||
{
|
||||
int r = _serialPort.Read(buf, total, remain);
|
||||
total += r; remain -= r;
|
||||
}
|
||||
else Thread.Sleep(2);
|
||||
}
|
||||
if (total < 39) return null;
|
||||
if (buf[1] != 0x01) continue; // not slave
|
||||
|
||||
ushort rxCrc = (ushort)(buf[37] | (buf[38] << 8)); // lo-first (표준 리틀엔디안)
|
||||
ushort calc = CalcCRC16(buf, 37);
|
||||
if (rxCrc != calc) { Log($"[CRC오류] 수신:0x{rxCrc:X4} 계산:0x{calc:X4}"); return null; }
|
||||
return buf;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public byte[] BuildResponse(RoomSimData room, byte id1, byte id2)
|
||||
{
|
||||
byte[] p = new byte[39];
|
||||
p[0] = 0xAA; p[1] = 0x01; p[2] = id1; p[3] = id2; p[4] = 0x00;
|
||||
p[5] = room.Power; p[6] = room.RunMode; p[7] = room.FanSpeed;
|
||||
p[8] = room.LedBrightness; p[9] = room.AirQualityStatus;
|
||||
p[10] = room.DamperAngleSA; p[11] = room.DamperAngleEA;
|
||||
void W16(int idx, int val) { p[idx] = (byte)((val >> 8) & 0xFF); p[idx + 1] = (byte)(val & 0xFF); }
|
||||
W16(12, room.PM10); W16(14, room.PM4); W16(16, room.PM25); W16(18, room.PM1);
|
||||
W16(20, room.Humidity); W16(22, room.Temperature);
|
||||
W16(24, room.Himpel ? room.TVOC : room.VOC); /* byte 24,25 : DL=VOC / 힘펠=TVOC (Rev1.3) */
|
||||
W16(26, room.NOx); W16(28, room.CO2);
|
||||
p[30] = 0; p[31] = 0; p[32] = 0;
|
||||
p[33] = (byte)((room.ErrorCode >> 8) & 0xFF); p[34] = (byte)(room.ErrorCode & 0xFF);
|
||||
p[35] = room.VersionMajor; p[36] = room.VersionMinor;
|
||||
ushort crc = CalcCRC16(p, 37);
|
||||
// lo-first : 펌웨어 RX가 (Rx[37]<<8)|Rx[38] 로 읽고 CRC16()=스왑값과 비교 → lo-first로 보내야 일치
|
||||
p[37] = (byte)(crc & 0xFF); p[38] = (byte)((crc >> 8) & 0xFF);
|
||||
return p;
|
||||
}
|
||||
|
||||
private 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,125 @@
|
||||
<Application x:Class="ERVSimulator.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
<!-- Tokyo Night palette (DiffuserSimulator 와 동일) -->
|
||||
<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="AccentOrange">#FF9E64</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="AccentOrangeBrush" Color="{StaticResource AccentOrange}"/>
|
||||
<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="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>
|
||||
|
||||
<Style x:Key="ModernTextBox" TargetType="TextBox">
|
||||
<Setter Property="Background" Value="{StaticResource CardBgBrush}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="8,5"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
</Style>
|
||||
|
||||
<!-- 공용 카드 (섹션 패널) -->
|
||||
<Style x:Key="SectionCard" TargetType="Border">
|
||||
<Setter Property="Background" Value="{StaticResource SecondaryBgBrush}"/>
|
||||
<Setter Property="CornerRadius" Value="10"/>
|
||||
<Setter Property="Padding" Value="14,12"/>
|
||||
<Setter Property="Margin" Value="0,0,0,8"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="InnerCard" TargetType="Border">
|
||||
<Setter Property="Background" Value="{StaticResource CardBgBrush}"/>
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="Padding" Value="10"/>
|
||||
<Setter Property="Margin" Value="4"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SectionTitle" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentCyanBrush}"/>
|
||||
<Setter Property="FontWeight" Value="Bold"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Margin" Value="0,0,0,6"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="FieldLabel" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="FieldValue" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="FontFamily" Value="Consolas"/>
|
||||
</Style>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace ERVSimulator
|
||||
{
|
||||
public partial class App : Application
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ERVSimulator</RootNamespace>
|
||||
<AssemblyName>ERVSimulator</AssemblyName>
|
||||
<StartupObject>ERVSimulator.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>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- ERV↔Dashboard 공용 프로토콜 (단일 진실원본) -->
|
||||
<ProjectReference Include="..\..\..\TestProgram\ErvProtocol\ErvProtocol.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,96 @@
|
||||
<Window x:Class="ERVSimulator.HystWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="공기질 센서 히스테리시스" SizeToContent="WidthAndHeight"
|
||||
ResizeMode="NoResize" WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource PrimaryBgBrush}">
|
||||
<Border Style="{StaticResource SectionCard}" Margin="10">
|
||||
<StackPanel>
|
||||
<TextBlock Text="공기질 센서 히스테리시스 — 모드(프리셋)별 오염단계 임계 + 히스(하강)" Style="{StaticResource SectionTitle}"/>
|
||||
|
||||
<!-- 활성 프리셋 선택 -->
|
||||
<StackPanel Orientation="Horizontal" x:Name="PresetPanel" Margin="0,2,0,10">
|
||||
<TextBlock Text="활성 프리셋" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center" Width="84" FontSize="12" FontWeight="SemiBold"/>
|
||||
<Button Content="ECO" Tag="0" Click="Preset_Click" Style="{StaticResource ModernButton}" Margin="0,0,6,0" MinWidth="80" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="NORMAL" Tag="1" Click="Preset_Click" Style="{StaticResource ModernButton}" Margin="0,0,6,0" MinWidth="80" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="TURBO" Tag="2" Click="Preset_Click" Style="{StaticResource ModernButton}" Margin="0,0,6,0" MinWidth="80" Background="{StaticResource CardBgBrush}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="오염단계 0~4(좋음·보통·나쁨·매우나쁨·최악). 각 값은 해당 단계의 상한(이하). 4단계(최악)는 3단계 상한 초과. + 히스테리시스(하강)." Foreground="{StaticResource TextSecondaryBrush}" FontSize="10" Margin="0,0,0,6"/>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="84"/>
|
||||
<ColumnDefinition Width="84"/>
|
||||
<ColumnDefinition Width="84"/>
|
||||
<ColumnDefinition Width="84"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Text="CO2" TextAlignment="Center" FontSize="11" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="2" Text="PM2.5" TextAlignment="Center" FontSize="11" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="3" Text="PM10" TextAlignment="Center" FontSize="11" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="4" Text="VOC" TextAlignment="Center" FontSize="11" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="0단계(좋음)" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource AccentBlueBrush}"/>
|
||||
<TextBox x:Name="TCo2_1" Grid.Row="1" Grid.Column="1" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
<TextBox x:Name="TPm25_1" Grid.Row="1" Grid.Column="2" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
<TextBox x:Name="TPm10_1" Grid.Row="1" Grid.Column="3" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
<TextBox x:Name="TVoc_1" Grid.Row="1" Grid.Column="4" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="1단계(보통)" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource AccentGreenBrush}"/>
|
||||
<TextBox x:Name="TCo2_2" Grid.Row="2" Grid.Column="1" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
<TextBox x:Name="TPm25_2" Grid.Row="2" Grid.Column="2" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
<TextBox x:Name="TPm10_2" Grid.Row="2" Grid.Column="3" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
<TextBox x:Name="TVoc_2" Grid.Row="2" Grid.Column="4" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="2단계(나쁨)" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource AccentYellowBrush}"/>
|
||||
<TextBox x:Name="TCo2_3" Grid.Row="3" Grid.Column="1" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
<TextBox x:Name="TPm25_3" Grid.Row="3" Grid.Column="2" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
<TextBox x:Name="TPm10_3" Grid.Row="3" Grid.Column="3" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
<TextBox x:Name="TVoc_3" Grid.Row="3" Grid.Column="4" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
|
||||
<TextBlock Grid.Row="4" Grid.Column="0" Text="3단계(매우나쁨)" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource AccentRedBrush}"/>
|
||||
<TextBox x:Name="TCo2_4" Grid.Row="4" Grid.Column="1" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
<TextBox x:Name="TPm25_4" Grid.Row="4" Grid.Column="2" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
<TextBox x:Name="TPm10_4" Grid.Row="4" Grid.Column="3" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
<TextBox x:Name="TVoc_4" Grid.Row="4" Grid.Column="4" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
|
||||
<TextBlock Grid.Row="5" Grid.Column="0" Text="4단계(최악)" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource AccentRedBrush}"/>
|
||||
<Border Grid.Row="5" Grid.Column="1" Margin="2,1" Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1">
|
||||
<TextBlock x:Name="MCo2" TextAlignment="Center" FontSize="11" Padding="3,2" Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
</Border>
|
||||
<Border Grid.Row="5" Grid.Column="2" Margin="2,1" Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1">
|
||||
<TextBlock x:Name="MPm25" TextAlignment="Center" FontSize="11" Padding="3,2" Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
</Border>
|
||||
<Border Grid.Row="5" Grid.Column="3" Margin="2,1" Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1">
|
||||
<TextBlock x:Name="MPm10" TextAlignment="Center" FontSize="11" Padding="3,2" Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
</Border>
|
||||
<Border Grid.Row="5" Grid.Column="4" Margin="2,1" Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1">
|
||||
<TextBlock x:Name="MVoc" TextAlignment="Center" FontSize="11" Padding="3,2" Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Row="6" Grid.Column="0" Text="히스(하강)" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource AccentYellowBrush}"/>
|
||||
<TextBox x:Name="DCo2" Grid.Row="6" Grid.Column="1" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource AccentYellowBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
<TextBox x:Name="DPm25" Grid.Row="6" Grid.Column="2" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource AccentYellowBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
<TextBox x:Name="DPm10" Grid.Row="6" Grid.Column="3" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource AccentYellowBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
<TextBox x:Name="DVoc" Grid.Row="6" Grid.Column="4" TextAlignment="Center" FontSize="11" Margin="2,1" Padding="3,2" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource AccentYellowBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Margin="0,14,0,0" HorizontalAlignment="Right">
|
||||
<Button Content="적용" Width="100" Style="{StaticResource ModernButton}" Click="Apply_Click"
|
||||
Margin="0,0,6,0" Background="{StaticResource AccentBlueBrush}"/>
|
||||
<Button Content="닫기" Width="90" Style="{StaticResource ModernButton}" Click="Close_Click"
|
||||
Background="{StaticResource CardBgBrush}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Window>
|
||||
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using ERVSimulator.Model;
|
||||
|
||||
namespace ERVSimulator
|
||||
{
|
||||
// 공기질 센서 히스테리시스 팝업 : 활성 프리셋(ECO/NORMAL/TURBO)의 오염단계 임계 + 히스(하강) 표시·수정
|
||||
public partial class HystWindow : Window
|
||||
{
|
||||
readonly ErvState _state;
|
||||
public event Action<string>? Applied;
|
||||
|
||||
Brush Br(string key) => (Brush)FindResource(key);
|
||||
|
||||
public HystWindow(ErvState state)
|
||||
{
|
||||
InitializeComponent();
|
||||
_state = state;
|
||||
// MainWindow 프리셋 버튼 / 대시보드 CTRL 로 HystPreset 변경 시 팝업도 즉시 동기화
|
||||
_state.PropertyChanged += OnStateChanged;
|
||||
RefreshPreset();
|
||||
}
|
||||
|
||||
void OnStateChanged(object? s, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(ErvState.HystPreset))
|
||||
Dispatcher.BeginInvoke(new Action(RefreshPreset));
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
_state.PropertyChanged -= OnStateChanged;
|
||||
base.OnClosed(e);
|
||||
}
|
||||
|
||||
void RefreshPreset()
|
||||
{
|
||||
foreach (var child in PresetPanel.Children)
|
||||
{
|
||||
if (child is Button btn && btn.Tag is string tag && byte.TryParse(tag, out var p))
|
||||
{
|
||||
bool active = p == _state.HystPreset;
|
||||
btn.Background = active ? Br("AccentCyanBrush") : Br("CardBgBrush");
|
||||
btn.Foreground = active ? Brushes.Black : Br("TextPrimaryBrush");
|
||||
}
|
||||
}
|
||||
FillGrid(_state.HystPreset);
|
||||
}
|
||||
|
||||
// 활성 프리셋 값으로 표 채우기
|
||||
void FillGrid(int p)
|
||||
{
|
||||
TCo2_1.Text = _state.Co2Thr[p][0].ToString(); TCo2_2.Text = _state.Co2Thr[p][1].ToString(); TCo2_3.Text = _state.Co2Thr[p][2].ToString(); TCo2_4.Text = _state.Co2Thr[p][3].ToString();
|
||||
TPm25_1.Text = _state.Pm25Thr[p][0].ToString(); TPm25_2.Text = _state.Pm25Thr[p][1].ToString(); TPm25_3.Text = _state.Pm25Thr[p][2].ToString(); TPm25_4.Text = _state.Pm25Thr[p][3].ToString();
|
||||
TPm10_1.Text = _state.Pm10Thr[p][0].ToString(); TPm10_2.Text = _state.Pm10Thr[p][1].ToString(); TPm10_3.Text = _state.Pm10Thr[p][2].ToString(); TPm10_4.Text = _state.Pm10Thr[p][3].ToString();
|
||||
TVoc_1.Text = _state.VocThr[p][0].ToString(); TVoc_2.Text = _state.VocThr[p][1].ToString(); TVoc_3.Text = _state.VocThr[p][2].ToString(); TVoc_4.Text = _state.VocThr[p][3].ToString();
|
||||
DCo2.Text = _state.Co2Db[p].ToString(); DPm25.Text = _state.Pm25Db[p].ToString(); DPm10.Text = _state.Pm10Db[p].ToString(); DVoc.Text = _state.VocDb[p].ToString();
|
||||
// 4단계(최악) : 3단계 상한 초과 = (상한+1)~ (사양서 10p)
|
||||
MCo2.Text = $"{_state.Co2Thr[p][3] + 1}~"; MPm25.Text = $"{_state.Pm25Thr[p][3] + 1}~";
|
||||
MPm10.Text = $"{_state.Pm10Thr[p][3] + 1}~"; MVoc.Text = $"{_state.VocThr[p][3] + 1}~";
|
||||
}
|
||||
|
||||
static ushort P(TextBox tb) { ushort.TryParse(tb.Text, out var v); return v; }
|
||||
|
||||
void Preset_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Button b && b.Tag is string tag && byte.TryParse(tag, out var p) && p < 3)
|
||||
{
|
||||
_state.HystPreset = p;
|
||||
RefreshPreset();
|
||||
Applied?.Invoke($"[Manual] 히스테리시스 프리셋 → {(p == 0 ? "ECO" : p == 1 ? "NORMAL" : "TURBO")}");
|
||||
}
|
||||
}
|
||||
|
||||
void Apply_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
int p = _state.HystPreset;
|
||||
_state.Co2Thr[p][0] = P(TCo2_1); _state.Co2Thr[p][1] = P(TCo2_2); _state.Co2Thr[p][2] = P(TCo2_3); _state.Co2Thr[p][3] = P(TCo2_4);
|
||||
_state.Pm25Thr[p][0] = P(TPm25_1); _state.Pm25Thr[p][1] = P(TPm25_2); _state.Pm25Thr[p][2] = P(TPm25_3); _state.Pm25Thr[p][3] = P(TPm25_4);
|
||||
_state.Pm10Thr[p][0] = P(TPm10_1); _state.Pm10Thr[p][1] = P(TPm10_2); _state.Pm10Thr[p][2] = P(TPm10_3); _state.Pm10Thr[p][3] = P(TPm10_4);
|
||||
_state.VocThr[p][0] = P(TVoc_1); _state.VocThr[p][1] = P(TVoc_2); _state.VocThr[p][2] = P(TVoc_3); _state.VocThr[p][3] = P(TVoc_4);
|
||||
_state.Co2Db[p] = P(DCo2); _state.Pm25Db[p] = P(DPm25); _state.Pm10Db[p] = P(DPm10); _state.VocDb[p] = P(DVoc);
|
||||
|
||||
Applied?.Invoke($"[Manual] 공기질 센서 히스테리시스 적용 (프리셋 {(p == 0 ? "ECO" : p == 1 ? "NORMAL" : "TURBO")})");
|
||||
}
|
||||
|
||||
void Close_Click(object sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
<Window x:Class="ERVSimulator.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:ERVSimulator"
|
||||
Title="ERV 시뮬레이터 - ERV Simulator"
|
||||
Width="1500" Height="880"
|
||||
MinWidth="1400" MinHeight="800"
|
||||
Background="{StaticResource PrimaryBgBrush}"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<Window.Resources>
|
||||
<local:BoolToOpenCloseConverter x:Key="BoolOC"/>
|
||||
<local:BoolToBrushConverter x:Key="BoolBrush"/>
|
||||
<local:ColorTagToBrushConverter x:Key="TagBrush"/>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="14">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/> <!-- 0: Title/Connection -->
|
||||
<RowDefinition Height="Auto"/> <!-- 1: Power + RunMode + Errors -->
|
||||
<RowDefinition Height="Auto"/> <!-- 2: BodyDampers -->
|
||||
<RowDefinition Height="Auto"/> <!-- 3: 자동운전 상태 -->
|
||||
<RowDefinition Height="*"/> <!-- 4: Log -->
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Row 0: 타이틀 + 통신 설정 -->
|
||||
<Border Grid.Row="0" Style="{StaticResource SectionCard}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="ERV 시뮬레이터" FontSize="20" FontWeight="Bold"
|
||||
Foreground="{StaticResource AccentCyanBrush}" Margin="0,0,14,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="0,0,24,0">
|
||||
<TextBlock Text="만든이 : 전경선" Foreground="{StaticResource TextSecondaryBrush}" FontSize="10"/>
|
||||
<TextBlock Text="수정일 : 2026.05.22" Foreground="{StaticResource TextSecondaryBrush}" FontSize="10"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="3" Orientation="Vertical">
|
||||
<!-- 통신 포트 카드 1x4 (각 포트 = 2줄: 상단 라벨·포트·통신속도 / 하단 연결·해제·상태) -->
|
||||
<UniformGrid Columns="4">
|
||||
<!-- RoomCon -->
|
||||
<Border Style="{StaticResource InnerCard}" Margin="0,0,6,0" Padding="8,6">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
|
||||
<TextBlock Text="RoomCon" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center"
|
||||
Width="62" FontSize="11" FontWeight="SemiBold"/>
|
||||
<ComboBox x:Name="RoomConPortCombo" Width="92" Style="{StaticResource ModernComboBox}" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="9600 8N1" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center" FontSize="11"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button x:Name="RoomConConnectBtn" Content="연결" Style="{StaticResource ModernButton}" Click="RoomConConnect_Click" Padding="12,5" Margin="0,0,4,0"/>
|
||||
<Button x:Name="RoomConDisconnectBtn" Content="해제" Style="{StaticResource ModernButton}" Click="RoomConDisconnect_Click" Padding="12,5"
|
||||
Background="{StaticResource AccentRedBrush}"/>
|
||||
<Ellipse x:Name="RoomConStatus" Width="10" Height="10" Margin="10,0,4,0" Fill="#F7768E" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="RoomConStatusText" Text="미연결" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center" FontSize="11"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- HomeNet -->
|
||||
<Border Style="{StaticResource InnerCard}" Margin="0,0,6,0" Padding="8,6">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
|
||||
<TextBlock Text="HomeNet" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center"
|
||||
Width="62" FontSize="11" FontWeight="SemiBold"/>
|
||||
<ComboBox x:Name="HomeNetPortCombo" Width="92" Style="{StaticResource ModernComboBox}" Margin="0,0,6,0"/>
|
||||
<ComboBox x:Name="HomeNetBaudCombo" Width="76" Style="{StaticResource ModernComboBox}" SelectedIndex="5">
|
||||
<ComboBoxItem Content="4800"/>
|
||||
<ComboBoxItem Content="9600"/>
|
||||
<ComboBoxItem Content="19200"/>
|
||||
<ComboBoxItem Content="38400"/>
|
||||
<ComboBoxItem Content="57600"/>
|
||||
<ComboBoxItem Content="115200"/>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button x:Name="HomeNetConnectBtn" Content="연결" Style="{StaticResource ModernButton}" Click="HomeNetConnect_Click" Padding="12,5" Margin="0,0,4,0"/>
|
||||
<Button x:Name="HomeNetDisconnectBtn" Content="해제" Style="{StaticResource ModernButton}" Click="HomeNetDisconnect_Click" Padding="12,5"
|
||||
Background="{StaticResource AccentRedBrush}"/>
|
||||
<Ellipse x:Name="HomeNetStatus" Width="10" Height="10" Margin="10,0,4,0" Fill="#F7768E" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="HomeNetStatusText" Text="미연결" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center" FontSize="11"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Diffuser (DiffuserSimulator 센서 수신) -->
|
||||
<Border Style="{StaticResource InnerCard}" Margin="0,0,6,0" Padding="8,6">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
|
||||
<TextBlock Text="Diffuser" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center"
|
||||
Width="62" FontSize="11" FontWeight="SemiBold"/>
|
||||
<ComboBox x:Name="DiffuserPortCombo" Width="92" Style="{StaticResource ModernComboBox}" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="115200 8N1" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center" FontSize="11"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button x:Name="DiffuserConnectBtn" Content="연결" Style="{StaticResource ModernButton}" Click="DiffuserConnect_Click" Padding="12,5" Margin="0,0,4,0"/>
|
||||
<Button x:Name="DiffuserDisconnectBtn" Content="해제" Style="{StaticResource ModernButton}" Click="DiffuserDisconnect_Click" Padding="12,5"
|
||||
Background="{StaticResource AccentRedBrush}"/>
|
||||
<Ellipse x:Name="DiffuserStatus" Width="10" Height="10" Margin="10,0,4,0" Fill="#F7768E" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="DiffuserStatusText" Text="미연결" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center" FontSize="11"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Hood (후드메인 연동 - HOOD 프로토콜 Rev1.3) -->
|
||||
<Border Style="{StaticResource InnerCard}" Padding="8,6">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
|
||||
<TextBlock Text="Hood" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center"
|
||||
Width="62" FontSize="11" FontWeight="SemiBold"/>
|
||||
<ComboBox x:Name="HoodPortCombo" Width="92" Style="{StaticResource ModernComboBox}" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="115200 8N1" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center" FontSize="11"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button x:Name="HoodConnectBtn" Content="연결" Style="{StaticResource ModernButton}" Click="HoodConnect_Click" Padding="12,5" Margin="0,0,4,0"/>
|
||||
<Button x:Name="HoodDisconnectBtn" Content="해제" Style="{StaticResource ModernButton}" Click="HoodDisconnect_Click" Padding="12,5"
|
||||
Background="{StaticResource AccentRedBrush}"/>
|
||||
<Ellipse x:Name="HoodStatus" Width="10" Height="10" Margin="10,0,4,0" Fill="#F7768E" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="HoodStatusText" Text="미연결" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center" FontSize="11"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UniformGrid>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,6,0,0">
|
||||
<Button Content="⟳ 포트 새로고침" Style="{StaticResource ModernButton}" Click="RefreshPorts_Click"
|
||||
Background="{StaticResource CardBgBrush}" Padding="10,4" FontSize="11"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Row 1: 전원 + 운전 모드 + 에러 코드 -->
|
||||
<Grid Grid.Row="1">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Grid.Column="0" Style="{StaticResource SectionCard}" Margin="0,0,8,8">
|
||||
<StackPanel>
|
||||
<TextBlock Text="전원" Style="{StaticResource SectionTitle}"/>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Border x:Name="PowerOnCard" Width="80" Padding="10,6" CornerRadius="6"
|
||||
Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" Margin="0,0,6,0">
|
||||
<TextBlock Text="ON" HorizontalAlignment="Center" FontWeight="Bold" FontSize="16"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
</Border>
|
||||
<Border x:Name="PowerOffCard" Width="80" Padding="10,6" CornerRadius="6"
|
||||
Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1">
|
||||
<TextBlock Text="OFF" HorizontalAlignment="Center" FontWeight="Bold" FontSize="16"
|
||||
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="1" Style="{StaticResource SectionCard}" Margin="0,0,8,8">
|
||||
<StackPanel>
|
||||
<TextBlock Text="운전 모드" Style="{StaticResource SectionTitle}"/>
|
||||
<!-- 운전모드 + 풍량 + (꺼짐)예약 한 줄 -->
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<StackPanel Orientation="Horizontal" x:Name="ModePanel" VerticalAlignment="Center">
|
||||
<Button Content="환기" Tag="Ventilation" Click="ModeButton_Click" Style="{StaticResource ModernButton}" Margin="0,0,6,0" MinWidth="70"
|
||||
Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="자동" Tag="Auto" Click="ModeButton_Click" Style="{StaticResource ModernButton}" Margin="0,0,6,0" MinWidth="70"
|
||||
Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="공청" Tag="AirClean" Click="ModeButton_Click" Style="{StaticResource ModernButton}" Margin="0,0,6,0" MinWidth="70"
|
||||
Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="바이패스" Tag="Bypass" Click="ModeButton_Click" Style="{StaticResource ModernButton}" Margin="0,0,0,0" MinWidth="70"
|
||||
Background="{StaticResource CardBgBrush}"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="풍량" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center" FontSize="12" FontWeight="SemiBold" Margin="18,0,8,0"/>
|
||||
<StackPanel Orientation="Horizontal" x:Name="FanPanel" VerticalAlignment="Center">
|
||||
<Button Content="0" Tag="0" Click="FanButton_Click" Style="{StaticResource ModernButton}" Margin="0,0,4,0" MinWidth="42" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="1" Tag="1" Click="FanButton_Click" Style="{StaticResource ModernButton}" Margin="0,0,4,0" MinWidth="42" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="2" Tag="2" Click="FanButton_Click" Style="{StaticResource ModernButton}" Margin="0,0,4,0" MinWidth="42" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="3" Tag="3" Click="FanButton_Click" Style="{StaticResource ModernButton}" Margin="0,0,4,0" MinWidth="42" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="4" Tag="4" Click="FanButton_Click" Style="{StaticResource ModernButton}" Margin="0,0,0,0" MinWidth="42" Background="{StaticResource CardBgBrush}"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="예약(꺼짐)" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center" FontSize="12" FontWeight="SemiBold" Margin="18,0,8,0"/>
|
||||
<ComboBox x:Name="ReserveCombo" Width="90" Style="{StaticResource ModernComboBox}" VerticalAlignment="Center" SelectionChanged="ReserveCombo_Changed" SelectedIndex="0">
|
||||
<ComboBoxItem Content="해제"/>
|
||||
<ComboBoxItem Content="1시간"/>
|
||||
<ComboBoxItem Content="2시간"/>
|
||||
<ComboBoxItem Content="3시간"/>
|
||||
<ComboBoxItem Content="4시간"/>
|
||||
<ComboBoxItem Content="5시간"/>
|
||||
<ComboBoxItem Content="6시간"/>
|
||||
<ComboBoxItem Content="7시간"/>
|
||||
<ComboBoxItem Content="8시간"/>
|
||||
</ComboBox>
|
||||
<TextBlock x:Name="ReserveText" Text="예약 없음" Foreground="{StaticResource AccentYellowBrush}" VerticalAlignment="Center" FontSize="12" Margin="10,0,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 자동모드 프리셋(절전/표준/쾌속) : 자동 선택 시에만 활성, 기본 표준(NORMAL).
|
||||
선택 프리셋이 공기질 판정 임계(=히스테리시스 임계)를 결정. Tag=프리셋 인덱스 0/1/2 -->
|
||||
<StackPanel Orientation="Horizontal" x:Name="PresetPanel" Margin="0,8,0,0" VerticalAlignment="Center">
|
||||
<Button Content="절전 (ECO)" Tag="0" Click="PresetButton_Click" Style="{StaticResource ModernButton}" Margin="0,0,6,0" MinWidth="96" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="표준 (NORMAL)" Tag="1" Click="PresetButton_Click" Style="{StaticResource ModernButton}" Margin="0,0,6,0" MinWidth="96" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="쾌속 (TURBO)" Tag="2" Click="PresetButton_Click" Style="{StaticResource ModernButton}" Margin="0,0,16,0" MinWidth="96" Background="{StaticResource CardBgBrush}"/>
|
||||
<!-- 공기질 센서 히스테리시스 + 풍량 VSP + 후드연동 (쾌속 옆) -->
|
||||
<Button Content="공기질 센서 히스테리시스 ▸" Click="OpenHyst_Click" Style="{StaticResource ModernButton}" Margin="0,0,6,0" MinWidth="84" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="풍량 VSP ▸" Click="OpenVsp_Click" Style="{StaticResource ModernButton}" Margin="0,0,6,0" Padding="12,4" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button x:Name="HoodLinkBtn" Content="후드연동" IsHitTestVisible="False" Style="{StaticResource ModernButton}"
|
||||
Padding="12,4" Background="{StaticResource CardBgBrush}" Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<Ellipse x:Name="HoodCommLed" Width="9" Height="9" Fill="#F7768E" VerticalAlignment="Center" Margin="8,0,5,0"/>
|
||||
<TextBlock x:Name="HoodCommText" Text="후드 통신 안됨" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 에러 코드 (운전모드 옆, 클릭 토글) -->
|
||||
<Border Grid.Column="2" Style="{StaticResource SectionCard}" Margin="0,0,0,8">
|
||||
<StackPanel>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock Text="에러 코드" Style="{StaticResource SectionTitle}" Margin="0"/>
|
||||
<TextBlock x:Name="ErrorCodeHex" DockPanel.Dock="Right" Style="{StaticResource FieldValue}"
|
||||
VerticalAlignment="Center" Margin="10,0,0,0" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
</DockPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Border x:Name="ErrCard_E02" Tag="E02" MouseDown="ErrorCard_Click" Cursor="Hand" CornerRadius="4" Padding="6,3" Margin="0,0,4,0"
|
||||
Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" ToolTip="온도센서 에러">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse x:Name="ErrLed_E02" Width="9" Height="9" Fill="#3B3B55" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="E02" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border x:Name="ErrCard_E09" Tag="E09" MouseDown="ErrorCard_Click" Cursor="Hand" CornerRadius="4" Padding="6,3" Margin="0,0,4,0"
|
||||
Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" ToolTip="급기(SA) 팬 에러">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse x:Name="ErrLed_E09" Width="9" Height="9" Fill="#3B3B55" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="E09" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border x:Name="ErrCard_E10" Tag="E10" MouseDown="ErrorCard_Click" Cursor="Hand" CornerRadius="4" Padding="6,3" Margin="0,0,4,0"
|
||||
Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" ToolTip="배기(EA) 팬 에러">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse x:Name="ErrLed_E10" Width="9" Height="9" Fill="#3B3B55" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="E10" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border x:Name="ErrCard_COLD" Tag="COLD" MouseDown="ErrorCard_Click" Cursor="Hand" CornerRadius="4" Padding="6,3" Margin="0,0,4,0"
|
||||
Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" ToolTip="장비보호모드">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse x:Name="ErrLed_COLD" Width="9" Height="9" Fill="#3B3B55" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="COLD" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border x:Name="ErrCard_E07" Tag="E07" MouseDown="ErrorCard_Click" Cursor="Hand" CornerRadius="4" Padding="6,3"
|
||||
Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" ToolTip="내부 통신 에러">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse x:Name="ErrLed_E07" Width="9" Height="9" Fill="#3B3B55" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="E07" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!-- 알람(유지보수) : 필터 청소/교환 -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,5,0,0">
|
||||
<Border x:Name="ErrCard_FCLEAN" Tag="FCLEAN" MouseDown="ErrorCard_Click" Cursor="Hand" CornerRadius="4" Padding="6,3" Margin="0,0,4,0"
|
||||
Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" ToolTip="필터 청소 알람">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse x:Name="ErrLed_FCLEAN" Width="9" Height="9" Fill="#3B3B55" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="필터청소" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border x:Name="ErrCard_FCHANGE" Tag="FCHANGE" MouseDown="ErrorCard_Click" Cursor="Hand" CornerRadius="4" Padding="6,3"
|
||||
Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" ToolTip="필터 교환 알람">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse x:Name="ErrLed_FCHANGE" Width="9" Height="9" Fill="#3B3B55" VerticalAlignment="Center" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="필터교환" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Row 2: 본체 댐퍼 (6개) -->
|
||||
<Border Grid.Row="2" Style="{StaticResource SectionCard}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="댐퍼 동작 (본체)" Style="{StaticResource SectionTitle}"/>
|
||||
<ItemsControl x:Name="DamperItems">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Columns="6"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Style="{StaticResource InnerCard}">
|
||||
<StackPanel>
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Left" Width="14" Height="14" CornerRadius="7"
|
||||
Background="{Binding ColorTag, Converter={StaticResource TagBrush}}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="{Binding Name}" FontWeight="Bold" Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="{Binding Connector}" Foreground="{StaticResource TextSecondaryBrush}" FontSize="11"/>
|
||||
<TextBlock FontSize="11" Foreground="{StaticResource TextPrimaryBrush}" FontFamily="Consolas">
|
||||
<Run Text="각도: "/>
|
||||
<Run Text="{Binding TargetAngle, Mode=OneWay}"/>
|
||||
<Run Text="°"/>
|
||||
</TextBlock>
|
||||
<TextBlock FontWeight="Bold" Margin="0,4,0,0" FontSize="13"
|
||||
Text="{Binding IsOpen, Converter={StaticResource BoolOC}}"
|
||||
Foreground="{Binding IsOpen, Converter={StaticResource BoolBrush}}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Row 3: DL 각실제어 (시나리오모드 + 자동운전 상태) -->
|
||||
<Border Grid.Row="3" Style="{StaticResource SectionCard}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="DL 각실제어" Style="{StaticResource SectionTitle}"/>
|
||||
|
||||
<!-- 시나리오모드 -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,2,0,0" x:Name="SubModePanel">
|
||||
<TextBlock Text="시나리오모드" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center" Width="84" FontSize="12" FontWeight="SemiBold"/>
|
||||
<Button Content="스마트수면" Tag="Sleep" Click="SubModeButton_Click" Style="{StaticResource ModernButton}" Margin="0,0,6,0" MinWidth="84" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="쾌적조리" Tag="Cook" Click="SubModeButton_Click" Style="{StaticResource ModernButton}" Margin="0,0,6,0" MinWidth="84" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="안심회복" Tag="Recovery" Click="SubModeButton_Click" Style="{StaticResource ModernButton}" Margin="0,0,16,0" MinWidth="84" Background="{StaticResource CardBgBrush}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 자동운전 상태 -->
|
||||
<DockPanel Margin="0,10,0,4">
|
||||
<TextBlock Text="자동운전 상태" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center"/>
|
||||
<Border DockPanel.Dock="Right" Background="{StaticResource CardBgBrush}" CornerRadius="6" Padding="10,3"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1">
|
||||
<TextBlock VerticalAlignment="Center" FontWeight="Bold" FontSize="13" Foreground="{StaticResource AccentCyanBrush}">
|
||||
<Run Text="동작: "/>
|
||||
<Run x:Name="AutoStateRun" Text="-"/>
|
||||
<Run Text=" (분산 / 집중)" Foreground="{StaticResource TextSecondaryBrush}" FontWeight="Normal"/>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
</DockPanel>
|
||||
<ItemsControl x:Name="RoomLoadItems">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Columns="4"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Style="{StaticResource InnerCard}" Margin="3">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Name}" HorizontalAlignment="Center" FontSize="11" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBlock Text="{Binding Level, Mode=OneWay}" HorizontalAlignment="Center" FontSize="20" FontWeight="Bold" Foreground="{StaticResource AccentBlueBrush}"/>
|
||||
<TextBlock Text="{Binding SensorText, Mode=OneWay}" HorizontalAlignment="Center" FontSize="9" Foreground="{StaticResource TextSecondaryBrush}" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Row 4: 통신 로그 -->
|
||||
<Border Grid.Row="4" Style="{StaticResource SectionCard}">
|
||||
<DockPanel>
|
||||
<Grid DockPanel.Dock="Top" Margin="0,0,0,6">
|
||||
<TextBlock Text="통신 로그" Style="{StaticResource SectionTitle}" Margin="0"/>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Content="로그 저장" Style="{StaticResource ModernButton}"
|
||||
Background="{StaticResource AccentBlueBrush}" Padding="10,3" FontSize="11" Margin="0,0,6,0"
|
||||
Click="SaveLog_Click"/>
|
||||
<Button Content="로그 지우기" Style="{StaticResource ModernButton}"
|
||||
Background="{StaticResource CardBgBrush}" Padding="10,3" FontSize="11"
|
||||
Click="ClearLog_Click"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<TextBox x:Name="LogList" IsReadOnly="True" Background="{StaticResource CardBgBrush}"
|
||||
Foreground="{StaticResource TextPrimaryBrush}" BorderBrush="{StaticResource BorderBrush}"
|
||||
BorderThickness="1" FontFamily="Consolas" FontSize="11"
|
||||
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
|
||||
TextWrapping="NoWrap" Padding="6"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,485 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace ERVSimulator.Model
|
||||
{
|
||||
// 펌웨어 [My_system.c] Air_Quality_damper_process() 포팅 (260520 사양)
|
||||
// - 실별 4종센서 → 0~4 Level (모드별 임계 + 하강 히스테리시스)
|
||||
// - 부하총점(Score) → 풍량단수, P_max/dP → 댐퍼(대기/집중/분산)
|
||||
// - 1초 주기. RunMode==Auto && PowerOn 일 때만 댐퍼/풍량 구동.
|
||||
public class AutoLogic
|
||||
{
|
||||
readonly ErvState _state;
|
||||
readonly DamperSequencer _seq;
|
||||
readonly DispatcherTimer _timer;
|
||||
public event Action<string>? Log;
|
||||
|
||||
// 센서별 이전 단계(히스테리시스 데드존 유지) [room 1..4]
|
||||
readonly int[] _prevCo2 = new int[5];
|
||||
readonly int[] _prevPm25 = new int[5];
|
||||
readonly int[] _prevPm10 = new int[5];
|
||||
readonly int[] _prevVoc = new int[5];
|
||||
|
||||
// ---- 쾌적조리(후드연동) 메이크업 에어 상태 (사양서 260613 9p) ----
|
||||
// 쾌적조리는 운전모드가 아닌 후드연동 토글. 토글 ON + 후드 가동중일 때만 메이크업 에어 발동.
|
||||
// 조리 종료 후 잔여 배출(메이크업 유지)은 후드측이 담당 → ERV는 후드 OFF 신호 받으면 즉시 원래 상태 복귀.
|
||||
bool _makeup; // 메이크업 에어(강제 연동) 동작중
|
||||
byte _makeupFan; // 후드 단수 추종 결과 풍량
|
||||
|
||||
// 시나리오모드(안심회복/스마트수면/쾌적조리) 해제 시 진입 직전 풍량 복귀용 (운전모드는 시뮬에서 유지됨)
|
||||
bool _prevScenario;
|
||||
byte _scenarioSavedFan;
|
||||
|
||||
// 스마트수면 : 실별 CO2 히스테리시스 댐퍼 개폐 상태 (사양서 8p, >=1000 OPEN / <=800 CLOSE)
|
||||
bool _prevSmartSleep;
|
||||
readonly bool[] _sleepOpen = new bool[5]; // [room 1..4] true=OPEN
|
||||
|
||||
public AutoLogic(ErvState state, DamperSequencer seq)
|
||||
{
|
||||
_state = state; _seq = seq;
|
||||
_timer = new DispatcherTimer(DispatcherPriority.Normal) { Interval = TimeSpan.FromSeconds(1) };
|
||||
_timer.Tick += (_, _) => Process();
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
// 센서값 -> 0~4 단계. 하강 시 (임계-데드밴드) 이하라야 내려감. 데드존이면 이전 유지.
|
||||
static int SensorLevel(int v, ushort[] t, ushort db, int prev)
|
||||
{
|
||||
int lv = prev;
|
||||
if (v <= t[0] - db) lv = 0;
|
||||
else if (v > t[0] && v <= t[1] - db) lv = 1;
|
||||
else if (v > t[1] && v <= t[2] - db) lv = 2;
|
||||
else if (v > t[2] && v <= t[3] - db) lv = 3;
|
||||
else if (v > t[3]) lv = 4;
|
||||
return lv;
|
||||
}
|
||||
|
||||
static int ScoreToStage(int s)
|
||||
{
|
||||
if (s == 0) return 0;
|
||||
if (s <= 4) return 1;
|
||||
if (s <= 8) return 2;
|
||||
if (s <= 12) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
// Level(0좋음~4매우나쁨) -> 공기질코드(1매우나쁨~4좋음)
|
||||
static int AirqCode(int level) => level switch { 0 => 4, 1 => 3, 2 => 2, _ => 1 };
|
||||
|
||||
// 쾌적조리(후드연동) 메이크업 에어 상태 갱신. 반환=이번 틱 메이크업 에어 적용 여부.
|
||||
// 매트릭스(9p 3.1) : 쾌적조리 OFF → 연동없음 / ON+후드꺼짐 → 대기(본래 설정) / ON+후드켜짐 → 메이크업 에어(강제 연동)
|
||||
// 잔여 배출(메이크업 유지)은 후드측이 담당 → 후드 OFF 신호 받으면 ERV는 즉시 원래 상태 복귀(3.3).
|
||||
bool UpdateCooking()
|
||||
{
|
||||
bool cookEnabled = _state.PowerOn && _state.HoodEnable; // 쾌적조리 토글(전원 ON 전제)
|
||||
bool hoodOn = cookEnabled && _state.HoodCmd && _state.HoodFan > 0; // 후드 가동중(전원+풍량)
|
||||
|
||||
if (hoodOn)
|
||||
{
|
||||
if (!_makeup)
|
||||
{
|
||||
_makeup = true;
|
||||
Log?.Invoke("[쾌적조리] 메이크업 에어 진입 — 자동/수동 일시정지");
|
||||
}
|
||||
_makeupFan = (byte)Math.Min((int)_state.HoodFan, 4); // 단수 추종(3.2) : 1→1,2→2,3→3,4→4,5→4
|
||||
return true;
|
||||
}
|
||||
|
||||
// 후드 정지 또는 쾌적조리 해제 → 즉시 원래 상태 복귀(메이크업 유지는 후드측이 담당)
|
||||
if (_makeup) EndMakeup(cookEnabled ? "후드 OFF — 원래 상태 복귀" : "쾌적조리 해제");
|
||||
return false;
|
||||
}
|
||||
|
||||
void EndMakeup(string why)
|
||||
{
|
||||
_makeup = false;
|
||||
// 진입 직전 풍량 복귀는 Process()의 시나리오 해제 처리에서 일괄 수행.
|
||||
Log?.Invoke($"[쾌적조리] 메이크업 종료 ({why})");
|
||||
}
|
||||
|
||||
void Process()
|
||||
{
|
||||
int p = _state.HystPreset;
|
||||
|
||||
// ---- 실별 Level 산출 (항상 - 표시용) ----
|
||||
for (int r = 1; r <= 4; r++)
|
||||
{
|
||||
var room = _state.GetRoom(r);
|
||||
int lc = SensorLevel(room.Co2, _state.Co2Thr[p], _state.Co2Db[p], _prevCo2[r]);
|
||||
int l25 = SensorLevel(room.Pm25, _state.Pm25Thr[p], _state.Pm25Db[p], _prevPm25[r]);
|
||||
int l10 = SensorLevel(room.Pm10, _state.Pm10Thr[p], _state.Pm10Db[p], _prevPm10[r]);
|
||||
int lv = SensorLevel(room.Voc, _state.VocThr[p], _state.VocDb[p], _prevVoc[r]);
|
||||
_prevCo2[r] = lc; _prevPm25[r] = l25; _prevPm10[r] = l10; _prevVoc[r] = lv;
|
||||
|
||||
int level = Math.Max(Math.Max(lc, l25), Math.Max(l10, lv));
|
||||
room.Level = level;
|
||||
room.AirQuality = AirqCode(level);
|
||||
}
|
||||
|
||||
// ---- 부하총점 / P_max / dP (260428 v.Final : dP = 정렬 내림차순[0]-[1], 동점 포함) ----
|
||||
// 최고단계 실이 2개 이상 동점이면 P_2nd=P_max → dP=0 → 분산. 한 실만 확실히(2↑) 나쁠 때만 집중.
|
||||
int score = 0;
|
||||
int[] levels = new int[4];
|
||||
for (int r = 1; r <= 4; r++) { levels[r - 1] = _state.GetRoom(r).Level; score += levels[r - 1]; }
|
||||
Array.Sort(levels); // 오름차순
|
||||
int pmax = levels[3]; // 최고 단계
|
||||
int p2nd = levels[2]; // 두번째로 높은 단계(동점 포함)
|
||||
int dP = pmax - p2nd;
|
||||
|
||||
_state.LoadScore = score;
|
||||
_state.PMax = pmax;
|
||||
_state.DP = dP;
|
||||
|
||||
// ---- 쾌적조리(후드연동) 메이크업 에어 상태 갱신 → 연동운전중(HoodStatus) 소유 ----
|
||||
bool makeupEffective = UpdateCooking();
|
||||
_state.HoodStatus = makeupEffective; // 후드 폴 응답 '연동운전중'(롤백 유지 포함)
|
||||
|
||||
// ---- 시나리오모드 해제 → 진입 직전 운전모드로 동작 복귀 ----
|
||||
// 시뮬은 시나리오 중에도 RunMode 를 유지(오버레이)하므로 운전모드는 자동 복귀.
|
||||
// 시나리오가 덮어쓴 풍량만 진입 직전 값으로 되돌린다(비자동 한정, 자동은 재계산).
|
||||
bool scenarioActive = _state.RecoveryMode || _state.SmartSleep || makeupEffective;
|
||||
if (scenarioActive && !_prevScenario)
|
||||
_scenarioSavedFan = _state.SetFanMode; // 진입 직전 풍량 저장
|
||||
else if (!scenarioActive && _prevScenario && _state.PowerOn && _state.RunMode != RunMode.Auto)
|
||||
_state.FanMode = _state.SetFanMode = _scenarioSavedFan; // 해제 → 이전 풍량 복귀
|
||||
_prevScenario = scenarioActive;
|
||||
|
||||
// ---- 댐퍼/풍량 구동 (펌웨어 Air_Quality_damper_process 와 동일) ----
|
||||
// 대시보드 수동 댐퍼/LED 제어는 환기·공청·바이패스(비자동·시나리오모드 아님)에서만 유지.
|
||||
// 그 외(자동·시나리오모드·전원OFF)에서는 수동 플래그 해제 → 자동 제어 복귀.
|
||||
// 쾌적조리 '대기 상태'(토글 ON·후드 꺼짐)는 본래 설정대로 가동 → subActive 아님.
|
||||
bool subActive = _state.RecoveryMode || _state.SmartSleep || makeupEffective;
|
||||
bool manualAllowed = _state.PowerOn && _state.RunMode != RunMode.Auto && !subActive;
|
||||
// 댐퍼 수동 : 환기/공청/바이패스(비자동·비시나리오)에서만 유지, 그 외 해제 → 자동 제어 복귀.
|
||||
// LED 수동 : 모든 운전모드·댐퍼 변경에도 유지(사용자 요청). 전원 OFF 시에만 해제 → 자동 추종(소등) 복귀.
|
||||
for (int r = 1; r <= 4; r++)
|
||||
{
|
||||
var rm = _state.GetRoom(r);
|
||||
if (!manualAllowed) rm.DamperManual = false;
|
||||
if (!_state.PowerOn) rm.LedManual = false;
|
||||
}
|
||||
|
||||
bool damperChanged = false;
|
||||
void SetDamper(DiffuserRoom rm, int sa, int ra)
|
||||
{
|
||||
if (rm.MemorySA != sa || rm.MemoryRA != ra) damperChanged = true;
|
||||
rm.MemorySA = sa; rm.MemoryRA = ra;
|
||||
}
|
||||
|
||||
bool fanChanged = false;
|
||||
string logTag;
|
||||
void SetFan(byte st) { if (_state.FanMode != st) { _state.FanMode = _state.SetFanMode = st; fanChanged = true; } }
|
||||
|
||||
if (!_state.PowerOn)
|
||||
{
|
||||
// 전원 OFF : 전 실 즉시 닫힘 (18초 슬롯 시퀀스 대기 없이 Current 직접 0)
|
||||
_state.AutoConcentrate = false;
|
||||
for (int r = 1; r <= 4; r++)
|
||||
{
|
||||
var rm = _state.GetRoom(r);
|
||||
SetDamper(rm, 0, 0);
|
||||
rm.CurrentSA = 0; rm.CurrentRA = 0;
|
||||
}
|
||||
logTag = "전원OFF 전실 닫힘";
|
||||
}
|
||||
else if (makeupEffective) // 쾌적조리 메이크업 에어 : 전실 급기(SA) 100% 개방, 배기(RA) 닫힘, 후드 단수 추종
|
||||
{
|
||||
_state.AutoConcentrate = false;
|
||||
for (int r = 1; r <= 4; r++) SetDamper(_state.GetRoom(r), 110, 0);
|
||||
SetFan(_makeupFan);
|
||||
logTag = $"쾌적조리 메이크업에어(전실 급기) {_makeupFan}단";
|
||||
}
|
||||
else if (_state.RecoveryMode) // 안심회복 : 침실1 음압 (급기X 배기O), 나머지 급기O 배기X, 2단
|
||||
{
|
||||
_state.AutoConcentrate = false;
|
||||
SetDamper(_state.GetRoom(1), 110, 0); // 거실 급기
|
||||
SetDamper(_state.GetRoom(2), 0, 110); // 침실1 배기(음압)
|
||||
SetDamper(_state.GetRoom(3), 110, 0); // 침실2 급기
|
||||
SetDamper(_state.GetRoom(4), 110, 0); // 침실3 급기
|
||||
SetFan(2);
|
||||
logTag = "안심회복(침실1 음압) 2단";
|
||||
}
|
||||
else if (_state.SmartSleep) // 스마트수면 : 1단 고정, 실별 CO2 기준 댐퍼 개폐 (사양서 8p)
|
||||
{
|
||||
_state.AutoConcentrate = false;
|
||||
// 진입 초기상태 : 거실 CLOSE, 침실1~3 OPEN (이후 CO2 히스테리시스의 데드존 시드)
|
||||
if (!_prevSmartSleep)
|
||||
{
|
||||
_sleepOpen[1] = false;
|
||||
_sleepOpen[2] = _sleepOpen[3] = _sleepOpen[4] = true;
|
||||
}
|
||||
// CO2 센서 기준 : 해당 실 CO2 >= 1000 OPEN, <= 800 CLOSE, 그 사이(데드존)는 현재 상태 유지
|
||||
for (int r = 1; r <= 4; r++)
|
||||
{
|
||||
int co2 = _state.GetRoom(r).Co2;
|
||||
if (co2 >= 1000) _sleepOpen[r] = true;
|
||||
else if (co2 <= 800) _sleepOpen[r] = false;
|
||||
int ang = _sleepOpen[r] ? 110 : 0;
|
||||
SetDamper(_state.GetRoom(r), ang, ang);
|
||||
}
|
||||
SetFan(1);
|
||||
logTag = "스마트수면 CO2기준 실별개폐 1단";
|
||||
}
|
||||
else if (_state.RunMode != RunMode.Auto)
|
||||
{
|
||||
// 환기/공청/바이패스 : 각실 SA/RA 개방. 단, 대시보드 수동 댐퍼(DamperManual) 실은 그 위치 유지.
|
||||
_state.AutoConcentrate = false;
|
||||
for (int r = 1; r <= 4; r++)
|
||||
{
|
||||
var rm = _state.GetRoom(r);
|
||||
if (rm.DamperManual) continue; // 대시보드 수동 댐퍼 - 자동 개방 덮어쓰기 안 함
|
||||
SetDamper(rm, 110, 110);
|
||||
}
|
||||
logTag = $"{_state.RunMode} 각실 SA/RA 개방";
|
||||
}
|
||||
else
|
||||
{
|
||||
// 자동 : 대기 / 집중 / 분산
|
||||
if (pmax == 0)
|
||||
{
|
||||
_state.AutoConcentrate = false;
|
||||
for (int r = 1; r <= 4; r++) SetDamper(_state.GetRoom(r), 0, 0);
|
||||
}
|
||||
else if (dP >= 2)
|
||||
{
|
||||
_state.AutoConcentrate = true;
|
||||
for (int r = 1; r <= 4; r++)
|
||||
{
|
||||
var rm = _state.GetRoom(r);
|
||||
int ang = rm.Level == pmax ? 110 : 0;
|
||||
SetDamper(rm, ang, ang);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 분산 (260428 v.Final) : 1단계 이상 실만 개방, 0단계(좋음) 실은 닫음
|
||||
_state.AutoConcentrate = false;
|
||||
for (int r = 1; r <= 4; r++)
|
||||
{
|
||||
var rm = _state.GetRoom(r);
|
||||
int ang = rm.Level >= 1 ? 110 : 0;
|
||||
SetDamper(rm, ang, ang);
|
||||
}
|
||||
}
|
||||
|
||||
// 최종 풍량 : 부하총점 매핑
|
||||
byte stage = (byte)ScoreToStage(score);
|
||||
if (_state.FanMode != stage) { _state.FanMode = _state.SetFanMode = stage; fanChanged = true; }
|
||||
logTag = $"자동 {(pmax == 0 ? "대기" : dP >= 2 ? "집중" : "분산")} Score={score} dP={dP} → {_state.FanMode}단";
|
||||
}
|
||||
|
||||
// ---- LED : 댐퍼(SA/RA 중 하나라도 개방) 추종. 닫히면 0. 수동 조작(LedManual)은 예외 ----
|
||||
for (int r = 1; r <= 4; r++)
|
||||
{
|
||||
var rm = _state.GetRoom(r);
|
||||
if (rm.LedManual) continue;
|
||||
int want = (_state.PowerOn && (rm.MemorySA > 0 || rm.MemoryRA > 0)) ? 9 : 0;
|
||||
if (rm.LightBright != want) rm.LightBright = want;
|
||||
}
|
||||
|
||||
if (fanChanged || damperChanged)
|
||||
{
|
||||
_seq.NotifyCommandChanged();
|
||||
Log?.Invoke($"[댐퍼] {logTag}");
|
||||
}
|
||||
|
||||
_prevSmartSleep = _state.SmartSleep; // 다음 틱의 스마트수면 진입 감지용
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace ERVSimulator.Model
|
||||
{
|
||||
// 펌웨어 [MyMotor.c] Damper_Mode() + Diffuser_Damper_process() 시퀀스를 흉내
|
||||
// - Cmd 변경 시 18초 시퀀스 트리거 (slot 180 / 120 / 60, 100ms tick)
|
||||
// - 본체 댐퍼 6개: Run_Mode 에 따라 즉시 목표각 세팅
|
||||
// - 디퓨저 댐퍼: Memory → Current 슬롯별 복사
|
||||
// - 팬 PWM: 매 tick ±1 ramp
|
||||
public class DamperSequencer
|
||||
{
|
||||
public ErvState State { get; }
|
||||
private readonly DispatcherTimer _timer;
|
||||
private int _diffuserSlot; // 180..0 카운트다운
|
||||
private int _seqType; // 1=on, 2=off, 3=decrease, 4=increase
|
||||
private int _prevAirVolume;
|
||||
private bool _pendingSequence;
|
||||
|
||||
public DamperSequencer(ErvState state)
|
||||
{
|
||||
State = state;
|
||||
_timer = new DispatcherTimer(DispatcherPriority.Normal)
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(100)
|
||||
};
|
||||
_timer.Tick += OnTick;
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
// RoomCon/HomeNet 핸들러가 Run_Mode/Fan_Mode 갱신 후 호출
|
||||
public void NotifyCommandChanged()
|
||||
{
|
||||
_pendingSequence = true;
|
||||
}
|
||||
|
||||
void OnTick(object? sender, EventArgs e)
|
||||
{
|
||||
// ---- Fan_Speed_process() 시작부: VENT && Fan=0 → 정지 진입 ----
|
||||
int newAirVolume = (State.RunMode != RunMode.Off && State.FanMode != 0) ? State.FanMode : 0;
|
||||
|
||||
if (_pendingSequence)
|
||||
{
|
||||
_pendingSequence = false;
|
||||
_diffuserSlot = 180;
|
||||
_seqType = DetermineSeqType(_prevAirVolume, newAirVolume);
|
||||
_prevAirVolume = newAirVolume;
|
||||
}
|
||||
|
||||
// ---- Damper_Mode(Run_Mode) — 본체 댐퍼 즉시 목표각 ----
|
||||
ApplyBodyDamperMode(EffectiveBodyMode());
|
||||
|
||||
// ---- Diffuser_Damper_process() — 슬롯 기반 적용 ----
|
||||
if (_diffuserSlot == 180)
|
||||
{
|
||||
if (_seqType == 1 || _seqType == 4)
|
||||
CopyMemoryToCurrent(1, 2);
|
||||
else if (_seqType == 2 || _seqType == 3)
|
||||
SetFanTargets(); // 즉시 ramp 시작
|
||||
}
|
||||
else if (_diffuserSlot == 120)
|
||||
{
|
||||
if (_seqType == 1 || _seqType == 4 || _seqType == 2 || _seqType == 3)
|
||||
CopyMemoryToCurrent(3, 4);
|
||||
}
|
||||
else if (_diffuserSlot == 60)
|
||||
{
|
||||
if (_seqType == 1 || _seqType == 4)
|
||||
SetFanTargets();
|
||||
else if (_seqType == 2 || _seqType == 3)
|
||||
CopyMemoryToCurrent(1, 2);
|
||||
}
|
||||
|
||||
if (_diffuserSlot > 0) _diffuserSlot--;
|
||||
|
||||
// ---- 팬 ramp ±1 (펌웨어와 동일) ----
|
||||
if (State.Fan1Current < State.Fan1Target) State.Fan1Current++;
|
||||
else if (State.Fan1Current > State.Fan1Target) State.Fan1Current--;
|
||||
if (State.Fan2Current < State.Fan2Target) State.Fan2Current++;
|
||||
else if (State.Fan2Current > State.Fan2Target) State.Fan2Current--;
|
||||
}
|
||||
|
||||
int DetermineSeqType(int prev, int now)
|
||||
{
|
||||
if (prev == 0 && now != 0) return 4; // increase (power on)
|
||||
if (prev != 0 && now == 0) return 3; // decrease (power off)
|
||||
if (prev > now) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
RunMode EffectiveBodyMode()
|
||||
{
|
||||
// VENT && Fan=0 → 본체 댐퍼는 MODE_OFF 로 진입 (펌웨어 Fan_Speed_process 분기)
|
||||
if (State.RunMode == RunMode.Off) return RunMode.Off;
|
||||
if (State.RunMode == RunMode.Ventilation && State.FanMode == 0 &&
|
||||
State.Fan1Current == 0 && State.Fan2Current == 0)
|
||||
return RunMode.Off;
|
||||
return State.RunMode;
|
||||
}
|
||||
|
||||
// 펌웨어 Damper_Mode() — MyMotor.c:472
|
||||
void ApplyBodyDamperMode(RunMode mode)
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case RunMode.Ventilation:
|
||||
SetBody(DamperId.OA, 0); SetBody(DamperId.EA, 0); SetBody(DamperId.BYPASS, 100);
|
||||
SetBody(DamperId.SA, 0); SetBody(DamperId.RA, 70); SetBody(DamperId.AIR, 105);
|
||||
break;
|
||||
case RunMode.AirClean:
|
||||
SetBody(DamperId.OA, 100); SetBody(DamperId.EA, 100); SetBody(DamperId.BYPASS, 100);
|
||||
SetBody(DamperId.SA, 0); SetBody(DamperId.RA, 140); SetBody(DamperId.AIR, 0);
|
||||
break;
|
||||
case RunMode.Bypass:
|
||||
SetBody(DamperId.OA, 0); SetBody(DamperId.EA, 0); SetBody(DamperId.BYPASS, 0);
|
||||
SetBody(DamperId.SA, 0); SetBody(DamperId.RA, 140); SetBody(DamperId.AIR, 105);
|
||||
break;
|
||||
case RunMode.Auto:
|
||||
// 펌웨어는 자동 시 공기질에 따라 VENT/AIR 선택. 단순화: VENT 와 동일.
|
||||
SetBody(DamperId.OA, 0); SetBody(DamperId.EA, 0); SetBody(DamperId.BYPASS, 100);
|
||||
SetBody(DamperId.SA, 0); SetBody(DamperId.RA, 70); SetBody(DamperId.AIR, 105);
|
||||
break;
|
||||
case RunMode.Off:
|
||||
default:
|
||||
SetBody(DamperId.OA, 100); SetBody(DamperId.EA, 100); SetBody(DamperId.BYPASS, 100);
|
||||
SetBody(DamperId.SA, 100); SetBody(DamperId.RA, 0); SetBody(DamperId.AIR, 105);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void SetBody(DamperId id, int angle) => State.GetDamper(id).TargetAngle = angle;
|
||||
|
||||
void CopyMemoryToCurrent(int fromRoom, int toRoom)
|
||||
{
|
||||
for (int r = fromRoom; r <= toRoom; r++)
|
||||
{
|
||||
var room = State.GetRoom(r);
|
||||
room.CurrentSA = room.MemorySA;
|
||||
room.CurrentRA = room.MemoryRA;
|
||||
}
|
||||
}
|
||||
|
||||
// 펌웨어 Fan_Speed_Setting(Run_Mode, Fan_Mode) — MyMotor.c:1233
|
||||
void SetFanTargets()
|
||||
{
|
||||
int idx = System.Math.Clamp(State.FanMode, (byte)0, (byte)4);
|
||||
switch (State.RunMode)
|
||||
{
|
||||
case RunMode.Ventilation:
|
||||
case RunMode.Auto:
|
||||
State.Fan1Target = State.FanSAPreset_Vent[idx];
|
||||
State.Fan2Target = State.FanEAPreset_Vent[idx];
|
||||
break;
|
||||
case RunMode.Bypass:
|
||||
State.Fan1Target = State.FanSAPreset_Bypass[idx];
|
||||
State.Fan2Target = State.FanEAPreset_Bypass[idx];
|
||||
break;
|
||||
case RunMode.AirClean:
|
||||
State.Fan1Target = State.FanSAPreset_Air[idx];
|
||||
State.Fan2Target = State.FanEAPreset_Air[idx];
|
||||
break;
|
||||
default:
|
||||
State.Fan1Target = 0;
|
||||
State.Fan2Target = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace ERVSimulator.Model
|
||||
{
|
||||
// 6 본체 댐퍼 (Damper_Mode()가 직접 각도 명령)
|
||||
public class BodyDamper : INotifyPropertyChanged
|
||||
{
|
||||
public string Name { get; }
|
||||
public string Connector { get; } // CN2, CN10 ...
|
||||
public string ColorTag { get; } // GREEN, YELLOW ...
|
||||
public DamperId Id { get; }
|
||||
|
||||
private int _targetAngle;
|
||||
public int TargetAngle
|
||||
{
|
||||
get => _targetAngle;
|
||||
set { if (_targetAngle != value) { _targetAngle = value; OnChanged(); OnChanged(nameof(IsOpen)); } }
|
||||
}
|
||||
|
||||
// 펌웨어 주석: 90 = close, 0 = open, 100/105 = close 변형
|
||||
// RA(환기)만 '3step--reverse' → 0=닫힘, 70/140=열림 으로 규칙 반대 (MyMotor.c:482)
|
||||
// → 전원 OFF(RA=0) 시 6개 댐퍼 모두 닫힘으로 표시
|
||||
public bool IsOpen => Id == DamperId.RA ? TargetAngle >= 50 : TargetAngle < 50;
|
||||
|
||||
public BodyDamper(DamperId id, string name, string cn, string color)
|
||||
{
|
||||
Id = id; Name = name; Connector = cn; ColorTag = color;
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
void OnChanged([CallerMemberName] string? n = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
|
||||
}
|
||||
|
||||
// 디퓨저 각실 (1~4)
|
||||
public class DiffuserRoom : INotifyPropertyChanged
|
||||
{
|
||||
public int RoomId { get; }
|
||||
public string Name { get; }
|
||||
|
||||
int _memorySA, _memoryRA, _currentSA, _currentRA, _light;
|
||||
// Memory_* (RoomCon/HomeNet 핸들러가 즉시 갱신, 목표값)
|
||||
public int MemorySA { get => _memorySA; set { if (_memorySA != value) { _memorySA = value; OnChanged(); } } }
|
||||
public int MemoryRA { get => _memoryRA; set { if (_memoryRA != value) { _memoryRA = value; OnChanged(); } } }
|
||||
// Diffuser_Dmp_Ang_* (시퀀서가 슬롯 시간에 Memory→Current 복사)
|
||||
public int CurrentSA { get => _currentSA; set { if (_currentSA != value) { _currentSA = value; OnChanged(); OnChanged(nameof(IsOpenSA)); } } }
|
||||
public int CurrentRA { get => _currentRA; set { if (_currentRA != value) { _currentRA = value; OnChanged(); OnChanged(nameof(IsOpenRA)); } } }
|
||||
public int LightBright { get => _light; set { if (_light != value) { _light = value; OnChanged(); } } }
|
||||
// 디퓨저 응답이 echo 한 실제 LED 단수 (디퓨저 수동 LED 제어 시 ERV 명령과 다를 수 있음) → STATUS 로 송신
|
||||
int _ledReported;
|
||||
public int LedReported { get => _ledReported; set { if (_ledReported != value) { _ledReported = value; OnChanged(); } } }
|
||||
// 수동 LED 조작(CTRL_LED) 시 true → 자동로직이 LED 를 덮어쓰지 않음(예외). 비-수동모드 진입 시 해제.
|
||||
public bool LedManual { get; set; }
|
||||
// 수동 댐퍼 조작(CTRL_DAMPER) 시 true → 비자동(환기/공청/바이패스)에서 자동개방 덮어쓰기 안 함. 자동/부가모드/전원OFF/모드전환 시 해제.
|
||||
public bool DamperManual { get; set; }
|
||||
public bool IsOpenSA => CurrentSA > 0;
|
||||
public bool IsOpenRA => CurrentRA > 0;
|
||||
|
||||
// ---- 공기질 센서값 (DiffuserSimulator 응답에서 수신) ----
|
||||
int _co2, _pm25, _pm10, _voc, _level, _airQuality = 4, _temp, _humi;
|
||||
public int Co2 { get => _co2; set { if (_co2 != value) { _co2 = value; OnChanged(); } } }
|
||||
public int Pm25 { get => _pm25; set { if (_pm25 != value) { _pm25 = value; OnChanged(); } } }
|
||||
public int Pm10 { get => _pm10; set { if (_pm10 != value) { _pm10 = value; OnChanged(); } } }
|
||||
public int Voc { get => _voc; set { if (_voc != value) { _voc = value; OnChanged(); } } }
|
||||
public int Temp { get => _temp; set { if (_temp != value) { _temp = value; OnChanged(); } } }
|
||||
public int Humi { get => _humi; set { if (_humi != value) { _humi = value; OnChanged(); } } }
|
||||
// 오염 단계 0~4 (자동로직 산출)
|
||||
public int Level { get => _level; set { if (_level != value) { _level = value; OnChanged(); OnChanged(nameof(SensorText)); } } }
|
||||
// 공기질 코드 1매우나쁨~4좋음 (STATUS 송신용)
|
||||
public int AirQuality { get => _airQuality; set { if (_airQuality != value) { _airQuality = value; OnChanged(); } } }
|
||||
|
||||
public string SensorText => $"CO2 {Co2} PM2.5 {Pm25} PM10 {Pm10} VOC {Voc} → Lv{Level}";
|
||||
|
||||
public DiffuserRoom(int id, string name) { RoomId = id; Name = name; }
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
void OnChanged([CallerMemberName] string? n = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
|
||||
}
|
||||
|
||||
public class ErvState : INotifyPropertyChanged
|
||||
{
|
||||
// ---- 상위 상태 ----
|
||||
bool _powerOn;
|
||||
RunMode _runMode = RunMode.Off;
|
||||
RunMode _setRunMode = RunMode.Off;
|
||||
byte _fanMode; // 0~4 단
|
||||
byte _setFanMode;
|
||||
|
||||
public bool PowerOn { get => _powerOn; set { if (_powerOn != value) { _powerOn = value; OnChanged(); } } }
|
||||
public RunMode RunMode { get => _runMode; set { if (_runMode != value) { _runMode = value; OnChanged(); } } }
|
||||
public RunMode SetRunMode { get => _setRunMode; set { if (_setRunMode != value) { _setRunMode = value; OnChanged(); } } }
|
||||
public byte FanMode { get => _fanMode; set { if (_fanMode != value) { _fanMode = value; OnChanged(); } } }
|
||||
public byte SetFanMode { get => _setFanMode; set { if (_setFanMode != value) { _setFanMode = value; OnChanged(); } } }
|
||||
|
||||
// ---- 260520 자동 동작로직 상태 ----
|
||||
byte _hystPreset = 1; // 0 ECO / 1 NORMAL / 2 TURBO
|
||||
bool _autoConcentrate; // false 분산 / true 집중
|
||||
int _loadScore, _pMax, _dP;
|
||||
public byte HystPreset { get => _hystPreset; set { if (_hystPreset != value) { _hystPreset = value; OnChanged(); } } }
|
||||
public bool AutoConcentrate { get => _autoConcentrate; set { if (_autoConcentrate != value) { _autoConcentrate = value; OnChanged(); OnChanged(nameof(AutoStateText)); } } }
|
||||
public int LoadScore { get => _loadScore; set { if (_loadScore != value) { _loadScore = value; OnChanged(); OnChanged(nameof(AutoStateText)); } } }
|
||||
public int PMax { get => _pMax; set { if (_pMax != value) { _pMax = value; OnChanged(); } } }
|
||||
public int DP { get => _dP; set { if (_dP != value) { _dP = value; OnChanged(); } } }
|
||||
public string AutoStateText => RunMode == RunMode.Auto
|
||||
? $"{(PMax == 0 ? "대기" : AutoConcentrate ? "집중" : "분산")} · Score {LoadScore} · {FanMode}단"
|
||||
: "(자동모드 아님)";
|
||||
|
||||
// 부가모드 (월패드 토글/버튼)
|
||||
byte _extRunMode;
|
||||
bool _hoodEnable;
|
||||
public byte ExtRunMode { get => _extRunMode; set { if (_extRunMode != value) { _extRunMode = value; OnChanged(); OnChanged(nameof(SubModeText)); } } } // 1 안심회복 / 4 스마트수면
|
||||
public bool HoodEnable { get => _hoodEnable; set { if (_hoodEnable != value) { _hoodEnable = value; OnChanged(); OnChanged(nameof(SubModeText)); } } } // 후드연동(쾌적조리)
|
||||
public bool HoodStatus { get; set; }
|
||||
public byte ResetState { get; set; } // ERV 리셋 토글 echo
|
||||
|
||||
// ---- 후드(HOOD 프로토콜 Rev1.3) 슬레이브 보고값 ----
|
||||
bool _hoodConnected;
|
||||
public bool HoodConnected { get => _hoodConnected; set { if (_hoodConnected != value) { _hoodConnected = value; OnChanged(); } } } // 후드 폴 응답 생존(통신 연결)
|
||||
int _hoodFan; bool _hoodLight; bool _hoodCmd; int _hoodError;
|
||||
public int HoodFan { get => _hoodFan; set { if (_hoodFan != value) { _hoodFan = value; OnChanged(); } } } // 후드 FAN STATUS 0~5
|
||||
public bool HoodLight { get => _hoodLight; set { if (_hoodLight != value) { _hoodLight = value; OnChanged(); } } } // 후드 LIGHT STATUS
|
||||
public bool HoodCmd { get => _hoodCmd; set { if (_hoodCmd != value) { _hoodCmd = value; OnChanged(); } } } // 연동 CMD(후드 동작중)
|
||||
public int HoodError { get => _hoodError; set { if (_hoodError != value) { _hoodError = value; OnChanged(); } } } // ERROR : 0 정상 / 1 FAN / 2 기타
|
||||
|
||||
public bool SmartSleep { get => ExtRunMode == 4; } // 스마트수면
|
||||
public bool CookingMode { get => HoodEnable; } // 쾌적조리(후드연동)
|
||||
public bool RecoveryMode { get => ExtRunMode == 1; } // 안심회복
|
||||
public string SubModeText
|
||||
{
|
||||
get
|
||||
{
|
||||
var s = "";
|
||||
if (SmartSleep) s += "스마트수면 ";
|
||||
if (CookingMode) s += "쾌적조리 ";
|
||||
if (RecoveryMode) s += "안심회복 ";
|
||||
return s.Length == 0 ? "없음" : s.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- (꺼짐)예약 0~8시간 ----
|
||||
int _reserveHours; // 0 = 해제
|
||||
int _reserveRemainSec;
|
||||
public int ReserveHours { get => _reserveHours; set { if (_reserveHours != value) { _reserveHours = value; OnChanged(); } } }
|
||||
public int ReserveRemainSec { get => _reserveRemainSec; set { if (_reserveRemainSec != value) { _reserveRemainSec = value; OnChanged(); OnChanged(nameof(ReserveText)); } } }
|
||||
public string ReserveText => ReserveRemainSec > 0
|
||||
? $"예약 꺼짐까지 {ReserveRemainSec / 3600}:{(ReserveRemainSec % 3600) / 60:00}:{ReserveRemainSec % 60:00}"
|
||||
: "예약 없음";
|
||||
|
||||
// 히스테리시스 데드밴드(하강) [preset] : CO2,PM2.5,PM10,VOC (사양서 10p)
|
||||
public ushort[] Co2Db { get; } = { 50, 50, 30 };
|
||||
public ushort[] Pm25Db { get; } = { 2, 2, 2 };
|
||||
public ushort[] Pm10Db { get; } = { 5, 5, 5 };
|
||||
public ushort[] VocDb { get; } = { 5, 5, 3 };
|
||||
// 모드별(ECO/NORMAL/TURBO) 오염단계 상한 임계 [preset][레벨1~4]
|
||||
public ushort[][] Co2Thr { get; } = { new ushort[]{1000,1300,1600,2000}, new ushort[]{800,1100,1400,1700}, new ushort[]{700,1000,1300,1600} };
|
||||
public ushort[][] Pm25Thr { get; } = { new ushort[]{20,38,60,86}, new ushort[]{14,29,49,69}, new ushort[]{12,23,38,52} };
|
||||
public ushort[][] Pm10Thr { get; } = { new ushort[]{40,86,126,173}, new ushort[]{28,66,102,138}, new ushort[]{24,53,78,104} };
|
||||
public ushort[][] VocThr { get; } = { new ushort[]{171,195,308,438}, new ushort[]{120,150,250,350}, new ushort[]{103,120,192,263} };
|
||||
|
||||
// ---- 본체 6 댐퍼 ----
|
||||
public ObservableCollection<BodyDamper> BodyDampers { get; }
|
||||
|
||||
// ---- 각실 디퓨저 4 룸 ----
|
||||
public ObservableCollection<DiffuserRoom> Rooms { get; }
|
||||
|
||||
// ---- 팬 (BLDC SA/EA) ----
|
||||
// 펌웨어 PWM duty 0~10000 매핑. UI는 0~10000 슬라이드로 표시 + 환산 RPM 추정.
|
||||
int _fan1Target, _fan1Current; // SA
|
||||
int _fan2Target, _fan2Current; // EA
|
||||
public int Fan1Target { get => _fan1Target; set { if (_fan1Target != value) { _fan1Target = value; OnChanged(); } } }
|
||||
public int Fan1Current { get => _fan1Current; set { if (_fan1Current != value) { _fan1Current = value; OnChanged(); } } }
|
||||
public int Fan2Target { get => _fan2Target; set { if (_fan2Target != value) { _fan2Target = value; OnChanged(); } } }
|
||||
public int Fan2Current { get => _fan2Current; set { if (_fan2Current != value) { _fan2Current = value; OnChanged(); } } }
|
||||
|
||||
// ---- 에러 코드 (PPT 매핑 + HERV 펌웨어 My_define.h:206 비트맵) ----
|
||||
public const byte ERR_FILTER_CLEAN = 0x01;
|
||||
public const byte ERR_FILTER_CHANGE = 0x02;
|
||||
public const byte ERR_SOJA_CHANGE = 0x04;
|
||||
public const byte ERR_TEMP_SENSOR = 0x08; // E02 온도센서 에러
|
||||
public const byte ERR_PROTECT = 0x10; // COLD 장비보호모드
|
||||
public const byte ERR_EA_FAN = 0x20; // E10 배기(EA)팬 에러
|
||||
public const byte ERR_SOMETIME = 0x40; // E07 내부통신 에러
|
||||
public const byte ERR_SA_FAN = 0x80; // E09 급기(SA)팬 에러
|
||||
|
||||
byte _errorCode;
|
||||
public byte ErrorCode
|
||||
{
|
||||
get => _errorCode;
|
||||
set
|
||||
{
|
||||
if (_errorCode != value)
|
||||
{
|
||||
_errorCode = value;
|
||||
OnChanged();
|
||||
OnChanged(nameof(E02_TempSensor));
|
||||
OnChanged(nameof(E09_SaFan));
|
||||
OnChanged(nameof(E10_EaFan));
|
||||
OnChanged(nameof(COLD_Protect));
|
||||
OnChanged(nameof(E07_InternalComm));
|
||||
OnChanged(nameof(FilterClean));
|
||||
OnChanged(nameof(FilterChange));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 알람(유지보수) — 필터 청소/교환. 룸콘·대시보드로 ErrorCode 비트로 전달.
|
||||
public bool FilterClean { get => (ErrorCode & ERR_FILTER_CLEAN) != 0; set => SetErr(ERR_FILTER_CLEAN, value); }
|
||||
public bool FilterChange { get => (ErrorCode & ERR_FILTER_CHANGE) != 0; set => SetErr(ERR_FILTER_CHANGE, value); }
|
||||
|
||||
public bool E02_TempSensor { get => (ErrorCode & ERR_TEMP_SENSOR) != 0; set => SetErr(ERR_TEMP_SENSOR, value); }
|
||||
public bool E09_SaFan { get => (ErrorCode & ERR_SA_FAN) != 0; set => SetErr(ERR_SA_FAN, value); }
|
||||
public bool E10_EaFan { get => (ErrorCode & ERR_EA_FAN) != 0; set => SetErr(ERR_EA_FAN, value); }
|
||||
public bool COLD_Protect { get => (ErrorCode & ERR_PROTECT) != 0; set => SetErr(ERR_PROTECT, value); }
|
||||
public bool E07_InternalComm { get => (ErrorCode & ERR_SOMETIME) != 0; set => SetErr(ERR_SOMETIME, value); }
|
||||
|
||||
void SetErr(byte bit, bool on)
|
||||
{
|
||||
byte newVal = on ? (byte)(_errorCode | bit) : (byte)(_errorCode & ~bit);
|
||||
ErrorCode = newVal;
|
||||
}
|
||||
|
||||
// 1~4단 VSP preset (1바이트 0~255). 기본값 = 사양서 DL H-ERV VSP 실측표 (index 1~4)
|
||||
public ushort[] FanSAPreset_Vent { get; } = { 0, 56, 63, 70, 86 }; // 환기 SA
|
||||
public ushort[] FanEAPreset_Vent { get; } = { 0, 57, 63, 70, 85 }; // 환기 EA
|
||||
public ushort[] FanSAPreset_Bypass { get; } = { 0, 67, 0, 0, 0 }; // 바이패스 SA (기본단)
|
||||
public ushort[] FanEAPreset_Bypass { get; } = { 0, 75, 0, 0, 0 }; // 바이패스 EA
|
||||
public ushort[] FanSAPreset_Air { get; } = { 0, 65, 72, 78, 80 }; // 공청 SA
|
||||
public ushort[] FanEAPreset_Air { get; } = { 0, 0, 0, 0, 0 }; // 공청 EA (미사용 '-')
|
||||
|
||||
public ErvState()
|
||||
{
|
||||
BodyDampers = new ObservableCollection<BodyDamper>
|
||||
{
|
||||
// PPT 순서/색상 매핑
|
||||
new(DamperId.OA, "외기(OA)", "CN2", "GREEN"),
|
||||
new(DamperId.AIR, "공청(AIR)", "CN10", "YELLOW"),
|
||||
new(DamperId.BYPASS, "바이패스", "CN5", "RED"),
|
||||
new(DamperId.EA, "배기(EA)", "CN3", "BLACK"),
|
||||
new(DamperId.SA, "급기(SA)", "CN7", "BLUE"),
|
||||
new(DamperId.RA, "환기(RA) 3단", "CN9", "WHITE"),
|
||||
};
|
||||
|
||||
Rooms = new ObservableCollection<DiffuserRoom>
|
||||
{
|
||||
new(1, "거실"),
|
||||
new(2, "침실1"),
|
||||
new(3, "침실2"),
|
||||
new(4, "침실3"),
|
||||
};
|
||||
}
|
||||
|
||||
public BodyDamper GetDamper(DamperId id)
|
||||
{
|
||||
foreach (var d in BodyDampers) if (d.Id == id) return d;
|
||||
throw new System.InvalidOperationException();
|
||||
}
|
||||
|
||||
public DiffuserRoom GetRoom(int roomId)
|
||||
{
|
||||
foreach (var r in Rooms) if (r.RoomId == roomId) return r;
|
||||
throw new System.InvalidOperationException();
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
void OnChanged([CallerMemberName] string? n = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ERVSimulator.Model
|
||||
{
|
||||
// HERV 펌웨어 SPEC_MODE_INFO=0x16 (대림사양, 히터X, 바이패스O, 공청X) 기준
|
||||
// My_define.h:271 #if !((SPEC_MODE_INFO&0x0F)==0x03||==0x06) 분기
|
||||
public enum RunMode : byte
|
||||
{
|
||||
Ventilation = 0,
|
||||
Auto = 1,
|
||||
Bypass = 2,
|
||||
AirClean = 3,
|
||||
FanTest = 4,
|
||||
Off = 10, // MODE_OFF (MyMotor.c)
|
||||
}
|
||||
|
||||
public enum DamperId
|
||||
{
|
||||
EA = 1, // 배기
|
||||
OA = 2, // 외기
|
||||
BYPASS = 3,
|
||||
SA = 4, // 급기
|
||||
RA = 5, // 환기
|
||||
AIR = 6, // 공청
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ERVSimulator.Protocol
|
||||
{
|
||||
public static class ChecksumHelper
|
||||
{
|
||||
public static byte Xor(byte[] data, int start, int length)
|
||||
{
|
||||
byte x = 0;
|
||||
for (int i = 0; i < length; i++) x ^= data[start + i];
|
||||
return x;
|
||||
}
|
||||
|
||||
public static byte Add(byte[] data, int start, int length)
|
||||
{
|
||||
int s = 0;
|
||||
for (int i = 0; i < length; i++) s += data[start + i];
|
||||
return (byte)(s & 0xFF);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using System.Windows.Threading;
|
||||
using ERVSimulator.Model;
|
||||
using ErvProtocol; // 공용 Crc16 (bunbaegi CRC 도 표준 MODBUS 동일)
|
||||
using RunMode = ERVSimulator.Model.RunMode; // ErvProtocol.RunMode 와 이름 충돌 해소
|
||||
|
||||
namespace ERVSimulator.Protocol
|
||||
{
|
||||
// 디퓨저 버스 마스터 (115200) <-> DiffuserSimulator(슬레이브)
|
||||
// 규격 : Protocol/수정_Each_Room_Jushin_protocol_RS485_Rev1.2 (펌웨어 My_Uart.c bunbaegi 미러)
|
||||
// 목적 : DiffuserSimulator 로부터 각실 센서값(PM2.5/PM10/VOC/CO2) 수신 → ErvState → 자동로직
|
||||
// - 마스터 폴(29B, 0x10): 실/타입(SA/RA)별 전원·모드·풍량·LED·댐퍼 송신 (poll-response 구조상 필수)
|
||||
// - 슬레이브 응답(39B, 0x01): 센서값 수신
|
||||
// ※ ERVSim 은 각실 댐퍼+LED 를 자체 표시하지 않음(DiffuserSimulator 가 표시). 통신만 수행.
|
||||
public class DiffuserMasterProtocol
|
||||
{
|
||||
readonly SerialChannel _ch;
|
||||
readonly ErvState _state;
|
||||
readonly Dispatcher _dispatcher;
|
||||
readonly DispatcherTimer _pollTimer;
|
||||
|
||||
int _pollIdx; // (room1 SA),(room1 RA)...(room4 RA) round-robin
|
||||
|
||||
readonly byte[] _rx = new byte[39];
|
||||
int _rxPos;
|
||||
DateTime _lastByte = DateTime.MinValue;
|
||||
static readonly TimeSpan FrameGap = TimeSpan.FromMilliseconds(40);
|
||||
|
||||
public event Action<string>? PacketReceived;
|
||||
public event Action<string>? PacketSent;
|
||||
public bool Verbose { get; set; } = false; // true면 모든 폴 로그
|
||||
|
||||
public DiffuserMasterProtocol(SerialChannel ch, ErvState state, Dispatcher dispatcher)
|
||||
{
|
||||
_ch = ch; _state = state; _dispatcher = dispatcher;
|
||||
_ch.ByteReceived += OnByte;
|
||||
_pollTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromMilliseconds(80) };
|
||||
_pollTimer.Tick += (_, _) => { if (_ch.IsConnected) PollNext(); };
|
||||
_pollTimer.Start();
|
||||
}
|
||||
|
||||
byte DiffRunMode() => _state.RunMode switch
|
||||
{
|
||||
RunMode.Ventilation => 0x01,
|
||||
RunMode.Auto => 0x02,
|
||||
RunMode.Bypass => 0x04,
|
||||
RunMode.AirClean => 0x08,
|
||||
_ => 0x01,
|
||||
};
|
||||
|
||||
void PollNext()
|
||||
{
|
||||
int room = _pollIdx / 2 + 1; // 1~4
|
||||
byte id1 = (byte)(_pollIdx % 2 == 0 ? 0x01 : 0x02); // SA / RA
|
||||
_pollIdx = (_pollIdx + 1) % 8;
|
||||
|
||||
var rm = _state.GetRoom(room);
|
||||
var p = new byte[29];
|
||||
p[0] = 0xAA; p[1] = 0x10; p[2] = id1; p[3] = (byte)room; p[4] = 0x00;
|
||||
p[5] = (byte)(_state.PowerOn ? 1 : 0);
|
||||
p[6] = DiffRunMode();
|
||||
p[7] = _state.FanMode;
|
||||
p[8] = (byte)rm.LightBright;
|
||||
p[9] = (byte)rm.AirQuality;
|
||||
p[10] = (byte)rm.CurrentSA;
|
||||
p[11] = (byte)rm.CurrentRA;
|
||||
ushort crc = Crc16.Modbus(p, 0, 27);
|
||||
// lo-first : 펌웨어 CRC16()이 표준MODBUS 바이트스왑값 반환 + [27]=icrc>>8 배치 → 와이어는 리틀엔디안
|
||||
p[27] = (byte)(crc & 0xFF);
|
||||
p[28] = (byte)(crc >> 8);
|
||||
_ch.Send(p, 29);
|
||||
if (Verbose) PacketSent?.Invoke($"Diff TX poll room{room} {(id1 == 1 ? "SA" : "RA")} SA={rm.CurrentSA} RA={rm.CurrentRA} LED={rm.LightBright}");
|
||||
}
|
||||
|
||||
void OnByte(byte b)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastByte > FrameGap) _rxPos = 0;
|
||||
_lastByte = now;
|
||||
|
||||
if (_rxPos == 0)
|
||||
{
|
||||
if (b == 0xAA) { _rx[0] = b; _rxPos = 1; }
|
||||
}
|
||||
else if (_rxPos == 1)
|
||||
{
|
||||
if (b == 0x01) { _rx[1] = b; _rxPos = 2; }
|
||||
else _rxPos = (b == 0xAA) ? 1 : 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
_rx[_rxPos++] = b;
|
||||
if (_rxPos >= 39)
|
||||
{
|
||||
var copy = (byte[])_rx.Clone();
|
||||
_dispatcher.BeginInvoke(new Action(() => HandleResponse(copy)));
|
||||
_rxPos = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void HandleResponse(byte[] p)
|
||||
{
|
||||
ushort rxcrc = (ushort)(p[37] | (p[38] << 8)); // lo-first (표준 리틀엔디안)
|
||||
if (Crc16.Modbus(p, 0, 37) != rxcrc)
|
||||
{
|
||||
PacketReceived?.Invoke($"Diff RX CRC오류 {HexFormat.Bytes(p, 39)}");
|
||||
return;
|
||||
}
|
||||
|
||||
int id1 = p[2]; // 0x01 SA / 0x02 RA
|
||||
int room = p[3]; // 1~4
|
||||
if (room < 1 || room > 4) return;
|
||||
|
||||
// 센서 (응답 39B, 빅엔디안) : LED[8] PM10[12,13] PM2.5[16,17] 습도[20,21] 온도[22,23] VOC[24,25] CO2[28,29]
|
||||
int led = p[8]; // 디퓨저가 echo 한 실제 LED 단수 (수동 제어 시 ERV 명령과 다를 수 있음)
|
||||
int pm10 = (p[12] << 8) | p[13];
|
||||
int pm25 = (p[16] << 8) | p[17];
|
||||
int humi = (p[20] << 8) | p[21];
|
||||
int temp = (p[22] << 8) | p[23];
|
||||
int voc = (p[24] << 8) | p[25];
|
||||
int co2 = (p[28] << 8) | p[29];
|
||||
|
||||
var rm = _state.GetRoom(room);
|
||||
bool changed = rm.Co2 != co2 || rm.Pm25 != pm25 || rm.Pm10 != pm10 || rm.Voc != voc || rm.Temp != temp || rm.Humi != humi || rm.LedReported != led;
|
||||
rm.Pm10 = pm10; rm.Pm25 = pm25; rm.Voc = voc; rm.Co2 = co2; rm.Temp = temp; rm.Humi = humi; rm.LedReported = led;
|
||||
|
||||
if (changed || Verbose)
|
||||
PacketReceived?.Invoke($"Diff RX {rm.Name} 센서 CO2={co2} PM2.5={pm25} PM10={pm10} VOC={voc} 온도={temp} 습도={humi} LED={led} (from {(id1 == 1 ? "SA" : "RA")})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
using System;
|
||||
using System.Windows.Threading;
|
||||
using ERVSimulator.Model;
|
||||
using ErvProtocol; // 공용 프로토콜 (단일 진실원본) : FrameParser/CtrlFrame/StatusEncoder/StatusRecord
|
||||
using RunMode = ERVSimulator.Model.RunMode; // ErvProtocol.RunMode 와 이름 충돌 해소
|
||||
|
||||
namespace ERVSimulator.Protocol
|
||||
{
|
||||
// HOMENET (UART1, 115200 N81) <-> ErvDashboard
|
||||
// 규격/코덱 모두 공용 라이브러리 ErvProtocol 사용 (PC_ERV_Protocol.md).
|
||||
// 본 클래스는 ErvState <-> ErvProtocol.StatusRecord 매핑 + 제어명령 적용만 담당.
|
||||
public class HomeNetProtocol
|
||||
{
|
||||
readonly SerialChannel _ch;
|
||||
readonly ErvState _state;
|
||||
readonly DamperSequencer _seq;
|
||||
readonly Dispatcher _dispatcher;
|
||||
readonly DispatcherTimer _statusTimer;
|
||||
readonly FrameParser _parser = new();
|
||||
|
||||
public event Action<string>? PacketReceived;
|
||||
public event Action<string>? PacketSent;
|
||||
|
||||
public HomeNetProtocol(SerialChannel ch, ErvState state, DamperSequencer seq, Dispatcher dispatcher)
|
||||
{
|
||||
_ch = ch; _state = state; _seq = seq; _dispatcher = dispatcher;
|
||||
|
||||
_parser.OnFrame += (cmd, pl) => _dispatcher.BeginInvoke(new Action(() => HandleFrame(cmd, pl)));
|
||||
_parser.OnError += msg => PacketReceived?.Invoke($"HomeNet {msg}");
|
||||
_ch.ByteReceived += b => _parser.FeedByte(b);
|
||||
|
||||
_statusTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(1) };
|
||||
_statusTimer.Tick += (_, _) => { if (_ch.IsConnected) SendStatus(); };
|
||||
_statusTimer.Start();
|
||||
}
|
||||
|
||||
// ---- ErvState → StatusRecord ----
|
||||
byte RunModeCode()
|
||||
{
|
||||
if (!_state.PowerOn) return 0;
|
||||
return _state.RunMode switch
|
||||
{
|
||||
RunMode.Ventilation => 1,
|
||||
RunMode.Auto => 2,
|
||||
RunMode.AirClean => 3,
|
||||
RunMode.Bypass => 4,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
|
||||
StatusRecord BuildRecord()
|
||||
{
|
||||
int hp = _state.HystPreset;
|
||||
var s = new StatusRecord
|
||||
{
|
||||
Power = (byte)(_state.PowerOn ? 1 : 0),
|
||||
RunMode = RunModeCode(),
|
||||
AutoState = (byte)(_state.AutoConcentrate ? 1 : 0),
|
||||
FanMode = _state.FanMode,
|
||||
SubMode = (byte)((_state.SmartSleep ? 0x01 : 0) | (_state.CookingMode ? 0x02 : 0) | (_state.RecoveryMode ? 0x04 : 0)),
|
||||
Hood = (byte)((_state.HoodEnable ? 0x01 : 0) | (_state.HoodStatus ? 0x02 : 0) | (_state.HoodConnected ? 0x04 : 0)),
|
||||
HystPreset = (byte)hp,
|
||||
HystPm25 = _state.Pm25Db[hp],
|
||||
HystPm10 = _state.Pm10Db[hp],
|
||||
HystVoc = _state.VocDb[hp],
|
||||
HystCo2 = _state.Co2Db[hp],
|
||||
ErrorCode = _state.ErrorCode,
|
||||
Reset = _state.ResetState,
|
||||
ReserveRemainSec = _state.ReserveRemainSec,
|
||||
};
|
||||
|
||||
for (int r = 0; r < 4; r++)
|
||||
{
|
||||
var room = _state.GetRoom(r + 1);
|
||||
var rr = s.Rooms[r];
|
||||
// 비트맵 : bit0=급기(SA) 열림 / bit1=배기(RA) 열림 (StatusRecord.RoomRecord 와 일치)
|
||||
rr.Damper = (byte)((room.MemorySA != 0 ? 0x01 : 0) | (room.MemoryRA != 0 ? 0x02 : 0));
|
||||
rr.Pm25 = room.Pm25;
|
||||
rr.Pm10 = room.Pm10;
|
||||
rr.Voc = room.Voc;
|
||||
rr.Co2 = room.Co2;
|
||||
rr.AirQuality = (byte)room.AirQuality;
|
||||
// 디퓨저가 응답마다 echo 하는 실제 LED 단수를 보고 → 디퓨저 수동 LED 제어가 대시보드에 반영됨
|
||||
rr.LedDim = (byte)room.LedReported;
|
||||
rr.LoadScore = room.Level;
|
||||
rr.FinalVolume = _state.FanMode;
|
||||
rr.Temp = (byte)Math.Clamp(room.Temp, 0, 255);
|
||||
rr.Humi = (byte)Math.Clamp(room.Humi, 0, 255);
|
||||
}
|
||||
|
||||
// VSP : 환기1~4, 바이패스, 공청1~4
|
||||
for (int i = 1; i <= 4; i++) { s.Vsp[i - 1].Sa = _state.FanSAPreset_Vent[i]; s.Vsp[i - 1].Ea = _state.FanEAPreset_Vent[i]; }
|
||||
s.Vsp[4].Sa = _state.FanSAPreset_Bypass[1]; s.Vsp[4].Ea = _state.FanEAPreset_Bypass[1];
|
||||
for (int i = 1; i <= 4; i++) { s.Vsp[4 + i].Sa = _state.FanSAPreset_Air[i]; s.Vsp[4 + i].Ea = _state.FanEAPreset_Air[i]; }
|
||||
|
||||
// 히스테리시스 데드밴드 테이블
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
s.HystTable[i].Pm25 = _state.Pm25Db[i];
|
||||
s.HystTable[i].Pm10 = _state.Pm10Db[i];
|
||||
s.HystTable[i].Voc = _state.VocDb[i];
|
||||
s.HystTable[i].Co2 = _state.Co2Db[i];
|
||||
}
|
||||
|
||||
// 모드별 오염단계 임계표
|
||||
for (int i = 0; i < 3; i++)
|
||||
for (int k = 0; k < 4; k++)
|
||||
{
|
||||
s.ThrTable[i].Co2[k] = _state.Co2Thr[i][k];
|
||||
s.ThrTable[i].Pm25[k] = _state.Pm25Thr[i][k];
|
||||
s.ThrTable[i].Pm10[k] = _state.Pm10Thr[i][k];
|
||||
s.ThrTable[i].Voc[k] = _state.VocThr[i][k];
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
public void SendStatus()
|
||||
{
|
||||
var frame = StatusEncoder.BuildStatusFrame(BuildRecord());
|
||||
_ch.Send(frame, frame.Length);
|
||||
string autoTag = _state.RunMode == RunMode.Auto ? (_state.AutoConcentrate ? " 집중" : " 분산") : ""; // 집중/분산은 자동모드에서만
|
||||
PacketSent?.Invoke($"HomeNet TX STATUS(0x81) [{(_state.PowerOn ? "ON" : "OFF")} {_state.RunMode} {_state.FanMode}단{autoTag}]");
|
||||
}
|
||||
|
||||
// ---- 수신 제어명령 적용 + ACK ----
|
||||
void HandleFrame(byte cmd, byte[] pl)
|
||||
{
|
||||
byte result = 0;
|
||||
bool modeChanged = false;
|
||||
|
||||
switch (cmd)
|
||||
{
|
||||
case CtrlFrame.CTRL_POWER:
|
||||
if (pl.Length >= 1)
|
||||
{
|
||||
bool on = pl[0] != 0;
|
||||
_state.PowerOn = on;
|
||||
if (on)
|
||||
{
|
||||
// 전원 ON : 환기 모드 + 풍량 1단. 디퓨저 개방·LED 는 AutoLogic 이 댐퍼 상태에 맞춰 구동.
|
||||
_state.RunMode = _state.SetRunMode = RunMode.Ventilation;
|
||||
_state.FanMode = _state.SetFanMode = 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 전원 OFF : 정지 (댐퍼 닫힘·LED 소등은 AutoLogic 이 처리)
|
||||
_state.FanMode = _state.SetFanMode = 0;
|
||||
}
|
||||
// 전원 토글 시 수동 LED·댐퍼 해제 → 자동 추종 복귀
|
||||
for (int r = 1; r <= 4; r++) { var rm = _state.GetRoom(r); rm.LedManual = false; rm.DamperManual = false; }
|
||||
modeChanged = true;
|
||||
}
|
||||
else result = 1;
|
||||
break;
|
||||
case CtrlFrame.CTRL_RUNMODE:
|
||||
if (pl.Length >= 1)
|
||||
{
|
||||
if (pl[0] == 0) _state.PowerOn = false;
|
||||
else
|
||||
{
|
||||
_state.PowerOn = true;
|
||||
RunMode m = pl[0] switch { 1 => RunMode.Ventilation, 2 => RunMode.Auto, 3 => RunMode.AirClean, 4 => RunMode.Bypass, _ => RunMode.Ventilation };
|
||||
_state.RunMode = _state.SetRunMode = m;
|
||||
// 운전모드 전환 시 풍량 1단 (자동은 부하점수로 결정하므로 제외)
|
||||
if (m != RunMode.Auto) _state.FanMode = _state.SetFanMode = 1;
|
||||
}
|
||||
// 모드 전환 시 수동 댐퍼만 해제 → 새 모드는 기본(전실 개방)에서 시작.
|
||||
// 수동 LED 디밍값은 모드가 바뀌어도 유지(사용자 요청, 전원 OFF 시에만 해제).
|
||||
for (int r = 1; r <= 4; r++) _state.GetRoom(r).DamperManual = false;
|
||||
modeChanged = true;
|
||||
}
|
||||
else result = 1;
|
||||
break;
|
||||
case CtrlFrame.CTRL_FAN:
|
||||
if (pl.Length >= 1)
|
||||
{
|
||||
// 모드별 풍량 상한 : 바이패스 1단, 그 외 4단 (자동은 부하점수로 결정)
|
||||
byte sp = pl[0];
|
||||
byte max = _state.RunMode == RunMode.Bypass ? (byte)1 : (byte)4;
|
||||
if (sp > max) sp = max;
|
||||
_state.FanMode = _state.SetFanMode = sp; modeChanged = true;
|
||||
}
|
||||
else result = 1;
|
||||
break;
|
||||
case CtrlFrame.CTRL_SUBMODE: // [type][on] 1수면 2조리 3회복
|
||||
if (pl.Length >= 2)
|
||||
{
|
||||
if (pl[0] == 1) _state.ExtRunMode = (byte)(pl[1] != 0 ? 4 : 0);
|
||||
else if (pl[0] == 2) _state.HoodEnable = pl[1] != 0;
|
||||
else if (pl[0] == 3) _state.ExtRunMode = (byte)(pl[1] != 0 ? 1 : 0);
|
||||
else result = 1;
|
||||
}
|
||||
else result = 1;
|
||||
break;
|
||||
case CtrlFrame.CTRL_HOOD:
|
||||
if (pl.Length >= 1) _state.HoodEnable = pl[0] != 0; else result = 1;
|
||||
break;
|
||||
case CtrlFrame.CTRL_HYST_PRESET:
|
||||
if (pl.Length >= 1 && pl[0] < 3) _state.HystPreset = pl[0]; else result = 1;
|
||||
break;
|
||||
case CtrlFrame.CTRL_HYST_VALUE: // [preset][pm25][pm10][voc][co2] u16 BE
|
||||
if (pl.Length >= 9 && pl[0] < 3)
|
||||
{
|
||||
int ps = pl[0];
|
||||
_state.Pm25Db[ps] = (ushort)((pl[1] << 8) | pl[2]);
|
||||
_state.Pm10Db[ps] = (ushort)((pl[3] << 8) | pl[4]);
|
||||
_state.VocDb[ps] = (ushort)((pl[5] << 8) | pl[6]);
|
||||
_state.Co2Db[ps] = (ushort)((pl[7] << 8) | pl[8]);
|
||||
}
|
||||
else result = 1;
|
||||
break;
|
||||
case CtrlFrame.CTRL_DAMPER: // [room][onoff] — 수동 댐퍼 : 비자동(환기/공청/바이패스)에서 위치 유지
|
||||
if (pl.Length >= 2 && pl[0] >= 1 && pl[0] <= 4)
|
||||
{
|
||||
var rm = _state.GetRoom(pl[0]);
|
||||
int ang = pl[1] != 0 ? 110 : 0;
|
||||
rm.MemorySA = rm.CurrentSA = ang;
|
||||
rm.MemoryRA = rm.CurrentRA = ang;
|
||||
rm.DamperManual = true; // 자동로직이 덮어쓰지 않도록 (자동/모드전환 시 해제)
|
||||
}
|
||||
else result = 1;
|
||||
break;
|
||||
case CtrlFrame.CTRL_LED: // [room][dim] — 수동 조작 : 자동 추종 해제하고 지정값 유지
|
||||
if (pl.Length >= 2 && pl[0] >= 1 && pl[0] <= 4)
|
||||
{
|
||||
var rm = _state.GetRoom(pl[0]);
|
||||
rm.LightBright = pl[1];
|
||||
rm.LedManual = true;
|
||||
}
|
||||
else result = 1;
|
||||
break;
|
||||
case CtrlFrame.CTRL_RESERVE: // [hours 0~8] : N시간 후 전원 OFF (0=해제)
|
||||
if (pl.Length >= 1 && pl[0] <= 8)
|
||||
{
|
||||
int h = pl[0];
|
||||
_state.ReserveHours = h;
|
||||
_state.ReserveRemainSec = h * 3600; // 0이면 해제. 카운트다운/전원OFF는 ReserveTick(1s)이 처리
|
||||
}
|
||||
else result = 1;
|
||||
break;
|
||||
case CtrlFrame.REQ_STATUS:
|
||||
break;
|
||||
case CtrlFrame.CTRL_RESET:
|
||||
if (pl.Length >= 1) _state.ResetState = (byte)(pl[0] != 0 ? 1 : 0); else result = 1;
|
||||
break;
|
||||
case CtrlFrame.CTRL_VSP: // [group][index][sa(2)][ea(2)]
|
||||
if (pl.Length >= 6) result = SetVsp(pl[0], pl[1], (pl[2] << 8) | pl[3], (pl[4] << 8) | pl[5]); else result = 1;
|
||||
break;
|
||||
case CtrlFrame.CTRL_HYST_THR: // [preset][pollutant][L1~L4 u16] : 오염단계 임계 설정
|
||||
if (pl.Length >= 10 && pl[0] < 3 && pl[1] < 4)
|
||||
{
|
||||
int ps = pl[0], g = pl[1];
|
||||
ushort[] arr = g switch { 0 => _state.Co2Thr[ps], 1 => _state.Pm25Thr[ps], 2 => _state.Pm10Thr[ps], 3 => _state.VocThr[ps], _ => null! };
|
||||
if (arr != null) for (int k = 0; k < 4; k++) arr[k] = (ushort)((pl[2 + k * 2] << 8) | pl[3 + k * 2]);
|
||||
else result = 1;
|
||||
}
|
||||
else result = 1;
|
||||
break;
|
||||
default: result = 1; break;
|
||||
}
|
||||
|
||||
PacketReceived?.Invoke($"HomeNet RX CMD=0x{cmd:X2} len={pl.Length} → {(result == 0 ? "OK" : "ERR")}");
|
||||
|
||||
if (modeChanged) _seq.NotifyCommandChanged();
|
||||
|
||||
var ack = StatusEncoder.BuildAckFrame(cmd, result);
|
||||
_ch.Send(ack, ack.Length);
|
||||
|
||||
if (cmd == CtrlFrame.REQ_STATUS) SendStatus();
|
||||
}
|
||||
|
||||
byte SetVsp(int grp, int idx, int sa, int ea)
|
||||
{
|
||||
if (grp == 0 && idx >= 1 && idx <= 4) { _state.FanSAPreset_Vent[idx] = (ushort)sa; _state.FanEAPreset_Vent[idx] = (ushort)ea; }
|
||||
else if (grp == 1 && idx == 1) { _state.FanSAPreset_Bypass[1] = (ushort)sa; _state.FanEAPreset_Bypass[1] = (ushort)ea; }
|
||||
else if (grp == 2 && idx >= 1 && idx <= 4) { _state.FanSAPreset_Air[idx] = (ushort)sa; _state.FanEAPreset_Air[idx] = (ushort)ea; }
|
||||
else return 1;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System;
|
||||
using System.Windows.Threading;
|
||||
using ERVSimulator.Model;
|
||||
using RunMode = ERVSimulator.Model.RunMode;
|
||||
|
||||
namespace ERVSimulator.Protocol
|
||||
{
|
||||
// 후드 버스 마스터 (115200) <-> 후드메인(슬레이브)
|
||||
// 규격 : Protocol/HOOD/주신전자_protocol_hood_전열교환기_Rev1.3_20241125.xlsx
|
||||
// - 9바이트 고정, 폴링주기 100~500ms, 응답 50ms 이내, CS = Preamble~CS직전 전체 XOR
|
||||
// 목적 : ERV(Master) 가 후드메인(Slave) 을 폴 → 후드 FAN/LIGHT/연동CMD 수신 → ErvState 반영
|
||||
// 마스터 폴(9B) : Preamble | M/S(0x21) | ID | MODE | FAN | 연동EN | 연동운전중 | ERROR | CS
|
||||
// 슬레이브 응답(9B) : Preamble | M/S(0x11) | ID | FAN STATUS | LIGHT STATUS | 0x00 | 연동CMD | ERROR | CS
|
||||
public class HoodMasterProtocol
|
||||
{
|
||||
const byte PREAMBLE = 0xAA;
|
||||
const byte MS_MASTER = 0x21;
|
||||
const byte MS_SLAVE = 0x11;
|
||||
const byte HOOD_ID = 0x01;
|
||||
const int FRAME_LEN = 9;
|
||||
|
||||
readonly SerialChannel _ch;
|
||||
readonly ErvState _state;
|
||||
readonly Dispatcher _dispatcher;
|
||||
readonly DispatcherTimer _pollTimer;
|
||||
|
||||
readonly byte[] _rx = new byte[FRAME_LEN];
|
||||
int _rxPos;
|
||||
DateTime _lastByte = DateTime.MinValue;
|
||||
static readonly TimeSpan FrameGap = TimeSpan.FromMilliseconds(40);
|
||||
|
||||
public event Action<string>? PacketReceived;
|
||||
public event Action<string>? PacketSent;
|
||||
public bool Verbose { get; set; } = false; // true면 모든 폴 로그
|
||||
|
||||
// 후드 통신 생존 표시용 — 마지막으로 유효한 응답을 받은 시각(UTC)
|
||||
public DateTime LastRxUtc { get; private set; } = DateTime.MinValue;
|
||||
// 폴(200ms) 기준 이 시간 내 응답이 없으면 미연결로 판정 (몇 회 누락 허용)
|
||||
static readonly TimeSpan ConnTimeout = TimeSpan.FromMilliseconds(1000);
|
||||
|
||||
public HoodMasterProtocol(SerialChannel ch, ErvState state, Dispatcher dispatcher)
|
||||
{
|
||||
_ch = ch; _state = state; _dispatcher = dispatcher;
|
||||
_ch.ByteReceived += OnByte;
|
||||
// 폴링주기 200ms (사양 100~500ms 범위 내)
|
||||
_pollTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromMilliseconds(200) };
|
||||
_pollTimer.Tick += (_, _) =>
|
||||
{
|
||||
if (_ch.IsConnected) Poll();
|
||||
// 폴 주기마다 통신 생존 갱신 : 채널 연결 && 최근 응답 수신 → 연결됨
|
||||
_state.HoodConnected = _ch.IsConnected && (DateTime.UtcNow - LastRxUtc) < ConnTimeout;
|
||||
};
|
||||
_pollTimer.Start();
|
||||
}
|
||||
|
||||
// MODE : 전원 OFF시 0, ON시 1 환기 / 2 자동 / 3 공청 / 4 바이패스 / 5 기타
|
||||
byte HoodMode()
|
||||
{
|
||||
if (!_state.PowerOn) return 0;
|
||||
return _state.RunMode switch
|
||||
{
|
||||
RunMode.Ventilation => 1,
|
||||
RunMode.Auto => 2,
|
||||
RunMode.AirClean => 3,
|
||||
RunMode.Bypass => 4,
|
||||
RunMode.Off => 0,
|
||||
_ => 5,
|
||||
};
|
||||
}
|
||||
|
||||
void Poll()
|
||||
{
|
||||
var p = new byte[FRAME_LEN];
|
||||
p[0] = PREAMBLE;
|
||||
p[1] = MS_MASTER;
|
||||
p[2] = HOOD_ID;
|
||||
p[3] = HoodMode();
|
||||
p[4] = _state.FanMode; // 전열교환기 FAN 0 OFF, 1~5단
|
||||
p[5] = (byte)(_state.HoodEnable ? 0x01 : 0x00); // 연동 Enable/Disable
|
||||
p[6] = (byte)(_state.HoodStatus ? 0x01 : 0x00); // 연동 운전중(후드 연동에 의한 환기장치 동작중)
|
||||
p[7] = 0x00; // ERROR
|
||||
p[8] = ChecksumHelper.Xor(p, 0, 8); // CS = Preamble~CS직전 XOR
|
||||
_ch.Send(p, FRAME_LEN);
|
||||
if (Verbose)
|
||||
PacketSent?.Invoke($"Hood TX poll MODE={p[3]} FAN={p[4]} EN={p[5]} 연동운전={p[6]}");
|
||||
}
|
||||
|
||||
void OnByte(byte b)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastByte > FrameGap) _rxPos = 0;
|
||||
_lastByte = now;
|
||||
|
||||
if (_rxPos == 0)
|
||||
{
|
||||
if (b == PREAMBLE) { _rx[0] = b; _rxPos = 1; }
|
||||
}
|
||||
else if (_rxPos == 1)
|
||||
{
|
||||
if (b == MS_SLAVE) { _rx[1] = b; _rxPos = 2; }
|
||||
else _rxPos = (b == PREAMBLE) ? 1 : 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
_rx[_rxPos++] = b;
|
||||
if (_rxPos >= FRAME_LEN)
|
||||
{
|
||||
var copy = (byte[])_rx.Clone();
|
||||
_dispatcher.BeginInvoke(new Action(() => HandleResponse(copy)));
|
||||
_rxPos = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void HandleResponse(byte[] p)
|
||||
{
|
||||
byte cs = ChecksumHelper.Xor(p, 0, 8);
|
||||
if (cs != p[8])
|
||||
{
|
||||
PacketReceived?.Invoke($"Hood RX CS오류 {HexFormat.Bytes(p, FRAME_LEN)}");
|
||||
return;
|
||||
}
|
||||
if (p[2] != HOOD_ID) return;
|
||||
LastRxUtc = DateTime.UtcNow; // 유효 응답 수신 → 통신 생존
|
||||
_state.HoodConnected = true; // 응답 받았으므로 즉시 연결 표시
|
||||
|
||||
int fan = p[3]; // 후드 FAN STATUS : 0 OFF, 1~5단
|
||||
bool light = p[4] != 0; // 후드 LIGHT STATUS : 0 OFF, 1 ON
|
||||
bool cmd = p[6] != 0; // 연동 CMD : 0 후드 꺼짐 / 1 후드 켜짐
|
||||
int err = p[7];
|
||||
|
||||
bool changed = _state.HoodFan != fan || _state.HoodLight != light || _state.HoodCmd != cmd || _state.HoodError != err;
|
||||
_state.HoodFan = fan;
|
||||
_state.HoodLight = light;
|
||||
_state.HoodCmd = cmd;
|
||||
_state.HoodError = err;
|
||||
// 연동운전중(HoodStatus)은 AutoLogic 이 메이크업 에어 상태(롤백 유지 포함)로 소유.
|
||||
|
||||
if (changed || Verbose)
|
||||
PacketReceived?.Invoke($"Hood RX FAN={fan} LIGHT={(light ? "ON" : "OFF")} 연동CMD={(cmd ? "ON" : "OFF")}{(err != 0 ? $" ERR={err}" : "")}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System;
|
||||
using ERVSimulator.Model;
|
||||
|
||||
namespace ERVSimulator.Protocol
|
||||
{
|
||||
// 룸콘 프로토콜 (UART2/SC0)
|
||||
// 패킷: AA | Cmd | D[2..12] | XOR_SUM[13] | EE (15 byte)
|
||||
// 펌웨어 [My_RJ2.c] rx_roomcon_check() + roomcon_parsing()
|
||||
public class RoomConProtocol
|
||||
{
|
||||
public const byte HEADER = 0xAA;
|
||||
public const byte TAIL = 0xEE;
|
||||
public const int PACKET_LEN = 15;
|
||||
|
||||
// Cmd (Rx_roomcon232_buffer[1])
|
||||
public const byte CMD_NORMAL = 0x00; // 상태 폴링
|
||||
public const byte CMD_EVENT = 0x01; // 모드/팬 변경 이벤트
|
||||
public const byte CMD_RESTART1 = 0x02; // 환기단 preset 요청
|
||||
public const byte CMD_RESTART2 = 0x12; // bypass/air preset 요청
|
||||
public const byte CMD_VSP = 0x03; // 테스트모드 진입
|
||||
public const byte CMD_EXIT = 0x04; // 테스트모드 종료
|
||||
public const byte CMD_HOOD_INFO = 0x0A; // ERV→룸콘 후드 연동 통지 (힘펠 V3.7 RX_DATA_HOOD_INFO)
|
||||
|
||||
readonly SerialChannel _ch;
|
||||
readonly ErvState _state;
|
||||
readonly DamperSequencer _seq;
|
||||
readonly System.Windows.Threading.Dispatcher _dispatcher;
|
||||
|
||||
readonly byte[] _rx = new byte[PACKET_LEN];
|
||||
int _rxPos;
|
||||
bool _hoodLinkReported; // 마지막으로 룸콘에 통지한 후드 연동 상태(변화 시에만 0x0A 송신)
|
||||
DateTime _lastByte = DateTime.MinValue;
|
||||
static readonly TimeSpan FrameGap = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
public event Action<string>? PacketReceived;
|
||||
public event Action<string>? PacketSent;
|
||||
|
||||
public RoomConProtocol(SerialChannel ch, ErvState state, DamperSequencer seq,
|
||||
System.Windows.Threading.Dispatcher dispatcher)
|
||||
{
|
||||
_ch = ch; _state = state; _seq = seq; _dispatcher = dispatcher;
|
||||
_ch.ByteReceived += OnByte;
|
||||
}
|
||||
|
||||
void OnByte(byte b)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastByte > FrameGap) _rxPos = 0;
|
||||
_lastByte = now;
|
||||
|
||||
// 펌웨어와 동일한 byte 파서 (My_RJ2.c:37)
|
||||
if (_rxPos == 0)
|
||||
{
|
||||
if (b != HEADER) return;
|
||||
_rx[_rxPos++] = b;
|
||||
return;
|
||||
}
|
||||
if (_rxPos >= 1 && _rxPos <= 12)
|
||||
{
|
||||
_rx[_rxPos++] = b;
|
||||
return;
|
||||
}
|
||||
if (_rxPos == 13)
|
||||
{
|
||||
byte cksum = ChecksumHelper.Xor(_rx, 0, 13);
|
||||
if (cksum != b) { _rxPos = 0; return; }
|
||||
_rx[_rxPos++] = b;
|
||||
return;
|
||||
}
|
||||
if (_rxPos == 14)
|
||||
{
|
||||
_rxPos = 0;
|
||||
if (b != TAIL) return;
|
||||
byte[] copy = (byte[])_rx.Clone();
|
||||
_dispatcher.BeginInvoke(new Action(() => HandlePacket(copy)));
|
||||
}
|
||||
}
|
||||
|
||||
void HandlePacket(byte[] p)
|
||||
{
|
||||
PacketReceived?.Invoke($"RoomCon RX: {HexFormat.Bytes(p, 14)} EE");
|
||||
|
||||
byte cmd = p[1];
|
||||
switch (cmd)
|
||||
{
|
||||
case CMD_EVENT: HandleEvent(p); break;
|
||||
case CMD_NORMAL: HandleNormal(p); break;
|
||||
case CMD_RESTART1: HandleRestart1(); break;
|
||||
case CMD_RESTART2: HandleRestart2(); break;
|
||||
case CMD_VSP: HandleVsp(p); break;
|
||||
case CMD_EXIT: HandleExit(); break;
|
||||
default:
|
||||
PacketReceived?.Invoke($" (unknown RoomCon cmd 0x{cmd:X2})");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// [My_RJ2.c:387] RX_DATA_MODE_EVENT - 운전 모드/팬 변경
|
||||
void HandleEvent(byte[] p)
|
||||
{
|
||||
byte runMode = p[2];
|
||||
byte fanMode = p[3];
|
||||
_state.RunMode = (RunMode)runMode;
|
||||
_state.SetRunMode = (RunMode)runMode;
|
||||
_state.FanMode = fanMode;
|
||||
_state.SetFanMode = fanMode;
|
||||
// VENT && fan=0 ⇒ Power OFF 진입
|
||||
_state.PowerOn = !(runMode == 0 && fanMode == 0);
|
||||
|
||||
// 예약 (룸콘 EVENT [10]=flag / [11]=시 / [12]=분). HOMENET STATUS(reserve)로도 전달 → 대시보드 반영
|
||||
if (p[10] == 1)
|
||||
{
|
||||
int hours = p[11];
|
||||
_state.ReserveHours = hours; // 0이면 해제
|
||||
_state.ReserveRemainSec = hours * 3600 + p[12] * 60; // 카운트다운/전원OFF는 ReserveTick(1s) 처리
|
||||
}
|
||||
|
||||
_seq.NotifyCommandChanged();
|
||||
|
||||
// 응답: AA 01 RunMode FanMode 00 misc... XOR EE (펌웨어 [My_RJ2.c:489])
|
||||
var tx = NewPacket();
|
||||
tx[1] = 0x01;
|
||||
tx[2] = runMode;
|
||||
tx[3] = fanMode;
|
||||
tx[5] = 0; // Heater/UV/Kijer
|
||||
tx[7] = _state.ErrorCode; // ErrorCode (E02/E07/E09/E10/COLD 비트맵)
|
||||
tx[8] = 0; // Out_Temperature sign
|
||||
tx[9] = 20 + 25; // Out_Temperature = 25
|
||||
tx[10] = 20 + 22; // In_Temperature = 22
|
||||
FinalizeAndSend(tx);
|
||||
}
|
||||
|
||||
// [My_RJ2.c:327] RX_DATA_MODE_NORMAL - 상태 폴링 응답
|
||||
void HandleNormal(byte[] p)
|
||||
{
|
||||
// 후드 연동 상태가 바뀌면 HOOD_INFO(0x0A)로 통지 (힘펠 V3.7, 펌웨어 Hood_info_command).
|
||||
// HoodStatus = 연동운전중(후드 가동 → 메이크업 에어). 후드 OFF로 ERV 복귀 시 0x80(OFF) 전송.
|
||||
if (_state.HoodStatus != _hoodLinkReported)
|
||||
{
|
||||
_hoodLinkReported = _state.HoodStatus;
|
||||
var hi = NewPacket();
|
||||
hi[1] = CMD_HOOD_INFO;
|
||||
hi[2] = _state.HoodStatus ? (byte)RunMode.Ventilation : (byte)_state.SetRunMode; // 연동 시 환기
|
||||
hi[3] = _state.HoodStatus ? (byte)1 : _state.SetFanMode;
|
||||
hi[6] = _state.HoodStatus ? (byte)0x81 : (byte)0x80; // 후드 연동 ON / OFF
|
||||
FinalizeAndSend(hi);
|
||||
return;
|
||||
}
|
||||
|
||||
var tx = NewPacket();
|
||||
tx[1] = 0x07; // COMMAND_CONTROLL
|
||||
tx[2] = (byte)_state.SetRunMode;
|
||||
tx[3] = _state.SetFanMode;
|
||||
tx[4] = 0; // Auto_Mode
|
||||
tx[5] = 0;
|
||||
tx[7] = _state.ErrorCode; // ErrorCode 도 동봉
|
||||
FinalizeAndSend(tx);
|
||||
}
|
||||
|
||||
// [My_RJ2.c:522] RX_DATA_MODE_RESTART1 - 환기 1~4단 preset
|
||||
void HandleRestart1()
|
||||
{
|
||||
var tx = NewPacket();
|
||||
tx[1] = 0x02;
|
||||
tx[4] = 0x10;
|
||||
tx[5] = (byte)_state.FanSAPreset_Vent[1]; tx[6] = (byte)_state.FanEAPreset_Vent[1];
|
||||
tx[7] = (byte)_state.FanSAPreset_Vent[2]; tx[8] = (byte)_state.FanEAPreset_Vent[2];
|
||||
tx[9] = (byte)_state.FanSAPreset_Vent[3]; tx[10] = (byte)_state.FanEAPreset_Vent[3];
|
||||
tx[11] = (byte)_state.FanSAPreset_Vent[4]; tx[12] = (byte)_state.FanEAPreset_Vent[4];
|
||||
FinalizeAndSend(tx);
|
||||
}
|
||||
|
||||
// [My_RJ2.c:556] RX_DATA_MODE_RESTART2 - bypass/air preset
|
||||
void HandleRestart2()
|
||||
{
|
||||
var tx = NewPacket();
|
||||
tx[1] = 0x12;
|
||||
tx[4] = 0x10;
|
||||
tx[5] = (byte)_state.FanSAPreset_Bypass[1]; tx[6] = (byte)_state.FanEAPreset_Bypass[1];
|
||||
tx[7] = (byte)_state.FanSAPreset_Air[1];
|
||||
tx[8] = (byte)_state.FanSAPreset_Air[2];
|
||||
tx[9] = (byte)_state.FanSAPreset_Air[3];
|
||||
tx[10] = (byte)_state.FanSAPreset_Air[4];
|
||||
FinalizeAndSend(tx);
|
||||
}
|
||||
|
||||
// [My_RJ2.c:579] RX_DATA_MODE_VSP - 테스트 모드 진입 (preset 갱신)
|
||||
void HandleVsp(byte[] p)
|
||||
{
|
||||
// 본 시뮬레이터에선 RX만 기록, preset 변경은 생략
|
||||
PacketReceived?.Invoke($" VSP select={p[3]} sa={p[4]} ea={p[5]}");
|
||||
}
|
||||
|
||||
void HandleExit()
|
||||
{
|
||||
PacketReceived?.Invoke(" VSP exit");
|
||||
}
|
||||
|
||||
byte[] NewPacket()
|
||||
{
|
||||
var tx = new byte[PACKET_LEN];
|
||||
tx[0] = HEADER;
|
||||
return tx;
|
||||
}
|
||||
|
||||
void FinalizeAndSend(byte[] tx)
|
||||
{
|
||||
tx[13] = ChecksumHelper.Xor(tx, 0, 13);
|
||||
tx[14] = TAIL;
|
||||
if (_ch.Send(tx, PACKET_LEN))
|
||||
PacketSent?.Invoke($"RoomCon TX: {HexFormat.Bytes(tx, 15)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.IO.Ports;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ERVSimulator.Protocol
|
||||
{
|
||||
// 공용 시리얼 채널 - byte 단위 수신 콜백 + 송신 helper
|
||||
public class SerialChannel : IDisposable
|
||||
{
|
||||
private SerialPort? _port;
|
||||
private CancellationTokenSource? _cts;
|
||||
private bool _disposed;
|
||||
|
||||
public string ChannelName { get; }
|
||||
public event Action<byte>? ByteReceived;
|
||||
public event Action<string>? Log;
|
||||
public event Action<bool>? ConnectionChanged;
|
||||
public bool IsConnected => _port?.IsOpen == true;
|
||||
|
||||
public SerialChannel(string channelName) { ChannelName = channelName; }
|
||||
|
||||
public static string[] GetAvailablePorts() => SerialPort.GetPortNames();
|
||||
|
||||
public bool Connect(string portName, int baudRate)
|
||||
{
|
||||
try
|
||||
{
|
||||
Disconnect();
|
||||
_port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One)
|
||||
{
|
||||
ReadTimeout = 100,
|
||||
WriteTimeout = 200,
|
||||
Handshake = Handshake.None,
|
||||
DtrEnable = false,
|
||||
RtsEnable = false,
|
||||
};
|
||||
_port.Open();
|
||||
_cts = new CancellationTokenSource();
|
||||
_ = Task.Run(() => ReadLoop(_cts.Token));
|
||||
Log?.Invoke($"[{ChannelName}] Connected {portName} @ {baudRate}");
|
||||
ConnectionChanged?.Invoke(true);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.Invoke($"[{ChannelName}] Connect FAIL: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Disconnect()
|
||||
{
|
||||
try { _cts?.Cancel(); } catch { }
|
||||
try { _port?.Close(); } catch { }
|
||||
_port?.Dispose();
|
||||
_port = null;
|
||||
ConnectionChanged?.Invoke(false);
|
||||
}
|
||||
|
||||
void ReadLoop(CancellationToken ct)
|
||||
{
|
||||
var buf = new byte[64];
|
||||
while (!ct.IsCancellationRequested && _port != null && _port.IsOpen)
|
||||
{
|
||||
try
|
||||
{
|
||||
int n = _port.Read(buf, 0, buf.Length);
|
||||
for (int i = 0; i < n; i++) ByteReceived?.Invoke(buf[i]);
|
||||
}
|
||||
catch (TimeoutException) { /* expected */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.Invoke($"[{ChannelName}] ReadLoop error: {ex.Message}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool Send(byte[] data, int length)
|
||||
{
|
||||
if (_port == null || !_port.IsOpen) return false;
|
||||
try
|
||||
{
|
||||
_port.Write(data, 0, length);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.Invoke($"[{ChannelName}] Send FAIL: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
Disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public static class HexFormat
|
||||
{
|
||||
public static string Bytes(byte[] data, int length)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder(length * 3);
|
||||
for (int i = 0; i < length; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(' ');
|
||||
sb.Append(data[i].ToString("X2"));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<Window x:Class="ERVSimulator.VspWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="풍량 VSP 설정" SizeToContent="WidthAndHeight"
|
||||
ResizeMode="NoResize" WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource PrimaryBgBrush}">
|
||||
<Border Style="{StaticResource SectionCard}" Margin="10">
|
||||
<StackPanel>
|
||||
<TextBlock Text="풍량 VSP 설정 (SA 급기 / EA 배기) — 수정 가능" Style="{StaticResource SectionTitle}"/>
|
||||
<ItemsControl x:Name="VspItems" Width="990">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Columns="3"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="{StaticResource CardBgBrush}" CornerRadius="8" Margin="3" Padding="10,8"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" Width="60" FontSize="13" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="SA" Margin="0,0,4,0" FontSize="11" Foreground="{StaticResource AccentBlueBrush}" VerticalAlignment="Center"/>
|
||||
<TextBox Text="{Binding Sa, UpdateSourceTrigger=PropertyChanged}" Width="80" Margin="0,0,10,0" Padding="4,3"
|
||||
TextAlignment="Right" FontSize="12"
|
||||
Background="{StaticResource PrimaryBgBrush}" Foreground="{StaticResource TextPrimaryBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
<TextBlock Text="EA" Margin="0,0,4,0" FontSize="11" Foreground="{StaticResource AccentGreenBrush}" VerticalAlignment="Center"/>
|
||||
<TextBox Text="{Binding Ea, UpdateSourceTrigger=PropertyChanged}" Width="80" Padding="4,3"
|
||||
TextAlignment="Right" FontSize="12"
|
||||
Background="{StaticResource PrimaryBgBrush}" Foreground="{StaticResource TextPrimaryBrush}"
|
||||
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,12,0,0" HorizontalAlignment="Right">
|
||||
<Button Content="VSP 적용" Width="100" Style="{StaticResource ModernButton}" Click="Apply_Click"
|
||||
Margin="0,0,6,0" Background="{StaticResource AccentBlueBrush}"/>
|
||||
<Button Content="닫기" Width="90" Style="{StaticResource ModernButton}" Click="Close_Click"
|
||||
Background="{StaticResource CardBgBrush}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Window>
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows;
|
||||
using ERVSimulator.Model;
|
||||
|
||||
namespace ERVSimulator
|
||||
{
|
||||
// 편집 가능한 풍량 VSP 행 (환기1~4, 바이패스, 공청1~4)
|
||||
public class VspEditRow
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public int Sa { get; set; }
|
||||
public int Ea { get; set; }
|
||||
public VspEditRow(string n, int sa, int ea) { Name = n; Sa = sa; Ea = ea; }
|
||||
}
|
||||
|
||||
public partial class VspWindow : Window
|
||||
{
|
||||
readonly ErvState _state;
|
||||
public ObservableCollection<VspEditRow> Rows { get; } = new();
|
||||
public event Action<string>? Applied;
|
||||
|
||||
public VspWindow(ErvState state)
|
||||
{
|
||||
InitializeComponent();
|
||||
_state = state;
|
||||
|
||||
Rows.Add(new VspEditRow("환기1", state.FanSAPreset_Vent[1], state.FanEAPreset_Vent[1]));
|
||||
Rows.Add(new VspEditRow("환기2", state.FanSAPreset_Vent[2], state.FanEAPreset_Vent[2]));
|
||||
Rows.Add(new VspEditRow("환기3", state.FanSAPreset_Vent[3], state.FanEAPreset_Vent[3]));
|
||||
Rows.Add(new VspEditRow("환기4", state.FanSAPreset_Vent[4], state.FanEAPreset_Vent[4]));
|
||||
Rows.Add(new VspEditRow("바이패스", state.FanSAPreset_Bypass[1], state.FanEAPreset_Bypass[1]));
|
||||
Rows.Add(new VspEditRow("공청1", state.FanSAPreset_Air[1], state.FanEAPreset_Air[1]));
|
||||
Rows.Add(new VspEditRow("공청2", state.FanSAPreset_Air[2], state.FanEAPreset_Air[2]));
|
||||
Rows.Add(new VspEditRow("공청3", state.FanSAPreset_Air[3], state.FanEAPreset_Air[3]));
|
||||
Rows.Add(new VspEditRow("공청4", state.FanSAPreset_Air[4], state.FanEAPreset_Air[4]));
|
||||
|
||||
VspItems.ItemsSource = Rows;
|
||||
}
|
||||
|
||||
static ushort Clamp(int v) => (ushort)(v < 0 ? 0 : v > 255 ? 255 : v); // VSP 1바이트
|
||||
|
||||
void Apply_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_state.FanSAPreset_Vent[1] = Clamp(Rows[0].Sa); _state.FanEAPreset_Vent[1] = Clamp(Rows[0].Ea);
|
||||
_state.FanSAPreset_Vent[2] = Clamp(Rows[1].Sa); _state.FanEAPreset_Vent[2] = Clamp(Rows[1].Ea);
|
||||
_state.FanSAPreset_Vent[3] = Clamp(Rows[2].Sa); _state.FanEAPreset_Vent[3] = Clamp(Rows[2].Ea);
|
||||
_state.FanSAPreset_Vent[4] = Clamp(Rows[3].Sa); _state.FanEAPreset_Vent[4] = Clamp(Rows[3].Ea);
|
||||
_state.FanSAPreset_Bypass[1] = Clamp(Rows[4].Sa); _state.FanEAPreset_Bypass[1] = Clamp(Rows[4].Ea);
|
||||
_state.FanSAPreset_Air[1] = Clamp(Rows[5].Sa); _state.FanEAPreset_Air[1] = Clamp(Rows[5].Ea);
|
||||
_state.FanSAPreset_Air[2] = Clamp(Rows[6].Sa); _state.FanEAPreset_Air[2] = Clamp(Rows[6].Ea);
|
||||
_state.FanSAPreset_Air[3] = Clamp(Rows[7].Sa); _state.FanEAPreset_Air[3] = Clamp(Rows[7].Ea);
|
||||
_state.FanSAPreset_Air[4] = Clamp(Rows[8].Sa); _state.FanEAPreset_Air[4] = Clamp(Rows[8].Ea);
|
||||
|
||||
Applied?.Invoke("[Manual] 풍량 VSP 값 적용");
|
||||
}
|
||||
|
||||
void Close_Click(object sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
외기(OA), CN2 , GREEN, U1(15~18),MCU (PB.12,PB.13,PB.14,PB.8)
|
||||
공청(AIR), CN10, YELLOW, U4 (11~14), MCU (PD.14,PD.7, PD.6,PB.3)
|
||||
바이패스(BYPASS), CN5, RED, U2 (15~18), MCU (PA.6,PA.14,PA.15,PC.8)
|
||||
배기(EA), CN3, BLACK, U1(11~14), MCU (PB.15,PC.14,PC.15,PC.6)
|
||||
급기(SA), CN7, BLUE, U2(11~14), MCU (PC.9,PC.10,PC.11,PB.9)
|
||||
환기(RA), CN9, WHITE, U4(15~18), MCU (PB.10,PC.2,PC.3,PD.15)
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace HoodSimulator
|
||||
{
|
||||
public partial class App : Application
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 = "연결";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 측 "후드연동" 버튼 : 미연결(기본색) / 통신중 정상(녹색) / 통신중 에러(빨강 + 에러명)
|
||||
@@ -0,0 +1,111 @@
|
||||
<Application x:Class="RJ2RoomConSimulator.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>
|
||||
|
||||
<Style x:Key="ModernTextBox" TargetType="TextBox">
|
||||
<Setter Property="Background" Value="{StaticResource CardBgBrush}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="6,4"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
</Style>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace RJ2RoomConSimulator
|
||||
{
|
||||
public partial class App : Application
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<Window x:Class="RJ2RoomConSimulator.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="RJ2-232 룸콘 시뮬레이터 - RoomCon Simulator"
|
||||
Width="620" Height="860"
|
||||
MinWidth="560" MinHeight="740"
|
||||
Background="{StaticResource PrimaryBgBrush}"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<Grid Margin="12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/> <!-- 0: 연결 -->
|
||||
<RowDefinition Height="Auto"/> <!-- 1: 룸콘 제어 -->
|
||||
<RowDefinition Height="Auto"/> <!-- 2: ERV 응답 -->
|
||||
<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="RJ2-232 룸콘 시뮬레이터" 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"/>
|
||||
<TextBlock Text="9600 8N1" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center" Margin="0,0,10,0" FontSize="11"/>
|
||||
<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,10,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"/>
|
||||
|
||||
<!-- 전원 + 후드 연동(표시 전용 : ERV 후드연동 상태 수신, 힘펠 V3.7 HOOD_INFO 0x0A) -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,14">
|
||||
<TextBlock Text="전원" Width="64" 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"/>
|
||||
|
||||
<Border Width="1" Background="{StaticResource BorderBrush}" Margin="18,2,16,2"/>
|
||||
<TextBlock Text="후드 연동" FontSize="13" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||
<Ellipse x:Name="HoodLinkLed" Width="11" Height="11" Fill="#3B3B55" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock x:Name="txtHoodLink" Text="OFF" FontSize="13" FontWeight="Bold" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 모드 -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
|
||||
<TextBlock Text="모드" Width="64" FontSize="13" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center"/>
|
||||
<StackPanel Orientation="Horizontal" x:Name="ModePanel">
|
||||
<Button Content="환기" Tag="0" Click="Mode_Click" Style="{StaticResource ModernButton}" Margin="0,0,6,0" MinWidth="78" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="자동" Tag="1" Click="Mode_Click" Style="{StaticResource ModernButton}" Margin="0,0,6,0" MinWidth="78" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="공기청정" Tag="3" Click="Mode_Click" Style="{StaticResource ModernButton}" Margin="0,0,6,0" MinWidth="78" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="바이패스" Tag="2" Click="Mode_Click" Style="{StaticResource ModernButton}" MinWidth="78" Background="{StaticResource CardBgBrush}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 풍량 -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
|
||||
<TextBlock Text="풍량" Width="64" 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,5,0" MinWidth="44" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="1" Tag="1" Click="Fan_Click" Style="{StaticResource ModernButton}" Margin="0,0,5,0" MinWidth="44" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="2" Tag="2" Click="Fan_Click" Style="{StaticResource ModernButton}" Margin="0,0,5,0" MinWidth="44" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="3" Tag="3" Click="Fan_Click" Style="{StaticResource ModernButton}" Margin="0,0,5,0" MinWidth="44" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="4" Tag="4" Click="Fan_Click" Style="{StaticResource ModernButton}" MinWidth="44" Background="{StaticResource CardBgBrush}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="(자동:변경불가 · 바이패스:1단)" FontSize="10" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center" Margin="10,0,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 예약 -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
|
||||
<TextBlock Text="예약(꺼짐)" Width="64" FontSize="13" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center"/>
|
||||
<ComboBox x:Name="ReserveCombo" Width="90" Style="{StaticResource ModernComboBox}" VerticalAlignment="Center" SelectionChanged="Reserve_Changed" SelectedIndex="0">
|
||||
<ComboBoxItem Content="해제"/>
|
||||
<ComboBoxItem Content="1시간"/>
|
||||
<ComboBoxItem Content="2시간"/>
|
||||
<ComboBoxItem Content="3시간"/>
|
||||
<ComboBoxItem Content="4시간"/>
|
||||
<ComboBoxItem Content="5시간"/>
|
||||
<ComboBoxItem Content="6시간"/>
|
||||
<ComboBoxItem Content="7시간"/>
|
||||
<ComboBoxItem Content="8시간"/>
|
||||
</ComboBox>
|
||||
<TextBlock Text="(모드 전환 시 해제)" FontSize="10" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center" Margin="10,0,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- id / vsp -->
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="ID" Width="64" FontSize="13" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center"/>
|
||||
<TextBox x:Name="txtDeviceId" Text="1" Width="50" Style="{StaticResource ModernTextBox}" TextAlignment="Center" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<Button Content="적용" Style="{StaticResource ModernButton}" Click="ApplyId_Click" Padding="12,5" Background="{StaticResource CardBgBrush}" Margin="0,0,18,0"/>
|
||||
<Button Content="VSP 설정 ▸" Style="{StaticResource ModernButton}" Click="OpenVsp_Click" Padding="12,5" Background="{StaticResource CardBgBrush}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Row 2: ERV 응답 상태 -->
|
||||
<Border Grid.Row="2" Background="{StaticResource SecondaryBgBrush}" CornerRadius="10" Padding="16" Margin="0,0,0,8">
|
||||
<StackPanel>
|
||||
<DockPanel Margin="0,0,0,10">
|
||||
<TextBlock Text="ERV 응답 상태" FontSize="14" FontWeight="Bold" Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
<TextBlock x:Name="txtRxCount" DockPanel.Dock="Right" Text="수신: 0" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<Border Background="{StaticResource CardBgBrush}" CornerRadius="6" Padding="12,8" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1">
|
||||
<StackPanel>
|
||||
<DockPanel Margin="0,0,0,3"><TextBlock Text="운전모드" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"/><TextBlock x:Name="txtErvMode" DockPanel.Dock="Right" Text="-" FontSize="12" FontWeight="Bold" Foreground="{StaticResource AccentCyanBrush}"/></DockPanel>
|
||||
<DockPanel Margin="0,0,0,3"><TextBlock Text="풍량" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"/><TextBlock x:Name="txtErvFan" DockPanel.Dock="Right" Text="-" FontSize="12" FontWeight="Bold" Foreground="{StaticResource TextPrimaryBrush}"/></DockPanel>
|
||||
<DockPanel Margin="0,0,0,3"><TextBlock Text="에러코드" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"/><TextBlock x:Name="txtErvErr" DockPanel.Dock="Right" Text="-" FontSize="12" FontWeight="Bold" Foreground="{StaticResource TextPrimaryBrush}"/></DockPanel>
|
||||
<DockPanel Margin="0,0,0,3"><TextBlock Text="실내온도" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"/><TextBlock x:Name="txtErvIn" DockPanel.Dock="Right" Text="-" FontSize="12" FontWeight="Bold" Foreground="{StaticResource TextPrimaryBrush}"/></DockPanel>
|
||||
<DockPanel><TextBlock Text="외기온도" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"/><TextBlock x:Name="txtErvOut" DockPanel.Dock="Right" Text="-" FontSize="12" FontWeight="Bold" Foreground="{StaticResource TextPrimaryBrush}"/></DockPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 알람 (필터 청소/교환) -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,10,0,4">
|
||||
<TextBlock Text="알람" Width="40" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center"/>
|
||||
<Border CornerRadius="4" Padding="6,3" Margin="0,0,4,0" Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1">
|
||||
<StackPanel Orientation="Horizontal"><Ellipse x:Name="AlFClean" Width="9" Height="9" Fill="#3B3B55" VerticalAlignment="Center" Margin="0,0,4,0"/><TextBlock Text="필터청소" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}"/></StackPanel>
|
||||
</Border>
|
||||
<Border CornerRadius="4" Padding="6,3" Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1">
|
||||
<StackPanel Orientation="Horizontal"><Ellipse x:Name="AlFChange" Width="9" Height="9" Fill="#3B3B55" VerticalAlignment="Center" Margin="0,0,4,0"/><TextBlock Text="필터교환" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}"/></StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 에러 (ERV ErrorCode) -->
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="에러" Width="40" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource TextPrimaryBrush}" VerticalAlignment="Center"/>
|
||||
<Border CornerRadius="4" Padding="6,3" Margin="0,0,4,0" Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" ToolTip="온도센서">
|
||||
<StackPanel Orientation="Horizontal"><Ellipse x:Name="ErE02" Width="9" Height="9" Fill="#3B3B55" VerticalAlignment="Center" Margin="0,0,4,0"/><TextBlock Text="E02" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}"/></StackPanel>
|
||||
</Border>
|
||||
<Border CornerRadius="4" Padding="6,3" Margin="0,0,4,0" Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" ToolTip="급기(SA) 팬">
|
||||
<StackPanel Orientation="Horizontal"><Ellipse x:Name="ErE09" Width="9" Height="9" Fill="#3B3B55" VerticalAlignment="Center" Margin="0,0,4,0"/><TextBlock Text="E09" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}"/></StackPanel>
|
||||
</Border>
|
||||
<Border CornerRadius="4" Padding="6,3" Margin="0,0,4,0" Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" ToolTip="배기(EA) 팬">
|
||||
<StackPanel Orientation="Horizontal"><Ellipse x:Name="ErE10" Width="9" Height="9" Fill="#3B3B55" VerticalAlignment="Center" Margin="0,0,4,0"/><TextBlock Text="E10" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}"/></StackPanel>
|
||||
</Border>
|
||||
<Border CornerRadius="4" Padding="6,3" Margin="0,0,4,0" Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" ToolTip="장비보호모드">
|
||||
<StackPanel Orientation="Horizontal"><Ellipse x:Name="ErCold" Width="9" Height="9" Fill="#3B3B55" VerticalAlignment="Center" Margin="0,0,4,0"/><TextBlock Text="COLD" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}"/></StackPanel>
|
||||
</Border>
|
||||
<Border CornerRadius="4" Padding="6,3" Background="{StaticResource CardBgBrush}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" ToolTip="내부 통신">
|
||||
<StackPanel Orientation="Horizontal"><Ellipse x:Name="ErE07" Width="9" Height="9" Fill="#3B3B55" VerticalAlignment="Center" Margin="0,0,4,0"/><TextBlock Text="E07" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" Foreground="{StaticResource TextPrimaryBrush}"/></StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</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>
|
||||
@@ -0,0 +1,262 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace RJ2RoomConSimulator
|
||||
{
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
readonly RoomConProtocol _proto = new();
|
||||
readonly DispatcherTimer _pollTimer;
|
||||
int _rxCount;
|
||||
bool _suppressReserve;
|
||||
VspWindow? _vspWin;
|
||||
|
||||
static readonly Brush AccentCyan = Br("#7DCFFF");
|
||||
static readonly Brush AccentGreen = Br("#9ECE6A");
|
||||
static readonly Brush AccentRed = Br("#F7768E");
|
||||
static readonly Brush AccentYellow = Br("#E0AF68");
|
||||
static readonly Brush CardBg = Br("#313147");
|
||||
static readonly Brush TextPrimary = Br("#C0CAF5");
|
||||
static readonly Brush TextSecondary = Br("#565F89");
|
||||
static readonly Brush LedOff = Br("#3B3B55");
|
||||
static Brush Br(string h) => (Brush)new BrushConverter().ConvertFromString(h)!;
|
||||
|
||||
static void SetChip(System.Windows.Shapes.Ellipse led, bool on, Brush onColor) => led.Fill = on ? onColor : LedOff;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
_proto.Log += OnLog;
|
||||
_proto.ConnectionChanged += OnConnectionChanged;
|
||||
_proto.ResponseReceived += OnResponse;
|
||||
|
||||
_pollTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
|
||||
_pollTimer.Tick += (_, _) => { if (_proto.IsConnected) _proto.SendNormal(); };
|
||||
|
||||
RefreshPorts();
|
||||
UpdateModeButtons();
|
||||
UpdateFanButtons();
|
||||
Closed += (_, _) => _proto.Dispose();
|
||||
}
|
||||
|
||||
// ================= 연결 =================
|
||||
void RefreshPorts()
|
||||
{
|
||||
cmbPort.Items.Clear();
|
||||
foreach (var p in _proto.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 (_proto.IsConnected) { _proto.Disconnect(); btnConnect.Content = "연결"; }
|
||||
else
|
||||
{
|
||||
if (cmbPort.SelectedItem == null) { MessageBox.Show("COM 포트를 선택하세요."); return; }
|
||||
if (_proto.Connect(cmbPort.SelectedItem.ToString()!)) btnConnect.Content = "연결 해제";
|
||||
}
|
||||
}
|
||||
|
||||
void Start_Click(object s, RoutedEventArgs e)
|
||||
{
|
||||
if (!_proto.IsConnected) return;
|
||||
_pollTimer.Start();
|
||||
btnStart.IsEnabled = false; btnStop.IsEnabled = true;
|
||||
OnLog("[폴링 시작] 상태 조회(NORMAL) 주기 송신");
|
||||
}
|
||||
void Stop_Click(object s, RoutedEventArgs e)
|
||||
{
|
||||
_pollTimer.Stop();
|
||||
btnStart.IsEnabled = _proto.IsConnected; btnStop.IsEnabled = false;
|
||||
OnLog("[폴링 중지]");
|
||||
}
|
||||
|
||||
// ================= 룸콘 제어 =================
|
||||
void Power_Click(object s, RoutedEventArgs e)
|
||||
{
|
||||
_proto.PowerOn = tglPower.IsChecked == true;
|
||||
if (_proto.PowerOn) { _proto.RunMode = 0; _proto.FanMode = 1; } // ON → 환기 1단
|
||||
else _proto.FanMode = 0; // OFF → 풍량 0
|
||||
txtPower.Text = _proto.PowerOn ? "ON" : "OFF";
|
||||
txtPower.Foreground = _proto.PowerOn ? AccentGreen : TextSecondary;
|
||||
UpdateModeButtons(); UpdateFanButtons();
|
||||
_proto.SendEvent();
|
||||
OnLog($"[제어] 전원 → {(_proto.PowerOn ? "ON (환기 1단)" : "OFF")}");
|
||||
}
|
||||
|
||||
void Mode_Click(object s, RoutedEventArgs e)
|
||||
{
|
||||
if (s is not Button b || b.Tag is not string tag || !byte.TryParse(tag, out var mode)) return;
|
||||
_proto.PowerOn = true;
|
||||
_proto.RunMode = mode;
|
||||
// 운전모드 전환 시 풍량 1단 (자동 제외). 바이패스는 1단 고정, 공청/환기도 전환 기본 1단.
|
||||
// (ERV/펌웨어는 룸컨이 보낸 fan을 그대로 따르므로 마스터인 룸컨이 1단을 보내야 동기화됨)
|
||||
if (mode != 1) _proto.FanMode = 1;
|
||||
// 모드 전환 시 예약 해제
|
||||
ClearReserve();
|
||||
tglPower.IsChecked = true; txtPower.Text = "ON"; txtPower.Foreground = AccentGreen;
|
||||
UpdateModeButtons(); UpdateFanButtons();
|
||||
_proto.SendEvent();
|
||||
OnLog($"[제어] 모드 → {ModeName(mode)}");
|
||||
}
|
||||
|
||||
void Fan_Click(object s, RoutedEventArgs e)
|
||||
{
|
||||
if (s is not Button b || b.Tag is not string tag || !byte.TryParse(tag, out var sp)) return;
|
||||
if (_proto.RunMode == 1) { OnLog("자동모드에서는 풍량 변경 불가"); return; } // 자동
|
||||
if (_proto.RunMode == 2 && sp > 1) { OnLog("바이패스는 1단 고정"); return; } // 바이패스
|
||||
_proto.FanMode = sp;
|
||||
_proto.PowerOn = sp > 0 || _proto.PowerOn;
|
||||
if (sp > 0) { tglPower.IsChecked = true; txtPower.Text = "ON"; txtPower.Foreground = AccentGreen; }
|
||||
UpdateFanButtons();
|
||||
_proto.SendEvent();
|
||||
OnLog($"[제어] 풍량 → {sp}단");
|
||||
}
|
||||
|
||||
void Reserve_Changed(object s, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_suppressReserve || !IsLoaded || ReserveCombo.SelectedIndex < 0) return;
|
||||
_proto.ReserveHours = ReserveCombo.SelectedIndex; // 0=해제
|
||||
_proto.SendEvent();
|
||||
OnLog(_proto.ReserveHours == 0 ? "[제어] 예약 해제" : $"[제어] {_proto.ReserveHours}시간 후 꺼짐 예약");
|
||||
}
|
||||
void ClearReserve()
|
||||
{
|
||||
_proto.ReserveHours = 0;
|
||||
if (ReserveCombo.SelectedIndex != 0) { _suppressReserve = true; ReserveCombo.SelectedIndex = 0; _suppressReserve = false; }
|
||||
}
|
||||
|
||||
void ApplyId_Click(object s, RoutedEventArgs e)
|
||||
{
|
||||
if (byte.TryParse(txtDeviceId.Text, out var id)) { _proto.DeviceId = id; OnLog($"[설정] ID → {id}"); }
|
||||
else OnLog("ID는 0~255 숫자만 가능");
|
||||
}
|
||||
|
||||
void OpenVsp_Click(object s, RoutedEventArgs e)
|
||||
{
|
||||
if (_vspWin == null)
|
||||
{
|
||||
_vspWin = new VspWindow(_proto) { Owner = this };
|
||||
_vspWin.Closed += (_, _) => _vspWin = null;
|
||||
_vspWin.Show();
|
||||
}
|
||||
else _vspWin.Activate();
|
||||
}
|
||||
|
||||
// ================= 버튼 강조 =================
|
||||
void UpdateModeButtons()
|
||||
{
|
||||
foreach (var child in ModePanel.Children)
|
||||
if (child is Button btn && btn.Tag is string t && byte.TryParse(t, out var m))
|
||||
{
|
||||
bool active = _proto.PowerOn && m == _proto.RunMode;
|
||||
btn.Background = active ? AccentCyan : CardBg;
|
||||
btn.Foreground = active ? Brushes.Black : TextPrimary;
|
||||
}
|
||||
}
|
||||
void UpdateFanButtons()
|
||||
{
|
||||
foreach (var child in FanPanel.Children)
|
||||
if (child is Button btn && btn.Tag is string t && byte.TryParse(t, out var sp))
|
||||
{
|
||||
bool active = sp == _proto.FanMode;
|
||||
btn.Background = active ? AccentCyan : CardBg;
|
||||
btn.Foreground = active ? Brushes.Black : TextPrimary;
|
||||
// 자동:전 단 비활성 / 바이패스:2~4 비활성
|
||||
btn.IsEnabled = !(_proto.RunMode == 1) && !(_proto.RunMode == 2 && sp > 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ================= ERV 응답 =================
|
||||
void OnResponse(byte cmd)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
_rxCount++;
|
||||
txtRxCount.Text = $"수신: {_rxCount}";
|
||||
txtErvMode.Text = $"{_proto.ErvRunMode} ({ModeName(_proto.ErvRunMode)})";
|
||||
txtErvFan.Text = _proto.ErvFanMode == 0 ? "0 (OFF)" : $"{_proto.ErvFanMode}단";
|
||||
txtErvErr.Text = $"0x{_proto.ErvError:X2}";
|
||||
txtErvErr.Foreground = _proto.ErvError != 0 ? AccentRed : TextPrimary;
|
||||
|
||||
// 알람/에러 비트 디코드 (ERV ErrorCode 비트맵)
|
||||
byte ec = _proto.ErvError;
|
||||
SetChip(AlFClean, (ec & 0x01) != 0, AccentYellow); // 필터 청소
|
||||
SetChip(AlFChange, (ec & 0x02) != 0, AccentYellow); // 필터 교환
|
||||
SetChip(ErE02, (ec & 0x08) != 0, AccentRed); // 온도센서
|
||||
SetChip(ErE09, (ec & 0x80) != 0, AccentRed); // 급기팬
|
||||
SetChip(ErE10, (ec & 0x20) != 0, AccentRed); // 배기팬
|
||||
SetChip(ErCold,(ec & 0x10) != 0, AccentCyan); // 장비보호
|
||||
SetChip(ErE07, (ec & 0x40) != 0, AccentRed); // 내부통신
|
||||
txtErvIn.Text = _proto.ErvInTemp == 100 ? "센서없음" : $"{_proto.ErvInTemp}℃";
|
||||
txtErvOut.Text = _proto.ErvOutTemp == 100 ? "센서없음" : $"{_proto.ErvOutTemp}℃";
|
||||
|
||||
// 후드 연동 표시 (ERV HOOD_INFO 0x0A 수신값) : 후드 ON 오면 연동 ON, ERV OFF면 OFF
|
||||
SetChip(HoodLinkLed, _proto.HoodLinked, AccentGreen);
|
||||
txtHoodLink.Text = _proto.HoodLinked ? "ON" : "OFF";
|
||||
txtHoodLink.Foreground = _proto.HoodLinked ? AccentGreen : TextSecondary;
|
||||
|
||||
// 자동모드: 풍량은 ERV가 자동 결정 → 룸콘 표시를 ERV 보고값으로 동기화
|
||||
if (_proto.RunMode == 1 && _proto.FanMode != _proto.ErvFanMode)
|
||||
{
|
||||
_proto.FanMode = _proto.ErvFanMode;
|
||||
UpdateFanButtons();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static string ModeName(int m) => m switch
|
||||
{
|
||||
0 => "환기", 1 => "자동", 2 => "바이패스", 3 => "공기청정", 4 => "팬테스트", 10 => "OFF", _ => $"?{m}"
|
||||
};
|
||||
|
||||
// ================= 로그 =================
|
||||
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 = $"RoomConSimLog_{DateTime.Now:yyyyMMdd_HHmmss}.txt" };
|
||||
if (dlg.ShowDialog() == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
string h = $"========================================\r\n RJ2-232 룸콘 시뮬레이터 통신 로그\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(() =>
|
||||
{
|
||||
statusLed.Fill = connected ? AccentGreen : AccentRed;
|
||||
txtStatus.Text = connected ? "연결됨" : "미연결";
|
||||
btnStart.IsEnabled = connected;
|
||||
btnConnect.Content = connected ? "연결 해제" : "연결";
|
||||
if (!connected) { _pollTimer.Stop(); btnStop.IsEnabled = false; }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>RJ2RoomConSimulator</RootNamespace>
|
||||
<AssemblyName>RJ2RoomConSimulator</AssemblyName>
|
||||
<StartupObject>RJ2RoomConSimulator.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>
|
||||
@@ -0,0 +1,226 @@
|
||||
using System;
|
||||
using System.IO.Ports;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace RJ2RoomConSimulator
|
||||
{
|
||||
// RJ2-232 룸콘(마스터) 시뮬레이터 프로토콜
|
||||
// 규격 : Protocol/ROOMCON/힘펠_환기장치프로토콜V3.7 (펌웨어 My_RJ2.c / ERVSimulator RoomConProtocol.cs)
|
||||
// 프레임 : AA | CMD | D[2..12] | XOR(0~12) | EE (15 byte 고정), RS-232 9600 8N1
|
||||
// 역할 : 룸콘이 마스터 — 모드/풍량/예약 변경(EVENT) 송신 + 주기 폴링(NORMAL), ERV 응답 수신/표시
|
||||
// ERV(ERVSimulator)가 응답측. 모드코드 : 0환기/1자동/2바이패스/3공청 (ERVSim RunMode enum 일치)
|
||||
public class RoomConProtocol : IDisposable
|
||||
{
|
||||
public const byte HEADER = 0xAA;
|
||||
public const byte TAIL = 0xEE;
|
||||
public const int LEN = 15;
|
||||
|
||||
// CMD
|
||||
public const byte CMD_NORMAL = 0x00; // 상태 폴링
|
||||
public const byte CMD_EVENT = 0x01; // 모드/팬/예약 변경 이벤트
|
||||
public const byte CMD_RESTART1 = 0x02; // 환기 preset 조회
|
||||
public const byte CMD_RESTART2 = 0x12; // bypass/air preset 조회
|
||||
public const byte CMD_VSP = 0x03; // 테스트(VSP) 진입/설정
|
||||
public const byte CMD_EXIT = 0x04; // 테스트 종료
|
||||
public const byte CMD_CONTROL = 0x07; // ERV→룸콘 상태응답(COMMAND_CONTROLL)
|
||||
public const byte CMD_HOOD_INFO = 0x0A; // ERV→룸콘 후드 연동 통지(힘펠 V3.7 RX_DATA_HOOD_INFO)
|
||||
|
||||
SerialPort? _port;
|
||||
CancellationTokenSource? _cts;
|
||||
readonly object _lock = new();
|
||||
bool _disposed;
|
||||
|
||||
// ---- 룸콘 제어 상태 (UI) ----
|
||||
public bool PowerOn;
|
||||
public byte RunMode; // 0환기/1자동/2바이패스/3공청
|
||||
public byte FanMode; // 0~4
|
||||
public int ReserveHours; // 0~8 (0=취소)
|
||||
public byte DeviceId = 1; // id 설정 (RS232 점대점 — 표시/로그용)
|
||||
|
||||
// ---- ERV 응답 파싱 결과 ----
|
||||
public byte ErvRunMode, ErvFanMode, ErvError;
|
||||
public bool HoodLinked; // 후드 연동중(HOOD_INFO byte[6] bit0 = 0x81 ON / 0x80 OFF)
|
||||
public int ErvInTemp, ErvOutTemp;
|
||||
public readonly int[] VentSa = new int[5], VentEa = new int[5]; // index 1~4
|
||||
public readonly int[] BypassSa = new int[2], BypassEa = new int[2];
|
||||
public readonly int[] AirSa = new int[5];
|
||||
|
||||
readonly byte[] _rx = new byte[LEN];
|
||||
int _rxPos;
|
||||
|
||||
public event Action<string>? Log;
|
||||
public event Action<bool>? ConnectionChanged;
|
||||
public event Action<byte>? ResponseReceived; // 파싱 완료된 CMD 전달
|
||||
|
||||
public bool IsConnected => _port?.IsOpen == true;
|
||||
|
||||
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, 9600, Parity.None, 8, StopBits.One)
|
||||
{
|
||||
ReadTimeout = 100, WriteTimeout = 300
|
||||
};
|
||||
_port.Open();
|
||||
_cts = new CancellationTokenSource();
|
||||
_ = Task.Run(() => ReadLoop(_cts.Token));
|
||||
Logm($"[연결] {portName} (9600, 8N1)");
|
||||
ConnectionChanged?.Invoke(true);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logm($"[오류] 연결 실패: {ex.Message}");
|
||||
ConnectionChanged?.Invoke(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Disconnect()
|
||||
{
|
||||
try { _cts?.Cancel(); } catch { }
|
||||
try { if (_port?.IsOpen == true) { _port.Close(); Logm("[연결 해제]"); } } catch { }
|
||||
_port?.Dispose(); _port = null;
|
||||
ConnectionChanged?.Invoke(false);
|
||||
}
|
||||
|
||||
// ================= 수신 (ERV 응답 파싱) =================
|
||||
void ReadLoop(CancellationToken ct)
|
||||
{
|
||||
var buf = new byte[64];
|
||||
while (!ct.IsCancellationRequested && _port != null && _port.IsOpen)
|
||||
{
|
||||
try
|
||||
{
|
||||
int n = _port.Read(buf, 0, buf.Length);
|
||||
for (int i = 0; i < n; i++) FeedByte(buf[i]);
|
||||
}
|
||||
catch (TimeoutException) { }
|
||||
catch (Exception ex) { if (!ct.IsCancellationRequested) Logm($"[오류] {ex.Message}"); break; }
|
||||
}
|
||||
}
|
||||
|
||||
DateTime _lastByte = DateTime.MinValue;
|
||||
static readonly TimeSpan FrameGap = TimeSpan.FromMilliseconds(50);
|
||||
void FeedByte(byte b)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastByte > FrameGap) _rxPos = 0;
|
||||
_lastByte = now;
|
||||
|
||||
if (_rxPos == 0) { if (b == HEADER) _rx[_rxPos++] = b; return; }
|
||||
if (_rxPos <= 12) { _rx[_rxPos++] = b; return; }
|
||||
if (_rxPos == 13) { if (Xor(_rx, 0, 13) == b) _rx[_rxPos++] = b; else _rxPos = 0; return; }
|
||||
if (_rxPos == 14)
|
||||
{
|
||||
_rxPos = 0;
|
||||
if (b != TAIL) return;
|
||||
HandleResponse((byte[])_rx.Clone());
|
||||
}
|
||||
}
|
||||
|
||||
void HandleResponse(byte[] p)
|
||||
{
|
||||
Logm($"RX: {Hex(p, 14)} EE");
|
||||
byte cmd = p[1];
|
||||
switch (cmd)
|
||||
{
|
||||
case CMD_EVENT: // ERV ack : runMode/fanMode/err/temp
|
||||
ErvRunMode = p[2]; ErvFanMode = p[3]; ErvError = p[7];
|
||||
ErvOutTemp = (p[9] == 0xFF) ? 100 : (p[8] == 0x01 ? -(p[9] - 20) : p[9] - 20);
|
||||
ErvInTemp = (p[10] == 0xFF) ? 100 : p[10] - 20;
|
||||
break;
|
||||
case CMD_CONTROL: // 상태 폴링 응답
|
||||
ErvRunMode = p[2]; ErvFanMode = p[3]; ErvError = p[7];
|
||||
break;
|
||||
case CMD_RESTART1: // 환기 preset
|
||||
VentSa[1] = p[5]; VentEa[1] = p[6]; VentSa[2] = p[7]; VentEa[2] = p[8];
|
||||
VentSa[3] = p[9]; VentEa[3] = p[10]; VentSa[4] = p[11]; VentEa[4] = p[12];
|
||||
break;
|
||||
case CMD_RESTART2: // bypass/air preset
|
||||
BypassSa[1] = p[5]; BypassEa[1] = p[6];
|
||||
AirSa[1] = p[7]; AirSa[2] = p[8]; AirSa[3] = p[9]; AirSa[4] = p[10];
|
||||
break;
|
||||
case CMD_HOOD_INFO: // 후드 연동 통지 : byte[6] 0x81=ON / 0x80=OFF
|
||||
HoodLinked = (p[6] & 0x01) != 0;
|
||||
break;
|
||||
}
|
||||
ResponseReceived?.Invoke(cmd);
|
||||
}
|
||||
|
||||
// ================= 송신 (룸콘 → ERV) =================
|
||||
byte[] NewPacket(byte cmd)
|
||||
{
|
||||
var p = new byte[LEN];
|
||||
p[0] = HEADER; p[1] = cmd;
|
||||
return p;
|
||||
}
|
||||
void Send(byte[] p)
|
||||
{
|
||||
p[13] = Xor(p, 0, 13);
|
||||
p[14] = TAIL;
|
||||
if (_port?.IsOpen != true) return;
|
||||
try { lock (_lock) { _port.Write(p, 0, LEN); } Logm($"TX: {Hex(p, LEN)}"); }
|
||||
catch (Exception ex) { Logm($"[송신오류] {ex.Message}"); }
|
||||
}
|
||||
|
||||
// 모드/풍량/예약 변경 이벤트 (EVENT). 전원 OFF는 환기+풍량0으로 표현(ERV가 Power OFF 처리).
|
||||
public void SendEvent()
|
||||
{
|
||||
var p = NewPacket(CMD_EVENT);
|
||||
p[2] = PowerOn ? RunMode : (byte)0; // OFF → 환기(0)
|
||||
p[3] = PowerOn ? FanMode : (byte)0; // OFF → 풍량 0
|
||||
p[5] = 0; // Heater/UV/Kijer
|
||||
p[10] = 1; p[11] = (byte)ReserveHours; p[12] = 0; // 예약(시) — 0이면 해제. flag=1로 항상 전달
|
||||
Send(p);
|
||||
}
|
||||
|
||||
// 상태 폴링 (NORMAL)
|
||||
public void SendNormal()
|
||||
{
|
||||
var p = NewPacket(CMD_NORMAL);
|
||||
if (ReserveHours > 0) { p[11] = (byte)ReserveHours; p[12] = 0; }
|
||||
Send(p);
|
||||
}
|
||||
|
||||
public void SendRestart1() => Send(NewPacket(CMD_RESTART1)); // 환기 preset 조회
|
||||
public void SendRestart2() => Send(NewPacket(CMD_RESTART2)); // bypass/air preset 조회
|
||||
public void SendExit() => Send(NewPacket(CMD_EXIT)); // 테스트 종료
|
||||
|
||||
// VSP(테스트) 설정 : select / sa / ea
|
||||
public void SendVsp(byte select, byte sa, byte ea)
|
||||
{
|
||||
var p = NewPacket(CMD_VSP);
|
||||
p[3] = select; p[4] = sa; p[5] = ea;
|
||||
Send(p);
|
||||
}
|
||||
|
||||
void Logm(string m) => Log?.Invoke($"[{DateTime.Now:HH:mm:ss.fff}] {m}");
|
||||
static string Hex(byte[] d, int n)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder(n * 3);
|
||||
for (int i = 0; i < n; i++) { if (i > 0) sb.Append(' '); sb.Append(d[i].ToString("X2")); }
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
Disconnect();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<Window x:Class="RJ2RoomConSimulator.VspWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="VSP 설정 (테스트 모드)" SizeToContent="WidthAndHeight"
|
||||
ResizeMode="NoResize" WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource PrimaryBgBrush}">
|
||||
<Border Background="{StaticResource SecondaryBgBrush}" CornerRadius="10" Margin="10" Padding="16">
|
||||
<StackPanel>
|
||||
<TextBlock Text="VSP 설정 — 풍량 프리셋 조회/설정 (RESTART/VSP)" FontSize="14" FontWeight="Bold"
|
||||
Foreground="{StaticResource TextPrimaryBrush}" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="조회 후 SA/EA 수정하고 '설정' 누르면 해당 프리셋 VSP 송신. 공청은 EA 미사용."
|
||||
FontSize="10" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,10"/>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<Button Content="환기 조회" Style="{StaticResource ModernButton}" Click="QueryVent_Click" Padding="12,5" Margin="0,0,6,0" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="바이패스/공청 조회" Style="{StaticResource ModernButton}" Click="QueryBypassAir_Click" Padding="12,5" Margin="0,0,6,0" Background="{StaticResource CardBgBrush}"/>
|
||||
<Button Content="테스트 종료" Style="{StaticResource ModernButton}" Click="Exit_Click" Padding="12,5" Background="{StaticResource AccentRedBrush}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<Grid Margin="0,0,0,4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="90"/><ColumnDefinition Width="70"/><ColumnDefinition Width="70"/><ColumnDefinition Width="70"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="프리셋" FontSize="11" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBlock Grid.Column="1" Text="SA" TextAlignment="Center" FontSize="11" Foreground="{StaticResource AccentBlueBrush}"/>
|
||||
<TextBlock Grid.Column="2" Text="EA" TextAlignment="Center" FontSize="11" Foreground="{StaticResource AccentGreenBrush}"/>
|
||||
</Grid>
|
||||
|
||||
<StackPanel x:Name="RowsPanel"/>
|
||||
|
||||
<Button Content="닫기" Style="{StaticResource ModernButton}" Click="Close_Click" Padding="14,6" Margin="0,12,0,0"
|
||||
HorizontalAlignment="Right" Background="{StaticResource CardBgBrush}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Window>
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace RJ2RoomConSimulator
|
||||
{
|
||||
// VSP(테스트 모드) 프리셋 조회/설정 팝업.
|
||||
// select 코드(펌웨어 My_RJ2.c) : 1~4 환기1~4 / 5 바이패스 / 6~9 공청1~4 (공청은 EA 미사용)
|
||||
public partial class VspWindow : Window
|
||||
{
|
||||
readonly RoomConProtocol _proto;
|
||||
|
||||
// 프리셋 행 정의 : (select, 라벨, EA 사용여부)
|
||||
static readonly (byte sel, string label, bool hasEa)[] Defs =
|
||||
{
|
||||
(1,"환기 1단",true),(2,"환기 2단",true),(3,"환기 3단",true),(4,"환기 4단",true),
|
||||
(5,"바이패스",true),
|
||||
(6,"공청 1단",false),(7,"공청 2단",false),(8,"공청 3단",false),(9,"공청 4단",false),
|
||||
};
|
||||
readonly TextBox[] _sa = new TextBox[10]; // index = select
|
||||
readonly TextBox[] _ea = new TextBox[10];
|
||||
|
||||
static Brush Br(string h) => (Brush)new BrushConverter().ConvertFromString(h)!;
|
||||
static readonly Brush CardBg = Br("#313147");
|
||||
static readonly Brush Border = Br("#3B3B55");
|
||||
static readonly Brush TextPri = Br("#C0CAF5");
|
||||
|
||||
public VspWindow(RoomConProtocol proto)
|
||||
{
|
||||
InitializeComponent();
|
||||
_proto = proto;
|
||||
BuildRows();
|
||||
_proto.ResponseReceived += OnResponse;
|
||||
Closed += (_, _) => _proto.ResponseReceived -= OnResponse;
|
||||
}
|
||||
|
||||
void BuildRows()
|
||||
{
|
||||
foreach (var d in Defs)
|
||||
{
|
||||
var g = new Grid { Margin = new Thickness(0, 2, 0, 2) };
|
||||
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(90) });
|
||||
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(70) });
|
||||
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(70) });
|
||||
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(70) });
|
||||
|
||||
var lab = new TextBlock { Text = d.label, FontSize = 12, Foreground = TextPri, VerticalAlignment = VerticalAlignment.Center };
|
||||
Grid.SetColumn(lab, 0); g.Children.Add(lab);
|
||||
|
||||
var sa = MakeBox(); Grid.SetColumn(sa, 1); g.Children.Add(sa); _sa[d.sel] = sa;
|
||||
var ea = MakeBox(); ea.IsEnabled = d.hasEa; Grid.SetColumn(ea, 2); g.Children.Add(ea); _ea[d.sel] = ea;
|
||||
|
||||
byte sel = d.sel;
|
||||
var btn = new Button { Content = "설정", Padding = new Thickness(10, 3, 10, 3), FontSize = 11, Margin = new Thickness(4, 0, 0, 0),
|
||||
Style = (Style)FindResource("ModernButton"), Background = CardBg };
|
||||
btn.Click += (_, _) => SetRow(sel);
|
||||
Grid.SetColumn(btn, 3); g.Children.Add(btn);
|
||||
|
||||
RowsPanel.Children.Add(g);
|
||||
}
|
||||
}
|
||||
|
||||
TextBox MakeBox() => new()
|
||||
{
|
||||
Width = 60, Margin = new Thickness(2, 0, 2, 0), Padding = new Thickness(3, 2, 3, 2),
|
||||
TextAlignment = TextAlignment.Center, FontSize = 12, Text = "0",
|
||||
Background = CardBg, Foreground = TextPri, BorderBrush = Border, BorderThickness = new Thickness(1),
|
||||
};
|
||||
|
||||
void SetRow(byte sel)
|
||||
{
|
||||
byte sa = ParseByte(_sa[sel].Text);
|
||||
byte ea = _ea[sel].IsEnabled ? ParseByte(_ea[sel].Text) : (byte)0;
|
||||
_proto.SendVsp(sel, sa, ea);
|
||||
}
|
||||
static byte ParseByte(string s) { int.TryParse(s, out var v); return (byte)(v < 0 ? 0 : v > 255 ? 255 : v); }
|
||||
|
||||
void QueryVent_Click(object s, RoutedEventArgs e) => _proto.SendRestart1();
|
||||
void QueryBypassAir_Click(object s, RoutedEventArgs e) => _proto.SendRestart2();
|
||||
void Exit_Click(object s, RoutedEventArgs e) => _proto.SendExit();
|
||||
void Close_Click(object s, RoutedEventArgs e) => Close();
|
||||
|
||||
// 조회 응답 수신 → 표 갱신
|
||||
void OnResponse(byte cmd)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (cmd == RoomConProtocol.CMD_RESTART1)
|
||||
{
|
||||
for (int i = 1; i <= 4; i++) { _sa[i].Text = _proto.VentSa[i].ToString(); _ea[i].Text = _proto.VentEa[i].ToString(); }
|
||||
}
|
||||
else if (cmd == RoomConProtocol.CMD_RESTART2)
|
||||
{
|
||||
_sa[5].Text = _proto.BypassSa[1].ToString(); _ea[5].Text = _proto.BypassEa[1].ToString();
|
||||
for (int i = 1; i <= 4; i++) { _sa[5 + i].Text = _proto.AirSa[i].ToString(); _ea[5 + i].Text = "0"; }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# RJ2-232 Simulator 사양서
|
||||
C# WPF (스타일: HoodSimulator 동일 — Tokyo Night 다크 테마)
|
||||
최종 수정 : 2026.06.07
|
||||
|
||||
## UI 기능
|
||||
통신 설정
|
||||
전원 ON / OFF (on 시 환기모드, 풍량 1단, off 시 풍량 0)
|
||||
모드 환기 / 자동 / 공기청정 / 바이패스 (환기, 자동, 공기청정 0 ~ 4단, 바이패스 1단, 자동은 풍량 변경 안됨)
|
||||
풍량 0 / 1 / 2 / 3 / 4
|
||||
예약 0 ~ 8 시간 (0은 예약 취소, 1시간 단위, 모드 전환 시 예약 해제)
|
||||
id 설정
|
||||
vsp 설정
|
||||
|
||||
로그 저장 / 지우기
|
||||
|
||||
---
|
||||
|
||||
## 구현 내용 (2026.06.07)
|
||||
|
||||
### 역할 / 통신
|
||||
- **룸콘(마스터) 시뮬레이터** — ERVSimulator(ERV, 응답측)와 RS-232로 연동.
|
||||
- 규격 : `Protocol/ROOMCON/힘펠_환기장치프로토콜V3.7` (펌웨어 My_RJ2.c / ERVSimulator RoomConProtocol.cs)
|
||||
- 포트 : **RS-232, 9600 8N1**
|
||||
- 프레임 : `AA | CMD | D[2..12] | XOR(byte0~12) | EE` (15바이트 고정)
|
||||
- COM 페어(가상 널모뎀 또는 어댑터 2개)로 RJ2 ↔ ERVSimulator RoomCon 포트(9600) 연결.
|
||||
|
||||
### CMD (룸콘 → ERV)
|
||||
| CMD | 이름 | 내용 |
|
||||
|---|---|---|
|
||||
| 0x00 | NORMAL | 상태 폴링(폴링 시작 시 주기 송신) → ERV가 0x07 응답 |
|
||||
| 0x01 | EVENT | 모드/풍량/예약 변경 → ERV가 0x01 응답(모드·풍량·에러·온도) |
|
||||
| 0x02 | RESTART1 | 환기 1~4단 preset 조회 → ERV가 0x02 응답 |
|
||||
| 0x12 | RESTART2 | 바이패스/공청 preset 조회 → ERV가 0x12 응답 |
|
||||
| 0x03 | VSP | 테스트모드 프리셋 설정(select/sa/ea) |
|
||||
| 0x04 | EXIT | 테스트모드 종료 |
|
||||
|
||||
### 모드 코드 (EVENT byte[2]) — ERVSimulator RunMode enum 일치
|
||||
0 = 환기 / 1 = 자동 / 2 = 바이패스 / 3 = 공기청정
|
||||
- 전원 OFF = EVENT 모드 0(환기) + 풍량 0 으로 표현(ERV가 Power OFF 처리)
|
||||
|
||||
### EVENT 패킷 데이터
|
||||
| byte | 내용 |
|
||||
|---|---|
|
||||
| 2 | 운전모드 |
|
||||
| 3 | 풍량(0~4) |
|
||||
| 5 | Heater/UV (미사용 0) |
|
||||
| 10 | 예약 flag (항상 1로 전송) |
|
||||
| 11 | 예약 시간(시, 0=해제) |
|
||||
| 12 | 예약 분(0) |
|
||||
|
||||
### 예약 연동 (룸콘 → ERV → 대시보드)
|
||||
- 예약(시)을 EVENT `[10]=1, [11]=시`로 항상 전송 → **설정·해제(0) 모두 전달**.
|
||||
- ERVSimulator가 수신하여 `ReserveHours / ReserveRemainSec` 반영 → ERVSim UI 표시 + 1초 카운트다운(0 도달 시 전원 OFF).
|
||||
- ERVSim의 HOMENET STATUS(reserve_remain)에 실려 **PCDashBoard / WebDashBoard에도 그대로 반영**.
|
||||
- 모드 전환 시 예약 해제(`[11]=0`) 전송 → ERV·대시보드 함께 해제.
|
||||
|
||||
### VSP 설정 팝업 (테스트 모드)
|
||||
- select 코드(펌웨어 My_RJ2.c) : 1~4 환기1~4 / 5 바이패스 / 6~9 공청1~4 (공청은 EA 미사용)
|
||||
- 환기 조회(RESTART1) / 바이패스·공청 조회(RESTART2)로 현재 preset 읽어 표 갱신
|
||||
- 행별 SA/EA 수정 후 '설정' → 해당 select VSP(0x03) 송신, '테스트 종료' → EXIT(0x04)
|
||||
|
||||
### ID 설정
|
||||
- RS-232 점대점이라 프레임에 ID 필드 없음 → UI 입력값은 표시/로그용(전송 안 함).
|
||||
|
||||
### ERV 응답 표시
|
||||
- 운전모드 / 풍량 / 에러코드 / 실내온도 / 외기온도 (0x01·0x07 응답 파싱), 수신 카운트.
|
||||
|
||||
### 알람 / 에러 표시 (ERV ErrorCode 비트 디코드)
|
||||
ERV 응답의 ErrorCode 바이트(응답 `[7]`)를 비트별로 칩 표시. NORMAL(0x07)/EVENT(0x01) 응답마다 갱신.
|
||||
- **알람**(노랑) : 필터청소(0x01) / 필터교환(0x02)
|
||||
- **에러**(빨강, COLD=시안) : E02 온도센서(0x08) / E09 급기팬(0x80) / E10 배기팬(0x20) / COLD 장비보호(0x10) / E07 내부통신(0x40)
|
||||
- 알람/에러 소스는 ERVSimulator(에러 카드 + 필터청소/교환 토글) → 같은 ErrorCode가 대시보드(STATUS)에도 반영됨.
|
||||
|
||||
| 비트 | 항목 | 구분 |
|
||||
|---|---|---|
|
||||
| 0x01 | 필터청소 | 알람 |
|
||||
| 0x02 | 필터교환 | 알람 |
|
||||
| 0x08 | E02 온도센서 | 에러 |
|
||||
| 0x10 | COLD 장비보호 | 에러 |
|
||||
| 0x20 | E10 배기(EA)팬 | 에러 |
|
||||
| 0x40 | E07 내부통신 | 에러 |
|
||||
| 0x80 | E09 급기(SA)팬 | 에러 |
|
||||
|
||||
### 참고
|
||||
- 본 시뮬레이터는 프로토콜대로 송신하며, ERVSimulator는 모드/풍량/예약/preset 조회를 처리(VSP 설정값 적용은 로그만).
|
||||
Reference in New Issue
Block a user