Files
HECO2/Simulator/ERVSimulator/Program/Protocol/HomeNetProtocol.cs
T
jeon a502322188 chore: HERV 통합 저장소 재초기화 커밋
손상된 .git 히스토리(missing tree)로 재초기화 후 작업트리 전체 커밋.
.claude/ 만 제외(로컬 에이전트 설정). 구 저장소 백업(.git_corrupt_backup/) 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:32:17 +09:00

282 lines
14 KiB
C#

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;
}
}
}