chore: HERV 통합 저장소 재초기화 커밋
손상된 .git 히스토리(missing tree)로 재초기화 후 작업트리 전체 커밋. .claude/ 만 제외(로컬 에이전트 설정). 구 저장소 백업(.git_corrupt_backup/) 포함. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
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;
|
||||
|
||||
// CRC 바이트순서 = lo-first(표준 리틀엔디안). 펌웨어 CRC16()이 표준MODBUS의
|
||||
// 바이트스왑값을 반환 + [27]=icrc>>8 배치 → 두 스왑 상쇄 → 와이어는 [27]=하위,[28]=상위.
|
||||
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);
|
||||
// lo-first (표준 리틀엔디안) — 와이어 [27]=하위, [28]=상위
|
||||
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)); // lo-first (표준 리틀엔디안)
|
||||
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);
|
||||
// lo-first : 펌웨어 RX가 (Rx[37]<<8)|Rx[38] 로 읽고 CRC16()=스왑값과 비교 → lo-first로 보내야 일치
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user