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
+19
View File
@@ -0,0 +1,19 @@
namespace ErvProtocol
{
// CRC-16/MODBUS (poly 0xA001 reflected, init 0xFFFF)
// PC_ERV_Protocol.md 1장 - CMD~PAYLOAD 구간, 결과 리틀엔디안
public static class Crc16
{
public static ushort Modbus(byte[] data, int start, int length)
{
ushort crc = 0xFFFF;
for (int i = 0; i < length; i++)
{
crc ^= data[start + i];
for (int b = 0; b < 8; b++)
crc = (crc & 0x0001) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
}
return crc;
}
}
}
+79
View File
@@ -0,0 +1,79 @@
namespace ErvProtocol
{
// PC↔ERV 고정패킷 프레임 빌더 (PC_ERV_Protocol.md Rev2.0)
// 모든 프레임은 항상 244B 고정 : 0xAA | CMD | DATA[240] | CRC16-MODBUS(LE)
// - PC→ERV 제어 : DATA 앞쪽에 명령 인자, 나머지 0 패딩
// - ERV→PC : STATUS(0x81) DATA=240B 상태, ACK(0x82) DATA[0]=echoCmd DATA[1]=result
public static class CtrlFrame
{
public const int DataLen = 240; // DATA 고정 크기(=STATUS 페이로드)
public const int FrameLen = 1 + 1 + DataLen + 2; // STX+CMD+DATA+CRC = 244
public const byte CTRL_POWER = 0x01;
public const byte CTRL_RUNMODE = 0x02;
public const byte CTRL_FAN = 0x03;
public const byte CTRL_SUBMODE = 0x04;
public const byte CTRL_HOOD = 0x05;
public const byte CTRL_HYST_PRESET = 0x06;
public const byte CTRL_HYST_VALUE = 0x07;
public const byte CTRL_DAMPER = 0x08;
public const byte CTRL_LED = 0x09;
public const byte REQ_STATUS = 0x0A;
public const byte CTRL_RESET = 0x0B;
public const byte CTRL_VSP = 0x0C;
public const byte CTRL_HYST_THR = 0x0D; // 오염단계 임계 설정 [preset][pollutant][L1~L4]
public const byte CTRL_RESERVE = 0x0E; // (꺼짐)예약 [hours 0~8] : N시간 후 전원 OFF (0=해제)
// cmd + args 로 244B 고정 프레임 생성 (DATA 앞쪽에 args, 나머지 0, CRC 포함)
public static byte[] Build(byte cmd, params byte[] args)
{
int argLen = args?.Length ?? 0;
if (argLen > DataLen) argLen = DataLen; // 240B 초과분은 잘림(설계상 발생하지 않음)
var frame = new byte[FrameLen]; // 전부 0 초기화 = DATA 0 패딩
frame[0] = 0xAA;
frame[1] = cmd;
if (argLen > 0) Array.Copy(args!, 0, frame, 2, argLen); // DATA[0..] = args
ushort crc = Crc16.Modbus(frame, 1, 1 + DataLen); // CMD + DATA[240] = 241B
frame[2 + DataLen] = (byte)(crc & 0xFF); // CRC_L
frame[2 + DataLen + 1] = (byte)(crc >> 8); // CRC_H
return frame;
}
static byte[] U16BE(int v) => new[] { (byte)((v >> 8) & 0xFF), (byte)(v & 0xFF) };
public static byte[] Power(int on) => Build(CTRL_POWER, (byte)(on != 0 ? 1 : 0));
public static byte[] RunModeCmd(int m) => Build(CTRL_RUNMODE, (byte)m);
public static byte[] Fan(int s) => Build(CTRL_FAN, (byte)s);
public static byte[] SubMode(int t, int on) => Build(CTRL_SUBMODE, (byte)t, (byte)(on != 0 ? 1 : 0));
public static byte[] Hood(int on) => Build(CTRL_HOOD, (byte)(on != 0 ? 1 : 0));
public static byte[] Preset(int p) => Build(CTRL_HYST_PRESET, (byte)p);
// preset: 0 ECO / 1 NORMAL / 2 TURBO
public static byte[] HystValue(int preset, int pm25, int pm10, int voc, int co2)
{
var p = new List<byte> { (byte)preset };
p.AddRange(U16BE(pm25)); p.AddRange(U16BE(pm10)); p.AddRange(U16BE(voc)); p.AddRange(U16BE(co2));
return Build(CTRL_HYST_VALUE, p.ToArray());
}
// type : 0=급기(SA) / 1=배기(EA)
public static byte[] Damper(int room, int type, int on) => Build(CTRL_DAMPER, (byte)room, (byte)type, (byte)(on != 0 ? 1 : 0));
public static byte[] Led(int room, int dim) => Build(CTRL_LED, (byte)room, (byte)dim);
public static byte[] RequestStatus() => Build(REQ_STATUS);
public static byte[] Reset(int on) => Build(CTRL_RESET, (byte)(on != 0 ? 1 : 0));
public static byte[] Reserve(int hours) => Build(CTRL_RESERVE, (byte)hours); // 0~8시간, 0=해제
public static byte[] Vsp(int group, int index, int sa, int ea)
{
var p = new List<byte> { (byte)group, (byte)index };
p.AddRange(U16BE(sa)); p.AddRange(U16BE(ea));
return Build(CTRL_VSP, p.ToArray());
}
// 오염단계 임계 : preset(0 ECO/1 NORMAL/2 TURBO), pollutant(0 CO2/1 PM2.5/2 PM10/3 VOC), L1~L4 상한
public static byte[] HystThr(int preset, int pollutant, int l1, int l2, int l3, int l4)
{
var p = new List<byte> { (byte)preset, (byte)pollutant };
p.AddRange(U16BE(l1)); p.AddRange(U16BE(l2)); p.AddRange(U16BE(l3)); p.AddRange(U16BE(l4));
return Build(CTRL_HYST_THR, p.ToArray());
}
}
}
+88
View File
@@ -0,0 +1,88 @@
namespace ErvProtocol
{
// 펌웨어 없이 UI/파이프라인 검증용 합성 STATUS payload(73B) 생성.
public static class DemoStatus
{
public static byte[] BuildPayload(int tick)
{
var p = new byte[StatusDecoder.STATUS_LEN];
p[0] = 1; // power
p[1] = (byte)RunMode.Auto; // runMode
p[2] = (byte)((tick / 5) % 2); // autoState 분산/집중
p[3] = (byte)(2 + (tick % 3)); // fanMode 2~4
p[4] = SubModeBits.SmartSleep; // subMode
p[5] = (byte)(tick % 2); // hood
p[6] = (byte)HystPreset.Normal; // preset
WriteU16(p, 7, 30); WriteU16(p, 9, 50); WriteU16(p, 11, 300); WriteU16(p, 13, 700);
WriteU16(p, 15, 0x0000); // errorCode
for (int r = 0; r < 4; r++)
{
int o = 17 + r * 14;
int seed = tick + r * 13;
p[o + 0] = (byte)((seed % 2) | (((seed % 3) == 0) ? 0x02 : 0)); // bit0 급기 / bit1 배기
WriteU16(p, o + 1, 10 + (seed * 3) % 60);
WriteU16(p, o + 3, 15 + (seed * 5) % 90);
WriteU16(p, o + 5, 100 + (seed * 7) % 400);
WriteU16(p, o + 7, 450 + (seed * 11) % 700);
p[o + 9] = (byte)(1 + (seed % 4));
p[o + 10] = (byte)(seed % 10);
WriteU16(p, o + 11, (seed * 17) % 100);
p[o + 13] = (byte)(seed % 5);
}
p[73] = 0; // reset (토글 off)
// 풍량 VSP 설정값 (1바이트, 사양서 DL H-ERV VSP 실측표) : 환기1~4, 바이패스, 공청1~4 의 SA/EA
int[] sa = { 56, 63, 70, 86, 67, 65, 72, 78, 80 };
int[] ea = { 57, 63, 70, 85, 75, 0, 0, 0, 0 };
for (int i = 0; i < 9; i++)
{
int o = 74 + i * 4;
WriteU16(p, o, sa[i]);
WriteU16(p, o + 2, ea[i]);
}
// 히스테리시스 데드밴드(하강) (ECO/NORMAL/TURBO 의 PM2.5/PM10/VOC/CO2) - 사양서
int[,] hyst = { { 2, 5, 5, 50 }, { 2, 5, 5, 50 }, { 2, 5, 3, 30 } };
for (int i = 0; i < 3; i++)
{
int o = 110 + i * 8;
WriteU16(p, o, hyst[i, 0]);
WriteU16(p, o + 2, hyst[i, 1]);
WriteU16(p, o + 4, hyst[i, 2]);
WriteU16(p, o + 6, hyst[i, 3]);
}
// 모드별 오염단계 임계표 (3프리셋 × [CO2,PM2.5,PM10,VOC] × L1~L4 상한) - 사양서
int[][,] thr =
{
new int[,] { {1000,1300,1600,2000}, {20,38,60,86}, {40,86,126,173}, {171,195,308,438} }, // ECO
new int[,] { {800,1100,1400,1700}, {14,29,49,69}, {28,66,102,138}, {120,150,250,350} }, // NORMAL
new int[,] { {700,1000,1300,1600}, {12,23,38,52}, {24,53,78,104}, {103,120,192,263} }, // TURBO
};
for (int i = 0; i < 3; i++)
{
int o = StatusDecoder.THR_OFF + i * 32;
for (int g = 0; g < 4; g++) // g: 0 CO2,1 PM2.5,2 PM10,3 VOC
for (int k = 0; k < 4; k++)
WriteU16(p, o + g * 8 + k * 2, thr[i][g, k]);
}
// 각실 온도/습도 (offset 230~, 4실 × [Temp, Humi])
for (int r = 0; r < 4; r++)
{
int o = StatusDecoder.TEMPHUMI_OFF + r * 2;
p[o + 0] = (byte)(22 + (tick + r) % 6); // 22~27℃
p[o + 1] = (byte)(40 + (tick + r * 7) % 30); // 40~69%
}
return p;
}
static void WriteU16(byte[] p, int off, int v)
{
p[off] = (byte)((v >> 8) & 0xFF);
p[off + 1] = (byte)(v & 0xFF);
}
}
}
+51
View File
@@ -0,0 +1,51 @@
namespace ErvProtocol
{
// 운전모드 (PC_ERV_Protocol.md 3.1)
public enum RunMode : byte
{
Off = 0x00,
Vent = 0x01, // 환기
Auto = 0x02, // 자동
AirClean = 0x03, // 공청
Bypass = 0x04, // 바이패스
}
// 자동운전 상태 (3.4)
public enum AutoState : byte
{
Distribute = 0x00, // 분산
Focus = 0x01, // 집중
}
// 히스테리시스 프리셋
public enum HystPreset : byte
{
Eco = 0x00,
Normal = 0x01,
Turbo = 0x02,
}
// 공기질 상태 (3.3) - 판단 기준은 협의 예정
public enum AirQuality : byte
{
VeryBad = 0x01, // 매우나쁨 - 빨강
Bad = 0x02, // 나쁨 - 주황
Normal = 0x03, // 보통 - 초록
Good = 0x04, // 좋음 - 파랑
}
// 부가모드 type (CTRL_SUBMODE)
public enum SubModeType : byte
{
SmartSleep = 0x01, // 스마트수면
ComfortCook = 0x02, // 쾌적조리
ReliefRecover = 0x03, // 안심회복
}
public static class SubModeBits
{
public const byte SmartSleep = 0x01;
public const byte ComfortCook = 0x02;
public const byte ReliefRecover = 0x04;
}
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- HuevenEco DL 각실제어 공용 프로토콜 라이브러리 (PC_ERV_Protocol.md 단일 진실원본)
PC 대시보드(WPF), 미니PC 수집서버(ErvCollector) 가 공통 참조한다. -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ErvProtocol</RootNamespace>
<AssemblyName>ErvProtocol</AssemblyName>
</PropertyGroup>
</Project>
+69
View File
@@ -0,0 +1,69 @@
namespace ErvProtocol
{
// 고정패킷 파서 (PC_ERV_Protocol.md Rev2.0)
// 0xAA | CMD | DATA[240] | CRC16-MODBUS(LE) = 244B 고정
// 연결(소켓/시리얼)마다 1개 인스턴스. CRC 통과 프레임만 OnFrame 으로 전달.
public sealed class FrameParser
{
public const byte STX = 0xAA;
public const int DataLen = CtrlFrame.DataLen; // 240
public event Action<byte, byte[]>? OnFrame; // (cmd, data[240])
public event Action<string>? OnError;
enum Step { Stx, Cmd, Data, CrcLo, CrcHi }
Step _step = Step.Stx;
byte _cmd, _crcLo;
readonly byte[] _data = new byte[DataLen];
int _dataPos;
public void Feed(ReadOnlySpan<byte> data)
{
foreach (var b in data) FeedByte(b);
}
public void FeedByte(byte b)
{
switch (_step)
{
case Step.Stx:
if (b == STX) _step = Step.Cmd;
break;
case Step.Cmd:
_cmd = b; _dataPos = 0; _step = Step.Data;
break;
case Step.Data:
_data[_dataPos++] = b;
if (_dataPos >= DataLen) _step = Step.CrcLo;
break;
case Step.CrcLo:
_crcLo = b; _step = Step.CrcHi;
break;
case Step.CrcHi:
ushort rxCrc = (ushort)(_crcLo | (b << 8));
_step = Step.Stx;
Verify(rxCrc);
break;
}
}
// 프레임 동기 리셋 (시리얼 프레임 갭 발생 시 등)
public void Reset() => _step = Step.Stx;
void Verify(ushort rxCrc)
{
var buf = new byte[1 + DataLen]; // CMD + DATA[240]
buf[0] = _cmd;
Array.Copy(_data, 0, buf, 1, DataLen);
ushort calc = Crc16.Modbus(buf, 0, buf.Length);
if (calc != rxCrc)
{
OnError?.Invoke($"CRC ERR cmd=0x{_cmd:X2} calc=0x{calc:X4} rx=0x{rxCrc:X4}");
return;
}
var data = new byte[DataLen];
Array.Copy(_data, data, DataLen);
OnFrame?.Invoke(_cmd, data);
}
}
}
+97
View File
@@ -0,0 +1,97 @@
namespace ErvProtocol
{
public static class StatusDecoder
{
public const byte STATUS = 0x81; // ERV→PC 상태
public const byte ACK = 0x82; // ERV→PC 제어 응답
public const int STATUS_LEN = 240; // 238 + 2((꺼짐)예약 잔여초 u16)
public const int THR_OFF = 134; // 모드별 오염단계 임계표 시작 오프셋
public const int TEMPHUMI_OFF = 230; // 각실 온도/습도 블록 시작 (4실 × [Temp,Humi])
public const int RESERVE_OFF = 238; // (꺼짐)예약 잔여초 u16
static int U16(byte[] p, int off) => (p[off] << 8) | p[off + 1]; // 빅엔디안
// STATUS(0x81) payload(73B) → StatusRecord. 길이 부족 시 null.
public static StatusRecord? Decode(byte[] p)
{
if (p.Length < STATUS_LEN) return null;
var s = new StatusRecord
{
Power = p[0],
RunMode = p[1],
AutoState = p[2],
FanMode = p[3],
SubMode = p[4],
Hood = p[5],
HystPreset = p[6],
HystPm25 = U16(p, 7),
HystPm10 = U16(p, 9),
HystVoc = U16(p, 11),
HystCo2 = U16(p, 13),
ErrorCode = U16(p, 15),
};
const int baseOff = 17;
for (int r = 0; r < 4; r++)
{
int o = baseOff + r * 14;
var room = s.Rooms[r];
room.Damper = p[o + 0];
room.Pm25 = U16(p, o + 1);
room.Pm10 = U16(p, o + 3);
room.Voc = U16(p, o + 5);
room.Co2 = U16(p, o + 7);
room.AirQuality = p[o + 9];
room.LedDim = p[o + 10];
room.LoadScore = U16(p, o + 11);
room.FinalVolume = p[o + 13];
}
// ERV 리셋 (offset 73)
s.Reset = p[73];
// 풍량 VSP 블록 (offset 74~, 9엔트리 × u16 SA·EA)
const int vspOff = 74;
for (int i = 0; i < 9; i++)
{
int o = vspOff + i * 4;
s.Vsp[i].Sa = U16(p, o);
s.Vsp[i].Ea = U16(p, o + 2);
}
// 히스테리시스 프리셋 테이블 (offset 110~, 3프리셋 × PM2.5/PM10/VOC/CO2 u16)
const int hystOff = 110;
for (int i = 0; i < 3; i++)
{
int o = hystOff + i * 8;
s.HystTable[i].Pm25 = U16(p, o);
s.HystTable[i].Pm10 = U16(p, o + 2);
s.HystTable[i].Voc = U16(p, o + 4);
s.HystTable[i].Co2 = U16(p, o + 6);
}
// 모드별 오염단계 임계표 (offset 134~, 3프리셋 × [CO2,PM2.5,PM10,VOC] × L1~L4 u16)
for (int i = 0; i < 3; i++)
{
int o = THR_OFF + i * 32;
for (int k = 0; k < 4; k++) s.ThrTable[i].Co2[k] = U16(p, o + 0 + k * 2);
for (int k = 0; k < 4; k++) s.ThrTable[i].Pm25[k] = U16(p, o + 8 + k * 2);
for (int k = 0; k < 4; k++) s.ThrTable[i].Pm10[k] = U16(p, o + 16 + k * 2);
for (int k = 0; k < 4; k++) s.ThrTable[i].Voc[k] = U16(p, o + 24 + k * 2);
}
// 각실 온도/습도 (offset 230~, 4실 × [Temp, Humi])
for (int r = 0; r < 4; r++)
{
int o = TEMPHUMI_OFF + r * 2;
s.Rooms[r].Temp = p[o + 0];
s.Rooms[r].Humi = p[o + 1];
}
// (꺼짐)예약 잔여초 (offset 238, u16)
s.ReserveRemainSec = U16(p, RESERVE_OFF);
return s;
}
}
}
+102
View File
@@ -0,0 +1,102 @@
namespace ErvProtocol
{
// StatusRecord → STATUS(0x81) 134B payload. StatusDecoder 의 역(逆).
// ERV(메인보드/시뮬레이터) 측이 STATUS 를 송신할 때 사용. PC_ERV_Protocol.md 4장.
public static class StatusEncoder
{
static void W16(byte[] p, int off, int v) // 빅엔디안
{
p[off] = (byte)((v >> 8) & 0xFF);
p[off + 1] = (byte)(v & 0xFF);
}
// StatusRecord → 134B payload
public static byte[] Encode(StatusRecord s)
{
var p = new byte[StatusDecoder.STATUS_LEN];
// 글로벌 0~16
p[0] = s.Power;
p[1] = s.RunMode;
p[2] = s.AutoState;
p[3] = s.FanMode;
p[4] = s.SubMode;
p[5] = s.Hood;
p[6] = s.HystPreset;
W16(p, 7, s.HystPm25);
W16(p, 9, s.HystPm10);
W16(p, 11, s.HystVoc);
W16(p, 13, s.HystCo2);
W16(p, 15, s.ErrorCode);
// 각실 17~ (14B x 4)
const int baseOff = 17;
for (int r = 0; r < 4; r++)
{
int o = baseOff + r * 14;
var room = s.Rooms[r];
p[o + 0] = room.Damper;
W16(p, o + 1, room.Pm25);
W16(p, o + 3, room.Pm10);
W16(p, o + 5, room.Voc);
W16(p, o + 7, room.Co2);
p[o + 9] = room.AirQuality;
p[o + 10] = room.LedDim;
W16(p, o + 11, room.LoadScore);
p[o + 13] = room.FinalVolume;
}
// ERV 리셋 (offset 73)
p[73] = s.Reset;
// 풍량 VSP (offset 74~, 9엔트리 × u16 SA·EA)
const int vspOff = 74;
for (int i = 0; i < 9; i++)
{
int o = vspOff + i * 4;
W16(p, o, s.Vsp[i].Sa);
W16(p, o + 2, s.Vsp[i].Ea);
}
// 히스테리시스 프리셋 테이블 (offset 110~, 3프리셋 × PM2.5/PM10/VOC/CO2 u16)
const int hystOff = 110;
for (int i = 0; i < 3; i++)
{
int o = hystOff + i * 8;
W16(p, o, s.HystTable[i].Pm25);
W16(p, o + 2, s.HystTable[i].Pm10);
W16(p, o + 4, s.HystTable[i].Voc);
W16(p, o + 6, s.HystTable[i].Co2);
}
// 모드별 오염단계 임계표 (offset 134~, 3프리셋 × [CO2,PM2.5,PM10,VOC] × L1~L4)
for (int i = 0; i < 3; i++)
{
int o = StatusDecoder.THR_OFF + i * 32;
for (int k = 0; k < 4; k++) W16(p, o + 0 + k * 2, s.ThrTable[i].Co2[k]);
for (int k = 0; k < 4; k++) W16(p, o + 8 + k * 2, s.ThrTable[i].Pm25[k]);
for (int k = 0; k < 4; k++) W16(p, o + 16 + k * 2, s.ThrTable[i].Pm10[k]);
for (int k = 0; k < 4; k++) W16(p, o + 24 + k * 2, s.ThrTable[i].Voc[k]);
}
// 각실 온도/습도 (offset 230~, 4실 × [Temp, Humi])
for (int r = 0; r < 4; r++)
{
int o = StatusDecoder.TEMPHUMI_OFF + r * 2;
p[o + 0] = s.Rooms[r].Temp;
p[o + 1] = s.Rooms[r].Humi;
}
// (꺼짐)예약 잔여초 (offset 238, u16)
W16(p, StatusDecoder.RESERVE_OFF, s.ReserveRemainSec);
return p;
}
// STATUS(0x81) 완성 프레임 (CRC 포함)
public static byte[] BuildStatusFrame(StatusRecord s) => CtrlFrame.Build(StatusDecoder.STATUS, Encode(s));
// ACK(0x82) 프레임 : [echoCmd][result(0 OK/1 ERR)]
public static byte[] BuildAckFrame(byte echoCmd, byte result) => CtrlFrame.Build(StatusDecoder.ACK, echoCmd, result);
}
}
+102
View File
@@ -0,0 +1,102 @@
namespace ErvProtocol
{
// STATUS(0x81) 1프레임을 디코드한 결과 (PC_ERV_Protocol.md 4장)
// 필드는 raw 수치(byte/int). 소비자(WPF)가 필요 시 enum 으로 캐스팅.
public sealed class StatusRecord
{
public byte Power; // 0/1
public byte RunMode; // 1환기 2자동 3공청 4바이패스
public byte AutoState; // 0분산 1집중
public byte FanMode; // 0~4
public byte SubMode; // 비트맵 bit0수면 bit1조리 bit2회복
public byte Hood; // 비트맵 bit0=연동Enable / bit1=연동운전중 / bit2=후드 통신연결(폴응답 생존)
public bool HoodEnable => (Hood & 0x01) != 0; // 후드연동 활성
public bool HoodRunning => (Hood & 0x02) != 0; // 후드연동에 의한 운전중
public bool HoodConnected => (Hood & 0x04) != 0; // 후드 485 통신 연결됨(ERV가 폴 응답 수신중)
public byte HystPreset; // 0 ECO 1 NORMAL 2 TURBO
public int HystPm25;
public int HystPm10;
public int HystVoc;
public int HystCo2;
public int ErrorCode; // 비트맵
public byte Reset; // ERV 리셋 토글 0/1
public int ReserveRemainSec; // (꺼짐)예약 잔여 초 (0=예약없음) — STATUS 끝 블록
public readonly RoomRecord[] Rooms = new RoomRecord[4];
// 풍량 VSP 9엔트리 (VspInfo.Labels 순서: 환기1~4, 바이패스, 공청1~4)
public readonly VspEntry[] Vsp = new VspEntry[9];
// 히스테리시스 프리셋별 데드밴드(하강) [0]=ECO [1]=NORMAL [2]=TURBO
public readonly HystSet[] HystTable = new HystSet[3];
// 모드(프리셋)별 오염단계 임계 경계표 [0]=ECO [1]=NORMAL [2]=TURBO
public readonly ThrSet[] ThrTable = new ThrSet[3];
public StatusRecord()
{
for (int i = 0; i < 4; i++) Rooms[i] = new RoomRecord();
for (int i = 0; i < 9; i++) Vsp[i] = new VspEntry();
for (int i = 0; i < 3; i++) HystTable[i] = new HystSet();
for (int i = 0; i < 3; i++) ThrTable[i] = new ThrSet();
}
}
// 히스테리시스 한 프리셋의 데드밴드(하강폭)
public sealed class HystSet
{
public int Pm25;
public int Pm10;
public int Voc;
public int Co2;
}
// 한 프리셋의 오염단계 임계(각 오염원별 레벨 1~4 상한 경계)
public sealed class ThrSet
{
public int[] Co2 = new int[4]; // L1~L4 상한
public int[] Pm25 = new int[4];
public int[] Pm10 = new int[4];
public int[] Voc = new int[4];
}
public sealed class VspEntry
{
public int Sa; // 급기 풍량 설정값
public int Ea; // 배기 풍량 설정값
}
// 풍량 VSP 엔트리 인덱스 ↔ 라벨/group/index 매핑 (PC_ERV_Protocol.md 3.5)
public static class VspInfo
{
public static readonly string[] Labels =
{ "환기1", "환기2", "환기3", "환기4", "바이패스", "공청1", "공청2", "공청3", "공청4" };
// InfluxDB 태그/JSON 용 ASCII 키
public static readonly string[] Keys =
{ "vent1", "vent2", "vent3", "vent4", "bypass", "air1", "air2", "air3", "air4" };
// group: 0 Vent / 1 Bypass / 2 AirClean
public static readonly int[] Group = { 0, 0, 0, 0, 1, 2, 2, 2, 2 };
// index: Vent/AirClean 1~4, Bypass 1
public static readonly int[] Index = { 1, 2, 3, 4, 1, 1, 2, 3, 4 };
public const int Count = 9;
}
public sealed class RoomRecord
{
public byte Damper; // 비트맵 : bit0=급기(SA) 열림 / bit1=배기(EA) 열림
public bool DamperSa => (Damper & 0x01) != 0; // 급기 댐퍼 열림
public bool DamperEa => (Damper & 0x02) != 0; // 배기 댐퍼 열림
public int Pm25;
public int Pm10;
public int Voc;
public int Co2;
public byte AirQuality; // 1매우나쁨~4좋음
public byte LedDim; // 0~9
public int LoadScore; // 부하점수
public byte FinalVolume; // 최종풍량
public byte Temp; // 온도(℃) — STATUS 끝 블록(offset 230~)
public byte Humi; // 습도(%) — STATUS 끝 블록(offset 230~)
}
}
+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();
}
}
+212
View File
@@ -0,0 +1,212 @@
# HuevenEco DL 각실제어 대시보드 ↔ ERV 메인보드 통신 프로토콜
- 물리계층 : RS-485 (또는 USB-Serial), **115200 bps, 8 Data, None Parity, 1 Stop (N81)**
- 역할 : PC 대시보드(Host) ↔ ERV 메인보드(Main Board)
- 본 프로토콜은 `Protocol/Each_Room_Jushin_protocol_RS485_Rev1.2` 의 프레임 형식(`0xAA` 시작 + CRC)을
참고하여, **PC 모니터링/제어 전용**으로 재정의한 것이다. 펌웨어(UART1 / HOMENET_485)도 본 규격에 맞춰 수정한다.
---
## 1. 공통 프레임
```
+------+------+------+------------------+--------+--------+
| STX | CMD | LEN | PAYLOAD[LEN] | CRC_L | CRC_H |
+------+------+------+------------------+--------+--------+
0xAA 1B 1B LEN bytes 16-bit CRC
```
| 필드 | 크기 | 설명 |
|----------|------|-------------------------------------------------------------|
| STX | 1 | 고정 `0xAA` |
| CMD | 1 | 명령/응답 코드 (아래 표) |
| LEN | 1 | PAYLOAD 바이트 수 (0~255) |
| PAYLOAD | LEN | 명령별 데이터 |
| CRC | 2 | **CRC-16/MODBUS** (poly 0xA001, init 0xFFFF), **CMD~PAYLOAD**까지, **리틀엔디안** |
- 멀티바이트 수치는 모두 **빅엔디안(상위 바이트 먼저)** 으로 표기한다. (CRC만 리틀엔디안)
- 프레임 간 구분은 STX(0xAA) 탐색 + LEN/CRC 검증으로 처리한다.
---
## 2. 명령 코드 (CMD)
### 2.1 PC → ERV (제어)
| CMD | 이름 | PAYLOAD | 설명 |
|------|-----------------|-------------------------------------------|-----------------------------------|
| 0x01 | CTRL_POWER | `[onoff]` | 전원 0=OFF / 1=ON |
| 0x02 | CTRL_RUNMODE | `[mode]` | 운전모드 (3.1 참조) |
| 0x03 | CTRL_FAN | `[speed]` | 풍량 0~4 (자동모드에서는 무시) |
| 0x04 | CTRL_SUBMODE | `[type][onoff]` | 부가모드 토글 (3.2 참조) |
| 0x05 | CTRL_HOOD | `[onoff]` | 연동후드 0=OFF / 1=ON |
| 0x06 | CTRL_HYST_PRESET| `[preset]` | 히스테리시스 프리셋 0=ECO/1=NORMAL/2=TURBO |
| 0x07 | CTRL_HYST_VALUE | `[preset][pm25(2)][pm10(2)][voc(2)][co2(2)]` | 프리셋별 히스테리시스 임계값 설정 (preset 0 ECO/1 NORMAL/2 TURBO, 값 BE) |
| 0x08 | CTRL_DAMPER | `[room][type][onoff]` | 각실 댐퍼 type 0=급기(SA)/1=배기(EA), 0=닫힘 / 1=열림 |
| 0x09 | CTRL_LED | `[room][dim]` | 각실 LED 디밍 0~9 |
| 0x0A | REQ_STATUS | (없음) | 상태 1회 즉시 요청 |
| 0x0B | CTRL_RESET | `[onoff]` | ERV 리셋 토글 0/1 |
| 0x0C | CTRL_VSP | `[group][index][sa(2)][ea(2)]` | 풍량 VSP 값 설정 (3.5 참조, sa/ea BE) |
- `room` : 1=거실, 2=침실1, 3=침실2, 4=침실3
### 2.2 ERV → PC (상태/응답)
| CMD | 이름 | PAYLOAD | 설명 |
|------|-----------|--------------------|---------------------------------------------|
| 0x81 | STATUS | 134 byte (4장 참조) | 전체 상태 스냅샷 (주기 송신 + REQ_STATUS 응답) |
| 0x82 | ACK | `[echoCmd][result]`| 제어 명령 수신 응답 result 0=OK / 1=ERR |
---
## 3. 값 정의
### 3.1 운전모드 (RunMode)
| 코드 | 의미 | 펌웨어 매핑 |
|------|--------|----------------------------|
| 0x00 | OFF | Power_On = 0 |
| 0x01 | 환기 | MODE_VENTILATION (0) |
| 0x02 | 자동 | MODE_AUTO (1) |
| 0x03 | 공청 | MODE_AIRCLEAN (2) |
| 0x04 | 바이패스 | MODE_BYPASS (3) |
### 3.2 부가모드 (SubMode)
`CTRL_SUBMODE``type` :
| type | 의미 | 펌웨어 매핑(예정) |
|------|------------|--------------------------|
| 0x01 | 스마트수면 | (신규) |
| 0x02 | 쾌적조리 | SamKeopSal_Mode_Flag |
| 0x03 | 안심회복 | Focus_Mode |
STATUS 의 `subMode` 는 비트맵 : bit0=스마트수면, bit1=쾌적조리, bit2=안심회복
### 3.3 공기질 상태 (AirQuality) — *판단 기준은 힘펠 협의 예정, 임시값*
| 코드 | 등급 | 색상 |
|------|-----------|--------|
| 0x01 | 매우나쁨 | 빨강 |
| 0x02 | 나쁨 | 주황 |
| 0x03 | 보통 | 초록 |
| 0x04 | 좋음 | 파랑 |
### 3.4 자동운전 상태 (AutoState)
| 코드 | 의미 |
|------|--------|
| 0x00 | 분산 |
| 0x01 | 집중 |
### 3.5 풍량 VSP (CTRL_VSP 0x0C / STATUS VSP 블록)
급기(SA)/배기(EA) 풍량 설정값. `CTRL_VSP` 로 한 엔트리씩 설정, STATUS 로 전체 표시.
- `group` : `0`=환기(Vent), `1`=바이패스(Bypass), `2`=공청(AirClean)
- `index` : 환기/공청 `1~4`, 바이패스 `1`
- `sa`,`ea` : 각 **u16 빅엔디안** (풍량 설정값/RPM·duty)
**STATUS VSP 9 엔트리 순서** (각 SA,EA): `환기1, 환기2, 환기3, 환기4, 바이패스, 공청1, 공청2, 공청3, 공청4`
---
## 4. STATUS(0x81) PAYLOAD 레이아웃 (73 byte)
### 4.1 글로벌 (offset 0~16, 17 byte)
| off | 크기 | 필드 | 비고 |
|-----|------|---------------|----------------------------------------|
| 0 | 1 | power | Power_On |
| 1 | 1 | runMode | 3.1 |
| 2 | 1 | autoState | 3.4 (분산/집중) |
| 3 | 1 | fanMode | 0~4 (Fan_Mode) |
| 4 | 1 | subMode | 비트맵 (3.2) |
| 5 | 1 | hood | Hood_YeunDong_Enable / Hood_Status |
| 6 | 1 | hystPreset | 0 ECO / 1 NORMAL / 2 TURBO |
| 7 | 2 | hystPM25 | 히스테리시스 PM2.5 |
| 9 | 2 | hystPM10 | 히스테리시스 PM10 |
| 11 | 2 | hystVOC | 히스테리시스 VOC (m_VOC_Level) |
| 13 | 2 | hystCO2 | 히스테리시스 CO2 (m_CO2_Level) |
| 15 | 2 | errorCode | 비트맵 (프로토콜 xlsx Error Code) |
### 4.2 각실 블록 (offset 17~, 14 byte × 4실 = 56 byte)
순서: 거실 → 침실1 → 침실2 → 침실3
| off(상대) | 크기 | 필드 | 비고 |
|-----------|------|-------------|-----------------------------------|
| +0 | 1 | damper | 비트맵 bit0=급기(SA) 열림 / bit1=배기(EA) 열림 |
| +1 | 2 | pm25 | SEN66_pm2p5 |
| +3 | 2 | pm10 | SEN66_pm10p0 |
| +5 | 2 | voc | SEN66_VOC_value |
| +7 | 2 | co2 | SEN66_CO2_value |
| +9 | 1 | airQuality | 3.3 |
| +10 | 1 | ledDim | 0~9 (Light_Bright) |
| +11 | 2 | loadScore | 각실 부하점수 |
| +13 | 1 | finalVolume | 최종 풍량 (ROOM_air_volume) |
### 4.3 ERV 리셋 (offset 73, 1 byte)
| off | 크기 | 필드 | 비고 |
|-----|------|-------|-----------------|
| 73 | 1 | reset | ERV 리셋 토글 0/1 |
### 4.4 풍량 VSP 블록 (offset 74~109, 9엔트리 × u16 SA·EA = 36 byte)
순서(3.5): 환기1~4 → 바이패스 → 공청1~4. 각 엔트리 SA(2) + EA(2), 빅엔디안.
| off(상대) | 크기 | 필드 |
|-----------|------|------|
| +0 | 2 | 환기1 SA |
| +2 | 2 | 환기1 EA |
| +4 | 2 | 환기2 SA |
| +6 | 2 | 환기2 EA |
| +8 | 2 | 환기3 SA |
| +10 | 2 | 환기3 EA |
| +12 | 2 | 환기4 SA |
| +14 | 2 | 환기4 EA |
| +16 | 2 | 바이패스 SA |
| +18 | 2 | 바이패스 EA |
| +20 | 2 | 공청1 SA |
| +22 | 2 | 공청1 EA |
| +24 | 2 | 공청2 SA |
| +26 | 2 | 공청2 EA |
| +28 | 2 | 공청3 SA |
| +30 | 2 | 공청3 EA |
| +32 | 2 | 공청4 SA |
| +34 | 2 | 공청4 EA |
### 4.5 히스테리시스 프리셋 테이블 (offset 110~133, 3프리셋 × PM2.5/PM10/VOC/CO2 u16 = 24 byte)
프리셋(ECO/NORMAL/TURBO)별 임계값. 현재 적용값은 글로벌의 `hystPM25~hystCO2`(off 7~14) = 본 테이블의 `[hystPreset]` 행.
| off(상대) | 크기 | 필드 |
|-----------|------|------|
| +0 | 2 | ECO PM2.5 |
| +2 | 2 | ECO PM10 |
| +4 | 2 | ECO VOC |
| +6 | 2 | ECO CO2 |
| +8 | 2 | NORMAL PM2.5 |
| +10 | 2 | NORMAL PM10 |
| +12 | 2 | NORMAL VOC |
| +14 | 2 | NORMAL CO2 |
| +16 | 2 | TURBO PM2.5 |
| +18 | 2 | TURBO PM10 |
| +20 | 2 | TURBO VOC |
| +22 | 2 | TURBO CO2 |
총 PAYLOAD = 17(글로벌) + 56(각실) + 1(reset) + 36(VSP) + 24(히스테리시스) = **134 byte**
---
## 5. 동작 시나리오
1. PC 연결 후 `REQ_STATUS(0x0A)` 송신 → ERV 가 `STATUS(0x81)` 응답.
2. ERV 는 약 500ms~1s 주기로 `STATUS(0x81)` 를 자동 송신 (대시보드 실시간 갱신).
3. PC 가 토글/슬라이드/선택 시 해당 `CTRL_*` 송신 → ERV 가 `ACK(0x82)` + 다음 STATUS 에 반영.
4. PC 는 STATUS 수신 시마다 로그(날짜·시간 + 전체 상태)에 적재/저장.
---
> 본 문서는 PC 대시보드 작성 기준 초안이며, 펌웨어(UART1) 구현 시 세부 필드는 상호 합의하여 조정한다.
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ErvCollector</RootNamespace>
<AssemblyName>ErvCollector</AssemblyName>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="wwwroot\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<!-- 그래프 이력(로그) 저장 — InfluxDB 없이 자체 SQLite -->
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<!-- 공용 프로토콜 라이브러리 (단일 진실원본) -->
<ProjectReference Include="..\..\ErvProtocol\ErvProtocol.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,256 @@
using System.Net;
using System.Text;
using System.Text.Json;
using ErvCollector.Storage;
using ErvProtocol;
namespace ErvCollector.Http
{
// 대시보드 서빙 + 모니터링/제어 HTTP API
// GET / → wwwroot/index.html
// GET /api/latest → 현장별 최신 상태 JSON
// POST /api/control → 제어 (헤더 X-Auth-Token 필요, 토큰 설정 시)
public sealed class ControlServer
{
readonly HttpListener _listener = new();
readonly SiteHub _hub;
readonly HistoryDb _history;
readonly string _token;
readonly string[] _sites;
readonly string _webRoot;
public event Action<string>? Log;
public ControlServer(string prefix, string token, IEnumerable<string> sites, SiteHub hub, HistoryDb history)
{
_hub = hub;
_history = history;
_token = token ?? "";
_sites = sites.ToArray();
_webRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
_listener.Prefixes.Add(prefix.EndsWith('/') ? prefix : prefix + "/");
}
public void Start()
{
_listener.Start();
if (string.IsNullOrEmpty(_token))
Log?.Invoke("경고: 제어 토큰이 비어있음 → 인증 없이 제어 허용(개발용). 운영 시 Http.Token 설정 권장");
_ = AcceptLoop();
}
async Task AcceptLoop()
{
while (_listener.IsListening)
{
HttpListenerContext ctx;
try { ctx = await _listener.GetContextAsync(); }
catch { break; }
_ = Task.Run(() => Handle(ctx));
}
}
void Handle(HttpListenerContext ctx)
{
try
{
var req = ctx.Request;
string path = req.Url?.AbsolutePath ?? "/";
if (req.HttpMethod == "GET" && (path == "/" || path == "/index.html"))
ServeFile(ctx, "index.html", "text/html; charset=utf-8");
else if (req.HttpMethod == "GET" && path == "/api/latest")
Json(ctx, 200, BuildLatestJson());
else if (req.HttpMethod == "GET" && path == "/api/history")
HandleHistory(ctx);
else if (req.HttpMethod == "GET" && path == "/api/dates")
HandleDates(ctx);
else if (req.HttpMethod == "POST" && path == "/api/control")
HandleControl(ctx);
else if (req.HttpMethod == "GET" && path.StartsWith("/"))
ServeFile(ctx, path.TrimStart('/'), GuessMime(path));
else
Text(ctx, 404, "not found");
}
catch (Exception ex)
{
try { Text(ctx, 500, "error: " + ex.Message); } catch { }
}
}
void HandleControl(HttpListenerContext ctx)
{
// 인증
if (!string.IsNullOrEmpty(_token))
{
var h = ctx.Request.Headers["X-Auth-Token"];
if (h != _token) { Json(ctx, 401, "{\"ok\":false,\"error\":\"unauthorized\"}"); return; }
}
string body;
using (var r = new StreamReader(ctx.Request.InputStream, Encoding.UTF8)) body = r.ReadToEnd();
string site, action;
byte[]? frame;
try
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
site = root.GetProperty("site").GetString() ?? "";
action = root.GetProperty("action").GetString() ?? "";
int V(string n, int def = 0) => root.TryGetProperty(n, out var e) ? e.GetInt32() : def;
frame = action switch
{
"power" => CtrlFrame.Power(V("value")),
"runmode" => CtrlFrame.RunModeCmd(V("value")),
"fan" => CtrlFrame.Fan(V("value")),
"submode" => CtrlFrame.SubMode(V("type"), V("value")),
"hood" => CtrlFrame.Hood(V("value")),
"preset" => CtrlFrame.Preset(V("value")),
"hyst" => CtrlFrame.HystValue(V("preset"), V("pm25"), V("pm10"), V("voc"), V("co2")),
"hystthr" => CtrlFrame.HystThr(V("preset"), V("pollutant"), V("l1"), V("l2"), V("l3"), V("l4")),
"damper" => CtrlFrame.Damper(V("room"), V("type"), V("value")), // type 0=급기/1=배기 (미지정 시 0)
"led" => CtrlFrame.Led(V("room"), V("value")),
"reset" => CtrlFrame.Reset(V("value")),
"reserve" => CtrlFrame.Reserve(V("value")), // (꺼짐)예약 0~8시간
"vsp" => CtrlFrame.Vsp(V("group"), V("index"), V("sa"), V("ea")),
_ => null,
};
}
catch (Exception ex) { Json(ctx, 400, $"{{\"ok\":false,\"error\":\"bad request: {ex.Message}\"}}"); return; }
if (frame == null) { Json(ctx, 400, "{\"ok\":false,\"error\":\"unknown action\"}"); return; }
if (!_sites.Contains(site)) { Json(ctx, 400, "{\"ok\":false,\"error\":\"unknown site\"}"); return; }
bool ok = _hub.TrySend(site, frame);
Log?.Invoke($"[{site}] 제어 {action} → {(ok ? "" : "()")} : {HexFormat.Bytes(frame, frame.Length)}");
Json(ctx, ok ? 200 : 503, $"{{\"ok\":{(ok ? "true" : "false")}}}");
}
void HandleHistory(HttpListenerContext ctx)
{
var q = ctx.Request.QueryString;
string site = q["site"] ?? "";
string dateStr = q["date"] ?? "";
if (!_sites.Contains(site)) { Json(ctx, 400, "{\"error\":\"unknown site\"}"); return; }
if (!DateTime.TryParse(dateStr, out var day)) day = DateTime.Now.Date;
try { Json(ctx, 200, _history.LoadDayJson(site, day)); }
catch (Exception ex) { Json(ctx, 500, $"{{\"error\":\"{ex.Message}\"}}"); }
}
void HandleDates(HttpListenerContext ctx)
{
string site = ctx.Request.QueryString["site"] ?? "";
if (!_sites.Contains(site)) { Json(ctx, 400, "{\"error\":\"unknown site\"}"); return; }
try { Json(ctx, 200, _history.LoadDatesJson(site)); }
catch (Exception ex) { Json(ctx, 500, $"{{\"error\":\"{ex.Message}\"}}"); }
}
string BuildLatestJson()
{
var sb = new StringBuilder("{");
for (int i = 0; i < _sites.Length; i++)
{
var site = _sites[i];
if (i > 0) sb.Append(',');
sb.Append('"').Append(site).Append("\":");
AppendSite(sb, site);
}
sb.Append('}');
return sb.ToString();
}
void AppendSite(StringBuilder sb, string site)
{
var s = _hub.GetStatus(site);
bool online = _hub.IsOnline(site);
if (s == null) { sb.Append($"{{\"online\":{(online ? "true" : "false")},\"g\":null,\"rooms\":[]}}"); return; }
sb.Append("{\"online\":").Append(online ? "true" : "false").Append(",\"g\":{")
.Append("\"power\":").Append(s.Power).Append(",\"run_mode\":").Append(s.RunMode)
.Append(",\"auto_state\":").Append(s.AutoState).Append(",\"fan_mode\":").Append(s.FanMode)
.Append(",\"sub_mode\":").Append(s.SubMode).Append(",\"hood\":").Append(s.Hood)
.Append(",\"hyst_preset\":").Append(s.HystPreset).Append(",\"hyst_pm25\":").Append(s.HystPm25)
.Append(",\"hyst_pm10\":").Append(s.HystPm10).Append(",\"hyst_voc\":").Append(s.HystVoc)
.Append(",\"hyst_co2\":").Append(s.HystCo2).Append(",\"error_code\":").Append(s.ErrorCode)
.Append(",\"reset\":").Append(s.Reset)
.Append(",\"reserve_remain\":").Append(s.ReserveRemainSec).Append("},\"rooms\":[");
for (int r = 0; r < 4; r++)
{
var rm = s.Rooms[r];
if (r > 0) sb.Append(',');
sb.Append("{\"damper_sa\":").Append(rm.DamperSa ? 1 : 0).Append(",\"damper_ea\":").Append(rm.DamperEa ? 1 : 0).Append(",\"pm25\":").Append(rm.Pm25)
.Append(",\"pm10\":").Append(rm.Pm10).Append(",\"voc\":").Append(rm.Voc)
.Append(",\"co2\":").Append(rm.Co2).Append(",\"air_quality\":").Append(rm.AirQuality)
.Append(",\"led_dim\":").Append(rm.LedDim).Append(",\"load_score\":").Append(rm.LoadScore)
.Append(",\"final_volume\":").Append(rm.FinalVolume)
.Append(",\"temp\":").Append(rm.Temp).Append(",\"humi\":").Append(rm.Humi).Append('}');
}
sb.Append("],\"vsp\":[");
for (int i = 0; i < 9; i++)
{
var v = s.Vsp[i];
if (i > 0) sb.Append(',');
sb.Append("{\"sa\":").Append(v.Sa).Append(",\"ea\":").Append(v.Ea).Append('}');
}
sb.Append("],\"hyst\":[");
for (int i = 0; i < 3; i++)
{
var h = s.HystTable[i];
if (i > 0) sb.Append(',');
sb.Append("{\"pm25\":").Append(h.Pm25).Append(",\"pm10\":").Append(h.Pm10)
.Append(",\"voc\":").Append(h.Voc).Append(",\"co2\":").Append(h.Co2).Append('}');
}
// 모드별 오염단계 임계표 (프리셋 ECO/NORMAL/TURBO × 오염원 × L1~L4)
sb.Append("],\"thr\":[");
for (int i = 0; i < 3; i++)
{
var th = s.ThrTable[i];
if (i > 0) sb.Append(',');
sb.Append("{\"co2\":[").Append(string.Join(",", th.Co2))
.Append("],\"pm25\":[").Append(string.Join(",", th.Pm25))
.Append("],\"pm10\":[").Append(string.Join(",", th.Pm10))
.Append("],\"voc\":[").Append(string.Join(",", th.Voc)).Append("]}");
}
sb.Append("]}");
}
void ServeFile(HttpListenerContext ctx, string rel, string mime)
{
var full = Path.GetFullPath(Path.Combine(_webRoot, rel));
if (!full.StartsWith(_webRoot) || !File.Exists(full)) { Text(ctx, 404, "not found"); return; }
var bytes = File.ReadAllBytes(full);
ctx.Response.ContentType = mime;
ctx.Response.StatusCode = 200;
ctx.Response.OutputStream.Write(bytes);
ctx.Response.Close();
}
static string GuessMime(string p) =>
p.EndsWith(".html") ? "text/html; charset=utf-8" :
p.EndsWith(".js") ? "application/javascript" :
p.EndsWith(".css") ? "text/css" : "application/octet-stream";
static void Json(HttpListenerContext ctx, int code, string json) => Write(ctx, code, json, "application/json");
static void Text(HttpListenerContext ctx, int code, string txt) => Write(ctx, code, txt, "text/plain; charset=utf-8");
static void Write(HttpListenerContext ctx, int code, string body, string mime)
{
var bytes = Encoding.UTF8.GetBytes(body);
ctx.Response.StatusCode = code;
ctx.Response.ContentType = mime;
ctx.Response.OutputStream.Write(bytes);
ctx.Response.Close();
}
}
static class HexFormat
{
public static string Bytes(byte[] d, int n)
{
var sb = new StringBuilder(n * 3);
for (int i = 0; i < n; i++) { if (i > 0) sb.Append(' '); sb.Append(d[i].ToString("X2")); }
return sb.ToString();
}
}
}
@@ -0,0 +1,155 @@
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using ErvCollector.Http;
using ErvCollector.Storage;
using ErvProtocol;
namespace ErvCollector
{
// 미니PC 수집/제어 서버:
// 3개 현장 EW11(TCP Client) → 포트별(6001/6002/6003) 수신 → 0xAA STATUS 파싱
// → 샘플러(10초 + 변화시) → InfluxDB 적재
// 동시에 HTTP(대시보드 + /api/latest + /api/control) 로 원격 모니터링/제어 제공.
// 제어는 현장 EW11 이 열어둔 동일 TCP 소켓으로 CTRL_* 프레임을 역방향 송신.
internal static class Program
{
static Config _cfg = null!;
static InfluxLineWriter _influx = null!;
static SiteHub _hub = null!;
static HistoryDb _history = null!;
static async Task Main()
{
_cfg = Config.Load("appsettings.json");
_influx = new InfluxLineWriter(_cfg.Influx.Url, _cfg.Influx.Org, _cfg.Influx.Bucket, _cfg.Influx.Token);
_influx.OnError += m => LogErr(m);
_hub = new SiteHub();
_hub.Log += LogErr;
// 그래프 이력 — exe 옆 SQLite (현장별 5초 누적)
_history = new HistoryDb(Path.Combine(AppContext.BaseDirectory, "erv_history.db"));
Log($"ErvCollector 시작. Influx={_cfg.Influx.Url} bucket={_cfg.Influx.Bucket} 샘플주기={_cfg.SampleIntervalSeconds}s");
// HTTP 대시보드/제어 서버
var http = new ControlServer(_cfg.Http.Prefix, _cfg.Http.Token, _cfg.Sites.Select(s => s.Name), _hub, _history);
http.Log += Log;
try { http.Start(); Log($" HTTP 대시보드/제어 ← {_cfg.Http.Prefix}"); }
catch (Exception ex) { LogErr($"HTTP 서버 시작 실패: {ex.Message}"); }
var tasks = new List<Task>();
foreach (var site in _cfg.Sites)
{
Log($" 현장 '{site.Name}' ← TCP 포트 {site.Port} 대기");
tasks.Add(ListenSiteAsync(site));
}
await Task.WhenAll(tasks);
}
static async Task ListenSiteAsync(SiteConfig site)
{
var listener = new TcpListener(IPAddress.Any, site.Port);
listener.Start();
while (true)
{
TcpClient client;
try { client = await listener.AcceptTcpClientAsync(); }
catch (Exception ex) { LogErr($"[{site.Name}] accept 오류: {ex.Message}"); await Task.Delay(500); continue; }
_ = HandleClientAsync(site, client);
}
}
static async Task HandleClientAsync(SiteConfig site, TcpClient client)
{
var remote = client.Client.RemoteEndPoint?.ToString() ?? "?";
Log($"[{site.Name}] 연결됨 ({remote})");
var parser = new FrameParser();
var sampler = new Sampler(_cfg.SampleIntervalSeconds);
var lastHist = DateTime.MinValue; // 그래프 이력 5초 throttle (현장별)
parser.OnError += m => LogErr($"[{site.Name}] {m}");
parser.OnFrame += (cmd, payload) =>
{
if (cmd != StatusDecoder.STATUS) return; // 저장 대상은 STATUS만
var rec = StatusDecoder.Decode(payload);
if (rec == null) { LogErr($"[{site.Name}] STATUS 길이부족 ({payload.Length})"); return; }
_hub.SetStatus(site.Name, rec); // 최신 상태(원격 조회/대시보드용)
var now = DateTime.UtcNow;
// 그래프 이력 : 5초마다 SQLite 기록
if ((now - lastHist).TotalSeconds >= 5)
{
lastHist = now;
try { _history.Insert(site.Name, rec); } catch (Exception ex) { LogErr($"[{site.Name}] 이력저장: {ex.Message}"); }
}
if (sampler.ShouldWrite(rec, now, out var reason))
_ = _influx.WriteAsync(site.Name, rec, now);
// reason=="skip" 이면 적재 생략 (주기/무변화)
if (reason == "change") Log($"[{site.Name}] 상태변화 기록 (mode={rec.RunMode} fan={rec.FanMode} err=0x{rec.ErrorCode:X4})");
};
NetworkStream? stream = null;
try
{
using (client)
using (stream = client.GetStream())
{
_hub.SetSocket(site.Name, stream); // 제어 송신용 소켓 등록
var buf = new byte[1024];
int n;
while ((n = await stream.ReadAsync(buf)) > 0)
parser.Feed(buf.AsSpan(0, n));
}
}
catch (Exception ex) { LogErr($"[{site.Name}] 수신 오류: {ex.Message}"); }
finally
{
if (stream != null) _hub.RemoveSocket(site.Name, stream);
Log($"[{site.Name}] 연결 종료 ({remote})");
}
}
static void Log(string m) => Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {m}");
static void LogErr(string m) => Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] ERR {m}");
}
// ---- 설정 ----
sealed class Config
{
public InfluxConfig Influx { get; set; } = new();
public HttpConfig Http { get; set; } = new();
public int SampleIntervalSeconds { get; set; } = 10;
public List<SiteConfig> Sites { get; set; } = new();
public static Config Load(string path)
{
var json = File.ReadAllText(path);
var opt = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
return JsonSerializer.Deserialize<Config>(json, opt)
?? throw new InvalidOperationException("appsettings.json 파싱 실패");
}
}
sealed class InfluxConfig
{
public string Url { get; set; } = "http://127.0.0.1:8086";
public string Org { get; set; } = "herv";
public string Bucket { get; set; } = "erv";
public string Token { get; set; } = "";
}
sealed class HttpConfig
{
public string Prefix { get; set; } = "http://+:8080/"; // Linux: http://*:8080/, Windows 비관리자: http://localhost:8080/
public string Token { get; set; } = ""; // 제어 인증 토큰(비우면 인증 없음, 개발용)
}
sealed class SiteConfig
{
public int Port { get; set; }
public string Name { get; set; } = "";
}
}
@@ -0,0 +1,220 @@
# ErvCollector — ERV 24시간 수집/저장 + 원격 모니터링·제어 서버 (미니PC)
3개 현장의 EW11(TCP Client)이 보내는 ERV `0xAA STATUS` 프레임을 수신·파싱하여
**InfluxDB** 에 적재(장기 보관)하고, **내장 웹 대시보드 + HTTP API** 로 실시간 모니터링과 **원격 제어**를 제공한다.
```
[현장1 ERV]─RS485─[EW11]─WiFi/인터넷─┐ TCP 6001
[현장2 ERV]─RS485─[EW11]─WiFi/인터넷─┤ TCP 6002 → [미니PC] ErvCollector ┬ InfluxDB(보관) → Grafana(장기분석)
[현장3 ERV]─RS485─[EW11]─WiFi/인터넷─┘ TCP 6003 └ HTTP 8080 (웹 대시보드 + 제어 API)
```
**원격 제어 원리**: EW11 은 양방향 투명 브리지이므로, 현장 EW11 이 서버로 열어둔 **동일 TCP 소켓**으로
서버가 `CTRL_*` 프레임을 역방향 전송하면 EW11 → RS-485 → ERV 로 전달된다(추가 회선 불필요).
→ ERV 펌웨어(UART1)가 `CTRL_*` 수신 처리(PC_ERV_Protocol 2.1)를 구현해야 실제 동작한다.
- 프레임 규격: `../PC_ERV_Protocol.md`
- 현장 구분: **포트 분리**(6001/6002/6003 = site01/02/03), 펌웨어/프로토콜 변경 불필요.
---
## 1. 저장 정책 (결정 사항)
| 항목 | 값 |
|---|---|
| 저장 해상도 | **10초 주기 + 이산 상태 변화 시 즉시** |
| 보관 기간 | **1년** (InfluxDB 버킷 retention) |
| 스택 | **InfluxDB OSS 2.x + Grafana** |
- 연속값(PM2.5/PM10/VOC/CO2 등)은 10초 주기 샘플.
- 이산값(전원/운전모드/풍량/부가모드/후드/프리셋/에러코드 + 각실 댐퍼·공기질등급·LED)이 바뀌면 **즉시 1건** 추가 기록 → 변화 시점 누락 없음.
---
## 1.5 원격 모니터링·제어 (HTTP API)
서버는 `Http.Prefix`(기본 8080)에서 웹 대시보드와 API 를 제공한다.
| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | `/` | 내장 웹 대시보드(`wwwroot/index.html`) — 3현장 모니터링 + 제어 |
| GET | `/api/latest` | 현장별 최신 상태 JSON(온라인 여부 + 글로벌 + 각실) |
| POST | `/api/control` | 제어. 헤더 `X-Auth-Token`(토큰 설정 시), 본문 JSON |
**제어 본문 예시**
```json
{ "site":"site01", "action":"power", "value":1 }
{ "site":"site01", "action":"runmode", "value":2 } // 1환기 2자동 3공청 4바이패스
{ "site":"site01", "action":"fan", "value":3 } // 0~4 (자동모드 무시)
{ "site":"site01", "action":"submode", "type":2, "value":1 }// type 1수면 2조리 3회복
{ "site":"site01", "action":"hood", "value":1 }
{ "site":"site01", "action":"preset", "value":2 } // 0 ECO 1 NORMAL 2 TURBO
{ "site":"site01", "action":"hyst", "pm25":30,"pm10":50,"voc":300,"co2":700 }
{ "site":"site01", "action":"damper", "room":2, "value":1 }// room 1거실 2~4침실
{ "site":"site01", "action":"led", "room":1, "value":7 }// 0~9
```
→ 서버가 해당 현장 EW11 소켓으로 `CTRL_*` 프레임 송신. 응답 `{"ok":true}` (연결 없으면 503).
> **보안**: 제어는 민감하므로 `Http.Token` 을 반드시 설정(대시보드 토큰칸/`X-Auth-Token`).
> 운영 시 HTTP 포트는 사내망/VPN 으로 제한하고 외부 직노출 금지 권장.
> **바인딩**: `Http.Prefix` — Linux 미니PC 는 `http://*:8080/`(전체 인터페이스),
> Windows 비관리자 테스트는 `http://localhost:8080/` 만 가능(`+`/`*` 는 관리자 또는 `netsh http add urlacl` 필요).
---
## 2. 데이터 스키마 (InfluxDB measurement)
**erv_global** (tag: `site`)
`power, run_mode, auto_state, fan_mode, sub_mode, hood, hyst_preset, hyst_pm25, hyst_pm10, hyst_voc, hyst_co2, error_code` (모두 정수)
**erv_room** (tag: `site`, `room`=1~4)
`damper, pm25, pm10, voc, co2, air_quality, led_dim, load_score, final_volume` (모두 정수)
> 코드값 의미(run_mode 1환기/2자동/…, air_quality 1매우나쁨~4좋음 등)는 Grafana **Value mappings** 로 라벨 표시.
---
## 3. 미니PC 설치 (Ubuntu 기준)
### 3.1 .NET 런타임
```bash
sudo apt-get update && sudo apt-get install -y dotnet-runtime-10.0 # 또는 dotnet-sdk-10.0
```
### 3.2 InfluxDB OSS 2.x
```bash
# 설치 (공식 저장소)
curl -s https://repos.influxdata.com/influxdata-archive_compat.key | sudo gpg --dearmor -o /usr/share/keyrings/influxdata.gpg
echo "deb [signed-by=/usr/share/keyrings/influxdata.gpg] https://repos.influxdata.com/debian stable main" | sudo tee /etc/apt/sources.list.d/influxdata.list
sudo apt-get update && sudo apt-get install -y influxdb2
sudo systemctl enable --now influxdb
# 초기 설정 (org=herv, bucket=erv, 보관 1년=8760h)
influx setup --org herv --bucket erv --retention 8760h --username admin --password '<PW>' --force
# 쓰기용 토큰 확인
influx auth list
```
→ 출력된 토큰을 `appsettings.json``Influx.Token` 에 기입.
### 3.3 Collector 빌드/실행
```bash
cd ErvCollector
dotnet publish -c Release -o /opt/erv-collector
# appsettings.json 의 Influx.Token / Sites 포트 확인 후
/opt/erv-collector/ErvCollector
```
### 3.4 systemd 등록 (24시간 자동 실행/재시작)
`/etc/systemd/system/erv-collector.service`
```ini
[Unit]
Description=ERV Collector
After=network.target influxdb.service
[Service]
WorkingDirectory=/opt/erv-collector
ExecStart=/opt/erv-collector/ErvCollector
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload && sudo systemctl enable --now erv-collector
journalctl -u erv-collector -f # 로그 확인
```
### 3.5 Grafana
```bash
sudo apt-get install -y grafana && sudo systemctl enable --now grafana-server
# 브라우저 http://<미니PC>:3000 (admin/admin)
# Data source: InfluxDB → Query language Flux → URL http://127.0.0.1:8086 → org=herv, token, default bucket=erv
```
대시보드: site별 변수(`site`)로 현장 전환, room별 패널로 PM/CO2/VOC 추이 + 운전모드/에러 상태 표시.
---
## 4. EW11 설정 (현장 3대, IOTService 또는 웹)
| 항목 | 값 |
|---|---|
| Serial | 115200 / 8 / None / 1 |
| Protocol | **TCP Client** |
| Server Addr | 회사 서버 **공인 IP**(또는 DDNS 도메인) |
| Server Port | 현장1→6001, 현장2→6002, 현장3→6003 |
| Security | **AES**(16자 키, 서버와 동일) ※ EW11 TLS는 인증서 미검증이라 AES 병행 권장 |
| Keep Alive / Reconnect | 활성 |
| Buffer Size | ≥ 256 (STATUS 78B 여유) |
> 회사측: 라우터에서 6001~6003 → 미니PC로 **포트포워딩**. 가능하면 inbound 를 3현장 공인 IP로 제한.
---
## 5. 용량 메모
- 10초 주기 × 3현장 ≈ 연 ~950만 레코드(이벤트 추가분 포함). InfluxDB 압축 시 연 수백 MB~2GB 수준 → 미니PC SSD로 충분.
- 1년 retention 이면 자동으로 오래된 데이터 만료.
---
## 6.5 WSL2 로 임시 구축 (미니PC 구입 전 검증)
WSL2 는 실제 Linux VM 이라 위 **3장 Ubuntu 설치 절차(.NET / InfluxDB / Grafana)가 그대로** 동작한다.
검증/개발용으로 충분하다. 단 아래 2가지를 챙겨야 한다.
### (1) WSL 안에서 서비스 자동 실행 — systemd 활성화
`/etc/wsl.conf` (WSL 내부)
```ini
[boot]
systemd=true
```
→ Windows PowerShell 에서 `wsl --shutdown` 후 재시작하면 `systemctl` 로 influxdb/grafana/erv-collector 를 서비스로 띄울 수 있다.
### (2) 외부 EW11 이 WSL 서비스에 접속 — 네트워크 노출
WSL2 는 기본적으로 NAT 라 **LAN 의 EW11 이 WSL 내부 포트에 직접 못 붙는다**. 둘 중 하나로 해결:
**방법 A — Mirrored networking (Windows 11 22H2+, 권장)**
`%UserProfile%\.wslconfig` (Windows 측)
```ini
[wsl2]
networkingMode=mirrored
```
`wsl --shutdown` 후 재시작. WSL 이 호스트 IP 를 공유하므로 EW11 은 **Windows PC 의 LAN IP : 6001~6003** 으로 바로 접속.
**방법 B — portproxy (Windows 10 등)**
관리자 PowerShell:
```powershell
$wsl = (wsl hostname -I).Trim().Split(" ")[0] # WSL 내부 IP (재부팅 시 변동)
foreach ($p in 6001,6002,6003) {
netsh interface portproxy add v4tov4 listenport=$p listenaddress=0.0.0.0 connectport=$p connectaddress=$wsl
}
```
### (3) Windows 방화벽 인바운드 허용
```powershell
New-NetFirewallRule -DisplayName "ERV Collector" -Direction Inbound -Protocol TCP -LocalPort 6001-6003 -Action Allow
New-NetFirewallRule -DisplayName "Grafana" -Direction Inbound -Protocol TCP -LocalPort 3000 -Action Allow
```
> ⚠️ WSL/PC 는 **검증용**으로 권장. 24시간 양산 운영은 절전/재부팅/업데이트로 끊길 수 있어 전용 미니PC 가 안전하다.
> 또한 방법 B 의 WSL 내부 IP 는 재부팅마다 바뀌므로 방법 A(mirrored)가 편하다.
### (4) 순수 로컬 테스트라면
EW11 없이 같은 PC 에서 데모 프레임만 흘려볼 거면 네트워크 설정 불필요 — `127.0.0.1:6001` 로 바로 송신해 검증 가능(앞선 스모크 테스트 방식).
---
## 7. 구성 파일 (`appsettings.json`)
```json
{
"Influx": { "Url": "http://127.0.0.1:8086", "Org": "herv", "Bucket": "erv", "Token": "<TOKEN>" },
"SampleIntervalSeconds": 10,
"Sites": [
{ "Port": 6001, "Name": "site01" },
{ "Port": 6002, "Name": "site02" },
{ "Port": 6003, "Name": "site03" }
]
}
```
@@ -0,0 +1,199 @@
using System.Globalization;
using System.Text;
using Microsoft.Data.Sqlite;
using ErvProtocol;
namespace ErvCollector.Storage
{
// 현장별 시계열 로그 (그래프용). InfluxDB 와 별개로 자체 SQLite 단일파일에 누적.
// - sample : 현장 1샘플 (시간·전원·운전/자동/풍량/시나리오/프리셋)
// - room_sample : 실별 댐퍼/센서/LED/부하
// 그래프는 /api/history?site=&date= 로 하루치를 읽어 표시, /api/dates 로 날짜 목록.
public sealed class HistoryDb : IDisposable
{
readonly SqliteConnection _conn;
readonly object _lock = new();
public HistoryDb(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,
site TEXT NOT NULL, time INTEGER NOT NULL,
power INTEGER, runmode INTEGER, automode INTEGER, fanmode INTEGER,
sleep INTEGER, cook INTEGER, recover INTEGER, hystpreset INTEGER);");
Exec("CREATE INDEX IF NOT EXISTS ix_sample_site_time ON sample(site, time);");
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();
}
// STATUS 1건 저장. time 은 로컬(날짜 버킷팅 일치).
public void Insert(string site, StatusRecord s)
{
// 자동운전 세부 : 0 비자동 / 1 자동-집중 / 2 자동-분산
int automode = s.RunMode == 2 ? (s.AutoState == 1 ? 1 : 2) : 0;
int sleep = (s.SubMode & 0x01) != 0 ? 1 : 0;
int cook = (s.SubMode & 0x02) != 0 ? 1 : 0;
int recov = (s.SubMode & 0x04) != 0 ? 1 : 0;
lock (_lock)
{
using var tx = _conn.BeginTransaction();
long id;
using (var cmd = _conn.CreateCommand())
{
cmd.Transaction = tx;
cmd.CommandText =
"INSERT INTO sample(site,time,power,runmode,automode,fanmode,sleep,cook,recover,hystpreset) " +
"VALUES($st,$t,$p,$rm,$am,$fm,$sl,$ck,$rc,$hp); SELECT last_insert_rowid();";
cmd.Parameters.AddWithValue("$st", site);
cmd.Parameters.AddWithValue("$t", DateTime.Now.Ticks);
cmd.Parameters.AddWithValue("$p", s.Power);
cmd.Parameters.AddWithValue("$rm", s.RunMode);
cmd.Parameters.AddWithValue("$am", automode);
cmd.Parameters.AddWithValue("$fm", s.FanMode);
cmd.Parameters.AddWithValue("$sl", sleep);
cmd.Parameters.AddWithValue("$ck", cook);
cmd.Parameters.AddWithValue("$rc", recov);
cmd.Parameters.AddWithValue("$hp", s.HystPreset);
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($id,$ri,$dsa,$dra,$co2,$pm25,$pm10,$voc,$tp,$hm,$led,$lv);";
var pId=cmd.Parameters.Add("$id",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);
pId.Value = id;
for (int i = 0; i < 4 && i < s.Rooms.Length; i++)
{
var rm = s.Rooms[i];
pRi.Value=i; pDsa.Value=rm.DamperSa?1:0; pDra.Value=rm.DamperEa?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.LedDim; pLv.Value=rm.LoadScore;
cmd.ExecuteNonQuery();
}
}
tx.Commit();
}
}
// 지정 현장·날짜(로컬 00:00~24:00) 하루치를 JSON 배열로 반환.
public string LoadDayJson(string site, DateTime day)
{
long start = day.Date.Ticks, end = day.Date.AddDays(1).Ticks;
lock (_lock)
{
// sample 먼저 (id 순)
var ids = new List<long>();
var rows = new List<string>(); // 각 sample 의 글로벌 필드 JSON 조각(rooms 채우기 전)
var idToIdx = new Dictionary<long, int>();
using (var cmd = _conn.CreateCommand())
{
cmd.CommandText =
"SELECT id,time,power,runmode,automode,fanmode,sleep,cook,recover,hystpreset " +
"FROM sample WHERE site=$s AND time>=$a AND time<$b ORDER BY id ASC;";
cmd.Parameters.AddWithValue("$s", site);
cmd.Parameters.AddWithValue("$a", start);
cmd.Parameters.AddWithValue("$b", end);
using var rd = cmd.ExecuteReader();
while (rd.Read())
{
long id = rd.GetInt64(0);
var t = new DateTime(rd.GetInt64(1));
var sb = new StringBuilder();
sb.Append("{\"t\":\"").Append(t.ToString("HH:mm:ss", CultureInfo.InvariantCulture)).Append('"')
.Append(",\"date\":\"").Append(t.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append('"')
.Append(",\"power\":").Append(rd.GetInt32(2))
.Append(",\"run_mode\":").Append(rd.GetInt32(3))
.Append(",\"auto_mode\":").Append(rd.GetInt32(4))
.Append(",\"fan_mode\":").Append(rd.GetInt32(5))
.Append(",\"sleep\":").Append(rd.GetInt32(6))
.Append(",\"cook\":").Append(rd.GetInt32(7))
.Append(",\"recover\":").Append(rd.GetInt32(8))
.Append(",\"hyst_preset\":").Append(rd.GetInt32(9))
.Append(",\"rooms\":[");
idToIdx[id] = rows.Count;
rows.Add(sb.ToString()); // 아직 rooms 미완 — 아래서 이어붙임
ids.Add(id);
}
}
if (ids.Count == 0) return "[]";
// room_sample 을 sample 별로 모음
var roomsById = new Dictionary<long, List<string>>();
using (var cmd = _conn.CreateCommand())
{
cmd.CommandText =
"SELECT sample_id,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", ids[0]);
cmd.Parameters.AddWithValue("$max", ids[ids.Count - 1]);
using var rd = cmd.ExecuteReader();
while (rd.Read())
{
long sid = rd.GetInt64(0);
if (!idToIdx.ContainsKey(sid)) continue;
if (!roomsById.TryGetValue(sid, out var lst)) { lst = new List<string>(); roomsById[sid] = lst; }
lst.Add("{\"damper_sa\":" + rd.GetInt32(1) + ",\"damper_ra\":" + rd.GetInt32(2) +
",\"co2\":" + rd.GetInt32(3) + ",\"pm25\":" + rd.GetInt32(4) + ",\"pm10\":" + rd.GetInt32(5) +
",\"voc\":" + rd.GetInt32(6) + ",\"temp\":" + rd.GetInt32(7) + ",\"humi\":" + rd.GetInt32(8) +
",\"led\":" + rd.GetInt32(9) + ",\"level\":" + rd.GetInt32(10) + "}");
}
}
var outSb = new StringBuilder("[");
for (int i = 0; i < ids.Count; i++)
{
if (i > 0) outSb.Append(',');
outSb.Append(rows[i]);
if (roomsById.TryGetValue(ids[i], out var lst)) outSb.Append(string.Join(",", lst));
outSb.Append("]}");
}
outSb.Append(']');
return outSb.ToString();
}
}
// 데이터가 있는 날짜 목록(JSON 배열, 오름차순).
public string LoadDatesJson(string site)
{
lock (_lock)
{
var list = new List<string>();
using var cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT DISTINCT time/864000000000 FROM sample WHERE site=$s ORDER BY 1;";
cmd.Parameters.AddWithValue("$s", site);
using var rd = cmd.ExecuteReader();
while (rd.Read())
{
var d = new DateTime(rd.GetInt64(0) * TimeSpan.TicksPerDay);
list.Add("\"" + d.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + "\"");
}
return "[" + string.Join(",", list) + "]";
}
}
public void Dispose() => _conn.Dispose();
}
}
@@ -0,0 +1,114 @@
using System.Net.Http.Headers;
using System.Text;
using ErvProtocol;
namespace ErvCollector.Storage
{
// InfluxDB v2 라인 프로토콜 기록 (외부 라이브러리 없이 HTTP /api/v2/write)
//
// measurement:
// erv_global,site=<site> power,run_mode,auto_state,fan_mode,sub_mode,hood,
// hyst_preset,hyst_pm25,hyst_pm10,hyst_voc,hyst_co2,error_code
// erv_room,site=<site>,room=<1..4> damper,pm25,pm10,voc,co2,air_quality,led_dim,
// load_score,final_volume
public sealed class InfluxLineWriter : IDisposable
{
readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) };
readonly string _writeUrl;
public event Action<string>? OnError;
public InfluxLineWriter(string url, string org, string bucket, string token)
{
_writeUrl = $"{url.TrimEnd('/')}/api/v2/write" +
$"?org={Uri.EscapeDataString(org)}&bucket={Uri.EscapeDataString(bucket)}&precision=ns";
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", token);
}
public async Task WriteAsync(string site, StatusRecord s, DateTime nowUtc)
{
long ts = ToUnixNanos(nowUtc);
var sb = new StringBuilder(512);
// erv_global
sb.Append("erv_global,site=").Append(site).Append(' ')
.Append("power=").Append(s.Power).Append("i,")
.Append("run_mode=").Append(s.RunMode).Append("i,")
.Append("auto_state=").Append(s.AutoState).Append("i,")
.Append("fan_mode=").Append(s.FanMode).Append("i,")
.Append("sub_mode=").Append(s.SubMode).Append("i,")
.Append("hood=").Append(s.Hood).Append("i,")
.Append("hyst_preset=").Append(s.HystPreset).Append("i,")
.Append("hyst_pm25=").Append(s.HystPm25).Append("i,")
.Append("hyst_pm10=").Append(s.HystPm10).Append("i,")
.Append("hyst_voc=").Append(s.HystVoc).Append("i,")
.Append("hyst_co2=").Append(s.HystCo2).Append("i,")
.Append("error_code=").Append(s.ErrorCode).Append("i,")
.Append("reset=").Append(s.Reset).Append('i')
.Append(' ').Append(ts).Append('\n');
// erv_room (4실)
for (int r = 0; r < 4; r++)
{
var rm = s.Rooms[r];
sb.Append("erv_room,site=").Append(site).Append(",room=").Append(r + 1).Append(' ')
.Append("damper=").Append(rm.Damper).Append("i,")
.Append("pm25=").Append(rm.Pm25).Append("i,")
.Append("pm10=").Append(rm.Pm10).Append("i,")
.Append("voc=").Append(rm.Voc).Append("i,")
.Append("co2=").Append(rm.Co2).Append("i,")
.Append("air_quality=").Append(rm.AirQuality).Append("i,")
.Append("led_dim=").Append(rm.LedDim).Append("i,")
.Append("load_score=").Append(rm.LoadScore).Append("i,")
.Append("final_volume=").Append(rm.FinalVolume).Append('i')
.Append(' ').Append(ts).Append('\n');
}
// erv_vsp (9엔트리: 환기1~4, 바이패스, 공청1~4)
for (int i = 0; i < 9; i++)
{
var v = s.Vsp[i];
sb.Append("erv_vsp,site=").Append(site).Append(",vsp=").Append(VspInfo.Keys[i]).Append(' ')
.Append("sa=").Append(v.Sa).Append("i,")
.Append("ea=").Append(v.Ea).Append('i')
.Append(' ').Append(ts).Append('\n');
}
// erv_hyst (프리셋별 임계값: eco/normal/turbo)
string[] presetKey = { "eco", "normal", "turbo" };
for (int i = 0; i < 3; i++)
{
var h = s.HystTable[i];
sb.Append("erv_hyst,site=").Append(site).Append(",preset=").Append(presetKey[i]).Append(' ')
.Append("pm25=").Append(h.Pm25).Append("i,")
.Append("pm10=").Append(h.Pm10).Append("i,")
.Append("voc=").Append(h.Voc).Append("i,")
.Append("co2=").Append(h.Co2).Append('i')
.Append(' ').Append(ts).Append('\n');
}
try
{
using var content = new StringContent(sb.ToString(), Encoding.UTF8);
using var resp = await _http.PostAsync(_writeUrl, content);
if (!resp.IsSuccessStatusCode)
{
string body = await resp.Content.ReadAsStringAsync();
OnError?.Invoke($"Influx write HTTP {(int)resp.StatusCode}: {body}");
}
}
catch (Exception ex)
{
OnError?.Invoke($"Influx write FAIL: {ex.Message}");
}
}
static long ToUnixNanos(DateTime utc)
{
long ticks = utc.ToUniversalTime().Ticks - DateTime.UnixEpoch.Ticks; // 100ns ticks
return ticks * 100L;
}
public void Dispose() => _http.Dispose();
}
}
@@ -0,0 +1,55 @@
using ErvProtocol;
namespace ErvCollector.Storage
{
// 저장 정책: "N초 주기 + 상태(이산값) 변화 시 즉시"
// - 연속값(센서 PM/VOC/CO2 등)은 주기 샘플
// - 이산값(전원/모드/팬/부가/후드/프리셋/에러 + 각실 댐퍼/공기질등급/LED)이 바뀌면 즉시 기록
public sealed class Sampler
{
readonly TimeSpan _interval;
string? _lastFingerprint;
DateTime _lastWriteUtc = DateTime.MinValue;
public Sampler(int intervalSeconds)
{
_interval = TimeSpan.FromSeconds(intervalSeconds <= 0 ? 10 : intervalSeconds);
}
// 기록해야 하면 true (그리고 내부 상태 갱신)
public bool ShouldWrite(StatusRecord s, DateTime nowUtc, out string reason)
{
string fp = Fingerprint(s);
bool changed = fp != _lastFingerprint;
bool periodic = (nowUtc - _lastWriteUtc) >= _interval;
if (changed || periodic)
{
reason = changed ? (_lastFingerprint == null ? "first" : "change") : "periodic";
_lastFingerprint = fp;
_lastWriteUtc = nowUtc;
return true;
}
reason = "skip";
return false;
}
static string Fingerprint(StatusRecord s)
{
// 이산 상태/제어값만 모아 변화 감지 (연속 센서값 제외)
var sb = new System.Text.StringBuilder(48);
sb.Append(s.Power).Append(s.RunMode).Append('.').Append(s.AutoState).Append('.')
.Append(s.FanMode).Append('.').Append(s.SubMode).Append('.').Append(s.Hood).Append('.')
.Append(s.HystPreset).Append('.').Append(s.ErrorCode).Append('.').Append(s.Reset);
foreach (var r in s.Rooms)
sb.Append('|').Append(r.Damper).Append(r.AirQuality).Append(r.LedDim);
// VSP(설정값) 변경도 즉시 기록
foreach (var v in s.Vsp)
sb.Append('#').Append(v.Sa).Append(',').Append(v.Ea);
// 히스테리시스 프리셋 테이블 변경도 즉시 기록
foreach (var h in s.HystTable)
sb.Append('@').Append(h.Pm25).Append(',').Append(h.Pm10).Append(',').Append(h.Voc).Append(',').Append(h.Co2);
return sb.ToString();
}
}
}
@@ -0,0 +1,55 @@
using System.Collections.Concurrent;
using System.Net.Sockets;
using ErvProtocol;
namespace ErvCollector.Storage
{
// 현장별 활성 소켓 + 최신 상태 보관. 제어 프레임을 해당 현장 EW11 소켓으로 송신.
public sealed class SiteHub
{
readonly ConcurrentDictionary<string, NetworkStream> _sockets = new();
readonly ConcurrentDictionary<string, StatusRecord> _last = new();
readonly ConcurrentDictionary<string, DateTime> _lastSeenUtc = new();
public event Action<string>? Log;
public void SetSocket(string site, NetworkStream s) { _sockets[site] = s; }
public void RemoveSocket(string site, NetworkStream s)
{
// 현재 등록된 것이 이 스트림일 때만 제거(재연결 레이스 방지)
if (_sockets.TryGetValue(site, out var cur) && ReferenceEquals(cur, s))
_sockets.TryRemove(site, out _);
}
public void SetStatus(string site, StatusRecord rec)
{
_last[site] = rec;
_lastSeenUtc[site] = DateTime.UtcNow;
}
public StatusRecord? GetStatus(string site) => _last.TryGetValue(site, out var r) ? r : null;
public DateTime LastSeen(string site) => _lastSeenUtc.TryGetValue(site, out var t) ? t : DateTime.MinValue;
public bool IsOnline(string site) =>
_sockets.ContainsKey(site) && (DateTime.UtcNow - LastSeen(site)) < TimeSpan.FromSeconds(30);
// 제어 프레임 송신. 성공 true.
public bool TrySend(string site, byte[] frame)
{
if (!_sockets.TryGetValue(site, out var stream))
{
Log?.Invoke($"[{site}] 제어 실패: 연결 없음");
return false;
}
try
{
lock (stream) { stream.Write(frame, 0, frame.Length); stream.Flush(); }
return true;
}
catch (Exception ex)
{
Log?.Invoke($"[{site}] 제어 송신 오류: {ex.Message}");
return false;
}
}
}
}
@@ -0,0 +1,27 @@
{
"Influx": {
"Url": "http://127.0.0.1:8086",
"Org": "herv",
"Bucket": "erv",
"Token": "PUT-YOUR-INFLUXDB-TOKEN-HERE"
},
"Http": {
"Prefix": "http://localhost:8080/",
"Token": ""
},
"SampleIntervalSeconds": 10,
"Sites": [
{
"Port": 6001,
"Name": "site01"
},
{
"Port": 6002,
"Name": "site02"
},
{
"Port": 6003,
"Name": "site03"
}
]
}
@@ -0,0 +1,597 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HuevenEco DL 각실제어 모니터링·제어</title>
<style>
:root{
--bg:#F4F6FB; --card:#FFFFFF; --border:#E3E7EF; --text:#1F2733; --text2:#8A93A6;
--accent:#3B82F6; --accent-soft:#E7F0FF; --good:#22C55E; --warn:#F59E0B; --bad:#EF4444; --track:#EDEFF4;
}
*{box-sizing:border-box;}
body{margin:0;background:var(--bg);color:var(--text);font-family:"Segoe UI","Malgun Gothic",sans-serif;font-size:14px;}
.wrap{max-width:1360px;margin:0 auto;padding:16px;}
.topbar{display:flex;align-items:center;justify-content:space-between;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:14px 18px;margin-bottom:12px;}
.title{font-size:20px;font-weight:700;}
.subtitle{font-size:12px;color:var(--text2);margin-top:2px;}
.meta{font-size:12px;color:var(--text2);text-align:right;line-height:1.5;}
.modechip{display:inline-block;border-radius:6px;padding:2px 8px;font-size:11px;font-weight:700;color:#fff;margin-left:8px;}
.tabs{display:flex;gap:8px;margin-bottom:12px;align-items:center;}
.tab{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:9px 18px;cursor:pointer;font-weight:600;color:var(--text);}
.tab:hover{background:var(--accent-soft);}
.tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
.tab .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:7px;background:var(--good);vertical-align:middle;}
.tab .dot.off{background:var(--bad);}
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:12px;}
.card-title{font-size:13px;font-weight:600;color:var(--text2);margin-bottom:12px;}
.tiles{display:grid;grid-template-columns:repeat(7,1fr);gap:10px;}
.tile{background:var(--track);border-radius:10px;padding:12px;text-align:center;}
.tile .label{font-size:12px;color:var(--text2);}
.tile .value{font-size:20px;font-weight:700;margin-top:4px;}
.ctrl-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;}
.ctrl-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;}
.ctrl-row .lbl{width:84px;color:var(--text2);font-size:13px;}
.btn{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:7px 13px;cursor:pointer;font-size:13px;color:var(--text);}
.btn:hover{background:var(--accent-soft);}
.btn.active{background:var(--accent);color:#fff;border-color:var(--accent);}
.btn.on{background:var(--good);color:#fff;border-color:var(--good);}
.btn.off{background:var(--card);color:var(--text2);}
.btn:disabled{opacity:.4;cursor:not-allowed;}
.autocard{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;}
.autobox{background:var(--track);border-radius:10px;padding:10px;text-align:center;}
.autobox .rn{font-size:12px;color:var(--text2);}
.autobox .sc{font-size:22px;font-weight:700;}
.autobox .vol{font-size:12px;color:var(--text2);margin-top:2px;}
.rooms{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;}
.room{border:1px solid var(--border);border-radius:12px;padding:14px;}
.room-head{display:flex;align-items:center;justify-content:space-between;}
.room-name{font-size:16px;font-weight:700;}
.aq{display:flex;align-items:center;gap:6px;font-weight:600;font-size:13px;}
.aq .led{width:14px;height:14px;border-radius:50%;}
.sensors{display:grid;grid-template-columns:1fr 1fr;gap:6px 12px;margin-top:12px;}
.sensor{display:flex;justify-content:space-between;}
.sensor .k{color:var(--text2);}
.sensor .v{font-weight:700;}
.ledrow{display:flex;justify-content:space-between;font-size:12px;color:var(--text2);margin-top:10px;}
input[type=range]{width:100%;}
.vsp-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;}
.vsp-row{display:flex;align-items:center;gap:6px;background:var(--track);border-radius:8px;padding:8px 10px;}
.vsp-row .vl{width:60px;font-weight:600;font-size:13px;}
.vsp-row .u{color:var(--text2);font-size:12px;}
.vsp-row input{width:56px;border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:13px;text-align:right;}
.hyst-grid{display:grid;grid-template-columns:90px repeat(4,1fr);gap:6px 10px;align-items:center;max-width:620px;}
.hyst-grid .hh{font-size:12px;color:var(--text2);font-weight:600;text-align:center;}
.hyst-grid .pl{font-weight:700;font-size:13px;}
.hyst-grid input{width:100%;border:1px solid var(--border);border-radius:6px;padding:5px 6px;font-size:13px;text-align:right;}
.thr-grid{display:grid;grid-template-columns:70px repeat(4,1fr);gap:5px 8px;align-items:center;margin-top:8px;}
.thr-grid .hh{font-size:11px;color:var(--text2);font-weight:600;text-align:center;}
.thr-grid .pl{font-weight:700;font-size:12px;}
.thr-grid input{width:100%;border:1px solid var(--border);border-radius:6px;padding:4px 5px;font-size:12px;text-align:right;}
.modal-bg{position:fixed;inset:0;background:rgba(15,23,42,.4);display:none;align-items:flex-start;justify-content:center;z-index:100;}
.modal-bg.show{display:flex;}
.modal{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:18px;margin-top:48px;width:92%;max-width:760px;max-height:86vh;overflow:auto;box-shadow:0 12px 40px rgba(0,0,0,.22);}
.modal.modal-wide{max-width:1180px;}
.modal-h{display:flex;justify-content:space-between;align-items:center;font-size:15px;font-weight:700;margin-bottom:14px;}
.footer{font-size:12px;color:var(--text2);text-align:center;margin-top:8px;}
.tokenbox{font-size:12px;border:1px solid var(--border);border-radius:8px;padding:6px 8px;width:130px;}
/* 그래프 */
.graph-wrap{display:grid;grid-template-columns:200px 1fr;gap:12px;}
.graph-side{border:1px solid var(--border);border-radius:8px;padding:8px;max-height:420px;overflow:auto;}
.graph-side .grp{font-weight:700;font-size:12px;margin:8px 0 3px;}
.graph-side label{display:block;font-size:12px;padding:2px 0;cursor:pointer;}
.graph-side label input{margin-right:5px;}
.graph-main canvas{width:100%;height:380px;display:block;border:1px solid var(--border);border-radius:8px;}
.glegend{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;color:var(--text2);margin-top:6px;}
.glegend span{display:inline-flex;align-items:center;gap:4px;}
.glegend i{width:11px;height:3px;border-radius:2px;display:inline-block;}
.gtoolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;}
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<div>
<div class="title">HuevenEco DL 각실제어 모니터링·제어 <span class="modechip" id="modeChip"></span></div>
<div class="subtitle">EW11(RS-485↔WiFi) ↔ 미니PC 수집/제어 서버 · PC 대시보드 기능 통합</div>
</div>
<div style="display:flex;align-items:center;gap:12px;">
<input class="tokenbox" id="tokenBox" placeholder="제어 토큰(선택)">
<button class="btn" id="demoBtn" style="display:none">데모 일시정지</button>
<div class="meta">만든이 : 전경선<br>만든날 : 2026.06.3</div>
</div>
</div>
<div class="tabs" id="tabs"></div>
<div class="card">
<div class="card-title">현장 개요</div>
<div class="tiles" id="tiles"></div>
</div>
<!-- ===== 제어 ===== -->
<div class="card">
<div class="card-title">ERV 제어 (원격)</div>
<div class="ctrl-grid">
<div>
<div class="ctrl-row"><span class="lbl">전원</span><button class="btn" id="cPower" onclick="togglePower()">OFF</button>
<span class="lbl" style="width:auto;margin-left:8px;">ERV 리셋</span><button class="btn" id="cReset" onclick="toggleReset()">OFF</button></div>
<div class="ctrl-row"><span class="lbl">운전모드</span><span id="cModes"></span></div>
<div class="ctrl-row"><span class="lbl">풍량</span><span id="cFans"></span>
<span class="lbl" style="width:auto;margin-left:8px;">자동 프리셋</span><span id="cPresetsMain"></span></div>
<div class="ctrl-row"><span class="lbl">(꺼짐)예약</span>
<select id="cReserve" class="btn" onchange="setReserve(this.value)">
<option value="0">해제</option><option value="1">1시간</option><option value="2">2시간</option>
<option value="3">3시간</option><option value="4">4시간</option><option value="5">5시간</option>
<option value="6">6시간</option><option value="7">7시간</option><option value="8">8시간</option>
</select>
<span id="cReserveTxt" style="color:var(--text2);margin-left:6px;font-size:12px;"></span></div>
</div>
<div>
<div class="ctrl-row"><span class="lbl">부가모드</span><span id="cSubs"></span></div>
<div class="ctrl-row"><span class="lbl">연동후드</span><button class="btn" id="cHood" onclick="toggleHood()">OFF</button>
<span id="cHoodConn" style="font-size:12px;margin-left:8px;"></span></div>
<div class="ctrl-row"><span class="lbl">스마트수면<br>시간설정</span>
<input type="time" id="slStart" class="tokenbox" style="width:110px" value="23:00">~
<input type="time" id="slEnd" class="tokenbox" style="width:110px" value="07:00">
<label style="font-size:12px;display:flex;align-items:center;gap:4px;"><input type="checkbox" id="slEnable" onchange="renderSleep()">예약</label>
<span id="slTxt" style="font-size:12px;color:var(--text2);"></span></div>
<div class="ctrl-row"><span class="lbl">설정</span>
<button class="btn" onclick="openModal('hyst')">공기질 히스테리시스 ▸</button>
<button class="btn" onclick="openModal('vsp')">풍량 VSP ▸</button>
</div>
</div>
</div>
</div>
<!-- ===== 자동운전 상태 ===== -->
<div class="card">
<div class="card-title">자동운전 상태 (표시 전용) <span id="autoSummary" style="font-weight:400;margin-left:8px;"></span></div>
<div class="autocard" id="autoCard"></div>
</div>
<div class="card">
<div class="card-title">각실 모니터링 · 제어</div>
<div class="rooms" id="rooms"></div>
</div>
<!-- ===== 로그 그래프 (별도 창) ===== -->
<div class="card">
<div class="card-title">로그 그래프</div>
<button class="btn" onclick="openModal('graph')">📂 로그 그래프 — 날짜별 불러오기 ▸</button>
<span style="color:var(--text2);font-size:12px;margin-left:8px;">날짜 선택 → 불러오기 → 좌측 시리즈 선택/해제 → (필요 시) 엑셀 저장</span>
</div>
<div class="footer" id="footNote"></div>
<!-- ===== 팝업: 공기질 히스테리시스 / 풍량 VSP ===== -->
<div class="modal-bg" id="modalBg" onclick="closeModalBg(event)">
<div class="modal" id="modalHyst" style="display:none">
<div class="modal-h"><span>공기질 히스테리시스 (ECO / NORMAL / TURBO)</span>
<button class="btn" onclick="closeModal()">닫기</button></div>
<div class="ctrl-row"><span class="lbl">프리셋</span><span id="cPresets"></span></div>
<div style="font-weight:700;font-size:13px;margin:6px 0 2px">데드밴드(하강)</div>
<div id="hystGrid"></div>
<button class="btn" style="margin-top:10px" onclick="applyHyst()">데드밴드 변경</button>
<div style="font-weight:700;font-size:13px;margin:16px 0 2px">오염단계 임계 (L1~L4 상한)</div>
<div id="thrGrid"></div>
<button class="btn" style="margin-top:10px" onclick="applyThr()">임계 변경</button>
</div>
<div class="modal" id="modalVsp" style="display:none">
<div class="modal-h"><span>풍량 VSP 제어 · 상태 (SA 급기 / EA 배기)</span>
<button class="btn" onclick="closeModal()">닫기</button></div>
<div class="vsp-grid" id="vspGrid"></div>
</div>
<div class="modal modal-wide" id="modalGraph" style="display:none">
<div class="modal-h"><span>로그 그래프 (가로 시간 · 세로 댐퍼/센서/모드)</span>
<button class="btn" onclick="closeModal()">닫기</button></div>
<div class="gtoolbar">
<span style="color:var(--text2)">날짜</span>
<input type="date" id="gDate" class="tokenbox" style="width:150px">
<button class="btn" onclick="loadGraphClick()">📂 불러오기</button>
<button class="btn" onclick="gSelectAll(true)">전체선택</button>
<button class="btn" onclick="gSelectAll(false)">전체해제</button>
<button class="btn" onclick="exportCsv()">📊 엑셀(CSV) 저장</button>
<span id="gCount" style="color:var(--text2);font-size:12px"></span>
</div>
<div class="graph-wrap">
<div class="graph-side" id="gSide"></div>
<div class="graph-main">
<canvas id="gChart" width="1000" height="380"></canvas>
<div class="glegend" id="gLegend"></div>
</div>
</div>
</div>
</div>
</div>
<script>
const MODE = location.protocol.startsWith("http") ? "server" : "demo";
const SITES = ["site01","site02","site03"];
const SITE_LABEL = {site01:"현장1",site02:"현장2",site03:"현장3"};
const ROOMS = ["거실","침실1","침실2","침실3"];
const ROOM_COLOR = ["#3B82F6","#22C55E","#F59E0B","#8B5CF6"];
const RUNMODE = {0:"OFF",1:"환기",2:"자동",3:"공청",4:"바이패스"};
const MODE_BTNS = [[1,"환기"],[2,"자동"],[3,"공청"],[4,"바이패스"]];
const SUB_BTNS = [[1,"수면",1],[2,"조리",2],[3,"회복",4]];
const PRESET_BTNS = [[0,"ECO"],[1,"NORMAL"],[2,"TURBO"]];
const AUTOSTATE = {0:"분산",1:"집중"};
const PRESET = {0:"ECO",1:"NORMAL",2:"TURBO"};
const AQ = {1:{t:"매우나쁨",c:"#EF4444"},2:{t:"나쁨",c:"#F59E0B"},3:{t:"보통",c:"#22C55E"},4:{t:"좋음",c:"#3B82F6"}};
const VSP_LABELS = ["환기1","환기2","환기3","환기4","바이패스","공청1","공청2","공청3","공청4"];
const VSP_GROUP = [0,0,0,0,1,2,2,2,2];
const VSP_INDEX = [1,2,3,4,1,1,2,3,4];
const VSP_DEMO = [[56,57],[63,63],[70,70],[86,85],[67,75],[65,0],[72,0],[78,0],[80,0]];
const HYST_PRESETS = ["ECO","NORMAL","TURBO"];
const HYST_FIELDS = ["pm25","pm10","voc","co2"];
const POLL = ["co2","pm25","pm10","voc"]; // 임계 오염원 순서(서버 thr 키)
const HYST_DEMO = [{pm25:20,pm10:40,voc:250,co2:600},{pm25:30,pm10:50,voc:300,co2:700},{pm25:40,pm10:70,voc:400,co2:900}];
const THR_DEMO = [
{co2:[1000,1300,1600,2000],pm25:[20,38,60,86],pm10:[40,86,126,173],voc:[171,195,308,438]},
{co2:[800,1100,1400,1700],pm25:[14,29,49,69],pm10:[28,66,102,138],voc:[120,150,250,350]},
{co2:[700,1000,1300,1600],pm25:[12,23,38,52],pm10:[24,53,78,104],voc:[103,120,192,263]}];
const HIST=60;
let current="site01", demoOn=true, tick=0;
const state={};
SITES.forEach((s,si)=>{ state[s]={
online:false,
g:{power:1,run_mode:2,auto_state:0,fan_mode:2,sub_mode:0,hood:0,hyst_preset:1,error_code:0,reset:0,reserve_remain:0},
rooms:Array.from({length:4},()=>({damper_sa:0,damper_ea:0,pm25:0,pm10:0,voc:0,co2:0,air_quality:3,led_dim:0,load_score:0,final_volume:0,temp:0,humi:0})),
vsp:VSP_DEMO.map(([sa,ea])=>({sa,ea})),
hyst:HYST_DEMO.map(h=>({...h})),
thr:THR_DEMO.map(t=>({co2:[...t.co2],pm25:[...t.pm25],pm10:[...t.pm10],voc:[...t.voc]})),
seed:si*100 }; });
// ===== 데모 생성기 =====
function genSensors(s,t){
const st=state[s]; st.online=true;
st.g.auto_state=Math.floor(t/8)%2;
for(let r=0;r<4;r++){
const seed=t+r*13+st.seed, rm=st.rooms[r];
rm.pm25=10+(seed*3+Math.round(8*Math.sin(t/6+r)))%60;
rm.pm10=15+(seed*5)%90; rm.voc=100+(seed*7)%400;
rm.co2=450+(seed*11)%700+Math.round(60*Math.sin(t/5+r));
rm.air_quality=1+(seed%4); rm.load_score=(seed*4)%5; rm.final_volume=seed%5;
rm.temp=22+(seed)%6; rm.humi=40+(seed*7)%30;
}
}
// ===== 제어 전송 =====
function applyLocal(site,action,a){
const st=state[site],g=st.g;
if(action==="power")g.power=a.value;
else if(action==="runmode")g.run_mode=a.value;
else if(action==="fan")g.fan_mode=a.value;
else if(action==="hood")g.hood=a.value?(g.hood|1):(g.hood&~1);
else if(action==="preset")g.hyst_preset=a.value;
else if(action==="submode"){const bit=a.type===1?1:a.type===2?2:4;g.sub_mode=a.value?(g.sub_mode|bit):(g.sub_mode&~bit);}
else if(action==="damper")st.rooms[a.room-1][a.type===1?"damper_ea":"damper_sa"]=a.value;
else if(action==="led")st.rooms[a.room-1].led_dim=a.value;
else if(action==="reset")g.reset=a.value;
else if(action==="reserve")g.reserve_remain=a.value*3600;
else if(action==="vsp")st.vsp[a._idx]={sa:a.sa,ea:a.ea};
else if(action==="hyst")st.hyst[a.preset]={pm25:a.pm25,pm10:a.pm10,voc:a.voc,co2:a.co2};
else if(action==="hystthr")st.thr[a.preset][a._poll]=[a.l1,a.l2,a.l3,a.l4];
}
async function ctl(action,a={}){
applyLocal(current,action,a);
renderTiles(); renderControls(); renderAuto(); renderHyst(); renderThr(); renderVsp(); renderRooms();
if(MODE==="server"){
try{
const r=await fetch("/api/control",{method:"POST",
headers:{"Content-Type":"application/json","X-Auth-Token":document.getElementById("tokenBox").value||""},
body:JSON.stringify(Object.assign({site:current,action},a))});
if(!r.ok) flash(`제어 실패 (${r.status})`);
}catch(e){ flash("제어 전송 오류: "+e.message); }
}
}
function flash(msg){ const f=document.getElementById("footNote"); f.textContent=msg; f.style.color="var(--bad)"; setTimeout(setFoot,2500); }
// ===== 제어 핸들러 =====
function togglePower(){ ctl("power",{value:state[current].g.power?0:1}); }
function toggleHood(){ ctl("hood",{value:(state[current].g.hood&1)?0:1}); }
function setMode(m){ ctl("runmode",{value:m}); }
function setFan(s){ const g=state[current].g; if(g.run_mode===2)return; if(g.run_mode===4&&s>1)return; ctl("fan",{value:s}); }
function toggleSub(type,bit){
const g=state[current].g, orig=g.sub_mode, on=(orig&bit)?0:1;
if(on){
if(type!==1 && (orig&1)) ctl("submode",{type:1,value:0});
if(type!==2 && (orig&2)) ctl("submode",{type:2,value:0});
if(type!==3 && (orig&4)) ctl("submode",{type:3,value:0});
}
ctl("submode",{type,value:on});
}
function setPreset(p){ ctl("preset",{value:p}); }
function setDamperSa(room){ if(state[current].g.run_mode===2)return; const cur=state[current].rooms[room-1].damper_sa; ctl("damper",{room,type:0,value:cur?0:1}); }
function setDamperEa(room){ if(state[current].g.run_mode===2)return; const cur=state[current].rooms[room-1].damper_ea; ctl("damper",{room,type:1,value:cur?0:1}); }
function setLed(room,val){ ctl("led",{room,value:parseInt(val)}); }
function setReserve(h){ ctl("reserve",{value:parseInt(h)}); }
function toggleReset(){ ctl("reset",{value:state[current].g.reset?0:1}); }
function setHyst(pi,field,val){ state[current].hyst[pi][field]=parseInt(val)||0; }
function applyHyst(){ const st=state[current];
for(let pi=0;pi<3;pi++){ const v=st.hyst[pi]; ctl("hyst",{preset:pi,pm25:v.pm25,pm10:v.pm10,voc:v.voc,co2:v.co2}); } }
function setThr(pi,poll,li,val){ state[current].thr[pi][poll][li]=parseInt(val)||0; }
function applyThr(){ const st=state[current];
for(let pi=0;pi<3;pi++) POLL.forEach((poll,pp)=>{ const v=st.thr[pi][poll];
ctl("hystthr",{preset:pi,pollutant:pp,l1:v[0],l2:v[1],l3:v[2],l4:v[3],_poll:poll}); }); }
// ===== 팝업 =====
function openModal(which){
document.getElementById("modalHyst").style.display = which==="hyst"?"block":"none";
document.getElementById("modalVsp").style.display = which==="vsp"?"block":"none";
document.getElementById("modalGraph").style.display = which==="graph"?"block":"none";
document.getElementById("modalBg").classList.add("show");
renderControls(); renderHyst(); renderThr(); renderVsp();
if(which==="graph"){ renderSide(); loadGraph(); } // 열 때 현재 로드날짜로 갱신
}
function graphOpen(){ return document.getElementById("modalBg").classList.contains("show")
&& document.getElementById("modalGraph").style.display==="block"; }
function closeModal(){ document.getElementById("modalBg").classList.remove("show"); }
function closeModalBg(e){ if(e.target.id==="modalBg") closeModal(); }
function setVsp(i,field,val){
const v=state[current].vsp[i];
const sa=field==="sa"?(parseInt(val)||0):v.sa, ea=field==="ea"?(parseInt(val)||0):v.ea;
ctl("vsp",{group:VSP_GROUP[i],index:VSP_INDEX[i],sa,ea,_idx:i});
}
// ===== 스마트수면 시간설정 (브라우저 스케줄, 현재 현장에 적용) =====
let _slLast=-1;
function hm2min(v){ const [h,m]=(v||"0:0").split(":").map(Number); return (h*60+m)||0; }
function renderSleep(){
const en=document.getElementById("slEnable").checked;
const s=document.getElementById("slStart").value, e=document.getElementById("slEnd").value;
document.getElementById("slTxt").textContent = en?`예약 ON (${s}~${e}, 현재 현장)`:"예약 OFF";
}
function sleepTick(){
if(!document.getElementById("slEnable").checked) return;
const now=new Date(), cur=now.getHours()*60+now.getMinutes();
const s=hm2min(document.getElementById("slStart").value), e=hm2min(document.getElementById("slEnd").value);
const inWin = s<e ? (cur>=s&&cur<e) : (cur>=s||cur<e); // 자정 넘김 지원
const want = inWin?1:0;
if(want!==_slLast){ _slLast=want; toggleSubForce(1, want); }
}
function toggleSubForce(type,on){ // 스케줄용: 상태와 무관하게 지정값 전송
if(on){ const g=state[current].g; if(g.sub_mode&2) ctl("submode",{type:2,value:0}); if(g.sub_mode&4) ctl("submode",{type:3,value:0}); }
ctl("submode",{type,value:on});
}
// ===== 렌더 =====
function renderTabs(){
const el=document.getElementById("tabs"); el.innerHTML="";
SITES.forEach(s=>{ const d=document.createElement("div");
d.className="tab"+(s===current?" active":"");
d.innerHTML=`<span class="dot ${state[s].online?"":"off"}"></span>${SITE_LABEL[s]}`;
d.onclick=()=>{current=s;renderAll();if(graphOpen())loadGraph();}; el.appendChild(d); });
}
function tile(label,value,color){return `<div class="tile"><div class="label">${label}</div><div class="value" ${color?`style="color:${color}"`:""}>${value}</div></div>`;}
function renderTiles(){
const g=state[current].g;
const err=g.error_code===0?"정상":("0x"+g.error_code.toString(16).toUpperCase().padStart(4,"0"));
document.getElementById("tiles").innerHTML=
tile("전원",g.power?"ON":"OFF",g.power?"var(--good)":"var(--text2)")+
tile("운전모드",RUNMODE[g.run_mode])+
tile("풍량",g.run_mode===2?`자동(${g.fan_mode})`:g.fan_mode)+
tile("자동상태",AUTOSTATE[g.auto_state])+
tile("연동후드",(g.hood&1)?"ON":"OFF",(g.hood&1)?"var(--good)":"var(--text2)")+
tile("히스테리시스",PRESET[g.hyst_preset])+
tile("에러",err,g.error_code?"var(--bad)":"var(--text2)");
}
function renderControls(){
const g=state[current].g;
const p=document.getElementById("cPower"); p.textContent=g.power?"ON":"OFF"; p.className="btn "+(g.power?"on":"off");
const rs=document.getElementById("cReset"); rs.textContent=g.reset?"ON":"OFF"; rs.className="btn "+(g.reset?"on":"off");
const h=document.getElementById("cHood"); h.textContent=(g.hood&1)?"ON":"OFF"; h.className="btn "+((g.hood&1)?"on":"off");
const hc=document.getElementById("cHoodConn");
if(g.hood&1){ const conn=(g.hood&4); hc.textContent=conn?"후드 연결":"후드 연결 안됨"; hc.style.color=conn?"var(--good)":"var(--bad)"; }
else hc.textContent="";
document.getElementById("cModes").innerHTML=MODE_BTNS.map(([m,l])=>
`<button class="btn ${g.run_mode===m?"active":""}" onclick="setMode(${m})">${l}</button>`).join("");
const fanMax = g.run_mode===4?1:4;
document.getElementById("cFans").innerHTML=[0,1,2,3,4].map(s=>
`<button class="btn ${g.fan_mode===s?"active":""}" ${(g.run_mode===2||s>fanMax)?"disabled":""} onclick="setFan(${s})">${s}</button>`).join("");
const presetMain=document.getElementById("cPresetsMain");
if(presetMain) presetMain.innerHTML=PRESET_BTNS.map(([v,l])=>
`<button class="btn ${g.hyst_preset===v?"active":""}" ${g.run_mode!==2?"disabled":""} onclick="setPreset(${v})">${l}</button>`).join("");
const sec=g.reserve_remain||0, rsv=document.getElementById("cReserve"), rt=document.getElementById("cReserveTxt");
if(rt) rt.textContent = sec>0 ? `꺼짐까지 ${Math.floor(sec/3600)}:${String(Math.floor(sec%3600/60)).padStart(2,"0")}:${String(sec%60).padStart(2,"0")}` : "예약 없음";
if(rsv && sec===0 && rsv.value!=="0") rsv.value="0";
document.getElementById("cSubs").innerHTML=SUB_BTNS.map(([t,l,bit])=>
`<button class="btn ${(g.sub_mode&bit)?"active":""}" onclick="toggleSub(${t},${bit})">${l}</button>`).join("");
const cp=document.getElementById("cPresets");
if(cp) cp.innerHTML=PRESET_BTNS.map(([v,l])=>
`<button class="btn ${g.hyst_preset===v?"active":""}" onclick="setPreset(${v})">${l}</button>`).join("");
}
function renderAuto(){
const st=state[current], g=st.g;
const total=st.rooms.reduce((a,rm)=>a+(rm.load_score||0),0);
document.getElementById("autoSummary").innerHTML = g.run_mode===2
? `동작: <b>${AUTOSTATE[g.auto_state]}</b> · 합산부하 ${total} · ${g.fan_mode}`
: "(자동모드 아님)";
document.getElementById("autoCard").innerHTML = st.rooms.map((rm,i)=>
`<div class="autobox"><div class="rn">${ROOMS[i]}</div><div class="sc">${rm.load_score||0}</div><div class="vol">풍량 ${rm.final_volume||0}</div></div>`).join("");
}
function renderRooms(){
const st=state[current], el=document.getElementById("rooms"); el.innerHTML="";
const isAuto = st.g.run_mode===2;
st.rooms.forEach((rm,i)=>{ const aq=AQ[rm.air_quality]||AQ[3]; const room=i+1;
el.insertAdjacentHTML("beforeend",`
<div class="room">
<div class="room-head"><div class="room-name">${ROOMS[i]}</div>
<div class="aq"><span class="led" style="background:${aq.c}"></span>${aq.t}</div></div>
<div class="ctrl-row" style="margin-top:10px;">
<span class="lbl" style="width:auto">급기댐퍼</span>
<button class="btn ${rm.damper_sa?"on":"off"}" ${isAuto?"disabled":""} onclick="setDamperSa(${room})">${rm.damper_sa?"열림":"닫힘"}</button>
<span class="lbl" style="width:auto;margin-left:10px;">배기댐퍼</span>
<button class="btn ${rm.damper_ea?"on":"off"}" ${isAuto?"disabled":""} onclick="setDamperEa(${room})">${rm.damper_ea?"열림":"닫힘"}</button>
</div>
<div class="sensors">
<div class="sensor"><span class="k">PM2.5</span><span class="v">${rm.pm25}</span></div>
<div class="sensor"><span class="k">PM10</span><span class="v">${rm.pm10}</span></div>
<div class="sensor"><span class="k">VOC</span><span class="v">${rm.voc}</span></div>
<div class="sensor"><span class="k">CO2</span><span class="v">${rm.co2}</span></div>
<div class="sensor"><span class="k">TEMP</span><span class="v">${rm.temp}℃</span></div>
<div class="sensor"><span class="k">HUMI.</span><span class="v">${rm.humi}%</span></div>
</div>
<div class="ledrow"><span>LED 디밍 (모든 모드)</span><span>${rm.led_dim} / 9 · 풍량 ${rm.final_volume} · 부하 ${rm.load_score}</span></div>
<input type="range" min="0" max="9" value="${rm.led_dim}" oninput="setLed(${room},this.value)">
</div>`);
});
}
function renderHyst(){
const grid=document.getElementById("hystGrid");
if(document.activeElement && grid.contains(document.activeElement)) return;
const st=state[current];
let h='<div class="hyst-grid"><span class="hh"></span><span class="hh">PM2.5</span><span class="hh">PM10</span><span class="hh">VOC</span><span class="hh">CO2</span>';
HYST_PRESETS.forEach((name,pi)=>{ const v=(st.hyst&&st.hyst[pi])||{pm25:0,pm10:0,voc:0,co2:0};
h+=`<span class="pl">${name}</span>`+HYST_FIELDS.map(f=>`<input type="number" min="0" value="${v[f]}" onchange="setHyst(${pi},'${f}',this.value)">`).join(""); });
grid.innerHTML=h+"</div>";
}
function renderThr(){
const grid=document.getElementById("thrGrid");
if(document.activeElement && grid.contains(document.activeElement)) return;
const st=state[current];
let h='<div class="thr-grid"><span class="hh"></span><span class="hh">L1</span><span class="hh">L2</span><span class="hh">L3</span><span class="hh">L4</span>';
HYST_PRESETS.forEach((name,pi)=>{
POLL.forEach(poll=>{ const v=(st.thr&&st.thr[pi]&&st.thr[pi][poll])||[0,0,0,0];
h+=`<span class="pl">${name}·${poll.toUpperCase()}</span>`+
[0,1,2,3].map(li=>`<input type="number" min="0" value="${v[li]}" onchange="setThr(${pi},'${poll}',${li},this.value)">`).join("");
});
});
grid.innerHTML=h+"</div>";
}
function renderVsp(){
const grid=document.getElementById("vspGrid");
if(document.activeElement && grid.contains(document.activeElement)) return;
const st=state[current];
grid.innerHTML = VSP_LABELS.map((lab,i)=>{ const v=(st.vsp&&st.vsp[i])||{sa:0,ea:0};
return `<div class="vsp-row"><span class="vl">${lab}</span>`+
`<span class="u">SA</span><input type="number" min="0" value="${v.sa}" onchange="setVsp(${i},'sa',this.value)">`+
`<span class="u">EA</span><input type="number" min="0" value="${v.ea}" onchange="setVsp(${i},'ea',this.value)"></div>`;
}).join("");
}
function renderAll(){renderTabs();renderTiles();renderControls();renderAuto();renderHyst();renderThr();renderVsp();renderRooms();renderSleep();}
// ===== 로그 그래프 =====
let GDEF=[], GON=[], GDATA=[], _loadedDate=todayStr();
function buildDefs(){
const c=["#3B82F6","#EF4444","#22C55E","#8B5CF6","#F59E0B","#06B6D4","#EC4899","#64748B","#84CC16"];
const defs=[
{g:"운전",l:"운전모드",f:s=>s.run_mode},
{g:"운전",l:"자동-집중",f:s=>s.auto_mode===1?1:0},
{g:"운전",l:"자동-분산",f:s=>s.auto_mode===2?1:0},
{g:"운전",l:"프리셋-ECO",f:s=>s.hyst_preset===0?1:0},
{g:"운전",l:"프리셋-NORMAL",f:s=>s.hyst_preset===1?1:0},
{g:"운전",l:"프리셋-TURBO",f:s=>s.hyst_preset===2?1:0},
{g:"운전",l:"풍량",f:s=>s.fan_mode},
{g:"운전",l:"전원",f:s=>s.power},
{g:"시나리오",l:"스마트수면",f:s=>s.sleep},
{g:"시나리오",l:"쾌적조리",f:s=>s.cook},
{g:"시나리오",l:"안심회복",f:s=>s.recover},
];
ROOMS.forEach((nm,r)=>{
[["CO2","co2"],["PM2.5","pm25"],["PM10","pm10"],["VOC","voc"],["온도","temp"],["습도","humi"],["LED","led"],["부하","level"],
["급기댐퍼","damper_sa"],["배기댐퍼","damper_ra"]].forEach(([lab,key])=>{
defs.push({g:nm,l:`${nm} ${lab}`,f:s=>(s.rooms[r]?s.rooms[r][key]:0)});
});
});
defs.forEach((d,i)=>d.color=c[i%c.length]);
GDEF=defs; GON=defs.map(_=>true);
}
function renderSide(){
const el=document.getElementById("gSide"); let h=""; let grp=null;
GDEF.forEach((d,i)=>{ if(d.g!==grp){grp=d.g; h+=`<div class="grp">${grp}</div>`;}
const lab=d.l.startsWith(d.g+" ")?d.l.slice(d.g.length+1):d.l;
h+=`<label><input type="checkbox" ${GON[i]?"checked":""} onchange="GON[${i}]=this.checked;drawGraph()">${lab}</label>`; });
el.innerHTML=h;
}
function gSelectAll(v){ GON=GON.map(_=>v); renderSide(); drawGraph(); }
// "불러오기" 클릭 : 선택한 날짜를 확정 로드. 오늘이면 이후 실시간 갱신, 과거면 정적 유지.
function loadGraphClick(){ _loadedDate = document.getElementById("gDate").value || todayStr(); loadGraph(); }
async function loadGraph(){
const dt=_loadedDate;
if(MODE==="server"){
try{ GDATA=await (await fetch(`/api/history?site=${current}&date=${dt}`)).json(); }
catch(e){ GDATA=[]; }
}else{ GDATA=demoHistory(dt); }
const live = dt===todayStr();
document.getElementById("gCount").textContent=`${dt} · ${GDATA.length}개 (5초)${live?" · 실시간":" · 과거(정적)"}`;
drawGraph();
}
function drawGraph(){
const cv=document.getElementById("gChart"),ctx=cv.getContext("2d"),W=cv.width,H=cv.height,pad=34;
ctx.clearRect(0,0,W,H);
const on=GDEF.map((d,i)=>GON[i]?i:-1).filter(i=>i>=0);
let max=-Infinity,min=Infinity;
GDATA.forEach(s=>on.forEach(i=>{const v=GDEF[i].f(s); if(v>max)max=v; if(v<min)min=v;}));
if(!isFinite(max)){max=1;min=0;} if(max===min)max=min+1; const range=max-min;
ctx.strokeStyle="#EDEFF4";ctx.fillStyle="#8A93A6";ctx.font="10px sans-serif";ctx.lineWidth=1;
for(let g=0;g<=4;g++){const y=pad+(H-2*pad)*g/4;ctx.beginPath();ctx.moveTo(pad,y);ctx.lineTo(W-6,y);ctx.stroke();
ctx.fillText(Math.round(max-range*g/4),2,y+3);}
const n=GDATA.length;
if(n>1) for(let g=0;g<=4;g++){const idx=Math.round((n-1)*g/4);const x=pad+(W-pad-6)*(idx/(n-1));
ctx.fillText(GDATA[idx].t,x-18,H-8);}
on.forEach(i=>{ const d=GDEF[i]; ctx.strokeStyle=d.color;ctx.lineWidth=1.6;ctx.beginPath();
GDATA.forEach((s,k)=>{const x=pad+(W-pad-6)*(n>1?k/(n-1):0),y=pad+(H-2*pad)*(1-(d.f(s)-min)/range);k?ctx.lineTo(x,y):ctx.moveTo(x,y);});ctx.stroke(); });
document.getElementById("gLegend").innerHTML=on.map(i=>`<span><i style="background:${GDEF[i].color}"></i>${GDEF[i].l}</span>`).join("");
}
function exportCsv(){
if(!GDATA.length){ flash("저장할 데이터가 없습니다."); return; }
const head=["날짜","시간","전원","운전모드","자동상태","풍량","스마트수면","쾌적조리","안심회복","프리셋"];
ROOMS.forEach(nm=>head.push(`${nm}_급기댐퍼`,`${nm}_배기댐퍼`,`${nm}_CO2`,`${nm}_PM2.5`,`${nm}_PM10`,`${nm}_VOC`,`${nm}_온도`,`${nm}_습도`,`${nm}_LED`,`${nm}_부하`));
const mn=["OFF","환기","자동","공청","바이패스"], an=["","집중","분산"], pn=["ECO","NORMAL","TURBO"];
const lines=[head.join(",")];
GDATA.forEach(s=>{ const row=[s.date,s.t,s.power,mn[s.run_mode]||s.run_mode,an[s.auto_mode]||"",s.fan_mode,s.sleep,s.cook,s.recover,pn[s.hyst_preset]||s.hyst_preset];
s.rooms.forEach(rm=>row.push(rm.damper_sa,rm.damper_ra,rm.co2,rm.pm25,rm.pm10,rm.voc,rm.temp,rm.humi,rm.led,rm.level));
lines.push(row.join(",")); });
const blob=new Blob([""+lines.join("\r\n")],{type:"text/csv;charset=utf-8"});
const a=document.createElement("a"); a.href=URL.createObjectURL(blob);
a.download=`HERV_${current}_${_loadedDate}.csv`; a.click();
}
function todayStr(){ const d=new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; }
function demoHistory(){ const arr=[]; for(let k=0;k<120;k++){ const t=k*5; const hh=String(Math.floor(t/3600)).padStart(2,"0"),mm=String(Math.floor(t%3600/60)).padStart(2,"0"),ss=String(t%60).padStart(2,"0");
arr.push({t:`${hh}:${mm}:${ss}`,date:todayStr(),power:1,run_mode:2,auto_mode:(k%16<8?2:1),fan_mode:k%5,sleep:0,cook:0,recover:0,hyst_preset:1,
rooms:Array.from({length:4},(_,r)=>({damper_sa:k%2,damper_ra:(k+1)%2,co2:500+(k*7+r*50)%900,pm25:10+(k*3+r*5)%60,pm10:15+(k*5)%90,voc:100+(k*7)%400,temp:23+r,humi:45+r*3,led:9,level:(k+r)%5}))}); }
return arr; }
// ===== 서버 폴링 =====
async function poll(){
try{
const j=await (await fetch("/api/latest")).json();
for(const s of SITES){ const d=j[s]; if(!d)continue; state[s].online=d.online;
if(d.g){ state[s].g=d.g; state[s].rooms=d.rooms; if(d.vsp)state[s].vsp=d.vsp; if(d.hyst)state[s].hyst=d.hyst; if(d.thr)state[s].thr=d.thr; } }
renderAll();
}catch(e){}
}
function setFoot(){
const f=document.getElementById("footNote"); f.style.color="var(--text2)";
f.textContent = MODE==="server"
? "서버 연동 모드: /api/latest 폴링 · 제어는 /api/control · 그래프는 /api/history(SQLite 누적) 로 동작합니다."
: "데모 모드(파일 직접 열기): 제어/그래프는 화면에만 반영됩니다. 실제는 미니PC 서버(http) 접속 시 동작.";
}
// ===== 시작 =====
buildDefs(); renderSide(); setFoot();
document.getElementById("gDate").value=todayStr();
document.getElementById("slStart").onchange=renderSleep; document.getElementById("slEnd").onchange=renderSleep;
const chip=document.getElementById("modeChip");
if(MODE==="server"){
chip.textContent="서버연동"; chip.style.background="var(--good)";
poll(); setInterval(poll,1000);
// 그래프 창이 열려있고 오늘을 보고 있을 때만 실시간 갱신
setInterval(()=>{ if(graphOpen() && _loadedDate===todayStr()) loadGraph(); },10000);
setInterval(sleepTick,20000);
}else{
chip.textContent="데모"; chip.style.background="var(--warn)";
document.getElementById("demoBtn").style.display="";
document.getElementById("demoBtn").onclick=function(){demoOn=!demoOn;this.textContent=demoOn?"데모 일시정지":"데모 시작";};
for(let i=0;i<HIST;i++){tick++;SITES.forEach(s=>genSensors(s,tick));}
renderAll();
setInterval(()=>{ if(demoOn){tick++;SITES.forEach(s=>genSensors(s,tick));renderTiles();renderAuto();renderRooms();} },1000);
}
</script>
</body>
</html>
@@ -0,0 +1,597 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HuevenEco DL 각실제어 모니터링·제어</title>
<style>
:root{
--bg:#F4F6FB; --card:#FFFFFF; --border:#E3E7EF; --text:#1F2733; --text2:#8A93A6;
--accent:#3B82F6; --accent-soft:#E7F0FF; --good:#22C55E; --warn:#F59E0B; --bad:#EF4444; --track:#EDEFF4;
}
*{box-sizing:border-box;}
body{margin:0;background:var(--bg);color:var(--text);font-family:"Segoe UI","Malgun Gothic",sans-serif;font-size:14px;}
.wrap{max-width:1360px;margin:0 auto;padding:16px;}
.topbar{display:flex;align-items:center;justify-content:space-between;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:14px 18px;margin-bottom:12px;}
.title{font-size:20px;font-weight:700;}
.subtitle{font-size:12px;color:var(--text2);margin-top:2px;}
.meta{font-size:12px;color:var(--text2);text-align:right;line-height:1.5;}
.modechip{display:inline-block;border-radius:6px;padding:2px 8px;font-size:11px;font-weight:700;color:#fff;margin-left:8px;}
.tabs{display:flex;gap:8px;margin-bottom:12px;align-items:center;}
.tab{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:9px 18px;cursor:pointer;font-weight:600;color:var(--text);}
.tab:hover{background:var(--accent-soft);}
.tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
.tab .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:7px;background:var(--good);vertical-align:middle;}
.tab .dot.off{background:var(--bad);}
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:12px;}
.card-title{font-size:13px;font-weight:600;color:var(--text2);margin-bottom:12px;}
.tiles{display:grid;grid-template-columns:repeat(7,1fr);gap:10px;}
.tile{background:var(--track);border-radius:10px;padding:12px;text-align:center;}
.tile .label{font-size:12px;color:var(--text2);}
.tile .value{font-size:20px;font-weight:700;margin-top:4px;}
.ctrl-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;}
.ctrl-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;}
.ctrl-row .lbl{width:84px;color:var(--text2);font-size:13px;}
.btn{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:7px 13px;cursor:pointer;font-size:13px;color:var(--text);}
.btn:hover{background:var(--accent-soft);}
.btn.active{background:var(--accent);color:#fff;border-color:var(--accent);}
.btn.on{background:var(--good);color:#fff;border-color:var(--good);}
.btn.off{background:var(--card);color:var(--text2);}
.btn:disabled{opacity:.4;cursor:not-allowed;}
.autocard{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;}
.autobox{background:var(--track);border-radius:10px;padding:10px;text-align:center;}
.autobox .rn{font-size:12px;color:var(--text2);}
.autobox .sc{font-size:22px;font-weight:700;}
.autobox .vol{font-size:12px;color:var(--text2);margin-top:2px;}
.rooms{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;}
.room{border:1px solid var(--border);border-radius:12px;padding:14px;}
.room-head{display:flex;align-items:center;justify-content:space-between;}
.room-name{font-size:16px;font-weight:700;}
.aq{display:flex;align-items:center;gap:6px;font-weight:600;font-size:13px;}
.aq .led{width:14px;height:14px;border-radius:50%;}
.sensors{display:grid;grid-template-columns:1fr 1fr;gap:6px 12px;margin-top:12px;}
.sensor{display:flex;justify-content:space-between;}
.sensor .k{color:var(--text2);}
.sensor .v{font-weight:700;}
.ledrow{display:flex;justify-content:space-between;font-size:12px;color:var(--text2);margin-top:10px;}
input[type=range]{width:100%;}
.vsp-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;}
.vsp-row{display:flex;align-items:center;gap:6px;background:var(--track);border-radius:8px;padding:8px 10px;}
.vsp-row .vl{width:60px;font-weight:600;font-size:13px;}
.vsp-row .u{color:var(--text2);font-size:12px;}
.vsp-row input{width:56px;border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:13px;text-align:right;}
.hyst-grid{display:grid;grid-template-columns:90px repeat(4,1fr);gap:6px 10px;align-items:center;max-width:620px;}
.hyst-grid .hh{font-size:12px;color:var(--text2);font-weight:600;text-align:center;}
.hyst-grid .pl{font-weight:700;font-size:13px;}
.hyst-grid input{width:100%;border:1px solid var(--border);border-radius:6px;padding:5px 6px;font-size:13px;text-align:right;}
.thr-grid{display:grid;grid-template-columns:70px repeat(4,1fr);gap:5px 8px;align-items:center;margin-top:8px;}
.thr-grid .hh{font-size:11px;color:var(--text2);font-weight:600;text-align:center;}
.thr-grid .pl{font-weight:700;font-size:12px;}
.thr-grid input{width:100%;border:1px solid var(--border);border-radius:6px;padding:4px 5px;font-size:12px;text-align:right;}
.modal-bg{position:fixed;inset:0;background:rgba(15,23,42,.4);display:none;align-items:flex-start;justify-content:center;z-index:100;}
.modal-bg.show{display:flex;}
.modal{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:18px;margin-top:48px;width:92%;max-width:760px;max-height:86vh;overflow:auto;box-shadow:0 12px 40px rgba(0,0,0,.22);}
.modal.modal-wide{max-width:1180px;}
.modal-h{display:flex;justify-content:space-between;align-items:center;font-size:15px;font-weight:700;margin-bottom:14px;}
.footer{font-size:12px;color:var(--text2);text-align:center;margin-top:8px;}
.tokenbox{font-size:12px;border:1px solid var(--border);border-radius:8px;padding:6px 8px;width:130px;}
/* 그래프 */
.graph-wrap{display:grid;grid-template-columns:200px 1fr;gap:12px;}
.graph-side{border:1px solid var(--border);border-radius:8px;padding:8px;max-height:420px;overflow:auto;}
.graph-side .grp{font-weight:700;font-size:12px;margin:8px 0 3px;}
.graph-side label{display:block;font-size:12px;padding:2px 0;cursor:pointer;}
.graph-side label input{margin-right:5px;}
.graph-main canvas{width:100%;height:380px;display:block;border:1px solid var(--border);border-radius:8px;}
.glegend{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;color:var(--text2);margin-top:6px;}
.glegend span{display:inline-flex;align-items:center;gap:4px;}
.glegend i{width:11px;height:3px;border-radius:2px;display:inline-block;}
.gtoolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;}
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<div>
<div class="title">HuevenEco DL 각실제어 모니터링·제어 <span class="modechip" id="modeChip"></span></div>
<div class="subtitle">EW11(RS-485↔WiFi) ↔ 미니PC 수집/제어 서버 · PC 대시보드 기능 통합</div>
</div>
<div style="display:flex;align-items:center;gap:12px;">
<input class="tokenbox" id="tokenBox" placeholder="제어 토큰(선택)">
<button class="btn" id="demoBtn" style="display:none">데모 일시정지</button>
<div class="meta">만든이 : 전경선<br>만든날 : 2026.06.3</div>
</div>
</div>
<div class="tabs" id="tabs"></div>
<div class="card">
<div class="card-title">현장 개요</div>
<div class="tiles" id="tiles"></div>
</div>
<!-- ===== 제어 ===== -->
<div class="card">
<div class="card-title">ERV 제어 (원격)</div>
<div class="ctrl-grid">
<div>
<div class="ctrl-row"><span class="lbl">전원</span><button class="btn" id="cPower" onclick="togglePower()">OFF</button>
<span class="lbl" style="width:auto;margin-left:8px;">ERV 리셋</span><button class="btn" id="cReset" onclick="toggleReset()">OFF</button></div>
<div class="ctrl-row"><span class="lbl">운전모드</span><span id="cModes"></span></div>
<div class="ctrl-row"><span class="lbl">풍량</span><span id="cFans"></span>
<span class="lbl" style="width:auto;margin-left:8px;">자동 프리셋</span><span id="cPresetsMain"></span></div>
<div class="ctrl-row"><span class="lbl">(꺼짐)예약</span>
<select id="cReserve" class="btn" onchange="setReserve(this.value)">
<option value="0">해제</option><option value="1">1시간</option><option value="2">2시간</option>
<option value="3">3시간</option><option value="4">4시간</option><option value="5">5시간</option>
<option value="6">6시간</option><option value="7">7시간</option><option value="8">8시간</option>
</select>
<span id="cReserveTxt" style="color:var(--text2);margin-left:6px;font-size:12px;"></span></div>
</div>
<div>
<div class="ctrl-row"><span class="lbl">부가모드</span><span id="cSubs"></span></div>
<div class="ctrl-row"><span class="lbl">연동후드</span><button class="btn" id="cHood" onclick="toggleHood()">OFF</button>
<span id="cHoodConn" style="font-size:12px;margin-left:8px;"></span></div>
<div class="ctrl-row"><span class="lbl">스마트수면<br>시간설정</span>
<input type="time" id="slStart" class="tokenbox" style="width:110px" value="23:00">~
<input type="time" id="slEnd" class="tokenbox" style="width:110px" value="07:00">
<label style="font-size:12px;display:flex;align-items:center;gap:4px;"><input type="checkbox" id="slEnable" onchange="renderSleep()">예약</label>
<span id="slTxt" style="font-size:12px;color:var(--text2);"></span></div>
<div class="ctrl-row"><span class="lbl">설정</span>
<button class="btn" onclick="openModal('hyst')">공기질 히스테리시스 ▸</button>
<button class="btn" onclick="openModal('vsp')">풍량 VSP ▸</button>
</div>
</div>
</div>
</div>
<!-- ===== 자동운전 상태 ===== -->
<div class="card">
<div class="card-title">자동운전 상태 (표시 전용) <span id="autoSummary" style="font-weight:400;margin-left:8px;"></span></div>
<div class="autocard" id="autoCard"></div>
</div>
<div class="card">
<div class="card-title">각실 모니터링 · 제어</div>
<div class="rooms" id="rooms"></div>
</div>
<!-- ===== 로그 그래프 (별도 창) ===== -->
<div class="card">
<div class="card-title">로그 그래프</div>
<button class="btn" onclick="openModal('graph')">📂 로그 그래프 — 날짜별 불러오기 ▸</button>
<span style="color:var(--text2);font-size:12px;margin-left:8px;">날짜 선택 → 불러오기 → 좌측 시리즈 선택/해제 → (필요 시) 엑셀 저장</span>
</div>
<div class="footer" id="footNote"></div>
<!-- ===== 팝업: 공기질 히스테리시스 / 풍량 VSP ===== -->
<div class="modal-bg" id="modalBg" onclick="closeModalBg(event)">
<div class="modal" id="modalHyst" style="display:none">
<div class="modal-h"><span>공기질 히스테리시스 (ECO / NORMAL / TURBO)</span>
<button class="btn" onclick="closeModal()">닫기</button></div>
<div class="ctrl-row"><span class="lbl">프리셋</span><span id="cPresets"></span></div>
<div style="font-weight:700;font-size:13px;margin:6px 0 2px">데드밴드(하강)</div>
<div id="hystGrid"></div>
<button class="btn" style="margin-top:10px" onclick="applyHyst()">데드밴드 변경</button>
<div style="font-weight:700;font-size:13px;margin:16px 0 2px">오염단계 임계 (L1~L4 상한)</div>
<div id="thrGrid"></div>
<button class="btn" style="margin-top:10px" onclick="applyThr()">임계 변경</button>
</div>
<div class="modal" id="modalVsp" style="display:none">
<div class="modal-h"><span>풍량 VSP 제어 · 상태 (SA 급기 / EA 배기)</span>
<button class="btn" onclick="closeModal()">닫기</button></div>
<div class="vsp-grid" id="vspGrid"></div>
</div>
<div class="modal modal-wide" id="modalGraph" style="display:none">
<div class="modal-h"><span>로그 그래프 (가로 시간 · 세로 댐퍼/센서/모드)</span>
<button class="btn" onclick="closeModal()">닫기</button></div>
<div class="gtoolbar">
<span style="color:var(--text2)">날짜</span>
<input type="date" id="gDate" class="tokenbox" style="width:150px">
<button class="btn" onclick="loadGraphClick()">📂 불러오기</button>
<button class="btn" onclick="gSelectAll(true)">전체선택</button>
<button class="btn" onclick="gSelectAll(false)">전체해제</button>
<button class="btn" onclick="exportCsv()">📊 엑셀(CSV) 저장</button>
<span id="gCount" style="color:var(--text2);font-size:12px"></span>
</div>
<div class="graph-wrap">
<div class="graph-side" id="gSide"></div>
<div class="graph-main">
<canvas id="gChart" width="1000" height="380"></canvas>
<div class="glegend" id="gLegend"></div>
</div>
</div>
</div>
</div>
</div>
<script>
const MODE = location.protocol.startsWith("http") ? "server" : "demo";
const SITES = ["site01","site02","site03"];
const SITE_LABEL = {site01:"현장1",site02:"현장2",site03:"현장3"};
const ROOMS = ["거실","침실1","침실2","침실3"];
const ROOM_COLOR = ["#3B82F6","#22C55E","#F59E0B","#8B5CF6"];
const RUNMODE = {0:"OFF",1:"환기",2:"자동",3:"공청",4:"바이패스"};
const MODE_BTNS = [[1,"환기"],[2,"자동"],[3,"공청"],[4,"바이패스"]];
const SUB_BTNS = [[1,"수면",1],[2,"조리",2],[3,"회복",4]];
const PRESET_BTNS = [[0,"ECO"],[1,"NORMAL"],[2,"TURBO"]];
const AUTOSTATE = {0:"분산",1:"집중"};
const PRESET = {0:"ECO",1:"NORMAL",2:"TURBO"};
const AQ = {1:{t:"매우나쁨",c:"#EF4444"},2:{t:"나쁨",c:"#F59E0B"},3:{t:"보통",c:"#22C55E"},4:{t:"좋음",c:"#3B82F6"}};
const VSP_LABELS = ["환기1","환기2","환기3","환기4","바이패스","공청1","공청2","공청3","공청4"];
const VSP_GROUP = [0,0,0,0,1,2,2,2,2];
const VSP_INDEX = [1,2,3,4,1,1,2,3,4];
const VSP_DEMO = [[20,18],[40,38],[60,58],[80,78],[70,0],[25,0],[45,0],[65,0],[85,0]];
const HYST_PRESETS = ["ECO","NORMAL","TURBO"];
const HYST_FIELDS = ["pm25","pm10","voc","co2"];
const POLL = ["co2","pm25","pm10","voc"]; // 임계 오염원 순서(서버 thr 키)
const HYST_DEMO = [{pm25:20,pm10:40,voc:250,co2:600},{pm25:30,pm10:50,voc:300,co2:700},{pm25:40,pm10:70,voc:400,co2:900}];
const THR_DEMO = [
{co2:[1000,1300,1600,2000],pm25:[20,38,60,86],pm10:[40,86,126,173],voc:[171,195,308,438]},
{co2:[800,1100,1400,1700],pm25:[14,29,49,69],pm10:[28,66,102,138],voc:[120,150,250,350]},
{co2:[700,1000,1300,1600],pm25:[12,23,38,52],pm10:[24,53,78,104],voc:[103,120,192,263]}];
const HIST=60;
let current="site01", demoOn=true, tick=0;
const state={};
SITES.forEach((s,si)=>{ state[s]={
online:false,
g:{power:1,run_mode:2,auto_state:0,fan_mode:2,sub_mode:0,hood:0,hyst_preset:1,error_code:0,reset:0,reserve_remain:0},
rooms:Array.from({length:4},()=>({damper_sa:0,damper_ea:0,pm25:0,pm10:0,voc:0,co2:0,air_quality:3,led_dim:0,load_score:0,final_volume:0,temp:0,humi:0})),
vsp:VSP_DEMO.map(([sa,ea])=>({sa,ea})),
hyst:HYST_DEMO.map(h=>({...h})),
thr:THR_DEMO.map(t=>({co2:[...t.co2],pm25:[...t.pm25],pm10:[...t.pm10],voc:[...t.voc]})),
seed:si*100 }; });
// ===== 데모 생성기 =====
function genSensors(s,t){
const st=state[s]; st.online=true;
st.g.auto_state=Math.floor(t/8)%2;
for(let r=0;r<4;r++){
const seed=t+r*13+st.seed, rm=st.rooms[r];
rm.pm25=10+(seed*3+Math.round(8*Math.sin(t/6+r)))%60;
rm.pm10=15+(seed*5)%90; rm.voc=100+(seed*7)%400;
rm.co2=450+(seed*11)%700+Math.round(60*Math.sin(t/5+r));
rm.air_quality=1+(seed%4); rm.load_score=(seed*4)%5; rm.final_volume=seed%5;
rm.temp=22+(seed)%6; rm.humi=40+(seed*7)%30;
}
}
// ===== 제어 전송 =====
function applyLocal(site,action,a){
const st=state[site],g=st.g;
if(action==="power")g.power=a.value;
else if(action==="runmode")g.run_mode=a.value;
else if(action==="fan")g.fan_mode=a.value;
else if(action==="hood")g.hood=a.value?(g.hood|1):(g.hood&~1);
else if(action==="preset")g.hyst_preset=a.value;
else if(action==="submode"){const bit=a.type===1?1:a.type===2?2:4;g.sub_mode=a.value?(g.sub_mode|bit):(g.sub_mode&~bit);}
else if(action==="damper")st.rooms[a.room-1][a.type===1?"damper_ea":"damper_sa"]=a.value;
else if(action==="led")st.rooms[a.room-1].led_dim=a.value;
else if(action==="reset")g.reset=a.value;
else if(action==="reserve")g.reserve_remain=a.value*3600;
else if(action==="vsp")st.vsp[a._idx]={sa:a.sa,ea:a.ea};
else if(action==="hyst")st.hyst[a.preset]={pm25:a.pm25,pm10:a.pm10,voc:a.voc,co2:a.co2};
else if(action==="hystthr")st.thr[a.preset][a._poll]=[a.l1,a.l2,a.l3,a.l4];
}
async function ctl(action,a={}){
applyLocal(current,action,a);
renderTiles(); renderControls(); renderAuto(); renderHyst(); renderThr(); renderVsp(); renderRooms();
if(MODE==="server"){
try{
const r=await fetch("/api/control",{method:"POST",
headers:{"Content-Type":"application/json","X-Auth-Token":document.getElementById("tokenBox").value||""},
body:JSON.stringify(Object.assign({site:current,action},a))});
if(!r.ok) flash(`제어 실패 (${r.status})`);
}catch(e){ flash("제어 전송 오류: "+e.message); }
}
}
function flash(msg){ const f=document.getElementById("footNote"); f.textContent=msg; f.style.color="var(--bad)"; setTimeout(setFoot,2500); }
// ===== 제어 핸들러 =====
function togglePower(){ ctl("power",{value:state[current].g.power?0:1}); }
function toggleHood(){ ctl("hood",{value:(state[current].g.hood&1)?0:1}); }
function setMode(m){ ctl("runmode",{value:m}); }
function setFan(s){ const g=state[current].g; if(g.run_mode===2)return; if(g.run_mode===4&&s>1)return; ctl("fan",{value:s}); }
function toggleSub(type,bit){
const g=state[current].g, orig=g.sub_mode, on=(orig&bit)?0:1;
if(on){
if(type!==1 && (orig&1)) ctl("submode",{type:1,value:0});
if(type!==2 && (orig&2)) ctl("submode",{type:2,value:0});
if(type!==3 && (orig&4)) ctl("submode",{type:3,value:0});
}
ctl("submode",{type,value:on});
}
function setPreset(p){ ctl("preset",{value:p}); }
function setDamperSa(room){ if(state[current].g.run_mode===2)return; const cur=state[current].rooms[room-1].damper_sa; ctl("damper",{room,type:0,value:cur?0:1}); }
function setDamperEa(room){ if(state[current].g.run_mode===2)return; const cur=state[current].rooms[room-1].damper_ea; ctl("damper",{room,type:1,value:cur?0:1}); }
function setLed(room,val){ ctl("led",{room,value:parseInt(val)}); }
function setReserve(h){ ctl("reserve",{value:parseInt(h)}); }
function toggleReset(){ ctl("reset",{value:state[current].g.reset?0:1}); }
function setHyst(pi,field,val){ state[current].hyst[pi][field]=parseInt(val)||0; }
function applyHyst(){ const st=state[current];
for(let pi=0;pi<3;pi++){ const v=st.hyst[pi]; ctl("hyst",{preset:pi,pm25:v.pm25,pm10:v.pm10,voc:v.voc,co2:v.co2}); } }
function setThr(pi,poll,li,val){ state[current].thr[pi][poll][li]=parseInt(val)||0; }
function applyThr(){ const st=state[current];
for(let pi=0;pi<3;pi++) POLL.forEach((poll,pp)=>{ const v=st.thr[pi][poll];
ctl("hystthr",{preset:pi,pollutant:pp,l1:v[0],l2:v[1],l3:v[2],l4:v[3],_poll:poll}); }); }
// ===== 팝업 =====
function openModal(which){
document.getElementById("modalHyst").style.display = which==="hyst"?"block":"none";
document.getElementById("modalVsp").style.display = which==="vsp"?"block":"none";
document.getElementById("modalGraph").style.display = which==="graph"?"block":"none";
document.getElementById("modalBg").classList.add("show");
renderControls(); renderHyst(); renderThr(); renderVsp();
if(which==="graph"){ renderSide(); loadGraph(); } // 열 때 현재 로드날짜로 갱신
}
function graphOpen(){ return document.getElementById("modalBg").classList.contains("show")
&& document.getElementById("modalGraph").style.display==="block"; }
function closeModal(){ document.getElementById("modalBg").classList.remove("show"); }
function closeModalBg(e){ if(e.target.id==="modalBg") closeModal(); }
function setVsp(i,field,val){
const v=state[current].vsp[i];
const sa=field==="sa"?(parseInt(val)||0):v.sa, ea=field==="ea"?(parseInt(val)||0):v.ea;
ctl("vsp",{group:VSP_GROUP[i],index:VSP_INDEX[i],sa,ea,_idx:i});
}
// ===== 스마트수면 시간설정 (브라우저 스케줄, 현재 현장에 적용) =====
let _slLast=-1;
function hm2min(v){ const [h,m]=(v||"0:0").split(":").map(Number); return (h*60+m)||0; }
function renderSleep(){
const en=document.getElementById("slEnable").checked;
const s=document.getElementById("slStart").value, e=document.getElementById("slEnd").value;
document.getElementById("slTxt").textContent = en?`예약 ON (${s}~${e}, 현재 현장)`:"예약 OFF";
}
function sleepTick(){
if(!document.getElementById("slEnable").checked) return;
const now=new Date(), cur=now.getHours()*60+now.getMinutes();
const s=hm2min(document.getElementById("slStart").value), e=hm2min(document.getElementById("slEnd").value);
const inWin = s<e ? (cur>=s&&cur<e) : (cur>=s||cur<e); // 자정 넘김 지원
const want = inWin?1:0;
if(want!==_slLast){ _slLast=want; toggleSubForce(1, want); }
}
function toggleSubForce(type,on){ // 스케줄용: 상태와 무관하게 지정값 전송
if(on){ const g=state[current].g; if(g.sub_mode&2) ctl("submode",{type:2,value:0}); if(g.sub_mode&4) ctl("submode",{type:3,value:0}); }
ctl("submode",{type,value:on});
}
// ===== 렌더 =====
function renderTabs(){
const el=document.getElementById("tabs"); el.innerHTML="";
SITES.forEach(s=>{ const d=document.createElement("div");
d.className="tab"+(s===current?" active":"");
d.innerHTML=`<span class="dot ${state[s].online?"":"off"}"></span>${SITE_LABEL[s]}`;
d.onclick=()=>{current=s;renderAll();if(graphOpen())loadGraph();}; el.appendChild(d); });
}
function tile(label,value,color){return `<div class="tile"><div class="label">${label}</div><div class="value" ${color?`style="color:${color}"`:""}>${value}</div></div>`;}
function renderTiles(){
const g=state[current].g;
const err=g.error_code===0?"정상":("0x"+g.error_code.toString(16).toUpperCase().padStart(4,"0"));
document.getElementById("tiles").innerHTML=
tile("전원",g.power?"ON":"OFF",g.power?"var(--good)":"var(--text2)")+
tile("운전모드",RUNMODE[g.run_mode])+
tile("풍량",g.run_mode===2?`자동(${g.fan_mode})`:g.fan_mode)+
tile("자동상태",AUTOSTATE[g.auto_state])+
tile("연동후드",(g.hood&1)?"ON":"OFF",(g.hood&1)?"var(--good)":"var(--text2)")+
tile("히스테리시스",PRESET[g.hyst_preset])+
tile("에러",err,g.error_code?"var(--bad)":"var(--text2)");
}
function renderControls(){
const g=state[current].g;
const p=document.getElementById("cPower"); p.textContent=g.power?"ON":"OFF"; p.className="btn "+(g.power?"on":"off");
const rs=document.getElementById("cReset"); rs.textContent=g.reset?"ON":"OFF"; rs.className="btn "+(g.reset?"on":"off");
const h=document.getElementById("cHood"); h.textContent=(g.hood&1)?"ON":"OFF"; h.className="btn "+((g.hood&1)?"on":"off");
const hc=document.getElementById("cHoodConn");
if(g.hood&1){ const conn=(g.hood&4); hc.textContent=conn?"후드 연결":"후드 연결 안됨"; hc.style.color=conn?"var(--good)":"var(--bad)"; }
else hc.textContent="";
document.getElementById("cModes").innerHTML=MODE_BTNS.map(([m,l])=>
`<button class="btn ${g.run_mode===m?"active":""}" onclick="setMode(${m})">${l}</button>`).join("");
const fanMax = g.run_mode===4?1:4;
document.getElementById("cFans").innerHTML=[0,1,2,3,4].map(s=>
`<button class="btn ${g.fan_mode===s?"active":""}" ${(g.run_mode===2||s>fanMax)?"disabled":""} onclick="setFan(${s})">${s}</button>`).join("");
const presetMain=document.getElementById("cPresetsMain");
if(presetMain) presetMain.innerHTML=PRESET_BTNS.map(([v,l])=>
`<button class="btn ${g.hyst_preset===v?"active":""}" ${g.run_mode!==2?"disabled":""} onclick="setPreset(${v})">${l}</button>`).join("");
const sec=g.reserve_remain||0, rsv=document.getElementById("cReserve"), rt=document.getElementById("cReserveTxt");
if(rt) rt.textContent = sec>0 ? `꺼짐까지 ${Math.floor(sec/3600)}:${String(Math.floor(sec%3600/60)).padStart(2,"0")}:${String(sec%60).padStart(2,"0")}` : "예약 없음";
if(rsv && sec===0 && rsv.value!=="0") rsv.value="0";
document.getElementById("cSubs").innerHTML=SUB_BTNS.map(([t,l,bit])=>
`<button class="btn ${(g.sub_mode&bit)?"active":""}" onclick="toggleSub(${t},${bit})">${l}</button>`).join("");
const cp=document.getElementById("cPresets");
if(cp) cp.innerHTML=PRESET_BTNS.map(([v,l])=>
`<button class="btn ${g.hyst_preset===v?"active":""}" onclick="setPreset(${v})">${l}</button>`).join("");
}
function renderAuto(){
const st=state[current], g=st.g;
const total=st.rooms.reduce((a,rm)=>a+(rm.load_score||0),0);
document.getElementById("autoSummary").innerHTML = g.run_mode===2
? `동작: <b>${AUTOSTATE[g.auto_state]}</b> · 합산부하 ${total} · ${g.fan_mode}`
: "(자동모드 아님)";
document.getElementById("autoCard").innerHTML = st.rooms.map((rm,i)=>
`<div class="autobox"><div class="rn">${ROOMS[i]}</div><div class="sc">${rm.load_score||0}</div><div class="vol">풍량 ${rm.final_volume||0}</div></div>`).join("");
}
function renderRooms(){
const st=state[current], el=document.getElementById("rooms"); el.innerHTML="";
const isAuto = st.g.run_mode===2;
st.rooms.forEach((rm,i)=>{ const aq=AQ[rm.air_quality]||AQ[3]; const room=i+1;
el.insertAdjacentHTML("beforeend",`
<div class="room">
<div class="room-head"><div class="room-name">${ROOMS[i]}</div>
<div class="aq"><span class="led" style="background:${aq.c}"></span>${aq.t}</div></div>
<div class="ctrl-row" style="margin-top:10px;">
<span class="lbl" style="width:auto">급기댐퍼</span>
<button class="btn ${rm.damper_sa?"on":"off"}" ${isAuto?"disabled":""} onclick="setDamperSa(${room})">${rm.damper_sa?"열림":"닫힘"}</button>
<span class="lbl" style="width:auto;margin-left:10px;">배기댐퍼</span>
<button class="btn ${rm.damper_ea?"on":"off"}" ${isAuto?"disabled":""} onclick="setDamperEa(${room})">${rm.damper_ea?"열림":"닫힘"}</button>
</div>
<div class="sensors">
<div class="sensor"><span class="k">PM2.5</span><span class="v">${rm.pm25}</span></div>
<div class="sensor"><span class="k">PM10</span><span class="v">${rm.pm10}</span></div>
<div class="sensor"><span class="k">VOC</span><span class="v">${rm.voc}</span></div>
<div class="sensor"><span class="k">CO2</span><span class="v">${rm.co2}</span></div>
<div class="sensor"><span class="k">TEMP</span><span class="v">${rm.temp}℃</span></div>
<div class="sensor"><span class="k">HUMI.</span><span class="v">${rm.humi}%</span></div>
</div>
<div class="ledrow"><span>LED 디밍 (모든 모드)</span><span>${rm.led_dim} / 9 · 풍량 ${rm.final_volume} · 부하 ${rm.load_score}</span></div>
<input type="range" min="0" max="9" value="${rm.led_dim}" oninput="setLed(${room},this.value)">
</div>`);
});
}
function renderHyst(){
const grid=document.getElementById("hystGrid");
if(document.activeElement && grid.contains(document.activeElement)) return;
const st=state[current];
let h='<div class="hyst-grid"><span class="hh"></span><span class="hh">PM2.5</span><span class="hh">PM10</span><span class="hh">VOC</span><span class="hh">CO2</span>';
HYST_PRESETS.forEach((name,pi)=>{ const v=(st.hyst&&st.hyst[pi])||{pm25:0,pm10:0,voc:0,co2:0};
h+=`<span class="pl">${name}</span>`+HYST_FIELDS.map(f=>`<input type="number" min="0" value="${v[f]}" onchange="setHyst(${pi},'${f}',this.value)">`).join(""); });
grid.innerHTML=h+"</div>";
}
function renderThr(){
const grid=document.getElementById("thrGrid");
if(document.activeElement && grid.contains(document.activeElement)) return;
const st=state[current];
let h='<div class="thr-grid"><span class="hh"></span><span class="hh">L1</span><span class="hh">L2</span><span class="hh">L3</span><span class="hh">L4</span>';
HYST_PRESETS.forEach((name,pi)=>{
POLL.forEach(poll=>{ const v=(st.thr&&st.thr[pi]&&st.thr[pi][poll])||[0,0,0,0];
h+=`<span class="pl">${name}·${poll.toUpperCase()}</span>`+
[0,1,2,3].map(li=>`<input type="number" min="0" value="${v[li]}" onchange="setThr(${pi},'${poll}',${li},this.value)">`).join("");
});
});
grid.innerHTML=h+"</div>";
}
function renderVsp(){
const grid=document.getElementById("vspGrid");
if(document.activeElement && grid.contains(document.activeElement)) return;
const st=state[current];
grid.innerHTML = VSP_LABELS.map((lab,i)=>{ const v=(st.vsp&&st.vsp[i])||{sa:0,ea:0};
return `<div class="vsp-row"><span class="vl">${lab}</span>`+
`<span class="u">SA</span><input type="number" min="0" value="${v.sa}" onchange="setVsp(${i},'sa',this.value)">`+
`<span class="u">EA</span><input type="number" min="0" value="${v.ea}" onchange="setVsp(${i},'ea',this.value)"></div>`;
}).join("");
}
function renderAll(){renderTabs();renderTiles();renderControls();renderAuto();renderHyst();renderThr();renderVsp();renderRooms();renderSleep();}
// ===== 로그 그래프 =====
let GDEF=[], GON=[], GDATA=[], _loadedDate=todayStr();
function buildDefs(){
const c=["#3B82F6","#EF4444","#22C55E","#8B5CF6","#F59E0B","#06B6D4","#EC4899","#64748B","#84CC16"];
const defs=[
{g:"운전",l:"운전모드",f:s=>s.run_mode},
{g:"운전",l:"자동-집중",f:s=>s.auto_mode===1?1:0},
{g:"운전",l:"자동-분산",f:s=>s.auto_mode===2?1:0},
{g:"운전",l:"프리셋-ECO",f:s=>s.hyst_preset===0?1:0},
{g:"운전",l:"프리셋-NORMAL",f:s=>s.hyst_preset===1?1:0},
{g:"운전",l:"프리셋-TURBO",f:s=>s.hyst_preset===2?1:0},
{g:"운전",l:"풍량",f:s=>s.fan_mode},
{g:"운전",l:"전원",f:s=>s.power},
{g:"시나리오",l:"스마트수면",f:s=>s.sleep},
{g:"시나리오",l:"쾌적조리",f:s=>s.cook},
{g:"시나리오",l:"안심회복",f:s=>s.recover},
];
ROOMS.forEach((nm,r)=>{
[["CO2","co2"],["PM2.5","pm25"],["PM10","pm10"],["VOC","voc"],["온도","temp"],["습도","humi"],["LED","led"],["부하","level"],
["급기댐퍼","damper_sa"],["배기댐퍼","damper_ra"]].forEach(([lab,key])=>{
defs.push({g:nm,l:`${nm} ${lab}`,f:s=>(s.rooms[r]?s.rooms[r][key]:0)});
});
});
defs.forEach((d,i)=>d.color=c[i%c.length]);
GDEF=defs; GON=defs.map(_=>true);
}
function renderSide(){
const el=document.getElementById("gSide"); let h=""; let grp=null;
GDEF.forEach((d,i)=>{ if(d.g!==grp){grp=d.g; h+=`<div class="grp">${grp}</div>`;}
const lab=d.l.startsWith(d.g+" ")?d.l.slice(d.g.length+1):d.l;
h+=`<label><input type="checkbox" ${GON[i]?"checked":""} onchange="GON[${i}]=this.checked;drawGraph()">${lab}</label>`; });
el.innerHTML=h;
}
function gSelectAll(v){ GON=GON.map(_=>v); renderSide(); drawGraph(); }
// "불러오기" 클릭 : 선택한 날짜를 확정 로드. 오늘이면 이후 실시간 갱신, 과거면 정적 유지.
function loadGraphClick(){ _loadedDate = document.getElementById("gDate").value || todayStr(); loadGraph(); }
async function loadGraph(){
const dt=_loadedDate;
if(MODE==="server"){
try{ GDATA=await (await fetch(`/api/history?site=${current}&date=${dt}`)).json(); }
catch(e){ GDATA=[]; }
}else{ GDATA=demoHistory(dt); }
const live = dt===todayStr();
document.getElementById("gCount").textContent=`${dt} · ${GDATA.length}개 (5초)${live?" · 실시간":" · 과거(정적)"}`;
drawGraph();
}
function drawGraph(){
const cv=document.getElementById("gChart"),ctx=cv.getContext("2d"),W=cv.width,H=cv.height,pad=34;
ctx.clearRect(0,0,W,H);
const on=GDEF.map((d,i)=>GON[i]?i:-1).filter(i=>i>=0);
let max=-Infinity,min=Infinity;
GDATA.forEach(s=>on.forEach(i=>{const v=GDEF[i].f(s); if(v>max)max=v; if(v<min)min=v;}));
if(!isFinite(max)){max=1;min=0;} if(max===min)max=min+1; const range=max-min;
ctx.strokeStyle="#EDEFF4";ctx.fillStyle="#8A93A6";ctx.font="10px sans-serif";ctx.lineWidth=1;
for(let g=0;g<=4;g++){const y=pad+(H-2*pad)*g/4;ctx.beginPath();ctx.moveTo(pad,y);ctx.lineTo(W-6,y);ctx.stroke();
ctx.fillText(Math.round(max-range*g/4),2,y+3);}
const n=GDATA.length;
if(n>1) for(let g=0;g<=4;g++){const idx=Math.round((n-1)*g/4);const x=pad+(W-pad-6)*(idx/(n-1));
ctx.fillText(GDATA[idx].t,x-18,H-8);}
on.forEach(i=>{ const d=GDEF[i]; ctx.strokeStyle=d.color;ctx.lineWidth=1.6;ctx.beginPath();
GDATA.forEach((s,k)=>{const x=pad+(W-pad-6)*(n>1?k/(n-1):0),y=pad+(H-2*pad)*(1-(d.f(s)-min)/range);k?ctx.lineTo(x,y):ctx.moveTo(x,y);});ctx.stroke(); });
document.getElementById("gLegend").innerHTML=on.map(i=>`<span><i style="background:${GDEF[i].color}"></i>${GDEF[i].l}</span>`).join("");
}
function exportCsv(){
if(!GDATA.length){ flash("저장할 데이터가 없습니다."); return; }
const head=["날짜","시간","전원","운전모드","자동상태","풍량","스마트수면","쾌적조리","안심회복","프리셋"];
ROOMS.forEach(nm=>head.push(`${nm}_급기댐퍼`,`${nm}_배기댐퍼`,`${nm}_CO2`,`${nm}_PM2.5`,`${nm}_PM10`,`${nm}_VOC`,`${nm}_온도`,`${nm}_습도`,`${nm}_LED`,`${nm}_부하`));
const mn=["OFF","환기","자동","공청","바이패스"], an=["","집중","분산"], pn=["ECO","NORMAL","TURBO"];
const lines=[head.join(",")];
GDATA.forEach(s=>{ const row=[s.date,s.t,s.power,mn[s.run_mode]||s.run_mode,an[s.auto_mode]||"",s.fan_mode,s.sleep,s.cook,s.recover,pn[s.hyst_preset]||s.hyst_preset];
s.rooms.forEach(rm=>row.push(rm.damper_sa,rm.damper_ra,rm.co2,rm.pm25,rm.pm10,rm.voc,rm.temp,rm.humi,rm.led,rm.level));
lines.push(row.join(",")); });
const blob=new Blob([""+lines.join("\r\n")],{type:"text/csv;charset=utf-8"});
const a=document.createElement("a"); a.href=URL.createObjectURL(blob);
a.download=`HERV_${current}_${_loadedDate}.csv`; a.click();
}
function todayStr(){ const d=new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; }
function demoHistory(){ const arr=[]; for(let k=0;k<120;k++){ const t=k*5; const hh=String(Math.floor(t/3600)).padStart(2,"0"),mm=String(Math.floor(t%3600/60)).padStart(2,"0"),ss=String(t%60).padStart(2,"0");
arr.push({t:`${hh}:${mm}:${ss}`,date:todayStr(),power:1,run_mode:2,auto_mode:(k%16<8?2:1),fan_mode:k%5,sleep:0,cook:0,recover:0,hyst_preset:1,
rooms:Array.from({length:4},(_,r)=>({damper_sa:k%2,damper_ra:(k+1)%2,co2:500+(k*7+r*50)%900,pm25:10+(k*3+r*5)%60,pm10:15+(k*5)%90,voc:100+(k*7)%400,temp:23+r,humi:45+r*3,led:9,level:(k+r)%5}))}); }
return arr; }
// ===== 서버 폴링 =====
async function poll(){
try{
const j=await (await fetch("/api/latest")).json();
for(const s of SITES){ const d=j[s]; if(!d)continue; state[s].online=d.online;
if(d.g){ state[s].g=d.g; state[s].rooms=d.rooms; if(d.vsp)state[s].vsp=d.vsp; if(d.hyst)state[s].hyst=d.hyst; if(d.thr)state[s].thr=d.thr; } }
renderAll();
}catch(e){}
}
function setFoot(){
const f=document.getElementById("footNote"); f.style.color="var(--text2)";
f.textContent = MODE==="server"
? "서버 연동 모드: /api/latest 폴링 · 제어는 /api/control · 그래프는 /api/history(SQLite 누적) 로 동작합니다."
: "데모 모드(파일 직접 열기): 제어/그래프는 화면에만 반영됩니다. 실제는 미니PC 서버(http) 접속 시 동작.";
}
// ===== 시작 =====
buildDefs(); renderSide(); setFoot();
document.getElementById("gDate").value=todayStr();
document.getElementById("slStart").onchange=renderSleep; document.getElementById("slEnd").onchange=renderSleep;
const chip=document.getElementById("modeChip");
if(MODE==="server"){
chip.textContent="서버연동"; chip.style.background="var(--good)";
poll(); setInterval(poll,1000);
// 그래프 창이 열려있고 오늘을 보고 있을 때만 실시간 갱신
setInterval(()=>{ if(graphOpen() && _loadedDate===todayStr()) loadGraph(); },10000);
setInterval(sleepTick,20000);
}else{
chip.textContent="데모"; chip.style.background="var(--warn)";
document.getElementById("demoBtn").style.display="";
document.getElementById("demoBtn").onclick=function(){demoOn=!demoOn;this.textContent=demoOn?"데모 일시정지":"데모 시작";};
for(let i=0;i<HIST;i++){tick++;SITES.forEach(s=>genSensors(s,tick));}
renderAll();
setInterval(()=>{ if(demoOn){tick++;SITES.forEach(s=>genSensors(s,tick));renderTiles();renderAuto();renderRooms();} },1000);
}
</script>
</body>
</html>
@@ -0,0 +1,89 @@
# 통신 방식 검토 : HTTP vs MQTT
- 작성일: 2026-06-03
- 대상: HuevenEco DL 각실제어 원격 모니터링·제어 (EW11 ↔ 미니PC 수집서버 ↔ 웹 대시보드)
- 관련: `ErvCollector/`, `../TestProgram/PC_ERV_Protocol.md`, `../EW11_RS485 TO WIFI/260603_EW11_클라우드전송_검토.md`
---
## 1. 현재 시스템의 통신 계층 (두 가지)
| # | 구간 | 현재 방식 | 비고 |
|---|------|-----------|------|
| ① | ERV ↔ EW11 ↔ 수집서버 | **0xAA 바이너리 프레임 over TCP** | 장치 구간 (HTTP 아님) |
| ② | 브라우저 ↔ 수집서버 | **HTTP** (`/api/latest` 1초 폴링 + `/api/control` POST) | 대시보드 구간 |
> "웹 프로토콜이 HTTP" = ②번. 일반적으로 IoT에서 MQTT가 거론되는 구간은 **①번(장치↔서버)** 이다.
---
## 2. HTTP vs MQTT 핵심 차이
| 항목 | HTTP (현재) | MQTT |
|---|---|---|
| 모델 | 요청-응답 (Pull) | 발행-구독 (Pub/Sub), **브로커** 경유 |
| 연결 | 매 요청 단발 | **지속 TCP 1개 유지** |
| 방향 | 클라이언트가 물어봐야 받음(폴링) | 변화 시 **즉시 푸시** |
| 오버헤드 | 헤더 큼(매 요청 수백 B) | 헤더 2~수 B, **매우 경량** |
| 실시간성 | 폴링 주기만큼 지연 | 낮은 지연 |
| 다수 장치 | 서버가 일일이 응답 | 브로커가 **N:N 중계**, 확장 용이 |
| 신뢰성/오프라인 | 별도 구현 필요 | **QoS 0/1/2, retained, LWT(접속 끊김 자동 통지)** |
| 브라우저 | 기본 지원 | **직접 불가 → MQTT over WebSocket 필요** |
| 추가 인프라 | 없음(수집서버가 이미 제공) | **브로커(예: Mosquitto) 1개 필요** |
---
## 3. 우리 프로젝트에 대입
### (A) EW11 → 서버 (3개 현장, 인터넷) — MQTT의 본 무대
- EW11은 **MQTT 클라이언트 모드 지원** (매뉴얼 검토 완료).
- 토픽 설계(예): 각 EW11이 `erv/site01/status` 에 0xAA STATUS **발행**, 서버 구독.
제어는 서버가 `erv/site01/control`**발행** → EW11 구독.
- 이점:
- 변화 즉시 푸시(저지연)
- **LWT(Last Will & Testament)** 로 접속 끊김 자동 감지 (현재는 30초 추정 방식)
- QoS 로 유실 방지, retained 로 최신값 즉시 수신
- 현장 수가 늘어도(10·50곳) 브로커가 확장 처리
- **중요**: 0xAA 프레임/파서/빌더(공용 `ErvProtocol`)는 **그대로 유지**. "raw TCP" 대신 "MQTT 페이로드"로 운반만 바뀜.
### (B) 브라우저 ↔ 서버 — 굳이 MQTT 아니어도 됨
- 브라우저는 raw MQTT 불가 → **MQTT-over-WebSocket** 필요(복잡도 증가).
- 모니터링 대시보드엔 **현재 1초 HTTP 폴링으로 충분**(트래픽 미미).
- 진짜 실시간 푸시가 필요하면 WebSocket(또는 MQTT-WS)로 전환 가능하나 필수는 아님.
---
## 4. 트레이드오프 / 주의
- MQTT는 **브로커(Mosquitto)** 를 미니PC에 추가로 운영해야 함(구성 요소 증가).
- **AWS IoT Core 직결은 EW11 클라이언트 인증서 미지원으로 곤란** (`260603_EW11_클라우드전송_검토.md` 참조) → MQTT로 간다면 **자체 Mosquitto(user/pass + 서버 TLS)** 가 현실적.
- 현재 raw TCP + HTTP 폴링도 3현장 모니터링·제어엔 충분히 동작 중.
- MQTT 이득이 큰 경우: **현장 수 증가 / 끊김 잦은 망 / 신뢰성·표준 IoT 스택 정렬**.
---
## 5. 권장
| 상황 | 권장 |
|------|------|
| 현장 3곳 고정 + 단순 운영 | **현행 유지** (raw TCP + HTTP 폴링) — 가장 간단 |
| 현장 확장 / 끊김 감지·신뢰성·표준화 중요 | **(A) 장치↔서버만 MQTT(Mosquitto) 전환**, 브라우저는 HTTP 유지 |
---
## 6. MQTT 전환 시 작업 범위 (참고)
전환해도 **변경은 전송 계층에 한정**된다.
- **EW11 설정**: TCP Client → **MQTT** 모드 (브로커 주소, Client ID, user/pass, publish/subscribe 토픽)
- **미니PC**: Mosquitto 브로커 설치(+user/pass, TLS)
- **수집서버(ErvCollector)**: 현장별 TCP 리스너 → **MQTT 구독자**로 교체
- `erv/+/status` 구독 → 기존 `FrameParser`/`StatusDecoder` 그대로 사용
- 제어 시 `erv/{site}/control``CtrlFrame.*` 결과를 **발행**
- 온라인/오프라인은 **LWT** + retained 로 처리(SiteHub 의 30초 추정 대체)
- **변경 없음**: 공용 프로토콜(`ErvProtocol` — 0xAA 프레임/CRC/STATUS/CTRL), 웹 대시보드(HTTP API 유지), WPF 대시보드, 프로토콜 문서(장치 페이로드 규격 동일)
---
> 결론: MQTT는 **장치↔서버 구간(①)** 에 의미가 있고, 0xAA 규격을 유지한 채 전송만 교체하면 된다.
> 3현장 검증을 먼저 마치고, 확장 시점에 (A)만 MQTT로 전환하는 단계적 접근을 권장한다.
@@ -0,0 +1,289 @@
# WSL2 설치 → ErvCollector 실행 가이드 (윈도우 PC)
미니PC 구입 전, **윈도우 PC + WSL2(Ubuntu)** 에서 수집/모니터·제어 서버(`ErvCollector`)와
InfluxDB·Grafana 를 띄워 전체 파이프라인을 검증하기 위한 단계별 가이드.
> 대상: `TestProgram/WebDashBoard/ErvCollector`
> 순서대로 복사·붙여넣기 하면 됩니다. (Ubuntu 24.04 기준)
---
## 0. 사전 요약 (무엇을 깔고 띄우나)
| 구성요소 | 역할 | 포트 |
|---|---|---|
| ErvCollector(.NET) | 현장 EW11 TCP 수신 + 웹 대시보드/제어 API | 6001~6003(수집), 8080(HTTP) |
| InfluxDB OSS 2.x | 시계열 데이터 24시간 저장(1년 보관) | 8086 |
| Grafana | 장기 분석 대시보드(선택) | 3000 |
---
## 1. WSL2 + Ubuntu 설치 (Windows PowerShell, 관리자)
```powershell
wsl --install -d Ubuntu-24.04
# 설치 후 재부팅 → Ubuntu 최초 실행 시 사용자/암호 설정
wsl --status # 버전 2 확인
wsl --update
```
이후 명령은 모두 **Ubuntu(WSL) 터미널** 안에서 실행한다.
---
## 2. WSL systemd 활성화 (서비스 자동실행용)
```bash
sudo tee /etc/wsl.conf >/dev/null <<'EOF'
[boot]
systemd=true
EOF
```
```powershell
# Windows PowerShell 에서 WSL 재시작
wsl --shutdown
```
다시 Ubuntu 터미널을 열고 확인:
```bash
systemctl is-system-running # running 또는 degraded 면 OK
```
---
## 3. .NET 10 SDK 설치
```bash
sudo apt-get update
sudo apt-get install -y dotnet-sdk-10.0 || {
# 패키지가 없으면 공식 스크립트로 설치
curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 10.0
echo 'export PATH="$HOME/.dotnet:$PATH"' >> ~/.bashrc && source ~/.bashrc
}
dotnet --version
```
---
## 4. InfluxDB OSS 2.x 설치 + 초기 설정
```bash
# 저장소 등록 및 설치
curl -s https://repos.influxdata.com/influxdata-archive_compat.key \
| sudo gpg --dearmor -o /usr/share/keyrings/influxdata.gpg
echo "deb [signed-by=/usr/share/keyrings/influxdata.gpg] https://repos.influxdata.com/debian stable main" \
| sudo tee /etc/apt/sources.list.d/influxdata.list
sudo apt-get update && sudo apt-get install -y influxdb2 influxdb2-cli
sudo systemctl enable --now influxdb
# 초기 설정 : org=herv, bucket=erv, 보관 1년(8760h), 토큰을 직접 지정
influx setup \
--org herv --bucket erv --retention 8760h \
--username admin --password 'change-this-pw' \
--token herv-erv-token-0001 \
--force
```
> 위 `--token herv-erv-token-0001` 값을 그대로 `appsettings.json` 의 `Influx.Token` 에 넣으면 된다.
> (토큰을 따로 확인하려면 `influx auth list`)
---
## 5. (선택) Grafana 설치
```bash
sudo apt-get install -y apt-transport-https software-properties-common
curl -s https://apt.grafana.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/grafana.gpg
echo "deb [signed-by=/usr/share/keyrings/grafana.gpg] https://apt.grafana.com stable main" \
| sudo tee /etc/apt/sources.list.d/grafana.list
sudo apt-get update && sudo apt-get install -y grafana
sudo systemctl enable --now grafana-server
```
- 브라우저에서 `http://localhost:3000` (초기 admin / admin)
- Connections → Data sources → **InfluxDB** 추가
- Query language: **Flux**
- URL: `http://localhost:8086`
- Organization: `herv`, Token: `herv-erv-token-0001`, Default bucket: `erv`
- 패널 쿼리 예시(거실 PM2.5 추이):
```flux
from(bucket: "erv")
|> range(start: v.timeRangeStart, stop: v.timeRangeStop)
|> filter(fn: (r) => r._measurement == "erv_room" and r._field == "pm25" and r.site == "site01" and r.room == "1")
```
---
## 6. 프로젝트 가져오기
> ⚠️ `ErvCollector` 는 공용 라이브러리 **`ErvProtocol`**(경로상 `HERV/TestProgram/ErvProtocol`)을 프로젝트 참조한다.
> (csproj: `..\..\ErvProtocol\ErvProtocol.csproj`) → 빌드 시 이 폴더가 **상대경로로 함께 있어야** 한다.
**방법 1 — 원본 위치에서 바로 빌드 (가장 간단, 권장)**
```bash
cd /mnt/d/project/nuvoton/HERV/TestProgram/WebDashBoard/ErvCollector
# ../../ErvProtocol 가 /mnt/d/.../HERV/TestProgram/ErvProtocol 로 자동 해석됨
```
> `/mnt` 는 다소 느리지만 경로 문제 없이 동작.
**방법 2 — 리눅스 홈으로 복사 (빌드 빠름, 상대구조 유지 필수)**
```bash
mkdir -p ~/herv
cp -r /mnt/d/project/nuvoton/HERV/TestProgram/ErvProtocol ~/herv/ErvProtocol
cp -r /mnt/d/project/nuvoton/HERV/TestProgram/WebDashBoard ~/herv/WebDashBoard
cd ~/herv/WebDashBoard/ErvCollector
# ../../ErvProtocol → ~/herv/ErvProtocol (깊이 일치) ✓
```
### appsettings.json 수정
```bash
nano appsettings.json
```
- `Influx.Token` → `herv-erv-token-0001` (4장에서 지정한 값)
- `Http.Prefix` → 로컬 검증은 `http://localhost:8080/`, **LAN 의 EW11 접속까지** 받으려면 `http://*:8080/`
- (권장) `Http.Token` → 임의의 제어 인증 토큰 지정
---
## 7. 빌드 & 실행
```bash
# 6장에서 정한 ErvCollector 폴더에서 (방법1: /mnt/d/... , 방법2: ~/herv/WebDashBoard/ErvCollector)
dotnet run # 참조된 ErvProtocol 라이브러리도 자동으로 함께 빌드됨
```
정상 기동 로그:
```
ErvCollector 시작. Influx=http://127.0.0.1:8086 bucket=erv 샘플주기=10s
HTTP 대시보드/제어 ← http://localhost:8080/
현장 'site01' ← TCP 포트 6001 대기
현장 'site02' ← TCP 포트 6002 대기
현장 'site03' ← TCP 포트 6003 대기
```
→ **Windows 브라우저**에서 `http://localhost:8080/` 접속(WSL localhost 는 Windows 와 공유됨).
---
## 8. 동작 검증 (EW11 없이)
`ErvCollector` 실행 중인 상태에서, **다른 Ubuntu 터미널**을 열고 데모 STATUS 프레임을 주입:
```bash
python3 - <<'PY'
import socket
# STATUS(0x81) 78바이트 예시 프레임 (PC_ERV_Protocol.md)
frame = bytes.fromhex(("AA 81 49 01 02 01 03 01 01 01 00 1E 00 32 01 2C 02 BC 00 00 "
"01 00 16 00 23 00 B4 02 6C 03 05 00 2A 03 00 00 30 00 46 02 08 03 84 02 03 00 58 04 "
"01 00 0C 00 12 00 5A 01 E0 04 00 00 0F 01 00 00 21 00 37 01 2C 02 D0 02 09 00 3C 02 EB 43").replace(" ",""))
s=socket.create_connection(("127.0.0.1",6001)) # 현장1 포트
import time
for _ in range(20):
s.sendall(frame); time.sleep(1) # 1초마다 1프레임
s.close()
PY
```
확인:
```bash
# 최신 상태 JSON (site01 online:true 로 채워짐)
curl -s http://localhost:8080/api/latest | head -c 400; echo
# 제어 테스트 (전원 OFF 프레임을 site01 소켓으로 송신 → 콜렉터 로그에 '송신' 찍힘)
curl -s -X POST http://localhost:8080/api/control \
-H "Content-Type: application/json" \
-H "X-Auth-Token: <설정한 Http.Token, 없으면 생략>" \
-d '{"site":"site01","action":"power","value":0}'
```
- 브라우저 `http://localhost:8080/` 상단 칩이 **서버연동**, 현장1 점이 초록(온라인)으로 표시.
- InfluxDB 적재 확인:
```bash
influx query 'from(bucket:"erv") |> range(start:-10m) |> filter(fn:(r)=>r._measurement=="erv_room") |> limit(n:5)'
```
---
## 9. (선택) 24시간 자동 실행 — systemd 서비스
```bash
# ErvCollector 폴더에서 게시(참조 ErvProtocol 포함 단일 폴더로 묶임)
dotnet publish -c Release -o ~/erv-publish
sudo tee /etc/systemd/system/erv-collector.service >/dev/null <<EOF
[Unit]
Description=ERV Collector
After=network.target influxdb.service
[Service]
WorkingDirectory=$HOME/erv-publish
ExecStart=$HOME/erv-publish/ErvCollector
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now erv-collector
journalctl -u erv-collector -f # 실시간 로그
```
---
## 10. 외부 EW11(LAN) 에서 WSL 서비스로 접속하게 만들기
WSL2 는 기본 NAT 라 LAN 의 EW11 이 WSL 내부 포트에 직접 못 붙는다. 둘 중 하나:
**방법 A — Mirrored networking (Windows 11 22H2+, 권장)**
`%UserProfile%\.wslconfig` (Windows 측, 메모장):
```ini
[wsl2]
networkingMode=mirrored
```
```powershell
wsl --shutdown
```
→ WSL 이 호스트 IP 공유 → EW11 은 **윈도우 PC LAN IP : 6001~6003** 으로 접속. `appsettings.json` 의 `Http.Prefix` 도 `http://*:8080/` 권장.
**방법 B — portproxy (Windows 10)**
관리자 PowerShell:
```powershell
$wsl = (wsl hostname -I).Trim().Split(" ")[0]
foreach ($p in 6001,6002,6003,8080) {
netsh interface portproxy add v4tov4 listenport=$p listenaddress=0.0.0.0 connectport=$p connectaddress=$wsl
}
```
> 방법 B 의 WSL 내부 IP 는 재부팅마다 바뀌므로 재실행 필요(방법 A 가 편함).
**Windows 방화벽 인바운드 허용** (관리자 PowerShell):
```powershell
New-NetFirewallRule -DisplayName "ERV Collector" -Direction Inbound -Protocol TCP -LocalPort 6001-6003 -Action Allow
New-NetFirewallRule -DisplayName "ERV Web" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow
```
EW11(IOTService) 설정: TCP Client / Server=윈도우 PC IP / Port=현장별 6001~6003 / 115200 8N1 / AES / Keepalive.
---
## 11. 종료 · 재시작 · 트러블슈팅
```bash
# 서비스 제어
sudo systemctl restart erv-collector
sudo systemctl status influxdb grafana-server erv-collector
# 포트 점유 확인
ss -ltnp | grep -E '6001|6002|6003|8080|8086'
```
| 증상 | 원인 / 조치 |
|---|---|
| `HTTP 서버 시작 실패` | `Http.Prefix` 가 `+`/`*` 인데 권한 부족 → WSL(Linux)에선 보통 OK. Windows 직접 실행 시 `localhost` 사용 |
| `Influx write FAIL: 연결 거부` | InfluxDB 미기동 → `sudo systemctl start influxdb`, 토큰/org/bucket 확인 |
| 브라우저에서 8080 접속 안됨 | WSL localhost 공유는 보통 자동. 안되면 `wsl --shutdown` 후 재시도 / 방화벽 |
| EW11 이 못 붙음 | 10장(mirrored/portproxy + 방화벽), `Http.Prefix=http://*:8080/`, 같은 서브넷 여부 |
| `dotnet` 없음 | 3장 재확인, `source ~/.bashrc` |
---
## 12. 참고 문서
- 수집서버 상세: `ErvCollector/README.md`
- 프레임 규격: `../TestProgram/PC_ERV_Protocol.md`
- EW11 클라우드 전송 검토: `../EW11_RS485 TO WIFI/260603_EW11_클라우드전송_검토.md`
+80
View File
@@ -0,0 +1,80 @@
개발언어 : C# WPF DASHBOARD 스타일
각 선택, 토글, 슬라이드, 숫자는 변경 가능하고 통신으로 해당 값 표시
예외인 경우 (수정 못함) 표시
제 목 : HuevenEco DL 각실제어시스템 대시보드
만든이 : 전경선
만든날 : 2026.06.3
구성 :
통신 제어 및 상태
통신포트
보레이트 : 115200BPS N81
연결 / 연결해제
통신시작 / 통신 중지
ERV 제어 및 상태
전원 ON / OFF > 토글
ERV 리셋 > 토글
운전모드
환기 / 자동 / 공청 / 바이패스 > 선택
스마트수면 / 쾌적조리 / 안심회복 > 토글
풍량 0 / 1 / 2 / 3 / 4 > 선택 (자동은 제외)
(꺼짐)예약 0 ~ 8시간 > 숫자
후드연동 ON / OFF > 토글
자동운전 상태
분산 / 집중 > 표시 (수정 못함)
각실 부하점수 및 최종 풍량 > 표시 (수정 못함)
거실 / 침실1 / 침실2 / 침실3 > 숫자 표시 (수정 못함)
공기질센서 히스테리시스 제어 및 상태
ECO / NORMAL / TURBO > 선택 (공기질센서 히스테리시스값 적용)
공기질센서 히스테리시스값 PM2.5 PM10 VOC CO2 > 숫자
풍량 VSP 제어 및 상태
환기1 SA / EA > 숫자
환기2 SA / EA > 숫자
환기3 SA / EA > 숫자
환기4 SA / EA > 숫자
바이패스 SA / EA > 숫자
공기청정1 SA / EA > 숫자
공기청정2 SA / EA > 숫자
공기청정3 SA / EA > 숫자
공기청정4 SA / EA > 숫자
각실 제어 및 상태
거실
댐퍼상태 ON / OFF > 토글
공기질센서값 PM2.5 PM10 VOC CO2 > 숫자
공기질 상태 좋음 / 보통 / 나쁨 / 매우나쁨 > LED 색상 및 숫자 표시 (수정 못함)
LED 디밍 0 ~ 9 > 슬라이드
침실1
댐퍼상태 ON / OFF > 토글
공기질센서값 PM2.5 PM10 VOC CO2 > 숫자
공기질 상태 좋음 / 보통 / 나쁨 / 매우나쁨 > LED 색상 및 숫자 표시 (수정 못함)
LED 디밍 0 ~ 9 > 슬라이드
침실2
댐퍼상태 ON / OFF > 토글
공기질센서값 PM2.5 PM10 VOC CO2 > 숫자
공기질 상태 좋음 / 보통 / 나쁨 / 매우나쁨 > LED 색상 및 숫자 표시 (수정 못함)
LED 디밍 0 ~ 9 > 슬라이드
침실3
댐퍼상태 ON / OFF > 토글
공기질센서값 PM2.5 PM10 VOC CO2 > 숫자
공기질 상태 좋음 / 보통 / 나쁨 / 매우나쁨 > LED 색상 및 숫자 표시 (수정 못함)
LED 디밍 0 ~ 9 > 슬라이드
로그 데이터 출력 및 저장
날짜.시간
ERV 운전모드 및 풍량, 연동상태
자동운전 상태
분산 / 집중 상태
각실 부하점수 및 최종 풍량
거실 / 침실1 / 침실2 / 침실3
ECO / NORMAL / TURBO
공기질센서 히스테리시스값 PM2.5 PM10 VOC CO2
각실별 댐퍼 상태, 공기질센서값, 공기질 상태, LED 디밍값