chore: HERV 통합 저장소 초기 커밋

- 펌웨어(program), C# 대시보드(TestProgram), 시뮬레이터(Simulator),
  프로토콜/문서(Protocol, doc) 전체를 단일 저장소로 통합
- program 폴더의 별도 git 저장소를 제거하고 통합 저장소에 흡수
- 빌드 산출물(program/build, bin/obj, *.o/.elf/.bin/.hex 등) .gitignore 처리
- 사내 Synology NAS Git 원격 연결 예정

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 21:44:23 +09:00
commit 5a96a696b1
265 changed files with 76458 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
namespace ErvProtocol
{
// CRC-16/MODBUS (poly 0xA001 reflected, init 0xFFFF)
// PC_ERV_Protocol.md 1장 - CMD~PAYLOAD 구간, 결과 리틀엔디안
public static class Crc16
{
public static ushort Modbus(byte[] data, int start, int length)
{
ushort crc = 0xFFFF;
for (int i = 0; i < length; i++)
{
crc ^= data[start + i];
for (int b = 0; b < 8; b++)
crc = (crc & 0x0001) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
}
return crc;
}
}
}
+79
View File
@@ -0,0 +1,79 @@
namespace ErvProtocol
{
// PC↔ERV 고정패킷 프레임 빌더 (PC_ERV_Protocol.md Rev2.0)
// 모든 프레임은 항상 244B 고정 : 0xAA | CMD | DATA[240] | CRC16-MODBUS(LE)
// - PC→ERV 제어 : DATA 앞쪽에 명령 인자, 나머지 0 패딩
// - ERV→PC : STATUS(0x81) DATA=240B 상태, ACK(0x82) DATA[0]=echoCmd DATA[1]=result
public static class CtrlFrame
{
public const int DataLen = 240; // DATA 고정 크기(=STATUS 페이로드)
public const int FrameLen = 1 + 1 + DataLen + 2; // STX+CMD+DATA+CRC = 244
public const byte CTRL_POWER = 0x01;
public const byte CTRL_RUNMODE = 0x02;
public const byte CTRL_FAN = 0x03;
public const byte CTRL_SUBMODE = 0x04;
public const byte CTRL_HOOD = 0x05;
public const byte CTRL_HYST_PRESET = 0x06;
public const byte CTRL_HYST_VALUE = 0x07;
public const byte CTRL_DAMPER = 0x08;
public const byte CTRL_LED = 0x09;
public const byte REQ_STATUS = 0x0A;
public const byte CTRL_RESET = 0x0B;
public const byte CTRL_VSP = 0x0C;
public const byte CTRL_HYST_THR = 0x0D; // 오염단계 임계 설정 [preset][pollutant][L1~L4]
public const byte CTRL_RESERVE = 0x0E; // (꺼짐)예약 [hours 0~8] : N시간 후 전원 OFF (0=해제)
// cmd + args 로 244B 고정 프레임 생성 (DATA 앞쪽에 args, 나머지 0, CRC 포함)
public static byte[] Build(byte cmd, params byte[] args)
{
int argLen = args?.Length ?? 0;
if (argLen > DataLen) argLen = DataLen; // 240B 초과분은 잘림(설계상 발생하지 않음)
var frame = new byte[FrameLen]; // 전부 0 초기화 = DATA 0 패딩
frame[0] = 0xAA;
frame[1] = cmd;
if (argLen > 0) Array.Copy(args!, 0, frame, 2, argLen); // DATA[0..] = args
ushort crc = Crc16.Modbus(frame, 1, 1 + DataLen); // CMD + DATA[240] = 241B
frame[2 + DataLen] = (byte)(crc & 0xFF); // CRC_L
frame[2 + DataLen + 1] = (byte)(crc >> 8); // CRC_H
return frame;
}
static byte[] U16BE(int v) => new[] { (byte)((v >> 8) & 0xFF), (byte)(v & 0xFF) };
public static byte[] Power(int on) => Build(CTRL_POWER, (byte)(on != 0 ? 1 : 0));
public static byte[] RunModeCmd(int m) => Build(CTRL_RUNMODE, (byte)m);
public static byte[] Fan(int s) => Build(CTRL_FAN, (byte)s);
public static byte[] SubMode(int t, int on) => Build(CTRL_SUBMODE, (byte)t, (byte)(on != 0 ? 1 : 0));
public static byte[] Hood(int on) => Build(CTRL_HOOD, (byte)(on != 0 ? 1 : 0));
public static byte[] Preset(int p) => Build(CTRL_HYST_PRESET, (byte)p);
// preset: 0 ECO / 1 NORMAL / 2 TURBO
public static byte[] HystValue(int preset, int pm25, int pm10, int voc, int co2)
{
var p = new List<byte> { (byte)preset };
p.AddRange(U16BE(pm25)); p.AddRange(U16BE(pm10)); p.AddRange(U16BE(voc)); p.AddRange(U16BE(co2));
return Build(CTRL_HYST_VALUE, p.ToArray());
}
// type : 0=급기(SA) / 1=배기(EA)
public static byte[] Damper(int room, int type, int on) => Build(CTRL_DAMPER, (byte)room, (byte)type, (byte)(on != 0 ? 1 : 0));
public static byte[] Led(int room, int dim) => Build(CTRL_LED, (byte)room, (byte)dim);
public static byte[] RequestStatus() => Build(REQ_STATUS);
public static byte[] Reset(int on) => Build(CTRL_RESET, (byte)(on != 0 ? 1 : 0));
public static byte[] Reserve(int hours) => Build(CTRL_RESERVE, (byte)hours); // 0~8시간, 0=해제
public static byte[] Vsp(int group, int index, int sa, int ea)
{
var p = new List<byte> { (byte)group, (byte)index };
p.AddRange(U16BE(sa)); p.AddRange(U16BE(ea));
return Build(CTRL_VSP, p.ToArray());
}
// 오염단계 임계 : preset(0 ECO/1 NORMAL/2 TURBO), pollutant(0 CO2/1 PM2.5/2 PM10/3 VOC), L1~L4 상한
public static byte[] HystThr(int preset, int pollutant, int l1, int l2, int l3, int l4)
{
var p = new List<byte> { (byte)preset, (byte)pollutant };
p.AddRange(U16BE(l1)); p.AddRange(U16BE(l2)); p.AddRange(U16BE(l3)); p.AddRange(U16BE(l4));
return Build(CTRL_HYST_THR, p.ToArray());
}
}
}
+88
View File
@@ -0,0 +1,88 @@
namespace ErvProtocol
{
// 펌웨어 없이 UI/파이프라인 검증용 합성 STATUS payload(73B) 생성.
public static class DemoStatus
{
public static byte[] BuildPayload(int tick)
{
var p = new byte[StatusDecoder.STATUS_LEN];
p[0] = 1; // power
p[1] = (byte)RunMode.Auto; // runMode
p[2] = (byte)((tick / 5) % 2); // autoState 분산/집중
p[3] = (byte)(2 + (tick % 3)); // fanMode 2~4
p[4] = SubModeBits.SmartSleep; // subMode
p[5] = (byte)(tick % 2); // hood
p[6] = (byte)HystPreset.Normal; // preset
WriteU16(p, 7, 30); WriteU16(p, 9, 50); WriteU16(p, 11, 300); WriteU16(p, 13, 700);
WriteU16(p, 15, 0x0000); // errorCode
for (int r = 0; r < 4; r++)
{
int o = 17 + r * 14;
int seed = tick + r * 13;
p[o + 0] = (byte)((seed % 2) | (((seed % 3) == 0) ? 0x02 : 0)); // bit0 급기 / bit1 배기
WriteU16(p, o + 1, 10 + (seed * 3) % 60);
WriteU16(p, o + 3, 15 + (seed * 5) % 90);
WriteU16(p, o + 5, 100 + (seed * 7) % 400);
WriteU16(p, o + 7, 450 + (seed * 11) % 700);
p[o + 9] = (byte)(1 + (seed % 4));
p[o + 10] = (byte)(seed % 10);
WriteU16(p, o + 11, (seed * 17) % 100);
p[o + 13] = (byte)(seed % 5);
}
p[73] = 0; // reset (토글 off)
// 풍량 VSP 설정값 (1바이트, 사양서 DL H-ERV VSP 실측표) : 환기1~4, 바이패스, 공청1~4 의 SA/EA
int[] sa = { 56, 63, 70, 86, 67, 65, 72, 78, 80 };
int[] ea = { 57, 63, 70, 85, 75, 0, 0, 0, 0 };
for (int i = 0; i < 9; i++)
{
int o = 74 + i * 4;
WriteU16(p, o, sa[i]);
WriteU16(p, o + 2, ea[i]);
}
// 히스테리시스 데드밴드(하강) (ECO/NORMAL/TURBO 의 PM2.5/PM10/VOC/CO2) - 사양서
int[,] hyst = { { 2, 5, 5, 50 }, { 2, 5, 5, 50 }, { 2, 5, 3, 30 } };
for (int i = 0; i < 3; i++)
{
int o = 110 + i * 8;
WriteU16(p, o, hyst[i, 0]);
WriteU16(p, o + 2, hyst[i, 1]);
WriteU16(p, o + 4, hyst[i, 2]);
WriteU16(p, o + 6, hyst[i, 3]);
}
// 모드별 오염단계 임계표 (3프리셋 × [CO2,PM2.5,PM10,VOC] × L1~L4 상한) - 사양서
int[][,] thr =
{
new int[,] { {1000,1300,1600,2000}, {20,38,60,86}, {40,86,126,173}, {171,195,308,438} }, // ECO
new int[,] { {800,1100,1400,1700}, {14,29,49,69}, {28,66,102,138}, {120,150,250,350} }, // NORMAL
new int[,] { {700,1000,1300,1600}, {12,23,38,52}, {24,53,78,104}, {103,120,192,263} }, // TURBO
};
for (int i = 0; i < 3; i++)
{
int o = StatusDecoder.THR_OFF + i * 32;
for (int g = 0; g < 4; g++) // g: 0 CO2,1 PM2.5,2 PM10,3 VOC
for (int k = 0; k < 4; k++)
WriteU16(p, o + g * 8 + k * 2, thr[i][g, k]);
}
// 각실 온도/습도 (offset 230~, 4실 × [Temp, Humi])
for (int r = 0; r < 4; r++)
{
int o = StatusDecoder.TEMPHUMI_OFF + r * 2;
p[o + 0] = (byte)(22 + (tick + r) % 6); // 22~27℃
p[o + 1] = (byte)(40 + (tick + r * 7) % 30); // 40~69%
}
return p;
}
static void WriteU16(byte[] p, int off, int v)
{
p[off] = (byte)((v >> 8) & 0xFF);
p[off + 1] = (byte)(v & 0xFF);
}
}
}
+51
View File
@@ -0,0 +1,51 @@
namespace ErvProtocol
{
// 운전모드 (PC_ERV_Protocol.md 3.1)
public enum RunMode : byte
{
Off = 0x00,
Vent = 0x01, // 환기
Auto = 0x02, // 자동
AirClean = 0x03, // 공청
Bypass = 0x04, // 바이패스
}
// 자동운전 상태 (3.4)
public enum AutoState : byte
{
Distribute = 0x00, // 분산
Focus = 0x01, // 집중
}
// 히스테리시스 프리셋
public enum HystPreset : byte
{
Eco = 0x00,
Normal = 0x01,
Turbo = 0x02,
}
// 공기질 상태 (3.3) - 판단 기준은 협의 예정
public enum AirQuality : byte
{
VeryBad = 0x01, // 매우나쁨 - 빨강
Bad = 0x02, // 나쁨 - 주황
Normal = 0x03, // 보통 - 초록
Good = 0x04, // 좋음 - 파랑
}
// 부가모드 type (CTRL_SUBMODE)
public enum SubModeType : byte
{
SmartSleep = 0x01, // 스마트수면
ComfortCook = 0x02, // 쾌적조리
ReliefRecover = 0x03, // 안심회복
}
public static class SubModeBits
{
public const byte SmartSleep = 0x01;
public const byte ComfortCook = 0x02;
public const byte ReliefRecover = 0x04;
}
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- HuevenEco DL 각실제어 공용 프로토콜 라이브러리 (PC_ERV_Protocol.md 단일 진실원본)
PC 대시보드(WPF), 미니PC 수집서버(ErvCollector) 가 공통 참조한다. -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ErvProtocol</RootNamespace>
<AssemblyName>ErvProtocol</AssemblyName>
</PropertyGroup>
</Project>
+69
View File
@@ -0,0 +1,69 @@
namespace ErvProtocol
{
// 고정패킷 파서 (PC_ERV_Protocol.md Rev2.0)
// 0xAA | CMD | DATA[240] | CRC16-MODBUS(LE) = 244B 고정
// 연결(소켓/시리얼)마다 1개 인스턴스. CRC 통과 프레임만 OnFrame 으로 전달.
public sealed class FrameParser
{
public const byte STX = 0xAA;
public const int DataLen = CtrlFrame.DataLen; // 240
public event Action<byte, byte[]>? OnFrame; // (cmd, data[240])
public event Action<string>? OnError;
enum Step { Stx, Cmd, Data, CrcLo, CrcHi }
Step _step = Step.Stx;
byte _cmd, _crcLo;
readonly byte[] _data = new byte[DataLen];
int _dataPos;
public void Feed(ReadOnlySpan<byte> data)
{
foreach (var b in data) FeedByte(b);
}
public void FeedByte(byte b)
{
switch (_step)
{
case Step.Stx:
if (b == STX) _step = Step.Cmd;
break;
case Step.Cmd:
_cmd = b; _dataPos = 0; _step = Step.Data;
break;
case Step.Data:
_data[_dataPos++] = b;
if (_dataPos >= DataLen) _step = Step.CrcLo;
break;
case Step.CrcLo:
_crcLo = b; _step = Step.CrcHi;
break;
case Step.CrcHi:
ushort rxCrc = (ushort)(_crcLo | (b << 8));
_step = Step.Stx;
Verify(rxCrc);
break;
}
}
// 프레임 동기 리셋 (시리얼 프레임 갭 발생 시 등)
public void Reset() => _step = Step.Stx;
void Verify(ushort rxCrc)
{
var buf = new byte[1 + DataLen]; // CMD + DATA[240]
buf[0] = _cmd;
Array.Copy(_data, 0, buf, 1, DataLen);
ushort calc = Crc16.Modbus(buf, 0, buf.Length);
if (calc != rxCrc)
{
OnError?.Invoke($"CRC ERR cmd=0x{_cmd:X2} calc=0x{calc:X4} rx=0x{rxCrc:X4}");
return;
}
var data = new byte[DataLen];
Array.Copy(_data, data, DataLen);
OnFrame?.Invoke(_cmd, data);
}
}
}
+97
View File
@@ -0,0 +1,97 @@
namespace ErvProtocol
{
public static class StatusDecoder
{
public const byte STATUS = 0x81; // ERV→PC 상태
public const byte ACK = 0x82; // ERV→PC 제어 응답
public const int STATUS_LEN = 240; // 238 + 2((꺼짐)예약 잔여초 u16)
public const int THR_OFF = 134; // 모드별 오염단계 임계표 시작 오프셋
public const int TEMPHUMI_OFF = 230; // 각실 온도/습도 블록 시작 (4실 × [Temp,Humi])
public const int RESERVE_OFF = 238; // (꺼짐)예약 잔여초 u16
static int U16(byte[] p, int off) => (p[off] << 8) | p[off + 1]; // 빅엔디안
// STATUS(0x81) payload(73B) → StatusRecord. 길이 부족 시 null.
public static StatusRecord? Decode(byte[] p)
{
if (p.Length < STATUS_LEN) return null;
var s = new StatusRecord
{
Power = p[0],
RunMode = p[1],
AutoState = p[2],
FanMode = p[3],
SubMode = p[4],
Hood = p[5],
HystPreset = p[6],
HystPm25 = U16(p, 7),
HystPm10 = U16(p, 9),
HystVoc = U16(p, 11),
HystCo2 = U16(p, 13),
ErrorCode = U16(p, 15),
};
const int baseOff = 17;
for (int r = 0; r < 4; r++)
{
int o = baseOff + r * 14;
var room = s.Rooms[r];
room.Damper = p[o + 0];
room.Pm25 = U16(p, o + 1);
room.Pm10 = U16(p, o + 3);
room.Voc = U16(p, o + 5);
room.Co2 = U16(p, o + 7);
room.AirQuality = p[o + 9];
room.LedDim = p[o + 10];
room.LoadScore = U16(p, o + 11);
room.FinalVolume = p[o + 13];
}
// ERV 리셋 (offset 73)
s.Reset = p[73];
// 풍량 VSP 블록 (offset 74~, 9엔트리 × u16 SA·EA)
const int vspOff = 74;
for (int i = 0; i < 9; i++)
{
int o = vspOff + i * 4;
s.Vsp[i].Sa = U16(p, o);
s.Vsp[i].Ea = U16(p, o + 2);
}
// 히스테리시스 프리셋 테이블 (offset 110~, 3프리셋 × PM2.5/PM10/VOC/CO2 u16)
const int hystOff = 110;
for (int i = 0; i < 3; i++)
{
int o = hystOff + i * 8;
s.HystTable[i].Pm25 = U16(p, o);
s.HystTable[i].Pm10 = U16(p, o + 2);
s.HystTable[i].Voc = U16(p, o + 4);
s.HystTable[i].Co2 = U16(p, o + 6);
}
// 모드별 오염단계 임계표 (offset 134~, 3프리셋 × [CO2,PM2.5,PM10,VOC] × L1~L4 u16)
for (int i = 0; i < 3; i++)
{
int o = THR_OFF + i * 32;
for (int k = 0; k < 4; k++) s.ThrTable[i].Co2[k] = U16(p, o + 0 + k * 2);
for (int k = 0; k < 4; k++) s.ThrTable[i].Pm25[k] = U16(p, o + 8 + k * 2);
for (int k = 0; k < 4; k++) s.ThrTable[i].Pm10[k] = U16(p, o + 16 + k * 2);
for (int k = 0; k < 4; k++) s.ThrTable[i].Voc[k] = U16(p, o + 24 + k * 2);
}
// 각실 온도/습도 (offset 230~, 4실 × [Temp, Humi])
for (int r = 0; r < 4; r++)
{
int o = TEMPHUMI_OFF + r * 2;
s.Rooms[r].Temp = p[o + 0];
s.Rooms[r].Humi = p[o + 1];
}
// (꺼짐)예약 잔여초 (offset 238, u16)
s.ReserveRemainSec = U16(p, RESERVE_OFF);
return s;
}
}
}
+102
View File
@@ -0,0 +1,102 @@
namespace ErvProtocol
{
// StatusRecord → STATUS(0x81) 134B payload. StatusDecoder 의 역(逆).
// ERV(메인보드/시뮬레이터) 측이 STATUS 를 송신할 때 사용. PC_ERV_Protocol.md 4장.
public static class StatusEncoder
{
static void W16(byte[] p, int off, int v) // 빅엔디안
{
p[off] = (byte)((v >> 8) & 0xFF);
p[off + 1] = (byte)(v & 0xFF);
}
// StatusRecord → 134B payload
public static byte[] Encode(StatusRecord s)
{
var p = new byte[StatusDecoder.STATUS_LEN];
// 글로벌 0~16
p[0] = s.Power;
p[1] = s.RunMode;
p[2] = s.AutoState;
p[3] = s.FanMode;
p[4] = s.SubMode;
p[5] = s.Hood;
p[6] = s.HystPreset;
W16(p, 7, s.HystPm25);
W16(p, 9, s.HystPm10);
W16(p, 11, s.HystVoc);
W16(p, 13, s.HystCo2);
W16(p, 15, s.ErrorCode);
// 각실 17~ (14B x 4)
const int baseOff = 17;
for (int r = 0; r < 4; r++)
{
int o = baseOff + r * 14;
var room = s.Rooms[r];
p[o + 0] = room.Damper;
W16(p, o + 1, room.Pm25);
W16(p, o + 3, room.Pm10);
W16(p, o + 5, room.Voc);
W16(p, o + 7, room.Co2);
p[o + 9] = room.AirQuality;
p[o + 10] = room.LedDim;
W16(p, o + 11, room.LoadScore);
p[o + 13] = room.FinalVolume;
}
// ERV 리셋 (offset 73)
p[73] = s.Reset;
// 풍량 VSP (offset 74~, 9엔트리 × u16 SA·EA)
const int vspOff = 74;
for (int i = 0; i < 9; i++)
{
int o = vspOff + i * 4;
W16(p, o, s.Vsp[i].Sa);
W16(p, o + 2, s.Vsp[i].Ea);
}
// 히스테리시스 프리셋 테이블 (offset 110~, 3프리셋 × PM2.5/PM10/VOC/CO2 u16)
const int hystOff = 110;
for (int i = 0; i < 3; i++)
{
int o = hystOff + i * 8;
W16(p, o, s.HystTable[i].Pm25);
W16(p, o + 2, s.HystTable[i].Pm10);
W16(p, o + 4, s.HystTable[i].Voc);
W16(p, o + 6, s.HystTable[i].Co2);
}
// 모드별 오염단계 임계표 (offset 134~, 3프리셋 × [CO2,PM2.5,PM10,VOC] × L1~L4)
for (int i = 0; i < 3; i++)
{
int o = StatusDecoder.THR_OFF + i * 32;
for (int k = 0; k < 4; k++) W16(p, o + 0 + k * 2, s.ThrTable[i].Co2[k]);
for (int k = 0; k < 4; k++) W16(p, o + 8 + k * 2, s.ThrTable[i].Pm25[k]);
for (int k = 0; k < 4; k++) W16(p, o + 16 + k * 2, s.ThrTable[i].Pm10[k]);
for (int k = 0; k < 4; k++) W16(p, o + 24 + k * 2, s.ThrTable[i].Voc[k]);
}
// 각실 온도/습도 (offset 230~, 4실 × [Temp, Humi])
for (int r = 0; r < 4; r++)
{
int o = StatusDecoder.TEMPHUMI_OFF + r * 2;
p[o + 0] = s.Rooms[r].Temp;
p[o + 1] = s.Rooms[r].Humi;
}
// (꺼짐)예약 잔여초 (offset 238, u16)
W16(p, StatusDecoder.RESERVE_OFF, s.ReserveRemainSec);
return p;
}
// STATUS(0x81) 완성 프레임 (CRC 포함)
public static byte[] BuildStatusFrame(StatusRecord s) => CtrlFrame.Build(StatusDecoder.STATUS, Encode(s));
// ACK(0x82) 프레임 : [echoCmd][result(0 OK/1 ERR)]
public static byte[] BuildAckFrame(byte echoCmd, byte result) => CtrlFrame.Build(StatusDecoder.ACK, echoCmd, result);
}
}
+102
View File
@@ -0,0 +1,102 @@
namespace ErvProtocol
{
// STATUS(0x81) 1프레임을 디코드한 결과 (PC_ERV_Protocol.md 4장)
// 필드는 raw 수치(byte/int). 소비자(WPF)가 필요 시 enum 으로 캐스팅.
public sealed class StatusRecord
{
public byte Power; // 0/1
public byte RunMode; // 1환기 2자동 3공청 4바이패스
public byte AutoState; // 0분산 1집중
public byte FanMode; // 0~4
public byte SubMode; // 비트맵 bit0수면 bit1조리 bit2회복
public byte Hood; // 비트맵 bit0=연동Enable / bit1=연동운전중 / bit2=후드 통신연결(폴응답 생존)
public bool HoodEnable => (Hood & 0x01) != 0; // 후드연동 활성
public bool HoodRunning => (Hood & 0x02) != 0; // 후드연동에 의한 운전중
public bool HoodConnected => (Hood & 0x04) != 0; // 후드 485 통신 연결됨(ERV가 폴 응답 수신중)
public byte HystPreset; // 0 ECO 1 NORMAL 2 TURBO
public int HystPm25;
public int HystPm10;
public int HystVoc;
public int HystCo2;
public int ErrorCode; // 비트맵
public byte Reset; // ERV 리셋 토글 0/1
public int ReserveRemainSec; // (꺼짐)예약 잔여 초 (0=예약없음) — STATUS 끝 블록
public readonly RoomRecord[] Rooms = new RoomRecord[4];
// 풍량 VSP 9엔트리 (VspInfo.Labels 순서: 환기1~4, 바이패스, 공청1~4)
public readonly VspEntry[] Vsp = new VspEntry[9];
// 히스테리시스 프리셋별 데드밴드(하강) [0]=ECO [1]=NORMAL [2]=TURBO
public readonly HystSet[] HystTable = new HystSet[3];
// 모드(프리셋)별 오염단계 임계 경계표 [0]=ECO [1]=NORMAL [2]=TURBO
public readonly ThrSet[] ThrTable = new ThrSet[3];
public StatusRecord()
{
for (int i = 0; i < 4; i++) Rooms[i] = new RoomRecord();
for (int i = 0; i < 9; i++) Vsp[i] = new VspEntry();
for (int i = 0; i < 3; i++) HystTable[i] = new HystSet();
for (int i = 0; i < 3; i++) ThrTable[i] = new ThrSet();
}
}
// 히스테리시스 한 프리셋의 데드밴드(하강폭)
public sealed class HystSet
{
public int Pm25;
public int Pm10;
public int Voc;
public int Co2;
}
// 한 프리셋의 오염단계 임계(각 오염원별 레벨 1~4 상한 경계)
public sealed class ThrSet
{
public int[] Co2 = new int[4]; // L1~L4 상한
public int[] Pm25 = new int[4];
public int[] Pm10 = new int[4];
public int[] Voc = new int[4];
}
public sealed class VspEntry
{
public int Sa; // 급기 풍량 설정값
public int Ea; // 배기 풍량 설정값
}
// 풍량 VSP 엔트리 인덱스 ↔ 라벨/group/index 매핑 (PC_ERV_Protocol.md 3.5)
public static class VspInfo
{
public static readonly string[] Labels =
{ "환기1", "환기2", "환기3", "환기4", "바이패스", "공청1", "공청2", "공청3", "공청4" };
// InfluxDB 태그/JSON 용 ASCII 키
public static readonly string[] Keys =
{ "vent1", "vent2", "vent3", "vent4", "bypass", "air1", "air2", "air3", "air4" };
// group: 0 Vent / 1 Bypass / 2 AirClean
public static readonly int[] Group = { 0, 0, 0, 0, 1, 2, 2, 2, 2 };
// index: Vent/AirClean 1~4, Bypass 1
public static readonly int[] Index = { 1, 2, 3, 4, 1, 1, 2, 3, 4 };
public const int Count = 9;
}
public sealed class RoomRecord
{
public byte Damper; // 비트맵 : bit0=급기(SA) 열림 / bit1=배기(EA) 열림
public bool DamperSa => (Damper & 0x01) != 0; // 급기 댐퍼 열림
public bool DamperEa => (Damper & 0x02) != 0; // 배기 댐퍼 열림
public int Pm25;
public int Pm10;
public int Voc;
public int Co2;
public byte AirQuality; // 1매우나쁨~4좋음
public byte LedDim; // 0~9
public int LoadScore; // 부하점수
public byte FinalVolume; // 최종풍량
public byte Temp; // 온도(℃) — STATUS 끝 블록(offset 230~)
public byte Humi; // 습도(%) — STATUS 끝 블록(offset 230~)
}
}