using System.Diagnostics; using System.Globalization; using System.IO; using System.Text; using System.Text.RegularExpressions; namespace FirmwareFlasher; public sealed class FlashStep { public required string Title { get; init; } public required List Args { get; init; } // 서브커맨드 + 인자 (전역옵션·--after 제외) public long Bytes { get; init; } public bool TouchesBootloader { get; init; } } public sealed class FlashRequest { public required string Port { get; init; } public required FlashMode Mode { get; init; } public required string AppPath { get; init; } public required string FirmwareFolder { get; init; } public required FlashProfile Profile { get; init; } public bool Force { get; init; } } public sealed class FlashResult { public bool Success { get; init; } public string? FailedStep { get; init; } public int ExitCode { get; init; } public bool BootloaderRejected { get; init; } } /// /// esptool v5.x CLI(하이픈 문법)로 플래시한다. 칩·baud·스텁·offset·쓰기방식은 프로파일이 결정. /// DB720: no-stub + 영역별 개별 호출 + 마지막 hard-reset(SDM). 클래식: 스텁 + 한 번에. /// public sealed class EsptoolRunner { readonly string _esptool; public string EsptoolPath => _esptool; public event Action? Log; public event Action? Progress; public EsptoolRunner(string esptoolPath) => _esptool = esptoolPath; public static string Locate(string baseDir, string? configured) { if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured)) return configured!; var bundled = Path.Combine(baseDir, "tools", "esptool", "esptool.exe"); if (File.Exists(bundled)) return bundled; return "esptool"; } static List Common(FlashProfile p, string port, string after) { var c = new List { "--chip", p.Chip, "-p", port, "-b", p.Baud.ToString(CultureInfo.InvariantCulture), "--before", p.Before, "--after", after, "--connect-attempts", p.ConnectAttempts.ToString(CultureInfo.InvariantCulture), }; if (p.NoStub) c.Add("--no-stub"); return c; } static List WriteFlashArgs(FlashProfile p, IEnumerable<(string off, string file)> pairs, bool force) { var a = new List { "write-flash" }; if (p.FlashMode is not null) { a.Add("-fm"); a.Add(p.FlashMode); } if (p.FlashSize is not null) { a.Add("-fs"); a.Add(p.FlashSize); } if (p.FlashFreq is not null) { a.Add("-ff"); a.Add(p.FlashFreq); } if (force) a.Add("--force"); foreach (var (off, file) in pairs) { a.Add(off); a.Add(file); } return a; } static long ParseOff(string s) => s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) && long.TryParse(s.AsSpan(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var v) ? v : (long.TryParse(s, out var d) ? d : long.MaxValue); public static List BuildSteps(FlashRequest r) { var p = r.Profile; bool includeBoot = r.Mode == FlashMode.Full; bool supportToo = r.Mode != FlashMode.AppOnly; var items = new List<(string off, string file)>(); if (supportToo) foreach (var rg in p.SupportRegions) { if (rg.Kind == "bootloader" && !includeBoot) continue; var path = Path.Combine(r.FirmwareFolder, rg.File!); if (File.Exists(path)) items.Add((rg.Offset, path)); } items.Add((p.AppOffset, r.AppPath)); items = items.OrderBy(x => ParseOff(x.off)).ToList(); var bootOff = p.Bootloader?.Offset; var steps = new List(); if (p.PerRegion) { foreach (var (off, file) in items) steps.Add(new FlashStep { Title = $"{Path.GetFileName(file)} @ {off}", Args = WriteFlashArgs(p, new[] { (off, file) }, r.Force), Bytes = SafeLen(file), TouchesBootloader = bootOff is not null && ParseOff(off) == ParseOff(bootOff), }); } else { steps.Add(new FlashStep { Title = $"전체 {items.Count}개 영역 한 번에 기록", Args = WriteFlashArgs(p, items, r.Force), Bytes = items.Sum(x => SafeLen(x.file)), TouchesBootloader = includeBoot && p.HasBootloader, }); } if (r.Mode == FlashMode.AppOnly && p.AppOnlyErasesOtadata) { var erase = new List { "erase-region", p.OtadataOffset, p.OtadataSize }; if (r.Force) erase.Add("--force"); steps.Add(new FlashStep { Title = $"otadata erase @ {p.OtadataOffset}", Args = erase, Bytes = 0 }); } return steps; } public async Task RunAsync(FlashRequest r, CancellationToken ct) { var p = r.Profile; var steps = BuildSteps(r); long total = Math.Max(1, steps.Sum(s => s.Bytes)); long done = 0; Log?.Invoke($"esptool: {_esptool}"); Log?.Invoke($"모델 {p.Name} · 포트 {r.Port} · 모드 {r.Mode}{(r.Force ? " · --force" : "")} · 단계 {steps.Count}개\n"); for (int i = 0; i < steps.Count; i++) { ct.ThrowIfCancellationRequested(); var step = steps[i]; bool last = i == steps.Count - 1; var args = Common(p, r.Port, last ? p.After : "no-reset"); args.AddRange(step.Args); Log?.Invoke($"[{i + 1}/{steps.Count}] {step.Title}"); Progress?.Invoke((double)done / total, step.Title); long stepBase = done; int rc = await RunOnce(args, pct => { double cur = (stepBase + step.Bytes * pct) / total; Progress?.Invoke(Math.Clamp(cur, 0, 1), step.Title); }, ct); if (rc != 0) return new FlashResult { Success = false, FailedStep = step.Title, ExitCode = rc, BootloaderRejected = step.TouchesBootloader }; done += step.Bytes; Progress?.Invoke((double)done / total, step.Title); } Progress?.Invoke(1.0, "완료"); return new FlashResult { Success = true }; } /// 플래시 전 안전 점검 — chip-id로 포트/케이블 확인. public async Task TestConnectionAsync(FlashProfile p, string port, CancellationToken ct) { var args = new List { "--chip", p.Chip, "-p", port, "-b", p.Baud.ToString(CultureInfo.InvariantCulture), "--before", p.Before, "--after", "hard-reset", "--connect-attempts", "8", }; if (p.NoStub) args.Add("--no-stub"); args.Add("chip-id"); Log?.Invoke($"[점검] {port} ({p.Chip}) 연결 확인…"); int rc = await RunOnce(args, _ => { }, ct); Log?.Invoke(rc == 0 ? "[점검] ✓ 연결 OK" : $"[점검] ✗ 연결 실패 (rc={rc})"); return rc == 0; } static readonly Regex AnsiRe = new(@"\x1B\[[0-9;?]*[ -/]*[@-~]", RegexOptions.Compiled); static readonly Regex PctRe = new(@"(\d{1,3})\s*%", RegexOptions.Compiled); async Task RunOnce(List args, Action onPct, CancellationToken ct) { var psi = new ProcessStartInfo { FileName = _esptool, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8, }; foreach (var a in args) psi.ArgumentList.Add(a); psi.Environment["PYTHONIOENCODING"] = "utf-8"; psi.Environment["PYTHONUNBUFFERED"] = "1"; using var proc = new Process { StartInfo = psi, EnableRaisingEvents = true }; void Handle(string? raw) { if (raw is null) return; var line = AnsiRe.Replace(raw, "").TrimEnd(); if (line.Length == 0) return; Log?.Invoke(line); var m = PctRe.Match(line); if (m.Success && int.TryParse(m.Groups[1].Value, out var pc)) onPct(Math.Clamp(pc / 100.0, 0, 1)); } proc.OutputDataReceived += (_, e) => Handle(e.Data); proc.ErrorDataReceived += (_, e) => Handle(e.Data); try { proc.Start(); } catch (Exception ex) { Log?.Invoke($"✗ esptool 실행 실패: {ex.Message}"); return -999; } proc.BeginOutputReadLine(); proc.BeginErrorReadLine(); using (ct.Register(() => { try { if (!proc.HasExited) proc.Kill(true); } catch { } })) await proc.WaitForExitAsync(ct); return proc.ExitCode; } static long SafeLen(string p) { try { return new FileInfo(p).Length; } catch { return 0; } } /// 한 번 실행하고 출력을 통째로 받는다(버전 확인용). public static async Task<(int rc, string text)> Capture(string exe, params string[] args) { try { var psi = new ProcessStartInfo { FileName = exe, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8, }; foreach (var a in args) psi.ArgumentList.Add(a); using var p = Process.Start(psi)!; string o = await p.StandardOutput.ReadToEndAsync(); string err = await p.StandardError.ReadToEndAsync(); await p.WaitForExitAsync(); return (p.ExitCode, AnsiRe.Replace((o + err).Trim(), "")); } catch (Exception ex) { return (-999, ex.Message); } } }