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
+48
View File
@@ -0,0 +1,48 @@
using ErvProtocol;
namespace ErvDashboard.Api
{
// PC 대시보드 ↔ ERV (PC_ERV_Protocol.md) 통신을 함수로 감싼 API.
// 내부적으로는 485 프레임(CtrlFrame 송신 / STATUS 수신)을 그대로 사용한다.
// 즉 전송 방식(프로토콜)은 불변이고, 호출부가 바이트 대신 의미 있는 함수를 쓰도록 캡슐화한 것.
public interface IErvApi : IDisposable
{
// ---- 연결 ----
bool IsConnected { get; }
bool Connect(string port, int baud);
void Disconnect();
event Action<bool>? ConnectionChanged;
// ---- 수신 ----
event Action<StatusRecord>? StatusReceived; // STATUS 디코드 완료
event Action<string>? Log; // 프레임/이벤트 hex 로그
void RequestStatus(); // STATUS 요청 (0x0A)
// 최근 수신한 STATUS 기준 각실(room 1~4) 상태 조회. 수신 이력이 없거나 room 범위 밖이면 false.
bool GetRoomStatus(int room, out bool damperSa, out bool damperEa, out AirQuality airQuality, out int led);
// ---- 제어(기본) ----
void SetPower(bool on); // 0x01
void SetRunMode(RunMode mode); // 0x02
void SetFan(int speed); // 0x03 (0~4)
void SetSubMode(SubModeType type, bool on); // 0x04
void SetHood(bool on); // 0x05
void SetReserve(int hours); // 0x0E (0~8, 0=해제)
void SetReset(bool on); // 0x0B
// ---- 각실 ----
void SetDiffuserDamper(int room, int type, bool open); // 0x08 (type 0=급기/1=배기)
void SetDiffuserLed(int room, int dim); // 0x09 (0~9)
// ---- 풍량 VSP ----
void SetVsp(int group, int index, int sa, int ea); // 0x0C
// ---- 히스테리시스 ----
void SetHystPreset(HystPreset preset); // 0x06
void SetHystDeadband(int preset, int pm25, int pm10, int voc, int co2); // 0x07
void SetHystThreshold(int preset, int pollutant, int l1, int l2, int l3, int l4); // 0x0D
// ---- 데모/테스트 (합성 STATUS를 수신 경로로 주입) ----
void InjectDemoStatus(int tick);
}
}
+113
View File
@@ -0,0 +1,113 @@
using ErvDashboard.Protocol; // SerialChannel, HexFormat
using ErvProtocol;
namespace ErvDashboard.Api
{
// IErvApi 의 RS485(시리얼) 구현. 시리얼 채널 + 공용 프레임 파서/빌더(CtrlFrame, StatusDecoder)를 내장한다.
// UI 비종속 : STATUS 는 StatusRecord 이벤트로만 알리고, DashboardState 매핑은 호출부(MainWindow)가 담당.
public sealed class SerialErvApi : IErvApi
{
readonly SerialChannel _ch = new();
readonly FrameParser _parser = new();
DateTime _lastByte = DateTime.MinValue;
static readonly TimeSpan FrameGap = TimeSpan.FromMilliseconds(60);
StatusRecord? _lastStatus; // 최근 수신 STATUS (GetRoomStatus 조회용)
public event Action<bool>? ConnectionChanged;
public event Action<StatusRecord>? StatusReceived;
public event Action<string>? Log;
public bool IsConnected => _ch.IsConnected;
public static string[] GetAvailablePorts() => SerialChannel.GetAvailablePorts();
public SerialErvApi()
{
_ch.ByteReceived += OnByte;
_ch.Log += s => Log?.Invoke(s);
_ch.ConnectionChanged += b => ConnectionChanged?.Invoke(b);
_parser.OnFrame += HandleFrame;
_parser.OnError += m => Log?.Invoke("RX " + m);
}
public bool Connect(string port, int baud) => _ch.Connect(port, baud);
public void Disconnect() => _ch.Disconnect();
// 최근 STATUS 기준 각실(room 1~4) 상태 조회
public bool GetRoomStatus(int room, out bool damperSa, out bool damperEa, out AirQuality airQuality, out int led)
{
damperSa = false; damperEa = false; airQuality = AirQuality.Normal; led = 0;
var s = _lastStatus;
if (s == null || room < 1 || room > 4) return false;
var r = s.Rooms[room - 1];
damperSa = r.DamperSa;
damperEa = r.DamperEa;
airQuality = (AirQuality)r.AirQuality;
led = r.LedDim;
return true;
}
// 시리얼 바이트 → 공용 파서 (프레임 갭 시 동기 리셋)
void OnByte(byte b)
{
var now = DateTime.UtcNow;
if (now - _lastByte > FrameGap) _parser.Reset();
_lastByte = now;
_parser.FeedByte(b);
}
void HandleFrame(byte cmd, byte[] p)
{
switch (cmd)
{
case StatusDecoder.STATUS:
Log?.Invoke($"RX STATUS ({p.Length}B)");
var rec = StatusDecoder.Decode(p);
if (rec != null) { _lastStatus = rec; StatusReceived?.Invoke(rec); }
else Log?.Invoke($" STATUS too short ({p.Length}<{StatusDecoder.STATUS_LEN})");
break;
case StatusDecoder.ACK:
if (p.Length >= 2)
Log?.Invoke($"RX ACK echo=0x{p[0]:X2} result={(p[1] == 0 ? "OK" : "ERR")}");
break;
default:
Log?.Invoke($"RX unknown cmd=0x{cmd:X2}");
break;
}
}
// ================= 송신 (공용 CtrlFrame) =================
void SendFrame(byte[] f)
{
if (_ch.Send(f, f.Length))
Log?.Invoke($"TX {HexFormat.Bytes(f, f.Length)}");
}
public void RequestStatus() => SendFrame(CtrlFrame.RequestStatus());
public void SetPower(bool on) => SendFrame(CtrlFrame.Power(on ? 1 : 0));
public void SetRunMode(RunMode mode) => SendFrame(CtrlFrame.RunModeCmd((int)mode));
public void SetFan(int speed) => SendFrame(CtrlFrame.Fan(speed));
public void SetSubMode(SubModeType type, bool on) => SendFrame(CtrlFrame.SubMode((int)type, on ? 1 : 0));
public void SetHood(bool on) => SendFrame(CtrlFrame.Hood(on ? 1 : 0));
public void SetReserve(int hours) => SendFrame(CtrlFrame.Reserve(hours));
public void SetReset(bool on) => SendFrame(CtrlFrame.Reset(on ? 1 : 0));
public void SetDiffuserDamper(int room, int type, bool open) => SendFrame(CtrlFrame.Damper(room, type, open ? 1 : 0));
public void SetDiffuserLed(int room, int dim) => SendFrame(CtrlFrame.Led(room, dim));
public void SetVsp(int group, int index, int sa, int ea) => SendFrame(CtrlFrame.Vsp(group, index, sa, ea));
public void SetHystPreset(HystPreset preset) => SendFrame(CtrlFrame.Preset((int)preset));
public void SetHystDeadband(int preset, int pm25, int pm10, int voc, int co2) => SendFrame(CtrlFrame.HystValue(preset, pm25, pm10, voc, co2));
public void SetHystThreshold(int preset, int pollutant, int l1, int l2, int l3, int l4) => SendFrame(CtrlFrame.HystThr(preset, pollutant, l1, l2, l3, l4));
// ================= 데모/테스트 =================
public void InjectDemoStatus(int tick)
{
var frame = CtrlFrame.Build(StatusDecoder.STATUS, DemoStatus.BuildPayload(tick));
_parser.Reset();
_parser.Feed(frame);
}
public void Dispose() => _ch.Dispose();
}
}
@@ -0,0 +1,74 @@
using ErvDashboard.Model;
using ErvProtocol;
namespace ErvDashboard.Api
{
// StatusRecord(공용 프로토콜 디코드 결과) → DashboardState(WPF UI 모델) 매핑.
// (이전 DashboardProtocol.ApplyStatus 로직 — API 는 UI 비종속, 매핑은 여기로 분리)
public static class StatusMapper
{
public static void Apply(StatusRecord s, DashboardState state)
{
state.PowerOn = s.Power != 0;
state.RunMode = (RunMode)s.RunMode;
state.AutoState = (AutoState)s.AutoState;
state.FanMode = s.FanMode;
state.SubModeBitmap = s.SubMode;
state.Hood = s.HoodEnable; // byte5 bit0 = 연동 Enable
state.HoodConnected = s.HoodConnected; // byte5 bit2 = 후드 통신연결
state.HystPreset = (HystPreset)s.HystPreset;
state.HystPm25 = s.HystPm25;
state.HystPm10 = s.HystPm10;
state.HystVoc = s.HystVoc;
state.HystCo2 = s.HystCo2;
state.ErrorCode = s.ErrorCode;
state.Reset = s.Reset != 0;
state.ReserveRemainSec = s.ReserveRemainSec;
for (int i = 0; i < state.Vsp.Count && i < s.Vsp.Length; i++)
{
state.Vsp[i].Sa = s.Vsp[i].Sa;
state.Vsp[i].Ea = s.Vsp[i].Ea;
}
for (int i = 0; i < state.HystTable.Count && i < s.HystTable.Length; i++)
{
state.HystTable[i].Pm25 = s.HystTable[i].Pm25;
state.HystTable[i].Pm10 = s.HystTable[i].Pm10;
state.HystTable[i].Voc = s.HystTable[i].Voc;
state.HystTable[i].Co2 = s.HystTable[i].Co2;
}
// 모드별 오염단계 임계표
for (int i = 0; i < 3 && i < s.ThrTable.Length; i++)
for (int k = 0; k < 4; k++)
{
state.Co2Thr[i][k] = s.ThrTable[i].Co2[k];
state.Pm25Thr[i][k] = s.ThrTable[i].Pm25[k];
state.Pm10Thr[i][k] = s.ThrTable[i].Pm10[k];
state.VocThr[i][k] = s.ThrTable[i].Voc[k];
}
int totalLoad = 0;
for (int r = 0; r < 4; r++)
{
var src = s.Rooms[r];
var room = state.Rooms[r];
room.DamperSaOpen = src.DamperSa; // 비트맵 bit0
room.DamperEaOpen = src.DamperEa; // 비트맵 bit1
room.Pm25 = src.Pm25;
room.Pm10 = src.Pm10;
room.Voc = src.Voc;
room.Co2 = src.Co2;
room.AirQuality = (AirQuality)src.AirQuality;
room.LedDim = src.LedDim;
room.LoadScore = src.LoadScore;
room.FinalVolume = src.FinalVolume;
room.Temp = src.Temp;
room.Humi = src.Humi;
totalLoad += src.LoadScore;
}
state.TotalLoadScore = totalLoad; // 합산 부하점수 (0~16)
}
}
}
+106
View File
@@ -0,0 +1,106 @@
<Application x:Class="ErvDashboard.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<!-- ===== Flat Light 팔레트 ===== -->
<SolidColorBrush x:Key="AppBg" Color="#F4F6FB"/>
<SolidColorBrush x:Key="CardBg" Color="#FFFFFF"/>
<SolidColorBrush x:Key="CardBorder" Color="#E3E7EF"/>
<SolidColorBrush x:Key="TextPrimary" Color="#1F2733"/>
<SolidColorBrush x:Key="TextSecondary" Color="#8A93A6"/>
<SolidColorBrush x:Key="Accent" Color="#3B82F6"/>
<SolidColorBrush x:Key="AccentSoft" Color="#E7F0FF"/>
<SolidColorBrush x:Key="Good" Color="#22C55E"/>
<SolidColorBrush x:Key="Warn" Color="#F59E0B"/>
<SolidColorBrush x:Key="Bad" Color="#EF4444"/>
<SolidColorBrush x:Key="Track" Color="#EDEFF4"/>
<!-- 공통 폰트 -->
<Style TargetType="{x:Type FrameworkElement}">
<Setter Property="TextOptions.TextFormattingMode" Value="Display"/>
</Style>
<!-- 카드 컨테이너 -->
<Style x:Key="Card" TargetType="Border">
<Setter Property="Background" Value="{StaticResource CardBg}"/>
<Setter Property="BorderBrush" Value="{StaticResource CardBorder}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="Padding" Value="16"/>
<Setter Property="Margin" Value="6"/>
</Style>
<!-- 카드 제목 -->
<Style x:Key="CardTitle" TargetType="TextBlock">
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{StaticResource TextSecondary}"/>
<Setter Property="Margin" Value="0,0,0,10"/>
</Style>
<!-- 라벨 -->
<Style x:Key="FieldLabel" TargetType="TextBlock">
<Setter Property="FontSize" Value="12"/>
<Setter Property="Foreground" Value="{StaticResource TextSecondary}"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<!-- 값 (숫자) -->
<Style x:Key="FieldValue" TargetType="TextBlock">
<Setter Property="FontSize" Value="15"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<!-- 읽기전용 뱃지 -->
<Style x:Key="ReadOnlyBadge" TargetType="Border">
<Setter Property="Background" Value="{StaticResource Track}"/>
<Setter Property="CornerRadius" Value="4"/>
<Setter Property="Padding" Value="5,1"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<!-- 일반 버튼 (Flat) -->
<Style x:Key="FlatButton" TargetType="Button">
<Setter Property="Background" Value="{StaticResource CardBg}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="BorderBrush" Value="{StaticResource CardBorder}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="14,7"/>
<Setter Property="Margin" Value="3"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{StaticResource AccentSoft}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 콤보박스 (간단 Flat) -->
<Style TargetType="ComboBox">
<Setter Property="Padding" Value="8,5"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="BorderBrush" Value="{StaticResource CardBorder}"/>
<Setter Property="Background" Value="{StaticResource CardBg}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
</Style>
</Application.Resources>
</Application>
+8
View File
@@ -0,0 +1,8 @@
using System.Windows;
namespace ErvDashboard
{
public partial class App : Application
{
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ErvDashboard</RootNamespace>
<AssemblyName>ErvDashboard</AssemblyName>
<StartupObject>ErvDashboard.App</StartupObject>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
<PackageReference Include="LiveChartsCore.SkiaSharpView.WPF" Version="2.1.0-dev-798" />
<PackageReference Include="System.IO.Ports" Version="10.0.5" />
</ItemGroup>
<ItemGroup>
<!-- 공용 프로토콜 라이브러리 (단일 진실원본) -->
<ProjectReference Include="..\ErvProtocol\ErvProtocol.csproj" />
</ItemGroup>
</Project>
+46
View File
@@ -0,0 +1,46 @@
<Window x:Class="ErvDashboard.GraphWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.WPF;assembly=LiveChartsCore.SkiaSharpView.WPF"
Title="로그 그래프" Width="1360" Height="840"
Background="{StaticResource AppBg}" FontFamily="Segoe UI, Malgun Gothic"
WindowStartupLocation="CenterOwner">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 툴바 -->
<Grid Grid.Row="0" Margin="0,0,0,8">
<StackPanel Orientation="Horizontal">
<TextBlock Text="로그 그래프" Style="{StaticResource CardTitle}" VerticalAlignment="Center"/>
<TextBlock Text="날짜:" VerticalAlignment="Center" Margin="14,0,4,0" Foreground="{StaticResource TextSecondary}"/>
<DatePicker x:Name="DatePick" VerticalAlignment="Center" Width="130"/>
<Button Content="📂 불러오기" Style="{StaticResource FlatButton}" Click="Load_Click" Padding="10,5" Margin="6,0,0,0"/>
<TextBlock x:Name="CountText" Text="" VerticalAlignment="Center" Margin="12,0,0,0" Foreground="{StaticResource TextSecondary}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="🔄 새로고침" Style="{StaticResource FlatButton}" Click="Refresh_Click" Padding="10,5" Margin="0,0,6,0"/>
<Button Content="📊 엑셀 저장" Style="{StaticResource FlatButton}" Click="Excel_Click" Padding="10,5" Margin="0,0,6,0"/>
<Button Content="닫기" Style="{StaticResource FlatButton}" Click="Close_Click" Padding="10,5"/>
</StackPanel>
</Grid>
<!-- 본문 : 좌 체크박스 / 우 차트 -->
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="{StaticResource CardBg}" BorderBrush="{StaticResource CardBorder}" BorderThickness="1" CornerRadius="8" Margin="0,0,8,0">
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="8">
<StackPanel x:Name="CheckPanel"/>
</ScrollViewer>
</Border>
<Border Grid.Column="1" Background="{StaticResource CardBg}" BorderBrush="{StaticResource CardBorder}" BorderThickness="1" CornerRadius="8" Padding="6">
<lvc:CartesianChart x:Name="Chart" LegendPosition="Bottom"/>
</Border>
</Grid>
</Grid>
</Window>
+218
View File
@@ -0,0 +1,218 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using LiveChartsCore.Defaults;
using LiveChartsCore.SkiaSharpView;
using ErvDashboard.Model;
using ErvDashboard.Storage;
namespace ErvDashboard
{
// 로그 그래프 창 : 가로=시간, 세로=각실 댐퍼on/off·센서값·운전/시나리오모드 (LiveCharts2).
// 좌측 체크박스로 표시 시리즈 선택. 데이터는 SQLite(LogDb)에서 최근 구간 로드 + 실시간 append.
public partial class GraphWindow : Window
{
readonly LogDb _db;
readonly string[] _roomNames;
const int MaxSamples = 17280; // 차트 표시 상한(최근 24h @ 5초). DB에는 전체 보관.
sealed class SeriesDef
{
public string Group = "";
public string Label = "";
public Func<LogSample, double> Value = _ => 0;
public bool Default;
}
SeriesDef[] _defs = Array.Empty<SeriesDef>();
LineSeries<DateTimePoint>[] _series = Array.Empty<LineSeries<DateTimePoint>>();
int _builtCount;
bool[] _shown = Array.Empty<bool>(); // 시리즈별 표시(체크) 상태 — 체크된 것만 차트/범례에 노출
DateTime _selectedDate = DateTime.Today; // 현재 로드된 날짜 (기본 오늘)
public GraphWindow(string[] roomNames, LogDb db)
{
InitializeComponent();
_db = db;
_roomNames = roomNames;
BuildDefs();
BuildChart();
BuildCheckboxes();
DatePick.SelectedDate = DateTime.Today;
_selectedDate = DateTime.Today;
BuildForDate(); // 초기: 오늘 로드(실시간). 이후 날짜는 '불러오기' 버튼으로 명시적 로드.
}
void BuildDefs()
{
var list = new List<SeriesDef>
{
new() { Group = "운전", Label = "운전모드", Value = s => s.RunMode },
new() { Group = "운전", Label = "자동-집중", Value = s => s.AutoMode == 1 ? 1 : 0 },
new() { Group = "운전", Label = "자동-분산", Value = s => s.AutoMode == 2 ? 1 : 0 },
new() { Group = "운전", Label = "프리셋-ECO", Value = s => s.HystPreset == 0 ? 1 : 0 },
new() { Group = "운전", Label = "프리셋-NORMAL", Value = s => s.HystPreset == 1 ? 1 : 0 },
new() { Group = "운전", Label = "프리셋-TURBO", Value = s => s.HystPreset == 2 ? 1 : 0 },
new() { Group = "운전", Label = "풍량", Value = s => s.FanMode },
new() { Group = "운전", Label = "전원", Value = s => s.Power ? 1 : 0 },
new() { Group = "시나리오", Label = "스마트수면", Value = s => s.SmartSleep ? 1 : 0 },
new() { Group = "시나리오", Label = "쾌적조리", Value = s => s.ComfortCook ? 1 : 0 },
new() { Group = "시나리오", Label = "안심회복", Value = s => s.ReliefRecover ? 1 : 0 },
};
for (int r = 0; r < _roomNames.Length; r++)
{
int ri = r; string nm = _roomNames[r];
list.Add(new() { Group = nm, Label = $"{nm} CO2", Value = s => s.Rooms[ri].Co2, Default = true });
list.Add(new() { Group = nm, Label = $"{nm} PM2.5", Value = s => s.Rooms[ri].Pm25 });
list.Add(new() { Group = nm, Label = $"{nm} PM10", Value = s => s.Rooms[ri].Pm10 });
list.Add(new() { Group = nm, Label = $"{nm} VOC", Value = s => s.Rooms[ri].Voc });
list.Add(new() { Group = nm, Label = $"{nm} 온도", Value = s => s.Rooms[ri].Temp });
list.Add(new() { Group = nm, Label = $"{nm} 습도", Value = s => s.Rooms[ri].Humi });
list.Add(new() { Group = nm, Label = $"{nm} LED", Value = s => s.Rooms[ri].Led });
list.Add(new() { Group = nm, Label = $"{nm} 부하점수", Value = s => s.Rooms[ri].Level });
list.Add(new() { Group = nm, Label = $"{nm} 급기댐퍼", Value = s => s.Rooms[ri].DamperSa ? 1 : 0 });
list.Add(new() { Group = nm, Label = $"{nm} 배기댐퍼", Value = s => s.Rooms[ri].DamperRa ? 1 : 0 });
}
_defs = list.ToArray();
}
void BuildChart()
{
_series = _defs.Select(d => new LineSeries<DateTimePoint>
{
Name = d.Label,
Values = new ObservableCollection<DateTimePoint>(),
GeometrySize = 0,
LineSmoothness = 0,
}).ToArray();
_shown = new bool[_defs.Length];
Array.Fill(_shown, true); // 그래프 열면 전체 선택(좌측 체크박스도 전부 체크)
// 가로축: 날짜 제거, 시간(HH:mm:ss)만 표시 (날짜는 상단 DatePicker)
Chart.XAxes = new[] { new DateTimeAxis(TimeSpan.FromSeconds(5), dt => dt.ToString("HH:mm:ss")) };
Chart.ZoomMode = LiveChartsCore.Measure.ZoomAndPanMode.X;
Chart.LegendTextSize = 10; // 범례 글씨 작게
Chart.LegendPosition = LiveChartsCore.Measure.LegendPosition.Bottom; // 범례 하단
ApplyVisible();
}
// 체크된 시리즈만 차트(+범례)에 노출. 체크 해제하면 범례에서도 사라짐.
void ApplyVisible()
{
Chart.Series = _series.Where((s, i) => _shown[i]).ToArray();
}
void BuildCheckboxes()
{
string? group = null;
for (int i = 0; i < _defs.Length; i++)
{
if (_defs[i].Group != group)
{
group = _defs[i].Group;
CheckPanel.Children.Add(new TextBlock { Text = group, FontWeight = FontWeights.Bold, Margin = new Thickness(0, 8, 0, 2) });
}
int idx = i;
var label = _defs[i].Label.StartsWith(group + " ") ? _defs[i].Label.Substring(group!.Length + 1) : _defs[i].Label;
var cb = new CheckBox { Content = label, IsChecked = true, Margin = new Thickness(4, 1, 0, 1) };
cb.Checked += (_, _) => { _shown[idx] = true; ApplyVisible(); };
cb.Unchecked += (_, _) => { _shown[idx] = false; ApplyVisible(); };
CheckPanel.Children.Add(cb);
}
}
// 선택 날짜(_selectedDate)의 하루치 데이터를 DB에서 로드해 차트 구성.
void BuildForDate()
{
var samples = _db.LoadByDate(_selectedDate);
for (int k = 0; k < _series.Length; k++)
{
var col = (ObservableCollection<DateTimePoint>)_series[k].Values!;
col.Clear();
foreach (var s in samples) col.Add(new DateTimePoint(s.Time, _defs[k].Value(s)));
}
_builtCount = samples.Count;
bool today = _selectedDate == DateTime.Today;
CountText.Text = $"{_selectedDate:yyyy-MM-dd} · {samples.Count}개 (5초){(today ? " · " : "")}";
}
// "불러오기" : 선택한 날짜를 확정 로드. 오늘이면 실시간 갱신, 과거면 정적 유지.
void Load_Click(object sender, RoutedEventArgs e)
{
_selectedDate = DatePick.SelectedDate?.Date ?? DateTime.Today;
BuildForDate();
}
// 메인이 새 샘플을 저장할 때 호출 — 오늘 보기일 때만 차트에 증분 1건 추가(상한 초과분 트림).
public void OnSampleAdded(LogSample s)
{
if (_selectedDate != DateTime.Today) return; // 과거 날짜 보기 중엔 실시간 추가 안 함
for (int k = 0; k < _series.Length; k++)
{
var col = (ObservableCollection<DateTimePoint>)_series[k].Values!;
col.Add(new DateTimePoint(s.Time, _defs[k].Value(s)));
if (col.Count > MaxSamples) col.RemoveAt(0);
}
_builtCount++;
CountText.Text = $"{_selectedDate:yyyy-MM-dd} · {Math.Min(_builtCount, MaxSamples)}개 (5초) · 실시간";
}
void Refresh_Click(object sender, RoutedEventArgs e) => BuildForDate();
// 선택 날짜의 데이터를 CSV(Excel 호환, UTF-8 BOM)로 저장. 날짜·시간 컬럼 분리.
void Excel_Click(object sender, RoutedEventArgs e)
{
var samples = _db.LoadByDate(_selectedDate);
if (samples.Count == 0) { MessageBox.Show("저장할 데이터가 없습니다."); return; }
var dlg = new Microsoft.Win32.SaveFileDialog
{
Filter = "CSV (Excel) (*.csv)|*.csv",
FileName = $"HERV_Log_{_selectedDate:yyyyMMdd}.csv"
};
if (dlg.ShowDialog() != true) return;
try { ExportCsv(dlg.FileName, samples); MessageBox.Show($"저장 완료:\n{dlg.FileName}"); }
catch (Exception ex) { MessageBox.Show($"저장 실패: {ex.Message}"); }
}
void ExportCsv(string path, List<LogSample> samples)
{
string[] modeName = { "환기", "자동", "바이패스", "공청" };
string[] autoName = { "", "집중", "분산" };
var head = new List<string> { "날짜", "시간", "전원", "운전모드", "자동상태", "풍량", "스마트수면", "쾌적조리", "안심회복" };
foreach (var nm in _roomNames)
head.AddRange(new[] { $"{nm}_급기댐퍼", $"{nm}_배기댐퍼", $"{nm}_CO2", $"{nm}_PM2.5", $"{nm}_PM10", $"{nm}_VOC", $"{nm}_온도", $"{nm}_습도", $"{nm}_LED", $"{nm}_부하점수" });
var sb = new System.Text.StringBuilder();
sb.AppendLine(string.Join(",", head));
foreach (var s in samples)
{
var row = new List<string>
{
s.Time.ToString("yyyy-MM-dd"),
s.Time.ToString("HH:mm:ss"),
s.Power ? "1" : "0",
s.RunMode < modeName.Length ? modeName[s.RunMode] : s.RunMode.ToString(),
s.AutoMode < autoName.Length ? autoName[s.AutoMode] : "",
s.FanMode.ToString(),
s.SmartSleep ? "1" : "0",
s.ComfortCook ? "1" : "0",
s.ReliefRecover ? "1" : "0",
};
foreach (var rm in s.Rooms)
{
row.Add(rm.DamperSa ? "1" : "0");
row.Add(rm.DamperRa ? "1" : "0");
row.Add(rm.Co2.ToString()); row.Add(rm.Pm25.ToString()); row.Add(rm.Pm10.ToString()); row.Add(rm.Voc.ToString());
row.Add(rm.Temp.ToString()); row.Add(rm.Humi.ToString()); row.Add(rm.Led.ToString()); row.Add(rm.Level.ToString());
}
sb.AppendLine(string.Join(",", row));
}
System.IO.File.WriteAllText(path, sb.ToString(), new System.Text.UTF8Encoding(true));
}
void Close_Click(object sender, RoutedEventArgs e) => Close();
}
}
+89
View File
@@ -0,0 +1,89 @@
<Window x:Class="ErvDashboard.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 AppBg}" FontFamily="Segoe UI, Malgun Gothic">
<Border Style="{StaticResource Card}" Margin="10">
<StackPanel>
<TextBlock Text="공기질 센서 히스테리시스 — 모드(프리셋)별 오염단계 임계 + 데드밴드" Style="{StaticResource CardTitle}"/>
<TextBlock Text="오염단계 0~4(좋음·보통·나쁨·매우나쁨·최악). 각 값은 해당 단계의 상한(이하). 4단계(최악)는 3단계 상한 초과." Style="{StaticResource FieldLabel}" Margin="0,0,0,4"/>
<TextBlock Text="활성 프리셋 선택 (선택한 프리셋의 임계/데드밴드 표시·수정)" Style="{StaticResource FieldLabel}" Margin="0,0,0,4"/>
<StackPanel x:Name="PresetPanel" Orientation="Horizontal" Margin="0,0,0,14"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="128"/>
<ColumnDefinition Width="72"/>
<ColumnDefinition Width="72"/>
<ColumnDefinition Width="72"/>
<ColumnDefinition Width="72"/>
</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" Style="{StaticResource FieldLabel}"/>
<TextBlock Grid.Row="0" Grid.Column="2" Text="PM2.5" TextAlignment="Center" Style="{StaticResource FieldLabel}"/>
<TextBlock Grid.Row="0" Grid.Column="3" Text="PM10" TextAlignment="Center" Style="{StaticResource FieldLabel}"/>
<TextBlock Grid.Row="0" Grid.Column="4" Text="VOC" TextAlignment="Center" Style="{StaticResource FieldLabel}"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="0단계(좋음)" VerticalAlignment="Center" Style="{StaticResource FieldValue}" FontWeight="Bold" Foreground="{StaticResource Accent}"/>
<TextBox x:Name="TCo2_1" Grid.Row="1" Grid.Column="1" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBox x:Name="TPm25_1" Grid.Row="1" Grid.Column="2" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBox x:Name="TPm10_1" Grid.Row="1" Grid.Column="3" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBox x:Name="TVoc_1" Grid.Row="1" Grid.Column="4" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="1단계(보통)" VerticalAlignment="Center" Style="{StaticResource FieldValue}" FontWeight="Bold" Foreground="{StaticResource Good}"/>
<TextBox x:Name="TCo2_2" Grid.Row="2" Grid.Column="1" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBox x:Name="TPm25_2" Grid.Row="2" Grid.Column="2" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBox x:Name="TPm10_2" Grid.Row="2" Grid.Column="3" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBox x:Name="TVoc_2" Grid.Row="2" Grid.Column="4" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="2단계(나쁨)" VerticalAlignment="Center" Style="{StaticResource FieldValue}" FontWeight="Bold" Foreground="#CA8A04"/>
<TextBox x:Name="TCo2_3" Grid.Row="3" Grid.Column="1" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBox x:Name="TPm25_3" Grid.Row="3" Grid.Column="2" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBox x:Name="TPm10_3" Grid.Row="3" Grid.Column="3" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBox x:Name="TVoc_3" Grid.Row="3" Grid.Column="4" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="3단계(매우나쁨)" VerticalAlignment="Center" Style="{StaticResource FieldValue}" FontWeight="Bold" Foreground="{StaticResource Bad}"/>
<TextBox x:Name="TCo2_4" Grid.Row="4" Grid.Column="1" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBox x:Name="TPm25_4" Grid.Row="4" Grid.Column="2" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBox x:Name="TPm10_4" Grid.Row="4" Grid.Column="3" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBox x:Name="TVoc_4" Grid.Row="4" Grid.Column="4" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="4단계(최악)" VerticalAlignment="Center" Style="{StaticResource FieldValue}" FontWeight="Bold" Foreground="{StaticResource Bad}"/>
<Border Grid.Row="5" Grid.Column="1" Margin="3,2" Background="{StaticResource CardBg}" BorderBrush="{StaticResource CardBorder}" BorderThickness="1">
<TextBlock x:Name="MCo2" TextAlignment="Right" Padding="4,3" Foreground="{StaticResource TextPrimary}"/>
</Border>
<Border Grid.Row="5" Grid.Column="2" Margin="3,2" Background="{StaticResource CardBg}" BorderBrush="{StaticResource CardBorder}" BorderThickness="1">
<TextBlock x:Name="MPm25" TextAlignment="Right" Padding="4,3" Foreground="{StaticResource TextPrimary}"/>
</Border>
<Border Grid.Row="5" Grid.Column="3" Margin="3,2" Background="{StaticResource CardBg}" BorderBrush="{StaticResource CardBorder}" BorderThickness="1">
<TextBlock x:Name="MPm10" TextAlignment="Right" Padding="4,3" Foreground="{StaticResource TextPrimary}"/>
</Border>
<Border Grid.Row="5" Grid.Column="4" Margin="3,2" Background="{StaticResource CardBg}" BorderBrush="{StaticResource CardBorder}" BorderThickness="1">
<TextBlock x:Name="MVoc" TextAlignment="Right" Padding="4,3" Foreground="{StaticResource TextPrimary}"/>
</Border>
<TextBlock Grid.Row="6" Grid.Column="0" Text="히스(하강)" VerticalAlignment="Center" Style="{StaticResource FieldValue}" FontWeight="Bold"/>
<TextBox x:Name="DCo2" Grid.Row="6" Grid.Column="1" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBox x:Name="DPm25" Grid.Row="6" Grid.Column="2" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBox x:Name="DPm10" Grid.Row="6" Grid.Column="3" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
<TextBox x:Name="DVoc" Grid.Row="6" Grid.Column="4" Margin="3,2" Padding="4,3" TextAlignment="Right"/>
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,14,0,0">
<Button Content="변경" Width="90" Style="{StaticResource FlatButton}" Click="Apply_Click"/>
<Button Content="닫기" Width="90" Style="{StaticResource FlatButton}" Click="Close_Click"/>
</StackPanel>
</StackPanel>
</Border>
</Window>
@@ -0,0 +1,97 @@
using System;
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using ErvDashboard.Model;
using ErvProtocol;
namespace ErvDashboard
{
// 공기질 센서 히스테리시스 팝업 : 활성 프리셋의 오염단계 임계(0~3단계 상한, 4단계 최악) + 데드밴드 표시·수정
public partial class HystWindow : Window
{
readonly MainWindow _owner;
readonly DashboardState _state;
readonly List<Button> _presetButtons = new();
static Brush Br(string key) => (Brush)Application.Current.Resources[key];
public HystWindow(MainWindow owner, DashboardState state)
{
InitializeComponent();
_owner = owner; _state = state;
foreach (var (label, preset) in new[] { ("ECO", HystPreset.Eco), ("NORMAL", HystPreset.Normal), ("TURBO", HystPreset.Turbo) })
{
var b = new Button { Content = label, Tag = preset, Width = 96, Style = (Style)FindResource("FlatButton") };
b.Click += Preset_Click;
_presetButtons.Add(b);
PresetPanel.Children.Add(b);
}
_state.PropertyChanged += OnStateChanged;
RefreshPreset();
}
void OnStateChanged(object? s, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(DashboardState.HystPreset))
Dispatcher.BeginInvoke(RefreshPreset);
}
void RefreshPreset()
{
foreach (var b in _presetButtons)
{
bool active = (HystPreset)b.Tag! == _state.HystPreset;
b.Background = active ? Br("Accent") : Br("CardBg");
b.Foreground = active ? Brushes.White : Br("TextPrimary");
b.BorderBrush = active ? Br("Accent") : Br("CardBorder");
}
FillGrid((int)_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();
var h = _state.HystTable[p];
DCo2.Text = h.Co2.ToString(); DPm25.Text = h.Pm25.ToString(); DPm10.Text = h.Pm10.ToString(); DVoc.Text = h.Voc.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 int P(TextBox tb) { int.TryParse(tb.Text, out int v); return v < 0 ? 0 : v > 65535 ? 65535 : v; }
void Preset_Click(object sender, RoutedEventArgs e)
{
if (sender is Button b && b.Tag is HystPreset p) _owner.SelectPreset(p);
}
void Apply_Click(object sender, RoutedEventArgs e)
{
int p = (int)_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);
var h = _state.HystTable[p];
h.Co2 = P(DCo2); h.Pm25 = P(DPm25); h.Pm10 = P(DPm10); h.Voc = P(DVoc);
_owner.ApplyHystPreset(p);
}
void Close_Click(object sender, RoutedEventArgs e) => Close();
protected override void OnClosed(System.EventArgs e)
{
_state.PropertyChanged -= OnStateChanged;
base.OnClosed(e);
}
}
}
+355
View File
@@ -0,0 +1,355 @@
<Window x:Class="ErvDashboard.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ErvDashboard"
xmlns:sys="clr-namespace:System;assembly=mscorlib"
Title="HuevenEco DL 각실제어시스템 대시보드"
Height="1000" Width="1600"
MinHeight="680" MinWidth="1080" MaxHeight="1200" MaxWidth="1920"
Background="{StaticResource AppBg}"
FontFamily="Segoe UI, Malgun Gothic">
<Window.Resources>
<local:AirQualityToBrushConverter x:Key="AqBrush"/>
<local:LevelToBrushConverter x:Key="LevelBrush"/>
<local:BoolToBrushConverter x:Key="OnOffBrush"/>
<local:BoolToOnOffConverter x:Key="OnOffText"/>
</Window.Resources>
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <!-- 헤더 + 통신 -->
<RowDefinition Height="*"/> <!-- 본문 -->
</Grid.RowDefinitions>
<!-- ============ 헤더 / 통신 ============ -->
<Border Grid.Row="0" Style="{StaticResource Card}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<StackPanel VerticalAlignment="Center">
<TextBlock Text="HuevenEco DL 각실제어시스템"
FontSize="20" FontWeight="Bold" Foreground="{StaticResource TextPrimary}"/>
<TextBlock Text="각실제어 대시보드 · RS-485 115200 N81" FontSize="12"
Foreground="{StaticResource TextSecondary}"/>
</StackPanel>
<Border Width="1" Background="{StaticResource CardBorder}" Margin="14,4"/>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="만든이 : 전경선" FontSize="12" Foreground="{StaticResource TextSecondary}"/>
<TextBlock Text="만든날 : 2026.06.3" FontSize="12" Foreground="{StaticResource TextSecondary}"/>
</StackPanel>
</StackPanel>
<!-- 통신 제어 -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Ellipse x:Name="ConnLed" Width="12" Height="12" Fill="{StaticResource Bad}"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock x:Name="ConnText" Text="미연결" Style="{StaticResource FieldLabel}" Margin="0,0,12,0"/>
<TextBlock Text="포트" Style="{StaticResource FieldLabel}" Margin="0,0,6,0"/>
<ComboBox x:Name="PortCombo" Width="110" Margin="0,0,6,0"/>
<Button Content="↻" Style="{StaticResource FlatButton}" Click="RefreshPorts_Click" Padding="10,7"/>
<Button x:Name="ConnectBtn" Content="연결" Style="{StaticResource FlatButton}" Click="Connect_Click"/>
<Button x:Name="DisconnectBtn" Content="연결해제" Style="{StaticResource FlatButton}" Click="Disconnect_Click" IsEnabled="False"/>
<Border Width="1" Background="{StaticResource CardBorder}" Margin="6,2"/>
<Button x:Name="StartBtn" Content="통신시작" Style="{StaticResource FlatButton}" Click="StartComm_Click" IsEnabled="False"/>
<Button x:Name="StopBtn" Content="통신중지" Style="{StaticResource FlatButton}" Click="StopComm_Click" IsEnabled="False"/>
</StackPanel>
</Grid>
</Border>
<!-- ============ 본문 ============ -->
<Grid Grid.Row="1" Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="3*"/>
<ColumnDefinition Width="1*"/>
</Grid.ColumnDefinitions>
<!-- 좌측: 제어 + 각실 -->
<ScrollViewer Grid.Column="0" VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- ERV 제어·상태 -->
<Border Style="{StaticResource Card}">
<StackPanel>
<TextBlock Text="ERV 제어 · 상태" Style="{StaticResource CardTitle}"/>
<!-- 전원 + 풍량 -->
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="전원" Style="{StaticResource FieldLabel}" Margin="0,0,8,0"/>
<Button x:Name="PowerBtn" Width="86" Content="OFF"
Style="{StaticResource FlatButton}" Click="Power_Click"/>
<TextBlock Text="ERV 리셋" Style="{StaticResource FieldLabel}" Margin="14,0,8,0"/>
<Button x:Name="ResetBtn" Width="86" Content="OFF"
Style="{StaticResource FlatButton}" Click="Reset_Click"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<TextBlock Text="풍량" Style="{StaticResource FieldLabel}" Margin="0,0,8,0"/>
<!-- 풍량 0~4단. Tag=int(풍량 단수), Click=Fan_Click. 강조/활성은 RefreshControls 가 처리 -->
<StackPanel x:Name="FanPanel" Orientation="Horizontal">
<Button x:Name="Fan0" Content="0" Width="40" Style="{StaticResource FlatButton}" Click="Fan_Click">
<Button.Tag><sys:Int32>0</sys:Int32></Button.Tag>
</Button>
<Button x:Name="Fan1" Content="1" Width="40" Style="{StaticResource FlatButton}" Click="Fan_Click">
<Button.Tag><sys:Int32>1</sys:Int32></Button.Tag>
</Button>
<Button x:Name="Fan2" Content="2" Width="40" Style="{StaticResource FlatButton}" Click="Fan_Click">
<Button.Tag><sys:Int32>2</sys:Int32></Button.Tag>
</Button>
<Button x:Name="Fan3" Content="3" Width="40" Style="{StaticResource FlatButton}" Click="Fan_Click">
<Button.Tag><sys:Int32>3</sys:Int32></Button.Tag>
</Button>
<Button x:Name="Fan4" Content="4" Width="40" Style="{StaticResource FlatButton}" Click="Fan_Click">
<Button.Tag><sys:Int32>4</sys:Int32></Button.Tag>
</Button>
</StackPanel>
</StackPanel>
</Grid>
<!-- 운전모드. Tag=string(ModeDefs.tag), Click=Mode_Click. 순서는 코드의 ModeDefs 와 동일 -->
<TextBlock Text="운전모드" Style="{StaticResource FieldLabel}" Margin="0,0,0,4"/>
<StackPanel x:Name="ModePanel" Orientation="Horizontal" Margin="0,0,0,12">
<Button x:Name="ModeVent" Content="환기" Tag="Vent" Width="86" Style="{StaticResource FlatButton}" Click="Mode_Click"/>
<Button x:Name="ModeAuto" Content="자동" Tag="Auto" Width="86" Style="{StaticResource FlatButton}" Click="Mode_Click"/>
<Button x:Name="ModeAir" Content="공청" Tag="AirClean" Width="86" Style="{StaticResource FlatButton}" Click="Mode_Click"/>
<Button x:Name="ModeBypass" Content="바이패스" Tag="Bypass" Width="86" Style="{StaticResource FlatButton}" Click="Mode_Click"/>
<!-- (꺼짐)예약 0~8시간 : N시간 뒤 ERV 전원 OFF -->
<Border Width="1" Background="{StaticResource CardBorder}" Margin="14,2,14,2"/>
<TextBlock Text="(꺼짐)예약" Style="{StaticResource FieldLabel}" VerticalAlignment="Center" Margin="0,0,6,0"/>
<ComboBox x:Name="ReserveCombo" Width="78" VerticalAlignment="Center" SelectedIndex="0"
SelectionChanged="Reserve_Changed">
<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="{Binding ReserveText}" Style="{StaticResource FieldValue}"
VerticalAlignment="Center" Margin="10,0,0,0"/>
<!-- 설정 : 공기질 센서 히스테리시스 / 풍량 VSP -->
<Border Width="1" Background="{StaticResource CardBorder}" Margin="14,2,14,2"/>
<TextBlock Text="설정" Style="{StaticResource FieldLabel}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<Button Content="공기질 센서 히스테리시스 ▸" Style="{StaticResource FlatButton}" Click="OpenHyst_Click"/>
<Button Content="풍량 VSP ▸" Style="{StaticResource FlatButton}" Click="OpenVsp_Click" Margin="6,0,0,0"/>
</StackPanel>
<!-- 자동모드 프리셋 : 자동 선택 시에만 활성. 공기질 판정 임계(=히스테리시스 임계)를
선택 프리셋으로 전환. 기본값 표준(NORMAL). Tag=HystPreset 이름, Click=Preset_Click -->
<StackPanel x:Name="PresetPanel" Orientation="Horizontal" Margin="0,0,0,12">
<Button x:Name="PresetEco" Content="절전 (ECO)" Tag="Eco" Width="116" Style="{StaticResource FlatButton}" Click="Preset_Click"/>
<Button x:Name="PresetNormal" Content="표준 (NORMAL)" Tag="Normal" Width="116" Style="{StaticResource FlatButton}" Click="Preset_Click"/>
<Button x:Name="PresetTurbo" Content="쾌속 (TURBO)" Tag="Turbo" Width="116" Style="{StaticResource FlatButton}" Click="Preset_Click"/>
</StackPanel>
<!-- 시나리오모드 + 후드 연동 + 설정 (한 줄 배치) -->
<TextBlock Text="시나리오모드" Style="{StaticResource FieldLabel}" Margin="0,0,0,4"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<Button x:Name="SmartSleepBtn" Content="스마트수면" Style="{StaticResource FlatButton}" Click="SubMode_Click" Tag="SmartSleep"/>
<Button x:Name="ComfortCookBtn" Content="쾌적조리" Style="{StaticResource FlatButton}" Click="SubMode_Click" Tag="ComfortCook"/>
<Button x:Name="ReliefRecoverBtn" Content="안심회복" Style="{StaticResource FlatButton}" Click="SubMode_Click" Tag="ReliefRecover"/>
<!-- 후드 연동 -->
<Border Width="1" Background="{StaticResource CardBorder}" Margin="14,2,14,2"/>
<TextBlock Text="후드 연동" Style="{StaticResource FieldLabel}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<Button x:Name="HoodBtn" Width="86" Content="OFF" Style="{StaticResource FlatButton}" Click="Hood_Click"/>
<!-- 후드 연동 ON 시 통신연결 상태 : 패킷 수신중 '후드 연결' / 없으면 '후드 연결 안됨' -->
<TextBlock x:Name="HoodConnText" Style="{StaticResource FieldValue}" FontSize="13"
VerticalAlignment="Center" Margin="8,0,0,0"/>
<!-- 스마트수면 시간설정 : 스마트수면 ON 일 때만 활성. 종료 시각 도달 시 자동 해제+이전모드 복귀 -->
<Border Width="1" Background="{StaticResource CardBorder}" Margin="14,2,14,2"/>
<Button x:Name="SmartSleepSetBtn" Content="스마트수면 시간설정 ▸" Style="{StaticResource FlatButton}"
Click="OpenSmartSleep_Click" IsEnabled="False"/>
</StackPanel>
</StackPanel>
</Border>
<!-- 자동운전 상태 -->
<Border Style="{StaticResource Card}">
<StackPanel>
<TextBlock Text="자동운전 상태 (표시 전용)" Style="{StaticResource CardTitle}"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="동작:" Style="{StaticResource FieldLabel}" Margin="0,0,6,0"/>
<Border Style="{StaticResource ReadOnlyBadge}">
<TextBlock Text="{Binding AutoStateText}" Style="{StaticResource FieldValue}" FontSize="13"/>
</Border>
<TextBlock Text="(분산 / 집중)" Style="{StaticResource FieldLabel}" Margin="8,0,0,0"/>
<TextBlock Text="합산부하점수:" Style="{StaticResource FieldLabel}" Margin="18,0,6,0"/>
<Border Style="{StaticResource ReadOnlyBadge}">
<TextBlock Text="{Binding TotalLoadScoreText}" Style="{StaticResource FieldValue}" FontSize="13"/>
</Border>
</StackPanel>
<TextBlock Text="각실 부하점수 / 최종 풍량" Style="{StaticResource FieldLabel}" Margin="0,0,0,6"/>
<ItemsControl ItemsSource="{Binding Rooms}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="4"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{StaticResource Track}" CornerRadius="8" Margin="3" Padding="10,8">
<StackPanel>
<TextBlock Text="{Binding Name}" Style="{StaticResource FieldLabel}" HorizontalAlignment="Center"/>
<TextBlock HorizontalAlignment="Center" Style="{StaticResource FieldValue}" FontSize="22"
Text="{Binding LoadScore}"/>
<TextBlock HorizontalAlignment="Center" Style="{StaticResource FieldLabel}"
Text="{Binding FinalVolume, StringFormat=풍량 {0}}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- 각실 제어·상태 -->
<Border Style="{StaticResource Card}">
<StackPanel>
<TextBlock Text="각실 제어 · 상태" Style="{StaticResource CardTitle}"/>
<ItemsControl x:Name="RoomItems">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="4"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{StaticResource CardBg}" BorderBrush="{StaticResource CardBorder}"
BorderThickness="1" CornerRadius="10" Margin="3" Padding="10">
<StackPanel>
<!-- 헤더: 이름 + 공기질 LED -->
<Grid>
<TextBlock Text="{Binding Name}" FontSize="16" FontWeight="Bold"
Foreground="{StaticResource TextPrimary}" VerticalAlignment="Center"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<Ellipse Width="14" Height="14" Margin="0,0,6,0"
Fill="{Binding LoadScore, Converter={StaticResource LevelBrush}}"/>
<TextBlock Text="{Binding AirQualityText}" Style="{StaticResource FieldValue}" FontSize="13"/>
</StackPanel>
</Grid>
<!-- 댐퍼 토글 (급기/배기 분리, 2줄 배치) -->
<StackPanel Margin="0,10,0,6">
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="급기댐퍼" Style="{StaticResource FieldLabel}" Width="60" VerticalAlignment="Center"/>
<Button Width="76" Tag="{Binding RoomId}" Click="DamperSa_Click"
Style="{StaticResource FlatButton}"
IsEnabled="{Binding DataContext.CanRoomControl, RelativeSource={RelativeSource AncestorType=Window}}"
Content="{Binding DamperSaOpen, Converter={StaticResource OnOffText}}"
Background="{Binding DamperSaOpen, Converter={StaticResource OnOffBrush}}"/>
</StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="배기댐퍼" Style="{StaticResource FieldLabel}" Width="60" VerticalAlignment="Center"/>
<Button Width="76" Tag="{Binding RoomId}" Click="DamperEa_Click"
Style="{StaticResource FlatButton}"
IsEnabled="{Binding DataContext.CanRoomControl, RelativeSource={RelativeSource AncestorType=Window}}"
Content="{Binding DamperEaOpen, Converter={StaticResource OnOffText}}"
Background="{Binding DamperEaOpen, Converter={StaticResource OnOffBrush}}"/>
</StackPanel>
</StackPanel>
<!-- 센서값 -->
<UniformGrid Columns="2" Margin="0,0,0,4">
<StackPanel Orientation="Horizontal" Margin="0,2">
<TextBlock Text="PM2.5" Style="{StaticResource FieldLabel}" Width="54"/>
<TextBlock Text="{Binding Pm25}" Style="{StaticResource FieldValue}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,2">
<TextBlock Text="PM10" Style="{StaticResource FieldLabel}" Width="54"/>
<TextBlock Text="{Binding Pm10}" Style="{StaticResource FieldValue}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,2">
<TextBlock Text="VOC" Style="{StaticResource FieldLabel}" Width="54"/>
<TextBlock Text="{Binding Voc}" Style="{StaticResource FieldValue}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,2">
<TextBlock Text="CO2" Style="{StaticResource FieldLabel}" Width="54"/>
<TextBlock Text="{Binding Co2}" Style="{StaticResource FieldValue}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,2">
<TextBlock Text="TEMP" Style="{StaticResource FieldLabel}" Width="54"/>
<TextBlock Text="{Binding Temp, StringFormat={}{0}℃}" Style="{StaticResource FieldValue}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,2">
<TextBlock Text="HUMI." Style="{StaticResource FieldLabel}" Width="54"/>
<TextBlock Text="{Binding Humi, StringFormat={}{0}%}" Style="{StaticResource FieldValue}"/>
</StackPanel>
</UniformGrid>
<!-- LED 디밍 슬라이더 -->
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
<TextBlock Text="LED" Style="{StaticResource FieldLabel}" Width="44" VerticalAlignment="Center"/>
<!-- LED 디밍은 모든 운전모드·시나리오모드에서 변경 가능 (CanRoomControl 게이트 미적용).
드래그/트랙클릭/키보드 모두 전송 — DragStarted+DragCompleted+ValueChanged 조합 -->
<Slider Width="120" Minimum="0" Maximum="9" TickFrequency="1" IsSnapToTickEnabled="True"
IsMoveToPointEnabled="True"
VerticalAlignment="Center" Tag="{Binding RoomId}"
Value="{Binding LedDim, Mode=TwoWay}"
Thumb.DragStarted="Led_DragStarted"
Thumb.DragCompleted="Led_DragCompleted"
ValueChanged="Led_ValueChanged"/>
<TextBlock Text="{Binding LedDim}" Style="{StaticResource FieldValue}" Width="24"
TextAlignment="Center" Margin="6,0,0,0"/>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<!-- 우측: 로그 -->
<Border Grid.Column="1" Style="{StaticResource Card}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<TextBlock Text="로그 데이터" Style="{StaticResource CardTitle}" VerticalAlignment="Center"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<CheckBox x:Name="AutoScrollChk" Content="자동스크롤" IsChecked="True"
Foreground="{StaticResource TextSecondary}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<Button Content="📈 그래프" Style="{StaticResource FlatButton}" Click="OpenGraph_Click" Padding="10,5" Margin="0,0,6,0"/>
<Button Content="저장" Style="{StaticResource FlatButton}" Click="SaveLog_Click" Padding="10,5"/>
<Button Content="지움" Style="{StaticResource FlatButton}" Click="ClearLog_Click" Padding="10,5"/>
</StackPanel>
</Grid>
<Border Grid.Row="1" Background="{StaticResource Track}" CornerRadius="8" Margin="0,4">
<!-- 읽기전용 TextBox : 텍스트 드래그 선택 / Ctrl+C 복사 가능 (ERV 시뮬레이터 로그와 동일) -->
<TextBox x:Name="LogList" IsReadOnly="True" IsReadOnlyCaretVisible="False"
Background="Transparent" BorderThickness="0" Padding="6"
FontFamily="Consolas, D2Coding" FontSize="12"
Foreground="{StaticResource TextPrimary}"
TextWrapping="NoWrap"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"/>
</Border>
<TextBlock x:Name="HexLine" Grid.Row="2" Style="{StaticResource FieldLabel}"
TextWrapping="Wrap" Margin="0,4,0,0"/>
</Grid>
</Border>
</Grid>
</Grid>
</Window>
+741
View File
@@ -0,0 +1,741 @@
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단
// 시나리오모드 활성 시: 운전모드·풍량·선택 안 된 시나리오모드 비활성화
bool subActive = _state.SmartSleep || _state.ComfortCook || _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);
SetActive(ComfortCookBtn, _state.ComfortCook);
SetActive(ReliefRecoverBtn, _state.ReliefRecover);
// (활성 모드 버튼은 OFF 토글 가능해야 하므로 자기 자신은 유지)
SmartSleepBtn.IsEnabled = !subActive || _state.SmartSleep;
ComfortCookBtn.IsEnabled = !subActive || _state.ComfortCook;
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;
}
}
@@ -0,0 +1,161 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using ErvProtocol;
namespace ErvDashboard.Model
{
// 대시보드 전체 상태 (STATUS 0x81 수신값 + 사용자 제어 의도)
public class DashboardState : INotifyPropertyChanged
{
// ---- ERV 제어/상태 ----
bool _powerOn;
RunMode _runMode = RunMode.Off;
byte _fanMode;
bool _hood, _hoodConnected;
bool _smartSleep, _comfortCook, _reliefRecover;
public bool PowerOn { get => _powerOn; set { if (_powerOn != value) { _powerOn = value; OnChanged(); } } }
public RunMode RunMode { get => _runMode; set { if (_runMode != value) { _runMode = value; OnChanged(); OnChanged(nameof(IsAuto)); OnChanged(nameof(CanRoomControl)); } } }
public bool IsAuto => RunMode == RunMode.Auto;
// 각실 댐퍼/LED 수동 제어 가능 여부 (환기/공청/바이패스에서만, 자동 제외)
public bool CanRoomControl => RunMode != RunMode.Auto;
public byte FanMode { get => _fanMode; set { if (_fanMode != value) { _fanMode = value; OnChanged(); } } }
public bool Hood { get => _hood; set { if (_hood != value) { _hood = value; OnChanged(); } } }
// 후드 485 통신연결 여부 (STATUS byte5 bit2). 후드연동 ON일 때 연결/미연결 텍스트 표시용
public bool HoodConnected { get => _hoodConnected; set { if (_hoodConnected != value) { _hoodConnected = value; OnChanged(); } } }
// ---- (꺼짐)예약 : 잔여초(STATUS 수신) ----
int _reserveRemainSec;
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}"
: "예약 없음";
public bool SmartSleep { get => _smartSleep; set { if (_smartSleep != value) { _smartSleep = value; OnChanged(); } } }
// 스마트수면 시간설정(대시보드 전용 — 종료 시각에 자동 해제). 자정 기준 분(0~1439).
int _sleepStartMin = 0; // 오전 12:00
int _sleepEndMin = 7 * 60 + 30; // 오전 7:30
public int SleepStartMin { get => _sleepStartMin; set { if (_sleepStartMin != value) { _sleepStartMin = value; OnChanged(); OnChanged(nameof(SleepSummary)); } } }
public int SleepEndMin { get => _sleepEndMin; set { if (_sleepEndMin != value) { _sleepEndMin = value; OnChanged(); OnChanged(nameof(SleepSummary)); } } }
public string SleepSummary => $"{DashboardState.FmtTime(SleepStartMin)} ~ {DashboardState.FmtTime(SleepEndMin)}";
public static string FmtTime(int min)
{
min = ((min % 1440) + 1440) % 1440;
int h = min / 60, m = min % 60, h12 = h % 12; if (h12 == 0) h12 = 12;
return $"{(h < 12 ? "" : "")} {h12}:{m:00}";
}
public bool ComfortCook { get => _comfortCook; set { if (_comfortCook != value) { _comfortCook = value; OnChanged(); } } }
public bool ReliefRecover { get => _reliefRecover; set { if (_reliefRecover != value) { _reliefRecover = value; OnChanged(); } } }
public byte SubModeBitmap
{
get
{
byte b = 0;
if (SmartSleep) b |= SubModeBits.SmartSleep;
if (ComfortCook) b |= SubModeBits.ComfortCook;
if (ReliefRecover) b |= SubModeBits.ReliefRecover;
return b;
}
set
{
SmartSleep = (value & SubModeBits.SmartSleep) != 0;
ComfortCook = (value & SubModeBits.ComfortCook) != 0;
ReliefRecover = (value & SubModeBits.ReliefRecover) != 0;
}
}
// ---- 자동운전 상태 (읽기전용) ----
AutoState _autoState = AutoState.Distribute;
public AutoState AutoState
{
get => _autoState;
set { if (_autoState != value) { _autoState = value; OnChanged(); OnChanged(nameof(AutoStateText)); } }
}
public string AutoStateText => AutoState == AutoState.Focus ? "집중" : "분산";
// 합산 부하점수 (4실 Level 합, 0~16) — STATUS 수신 시 StatusMapper 가 갱신
int _totalLoadScore;
public int TotalLoadScore
{
get => _totalLoadScore;
set { if (_totalLoadScore != value) { _totalLoadScore = value; OnChanged(); OnChanged(nameof(TotalLoadScoreText)); } }
}
public string TotalLoadScoreText => $"{TotalLoadScore} / 16";
// ---- 히스테리시스 ----
HystPreset _hystPreset = HystPreset.Normal;
int _hystPm25, _hystPm10, _hystVoc, _hystCo2;
public HystPreset HystPreset { get => _hystPreset; set { if (_hystPreset != value) { _hystPreset = value; OnChanged(); } } }
public int HystPm25 { get => _hystPm25; set { if (_hystPm25 != value) { _hystPm25 = value; OnChanged(); } } }
public int HystPm10 { get => _hystPm10; set { if (_hystPm10 != value) { _hystPm10 = value; OnChanged(); } } }
public int HystVoc { get => _hystVoc; set { if (_hystVoc != value) { _hystVoc = value; OnChanged(); } } }
public int HystCo2 { get => _hystCo2; set { if (_hystCo2 != value) { _hystCo2 = value; OnChanged(); } } }
// ---- 에러코드 ----
int _errorCode;
public int ErrorCode { get => _errorCode; set { if (_errorCode != value) { _errorCode = value; OnChanged(); OnChanged(nameof(ErrorCodeHex)); } } }
public string ErrorCodeHex => $"0x{ErrorCode:X4}";
// ---- ERV 리셋 (토글) ----
bool _reset;
public bool Reset { get => _reset; set { if (_reset != value) { _reset = value; OnChanged(); } } }
// ---- 풍량 VSP (9엔트리) ----
public ObservableCollection<VspRow> Vsp { get; }
// ---- 히스테리시스 데드밴드 테이블 (ECO/NORMAL/TURBO 별 PM2.5/PM10/VOC/CO2) ----
public ObservableCollection<HystRow> HystTable { get; }
// ---- 모드별 오염단계 임계표 [preset 0 ECO/1 NORMAL/2 TURBO][L1~L4 상한] ----
public int[][] Co2Thr { get; } = { new int[4], new int[4], new int[4] };
public int[][] Pm25Thr { get; } = { new int[4], new int[4], new int[4] };
public int[][] Pm10Thr { get; } = { new int[4], new int[4], new int[4] };
public int[][] VocThr { get; } = { new int[4], new int[4], new int[4] };
// ---- 각실 ----
public ObservableCollection<RoomState> Rooms { get; }
public DashboardState()
{
Rooms = new ObservableCollection<RoomState>
{
new(1, "거실"),
new(2, "침실1"),
new(3, "침실2"),
new(4, "침실3"),
};
// 히스테리시스 기본값 (NORMAL 가정, 펌웨어 m_*_Level 기준)
HystPm25 = 30; HystPm10 = 50; HystVoc = 300; HystCo2 = 700;
// 풍량 VSP 9엔트리 (환기1~4, 바이패스, 공청1~4)
Vsp = new ObservableCollection<VspRow>();
for (int i = 0; i < VspInfo.Count; i++)
Vsp.Add(new VspRow(VspInfo.Labels[i], VspInfo.Group[i], VspInfo.Index[i]));
// 히스테리시스 데드밴드(하강) 기본값 - 사양서
HystTable = new ObservableCollection<HystRow>
{
new("ECO", 0) { Pm25 = 2, Pm10 = 5, Voc = 5, Co2 = 50 },
new("NORMAL", 1) { Pm25 = 2, Pm10 = 5, Voc = 5, Co2 = 50 },
new("TURBO", 2) { Pm25 = 2, Pm10 = 5, Voc = 3, Co2 = 30 },
};
// 모드별 오염단계 임계 기본값 - 사양서 (CO2/PM2.5/PM10/VOC 의 L1~L4 상한)
int[][] co2 = { new[]{1000,1300,1600,2000}, new[]{800,1100,1400,1700}, new[]{700,1000,1300,1600} };
int[][] pm25 = { new[]{20,38,60,86}, new[]{14,29,49,69}, new[]{12,23,38,52} };
int[][] pm10 = { new[]{40,86,126,173}, new[]{28,66,102,138}, new[]{24,53,78,104} };
int[][] voc = { new[]{171,195,308,438}, new[]{120,150,250,350}, new[]{103,120,192,263} };
for (int i = 0; i < 3; i++) for (int k = 0; k < 4; k++)
{ Co2Thr[i][k] = co2[i][k]; Pm25Thr[i][k] = pm25[i][k]; Pm10Thr[i][k] = pm10[i][k]; VocThr[i][k] = voc[i][k]; }
}
public RoomState Room(int id) => Rooms[id - 1];
public event PropertyChangedEventHandler? PropertyChanged;
void OnChanged([CallerMemberName] string? n = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
}
}
+24
View File
@@ -0,0 +1,24 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace ErvDashboard.Model
{
// 히스테리시스 한 프리셋(ECO/NORMAL/TURBO)의 임계값 — 편집 가능
public class HystRow : INotifyPropertyChanged
{
public string Name { get; }
public int Preset { get; } // 0 ECO / 1 NORMAL / 2 TURBO (CTRL_HYST_VALUE)
public HystRow(string name, int preset) { Name = name; Preset = preset; }
int _pm25, _pm10, _voc, _co2;
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 Co2 { get => _co2; set { if (_co2 != value) { _co2 = value; OnChanged(); } } }
public event PropertyChangedEventHandler? PropertyChanged;
void OnChanged([CallerMemberName] string? n = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
}
}
@@ -0,0 +1,23 @@
using System;
namespace ErvDashboard.Model
{
// 그래프/DB용 시계열 1샘플 (5초 간격 기록 → SQLite 저장).
public sealed class LogSample
{
public DateTime Time;
public bool Power;
public byte RunMode; // 0 환기 / 1 자동 / 2 바이패스 / 3 공청 (RunMode enum)
public byte AutoMode; // 자동운전 세부 : 0 비자동 / 1 자동-집중 / 2 자동-분산
public byte HystPreset; // 공기질 프리셋 : 0 ECO / 1 NORMAL / 2 TURBO
public byte FanMode; // 0~4
public bool SmartSleep, ComfortCook, ReliefRecover;
public RoomSample[] Rooms = Array.Empty<RoomSample>();
}
public struct RoomSample
{
public bool DamperSa, DamperRa; // 급기/배기 댐퍼 열림
public int Co2, Pm25, Pm10, Voc, Temp, Humi, Led, Level;
}
}
@@ -0,0 +1,80 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using ErvProtocol;
namespace ErvDashboard.Model
{
// 각실(거실/침실1~3) 상태 + 제어값
public class RoomState : INotifyPropertyChanged
{
public int RoomId { get; } // 1=거실, 2~4=침실1~3
public string Name { get; }
public RoomState(int id, string name) { RoomId = id; Name = name; }
bool _damperSaOpen, _damperEaOpen;
int _pm25, _pm10, _voc, _co2, _temp, _humi;
AirQuality _airQuality = AirQuality.Normal;
int _ledDim;
int _loadScore;
int _finalVolume;
// 급기(SA) 댐퍼 열림/닫힘 (토글, 제어 가능)
public bool DamperSaOpen
{
get => _damperSaOpen;
set { if (_damperSaOpen != value) { _damperSaOpen = value; OnChanged(); } }
}
// 배기(EA) 댐퍼 열림/닫힘 (토글, 제어 가능)
public bool DamperEaOpen
{
get => _damperEaOpen;
set { if (_damperEaOpen != value) { _damperEaOpen = 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 Co2 { get => _co2; set { if (_co2 != value) { _co2 = 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(); } } }
// 공기질 상태코드(1~4, 프로토콜) — L3/L4 가 모두 매우나쁨(1)으로 합쳐지므로 표시는 LoadScore(Level) 사용
public AirQuality AirQuality
{
get => _airQuality;
set { if (_airQuality != value) { _airQuality = value; OnChanged(); } }
}
// 공기질 표시(좋음/보통/나쁨/매우나쁨/최악)는 각실 Level(=LoadScore 0~4) 기준 — L4(최악, 빨강)까지 구분
public string AirQualityText => LoadScore switch
{
0 => "좋음",
1 => "보통",
2 => "나쁨",
3 => "매우나쁨",
4 => "최악",
_ => "-",
};
// LED 디밍 0~9 (슬라이드, 제어 가능)
public int LedDim
{
get => _ledDim;
set { var v = value < 0 ? 0 : value > 9 ? 9 : value; if (_ledDim != v) { _ledDim = v; OnChanged(); } }
}
// 자동운전 - 각실 부하점수(=Level 0~4, 읽기전용). 변경 시 공기질 표시도 갱신.
public int LoadScore { get => _loadScore; set { if (_loadScore != value) { _loadScore = value; OnChanged(); OnChanged(nameof(AirQualityText)); } } }
// 자동운전 - 최종 풍량 (읽기전용)
public int FinalVolume { get => _finalVolume; set { if (_finalVolume != value) { _finalVolume = value; OnChanged(); } } }
public event PropertyChangedEventHandler? PropertyChanged;
void OnChanged([CallerMemberName] string? n = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
}
}
+23
View File
@@ -0,0 +1,23 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace ErvDashboard.Model
{
// 풍량 VSP 한 엔트리 (환기1~4 / 바이패스 / 공청1~4) — SA/EA 편집 가능
public class VspRow : INotifyPropertyChanged
{
public string Name { get; }
public int Group { get; } // 0환기 1바이패스 2공청 (CTRL_VSP)
public int Index { get; } // 환기/공청 1~4, 바이패스 1
public VspRow(string name, int group, int index) { Name = name; Group = group; Index = index; }
int _sa, _ea;
public int Sa { get => _sa; set { if (_sa != value) { _sa = value; OnChanged(); } } }
public int Ea { get => _ea; set { if (_ea != value) { _ea = value; OnChanged(); } } }
public event PropertyChangedEventHandler? PropertyChanged;
void OnChanged([CallerMemberName] string? n = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
}
}
@@ -0,0 +1,111 @@
using System.IO.Ports;
namespace ErvDashboard.Protocol
{
// 공용 시리얼 채널 - byte 단위 수신 콜백 + 송신 helper
// (ERVSimulator/Protocol/SerialChannel.cs 와 동일 구조)
public class SerialChannel : IDisposable
{
private SerialPort? _port;
private CancellationTokenSource? _cts;
private bool _disposed;
public event Action<byte>? ByteReceived;
public event Action<string>? Log;
public event Action<bool>? ConnectionChanged;
public bool IsConnected => _port?.IsOpen == true;
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($"Connected {portName} @ {baudRate} N81");
ConnectionChanged?.Invoke(true);
return true;
}
catch (Exception ex)
{
Log?.Invoke($"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[128];
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($"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($"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,77 @@
<Window x:Class="ErvDashboard.SmartSleepWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="스마트수면 설정" Width="360" SizeToContent="Height"
ResizeMode="NoResize" WindowStartupLocation="CenterOwner"
Background="{StaticResource AppBg}" FontFamily="Segoe UI, Malgun Gothic">
<Border Style="{StaticResource Card}" Margin="12">
<StackPanel>
<!-- 헤더 -->
<Grid Margin="0,0,0,2">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="스마트수면 설정" FontSize="16" FontWeight="Bold" HorizontalAlignment="Center" Foreground="{StaticResource TextPrimary}"/>
<TextBlock Text="수면시간을 설정하세요" FontSize="11" HorizontalAlignment="Center" Margin="0,2,0,0" Foreground="{StaticResource TextSecondary}"/>
</StackPanel>
<Button Content="✕" Width="28" Height="28" Padding="0" HorizontalAlignment="Right" VerticalAlignment="Top"
Click="Close_Click" Style="{StaticResource FlatButton}"/>
</Grid>
<!-- 원형 다이얼 -->
<Grid Width="260" Height="260" HorizontalAlignment="Center" Margin="0,8,0,6">
<Ellipse Width="208" Height="208" HorizontalAlignment="Center" VerticalAlignment="Center"
Stroke="{StaticResource Track}" StrokeThickness="16" Fill="Transparent"/>
<Path x:Name="SleepArc" Stroke="{StaticResource Accent}" StrokeThickness="16"
StrokeStartLineCap="Round" StrokeEndLineCap="Round"/>
<Canvas x:Name="DialCanvas" Width="260" Height="260">
<TextBlock Text="0" Canvas.Left="126" Canvas.Top="40" FontSize="12" Foreground="{StaticResource TextSecondary}"/>
<TextBlock Text="6" Canvas.Left="212" Canvas.Top="122" FontSize="12" Foreground="{StaticResource TextSecondary}"/>
<TextBlock Text="12" Canvas.Left="122" Canvas.Top="204" FontSize="12" Foreground="{StaticResource TextSecondary}"/>
<TextBlock Text="18" Canvas.Left="38" Canvas.Top="122" FontSize="12" Foreground="{StaticResource TextSecondary}"/>
<Border x:Name="StartHandle" Width="30" Height="30" CornerRadius="15" Background="{StaticResource Accent}" Cursor="Hand"
MouseLeftButtonDown="StartHandle_Down" MouseMove="Handle_Move" MouseLeftButtonUp="Handle_Up">
<TextBlock Text="🛏" FontSize="13" HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="White"/>
</Border>
<Border x:Name="EndHandle" Width="30" Height="30" CornerRadius="15" Background="{StaticResource Accent}" Cursor="Hand"
MouseLeftButtonDown="EndHandle_Down" MouseMove="Handle_Move" MouseLeftButtonUp="Handle_Up">
<TextBlock Text="⏰" FontSize="13" HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="White"/>
</Border>
</Canvas>
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" IsHitTestVisible="False">
<TextBlock HorizontalAlignment="Center" Foreground="{StaticResource TextPrimary}">
<Run Text="🛏 시작 "/><Run x:Name="StartRun" FontWeight="Bold" FontSize="16"/>
</TextBlock>
<TextBlock HorizontalAlignment="Center" Margin="0,4,0,0" Foreground="{StaticResource TextPrimary}">
<Run Text="⏰ 종료 "/><Run x:Name="EndRun" FontWeight="Bold" FontSize="16"/>
</TextBlock>
</StackPanel>
</Grid>
<!-- 시작/종료 콤보 -->
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="시작" Style="{StaticResource FieldLabel}" Margin="0,0,0,4"/>
<ComboBox x:Name="StartCombo"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="~" VerticalAlignment="Bottom" Margin="8,0,8,6" Foreground="{StaticResource TextSecondary}"/>
<StackPanel Grid.Column="2">
<TextBlock Text="종료" Style="{StaticResource FieldLabel}" Margin="0,0,0,4"/>
<ComboBox x:Name="EndCombo"/>
</StackPanel>
</Grid>
<Grid Margin="0,14,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0" Content="취소" Click="Close_Click" Style="{StaticResource FlatButton}" Margin="0,0,6,0"/>
<Button Grid.Column="1" Content="적용" Click="Apply_Click" Style="{StaticResource FlatButton}"/>
</Grid>
</StackPanel>
</Border>
</Window>
@@ -0,0 +1,132 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using ErvDashboard.Model;
namespace ErvDashboard
{
// 스마트수면 시간설정 팝업 (원형 다이얼). 시작/종료 시각을 설정 → 적용 시 메인이 종료 시각에 자동 해제 예약.
// 대시보드 전용(ERV 프로토콜 무관). 시각은 자정 기준 분(0~1439, 30분 단위).
public partial class SmartSleepWindow : Window
{
readonly MainWindow _owner;
readonly DashboardState _state;
const double CX = 130, CY = 130, R = 104; // DialCanvas(260x260) 중심/반지름
int _startMin, _endMin;
bool _dragStart, _dragEnd, _updating;
public SmartSleepWindow(MainWindow owner, DashboardState state)
{
InitializeComponent();
_owner = owner;
_state = state;
_startMin = state.SleepStartMin;
_endMin = state.SleepEndMin;
for (int m = 0; m < 1440; m += 30)
{
StartCombo.Items.Add(new ComboBoxItem { Content = DashboardState.FmtTime(m), Tag = m });
EndCombo.Items.Add(new ComboBoxItem { Content = DashboardState.FmtTime(m), Tag = m });
}
StartCombo.SelectionChanged += StartCombo_Changed;
EndCombo.SelectionChanged += EndCombo_Changed;
UpdateDial();
}
// ===== 다이얼/콤보 갱신 =====
void UpdateDial()
{
_updating = true;
SleepArc.Data = BuildArc(_startMin, _endMin);
PositionHandle(StartHandle, _startMin);
PositionHandle(EndHandle, _endMin);
StartRun.Text = DashboardState.FmtTime(_startMin);
EndRun.Text = DashboardState.FmtTime(_endMin);
StartCombo.SelectedIndex = _startMin / 30;
EndCombo.SelectedIndex = _endMin / 30;
_updating = false;
}
Geometry BuildArc(int s, int e)
{
Point p0 = Polar(s / 1440.0 * 360.0);
Point p1 = Polar(e / 1440.0 * 360.0);
double sweep = ((e - s + 1440) % 1440) / 1440.0 * 360.0;
var fig = new System.Windows.Media.PathFigure { StartPoint = p0, IsClosed = false };
fig.Segments.Add(new ArcSegment(p1, new Size(R, R), 0, sweep > 180.0, SweepDirection.Clockwise, true));
var g = new PathGeometry();
g.Figures.Add(fig);
return g;
}
static Point Polar(double angleDeg) // 0°=위, 시계방향
{
double t = angleDeg * Math.PI / 180.0;
return new Point(CX + R * Math.Sin(t), CY - R * Math.Cos(t));
}
static void PositionHandle(FrameworkElement h, int min)
{
Point p = Polar(min / 1440.0 * 360.0);
Canvas.SetLeft(h, p.X - h.Width / 2);
Canvas.SetTop(h, p.Y - h.Height / 2);
}
static int AngleToMin(Point p)
{
double ang = Math.Atan2(p.X - CX, -(p.Y - CY)) * 180.0 / Math.PI; // 0=위, 시계방향
if (ang < 0) ang += 360;
int min = (int)Math.Round(ang / 360.0 * 1440.0 / 30.0) * 30;
return ((min % 1440) + 1440) % 1440;
}
// ===== 드래그 =====
void StartHandle_Down(object sender, MouseButtonEventArgs e) { _dragStart = true; StartHandle.CaptureMouse(); e.Handled = true; }
void EndHandle_Down(object sender, MouseButtonEventArgs e) { _dragEnd = true; EndHandle.CaptureMouse(); e.Handled = true; }
void Handle_Move(object sender, MouseEventArgs e)
{
if (!_dragStart && !_dragEnd) return;
int min = AngleToMin(e.GetPosition(DialCanvas));
if (_dragStart) _startMin = min; else _endMin = min;
UpdateDial();
}
void Handle_Up(object sender, MouseButtonEventArgs e)
{
_dragStart = _dragEnd = false;
(sender as UIElement)?.ReleaseMouseCapture();
}
// ===== 콤보 =====
void StartCombo_Changed(object sender, SelectionChangedEventArgs e)
{
if (_updating || StartCombo.SelectedItem is not ComboBoxItem it) return;
_startMin = (int)it.Tag;
UpdateDial();
}
void EndCombo_Changed(object sender, SelectionChangedEventArgs e)
{
if (_updating || EndCombo.SelectedItem is not ComboBoxItem it) return;
_endMin = (int)it.Tag;
UpdateDial();
}
// ===== 버튼 =====
void Apply_Click(object sender, RoutedEventArgs e)
{
_state.SleepStartMin = _startMin;
_state.SleepEndMin = _endMin;
_owner.ApplySmartSleep();
Close();
}
void Close_Click(object sender, RoutedEventArgs e) => Close();
}
}
+201
View File
@@ -0,0 +1,201 @@
using System;
using System.Collections.Generic;
using Microsoft.Data.Sqlite;
using ErvDashboard.Model;
namespace ErvDashboard.Storage
{
// 로그 시계열 실시간 저장 (SQLite 단일파일, 무제한 누적).
// - sample : 5초 1행 (시간·전원·운전/풍량·시나리오)
// - room_sample : 실별 댐퍼/센서 (sample 당 N행)
// 그래프는 LoadRecent() 로 최근 구간을 읽어 표시, 이후 실시간 append.
public sealed class LogDb : IDisposable
{
readonly SqliteConnection _conn;
public LogDb(string path)
{
_conn = new SqliteConnection($"Data Source={path}");
_conn.Open();
Exec("PRAGMA journal_mode=WAL;"); // 동시 읽기/쓰기 성능
Exec("PRAGMA synchronous=NORMAL;");
Exec(@"CREATE TABLE IF NOT EXISTS sample(
id INTEGER PRIMARY KEY AUTOINCREMENT,
time INTEGER NOT NULL, -- DateTime.Ticks
power INTEGER, runmode INTEGER, automode INTEGER, fanmode INTEGER,
sleep INTEGER, cook INTEGER, recover INTEGER, hystpreset INTEGER);");
// 구버전 DB 마이그레이션 — 이미 있으면 무시
try { Exec("ALTER TABLE sample ADD COLUMN automode INTEGER DEFAULT 0;"); } catch { }
try { Exec("ALTER TABLE sample ADD COLUMN hystpreset INTEGER DEFAULT 0;"); } catch { }
Exec(@"CREATE TABLE IF NOT EXISTS room_sample(
sample_id INTEGER NOT NULL,
room_idx INTEGER NOT NULL,
damper_sa INTEGER, damper_ra INTEGER,
co2 INTEGER, pm25 INTEGER, pm10 INTEGER, voc INTEGER,
temp INTEGER, humi INTEGER, led INTEGER, level INTEGER,
PRIMARY KEY(sample_id, room_idx));");
}
void Exec(string sql)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
// 1샘플 실시간 저장 (sample + room_sample 트랜잭션 1회).
public void Insert(LogSample s)
{
using var tx = _conn.BeginTransaction();
long id;
using (var cmd = _conn.CreateCommand())
{
cmd.Transaction = tx;
cmd.CommandText =
"INSERT INTO sample(time,power,runmode,automode,fanmode,sleep,cook,recover,hystpreset) " +
"VALUES($t,$p,$rm,$am,$fm,$sl,$ck,$rc,$hp); SELECT last_insert_rowid();";
cmd.Parameters.AddWithValue("$t", s.Time.Ticks);
cmd.Parameters.AddWithValue("$p", s.Power ? 1 : 0);
cmd.Parameters.AddWithValue("$rm", (int)s.RunMode);
cmd.Parameters.AddWithValue("$am", (int)s.AutoMode);
cmd.Parameters.AddWithValue("$hp", (int)s.HystPreset);
cmd.Parameters.AddWithValue("$fm", (int)s.FanMode);
cmd.Parameters.AddWithValue("$sl", s.SmartSleep ? 1 : 0);
cmd.Parameters.AddWithValue("$ck", s.ComfortCook ? 1 : 0);
cmd.Parameters.AddWithValue("$rc", s.ReliefRecover ? 1 : 0);
id = (long)(cmd.ExecuteScalar() ?? 0L);
}
using (var cmd = _conn.CreateCommand())
{
cmd.Transaction = tx;
cmd.CommandText =
"INSERT INTO room_sample(sample_id,room_idx,damper_sa,damper_ra,co2,pm25,pm10,voc,temp,humi,led,level) " +
"VALUES($sid,$ri,$dsa,$dra,$co2,$pm25,$pm10,$voc,$tp,$hm,$led,$lv);";
var pSid = cmd.Parameters.Add("$sid", SqliteType.Integer);
var pRi = cmd.Parameters.Add("$ri", SqliteType.Integer);
var pDsa = cmd.Parameters.Add("$dsa", SqliteType.Integer);
var pDra = cmd.Parameters.Add("$dra", SqliteType.Integer);
var pCo2 = cmd.Parameters.Add("$co2", SqliteType.Integer);
var pP25 = cmd.Parameters.Add("$pm25", SqliteType.Integer);
var pP10 = cmd.Parameters.Add("$pm10", SqliteType.Integer);
var pVoc = cmd.Parameters.Add("$voc", SqliteType.Integer);
var pTp = cmd.Parameters.Add("$tp", SqliteType.Integer);
var pHm = cmd.Parameters.Add("$hm", SqliteType.Integer);
var pLed = cmd.Parameters.Add("$led", SqliteType.Integer);
var pLv = cmd.Parameters.Add("$lv", SqliteType.Integer);
pSid.Value = id;
for (int i = 0; i < s.Rooms.Length; i++)
{
var rm = s.Rooms[i];
pRi.Value = i;
pDsa.Value = rm.DamperSa ? 1 : 0;
pDra.Value = rm.DamperRa ? 1 : 0;
pCo2.Value = rm.Co2; pP25.Value = rm.Pm25; pP10.Value = rm.Pm10; pVoc.Value = rm.Voc;
pTp.Value = rm.Temp; pHm.Value = rm.Humi; pLed.Value = rm.Led; pLv.Value = rm.Level;
cmd.ExecuteNonQuery();
}
}
tx.Commit();
}
// 최근 max개 샘플을 시간 오름차순으로 로드 (그래프 표시용).
public List<LogSample> LoadRecent(int max)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText =
"SELECT id,time,power,runmode,automode,fanmode,sleep,cook,recover,hystpreset FROM " +
"(SELECT * FROM sample ORDER BY id DESC LIMIT $n) ORDER BY id ASC;";
cmd.Parameters.AddWithValue("$n", max);
return Materialize(cmd);
}
// 지정 날짜(그날 00:00 ~ 다음날 00:00, 로컬)의 샘플을 시간 오름차순으로 로드.
public List<LogSample> LoadByDate(DateTime day)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText =
"SELECT id,time,power,runmode,automode,fanmode,sleep,cook,recover,hystpreset " +
"FROM sample WHERE time >= $s AND time < $e ORDER BY id ASC;";
cmd.Parameters.AddWithValue("$s", day.Date.Ticks);
cmd.Parameters.AddWithValue("$e", day.Date.AddDays(1).Ticks);
return Materialize(cmd);
}
// 데이터가 존재하는 날짜 목록(오름차순) — 상단 날짜 선택 UI용.
public List<DateTime> LoadDates()
{
var list = new List<DateTime>();
using var cmd = _conn.CreateCommand();
// Ticks/일(864e9)로 버킷팅 → 날짜(자정) 환산
cmd.CommandText = "SELECT DISTINCT time/864000000000 FROM sample ORDER BY 1;";
using var rd = cmd.ExecuteReader();
while (rd.Read()) list.Add(new DateTime(rd.GetInt64(0) * TimeSpan.TicksPerDay));
return list;
}
// sample 행을 읽고 room_sample 을 붙여 LogSample 리스트로 구성 (시간 오름차순).
List<LogSample> Materialize(SqliteCommand sampleCmd)
{
var byId = new Dictionary<long, LogSample>();
var order = new List<long>();
using (var rd = sampleCmd.ExecuteReader())
{
while (rd.Read())
{
long id = rd.GetInt64(0);
byId[id] = new LogSample
{
Time = new DateTime(rd.GetInt64(1)),
Power = rd.GetInt32(2) != 0,
RunMode = (byte)rd.GetInt32(3),
AutoMode = (byte)(rd.IsDBNull(4) ? 0 : rd.GetInt32(4)),
FanMode = (byte)rd.GetInt32(5),
SmartSleep = rd.GetInt32(6) != 0,
ComfortCook = rd.GetInt32(7) != 0,
ReliefRecover = rd.GetInt32(8) != 0,
HystPreset = (byte)(rd.IsDBNull(9) ? 0 : rd.GetInt32(9)),
};
order.Add(id);
}
}
if (order.Count == 0) return new List<LogSample>();
AttachRooms(byId, order);
var result = new List<LogSample>(order.Count);
foreach (var id in order) result.Add(byId[id]);
return result;
}
// order(오름차순 id) 범위의 room_sample 을 읽어 각 LogSample.Rooms 채움.
void AttachRooms(Dictionary<long, LogSample> byId, List<long> order)
{
var rooms = new Dictionary<long, List<RoomSample>>();
using var cmd = _conn.CreateCommand();
cmd.CommandText =
"SELECT sample_id,room_idx,damper_sa,damper_ra,co2,pm25,pm10,voc,temp,humi,led,level " +
"FROM room_sample WHERE sample_id BETWEEN $min AND $max ORDER BY sample_id, room_idx;";
cmd.Parameters.AddWithValue("$min", order[0]);
cmd.Parameters.AddWithValue("$max", order[order.Count - 1]);
using var rd = cmd.ExecuteReader();
while (rd.Read())
{
long sid = rd.GetInt64(0);
if (!byId.ContainsKey(sid)) continue;
if (!rooms.TryGetValue(sid, out var lst)) { lst = new List<RoomSample>(); rooms[sid] = lst; }
lst.Add(new RoomSample
{
DamperSa = rd.GetInt32(2) != 0,
DamperRa = rd.GetInt32(3) != 0,
Co2 = rd.GetInt32(4), Pm25 = rd.GetInt32(5), Pm10 = rd.GetInt32(6), Voc = rd.GetInt32(7),
Temp = rd.GetInt32(8), Humi = rd.GetInt32(9), Led = rd.GetInt32(10), Level = rd.GetInt32(11),
});
}
foreach (var id in order)
if (rooms.TryGetValue(id, out var lst)) byId[id].Rooms = lst.ToArray();
}
public void Dispose() => _conn.Dispose();
}
}
+34
View File
@@ -0,0 +1,34 @@
<Window x:Class="ErvDashboard.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 AppBg}" FontFamily="Segoe UI, Malgun Gothic">
<Border Style="{StaticResource Card}" Margin="10">
<StackPanel>
<TextBlock Text="풍량 VSP 제어 · 상태 (SA 급기 / EA 배기)" Style="{StaticResource CardTitle}"/>
<ItemsControl ItemsSource="{Binding Vsp}" Width="990">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="3"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{StaticResource Track}" CornerRadius="8" Margin="3" Padding="10,8">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" Style="{StaticResource FieldValue}" FontSize="13" Width="58" VerticalAlignment="Center"/>
<TextBlock Text="SA" Style="{StaticResource FieldLabel}" Margin="0,0,4,0" VerticalAlignment="Center"/>
<TextBox Text="{Binding Sa}" Width="80" Padding="4,4" Margin="0,0,8,0"/>
<TextBlock Text="EA" Style="{StaticResource FieldLabel}" Margin="0,0,4,0" VerticalAlignment="Center"/>
<TextBox Text="{Binding Ea}" Width="80" Padding="4,4"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<StackPanel Orientation="Horizontal" Margin="0,12,0,0">
<Button Content="VSP 적용" Width="100" Style="{StaticResource FlatButton}" Click="Apply_Click"/>
<Button Content="닫기" Width="90" Style="{StaticResource FlatButton}" Click="Close_Click"/>
</StackPanel>
</StackPanel>
</Border>
</Window>
+21
View File
@@ -0,0 +1,21 @@
using System.Windows;
using ErvDashboard.Model;
namespace ErvDashboard
{
// 풍량 VSP 팝업 (환기1~4 / 바이패스 / 공청1~4 SA·EA 편집)
public partial class VspWindow : Window
{
readonly MainWindow _owner;
public VspWindow(MainWindow owner, DashboardState state)
{
InitializeComponent();
_owner = owner;
DataContext = state;
}
void Apply_Click(object sender, RoutedEventArgs e) => _owner.ApplyVsp();
void Close_Click(object sender, RoutedEventArgs e) => Close();
}
}