Files
story/firmware/echoear-platform-agent/tools/flash-app/EchoEarFlasher.cs
T

808 lines
49 KiB
C#
Raw Normal View History

// Build with the Windows .NET Framework C# compiler; no third-party UI packages.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
using System.Windows.Forms;
namespace EchoEarFlasher
{
internal static class Program
{
[STAThread]
private static int Main(string[] args)
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
bool offline = args.Length == 2 && (args[0] == "--render-preview" || args[0] == "--ui-smoke-test");
using (FlashForm form = new FlashForm(offline))
{
if (offline)
{
if (args[0] == "--render-preview") form.RenderPreview(args[1]);
else return form.RunSmokeTest(args[1]);
return 0;
}
if (args.Length != 0) return 2;
Application.Run(form);
}
return 0;
}
}
internal sealed class PortItem
{
internal string Port;
internal string Description;
internal bool Known;
public override string ToString() { return Port + " · " + Description; }
}
internal sealed class BridgeResult
{
internal int ExitCode;
internal Dictionary<string, object> Payload;
internal string Error;
}
internal sealed class FlashForm : Form
{
private static readonly Color Navy = Color.FromArgb(21, 40, 65);
private static readonly Color Teal = Color.FromArgb(0, 126, 128);
private static readonly Color TextColor = Color.FromArgb(35, 51, 69);
private static readonly Color Muted = Color.FromArgb(106, 122, 137);
private static readonly Color Line = Color.FromArgb(224, 233, 238);
private const string KnownMac = "B4:3A:45:16:34:A8";
private const string AdminUrl = "https://82.157.201.114/maintenance?area=devices";
private readonly bool offline;
private readonly JavaScriptSerializer json = new JavaScriptSerializer();
private readonly List<Control> inputs = new List<Control>();
private readonly List<Panel> cards = new List<Panel>();
private string bridgePath;
private string sessionFile;
private string lastBackup;
private Dictionary<string, object> previewSummary;
private bool busy;
private bool loading;
private Panel workspace;
private FlowLayoutPanel stack;
private Panel advancedCard;
private TextBox credential;
private TextBox mac;
private TextBox build;
private TextBox python;
private TextBox output;
private ComboBox ports;
private RadioButton identity;
private RadioButton full;
private CheckBox acknowledged;
private Button refresh;
private Button previewButton;
private Button flashButton;
private Button advancedToggle;
private Button backupButton;
private Label sidebarPort;
private Label sidebarStage;
private Label stageDetail;
private Label status;
private Label summaryLine;
private Label summaryPlatform;
private Label summaryFirmware;
private Label previewBadge;
private RichTextBox log;
private DataGridView regions;
private ProgressBar progress;
internal FlashForm(bool offlineMode)
{
offline = offlineMode;
loading = true;
Text = "EchoEar 设备烧录助手";
Font = new Font("Microsoft YaHei UI", 9F);
BackColor = Color.FromArgb(242, 246, 249);
ForeColor = TextColor;
ClientSize = new Size(1120, 838);
MinimumSize = new Size(920, 650);
StartPosition = FormStartPosition.CenterScreen;
AutoScaleMode = AutoScaleMode.Dpi;
BuildSidebar();
workspace = new Panel { Dock = DockStyle.Fill, Padding = new Padding(24, 20, 18, 14) };
Controls.Add(workspace);
workspace.BringToFront();
Panel heading = new Panel { Dock = DockStyle.Top, Height = 74 };
heading.Controls.Add(LabelAt("设备初始化与固件烧录", 0, 0, 730, 34, 21, Navy, true));
heading.Controls.Add(LabelAt("导入后台烧录信息,确认设备身份,再备份和写入。", 1, 41, 730, 25, 10, Muted, false));
stack = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, WrapContents = false, AutoScroll = true, Padding = new Padding(0, 3, 2, 8) };
workspace.Controls.Add(stack);
workspace.Controls.Add(heading);
BuildConfiguration();
BuildAdvanced();
BuildPreview();
BuildExecution();
FindDefaults();
WireChanges();
loading = false;
Resize += delegate { ResizeCards(); };
Shown += async delegate { ResizeCards(); if (!offline) await RefreshPorts(); };
FormClosing += delegate(object sender, FormClosingEventArgs e) {
if (busy) { e.Cancel = true; SetStatus("操作正在进行,请等待结束后关闭窗口。", false); }
};
ResizeCards();
UpdateActions();
}
private Label LabelAt(string text, int x, int y, int width, int height, float size, Color color, bool bold)
{
return new Label { Text = text, Location = new Point(x, y), Size = new Size(width, height), Font = new Font("Microsoft YaHei UI", size, bold ? FontStyle.Bold : FontStyle.Regular), ForeColor = color, AutoEllipsis = true, TextAlign = ContentAlignment.MiddleLeft };
}
private Button ButtonAt(string text, int x, int y, int width, bool primary)
{
Button button = new Button { Text = text, Location = new Point(x, y), Size = new Size(width, 34), FlatStyle = FlatStyle.Flat, BackColor = primary ? Teal : Color.White, ForeColor = primary ? Color.White : TextColor, Cursor = Cursors.Hand, UseVisualStyleBackColor = false };
button.FlatAppearance.BorderColor = primary ? Teal : Line;
button.FlatAppearance.BorderSize = 1;
return button;
}
private TextBox InputAt(Panel parent, int x, int y, int width)
{
TextBox input = new TextBox { Location = new Point(x, y), Width = width, BorderStyle = BorderStyle.FixedSingle, Font = new Font("Microsoft YaHei UI", 9.5F), BackColor = Color.White, Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right };
parent.Controls.Add(input);
inputs.Add(input);
return input;
}
private Panel Card(int height)
{
Panel panel = new Panel { BackColor = Color.White, Height = height, Width = 816, Margin = new Padding(0, 0, 0, 12) };
panel.Paint += delegate(object sender, PaintEventArgs e) { using (Pen p = new Pen(Line)) e.Graphics.DrawRectangle(p, 0, 0, panel.Width - 1, panel.Height - 1); };
cards.Add(panel);
stack.Controls.Add(panel);
return panel;
}
private void BuildSidebar()
{
Panel side = new Panel { Dock = DockStyle.Left, Width = 222, Height = ClientSize.Height, BackColor = Navy };
side.Controls.Add(LabelAt("ECHOEAR", 24, 27, 176, 32, 22, Color.White, true));
side.Controls.Add(LabelAt("设备烧录助手", 25, 69, 175, 24, 11, Color.FromArgb(177, 204, 221), false));
Panel accent = new Panel { BackColor = Color.FromArgb(54, 196, 182), Location = new Point(25, 113), Size = new Size(37, 4) };
side.Controls.Add(accent);
side.Controls.Add(LabelAt("当前设备", 25, 149, 172, 24, 9, Color.FromArgb(161, 181, 201), false));
side.Controls.Add(LabelAt("EchoEar S3", 25, 176, 174, 30, 17, Color.White, true));
side.Controls.Add(LabelAt("ESP32-S3 / 16 MB", 25, 210, 176, 28, 10, Color.FromArgb(205, 220, 232), false));
sidebarPort = LabelAt("串口 · 尚未选择", 25, 247, 176, 28, 10, Color.FromArgb(87, 216, 198), true);
side.Controls.Add(sidebarPort);
side.Controls.Add(LabelAt("操作状态", 25, 314, 172, 24, 9, Color.FromArgb(161, 181, 201), false));
sidebarStage = LabelAt("等待导入", 25, 345, 174, 32, 17, Color.White, true);
side.Controls.Add(sidebarStage);
stageDetail = LabelAt("选择后台下载的 JSON\n然后检查并生成预览。", 25, 387, 172, 76, 10, Color.FromArgb(205, 220, 232), false);
stageDetail.AutoEllipsis = false;
side.Controls.Add(stageDetail);
Label flow = LabelAt("01 导入与检查\n\n02 备份与写入\n\n03 重启与配网", 25, 503, 173, 136, 10, Color.FromArgb(178, 201, 218), false);
side.Controls.Add(flow);
Button admin = ButtonAt("打开设备管理后台 ↗", 24, 700, 174, false);
admin.Anchor = AnchorStyles.Left | AnchorStyles.Bottom;
admin.BackColor = Color.FromArgb(31, 57, 82);
admin.ForeColor = Color.White;
admin.FlatAppearance.BorderColor = Color.FromArgb(57, 80, 101);
admin.Location = new Point(24, ClientSize.Height - 97);
admin.Click += delegate { OpenTarget(AdminUrl); };
side.Controls.Add(admin);
Label foot = LabelAt("本地操作 · 密钥不显示", 25, ClientSize.Height - 50, 175, 24, 9, Color.FromArgb(161, 181, 201), false);
foot.Anchor = AnchorStyles.Left | AnchorStyles.Bottom;
side.Controls.Add(foot);
Controls.Add(side);
}
private void BuildConfiguration()
{
Panel card = Card(213);
card.Controls.Add(LabelAt("1 选择烧录内容", 18, 11, 350, 27, 12, Navy, true));
identity = new RadioButton { Text = "重新初始化(保留固件)", Checked = true, Location = new Point(19, 47), Size = new Size(235, 28) };
full = new RadioButton { Text = "完整固件 + 初始化", Location = new Point(272, 47), Size = new Size(224, 28) };
inputs.Add(identity); inputs.Add(full);
card.Controls.Add(identity); card.Controls.Add(full);
card.Controls.Add(LabelAt("烧录信息", 19, 91, 80, 26, 9, Muted, false));
credential = InputAt(card, 104, 93, 586);
Button browse = ButtonAt("选择 JSON", 704, 88, 94, false);
browse.Anchor = AnchorStyles.Top | AnchorStyles.Right;
browse.Click += delegate { ChooseFile(credential, "后台烧录信息 (*.json)|*.json", "选择后台下载的烧录信息 JSON"); };
inputs.Add(browse); card.Controls.Add(browse);
card.Controls.Add(LabelAt("设备串口", 19, 135, 80, 26, 9, Muted, false));
ports = new ComboBox { Location = new Point(104, 136), Size = new Size(315, 28), DropDownStyle = ComboBoxStyle.DropDownList, Anchor = AnchorStyles.Top | AnchorStyles.Left };
inputs.Add(ports); card.Controls.Add(ports);
refresh = ButtonAt("刷新", 432, 131, 70, false);
refresh.Click += async delegate { await RefreshPorts(); };
inputs.Add(refresh); card.Controls.Add(refresh);
card.Controls.Add(LabelAt("设备 MAC", 517, 135, 79, 26, 9, Muted, false));
mac = InputAt(card, 600, 136, 197);
mac.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
mac.Text = KnownMac;
card.Controls.Add(LabelAt("连接设备 USB;自动识别已知设备,其余串口请手动选择。", 104, 171, 682, 27, 9, Muted, false));
}
private void BuildAdvanced()
{
advancedCard = Card(39);
advancedToggle = ButtonAt("▸ 高级设置:固件目录、Python、备份位置", 10, 2, 510, false);
advancedToggle.FlatAppearance.BorderSize = 0;
advancedToggle.TextAlign = ContentAlignment.MiddleLeft;
advancedToggle.Click += delegate {
bool expanding = advancedCard.Height < 100;
advancedCard.Height = expanding ? 188 : 39;
advancedToggle.Text = (expanding ? "▾" : "▸") + " 高级设置:固件目录、Python、备份位置";
ResizeCards();
};
inputs.Add(advancedToggle); advancedCard.Controls.Add(advancedToggle);
advancedCard.Controls.Add(LabelAt("固件目录", 19, 48, 80, 26, 9, Muted, false));
build = InputAt(advancedCard, 104, 50, 586);
Button buildBrowse = ButtonAt("选择目录", 704, 45, 94, false);
buildBrowse.Anchor = AnchorStyles.Top | AnchorStyles.Right;
buildBrowse.Click += delegate { ChooseFolder(build, "选择包含 flasher_args.json 的固件 build 目录"); };
inputs.Add(buildBrowse); advancedCard.Controls.Add(buildBrowse);
advancedCard.Controls.Add(LabelAt("Python", 19, 91, 80, 26, 9, Muted, false));
python = InputAt(advancedCard, 104, 93, 586);
Button pythonBrowse = ButtonAt("选择程序", 704, 88, 94, false);
pythonBrowse.Anchor = AnchorStyles.Top | AnchorStyles.Right;
pythonBrowse.Click += delegate { ChooseFile(python, "Python 程序 (python.exe)|python.exe|应用程序 (*.exe)|*.exe", "选择已安装 esptool、pyserial 的 Python"); };
inputs.Add(pythonBrowse); advancedCard.Controls.Add(pythonBrowse);
advancedCard.Controls.Add(LabelAt("备份位置", 19, 134, 80, 26, 9, Muted, false));
output = InputAt(advancedCard, 104, 136, 586);
Button outputBrowse = ButtonAt("选择目录", 704, 131, 94, false);
outputBrowse.Anchor = AnchorStyles.Top | AnchorStyles.Right;
outputBrowse.Click += delegate { ChooseFolder(output, "选择备份与初始化文件的保存位置"); };
inputs.Add(outputBrowse); advancedCard.Controls.Add(outputBrowse);
}
private void BuildPreview()
{
Panel card = Card(231);
card.Controls.Add(LabelAt("2 检查写入预览", 18, 10, 350, 28, 12, Navy, true));
previewBadge = LabelAt("尚未生成", 669, 12, 128, 24, 9, Muted, true);
previewBadge.TextAlign = ContentAlignment.MiddleRight;
previewBadge.Anchor = AnchorStyles.Top | AnchorStyles.Right;
card.Controls.Add(previewBadge);
summaryLine = LabelAt("设备编号 — 型号 —", 19, 44, 779, 24, 9, TextColor, false);
summaryPlatform = LabelAt("平台地址 —", 19, 70, 779, 24, 9, Muted, false);
summaryFirmware = LabelAt("固件 保留设备当前固件", 19, 96, 779, 24, 9, Teal, true);
foreach (Label label in new[] { summaryLine, summaryPlatform, summaryFirmware }) { label.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; card.Controls.Add(label); }
regions = new DataGridView { Location = new Point(19, 128), Size = new Size(778, 83), Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right, BackgroundColor = Color.White, BorderStyle = BorderStyle.None, ReadOnly = true, AllowUserToAddRows = false, AllowUserToDeleteRows = false, AllowUserToResizeRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, SelectionMode = DataGridViewSelectionMode.FullRowSelect, CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal, GridColor = Line, EnableHeadersVisualStyles = false, ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.DisableResizing, ColumnHeadersHeight = 27, MultiSelect = false };
regions.ColumnHeadersDefaultCellStyle = new DataGridViewCellStyle { BackColor = Color.FromArgb(242, 246, 249), ForeColor = Muted, Font = new Font("Microsoft YaHei UI", 8.5F) };
regions.DefaultCellStyle = new DataGridViewCellStyle { BackColor = Color.White, ForeColor = TextColor, SelectionBackColor = Color.FromArgb(225, 244, 241), SelectionForeColor = TextColor, Font = new Font("Microsoft YaHei UI", 8.5F), Padding = new Padding(2) };
regions.RowTemplate.Height = 26;
regions.Columns.Add("name", "写入区域"); regions.Columns.Add("offset", "起始地址"); regions.Columns.Add("size", "大小"); regions.Columns.Add("hash", "SHA-256 校验摘要");
regions.Columns[0].FillWeight = 26; regions.Columns[1].FillWeight = 19; regions.Columns[2].FillWeight = 19; regions.Columns[3].FillWeight = 36;
card.Controls.Add(regions);
}
private void BuildExecution()
{
Panel card = Card(239);
acknowledged = new CheckBox { Text = "我已了解:会清除旧 Wi-Fi 和语音运行配置,完成后需重新配网。", Location = new Point(19, 12), Size = new Size(775, 29), Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right };
acknowledged.CheckedChanged += delegate { UpdateActions(); };
inputs.Add(acknowledged); card.Controls.Add(acknowledged);
previewButton = ButtonAt("1 检查并生成预览", 19, 50, 178, false);
previewButton.Click += async delegate { await PreparePreview(); };
card.Controls.Add(previewButton);
flashButton = ButtonAt("2 开始烧录", 210, 50, 178, true);
flashButton.Enabled = false;
flashButton.Click += async delegate { await Flash(); };
card.Controls.Add(flashButton);
backupButton = ButtonAt("打开备份文件夹", 639, 50, 158, false);
backupButton.Anchor = AnchorStyles.Top | AnchorStyles.Right;
backupButton.Click += delegate {
string path = String.IsNullOrEmpty(lastBackup) ? output.Text.Trim() : lastBackup;
if (Directory.Exists(path)) OpenTarget(path);
else
{
string parent = Path.GetDirectoryName(path);
string available = Directory.Exists(parent) ? parent : output.Text.Trim();
if (Directory.Exists(available)) OpenTarget(available);
SetStatus("当前操作尚未生成备份;已打开可用的会话或保存目录。", false);
}
};
card.Controls.Add(backupButton);
progress = new ProgressBar { Location = new Point(19, 97), Size = new Size(778, 4), Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right, Style = ProgressBarStyle.Blocks, Value = 0, MarqueeAnimationSpeed = 0 };
card.Controls.Add(progress);
status = LabelAt("准备就绪。检查只生成预览,不会写入设备。", 19, 109, 778, 27, 9, Muted, false);
status.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
card.Controls.Add(status);
log = new RichTextBox { Location = new Point(19, 146), Size = new Size(778, 75), Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right, ReadOnly = true, BorderStyle = BorderStyle.None, BackColor = Color.FromArgb(246, 248, 250), ForeColor = Muted, Font = new Font("Microsoft YaHei UI", 8.5F), DetectUrls = false, ScrollBars = RichTextBoxScrollBars.Vertical };
card.Controls.Add(log);
}
private void ResizeCards()
{
if (stack == null) return;
// Configuration remains readable; horizontal scrolling is available at small sizes/high DPI.
int width = Math.Max(816, stack.ClientSize.Width - 23);
stack.SuspendLayout();
foreach (Panel panel in cards) panel.Width = width;
stack.ResumeLayout();
}
private void FindDefaults()
{
string executableRoot = AppDomain.CurrentDomain.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
string distributedBridge = Path.Combine(executableRoot, "engine", "flash_app_bridge.py");
bool distributed = File.Exists(distributedBridge);
if (distributed) bridgePath = distributedBridge;
else
{
DirectoryInfo cursor = new DirectoryInfo(executableRoot);
for (int depth = 0; cursor != null && depth < 8; depth++, cursor = cursor.Parent)
{
string candidate = Path.Combine(cursor.FullName, "flash_app_bridge.py");
if (File.Exists(candidate)) { bridgePath = candidate; break; }
candidate = Path.Combine(cursor.FullName, "tools", "flash_app_bridge.py");
if (File.Exists(candidate)) { bridgePath = candidate; break; }
}
}
string projectRoot = bridgePath == null ? executableRoot : Directory.GetParent(Path.GetDirectoryName(bridgePath)).FullName;
build.Text = Path.Combine(distributed ? executableRoot : projectRoot, distributed ? "firmware-build" : "build");
output.Text = distributed ? Path.Combine(executableRoot, "backups") : Path.Combine(projectRoot, "backups", "gui");
python.Text = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".espressif", "python_env", "idf5.5_py3.12_env", "Scripts", "python.exe");
string portablePython = Path.Combine(executableRoot, "python", "python.exe");
if (File.Exists(portablePython)) python.Text = portablePython;
}
private void WireChanges()
{
foreach (TextBox input in new[] { credential, mac, build, python, output }) input.TextChanged += delegate { InvalidatePreview(); };
identity.CheckedChanged += delegate { InvalidatePreview(); };
full.CheckedChanged += delegate { InvalidatePreview(); };
ports.SelectedIndexChanged += delegate {
PortItem port = ports.SelectedItem as PortItem;
sidebarPort.Text = port == null ? "串口 · 尚未选择" : "串口 · " + port.Port;
InvalidatePreview();
};
}
private void InvalidatePreview()
{
if (loading) return;
bool wasValid = !String.IsNullOrEmpty(sessionFile);
sessionFile = null;
previewSummary = null;
acknowledged.Checked = false;
previewBadge.Text = "需要重新检查";
previewBadge.ForeColor = Muted;
summaryLine.Text = "设备编号 — 型号 —";
summaryPlatform.Text = "平台地址 —";
summaryFirmware.Text = identity.Checked ? "固件 保留设备当前固件" : "固件 检查后显示待写入版本;文件校验通过不等于真机验收";
regions.Rows.Clear();
if (wasValid) SetStatus("配置已更改,请重新检查并生成预览。", false);
UpdateActions();
}
private void UpdateActions()
{
if (flashButton == null) return;
flashButton.Enabled = !busy && !String.IsNullOrEmpty(sessionFile) && acknowledged.Checked;
flashButton.BackColor = flashButton.Enabled ? Teal : Color.FromArgb(223, 230, 235);
flashButton.ForeColor = flashButton.Enabled ? Color.White : Muted;
flashButton.FlatAppearance.BorderColor = flashButton.Enabled ? Teal : Color.FromArgb(210, 220, 227);
previewButton.Enabled = !busy;
backupButton.Enabled = !busy;
}
private void SetBusy(bool value, string stage)
{
busy = value;
foreach (Control input in inputs) input.Enabled = !value;
UseWaitCursor = value;
progress.Style = value ? ProgressBarStyle.Marquee : ProgressBarStyle.Blocks;
progress.MarqueeAnimationSpeed = value ? 35 : 0;
if (!value) progress.Value = 0;
if (!String.IsNullOrEmpty(stage)) sidebarStage.Text = stage;
UpdateActions();
}
private void SetStatus(string message, bool error)
{
status.Text = message;
status.ForeColor = error ? Color.FromArgb(175, 64, 47) : Muted;
}
private void AppendLog(string message)
{
if (log.TextLength > 28000) log.Clear();
log.AppendText(DateTime.Now.ToString("HH:mm:ss") + " " + message.Replace("\r", " ").Replace("\n", " ") + Environment.NewLine);
log.SelectionStart = log.TextLength;
log.ScrollToCaret();
}
private void ChooseFile(TextBox destination, string filter, string title)
{
using (OpenFileDialog dialog = new OpenFileDialog { Filter = filter, Title = title, CheckFileExists = true })
if (dialog.ShowDialog(this) == DialogResult.OK) destination.Text = dialog.FileName;
}
private void ChooseFolder(TextBox destination, string title)
{
using (FolderBrowserDialog dialog = new FolderBrowserDialog { Description = title, SelectedPath = Directory.Exists(destination.Text) ? destination.Text : "" })
if (dialog.ShowDialog(this) == DialogResult.OK) destination.Text = dialog.SelectedPath;
}
private void OpenTarget(string target)
{
try { Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); }
catch { SetStatus("无法打开,请在资源管理器或浏览器中手动打开。", true); }
}
private bool CheckEngine()
{
if (offline) { SetStatus("当前为离线界面验证模式,已禁用设备操作。", true); return false; }
if (String.IsNullOrEmpty(bridgePath) || !File.Exists(bridgePath)) { SetStatus("未找到烧录引擎 flash_app_bridge.py,请使用完整程序包。", true); return false; }
if (!File.Exists(python.Text.Trim()))
{
advancedCard.Height = 188;
advancedToggle.Text = "▾ 高级设置:固件目录、Python、备份位置";
SetStatus("请选择 Python 程序:高级设置中需使用已安装 esptool、pyserial 的环境。", true);
return false;
}
return true;
}
private async Task RefreshPorts()
{
if (busy || !CheckEngine()) return;
InvalidatePreview();
SetBusy(true, "识别串口");
SetStatus("正在枚举 USB 串口…", false);
BridgeResult result = await RunBridge("ports", new Dictionary<string, object>(), "ports");
if (result.ExitCode == 0 && result.Payload != null)
{
ports.Items.Clear();
PortItem known = null;
foreach (object entry in Items(result.Payload, "ports"))
{
Dictionary<string, object> item = entry as Dictionary<string, object>;
if (item == null) continue;
string serial = Regex.Replace(Get(item, "serial_number"), "[^a-fA-F0-9]", "").ToUpperInvariant();
int vid = 0; Int32.TryParse(Get(item, "vid"), out vid);
PortItem port = new PortItem { Port = Get(item, "port"), Description = Get(item, "description"), Known = vid == 0x303A && serial == "B43A451634A8" };
if (String.IsNullOrEmpty(port.Port)) continue;
if (port.Known) { known = port; port.Description += " · 已知设备"; }
ports.Items.Add(port);
}
if (known != null) ports.SelectedItem = known;
SetStatus(known != null ? "已识别已知设备。选择后台 JSON 后生成预览。" : (ports.Items.Count > 0 ? "找到串口,请手动选择目标设备并核对 MAC。" : "未发现串口,请检查 USB 线与设备连接后刷新。"), false);
AppendLog(known != null ? "已识别已知设备串口 " + known.Port + "。" : "串口列表已刷新;未自动选择未知设备。");
}
else ReportFailure(result, "串口识别失败");
SetBusy(false, "等待导入");
}
private async Task PreparePreview()
{
if (busy || !CheckEngine()) return;
PortItem selected = ports.SelectedItem as PortItem;
if (!File.Exists(credential.Text.Trim())) { SetStatus("请先选择后台下载的烧录信息 JSON。", true); return; }
if (selected == null) { SetStatus("请选择目标设备串口。", true); return; }
if (!Regex.IsMatch(mac.Text.Trim(), "^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$")) { SetStatus("设备 MAC 格式应为 B4:3A:45:16:34:A8。", true); return; }
if (!Directory.Exists(build.Text.Trim())) { SetStatus("固件参考目录不存在,请在高级设置中选择 build 目录。", true); return; }
if (String.IsNullOrWhiteSpace(output.Text)) { SetStatus("请选择备份保存位置。", true); return; }
Dictionary<string, object> request = new Dictionary<string, object> {
{ "credential_path", credential.Text.Trim() }, { "build_dir", build.Text.Trim() }, { "mode", identity.Checked ? "identity" : "full" },
{ "expected_mac", mac.Text.Trim().ToUpperInvariant() }, { "output_root", output.Text.Trim() }, { "port", selected.Port }
};
InvalidatePreview();
SetBusy(true, "检查信息");
SetStatus("正在检查烧录信息和文件,生成写入预览…", false);
AppendLog("开始检查。本步骤不会写入设备。");
BridgeResult result = await RunBridge("preview", request, "preview");
if (result.ExitCode == 0 && result.Payload != null)
{
Dictionary<string, object> summary = Dict(result.Payload, "summary");
string session = Get(result.Payload, "session_file");
if (summary == null || String.IsNullOrEmpty(session)) { result.Error = "烧录引擎未返回完整预览,请重新检查。"; ReportFailure(result, "检查失败"); }
else
{
ApplyPreview(session, summary);
SetStatus("预览已生成。请核对设备和区域,再勾选配网提示。", false);
AppendLog("预览检查完成;尚未开始备份或写入。");
}
}
else ReportFailure(result, "检查失败");
SetBusy(false, String.IsNullOrEmpty(sessionFile) ? "检查失败" : "预览就绪");
stageDetail.Text = String.IsNullOrEmpty(sessionFile) ? "根据下方提示修正后\n重新检查。" : "核对设备身份与写入内容。\n开始烧录前还会再次确认。";
}
private void ApplyPreview(string session, Dictionary<string, object> summary)
{
previewSummary = summary;
sessionFile = session;
// A new attempt must never send the user to an earlier device's backup.
lastBackup = Get(summary, "backup_dir");
acknowledged.Checked = false;
previewBadge.Text = "检查通过 · 待确认";
previewBadge.ForeColor = Teal;
summaryLine.Text = "设备编号 " + Get(summary, "device_id") + " 型号 " + Get(summary, "model");
summaryPlatform.Text = "平台 " + Get(summary, "origin") + " 密钥版本 " + Get(summary, "key_version");
summaryFirmware.Text = Get(summary, "mode") == "identity" ? "固件 保留设备当前固件 · 只更新身份并清除运行配置" : "固件 将写入 " + Get(summary, "firmware_version") + "(尚未实机验收)· 文件校验不代表真机验收";
regions.Rows.Clear();
foreach (object entry in Items(summary, "regions"))
{
Dictionary<string, object> region = entry as Dictionary<string, object>;
if (region == null) continue;
string hash = Get(region, "sha256");
string offset = Get(region, "offset");
long numeric; if (Int64.TryParse(offset, out numeric)) offset = "0x" + numeric.ToString("X");
string size = Get(region, "size");
if (Int64.TryParse(size, out numeric)) size = numeric.ToString("N0") + " B";
int index = regions.Rows.Add(Get(region, "name"), offset, size, hash.Length > 20 ? hash.Substring(0, 20) + "…" : hash);
regions.Rows[index].Cells[3].ToolTipText = hash;
}
regions.ClearSelection();
UpdateActions();
}
private async Task Flash()
{
if (offline || busy || String.IsNullOrEmpty(sessionFile) || previewSummary == null || !acknowledged.Checked) return;
string confirmation = "请确认即将操作的设备:\n\n串口:" + Get(previewSummary, "port") + "\n目标 MAC:" + Get(previewSummary, "expected_mac") + "\n设备编号:" + Get(previewSummary, "device_id") + "\n模式:" + (Get(previewSummary, "mode") == "identity" ? "重新初始化,保留现有固件" : "完整固件与初始化") + "\n\n会先读取实际 MAC 并与目标核对,完整备份后才写入。\n会清除旧 Wi-Fi 和语音运行配置,完成后需重新配网。\n\n操作期间请勿拔掉 USB 或断电。是否开始?";
if (MessageBox.Show(this, confirmation, "确认设备并开始烧录", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2) != DialogResult.Yes) return;
string approvedSession = sessionFile;
SetBusy(true, "准备烧录");
SetStatus("正在核对设备并备份,请保持 USB 连接…", false);
stageDetail.Text = "按实际步骤显示状态。\n操作期间请保持连接。";
AppendLog("用户已确认。开始核对硬件身份、备份和写入。");
BridgeResult result = await RunBridge("flash", new Dictionary<string, object> { { "session_file", approvedSession }, { "execute", true } }, "complete");
InvalidatePreview();
if (IsVerifiedCompletion(result))
{
Dictionary<string, object> completed = Dict(result.Payload, "result");
lastBackup = Get(completed, "backup_dir");
SetBusy(false, "烧录完成");
stageDetail.Text = "请手动重启设备。\n随后在家庭端重新蓝牙配网。";
previewBadge.Text = "烧录完成"; previewBadge.ForeColor = Teal;
SetStatus("完成并校验通过。请手动重启设备,再通过家庭端蓝牙重新配网。", false);
AppendLog("备份、写入和校验完成。请手动重启设备并重新配网。");
MessageBox.Show(this, "烧录与校验已完成。\n\n1. 手动重启设备。\n2. 在家庭端选择老人,重新进行蓝牙配网。\n3. 使用 2.4 GHz Wi-Fi,按设备提示完成确认。\n\n备份可通过“打开备份文件夹”查看。", "设备已准备好", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
else
{
if (result.ExitCode == 0 && String.IsNullOrEmpty(result.Error)) result.Error = "引擎未确认写入与校验成功完成,必须重新检查设备状态。";
ReportFailure(result, "烧录未完成");
SetBusy(false, "烧录失败");
stageDetail.Text = "保留设备连接并查看提示。\n重试前需要重新生成预览。";
}
}
private static bool IsVerifiedCompletion(BridgeResult result)
{
return result != null && result.ExitCode == 0 && String.IsNullOrEmpty(result.Error) &&
Get(result.Payload, "event") == "complete" && Get(Dict(result.Payload, "result"), "status") == "written_and_verified";
}
private void ReportFailure(BridgeResult result, string heading)
{
string message = String.IsNullOrEmpty(result.Error) ? "引擎未成功完成(退出码 " + result.ExitCode + ")。请检查环境、USB 连接和文件后重试。" : result.Error;
SetStatus(heading + ":" + message, true);
AppendLog(heading + ":" + message);
}
private async Task<BridgeResult> RunBridge(string command, Dictionary<string, object> request, string expectedEvent)
{
string pythonPath = python.Text.Trim();
string engine = bridgePath;
return await Task.Run(delegate {
BridgeResult result = new BridgeResult { ExitCode = -1 };
string requestFile = Path.Combine(Path.GetTempPath(), "echoear-request-" + Guid.NewGuid().ToString("N") + ".json");
try
{
// Request data contains paths and the public MAC only; never copy credential JSON here.
File.WriteAllText(requestFile, json.Serialize(request), new UTF8Encoding(false));
ProcessStartInfo start = new ProcessStartInfo {
FileName = pythonPath, Arguments = QuoteWindows("-u") + " " + QuoteWindows(engine) + " " + QuoteWindows(command) + " --request " + QuoteWindows(requestFile),
WorkingDirectory = Path.GetDirectoryName(engine), UseShellExecute = false, CreateNoWindow = true,
RedirectStandardOutput = true, RedirectStandardError = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8
};
start.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8";
using (Process process = new Process { StartInfo = start })
{
process.Start();
// Drain stderr without exposing raw dependency output that could contain credentials.
Task stderr = Task.Run(delegate { while (process.StandardError.ReadLine() != null) { } });
string line;
while ((line = process.StandardOutput.ReadLine()) != null)
{
if (line.Length > 1024 * 1024) { result.Error = "烧录引擎返回了过大的响应。"; continue; }
Dictionary<string, object> data;
try { data = new JavaScriptSerializer().Deserialize<Dictionary<string, object>>(line); }
catch { result.Error = "烧录引擎响应格式错误;原始输出已隐藏。"; continue; }
if (data == null) continue;
string kind = Get(data, "event");
if (kind == "phase")
{
string message = Get(data, "message");
string stage = Get(data, "stage");
Ui(delegate { SetStatus(message, false); AppendLog(message); sidebarStage.Text = StageName(stage); stageDetail.Text = message; });
}
else if (kind == expectedEvent) result.Payload = data;
else if (kind == "error") result.Error = Get(data, "message");
}
process.WaitForExit();
stderr.Wait();
result.ExitCode = process.ExitCode;
if (!String.IsNullOrEmpty(result.Error) || result.Payload == null) { result.Payload = null; if (result.ExitCode == 0) result.ExitCode = -1; }
}
}
catch { result.Error = "无法运行烧录引擎。请检查 Python 环境、程序文件以及目录读写权限。"; result.ExitCode = -1; result.Payload = null; }
finally { try { if (File.Exists(requestFile)) File.Delete(requestFile); } catch { } }
return result;
});
}
private void Ui(Action action)
{
if (IsDisposed || !IsHandleCreated) return;
try { BeginInvoke(action); } catch (InvalidOperationException) { }
}
private static string StageName(string stage)
{
switch (stage.ToLowerInvariant())
{
case "validating": return "检查信息";
case "preparing": return "生成预览";
case "ready": return "预览就绪";
case "checking": return "核对设备";
case "backup": return "完整备份";
case "writing": return "写入设备";
case "verifying": return "校验写入";
default: return "正在处理";
}
}
private static string QuoteWindows(string value)
{
if (value == null) value = "";
StringBuilder result = new StringBuilder("\"");
int backslashes = 0;
foreach (char character in value)
{
if (character == '\\') { backslashes++; continue; }
if (character == '"') { result.Append('\\', backslashes * 2 + 1); result.Append('"'); }
else { result.Append('\\', backslashes); result.Append(character); }
backslashes = 0;
}
result.Append('\\', backslashes * 2);
result.Append('"');
return result.ToString();
}
private static string Get(Dictionary<string, object> data, string key)
{
object value; return data != null && data.TryGetValue(key, out value) && value != null ? Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) : "";
}
private static Dictionary<string, object> Dict(Dictionary<string, object> data, string key)
{
object value; return data != null && data.TryGetValue(key, out value) ? value as Dictionary<string, object> : null;
}
private static IEnumerable Items(Dictionary<string, object> data, string key)
{
object value; return data != null && data.TryGetValue(key, out value) && value is IEnumerable ? (IEnumerable)value : new object[0];
}
private Dictionary<string, object> DemoSummary()
{
return new Dictionary<string, object> {
{ "mode", "identity" }, { "port", "COM6" }, { "expected_mac", KnownMac }, { "device_id", "DEMO-ECHOEAR-001(演示)" }, { "model", "ECHOEAR-S3" },
{ "origin", "https://82.157.201.114" }, { "key_version", 1 }, { "reset_runtime", true }, { "firmware_version", null }, { "backup_dir", @"C:\offline-demo\current-session\backup" },
{ "regions", new object[] {
new Dictionary<string, object> { { "name", "ble_factory" }, { "offset", "0xe4e000" }, { "size", 24576 }, { "sha256", "c38bf520abf240ae9e0b0000000000000000000000000000000000000000000000" } },
new Dictionary<string, object> { { "name", "reset-runtime" }, { "offset", "0x9000" }, { "size", 24576 }, { "sha256", "d18942e3789d23fdf64c0000000000000000000000000000000000000000000000" } }
} }
};
}
internal void RenderPreview(string path)
{
if (!offline) throw new InvalidOperationException("Offline only");
loading = true;
ports.Items.Add(new PortItem { Port = "COM6", Description = "EchoEar S3 · 演示", Known = true });
ports.SelectedIndex = 0;
credential.Text = @"C:\Users\demo\Downloads\echoear-device.json";
loading = false;
ApplyPreview("offline-demo-only", DemoSummary());
sidebarPort.Text = "串口 · COM6";
sidebarStage.Text = "预览就绪";
stageDetail.Text = "演示界面 · 未连接设备\n核对信息后再开始。";
SetStatus("演示界面:仅展示操作流程,未读取或写入任何设备。", false);
AppendLog("演示预览已生成。实际烧录前会核对 MAC 并完整备份。");
ClientSize = new Size(1120, 900);
// Native combo/grid rendering needs a visible handle. The offline-only window stays
// off screen, never activates, and does not appear in the taskbar or run the bridge.
ShowInTaskbar = false;
StartPosition = FormStartPosition.Manual;
Location = new Point(-30000, -30000);
IntPtr ignored = Handle;
CreateChildHandles(this);
Show();
PerformLayout();
ResizeCards();
Application.DoEvents();
string absolute = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(absolute));
using (Bitmap bitmap = new Bitmap(Width, Height)) { DrawToBitmap(bitmap, new Rectangle(0, 0, Width, Height)); bitmap.Save(absolute, ImageFormat.Png); }
Hide();
}
protected override bool ShowWithoutActivation { get { return offline; } }
private static void CreateChildHandles(Control parent)
{
foreach (Control child in parent.Controls) { child.CreateControl(); IntPtr handle = child.Handle; CreateChildHandles(child); }
}
internal int RunSmokeTest(string path)
{
List<object> checks = new List<object>();
bool passed = true;
Action<string, bool> check = delegate(string name, bool ok) { checks.Add(new Dictionary<string, object> { { "name", name }, { "passed", ok } }); passed &= ok; };
check("initial_flash_disabled", !flashButton.Enabled);
ApplyPreview("offline-session", DemoSummary());
check("preview_requires_acknowledgement", !flashButton.Enabled);
acknowledged.Checked = true;
check("acknowledged_preview_enables_flash", flashButton.Enabled);
credential.Text = "changed.json";
check("credential_change_invalidates_preview", sessionFile == null && !flashButton.Enabled && !acknowledged.Checked);
ApplyPreview("offline-session", DemoSummary()); acknowledged.Checked = true;
mac.Text = "B4:3A:45:16:34:A9";
check("mac_change_invalidates_preview", sessionFile == null && !flashButton.Enabled);
ApplyPreview("offline-session", DemoSummary()); acknowledged.Checked = true;
full.Checked = true;
check("mode_change_invalidates_preview", sessionFile == null && !flashButton.Enabled);
ApplyPreview("offline-session", DemoSummary()); acknowledged.Checked = true;
SetBusy(true, "测试");
check("busy_disables_actions_and_inputs", !flashButton.Enabled && !previewButton.Enabled && inputs.All(delegate(Control input) { return !input.Enabled; }));
FormClosingEventArgs close = new FormClosingEventArgs(CloseReason.UserClosing, false);
OnFormClosing(close);
check("busy_blocks_window_close", close.Cancel);
SetBusy(false, "测试完成");
check("idle_restores_inputs", inputs.All(delegate(Control input) { return input.Enabled; }));
InvalidatePreview();
check("retry_requires_new_preview", !flashButton.Enabled && sessionFile == null);
check("quotes_trailing_backslash", QuoteWindows("C:\\path with spaces\\") == "\"C:\\path with spaces\\\\\"");
check("quotes_embedded_quote", QuoteWindows("a\"b") == "\"a\\\"b\"");
Dictionary<string, object> verifiedResult = new Dictionary<string, object> {
{ "status", "written_and_verified" }, { "expected_mac", KnownMac }, { "backup_dir", @"C:\offline-demo\backup" },
{ "firmware_version", null }, { "firmware_action", "preserved" }, { "next_step", "reboot_and_provision" }
};
BridgeResult completion = new BridgeResult { ExitCode = 0, Payload = new Dictionary<string, object> { { "event", "complete" }, { "result", verifiedResult } } };
check("real_backend_completion_status_accepted", IsVerifiedCompletion(completion));
verifiedResult["status"] = "ok";
check("generic_success_status_rejected", !IsVerifiedCompletion(completion));
verifiedResult["status"] = "written_and_verified";
completion.ExitCode = 1;
check("nonzero_exit_rejects_complete_payload", !IsVerifiedCompletion(completion));
completion.ExitCode = 0;
completion.Error = "protocol_error";
check("error_rejects_complete_payload", !IsVerifiedCompletion(completion));
lastBackup = @"C:\offline-demo\previous-device\backup";
ApplyPreview("new-session", DemoSummary());
check("new_preview_replaces_previous_backup_target", lastBackup == @"C:\offline-demo\current-session\backup");
InvalidatePreview();
check("failed_attempt_retains_current_backup_target", lastBackup == @"C:\offline-demo\current-session\backup");
check("bridge_stages_map_to_real_phases", StageName("writing") == "写入设备" && StageName("preparing") == "生成预览" && StageName("checking") == "核对设备" && StageName("ready") == "预览就绪" && StageName("verifying") == "校验写入");
string absolute = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(absolute));
File.WriteAllText(absolute, json.Serialize(new Dictionary<string, object> { { "passed", passed }, { "checks", checks }, { "hardware_accessed", false }, { "background_processes_started", 0 } }), new UTF8Encoding(false));
return passed ? 0 : 1;
}
}
}