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
@@ -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 = "연결";
}
});
}
}
}