// 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 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 inputs = new List(); private readonly List cards = new List(); private string bridgePath; private string sessionFile; private string lastBackup; private Dictionary 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(), "ports"); if (result.ExitCode == 0 && result.Payload != null) { ports.Items.Clear(); PortItem known = null; foreach (object entry in Items(result.Payload, "ports")) { Dictionary item = entry as Dictionary; 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 request = new Dictionary { { "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 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 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 region = entry as Dictionary; 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 { { "session_file", approvedSession }, { "execute", true } }, "complete"); InvalidatePreview(); if (IsVerifiedCompletion(result)) { Dictionary 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 RunBridge(string command, Dictionary 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 data; try { data = new JavaScriptSerializer().Deserialize>(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 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 Dict(Dictionary data, string key) { object value; return data != null && data.TryGetValue(key, out value) ? value as Dictionary : null; } private static IEnumerable Items(Dictionary data, string key) { object value; return data != null && data.TryGetValue(key, out value) && value is IEnumerable ? (IEnumerable)value : new object[0]; } private Dictionary DemoSummary() { return new Dictionary { { "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 { { "name", "ble_factory" }, { "offset", "0xe4e000" }, { "size", 24576 }, { "sha256", "c38bf520abf240ae9e0b0000000000000000000000000000000000000000000000" } }, new Dictionary { { "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 checks = new List(); bool passed = true; Action check = delegate(string name, bool ok) { checks.Add(new Dictionary { { "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 verifiedResult = new Dictionary { { "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 { { "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 { { "passed", passed }, { "checks", checks }, { "hardware_accessed", false }, { "background_processes_started", 0 } }), new UTF8Encoding(false)); return passed ? 0 : 1; } } }