c5e4c48d24
- 내부댐퍼: 모드전환 중 명령경로(CTRL_FAN)가 팬 감속에 끼어들어 댐퍼가 간헐적으로 안 움직이던 문제 수정 (Fan_Speed_process 게이트 보호) - 명령경로 즉시 팬설정(My_Homenet/My_Hood) 모드변경분 주석 — 마스터 정렬 - 스마트수면: 거실(room1) CO2 무관 항상 CLOSE (사양 8p) - 대시보드: 쾌적조리 버튼 강조=ComfortCook(연동 Enable), 활성=후드/시나리오 무관 항상 토글 (사양 9p 3.1 독립 토글) - doc/260618 수정 정리 + 개발사양서 갱신 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
747 lines
34 KiB
C#
747 lines
34 KiB
C#
using System.Globalization;
|
|
using System.IO;
|
|
using System.Text;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Controls.Primitives;
|
|
using System.Windows.Data;
|
|
using System.Windows.Media;
|
|
using System.Windows.Shapes;
|
|
using System.Windows.Threading;
|
|
using ErvDashboard.Api;
|
|
using ErvDashboard.Model;
|
|
using ErvProtocol;
|
|
using Microsoft.Win32;
|
|
|
|
namespace ErvDashboard
|
|
{
|
|
public partial class MainWindow : Window
|
|
{
|
|
const int BaudRate = 115200;
|
|
|
|
readonly DashboardState _state = new();
|
|
readonly IErvApi _api = new SerialErvApi();
|
|
|
|
readonly DispatcherTimer _demoTimer;
|
|
int _demoTick;
|
|
bool _commActive;
|
|
bool _ledDragging; // LED 슬라이더 thumb 드래그 중 (드래그 중엔 전송 보류 → 완료 시 1회)
|
|
bool _suppressLed; // STATUS 동기 적용 중 LedDim→슬라이더 갱신으로 인한 ValueChanged 전송 차단
|
|
readonly Dictionary<int, int> _lastLed = new(); // roomId→마지막 송신/수신 LED. 동일값 재전송(에코) 차단
|
|
|
|
static readonly Brush Accent = Brush2("#3B82F6");
|
|
static readonly Brush AccentSoftBr = Brush2("#E7F0FF");
|
|
static readonly Brush CardBgBr = Brush2("#FFFFFF");
|
|
static readonly Brush CardBorderBr = Brush2("#E3E7EF");
|
|
static readonly Brush TextPrimaryBr = Brush2("#1F2733");
|
|
static readonly Brush GoodBr = Brush2("#22C55E");
|
|
static readonly Brush BadBr = Brush2("#EF4444");
|
|
|
|
static Brush Brush2(string hex) => (Brush)new BrushConverter().ConvertFromString(hex)!;
|
|
|
|
// 운전모드 버튼 정의
|
|
static readonly (string tag, string label, RunMode mode)[] ModeDefs =
|
|
{
|
|
("Vent", "환기", RunMode.Vent),
|
|
("Auto", "자동", RunMode.Auto),
|
|
("AirClean", "공청", RunMode.AirClean),
|
|
("Bypass", "바이패스", RunMode.Bypass),
|
|
};
|
|
|
|
readonly List<Button> _fanButtons = new();
|
|
readonly List<Button> _modeButtons = new();
|
|
readonly List<Button> _presetButtons = new();
|
|
|
|
HystWindow? _hystWin;
|
|
VspWindow? _vspWin;
|
|
SmartSleepWindow? _sleepWin;
|
|
RunMode _modeBeforeScenario = RunMode.Vent; // 시나리오모드 첫 진입 직전 운전모드(해제 시 복귀)
|
|
byte _fanBeforeScenario = 1; // 시나리오모드 첫 진입 직전 풍량
|
|
DateTime? _sleepEndAt; // 스마트수면 자동 해제 예정 시각(대시보드 전용)
|
|
readonly DispatcherTimer _clockTimer = new() { Interval = TimeSpan.FromSeconds(1) };
|
|
|
|
// ---- 그래프용 시계열 샘플링 (5초 간격) → SQLite 실시간 저장(무제한 누적) ----
|
|
readonly Storage.LogDb _logDb = new(System.IO.Path.Combine(AppContext.BaseDirectory, "HERV_Log.db"));
|
|
readonly DispatcherTimer _sampleTimer = new() { Interval = TimeSpan.FromSeconds(5) };
|
|
GraphWindow? _graphWin;
|
|
|
|
public MainWindow()
|
|
{
|
|
InitializeComponent();
|
|
DataContext = _state;
|
|
|
|
RoomItems.ItemsSource = _state.Rooms;
|
|
|
|
_api.Log += Log;
|
|
_api.ConnectionChanged += b => Dispatcher.BeginInvoke(() => OnConnectionChanged(b));
|
|
_api.StatusReceived += rec => Dispatcher.BeginInvoke(() =>
|
|
{
|
|
// STATUS 역갱신이 슬라이더를 움직여 ValueChanged→재전송(에코)되는 것을 2중으로 차단:
|
|
// 1) _suppressLed : Apply 동기 실행 중 발생하는 ValueChanged 차단 (바인딩이 동기 갱신될 때)
|
|
// 2) _lastLed : Apply 후(또는 비동기 바인딩 갱신 시) 같은 값 재전송 차단 (디스패처 타이밍 무관)
|
|
_suppressLed = true;
|
|
StatusMapper.Apply(rec, _state);
|
|
_suppressLed = false;
|
|
foreach (var r in _state.Rooms) _lastLed[r.RoomId] = r.LedDim;
|
|
LogStatusSnapshot();
|
|
});
|
|
|
|
_state.PropertyChanged += (_, _) => Dispatcher.BeginInvoke(RefreshControls);
|
|
foreach (var room in _state.Rooms)
|
|
room.PropertyChanged += (_, _) => Dispatcher.BeginInvoke(RefreshControls);
|
|
|
|
BuildFanButtons();
|
|
BuildModeButtons();
|
|
BuildPresetButtons();
|
|
|
|
_demoTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(700) };
|
|
_demoTimer.Tick += (_, _) => DemoTick();
|
|
|
|
_clockTimer.Tick += (_, _) => CheckSleepSchedule();
|
|
_clockTimer.Start();
|
|
|
|
_sampleTimer.Tick += (_, _) => TakeSample();
|
|
_sampleTimer.Start();
|
|
|
|
RefreshPortsList();
|
|
RefreshControls();
|
|
}
|
|
|
|
// ================= 버튼 목록 등록 (UI 는 MainWindow.xaml 에 정의) =================
|
|
// 강조/활성 갱신(RefreshControls)을 위해 XAML 버튼을 리스트로 모은다. 순서는 ModeDefs 와 동일.
|
|
void BuildFanButtons()
|
|
{
|
|
_fanButtons.AddRange(new[] { Fan0, Fan1, Fan2, Fan3, Fan4 });
|
|
}
|
|
|
|
void BuildModeButtons()
|
|
{
|
|
_modeButtons.AddRange(new[] { ModeVent, ModeAuto, ModeAir, ModeBypass });
|
|
}
|
|
|
|
// 자동모드 프리셋(ECO/NORMAL/TURBO). 순서는 PresetPanel 의 버튼 순서와 동일.
|
|
void BuildPresetButtons()
|
|
{
|
|
_presetButtons.AddRange(new[] { PresetEco, PresetNormal, PresetTurbo });
|
|
}
|
|
|
|
// ================= 설정 팝업 =================
|
|
void OpenHyst_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_hystWin == null) { _hystWin = new HystWindow(this, _state) { Owner = this }; _hystWin.Closed += (_, _) => _hystWin = null; _hystWin.Show(); }
|
|
else _hystWin.Activate();
|
|
}
|
|
|
|
void OpenVsp_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_vspWin == null) { _vspWin = new VspWindow(this, _state) { Owner = this }; _vspWin.Closed += (_, _) => _vspWin = null; _vspWin.Show(); }
|
|
else _vspWin.Activate();
|
|
}
|
|
|
|
void OpenSmartSleep_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_sleepWin == null) { _sleepWin = new SmartSleepWindow(this, _state) { Owner = this }; _sleepWin.Closed += (_, _) => _sleepWin = null; _sleepWin.Show(); }
|
|
else _sleepWin.Activate();
|
|
}
|
|
|
|
// 스마트수면 시간설정 적용(팝업 호출) : 설정한 종료 시각에 자동 해제하도록 예약(대시보드 전용).
|
|
public void ApplySmartSleep()
|
|
{
|
|
_sleepEndAt = NextOccurrence(_state.SleepEndMin);
|
|
Log($"[스마트수면] 시간설정 {_state.SleepSummary} → 종료 {_sleepEndAt:MM-dd HH:mm} 자동 해제 예약");
|
|
}
|
|
|
|
static DateTime NextOccurrence(int min)
|
|
{
|
|
var now = DateTime.Now;
|
|
var t = now.Date.AddMinutes(((min % 1440) + 1440) % 1440);
|
|
return t > now ? t : t.AddDays(1);
|
|
}
|
|
|
|
// 1초 주기 : 스마트수면 종료 시각 도달 시 자동 해제 + 이전 운전모드 복귀(기존 명령만 사용)
|
|
void CheckSleepSchedule()
|
|
{
|
|
if (!_state.SmartSleep || _sleepEndAt is not { } endAt || DateTime.Now < endAt) return;
|
|
_sleepEndAt = null;
|
|
if (!_demoTimer.IsEnabled && CanSend()) _api.SetSubMode(SubModeType.SmartSleep, false);
|
|
ApplySubModeLocal("SmartSleep", false);
|
|
Log("[스마트수면] 종료 시각 도달 → 자동 해제");
|
|
if (NoScenarioActive()) RestorePreviousMode();
|
|
RefreshControls();
|
|
}
|
|
|
|
static string ModeName(RunMode m) => m switch
|
|
{
|
|
RunMode.Vent => "환기", RunMode.Auto => "자동", RunMode.AirClean => "공청", RunMode.Bypass => "바이패스", _ => m.ToString()
|
|
};
|
|
|
|
// 5초마다 현재 상태를 시계열 샘플로 SQLite(HERV_Log.db)에 실시간 저장(무제한 누적).
|
|
// 앱 실행 중 항상 동작 — 그래프 창 열림 여부와 무관. 그래프는 DB를 읽어 표시.
|
|
void TakeSample()
|
|
{
|
|
var rooms = _state.Rooms;
|
|
var rs = new Model.RoomSample[rooms.Count];
|
|
for (int i = 0; i < rooms.Count; i++)
|
|
{
|
|
var r = rooms[i];
|
|
rs[i] = new Model.RoomSample
|
|
{
|
|
DamperSa = r.DamperSaOpen, DamperRa = r.DamperEaOpen,
|
|
Co2 = r.Co2, Pm25 = r.Pm25, Pm10 = r.Pm10, Voc = r.Voc,
|
|
Temp = r.Temp, Humi = r.Humi, Led = r.LedDim, Level = r.LoadScore,
|
|
};
|
|
}
|
|
var sample = new Model.LogSample
|
|
{
|
|
Time = DateTime.Now, Power = _state.PowerOn,
|
|
RunMode = (byte)_state.RunMode, FanMode = _state.FanMode,
|
|
// 자동운전 세부 : 0 비자동 / 1 자동-집중 / 2 자동-분산
|
|
AutoMode = _state.RunMode == RunMode.Auto
|
|
? (_state.AutoState == AutoState.Focus ? (byte)1 : (byte)2)
|
|
: (byte)0,
|
|
HystPreset = (byte)_state.HystPreset,
|
|
SmartSleep = _state.SmartSleep, ComfortCook = _state.ComfortCook, ReliefRecover = _state.ReliefRecover,
|
|
Rooms = rs,
|
|
};
|
|
try { _logDb.Insert(sample); }
|
|
catch (Exception ex) { Log($"[로그DB] 저장 실패: {ex.Message}"); }
|
|
_graphWin?.OnSampleAdded(sample);
|
|
}
|
|
|
|
void OpenGraph_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
var names = new System.Collections.Generic.List<string>();
|
|
foreach (var r in _state.Rooms) names.Add(r.Name);
|
|
if (_graphWin == null) { _graphWin = new GraphWindow(names.ToArray(), _logDb) { Owner = this }; _graphWin.Closed += (_, _) => _graphWin = null; _graphWin.Show(); }
|
|
else _graphWin.Activate();
|
|
}
|
|
|
|
// 팝업에서 호출 (제어 송신은 메인이 담당)
|
|
public void SelectPreset(HystPreset preset)
|
|
{
|
|
if (_demoTimer.IsEnabled) { _state.HystPreset = preset; return; }
|
|
if (!CanSend()) return;
|
|
_api.SetHystPreset(preset);
|
|
_state.HystPreset = preset;
|
|
Log($"[제어] 히스테리시스 프리셋 → {preset}");
|
|
}
|
|
|
|
public void ApplyHyst()
|
|
{
|
|
if (!CanSend()) return;
|
|
foreach (var h in _state.HystTable)
|
|
_api.SetHystDeadband(h.Preset, h.Pm25, h.Pm10, h.Voc, h.Co2);
|
|
Log($"[제어] 히스테리시스 프리셋 {_state.HystTable.Count}개 값 변경");
|
|
}
|
|
|
|
// 활성 프리셋의 오염단계 임계 + 데드밴드 송신 (HystWindow '변경')
|
|
public void ApplyHystPreset(int preset)
|
|
{
|
|
if (_demoTimer.IsEnabled) return; // 데모: 상태만 갱신됨
|
|
if (!CanSend()) return;
|
|
_api.SetHystThreshold(preset, 0, _state.Co2Thr[preset][0], _state.Co2Thr[preset][1], _state.Co2Thr[preset][2], _state.Co2Thr[preset][3]);
|
|
_api.SetHystThreshold(preset, 1, _state.Pm25Thr[preset][0], _state.Pm25Thr[preset][1], _state.Pm25Thr[preset][2], _state.Pm25Thr[preset][3]);
|
|
_api.SetHystThreshold(preset, 2, _state.Pm10Thr[preset][0], _state.Pm10Thr[preset][1], _state.Pm10Thr[preset][2], _state.Pm10Thr[preset][3]);
|
|
_api.SetHystThreshold(preset, 3, _state.VocThr[preset][0], _state.VocThr[preset][1], _state.VocThr[preset][2], _state.VocThr[preset][3]);
|
|
var h = _state.HystTable[preset];
|
|
_api.SetHystDeadband(preset, h.Pm25, h.Pm10, h.Voc, h.Co2);
|
|
Log($"[제어] 히스테리시스 프리셋 {(HystPreset)preset} 임계/데드밴드 적용");
|
|
}
|
|
|
|
public void ApplyVsp()
|
|
{
|
|
if (!CanSend()) return;
|
|
foreach (var v in _state.Vsp)
|
|
_api.SetVsp(v.Group, v.Index, Math.Clamp(v.Sa, 0, 255), Math.Clamp(v.Ea, 0, 255)); // VSP 1바이트
|
|
Log($"[제어] 풍량 VSP {_state.Vsp.Count}개 적용");
|
|
}
|
|
|
|
// ================= 통신 =================
|
|
void RefreshPorts_Click(object sender, RoutedEventArgs e) => RefreshPortsList();
|
|
|
|
void RefreshPortsList()
|
|
{
|
|
var ports = SerialErvApi.GetAvailablePorts();
|
|
Array.Sort(ports);
|
|
PortCombo.ItemsSource = ports;
|
|
if (ports.Length > 0 && PortCombo.SelectedIndex < 0) PortCombo.SelectedIndex = 0;
|
|
}
|
|
|
|
void Connect_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (PortCombo.SelectedItem is string p) _api.Connect(p, BaudRate);
|
|
else Log("포트를 선택하세요.");
|
|
}
|
|
|
|
void Disconnect_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
StopCommInternal();
|
|
_api.Disconnect();
|
|
}
|
|
|
|
void OnConnectionChanged(bool connected)
|
|
{
|
|
ConnLed.Fill = connected ? GoodBr : BadBr;
|
|
ConnText.Text = connected ? "연결됨" : "미연결";
|
|
ConnectBtn.IsEnabled = !connected;
|
|
DisconnectBtn.IsEnabled = connected;
|
|
if (connected)
|
|
{
|
|
_commActive = true; // 연결 즉시 제어/통신 활성
|
|
_api.RequestStatus(); // 최초 STATUS 요청
|
|
}
|
|
else
|
|
{
|
|
_commActive = false;
|
|
}
|
|
StartBtn.IsEnabled = connected && !_commActive;
|
|
StopBtn.IsEnabled = connected && _commActive;
|
|
}
|
|
|
|
void StartComm_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (!_api.IsConnected) return;
|
|
_commActive = true;
|
|
StartBtn.IsEnabled = false;
|
|
StopBtn.IsEnabled = true;
|
|
_api.RequestStatus();
|
|
Log("통신 시작 - STATUS 요청");
|
|
}
|
|
|
|
void StopComm_Click(object sender, RoutedEventArgs e) => StopCommInternal();
|
|
|
|
void StopCommInternal()
|
|
{
|
|
if (!_commActive) return;
|
|
_commActive = false;
|
|
StartBtn.IsEnabled = _api.IsConnected;
|
|
StopBtn.IsEnabled = false;
|
|
Log("통신 중지");
|
|
}
|
|
|
|
bool CanSend()
|
|
{
|
|
if (_demoTimer.IsEnabled) return false; // 데모 모드에선 송신 안 함
|
|
if (!_api.IsConnected)
|
|
{
|
|
Log("연결 후 제어 가능합니다.");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ================= ERV 제어 =================
|
|
void Power_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
bool next = !_state.PowerOn;
|
|
if (_demoTimer.IsEnabled) { _state.PowerOn = next; return; }
|
|
if (!CanSend()) return;
|
|
_api.SetPower(next);
|
|
_state.PowerOn = next;
|
|
Log($"[제어] 전원 → {(next ? "ON" : "OFF")}");
|
|
}
|
|
|
|
void Mode_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is not Button b || b.Tag is not string tag) return;
|
|
var def = Array.Find(ModeDefs, d => d.tag == tag);
|
|
// 운전모드 전환 시 풍량 1단 (자동 제외). 실연결 시 ERV STATUS 로 최종 확정.
|
|
if (_demoTimer.IsEnabled) { _state.RunMode = def.mode; if (def.mode != RunMode.Auto) _state.FanMode = 1; return; }
|
|
if (!CanSend()) return;
|
|
_api.SetRunMode(def.mode);
|
|
_state.RunMode = def.mode;
|
|
if (def.mode != RunMode.Auto) _state.FanMode = 1;
|
|
Log($"[제어] 운전모드 → {def.label}");
|
|
}
|
|
|
|
// 자동모드 프리셋 선택 : 선택 프리셋의 임계(=공기질 판정 기준)로 전환.
|
|
// 버튼은 자동모드에서만 활성(RefreshControls). 송신/상태갱신은 SelectPreset 이 담당.
|
|
void Preset_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is not Button b || b.Tag is not string tag) return;
|
|
if (!_state.IsAuto) { Log("프리셋은 자동모드에서만 선택할 수 있습니다."); return; }
|
|
if (!Enum.TryParse<HystPreset>(tag, out var preset)) return;
|
|
SelectPreset(preset);
|
|
}
|
|
|
|
void Fan_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is not Button b || b.Tag is not int speed) return;
|
|
if (_state.IsAuto) { Log("자동모드에서는 풍량 조절 불가"); return; }
|
|
if (_state.RunMode == RunMode.Bypass && speed > 1) { Log("바이패스는 1단 고정"); return; }
|
|
if (_demoTimer.IsEnabled) { _state.FanMode = (byte)speed; return; }
|
|
if (!CanSend()) return;
|
|
_api.SetFan(speed);
|
|
_state.FanMode = (byte)speed;
|
|
Log($"[제어] 풍량 → {speed}");
|
|
}
|
|
|
|
void SubMode_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is not Button b || b.Tag is not string tag) return;
|
|
bool anyBefore = _state.SmartSleep || _state.ComfortCook || _state.ReliefRecover;
|
|
(SubModeType type, bool next) = tag switch
|
|
{
|
|
"SmartSleep" => (SubModeType.SmartSleep, !_state.SmartSleep),
|
|
"ComfortCook" => (SubModeType.ComfortCook, !_state.ComfortCook),
|
|
_ => (SubModeType.ReliefRecover, !_state.ReliefRecover),
|
|
};
|
|
// 시나리오 첫 진입 → 직전 운전모드/풍량 기억(해제 시 복귀용)
|
|
if (next && !anyBefore) { _modeBeforeScenario = _state.RunMode; _fanBeforeScenario = _state.FanMode; }
|
|
if (tag == "SmartSleep" && !next) _sleepEndAt = null; // 스마트수면 수동 해제 → 자동해제 예약 취소
|
|
if (_demoTimer.IsEnabled)
|
|
{
|
|
ApplySubModeLocal(tag, next);
|
|
if (NoScenarioActive()) RestorePreviousMode();
|
|
return;
|
|
}
|
|
if (!CanSend()) return;
|
|
// 상호배타: 새 모드를 켤 때 기존 활성 모드는 장치에도 OFF 전송
|
|
// (펌웨어는 시나리오모드를 독립 변수로 유지 → status 재수신 시 부활 방지)
|
|
if (next)
|
|
{
|
|
if (tag != "SmartSleep" && _state.SmartSleep) _api.SetSubMode(SubModeType.SmartSleep, false);
|
|
if (tag != "ComfortCook" && _state.ComfortCook) _api.SetSubMode(SubModeType.ComfortCook, false);
|
|
if (tag != "ReliefRecover" && _state.ReliefRecover) _api.SetSubMode(SubModeType.ReliefRecover, false);
|
|
}
|
|
_api.SetSubMode(type, next);
|
|
ApplySubModeLocal(tag, next);
|
|
Log($"[제어] 시나리오모드 {b.Content} → {(next ? "ON" : "OFF")}");
|
|
if (NoScenarioActive()) RestorePreviousMode();
|
|
}
|
|
|
|
bool NoScenarioActive() => !_state.SmartSleep && !_state.ComfortCook && !_state.ReliefRecover;
|
|
|
|
// 시나리오모드 해제 → 진입 직전 운전모드/풍량으로 동작 복귀(이전모드로 동작).
|
|
// 실연결 시 ERV(펌웨어/시뮬)도 자체 복원하므로 로컬은 즉시 반영하고 STATUS 로 재동기화.
|
|
void RestorePreviousMode()
|
|
{
|
|
_state.RunMode = _modeBeforeScenario;
|
|
_state.FanMode = _fanBeforeScenario;
|
|
Log($"[시나리오] 해제 → 이전 운전모드({ModeName(_modeBeforeScenario)} {_fanBeforeScenario}단) 복귀");
|
|
}
|
|
|
|
void ApplySubModeLocal(string tag, bool on)
|
|
{
|
|
// 시나리오모드는 상호배타: 하나를 켜면 나머지는 해제
|
|
if (on)
|
|
{
|
|
_state.SmartSleep = tag == "SmartSleep";
|
|
_state.ComfortCook = tag == "ComfortCook";
|
|
_state.ReliefRecover = tag == "ReliefRecover";
|
|
}
|
|
else
|
|
{
|
|
switch (tag)
|
|
{
|
|
case "SmartSleep": _state.SmartSleep = false; break;
|
|
case "ComfortCook": _state.ComfortCook = false; break;
|
|
case "ReliefRecover": _state.ReliefRecover = false; break;
|
|
}
|
|
}
|
|
}
|
|
|
|
void Hood_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
bool next = !_state.Hood;
|
|
if (_demoTimer.IsEnabled) { _state.Hood = next; return; }
|
|
if (!CanSend()) return;
|
|
_api.SetHood(next);
|
|
_state.Hood = next;
|
|
Log($"[제어] 연동후드 → {(next ? "ON" : "OFF")}");
|
|
}
|
|
|
|
void Reset_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
bool next = !_state.Reset;
|
|
if (_demoTimer.IsEnabled) { _state.Reset = next; return; }
|
|
if (!CanSend()) return;
|
|
_api.SetReset(next);
|
|
_state.Reset = next;
|
|
Log($"[제어] ERV 리셋 → {(next ? "ON" : "OFF")}");
|
|
}
|
|
|
|
// ================= 각실 제어 =================
|
|
// 급기(SA) 댐퍼 토글
|
|
void DamperSa_Click(object sender, RoutedEventArgs e) => DamperToggle(sender, type: 0);
|
|
// 배기(EA) 댐퍼 토글
|
|
void DamperEa_Click(object sender, RoutedEventArgs e) => DamperToggle(sender, type: 1);
|
|
|
|
// type : 0=급기(SA) / 1=배기(EA)
|
|
void DamperToggle(object sender, int type)
|
|
{
|
|
if (sender is not Button b || b.Tag is not int roomId) return;
|
|
var room = _state.Room(roomId);
|
|
bool cur = type == 0 ? room.DamperSaOpen : room.DamperEaOpen;
|
|
bool next = !cur;
|
|
if (_demoTimer.IsEnabled)
|
|
{
|
|
if (type == 0) room.DamperSaOpen = next; else room.DamperEaOpen = next;
|
|
return;
|
|
}
|
|
if (!CanSend()) return;
|
|
_api.SetDiffuserDamper(roomId, type, next);
|
|
if (type == 0) room.DamperSaOpen = next; else room.DamperEaOpen = next;
|
|
Log($"[제어] {room.Name} {(type == 0 ? "급기" : "배기")}댐퍼 → {(next ? "열림" : "닫힘")}");
|
|
}
|
|
|
|
// LED 디밍 전송 경로 (드래그/클릭/키보드 모두 지원)
|
|
// - thumb 드래그 : 중간값 전송 보류(_ledDragging) → DragCompleted 에서 최종값 1회 전송
|
|
// - 트랙 클릭/키보드 : ValueChanged 에서 즉시 전송
|
|
// - STATUS 역갱신(_suppressLed) 으로 인한 변경은 전송 안 함
|
|
void Led_DragStarted(object sender, DragStartedEventArgs e) => _ledDragging = true;
|
|
|
|
void Led_DragCompleted(object sender, DragCompletedEventArgs e)
|
|
{
|
|
_ledDragging = false;
|
|
if (sender is Slider s) SendLed(s);
|
|
}
|
|
|
|
void Led_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
|
{
|
|
if (_ledDragging) return; // 드래그 중 중간값은 DragCompleted 에서 처리
|
|
if (sender is Slider s) SendLed(s);
|
|
}
|
|
|
|
void SendLed(Slider s)
|
|
{
|
|
if (s.Tag is not int roomId) return;
|
|
if (_suppressLed) return; // STATUS 동기 적용 중 echo 차단
|
|
int dim = (int)s.Value;
|
|
// 직전 송신/수신값과 같으면 전송 안 함 — STATUS 역갱신이 유발한 ValueChanged(에코) 차단.
|
|
// 사용자가 값을 실제로 바꾸면 dim 이 달라지므로 정상 전송됨.
|
|
if (_lastLed.TryGetValue(roomId, out var last) && last == dim) return;
|
|
if (_demoTimer.IsEnabled) return;
|
|
if (!CanSend()) return;
|
|
_api.SetDiffuserLed(roomId, dim);
|
|
_lastLed[roomId] = dim;
|
|
Log($"[제어] {_state.Room(roomId).Name} LED 디밍 → {dim}");
|
|
}
|
|
|
|
// ================= (꺼짐)예약 =================
|
|
bool _suppressReserve; // 상태→콤보 동기화 중 Reserve_Changed 재진입 차단
|
|
void Reserve_Changed(object sender, SelectionChangedEventArgs e)
|
|
{
|
|
if (!IsLoaded || _suppressReserve) return;
|
|
if (ReserveCombo.SelectedIndex < 0) return;
|
|
int hours = ReserveCombo.SelectedIndex; // 0=해제, 1~8시간
|
|
if (_demoTimer.IsEnabled) { _state.ReserveRemainSec = hours * 3600; return; }
|
|
if (!CanSend()) return;
|
|
_api.SetReserve(hours);
|
|
Log(hours == 0 ? "[제어] 예약 해제" : $"[제어] {hours}시간 후 꺼짐 예약");
|
|
}
|
|
|
|
// ================= 데모 모드 (버튼 제거됨 — 내부 합성 STATUS 경로는 비활성 상태로 유지) =================
|
|
void DemoTick()
|
|
{
|
|
_api.InjectDemoStatus(_demoTick++);
|
|
}
|
|
|
|
// ================= UI 갱신 =================
|
|
void RefreshControls()
|
|
{
|
|
// 전원
|
|
SetToggle(PowerBtn, _state.PowerOn, "ON", "OFF");
|
|
// ERV 리셋
|
|
SetToggle(ResetBtn, _state.Reset, "ON", "OFF");
|
|
// 연동후드 + 통신연결 상태 텍스트
|
|
SetToggle(HoodBtn, _state.Hood, "ON", "OFF");
|
|
if (_state.Hood)
|
|
{
|
|
HoodConnText.Text = _state.HoodConnected ? "후드 연결" : "후드 연결 안됨";
|
|
HoodConnText.Foreground = _state.HoodConnected ? GoodBr : BadBr;
|
|
}
|
|
else
|
|
{
|
|
HoodConnText.Text = "";
|
|
}
|
|
|
|
// 운전모드 강조
|
|
for (int i = 0; i < _modeButtons.Count; i++)
|
|
SetActive(_modeButtons[i], ModeDefs[i].mode == _state.RunMode);
|
|
|
|
// 풍량 : 현재 단수는 모드와 무관하게 항상 강조(자동은 ERV가 결정한 단수 표시).
|
|
// - 자동 : 수동 조절 불가(전 단 비활성)
|
|
// - 바이패스 : 최대 1단(2~4단 비활성)
|
|
// - 환기/공청 : 0~4단
|
|
// 시나리오모드 활성 시: 운전모드·풍량·선택 안 된 시나리오모드 비활성화
|
|
// 쾌적조리는 '연동운전중(HoodRunning=후드 가동중)' 기준으로 시나리오 활성 판단.
|
|
// 후드 OFF(대기 상태)면 ERV는 본래 운전모드로 복귀하므로 운전모드를 다시 활성화해야 함(사양 3.1).
|
|
bool subActive = _state.SmartSleep || _state.HoodRunning || _state.ReliefRecover;
|
|
int fanMax = _state.RunMode == RunMode.Bypass ? 1 : 4;
|
|
foreach (var fb in _fanButtons)
|
|
{
|
|
int sp = (int)fb.Tag!;
|
|
fb.IsEnabled = !subActive && !_state.IsAuto && sp <= fanMax;
|
|
SetActive(fb, sp == _state.FanMode);
|
|
}
|
|
|
|
// 시나리오모드
|
|
SetActive(SmartSleepBtn, _state.SmartSleep);
|
|
// 쾌적조리는 사양 9p 3.1 의 'UI 토글(연동 스위치)' — 후드 가동중이 아니라 토글 ON/OFF(=Hood_YeunDong_Enable)를 강조.
|
|
// (HoodRunning 으로 강조하면 대기/Roll-back 상태에서 버튼이 꺼져 보여 재선택 시 토글이 반대로 먹던 문제 수정)
|
|
SetActive(ComfortCookBtn, _state.ComfortCook);
|
|
SetActive(ReliefRecoverBtn, _state.ReliefRecover);
|
|
// (활성 모드 버튼은 OFF 토글 가능해야 하므로 자기 자신은 유지)
|
|
SmartSleepBtn.IsEnabled = !subActive || _state.SmartSleep;
|
|
// 쾌적조리는 사양 9p 3.1 의 '독립 토글(연동 스위치)' — 후드 연결/가동·다른 시나리오와 무관하게 항상 토글 가능.
|
|
ComfortCookBtn.IsEnabled = true;
|
|
ReliefRecoverBtn.IsEnabled = !subActive || _state.ReliefRecover;
|
|
// 스마트수면 시간설정 버튼 : 스마트수면 ON 일 때만 활성
|
|
SmartSleepSetBtn.IsEnabled = _state.SmartSleep;
|
|
foreach (var mb in _modeButtons) mb.IsEnabled = !subActive;
|
|
|
|
// 자동모드 프리셋(ECO/NORMAL/TURBO) : 자동모드에서만 활성, 활성 프리셋 강조.
|
|
// 선택 프리셋이 곧 공기질 판정 임계(=히스테리시스 임계). 기본값은 표준(NORMAL, 상태 초기값).
|
|
// (HystWindow 팝업의 프리셋 버튼과 _state.HystPreset 으로 동기화)
|
|
bool presetEnabled = _state.IsAuto && !subActive;
|
|
var presets = new[] { HystPreset.Eco, HystPreset.Normal, HystPreset.Turbo };
|
|
for (int i = 0; i < _presetButtons.Count; i++)
|
|
{
|
|
_presetButtons[i].IsEnabled = presetEnabled;
|
|
SetActive(_presetButtons[i], presetEnabled && presets[i] == _state.HystPreset);
|
|
}
|
|
|
|
// (꺼짐)예약 : 만료(전원OFF)/해제 시 콤보를 '해제'로 되돌림
|
|
if (_state.ReserveRemainSec == 0 && ReserveCombo.SelectedIndex != 0)
|
|
{
|
|
_suppressReserve = true;
|
|
ReserveCombo.SelectedIndex = 0;
|
|
_suppressReserve = false;
|
|
}
|
|
}
|
|
|
|
void SetToggle(Button b, bool on, string onText, string offText)
|
|
{
|
|
b.Content = on ? onText : offText;
|
|
b.Background = on ? Accent : CardBgBr;
|
|
b.Foreground = on ? Brushes.White : TextPrimaryBr;
|
|
b.BorderBrush = on ? Accent : CardBorderBr;
|
|
}
|
|
|
|
void SetActive(Button b, bool active)
|
|
{
|
|
b.Background = active ? Accent : CardBgBr;
|
|
b.Foreground = active ? Brushes.White : TextPrimaryBr;
|
|
b.BorderBrush = active ? Accent : CardBorderBr;
|
|
}
|
|
|
|
// ================= 로그 =================
|
|
void LogStatusSnapshot()
|
|
{
|
|
// 사양서 로그 항목: 운전모드/풍량/연동, 자동상태(분산/집중), 각실 부하점수·풍량, 프리셋, 히스테리시스값, 각실 댐퍼/센서/공기질/LED
|
|
var sb = new StringBuilder();
|
|
sb.Append($"STATUS pwr={(_state.PowerOn ? "ON" : "OFF")} mode={_state.RunMode} fan={_state.FanMode} ");
|
|
sb.Append($"hood={(_state.Hood ? 1 : 0)} sub=0x{_state.SubModeBitmap:X2} auto={_state.AutoStateText} ");
|
|
sb.Append($"preset={_state.HystPreset} hyst[PM2.5={_state.HystPm25},PM10={_state.HystPm10},VOC={_state.HystVoc},CO2={_state.HystCo2}] ");
|
|
sb.Append($"err={_state.ErrorCodeHex}");
|
|
Log(sb.ToString());
|
|
foreach (var r in _state.Rooms)
|
|
Log($" {r.Name}: 급기={(r.DamperSaOpen ? "O" : "X")} 배기={(r.DamperEaOpen ? "O" : "X")} PM2.5={r.Pm25} PM10={r.Pm10} VOC={r.Voc} CO2={r.Co2} " +
|
|
$"AQ={r.AirQualityText} LED={r.LedDim} load={r.LoadScore} vol={r.FinalVolume}");
|
|
}
|
|
|
|
void Log(string msg)
|
|
{
|
|
var line = $"[{DateTime.Now:HH:mm:ss.fff}] {msg}";
|
|
Dispatcher.BeginInvoke(() =>
|
|
{
|
|
LogList.AppendText(line + Environment.NewLine);
|
|
if (LogList.LineCount > 1000) // 오래된 줄 정리(최근 600줄 유지)
|
|
{
|
|
var lines = LogList.Text.Split(Environment.NewLine);
|
|
LogList.Text = string.Join(Environment.NewLine, lines[^600..]);
|
|
}
|
|
if (AutoScrollChk.IsChecked == true) LogList.ScrollToEnd();
|
|
});
|
|
}
|
|
|
|
void ClearLog_Click(object sender, RoutedEventArgs e) => LogList.Clear();
|
|
|
|
void SaveLog_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
var dlg = new SaveFileDialog
|
|
{
|
|
Filter = "텍스트 파일 (*.txt)|*.txt|모든 파일 (*.*)|*.*",
|
|
FileName = $"ERV_Log_{DateTime.Now:yyyyMMdd_HHmmss}.txt",
|
|
};
|
|
if (dlg.ShowDialog() != true) return;
|
|
try
|
|
{
|
|
File.WriteAllText(dlg.FileName, LogList.Text, Encoding.UTF8);
|
|
Log($"로그 저장 완료: {dlg.FileName}");
|
|
}
|
|
catch (Exception ex) { Log($"로그 저장 실패: {ex.Message}"); }
|
|
}
|
|
|
|
protected override void OnClosed(EventArgs e)
|
|
{
|
|
_demoTimer.Stop();
|
|
_api.Dispose();
|
|
_logDb.Dispose();
|
|
base.OnClosed(e);
|
|
}
|
|
}
|
|
|
|
// ================= 컨버터 =================
|
|
public class AirQualityToBrushConverter : IValueConverter
|
|
{
|
|
static readonly Brush Red = (Brush)new BrushConverter().ConvertFromString("#EF4444")!;
|
|
static readonly Brush Orange = (Brush)new BrushConverter().ConvertFromString("#F59E0B")!;
|
|
static readonly Brush Green = (Brush)new BrushConverter().ConvertFromString("#22C55E")!;
|
|
static readonly Brush Blue = (Brush)new BrushConverter().ConvertFromString("#3B82F6")!;
|
|
static readonly Brush Gray = (Brush)new BrushConverter().ConvertFromString("#CBD2DE")!;
|
|
|
|
public object Convert(object value, Type t, object p, CultureInfo c) =>
|
|
value is AirQuality aq ? aq switch
|
|
{
|
|
AirQuality.VeryBad => Red,
|
|
AirQuality.Bad => Orange,
|
|
AirQuality.Normal => Green,
|
|
AirQuality.Good => Blue,
|
|
_ => Gray,
|
|
} : Gray;
|
|
|
|
public object ConvertBack(object v, Type t, object p, CultureInfo c) => DependencyProperty.UnsetValue;
|
|
}
|
|
|
|
// 각실 Level(0~4) → 색 (사양 색상). 0 좋음(파랑) / 1 보통(초록) / 2 나쁨(노랑) / 3 매우나쁨(빨강) / 4 최악(빨강)
|
|
public class LevelToBrushConverter : IValueConverter
|
|
{
|
|
static readonly Brush Blue = (Brush)new BrushConverter().ConvertFromString("#3B82F6")!;
|
|
static readonly Brush Green = (Brush)new BrushConverter().ConvertFromString("#22C55E")!;
|
|
static readonly Brush Yellow = (Brush)new BrushConverter().ConvertFromString("#EAB308")!;
|
|
static readonly Brush Red = (Brush)new BrushConverter().ConvertFromString("#EF4444")!;
|
|
static readonly Brush Gray = (Brush)new BrushConverter().ConvertFromString("#CBD2DE")!;
|
|
|
|
public object Convert(object value, Type t, object p, CultureInfo c) =>
|
|
value is int lv ? lv switch
|
|
{
|
|
0 => Blue,
|
|
1 => Green,
|
|
2 => Yellow,
|
|
3 => Red, // 매우나쁨 (요청: 주황→빨강)
|
|
4 => Red, // 최악
|
|
_ => Gray,
|
|
} : Gray;
|
|
|
|
public object ConvertBack(object v, Type t, object p, CultureInfo c) => DependencyProperty.UnsetValue;
|
|
}
|
|
|
|
public class BoolToBrushConverter : IValueConverter
|
|
{
|
|
static readonly Brush On = (Brush)new BrushConverter().ConvertFromString("#22C55E")!;
|
|
static readonly Brush Off = (Brush)new BrushConverter().ConvertFromString("#FFFFFF")!;
|
|
public object Convert(object value, Type t, object p, CultureInfo c) =>
|
|
(value is bool b && b) ? On : Off;
|
|
public object ConvertBack(object v, Type t, object p, CultureInfo c) => DependencyProperty.UnsetValue;
|
|
}
|
|
|
|
public class BoolToOnOffConverter : 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;
|
|
}
|
|
}
|