using System; using System.Windows.Threading; using ERVSimulator.Model; using RunMode = ERVSimulator.Model.RunMode; namespace ERVSimulator.Protocol { // 후드 버스 마스터 (115200) <-> 후드메인(슬레이브) // 규격 : Protocol/HOOD/주신전자_protocol_hood_전열교환기_Rev1.3_20241125.xlsx // - 9바이트 고정, 폴링주기 100~500ms, 응답 50ms 이내, CS = Preamble~CS직전 전체 XOR // 목적 : ERV(Master) 가 후드메인(Slave) 을 폴 → 후드 FAN/LIGHT/연동CMD 수신 → ErvState 반영 // 마스터 폴(9B) : Preamble | M/S(0x21) | ID | MODE | FAN | 연동EN | 연동운전중 | ERROR | CS // 슬레이브 응답(9B) : Preamble | M/S(0x11) | ID | FAN STATUS | LIGHT STATUS | 0x00 | 연동CMD | ERROR | CS public class HoodMasterProtocol { const byte PREAMBLE = 0xAA; const byte MS_MASTER = 0x21; const byte MS_SLAVE = 0x11; const byte HOOD_ID = 0x01; const int FRAME_LEN = 9; readonly SerialChannel _ch; readonly ErvState _state; readonly Dispatcher _dispatcher; readonly DispatcherTimer _pollTimer; readonly byte[] _rx = new byte[FRAME_LEN]; int _rxPos; DateTime _lastByte = DateTime.MinValue; static readonly TimeSpan FrameGap = TimeSpan.FromMilliseconds(40); public event Action? PacketReceived; public event Action? PacketSent; public bool Verbose { get; set; } = false; // true면 모든 폴 로그 // 후드 통신 생존 표시용 — 마지막으로 유효한 응답을 받은 시각(UTC) public DateTime LastRxUtc { get; private set; } = DateTime.MinValue; // 폴(200ms) 기준 이 시간 내 응답이 없으면 미연결로 판정 (몇 회 누락 허용) static readonly TimeSpan ConnTimeout = TimeSpan.FromMilliseconds(1000); public HoodMasterProtocol(SerialChannel ch, ErvState state, Dispatcher dispatcher) { _ch = ch; _state = state; _dispatcher = dispatcher; _ch.ByteReceived += OnByte; // 폴링주기 200ms (사양 100~500ms 범위 내) _pollTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromMilliseconds(200) }; _pollTimer.Tick += (_, _) => { if (_ch.IsConnected) Poll(); // 폴 주기마다 통신 생존 갱신 : 채널 연결 && 최근 응답 수신 → 연결됨 _state.HoodConnected = _ch.IsConnected && (DateTime.UtcNow - LastRxUtc) < ConnTimeout; }; _pollTimer.Start(); } // MODE : 전원 OFF시 0, ON시 1 환기 / 2 자동 / 3 공청 / 4 바이패스 / 5 기타 byte HoodMode() { if (!_state.PowerOn) return 0; return _state.RunMode switch { RunMode.Ventilation => 1, RunMode.Auto => 2, RunMode.AirClean => 3, RunMode.Bypass => 4, RunMode.Off => 0, _ => 5, }; } void Poll() { var p = new byte[FRAME_LEN]; p[0] = PREAMBLE; p[1] = MS_MASTER; p[2] = HOOD_ID; p[3] = HoodMode(); p[4] = _state.FanMode; // 전열교환기 FAN 0 OFF, 1~5단 p[5] = (byte)(_state.HoodEnable ? 0x01 : 0x00); // 연동 Enable/Disable p[6] = (byte)(_state.HoodStatus ? 0x01 : 0x00); // 연동 운전중(후드 연동에 의한 환기장치 동작중) p[7] = 0x00; // ERROR p[8] = ChecksumHelper.Xor(p, 0, 8); // CS = Preamble~CS직전 XOR _ch.Send(p, FRAME_LEN); if (Verbose) PacketSent?.Invoke($"Hood TX poll MODE={p[3]} FAN={p[4]} EN={p[5]} 연동운전={p[6]}"); } void OnByte(byte b) { var now = DateTime.UtcNow; if (now - _lastByte > FrameGap) _rxPos = 0; _lastByte = now; if (_rxPos == 0) { if (b == PREAMBLE) { _rx[0] = b; _rxPos = 1; } } else if (_rxPos == 1) { if (b == MS_SLAVE) { _rx[1] = b; _rxPos = 2; } else _rxPos = (b == PREAMBLE) ? 1 : 0; } else { _rx[_rxPos++] = b; if (_rxPos >= FRAME_LEN) { var copy = (byte[])_rx.Clone(); _dispatcher.BeginInvoke(new Action(() => HandleResponse(copy))); _rxPos = 0; } } } void HandleResponse(byte[] p) { byte cs = ChecksumHelper.Xor(p, 0, 8); if (cs != p[8]) { PacketReceived?.Invoke($"Hood RX CS오류 {HexFormat.Bytes(p, FRAME_LEN)}"); return; } if (p[2] != HOOD_ID) return; LastRxUtc = DateTime.UtcNow; // 유효 응답 수신 → 통신 생존 _state.HoodConnected = true; // 응답 받았으므로 즉시 연결 표시 int fan = p[3]; // 후드 FAN STATUS : 0 OFF, 1~5단 bool light = p[4] != 0; // 후드 LIGHT STATUS : 0 OFF, 1 ON bool cmd = p[6] != 0; // 연동 CMD : 0 후드 꺼짐 / 1 후드 켜짐 int err = p[7]; bool changed = _state.HoodFan != fan || _state.HoodLight != light || _state.HoodCmd != cmd || _state.HoodError != err; _state.HoodFan = fan; _state.HoodLight = light; _state.HoodCmd = cmd; _state.HoodError = err; // 연동운전중(HoodStatus)은 AutoLogic 이 메이크업 에어 상태(롤백 유지 포함)로 소유. if (changed || Verbose) PacketReceived?.Invoke($"Hood RX FAN={fan} LIGHT={(light ? "ON" : "OFF")} 연동CMD={(cmd ? "ON" : "OFF")}{(err != 0 ? $" ERR={err}" : "")}"); } } }