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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jeon
2026-06-16 09:29:03 +09:00
commit a502322188
630 changed files with 65126 additions and 0 deletions
+112
View File
@@ -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>
+8
View File
@@ -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>
+111
View File
@@ -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 = "연결";
}
});
}
}
}
+189
View File
@@ -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);
}
}
}