feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s

Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
Codex Local
2026-10-08 10:06:44 +08:00
parent 98fb2965d0
commit bf1233d5df
861 changed files with 94614 additions and 3209 deletions
@@ -0,0 +1,807 @@
// 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;
}
}
}
@@ -0,0 +1,47 @@
param(
[string]$OutputDirectory = (Join-Path $PSScriptRoot 'dist'),
[string]$BuildDirectory = (Join-Path $PSScriptRoot '..\..\build')
)
$ErrorActionPreference = 'Stop'
$firmwareRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
$sourcePath = Join-Path $PSScriptRoot 'EchoEarFlasher.cs'
$compiler = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'
if (-not (Test-Path -LiteralPath $compiler)) { throw 'Windows .NET Framework C# compiler is required.' }
$outputPath = [IO.Path]::GetFullPath($OutputDirectory)
$buildPath = (Resolve-Path -LiteralPath $BuildDirectory).Path
$manifestPath = Join-Path $buildPath 'flasher_args.json'
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
if ($manifest.extra_esptool_args.chip -ne 'esp32s3') { throw 'The build must target ESP32-S3.' }
$null = New-Item -ItemType Directory -Path $outputPath -Force
$enginePath = Join-Path $outputPath 'engine'
$packageBuild = Join-Path $outputPath 'firmware-build'
$null = New-Item -ItemType Directory -Path $enginePath -Force
$null = New-Item -ItemType Directory -Path $packageBuild -Force
# Allowlist only application code; never copy private credentials or backups.
foreach ($name in @('ble_factory.py', 'device_prepare.py', 'device_flash.py', 'flash_app_bridge.py')) {
Copy-Item -LiteralPath (Join-Path $firmwareRoot "tools\$name") -Destination (Join-Path $enginePath $name) -Force
}
Copy-Item -LiteralPath $manifestPath -Destination (Join-Path $packageBuild 'flasher_args.json') -Force
foreach ($entry in $manifest.flash_files.PSObject.Properties) {
$relative = [string]$entry.Value
if ([IO.Path]::IsPathRooted($relative) -or ($relative -split '[/\\]') -contains '..') { throw 'Unsafe image path.' }
$imagePath = [IO.Path]::GetFullPath((Join-Path $buildPath $relative))
if (-not $imagePath.StartsWith($buildPath.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) { throw 'Image escapes build directory.' }
$target = Join-Path $packageBuild $relative
$null = New-Item -ItemType Directory -Path (Split-Path -Parent $target) -Force
Copy-Item -LiteralPath $imagePath -Destination $target -Force
}
$executable = Join-Path $outputPath 'EchoEarFlasher.exe'
& $compiler /nologo /target:winexe /platform:x64 /optimize+ /codepage:65001 `
/r:System.dll /r:System.Core.dll /r:System.Drawing.dll /r:System.Windows.Forms.dll /r:System.Web.Extensions.dll `
"/out:$executable" $sourcePath
if ($LASTEXITCODE -ne 0) { throw 'Application compilation failed.' }
$guide = Join-Path $PSScriptRoot '使用说明.txt'
if (Test-Path -LiteralPath $guide) { Copy-Item -LiteralPath $guide -Destination $outputPath -Force }
$records = Get-ChildItem -LiteralPath $outputPath -File -Recurse | Where-Object { $_.FullName -ne (Join-Path $outputPath 'package-files.json') } | ForEach-Object {
[ordered]@{ file = $_.FullName.Substring($outputPath.Length + 1); size = $_.Length; sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() }
}
[ordered]@{ application = 'EchoEar 烧录助手'; hardware = 'ESP32-S3 / 16MB'; built_at = (Get-Date).ToString('o'); files = @($records) } |
ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $outputPath 'package-files.json') -Encoding UTF8
Write-Output "Built: $executable"
@@ -0,0 +1,45 @@
EchoEar 烧录助手 — Windows 图形界面
启动
双击 EchoEarFlasher.exe。engine 和 firmware-build 文件夹必须与程序放在一起。
这台电脑已具备所需 Python 环境,无需打开终端。
适用设备:原 COM6 的 EchoEar ESP32-S3,16MB Flash,MAC B4:3A:45:16:34:A8。
恢复迁移后连接失败的设备
1. 打开“线上设备管理”,用运维账号登录。
2. 创建设备,型号 ECHOEAR-S3,下载 KP01-…-burn-in-v1.json。
3. 在助手里选择该 JSON,确认串口 COM6 和硬件 MAC。
4. 模式保持“重新初始化(保留固件)”。
5. 点击检查/预览,核对出厂编号、平台地址、写入区域。
6. 确认将清除旧 Wi-Fi 和语音配置,点击开始烧录并在确认窗口核对目标。
7. 等待检查设备 → 完整16MB备份 → 写入 → 校验完成。
8. 成功后手动复位或断电重启,再到家庭端/小程序重新蓝牙配网与绑定老人。
预览只处理电脑上的文件,不打开设备串口。只有点击开始烧录并确认才会写设备。
烧录过程中不要拔线或关闭程序;界面显示实际阶段,不显示虚构的进度百分比。
失败时查看提示,备份保留;重新检查并预览后再重试。
完整固件模式
需要重新刷整机固件时,选择完整固件模式。它会写配套Bootloader、分区表、
应用、OTA启动信息、语音模型和屏幕资源,同时清理旧运行状态,之后必须重新配网。
本包包含项目现有1.7.0-controls.28构建:离线文件校验通过,尚未完成实机验收。
本次云端清库后的身份恢复推荐保留现有固件,无需升级到.28。
完整模式必须使用一整套构建目录,不能随意选择单个应用.bin作为整机镜像。
初始化信息
设备编号、型号、平台地址和设备密钥来自后台JSON,无需手填密钥。
家庭Wi-Fi、账号归属和老人通过家庭端/小程序配置;不要写入火山主账号AK/SK。
本次云端已经清空记录,因此可按新设备重新建立身份。对于仍在使用的其他设备,
本地清配置不会替代云端解绑,需先处理归属。
文件与依赖
生成的工厂身份、完整Flash备份含设备秘密,保存在界面指定的本地私有目录。
程序不会把密钥显示在界面日志,不会自动上传备份,不会执行整片擦除。
本版本使用电脑上已安装的Python运行烧录引擎,不是自带Python的独立运行包。
默认查找:%USERPROFILE%\.espressif\python_env\idf5.5_py3.12_env\Scripts\python.exe。
若换电脑,可在高级设置选择Python 3.10+解释器;该解释器需有
esptool 4.x/5.x、esp-idf-nvs-partition-gen、pyserial。
本机已验证依赖版本:esptool 4.12.0、esp-idf-nvs-partition-gen 0.1.9、pyserial 3.5。
备份目录及初始化材料请妥善保存,勿提交Git或公开分享。
程序和模拟测试通过不等于真机烧录验收;本次交付没有替用户执行烧录。