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
@@ -0,0 +1,226 @@
using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;
namespace RJ2RoomConSimulator
{
// RJ2-232 룸콘(마스터) 시뮬레이터 프로토콜
// 규격 : Protocol/ROOMCON/힘펠_환기장치프로토콜V3.7 (펌웨어 My_RJ2.c / ERVSimulator RoomConProtocol.cs)
// 프레임 : AA | CMD | D[2..12] | XOR(0~12) | EE (15 byte 고정), RS-232 9600 8N1
// 역할 : 룸콘이 마스터 — 모드/풍량/예약 변경(EVENT) 송신 + 주기 폴링(NORMAL), ERV 응답 수신/표시
// ERV(ERVSimulator)가 응답측. 모드코드 : 0환기/1자동/2바이패스/3공청 (ERVSim RunMode enum 일치)
public class RoomConProtocol : IDisposable
{
public const byte HEADER = 0xAA;
public const byte TAIL = 0xEE;
public const int LEN = 15;
// CMD
public const byte CMD_NORMAL = 0x00; // 상태 폴링
public const byte CMD_EVENT = 0x01; // 모드/팬/예약 변경 이벤트
public const byte CMD_RESTART1 = 0x02; // 환기 preset 조회
public const byte CMD_RESTART2 = 0x12; // bypass/air preset 조회
public const byte CMD_VSP = 0x03; // 테스트(VSP) 진입/설정
public const byte CMD_EXIT = 0x04; // 테스트 종료
public const byte CMD_CONTROL = 0x07; // ERV→룸콘 상태응답(COMMAND_CONTROLL)
public const byte CMD_HOOD_INFO = 0x0A; // ERV→룸콘 후드 연동 통지(힘펠 V3.7 RX_DATA_HOOD_INFO)
SerialPort? _port;
CancellationTokenSource? _cts;
readonly object _lock = new();
bool _disposed;
// ---- 룸콘 제어 상태 (UI) ----
public bool PowerOn;
public byte RunMode; // 0환기/1자동/2바이패스/3공청
public byte FanMode; // 0~4
public int ReserveHours; // 0~8 (0=취소)
public byte DeviceId = 1; // id 설정 (RS232 점대점 — 표시/로그용)
// ---- ERV 응답 파싱 결과 ----
public byte ErvRunMode, ErvFanMode, ErvError;
public bool HoodLinked; // 후드 연동중(HOOD_INFO byte[6] bit0 = 0x81 ON / 0x80 OFF)
public int ErvInTemp, ErvOutTemp;
public readonly int[] VentSa = new int[5], VentEa = new int[5]; // index 1~4
public readonly int[] BypassSa = new int[2], BypassEa = new int[2];
public readonly int[] AirSa = new int[5];
readonly byte[] _rx = new byte[LEN];
int _rxPos;
public event Action<string>? Log;
public event Action<bool>? ConnectionChanged;
public event Action<byte>? ResponseReceived; // 파싱 완료된 CMD 전달
public bool IsConnected => _port?.IsOpen == true;
public static byte Xor(byte[] d, int start, int len)
{
byte x = 0;
for (int i = 0; i < len; i++) x ^= d[start + i];
return x;
}
public string[] GetAvailablePorts() => SerialPort.GetPortNames();
public bool Connect(string portName)
{
try
{
Disconnect();
_port = new SerialPort(portName, 9600, Parity.None, 8, StopBits.One)
{
ReadTimeout = 100, WriteTimeout = 300
};
_port.Open();
_cts = new CancellationTokenSource();
_ = Task.Run(() => ReadLoop(_cts.Token));
Logm($"[연결] {portName} (9600, 8N1)");
ConnectionChanged?.Invoke(true);
return true;
}
catch (Exception ex)
{
Logm($"[오류] 연결 실패: {ex.Message}");
ConnectionChanged?.Invoke(false);
return false;
}
}
public void Disconnect()
{
try { _cts?.Cancel(); } catch { }
try { if (_port?.IsOpen == true) { _port.Close(); Logm("[연결 해제]"); } } catch { }
_port?.Dispose(); _port = null;
ConnectionChanged?.Invoke(false);
}
// ================= 수신 (ERV 응답 파싱) =================
void ReadLoop(CancellationToken ct)
{
var buf = new byte[64];
while (!ct.IsCancellationRequested && _port != null && _port.IsOpen)
{
try
{
int n = _port.Read(buf, 0, buf.Length);
for (int i = 0; i < n; i++) FeedByte(buf[i]);
}
catch (TimeoutException) { }
catch (Exception ex) { if (!ct.IsCancellationRequested) Logm($"[오류] {ex.Message}"); break; }
}
}
DateTime _lastByte = DateTime.MinValue;
static readonly TimeSpan FrameGap = TimeSpan.FromMilliseconds(50);
void FeedByte(byte b)
{
var now = DateTime.UtcNow;
if (now - _lastByte > FrameGap) _rxPos = 0;
_lastByte = now;
if (_rxPos == 0) { if (b == HEADER) _rx[_rxPos++] = b; return; }
if (_rxPos <= 12) { _rx[_rxPos++] = b; return; }
if (_rxPos == 13) { if (Xor(_rx, 0, 13) == b) _rx[_rxPos++] = b; else _rxPos = 0; return; }
if (_rxPos == 14)
{
_rxPos = 0;
if (b != TAIL) return;
HandleResponse((byte[])_rx.Clone());
}
}
void HandleResponse(byte[] p)
{
Logm($"RX: {Hex(p, 14)} EE");
byte cmd = p[1];
switch (cmd)
{
case CMD_EVENT: // ERV ack : runMode/fanMode/err/temp
ErvRunMode = p[2]; ErvFanMode = p[3]; ErvError = p[7];
ErvOutTemp = (p[9] == 0xFF) ? 100 : (p[8] == 0x01 ? -(p[9] - 20) : p[9] - 20);
ErvInTemp = (p[10] == 0xFF) ? 100 : p[10] - 20;
break;
case CMD_CONTROL: // 상태 폴링 응답
ErvRunMode = p[2]; ErvFanMode = p[3]; ErvError = p[7];
break;
case CMD_RESTART1: // 환기 preset
VentSa[1] = p[5]; VentEa[1] = p[6]; VentSa[2] = p[7]; VentEa[2] = p[8];
VentSa[3] = p[9]; VentEa[3] = p[10]; VentSa[4] = p[11]; VentEa[4] = p[12];
break;
case CMD_RESTART2: // bypass/air preset
BypassSa[1] = p[5]; BypassEa[1] = p[6];
AirSa[1] = p[7]; AirSa[2] = p[8]; AirSa[3] = p[9]; AirSa[4] = p[10];
break;
case CMD_HOOD_INFO: // 후드 연동 통지 : byte[6] 0x81=ON / 0x80=OFF
HoodLinked = (p[6] & 0x01) != 0;
break;
}
ResponseReceived?.Invoke(cmd);
}
// ================= 송신 (룸콘 → ERV) =================
byte[] NewPacket(byte cmd)
{
var p = new byte[LEN];
p[0] = HEADER; p[1] = cmd;
return p;
}
void Send(byte[] p)
{
p[13] = Xor(p, 0, 13);
p[14] = TAIL;
if (_port?.IsOpen != true) return;
try { lock (_lock) { _port.Write(p, 0, LEN); } Logm($"TX: {Hex(p, LEN)}"); }
catch (Exception ex) { Logm($"[송신오류] {ex.Message}"); }
}
// 모드/풍량/예약 변경 이벤트 (EVENT). 전원 OFF는 환기+풍량0으로 표현(ERV가 Power OFF 처리).
public void SendEvent()
{
var p = NewPacket(CMD_EVENT);
p[2] = PowerOn ? RunMode : (byte)0; // OFF → 환기(0)
p[3] = PowerOn ? FanMode : (byte)0; // OFF → 풍량 0
p[5] = 0; // Heater/UV/Kijer
p[10] = 1; p[11] = (byte)ReserveHours; p[12] = 0; // 예약(시) — 0이면 해제. flag=1로 항상 전달
Send(p);
}
// 상태 폴링 (NORMAL)
public void SendNormal()
{
var p = NewPacket(CMD_NORMAL);
if (ReserveHours > 0) { p[11] = (byte)ReserveHours; p[12] = 0; }
Send(p);
}
public void SendRestart1() => Send(NewPacket(CMD_RESTART1)); // 환기 preset 조회
public void SendRestart2() => Send(NewPacket(CMD_RESTART2)); // bypass/air preset 조회
public void SendExit() => Send(NewPacket(CMD_EXIT)); // 테스트 종료
// VSP(테스트) 설정 : select / sa / ea
public void SendVsp(byte select, byte sa, byte ea)
{
var p = NewPacket(CMD_VSP);
p[3] = select; p[4] = sa; p[5] = ea;
Send(p);
}
void Logm(string m) => Log?.Invoke($"[{DateTime.Now:HH:mm:ss.fff}] {m}");
static string Hex(byte[] d, int n)
{
var sb = new System.Text.StringBuilder(n * 3);
for (int i = 0; i < n; i++) { if (i > 0) sb.Append(' '); sb.Append(d[i].ToString("X2")); }
return sb.ToString();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Disconnect();
GC.SuppressFinalize(this);
}
}
}