5a96a696b1
- 펌웨어(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>
282 lines
14 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|