chore: HERV 통합 저장소 재초기화 커밋
손상된 .git 히스토리(missing tree)로 재초기화 후 작업트리 전체 커밋. .claude/ 만 제외(로컬 에이전트 설정). 구 저장소 백업(.git_corrupt_backup/) 포함. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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~)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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 디밍값
|
||||
Reference in New Issue
Block a user