Files
story/scripts/export-private-device-handoff.cjs
T

108 lines
7.4 KiB
JavaScript
Raw Normal View History

// Local-only documentation export. Never print or copy a device key into the repository.
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { pathToFileURL } = require('node:url');
async function main() {
const input = path.resolve(process.argv[2] || '');
if (!process.argv[2] || path.basename(input) !== 'device-init.private.json') {
throw Error('Provide the local device-init.private.json path');
}
const repo = path.resolve(__dirname, '..');
const folder = path.dirname(input);
const relative = path.relative(repo, folder);
if (!relative.startsWith('..' + path.sep) && !path.isAbsolute(relative)) {
throw Error('Private export must be outside the repository');
}
const init = JSON.parse(fs.readFileSync(input, 'utf8').replace(/^\uFEFF/, ''));
if (init.factory_id !== 'KP01-020000260914' ||
init.origin !== 'https://82.157.201.114' ||
!/^[0-9a-f]{64}$/i.test(init.device_key_hex)) {
throw Error('Unexpected private identity; values not printed');
}
const fields = ['factory_id', 'model', 'origin', 'device_key_hex', 'key_version'];
const values = Object.fromEntries(fields.map(key => [key, init[key]]));
if (Object.values(values).some(value => value === undefined)) throw Error('Missing initialization field');
const source = path.join(repo, 'docs/embedded-device-business-guide-20260914.md');
const publicMarkdown = fs.readFileSync(source, 'utf8').replace(/\r\n/g, '\n');
if (publicMarkdown.includes(init.device_key_hex)) throw Error('Private key found in public source');
const sectionStart = publicMarkdown.indexOf('## 1. 设备需要烧录的字段');
const sectionEnd = publicMarkdown.indexOf('## 2. 蓝牙协议与名称约定');
if (sectionStart < 0 || sectionEnd < sectionStart) throw Error('Unexpected source structure');
const actualHeader = [
'# VoiceLink 设备完整接口文档(私有交接)', '',
'2026-09-15 · 设备接口专用版 · 蓝牙配网、绑定、语音聊天、MQTT 心跳、OTA', '',
'## 1. 实际需要烧录的内容', '',
'以下 5 项是平台已登记的这台独立联调设备的实际值,不是占位符。只给一台联调设备使用;这是测试身份,不是测得的实物 MAC,也不是最终硬件型号。不同实物需分别在平台登记。', '',
'```json', JSON.stringify(values, null, 2), '```', '',
'**本文件含真实设备密钥,仅在本机/受控交接中使用,不上传公开仓库或公开网页。**', '',
'| 烧录字段 | 中文解释与用途 |', '|---|---|',
'| factory_id | 设备唯一编号;设备接口的 device_id、请求头 X-Device-ID 使用它 |',
'| model | 平台登记的设备型号;广播名称和 OTA 型号核对使用它 |',
'| origin | 平台 HTTPS 地址 |',
'| device_key_hex | 解码成 32 字节设备密钥;用于现有 HTTP 请求签名,不是火山密钥 |',
'| key_version | 上述设备密钥的版本,当前为 1 |', '',
`本设备蓝牙名称:\`vl-${values.model}-${values.factory_id.slice(-8)}\`。由型号和设备编号拼接,不是额外烧录字段。`, '',
'固件另外带实际运行的 `firmware_version` 和数字 `version_code`;未对应平台发布记录时顺序号填 0。它们属于固件构建信息。hardware_revision 仅是库存信息,新设备接口无需烧录或上报。', '',
'不用烧录 Wi-Fi、账号、老人 ID、智能体 ID、火山凭据或 MQTT 密码。Wi-Fi 和 bind_token 由小程序发送;绑定成功后平台直接下发火山专属凭据;MQTT 配置联网获取。', '',
'平台:`https://82.157.201.114`。聊天继续按每通 `session_id` 关联火山回调,不改成固定老人 ID。', '',
'本文只保留设备直接参与的通信。阅读顺序:第 2–5 节配网绑定 → 附录 B 本地初始化 SDK(不请求平台登记)→ 第 6–7 节聊天 → 第 8–10 节 MQTT 心跳与 OTA。设备平台请求统一按附录 A 封装。示例时间、版本、任务 ID 和下载链接不能写死。', '',
].join('\n');
const cFolder = path.join(folder, 'device-protocol-c');
fs.mkdirSync(cFolder, { recursive: true });
const codeBlocks = ['vl_protocol.h', 'vl_protocol.c'].map(filename => {
const code = fs.readFileSync(path.join(repo, 'docs/device-protocol-c', filename), 'utf8');
fs.writeFileSync(path.join(cFolder, filename), code);
return '### ' + filename + '\n\n```c\n' + code.trimEnd() + '\n```\n';
}).join('\n');
const markdown = (actualHeader + '\n' + publicMarkdown.slice(sectionEnd))
.replace('<!-- VL_PROTOCOL_C_SOURCE -->', codeBlocks);
const { marked } = await import(pathToFileURL(require.resolve('marked')).href);
let jsonExamples = 0, tables = 0, headings = 0;
marked.walkTokens(marked.lexer(markdown), token => {
if (token.type === 'code' && token.lang === 'json') {
try { JSON.parse(token.text); } catch { throw Error('Invalid JSON example; content not printed'); }
jsonExamples++;
}
if (token.type === 'table') {
tables++;
if (token.rows.some(row => row.length !== token.header.length)) throw Error('Malformed table');
}
if (token.type === 'heading' && token.depth === 2) headings++;
if (token.type === 'link' && !/^(https?:|#)/.test(token.href)) {
if (!fs.existsSync(path.join(folder, decodeURIComponent(token.href.split('#')[0])))) {
throw Error('Missing handoff reference');
}
}
});
const requiredRoutes = ['ble-bind', 'ble-state', 'voice-config', 'chat/start', 'chat/end', 'mqtt-config', 'ota/report'];
for (const route of requiredRoutes) {
if (!markdown.includes('/api/v1/device/' + route)) throw Error('Missing required device route: ' + route);
}
if (/\/api\/v1\/(?:devices\/|ops\/)|X-Login-Portal|X-Tenant-Id|第 8–12 节/.test(markdown)) {
throw Error('Non-device interfaces or stale references found');
}
if (/\/api\/v1\/device\/(register|time-challenges|voice-runtime|activation-acks)|"boot_id"|CFG1|TIME1/.test(markdown)) {
throw Error('Removed startup handshake reappeared');
}
if (headings !== 13 || jsonExamples < 31 || !markdown.includes('/callbacks/v1/volcengine/chat?session_id=') ||
markdown.includes('固定老人回调方案调整')) throw Error('Incomplete or stale document');
const firstJSON = /```json\n([\s\S]*?)\n```/.exec(markdown);
if (!firstJSON || JSON.stringify(JSON.parse(firstJSON[1])) !== JSON.stringify(values)) {
throw Error('Actual initialization must be first JSON block');
}
const output = path.join(folder, '设备完整接口文档-20260915.private.md');
fs.writeFileSync(output, markdown, { mode: 0o600 });
if (fs.readFileSync(output, 'utf8') !== markdown) throw Error('Export mismatch');
// Refresh the old private entry point too, so it cannot retain the discarded elder callback proposal.
const shortOutput = path.join(folder, '设备实际烧录信息.private.md');
fs.writeFileSync(shortOutput, actualHeader + '\n完整流程见 [设备完整接口文档](设备完整接口文档-20260915.private.md)。\n', { mode: 0o600 });
console.log(JSON.stringify({ output, shortOutput, headings, jsonExamples, tables,
actualInitializationFirst: true, callbackMode: 'session_id', cHelpersIncluded: true,
sha256: crypto.createHash('sha256').update(markdown).digest('hex') }));
}
main().catch(() => { console.error('Private handoff export failed; no private values logged.'); process.exitCode = 1; });