feat: 06-17 신규 작업본 반영 (개발사양서/기능검토/승인원/Source 등 추가)
.claude/ 제외(.gitignore 추가). 기존 초기커밋(5a96a69) 위에 신규·수정·이동분 커밋.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<Application x:Class="CvnetPacketProgram.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace CvnetPacketProgram;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<AssemblyName>CvnetPacketProgram</AssemblyName>
|
||||
<RootNamespace>CvnetPacketProgram</RootNamespace>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<!-- 단일 exe 배포 옵션 (publish 시 적용) -->
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.IO.Ports" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace CvnetPacketProgram;
|
||||
|
||||
/// <summary>
|
||||
/// 수신 바이트 스트림에서 0xF7 0x32 로 시작하는 완전한 프레임을 추출한다.
|
||||
/// 프레임 길이 = 5(Header~Len) + Len + 2(XOR,ADD).
|
||||
/// </summary>
|
||||
public sealed class FrameParser
|
||||
{
|
||||
private readonly List<byte> _buf = new();
|
||||
|
||||
public void Append(byte[] data, int len)
|
||||
{
|
||||
for (int i = 0; i < len; i++) _buf.Add(data[i]);
|
||||
}
|
||||
|
||||
/// <summary>버퍼에서 추출 가능한 모든 완전 프레임을 반환한다.</summary>
|
||||
public List<byte[]> Extract()
|
||||
{
|
||||
var result = new List<byte[]>();
|
||||
|
||||
while (true)
|
||||
{
|
||||
// Header(0xF7) + Device(0x32) 동기화
|
||||
int sync = FindSync();
|
||||
if (sync < 0)
|
||||
{
|
||||
// 동기 패턴 없음 — 마지막 1바이트(0xF7 가능성)만 남기고 버림
|
||||
if (_buf.Count > 1) _buf.RemoveRange(0, _buf.Count - 1);
|
||||
break;
|
||||
}
|
||||
if (sync > 0) _buf.RemoveRange(0, sync); // 앞쪽 쓰레기 제거
|
||||
|
||||
if (_buf.Count < 5) break; // Len 까지 못 받음
|
||||
int len = _buf[4];
|
||||
int frameLen = 5 + len + 2;
|
||||
if (_buf.Count < frameLen) break; // 프레임 미완성
|
||||
|
||||
var frame = _buf.GetRange(0, frameLen).ToArray();
|
||||
_buf.RemoveRange(0, frameLen);
|
||||
result.Add(frame);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private int FindSync()
|
||||
{
|
||||
for (int i = 0; i + 1 < _buf.Count; i++)
|
||||
if (_buf[i] == Cvnet.Header && _buf[i + 1] == Cvnet.Device)
|
||||
return i;
|
||||
// 마지막 바이트가 Header 면 다음 바이트 대기
|
||||
if (_buf.Count > 0 && _buf[^1] == Cvnet.Header) return _buf.Count - 1;
|
||||
return -1;
|
||||
}
|
||||
|
||||
public void Clear() => _buf.Clear();
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<Window x:Class="CvnetPacketProgram.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="CVNET DL 사양 패킷 통신프로그램 (프로토콜 230824 기준)"
|
||||
Height="860" Width="1320" MinHeight="600" MinWidth="1000"
|
||||
FontFamily="Segoe UI" FontSize="13" Background="#F4F5F7">
|
||||
<Window.Resources>
|
||||
<Style TargetType="GroupBox">
|
||||
<Setter Property="Margin" Value="0,0,0,8"/>
|
||||
<Setter Property="Padding" Value="8"/>
|
||||
</Style>
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="Padding" Value="10,4"/>
|
||||
<Setter Property="Margin" Value="0,0,6,0"/>
|
||||
<Setter Property="MinWidth" Value="72"/>
|
||||
</Style>
|
||||
<Style x:Key="Mono" TargetType="TextBox">
|
||||
<Setter Property="FontFamily" Value="Consolas"/>
|
||||
<Setter Property="FontSize" Value="12.5"/>
|
||||
<Setter Property="IsReadOnly" Value="True"/>
|
||||
<Setter Property="VerticalScrollBarVisibility" Value="Auto"/>
|
||||
<Setter Property="HorizontalScrollBarVisibility" Value="Auto"/>
|
||||
<Setter Property="TextWrapping" Value="NoWrap"/>
|
||||
<Setter Property="Background" Value="#1E1E1E"/>
|
||||
<Setter Property="Foreground" Value="#DDE6EE"/>
|
||||
<Setter Property="Padding" Value="6"/>
|
||||
</Style>
|
||||
<Style x:Key="Lbl" TargetType="TextBlock">
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="Margin" Value="0,0,6,0"/>
|
||||
<Setter Property="MinWidth" Value="78"/>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<DockPanel Margin="8">
|
||||
|
||||
<!-- ===== 상단 연결 바 ===== -->
|
||||
<Border DockPanel.Dock="Top" Background="White" BorderBrush="#D0D5DD" BorderThickness="1"
|
||||
CornerRadius="6" Padding="10,8" Margin="0,0,0,8">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="COM" Style="{StaticResource Lbl}" MinWidth="34"/>
|
||||
<ComboBox x:Name="CmbPort" Width="100" VerticalAlignment="Center"/>
|
||||
<Button Content="↻" Click="OnRefreshPorts" Margin="4,0,12,0" MinWidth="30"/>
|
||||
<TextBlock Text="Baud" Style="{StaticResource Lbl}" MinWidth="40"/>
|
||||
<ComboBox x:Name="CmbBaud" Width="90" VerticalAlignment="Center"/>
|
||||
<Button x:Name="BtnOpen" Content="열기" Click="OnToggleOpen" Margin="12,0,6,0" MinWidth="80"/>
|
||||
<Ellipse x:Name="LedConn" Width="14" Height="14" Fill="#C0392B" VerticalAlignment="Center" Margin="6,0"/>
|
||||
<TextBlock x:Name="TxtConn" Text="닫힘" VerticalAlignment="Center" Foreground="#475467"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="410"/>
|
||||
<ColumnDefinition Width="6"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- ===== 좌측 : 패킷 빌더 ===== -->
|
||||
<Border Grid.Column="0" Background="White" BorderBrush="#D0D5DD" BorderThickness="1" CornerRadius="6">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="10">
|
||||
<StackPanel>
|
||||
<TextBlock Text="패킷 빌더" FontWeight="Bold" FontSize="15" Margin="0,0,0,8"/>
|
||||
|
||||
<Grid Margin="0,0,0,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="Cmd" Style="{StaticResource Lbl}"/>
|
||||
<ComboBox x:Name="CmbCmd" Grid.Column="1" SelectionChanged="OnCmdChanged"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,0,0,4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="Sub ID(hex)" Style="{StaticResource Lbl}"/>
|
||||
<TextBox x:Name="TxtSubId" Grid.Column="1" Text="01" MaxLength="2"/>
|
||||
</Grid>
|
||||
|
||||
<!-- 모드 / 풍량 / 예약 (제어·응답 공통) -->
|
||||
<GroupBox x:Name="GrpFields" Header="필드">
|
||||
<StackPanel>
|
||||
<Grid Margin="0,0,0,5">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="모드" Style="{StaticResource Lbl}"/>
|
||||
<ComboBox x:Name="CmbMode" Grid.Column="1"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,0,0,5">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="풍량" Style="{StaticResource Lbl}"/>
|
||||
<ComboBox x:Name="CmbFan" Grid.Column="1"/>
|
||||
</Grid>
|
||||
|
||||
<WrapPanel Margin="0,2,0,4">
|
||||
<CheckBox x:Name="ChkBasic" Content="기저모드" Margin="0,2,12,2"/>
|
||||
<CheckBox x:Name="ChkRange" Content="렌지연동" Margin="0,2,12,2"/>
|
||||
</WrapPanel>
|
||||
|
||||
<!-- 제어 요구(0x51) 전용 Flag -->
|
||||
<StackPanel x:Name="PnlCtrlFlags">
|
||||
<TextBlock Text="제어 Flag (요청 항목만 ON)" FontWeight="SemiBold" Margin="0,4,0,2"/>
|
||||
<WrapPanel>
|
||||
<CheckBox x:Name="ChkModeFlag" Content="모드Flag" IsChecked="True" Margin="0,2,12,2"/>
|
||||
<CheckBox x:Name="ChkFanFlag" Content="풍량Flag" IsChecked="True" Margin="0,2,12,2"/>
|
||||
<CheckBox x:Name="ChkRsvFlag" Content="예약Flag" Margin="0,2,12,2"/>
|
||||
<CheckBox x:Name="ChkFilterReset" Content="필터타이머리셋" Margin="0,2,12,2"/>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Grid Margin="0,4,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="예약시간(0~24)" Style="{StaticResource Lbl}" MinWidth="100"/>
|
||||
<TextBox x:Name="TxtReserve" Grid.Column="1" Text="0"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<TextBlock Text="미리보기 (HEX)" FontWeight="SemiBold" Margin="0,4,0,2"/>
|
||||
<TextBox x:Name="TxtPreview" Style="{StaticResource Mono}" Height="48"/>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
|
||||
<Button Content="빌드" Click="OnBuild"/>
|
||||
<Button x:Name="BtnSend" Content="전송" Click="OnSendBuilt" Background="#1D6FE0" Foreground="White"/>
|
||||
</StackPanel>
|
||||
|
||||
<Separator Margin="0,12"/>
|
||||
|
||||
<TextBlock Text="직접 HEX 전송" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<TextBox x:Name="TxtRawHex" Height="44" FontFamily="Consolas"
|
||||
TextWrapping="Wrap" AcceptsReturn="True"
|
||||
Text="F7 32 01 11 00"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
|
||||
<CheckBox x:Name="ChkAutoSum" Content="XOR/ADD 자동추가" IsChecked="True" VerticalAlignment="Center" Margin="0,0,10,0"/>
|
||||
<Button Content="HEX 전송" Click="OnSendRaw"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<GridSplitter Grid.Column="1" Width="6" HorizontalAlignment="Stretch" Background="Transparent"/>
|
||||
|
||||
<!-- ===== 우측 : 송신/수신 로그 ===== -->
|
||||
<Grid Grid.Column="2">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="6"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- 송신 로그 -->
|
||||
<Border Grid.Row="0" Background="White" BorderBrush="#D0D5DD" BorderThickness="1" CornerRadius="6">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Top" Background="#0B3D91" CornerRadius="6,6,0,0" Padding="10,6">
|
||||
<DockPanel>
|
||||
<TextBlock Text="▶ 송신 (TX)" Foreground="White" FontWeight="Bold" FontSize="14"/>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" DockPanel.Dock="Right">
|
||||
<CheckBox x:Name="ChkTxAutoScroll" Content="자동스크롤" IsChecked="True" Foreground="White" VerticalAlignment="Center" Margin="0,0,10,0"/>
|
||||
<Button Content="지우기" Click="OnClearTx" MinWidth="60"/>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<TextBox x:Name="TxtTxLog" Style="{StaticResource Mono}" Background="#10243F" BorderThickness="0"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<GridSplitter Grid.Row="1" Height="6" HorizontalAlignment="Stretch" Background="Transparent"/>
|
||||
|
||||
<!-- 수신 로그 -->
|
||||
<Border Grid.Row="2" Background="White" BorderBrush="#D0D5DD" BorderThickness="1" CornerRadius="6">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Top" Background="#0A6B3B" CornerRadius="6,6,0,0" Padding="10,6">
|
||||
<DockPanel>
|
||||
<TextBlock Text="◀ 수신 (RX)" Foreground="White" FontWeight="Bold" FontSize="14"/>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" DockPanel.Dock="Right">
|
||||
<CheckBox x:Name="ChkRxAutoScroll" Content="자동스크롤" IsChecked="True" Foreground="White" VerticalAlignment="Center" Margin="0,0,10,0"/>
|
||||
<Button Content="지우기" Click="OnClearRx" MinWidth="60"/>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<TextBox x:Name="TxtRxLog" Style="{StaticResource Mono}" Background="#0E2418" BorderThickness="0"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,286 @@
|
||||
using System.IO.Ports;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace CvnetPacketProgram;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private SerialPort? _port;
|
||||
private readonly FrameParser _parser = new();
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitUi();
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 초기화
|
||||
// ====================================================================
|
||||
private void InitUi()
|
||||
{
|
||||
foreach (var b in new[] { 9600, 19200, 38400, 57600, 115200, 4800, 2400 })
|
||||
CmbBaud.Items.Add(b);
|
||||
CmbBaud.SelectedIndex = 0; // 9600 기본
|
||||
|
||||
// 송신은 마스터(월패드) 측 요청만 — 응답(0x91/0xD1)은 ERV가 보내며 RX 로그에서 디코딩한다.
|
||||
CmbCmd.Items.Add(new CmdItem(Cvnet.CmdStatusQuery, "상태 조회 (0x11)"));
|
||||
CmbCmd.Items.Add(new CmdItem(Cvnet.CmdCtrlReq, "상세 제어 요구 (0x51)"));
|
||||
|
||||
foreach (var m in Cvnet.Modes) CmbMode.Items.Add(new ByteItem(m.val, $"0x{m.val:X2} {m.name}"));
|
||||
foreach (var fobj in Cvnet.Fans) CmbFan.Items.Add(new ByteItem(fobj.val, $"0x{fobj.val:X2} {fobj.name}"));
|
||||
CmbMode.SelectedIndex = 2; // 수동 일반
|
||||
CmbFan.SelectedIndex = 1; // 약
|
||||
|
||||
RefreshPorts();
|
||||
CmbCmd.SelectedIndex = 1; // 상세 제어 요구 기본
|
||||
}
|
||||
|
||||
private void RefreshPorts()
|
||||
{
|
||||
string? cur = CmbPort.SelectedItem as string;
|
||||
CmbPort.Items.Clear();
|
||||
foreach (var p in SerialPort.GetPortNames().OrderBy(NaturalKey))
|
||||
CmbPort.Items.Add(p);
|
||||
if (cur != null && CmbPort.Items.Contains(cur)) CmbPort.SelectedItem = cur;
|
||||
else if (CmbPort.Items.Count > 0) CmbPort.SelectedIndex = 0;
|
||||
}
|
||||
|
||||
private static int NaturalKey(string s)
|
||||
=> int.TryParse(new string(s.Where(char.IsDigit).ToArray()), out int n) ? n : 0;
|
||||
|
||||
private void OnRefreshPorts(object sender, RoutedEventArgs e) => RefreshPorts();
|
||||
|
||||
// ====================================================================
|
||||
// 연결
|
||||
// ====================================================================
|
||||
private void OnToggleOpen(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_port is { IsOpen: true }) { ClosePort(); return; }
|
||||
|
||||
if (CmbPort.SelectedItem is not string portName)
|
||||
{
|
||||
MessageBox.Show("COM 포트를 선택하세요.", "포트", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
_port = new SerialPort(portName, (int)CmbBaud.SelectedItem!, Parity.None, 8, StopBits.One)
|
||||
{
|
||||
ReadTimeout = 500,
|
||||
WriteTimeout = 500,
|
||||
};
|
||||
_port.DataReceived += OnDataReceived;
|
||||
_parser.Clear();
|
||||
_port.Open();
|
||||
SetConnUi(true, portName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_port = null;
|
||||
MessageBox.Show($"포트 열기 실패:\n{ex.Message}", "오류", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClosePort()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_port != null)
|
||||
{
|
||||
_port.DataReceived -= OnDataReceived;
|
||||
if (_port.IsOpen) _port.Close();
|
||||
_port.Dispose();
|
||||
}
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
_port = null;
|
||||
SetConnUi(false, null);
|
||||
}
|
||||
|
||||
private void SetConnUi(bool open, string? port)
|
||||
{
|
||||
LedConn.Fill = open
|
||||
? System.Windows.Media.Brushes.LimeGreen
|
||||
: new System.Windows.Media.SolidColorBrush(
|
||||
System.Windows.Media.Color.FromRgb(0xC0, 0x39, 0x2B));
|
||||
TxtConn.Text = open ? $"{port} 연결됨" : "닫힘";
|
||||
BtnOpen.Content = open ? "닫기" : "열기";
|
||||
CmbPort.IsEnabled = !open;
|
||||
CmbBaud.IsEnabled = !open;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 수신
|
||||
// ====================================================================
|
||||
private void OnDataReceived(object sender, SerialDataReceivedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sp = _port;
|
||||
if (sp is not { IsOpen: true }) return;
|
||||
int n = sp.BytesToRead;
|
||||
if (n <= 0) return;
|
||||
var buf = new byte[n];
|
||||
int read = sp.Read(buf, 0, n);
|
||||
_parser.Append(buf, read);
|
||||
|
||||
var frames = _parser.Extract();
|
||||
if (frames.Count == 0) return;
|
||||
|
||||
Dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
foreach (var f in frames)
|
||||
AppendLog(TxtRxLog, ChkRxAutoScroll, "RX", f, Cvnet.Decode(f));
|
||||
});
|
||||
}
|
||||
catch { /* 수신 중 포트 닫힘 등 무시 */ }
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Cmd 변경 → 입력 패널 토글
|
||||
// ====================================================================
|
||||
private void OnCmdChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (CmbCmd.SelectedItem is not CmdItem ci) return;
|
||||
bool isQuery = ci.Cmd == Cvnet.CmdStatusQuery;
|
||||
bool isCtrl = ci.Cmd == Cvnet.CmdCtrlReq;
|
||||
|
||||
if (GrpFields != null) GrpFields.Visibility = isQuery ? Visibility.Collapsed : Visibility.Visible;
|
||||
if (PnlCtrlFlags != null) PnlCtrlFlags.Visibility = isCtrl ? Visibility.Visible : Visibility.Collapsed;
|
||||
OnBuild(sender, e);
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 빌드 / 전송
|
||||
// ====================================================================
|
||||
private byte[]? BuildPacket()
|
||||
{
|
||||
if (CmbCmd.SelectedItem is not CmdItem ci) return null;
|
||||
byte subId = ParseHexByte(TxtSubId.Text, 0x01);
|
||||
|
||||
byte mode = (CmbMode.SelectedItem as ByteItem)?.Val ?? 0x00;
|
||||
byte fan = (CmbFan.SelectedItem as ByteItem)?.Val ?? 0x00;
|
||||
byte reserve = ParseDecByte(TxtReserve.Text, 0);
|
||||
|
||||
switch (ci.Cmd)
|
||||
{
|
||||
case Cvnet.CmdStatusQuery:
|
||||
return Cvnet.BuildStatusQuery(subId);
|
||||
|
||||
case Cvnet.CmdCtrlReq:
|
||||
return Cvnet.BuildCtrlReq(
|
||||
subId,
|
||||
mode, ChkModeFlag.IsChecked == true, ChkBasic.IsChecked == true, ChkRange.IsChecked == true,
|
||||
fan, ChkFanFlag.IsChecked == true, ChkFilterReset.IsChecked == true,
|
||||
reserve, ChkRsvFlag.IsChecked == true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void OnBuild(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var pkt = BuildPacket();
|
||||
if (pkt != null) TxtPreview.Text = Cvnet.Hex(pkt);
|
||||
}
|
||||
|
||||
private void OnSendBuilt(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var pkt = BuildPacket();
|
||||
if (pkt == null) return;
|
||||
TxtPreview.Text = Cvnet.Hex(pkt);
|
||||
SendBytes(pkt);
|
||||
}
|
||||
|
||||
private void OnSendRaw(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var bytes = ParseHexString(TxtRawHex.Text);
|
||||
if (bytes.Count == 0)
|
||||
{
|
||||
MessageBox.Show("유효한 HEX 바이트가 없습니다.", "HEX", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
if (ChkAutoSum.IsChecked == true)
|
||||
bytes = Cvnet.Finalize(bytes).ToList();
|
||||
SendBytes(bytes.ToArray());
|
||||
}
|
||||
|
||||
private void SendBytes(byte[] pkt)
|
||||
{
|
||||
if (_port is not { IsOpen: true })
|
||||
{
|
||||
// 포트 미연결이어도 로그에는 남겨 패킷을 확인할 수 있게 한다.
|
||||
AppendLog(TxtTxLog, ChkTxAutoScroll, "TX(미연결)", pkt, Cvnet.Decode(pkt));
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
_port.Write(pkt, 0, pkt.Length);
|
||||
AppendLog(TxtTxLog, ChkTxAutoScroll, "TX", pkt, Cvnet.Decode(pkt));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"전송 실패:\n{ex.Message}", "오류", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 로그
|
||||
// ====================================================================
|
||||
private static void AppendLog(TextBox box, CheckBox autoScroll, string tag, byte[] frame, string decode)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var sb = new StringBuilder();
|
||||
sb.Append('[').Append(now.ToString("HH:mm:ss.fff")).Append("] ")
|
||||
.Append(tag).Append(" (").Append(frame.Length).Append("B)\n");
|
||||
sb.Append(" HEX: ").Append(Cvnet.Hex(frame)).Append('\n');
|
||||
sb.Append(IndentLines(decode)).Append("\n\n");
|
||||
|
||||
box.AppendText(sb.ToString());
|
||||
if (autoScroll.IsChecked == true) box.ScrollToEnd();
|
||||
}
|
||||
|
||||
private static string IndentLines(string s)
|
||||
{
|
||||
var lines = s.Replace("\r", "").Split('\n');
|
||||
return string.Join('\n', lines.Select(l => " " + l));
|
||||
}
|
||||
|
||||
private void OnClearTx(object sender, RoutedEventArgs e) => TxtTxLog.Clear();
|
||||
private void OnClearRx(object sender, RoutedEventArgs e) => TxtRxLog.Clear();
|
||||
|
||||
// ====================================================================
|
||||
// 파서 유틸
|
||||
// ====================================================================
|
||||
private static byte ParseHexByte(string s, byte def)
|
||||
=> byte.TryParse(s?.Trim().Replace("0x", "", StringComparison.OrdinalIgnoreCase),
|
||||
System.Globalization.NumberStyles.HexNumber, null, out var v) ? v : def;
|
||||
|
||||
private static byte ParseDecByte(string s, byte def)
|
||||
=> byte.TryParse(s?.Trim(), out var v) ? v : def;
|
||||
|
||||
private static List<byte> ParseHexString(string s)
|
||||
{
|
||||
var list = new List<byte>();
|
||||
if (string.IsNullOrWhiteSpace(s)) return list;
|
||||
var tokens = s.Replace("0x", " ", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace(",", " ").Replace("\r", " ").Replace("\n", " ").Replace("\t", " ")
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var t in tokens)
|
||||
if (byte.TryParse(t, System.Globalization.NumberStyles.HexNumber, null, out var v))
|
||||
list.Add(v);
|
||||
return list;
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
ClosePort();
|
||||
base.OnClosed(e);
|
||||
}
|
||||
|
||||
// 콤보 항목 ----------------------------------------------------------
|
||||
private sealed record CmdItem(byte Cmd, string Text) { public override string ToString() => Text; }
|
||||
private sealed record ByteItem(byte Val, string Text) { public override string ToString() => Text; }
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using System.Text;
|
||||
|
||||
namespace CvnetPacketProgram;
|
||||
|
||||
// ============================================================================
|
||||
// 대림 환기 프로토콜 (20230824 시트) — 구현은 해당 시트 내용만 근거로 함.
|
||||
//
|
||||
// 공통 프레임 구조
|
||||
// Header(0xF7) | Device(0x32) | Sub ID | Cmd | Len | Data[Len] | XOR SUM | ADD SUM
|
||||
//
|
||||
// 비트 표기: 시트의 "BIT 8 7 6 5 4 3 2 1" 기준 (bit8 = 0x80(MSB), bit1 = 0x01(LSB))
|
||||
//
|
||||
// 체크섬 (시트: "기존 대림 3.0 기준" — 표준 대림 3.0 방식)
|
||||
// XOR SUM = Header ~ 마지막 Data 까지 전체 XOR
|
||||
// ADD SUM = (Header ~ XOR SUM 까지 전체 합) & 0xFF
|
||||
// ============================================================================
|
||||
public static class Cvnet
|
||||
{
|
||||
public const byte Header = 0xF7;
|
||||
public const byte Device = 0x32;
|
||||
|
||||
// Cmd (시트)
|
||||
public const byte CmdStatusQuery = 0x11; // 상태 조회 (요청) Len 0
|
||||
public const byte CmdStatusResp = 0x91; // 상태 조회 응답 Len 6
|
||||
public const byte CmdCtrlReq = 0x51; // 상세 제어 요구 (요청) Len 3
|
||||
public const byte CmdCtrlResp = 0xD1; // 상세 제어 요구 응답 Len 6
|
||||
|
||||
// 모드 상태 (Data 하위 4bit)
|
||||
public static readonly (byte val, string name)[] Modes =
|
||||
{
|
||||
(0x00, "정지(꺼짐)"),
|
||||
(0x01, "자동 - Matrix(환기)"),
|
||||
(0x02, "수동 일반(환기)"),
|
||||
(0x03, "스케쥴(사용안함)"),
|
||||
(0x04, "바이패스"),
|
||||
(0x05, "공기청정"),
|
||||
(0x06, "히터운전(자동포함)"),
|
||||
(0x0A, "자동 - Matrix(공기청정)"),
|
||||
};
|
||||
|
||||
// 풍량 정도 (bit5~7, 3bit)
|
||||
public static readonly (byte val, string name)[] Fans =
|
||||
{
|
||||
(0x00, "꺼짐"),
|
||||
(0x01, "약"),
|
||||
(0x02, "중"),
|
||||
(0x03, "강"),
|
||||
};
|
||||
|
||||
public static string ModeName(int v) => Lookup(Modes, (byte)(v & 0x0F));
|
||||
public static string FanName(int v) => Lookup(Fans, (byte)(v & 0x07));
|
||||
|
||||
private static string Lookup((byte val, string name)[] table, byte v)
|
||||
{
|
||||
foreach (var t in table) if (t.val == v) return t.name;
|
||||
return $"미정의(0x{v:X2})";
|
||||
}
|
||||
|
||||
public static string CmdName(byte cmd) => cmd switch
|
||||
{
|
||||
CmdStatusQuery => "상태 조회",
|
||||
CmdStatusResp => "상태 조회 응답",
|
||||
CmdCtrlReq => "상세 제어 요구",
|
||||
CmdCtrlResp => "상세 제어 요구 응답",
|
||||
_ => $"미정의 Cmd(0x{cmd:X2})",
|
||||
};
|
||||
|
||||
// ---- 체크섬 ----------------------------------------------------------
|
||||
public static byte Xor(IReadOnlyList<byte> bytes, int start, int count)
|
||||
{
|
||||
byte x = 0;
|
||||
for (int i = start; i < start + count; i++) x ^= bytes[i];
|
||||
return x;
|
||||
}
|
||||
|
||||
public static byte Add(IReadOnlyList<byte> bytes, int start, int count)
|
||||
{
|
||||
int s = 0;
|
||||
for (int i = start; i < start + count; i++) s += bytes[i];
|
||||
return (byte)(s & 0xFF);
|
||||
}
|
||||
|
||||
/// <summary>Header~Data 까지 채워진 프레임에 XOR/ADD 2바이트를 덧붙여 완성한다.</summary>
|
||||
public static byte[] Finalize(List<byte> body)
|
||||
{
|
||||
byte xor = Xor(body, 0, body.Count);
|
||||
body.Add(xor);
|
||||
byte add = Add(body, 0, body.Count); // XOR 포함 합
|
||||
body.Add(add);
|
||||
return body.ToArray();
|
||||
}
|
||||
|
||||
public static string Hex(IReadOnlyList<byte> b)
|
||||
{
|
||||
var sb = new StringBuilder(b.Count * 3);
|
||||
for (int i = 0; i < b.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(' ');
|
||||
sb.Append(b[i].ToString("X2"));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 프레임 빌더
|
||||
// ====================================================================
|
||||
|
||||
/// <summary>상태 조회 (0x11), Len 0</summary>
|
||||
public static byte[] BuildStatusQuery(byte subId)
|
||||
{
|
||||
var body = new List<byte> { Header, Device, subId, CmdStatusQuery, 0x00 };
|
||||
return Finalize(body);
|
||||
}
|
||||
|
||||
/// <summary>상세 제어 요구 (0x51), Len 3</summary>
|
||||
public static byte[] BuildCtrlReq(
|
||||
byte subId,
|
||||
byte mode, bool modeFlag, bool basicMode, bool rangeLink,
|
||||
byte fan, bool fanFlag, bool filterTimerReset,
|
||||
byte reserveHour, bool reserveFlag)
|
||||
{
|
||||
// Data0: bit8 기저모드, bit7 렌지연동, bit5 모드Flag, bit1~4 모드상태
|
||||
byte d0 = (byte)(mode & 0x0F);
|
||||
if (modeFlag) d0 |= 0x10;
|
||||
if (rangeLink) d0 |= 0x40;
|
||||
if (basicMode) d0 |= 0x80;
|
||||
|
||||
// Data1: bit8 풍량Flag, bit5~7 풍량정도, bit1 필터타이머리셋
|
||||
byte d1 = (byte)((fan & 0x07) << 4);
|
||||
if (filterTimerReset) d1 |= 0x01;
|
||||
if (fanFlag) d1 |= 0x80;
|
||||
|
||||
// Data2: bit6 예약Flag, bit1~5 꺼짐예약 설정시간(0x1F=예약끄기/연속)
|
||||
byte d2 = (byte)(reserveHour & 0x1F);
|
||||
if (reserveFlag) d2 |= 0x20;
|
||||
|
||||
var body = new List<byte> { Header, Device, subId, CmdCtrlReq, 0x03, d0, d1, d2 };
|
||||
return Finalize(body);
|
||||
}
|
||||
|
||||
// 응답(0x91 상태 응답 / 0xD1 제어 응답)은 ERV가 송신하므로 빌더 없음.
|
||||
// 수신 프레임은 아래 Decode()에서 해석한다.
|
||||
|
||||
// ====================================================================
|
||||
// 디코더 — 수신 프레임 해석
|
||||
// ====================================================================
|
||||
public static string Decode(byte[] f)
|
||||
{
|
||||
if (f.Length < 7) return "(길이 부족 — 최소 7바이트)";
|
||||
var sb = new StringBuilder();
|
||||
byte subId = f[2];
|
||||
byte cmd = f[3];
|
||||
byte len = f[4];
|
||||
|
||||
sb.AppendLine($"Header=0x{f[0]:X2} Device=0x{f[1]:X2} Sub ID=0x{subId:X2} Cmd=0x{cmd:X2} ({CmdName(cmd)}) Len={len}");
|
||||
|
||||
int dataStart = 5;
|
||||
int need = 5 + len + 2;
|
||||
bool lenOk = f.Length >= need;
|
||||
if (!lenOk)
|
||||
{
|
||||
sb.AppendLine($" ! Len 기준 필요 길이 {need}B, 실제 {f.Length}B");
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
byte rxXor = f[5 + len];
|
||||
byte rxAdd = f[5 + len + 1];
|
||||
byte calcXor = Xor(f, 0, 5 + len);
|
||||
byte calcAdd = Add(f, 0, 5 + len + 1); // XOR 포함
|
||||
string xorMark = rxXor == calcXor ? "OK" : $"X (계산 0x{calcXor:X2})";
|
||||
string addMark = rxAdd == calcAdd ? "OK" : $"X (계산 0x{calcAdd:X2})";
|
||||
|
||||
switch (cmd)
|
||||
{
|
||||
case CmdStatusQuery:
|
||||
sb.AppendLine(" [상태 조회 요청]");
|
||||
break;
|
||||
|
||||
case CmdCtrlReq when len >= 3:
|
||||
DecodeCtrlReq(sb, f, dataStart);
|
||||
break;
|
||||
|
||||
case CmdStatusResp when len >= 6:
|
||||
case CmdCtrlResp when len >= 6:
|
||||
DecodeResponse(sb, f, dataStart);
|
||||
break;
|
||||
|
||||
default:
|
||||
if (len > 0)
|
||||
sb.AppendLine($" Data: {Hex(f[dataStart..(dataStart + len)])}");
|
||||
break;
|
||||
}
|
||||
|
||||
sb.Append($" XOR=0x{rxXor:X2} [{xorMark}] ADD=0x{rxAdd:X2} [{addMark}]");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void DecodeCtrlReq(StringBuilder sb, byte[] f, int s)
|
||||
{
|
||||
byte d0 = f[s], d1 = f[s + 1], d2 = f[s + 2];
|
||||
sb.AppendLine(" [상세 제어 요구]");
|
||||
sb.AppendLine($" Data0=0x{d0:X2} 모드={ModeName(d0 & 0x0F)}"
|
||||
+ $" 모드Flag={Bit(d0, 0x10)} 렌지연동={Bit(d0, 0x40)} 기저모드={Bit(d0, 0x80)}");
|
||||
sb.AppendLine($" Data1=0x{d1:X2} 풍량={FanName((d1 >> 4) & 0x07)}"
|
||||
+ $" 풍량Flag={Bit(d1, 0x80)} 필터타이머리셋={Bit(d1, 0x01)}");
|
||||
sb.AppendLine($" Data2=0x{d2:X2} 예약설정시간={ReserveSet(d2 & 0x1F)} 예약Flag={Bit(d2, 0x20)}");
|
||||
}
|
||||
|
||||
private static void DecodeResponse(StringBuilder sb, byte[] f, int s)
|
||||
{
|
||||
byte d0 = f[s], d1 = f[s + 1], d2 = f[s + 2], d3 = f[s + 3], d4 = f[s + 4], d5 = f[s + 5];
|
||||
sb.AppendLine($" Data0=0x{d0:X2} [에러] 장비보호={Bit(d0,0x80)} 미세먼지센서={Bit(d0,0x40)} 배기팬={Bit(d0,0x20)} 급기팬={Bit(d0,0x10)} 내부통신(룸콘)={Bit(d0,0x08)} CO2센서={Bit(d0,0x04)} 소자교환={Bit(d0,0x02)} 온/습도센서={Bit(d0,0x01)}");
|
||||
sb.AppendLine($" Data1=0x{d1:X2} 모드={ModeName(d1 & 0x0F)} 기저모드={Bit(d1,0x80)} 렌지연동={Bit(d1,0x40)}");
|
||||
sb.AppendLine($" Data2=0x{d2:X2} 풍량={FanName((d2 >> 4) & 0x07)} 필터청소={Bit(d2,0x02)} 필터교환={Bit(d2,0x01)}");
|
||||
sb.AppendLine($" Data3=0x{d3:X2} 예약설정시간={ReserveSet(d3 & 0x1F)} 예약상태={Bit(d3,0x20)}");
|
||||
sb.AppendLine($" Data4=0x{d4:X2} 남은시간(시)={d4}");
|
||||
sb.AppendLine($" Data5=0x{d5:X2} 남은시간(분)={d5}");
|
||||
}
|
||||
|
||||
private static string Bit(byte v, byte mask) => (v & mask) != 0 ? "ON" : "off";
|
||||
|
||||
private static string ReserveSet(int v) => v switch
|
||||
{
|
||||
0x00 => "0(없음)",
|
||||
0x1F => "예약끄기(연속)",
|
||||
_ => $"{v}시간",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="CvnetPacketProgram.app" />
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
Binary file not shown.
Reference in New Issue
Block a user