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:
insulee
2026-06-23 16:10:40 +09:00
commit 69f4d4754f
25 changed files with 2545 additions and 0 deletions

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