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? Log; public event Action? ConnectionChanged; public event Action? 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); } } }