            if (_demoTimer.IsEnabled)
            {
                ApplySubModeLocal(tag, next);
                if (NoScenarioActive()) RestorePreviousMode();
                return;
            }
            if (!CanSend()) return;
            // 상호배타: 새 모드를 켤 때 기존 활성 모드는 장치에도 OFF 전송
            // (펌웨어는 시나리오모드를 독립 변수로 유지 → status 재수신 시 부활 방지)
            if (next)
            {
                if (tag != "SmartSleep"    && _state.SmartSleep)    _api.SetSubMode(SubModeType.SmartSleep,    false);
                if (tag != "ComfortCook"   && _state.ComfortCook)   _api.SetSubMode(SubModeType.ComfortCook,   false);
                if (tag != "ReliefRecover" && _state.ReliefRecover) _api.SetSubMode(SubModeType.ReliefRecover, false);
            }
            _api.SetSubMode(type, next);

            if (!CanSend()) return;
            _api.SetSubMode(type, next);

// 시나리오 첫 진입 → 직전 운전모드/풍량 기억(해제 시 복귀용)

if (next && !anyBefore) { _modeBeforeScenario = _state.RunMode; _fanBeforeScenario = _state.FanMode; }

if (tag == "SmartSleep" && !next) _sleepEndAt = null;   // 스마트수면 수동 해제 → 자동해제 예약 취소

-                ApplySubModeLocal(tag, next);

-                if (NoScenarioActive()) RestorePreviousMode();

-            // 상호배타: 새 모드를 켤 때 기존 활성 모드는 장치에도 OFF 전송

-            // (펌웨어는 시나리오모드를 독립 변수로 유지 → status 재수신 시 부활 방지)

-                if (tag != "SmartSleep"    && _state.SmartSleep)    _api.SetSubMode(SubModeType.SmartSleep,    false);

-                if (tag != "ComfortCook"   && _state.ComfortCook)   _api.SetSubMode(SubModeType.ComfortCook,   false);

-                if (tag != "ReliefRecover" && _state.ReliefRecover) _api.SetSubMode(SubModeType.ReliefRecover, false);

Log($"[제어] 시나리오모드 {b.Content} → {(next ? "ON" : "OFF")}");


Edit

            if (_demoTimer.IsEnabled) { _state.Hood = next; return; }
            if (!CanSend()) return;


The file d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\MainWindow.xaml.cs has been updated successfully. (file state is current in your context — no need to Read it back)

            if (_demoTimer.IsEnabled) { _state.Hood = next; return; }
            if (!CanSend()) return;

void Hood_Click(object sender, RoutedEventArgs e)

-            if (_demoTimer.IsEnabled) { _state.Hood = next; return; }


            if (_demoTimer.IsEnabled) { _state.Reset = next; return; }
            if (!CanSend()) return;


            if (_demoTimer.IsEnabled) { _state.Reset = next; return; }
            if (!CanSend()) return;

void Reset_Click(object sender, RoutedEventArgs e)

-            if (_demoTimer.IsEnabled) { _state.Reset = next; return; }


            if (_demoTimer.IsEnabled)
            {
                if (type == 0) room.DamperSaOpen = next; else room.DamperEaOpen = next;
                return;
            }
            if (!CanSend()) return;


            if (_demoTimer.IsEnabled)
            {
                if (type == 0) room.DamperSaOpen = next; else room.DamperEaOpen = next;
                return;
            }
            if (!CanSend()) return;

bool cur  = type == 0 ? room.DamperSaOpen : room.DamperEaOpen;

-                if (type == 0) room.DamperSaOpen = next; else room.DamperEaOpen = next;


            if (_lastLed.TryGetValue(roomId, out var last) && last == dim) return;
            if (_demoTimer.IsEnabled) return;
            if (!CanSend()) return;

            if (_lastLed.TryGetValue(roomId, out var last) && last == dim) return;
            if (!CanSend()) return;


            if (_lastLed.TryGetValue(roomId, out var last) && last == dim) return;
            if (_demoTimer.IsEnabled) return;
            if (!CanSend()) return;

            if (_lastLed.TryGetValue(roomId, out var last) && last == dim) return;
            if (!CanSend()) return;

// 직전 송신/수신값과 같으면 전송 안 함 — STATUS 역갱신이 유발한 ValueChanged(에코) 차단.

// 사용자가 값을 실제로 바꾸면 dim 이 달라지므로 정상 전송됨.

-            if (_demoTimer.IsEnabled) return;


            if (_demoTimer.IsEnabled) { _state.ReserveRemainSec = hours * 3600; return; }
            if (!CanSend()) return;


            if (_demoTimer.IsEnabled) { _state.ReserveRemainSec = hours * 3600; return; }
            if (!CanSend()) return;

if (!IsLoaded || _suppressReserve) return;

if (ReserveCombo.SelectedIndex < 0) return;

int hours = ReserveCombo.SelectedIndex;   // 0=해제, 1~8시간

-            if (_demoTimer.IsEnabled) { _state.ReserveRemainSec = hours * 3600; return; }

Log(hours == 0 ? "[제어] 예약 해제" : $"[제어] {hours}시간 후 꺼짐 예약");


        // ================= 데모 모드 (버튼 제거됨 — 내부 합성 STATUS 경로는 비활성 상태로 유지) =================
        void DemoTick()
        {
            _api.InjectDemoStatus(_demoTick++);
        }

        void RefreshControls()


<tool_use_error>String to replace not found in file.
String:         // ================= 데모 모드 (버튼 제거됨 — 내부 합성 STATUS 경로는 비활성 상태로 유지) =================
        void DemoTick()
        {
            _api.InjectDemoStatus(_demoTick++);
        }

        void RefreshControls()
(note: Edit also tried swapping \uXXXX escapes and their characters; neither form matched, so the mismatch is likely elsewhere in old_string. Re-read the file and copy the exact surrounding text.)</tool_use_error>

Error: String to replace not found in file.
String:         // ================= 데모 모드 (버튼 제거됨 — 내부 합성 STATUS 경로는 비활성 상태로 유지) =================
        void DemoTick()
        {
            _api.InjectDemoStatus(_demoTick++);
        }

        void RefreshControls()
(note: Edit also tried swapping \uXXXX escapes and their characters; neither form matched, so the mismatch is likely elsewhere in old_string. Re-read the file and copy the exact surrounding text.)


            _demoTimer.Stop();
            _api.Dispose();


            _demoTimer.Stop();
            _api.Dispose();

protected override void OnClosed(EventArgs e)


        void ApplySubModeLocal(string tag, bool on)
        {
            // 시나리오모드는 상호배타: 하나를 켜면 나머지는 해제
            if (on)
            {
                _state.SmartSleep    = tag == "SmartSleep";
                _state.ComfortCook   = tag == "ComfortCook";
                _state.ReliefRecover = tag == "ReliefRecover";
            }
            else
            {
                switch (tag)
                {
                    case "SmartSleep":    _state.SmartSleep = false; break;
                    case "ComfortCook":   _state.ComfortCook = false; break;
                    case "ReliefRecover": _state.ReliefRecover = false; break;
                }
            }
        }

        void ApplySubModeLocal(string tag, bool on)
        {
            // 시나리오모드 상호배타 없음 — 클릭한 모드만 on/off (사용자가 하나만 선택).
            switch (tag)
            {
                case "SmartSleep":    _state.SmartSleep = on; break;
                case "ComfortCook":   _state.ComfortCook = on; break;
                case "ReliefRecover": _state.ReliefRecover = on; break;
            }
        }


상호베타 로직은 삭제해..어차피 하나만 선택할꺼야.. 그리고, 프로그램 실행하니 안심회복이 선택된걸로 되어 있어..기본값은 모두 선택이 안된거야.


        void ApplySubModeLocal(string tag, bool on)
        {
            // 시나리오모드는 상호배타: 하나를 켜면 나머지는 해제
            if (on)
            {
                _state.SmartSleep    = tag == "SmartSleep";
                _state.ComfortCook   = tag == "ComfortCook";
                _state.ReliefRecover = tag == "ReliefRecover";
            }
            else
            {
                switch (tag)
                {
                    case "SmartSleep":    _state.SmartSleep = false; break;
                    case "ComfortCook":   _state.ComfortCook = false; break;
                    case "ReliefRecover": _state.ReliefRecover = false; break;
                }
            }
        }

        void ApplySubModeLocal(string tag, bool on)
        {
            // 시나리오모드 상호배타 없음 — 클릭한 모드만 on/off (사용자가 하나만 선택).
            switch (tag)
            {
                case "SmartSleep":    _state.SmartSleep = on; break;
                case "ComfortCook":   _state.ComfortCook = on; break;
                case "ReliefRecover": _state.ReliefRecover = on; break;
            }
        }

-            // 시나리오모드는 상호배타: 하나를 켜면 나머지는 해제

+            // 시나리오모드 상호배타 없음 — 클릭한 모드만 on/off (사용자가 하나만 선택).

-                _state.SmartSleep    = tag == "SmartSleep";

-                _state.ComfortCook   = tag == "ComfortCook";

-                _state.ReliefRecover = tag == "ReliefRecover";

+                case "SmartSleep":    _state.SmartSleep = on; break;

+                case "ComfortCook":   _state.ComfortCook = on; break;

+                case "ReliefRecover": _state.ReliefRecover = on; break;

-                    case "SmartSleep":    _state.SmartSleep = false; break;

-                    case "ComfortCook":   _state.ComfortCook = false; break;

-                    case "ReliefRecover": _state.ReliefRecover = false; break;

void Hood_Click(object sender, RoutedEventArgs e)


E17(DemoTick 제거)만 실패했습니다. 해당 구간을 다시 읽어 정확히 제거하겠습니다.


516	            // 운전모드 강조
517	            for (int i = 0; i < _modeButtons.Count; i++)
518	                SetActive(_modeButtons[i], ModeDefs[i].mode == _state.RunMode);
519
520	            // 풍량 : 현재 단수는 모드와 무관하게 항상 강조(자동은 ERV가 결정한 단수 표시).
521	            //   - 자동 : 수동 조절 불가(전 단 비활성)
522	            //   - 바이패스 : 최대 1단(2~4단 비활성)
523	            //   - 환기/공청 : 0~4단
524	            // 시나리오모드 활성 시: 운전모드·풍량·선택 안 된 시나리오모드 비활성화(=클릭 불가)
525	            // 쾌적조리 잠금은 '메이크업 실제 연동중'(쾌적조리 토글 ON + 후드 가동) 일 때만.
526	            //   - 펌웨어 Hood_process() 는 Hood_YeunDong_Enable==1 일 때만 모드/풍량을 건드림.
527	            //     쾌적조리 OFF면 후드만 켜져도 ERV는 그대로 → 다른 시나리오모드/운전모드 선택 가능해야 함.
528	            //   - 후드 OFF(대기)면 메이크업 비연동 → 본래 운전모드로 복귀하므로 잠금 해제(사양 3.1).
529	            bool makeupActive = _state.ComfortCook && _state.HoodRunning;
530	            bool subActive = _state.SmartSleep || makeupActive || _state.ReliefRecover;
531	            int fanMax = _state.RunMode == RunMode.Bypass ? 1 : 4;

            // 운전모드 강조
            for (int i = 0; i < _modeButtons.Count; i++)
                SetActive(_modeButtons[i], ModeDefs[i].mode == _state.RunMode);

            // 풍량 : 현재 단수는 모드와 무관하게 항상 강조(자동은 ERV가 결정한 단수 표시).
            //   - 자동 : 수동 조절 불가(전 단 비활성)
            //   - 바이패스 : 최대 1단(2~4단 비활성)
            //   - 환기/공청 : 0~4단
            // 시나리오모드 활성 시: 운전모드·풍량·선택 안 된 시나리오모드 비활성화(=클릭 불가)
            // 쾌적조리 잠금은 '메이크업 실제 연동중'(쾌적조리 토글 ON + 후드 가동) 일 때만.
            //   - 펌웨어 Hood_process() 는 Hood_YeunDong_Enable==1 일 때만 모드/풍량을 건드림.
            //     쾌적조리 OFF면 후드만 켜져도 ERV는 그대로 → 다른 시나리오모드/운전모드 선택 가능해야 함.
            //   - 후드 OFF(대기)면 메이크업 비연동 → 본래 운전모드로 복귀하므로 잠금 해제(사양 3.1).
            bool makeupActive = _state.ComfortCook && _state.HoodRunning;
            bool subActive = _state.SmartSleep || makeupActive || _state.ReliefRecover;
            int fanMax = _state.RunMode == RunMode.Bypass ? 1 : 4;


489-        }
490-
491:        // ================= 데모 모드 (버튼 제거됨 — 내부 합성 STATUS 경로는 비활성 상태로 유지) =================
492:        void DemoTick()
493-        {
494-            _api.InjectDemoStatus(_demoTick++);
495-        }
496-


        // ================= 데모 모드 (버튼 제거됨 — 내부 합성 STATUS 경로는 비활성 상태로 유지) =================
        void DemoTick()
        {
            _api.InjectDemoStatus(_demoTick++);
        }


        // ================= 데모 모드 (버튼 제거됨 — 내부 합성 STATUS 경로는 비활성 상태로 유지) =================
        void DemoTick()
        {
            _api.InjectDemoStatus(_demoTick++);
        }

-            _api.InjectDemoStatus(_demoTick++);


Now the API files and delete DemoStatus.cs:


        void SetHystThreshold(int preset, int pollutant, int l1, int l2, int l3, int l4); // 0x0D

        // ---- 데모/테스트 (합성 STATUS를 수신 경로로 주입) ----
        void InjectDemoStatus(int tick);
    }

        void SetHystThreshold(int preset, int pollutant, int l1, int l2, int l3, int l4); // 0x0D
    }


The file d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\Api\IErvApi.cs has been updated successfully. (file state is current in your context — no need to Read it back)

        void SetHystThreshold(int preset, int pollutant, int l1, int l2, int l3, int l4); // 0x0D
    }

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

-        // ---- 데모/테스트 (합성 STATUS를 수신 경로로 주입) ----

-        void InjectDemoStatus(int tick);


        // ================= 데모/테스트 =================
        public void InjectDemoStatus(int tick)
        {
            var frame = CtrlFrame.Build(StatusDecoder.STATUS, DemoStatus.BuildPayload(tick));
            _parser.Reset();
            _parser.Feed(frame);
        }

        public void Dispose() => _ch.Dispose();


Bash

rm "d:/project/nuvoton/HECO2/TestProgram/ErvProtocol/DemoStatus.cs" && echo "deleted"

Delete unused DemoStatus.cs


The file d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\Api\SerialErvApi.cs has been updated successfully. (file state is current in your context — no need to Read it back)

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

-        public void InjectDemoStatus(int tick)

-            var frame = CtrlFrame.Build(StatusDecoder.STATUS, DemoStatus.BuildPayload(tick));


deleted


데모/상호배타 제거 완료. 빌드로 검증합니다.


PowerShell

dotnet publish "d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\ErvDashboard.csproj" -c Release 2>&1 | Select-String -Pattern "error|ErvDashboard ->|Build succeeded|오류" | Select-Object -Last 10

Publish dashboard after demo+exclusivity removal


  ErvDashboard -> d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\bin\Release\net10.0-windows\win-x64\ErvDashboard.dll
  ErvDashboard -> d:\project\nuvoton\HECO2\TestProgram\PCDashBoard\bin\Release\net10.0-windows\win-x64\publish\


대시보드 빌드 성공 (오류 0). 상호배타 제거 + 데모 루틴 전체 삭제 완료. 문서에 기록합니다.


Edit

## 후속/보류
- 현재 상호배타라 **쾌적조리 ON 중 스마트수면/안심회복 선택 시 쾌적조리도 꺼짐**(SubMode_Click L401~406). 사양상 쾌적조리는 독립이므로, 쾌적조리는 시나리오 전환과 무관하게 유지하길 원하면 별도 분리 필요(추후 결정).

## 후속/보류
- 현재 상호배타라 **쾌적조리 ON 중 스마트수면/안심회복 선택 시 쾌적조리도 꺼짐**(SubMode_Click L401~406). 사양상 쾌적조리는 독립이므로, 쾌적조리는 시나리오 전환과 무관하게 유지하길 원하면 별도 분리 필요(추후 결정).
  - → 260618 (9) 에서 상호배타 로직 전체 삭제로 해소.

---

# 260618 (9) 시나리오 상호배타 로직 삭제 + 데모 루틴 전체 삭제

## 요청 (사장님)
- 시나리오 상호배타 로직 삭제(어차피 하나만 선택). 데모 루틴도 모두 삭제.

## 변경 — 상호배타 삭제
- `TestProgram/PCDashBoard/MainWindow.xaml.cs`
  - `SubMode_Click` : 새 모드 켤 때 기존 모드 OFF 전송하던 블록(구 L399~406) 삭제.
  - `ApplySubModeLocal` : on 시 나머지 해제하던 로직 → **클릭한 태그만 on/off** 로 단순화.
- 동작: 시나리오 버튼은 서로 영향 없이 각각 토글. (스마트수면↔안심회복은 펌웨어 단일 Ext_Run_Mode 라 장치단에서 전환되고 STATUS 로 재동기.)

## 변경 — 데모 루틴 전체 삭제
- `MainWindow.xaml.cs` : `_demoTimer`/`_demoTick` 필드, 생성자 타이머 초기화, `DemoTick()`, OnClosed `_demoTimer.Stop()`, 그리고 제어 핸들러 곳곳의 `if(_demoTimer.IsEnabled){...}` 분기(전원/모드/풍량/시나리오/후드/리셋/댐퍼/LED/예약/프리셋) 전부 제거. `CanSend()` 의 데모 가드도 제거.
- `Api/IErvApi.cs` : `InjectDemoStatus` 선언 삭제.
- `Api/SerialErvApi.cs` : `InjectDemoStatus` 구현 삭제.
- `ErvProtocol/DemoStatus.cs` : 파일 삭제(타 프로젝트 참조 없음 확인).

## 빌드/배포
- `dotnet publish ErvDashboard.csproj -c Release` → 오류 0. publish 갱신.

## 미해결 — (10) 안심회복 기본 선택 표시 (조사중)
- 증상: 대시보드 실행 시 안심회복이 선택된 것으로 표시. 기본값은 모두 미선택이어야 함.
- 정적분석: 대시보드는 기본 미선택이 맞음(_state 전부 false, RefreshControls 가 _state 값으로만 강조). 안심회복 강조는 오직 STATUS byte4 bit2(=펌웨어 `Ext_Run_Mode==1`) 수신 시에만 켜짐.
- 펌웨어 `Ext_Run_Mode` 기본값 0(My_bunbaegi.c:984), 1로 설정되는 곳은 명령(My_Homenet.c:319, ReliefRecover ON)뿐 → 펌웨어 자가진입 없음.
- 추정: **연결된 ERV가 이전 테스트에서 안심회복 선택된 상태로 RAM에 남아있고(전원 미재투입)**, 대시보드만 재실행하니 그 상태를 그대로 보고받아 표시. ERV 전원 재투입(콜드부트) 시 0 → 미선택 예상.
- 확인 필요: ERV 콜드부트 후에도 안심회복이 뜨는지. 뜨면 펌웨어/STATUS 추가 추적.


The file d:\project\nuvoton\HECO2\doc\260618_내부댐퍼_모드변경시_미동작_수정.md has been updated successfully. (file state is current in your context — no need to Read it back)

## 후속/보류
- 현재 상호배타라 **쾌적조리 ON 중 스마트수면/안심회복 선택 시 쾌적조리도 꺼짐**(SubMode_Click L401~406). 사양상 쾌적조리는 독립이므로, 쾌적조리는 시나리오 전환과 무관하게 유지하길 원하면 별도 분리 필요(추후 결정).
  - → 260618 (9) 에서 상호배타 로직 전체 삭제로 해소.

---

# 260618 (9) 시나리오 상호배타 로직 삭제 + 데모 루틴 전체 삭제

## 요청 (사장님)
- 시나리오 상호배타 로직 삭제(어차피 하나만 선택). 데모 루틴도 모두 삭제.

## 변경 — 상호배타 삭제
- `TestProgram/PCDashBoard/MainWindow.xaml.cs`
  - `SubMode_Click` : 새 모드 켤 때 기존 모드 OFF 전송하던 블록(구 L399~406) 삭제.
  - `ApplySubModeLocal` : on 시 나머지 해제하던 로직 → **클릭한 태그만 on/off** 로 단순화.
- 동작: 시나리오 버튼은 서로 영향 없이 각각 토글. (스마트수면↔안심회복은 펌웨어 단일 Ext_Run_Mode 라 장치단에서 전환되고 STATUS 로 재동기.)

## 변경 — 데모 루틴 전체 삭제
- `MainWindow.xaml.cs` : `_demoTimer`/`_demoTick` 필드, 생성자 타이머 초기화, `DemoTick()`, OnClosed `_demoTimer.Stop()`, 그리고 제어 핸들러 곳곳의 `if(_demoTimer.IsEnabled){...}` 분기(전원/모드/풍량/시나리오/후드/리셋/댐퍼/LED/예약/프리셋) 전부 제거. `CanSend()` 의 데모 가드도 제거.
- `Api/IErvApi.cs` : `InjectDemoStatus` 선언 삭제.
- `Api/SerialErvApi.cs` : `InjectDemoStatus` 구현 삭제.
- `ErvProtocol/DemoStatus.cs` : 파일 삭제(타 프로젝트 참조 없음 확인).

## 빌드/배포
- `dotnet publish ErvDashboard.csproj -c Release` → 오류 0. publish 갱신.

## 미해결 — (10) 안심회복 기본 선택 표시 (조사중)
- 증상: 대시보드 실행 시 안심회복이 선택된 것으로 표시. 기본값은 모두 미선택이어야 함.
- 정적분석: 대시보드는 기본 미선택이 맞음(_state 전부 false, RefreshControls 가 _state 값으로만 강조). 안심회복 강조는 오직 STATUS byte4 bit2(=펌웨어 `Ext_Run_Mode==1`) 수신 시에만 켜짐.
- 펌웨어 `Ext_Run_Mode` 기본값 0(My_bunbaegi.c:984), 1로 설정되는 곳은 명령(My_Homenet.c:319, ReliefRecover ON)뿐 → 펌웨어 자가진입 없음.
- 추정: **연결된 ERV가 이전 테스트에서 안심회복 선택된 상태로 RAM에 남아있고(전원 미재투입)**, 대시보드만 재실행하니 그 상태를 그대로 보고받아 표시. ERV 전원 재투입(콜드부트) 시 0 → 미선택 예상.
- 확인 필요: ERV 콜드부트 후에도 안심회복이 뜨는지. 뜨면 펌웨어/STATUS 추가 추적.

+  - → 260618 (9) 에서 상호배타 로직 전체 삭제로 해소.

+# 260618 (9) 시나리오 상호배타 로직 삭제 + 데모 루틴 전체 삭제

+## 요청 (사장님)

+- 시나리오 상호배타 로직 삭제(어차피 하나만 선택). 데모 루틴도 모두 삭제.

+## 변경 — 상호배타 삭제

+  - `SubMode_Click` : 새 모드 켤 때 기존 모드 OFF 전송하던 블록(구 L399~406) 삭제.

+  - `ApplySubModeLocal` : on 시 나머지 해제하던 로직 → **클릭한 태그만 on/off** 로 단순화.

+- 동작: 시나리오 버튼은 서로 영향 없이 각각 토글. (스마트수면↔안심회복은 펌웨어 단일 Ext_Run_Mode 라 장치단에서 전환되고 STATUS 로 재동기.)

+## 변경 — 데모 루틴 전체 삭제

+- `MainWindow.xaml.cs` : `_demoTimer`/`_demoTick` 필드, 생성자 타이머 초기화, `DemoTick()`, OnClosed `_demoTimer.Stop()`, 그리고 제어 핸들러 곳곳의 `if(_demoTimer.IsEnabled){...}` 분기(전원/모드/풍량/시나리오/후드/리셋/댐퍼/LED/예약/프리셋) 전부 제거. `CanSend()` 의 데모 가드도 제거.

+- `Api/IErvApi.cs` : `InjectDemoStatus` 선언 삭제.

+- `Api/SerialErvApi.cs` : `InjectDemoStatus` 구현 삭제.

+- `ErvProtocol/DemoStatus.cs` : 파일 삭제(타 프로젝트 참조 없음 확인).

+- `dotnet publish ErvDashboard.csproj -c Release` → 오류 0. publish 갱신.

+## 미해결 — (10) 안심회복 기본 선택 표시 (조사중)

+- 증상: 대시보드 실행 시 안심회복이 선택된 것으로 표시. 기본값은 모두 미선택이어야 함.

+- 정적분석: 대시보드는 기본 미선택이 맞음(_state 전부 false, RefreshControls 가 _state 값으로만 강조). 안심회복 강조는 오직 STATUS byte4 bit2(=펌웨어 `Ext_Run_Mode==1`) 수신 시에만 켜짐.

+- 펌웨어 `Ext_Run_Mode` 기본값 0(My_bunbaegi.c:984), 1로 설정되는 곳은 명령(My_Homenet.c:319, ReliefRecover ON)뿐 → 펌웨어 자가진입 없음.

+- 추정: **연결된 ERV가 이전 테스트에서 안심회복 선택된 상태로 RAM에 남아있고(전원 미재투입)**, 대시보드만 재실행하니 그 상태를 그대로 보고받아 표시. ERV 전원 재투입(콜드부트) 시 0 → 미선택 예상.

+- 확인 필요: ERV 콜드부트 후에도 안심회복이 뜨는지. 뜨면 펌웨어/STATUS 추가 추적.


완료했습니다.

## 처리 결과

### ✅ 1. 시나리오 상호배타 로직 삭제
[MainWindow.xaml.cs](TestProgram/PCDashBoard/MainWindow.xaml.cs) `SubMode_Click`에서 "새 모드 켤 때 기존 모드 OFF 전송" 블록 제거 + `ApplySubModeLocal`을 **클릭한 모드만 on/off**로 단순화. 이제 시나리오 버튼이 서로 끄지 않습니다.

### ✅ 3. 데모 루틴 전체 삭제
- `MainWindow.xaml.cs`: `_demoTimer`/`_demoTick` 필드, 타이머 초기화, `DemoTick()`, `OnClosed`의 Stop, 그리고 전원·모드·풍량·시나리오·후드·리셋·댐퍼·LED·예약·프리셋 핸들러의 `if(_demoTimer.IsEnabled){…}` 분기 전부 제거. `CanSend()`의 데모 가드도 제거.
- `IErvApi.cs` / `SerialErvApi.cs`: `InjectDemoStatus` 제거.
- `ErvProtocol/DemoStatus.cs`: **파일 삭제** (타 프로젝트 참조 없음 확인).

빌드: dotnet publish 오류 0, 단일 exe 갱신.

### ⚠ 2. 안심회복 기본 선택 — 원인 확인 필요

정적 분석 결과 **대시보드 자체는 기본 미선택이 맞습니다.** `_state`는 전부 false이고, `SetActive(ReliefRecoverBtn, …)`은 오직 `_state.ReliefRecover` 값으로만 강조하며, 그 값은 **STATUS byte4 bit2 = 펌웨어 `Ext_Run_Mode==1`**일 때만 켜집니다.

그런데 펌웨어 `Ext_Run_Mode` 기본값은 0이고, 1로 바뀌는 곳은 **대시보드의 안심회복 ON 명령(`My_Homenet.c:319`)뿐**입니다 — 펌웨어가 스스로 안심회복에 들어가는 경로는 없습니다.

**가장 유력한 추정:** 연결된 ERV가 **이전 테스트에서 안심회복을 켠 상태로 RAM에 남아 있고**(전원 재투입을 안 함), 대시보드만 다시 실행하니 그 상태를 그대로 받아 표시하는 것입니다. ERV는 RAM이라 **전원을 껐다 켜면(콜드부트) `Ext_Run_Mode=0`** → 미선택이 됩니다.

👉 확인 부탁드립니다: **ERV 전원을 완전히 껐다 켠 뒤에도** 대시보드에 안심회복이 뜨나요?
- 안 뜨면 → 정상(이전 상태가 남았던 것).
- 여전히 뜨면 → 펌웨어/STATUS 쪽을 더 추적하겠습니다 (그땐 어떤 펌웨어 버전을 올리셨는지도 알려주세요).


erv 전원을 off > on하고 대시보드를 실행하니 안심회복이 버튼이 해제되어 있어.


확인 감사합니다. 예상대로 **이전 테스트 상태가 RAM에 남아 있던 것**이었고, ERV 콜드부트(전원 OFF→ON) 후엔 `Ext_Run_Mode=0`이라 안심회복이 정상적으로 해제됩니다. 대시보드 버그가 아니었습니다. 문서에 정리해두겠습니다.


## 미해결 — (10) 안심회복 기본 선택 표시 (조사중)
- 증상: 대시보드 실행 시 안심회복이 선택된 것으로 표시. 기본값은 모두 미선택이어야 함.
- 정적분석: 대시보드는 기본 미선택이 맞음(_state 전부 false, RefreshControls 가 _state 값으로만 강조). 안심회복 강조는 오직 STATUS byte4 bit2(=펌웨어 `Ext_Run_Mode==1`) 수신 시에만 켜짐.
- 펌웨어 `Ext_Run_Mode` 기본값 0(My_bunbaegi.c:984), 1로 설정되는 곳은 명령(My_Homenet.c:319, ReliefRecover ON)뿐 → 펌웨어 자가진입 없음.
- 추정: **연결된 ERV가 이전 테스트에서 안심회복 선택된 상태로 RAM에 남아있고(전원 미재투입)**, 대시보드만 재실행하니 그 상태를 그대로 보고받아 표시. ERV 전원 재투입(콜드부트) 시 0 → 미선택 예상.
- 확인 필요: ERV 콜드부트 후에도 안심회복이 뜨는지. 뜨면 펌웨어/STATUS 추가 추적.

## (10) 안심회복 기본 선택 표시 — 해결됨(대시보드 버그 아님)
- 증상: 대시보드 실행 시 안심회복이 선택된 것으로 표시.
- 정적분석: 대시보드는 기본 미선택이 맞음(_state 전부 false, RefreshControls 가 _state 값으로만 강조). 안심회복 강조는 오직 STATUS byte4 bit2(=펌웨어 `Ext_Run_Mode==1`) 수신 시에만 켜짐.
- 펌웨어 `Ext_Run_Mode` 기본값 0(My_bunbaegi.c:984), 1로 설정되는 곳은 명령(My_Homenet.c:319, ReliefRecover ON)뿐 → 펌웨어 자가진입 없음.
- **원인 확정**: 연결된 ERV가 이전 테스트에서 안심회복 켠 상태로 RAM에 남아있었고(전원 미재투입), 대시보드만 재실행해 그 상태를 그대로 표시한 것.
- **검증 완료**: ERV 전원 OFF→ON(콜드부트) 후 대시보드 실행 시 안심회복 버튼 해제됨(사장님 확인 2026-06-18). 코드 수정 불필요.


## 미해결 — (10) 안심회복 기본 선택 표시 (조사중)
- 증상: 대시보드 실행 시 안심회복이 선택된 것으로 표시. 기본값은 모두 미선택이어야 함.
- 정적분석: 대시보드는 기본 미선택이 맞음(_state 전부 false, RefreshControls 가 _state 값으로만 강조). 안심회복 강조는 오직 STATUS byte4 bit2(=펌웨어 `Ext_Run_Mode==1`) 수신 시에만 켜짐.
- 펌웨어 `Ext_Run_Mode` 기본값 0(My_bunbaegi.c:984), 1로 설정되는 곳은 명령(My_Homenet.c:319, ReliefRecover ON)뿐 → 펌웨어 자가진입 없음.
- 추정: **연결된 ERV가 이전 테스트에서 안심회복 선택된 상태로 RAM에 남아있고(전원 미재투입)**, 대시보드만 재실행하니 그 상태를 그대로 보고받아 표시. ERV 전원 재투입(콜드부트) 시 0 → 미선택 예상.
- 확인 필요: ERV 콜드부트 후에도 안심회복이 뜨는지. 뜨면 펌웨어/STATUS 추가 추적.

## (10) 안심회복 기본 선택 표시 — 해결됨(대시보드 버그 아님)
- 증상: 대시보드 실행 시 안심회복이 선택된 것으로 표시.
- 정적분석: 대시보드는 기본 미선택이 맞음(_state 전부 false, RefreshControls 가 _state 값으로만 강조). 안심회복 강조는 오직 STATUS byte4 bit2(=펌웨어 `Ext_Run_Mode==1`) 수신 시에만 켜짐.
- 펌웨어 `Ext_Run_Mode` 기본값 0(My_bunbaegi.c:984), 1로 설정되는 곳은 명령(My_Homenet.c:319, ReliefRecover ON)뿐 → 펌웨어 자가진입 없음.
- **원인 확정**: 연결된 ERV가 이전 테스트에서 안심회복 켠 상태로 RAM에 남아있었고(전원 미재투입), 대시보드만 재실행해 그 상태를 그대로 표시한 것.
- **검증 완료**: ERV 전원 OFF→ON(콜드부트) 후 대시보드 실행 시 안심회복 버튼 해제됨(사장님 확인 2026-06-18). 코드 수정 불필요.

- `dotnet publish ErvDashboard.csproj -c Release` → 오류 0. publish 갱신.

-## 미해결 — (10) 안심회복 기본 선택 표시 (조사중)

-- 증상: 대시보드 실행 시 안심회복이 선택된 것으로 표시. 기본값은 모두 미선택이어야 함.

+## (10) 안심회복 기본 선택 표시 — 해결됨(대시보드 버그 아님)

+- 증상: 대시보드 실행 시 안심회복이 선택된 것으로 표시.

-- 추정: **연결된 ERV가 이전 테스트에서 안심회복 선택된 상태로 RAM에 남아있고(전원 미재투입)**, 대시보드만 재실행하니 그 상태를 그대로 보고받아 표시. ERV 전원 재투입(콜드부트) 시 0 → 미선택 예상.

-- 확인 필요: ERV 콜드부트 후에도 안심회복이 뜨는지. 뜨면 펌웨어/STATUS 추가 추적.

+- **원인 확정**: 연결된 ERV가 이전 테스트에서 안심회복 켠 상태로 RAM에 남아있었고(전원 미재투입), 대시보드만 재실행해 그 상태를 그대로 표시한 것.

+- **검증 완료**: ERV 전원 OFF→ON(콜드부트) 후 대시보드 실행 시 안심회복 버튼 해제됨(사장님 확인 2026-06-18). 코드 수정 불필요.


정리됐습니다. 오늘 진행한 대시보드/펌웨어 작업 전체 요약:

| # | 항목 | 상태 |
|---|------|------|
| (6) | 후드 전원 OFF 시 쾌적조리 토글 자동 해제 (펌웨어) | ✅ |
| (7) | 쾌적조리 미선택+후드 ON 시 다른 시나리오 버튼 선택 가능 | ✅ |
| (8) | 시나리오 버튼 항상 선택 가능 (상호 비활성 해제) | ✅ |
| (9) | 시나리오 상호배타 로직 삭제 + 데모 루틴 전체 삭제 | ✅ |
| (10) | 안심회복 기본선택 표시 → **버그 아님**(이전 RAM 상태, 콜드부트 후 정상) | ✅ 검증완료 |

빌드 산출물은 펌웨어/대시보드 모두 최신입니다.

남은 선택지:
- 지금까지 변경분이 모두 동작 확인됐으니, 원하시면 **커밋**해 드릴까요? (현재 `main` 브랜치)
- 아니면 다른 작업 이어서 진행할까요?


자동운전에서 디퓨저 시뮬레이터에서 자동변경 프리셋을 변경했는데 대시보드에서는 변경이 안돼.
펌웨어가 문제인가..대시보드인가..


자동 프리셋(ECO/NORMAL/TURBO)이 디퓨저 시뮬레이터 → ERV → 대시보드로 반영되는 경로를 양쪽에서 확인하겠습니다. 대시보드 표시부와 펌웨어 `Hyst_Preset` 처리를 봅니다.


588	            var sb = new StringBuilder();
589	            sb.Append($"STATUS pwr={(_state.PowerOn ? "ON" : "OFF")} mode={_state.RunMode} fan={_state.FanMode} ");
590	            sb.Append($"hood={(_state.Hood ? 1 : 0)} sub=0x{_state.SubModeBitmap:X2} auto={_state.AutoStateText} ");
591	            sb.Append($"preset={_state.HystPreset} hyst[PM2.5={_state.HystPm25},PM10={_state.HystPm10},VOC={_state.HystVoc},CO2={_state.HystCo2}] ");
592	            sb.Append($"err={_state.ErrorCodeHex}");
593	            Log(sb.ToString());
594	            foreach (var r in _state.Rooms)
595	                Log($"  {r.Name}: 급기={(r.DamperSaOpen ? "O" : "X")} 배기={(r.DamperEaOpen ? "O" : "X")} PM2.5={r.Pm25} PM10={r.Pm10} VOC={r.Voc} CO2={r.Co2} " +
596	                    $"AQ={r.AirQualityText} LED={r.LedDim} load={r.LoadScore} vol={r.FinalVolume}");
597	        }
598
599	        void Log(string msg)
600	        {
601	            var line = $"[{DateTime.Now:HH:mm:ss.fff}] {msg}";
602	            Dispatcher.BeginInvoke(() =>
603	            {
604	                LogList.AppendText(line + Environment.NewLine);
605	                if (LogList.LineCount > 1000)   // 오래된 줄 정리(최근 600줄 유지)
606	                {
607	                    var lines = LogList.Text.Split(Environment.NewLine);
608	                    LogList.Text = string.Join(Environment.NewLine, lines[^600..]);
609	                }
610	                if (AutoScrollChk.IsChecked == true) LogList.ScrollToEnd();
611	            });
612	        }

            var sb = new StringBuilder();
            sb.Append($"STATUS pwr={(_state.PowerOn ? "ON" : "OFF")} mode={_state.RunMode} fan={_state.FanMode} ");
            sb.Append($"hood={(_state.Hood ? 1 : 0)} sub=0x{_state.SubModeBitmap:X2} auto={_state.AutoStateText} ");
            sb.Append($"preset={_state.HystPreset} hyst[PM2.5={_state.HystPm25},PM10={_state.HystPm10},VOC={_state.HystVoc},CO2={_state.HystCo2}] ");
            sb.Append($"err={_state.ErrorCodeHex}");
            Log(sb.ToString());
            foreach (var r in _state.Rooms)
                Log($"  {r.Name}: 급기={(r.DamperSaOpen ? "O" : "X")} 배기={(r.DamperEaOpen ? "O" : "X")} PM2.5={r.Pm25} PM10={r.Pm10} VOC={r.Voc} CO2={r.Co2} " +
                    $"AQ={r.AirQualityText} LED={r.LedDim} load={r.LoadScore} vol={r.FinalVolume}");
        }

        void Log(string msg)
        {
            var line = $"[{DateTime.Now:HH:mm:ss.fff}] {msg}";
            Dispatcher.BeginInvoke(() =>
            {
                LogList.AppendText(line + Environment.NewLine);
                if (LogList.LineCount > 1000)   // 오래된 줄 정리(최근 600줄 유지)
                {
                    var lines = LogList.Text.Split(Environment.NewLine);
                    LogList.Text = string.Join(Environment.NewLine, lines[^600..]);
                }
                if (AutoScrollChk.IsChecked == true) LogList.ScrollToEnd();
            });
        }


자동운전에서 디퓨저 시뮬레이터에서 자동변경 프리셋을 변경했는데 대시보드에서는 변경이 안돼. 펌웨어가 문제인가..대시보드인가..


SOURCE\HECO2\User\MyControl.c:43:    page_data[EEP_HYST_PRESET] = Hyst_Preset;
SOURCE\HECO2\User\MyControl.c:92:    Hyst_Preset = page_data[EEP_HYST_PRESET];
SOURCE\HECO2\User\My_define.h:334:#define	EEP_HYST_PRESET		43          /* 1B : Hyst_Preset 0~2 (유효성 마커 겸용) */
SOURCE\HECO2\User\My_define.h:453:extern uint8_t  Hyst_Preset;        /* 0 ECO / 1 NORMAL / 2 TURBO */
SOURCE\HECO2\User\My_Homenet.c:132:    p[6] = Hyst_Preset;
SOURCE\HECO2\User\My_Homenet.c:133:    hn_w16(&p[7],  Pm25_Db[Hyst_Preset]);
SOURCE\HECO2\User\My_Homenet.c:134:    hn_w16(&p[9],  Pm10_Db[Hyst_Preset]);
SOURCE\HECO2\User\My_Homenet.c:135:    hn_w16(&p[11], Voc_Db[Hyst_Preset]);
SOURCE\HECO2\User\My_Homenet.c:136:    hn_w16(&p[13], Co2_Db[Hyst_Preset]);
SOURCE\HECO2\User\My_Homenet.c:329:            if(len >= 1 && pl[0] < 3) Hyst_Preset = pl[0]; else result = 1;
SOURCE\HECO2\User\My_system.c:714:uint8_t  Hyst_Preset = 1;          /* 0 ECO / 1 NORMAL / 2 TURBO */
SOURCE\HECO2\User\My_system.c:764:        lc   = sensor_level(SEN66_CO2_value[Room_Num],            Co2_Thr[Hyst_Preset],  Co2_Db[Hyst_Preset],  Prev_CO2_Lv[Room_Num]);
SOURCE\HECO2\User\My_system.c:765:        lp25 = sensor_level(SEN66_pm2p5[Room_Num],                Pm25_Thr[Hyst_Preset], Pm25_Db[Hyst_Preset], Prev_PM25_Lv[Room_Num]);
SOURCE\HECO2\User\My_system.c:766:        lp10 = sensor_level(SEN66_pm10p0[Room_Num],               Pm10_Thr[Hyst_Preset], Pm10_Db[Hyst_Preset], Prev_PM10_Lv[Room_Num]);
SOURCE\HECO2\User\My_system.c:767:        lvc  = sensor_level((uint16_t)SEN66_VOC_value[Room_Num],  Voc_Thr[Hyst_Preset],  Voc_Db[Hyst_Preset],  Prev_VOC_Lv[Room_Num]);


ERV 펌웨어에서 `Hyst_Preset`은 **대시보드 명령(CTRL_HYST_PRESET)으로만** 바뀌고 STATUS byte6로 보고됩니다. 디퓨저가 ERV의 `Hyst_Preset`을 바꾸는 경로는 펌웨어에 없습니다. 토폴로지와 디퓨저 시뮬레이터 쪽을 확인해야 정확히 판단됩니다. 디퓨저 시뮬레이터와 ERV가 디퓨저 데이터를 받는 경로를 찾겠습니다.


No files found


디퓨저 시뮬레이터 소스를 보겠습니다.


Simulator\DiffuserSimulator\App.xaml
Simulator\DiffuserSimulator\MainWindow.xaml
Simulator\DiffuserSimulator\RoomPanel.xaml


Simulator\DiffuserSimulator\MainWindow.xaml.cs:22:        // 자동변경 : 거실→방1→방2→방3 순서로 30초마다 오염레벨 0→1→2→3→4 자동 적용
Simulator\DiffuserSimulator\MainWindow.xaml.cs:27:        private int _autoPreset = 1;     // 자동변경 시 적용할 프리셋모드 (0 ECO/1 NORMAL/2 TURBO, 기본 NORMAL)
Simulator\DiffuserSimulator\MainWindow.xaml.cs:37:        // 프리셋 값 — 히스테리시스 프리셋별 임계 밴드(CLAUDE.md)의 '중앙값'.
Simulator\DiffuserSimulator\MainWindow.xaml.cs:38:        // 선택한 프리셋모드에 맞춰 좋음=L0 / 보통=L1 / 나쁨=L2 / 매우나쁨=L3 / 최악(빨강)=L4 로 정확히 분류되도록 함.
Simulator\DiffuserSimulator\MainWindow.xaml.cs:39:        // [프리셋 0 ECO / 1 NORMAL / 2 TURBO / 3 힘펠][레벨 0~4] — index 4 = L4(임계 상한 초과, ERV 부하점수 4)
Simulator\DiffuserSimulator\MainWindow.xaml.cs:48:        // 분류용 상한 임계 [프리셋][L1~L3] (그 이상 = 매우나쁨) — ECO/NORMAL/TURBO 는 ErvState 와 동일, 힘펠은 룸컨 사양
Simulator\DiffuserSimulator\MainWindow.xaml.cs:55:        private const int PresetNoSensor = 5;                                 /* level 5 = 센서없음 (L0~3 + L4 최악) */
Simulator\DiffuserSimulator\MainWindow.xaml.cs:60:        // 실별 선택 상태 : 프리셋모드(0 ECO/1 NORMAL/2 TURBO, 기본 NORMAL) / 공기질 레벨(0~3 or 센서없음, 기본 보통)
Simulator\DiffuserSimulator\MainWindow.xaml.cs:61:        private readonly int[] _roomPreset  = { 1, 1, 1, 1, 1 };
Simulator\DiffuserSimulator\MainWindow.xaml.cs:80:            // 자동변경 프리셋모드 선택 버튼 (ECO/NORMAL/TURBO)
Simulator\DiffuserSimulator\MainWindow.xaml.cs:81:            RbAutoEco.Checked   += (s, e) => SetAutoPreset(0);
Simulator\DiffuserSimulator\MainWindow.xaml.cs:82:            RbAutoNorm.Checked  += (s, e) => SetAutoPreset(1);
Simulator\DiffuserSimulator\MainWindow.xaml.cs:83:            RbAutoTurbo.Checked += (s, e) => SetAutoPreset(2);
Simulator\DiffuserSimulator\MainWindow.xaml.cs:104:                    // 처음 상태 reset: damper 0, LED 0, 센서 보통 preset, 양쪽 toggle OFF
Simulator\DiffuserSimulator\MainWindow.xaml.cs:193:                // 프리셋 (좋음/보통/나쁨/매우나쁨/최악/센서없음)
Simulator\DiffuserSimulator\MainWindow.xaml.cs:194:                u.RbGood.Checked += (s, e) => ApplyPreset(idx, 0);
Simulator\DiffuserSimulator\MainWindow.xaml.cs:195:                u.RbNormal.Checked += (s, e) => ApplyPreset(idx, 1);
Simulator\DiffuserSimulator\MainWindow.xaml.cs:196:                u.RbBad.Checked += (s, e) => ApplyPreset(idx, 2);
Simulator\DiffuserSimulator\MainWindow.xaml.cs:197:                u.RbVeryBad.Checked += (s, e) => ApplyPreset(idx, 3);
Simulator\DiffuserSimulator\MainWindow.xaml.cs:198:                u.RbWorst.Checked += (s, e) => ApplyPreset(idx, 4);
Simulator\DiffuserSimulator\MainWindow.xaml.cs:199:                u.RbNoSensor.Checked += (s, e) => ApplyPreset(idx, PresetNoSensor);
Simulator\DiffuserSimulator\MainWindow.xaml.cs:201:                // 프리셋모드 (ECO/NORMAL/TURBO/힘펠)
Simulator\DiffuserSimulator\MainWindow.xaml.cs:202:                u.RbEco.Checked    += (s, e) => ApplyHystPreset(idx, 0);
Simulator\DiffuserSimulator\MainWindow.xaml.cs:203:                u.RbNorm.Checked   += (s, e) => ApplyHystPreset(idx, 1);
Simulator\DiffuserSimulator\MainWindow.xaml.cs:204:                u.RbTurbo.Checked  += (s, e) => ApplyHystPreset(idx, 2);
Simulator\DiffuserSimulator\MainWindow.xaml.cs:298:                _ui[i].RbGood.IsChecked = true;       // 공기질 '좋음' preset
Simulator\DiffuserSimulator\MainWindow.xaml.cs:327:            // 프리셋모드(ECO/NORMAL/TURBO) : DL=활성 / 힘펠=비활성 (전체 방)
Simulator\DiffuserSimulator\MainWindow.xaml.cs:337:            // 자동변경 프리셋모드 선택 버튼 : DL=활성 / 힘펠=비활성
Simulator\DiffuserSimulator\MainWindow.xaml.cs:399:            // 공기질 상태 자동 계산 — 선택한 프리셋모드(ECO/NORMAL/TURBO)의 임계 밴드로
Simulator\DiffuserSimulator\MainWindow.xaml.cs:400:            int p = _roomPreset[idx];
Simulator\DiffuserSimulator\MainWindow.xaml.cs:406:            // 프리셋 라디오 버튼 동기화 (RbNoSensor 체크 상태면 skip — 사용자 선택 보존).
Simulator\DiffuserSimulator\MainWindow.xaml.cs:452:        // ========== 프리셋 적용 ==========
Simulator\DiffuserSimulator\MainWindow.xaml.cs:453:        // level 0~4: 좋음 / 보통 / 나쁨 / 매우나쁨 / 최악(빨강) (Pre*[프리셋모드] 배열 lookup)
Simulator\DiffuserSimulator\MainWindow.xaml.cs:455:        private void ApplyPreset(int idx, int level)
Simulator\DiffuserSimulator\MainWindow.xaml.cs:461:            int p = _roomPreset[idx];
Simulator\DiffuserSimulator\MainWindow.xaml.cs:462:            if (level == PresetNoSensor)
Simulator\DiffuserSimulator\MainWindow.xaml.cs:484:        // 프리셋모드(ECO/NORMAL/TURBO) 변경 → 선택 밴드로 현재 공기질 프리셋 재적용
Simulator\DiffuserSimulator\MainWindow.xaml.cs:485:        private void ApplyHystPreset(int idx, int preset)
Simulator\DiffuserSimulator\MainWindow.xaml.cs:488:            _roomPreset[idx] = preset;
Simulator\DiffuserSimulator\MainWindow.xaml.cs:490:            if (_roomQuality[idx] != PresetNoSensor)
Simulator\DiffuserSimulator\MainWindow.xaml.cs:491:                ApplyPreset(idx, _roomQuality[idx]);
Simulator\DiffuserSimulator\MainWindow.xaml.cs:609:        // ========== 자동변경 : 거실→방1→방2→방3, 각 방 오염레벨 0~4를 30초 단위로 ==========
Simulator\DiffuserSimulator\MainWindow.xaml.cs:617:                OnLog("[자동변경] 중지");
Simulator\DiffuserSimulator\MainWindow.xaml.cs:620:            // 거실~방3(0~3) 활성화 (이미 켜져 있으면 무시) 후 선택 프리셋모드 밴드의 전체 0(좋음)에서 시작
Simulator\DiffuserSimulator\MainWindow.xaml.cs:623:            ApplyAutoPresetToRooms();                 // 선택한 ECO/NORMAL/TURBO 밴드 적용
Simulator\DiffuserSimulator\MainWindow.xaml.cs:624:            for (int r = 0; r <= 3; r++) ApplyPreset(r, 0);
Simulator\DiffuserSimulator\MainWindow.xaml.cs:629:            OnLog($"[자동변경] 시작({AutoPresetName(_autoPreset)}) — 전체 0에서 30초 대기 후 방1→방2→방3→거실 순 누적(0→4)");
Simulator\DiffuserSimulator\MainWindow.xaml.cs:633:        private static string AutoPresetName(int p) => p == 0 ? "ECO" : p == 2 ? "TURBO" : "NORMAL";
Simulator\DiffuserSimulator\MainWindow.xaml.cs:635:        // 자동변경 프리셋모드 선택 → 실행 중이면 즉시 현재 방들에 새 밴드 재적용
Simulator\DiffuserSimulator\MainWindow.xaml.cs:636:        private void SetAutoPreset(int preset)
Simulator\DiffuserSimulator\MainWindow.xaml.cs:638:            _autoPreset = preset;
Simulator\DiffuserSimulator\MainWindow.xaml.cs:639:            if (_autoRunning) ApplyAutoPresetToRooms();
Simulator\DiffuserSimulator\MainWindow.xaml.cs:642:        // 선택한 자동변경 프리셋모드를 거실~방3(0~3) 패널에 반영 → 각 방의 센서 밴드(_roomPreset) 갱신
Simulator\DiffuserSimulator\MainWindow.xaml.cs:643:        private void ApplyAutoPresetToRooms()
Simulator\DiffuserSimulator\MainWindow.xaml.cs:647:                switch (_autoPreset)
Simulator\DiffuserSimulator\MainWindow.xaml.cs:664:                for (int r = 0; r <= 3; r++) ApplyPreset(r, 0);
Simulator\DiffuserSimulator\MainWindow.xaml.cs:665:                OnLog("[자동변경] 사이클 완료 — 전체 0 리셋 후 반복");
Simulator\DiffuserSimulator\MainWindow.xaml.cs:669:            ApplyPreset(room, level);                 // 누적: 다른 방은 건드리지 않음
Simulator\DiffuserSimulator\MainWindow.xaml.cs:670:            OnLog($"[자동변경] {RoomNames[room]} 오염레벨 {level}");
Simulator\DiffuserSimulator\MainWindow.xaml.cs:716:                // 공기질 프리셋은 master 가 보내지 않음 (byte 9 = 0) — 사용자 선택 보존.
Simulator\DiffuserSimulator\SlaveProtocol.cs:31:        /* 디폴트: 보통 preset */
Simulator\DiffuserSimulator\SlaveProtocol.cs:39:        public int VOC = 115;       /* VOC index (0~500), 보통 preset */


Simulator\DiffuserSimulator\App.xaml:103:        <!-- 프리셋모드 선택 버튼(ECO/NORMAL/TURBO) — RadioButton 을 토글 버튼 모양으로 -->
Simulator\DiffuserSimulator\App.xaml:104:        <Style x:Key="PresetRadio" TargetType="RadioButton">
Simulator\DiffuserSimulator\MainWindow.xaml:60:                                ToolTip="선택한 프리셋모드(ECO/NORMAL/TURBO) 밴드로 거실→방1~3 순서로 30초마다 오염레벨 0→1→2→3→4 자동 변경"/>
Simulator\DiffuserSimulator\MainWindow.xaml:62:                            <RadioButton x:Name="RbAutoEco"   Content="ECO"    GroupName="AutoPreset" Style="{StaticResource PresetRadio}"/>
Simulator\DiffuserSimulator\MainWindow.xaml:63:                            <RadioButton x:Name="RbAutoNorm"  Content="NORMAL" GroupName="AutoPreset" Style="{StaticResource PresetRadio}" IsChecked="True"/>
Simulator\DiffuserSimulator\MainWindow.xaml:64:                            <RadioButton x:Name="RbAutoTurbo" Content="TURBO"  GroupName="AutoPreset" Style="{StaticResource PresetRadio}"/>
Simulator\DiffuserSimulator\RoomPanel.xaml:118:                <!-- 공기질 프리셋 (RbNoSensor=센서없음). GroupName 없이 부모 UniformGrid 단위로 그룹화 → 실별 독립 -->
Simulator\DiffuserSimulator\RoomPanel.xaml:119:                <TextBlock Text="프리셋" FontSize="9" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,2,0,4"/>
Simulator\DiffuserSimulator\RoomPanel.xaml:129:                <!-- 프리셋모드 (센서값 밴드 선택). 기본 NORMAL -->
Simulator\DiffuserSimulator\RoomPanel.xaml:130:                <TextBlock Text="프리셋모드" FontSize="9" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,2,0,4"/>


핵심을 찾았습니다. 디퓨저 시뮬레이터의 "자동변경 프리셋모드(ECO/NORMAL/TURBO)"는 **시뮬레이터 내부에서 센서값을 생성하는 밴드 선택**일 뿐입니다(MainWindow.xaml.cs:642 `ApplyAutoPresetToRooms` → 각 방 `_roomPreset` 갱신). 디퓨저가 ERV로 보내는 건 센서값/공기질레벨이지 프리셋이 아닙니다. 송신 프레임을 확인해 확정하겠습니다.


1	using System;
2	using System.IO.Ports;
3	using System.Threading;
4	using System.Threading.Tasks;
5
6	namespace DiffuserSimulator
7	{
8	    public class RoomSimData
9	    {
10	        public byte Id2;
11	        public bool Enabled;
12	        /* Master 모드 폴링 toggle — 급기/배기 디퓨저 별 활성화 */
13	        public bool PollSA = false;
14	        public bool PollRA = false;
15	        public byte Power = 0x01;
16	        public byte RunMode = 0x01;
17	        public byte FanSpeed = 0x00;
18	        public byte LedBrightness = 0x00;
19	        public byte AirQualityStatus = 0x03;  // 보통 - green
20	        public byte DamperAngleSA = 0;
21	        public byte DamperAngleEA = 0;
22	        /* 수동 닫기 오버라이드 (Slave) — true면 마스터 개방명령 무시하고 0 유지 */
23	        public bool ManualCloseSA = false;
24	        public bool ManualCloseRA = false;
25	        /* LED 디밍 수동 제어 (Slave) — true면 마스터 LED 명령 무시하고 슬라이더 값 유지 */
26	        public bool ManualLed = false;
27	        /* 제품 모드 : false=DL(byte24~25 VOC 송신) / true=힘펠(TVOC 송신). RA2 활성에도 영향 (거실 한정) */
28	        public bool Himpel = false;
29	        /* RA(배기) 디퓨저 응답 활성 — 거실2는 힘펠 모드에서만 RA 동작 (DL 모드는 SA만) */
30	        public bool RaActive = true;
31	        /* 디폴트: 보통 preset */
32	        public int PM10 = 30;
33	        public int PM25 = 25;
34	        public int PM4 = 20;
35	        public int PM1 = 10;
36	        public int Humidity = 50;
37	        public int Temperature = 25;
38	        public int TVOC = 250;
39	        public int VOC = 115;       /* VOC index (0~500), 보통 preset */
40	        public int NOx = 0;
41	        public int CO2 = 850;
42	        public ushort ErrorCode = 0x0000;
43	        public byte VersionMajor = 0x01;
44	        public byte VersionMinor = 0x00;
45	    }
46
47	    public enum SimMode { Slave, Master }
48
49	    public class SlaveProtocol : IDisposable
50	    {
51	        private SerialPort? _serialPort;
52	        private CancellationTokenSource? _listenCts;
53	        private readonly object _lock = new();
54	        private bool _disposed;
55	        private bool _responding;
56
57	        // Rooms 인덱스 : 0=거실(Id2 1), 1=방1(Id2 2), 2=방2(Id2 3), 3=방3(Id2 4), 4=방4(Id2 5), 5=거실2(Id2 0).
58	        //   거실2(RA2/SA2)는 거실 패널이 제어. Rev1.3 : ID2 0x00=거실2, 0x01=거실, 0x02~0x05=방1~4.
59	        public RoomSimData[] Rooms = new RoomSimData[6];
60	        public const int LivingRoom2Index = 5;
61	        public SimMode Mode { get; private set; } = SimMode.Slave;
62
63	        public event Action<byte[], byte>? MasterPacketReceived;     // Slave mode: master packet received
64	        public event Action<byte[], byte, byte>? SlavePacketReceived; // Master mode: slave response received (id1, id2)
65	        public event Action<byte, bool>? ResponseSent; // id2, responded
66	        public event Action<byte, byte>? MasterPollSent; // Master mode: id1, id2 polled
67	        public event Action<string>? LogMessage;
68	        public event Action<bool>? ConnectionChanged;
69
70	        public bool IsConnected => _serialPort?.IsOpen == true;
71	        public bool IsResponding => _responding;
72
73	        public SlaveProtocol()
74	        {
75	            for (int i = 0; i < 5; i++)
76	                Rooms[i] = new RoomSimData { Id2 = (byte)(i + 1) };
77	            Rooms[LivingRoom2Index] = new RoomSimData { Id2 = 0x00 };   // 거실2
78	        }
79
80	        // ID2 → Rooms 인덱스. 거실2(0)=5, 거실(1)=0, 방1~4(2~5)=1~4. 범위 밖이면 -1.
81	        public static int Id2ToIndex(byte id2) => id2 == 0 ? LivingRoom2Index : (id2 <= 5 ? id2 - 1 : -1);
82
83	        public static ushort CalcCRC16(byte[] data, int length)
84	        {
85	            ushort crc = 0xFFFF;
86	            for (int i = 0; i < length; i++)
87	            {
88	                crc ^= data[i];
89	                for (int j = 0; j < 8; j++)
90	                    crc = ((crc & 1) != 0) ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
91	            }
92	            return crc;
93	        }
94
95	        public string[] GetAvailablePorts() => SerialPort.GetPortNames();
96
97	        public bool Connect(string portName)
98	        {
99	            try
100	            {
101	                Disconnect();
102	                _serialPort = new SerialPort(portName)
103	                {
104	                    BaudRate = 115200, DataBits = 8,
105	                    StopBits = StopBits.One, Parity = Parity.None,
106	                    ReadTimeout = 100, WriteTimeout = 500
107	                };
108	                _serialPort.Open();
109	                Log($"[연결] {portName} (115200, 8N1)");
110	                ConnectionChanged?.Invoke(true);
111	                return true;
112	            }
113	            catch (Exception ex)
114	            {
115	                Log($"[오류] 연결 실패: {ex.Message}");
116	                ConnectionChanged?.Invoke(false);
117	                return false;
118	            }
119	        }
120
121	        public void Disconnect()
122	        {
123	            StopResponding();
124	            try { if (_serialPort?.IsOpen == true) { _serialPort.Close(); Log("[연결 해제]"); } } catch { }
125	            _serialPort?.Dispose();
126	            _serialPort = null;
127	            ConnectionChanged?.Invoke(false);
128	        }
129
130	        public void StartResponding(int intervalMs = 1000)
131	        {
132	            StopResponding();
133	            Mode = SimMode.Slave;
134	            _responding = true;
135	            _listenCts = new CancellationTokenSource();
136	            var token = _listenCts.Token;
137
138	            // 능동 송신 제거됨 — 마스터 polling 수신 시에만 응답.
139	            //   (이전: 능동 송신 + 마스터 응답 dual mode → STM32 master 와 bus 충돌 우려 + STM32 측 cycle 안에 garbage 유입 가능)
140
141	            // 수신 Task: 마스터 폴링이 오면 응답
142	            Task.Run(() =>
143	            {
144	                while (!token.IsCancellationRequested)
145	                {
146	                    try
147	                    {
148	                        if (_serialPort?.IsOpen != true) { Thread.Sleep(50); continue; }
149	                        if (_serialPort.BytesToRead < 1) { Thread.Sleep(3); continue; }
150
151	                        byte b = (byte)_serialPort.ReadByte();
152	                        if (b != 0xAA) continue;
153
154	                        byte[] rxBuf = new byte[29];
155	                        rxBuf[0] = 0xAA;
156	                        int totalRead = 1, remaining = 28, retries = 100;
157	                        while (remaining > 0 && retries-- > 0)
158	                        {
159	                            if (_serialPort.BytesToRead > 0)
160	                            { int r = _serialPort.Read(rxBuf, totalRead, remaining); totalRead += r; remaining -= r; }
161	                            else Thread.Sleep(2);
162	                        }
163	                        if (totalRead < 29) continue;
164
165	                        // CRC 바이트순서 = lo-first(표준 리틀엔디안). 펌웨어 CRC16()이 표준MODBUS의
166	                        // 바이트스왑값을 반환 + [27]=icrc>>8 배치 → 두 스왑 상쇄 → 와이어는 [27]=하위,[28]=상위.
167	                        ushort rxCrc = (ushort)(rxBuf[27] | (rxBuf[28] << 8));
168	                        ushort calcCrc = CalcCRC16(rxBuf, 27);
169	                        if (rxCrc != calcCrc) { Log($"[CRC오류] 수신:0x{rxCrc:X4} 계산:0x{calcCrc:X4}"); continue; }
170	                        if (rxBuf[1] != 0x10) continue;
171
172	                        byte id2 = rxBuf[3];
173	                        Log($"[RX] {BitConverter.ToString(rxBuf)}");
174	                        /* MasterPacketReceived event invoke 는 room.DamperAngleSA/EA + LED 등
175	                           실제 갱신 이후로 이동 — UI 가 새 값 표시. 이전엔 갱신 전 호출이라
176	                           UI 가 한 cycle 전 (옛) 값 표시 → 사용자가 0/110 mismatch 보고. */
177
178	                        int ri = Id2ToIndex(id2);
179	                        if (ri < 0) continue;
180	                        var room = Rooms[ri];
181	                        byte id1 = rxBuf[2];
182
183	                        // 응답 조건: 방 Enabled 면 SA/RA 모두 응답.
184	                        // (배기/급기 토글은 댐퍼 열림/닫힘 표시용 — 각도 연동, 응답 게이트 아님)
185	                        if (!room.Enabled)
186	                        {
187	                            ResponseSent?.Invoke(id2, false);
188	                            continue;
189	                        }
190	                        // RA(배기 0x02) 디퓨저 비활성(거실2 DL 모드)이면 RA 폴링 무응답
191	                        if (id1 == 0x02 && !room.RaActive)
192	                        {
193	                            ResponseSent?.Invoke(id2, false);
194	                            continue;
195	                        }
196
197	                        // 패킷 구분 : byte5 = Power. 하위비트 = 전원(0/1), 상위 0x80 = Control Cmd (dump.txt 스펙).
198	                        //   전원 OFF(하위비트 0) : 펌웨어가 댐퍼=0·LED=0 을 보냄 → 디퓨저 닫힘·소등 (전원우선)
199	                        //   전원 ON  : 폴링/명령 모두 byte5 하위비트=1 → 댐퍼/LED 상태 적용
200	                        // (과거 isCommand=byte5!=0 만 적용 → 전원 OFF(byte5=0) 닫힘명령을 폴링으로 오인,
201	                        //  댐퍼 열림·LED 켜진 채 남던 문제 수정)
202	                        bool powerOff = ((rxBuf[5] & 0x7F) == 0x00);
203	                        bool isCommand = (rxBuf[5] != 0x00);
204	                        if (powerOff)
205	                        {
206	                            // 전원 OFF → 급기/배기 댐퍼 닫힘 + LED 소등 (수동닫기/수동LED 와 무관, 전원이 우선)
207	                            room.Power = 0;
208	                            room.DamperAngleSA = 0;
209	                            room.DamperAngleEA = 0;
210	                            room.LedBrightness = 0;
211	                        }
212	                        else if (isCommand)
213	                        {
214	                            // 마스터 명령 적용 — ID1 별로 해당 type 의 필드만 갱신.
215	                            //   ID1=0x01 (SA): damper SA 만
216	                            //   ID1=0x02 (RA): damper RA + LED + 공통 (Power/RunMode/Fan/Color)
217	                            if (id1 == 0x01)
218	                            {
219	                                room.DamperAngleSA = room.ManualCloseSA ? (byte)0 : rxBuf[10];
220	                            }
221	                            else if (id1 == 0x02)
222	                            {
223	                                room.DamperAngleEA = room.ManualCloseRA ? (byte)0 : rxBuf[11];
224	                                // 힘펠 모드는 LED 디밍 미사용 → 마스터 byte 8 무시(갱신 안 함)
225	                                if (!room.ManualLed && !room.Himpel) room.LedBrightness = rxBuf[8];
226	                                room.Power = rxBuf[5];
227	                                room.RunMode = rxBuf[6];
228	                                room.FanSpeed = rxBuf[7];
229	                                if (rxBuf[9] != 0) room.AirQualityStatus = rxBuf[9];
230	                            }
231	                        }
232
233	                        /* room 갱신 후 UI 동기화 event. UI 의 TbSAAngle/TbEAAngle/LED 가 새 값 표시. */
234	                        MasterPacketReceived?.Invoke(rxBuf, id2);
235
236	                        // 응답 전송
237	                        byte[] tx = BuildResponse(room, id1, id2);
238	                        lock (_lock)
239	                        {
240	                            _serialPort?.Write(tx, 0, tx.Length);
241	                        }
242	                        Log($"[TX 응답] {BitConverter.ToString(tx)}");
243	                        ResponseSent?.Invoke(id2, true);
244	                    }
245	                    catch (TimeoutException) { }
246	                    catch (OperationCanceledException) { break; }
247	                    catch (Exception ex)
248	                    {
249	                        if (!token.IsCancellationRequested) Log($"[오류] {ex.Message}");
250	                        Thread.Sleep(100);
251	                    }
252	                }
253	            }, token);
254	            Log("[통신 시작] 마스터 응답 모드");
255	        }
256
257	        public void StopResponding()
258	        {
259	            _responding = false;
260	            _listenCts?.Cancel();
261	            _listenCts?.Dispose();
262	            _listenCts = null;
263	        }
264
265	        // ========================= Master Mode =========================
266	        // 시뮬레이터가 마스터 역할: enabled 된 room 들을 SA(0x01) + RA(0x02) 로 순회 polling.
267	        // STM32 (slave) 는 ID1/ID2 매칭 시 39 byte 응답.
268	        public void StartMasterPolling(int intervalMs = 1000)
269	        {
270	            StopResponding();
271	            Mode = SimMode.Master;
272	            _responding = true;
273	            _listenCts = new CancellationTokenSource();
274	            var token = _listenCts.Token;
275
276	            Task.Run(async () =>
277	            {
278	                try
279	                {
280	                    while (!token.IsCancellationRequested)
281	                    {
282	                        if (_serialPort?.IsOpen != true) { await Task.Delay(100, token); continue; }
283
284	                        // Active polling slot 수 (Enabled + PollSA/RA 켜진 것)
285	                        int activeSlots = 0;
286	                        for (int i = 0; i < Rooms.Length; i++)
287	                        {
288	                            if (!Rooms[i].Enabled) continue;
289	                            if (Rooms[i].PollSA) activeSlots++;
290	                            if (Rooms[i].PollRA && Rooms[i].RaActive) activeSlots++;
291	                        }
292	                        if (activeSlots == 0) { await Task.Delay(intervalMs, token); continue; }
293
294	                        int slotMs = Math.Max(20, intervalMs / activeSlots);
295
296	                        // 각 enabled room 에 대해 SA → RA 폴링 (toggle 켜진 것만)
297	                        foreach (byte id1 in new byte[] { 0x01, 0x02 })
298	                        {
299	                            for (int i = 0; i < Rooms.Length && !token.IsCancellationRequested; i++)
300	                            {
301	                                var room = Rooms[i];
302	                                if (!room.Enabled) continue;
303	                                if (id1 == 0x01 && !room.PollSA) continue;
304	                                if (id1 == 0x02 && (!room.PollRA || !room.RaActive)) continue;
305
306	                                byte[] tx = BuildMasterPacket(room, id1);
307	                                lock (_lock) { _serialPort?.Write(tx, 0, tx.Length); }
308	                                Log($"[TX-M] ID1=0x{id1:X2} ID2=0x{room.Id2:X2} {BitConverter.ToString(tx)}");
309	                                MasterPollSent?.Invoke(id1, room.Id2);
310
311	                                // Slave 응답 대기 (39 byte, 80ms timeout)
312	                                byte[]? resp = TryReceiveSlaveResponse(80);
313	                                if (resp != null)
314	                                {
315	                                    Log($"[RX-S] ID1=0x{resp[2]:X2} ID2=0x{resp[3]:X2} {BitConverter.ToString(resp)}");
316	                                    SlavePacketReceived?.Invoke(resp, resp[2], resp[3]);
317
318	                                    // 받은 SEN66 값을 room 에 갱신 (sensor 만)
319	                                    int ri = Id2ToIndex(resp[3]);
320	                                    if (ri >= 0)
321	                                    {
322	                                        var r = Rooms[ri];
323	                                        r.PM10 = (resp[12] << 8) | resp[13];
324	                                        r.PM4 = (resp[14] << 8) | resp[15];
325	                                        r.PM25 = (resp[16] << 8) | resp[17];
326	                                        r.PM1 = (resp[18] << 8) | resp[19];
327	                                        r.Humidity = (resp[20] << 8) | resp[21];
328	                                        r.Temperature = (resp[22] << 8) | resp[23];
329	                                        r.TVOC = (resp[24] << 8) | resp[25];
330	                                        r.NOx = (resp[26] << 8) | resp[27];
331	                                        r.CO2 = (resp[28] << 8) | resp[29];
332	                                    }
333	                                }
334
335	                                await Task.Delay(slotMs, token);
336	                            }
337	                        }
338	                    }
339	                }
340	                catch (OperationCanceledException) { }
341	                catch (Exception ex) { if (!token.IsCancellationRequested) Log($"[마스터 오류] {ex.Message}"); }
342	            }, token);
343
344	            Log("[통신 시작] Master Mode — Polling 송신");
345	        }
346
347	        public byte[] BuildMasterPacket(RoomSimData room, byte id1)
348	        {
349	            byte[] p = new byte[29];
350	            p[0] = 0xAA; p[1] = 0x10; p[2] = id1; p[3] = room.Id2; p[4] = 0x00;
351	            // 실제 protocol dump 분석 결과 0x80 Control bit 없음 — raw 값 그대로.
352	            p[5] = room.Power;
353	            p[6] = room.RunMode;
354	            p[7] = room.FanSpeed;
355	            // LED 디밍은 RA(0x02) 명령 패킷에만 전송 — SA(0x01)·힘펠은 0
356	            p[8] = (id1 == 0x02 && !room.Himpel) ? room.LedBrightness : (byte)0;
357	            p[9] = room.AirQualityStatus;
358	            p[10] = room.DamperAngleSA;
359	            p[11] = room.DamperAngleEA;
360	            // byte 12~26: 0 (RPM / Reset / 예약 등 미사용)
361	            ushort crc = CalcCRC16(p, 27);
362	            // lo-first (표준 리틀엔디안) — 와이어 [27]=하위, [28]=상위
363	            p[27] = (byte)(crc & 0xFF);
364	            p[28] = (byte)((crc >> 8) & 0xFF);
365	            return p;
366	        }
367
368	        private byte[]? TryReceiveSlaveResponse(int timeoutMs)
369	        {
370	            if (_serialPort?.IsOpen != true) return null;
371	            long deadline = Environment.TickCount64 + timeoutMs;
372	            // header 0xAA 대기
373	            while (Environment.TickCount64 < deadline)
374	            {
375	                if (_serialPort.BytesToRead < 1) { Thread.Sleep(2); continue; }
376	                byte b = (byte)_serialPort.ReadByte();
377	                if (b != 0xAA) continue;
378
379	                byte[] buf = new byte[39];
380	                buf[0] = 0xAA;
381	                int total = 1, remain = 38;
382	                while (remain > 0 && Environment.TickCount64 < deadline)
383	                {
384	                    if (_serialPort.BytesToRead > 0)
385	                    {
386	                        int r = _serialPort.Read(buf, total, remain);
387	                        total += r; remain -= r;
388	                    }
389	                    else Thread.Sleep(2);
390	                }
391	                if (total < 39) return null;
392	                if (buf[1] != 0x01) continue; // not slave
393
394	                ushort rxCrc = (ushort)(buf[37] | (buf[38] << 8));   // lo-first (표준 리틀엔디안)
395	                ushort calc = CalcCRC16(buf, 37);
396	                if (rxCrc != calc) { Log($"[CRC오류] 수신:0x{rxCrc:X4} 계산:0x{calc:X4}"); return null; }
397	                return buf;
398	            }
399	            return null;
400	        }
401
402	        public byte[] BuildResponse(RoomSimData room, byte id1, byte id2)
403	        {
404	            byte[] p = new byte[39];
405	            p[0] = 0xAA; p[1] = 0x01; p[2] = id1; p[3] = id2; p[4] = 0x00;
406	            p[5] = room.Power; p[6] = room.RunMode; p[7] = room.FanSpeed;
407	            p[8] = room.LedBrightness; p[9] = room.AirQualityStatus;
408	            p[10] = room.DamperAngleSA; p[11] = room.DamperAngleEA;
409	            void W16(int idx, int val) { p[idx] = (byte)((val >> 8) & 0xFF); p[idx + 1] = (byte)(val & 0xFF); }
410	            W16(12, room.PM10); W16(14, room.PM4); W16(16, room.PM25); W16(18, room.PM1);
411	            W16(20, room.Humidity); W16(22, room.Temperature);
412	            W16(24, room.Himpel ? room.TVOC : room.VOC);   /* byte 24,25 : DL=VOC / 힘펠=TVOC (Rev1.3) */
413	            W16(26, room.NOx); W16(28, room.CO2);
414	            p[30] = 0; p[31] = 0; p[32] = 0;
415	            p[33] = (byte)((room.ErrorCode >> 8) & 0xFF); p[34] = (byte)(room.ErrorCode & 0xFF);
416	            p[35] = room.VersionMajor; p[36] = room.VersionMinor;
417	            ushort crc = CalcCRC16(p, 37);
418	            // lo-first : 펌웨어 RX가 (Rx[37]<<8)|Rx[38] 로 읽고 CRC16()=스왑값과 비교 → lo-first로 보내야 일치
419	            p[37] = (byte)(crc & 0xFF); p[38] = (byte)((crc >> 8) & 0xFF);
420	            return p;
421	        }
422
423	        private void Log(string msg) => LogMessage?.Invoke($"[{DateTime.Now:HH:mm:ss.fff}] {msg}");
424
425	        public void Dispose()
426	        {
427	            if (_disposed) return;
428	            _disposed = true;
429	            Disconnect();
430	            GC.SuppressFinalize(this);
431	        }
432	    }
433	}
434

using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;

namespace DiffuserSimulator
{
    public class RoomSimData
    {
        public byte Id2;
        public bool Enabled;
        /* Master 모드 폴링 toggle — 급기/배기 디퓨저 별 활성화 */
        public bool PollSA = false;
        public bool PollRA = false;
        public byte Power = 0x01;
        public byte RunMode = 0x01;
        public byte FanSpeed = 0x00;
        public byte LedBrightness = 0x00;
        public byte AirQualityStatus = 0x03;  // 보통 - green
        public byte DamperAngleSA = 0;
        public byte DamperAngleEA = 0;
        /* 수동 닫기 오버라이드 (Slave) — true면 마스터 개방명령 무시하고 0 유지 */
        public bool ManualCloseSA = false;
        public bool ManualCloseRA = false;
        /* LED 디밍 수동 제어 (Slave) — true면 마스터 LED 명령 무시하고 슬라이더 값 유지 */
        public bool ManualLed = false;
        /* 제품 모드 : false=DL(byte24~25 VOC 송신) / true=힘펠(TVOC 송신). RA2 활성에도 영향 (거실 한정) */
        public bool Himpel = false;
        /* RA(배기) 디퓨저 응답 활성 — 거실2는 힘펠 모드에서만 RA 동작 (DL 모드는 SA만) */
        public bool RaActive = true;
        /* 디폴트: 보통 preset */
        public int PM10 = 30;
        public int PM25 = 25;
        public int PM4 = 20;
        public int PM1 = 10;
        public int Humidity = 50;
        public int Temperature = 25;
        public int TVOC = 250;
        public int VOC = 115;       /* VOC index (0~500), 보통 preset */
        public int NOx = 0;
        public int CO2 = 850;
        public ushort ErrorCode = 0x0000;
        public byte VersionMajor = 0x01;
        public byte VersionMinor = 0x00;
    }

    public enum SimMode { Slave, Master }

    public class SlaveProtocol : IDisposable
    {
        private SerialPort? _serialPort;
        private CancellationTokenSource? _listenCts;
        private readonly object _lock = new();
        private bool _disposed;
        private bool _responding;

        // Rooms 인덱스 : 0=거실(Id2 1), 1=방1(Id2 2), 2=방2(Id2 3), 3=방3(Id2 4), 4=방4(Id2 5), 5=거실2(Id2 0).
        //   거실2(RA2/SA2)는 거실 패널이 제어. Rev1.3 : ID2 0x00=거실2, 0x01=거실, 0x02~0x05=방1~4.
        public RoomSimData[] Rooms = new RoomSimData[6];
        public const int LivingRoom2Index = 5;
        public SimMode Mode { get; private set; } = SimMode.Slave;

        public event Action<byte[], byte>? MasterPacketReceived;     // Slave mode: master packet received
        public event Action<byte[], byte, byte>? SlavePacketReceived; // Master mode: slave response received (id1, id2)
        public event Action<byte, bool>? ResponseSent; // id2, responded
        public event Action<byte, byte>? MasterPollSent; // Master mode: id1, id2 polled
        public event Action<string>? LogMessage;
        public event Action<bool>? ConnectionChanged;

        public bool IsConnected => _serialPort?.IsOpen == true;
        public bool IsResponding => _responding;

        public SlaveProtocol()
        {
            for (int i = 0; i < 5; i++)
                Rooms[i] = new RoomSimData { Id2 = (byte)(i + 1) };
            Rooms[LivingRoom2Index] = new RoomSimData { Id2 = 0x00 };   // 거실2
        }

        // ID2 → Rooms 인덱스. 거실2(0)=5, 거실(1)=0, 방1~4(2~5)=1~4. 범위 밖이면 -1.
        public static int Id2ToIndex(byte id2) => id2 == 0 ? LivingRoom2Index : (id2 <= 5 ? id2 - 1 : -1);

        public static ushort CalcCRC16(byte[] data, int length)
        {
            ushort crc = 0xFFFF;
            for (int i = 0; i < length; i++)
            {
                crc ^= data[i];
                for (int j = 0; j < 8; j++)
                    crc = ((crc & 1) != 0) ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
            }
            return crc;
        }

        public string[] GetAvailablePorts() => SerialPort.GetPortNames();

        public bool Connect(string portName)
        {
            try
            {
                Disconnect();
                _serialPort = new SerialPort(portName)
                {
                    BaudRate = 115200, DataBits = 8,
                    StopBits = StopBits.One, Parity = Parity.None,
                    ReadTimeout = 100, WriteTimeout = 500
                };
                _serialPort.Open();
                Log($"[연결] {portName} (115200, 8N1)");
                ConnectionChanged?.Invoke(true);
                return true;
            }
            catch (Exception ex)
            {
                Log($"[오류] 연결 실패: {ex.Message}");
                ConnectionChanged?.Invoke(false);
                return false;
            }
        }

        public void Disconnect()
        {
            StopResponding();
            try { if (_serialPort?.IsOpen == true) { _serialPort.Close(); Log("[연결 해제]"); } } catch { }
            _serialPort?.Dispose();
            _serialPort = null;
            ConnectionChanged?.Invoke(false);
        }

        public void StartResponding(int intervalMs = 1000)
        {
            StopResponding();
            Mode = SimMode.Slave;
            _responding = true;
            _listenCts = new CancellationTokenSource();
            var token = _listenCts.Token;

            // 능동 송신 제거됨 — 마스터 polling 수신 시에만 응답.
            //   (이전: 능동 송신 + 마스터 응답 dual mode → STM32 master 와 bus 충돌 우려 + STM32 측 cycle 안에 garbage 유입 가능)

            // 수신 Task: 마스터 폴링이 오면 응답
            Task.Run(() =>
            {
                while (!token.IsCancellationRequested)
                {
                    try
                    {
                        if (_serialPort?.IsOpen != true) { Thread.Sleep(50); continue; }
                        if (_serialPort.BytesToRead < 1) { Thread.Sleep(3); continue; }

                        byte b = (byte)_serialPort.ReadByte();
                        if (b != 0xAA) continue;

                        byte[] rxBuf = new byte[29];
                        rxBuf[0] = 0xAA;
                        int totalRead = 1, remaining = 28, retries = 100;
                        while (remaining > 0 && retries-- > 0)
                        {
                            if (_serialPort.BytesToRead > 0)
                            { int r = _serialPort.Read(rxBuf, totalRead, remaining); totalRead += r; remaining -= r; }
                            else Thread.Sleep(2);
                        }
                        if (totalRead < 29) continue;

                        // CRC 바이트순서 = lo-first(표준 리틀엔디안). 펌웨어 CRC16()이 표준MODBUS의
                        // 바이트스왑값을 반환 + [27]=icrc>>8 배치 → 두 스왑 상쇄 → 와이어는 [27]=하위,[28]=상위.
                        ushort rxCrc = (ushort)(rxBuf[27] | (rxBuf[28] << 8));
                        ushort calcCrc = CalcCRC16(rxBuf, 27);
                        if (rxCrc != calcCrc) { Log($"[CRC오류] 수신:0x{rxCrc:X4} 계산:0x{calcCrc:X4}"); continue; }
                        if (rxBuf[1] != 0x10) continue;

                        byte id2 = rxBuf[3];
                        Log($"[RX] {BitConverter.ToString(rxBuf)}");
                        /* MasterPacketReceived event invoke 는 room.DamperAngleSA/EA + LED 등
                           실제 갱신 이후로 이동 — UI 가 새 값 표시. 이전엔 갱신 전 호출이라
                           UI 가 한 cycle 전 (옛) 값 표시 → 사용자가 0/110 mismatch 보고. */

                        int ri = Id2ToIndex(id2);
                        if (ri < 0) continue;
                        var room = Rooms[ri];
                        byte id1 = rxBuf[2];

                        // 응답 조건: 방 Enabled 면 SA/RA 모두 응답.
                        // (배기/급기 토글은 댐퍼 열림/닫힘 표시용 — 각도 연동, 응답 게이트 아님)
                        if (!room.Enabled)
                        {
                            ResponseSent?.Invoke(id2, false);
                            continue;
                        }
                        // RA(배기 0x02) 디퓨저 비활성(거실2 DL 모드)이면 RA 폴링 무응답
                        if (id1 == 0x02 && !room.RaActive)
                        {
                            ResponseSent?.Invoke(id2, false);
                            continue;
                        }

                        // 패킷 구분 : byte5 = Power. 하위비트 = 전원(0/1), 상위 0x80 = Control Cmd (dump.txt 스펙).
                        //   전원 OFF(하위비트 0) : 펌웨어가 댐퍼=0·LED=0 을 보냄 → 디퓨저 닫힘·소등 (전원우선)
                        //   전원 ON  : 폴링/명령 모두 byte5 하위비트=1 → 댐퍼/LED 상태 적용
                        // (과거 isCommand=byte5!=0 만 적용 → 전원 OFF(byte5=0) 닫힘명령을 폴링으로 오인,
                        //  댐퍼 열림·LED 켜진 채 남던 문제 수정)
                        bool powerOff = ((rxBuf[5] & 0x7F) == 0x00);
                        bool isCommand = (rxBuf[5] != 0x00);
                        if (powerOff)
                        {
                            // 전원 OFF → 급기/배기 댐퍼 닫힘 + LED 소등 (수동닫기/수동LED 와 무관, 전원이 우선)
                            room.Power = 0;
                            room.DamperAngleSA = 0;
                            room.DamperAngleEA = 0;
                            room.LedBrightness = 0;
                        }
                        else if (isCommand)
                        {
                            // 마스터 명령 적용 — ID1 별로 해당 type 의 필드만 갱신.
                            //   ID1=0x01 (SA): damper SA 만
                            //   ID1=0x02 (RA): damper RA + LED + 공통 (Power/RunMode/Fan/Color)
                            if (id1 == 0x01)
                            {
                                room.DamperAngleSA = room.ManualCloseSA ? (byte)0 : rxBuf[10];
                            }
                            else if (id1 == 0x02)
                            {
                                room.DamperAngleEA = room.ManualCloseRA ? (byte)0 : rxBuf[11];
                                // 힘펠 모드는 LED 디밍 미사용 → 마스터 byte 8 무시(갱신 안 함)
                                if (!room.ManualLed && !room.Himpel) room.LedBrightness = rxBuf[8];
                                room.Power = rxBuf[5];
                                room.RunMode = rxBuf[6];
                                room.FanSpeed = rxBuf[7];
                                if (rxBuf[9] != 0) room.AirQualityStatus = rxBuf[9];
                            }
                        }

                        /* room 갱신 후 UI 동기화 event. UI 의 TbSAAngle/TbEAAngle/LED 가 새 값 표시. */
                        MasterPacketReceived?.Invoke(rxBuf, id2);

                        // 응답 전송
                        byte[] tx = BuildResponse(room, id1, id2);
                        lock (_lock)
                        {
                            _serialPort?.Write(tx, 0, tx.Length);
                        }
                        Log($"[TX 응답] {BitConverter.ToString(tx)}");
                        ResponseSent?.Invoke(id2, true);
                    }
                    catch (TimeoutException) { }
                    catch (OperationCanceledException) { break; }
                    catch (Exception ex)
                    {
                        if (!token.IsCancellationRequested) Log($"[오류] {ex.Message}");
                        Thread.Sleep(100);
                    }
                }
            }, token);
            Log("[통신 시작] 마스터 응답 모드");
        }

        public void StopResponding()
        {
            _responding = false;
            _listenCts?.Cancel();
            _listenCts?.Dispose();
            _listenCts = null;
        }

        // ========================= Master Mode =========================
        // 시뮬레이터가 마스터 역할: enabled 된 room 들을 SA(0x01) + RA(0x02) 로 순회 polling.
        // STM32 (slave) 는 ID1/ID2 매칭 시 39 byte 응답.
        public void StartMasterPolling(int intervalMs = 1000)
        {
            StopResponding();
            Mode = SimMode.Master;
            _responding = true;
            _listenCts = new CancellationTokenSource();
            var token = _listenCts.Token;

            Task.Run(async () =>
            {
                try
                {
                    while (!token.IsCancellationRequested)
                    {
                        if (_serialPort?.IsOpen != true) { await Task.Delay(100, token); continue; }

                        // Active polling slot 수 (Enabled + PollSA/RA 켜진 것)
                        int activeSlots = 0;
                        for (int i = 0; i < Rooms.Length; i++)
                        {
                            if (!Rooms[i].Enabled) continue;
                            if (Rooms[i].PollSA) activeSlots++;
                            if (Rooms[i].PollRA && Rooms[i].RaActive) activeSlots++;
                        }
                        if (activeSlots == 0) { await Task.Delay(intervalMs, token); continue; }

                        int slotMs = Math.Max(20, intervalMs / activeSlots);

                        // 각 enabled room 에 대해 SA → RA 폴링 (toggle 켜진 것만)
                        foreach (byte id1 in new byte[] { 0x01, 0x02 })
                        {
                            for (int i = 0; i < Rooms.Length && !token.IsCancellationRequested; i++)
                            {
                                var room = Rooms[i];
                                if (!room.Enabled) continue;
                                if (id1 == 0x01 && !room.PollSA) continue;
                                if (id1 == 0x02 && (!room.PollRA || !room.RaActive)) continue;

                                byte[] tx = BuildMasterPacket(room, id1);
                                lock (_lock) { _serialPort?.Write(tx, 0, tx.Length); }
                                Log($"[TX-M] ID1=0x{id1:X2} ID2=0x{room.Id2:X2} {BitConverter.ToString(tx)}");
                                MasterPollSent?.Invoke(id1, room.Id2);

                                // Slave 응답 대기 (39 byte, 80ms timeout)
                                byte[]? resp = TryReceiveSlaveResponse(80);
                                if (resp != null)
                                {
                                    Log($"[RX-S] ID1=0x{resp[2]:X2} ID2=0x{resp[3]:X2} {BitConverter.ToString(resp)}");
                                    SlavePacketReceived?.Invoke(resp, resp[2], resp[3]);

                                    // 받은 SEN66 값을 room 에 갱신 (sensor 만)
                                    int ri = Id2ToIndex(resp[3]);
                                    if (ri >= 0)
                                    {
                                        var r = Rooms[ri];
                                        r.PM10 = (resp[12] << 8) | resp[13];
                                        r.PM4 = (resp[14] << 8) | resp[15];
                                        r.PM25 = (resp[16] << 8) | resp[17];
                                        r.PM1 = (resp[18] << 8) | resp[19];
                                        r.Humidity = (resp[20] << 8) | resp[21];
                                        r.Temperature = (resp[22] << 8) | resp[23];
                                        r.TVOC = (resp[24] << 8) | resp[25];
                                        r.NOx = (resp[26] << 8) | resp[27];
                                        r.CO2 = (resp[28] << 8) | resp[29];
                                    }
                                }

                                await Task.Delay(slotMs, token);
                            }
                        }
                    }
                }
                catch (OperationCanceledException) { }
                catch (Exception ex) { if (!token.IsCancellationRequested) Log($"[마스터 오류] {ex.Message}"); }
            }, token);

            Log("[통신 시작] Master Mode — Polling 송신");
        }

        public byte[] BuildMasterPacket(RoomSimData room, byte id1)
        {
            byte[] p = new byte[29];
            p[0] = 0xAA; p[1] = 0x10; p[2] = id1; p[3] = room.Id2; p[4] = 0x00;
            // 실제 protocol dump 분석 결과 0x80 Control bit 없음 — raw 값 그대로.
            p[5] = room.Power;
            p[6] = room.RunMode;
            p[7] = room.FanSpeed;
            // LED 디밍은 RA(0x02) 명령 패킷에만 전송 — SA(0x01)·힘펠은 0
            p[8] = (id1 == 0x02 && !room.Himpel) ? room.LedBrightness : (byte)0;
            p[9] = room.AirQualityStatus;
            p[10] = room.DamperAngleSA;
            p[11] = room.DamperAngleEA;
            // byte 12~26: 0 (RPM / Reset / 예약 등 미사용)
            ushort crc = CalcCRC16(p, 27);
            // lo-first (표준 리틀엔디안) — 와이어 [27]=하위, [28]=상위
            p[27] = (byte)(crc & 0xFF);
            p[28] = (byte)((crc >> 8) & 0xFF);
            return p;
        }

        private byte[]? TryReceiveSlaveResponse(int timeoutMs)
        {
            if (_serialPort?.IsOpen != true) return null;
            long deadline = Environment.TickCount64 + timeoutMs;
            // header 0xAA 대기
            while (Environment.TickCount64 < deadline)
            {
                if (_serialPort.BytesToRead < 1) { Thread.Sleep(2); continue; }
                byte b = (byte)_serialPort.ReadByte();
                if (b != 0xAA) continue;

                byte[] buf = new byte[39];
                buf[0] = 0xAA;
                int total = 1, remain = 38;
                while (remain > 0 && Environment.TickCount64 < deadline)
                {
                    if (_serialPort.BytesToRead > 0)
                    {
                        int r = _serialPort.Read(buf, total, remain);
                        total += r; remain -= r;
                    }
                    else Thread.Sleep(2);
                }
                if (total < 39) return null;
                if (buf[1] != 0x01) continue; // not slave

                ushort rxCrc = (ushort)(buf[37] | (buf[38] << 8));   // lo-first (표준 리틀엔디안)
                ushort calc = CalcCRC16(buf, 37);
                if (rxCrc != calc) { Log($"[CRC오류] 수신:0x{rxCrc:X4} 계산:0x{calc:X4}"); return null; }
                return buf;
            }
            return null;
        }

        public byte[] BuildResponse(RoomSimData room, byte id1, byte id2)
        {
            byte[] p = new byte[39];
            p[0] = 0xAA; p[1] = 0x01; p[2] = id1; p[3] = id2; p[4] = 0x00;
            p[5] = room.Power; p[6] = room.RunMode; p[7] = room.FanSpeed;
            p[8] = room.LedBrightness; p[9] = room.AirQualityStatus;
            p[10] = room.DamperAngleSA; p[11] = room.DamperAngleEA;
            void W16(int idx, int val) { p[idx] = (byte)((val >> 8) & 0xFF); p[idx + 1] = (byte)(val & 0xFF); }
            W16(12, room.PM10); W16(14, room.PM4); W16(16, room.PM25); W16(18, room.PM1);
            W16(20, room.Humidity); W16(22, room.Temperature);
            W16(24, room.Himpel ? room.TVOC : room.VOC);   /* byte 24,25 : DL=VOC / 힘펠=TVOC (Rev1.3) */
            W16(26, room.NOx); W16(28, room.CO2);
            p[30] = 0; p[31] = 0; p[32] = 0;
            p[33] = (byte)((room.ErrorCode >> 8) & 0xFF); p[34] = (byte)(room.ErrorCode & 0xFF);
            p[35] = room.VersionMajor; p[36] = room.VersionMinor;
            ushort crc = CalcCRC16(p, 37);
            // lo-first : 펌웨어 RX가 (Rx[37]<<8)|Rx[38] 로 읽고 CRC16()=스왑값과 비교 → lo-first로 보내야 일치
            p[37] = (byte)(crc & 0xFF); p[38] = (byte)((crc >> 8) & 0xFF);
            return p;
        }

        private void Log(string msg) => LogMessage?.Invoke($"[{DateTime.Now:HH:mm:ss.fff}] {msg}");

        public void Dispose()
        {
            if (_disposed) return;
            _disposed = true;
            Disconnect();
            GC.SuppressFinalize(this);
        }
    }
}


확인 완료. 디퓨저 시뮬레이터의 송신 프레임(`BuildResponse`, SlaveProtocol.cs:402)을 보면 디퓨저가 ERV로 보내는 건 **센서값(PM/VOC/CO2 등) + 공기질레벨 + 댐퍼/LED 상태**뿐이고, **프리셋(ECO/NORMAL/TURBO) 필드는 없습니다.**

## 결론: 펌웨어도 대시보드도 버그가 아닙니다 — 원래 연결되지 않는 값입니다

"프리셋(ECO/NORMAL/TURBO)"이라는 같은 이름이 **두 군데에 따로** 있습니다:

| | 디퓨저 시뮬레이터의 "자동변경 프리셋모드" | 대시보드의 프리셋 버튼 |
|---|---|---|
| 정체 | 시뮬레이터가 **센서값을 만들어낼 때 쓰는 밴드** (이 밴드로 PM/CO2/VOC 숫자를 생성) | ERV의 `Hyst_Preset` (공기질을 **판정**하는 임계 밴드) |
| 바꾸는 주체 | 시뮬레이터 로컬 (`_roomPreset`) | **대시보드만** (`CTRL_HYST_PRESET` → 펌웨어 `Hyst_Preset`) |
| 전송 여부 | ERV로 **안 보냄** (프로토콜에 프리셋 필드 없음) | STATUS byte6로 보고 |

즉 디퓨저 쪽 프리셋은 "어떤 센서 숫자를 만들지"를 정할 뿐이고, ERV/대시보드의 프리셋은 "그 숫자를 어떤 기준으로 판정할지"를 정합니다. **서로 독립**이라, 디퓨저 시뮬레이터에서 프리셋을 바꿔도 대시보드의 프리셋 버튼은 바뀌지 않는 게 정상입니다.

디퓨저 프리셋을 바꾸면 대시보드에서 **바뀌어야 하는 것**은 프리셋 버튼이 아니라 **각 방의 센서값(PM2.5/PM10/VOC/CO2)과 공기질 표시**입니다 (이건 응답 프레임에 실려 ERV→대시보드로 전달됨).

---

확인 부탁드립니다 — 어느 쪽을 기대하셨나요?

1. **대시보드의 프리셋 버튼(ECO/NORMAL/TURBO)이 따라 바뀌길** 기대 → 이건 설계상 안 됩니다. 정말 연동을 원하시면 디퓨저→ERV 프로토콜에 프리셋 필드를 추가하는 **신규 기능**이 필요합니다 (펌웨어+프로토콜+대시보드 모두 수정). 다만 프리셋은 보통 "사용자가 대시보드에서 정하는 판정기준"이라 디퓨저가 정할 값은 아닙니다.

2. **각 방 센서값/공기질이 안 바뀌는** 문제 → 이건 진짜 버그일 수 있습니다. 디퓨저 시뮬레이터가 Master 모드인지 Slave 모드인지, 그리고 대시보드 각 방 PM/CO2 수치가 그대로인지 알려주시면 그 경로(디퓨저→ERV→대시보드)를 추적하겠습니다.

어느 경우인가요?


아니다..생각해 보니 맞는 얘기야..그게 맞아..


네, 맞습니다. 디퓨저 쪽 프리셋(센서값 생성 밴드)과 대시보드 프리셋(ERV 판정 기준)은 별개라서 서로 안 따라가는 게 정상 동작입니다. 수정할 것 없습니다.

지금까지 오늘 작업한 변경분(펌웨어 + 대시보드)이 모두 동작 확인되고 빌드도 최신입니다:

- (6) 후드 OFF 시 쾌적조리 자동 해제 (펌웨어)
- (7) 쾌적조리 미선택+후드 ON 시 다른 시나리오 버튼 선택 가능
- (8) 시나리오 버튼 항상 선택 가능
- (9) 상호배타 로직 삭제 + 데모 루틴 전체 삭제
- (10) 안심회복 기본선택 → 버그 아님(콜드부트 후 정상)

다음으로 진행할까요?
- **커밋** — 오늘 변경분을 정리해서 커밋 (현재 `main` 브랜치)
- 아니면 다른 작업 이어서

어떻게 할까요?


대시보드에서 전원 on을 하면 erv에 연결된 룸컨이 간헐적으로 안켜져.
erv 펌웨어 좀 살펴봐


전원 ON 시 룸컨이 간헐적으로 안 켜지는 문제군요 — 타이밍/레이스 냄새가 납니다. 전원 명령 경로(`My_Homenet.c` CTRL_POWER)와 룸컨(`My_RJ2.c`)으로의 전원 전달을 살펴보겠습니다.

먼저 CTRL_POWER 핸들러를 봅니다.


248	                Power_On = pl[0] ? 1 : 0;
249	                for(i = 1; i < 7; i++) Diffuser_Power[i] = Power_On;
250	                /* 전원 OFF : OFF 표현은 Fan_Mode==0 && Run_Mode==MODE_VENTILATION(0).
251	                   Set_* 만 0 으로 두면 Run_Mode/Fan_Mode 가 이전값(유효모드/풍량)으로 남아
252	                   Fan_Speed_process 의 OFF 분기가 성립 안 돼 모터가 Power_On 을 다시 켬 → 직접 0 설정 */
253	                if(Power_On == 0){ Set_Run_Mode = Run_Mode = MODE_VENTILATION; Set_Fan_Mode = Fan_Mode = 0; }
254	                else
255	                {
256	                    /* 전원 ON : 환기 모드 + 풍량 1단 (ERVSimulator HomeNetProtocol 와 동일) */
257	                    Set_Run_Mode = Run_Mode = MODE_VENTILATION;
258	                    Set_Fan_Mode = Fan_Mode = 1;
259	                    /* [모드변경댐퍼테스트] 마스터 정렬 : 모드변경 시 명령경로 즉시 팬설정 제거.
260	                       팬이 0까지 감속해야 Fan_Speed_process 가 Damper_Mode 를 호출(내부댐퍼 이동).
261	                       팬 복원은 Fan_Speed_process wait==1 에서 수행. */
262	                    //Fan_Speed_Setting(Run_Mode, Fan_Mode);   /* 즉시 반영 (모드전환과 동일) */
263	                }
264	                /* 전원 토글 시 수동 댐퍼/LED 해제 → 자동 추종 복귀 */
265	                for(i = 1; i <= 4; i++) { Diffuser_Damper_Manual[i] = 0; Diffuser_Led_Manual[i] = 0; }
266	                /* 각실분배기 : 전원 비트(TYPE_POWER) + 모드/풍량. 룸컨(RJ2)도 Command_request_type 로 푸시 */
267	                Command_request_type |= (TYPE_POWER | TYPE_MODE | TYPE_FAN_SPEED);
268	            } else result = 1;
269	            break;
270	        case 0x02:  /* CTRL_RUNMODE [mode] 0off/1환기/2자동/3공청/4바이패스 */
271	            if(len >= 1)
272	            {
273	                if(pl[0] == 0) Power_On = 0;
274	                else
275	                {
276	                    Power_On = 1;
277	                    switch(pl[0])
278	                    {
279	                        case 1: Set_Run_Mode = MODE_VENTILATION; break;
280	                        case 2: Set_Run_Mode = MODE_AUTO;        break;
281	                        case 3: Set_Run_Mode = MODE_AIRCLEAN;    break;
282	                        case 4: Set_Run_Mode = MODE_BYPASS;      break;
283	                        default: result = 1; break;
284	                    }
285	                    if(result == 0)
286	                    {
287	                        Run_Mode = Set_Run_Mode;
288	                        Command_request_type |= TYPE_MODE;   /* 룸컨(RJ2)이 새 모드 푸시 */
289	                        /* 운전모드 전환 시 풍량 1단 (자동 제외 - 자동은 부하점수로 결정) */
290	                        if(Set_Run_Mode != MODE_AUTO)
291	                        {
292	                            Set_Fan_Mode = Fan_Mode = 1;
293	                            Command_request_type |= TYPE_FAN_SPEED;
294	                            /* [모드변경댐퍼테스트] 마스터 정렬 : 모드변경 시 명령경로 즉시 팬설정 제거.
295	                               팬이 0까지 감속해야 Fan_Speed_process 가 Damper_Mode 를 호출(내부댐퍼 이동).
296	                               새 모드 VSP 복원은 Fan_Speed_process wait==1 에서 수행. */
297	                            //Fan_Speed_Setting(Run_Mode, Fan_Mode);
298	                        }
299	                    }
300	                }
301	                /* 모드 전환 시 수동 댐퍼 해제 → 새 모드는 기본(전실 개방)에서 시작 */
302	                for(i = 1; i <= 4; i++) Diffuser_Damper_Manual[i] = 0;
303	            } else result = 1;
304	            break;
305	        case 0x03:  /* CTRL_FAN [speed] */
306	            if(len >= 1)
307	            {
308	                Set_Fan_Mode = Fan_Mode = pl[0];
309	                Command_request_type |= TYPE_FAN_SPEED;   /* 룸컨(RJ2)이 새 풍량 푸시 */

                Power_On = pl[0] ? 1 : 0;
                for(i = 1; i < 7; i++) Diffuser_Power[i] = Power_On;
                /* 전원 OFF : OFF 표현은 Fan_Mode==0 && Run_Mode==MODE_VENTILATION(0).
                   Set_* 만 0 으로 두면 Run_Mode/Fan_Mode 가 이전값(유효모드/풍량)으로 남아
                   Fan_Speed_process 의 OFF 분기가 성립 안 돼 모터가 Power_On 을 다시 켬 → 직접 0 설정 */
                if(Power_On == 0){ Set_Run_Mode = Run_Mode = MODE_VENTILATION; Set_Fan_Mode = Fan_Mode = 0; }
                else
                {
                    /* 전원 ON : 환기 모드 + 풍량 1단 (ERVSimulator HomeNetProtocol 와 동일) */
                    Set_Run_Mode = Run_Mode = MODE_VENTILATION;
                    Set_Fan_Mode = Fan_Mode = 1;
                    /* [모드변경댐퍼테스트] 마스터 정렬 : 모드변경 시 명령경로 즉시 팬설정 제거.
                       팬이 0까지 감속해야 Fan_Speed_process 가 Damper_Mode 를 호출(내부댐퍼 이동).
                       팬 복원은 Fan_Speed_process wait==1 에서 수행. */
                    //Fan_Speed_Setting(Run_Mode, Fan_Mode);   /* 즉시 반영 (모드전환과 동일) */
                }
                /* 전원 토글 시 수동 댐퍼/LED 해제 → 자동 추종 복귀 */
                for(i = 1; i <= 4; i++) { Diffuser_Damper_Manual[i] = 0; Diffuser_Led_Manual[i] = 0; }
                /* 각실분배기 : 전원 비트(TYPE_POWER) + 모드/풍량. 룸컨(RJ2)도 Command_request_type 로 푸시 */
                Command_request_type |= (TYPE_POWER | TYPE_MODE | TYPE_FAN_SPEED);
            } else result = 1;
            break;
        case 0x02:  /* CTRL_RUNMODE [mode] 0off/1환기/2자동/3공청/4바이패스 */
            if(len >= 1)
            {
                if(pl[0] == 0) Power_On = 0;
                else
                {
                    Power_On = 1;
                    switch(pl[0])
                    {
                        case 1: Set_Run_Mode = MODE_VENTILATION; break;
                        case 2: Set_Run_Mode = MODE_AUTO;        break;
                        case 3: Set_Run_Mode = MODE_AIRCLEAN;    break;
                        case 4: Set_Run_Mode = MODE_BYPASS;      break;
                        default: result = 1; break;
                    }
                    if(result == 0)
                    {
                        Run_Mode = Set_Run_Mode;
                        Command_request_type |= TYPE_MODE;   /* 룸컨(RJ2)이 새 모드 푸시 */
                        /* 운전모드 전환 시 풍량 1단 (자동 제외 - 자동은 부하점수로 결정) */
                        if(Set_Run_Mode != MODE_AUTO)
                        {
                            Set_Fan_Mode = Fan_Mode = 1;
                            Command_request_type |= TYPE_FAN_SPEED;
                            /* [모드변경댐퍼테스트] 마스터 정렬 : 모드변경 시 명령경로 즉시 팬설정 제거.
                               팬이 0까지 감속해야 Fan_Speed_process 가 Damper_Mode 를 호출(내부댐퍼 이동).
                               새 모드 VSP 복원은 Fan_Speed_process wait==1 에서 수행. */
                            //Fan_Speed_Setting(Run_Mode, Fan_Mode);
                        }
                    }
                }
                /* 모드 전환 시 수동 댐퍼 해제 → 새 모드는 기본(전실 개방)에서 시작 */
                for(i = 1; i <= 4; i++) Diffuser_Damper_Manual[i] = 0;
            } else result = 1;
            break;
        case 0x03:  /* CTRL_FAN [speed] */
            if(len >= 1)
            {
                Set_Fan_Mode = Fan_Mode = pl[0];
                Command_request_type |= TYPE_FAN_SPEED;   /* 룸컨(RJ2)이 새 풍량 푸시 */


전원 ON 시 핸들러는 `Command_request_type |= (TYPE_POWER|TYPE_MODE|TYPE_FAN_SPEED)`로 룸컨(RJ2)에 푸시를 요청합니다. 룸컨이 이 플래그를 어떻게 소비해 전원을 켜는지, 그리고 간헐 실패 지점을 보겠습니다.


142:uint8_t Command_request_type = 0;
332:			if(Command_request_type & (TYPE_MODE|TYPE_FAN_SPEED|TYPE_RESERVATION) )
338:					Command_request_type |= (TYPE_HOOD_STATE);
353:				if(Command_request_type & TYPE_RESERVATION)
371:                Command_request_type |= TYPE_SEND_FLAG;
374:            else if(Command_request_type & (TYPE_HOOD_STATE))// add 2022.1.25
397:			if(Command_request_type & TYPE_SEND_FLAG)
399:                    Command_request_type = 0;
400:                    Command_request_type &= ~TYPE_SEND_FLAG;
421:                                Hood_Power_On = 0;
427:                                Hood_Power_On = 1;
437:                        Command_request_type |= (TYPE_MODE|TYPE_FAN_SPEED);
451:                        Command_request_type |= (TYPE_HOOD_STATE);
677:			Command_request_type &= ~(TYPE_SENSOR_INFO);
683:			Command_request_type &= ~(TYPE_FILTER_INFO);
689:			if(Command_request_type & TYPE_SEND_FLAG)
691:                    Command_request_type &= ~TYPE_SEND_FLAG;
715:			if(Set_Run_Mode == Run_Mode)Command_request_type &= ~TYPE_MODE;
716:			if(Set_Fan_Mode == Fan_Mode)Command_request_type &= ~TYPE_FAN_SPEED;
718:			if(Reserve_timer_sec == 0){if(Set_Reserve_timer_sec == 0)Command_request_type &= ~TYPE_RESERVATION;}
723:					if(((Reserve_timer_sec-1)/3600 + 1) == ((Set_Reserve_timer_sec-1)/3600 + 1))Command_request_type &= ~TYPE_RESERVATION;
758:			Command_request_type &= ~(TYPE_HOOD_STATE);


대시보드에서 전원 on을 하면 erv에 연결된 룸컨이 간헐적으로 안켜져. erv 펌웨어 좀 살펴봐


룸컨 송신 로직을 봐야 합니다. line 332에서 `TYPE_MODE|TYPE_FAN_SPEED|TYPE_RESERVATION` 조건에 **TYPE_POWER가 빠져 있는** 점이 눈에 띕니다. 전체 흐름을 읽겠습니다. (My_RJ2.c는 수정금지지만 원인 파악용)