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:
2026-06-15 21:44:23 +09:00
commit 5a96a696b1
265 changed files with 76458 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# ── 펌웨어 빌드 산출물 ──
program/build/
*.o
*.d
*.elf
*.bin
*.hex
*.map
*.lst
*.crf
*.axf
*.htm
*.scvd
*.dep
*.bak
*.lnp
*.iex
*.sct
*.__i
# ── C# / .NET 빌드 산출물 ──
[Bb]in/
[Oo]bj/
[Dd]ebug/
[Rr]elease/
*.user
*.suo
.vs/
*_wpftmp.csproj
# ── OS / 에디터 ──
Thumbs.db
desktop.ini
.DS_Store
# ── 로그 / 임시 ──
*.log
*.tmp
+141
View File
@@ -0,0 +1,141 @@
DL 각실제어 시스템 구성
HERV (전열교환기) -(485통신) BUNBAGI -(485통신) 배기(RA) DIFFUSER -(485통신) 급기(SA) DIFFUSER
각실 DIFFUSER 구성
거실 : 배기(RA) DIFFUSER / 급기(SA1) DIFFUSER / 급기(SA2) DIFFUSER
방1 ~ 방3 : 배기(RA) DIFFUSER / 급기(SA) DIFFUSER
프로토콜
HERV / BUNBAGI / DIFFUSER : 수정_Each_Room_Jushin_protocol_RS485_Rev1.2_20260115_CWS
대시보드 프로그램 (모두 TestProgram 폴더 하위)
C# WPF .NET 10
빌드 및 배포방법
PC 대시보드
dotnet cleand 프로젝트명
dotnet build 프로젝트명
dotnet publish "d:\project\nuvoton\HERV\TestProgram\PCDashBoard\ErvDashboard.csproj" -c Release
csproj에 SelfContained / PublishSingleFile / win-x64 가 이미 설정돼 있어서 추가 옵션이 필요 없습니다.
결과물: TestProgram\PCDashBoard\bin\Release\net10.0-windows\win-x64\publish\ErvDashboard.exe
WEB 대시보드
# 런타임 미설치 PC용 (단일 exe, 권장)
dotnet publish "d:\project\nuvoton\HERV\TestProgram\WebDashBoard\ErvCollector\ErvCollector.csproj" `
-c Release -r win-x64 --self-contained true -p:PublishSingleFile=true
# .NET 10 런타임 이미 깔린 PC용 (가벼움)
dotnet publish "d:\project\nuvoton\HERV\TestProgram\WebDashBoard\ErvCollector\ErvCollector.csproj" -c Release
결과물: ...\ErvCollector\bin\Release\net10.0\win-x64\publish\
appsettings.json 과 wwwroot\ (웹 모니터 정적파일)가 publish 폴더에 같이 복사됩니다 — publish 폴더 전체를 통째로 복사하세요. exe만 떼면 웹 화면이 안 뜹니다.
해상도 : 1920 X 1200 (노트북 기준) 안 넘게 제작.
프로토콜 : PC_ERV_Protocol.MD
공용 라이브러리 : TestProgram/ErvProtocol 폴더 (단일 진실원본)
PC용 (485통신) - TestProgram/PCDashBoard (ErvDashboard.csproj)
WEB용 (WIFI) - TestProgram/WebDashBoard (ErvCollector 수집·제어 서버 + ErvWebDashboard 모니터)
시뮬레이터 프로그램
C# WPF
UI 내용은 정적생성 할 것. 반복생성은 제외.
ERV SIMULATOR :
DIFFUSER SIMULATOR :
HERV 사양
동작사양
전원 : ON, OFF
운전모드 : 환기(수동), 자동, 바이패스, 공기청정
(운전모드 전환 시 풍량 1단, 자동 제외)
특이사항 : 기저운전/장비보호
(꺼짐예약) : 0 ~ 8 시간, 1시간 단위, 0은 꺼짐
VSP 설정값 (1바이트 0~255, 사양서 DL 대덕연구소 H-ERV 실측표)
| 구분 | 단수 | 풍량(CMH) | VSP SA | VSP EA | 전압 SA(V) | 전압 EA(V) |
|----------|--------|-----------|--------|--------|-----------|-----------|
| 환기 | 약-1 | 100 | 57 | 56 | 3.347 | 3.296 |
| 환기 | 중-2 | 150 | 63 | 61 | 3.634 | 3.571 |
| 환기 | 강-3 | 200 | 69 | 67 | 3.968 | 3.893 |
| 환기 | 터보-4 | 250 | 86 | 85 | 4.391 | 4.125 |
| 바이패스 | 기본 | 150 | 66 | 80 | 3.776 | 4.539 |
| 공청 | 약-1 | 80 | 66 | - | 3.778 | - |
| 공청 | 중-2 | 100 | 70 | - | 4.018 | - |
| 공청 | 강-3 | 120 | 77 | - | 4.354 | - |
| 공청 | 터보-4 | 150 | 80 | - | 4.50 | - |
VSP 테이블 (VSP ↔ 실측전압, V = VSP × 0.05 + 0.5)
| VSP | V | VSP | V | VSP | V | VSP | V | VSP | V |
|----|------|----|------|----|------|----|------|----|------|
| 5 | 0.75 | 25 | 1.75 | 45 | 2.75 | 65 | 3.75 | 85 | 4.75 |
| 6 | 0.80 | 26 | 1.80 | 46 | 2.80 | 66 | 3.80 | 86 | 4.80 |
| 7 | 0.85 | 27 | 1.85 | 47 | 2.85 | 67 | 3.85 | 87 | 4.85 |
| 8 | 0.90 | 28 | 1.90 | 48 | 2.90 | 68 | 3.90 | 88 | 4.90 |
| 9 | 0.95 | 29 | 1.95 | 49 | 2.95 | 69 | 3.95 | 89 | 4.95 |
| 10 | 1.00 | 30 | 2.00 | 50 | 3.00 | 70 | 4.00 | 90 | 5.00 |
| 11 | 1.05 | 31 | 2.05 | 51 | 3.05 | 71 | 4.05 | 91 | 5.05 |
| 12 | 1.10 | 32 | 2.10 | 52 | 3.10 | 72 | 4.10 | 92 | 5.10 |
| 13 | 1.15 | 33 | 2.15 | 53 | 3.15 | 73 | 4.15 | 93 | 5.15 |
| 14 | 1.20 | 34 | 2.20 | 54 | 3.20 | 74 | 4.20 | 94 | 5.20 |
| 15 | 1.25 | 35 | 2.25 | 55 | 3.25 | 75 | 4.25 | 95 | 5.25 |
| 16 | 1.30 | 36 | 2.30 | 56 | 3.30 | 76 | 4.30 | | |
| 17 | 1.35 | 37 | 2.35 | 57 | 3.35 | 77 | 4.35 | | |
| 18 | 1.40 | 38 | 2.40 | 58 | 3.40 | 78 | 4.40 | | |
| 19 | 1.45 | 39 | 2.45 | 59 | 3.45 | 79 | 4.45 | | |
| 20 | 1.50 | 40 | 2.50 | 60 | 3.50 | 80 | 4.50 | | |
| 21 | 1.55 | 41 | 2.55 | 61 | 3.55 | 81 | 4.55 | | |
| 22 | 1.60 | 42 | 2.60 | 62 | 3.60 | 82 | 4.60 | | |
| 23 | 1.65 | 43 | 2.65 | 63 | 3.65 | 83 | 4.65 | | |
| 24 | 1.70 | 44 | 2.70 | 64 | 3.70 | 84 | 4.70 | | |
공기질 센서 모드별 오염단계 임계 및 히스테리시스(하강)
| 오염단계 | ECO CO2 | ECO PM2.5 | ECO PM10 | ECO VOC | NORMAL CO2 | NORMAL PM2.5 | NORMAL PM10 | NORMAL VOC | TURBO CO2 | TURBO PM2.5 | TURBO PM10 | TURBO VOC | 상태/색상 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0~1000 | 0~20 | 0~40 | 0~171 | 0~800 | 0~14 | 0~28 | 0~120 | 0~700 | 0~12 | 0~24 | 0~103 | 좋음(파랑) |
| 1 | 1001~1300 | 21~38 | 41~86 | 172~195 | 801~1100 | 15~29 | 29~66 | 121~150 | 701~1000 | 13~23 | 25~53 | 104~120 | 보통(초록) |
| 2 | 1301~1600 | 39~60 | 87~126 | 196~308 | 1101~1400 | 30~49 | 67~102 | 151~250 | 1001~1300 | 24~38 | 54~78 | 121~192 | 나쁨(노랑) |
| 3 | 1601~2000 | 61~86 | 127~173 | 309~438 | 1401~1700 | 50~69 | 103~138 | 251~350 | 1301~1600 | 39~52 | 79~104 | 193~263 | 매우나쁨(주황) |
| 4 | 2001~ | 87~ | 174~ | 439~ | 1701~ | 70~ | 139~ | 351~ | 1601~ | 53~ | 105~ | 264~ | 매우나쁨(빨강) |
| 히스(하강) | -50 | -2 | -5 | -5 | -50 | -2 | -5 | -5 | -30 | -2 | -5 | -3 | |
각실제어 시나리오모드 : 자동(분산,집중), 안심회복, 스마트 수면, 쾌적조리
회로도 : HERV_MAIN_REV1.1_20240826(회로도).PDF
EEPROM 저장 데이터 (My_define.h EEP_*, page 128B / 엔트리 약 122)
| 인덱스 | 항목 | 비고 |
|---|---|---|
| 0~3 | 서명 0x55AA55AA | 초기화 마커 |
| 5 | Modbus ID | |
| 10~33 | 팬 VSP 프리셋 s_FAN1/2 (환기·공청·바이패스 1~4) | 1바이트, 대시보드 CTRL_VSP 반영 |
| 34~39 | 필터/소자 청소·교체 시간 | u16 |
| 40~42 | 정전복귀(전원/운전모드/풍량) | |
| 43 | 히스테리시스 활성 프리셋 (0 ECO/1 NORMAL/2 TURBO) | 유효성 마커 겸용 |
| 44~55 | 데드밴드(하강) Co2/Pm25/Pm10/Voc × 3프리셋 | u8 |
| 56~79 | CO2 임계 Co2_Thr[3][4] | u16 |
| 80~103 | VOC 임계 Voc_Thr[3][4] | u16 |
| 104~115 | PM2.5 임계 Pm25_Thr[3][4] | u8 |
| 116~127 | PM10 임계 Pm10_Thr[3][4] | u8 |
- 대시보드에서 전원/모드/VSP/히스테리시스(프리셋·임계·데드밴드) 변경 → ERV가 EEPROM 저장(재부팅 유지).
- 구버전 펌웨어(STATUS 134B, 구 m_*_Level) EEPROM은 신규 영역 미초기화 → 기본값(사양값) 사용 후 변경 시 저장.
BUNBAGI 사양
회로도 : HERV_MAIN_REV1.1_20240826(회로도).PDF
STATUS LED : STATUS_LED-PB.14, ACTIVE LOW
DIFFUSER 사양
급기(SA) DIFFUSER 구성 : 댐퍼
배기(RA) DIFFUSER 구성 : 댐퍼, 공기질센서, LED
회로도 : 전동디퓨져_REV1.0_20260130.PDF
STATUS LED : STATUS_LED-PA.11, ACTIVE LOW
SELECT SWITCH : SW1, DIPSW1-PA.10, LOW-SA, HIGH-RA
485 통신 : CN3, CN4, INT485TX-PB.5, INT485RX-PB.4, INT485DIR-PB.6
댐퍼 : CN2, STEP1_A-PC.8,STEP1_B-PC.9,STEP1_C-PC.10,STEP1_D-PC.11
공기질 센서 : CN5, SENSOR_ON-PA.3, SENSOR_SCL-PA.5, SENSOR_SDA-PA.4, SEN66
LED : CN6, LED_ON-PA.15, LED_R-PA.14,LED_G-PA.13,LED_B-PA.12
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>AirPlanner</RootNamespace>
<AssemblyName>AirPlanner</AssemblyName>
<StartupObject>AirPlanner.App</StartupObject>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
</PropertyGroup>
</Project>
+12
View File
@@ -0,0 +1,12 @@
<Application x:Class="AirPlanner.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Styles.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
@@ -0,0 +1,8 @@
using System.Windows;
namespace AirPlanner
{
public partial class App : Application
{
}
}
@@ -0,0 +1,37 @@
<UserControl x:Class="AirPlanner.ControlView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
FontFamily="Malgun Gothic, Segoe UI" Background="#FFFFFF">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="210"/>
<ColumnDefinition Width="160"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 카테고리 -->
<Border Grid.Column="0" Background="#F6F7F9" BorderBrush="{StaticResource CardBorder}" BorderThickness="0,0,1,0">
<StackPanel Margin="0,28,0,0">
<Button Style="{StaticResource ZoneBtn}" Content="시스템 에어컨"/>
<Button Style="{StaticResource ZoneBtn}" Content="환기" Foreground="{StaticResource Ink}" FontWeight="Bold"/>
<Button Style="{StaticResource ZoneBtn}" Content="대기전력콘센트"/>
<Button Style="{StaticResource ZoneBtn}" Content="엘리베이터 호출"/>
<Button Style="{StaticResource ZoneBtn}" Content="쿡탑"/>
</StackPanel>
</Border>
<!-- 구역 (클릭 → 우측 전환) -->
<Border Grid.Column="1" Background="#F6F7F9" BorderBrush="{StaticResource CardBorder}" BorderThickness="0,0,1,0">
<StackPanel Margin="0,28,0,0">
<Button x:Name="zAll" Style="{StaticResource ZoneBtn}" Content="전체" Click="Zone_Click" Tag="전체"/>
<Button x:Name="zLiving" Style="{StaticResource ZoneBtn}" Content="거실" Click="Zone_Click" Tag="거실"/>
<Button x:Name="zR1" Style="{StaticResource ZoneBtn}" Content="침실1" Click="Zone_Click" Tag="침실1"/>
<Button x:Name="zR2" Style="{StaticResource ZoneBtn}" Content="침실2" Click="Zone_Click" Tag="침실2"/>
<Button x:Name="zR3" Style="{StaticResource ZoneBtn}" Content="침실3" Click="Zone_Click" Tag="침실3"/>
</StackPanel>
</Border>
<!-- 구역별 콘텐츠 -->
<ContentControl x:Name="ZoneContent" Grid.Column="2"/>
</Grid>
</UserControl>
@@ -0,0 +1,34 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace AirPlanner
{
public partial class ControlView : UserControl
{
static readonly Brush Off = (Brush)new BrushConverter().ConvertFromString("#A6ABB6")!;
static readonly Brush On = (Brush)new BrushConverter().ConvertFromString("#161616")!;
public ControlView()
{
InitializeComponent();
ShowZone("전체", zAll);
}
void Zone_Click(object sender, RoutedEventArgs e)
{
if (sender is Button b && b.Tag is string zone) ShowZone(zone, b);
}
void ShowZone(string zone, Button active)
{
ZoneContent.Content = zone == "전체" ? new VentAllView() : new RoomDetailView(zone);
foreach (var b in new[] { zAll, zLiving, zR1, zR2, zR3 })
{
bool on = b == active;
b.Foreground = on ? On : Off;
b.FontWeight = on ? FontWeights.Bold : FontWeights.Normal;
}
}
}
}
@@ -0,0 +1,154 @@
<UserControl x:Class="AirPlanner.HomeView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
FontFamily="Malgun Gothic, Segoe UI" Background="#FFFFFF">
<Grid Margin="26,22,26,22">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="600"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 좌: 환기 상태 -->
<StackPanel Grid.Column="0">
<TextBlock x:Name="txtClock" Text="10월 28일 (월) 오후 12시 37분" FontSize="16" Foreground="#6B7280"/>
<StackPanel Orientation="Horizontal" Margin="0,12,0,0">
<Grid Width="34" Height="34" VerticalAlignment="Center" Margin="0,0,12,0">
<Ellipse Fill="#141417"/>
<TextBlock Text="&#x23FB;" FontFamily="Segoe UI Symbol" FontSize="17" Foreground="White"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
<TextBlock x:Name="txtState" Text="자동운전 중입니다." FontSize="30" FontWeight="Bold" Foreground="{StaticResource Ink}" VerticalAlignment="Center"/>
</StackPanel>
<!-- 4실 원형 -->
<UniformGrid Columns="2" Rows="2" Width="360" HorizontalAlignment="Left" Margin="40,22,0,0">
<Grid Width="94" Height="94" Margin="0,0,0,18" HorizontalAlignment="Left">
<Ellipse Width="94" Height="94" StrokeThickness="3.5" Stroke="#5FB236" Fill="Transparent"/>
<TextBlock Text="거실" FontSize="17" Foreground="{StaticResource Ink}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
<Grid Width="94" Height="94" Margin="0,0,0,18" HorizontalAlignment="Left">
<Ellipse Width="94" Height="94" StrokeThickness="3.5" Stroke="#5BB6E8" Fill="Transparent"/>
<TextBlock Text="침실1" FontSize="17" Foreground="{StaticResource Ink}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
<Grid Width="94" Height="94" HorizontalAlignment="Left">
<Ellipse Width="94" Height="94" StrokeThickness="3.5" Stroke="#EF5350" Fill="Transparent"/>
<TextBlock Text="침실2" FontSize="17" Foreground="{StaticResource Ink}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
<Grid Width="94" Height="94" HorizontalAlignment="Left">
<Ellipse Width="94" Height="94" StrokeThickness="3.5" Stroke="#F59E0B" Fill="Transparent"/>
<TextBlock Text="침실3" FontSize="17" Foreground="{StaticResource Ink}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</UniformGrid>
<!-- 제어 카드 -->
<Border CornerRadius="14" Background="#FFFFFF" BorderBrush="{StaticResource CardBorder}" BorderThickness="1.5"
Padding="22,18" Margin="0,16,0,0" HorizontalAlignment="Left" Width="560">
<Grid>
<Grid.ColumnDefinitions><ColumnDefinition Width="Auto"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
<!-- 좌: 4 토글 -->
<StackPanel Grid.Column="0">
<Grid Margin="0,0,0,12"><Grid.ColumnDefinitions><ColumnDefinition Width="100"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<TextBlock Text="자동운전" Style="{StaticResource ToggleLabel}"/>
<ToggleButton Grid.Column="1" Style="{StaticResource Toggle}" IsChecked="True"/></Grid>
<Grid Margin="0,0,0,12"><Grid.ColumnDefinitions><ColumnDefinition Width="100"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<TextBlock Text="스마트수면" Style="{StaticResource ToggleLabel}"/>
<ToggleButton Grid.Column="1" Style="{StaticResource Toggle}"/></Grid>
<Grid Margin="0,0,0,12"><Grid.ColumnDefinitions><ColumnDefinition Width="100"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<TextBlock Text="쾌적조리" Style="{StaticResource ToggleLabel}"/>
<ToggleButton Grid.Column="1" Style="{StaticResource Toggle}" IsChecked="True"/></Grid>
<Grid><Grid.ColumnDefinitions><ColumnDefinition Width="100"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<TextBlock Text="안심회복" Style="{StaticResource ToggleLabel}"/>
<ToggleButton Grid.Column="1" Style="{StaticResource Toggle}"/></Grid>
</StackPanel>
<!-- 우: 세그먼트 + 알약 + 제습기 연동 -->
<StackPanel Grid.Column="1" Margin="20,0,0,0" VerticalAlignment="Center">
<Border CornerRadius="9" Background="#EFEFF1" Padding="3" HorizontalAlignment="Left" Margin="0,0,0,12">
<StackPanel Orientation="Horizontal">
<RadioButton GroupName="seg" Style="{StaticResource Seg}" Content="Eco"/>
<RadioButton GroupName="seg" Style="{StaticResource Seg}" Content="Normal" IsChecked="True"/>
<RadioButton GroupName="seg" Style="{StaticResource Seg}" Content="Turbo"/>
</StackPanel>
</Border>
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<Border CornerRadius="9" BorderBrush="{StaticResource CardBorder}" BorderThickness="1.5" Padding="13,9" Margin="0,0,10,0">
<StackPanel Orientation="Horizontal"><TextBlock Text="운전모드" Style="{StaticResource PillLabel}"/><TextBlock Text="청정환기" Style="{StaticResource PillValue}" Margin="8,0,0,0"/></StackPanel></Border>
<Border CornerRadius="9" BorderBrush="{StaticResource CardBorder}" BorderThickness="1.5" Padding="13,9">
<StackPanel Orientation="Horizontal"><TextBlock Text="풍량" Style="{StaticResource PillLabel}"/><TextBlock Text="3단" Style="{StaticResource PillValue}" Margin="8,0,0,0"/></StackPanel></Border>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<TextBlock Text="제습기 연동" Style="{StaticResource ToggleLabel}" FontSize="15" Margin="0,0,12,0"/>
<ToggleButton Style="{StaticResource Toggle}"/>
</StackPanel>
</StackPanel>
</Grid>
</Border>
</StackPanel>
<!-- 우: 스마트홈 카드 -->
<UniformGrid Grid.Column="1" Columns="3" Rows="2" Margin="18,0,0,0">
<Border Style="{StaticResource HomeCard}">
<Grid>
<Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions>
<Grid><TextBlock Text="기상정보" Style="{StaticResource CardTitle}"/><TextBlock Text="&#x203A;" Style="{StaticResource CardArrow}"/></Grid>
<StackPanel Grid.Row="1" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#x26C5;" FontFamily="Segoe UI Emoji" FontSize="54" VerticalAlignment="Center"/>
<TextBlock Text="15&#176;" FontSize="40" FontWeight="Bold" Foreground="{StaticResource Ink}" VerticalAlignment="Center" Margin="14,0,0,0"/>
<TextBlock Text="11&#176; / 23&#176;" FontSize="15" Foreground="{StaticResource Ink2}" VerticalAlignment="Bottom" Margin="12,0,0,12"/>
</StackPanel>
</Grid>
</Border>
<Border Style="{StaticResource HomeCard}">
<Grid>
<Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions>
<Grid><TextBlock Text="엘리베이터 호출" Style="{StaticResource CardTitle}"/><TextBlock Text="&#x203A;" Style="{StaticResource CardArrow}"/></Grid>
<TextBlock Grid.Row="1" Text="7F" FontSize="20" Foreground="{StaticResource Ink2}" Margin="0,8,0,0"/>
<Border Grid.Row="2" CornerRadius="8" BorderBrush="{StaticResource CardBorder}" BorderThickness="1.5" Padding="0,10">
<TextBlock Text="CALL" FontSize="15" Foreground="{StaticResource Ink2}" HorizontalAlignment="Center"/>
</Border>
</Grid>
</Border>
<Border Style="{StaticResource HomeCard}">
<Grid>
<Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions>
<Grid><TextBlock Text="방문차량등록" Style="{StaticResource CardTitle}"/><TextBlock Text="&#x203A;" Style="{StaticResource CardArrow}"/></Grid>
<TextBlock Grid.Row="1" Text="총1대" FontSize="18" Foreground="{StaticResource Ink2}" Margin="0,8,0,0"/>
<TextBlock Grid.Row="3" Foreground="{StaticResource Ink2}" FontSize="15">
<Run Text="잔여마일리지"/><LineBreak/><Run Text="32시간 50분"/>
</TextBlock>
</Grid>
</Border>
<Border Style="{StaticResource HomeCard}">
<Grid>
<Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions>
<Grid><TextBlock Text="주차위치" Style="{StaticResource CardTitle}"/><TextBlock Text="&#x203A;" Style="{StaticResource CardArrow}"/></Grid>
<StackPanel Grid.Row="1" Margin="0,10,0,0">
<Grid Margin="0,0,0,6"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<TextBlock Text="B1-A01" FontSize="17" Foreground="{StaticResource Ink2}"/><TextBlock Grid.Column="1" Text="9401" FontSize="17" Foreground="{StaticResource Ink2}"/></Grid>
<Grid><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<TextBlock Text="B2-C11" FontSize="17" Foreground="{StaticResource Ink2}"/><TextBlock Grid.Column="1" Text="3486" FontSize="17" Foreground="{StaticResource Ink2}"/></Grid>
</StackPanel>
</Grid>
</Border>
<Border Style="{StaticResource HomeCard}">
<Grid>
<Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions>
<Grid><TextBlock Text="에너지사용량" Style="{StaticResource CardTitle}"/><TextBlock Text="&#x203A;" Style="{StaticResource CardArrow}"/></Grid>
<TextBlock Grid.Row="1" Text="전기 256kwh" FontSize="17" Foreground="{StaticResource Ink2}" Margin="0,10,0,0"/>
</Grid>
</Border>
<Border Style="{StaticResource HomeCard}">
<TextBlock Text="+ 추가" FontSize="18" Foreground="{StaticResource Ink2}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</UniformGrid>
</Grid>
</UserControl>
@@ -0,0 +1,29 @@
using System;
using System.Windows.Controls;
using System.Windows.Threading;
namespace AirPlanner
{
public partial class HomeView : UserControl
{
readonly DispatcherTimer _clock = new() { Interval = TimeSpan.FromSeconds(10) };
public HomeView()
{
InitializeComponent();
_clock.Tick += (_, _) => UpdateClock();
_clock.Start();
UpdateClock();
Unloaded += (_, _) => _clock.Stop();
}
void UpdateClock()
{
var n = DateTime.Now;
string[] dow = { "일", "월", "화", "수", "목", "금", "토" };
string ap = n.Hour < 12 ? "오전" : "오후";
int h12 = n.Hour % 12; if (h12 == 0) h12 = 12;
txtClock.Text = $"{n.Month}월 {n.Day}일 ({dow[(int)n.DayOfWeek]}) {ap} {h12}시 {n.Minute:00}분";
}
}
}
@@ -0,0 +1,37 @@
<Window x:Class="AirPlanner.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="AirPlanner — 에어플래너 (e편한세상)" Height="600" Width="1400"
Background="#141417" FontFamily="Malgun Gothic, Segoe UI"
WindowStartupLocation="CenterScreen">
<Viewbox Stretch="Uniform">
<Grid Width="1400" Height="560" Background="#FFFFFF">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="92"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 좌측 네비게이션 (공유) -->
<Border Grid.Column="0" Background="#141417">
<StackPanel Margin="0,30,0,0">
<Button x:Name="navHome" Style="{StaticResource NavBtn}" Content="홈" Click="Nav_Home"/>
<Button x:Name="navCall" Style="{StaticResource NavBtn}" Content="통화" Click="Nav_Todo"/>
<Button x:Name="navCtrl" Style="{StaticResource NavBtn}" Content="제어" Click="Nav_Control"/>
<Button x:Name="navView" Style="{StaticResource NavBtn}" Content="조회" Click="Nav_Todo"/>
<Button x:Name="navSet" Style="{StaticResource NavBtn}" Content="설정" Click="Nav_Todo"/>
<Button x:Name="navAlarm" Style="{StaticResource NavBtn}" Click="Nav_Todo">
<Grid>
<TextBlock Text="알림" FontSize="18" Foreground="#7B8090"/>
<Ellipse Width="6" Height="6" Fill="#EF4444" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,2,-8,0"/>
</Grid>
</Button>
<Button x:Name="navEmer" Style="{StaticResource NavBtn}" Content="비상" Foreground="#EF4444" Click="Nav_Todo"/>
</StackPanel>
</Border>
<!-- 화면 영역 -->
<ContentControl x:Name="MainContent" Grid.Column="1"/>
</Grid>
</Viewbox>
</Window>
@@ -0,0 +1,37 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace AirPlanner
{
public partial class MainWindow : Window
{
static readonly Brush NavOff = (Brush)new BrushConverter().ConvertFromString("#7B8090")!;
static readonly Brush NavOn = Brushes.White;
public MainWindow()
{
InitializeComponent();
Show("home");
}
void Show(string view)
{
if (view == "control") { MainContent.Content = new ControlView(); SetActive(navCtrl); }
else { MainContent.Content = new HomeView(); SetActive(navHome); }
}
void SetActive(Button active)
{
foreach (var b in new[] { navHome, navCall, navCtrl, navView, navSet })
{
b.Foreground = b == active ? NavOn : NavOff;
b.FontWeight = b == active ? FontWeights.Bold : FontWeights.Normal;
}
}
void Nav_Home(object sender, RoutedEventArgs e) => Show("home");
void Nav_Control(object sender, RoutedEventArgs e) => Show("control");
void Nav_Todo(object sender, RoutedEventArgs e) { /* 통화/조회/설정/알림/비상 — 추후 구현 */ }
}
}
@@ -0,0 +1,51 @@
<UserControl x:Class="AirPlanner.RoomCard"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
FontFamily="Malgun Gothic, Segoe UI">
<Border CornerRadius="12" Background="{StaticResource CardBg}"
BorderBrush="{StaticResource CardBorder}" BorderThickness="1.5" Margin="8" Padding="18,16">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 헤더 : 이름 + 공기질 원 -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<TextBlock x:Name="NameText" Grid.Column="0" Text="거실" FontSize="20" FontWeight="Bold"
Foreground="{StaticResource Ink}" VerticalAlignment="Center"/>
<Grid Grid.Column="1" Width="64" Height="64">
<Ellipse x:Name="AqRing" Width="64" Height="64" StrokeThickness="3.5" Stroke="{StaticResource AqGood}" Fill="Transparent"/>
<TextBlock x:Name="AqText" Text="좋음" FontSize="13" FontWeight="SemiBold" Width="54" TextAlignment="Center" TextWrapping="Wrap"
Foreground="{StaticResource AqGood}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</Grid>
<!-- 센서 4종 (글씨 검정) -->
<StackPanel Grid.Row="1" Margin="2,14,0,0" VerticalAlignment="Top">
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
<TextBlock Text="&#x2022;" Foreground="{StaticResource AqVeryBad}" FontSize="13" Margin="0,0,7,0"/>
<TextBlock x:Name="S0" Text="초미세먼지: 좋음" FontSize="13.5" Foreground="{StaticResource Ink}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
<TextBlock Text="&#x2022;" Foreground="{StaticResource AqVeryBad}" FontSize="13" Margin="0,0,7,0"/>
<TextBlock x:Name="S1" Text="미세먼지: 좋음" FontSize="13.5" Foreground="{StaticResource Ink}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
<TextBlock Text="&#x2022;" Foreground="{StaticResource AqVeryBad}" FontSize="13" Margin="0,0,7,0"/>
<TextBlock x:Name="S2" Text="CO2: 좋음" FontSize="13.5" Foreground="{StaticResource Ink}"/>
</StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x2022;" Foreground="{StaticResource AqVeryBad}" FontSize="13" Margin="0,0,7,0"/>
<TextBlock x:Name="S3" Text="VOCs: 좋음" FontSize="13.5" Foreground="{StaticResource Ink}"/>
</StackPanel>
</StackPanel>
<!-- ON / Standby / OFF -->
<ToggleButton x:Name="OnBtn" Grid.Row="2" Style="{StaticResource BigToggleBtn}" Content="ON"
Height="48" Margin="0,16,0,0"/>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,30 @@
using System.Windows.Controls;
using System.Windows.Media;
namespace AirPlanner
{
public partial class RoomCard : UserControl
{
public RoomCard()
{
InitializeComponent();
}
// 실명 / 공기질 텍스트·색 / 센서 4종 상태 / 버튼(ON·Standby·OFF, active=검정)
public void Set(string name, string aqText, Brush aqColor,
string pm25, string pm10, string co2, string voc,
string btnText, bool btnActive)
{
NameText.Text = name;
AqText.Text = aqText;
AqText.Foreground = aqColor;
AqRing.Stroke = aqColor;
S0.Text = $"초미세먼지: {pm25}";
S1.Text = $"미세먼지: {pm10}";
S2.Text = $"CO2: {co2}";
S3.Text = $"VOCs: {voc}";
OnBtn.Content = btnText;
OnBtn.IsChecked = btnActive; // true → 검정(ON/Standby)
}
}
}
@@ -0,0 +1,84 @@
<UserControl x:Class="AirPlanner.RoomDetailView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
FontFamily="Malgun Gothic, Segoe UI" Background="#FFFFFF">
<Grid Margin="40,26,40,26">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 상단 : 전원원형 + 운전모드/토글 -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid Grid.Column="0" Width="200" Height="200" VerticalAlignment="Center" HorizontalAlignment="Center">
<Ellipse Fill="#141417"/>
<TextBlock Text="&#x23FB;" FontFamily="Segoe UI Symbol" FontSize="86" Foreground="White"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="40,0,0,0">
<TextBlock Text="청정환기" FontSize="24" FontWeight="Bold" Foreground="{StaticResource Ink}"/>
<TextBlock Text="자동운전" FontSize="24" FontWeight="Bold" Foreground="{StaticResource Ink}" Margin="0,8,0,16"/>
<!-- 실별 토글 — 코드에서 구성 -->
<StackPanel x:Name="TogglePanel"/>
</StackPanel>
</Grid>
<!-- 하단 : 실내온습도 + 공기질 4종 (같은 줄) -->
<Grid Grid.Row="1" Margin="0,18,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 실내온도 / 실내습도 (크게) -->
<StackPanel Grid.Column="0" Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="실내온도" FontSize="17" Foreground="{StaticResource Ink2}" HorizontalAlignment="Center"/>
<TextBlock Text="24&#176;" FontSize="28" FontWeight="Bold" Foreground="{StaticResource Ink}" HorizontalAlignment="Center" Margin="0,4,0,0"/>
</StackPanel>
<Border Width="1" Background="{StaticResource CardBorder}" Margin="26,4,26,4"/>
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="실내습도" FontSize="17" Foreground="{StaticResource Ink2}" HorizontalAlignment="Center"/>
<TextBlock Text="24%" FontSize="28" FontWeight="Bold" Foreground="{StaticResource Ink}" HorizontalAlignment="Center" Margin="0,4,0,0"/>
</StackPanel>
</StackPanel>
<!-- 공기질 4종 (모두 좋음) -->
<UniformGrid Grid.Column="1" Columns="4" VerticalAlignment="Center">
<StackPanel HorizontalAlignment="Center">
<Grid Width="64" Height="64" HorizontalAlignment="Center">
<Ellipse Width="64" Height="64" StrokeThickness="3.5" Stroke="{StaticResource AqGood}" Fill="Transparent"/>
<TextBlock Text="좋음" FontSize="13" FontWeight="SemiBold" Foreground="{StaticResource AqGood}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
<TextBlock Text="초미세먼지" FontSize="13" Foreground="{StaticResource Ink}" HorizontalAlignment="Center" Margin="0,8,0,0"/>
</StackPanel>
<StackPanel HorizontalAlignment="Center">
<Grid Width="64" Height="64" HorizontalAlignment="Center">
<Ellipse Width="64" Height="64" StrokeThickness="3.5" Stroke="{StaticResource AqGood}" Fill="Transparent"/>
<TextBlock Text="좋음" FontSize="13" FontWeight="SemiBold" Foreground="{StaticResource AqGood}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
<TextBlock Text="미세먼지" FontSize="13" Foreground="{StaticResource Ink}" HorizontalAlignment="Center" Margin="0,8,0,0"/>
</StackPanel>
<StackPanel HorizontalAlignment="Center">
<Grid Width="64" Height="64" HorizontalAlignment="Center">
<Ellipse Width="64" Height="64" StrokeThickness="3.5" Stroke="{StaticResource AqGood}" Fill="Transparent"/>
<TextBlock Text="좋음" FontSize="13" FontWeight="SemiBold" Foreground="{StaticResource AqGood}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
<TextBlock Text="이산화탄소" FontSize="13" Foreground="{StaticResource Ink}" HorizontalAlignment="Center" Margin="0,8,0,0"/>
</StackPanel>
<StackPanel HorizontalAlignment="Center">
<Grid Width="64" Height="64" HorizontalAlignment="Center">
<Ellipse Width="64" Height="64" StrokeThickness="3.5" Stroke="{StaticResource AqGood}" Fill="Transparent"/>
<TextBlock Text="좋음" FontSize="13" FontWeight="SemiBold" Foreground="{StaticResource AqGood}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
<TextBlock Text="휘발성" FontSize="13" Foreground="{StaticResource Ink}" HorizontalAlignment="Center" Margin="0,8,0,0"/>
<TextBlock Text="유기 화합물" FontSize="13" Foreground="{StaticResource Ink}" HorizontalAlignment="Center"/>
</StackPanel>
</UniformGrid>
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,34 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;
namespace AirPlanner
{
public partial class RoomDetailView : UserControl
{
// 거실 : 표시램프만
// 침실1 : 안심회복 + 표시램프 + 제습기 연동 (안심회복은 침실1 전용)
// 침실2·침실3 : 표시램프 + 제습기 연동
public RoomDetailView(string roomName)
{
InitializeComponent();
bool isBedroom = roomName != "거실";
if (roomName == "침실1") TogglePanel.Children.Add(MakeRow("안심회복", false));
TogglePanel.Children.Add(MakeRow("표시램프", true));
if (isBedroom) TogglePanel.Children.Add(MakeRow("제습기 연동", false));
}
FrameworkElement MakeRow(string label, bool on)
{
var sp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 12) };
sp.Children.Add(new TextBlock
{
Text = label, FontSize = 18, VerticalAlignment = VerticalAlignment.Center,
Foreground = (Brush)FindResource("Ink"), Margin = new Thickness(0, 0, 12, 0), MinWidth = 86
});
sp.Children.Add(new ToggleButton { Style = (Style)FindResource("Toggle"), IsChecked = on });
return sp;
}
}
}
@@ -0,0 +1,178 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ===== 색상 ===== -->
<Color x:Key="cBg">#FFFFFF</Color>
<SolidColorBrush x:Key="Bg" Color="#FFFFFF"/>
<SolidColorBrush x:Key="CardBg" Color="#FFFFFF"/>
<SolidColorBrush x:Key="CardBorder" Color="#E5E7EB"/>
<SolidColorBrush x:Key="Ink" Color="#161616"/>
<SolidColorBrush x:Key="Ink2" Color="#6B7280"/>
<SolidColorBrush x:Key="TrackOff" Color="#E3E5EA"/>
<SolidColorBrush x:Key="ThumbBr" Color="#FFFFFF"/>
<!-- 공기질 4단계 -->
<SolidColorBrush x:Key="AqGood" Color="#3B82F6"/> <!-- 좋음 파랑 -->
<SolidColorBrush x:Key="AqNormal" Color="#22C55E"/> <!-- 보통 초록 -->
<SolidColorBrush x:Key="AqBad" Color="#F59E0B"/> <!-- 나쁨 주황 -->
<SolidColorBrush x:Key="AqVeryBad" Color="#EF4444"/> <!-- 매우나쁨 빨강 -->
<!-- ===== iOS 스타일 토글 스위치 ===== -->
<Style x:Key="Toggle" TargetType="ToggleButton">
<Setter Property="Width" Value="50"/>
<Setter Property="Height" Value="29"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Grid>
<Border x:Name="track" CornerRadius="15" Background="{StaticResource TrackOff}"/>
<Ellipse x:Name="thumb" Width="23" Height="23" Fill="{StaticResource ThumbBr}"
HorizontalAlignment="Left" Margin="3,0,0,0">
<Ellipse.Effect><DropShadowEffect BlurRadius="4" ShadowDepth="1" Opacity="0.25"/></Ellipse.Effect>
</Ellipse>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="track" Property="Background" Value="{StaticResource Ink}"/>
<Setter TargetName="thumb" Property="HorizontalAlignment" Value="Right"/>
<Setter TargetName="thumb" Property="Margin" Value="0,0,3,0"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.35"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 토글 행 라벨 -->
<Style x:Key="ToggleLabel" TargetType="TextBlock">
<Setter Property="FontSize" Value="17"/>
<Setter Property="Foreground" Value="{StaticResource Ink}"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<!-- ===== 큰 ON/OFF 버튼 (전체/각실) ===== -->
<Style x:Key="BigToggleBtn" TargetType="ToggleButton">
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FontSize" Value="20"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{StaticResource Ink2}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="b" CornerRadius="10" Background="{StaticResource CardBg}"
BorderBrush="{StaticResource CardBorder}" BorderThickness="1.5">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="b" Property="Background" Value="{StaticResource Ink}"/>
<Setter TargetName="b" Property="BorderBrush" Value="{StaticResource Ink}"/>
<Setter Property="Foreground" Value="White"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 좌측 네비 버튼 ===== -->
<Style x:Key="NavBtn" TargetType="Button">
<Setter Property="Foreground" Value="#7B8090"/>
<Setter Property="FontSize" Value="18"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Margin" Value="0,0,0,30"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="Transparent" Padding="0,2">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 카테고리/구역 리스트 버튼 ===== -->
<Style x:Key="ZoneBtn" TargetType="Button">
<Setter Property="Foreground" Value="#A6ABB6"/>
<Setter Property="FontSize" Value="18"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Margin" Value="0,0,0,40"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="Transparent" Padding="0,2">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== Eco/Normal/Turbo 세그먼트 (RadioButton) ===== -->
<Style x:Key="Seg" TargetType="RadioButton">
<Setter Property="Foreground" Value="#6B7280"/>
<Setter Property="FontSize" Value="15"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Padding" Value="18,7"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<Border x:Name="b" CornerRadius="7" Background="Transparent" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="b" Property="Background" Value="White"/>
<Setter Property="Foreground" Value="{StaticResource Ink}"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter TargetName="b" Property="Effect">
<Setter.Value><DropShadowEffect BlurRadius="5" ShadowDepth="1" Opacity="0.18"/></Setter.Value>
</Setter>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 스마트홈 카드 (홈 우측) ===== -->
<Style x:Key="HomeCard" TargetType="Border">
<Setter Property="CornerRadius" Value="14"/>
<Setter Property="Background" Value="{StaticResource CardBg}"/>
<Setter Property="BorderBrush" Value="{StaticResource CardBorder}"/>
<Setter Property="BorderThickness" Value="1.5"/>
<Setter Property="Padding" Value="18,14"/>
<Setter Property="Margin" Value="8"/>
</Style>
<Style x:Key="CardTitle" TargetType="TextBlock">
<Setter Property="FontSize" Value="18"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{StaticResource Ink}"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
</Style>
<Style x:Key="CardArrow" TargetType="TextBlock">
<Setter Property="FontSize" Value="20"/>
<Setter Property="Foreground" Value="{StaticResource Ink2}"/>
<Setter Property="HorizontalAlignment" Value="Right"/>
<Setter Property="VerticalAlignment" Value="Top"/>
</Style>
<!-- ===== 상태 알약 (운전모드/풍량) ===== -->
<Style x:Key="PillLabel" TargetType="TextBlock">
<Setter Property="FontSize" Value="14"/>
<Setter Property="Foreground" Value="{StaticResource Ink2}"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style x:Key="PillValue" TargetType="TextBlock">
<Setter Property="FontSize" Value="15"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Foreground" Value="{StaticResource Ink}"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Margin" Value="10,0,0,0"/>
</Style>
</ResourceDictionary>
@@ -0,0 +1,69 @@
<UserControl x:Class="AirPlanner.VentAllView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:AirPlanner"
FontFamily="Malgun Gothic, Segoe UI" Background="#FFFFFF">
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="14,14,8,14">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/><ColumnDefinition Width="*"/><ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 전체 제어 카드 (2열 차지) -->
<Border Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Style="{StaticResource HomeCard}">
<Grid>
<Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/></Grid.RowDefinitions>
<Grid Grid.Row="0">
<Grid.ColumnDefinitions><ColumnDefinition Width="Auto"/><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="전체" FontSize="20" FontWeight="Bold" Foreground="{StaticResource Ink}" VerticalAlignment="Top" Margin="0,2,0,0"/>
<!-- 토글 4 -->
<StackPanel Grid.Column="1" HorizontalAlignment="Center">
<Grid Margin="0,0,0,11"><Grid.ColumnDefinitions><ColumnDefinition Width="92"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<TextBlock Text="자동운전" Style="{StaticResource ToggleLabel}" FontSize="15"/>
<ToggleButton Grid.Column="1" Style="{StaticResource Toggle}" IsChecked="True"/></Grid>
<Grid Margin="0,0,0,11"><Grid.ColumnDefinitions><ColumnDefinition Width="92"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<TextBlock Text="스마트수면" Style="{StaticResource ToggleLabel}" FontSize="15"/>
<ToggleButton Grid.Column="1" Style="{StaticResource Toggle}"/></Grid>
<Grid Margin="0,0,0,11"><Grid.ColumnDefinitions><ColumnDefinition Width="92"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<TextBlock Text="쾌적조리" Style="{StaticResource ToggleLabel}" FontSize="15"/>
<ToggleButton Grid.Column="1" Style="{StaticResource Toggle}" IsChecked="True"/></Grid>
<Grid><Grid.ColumnDefinitions><ColumnDefinition Width="92"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<TextBlock Text="안심회복" Style="{StaticResource ToggleLabel}" FontSize="15"/>
<ToggleButton Grid.Column="1" Style="{StaticResource Toggle}"/></Grid>
</StackPanel>
<!-- 알약 3 (자동운전/운전모드/풍량) -->
<StackPanel Grid.Column="2" VerticalAlignment="Center" Margin="14,0,0,0">
<Border CornerRadius="9" BorderBrush="{StaticResource CardBorder}" BorderThickness="1.5" Padding="13,9" Margin="0,0,0,9">
<StackPanel Orientation="Horizontal"><TextBlock Text="자동운전" Style="{StaticResource PillLabel}"/><TextBlock Text="Normal" Style="{StaticResource PillValue}" Margin="8,0,0,0"/></StackPanel></Border>
<Border CornerRadius="9" BorderBrush="{StaticResource CardBorder}" BorderThickness="1.5" Padding="13,9" Margin="0,0,0,9">
<StackPanel Orientation="Horizontal"><TextBlock Text="운전모드" Style="{StaticResource PillLabel}"/><TextBlock Text="청정환기" Style="{StaticResource PillValue}" Margin="8,0,0,0"/></StackPanel></Border>
<Border CornerRadius="9" BorderBrush="{StaticResource CardBorder}" BorderThickness="1.5" Padding="13,9">
<StackPanel Orientation="Horizontal"><TextBlock Text="풍량" Style="{StaticResource PillLabel}"/><TextBlock Text="3단" Style="{StaticResource PillValue}" Margin="8,0,0,0"/></StackPanel></Border>
</StackPanel>
</Grid>
<!-- 하단 : ON + 제습기 연동 -->
<Grid Grid.Row="1" Margin="0,16,0,0">
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<ToggleButton Grid.Column="0" Style="{StaticResource BigToggleBtn}" Content="ON" IsChecked="True" Height="46" Margin="0,0,16,0"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="제습기 연동" Style="{StaticResource ToggleLabel}" FontSize="15" Margin="0,0,12,0"/>
<ToggleButton Style="{StaticResource Toggle}"/>
</StackPanel>
</Grid>
</Grid>
</Border>
<local:RoomCard x:Name="cLiving" Grid.Row="0" Grid.Column="2"/>
<local:RoomCard x:Name="cR1" Grid.Row="1" Grid.Column="0"/>
<local:RoomCard x:Name="cR2" Grid.Row="1" Grid.Column="1"/>
<local:RoomCard x:Name="cR3" Grid.Row="1" Grid.Column="2"/>
</Grid>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,22 @@
using System.Windows.Controls;
using System.Windows.Media;
namespace AirPlanner
{
public partial class VentAllView : UserControl
{
public VentAllView()
{
InitializeComponent();
Loaded += (_, _) =>
{
cLiving.Set("거실", "좋음", Aq("AqGood"), "좋음", "좋음", "좋음", "좋음", "Standby", true);
cR1.Set("침실1", "좋음", Aq("AqGood"), "좋음", "좋음", "좋음", "좋음", "Standby", true);
cR2.Set("침실2", "보통", Aq("AqNormal"), "좋음", "좋음", "보통", "보통", "ON", true);
cR3.Set("침실3", "나쁨", Aq("AqBad"), "좋음", "좋음", "보통", "나쁨", "ON", true);
};
}
Brush Aq(string key) => (Brush)FindResource(key);
}
}
+142
View File
@@ -0,0 +1,142 @@
# EW11 (RS-485 ↔ WiFi) HOMENET 연결 검토
- 작성일: 2026-05-26
- 대상 장비: Elfin EW11A (Hi-Flying) — RS-485 ↔ WiFi 투명 시리얼 브리지
- 대상 MCU: Nuvoton Nano100 시리즈 (HERV)
- 관련 문서:
- `EW11_RS485 TO WIFI/EW11A manual/Elfin-EW1X_User ManualV1.3(20200415).pdf`
- `EW11_RS485 TO WIFI/IOTService3.1.0_20220926/`
- `doc/HERV_MCU PINMAP & CONNECTOR.txt`
---
## 1. 결론
**하드웨어/펌웨어 모두 가능**. 단, UART1 채널이 PC_Monitoring(디버그)과 HOMENET(CVnet)에 동시에 사용될 수 없는 구조적 트레이드오프가 있음.
EW11은 **투명 시리얼 브리지(Transparent Serial Bridge)**이므로 UART ↔ TCP/WiFi 변환을 EW11 내부에서 처리한다. MCU 펌웨어 측에서는 EW11의 존재를 알 필요가 없고, 기존 CVnet 프로토콜 코드를 그대로 사용하면 된다.
---
## 2. 현재 시스템 UART 채널 구성
| 채널 | 용도 | 초기화 위치 | Baud |
|---|---|---|---|
| UART0 | Hood (후드, CN6/CN13) | `program/User/My_system.c:36` | 9600 |
| **UART1** | **HOMENET (CVnet) — CN15** | `program/User/My_system.c:50` | 9600 |
| SC0 (SCUART) | Roomcon 232 | `program/User/My_system.c:64` | 1200 |
| SC1 (SCUART) | Bunbaegi 분배기 | `program/User/My_system.c:73` | 115200 |
### HOMENET 핀맵 (CN15, RED, HOMENET_485)
- TX: PB.5 (E485_TX)
- RX: PB.4 (E485_RX)
- DIR: PB.6 (E485_DIR)
> Nano100 시리즈는 **UART0/UART1 2개만 제공**하므로 추가 UART 확보가 불가능하다.
---
## 3. 하드웨어 연결 방안
| 항목 | 기존 HOMENET | EW11 연결 |
|---|---|---|
| 커넥터 | CN15 (RED), HOMENET_485 | CN15 RS-485 A/B 라인에 EW11 RS-485 단자 병렬 연결 |
| MCU 핀 | UART1: PB.4(RX), PB.5(TX), PB.6(DIR) | 변경 없음 |
| Baud | 9600 8N1 | EW11도 9600 8N1로 설정 |
EW11은 자체 RS-485 트랜시버를 가지고 DIR을 자동 제어하므로 MCU의 PB.6 DIR 제어와 충돌하지 않는다(RS-485 멀티드롭 버스 구성).
```
[홈넷 서버/앱] ──(WiFi/TCP)── [EW11] ──(RS-485 A/B)── [CN15] ── [MCU UART1]
```
---
## 4. 펌웨어 측 필요 작업
### 4.1 CVnet 프로토콜 복구 (직전 작업 되돌리기)
직전 작업으로 비활성화한 두 곳을 다시 활성화해야 HOMENET 패킷을 처리할 수 있다.
- `program/User/main.c:110`
```c
CVnet_Com_process();
```
- `program/User/My_Uart.c:1758`
```c
rx_debug_check(u8InChar);
Rx_CVnet_TimeOut = 50;
```
### 4.2 PC_Monitoring과 동시 사용 불가 ⚠️
UART1은 단 하나의 물리 채널이므로 PC_Monitoring(디버그)과 CVnet(홈넷)이 **동시에 사용 불가**하다. 다음 중 하나를 선택해야 한다.
| 옵션 | 설명 | 비용 | 권장 |
|---|---|---|---|
| ① 평소 CVnet만, 디버그 시에만 PC_Monitoring | `#ifdef DEBUG_PC_MODE` 컴파일 스위치로 분리 | 펌웨어만 수정 | **★ 권장** |
| ② PC_Monitoring을 UART0(Hood)로 이전 | Hood 통신을 잠시 끊고 디버그 | Hood 제어 일시 중단 | △ |
| ③ 다른 시리얼(SC0/SC1) 사용 | 기존 통신 영향 | 다른 통신 끊김 | △ |
### 4.3 CVnet 타임아웃 검토
WiFi 경로는 **20 ~ 200 ms의 가변 지연**이 발생한다.
- MCU 측 `Rx_CVnet_TimeOut = 50` (50 ms): **패킷 수신 중단 감지용**이므로 EW11 bridge엔 직접 영향 없음.
- 홈넷 마스터(서버) 측의 응답 타임아웃은 EW11 RTT를 감안해 설정 필요. 너무 짧으면 재전송 빈발.
---
## 5. EW11 설정 (PC에서 IOTService 도구로)
| 항목 | 권장값 |
|---|---|
| Serial Baudrate | 9600 |
| Data / Parity / Stop | 8 / None / 1 |
| Flow Control | None |
| Protocol | **TCP Server** (홈넷 서버가 클라이언트로 접속) 또는 **TCP Client** (EW11이 서버에 접속) |
| Port | 홈넷 게이트웨이와 합의된 포트 |
| Buffer length | 8 (CVnet 패킷이 8바이트 고정) — 패킷 단위 전송 보장 |
| Heartbeat / KeepAlive | 활성화 (WiFi 연결 유지) |
| WiFi 보안 | WPA2-PSK |
| Reconnect | 자동 재접속 활성화 |
IOTService 다운로드: `http://ftp.hi-flying.com:9000/IOTService/`
---
## 6. 위험 요소 및 평가
| 항목 | 평가 |
|---|---|
| 펌웨어 변경량 | 매우 적음 (CVnet 코드 복구만) |
| 하드웨어 비용 | EW11 1개 + 5V 전원 + WiFi AP |
| 응답 지연 | WiFi RTT 20~200 ms 추가 — 시나리오 제어용으론 충분 |
| 신뢰성 | WiFi 끊김 시 홈넷 단절. EW11 자동 재접속 설정 필수 |
| 보안 | EW11 자체 암호화는 제한적. 동일 LAN 내 사용 권장. 외부 노출 시 VPN/방화벽 필요 |
| 디버그 병행 | **불가** — PC 디버그 채널을 별도 UART로 이전하거나 컴파일 분리 필요 |
| 멀티드롭 충돌 | MCU와 EW11이 같은 RS-485 버스 → 마스터-슬레이브 규칙 준수 시 문제없음 |
---
## 7. 권장 진행 순서
1. **CVnet 코드 복구** — `main.c:110`, `My_Uart.c:1758` 주석 해제
2. **PC_Monitoring 조건부 컴파일 분리** — `#ifdef DEBUG_PC_MODE`로 양산 빌드에서 제외
3. **EW11 평가보드 1대 확보** 후 단독 테스트
- PC ↔ EW11(WiFi) ↔ RS-485 루프백 (송수신 확인)
- PC ↔ EW11(WiFi) ↔ RS-485 ↔ MCU 통신 (CVnet 패킷 응답 확인)
4. **응답 지연 측정** — 마스터-슬레이브 타임아웃 마진 검증
5. **장시간 안정성 시험** — WiFi 끊김/재접속 시나리오 포함
---
## 8. 참고
- EW11A 매뉴얼: `EW11_RS485 TO WIFI/EW11A manual/Elfin-EW1X_User ManualV1.3(20200415).pdf`
- 운용 가이드: `EW11_RS485 TO WIFI/EW11A manual/HF2211S_EW1X_PW1X_Wport-W30_Operation Guide(20220525).pdf`
- 소프트웨어 기능: `EW11_RS485 TO WIFI/EW11A manual/IOT_Device_Series_Software_Funtion_20250328.pdf`
- IOTService 매뉴얼: `EW11_RS485 TO WIFI/IOTService3.1.0_20220926/IOTService_User_Manual_20200708.pdf`
- 핀맵: `doc/HERV_MCU PINMAP & CONNECTOR.txt`
- HOMENET 프로토콜: `Protocol/힘펠_환기장치프로토콜V3.7_240111.xlsx`
- 시나리오 프로토콜: `Protocol/DL 환기 시나리오 제어 프로토콜_20260319-1_노량진모델하우스_2차.xlsx`
@@ -0,0 +1,134 @@
# EW11 → 인터넷(AWS) 데이터 전송 검토
- 작성일: 2026-06-03
- 목적: ERV 각실제어 데이터(신규 PC 대시보드 0xAA 프로토콜)를 **EW11(RS-485↔WiFi)** 로 인터넷 서버에 전송
- 대상 장비: Elfin EW11A (Hi-Flying)
- 관련 문서:
- `EW11A manual/IOT_Device_Series_Software_Funtion_20250328.pdf`
- `TestProgram/PC_ERV_Protocol.md` (전송할 프레임 규격)
- `260526_EW11_검토.md` (기존: 로컬 HOMENET 연결 검토)
---
## 1. 결론 요약
**EW11 사용 가능.** 가장 적합한 방식은 **EW11 = TCP Client → AWS 자체 서버(EC2/Lightsail)** 이며, 우리 0xAA 바이너리 프레임이 **투명 전송(Transparent)** 으로 그대로 서버에 도달한다(PC 대시보드의 파서/프로토콜 코드를 서버에서 재사용 가능).
- **AWS IoT Core 직결은 비권장** — IoT Core는 MQTT 연결 시 **X.509 클라이언트 인증서 기반 mutual TLS**(또는 커스텀 권한부여자)가 필수인데, EW11의 TLS는 매뉴얼상 **"no certificate method"**(클라이언트 인증서 제시 불가)라 직접 연결이 곤란하다.
- 표준 IoT 스택이 필요하면 **EC2에 Mosquitto MQTT 브로커**를 띄우고 EW11 MQTT Client(user/password + 서버 TLS)로 붙이는 방식이 차선.
---
## 2. EW11 네트워크 기능 (매뉴얼 확인)
| 기능 | 지원 | 비고 |
|---|---|---|
| TCP Client / TCP Server | ✅ | Client 모드로 외부 서버 접속 가능 |
| UDP Client / Server | ✅ | |
| Multiple Socket | ✅ | 최대 5 소켓 동시 |
| HTTP (GET/POST) | ✅ | UART 데이터를 HTTP 본문/쿼리로 래핑, 헤더 커스텀 |
| WebSocket Client | ✅ | |
| **MQTT Client** | ✅ | Version 4(MQTT 3.1.1), Client ID/User/Password, Sub/Pub Topic, QoS |
| 암호화 | ✅ | **AES**(CBC, 16자 키), **DES3**(24자), **TLS(인증서 없음)** |
| TCP Keepalive / Reconnect | ✅ | 연결 끊김 자동 재접속 |
| Register / Heartbeat | ✅ | 접속 시 장치 ID 전송, 주기적 하트비트 |
| NTP | ✅ | 시각 동기 |
> ⚠️ **TLS 제약**: "TLS use no certificate method" — 서버/클라이언트 인증서 검증을 하지 않는 단순 암호화. → AWS IoT Core(mutual TLS 강제) 직결 불가, HTTPS(API Gateway) 서버 인증서 검증도 신뢰 어려움.
---
## 3. 전송 토폴로지
```
ERV 메인보드 (RS-485, 0xAA STATUS/ACK 프레임, 115200 8N1)
│ A / B
┌────────┐
│ EW11 │ WiFi
└────────┘────────── [현장 공유기] ──── 인터넷 ────┐
(TCP Client / MQTT Client, AES 암호화) │
┌──────────────┐
│ AWS 서버 │
│ 0xAA 파서 + │
│ 저장 + 웹표시 │
└──────────────┘
```
- PC 대시보드는 (a) 기존처럼 로컬 시리얼로 직결하거나, (b) 동일 0xAA 프레임을 AWS 서버에서 받아 보는 형태로 확장 가능.
- 양방향(제어 CTRL_*)도 같은 TCP 소켓으로 가능(서버→EW11→ERV).
---
## 4. AWS 서버 옵션 비교
| 옵션 | 구성 | 우리 프로토콜 적합성 | 비용(개략) | 평가 |
|---|---|---|---|---|
| **A. EC2/Lightsail + 경량 TCP 서버** | EW11=TCP Client → 공인IP:Port. 서버가 0xAA 프레임 파싱·저장·웹표시 | ★★★ 바이너리 그대로 투명 전송, PC 파서 재사용 | t4g.nano ~$2~3/월, Lightsail $3.5~5/월 | **권장 1** |
| **B. EC2 + Mosquitto(MQTT)** | EW11=MQTT Client(user/pass+TLS) → up/down 토픽 | ★★ 표준 IoT, 장치 다수 확장 유리 | EC2 비용 + 운영 | 권장 2 |
| C. AWS IoT Core 직결 | EW11=MQTT Client | ✗ 클라이언트 인증서 필요(EW11 미지원) | 종량제 | **비권장**(브리지 필요) |
| D. HTTP→API Gateway+Lambda+DynamoDB | EW11=HTTP POST | △ HTTPS 인증서 이슈 + 바이너리 본문 번거로움 + 주기 폴링형 펌웨어 필요 | 서버리스 종량제 | 부적합 |
### 권장안 상세 (옵션 A)
1. **AWS Lightsail**(가장 운영이 간단) 또는 **EC2 t4g.nano** 1대 + 고정 공인 IP.
2. 그 위에 경량 TCP 서버(Node.js/Python) 실행 → 포트 개방.
3. EW11을 **TCP Client** 로 설정(서버 공인 IP/포트, AES 암호화, Keepalive/Register 활성).
4. 서버는 `PC_ERV_Protocol.md` 와 동일한 파서(STX 0xAA → CMD/LEN/PAYLOAD → CRC16-MODBUS 검증)로 STATUS(0x81) 73B 해석 → DB/파일 적재 → 간단 웹대시보드 또는 API 제공.
---
## 5. EW11 설정 포인트
| 항목 | 권장값 |
|---|---|
| Serial Baudrate | **115200** (신규 PC 프로토콜 기준. 기존 HOMENET 9600과 다름 주의) |
| Data/Parity/Stop | 8 / None / 1 |
| Protocol | **TCP Client** (서버 공인 IP/포트) — 또는 MQTT |
| Buffer Size | ≥ 최대 프레임(STATUS 78B 이상 여유) |
| UART Auto-Frame | Frame Time(gap) 또는 Frame Length로 프레임 경계 정렬 (단, 프로토콜에 LEN 필드 있어 서버 재조립도 가능) |
| Security | AES(16자 키) 활성 + 서버 동일 키 복호화 |
| Keepalive / Reconnect / Heartbeat | 활성 |
| NTP | 활성(타임스탬프 정확도) |
설정 도구: IOTService (`IOTService3.1.0_20220926/`) 또는 내장 웹페이지.
---
## 6. 보안 / 위험 평가
| 항목 | 평가 / 대책 |
|---|---|
| EW11 TLS 신뢰성 | "no certificate" → MITM 방어 약함. **AES 암호화 병행** 필수 |
| 공인 포트 노출 | EC2 보안그룹 inbound를 **현장 공인 IP로 제한**, 불필요 포트 폐쇄 |
| 권장 폐쇄망 | 가능하면 VPN / 4G 라우터 / 사설망 경유 |
| WiFi 끊김 | EW11 자동 재접속 + 서버측 연결 상태 모니터링 |
| 응답 지연 | WiFi+인터넷 RTT 수십~수백 ms. 모니터링/시나리오 제어엔 충분, 실시간 폐루프 제어엔 부적합 |
| 트래픽 | STATUS 78B를 1초 주기 전송 시 ≈ 200MB/월 → 비용 영향 미미 |
---
## 7. 권장 진행 순서
1. AWS **Lightsail nano**(또는 EC2 t4g.nano) 1대 생성 + 고정 IP.
2. 경량 TCP 서버 작성(0xAA 파서는 `DashboardProtocol` 로직 이식) → 수신 로그 확인.
3. EW11 1대 확보 → IOTService로 TCP Client + AES + 115200 설정.
4. **루프백 시험**: PC(0xAA 프레임 송신) → EW11 → 서버 수신 파싱 검증.
5. **실연동**: ERV → EW11 → 서버 STATUS 적재 확인.
6. 보안그룹/암호화/재접속 안정성 시험 후 운영.
---
## 8. 비용 메모 (2026-06 기준, 변동 가능)
- **EC2 t4g.nano**: us-east-1 약 $2~3/월 (ARM Graviton, 0.5GB RAM). t4g.small은 2026-12-31까지 750h/월 무료 체험.
- **신규 AWS 계정**: 12개월 t3.micro 750h/월 무료.
- **Lightsail**: 최저 $3.5/월(IPv6, 512MB/2vCPU/20GB/1TB), 공인 IPv4 포함 $5/월. 고정요금이라 가장 단순.
- AWS IoT Core: 종량제(연결/메시지/규칙별 과금). 본 케이스에선 비권장.
---
## 9. 참고
- EW11 소프트웨어 기능: `EW11A manual/IOT_Device_Series_Software_Funtion_20250328.pdf` (1.4 Working Mode, 1.5 암호화, 1.17 Register, 1.18 Heartbeat)
- AWS IoT Core 클라이언트 인증: https://docs.aws.amazon.com/iot/latest/developerguide/client-authentication.html
- 전송 프레임 규격: `TestProgram/PC_ERV_Protocol.md`
Binary file not shown.
@@ -0,0 +1,3 @@
IOTService工具下载地址:
IOTService tools download address
http://ftp.hi-flying.com:9000/IOTService/
@@ -0,0 +1,262 @@
# PCDashBoard ↔ ERV 통신 — API 함수 & 프로토콜 상세 (2026.06.07)
PC 대시보드(ErvDashboard) ↔ ERV 메인보드 통신을 **485 프로토콜을 그대로 사용하되 `IErvApi` 함수로 감싼**
구조(인프로세스 API 파사드)로 정리한 문서. 선로 통신은 변함없이 485 프레임이며, UI 코드는 바이트/프레임을
다루지 않고 의미 있는 API 함수만 호출한다.
---
## 0. 구조 개요
```
[MainWindow.xaml.cs (UI)]
│ _api.SetPower(true) / SetFan(2) / RequestStatus() ...
[IErvApi] ←─ 인터페이스 (TestProgram/PCDashBoard/Api/IErvApi.cs)
[SerialErvApi] ←─ 구현 (Api/SerialErvApi.cs)
│ CtrlFrame 으로 485 프레임 빌드 / FrameParser 로 STATUS 파싱
[SerialChannel] ── RS485 / USB-Serial ── [ERV 메인보드]
```
- 송신 : `IErvApi.SetXxx()``CtrlFrame.Xxx()``0xAA … CRC16` 프레임 생성 → 시리얼 전송
- 수신 : 시리얼 바이트 → `FrameParser``STATUS(0x81)` 디코드 → `StatusReceived(StatusRecord)` 이벤트
- UI 매핑 : `StatusRecord``DashboardState``StatusMapper.Apply()`(UI 측)가 담당 → API 는 UI 비종속
### 파일 구성
| 파일 | 역할 |
|---|---|
| `PCDashBoard/Api/IErvApi.cs` | 통신 API 인터페이스(함수/이벤트 정의) |
| `PCDashBoard/Api/SerialErvApi.cs` | RS485 구현(SerialChannel + FrameParser + CtrlFrame 내장) |
| `PCDashBoard/Api/StatusMapper.cs` | `StatusRecord → DashboardState` 매핑 |
| `PCDashBoard/Protocol/SerialChannel.cs` | 시리얼 포트 송수신(byte) |
| `ErvProtocol/CtrlFrame.cs` | PC→ERV 제어 프레임 빌더 |
| `ErvProtocol/StatusDecoder.cs` | ERV→PC STATUS 디코더 |
| `ErvProtocol/FrameParser.cs` | 0xAA/LEN/CRC 프레임 분리 |
| `ErvProtocol/Enums.cs` | RunMode/SubModeType/HystPreset/AirQuality/AutoState |
---
## 1. API 함수 (IErvApi)
### 1.1 연결
| 함수 / 속성 | 설명 |
|---|---|
| `bool Connect(string port, int baud)` | 시리얼 연결 (대시보드 기본 115200) |
| `void Disconnect()` | 연결 해제 |
| `bool IsConnected { get; }` | 연결 여부 |
| `static string[] SerialErvApi.GetAvailablePorts()` | 사용 가능한 COM 포트 목록 |
| `event Action<bool> ConnectionChanged` | 연결 상태 변경 통지 |
### 1.2 수신
| 함수 / 이벤트 | 설명 |
|---|---|
| `event Action<StatusRecord> StatusReceived` | STATUS(0x81) 디코드 완료 시 발생 |
| `event Action<string> Log` | 프레임 hex / 이벤트 로그 |
| `void RequestStatus()` | STATUS 1회 즉시 요청 (CMD 0x0A) |
| `bool GetRoomStatus(int room, out bool damperSa, out bool damperEa, out AirQuality airQuality, out int led)` | 최근 STATUS 기준 각실(room 1~4) 급기·배기 댐퍼/공기질/LED 조회. 수신 이력 없거나 room 범위 밖이면 false |
### 1.3 제어 함수 ↔ 프로토콜 CMD 매핑
| API 함수 | CMD | PAYLOAD | 비고 |
|---|---|---|---|
| `SetPower(bool on)` | 0x01 | `[onoff]` | 전원 ON/OFF |
| `SetRunMode(RunMode mode)` | 0x02 | `[mode]` | 환기/자동/공청/바이패스 |
| `SetFan(int speed)` | 0x03 | `[speed]` | 0~4, 자동모드 시 무시 |
| `SetSubMode(SubModeType type, bool on)` | 0x04 | `[type][onoff]` | 스마트수면/쾌적조리/안심회복 |
| `SetHood(bool on)` | 0x05 | `[onoff]` | 연동후드 |
| `SetHystPreset(HystPreset preset)` | 0x06 | `[preset]` | ECO/NORMAL/TURBO |
| `SetHystDeadband(int preset, int pm25, int pm10, int voc, int co2)` | 0x07 | `[preset][pm25(2)][pm10(2)][voc(2)][co2(2)]` | 프리셋별 히스(하강) 값 |
| `SetDiffuserDamper(int room, int type, bool open)` | 0x08 | `[room][type][onoff]` | room 1~4, type 0=급기(SA)/1=배기(EA) |
| `SetDiffuserLed(int room, int dim)` | 0x09 | `[room][dim]` | dim 0~9 |
| `RequestStatus()` | 0x0A | (없음) | 상태 요청 |
| `SetReset(bool on)` | 0x0B | `[onoff]` | ERV 리셋 토글 |
| `SetVsp(int group, int index, int sa, int ea)` | 0x0C | `[group][index][sa(2)][ea(2)]` | 풍량 VSP 1엔트리 |
| `SetHystThreshold(int preset, int pollutant, int l1, int l2, int l3, int l4)` | 0x0D | `[preset][pollutant][L1(2)][L2(2)][L3(2)][L4(2)]` | 오염단계 임계 |
| `SetReserve(int hours)` | 0x0E | `[hours]` | (꺼짐)예약 0~8시간(0=해제) |
- `room` : 1=거실, 2=침실1, 3=침실2, 4=침실3
- `type`(댐퍼) : 0=급기(SA) / 1=배기(EA)
- `group` : 0=환기(Vent) / 1=바이패스(Bypass) / 2=공청(AirClean)
- `pollutant` : 0=CO2 / 1=PM2.5 / 2=PM10 / 3=VOC
- 멀티바이트(2) 값은 **빅엔디안**
### 1.4 데모
| 함수 | 설명 |
|---|---|
| `void InjectDemoStatus(int tick)` | 합성 STATUS 를 수신 경로로 주입(시리얼 없이 UI 테스트) |
### 1.5 enum 정의 (ErvProtocol/Enums.cs)
```
RunMode : Off=0, Vent=1, Auto=2, AirClean=3, Bypass=4
AutoState : Distribute=0(분산), Focus=1(집중)
HystPreset : Eco=0, Normal=1, Turbo=2
AirQuality : VeryBad=1, Bad=2, Normal=3, Good=4
SubModeType : SmartSleep=1, ComfortCook=2, ReliefRecover=3
```
---
## 2. 사용법
### 2.1 초기화 / 이벤트 등록
```csharp
readonly DashboardState _state = new();
readonly IErvApi _api = new SerialErvApi();
// 로그 / 연결상태 / STATUS 수신
_api.Log += Log;
_api.ConnectionChanged += b => Dispatcher.BeginInvoke(() => OnConnectionChanged(b));
_api.StatusReceived += rec => Dispatcher.BeginInvoke(() =>
{
StatusMapper.Apply(rec, _state); // StatusRecord → UI 모델
LogStatusSnapshot();
});
```
> `SerialErvApi` 이벤트는 시리얼 수신(백그라운드) 스레드에서 발생하므로, UI 갱신은
> `Dispatcher.BeginInvoke` 로 마샬링한다.
### 2.2 연결 / 상태 요청
```csharp
var ports = SerialErvApi.GetAvailablePorts(); // COM 목록
_api.Connect("COM3", 115200); // 연결
_api.RequestStatus(); // 최초 STATUS 요청
...
_api.Disconnect();
```
### 2.3 제어 송신 (예)
```csharp
_api.SetPower(true); // 전원 ON
_api.SetRunMode(RunMode.Auto); // 자동
_api.SetFan(2); // 풍량 2단
_api.SetSubMode(SubModeType.ComfortCook, true); // 쾌적조리 ON
_api.SetHood(true); // 연동후드 ON
_api.SetReserve(3); // 3시간 후 꺼짐 예약
_api.SetDiffuserDamper(1, 0, true); // 거실 급기(SA) 댐퍼 열림
_api.SetDiffuserDamper(1, 1, false); // 거실 배기(EA) 댐퍼 닫힘
_api.SetDiffuserLed(2, 5); // 침실1 LED 디밍 5
// 풍량 VSP (환기2단 SA/EA)
_api.SetVsp(group:0, index:2, sa:63, ea:61);
// 히스테리시스
_api.SetHystPreset(HystPreset.Normal);
_api.SetHystDeadband(preset:1, pm25:2, pm10:5, voc:5, co2:50);
_api.SetHystThreshold(preset:1, pollutant:0 /*CO2*/, l1:700, l2:1000, l3:1300, l4:1600);
```
### 2.4 수신 처리
`StatusReceived``StatusMapper.Apply(rec, state)``DashboardState`(전원/모드/풍량/각실 센서·
댐퍼·LED·부하점수·온습도/VSP/히스테리시스/임계표) 전체가 갱신된다.
각실 일부 항목만 즉시 조회할 때는 `GetRoomStatus` 사용(최근 수신 STATUS 기준) :
```csharp
if (_api.GetRoomStatus(1, out bool sa, out bool ea, out AirQuality aq, out int led))
Console.WriteLine($"거실: 급기={(sa ? "" : "")} 배기={(ea ? "" : "")} 공기질={aq} LED={led}");
```
---
## 3. 프로토콜 상세
### 3.1 물리계층
- RS-485 (또는 USB-Serial), **115200 bps, 8 Data, None Parity, 1 Stop (N81)**
### 3.2 공통 프레임 (Rev2.0 — 244byte 고정)
```
+------+------+----------------+--------+--------+
| STX | CMD | DATA[240] | CRC_L | CRC_H |
+------+------+----------------+--------+--------+
0xAA 1B 240B 고정 16-bit CRC(LE)
```
| 필드 | 크기 | 설명 |
|---|---|---|
| STX | 1 | 고정 `0xAA` |
| CMD | 1 | 명령/응답 코드 |
| DATA | 240 | **고정 240byte.** 제어는 앞쪽 인자 + 나머지 `0` 패딩, ERV→PC 는 STATUS/ACK 데이터 |
| CRC | 2 | **CRC-16/MODBUS**(poly 0xA001, init 0xFFFF), **CMD~DATA(241byte)** 범위, **리틀엔디안** |
- **LEN 필드 폐기.** 모든 프레임은 항상 `1+1+240+2 = 244byte` 고정. 파서는 정해진 바이트 수만 읽고 CRC 검증.
- 멀티바이트 수치는 **빅엔디안**(CRC만 리틀엔디안)
- 프레임 동기 : STX(0xAA) 탐색 + 고정길이 수신 + CRC 검증. 60ms 이상 바이트 공백 시 파서 리셋.
### 3.3 명령 코드
**PC → ERV (제어)** : 1.3 표 참조 (0x01~0x0E)
**ERV → PC (상태/응답)**
| CMD | 이름 | PAYLOAD | 설명 |
|---|---|---|---|
| 0x81 | STATUS | DATA 240 byte (3.5) | 전체 상태 스냅샷 (주기 송신 + REQ_STATUS 응답) |
| 0x82 | ACK | DATA `[echoCmd][result]` + 0패딩 | 제어 수신 응답 result 0=OK / 1=ERR |
### 3.4 값 정의
- 운전모드 : 0 OFF / 1 환기 / 2 자동 / 3 공청 / 4 바이패스
- 부가모드 type : 1 스마트수면 / 2 쾌적조리 / 3 안심회복 (STATUS subMode 는 비트맵 bit0/1/2)
- 공기질 : 1 매우나쁨(빨강) / 2 나쁨(주황) / 3 보통(초록) / 4 좋음(파랑)
- 자동상태 : 0 분산 / 1 집중
- 히스 프리셋 : 0 ECO / 1 NORMAL / 2 TURBO
### 3.5 STATUS(0x81) PAYLOAD 레이아웃 — **총 240 byte**
| 블록 | offset | 크기 | 내용 |
|---|---|---|---|
| 글로벌 | 0~16 | 17 | power, runMode, autoState, fanMode, subMode, hood, hystPreset, hystPM25(7,2), hystPM10(9,2), hystVOC(11,2), hystCO2(13,2), errorCode(15,2) |
| 각실 ×4 | 17~72 | 14×4=56 | 거실→침실1→2→3, 아래 표 |
| 리셋 | 73 | 1 | reset 토글 0/1 |
| VSP | 74~109 | 4×9=36 | 환기1~4, 바이패스, 공청1~4 의 SA(2)+EA(2) |
| 히스 프리셋표 | 110~133 | 8×3=24 | ECO/NORMAL/TURBO 의 PM2.5(2)/PM10(2)/VOC(2)/CO2(2) |
| 오염단계 임계표 | 134~229 | 32×3=96 | 프리셋×[CO2,PM2.5,PM10,VOC] 각 L1~L4 u16 |
| 각실 온습도 ×4 | 230~237 | 2×4=8 | 실별 Temp(1)+Humi(1) |
| (꺼짐)예약 | 238~239 | 2 | 잔여 초 u16 (0=예약없음) |
**각실 블록(14 byte) 상세** (상대 offset)
| +off | 크기 | 필드 |
|---|---|---|
| +0 | 1 | damper 비트맵 (bit0 급기 열림 / bit1 배기 열림) |
| +1 | 2 | pm25 |
| +3 | 2 | pm10 |
| +5 | 2 | voc |
| +7 | 2 | co2 |
| +9 | 1 | airQuality (3.4) |
| +10 | 1 | ledDim (0~9) |
| +11 | 2 | loadScore (부하점수) |
| +13 | 1 | finalVolume (최종풍량) |
**오염단계 임계표(프리셋당 32 byte)** : `CO2[L1..L4]` `PM2.5[L1..L4]` `PM10[L1..L4]` `VOC[L1..L4]` 순, 각 u16(빅엔디안).
> 계산식 : 17 + 56 + 1 + 36 + 24 + 96 + 8 + 2 = **240**
### 3.6 동작 시나리오
1. 연결 후 `RequestStatus()(0x0A)` 송신 → ERV 가 `STATUS(0x81)` 응답.
2. ERV 는 약 500ms~1s 주기로 `STATUS` 자동 송신 → 대시보드 실시간 갱신.
3. PC 제어 시 해당 `CTRL_*` 송신 → ERV 가 `ACK(0x82)` + 다음 STATUS 에 반영.
4. PC 는 STATUS 수신 시마다 로그(시각 + 전체 상태) 적재.
---
## 4. 비고
- 본 문서의 API(IErvApi) 도입은 **전송 방식(485 프로토콜)을 바꾸지 않는다.** 호출부를 함수로 캡슐화한 것.
- `IErvApi` 는 인터페이스이므로, 향후 다른 전송(예: HTTP) 구현(`HttpErvApi`)을 추가해 설정으로 교체 가능.
- 펌웨어(UART1/HOMENET_485) 세부 필드는 상호 합의하여 조정한다. (원본 초안: `TestProgram/PC_ERV_Protocol.md`)
+207
View File
@@ -0,0 +1,207 @@
# HuevenEco DL 각실제어 대시보드 ↔ ERV 메인보드 통신 프로토콜 (Rev2.0 고정패킷)
- 물리계층 : RS-485 (또는 USB-Serial), **115200 bps, 8 Data, None Parity, 1 Stop (N81)**
- 역할 : PC 대시보드(Host) ↔ ERV 메인보드(Main Board)
- **Rev2.0 변경점** : 가변 길이(`LEN` 필드) 프레임을 폐기하고 **모든 프레임을 244byte 고정**으로 통일.
파서는 "STX 탐색 → 정해진 바이트 수만 읽기 → CRC 검증"으로 단순화된다. CMD 의미·STATUS 레이아웃·CRC 방식은 종전과 동일.
---
## 1. 공통 프레임 (244byte 고정)
```
+------+------+------------------+--------+--------+
| STX | CMD | DATA[240] | CRC_L | CRC_H |
+------+------+------------------+--------+--------+
0xAA 1B 240B 고정 16-bit CRC
```
| 필드 | 크기 | 설명 |
|----------|------|-------------------------------------------------------------|
| STX | 1 | 고정 `0xAA` |
| CMD | 1 | 명령/응답 코드 (2장) |
| DATA | 240 | **고정 240byte.** PC→ERV 제어는 앞쪽에 인자 + 나머지 `0` 패딩, ERV→PC 는 STATUS/ACK 데이터 |
| CRC | 2 | **CRC-16/MODBUS** (poly 0xA001, init 0xFFFF), **CMD~DATA(241byte)**, **리틀엔디안** |
- **LEN 필드 없음.** 모든 프레임은 정확히 `1+1+240+2 = 244byte`.
- 멀티바이트 수치는 모두 **빅엔디안(상위 바이트 먼저)** 으로 표기한다. (CRC만 리틀엔디안)
- 프레임 동기 : STX(0xAA) 탐색 후 고정 길이로 수신, CRC 로 검증. 바이트 공백(예 60ms) 발생 시 파서를 STX 탐색으로 리셋해 재동기.
---
## 2. 명령 코드 (CMD)
DATA 앞쪽에 아래 인자를 싣고 나머지는 `0` 으로 패딩한다.
### 2.1 PC → ERV (제어)
| CMD | 이름 | DATA 앞쪽 인자 | 설명 |
|------|-----------------|-------------------------------------------|-----------------------------------|
| 0x01 | CTRL_POWER | `[onoff]` | 전원 0=OFF / 1=ON |
| 0x02 | CTRL_RUNMODE | `[mode]` | 운전모드 (3.1 참조) |
| 0x03 | CTRL_FAN | `[speed]` | 풍량 0~4 (자동모드에서는 무시) |
| 0x04 | CTRL_SUBMODE | `[type][onoff]` | 부가모드 토글 (3.2 참조) |
| 0x05 | CTRL_HOOD | `[onoff]` | 연동후드 0=OFF / 1=ON |
| 0x06 | CTRL_HYST_PRESET| `[preset]` | 히스테리시스 프리셋 0=ECO/1=NORMAL/2=TURBO |
| 0x07 | CTRL_HYST_VALUE | `[preset][pm25(2)][pm10(2)][voc(2)][co2(2)]` | 프리셋별 히스(하강) 데드밴드 설정 (값 BE) |
| 0x08 | CTRL_DAMPER | `[room][type][onoff]` | 각실 댐퍼 type 0=급기(SA)/1=배기(EA), 0=닫힘 / 1=열림 |
| 0x09 | CTRL_LED | `[room][dim]` | 각실 LED 디밍 0~9 |
| 0x0A | REQ_STATUS | (없음, 전부 0) | 상태 1회 즉시 요청 |
| 0x0B | CTRL_RESET | `[onoff]` | ERV 리셋 토글 0/1 |
| 0x0C | CTRL_VSP | `[group][index][sa(2)][ea(2)]` | 풍량 VSP 값 설정 (3.5 참조, sa/ea BE) |
| 0x0D | CTRL_HYST_THR | `[preset][pollutant][L1(2)][L2(2)][L3(2)][L4(2)]` | 오염단계 임계 설정 (pollutant 0=CO2/1=PM2.5/2=PM10/3=VOC, 값 BE) |
| 0x0E | CTRL_RESERVE | `[hours]` | (꺼짐)예약 0~8시간(0=해제) |
- `room` : 1=거실, 2=침실1, 3=침실2, 4=침실3
### 2.2 ERV → PC (상태/응답)
| CMD | 이름 | DATA | 설명 |
|------|-----------|--------------------|---------------------------------------------|
| 0x81 | STATUS | 240byte (4장 참조) | 전체 상태 스냅샷 (주기 송신 + REQ_STATUS 응답) |
| 0x82 | ACK | `[echoCmd][result]` + 0패딩 | 제어 명령 수신 응답 result 0=OK / 1=ERR |
---
## 3. 값 정의
### 3.1 운전모드 (RunMode)
| 코드 | 의미 | 펌웨어 매핑 |
|------|--------|----------------------------|
| 0x00 | OFF | Power_On = 0 |
| 0x01 | 환기 | MODE_VENTILATION (0) |
| 0x02 | 자동 | MODE_AUTO (1) |
| 0x03 | 공청 | MODE_AIRCLEAN (2) |
| 0x04 | 바이패스 | MODE_BYPASS (3) |
### 3.2 부가모드 (SubMode)
`CTRL_SUBMODE``type` :
| type | 의미 | 펌웨어 매핑 |
|------|------------|----------------------|
| 0x01 | 스마트수면 | Ext_Run_Mode = 4 |
| 0x02 | 쾌적조리 | Hood_YeunDong_Enable |
| 0x03 | 안심회복 | Ext_Run_Mode = 1 |
STATUS 의 `subMode` 는 비트맵 : bit0=스마트수면, bit1=쾌적조리, bit2=안심회복
### 3.3 공기질 상태 (AirQuality)
| 코드 | 등급 | 색상 |
|------|-----------|--------|
| 0x01 | 매우나쁨 | 빨강 |
| 0x02 | 나쁨 | 주황 |
| 0x03 | 보통 | 초록 |
| 0x04 | 좋음 | 파랑 |
### 3.4 자동운전 상태 (AutoState)
| 코드 | 의미 |
|------|--------|
| 0x00 | 분산 |
| 0x01 | 집중 |
### 3.5 풍량 VSP (CTRL_VSP 0x0C / STATUS VSP 블록)
급기(SA)/배기(EA) 풍량 설정값. `CTRL_VSP` 로 한 엔트리씩 설정, STATUS 로 전체 표시.
- `group` : `0`=환기(Vent), `1`=바이패스(Bypass), `2`=공청(AirClean)
- `index` : 환기/공청 `1~4`, 바이패스 `1`
- `sa`,`ea` : 각 **u16 빅엔디안**
**STATUS VSP 9 엔트리 순서** (각 SA,EA): `환기1, 환기2, 환기3, 환기4, 바이패스, 공청1, 공청2, 공청3, 공청4`
---
## 4. STATUS(0x81) DATA 레이아웃 (240 byte)
| 블록 | offset | 크기 | 내용 |
|---|---|---|---|
| 글로벌 | 0~16 | 17 | power, runMode, autoState, fanMode, subMode, hood, hystPreset, hystPM25(7,2), hystPM10(9,2), hystVOC(11,2), hystCO2(13,2), errorCode(15,2) |
| 각실 ×4 | 17~72 | 14×4=56 | 거실→침실1→2→3, 4.2 표 |
| 리셋 | 73 | 1 | reset 토글 0/1 |
| VSP | 74~109 | 4×9=36 | 환기1~4, 바이패스, 공청1~4 의 SA(2)+EA(2) |
| 히스 프리셋표 | 110~133 | 8×3=24 | ECO/NORMAL/TURBO 의 PM2.5(2)/PM10(2)/VOC(2)/CO2(2) |
| 오염단계 임계표 | 134~229 | 32×3=96 | 프리셋×[CO2,PM2.5,PM10,VOC] 각 L1~L4 u16 |
| 각실 온습도 ×4 | 230~237 | 2×4=8 | 실별 Temp(1)+Humi(1) |
| (꺼짐)예약 | 238~239 | 2 | 잔여 초 u16 (0=예약없음) |
> 계산식 : 17 + 56 + 1 + 36 + 24 + 96 + 8 + 2 = **240 byte**
### 4.1 글로벌 (offset 0~16, 17 byte)
| off | 크기 | 필드 | 비고 |
|-----|------|---------------|----------------------------------------|
| 0 | 1 | power | Power_On |
| 1 | 1 | runMode | 3.1 |
| 2 | 1 | autoState | 3.4 (분산/집중) |
| 3 | 1 | fanMode | 0~4 (Fan_Mode) |
| 4 | 1 | subMode | 비트맵 (3.2) |
| 5 | 1 | hood | bit0 Hood_YeunDong_Enable / bit1 Hood_Status |
| 6 | 1 | hystPreset | 0 ECO / 1 NORMAL / 2 TURBO |
| 7 | 2 | hystPM25 | 활성 프리셋 데드밴드 PM2.5 |
| 9 | 2 | hystPM10 | 활성 프리셋 데드밴드 PM10 |
| 11 | 2 | hystVOC | 활성 프리셋 데드밴드 VOC |
| 13 | 2 | hystCO2 | 활성 프리셋 데드밴드 CO2 |
| 15 | 2 | errorCode | 비트맵 (Err_Code) |
### 4.2 각실 블록 (offset 17~, 14 byte × 4실 = 56 byte)
순서: 거실 → 침실1 → 침실2 → 침실3
| off(상대) | 크기 | 필드 | 비고 |
|-----------|------|-------------|-----------------------------------|
| +0 | 1 | damper | 비트맵 bit0=급기(SA) 열림 / bit1=배기(EA) 열림 |
| +1 | 2 | pm25 | SEN66_pm2p5 |
| +3 | 2 | pm10 | SEN66_pm10p0 |
| +5 | 2 | voc | SEN66_VOC_value |
| +7 | 2 | co2 | SEN66_CO2_value |
| +9 | 1 | airQuality | 3.3 |
| +10 | 1 | ledDim | 0~9 (Light_Bright) |
| +11 | 2 | loadScore | 각실 부하점수 (Room_Level) |
| +13 | 1 | finalVolume | 최종 풍량 (Fan_Mode) |
### 4.3 VSP 블록 (offset 74~109, 9엔트리 × SA(2)·EA(2) = 36 byte)
순서(3.5): 환기1~4 → 바이패스 → 공청1~4. 각 엔트리 SA(2) + EA(2), 빅엔디안.
### 4.4 히스 데드밴드 프리셋표 (offset 110~133, 3프리셋 × PM2.5/PM10/VOC/CO2 u16 = 24 byte)
프리셋(ECO/NORMAL/TURBO)별 하강 데드밴드. 현재 적용값은 글로벌 `hystPM25~hystCO2`(off 7~14) = 본 표의 `[hystPreset]` 행.
### 4.5 오염단계 임계표 (offset 134~229, 3프리셋 × 32byte = 96 byte)
프리셋당 `CO2[L1..L4]` `PM2.5[L1..L4]` `PM10[L1..L4]` `VOC[L1..L4]` 순, 각 u16(빅엔디안).
### 4.6 각실 온습도 (offset 230~237, 4실 × Temp(1)+Humi(1) = 8 byte)
순서: 거실 → 침실1 → 침실2 → 침실3. 디퓨저 SEN66 값, 0~255 클램프.
### 4.7 (꺼짐)예약 잔여초 (offset 238~239, u16 BE)
전원 자동 OFF 까지 잔여 초. 0 = 예약 없음.
---
## 5. 동작 시나리오
1. PC 연결 후 `REQ_STATUS(0x0A)` 송신 → ERV 가 `STATUS(0x81)` 응답.
2. ERV 는 약 500ms~1s 주기로 `STATUS(0x81)` 를 자동 송신 (대시보드 실시간 갱신).
3. PC 가 토글/슬라이드/선택 시 해당 `CTRL_*` 송신 → ERV 가 `ACK(0x82)` + 다음 STATUS 에 반영.
4. PC 는 STATUS 수신 시마다 로그(날짜·시간 + 전체 상태)에 적재/저장.
---
## 6. 구현 매핑
| 계층 | 파일 | 역할 |
|---|---|---|
| 펌웨어 | `program/User/My_Homenet.c` | 244B 고정 프레임 수신/송신, CMD 적용, STATUS 빌드 |
| C# 공용 | `TestProgram/ErvProtocol/CtrlFrame.cs` | 244B 제어 프레임 빌더 |
| C# 공용 | `TestProgram/ErvProtocol/FrameParser.cs` | 고정 길이 프레임 분리 + CRC 검증 |
| C# 공용 | `TestProgram/ErvProtocol/StatusDecoder.cs` / `StatusEncoder.cs` | STATUS 240B 디코드/인코드 |
| C# 대시보드 | `TestProgram/PCDashBoard/Api/IErvApi.cs` / `SerialErvApi.cs` | API 파사드(함수 호출 → 프레임) |
> API 함수 목록·사용법은 `260607_PCDashBoard_API_및_프로토콜.md` 1·2장 참조. 본 문서가 와이어 프로토콜 정본이다.
+215
View File
@@ -0,0 +1,215 @@
==========History==========
각실제어 프로토콜 | | | | | | | | | | | | | |
VERSION | | | DATE | | | | DESCRIPTER | | | | DESCRIPTION | | |
V 1.0 | | | 2024-11-20 00:00:00 | | | | 강은철 / 주신전자 | | | | 초 안 | | |
V 1.1 | | | 2024-12-18 00:00:00 | | | | 안준형 / 주신전자 | | | | 룸콘 응답 패킷 추가
Run Mode 수정
Fan Speed 수정
에러코드 패킷 내용 수정 | | |
1. 통신규격 | | | | | | | | | | | | | |
| 가. 통신방식 : RS-485 | | | | | | | | | | | | |
| 나. 통신속도 : 115200 bps | | | | | | | | | | | | |
| 다. 데이터 구성 : 1 Start Bit, 8 Data Bit, 1 Stop Bit, None Parity | | | | | | | | | | | | |
==========Main->Slave==========
| Protocol (UART - 115,200bps) | | | | | | | | | | | | | | | | | | | | | | | | | | | | |
| 1. Normal Polling Packet | | | | | | 메인보드(Master) -> 분배기_디퓨져(Slave) | | | | | | | | | | | | | | | | | | | | | | |
| byte | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | | | | | | |
| 내용 | 0xAA | M/S 0x10 | ID 1 | ID 2 | VSP Mode | Power | Run Mode | Fan Speed | LED 밝기 | 공기질 상태 | 댐퍼 각도(SA) | 댐퍼 각도(RA) | SA RPM | | EA RPM | | SA Reset | RA Reset | 예약(Hour) | 예비(0x00) | 예비(0x00) | 예비(0x00) | | | | | | |
| | | | | | | | | | | | | | | | | 22 | 23 | 24 | 25 | 26 | 27 | 28 | | | | | | |
| | | | | | | | | | | | | | | | | 예비(0x00) | Version | | Error Code | | CRC | | | | | | | |
| < M/S > byte1 | | | | | | | | < Fan Speed > | | | | Control Cmd | 0x80 | | | < SA RPM > | | | | | | | | | | | | | |
| 0x10 : Master ( Main BxD ) | | | | | | | | 0x00 : OFF | | | | | | | 전열교환기 SA RPM | | | | | | | | | | | | | |
| 0x01 : Slave ( Display, Sensor Pack … ) | | | | | | | | 0x01 ~ 0x03 : 1~3 | | | | | | | | | | | | | | | | | | | | |
| | | | | | | | | | | | | | | | < EA RPM > | | | | | | | | | | | | | |
| < ID 1 > | | | | | | | | < LED > byte8 | | | | Control Cmd | 0x80 | | | 전열교환기 EA RPM | | | | | | | | | | | | | |
| 0x01 : SA Diffuser | | | | | | | | 디퓨저의 RGB LED 밝기 제어 | | | | | | | | | | | | | | | | | | | | |
| 0x02 : RA Diffuser | | | | | | | | 0x00 : OFF | | | | | | | < SA Reset > | | | | | | | | | | | | | |
| 0x03 : Room Controller | | | | | | | | 0x01 ~ 0x0A : 1 ~ 10단 (10단: 최대) | | | | | | | SA댐퍼 초기화 데이터 | | | | | | | | | | | | | |
| < ID 2 > | | | | | | | | < 공기질 상태 > | | | | | | | < RA Reset > | | | | | | | | | | | | | |
| 0x01 ~ 0x06 : SA/RA/RoomCon ID | | | | | | | | 디퓨저의 RGB LED 색상 표시 | | | | | | | EA댐퍼 초기화 데이터 | | | | | | | | | | | | | |
| * RoomCon ID는 해당 SA Diffuser 같음. | | | | | | | | 0x01 : 매우나쁨 - 빨강 | | | | | | | | | | | | | | | | | | | | |
| | | | | | | | | 0x02 : 나쁨 - 주황 | | | | | | | < 예약 > | | | | | | | | | | | | | |
| < VSP Mode > : Diffuser는 해당 사항 없고, RoomCon ID 1 에 해당함 | | | | | | | | 0x03 : 보통 - 초록 | | | | | | | 시간(hour) 단위 | | | | | | | | | | | | | |
| 0x01, 0x02, 0x03, 0x10 : 아래내용 참고 | | | | | | | | 0x04 : 좋음 - 파랑 | | | | | | | | | | | | | | | | | | | | |
| | | | | | | | | * 공기질 판단 기준은 다음 Sheet 참조 | | | | | | | < 예비 > | | | | | | | | | | | | | |
| < Power > | | | | Control Cmd | 0x80 | | | | < 댐퍼각도 SA > | | | | | | | | | | | | | | | | | | | | |
| 0x00 / 0x01 : Power On / Off | | | | | | | | 디퓨저 각도 제어(0~180도) | | | | | | | | | | | | | | | | | | | | |
| | | | | | | | | 0x00 ~ 0xB4 | | | | | | | | | | | | | | | | | | | | |
| < Run Mode > | | | | Control Cmd | 0x80 | | | | < 댐퍼각도 RA > | | | | | | | | | | | | | | | | | | | | |
| 0x01 : 수동 | | | | | | | | 디퓨저 각도 제어(0~180도) | | | | | | | | | | | | | | | | | | | | |
| 0x02 : 자동 | | | | | | | | 0x00 ~ 0xB4 | | | | | | | 예비 | | | | | | | | | | | | | |
| 0x04 : 바이패스 | | | | | | | | | | | | | | | | | | | | | | | | | | | | |
| 0x08 : 공기청정 | | | | | | | | | | | | | | | < Version > | | | | | | | | | | | | | |
| | | | | | | | | | | | | | | | 예) Ver 1.23 | | | | | | | | | | | | | |
| | | | | | | | | | | | | | | | byte23 : 0x01 | | | | | | | | | | | | | |
| | | | | | | | | | | | | | | | byte24 : 0x17 | | | | | | | | | | | | | |
| < Error Code Echo> | | | | | | | | | | | | | | | | | | | | | | | | | | | | |
| 0x0001 : 필터 청소 | | | | 0x0010 : 장비보호모드 | | | | 0x0100 : 통합센서 에러(거실) | | | | | 0x1000 : 통신에러(거실) | | | | | | | | | | | | | | | |
| 0x0002 : 필터 교체 | | | | 0x0020 : EA 팬 에러 | | | | 0x0200 : 통합센서 에러(방1) | | | | | 0x2000 : 통신에러(방1) | | | | | | | | | | | | | | | |
| 0x0004 : 소자 교체 | | | | 0x0040 : 간헐운전모드 | | | | 0x0400 : 통합센서 에러(방2) | | | | | 0x4000 : 통신에러(방2) | | | | | | | | | | | | | | | |
| 0x0008 : 온도센서 에러 | | | | 0x0080 : SA 팬 에러 | | | | 0x0800 : 통합센서 에러(방3) | | | | | 0x8000 : 통신에러(방3) | | | | | | | | | | | | | | | |
| 2. Nomal Mode ( Vsp Mode = 0 ) | | | | | | 분배기_디퓨져(Slave) -> 메인보드 ( Master ) | | | | | | | | | | | | | | | | | | | | | | |
| byte | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | | | | | | |
| 내용 | 0xAA | M/S 0x01 | ID 1 | ID 2 | VSP Mode | Power | Run Mode | Fan Speed | LED 밝기 | 공기질 상태 | 댐퍼 각도(SA) | 댐퍼 각도(RA) | PM10 | | PM4 | | PM2.5 | | PM1.0 | | Humidity | | | | | | | |
| | | | | | | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | | | | | | |
| | | | | | | Temperature | | TVOC | | NOx | | CO2 | | 0x00 | 0x00 | 0x00 | Error Code | | Version | | CRC | | | | | | | |
| < M/S > byte1 | | | | | | | | < LED > byte8 | | | | Control Cmd | 0x80 | | | < 공기질센서 > byte12~29 | | | | | | | | | | | | | |
| 0x10 : Master ( Main BxD ) | | | | | | | | 디퓨저의 RGB LED 밝기 제어 | | | | | | | byte12~13 : PM10 | | | | | | | | | | | | | |
| 0x01 : Slave ( Display, Sensor Pack … ) | | | | | | | | 0x00 : OFF | | | | | | | byte14~15 : PM4 | | | | | | | | | | | | | |
| | | | | | | | | 0x01 ~ 0x0A : 1 ~ 10단 (10단: 최대) | | | | | | | byte16~17 : PM2.5 | | | | | | | | | | | | | |
| < ID 1 > | | | | | | | | | | | | | | | byte18~19 : PM1.0 | | | | | | | | | | | | | |
| 0x01 : SA Diffuser | | | | | | | | < 공기질 상태 > byte9 | | | | | | | byte20~21 : Humidity(습도) | | | | | | | | | | | | | |
| 0x02 : RA Diffuser | | | | | | | | 디퓨저의 RGB LED 색상 표시 | | | | | | | byte22~23 : Temperature(온도) | | | | | | | | | | | | | |
| 0x03 : Room Controller | | | | | | | | 0x01 : 매우나쁨 - 빨강 | | | | | | | byte24~25 : TVOC(총 휘발성 유기화합물) | | | | | | | | | | | | | |
| | | | | | | | | 0x02 : 나쁨 - 주황 | | | | | | | byte26~27 : NOx(질소산화물) | | | | | | | | | | | | | |
| < ID 2 > | | | | | | | | 0x03 : 보통 - 초록 | | | | | | | byte28~29 : CO2 | | | | | | | | | | | | | |
| 0x01 ~ 0x06 : SA/RA/RoomCon ID | | | | | | | | 0x04 : 좋음 - 파랑 | | | | | | | | | | | | | | | | | | | | |
| * RoomCon ID는 해당 SA Diffuser 같음. | | | | | | | | * 공기질 판단 기준은 다음 Sheet 참조 | | | | | | | < 예비 > byte30~32 | | | | | | | | | | | | | |
| | | | | | | | | | | | | | | | 예비 | | | | | | | | | | | | | |
| < VSP Mode > : Diffuser는 해당 사항 없고, RoomCon ID 1 에 해당함 | | | | | | | | < 댐퍼각도 SA > byte10 | | | | | | | | | | | | | | | | | | | | |
| 0x01 ~ 0x06 : SA/EA/RoomCon ID | | | | | | | | 디퓨저 각도 제어(0~180도) | | | | | | | < Version > byte35~36 | | | | | | | | | | | | | |
| * RoomCon ID는 해당 SA Diffuser 같음. | | | | | | | | 0x00 ~ 0xB4 | | | | | | | 예) Ver 1.23 | | | | | | | | | | | | | |
| | | | | | | | | | | | | | | | byte23 : 0x01 | | | | | | | | | | | | | |
| < Power > | | | | Control Cmd | 0x80 | | | | < 댐퍼각도 RA > byte11 | | | | | | | byte24 : 0x17 | | | | | | | | | | | | | |
| 0x00 / 0x01 : Power On / Off | | | | | | | | 디퓨저 각도 제어(0~180도) | | | | | | | | | | | | | | | | | | | | |
| | | | | | | | | 0x00 ~ 0xB4 | | | | | | | | | | | | | | | | | | | | |
| < Run Mode > | | | | Control Cmd | 0x80 | | | | | | | | | | | | | | | | | | | | | | | | |
| 0x01 : 수동 | | | | | | | | | | | | | | | | | | | | | | | | | | | | |
| 0x02 : 자동 | | | | | | | | | | | | | | | | | | | | | | | | | | | | |
| 0x04 : 바이패스 | | | | | | | | | | | | | | | | | | | | | | | | | | | | |
| 0x08 : 공기청정 | | | | | | | | | | | | | | | | | | | | | | | | | | | | |
| 2.1. 공기질 정보 Mode1 ( Vsp Mode = 0x11 ) 분배기_룸콘_거실_공기질 추가(Slave) <-> 메인보드 ( Master ) | | | | | | | | | | | | | | | | | | | | | | | | | | | | |
| byte | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | | | | | | |
| 내용 | 0xAA | M/S 0x01 | ID 1 | ID 2 | VSP Mode | 방 1
PM2.5 | | 방 1
Humidity | | 방 1
Temperature | | 방 1
CO2 | | 방 1
TVOC | | | | | | | | | | | | | | |
| | | | | | | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | | | | | | |
| | | | | | | 방 2
PM2.5 | | 방 2
Humidity | | 방 2
Temperature | | 방 2
CO2 | | 방 2
TVOC | | | Error Code | | COM
SA 1~6 | COM
EA 1~6 | CRC | | | | | | | |
| 2.2. 공기질 정보 Mode1 ( Vsp Mode = 0x12 ) 분배기_룸콘_거실_공기질 추가(Slave) <-> 메인보드 ( Master ) | | | | | | | | | | | | | | | | | | | | | | | | | | | | |
| byte | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | | | | | | |
| 내용 | 0xAA | M/S 0x01 | ID 1 | ID 2 | VSP Mode | 방 3
PM2.5 | | 방 3
Humidity | | 방 3
Temperature | | 방 3
CO2 | | 방 3
TVOC | | | | | | | | | | | | | | |
| | | | | | | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | | | | | | |
| | | | | | | 방 4
PM2.5 | | 방 4
Humidity | | 방 4
Temperature | | 방 4
CO2 | | 방 4
TVOC | | | Error Code | | COM
SA 1~6 | COM
EA 1~6 | CRC | | | | | | | |
| 2.3. Nomal Mode ( Vsp Mode = 0 ) | | | | | | 분배기_룸콘(Slave) <-> 메인보드 ( Master ) | | | | | | | | | | | | | | | | | | | | | | |
| byte | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | | | | | | |
| 내용 | 0xAA | M/S 0x01 | ID 1 | ID 2 | VSP Mode | Power | Run Mode | Fan Speed | 예약
시간 | 히터
/UV | 필터
리셋 | RPM
START | RPM
STOP | PM2.5 | | PM10 | | Humidity | | Temperature | | Co2 | | | | | | |
| | | | | | | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | | | | | | |
| | | | | | | Co2 | Room
Power | 소비전류 | | CO2
Hysteresys | TVOC | | 필터시간 | | 소자시간 | | Error Code | | COM
SA 1~6 | COM
EA 1~6 | CRC | | | | | | | |
| < M/S > byte1 | | | | | | | | < Fan Speed > | | | | Control Cmd | 0x80 | | < RPM START > byte11 | | | | | < COM SA 1~6, EA 1~6 > | | | | | | | | | |
| 0x10 : Master ( Main BxD ) | | | | | | | | 0x00 : OFF | | | | | | RPM 측정 시작 Flag | | | | | 통신 상태 : On/OFF - 1/0 | | | | | | | | | |
| 0x01 : Slave ( Display, Sensor Pack … ) | | | | | | | | 0x01 ~ 0x03 : 1~3 | | | | | | 0x01 : Start | | | | | 0x01 : 거실1 | | | | | | | | | |
| | | | | | | | | | | | | | | Main 응답은 Echo 처리 | | | | | 0x02 : 거실2 | | | | | | | | | |
| < ID 1 > | | | | | | | | | | | | | | | | | | | 0x04 : 방 1 | | | | | | | | | |
| 0x01 : SA Diffuser | | | | | | | | | | | | | | < RPM START > byte12 | | | | | 0x08 : 방 2 | | | | | | | | | |
| 0x02 : EA Diffuser | | | | | | | | | | | | | | RPM 측정 중지 Flag | | | | | 0x10 : 방 3 | | | | | | | | | |
| 0x03 : Room Controller | | | | | | | | | | | | | | 0x01 : 메인에서 RPM 측정 완료 시 응답 | | | | | 0x20 : 방 4 | | | | | | | | | |
| | | | | | | | | | | | | | | 룸콘 응답은 Echo 처리 | | | | | | | | | | | | | | |
| < ID 2 > | | | | | | | | | | | | | | | | | | | | | | | | | | | | |
| 0x01 ~ 0x06 : SA/EA/RoomCon ID | | | | | | | | | | | | | | < 예비 > byte30~32 | | | | | | | | | | | | | | |
| * RoomCon ID는 해당 SA Diffuser 같음. | | | | | | | | | | | | | | 예비 | | | | | | | | | | | | | | |
| < VSP Mode > : Diffuser는 해당 사항 없고, RoomCon ID 1 에 해당함 | | | | | | | | < 예약시간 > byte8 | | | | Control Cmd | 0x80 | | < Version > byte35~36 | | | | | | | | | | | | | | |
| 0x01 ~ 0x06 : SA/EA/RoomCon ID | | | | | | | | 룸콘에서 설정한 예약 시간(hour) | | | | | | 예) Ver 1.23 | | | | | | | | | | | | | | |
| * RoomCon ID는 해당 SA Diffuser 같음. | | | | | | | | 0x00 : OFF | | | | | | byte23 : 0x01 | | | | | | | | | | | | | | |
| | | | | | | | | 0x01 ~ 0x08 : 1 ~ 8시간 | | | | | | byte24 : 0x17 | | | | | | | | | | | | | | |
| < Power > | | | | Control Cmd | 0x80 | | | | | | | | | | | | | | | | | | | | | | | | |
| 0x00 / 0x01 : Power On / Off | | | | | | | | < 히터 / UV > byte9 | | | | Control Cmd | 0x80 | | < Room Power > | | | | Control Cmd | 0x80 | | 0x01 | 방1 | | | | | | | |
| | | | | | | | | 히터 및 UV ON/OFF Flag | | | | | | ID2 가 1의 경우만 사용 | | | | | | 0x02 | 방2 | | | | | | | |
| | | | | | | | | bit1(0x01) : 1-ON / 0-OFF 히터 | | | | | | 각방의 POWER ON/OFF 제어 | | | | | | 0x04 | 방3 | | | | | | | |
| < Run Mode > | | | | Control Cmd | 0x80 | | | | bit5(0x10) : 1-ON / 0-OFF UV | | | | | | | | | | | | 0x08 | 방4 | | | | | | | |
| 0x01 : 수동 | | | | | | | | | | | | | | < 소비전류 > | | | | | | | | | | | | | | |
| 0x02 : 자동 | | | | | | | | < 필터리셋 > byte10 | | | | | | MAIN -> 룸컨 | | | | | | | | | | | | | | |
| 0x04 : 바이패스 | | | | | | | | 필터리셋 시 전달 Flag | | | | | | Wh 단위로 전송 ( uint16_t ) | | | | | | | | | | | | | | |
| 0x08 : 공기청정 | | | | | | | | 0x01 : 필터리셋 | | | | | | | | | | | | | | | | | | | | |
| | | | | | | | | 0x00 : OFF | | | | | | | | | | | | | | | | | | | | |
| 3. VSP 상태 전송모드 ( Vsp Mode = 0x01 ~ 0x03 ) : 메인보드(Master) -> 분배기_룸컨(Slave) ( 룸컨의 요청으로 메인보드에서 polling 주기에서 함) | | | | | | | | | | | | | | | | | | | | | | | | | | | | |
| ( 룸컨의 요청은 2.1 packet 으로함) | | | | | | | | | | | | | | | | | | | | | | | | | | | | |
| byte | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | | | | | | |
| 내용 | 0xAA | M/S 0x10 | ID 1
= 0x03 | ID 2
= 0x01 | 0x01 | 환기 SA 1 | 환기 EA 1 | 환기 SA 2 | 환기 EA 2 | 환기 SA 3 | 환기 EA 3 | 환기 SA 4 | 환기 EA 4 | 환기 SA 5 | 환기 EA 5 | 공청 SA 1 | 공청 EA 1 | 공청 SA 2 | 공청 EA 2 | 공청 SA 3 | 공청 EA 3 | 공청 SA 4 | | | | | | |
| | | | | | | | | | | | | | | | | 22 | 23 | 24 | 25 | 26 | 27 | 28 | | | | | | |
| | | | | | | | | | | | | | | | | 공청 EA 4 | 공청 SA 5 | 공청 EA 5 | | | CRC | | | | | | | |
| byte | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | | | | | | |
| 내용 | 0xAA | M/S 0x10 | ID 1
= 0x03 | ID 2
= 0x01 | 0x02 | 바이 SA 1 | 바이 EA 1 | 바이 SA 2 | 바이 EA 2 | 바이 SA 3 | 바이 EA 3 | 바이 SA 4 | 바이 EA 4 | 바이 SA 5 | 바이 EA 5 | | | RoomCon
개수 | Diffuser
개수 | | | | | | | | | |
| | | | | | | | | | | | | | | | | 22 | 23 | 24 | 25 | 26 | 27 | 28 | | | | | | |
| | | | | | | | | | | | | | | | | | | | | | CRC | | | | | | | |
| byte | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | | | | | | |
| 내용 | 0xAA | M/S 0x10 | ID 1
= 0x03 | ID 2
= 0x01 | 0x03 | 환기 RPM REF 중 | | 환기 RPM DELTA 중 | | 환기 RPM REF 강 | | 환기 RPM DELTA 강 | | 공청 RPM REF 중 | | 공청 RPM DELTA 중 | | 공청 RPM REF 강 | | 공청 RPM DELTA 강 | | Modbus
ID | | | | | | |
| | | | | | | | | | | | | | | | | 22 | 23 | 24 | 25 | 26 | 27 | 28 | | | | | | |
| | | | | | | | | | | | | | | | | | | | | | CRC | | | | | | | |
| 4. VSP 세팅 모드 ( Vsp Mode = 0x10 ) : 분배기_룸컨(Slave) -> 메인보드(Master) ( VSP 세팅 모드이면 메인보드 polling(2.1 packet)에 대한 응답 ) | | | | | | | | | | | | | | | | | | | | | | | | | | | | |
| byte | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | | | | | | |
| 내용 | 0xAA | M/S 0x01 | ID 1
= 0x03 | ID 2
= 0x01 | 0x10 | Vsp
Select | SA1
Vsp | EA1
Vsp | SA2
Vsp | EA2
Vsp | SA3
Vsp | EA3
Vsp | SA4
Vsp | EA4
Vsp | SA5
Vsp | EA5
Vsp | | FAN
Mode | 풍량 중
RPM REF | | 풍량 중
RPM DELTA | | | | | | | |
| | | | | | | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | | | | | | |
| | | | | | | 풍량 강
RPM REF | | 풍량 강
RPM DELTA | | | | | | RoomCon
개수 설정 | Diffuser
개수 설정 | | | Modbus
ID | WR | | CRC | | | | | | | |
| < VSP Select > | | | | < SA1 Vsp ~ EA5 Vsp > | | | | | | | < Modbus ID > | | | | | | | | | | | | | | | | | |
| 0x00 : None | | | | VSP Select(해당모드) 에 의한 SA, EA 설정값을 표현 | | | | | | | 메인보드에서 외부홈넷과 연결될 경우 필요에 따라 ID를 부여 | | | | | | | | | | | | | | | | | |
| 0x01 : 환기모드 | | | | | | | | | | | | | | | | | | | | | | | | | | | | |
| 0x02 : 공청모드 | | | | < 풍량중 RPM REF ~ 풍량강 RPM DELTA > | | | | | | | < WR > : 설정한 값들을 저장하기 위한 Command | | | | | | | | | | | | | | | | | |
| 0x03 : 바이패스모드 | | | | VSP Select(해당모드) 에 의한 RPM REF 및 DELTA 설정 | | | | | | | 0x0000 : 저장 없음 | | | | | | | | | | | | | | | | | |
| | | | | 단, 환기모드와 공청모드만 해당함 | | | | | | | 0x0001 : 메인보드에 설정값 저장 | | | | | | | | | | | | | | | | | |
| | | | | < RoomCon 개수, Diffuser 개수 > | | | | | | | | | | | | | | | | | | | | | | | | |
| | | | | 메인보드가 Polling 해야할 RoomCon 및 Diffuser 개수 | | | | | | | | | | | | | | | | | | | | | | | | |
| | | | | RoomCon Min 1 ( 거실 1개 ) ~ Max 5 | | | | | | | | | | | | | | | | | | | | | | | | |
| | | | | Diffuser Min 2 (거실 지정된 2개) ~ Max 6 | | | | | | | | | | | | | | | | | | | | | | | | |
==========Reference==========
| | * 공기질 판단 기준은 힘펠과 협의 예정
@@ -0,0 +1,489 @@
# 각실제어 내부 통신 프로토콜 (Rev 2.0, CMD 기반)
> 휴벤ECO(ERV) ↔ 각실분배기 ↔ 디퓨저 ↔ 룸컨 **내부 RS-485 통신** 규격
>
> 본 문서는 `Protocol/수정_Each_Room_Jushin_protocol_RS485_Rev1.2` (주신전자) 와
> 펌웨어 `program/User/My_Uart.c` (각실분배기/디퓨저 폴링) 구현을 기반으로,
> **CMD 기반으로 단순화·확장**하여 새로 정의한 규격이다.
> (DL 룸컨 232 프로토콜 `My_RJ2.c` 는 DL 사양으로 본 규격과 무관 — 변경하지 않는다.)
- **물리계층** : RS-485, **115200 bps, 8 Data, None Parity, 1 Stop (N81)**
- **토폴로지** : 1 Master(메인보드) ↔ N Slave(디퓨저/룸컨), 메인보드 폴링 방식
- **대상 시스템** : 사양서 5페이지(힘펠 배선도) 구성 = **거실 + 방1~4 (5실)**, 각 실 룸컨
- 디퓨저 : 거실 SA 2·RA 2, 방1~4 각 SA 1·RA 1 → **SA 디퓨저 6 + RA 디퓨저 6**, 룸컨 5
---
## 0. 무엇이 바뀌었나 (Rev 1.2 → Rev 2.0)
| 구분 | 기존 (Rev 1.2) | 개선 (Rev 2.0) |
|------|----------------|----------------|
| 패킷 식별 | `VSP Mode` 값(0x00/0x11/0x12/0x01~03/0x10)에 따라 **같은 byte 위치의 의미가 계속 바뀜** | **CMD 1바이트로 패킷 종류 고정** → 한 패킷은 항상 같은 레이아웃 |
| 거실 공기질 | 별도 모드 0x11 / 0x12 로 방을 쪼개서 전송 | **방별 센서 패킷 1종으로 통합**, ROOM 필드로 구분 |
| 길이 | 29/39 byte 가변, 위치로 추정 | **LEN 필드 명시** → 파싱 단순 |
| 장치 주소 | ID1(종류)+ID2(번호) 혼용, 거실=5 등 불규칙 | **DEV / ROOM / IDX 3필드**로 직관적 주소화 |
| 에러코드 | **16비트 전부 소진** → 방4 추가 불가 (한계) | **32비트로 확장** → 방4 + 향후 여유 |
| 확장성 | 4실 고정 | **5실(거실+방1~4) + 룸컨 5 / SA·RA 디퓨저 각 6** 명시 |
| 역할 정의 | 명령/상태 방향이 패킷마다 섞여 모호 | **버스 마스터=ERV(폴링) / 명령 주체=룸컨 / 실행=ERV→디퓨저** 로 명확화 |
> **제어 권한 정리** : 사용자는 룸컨에서 전원·모드·풍량·예약·필터리셋과 VSP 풍량 테이블을 설정한다.
> 룸컨은 버스상 Slave이므로 이 명령들을 ERV 폴링에 대한 응답(`CMD_ROOMCON`/`CMD_VSP_SET`)으로 올린다.
> ERV는 명령을 받아 분배기를 통해 디퓨저 댐퍼·풍량·LED를 제어하고, 센서값을 읽어 룸컨에 전달한다.
---
## 1. 공통 프레임
```
+------+------+------+------------------+--------+--------+
| STX | CMD | LEN | PAYLOAD[LEN] | CRC_H | CRC_L |
+------+------+------+------------------+--------+--------+
0xAA 1B 1B LEN bytes 16-bit CRC
```
| 필드 | 크기 | 설명 |
|---------|------|------|
| STX | 1 | 고정 `0xAA` |
| CMD | 1 | 명령/응답 코드 (2장) |
| LEN | 1 | PAYLOAD 바이트 수 (0~255) |
| PAYLOAD | LEN | CMD 별 데이터 (3·4장) |
| CRC | 2 | **CRC-16/MODBUS** (poly 0xA001, init 0xFFFF), **CMD~PAYLOAD** 까지, **빅엔디안(CRC_H 먼저)** |
- 모든 멀티바이트 수치는 **빅엔디안(상위 바이트 먼저)**. (CRC 포함, 펌웨어 `My_Uart.c` 관례 유지)
- 프레임 구분 : `STX(0xAA)` 탐색 → `LEN` 으로 길이 확보 → `CRC` 검증.
- 수신 타임아웃(예: 50ms) 내 미완성 프레임은 폐기하고 STX 재탐색.
> **CRC 계산** : `My_Uart.c` 의 `CRC16()` (MODBUS 룩업테이블) 동일. 결과 16비트를 `CRC_H = (crc>>8)`, `CRC_L = (crc&0xFF)` 순으로 전송.
---
## 2. 명령 코드 (CMD)
**버스 레벨과 제어 권한을 구분해서 이해해야 한다.**
- **버스 마스터 = ERV(메인보드)** : 모든 통신을 ERV가 폴링한다. 룸컨/디퓨저는 폴링을 받아야만 송신하는 Slave.
- **제어 권한(명령 발생) = 룸컨** : 사용자가 룸컨에서 전원/모드/풍량/예약/필터리셋, **VSP 풍량 테이블 설정·저장**을 한다.
룸컨은 Slave이므로 이 명령들을 ERV 폴링에 대한 **응답에 실어** ERV로 보낸다 (룸컨 → ERV).
- **실행 = ERV** : 룸컨 명령을 받아 분배기를 통해 **디퓨저 댐퍼 개폐·풍량·LED를 제어**하고, **센서값을 읽어 룸컨에 전달**한다.
즉 데이터 흐름은: **룸컨(명령) → ERV(실행) → 디퓨저(댐퍼/센서) → ERV(센서수집) → 룸컨(표시)**.
CMD 상위 비트로 송신 방향을 구분한다. **0x10~0x7F = ERV(Master) 송신**, **0x90~0xFF = Slave 송신(응답)**.
### 2.1 ERV(메인보드, Master) 송신
| CMD | 이름 | 대상 | PAYLOAD | 설명 |
|------|----------------------|--------|---------|------|
| 0x10 | `CMD_DIFFUSER_CTRL` | 디퓨저 | 4.1 | **ERV가 디퓨저 제어**(댐퍼각/풍량/LED/리셋) + 폴링. ERV가 제어 주체 |
| 0x20 | `POLL_ROOMCON` | 룸컨 | 4.3 | ERV가 룸컨 폴링 + **표시용 데이터 전달**(ERV 동작상태/해당 실 공기질/에러/온도). 명령 수용 결과 echo |
| 0x30 | `RSP_VSP_STATUS` | 룸컨 | 4.5 | 룸컨 요청에 대한 **현재 VSP 풍량 테이블 값 회신**(룸컨 표시용) |
| 0x40 | `POLL_SPEC` | 공통 | 없음 | 장치 사양/버전 요청 |
### 2.2 Slave(룸컨/디퓨저) 송신 = 명령/상태 보고
| CMD | 이름 | 송신자 | PAYLOAD | 설명 |
|------|------------------------|--------|---------|------|
| 0xA0 | `CMD_ROOMCON` | 룸컨 | 4.4 | **룸컨이 사용자 명령 전달**: 전원/모드/풍량/예약/히터·UV/필터리셋 (0x20 응답) |
| 0xB0 | `CMD_VSP_SET` | 룸컨 | 4.6 | **룸컨이 VSP 풍량 테이블 설정·저장** + 장치 개수 설정 (0x20 응답, VSP 세팅모드) |
| 0x90 | `RSP_DIFFUSER_STATUS` | 디퓨저 | 4.2 | 디퓨저 댐퍼각/공기질/RPM/에러/버전 (0x10 응답) |
| 0x91 | `RSP_DIFFUSER_SENSOR` | 디퓨저 | 4.7 | 통합공기질 센서값(PM/온습도/VOC/NOx/CO2) (방별) |
| 0xC0 | `RSP_SPEC` | 공통 | 4.8 | 사양/버전 (0x40 응답) |
> 모든 패킷은 대상 장치 주소(DEV/ROOM/IDX)를 선두에 싣고, 응답은 이를 그대로 에코한다.
> `CMD_ROOMCON`(0xA0)·`CMD_VSP_SET`(0xB0)은 이름은 "CMD"지만 버스상으로는 **룸컨이 ERV 폴링(0x20)에 응답하는 형태**로 전송된다. (룸컨이 명령 주체이기 때문)
---
## 3. 공통 값 정의
### 3.1 장치 주소 (DEV / ROOM / IDX)
각 PAYLOAD 선두 3바이트는 항상 장치 주소다.
| 필드 | 크기 | 값 | 의미 |
|------|------|----|------|
| DEV | 1 | `0x10`=SA 디퓨저, `0x20`=RA 디퓨저, `0x30`=룸컨 | 장치 종류 |
| ROOM | 1 | `1`=거실, `2`=방1, `3`=방2, `4`=방3, `5`=방4 | 실 번호 |
| IDX | 1 | `1`~ | 같은 실·같은 종류 장치의 일련번호 (거실 SA·RA는 `1`,`2`) |
**5실 디바이스 맵 (힘펠 배선도 기준 — SA 6 / RA 6 / 룸컨 5)**
| 실 | ROOM | SA 디퓨저 | RA 디퓨저 | 룸컨 |
|----|------|-----------|-----------|------|
| 거실 | 1 | (0x10,1,1) 거실급기1 · (0x10,1,2) 거실급기2 | (0x20,1,1) 거실배기1 · (0x20,1,2) 거실배기2 | (0x30,1,1) |
| 방1 | 2 | (0x10,2,1) | (0x20,2,1) | (0x30,2,1) |
| 방2 | 3 | (0x10,3,1) | (0x20,3,1) | (0x30,3,1) |
| 방3 | 4 | (0x10,4,1) | (0x20,4,1) | (0x30,4,1) |
| 방4 | 5 | (0x10,5,1) | (0x20,5,1) | (0x30,5,1) |
> 방1~4 구성은 모두 동일(SA 1 · RA 1 · 룸컨 1). 거실만 SA·RA 각 2대.
### 3.2 전원 (Power)
| 값 | 의미 |
|----|------|
| 0x00 | OFF |
| 0x01 | ON |
### 3.3 운전모드 (RunMode)
| 값 | 의미 | 펌웨어 매핑 |
|----|------|-------------|
| 0x01 | 수동(환기) | MODE_VENTILATION |
| 0x02 | 자동 | MODE_AUTO |
| 0x04 | 바이패스 | MODE_BYPASS |
| 0x08 | 공기청정 | MODE_AIRCLEAN |
### 3.4 풍량 (FanSpeed)
| 값 | 의미 |
|----|------|
| 0x00 | OFF/정지 |
| 0x01 | 1단 (약) |
| 0x02 | 2단 (중) |
| 0x03 | 3단 (강) |
| 0x04 | 4단 (터보) |
> 자동/공청 모드에서는 메인보드 로직(부하점수)이 단수를 결정하므로 디퓨저로 보내는 풍량은 계산 결과값.
### 3.5 LED 밝기 (LedDim)
| 값 | 의미 |
|----|------|
| 0x00 | OFF |
| 0x01~0x0A | 1~10단 (10단 최대) |
> 월패드/스마트스위치 UI는 0~9 단계. 0=OFF 포함 시 0~10으로 매핑.
### 3.6 댐퍼 각도 (DamperAngle)
| 값 | 의미 |
|----|------|
| 0x00 | 0° (닫힘) |
| 0x00~0xB4 | 0~180° (0xB4 = 180° 완전개방) |
### 3.7 에러코드 (ErrorCode, **u32 비트맵** — 확장)
| 비트 | 마스크 | 의미 |
|------|--------|------|
| 0 | 0x00000001 | 필터 청소 |
| 1 | 0x00000002 | 필터 교체 |
| 2 | 0x00000004 | 소자 교체 |
| 3 | 0x00000008 | 온도센서 에러 |
| 4 | 0x00000010 | 장비보호 모드 |
| 5 | 0x00000020 | EA 팬 에러 |
| 6 | 0x00000040 | 간헐운전 모드 |
| 7 | 0x00000080 | SA 팬 에러 |
| 8 | 0x00000100 | 통합센서 에러 — 거실 |
| 9 | 0x00000200 | 통합센서 에러 — 방1 |
| 10 | 0x00000400 | 통합센서 에러 — 방2 |
| 11 | 0x00000800 | 통합센서 에러 — 방3 |
| 12 | 0x00001000 | **통합센서 에러 — 방4 (신규)** |
| 16 | 0x00010000 | 통신 에러 — 거실 |
| 17 | 0x00020000 | 통신 에러 — 방1 |
| 18 | 0x00040000 | 통신 에러 — 방2 |
| 19 | 0x00080000 | 통신 에러 — 방3 |
| 20 | 0x00100000 | **통신 에러 — 방4 (신규)** |
> 비트 13~15, 21~31 은 향후 확장용 예약.
---
## 4. PAYLOAD 상세
> 모든 PAYLOAD는 `[DEV][ROOM][IDX]` 3바이트로 시작. (3.1)
> `u16` 은 빅엔디안. 아래 off 는 PAYLOAD 내 상대 오프셋.
### 4.1 `CMD_DIFFUSER_CTRL` (0x10, Master→디퓨저)
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | DEV=0x10(SA) 또는 0x20(RA) |
| 3 | 1 | power | 3.2 |
| 4 | 1 | runMode | 3.3 |
| 5 | 1 | fanSpeed | 3.4 |
| 6 | 1 | ledDim | 3.5 (SA 디퓨저만 유효) |
| 7 | 1 | dmpAngle | 3.6 (해당 포트의 댐퍼 목표각) |
| 8 | 1 | dmpReset | 0=정상 / 1=댐퍼 초기화 |
| 9 | 1 | reserveHour | 예약 정지 0~8시간 (0=없음) |
LEN = 10.
### 4.2 `RSP_DIFFUSER_STATUS` (0x90, 디퓨저→Master)
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | 요청 에코 |
| 3 | 1 | power | 현재 전원 |
| 4 | 1 | runMode | 현재 운전모드 |
| 5 | 1 | fanSpeed | 현재 풍량 |
| 6 | 1 | ledDim | 현재 LED 밝기 |
| 7 | 1 | dmpAngle | 현재 댐퍼 각도 (3.6) |
| 8 | 2 | rpm | 해당 팬 실측 RPM (u16) |
| 10 | 4 | errorCode | 3.7 (u32) |
| 14 | 2 | version | 예) 0x0117 = Ver 1.23 |
LEN = 16.
### 4.3 `POLL_ROOMCON` (0x20, ERV→룸컨)
ERV가 룸컨을 폴링하면서 **룸컨 화면에 표시할 데이터**(ERV 동작상태·에러·온도)를 전달하고,
직전에 받은 룸컨 명령의 **수용 결과(ackFlags)** 를 에코한다.
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | DEV=0x30 |
| 3 | 1 | power | ERV 현재 전원 (3.2) |
| 4 | 1 | runMode | ERV 현재 운전모드 (3.3) |
| 5 | 1 | fanSpeed | ERV 현재 풍량 (3.4) |
| 6 | 1 | autoState | 0=분산, 1=집중 (자동모드) |
| 7 | 1 | reserveRemain | 예약 잔여 시간(hour) |
| 8 | 4 | errorCode | ERV 현재 에러 통보 (3.7, u32) |
| 12 | 2 | outTemp | 외기온도 ×10 (signed, ℃) |
| 14 | 2 | inTemp | 내기온도 ×10 (signed, ℃) |
| 16 | 1 | ackFlags | 직전 룸컨 명령 수용 비트(4.4 cmdFlags 동일 배치) |
LEN = 17.
### 4.4 `CMD_ROOMCON` (0xA0, 룸컨→ERV) — **룸컨이 명령 주체**
사용자가 룸컨에서 조작한 명령을 ERV에 전달한다. `cmdFlags`**이번에 실제로 바꾼 항목만** 표시한다(나머지는 무시).
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | DEV=0x30, ERV 폴링(0x20)에 대한 응답으로 송신 |
| 3 | 1 | cmdFlags | bit0 power, bit1 runMode, bit2 fanSpeed, bit3 reserveHour, bit4 heaterUV, bit5 filterReset, bit6 ledDim (1=이 필드 명령 유효) |
| 4 | 1 | power | 3.2 |
| 5 | 1 | runMode | 3.3 |
| 6 | 1 | fanSpeed | 3.4 |
| 7 | 1 | reserveHour | 0~8시간 |
| 8 | 1 | heaterUV | bit0=히터, bit4=UV (1=ON) |
| 9 | 1 | filterReset | 1=필터 리셋 |
| 10 | 1 | ledDim | 3.5 (해당 실 디퓨저 LED 밝기) |
| 11 | 2 | version | 룸컨 버전 |
LEN = 13.
> ERV는 받은 명령을 실행(분배기→디퓨저 제어)하고, 다음 `POLL_ROOMCON(0x20)`의 `ackFlags`·동작상태로 결과를 회신한다.
### 4.5 `RSP_VSP_STATUS` (0x30, ERV→룸컨)
룸컨이 현재 VSP 값을 요청(`CMD_VSP_SET` 의 reqStatus=1)하면, ERV가 **저장된 현재 VSP 풍량 테이블**을 회신한다(룸컨 표시용). 레이아웃은 4.6과 동일(끝의 save/장치개수 필드는 제외, vspSelect로 어떤 모드값인지 표시).
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | DEV=0x30 |
| 3 | 1 | vspSelect | 회신하는 모드 (`1`=환기/`2`=공청/`3`=바이패스) |
| 4 | 10 | sa1,ea1 … sa5,ea5 | SA/EA 1~5단 VSP (각 1B) |
| 14 | 2 | rpmRefMid | 중(2단) RPM 기준 |
| 16 | 2 | rpmDeltaMid | 중(2단) RPM 허용편차 |
| 18 | 2 | rpmRefHigh | 강(3단) RPM 기준 |
| 20 | 2 | rpmDeltaHigh | 강(3단) RPM 허용편차 |
LEN = 22.
### 4.6 `CMD_VSP_SET` (0xB0, 룸컨→ERV) — **VSP 설정·저장 주체는 룸컨**
룸컨에서 VSP 풍량 테이블을 한 모드씩(vspSelect) 설정하고 ERV에 저장 요청. 폴링할 장치 개수도 함께 설정.
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | DEV=0x30, ERV 폴링(0x20)에 대한 응답으로 송신 |
| 3 | 1 | vspSelect | `0`=None, `1`=환기, `2`=공청, `3`=바이패스 |
| 4 | 1 | sa1 | SA 1단 VSP |
| 5 | 1 | ea1 | EA 1단 VSP |
| 6 | 1 | sa2 | SA 2단 |
| 7 | 1 | ea2 | EA 2단 |
| 8 | 1 | sa3 | SA 3단 |
| 9 | 1 | ea3 | EA 3단 |
| 10 | 1 | sa4 | SA 4단 |
| 11 | 1 | ea4 | EA 4단 |
| 12 | 1 | sa5 | SA 5단 |
| 13 | 1 | ea5 | EA 5단 |
| 14 | 2 | rpmRefMid | 중(2단) RPM 기준 (환기/공청만) |
| 16 | 2 | rpmDeltaMid | 중(2단) RPM 허용편차 |
| 18 | 2 | rpmRefHigh | 강(3단) RPM 기준 |
| 20 | 2 | rpmDeltaHigh | 강(3단) RPM 허용편차 |
| 22 | 1 | roomconNum | 폴링할 룸컨 수 (1~5) |
| 23 | 1 | saDiffuserNum | 폴링할 SA 디퓨저 수 (2~6) |
| 24 | 1 | raDiffuserNum | 폴링할 RA 디퓨저 수 (2~6) |
| 25 | 1 | modbusId | 외부 홈넷 연동용 ID (선택) |
| 26 | 1 | save | 0=저장안함, 1=EEPROM 저장 |
| 27 | 1 | reqStatus | 1=현재 VSP 값 회신 요청(ERV가 `RSP_VSP_STATUS`로 응답) |
LEN = 28.
### 4.7 `RSP_DIFFUSER_SENSOR` (0x91, 디퓨저→Master)
통합공기질 센서(SEN66) 1실분. 기존 0x11/0x12 분리 모드를 **이 패킷 1종**으로 대체.
| off | 크기 | 필드 | 단위/비고 |
|-----|------|------|-----------|
| 0 | 3 | DEV/ROOM/IDX | 센서 부착 디퓨저(통상 RA, DEV=0x20) |
| 3 | 2 | pm1p0 | ㎍/㎥ |
| 5 | 2 | pm2p5 | ㎍/㎥ |
| 7 | 2 | pm4p0 | ㎍/㎥ |
| 9 | 2 | pm10p0 | ㎍/㎥ |
| 11 | 2 | humidity | %RH ×10 |
| 13 | 2 | temperature | ℃ ×10 (signed) |
| 15 | 2 | voc | TVOC index |
| 17 | 2 | nox | NOx index |
| 19 | 2 | co2 | ppm |
| 21 | 4 | errorCode | 센서/통신 에러 (3.7) |
LEN = 25.
### 4.8 `RSP_SPEC` (0xC0)
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | |
| 3 | 2 | version | 펌웨어 버전 |
| 5 | 1 | deviceType | 장치 타입 코드 |
| 6 | 1 | capability | bit0=히터, bit1=UV, bit2=후드연동 … |
LEN = 7.
---
## 5. 동작 시나리오 (폴링)
ERV가 버스 마스터로서 **룸컨 → 디퓨저 순으로 라운드로빈 폴링**하고, 명령은 룸컨이 응답에 실어 올린다.
1. **룸컨 폴링**
- ERV → 룸컨 : `POLL_ROOMCON(0x20)` (ERV 동작상태·해당 실 공기질·온도·에러 전달 + 직전 명령 ack)
- 룸컨 → ERV : 사용자가 조작했으면 `CMD_ROOMCON(0xA0)` (cmdFlags로 바뀐 항목 표시), VSP 설정중이면 `CMD_VSP_SET(0xB0)` 으로 응답
2. **디퓨저 폴링 (ERV가 제어 주체)**
- ERV → 디퓨저 : `CMD_DIFFUSER_CTRL(0x10)` (댐퍼각/풍량/LED 지시)
- 디퓨저 → ERV : `RSP_DIFFUSER_STATUS(0x90)`, 센서 부착 디퓨저는 이어서 `RSP_DIFFUSER_SENSOR(0x91)`
3. **명령 처리 흐름** : 룸컨 명령(0xA0) 수신 → ERV가 운전모드/풍량 결정 → 각 디퓨저에 `CMD_DIFFUSER_CTRL(0x10)`로 댐퍼 개폐·풍량 지시 → 결과를 다음 `POLL_ROOMCON(0x20)`의 ackFlags·동작상태로 룸컨에 회신.
4. **자동/공청 모드** : ERV가 각 실 센서(0x91)로 부하점수·집중/분산 계산 → 디퓨저 댐퍼각·풍량을 `CMD_DIFFUSER_CTRL`로 지시 (사양서 10~11P 로직).
5. **VSP 시운전** : 룸컨이 `CMD_VSP_SET(0xB0)`으로 단별 VSP·RPM·장치개수 설정(save=1 시 ERV가 EEPROM 저장), reqStatus=1이면 ERV가 `RSP_VSP_STATUS(0x30)`로 현재값 회신.
6. **통신 단절** : 폴링 타임아웃 N회 시 ERV가 해당 실 통신에러 비트(3.7 bit16~20) set, 재연결 시 clear.
---
## 6. 펌웨어 반영 메모
- 대상 파일 : `program/User/My_Uart.c` (SC1, 각실분배기/디퓨저), `bunbaegi_parsing()` / `Bunbaegi_Polling()` 를 CMD 기반으로 교체.
- **`My_RJ2.c` (DL 룸컨 232) 는 변경하지 않는다** — DL 사양 별도 유지.
- CRC : 기존 `CRC16()` (MODBUS) 그대로 사용, 전송은 빅엔디안(Hi→Lo).
- 센서/디퓨저 배열은 현행 `[7]` (index 1~6) 유지 가능하나, ROOM(1~5)+IDX 매핑 테이블 1개로 주소→배열 변환.
- 에러코드는 `uint16_t Err_Code`**`uint32_t`** 로 확장 (방4 비트 수용).
---
## 7. 송수신 예제 — 거실 룸컨이 전 실 상태 표시
**목표** : 거실 룸컨에서 모든 방 디퓨저(SA/RA) 댐퍼·LED·센서 + ERV 동작상태(모드/풍량/예약)를 표시.
**전제** : 거실 룸컨은 디퓨저를 직접 못 읽는다. 버스 마스터는 ERV 하나뿐이므로 항상 2단계.
```
① 수집 : ERV ──폴링──> 각 디퓨저(SA/RA) ──상태/센서──> ERV (캐싱)
② 표시 : ERV ──전달──> 거실 룸컨 (화면 표시)
```
> 아래 모든 프레임의 CRC는 **STX(0xAA) 제외, `CMD~PAYLOAD` 구간**에 대한 CRC-16/MODBUS 결과를 빅엔디안(Hi,Lo)으로 붙인 실제 값이다.
> 예시 값 : runMode=`0x02`(자동), fanSpeed=`2`, 댐퍼 `0xB4`=180°(열림)/`0x00`=닫힘, 온도·습도 ×10.
> **폴링 시간** : 1회 폴링(TX 요청 → RX 응답)을 **300ms**로 가정한다. (각 [TX]/[RX] 쌍 = 300ms)
### ① 수집 단계 — ERV가 각 디퓨저를 라운드로빈 폴링
ERV가 `거실SA1 → 거실SA2 → 거실RA1 → 거실RA2 → 방1SA → 방1RA → … → 방4RA` 순으로 폴링. 대표 예 (괄호는 누적시간):
**거실 SA1 디퓨저 폴링/제어**`CMD_DIFFUSER_CTRL(0x10)` *(t=0 ~ 300ms)*
```
[TX] AA 10 0A | 10 01 01 | 01 02 02 05 B4 00 00 | E4 61
STX CMD LEN DEV ROOM IDX power runMode fan led dmp reset reserve
(SA,거실,1) ON 02(자동) 2 5 B4 0 0
```
```
[RX] AA 90 10 | 10 01 01 | 01 02 02 05 B4 03 52 00 00 00 00 01 17 | A6 08
└RSP_DIFFUSER_STATUS power mode fan led dmp rpm=0352(850) err=0(4B) ver=0117
```
**거실 RA1 디퓨저 폴링** — 상태 `RSP_DIFFUSER_STATUS(0x90)` + 센서 `RSP_DIFFUSER_SENSOR(0x91)` *(t=600 ~ 900ms)*
```
[RX] AA 91 19 | 20 01 01 | 00 05 00 08 00 09 00 0B 01 C2 00 DC 00 64 00 01 02 8A | 00 00 00 00 | E8 AF
└센서 (RA,거실,1) pm1=5 pm2.5=8 pm4=9 pm10=11 습45.0% 온22.0℃ voc100 nox1 co2=028A(650) err=0
```
> RA 디퓨저는 한 폴링 슬롯(300ms)에서 상태(0x90)+센서(0x91)를 함께 응답.
> RA2·방1~4 디퓨저도 같은 방식으로 폴링 → ERV가 전 실 댐퍼각/LED/RPM/센서를 모두 캐싱.
**ERV 동작상태를 거실 룸컨에 전달 (+ 룸컨 명령 수신)**`POLL_ROOMCON(0x20)` *(룸컨 폴링 슬롯, 각 300ms)*
```
[TX] AA 20 11 | 30 01 01 | 01 02 02 01 00 | 00 00 00 00 | 00 96 | 00 DC | 00 | B5 61
└ERV→룸컨 (룸컨,거실,1) power mode fan auto=1(집중) reserveRemain=0
err=0(4B) 외기15.0℃ 내기22.0℃ ack=0
[RX] (거실 룸컨이 조작했으면 CMD_ROOMCON(0xA0)/CMD_VSP_SET(0xB0)로 응답, 없으면 빈 응답)
```
### ② 표시 단계 — 전 실 집계 전달
거실 룸컨이 전 실 디퓨저·센서를 한 화면에 표시하려면, ERV가 ①에서 모은 값을 집계해 거실 룸컨에 전달해야 한다.
> **참고(본 규격 미반영 제안)** : 아래 `CMD_ALLROOM_STATUS(0x21)` 는 "전 실 집계"용으로 검토한 예시 패킷이다.
> 정식 채택은 보류 상태이며, 필요 시 별도 협의 후 2·4장에 추가한다.
**헤더(9B)** + **실별 블록(18B) × 5실** :
| 헤더 off | 필드 | 실 블록 off | 필드 |
|---|---|---|---|
| 0~2 | 룸컨 주소(30·01·01) | +0 | roomNo |
| 3 | ERV power | +1,+2 | SA댐퍼1·2 (`0xFF`=없음) |
| 4 | ERV runMode | +3,+4 | RA댐퍼1·2 |
| 5 | ERV fanSpeed | +5 | LED 밝기 |
| 6 | 예약 잔여(h) | +6,+7 | PM2.5 |
| 7 | autoState | +8,+9 | PM10 |
| 8 | roomCount(=5) | +10,+11 | CO2 |
| | | +12,+13 | VOC |
| | | +14,+15 | 온도 ×10 |
| | | +16,+17 | 습도 ×10 |
```
[TX] AA 21 63 | 30 01 01 01 02 02 00 01 05 | <실1..실5 블록> | D9 F4
└CMD_ALLROOM 헤더(LEN=0x63=99) 거실은 SA·RA 2개 모두 사용
실1(거실): 01 B4 B4 B4 B4 05 00 08 00 0B 02 8A 00 64 00 DC 01 C2 SA열림×2,RA열림×2,LED5,PM2.5=8,PM10=11,CO2=650,VOC=100,22.0℃,45.0%
실2(방1) : 02 B4 FF B4 FF 04 00 0C 00 12 02 D0 00 82 00 DD 01 C4 SA/RA 각1, slot2=FF
실3(방2) : 03 00 FF 00 FF 00 00 06 00 09 02 62 00 5A 00 DC 01 C0 댐퍼 닫힘, LED OFF
실4(방3) : 04 B4 FF B4 FF 06 00 14 00 1C 03 2A 00 A0 00 DB 01 C7
실5(방4) : 05 B4 FF B4 FF 03 00 07 00 0A 02 80 00 5F 00 DC 01 C1
```
> 전체 104 byte 1프레임으로 거실 룸컨이 **전 실 SA/RA 댐퍼·LED·센서 + ERV 모드/풍량/예약**을 모두 표시.
### 폴링 주기와 전체 시간 (1회 = 300ms)
5실 구성에서 ERV가 한 바퀴 도는 동안 폴링하는 장치:
| 장치 | 개수 | 폴링 시간 (×300ms) |
|------|------|--------------------|
| SA 디퓨저 (거실2 + 방1~4 각1) | 6 | 1,800 ms |
| RA 디퓨저 (거실2 + 방1~4 각1, 상태+센서 동시) | 6 | 1,800 ms |
| 룸컨 (거실 + 방1~4) | 5 | 1,500 ms |
| **합계 (1주기)** | **17** | **5,100 ms ≈ 5.1초** |
- 거실 룸컨 화면은 **약 5.1초마다 전 실 데이터가 1회 갱신**된다.
- 갱신을 더 빠르게 하려면 : 폴링 시간 단축(예: 200ms → 17×200 = 3.4초), 또는 폴링 대상 축소.
- `0x21` 집계 프레임은 거실 룸컨 폴링 슬롯 안에서 전달되므로 별도 시간이 추가되지 않는다.
```
[1 주기 = 5.1초]
SA1 SA2 RA1 RA2 | 방1SA 방1RA | 방2SA 방2RA | 방3SA 방3RA | 방4SA 방4RA | RC거실 RC방1 RC방2 RC방3 RC방4
└─거실 4슬롯─┘ └─방1 2─┘ └─방2 2─┘ └─방3 2─┘ └─방4 2─┘ └────── 룸컨 5슬롯 ──────┘
각 슬롯 300ms × 17 = 5,100ms
```
### 요약
| 단계 | 방향 | 패킷 | 역할 |
|------|------|------|------|
| ① | ERV→디퓨저 / 디퓨저→ERV | `0x10` / `0x90`,`0x91` | 전 실 댐퍼·LED·센서 수집 |
| ② | ERV→거실룸컨 | `0x20` | ERV 동작상태(모드/풍량/예약) 전달 |
| ② | ERV→거실룸컨 | `0x21` (미반영 제안) | 전 실 디퓨저·센서 집계 전달 |
---
> 본 문서는 내부 통신 재정의 초안(Rev 2.0)이다. 디퓨저/룸컨 펌웨어 담당(주신전자)과
> CMD 코드·필드 세부값을 상호 합의하여 확정한다.
@@ -0,0 +1,546 @@
# 각실제어 내부 통신 프로토콜 (Rev 3.0, 2-Tier 계층형)
> 휴벤ECO(ERV) ↔ **각실분배기** ↔ 디퓨저(SA/RA) · 룸컨 **내부 통신** 규격
>
> 본 규격은 [각실제어_내부프로토콜_Rev2.0_CMD.md](각실제어_내부프로토콜_Rev2.0_CMD.md) 를
> **계층형(2-Tier)** 으로 재구성한 것이다. 분배기 회로도 `Schematic/BUNBAGI_REV4.1_20251124(회로도).pdf`
> 와 펌웨어 `program/User/My_Uart.c` 를 근거로 한다.
> (Rev 2.0 은 그대로 보존하며, 본 Rev 3.0 이 상위 규격이다. DL 룸컨 232 `My_RJ2.c` 는 무관 — 변경 안 함.)
- **물리계층** : RS-485, **115200 bps, 8 Data, None Parity, 1 Stop (N81)**
- **대상 시스템** : 사양서 5페이지 구성 = **거실 + 방1~4 (5실)**
---
## 0. 무엇이 바뀌었나 (Rev 2.0 단일버스 → Rev 3.0 2-Tier)
회로도 분석 결과, 각실분배기는 **자체 MCU(Nuvoton NANO100SE3BN)를 가진 능동 컨트롤러**임이 확인되었다. 따라서 통신을 두 계층으로 분리한다.
| 구분 | Rev 2.0 (단일 버스) | Rev 3.0 (2-Tier) |
|------|---------------------|------------------|
| 버스 구조 | ERV가 모든 디퓨저/룸컨(17대)을 1버스로 직접 폴링 | **상위: ERV↔분배기 1버스 / 하위: 분배기↔실별 디퓨저·룸컨** |
| ERV 폴링 대상 | 17대 | **분배기 1대** |
| 분배기 역할 | 없음(전원·배선 통과) | **하위 5채널 로컬 마스터 + 에러격리 + 포트별 통신상태 LED** |
| 디퓨저 LED 제어 | 디퓨저에 ledDim 전송 | **디퓨저에 ledDim 전송** (유지). 조명 LED는 **RA 디퓨저에만** 있고 ERV→분배기→RA디퓨저로 밝기 제어 |
| 자동운전 판단 | ERV | **ERV** (분배기는 센서수집·명령실행만) |
| 집계 패킷 | 0x21 제안(미반영) | **상위버스 정식 패킷(`RSP_ALLROOM_*`)으로 채택** |
> **하드웨어 근거 (BUNBAGI Rev4.1)** : 상위 `M485`(RS485 1채널, U5, 커넥터 CN3) / 하위 `SA485`(RS485 **5채널**, 실별 RJ45 J1~J5, RX는 8:1 MUX U6로 실 선택, DIR 채널별) / 전원 24V(60W)·3.3V.
> ※ 분배기의 2×74HC595(U10·U12, SA=초록·RA=노랑) LED는 **각 포트 SA/RA 디퓨저의 통신상태 표시등**(분배기 보드 진단용)이며, 프로토콜과 무관하다.
> ※ **방의 조명용 LED는 RA 디퓨저에만 달려 있고**, ERV가 밝기(0~9)를 명령한다 → 분배기가 해당 RA 디퓨저로 전달.
---
## 0-1. Rev 2.0(단일버스) → Rev 3.0(2-Tier) 장단점
> 채택 전 의사결정용 요약. 결론적으로 **하드웨어(분배기 MCU·하위 5채널·SPOF 구조)와 5실 확장 요구를 고려하면 2-Tier 도입 권장**이되, 분배기 펌웨어 신규 개발과 단일 장애점(SPOF) 대비가 전제다.
### ✅ 장점
| # | 항목 | 내용 | 효과 |
|---|------|------|------|
| 1 | **ERV 폴링 부하 격감** | ERV가 17대 직접 폴링 → **분배기 1대만** 상대 | ERV 펌웨어·루프 단순, CPU 여유 |
| 2 | **갱신 속도 향상** | ERV 입장 1주기 **5.1초 → 0.6초** (상·하위 병렬, 하위는 분배기가 빠르게 순환) | 룸컨·대시보드 응답성 ↑ |
| 3 | **버스 트래픽·충돌 감소** | 상위 버스엔 노드 1개, 하위는 5채널로 부하 분산(MUX+채널별 DIR) | 패킷 충돌/재전송 ↓ |
| 4 | **실별 통신에러 격리** | 한 실 디퓨저 고장이 채널 단위로 격리 → 타 실·ERV에 영향 최소 | 가용성 ↑, 원인 국소화 |
| 5 | **신호 무결성·배선 이점** | 하위는 짧은 로컬 세그먼트 5채널 분리, 채널별 종단·DIR | 노이즈·반사 ↓ (긴 데이지체인 대비) |
| 6 | **ERV–분배기 인터페이스 추상화** | ERV는 디퓨저 주소·개수를 몰라도 됨. 디퓨저 증설/사양변경을 **분배기가 흡수** | ERV 코드 변경 없이 하위 확장 |
| 7 | **역할·개발 분담** | ERV(시스템 로직) ↔ 분배기/디퓨저/룸컨(주신) 펌웨어 디커플링 | 병행 개발·유지보수 용이 |
| 8 | **확장성** | 실/디퓨저 추가는 하위에서 처리, 멀티 분배기(`nodeId`) 대비 | 향후 평면 확장 수월 |
| 9 | **MCU 동일(Nano100)** | ERV·분배기 동일 계열 → 프레임/CRC/드라이버 코드 공유 | 개발 재사용 |
### ⚠️ 단점 / 리스크
| # | 항목 | 내용 | 완화책 |
|---|------|------|--------|
| 1 | **분배기 펌웨어 신규 개발** | 하위 마스터 + 상위 슬레이브 + MUX/DIR + 595 + 집계 캐시 로직 필요 | Nano100 공통 드라이버 재사용, `My_Uart.c` 골격 활용 |
| 2 | **지연(latency) 1홉 추가** | 명령이 ERV→분배기→디퓨저 2단. 최악 상위주기+하위주기 합산 | 긴급 제어는 분배기가 즉시 중계, 댐퍼는 비실시간이라 영향 작음 |
| 3 | **상태 동기화 시차** | ERV가 보는 값은 분배기 캐시(직전 하위 폴링 결과) → 한 박자 지연 가능 | 변경 이벤트 우선 보고, 타임스탬프/시퀀스로 정합 |
| 4 | **단일 장애점(SPOF)** | 중앙 분배기 1대 고장 시 **전 실 통신 두절** (단일버스는 ERV-디퓨저 직접이라 무관) | 분배기 워치독·자기진단, 통신두절 시 ERV 안전모드 |
| 5 | **집계 패킷이 큼** | `RSP_ALLROOM_STATUS` 93B 등 → 1프레임 손상 시 전 실 갱신 실패(재전송 단위 큼) | CRC+재요청, 필요 시 실별 분할 응답 옵션 |
| 6 | **디버깅·추적 복잡** | 장애 시 상위/하위/분배기 로직 분리 진단, 버스 스니핑 2곳 | 분배기 진단로그·상태 LED(595), SPEC/버전 패킷 |
| 7 | **프로토콜 2종 관리** | 상위·하위 CMD 세트 2개 정의·문서화·버전관리 | 본 문서 단일화, 프레임 규칙 공통 |
| 8 | **3자 버전 정합** | ERV·분배기·디퓨저/룸컨 펌웨어 호환성 관리 | `SPEC`(0x1F/0x9F) 버전 교환·검증 |
### 한눈에
| 관점 | Rev 2.0 단일버스 | Rev 3.0 2-Tier |
|------|------------------|----------------|
| ERV 부담 | 큼(17대 폴링) | **작음(1대)** |
| 갱신 주기(ERV) | 5.1초 | **0.6초** |
| 분배기 펌웨어 | 불필요 | **필요(신규)** |
| 장애 격리 | 디퓨저별(분배기 무관) | 실별(단, 분배기 SPOF) |
| 확장/유지보수 | ERV가 전부 관리 | **계층 분리로 용이** |
| 구현 난이도 | 낮음 | 중(분배기 추가) |
---
## 1. 시스템 구조
```
[상위 버스: M485 · RS485 1채널] [하위 버스: SA485 · RS485 5채널 (MUX)]
┌── ch1 거실 : SA디퓨저×2, RA디퓨저×2, 룸컨
ERV ───────────────────────── 각실분배기 ─┼── ch2 방1 : SA디퓨저, RA디퓨저, 룸컨
(Master) (Nano100) ├── ch3 방2 : SA디퓨저, RA디퓨저, 룸컨
· 상위 Slave ├── ch4 방3 : SA디퓨저, RA디퓨저, 룸컨
· 하위 Master └── ch5 방4 : SA디퓨저, RA디퓨저, 룸컨
· 포트별 통신상태 LED(74HC595, 진단용)
· 실별 전원(24V)·통신에러 격리
```
**역할 분담**
| 주체 | 책임 |
|------|------|
| **ERV (메인)** | 전열교환기 팬/VSP 제어, **자동운전 판단**(부하점수·집중/분산, 사양서 10~11P), **부가모드(스마트수면/쾌적조리/안심회복)·후드 연동(HOOD-485) 처리**, 분배기에 전원/모드/풍량 + 실별 댐퍼·LED 타겟 하달, 집계상태 수신 |
| **분배기** | 하위 5채널 폴링(실별 SA/RA 디퓨저·룸컨), **댐퍼·LED 명령 중계·실행**, 센서·룸컨명령 수집→ERV에 집계 보고, 실별 통신에러 격리, **포트별 통신상태 표시 LED**(74HC595, 진단용) |
| **디퓨저(SA/RA)** | 댐퍼 구동·각도/RPM 회신, 센서값 회신. **RA 디퓨저는 자체 조명 LED 점등(밝기 0~9)** |
| **룸컨** | 사용자 입력(전원/모드/풍량/예약 등) 보고, ERV 상태 표시 |
**데이터 흐름 (자동운전)**
```
분배기 ──(실별 원시 센서)──> ERV ──[부하점수·집중/분산 계산]──> 실별 댐퍼/풍량/LED 타겟
ERV ──(타겟)──> 분배기 ──(댐퍼·LED 실행 / 디퓨저 제어)──> 각 실
```
---
## 2. 공통 프레임 (상·하위 동일)
```
+------+------+------+------------------+--------+--------+
| STX | CMD | LEN | PAYLOAD[LEN] | CRC_H | CRC_L |
+------+------+------+------------------+--------+--------+
0xAA 1B 1B LEN bytes 16-bit CRC
```
| 필드 | 크기 | 설명 |
|---------|------|------|
| STX | 1 | 고정 `0xAA` |
| CMD | 1 | 명령/응답 코드 (상위 4장 / 하위 5장) |
| LEN | 1 | PAYLOAD 바이트 수 (0~255) |
| PAYLOAD | LEN | CMD 별 데이터 |
| CRC | 2 | **CRC-16/MODBUS** (poly 0xA001, init 0xFFFF), **CMD~PAYLOAD** 까지, **빅엔디안(CRC_H 먼저)** |
- 모든 멀티바이트 수치는 **빅엔디안**. 상·하위 버스 모두 같은 프레임/CRC 규칙을 쓴다.
- 분배기 MCU와 ERV MCU가 동일 계열(Nano100)이므로 CRC·프레임 파서 코드를 공유한다.
---
## 3. 공통 값 정의
### 3.1 전원 (Power)
`0x00`=OFF, `0x01`=ON
### 3.2 운전모드 (RunMode)
| 값 | 의미 | 펌웨어 |
|----|------|--------|
| 0x01 | 수동(환기) | MODE_VENTILATION |
| 0x02 | 자동 | MODE_AUTO |
| 0x04 | 바이패스 | MODE_BYPASS |
| 0x08 | 공기청정 | MODE_AIRCLEAN |
### 3.3 풍량 (FanSpeed)
`0`=정지, `1`=약, `2`=중, `3`=강, `4`=터보
### 3.4 LED 밝기 (LedDim) — **RA 디퓨저 조명**
`0`=OFF, `1`~`9`=1~9단(9=최대). 조명 LED는 **RA 디퓨저에만** 존재(단색, 색온도 3800K). ERV가 실별 밝기를 지시 → 분배기가 해당 실 RA 디퓨저로 전달 → RA 디퓨저가 자체 점등.
> 분배기 보드의 SA(초록)/RA(노랑) LED는 별개로, 포트별 통신상태 표시등이다(프로토콜 무관).
### 3.5 댐퍼 각도 (DamperAngle)
`0x00`=0°(닫힘) ~ `0xB4`=180°(완전개방). 슬롯 미사용 시 `0xFF`(장치 없음).
### 3.6 에러코드 (ErrorCode, **u32 비트맵**)
| 비트 | 마스크 | 의미 |
|------|--------|------|
| 0 | 0x00000001 | 필터 청소 |
| 1 | 0x00000002 | 필터 교체 |
| 2 | 0x00000004 | 소자 교체 |
| 3 | 0x00000008 | 온도센서 에러 |
| 4 | 0x00000010 | 장비보호 모드 |
| 5 | 0x00000020 | EA 팬 에러 |
| 6 | 0x00000040 | 간헐운전 모드 |
| 7 | 0x00000080 | SA 팬 에러 |
| 8~12 | 0x00000100~0x00001000 | 통합센서 에러 — 거실/방1/방2/방3/방4 |
| 16~20 | 0x00010000~0x00100000 | 통신 에러 — 거실/방1/방2/방3/방4 |
### 3.7 실 번호 (Room)
`1`=거실, `2`=방1, `3`=방2, `4`=방3, `5`=방4. (하위버스 MUX 채널 ch1~ch5 와 1:1)
### 3.8 부가모드 (AddMode, 비트맵)
DL 사양 시나리오 모드. 룸컨에서 토글하며 운전모드(3.2)에 덧씌워진다. 여러 비트 동시 가능.
| 비트 | 마스크 | 의미 | 비고 (사양서 8~9P) |
|------|--------|------|--------------------|
| 0 | 0x01 | 스마트수면 | 자동·풍량1단 고정, 1시간마다 CO2 기준 댐퍼 |
| 1 | 0x02 | 쾌적조리 | 렌지후드 연동(아래 3.9), 환기·3단 급기 |
| 2 | 0x04 | 안심회복 | 침실1 음압, 환기·2단 |
> `0x00` = 부가모드 없음. (회복모드 중 수면모드 불가 등 배타조건은 ERV 로직에서 처리)
### 3.9 후드 연동 (Hood, 비트맵)
렌지후드와 전열교환기 연동. 후드 동작 시 ERV도 동작해야 하므로 ERV가 후드 상태를 감지·연동한다(HOOD-485). 쾌적조리(3.8 bit1) 활성 시 적용.
| 비트 | 마스크 | 의미 |
|------|--------|------|
| 0 | 0x01 | 후드연동 활성화(enable) |
| 1 | 0x02 | 후드 현재 동작중(ON) |
> 예: `0x03` = 연동 ON + 후드 가동중 → ERV 환기·3단 급기. `0x00` = 연동 없음.
---
# 상위 버스 — ERV ↔ 각실분배기
> ERV(Master)가 분배기(유일 Slave)를 폴링한다. 분배기는 하위에서 모은 전 실 데이터를 **집계해 응답**한다.
> 멀티 분배기 확장 대비로 페이로드 선두에 `nodeId`(분배기 번호, 기본 `0x01`)를 둔다.
## 4. 상위 CMD 및 PAYLOAD
| CMD | 이름 | 방향 | PAYLOAD | 설명 |
|------|------|------|---------|------|
| 0x10 | `CMD_SYSTEM` | ERV→분배기 | 4.1 | 전원/모드/풍량/예약 + 실별 댐퍼·LED 타겟 (폴링 겸용) |
| 0x12 | `POLL_SENSOR` | ERV→분배기 | nodeId(1) | 전 실 센서 집계 요청 |
| 0x14 | `CMD_CONFIG` | ERV→분배기 | 4.5 | VSP 테이블·장치개수·ID 설정/저장 |
| 0x1F | `POLL_SPEC` | ERV→분배기 | nodeId(1) | 분배기 사양/버전 요청 |
| 0x90 | `RSP_ALLROOM_STATUS` | 분배기→ERV | 4.2 | 전 실 댐퍼·RPM·LED·룸컨명령·에러 집계 (0x10 응답) |
| 0x92 | `RSP_ALLROOM_SENSOR` | 분배기→ERV | 4.3 | 전 실 통합공기질 센서 집계 (0x12 응답) |
| 0x94 | `RSP_CONFIG` | 분배기→ERV | 4.5 | 설정 에코/저장결과 (0x14 응답) |
| 0x9F | `RSP_SPEC` | 분배기→ERV | 4.6 | 분배기 사양/버전 |
### 4.1 `CMD_SYSTEM` (0x10, ERV→분배기)
**글로벌(off 0~15, 16B)** + **실별 타겟 블록(6B) × 5실**
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 1 | nodeId | 분배기 번호(0x01) |
| 1 | 1 | power | 3.1 |
| 2 | 1 | runMode | 3.2 |
| 3 | 1 | fanSpeed | 3.3 |
| 4 | 1 | addMode | 부가모드 비트맵 (3.8) |
| 5 | 1 | hood | 후드 연동 비트맵 (3.9) |
| 6 | 1 | reserveHour | 0~8시간 |
| 7 | 4 | errorCode | ERV 시스템 에러 통보 (3.6) |
| 11 | 2 | outTemp | 외기온도 ×10 (signed) |
| 13 | 2 | inTemp | 내기온도 ×10 (signed) |
| 15 | 1 | roomCount | 실 수(=5) |
실별 타겟 블록(6B, roomCount회 반복) :
| off(상대) | 크기 | 필드 | 비고 |
|-----------|------|------|------|
| +0 | 1 | roomNo | 3.7 |
| +1 | 1 | saDamper1 | SA 디퓨저1 목표각 (3.5) |
| +2 | 1 | saDamper2 | SA 디퓨저2 (거실만, 그 외 0xFF) |
| +3 | 1 | raDamper1 | RA 디퓨저1 목표각 |
| +4 | 1 | raDamper2 | RA 디퓨저2 (거실만, 그 외 0xFF) |
| +5 | 1 | ledDim | 해당 실 RA 디퓨저 조명 밝기 (3.4) |
LEN = 16 + 6×5 = **46**.
### 4.2 `RSP_ALLROOM_STATUS` (0x90, 분배기→ERV)
**글로벌(8B)** + **실별 상태 블록(17B) × 5실**
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 1 | nodeId | |
| 1 | 1 | bunbagiState | 분배기 동작/에러 요약 |
| 2 | 1 | addModeReq | 룸컨이 요청한 부가모드 비트맵 (3.8) |
| 3 | 4 | errorCode | 전 실 통신·센서 에러 집계 (3.6) |
| 7 | 1 | roomCount | =5 |
실별 상태 블록(17B) :
| off(상대) | 크기 | 필드 | 비고 |
|-----------|------|------|------|
| +0 | 1 | roomNo | |
| +1 | 1 | saDamper1 | 실제 각도 |
| +2 | 1 | saDamper2 | (없으면 0xFF) |
| +3 | 1 | raDamper1 | |
| +4 | 1 | raDamper2 | |
| +5 | 2 | saRpm | SA 팬 실측 RPM |
| +7 | 2 | raRpm | RA 팬 실측 RPM |
| +9 | 1 | ledDim | 현재 RA 조명 LED 밝기 |
| +10 | 1 | rcCmdFlags | 룸컨 명령 비트(아래 5.5 cmdFlags 동일, bit7=부가모드 변경) |
| +11 | 1 | rcPower | 룸컨 설정 전원 |
| +12 | 1 | rcRunMode | 룸컨 설정 모드 |
| +13 | 1 | rcFanSpeed | 룸컨 설정 풍량 |
| +14 | 1 | rcReserveHour | 룸컨 설정 예약 |
| +15 | 1 | rcHeaterUV | bit0=히터,bit4=UV |
| +16 | 1 | rcFilterReset | 1=필터리셋 요청 |
LEN = 8 + 17×5 = **93**.
> `rcCmdFlags` 가 0 이 아니면, 해당 실 룸컨에서 사용자 조작이 발생했다는 뜻 → ERV가 수용해 운전상태 갱신.
### 4.3 `RSP_ALLROOM_SENSOR` (0x92, 분배기→ERV)
**글로벌(2B)** + **실별 센서 블록(19B) × 5실**
| off | 크기 | 필드 |
|-----|------|------|
| 0 | 1 | nodeId |
| 1 | 1 | roomCount(=5) |
실별 센서 블록(19B) :
| off(상대) | 크기 | 필드 | 단위 |
|-----------|------|------|------|
| +0 | 1 | roomNo | |
| +1 | 2 | pm1p0 | ㎍/㎥ |
| +3 | 2 | pm2p5 | ㎍/㎥ |
| +5 | 2 | pm4p0 | ㎍/㎥ |
| +7 | 2 | pm10p0 | ㎍/㎥ |
| +9 | 2 | humidity | %RH ×10 |
| +11 | 2 | temperature | ℃ ×10 (signed) |
| +13 | 2 | voc | TVOC index |
| +15 | 2 | nox | NOx index |
| +17 | 2 | co2 | ppm |
LEN = 2 + 19×5 = **97**.
### 4.5 `CMD_CONFIG` (0x14) / `RSP_CONFIG` (0x94)
VSP 풍량 테이블·장치개수·Modbus ID 설정.
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 1 | nodeId | |
| 1 | 1 | vspSelect | 0=None,1=환기,2=공청,3=바이패스 |
| 2 | 10 | sa1,ea1 … sa5,ea5 | 단별 VSP (각 1B) |
| 12 | 2 | rpmRefMid | 중 RPM 기준 |
| 14 | 2 | rpmDeltaMid | 중 RPM 편차 |
| 16 | 2 | rpmRefHigh | 강 RPM 기준 |
| 18 | 2 | rpmDeltaHigh | 강 RPM 편차 |
| 20 | 1 | roomconNum | 룸컨 수(1~5) |
| 21 | 1 | saDiffuserNum | SA 디퓨저 수(2~6) |
| 22 | 1 | raDiffuserNum | RA 디퓨저 수(2~6) |
| 23 | 1 | modbusId | 외부 홈넷 연동 ID |
| 24 | 1 | save | 1=EEPROM 저장 |
LEN = 25. `RSP_CONFIG` 는 동일 레이아웃 에코 + save 결과.
### 4.6 `RSP_SPEC` (0x9F)
| off | 크기 | 필드 |
|-----|------|------|
| 0 | 1 | nodeId |
| 1 | 2 | version (예 0x0117=Ver1.23) |
| 3 | 1 | deviceType |
| 4 | 1 | capability (bit0 히터/bit1 UV/bit2 후드연동) |
LEN = 5.
---
# 하위 버스 — 각실분배기 ↔ 디퓨저 · 룸컨
> 분배기(로컬 Master)가 **MUX로 실 채널(ch1~ch5)을 선택**한 뒤 그 실의 SA/RA 디퓨저·룸컨을 폴링한다.
> 조명 LED(RA 디퓨저)는 `LCMD_DIFFUSER` 의 `ledDim` 으로 제어한다(3.4). 분배기 보드의 74HC595 LED는 별개(포트별 통신상태 표시, 진단용).
## 5. 하위 CMD 및 PAYLOAD
PAYLOAD 선두 3바이트는 장치 주소 `[DEV][ROOM][IDX]`.
- DEV : `0x10`=SA 디퓨저, `0x20`=RA 디퓨저, `0x30`=룸컨
- ROOM : 3.7 (채널과 1:1) / IDX : 같은 실·종류 일련번호(거실 SA·RA는 1,2)
| CMD | 이름 | 방향 | PAYLOAD | 설명 |
|------|------|------|---------|------|
| 0x20 | `LCMD_DIFFUSER` | 분배기→디퓨저 | 5.1 | 디퓨저 댐퍼/풍량 제어 + 폴링 |
| 0x21 | `LPOLL_ROOMCON` | 분배기→룸컨 | 5.4 | 룸컨 폴링 + ERV 상태 표시데이터 전달 |
| 0xA0 | `LRSP_DIFFUSER` | 디퓨저→분배기 | 5.2 | 댐퍼각/RPM/에러/버전 (0x20 응답) |
| 0xA1 | `LRSP_DIFFUSER_SENSOR` | 디퓨저→분배기 | 5.3 | 통합공기질 센서 (방별, 센서 부착 디퓨저) |
| 0xB0 | `LRSP_ROOMCON` | 룸컨→분배기 | 5.5 | 룸컨 사용자 명령 (0x21 응답) |
### 5.1 `LCMD_DIFFUSER` (0x20, 분배기→디퓨저)
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | DEV=0x10(SA)/0x20(RA) |
| 3 | 1 | power | 3.1 |
| 4 | 1 | runMode | 3.2 |
| 5 | 1 | fanSpeed | 3.3 |
| 6 | 1 | ledDim | RA 디퓨저 조명 밝기 (3.4) — **RA(0x20)만 유효, SA는 0** |
| 7 | 1 | dmpAngle | 목표 댐퍼각 (3.5) |
| 8 | 1 | dmpReset | 1=댐퍼 초기화 |
LEN = 9.
### 5.2 `LRSP_DIFFUSER` (0xA0, 디퓨저→분배기)
| off | 크기 | 필드 |
|-----|------|------|
| 0 | 3 | DEV/ROOM/IDX (에코) |
| 3 | 1 | power |
| 4 | 1 | runMode |
| 5 | 1 | fanSpeed |
| 6 | 1 | ledDim (현재 RA 조명 밝기, SA는 0) |
| 7 | 1 | dmpAngle (현재각) |
| 8 | 2 | rpm |
| 10 | 4 | errorCode (3.6) |
| 14 | 2 | version |
LEN = 16.
### 5.3 `LRSP_DIFFUSER_SENSOR` (0xA1, 디퓨저→분배기)
| off | 크기 | 필드 | 단위 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | 센서 부착 디퓨저(통상 RA) |
| 3 | 2 | pm1p0 | ㎍/㎥ |
| 5 | 2 | pm2p5 | ㎍/㎥ |
| 7 | 2 | pm4p0 | ㎍/㎥ |
| 9 | 2 | pm10p0 | ㎍/㎥ |
| 11 | 2 | humidity | %RH ×10 |
| 13 | 2 | temperature | ℃ ×10 |
| 15 | 2 | voc | TVOC |
| 17 | 2 | nox | NOx |
| 19 | 2 | co2 | ppm |
| 21 | 4 | errorCode | 3.6 |
LEN = 25.
### 5.4 `LPOLL_ROOMCON` (0x21, 분배기→룸컨)
분배기가 ERV로부터 받은 동작상태를 룸컨에 전달(표시용) + 룸컨 명령 회수.
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | DEV=0x30 |
| 3 | 1 | power | ERV 현재 전원 |
| 4 | 1 | runMode | ERV 현재 모드 |
| 5 | 1 | fanSpeed | ERV 현재 풍량 |
| 6 | 1 | addMode | 현재 부가모드 비트맵 (3.8) |
| 7 | 1 | hood | 후드 연동 상태 (3.9) |
| 8 | 1 | reserveRemain | 예약 잔여(h) |
| 9 | 4 | errorCode | 3.6 |
| 13 | 2 | outTemp | ×10 |
| 15 | 2 | inTemp | ×10 |
| 17 | 1 | ackFlags | 직전 룸컨 명령 수용 비트 |
LEN = 18.
### 5.5 `LRSP_ROOMCON` (0xB0, 룸컨→분배기)
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | DEV=0x30 |
| 3 | 1 | cmdFlags | bit0 power,1 runMode,2 fanSpeed,3 reserveHour,4 heaterUV,5 filterReset,6 ledDim,**7 addMode** |
| 4 | 1 | power | 3.1 |
| 5 | 1 | runMode | 3.2 |
| 6 | 1 | fanSpeed | 3.3 |
| 7 | 1 | reserveHour | 0~8 |
| 8 | 1 | heaterUV | bit0 히터,bit4 UV |
| 9 | 1 | filterReset | 1=리셋 |
| 10 | 1 | addMode | 부가모드 토글 비트맵 (3.8) |
| 11 | 2 | version | |
LEN = 13.
> 룸컨이 보낸 명령은 분배기가 모아 상위 `RSP_ALLROOM_STATUS` 의 `rc*` 필드로 ERV에 전달 → ERV가 판단 후 다시 하달.
---
## 6. 동작 시나리오 & 폴링 타이밍
**두 버스가 독립적으로 동작**한다.
**하위 버스 (분배기 로컬 루프)** — 분배기가 ch1~ch5를 MUX로 돌며 각 실의 SA/RA 디퓨저·룸컨 폴링. 케이블이 짧고 전용 채널이라 빠르게 순환하며 전 실 상태를 캐싱.
**상위 버스 (ERV 루프)** — ERV는 **분배기 1대만** 주기적으로 폴링:
1. ERV → 분배기 `CMD_SYSTEM(0x10)` (전원/모드/풍량 + 실별 댐퍼·LED 타겟) → 분배기 `RSP_ALLROOM_STATUS(0x90)` (전 실 댐퍼·RPM·LED·룸컨명령·에러)
2. ERV → 분배기 `POLL_SENSOR(0x12)` → 분배기 `RSP_ALLROOM_SENSOR(0x92)` (전 실 센서)
3. ERV가 센서로 **부하점수·집중/분산 계산**(사양서 10~11P) → 다음 `CMD_SYSTEM` 의 실별 댐퍼/LED/풍량 타겟에 반영
4. 룸컨 조작은 `RSP_ALLROOM_STATUS.rcCmdFlags`(+`addModeReq`) 로 ERV에 보고 → ERV 수용 → 다음 `CMD_SYSTEM` 으로 반영
5. **부가모드(3.8)** : 룸컨에서 스마트수면/쾌적조리/안심회복 토글 → ERV가 해당 시나리오 로직 수행(댐퍼·풍량 타겟 조정) → `CMD_SYSTEM.addMode` 로 현재 상태 회신(룸컨 표시)
6. **후드 연동(3.9)** : ERV가 HOOD-485로 후드 ON 감지 → (쾌적조리 활성 시) ERV 환기·3단 급기 동작 → `CMD_SYSTEM.hood` 로 후드 상태를 분배기·룸컨에 전달. 후드 OFF 시 이전 모드 복귀
7. 통신 단절 : 분배기가 실별 통신에러 비트(3.6)를 set 해 ERV에 보고, 재연결 시 clear
### 폴링 시간 (1회 = 300ms 가정)
| 버스 | 폴링 대상 | 1주기 |
|------|-----------|-------|
| **상위 (ERV↔분배기)** | 2회 (CMD_SYSTEM + POLL_SENSOR) | **600 ms** |
| 하위 (분배기↔실) | 17회(SA6+RA6+룸컨5) — **로컬에서 병렬 진행** | 분배기 내부에서 순환(ERV와 무관) |
> Rev 2.0 은 ERV가 17대를 직접 폴링 → 1주기 **5.1초**.
> Rev 3.0 은 ERV 입장에서 **0.6초**면 전 실 상태를 받는다. 하위 루프는 분배기가 별도로 빠르게 순환하므로 ERV 주기와 분리된다. → **갱신 약 8배 빨라지고 ERV 부하 격감.**
---
## 7. 펌웨어 반영 메모
- **분배기 펌웨어(Nano100)** : 신규. UART1(`SA485`, PB.4/PB.5)=하위 로컬 마스터, UART2(`M485`, PA.8/PA.9)=상위 슬레이브. MUX(SA_MUX_A/B/C/EN)로 채널 선택, DIR(SA485_DIR_01~05) 채널별. 74HC595(LED_DS/SCK/LCK)는 **포트별 SA/RA 통신상태 표시 LED** 구동(진단용). 방 조명 LED는 RA 디퓨저가 자체 점등하므로 ledDim 명령만 중계.
- **ERV 펌웨어** : `My_Uart.c` 의 17대 직접 폴링 → **분배기 1대 상위 프로토콜**로 교체. 자동로직(부하점수·집중/분산)은 ERV에 유지.
- **`My_RJ2.c`(DL 룸컨 232)는 변경하지 않는다.**
- CRC : 기존 `CRC16()`(MODBUS) 공유, 빅엔디안(Hi→Lo).
- 에러코드 `uint32_t` (방4 비트 수용).
---
## 8. 송수신 예제
> 모든 프레임 CRC는 **STX 제외 `CMD~PAYLOAD`** 구간 CRC-16/MODBUS(빅엔디안)의 실제 계산값.
> 예시 : runMode=`0x02`(자동), fan=`2`(중), addMode=`0x02`(쾌적조리), hood=`0x03`(연동ON+가동중), 댐퍼 `0xB4`=열림/`0x00`=닫힘/`0xFF`=없음, 온·습도 ×10. 폴링 1회=300ms.
### 8-A. 상위 버스 (ERV ↔ 분배기)
**① ERV → 분배기 : 전원ON·자동·풍량2·쾌적조리·후드가동 + 실별 댐퍼/LED 타겟** — `CMD_SYSTEM(0x10)` *(t=0~300ms)*
```
[TX] AA 10 2E | 01 01 02 02 02 03 00 00000000 0096 00DC 05 | <실1..5 타겟 6B> | AA 01
CMD LEN node pw md fan add hood rsv err 외15℃ 내22℃ rooms=5
↑쾌적조리 ↑후드연동ON+가동
실1(거실): 01 B4 B4 B4 B4 05 (SA1·SA2·RA1·RA2 열림, RA조명 LED 5)
실2(방1) : 02 B4 FF B4 FF 04
실3(방2) : 03 00 FF 00 FF 00 (댐퍼 닫힘, LED OFF)
실4(방3) : 04 B4 FF B4 FF 06
실5(방4) : 05 B4 FF B4 FF 03
```
**② 분배기 → ERV : 전 실 상태 집계** — `RSP_ALLROOM_STATUS(0x90)`
```
[RX] AA 90 5D | 01 00 02 00000000 05 | <실1..5 상태 17B> | C5 31
node bunSt addModeReq=02 err rooms=5
실1(거실): 01 B4 B4 B4 B4 0352 0334 05 00 01 02 02 00 00 00
roomNo SA1 SA2 RA1 RA2 saRpm=850 raRpm=820 ledDim | rcFlags pw md fan rsv hu fr
실2(방1) : 02 B4 FF B4 FF 0348 032A 04 00 01 02 02 00 00 00
실3(방2) : 03 00 FF 00 FF 0000 0000 00 00 01 02 02 00 00 00
실4(방3) : 04 B4 FF B4 FF 034D 032F 06 00 01 02 02 00 00 00
실5(방4) : 05 B4 FF B4 FF 0350 0332 03 00 01 02 02 00 00 00
```
**③ ERV → 분배기 : 센서 요청 / 분배기 응답** — `POLL_SENSOR(0x12)``RSP_ALLROOM_SENSOR(0x92)` *(t=300~600ms)*
```
[TX] AA 12 01 | 01 | <CRC>
[RX] AA 92 61 | 01 05 | <실1..5 센서 19B> | DC 05
실1(거실): 01 0005 0008 0009 000B 01C2 00DC 0064 0001 028A
roomNo pm1 pm2.5 pm4 pm10 습45.0% 온22.0℃ voc100 nox1 co2=650
실2(방1) : 02 0007 000C 000D 0012 01C4 00DD 0082 0001 02D0 (co2=720)
실3(방2) : 03 0004 0006 0007 0009 01C0 00DC 005A 0001 0262 (co2=610)
실4(방3) : 04 0009 0014 0016 001C 01C7 00DB 00A0 0002 032A (co2=810)
실5(방4) : 05 0005 0007 0008 000A 01C1 00DC 005F 0001 0280 (co2=640)
```
> ERV는 ②③으로 **전 실 상태+센서를 0.6초만에** 확보 → 부하점수·집중/분산 계산 → 다음 `CMD_SYSTEM` 타겟 갱신.
### 8-B. 하위 버스 (분배기 ↔ 디퓨저/룸컨) — 분배기 로컬 루프
**거실 RA1 디퓨저 제어/폴링**`LCMD_DIFFUSER(0x20)``LRSP_DIFFUSER(0xA0)` (RA = 조명 LED 보유)
```
[TX] AA 20 09 | 20 01 01 | 01 02 02 05 B4 00 | 6E 2B (RA,거실,1 / power mode fan led=5 dmp=B4 reset=0)
[RX] AA A0 10 | 20 01 01 | 01 02 02 05 B4 0352 00000000 0117 | ... (led현재5, dmp B4, rpm850, err0, ver1.23)
```
> SA 디퓨저는 LED가 없으므로 `ledDim=0`. 예) 거실 SA1 TX: `AA 20 09 10 01 01 01 02 02 00 B4 00 6E 6F` → RX `AA A0 10 10 01 01 01 02 02 00 B4 03 52 00 00 00 00 01 17 B9 17`.
**거실 RA1 디퓨저 센서**`LRSP_DIFFUSER_SENSOR(0xA1)`
```
[RX] AA A1 19 20 01 01 00 05 00 08 00 09 00 0B 01 C2 00 DC 00 64 00 01 02 8A 00 00 00 00 58 AE
(RA,거실,1) pm1=5 pm2.5=8 pm4=9 pm10=11 습45.0% 온22.0℃ voc100 nox1 co2=650 err0
```
**거실 룸컨 폴링**`LPOLL_ROOMCON(0x21)``LRSP_ROOMCON(0xB0)`
```
[TX] AA 21 12 30 01 01 01 02 02 02 03 00 00 00 00 00 00 96 00 DC 00 92 FA
(ERV상태 전달: 자동·풍량2·쾌적조리(02)·후드연동ON+가동(03))
[RX] AA B0 0D 30 01 01 80 01 02 02 00 00 00 02 01 10 34 4D
cmdFlags=80(부가모드 변경) ... addMode=02(쾌적조리 토글) ver0x0110
```
> 룸컨이 부가모드(쾌적조리)를 토글하면 `cmdFlags` bit7=1·`addMode`=0x02 로 보고 → 분배기가 상위 `RSP_ALLROOM_STATUS.addModeReq`·`rcCmdFlags`에 실어 ERV에 전달 → ERV가 후드연동 운전 수행.
---
> 본 문서는 2-Tier 재정의 초안(Rev 3.0)이다. 분배기/디퓨저/룸컨 펌웨어 담당과 CMD 코드·필드 세부값을 상호 합의하여 확정한다.
@@ -0,0 +1,489 @@
# 각실제어 내부 통신 프로토콜 (Rev 2.0, CMD 기반)
> 휴벤ECO(ERV) ↔ 각실분배기 ↔ 디퓨저 ↔ 룸컨 **내부 RS-485 통신** 규격
>
> 본 문서는 `Protocol/수정_Each_Room_Jushin_protocol_RS485_Rev1.2` (주신전자) 와
> 펌웨어 `program/User/My_Uart.c` (각실분배기/디퓨저 폴링) 구현을 기반으로,
> **CMD 기반으로 단순화·확장**하여 새로 정의한 규격이다.
> (DL 룸컨 232 프로토콜 `My_RJ2.c` 는 DL 사양으로 본 규격과 무관 — 변경하지 않는다.)
- **물리계층** : RS-485, **115200 bps, 8 Data, None Parity, 1 Stop (N81)**
- **토폴로지** : 1 Master(메인보드) ↔ N Slave(디퓨저/룸컨), 메인보드 폴링 방식
- **대상 시스템** : 사양서 5페이지(힘펠 배선도) 구성 = **거실 + 방1~4 (5실)**, 각 실 룸컨
- 디퓨저 : 거실 SA 2·RA 2, 방1~4 각 SA 1·RA 1 → **SA 디퓨저 6 + RA 디퓨저 6**, 룸컨 5
---
## 0. 무엇이 바뀌었나 (Rev 1.2 → Rev 2.0)
| 구분 | 기존 (Rev 1.2) | 개선 (Rev 2.0) |
|------|----------------|----------------|
| 패킷 식별 | `VSP Mode` 값(0x00/0x11/0x12/0x01~03/0x10)에 따라 **같은 byte 위치의 의미가 계속 바뀜** | **CMD 1바이트로 패킷 종류 고정** → 한 패킷은 항상 같은 레이아웃 |
| 거실 공기질 | 별도 모드 0x11 / 0x12 로 방을 쪼개서 전송 | **방별 센서 패킷 1종으로 통합**, ROOM 필드로 구분 |
| 길이 | 29/39 byte 가변, 위치로 추정 | **LEN 필드 명시** → 파싱 단순 |
| 장치 주소 | ID1(종류)+ID2(번호) 혼용, 거실=5 등 불규칙 | **DEV / ROOM / IDX 3필드**로 직관적 주소화 |
| 에러코드 | **16비트 전부 소진** → 방4 추가 불가 (한계) | **32비트로 확장** → 방4 + 향후 여유 |
| 확장성 | 4실 고정 | **5실(거실+방1~4) + 룸컨 5 / SA·RA 디퓨저 각 6** 명시 |
| 역할 정의 | 명령/상태 방향이 패킷마다 섞여 모호 | **버스 마스터=ERV(폴링) / 명령 주체=룸컨 / 실행=ERV→디퓨저** 로 명확화 |
> **제어 권한 정리** : 사용자는 룸컨에서 전원·모드·풍량·예약·필터리셋과 VSP 풍량 테이블을 설정한다.
> 룸컨은 버스상 Slave이므로 이 명령들을 ERV 폴링에 대한 응답(`CMD_ROOMCON`/`CMD_VSP_SET`)으로 올린다.
> ERV는 명령을 받아 분배기를 통해 디퓨저 댐퍼·풍량·LED를 제어하고, 센서값을 읽어 룸컨에 전달한다.
---
## 1. 공통 프레임
```
+------+------+------+------------------+--------+--------+
| STX | CMD | LEN | PAYLOAD[LEN] | CRC_H | CRC_L |
+------+------+------+------------------+--------+--------+
0xAA 1B 1B LEN bytes 16-bit CRC
```
| 필드 | 크기 | 설명 |
|---------|------|------|
| STX | 1 | 고정 `0xAA` |
| CMD | 1 | 명령/응답 코드 (2장) |
| LEN | 1 | PAYLOAD 바이트 수 (0~255) |
| PAYLOAD | LEN | CMD 별 데이터 (3·4장) |
| CRC | 2 | **CRC-16/MODBUS** (poly 0xA001, init 0xFFFF), **CMD~PAYLOAD** 까지, **빅엔디안(CRC_H 먼저)** |
- 모든 멀티바이트 수치는 **빅엔디안(상위 바이트 먼저)**. (CRC 포함, 펌웨어 `My_Uart.c` 관례 유지)
- 프레임 구분 : `STX(0xAA)` 탐색 → `LEN` 으로 길이 확보 → `CRC` 검증.
- 수신 타임아웃(예: 50ms) 내 미완성 프레임은 폐기하고 STX 재탐색.
> **CRC 계산** : `My_Uart.c` 의 `CRC16()` (MODBUS 룩업테이블) 동일. 결과 16비트를 `CRC_H = (crc>>8)`, `CRC_L = (crc&0xFF)` 순으로 전송.
---
## 2. 명령 코드 (CMD)
**버스 레벨과 제어 권한을 구분해서 이해해야 한다.**
- **버스 마스터 = ERV(메인보드)** : 모든 통신을 ERV가 폴링한다. 룸컨/디퓨저는 폴링을 받아야만 송신하는 Slave.
- **제어 권한(명령 발생) = 룸컨** : 사용자가 룸컨에서 전원/모드/풍량/예약/필터리셋, **VSP 풍량 테이블 설정·저장**을 한다.
룸컨은 Slave이므로 이 명령들을 ERV 폴링에 대한 **응답에 실어** ERV로 보낸다 (룸컨 → ERV).
- **실행 = ERV** : 룸컨 명령을 받아 분배기를 통해 **디퓨저 댐퍼 개폐·풍량·LED를 제어**하고, **센서값을 읽어 룸컨에 전달**한다.
즉 데이터 흐름은: **룸컨(명령) → ERV(실행) → 디퓨저(댐퍼/센서) → ERV(센서수집) → 룸컨(표시)**.
CMD 상위 비트로 송신 방향을 구분한다. **0x10~0x7F = ERV(Master) 송신**, **0x90~0xFF = Slave 송신(응답)**.
### 2.1 ERV(메인보드, Master) 송신
| CMD | 이름 | 대상 | PAYLOAD | 설명 |
|------|----------------------|--------|---------|------|
| 0x10 | `CMD_DIFFUSER_CTRL` | 디퓨저 | 4.1 | **ERV가 디퓨저 제어**(댐퍼각/풍량/LED/리셋) + 폴링. ERV가 제어 주체 |
| 0x20 | `POLL_ROOMCON` | 룸컨 | 4.3 | ERV가 룸컨 폴링 + **표시용 데이터 전달**(ERV 동작상태/해당 실 공기질/에러/온도). 명령 수용 결과 echo |
| 0x30 | `RSP_VSP_STATUS` | 룸컨 | 4.5 | 룸컨 요청에 대한 **현재 VSP 풍량 테이블 값 회신**(룸컨 표시용) |
| 0x40 | `POLL_SPEC` | 공통 | 없음 | 장치 사양/버전 요청 |
### 2.2 Slave(룸컨/디퓨저) 송신 = 명령/상태 보고
| CMD | 이름 | 송신자 | PAYLOAD | 설명 |
|------|------------------------|--------|---------|------|
| 0xA0 | `CMD_ROOMCON` | 룸컨 | 4.4 | **룸컨이 사용자 명령 전달**: 전원/모드/풍량/예약/히터·UV/필터리셋 (0x20 응답) |
| 0xB0 | `CMD_VSP_SET` | 룸컨 | 4.6 | **룸컨이 VSP 풍량 테이블 설정·저장** + 장치 개수 설정 (0x20 응답, VSP 세팅모드) |
| 0x90 | `RSP_DIFFUSER_STATUS` | 디퓨저 | 4.2 | 디퓨저 댐퍼각/공기질/RPM/에러/버전 (0x10 응답) |
| 0x91 | `RSP_DIFFUSER_SENSOR` | 디퓨저 | 4.7 | 통합공기질 센서값(PM/온습도/VOC/NOx/CO2) (방별) |
| 0xC0 | `RSP_SPEC` | 공통 | 4.8 | 사양/버전 (0x40 응답) |
> 모든 패킷은 대상 장치 주소(DEV/ROOM/IDX)를 선두에 싣고, 응답은 이를 그대로 에코한다.
> `CMD_ROOMCON`(0xA0)·`CMD_VSP_SET`(0xB0)은 이름은 "CMD"지만 버스상으로는 **룸컨이 ERV 폴링(0x20)에 응답하는 형태**로 전송된다. (룸컨이 명령 주체이기 때문)
---
## 3. 공통 값 정의
### 3.1 장치 주소 (DEV / ROOM / IDX)
각 PAYLOAD 선두 3바이트는 항상 장치 주소다.
| 필드 | 크기 | 값 | 의미 |
|------|------|----|------|
| DEV | 1 | `0x10`=SA 디퓨저, `0x20`=RA 디퓨저, `0x30`=룸컨 | 장치 종류 |
| ROOM | 1 | `1`=거실, `2`=방1, `3`=방2, `4`=방3, `5`=방4 | 실 번호 |
| IDX | 1 | `1`~ | 같은 실·같은 종류 장치의 일련번호 (거실 SA·RA는 `1`,`2`) |
**5실 디바이스 맵 (힘펠 배선도 기준 — SA 6 / RA 6 / 룸컨 5)**
| 실 | ROOM | SA 디퓨저 | RA 디퓨저 | 룸컨 |
|----|------|-----------|-----------|------|
| 거실 | 1 | (0x10,1,1) 거실급기1 · (0x10,1,2) 거실급기2 | (0x20,1,1) 거실배기1 · (0x20,1,2) 거실배기2 | (0x30,1,1) |
| 방1 | 2 | (0x10,2,1) | (0x20,2,1) | (0x30,2,1) |
| 방2 | 3 | (0x10,3,1) | (0x20,3,1) | (0x30,3,1) |
| 방3 | 4 | (0x10,4,1) | (0x20,4,1) | (0x30,4,1) |
| 방4 | 5 | (0x10,5,1) | (0x20,5,1) | (0x30,5,1) |
> 방1~4 구성은 모두 동일(SA 1 · RA 1 · 룸컨 1). 거실만 SA·RA 각 2대.
### 3.2 전원 (Power)
| 값 | 의미 |
|----|------|
| 0x00 | OFF |
| 0x01 | ON |
### 3.3 운전모드 (RunMode)
| 값 | 의미 | 펌웨어 매핑 |
|----|------|-------------|
| 0x01 | 수동(환기) | MODE_VENTILATION |
| 0x02 | 자동 | MODE_AUTO |
| 0x04 | 바이패스 | MODE_BYPASS |
| 0x08 | 공기청정 | MODE_AIRCLEAN |
### 3.4 풍량 (FanSpeed)
| 값 | 의미 |
|----|------|
| 0x00 | OFF/정지 |
| 0x01 | 1단 (약) |
| 0x02 | 2단 (중) |
| 0x03 | 3단 (강) |
| 0x04 | 4단 (터보) |
> 자동/공청 모드에서는 메인보드 로직(부하점수)이 단수를 결정하므로 디퓨저로 보내는 풍량은 계산 결과값.
### 3.5 LED 밝기 (LedDim)
| 값 | 의미 |
|----|------|
| 0x00 | OFF |
| 0x01~0x0A | 1~10단 (10단 최대) |
> 월패드/스마트스위치 UI는 0~9 단계. 0=OFF 포함 시 0~10으로 매핑.
### 3.6 댐퍼 각도 (DamperAngle)
| 값 | 의미 |
|----|------|
| 0x00 | 0° (닫힘) |
| 0x00~0xB4 | 0~180° (0xB4 = 180° 완전개방) |
### 3.7 에러코드 (ErrorCode, **u32 비트맵** — 확장)
| 비트 | 마스크 | 의미 |
|------|--------|------|
| 0 | 0x00000001 | 필터 청소 |
| 1 | 0x00000002 | 필터 교체 |
| 2 | 0x00000004 | 소자 교체 |
| 3 | 0x00000008 | 온도센서 에러 |
| 4 | 0x00000010 | 장비보호 모드 |
| 5 | 0x00000020 | EA 팬 에러 |
| 6 | 0x00000040 | 간헐운전 모드 |
| 7 | 0x00000080 | SA 팬 에러 |
| 8 | 0x00000100 | 통합센서 에러 — 거실 |
| 9 | 0x00000200 | 통합센서 에러 — 방1 |
| 10 | 0x00000400 | 통합센서 에러 — 방2 |
| 11 | 0x00000800 | 통합센서 에러 — 방3 |
| 12 | 0x00001000 | **통합센서 에러 — 방4 (신규)** |
| 16 | 0x00010000 | 통신 에러 — 거실 |
| 17 | 0x00020000 | 통신 에러 — 방1 |
| 18 | 0x00040000 | 통신 에러 — 방2 |
| 19 | 0x00080000 | 통신 에러 — 방3 |
| 20 | 0x00100000 | **통신 에러 — 방4 (신규)** |
> 비트 13~15, 21~31 은 향후 확장용 예약.
---
## 4. PAYLOAD 상세
> 모든 PAYLOAD는 `[DEV][ROOM][IDX]` 3바이트로 시작. (3.1)
> `u16` 은 빅엔디안. 아래 off 는 PAYLOAD 내 상대 오프셋.
### 4.1 `CMD_DIFFUSER_CTRL` (0x10, Master→디퓨저)
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | DEV=0x10(SA) 또는 0x20(RA) |
| 3 | 1 | power | 3.2 |
| 4 | 1 | runMode | 3.3 |
| 5 | 1 | fanSpeed | 3.4 |
| 6 | 1 | ledDim | 3.5 (SA 디퓨저만 유효) |
| 7 | 1 | dmpAngle | 3.6 (해당 포트의 댐퍼 목표각) |
| 8 | 1 | dmpReset | 0=정상 / 1=댐퍼 초기화 |
| 9 | 1 | reserveHour | 예약 정지 0~8시간 (0=없음) |
LEN = 10.
### 4.2 `RSP_DIFFUSER_STATUS` (0x90, 디퓨저→Master)
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | 요청 에코 |
| 3 | 1 | power | 현재 전원 |
| 4 | 1 | runMode | 현재 운전모드 |
| 5 | 1 | fanSpeed | 현재 풍량 |
| 6 | 1 | ledDim | 현재 LED 밝기 |
| 7 | 1 | dmpAngle | 현재 댐퍼 각도 (3.6) |
| 8 | 2 | rpm | 해당 팬 실측 RPM (u16) |
| 10 | 4 | errorCode | 3.7 (u32) |
| 14 | 2 | version | 예) 0x0117 = Ver 1.23 |
LEN = 16.
### 4.3 `POLL_ROOMCON` (0x20, ERV→룸컨)
ERV가 룸컨을 폴링하면서 **룸컨 화면에 표시할 데이터**(ERV 동작상태·에러·온도)를 전달하고,
직전에 받은 룸컨 명령의 **수용 결과(ackFlags)** 를 에코한다.
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | DEV=0x30 |
| 3 | 1 | power | ERV 현재 전원 (3.2) |
| 4 | 1 | runMode | ERV 현재 운전모드 (3.3) |
| 5 | 1 | fanSpeed | ERV 현재 풍량 (3.4) |
| 6 | 1 | autoState | 0=분산, 1=집중 (자동모드) |
| 7 | 1 | reserveRemain | 예약 잔여 시간(hour) |
| 8 | 4 | errorCode | ERV 현재 에러 통보 (3.7, u32) |
| 12 | 2 | outTemp | 외기온도 ×10 (signed, ℃) |
| 14 | 2 | inTemp | 내기온도 ×10 (signed, ℃) |
| 16 | 1 | ackFlags | 직전 룸컨 명령 수용 비트(4.4 cmdFlags 동일 배치) |
LEN = 17.
### 4.4 `CMD_ROOMCON` (0xA0, 룸컨→ERV) — **룸컨이 명령 주체**
사용자가 룸컨에서 조작한 명령을 ERV에 전달한다. `cmdFlags`**이번에 실제로 바꾼 항목만** 표시한다(나머지는 무시).
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | DEV=0x30, ERV 폴링(0x20)에 대한 응답으로 송신 |
| 3 | 1 | cmdFlags | bit0 power, bit1 runMode, bit2 fanSpeed, bit3 reserveHour, bit4 heaterUV, bit5 filterReset, bit6 ledDim (1=이 필드 명령 유효) |
| 4 | 1 | power | 3.2 |
| 5 | 1 | runMode | 3.3 |
| 6 | 1 | fanSpeed | 3.4 |
| 7 | 1 | reserveHour | 0~8시간 |
| 8 | 1 | heaterUV | bit0=히터, bit4=UV (1=ON) |
| 9 | 1 | filterReset | 1=필터 리셋 |
| 10 | 1 | ledDim | 3.5 (해당 실 디퓨저 LED 밝기) |
| 11 | 2 | version | 룸컨 버전 |
LEN = 13.
> ERV는 받은 명령을 실행(분배기→디퓨저 제어)하고, 다음 `POLL_ROOMCON(0x20)`의 `ackFlags`·동작상태로 결과를 회신한다.
### 4.5 `RSP_VSP_STATUS` (0x30, ERV→룸컨)
룸컨이 현재 VSP 값을 요청(`CMD_VSP_SET` 의 reqStatus=1)하면, ERV가 **저장된 현재 VSP 풍량 테이블**을 회신한다(룸컨 표시용). 레이아웃은 4.6과 동일(끝의 save/장치개수 필드는 제외, vspSelect로 어떤 모드값인지 표시).
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | DEV=0x30 |
| 3 | 1 | vspSelect | 회신하는 모드 (`1`=환기/`2`=공청/`3`=바이패스) |
| 4 | 10 | sa1,ea1 … sa5,ea5 | SA/EA 1~5단 VSP (각 1B) |
| 14 | 2 | rpmRefMid | 중(2단) RPM 기준 |
| 16 | 2 | rpmDeltaMid | 중(2단) RPM 허용편차 |
| 18 | 2 | rpmRefHigh | 강(3단) RPM 기준 |
| 20 | 2 | rpmDeltaHigh | 강(3단) RPM 허용편차 |
LEN = 22.
### 4.6 `CMD_VSP_SET` (0xB0, 룸컨→ERV) — **VSP 설정·저장 주체는 룸컨**
룸컨에서 VSP 풍량 테이블을 한 모드씩(vspSelect) 설정하고 ERV에 저장 요청. 폴링할 장치 개수도 함께 설정.
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | DEV=0x30, ERV 폴링(0x20)에 대한 응답으로 송신 |
| 3 | 1 | vspSelect | `0`=None, `1`=환기, `2`=공청, `3`=바이패스 |
| 4 | 1 | sa1 | SA 1단 VSP |
| 5 | 1 | ea1 | EA 1단 VSP |
| 6 | 1 | sa2 | SA 2단 |
| 7 | 1 | ea2 | EA 2단 |
| 8 | 1 | sa3 | SA 3단 |
| 9 | 1 | ea3 | EA 3단 |
| 10 | 1 | sa4 | SA 4단 |
| 11 | 1 | ea4 | EA 4단 |
| 12 | 1 | sa5 | SA 5단 |
| 13 | 1 | ea5 | EA 5단 |
| 14 | 2 | rpmRefMid | 중(2단) RPM 기준 (환기/공청만) |
| 16 | 2 | rpmDeltaMid | 중(2단) RPM 허용편차 |
| 18 | 2 | rpmRefHigh | 강(3단) RPM 기준 |
| 20 | 2 | rpmDeltaHigh | 강(3단) RPM 허용편차 |
| 22 | 1 | roomconNum | 폴링할 룸컨 수 (1~5) |
| 23 | 1 | saDiffuserNum | 폴링할 SA 디퓨저 수 (2~6) |
| 24 | 1 | raDiffuserNum | 폴링할 RA 디퓨저 수 (2~6) |
| 25 | 1 | modbusId | 외부 홈넷 연동용 ID (선택) |
| 26 | 1 | save | 0=저장안함, 1=EEPROM 저장 |
| 27 | 1 | reqStatus | 1=현재 VSP 값 회신 요청(ERV가 `RSP_VSP_STATUS`로 응답) |
LEN = 28.
### 4.7 `RSP_DIFFUSER_SENSOR` (0x91, 디퓨저→Master)
통합공기질 센서(SEN66) 1실분. 기존 0x11/0x12 분리 모드를 **이 패킷 1종**으로 대체.
| off | 크기 | 필드 | 단위/비고 |
|-----|------|------|-----------|
| 0 | 3 | DEV/ROOM/IDX | 센서 부착 디퓨저(통상 RA, DEV=0x20) |
| 3 | 2 | pm1p0 | ㎍/㎥ |
| 5 | 2 | pm2p5 | ㎍/㎥ |
| 7 | 2 | pm4p0 | ㎍/㎥ |
| 9 | 2 | pm10p0 | ㎍/㎥ |
| 11 | 2 | humidity | %RH ×10 |
| 13 | 2 | temperature | ℃ ×10 (signed) |
| 15 | 2 | voc | TVOC index |
| 17 | 2 | nox | NOx index |
| 19 | 2 | co2 | ppm |
| 21 | 4 | errorCode | 센서/통신 에러 (3.7) |
LEN = 25.
### 4.8 `RSP_SPEC` (0xC0)
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | |
| 3 | 2 | version | 펌웨어 버전 |
| 5 | 1 | deviceType | 장치 타입 코드 |
| 6 | 1 | capability | bit0=히터, bit1=UV, bit2=후드연동 … |
LEN = 7.
---
## 5. 동작 시나리오 (폴링)
ERV가 버스 마스터로서 **룸컨 → 디퓨저 순으로 라운드로빈 폴링**하고, 명령은 룸컨이 응답에 실어 올린다.
1. **룸컨 폴링**
- ERV → 룸컨 : `POLL_ROOMCON(0x20)` (ERV 동작상태·해당 실 공기질·온도·에러 전달 + 직전 명령 ack)
- 룸컨 → ERV : 사용자가 조작했으면 `CMD_ROOMCON(0xA0)` (cmdFlags로 바뀐 항목 표시), VSP 설정중이면 `CMD_VSP_SET(0xB0)` 으로 응답
2. **디퓨저 폴링 (ERV가 제어 주체)**
- ERV → 디퓨저 : `CMD_DIFFUSER_CTRL(0x10)` (댐퍼각/풍량/LED 지시)
- 디퓨저 → ERV : `RSP_DIFFUSER_STATUS(0x90)`, 센서 부착 디퓨저는 이어서 `RSP_DIFFUSER_SENSOR(0x91)`
3. **명령 처리 흐름** : 룸컨 명령(0xA0) 수신 → ERV가 운전모드/풍량 결정 → 각 디퓨저에 `CMD_DIFFUSER_CTRL(0x10)`로 댐퍼 개폐·풍량 지시 → 결과를 다음 `POLL_ROOMCON(0x20)`의 ackFlags·동작상태로 룸컨에 회신.
4. **자동/공청 모드** : ERV가 각 실 센서(0x91)로 부하점수·집중/분산 계산 → 디퓨저 댐퍼각·풍량을 `CMD_DIFFUSER_CTRL`로 지시 (사양서 10~11P 로직).
5. **VSP 시운전** : 룸컨이 `CMD_VSP_SET(0xB0)`으로 단별 VSP·RPM·장치개수 설정(save=1 시 ERV가 EEPROM 저장), reqStatus=1이면 ERV가 `RSP_VSP_STATUS(0x30)`로 현재값 회신.
6. **통신 단절** : 폴링 타임아웃 N회 시 ERV가 해당 실 통신에러 비트(3.7 bit16~20) set, 재연결 시 clear.
---
## 6. 펌웨어 반영 메모
- 대상 파일 : `program/User/My_Uart.c` (SC1, 각실분배기/디퓨저), `bunbaegi_parsing()` / `Bunbaegi_Polling()` 를 CMD 기반으로 교체.
- **`My_RJ2.c` (DL 룸컨 232) 는 변경하지 않는다** — DL 사양 별도 유지.
- CRC : 기존 `CRC16()` (MODBUS) 그대로 사용, 전송은 빅엔디안(Hi→Lo).
- 센서/디퓨저 배열은 현행 `[7]` (index 1~6) 유지 가능하나, ROOM(1~5)+IDX 매핑 테이블 1개로 주소→배열 변환.
- 에러코드는 `uint16_t Err_Code`**`uint32_t`** 로 확장 (방4 비트 수용).
---
## 7. 송수신 예제 — 거실 룸컨이 전 실 상태 표시
**목표** : 거실 룸컨에서 모든 방 디퓨저(SA/RA) 댐퍼·LED·센서 + ERV 동작상태(모드/풍량/예약)를 표시.
**전제** : 거실 룸컨은 디퓨저를 직접 못 읽는다. 버스 마스터는 ERV 하나뿐이므로 항상 2단계.
```
① 수집 : ERV ──폴링──> 각 디퓨저(SA/RA) ──상태/센서──> ERV (캐싱)
② 표시 : ERV ──전달──> 거실 룸컨 (화면 표시)
```
> 아래 모든 프레임의 CRC는 **STX(0xAA) 제외, `CMD~PAYLOAD` 구간**에 대한 CRC-16/MODBUS 결과를 빅엔디안(Hi,Lo)으로 붙인 실제 값이다.
> 예시 값 : runMode=`0x02`(자동), fanSpeed=`2`, 댐퍼 `0xB4`=180°(열림)/`0x00`=닫힘, 온도·습도 ×10.
> **폴링 시간** : 1회 폴링(TX 요청 → RX 응답)을 **300ms**로 가정한다. (각 [TX]/[RX] 쌍 = 300ms)
### ① 수집 단계 — ERV가 각 디퓨저를 라운드로빈 폴링
ERV가 `거실SA1 → 거실SA2 → 거실RA1 → 거실RA2 → 방1SA → 방1RA → … → 방4RA` 순으로 폴링. 대표 예 (괄호는 누적시간):
**거실 SA1 디퓨저 폴링/제어**`CMD_DIFFUSER_CTRL(0x10)` *(t=0 ~ 300ms)*
```
[TX] AA 10 0A | 10 01 01 | 01 02 02 05 B4 00 00 | E4 61
STX CMD LEN DEV ROOM IDX power runMode fan led dmp reset reserve
(SA,거실,1) ON 02(자동) 2 5 B4 0 0
```
```
[RX] AA 90 10 | 10 01 01 | 01 02 02 05 B4 03 52 00 00 00 00 01 17 | A6 08
└RSP_DIFFUSER_STATUS power mode fan led dmp rpm=0352(850) err=0(4B) ver=0117
```
**거실 RA1 디퓨저 폴링** — 상태 `RSP_DIFFUSER_STATUS(0x90)` + 센서 `RSP_DIFFUSER_SENSOR(0x91)` *(t=600 ~ 900ms)*
```
[RX] AA 91 19 | 20 01 01 | 00 05 00 08 00 09 00 0B 01 C2 00 DC 00 64 00 01 02 8A | 00 00 00 00 | E8 AF
└센서 (RA,거실,1) pm1=5 pm2.5=8 pm4=9 pm10=11 습45.0% 온22.0℃ voc100 nox1 co2=028A(650) err=0
```
> RA 디퓨저는 한 폴링 슬롯(300ms)에서 상태(0x90)+센서(0x91)를 함께 응답.
> RA2·방1~4 디퓨저도 같은 방식으로 폴링 → ERV가 전 실 댐퍼각/LED/RPM/센서를 모두 캐싱.
**ERV 동작상태를 거실 룸컨에 전달 (+ 룸컨 명령 수신)**`POLL_ROOMCON(0x20)` *(룸컨 폴링 슬롯, 각 300ms)*
```
[TX] AA 20 11 | 30 01 01 | 01 02 02 01 00 | 00 00 00 00 | 00 96 | 00 DC | 00 | B5 61
└ERV→룸컨 (룸컨,거실,1) power mode fan auto=1(집중) reserveRemain=0
err=0(4B) 외기15.0℃ 내기22.0℃ ack=0
[RX] (거실 룸컨이 조작했으면 CMD_ROOMCON(0xA0)/CMD_VSP_SET(0xB0)로 응답, 없으면 빈 응답)
```
### ② 표시 단계 — 전 실 집계 전달
거실 룸컨이 전 실 디퓨저·센서를 한 화면에 표시하려면, ERV가 ①에서 모은 값을 집계해 거실 룸컨에 전달해야 한다.
> **참고(본 규격 미반영 제안)** : 아래 `CMD_ALLROOM_STATUS(0x21)` 는 "전 실 집계"용으로 검토한 예시 패킷이다.
> 정식 채택은 보류 상태이며, 필요 시 별도 협의 후 2·4장에 추가한다.
**헤더(9B)** + **실별 블록(18B) × 5실** :
| 헤더 off | 필드 | 실 블록 off | 필드 |
|---|---|---|---|
| 0~2 | 룸컨 주소(30·01·01) | +0 | roomNo |
| 3 | ERV power | +1,+2 | SA댐퍼1·2 (`0xFF`=없음) |
| 4 | ERV runMode | +3,+4 | RA댐퍼1·2 |
| 5 | ERV fanSpeed | +5 | LED 밝기 |
| 6 | 예약 잔여(h) | +6,+7 | PM2.5 |
| 7 | autoState | +8,+9 | PM10 |
| 8 | roomCount(=5) | +10,+11 | CO2 |
| | | +12,+13 | VOC |
| | | +14,+15 | 온도 ×10 |
| | | +16,+17 | 습도 ×10 |
```
[TX] AA 21 63 | 30 01 01 01 02 02 00 01 05 | <실1..실5 블록> | D9 F4
└CMD_ALLROOM 헤더(LEN=0x63=99) 거실은 SA·RA 2개 모두 사용
실1(거실): 01 B4 B4 B4 B4 05 00 08 00 0B 02 8A 00 64 00 DC 01 C2 SA열림×2,RA열림×2,LED5,PM2.5=8,PM10=11,CO2=650,VOC=100,22.0℃,45.0%
실2(방1) : 02 B4 FF B4 FF 04 00 0C 00 12 02 D0 00 82 00 DD 01 C4 SA/RA 각1, slot2=FF
실3(방2) : 03 00 FF 00 FF 00 00 06 00 09 02 62 00 5A 00 DC 01 C0 댐퍼 닫힘, LED OFF
실4(방3) : 04 B4 FF B4 FF 06 00 14 00 1C 03 2A 00 A0 00 DB 01 C7
실5(방4) : 05 B4 FF B4 FF 03 00 07 00 0A 02 80 00 5F 00 DC 01 C1
```
> 전체 104 byte 1프레임으로 거실 룸컨이 **전 실 SA/RA 댐퍼·LED·센서 + ERV 모드/풍량/예약**을 모두 표시.
### 폴링 주기와 전체 시간 (1회 = 300ms)
5실 구성에서 ERV가 한 바퀴 도는 동안 폴링하는 장치:
| 장치 | 개수 | 폴링 시간 (×300ms) |
|------|------|--------------------|
| SA 디퓨저 (거실2 + 방1~4 각1) | 6 | 1,800 ms |
| RA 디퓨저 (거실2 + 방1~4 각1, 상태+센서 동시) | 6 | 1,800 ms |
| 룸컨 (거실 + 방1~4) | 5 | 1,500 ms |
| **합계 (1주기)** | **17** | **5,100 ms ≈ 5.1초** |
- 거실 룸컨 화면은 **약 5.1초마다 전 실 데이터가 1회 갱신**된다.
- 갱신을 더 빠르게 하려면 : 폴링 시간 단축(예: 200ms → 17×200 = 3.4초), 또는 폴링 대상 축소.
- `0x21` 집계 프레임은 거실 룸컨 폴링 슬롯 안에서 전달되므로 별도 시간이 추가되지 않는다.
```
[1 주기 = 5.1초]
SA1 SA2 RA1 RA2 | 방1SA 방1RA | 방2SA 방2RA | 방3SA 방3RA | 방4SA 방4RA | RC거실 RC방1 RC방2 RC방3 RC방4
└─거실 4슬롯─┘ └─방1 2─┘ └─방2 2─┘ └─방3 2─┘ └─방4 2─┘ └────── 룸컨 5슬롯 ──────┘
각 슬롯 300ms × 17 = 5,100ms
```
### 요약
| 단계 | 방향 | 패킷 | 역할 |
|------|------|------|------|
| ① | ERV→디퓨저 / 디퓨저→ERV | `0x10` / `0x90`,`0x91` | 전 실 댐퍼·LED·센서 수집 |
| ② | ERV→거실룸컨 | `0x20` | ERV 동작상태(모드/풍량/예약) 전달 |
| ② | ERV→거실룸컨 | `0x21` (미반영 제안) | 전 실 디퓨저·센서 집계 전달 |
---
> 본 문서는 내부 통신 재정의 초안(Rev 2.0)이다. 디퓨저/룸컨 펌웨어 담당(주신전자)과
> CMD 코드·필드 세부값을 상호 합의하여 확정한다.
@@ -0,0 +1,546 @@
# 각실제어 내부 통신 프로토콜 (Rev 3.0, 2-Tier 계층형)
> 휴벤ECO(ERV) ↔ **각실분배기** ↔ 디퓨저(SA/RA) · 룸컨 **내부 통신** 규격
>
> 본 규격은 [각실제어_내부프로토콜_Rev2.0_CMD.md](각실제어_내부프로토콜_Rev2.0_CMD.md) 를
> **계층형(2-Tier)** 으로 재구성한 것이다. 분배기 회로도 `Schematic/BUNBAGI_REV4.1_20251124(회로도).pdf`
> 와 펌웨어 `program/User/My_Uart.c` 를 근거로 한다.
> (Rev 2.0 은 그대로 보존하며, 본 Rev 3.0 이 상위 규격이다. DL 룸컨 232 `My_RJ2.c` 는 무관 — 변경 안 함.)
- **물리계층** : RS-485, **115200 bps, 8 Data, None Parity, 1 Stop (N81)**
- **대상 시스템** : 사양서 5페이지 구성 = **거실 + 방1~4 (5실)**
---
## 0. 무엇이 바뀌었나 (Rev 2.0 단일버스 → Rev 3.0 2-Tier)
회로도 분석 결과, 각실분배기는 **자체 MCU(Nuvoton NANO100SE3BN)를 가진 능동 컨트롤러**임이 확인되었다. 따라서 통신을 두 계층으로 분리한다.
| 구분 | Rev 2.0 (단일 버스) | Rev 3.0 (2-Tier) |
|------|---------------------|------------------|
| 버스 구조 | ERV가 모든 디퓨저/룸컨(17대)을 1버스로 직접 폴링 | **상위: ERV↔분배기 1버스 / 하위: 분배기↔실별 디퓨저·룸컨** |
| ERV 폴링 대상 | 17대 | **분배기 1대** |
| 분배기 역할 | 없음(전원·배선 통과) | **하위 5채널 로컬 마스터 + 에러격리 + 포트별 통신상태 LED** |
| 디퓨저 LED 제어 | 디퓨저에 ledDim 전송 | **디퓨저에 ledDim 전송** (유지). 조명 LED는 **RA 디퓨저에만** 있고 ERV→분배기→RA디퓨저로 밝기 제어 |
| 자동운전 판단 | ERV | **ERV** (분배기는 센서수집·명령실행만) |
| 집계 패킷 | 0x21 제안(미반영) | **상위버스 정식 패킷(`RSP_ALLROOM_*`)으로 채택** |
> **하드웨어 근거 (BUNBAGI Rev4.1)** : 상위 `M485`(RS485 1채널, U5, 커넥터 CN3) / 하위 `SA485`(RS485 **5채널**, 실별 RJ45 J1~J5, RX는 8:1 MUX U6로 실 선택, DIR 채널별) / 전원 24V(60W)·3.3V.
> ※ 분배기의 2×74HC595(U10·U12, SA=초록·RA=노랑) LED는 **각 포트 SA/RA 디퓨저의 통신상태 표시등**(분배기 보드 진단용)이며, 프로토콜과 무관하다.
> ※ **방의 조명용 LED는 RA 디퓨저에만 달려 있고**, ERV가 밝기(0~9)를 명령한다 → 분배기가 해당 RA 디퓨저로 전달.
---
## 0-1. Rev 2.0(단일버스) → Rev 3.0(2-Tier) 장단점
> 채택 전 의사결정용 요약. 결론적으로 **하드웨어(분배기 MCU·하위 5채널·SPOF 구조)와 5실 확장 요구를 고려하면 2-Tier 도입 권장**이되, 분배기 펌웨어 신규 개발과 단일 장애점(SPOF) 대비가 전제다.
### ✅ 장점
| # | 항목 | 내용 | 효과 |
|---|------|------|------|
| 1 | **ERV 폴링 부하 격감** | ERV가 17대 직접 폴링 → **분배기 1대만** 상대 | ERV 펌웨어·루프 단순, CPU 여유 |
| 2 | **갱신 속도 향상** | ERV 입장 1주기 **5.1초 → 0.6초** (상·하위 병렬, 하위는 분배기가 빠르게 순환) | 룸컨·대시보드 응답성 ↑ |
| 3 | **버스 트래픽·충돌 감소** | 상위 버스엔 노드 1개, 하위는 5채널로 부하 분산(MUX+채널별 DIR) | 패킷 충돌/재전송 ↓ |
| 4 | **실별 통신에러 격리** | 한 실 디퓨저 고장이 채널 단위로 격리 → 타 실·ERV에 영향 최소 | 가용성 ↑, 원인 국소화 |
| 5 | **신호 무결성·배선 이점** | 하위는 짧은 로컬 세그먼트 5채널 분리, 채널별 종단·DIR | 노이즈·반사 ↓ (긴 데이지체인 대비) |
| 6 | **ERV–분배기 인터페이스 추상화** | ERV는 디퓨저 주소·개수를 몰라도 됨. 디퓨저 증설/사양변경을 **분배기가 흡수** | ERV 코드 변경 없이 하위 확장 |
| 7 | **역할·개발 분담** | ERV(시스템 로직) ↔ 분배기/디퓨저/룸컨(주신) 펌웨어 디커플링 | 병행 개발·유지보수 용이 |
| 8 | **확장성** | 실/디퓨저 추가는 하위에서 처리, 멀티 분배기(`nodeId`) 대비 | 향후 평면 확장 수월 |
| 9 | **MCU 동일(Nano100)** | ERV·분배기 동일 계열 → 프레임/CRC/드라이버 코드 공유 | 개발 재사용 |
### ⚠️ 단점 / 리스크
| # | 항목 | 내용 | 완화책 |
|---|------|------|--------|
| 1 | **분배기 펌웨어 신규 개발** | 하위 마스터 + 상위 슬레이브 + MUX/DIR + 595 + 집계 캐시 로직 필요 | Nano100 공통 드라이버 재사용, `My_Uart.c` 골격 활용 |
| 2 | **지연(latency) 1홉 추가** | 명령이 ERV→분배기→디퓨저 2단. 최악 상위주기+하위주기 합산 | 긴급 제어는 분배기가 즉시 중계, 댐퍼는 비실시간이라 영향 작음 |
| 3 | **상태 동기화 시차** | ERV가 보는 값은 분배기 캐시(직전 하위 폴링 결과) → 한 박자 지연 가능 | 변경 이벤트 우선 보고, 타임스탬프/시퀀스로 정합 |
| 4 | **단일 장애점(SPOF)** | 중앙 분배기 1대 고장 시 **전 실 통신 두절** (단일버스는 ERV-디퓨저 직접이라 무관) | 분배기 워치독·자기진단, 통신두절 시 ERV 안전모드 |
| 5 | **집계 패킷이 큼** | `RSP_ALLROOM_STATUS` 93B 등 → 1프레임 손상 시 전 실 갱신 실패(재전송 단위 큼) | CRC+재요청, 필요 시 실별 분할 응답 옵션 |
| 6 | **디버깅·추적 복잡** | 장애 시 상위/하위/분배기 로직 분리 진단, 버스 스니핑 2곳 | 분배기 진단로그·상태 LED(595), SPEC/버전 패킷 |
| 7 | **프로토콜 2종 관리** | 상위·하위 CMD 세트 2개 정의·문서화·버전관리 | 본 문서 단일화, 프레임 규칙 공통 |
| 8 | **3자 버전 정합** | ERV·분배기·디퓨저/룸컨 펌웨어 호환성 관리 | `SPEC`(0x1F/0x9F) 버전 교환·검증 |
### 한눈에
| 관점 | Rev 2.0 단일버스 | Rev 3.0 2-Tier |
|------|------------------|----------------|
| ERV 부담 | 큼(17대 폴링) | **작음(1대)** |
| 갱신 주기(ERV) | 5.1초 | **0.6초** |
| 분배기 펌웨어 | 불필요 | **필요(신규)** |
| 장애 격리 | 디퓨저별(분배기 무관) | 실별(단, 분배기 SPOF) |
| 확장/유지보수 | ERV가 전부 관리 | **계층 분리로 용이** |
| 구현 난이도 | 낮음 | 중(분배기 추가) |
---
## 1. 시스템 구조
```
[상위 버스: M485 · RS485 1채널] [하위 버스: SA485 · RS485 5채널 (MUX)]
┌── ch1 거실 : SA디퓨저×2, RA디퓨저×2, 룸컨
ERV ───────────────────────── 각실분배기 ─┼── ch2 방1 : SA디퓨저, RA디퓨저, 룸컨
(Master) (Nano100) ├── ch3 방2 : SA디퓨저, RA디퓨저, 룸컨
· 상위 Slave ├── ch4 방3 : SA디퓨저, RA디퓨저, 룸컨
· 하위 Master └── ch5 방4 : SA디퓨저, RA디퓨저, 룸컨
· 포트별 통신상태 LED(74HC595, 진단용)
· 실별 전원(24V)·통신에러 격리
```
**역할 분담**
| 주체 | 책임 |
|------|------|
| **ERV (메인)** | 전열교환기 팬/VSP 제어, **자동운전 판단**(부하점수·집중/분산, 사양서 10~11P), **부가모드(스마트수면/쾌적조리/안심회복)·후드 연동(HOOD-485) 처리**, 분배기에 전원/모드/풍량 + 실별 댐퍼·LED 타겟 하달, 집계상태 수신 |
| **분배기** | 하위 5채널 폴링(실별 SA/RA 디퓨저·룸컨), **댐퍼·LED 명령 중계·실행**, 센서·룸컨명령 수집→ERV에 집계 보고, 실별 통신에러 격리, **포트별 통신상태 표시 LED**(74HC595, 진단용) |
| **디퓨저(SA/RA)** | 댐퍼 구동·각도/RPM 회신, 센서값 회신. **RA 디퓨저는 자체 조명 LED 점등(밝기 0~9)** |
| **룸컨** | 사용자 입력(전원/모드/풍량/예약 등) 보고, ERV 상태 표시 |
**데이터 흐름 (자동운전)**
```
분배기 ──(실별 원시 센서)──> ERV ──[부하점수·집중/분산 계산]──> 실별 댐퍼/풍량/LED 타겟
ERV ──(타겟)──> 분배기 ──(댐퍼·LED 실행 / 디퓨저 제어)──> 각 실
```
---
## 2. 공통 프레임 (상·하위 동일)
```
+------+------+------+------------------+--------+--------+
| STX | CMD | LEN | PAYLOAD[LEN] | CRC_H | CRC_L |
+------+------+------+------------------+--------+--------+
0xAA 1B 1B LEN bytes 16-bit CRC
```
| 필드 | 크기 | 설명 |
|---------|------|------|
| STX | 1 | 고정 `0xAA` |
| CMD | 1 | 명령/응답 코드 (상위 4장 / 하위 5장) |
| LEN | 1 | PAYLOAD 바이트 수 (0~255) |
| PAYLOAD | LEN | CMD 별 데이터 |
| CRC | 2 | **CRC-16/MODBUS** (poly 0xA001, init 0xFFFF), **CMD~PAYLOAD** 까지, **빅엔디안(CRC_H 먼저)** |
- 모든 멀티바이트 수치는 **빅엔디안**. 상·하위 버스 모두 같은 프레임/CRC 규칙을 쓴다.
- 분배기 MCU와 ERV MCU가 동일 계열(Nano100)이므로 CRC·프레임 파서 코드를 공유한다.
---
## 3. 공통 값 정의
### 3.1 전원 (Power)
`0x00`=OFF, `0x01`=ON
### 3.2 운전모드 (RunMode)
| 값 | 의미 | 펌웨어 |
|----|------|--------|
| 0x01 | 수동(환기) | MODE_VENTILATION |
| 0x02 | 자동 | MODE_AUTO |
| 0x04 | 바이패스 | MODE_BYPASS |
| 0x08 | 공기청정 | MODE_AIRCLEAN |
### 3.3 풍량 (FanSpeed)
`0`=정지, `1`=약, `2`=중, `3`=강, `4`=터보
### 3.4 LED 밝기 (LedDim) — **RA 디퓨저 조명**
`0`=OFF, `1`~`9`=1~9단(9=최대). 조명 LED는 **RA 디퓨저에만** 존재(단색, 색온도 3800K). ERV가 실별 밝기를 지시 → 분배기가 해당 실 RA 디퓨저로 전달 → RA 디퓨저가 자체 점등.
> 분배기 보드의 SA(초록)/RA(노랑) LED는 별개로, 포트별 통신상태 표시등이다(프로토콜 무관).
### 3.5 댐퍼 각도 (DamperAngle)
`0x00`=0°(닫힘) ~ `0xB4`=180°(완전개방). 슬롯 미사용 시 `0xFF`(장치 없음).
### 3.6 에러코드 (ErrorCode, **u32 비트맵**)
| 비트 | 마스크 | 의미 |
|------|--------|------|
| 0 | 0x00000001 | 필터 청소 |
| 1 | 0x00000002 | 필터 교체 |
| 2 | 0x00000004 | 소자 교체 |
| 3 | 0x00000008 | 온도센서 에러 |
| 4 | 0x00000010 | 장비보호 모드 |
| 5 | 0x00000020 | EA 팬 에러 |
| 6 | 0x00000040 | 간헐운전 모드 |
| 7 | 0x00000080 | SA 팬 에러 |
| 8~12 | 0x00000100~0x00001000 | 통합센서 에러 — 거실/방1/방2/방3/방4 |
| 16~20 | 0x00010000~0x00100000 | 통신 에러 — 거실/방1/방2/방3/방4 |
### 3.7 실 번호 (Room)
`1`=거실, `2`=방1, `3`=방2, `4`=방3, `5`=방4. (하위버스 MUX 채널 ch1~ch5 와 1:1)
### 3.8 부가모드 (AddMode, 비트맵)
DL 사양 시나리오 모드. 룸컨에서 토글하며 운전모드(3.2)에 덧씌워진다. 여러 비트 동시 가능.
| 비트 | 마스크 | 의미 | 비고 (사양서 8~9P) |
|------|--------|------|--------------------|
| 0 | 0x01 | 스마트수면 | 자동·풍량1단 고정, 1시간마다 CO2 기준 댐퍼 |
| 1 | 0x02 | 쾌적조리 | 렌지후드 연동(아래 3.9), 환기·3단 급기 |
| 2 | 0x04 | 안심회복 | 침실1 음압, 환기·2단 |
> `0x00` = 부가모드 없음. (회복모드 중 수면모드 불가 등 배타조건은 ERV 로직에서 처리)
### 3.9 후드 연동 (Hood, 비트맵)
렌지후드와 전열교환기 연동. 후드 동작 시 ERV도 동작해야 하므로 ERV가 후드 상태를 감지·연동한다(HOOD-485). 쾌적조리(3.8 bit1) 활성 시 적용.
| 비트 | 마스크 | 의미 |
|------|--------|------|
| 0 | 0x01 | 후드연동 활성화(enable) |
| 1 | 0x02 | 후드 현재 동작중(ON) |
> 예: `0x03` = 연동 ON + 후드 가동중 → ERV 환기·3단 급기. `0x00` = 연동 없음.
---
# 상위 버스 — ERV ↔ 각실분배기
> ERV(Master)가 분배기(유일 Slave)를 폴링한다. 분배기는 하위에서 모은 전 실 데이터를 **집계해 응답**한다.
> 멀티 분배기 확장 대비로 페이로드 선두에 `nodeId`(분배기 번호, 기본 `0x01`)를 둔다.
## 4. 상위 CMD 및 PAYLOAD
| CMD | 이름 | 방향 | PAYLOAD | 설명 |
|------|------|------|---------|------|
| 0x10 | `CMD_SYSTEM` | ERV→분배기 | 4.1 | 전원/모드/풍량/예약 + 실별 댐퍼·LED 타겟 (폴링 겸용) |
| 0x12 | `POLL_SENSOR` | ERV→분배기 | nodeId(1) | 전 실 센서 집계 요청 |
| 0x14 | `CMD_CONFIG` | ERV→분배기 | 4.5 | VSP 테이블·장치개수·ID 설정/저장 |
| 0x1F | `POLL_SPEC` | ERV→분배기 | nodeId(1) | 분배기 사양/버전 요청 |
| 0x90 | `RSP_ALLROOM_STATUS` | 분배기→ERV | 4.2 | 전 실 댐퍼·RPM·LED·룸컨명령·에러 집계 (0x10 응답) |
| 0x92 | `RSP_ALLROOM_SENSOR` | 분배기→ERV | 4.3 | 전 실 통합공기질 센서 집계 (0x12 응답) |
| 0x94 | `RSP_CONFIG` | 분배기→ERV | 4.5 | 설정 에코/저장결과 (0x14 응답) |
| 0x9F | `RSP_SPEC` | 분배기→ERV | 4.6 | 분배기 사양/버전 |
### 4.1 `CMD_SYSTEM` (0x10, ERV→분배기)
**글로벌(off 0~15, 16B)** + **실별 타겟 블록(6B) × 5실**
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 1 | nodeId | 분배기 번호(0x01) |
| 1 | 1 | power | 3.1 |
| 2 | 1 | runMode | 3.2 |
| 3 | 1 | fanSpeed | 3.3 |
| 4 | 1 | addMode | 부가모드 비트맵 (3.8) |
| 5 | 1 | hood | 후드 연동 비트맵 (3.9) |
| 6 | 1 | reserveHour | 0~8시간 |
| 7 | 4 | errorCode | ERV 시스템 에러 통보 (3.6) |
| 11 | 2 | outTemp | 외기온도 ×10 (signed) |
| 13 | 2 | inTemp | 내기온도 ×10 (signed) |
| 15 | 1 | roomCount | 실 수(=5) |
실별 타겟 블록(6B, roomCount회 반복) :
| off(상대) | 크기 | 필드 | 비고 |
|-----------|------|------|------|
| +0 | 1 | roomNo | 3.7 |
| +1 | 1 | saDamper1 | SA 디퓨저1 목표각 (3.5) |
| +2 | 1 | saDamper2 | SA 디퓨저2 (거실만, 그 외 0xFF) |
| +3 | 1 | raDamper1 | RA 디퓨저1 목표각 |
| +4 | 1 | raDamper2 | RA 디퓨저2 (거실만, 그 외 0xFF) |
| +5 | 1 | ledDim | 해당 실 RA 디퓨저 조명 밝기 (3.4) |
LEN = 16 + 6×5 = **46**.
### 4.2 `RSP_ALLROOM_STATUS` (0x90, 분배기→ERV)
**글로벌(8B)** + **실별 상태 블록(17B) × 5실**
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 1 | nodeId | |
| 1 | 1 | bunbagiState | 분배기 동작/에러 요약 |
| 2 | 1 | addModeReq | 룸컨이 요청한 부가모드 비트맵 (3.8) |
| 3 | 4 | errorCode | 전 실 통신·센서 에러 집계 (3.6) |
| 7 | 1 | roomCount | =5 |
실별 상태 블록(17B) :
| off(상대) | 크기 | 필드 | 비고 |
|-----------|------|------|------|
| +0 | 1 | roomNo | |
| +1 | 1 | saDamper1 | 실제 각도 |
| +2 | 1 | saDamper2 | (없으면 0xFF) |
| +3 | 1 | raDamper1 | |
| +4 | 1 | raDamper2 | |
| +5 | 2 | saRpm | SA 팬 실측 RPM |
| +7 | 2 | raRpm | RA 팬 실측 RPM |
| +9 | 1 | ledDim | 현재 RA 조명 LED 밝기 |
| +10 | 1 | rcCmdFlags | 룸컨 명령 비트(아래 5.5 cmdFlags 동일, bit7=부가모드 변경) |
| +11 | 1 | rcPower | 룸컨 설정 전원 |
| +12 | 1 | rcRunMode | 룸컨 설정 모드 |
| +13 | 1 | rcFanSpeed | 룸컨 설정 풍량 |
| +14 | 1 | rcReserveHour | 룸컨 설정 예약 |
| +15 | 1 | rcHeaterUV | bit0=히터,bit4=UV |
| +16 | 1 | rcFilterReset | 1=필터리셋 요청 |
LEN = 8 + 17×5 = **93**.
> `rcCmdFlags` 가 0 이 아니면, 해당 실 룸컨에서 사용자 조작이 발생했다는 뜻 → ERV가 수용해 운전상태 갱신.
### 4.3 `RSP_ALLROOM_SENSOR` (0x92, 분배기→ERV)
**글로벌(2B)** + **실별 센서 블록(19B) × 5실**
| off | 크기 | 필드 |
|-----|------|------|
| 0 | 1 | nodeId |
| 1 | 1 | roomCount(=5) |
실별 센서 블록(19B) :
| off(상대) | 크기 | 필드 | 단위 |
|-----------|------|------|------|
| +0 | 1 | roomNo | |
| +1 | 2 | pm1p0 | ㎍/㎥ |
| +3 | 2 | pm2p5 | ㎍/㎥ |
| +5 | 2 | pm4p0 | ㎍/㎥ |
| +7 | 2 | pm10p0 | ㎍/㎥ |
| +9 | 2 | humidity | %RH ×10 |
| +11 | 2 | temperature | ℃ ×10 (signed) |
| +13 | 2 | voc | TVOC index |
| +15 | 2 | nox | NOx index |
| +17 | 2 | co2 | ppm |
LEN = 2 + 19×5 = **97**.
### 4.5 `CMD_CONFIG` (0x14) / `RSP_CONFIG` (0x94)
VSP 풍량 테이블·장치개수·Modbus ID 설정.
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 1 | nodeId | |
| 1 | 1 | vspSelect | 0=None,1=환기,2=공청,3=바이패스 |
| 2 | 10 | sa1,ea1 … sa5,ea5 | 단별 VSP (각 1B) |
| 12 | 2 | rpmRefMid | 중 RPM 기준 |
| 14 | 2 | rpmDeltaMid | 중 RPM 편차 |
| 16 | 2 | rpmRefHigh | 강 RPM 기준 |
| 18 | 2 | rpmDeltaHigh | 강 RPM 편차 |
| 20 | 1 | roomconNum | 룸컨 수(1~5) |
| 21 | 1 | saDiffuserNum | SA 디퓨저 수(2~6) |
| 22 | 1 | raDiffuserNum | RA 디퓨저 수(2~6) |
| 23 | 1 | modbusId | 외부 홈넷 연동 ID |
| 24 | 1 | save | 1=EEPROM 저장 |
LEN = 25. `RSP_CONFIG` 는 동일 레이아웃 에코 + save 결과.
### 4.6 `RSP_SPEC` (0x9F)
| off | 크기 | 필드 |
|-----|------|------|
| 0 | 1 | nodeId |
| 1 | 2 | version (예 0x0117=Ver1.23) |
| 3 | 1 | deviceType |
| 4 | 1 | capability (bit0 히터/bit1 UV/bit2 후드연동) |
LEN = 5.
---
# 하위 버스 — 각실분배기 ↔ 디퓨저 · 룸컨
> 분배기(로컬 Master)가 **MUX로 실 채널(ch1~ch5)을 선택**한 뒤 그 실의 SA/RA 디퓨저·룸컨을 폴링한다.
> 조명 LED(RA 디퓨저)는 `LCMD_DIFFUSER` 의 `ledDim` 으로 제어한다(3.4). 분배기 보드의 74HC595 LED는 별개(포트별 통신상태 표시, 진단용).
## 5. 하위 CMD 및 PAYLOAD
PAYLOAD 선두 3바이트는 장치 주소 `[DEV][ROOM][IDX]`.
- DEV : `0x10`=SA 디퓨저, `0x20`=RA 디퓨저, `0x30`=룸컨
- ROOM : 3.7 (채널과 1:1) / IDX : 같은 실·종류 일련번호(거실 SA·RA는 1,2)
| CMD | 이름 | 방향 | PAYLOAD | 설명 |
|------|------|------|---------|------|
| 0x20 | `LCMD_DIFFUSER` | 분배기→디퓨저 | 5.1 | 디퓨저 댐퍼/풍량 제어 + 폴링 |
| 0x21 | `LPOLL_ROOMCON` | 분배기→룸컨 | 5.4 | 룸컨 폴링 + ERV 상태 표시데이터 전달 |
| 0xA0 | `LRSP_DIFFUSER` | 디퓨저→분배기 | 5.2 | 댐퍼각/RPM/에러/버전 (0x20 응답) |
| 0xA1 | `LRSP_DIFFUSER_SENSOR` | 디퓨저→분배기 | 5.3 | 통합공기질 센서 (방별, 센서 부착 디퓨저) |
| 0xB0 | `LRSP_ROOMCON` | 룸컨→분배기 | 5.5 | 룸컨 사용자 명령 (0x21 응답) |
### 5.1 `LCMD_DIFFUSER` (0x20, 분배기→디퓨저)
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | DEV=0x10(SA)/0x20(RA) |
| 3 | 1 | power | 3.1 |
| 4 | 1 | runMode | 3.2 |
| 5 | 1 | fanSpeed | 3.3 |
| 6 | 1 | ledDim | RA 디퓨저 조명 밝기 (3.4) — **RA(0x20)만 유효, SA는 0** |
| 7 | 1 | dmpAngle | 목표 댐퍼각 (3.5) |
| 8 | 1 | dmpReset | 1=댐퍼 초기화 |
LEN = 9.
### 5.2 `LRSP_DIFFUSER` (0xA0, 디퓨저→분배기)
| off | 크기 | 필드 |
|-----|------|------|
| 0 | 3 | DEV/ROOM/IDX (에코) |
| 3 | 1 | power |
| 4 | 1 | runMode |
| 5 | 1 | fanSpeed |
| 6 | 1 | ledDim (현재 RA 조명 밝기, SA는 0) |
| 7 | 1 | dmpAngle (현재각) |
| 8 | 2 | rpm |
| 10 | 4 | errorCode (3.6) |
| 14 | 2 | version |
LEN = 16.
### 5.3 `LRSP_DIFFUSER_SENSOR` (0xA1, 디퓨저→분배기)
| off | 크기 | 필드 | 단위 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | 센서 부착 디퓨저(통상 RA) |
| 3 | 2 | pm1p0 | ㎍/㎥ |
| 5 | 2 | pm2p5 | ㎍/㎥ |
| 7 | 2 | pm4p0 | ㎍/㎥ |
| 9 | 2 | pm10p0 | ㎍/㎥ |
| 11 | 2 | humidity | %RH ×10 |
| 13 | 2 | temperature | ℃ ×10 |
| 15 | 2 | voc | TVOC |
| 17 | 2 | nox | NOx |
| 19 | 2 | co2 | ppm |
| 21 | 4 | errorCode | 3.6 |
LEN = 25.
### 5.4 `LPOLL_ROOMCON` (0x21, 분배기→룸컨)
분배기가 ERV로부터 받은 동작상태를 룸컨에 전달(표시용) + 룸컨 명령 회수.
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | DEV=0x30 |
| 3 | 1 | power | ERV 현재 전원 |
| 4 | 1 | runMode | ERV 현재 모드 |
| 5 | 1 | fanSpeed | ERV 현재 풍량 |
| 6 | 1 | addMode | 현재 부가모드 비트맵 (3.8) |
| 7 | 1 | hood | 후드 연동 상태 (3.9) |
| 8 | 1 | reserveRemain | 예약 잔여(h) |
| 9 | 4 | errorCode | 3.6 |
| 13 | 2 | outTemp | ×10 |
| 15 | 2 | inTemp | ×10 |
| 17 | 1 | ackFlags | 직전 룸컨 명령 수용 비트 |
LEN = 18.
### 5.5 `LRSP_ROOMCON` (0xB0, 룸컨→분배기)
| off | 크기 | 필드 | 비고 |
|-----|------|------|------|
| 0 | 3 | DEV/ROOM/IDX | DEV=0x30 |
| 3 | 1 | cmdFlags | bit0 power,1 runMode,2 fanSpeed,3 reserveHour,4 heaterUV,5 filterReset,6 ledDim,**7 addMode** |
| 4 | 1 | power | 3.1 |
| 5 | 1 | runMode | 3.2 |
| 6 | 1 | fanSpeed | 3.3 |
| 7 | 1 | reserveHour | 0~8 |
| 8 | 1 | heaterUV | bit0 히터,bit4 UV |
| 9 | 1 | filterReset | 1=리셋 |
| 10 | 1 | addMode | 부가모드 토글 비트맵 (3.8) |
| 11 | 2 | version | |
LEN = 13.
> 룸컨이 보낸 명령은 분배기가 모아 상위 `RSP_ALLROOM_STATUS` 의 `rc*` 필드로 ERV에 전달 → ERV가 판단 후 다시 하달.
---
## 6. 동작 시나리오 & 폴링 타이밍
**두 버스가 독립적으로 동작**한다.
**하위 버스 (분배기 로컬 루프)** — 분배기가 ch1~ch5를 MUX로 돌며 각 실의 SA/RA 디퓨저·룸컨 폴링. 케이블이 짧고 전용 채널이라 빠르게 순환하며 전 실 상태를 캐싱.
**상위 버스 (ERV 루프)** — ERV는 **분배기 1대만** 주기적으로 폴링:
1. ERV → 분배기 `CMD_SYSTEM(0x10)` (전원/모드/풍량 + 실별 댐퍼·LED 타겟) → 분배기 `RSP_ALLROOM_STATUS(0x90)` (전 실 댐퍼·RPM·LED·룸컨명령·에러)
2. ERV → 분배기 `POLL_SENSOR(0x12)` → 분배기 `RSP_ALLROOM_SENSOR(0x92)` (전 실 센서)
3. ERV가 센서로 **부하점수·집중/분산 계산**(사양서 10~11P) → 다음 `CMD_SYSTEM` 의 실별 댐퍼/LED/풍량 타겟에 반영
4. 룸컨 조작은 `RSP_ALLROOM_STATUS.rcCmdFlags`(+`addModeReq`) 로 ERV에 보고 → ERV 수용 → 다음 `CMD_SYSTEM` 으로 반영
5. **부가모드(3.8)** : 룸컨에서 스마트수면/쾌적조리/안심회복 토글 → ERV가 해당 시나리오 로직 수행(댐퍼·풍량 타겟 조정) → `CMD_SYSTEM.addMode` 로 현재 상태 회신(룸컨 표시)
6. **후드 연동(3.9)** : ERV가 HOOD-485로 후드 ON 감지 → (쾌적조리 활성 시) ERV 환기·3단 급기 동작 → `CMD_SYSTEM.hood` 로 후드 상태를 분배기·룸컨에 전달. 후드 OFF 시 이전 모드 복귀
7. 통신 단절 : 분배기가 실별 통신에러 비트(3.6)를 set 해 ERV에 보고, 재연결 시 clear
### 폴링 시간 (1회 = 300ms 가정)
| 버스 | 폴링 대상 | 1주기 |
|------|-----------|-------|
| **상위 (ERV↔분배기)** | 2회 (CMD_SYSTEM + POLL_SENSOR) | **600 ms** |
| 하위 (분배기↔실) | 17회(SA6+RA6+룸컨5) — **로컬에서 병렬 진행** | 분배기 내부에서 순환(ERV와 무관) |
> Rev 2.0 은 ERV가 17대를 직접 폴링 → 1주기 **5.1초**.
> Rev 3.0 은 ERV 입장에서 **0.6초**면 전 실 상태를 받는다. 하위 루프는 분배기가 별도로 빠르게 순환하므로 ERV 주기와 분리된다. → **갱신 약 8배 빨라지고 ERV 부하 격감.**
---
## 7. 펌웨어 반영 메모
- **분배기 펌웨어(Nano100)** : 신규. UART1(`SA485`, PB.4/PB.5)=하위 로컬 마스터, UART2(`M485`, PA.8/PA.9)=상위 슬레이브. MUX(SA_MUX_A/B/C/EN)로 채널 선택, DIR(SA485_DIR_01~05) 채널별. 74HC595(LED_DS/SCK/LCK)는 **포트별 SA/RA 통신상태 표시 LED** 구동(진단용). 방 조명 LED는 RA 디퓨저가 자체 점등하므로 ledDim 명령만 중계.
- **ERV 펌웨어** : `My_Uart.c` 의 17대 직접 폴링 → **분배기 1대 상위 프로토콜**로 교체. 자동로직(부하점수·집중/분산)은 ERV에 유지.
- **`My_RJ2.c`(DL 룸컨 232)는 변경하지 않는다.**
- CRC : 기존 `CRC16()`(MODBUS) 공유, 빅엔디안(Hi→Lo).
- 에러코드 `uint32_t` (방4 비트 수용).
---
## 8. 송수신 예제
> 모든 프레임 CRC는 **STX 제외 `CMD~PAYLOAD`** 구간 CRC-16/MODBUS(빅엔디안)의 실제 계산값.
> 예시 : runMode=`0x02`(자동), fan=`2`(중), addMode=`0x02`(쾌적조리), hood=`0x03`(연동ON+가동중), 댐퍼 `0xB4`=열림/`0x00`=닫힘/`0xFF`=없음, 온·습도 ×10. 폴링 1회=300ms.
### 8-A. 상위 버스 (ERV ↔ 분배기)
**① ERV → 분배기 : 전원ON·자동·풍량2·쾌적조리·후드가동 + 실별 댐퍼/LED 타겟** — `CMD_SYSTEM(0x10)` *(t=0~300ms)*
```
[TX] AA 10 2E | 01 01 02 02 02 03 00 00000000 0096 00DC 05 | <실1..5 타겟 6B> | AA 01
CMD LEN node pw md fan add hood rsv err 외15℃ 내22℃ rooms=5
↑쾌적조리 ↑후드연동ON+가동
실1(거실): 01 B4 B4 B4 B4 05 (SA1·SA2·RA1·RA2 열림, RA조명 LED 5)
실2(방1) : 02 B4 FF B4 FF 04
실3(방2) : 03 00 FF 00 FF 00 (댐퍼 닫힘, LED OFF)
실4(방3) : 04 B4 FF B4 FF 06
실5(방4) : 05 B4 FF B4 FF 03
```
**② 분배기 → ERV : 전 실 상태 집계** — `RSP_ALLROOM_STATUS(0x90)`
```
[RX] AA 90 5D | 01 00 02 00000000 05 | <실1..5 상태 17B> | C5 31
node bunSt addModeReq=02 err rooms=5
실1(거실): 01 B4 B4 B4 B4 0352 0334 05 00 01 02 02 00 00 00
roomNo SA1 SA2 RA1 RA2 saRpm=850 raRpm=820 ledDim | rcFlags pw md fan rsv hu fr
실2(방1) : 02 B4 FF B4 FF 0348 032A 04 00 01 02 02 00 00 00
실3(방2) : 03 00 FF 00 FF 0000 0000 00 00 01 02 02 00 00 00
실4(방3) : 04 B4 FF B4 FF 034D 032F 06 00 01 02 02 00 00 00
실5(방4) : 05 B4 FF B4 FF 0350 0332 03 00 01 02 02 00 00 00
```
**③ ERV → 분배기 : 센서 요청 / 분배기 응답** — `POLL_SENSOR(0x12)``RSP_ALLROOM_SENSOR(0x92)` *(t=300~600ms)*
```
[TX] AA 12 01 | 01 | <CRC>
[RX] AA 92 61 | 01 05 | <실1..5 센서 19B> | DC 05
실1(거실): 01 0005 0008 0009 000B 01C2 00DC 0064 0001 028A
roomNo pm1 pm2.5 pm4 pm10 습45.0% 온22.0℃ voc100 nox1 co2=650
실2(방1) : 02 0007 000C 000D 0012 01C4 00DD 0082 0001 02D0 (co2=720)
실3(방2) : 03 0004 0006 0007 0009 01C0 00DC 005A 0001 0262 (co2=610)
실4(방3) : 04 0009 0014 0016 001C 01C7 00DB 00A0 0002 032A (co2=810)
실5(방4) : 05 0005 0007 0008 000A 01C1 00DC 005F 0001 0280 (co2=640)
```
> ERV는 ②③으로 **전 실 상태+센서를 0.6초만에** 확보 → 부하점수·집중/분산 계산 → 다음 `CMD_SYSTEM` 타겟 갱신.
### 8-B. 하위 버스 (분배기 ↔ 디퓨저/룸컨) — 분배기 로컬 루프
**거실 RA1 디퓨저 제어/폴링**`LCMD_DIFFUSER(0x20)``LRSP_DIFFUSER(0xA0)` (RA = 조명 LED 보유)
```
[TX] AA 20 09 | 20 01 01 | 01 02 02 05 B4 00 | 6E 2B (RA,거실,1 / power mode fan led=5 dmp=B4 reset=0)
[RX] AA A0 10 | 20 01 01 | 01 02 02 05 B4 0352 00000000 0117 | ... (led현재5, dmp B4, rpm850, err0, ver1.23)
```
> SA 디퓨저는 LED가 없으므로 `ledDim=0`. 예) 거실 SA1 TX: `AA 20 09 10 01 01 01 02 02 00 B4 00 6E 6F` → RX `AA A0 10 10 01 01 01 02 02 00 B4 03 52 00 00 00 00 01 17 B9 17`.
**거실 RA1 디퓨저 센서**`LRSP_DIFFUSER_SENSOR(0xA1)`
```
[RX] AA A1 19 20 01 01 00 05 00 08 00 09 00 0B 01 C2 00 DC 00 64 00 01 02 8A 00 00 00 00 58 AE
(RA,거실,1) pm1=5 pm2.5=8 pm4=9 pm10=11 습45.0% 온22.0℃ voc100 nox1 co2=650 err0
```
**거실 룸컨 폴링**`LPOLL_ROOMCON(0x21)``LRSP_ROOMCON(0xB0)`
```
[TX] AA 21 12 30 01 01 01 02 02 02 03 00 00 00 00 00 00 96 00 DC 00 92 FA
(ERV상태 전달: 자동·풍량2·쾌적조리(02)·후드연동ON+가동(03))
[RX] AA B0 0D 30 01 01 80 01 02 02 00 00 00 02 01 10 34 4D
cmdFlags=80(부가모드 변경) ... addMode=02(쾌적조리 토글) ver0x0110
```
> 룸컨이 부가모드(쾌적조리)를 토글하면 `cmdFlags` bit7=1·`addMode`=0x02 로 보고 → 분배기가 상위 `RSP_ALLROOM_STATUS.addModeReq`·`rcCmdFlags`에 실어 ERV에 전달 → ERV가 후드연동 운전 수행.
---
> 본 문서는 2-Tier 재정의 초안(Rev 3.0)이다. 분배기/디퓨저/룸컨 펌웨어 담당과 CMD 코드·필드 세부값을 상호 합의하여 확정한다.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

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

Some files were not shown because too many files have changed in this diff Show More