using System; using System.Windows.Threading; using ERVSimulator.Model; using ErvProtocol; // 공용 프로토콜 (단일 진실원본) : FrameParser/CtrlFrame/StatusEncoder/StatusRecord using RunMode = ERVSimulator.Model.RunMode; // ErvProtocol.RunMode 와 이름 충돌 해소 namespace ERVSimulator.Protocol { // HOMENET (UART1, 115200 N81) <-> ErvDashboard // 규격/코덱 모두 공용 라이브러리 ErvProtocol 사용 (PC_ERV_Protocol.md). // 본 클래스는 ErvState <-> ErvProtocol.StatusRecord 매핑 + 제어명령 적용만 담당. public class HomeNetProtocol { readonly SerialChannel _ch; readonly ErvState _state; readonly DamperSequencer _seq; readonly Dispatcher _dispatcher; readonly DispatcherTimer _statusTimer; readonly FrameParser _parser = new(); public event Action? PacketReceived; public event Action? PacketSent; public HomeNetProtocol(SerialChannel ch, ErvState state, DamperSequencer seq, Dispatcher dispatcher) { _ch = ch; _state = state; _seq = seq; _dispatcher = dispatcher; _parser.OnFrame += (cmd, pl) => _dispatcher.BeginInvoke(new Action(() => HandleFrame(cmd, pl))); _parser.OnError += msg => PacketReceived?.Invoke($"HomeNet {msg}"); _ch.ByteReceived += b => _parser.FeedByte(b); _statusTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(1) }; _statusTimer.Tick += (_, _) => { if (_ch.IsConnected) SendStatus(); }; _statusTimer.Start(); } // ---- ErvState → StatusRecord ---- byte RunModeCode() { if (!_state.PowerOn) return 0; return _state.RunMode switch { RunMode.Ventilation => 1, RunMode.Auto => 2, RunMode.AirClean => 3, RunMode.Bypass => 4, _ => 1, }; } StatusRecord BuildRecord() { int hp = _state.HystPreset; var s = new StatusRecord { Power = (byte)(_state.PowerOn ? 1 : 0), RunMode = RunModeCode(), AutoState = (byte)(_state.AutoConcentrate ? 1 : 0), FanMode = _state.FanMode, SubMode = (byte)((_state.SmartSleep ? 0x01 : 0) | (_state.CookingMode ? 0x02 : 0) | (_state.RecoveryMode ? 0x04 : 0)), Hood = (byte)((_state.HoodEnable ? 0x01 : 0) | (_state.HoodStatus ? 0x02 : 0) | (_state.HoodConnected ? 0x04 : 0)), HystPreset = (byte)hp, HystPm25 = _state.Pm25Db[hp], HystPm10 = _state.Pm10Db[hp], HystVoc = _state.VocDb[hp], HystCo2 = _state.Co2Db[hp], ErrorCode = _state.ErrorCode, Reset = _state.ResetState, ReserveRemainSec = _state.ReserveRemainSec, }; for (int r = 0; r < 4; r++) { var room = _state.GetRoom(r + 1); var rr = s.Rooms[r]; // 비트맵 : bit0=급기(SA) 열림 / bit1=배기(RA) 열림 (StatusRecord.RoomRecord 와 일치) rr.Damper = (byte)((room.MemorySA != 0 ? 0x01 : 0) | (room.MemoryRA != 0 ? 0x02 : 0)); rr.Pm25 = room.Pm25; rr.Pm10 = room.Pm10; rr.Voc = room.Voc; rr.Co2 = room.Co2; rr.AirQuality = (byte)room.AirQuality; // 디퓨저가 응답마다 echo 하는 실제 LED 단수를 보고 → 디퓨저 수동 LED 제어가 대시보드에 반영됨 rr.LedDim = (byte)room.LedReported; rr.LoadScore = room.Level; rr.FinalVolume = _state.FanMode; rr.Temp = (byte)Math.Clamp(room.Temp, 0, 255); rr.Humi = (byte)Math.Clamp(room.Humi, 0, 255); } // VSP : 환기1~4, 바이패스, 공청1~4 for (int i = 1; i <= 4; i++) { s.Vsp[i - 1].Sa = _state.FanSAPreset_Vent[i]; s.Vsp[i - 1].Ea = _state.FanEAPreset_Vent[i]; } s.Vsp[4].Sa = _state.FanSAPreset_Bypass[1]; s.Vsp[4].Ea = _state.FanEAPreset_Bypass[1]; for (int i = 1; i <= 4; i++) { s.Vsp[4 + i].Sa = _state.FanSAPreset_Air[i]; s.Vsp[4 + i].Ea = _state.FanEAPreset_Air[i]; } // 히스테리시스 데드밴드 테이블 for (int i = 0; i < 3; i++) { s.HystTable[i].Pm25 = _state.Pm25Db[i]; s.HystTable[i].Pm10 = _state.Pm10Db[i]; s.HystTable[i].Voc = _state.VocDb[i]; s.HystTable[i].Co2 = _state.Co2Db[i]; } // 모드별 오염단계 임계표 for (int i = 0; i < 3; i++) for (int k = 0; k < 4; k++) { s.ThrTable[i].Co2[k] = _state.Co2Thr[i][k]; s.ThrTable[i].Pm25[k] = _state.Pm25Thr[i][k]; s.ThrTable[i].Pm10[k] = _state.Pm10Thr[i][k]; s.ThrTable[i].Voc[k] = _state.VocThr[i][k]; } return s; } public void SendStatus() { var frame = StatusEncoder.BuildStatusFrame(BuildRecord()); _ch.Send(frame, frame.Length); string autoTag = _state.RunMode == RunMode.Auto ? (_state.AutoConcentrate ? " 집중" : " 분산") : ""; // 집중/분산은 자동모드에서만 PacketSent?.Invoke($"HomeNet TX STATUS(0x81) [{(_state.PowerOn ? "ON" : "OFF")} {_state.RunMode} {_state.FanMode}단{autoTag}]"); } // ---- 수신 제어명령 적용 + ACK ---- void HandleFrame(byte cmd, byte[] pl) { byte result = 0; bool modeChanged = false; switch (cmd) { case CtrlFrame.CTRL_POWER: if (pl.Length >= 1) { bool on = pl[0] != 0; _state.PowerOn = on; if (on) { // 전원 ON : 환기 모드 + 풍량 1단. 디퓨저 개방·LED 는 AutoLogic 이 댐퍼 상태에 맞춰 구동. _state.RunMode = _state.SetRunMode = RunMode.Ventilation; _state.FanMode = _state.SetFanMode = 1; } else { // 전원 OFF : 정지 (댐퍼 닫힘·LED 소등은 AutoLogic 이 처리) _state.FanMode = _state.SetFanMode = 0; } // 전원 토글 시 수동 LED·댐퍼 해제 → 자동 추종 복귀 for (int r = 1; r <= 4; r++) { var rm = _state.GetRoom(r); rm.LedManual = false; rm.DamperManual = false; } modeChanged = true; } else result = 1; break; case CtrlFrame.CTRL_RUNMODE: if (pl.Length >= 1) { if (pl[0] == 0) _state.PowerOn = false; else { _state.PowerOn = true; RunMode m = pl[0] switch { 1 => RunMode.Ventilation, 2 => RunMode.Auto, 3 => RunMode.AirClean, 4 => RunMode.Bypass, _ => RunMode.Ventilation }; _state.RunMode = _state.SetRunMode = m; // 운전모드 전환 시 풍량 1단 (자동은 부하점수로 결정하므로 제외) if (m != RunMode.Auto) _state.FanMode = _state.SetFanMode = 1; } // 모드 전환 시 수동 댐퍼만 해제 → 새 모드는 기본(전실 개방)에서 시작. // 수동 LED 디밍값은 모드가 바뀌어도 유지(사용자 요청, 전원 OFF 시에만 해제). for (int r = 1; r <= 4; r++) _state.GetRoom(r).DamperManual = false; modeChanged = true; } else result = 1; break; case CtrlFrame.CTRL_FAN: if (pl.Length >= 1) { // 모드별 풍량 상한 : 바이패스 1단, 그 외 4단 (자동은 부하점수로 결정) byte sp = pl[0]; byte max = _state.RunMode == RunMode.Bypass ? (byte)1 : (byte)4; if (sp > max) sp = max; _state.FanMode = _state.SetFanMode = sp; modeChanged = true; } else result = 1; break; case CtrlFrame.CTRL_SUBMODE: // [type][on] 1수면 2조리 3회복 if (pl.Length >= 2) { if (pl[0] == 1) _state.ExtRunMode = (byte)(pl[1] != 0 ? 4 : 0); else if (pl[0] == 2) _state.HoodEnable = pl[1] != 0; else if (pl[0] == 3) _state.ExtRunMode = (byte)(pl[1] != 0 ? 1 : 0); else result = 1; } else result = 1; break; case CtrlFrame.CTRL_HOOD: if (pl.Length >= 1) _state.HoodEnable = pl[0] != 0; else result = 1; break; case CtrlFrame.CTRL_HYST_PRESET: if (pl.Length >= 1 && pl[0] < 3) _state.HystPreset = pl[0]; else result = 1; break; case CtrlFrame.CTRL_HYST_VALUE: // [preset][pm25][pm10][voc][co2] u16 BE if (pl.Length >= 9 && pl[0] < 3) { int ps = pl[0]; _state.Pm25Db[ps] = (ushort)((pl[1] << 8) | pl[2]); _state.Pm10Db[ps] = (ushort)((pl[3] << 8) | pl[4]); _state.VocDb[ps] = (ushort)((pl[5] << 8) | pl[6]); _state.Co2Db[ps] = (ushort)((pl[7] << 8) | pl[8]); } else result = 1; break; case CtrlFrame.CTRL_DAMPER: // [room][onoff] — 수동 댐퍼 : 비자동(환기/공청/바이패스)에서 위치 유지 if (pl.Length >= 2 && pl[0] >= 1 && pl[0] <= 4) { var rm = _state.GetRoom(pl[0]); int ang = pl[1] != 0 ? 110 : 0; rm.MemorySA = rm.CurrentSA = ang; rm.MemoryRA = rm.CurrentRA = ang; rm.DamperManual = true; // 자동로직이 덮어쓰지 않도록 (자동/모드전환 시 해제) } else result = 1; break; case CtrlFrame.CTRL_LED: // [room][dim] — 수동 조작 : 자동 추종 해제하고 지정값 유지 if (pl.Length >= 2 && pl[0] >= 1 && pl[0] <= 4) { var rm = _state.GetRoom(pl[0]); rm.LightBright = pl[1]; rm.LedManual = true; } else result = 1; break; case CtrlFrame.CTRL_RESERVE: // [hours 0~8] : N시간 후 전원 OFF (0=해제) if (pl.Length >= 1 && pl[0] <= 8) { int h = pl[0]; _state.ReserveHours = h; _state.ReserveRemainSec = h * 3600; // 0이면 해제. 카운트다운/전원OFF는 ReserveTick(1s)이 처리 } else result = 1; break; case CtrlFrame.REQ_STATUS: break; case CtrlFrame.CTRL_RESET: if (pl.Length >= 1) _state.ResetState = (byte)(pl[0] != 0 ? 1 : 0); else result = 1; break; case CtrlFrame.CTRL_VSP: // [group][index][sa(2)][ea(2)] if (pl.Length >= 6) result = SetVsp(pl[0], pl[1], (pl[2] << 8) | pl[3], (pl[4] << 8) | pl[5]); else result = 1; break; case CtrlFrame.CTRL_HYST_THR: // [preset][pollutant][L1~L4 u16] : 오염단계 임계 설정 if (pl.Length >= 10 && pl[0] < 3 && pl[1] < 4) { int ps = pl[0], g = pl[1]; ushort[] arr = g switch { 0 => _state.Co2Thr[ps], 1 => _state.Pm25Thr[ps], 2 => _state.Pm10Thr[ps], 3 => _state.VocThr[ps], _ => null! }; if (arr != null) for (int k = 0; k < 4; k++) arr[k] = (ushort)((pl[2 + k * 2] << 8) | pl[3 + k * 2]); else result = 1; } else result = 1; break; default: result = 1; break; } PacketReceived?.Invoke($"HomeNet RX CMD=0x{cmd:X2} len={pl.Length} → {(result == 0 ? "OK" : "ERR")}"); if (modeChanged) _seq.NotifyCommandChanged(); var ack = StatusEncoder.BuildAckFrame(cmd, result); _ch.Send(ack, ack.Length); if (cmd == CtrlFrame.REQ_STATUS) SendStatus(); } byte SetVsp(int grp, int idx, int sa, int ea) { if (grp == 0 && idx >= 1 && idx <= 4) { _state.FanSAPreset_Vent[idx] = (ushort)sa; _state.FanEAPreset_Vent[idx] = (ushort)ea; } else if (grp == 1 && idx == 1) { _state.FanSAPreset_Bypass[1] = (ushort)sa; _state.FanEAPreset_Bypass[1] = (ushort)ea; } else if (grp == 2 && idx >= 1 && idx <= 4) { _state.FanSAPreset_Air[idx] = (ushort)sa; _state.FanEAPreset_Air[idx] = (ushort)ea; } else return 1; return 0; } } }