Initial commit: 펌웨어 플래시 도구 (WPF) + 기존 PowerShell 키트
- DB720Flasher: WPF .NET 9 GUI 플래셔 - 멀티모델 프로파일(flash.json + 빌트인 DB720/DB402S/DB400S/DB400Q/DB300eM) - 포트 자동감지(JTAG 우선, 유령 포트 자동 회피), esptool v5 동봉 - flash_app.ps1 / flash_full.ps1 / list_ports.ps1: 기존 PowerShell 키트 - profiles/: 모델별 flash.json 템플릿 - firmware/flash.json: DB720 모델 설정 (서명 실펌웨어 .bin 은 repo 제외) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
261
DB720Flasher/Services/EsptoolRunner.cs
Normal file
261
DB720Flasher/Services/EsptoolRunner.cs
Normal file
@@ -0,0 +1,261 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DB720Flasher;
|
||||
|
||||
public sealed class FlashStep
|
||||
{
|
||||
public required string Title { get; init; }
|
||||
public required List<string> 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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// esptool v5.x CLI(하이픈 문법)로 플래시한다. 칩·baud·스텁·offset·쓰기방식은 프로파일이 결정.
|
||||
/// DB720: no-stub + 영역별 개별 호출 + 마지막 hard-reset(SDM). 클래식: 스텁 + 한 번에.
|
||||
/// </summary>
|
||||
public sealed class EsptoolRunner
|
||||
{
|
||||
readonly string _esptool;
|
||||
public string EsptoolPath => _esptool;
|
||||
|
||||
public event Action<string>? Log;
|
||||
public event Action<double, string>? 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<string> Common(FlashProfile p, string port, string after)
|
||||
{
|
||||
var c = new List<string>
|
||||
{
|
||||
"--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<string> WriteFlashArgs(FlashProfile p, IEnumerable<(string off, string file)> pairs, bool force)
|
||||
{
|
||||
var a = new List<string> { "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<FlashStep> 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<FlashStep>();
|
||||
|
||||
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<string> { "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<FlashResult> 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 };
|
||||
}
|
||||
|
||||
/// <summary>플래시 전 안전 점검 — chip-id로 포트/케이블 확인.</summary>
|
||||
public async Task<bool> TestConnectionAsync(FlashProfile p, string port, CancellationToken ct)
|
||||
{
|
||||
var args = new List<string>
|
||||
{
|
||||
"--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<int> RunOnce(List<string> args, Action<double> 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; } }
|
||||
|
||||
/// <summary>한 번 실행하고 출력을 통째로 받는다(버전 확인용).</summary>
|
||||
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); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user