Files
HECO2/Simulator/DiffuserSimulator/SlaveProtocol.cs
T
jeon 5a96a696b1 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>
2026-06-15 21:44:23 +09:00

420 lines
19 KiB
C#

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<byte[], byte>? MasterPacketReceived; // Slave mode: master packet received
public event Action<byte[], byte, byte>? SlavePacketReceived; // Master mode: slave response received (id1, id2)
public event Action<byte, bool>? ResponseSent; // id2, responded
public event Action<byte, byte>? MasterPollSent; // Master mode: id1, id2 polled
public event Action<string>? LogMessage;
public event Action<bool>? 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);
}
}
}