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.
108 lines
7.4 KiB
JavaScript
108 lines
7.4 KiB
JavaScript
// 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; });
|