using System; using System.IO.Ports; using System.Threading; using System.Threading.Tasks; namespace DiffuserSimulator { public class RoomSimData { public byte Id2; public bool Enabled; /* Master 모드 폴링 toggle — 급기/배기 디퓨저 별 활성화 */ public bool PollSA = false; public bool PollRA = false; public byte Power = 0x01; public byte RunMode = 0x01; public byte FanSpeed = 0x00; public byte LedBrightness = 0x00; public byte AirQualityStatus = 0x03; // 보통 - green public byte DamperAngleSA = 0; public byte DamperAngleEA = 0; /* 수동 닫기 오버라이드 (Slave) — true면 마스터 개방명령 무시하고 0 유지 */ public bool ManualCloseSA = false; public bool ManualCloseRA = false; /* LED 디밍 수동 제어 (Slave) — true면 마스터 LED 명령 무시하고 슬라이더 값 유지 */ public bool ManualLed = false; /* 제품 모드 : false=DL(byte24~25 VOC 송신) / true=힘펠(TVOC 송신). RA2 활성에도 영향 (거실 한정) */ public bool Himpel = false; /* RA(배기) 디퓨저 응답 활성 — 거실2는 힘펠 모드에서만 RA 동작 (DL 모드는 SA만) */ public bool RaActive = true; /* 디폴트: 보통 preset */ public int PM10 = 30; public int PM25 = 25; public int PM4 = 20; public int PM1 = 10; public int Humidity = 50; public int Temperature = 25; public int TVOC = 250; public int VOC = 115; /* VOC index (0~500), 보통 preset */ public int NOx = 0; public int CO2 = 850; public ushort ErrorCode = 0x0000; public byte VersionMajor = 0x01; public byte VersionMinor = 0x00; } public enum SimMode { Slave, Master } public class SlaveProtocol : IDisposable { private SerialPort? _serialPort; private CancellationTokenSource? _listenCts; private readonly object _lock = new(); private bool _disposed; private bool _responding; // Rooms 인덱스 : 0=거실(Id2 1), 1=방1(Id2 2), 2=방2(Id2 3), 3=방3(Id2 4), 4=방4(Id2 5), 5=거실2(Id2 0). // 거실2(RA2/SA2)는 거실 패널이 제어. Rev1.3 : ID2 0x00=거실2, 0x01=거실, 0x02~0x05=방1~4. public RoomSimData[] Rooms = new RoomSimData[6]; public const int LivingRoom2Index = 5; public SimMode Mode { get; private set; } = SimMode.Slave; public event Action? MasterPacketReceived; // Slave mode: master packet received public event Action? SlavePacketReceived; // Master mode: slave response received (id1, id2) public event Action? ResponseSent; // id2, responded public event Action? MasterPollSent; // Master mode: id1, id2 polled public event Action? LogMessage; public event Action? ConnectionChanged; public bool IsConnected => _serialPort?.IsOpen == true; public bool IsResponding => _responding; public SlaveProtocol() { for (int i = 0; i < 5; i++) Rooms[i] = new RoomSimData { Id2 = (byte)(i + 1) }; Rooms[LivingRoom2Index] = new RoomSimData { Id2 = 0x00 }; // 거실2 } // ID2 → Rooms 인덱스. 거실2(0)=5, 거실(1)=0, 방1~4(2~5)=1~4. 범위 밖이면 -1. public static int Id2ToIndex(byte id2) => id2 == 0 ? LivingRoom2Index : (id2 <= 5 ? id2 - 1 : -1); public static ushort CalcCRC16(byte[] data, int length) { ushort crc = 0xFFFF; for (int i = 0; i < length; i++) { crc ^= data[i]; for (int j = 0; j < 8; j++) crc = ((crc & 1) != 0) ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1); } return crc; } public string[] GetAvailablePorts() => SerialPort.GetPortNames(); public bool Connect(string portName) { try { Disconnect(); _serialPort = new SerialPort(portName) { BaudRate = 115200, DataBits = 8, StopBits = StopBits.One, Parity = Parity.None, ReadTimeout = 100, WriteTimeout = 500 }; _serialPort.Open(); Log($"[연결] {portName} (115200, 8N1)"); ConnectionChanged?.Invoke(true); return true; } catch (Exception ex) { Log($"[오류] 연결 실패: {ex.Message}"); ConnectionChanged?.Invoke(false); return false; } } public void Disconnect() { StopResponding(); try { if (_serialPort?.IsOpen == true) { _serialPort.Close(); Log("[연결 해제]"); } } catch { } _serialPort?.Dispose(); _serialPort = null; ConnectionChanged?.Invoke(false); } public void StartResponding(int intervalMs = 1000) { StopResponding(); Mode = SimMode.Slave; _responding = true; _listenCts = new CancellationTokenSource(); var token = _listenCts.Token; // 능동 송신 제거됨 — 마스터 polling 수신 시에만 응답. // (이전: 능동 송신 + 마스터 응답 dual mode → STM32 master 와 bus 충돌 우려 + STM32 측 cycle 안에 garbage 유입 가능) // 수신 Task: 마스터 폴링이 오면 응답 Task.Run(() => { while (!token.IsCancellationRequested) { try { if (_serialPort?.IsOpen != true) { Thread.Sleep(50); continue; } if (_serialPort.BytesToRead < 1) { Thread.Sleep(3); continue; } byte b = (byte)_serialPort.ReadByte(); if (b != 0xAA) continue; byte[] rxBuf = new byte[29]; rxBuf[0] = 0xAA; int totalRead = 1, remaining = 28, retries = 100; while (remaining > 0 && retries-- > 0) { if (_serialPort.BytesToRead > 0) { int r = _serialPort.Read(rxBuf, totalRead, remaining); totalRead += r; remaining -= r; } else Thread.Sleep(2); } if (totalRead < 29) continue; ushort rxCrc = (ushort)(rxBuf[27] | (rxBuf[28] << 8)); ushort calcCrc = CalcCRC16(rxBuf, 27); if (rxCrc != calcCrc) { Log($"[CRC오류] 수신:0x{rxCrc:X4} 계산:0x{calcCrc:X4}"); continue; } if (rxBuf[1] != 0x10) continue; byte id2 = rxBuf[3]; Log($"[RX] {BitConverter.ToString(rxBuf)}"); /* MasterPacketReceived event invoke 는 room.DamperAngleSA/EA + LED 등 실제 갱신 이후로 이동 — UI 가 새 값 표시. 이전엔 갱신 전 호출이라 UI 가 한 cycle 전 (옛) 값 표시 → 사용자가 0/110 mismatch 보고. */ int ri = Id2ToIndex(id2); if (ri < 0) continue; var room = Rooms[ri]; byte id1 = rxBuf[2]; // 응답 조건: 방 Enabled 면 SA/RA 모두 응답. // (배기/급기 토글은 댐퍼 열림/닫힘 표시용 — 각도 연동, 응답 게이트 아님) if (!room.Enabled) { ResponseSent?.Invoke(id2, false); continue; } // RA(배기 0x02) 디퓨저 비활성(거실2 DL 모드)이면 RA 폴링 무응답 if (id1 == 0x02 && !room.RaActive) { ResponseSent?.Invoke(id2, false); continue; } // Option B 패킷 구분 (250624 dump 패턴 일치): // byte 5 = 0x01 → 명령 (Power ON, state 적용) // byte 5 = 0x00 → 폴링 (상태 조회만, state 무변경) // 폴링에서 byte 10/11/8 = 0 을 그대로 적용하면 댐퍼/LED 가 0 으로 reset 됨. bool isCommand = (rxBuf[5] != 0x00); if (isCommand) { // 마스터 명령 적용 — ID1 별로 해당 type 의 필드만 갱신. // ID1=0x01 (SA): damper SA 만 // ID1=0x02 (RA): damper RA + LED + 공통 (Power/RunMode/Fan/Color) if (id1 == 0x01) { room.DamperAngleSA = room.ManualCloseSA ? (byte)0 : rxBuf[10]; } else if (id1 == 0x02) { room.DamperAngleEA = room.ManualCloseRA ? (byte)0 : rxBuf[11]; // 힘펠 모드는 LED 디밍 미사용 → 마스터 byte 8 무시(갱신 안 함) if (!room.ManualLed && !room.Himpel) room.LedBrightness = rxBuf[8]; room.Power = rxBuf[5]; room.RunMode = rxBuf[6]; room.FanSpeed = rxBuf[7]; if (rxBuf[9] != 0) room.AirQualityStatus = rxBuf[9]; } } /* room 갱신 후 UI 동기화 event. UI 의 TbSAAngle/TbEAAngle/LED 가 새 값 표시. */ MasterPacketReceived?.Invoke(rxBuf, id2); // 응답 전송 byte[] tx = BuildResponse(room, id1, id2); lock (_lock) { _serialPort?.Write(tx, 0, tx.Length); } Log($"[TX 응답] {BitConverter.ToString(tx)}"); ResponseSent?.Invoke(id2, true); } catch (TimeoutException) { } catch (OperationCanceledException) { break; } catch (Exception ex) { if (!token.IsCancellationRequested) Log($"[오류] {ex.Message}"); Thread.Sleep(100); } } }, token); Log("[통신 시작] 마스터 응답 모드"); } public void StopResponding() { _responding = false; _listenCts?.Cancel(); _listenCts?.Dispose(); _listenCts = null; } // ========================= Master Mode ========================= // 시뮬레이터가 마스터 역할: enabled 된 room 들을 SA(0x01) + RA(0x02) 로 순회 polling. // STM32 (slave) 는 ID1/ID2 매칭 시 39 byte 응답. public void StartMasterPolling(int intervalMs = 1000) { StopResponding(); Mode = SimMode.Master; _responding = true; _listenCts = new CancellationTokenSource(); var token = _listenCts.Token; Task.Run(async () => { try { while (!token.IsCancellationRequested) { if (_serialPort?.IsOpen != true) { await Task.Delay(100, token); continue; } // Active polling slot 수 (Enabled + PollSA/RA 켜진 것) int activeSlots = 0; for (int i = 0; i < Rooms.Length; i++) { if (!Rooms[i].Enabled) continue; if (Rooms[i].PollSA) activeSlots++; if (Rooms[i].PollRA && Rooms[i].RaActive) activeSlots++; } if (activeSlots == 0) { await Task.Delay(intervalMs, token); continue; } int slotMs = Math.Max(20, intervalMs / activeSlots); // 각 enabled room 에 대해 SA → RA 폴링 (toggle 켜진 것만) foreach (byte id1 in new byte[] { 0x01, 0x02 }) { for (int i = 0; i < Rooms.Length && !token.IsCancellationRequested; i++) { var room = Rooms[i]; if (!room.Enabled) continue; if (id1 == 0x01 && !room.PollSA) continue; if (id1 == 0x02 && (!room.PollRA || !room.RaActive)) continue; byte[] tx = BuildMasterPacket(room, id1); lock (_lock) { _serialPort?.Write(tx, 0, tx.Length); } Log($"[TX-M] ID1=0x{id1:X2} ID2=0x{room.Id2:X2} {BitConverter.ToString(tx)}"); MasterPollSent?.Invoke(id1, room.Id2); // Slave 응답 대기 (39 byte, 80ms timeout) byte[]? resp = TryReceiveSlaveResponse(80); if (resp != null) { Log($"[RX-S] ID1=0x{resp[2]:X2} ID2=0x{resp[3]:X2} {BitConverter.ToString(resp)}"); SlavePacketReceived?.Invoke(resp, resp[2], resp[3]); // 받은 SEN66 값을 room 에 갱신 (sensor 만) int ri = Id2ToIndex(resp[3]); if (ri >= 0) { var r = Rooms[ri]; r.PM10 = (resp[12] << 8) | resp[13]; r.PM4 = (resp[14] << 8) | resp[15]; r.PM25 = (resp[16] << 8) | resp[17]; r.PM1 = (resp[18] << 8) | resp[19]; r.Humidity = (resp[20] << 8) | resp[21]; r.Temperature = (resp[22] << 8) | resp[23]; r.TVOC = (resp[24] << 8) | resp[25]; r.NOx = (resp[26] << 8) | resp[27]; r.CO2 = (resp[28] << 8) | resp[29]; } } await Task.Delay(slotMs, token); } } } } catch (OperationCanceledException) { } catch (Exception ex) { if (!token.IsCancellationRequested) Log($"[마스터 오류] {ex.Message}"); } }, token); Log("[통신 시작] Master Mode — Polling 송신"); } public byte[] BuildMasterPacket(RoomSimData room, byte id1) { byte[] p = new byte[29]; p[0] = 0xAA; p[1] = 0x10; p[2] = id1; p[3] = room.Id2; p[4] = 0x00; // 실제 protocol dump 분석 결과 0x80 Control bit 없음 — raw 값 그대로. p[5] = room.Power; p[6] = room.RunMode; p[7] = room.FanSpeed; // LED 디밍은 RA(0x02) 명령 패킷에만 전송 — SA(0x01)·힘펠은 0 p[8] = (id1 == 0x02 && !room.Himpel) ? room.LedBrightness : (byte)0; p[9] = room.AirQualityStatus; p[10] = room.DamperAngleSA; p[11] = room.DamperAngleEA; // byte 12~26: 0 (RPM / Reset / 예약 등 미사용) ushort crc = CalcCRC16(p, 27); p[27] = (byte)(crc & 0xFF); p[28] = (byte)((crc >> 8) & 0xFF); return p; } private byte[]? TryReceiveSlaveResponse(int timeoutMs) { if (_serialPort?.IsOpen != true) return null; long deadline = Environment.TickCount64 + timeoutMs; // header 0xAA 대기 while (Environment.TickCount64 < deadline) { if (_serialPort.BytesToRead < 1) { Thread.Sleep(2); continue; } byte b = (byte)_serialPort.ReadByte(); if (b != 0xAA) continue; byte[] buf = new byte[39]; buf[0] = 0xAA; int total = 1, remain = 38; while (remain > 0 && Environment.TickCount64 < deadline) { if (_serialPort.BytesToRead > 0) { int r = _serialPort.Read(buf, total, remain); total += r; remain -= r; } else Thread.Sleep(2); } if (total < 39) return null; if (buf[1] != 0x01) continue; // not slave ushort rxCrc = (ushort)(buf[37] | (buf[38] << 8)); ushort calc = CalcCRC16(buf, 37); if (rxCrc != calc) { Log($"[CRC오류] 수신:0x{rxCrc:X4} 계산:0x{calc:X4}"); return null; } return buf; } return null; } public byte[] BuildResponse(RoomSimData room, byte id1, byte id2) { byte[] p = new byte[39]; p[0] = 0xAA; p[1] = 0x01; p[2] = id1; p[3] = id2; p[4] = 0x00; p[5] = room.Power; p[6] = room.RunMode; p[7] = room.FanSpeed; p[8] = room.LedBrightness; p[9] = room.AirQualityStatus; p[10] = room.DamperAngleSA; p[11] = room.DamperAngleEA; void W16(int idx, int val) { p[idx] = (byte)((val >> 8) & 0xFF); p[idx + 1] = (byte)(val & 0xFF); } W16(12, room.PM10); W16(14, room.PM4); W16(16, room.PM25); W16(18, room.PM1); W16(20, room.Humidity); W16(22, room.Temperature); W16(24, room.Himpel ? room.TVOC : room.VOC); /* byte 24,25 : DL=VOC / 힘펠=TVOC (Rev1.3) */ W16(26, room.NOx); W16(28, room.CO2); p[30] = 0; p[31] = 0; p[32] = 0; p[33] = (byte)((room.ErrorCode >> 8) & 0xFF); p[34] = (byte)(room.ErrorCode & 0xFF); p[35] = room.VersionMajor; p[36] = room.VersionMinor; ushort crc = CalcCRC16(p, 37); p[37] = (byte)(crc & 0xFF); p[38] = (byte)((crc >> 8) & 0xFF); return p; } private void Log(string msg) => LogMessage?.Invoke($"[{DateTime.Now:HH:mm:ss.fff}] {msg}"); public void Dispose() { if (_disposed) return; _disposed = true; Disconnect(); GC.SuppressFinalize(this); } } }