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>
This commit is contained in:
2026-06-15 21:44:23 +09:00
commit 5a96a696b1
265 changed files with 76458 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
using ErvProtocol;
namespace ErvDashboard.Api
{
// PC 대시보드 ↔ ERV (PC_ERV_Protocol.md) 통신을 함수로 감싼 API.
// 내부적으로는 485 프레임(CtrlFrame 송신 / STATUS 수신)을 그대로 사용한다.
// 즉 전송 방식(프로토콜)은 불변이고, 호출부가 바이트 대신 의미 있는 함수를 쓰도록 캡슐화한 것.
public interface IErvApi : IDisposable
{
// ---- 연결 ----
bool IsConnected { get; }
bool Connect(string port, int baud);
void Disconnect();
event Action<bool>? ConnectionChanged;
// ---- 수신 ----
event Action<StatusRecord>? StatusReceived; // STATUS 디코드 완료
event Action<string>? Log; // 프레임/이벤트 hex 로그
void RequestStatus(); // STATUS 요청 (0x0A)
// 최근 수신한 STATUS 기준 각실(room 1~4) 상태 조회. 수신 이력이 없거나 room 범위 밖이면 false.
bool GetRoomStatus(int room, out bool damperSa, out bool damperEa, out AirQuality airQuality, out int led);
// ---- 제어(기본) ----
void SetPower(bool on); // 0x01
void SetRunMode(RunMode mode); // 0x02
void SetFan(int speed); // 0x03 (0~4)
void SetSubMode(SubModeType type, bool on); // 0x04
void SetHood(bool on); // 0x05
void SetReserve(int hours); // 0x0E (0~8, 0=해제)
void SetReset(bool on); // 0x0B
// ---- 각실 ----
void SetDiffuserDamper(int room, int type, bool open); // 0x08 (type 0=급기/1=배기)
void SetDiffuserLed(int room, int dim); // 0x09 (0~9)
// ---- 풍량 VSP ----
void SetVsp(int group, int index, int sa, int ea); // 0x0C
// ---- 히스테리시스 ----
void SetHystPreset(HystPreset preset); // 0x06
void SetHystDeadband(int preset, int pm25, int pm10, int voc, int co2); // 0x07
void SetHystThreshold(int preset, int pollutant, int l1, int l2, int l3, int l4); // 0x0D
// ---- 데모/테스트 (합성 STATUS를 수신 경로로 주입) ----
void InjectDemoStatus(int tick);
}
}
+113
View File
@@ -0,0 +1,113 @@
using ErvDashboard.Protocol; // SerialChannel, HexFormat
using ErvProtocol;
namespace ErvDashboard.Api
{
// IErvApi 의 RS485(시리얼) 구현. 시리얼 채널 + 공용 프레임 파서/빌더(CtrlFrame, StatusDecoder)를 내장한다.
// UI 비종속 : STATUS 는 StatusRecord 이벤트로만 알리고, DashboardState 매핑은 호출부(MainWindow)가 담당.
public sealed class SerialErvApi : IErvApi
{
readonly SerialChannel _ch = new();
readonly FrameParser _parser = new();
DateTime _lastByte = DateTime.MinValue;
static readonly TimeSpan FrameGap = TimeSpan.FromMilliseconds(60);
StatusRecord? _lastStatus; // 최근 수신 STATUS (GetRoomStatus 조회용)
public event Action<bool>? ConnectionChanged;
public event Action<StatusRecord>? StatusReceived;
public event Action<string>? Log;
public bool IsConnected => _ch.IsConnected;
public static string[] GetAvailablePorts() => SerialChannel.GetAvailablePorts();
public SerialErvApi()
{
_ch.ByteReceived += OnByte;
_ch.Log += s => Log?.Invoke(s);
_ch.ConnectionChanged += b => ConnectionChanged?.Invoke(b);
_parser.OnFrame += HandleFrame;
_parser.OnError += m => Log?.Invoke("RX " + m);
}
public bool Connect(string port, int baud) => _ch.Connect(port, baud);
public void Disconnect() => _ch.Disconnect();
// 최근 STATUS 기준 각실(room 1~4) 상태 조회
public bool GetRoomStatus(int room, out bool damperSa, out bool damperEa, out AirQuality airQuality, out int led)
{
damperSa = false; damperEa = false; airQuality = AirQuality.Normal; led = 0;
var s = _lastStatus;
if (s == null || room < 1 || room > 4) return false;
var r = s.Rooms[room - 1];
damperSa = r.DamperSa;
damperEa = r.DamperEa;
airQuality = (AirQuality)r.AirQuality;
led = r.LedDim;
return true;
}
// 시리얼 바이트 → 공용 파서 (프레임 갭 시 동기 리셋)
void OnByte(byte b)
{
var now = DateTime.UtcNow;
if (now - _lastByte > FrameGap) _parser.Reset();
_lastByte = now;
_parser.FeedByte(b);
}
void HandleFrame(byte cmd, byte[] p)
{
switch (cmd)
{
case StatusDecoder.STATUS:
Log?.Invoke($"RX STATUS ({p.Length}B)");
var rec = StatusDecoder.Decode(p);
if (rec != null) { _lastStatus = rec; StatusReceived?.Invoke(rec); }
else Log?.Invoke($" STATUS too short ({p.Length}<{StatusDecoder.STATUS_LEN})");
break;
case StatusDecoder.ACK:
if (p.Length >= 2)
Log?.Invoke($"RX ACK echo=0x{p[0]:X2} result={(p[1] == 0 ? "OK" : "ERR")}");
break;
default:
Log?.Invoke($"RX unknown cmd=0x{cmd:X2}");
break;
}
}
// ================= 송신 (공용 CtrlFrame) =================
void SendFrame(byte[] f)
{
if (_ch.Send(f, f.Length))
Log?.Invoke($"TX {HexFormat.Bytes(f, f.Length)}");
}
public void RequestStatus() => SendFrame(CtrlFrame.RequestStatus());
public void SetPower(bool on) => SendFrame(CtrlFrame.Power(on ? 1 : 0));
public void SetRunMode(RunMode mode) => SendFrame(CtrlFrame.RunModeCmd((int)mode));
public void SetFan(int speed) => SendFrame(CtrlFrame.Fan(speed));
public void SetSubMode(SubModeType type, bool on) => SendFrame(CtrlFrame.SubMode((int)type, on ? 1 : 0));
public void SetHood(bool on) => SendFrame(CtrlFrame.Hood(on ? 1 : 0));
public void SetReserve(int hours) => SendFrame(CtrlFrame.Reserve(hours));
public void SetReset(bool on) => SendFrame(CtrlFrame.Reset(on ? 1 : 0));
public void SetDiffuserDamper(int room, int type, bool open) => SendFrame(CtrlFrame.Damper(room, type, open ? 1 : 0));
public void SetDiffuserLed(int room, int dim) => SendFrame(CtrlFrame.Led(room, dim));
public void SetVsp(int group, int index, int sa, int ea) => SendFrame(CtrlFrame.Vsp(group, index, sa, ea));
public void SetHystPreset(HystPreset preset) => SendFrame(CtrlFrame.Preset((int)preset));
public void SetHystDeadband(int preset, int pm25, int pm10, int voc, int co2) => SendFrame(CtrlFrame.HystValue(preset, pm25, pm10, voc, co2));
public void SetHystThreshold(int preset, int pollutant, int l1, int l2, int l3, int l4) => SendFrame(CtrlFrame.HystThr(preset, pollutant, l1, l2, l3, l4));
// ================= 데모/테스트 =================
public void InjectDemoStatus(int tick)
{
var frame = CtrlFrame.Build(StatusDecoder.STATUS, DemoStatus.BuildPayload(tick));
_parser.Reset();
_parser.Feed(frame);
}
public void Dispose() => _ch.Dispose();
}
}
@@ -0,0 +1,74 @@
using ErvDashboard.Model;
using ErvProtocol;
namespace ErvDashboard.Api
{
// StatusRecord(공용 프로토콜 디코드 결과) → DashboardState(WPF UI 모델) 매핑.
// (이전 DashboardProtocol.ApplyStatus 로직 — API 는 UI 비종속, 매핑은 여기로 분리)
public static class StatusMapper
{
public static void Apply(StatusRecord s, DashboardState state)
{
state.PowerOn = s.Power != 0;
state.RunMode = (RunMode)s.RunMode;
state.AutoState = (AutoState)s.AutoState;
state.FanMode = s.FanMode;
state.SubModeBitmap = s.SubMode;
state.Hood = s.HoodEnable; // byte5 bit0 = 연동 Enable
state.HoodConnected = s.HoodConnected; // byte5 bit2 = 후드 통신연결
state.HystPreset = (HystPreset)s.HystPreset;
state.HystPm25 = s.HystPm25;
state.HystPm10 = s.HystPm10;
state.HystVoc = s.HystVoc;
state.HystCo2 = s.HystCo2;
state.ErrorCode = s.ErrorCode;
state.Reset = s.Reset != 0;
state.ReserveRemainSec = s.ReserveRemainSec;
for (int i = 0; i < state.Vsp.Count && i < s.Vsp.Length; i++)
{
state.Vsp[i].Sa = s.Vsp[i].Sa;
state.Vsp[i].Ea = s.Vsp[i].Ea;
}
for (int i = 0; i < state.HystTable.Count && i < s.HystTable.Length; i++)
{
state.HystTable[i].Pm25 = s.HystTable[i].Pm25;
state.HystTable[i].Pm10 = s.HystTable[i].Pm10;
state.HystTable[i].Voc = s.HystTable[i].Voc;
state.HystTable[i].Co2 = s.HystTable[i].Co2;
}
// 모드별 오염단계 임계표
for (int i = 0; i < 3 && i < s.ThrTable.Length; i++)
for (int k = 0; k < 4; k++)
{
state.Co2Thr[i][k] = s.ThrTable[i].Co2[k];
state.Pm25Thr[i][k] = s.ThrTable[i].Pm25[k];
state.Pm10Thr[i][k] = s.ThrTable[i].Pm10[k];
state.VocThr[i][k] = s.ThrTable[i].Voc[k];
}
int totalLoad = 0;
for (int r = 0; r < 4; r++)
{
var src = s.Rooms[r];
var room = state.Rooms[r];
room.DamperSaOpen = src.DamperSa; // 비트맵 bit0
room.DamperEaOpen = src.DamperEa; // 비트맵 bit1
room.Pm25 = src.Pm25;
room.Pm10 = src.Pm10;
room.Voc = src.Voc;
room.Co2 = src.Co2;
room.AirQuality = (AirQuality)src.AirQuality;
room.LedDim = src.LedDim;
room.LoadScore = src.LoadScore;
room.FinalVolume = src.FinalVolume;
room.Temp = src.Temp;
room.Humi = src.Humi;
totalLoad += src.LoadScore;
}
state.TotalLoadScore = totalLoad; // 합산 부하점수 (0~16)
}
}
}