using System.IO; using System.Windows; using System.Windows.Controls; using System.Windows.Interop; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Threading; using Microsoft.Win32; namespace DB720Flasher; public partial class MainWindow : Window { string _baseDir = ""; string _esptoolPath = "esptool"; string? _fwFolder; List _apps = new(); List _builtins = new(); List _models = new(); FlashProfile? _profile; List _ports = new(); CancellationTokenSource? _cts; bool _busy, _ready, _refreshing; readonly DispatcherTimer _debounce; readonly DispatcherTimer _watch; string[] _portSnapshot = Array.Empty(); string? _shotPath; public MainWindow() { InitializeComponent(); _debounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(450) }; _debounce.Tick += async (_, __) => { _debounce.Stop(); if (!_busy) await RefreshPortsAsync(); }; _watch = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; _watch.Tick += OnWatchTick; var args = Environment.GetCommandLineArgs(); int si = Array.IndexOf(args, "--shot"); if (si >= 0 && si + 1 < args.Length) _shotPath = args[si + 1]; Loaded += OnLoaded; } async void OnLoaded(object? sender, RoutedEventArgs e) { _baseDir = AppContext.BaseDirectory; _esptoolPath = EsptoolRunner.Locate(_baseDir, null); _builtins = ProfileStore.BuiltIns(); cboModel.SelectionChanged += (_, __) => OnModelChanged(); cboApps.SelectionChanged += (_, __) => UpdateFlashEnabled(); cboPorts.SelectionChanged += (_, __) => { UpdatePortHint(); UpdateConsoleWarn(); UpdateFlashEnabled(); }; var fwFolder = FirmwareCatalog.AutoLocate(_baseDir) ?? FirmwareCatalog.AutoLocate(Directory.GetCurrentDirectory()); if (fwFolder is not null) LoadFirmware(fwFolder); else { txtFwFolder.Text = "(firmware 폴더를 찾지 못함 — '폴더…' 로 지정)"; cboModel.ItemsSource = _builtins; cboModel.SelectedItem = _builtins.FirstOrDefault(); } _ready = true; OnModeChanged(this, new RoutedEventArgs()); await RefreshPortsAsync(); _ = ProbeEsptoolAsync(); _portSnapshot = CurrentPortNames(); _watch.Start(); if (_shotPath is not null) await CaptureAndExitAsync(_shotPath); } protected override void OnSourceInitialized(EventArgs e) { base.OnSourceInitialized(e); (PresentationSource.FromVisual(this) as HwndSource)?.AddHook(WndProc); } const int WM_DEVICECHANGE = 0x0219; IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { if (msg == WM_DEVICECHANGE) { _debounce.Stop(); _debounce.Start(); } return IntPtr.Zero; } async void OnWatchTick(object? sender, EventArgs e) { var now = CurrentPortNames(); if (now.SequenceEqual(_portSnapshot)) return; _portSnapshot = now; if (!_busy) await RefreshPortsAsync(); } static string[] CurrentPortNames() { try { var n = System.IO.Ports.SerialPort.GetPortNames(); Array.Sort(n); return n; } catch { return Array.Empty(); } } // ===== 펌웨어 폴더 + 모델 ===== void LoadFirmware(string folder) { _fwFolder = folder; txtFwFolder.Text = folder; _apps = FirmwareCatalog.LoadApps(folder); cboApps.ItemsSource = _apps; cboApps.SelectedItem = _apps.FirstOrDefault(); var models = new List(_builtins); FlashProfile? pick; var fileProf = ProfileStore.LoadFromFolder(folder); if (fileProf is not null) { fileProf.Name = $"{fileProf.Name} (폴더 flash.json)"; models.Insert(0, fileProf); pick = fileProf; } else { pick = ProfileStore.MatchByApp(_apps.Select(a => a.FileName), _builtins); } _models = models; cboModel.ItemsSource = _models; cboModel.SelectedItem = pick ?? _models.FirstOrDefault(); } void OnModelChanged() { _profile = cboModel.SelectedItem as FlashProfile ?? _builtins.FirstOrDefault(); if (_profile is null) return; txtProfile.Text = "설정: " + _profile.Summary + (_profile.FromFile ? " · 폴더 flash.json" : " · 빌트인"); txtDlMode.Text = _profile.DownloadModeHint ?? "(안내 없음)"; SelectPreferredPort((cboPorts.SelectedItem as ComPortInfo)?.PortName); UpdatePortHint(); UpdateConsoleWarn(); UpdateFlashEnabled(); } void OnBrowseClick(object sender, RoutedEventArgs e) { var dlg = new OpenFolderDialog { Title = "펌웨어 폴더 선택" }; if (_fwFolder is not null && Directory.Exists(_fwFolder)) dlg.InitialDirectory = _fwFolder; if (dlg.ShowDialog(this) == true) LoadFirmware(dlg.FolderName); } // ===== 포트 ===== async Task RefreshPortsAsync() { if (_refreshing) return; _refreshing = true; try { string? prev = (cboPorts.SelectedItem as ComPortInfo)?.PortName; List ports; try { ports = await Task.Run(PortScanner.Scan); } catch { ports = new(); } _ports = ports; cboPorts.ItemsSource = ports; // 전부 표시(제외 없음) SelectPreferredPort(prev); UpdatePortHint(); UpdateConsoleWarn(); UpdateFlashEnabled(); } finally { _refreshing = false; } } void SelectPreferredPort(string? prev) { var list = (cboPorts.ItemsSource as IEnumerable)?.ToList() ?? new(); // JTAG/권장 포트 중 가장 높은 번호(재열거된 최신)를 우선 선택 — 유령(옛 번호) 회피 var pref = list .Where(p => p.IsJtag || (_profile is not null && p.Matches(_profile.FlashPortHint))) .OrderByDescending(p => p.Number) .FirstOrDefault(); cboPorts.SelectedItem = pref ?? list.FirstOrDefault(p => p.PortName == prev) ?? list.FirstOrDefault(); } /// 선택 포트가 유령이면(열기 실패) 살아있는 JTAG/권장 포트로 자동 전환해 돌려준다. async Task ResolveLivePortAsync(ComPortInfo sel) { if (await Task.Run(() => PortScanner.CanOpen(sel.PortName))) return sel; AppendLog($"[포트] {sel.PortName} 를 열 수 없음 — 보드 리셋으로 번호가 바뀐 듯. 살아있는 포트 탐색…"); var ports = await Task.Run(PortScanner.Scan); foreach (var p in ports .Where(p => p.IsJtag || (_profile is not null && p.Matches(_profile.FlashPortHint))) .OrderByDescending(p => p.Number)) { if (string.Equals(p.PortName, sel.PortName, StringComparison.OrdinalIgnoreCase)) continue; if (await Task.Run(() => PortScanner.CanOpen(p.PortName))) { AppendLog($"[포트] → {p.PortName} 로 자동 전환"); _ports = ports; cboPorts.ItemsSource = ports; cboPorts.SelectedItem = ports.FirstOrDefault(x => x.PortName == p.PortName); return cboPorts.SelectedItem as ComPortInfo; } } return null; } void UpdatePortHint() { var sel = cboPorts.SelectedItem as ComPortInfo; var jtags = _ports.Where(p => p.IsJtag).ToList(); string multi = jtags.Count > 1 ? $" · JTAG {jtags.Count}개(리셋으로 번호 바뀜) — 안 되면 자동으로 다른 번호 시도" : ""; if (sel is null) { txtPortStatus.Foreground = GetBrush("Danger"); txtPortStatus.Text = "포트 없음 — 케이블을 연결하세요 (자동 감지 대기 중)"; } else if (sel.IsJtag) { txtPortStatus.Foreground = GetBrush("Success"); txtPortStatus.Text = $"✓ {sel.PortName} 선택됨 — USB-JTAG (303A:1001) 자동 감지{multi}"; } else if (_profile is not null && sel.Matches(_profile.FlashPortHint)) { txtPortStatus.Foreground = GetBrush("Success"); txtPortStatus.Text = $"✓ {sel.PortName} 선택됨 — {sel.Kind} (모델 권장 포트){multi}"; } else { var liveJtag = jtags.OrderByDescending(p => p.Number).FirstOrDefault(); txtPortStatus.Foreground = GetBrush("Warn"); txtPortStatus.Text = $"{sel.PortName} 선택됨 — {sel.Kind}. 모델에 맞는 포트인지 확인하세요" + (liveJtag is not null ? $" (JTAG {liveJtag.PortName}도 있음)" : ""); } } void UpdateConsoleWarn() { var sel = cboPorts.SelectedItem as ComPortInfo; // FlashPortHint 가 있는(=JTAG로 굽는) 모델에서만, 콘솔 포트를 고르면 경고. 막지는 않음. if (_profile?.FlashPortHint is not null && _profile.ConsolePortHint is not null && sel is not null && sel.Matches(_profile.ConsolePortHint)) { txtConsoleWarn.Visibility = Visibility.Visible; txtConsoleWarn.Text = $"※ {sel.PortName}({sel.Kind})는 이 모델에선 디버그 콘솔일 수 있습니다. 플래시는 보통 JTAG로 합니다 — 의도한 거면 그대로 진행하세요."; } else txtConsoleWarn.Visibility = Visibility.Collapsed; } async void OnRefreshClick(object sender, RoutedEventArgs e) => await RefreshPortsAsync(); // ===== 모드 ===== FlashMode CurrentMode() => rbFull.IsChecked == true ? FlashMode.Full : rbStandard.IsChecked == true ? FlashMode.Standard : FlashMode.AppOnly; void OnModeChanged(object sender, RoutedEventArgs e) { if (!_ready) return; pnlEfuse.Visibility = CurrentMode() == FlashMode.Full ? Visibility.Visible : Visibility.Collapsed; UpdateFlashEnabled(); } void UpdateFlashEnabled() { if (!_ready) return; bool ok = !_busy && cboPorts.SelectedItem is ComPortInfo && cboApps.SelectedItem is FirmwareItem && _profile is not null && _fwFolder is not null && FirmwareCatalog.MissingFor(_fwFolder, _profile, CurrentMode()) is null; if (CurrentMode() == FlashMode.Full && chkEfuseAck.IsChecked != true) ok = false; btnFlash.IsEnabled = ok; } // ===== 실행 ===== async void OnFlashClick(object sender, RoutedEventArgs e) { if (_profile is null || _fwFolder is null) return; if (cboPorts.SelectedItem is not ComPortInfo port) { Info("포트를 선택하세요."); return; } if (cboApps.SelectedItem is not FirmwareItem app) { Info("올릴 앱을 선택하세요."); return; } var mode = CurrentMode(); if (FirmwareCatalog.MissingFor(_fwFolder, _profile, mode) is string miss) { Info($"이 모드에 필요한 파일이 없습니다: {miss}"); return; } // 로그/진행 초기화 후 '살아있는' 포트 확인 — 유령 COM(번호 바뀜) 자동 회피 HideResult(); txtLog.Clear(); barProgress.Value = 0; txtPercent.Text = "0%"; txtStep.Text = ""; var live = await ResolveLivePortAsync(port); if (live is null) { ShowResultBox(false, "포트를 열 수 없습니다. 보드 리셋으로 COM 번호가 바뀌었거나(유령 포트) 다른 프로그램이 포트를 잡고 있을 수 있습니다 — USB 케이블을 다시 꽂고 '↻ 새로고침' 후 다시 시도하세요."); return; } port = live; string modeName = mode switch { FlashMode.AppOnly => "앱만", FlashMode.Standard => "표준", _ => "전체" }; string msg = $"모델: {_profile.Name}\n포트: {port.PortName} ({port.Kind})\n앱: {app.FileName}\n모드: {modeName} 플래시{(chkForce.IsChecked == true ? " (--force)" : "")}"; if (mode == FlashMode.Full) msg += "\n\n★ 전체 플래시는 부트로더를 다시 씁니다. eFuse 미굽힘 보드는 첫 부팅에 영구로 구워집니다(비가역)."; if (MessageBox.Show(this, msg + "\n\n진행할까요?", "플래시 확인", MessageBoxButton.YesNo, mode == FlashMode.Full ? MessageBoxImage.Warning : MessageBoxImage.Question) != MessageBoxResult.Yes) return; var req = new FlashRequest { Port = port.PortName, Mode = mode, AppPath = app.FullPath, FirmwareFolder = _fwFolder, Profile = _profile, Force = chkForce.IsChecked == true, }; SetBusy(true); _cts = new CancellationTokenSource(); var runner = MakeRunner(); try { var res = await runner.RunAsync(req, _cts.Token); ShowResult(res); } catch (OperationCanceledException) { AppendLog("\n■ 중지됨"); ShowResultBox(false, "중지됨 — 플래시가 중간에 멈췄습니다. 보드 상태가 불완전할 수 있으니 다시 하세요."); } catch (Exception ex) { AppendLog("\n✗ 오류: " + ex.Message); ShowResultBox(false, "오류: " + ex.Message); } finally { _cts?.Dispose(); _cts = null; SetBusy(false); } } async void OnTestClick(object sender, RoutedEventArgs e) { if (_profile is null) { Info("모델을 선택하세요."); return; } if (cboPorts.SelectedItem is not ComPortInfo port) { Info("점검할 포트를 선택하세요."); return; } HideResult(); txtLog.Clear(); var live = await ResolveLivePortAsync(port); if (live is null) { ShowResultBox(false, "포트를 열 수 없습니다 — USB 케이블을 다시 꽂고 '↻ 새로고침' 후 다시 시도하세요."); return; } port = live; SetBusy(true); txtStep.Text = "연결 점검"; _cts = new CancellationTokenSource(); var runner = MakeRunner(); try { bool ok = await runner.TestConnectionAsync(_profile, port.PortName, _cts.Token); ShowResultBox(ok, ok ? $"✓ {port.PortName} 연결 OK — 플래시 준비됨." : $"✗ {port.PortName} 연결 실패 — 케이블/포트·다운로드 모드 진입을 확인하세요. 다른 프로그램이 포트를 잡고 있을 수 있습니다."); } catch (OperationCanceledException) { AppendLog("\n■ 중지됨"); } catch (Exception ex) { ShowResultBox(false, "오류: " + ex.Message); } finally { _cts?.Dispose(); _cts = null; SetBusy(false); } } void OnStopClick(object sender, RoutedEventArgs e) { AppendLog("\n■ 중지 요청…"); _cts?.Cancel(); } EsptoolRunner MakeRunner() { var runner = new EsptoolRunner(_esptoolPath); runner.Log += s => Dispatcher.BeginInvoke(() => AppendLog(s)); runner.Progress += (p, t) => Dispatcher.BeginInvoke(() => { barProgress.Value = p; txtPercent.Text = $"{p * 100:0}%"; txtStep.Text = t; }); return runner; } // ===== 상태 표시 ===== void SetBusy(bool busy) { _busy = busy; foreach (Control c in new Control[] { cboModel, cboApps, cboPorts, btnRefresh, btnTest, btnBrowse, rbAppOnly, rbStandard, rbFull, chkForce, chkEfuseAck }) c.IsEnabled = !busy; btnStop.IsEnabled = busy; if (busy) btnFlash.IsEnabled = false; else UpdateFlashEnabled(); } void ShowResult(FlashResult res) { if (res.Success) { string tip = _profile is { NoStub: true } ? "\n• USB-JTAG 케이블을 뽑고 전원을 재인가하면 확실히 정상 부팅합니다." : ""; ShowResultBox(true, "✓ 플래시 완료. 보드 전원을 재인가(재부팅)하세요." + tip); } else if (res.BootloaderRejected) { ShowResultBox(false, $"부트로더 쓰기를 거부했습니다 (rc={res.ExitCode}).\n" + "이미 secure boot가 구워진 보드면 '정상'입니다 — 모드를 '표준'이나 '앱만'으로 바꿔 다시 하세요(부트로더 건너뜀). 꼭 부트로더를 써야 하면 '--force'를 켜세요."); } else { ShowResultBox(false, $"단계 실패: {res.FailedStep} (rc={res.ExitCode}).\n" + "로그를 확인하세요. 포트·케이블, 다운로드 모드 진입, 모델 선택을 점검하고 재시도하세요."); } } void ShowResultBox(bool ok, string text) { pnlResult.Visibility = Visibility.Visible; pnlResult.BorderBrush = GetBrush(ok ? "Success" : "Danger"); pnlResult.Background = GetBrush(ok ? "SuccessDim" : "DangerDim"); txtResult.Foreground = GetBrush("Text"); txtResult.Text = text; } void HideResult() => pnlResult.Visibility = Visibility.Collapsed; void AppendLog(string s) { txtLog.AppendText(s + "\n"); txtLog.ScrollToEnd(); } async Task ProbeEsptoolAsync() { var (rc, text) = await EsptoolRunner.Capture(_esptoolPath, "version"); string tag = _esptoolPath.Contains(@"\tools\esptool\", StringComparison.OrdinalIgnoreCase) ? "동봉" : (Path.IsPathRooted(_esptoolPath) ? "설치본" : "PATH"); if (rc == 0) { var ver = text.Split('\n')[0].Trim(); txtEsptool.Text = $"{ver} · {tag} · {_esptoolPath}"; } else { txtEsptool.Foreground = GetBrush("Warn"); txtEsptool.Text = $"⚠ esptool 실행 불가 ({_esptoolPath}) — tools\\esptool\\ 에 동봉하거나 설치가 필요합니다."; } } // ===== 디버그용 화면 캡처(--shot 경로) ===== async Task CaptureAndExitAsync(string path) { await Task.Delay(1100); try { UpdateLayout(); int w = Math.Max(1, (int)Math.Ceiling(ActualWidth)); int h = Math.Max(1, (int)Math.Ceiling(ActualHeight)); var rtb = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32); rtb.Render(this); var enc = new PngBitmapEncoder(); enc.Frames.Add(BitmapFrame.Create(rtb)); using var fs = File.Create(path); enc.Save(fs); } catch { } Application.Current.Shutdown(); } void Info(string m) => MessageBox.Show(this, m, "확인", MessageBoxButton.OK, MessageBoxImage.Information); Brush GetBrush(string key) => (Brush)FindResource(key); }