808 lines
49 KiB
C#
808 lines
49 KiB
C#
// 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;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|