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>
This commit is contained in:
insulee
2026-06-23 16:37:48 +09:00
parent 23cb8f3015
commit 196b00b91e
16 changed files with 17 additions and 17 deletions

View File

@@ -0,0 +1,261 @@
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<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); }
}
}

View File

@@ -0,0 +1,69 @@
using System.IO;
namespace FirmwareFlasher;
public static class FirmwareCatalog
{
// 모델 무관하게 보조 파일명은 공통 — 앱 후보 = 이 4개를 뺀 .bin
static readonly HashSet<string> Support = new(StringComparer.OrdinalIgnoreCase)
{ "bootloader.bin", "partition-table.bin", "ota_data_initial.bin", "www_fs.bin" };
static string? KnownNote(string fileName) => fileName switch
{
"DIBD720Te_08C_04R020C.bin" => "320×64 (4단20열, 8색) · 부팅 검증됨",
"DIBD720Ue_08B_08R040C.bin" => "640×128 (8단40열, 256색) · 미검증",
_ => null,
};
/// <summary>폴더의 앱 후보(.bin 중 보조 4종 제외)를 나열.</summary>
public static List<FirmwareItem> LoadApps(string folder)
{
var apps = new List<FirmwareItem>();
if (!Directory.Exists(folder)) return apps;
foreach (var path in Directory.EnumerateFiles(folder, "*.bin"))
{
var name = Path.GetFileName(path);
if (Support.Contains(name)) continue;
apps.Add(new FirmwareItem
{
FileName = name,
FullPath = path,
Size = SafeLen(path),
Note = KnownNote(name),
});
}
apps.Sort((a, b) => string.Compare(a.FileName, b.FileName, StringComparison.OrdinalIgnoreCase));
return apps;
}
/// <summary>선택한 모드+프로파일에 필요한 보조 파일이 폴더에 다 있는지. 없으면 빠진 목록.</summary>
public static string? MissingFor(string folder, FlashProfile p, FlashMode mode)
{
if (mode == FlashMode.AppOnly) return null;
bool includeBoot = mode == FlashMode.Full;
var miss = new List<string>();
foreach (var rg in p.SupportRegions)
{
if (rg.Kind == "bootloader" && !includeBoot) continue;
if (!File.Exists(Path.Combine(folder, rg.File!))) miss.Add(rg.File!);
}
return miss.Count == 0 ? null : string.Join(", ", miss);
}
/// <summary>exe 위치에서 위로 올라가며 firmware\ (bin 포함) 폴더를 찾는다.</summary>
public static string? AutoLocate(string startDir)
{
var dir = new DirectoryInfo(startDir);
for (int i = 0; i < 6 && dir is not null; i++)
{
var candidate = Path.Combine(dir.FullName, "firmware");
if (Directory.Exists(candidate) && Directory.EnumerateFiles(candidate, "*.bin").Any())
return candidate;
dir = dir.Parent;
}
return null;
}
static long SafeLen(string p) { try { return new FileInfo(p).Length; } catch { return 0; } }
}

View File

@@ -0,0 +1,82 @@
using System.IO.Ports;
using System.Management;
using System.Text.RegularExpressions;
namespace FirmwareFlasher;
/// <summary>
/// WMI(Win32_PnPEntity)로 COM 포트를 전부 열거하고 VID/PID로 종류만 라벨링한다.
/// 어떤 포트도 제외하지 않는다 — JTAG로도, UART(CP210x/CH340/FTDI)로도 플래시할 수 있으므로
/// 전부 보여주고 사용자가 고른다. JTAG(303A:1001)는 맨 위로 올려 자동선택을 돕는다.
/// </summary>
public static partial class PortScanner
{
[GeneratedRegex(@"\(COM(\d+)\)")] private static partial Regex ComRe();
[GeneratedRegex(@"VID_([0-9A-Fa-f]{4})")] private static partial Regex VidRe();
[GeneratedRegex(@"PID_([0-9A-Fa-f]{4})")] private static partial Regex PidRe();
public static List<ComPortInfo> Scan()
{
var list = new List<ComPortInfo>();
try
{
using var searcher = new ManagementObjectSearcher(
"SELECT Name, DeviceID FROM Win32_PnPEntity WHERE Name LIKE '%(COM%'");
foreach (ManagementBaseObject mo in searcher.Get())
{
var name = mo["Name"] as string ?? "";
var devId = mo["DeviceID"] as string ?? "";
var cm = ComRe().Match(name);
if (!cm.Success) continue;
string? vid = VidRe().Match(devId) is { Success: true } v ? v.Groups[1].Value.ToUpperInvariant() : null;
string? pid = PidRe().Match(devId) is { Success: true } p ? p.Groups[1].Value.ToUpperInvariant() : null;
list.Add(new ComPortInfo
{
PortName = $"COM{cm.Groups[1].Value}",
Number = int.TryParse(cm.Groups[1].Value, out var n) ? n : 9999,
FriendlyName = name,
DeviceId = devId,
Vid = vid,
Pid = pid,
Kind = KindOf(vid, pid),
});
mo.Dispose();
}
}
catch { /* WMI 실패 시 빈 목록 */ }
// JTAG는 맨 위에, 그 중 '최신(번호 높은)' 것을 먼저 — 보드 리셋으로 번호가 올라가면
// 옛 번호는 유령 포트가 되므로 높은 번호가 살아있을 확률이 높다.
var jtag = list.Where(p => p.IsJtag).OrderByDescending(p => p.Number);
var rest = list.Where(p => !p.IsJtag).OrderBy(p => p.Number);
return jtag.Concat(rest).ToList();
}
/// <summary>
/// 포트를 실제로 열 수 있는지 빠르게 확인(유령 포트 판별).
/// DTR/RTS를 건드리지 않아 보드를 리셋하지 않는다.
/// </summary>
public static bool CanOpen(string portName)
{
try
{
using var sp = new SerialPort(portName) { DtrEnable = false, RtsEnable = false };
sp.Open();
return true;
}
catch { return false; }
}
static string KindOf(string? vid, string? pid) => (vid, pid) switch
{
("303A", "1001") => "USB-JTAG (내장)",
("10C4", _) => "CP210x",
("1A86", "7523") => "CH340",
("1A86", _) => "CH34x/CH9102",
("0403", _) => "FTDI",
("067B", _) => "PL2303",
_ => "USB 직렬",
};
}

View File

@@ -0,0 +1,103 @@
using System.IO;
using System.Text.Json;
namespace FirmwareFlasher;
/// <summary>빌트인 모델 프로파일 + 폴더 flash.json 로더 + 앱 파일명 자동매칭.</summary>
public static class ProfileStore
{
static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
static FwRegion Boot(string off) => new() { File = "bootloader.bin", Offset = off, Kind = "bootloader" };
static FwRegion Part() => new() { File = "partition-table.bin", Offset = "0x10000", Kind = "support" };
static FwRegion Ota() => new() { File = "ota_data_initial.bin", Offset = "0x11000", Kind = "support" };
static FwRegion Www() => new() { File = "www_fs.bin", Offset = "0x420000", Kind = "support" };
static FwRegion AppAt(string off = "0x20000") => new() { App = true, Offset = off, Kind = "app" };
public static List<FlashProfile> BuiltIns() => new()
{
new FlashProfile
{
Name = "DB720 (ESP32-P4)", Chip = "esp32p4", Baud = 115200, NoStub = true, PerRegion = true,
ConnectAttempts = 30, FlashSize = "16MB", FlashMode = "dio", FlashFreq = "80m",
AppOnlyErasesOtadata = true,
Regions = { Boot("0x2000"), Part(), Ota(), Www(), AppAt() },
FlashPortHint = "303A:1001", ConsolePortHint = "10C4:EA60",
DownloadModeHint = "USB-JTAG 케이블 연결. 포트가 안 잡히면 케이블만 다시 꽂으면 됩니다(자동 감지).",
Notes = "secure_release · db250513 서명. 영역별 개별 호출(SDM). 부트로더(0x2000)는 이미 구워진 보드면 거부될 수 있음(정상 → 표준/앱만 사용).",
},
new FlashProfile
{
Name = "DB402S (ESP32-S3)", Chip = "esp32s3", Baud = 921600,
Regions = { Boot("0x0000"), Part(), Ota(), AppAt() },
ConsolePortHint = "10C4:EA60",
DownloadModeHint = "TTL(COM2) 연결. SW1 누른 상태에서 SW2 눌렀다 떼고 SW1 떼면 다운로드 모드 진입.",
Notes = "esp32-s3. 부트로더 offset 0x0000(중요). 스텁 921600 · 한 번에 기록. secure 보드는 부트로더(<0x8000) 거부 → --force 또는 앱만.",
},
new FlashProfile
{
Name = "DB400S (ESP32)", Chip = "esp32", Baud = 921600,
Regions = { Boot("0x1000"), Part(), Ota(), AppAt() },
ConsolePortHint = "10C4:EA60",
DownloadModeHint = "TTL(COM2) 연결. SW1 누른 상태에서 SW2 눌렀다 떼고 SW1 떼면 다운로드 모드 진입.",
Notes = "esp32 클래식. 부트로더 0x1000. 스텁 921600 · 한 번에. secure 보드는 부트로더(<0x8000) 거부 → --force 또는 앱만.",
},
new FlashProfile
{
Name = "DB400Q (ESP32)", Chip = "esp32", Baud = 921600,
Regions = { Boot("0x1000"), Part(), Ota(), AppAt() },
ConsolePortHint = "10C4:EA60",
DownloadModeHint = "TTL(COM2) 연결. SW1 누른 상태에서 SW2 눌렀다 떼고 SW1 떼면 다운로드 모드 진입.",
Notes = "esp32 클래식. DB400S와 동일 방식.",
},
new FlashProfile
{
Name = "DB300eM (ESP32)", Chip = "esp32", Baud = 921600,
Regions = { Boot("0x1000"), Part(), Ota(), AppAt() },
ConsolePortHint = "10C4:EA60",
DownloadModeHint = "TTL(COM2) 연결. JP1 점퍼핀 단락 후 전원 재인가하면 다운로드 모드 진입.",
Notes = "esp32 클래식. JP1 점퍼로 다운로드 진입. web OTA(IP 입력)도 가능.",
},
};
/// <summary>폴더의 flash.json 을 읽는다(없으면 null).</summary>
public static FlashProfile? LoadFromFolder(string folder)
{
try
{
var p = Path.Combine(folder, "flash.json");
if (!File.Exists(p)) return null;
var prof = JsonSerializer.Deserialize<FlashProfile>(File.ReadAllText(p), JsonOpts);
if (prof is not null) prof.FromFile = true;
return prof;
}
catch { return null; }
}
/// <summary>앱 bin 파일명(DIBD720…, DIBD402S… 등)으로 빌트인 프로파일을 추정.</summary>
public static FlashProfile? MatchByApp(IEnumerable<string> appFileNames, List<FlashProfile> profiles)
{
// 더 구체적인 토큰을 먼저 (402S 가 400 보다, 400S 가 400 보다 우선)
(string token, string profileTag)[] map =
{
("402S", "DB402S"), ("400S", "DB400S"), ("400Q", "DB400Q"),
("300", "DB300"), ("720", "DB720"),
};
foreach (var name in appFileNames)
{
var up = name.ToUpperInvariant();
foreach (var (token, tag) in map)
if (up.Contains(token))
{
var hit = profiles.FirstOrDefault(p => p.Name.ToUpperInvariant().Contains(tag));
if (hit is not null) return hit;
}
}
return null;
}
}