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? ConnectionChanged; public event Action? StatusReceived; public event Action? 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 Dispose() => _ch.Dispose(); } }