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