Files
firmware-flasher/FirmwareFlasher/MainWindow.xaml.cs
insulee 196b00b91e refactor: 프로젝트명 DB720Flasher → FirmwareFlasher
폴더·csproj·어셈블리명·RootNamespace·코드 namespace·x:Class·app.manifest 전부 변경.
빌드 결과가 네이티브로 FirmwareFlasher.exe 로 나온다(dist 수동 리네임 불필요).
README 빌드/구조 경로도 갱신. 모델명 "DB720"은 그대로 유지.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:37:48 +09:00

477 lines
19 KiB
C#

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 FirmwareFlasher;
public partial class MainWindow : Window
{
string _baseDir = "";
string _esptoolPath = "esptool";
string? _fwFolder;
List<FirmwareItem> _apps = new();
List<FlashProfile> _builtins = new();
List<FlashProfile> _models = new();
FlashProfile? _profile;
List<ComPortInfo> _ports = new();
CancellationTokenSource? _cts;
bool _busy, _ready, _refreshing;
readonly DispatcherTimer _debounce;
readonly DispatcherTimer _watch;
string[] _portSnapshot = Array.Empty<string>();
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<string>(); }
}
// ===== 펌웨어 폴더 + 모델 =====
void LoadFirmware(string folder)
{
_fwFolder = folder;
txtFwFolder.Text = folder;
_apps = FirmwareCatalog.LoadApps(folder);
cboApps.ItemsSource = _apps;
cboApps.SelectedItem = _apps.FirstOrDefault();
var models = new List<FlashProfile>(_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<ComPortInfo> 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<ComPortInfo>)?.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();
}
/// <summary>선택 포트가 유령이면(열기 실패) 살아있는 JTAG/권장 포트로 자동 전환해 돌려준다.</summary>
async Task<ComPortInfo?> 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);
}