chore: HERV 통합 저장소 초기 커밋
- 펌웨어(program), C# 대시보드(TestProgram), 시뮬레이터(Simulator), 프로토콜/문서(Protocol, doc) 전체를 단일 저장소로 통합 - program 폴더의 별도 git 저장소를 제거하고 통합 저장소에 흡수 - 빌드 산출물(program/build, bin/obj, *.o/.elf/.bin/.hex 등) .gitignore 처리 - 사내 Synology NAS Git 원격 연결 예정 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,419 @@
|
||||
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;
|
||||
|
||||
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);
|
||||
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));
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user