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:
jeon
2026-06-16 09:29:03 +09:00
commit a502322188
630 changed files with 65126 additions and 0 deletions
@@ -0,0 +1,19 @@
namespace ERVSimulator.Protocol
{
public static class ChecksumHelper
{
public static byte Xor(byte[] data, int start, int length)
{
byte x = 0;
for (int i = 0; i < length; i++) x ^= data[start + i];
return x;
}
public static byte Add(byte[] data, int start, int length)
{
int s = 0;
for (int i = 0; i < length; i++) s += data[start + i];
return (byte)(s & 0xFF);
}
}
}
@@ -0,0 +1,132 @@
using System;
using System.Windows.Threading;
using ERVSimulator.Model;
using ErvProtocol; // 공용 Crc16 (bunbaegi CRC 도 표준 MODBUS 동일)
using RunMode = ERVSimulator.Model.RunMode; // ErvProtocol.RunMode 와 이름 충돌 해소
namespace ERVSimulator.Protocol
{
// 디퓨저 버스 마스터 (115200) <-> DiffuserSimulator(슬레이브)
// 규격 : Protocol/수정_Each_Room_Jushin_protocol_RS485_Rev1.2 (펌웨어 My_Uart.c bunbaegi 미러)
// 목적 : DiffuserSimulator 로부터 각실 센서값(PM2.5/PM10/VOC/CO2) 수신 → ErvState → 자동로직
// - 마스터 폴(29B, 0x10): 실/타입(SA/RA)별 전원·모드·풍량·LED·댐퍼 송신 (poll-response 구조상 필수)
// - 슬레이브 응답(39B, 0x01): 센서값 수신
// ※ ERVSim 은 각실 댐퍼+LED 를 자체 표시하지 않음(DiffuserSimulator 가 표시). 통신만 수행.
public class DiffuserMasterProtocol
{
readonly SerialChannel _ch;
readonly ErvState _state;
readonly Dispatcher _dispatcher;
readonly DispatcherTimer _pollTimer;
int _pollIdx; // (room1 SA),(room1 RA)...(room4 RA) round-robin
readonly byte[] _rx = new byte[39];
int _rxPos;
DateTime _lastByte = DateTime.MinValue;
static readonly TimeSpan FrameGap = TimeSpan.FromMilliseconds(40);
public event Action<string>? PacketReceived;
public event Action<string>? PacketSent;
public bool Verbose { get; set; } = false; // true면 모든 폴 로그
public DiffuserMasterProtocol(SerialChannel ch, ErvState state, Dispatcher dispatcher)
{
_ch = ch; _state = state; _dispatcher = dispatcher;
_ch.ByteReceived += OnByte;
_pollTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromMilliseconds(80) };
_pollTimer.Tick += (_, _) => { if (_ch.IsConnected) PollNext(); };
_pollTimer.Start();
}
byte DiffRunMode() => _state.RunMode switch
{
RunMode.Ventilation => 0x01,
RunMode.Auto => 0x02,
RunMode.Bypass => 0x04,
RunMode.AirClean => 0x08,
_ => 0x01,
};
void PollNext()
{
int room = _pollIdx / 2 + 1; // 1~4
byte id1 = (byte)(_pollIdx % 2 == 0 ? 0x01 : 0x02); // SA / RA
_pollIdx = (_pollIdx + 1) % 8;
var rm = _state.GetRoom(room);
var p = new byte[29];
p[0] = 0xAA; p[1] = 0x10; p[2] = id1; p[3] = (byte)room; p[4] = 0x00;
p[5] = (byte)(_state.PowerOn ? 1 : 0);
p[6] = DiffRunMode();
p[7] = _state.FanMode;
p[8] = (byte)rm.LightBright;
p[9] = (byte)rm.AirQuality;
p[10] = (byte)rm.CurrentSA;
p[11] = (byte)rm.CurrentRA;
ushort crc = Crc16.Modbus(p, 0, 27);
// lo-first : 펌웨어 CRC16()이 표준MODBUS 바이트스왑값 반환 + [27]=icrc>>8 배치 → 와이어는 리틀엔디안
p[27] = (byte)(crc & 0xFF);
p[28] = (byte)(crc >> 8);
_ch.Send(p, 29);
if (Verbose) PacketSent?.Invoke($"Diff TX poll room{room} {(id1 == 1 ? "SA" : "RA")} SA={rm.CurrentSA} RA={rm.CurrentRA} LED={rm.LightBright}");
}
void OnByte(byte b)
{
var now = DateTime.UtcNow;
if (now - _lastByte > FrameGap) _rxPos = 0;
_lastByte = now;
if (_rxPos == 0)
{
if (b == 0xAA) { _rx[0] = b; _rxPos = 1; }
}
else if (_rxPos == 1)
{
if (b == 0x01) { _rx[1] = b; _rxPos = 2; }
else _rxPos = (b == 0xAA) ? 1 : 0;
}
else
{
_rx[_rxPos++] = b;
if (_rxPos >= 39)
{
var copy = (byte[])_rx.Clone();
_dispatcher.BeginInvoke(new Action(() => HandleResponse(copy)));
_rxPos = 0;
}
}
}
void HandleResponse(byte[] p)
{
ushort rxcrc = (ushort)(p[37] | (p[38] << 8)); // lo-first (표준 리틀엔디안)
if (Crc16.Modbus(p, 0, 37) != rxcrc)
{
PacketReceived?.Invoke($"Diff RX CRC오류 {HexFormat.Bytes(p, 39)}");
return;
}
int id1 = p[2]; // 0x01 SA / 0x02 RA
int room = p[3]; // 1~4
if (room < 1 || room > 4) return;
// 센서 (응답 39B, 빅엔디안) : LED[8] PM10[12,13] PM2.5[16,17] 습도[20,21] 온도[22,23] VOC[24,25] CO2[28,29]
int led = p[8]; // 디퓨저가 echo 한 실제 LED 단수 (수동 제어 시 ERV 명령과 다를 수 있음)
int pm10 = (p[12] << 8) | p[13];
int pm25 = (p[16] << 8) | p[17];
int humi = (p[20] << 8) | p[21];
int temp = (p[22] << 8) | p[23];
int voc = (p[24] << 8) | p[25];
int co2 = (p[28] << 8) | p[29];
var rm = _state.GetRoom(room);
bool changed = rm.Co2 != co2 || rm.Pm25 != pm25 || rm.Pm10 != pm10 || rm.Voc != voc || rm.Temp != temp || rm.Humi != humi || rm.LedReported != led;
rm.Pm10 = pm10; rm.Pm25 = pm25; rm.Voc = voc; rm.Co2 = co2; rm.Temp = temp; rm.Humi = humi; rm.LedReported = led;
if (changed || Verbose)
PacketReceived?.Invoke($"Diff RX {rm.Name} 센서 CO2={co2} PM2.5={pm25} PM10={pm10} VOC={voc} 온도={temp} 습도={humi} LED={led} (from {(id1 == 1 ? "SA" : "RA")})");
}
}
}
@@ -0,0 +1,281 @@
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<string>? PacketReceived;
public event Action<string>? 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;
}
}
}
@@ -0,0 +1,143 @@
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<string>? PacketReceived;
public event Action<string>? 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}" : "")}");
}
}
}
@@ -0,0 +1,214 @@
using System;
using ERVSimulator.Model;
namespace ERVSimulator.Protocol
{
// 룸콘 프로토콜 (UART2/SC0)
// 패킷: AA | Cmd | D[2..12] | XOR_SUM[13] | EE (15 byte)
// 펌웨어 [My_RJ2.c] rx_roomcon_check() + roomcon_parsing()
public class RoomConProtocol
{
public const byte HEADER = 0xAA;
public const byte TAIL = 0xEE;
public const int PACKET_LEN = 15;
// Cmd (Rx_roomcon232_buffer[1])
public const byte CMD_NORMAL = 0x00; // 상태 폴링
public const byte CMD_EVENT = 0x01; // 모드/팬 변경 이벤트
public const byte CMD_RESTART1 = 0x02; // 환기단 preset 요청
public const byte CMD_RESTART2 = 0x12; // bypass/air preset 요청
public const byte CMD_VSP = 0x03; // 테스트모드 진입
public const byte CMD_EXIT = 0x04; // 테스트모드 종료
public const byte CMD_HOOD_INFO = 0x0A; // ERV→룸콘 후드 연동 통지 (힘펠 V3.7 RX_DATA_HOOD_INFO)
readonly SerialChannel _ch;
readonly ErvState _state;
readonly DamperSequencer _seq;
readonly System.Windows.Threading.Dispatcher _dispatcher;
readonly byte[] _rx = new byte[PACKET_LEN];
int _rxPos;
bool _hoodLinkReported; // 마지막으로 룸콘에 통지한 후드 연동 상태(변화 시에만 0x0A 송신)
DateTime _lastByte = DateTime.MinValue;
static readonly TimeSpan FrameGap = TimeSpan.FromMilliseconds(50);
public event Action<string>? PacketReceived;
public event Action<string>? PacketSent;
public RoomConProtocol(SerialChannel ch, ErvState state, DamperSequencer seq,
System.Windows.Threading.Dispatcher dispatcher)
{
_ch = ch; _state = state; _seq = seq; _dispatcher = dispatcher;
_ch.ByteReceived += OnByte;
}
void OnByte(byte b)
{
var now = DateTime.UtcNow;
if (now - _lastByte > FrameGap) _rxPos = 0;
_lastByte = now;
// 펌웨어와 동일한 byte 파서 (My_RJ2.c:37)
if (_rxPos == 0)
{
if (b != HEADER) return;
_rx[_rxPos++] = b;
return;
}
if (_rxPos >= 1 && _rxPos <= 12)
{
_rx[_rxPos++] = b;
return;
}
if (_rxPos == 13)
{
byte cksum = ChecksumHelper.Xor(_rx, 0, 13);
if (cksum != b) { _rxPos = 0; return; }
_rx[_rxPos++] = b;
return;
}
if (_rxPos == 14)
{
_rxPos = 0;
if (b != TAIL) return;
byte[] copy = (byte[])_rx.Clone();
_dispatcher.BeginInvoke(new Action(() => HandlePacket(copy)));
}
}
void HandlePacket(byte[] p)
{
PacketReceived?.Invoke($"RoomCon RX: {HexFormat.Bytes(p, 14)} EE");
byte cmd = p[1];
switch (cmd)
{
case CMD_EVENT: HandleEvent(p); break;
case CMD_NORMAL: HandleNormal(p); break;
case CMD_RESTART1: HandleRestart1(); break;
case CMD_RESTART2: HandleRestart2(); break;
case CMD_VSP: HandleVsp(p); break;
case CMD_EXIT: HandleExit(); break;
default:
PacketReceived?.Invoke($" (unknown RoomCon cmd 0x{cmd:X2})");
break;
}
}
// [My_RJ2.c:387] RX_DATA_MODE_EVENT - 운전 모드/팬 변경
void HandleEvent(byte[] p)
{
byte runMode = p[2];
byte fanMode = p[3];
_state.RunMode = (RunMode)runMode;
_state.SetRunMode = (RunMode)runMode;
_state.FanMode = fanMode;
_state.SetFanMode = fanMode;
// VENT && fan=0 ⇒ Power OFF 진입
_state.PowerOn = !(runMode == 0 && fanMode == 0);
// 예약 (룸콘 EVENT [10]=flag / [11]=시 / [12]=분). HOMENET STATUS(reserve)로도 전달 → 대시보드 반영
if (p[10] == 1)
{
int hours = p[11];
_state.ReserveHours = hours; // 0이면 해제
_state.ReserveRemainSec = hours * 3600 + p[12] * 60; // 카운트다운/전원OFF는 ReserveTick(1s) 처리
}
_seq.NotifyCommandChanged();
// 응답: AA 01 RunMode FanMode 00 misc... XOR EE (펌웨어 [My_RJ2.c:489])
var tx = NewPacket();
tx[1] = 0x01;
tx[2] = runMode;
tx[3] = fanMode;
tx[5] = 0; // Heater/UV/Kijer
tx[7] = _state.ErrorCode; // ErrorCode (E02/E07/E09/E10/COLD 비트맵)
tx[8] = 0; // Out_Temperature sign
tx[9] = 20 + 25; // Out_Temperature = 25
tx[10] = 20 + 22; // In_Temperature = 22
FinalizeAndSend(tx);
}
// [My_RJ2.c:327] RX_DATA_MODE_NORMAL - 상태 폴링 응답
void HandleNormal(byte[] p)
{
// 후드 연동 상태가 바뀌면 HOOD_INFO(0x0A)로 통지 (힘펠 V3.7, 펌웨어 Hood_info_command).
// HoodStatus = 연동운전중(후드 가동 → 메이크업 에어). 후드 OFF로 ERV 복귀 시 0x80(OFF) 전송.
if (_state.HoodStatus != _hoodLinkReported)
{
_hoodLinkReported = _state.HoodStatus;
var hi = NewPacket();
hi[1] = CMD_HOOD_INFO;
hi[2] = _state.HoodStatus ? (byte)RunMode.Ventilation : (byte)_state.SetRunMode; // 연동 시 환기
hi[3] = _state.HoodStatus ? (byte)1 : _state.SetFanMode;
hi[6] = _state.HoodStatus ? (byte)0x81 : (byte)0x80; // 후드 연동 ON / OFF
FinalizeAndSend(hi);
return;
}
var tx = NewPacket();
tx[1] = 0x07; // COMMAND_CONTROLL
tx[2] = (byte)_state.SetRunMode;
tx[3] = _state.SetFanMode;
tx[4] = 0; // Auto_Mode
tx[5] = 0;
tx[7] = _state.ErrorCode; // ErrorCode 도 동봉
FinalizeAndSend(tx);
}
// [My_RJ2.c:522] RX_DATA_MODE_RESTART1 - 환기 1~4단 preset
void HandleRestart1()
{
var tx = NewPacket();
tx[1] = 0x02;
tx[4] = 0x10;
tx[5] = (byte)_state.FanSAPreset_Vent[1]; tx[6] = (byte)_state.FanEAPreset_Vent[1];
tx[7] = (byte)_state.FanSAPreset_Vent[2]; tx[8] = (byte)_state.FanEAPreset_Vent[2];
tx[9] = (byte)_state.FanSAPreset_Vent[3]; tx[10] = (byte)_state.FanEAPreset_Vent[3];
tx[11] = (byte)_state.FanSAPreset_Vent[4]; tx[12] = (byte)_state.FanEAPreset_Vent[4];
FinalizeAndSend(tx);
}
// [My_RJ2.c:556] RX_DATA_MODE_RESTART2 - bypass/air preset
void HandleRestart2()
{
var tx = NewPacket();
tx[1] = 0x12;
tx[4] = 0x10;
tx[5] = (byte)_state.FanSAPreset_Bypass[1]; tx[6] = (byte)_state.FanEAPreset_Bypass[1];
tx[7] = (byte)_state.FanSAPreset_Air[1];
tx[8] = (byte)_state.FanSAPreset_Air[2];
tx[9] = (byte)_state.FanSAPreset_Air[3];
tx[10] = (byte)_state.FanSAPreset_Air[4];
FinalizeAndSend(tx);
}
// [My_RJ2.c:579] RX_DATA_MODE_VSP - 테스트 모드 진입 (preset 갱신)
void HandleVsp(byte[] p)
{
// 본 시뮬레이터에선 RX만 기록, preset 변경은 생략
PacketReceived?.Invoke($" VSP select={p[3]} sa={p[4]} ea={p[5]}");
}
void HandleExit()
{
PacketReceived?.Invoke(" VSP exit");
}
byte[] NewPacket()
{
var tx = new byte[PACKET_LEN];
tx[0] = HEADER;
return tx;
}
void FinalizeAndSend(byte[] tx)
{
tx[13] = ChecksumHelper.Xor(tx, 0, 13);
tx[14] = TAIL;
if (_ch.Send(tx, PACKET_LEN))
PacketSent?.Invoke($"RoomCon TX: {HexFormat.Bytes(tx, 15)}");
}
}
}
@@ -0,0 +1,116 @@
using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;
namespace ERVSimulator.Protocol
{
// 공용 시리얼 채널 - byte 단위 수신 콜백 + 송신 helper
public class SerialChannel : IDisposable
{
private SerialPort? _port;
private CancellationTokenSource? _cts;
private bool _disposed;
public string ChannelName { get; }
public event Action<byte>? ByteReceived;
public event Action<string>? Log;
public event Action<bool>? ConnectionChanged;
public bool IsConnected => _port?.IsOpen == true;
public SerialChannel(string channelName) { ChannelName = channelName; }
public static string[] GetAvailablePorts() => SerialPort.GetPortNames();
public bool Connect(string portName, int baudRate)
{
try
{
Disconnect();
_port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One)
{
ReadTimeout = 100,
WriteTimeout = 200,
Handshake = Handshake.None,
DtrEnable = false,
RtsEnable = false,
};
_port.Open();
_cts = new CancellationTokenSource();
_ = Task.Run(() => ReadLoop(_cts.Token));
Log?.Invoke($"[{ChannelName}] Connected {portName} @ {baudRate}");
ConnectionChanged?.Invoke(true);
return true;
}
catch (Exception ex)
{
Log?.Invoke($"[{ChannelName}] Connect FAIL: {ex.Message}");
return false;
}
}
public void Disconnect()
{
try { _cts?.Cancel(); } catch { }
try { _port?.Close(); } catch { }
_port?.Dispose();
_port = null;
ConnectionChanged?.Invoke(false);
}
void ReadLoop(CancellationToken ct)
{
var buf = new byte[64];
while (!ct.IsCancellationRequested && _port != null && _port.IsOpen)
{
try
{
int n = _port.Read(buf, 0, buf.Length);
for (int i = 0; i < n; i++) ByteReceived?.Invoke(buf[i]);
}
catch (TimeoutException) { /* expected */ }
catch (Exception ex)
{
Log?.Invoke($"[{ChannelName}] ReadLoop error: {ex.Message}");
break;
}
}
}
public bool Send(byte[] data, int length)
{
if (_port == null || !_port.IsOpen) return false;
try
{
_port.Write(data, 0, length);
return true;
}
catch (Exception ex)
{
Log?.Invoke($"[{ChannelName}] Send FAIL: {ex.Message}");
return false;
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Disconnect();
}
}
public static class HexFormat
{
public static string Bytes(byte[] data, int length)
{
var sb = new System.Text.StringBuilder(length * 3);
for (int i = 0; i < length; i++)
{
if (i > 0) sb.Append(' ');
sb.Append(data[i].ToString("X2"));
}
return sb.ToString();
}
}
}