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,558 @@
# BLE-PROV-1.0 蓝牙配网与账号绑定协议
版本:1.0 草案|日期:2026-09-13|面向:网页前端、设备固件、SaaS 后端
**协议选型:手机/电脑与设备之间使用 BLE GATT;安全层采用 ESP-IDF Protocomm Security 2(patch 1);业务层使用本协议定义的 UTF-8 JSON 消息。设备联网后,通过 HTTPS + DEVREQ1 设备认证完成账号绑定。**
本文是一份新增协议契约,不表示功能已经实现或完成实机联调。当前工作区为 ESP32-S3 EchoEar、ESP-IDF v5.5.1;固件仍使用配置热点,当前 sdkconfig 未启用蓝牙。本文新增的 UUID、BLE 帧、命令和 `/ble-*` 云端接口均为拟定值,可据此开展实现与评审;不会自动兼容原来的热点配网页。
“必须 / 不得”表示互操作或安全的必要条件;“建议”表示可按产品调整的默认值。所有字段、字节序及枚举按本文执行,不允许前端和固件各自解释。
## 1. 范围与参与方
目标流程:用户登录平台,发现并识别附近设备,建立安全蓝牙会话,发送 Wi-Fi 配置与一次性绑定凭证,设备确认操作后联网,由 SaaS 提交绑定,页面显示结果。
| 链路 | 协议 | 职责 |
|---|---|---|
| 网页 ↔ SaaS | HTTPS + 当前用户登录会话 | 签发绑定凭证、查询归属结果 |
| 手机/电脑 ↔ 设备 | BLE GATT + Security 2 + BLE-PROV JSON | 身份核对、配网参数传输、读取进度 |
| 设备 ↔ SaaS | HTTPS + DEVREQ1 HMAC-SHA256 | 证明设备身份、提交绑定、恢复结果 |
网页通过用户手机/电脑的蓝牙与设备通信。SaaS 服务器不直接扫描用户身边的蓝牙设备。网页是 BLE Central / GATT Client,设备是 Peripheral / GATT Server。GATT 的 Server 在此指设备,不是云端服务器。[参考 R1、R2]
本版包含首次绑定,不包含 OTA、音频传输、企业 Wi-Fi、设备转让和已绑定设备重配网。已绑定设备返回 `ALREADY_BOUND`;不能通过重新配网或恢复网络设置覆盖云端归属。AI 服务激活沿用平台独立流程,不作为蓝牙配网完成条件。
## 2. 标识与生产数据
| 字段 | 格式 | 含义 |
|---|---|---|
| `factory_device_id` | `KP01-` + 12 位大写十六进制 | 工厂身份,须与当前平台库存及 DEVREQ1 身份一致 |
| `platform_device_id` | UUID | SaaS 内部设备主键,绑定结果中返回 |
| `product_id` | uint32,1~4294967295 | 产品编号,平台维护编号与型号映射 |
| `model` | ASCII,1~32 字节 | 型号,例如 `ECHOEAR-S3`;这是示例命名,不是已登记型号 |
| `short_id` | 末 6 位工厂编号 | 只用于名称展示,不是认证依据,也不保证全局唯一 |
| `setup_nonce` | 16 个随机字节的 Base64URL,无 `=`,22 字符 | 本次实体配网窗口标识 |
| `session_id` | UUID v4 | SaaS 签发的绑定申请标识 |
| `request_id` | UUID v4 | 一次业务操作的幂等标识 |
| `mid` | uint16,1~65535 | 当前 BLE 连接内的一次请求/应答编号 |
产线必须写入工厂身份、产品编号、型号、现有的单设备云端认证密钥及版本,并生成独立的 SRP 配网凭据。不能用蓝牙地址、设备名称或用户登录密码作为密钥。
SRP 用户名固定为 `factory_device_id`,配网密码是每设备独立生成的 32 个随机字节的 Base64URL 字符串。这里的“用户名/密码”仅是本地安全握手凭据,与平台账号、Wi-Fi 密码无关。设备保存按官方算法生成的 16 字节 salt 和 verifier;二维码保存配网用户名和密码。二维码不得包含云端设备密钥。[参考 R3、R5]
二维码内容示意如下;尖括号内容必须在生产时替换,不能用于联调或生产认证:
```json
{
"v": 1,
"factory_device_id": "KP01-000000000001",
"product_id": 1001,
"setup_user": "KP01-000000000001",
"setup_password": "<每设备独立的43字符Base64URL配网密码>"
}
```
二维码采用设备本地可解析的数据,不把秘密放入网页 URL、查询参数或后台日志。前端仅在本次页面内存保存配网密码。本版要求扫码或输入该设备配网凭据;仅凭附近蓝牙名称不能完成安全认证。
## 3. 进入配网及设备发现
1. 用户在平台完成登录,选择“添加设备”。
2. 用户长按设备指定触摸区域或按键 3 秒;未绑定设备打开 300 秒配网窗口,生成新 `setup_nonce`,显示配网提示。
3. 设备发送可连接 BLE 广播;窗口内最多接受一个客户端连接。
4. 网页通过本协议 Service UUID 过滤设备,用户在浏览器设备选择框中选择目标。
5. 网页读取协议能力、建立安全会话,读取可信会话内的设备信息并核对二维码;随后可发送 `IDENTIFY` 让设备闪灯/显示识别动画。
广播名称为 `EchoEar-` + `short_id`,例如 `EchoEar-000001`。广播包包含 Flags 和完整的 128 位 Service UUID;完整名称放入 Scan Response,避免超过传统 31 字节广播包限制。不得要求浏览器在选择设备前读取任意厂家自定义广播字段。名称与信号强度只辅助选择;型号及身份以安全会话内信息、二维码和后台库存核对结果为准。
成功、窗口超时或用户退出后停止配网广播。设备普通重启不生成新的可绑定入口;需要明确的首次启用策略或上述实体操作。窗口到期后不得受理新配置,但已经受理的联网/云端确认任务继续收尾。BLE 断开不取消该任务。
## 4. GATT 服务与特征
以下为本协议拟定的固定 128 位 UUID,不是蓝牙 SIG 分配的标准配网服务。UUID 字符串按表使用;固件 SDK 若接收小端字节数组,必须按 SDK 规则转换并通过实际服务发现核对。[参考 R1]
| 名称 | UUID | 属性 | 数据与权限 |
|---|---|---|---|
| `PROVISION_SERVICE` | `8e7a1000-6f4b-4a2d-9c31-7d824f5e6100` | Service | 服务入口 |
| `PROTOCOL_INFO` | `8e7a1001-6f4b-4a2d-9c31-7d824f5e6100` | Read | 明文 16 字节协议能力,不含秘密 |
| `RX` | `8e7a1002-6f4b-4a2d-9c31-7d824f5e6100` | Write with response | 客户端发送握手或加密业务分片 |
| `TX` | `8e7a1003-6f4b-4a2d-9c31-7d824f5e6100` | Indicate | 设备返回对应应答分片,带标准 CCCD `0x2902` |
前端必须先启用 TX 的 Indication,再发送 RX。Web Bluetooth 对应 `startNotifications()` 和 `writeValueWithResponse()`;方法名中的 Notifications 不代表本特征必须设置 Notify,本协议 TX 只设置 Indicate。[参考 R1、R2]
设备逐片发送 Indication,收到 ATT confirmation 后再发送下一片。确认只证明对端蓝牙协议栈接收,不表示网页业务已处理。GATT 写成功也不表示配网或绑定成功。
`PROTOCOL_INFO` 固定 16 字节,所有多字节整数为小端:
| 偏移 | 长度 | 字段 | v1 值 |
|---:|---:|---|---|
| 0 | 2 | magic | `42 50`,ASCII `BP` |
| 2 | 1 | major | `01` |
| 3 | 1 | minor | `00` |
| 4 | 1 | security_version | `02` |
| 5 | 1 | security_patch | `01` |
| 6 | 2 | max_gatt_value | 20,即 `14 00` |
| 8 | 2 | max_record | 4096,即 `00 10` |
| 10 | 2 | capabilities | `01 00`:支持实体确认;其余位必须为 0 |
| 12 | 4 | product_id | uint32,例如 1001 为 `E9 03 00 00` |
客户端只接受本文的 major、minor、security_version、security_patch 组合;不兼容时显示升级提示并断开。本特征未经认证,不能用于决定降级加密或证明设备型号。后续 `GET_INFO` 必须再次返回并核对这些能力。
## 5. 安全会话
### 5.1 固定安全配置
采用 ESP-IDF **v5.5.1 的 Protocomm Security 2,patch 1**:SRP6a 双向证明建立会话,业务使用 AES-256-GCM。握手消息严格使用该版本 `session.proto`、`sec2.proto` 及其依赖定义;算法、大整数编码和证明校验以同版本官方实现为互操作依据。[参考 R3~R6]
不得使用 Security 0,不得回退 Security 1,不得接受 Security 2 patch 0。BLE 链路配对可以作为附加保护,但不能替代此安全会话。设备不得把单设备云端认证密钥发给网页。
### 5.2 握手交换
双方建立 BLE 连接后,设备创建独立 protocomm transport session,并注册两个逻辑 endpoint:`prov-session` 和 `prov-data`。它们是固件内部路由名,不是 HTTP 路径或新增 GATT UUID。
| 次序 | 客户端请求 | 设备应答 | 本协议帧 SECURITY 位 |
|---|---|---|---|
| 1 | `SessionData / SecScheme2 / S2Session_Command0` | `S2Session_Response0` | 1 |
| 2 | `SessionData / SecScheme2 / S2Session_Command1` | `S2Session_Response1` | 1 |
| 3 | 已加密的 `GET_INFO` JSON | 已加密的设备信息 JSON | 0 |
每个 protobuf 请求/应答作为一个完整 record,再按第 6 节分片。收到设备证明并验证成功前,前端不得发送 Wi-Fi 或绑定凭证。设备在握手完成前收到业务帧必须拒绝并断开。
### 5.3 业务加密及顺序
业务 record 是官方 Security 2 加密函数输出的 `ciphertext || 16-byte GCM tag`,不额外 Base64,不附加自定义 nonce。会话 nonce、密钥取得及计数器行为必须与 patch 1 一致:握手返回的 12 字节 nonce 由 8 字节会话值和 4 字节大端计数器组成;计数器从 1 开始,每完成一次加密或解密后递增。客户端请求、设备应答共用这条有序计数序列。[参考 R5、R6]
因此每个连接只能有一个未完成请求;设备不得主动插入加密进度推送。进度由客户端每 2 秒发送 `GET_STATUS` 获取,设备仍通过 TX Indication 返回。禁止同时轮询和发送配置,也不能跳过某条响应后继续发送下一条请求。
断线、解密失败、请求或应答传输超时后销毁安全会话,重新连接并握手。不得在原会话上自行重发整条密文或重置计数器。重新握手使用新随机数和新密钥;恢复业务通过原 `request_id` 和云端 `session_id` 实现。连续 5 次握手失败后设备暂停接受握手 60 秒。
## 6. BLE 分片格式与收发规则
v1 固定每次 GATT Value **不超过 20 字节**,即使链路协商了更大 ATT MTU 也不改变。这样网页不需要读取或控制 MTU。20 字节含 8 字节本协议头,单片净载荷最多 12 字节。
| 偏移 | 长度 | 字段 | 定义 |
|---:|---:|---|---|
| 0 | 1 | magic | 固定 `A5` |
| 1 | 1 | flags | bit0 START;bit1 END;bit2 RESPONSE;bit3 SECURITY;bit4~7 必须为 0 |
| 2 | 2 | mid | uint16 小端;请求从 1 递增,应答原值回显 |
| 4 | 2 | total_len | uint16 小端,完整 record 长度,1~4096 |
| 6 | 2 | offset | uint16 小端,本片载荷在 record 中的字节偏移 |
| 8 | 1~12 | payload | record 的这一段原始字节;不得按字符切割 |
规则:
1. 每个请求占用一个 `mid`,握手也占用;同连接下不回绕,达到 65535 后完成应答再重连。
2. 客户端 RESPONSE=0,设备 RESPONSE=1;双方相同交换使用相同 SECURITY 位。SECURITY=1 是握手 protobuf,不是“已经加密”;SECURITY=0 是安全会话中的加密业务 record。
3. 首片 offset=0 且 START=1;末片必须满足 offset+payload_len=total_len 且 END=1。单片完整消息 START、END 同时为 1。
4. 除末片外 payload 必须恰好 12 字节,offset 每次增加 12。相同 record 的 mid、total_len、方向及 SECURITY 位必须一致。接收端只保留一个在组装 record。
5. 必须检查长度上限、越界、空载荷、保留位及次序。重复、缺失或错序片不得交给业务处理;结束连接并通过新会话恢复,不能猜测拼接。
6. 完整重组以后才调用 protobuf 或安全解密。加密业务 record 至少 17 字节;解密前必须检查这一点,避免把不足 GCM tag 长度的输入交给 SDK。
7. 每片按蓝牙栈能力使用 GATT 应答/确认推进。本协议不另外增加分片 ACK、CRC 或重传命令;完整业务由 GCM 校验完整性。传输失败按第 5.3 节重连。
8. 完整业务 JSON 的 `mid` 必须等于帧头 mid;响应还须匹配原 request_id 和 cmd。帧头不作为身份依据。
仅用于分片算法校验的向量:record 是 ASCII `0123456789ABCDEFGHIJ`,共 20 字节;mid=1,方向为请求,SECURITY=1。它不是合法握手 protobuf,也不能用于发送真实配网数据。
```text
第一片(20字节):a509010014000000303132333435363738394142
第二片(16字节):a50a010014000c00434445464748494a
```
product_id=1001 的 PROTOCOL_INFO 向量:
```text
425001000201140000100100e9030000
```
## 7. 业务 JSON 公共结构
JSON 使用 UTF-8、无 BOM;禁止重复键、非有限数字、额外顶层值和未知字段。最大明文 4080 字节,嵌套最多 8 层。长度以编码后的字节数计。所有 UUID 使用标准小写带连字符格式;Base64URL 无填充。新增字段或修改含义必须升级协议,不能静默改变 v1。
请求统一结构:
```json
{
"v": 1,
"mid": 3,
"request_id": "11111111-1111-4111-8111-111111111111",
"cmd": "GET_INFO",
"params": {}
}
```
响应统一结构;成功时 error=null,失败时 result=null,错误结构固定为 code、message、retryable:
```json
{
"v": 1,
"mid": 3,
"request_id": "11111111-1111-4111-8111-111111111111",
"cmd": "GET_INFO",
"ok": false,
"result": null,
"error": {
"code": "SETUP_EXPIRED",
"message": "配网窗口已结束",
"retryable": false
}
}
```
只有能完整解密并解析公共字段时才发送业务错误;无法认证或解析公共结构的消息直接断开,不能返回未加密业务错误。`message` 仅作展示,前端判断依据是稳定的 `code`。
## 8. 命令定义
### 8.1 GET_INFO:读取设备身份
params 固定为 `{}`。成功 result:
```json
{
"factory_device_id": "KP01-000000000001",
"product_id": 1001,
"model": "ECHOEAR-S3",
"firmware_version": "<固件实际版本>",
"protocol": {"major": 1, "minor": 0, "security_version": 2, "security_patch": 1},
"wifi_bands": ["2.4GHz"],
"wifi_security": ["WPA2_PSK"],
"binding_state": "UNBOUND",
"setup_nonce": "AAECAwQFBgcICQoLDA0ODw",
"setup_expires_in_s": 240,
"key_version": 1,
"setup_proof": "<64字符小写十六进制HMAC>"
}
```
`firmware_version` 为 ASCII 1~64 字节。`binding_state` 为 UNBOUND / BOUND / UNKNOWN;UNKNOWN 时后端仍须检查实际归属,不以设备自报未绑定作为授权。窗口剩余时间使用设备单调时钟。`setup_nonce` 在同一窗口重连时保持不变,重新进入窗口必须刷新。
setup_proof 用现有原始 32 字节设备认证密钥计算 HMAC-SHA256,结果小写 hex。签名正文是以下五行 UTF-8,LF 分隔,末尾无 LF;数字按无前导零十进制编码:
```text
BLESETUP1
{factory_device_id}
{key_version}
{product_id}
{setup_nonce}
```
这是本协议新增的域隔离证明,用于后台核验客户端拿到了设备生成的窗口信息。它不是绑定授权,也没有独立的可信时间;真正提交时必须再次校验当前窗口、设备请求认证和实体确认。网页核对二维码中的工厂编号及 product_id,不匹配时终止。
### 8.2 IDENTIFY:识别实体设备
params 为 `{"duration_ms":3000}`,允许 1000~5000。设备闪灯或显示识别动画,返回 `{"accepted":true}`。设备不输出账号或网络秘密;同连接最多每 5 秒执行一次。硬件没有可用指示能力时返回 `IDENTIFY_UNSUPPORTED`。
### 8.3 CONFIGURE:提交 Wi-Fi 与账号绑定凭证
params 示例;claim_token 是占位值,真实值由 SaaS 生成:
```json
{
"session_id": "22222222-2222-4222-8222-222222222222",
"factory_device_id": "KP01-000000000001",
"setup_nonce": "AAECAwQFBgcICQoLDA0ODw",
"claim_token": "<43字符Base64URL一次性令牌>",
"wifi": {
"ssid_b64": "TXlIb21l",
"security": "WPA2_PSK",
"password": "example-password",
"hidden": false
}
}
```
| 参数 | 必须 | 校验 |
|---|---|---|
| session_id | 是 | UUID v4,与云端申请一致 |
| factory_device_id | 是 | 必须等于本机身份 |
| setup_nonce | 是 | 必须等于当前有效实体配网窗口 |
| claim_token | 是 | Base64URL 解码为 32 字节;不包含平台登录密码或长期登录令牌 |
| wifi.ssid_b64 | 是 | Base64URL,解码后 1~32 字节;此例解码为 `MyHome` |
| wifi.security | 是 | v1 仅 `WPA2_PSK` |
| wifi.password | 是 | 8~63 个可打印 ASCII 字节;v1 不支持 64 位十六进制原始 PSK |
| wifi.hidden | 是 | boolean;true 表示按给定 SSID 主动连接隐藏网络 |
网页输入的常规 SSID 按 UTF-8 编码后再 Base64URL。设备最终向 Wi-Fi 驱动传原始 SSID 字节和明确长度,不把 SSID 当以零结尾的字符串。v1 不支持开放网络、WPA3-only、WPA-Enterprise 或 5GHz;WPA2/WPA3 混合网络只有允许 WPA2-PSK 时才可尝试。其他安全模式必须通过新版本扩展,不能当作 WPA2 强行连接。
设备完成校验并把待处理操作安全写入持久存储后,返回:
```json
{
"accepted": true,
"config_request_id": "33333333-3333-4333-8333-333333333333",
"stage": "WAITING_CONFIRMATION"
}
```
`config_request_id` 必须等于 CONFIGURE 公共结构中的 request_id。此响应表示已受理,不表示联网成功。设备显示“确认本次配网与绑定”,用户在 60 秒内短按指定实体区域确认;固件生成 press_event_id(UUID v4),把该事件关联本次 session_id、config_request_id、setup_nonce 并持久化,才开始联网。先前进入窗口的长按不能复用作这次申请确认。
同一设备同时只运行一个配置任务。活动任务期间新 request_id 的 CONFIGURE 返回 BUSY;相同 request_id 且相同 params 返回原任务当前结果,不重复落盘或联网;相同 request_id 但 params 不同返回 REQUEST_CONFLICT。幂等摘要只包含 cmd 与 params 的确定性字段值,不包含会随重连变化的 mid;实现可用有序字段编码,不能依赖不同 JSON 空白/键序。
凭证在受保护存储中保存为待处理任务;Wi-Fi 取得 IP 后再提交为可用网络配置,失败保留旧网络配置。账号绑定以服务器事实为准。Wi-Fi 密码不上传 SaaS、不回显、不写业务日志;终态后清除临时 claim_token 和待处理 Wi-Fi 副本。
### 8.4 GET_STATUS:查询进度和恢复
params 为 `{"config_request_id":"33333333-3333-4333-8333-333333333333"}`。每次查询使用新的公共 request_id 与新的 mid;被查询的 config_request_id 保持不变。
成功 result 的固定字段如下;尚无结果时 binding_id 和 platform_device_id 为 null,未绑定 generation=0:
```json
{
"config_request_id": "33333333-3333-4333-8333-333333333333",
"session_id": "22222222-2222-4222-8222-222222222222",
"stage": "BINDING",
"wifi_state": "CONNECTED",
"binding_state": "UNKNOWN",
"local_saved": false,
"platform_device_id": null,
"binding_id": null,
"binding_generation": 0,
"error": null
}
```
wifi_state 为 NOT_STARTED / CONNECTING / CONNECTED / FAILED;binding_state 为 UNBOUND / UNKNOWN / BOUND。`error` 为 null 或第 7 节统一错误对象。stage=FAILED 时必须带错误;stage=PENDING_RECONCILIATION 时可带暂时故障。查询到失败任务的外层 ok 仍为 true,因为查询本身成功。
无本地记录返回 STATUS_NOT_FOUND,此时页面应查询云端 session_id。断线重连后的状态查询须先完成新的安全握手,并仍在原窗口内;窗口已关闭时页面通过云端查询。云端提交成功但设备未保存时,页面应显示“已绑定,设备正在同步”,不能提示用户重新绑定。
### 8.5 FINISH:确认页面已收到结果
params 为 `{"config_request_id":"33333333-3333-4333-8333-333333333333"}`。仅 stage=SUCCESS 时允许,返回 `{"accepted":true,"close_after_ms":3000}`。设备必须先发送完整响应,再于 3 秒后结束 BLE 配网。
没有 FINISH 时,设备成功后保留 BLE 最多 30 秒用于读取结果,随后自行关闭。FINISH 只结束本地会话,不新增、取消或修改云端绑定。v1 不提供在途取消命令;网络异常不能被当作取消成功。
## 9. 云端绑定契约(新增)
以下四个路径均为拟新增接口,不与现有 `/api/v1/web/claims` 或 `/api/v1/device/claim-confirmations` 混用。成功响应固定包含 `ok=true`、`code="OK"`、request_id、server_time_ms(Unix 毫秒整数)及 data;失败响应固定包含 `ok=false`、code、message、request_id,不包含 data。这与当前平台封装一致,不能用 BLE 的 result/error 外壳解析 HTTP 响应。data 字段定义见下文,业务错误码见第 11 节。
### 9.1 创建绑定申请
`POST /api/v1/web/ble-setup-sessions`,使用当前客户登录 Cookie、`Content-Type: application/json`、`X-CSRF-Token` 及 X-Request-ID(UUID v4)。CSRF token 从已有 `GET /api/v1/web/account/session` 获取,服务端执行既有同源及账号 CSRF 校验。body:
```json
{
"contract": "BLE-PROV-1.0",
"factory_device_id": "KP01-000000000001",
"product_id": 1001,
"key_version": 1,
"setup_nonce": "AAECAwQFBgcICQoLDA0ODw",
"setup_proof": "<GET_INFO返回的64字符HMAC>",
"profile_id": "44444444-4444-4444-8444-444444444444"
}
```
profile_id 是平台使用者档案,必须属于当前账号有权管理的家庭/租户。账号主体由服务端从登录态取得,禁止请求方提交 user_id、owner_id、手机号或密码来决定归属。
服务端检查库存放行、型号、密钥版本、setup_proof、归属及档案权限。成功 HTTP 201 的 data 为:
```json
{
"session_id": "22222222-2222-4222-8222-222222222222",
"claim_token": "<CSPRNG生成的32字节Base64URL令牌>",
"expires_in_s": 300,
"binding_state": "PENDING"
}
```
令牌绑定当前账号、租户/家庭、profile_id、设备、setup_nonce、预期 binding_generation;只有设备认证请求能兑换。普通业务记录保存令牌哈希。相同 X-Request-ID 的创建重试必须返回同一申请和原有效令牌:可以在单独的受保护短期存储保存加密响应至到期,不把令牌放进普通幂等缓存。所有令牌响应加 `Cache-Control: no-store`。
### 9.2 设备提交绑定
`POST /api/v1/device/ble-claims`,设备联网、完成实体确认及可信校时后调用,使用第 10 节 DEVREQ1。body:
```json
{
"contract": "BLE-PROV-1.0",
"session_id": "22222222-2222-4222-8222-222222222222",
"claim_token": "<CONFIGURE收到的一次性令牌>",
"setup_nonce": "AAECAwQFBgcICQoLDA0ODw",
"config_request_id": "33333333-3333-4333-8333-333333333333",
"press_event_id": "55555555-5555-4555-8555-555555555555"
}
```
服务端必须在同一事务中锁定设备和申请,核验:认证设备与申请相同、令牌正确、申请未过期/撤销、窗口标识匹配、目标权限仍有效、实体确认事件对应本次申请、绑定代数未变化且不存在其他有效归属。完成后消费令牌、建立绑定、保存 generation 和申请终态,并安排独立 AI 激活任务。press_event_id 只能确认一个 session_id;这是可信固件上报的实体操作证据,不是服务器独立读取到了 GPIO。
HTTP 200 的 data:
```json
{
"session_id": "22222222-2222-4222-8222-222222222222",
"config_request_id": "33333333-3333-4333-8333-333333333333",
"platform_device_id": "66666666-6666-4666-8666-666666666666",
"binding_id": "77777777-7777-4777-8777-777777777777",
"binding_generation": 1,
"binding_state": "BOUND"
}
```
相同申请、设备、配置请求和 press_event_id 的重试返回原提交结果,不再创建绑定;先检查是否为已提交的同一业务,再判断未提交申请的到期时间。若期间发生解绑、换绑或设备吊销,返回 STATE_CHANGED,不能把旧绑定当作当前有效结果。不得以“令牌已消费”为由让已成功的设备无限重绑。
### 9.3 页面查询与设备恢复
| 接口 | 认证 | 输入 | data |
|---|---|---|---|
| `GET /api/v1/web/ble-setup-sessions/{session_id}` | 原创建账号登录态 | 路径 session_id | session_id、status、binding_state、config_request_id、platform_device_id、binding_id、binding_generation、error |
| `POST /api/v1/device/ble-setup-status` | DEVREQ1 | `{"session_id":"…","config_request_id":"…"}` | 同上,只允许申请对应设备查询 |
status 为 PENDING / COMMITTED / EXPIRED / REJECTED。PENDING 时 binding_state=UNKNOWN;COMMITTED 时 binding_state=BOUND;其余状态按实际事实返回,不能从“申请失败”推导设备不存在其他绑定。没有值的 config_request_id、设备及绑定 UUID 为 null;generation 未知/未绑定为 0,error 为 null 或统一错误对象。
页面每 2 秒查询,120 秒仍未确认则显示“等待设备确认”,后续由用户刷新或每 10 秒查询;不自动生成新申请。SaaS 未收到设备 claim 时不知道其本地 Wi-Fi 阶段,云端接口不得伪造“密码错误”等本地进度。
设备在提交前持久化 session_id、config_request_id、press_event_id 及 pending 标记。POST 响应丢失时查询同一申请;重启后先恢复原任务并查询云端,COMMITTED 则保存既有结果。只清理本地 Wi-Fi 不能撤销已经提交的账号绑定。
## 10. 设备 HTTPS 请求认证
复用当前 `internal/onboarding/crypto.go` 与 `auth.go` 的 DEVREQ1,不使用普通用户 Bearer token,也不使用名为 `Device-Certificate` 的自定义 Authorization 占位值。
必填请求头:
```text
Content-Type: application/json
X-Device-ID: KP01-000000000001
X-Key-Version: 1
X-Timestamp-Ms: <Unix毫秒时间,十进制>
X-Nonce: <16随机字节的22字符Base64URL>
X-Request-ID: <UUID v4>
Authorization: Device-HMAC-SHA256 <64字符小写hex签名>
```
签名为 `hex_lower(HMAC_SHA256(raw_device_key_32bytes, canonical_UTF8))`。canonical 固定九行,LF 分隔且末尾无 LF:
```text
DEVREQ1
POST
{请求路径,不带query}
{X-Device-ID}
{X-Key-Version}
{X-Timestamp-Ms}
{X-Nonce}
{X-Request-ID}
{实际HTTP原始body字节的SHA256小写hex}
```
密钥是原始 32 字节,不是 hex 字符串的 UTF-8 字节。允许时间误差 ±120 秒;nonce 防重放至少保留 5 分钟。重试保持相同业务 request_id 和原 body 字节,但必须更新时间、nonce 并重新签名。body 上限 8 KiB,不能携带 Wi-Fi 密码。
设备必须验证 TLS 服务端证书和预置/受控配置的 SaaS 域名,禁止通过 BLE 任意替换目标服务器。没有可信时间时复用既有 time-challenges 的签名校时契约,随后严格验证 HTTPS;不得为解决时间问题关闭证书校验。现有校时细节以工作区 `openapi/onboarding.yaml` 与实现为准。[参考 R7]
## 11. 状态、错误与超时
### 11.1 本地任务状态
| stage | 含义 | 下一步 |
|---|---|---|
| WAITING_CONFIRMATION | 已安全保存配置,等待本次实体确认 | 确认后 WIFI_CONNECTING;超时 FAILED |
| WIFI_CONNECTING | 正在关联路由器及获取 IP | 成功 CLOUD_CONNECTING;确定失败 FAILED |
| CLOUD_CONNECTING | 已取得 IP,准备可信校时和 HTTPS | BINDING 或网络故障 |
| BINDING | 已开始向云端提交账号归属 | SAVING_RESULT、确定拒绝 FAILED,或结果不明 |
| PENDING_RECONCILIATION | 请求可能已提交,暂时无法确认 | 查询同一 session,不能自动重绑 |
| SAVING_RESULT | 已确认云端绑定,保存到设备 | SUCCESS 或 LOCAL_SAVE_FAILED |
| SUCCESS | Wi-Fi 可用、云端 BOUND、本地保存完成 | FINISH / 自动关闭 |
| FAILED | 本次确定失败;详见 error | 修复后重新申请或重试允许的阶段 |
LOCAL_SAVE_FAILED 用 stage=FAILED、binding_state=BOUND、local_saved=false 表示;只修复本地保存,不能解除/重复建立绑定。Wi-Fi 成功与账号成功分别展示,AI 激活失败不改写 SUCCESS。
### 11.2 错误码
| code | 含义 | 处理 |
|---|---|---|
| UNSUPPORTED_VERSION | 版本不兼容 | 升级客户端/固件,不能降级安全机制 |
| AUTH_FAILED | SRP 认证失败 | 检查设备二维码;握手层失败直接断开 |
| INVALID_FRAME | 分片不合法 | 断开并新建安全会话 |
| INVALID_PARAM | 字段、长度或格式不合法 | 修正参数 |
| DEVICE_MISMATCH | 选择设备与目标不一致 | 重新识别设备 |
| SETUP_EXPIRED | 配网窗口已关闭 | 再次实体进入配网 |
| CONFIRM_TIMEOUT | 60 秒内未确认当前申请 | 重新提交新申请,旧申请不自动执行 |
| BUSY | 已有进行中的配置任务 | 查询原任务 |
| REQUEST_CONFLICT | 相同请求号对应不同参数 | 修正调用方幂等实现 |
| STATUS_NOT_FOUND | 设备没有该本地任务 | 查云端申请,再决定恢复 |
| IDENTIFY_UNSUPPORTED | 无可用识别指示器 | 按铭牌/二维码核对 |
| WIFI_NOT_FOUND | 未发现目标网络 | 检查 SSID、频段和距离 |
| WIFI_AUTH_FAILED | 驱动明确报告认证失败 | 检查密码及路由器认证方式 |
| WIFI_CONNECT_FAILED | 原因无法精确归类 | 显示连接失败,不能一律报密码错误 |
| WIFI_IP_TIMEOUT | 已尝试关联但未及时取得 IP | 检查 DHCP/路由器 |
| WIFI_SECURITY_UNSUPPORTED | 非本版支持的网络类型 | 使用支持网络或升级协议 |
| CLOUD_UNREACHABLE | 云端暂时不可达 | 按是否可能提交进入重试或对账 |
| TOKEN_INVALID / TOKEN_EXPIRED | 一次性令牌非法或未提交即到期 | 登录页面重新申请 |
| ALREADY_BOUND | 设备已有有效归属 | 进入独立解绑/转移流程 |
| PERMISSION_DENIED | 当前账号/目标档案权限不满足 | 修正账号或档案选择 |
| DEVICE_NOT_RELEASED | 设备未放行、停用或凭据无效 | 由平台处理库存/凭据 |
| STATE_CHANGED | 提交期间权限或绑定代数变化 | 查询当前归属 |
| LOCAL_SAVE_FAILED | 本地持久化失败 | 保留云端结果,修复保存 |
| INTERNAL_ERROR | 内部处理失败 | 记录不含秘密的关联 ID,按状态恢复 |
AUTH_FAILED、INVALID_FRAME 可由客户端根据断开阶段归类,不要求设备在未认证通道发送错误 JSON。云端 HTTP 状态约定:400 参数/协议错误;401 未登录/设备认证失败;403 无权限/未放行;404 申请不存在或不可见;409 已绑定/请求冲突/状态变化;410 申请过期;429 限流;503 暂时不可用。业务 code 优先于 message。
### 11.3 默认时限
| 项目 | 默认值 | 超时行为 |
|---|---:|---|
| 新配置受理窗口 | 300 秒 | 停止受理;已开始任务继续 |
| BLE 连接后未完成握手 | 90 秒 | 断开并销毁会话 |
| 相邻分片无进展 | 10 秒 | 断开;不能继续使用原安全会话 |
| 一条 record 传输 | 45 秒 | 断开 |
| 完整请求后设备开始应答 | 5 秒 | 断开后查询/恢复;配置后台异步执行 |
| 未挂起请求的连接空闲 | 90 秒 | 断开;不取消任务 |
| 配置后的实体确认 | 60 秒 | 本地确认超时,不提交云端 |
| Wi-Fi 关联 / DHCP | 45 秒 / 15 秒 | 确定失败,保留旧网络 |
| 云端请求单次 | 15 秒 | 按同一申请重试/查询 |
| 云端自动重试总窗口 | 90 秒 | 不确定则转 PENDING_RECONCILIATION |
| 令牌有效期 | 服务端签发起 300 秒 | 不接受新的绑定提交;已提交结果仍可查询 |
| 成功后等待 FINISH | 30 秒 | 关闭 BLE,保留绑定与网络 |
云端重试退避为 1、2、4、8、16 秒,之后最多每 16 秒一次,可增加不超过 20% 的随机抖动;确定的权限、设备归属或参数错误不自动重试。PENDING_RECONCILIATION 后设备在网络可用时每 30 秒查询原申请,得到 COMMITTED 或确定终态后停止。物理窗口结束不阻止设备恢复已有申请,但不能据此新建绑定。
## 12. 实现边界与现有系统衔接
| 端 | 需要实现的内容 |
|---|---|
| 前端 | 登录态复用、浏览器能力检查、扫码、GATT 连接/订阅、分片、Security 2 客户端、命令串行队列、状态与断线恢复 |
| 设备 | BLE 广播和 GATT 服务、分片重组、自定义 protocomm transport 适配、实体确认、Wi-Fi 后台任务、DEVREQ1、受保护持久化 |
| 后端 | 四个新增接口、BLESETUP1 校验、短期令牌、事务绑定与幂等、权限复查、状态恢复、审计 |
| 产线 | 库存身份映射、独立 SRP 凭据/二维码、云端单设备密钥烧录、放行与秘密保护 |
设备 RX 回调只做有界校验、复制和投递工作队列,不能在蓝牙回调中等待 Wi-Fi/DHCP/HTTPS。完整 SECURITY record 交给 `prov-session`,完整业务密文交给 `prov-data`;通过 `protocomm_req_handle` 处理,由 protocomm 完成业务解密、handler 调用与响应加密,外层再分片返回,不能额外加密第二遍。[参考 R3]
本版使用**自定义 GATT 分片 transport + 官方 protocomm 安全层**,不是原生 `wifi_prov_mgr` 的 BLE 线协议。不能直接拿 ESP Provisioning 通用 App 与本文 UUID 通信;前端及调试工具需要本协议适配。官方 Security 2 Python 客户端可作为握手互操作对照,但其默认 transport 不理解本文帧头。
首次落地目标为支持 Web Bluetooth 的桌面/Android 浏览器。页面必须 HTTPS 且发现设备由用户点击触发;浏览器负责设备选择。Safari/iPhone 不能直接承诺网页蓝牙可用,需要小程序或原生客户端适配同一 BLE 协议,实际支持矩阵以验收为准。[参考 R2、R8]
当前平台已存在 ONBOARDING-1.1 的 DEVREQ1 及实体确认绑定流程;旧设计文档的“接口未实现”不能作为当前状态依据。本协议保留其原始设备密钥格式与当前归属约束,但新增 BLE 入口和令牌交换,不修改旧客户端字段或允许绕过实体确认。应新增明确的接入契约配置;当前工厂身份查询还限制 enrollment_mode,适配时必须显式允许本契约并保留设备/租户门禁,不能简单放开所有设备。
现有测试板的 `echoear-…` 硬件标识与 ONBOARDING 使用的 `KP01-…` 工厂身份不是同一个字段。量产/迁移时建立明确映射;不能由前端截断 MAC 临时制造已认证身份。当前 sdkconfig 中 `CONFIG_BT_ENABLED` 未开启,需要固件开发与内存/射频共存验证,这不是仅修改网页即可实现的功能。
## 13. 联调验收
以下是实施后的验收要求,不代表已经通过。正式发布前必须有浏览器和实机证据。
| 场景 | 必须观察到的结果 |
|---|---|
| 正常首次配网 | 识别正确设备,实体确认后联网;云端只有一条有效绑定;本地保存后 SUCCESS |
| 附近两台同型号 | 按短编号/二维码选择,认证失败时绝不发送 Wi-Fi;IDENTIFY 指示所选设备 |
| 官方安全互操作 | 前端与 v5.5.1 Security 2 patch 1 双向握手、加解密一致,错误配网密码失败 |
| 降级及篡改 | Security 0/1、patch 0、篡改密文、非法分片全部拒绝,无配置副作用 |
| 默认 MTU | 20 字节 Value 下完成最长合法配置;不能依赖 MTU=247 |
| 分片边界 | 第 6 节向量重组正确;测试 1、12、13、4096 字节 record;超长、重复、越界拒绝 |
| 串行加密 | GET_STATUS 不与 CONFIGURE 并发;超时必须重握手,不能重置 nonce 续传 |
| Wi-Fi 错误 | 认证失败、无网络、DHCP 超时分别处理;不删除原可用配置 |
| 重复配置 | 相同 request_id 和参数只执行一次;不同参数返回 REQUEST_CONFLICT |
| BLE 中途断开 | 云端任务不被取消;新安全会话或云端页面可以恢复原状态 |
| 请求已提交但回包丢失 | 查询原 session 得到原绑定,不产生第二条绑定 |
| 绑定后断电/落盘失败 | 云端 BOUND 保留,设备恢复保存,页面不要求重新绑定 |
| 令牌过期/跨设备 | 未提交的过期申请被拒绝,其他设备不能兑换令牌 |
| 账号越权/并发抢绑 | 无权限 profile 被拒绝;并发申请最多一个提交成功 |
| 未实体确认 | 不能联网后自动替用户提交新绑定;确认事件不能复用于另一申请 |
| 已绑定/恢复出厂 | BLE 首次绑定入口不覆盖已有云端归属 |
| 秘密检查 | 广播、日志、云端配网 body、浏览器持久存储中无 Wi-Fi 密码和配网秘密 |
| AI 激活失败 | 页面保持“已绑定”,AI 激活单独重试 |
## 14. 依据与规范性引用
- R1:[Bluetooth SIG:GATT 规范](https://www.bluetooth.com/wp-content/uploads/Files/Specification/HTML/Core-61/out/en/host/generic-attribute-profile--gatt-.html)。GATT 读写、特征与 indication 的标准依据;本文业务 UUID、分片和 JSON 是自定义内容。
- R2:[Chrome:Web Bluetooth 开发文档](https://developer.chrome.com/docs/capabilities/bluetooth)。浏览器访问约束及 GATT 调用方式。
- R3:[ESP-IDF v5.5.1 / ESP32-S3:Protocomm](https://docs.espressif.com/projects/esp-idf/en/v5.5.1/esp32s3/api-reference/provisioning/protocomm.html)。安全组件与 endpoint/transport 集成方式。
- R4:[session.proto](https://github.com/espressif/esp-idf/blob/v5.5.1/components/protocomm/proto/session.proto)、[sec2.proto](https://github.com/espressif/esp-idf/blob/v5.5.1/components/protocomm/proto/sec2.proto)、[constants.proto](https://github.com/espressif/esp-idf/blob/v5.5.1/components/protocomm/proto/constants.proto)。握手 wire schema 以该固定 tag 及导入依赖为准。
- R5:[官方 Security 2 客户端](https://github.com/espressif/esp-idf/blob/v5.5.1/tools/esp_prov/security/security2.py)、[SRP6a 实现](https://github.com/espressif/esp-idf/blob/v5.5.1/tools/esp_prov/security/srp6a.py)。客户端握手、凭据生成与密码学编码的互操作参考。
- R6:[官方 Security 2 固件实现](https://github.com/espressif/esp-idf/blob/v5.5.1/components/protocomm/src/security/security2.c)。patch 1、nonce 顺序、GCM 输出与安全会话行为。
- R7:本地现有实现:[DEVREQ1](../internal/onboarding/crypto.go)、[设备认证](../internal/onboarding/auth.go)、[当前路由](../internal/onboarding/http.go)、[Onboarding API](../openapi/onboarding.yaml)、[现有认证测试向量](../test/fixtures/onboarding-devreq1.json)。这些已有契约不能与新增 BLE 接口混为一谈。
- R8:[WebKit:未实现的相关能力说明](https://webkit.org/tracking-prevention/)。网页客户端覆盖范围的限制依据。
- 设备基线:[EchoEar 固件说明](../firmware/echoear-platform-agent/README.md)、[当前 sdkconfig](../firmware/echoear-platform-agent/sdkconfig)。工作区读取日期为 2026-09-13,未将本协议部署到设备或服务器。
修订记录:1.0 草案确定 BLE GATT transport、Security 2 patch 1、JSON 配网消息、一次性账号绑定令牌及失败恢复约定;实施并完成互操作验收后再冻结为发布版本。
+93
View File
@@ -0,0 +1,93 @@
# CALL-MEMORY-1.1 实施记录
日期:2026-09-12。依据:`AI电话_完整通话与记忆管理_完整流程与详细设计(2).docx`,v3.1,CALL-MEMORY-1.1。原文件 SHA-256:`72cd61b7f8ee0a65ffe7a932d6856a56726d906ad469ad482338aedc153fe9f7`。代码起点:`98fb2965d0898cbe08bda36df3bb99e00ed32626`。
**本次实现了服务端通话控制、记忆变更与管理页面,并修复现有固件的本地音频和快速摘挂机问题。完整的新 RTC 固件客户端尚未接入,不能将本次改动称为文档全部功能已端到端交付。** 当前改动在本地工作区,未部署生产、未刷机。
## 已落地的功能
| 文档要求 | 当前实现及入口 | 验证边界 |
|---|---|---|
| 七个设备通话接口 | `internal/callmemory/{service,lifecycle,events}.go`;warmup、prepare、events、heartbeat、end、status、command-acks | 已运行数据库集成测试;固件目前尚未调用这些新接口 |
| ONBOARDING 签名复用 | `internal/onboarding/call_auth.go`;复用 DEVREQ1 原始正文签名、当前绑定代数、nonce 与 HTTPS 校验 | 不另造用户身份;新接口要求 physical_confirm 设备身份 |
| 预热和签发 | 已发布 SDK/音频 profile 匹配;返回服务端配置版本;绑定、配置 ACK、SDK 注册和 engine_ready 校验;FIRST_TEST 绑定现有授权尝试 | 不凭客户端 known_config_version 提升版本;真实服务商 profile 必须单独核验 |
| 清单与启动 | CALLCFG1 HMAC、原始 base64url、30 秒启动期限、45 秒绝对租约、首次 heartbeat 后才能 START_INTENT | 重复 prepare 不重复分配;提前取消不会复活;status/events 不续租 |
| 通话撤销 | 隐私版本变化撤销旧清单、停止命令、阻断旧配置缓存 | 本地静音、SDK 停止、供应商停止为独立事实;远端 UNKNOWN 不完成清理 |
| 控制面限额 | 与旧通话共用租户并发锁和额度检查,新会话冻结预算快照、按较短上限限制通话 | 新通话的供应商费用回执尚未接入旧 conversation_usage_event 报表 |
| 最终文字 | 分片最多 4096 UTF-8 字节、16 片、整段摘要校验;每批最多 16 个事件且设备正文上限 8 KiB;持久化 ACK、事件序号/ID 去重、修订只向前 | 接收端可靠;设备 64 KiB/15 分钟补传队列仍待移植 |
| 收尾与抽取 | DRAINING 安静 15 秒或最长 60 秒;COMPLETE/PARTIAL/EMPTY;迟到修订重新处理;只从可靠 USER final 抽取,来源偏移与原话核对 | 配置真实结构化内容模型后执行;本地测试没有调用模型生产账号 |
| 七类记忆 | RELATIONSHIP、PROFILE、EVENT、PREFERENCE、OPEN_ITEM、STORY、INTERACTION | 页面可维护;旧记录迁移为 STORY,不重新解释历史原话 |
| 使用与敏感策略 | ALLOWED/RESPOND_ONLY/NEVER 与敏感级别分离;普通主动可用条目才进入热上下文/Viking 投影 | RESPOND_ONLY 已阻止主动使用,但新通话绑定可靠用户提问的按需检索入口仍待接入 |
| 事项状态 | 稳定 entity_key、OPEN/COMPLETED/CANCELLED/UNKNOWN、计划时间、完成时间及明确依据;不会因日期到达自动完成 | 关闭事项不进入主动开场;重开会清除旧完成依据;切换非事项类型清除事项字段 |
| 热上下文 | 最新事实优先、同实体去重、只保留完整条目,保留否定与不确定性,最多约 1000 字 | 已有完整事实/关闭事项测试 |
| 四类记忆变更 | ADD/CORRECT/SET_USAGE/DELETE;稳定 mutation_id、目标 record_version;语音来源按会话/话轮修订/动作序号去重 | 只有高置信度普通 ADD 可自动进入主库,其他语音方案在页面确认 |
| 页面确认 | 待确认方案、具体目标和版本、10 分钟单次 token、proposal_digest;提交后取消重放不能回滚已提交变更 | 前端保留编辑时核对的版本;后台轮询不会悄悄更换提交版本 |
| 六阶段任务 | main_store、serving_projection、derived_context、active_context、source_cleanup、backup_cleanup;不涉及阶段为 NOT_REQUIRED | 必要阶段全部 DONE 才 COMPLETED;备份和供应商停止不会只按时间推定完成 |
| 独立记忆权限 | 家庭所有者管理授权;普通成员独立获得查看、修改、敏感记忆权限;旧记忆接口也检查授权 | 已验证设备管理权限不能绕过记忆授权;撤权后旧确认 token 失效 |
| 删除与防恢复 | 隐私版本、来源抑制、删除台账、历史正文/变更内容/事项备注清理;按范围清理原始话轮和故事来源;旧摘要和 manifest 缓存失效 | 来源只保留 ID、修订和偏移;恢复数据库前必须重放删除台账 |
| Viking 主从关系 | `internal/vikingmemory/projection.go` 只投影已审定条目;新链路不走 AddSession;按用户串行对账 | 没有真实 H08/schema 证据时不发布条目 |
| Viking 不确定写入 | 先持久化 WRITING;写入回包丢失只按 operation key 对账;不会盲目重复新增;ACK 与可查询可见分离 | 已测试未知新增后空查询不能直接证明删除完成,水位不能越过未完成版本 |
| 现有固件安全修复 | `volc_conv.c`、`volc_conv_service.c`、`volc_hal_player.c`、`platform_client.c`;本地音频授权检查、有限等待、旧任务清理前禁止新启动、SDK 参数保持到 stop 返回 | ESP-IDF 构建通过;这些改动不等于已移植 CALL-MEMORY RTC 客户端 |
| SDK 日志隐私 | 清除注册/GetRTCConfig 正文、HTTP/WS 原始消息、RTC Token 和供应商错误正文日志;增加源码回归检查 | 源码检查通过;运行时日志抓取仍需真实设备 |
| API 契约 | `openapi/call-memory.yaml`、主 OpenAPI 0.79.0、生成的 TypeScript 类型 | 生成器覆盖 7 个通话接口、6 个记忆接口及额外的 2 个授权接口 |
## 管理页面
入口为当前长者的故事/记忆区域中的“通话记忆管理”。支持新增、修正、使用范围、事项状态、删除范围、语音候选确认、六阶段进度以及记忆访问授权。旧的空记忆列表不再重复显示。
“已保存”只表示主库提交。“正在生效”“等待备份核验”“同步需处理”会继续显示,避免把等待外部处理伪装成全部完成。采用文档允许的 PAGE_ONLY:确认及结果在页面完成,本次没有宣称已实现设备本地语音提示。
## 尚未完成的文档要求
1. **完整设备协议切换**:当前 ECHOEAR 固件仍使用旧 `/device/v3` 身份/会话接口和 `VOLC_MODE_WS`。需要完成 ONBOARDING 原始 32 字节密钥接入、新 7 接口调用、CALLCFG1 验签、可信时钟误差上界、原始 `vendor_params_json` 的 RTC 透传和 FIRST_TEST 实机闭环。
2. **设备可靠运行时**:仍需完成每次 media_attempt 的回调隔离、持久化终稿 64 KiB/15 分钟队列、7 天结束记录、启动 10 秒/首音频 8 秒超时及 LISTENING 120+60 秒空闲处理。当前修复了快速重拨竞态和本地音频关断,未把这些项目标成完成。
3. **真实服务商核验**:共享 S2S Bot、RTC 格式、OFF 真正不查旧记忆、Viking 条目 schema、关闭自动抽取、停止/清上下文、字段漂移及 H01–H12 实测证据。示例/单元测试中的 synthetic 标识绝不能作为发布证据。
4. **新通话按需检索**:RESPOND_ONLY 现被排除出主动上下文和旧模型工具;仍需把可靠当前 USER 提问绑定到新通话检索入口,才能在“被问及”时使用。
5. **供应商停止自动对账**:现有服务没有可验证的目标供应商查询回调。保留 UNKNOWN,并提供带证据的运维登记命令;不会用 SDK_STOPPED 自动冒充远端停止。
6. **既有报表与新通话桥接**:新 `call_session` 的全文/历史展示、供应商使用量和既有费用/通话报表尚未整合。预算准入和时长限制已经接入,但费用闭环不能据此宣称完成。
7. **全部 T01–T48 的故障注入验收**:本次执行的本地检查见下表;真实联网、断电、回调乱序、回声和隐私日志抓取仍须在目标设备上完成。
兼容影响:发生关键记忆变更、授权撤回等隐私版本变化后,旧 v3 客户端的新建会话会返回 `426 CALL_MEMORY_PROTOCOL_REQUIRED`。新 prepare 也会等待旧 SDK/远端清理证据,不能通过换会话 ID 绕过。**因此这批改动应先进入联调环境;在新固件接入前,不能直接当作老设备无感升级的生产版本发布。**
## 数据迁移与操作
迁移:`migrations/000059_call_memory_contract.up.sql`。新增表与触发器会把历史主体设为 `legacy_projection_pending=true`,并创建现有记忆的投影任务;不会把历史通话原文交给另一套抽取器。没有经过核验的已发布 profile 时,新 warmup/prepare 会拒绝启动。
迁移需要配套运行本版本 API、worker 和 Web。`down.sql` 故意拒绝自动删除隐私台账;如果需要恢复备份,应保持服务关闭、先恢复数据库,再重放删除台账与来源抑制并完成外部清理核验,最后决定是否恢复服务。不可只回退数据库而继续运行旧固件访问已撤回的记忆。
运维命令使用已有 `DATABASE_URL` 环境变量,不把密钥写进命令或文档:
```powershell
# 导入未发布的草稿;真实字段和证据由经过核对的 profile JSON 提供。
go run ./cmd/call-memory-admin -operation import-profile -file ./verified-profile.json
# 完成真实核验后,使用新的不可变 profile ID 发布。
go run ./cmd/call-memory-admin -operation import-profile -file ./verified-profile-v2.json -publish
# 服务商停止证据登记;设备必须已提供本地音频关闭和 SDK 停止证据。
go run ./cmd/call-memory-admin -operation attest-call-stopped -session-id <business-session-uuid> -evidence <immutable-evidence-ref>
# 已完成旧命名空间和旧运行时清理后登记。
go run ./cmd/call-memory-admin -operation attest-legacy-cleanup -subject-id <internal-subject-uuid> -evidence <immutable-evidence-ref>
# 完成真实备份清理核验后登记。
go run ./cmd/call-memory-admin -operation attest-backup-cleanup -subject-id <internal-subject-uuid> -evidence <immutable-evidence-ref>
# 修复配置后恢复对账;不会直接再次新增结果未知的条目。
go run ./cmd/call-memory-admin -operation retry-projection -subject-id <internal-subject-uuid> -evidence <repair-evidence-ref>
# 外部备份恢复前后使用删除台账;导出不包含记忆正文。
go run ./cmd/call-memory-admin -operation export-deletion-ledger > deletion-ledger.json
go run ./cmd/call-memory-admin -operation replay-deletion-ledger -file ./deletion-ledger.json
```
证据登记只是一条受控运维入口,不会自行执行备份擦除或凭空验证云端停止。禁止填写虚构证据以消除页面上的等待状态。
## 本地验证与复现
| 检查 | 本次结果 |
|---|---|
| `go test ./...`、`go vet ./...` | 通过;未设置测试数据库的单独运行会跳过数据库测试,以下隔离数据库命令另外执行 |
| `scripts/test-onboarding-integration.ps1 -Packages billing,callmemory,onboarding,saas,extraction,retrieval,memorytool,privacy,orchestration,vikingmemory` | 10 个包通过;脚本创建和删除独立测试数据库,没有迁移生产库 |
| 新通话/记忆数据库用例 | 幂等和提前取消、首次心跳、租约不可复活、文字修订/收尾、确认重放、敏感权限、投影 ACK/可见水位、写入途中撤权、全量删除覆盖新通话、迟到文字修订不能恢复已删来源 |
| Viking HTTP + 数据库用例 | 结果未知不重复新增,空查询不伪造删除完成,后续可见对账保持身份范围 |
| `npm run generate:api`、`npm run build` | 通过;仍有既有大型前端 chunk 的体积提示 |
| `test/e2e/memory-workspace.mjs` | 真实 React 组件 + 合成 API;编辑期间轮询保留核对版本、删除范围确认、不可修改所有者授权、375px 无页面横向溢出、撤权清除旧正文;无 pageerror |
| 固件 `idf.py build` | 通过;`VolcRTCDemo.bin` 大小 `0x2ce070`,应用分区剩余 44%;保持原锁文件依赖版本,未刷机 |
| 固件 Node 检查 `setup-portal.test.cjs`、`private-sdk-logs.test.cjs` | 6 项配网检查 + 1 项 SDK 日志隐私检查通过 |
浏览器脚本需要本机 Playwright 与 Chromium;可使用 `PLAYWRIGHT_REQUIRE_FROM` 指定现有 Playwright 包路径、`CHROME_PATH` 指定浏览器。脚本从仓库根目录运行,自行启动并关闭本地 Vite,只拦截合成 API。结果和截图位于 `.design-evidence/call-memory/`(已忽略,不入库)。这些证据不替代完整登录部署或真实话机测试。
+98
View File
@@ -0,0 +1,98 @@
# CALL-MEMORY-1.1 验收清单
验收对象:当前本地工作区的改动。**服务端和管理页面可先验收;完整 RTC 固件、断网补传和真实服务商闭环尚未完成,不能据此验收整份设计文档。** 详细范围见 [实施记录](CALL-MEMORY-1.1实施记录.md)。
## 1. 一条命令复跑本地检查
在 PowerShell 中执行:
```powershell
Set-Location E:\Codex\story\platform
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify-call-memory.ps1
```
前提是本机 Docker Desktop 已启动,Go、Node/npm 可用,已安装 `web/node_modules`,并具备 Playwright 和 Chrome。本机现有 Codex Playwright 会自动识别;换电脑可通过 `PLAYWRIGHT_REQUIRE_FROM` 指向 Playwright 的 package.json、通过 `CHROME_PATH` 指向 Chrome。缺少前端依赖时先执行 `npm ci --prefix web`。
脚本会启动本地 PostgreSQL/Redis,创建独立的临时数据库,执行完整迁移和合成数据导入,然后逐包运行测试;结束时删除本次创建的数据库。它不会启动整套应用、迁移现有业务数据库、发布服务或刷机。Redis 使用现有本地测试实例。
检查项目:
1. 全量 Go 测试、Go 静态检查。
2. billing、callmemory、onboarding、saas、extraction、retrieval、memorytool、privacy、orchestration、vikingmemory 共 10 个包的数据库测试,开启详细输出。缺包、失败或跳过必需用例均不算通过。既有的 `TestLocalModelContentValueDecisions` 另需配置真实内容模型;未配置时单独列为 `UNVERIFIED`,不计入通过数,不影响这组本地数据库检查。
3. 前端 TypeScript 检查及生产构建。
4. 真实 React 组件的 5 项浏览器交互检查,但 API 为合成响应。
5. 固件配网页面和 SDK 日志检查;本脚本不重新编译固件、不连接话机。
最终只有输出 `PASSED_LOCAL_CHECKS`、进程退出码为 0,才表示这一组本地检查通过。**它不表示全部文档需求通过。** 单独执行 `go test ./...` 会跳过未配置数据库的集成用例,因此不能替代这条命令。
每次证据保存在 `.design-evidence/call-memory-acceptance/日期时间/`:
| 文件 | 如何核对 |
|---|---|
| `summary.json` | 本次时间、各步骤退出码、失败原因和验收边界 |
| `database-passed-tests.txt` | 真正执行通过的数据库用例名称 |
| `database-skipped-tests.txt` | 未执行的用例;真实内容模型未配置时会明确记录,不能算通过 |
| `database/*.log` | 每个包的详细执行过程;可搜索 `PASS`、`FAIL`、`SKIP` |
| `browser/result.json` | 5 项界面断言及页面异常列表 |
| `browser/*.png` | 桌面、手机及删除确认页面截图;截图仅作为辅助证据 |
| `source-sha256.json`、`base-commit.txt`、`worktree-status.txt` | 本次运行开始时的源码指纹、基准提交及未提交文件,避免拿旧版本结果替代新版本 |
## 2. 你亲自操作页面
本轮自动化命令不会启动完整网站。目前只启动测试所需的数据库和 Redis。需要手动验收时,在**本地联调环境**启动配套版本:
```powershell
Set-Location E:\Codex\story\platform
.\scripts\dev.ps1
```
此命令会构建并启动整套应用,同时对本地 `voice_platform` 数据库执行迁移。第 59 号迁移包含隐私删除台账,不能自动降级;若这个本地库包含要保留的数据,应先备份。使用专门的测试长者和合成内容,不要关联日常使用的旧固件话机:关键记忆变更后旧协议设备会被要求升级。
启动成功后打开 <http://localhost:3100>,使用本地合成账号 `owner` / `LocalOnly123!` 登录,选择测试长者,在故事区域找到“通话记忆管理”。如果提示缺少个性化记忆授权,先在该测试长者的授权管理中完成对应授权。
| 编号 | 你的操作 | 应看到什么;怎样判定失败 |
|---|---|---|
| M01 新增与持久化 | 新增偏好“验收:喜欢温的淡茶”,保存后整页刷新,再退出登录并重新进入 | 正文和“偏好”类型仍在;只在当前页面临时显示不算通过。七种类型均应可选 |
| M02 修正 | 将上条修正为“验收:只喝温的淡茶”,保存并刷新 | 显示新正文、记录版本增加,不继续把旧正文作为当前事实 |
| M03 并发冲突 | 两个窗口打开同一条记忆的“修正”弹窗;B 保存新内容后,A 保持原弹窗并尝试保存 | A 显示“这条记忆已被其他人修改”;不能覆盖 B 的新内容。可在开发者工具 Network 中核对变更请求的 409 |
| M04 使用策略 | 分别设置“只在被问及时使用”“禁止使用”,每次保存后重新加载 | 所选策略持久保存。此项只验收设置与保存;新通话的“被问及时检索”入口尚未接完,不能算语音效果已验证 |
| M05 事项完成依据 | 新增“验收:预约看牙”,类型“待跟进事项”,计划时间填一个过去日期;随后尝试标记完成,先留空完成依据,再填写依据提交 | 过去日期不会自动把事项变为完成;完成依据为空不能提交;有明确依据后显示“已完成” |
| M06 独立权限 | owner 在“记忆授权”给 editor 授予读取权限但不授予修改权限;用无痕窗口以 editor / LocalOnly123! 登录同一家庭长者 | editor 可查看但不可添加、修正或删除。设备管理角色本身不能代替记忆授权 |
| M07 撤权 | owner 撤销 editor 的读取权限,在 editor 窗口点刷新或等待一次 5 秒轮询 | 出现无访问权限提示,原先可见正文清空,添加按钮不可用;接口返回 403 |
| M08 删除范围 | 删除刚创建的测试记忆,先不勾“已核对目标和删除范围”,再勾选并提交 | 未勾选不能提交;两种删除范围可区分,提交后可查看具体任务。手动新增记录没有原始通话来源,因此不能用它证明原始文字已清理 |
| M09 六阶段状态 | 查看删除任务的记忆正文、通话记忆同步、摘要和开场上下文、当前通话停止、来源清理、备份清理 | 各阶段分别显示;缺少外部清理证据时应继续“等待完成/待核验/需处理”,不能假装全完成。真实全部完成须另有供应商和备份证据 |
建议每项记录测试时间、测试账号、测试长者、memory_id/mutation_id、操作前后截图及通过/失败。页面截图验证交互;页面重新加载后的结果和接口响应验证真实保存。失败时保留该项证据,不要用刷新后重试成功掩盖首次失败。
## 3. 页面看不到的逻辑,用这些用例核对
在 `database/integration-callmemory.log` 中搜索以下名称;用例使用真实 PostgreSQL,但设备输入为程序模拟,不是物理话机:
| 功能 | 用例名 |
|---|---|
| 修改确认、重复请求不能反向取消已提交操作、删除分阶段等待 | `TestMutationVersionFenceAndConfirmationReplay` |
| 租约过期后旧心跳不能复活通话 | `TestLeaseExpiresAndCannotResurrect` |
| prepare 幂等、提前取消、首次心跳后才能启动 | `TestPrepareIdempotenceCancelAndFirstHeartbeat` |
| 最终文字修订、收尾及撤回文字存储授权 | `TestTextRevisionDrainingAndConsent` |
| 普通设备角色不能获取记忆权限 | `TestMemoryGrantIndependentOfDeviceRole` |
| 敏感内容不能通过任务状态接口绕过授权 | `TestSensitiveMutationStatusCannotBypassListGrant` |
| 外部 ACK 不等于查询可见,版本水位不能跳过未完成项 | `TestProjectionAckIsNotVisibilityAndWatermarkHasNoHoles` |
| 投影途中再次核对授权和敏感状态 | `TestProjectionRechecksConsentAndSensitiveState` |
| 隐私删除覆盖新通话数据,且等待运行时停止证据 | `TestPrivacyDeleteIncludesNewCallDataAndWaitsForRuntime` |
| 删除后迟到文字不能恢复来源 | `TestLateRevisionCannotRestoreDeletedSource` |
| status 符合文档查询键且不能续租 | `TestStatusUsesDocumentKeyWithoutRenewingLease` |
另在 `database/integration-vikingmemory.log` 中核对 `TestCuratedProjectionUnknownCreateNeverDuplicatesOrProvesDeletion`:数据库真实,Viking HTTP 是本地模拟服务,验证响应丢失后不盲目重复新增、空查询不能冒充删除证明。
## 4. 整机验收仍待完成
以下均应保留“未验收”,不能用上述脚本或截图替代:
- 新 RTC 固件实际调用新协议、校验签名和租约、FIRST_TEST 从摘机到听到声音的闭环。
- 真实通话中新增事实,再次通话能正确使用;禁止使用、撤回授权和删除后不再使用旧事实。
- “只在被问及时使用”的按需检索入口及实际语音效果。
- 断网终稿持久化和补传、断电重启、快速摘挂机、旧回调不会串入新通话。
- 服务商实际停止、旧上下文清理、Viking schema 和查询可见性、备份清理的真实证据。
- 新通话历史/全文展示、供应商使用量和费用报表闭环。
完成这些开发和真实设备/供应商验证后,才能按原文档的 H01–H12、T01–T48 逐项签收。目前可签收的范围仅限于通过测试与手动检查的具体条目。
+30
View File
@@ -0,0 +1,30 @@
# 管理端五页上线记录 — 2026-09-21
已部署至 https://82.157.201.114/maintenance?area=devices 。版本 `0.99.2-rc.admin1`,上一版本 `0.99.1-rc.portal-display1`。
本次仅更新 Web:保留设备管理、固件管理、大模型管理、验证码配置、用量与费用五个入口,其余业务菜单隐藏;默认进入设备管理。运行配置只展示验证码设置。
## 发布范围
从当前线上源码构造独立候选,仅叠加本轮 13 个源码文件。既有修改文件与本轮开始前的备份逐一匹配,依赖锁文件与线上相同。候选 TypeScript/Vite 构建通过,JS/CSS 摘要与本地已验收产物相同;不包含 DEV 示例数据入口或设计对照页。生效 Compose 配置与上一版比较,仅 Web 镜像发生变化。
- 当前发布目录:`/opt/data-terminal-cloud/releases/0.99.2-rc.admin1`
- Web 镜像:`data-terminal-cloud-web:0.99.2-rc.admin1`
- API 保持:`data-terminal-cloud-api:0.99.0-rc.portal1`
- Worker 保持:`data-terminal-cloud-backend:0.98.0-rc.trusted-login1`
- 数据库保持 `73|f`,本轮没有数据库迁移。
- JS:`/assets/index-D0nDdepd.js`
- CSS:`/assets/index-DUKR9SNd.css`
- 发布包 SHA256:`fcc152ae0140ffcdc65298586af919e4b23c44f758bc1e13d78268b9b487587c`
## 已验证
公网首页、维护入口、JS/CSS 和六张既有插画的 SHA256 与候选一致。API/Worker readiness 正常,Web 运行且重启次数为零。API、Worker、PostgreSQL、Redis、MinIO、Gateway、Callbacks、Keycloak 的容器 ID 未变;网关只执行配置校验和重载。
浏览器线上维护登录页正常加载,腾讯云验证方式配置可正常读取,无 console warning/error。当前内置浏览器没有管理员会话,未登录执行真实设备创建、固件上传、模型修改、验证码保存或价格审批。本地各主流程验收记录见 `web/design-qa.md`,不能将它视为真实账号下的线上全流程验收。
## 发布证据与回退
服务器发布目录内保存 `admin-pages-release-manifest.json`,记录基线源码、源文件和构建产物摘要、原容器标识与激活时间。本地清单、前后状态、构建日志和部署脚本位于 `.tmp-admin-deploy/`。
旧发布目录及镜像已保留。回退时使用前一版本记录的完整 Compose overlay 链,仅重建 Web,再重载 Gateway 并原子切换 current 链接;不执行整栈 down,不重建后端,不恢复数据库。部署脚本对切换过程配置了健康/资源校验失败时的自动 Web 回退。
+32
View File
@@ -0,0 +1,32 @@
# 管理端宽屏布局修复发布 — 2026-10-05
已于北京时间 2026-10-05 00:27 发布 Web `0.99.37-rc.admin-width1`,基线为 `0.99.36-rc.auth-desktop1`。线上入口为 https://82.157.201.114/maintenance?area=devices 。
管理端原先使用 `max-width:1680px`,并靠左排列;宽屏超过这个上限后,多余空间全部留在右侧。本次将 `web/src/admin-redesign.css` 中管理页面的最大宽度改为 `none`,保留桌面左右 28px 和手机左右 16px 的内边距。
## 发布范围
从当前线上版本只读导出源码、静态资源、完整 Compose 层链与容器状态后构造独立候选,源码差异仅一个 CSS 文件。线上源码的 TypeScript 与 Vite 构建通过;候选编译后的 CSS 与原线上 CSS 比较,恰好只改变该属性。
发布继承当前 Web 镜像,只覆盖 `index.html` 和新的 CSS 文件。JavaScript 的 URL 与字节内容保持原样,旧 CSS 保留,静态图片、字体、第三方许可文件与运行配置继承并校验。
- 当前发布目录:`/opt/data-terminal-cloud/releases/0.99.37-rc.admin-width1`。
- Web 镜像:`data-terminal-cloud-web:0.99.37-rc.admin-width1`。
- API 镜像:`data-terminal-cloud-api:0.99.35-rc.privacy-progress1`。
- Compose 从 51 层增加至 52 层;有效配置只改变 Web 镜像及发布目录路径。
- JavaScript:`/assets/index-CT5oSze-.js`。
- 新 CSS:`/assets/index-BPCj6XQe.css`。
## 验证
119 个 Web 静态文件经容器读回与公网摘要校验;生成的运行配置另行校验。首页、管理端、设备入口和登录入口返回候选页面。API 与 Worker readiness、公开版本接口、Web 运行状态与数据库 schema 检查通过;schema 保持 74 且 clean。Web 重启计数为零,其它容器 ID、启动时间和运行状态保持原状,网关仅进行配置校验和重载。
本地已检查五个管理页面在 2560px 宽屏和手机尺寸下的布局,以及菜单收起后的主内容宽度。2560px 宽屏主内容两侧均留 28px,无根页面横向溢出;手机两侧均留 16px。
浏览器线上管理登录页正常加载,新 CSS 与原 JavaScript 引用正确,控制台没有 warning/error。浏览器没有管理员会话,此次未在真实管理员账号下进行页面操作。没有执行数据库迁移、修改业务数据、发起通话或 OTA。
## 证据与回退
本地证据目录:`E:/Codex/story/.local-evidence/admin-width-20261005`。生产回执为 `production-receipt.json`,归档、源文件差异、候选构建日志、线上基线与部署日志均已保存。服务器当前发布目录内也保存生产回执。
上一发布目录和 Web 镜像 `0.99.36-rc.auth-desktop1` 保留。回退应使用回执记录的上一版本完整 51 层 Compose 配置,仅恢复 Web,随后校验并重载网关、核对旧资源和健康状态、原子切换 current 链接。发布脚本配置了激活后验证失败时的自动 Web 回退。
+9
View File
@@ -0,0 +1,9 @@
# 配网取消选择中文提示
取消浏览器蓝牙设备选择器后,原先直接显示英文异常 `User cancelled the requestDevice() chooser.`。现在仅在 requestDevice 阶段将 NotFoundError/AbortError 转为明确的取消类型,界面显示普通状态“已取消选择设备,可重新搜索。”,清空错误并恢复搜索按钮。实际 GATT 服务缺失仍作为连接错误处理,不误判为取消。
源码范围:web/src/ble/client.ts、web/src/components/BLEProvisioning.tsx。BLE 原帧、配网指令与设备写入逻辑保留。
正式候选基于线上 0.99.44-rc.consent-checklist1,仅包含以上两处源文件。候选 TypeScript/Vite 构建、9 项 BLE 客户端测试通过。浏览器隔离模拟两种取消异常,验证中文普通 status、零 alert、可重复搜索;未在真实设备上配网或提交业务请求。证据与截图位于 E:/Codex/story/.local-evidence/ble-cancel-chinese-20261005。
待发布版本:0.99.45-rc.ble-cancel-zh1。发布包及自动回退脚本已准备好。生产发布被自动审批拒绝,原因是用户本次要求中文提示,先前“直接上线”针对长者切换,不能推定本次新的生产发布授权。尚未执行上传或生产修改,等待用户明确上线授权。
+25
View File
@@ -0,0 +1,25 @@
# SaaS 蓝牙添加设备上线记录 — 2026-09-21
已部署至 `https://82.157.201.114/customer/devices`,Web 版本为 `0.99.3-rc.ble1`,上一版本为 `0.99.2-rc.admin1`。
## 发布范围
- 家庭端“添加陪伴设备”移除绑定码模式,仅保留蓝牙发现。
- 添加流程调整为“搜索附近设备 → 选择并连接 → 发送 Wi-Fi 配网信息”。
- Web 蓝牙客户端改用与当前小程序和固件一致的 BLE 明文 v2 帧,不再要求设备标签编号、配网密码或 Security2 握手。
- 只叠加 5 个生产前端文件和 1 个回归测试文件;API、Worker、数据库与运行配置均未修改。
## 发布结果
- 当前目录:`/opt/data-terminal-cloud/releases/0.99.3-rc.ble1`
- Web 镜像:`data-terminal-cloud-web:0.99.3-rc.ble1`
- API 保持:`0.99.0-rc.portal1`
- JS:`/assets/index-BvMiW62U.js`
- CSS:`/assets/index-BE0QqIen.css`
- 发布包 SHA-256:`36b472b04341e2cab95ea4b7d35d93f2c1e2ffb85a09ad79b674a9cccd5919eb`
## 验证与回退
候选 TypeScript/Vite 构建和 BLE v2 回归测试通过。公网首页、设备页面、JS、CSS 摘要与候选一致;线上 JS 包含“搜索附近设备”和“发送配网信息”,不包含“绑定码添加”。Web 容器运行且重启次数为零;API、Worker、PostgreSQL、Redis、MinIO、Gateway、Callbacks、Keycloak 容器均未更换。
服务器发布目录保存 `ble-saas-release-manifest.json`。本地候选、发布包、部署脚本和线上清单位于 `.tmp-ble-saas-deploy/`。若需回退,使用上一版本完整 Compose 层只重建 Web,并重载 Gateway;不执行整栈 down,不修改数据库。
+45
View File
@@ -0,0 +1,45 @@
# EchoEar 屏幕确认后重启修复
设备:`KP01-B43A451634A8`,EchoEar ESP32-S3,串口 COM6。
## 原因与修复
实机日志确认:Wi-Fi 默认静态缓冲区分配失败;失败路径保留了 `WIFI_STA`,但下一次调用又创建同名接口,触发 SDK 内部断言并重启。
- 新增 `main/ble_wifi.c`,逐阶段持有接口、事件组、驱动和事件处理器,重试不会重复创建或注册。
- 使用带错误检查的网络接口创建 API,不使用内部会 assert 的便捷创建函数;接口连接失败时清理自己创建的接口,不接管其他模块的接口。
- 仅 BLE 配网模式使用静态 RX/TX 各 4、动态 RX 8、TX cache 4,关闭该模式的 AMPDU/AMSDU。普通语音模式的 sdkconfig 性能配置不变。
- 在蓝牙启动之前预留 Wi-Fi 驱动资源;此时不启动 Wi-Fi 连接、不发送账号信息,实体点击确认仍然必需。
- 每次更换连接任务先停止上一次 STA,并等待停止事件;失败重试不重复初始化网络接口。
- Wi-Fi 启动内存不足和网络连接超时分别提示。重连失败时不继续提交云端绑定。
- 诊断只记录错误码、内存余量、断开原因编号,不记录 Wi-Fi 密码或绑定凭据。
本次没有修改线上服务、小程序协议或数据库,也没有解除绑定或恢复出厂设置。
## 验证
- ESP-IDF v5.5.1 固件编译通过;程序大小 3,175,712 字节,5 MiB 程序分区剩余约 39%。
- `tests/ble-wifi-host.cjs` 将真实 `ble_wifi.c` 函数接入 SDK 模拟器,22 个生命周期/故障注入测试通过;使用 GCC `-Wall -Wextra -Werror -fsanitize=address,undefined`。
- 覆盖连续 10 次驱动初始化内存不足后恢复、所有初始化阶段失败、网络接口所有权保护、连接超时/断开/停止事件延迟、重复任务和参数校验。
- SDK 私密日志回归测试通过。
- COM6 只刷写 `0x10000` 程序,写入哈希校验通过;没有写入 NVS、分区表、工厂身份或资源分区。
- 新固件实机启动:Wi-Fi 初始化成功后内部可用内存 58,519 字节,最大连续块 31,744 字节。蓝牙初始化与启动任务正常完成。
- 45 秒启动观察只看到刷入后的 1 次主动重启,没有出现额外崩溃重启。
- 用小程序实际 Security2 实现完成实机自动连接和加密握手,连续两轮读取设备信息、状态成功,状态 `idle`。
- 用户随后通过小程序重新提交配网信息并轻点屏幕:约 6.4 秒取得 IP,再约 2.5 秒设备状态到达 `success`。上述只读握手测试没有代用户提交绑定。
- 云端只读复核:绑定记录 `COMMITTED`,完成时间 2026-09-13 21:11:25(北京时间),`binding_generation=5`,有效绑定数为 1。
- 云端语音激活状态当时为 `PROVISIONING`;绑定成功不代表语音服务已经开通完成。
## 文件与回退
固件:`firmware/echoear-platform-agent/build/VolcRTCDemo.bin`
SHA256:`261D68285C062ACDE5927690667A85DEDD5D19C6B88FF696F235F8CB0DE3D6EA`
刷入前 5 MiB 程序分区备份:`.tmp-mini-release/before-wifi-fix-app-20260913.bin`
备份 SHA256:`15B84C11253A1D96B23F3952433751195550436EB522F87D4F171D7C98D20814`
仅在明确需要回退时把该程序备份写回 `0x10000`,不要整片擦除。
只读诊断:`tools/monitor_provisioning.py --port COM6 --seconds 45`,默认不重启;`--reset` 必须显式指定。
+30
View File
@@ -0,0 +1,30 @@
# 当前设备聊天记录状态核查
核查时间:2026-10-05 11:51–11:53(北京时间)。仅查询设备、会话、授权元数据及回调路径/状态码,没有读取聊天正文,没有更改授权或历史会话。
设备:`KP01-8995ACF5E69E`,public ID `3ef575a6-bbc0-4e00-9a5c-58a8157d9520`,固件 `1.7.0-controls.29`。
## 已确认的原因
1. 当前绑定老人没有任何 `consent_record`,文字保存授权检查为 false。会话隐私 epoch 与当前 epoch 均为 0,无删除请求,因此不是隐私版本变化导致的拒绝。
2. 最近两小时网关记录 52 次 `/callbacks/v1/volcengine/chat` 请求,全部 HTTP 403。`DirectSubtitle` 在缺少 `transcript_storage` 授权时返回 `TRANSCRIPT_STORAGE_REVOKED`;签名失败会返回 401。本次没有伪造字幕或重放请求。
3. 当前设备的五次会话 `call_utterance` 均为 0,`last_text_at` 均为空。虽然 `transcript_state=PARTIAL`,实际上没有保存任何聊天文字。
4. `DirectRequest` 创建 DIRECT-CHAT-1 会话时将 `text_gap=true`。`Maintain` 先判断 `text_gap` 再判断是否存在文字,导致没有文字也落入 PARTIAL。前端将 PARTIAL 直接显示成“已保存部分文字”,因此提示不准确。不能把所有 DIRECT 字幕改成 COMPLETE:目前没有供应商完整性确认。
## 通话结束状态
| 开始时间(北京时间) | 云端状态 | 结束回执情况 |
| --- | --- | --- |
| 11:02:23 | FAILED | 最后租约 11:09:55 到期,11:09:56 判失败;结束回执 11:16:56 才收到 |
| 11:17:14 | FAILED | 租约 11:17:59 到期,11:18:00 判失败;结束回执 11:18:47 才收到 |
| 11:47:38 | ENDED | 11:48:12 收到结束回执,本地音频已关闭,SDK 已停止 |
| 11:48:16 | ENDED | 11:48:44 收到结束回执,本地音频已关闭,SDK 已停止 |
| 11:48:48 | ENDED | 11:48:59 收到结束回执,本地音频已关闭,SDK 已停止 |
旧两次失败由租约到期且缺少及时结束回执直接证实;具体导致续期/结束回执延迟的设备或网络原因没有进一步证据。最新三次说明 `.29` 点击挂断后云端收尾正常;不能把旧失败记录无依据改成正常结束。
## 用户可采取的操作
在当前老人的隐私与授权页,对“保存文字记录”进行授权后,新聊天的字幕才允许保存。此次问题查询没有授权代理开启文字保存。此前未入库的文字无法仅凭这些状态元数据恢复。
原始只读证据:工作区 `.local-evidence/chat-record-state-20261005/state.json`、`runtime.json`、`permission.json` 及相应查询脚本。
+40
View File
@@ -0,0 +1,40 @@
# 火山云录音接入(2026-09-27)
已发布:`0.99.13-rc.cloud-recordings1`,基于线上 `0.99.12-rc.portal-records1` 的小范围覆盖。
## 当前可用功能
- 聊天记录 → 选择通话 → 聊天录音,分别列出长者和助手的 MP4 文件、时长和大小。
- 使用私有 TOS 桶 `memory927`,地域 `cn-beijing`;复用服务器已有的火山凭据文件,没有把密钥写入网页或仓库。
- `calls/recordings` 只查询对象元数据,`calls/recording-access` 返回最长 300 秒的签名地址。音频由客户浏览器直接访问 TOS;服务端没有音频 GET、缓存、转发或落盘代码。
- 使用现有家庭成员、长者、敏感信息权限、原音频保存同意及独立 PLAY/DOWNLOAD 授权。撤销授权后不能申请新地址;已经发出的地址在原有效期内仍可能访问,最长 5 分钟,且不超过授权或同意到期时间。
- 家庭所有者可以主动点击为本账号授予 24 小时播放/下载授权;部署没有替任何账号自动授予权限。
## 关联依据
通过会话专属签名校验后的字幕回调保留 `bot_<17位时间戳>` RTC 用户标识,在 `call_session.budget_snapshot.rtc_agent_user_id` 中保存。结合通话记录中的设备名,构造硬件网关的精确房间和录音前缀;列表和签名操作都先核对会话所属长者与租户,再严格匹配完整文件名。客户端不能提交任意对象 Key。
现有通话 `562a391e-e7a0-409f-804e-0eb0d5e576dd` 通过已有字幕的 UID/轮次/序号 SHA-256 标识交叉验证后补充关联:长者 58 秒(1,045,859 字节),助手 103 秒(1,845,555 字节)。没有按时间接近程度匹配历史记录。没有确切 UID 的历史通话显示关联信息暂缺;回调缺失的新通话也不会错误关联录音。
当前火山配置是单流自动录制,不是双方混音。文件生成需要等待通话结束及火山处理,页面提供刷新。
## 在线播放的剩余配置
实测使用默认域名签名 HEAD 返回 HTTP 200,但即使指定 inline,响应仍为 `Content-Disposition: attachment`。火山对 2024-01-03 后新建桶的默认域名禁止预签名直接预览:https://www.volcengine.com/docs/6349/1175779。
因此线上现在提供查询和下载,不显示不可用的播放按钮。绑定带 HTTPS 的 TOS 自定义域名后,配置 `TOS_RECORDING_PLAYBACK_ENDPOINT=https://<已绑定域名>` 可启用播放器;下载继续使用默认 TOS 域名。不能把 CDN 域名直接当作此配置,必须使用支持 TOS 私有签名的已绑定域名,并验证证书和签名响应。未配置自定义域名时 PLAY 请求被服务端拒绝。
## 验证
- 使用临时独立 PostgreSQL 容器执行 7 项数据库/直聊/录音测试:授权、下载权限独立、跨长者通话、错误文件 ID、撤回同意、链接有效期、回调身份保存及冲突。测试容器已经移除;没有在业务数据库创建测试账号或授权。
- 候选版本网页构建通过;浏览器测试 7 项通过,包含直连地址、切换通话时取消旧请求、拒绝访问后清除旧链接、移动布局、未配置域名时保留下载并隐藏播放。
- `go vet ./internal/callmemory` 通过。最初 `internal/app` 的集成测试因本地 PostgreSQL/Redis 未启动而失败;随后相关数据库行为在上述独立环境验证。
- 生产 TOS 只使用 LIST/HEAD 检查元数据和下载响应,未下载真实音频。
- 线上新接口匿名请求均返回 401,api/callbacks/web 健康;生产配置下的服务元数据查询准确返回上述两份录音。
- 网页构建文件逐个校验 SHA-256;worker/gateway/Postgres/Redis/MinIO 容器未更换。
发布清单位于服务器 `/opt/data-terminal-cloud/current/cloud-recordings-release.json`。部署脚本及本地证据位于 `.tmp-tos-recordings/`。测试入口为 `test/e2e/cloud-recordings.mjs`(候选构建目录)以及 `internal/callmemory/recordings_test.go`。
## 边界
本次只管理录音查询和短期访问地址。火山的自动录制开关、对象保留期和 TOS 删除策略仍由火山配置决定;平台隐私删除请求会阻止录音访问,但本次未增加远端 TOS 音频删除任务。TOS 存储、云录制和外网下载产生的火山费用仍按账号计费。
+132
View File
@@ -0,0 +1,132 @@
# COM6 设备能力实现与验收
目标设备:COM6,ESP32-S3,MAC B4:3A:45:16:34:A8,16 MB Flash。
目标包含现有 SaaS 和小程序列出的设备能力,以及前面识别的重启、名称设置、恢复出厂和待机控制缺口。不得用页面提交成功代替实机执行成功。
最新状态(2026-09-22 03:57 UTC):线上 API/Web 为 `0.99.10-rc.device-transfer1`,COM6 为 `1.7.0-controls.8`。以下按时间追加的旧版本、旧分区说明均为历史基线,不能作为当前状态。真实 OTA/回滚、蓝牙全流程、恢复出厂、小程序发布及登录页面端到端验收仍未完成。
## 验收清单(未完成项不得标记完成)
- [x] 实机完整 Flash 备份及哈希;未修改出厂身份、Wi-Fi 和平台归属。
- [ ] MQTT 心跳、断线/重连、两端在线状态一致(controls.7 实机主动重启的离线/重连已验证;两端登录页面展示及非主动断网仍待验收)。
- [x] 云端音量配置应用到编解码器,实际值回传,重启后恢复。
- [ ] 音频、网络、存储诊断真实采集;命令领取、回执、重复投递去重。
- [x] 重启命令持久化,重启后回报;忙碌时拒绝或延后。
- [ ] 名称配置及两端展示(SaaS、设备持久化和回传已验证;小程序代码完成,正式发布与真机页面验收待完成)。
- [x] COM6 待机/唤醒执行、停止采音、熄屏、启动保持待机及通话期间延后执行。
- [ ] 待机/唤醒两端登录页面的端到端验收、小程序正式发布。
- [ ] 恢复出厂的设备执行、权限、状态及可恢复验证。
- [ ] A/B 分区、OTA 大小/摘要/型号/版本校验、任务去重、重启验证和失败恢复。
- [ ] SaaS 重新配网、账号先添加、OTA、命令结果入口。
- [ ] 小程序新增控制入口,现有配网、解绑、老人转移回归。
- [ ] 实机聊天和声音回归;核对平台绑定、固件、命令和配置最终状态。
- [ ] 线上部署和微信版本状态说明。
2026-09-22 基线:在线 Web 为 0.99.4-rc.login1;上述四个关键页面源文件哈希与本地一致。COM6 的 USB 诊断显示 RTC 任务存在,内部可用内存约 6 KB;需控制新增任务和缓冲的内部 RAM 占用。当前分区仅有 factory 应用,没有 OTA 槽。
本文件仅记录验收进度,不构成完成证明。
整片备份:本机受限目录 `.local-evidence/com6-controls-20260922/before-controls-16mb.bin`,16,777,216 字节,SHA256 `df426d931d3208ea3714f3393e65c06de75da31606dd6668d9afa027e4d0a187`。esptool 已确认板上 MAC;读取后硬件复位。备份含凭据,不纳入版本库或公开发布。
## 本轮已得到的实机证据
- 已部署 API `0.99.5-rc.device-controls1`,基于线上源码仅覆盖设备控制接口、OTA HTTPS 清单和 SaaS 在线状态读取。部署清单位于服务器新 release 的 `device-controls-release.json`。Web、worker、callbacks、gateway、数据库、Redis、MinIO 容器 ID 未变化。
- 已烧录新的 bootloader、A/B 分区表、初始 OTA 数据和移动后的唤醒模型。普通 NVS、coredump、storage、eaf 和出厂身份分区未擦写;平台绑定数保持 1。
- 首轮烧录应用 `1.7.0-controls.1`:3,749,408 字节;SHA256 `1d90bd68c3cde076ffa84203fc427f6fddde99515720338706ebc26b6a7b3aa7`。随后从 COM6 实际读回应用,哈希一致,保留在受限证据目录 `controls1-app.bin`。
- 2026-09-22 00:46 UTC:COM6 串口 `mqtt=1 speaker=60 revision=2 ota=1 internal_free=19047`;RTC 任务存在,栈剩余 42,688 字节。
- 同时平台查询:期望音量 60、实际音量 60、期望版本 2、已应用版本 2;固件 `1.7.0-controls.1`,MQTT 在线,OTA 支持标记 true。这是设备实际读取 codec 音量后的上报,不是云端复制期望值。
- 原播放器只在通话中存在。控制逻辑已改用常驻 codec,避免空闲时无法应用音量,也避免控制线程访问正在销毁的播放器。AI 服务任务使用 PSRAM 栈,启动失败会记录错误。
- 后端 onboarding、devicefleet、saas 隔离数据库集成测试通过;包括忙碌不投递、命令设备隔离、终态幂等、配置版本冲突、HTTPS OTA 清单与票据隔离。
- 实际 C 的聊天适配器、配置应用和 OTA 同源检查在 ASan/UBSan 下通过;覆盖 codec 失败、NVS 提交失败不误报已应用、重复配置不重复写 Flash、维护状态阻止开始聊天。
- 00:48 UTC:诊断命令 `5005088a-bd44-4392-9267-983996f4417e` 首次投递后 SUCCEEDED;codec 就绪、实际音量 60、网络已连接,NVS 实际写入/提交/读取检查成功。随后重启命令 `a39e0493-ff4c-4346-b32e-8ed5300290ad` 在启动后回传 SUCCEEDED,音量 60、配置版本 2 和绑定保留。
- 已部署 API 与 Web `0.99.6-rc.device-pages1`。网页新增固件状态/OTA、重启及最近命令结果;重新配网不生成绑定令牌,目标设备必须与蓝牙选择匹配;添加设备可不指定老人,账号设备不再被家庭筛选隐藏,支持后续绑定老人。后台复用既有设备归属服务,新增网页的状态与指定老人路由。
- 网页蓝牙协议测试 5 项通过;onboarding 全包隔离数据库测试通过(12.649 秒),包括网页与小程序先添加再分配流程、跨账号拒绝、CSRF/Origin 检查和绑定代次保持。独立线上基线候选的 TypeScript 与 Vite 构建通过。
- 本地浏览器检查了无家庭、待绑定老人、重新配网面板、未选择老人时禁止提交以及服务失败时保留选择。此检查使用本地页面数据,不代替 COM6 蓝牙实机验收。
- 发布后已验证线上首页、JS/CSS 的 SHA256 与候选完全一致,运行配置保持原值,新接口匿名访问返回 401;worker/callbacks/gateway/数据库/Redis/MinIO 未重建。首次发布探测漏带 X-Request-ID 导致 400,自动回退成功;补齐探测请求后重新发布成功。失败候选保留为 `.probe-rollback` 目录。
- 01:15–01:18 UTC 语音回归未通过:平台 chat/start HTTP 200,但语音 SDK 连接失败,`mbedtls_net_connect` 返回 `-0x42`(创建 socket 失败),没有下行 PCM/播放记录。结束已确认,未保存语音文本。正在排查 socket 容量与内存,不能据此宣称真实聊天正常。
- 01:23 UTC 已烧录 `1.7.0-controls.2`:3,749,728 字节,SHA256 `f7532cbb0cb83ee8dec90ce0208daacc9e624d87a3d5462fc7d7910b43c12bda`,仅写活动应用槽,烧录哈希校验成功。将 lwIP socket 上限从 10 调整至 16,加入活动 socket 数和连接 errno 诊断;OTA 启动确认增加真实 SDK 初始化成功条件。
- `controls.2` 固定语句回归成功建立语音连接并收到持续下行 PCM,播放器实际写入,解码/写入错误均为 0,40 秒后主动结束并收到成功回执。通话中 socket 数为 10 / 上限 16(临时语音服务 HTTPS 请求还需要额外连接),内部剩余内存 9,483 字节。证据 `voice-20260922T012415Z.log`。这证明软件音频通路恢复,不代替扬声器听感和麦克风真实收音确认。
- 通过受限 COM6 操作工具调用实际 SaaS Service 验证音量 `60→55→60`:设备实际 codec 上报分别匹配配置版本 3、4。随后重启命令 `67c37ad7-b430-4fa1-b264-7718b78d0b55` 首次投递,01:26:18 UTC 回传 SUCCEEDED。最终在线、音量 60、期望/实际配置版本均为 4、绑定数 1、未完成命令 0。串口 RTC 任务存在,内部剩余 16,307 字节,栈剩余 43,232 字节。
## 仍未完成
在线 OTA 安装及失败回滚、真实聊天声音/麦克风回归、名称和待机的小程序发布验收、恢复出厂、两端蓝牙回归及小程序正式发布状态仍未完成。`ota_supported=true` 表示代码和分区具备能力,不能替代实物 OTA 成功的验收记录。
工作目录 `.tmp-com6-deploy`、`.tmp-device-web-deploy` 包含线上基线、构建候选、增量发布脚本和只读 COM6 状态查询脚本。COM6 串口观察脚本为 `tools/observe-com6-controls.py`,固定测试语句回归为 `tools/com6_voice_acceptance.py`,输出仅包含运行元数据。当前尚未执行 OTA,活动槽仍为 `ota_0` / `0x10000`;一旦执行过 OTA,后续串口烧录必须先确认活动槽。不要再次运行首次分区迁移脚本清空正在使用的 OTA 状态。
## 2026-09-22 设备名称实现与实机验收
- API 与 Web 已发布 `0.99.7-rc.device-name1`,基于线上 `0.99.6-rc.device-pages1` 的源码构建,只有清单中的 10 个文件发生变化。独立候选构建和首页、JS/CSS SHA256 校验通过,其他服务容器保持原值;没有数据库迁移。部署清单为服务器 release 内 `device-name-release.json`。
- 配置契约为 `desired_config.identity.display_name`;空字符串恢复默认名称。后端限制 UTF-8 最多 96 字节,无首尾空白及控制字符。沿用设备管理权限和 If-Match 配置版本检查。设备通过 DEVREQ1 同步接口回传实际名称,旧固件不回传时不会伪造确认。
- SaaS 设备详情提供名称编辑、保存和放弃修改刷新;分别显示已保存名称及设备确认值。保存成功保留详情页面;后台刷新保留正在编辑的版本,避免覆盖其他人的修改。
- 小程序设备列表/管理和家庭设备工具显示名称;设备管理提供修改与恢复默认。新增用例覆盖版本冲突保留输入、重复点击、隐藏页面后的异步结果和非法名称拒绝;3 项新测试、19 项已有设备流程回归通过,9 页 WXML 与 10 个 WXSS 编译通过。小程序正式发布状态仍未确认。
- 后端 onboarding 全包隔离数据库测试通过(12.699 秒);HTTP 配置校验、名称边界测试通过。实际 C 配置函数在 ASan/UBSan 下验证名称存储提交失败、读回失败不确认配置、中文名称、超长名称、清空和重复同步不重复写入。
- COM6 已更新 `1.7.0-controls.3`,3,750,192 字节,SHA256 `9ab5da3913661b93a30ecd02a90fa51430e71f5334431ea955fb6f4192d8bc2b`。OTA 数据读回表明 ota_0 / 0x10000 为当前有效槽;烧录前检查设备空闲、绑定数为 1,只更新该应用槽。烧录校验成功。
- 通过受限 COM6 工具调用实际 SaaS Service,名称“COM6陪伴设备”下发并由设备回传,期望/实际配置均为 5。重启命令 `e29b45c4-5e28-4f6d-892b-cb15172510ac` 成功;01:55:45 UTC 首次启动上报的配置版本尚为 0(接口显示 null),名称已从 NVS 恢复,随后实际版本同步到 5。该证据排除了单纯从云端重新应用名称来冒充持久化。
- 01:56 UTC 已恢复默认名称,期望/实际配置均为 6;COM6 在线,实际音量 60,绑定数 1,未完成命令 0。验收记录保存在受限目录 `name-acceptance.json`,应用和烧录记录为 `controls3-app.bin`、`flash-controls3.json`。当前仍未执行实物 OTA。
- 浏览器验证了名称表单、输入验证和模拟接口失败后保留输入;线上家属账号尚未登录完成,因此上述 Service 实机验收不能作为网页/小程序点击操作的端到端证明。待机/唤醒、恢复出厂、OTA 安装回滚、蓝牙全流程及小程序发布继续保留在总验收范围内。
## 2026-09-22 待机与唤醒实机验收
- API 与 Web 已发布 `0.99.8-rc.device-power1`;2026-09-22 再次读取服务器 current 链接确认。SaaS 详情显示实际状态、待同步状态与确认操作。小程序对应代码已实现,正式发布和登录页面实测尚未完成。
- COM6 当前应用为 `1.7.0-controls.4`,3,750,096 字节,SHA256 `636103c1016fcda6146dd4511567cf532e8f009fe29b48a03ca2cfd29edbaf88`。仅更新 ota_0 应用槽,未做实际 OTA。固件暂停 AFE 和麦克风读取、关闭背光、阻止待机期间开始通话;保持网络以接收唤醒。待机状态写入 NVS,并在启动采音前恢复。
- `power-acceptance.json`:待机后实际背光 0、麦克风暂停;跨两次新上报,麦克风读取次数保持 968。待机时发送诊断聊天启动请求,云端通话数量仍为 19。随后重启的第一份报告已经是待机,麦克风读取次数为 0,排除了启动后重新从云端应用才停止采音。唤醒后背光 80、采音恢复,音量仍为 60。
- 首次人工交错的通话/待机检查未捕获到 busy=true,不作为通过证据。新增 `.tmp-power-controls/verify_busy_power.py` 在真实语音下行出现时自动发起待机,再核对云端通话状态和设备实际配置。
- 自动检查证据 `standby-during-call-v2.json`:02:27:07、02:27:11、02:27:15 UTC 三次均为通话忙碌、期望版本 11/standby、实际版本 10/active、背光 80、采音继续。挂断后 02:27:55 实际版本 11、standby、背光 0、采音暂停。证明待机请求不会提前中断本次通话,挂断后会被执行。
- 操作工具自身等待窗口为 40 秒,因此在这次 40 秒通话结束并同步前报告 `power transition timeout`;这是验收工具观察超时,不是设备任务终态。验收以随后带新上报时间的实际状态和匹配版本为依据,不以该工具退出码判断执行成功。该限制明确保留在证据中。
- 同次 `voice-20260922T022756Z.log` 包含持续下行 PCM、播放器写入和挂断确认,保存的仅是运行元数据。这仍不能替代扬声器听感和麦克风真实收音确认。
- 02:28:08 UTC 最终读取:设备在线,实际/期望版本均为 12,active、背光 80、采音恢复,实际音量 60。测试期间绑定数始终为 1。
- 完整目标仍未完成:恢复出厂、OTA 安装/失败回滚、蓝牙及归属变更实机回归、两端登录操作验收、小程序正式发布和真实收音/听感确认继续保留。恢复出厂须遵循现有 physical_confirm 设备先经账号授权解绑的约束,不能通过擦除工厂身份绕过归属。
## 2026-09-22 恢复出厂实现与发布(实物清空待验收)
已完成 DEVREQ1 重置接口适配、云端旧配置清理及通话/OTA 保护,并通过 devicereset、onboarding 隔离数据库测试。固件新增持久化重置日志、批准后清理、启动恢复,以及需要实体轻触确认的 BLE `factory_reset` 命令;真实 C 重置与 BLE endpoint 在 ASan/UBSan 下通过。
两端入口已实现,API/Web 发布于 `0.99.9-rc.device-reset1`;COM6 已仅更新活动应用槽至 `1.7.0-controls.5`(3,753,168 字节,SHA256 `6a898dc76fb2f916d212f6b7a8137f2445929700046405c5a693d718fa645647`)。烧录校验、线上资产校验和匿名访问拒绝检查通过。`voice-20260922T030755Z.log` 证明更新后持续下行 PCM、播放写入和结束成功回执,仍不等同于现场听感/收音验收。未对 COM6 清空设置,原绑定和音量保留;详见 [恢复出厂实现进度](device-factory-reset-controls-20260922.md)。
## 2026-09-22 更换使用老人流程与发布
- 原按钮调用旧版直接转移接口,被 COM6 的实体确认要求拒绝。现已增加附近蓝牙转移:选择新老人、检查影响、发送绑定版本受限的令牌,再由设备轻触确认。确认前原归属保持有效,事务失败完整回退,历史聊天仍归原老人。
- 复用现有 BLE 协议和数据库 claim guard,无迁移,无需再次烧录 COM6。两边家庭权限在申请和设备确认时都检查;通话/命令/OTA 期间拒绝;与 direct-call 共用事务锁,避免并发开始通话。
- onboarding 最新全包隔离测试通过(15.232 秒),saas 和 callmemory 集成测试通过;验证源权限独立撤销、重试、写入故障回滚及语音身份只注册一次。小程序 59 项相关测试和 9 页 WXML / 10 个 WXSS 原生编译通过;浏览器验证使用真实组件的模拟等待/成功/冲突流程。
- API/Web 已发布 `0.99.10-rc.device-transfer1`,基于线上 `0.99.9` 只覆盖 9 个清单文件;未部署本地其他改动。发布包 SHA256 `1a546d2f3eaf42c5e0e9fa0320c3787b32d8f1ef4addbd0f77c66a8b6bf495be`。首页和 JS/CSS 哈希、版本、运行配置及匿名拒绝检查通过,worker/callbacks/gateway/数据库/Redis/MinIO 未重建。
- 03:28 UTC 发布后 COM6 上报:`controls.5`,MQTT 已连接,绑定数 1,实际/期望音量 60,实际/期望配置版本 12,未完成命令 0。未更改真实使用老人。
- 实物转移、恢复出厂后重绑、真实 OTA/回滚、两端登录后的设备操作、小程序正式发布、声学听感及收音仍未完成。已请求家属账号登录、现场轻触配合及真实设置清除授权,尚未收到答复;仍可继续推进其他独立工作。详见 [实体确认转移](device-physical-transfer-20260922.md)。
## 2026-09-22 OTA 恢复修复与 controls.6 实机回归
- OTA 安装版本改为分槽保存,避免启动确认失败后覆盖旧固件版本;健康计时器创建/启动失败立即请求回退;数值转换检查整数与范围;记录损坏或读失败时停用 OTA 并保留待确认固件的超时保护。
- 真实生产 OTA C 全部故障注入测试通过 ASan/UBSan/float-cast-overflow 检查;ESP-IDF 编译通过。替身测试不算实物 OTA 或 bootloader 回滚通过。
- COM6 已仅更新活动应用槽至 `1.7.0-controls.6`,3,753,536 字节,SHA256 `25f2b93354b6f30492ab68a5c179359601da4cc4710852397366b699e772a0a9`;烧录校验成功,原绑定和设置保留。
- `voice-20260922T034336Z.log`:持续音频下行和实际播放器写入,观察到的错误为 0,结束通话收到成功回执。03:43:56 UTC 云端确认固件 controls.6、MQTT 已连接、绑定数 1、音量 60、配置版本 12、未完成命令 0。
- 尚未完成真实在线安装、A/B 切换和回滚;版本代码仍为 0。详细修复、证据边界和剩余验收见 [OTA 恢复记录](device-ota-recovery-20260922.md)。
## 2026-09-22 主动重启的 MQTT 离线状态修复
- controls.6 实测重启成功、MQTT 会话更换,但约 0.3 秒间隔采样没有观察到离线;快速重启时旧 TCP 会话未及时关闭,平台持续显示在线。证据 `reconnect-20260922T034715Z.jsonl`,命令 `99a040d1-21b4-4a05-9789-580c8dc9fbf5`。
- 新增应用任务使用的 `device_control_prepare_restart`:停止心跳并调用 ESP-IDF MQTT stop,在重启前发送 DISCONNECT、关闭连接。重启命令仍先持久化回执,再关闭 MQTT;启动后才发送成功回执。OTA 安装后的正常重启也使用此路径;早期启动回退和异常断电仍依赖原连接超时机制。
- 生产重启命令 C 测试通过 ASan/UBSan:覆盖持久化与断开顺序、忙碌、ACK 失败、存储失败、无 MQTT 客户端及 MQTT stop 失败。完整 OTA C 故障测试通过;ESP-IDF 编译通过。未修改在线 API/Web。
- COM6 仅更新 ota_0 至 `1.7.0-controls.7`,3,753,648 字节,SHA256 `31d154591f37daf7816ba964a5b477c47f2b0065802fe80762f2e03b3fbb831e`;写入校验成功,OTA 数据、出厂身份、NVS 保留。
- 复测命令 `fe9f342d-b802-4064-8ecb-277f49864a53`:03:51:36 UTC 观察到 `connected=false / online=false`(请求后 2.894 秒),03:51:50 UTC 新会话恢复在线(请求后 16.696 秒),命令首次投递后 SUCCEEDED。期望/实际配置仍为 12,音量 60、active、绑定数 1,未完成命令 0。证据 `reconnect-20260922T035132Z.jsonl`。
- 该检查使用真实 SaaS Service 下发命令和真实 MQTT 数据库状态,不等同于浏览器/手机页面的登录端到端验收。两端在线读取代码均使用 `fleet_presence` 的连接标志和 90 秒心跳有效期;实际 UI 刷新显示仍待账号登录验证。异常断网、真实 OTA/回滚及其他既有未完成项继续保留。
## 2026-09-22 持久化命令回执故障保护
- 新增 `tests/device-receipt-host.cjs`,编译生产 `save_result`、`flush_result`、`ack` 与真实 cJSON,注入 NVS 打开/读/写/提交失败和网络回执丢失。修复前,NVS 命名空间读取失败被当作没有待补发回执,`open-error` 场景断言失败;这会允许控制任务继续领取命令。
- 修复后只将 `ESP_ERR_NVS_NOT_FOUND` 视为没有记录;其他打开/读取失败阻止继续领取。收紧记录长度范围,并按实际读取长度解析 JSON。回执提交前仍要求重启标识变化;网络失败、删除提交失败保留同一回执重试。
- ASan/UBSan 下 `open-error` 及完整回执场景通过,覆盖同一次启动不提前确认、重启后补发、丢失响应、删除失败、无记录、损坏记录、终态冲突和普通诊断回执。此故障注入不等于实机人为损坏 NVS 或真实丢包验收。
- ESP-IDF 编译通过,COM6 仅写 ota_0 至 `1.7.0-controls.8`,3,753,664 字节,SHA256 `c5166683a9db25d050fae602441e3163e8a59f34813bf934a6333e78094fe670`;烧录校验成功。
- 实际诊断命令 `00302a24-33fd-46e4-a5f1-44d42dd9c475` 首次投递后 SUCCEEDED:codec 就绪、音量 60、网络已连接、NVS 可读且写入/提交/读回成功。证据 `diagnostics-controls8.jsonl`。
- 实际重启命令 `2e03ddf3-4fe6-429d-b066-ff791296ab9f` 首次投递后 SUCCEEDED。请求后 4.922 秒观察到离线,17.430 秒新会话上线;最终配置版本 12、音量 60、active 和绑定数 1 保留。证据 `reconnect-20260922T035730Z.jsonl`。
## 剩余工作与阻塞核对
本轮实现和可独立执行的故障保护验证有实质进展,但整体目标仍不能判定完成。登录和现场确认的缺口已连续多轮存在,未获得新的用户答复。
- 已重新读取当前浏览器:线上仍在维护中心登录页,手机号与短信验证码未填写;没有可用于家属端 OTA、蓝牙归属和控制 UI 验收的登录会话。不能通过伪造会话或绕过权限来代替。
- 添加/重新配网、更换老人、解绑后恢复出厂和重新绑定需要设备现场轻触。清除真实 Wi-Fi 和用户设置的测试仍未收到确认,未执行。
- 声学听感和真实麦克风收音需要现场反馈;音频下行和播放器写入记录只能证明软件通路。
- 小程序当前工程为 `C:/Users/19926/WeChatProjects/voicelink`、AppID `wx4553119e05f11660`。README 与设备接口文档仍记录 IP 后端及正式发布所需的合法域名/权限准备;没有当前正式版发布证据。构建、测试、原生编译不能替代开发者账号上传、微信配置和手机操作验收。
- 真实 OTA 安装及受控失败回滚、异常断网恢复、丢包后的实物命令重试仍保留在完整验收范围中;不以 USB 烧录和主机故障测试替代。
下一步需要用户完成家属账号登录,明确是否可现场操作及是否允许恢复出厂测试,并提供可用的小程序发布环境。之后继续原范围验收,不将未验收项删减为已完成。
@@ -0,0 +1,33 @@
# 小禾动态开场试用(无天气,2026-10-01)
2026-10-03 已发布 API `0.99.26-rc.natural-chat1`,并将同一试用长者的基础人设保存为 `companion-xiaozhi-v2`。普通开场改为简短日常寒暄,移除主动谜语、猜物件、词语标题与编故事活动;中性话题允许重复,只轮换近期句子,不再为轮换主题而安排活动。短答不视为请求游戏,问候不扩展成知识讲解;兴趣识别、避谈、同通冻结和记忆授权保留。新角色与欢迎语已实际保存并回读,非角色供应商配置保持不变,下一次新通话由现有 Prepare 同步。开场单测和 10 项隔离通话集成通过,真实语音自然度尚待实听;没有为验收主动发起通话。证据位于 `.local-evidence/companion-natural-chat-20261003`,以下为旧版历史记录。
已发布 API `0.99.22-rc.opening1`,基线为 `0.99.21-rc.companion1`。在设备 `KP01-020026091601` 当前绑定的长者上开启 `conversation_preferences.dynamic_opening=true`;其它长者默认关闭。下一次新会话生成新的开场,同一会话的续约和重试仍使用原冻结参数。
## 行为
- 按长者已设置的时区问候,空值、无效值或 `Local` 回退上海时区;时区数据随二进制内置。
- 有个性化记忆授权时,从档案兴趣和优先话题识别固定类别标签,不把任意原文写进开场。十二类轻话题轮换,近期五通避开相同模板和主话题;普通轻话题多数直接提供内容,正文最多 45 字,带问候与首次介绍最多约 60 字。
- 没有近期通话记录时介绍一次“小禾”;之后不反复自我介绍。上一通在十分钟内结束且存在双方真实字幕,并仍有个性化记忆和字幕授权时,使用六种简短招呼轮换。不声称已记得某段对话,也不据此认定某话题未完成。
- 遵守明确的避谈类别和模板跨题材依赖;无法识别的避谈内容使用中性开场。极端限制下允许安全的中性类别复用,优先保持句子变化。
- 不使用天气、定位、外部资讯查询或额外的模型生成请求。现有火山 Viking 抽取、`curated_memory` 注入和原生记忆开关沿用原实现。
## 会话一致性与记录
在现有长者锁和无活动通话检查内选择一条开场;`PrepareWithWelcome` 将同一句写入提交火山的配置,成功后再签发设备运行参数。它从保存的基础配置重建本通角色,补充“简短同意时沿开场继续”的引导,不把动态开场或本通背景写回基础 Bot 配置。关闭试用的长者继续使用旧 `Prepare`。
`call_session.budget_snapshot.opening` 仅保存有界的模板 ID、主话题、类别及版本,不保存档案原文或记忆正文。它表示选中了哪种开场,不代表设备已经播放或老人已聊过该话题。历史按同一长者及当前 `privacy_epoch` 筛选,个人兴趣与字幕证据分别经过授权检查;撤回后停止相应用途。
## 验证与部署
- `companionopening`、`elderagent`、`callmemory` 包测试及 Go vet 通过,Linux API 和测试程序构建通过。
- 独立 PostgreSQL、Redis 与内部测试网络中应用 74 个迁移并使用本地测试种子,10 项会话测试全部通过、0 跳过;覆盖连续开场轮换、两端同句、成功会话重试冻结、长者隔离、真实字幕重开、授权撤回和配置失败不提交。火山调用使用模拟回调,没有读写生产通话数据。测试容器、网络及临时目录已清理。
- 候选从已发布基线构建,仅包含九个本次源码/测试文件,其中 `app.go` 只移植一行接线。其它 696 个基线文件及 `go.mod`、`go.sum` 保持一致,没有夹带共享工作区的其它修改。
- 发布前确认无活动通话;只重建 API。readiness 与公网版本核对通过;schema 仍为 74 且 clean。其它容器 ID、网页内容、API 环境/挂载/端口及许可证保持一致。
- 在同一长者锁内核对设备绑定、Bot 和基础角色摘要后,只开启一个偏好字段;基础人设和欢迎语未改写。尚未实听设备首句、后续内容吸引力或首音延迟;自动测试与发布成功不能替代实际通话验收。
## 回滚与证据
动态开关的旧值保存于服务器私有备份 `/opt/data-terminal-cloud/backups/companion-opening-20261001.json`,权限 `0600`。本地 `apply-pilot.py --execute --rollback` 会在同一长者锁内核对绑定、确认无通话,只恢复该字段。下一次新通话重新走原静态开场;不得改写正在通话的 Bot。API 原镜像与上一发布目录保留,可以独立回退 API。
操作与证据在 [本次证据目录](E:/Codex/story/.local-evidence/companion-opening-20261001),包括 [发布回执](E:/Codex/story/.local-evidence/companion-opening-20261001/production-receipt.json)、[开关保存回执](E:/Codex/story/.local-evidence/companion-opening-20261001/pilot-apply-receipt.json)、[隔离测试](E:/Codex/story/.local-evidence/companion-opening-20261001/result.json) 和 [仅本次改动](E:/Codex/story/.local-evidence/companion-opening-20261001/task-only.patch)。
+36
View File
@@ -0,0 +1,36 @@
# 小禾陪聊一期试用发布(2026-10-01)
2026-10-03 已在原试用长者上替换为 [自然陪聊人设 v2](E:/Codex/story/platform/deploy/personas/elder-companion-xiaozhi-v2.md),并发布 API `0.99.26-rc.natural-chat1`。取消“短答后猜物件”的引导,小游戏及故事仅在本通明确提出后进入;普通问候、少追问和当前内容接话作为默认方式。实际角色与欢迎语读回一致,下一次新通话生效。新版记录见 [开场更新说明](E:/Codex/story/platform/docs/companion-opening-release-20261001.md),以下 v1 角色和欢迎语为历史记录。
后续已增加无天气的动态开场,见 [动态开场发布说明](E:/Codex/story/platform/docs/companion-opening-release-20261001.md)。以下保留首次人设试用发布记录。
已发布 API `0.99.21-rc.companion1`,基线为 `0.99.20-rc.ota-budget1`。已将当前试用设备绑定长者的基础人设和欢迎语保存到其专属 Bot 配置;下一次新通话由现有 `elderagent.Prepare` 同步到火山。当前完成角色与开场试用,真实语音效果仍待下一通实听验证。
## 试用行为
角色名“小禾”。先回应具体内容,再补充见闻、观点或轻玩法;通常一到三句,允许不提问,连续短答时提供内容。尊重换题、拒绝和疲惫,不编造共同经历或无工具回执的操作。可审阅来源为 [一期人设](E:/Codex/story/platform/deploy/personas/elder-companion-xiaozhi-v1.md),`source_version=companion-xiaozhi-v1`。
欢迎语:“您好,我是小禾。今天想和您聊聊老物件,也可以给您讲个小故事。”
试用限定于设备 `KP01-020026091601` 当前绑定的长者专属 Bot `botYswtEzBXb`。仅更新该行保存的 `system_role` 与 `WelcomeMessage`,读取校验确认其它供应商配置一致。Viking 上传、抽取、本地 `curated_memory` 镜像与开聊注入沿用现有实现。原生 `MemoryConfig.Enable=false` 保持原值,不增加第二条记忆读取通道。后续摘要引用和话题进度管理按设计逐步接入,本次无需数据库迁移。
## 后端修复
`internal/callmemory/direct.go` 在新会话持有长者锁、确认无活跃会话后,从同一长者和 Bot 的 `agent_config` 读取欢迎语,放入运行参数。空值或空白沿用原默认欢迎语。既有会话继续复用冻结参数;开场配置修改在下一次新会话生效。事实记忆和 S2S 凭据只送现有服务端 `Prepare`,不进入设备运行参数。
发布候选从线上基线构建,运行代码仅此文件变化;同时包含本次欢迎语测试。共享工作区原有的三项启动协议测试保持原样,其依赖的 OpenAPI 文档不同于当前线上基线,未夹带到此发布。其余 697 个候选基线文件、`go.mod` 和 `go.sum` 哈希保持一致。
## 验证
- 基线候选上的 `elderagent`、`callmemory` 单元测试及 Go vet 通过,Linux API 和测试二进制构建通过。
- 独立 PostgreSQL、Redis 和内部测试网络上应用 74 个迁移并使用本地测试种子,7 项会话测试全部通过、0 跳过。覆盖欢迎语回退、按长者读取、同会话冻结、下一通刷新、长者隔离、事实不泄漏到运行参数、并发和结束校验、字幕回调签名及去重。没有读取或复制生产业务数据,没有真实火山对话请求;隔离资源清理完成。
- 发布前确认无活动设备通话。仅重建 API;readiness 与公网版本核对成功,schema 仍为 74 且 clean。其余容器 ID、网页内容哈希、API 环境、端口、挂载和随镜像交付的许可证保持一致。
- 对目标配置在同一长者锁内更新并读回比对,确认只改变角色和欢迎语。尚未确认真实设备首句、回复吸引力或首音延迟;保存配置与集成测试通过不能替代实际通话验证。
## 回滚
服务器保存了仅包含目标旧角色与欢迎语的私有备份:`/opt/data-terminal-cloud/backups/companion-pilot-20261001.json`。人设回滚工具会核对目标绑定及当前仍为本次角色,持有同一长者锁并确认没有通话,再恢复这两个字段;下一通由现有 Prepare 同步。不得直接覆写正在通话的 Bot。
本地操作工具与证据保存在 [试用证据目录](E:/Codex/story/.local-evidence/companion-pilot-20261001)。人设回滚入口为 `apply-persona.py --execute --rollback`;API 如需回滚,沿用上一发布全部 Compose 层与环境文件,仅恢复旧 API 镜像并核对 readiness 和版本。新 API 欢迎语读取逻辑兼容旧配置,单独恢复人设即可恢复原试用长者的角色与开场。
证据包括 [API 发布回执](E:/Codex/story/.local-evidence/companion-pilot-20261001/production-receipt.json)、[人设保存回执](E:/Codex/story/.local-evidence/companion-pilot-20261001/persona-apply-receipt.json)、[隔离会话测试](E:/Codex/story/.local-evidence/companion-pilot-20261001/result.json) 与 [仅本次改动](E:/Codex/story/.local-evidence/companion-pilot-20261001/task-only.patch)。
@@ -0,0 +1,15 @@
# 陪伴准备数据授权简化及上线
“长者资料 → 陪伴准备 → 数据授权”直接打开五项勾选列表,复用创建长者的列表及样式。已有有效授权显示已勾选;新增或取消勾选后一次确认保存。取消、关闭和 Escape 不提交调整。详细说明可按需展开。
保存仍通过现有分项授权和撤回 API 逐项记录政策版本及告知摘要,不增加后端接口或迁移。提交中锁定操作;保存前读取最新状态,只应用用户调整的项目。部分失败保留剩余选择并刷新状态,重试不会重复提交已生效授权。只读账号仅查看,过期或已撤回授权不显示为有效。
已发布 `0.99.44-rc.consent-checklist1`,以上线版本 `0.99.43-rc.elder-roster2` 为基线,保留刚发布的长者列表。仅叠加三个相关源文件及新网页构建;共享工作区其他未发布修改没有加入。发布时间(北京时间):`2026-10-05T20:02:26.600798+08:00`。
最终候选的 TypeScript/Vite 构建、两项授权状态单元检查、现有授权提示回归和五项勾选浏览器验收通过。验收涵盖明确提交、取消、重复提交防护、撤回、部分失败重试、并发授权、读取失败重试、切换长者、过期与撤回状态、只读账号、桌面及手机布局。脚本和静态资源错误均为空。
线上核对 129 个静态文件、公开入口及运行配置哈希,API/worker 就绪,其他服务容器及启动时间保持一致。数据库 schema 74/clean,没有迁移或业务数据写入。真实浏览器显示全部五项授权、正确回显已有授权,未修改时确认保存禁用,脚本错误为空;没有提交真实授权调整。
证据:`E:/Codex/story/.local-evidence/subject-consent-checklist-20261005/attempt-2/` 下的 `production-receipt.json`、`release-manifest.json`、`production-ui-checks.json`、`deployment.log`、`candidate-build.log`、`candidate-checklist.log`、`candidate-guidance.log`、`candidate-unit.log` 和 `candidate-qa/`。
发布前曾检测到线上基线更新,首个尝试在切换前停止,重新基于最新线上源码构建后发布。回退目标为 `0.99.43-rc.elder-roster2`;部署脚本在切换后的校验失败时自动回退。
+13
View File
@@ -6,6 +6,19 @@
当前抽取器支持两类可切换适配器:默认的`local-deterministic-extractor`用于本地业务验证,`ark:<model-id>`通过火山方舟OpenAI兼容Chat Completions接口生成结构化候选。向量生成与查询同样支持`local-deterministic-hash-v1`和`ark:<embedding-model-id>`两类适配器,并强制使用数据库既定的1536维。生产配置门禁禁止内容生成、索引或查询继续使用本地适配器;真实方舟账号效果仍需外部验收。
### 新硬件 DIRECT-CHAT-1 接入(2026-10-05)
迁移 `000075_direct_call_stories` 为故事增加原生 `call_session` / `call_utterance` 来源,不复制或伪造旧 `conversation`。API、worker 和 Web 需随迁移一起发布;固件接口不变。
- 开聊时冻结 `story_extraction_allowed`,要求当时存在转写保存和故事提取授权。只处理升级后带此快照的新通话,不批量回填旧通话。
- 结束并完成字幕排空后,worker 按通话、转写版本、隐私版本投递 `call.story_extraction_requested`。Viking 记忆摘要同步保持独立;关闭个性化记忆不会替代故事提取授权。
- 共用现有内容模型和输入/输出安全筛选,只把未删除、未屏蔽的长者最终原话作为材料。有故事价值时写入待审核草稿、原话来源和事实候选;寒暄或无故事价值内容不建草稿。
- 重放不重复生成;迟到字幕仅替换尚未人工处理的候选。人工修订、确认、拒绝或事实审核后,后台不覆盖其结果。
- 在故事页“查看已整理的故事”中审核,可从故事打开对应设备通话。确认保存仍要求当前个性化记忆授权,并保存原始通话、话轮及修订引用。
- 生成提交、读取及人工审核均检查当前授权、隐私版本和来源有效性。撤回授权、删除/屏蔽原话或来源修订失效后,不接受旧异步结果继续落库。
本地验证包括数据库提取→修订→确认保存、重复事件、迟到字幕、授权/来源失效、跨家庭权限、分享,以及 `test/e2e/direct-story-history.mjs` 的原始通话定位、翻页、刷新和权限拒绝。
## 2. 功能设计
| 功能 | 输入 | 处理规则 | 输出 |
+22
View File
@@ -0,0 +1,22 @@
# 创建老人授权步骤
用户明确要求在创建老人时列出全部授权,由用户授权。此次修改替代此前新建资料默认开启字幕和记忆的行为。
网页家庭入口及设备配网入口采用“填写资料 → 开通授权 → 确认创建”。授权步骤展示字幕保存、原录音保存、故事整理、个性化记忆、家庭分享五项用途。各项初始未选中,用户可逐项勾选,也可选择“暂不开通,完成创建”。故事和记忆需要字幕保存,界面会提示该依赖。
小程序沿用已有创建后立即弹出的分项授权页,更新创建提示和跳过按钮,移除默认开通说明。源码位于 `C:/Users/19926/WeChatProjects/voicelink`,需在微信开发者工具重新编译并上传才能更新手机端文案。
两条后端创建入口支持 `initial_consents: { types: string[], confirmed: boolean }`。只记录明确选择并确认的项目;省略或空选择均不开通任何分项数据授权。非空选择未确认、未知类型、重复类型返回验证错误。资料、授权记录、审计、快照在同一事务提交,失败全部回滚。重放创建请求不重复授权;再次绑定和修改资料不会恢复撤回的权限。
既有授权按已有记录保留,不批量补充或撤回。本次创建步骤修改不包含生产业务数据写入。
## 验证及发布
- 工作区隔离数据库回归 439 项通过;精确线上候选使用 74 个迁移,saas 42、onboarding 154、httpapi 237 项通过,合计 433,零跳过。
- 验证包括不授权、部分授权、全部授权、非法选择拒绝、失败回滚、请求重放、撤回后绑定不会恢复授权。
- Go vet、Linux API 构建、网页 TypeScript 检查和生产构建通过。
- 小程序 144 项测试通过,原生编译器成功编译 9 个 WXML、10 个 WXSS。
- 已上线 `0.99.40-rc.creation-consent1`,以线上 `0.99.39-rc.initial-memory1` 为基线,仅包含 15 项任务源文件变化和 API/Web 构建产物,不包含共享工作区其他未发布修改,无数据库迁移。
- 生产 API 版本、运行二进制 SHA256、122 个网页静态文件及运行配置回读通过;数据库保持版本 74 且 clean,其他服务容器与启动时间保持一致。
证据目录:`E:/Codex/story/.local-evidence/creation-consent-step-20261005`。最终线上状态以 `production-receipt.json`、`deployment.log` 为准,候选测试记录位于 `candidate/integration-results/results.json`。
+67
View File
@@ -0,0 +1,67 @@
# 家庭端全页重实现与后端配套 — 2026-09-20
已按确认的家庭端视觉方向完成 18 个页面/状态,维护中心 9 个业务区统一视觉与响应式布局。2026-09-20 已部署线上:Web `0.99.1-rc.portal-display1`、API `0.99.0-rc.portal1`、数据库迁移 73;健康检查及家庭端真实会话抽查通过。发布详情见 [发布记录](portal-redesign-release-20260920.md)。
## 页面与接口对应
| 页面 | 实现和配套能力 |
| --- | --- |
| 首页 | 陪伴准备、设备状态、本周统计、最新聊天;读取长者、设备、聊天、准备度接口 |
| 聊天记录 | 已加载内容搜索、日期/状态筛选、摘要/文字/录音;保留独立录音访问授权 |
| 故事与记忆 | 记忆卡片/详情/编辑/删除/历史、故事候选审核;调用 call-memory 与故事版本接口 |
| 长者资料 | 档案摘要、偏好、已核实事实、授权入口 |
| 提醒与待办 | 待确认故事、离线设备、数据请求及最近 7 天动态 |
| 家庭管理 | 成员角色、邀请、移除、权限与家庭创建,保留确认和幂等处理 |
| 陪伴设备 | 绑定、在线、设置同步分别呈现,电源/网络指引 |
| 内容分享 | 当前授权、历史、撤销、一次性凭据;内容与权限分别授权 |
| 数据与授权 | 五项独立授权、数据导出/更正/删除/撤回及进度 |
| 服务用量 | 实际分钟、次数、每日活动、近期记录;未核价费用不可用 |
| 设置与权限 | 昵称编辑、密码管理、各家庭角色/权限、退出登录 |
| 我的陪伴设备 | 账户设备列表,服务开通与网络在线分开显示 |
| 设备设置引导 | 有效入口走原绑定/设置链路;失效入口提供重新打开和恢复指引 |
| 登录 | 新插画与双栏布局;保留验证码、密码、腾讯验证码与既有认证逻辑 |
| 编辑长者资料 | 头像、名字、语言/时区、节奏、兴趣、话题、陪伴方向;版本确认保存 |
| 设备详情 | 状态、音量下发、同步反馈、诊断、使用人转移和解绑 |
| 新建分享 | 内容→接收人/权限/期限/用途→确认;沿用后端单权限 grant 模型 |
| 绑定设备 | 仅保留蓝牙入口;搜索附近设备、选择连接后发送 Wi-Fi 配网信息 |
维护中心:维护总览、设备管理、固件管理、运行配置、大模型管理、内容质量、用量与费用、告警处置、联调与诊断。原业务接口与受控操作保持,移动端工具栏和表格已适配。
## 补齐的后端
1. `PATCH /api/v1/me/profile`:保存当前认证身份的昵称。仅接受 display_name,校验 1–80 个 Unicode 字符且不含控制字符,不能指定其他用户;同步该身份所在的有效账户。沿用 Cookie/Bearer 认证、CSRF 和限流。
2. `GET /api/v1/events/history`:返回当前成员仍可访问的家庭在最近 7 天内最多 200 条保留事件元数据。验证成员、账户、家庭和组织有效,响应 no-store;没有聊天正文。
3. 迁移 `000073_customer_activity_history`:事件默认保留期从 15 分钟扩展到 7 天,增加家庭成员变化事件及查询索引。前端将历史与实时事件去重、排序,并说明时间和条数范围。迁移不会恢复已经过期或清理的旧事件。
4. 更新 OpenAPI 契约和生成的前端类型。
其余主要能力已有真实服务支持,本轮重接界面并验证;没有用本地示例接口替代生产功能。
## 验证结果
- `npm run generate:api`、`npm run build` 通过。构建保留既有大包体积提示。
- `node --test test/home-model.test.mjs`:6/6 通过。
- `go test ./internal/contractgovernance ./internal/httpapi ./internal/identity ./internal/webevents` 通过。
- 临时 PostgreSQL 数据库应用全部迁移与种子,独立运行 identity、webevents、saas、sharing、billing、callmemory、httpapi、onboarding(8 包)通过。日志:`.tmp-all-pages-tests-v4/`。
- opsdevice、opsconfig、alerts、contentmodel、privacy、diagnostics(6 包)同样通过。最终完整日志:`.tmp-all-pages-ops-final/`。
- 新测试覆盖昵称校验与身份隔离、匿名/无效请求、历史保留与过期过滤、重复读取及跨家庭隔离。测试使用本次创建的临时库,并由脚本仅清理这些临时库。
- 本地浏览器检查使用生产同一组件与 DEV 独立示例数据。查看了家庭端 18 个目标及维护端 9 个区的页面,并检查手机、平板和宽屏;详情见 `web/design-qa.md`。
- 检查生产 `dist` 不含 `portal-preview.html` 或隔离示例数据代码。
## 本地查看
启动:在 `web` 下运行 `npm run dev -- --host 127.0.0.1 --port 5180`。
- 家庭端:`http://127.0.0.1:5180/portal-preview.html?section=home`
- 登录:`http://127.0.0.1:5180/portal-preview.html?screen=login`
- 设备入口:`http://127.0.0.1:5180/portal-preview.html?screen=onboarding-devices`
- 设置引导:`http://127.0.0.1:5180/portal-preview.html?screen=setup`
- 维护中心:`http://127.0.0.1:5180/portal-preview.html?screen=maintenance&area=overview`
- 家庭空数据检查可添加 `&state=empty`。
预览只在开发入口启用;生产运行仍使用真实 API。浏览器验收未调用真实短信、未操作实物蓝牙、未创建真实对外分享或删除真实数据;这些外部链路不能由本地界面预览证明已完成线上验收。
## 线上发布
已以线上 `0.98.3-rc.home-width1` 为基线组装独立候选,只叠加本轮所需源码和资源,保留既有登录、验证码修复。完成数据库备份、迁移 000073、API 与 Web 发布;随后仅更新 Web,修复手机号登录时设置页账户标识为空的问题。
使用已有真实登录会话检查了首页、聊天记录、提醒与待办、陪伴设备、服务用量、设置与权限,数据正常加载;复查期间浏览器没有新增 warning/error。没有修改真实昵称、权限或分享数据;维护中心真实角色全流程、短信及实物蓝牙链路不在本次线上抽查范围。原发布目录、镜像和服务器数据库备份均保留。
+31
View File
@@ -0,0 +1,31 @@
# 新手机号授权自动建号修复
## 原因与修复
线上 API 的 `ONBOARDING_SELF_SIGNUP_TENANT_IDS` 为空。微信手机号授权成功后,已有自动建号代码因此拒绝创建新账号,并返回 `SMS_ACCOUNT_NOT_REGISTERED`。这不是需要先绑定设备的业务限制。
2026-09-16 已在 82.157.201.114 当前部署的 `deploy/server/.env` 明确配置客户组织 public_id:`10000000-0000-0000-0000-000000000010`。只重建 API 容器并刷新网关解析,未更换后端镜像,未修改数据库结构,未发布小程序。
## 预期流程
1. 微信首次登录:请求授权手机号。
2. 后端向微信校验授权结果,按已验证手机号查找账号。
3. 未注册的手机号:在部署配置指定的客户组织创建普通客户账号,关联微信并返回登录凭据。
4. 已注册的手机号:复用原账号,不重复创建。
5. 新账号没有家庭、老人和设备绑定,由用户登录后自行创建。
只输入手机号或密码不能绕过手机号验证自动建号;管理员登录不开放自动注册。
## 验证
- 线上 HTTPS `/api/v1/web/login/session` 返回 HTTP 200,`signup_enabled=true`。
- 12 项微信与短信登录测试通过(另含两个子用例),包括新增 `TestWeChatNewPhoneCreatesCustomerWithoutFamilyOrDevice`。
- 新增用例覆盖:授权前不建号、首次授权创建普通客户账号、没有家庭/设备也能注册、重复微信登录复用账号。
- 数据库测试使用独立临时数据库(仅复制线上结构,无线上用户数据)和独立 Redis;执行后均已移除。
- 测试使用模拟微信/短信提供方,没有给真实手机号发短信,没有预先注册用户的测试账号。真机微信授权需用户重新尝试。
## 部署注意
后续迁移或重新部署须保留这一配置。不能仅检查已有账号能登录,应同时检查 `signup_enabled`,并执行全新手机号授权建号回归。
当前部署环境文件留有修复前的受限备份 `.env.before-customer-signup-20260916`,仅用于定向回滚,勿覆盖后续新增配置。
+70
View File
@@ -0,0 +1,70 @@
# 家庭端手机号验证码登录
家庭平台使用独立 `/login` 页面,默认“手机号 + 短信验证码”,无需先打开设备绑定链接。未登录访问业务页会自动跳转,登录后返回原目标页面。当前页面默认中国大陆 +86 手机号;后台继续接受 E.164 格式。维护端保留 OIDC 登录。
## 已实现
- 手机号校验、6 位验证码、60 秒重发倒计时、5 分钟过期提示;改手机号后清除旧挑战和验证码。
- 独立匿名登录会话,无设备、绑定码、RTC 服务也能登录。
- 服务端验证码只存 keyed hash,手机号加密存储;验证码限 5 次尝试、一次性消费,并绑定发送时的浏览器。
- 手机号限额与设备绑定短信共用:60 秒内不得重发、每小时最多 5 次;另有 IP、浏览器会话及全局限额。
- 相同请求号重试不重复发送;网关结果不确定时明确提示,不伪造成功、不返回测试验证码。
- 登录后使用 HttpOnly、Secure、SameSite=Lax 账号 Cookie,24 小时有效。重新加载页面恢复登录;家庭写请求携带 CSRF,仍执行原有家庭/记忆/敏感权限校验。
- 同一账号 Cookie 的 CSRF 可跨标签页重新读取;过期、撤销和退出后业务接口返回 401,页面移除原有私密内容。
- 账号 Cookie 只开放明确列出的家庭接口;维护接口、设备协议和服务商回调保持各自认证方式。
入口:`GET /api/v1/web/login/session`、`POST /api/v1/web/login/sms/send`、`POST /api/v1/web/login/sms/verify`。登录后继续通过 `GET /api/v1/me` 读取服务端账号和家庭权限。
## 真实短信配置
当前代码支持原有 HTTPS 短信网关,也支持直接调用腾讯云短信服务;部署配置和小程序接口见[腾讯云验证码短信接入](tencent-sms-integration.md)。未配置真实短信供应商时不会发送短信。浏览器自动化中使用的验证码只存在于测试脚本,不是产品可用的固定验证码。
API 和 worker 需要相同的部署配置:
| 配置 | 用途 |
|---|---|
| `ONBOARDING_SMS_LOGIN_ENABLED=true` | 开启设备绑定及家庭首页短信入口 |
| `ONBOARDING_MASTER_KEY` 或对应 `_FILE` | 已有 32 字节主密钥的十六进制值;已有环境继续使用原密钥 |
| `ONBOARDING_ENTRY_URL` | 与家庭网站同域的固定 HTTPS 入口,例如 `https://example.com/onboarding/entry`,用于同源检查 |
| `ONBOARDING_TRUSTED_PROXY_CIDRS` | 明确的反向代理网络;代理转发 `X-Forwarded-Proto: https` |
| `ONBOARDING_SMS_GATEWAY_URL` | 固定 HTTPS 网关 POST 地址,不带账号、查询串或跳转 |
| `ONBOARDING_SMS_GATEWAY_TOKEN` 或对应 `_FILE` | 网关访问凭据 |
| `ONBOARDING_SELF_SIGNUP_TENANT_IDS` | 配置唯一的租户 public_id 时,未注册手机号可创建该租户下的普通客户账号;空或多个值时仅登录已有短信账号,新用户通过设备绑定入口按设备所属租户注册 |
首页短信登录不依赖 `ONBOARDING_ENABLED`、设备开通或 RTC 开关。部署时运行迁移 `000060_customer_sms_login`,并使用配套 API、worker、Web。无需刷机。完整开发环境执行 `scripts/dev.ps1` 会同时应用其他尚未执行的迁移,包含此前的通话记忆改动,应在联调环境使用。
网关约定:请求 Header 为 `Authorization: Bearer <gateway-token>` 和 `Idempotency-Key: <challenge-id>`,JSON 为:
```json
{
"phone_e164": "+8613800000000",
"code": "本次随机生成的六位数字",
"purpose": "LOGIN",
"request_id": "挑战UUID",
"expires_in_seconds": 300
}
```
网关必须按 request_id 去重;接受发送返回 2xx。当前接口是统一网关协议,不可直接把阿里云/腾讯云等供应商的原生 API 地址填进来;需由网关完成签名、短信签名名称及模板映射,或另接对应供应商适配器。
## 旧账号
已有设备绑定短信账号直接复用原身份和家庭数据。原 `owner` 等 OIDC 账号没有已验证手机号关联,不能凭输入某个手机号自动合并。因此独立登录页保留“账号登录”;手机新账号不会自动继承原 OIDC 账号的家庭。若要完全关闭原账号入口,需要先完成已验证手机号与旧账号的迁移。
## 验证
在仓库根目录运行(先启动本地 PostgreSQL 和 Redis):
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "& './scripts/test-onboarding-integration.ps1' -Packages onboarding,httpapi -VerboseTests -OutputDirectory '.tmp-sms-login'"
go test ./...
go vet ./...
npm run build --prefix web
$env:PLAYWRIGHT_REQUIRE_FROM = "$env:USERPROFILE/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright/package.json"
node test/e2e/customer-sms-login.mjs
node test/e2e/login-routing.mjs
```
数据库脚本创建临时库并自动清理,不迁移现有业务库。新增测试覆盖无设备登录、Cookie/CSRF/退出、重复发送、设备入口共享限额、错误次数/过期、跨浏览器/跨站、网关失败、已有账号复用和禁止未授权注册。短信发送器为内存测试替身,数据库真实。
浏览器测试运行真实登录页面、路由、认证状态和 API 客户端,验证短信交互与登录返回流程。证据在 `.design-evidence/customer-sms-login/` 和 `.design-evidence/login-routing/`,包含桌面/手机截图和 `result.json`。设备绑定入口及 OIDC 返回规则见 [Web 登录与页面恢复](web-authentication-session.md)。真实短信送达、供应商签名模板和生产 HTTPS 反向代理仍需完成配置后实测。
+32
View File
@@ -0,0 +1,32 @@
# 先添加设备,后绑定老人
2026-09-18 · 后端已上线版本 `0.94.0-rc.account1` · 数据库迁移 68。
小程序新增流程:登录客户账号 → 蓝牙配网并添加设备 → 设备列表显示“待绑定老人” → 创建家庭和老人 → 设备管理 → 绑定老人。
## 接口变化
- `POST /api/v1/mini/ble-device-state`:未归属的设备只要求同组织的有效客户账号,不要求家庭或老人。已有归属仍校验账号或家庭管理权限。`bound` 表示已有账号或老人归属。
- `POST /api/v1/mini/ble-bind-tokens`:只传工厂 `device_id` 即可申请账号归属令牌;显式传 `elder_id` 仍支持旧的一步绑定流程。
- `POST /api/v1/device/ble-bind`:保持原请求及成功响应的五个火山字段。设备实体确认后才建立账号归属;不会自动创建家庭、老人或开启聊天。
- `GET /api/v1/devices`:包含已添加到账号、尚未绑定老人的设备。这类设备的 `account_binding_id` 非空,`household_id`、`subject_id`、`subject_name` 为空字符串。可查询固件、在线状态、重新配网和移除。
- `POST /api/v1/mini/device-subject`:提交平台设备 UUID `device_id`、老人 UUID `elder_id`、列表返回的 `expected_account_binding_id`,并携带 `X-Request-ID`。要求当前账号拥有设备且有目标老人管理权限,不能跨组织。相同归属版本和老人可重试。
- 绑定老人时原账号归属转为已有家庭设备绑定,并复用火山注册身份;不会再次注册火山。
- 只有老人绑定完成后才可聊天;没有老人时聊天接口仍返回 `409 DEVICE_UNBOUND`。
- 移除设备沿用 `binding-impact` / `binding-change`。旧令牌立即失效,再次添加需要设备实体确认。恢复出厂须先解除账号/家庭归属。
## 验证
- 独立 PostgreSQL 和 Redis 容器,完整执行迁移 1–68;未在业务数据库中制造测试家庭、老人或设备绑定。
- 集成覆盖:无家庭账号添加、设备列表可见性、其他账号拒绝、竞争令牌失效、并发重试、后建家庭老人、真实 Mini 鉴权后的老人分配、相同分配重试、火山只注册一次、无老人聊天拒绝、移除与旧令牌重放拒绝。
- 小程序 101 项测试、9 页 WXML / 10 个 WXSS 编译通过。
- 网页 API 类型生成与 TypeScript 检查通过。
- 后端 onboarding、saas、httpapi、devicereset、callmemory 集成测试通过。
- 线上原问题账号(家庭数量为 0)只读验证通过:设备状态检查允许继续,设备仍未绑定。线上未创建测试家庭、老人或设备归属。
- API、worker、callbacks 已切换至新版本,数据库迁移 68 状态正常;小程序代码已完成,尚未上传发布。
## 客户端更新
小程序代码位于 `C:\Users\19926\WeChatProjects\voicelink`。旧小程序仍包含提交前的老人校验,需要更新小程序版本才能使用新流程。现有设备 BLE 消息和设备 HTTP 接口不变,无需为本次流程调整重新烧录。
数据库回退脚本在已有账号设备归属时拒绝删除数据;运行版本回退必须先评估新增归属,不能仅切回旧后端。
+12 -1
View File
@@ -1,7 +1,9 @@
# 设备 API 兼容性约定
> 2026-09-18 更新:小程序支持先添加设备到账号、后创建家庭和老人。设备端 BLE 与 ble-bind 的五个火山字段保持不变,具体变化见 [账号优先流程](device-account-first-20260918.md)。
> 当前设备接口主版本:`/device/v2`
> 当前接口契约:OpenAPI 0.78.0
> 当前接口契约:OpenAPI 0.79.0
> 适用对象:云端 API、终端固件、模拟终端和设备联调工具
## 1. 兼容目标
@@ -84,3 +86,12 @@ CI在拉取请求中以目标分支OpenAPI为基线执行破坏性变更检查
## 首次接入协议隔离
`ONBOARDING-1.2-SAAS` 仅适用于生产登记为 `physical_confirm` 的新设备,不依靠固件版本猜测协议。工装写入该契约标识、物理编号、raw32字节认证密钥和版本。新设备调用 `/device/v3/agent-sessions`;同一路径新旧认证由已登记身份区分,认证失败不降级。旧 `/device/v2/conversations` 创建不向新设备开放。新设备恢复出厂前须授权解绑,重置响应不含旧claim码。
## CALL-MEMORY-1.1 并行协议与迁移门禁
OpenAPI 0.79.0 新增 `/api/v1/device/calls/*`,复用 ONBOARDING-1.1 DEVREQ1 身份,使用 CALL-MEMORY-1.1 契约;可选 contract_version 声明不匹配时拒绝。旧 `/device/v2`、`/device/v3` 不会被静默解释为新协议。
关键记忆变更或授权撤回后,主体 privacy_epoch 提升,旧 v3 新会话返回 `426 CALL_MEMORY_PROTOCOL_REQUIRED`;旧活动会话进入清理。新会话必须通过独立清单和首次 heartbeat 授权,不能依靠 session_updated 续租。设备回报 SDK 停止只证明本地 SDK 状态;远端 UNKNOWN 仍需可核验证据。
当前板卡仍在旧 v3/WS 链路,只完成本地音频与快速重拨修复;完整 RTC 客户端和实机核验见 [实施记录](./CALL-MEMORY-1.1实施记录.md)。新固件尚未完成前,禁止把该版本按老设备无感兼容升级发布。
+42
View File
@@ -0,0 +1,42 @@
# 蓝牙设备检查的具体错误
2026-09-18 · 后端版本 `0.94.1-rc.ble-errors1` · 无数据库迁移。
覆盖 `POST /api/v1/mini/ble-device-state` 及小程序、网页的 `ble-bind-tokens`。原来的“设备不可用或当前账号没有设备管理权限”已拆成实际失败条件。未创建家庭、老人仍允许添加未归属的设备。
| HTTP | code | 原因 |
| --- | --- | --- |
| 400 | DEVICE_ID_INVALID | 设备编号格式不正确 |
| 404 | DEVICE_NOT_REGISTERED | 平台未找到设备注册记录 |
| 409 | DEVICE_NOT_RELEASED | 已登记,但未完成出厂放行 |
| 409 | DEVICE_ENROLLMENT_UNSUPPORTED | 未启用蓝牙实体确认绑定 |
| 403 | DEVICE_REVOKED | 设备被停用 |
| 403 | DEVICE_LOST | 设备被标记丢失 |
| 403 | DEVICE_QUARANTINED | 设备被隔离 |
| 403 | ACCOUNT_NOT_FOUND | 登录身份无对应平台账号 |
| 403 | ACCOUNT_DISABLED / ACCOUNT_DELETED | 账号已停用 / 已删除 |
| 403 | CUSTOMER_REQUIRED | 使用了管理账号,需切换家庭账号 |
| 403 | DEVICE_TENANT_MISMATCH | 设备不属于当前账号所在组织 |
| 403 | TENANT_INACTIVE | 设备所属组织已停用 |
| 403 | DEVICE_OWNED_BY_OTHER_ACCOUNT | 设备已添加到其他账号,需原账号操作或移除 |
| 403 | DEVICE_MANAGE_PERMISSION_REQUIRED | 已绑定家庭,当前账号无该家庭设备管理权限 |
| 409 | DEVICE_BINDING_STALE | 设备归属记录与当前状态不一致 |
| 409 | ALREADY_BOUND | 令牌申请时设备已有归属,无需重复添加,可重新配网 |
| 400 | ELDER_ID_INVALID | 令牌申请时显式传入的老人编号格式不正确 |
返回格式保持不变,示例:
```json
{
"ok": false,
"code": "DEVICE_NOT_RELEASED",
"message": "设备已登记但尚未放行,请联系管理员完成出厂放行",
"request_id": "与请求对应的追踪编号"
}
```
多项条件同时不满足时,先返回账号/组织准入问题,再返回设备状态与归属问题。不给出其他设备归属账号的姓名、手机号或老人资料。数据库、内部故障仍走服务器异常处理,不将原始 SQL、凭据或内部错误暴露给客户端。
小程序现有请求层和设备选择页面直接展示 message,已用回归测试验证具体提示能保留到界面、业务 403 不会清除登录。此提示调整无需更新小程序或设备固件;此前“先添加设备、后绑定老人”的新入口仍需发布新版小程序。
验证:独立 PostgreSQL/Redis 实跑完整 onboarding 集成测试,覆盖上述设备状态、账号、组织、账号归属、家庭权限、归属失效及真实 Mini HTTP 错误信封;小程序 103 项测试通过。
+31
View File
@@ -0,0 +1,31 @@
# 设备聊天静默自动结束修复
日期:2026-10-04。目标测试设备为 `KP01-020026091601`,型号 `ECHOEAR-S3`。
最新进展(2026-10-05):用户授权直接下发后,静默修复已与“再次轻触屏幕挂断”一起通过 `.29 / code 12` 远程安装到清库后重新登记的 `KP01-8995ACF5E69E`。设备已回报成功并恢复新版本在线心跳;实物静默与触摸验收仍待人工操作。详见 [点击挂断交付记录](device-screen-call-20261005.md)。下方保留 10 月 4 日的历史状态。
## 现象与原因
当前设备运行 `1.7.0-controls.27 / code 11`。云端只读查询确认,最近一通仍为 TALKING,最后文字后约十五分钟仍持续续期。`.27` 为避免旧 WebSocket 计数器误切断 RTC 通话,取消了 RTC 的旧 60 次循环超时,但没有补充静默超时。
通过现有 USB `diag-stop` 正常结束这通聊天,串口观测到 `chat end acknowledged=1`。未重置绑定或 Wi-Fi。
## .28 行为
- 使用单调时钟:连续 60 秒没有语音活动时,走既有结束流程,先关闭本地音频,再停止 SDK、上报结束并恢复待机。
- 本地 VAD 开始、结束及检测到说话时的采集帧刷新活动时间;静音采集帧不刷新。
- 非空字幕、助手回应状态及有声下行 PCM 刷新活动时间;LISTENING 状态和平台续期不刷新。
- 实机观察发现 RTC 在安静时仍连续发送下行 PCM。因此不按音频包长度刷新计时:下行 PCM16 的平均绝对幅值至少为 64 时才算有声,判断发生在扬声器音量调整之前。这个门槛尚需实机回归验证。
- 回调只更新原子活动状态,结束操作仍由通话任务执行。保留授权、平台停止及主动挂断检查。
## 已完成验证
- ESP-IDF 5.5.1 编译成功,应用镜像 3,772,944 字节,符合 4 MiB OTA 分区限制。
- 两个提取实际生产 C 函数的主机测试通过 UBSan:60 秒静默结束、活动延续至三分钟后结束、VAD 开始/结束、静音与低幅度 PCM 排除、32 位时钟回绕、原有通话显示与结束流程、USB 诊断及麦克风门控。
- 固件版本 `1.7.0-controls.28`,SHA256 `5feedafd1fd235b0cf158f95fef3e8aedc2889053fec9f5195e62b5c540c7ef4`。
## 待完成
自动审批拒绝将专有固件上传至 `https://82.157.201.114` 并创建持久发布记录,理由为没有对该固件与上传目标的明确用户授权。没有上传、创建 .28 发布或触发 OTA。设备仍为 .27。
授权后沿用平台现有服务发布流程,校验 HTTPS 回读哈希,通过设备管理页给这台测试设备升级,再实测持续聊天超过一分钟、双方安静后约 60 秒自动结束及云端终态。当前没有把本地测试当作实机验收。
+39
View File
@@ -0,0 +1,39 @@
# 挂断后再次开聊无回应修复
2026-10-05,用户反馈:首次轻触屏幕可正常聊天,轻触挂断后再次启动不能聊天。当前目标为 `KP01-8995ACF5E69E`,公开编号 `3ef575a6-bbc0-4e00-9a5c-58a8157d9520`,运行 `1.7.0-controls.29 / code 12`。
只读云端检查确认设备在线、有效绑定 1、配置 2/2,最近三通均已结束并收到 SDK 停止回执。COM6 的 VID:PID 和硬件标识与此前确认的目标一致;没有复位或启动通话,仅发送 `diag-state` 读取数字诊断。设备在结束通话后内部空闲内存仅为 695 字节,最大可分配块 128 字节。
## 已定位并修复
- 正常录音关闭在停止管线后立即将句柄置空,后面的 `esp_gmf_pipeline_destroy` 永远不会执行。现在保留句柄,待读取者退出后完整销毁;启动失败也释放已分配的录音资源池。
- 收音停止以前没有等待旧工作任务退出,录音 FIFO 可能在读取者仍持有数据块时被销毁,下一次启动也可能与旧任务重叠。现在先关闭采集入口、终止阻塞读取,等待工作任务结束,再回收任务、缓冲和句柄。读取者计数保护 FIFO 生命周期。
- 收音和通话任务由 `xTaskCreatePinnedToCoreWithCaps` 创建,原来通过普通自删除退出,未使用相应的 WithCaps 回收。现在收音控制方和通话管理器分别从其他任务上下文释放原始任务句柄;结束事件处理完毕才重新开放通话入口。
- 检查收音初始化和任务创建结果,启动失败进入既有结束流程,避免继续显示成功连接却没有收音。
以上代码问题和当前内存不足有明确证据;尚未通过更新后的实机连续聊天验证,因此不将所有第二次无回应原因视为已经排除。
## 本地验证与交付
ESP-IDF 5.5.1 编译通过。对照已交付 `.29` 检查 5403 项生产输入,仅改动录音处理、收音 HAL、通话任务、通话管理器、版本头五个文件,保留其余输入。
四项提取真实生产 C 的主机测试通过 UBSan。新增测试运行 100 次带并发读取的收音/待机切换,覆盖阻塞读取终止、成功 PCM 回调、管线/任务/缓冲回收、重复停止、启动失败恢复和最终销毁。屏幕点击测试验证管理器调用 WithCaps 回收;原有显示、静默计时和诊断回归通过。主机调度模拟不代替 ESP32 实物验收。
固件 `1.7.0-controls.30`,应用镜像 3,772,992 字节,SHA256 `3a20b71f6c8d1f376b05a9520dc16e3227728d8f8fed038a803bf2818522bbb3`,符合 4 MiB OTA 槽限制。交付目录为 `artifacts/firmware/1.7.0-controls.30`,包含应用、manifest、验证记录和五个冻结源文件。可比对的旧源差异单独标为 `source-delta.partial.patch`;录音处理和收音 HAL 未找到哈希匹配的旧源副本,不把部分差异称为完整差异。
证据保存在 `E:/Codex/story/.local-evidence/call-restart-20261005`。没有保存通话正文或音频。
## 授权前部署状态
本次尝试通过现有 SSH service 维护流程上传冻结包并创建云端发布时,自动审批在命令执行前拒绝:用户已授权修复和验证,但尚未明确授权向现有云平台发布或部署固件。未上传、创建新发布或启动 OTA,设备仍为 `.29`。已请求授权将 `.30` 发布到 `https://82.157.201.114`,并仅升级当前设备。
授权后需核对设备空闲和 OTA 状态,发布并校验 HTTPS 全量回读,沿用已登录家庭端安装,确认新版心跳及绑定/配置保留。设备连续轻触开聊、挂断、再开聊和实际双向语音仍需实机验收。
## 已安装(2026-10-05)
用户明确回复“升级”后,已将 `.30 / code 13` 发布到现有云平台,service 维护审计及 HTTPS 全量回读哈希均通过。首次发布工具因 API 运行镜像已由 `.35` 更新为 `.40` 而在维护执行前停止;只读核对后更新校验基线,没有改生产镜像或服务。
第一次 OTA `b488ec0d-d049-4bac-b2f4-5a24189fbd7a` 下载完整,但校验阶段返回 `ESP_ERR_NO_MEM / 0x101`,原版保留。通过真实家庭端重启后重试,OTA `4691317b-f698-475b-a7a2-f2e9353f1f0f` 于 2026-10-05 14:49:32 北京时间回报成功,设备已运行 `1.7.0-controls.30 / code 13`。两次新版在线快照确认心跳推进;绑定数 1、绑定代次 1、配置 2/2 保留。
安装耗时摘要:`OTA ms=32264 erase=6305 read=13810 write=7430 sha=1413 buf=8192`。本次没有启动通话或保存音频/正文;连续点击开聊、挂断、再开聊的实际语音验收仍需实物操作。
@@ -0,0 +1,41 @@
# COM6 恢复出厂实现进度
状态:后端和网页已发布 `0.99.9-rc.device-reset1`,小程序入口已实现但尚未确认正式发布。COM6 已烧录 `1.7.0-controls.5` 并通过更新后的软件语音通路回归。尚未对 COM6 执行清空,实机重置、重新配网及重新绑定验收仍待完成。
## 已实现的流程
1. 原账号通过现有解绑流程释放设备。新设备仍绑定家庭或账号时,恢复出厂接口返回 `DEVICE_RESET_AUTHORIZATION_REQUIRED`。
2. 在设备附近进入蓝牙配网模式,客户端读取 `get_info.factory_reset_supported`。两端页面复用 Wi-Fi 输入,确认擦除范围后发送 `factory_reset` 命令,不发送绑定令牌。已绑定设备需要先从原账号解绑,旧固件不显示入口。
3. 设备显示擦除提示,用户在 60 秒内轻触确认。请求在确认前只处于 `received`,没有向云端发起重置。确认事件和 Wi-Fi 参数写入现有 BLE pending 记录,格式版本 3。
4. 设备联网后,以既有 DEVREQ1 工厂身份签名读取 `/api/v1/device/factory-reset/state`。该路由只向未绑定设备返回当前重置计数器,不是解绑授权。
5. 设备生成并持久化固定重置正文,再调用 `/api/v1/device/factory-reset`。重试保持正文和 reset_id,HTTP nonce 可更新。后端复用 devicereset 事务和幂等记录,绑定代次变化或新归属会使旧请求失效。
6. 事务检查通话和两类固件任务,清空云端期望与实际配置,吊销旧用户会话及绑定上下文,并保留出厂身份和 License。此次修复了原服务仍保留 desired_config 和漏查 fleet_ota 的问题。
7. 固件验证重置编号、计数器、CLAIMABLE 状态和完整清理动作清单,再把“云端已批准”保存到 Flash,最后清除本地用户设置与 Wi-Fi。
8. 若清理中断,下次启动在初始化语音、触摸线程和 BLE 前完成剩余清理;未获得云端批准时不会擦除。完成后重启至蓝牙添加流程。
## 本地清理范围
- 清除 `voice`、`ble_setup`、`wifi` 命名空间。
- 清除 `platform` 中的语音服务凭据和 pending_conv;保留平台设备身份。
- 清除 `control` 中的音量、名称、待机状态、旧命令回执和诊断探针。
- 保留 `ble_factory` 工厂身份分区、control 的 OTA/installed 元数据、应用分区和资源分区。
- 保留 `factory_reset` 恢复日志;批准标记写入并读回验证后才允许清理,清理全部成功才写完成标记。
- Wi-Fi 驱动在现有正常启动与 BLE 路径均使用 RAM 存储;仅从上述 Wi-Fi 命名空间恢复连接。
## 已验证
- devicereset 与 onboarding 全包隔离数据库测试通过,最新日志 `.tmp-factory-reset/integration/`。
- 新增真实 DEVREQ1 路由测试:清除旧配置、丢失响应后重放而不重复重置、禁止修改重放正文、家庭/账号绑定拒绝、匿名拒绝、pending/accepted OTA 拒绝、四种活动通话状态拒绝、结束后允许、重新绑定后旧回执不能清除新归属。
- 原恢复出厂 HTTP handler 测试通过。
- `device-reset-host.cjs` 执行真实 C 实现,在 ASan/UBSan 下通过:云端拒绝不擦除、正文重试一致、错误回执拒绝、请求保存失败、批准保存失败、Wi-Fi 清理前中断、完成标记提交失败以及损坏恢复记录。
- 更新真实 BLE endpoint 的主机测试,验证新命令等待实体确认、不能与配网任务互换、禁止携带绑定令牌、配网窗口结束拒绝。
- 最终 ESP-IDF 构建通过:`1.7.0-controls.5`,3,753,168 字节,SHA256 `6a898dc76fb2f916d212f6b7a8137f2445929700046405c5a693d718fa645647`。日志 `.tmp-factory-reset/firmware-build6.log`。确认 ota_0 仍为有效活动槽后,仅烧录 0x10000 应用槽,设备 MAC 与烧录哈希校验通过;证据 `flash-controls5.json`。
- BLE 回执新增 `operation`,区分 `factory_reset` 与 `configure`;两端仅接受匹配的重置回执,蓝牙断开、其他操作成功或仅云端未绑定均不能显示重置成功。
- 线上发布检查:API/Web 版本、首页及 JS/CSS 哈希一致;运行配置和其他服务容器保持原值;新重置接口匿名请求均被拒绝。
- 网页本地模拟验收覆盖明确擦除提示、等待实体确认、成功后重新添加指引、断连不误报成功及旧固件隐藏入口;此项不替代设备实测。
- 03:05 UTC COM6 上报 `controls.5`、MQTT 已连接,绑定数 1,音量实际/期望均 60,配置版本实际/期望均 12,未完成命令 0。
- 更新后固定语句回归 `voice-20260922T030755Z.log`:持续下行 PCM、实际播放写入、解码及写入错误 0,主动结束成功回执。没有保存语音文本;仍需现场听感及真实麦克风收音确认。
## 待完成
需要家属账号授权解绑及设备现场轻触确认,完成实物重置、重新配网、重新绑定和语音恢复。已经请求用户确认是否同意清除真实设备设置及现场配合;未收到答复不能当作授权。小程序正式发布和两端登录后的实机端到端验收仍待完成。主机故障注入与数据库测试不作为 COM6 清空成功记录。
+47
View File
@@ -0,0 +1,47 @@
# EchoEar 烧录助手(Windows)
这是设备初始化与烧录工具的 Windows 图形界面,复用 `device_prepare.py` 和 `device_flash.py` 的校验、完整备份和分区写入流程。
## 交付与使用
本机交付位置:`E:\Codex\story\deliverables\echoear-flasher-20261004\EchoEarFlasher.exe`。
双击程序,选择维护后台下载的 `burn-in` JSON,确认 COM6 和 `B4:3A:45:16:34:A8`,保持默认的重新初始化模式,检查预览后开始。身份恢复模式保留设备应用固件,清理旧 Wi-Fi、语音凭据和运行状态,再写新工厂身份。
界面会显示出厂编号、平台地址、串口、预期 MAC 和写入区域。任何配置改变都会使已生成的预览失效。预览不打开串口;真实写入需要在界面确认目标与清理范围。进度按实际阶段显示;备份或校验失败会停止后续操作,不显示为成功。
完整固件模式使用配套构建目录,重写 Bootloader、分区表、应用、初始 OTA 选择、模型和显示资源,并清理旧运行配置。当前分发的是已有 `.28` 构建,尚未完成真机验收,清库后的身份恢复无需升级固件。
烧录成功后手动复位/断电重启,通过家庭端或小程序重新蓝牙配网并绑定老人。具体云端创建与配网流程见 [设备恢复说明](device-reinitialize-and-flash-20261004.md)。
## 环境
本机已有 ESP-IDF Python,程序会自动寻找;也可在高级设置选择解释器。依赖是 Python 3.10+、esptool 4.x/5.x、esp-idf-nvs-partition-gen 与 pyserial。本机验证版本为 Python 3.12、esptool 4.12.0、NVS 生成器 0.1.9、pyserial 3.5。
这是使用本机 Python 环境的原生 Windows 应用,分发目录应一起保留;它没有将 Python 运行时打包进 EXE。后台子进程隐藏运行,GUI 不显示设备密钥,不自动上传工厂材料或 Flash 备份。
## 源码与构建
- `firmware/echoear-platform-agent/tools/flash-app/EchoEarFlasher.cs`:C# WinForms 界面。
- `firmware/echoear-platform-agent/tools/flash_app_bridge.py`:JSONL 进程桥接、预览会话、冻结输入检查。
- `firmware/echoear-platform-agent/tools/flash-app/build-app.ps1`:使用系统 .NET Framework 编译器构建与按白名单打包,不安装 NuGet。
```powershell
Set-Location 'E:\Codex\story\platform\firmware\echoear-platform-agent'
.\tools\flash-app\build-app.ps1 -OutputDirectory 'E:\Codex\story\deliverables\echoear-flasher-20261004'
```
生成文件及完整 Flash 备份包含设备秘密,默认保存到程序指定的私有备份目录。源码/交付包不包含真实设备密钥。
## 验证
62 项 Python 测试通过(烧录引擎 40、初始化 6、界面桥接 16),19 项原生界面检查通过。真实 NVS 生成器及当前固件构建通过离线预览联调。界面已渲染检查,串口下拉、写入区域表、确认开关和后台入口正常显示。
离线 UI 自检不会启动桥接或访问硬件:
```powershell
.\EchoEarFlasher.exe --ui-smoke-test .\ui-test.json
.\EchoEarFlasher.exe --render-preview .\preview.png
```
本次没有创建线上设备、执行真实烧录或完成配网/通话验收。
+60
View File
@@ -0,0 +1,60 @@
# MQTT与OTA本次交付记录
2026-09-14,后端/SaaS已部署至 `https://82.157.201.114`,版本 `0.92.0-rc.fleet1`,数据库迁移66。小程序源码在 `C:\Users\19926\WeChatProjects\voicelink`,尚未代用户上传新的微信体验版。
2026-09-18 已发布 `0.96.2-rc.mqtt1`,MQTT 改为明文 TCP 1883。公网 MQTT 协议握手、HTTPS readiness 和宿主机健康检查均通过;旧 8883 已停止监听。
## 已完成
- MQTT 3.1.1 / TCP 1883:设备连接参数、按设备主题发布/订阅、断线状态、30秒心跳、90秒离线判定。MQTT 传输不加密。
- 小程序设备管理:在线/离线/状态未知,当前与最新版本、更新内容、下载地址复制、更新按钮和任务状态;10秒刷新,页面隐藏后停止。
- OTA:校验当前绑定权限、型号、数字顺序号、在线状态和忙碌状态;保存任务后MQTT发送下载地址;设备HTTP上报accepted/success/failed;超时处理及同请求号去重。
- SaaS固件管理:选择型号、版本名称、更新内容、上传文件。顺序号自动生成;先上传、校验,再发布,不覆盖已发布版本。
- 现有签名固件/批量发布的后台接口和数据保留;新简化页面使用独立的设备固件接口。
- 根据用户后续要求,没有修改、编译或烧录EchoEar硬件;最终硬件按接口对接。
## 验证结果
- Go内部包测试通过;隔离数据库的devicefleet、saas、onboarding、httpapi集成测试通过。
- 模拟设备完成真实 MQTT/TCP 连接、心跳、订阅、OTA 指令接收、确认及成功上报;覆盖错误密码、跨设备订阅/上报、错误版本、重复请求、并发升级、离线和超时。
- 小程序44项测试通过;微信原生编译器WXML 8页、WXSS 9文件通过。
- SaaS生产构建通过;浏览器验证上传窗口恰好四项输入,截图位于 `.tmp-fleet-deploy/firmware-upload.png`。
- 线上HTTPS readiness返回200,PostgreSQL、Redis、对象存储正常;schema=66,dirty=false。
- 原 TLS 8883 版本曾通过 SSH 隧道完成联调。2026-09-18 切换为明文 TCP 1883 后,已从外部网络完成 TCP 连通和 MQTT CONNACK 协议验证。
- 线上未上传任何测试固件,未触发真实OTA;原4条有效设备绑定保留。发布前完成加密备份及恢复验证。
## 仍需完成的外部步骤
1. 在微信开发者工具重新上传当前小程序源码,设置为体验版。此前公网IP在微信普通体验环境下的合法请求域名限制仍存在,本次没有改变微信的域名规则。
2. 最终嵌入式硬件实现文档中的心跳及OTA安装/重启回报,再进行实物升级验证。
## 接口文档
- 完整业务流程:[设备接口文档](embedded-device-business-guide-20260914.md)
- 本次新增:[MQTT与OTA接口](device-mqtt-ota.md)
- 桌面交接文档已同步为“设备接口文档-MQTT与OTA版.md”和“设备接口简版.html”。
## 后续部署注意
运行目录:`/opt/data-terminal-cloud/current`,指向 `/opt/data-terminal-cloud/releases/0.92.0-rc.fleet1`。必须保留运行时私有 `.env`,同时设置 `PLATFORM_BUILD_VERSION` 与 `ONBOARDING_RELEASE_VERSION`,不能只改前一个。
Compose叠加顺序:
```sh
docker compose -p data-terminal-cloud \
-f compose.yaml \
-f deploy/server/compose.override.yaml \
-f deploy/server/compose.subtitle.yaml \
-f deploy/server/compose.onboarding.yaml \
-f deploy/server/compose.migration.yaml \
-f deploy/server/compose.fleet.yaml \
--env-file deploy/server/.env up -d --no-deps api callbacks worker web
```
不要重新运行seed或删除数据卷。更换API容器后,分别reload网关和web容器内的Nginx,使静态解析的上游IP更新。
MQTT 直接监听 TCP 1883,不再挂载证书目录,也不需要 MQTT 证书续期服务。健康监控允许端口为 22、80、443、1883。
当前嵌入式Broker只支持单API实例;后续需要多实例扩容时再拆出共享MQTT服务,不要直接复制API实例。
源包SHA256:`8d2f22a1a7ff0606ab1d422112aaefb319e669c0a50db484e9be8da3326bb464`。此记录和后续诊断脚本修订属于部署后的交接补充,未改变运行二进制。
+248
View File
@@ -0,0 +1,248 @@
# 设备配网、绑定与管理接口(BLE 明文 v2)
更新时间:2026-09-30。对应小程序 `wx4553119e05f11660`。先添加设备、后创建家庭老人,见 [账号优先流程](device-account-first-20260918.md)。SaaS 家庭端的“添加设备”也使用本页 BLE v2 协议,不再展示绑定码入口。
后端地址:`https://82.157.201.114`。小程序代码:`C:\Users\19926\WeChatProjects\voicelink`。
## 1. 完整流程
1. 登录家庭账号。在“设备”页选择“添加设备”,或展开已有设备的“设备管理 → 重新配网”。
2. 设备首次使用/升级到 v2 后进入蓝牙配网;完成过 v2 配网的设备长按**屏幕底部中央 3 秒**重新进入。语音服务未就绪时也可双击屏幕,正常聊天就绪时双击会发起聊天。不要长按电源键。配网广播窗口 5 分钟。已有 BLE 工厂身份的设备开机连不上旧 Wi-Fi 时自动重启进入 BLE;已完成配网的设备进入 BLE 后没有确认新任务,窗口过期会恢复旧 Wi-Fi,保留账号绑定。
3. 小程序按固定服务 UUID 搜索蓝牙,不按名称过滤。当前固件命名为 `vl-型号-xxxxxxxx`,本机为 `vl-ECHOEAR-S3-26091601`;xxxxxxxx 为工厂编号后 8 位。广播型号最多 17 个 UTF-8 字节,完整型号由 get_info 返回。
4. 点击设备:连接 BLE → 获取服务和特征 → 订阅 TX → 发送 `get_info`,得到设备编号、型号、协议版本。没有加密握手,也不调用旧 `/mini/ble-session`。
5. 小程序调用 `/mini/ble-device-state`,确认当前账号有权限,并显示是否已经绑定。点击发送时再查询一次,避免使用过期状态。
6. 未添加:只传设备编号申请 `bind_token`,不要求家庭或老人;已有账号/家庭归属:不申请令牌,使用空字符串 `""`。添加后可创建家庭和老人,再从设备管理中的“绑定老人”完成分配。
7. BLE 发送 Wi-Fi 和 `bind_token`。设备回复 `received`,等待用户在 60 秒内轻点设备屏幕。
8. 设备确认后连接 Wi-Fi。有令牌时调用 `/device/ble-bind`;空令牌时调用只读 `/device/ble-state`,必须仍为已绑定才保存 Wi-Fi。
9. 设备成功保存后返回 `success`,约 15 秒后重启。首次绑定可同时轮询云端绑定结果;仅配网以设备 BLE 成功响应为准。断线不能当作成功。
10. 解绑:小程序先检查影响 → 弹窗确认 → 调用已有绑定变更接口。历史记录不删除;解除账号归属不远程清除设备保存的 Wi-Fi。
普通运行断网会先快速重连 5 次,然后每 10 秒继续尝试旧 SSID;恢复 IP 后重置重试次数。新地点/热点名称或密码不同需要按第 2 步重新配网,不会从远程云端获得新 Wi-Fi。已实体确认且持久化的任务遇到平台超时会继续重试,不因 5 分钟 BLE 窗口结束而删除任务。出门前检查办法见 [重新联网与配网检查](device-reconnect-20260930.md)。
SaaS 家庭端的实体操作顺序相同:点击“搜索附近设备”后,由 Chrome / Edge 的系统蓝牙选择器列出匹配固定服务 UUID 的设备;用户选择一台并读取 `get_info`,再填写使用者和 Wi-Fi,点击“发送配网信息”。Web Bluetooth 不允许网页静默枚举附近设备,因此设备列表显示在浏览器原生选择器内,而不是网页自绘列表中。SaaS 当前仍显式选择使用者并完成一步绑定;小程序则先添加到账号,之后在设备管理中绑定老人。
**空 `bind_token` 只表示更新 Wi-Fi,不代表解绑,不会改变账号归属,也不等同于省略字段或 `null`。**
## 2. 蓝牙接口
底层:BLE GATT。业务协议:自定义明文 UTF-8 JSON。无 SRP、AES、`session_password`、BLE `client_nonce/server_nonce/proof`,不要求蓝牙配对加密。
| 用途 | UUID | 属性 |
| --- | --- | --- |
| 配网服务 | `8e7a2000-6f4b-4a2d-9c31-7d824f5e6100` | Primary Service |
| RX:手机发给设备 | `8e7a2001-6f4b-4a2d-9c31-7d824f5e6100` | Write,带响应 |
| TX:设备回复手机 | `8e7a2002-6f4b-4a2d-9c31-7d824f5e6100` | Indicate,先订阅 |
这些 UUID 固定写在固件和客户端中,不是每台设备的身份凭据。
一条完整消息 = `JSON 字节长度(uint16,小端,2 字节) + JSON UTF-8 字节`。
每次写入/指示最多 20 字节,按顺序切片、拼接。长度头只在整条消息开头出现一次,不带旧版 type 字节。JSON 上限 2048 字节,同时只能有一个请求等待响应。
例如 `{"cmd":"get_info"}` 长度 18,完整帧前两个字节为 `12 00`(十六进制)。长度按 UTF-8 字节数计算,不能用中文字符串字符数。
单条消息总超时 45 秒,分片无进展超时 10 秒。长度错误、额外字段、重复字段、无效 JSON 等会导致连接关闭;字段值错误返回 `failed`。客户端重连后查询结果,不把超时当作未执行。
### 获取设备信息
输入:
```json
{"cmd":"get_info"}
```
输出:
```json
{"device_id":"KP01-B43A451634A8","model":"ECHOEAR-S3","protocol_version":2}
```
微信扫描返回的 `deviceId` 是手机系统的 BLE 地址/标识,只用于微信蓝牙 API;上面的 `device_id` 是工厂编号,两者不要混用。信息是设备自报,明文协议不提供无线设备身份认证。是否绑定和账号权限以服务器为准。
### 发送配网信息
未绑定:
```json
{"cmd":"configure","wifi_ssid":"HomeWiFi","wifi_password":"Example123","bind_token":"<服务器返回的43字符令牌>"}
```
已经绑定,仅修改 Wi-Fi:
```json
{"cmd":"configure","wifi_ssid":"NewHomeWiFi","wifi_password":"Example456","bind_token":""}
```
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `cmd` | 是 | 固定 `configure` |
| `wifi_ssid` | 是 | 1–32 个 UTF-8 字节,2.4 GHz Wi-Fi |
| `wifi_password` | 是 | 8–63 位可打印 ASCII;本版本不支持开放网络 |
| `bind_token` | 是 | 未绑定:43 位 base64url;已绑定:空字符串 |
响应:`{"status":"received"}`。**只代表收到信息,不代表配网/绑定成功。**账号登录密码、手机号、长期登录 token 不发送给设备。Wi-Fi 密码不发送给后端。
### 查询配网结果
输入:`{"cmd":"get_status"}`。
输出:`{"status":"wifi_connecting"}`;失败时如 `{"status":"failed","message":"设备已解绑,请重新搜索并绑定账号"}`。
| status | 含义 |
| --- | --- |
| `idle` | 等待发送配网信息 |
| `received` | 已收到,等待轻点设备屏幕 |
| `wifi_connecting` | 正在连接 Wi-Fi |
| `binding` | 正在提交绑定,或检查已有绑定状态 |
| `success` | 操作成功且 Wi-Fi 已保存 |
| `failed` | 明确失败,查看 message |
确认前不连接新 Wi-Fi。任务进行中重复发送相同内容只返回现有状态,不再创建任务;不同内容被拒绝。确认后暂存任务,断电可重试;首次绑定重试使用同一令牌和确认事件 ID,避免重复绑定。网络异常保留任务,4xx 明确拒绝(408/429 除外)返回失败。
## 3. 小程序调用的 HTTPS 接口
共同请求头:
```http
Authorization: Mini customer.<access_token中的随机部分>
Content-Type: application/json
X-Request-ID: <每次POST生成的UUID v4>
X-Login-Portal: customer
```
登录接口返回的 `access_token` 本身已含 `customer.` 前缀,实际拼接为 `Mini ` + access_token,不重复加前缀。使用已有手机号密码登录;微信手机号授权登录不在此次改动中。
### 3.1 查询绑定状态(新增)
`POST /api/v1/mini/ble-device-state`
输入:`{"device_id":"KP01-B43A451634A8"}`。
输出 data:
```json
{"device_id":"KP01-B43A451634A8","id":"<平台设备UUID>","model":"ECHOEAR-S3","bound":false}
```
`bound=true` 时仅允许所属家庭中有设备管理权限的账号访问;不向无权限账号披露设备归属。`id` 用于设备管理 URL。未绑定也要求同租户有效家庭管理权限。403 时停止,不向 BLE 发送 Wi-Fi。
### 3.2 申请绑定令牌(仅未绑定)
`POST /api/v1/mini/ble-bind-tokens`
小程序输入:`{"device_id":"KP01-B43A451634A8"}`。
输出 data:`{"bind_token":"<43字符令牌>"}`,有效期 5 分钟。小程序省略 `elder_id`,只把设备添加到当前账号;SaaS 家庭端显式传入已选择的 `elder_id`,一步完成使用者绑定。
已绑定返回 409 `ALREADY_BOUND`,不会覆盖归属。服务器锁定设备并再次检查,所以查询和申请之间发生绑定也不会重复绑定。
### 3.3 查询绑定结果(仅有令牌时)
`POST /api/v1/mini/ble-bind-status`
输入:`{"bind_token":"<43字符令牌>"}`。
输出 data:`{"status":"pending"}`、`{"status":"success"}` 或 `{"status":"failed","message":"..."}`。客户端约每 2.5 秒查询。仅重新配网时不调用此接口,避免传空令牌。
### 3.4 已绑定设备列表、详情
`GET /api/v1/devices` → data 为 `{"items":[...]}`。
`GET /api/v1/devices/{id}` → data 为设备详情。
主要字段:`id`(平台设备 UUID)、`binding_id`(当前绑定 UUID)、`model`、`subject_id`、`subject_name`、`household_id`、`status`、`firmware_version`。可能带 `activation_state` 等业务字段。不要把“已绑定/已开通”当成实时在线状态。
### 3.5 解绑影响检查
`GET /api/v1/devices/{id}/binding-impact?action=unbind`
输入:URL 中的平台设备 UUID。主要输出 data:
```json
{"device_id":"<设备UUID>","binding_id":"<当前绑定UUID>","action":"unbind","allowed":true,"blockers":[],"pending_command_count":0,"active_firmware_task_count":0,"source_household_id":"<家庭UUID>","source_subject_id":"<使用者UUID>","source_subject_name":"家人","evaluated_at":"<时间>"}
```
有进行中的通话、有效待执行指令、固件任务等时 `allowed=false`;小程序不发送解绑。
### 3.6 确认解绑
`POST /api/v1/devices/{id}/binding-change`(注意是单数 `binding-change`)
输入:
```json
{"action":"unbind","expected_binding_id":"<影响检查返回的binding_id>","acknowledged":true,"reason":"小程序设备管理解除绑定"}
```
输出 data:
```json
{"device_id":"<设备UUID>","action":"unbind","previous_binding_id":"<旧绑定UUID>","changed_at":"<时间>"}
```
服务器再次校验权限、当前绑定版本及阻塞任务。旧绑定不删除,而是记录解除时间;历史通话保留,旧设备运行权限/待绑定状态按现有生命周期机制失效。需要重新绑定后使用,语音服务可能需要重新开通。小程序刷新列表后设备消失。
### HTTP 响应包装与错误
`/mini/*` 和 `/device/ble-*`:
```json
{"ok":true,"code":"OK","request_id":"<UUID>","server_time_ms":1789280000000,"data":{}}
```
错误:`{"ok":false,"code":"DEVICE_UNAVAILABLE","message":"...","request_id":"<UUID>"}`,必须同时检查 HTTP 状态。
已有 `/devices/*` 管理接口成功包装为 `{"request_id":"<UUID>","data":{...}}`,错误使用已有 API 错误包装。401 重新登录;403 无权限;409 状态冲突,刷新后再操作;429 稍后重试。不要循环重发解绑。
## 4. 设备调用的后端接口
设备联网后继续使用已有 **HTTPS + 工厂密钥 HMAC**,不是蓝牙加密。此次不删除后端鉴权,工厂密钥不得交给小程序。
### 4.1 校时
`POST http://121.41.46.238/api/v1/device/time-challenges`
输入:`{"device_id":"<工厂编号>","key_version":1,"boot_id":"<UUID v4>","client_nonce":"<22字符随机串>"}`。
输出不是普通信封:`{"payload_b64":"<base64url编码JSON>","mac_hex":"<64位HMAC>"}`。设备校验 `TIME1\n` + payload_b64 的 HMAC、原样回传的编号/boot_id/client_nonce 和 15 秒时限,从载荷取 server_time_ms 校时。该接口是唯一 HTTP 特例;Wi-Fi 密码、绑定令牌不在其中。这里的 nonce 是后端请求校时字段,不是已取消的 BLE 握手。
### 4.2 绑定
`POST https://121.41.46.238/api/v1/device/ble-bind`
输入:`{"bind_token":"<43字符令牌>"}`;输出 data:`{"status":"success"}`。
此接口不接受空令牌,具有代次和幂等检查。
### 4.3 检查已有绑定(新增,只读)
`POST https://121.41.46.238/api/v1/device/ble-state`
输入:`{}`;输出 data:`{"bound":true}` 或 `{"bound":false}`。
设备收到空 bind_token 时调用它。false 或明确拒绝时不保存新 Wi-Fi,提示重新搜索并绑定;true 时只保存 Wi-Fi。此查询不会创建绑定、改变家人或推进绑定代次。
设备 4.2/4.3 请求头:`X-Device-ID`、`X-Key-Version`、`X-Timestamp-Ms`、`X-Nonce`(16 字节随机数的 base64url)、`X-Request-ID`、`Authorization: Device-HMAC-SHA256 <hex>`、`Content-Type: application/json`。
签名内容逐行拼接,无结尾换行:
```text
DEVREQ1
POST
<URL路径,不含域名>
<工厂设备编号>
<key_version>
<毫秒时间戳>
<X-Nonce>
<X-Request-ID>
<原始请求体SHA256小写hex>
```
使用 32 字节工厂密钥做 HMAC-SHA256,结果为小写 hex。重试使用新时间戳/nonce,同一任务保留确认事件 request_id;设备校验服务器 TLS 证书。
## 5. 安全边界与兼容
- 明文 BLE 的 Wi-Fi 密码和绑定令牌可能被附近攻击者窃听;设备名字和 get_info 可被仿冒。物理点屏确认不能替代无线加密或密码学身份验证。
- 云端账号权限保护 HTTP 管理操作;不代表任意第三方 BLE 客户端都必须先登录。持有设备并点屏的人可能为已绑定设备更换 Wi-Fi。归属本身仍受后端规则保护。
- 保留短配网窗口、物理确认、严格字段校验、不记录密码、HTTPS、工厂 HMAC 和账号权限。解绑不是恢复出厂,不清除本地 Wi-Fi 或工厂身份。
- v2 客户端和固件必须一起升级;旧版网页/小程序的 Security2 蓝牙客户端不能操作 v2 固件。当前 SaaS 家庭端和小程序均已使用明文 v2;旧 `/mini/ble-session` 接口仅为历史兼容保留,当前客户端不调用它。
- 真机小程序访问后端仍受微信合法域名/开发调试设置限制;本次接入不等于解决域名备案和正式发布审核。
+263
View File
@@ -0,0 +1,263 @@
# VoiceLink:MQTT、在线状态与固件升级
平台地址:`https://82.157.201.114`。EchoEar 固件已接入下面的 MQTT 心跳、HTTPS 升级清单及结果上报协议;是否能够远程升级,以设备实际上报的 `ota_supported`、在线状态及真实升级回执为准。
部署时 `FIRMWARE_DOWNLOAD_BASE_URL` 必须配置为设备能够访问的平台 HTTPS 根地址,例如 `https://82.157.201.114`。它只用于生成 `/f/{token}` 短下载地址,不能填写 MinIO 内网地址。
HTTP 成功统一返回 `{"data": ...}` 外层结构,下面展示其中的 data。设备 HTTP 使用现有 DEVREQ1 请求头;小程序沿用登录 token。无需新增出厂烧录密钥。
## 8. MQTT 连接与设备心跳
### 设备取得连接参数
`POST https://82.157.201.114/api/v1/device/mqtt-config`
输入:
```json
{}
```
输出示例:
```json
{
"broker_url": "mqtt://82.157.201.114:1883",
"client_id": "KP01-020000260914",
"username": "KP01-020000260914",
"password": "<平台返回值,原样使用>",
"heartbeat_interval_seconds": 30,
"keepalive_seconds": 60,
"status_topic": "vl/devices/KP01-020000260914/status",
"ota_topic": "vl/devices/KP01-020000260914/ota",
"qos": 1
}
```
| 字段 | 中文说明与用途 |
|---|---|
| broker_url | MQTT 服务器,TCP 1883,明文连接 |
| client_id / username / password | 连接时原样使用,不是老人的账号密码 |
| heartbeat_interval_seconds | 心跳间隔,30秒 |
| keepalive_seconds | MQTT Keep Alive,60秒 |
| status_topic | 设备发布在线心跳的主题 |
| ota_topic | 设备订阅升级指令的主题 |
| qos | 消息等级1,可能重复投递,升级需要按 task_id 去重 |
使用 MQTT 3.1.1,Clean Session=true。每次重连后重新订阅 ota_topic,订阅成功后立即上报心跳。消息是完整 UTF-8 JSON,不加蓝牙长度头,Retain=false。当前 MQTT 链路不使用 TLS,连接密码和消息内容在传输中不加密。
### 设备每30秒发布心跳
发送到返回的 status_topic:
```json
{
"online": true,
"firmware_version": "1.0.0",
"version_code": 0,
"ota_supported": true
}
```
| 输入字段 | 中文说明与用途 |
|---|---|
| online | true=在线;主动离线时发送false |
| firmware_version | 当前实际运行固件的显示版本名称 |
| version_code | 当前实际运行固件的数字顺序号;尚未关联平台固件的出厂版本填0,完成升级后使用指令里的数字 |
| ota_supported | 最终硬件已实现OTA时填true,否则false,小程序不会允许更新 |
设置遗嘱主题为自己的 status_topic,遗嘱内容:
```json
{"online": false}
```
平台以 MQTT 连接和最近心跳判断在线:连接断开即离线,超过90秒未收到心跳也视为离线。仅建立连接、未发心跳不算在线;时间以服务器接收时间为准。
设备不用自行计算 MQTT 密码。若需要排查,当前平台生成方法是 `Base64URL无填充(HMAC-SHA256(device_key原始32字节, UTF8("MQTT1\n" + factory_id)))`。与已有设备 HTTP 签名不是同一个计算内容。
## 9. 小程序查询设备在线情况
`GET https://82.157.201.114/api/v1/devices/{id}/status`
输入:路径 id 为平台设备 UUID,不是 factory_id;无请求正文。只能查询当前账号有权查看的设备。
该接口与下一节 firmware 接口返回相同结构,小程序只需要请求一次 firmware,便可同时展示在线、版本和升级进度。
主要输出:online=true/false、last_seen_at=最近心跳时间(未收到过为null)。查询失败应显示“状态未知”,不能当成已在线。
## 10. 当前版本、最新版本与SaaS固件管理
`GET https://82.157.201.114/api/v1/devices/{id}/firmware`
输入:路径设备 UUID,无请求正文。
输出 data 示例(版本、ID和下载链接仅为示例):
```json
{
"online": true,
"last_seen_at": "2026-09-14T15:00:00Z",
"current_version": "1.0.0",
"current_version_code": 0,
"current_download_url": "",
"ota_supported": true,
"latest": {
"id": "e6380075-682c-40ca-b327-d2662d04a02c",
"model": "VL-S3",
"version": "1.1.0",
"version_code": 12,
"release_notes": "改善联网稳定性",
"size": 3145728,
"sha256": "abababababababababababababababababababababababababababababababab",
"status": "READY",
"created_at": "2026-09-14T14:00:00Z",
"download_url": "https://82.157.201.114/f/K7z3mF2pQ8xLc4VnB1wRtg"
},
"update_available": true,
"ota_task": null
}
```
| 输出字段 | 中文说明与用途 |
|---|---|
| current_version / current_version_code | 设备已上报的当前版本名称/顺序号,未上报时名称为空、顺序号0 |
| current_download_url | 当前固件下载地址;平台未收录该版本时为空 |
| latest | 同租户、同型号中顺序号最大的已发布固件;暂无固件时null |
| latest.version / latest.version_code | 最新显示名称/数字顺序号 |
| latest.release_notes | 更新内容 |
| latest.download_url | 最新固件临时下载地址,有效30分钟,需要时重新查询 |
| update_available | 最新顺序号大于当前顺序号时true,不能比较版本名称的字符串大小 |
| ota_task | 最近一次升级任务,没有则null |
小程序每10秒刷新,离开页面停止。最新顺序号等于当前时显示“当前是最新版本”;暂无固件时显示“该型号暂无已发布固件”。在线、支持OTA、存在更新且没有进行中任务时,才允许点击更新。两个下载地址可以在小程序中复制。
### SaaS上传页面
只填写:**设备型号、版本名称、更新内容、固件文件**。顺序号由平台自动生成,数字越大越新,不要求连续;每个型号单独判断最新版本。最大文件32MB。同型号已经发布的版本名称不能重复,已发布文件不能覆盖;上传失败可以用相同版本名称重试。
管理接口需运营账号登录,并携带 `X-Tenant-Id`:
| 接口 | 输入 | 输出/用途 |
|---|---|---|
| GET /api/v1/ops/device-firmware/models | 无正文 | items:库存已有型号,供下拉选择 |
| GET /api/v1/ops/device-firmware | 无正文 | items:最近200条固件记录,包含版本名称、数字顺序号、状态和更新内容 |
| POST /api/v1/ops/device-firmware | model、version、release_notes、size、sha256 | 固件记录、自动生成的version_code、upload_url |
| PUT 返回的upload_url | 固件文件原始字节,Content-Type: application/octet-stream | 2xx表示文件上传成功 |
| POST /api/v1/ops/device-firmware/{id}/finalize | 无正文 | 校验文件大小和SHA256,保存发布文件,状态变为READY;之前不会提供给设备更新 |
| GET /api/v1/ops/device-firmware/{id}/download | 无正文 | 固件信息和临时download_url |
前端自动计算 size(字节数)、sha256(文件内容的SHA256,64位小写十六进制),用户无需填写。
EchoEar 上传的是 ESP-IDF 构建生成的**应用固件** `build/VolcRTCDemo.bin`。不要上传整片 Flash 备份、合并烧录包、bootloader 或分区表。当前设备两个应用槽分别为 4 MiB,应用文件必须小于等于槽大小;平台通用上传上限 32 MB 不代表 EchoEar 可以安装 32 MB 文件。版本名称应与本次构建的 `main/firmware_version.h` 中 `DEVICE_FIRMWARE_VERSION` 保持一致。
发布后,在绑定设备的家属端进入设备详情 →「固件与远程控制」→「更新固件」→ 确认。发布不会自动升级所有同型号设备;每次更新都对当前选择的单台设备发起。保持设备通电联网,结束通话后操作,等到状态显示「升级完成」且当前版本变为目标版本再确认完成。新 Wi-Fi 或手机热点需要先完成重新配网,离线设备无法接收升级指令。
创建固件输入示例:
```json
{"model":"VL-S3","version":"1.1.0","release_notes":"改善联网稳定性","size":3145728,"sha256":"abababababababababababababababababababababababababababababababab"}
```
## 11. 用户点击更新,平台通过MQTT下发
### 小程序请求
`POST https://82.157.201.114/api/v1/devices/{id}/ota`
输入:
```json
{"version_code":12}
```
version_code 使用最新查询结果,小程序不传下载地址。同一 `X-Request-ID` 重试返回同一任务,不重复下发;不同请求也不能同时创建两个进行中升级。
成功为HTTP 200,data示例:
```json
{"task_id":"f58d3018-38a8-4fc4-b490-4d3154fa8464","status":"pending","version":"1.1.0","version_code":12,"message":"","created_at":"2026-09-14T15:00:00Z"}
```
常见拒绝:DEVICE_OFFLINE(离线)、DEVICE_BUSY(通话/指令/升级进行中)、OTA_UNSUPPORTED(设备未实现OTA)、VERSION_CHANGED(最新版本变化或已经无需更新)。此时刷新设备页面。
### 设备收到升级指令
接收主题:mqtt-config 返回的 ota_topic。
```json
{
"task_id": "f58d3018-38a8-4fc4-b490-4d3154fa8464",
"model": "VL-S3",
"version": "1.1.0",
"version_code": 12,
"download_url": "https://82.157.201.114/f/K7z3mF2pQ8xLc4VnB1wRtg",
"sha256": "abababababababababababababababababababababababababababababababab",
"size": 3145728,
"expires_at_ms": 1789398030000
}
```
| 输入字段 | 中文说明与用途 |
|---|---|
| task_id | 本次升级任务,持久化保存,用于去重和重启后的结果上报 |
| model | 必须与设备型号一致 |
| version / version_code | 升级后显示的版本名称/数字顺序号,顺序号必须大于当前版本 |
| download_url | HTTPS短地址下载固件,不通过MQTT传文件;当前完整地址不超过64字符 |
| sha256 / size | 下载后的文件必须校验,计算方式同上一节 |
| expires_at_ms | 指令最晚开始接收时间,Unix毫秒,发出后30秒;过期指令不能开始升级 |
MQTT 使用明文连接,升级消息仅用于唤醒。设备不得直接采用 MQTT 消息中的下载地址、摘要或目标版本安装固件。收到 `task_id` 后,先使用现有 DEVREQ1 身份签名与 HTTPS 证书校验获取服务端清单:
`POST https://82.157.201.114/api/v1/device/ota/manifest`
```json
{"task_id":"f58d3018-38a8-4fc4-b490-4d3154fa8464"}
```
返回 `data` 中含 `task_id`、`model`、`version`、`version_code`、`download_url`、`sha256`、`size`、`expires_at_ms`,字段格式同上面的升级消息。清单只可由该任务所属设备获取,服务端重新签发短下载票据;下载时使用这次 HTTPS 返回的地址。其他设备、已结束任务、已过期任务、未发布或型号不符的固件均不能获取清单。新请求清单会使同一任务之前的下载票据失效。
设备步骤:接收唤醒并取得 HTTPS 清单 → 检查型号、顺序号、有效期和当前是否空闲 → 持久化 task_id 和目标版本 → HTTP 上报 accepted → 上报成功后下载并校验 → 写入另一个应用槽并重启 → 新槽启动及健康检查成功后上报 success。下载/校验/安装失败上报 failed;正在通话时拒绝并上报 failed 及原因。同一 task_id 重复到达只回复已有进度,不再执行安装。
`/f/{token}` 是平台签发的短期私有下载票据,不会再跳转到较长的对象存储预签名地址。平台下载接口支持 `HEAD` 和 HTTP `Range`;当前 EchoEar 固件下载中断后保留旧槽并报告失败,由用户重新发起升级,不承诺自动断点续传。任务成功、失败、超时或票据过期后立即失效。设备不得记录或上报完整票据,安装前仍必须校验 HTTPS 清单中的大小和 SHA-256。
## 12. 设备报告升级结果
`POST https://82.157.201.114/api/v1/device/ota/report`
设备已接收:
```json
{"task_id":"f58d3018-38a8-4fc4-b490-4d3154fa8464","status":"accepted"}
```
重启并确认运行目标固件后:
```json
{"task_id":"f58d3018-38a8-4fc4-b490-4d3154fa8464","status":"success","firmware_version":"1.1.0","version_code":12}
```
失败:
```json
{"task_id":"f58d3018-38a8-4fc4-b490-4d3154fa8464","status":"failed","message":"固件下载失败,仍运行原版本"}
```
成功输出 data:
```json
{"status":"ok"}
```
| 输入字段 | 中文说明与用途 |
|---|---|
| task_id | 必须是平台发给本设备的升级任务ID |
| status | accepted=接收并准备升级;success=实际运行新版本;failed=升级失败 |
| firmware_version / version_code | success时必填,必须同时与目标名称和数字顺序号一致 |
| message | 失败原因,最多500字节,可省略 |
上报允许重试;完成任务后继续每30秒心跳,填写实际运行的版本和顺序号。不能只因为下载完就上报success。网络不通时保留结果,重连后补报。
平台任务状态:pending=已发送、accepted=设备升级中、success=已完成、failed=失败、timeout=结果超时。30秒未确认或10分钟未完成转为timeout,超时不代表升级成功;设备可以补报success/failed。过期任务不能再接收为accepted。
防火墙:设备 MQTT 需要公网 TCP 1883,HTTP 接口与固件下载仍走 HTTPS 443。不需要给小程序直接连接 MQTT。
+61
View File
@@ -0,0 +1,61 @@
# EchoEar OTA 下载性能优化
日期:2026-10-01。设备:`KP01-020026091601`,型号 `ECHOEAR-S3`。
## 实现
- 已确认清单型号、版本、有效期、大小、SHA256 和同源 HTTPS,HTTP 200 与 Content-Length 匹配后,使用 `esp_ota_begin(target, state.size)` 按已知镜像大小预擦备用应用分区。SDK 可合并对齐的 64KiB 擦除;运行应用分区另有地址检查,禁止作为目标。
- 写入缓冲使用 `MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT`,8KiB 优先、4KiB 降级,两个分配均失败即保留旧应用并报错。SPI1 的 SDK 非 DRAM 写入分支每次通过 32 字节临时缓冲拷贝,显式内部内存避免落入这条路径。未记录旧固件缓冲的实际位置,不能据此单独证明此前慢速的主因。
- HTTP 接收缓冲由 4KiB 增至 16KiB,写入缓冲保持内部内存。第二轮将 TCP 接收邮箱从 6 增至 24,运行时 Wi-Fi 动态接收缓存从 16 增至 32;TCP 窗口仍为 32KiB,静态接收、发送缓存与 BA 窗口保持原值。8KiB 写入块对齐 SDK 常规写入块大小,保留内部内存余量。
- 下载预算从分配和连接前开始,包含预擦、读取、写入、SHA 校验及 OTA 结束。每次 HTTP 等待按剩余预算裁剪,只有 EAGAIN 在同一连接与偏移上有限重试。SDK HTTP 的超时是每次 transport 等待的上限;生产代码在每次应用读取前后检查总预算,不宣称能在底层一次阻塞读取的中途主动取消。
- 累计并记录预擦、读取、写入、SHA、OTA 结束、最长阶段调用及完整用时;每 30 秒最多一条进度日志。成功摘要写入原有 100 字节持久化消息,经过重启确认再发云端成功回执。后续日志提交或启动选择失败明确覆盖摘要,保留真实失败原因。
- 不改变 OTA 日志结构、型号/签名/摘要/证书校验、应用槽、启动健康确认、A/B 回退及绑定/配网规则。SDK begin 可能先分配句柄再遇到擦除失败,非零句柄会被 abort 回收。
方案参考 [ESP-IDF 5.5.1 官方 OTA 性能建议](https://github.com/espressif/esp-idf/blob/v5.5.1/docs/en/api-reference/system/ota.rst);实现依据本机同版本 SDK 的 `app_update/esp_ota_ops.c`、`spi_flash/esp_flash_api.c`、`hal/spi_flash_hal.c` 和 `esp_http_client/esp_http_client.c`。
## 验证
完整生产 C 与真实 SHA256 的 22 组故障测试通过 GCC 13.3、AddressSanitizer、UndefinedBehaviorSanitizer 和 float-cast-overflow。覆盖已知大小与非运行槽预擦、预擦错误句柄清理、预擦/连接/校验期限、8KiB/4KiB 降级与尾包、全部分配失败、读写/摘要失败、成功计时跨重启保存及后续失败原因。
`controls.17` 启动包和 `controls.18` OTA 包均通过 ESP-IDF 5.5.1 编译,大小 3,760,896 字节。前一版 16 的 38 项应用/配置来源中,仅 `main/device_ota.c` 和 `main/firmware_version.h` 变化;两新包之间仅版本字符串变化。SDK 配置、分区表、模型、音频和屏幕资源均未修改。
优化逻辑已应用程序方式安装到同一设备的 `ota_0 / 0x10000`:先核对原 16MiB 恢复备份、MAC、有效 OTA 选择、原 15 应用摘要与分区,再只写 17 应用;完整读回 SHA256 为 `43178315a21126608023cc18fba3ca6352e83096d46707cf0bcbc6a3440d9fd1`。分区表和 OTA 数据写前写后相同,原绑定数 1、配置 19/19、音量 56 均保留,云端确认 17 在线。
真实云端 17 → 18 已失败:任务 `2a3ca992-c246-46ab-98ee-dc5606ade2c4` 在接收 1,372,872 / 3,760,896 字节后出现 HTTP 读取错误,耗时约 355 秒,设备保留 17 并继续在线。这次 USB 只采集非敏感日志,18 镜像由设备通过 Wi-Fi/HTTPS 接收,没有通过 USB 写入。断开 USB 后的独立供电验证尚未完成。
设备实测预擦耗时 4.69 秒;前 605,685 字节的写入仅 1.149 秒,读取累计 206.511 秒,说明剩余慢速主要发生在网络读取。匿名候选下载连接累计重传 464 次、拥塞窗口降至 1;接收窗口非零,不能只凭这一连接快照确定丢包位置。源码核对未发现服务端 3 分钟硬期限,暂不修改生产 API 超时。
第二轮针对接收容量调整,增加慢速读取、短读取和 EAGAIN 的纯数字日志。完整生产 C 的 22 组故障测试及原有网络重连、BLE 配网生命周期、超时恢复、配网端点回归均通过。
真实 19 → 20 任务 `aebecc4c-a07a-4fc9-a37a-5afbc4fb2703` 已在约 31.3 秒接收完 3,760,896 字节并通过完整 SHA256。30.036 秒时已收 3,612,672 字节,累计读取 16.637 秒、写入 6.939 秒,预擦 3.065 秒。匿名候选 TCP 流的 ACK 在 10 秒内增加 957,138 字节,队列清零,拥塞窗口 18 → 25,无新增重传;这支持接收端调整的效果,但不能替代任务成功回执。
这轮随后在 SDK 的 `esp_ota_end` 镜像验证阶段触发内部栈断言:`esp_image_verify → bootloader_mmap → esp_mmu_map → esp_cache_freeze_caches_disable_interrupts`,控制任务原有 12KiB PSRAM 栈不满足要求。因此任务失败并保留 19,不能将下载完成称为升级成功。
最终修复将 END、启动分区选择及失败回退的镜像验证放在临时 8KiB `MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT` 任务中。先释放 HTTP/TLS、SHA 和内部写入缓冲,再创建验证任务,完成后由原任务调用 `vTaskDeleteWithCaps` 回收任务栈和 TCB;分配失败仍 abort 活跃句柄。已调用 `esp_ota_end` 的错误返回不再重复 abort。回退任务使用无重启 API,回收后由原任务按原有成功条件重启,保留健康时限、日志和启动选择顺序。编译后的 21/22 应用均为 3,761,200 字节,两包只差版本字符串;精确生产 C 的 24 组 OTA 及 4 套联网/BLE 回归通过三个 sanitizer。主机调度模拟不证明 DRAM/MMU 行为,最终安装路径另经真实硬件验收。
## 最终实物验收
2026-10-01 09:43:24 北京时间,从已登录家庭端发起真实 21 → 22 云端任务 `18216193-939f-47a4-b49b-96e9002eb05e`。09:44:31 成功回执确认运行 `1.7.0-controls.22 / code 6`,备用 `ota_1 / 0x410000` 镜像经过完整摘要、SDK 安装校验与重启健康确认。镜像大小 3,761,200 字节,SHA256 `c13ae3b2aab18431466d40b5d60b101a8c3345e438be87ef474f85ac8653616f`。
| 实测项目 | 用时 |
| --- | --- |
| 下载、写入、SHA 和 SDK 安装校验 | 41.003 秒 |
| 备用分区预擦 | 6.087 秒 |
| 累计网络读取 | 22.656 秒 |
| 累计 Flash 写入 | 7.267 秒 |
| 累计 SHA 校验 | 1.409 秒 |
| 用户发起至重启后的成功回执 | 67.394 秒 |
整包处理吞吐约 91.7KB/s,约为旧失败尝试 6.32KB/s 的 14.5 倍。比较基线是旧版本的未完成传输,不能描述为同一完整镜像的成功 A/B 速度测试;以上是单次实测,网络状况会影响时间。21/22 与 19/20 的接收设置相同。
成功后 MQTT 在线、支持 OTA,配置 19/19、音量 56/56、原绑定数 1 均保留。网页显示当前版本 22 和“升级完成”,成功消息持久化原始计时。USB 当时接着提供供电和非敏感日志,22 镜像由设备通过 Wi-Fi/HTTPS 下载,没有通过 USB 写入。**尚未完成断开 USB 的独立供电验收、受控实物启动失败回退和手机热点切换**;不将软件故障测试冒称为这些实物验收。
最终可上传应用包及已脱敏验收记录在 [固件 22 目录](../artifacts/firmware/1.7.0-controls.22/manifest.json)。发布使用真实 SSH 维护服务身份,四条 service/null 成功审计及完整公网回读均通过,原 11 个生产容器不变,临时 helper 和凭据文件均已清理。
## 对照与解释
- 旧固件失败尝试:430.039 秒传送 2,719,221 字节,约 6.3KB/s;7 分钟期限内只完成约 72%。这是包含设备反压的端到端数据,未分开测出网络和 Flash 耗时。
- 同路线电脑对照:16 固件 3,758,224 字节,5.812 秒,646.6KB/s,完整 SHA256 匹配。对照缩小排查范围,但不能替代实物优化测速。
- 最终以完整镜像校验、启动确认、云端成功回执及新版本上线为验收条件。速度使用设备实际完成时间;总时间包含连接、预擦和校验,不能只选取某次最快读取作为整包速度。
受限验证资料保存在 `E:/Codex/story/.local-evidence/ota-speed-20261001`;含设备恢复数据的备份不可作为发布固件分发。
+39
View File
@@ -0,0 +1,39 @@
# COM6 OTA 故障恢复与当前验收状态
## 当前交付
后续连接状态及命令回执修复已将 COM6 更新至 `1.7.0-controls.8`,保留本文 OTA 修复;最新烧录、诊断和主动重启离线/重连证据见 [COM6 总记录](com6-controls-progress-20260922.md)。下述 controls.6 为此次 OTA 修复的首次交付及语音回归记录。
2026-09-22,COM6 已通过 USB 更新活动应用槽至 `1.7.0-controls.6`。应用大小 3,753,536 字节,SHA256 为 `25f2b93354b6f30492ab68a5c179359601da4cc4710852397366b699e772a0a9`。
更新前核对设备 MAC、云端绑定数 1、无活动通话/升级,以及 OTA 数据指向已确认的 `ota_0`(0x10000)。只写该应用槽,esptool 校验成功;未修改 NVS、出厂身份、分区表或 OTA 数据。记录位于本机受限证据目录 `.local-evidence/com6-controls-20260922/flash-controls6.json`。
这次 USB 更新不构成在线 OTA、实际 A/B 切换或 bootloader 回滚验收。
## 修复
- 安装版本元数据按 OTA 槽分别保存,保留旧版单槽记录兼容回退。候选固件确认失败时,旧固件的版本号不会被新版本记录覆盖。
- 新固件健康检查计时器创建或启动失败时,立即请求回退;不再忽略失败并留下未确认固件。
- 版本号、大小和时间戳在浮点转整数前检查有限、非负、整数及 JSON 安全整数范围,拒绝溢出或小数输入。
- 无记录与记录损坏/读取失败分别处理。升级日志或安装版本记录无法可靠读取时,不开放 OTA;待确认固件的健康超时保护仍保留。
## 自动验证
`tests/device-ota-host.cjs` 把生产 `main/device_ota.c`、ESP-IDF cJSON 和 `tests/device-ota-host.c` 组合为独立 C 程序。SHA256 使用真实 OpenSSL 实现;ESP 分区、HTTP、NVS、计时器和重启使用故障注入替身。
最终源码在 GCC 的 AddressSanitizer、UndefinedBehaviorSanitizer 和 float-cast-overflow 检查下运行全部场景,返回 `PASS production OTA: all`、退出码 0。覆盖正常下载/启动确认、重复任务、型号/版本/大小/有效期校验、下载摘要错误、重定向和截断、写入中断、启动确认失败、两槽交替升级后的版本恢复、计时器故障及记录损坏。ESP-IDF 5.5.1 实际固件编译通过。
故障注入证明生产 C 状态机在所列替身条件下的行为,不证明真实断电、Flash 硬件故障或 bootloader 回退。
## 更新后实机检查
- `voice-20260922T034336Z.log`:固定测试语句产生持续下行 PCM 和播放器写入,观察到的解码/写入错误为 0,结束通话收到 `chat end acknowledged=1`。未采集聊天文本。
- 通话中串口显示 `mqtt=1 speaker=60 revision=12 ota=1`;socket 使用 10/16,RTC 任务存在。
- 03:43:56 UTC 云端上报为 `1.7.0-controls.6`,绑定数 1,期望/实际音量均 60,期望/实际配置版本均 12,MQTT 已连接,未完成命令 0。
- 平台固件版本代码仍为 0,符合当前仅经 USB 烧录、尚未完成正式 OTA 的状态。
## 剩余验收
需要通过已登录、有管理权限的家属端发起真实 OTA,并观察下载、槽切换、启动确认和平台成功回执;失败回滚另需受控实物验收。不得通过创建登录令牌、放开 MQTT 权限或空实现 Publish 来替代原操作路径。
蓝牙添加、重新配网、更换老人、解绑后恢复出厂及重新绑定仍需现场设备确认。恢复出厂清除真实 Wi-Fi 和用户设置,尚未得到本次清除测试的答复,因此没有执行。小程序正式发布及真机页面操作、扬声器听感和麦克风收音也仍待完成。
+26
View File
@@ -0,0 +1,26 @@
# COM6 更换使用老人
两端原“更换使用老人”调用旧版直接转移接口;COM6 为 physical_confirm,旧接口应继续拒绝绕过设备确认。新增蓝牙转移流程保留该约束。
API 与网页已发布 `0.99.10-rc.device-transfer1`;小程序代码已完成,正式发布和 COM6 实物转移待验收。
## 实现
- 家属端先选择目标老人并检查绑定影响。`requires_physical_confirmation` 指示进入附近蓝牙流程;旧设备继续原直接转移方式。
- `ble-bind-tokens` 新增可选 `expected_binding_id`,与 `elder_id` 一起明确表示转移。缺少原绑定版本不能把普通添加误当转移。账号必须同时有原家庭及目标家庭管理权限。
- 申请期间原绑定、旧聊天归属和设备设置保持原值。现有 BLE `configure` 携带一次性令牌,仍需设备轻触确认和 DEVREQ1 设备签名。
- `device_ble_binding.binding_id` 在 PENDING 转移时固定原绑定,COMMITTED 时记录新绑定;普通添加仍从 NULL 开始。无需数据库迁移。
- 设备确认事务重新核对当前绑定及代次、两边权限、通话、有效命令、两类 OTA 和待确认 claim,关闭旧绑定后通过已有数据库 claim guard 建立新绑定。任一步失败全部回滚,不留下中间解绑状态。
- 与 callmemory 使用相同的设备事务锁,先取得通话锁,再按 twin/device 顺序锁定控制状态,防止检查空闲后并发开始通话。
- 旧绑定关闭和新绑定插入分别推进代次;持久化转移令牌的预期代次随之更新,使丢失响应和并发重试只返回同一结果。旧绑定回执不能恢复旧归属。
- 原语音服务身份复用,不重复注册。历史聊天仍属于原老人,后续聊天使用新老人。
- 两端都必须查询云端令牌最终状态,不能将蓝牙配置成功当作转移成功。选择了其他设备、绑定过期、权限变化和连接中断都有独立处理。
## 验证与边界
- onboarding 隔离数据库全包通过,包括同源网页登录令牌、缺少设备签名拒绝、原绑定保留、重复确认、过期、跨账号/设备/目标、源权限单独撤销、真实语音注册身份复用、四种活动通话状态、OTA pending/accepted、写入故障回滚及正在启动通话的并发锁检查。
- saas 和 callmemory 集成测试通过。
- 小程序 59 项相关测试通过,9 页 WXML、10 个 WXSS 原生编译通过。确认导航携带指定设备、原绑定和目标老人,不调用旧直接转移接口;重复点击、隐藏页面、冲突及蓝牙假成功均不误报完成。
- 网页候选 TypeScript/Vite 及 Linux API 构建通过。使用真实组件和模拟接口验证等待实体确认、云端完成和归属冲突提示;模拟页不进入生产构建。
- COM6 尚未实际更换老人;没有创建测试家庭或修改真实归属。需要用户登录家属账号选择目标老人并进行设备现场确认。小程序尚未确认正式发布。
- 发布清单:线上 release 的 `device-transfer-release.json`;9 个源码/契约文件增量覆盖,未做数据库迁移。候选 API/Web 构建、线上版本及资产哈希、匿名请求拒绝、其他服务容器不变检查通过。发布后 COM6 仍在线、绑定 1、音量 60、配置版本 12。
@@ -0,0 +1,5 @@
/* Host tests only. In your firmware use its own Mbed TLS configuration. */
#define MBEDTLS_MD_C
#define MBEDTLS_SHA256_C
#define MBEDTLS_BASE64_C
#define MBEDTLS_PLATFORM_C
+72
View File
@@ -0,0 +1,72 @@
#include "vl_protocol.h"
#include "vectors.generated.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
static int test_rng(void *context, uint8_t *out, size_t n) {
(void)context;
for (size_t i = 0; i < n; i++) out[i] = (uint8_t)i;
return 0; /* Fixed bytes are ONLY for this offline test. */
}
static int failed_rng(void *context, uint8_t *out, size_t n) {
(void)context; (void)out; (void)n; return -1;
}
static int read_abc(void *context, uint8_t *out, size_t cap, size_t *n) {
size_t *offset = context;
if (!cap) return -1;
*n = *offset < 3 ? 1 : 0;
if (*n) out[0] = (uint8_t)"abc"[(*offset)++];
return 0;
}
static int read_error(void *context, uint8_t *out, size_t cap, size_t *n) {
(void)context; (void)out; (void)cap; (void)n; return -1;
}
int main(void) {
uint8_t key[32], decoded[4096], frame[2050];
char hash[65], signature[65], nonce[23], uuid[37];
size_t n;
assert(vl_key_from_hex(TEST_KEY, key) == 0);
assert(vl_key_from_hex("short", key) != 0);
assert(vl_key_from_hex(NULL, key) != 0);
assert(vl_sha256_hex("{}", 2, hash) == 0 && !strcmp(hash, BODY_SHA));
assert(vl_sha256_hex(NULL, 0, hash) == 0 && !strcmp(hash, EMPTY_SHA));
assert(vl_http_sign(key, "POST", "/api/v1/device/ble-state", "KP01-020000260914",
"1", "1789372800000", "AAECAwQFBgcICQoLDA0ODw", "12345678-1234-4234-8234-123456789abc",
"{}", 2, signature) == 0 && !strcmp(signature, HTTP_SIGNATURE));
assert(vl_http_sign(key, "POST", "/api/v1/device/ble-state", "KP01-020000260914",
"1", "1789372800000", "AAECAwQFBgcICQoLDA0ODw", "12345678-1234-4234-8234-123456789abc",
"{ }", 3, signature) == 0 && strcmp(signature, HTTP_SIGNATURE));
assert(vl_http_sign(key, "POST", "/bad\npath", "device", "1", "1", "nonce", "id",
"{}", 2, signature) != 0);
assert(vl_nonce(test_rng, NULL, nonce) == 0 && !strcmp(nonce, "AAECAwQFBgcICQoLDA0ODw"));
assert(vl_uuid4(test_rng, NULL, uuid) == 0 && !strcmp(uuid, "00010203-0405-4607-8809-0a0b0c0d0e0f"));
assert(vl_nonce(failed_rng, NULL, nonce) != 0 && nonce[0] == 0);
assert(vl_uuid4(failed_rng, NULL, uuid) != 0 && uuid[0] == 0);
const char *json = "{\"cmd\":\"get_info\"}";
assert(vl_ble_pack(json, strlen(json), frame, sizeof(frame), &n) == 0);
assert(n == 20 && frame[0] == 18 && frame[1] == 0 && !memcmp(frame + 2, json, 18));
assert(vl_ble_pack(json, strlen(json), frame, 19, &n) != 0 && n == 0);
memset(decoded, 'a', 2049);
assert(vl_ble_pack(decoded, 2048, frame, sizeof(frame), &n) == 0 && n == 2050);
assert(frame[0] == 0 && frame[1] == 8);
assert(vl_ble_pack(decoded, 2049, frame, sizeof(frame), &n) != 0);
assert(vl_ble_pack("\xe4\xbd\xa0", 3, frame, sizeof(frame), &n) == 0 && frame[0] == 3);
mbedtls_md_context_t sha;
assert(vl_sha256_begin(&sha) == 0);
assert(vl_sha256_update(&sha, "a", 1) == 0);
assert(vl_sha256_update(&sha, "bc", 2) == 0);
assert(vl_sha256_finish(&sha, hash) == 0 && !strcmp(hash, ABC_SHA));
mbedtls_md_free(&sha);
size_t offset = 0;
assert(vl_ota_verify(read_abc, &offset, 3, ABC_SHA) == 0);
offset = 0;
assert(vl_ota_verify(read_abc, &offset, 2, ABC_SHA) != 0);
offset = 0;
assert(vl_ota_verify(read_abc, &offset, 4, ABC_SHA) != 0);
offset = 0;
assert(vl_ota_verify(read_abc, &offset, 3, EMPTY_SHA) != 0);
assert(vl_ota_verify(read_error, NULL, 3, ABC_SHA) != 0);
puts("PASS: HTTP signature, SHA256/OTA streaming, UUID/nonce, BLE framing and error cases");
return 0;
}
+156
View File
@@ -0,0 +1,156 @@
#include "vl_protocol.h"
#include "mbedtls/base64.h"
#include <string.h>
static int hex_digit(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
static void to_hex(const uint8_t *in, size_t n, char *out) {
const char *hex = "0123456789abcdef";
for (size_t i = 0; i < n; i++) {
out[i * 2] = hex[in[i] >> 4];
out[i * 2 + 1] = hex[in[i] & 15];
}
out[n * 2] = '\0';
}
int vl_key_from_hex(const char *hex, uint8_t key[32]) {
if (!hex || !key || strlen(hex) != 64) return -1;
for (size_t i = 0; i < 64; i++) if (hex_digit(hex[i]) < 0) return -1;
for (size_t i = 0; i < 32; i++)
key[i] = (uint8_t)((hex_digit(hex[i * 2]) << 4) | hex_digit(hex[i * 2 + 1]));
return 0;
}
int vl_nonce(vl_rng_fn rng, void *context, char out[23]) {
uint8_t random[16], base64[25];
size_t n = 0;
if (!rng || !out) return -1;
out[0] = '\0';
if (rng(context, random, sizeof(random)) != 0) return -1;
if (mbedtls_base64_encode(base64, sizeof(base64), &n, random, sizeof(random))) return -1;
if (n != 24) return -1;
for (size_t i = 0; i < 22; i++)
out[i] = base64[i] == '+' ? '-' : base64[i] == '/' ? '_' : (char)base64[i];
out[22] = '\0';
return 0;
}
int vl_uuid4(vl_rng_fn rng, void *context, char out[37]) {
uint8_t random[16];
char hex[33];
size_t p = 0;
if (!rng || !out) return -1;
out[0] = '\0';
if (rng(context, random, sizeof(random)) != 0) return -1;
random[6] = (uint8_t)((random[6] & 15) | 0x40);
random[8] = (uint8_t)((random[8] & 63) | 0x80);
to_hex(random, 16, hex);
for (size_t i = 0; i < 32; i++) {
if (i == 8 || i == 12 || i == 16 || i == 20) out[p++] = '-';
out[p++] = hex[i];
}
out[p] = '\0';
return 0;
}
int vl_sha256_begin(mbedtls_md_context_t *context) {
if (!context) return -1;
mbedtls_md_init(context);
const mbedtls_md_info_t *info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
if (!info) return -1;
int rc = mbedtls_md_setup(context, info, 0);
if (rc == 0) rc = mbedtls_md_starts(context);
return rc;
}
int vl_sha256_update(mbedtls_md_context_t *context, const void *bytes, size_t n) {
if (!context || (!bytes && n)) return -1;
return n ? mbedtls_md_update(context, bytes, n) : 0;
}
int vl_sha256_finish(mbedtls_md_context_t *context, char out[65]) {
uint8_t digest[32];
if (!context || !out) return -1;
out[0] = '\0';
int rc = mbedtls_md_finish(context, digest);
if (rc == 0) to_hex(digest, 32, out);
return rc;
}
int vl_sha256_hex(const void *bytes, size_t length, char out[65]) {
if (!out || (!bytes && length)) return -1;
out[0] = '\0';
mbedtls_md_context_t context;
int rc = vl_sha256_begin(&context);
if (rc == 0) rc = vl_sha256_update(&context, bytes, length);
if (rc == 0) rc = vl_sha256_finish(&context, out);
mbedtls_md_free(&context);
return rc;
}
/* Hash the exact LF-joined fields without building a large intermediate string. */
static int hmac_fields(const uint8_t key[32], const char **fields,
size_t count, char out[65]) {
if (!key || !fields || !out) return -1;
out[0] = '\0';
for (size_t i = 0; i < count; i++)
if (!fields[i] || strchr(fields[i], '\n') || strchr(fields[i], '\r')) return -1;
const mbedtls_md_info_t *info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
if (!info) return -1;
mbedtls_md_context_t context;
uint8_t digest[32];
mbedtls_md_init(&context);
int rc = mbedtls_md_setup(&context, info, 1);
if (rc == 0) rc = mbedtls_md_hmac_starts(&context, key, 32);
for (size_t i = 0; rc == 0 && i < count; i++) {
if (i) rc = mbedtls_md_hmac_update(&context, (const uint8_t *)"\n", 1);
if (rc == 0) rc = mbedtls_md_hmac_update(&context, (const uint8_t *)fields[i], strlen(fields[i]));
}
if (rc == 0) rc = mbedtls_md_hmac_finish(&context, digest);
mbedtls_md_free(&context);
if (rc == 0) to_hex(digest, 32, out);
return rc;
}
int vl_http_sign(const uint8_t key[32], const char *method, const char *path,
const char *device_id, const char *key_version,
const char *timestamp_ms, const char *nonce,
const char *request_id, const void *body, size_t body_length,
char signature_hex[65]) {
if (!signature_hex) return -1;
signature_hex[0] = '\0';
if (!method || strcmp(method, "POST") || !path || path[0] != '/') return -1;
char body_sha[65];
if (vl_sha256_hex(body, body_length, body_sha)) return -1;
const char *fields[] = {"DEVREQ1", method, path, device_id, key_version,
timestamp_ms, nonce, request_id, body_sha};
return hmac_fields(key, fields, 9, signature_hex);
}
int vl_ble_pack(const void *json_utf8, size_t length,
uint8_t *frame, size_t capacity, size_t *frame_length) {
if (!frame_length) return -1;
*frame_length = 0;
if (!json_utf8 || !frame || length == 0 || length > 2048 || capacity < length + 2) return -1;
frame[0] = (uint8_t)(length & 255);
frame[1] = (uint8_t)(length >> 8);
memcpy(frame + 2, json_utf8, length);
*frame_length = length + 2;
return 0;
}
int vl_ota_verify(vl_read_fn read, void *context, uint64_t expected_size,
const char *expected_sha256) {
uint8_t buffer[1024], expected[32], actual[32];
char hash[65];
uint64_t total = 0;
if (!read || !expected_size || vl_key_from_hex(expected_sha256, expected)) return -1;
mbedtls_md_context_t sha;
int rc = vl_sha256_begin(&sha);
while (rc == 0) {
size_t n = 0;
if (read(context, buffer, sizeof(buffer), &n) != 0 || n > sizeof(buffer)) { rc = -1; break; }
if (n == 0) break;
if ((uint64_t)n > expected_size - total) { rc = -1; break; }
total += n;
rc = vl_sha256_update(&sha, buffer, n);
}
if (rc == 0 && total != expected_size) rc = -1;
if (rc == 0) rc = vl_sha256_finish(&sha, hash);
mbedtls_md_free(&sha);
if (rc != 0 || vl_key_from_hex(hash, actual)) return -1;
return memcmp(actual, expected, 32) == 0 ? 0 : -1;
}
+33
View File
@@ -0,0 +1,33 @@
#ifndef VL_PROTOCOL_H
#define VL_PROTOCOL_H
#include <stddef.h>
#include <stdint.h>
#include "mbedtls/md.h"
/* All functions return 0 on success, nonzero on failure. Not board firmware.
* String outputs include NUL, binary outputs do not. Never transmit the NUL.
* Caller owns buffers; inputs/outputs must not overlap. */
int vl_key_from_hex(const char *hex, uint8_t key[32]);
/* rng must fill ALL n bytes from the hardware/OS secure RNG; return 0 or fail. */
typedef int (*vl_rng_fn)(void *context, uint8_t *bytes, size_t n);
int vl_nonce(vl_rng_fn rng, void *context, char out[23]);
int vl_uuid4(vl_rng_fn rng, void *context, char out[37]);
int vl_sha256_hex(const void *bytes, size_t length, char out[65]);
int vl_http_sign(const uint8_t key[32], const char *method, const char *path,
const char *device_id, const char *key_version,
const char *timestamp_ms, const char *nonce,
const char *request_id, const void *body, size_t body_length,
char signature_hex[65]);
int vl_ble_pack(const void *json_utf8, size_t length,
uint8_t *frame, size_t capacity, size_t *frame_length);
/* Stream OTA bytes with bounded memory. Always call mbedtls_md_free after
* begin, even if an update/finish fails. Do not begin twice on a live context. */
int vl_sha256_begin(mbedtls_md_context_t *context);
int vl_sha256_update(mbedtls_md_context_t *context, const void *bytes, size_t n);
int vl_sha256_finish(mbedtls_md_context_t *context, char out[65]);
/* Read already downloaded bytes (e.g. flash/file): 0=success, *n=0 means EOF.
* On each call write at most capacity bytes; return nonzero on I/O failure. */
typedef int (*vl_read_fn)(void *context, uint8_t *out, size_t capacity, size_t *n);
int vl_ota_verify(vl_read_fn read, void *context, uint64_t expected_size,
const char *expected_sha256);
#endif
+42
View File
@@ -0,0 +1,42 @@
# EchoEar 重新联网与配网检查
日期:2026-09-30。当前小程序:`C:\Users\19926\WeChatProjects\voicelink`。
## 出门后更换网络
1. 准备有互联网的 2.4 GHz WPA2 Wi-Fi,或手机的 2.4 GHz 热点。热点和路由器密码需 8–63 位可打印 ASCII;设备不支持本流程下的开放网络、需要网页认证的公共 Wi-Fi 或仅 5 GHz 网络。
2. 长按**设备触摸屏底部中央 3 秒**,看到蓝牙配网提示后松手。固件触摸区域为 x=60–300、y=280–335;这是屏幕操作,不是电源键。语音未就绪时双击也能开启,语音就绪时双击会发起聊天,因此界面统一提示长按。
3. 登录原来的小程序账号,在“设备 → 设备管理 → 重新配网”打开操作页。开启手机蓝牙和微信蓝牙权限,搜索并选择该设备;当前固件广播名称为 `vl-ECHOEAR-S3-xxxxxxxx`,这台 COM6 为 `vl-ECHOEAR-S3-26091601`。
4. 填新 Wi-Fi 名称和密码,发送后在 **60 秒内轻点设备屏幕确认**。`received` 仅表示收到了信息。
5. 等小程序显示 `Wi-Fi 已更新` / 设备状态 `success`,设备约 15 秒后重启。仅修改 Wi-Fi 时蓝牙断开不能单独作为成功证据;应回设备页确认实时在线及固件版本。
已绑定设备发送空 `bind_token`,设备调用只读 `/api/v1/device/ble-state` 核对仍有有效绑定,再保存新 Wi-Fi。不会解绑、创建新归属、删除历史聊天或清除工厂身份。检查前不要使用“恢复出厂”或“解除绑定”操作。
## 自动恢复规则
| 场景 | 当前行为 |
| --- | --- |
| 正常运行时 Wi-Fi 短暂掉线 | 快速重连 5 次,随后每 10 秒继续尝试;取得 IP 后清掉失败标记和重试计数。 |
| 开机旧 Wi-Fi 不可用,且有 BLE 工厂身份 | 正常联网等待最多 30 秒(扫描另有耗时)后,记录配网请求并重启进入 BLE;不会回到旧的 192.168.4.1 热点流程。 |
| 已完成 v2 配网,手动/自动进入 BLE 后未确认新任务 | 5 分钟窗口结束停止广播;检查没有持久化待执行任务后清配网请求,重启连接旧 Wi-Fi。旧 Wi-Fi 仍不可用则再次进入 BLE。 |
| 已收到新 Wi-Fi,但 60 秒内没有轻点确认 | 标记确认超时,可重新发送;窗口结束后可按上行规则回到旧 Wi-Fi。 |
| 已实体确认并持久化,Wi-Fi/平台调用仍处理中 | 保留任务继续恢复/重试;窗口结束不会清除已确认操作或绑定凭据。 |
| 首次配网,没有完成记录或保存的旧 Wi-Fi | 窗口结束后不会凭空恢复网络;长按底部中央 3 秒重新开启。 |
| 传统设备没有 BLE 工厂身份 | 保留原有 Wi-Fi 热点配网页兼容流程。 |
已确认任务在平台超时、429 或服务器异常时保留相同任务继续重试。处于 `binding` 时不要把断线当成功;如果新网络没有互联网,应先恢复该网络的互联网连接。当前任务没有通用取消入口。
## 本次修复与验证
- 修复普通运行断网重试耗尽后永久离线;恢复 IP 清除过期失败状态。
- 修复已完成 BLE 配网的设备去新地点开机时落入旧热点配网页,改成与小程序一致的 BLE 流程。
- 修复 BLE 超时后一直留在配网模式,导致保存的原网络无法恢复;关闭广告只做一次,避免每秒重复调用 stop。
- 修复确认过期后 `received` 状态吞掉短触摸,造成窗口到期无法双击重开的情况。
- 修复 BLE 服务初始化失败后误继续普通 Wi-Fi 初始化,避免两个模块重复持有 STA。
- 网页与当前小程序提示改成真实可用的屏幕底部中央长按入口。
- 新测试 `tests/network-reconnect-host.cjs` 编译完整生产 `network.c`,覆盖连续 25 次重连、BSSID 回退、延迟回调、IP 恢复、驱动停止防护、BLE/传统 AP 启动分流、旧凭据保留和存储失败。
- 新测试 `tests/ble-timeout-host.cjs` 运行生产确认/超时恢复函数,覆盖无人确认后回旧 Wi-Fi、持久化任务保护、首次配网保护、存储失败、处理中状态及仅重启一次。
- 上述真实 C 测试与原有 plaintext endpoint、22 项 BLE Wi-Fi 生命周期测试,在隔离 `/tmp` 内经 GCC AddressSanitizer / UndefinedBehaviorSanitizer 通过。
- 当前 voicelink 小程序家庭端微信一键登录改造后 140 项测试通过;9 个原生页面、事件处理、路由以及 WXML/WXSS 编译通过。源代码已修改,本次未上传发布小程序。
这些测试验证代码行为;本机现已通过真实无线 OTA 运行 `controls.22`,实物自动恢复保存的 Wi-Fi 并实时在线。接收容量优化仅将运行时动态 Wi-Fi 接收缓存改为 32、TCP 接收队列改为 24,原配网和重连规则保持不变,最终版本的联网/BLE 回归再次通过。真实手机热点切换仍未验收;真实 OTA 结果以 [远程升级记录](device-remote-ota-20260930.md) 为准。重新配网需要手机在设备附近通过蓝牙操作;云端“重新配网”入口不会远程打开蓝牙,也不能向离线设备直接写入陌生 Wi-Fi。
@@ -0,0 +1,171 @@
# 原 COM6 设备:线上创建、烧录与初始化
现在可使用 [Windows 图形界面烧录助手](device-flasher-app-20261004.md),双击 EXE 后选择后台 JSON、检查预览并执行。下方保留完整命令行操作说明。
适用设备:之前那台 EchoEar ESP32-S3(EchoEar V1.2),16 MB Flash / 16 MB PSRAM,硬件 MAC `B4:3A:45:16:34:A8`。本次只读串口枚举确认当前仍为 COM6,USB VID:PID 为 `303A:1001`、序列标识与此 MAC 一致。重新插拔后串口号可能改变,工具会核对 MAC。
## 1. 这次为什么连接失败
10 月 4 日火山账号迁移的交付记录 `deliverables/volc-account-migration-20261004/上线与清空结果.json` 显示:云端设备、认证密钥、绑定、家庭、老人和供应商激活记录均已清空,只保留运维账号;实体设备的本地配置没有重置。
旧设备继续使用旧编号、旧认证密钥和旧语音配置时,新云端无法找到对应记录。这个证据足以解释认证失败,但本次没有读取真机串口,尚未确认设备屏幕上的错误具体落在哪一阶段。
项目已有初始化、重新配网和恢复出厂功能。但“恢复出厂”本身需要设备先通过云端认证,而且会保留出厂身份;云端已删除旧密钥时,不能靠此按钮重新生成身份。只换 Wi-Fi 也不会恢复已删除的云端登记。
本次建议:**线上重新创建设备 → USB 写新工厂身份并清理旧运行配置 → 家庭端重新配网绑定。保留当前实机固件即可。**
线上只读检查:2026-10-04 设备管理页面返回 HTTP 200,版本接口返回 `0.99.30-rc.captcha-off1`。这证明网页/版本接口可访问,不等于已经验证设备认证或语音通话。
## 2. 线上创建设备
1. 打开 [维护后台/设备管理](https://82.157.201.114/maintenance?area=devices),用保留下来的运维账号登录。
2. 点“创建设备”,设备型号填 **ECHOEAR-S3**。
3. 点“创建并下载烧录信息”。保存下载的 `KP01-……-burn-in-v1.json`。
4. 检查下载文件里的 `origin` 为 `https://82.157.201.114`。如果后台仍导出旧地址,应先修正后台设备接入地址,再生成材料;不要把不同环境的设备密钥混用。
文件包含以下五项,由后台生成,不需自己编密钥:
| 字段 | 用途 |
| --- | --- |
| `factory_id` | 云端出厂编号,`KP01-` 开头。随机生成,不等于芯片 MAC。 |
| `model` | 当前硬件使用 `ECHOEAR-S3`。 |
| `origin` | 设备访问的平台 HTTPS 地址。 |
| `key_version` | 这台设备的认证密钥版本。 |
| `device_key_hex` | 32 字节设备认证密钥,由平台生成,必须与云端一致。 |
完整密钥只在创建或重置时提供。文件丢失后,设备行菜单“重置烧录信息”会立即废弃旧密钥,必须重新烧录新文件。设备编号、设备密钥要一机一份,不要复制给另一台机器。
## 3. 已补齐的程序
程序位于 `firmware/echoear-platform-agent/tools/`:
- `device_prepare.py`:直接接收后台下载的 JSON,生成该设备专属的 `ble_factory.bin`、`factory.csv`、`setup-label.json` 和校验清单 `manifest.json`。兼容现有后台的 `factory_id`,也兼容旧导出的 `device_id`。
- `device_flash.py`:列串口、预览烧录计划、检查芯片/MAC/Flash/分区和镜像、完整备份、写入、校验。支持“只更新身份”和“完整固件烧录”。
工具不替你创建设备、不自动填 Wi-Fi,也不需要火山主账号的 AK/SK。真实烧录必须使用 `flash --execute`;`plan` 不连接设备。`ports` 只枚举串口。
准备工具会先设置输出目录权限,再写入秘密。`factory.csv`、`ble_factory.bin`、后台下载 JSON 和完整 Flash 备份均含设备秘密;放在本地私有目录,不提交 Git。下方默认放在项目已忽略的 `backups` 目录。
## 4. 这台旧设备的恢复操作(推荐)
### 4.1 打开 PowerShell,准备路径
这台电脑已安装 ESP-IDF Python、esptool 4.12.0、NVS 生成器 0.1.9,无需重复安装。
```powershell
Set-Location 'E:\Codex\story\platform\firmware\echoear-platform-agent'
$pythonPath = 'C:\Users\19926\.espressif\python_env\idf5.5_py3.12_env\Scripts\python.exe'
$deviceMac = 'B4:3A:45:16:34:A8'
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$factoryDir = Join-Path $PWD "backups\factory-$stamp"
$backupDir = Join-Path $PWD "backups\before-reinitialize-$stamp"
$credentialPath = (Read-Host '请输入后台下载的 burn-in JSON 完整路径').Trim('"')
```
### 4.2 生成这台设备的初始化分区
```powershell
& $pythonPath tools/device_prepare.py --credential $credentialPath --expect-mac $deviceMac --output $factoryDir
if ($LASTEXITCODE -ne 0) { throw '初始化材料生成失败,请检查 JSON 和输出目录' }
```
成功显示 `prepared_not_flashed`。这一步只生成本地文件,没有写设备。工具拒绝覆盖已存在的输出目录。
### 4.3 查看串口与写入计划
用能传输数据的 USB 线连接设备,关闭串口监视器或其他占用串口的软件。
```powershell
& $pythonPath tools/device_flash.py ports
& $pythonPath tools/device_flash.py plan --factory-dir $factoryDir --identity-only --reset-runtime
```
确认计划只有两个区域:
| 区域 | 地址/大小 | 作用 |
| --- | --- | --- |
| 运行 NVS | `0x9000`/`0x6000` | 清除旧 Wi-Fi、配网任务、旧语音凭据、控制设置等本地运行状态。 |
| 工厂身份 | `0xe4e000`/`0x6000` | 写入后台新创建的设备身份。 |
`--reset-runtime` 是这次云端清空后的重新初始化操作,会丢弃上述设备本地配置,需要重新配 Wi-Fi。它也会清除 NVS 中保存的 OTA 安装版本计数,使 `version_code` 回到 0;应用内固件版本字符串保留,下一次成功 OTA 会重建安装记录。它不执行云端解绑,也不是日常升级必需参数;只用于本次已清库设备或已完成云端解绑的重新制备。
应用、OTA 选择、语音模型和屏幕资源都保留。这可以保留此前已实机验收的 `.27` 固件。
### 4.4 真正执行
确认 `ports` 输出仍为 COM6;如果变了,把命令中的 COM6 改为实际串口。MAC 不能随意改。
```powershell
& $pythonPath tools/device_flash.py flash --execute --port COM6 --expect-mac $deviceMac --factory-dir $factoryDir --identity-only --reset-runtime --backup-dir $backupDir
if ($LASTEXITCODE -ne 0) { throw '烧录未完成:保留备份和输出,检查报错后再处理' }
```
工具会先核对物理设备、读取完整 16 MB Flash 并保存备份及校验记录,然后写入指定区域并校验。任何备份、布局或验证失败都不能当作完成。完成后手动按复位或断电重启,进入首次配网。
若无法自动进入下载模式,按住 BOOT,再点 RESET/重新连接 USB,然后松开 BOOT;使用板上的 BOOT/RESET,屏幕长按用于蓝牙配网,不能替代串口下载模式。若传输不稳定,先更换短数据线/USB 接口,并根据工具 `--help` 降低波特率。
## 5. 如果还要重烧完整固件
当前 `build/VolcRTCDemo.bin` 是 `1.7.0-controls.28`,构建通过,但交付记录明确其尚未安装、未进行真机验收。这次恢复身份无需升级它。
确实要重烧完整固件时,使用构建目录中的 `flasher_args.json` 和配套文件,不能只把一个应用 `.bin` 当作整机固件烧到地址 0,也不能照旧文档使用已变更的分区地址。
```powershell
& $pythonPath tools/device_flash.py plan --build-dir build --factory-dir $factoryDir --reset-runtime
# 核对计划中的固件版本、全部文件和区域后执行;使用新的备份目录。
$fullBackupDir = Join-Path $PWD ('backups\before-full-flash-' + (Get-Date -Format 'yyyyMMdd-HHmmss'))
& $pythonPath tools/device_flash.py flash --execute --port COM6 --expect-mac $deviceMac --build-dir build --factory-dir $factoryDir --reset-runtime --backup-dir $fullBackupDir
```
完整模式写 Bootloader、分区表、应用 ota_0、初始 otadata、语音模型和屏幕资源;重设 otadata 使 ota_0 启动,避免设备仍启动原 ota_1。提供 `--factory-dir` 才写工厂身份;不提供时保留原身份。
**完整 USB 烧录要求显式 `--reset-runtime`,之后要重新配网。** 原因是旧 NVS 内还有 `installed0` 等 OTA 版本及任务记录,只换应用和启动槽会使新固件继续上报旧版本。工具会拒绝保留这些旧记录的完整写入。默认 `plan` 可预览全部固件文件,但不能直接将未带此参数的计划执行。日常需保留 Wi-Fi/运行配置的升级,应走现有平台 OTA 流程。
需要从源码重新构建时,在固件工程目录执行:
```powershell
$env:PYTHONUTF8 = '1'
$env:PYTHONIOENCODING = 'utf-8'
$env:Path = 'C:\Users\19926\.espressif\python_env\idf5.5_py3.12_env\Scripts;' + $env:Path
. 'E:\Codex\toolchains\esp-idf-v5.5.1\export.ps1'
idf.py -D CMAKE_NINJA_FORCE_RESPONSE_FILE=ON build
```
此工具针对当前 EchoEar 16 MB A/B 布局。它会拒绝不兼容的设备布局或镜像;不要通过强制参数绕过,也不要执行整片擦除。esptool 的读写和区域擦除行为见 [Espressif 官方烧录说明](https://docs.espressif.com/projects/esptool/en/latest/esp32s3/esptool/basic-commands.html)。
## 6. 烧录后配初始化信息
出厂身份在上面已写好;家庭 Wi-Fi、账号归属和老人资料通过正常使用流程配置。
1. 打开 [家庭端登录](https://82.157.201.114/login?returnTo=%2F),注册/登录客户账号。运维账号用于后台管理,不直接作为客户绑定账号。
2. 在家庭管理创建家庭、添加老人信息。此次清库后这些数据需要重新建立。
3. 打开设备页的“通过蓝牙添加设备”,或使用已配置为新平台地址的微信小程序设备添加入口。
4. 设备首次启动进入配网;没有进入时长按圆屏**底部中央约 3 秒**。
5. 搜索并选择 `vl-ECHOEAR-S3-xxxxxxxx`。后八位来自本次新云端编号,所以可能不再是之前的 `26091601`。
6. 选择老人,填写有互联网的 **2.4 GHz WPA2 Wi-Fi** 及密码,发送后 **60 秒内轻触屏幕确认**。
7. 等待明确成功提示,回设备页检查实际在线、固件版本,再发起一次聊天验证语音。
当前网页自动验证蓝牙身份,不需要自己填写设备密钥。支持 Web Bluetooth 的电脑或 Android Chrome/Edge 可走网页;iPhone 用微信小程序。新生成 `setup-label.json` 的密码用于兼容旧配网客户端,不是平台登录密码。
只“添加到我的账号”而不分配老人,可以先登记归属,但不能完成聊天初始化。火山语音实例、ProductKey、设备运行密钥和智能体配置由平台下发,**不要把火山主账号 AK/SK 写进设备**。
## 7. 常见结果怎么判断
| 现象 | 检查 |
| --- | --- |
| 后台创建成功但设备仍离线 | 云端记录创建不代表已经写入实体设备;检查是否执行了 flash,是否写入对应 JSON。 |
| 校时/认证失败、无效设备 | 检查新编号、密钥版本、目标平台是否一致,是否误用已被“重置烧录信息”废弃的文件。 |
| 能连 Wi-Fi 但仍使用旧语音配置/不进配网 | 本次清库恢复是否明确执行了 `--reset-runtime`。 |
| 蓝牙发现后断开 | 断开不等于成功;看配网任务结果及云端绑定/在线状态。 |
| 已绑定但不能聊天 | 是否分配老人,平台新火山账号的语音服务/智能体是否准备完成。 |
| MAC 不匹配 | 选错串口或不是这台板;停止,核对设备。 |
| 分区或镜像校验失败 | 构建文件不配套、设备布局不符或材料损坏;保留证据,不强刷。 |
本次交付包含工具与软件验证,尚未创建真实新设备、写入实体 Flash 或完成现场配网/通话验收。
软件验证:40 项烧录保护测试与 6 项初始化测试均通过;使用真实 NVS 生成器把模拟后台 JSON 生成为工厂镜像,并成功交给当前真实构建的身份写入计划。覆盖错误 MAC、重复 MAC 输出、镜像损坏、路径越界、备份失败、验证失败和完整 USB 烧录缺少运行态清理等情况。真实构建的六类固件文件及分区表通过离线校验。复现命令:
```powershell
& $pythonPath -m unittest discover -s tools -p test_device_flash.py -q
& $pythonPath -m unittest discover -s tests -p test_device_prepare.py -v
```
+73
View File
@@ -0,0 +1,73 @@
# EchoEar 出门前远程升级交付记录
日期:2026-09-30。目标设备为 COM6,工厂号 `KP01-020026091601`,型号 `ECHOEAR-S3`。
## 最新结果(2026-10-01)
真实 21 → 22 远程升级已成功,任务 `18216193-939f-47a4-b49b-96e9002eb05e` 于 09:44:31 北京时间回报成功。设备运行 `1.7.0-controls.22 / code 6`,MQTT 在线,配置 19/19、音量 56、原绑定数 1 保留。3,761,200 字节镜像下载、写入和安装校验耗时 41.003 秒,发起至重启成功回执共 67.394 秒。USB 只供电及采集日志,镜像经 Wi-Fi/HTTPS 下载;独立供电、受控实物启动失败回退和手机更换热点仍未验收。详细对照、失败过程及修复见 [性能验收记录](device-ota-performance-20261001.md)。
## 逐次验收记录
- 设备已通过 USB 安装 `1.7.0-controls.13`,只写已确认的活动应用槽 `ota_0 / 0x10000`;应用哈希校验成功。写入前已完整备份 16 MiB Flash,保留 NVS、工厂身份、分区、启动程序、OTA 数据及屏幕/语音资源。
- 原设备停留在过期的 BLE 窗口,云端最后心跳为 9 月 27 日。本次安装后真实蓝牙扫描、连接、`get_info/get_status` 均通过。
- 未提交新配网信息,5 分钟窗口到期后设备恢复原来保存的 `asus` Wi-Fi。20:53:59 北京时间云端确认实时在线、MQTT 已连接、`ota_supported=true`、设备空闲。
- 原账号绑定数 1、音量 56、配置版本 19/19、正常运行状态均保留。用户提供了同名 Wi-Fi;因为设备已自行恢复联网,没有再写入密码,也没有将密码加入项目文件。
- 云端已发布 `1.7.0-controls.14`,版本顺序号 **2**,状态 `READY`。它与串口安装的修复版仅版本字符串不同,用于真实远程升级和 A/B 槽切换验收。
- 家属已完成新版拼图验证并登录。23:13:04 北京时间通过真实家庭端发起远程 OTA;设备接收任务并开始 HTTPS 下载,23:14:39 返回 `OTA failed: ESP_FAIL; original firmware retained`。原固件 `controls.13`、顺序号 0 和原绑定、音量、配置均保留。**该次真实 OTA 未通过,不能把编译、USB 刷写、HTTP 200 或 `ota_supported=true` 当成成功。**
- 23:32:42 通过同一已登录家庭端发起一次受控重试;23:40:05 返回 `ESP_ERR_TIMEOUT`,原固件保留。该次 `/f/` 请求持续 430.039 秒,向设备发送 2,719,221 / 3,757,536 字节(72.36%),上游读取 2,739,701 字节。数据确认当前实物下载在固件 420 秒总期限内没有完成;不能把 HTTP 200 当作完整送达。
- 临时传输日志仅记录固定脱敏路由、状态和计数,不记录下载票据、IP 或请求头。采样后已恢复网关原配置,SHA256 和所有容器 ID 一致。两个任务均失败,当前设备仍为 `controls.13` / code 0,MQTT 在线、配置 19/19、音量 56、绑定数 1。
- 10 月 1 日 00:00 前后,用户将 USB 接回,已确认同一 MAC、原 `controls.13` 应用 SHA256、活动 `ota_0` 的有效选择数据与原分区表一致。只写 `0x10000` 的应用镜像 `controls.15`,完整读回 SHA256 为 `cdb2852dd113ce551f95742b7ea784a3c70bb96f23c369a665a04c47f89b4236`;没有写启动程序、分区、OTA 数据、NVS、身份或资源。分区表/OTA 数据写前写后逐字节相同。
- 修复版将下载总期限改为 **900 秒**;同一连接无数据的 EAGAIN 最多连续等待三次,收到数据重置计数。每次等待按剩余总期限裁剪;提前 EOF 和其他负值不盲重试。失败回执包含具体阶段、成功写入字节数、原始读取值及 errno,不包含私有下载地址。
- USB 本地更新后,云端真实确认 `controls.15` / code 0、MQTT 在线、支持 OTA;配置 19/19、音量 56、原绑定数 1 保留。**这只证明本地更新后的运行状态,不是远程升级通过。**
- 配套 API `0.99.20-rc.ota-budget1` 已上线:已接收任务、下载授权和下载写入期限为 **20 分钟**,覆盖 15 分钟下载和 3 分钟启动健康确认;待接收仍为 30 秒,票据仍为 30 分钟,晚到真实终态回执的纠正行为保留。只重建 API;网页 19、其他容器 ID、运行环境/端口/挂载、滑块策略 4 和数据库版本 74 均保留。
- `controls.16` / code **3** 已发布 `READY`,与本地启动修复版 15 仅版本宏不同,大小 3,758,224 字节、SHA256 `6868bcb45f6c211e5e91524e7be5944fe2989f2e377f3bb455f3cdaacfbe0ecf`。发布使用真实 SSH 维护服务身份并记录 service/null 审计,没有使用或构造用户/运营登录会话;公网 HTTPS 全量读回与冻结镜像相同。发布记录 `a67129e0-e9c1-45e2-a30b-141018f74c76`。
- 当时 15 → 16 的无线验收尚未发起。后续在 17 → 18 排除 Flash 写入瓶颈,在 19 → 20 证实接收端提速并定位 PSRAM 栈安装错误,最终 21 → 22 完整成功;以上旧状态按发生顺序保留。
## 明天远程升级的方法
1. 设备持续通电,连接有互联网的 Wi-Fi 或 2.4 GHz 手机热点;设备状态须实时在线。
2. 运营端“固件管理”上传 **应用固件 `.bin`**,填写准确型号、版本名称和更新说明,等待平台大小/摘要校验完成并发布。
3. 用对该设备有管理权限的家属账号进入设备详情,在“固件与远程控制”点“更新固件”并确认。小程序的设备管理也沿用同一云端升级流程。设备通话中须结束通话后再发起。
4. 观察“等待设备接收 → 设备正在升级 → 升级完成”;升级期间会短暂离线。只有重启确认和成功回执都完成,才算升级成功。
5. 如果显示未接收/失败/超时,确认设备恢复在线,刷新状态再发起;旧的 30 秒接收窗口不会因重连自动延长。
当前修复上传包在 `artifacts/firmware/1.7.0-controls.22/echoear-1.7.0-controls.22.bin`。大小为 3,761,200 字节,SHA256:`c13ae3b2aab18431466d40b5d60b101a8c3345e438be87ef474f85ac8653616f`;同目录的 `verification.json` 记录真实成功回执和计时。之前的 14/16/18/20 仅用于前述分阶段验证,不代表最终修复。
该设备每个应用槽为 4 MiB;不得上传合并后的整片 Flash、工厂身份、分区表、模型或屏幕资源镜像。远程升级不会更新这些独立资源分区。后续构建完成后可用工具生成经过型号、芯片、大小及编译版本检查的上传包:
```powershell
python tools/package-ota.py --model ECHOEAR-S3 --out <输出目录>
```
## 升级与联网修复
MQTT 仅唤醒升级任务;设备通过 DEVREQ1 签名 HTTPS 请求取不可变升级清单,再检查型号、顺序号、有效期、大小和 SHA256,写入非活动应用槽。启动健康确认后才报告成功;结果暂时发不出去则持久化补报。固件下载和安装失败保留原槽,待确认镜像有启动健康期限和 bootloader 回退保护。
本次补齐清单请求暂时失败后的有限重试,避免 MQTT 已确认投递后丢失唯一唤醒;终态回执的本地持久化失败不会放行下一次升级。云端已完成任务的冲突响应也不会永久卡住后续升级。日志只记录运行槽、版本顺序号、字节数和阶段,不记录私有下载票据。
云端修复 fleet OTA 与已有 SaaS `ASSIGNED` 升级任务的互斥判断。API 最小增量 `0.99.17-rc.ota-ready1` 已上线,实际生产源码只改该判断;运行环境、端口及挂载保持一致,其余服务未重建。
网页最小增量 `0.99.18-rc.reconnect-copy1` 的配网提示更正为“长按设备屏幕底部中央 3 秒”,已通过类型检查、构建和静态文件哈希检查。当前生产 `0.99.19-rc.local-slide1` 保留上述 OTA 和配网修复,并已部署本地开源拼图验证码。当前本地小程序文案也已更正并通过测试,本次未发布小程序。
重新联网与重新配网的完整规则见 [重新联网说明](device-reconnect-20260930.md):正常掉线持续重试,异地开机旧网络失败自动进入 BLE,已有配网设备无人确认时超时返回旧网络。**操作入口为长按屏幕底部中央 3 秒,发送新网络后 60 秒内轻触确认;保留原绑定。**
## 验证边界
### 10 月 1 日 08:28 下载慢的复查
- 电脑通过真实家庭端取得同一个 16 固件的短下载地址,正常 TLS 校验、无重定向,全量下载 3,758,224 字节用时 5.812 秒(646.6 KB/s),SHA256 与冻结包一致。未启动设备升级、未写设备 Flash,私有下载地址只临时保存在本地并在测试后删除。
- 设备当前实际 RSSI 为 -21 dBm,报告时间 08:23:50,固件仍 15、在线;该信号是当前值,不能充当昨夜下载全程的信号测量。
- 设备旧实测 430.039 秒发送 2,719,221 字节(约 6.3 KB/s),这个速度包含设备同步读取、擦写、校验及调度产生的反压,不是单独的网络吞吐测量。电脑对照与已排查的服务端限速/负载表明,应进一步定位设备处理和到设备的网络路径。
- 当前每次最多读 4 KiB,再同步 `esp_ota_write`,使用 `OTA_WITH_SEQUENTIAL_WRITES` 逐扇区擦除。16 镜像按 4 KiB 对齐为 918 扇区;本板两个应用分区均按 64 KiB 对齐,当前 SDK 允许 64 KiB block erase,按已验证大小预擦可形成 57 个 64 KiB 块加 6 个 4 KiB 扇区。这是可测的优化点,**尚无分段实物计时,不能把它认定为全部慢速的唯一原因,也不能按擦除次数推导提速倍数。**
- 本机开启 PSRAM 指令/常量读取,不能泛称本板每次擦除都会关掉 Wi-Fi/双核缓存。SDK 的 600ms 是擦除超时上限,不是固定等待时间。
- 截止此复查,未修改预擦算法、未启用额外设备计时、未写新固件;此前 900 秒预算修复没有证明下载已提速。后续需要分别记录 begin/read/write/SHA 耗时,对比预擦方案并验收完整 OTA。
- 最终生产 OTA C 全部故障注入场景通过 GCC AddressSanitizer、UndefinedBehaviorSanitizer 与 float-cast-overflow,覆盖真实 SHA256、正常安装/确认、两槽版本恢复、重复任务、非法清单、截断/重定向/下载失败、持久化与计时器故障及本次恢复路径。
- 网络重连和 BLE 超时的生产函数测试通过 ASan/UBSan,原有 BLE Wi-Fi 22 项生命周期测试通过。家庭端微信一键登录改造后的当前小程序 140 项测试及 9 页原生编译检查通过;网页类型检查通过。
- ESP-IDF 5.5.1 最终固件编译通过,`controls.14` 的 host 测试编译单元与已验证 `controls.13` 完全相同。
- 云端升级生命周期使用隔离数据库和真实 MQTT broker 验证;发布包经公网 HTTPS 下载回读,大小和 SHA256 与冻结包一致。
- 已确认真实 USB 应用写入、BLE 只读交互和无人操作后自动回旧 Wi-Fi;首次真实 OTA 失败后原槽继续运行,最终 21 → 22 完成真实 A/B 远程安装、重启确认和成功回执,原绑定和设置保留。**受控实物启动失败回滚和手机更换热点仍未验收。**
- 下载时间限制已用精确生产源码和完整 Middleware/otel 包装,在真实 TCP 上做三组对照:40ms 默认写超时会截断,下载处理器覆盖至 15 分钟后完整传输且 SHA256 一致。MinIO 活动下载体没有 60/90 秒总超时。此证据排除所测服务器期限问题,不能代替实物下载成功。
- 最终 20 分钟云端增量通过隔离 Linux 的 8 项真实数据库/MQTT 生命周期测试(0 跳过)和 3 项完整 HTTP 封装 TCP 测试;测试覆盖 19:59/20:01、待接收 29/31 秒、撤销票据与晚到终态纠正。固件读取修复的完整生产 C 测试 15 组通过,包含 11 个新增故障场景,ASan/UBSan 无错误;14 验证构建、15 启动包和 16 OTA 包均通过 ESP-IDF 编译,源哈希已冻结。
受限本机证据保存在工作区外的 `.local-evidence/com6-ota-20260930` 和 `.local-evidence/remote-ota-20260930`;完整备份包含设备凭据,不应作为上传固件分发。
+33
View File
@@ -0,0 +1,33 @@
# EchoEar 屏幕点击开始与挂断
后续反馈:用户发现挂断后再次启动无法聊天。`.30` 资源回收修复已通过本地回归,并在用户回复“升级”后于 2026-10-05 14:49:32 北京时间通过单设备 OTA 安装成功。设备已恢复 `.30 / code 13` 在线心跳,实际连续通话仍待实物验收。详见 [再次开聊修复记录](device-call-restart-20261005.md)。
2026-10-05,已对用户确认的当前在线设备 `KP01-8995ACF5E69E`(公开编号 `3ef575a6-bbc0-4e00-9a5c-58a8157d9520`,ECHOEAR-S3)完成远程升级,运行 `1.7.0-controls.29 / code 12`。
随后核对电脑 USB:COM6 的 VID:PID 为 `303A:1001`,序列标识 `B4:3A:45:16:34:A8`。本地烧录助手备份 `20261004T161207Z-dd5c8edfa07f` 的身份写入计划、工厂清单及 `written_and_verified` 回执确认这个硬件 MAC 对应 `KP01-8995ACF5E69E`,因此电脑连接的设备就是本次远程升级目标。核对只枚举串口并被动监听,没有再次复位或刷写。
## 操作
- 待机且屏幕亮起时,轻触屏幕开始通话。
- 正在连接或通话时,再次轻触屏幕取消或挂断。
- 正在结束时,连续点击不会创建第二通;等待结束和待机后可重新轻触开聊。
- 短触沿用按下 50—1500 ms、松开后响应的规则,去抖间隔改为 300 ms,取消原来 5 秒只允许开聊的间隔。
- 屏幕底部中央长按 3 秒的重新配网入口保持原有行为。
点击事件交给现有通话管理任务处理,管理器根据是否仍持有通话任务决定开始或停止。连接中和结束中的任务仍占用开聊入口,直到音频、SDK及结束上报完成并收到 OVER。停止沿用现有本地音频关闭和平台收尾流程。
本版本同时包含此前未下发的 `.28` 双方静默 60 秒自动结束修复;有效语音、字幕和回应状态刷新活动时间,静音音频包和平台续期不刷新。
## 验证与交付
ESP-IDF 5.5.1 编译通过,检查 5403 项构建输入。与已安装 `.27` 相比,生产输入仅变更触摸入口、通话管理器、事件枚举、静默计时和版本头;依赖、分区、配置与 RTC 源码保留。
三个提取实际生产函数的 C 主机测试通过 UBSan,覆盖点击开始、300 ms 后再次点击挂断、连接及结束阶段的重复点击、任务创建失败、投递失败重试、计时回绕、通话结束显示、本地音频关闭、慢结束上报和静默计时。设备实物的触摸与静默听感测试尚未人工验收,不把软件测试当成实物触摸验证。
应用镜像 3,772,928 字节,SHA256 `2fc8ddacfb751fb5a479dd909955336bf1484005263964167c6249dfa73544d9`,在 4 MiB OTA 槽限制内。维护发布走现有平台服务、真实 service 身份审计和 HTTPS 全量回读,不构造用户登录会话。浏览器扩展本地文件上传权限未开启,未更改扩展权限。
发布过程中发现清库后保留的发布记录最大顺序号 11,而固件序列停留在 5;Prepare 返回 PostgreSQL 23505。仅将 `fleet_firmware_version_code_seq` 向上同步到现有最大值,写入 service 审计,随后平台自动生成顺序号 12。
真实已登录家庭端对确认的单设备发起 OTA:任务 `e4933f76-5c1e-4fb3-9634-0748f3244640`,2026-10-05 11:28:37 北京时间发起,11:29:29 回报 success。设备报告下载写入校验耗时 28.075 秒,重启后新版本心跳及 code 12 已确认。11:29:55 后再次在线;原绑定数 1、绑定代次 1、配置版本 2/2 保留。
交付文件位于 `artifacts/firmware/1.7.0-controls.29`,包含应用 `.bin`、manifest、源码差异、verification 和真实升级完成截图。私有脱敏操作证据保存在工作区 `.local-evidence/screen-call-20261005`。
@@ -0,0 +1,13 @@
# 设备转移弹窗修复 — 2026-10-05
用户报告线上“转移使用人”点击无反应。已使用只读导出的线上源码在本地真实 DeviceCard 组件中复现:点击处理正常执行,弹窗进入 DOM,但设备详情样式 `.cp-device-card:has(.cp-page-detail)>:not(.cp-page-detail)` 对同级转移弹窗应用 `display:none!important`。解绑弹窗也受同一规则影响。
修复仅涉及 `web/src/pages/SaaSWorkspace.tsx` 和 `web/src/components/home/customer-portal.css`:隐藏设备卡片内容时保留嵌套弹窗;打开转移或解绑窗口后自动检查影响;切换目标时清除旧结果并取消旧请求;提交前核对结果的目标编号与当前选择。没有其他可选长者时允许打开窗口并提示先前往长者资料新增使用人。
真实组件的隔离浏览器回归覆盖:原版隐藏复现、电脑与手机弹窗、自动检查、蓝牙实体确认入口、活动通话阻止、目标切换和迟到响应、无目标提示、解绑窗口、旧设备提交的目标与绑定版本,以及只读角色。API 全部拦截为模拟接口,没有执行真实转移、解绑、配网或设备写入。TypeScript 和生产构建通过。
初次发布在当前版本检查阶段发现另一个更新已上线,停止后重新只读导出 `0.99.46-rc.pagination-elder1`,将同一两文件修复应用于最新完整源码并重新构建、回归。发布候选为 `0.99.47-rc.device-transfer-fix1`;发布脚本包含基线和容器检查、静态资源完整校验、健康检查及自动 Web 回退。
证据目录:`E:/Codex/story/.local-evidence/device-transfer-fix-20261005/attempt2/`。`checks.json` 保存回归结果,`source-changes.json` 和两份 diff 保存上线前后的源码变化,`build.log` 保存构建输出。发布状态以该目录的 `deployment.log` 和成功后读取的 `production-receipt.json` 为准。
已发布 `0.99.47-rc.device-transfer-fix1`。135 个 Web 静态文件经容器回读及公网 SHA-256 校验;新 JavaScript 包含转移窗口、无目标提示及设备确认入口。API/worker readiness、入口页面及运行配置校验通过,其余服务容器 ID 和启动时间保持一致。数据库 schema 74 且 clean,没有执行迁移或写入业务数据。设备实体转移仍需用户选择目标并按提示进行现场确认。
+49
View File
@@ -0,0 +1,49 @@
# 语音结束聊天:.31 已发布并安装
2026-10-05,用户反馈:对设备说“拜拜吧”或“就聊到这吧”,模型能理解结束意图,但设备不能主动结束会话。
## 原因与证据
北京时间 17:51 的云端只读核对确认,当前设备 `KP01-8995ACF5E69E`(公开编号 `3ef575a6-bbc0-4e00-9a5c-58a8157d9520`)在线,运行 `1.7.0-controls.30`,绑定数 1,没有活动通话。该长者的保存配置为 `SubtitleConfig.DisableRTSSubtitle=true`、`SubtitleMode=1`、`EnableConversationStateCallback=false`。
火山 [参数说明](https://www.volcengine.com/docs/6348/1807452?lang=zh) 明确,`DisableRTSSubtitle=true` 关闭房间内客户端字幕回调。现有平台仍可在服务端保存字幕,但设备本地演示逻辑收不到用于判断结束请求的字幕。固件原演示逻辑还只按子串识别“退下吧/拜拜”,不区分用户和助手,并等待固定文字“好的拜拜”才发出退出事件。
## .31 行为
新增设备本地短指令识别,支持“拜拜吧”“再见”“就聊到这吧”“今天就聊到这里吧”“结束通话”“挂断电话”等完整表达;“就聊到这把”也作为常见同音识别结果接受。这是有限的语音指令词表,不是模型原生 Function Calling,也不承诺任意结束表达都能识别。
RTC 路径只处理身份匹配的用户字幕,以 `definite` 收集同一轮确定分句,等 `paragraph=true` 后对整句判断。重复序号不重复执行,中间识别结果不占控制队列。助手、未知说话人、问句、引号、否定或叙述里的结束词不触发。低负载路径使用完整输入转写事件。
识别后由通话任务等待助手回复完成、下行有声 PCM 至少安静 1200 毫秒、播放队列为空且播放中的写入已结束、扬声器尾音至少等待 600 毫秒,再走正常结束流程。这里的音频活动门槛沿用 `.30` 的平均绝对幅值 64,仍需实机验证。没有回复或完成事件时,在用户没有继续说话的条件下等待约 12 秒后结束,避免一直开着会话。用户正在说话时延后结束;收到后续完整的继续聊天请求则取消待结束状态。
结束仍先关闭本地采集和播放,再停止 SDK、记录停止结果、上报平台、回到待机,并沿用 `.30` 的录音和 WithCaps 任务回收。物理点击、静默 60 秒、授权租约及平台停止继续使用既有流程。
## 验证与交付
ESP-IDF 5.5.1 完整编译通过。六项使用实际生产 C 的主机测试通过 UBSan:指令与真实 RTC/WS 消息解析、实际 PCM 播放任务及尾音等待、实际通话任务与显示状态、100 次并发采集切换、屏幕控制、USB 语音诊断。覆盖否定、引用、问句、同一句多分句、重复字幕、中间字幕洪流、取消结束、缺少回应、时钟回绕及挂断后再次开聊。
与 `.30` 的 5403 项输入比对:只修改六个既有文件,新增两个语音结束模块文件;最终核对 5405 项输入一致。固件为 `1.7.0-controls.31`,应用镜像 3,775,440 字节,小于 4 MiB OTA 槽;SHA256 为 `5c33f6cae3ef93433fffb6c61920b5752fcb9edb8663b8e73eb78a4948ef9dce`。
交付位于 `artifacts/firmware/1.7.0-controls.31`,包含应用、manifest、验证记录、八个冻结源文件及定向配置脚本。`source-delta.partial.patch` 只对比有匹配旧副本的通话源文件和版本头,不代表完整差异。
## 发布与安装结果
用户回复“发布”后,将 `.31 / code 14` 发布到现有云平台。发布编号 `9826dd1b-8e75-4689-961d-6bffa2d8ee39`,service 维护审计与公开 HTTPS 全量回读 SHA256 均与本地镜像一致。API 运行镜像保留为 `data-terminal-cloud-api:0.99.40-rc.creation-consent1`。
前两次 OTA 在下载完整后返回 `ESP_ERR_NO_MEM / 0x101`,原版保留;一次云端软重启仍未恢复安装。只读 COM6 检查确认目标芯片 MAC `B4:3A:45:16:34:A8`,内部空闲内存 11,691 字节、最大块 4,096 字节。随后通过 USB 硬件复位(没有擦除或写入闪存)重试,OTA `0d467bd9-a8ed-48a7-9008-b3fb80af3344` 于北京时间 2026-10-05 18:37:13 回报成功。安装摘要:`OTA ms=27803 erase=6177 read=9607 write=7397 sha=1458 buf=8192`。
两次新版在线快照确认心跳持续推进,最后上报北京时间 2026-10-05 18:51:04。设备运行 `.31 / code 14`;绑定数 1、绑定代次 1、配置 2/2 保留,没有活动通话或升级任务。
## 字幕配置与剩余验证
已通过带绑定、摘要和空闲守卫的定向脚本,只将该设备唯一绑定长者 Bot 的保存配置 `SubtitleConfig.DisableRTSSubtitle` 改为 `false`;`SubtitleMode=1`、AgentConfig 摘要及其他保存配置保持一致。Bot 为 `botGjBMujzIc`。
当前已部署数据库没有后台人设同步字段,因此此前计划中的“既有人设同步”不适用。现有开聊 `elderagent.Prepare` 会读取保存的完整 Config 并通过 `AibotUpdate` 应用;下一次开聊预期会打开客户端字幕回调。固件本身不提交含 S2S 密钥的覆盖配置。
北京时间 2026-10-05 18:51:15 的只读回读确认:平台保存值为 `false`,供应商现有值仍为 `true`。即时供应商同步完整保存 Config 的操作在执行前被自动审批拒绝,理由是配置可能包含敏感参数,当前授权不足以发送整个配置。没有执行该更新。只读比较确认保存配置与供应商配置并非除字幕开关外完全相同,因此未据此再次尝试。供应商原生编辑器也返回未知错误,未保存任何设置。
固件发布和安装已完成,平台配置已保存;供应商即时同步、下一次开聊后的回读,以及真实说“拜拜吧/就聊到这吧”后待机的验收仍未完成。正常开聊会应用保存配置;如果要求在开聊前立即同步并回读供应商端,需补充授权将该 Bot 的现有完整配置(可能含模型凭据和人设)发送至火山引擎。
实机还需确认告别播报后回待机、否定和引用不误挂、继续聊天取消挂断,以及再次轻触可开聊。本次没有启动通话或保存音频、正文。
证据目录:`E:/Codex/story/.local-evidence/voice-end-20261005`。升级成功截图为 `installed31.png`,固件交付验证记录已更新。
@@ -0,0 +1,265 @@
{
"schema_version": 1,
"diagram_type": "architecture",
"meta": {
"title": "火山语音陪聊 · 小智思路适配",
"locale": "zh-CN",
"quality_profile": "showcase",
"views": [
{
"id": "voice",
"label": "实时语音",
"focus": [
"device",
"volcano"
],
"note": "设备与火山继续承担实时收音、理解和发声。"
},
{
"id": "before",
"label": "开聊前",
"focus": [
"device",
"start",
"compile",
"prepare",
"volcano",
"context"
],
"note": "平台为每次新会话编译角色、开场和授权的续聊背景。"
},
{
"id": "after",
"label": "通话后",
"focus": [
"volcano",
"callback",
"summarize",
"context"
],
"note": "字幕排空后异步整理记忆和话题状态,供下次开聊使用。"
}
]
},
"components": [
{
"id": "device",
"type": "frontend",
"label": "EchoEar 设备",
"sublabel": "收音 · 播放 · 打断",
"tag": "已有",
"pos": [
50,
70
],
"size": [
180,
82
]
},
{
"id": "volcano",
"type": "cloud",
"label": "火山 RTC / S2S",
"sublabel": "长者专属 Bot · 会话内接话",
"tag": "已有",
"pos": [
950,
70
],
"size": [
190,
82
]
},
{
"id": "start",
"type": "backend",
"label": "开聊接口",
"sublabel": "身份 · 授权 · 长者互斥",
"tag": "已有",
"pos": [
50,
300
],
"size": [
180,
82
]
},
{
"id": "compile",
"type": "backend",
"label": "陪聊背景编译",
"sublabel": "角色 + 开场 + 续聊线索",
"tag": "拟扩展",
"pos": [
350,
300
],
"size": [
180,
82
]
},
{
"id": "prepare",
"type": "backend",
"label": "专属 Bot 准备",
"sublabel": "AibotUpdate · 会话快照",
"tag": "已有 / 拟扩展",
"pos": [
650,
300
],
"size": [
180,
82
]
},
{
"id": "context",
"type": "database",
"label": "陪聊资料",
"sublabel": "角色版本 · 授权记忆 · 话题状态",
"tag": "已有 / 拟扩展",
"pos": [
350,
530
],
"size": [
180,
82
]
},
{
"id": "summarize",
"type": "backend",
"label": "通话后整理",
"sublabel": "Managed Viking + 摘要提取",
"tag": "已有 / 拟扩展",
"pos": [
650,
530
],
"size": [
180,
82
]
},
{
"id": "callback",
"type": "backend",
"label": "字幕回调入库",
"sublabel": "按会话保存 · 等待排空",
"tag": "已有",
"pos": [
950,
530
],
"size": [
190,
82
]
}
],
"boundaries": [
{
"kind": "region",
"label": "现有 Go 平台 · 扩展陪聊能力",
"wraps": [
"start",
"compile",
"prepare",
"context",
"summarize",
"callback"
],
"pad": 30
}
],
"connections": [
{
"id": "audio",
"from": "device",
"to": "volcano",
"label": "双向实时语音",
"variant": "emphasis"
},
{
"id": "start",
"from": "device",
"to": "start",
"label": "申请会话",
"labelDy": 70
},
{
"id": "compile",
"from": "start",
"to": "compile"
},
{
"id": "prepare",
"from": "compile",
"to": "prepare"
},
{
"id": "configure",
"from": "prepare",
"to": "volcano",
"label": "开聊前配置",
"variant": "security"
},
{
"id": "callback",
"from": "volcano",
"to": "callback",
"label": "字幕 / 结束事件",
"labelDy": 70,
"variant": "dashed"
},
{
"id": "summarize",
"from": "callback",
"to": "summarize"
},
{
"id": "save",
"from": "summarize",
"to": "context",
"label": "异步保存"
},
{
"id": "context",
"from": "context",
"to": "compile",
"label": "本地读取"
}
],
"cards": [
{
"dot": "cyan",
"title": "先做:聊得自然",
"items": [
"短回应、具体接话,允许没有追问",
"故事 / 怀旧 / 猜物件,少量玩法写入角色"
]
},
{
"dot": "emerald",
"title": "再做:记得进展",
"items": [
"每次新会话最多带入一条续聊线索",
"拒绝、已完成和冷却状态避免反复提问"
]
},
{
"dot": "amber",
"title": "待验证:会话中工具",
"items": [
"火山 RTC / S2S 通用工具协议先实测",
"验证后再接提醒和设备表现"
]
}
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,40 @@
{
"diagram_type": "architecture",
"output": "E:/Codex/story/platform/docs/diagrams/volc-companion-xiaozhi-20261001.html",
"specification": {
"path": "E:/Codex/story/platform/docs/diagrams/volc-companion-xiaozhi-20261001.architecture.json",
"sha256": "de41d4825b866007d8b2abbdd1b26d232e6d81bdb99eb9c59e00dfa71a0c74a8",
"bytes": 5276
},
"artifact": {
"sha256": "c69dd15e311db9e6a7b1c505b89b5465060504eed6c0f8df042afe01c9ecca7b",
"bytes": 713303
},
"validation": {
"profile": "showcase",
"checks_passed": 9,
"checks_total": 9,
"errors": 0,
"warnings": 0
},
"visual_review": "passed",
"visual_review_evidence": {
"viewports": [
"1440x900",
"1600x1000",
"1920x1080",
"2048x1320"
],
"containment": "passed",
"screenshots_inspected": [
"1440x900.light",
"1440x900.dark",
"2048x1320.light",
"2048x1320.dark"
],
"notes": "已核对本次备注更新后的浅色和深色主题,节点与连线无重叠,首屏内容完整;待验证卡片仅保留提醒和设备表现,图结构与布局未改变。"
},
"correction_rounds": 0,
"visual_correction_rounds": 0,
"scope": "设计文档及架构图。未修改生产代码、设备配置或部署;结构与视觉检查不代表运行时陪聊效果验证。"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Archify visual-check · volc-companion-xiaozhi-20261001.html</title>
<style>
*{box-sizing:border-box}body{margin:0;padding:24px;background:#e9eef5;color:#172033;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}header{max-width:1500px;margin:0 auto 18px}h1{margin:0 0 6px;font-size:20px}p{margin:0;color:#526176}.grid{max-width:1500px;margin:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}figure{margin:0;padding:10px;background:white;border:1px solid #c9d4e3;border-radius:12px;box-shadow:0 10px 30px rgba(15,23,42,.08)}img{display:block;width:100%;height:auto;border:1px solid #e2e8f0}figcaption{padding:9px 4px 2px;color:#526176}@media(max-width:900px){.grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<header><h1>Archify visual-check</h1><p>volc-companion-xiaozhi-20261001.html · automated containment pass · visual review pending</p></header>
<main class="grid">
<figure>
<img src="volc-companion-xiaozhi-20261001.visual-check.1440x900.light.png" alt="light 1440 by 900">
<figcaption><strong>LIGHT</strong> · 1440×900 · containment pass</figcaption>
</figure>
<figure>
<img src="volc-companion-xiaozhi-20261001.visual-check.1440x900.dark.png" alt="dark 1440 by 900">
<figcaption><strong>DARK</strong> · 1440×900 · containment pass</figcaption>
</figure>
<figure>
<img src="volc-companion-xiaozhi-20261001.visual-check.2048x1320.light.png" alt="light 2048 by 1320">
<figcaption><strong>LIGHT</strong> · 2048×1320 · containment pass</figcaption>
</figure>
<figure>
<img src="volc-companion-xiaozhi-20261001.visual-check.2048x1320.dark.png" alt="dark 2048 by 1320">
<figcaption><strong>DARK</strong> · 2048×1320 · containment pass</figcaption>
</figure>
</main>
</body>
</html>
@@ -0,0 +1,547 @@
{
"schemaVersion": 1,
"ok": true,
"command": "visual-check",
"status": "pass",
"visualReview": "pending",
"artifact": {
"path": "E:\\Codex\\story\\platform\\docs\\diagrams\\volc-companion-xiaozhi-20261001.html",
"sha256": "c69dd15e311db9e6a7b1c505b89b5465060504eed6c0f8df042afe01c9ecca7b",
"bytes": 713303
},
"state": {
"detail": "read",
"motion": "still"
},
"chrome": {
"status": "available",
"executable": "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
},
"diagnostics": [],
"containment": {
"status": "pass",
"viewports": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1038,
"diagramWidth": 1008,
"viewBoxWidth": 1210,
"minimumProjectedNodeTextPx": 7.497520661157025,
"minimumProjectedNodeText": "收音 · 播放 · 打断",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1600,
"height": 1000,
"theme": "light",
"innerWidth": 1600,
"innerHeight": 1000,
"scrollWidth": 1600,
"scrollHeight": 1000,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1069,
"diagramWidth": 1039,
"viewBoxWidth": 1210,
"minimumProjectedNodeTextPx": 7.728099173553719,
"minimumProjectedNodeText": "收音 · 播放 · 打断",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1920,
"height": 1080,
"theme": "light",
"innerWidth": 1920,
"innerHeight": 1080,
"scrollWidth": 1920,
"scrollHeight": 1080,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1207,
"diagramWidth": 1177,
"viewBoxWidth": 1210,
"minimumProjectedNodeTextPx": 8.754545454545456,
"minimumProjectedNodeText": "收音 · 播放 · 打断",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1570,
"diagramWidth": 1520,
"viewBoxWidth": 1210,
"minimumProjectedNodeTextPx": 9,
"minimumProjectedNodeText": "收音 · 播放 · 打断",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
}
]
},
"readability": {
"status": "pass",
"minimumProjectedNodeTextPx": 6,
"viewports": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1038,
"diagramWidth": 1008,
"viewBoxWidth": 1210,
"minimumProjectedNodeTextPx": 7.497520661157025,
"minimumProjectedNodeText": "收音 · 播放 · 打断",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1600,
"height": 1000,
"theme": "light",
"innerWidth": 1600,
"innerHeight": 1000,
"scrollWidth": 1600,
"scrollHeight": 1000,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1069,
"diagramWidth": 1039,
"viewBoxWidth": 1210,
"minimumProjectedNodeTextPx": 7.728099173553719,
"minimumProjectedNodeText": "收音 · 播放 · 打断",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1920,
"height": 1080,
"theme": "light",
"innerWidth": 1920,
"innerHeight": 1080,
"scrollWidth": 1920,
"scrollHeight": 1080,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1207,
"diagramWidth": 1177,
"viewBoxWidth": 1210,
"minimumProjectedNodeTextPx": 8.754545454545456,
"minimumProjectedNodeText": "收音 · 播放 · 打断",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1570,
"diagramWidth": 1520,
"viewBoxWidth": 1210,
"minimumProjectedNodeTextPx": 9,
"minimumProjectedNodeText": "收音 · 播放 · 打断",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
}
]
},
"viewerChrome": {
"status": "pass",
"viewports": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1038,
"diagramWidth": 1008,
"viewBoxWidth": 1210,
"minimumProjectedNodeTextPx": 7.497520661157025,
"minimumProjectedNodeText": "收音 · 播放 · 打断",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1600,
"height": 1000,
"theme": "light",
"innerWidth": 1600,
"innerHeight": 1000,
"scrollWidth": 1600,
"scrollHeight": 1000,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1069,
"diagramWidth": 1039,
"viewBoxWidth": 1210,
"minimumProjectedNodeTextPx": 7.728099173553719,
"minimumProjectedNodeText": "收音 · 播放 · 打断",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1920,
"height": 1080,
"theme": "light",
"innerWidth": 1920,
"innerHeight": 1080,
"scrollWidth": 1920,
"scrollHeight": 1080,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1207,
"diagramWidth": 1177,
"viewBoxWidth": 1210,
"minimumProjectedNodeTextPx": 8.754545454545456,
"minimumProjectedNodeText": "收音 · 播放 · 打断",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1570,
"diagramWidth": 1520,
"viewBoxWidth": 1210,
"minimumProjectedNodeTextPx": 9,
"minimumProjectedNodeText": "收音 · 播放 · 打断",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
}
]
},
"captures": {
"status": "pass",
"screenshots": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1038,
"diagramWidth": 1008,
"viewBoxWidth": 1210,
"minimumProjectedNodeTextPx": 7.497520661157025,
"minimumProjectedNodeText": "收音 · 播放 · 打断",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light",
"file": "volc-companion-xiaozhi-20261001.visual-check.1440x900.light.png"
},
{
"width": 1440,
"height": 900,
"theme": "dark",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1038,
"diagramWidth": 1008,
"viewBoxWidth": 1210,
"minimumProjectedNodeTextPx": 7.497520661157025,
"minimumProjectedNodeText": "收音 · 播放 · 打断",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "dark",
"file": "volc-companion-xiaozhi-20261001.visual-check.1440x900.dark.png"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1570,
"diagramWidth": 1520,
"viewBoxWidth": 1210,
"minimumProjectedNodeTextPx": 9,
"minimumProjectedNodeText": "收音 · 播放 · 打断",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light",
"file": "volc-companion-xiaozhi-20261001.visual-check.2048x1320.light.png"
},
{
"width": 2048,
"height": 1320,
"theme": "dark",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1570,
"diagramWidth": 1520,
"viewBoxWidth": 1210,
"minimumProjectedNodeTextPx": 9,
"minimumProjectedNodeText": "收音 · 播放 · 打断",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "dark",
"file": "volc-companion-xiaozhi-20261001.visual-check.2048x1320.dark.png"
}
],
"contactSheet": "volc-companion-xiaozhi-20261001.visual-check.html"
},
"sidecars": {
"receipt": "volc-companion-xiaozhi-20261001.visual-check.json",
"contactSheet": "volc-companion-xiaozhi-20261001.visual-check.html"
}
}
+73
View File
@@ -0,0 +1,73 @@
# 聊天整理与图文回忆录工作流
线上使用自托管 Dify 1.17.1。入口为 <https://82.157.201.114/apps>,使用独立管理员登录。账号密码保存在服务器 `/opt/story-dify/admin-private.json`,不要提交到代码仓库。
## 两个已发布的工作流
- 聊天整理:<https://82.157.201.114/app/984b4de8-9814-4f41-85da-988f43307de9/workflow>。
- 图文回忆录:<https://82.157.201.114/app/fbda0f7e-d24b-4f8c-b932-a7167b992f68/workflow>。
聊天流程:接收任务 → 读取聊天与已有故事 → 分类并逐事件拆分 → 独立审核今天实际事件 → 检查原话证据 → 同一事件比较新增信息 → 返回新增、更新、重复的统计。分类与日常审核分别选择模型,默认均为火山 `deepseek-v4-1-flash-260910`。
回忆录流程:读取该长者全部可用故事 → `doubao-seed-evolving` 一次编写整本 → 核验来源、章节与日期 → 平台先保存并显示正文 → `doubao-seedream-5-0-pro-260628` 逐章配图(并行 3 章)→ 汇总并保存。所有故事必须各出现一次,保持来源日期;插画失败仍保留正文。
选择模型节点,在右侧“设置”的“模型”菜单中选择,提示词在输入变量 `prompt` 中编辑。四个可配置节点如下:
| 工作流 | 节点 | 默认模型 |
| --- | --- | --- |
| 聊天整理 | 分类与故事拆分 | DeepSeek V4.1 Flash |
| 聊天整理 | 审核今天实际事件 | DeepSeek V4.1 Flash |
| 图文回忆录 | 全部故事统一编写整本 | Doubao Seed Evolving |
| 图文回忆录 | 绘制该章情景插画 | Seedream 5.0 Pro |
文本菜单包含 DeepSeek V4.1 Flash、Doubao Seed Evolving、Seed 2.1 Pro、Seed 2.1 Lite。整本写作菜单仅列支持 JSON Schema 的三个 Doubao 模型。插画菜单包含 Seedream 5.0 Pro、5.0 Flash 和 4.0。选择“自定义模型 / 接入点”时,在下面填写火山模型 ID 或 `ep-` 接入点;写作模型须支持 JSON Schema,插画接入点须为图像生成。模型权限和费用沿用当前火山账户,菜单不会切换密钥或服务地址。选错模型或调用失败会明确报错,不会悄悄改回默认模型。
草稿自动保存,先“测试运行”,确认效果后点击右上角“发布”。发布影响后续线上生成任务,已在执行的任务继续使用开始时的工作流版本,同一任务的重复步骤不会因为菜单变化再次调用模型。开始节点的两个输入由平台自动传入。Dify 中手动测试且留空时,使用虚构示例,不写入长者账号。手动测试仍会调用实际火山模型。
画布的“日志”可查看真实任务执行进度、各节点耗时、实际模型 ID 与错误。回忆录正文内记录 `workflow_run_id`,便于对应 Dify 日志。四个模型步骤是原生自定义 API 工具节点,其余步骤保留 HTTP 请求、校验代码及原生迭代节点。
## 数据与授权
Go 后端继续负责登录、账号隔离、授权、来源版本检查、故事合并和数据库保存。真实聊天与整本内容只在私有模型桥接服务的短期内存中传递;Dify 输入和输出只有临时任务编号、执行凭证及统计。上下文任务结束即删除,最长 20 分钟。桥接服务没有数据库凭证,没有公开端口。
火山密钥、两个 Dify 工作流密钥和桥接服务密钥均通过服务器只读秘密文件加载。不要把长者原文或长期密钥粘贴到工作流变量、提示词或导出文件中。
## 配置与维护
API/Worker 配置:
```dotenv
DIFY_ENABLED=true
DIFY_BASE_URL=http://story-dify-nginx/v1
DIFY_BRIDGE_URL=http://model-bridge:8188
DIFY_BRIDGE_SECRET_FILE=/run/secrets/voice-runtime/dify-bridge-secret
DIFY_EXTRACTION_API_KEY_FILE=/run/secrets/voice-runtime/dify-extract-api-key
DIFY_MEMOIR_API_KEY_FILE=/run/secrets/voice-runtime/dify-memoir-api-key
```
默认 `DIFY_ENABLED=false`,使用原有生成器。启用时缺少凭证会在启动时明确失败。
桥接服务源码为 `cmd/workflow-model-bridge`、`internal/workflowbridge`,连接器为 `internal/dify`。模型名称从节点传入,缺省时兼容旧工作流的原模型。Dify 自托管目录为 `/opt/story-dify`,已解析的私密 Compose 配置为 `compose.private.json`。服务配置、数据库密码及所有私密文件均禁止提交。
原生工具提供者名称为“故事平台 · 分步骤模型”,由 `scripts/dify-model-tools.py` 管理。Dify 1.17.1 的 OpenAPI 解析器会忽略 enum 和参数 form,因此安装脚本在原生 `ApiToolBundle` 元数据中配置 SELECT 菜单;API 和运行时使用同一份元数据。不要直接在工具页重新保存 schema,否则菜单参数会被解析回普通文本;变更菜单应更新安装脚本并重新生成元数据。字符串凭证参数使用 `mixed` 变量模板,避免 Dify 编辑器在更换模型时清空字符串的 `variable` 绑定。
API 与 Worker 的 `API_TOOL_DEFAULT_READ_TIMEOUT=660` 必须同时保留,否则原生工具默认 60 秒会中断整本写作。2026-10-07 模型菜单版本的桥接镜像为 `story-workflow-model-bridge:1.1.0-model-menu`,配置、桥接二进制和原工作流备份在 `/opt/story-dify/model-menu-backup-20261007`,回滚时应同时恢复原发布工作流指针和原草稿。
```sh
docker compose -p story-dify -f /opt/story-dify/compose.private.json ps
docker compose -p story-dify -f /opt/story-dify/compose.private.json logs --tail 100 api worker model-bridge
```
平台 API、Worker、gateway 与 Dify nginx、模型桥接服务共用外部网络 `story-workflows`。Dify nginx 的自身 API/Web 上游使用独立别名 `story-dify-api` 和 `story-dify-web`,避免与故事平台服务名冲突。gateway 的 Dify 路由配置有 `/opt/story-dify/gateway-before-dify.conf` 备份。后续重建 gateway 必须保留这个外部网络和 Dify 路由;本次平台发布的冻结 Compose 配置已包含该网络。
Dify 1.17.1 的登录跳转和会话刷新经过 `/auth/refresh`,gateway 必须将该路径精确代理到 `story-dify-nginx`。漏配时,访问 `/apps` 会误进入故事平台家庭登录页。不要代理整个 `/auth/` 前缀,故事平台自身使用 `/auth/callback` 处理 OIDC 回调。2026-10-07 已补齐精确路由,并同步正在挂载的配置、当前发布配置与 `/opt/story-dify/gateway-dify-routes.conf`;修复前备份位于 `/opt/story-dify/gateway-auth-refresh-backup-20261007T003108Z`。修改挂载文件应保留 inode,再执行 `nginx -t` 和 `nginx -s reload`。
Dify 与 sandbox 的执行密钥必须一致;Celery broker URL 必须使用实际 Redis 密码。仅健康探针通过不足以确认工作流可执行。整本模型单次请求允许 480 秒,整体重试最多 600 秒。整本编写节点读超时为 660 秒,Squid 的请求/读超时为 12 分钟、连接存活为 20 分钟,避免默认两分钟代理限制中断长正文。
## 可复现的画布与验证
`python scripts/build-dify-workflows.py --provider-id PROVIDER_UUID` 从应用提示词生成带菜单的 `deploy/dify/workflows/*.yml` 和工具 OpenAPI 定义。先在目标工作空间安装原生提供者,再传入其 UUID;当前工作空间为 `5210ee18-44c1-47f3-a36e-4996e2403d50`。不传 UUID 则生成兼容旧版桥接服务的 HTTP 画布。这些文件是合法 YAML 的 JSON 写法,Dify 0.7.0 DSL 可直接导入,不含长期密钥。可以从 Dify 导出当前已修改的 DSL 作为新版本。部署迁移应使用 `dify-model-tools.py stage APP_ID... --backup-dir PRIVATE_PATH`,保留实际草稿里的提示词;验证后再 `publish`,发生并发修改时哈希校验会拒绝发布。
验证覆盖:Dify 流式执行和中断停止、私有上下文授权和删除、并发步骤避免重复调用、完整故事目录成书、插画欠费保留正文、空故事数组传输,以及数据库中正文先显示、来源变更失效、撤回授权后拒绝读取。实际 Dify 画布另用隔离模型夹具执行两个完整流程,其中回忆录测试包含三个章节的原生并行迭代;测试应用和容器结束后删除。真实火山模型调用另行验证。
回忆录模型的 JSON 格式兼容:根据 schema 补齐缺失的段落数组左括号,并处理引号和换行的转义。修复保持原始段落、故事编号和日期,之后仍执行完整来源校验;被长度截断的模型回复不会被修复后接收。
+44
View File
@@ -0,0 +1,44 @@
# 设备直连聊天简化与验证记录
2026-09-15,后端版本 `0.93.0-rc.direct1`,已发布到 `https://82.157.201.114`。
## 本次变更
- 平台仍负责火山设备注册;ble-bind 成功直接下发 5 个专属语音字段,设备保存后本地初始化 SDK。
- 新设备无需 time-challenges、register、voice-runtime、activation-acks 流程。设备通过 RTC/SNTP 取得正确 UTC 时间,现有 HTTPS 请求签名不变。
- chat/start 只需 device_id、session_id;不再检查平台开机登记或本地 SDK 初始化回执。旧固件携带合法 boot_id 仍兼容,旧接口未删除。
- 移除首次成功聊天更新设备状态时对旧初始化回执的依赖,否则新设备会一直停留在 PROVISIONING。状态更新仍要求真实供应商字幕证据和设备结束标识,不伪造 SDK 回执。
- 保留当前绑定、供应商注册结果、老人智能体、配额、同会话续期、结束去重和授权检查。聊天记录仍按 session_id 的回调地址关联。
- 设备交接文档已同步;初始化必需字段由 6 项缩减为 5 项,hardware_revision 无需新设备上报。删除旧 TIME1/CFG1 计算示例,保留 HTTP 签名、UUID/nonce、BLE 和 OTA 校验 C 函数。
## 本地验证
`go test ./...` 通过;相关包 `go vet` 通过。隔离数据库测试无跳过:callmemory 25 项、onboarding 59 项、devicefleet 3 项,共 87 项(不计子测试)。日志在 `.tmp-direct-no-startup-tests`。随后新增 OpenAPI 格式与必填字段检查也通过。
新增端到端测试使用真实 PostgreSQL、Redis、HTTP 路由和设备签名,供应商边界使用测试替身:
1. 新出厂测试设备从未登记开机,未绑定聊天返回 DEVICE_UNBOUND。
2. HTTP ble-bind 返回完整火山凭据;同请求重试不重复注册供应商。
3. 不传 boot_id、不领初始化配置、不发送初始化回执,chat/start 成功;重试/续期不重复准备供应商任务。
4. 模拟火山签名回调按 session_id 入库;重复回调只保留一条记录。
5. 重复上报 chat/end 成功,首通后设备正常进入 ACTIVE;数据库中仍没有虚构的开机登记/SDK 回执。
6. 解绑后新会话和续期被拒绝,旧会话结束补报仍允许。
7. 供应商未完成注册、旧绑定代次、并发通话、过期租约、已结束会话复活等被拒绝。
C 辅助函数在 Windows 主机使用 GCC 和真实 Mbed TLS 编译运行通过,覆盖 HTTP 签名、随机串格式、UUID、BLE 长度/分片打包及 OTA 分块哈希/大小校验。不使用实物密钥运行这些测试。
## 线上验证与发布
- 发布前完成平台加密备份,确认没有进行中通话;未迁移数据库、未执行 seed、未改变设备绑定,也未烧录硬件。
- 仅生产业务文件 `internal/callmemory/direct.go` 改变;基于原发布源码构建 API、callbacks、worker,其他生产配置与前端保持不变。
- 服务器拉取构建基础镜像超时,改为本机交叉编译生产源码;第一次运行发现二进制执行权限缺失,自动回退后修复权限,第二次发布成功。
- 当前发布目录 `/opt/data-terminal-cloud/releases/0.93.0-rc.direct1`;三个后端容器均运行新版本,HTTPS readiness=200,PostgreSQL/Redis/对象存储正常。
- 独立联调设备 `KP01-020000260914` 在线上不传 boot_id 请求 chat/start,返回 409 DEVICE_UNBOUND,而非旧参数校验错误。未调用启动接口、未改变绑定、未发起真实通话。
## 尚未验证/外部问题
- 公网 MQTT 8883 的 TLS 连接仍返回 ECONNRESET;服务器已监听 IPv4/IPv6 8883,本机 TLS 1.3 握手及 IP 证书校验通过。说明服务端 TLS 正常,但公网链路仍需排查云安全组/网络路径,不能断言具体阻断点。
- 真实设备音频采集/播放、真实火山 RTC 通话与真实固件安装未执行。本地供应商模拟回调不等于真实火山端到端验收。
- 因此本次能确认平台协议和覆盖的业务测试通过,不能保证没有其他缺陷,也不能宣称实物 MQTT/OTA 全流程已经打通。
交接主文档:[设备接口文档](embedded-device-business-guide-20260914.md)。真实初始化密钥仅在桌面私有交接文件中,不放本记录或公开仓库。
+79
View File
@@ -0,0 +1,79 @@
# 老人专属智能体与设备聊天接口
服务地址:`https://82.157.201.114`。2026-09-15 起,新设备不需要平台开机登记或 SDK 初始化回执。
## 1. 创建老人及智能体
`POST /api/v1/households/{household_id}/subjects`
```json
{"preferred_name":"张奶奶","language":"zh-CN","timezone":"Asia/Shanghai","profile_data":{},"conversation_preferences":{}}
```
后端保存老人,调用火山 `AibotCreate`,使用服务器现有豆包 S2S 模板(Provider=volcano,OutputMode=0);再调用 `AibotBind` 把新智能体关联到现有硬件产品,否则设备会被火山返回 `LimitExceeded.DeviceRtcBotNotFound`。每位老人一个 bot_id,存入自己的 PostgreSQL `subject_voice_agent`,不保存在小程序缓存里。智能体模型配置及 S2S 应用信息不再随开聊接口发给设备。
返回的 `data` 包含原老人字段,以及:
```json
{"id":"老人UUID","voice_agent":{"bot_id":"bot...","status":"READY"}}
```
火山失败时老人仍保存成功,`voice_agent.status=FAILED` 并给出提示;在原老人上重试,不要再创建一个老人:
`POST /api/v1/subjects/{subject_id}/voice-agent`,不需要请求体。成功返回 `data:{bot_id,status:"READY"}`。查询老人、老人列表也返回智能体状态。
## 2. 绑定设备
沿用现有 BLE 配网和绑定接口。关系为 `设备 → 当前绑定的老人 → 老人的 bot_id`。换绑会自动使用新老人的智能体;解绑不删除老人、智能体或历史聊天。设备供应商注册凭据沿用,不需要为每个智能体重新注册硬件。
## 3. 设备开始聊天
`POST /api/v1/device/chat/start`
```json
{"device_id":"KP01-B43A451634A8","session_id":"本次聊天UUID"}
```
`session_id` 由设备为每次聊天生成;同一聊天重试使用相同值。不需要 boot_id、register、voice-runtime、activation-acks;保存绑定响应的 5 个火山字段后,在本地初始化 SDK 即可请求聊天。设备 HTTP 签名保留。旧固件携带合法 boot_id 仍兼容,但不再与开机登记比对。
成功返回:
```json
{"ok":true,"code":"OK","data":{"session_id":"本次聊天UUID","bot_id":"该老人的bot_id","params":{"PlatformTaskID":"本次聊天UUID","AgentConfig":{"WelcomeMessage":"你好,今天想聊些什么?","EnableConversationStateCallback":false,"ServerMessageURLForRTS":"https://121.41.46.238/callbacks/v1/volcengine/chat?session_id=本次聊天UUID","ServerMessageSignatureForRTS":"沿用已有回调字段"}},"lease_expires_at_ms":1790000000000,"expires_at_ms":1790003600000}}
```
设备把 `bot_id`、`params` 交给现有火山 SDK;SDK 直接连火山拿 RTC 房间并收发音频。无需再传 S2SConfig。后端在每次新聊天开始时,通过 AibotUpdate 给这个已有智能体设置本次回调地址及当前可用记忆;不是重新创建智能体。`AgentConfig` 也携带相同回调地址,让聊天记录准确找到本次 session_id。原固件每 10 秒重复 start 续期逻辑保留,续期不重复更新火山。同一老人同一时间只允许一通聊天。
未绑定:HTTP 409,`code=DEVICE_UNBOUND`,`message=绑定错误:设备没有绑定人员`。智能体未就绪:HTTP 409,`code=VOICE_AGENT_NOT_READY`。供应商 SDK 尚未注册完成:`DEVICE_NOT_READY`。
## 4. 火山回调聊天记录
`POST /callbacks/v1/volcengine/chat?session_id={session_id}`
由火山直接请求服务器,不经过设备或小程序:
```json
{"message":"火山字幕包的Base64","signature":"本次配置中的ServerMessageSignatureForRTS"}
```
`message` 解码后是原生 subtitle 数据(或带 subv 帧头的同一数据):
```json
{"type":"subtitle","data":[{"userId":"input","roundId":1,"sequence":1,"text":"今天心情不错","definite":true,"paragraph":true}]}
```
保存最终字幕到 `call_utterance`,按 session_id 关联本次老人、设备,重复回调去重,挂断后迟到的回调仍归原会话。成功返回 HTTP 200、文本 `ok`。这里配置的是 `AgentConfig.ServerMessageURLForRTS`,不是 Function calling 接收消息 URL。
## 5. 设备结束聊天
`POST /api/v1/device/chat/end`
```json
{"device_id":"KP01-B43A451634A8","session_id":"本次聊天UUID","reason":"hangup","sdk_stopped":true}
```
返回 `data:{session_id,ended:true}`。失败可重复发送;即使之后设备解绑,旧会话的结束通知仍可提交。其他 reason:`start_failed`、`disconnected`、`reboot`、`timeout`。
本次只改变智能体和聊天通路,不新增安全验证步骤。火山原生 MemoryConfig 暂不启用;现有记录存储、后台整理和已授权平台记忆继续使用。记忆改由后端直接配置到智能体,不经设备转发。
火山管理 API:`POST https://rtc.volcengineapi.com?Action=AibotCreate&Version=2025-08-01`。查询/重试使用同版本 AibotQuery、AibotUpdate、AibotBind。绑定产品的请求体为 `{"Id":"bot...","Binding":{"InstanceId":"现有实例ID","ProductKey":"现有产品Key"}}`。AccessType=public 表示当前火山账户的平台智能体,不表示跨账户公开共享;业务层依然是一位老人一个智能体。
@@ -0,0 +1,34 @@
# 老人专属智能体改造验证(2026-09-14)
接口说明见 [elder-agent-flow.md](elder-agent-flow.md)。
## 交付版本
- 线上服务:`0.89.1-rc.elder2`,数据库迁移版本 `64`。
- EchoEar 固件:`1.5.0-elder-agent.2`,COM6,设备 `KP01-B43A451634A8`。
- 应用镜像:3,707,136 字节,SHA-256 `9716b47c125725b6e3cd8a6563305499af91405540e891b742f10e5183617499`。
- 仅写入应用分区 `0x10000`。保留 NVS、Wi-Fi、当前绑定及资产分区;改造前应用备份位于 `firmware/echoear-platform-agent/.tmp-elder-device/before-app.bin`。
- 火山智能体信息保存在本项目自己的 PostgreSQL,不在设备或小程序缓存中。
## 已验证
- 实际通过 AibotCreate 创建两位现有老人的独立智能体,保存为 READY;实际通过 AibotBind 关联现有产品。
- 设备当前绑定测试长者,使用其专属智能体 `botAaeozGmi2`,不是旧全局智能体。
- `go test ./...` 通过。
- 独立数据库迁移及 `callmemory`、`saas`、`httpapi` 集成测试通过,涵盖创建/失败重试、老人智能体隔离、解绑拒绝、回调去重及迟到回调。
- 网页类型检查和生产构建通过。
- 真实 C 适配器的主机 ASan/UBSan 测试通过,包括时钟偏差、会话续期、结束重试;PCM 分帧及运行配置 JSON 测试通过。
- ESP-IDF 固件编译及应用分区刷写校验通过。
- 设备真实入房、收到助手 Opus 音频、解码到 16 kHz 单声道 PCM16 播放;麦克风按 20 ms 编码 Opus 上传,实测发送错误计数为 0。
- 火山真实字幕回调已写入 `call_utterance`,房间机器人 `bot_<时间戳>` 正确识别为 ASSISTANT。
- 真实会话 `99b4e311-6499-42bc-af69-5926b8377036`、`74658628-2c33-41f0-9572-f3e622d1f566` 均 ENDED、sdk_stopped=true,后者为同次开机再次开聊。
- 最终镜像连续两通实测会话 `a3afe40a-1546-4c79-aec4-f3987fd10030`、`eacab6a5-2d78-44b2-9940-d8e78fc9839c` 均 ENDED、sdk_stopped=true,并各有真实助手字幕;两通均成功播放,无重启、Opus 解码错误、音频队列溢出或 feeder 写入错误。
- 当前设备激活状态 ACTIVE,绑定代次 9。
## 验证边界
这些是串口、SDK 和数据库证据,不等于现场听感验收。尚需用户现场确认扬声器音质,以及对设备说话后能否得到自然回答;本次未进行长时间连续通话/弱网压力测试。
## 音频修正
旧代码把 RTC 收到的 Opus 包直接当 PCM 播放,并将 60 ms PCM 原样送入 RTC。现已在 ESP32 端增加 Opus 编解码和 20 ms 分帧,适配编解码线程栈,等待入房/远端加入后再送音频,释放上次房间参数,并调整 PCM 播放等待时间。
+15
View File
@@ -0,0 +1,15 @@
# 添加长者与授权界面更新
已修改:添加长者资料中移除只有普通话的固定选择、不可操作的陪伴方式,以及下一步授权介绍;按钮简化为“下一步”。授权步骤使用居中列表弹窗,每项说明未授权的实际影响,支持返回资料、查看授权说明以及暂不开通。
授权默认全部不选,仅提交明确选中的项目;暂不开通始终提交空授权列表。提交期间禁止重复操作。家庭分享按现有独立资源授权规则描述,生成新记忆的文字依赖单独标注。
添加长者与设备设置中的创建入口复用同一组件。现有后端请求、分项确认和 API 保留。
验证:冻结线上 0.99.40-rc.creation-consent1 源码后应用本次六个文件的变更,TypeScript/Vite 构建成功;1440×1024 桌面与 390×844 手机布局、五项复选框、内部滚动、返回保留资料、Escape 关闭、默认零授权、依赖提示与明确提交验证通过。浏览器错误日志为空。隔离请求确认跳过提交 types=[]、confirmed=true,正常提交只含选中的 transcript_storage/raw_audio_storage。
验收与截图:E:/Codex/story/.local-evidence/elder-consent-redesign-20261005/design-qa.md、desktop-final.jpg、mobile-clip.jpg、comparison.jpg、comparison-detail.jpg。
状态:用户明确要求“发布”后,已上线 0.99.41-rc.consent-design1。仅替换 Web 服务,API 仍为 0.99.40-rc.creation-consent1;其他服务容器 ID 和启动时间均保留,数据库 schema 74/clean,无迁移或业务数据写入。124 个静态文件完整回读和公开入口校验、API/worker 健康检查全部通过。
线上浏览器验收:简化的长者表单与新授权弹窗已显示,五项授权默认不勾选,说明为未授权后果,浏览器错误日志为空。刷新前的资料草稿已恢复;没有提交创建长者或开通授权。线上截图为 E:/Codex/story/.local-evidence/elder-consent-redesign-20261005/production.jpg;发布回执为同目录 production-receipt.json。
+36
View File
@@ -0,0 +1,36 @@
# 老人档案与火山专属智能体同步
资料保存成功后立即尝试更新专属 Bot;同步失败不会回滚已保存资料。第 76 个迁移在 `subject_voice_agent` 中记录待同步版本、已确认版本、状态与重试时间,档案更新和待同步登记在同一数据库事务内完成。隐私更正走同一触发器。头像、内部备注、单独的档案版本号更新不会触发供应商更新。
## 发给模型的内容
- 基础角色:保留现有专属 Bot 的基础人设,迁移只移除旧编译器追加的末尾称呼句。
- 交流设置:称呼、语言、节奏、避免主动提及的话题。
- 个性化资料:背景、兴趣、长期陪伴方向、优先话题;需要现有 `personalized_memory` 授权和有效语音聊天授权。
- 本通上下文:开场、已授权记忆、最近问题线索;只在开聊准备时追加,不写回基础配置。
完整档案使用 JSON 编码,作为事实和偏好资料;自由文本不能改变基础角色和安全规则。避免主动提及列表作为交流边界,不能用于主动开场、引导或追问;长者主动提起时尊重当前意愿。优先话题不强制转题,长期陪伴方向不要求每通完成。头像、内部备注和其他扩展字段不会进入模型上下文。原生 `MemoryConfig.Enable` 保持关闭。
## 同步与并发
API 保存后调用 `Sync`。worker 每两秒扫描待同步项目,失败按 1、2、4……256 秒的间隔持续重试。任务只保存版本和固定错误码,不记录供应商错误正文、档案正文或凭据。连续编辑会合并为最新版本。
资料更新、同步和新通话准备使用同一个内部老人 ID 的锁。已有活动通话时不更新供应商配置;直连通话需要结束记录、音频关闭和 SDK 停止,才可同步。状态显示“当前通话结束后同步,下次聊天生效”。供应商更新返回后会读取 Bot,核验人设、欢迎语、回调地址、验签配置及原生记忆关闭状态;凭据可能被供应商遮蔽,不比较或输出凭据。
开聊准备使用相同编译器读取最新档案,再附加本通上下文。更新或核验失败时沿用现有 `VOICE_AGENT_CONFIG_FAILED` 启动错误,不使用旧禁提配置启动新通话。同一会话续约仍复用冻结参数。通话结束后后台清理供应商上的临时开场、记忆和回调,恢复专属 Bot 的稳定配置。
授权撤回会登记待同步;授权到期由扫描器重新检查并清理个性化内容。更新过程中发生授权变化时,旧请求不能确认新版本。已有的通话隐私停止机制继续生效。
## 前端与接口
`Subject.voice_agent` 增加 `persona_sync_status`(PENDING/SYNCED/DEFERRED/FAILED)、`persona_revision`、`persona_synced_revision`、`persona_synced_at` 和 `personalization_included`。同步状态与 Bot 创建状态分开;同步失败不把 READY Bot 改成创建失败。
资料编辑页显示同步状态。待同步期间每五秒只刷新同步元数据,不重置未保存的编辑。同步失败可使用原有 `POST /api/v1/subjects/{id}/voice-agent` 重试;该接口现在会同步 READY Bot 的待处理资料。缺少个性化授权时页面明确说明相关字段尚未进入模型。
## 发布与验证
需要先应用迁移 `000076_elder_persona_sync`,再发布 API、worker 和 Web。worker 需要同 API 一样可读取 `DIRECT_CHAT_TEMPLATE_FILE` 或 `VOLC_VOICE_CHAT_TEMPLATE_FILE`,并具有火山更新、查询权限。迁移将现有专属 Bot 标为 PENDING,由后台补同步;正在通话的 Bot 延后处理。尚未创建成功的 Bot 继续使用既有创建重试入口。
运行 `scripts/test-elder-persona-sync.ps1` 验证迁移回填、升级回滚、真实数据库事务与现有相关功能。脚本创建独立的临时 PostgreSQL/Redis 容器,只发布到本机,使用合成数据和本地供应商替身,结束后自动清理,不接触 Compose 或生产服务。
本次本地验证四个相关包共有 372 项测试(包含子案例)通过,无跳过;火山接口通过本机 HTTP 模拟验证更新和查询回读。上线后仍需对真实 Bot 做读取核验和实际语音交流验收,接口同步成功不代表模型在每一轮都遵守全部偏好。
+15
View File
@@ -0,0 +1,15 @@
# 长者资料切换改版(第 2 版)
用户选择左侧长者名单、右侧资料的第 2 版。桌面直接点击头像和称呼切换,当前长者以浅绿背景和绿条标记;手机名单横向滚动,资料在下方显示。基本资料、交流偏好与数据授权入口移入资料区域下方。
代码范围:`web/src/components/home/ElderRoster.tsx`、`web/src/components/home/elder-roster.css`、`web/src/pages/SaaSWorkspace.tsx` 中 SubjectPanel 的布局与导入。复用现有资料、头像、选中状态和授权接口。方向键及 Home/End 支持键盘切换,长名字有完整可访问名称。
用户明确要求“直接上线”后,已发布 `0.99.43-rc.elder-roster2`。线上入口:https://82.157.201.114/?section=subjects 。本地演示预览:http://127.0.0.1:5189/portal-preview.html?section=subjects 。
验收证据位于 `E:/Codex/story/.local-evidence/elder-switcher-design-20261005`。预览候选由上一已发布前端基线加本次 3 个源文件变更组成;DEV 演示数据和两张示例头像只位于隔离预览。预览专用 Vite fs/watch 配置不进入项目配置。后续发布需从源文件变更生成正式静态包,排除演示头像及 DEV 入口。
TypeScript、候选生产构建和浏览器验收均通过。检查了桌面/手机、3 位长者分别显示正确资料与 5/1/0 项授权、详情入口、键盘切换、15 位长名单、长名字、单人和空列表。完整画面与内容局部已对照选定参考图;详见 `web/design-qa.md` 2026-10-05 长者切换章节。
正式发布基于当时最新的线上 `0.99.42-rc.permission-impact1`,保留其权限提示与单项授权入口。增量仅包含上述 3 个源文件;正式候选重新构建通过,演示数据和演示头像已排除。仅替换 Web 镜像,128 个静态文件完整回读和公开入口哈希校验通过,API/worker 健康,其他服务容器 ID 与启动时间保留,数据库 schema 74/clean,无迁移或业务数据写入。回退目标为 `.42` 的网页镜像和 Compose 链。
线上已登录隔离验收家庭中,爷爷和姥姥的切换、对应兴趣与语速、5/3 项有效授权读取通过;手机页面宽度无溢出。应用未发现控制台错误;浏览器日志中有既存 MetaMask 扩展告警。生产回执、脚本、清单和桌面/手机截图位于 `.local-evidence/elder-switcher-design-20261005/release/`。
@@ -0,0 +1,648 @@
# VoiceLink 设备接口文档(蓝牙、聊天、MQTT、OTA)
> 2026-09-18 更新:小程序支持先添加设备到账号、后创建家庭和老人。设备端 BLE 与 ble-bind 的五个火山字段保持不变,具体变化见 [账号优先流程](device-account-first-20260918.md)。
2026-09-15 · 设备接口专用版 · 给嵌入式开发人员
**流程:小程序连接蓝牙 → 下发 Wi-Fi 和绑定令牌 → 设备联网绑定 → 每次聊天向平台拿参数 → 设备直连火山 → 设备上报结束。**
平台地址:`https://82.157.201.114`。下文设备编号统一使用本次已登记的联调设备 `KP01-020000260914`。
本文只包含设备直接参与的蓝牙、HTTP 和 MQTT 通信。第 1–7 节为初始化信息、配网绑定和聊天,第 8–10 节为心跳和 OTA;附录为设备 HTTP 请求格式、开机初始化和 C 计算函数。聊天继续按每通 `session_id` 关联回调。
这是一台独立联调设备的测试身份,型号沿用平台登记的 ECHOEAR-S3,不代表最终硬件型号。最终实物需分别在平台登记,不要把同一身份复制烧录到多台设备。本文的版本号、时间戳、任务 ID 和下载链接均为格式示例,运行时使用真实返回值。
蓝牙名称采用新约定 `vl-型号-编号后8位`,由嵌入式实现;小程序已经支持。
## 1. 设备需要烧录的字段
| 字段 | 本次设备填写值 | 用途 |
|---|---|---|
| factory_id | KP01-020000260914 | 设备唯一编号;接口里叫 device_id |
| model | ECHOEAR-S3 | 设备型号 |
| origin | https://82.157.201.114 | 平台地址 |
| device_key_hex | 平台生成的 64 位十六进制字符串,烧录时解码成 device_key 的 32 字节 | 当前平台请求签名用的设备密钥 |
| key_version | 1 | 设备密钥版本 |
实际烧录值见同目录 [device-init.private.json](device-init.private.json)。这是**一台联调设备**的身份,不要复制到多台设备。
烧录交接只取以下 5 个字段,私有文件中的其他平台管理字段不用烧录:
```json
{
"factory_id": "KP01-020000260914",
"model": "ECHOEAR-S3",
"origin": "https://82.157.201.114",
"device_key_hex": "<平台生成的本设备密钥>",
"key_version": 1
}
```
MQTT 配置联网后向平台获取,不新增每台设备的烧录字段。固件自身带实际 `firmware_version` 和 `version_code`,随每次固件发布更新;未对应平台已发布固件时顺序号填 0。建议 firmware_version 使用简短的版本名称。hardware_revision 仅是平台库存信息,新设备接口无需烧录或上报它。
不需要烧录:Wi-Fi、账号、老人 ID、智能体 ID、火山产品密钥。Wi-Fi 和绑定令牌由小程序发送;老人和智能体的关系由平台保存。火山设备注册由后端执行,设备只保存后端下发的专属语音凭据。
## 2. 蓝牙协议与名称约定
| 项目 | 固定值 / 约定 |
|---|---|
| 协议 | BLE GATT,明文 UTF-8 JSON |
| Service ID | 8e7a2000-6f4b-4a2d-9c31-7d824f5e6100 |
| RX 特征值 | 8e7a2001-6f4b-4a2d-9c31-7d824f5e6100,Write;小程序发给设备 |
| TX 特征值 | 8e7a2002-6f4b-4a2d-9c31-7d824f5e6100,Indicate;设备回复小程序,提供 CCCD |
| 蓝牙名称 | vl-型号-设备编号后8位 |
| 本次设备名称 | vl-ECHOEAR-S3-00260914 |
UUID 写在程序里,所有同协议设备相同。设备开启可连接广播,Service ID 放广播包,名称单独放扫描响应包。小程序按 Service ID 搜索,名称只用于显示,旧名称也能连接。
名称最多 29 个 UTF-8 字节,因此 `vl-` 后的型号最多占 17 字节;尽量用英文数字,完整型号通过下一节返回。
**以下所有蓝牙 JSON 都按同一种方式传输:**
```text
完整消息 = 2 字节小端 JSON 长度 + JSON 的 UTF-8 字节
每片最多 20 字节;接收端拼完整后再解析。JSON 最多 2048 字节。
长度头只加一次,不含头本身,也不含字符串结尾的 NUL。
{"cmd":"get_info"} 的 JSON 长度为 18,长度头是 12 00(十六进制)。
```
小程序先订阅 TX,再向 RX 发命令。设备收到命令后回复,**不要一连接就主动推送 JSON**。发送 Indicate 分片时,收到确认再发下一片。
## 3. 蓝牙连接后:小程序读取设备信息
用途:让小程序知道设备编号和型号,用设备编号向平台查询绑定关系。
小程序 → 设备,输入:
```json
{"cmd":"get_info"}
```
设备 → 小程序,输出:
```json
{
"device_id": "KP01-020000260914",
"model": "ECHOEAR-S3",
"protocol_version": 2
}
```
| 字段 | 中文说明与用途 |
|---|---|
| cmd | 命令,固定 get_info |
| device_id | 烧录的 factory_id,不是手机扫描得到的蓝牙地址 |
| model | 完整设备型号 |
| protocol_version | 固定数字 2,小程序用来确认协议一致 |
## 4. 配网:小程序发送 Wi-Fi 和绑定信息
设备收到非空 bind_token 时进行绑定;收到空字符串时只重新配网并检查原绑定。设备不用处理用户登录。
小程序 → 设备,输入:
```json
{
"cmd": "configure",
"wifi_ssid": "HomeWiFi",
"wifi_password": "Example123",
"bind_token": "<小程序从平台取得的43字符令牌,原样传递>"
}
```
| 字段 | 中文说明与用途 |
|---|---|
| cmd | 固定 configure |
| wifi_ssid | Wi-Fi 名称,1–32 个 UTF-8 字节,当前设备使用 2.4 GHz Wi-Fi |
| wifi_password | Wi-Fi 密码,当前支持 8–63 个可打印 ASCII 字符,不支持开放网络 |
| bind_token | 非空表示绑定到选定老人;空字符串表示保留原绑定、只重新配网 |
设备立即回复:
```json
{"status":"received"}
```
只换 Wi-Fi 的输入示例:
```json
{"cmd":"configure","wifi_ssid":"NewHomeWiFi","wifi_password":"Example456","bind_token":""}
```
设备收到后:回复 received → 提示用户轻点屏幕/确认键(60 秒内)→ 连接 Wi-Fi → 调用下一节的平台接口 → 成功后保存 Wi-Fi。
**received 只表示收到,不表示绑定成功。** bind_token 不传 null,也不要省略。Wi-Fi 密码不上传平台,设备不需要用户手机号或账号密码。
### 小程序查询处理结果
输入:
```json
{"cmd":"get_status"}
```
输出成功示例:
```json
{"status":"success"}
```
输出失败示例:
```json
{"status":"failed","message":"Wi-Fi 连接失败,请检查密码"}
```
| status | 含义 |
|---|---|
| idle | 等待下发参数 |
| received | 已接收,等待用户在设备上确认 |
| wifi_connecting | 正在连接 Wi-Fi |
| binding | 正在向平台绑定或检查已有绑定 |
| success | 平台处理成功,Wi-Fi 已保存 |
| failed | 失败,message 填具体原因 |
小程序约每 2.5 秒查询一次。设备只在收到 get_status 时回复,不主动推送。
## 5. 联网后:设备向平台绑定
下面都是 HTTP POST,正文为 JSON,必须携带末尾“平台请求公共格式”中的请求头。设备先完成校时。
### 5.1 bind_token 非空:提交绑定
地址:`POST https://82.157.201.114/api/v1/device/ble-bind`
用途:把这台设备绑定到小程序选定的老人。平台通过请求头 X-Device-ID 识别设备,通过 bind_token 找到老人。
输入:
```json
{"bind_token":"<蓝牙收到的原始令牌>"}
```
平台在本次请求中完成绑定、向火山注册(已注册则复用),拿齐配置后才返回成功。设备不需要另查“是否准备好”。
成功输出(业务字段,位于 HTTP 响应的 data 中):
```json
{
"status":"success",
"instance_id":"<火山实例>",
"product_key":"<火山产品标识>",
"device_name":"<本设备在火山的名称>",
"device_secret":"<本设备专属火山密钥>",
"RTCAppID":"<RTC应用ID>"
}
```
| 字段 | 用途 |
|---|---|
| bind_token(输入) | 蓝牙收到的令牌原样转发,不再传账号、老人 ID 或 Wi-Fi |
| status(输出) | success 表示绑定完成且下方 5 个字段均已取得 |
| instance_id / product_key / device_name | 设备连接火山时使用的实例、产品和设备身份 |
| device_secret | 火山设备密钥,不是烧录的 device_key;由平台注册取得并保存 |
| RTCAppID | 初始化火山 RTC SDK 的应用 ID |
设备保存这 5 个字段,**不用调用火山 DynamicRegister,不再申请注册许可或上报 SDK_REGISTER 成功**。这些凭据只返回给设备,不通过蓝牙转发给小程序。
保存上述字段和 Wi-Fi 后,清理本次令牌,蓝牙 get_status 返回 success。绑定接口不再返回 voice_status、voice,也不会以 success 搭配空配置。
设备 HTTP 超时设为 **45 秒**;平台取参等待上限为 35 秒。网络超时或 5xx 时保留原 bind_token 和本次 X-Request-ID 重试(更新签名时间与 nonce),不要重新申请绑定。平台保留已完成的绑定和注册进度,不重复注册火山。
| HTTP / code | 含义与处理 |
|---|---|
| 504 / VOICE_REGISTRATION_TIMEOUT | 取参超时,原绑定已保存;使用原令牌重试 |
| 502 / VOICE_REGISTRATION_FAILED | 注册失败或结果不确定,绑定已保存;持续出现请平台核对,不重新注册 |
| 502 / VOICE_CONFIG_INVALID | 平台取得的配置不完整,请平台处理 |
| 503 / VOICE_REGISTRATION_PAUSED | 语音服务暂停,绑定已保存;稍后原令牌重试 |
| 409 / VOICE_MIGRATION_REQUIRED | 旧设备需补交原火山凭据,按迁移说明处理后原令牌重试 |
| 409 / BINDING_CHANGED | 等待期间设备被解绑或归属变化,重新绑定 |
错误响应示例(外层):
```json
{"ok":false,"code":"VOICE_REGISTRATION_FAILED","message":"绑定已保存,火山注册结果未确认,请联系平台核对","request_id":"<本次请求UUID>"}
```
### 5.2 bind_token 为空:检查原绑定,不重新绑定
地址:`POST https://82.157.201.114/api/v1/device/ble-state`
输入必须是空 JSON 对象:
```json
{}
```
输出(业务字段):
```json
{"bound":true}
```
bound=true:保存新 Wi-Fi,蓝牙返回 success。bound=false:说明平台已经解绑,蓝牙返回 failed,message 填“设备没有绑定人员,请重新绑定”。空令牌不能让设备跳过平台检查。
### 5.3 可选:丢失本地配置后重新获取语音凭据
地址:`POST https://82.157.201.114/api/v1/device/voice-config`
输入:
```json
{}
```
输出 data:
```json
{
"voice_status":"ready",
"voice":{
"instance_id":"<火山实例>",
"product_key":"<火山产品标识>",
"device_name":"<本设备在火山的名称>",
"device_secret":"<本设备专属火山密钥>",
"RTCAppID":"<RTC应用ID>"
}
}
```
这是已有的恢复接口,保留 voice_status / voice 格式;**正常绑定成功后不需要调用它**。绑定响应丢失时直接重试 ble-bind 即可。本机凭据丢失后可通过本接口取回原凭据,不重复注册火山。平台还在准备时会返回 `{"voice_status":"preparing","voice":null}`,每 5 秒再查;不需要另外领取初始化命令。
其他状态:paused=平台暂停语音准备;reconcile_required=注册结果不确定,需要平台核对;migration_required=旧设备凭据仅存于设备,需要按迁移说明补交一次。设备被解绑时返回 HTTP 409 / DEVICE_UNBOUND,不继续返回凭据。
## 6. 每次聊天之前:向平台申请聊天参数
地址:`POST https://82.157.201.114/api/v1/device/chat/start`
用途:平台查询“设备 → 已绑定老人 → 老人的智能体”,有绑定且语音已就绪才返回开聊参数。
**前置条件:设备已联网、绑定成功,已保存绑定响应的 5 个火山字段,并在本机初始化 SDK。** 不再要求向平台注册开机、重新领配置或上报 SDK 初始化确认。每次直接调用下述 chat/start。
输入:
```json
{
"device_id": "KP01-020000260914",
"session_id": "5b5b9120-58c3-4433-96e1-67923598cafa"
}
```
| 输入字段 | 中文说明与用途 |
|---|---|
| device_id | 设备编号,与 X-Device-ID 一致 |
| session_id | 本次聊天编号,设备每次新聊天生成一个 UUID v4;这次聊天结束前不变 |
输出(业务字段,示意值):
```json
{
"session_id": "5b5b9120-58c3-4433-96e1-67923598cafa",
"bot_id": "<该老人对应的火山智能体ID>",
"params": {
"PlatformTaskID": "5b5b9120-58c3-4433-96e1-67923598cafa",
"AgentConfig": {
"WelcomeMessage": "你好,今天想聊些什么?",
"EnableConversationStateCallback": false,
"ServerMessageURLForRTS": "https://82.157.201.114/callbacks/v1/volcengine/chat?session_id=5b5b9120-58c3-4433-96e1-67923598cafa",
"ServerMessageSignatureForRTS": "<平台返回值,原样使用>"
}
},
"lease_expires_at_ms": 1789372845000,
"expires_at_ms": 1789376400000
}
```
| 输出字段 | 中文说明与设备用途 |
|---|---|
| session_id | 对应本次聊天,结束时传同一个值 |
| bot_id | 本次使用的老人智能体,传给火山 SDK |
| params.PlatformTaskID | 本次火山任务标识,传给 task_id;回调归属仍以回调 URL 中的 session_id 为准 |
| params.AgentConfig | 本次欢迎语和回调设置,整体传给 SDK;底层 GetRTCConfig 的字段叫 agent_config |
| lease_expires_at_ms | 当前允许聊天的截止时间,UTC 毫秒;需要续期 |
| expires_at_ms | 整通聊天的最终截止时间,UTC 毫秒 |
拿到成功响应后,把 bot_id 和 params 交给本项目火山 SDK,结合第 5 节平台下发的 5 个语音字段获取房间并开始收发音频。凭据不是每次 chat/start 返回。**S2SConfig、豆包模型、MemoryConfig 由平台配置在老人的智能体上,设备不用再配置。**
聊天过程中每 10 秒用相同输入再请求一次 chat/start,更新有效期;不重新加入房间。续期失败不能自行延长,到期或收到解绑错误就停止聊天并上报结束。
没有绑定时,HTTP 409 输出示例:
```json
{
"ok": false,
"code": "DEVICE_UNBOUND",
"message": "绑定错误:设备没有绑定人员"
}
```
此时设备提示重新绑定,不使用以前保存的 bot_id 开聊。DEVICE_NOT_READY 表示平台尚未确认火山设备注册完成,VOICE_AGENT_NOT_READY 表示平台尚未准备好老人的智能体。
### 聊天记录怎么回平台
**火山直接回调平台,设备不上传聊天文字。** 设备只需把上例的回调 URL 和签名原样传给火山 SDK,不计算这个签名,也不把它当成 Function calling 地址。
平台负责接收、保存和关联老人;平台端需已开启该老人的聊天记录存储授权。回调 URL 中的 `session_id` 对应本通聊天,不替换为老人 ID,不复用上一通的 URL/签名。火山回调消息本身不保证带本平台的会话编号,不能仅靠 PlatformTaskID 或“老人最新一通聊天”关联记录。
## 7. 聊天结束:设备通知平台
地址:`POST https://82.157.201.114/api/v1/device/chat/end`
用途:通知平台本次聊天已经结束。设备先停麦克风、播放和火山 SDK,再发送。
输入:
```json
{
"device_id": "KP01-020000260914",
"session_id": "5b5b9120-58c3-4433-96e1-67923598cafa",
"reason": "hangup",
"sdk_stopped": true
}
```
| 输入字段 | 中文说明与用途 |
|---|---|
| device_id | 设备编号 |
| session_id | 开始聊天时使用的同一个编号 |
| reason | hangup=正常挂断;start_failed=启动失败;disconnected=连接中断;reboot=重启补报;timeout=超时 |
| sdk_stopped | 只有确认 SDK 已停止才填 true,没确认则填 false |
输出(业务字段):
```json
{"session_id":"5b5b9120-58c3-4433-96e1-67923598cafa","ended":true}
```
ended=true 表示平台已接收结束通知。断网时保存待上报任务,联网后重试;重启补报使用 reason=reboot、sdk_stopped=false。再次聊天生成新的 session_id。
## 8. MQTT 连接与设备心跳
### 设备取得连接参数
`POST https://82.157.201.114/api/v1/device/mqtt-config`
输入:
```json
{}
```
输出示例:
```json
{
"broker_url": "mqtt://82.157.201.114:1883",
"client_id": "KP01-020000260914",
"username": "KP01-020000260914",
"password": "<平台返回值,原样使用>",
"heartbeat_interval_seconds": 30,
"keepalive_seconds": 60,
"status_topic": "vl/devices/KP01-020000260914/status",
"ota_topic": "vl/devices/KP01-020000260914/ota",
"qos": 1
}
```
| 字段 | 中文说明与用途 |
|---|---|
| broker_url | MQTT 服务器,TCP 1883,明文连接 |
| client_id / username / password | 连接时原样使用,不是老人的账号密码 |
| heartbeat_interval_seconds | 心跳间隔,30秒 |
| keepalive_seconds | MQTT Keep Alive,60秒 |
| status_topic | 设备发布在线心跳的主题 |
| ota_topic | 设备订阅升级指令的主题 |
| qos | 消息等级1,可能重复投递,升级需要按 task_id 去重 |
使用 MQTT 3.1.1,Clean Session=true。每次重连后重新订阅 ota_topic,订阅成功后立即上报心跳。消息是完整 UTF-8 JSON,不加蓝牙长度头,Retain=false。
### 设备每30秒发布心跳
发送到返回的 status_topic:
```json
{
"online": true,
"firmware_version": "1.0.0",
"version_code": 0,
"ota_supported": true
}
```
| 输入字段 | 中文说明与用途 |
|---|---|
| online | true=在线;主动离线时发送false |
| firmware_version | 当前实际运行固件的显示版本名称 |
| version_code | 当前实际运行固件的数字顺序号;尚未关联平台固件的出厂版本填0,完成升级后使用指令里的数字 |
| ota_supported | 最终硬件已实现OTA时填true,否则false,小程序不会允许更新 |
设置遗嘱主题为自己的 status_topic,遗嘱内容:
```json
{"online": false}
```
平台以 MQTT 连接和最近心跳判断在线:连接断开即离线,超过90秒未收到心跳也视为离线。仅建立连接、未发心跳不算在线;时间以服务器接收时间为准。
设备不用自行计算 MQTT 密码。若需要排查,当前平台生成方法是 `Base64URL无填充(HMAC-SHA256(device_key原始32字节, UTF8("MQTT1\n" + factory_id)))`。与已有设备 HTTP 签名不是同一个计算内容。
## 9. 设备通过 MQTT 接收 OTA 升级指令
接收主题:mqtt-config 返回的 ota_topic。
```json
{
"task_id": "f58d3018-38a8-4fc4-b490-4d3154fa8464",
"model": "ECHOEAR-S3",
"version": "1.1.0",
"version_code": 12,
"download_url": "https://82.157.201.114/private-exports/<平台返回的临时下载路径>",
"sha256": "abababababababababababababababababababababababababababababababab",
"size": 3145728,
"expires_at_ms": 1789398030000
}
```
| 输入字段 | 中文说明与用途 |
|---|---|
| task_id | 本次升级任务,持久化保存,用于去重和重启后的结果上报 |
| model | 必须与设备型号一致 |
| version / version_code | 目标版本名称/平台分配的数字顺序号;同型号顺序号越大越新,必须大于当前值,不比较版本名称字符串 |
| download_url | HTTPS下载固件,不通过MQTT传文件 |
| sha256 / size | 下载文件原始字节的 SHA256(64 位小写十六进制)/总字节数;两者都要核对,C 函数见附录 C |
| expires_at_ms | 指令最晚开始接收时间,Unix毫秒,发出后30秒;过期指令不能开始升级 |
设备步骤:检查型号、顺序号、有效期和当前是否空闲 → 持久化task_id和目标版本 → HTTP上报accepted → 上报成功后下载并校验 → 安装并重启 → 确认运行新版本后上报success。下载/校验/安装失败上报failed;正在通话时拒绝并上报failed及原因。同一task_id重复到达只回复已有进度,不再执行安装。
## 10. 设备报告升级结果
`POST https://82.157.201.114/api/v1/device/ota/report`
设备已接收:
```json
{"task_id":"f58d3018-38a8-4fc4-b490-4d3154fa8464","status":"accepted"}
```
重启并确认运行目标固件后:
```json
{"task_id":"f58d3018-38a8-4fc4-b490-4d3154fa8464","status":"success","firmware_version":"1.1.0","version_code":12}
```
失败:
```json
{"task_id":"f58d3018-38a8-4fc4-b490-4d3154fa8464","status":"failed","message":"固件下载失败,仍运行原版本"}
```
成功输出 data:
```json
{"status":"ok"}
```
| 输入字段 | 中文说明与用途 |
|---|---|
| task_id | 必须是平台发给本设备的升级任务ID |
| status | accepted=接收并准备升级;success=实际运行新版本;failed=升级失败 |
| firmware_version / version_code | success时必填,必须同时与目标名称和数字顺序号一致 |
| message | 失败原因,最多500字节,可省略 |
上报允许重试;完成任务后继续每30秒心跳,填写实际运行的版本和顺序号。不能只因为下载完就上报success。网络不通时保留结果,重连后补报。
平台任务状态:pending=已发送、accepted=设备升级中、success=已完成、failed=失败、timeout=结果超时。30秒未确认或10分钟未完成转为timeout,超时不代表升级成功;设备可以补报success/failed。过期任务不能再接收为accepted。
防火墙:设备 MQTT 需要公网 TCP 1883,HTTP 接口与固件下载仍走 HTTPS 443。当前 MQTT 链路不使用 TLS,不需要给小程序直接连接 MQTT。
## 附录 A. 平台请求公共格式(统一封装一次)
蓝牙不加密;设备发起的平台 HTTPS POST 都带以下请求头,MQTT 使用第 8 节 CONNECT 配置。联网后用系统 RTC/SNTP 取得正确 UTC 时间,再发送带时间戳的请求;这不是向平台注册开机,不需要增加平台登记接口。
| 请求头 | 填什么 |
|---|---|
| Content-Type | application/json |
| X-Device-ID | 烧录的 factory_id |
| X-Key-Version | 1 |
| X-Timestamp-Ms | 已校时的当前 UTC 毫秒时间,十进制字符串 |
| X-Nonce | 每次请求生成 16 随机字节,base64url 无填充,22 字符 |
| X-Request-ID | UUID v4;同一次绑定重试保持不变,正文也保持不变 |
| Authorization | Device-HMAC-SHA256 加空格,再加下方计算出的签名 |
只有 Authorization 需要计算。使用烧录的 device_key,而不是火山密钥:
```text
body_sha = 小写hex(SHA256(实际发送的JSON字节))
原文 = 用真实换行符LF连接以下9项,最后不加换行:
DEVREQ1
POST
接口路径(例如 /api/v1/device/ble-bind,不含域名)
X-Device-ID
X-Key-Version
X-Timestamp-Ms
X-Nonce
X-Request-ID
body_sha
签名 = 小写hex(HMAC-SHA256(device_key的32字节, 原文的UTF-8字节))
```
每次重试更新时间、随机数和签名。通用计算代码见 [embedded-signing-reference.cjs](embedded-signing-reference.cjs),离线验算见 [test-vectors.json](test-vectors.json)。UUID 和随机数示例不能在真实请求里固定复用。
设备接口 `/api/v1/device/...` 成功响应的完整外层如下。正文为避免重复,大部分“输出”示例只展示 data 的内容:
```json
{
"ok": true,
"code": "OK",
"request_id": "<本次请求UUID>",
"server_time_ms": 1789372800000,
"data": {"status":"success"}
}
```
只在 HTTP 成功且 ok=true 时读取 data;失败看 code/message,不把收到响应当成业务成功。聊天有效期的示例时间只是示意,必须使用真实响应。
聊天续期剩余毫秒数 = lease_expires_at_ms − server_time_ms − 本次请求往返耗时;用单调时钟倒计时,不按“现在加45秒”自行续期。
设备 HTTP 业务失败返回 `ok=false`、`code`、`message`、`request_id` 等字段;第 6 节未绑定示例省略了 request_id。
MQTT 消息和蓝牙 JSON 不套 HTTP 外层。
## 附录 B. 设备本地启动(不请求平台登记)
设备每次开机只需在本地完成以下工作:
1. 读取已保存的 Wi-Fi 和 5 个火山字段;没有 Wi-Fi 就等待蓝牙配网。
2. 连网,用 RTC/SNTP 校时;使用已保存的火山凭据初始化 SDK。
3. 获取 MQTT 配置、连接并每 30 秒发心跳。
4. 用户开始聊天时,生成新的 session_id,请求 chat/start,使用本次 bot_id 和 params 开聊。
5. 停止聊天后上报 chat/end;断网/重启时保存待补报的 session_id,联网后先补报,再开始新聊天。
**不再调用平台开机登记、语音配置命令领取、SDK 初始化确认接口,也不需要发送 boot_id。** 本地 SDK 初始化失败就不要开聊;已经申请过会话的,按 start_failed 上报结束。
首次绑定通过 ble-bind 直接拿齐火山凭据。只有本机凭据丢失时,才使用第 5.3 节 voice-config 恢复;无需每次开机重复获取。硬件在火山侧只由平台注册一次,重新绑定老人也不重复注册。
设备 HTTPS 请求仍需有效的 CA 信任配置与正确的 UTC 时间。旧启动接口仅供既有固件兼容,新接入不需要实现。
## 附录 C. 需要计算的字段:C 函数
这部分只是协议计算辅助函数,**不是完整固件**,不绑定 EchoEar 或某个芯片。使用 C99 和 Mbed TLS 的 MD/SHA256/Base64 模块;目标工程开启 `MBEDTLS_MD_C`、`MBEDTLS_SHA256_C`、`MBEDTLS_BASE64_C` 并链接对应库。不同芯片的随机数、Flash、网络、蓝牙收发和 JSON 解析由嵌入式工程接入。
可直接取用:[vl_protocol.h](device-protocol-c/vl_protocol.h)、[vl_protocol.c](device-protocol-c/vl_protocol.c)。所有函数 **0 表示成功,非 0 表示失败**;失败时不能继续发送或安装。字符串输出容量包含结尾 `\0`,发送长度不包含 `\0`,输入/输出缓冲区不要重叠。
| 要处理的字段/数据 | C 函数 | 输入与输出用途 |
|---|---|---|
| device_key_hex | vl_key_from_hex | 64 字符十六进制 → 32 字节设备密钥;不能直接把 64 字符文本当 HMAC 密钥 |
| X-Nonce | vl_nonce | 通过回调取 16 个硬件/系统随机字节 → 22 字符 Base64URL;out 至少 23 字节 |
| session_id、X-Request-ID | vl_uuid4 | 通过随机源生成 UUID v4;out 至少 37 字节;各 ID 的生成/重用时机按正文 |
| Authorization 后面的签名 | vl_http_sign | 设备密钥、路径、请求头值和实际 JSON 字节 → 64 字符小写签名;out 至少 65 字节 |
| BLE 长度头 | vl_ble_pack | UTF-8 JSON 字节 → 2 字节小端长度头 + JSON;最长输出 2050 字节 |
| 普通内容 SHA256 | vl_sha256_hex | 原始字节 → 64 字符小写 SHA256 |
| OTA 下载文件 | vl_ota_verify | 分块读取已下载文件,同时核对实际字节数和平台 sha256;每次只读 1024 字节 |
**以下字段不用设备计算:** bind_token、bot_id、火山 5 个语音字段、MQTT password、ServerMessageURLForRTS、ServerMessageSignatureForRTS、OTA task_id/download_url/version_code。全部取平台或小程序返回值,不用这些函数自行生成替代值。
### C.1 HTTP 签名调用示例
以下是调用片段,`device_key_hex` 来自烧录配置;nonce、request_id、timestamp_ms 按附录 A 取得。签名和发送必须使用同一份 body,不能签名后重新格式化 JSON:
```c
uint8_t device_key[32];
char signature[65], authorization[96];
const char *body = "{}";
if (vl_key_from_hex(device_key_hex, device_key) != 0) return -1;
if (vl_http_sign(device_key, "POST", "/api/v1/device/ble-state",
"KP01-020000260914", "1", timestamp_ms, nonce,
request_id, body, strlen(body), signature) != 0) return -1;
int n = snprintf(authorization, sizeof(authorization),
"Device-HMAC-SHA256 %s", signature);
if (n < 0 || (size_t)n >= sizeof(authorization)) return -1;
/* Authorization 请求头填 authorization;HTTP body 发送上面 body 的原始字节。 */
```
使用片段时包含 `vl_protocol.h`、`stdio.h`、`string.h`。每次重试重新生成 nonce 和时间;同一逻辑操作的 request_id 与 body 按正文保持不变。
### C.2 蓝牙打包与 OTA 校验
```c
const char *json_text = "{\"cmd\":\"get_info\"}";
uint8_t frame[2050];
size_t frame_length = 0;
if (vl_ble_pack(json_text, strlen(json_text), frame,
sizeof(frame), &frame_length) != 0) return -1;
/* frame_length=20;前两个字节为 0x12,0x00。
* 把完整 frame 每次最多切 20 字节发送,只有整条消息开头加长度头。
* 此 get_info 是小程序->设备示例;设备回复 JSON 使用同一个打包函数。 */
```
接收方先累积至少 2 字节,解析 `length = buffer[0] | (buffer[1] << 8)`,长度必须为 1–2048;继续累积满 length 个 JSON 字节再解析。收到下一包不是新消息开始,不要给每片都读一个长度头。
OTA 使用 `vl_ota_verify(read_firmware, reader_context, command_size, command_sha256)`。`read_firmware` 由嵌入式接入已下载文件/Flash 分块读取:返回 0 且 `*n>0` 表示读到数据,返回 0 且 `*n=0` 表示结束,非 0 表示读取失败;每次不超过 capacity。校验返回 0 后才安装,**仍需确认新版本实际运行再上报 success**。
随机源回调须一次填满指定字节数并返回 0,失败返回非 0;用芯片硬件随机源或系统随机 API,不能用 rand()、时间戳或固定数组。离线测试用的固定随机数不能搬进固件。
下面交接导出版本附有上述 `.h`/`.c` 的完整内容,便于只转发一个 Markdown 文件;旁边的独立文件内容完全一致。
<!-- VL_PROTOCOL_C_SOURCE -->
+737
View File
@@ -0,0 +1,737 @@
# VoiceLink 设备接入交接文档
> 历史技术明细:本文描述旧的“设备自行注册火山”流程。当前代码已改为平台代注册,新的接口与迁移说明见同目录《平台注册火山与设备取参.md》和《设备接口文档-MQTT与OTA版.md》。本文件保留作旧协议参考,不用于指导新固件注册。
版本:1.1 / 2026-09-14。交付对象:嵌入式开发人员。本版更新蓝牙名称及小程序筛选规则,未改设备身份或密钥。
依据:线上后端 `0.90.0-rc.wechat1`、EchoEar 固件 `1.5.0-elder-agent.2`、VoiceLink 小程序 `wx4553119e05f11660` 的实际实现。
本文是当前方案:**明文 BLE 配网绑定 + 设备向平台申请开聊参数 + 设备直连火山 RTC + 火山回调文字 + 设备上报结束**。每位老人一个智能体,平台负责创建、保存、配置;设备不创建老人或智能体。
不要混用旧文档中的 `/device/v2/*`、`/device/v3/agent-sessions`、`/api/v1/device/calls/*`、旧网页 setup/handoff 接口。当前蓝牙无 `salt/verifier/session_password/client_nonce/server_nonce/proof` 握手。平台 HTTPS 的工厂密钥签名仍在实际代码中,本文如实列出,不是新增功能。
## 1. 已创建的交接设备与初始化
| 项目 | 本次设备值 | 中文解释与用途 |
|---|---|---|
| 工厂设备编号 `factory_id` / 接口 `device_id` | `KP01-020000260914` | BLE 自报和所有设备请求中的身份;不要使用平台 UUID 替代 |
| 生产编号后缀 `factory_mac` | `020000260914` | 本次人为保留的联调编号,不是读取到的实物 MAC;不要修改网卡真实 MAC 来迁就它 |
| 平台设备 UUID | `f9839cde-3078-4edc-a40a-c2dbe284fad6` | 平台列表、详情、解绑使用;设备协议不使用它作为 X-Device-ID |
| 型号 `model` | `ECHOEAR-S3` | BLE get_info 输出;注册 hardware_model 必须一致 |
| 硬件版本 | `V1.2` | register 的 hardware_revision 必须与此一致 |
| 后端地址 `origin` | `https://121.41.46.238` | 无末尾斜线;设备 HTTP 请求拼接下表路径 |
| 蓝牙广播名 | `voicelink-ECHOEAR-S3-00260914` | voicelink- + 型号 + - + 工厂编号末 8 位;小程序仅按服务 UUID 搜索,不按名称拒绝设备 |
| 平台密钥版本 `key_version` | `1` | 请求头、校时请求携带 |
| 平台设备密钥 | 同目录 `device-init.private.json` | 本设备专属随机 32 字节;不在通用文档明文展示 |
| BLE 协议版本 | `2` | get_info 的 protocol_version,必须是数字 2 |
| 平台接入契约标识 | `ONBOARDING-1.1` | 当前响应中的实际值;不是 BLE 版本,也不是聊天接口路径 |
| 当前状态 | `CLAIMABLE`、`released=true`、未绑定 | 平台已登记放行,尚未绑定任何老人、未做火山动态注册 |
| 登记批次 | `EMBEDDED-HANDOFF-20260914-TEST` | 后台查找这台联调设备 |
本次只给一台联调板使用这份身份,**不能把同一份密钥和编号烧进多台设备**。正式生产应读取各板真实 MAC,按对应编号逐台登记并分配独立密钥;本次测试身份可直接写入应用身份区联调,固件并不要求它等于网卡当前地址。若硬件并非 EchoEar,需要移植屏幕确认、音频驱动、存储、RTC SDK,不应照抄 EchoEar 分区偏移去烧录。
### 1.1 烧录/配置字段表
| 字段 | 类型/存储 | 中文解释与用途 | 如何取得 |
|---|---|---|---|
| `factory_id` | UTF-8 字符串 | 平台设备身份,17 字符,不含 NUL 终止符长度 | 上表及私有 JSON |
| `model` | 字符串,1–32 字节 | 型号 | `ECHOEAR-S3` |
| `origin` | 字符串 | 平台 HTTPS 根地址 | 上表 |
| `device_key` | **32 字节二进制** | 平台 HMAC 密钥 | 将私有 JSON 的 `device_key_hex` 每两位解码为 1 字节;或解码 `device_key_b64`,二者是同一密钥,不是两把 |
| `key_version` | uint32 | 平台密钥版本 | `1` |
| `hardware_revision` | 编译常量或持久化字符串 | 每次注册上报实际登记版本 | `V1.2` |
| TLS 信任根 | 系统/SDK CA 证书集合 | 校验平台及火山 HTTPS 证书;设备先校时再 TLS | 支持根证书更新,勿关闭证书验证;可使用 ESP-IDF crt bundle |
现有 ESP-IDF 代码从 `ble_factory` NVS 分区、`identity` 命名空间读取前 5 项:`factory_id/model/origin` 是 NVS string,`device_key` 是 blob,`key_version` 是 u32。其它 MCU 使用等价持久化区即可。BLE 服务 UUID 是编译常量,无需逐台烧录。旧 salt/verifier 不需要生成、读取或发送。
**初始化时不要烧录** Wi-Fi、手机号、账号密码、老人 ID、bind_token、bot_id、S2SConfig、MemoryConfig、微信 AppSecret、火山管理 AK/SK。Wi-Fi 和临时绑定令牌来自小程序;火山产品配置来自平台;注册得到的火山设备密钥和 RTCAppID 运行时保存。平台注册密钥与火山设备密钥不是同一个东西。
新板用户配置区初始为空:没有 Wi-Fi、没有待绑定任务、没有火山 device_secret/RTCAppID、没有未结束会话;首次启动生成新的 boot_id。不需要预生成 session_id、attempt_id、client_nonce;它们按流程产生。不要把参考板的运行期 NVS 整块克隆过来。
## 2. 完整实现顺序
| 顺序 | 谁做 | 动作/结果 |
|---|---|---|
| 1 | 设备 | 读取初始化身份,进入可连接 BLE 配网广播 |
| 2 | 小程序 | 用户登录,搜索、连接、订阅 TX,get_info 识别工厂编号和型号 |
| 3 | 小程序→平台 | 查是否已绑定;未绑定选择老人并拿 bind_token,已绑定使用空字符串 |
| 4 | 小程序→设备 | configure 发送 Wi-Fi 和 bind_token;设备先回复 received |
| 5 | 设备 | 等待 60 秒内轻点屏幕确认;确认后持久化任务,连接 Wi-Fi |
| 6 | 设备→平台 | 校时;有令牌则 ble-bind,空令牌则 ble-state 检查仍已绑定 |
| 7 | 设备 | 云端成功才保存 Wi-Fi,BLE get_status 返回 success;可约 15 秒后重启 |
| 8 | 设备→平台 | 每次新启动:新 boot_id → time-challenges → register → voice-runtime |
| 9 | 设备→平台/火山 | 首次没有火山设备凭据:activation-attempts → SDK 动态注册 → 保存凭据 → activation-results |
| 10 | 设备→平台 | 初始化 RTC SDK,保存配置后 activation-acks;已有凭据时跳过重复注册,但每次启动仍获取配置并确认 |
| 11 | 设备→平台 | 每次聊天新 session_id,chat/start 检查绑定并获得老人的 bot_id、params |
| 12 | 设备↔火山 | 用 SDK 获取房间、加入 RTC、采集和播放音频;聊天中每 10 秒重复 chat/start 续期 |
| 13 | 火山→平台 | 字幕回调自动保存聊天文字;设备不用上传聊天文字 |
| 14 | 设备→平台 | 先停音频和 SDK,再 chat/end;失败保留结束任务补报 |
平台创建老人时创建专属智能体、配置豆包 S2S 并关联火山硬件产品。嵌入式只选择平台本次返回的 bot_id,不能把某一老人 bot_id 固化成所有设备共用值。
## 3. 接口地址总表
以下 H = `https://121.41.46.238`。设备接口均由设备主动发起,平台通过响应返回数据,不需要在设备上开 HTTP 服务。
| 编号 | 调用方向 | 方法和完整地址/通道 | 接口描述 |
|---|---|---|---|
| B1 | 小程序→设备 | BLE RX / `get_info` | 获取设备身份、型号、协议版本 |
| B2 | 小程序→设备 | BLE RX / `configure` | 发送 Wi-Fi 和绑定令牌 |
| B3 | 小程序→设备 | BLE RX / `get_status` | 查询配网绑定结果 |
| M1 | 小程序→平台 | POST `https://121.41.46.238/api/v1/mini/ble-device-state` | 权限校验、查询绑定状态 |
| M2 | 小程序→平台 | POST `https://121.41.46.238/api/v1/mini/ble-bind-tokens` | 给未绑定设备申请短期绑定令牌 |
| M3 | 小程序→平台 | POST `https://121.41.46.238/api/v1/mini/ble-bind-status` | 查询令牌对应的绑定结果 |
| M4 | 小程序→平台 | GET `https://121.41.46.238/api/v1/devices` | 当前账号设备列表 |
| M5 | 小程序→平台 | GET `https://121.41.46.238/api/v1/devices/{id}/binding-impact?action=unbind` | 查询解绑影响,id 为平台 UUID |
| M6 | 小程序→平台 | POST `https://121.41.46.238/api/v1/devices/{id}/binding-change` | 用户确认解绑 |
| D1 | 设备→平台 | POST `http://121.41.46.238/api/v1/device/time-challenges` | 启动校时,唯一 HTTP 特例 |
| D2 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/ble-bind` | 兑现绑定令牌 |
| D3 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/ble-state` | 只读查询是否绑定 |
| D4 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/register` | 当前启动注册,不是创建库存设备 |
| D5 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/voice-runtime` | 获取当前绑定的语音注册配置 |
| D6 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/activation-attempts` | 获取一次 SDK 注册执行许可 |
| D7 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/activation-results` | 上报 SDK 注册真实结果 |
| D8 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/activation-acks` | 确认配置已保存、SDK 已初始化 |
| D9 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/chat/start` | 开聊检查/聊天续期 |
| D10 | 设备→平台 | POST `https://121.41.46.238/api/v1/device/chat/end` | 上报一次聊天结束 |
| V1 | 设备 SDK→火山 | POST `https://iot-cn-shanghai.iot.volces.com/2021-12-14/DynamicRegister?Action=DynamicRegister&Version=2021-12-14` | 获取火山设备密钥和 RTCAppID |
| V2 | 设备 SDK→火山 | POST `https://iot-cn-shanghai.iot.volces.com/2021-12-14/GetRTCConfig?Action=GetRTCConfig&Version=2021-12-14` | 获取本次 RTC 房间参数 |
| C1 | 火山→平台 | POST `https://121.41.46.238/callbacks/v1/volcengine/chat?session_id={session_id}` | 聊天文字回调;设备不调用 |
V1/V2 为当前工程内火山 SDK 的调用地址,优先复用 SDK,不把 RTC 当成普通 WebSocket。平台当前 IP 地址可用于设备 HTTPS;小程序正式发布的 request 合法域名配置是另一项工作。
## 4. 小程序与设备:BLE GATT
### 4.1 发现、连接和传输
| 项目 | 值 | 用途 |
|---|---|---|
| 服务 UUID | `8e7a2000-6f4b-4a2d-9c31-7d824f5e6100` | 放入广播/扫描响应的 128-bit Service UUID,小程序按它搜索 |
| RX UUID | `8e7a2001-6f4b-4a2d-9c31-7d824f5e6100` | 属性 Write,手机→设备,带应答写入 |
| TX UUID | `8e7a2002-6f4b-4a2d-9c31-7d824f5e6100` | 属性 Indicate,设备→手机,提供 CCCD 0x2902,等待 indication 确认后发下一片 |
| 广播名称 | `voicelink-ECHOEAR-S3-00260914` | 格式 voicelink-型号-xxxxxxxx,末尾为工厂编号后 8 位大写十六进制 |
| 协议 | 明文 UTF-8 JSON | 不要求 BLE 配对,不做业务加密握手 |
| 消息边界 | 2 字节小端长度 + JSON 字节 | 长度不含自身 2 字节,也不含 C 字符串结尾 NUL |
| 分片 | 每片最多 20 字节 | 同一逻辑消息仅头部出现一次长度,后续直接续传 |
| 消息长度 | 1–2048 字节 JSON | 帧最多 2050 字节;不支持同时并发多条命令 |
| 超时 | 整条消息 45 秒,无分片进展 10 秒 | 不完整帧超时断开并清缓冲,不能把部分 JSON 当作完整请求 |
小程序顺序:openBluetoothAdapter → startBluetoothDevicesDiscovery(services=[服务 UUID])→ createBLEConnection → getBLEDeviceServices → getBLEDeviceCharacteristics → notifyBLECharacteristicValueChange(type=indication)→ 写 RX。订阅后约等 150 ms 再写入。必须在 TX 已订阅后发送应答。扫描约 20 秒停止,可重搜。
服务 UUID 是发现条件,名称仅用于显示,不是身份认证;旧 EchoEar 名称、自定义名称如 abcd-232223 也不会因名称被过滤,无名称时显示“未命名设备”。若扫描回调明确给出服务 UUID 列表,则也检查其中包含配网 UUID;未提供列表时依赖系统扫描过滤,连接后再次发现服务/特征并执行 B1。手机蓝牙 API 的 `deviceId` 是操作系统临时标识,只用于连接;业务 `device_id` 从 B1 读取。UUID 字符串比较可忽略大小写。广播 UUID 的字节序以蓝牙栈 API 为准,不能把文本 UUID 直接当原始 16 字节发送。
当前使用传统 31 字节扫描响应,名称 AD 的长度和类型占 2 字节,可用名称最多 29 字节。命名分配:`voicelink-` 10 字节 + 型号最多 10 个 UTF-8 字节 + `-` 1 字节 + 后缀 8 字节。超长型号只在广播名中按完整 UTF-8 字符边界缩短,B1 的 model 不缩短。GAP Device Name 和扫描响应使用同一个名称。原 EchoEar 实物编号 KP01-B43A451634A8 对应 `voicelink-ECHOEAR-S3-451634A8`;本次联调编号对应表中 `00260914`,这不改变编号本身。
帧示例(可直接比对十六进制):
```text
JSON: {"cmd":"get_info"}
UTF-8 长度: 18 = 0x0012
完整帧 hex: 12007b22636d64223a226765745f696e666f227d
```
不使用换行分帧,不对每个 BLE 分片单独加头,不在 JSON 中加入包序号。按长度累加,完整后只处理一个对象。设备仅响应命令,**不主动推送状态**,否则当前小程序会认为收到非预期数据并断开。get_status 使用轮询。
### 4.2 B1 获取设备信息
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | cmd | string,必填 | 命令 | `get_info` |
| 输出 | device_id | string | 工厂编号,后续向平台查询归属 | `KP01-020000260914` |
| 输出 | model | string | 设备型号,用于显示与注册一致性 | `ECHOEAR-S3` |
| 输出 | protocol_version | number | 明文 BLE 版本,小程序仅接受 2 | `2` |
```json
{"cmd":"get_info"}
```
```json
{"device_id":"KP01-020000260914","model":"ECHOEAR-S3","protocol_version":2}
```
### 4.3 B2 配网和绑定
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | cmd | string,必填 | 配网命令 | `configure` |
| 输入 | wifi_ssid | string,必填,1–32 UTF-8 字节 | 2.4 GHz Wi-Fi 名称 | `HomeWiFi` |
| 输入 | wifi_password | string,必填,8–63 可打印 ASCII 字符 | Wi-Fi 密码;当前不支持开放网络 | `Example123` |
| 输入 | bind_token | string,必填 | 未绑定时 43 字符 base64url 令牌;已绑定仅配网时必须是空字符串 | `服务器返回值` 或 `""` |
| 输出 | status | string | received 只表示已接收,尚未联网/绑定成功 | `received` |
| 输出 | message | string,仅失败时 | 给用户的失败说明 | `Wi-Fi 参数不正确` |
```json
{"cmd":"configure","wifi_ssid":"HomeWiFi","wifi_password":"Example123","bind_token":"<M2返回的43字符原值>"}
```
```json
{"status":"received"}
```
已绑定只换 Wi-Fi 的完整示例:
```json
{"cmd":"configure","wifi_ssid":"NewHomeWiFi","wifi_password":"Example456","bind_token":""}
```
空令牌不等于解绑,也不等于缺字段或 null。设备不需要获得微信 OpenID、手机号、家庭账号密码或账号 access_token。Wi-Fi 密码只在手机与设备之间传输,不上传平台。
设备收到后提示用户轻点屏幕,60 秒内确认才开始联网。嵌入式可以实现等价的明确确认按键,但不能直接把 received 当成确认成功。确认后把本次 Wi-Fi、令牌和新 UUID v4 确认事件 ID 原子保存,D2 的 X-Request-ID 使用此事件 ID;重试保持它和正文不变。空令牌调用 D3,返回 bound=false 时不保存新 Wi-Fi,提示重新绑定。
### 4.4 B3 查询配网状态
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | cmd | string,必填 | 查询命令 | `get_status` |
| 输出 | status | string | 当前状态,见下表 | `success` |
| 输出 | message | string,可选 | 失败/恢复提示 | `设备已解绑,请重新搜索并绑定账号` |
```json
{"cmd":"get_status"}
```
```json
{"status":"failed","message":"设备已解绑,请重新搜索并绑定账号"}
```
| status | 中文解释与设备动作 |
|---|---|
| idle | 等待 configure |
| received | 已收参数,等待设备屏幕确认 |
| wifi_connecting | 正在连接新 Wi-Fi |
| binding | 正在 D2 绑定或 D3 查询已有绑定 |
| success | 云端成功且 Wi-Fi 已持久化;可以结束配网 |
| failed | 明确失败,message 解释原因 |
小程序约每 2.5 秒轮询结果。同一任务进行中收到完全相同的 configure 返回已有状态;不同参数不替换进行中的任务。云端超时不代表绑定未执行,保留任务重试。明确 4xx(408/429 除外)停止并提示。BLE 断开不能作为成功依据。当前固件支持首次自动配网、双击触摸屏重新进入、5 分钟广播窗口;不要长按电源键作为入口,它会关机。
## 5. 小程序辅助接口(嵌入式不调用)
公共请求头:`Content-Type: application/json`,`X-Request-ID: UUID v4`,`Authorization: Mini <登录返回的access_token>`,`X-Login-Portal: customer`。access_token 已包含 customer. 前缀,不重复添加。用户登录及 getPhoneNumber 均由小程序负责。
以下示例省略公共成功信封,仅展示 data。设备状态查询和绑定令牌都应以平台为准,不能靠设备本地保存的“已绑定”标志替代。
| 接口 | 方向/字段 | 中文解释与用途 | 示例 |
|---|---|---|---|
| M1 | 输入 device_id | 工厂编号,查询当前账号是否有权操作 | `KP01-020000260914` |
| M1 | 输出 device_id / id / model / bound | 工厂编号 / 平台公开 UUID / 型号 / 是否已绑定 | `{"device_id":"KP01-020000260914","id":"f9839cde-3078-4edc-a40a-c2dbe284fad6","model":"ECHOEAR-S3","bound":false}` |
| M2 | 输入 device_id / elder_id | 工厂编号 / 选定老人公开 UUID,均由小程序明确传入 | `{"device_id":"KP01-020000260914","elder_id":"<老人UUID>"}` |
| M2 | 输出 bind_token | 32 随机字节的 base64url 无填充编码,43 字符,5 分钟有效;原样 BLE 转发,不自行计算 | `{"bind_token":"<43字符值>"}` |
| M3 | 输入 bind_token | 本次申请的非空令牌,查绑定结果 | `{"bind_token":"<43字符值>"}` |
| M3 | 输出 status / message | pending 等待、success 成功、failed 失败及说明 | `{"status":"pending"}` |
| M4 | 输入 | 无正文 | GET 请求 |
| M4 | 输出 items[] | 设备集合,含 id、binding_id、model、subject_id、subject_name、household_id、status、firmware_version | `{"items":[]}` |
| M5 | 输入 URL id / action | 平台设备 UUID / 固定 unbind | `.../devices/f9839cde-3078-4edc-a40a-c2dbe284fad6/binding-impact?action=unbind` |
| M5 | 输出 binding_id / allowed / blockers | 当前绑定版本、是否可解绑、不能解绑的原因;另有任务数量及源家庭/老人信息 | `{"binding_id":"<绑定UUID>","allowed":true,"blockers":[]}` |
| M6 | 输入 action / expected_binding_id / acknowledged / reason | 固定 unbind / M5 取得的绑定 UUID / 用户已确认 true / 解绑原因 | `{"action":"unbind","expected_binding_id":"<绑定UUID>","acknowledged":true,"reason":"小程序设备管理解除绑定"}` |
| M6 | 输出 device_id / action / previous_binding_id / changed_at | 变更设备 UUID、操作类型、旧绑定、变更时间 | `{"device_id":"<平台UUID>","action":"unbind","previous_binding_id":"<旧绑定UUID>","changed_at":"<UTC时间>"}` |
小程序在点击发送前再次调用 M1。未绑定才调用 M2,已绑定直接发空令牌。M1 返回 403 时停止,不向设备发送 Wi-Fi。M2 的 409 ALREADY_BOUND 要刷新状态,不得覆盖绑定。解绑由 M5/M6 完成,历史记录不删除,设备 Wi-Fi 不被远程擦除;设备下次 D9 会收到绑定错误。当前平台有进行中的通话等阻塞项时会拒绝解绑,不能循环强行提交。
## 6. 设备 HTTPS 公共格式与计算
### 6.1 请求头(D2–D10)
| 输入字段 | 类型 | 中文解释与用途 | 示例/计算 |
|---|---|---|---|
| Content-Type | header string | JSON 正文 | `application/json` |
| X-Device-ID | header string | 工厂编号,不是蓝牙地址或平台 UUID | `KP01-020000260914` |
| X-Key-Version | header 十进制字符串 | 平台密钥版本,无前导零 | `1` |
| X-Timestamp-Ms | header 十进制字符串 | 校时后的 Unix UTC 毫秒时间,需在服务器 ±120 秒内 | `1789372800000` |
| X-Nonce | header string | 每次 HTTP 尝试新生成 16 随机字节,base64url 无填充 → 22 字符 | 不复用;不是 boot_id |
| X-Request-ID | header string | 小写 UUID v4;业务重试策略见 6.4 | `12345678-1234-4234-8234-123456789abc` |
| Authorization | header string | 按下一节 HMAC-SHA256 计算 | `Device-HMAC-SHA256 <64位小写hex>` |
这些接口没有 URL 查询参数,不能自行添加 `?device_id=...`。不用旧的 X-Device-Timestamp、X-Device-Signature 等签名头。正文必须是 UTF-8 JSON 对象,不允许重复字段、未知字段、错误类型;普通设备接口请求正文上限 8192 字节,chat/start/end 上限 2048 字节。必须处理非 2xx 和业务 code。
### 6.2 签名算法:直接照此实现
```text
K = hex_decode(device-init.private.json 中 device_key_hex) // 32 字节,不是64字符文本
raw = UTF8(实际要发送的 JSON 字符串)
body_sha = lowercase_hex(SHA256(raw))
canonical = "DEVREQ1\n"
+ "POST\n"
+ path + "\n" // 如 /api/v1/device/ble-state;无域名、无查询串
+ factory_id + "\n"
+ decimal(key_version) + "\n"
+ decimal(timestamp_ms) + "\n"
+ nonce_base64url + "\n"
+ request_id + "\n"
+ body_sha // 最后没有换行
signature = lowercase_hex(HMAC_SHA256(K, UTF8(canonical)))
Authorization = "Device-HMAC-SHA256 " + signature
```
分隔符是真实 LF 字节 `0x0A`,不是反斜杠和 n 两个字符,不是 Windows CRLF。先序列化一次,签名和发送使用同一份 raw;空格、换行、字段顺序都会改变摘要。D3 的正文是 `{}`,不是空字符串。
base64url 无填充:普通 Base64 的 `+` 换成 `-`、`/` 换成 `_`,去掉末尾 `=`。解码时反向替换并补足到 4 的倍数。UUID v4:16 随机字节,byte[6]=(byte[6]&0x0f)|0x40,byte[8]=(byte[8]&0x3f)|0x80,再输出小写 `8-4-4-4-12` 十六进制字符串。
离线验算(以下是公开假密钥,不是新设备真实密钥):
```text
K hex = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
path = /api/v1/device/ble-state
factory_id = KP01-020000260914
key_version = 1
timestamp_ms = 1789372800000
nonce = AAECAwQFBgcICQoLDA0ODw
request_id = 12345678-1234-4234-8234-123456789abc
raw = {}
body_sha = 44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
signature = 6589d587fad96f306ed1ef839d66a9a4f8408e133a1cb65796168106e89ca66d
```
附带 `embedded-signing-reference.cjs` 可用 Node.js 执行并输出完整 canonical、请求头及 BLE 帧。固定测试时间、nonce 和示例假密钥仅用于离线算法测试,不能用于线上请求。
### 6.3 公共输出信封
```json
{"ok":true,"code":"OK","request_id":"<UUID>","server_time_ms":1789372800000,"data":{}}
```
| 输出字段 | 中文解释与用途 |
|---|---|
| ok | 业务是否成功;不能仅看 TCP/HTTP 发送成功 |
| code | 业务码,成功 OK;错误按码分支 |
| request_id | 本次请求追踪 ID |
| server_time_ms | 服务器 UTC 毫秒时间;用于聊天有效期计算 |
| data | 下面各接口的业务输出 |
| message | 错误说明,可能存在于失败响应 |
| recovery_action | 部分错误附带恢复提示;优先按 HTTP/code 实现 |
错误示例:HTTP 409,`{"ok":false,"code":"DEVICE_UNBOUND","message":"绑定错误:设备没有绑定人员","request_id":"<UUID>"}`。D1 校时成功响应例外,不带这层信封。M4–M6 的旧业务接口采用 `{request_id,data}` 成功包装,小程序自行处理,嵌入式不用兼容它。
### 6.4 重试和持久化
| 对象 | 生成/重试规则 |
|---|---|
| boot_id | 每次 MCU 真正启动生成一次,当前启动各请求保持;不是每次 HTTP 新建 |
| session_id | 每次新聊天生成一次;start 重试、10 秒续期、end 始终相同;结束后不能复用 |
| X-Request-ID | D2 确认事件、D4 注册、D6 申请、D7 结果、D8 确认的一次业务操作重试保持,正文逐字节不变;新操作新建;同 ID 不同正文返回冲突 |
| D9/D10 | 核心业务幂等按 device + session_id;每次 start/续期可新 request_id;end 建议持久化一个固定 request_id 及正文直到成功 |
| timestamp / X-Nonce | **任何 HTTP 重试都重新生成**,即使 request_id 保持;重新计算签名 |
| bind_token | 仅本次绑定使用,成功清理;过期不自行生成,回小程序重新申请 |
| 火山 SDK 注册结果 | 成功先保存 device_secret、RTCAppID、attempt_id 和待上报结果,再确认平台;断电后补报,不重复消耗注册流程 |
| 待结束聊天 | 保存 session_id、reason、sdk_stopped、end request_id;成功才删除。重启残留会话 reason=reboot,不能伪造已观察到 SDK 正常停止 |
网络/5xx/408/429 使用有上限的延迟重试;有有效租期时只用剩余时间,不能自行延长音频权限。401 先校时核验设备身份;403/409 按业务码恢复,不无限重试。任务 HTTP 不要放在蓝牙回调或 UI 回调里阻塞执行,使用独立任务,避免看门狗重启。
## 7. 设备与平台接口明细
下文输入均为 JSON body,输出为 data 内字段(D1 例外)。每个接口完整地址见第 3 节。
### D1 校时:`POST /api/v1/device/time-challenges`
唯一可用 HTTP 的设备接口,不需要 6.1 签名头,也不传 Wi-Fi/绑定令牌;响应自带工厂密钥 HMAC。设备时钟尚未设置时先做它。
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | device_id | string,必填 | 工厂编号 | `KP01-020000260914` |
| 输入 | key_version | integer,必填 | 工厂密钥版本 | `1` |
| 输入 | boot_id | UUID v4,必填 | 当前启动编号 | `<本次启动UUID>` |
| 输入 | client_nonce | string,必填 | 新 16 字节随机数 base64url,无填充 22 字符,关联请求与响应 | `<22字符值>` |
| 输出 | payload_b64 | string | base64url 编码的 JSON,不是密文 | `<编码值>` |
| 输出 | mac_hex | string | 64 小写 hex,校验返回内容 | `HMAC(K,"TIME1\n"+payload_b64)` |
| 解码输出 | device_id / boot_id / client_nonce | string | 必须与本次请求逐项相同,拒绝错设备/旧响应 | 原值 |
| 解码输出 | challenge_id | UUID v4 | 启动注册所用挑战 ID | `<服务器UUID>` |
| 解码输出 | server_time_ms | int64 | 用于设置设备 UTC 时钟 | `<当前毫秒>` |
| 解码输出 | expires_at_ms | int64 | 挑战过期时间,当前为签发后 120 秒 | `<当前毫秒+120000>` |
```json
{"device_id":"KP01-020000260914","key_version":1,"boot_id":"12345678-1234-4234-8234-123456789abc","client_nonce":"AAECAwQFBgcICQoLDA0ODw"}
```
```json
{"payload_b64":"<base64url JSON>","mac_hex":"<64位小写hex>"}
```
计算 `expected=hex_lower(HMAC_SHA256(K,UTF8("TIME1\n"+payload_b64)))`,与 mac_hex 比较成功,再解码 JSON,核对回传字段。使用单调计时判断整个请求往返小于 15 秒,不能用未校准的墙上时钟判断。正确后设置 UTC,保存 challenge_id 并在 120 秒内 register。这里 client_nonce 是校时用,不是已取消的 BLE 握手。
### D2 兑现绑定:`POST /api/v1/device/ble-bind`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | bind_token | string,必填,43 字符 | 小程序 BLE 传来的非空令牌,原样传递 | `{"bind_token":"<43字符原值>"}` |
| 输出 | status | string | 绑定是否完成;成功固定 success | `{"status":"success"}` |
设备完成物理确认、联网、校时后调用。X-Request-ID 使用已保存的本次确认事件 UUID。无需发送 Wi-Fi、老人 ID 或账号资料,令牌已包含服务器端关联。平台验证令牌所属设备/家庭/老人、有效期及绑定代次;重试不会重复绑定。不接受空令牌。
### D3 查询绑定:`POST /api/v1/device/ble-state`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | 无业务字段 | 空对象,必填 | 查询签名头对应的设备,不传其他字段 | `{}` |
| 输出 | bound | boolean | 当前是否仍绑定;false 要回到首次绑定流程 | `{"bound":false}` |
收到空 bind_token 时调用。bound=true 才保存本次新 Wi-Fi,bound=false 不改变归属、不保存新配置。这里为 true 仅表示已绑定,不代表 SDK 已注册或智能体已就绪。
### D4 每次启动注册:`POST /api/v1/device/register`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | challenge_id | UUID v4,必填 | D1 返回值,未过期、未消费 | `<D1值>` |
| 输入 | boot_id | UUID v4,必填 | 与 D1 同一启动编号 | `<本次启动UUID>` |
| 输入 | hardware_model | string,必填 | 必须与生产登记型号相同 | `ECHOEAR-S3` |
| 输入 | hardware_revision | string,必填 | 必须与生产登记硬件版本相同 | `V1.2` |
| 输入 | firmware_version | string,必填,1–32 字节 | 自己的真实固件版本 | `1.5.0-elder-agent.2` |
| 输入 | sdk_version | string,必填,1–64 字节 | 使用的真实语音 SDK 版本 | `2.0.0` |
| 输出 | contract_version | string | 当前平台接入契约标识 | `ONBOARDING-1.1` |
| 输出 | state | object | 当前绑定/激活状态,见下表 | `<状态对象>` |
```json
{"challenge_id":"<D1挑战UUID>","boot_id":"<启动UUID>","hardware_model":"ECHOEAR-S3","hardware_revision":"V1.2","firmware_version":"1.5.0-elder-agent.2","sdk_version":"2.0.0"}
```
```json
{"contract_version":"ONBOARDING-1.1","state":{"device_id":"KP01-020000260914","device_revision":3,"binding_generation":0,"binding_state":"UNBOUND","activation_state":"NOT_STARTED","state_hash":"<64位摘要>"}}
```
示例 revision 仅示意,以真实返回为准。新 boot_id 会关闭旧启动的设置状态,正在执行但未确认的 SDK 注册可能转为待对账;不要每次请求都更换 boot_id。
#### 公共 state 输出
| 输出字段 | 中文解释与用途 |
|---|---|
| device_id | 工厂编号 |
| device_revision | 平台设备状态版本,状态变化会增加;不是固件版本 |
| binding_generation | 绑定代次,绑定/解绑会变化;设备只能保存响应原值,不自行 +1 |
| binding_state | UNBOUND 未绑定、CLAIM_PENDING 旧流程待确认、BOUND 已绑定 |
| activation_state | NOT_STARTED/RESERVED/CONTACT_POSSIBLE/REGISTERED_OBSERVED/READY/RECONCILE_REQUIRED/BLOCKED 等激活任务状态;不是库存的 CLAIMABLE 生命周期 |
| state_hash | SHA256 状态摘要;当前最小流程无需单独 state-acks,可保存和核对 |
如需验算 state_hash,使用:
```text
hex_lower(SHA256(UTF8("STATE1\n" + device_id + "\n" + decimal(device_revision)
+ "\n" + decimal(binding_generation) + "\n" + binding_state + "\n" + activation_state)))
```
### D5 获取语音运行配置:`POST /api/v1/device/voice-runtime`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | boot_id | UUID v4,必填 | 已完成 D4 的当前启动编号 | `{"boot_id":"<启动UUID>"}` |
| 输出 | state | object | 当前绑定代次、激活状态 | 见 D4 |
| 输出 | command | object 或 null | 当前需应用的激活配置;null 不可继续当作有效配置 | 见下例 |
| 输出 | command.command_id | UUID | 激活任务 ID | `<activation_id>` |
| 输出 | command.type | string | 配置命令 | `APPLY_ACTIVATION` |
| 输出 | command.expires_at_ms | int64 | 配置有效期 | `<毫秒>` |
| 输出 | command.payload.config_b64 | string | base64url JSON 配置 | `<编码值>` |
| 输出 | command.payload.config_mac | string | 配置 HMAC,校验公式如下 | `<64位hex>` |
| 输出 | call_contract | string | 现有兼容字段,可能仍为 CALL-MEMORY-1.1;本实现明确使用 D9/D10,不按此字段切回旧 calls 路径 | `CALL-MEMORY-1.1` |
```json
{"state":{"binding_generation":1,"binding_state":"BOUND","activation_state":"RESERVED"},"command":{"command_id":"<激活UUID>","type":"APPLY_ACTIVATION","expires_at_ms":1789459200000,"payload":{"config_b64":"<编码配置>","config_mac":"<64位hex>"}},"call_contract":"CALL-MEMORY-1.1"}
```
state 在这里省略了 D4 已列出的通用字段。先比较:
```text
expected_config_mac = hex_lower(HMAC_SHA256(K, UTF8("CFG1\n" + config_b64)))
config = JSON_parse(base64url_decode(config_b64))
```
此处是 **Base64 编码 + 签名,不是应用层加密**;实际通过 HTTPS 传输。解码后的字段:
| 输出字段 | 中文解释与用途 | 示例/处理 |
|---|---|---|
| contract_version | 当前注册配置契约 | ONBOARDING-1.1 |
| device_id | 应与烧录工厂编号一致 | 不一致拒绝 |
| activation_id | 本次绑定的激活任务 ID,后续 D6–D8 使用 | 原样保存 |
| config_version | 当前配置版本,D6/D8 使用 | 原样保存,不自行递增 |
| binding_generation | 必须与 state.binding_generation 一致 | 不一致重新拉取 |
| expires_at_ms | 24 小时配置有效期,以实际返回为准 | 过期重新拉取 |
| iot.instance_id | 火山实例 ID | SDK 初始化/注册使用 |
| iot.product_key | 火山产品 Key | SDK 初始化/注册使用 |
| iot.product_secret | 当前实现确实下发的火山产品注册密钥 | 仅动态注册时使用,不是平台设备密钥;不打印、不发给小程序 |
| iot.device_name | 平台分配的火山设备名称 | 原样使用,不能自行替换为 MAC |
| bot_id | 产品默认激活配置中的 bot | **聊天必须用 D9 返回的老人专属 bot_id,不要用这个默认值覆盖它** |
未绑定返回 409 DEVICE_UNBOUND。已经有火山 device_secret 时复用本地凭据,检查实例/产品/名称是否匹配;不因为重启或换绑老人就重新动态注册。若注册状态不确定(RECONCILE_REQUIRED),停止重复注册,交给平台核对。
### D6 SDK 注册执行许可:`POST /api/v1/device/activation-attempts`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | activation_id | UUID v4,必填 | D5 激活任务 | `<D5值>` |
| 输入 | config_version | integer,必填,≥1 | D5 配置版本 | `1` |
| 输入 | attempt_id | UUID v4,必填 | 设备为一次 SDK 注册生成并保存的编号 | `<新UUID>` |
| 输出 | allowed | boolean | 是否允许这次外部 SDK 操作 | `true` |
| 输出 | attempt_id | UUID | 对应本次操作 | 原值 |
| 输出 | permit_expires_at_ms | int64 | 必须在许可有效期内发起注册,当前 30 秒 | `<毫秒>` |
| 输出 | state | object | 平台激活状态 | CONTACT_POSSIBLE |
```json
{"activation_id":"<D5激活UUID>","config_version":1,"attempt_id":"<新UUID>"}
```
```json
{"allowed":true,"attempt_id":"<同一UUID>","permit_expires_at_ms":1789372830000,"state":{"activation_state":"CONTACT_POSSIBLE"}}
```
这一步只在确实需要首次动态注册时执行,不是每次聊天都调用。拿到许可并保存“可能已经发起注册”记录后才调用 V1。不要在许可不确定/超时后换个 attempt_id 再次注册;先恢复原结果,防止重复注册。当前直聊首次通话可完成激活,不用另外实现旧 CONVERSATION_TEST 会话。
### D7 SDK 注册结果:`POST /api/v1/device/activation-results`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | activation_id | UUID v4,必填 | 当前激活任务 | `<D5值>` |
| 输入 | attempt_id | UUID v4,必填 | D6 使用的原编号 | `<D6值>` |
| 输入 | stage | string,必填 | 当前最小流程固定 SDK_REGISTER | `SDK_REGISTER` |
| 输入 | sdk_result | integer,必填 | SDK 返回码,成功必须 0 | `0` |
| 输入 | outcome | string,必填 | SUCCESS_OBSERVED 确认成功、DEFINITE_FAILURE 确认失败、UNKNOWN 无法确认 | `SUCCESS_OBSERVED` |
| 输出 | activation_id | UUID | 对应激活任务 | 原值 |
| 输出 | state | object | 成功通常 REGISTERED_OBSERVED | 见 D4 |
| 输出 | reconcile_required | boolean | true 表示需平台核对,不能再盲目注册 | `false` |
```json
{"activation_id":"<D5值>","attempt_id":"<D6值>","stage":"SDK_REGISTER","sdk_result":0,"outcome":"SUCCESS_OBSERVED"}
```
```json
{"activation_id":"<D5值>","state":{"activation_state":"REGISTERED_OBSERVED"},"reconcile_required":false}
```
只有真的拿到并保存火山 device_secret 和 RTCAppID 才报成功。超时、断电无法确认归 UNKNOWN,不等于 DEFINITE_FAILURE。该接口不上传火山密钥。结果重试保持同一正文和结果 request_id;同一 attempt 已保存不同结果时不能覆盖。
### D8 SDK 初始化确认:`POST /api/v1/device/activation-acks`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | activation_id | UUID v4,必填 | D5 任务 ID | `<原值>` |
| 输入 | config_version | integer,必填,≥1 | 已保存的当前配置版本 | `1` |
| 输入 | local_generation | integer,必填,≥0 | 本地保存配置的代次;同一启动不得倒退。现有固件直接用 config_version | `1` |
| 输出 | accepted | boolean | 平台已接受保存/初始化回执 | `true` |
| 输出 | state | object | 当前状态 | REGISTERED_OBSERVED 或 READY |
```json
{"activation_id":"<D5值>","config_version":1,"local_generation":1}
```
```json
{"accepted":true,"state":{"activation_state":"REGISTERED_OBSERVED"}}
```
在 D7 成功之后、RTC SDK 初始化成功之后调用。没有成功回执不能开始聊天。普通每次开机已存在火山凭据时仍需要 D4→D5→SDK 初始化→D8。
### D9 开始聊天/续期:`POST /api/v1/device/chat/start`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | device_id | string,本实现必传 | 与 X-Device-ID 一致;服务端兼容旧固件可省略,但新实现不要省 | `KP01-020000260914` |
| 输入 | session_id | UUID,必填,建议 UUID v4 | 一次聊天的唯一编号;重试和续期保持 | `<新聊天UUID>` |
| 输入 | boot_id | UUID,必填 | 已完成 D4 的当前启动编号 | `<启动UUID>` |
| 输出 | session_id | UUID | 必须对应本次聊天 | 原值 |
| 输出 | bot_id | string | 当前绑定老人的专属智能体 | `bot...` |
| 输出 | params | object | 整体交给 SDK;不要只挑一两个字段 | 下表 |
| 输出 | lease_expires_at_ms | int64 | 当前音频运行租期截止,最多 45 秒 | `<毫秒>` |
| 输出 | expires_at_ms | int64 | 本通话总截止,最多 1 小时,也可能受配额缩短 | `<毫秒>` |
```json
{"device_id":"KP01-020000260914","session_id":"<本次聊天UUID>","boot_id":"<启动UUID>"}
```
```json
{"ok":true,"code":"OK","request_id":"<请求UUID>","server_time_ms":1789372800000,"data":{"session_id":"<本次聊天UUID>","bot_id":"<老人bot_id>","params":{"PlatformTaskID":"<本次聊天UUID>","AgentConfig":{"WelcomeMessage":"你好,今天想聊些什么?","EnableConversationStateCallback":false,"ServerMessageURLForRTS":"https://121.41.46.238/callbacks/v1/volcengine/chat?session_id=<本次聊天UUID>","ServerMessageSignatureForRTS":"<平台生成的本次回调签名>"}},"lease_expires_at_ms":1789372845000,"expires_at_ms":1789376400000}}
```
| params 输出字段 | 中文解释与用途 | 设备动作 |
|---|---|---|
| PlatformTaskID | 本次平台会话关联号 | SDK 取它作为火山 task_id;不能另外随机生成 |
| AgentConfig.WelcomeMessage | 欢迎语 | 原样传给 SDK |
| AgentConfig.EnableConversationStateCallback | 是否开通供应商会话状态回调,当前 false | 原样传递;聊天结束由设备上报 |
| AgentConfig.ServerMessageURLForRTS | 本次字幕回调 URL,含 session_id | 原样传火山;不是 Function calling URL |
| AgentConfig.ServerMessageSignatureForRTS | 平台生成的回调签名值 | 原样传火山;设备不需要计算,不需要平台回调主密钥 |
该字段由平台计算为 `base64url_no_padding(HMAC_SHA256(callbackSecret, UTF8("volc-subtitle-v1\ndirect:" + session_id)))`。callbackSecret 只留服务器;这条公式供接口理解,嵌入式不需要获得该密钥,也不能改用工厂设备密钥计算。设备仅复制 D9 实际返回值。
平台每次新聊天查 `设备→绑定老人→老人智能体`,未绑定 HTTP 409 DEVICE_UNBOUND;未注册 SDK/未 D8 确认 DEVICE_NOT_READY;智能体未创建成功 VOICE_AGENT_NOT_READY。同老人/设备有另一通活动聊天返回 CHAT_ALREADY_ACTIVE。S2SConfig、模型、记忆由平台配置到该老人智能体,本接口不再把它们交给设备配置。
**聊天中每 10 秒重复调用 D9**,同 session_id/boot_id,返回新的 lease_expires_at_ms;这不是重新创建聊天。第一次把 params 交给 SDK,续期不重进房。超过租期或明确绑定/授权错误,关闭本地采集、播放及 SDK,然后 D10。
设备使用单调计时避免校时跳变:
```text
request_started = monotonic_ms()
收到响应后 elapsed = monotonic_ms() - request_started
remaining = data.lease_expires_at_ms - envelope.server_time_ms - elapsed
要求 0 < remaining <= 45000
local_deadline = monotonic_ms() + remaining
```
剩余时间非正则拒绝这次授权。expires_at_ms <= server_time_ms 也拒绝。网络失败仅沿用上一次仍有效的 local_deadline,不能按“现在+45秒”自续。总期限到达需结束,不复用旧 session_id。
### D10 聊天结束:`POST /api/v1/device/chat/end`
| 方向 | 字段 | 类型/要求 | 中文解释与用途 | 示例 |
|---|---|---|---|---|
| 输入 | device_id | string,本实现必传 | 与请求头工厂编号一致 | `KP01-020000260914` |
| 输入 | session_id | UUID,必填 | D9 使用的同一个聊天编号 | `<原值>` |
| 输入 | reason | string,必填 | 结束原因:hangup、start_failed、disconnected、reboot、timeout | `hangup` |
| 输入 | sdk_stopped | boolean,本实现明确传 | 已观察到本地 SDK 停止成功才 true;不是“我准备停止” | `true` |
| 输出 | session_id | UUID | 对应会话 | 原值 |
| 输出 | ended | boolean | 平台已接收结束;不证明远端所有媒体均已被平台观测停止 | `true` |
```json
{"device_id":"KP01-020000260914","session_id":"<本次聊天UUID>","reason":"hangup","sdk_stopped":true}
```
```json
{"session_id":"<本次聊天UUID>","ended":true}
```
先停本地音频,再停止 SDK,最后上报。网络失败保存补报;重启时未完成会话使用 reason=reboot,sdk_stopped=false(未亲眼观察到旧 SDK 正常停止)。即使已解绑,仍可用有效工厂身份给旧 session_id 补报结束。已结束的 session_id 再 start 返回 CHAT_ENDED;start 超时也要尝试 end,避免服务器已创建而设备不知道。
## 8. 设备直连火山 SDK 的必要参数
本节按项目内已验证的火山硬件 SDK 实现说明,不是让嵌入式自行实现整套 RTC。初次注册有外部 License/配额影响;本交接设备尚未执行此步骤。
### 8.1 V1 DynamicRegister:首次获取火山设备凭据
SDK 函数 `volc_device_register(&iot_info, &device_secret)` 使用 D5 的 instance_id/product_key/product_secret/device_name。收到 D6 许可后才执行。
| 方向 | 字段 | 中文解释与用途 | 示例/来源 |
|---|---|---|---|
| 输入 | InstanceID | 火山实例 ID,注意大小写 | D5 iot.instance_id |
| 输入 | product_key | 产品标识 | D5 iot.product_key |
| 输入 | device_name | 火山设备名 | D5 iot.device_name |
| 输入 | random_num | 本次签名随机字段;当前 SDK 取 int32(timestamp_ms),可能为负数 | 原始十进制值参与签名 |
| 输入 | timestamp | UTC 毫秒数字,不是秒 | 已校时设备时钟 |
| 输入 | auth_type | 注册使用产品密钥,固定数字 1 | `1` |
| 输入 | signature | 火山格式 HMAC 签名,见 8.3 | 普通 Base64,非 hex |
| 输出 | Result.payload | 加密的火山设备密钥 | SDK 解密后得到 device_secret |
| 输出 | Result.RTCAppID | RTC 应用 ID | 持久化,初始化 RTC 引擎 |
| 输出 | ResponseMetadata.Error | 火山错误信息(失败时) | SDK 检查,不把错误包当成功 |
```json
{"InstanceID":"<实例>","product_key":"<产品>","device_name":"<火山设备名>","random_num":123456,"timestamp":1789372800000,"auth_type":1,"signature":"<普通Base64签名>"}
```
```json
{"Result":{"payload":"<加密Base64内容>","RTCAppID":"<RTC应用ID>"}}
```
SDK 注册解码:`ciphertext=普通Base64_decode(Result.payload)`,AES-128-CBC 密钥为 **product_secret 字符串前16字节**,IV 也是前16字节,解密后校验并去除 PKCS#7 填充,得到火山 device_secret 字符串。不是用平台的 32 字节 K,也不是将 product_secret 按 hex 解码。项目现有 `volc_aes_decode(product_secret,payload,true)` 已实现,优先复用。
### 8.2 V2 GetRTCConfig:每次开聊获取房间
| 方向 | 字段 | 中文解释与用途 | 示例/来源 |
|---|---|---|---|
| 输入 | InstanceID / product_key / device_name | 此设备火山身份 | 首次注册配置的原值 |
| 输入 | random_num / timestamp / signature | 用火山 device_secret 签名,auth_type 按 0 参与计算;当前此请求 body 不额外加 auth_type | 见 8.3 |
| 输入 | bot_id | 此次老人的智能体 | **D9 bot_id** |
| 输入 | audio_codec | RTC 编码枚举,必须与 SDK 引擎和实际上传编码一致 | 当前 RTCLite `AUDIO_CODEC_TYPE_OPUS=1`,本项目传数字 `1` |
| 输入 | task_id | 此次通话关联号 | D9 params.PlatformTaskID |
| 输入 | agent_config | 本次欢迎语和字幕回调配置 | D9 params.AgentConfig,转换为 body 小写字段 agent_config |
| 输入 | config | 可选的覆盖配置 | 当前老人智能体方案 **省略整个 config 字段**,不要传空 S2SConfig 覆盖后台配置 |
| 输出 | Result.RoomID | RTC 房间 ID | SDK 加入该房间 |
| 输出 | Result.UserID | 本设备在房间内的用户 ID | 原样传 SDK |
| 输出 | Result.Token | RTC 临时凭据 | 原样传 SDK,不当永久设备密钥 |
| 输出 | Result.TaskID | 火山任务 ID | SDK 保存管理本次会话 |
```json
{"InstanceID":"<实例>","product_key":"<产品>","device_name":"<火山设备名>","random_num":123456,"timestamp":1789372800000,"signature":"<签名>","bot_id":"<D9老人bot_id>","audio_codec":1,"task_id":"<D9 PlatformTaskID>","agent_config":{"WelcomeMessage":"你好,今天想聊些什么?","EnableConversationStateCallback":false,"ServerMessageURLForRTS":"<D9回调URL>","ServerMessageSignatureForRTS":"<D9签名原值>"}}
```
上例 audio_codec=1 对应当前工程 RTCLite 的 OPUS 枚举;换 SDK 时需重新核对该层枚举,不能只改编码标志而不改实际音频编码。输出示例:`{"Result":{"RoomID":"<房间>","UserID":"<本机用户>","Token":"<房间token>","TaskID":"<任务>"}}`。
最容易遗漏:`params.AgentConfig` 转为 `agent_config`,`params.PlatformTaskID` 转为 `task_id`。如果换用未包含这些修改的原始 SDK,可能能通话但回调对不上。参考包提供本项目实际修改过的 SDK 对接源文件,不是可以脱离完整 SDK 独立编译的库。
### 8.3 火山签名计算(与平台 DEVREQ1 不同)
```text
canonical = "auth_type=" + decimal(auth_type)
+ "&device_name=" + device_name
+ "&random_num=" + decimal(random_num)
+ "&product_key=" + product_key
+ "&timestamp=" + decimal(timestamp_ms)
signature = STANDARD_BASE64(HMAC_SHA256(UTF8(secret_string), UTF8(canonical)))
```
顺序必须与上面一致(random_num 在 product_key 前),没有末尾换行。注册 auth_type=1、secret_string=product_secret;RTC 配置 auth_type=0、secret_string=火山 device_secret。Base64 保留 `+ / =`,不要使用平台请求的 lowercase hex 或 base64url。实例 ID 在 JSON 中发送,不在此 RTC 签名原文中;不要混用旧 WS 的追加 instance_id 算法。
### 8.4 音频与 SDK 停止
当前实机链路使用 high_quality/RTC;应用侧麦克风/扬声器为 **16 kHz、单声道、16-bit signed little-endian PCM**。20 ms PCM 一帧为 `16000×0.020×1×2 = 640` 字节;当前 RTC 上行在封装内编码 Opus(24 kbps),下行 Opus 解码后播放。不能把 PCM 字节标成 Opus 发送,也不能把 Opus 压缩数据直接送 DAC。
优先用完整工程中的音频/RTC 封装。通话停止时关闭采集和播放,调用 SDK stop/leave room;用真实返回结果设置 sdk_stopped,不能仅设置一个本地布尔值假装 SDK 已停止。
## 9. C1 火山聊天记录回调
这是 **火山→服务器**,不需要嵌入式上传文字、生成字幕签名或连接记忆数据库。设备责任只有把 D9 的回调字段交给火山 SDK。
| 方向 | 字段 | 中文解释与用途 | 示例 |
|---|---|---|---|
| 输入 URL | session_id | 平台本次聊天编号 | D9 session_id |
| 输入 body | message | 火山字幕包的普通 Base64 | `<Base64>` |
| 输入 body | signature | 原样回传本次 ServerMessageSignatureForRTS | `<平台给出的值>` |
| 解码字段 | type | 字幕消息类型 | subtitle |
| 解码字段 | data[].userId | 发言方,input=用户,output 或 RTC bot 用户=助手 | input |
| 解码字段 | data[].roundId / sequence | 轮次/片段序号,用于去重 | 1 / 1 |
| 解码字段 | data[].text | 发言文本 | 今天心情不错 |
| 解码字段 | data[].definite | 是否确定稿,平台只保存确定稿 | true |
| 解码字段 | data[].paragraph | 供应商段落标记 | true |
| 输出 | HTTP 200 + text/plain `ok` | 已处理回调 | `ok` |
```json
{"message":"<以下subtitle JSON的普通Base64>","signature":"<本次ServerMessageSignatureForRTS>"}
```
```json
{"type":"subtitle","data":[{"userId":"input","roundId":1,"sequence":1,"text":"今天心情不错","definite":true,"paragraph":true}]}
```
平台也支持供应商带 subv 帧头的字幕包,嵌入式不需要解析或重编码。文字写入本次会话的 call_utterance,重复回调去重,迟到回调仍归旧会话。**文字保存要求平台已获得该老人 transcript_storage 授权**;仅绑定的默认配置不自动开通记录存储。没有此授权时回调可能返回 403,设备不能据此改走私自上传。首次完整激活还依赖正常挂断和实际助手字幕证据,联调前由平台确认授权与老人智能体就绪。
## 10. 异常处理与验收清单
| 错误/场景 | 嵌入式处理 |
|---|---|
| DEVICE_AUTHENTICATION_FAILED / 401 | 核对工厂编号、32字节密钥解码、签名 LF、正文和校时;不可降级旧签名协议 |
| DEVICE_UNBOUND / 409 | 停止/禁止聊天,提示设备未绑定人员,进入配网绑定入口 |
| DEVICE_BINDING_CHANGED / BINDING_GENERATION_CONFLICT | 停止使用旧参数,同步当前启动/绑定,必要时重新走 D4/D5/D8 |
| DEVICE_NOT_READY | 恢复 D5–D8,不能绕过注册/保存确认 |
| VOICE_AGENT_NOT_READY | 平台修复老人智能体,设备不自行改用公共默认 bot |
| CHAT_ALREADY_ACTIVE | 清理/结束本设备旧会话,不并发制造新 session_id |
| CHAT_ENDED / 总期限或租期到期 | 停音频、停 SDK、D10;再次聊天用新 session_id |
| VOICE_CHAT_CONSENT_REQUIRED / MEMORY_CONSENT_REVOKED | 停止使用旧授权,交给平台/用户处理 |
| RECONCILE_REQUIRED / ATTEMPT_RECONCILIATION_REQUIRED | 保留注册凭据和结果,平台核对,不循环动态注册 |
| 429 / 配额不足 | 提示并退避,不能高速循环重试 |
| BLE received 后无 success | 查询 B3/M3;不能因为 BLE 断开就认定成功 |
| 解绑后再换 Wi-Fi | 空 bind_token + D3 得到 false,转首次绑定流程 |
| 断电重启 | boot_id 更新;恢复未完成绑定、注册结果及结束上报;工厂密钥不擦除 |
验收项目:
1. 小程序能发现 voicelink-ECHOEAR-S3-00260914,B1 型号和编号一致,协议为数字 2;UUID 正确的旧名称、自定义名称也能显示。
2. 离线 BLE 帧、DEVREQ1 测试向量完全一致,包括中文 SSID 的 UTF-8 字节长度。
3. 未绑定时配网令牌非空,设备确认后绑定成功;已绑定只配网发送空令牌,归属不改变。
4. D1 验签和 D4 注册通过;错误密钥/型号/硬件版本被拒绝。
5. 首次火山注册后凭据掉电保留,连续重启不重复注册;未确认结果恢复正确。
6. 连续两次聊天各用不同 session_id;同次每 10 秒续期;音频双向正常。
7. 文字由火山回调入平台,人物/设备/会话对应正确;设备无文字上传队列。
8. 正常挂断、断网、重启都能补报 D10;结束后不继续采集或播放。
9. 小程序解绑后,再 start 返回 DEVICE_UNBOUND,不再用旧 bot_id 直接开聊。
### 本次交付已验证与未验证
已创建并放行库存设备;使用私有初始化密钥在真实线上验证 TIME1 校时 HMAC、D4 注册 HTTP 200、D3 bound=false、D9 返回 HTTP 409 / DEVICE_UNBOUND。测试注册固件标记为 handoff-probe-1,真实硬件下一次 D4 会报告自己的固件版本。未绑定任何老人,未发起火山动态注册,未修改原有 EchoEar。
尚未把新身份烧录到实际硬件;该新设备的 BLE、Wi-Fi、音频及完整火山聊天需要嵌入式实现后按上述清单验收。不要把接口连通测试等同于新硬件整机验收。
## 11. 交付文件和代码依据
| 文件 | 用途 |
|---|---|
| 本文 `.md` / 同内容 `.html` | 可转发给工程师的接口、字段、算法、示例说明 |
| `device-init.private.json` | 只给本设备的烧录信息,含真实设备密钥;受控交接,不提交公共仓库 |
| `embedded-signing-reference.cjs` | 无第三方依赖的 JS 签名和 BLE 帧参考,Node.js 18+ |
| `test-vectors.json` | 离线公开假密钥测试向量,与真实设备密钥无关 |
| `reference/` | 当前固件核心通信源文件快照,辅助 C 实现;不包含完整 BSP/SDK/构建依赖 |
运行参考:`node embedded-signing-reference.cjs` 仅输出离线向量。`node embedded-signing-reference.cjs --probe device-init.private.json` 会对**这台未绑定联调设备**执行线上校时/注册/未绑定验证,不绑定、不注册火山;实物已绑定后不要再执行该 probe,避免创建新 boot_id 干扰实物。
代码依据:平台 `internal/onboarding/{auth,crypto,setup,ble,voice_runtime,device_activation,service}.go`、`internal/callmemory/direct.go`;固件 `main/{ble_cloud,ble_setup,call_client}.c`;小程序 `src/ble/client.js`;火山适配 `volc_device_manager.c`、`volc_auth.c`、`volc_rtc.c`。当前服务端事实优先于历史兼容文档,后续接口变更应同步更新本文版本。
+37
View File
@@ -0,0 +1,37 @@
// Node.js 18+. No packages. Default: offline vectors. --probe FILE: only this
// handoff device's time/register/unbound checks; never binds or registers Volcano.
const crypto=require('node:crypto'),fs=require('node:fs'),assert=require('node:assert/strict');
const sha=b=>crypto.createHash('sha256').update(b).digest('hex');
const hmac=(key,text)=>crypto.createHmac('sha256',key).update(text,'utf8').digest('hex');
function sign(key,{method='POST',path,deviceId,keyVersion=1,timestamp,nonce,requestId,raw}) {
const bodySha256=sha(Buffer.from(raw,'utf8'));
const canonical=['DEVREQ1',method.toUpperCase(),path,deviceId,String(keyVersion),String(timestamp),nonce,requestId,bodySha256].join('\n');
const signature=hmac(key,canonical);
return {bodySha256,canonical,signature,headers:{'Content-Type':'application/json','X-Device-ID':deviceId,'X-Key-Version':String(keyVersion),'X-Timestamp-Ms':String(timestamp),'X-Nonce':nonce,'X-Request-ID':requestId,'Authorization':'Device-HMAC-SHA256 '+signature}};
}
function frame(raw){const bytes=Buffer.from(raw,'utf8');if(bytes.length<1||bytes.length>2048)throw Error('invalid length');const head=Buffer.alloc(2);head.writeUInt16LE(bytes.length);return Buffer.concat([head,bytes]);}
function vectors(){
const key=Buffer.from(Array.from({length:32},(_,i)=>i));
const input={path:'/api/v1/device/ble-state',deviceId:'KP01-020000260914',timestamp:1789372800000,nonce:Buffer.from(Array.from({length:16},(_,i)=>i)).toString('base64url'),requestId:'12345678-1234-4234-8234-123456789abc',raw:'{}'};
const calculated=sign(key,input);const bleRaw='{"cmd":"get_info"}';
assert.equal(frame(bleRaw).toString('hex'),'12007b22636d64223a226765745f696e666f227d');
return {warning:'OFFLINE TEST KEY ONLY. NOT THE LIVE DEVICE KEY. Fixed timestamps/nonces must not be used in live requests.',testKeyHex:key.toString('hex'),input,...calculated,ble:{raw:bleRaw,utf8Length:Buffer.byteLength(bleRaw),frameHex:frame(bleRaw).toString('hex')}};
}
async function probe(file){
const init=JSON.parse(fs.readFileSync(file,'utf8').replace(/^\uFEFF/,''));
assert.equal(init.factory_id,'KP01-020000260914','probe restricted to handoff device');assert.equal(init.origin,'https://82.157.201.114');
const key=Buffer.from(init.device_key_hex,'hex');assert.equal(key.length,32);
const boot=crypto.randomUUID(),nonce=crypto.randomBytes(16).toString('base64url');
let before=performance.now();
const response=await fetch(init.origin.replace('https:','http:')+'/api/v1/device/time-challenges',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({device_id:init.factory_id,key_version:init.key_version,boot_id:boot,client_nonce:nonce}),redirect:'error',signal:AbortSignal.timeout(15000)});
assert.equal(response.status,200);const clock=await response.json();assert.equal(clock.mac_hex,hmac(key,'TIME1\n'+clock.payload_b64));
const payload=JSON.parse(Buffer.from(clock.payload_b64,'base64url'));assert.equal(payload.device_id,init.factory_id);assert.equal(payload.boot_id,boot);assert.equal(payload.client_nonce,nonce);assert.ok(performance.now()-before<15000);
const offset=payload.server_time_ms-Date.now();
async function post(path,body){const raw=JSON.stringify(body);const signed=sign(key,{path,deviceId:init.factory_id,keyVersion:init.key_version,timestamp:Date.now()+offset,nonce:crypto.randomBytes(16).toString('base64url'),requestId:crypto.randomUUID(),raw});const r=await fetch(init.origin+path,{method:'POST',headers:signed.headers,body:raw,redirect:'error',signal:AbortSignal.timeout(20000)});return {status:r.status,data:await r.json()};}
const registered=await post('/api/v1/device/register',{challenge_id:payload.challenge_id,boot_id:boot,hardware_model:init.model,hardware_revision:init.hardware_revision,firmware_version:'handoff-probe-1',sdk_version:'2.0.0'});assert.equal(registered.status,200);assert.equal(registered.data.data.state.binding_state,'UNBOUND');
const state=await post('/api/v1/device/ble-state',{});assert.equal(state.status,200);assert.equal(state.data.data.bound,false);
const chat=await post('/api/v1/device/chat/start',{device_id:init.factory_id,session_id:crypto.randomUUID(),boot_id:boot});assert.equal(chat.status,409);assert.equal(chat.data.code,'DEVICE_UNBOUND');
console.log(JSON.stringify({device_id:init.factory_id,time_mac_verified:true,register_status:registered.status,binding_state:registered.data.data.state.binding_state,ble_state:state.data.data.bound,chat_status:chat.status,chat_error:chat.data.code,provider_registration_performed:false},null,2));
}
module.exports={sign,frame,vectors};
if(require.main===module){if(process.argv[2]==='--probe'){probe(process.argv[3]).catch(()=>{console.error('Probe failed; check network, identity and current device state. No credentials printed.');process.exitCode=1;});}else console.log(JSON.stringify(vectors(),null,2));}
+15
View File
@@ -0,0 +1,15 @@
# 语音聊天中的时间追问(2026-10-07)
已发布 API `0.99.53-rc.event-time1`,基线为 `0.99.52-rc.dify2`。每次新通话准备时,在发给火山语音模型的角色中追加[时间追问规则](../deploy/personas/event-time-guidance-v1.md),已有与新建的专属 Bot 均适用。已开始通话继续使用原冻结配置。
长者自愿展开一件已发生的经历、时间归属尚不清楚且有助于理解时,先回应真实细节,在停顿处可以轻问一次“大概是什么时候的事呀?”时间问题计入每轮最多一个问题、连续三次回复最多一个问题的限制。同一事件最多主动问一次;已经有时间线索时不重复。接受大概年份、年代、季节与人生阶段,不索要精确日期,不猜时间选项或根据年龄、档案、系统日期推算。
“忘了”“记不清”“不想说”、拒绝、疲惫、转题和连续短答均停止追问。长者主动补充或更正时保留原话中的不确定性。时间缺失可以继续保留经历,不能为了故事完整编造时间。时间事实仍须来自长者自己的讲述,助手的问题和简短附和不能成为证据。
规则在通话准备时追加,不改写已保存的人设与档案。每次从该长者的保存配置重建,支持幂等追加,避免前次通话背景累积。运行提示词嵌入 API 二进制,可审阅版本与嵌入正文在构建时比对一致。
发布候选仅移植六个本次文件,其余 715 个基线文件保持原哈希;没有夹带本地尚未部署的人设同步改动。相关包单元测试、Go vet 和 Linux API 构建通过。数据库集成案例未配置隔离数据库,本次验证不覆盖这些案例,也未主动发起真实语音通话。
上线确认无活动通话,只重建 API。readiness、公网版本与运行二进制哈希核验通过,API 环境、挂载、端口、网络及其他容器 ID 保持一致,数据库迁移版本不变。部署回执与本次补丁位于 `E:/Codex/story/.local-evidence/event-time-guidance-20261007`。真实语音追问是否自然仍待下一通实听;提示词约束不保证模型每轮都完全遵守。
回滚使用服务器 `/opt/data-terminal-cloud/release-backups/0.99.52-rc.dify2/api-forward.compose.json` 的原 API 配置,再将 `current` 恢复至原发布目录。新版本的冻结 Compose 位于 `/opt/data-terminal-cloud/release-backups/0.99.53-rc.event-time1/api-forward.compose.json`,包含私密配置,禁止导出或提交。
@@ -0,0 +1,45 @@
# 小禾首次聊天介绍发布(2026-10-04)
已于 2026-10-04 09:41(北京时间)发布 API `0.99.27-rc.first-introduction1`,基线为 `0.99.26-rc.natural-chat1`;Web 保持 `0.99.25-rc.firmware-entry1`。本次增加独立于普通动态开场的首次使用介绍,候选构建、单元测试、18 项隔离会话集成和生产上线复核均通过。目标设备当前绑定尚无介绍完成记录,下一通新会话将使用下述介绍;真实设备口播与麦克风验收仍待完成。
## 已批准的介绍原文
> 您好,我叫小禾,是陪您聊天的智能助手。平时,我能陪您聊聊家常,听您说说生活里的事,也能给您讲故事、解答一些日常问题。您想说什么,直接跟我说就行,就像平常聊天一样。要是没听清我说的话,您就说“再说一遍”。咱们先认识一下,您希望我怎么称呼您?
原文以 `introduction-v1` 固定模板交付,主话题为 `neutral`、类别为 `introduction`。功能示例只用于说明能做什么,不视为老人已经请求讲故事、解答问题或参加活动。
介绍后等待老人回应,不立即追加故事、功能说明或活动。老人提供称呼时简短确认,并按其希望的称呼交流;只说听到了、不愿提供称呼或直接聊别的时,接住当前内容,不反复索要姓名。不承诺已保存称呼或已修改个人资料;本次引导不新增资料写入操作,也不改变原有记忆与字幕授权。
## 首次范围与完成条件
- 首次按“设备 + 老人 + 当前绑定代数”判断。同一老人换另一台设备会重新介绍;设备从 A 换绑到 B、再换回 A,新的绑定代数也会重新介绍,不能沿用旧绑定的完成记录。
- 旧设备上线后,如果当前绑定没有完成过 `introduction-v1`,下一通新会话补一次介绍。旧版普通问候或已经聊过很多通,不会代替这次介绍的完成记录。
- 仅当前范围内的 `DIRECT-CHAT-1` 介绍会话同时满足 `call_state=ENDED`、`ended_at` 非空、`local_audio_off=true` 和 `sdk_stopped=true`,才视为已经完成,后续新通话恢复普通开场。这是正常结束的运行记录,不是设备已完整播放、老人已听见或已理解的证明。
- 准备供应商配置失败时不提交介绍会话;启动失败、断开、超时等失败会话,以及音频或 SDK 尚未停止的会话,不消费这次机会,下一通仍可介绍。完成记录不受最近五通轮换窗口、后续失败通话或记忆隐私代数变化影响。
- 普通动态开场关闭、偏好格式不支持、未授权个性化记忆或未授权字幕保存时,首次介绍仍生效。它不需要历史字幕或个人记忆;记忆未授权时仍保持 `memory_mode=OFF`,不因介绍而开启存储。
## 会话一致性与实现范围
在现有长者锁、绑定校验和活动通话检查内选择介绍。设备运行参数与供应商 `PrepareWithWelcome` 使用同一句原文;同一会话的重试与续约复用冻结参数,不重新选择介绍,也不重复准备供应商配置。会话期间修改偏好或基础欢迎语,不改变当前会话的开场。
首次介绍的行为补充只作用于本通角色,不写回基础人设,也不带入后续普通会话。`call_session.budget_snapshot.opening` 保存有界的模板键及 `companion-opening-v2` 版本,不在该元数据中保存口播正文、老人称呼或记忆事实。普通开场的近期记录另按当前设备、老人、绑定代数和隐私代数限定,既有兴趣和字幕用途继续受原授权约束。
候选从线上基线构建,源码与测试差异为六个文件:新增介绍模板和会话集成测试,调整 `callmemory` 的开场选择与调用、`elderagent` 的本通角色引导及相应测试。没有新增数据库迁移;本说明为仓库发布记录。
## 已完成验证
- `companionopening`、`elderagent`、`callmemory` 单元测试和 Go vet 通过,Linux API 与测试二进制构建通过。
- 独立 PostgreSQL、Redis 和内部测试网络中应用 74 个迁移及本地测试种子,18 项集成测试全部通过、0 跳过。覆盖无记忆或字幕权限、关闭动态开场、同会话冻结、失败后重试、音频及 SDK 停止条件、完成记录不依赖字幕和近期窗口、旧设备补介绍、A→B→A 换绑、同老人不同设备,以及原有动态开场、绑定隔离、结束与回调校验。
- 供应商准备使用本地模拟,没有真实火山对话请求、生产业务数据复制或生产容器修改;隔离测试容器、网络和远端临时目录已清理。以上隔离结论不替代生产发布回执。
## 生产与设备验收
发布使用 42 层 Compose 配置,仅更新 API;运行二进制 SHA256 为 `3d8ffec7052627202c93a5ae7cf4098ebbd46a97ad80d20dfbd72003b0560c18`,与冻结候选一致。生产版本与 readiness 复核通过,schema 为 74 且 clean,没有执行迁移。API 环境、挂载和端口、其它容器及 Web 容器 ID 保持一致;112 个 Web 静态文件及生成的运行配置验证通过。未写业务数据、重新应用人设、主动发起通话或 OTA。发布回执时间为 `2026-10-04T01:41:07.583602+00:00`,上一 API 镜像为 `0.99.26-rc.natural-chat1`。
Windows 只读枚举确认 COM6 的父 USB 设备序列为 `B4:3A:45:16:34:A8`,与目标 EchoEar 的物理 MAC 一致。发布后 09:41:20(北京时间)的云端只读快照显示设备在线,最近心跳为 09:41:08,固件为 `1.7.0-controls.23 / code 7`,有效绑定一条、绑定代数 1、配置版本 19/19,没有活动通话或 OTA。介绍会话数与已完成数均为 0,下一通新会话应补首次介绍。
本次 `diag-state` 观察记录没有收到诊断输出,不能写作串口诊断成功。尚未确认设备实际播出上述介绍,也未完成麦克风输入、扬声器听感、介绍后等待回应或真实多轮交流验收。本次不发布设备固件;COM6 身份和云端在线状态不能代替音频验收。
## 证据
证据位于 [本次证据目录](E:/Codex/story/.local-evidence/first-chat-introduction-20261004),包括 [生产发布回执](E:/Codex/story/.local-evidence/first-chat-introduction-20261004/production-receipt.json)、[候选构建清单](E:/Codex/story/.local-evidence/first-chat-introduction-20261004/build-manifest.json)、[隔离测试结果](E:/Codex/story/.local-evidence/first-chat-introduction-20261004/result.json)、[18 项集成日志](E:/Codex/story/.local-evidence/first-chat-introduction-20261004/integration-callmemory.log)、[仅本次源码差异](E:/Codex/story/.local-evidence/first-chat-introduction-20261004/task-only.patch)、[发布前设备快照](E:/Codex/story/.local-evidence/first-chat-introduction-20261004/device-cloud-before.json)、[发布后设备快照](E:/Codex/story/.local-evidence/first-chat-introduction-20261004/device-cloud-after.json) 和 [串口观察结果](E:/Codex/story/.local-evidence/first-chat-introduction-20261004/device-before.json)。
+39
View File
@@ -0,0 +1,39 @@
# 用户端首页重设计发布记录
## 宽屏修复:0.98.3-rc.home-width1
以线上 `0.98.2-rc.captcha-csp1` 为基线,仅修改 `web/src/components/home/customer-home.css`:移除1660px最大宽度和靠左的宽屏自动右边距;大屏插画保持完整比例,统计项居中。生产构建通过,2560/1920/390px 浏览器验证通过。
发布包 SHA-256:`162bc747c3519978f1d80490251d431800d6a242fda60046da41704d1d10825b`。新资源为 `/assets/index-GTdGpha7.js`、`/assets/index-kv3VVK0K.css`。沿用当前镜像的登录和CSP配置,只替换Web并重载gateway;完整Compose层最后增加 `deploy/server/compose.home-width.yaml`。保留 `0.98.2-rc.captcha-csp1` 镜像和目录供Web回退,后端和数据服务保持原容器。
- 时间:2026-09-20,约 22:21(Asia/Shanghai)。
- 地址:https://82.157.201.114/ 。登录后首页采用新设计。
- 本次首页版本:`0.98.1-rc.home1`,仅替换 Web 容器,gateway 重载。
- 最终检查时线上为 `0.98.2-rc.captcha-csp1`;该后续验证码 CSP 修复保留了首页源码、发布清单和同一组首页 JS/CSS。
- API/worker 仍为 `0.98.0-rc.trusted-login1`;本次首页部署未重启后端或数据服务,没有数据库迁移。
## 发布范围与基线
初始候选基于 `0.97.1-rc.tencent-captcha1`。激活检查发现线上已变为 `0.98.0-rc.trusted-login1`,在切换前中止,没有覆盖新登录功能。重新下载最新线上源码,仅叠加首页相关的 12 个源文件/资产和两项 UI 依赖后构建。
既有修改文件仅为 CustomerPortal、SaaSWorkspace、两个主题文件中的首页排除选择器,以及 package.json/package-lock.json。新增首页组件、样式、统计模型和三张插画。锁文件检查确认原有依赖版本未变,仅新增 Noto Sans SC 和 Phosphor Icons。开发预览入口未进入生产构建。
候选源码:`.tmp-home-deploy/candidate/web`。
发布包 SHA-256:`d510b7d8b5515e0a69d643708943529f778348d0179970ceea9691fa6494c2e6`。
服务器首页清单:`/opt/data-terminal-cloud/releases/0.98.1-rc.home1/home-release-manifest.json`;后续 release 也保留该清单。
## 已验证
- 生产构建及 6 项首页模型测试通过;桌面、390px、320px 和 900px 布局检查完成。
- 生效 Compose 配置与基线相比,唯一变化为 Web 镜像。
- 公网 index.html、入口 JS/CSS、三张插画均以 SHA-256 对照本次构建通过。
- 线上入口资源为 `/assets/index-l3QELZYP.js` 和 `/assets/index-BeTfdCiD.css`,后续 CSP 修复后再次核对仍一致。
- API/worker readiness 正常;API、worker、Postgres、Redis、MinIO 容器 ID 在首页发布前后未变。
- Web/API/worker 运行正常、重启计数 0。浏览器登录页正常,未见 console error/warn。
- 浏览器现有会话未登录,未重新完成真实账户登录,也未宣称真实账户下首页全流程验收完成。
## 保留与回退
保留旧 Web 镜像 `data-terminal-cloud-web:0.98.0-rc.trusted-login1`、旧 release 和本次发布清单。发布脚本在健康或资源校验失败时会自动仅回退 Web。
当前后续 CSP 修复已生效,手动回退时必须保留它:应以当前完整 Compose 层为基础追加 Web 镜像覆盖,并核对 CSP/登录依赖,不能直接把 current 指针退到旧 release 或遗漏 trusted-login/captcha-csp 覆盖层。不要执行 compose down,不要删除生产卷。
+33
View File
@@ -0,0 +1,33 @@
# 新建老人默认开启聊天记忆
> 历史发布记录:后续创建行为已改为用户逐项选择授权,参见 [创建老人授权步骤](creation-consent-step-20261005.md)。本文的默认开通行为已被替代。
2026-10-05 已发布 API 和 Web `0.99.39-rc.initial-memory1`,线上基线为 `0.99.38-rc.companion-unified1`,数据库仍为 74 且 clean,本次无需迁移。
## 新建行为
家庭页面、小程序调用的 `CreateSubject`,以及设备配网页面的 `CreateProfile`,都在创建资料的同一个事务中写入 `transcript_storage`、`personalized_memory` 两项授权和不可变授权快照。默认快照中语音聊天、字幕保存和个性化记忆为 true。录音保存、故事整理、家庭分享仍使用原有独立设置。
授权记录包含创建操作人、租户当前政策版本、用途说明摘要、创建请求证据引用;每项授权分别记录审计。失败时连同老人资料和新建家庭一起回滚。配网入口的重复请求复用结果,不重复建老人或授权。
默认授权只发生在新建资料时。修改资料、再次绑定、换设备或读取资料不会恢复撤回的授权;已有撤回流程继续生效。
创建页说明后续聊天文字的保存和记忆用途,按钮为同意授权并创建。网页已上线;小程序源码已同步到 `C:/Users/19926/WeChatProjects/voicelink/miniprogram/pages/family`,提示需重新编译并上传后才会出现在手机上。旧客户端的新建请求也由后端创建默认授权。
## 当前测试设备
根据用户本次明确要求,为 `KP01-8995ACF5E69E` 当前绑定且此前没有授权记录的老人资料补齐了上述两项授权,记录系统操作审计与新快照。2026-10-05 12:29(北京时间)只读复核:记忆使用和字幕上传均已授权。
没有批量修改其他老人的授权,没有修改历史通话、补造字幕或记忆。之前五通聊天的模式仍是 OFF;下一通聊天会按新授权准备。姓名需要重新告知,正常结束后等待异步提取,再进行实际重启后的语音回忆验收;该主观验收尚未完成。
## 验证
- 工作区独立 PostgreSQL/Redis:saas 37、onboarding 152、httpapi 238 项通过,0 跳过。
- 精确线上候选使用线上 74 个迁移:saas 34、onboarding 150、httpapi 237 项通过,0 跳过,包含授权失败回滚、重复请求、撤回后设备绑定及两条创建入口。
- Go vet、Linux API 构建、网页 TypeScript 检查与生产构建通过。
- 小程序 144 项测试通过,微信原生编译器成功编译 9 个 WXML、10 个 WXSS。
- 生产 API 版本、运行二进制 SHA256、121 个网页静态文件及运行配置回读通过,其他服务容器及启动时间保持一致。
本次发布代码变化限定为两个后端创建入口、一个共用授权初始化函数、相应测试和两个网页创建提示。没有包含共享工作区其他未发布改动。
证据目录:`E:/Codex/story/.local-evidence/name-memory-20261005`。发布回执为 `production-receipt.json`,候选测试为 `candidate/integration-results/results.json`,当前设备授权回执为 `current-authorization.json`,只读复核为 `inspection.jsonl`。
+15
View File
@@ -0,0 +1,15 @@
# 本地拼图滑块验证码
SaaS 登录使用本地开源拼图滑块,数字图片与腾讯云验证码仍是服务端策略允许的模式。迁移 74 只扩展 SaaS 可选模式,不自动更改默认策略;小程序继续使用原图片模式,不能启用尚未发布的滑块协议。
生成接口返回 `mode: slide`、`captcha_id`、`expires_at_ms`,320×180 JPEG 主图、52×52 透明 PNG 拼块以及拼块初始 `thumb_x/thumb_y`。图像均为内嵌 data URL。主图有一个目标缺口和两个同形干扰缺口,拼块对应目标处的背景。坐标、背景和形状使用 `crypto/rand`;客户端不获得目标 X。
客户端将释放点转换为原图坐标,向同一入口的 `/captcha/verify` 提交 `{captcha_id,x,y}`,位置误差最多 3 像素。正确位置换取随机 32 字节 `captcha_ticket`,再通过既有密码登录或申请短信参数提交该凭证;图片答案和腾讯随机字符串不能替代本地凭证。
挑战与凭证总有效期仍为生成后 2 分钟,验证不会延期。会话、客户端和策略版本必须匹配;错误位置原子消费挑战,凭证只可消费一次。刷新会替换旧挑战,Lua 比较旧记录并保持原 TTL,避免延迟校验覆盖新挑战。Redis 中目标位置经过 AES-GCM 加密,凭证只存 HMAC,Origin、CSRF、会话检查及原手机号/IP/短信总量限制继续执行。
拼图是自部署的人机交互门槛,并不具备腾讯云完整的行为识别、设备信誉或风险模型;图像匹配程序仍可能解题。验证码不能替代短信限流或可信浏览器策略。增加验证码或验证尝试仍受会话及 IP 限流约束。最多同时渲染两个挑战,静态图片只解码一次,拥挤时返回 `CAPTCHA_BUSY`。
组件和图片来源、版本及 Apache-2.0/MIT 许可证见 `third_party/local-slide-captcha/NOTICE.md`。API 与 Web 分发镜像均携带完整通知及许可证,运行容器逐文件摘要已核对。
2026-09-30 已上线 API/Web `0.99.19-rc.local-slide1`、迁移 74 和策略 version=4(SaaS=slide、mini=image)。42 项隔离后端集成及 10 项 React 测试通过,API Linux 与前端构建通过;两个既有安全依赖补丁后扫描无应用可达报告。真实浏览器弹窗、换图、过期提示及取消已确认。没有自动解答真实验证码、发送真实短信或据此宣称账号登录成功。旧小程序兼容 API 保留;用户后续要求小程序只开放家庭端、只保留微信一键登录,运营使用网页,小程序发布状态另见其验收记录。
+29
View File
@@ -0,0 +1,29 @@
# 登录页按需弹出腾讯云验证 — 2026-09-21
已部署至 `https://82.157.201.114/login?returnTo=%2F`,Web 版本 `0.99.4-rc.login1`,上一版本 `0.99.3-rc.ble1`。
## 发布范围
- 腾讯云模式登录页移除常驻“安全验证”标题、说明及“刷新验证”入口。
- 点击“获取验证码”后才加载并弹出腾讯云插件;验证通过后发送短信,取消或验证失败不发送。
- 隐藏的验证挑战过期或初次获取失败时,可由用户操作重新获取,避免缺少刷新入口而无法继续。
- 保留密码登录、忘记密码,以及服务端配置的图片验证码模式。
- 仅叠加生产文件 `web/src/components/SMSLogin.tsx`;API、数据库、运行配置及蓝牙配网功能未改动。
## 验证
TypeScript/Vite 生产构建通过。模拟 SDK/API 的浏览器回归覆盖初始无验证区域、SDK 延迟加载、过期更新、取消、失败、成功后单次短信请求、密码登录及图片验证码模式。
公网登录页桌面和 375px 手机检查通过,无横向溢出或页面异常;确认线上为腾讯云模式,点击前无 SDK 请求。未发送真实短信,也未执行收费验证。公网首页、登录页、JS/CSS 的 SHA-256 与构建产物一致,其他 8 个服务容器未更换。
## 发布产物与回退
- 目录:`/opt/data-terminal-cloud/releases/0.99.4-rc.login1`
- 镜像:`data-terminal-cloud-web:0.99.4-rc.login1`
- JS:`/assets/index-TjEKnN2O.js`
- CSS:`/assets/index-BE0QqIen.css`
- 发布包 SHA-256:`5ac482961c7c35c97ef334244a87159514c7c723077d7154206dc82bf3ab995d`
- 服务器记录:`login-captcha-release-manifest.json`
- 本地构建、脚本和验证截图:`.tmp-login-captcha-deploy/`
回退使用上一版本完整 Compose 层只重建 Web,重载 Gateway 并恢复 current 链接;不执行整栈 down 或修改数据库。完整层列表保存在服务器发布清单中。
+13
View File
@@ -0,0 +1,13 @@
# 登录会话与短信图片验证修复
版本:`0.82.1-rc.login-fix1`。
首页加载家庭后会读取 `/api/v1/content-models`。此前此接口仅识别 Bearer 登录,而手机号使用 Cookie,导致接口返回 401,前端又据此清除整个会话。此次允许该接口的 GET 请求使用客户 Cookie,继续由服务层校验租户归属;模型创建、测试和启用仍保留运维认证及权限校验。
前端遇到业务接口 401 后,先读取 `/api/v1/me` 确认当前会话。只有会话确实失效才返回登录页;网络故障不自动退出。旧 OIDC 续期与卸载事件不再清除手机号会话。
短信登录与设备绑定的短信入口均要求图片验证。图片绑定各自浏览器会话,2 分钟过期;刷新和每次验证均使旧图失效。新的发送请求必须通过图片验证,随后才会创建短信记录和调用供应商;同一请求号的已完成发送重试只返回原结果。短信发送间隔、频率限制和验证码尝试次数仍生效。密码登录、短信登录同页切换,手机号与短信倒计时保留。
验证包括隔离 PostgreSQL/Redis 中的短信发送保护、错误与过期图片、跨会话隔离、重放拒绝及发送幂等;前端覆盖两种登录方式、设备绑定、路由返回、错误 401、旧 OIDC 事件及真正的会话过期。公网登录与页面浏览结果另保存在本次发布的验证记录中。
短信供应商仍未配置,因此未发送真实短信。此次部署不调整账号密码或家庭数据。
+82
View File
@@ -0,0 +1,82 @@
# 聊天记录与老人记忆
> 2026-10-07 更新:用户已确认聊天应直接从火山 Viking 检索;平台回忆提取用于整理故事和回忆录。原来的“平台筛选后注入聊天”方式被替换,详见 [原生检索说明](native-viking-retrieval-20261007.md)。以下保留 2026-09-16 的实施记录。
## 现在的流程
1. 设备向平台请求开始聊天,平台查设备绑定的老人。
2. 平台把该老人的有效记忆加入其智能体的 S2S 对话配置,然后返回设备所需的聊天参数。
3. 设备直接与火山聊天。火山按照本次 `session_id` 把聊天文字回调给平台,平台保存聊天记录。
4. 设备报告结束。平台等待末尾字幕回调,后台把已收到的用户和助手文字上传到火山记忆库。
5. 火山异步提取记忆;平台读取结果,保存成小程序可查看、修改和删除的记忆。下一通聊天再带入。
设备不用另传整份聊天文字,也不用拿记忆库密钥。这次不需要重新烧录或重新绑定。
## 两份数据分别干什么
| 数据 | 保存位置 | 用途 |
| --- | --- | --- |
| 聊天记录 | 平台数据库 `call_utterance` | 家人查看原始对话,作为上传来源 |
| 火山提取的记忆 | 火山记忆库 `memory` 的 `event_v1` | 火山根据对话自动整理事实 |
| 可管理的记忆 | 平台 `curated_memory` / `memory_version` | 家人查看、纠正、停用、删除;作为下次聊天的资料 |
不同老人使用由“租户 ID + 老人 ID”生成的独立 `user_id`,查询和上传都限定该标识,不以设备编号当作老人记忆身份。换设备后仍跟随老人。
## 小程序
在家人的故事与记忆页面查看记忆。聊天结束后不是立即生成;处理期间会显示上传或提取状态。接口失败不会把已有列表隐藏。
人工修改优先,后台不会用原提取结果覆盖人工修改。删除、停用后不再带入新的聊天;后台同步处理火山对应事件。撤回授权停止上传,个人信息删除沿用平台现有删除流程。
## 运行条件
- 老人已授权“聊天记录保存”和“个性化记忆”,设备仍按原流程绑定。
- API、worker 使用已有 `VIKING_MEMORY_RESOURCE_ID`、`VIKING_MEMORY_API_KEY_FILE`、事件类型配置。
- 数据库新增迁移 `000067_managed_call_memory`,记录上传进度和云端事件对应关系;上传或查询失败自动重试。
- 已结束且仍有授权、仍保存文字的旧直连聊天会自动补传。不补造缺失的聊天记录。
本方案使用火山提取事件记忆,不依赖平台另配 ARK 文本提取模型;不另外自动生成 `profile_v1`。老人偏好等事实也可以包含在事件记忆中。
`MemoryConfig.Enable` 仍为 `false`,这是有意的:纯 S2S 使用平台整理后的资料注入,避免云端直接检索绕过家人的修改、停用和删除。不要仅为“启用记忆”把这个开关打开。
## 状态接口
`POST /api/v1/memories/sync/status`(使用已有小程序登录凭证)
输入:`{"elder_id":"老人公开ID"}`。
在原有返回字段上增加:
| 输出字段 | 含义 |
| --- | --- |
| `upload_pending_count` | 等待上传的通话数 |
| `extracting_count` | 已上传,尚未读取到记忆结果的通话数 |
| `synced_call_count` | 已读取到记忆结果的通话数 |
| `error_code` | 最近的同步错误码;后台继续重试 |
`SYNCED` 表示已取到记忆,不代表火山保证提取全部结束;没有新事实的聊天可能一直没有提取结果。平台保留聊天记录,不会凭空编造记忆。
## 当前边界
- 长期记忆由模型提取,可能遗漏或理解错误,家人可以纠正。
- 纯 S2S 每通开始时带入近期有效记忆,当前沿用约 1000 字符的上下文预算;并非把全部历史聊天无限塞入模型,也不是通话中随问随查全部历史的检索系统。
- 单通上传最多 1000 条文字消息;超限会保留原文并标记 `TRANSCRIPT_TOO_LARGE`,不静默截断。
- 回调只有收到的文字才能入库;中断造成的缺失字幕无法恢复。迟到回调按新的文字版本补传,同一会话重试使用稳定云端会话 ID。
## 已做的验证
- 独立测试数据库:上传失败重试、重复读取去重、跨老人结果排除、自动记忆在家人页面可见、下一次聊天带入记忆、修改同步、删除不复活、撤回授权清理。
- 火山真实接口使用独立测试身份完成上传、提取、更新、单条删除和按测试身份清理;未写入真实老人测试事实。
- 设备接口和固件均保持不变。
## 上线结果(2026-09-16)
- 后端版本 `0.93.1-rc.memory1` 已部署到 `82.157.201.114`,数据库迁移到 67;切换前已备份。
- API、callbacks、worker 均正常运行,健康检查通过。
- 当前 COM6 绑定的老人“姥姥”:4 通有效历史对话完成补传,生成 6 条记忆,6 条同步任务可见,无上传错误。另有 6 通缺少用户或助手正文的通话被跳过,未编造记忆。
- 后端全量 Go 测试通过;4 个核心模块使用隔离数据库完成集成测试;小程序 97 项测试通过,原生 WXML 检查家人资料和家庭成员页面正常。
- 火山真实测试仅写入独立联调身份,测试事件已删除并查询确认无剩余。
- 小程序状态提示改动在 `C:\Users\19926\WeChatProjects\voicelink`,需要重新编译/上传体验版才会出现在手机上。
- 尚未代替用户进行上线后的实体设备主观语音记忆验收;自动化测试已验证新通话配置会带入该老人的记忆。
接口参考:[上传会话 AddSession](https://www.volcengine.com/docs/84313/1783353?lang=zh)、[事件检索](https://www.volcengine.com/docs/84313/2100967)。
+21
View File
@@ -0,0 +1,21 @@
# 回忆录阅读模式与配乐发布 — 2026-10-07
用户明确要求“上线”后,于北京时间 14:06 发布 Web `0.99.54-rc.memoir-modes1`。入口为 https://82.157.201.114/?section=memoirs,登录后进入回忆录。
客户可切换精装书、画卷阅读、年代浏览,沿用同一份回忆录与鉴权插画接口。切换保留当前章节,浏览器按长者记住阅读模式。新增纸页微光、桂香旧院、岁月慢行三段配乐,支持选曲、暂停和音量调节,默认关闭;跨阅读模式保持播放,离开阅读器或页面隐藏时停止。移动端使用底部选曲面板。
配乐由项目乐谱与合成脚本生成,没有使用第三方歌曲、录音或乐器采样。生成记录、音频统计和 SHA-256 保存在 `web/design/memoir-modes/music/score-manifest.json`,脚本为 `web/scripts/create-memoir-music.py`。这份来源记录不构成绝对零版权风险或独占版权承诺。
发布以线上 `0.99.53-rc.event-time1` 的冻结源码为基线,仅应用 14 个回忆录相关文件,在隔离候选中构建。其他 134 个既有 Web 源文件与基线一致,没有将工作区其他未发布改动打包。虚构预览正文、院落和车站示例图未进入生产资源。
验证结果:
- 隔离候选 TypeScript、Vite 生产构建、4 项回忆录分组与偏好测试通过;本地桌面、手机、三种模式和三个音源播放验收通过。
- 线上容器与公开 HTTP 响应共 154 个静态文件的 SHA-256 完整匹配;入口、回忆录路径、维护中心与登录路径返回新构建,runtime-config 保留。
- 三段线上 MP3 内容哈希匹配,`audio/mpeg` 正确,Range `bytes=0-1023` 返回 206 与 1024 字节。
- API/worker 就绪,API 保持 `0.99.53-rc.event-time1`。除 Web 外所有服务的容器 ID 与启动时间保留。数据库保持迁移 77、dirty=false,没有执行迁移或写入业务资料。
- 实际浏览器访问线上入口正常进入登录页,并加载新主包 `/assets/index-DIbSVs-X.js`。当前浏览器没有生产登录会话,因此本次没有声称完成登录后真实家庭回忆录的线上交互复查。浏览器直接打开 MP3 被客户端拦截,线上音频交付检查以 HTTP 哈希、MIME 和 Range 验证为准;解码与播放已在本地相同音源中验证。
发布回执:服务器 `/opt/data-terminal-cloud/releases/0.99.54-rc.memoir-modes1/production-receipt.json`。本地发布包、完整资源清单、构建日志与回执位于 `E:/Codex/story/.local-evidence/memoir-modes-20261007`。发布包 SHA-256 为 `ca831be206756cba7b87b694c119e7223a26186133d1d59f41902eaaed7004ce`。
Web 使用独立冻结 Compose `/opt/data-terminal-cloud/release-backups/0.99.54-rc.memoir-modes1/web-forward.compose.json`。上一版 Web 镜像为 `data-terminal-cloud-web:0.99.50-rc.memoirs1`,对应回滚 Compose 为 `/opt/data-terminal-cloud/release-backups/0.99.50-rc.memoirs1/web-forward.compose.json`;回滚只重建 Web,并重新校验及加载网关。此前完整源码版本为 `0.99.53-rc.event-time1`。私有 Compose 和环境配置仅保留在服务器。
+19
View File
@@ -0,0 +1,19 @@
# 聊天整理与回忆录(2026-10-06)
聊天总结和事件分类默认使用火山 `deepseek-v4-1-flash-260910`。回忆录正文使用 `doubao-seed-evolving`,插画使用 `doubao-seedream-5-0-pro-260628`。插画在页面标为 AI 情景插画,不是历史照片。
“日常”的标准是聊天当天已经发生或正在发生的事。今天提起的往事、习惯、人物资料、未来计划和未动手的事项均排除。分类有单独的时间审核,并要求逐字原话证据。日常页面只展示该分类结果。
过去每件独立事件形成一个故事,提供原话来源。模型比较同一长者的已有故事:仅换措辞不更新;同一事件有新增事实则保留原有正文,追加新事实与来源,记录新版本。模型不能覆盖人工修订。故事更新后旧回忆录不再作为当前版本展示。
客户菜单增加“回忆录”,可以导入带“我:/豆包:”标记的聊天、查看故事原料和当日日常、生成或重新生成图文回忆录。生成任务异步执行,格式错误最多重试三次,每次把全部可用故事交给模型,统一生成整本回忆录;正文通过验证后立即保存展示,随后完成各章配图。章节采用“日期与标题 → 完整正文 → 插画”的连续阅读排版。配图请求有并发限制。图片保存在私有数据库中,每次访问检查账号权限、当前授权和故事来源版本。
迁移 77 添加原生聊天导入、日常事件、故事事件编号、来源关联、生成任务和回忆录数据。导入聊天具有自己的来源标记,不伪造设备通话;确认故事、修订、导出、分享与删除都支持该来源。源文或授权失效后,相应回忆录不可读取。
使用统一火山账户文件时,`VOLC_ACCOUNT_CONFIG_FILE` 的配置优先于普通环境变量。正式部署保存独立的 `volc-account-memoir.env`,保留原账户的语音、设备和录音配置,仅配置聊天整理模型及本次提供的 Ark key。密钥存于服务器私有目录,不写入源代码、浏览器或日志。
验证:隔离 PostgreSQL 中测试事件拆分、幂等、同事件补充、来源删除、跨账号权限、授权撤回和回忆录版本失效;真实 DeepSeek 重复讲述验证故事数量、ID、版本均不增加;真实模型生成正文和插画;桌面 1440 与手机 390 宽度无横向溢出。
本次用户提供的测试聊天只导入手机号 18642041700 下“姥姥”的故事来源,不据此修改长者资料。
正式页面验收:18642041700 下姥姥的既有成品 11 章、11 幅插画均可访问,1440/390 宽度通过,未登录读取被拒绝。全部故事统一生成及严格 JSON Schema 已部署;本次新流程的真实重新生成被火山 AccountOverdueError 阻断,需充值后继续验证。当前展示此前成功保存的成品。
+31
View File
@@ -0,0 +1,31 @@
# COM6 麦克风上传丢字排查
## 复现与定位
用户测试原话:“没有什么事儿,我今天带孩子出去旅游了”。
2026-09-26 20:32(北京时间)的云端会话只留下“了”和“没有什么,我今天。”,助手随后针对残句回答。因此不是聊天记录页面单独漏显示。
随后设备观察窗口中,上行每约 2 秒送出 34–35 个 60 ms PCM 帧,发送返回错误为 0;这只能证明采集回调持续和 SDK 接口未报错,不能证明远端收到了完整声音。采集前两路麦克风都有信号。现有云端 S2S `asr.extra.end_smooth_window_ms` 为 1200,打断开启。本次不修改断句参数。
发现 RTC 外部 Opus 发送配置仍为 `s_sample_rate=16000`、`s_samples_per_frame=320`。接收端已在此前修复为 48000/960。实际编码器输入为 16 kHz 单声道 PCM,每包 20 ms;Opus RTP 的时钟应为 48 kHz,每包 960 ticks,而不是 PCM 样本数 320。
本地 SDK 符号/反汇编显示 `createLiteRtpTransceiver` 将 `globalGetAudioSampleRateSent` 结果传给音频发送流配置,因此该配置也用于传输层,不能仅按 PCM 输入频率设置。依据:[RFC 7587 §4.1](https://datatracker.ietf.org/doc/html/rfc7587#section-4.1)。实际丢字是否由此导致仍需修正后的真人对话确认。
## 修改
- 固件版本 `1.7.0-controls.11`。
- RTC Opus 发送时钟改为 48000/960,与接收端一致。
- 保留独立编解码器 16 kHz、单声道、20 ms 和麦克风增益。
- 更新实际 C 分帧及运行时 JSON 回归检查。
## 验证与恢复
- ESP-IDF v5.5.1 编译成功,应用大小 `0x394920`,小于 4 MiB 分区。
- 主机 ASan/UBSan 分帧、碎片输入、错误返回和 JSON 配置检查通过。
- 应用 SHA256:`73ea6d5ed0902873b61a9af742c56dab83d1d70a2aa2ddd2e64dc0cd9e8c23bb`。
- 实机读取分区表确认 A/B 各 4 MiB;otadata 序列 1、VALID、CRC 正确,当前为 ota_0(0x10000)。
- 原应用备份及安装日志位于被 Git 忽略的 `.tmp-microphone-fix/`。只更新应用,保留分区表、otadata、Wi-Fi 与工厂绑定数据。
- 已安装到 COM6,esptool 写入 3,754,272 字节并校验哈希成功。原 ota_0 全分区备份 SHA256:`9aef5ffa4a98d02aedf62052d74a34ec844e90073f731a079cafd3669c6b7df0`。
- 2026-09-26 20:45:33(北京时间)平台已收到 `1.7.0-controls.11` 上报,目标/实际配置版本均为 19,扬声器音量 56。
当前真人复测尚未完成,不将“编译成功”视为语音识别问题已经解决。
+31
View File
@@ -0,0 +1,31 @@
# 小程序直连与自动蓝牙配网
小程序目录:`C:\Users\19926\WeChatProjects\peibanzhushou`,AppID `wxfead7715305e1115`。
开发后端:`https://121.41.46.238`。使用已有服务器,不使用云开发。
## 用户操作
1. 登录家庭账号,打开设备 → 添加设备。
2. 长按设备屏幕底部 3 秒,搜索并选择 EchoEar。
3. 输入 2.4 GHz Wi-Fi 名称、密码,点击绑定设备。
4. 按设备屏幕提示轻点确认,等待绑定完成。
不再要求用户输入设备编号、标签配网密码或点击“验证设备身份”。单个使用者自动选择;多个使用者时选择归属。
管理账号可以登录查看管理数据,但不能冒用家庭身份绑定设备。
## 实现
原生登录接口见 `openapi/mini.yaml`。手机号密码和验证码沿用原账号体系;短期令牌由服务器签发并验证,存储中不保留密码。网页 Cookie/CSRF 不变。
BLE 服务和原有 20 字节分片不变:帧为 type:uint8 + length:uint16LE + payload。
新增 type=2 自动连接引导帧;该帧只能作为当前连接的首条请求,且只允许一次。type=0 为原 Security2 握手,type=1 为加密业务 JSON。
引导请求只有 `client_nonce`(安全随机16字节,无填充Base64URL)。设备返回 `device_id,model,key_version,client_nonce,server_nonce,proof`,其中 `server_nonce` 同样为随机16字节。
令 T 为五行 `device_id\nmodel\nkey_version\nclient_nonce\nserver_nonce`,无末尾换行。
proof = HMAC-SHA256(factory_key, "BLE-PROOF1\n" + T),小写十六进制。
session_password = HMAC-SHA256(factory_key, "BLE-PASS1\n" + T),小写十六进制。
服务器核验设备证明和当前家庭权限,通过 HTTPS 返回本次连接的 session_password;设备本地生成对应 SRP salt/verifier。小程序自动完成 Security2。密码不经过明文蓝牙,工厂密钥从不离开设备和后端。
随后自动 get_info,再发送加密的 `cmd:configure,wifi_ssid,wifi_password,bind_token`。平台账号密码绝不发送给设备。设备实体确认、DEVREQ1 签名绑定、持久化与重试保持原实现。云端成功不等于语音服务已开通。
无备案域名期间仅用于开发联调:微信开发者工具关闭域名校验,iPhone 使用真机调试;不能据此视为正式上线可用。正式发布仍需配置合法 HTTPS 域名及微信隐私声明。
+40
View File
@@ -0,0 +1,40 @@
# 小程序设备管理 / BLE v2 交付记录
日期:2026-09-13。
## 已交付
- 小程序:`C:\Users\19926\WeChatProjects\peibanzhushou`,公开配置版本 `0.3.0`,AppID `wxfead7715305e1115`。
- 线上后端:`https://121.41.46.238`,`0.86.0-rc.mini2`;源码存档和发布清单保存在 `/opt/data-terminal-cloud/releases/0.86.0-rc.mini2`。
- 设备:COM6,`KP01-B43A451634A8`,`ECHOEAR-S3`,仅烧录 app 分区 `0x10000`,保留工厂身份、NVS 和资源分区。
- 固件:`firmware/echoear-platform-agent/build/VolcRTCDemo.bin`,3146016 字节,SHA256 `68f1dd9f8314079695e47c8940b05f95fb849339bcaf14955a496437143d9199`,烧录校验成功。
- 升级前固件备份:`.tmp-mini-v2-release/before-v2-app.bin`,3211264 字节;前 3175712 字节 SHA256 为已验证的旧版本 `261d68285c062acde5927690667a85dedd5d19c6b88ff696f235f8cb0de3d6ea`。
## 实现内容
1. 蓝牙改为两字节长度头 + 明文 JSON,20 字节分片。新小程序 bundle 不再包含 Security2、密码握手或 cipher/hash 实现;旧加密实现只保留为独立历史参考测试。
2. 点击设备和发送前都查询真实云端绑定状态,校验当前账号管理权限。未绑定申请 bind_token;已绑定传 `""`,设备只读检查现有绑定并保存 Wi-Fi。
3. 设备卡片增加信息查看、重新配网、解绑影响检查和二次确认,复用后端原有 `binding-change` 接口。无历史数据删除或自动解绑。
4. 双击触摸屏可从非语音运行状态进入配网;原触摸屏底部长按入口仍保留。BLE 窗口过期后双击可重新开启。首次升级自动进入 v2 配网,完成后记住协议版本。
5. 按 Product Design 的现有产品改版约束保留绿色视觉,加入 ImageGen 家庭插图、Lucide 图标、导航/设备/账号/记录样式和真实空状态。
6. API 文档:`docs/device-mini-ble-v2.md`;小程序目录同步副本 `docs/device-api.md`。机器可读接口补充于 `openapi/mini.yaml`。
## 验证
- `go test ./...` 通过。
- 隔离 PostgreSQL / Redis 集成测试:onboarding、httpapi、saas 全部通过;测试数据库不使用线上生产数据库。
- 小程序 `npm test`:29 项通过(包含独立旧加密历史测试,不进入新 bundle)。新增测试覆盖空令牌、权限查询失败不发送密码、解绑确认/取消/阻塞和真实资产完整性。
- 小程序 `npm run check`、`npm run check:native` 通过:8 个原生页面、WXML/WXSS、事件处理函数及路由检查。
- 生产 C 函数测试:明文 endpoint 的绑定/仅配网、重复提交、内容冲突、失败重试、超时和非法 JSON;ASAN/UBSAN 通过。
- 原有 22 项 Wi-Fi 生命周期/故障注入测试及 SDK 敏感日志检查继续通过。
- ESP-IDF 5.5.1 编译成功,app 分区剩余约 40%。
- 线上候选服务首次启动对象存储连接超时,未切换线上;重试候选检查成功后切换,API / worker / callbacks 健康。未改变运行密钥或 schema 63。
- 线上无凭据的新小程序接口返回 401;使用本设备工厂 HMAC 调用新只读接口返回 `bound:false`。
- 真实 BLE 实测搜索到 `EchoEar-1634A8`,GATT `get_info` 返回设备编号/型号/version=2,`get_status` 返回 idle,分片读取通过。
## 尚需用户操作的验收
- 微信开发者工具服务端口关闭,CLI 自动截图未执行成功;没有用模拟截图冒充原生页面验收。
- 需要用户重新编译小程序、输入 Wi-Fi 并轻点设备屏幕,完成手机端首次绑定 → 重新配网 → 解绑的实机体验验收。此次没有替用户发送真实 Wi-Fi、创建新绑定或解除新的绑定。
- 原有 IP 地址/未备案域名的微信真机调试与正式发布限制仍存在,未通过更改安全设置绕过。
- 旧网页 Security2 配网组件不能操作新 v2 固件;以此次新小程序配网为准。
+32
View File
@@ -0,0 +1,32 @@
# 完整对话录音发布与实机验证(2026-09-27)
## 已发布
- Web:`0.99.15-rc.mixed-recordings1`。
- API:`0.99.16-rc.record-ready1`,前一版为 `0.99.15-rc.mixed-recordings1`。
- 设备固件:`1.7.0-controls.12`,COM6 应用分区写入并通过哈希校验,线上已上报此版本。
- 故事列表保留此前已上线的书本图标。
## 行为
设备获取 RTC 房间后、加入房间发布音频前,使用现有设备认证请求平台准备云端录制。平台校验设备绑定、会话、应用、房间及录音同意,调用火山 StartRecord:混流、纯音频、MP3、保存至 TOS。API 等待 GetRecordTask 返回 RUNNING 再响应设备。通话结束调用 StopRecord,另有空闲与最长时限作为兜底。
平台优先查询该会话对应的混流文件,界面显示“完整对话录音”;新版会话等待混流文件时不会显示旧分轨结果。旧版通话继续展示原有分轨文件,并注明旧版录制方式。
声音保持真实时间顺序,由火山混流生成,不在平台下载后拼接。平台只处理控制请求、元数据、权限和短期签名链接;客户从 TOS 直接下载,音频不存入平台、不经平台转发。当前默认 TOS 域名提供下载;在线播放仍需配置可用的自定义域名。
## 验证证据
- 隔离数据库上的 9 项后端及设备会话测试通过;Go vet 通过。
- 8 项浏览器验证通过,包含完整对话单文件、旧分轨、授权、直接链接、移动端和失效清理。
- 固件 ESP-IDF 编译通过,应用大小 3,754,928 字节;实际 C 代码主机测试通过 ASan/UBSan。
- 使用真实火山 API 的独立空房间验证:录制进入 RUNNING 用时 513ms,随后停止成功。没有用户音频。
- 用户于北京时间 2026-09-27 10:03–10:04 完成真实设备对话并结束。会话 `05673ed2-9642-49ac-80ef-340052184e85` 为 ENDED,mixed_recording=true。
- 使用真实家庭账号对应权限查询平台服务,返回 READY、mixed=true、**1 个 DIALOG 文件,65 秒,1,044,138 字节**;can_download=true,can_play=false,playback_ready=false。
- 发布前检查没有进行中的设备通话;发布清单校验基线源码和二进制哈希、Compose 差异。`.16` 仅替换 API,Web、callbacks、worker、gateway 与数据容器不变。匿名录音接口访问返回 401。
验证仅访问录制控制接口及文件元数据,没有下载或试听用户音频。因此已确认混流任务和单文件生成,具体声音内容及听感仍以客户下载试听为准。
## 发布记录
线上 `.15` 目录内 `mixed-recordings-release.json` 记录 API/Web 发布;`.16` 目录内 `record-ready-release.json` 记录 API 启动等待改进与前版镜像。固件备份与编译、刷写日志保留在本地 `.tmp-mixed-recordings/`。
+31
View File
@@ -0,0 +1,31 @@
# 聊天直接检索火山 Viking
用户确认的分工:聊天智能体从 Viking 检索历史记忆;平台回忆提取用于分类、整理故事和回忆录。聊天启动不再把 `curated_memory` 或平台故事摘要加入系统提示词。
## 配置
- `MemoryConfig.Enable=true`,`Provider=volc`,当前库为 `aiphone`,事件规则为 `event_v1`,每次最多召回 3 条。
- `user_id` 保持 `u_ + SHA256(tenant_id + ":" + subject_id)`,`assistant_id` 保持 `platform_companion_v1`,与已有聊天上传一致。换设备不换记忆身份,不导入或重新上传历史数据。
- 创建新智能体和每次准备新聊天时,核对当前老人、语音授权和个性化记忆授权。授权撤回时下发 `Enable=false`,不保留检索过滤器。
- 火山配置读回校验包含完整的库名和用户过滤器,不能只看 Enable。
- 聊天结束后的字幕上传、Viking 提取与平台回忆整理继续沿用原流程。
平台的称呼、交流偏好和避免主动提及的话题属于聊天约束,不属于回忆提取正文。这次不发布尚未上线的完整人设同步功能。
## 火山访问权限
核查发现 `VoiceChatRoleForRTC` 原来没有 Viking 记忆访问策略。新增自定义策略 `StoryRTCVikingMemoryReadOnly`,仅允许 `MemoryCollectionInfo`、`MemoryEventSearch`、`MemoryProfileSearch`、`MemorySearch`、`MemorySearchUser` 和 `ml_platform:VikingDBAPIRead`。不授予写入、删除、密钥读取权限。该角色的其他策略保留。
该只读策略使用账户范围 Resource `*`;具体每位老人的范围由智能体中严格核对的用户过滤器确定。策略绑定和火山智能体配置读回成功并不等于实体语音召回已验收。
## 验证边界
隔离测试智能体成功保存并读回原生 Viking 配置。使用软件端 `StartVoiceChat` 验证硬件应用时返回 `NoPermissionForApp`(硬件 RTC 应用不是软件 AI agent 类型),因此未用此接口证明硬件语音召回,也未变更 RTC 应用类型。
本次服务端验证包括原生过滤身份、跨租户与跨老人隔离、撤回授权关闭检索、平台回忆不进入聊天、原有开场和会话幂等。实体设备还需通过一次只存在于 Viking 的事实提问确认实际召回;测试事实不能同时出现在人设、开场或平台注入中。
## 上线结果
服务端版本为 `0.99.56-rc.native-memory2`,48 项隔离集成测试全部通过,补充读回测试覆盖火山省略 `Enable=false` 的实际行为。现有 3 个智能体中,2 个满足授权条件并开启原生检索,1 个因授权条件未满足而关闭;均完成云端读回。既有语音配置、欢迎语和服务运行条件保留。更新现有智能体使用完整配置提交,因为火山更新接口会替换整份 Config,不能只提交 MemoryConfig。
参考:[火山智能体 MemoryConfig](https://docs.volcengine.com/docs/6348/1807450)、[原生长期记忆工作方式](https://docs.volcengine.com/docs/real_time_communication/Translationmemorylongtermmemory?lang=zh)。
+17
View File
@@ -0,0 +1,17 @@
# 管理端手机号登录
管理端 `/maintenance` 使用与家庭端相同的手机号登录界面:手机号加短信验证码、手机号加密码;两种方式可以切换,手机号保持不变。密码提交与短信发送前都必须通过图片验证码。未登录访问管理页面时转到登录页,成功后恢复原路径、参数和片段。
用户已明确指定将管理手机号 `18941657635` 绑定到原 Keycloak `admin`。已核实其现有账号、身份签发者和 subject,沿用 `ops_admin` 角色;不新建管理员、不修改家庭端 owner。初始密码通过离线 CLI 的标准输入设置,不写入源码或部署记录。
迁移 62 新增独立管理端凭据、匿名登录、短信挑战和账号会话表。HTTPS 下分别使用 `__Host-platform-operator-login` 与 `__Host-platform-operator` Cookie。管理端和家庭端密码、会话、CSRF 分开;修改管理密码撤销其他管理会话,保留当前会话及家庭会话。管理端禁止自助注册,短信登录仅接受预先绑定且有效的管理身份。
管理 Cookie 可用于 `/api/v1/ops/*`;读取 `/api/v1/me`、`/api/v1/events`、管理内容模型接口时使用 `X-Login-Portal: operator`。该头只选择认证方式,权限仍由数据库中的有效账号、角色、租户和接口权限校验决定。写接口仍要求同源与 CSRF,原 Bearer 认证继续兼容。
管理账户菜单的“登录与安全”支持修改密码。短信验证后可重置密码;短信通道未接通时只能使用密码登录,页面不会显示虚假的短信发送成功。
验证:迁移后的独立 PostgreSQL、Redis 集成测试通过 114 个顶层用例;浏览器回归通过 39 项检查(管理端 7 项、家庭短信 11 项、密码 9 项、登录路由 12 项)。包括跨端身份隔离、禁止创建未绑定管理账号、图片验证、CSRF、密码变更及会话撤销、禁用管理员立即失效、返回原页面、刷新和移动端布局。短信测试使用测试网关,未向真实手机号发送短信。
发布版本为 `0.83.1-rc.operator-login-fix1`;线上实际验证和部署记录保存在该发布目录。当前短信供应商未配置。
线上回归发现独立注册的告警路由仍只接受 Bearer,已补齐管理 Cookie 认证并添加实际路由回归测试。用户后续明确指定管理账号密码,已通过一次性、限定原 admin 身份和完整手机号摘要的离线操作更新;原管理会话已撤销,公共修改密码策略不变。密码不写入发布记录。
+34
View File
@@ -0,0 +1,34 @@
# 全页长列表分页设计 — 2026-10-05
已按用户确认的预览方案接入现有页面,未发布。沿用现有墨绿强调色、表格、卡片与导航。
## 设计与范围
- 表格和普通列表默认每页 10 条;设备、故事卡片及长者名单默认 6 条;待办和版本详情默认 5 条;长聊天文字默认 20 条。
- 桌面显示数量、当前范围、每页条数、页码、省略号和跳页。窄栏及手机显示每页条数、上一页、当前页与下一页。短列表无需分页时不显示控件。
- 家庭端涵盖聊天、日常/故事记忆、长者、成员与家庭动态、陪伴设备、分享、数据请求、导出和服务用量。维护端涵盖设备、固件、模型、用量、价格版本与额度历史,同时覆盖已有配置、告警、诊断和内容质量组件中的动态列表。
- 菜单、固定授权选项、图表和少量概览不分页。各个业务列表独立保存页码,筛选、对象或所属范围改变时重置;删除导致页码越界时回到有效末页。
- 设备表跨页勾选保留;长者名单方向键、Home、End 可跨页选择并聚焦。
## 数据范围
沿用现有接口与数据请求,未修改后端。完整数组在前端分页;游标接口显示“已读取”数量,保留“读取后续记录”,新读取的数据也分页显示。不会将已读取数量当作后台总量。记忆搜索仍限于已读取内容;无匹配时仍可读取后续记录。
## 本地预览
- 家庭端:`http://127.0.0.1:5176/pagination-preview.html?state=pagination&section=members`
- 维护端:`http://127.0.0.1:5176/pagination-preview.html?state=pagination&screen=maintenance&area=devices`
预览使用仅 DEV 的示例数据。37 条成员/设备/模型/固件等记录,67 条聊天,120 条记忆;生产入口不加载预览代码。页面导航保留示例参数。
## 验证
TypeScript 与 Vite 生产构建通过,保留既有大包体积提示。浏览器操作验证设备末页 7 行、每页条数切换、跳页、搜索复位;记忆列表翻页、无搜索结果时继续读取、清空搜索复位、追加读取通过。手机尺寸下页码与跳页隐藏,显示紧凑翻页条;设备页 document.scrollWidth 与 clientWidth 相同。
初次预览的 Chrome 截图持续超时;确认实施后,内置浏览器截图可用,已补充设备分页截图并修正分页按钮被全局样式覆盖的问题。内置浏览器验证设备末页 7 行,以及勾选首行、翻到末页、返回首页后仍保持勾选。尚未完成所有页面的逐页截图对比,尚未部署到线上。
## 确认后的实施范围
确认后仅补充分页容器对原有 HTML 属性和列表标识的保留、维护账户切换组织时的页码复位,以及分页内部的当前页颜色区分。本次未编辑 API、认证入口或业务提交回调。
工作区含其他工作产生的家庭资料和隐私相关修改。批准预览的快照检查曾通过,随后整体哈希检查观察到这些文件变化;保留原有快照和检查结果,不将整个工作区的差异视为分页修改,也未回退其他工作。
+19
View File
@@ -0,0 +1,19 @@
# 分页与长者选择器发布记录
2026-10-05,用户授权上线后,已发布 `0.99.46-rc.pagination-elder1`。
- 入口:`https://82.157.201.114/`
- 当前目录:`/opt/data-terminal-cloud/releases/0.99.46-rc.pagination-elder1`
- Web 镜像:`data-terminal-cloud-web:0.99.46-rc.pagination-elder1`
- API 保持:`data-terminal-cloud-api:0.99.45-rc.usage-sessions1`
- 数据库 schema 保持 74,dirty=false;未运行迁移,未写业务数据。
发布内容仅为长列表分页和用户端长者选择界面。长者筛选采用长者资料页的头像与姓名名单,首页通过侧边抽屉选择;原来的选中对象、接口、权限处理及业务操作保持原样。分页支持页码、条数、跳转,游标列表沿用原来的后续记录读取接口。
候选从当前线上源文件构建,只合入 22 个范围内文件;工作区其他未上线改动没有打入此次发布。包仅包含 22 个源码文件、入口 HTML、新 JS/CSS 和 Dockerfile;继承原 Web 镜像与已有资源。
验证:TypeScript 和 Vite 生产构建通过;候选浏览器验证记忆翻页、长者切换后复位、键盘跨页及手机宽度;生产浏览器登录入口加载新 JS/CSS 且没有控制台错误。登录后的业务操作没有在生产数据上重放。服务器校验 133 个静态文件、入口路由、运行时配置及 API/worker 健康状态,其他服务容器 ID 与启动时间保持原样。
证据位于工作区 `.local-evidence/pagination-release-20261005/`,包含 baseline、候选、源码范围与回调核对、检查记录、发布包、部署日志、production-receipt.json。服务器发布目录也保存 production-receipt.json。
上一版目录为 `/opt/data-terminal-cloud/releases/0.99.45-rc.usage-sessions1`,上一 Web 镜像为 `data-terminal-cloud-web:0.99.45-rc.privacy-roster1`。部署脚本包含失败后的 Web 回退;成功发布后保留上一版目录和镜像。后续发布必须读取当前 receipt 的完整 Compose 层链,不能只沿用旧 Web 容器之前的层链。
@@ -0,0 +1,50 @@
# 权限影响提示与快捷授权
本次修改针对网页家庭入口。使用现有授权接口,未修改服务端权限规则,未为任何真实用户开通授权。网页已发布到 https://82.157.201.114/,版本 `0.99.42-rc.permission-impact1`。
## 数据用途授权
| 授权 | 缺少时的影响 | 依赖与边界 |
| --- | --- | --- |
| 保存文字记录 | 新聊天文字不能保存,无法从这些文字生成故事、摘要或记忆 | 通话条目仍可存在;故事和记忆另需相应授权。未入库文字不能凭补授权恢复 |
| 保存原始录音 | 新聊天不生成可回听、下载的录音;已有录音访问也受当前有效授权检查约束 | 设备与云端录制能力仍需支持;播放和下载是两种独立权限。补授权不能生成过去未录制的音频 |
| 整理故事与摘要 | 新聊天不自动整理为故事候选或摘要 | 需要文字保存;候选仍需家人核对。不会自动补齐历史故事 |
| 个性化记忆 | 新聊天不使用长期记忆,也不从聊天生成并同步新记忆 | 生成新记忆需要文字保存;故事摘要需要故事整理。家人的记忆访问权另行管理 |
| 家庭资源分享 | 尚未记录家庭分享的数据用途授权,家庭角色也不会自动创建资源分享 | 开启此项目不自动开放任何资源,仍需在内容分享中指定资源、接收人、用途、能力和有效期 |
设置路径:**长者资料 → 选择当前长者 → 资料与授权 → 新增分项授权**。家庭所有者、管理员、编辑者可以新增分项授权;查看者看到明确的联系对象和设置路径。
说明依据:`internal/saas/service.go` 的 `GrantConsent` 校验 `subject.write`;`internal/callmemory/events.go` 检查文字保存;录音、故事提取、记忆处理分别检查自身授权。现有 `family_sharing` 是单独记录的数据用途授权,当前分享服务主要依据具体资源分享授权,并不统一以该字段拦截全部资源分享。因此页面没有断言“未开通家庭分享就绝对无法分享”;本次没有扩大为服务端分享策略变更。
## 内容访问与家庭角色
- 设备聊天文字:家庭所有者默认保留访问权,其他角色需由所有者在 **故事与记忆 → 记忆授权** 同时开通“查看”和“敏感记忆”。权限说明依据 `callmemory.ResolveAccess` 与 `records.go` 的敏感内容校验。
- 记忆:查看普通记忆、修改记忆和查看敏感记忆分别管理;修改影响新增、编辑、删除、使用范围调整。只有家庭所有者可授予这些访问权限。
- 录音:播放与下载分别授予,所有者也不自动拥有录音访问权。现有聊天录音提供“授权本账号播放/下载 24 小时”快捷按钮;指定其他成员由所有者或管理员在早期聊天详情的“聊天语音 → 显式授权”操作。
- 家庭角色:成员管理、设备管理、审核故事、隐私请求依角色开放。权限对照明确区分管理能力、数据用途授权和具体内容访问权;个性化记忆授权不能替代家人的记忆访问授权。
## 页面行为
首页、聊天记录、故事与记忆和内容分享按功能显示缺失授权;长者资料和数据与授权显示完整五项状态、影响及依赖。创建长者的授权步骤使用相同说明。
有权管理的账号在提示旁点击“去授权…”打开当前长者与单项用途的确认窗口。必须勾选告知说明才能提交,取消不写入;提交包含现有隐私政策版本、告知摘要和用途范围。不会自动捎带开通文字保存等其他项目。
授权读取独立于故事、事实、隐私请求等数据读取。失败显示“状态未知”与重试,避免将请求失败显示为未授权;切换长者或登录身份立即屏蔽旧状态并关闭旧窗口。授权成功刷新状态与录音组件。
聊天记录的 `PARTIAL` 显示为“文字不完整或未保存”,避免零条文字时宣称“已保存部分文字”。当前授权状态不被当作某次历史通话缺失的已确认原因。无正文时分别提示当时可能未授权、文字未送达或数据已清理。
## 验证
- 网页 TypeScript 检查、生产构建通过;30 项既有单元回归与 2 项授权有效期、撤回和用途隔离检查通过。
- `test/e2e/consent-guidance.mjs` 使用真实家庭工作台与合成响应,验证缺失权限说明、取消零写入、显式单项授权、依赖功能、长者切换隔离、读取失败重试、查看者限制、聊天和录音独立权限说明与手机布局。
- 既有故事来源聊天分页与撤权清空正文、录音播放下载与撤权清空链接回归通过。
- 记忆操作回归通过:编辑期间保持核对过的版本、删除范围确认、所有者访问权不可关闭、手机布局及撤权清空正文。旧测试的“修正/删除”按钮定位已按现有详情页的“编辑记忆/删除记忆”及折叠区域入口更新。
- 截图保存在 `.design-evidence/consent-guidance/`,只包含合成资料。
本次仅部署网页。小程序未更新;历史未保存文字与未生成录音没有恢复。
## 发布验证
基于线上 `0.99.41-rc.consent-design1` 构建独立发布包,只叠加 7 个权限提示相关源文件,保留最新的长者创建授权样式。实际发布包重新通过生产构建、32 项单元检查和权限、录音、记忆三组界面回归。
线上逐一核对 126 个静态文件和入口路由,运行时配置哈希一致。API 与 worker 健康检查通过,其他服务容器与启动时间、数据库版本保持一致,没有执行迁移或业务数据写入。匿名浏览器检查确认新资源加载,桌面与手机入口正常,无脚本异常或静态资源失败。发布凭据与截图见 `../../.local-evidence/permission-impact-20261005/`(工作区证据目录)。
+44
View File
@@ -0,0 +1,44 @@
# 2026-09-13 手机号登录发布记录
已部署 `0.82.0-rc.phone-login1`:<https://121.41.46.238/login>。
当前目录为 `/opt/data-terminal-cloud/releases/0.82.0-rc.phone-login1`,`/opt/data-terminal-cloud/current` 已切换。API、worker、callbacks 和前端使用本次镜像,保留服务器的供应商、短信开关、OIDC、对象存储及密钥配置。当前服务器沿用既有单机演示部署模式。
## 发布内容
- 家庭端独立登录页、未登录路由保护、原页面返回。
- 手机号验证码 / 手机号密码两种登录方式,同页切换;密码登录带后端校验的图片验证码。
- 个人中心设置与修改密码、短信验证后重置密码。
- 前几轮累计的页面文案清理,以及 CALL-MEMORY-1.1 服务端与记忆管理页面实现。
- 数据库从 58 升至 61,迁移状态正常。新固件没有在此次发布中刷入设备;完整 RTC 设备链路的未完成项仍以 CALL-MEMORY-1.1 实施记录为准。
## 验证
发布前全量 Go 测试、go vet、前端生产构建通过。11 个模块的隔离数据库集成测试共 186 项顶层测试通过;另有一项依赖真实内容模型的可选测试跳过,未冒称验证。手机号登录浏览器回归 9 项和记忆工作区回归 5 项通过。
线上 API、worker、callbacks 健康接口通过,公网 HTTPS 版本接口返回本次版本。真实公网浏览器检查 9 项通过,包括未登录跳转、双模式切换、PNG 图片刷新、安全 Cookie、缺失图片校验拒绝、CSRF / 跨源拒绝、密码管理鉴权及手机端布局;浏览器无页面异常。检查使用正常 TLS 校验。
已按用户要求将 `+86186****1700` 绑定到 Keycloak 中既有的 `owner` 身份,并设置随机初始密码。管理命令核对账号 ID、issuer、subject,事务内写入手机凭据映射和审计事件;不创建新的业务账号、不调整家庭权限,也不将此次人工绑定记作短信验证。已有绑定或不同密码不会被命令覆盖。绑定命令及相关隔离数据库集成测试通过。
通过真实公网浏览器输入图片验证码后,手机号密码登录成功。核对身份仍为既有 owner,原家庭 owner 权限可访问;原页面参数和锚点恢复、刷新会话、退出登录均通过,浏览器无页面异常。初始密码不写入发布记录、源码或验证材料。
**线上短信仍关闭,未发送真实短信。** 密码修改与会话撤销已在本地真实服务及隔离数据库中验证,线上此次仅验证登录、原页面返回、刷新与退出。
## 备份和恢复
发布前完整加密备份:`/var/backups/data-terminal-cloud/platform-20260913T000247Z.tar.gz.enc`,已进行隔离数据库恢复及 Redis、对象存储、Keycloak 文件校验。备份时数据库为 `58|false`,2 个租户、6 个账号。
旧版本 `0.81.0-rc.onboarding1` 保留,未删除数据或执行数据库回退。上线后的新数据不可用旧备份直接覆盖;涉及通话或记忆新协议的运行状态应优先前向修复。
修复了备份脚本缺少执行权限的问题。新版本目录内的服务器脚本已恢复 Unix 换行和执行权限,避免 Windows 打包后 systemd 报 203/EXEC。
回调服务不负责设备绑定,因此部署覆盖层显式关闭该服务的 ONBOARDING、激活和短信开关;API / worker 保留原配置。上线时发现并修正了此配置继承问题,最终三个后端服务均正常。
## 可追溯材料
- 源码基线:`98fb2965d0898cbe08bda36df3bb99e00ed32626`,包含未提交工作区改动,以文件清单及哈希为准。
- 源码包 SHA-256:`22f514f5f187a2d72d3a18d33023b5859db4b82bd9b747cc452d78dacefd6d9f`。
- 运行文件包 SHA-256:`b5605eee5b2edd66aa5ef19822c85fe1e54c5563f9ec3de85dba40a9df83fb0c`。
- 服务器保存 `source.zip`、`release-files.json`、构建日志、迁移日志和健康检查结果。回调服务的最终部署配置及本发布记录另行留档。
- 本地验证材料:`.tmp-online-phone-release/`;未上传 `.env`、SSH 私钥或本地数据库。
- 后续 owner 手机号绑定材料:`.tmp-owner-phone-bind/verification.json`。绑定命令是发布后单独运行的管理工具,未重建业务服务镜像;服务器 `owner-phone-setup/` 保存该工具源码、哈希和脱敏验证结果,原发布包哈希不变。
+27
View File
@@ -0,0 +1,27 @@
# 网页聊天记录、音量状态与日常/故事分类修复
2026-09-26 发布 Web `0.99.12-rc.portal-records1`,基于线上 `0.99.11-rc.spoken-volume1` 源码,仅覆盖七个前端源文件。API、callbacks、worker、gateway、数据库、Redis、MinIO 容器未变;无需固件更新或数据库迁移。
## 原因与处理
- 网页只查询旧版 `conversation`,当前设备聊天在 `call_session` / `call_utterance`。接入已有鉴权接口 `POST /api/v1/memories/calls/query` 与 `calls/turns`,支持列表和文字独立分页、权限错误、请求取消和切换长者隔离。原记录仍可从“查看早期聊天记录”进入,原录音和故事操作保持可访问。
- 音量保存后使用固定成功提示,设备回报不会更新文案。改为设备页面每五秒读取实际配置与在线状态,依据实际音量和配置版本同时匹配显示“已生效”。离线、等待确认、已确认分别展示。提交期间中止轮询,防止旧响应覆盖提交结果。
- 线上只读核对:期望/实际上报音量均为 56,期望/上报版本均为 19;发布后仍持续上报。没有为测试改动真实设备音量。
- 在线连接对号改为绿色,离线保留原状态。
- 用户最终确认页签名称为“日常”和“故事”。EVENT、OPEN_ITEM、INTERACTION 显示在日常;PROFILE、RELATIONSHIP、PREFERENCE、STORY 显示在故事。既有故事保留入口。分类依据现有记录类型,不改写事实内容;家人可编辑类型。
## 当前总结来源
设备直连聊天文字由火山回调保存,结束后后台上传到火山记忆库 `memory`,异步提取 `event_v1`,读取结果中的 `summary` 后保存为可管理记忆。当前自动导入记录统一标记 EVENT,因此显示在日常;这次未追加语义分类模型,也未将旧事实自动重写为人生故事。
只读检查时平台有 15 条 EVENT 记录,9 项记忆同步任务为 SYNCED。旧版 `conversation_summary` 的 150 条记录均标记 `local-deterministic-extractor`;worker 的 `CONTENT_GENERATOR=local`、`ARK_CONTENT_MODEL` 为空。火山托管记忆提取未在平台暴露具体底层模型型号,不能把它断言为某个豆包版本。
## 验证
- 线上基线候选 TypeScript 与 Vite 生产构建通过;已有大包体积警告仍存在。
- `node --test web/test/portal-records.test.mjs`:两项通过,覆盖实际音量/版本不匹配、离线和零音量,以及七种记录类型归类。
- `test/e2e/portal-records-fix.mjs`:七组浏览器检查通过。覆盖当前通话列表/详情分页、手机布局、迟到响应不串长者、权限错误、分类、真实组件中的模拟设备回报与绿色对号、完整家属端聊天和页签。使用合成接口数据,未写入真实家庭。
- 老的 `memory-workspace.mjs` 仍期待已被既有改版移除的“修正”按钮,未通过;未以其代替本轮专项验收。
- 发布检查比对线上 HTML/JS/CSS 哈希、运行配置、未改动服务容器 ID,以及聊天接口匿名请求拒绝。首次探测漏 Origin,第二次环境变量列表顺序比较过严,均自动回退;修正验证脚本后发布成功。
- 发布记录:服务器新 release 的 `portal-records-release.json`。专项截图与检查结果位于 `.tmp-portal-records-fix/evidence/`。
- 未在真实家属登录会话中再次操作音量或逐条浏览私密聊天;线上数据核对为只读查询,交互验证使用真实组件与合成数据。
+50
View File
@@ -0,0 +1,50 @@
# 全页改版发布记录 — 2026-09-20
已发布至 https://82.157.201.114/ 。主版本于北京时间 23:44 上线,设置页账户显示修正于 23:48 上线。
## 当前版本
| 项目 | 发布结果 |
| --- | --- |
| 当前目录 | `/opt/data-terminal-cloud/releases/0.99.1-rc.portal-display1` |
| Web | `data-terminal-cloud-web:0.99.1-rc.portal-display1` |
| API | `data-terminal-cloud-api:0.99.0-rc.portal1` |
| Worker | 保留 `data-terminal-cloud-backend:0.98.0-rc.trusted-login1` |
| 数据库 | 迁移 73,dirty=false |
| JS | `/assets/index-Bdo_d906.js` |
| CSS | `/assets/index-DCoHY0BU.css` |
| 发布基线 | `0.98.3-rc.home-width1` |
## 交付范围
家庭端 18 个页面/状态、维护中心 9 个业务区视觉与响应式调整,新增昵称接口、家庭最近 7 天事件历史接口,以及迁移 `000073_customer_activity_history`。详情见 [实现记录](customer-portal-redesign-20260920.md)。
使用线上源码基线构造独立候选,初始发布叠加 41 个必要源码/资源文件,保留线上认证、短信和验证码实现。没有把含其他任务变更的整个工作目录打包发布。最终补丁只改设置页账户标识:preferred_username 缺失时回退到身份/账户显示名,并继续掩码处理手机号。
首次切换只重建 API 和 Web;Worker、回调、PostgreSQL、Redis、MinIO、网关容器 ID 保持不变,网关执行配置重载。最终显示补丁只重建 Web,API 和数据库保持运行。
## 验证
- 候选 TypeScript/Vite 构建通过;候选 identity、webevents、httpapi 使用独立测试库复验通过。全量本地测试及 14 个服务集成测试见实现记录。
- API 和 Worker 就绪检查通过,API 版本为 `0.99.0-rc.portal1`;API/Web 容器运行且重启次数为 0。
- 数据库为 `73|f`,公开 index、JS、CSS 和插画资源的内容摘要与发布产物一致。
- 新增事件历史接口匿名请求返回 401;昵称接口匿名修改请求被拒绝。
- 真实已有登录会话持续有效。家庭端首页、聊天记录、提醒与待办、陪伴设备、服务用量、设置与权限抽查通过,浏览器无新增 warning/error;手机号登录账户显示修正已复查。
- 未改动真实昵称、权限、分享或删除数据;维护中心真实角色全流程、短信和实物蓝牙未在生产执行。
## 备份与可追溯产物
服务器数据库备份:`/opt/data-terminal-cloud/backups/before-0.99.0-rc.portal1-20260920T234309/database.dump`。目录权限 0700、文件权限 0600,`pg_restore --list` 校验通过;备份保留在服务器。
- 数据库备份 SHA-256:`b125b168f3617530b3c6f4e32b7c57846814831300a14dba62ad202af912345d`
- 主发布包 SHA-256:`6055a478469a8a6e6e78059d01cc746df065a74aa558f9bbce2aba675a9de639`
- 显示补丁包 SHA-256:`db6b81cd61e13515026f9b34eb3b0d5931b17cbcdead79f51dda46a85b938ec1`
- API 二进制 SHA-256:`850dd9ff29225c09bc9b0148ead4359a5304fe0e25a65ddd7b49849eec5b67a2`
服务器最终发布目录保留 `portal-release-manifest.json` 和 `portal-display-release-manifest.json`,记录文件摘要、前一版本及发布时间。本地副本位于 `.tmp-all-pages-deploy/online-portal-release-manifest.json`、`.tmp-all-pages-deploy/online-display-release-manifest.json`。构建、集成测试与部署脚本也保留在 `.tmp-all-pages-deploy/`。
## 回滚说明
前一 Web/API 发布目录、镜像均保留。显示补丁可回退至 `0.99.0-rc.portal1`,仅重建 Web;全套应用可回退至 `0.98.3-rc.home-width1`,仅重建 API/Web,并切换 current 链接、重载网关。迁移 73 是兼容旧版本的增量变更,应用回退时保留迁移,不恢复旧数据库覆盖新业务数据。
操作应沿用部署脚本中的 Compose 项目名 `data-terminal-cloud`、对应发布目录的环境文件和完整 overlay 链。当前链为 `compose.yaml` 加 `deploy/server/compose.{override,subtitle,onboarding,migration,fleet,admin-ui,admin-ui2,tencent-sms,tencent-captcha,trusted-login,home,captcha-csp,home-width,portal,portal-display}.yaml`,回退时去除目标版本之后的 overlay。不得通过整栈 down 或删除数据卷执行应用回滚。
@@ -0,0 +1,17 @@
# 网页发布 0.99.42-rc.permission-impact1
- 网站:https://82.157.201.114/
- 上一版本:`0.99.41-rc.consent-design1`
- 发布时间(UTC):`2026-10-05T10:30:06.531532+00:00`
聊天记录会说明缺少文字保存和录音保存授权的影响,并提供单项授权入口。故事、摘要、个性化记忆、家庭资源分享、内容访问和家庭角色分别说明条件、设置位置与应联系的家庭成员。授权状态读取失败显示待核对,补授权不会恢复过去未保存的内容。
本次发布只更换网页镜像,基于最新线上源代码制作 7 个相关源文件的增量;没有发布共享工作区里的其他改动。已保留上一版长者创建授权窗口与样式。
发布前,实际候选目录的 TypeScript 和生产构建、32 项单元测试、权限提示与快捷授权、录音播放下载和记忆操作回归通过。桌面确认窗口和手机权限提示截图已检查。
发布后,126 个静态文件、入口路由和运行时配置哈希验证通过;API/worker 就绪。其他服务容器和启动时间保持一致,数据库 schema `74` 无变更。匿名浏览器确认新 JS 资源、桌面和手机布局正常。没有为真实账号开通授权、执行迁移或写入业务数据。
发布证据在工作区 `.local-evidence/permission-impact-20261005/`:`release-manifest.json`、`production-receipt.json`、`production-smoke.json`、构建和回归日志。
回退目标为 `0.99.41-rc.consent-design1` 的网页镜像与 Compose 文件链;发布脚本在切换后的验证失败时自动回退。
@@ -0,0 +1,11 @@
# 网页发布 0.99.43-rc.elder-roster2
用户明确授权“直接上线”。本次基于最新线上 `0.99.42-rc.permission-impact1`,更新长者资料切换:桌面左侧名单、右侧资料,手机横向名单。只发布三处相关源文件,保留上一版权限影响提示。
正式候选 TypeScript/Vite 构建通过,示例数据与两张演示头像未进入正式包。只更新 Web,128 个静态文件完整回读和公开入口、runtime-config 校验通过;API/worker 就绪,其他容器 ID 与启动时间保留,数据库 schema 74/clean,无迁移或业务数据写入。
线上隔离验收家庭已确认两位长者切换对应兴趣、语速和 5/3 项有效授权;手机页面无横向溢出。保存生产桌面、手机截图。应用未发现控制台错误,既存浏览器扩展告警不来自网站。
证据:`E:/Codex/story/.local-evidence/elder-switcher-design-20261005/release/` 中 production-receipt.json、release-manifest.json、deployment.log、production.jpg、production-mobile.jpg。
线上入口:https://82.157.201.114/?section=subjects 。自动回退目标为 `.42` 网页镜像与对应 Compose 链。
+51
View File
@@ -0,0 +1,51 @@
# EchoEar screen wake, 2026-10-07
Firmware `1.7.0-controls.32` (fleet version code 15) and API
`0.99.58-rc.screen-wake1` add wake by a short screen tap while in standby.
The first tap restores the display and microphone without starting a call.
The next tap keeps the existing start/stop conversation behavior. The existing
50–1500 ms short-touch threshold, 300 ms debounce, and provisioning long press
are preserved.
The touch task queues the wake to the control worker. Before changing hardware,
the worker persists an event UUID and the applied configuration revision in NVS.
After local wake succeeds, the authenticated control report includes that event.
The API changes only desired power from standby to active under the twin row lock,
increments its revision once, and records the receipt in the same transaction as
the actual hardware report. The firmware clears the event only after an exact
receipt and a committed NVS erase, then applies returned configuration. Newer
cloud revisions supersede an old offline event; duplicate delivery is idempotent.
An offline tap can wake after initial configuration has been applied. During
initialization, a tap is retained until a configuration revision is available.
Hardware, NVS, and missing-receipt failures retry. An unacknowledged event survives
a reboot. No database migration, credential change, or binding reset is needed.
Verification:
- Three host suites compile the production C with undefined-behavior sanitizer:
screen-call, screen-wake, and device-power. They cover first-tap consumption,
debounce, offline wake, hardware/NVS failures, receipts, and restart recovery.
- Six onboarding tests passed against an isolated PostgreSQL/Redis environment
initialized with all 77 production migrations, with no skips. They cover actual
reports, tenant/device isolation, duplicate and concurrent wake delivery,
revision fencing, and preservation of volume and device name.
- `go vet`, Linux API build, ESP-IDF firmware build, and task-path diff checks pass.
- Production API was built from release `0.99.57-rc.viking-console1` plus only the
two onboarding source/test changes. Its binary was read back and verified after
deployment. All 157 web files, runtime configuration, schema version 77, and
21 other containers are unchanged.
- Firmware compilation inputs were checked against the installed `.31` baseline:
five changed production files, two new wake files, and 5407 verified inputs.
- The published application is 3776624 bytes, below the 4194304-byte OTA limit,
SHA256 `7b61a24a1d38c7a98db7b11de4d5866339c767c448ddd538d6b4ef88b12f7133`.
HTTPS publication readback matched exactly.
- COM6 device `KP01-8995ACF5E69E` installed this image through OTA into slot
`0x10000`. Boot verification and server receipt both reported success. Firmware
and version code report `.32`/15, one binding at generation 1, volume 45, and
standby at configuration revision 4. The first attempt retained `.31` after
DRAM allocation failed at final verification; reboot and retry succeeded.
Physical touch confirmation is tracked separately in the package verification
record. Automated tests and successful installation do not establish that a
person has touched the physical display.
+1 -1
View File
@@ -96,7 +96,7 @@ PLATFORM_HEALTH_REPORT_SECRET_FILE=/etc/data-terminal-cloud/platform-health-call
| `CERTIFICATE_MIN_SECONDS` | `129600` | 证书最小剩余时间,36小时 |
| `DISK_WARNING_PERCENT` | `80` | 磁盘警告阈值 |
| `DISK_CRITICAL_PERCENT` | `90` | 磁盘严重阈值 |
| `EXPECTED_PUBLIC_TCP_PORTS` | `22,80,443` | 宿主机允许绑定非回环地址的TCP端口,逗号分隔 |
| `EXPECTED_PUBLIC_TCP_PORTS` | `22,80,443,1883` | 宿主机允许绑定非回环地址的TCP端口,逗号分隔 |
| `MONITOR_STATE_DIR` | `/var/lib/data-terminal-cloud-monitor` | 原子状态文件目录 |
| `MONITOR_CONFIG_FILE` | `/etc/data-terminal-cloud/monitor.env` | 可选通知配置 |
| `TLS_CERTIFICATE_FILE` | 根据`PUBLIC_ORIGIN`主机名推导 | 当前活动证书文件 |
+53
View File
@@ -0,0 +1,53 @@
# VoiceLink 服务器迁移记录
日期:2026-09-14。新地址:https://82.157.201.114 。旧地址:https://121.41.46.238 。
## 已迁移
- 后端部署版本:`0.91.0-rc.migrate1`,包含绑定接口同步返回 5 个火山字段的修改。
- 前端复用旧服务器已运行的 `0.90.0-rc.wechat1` 页面镜像,登录地址运行时切换为新 IP。
- PostgreSQL(schema 65)、Redis、MinIO 文件、Keycloak 登录数据以及原有平台/设备/火山/微信密钥全部保留。
- 切换前记录:6 个账号、2 位老人、8 台设备、4 条有效绑定。无进行中通话;备份在停止写入后进行,恢复后对比一致。
- 登录 issuer 和老人智能体配置中的旧地址已更新。历史健康记录和幂等请求证据保留原样。下一次聊天时,平台会通过已有智能体更新流程设置新回调地址,不重复创建智能体。
- 小程序 `C:\Users\19926\WeChatProjects\voicelink\miniprogram\config.js` 已改为新地址,未代替用户上传/发布微信小程序。
- 桌面交接设备初始化文件仅更新 origin,设备 ID 和密钥未变;没有烧录硬件。现有实物仍可通过旧 IP 转发访问。
## 运行目录与检查
新服务器:Ubuntu 24.04、Docker 29.6.1、Compose 5.3.1。
发布目录:`/opt/data-terminal-cloud/releases/0.91.0-rc.migrate1`;`/opt/data-terminal-cloud/current` 指向此处。
日常操作使用完整组合,不要执行 seed,也不要删除卷:
```bash
cd /opt/data-terminal-cloud/current
docker compose -p data-terminal-cloud \
-f compose.yaml \
-f deploy/server/compose.override.yaml \
-f deploy/server/compose.subtitle.yaml \
-f deploy/server/compose.onboarding.yaml \
-f deploy/server/compose.migration.yaml \
--env-file deploy/server/.env ps
```
仅发布 TCP 80、443;SSH 为 22。数据库、Redis、MinIO、内部 API 端口不对公网发布。
## 备份、证书与旧地址
- 新 IP 已取得有效 HTTPS 证书;Certbot webroot 续期模拟通过,12 小时检查一次,续期后重载网关,不停机。
- `platform-backup.timer` 每日运行;新机首次加密备份与隔离数据库恢复验证成功。备份目录 `/var/backups/data-terminal-cloud`。
- `platform-health-monitor.timer` 检查服务健康。异地 OSS 备份原先未启用,迁移后仍未启用;阿里云 ECS 身份不能直接用于腾讯云主机。
- 旧服务器只运行兼容网关和 `voicelink-forward.service`。页面跳转新地址;设备、接口、回调经验证新 IP 证书的 HTTPS 转发至新服务器。内部桥接端口 18081 只绑定 Docker 私网地址,不是公网服务。
- 旧业务容器停止,旧数据和迁移备份保留。不要重启旧 API/worker 形成双写,也不要立即释放旧 IP。
- 旧机备份:`/var/backups/voicelink-migration-20260914`;新机迁移校验资料:`/root/voicelink-migration`。均包含敏感材料,仅管理员可读,不对外分享。
## 验证范围
- 新页面、版本接口、OIDC issuer、API/callbacks/worker 就绪检查通过。
- 联调设备校时签名、平台登记、未绑定查询、未绑定聊天拒绝检查通过;没有执行新绑定或消耗火山注册额度。
- 小程序 40 项测试及原生页面检查通过。
- 未进行真机音频通话或真实微信授权登录;仍需用户在手机/设备上完成体验检查。
- MQTT、在线上报、OTA 仍是此前的待实现功能,本次迁移不会自动补齐。
回退前必须停止新机写入并同步新产生的数据;旧备份只代表切换时点,不能直接启动旧库当成无损回退。
+149
View File
@@ -0,0 +1,149 @@
# 平台注册火山与设备取参
2026-09-15 · 新流程:平台代注册,绑定直接下发凭据;设备本地初始化 SDK 后直接 chat/start,不再登记 boot_id 或上报初始化回执。第 3、4 节仅供旧固件兼容,新设备不要实现。
## 1. 业务分工
平台在 ble-bind 请求中绑定老人、调用火山 DynamicRegister,保存物理设备的专属密钥和 RTCAppID,拿齐配置后才返回成功。后台任务保留作恢复兜底,与请求处理共用同一注册记录,不重复注册。设备不拿 product_secret,不调用 DynamicRegister。
物理设备只注册一次。重新绑定老人不重复注册;聊天时的 bot_id 跟随老人变化。注册超时不等于失败,平台标记待核对,不自动重复消耗注册流程。
## 2. 绑定时直接返回 5 个字段
`POST https://82.157.201.114/api/v1/device/ble-bind`
输入:`{"bind_token":"<原令牌>"}`。
成功 data(不再包含 voice_status / voice):
```json
{
"status":"success",
"instance_id":"<实例>",
"product_key":"<产品>",
"device_name":"<火山设备名>",
"device_secret":"<本设备的火山密钥>",
"RTCAppID":"<应用ID>"
}
```
success 必须带齐以上字段;设备保存它们和 Wi-Fi 后再向小程序报告 success。无需另调取参接口等待 ready。开机读取本地凭据初始化 SDK,聊天请求只需 device_id 和 session_id。
设备请求超时设 45 秒,平台取参等待上限 35 秒。HTTP 504 / VOICE_REGISTRATION_TIMEOUT 表示等待超时;502 / VOICE_REGISTRATION_FAILED 表示失败或结果未确认;503 / VOICE_REGISTRATION_PAUSED 表示服务暂停;502 / VOICE_CONFIG_INVALID 表示配置不完整;409 / VOICE_MIGRATION_REQUIRED 表示旧设备需补交凭据。它们都不返回成功或空配置,也不删除已完成的绑定。网络超时、5xx 保留原 bind_token 和 X-Request-ID 重试,刷新时间、nonce 和签名;注册结果不确定时由平台核对,不盲目重复注册。
若等待期间解绑,返回 409 / BINDING_CHANGED,不下发凭据。成功响应丢失后,即使原令牌已过期,只要原绑定仍有效,也能用原令牌重新取得相同配置。
以下是**可选恢复接口,正常绑定不需要调用**:
`POST https://82.157.201.114/api/v1/device/voice-config`
输入:`{}`。可在绑定后直接调用,不要求先完成本次 boot 登记。
成功 data 示例:
```json
{
"voice_status":"ready",
"voice":{
"instance_id":"<实例>",
"product_key":"<产品>",
"device_name":"<火山设备名>",
"device_secret":"<本设备的火山密钥>",
"RTCAppID":"<应用ID>"
}
}
```
preparing 时每 5 秒重试;paused、reconcile_required 时提示平台处理;migration_required 见第 5 节。非 ready 时 voice=null。解绑后返回 409 DEVICE_UNBOUND,不下发旧凭据。
## 3. 旧固件兼容:开机获取 SDK 配置(新设备不调用)
设备照常完成 time-challenges → register 后,调用:
`POST https://82.157.201.114/api/v1/device/voice-runtime`
输入:
```json
{"boot_id":"<本次已登记的开机UUID>"}
```
输出 data 中有:
| 字段 | 用途 |
|---|---|
| voice_status / voice | 与 voice-config 相同的状态和 5 个字段 |
| state | 当前绑定和激活状态 |
| command | ready 时的配置命令;未就绪时可为 null |
| command.payload.config_b64 | Base64URL 编码的配置,下例为解码后的内容 |
| command.payload.config_mac | 现有 CFG1 配置签名 |
| call_contract | 旧兼容字段,不据此切回旧聊天接口 |
```json
{
"contract_version":"ONBOARDING-1.1",
"device_id":"KP01-020000260914",
"activation_id":"<平台激活任务UUID>",
"config_version":1,
"binding_generation":1,
"expires_at_ms":1789459200000,
"iot":{
"instance_id":"<实例>",
"product_key":"<产品>",
"device_name":"<火山设备名>",
"device_secret":"<本设备的火山密钥>",
"RTCAppID":"<应用ID>"
},
"bot_id":"<兼容的默认值,实际聊天仍用chat/start返回值>"
}
```
校验 `config_mac=小写hex(HMAC_SHA256(烧录设备密钥, UTF8("CFG1\n"+config_b64)))`,再 Base64URL 解码 config_b64 并解析 JSON。核对 device_id、binding_generation 与 state 一致,expires_at_ms 未过期。新固件沿用此封装,不再提取 product_secret。
preparing 时自动等待,每 5 秒再次请求;不要把 preparing 当成需要解绑。获取配置后保存 5 个语音字段,以及 activation_id、config_version,再初始化 RTC SDK。
## 4. 旧固件兼容:SDK 初始化完成通知(新设备不调用)
`POST https://82.157.201.114/api/v1/device/activation-acks`
输入:
```json
{"activation_id":"<上一步UUID>","config_version":1,"local_generation":1}
```
activation_id、config_version 使用最新配置原值,local_generation 可使用 config_version。输出 data 中 accepted=true 表示平台已记录配置保存和 SDK 初始化完成;不是说用户已经完成第一次聊天。
随后每次聊天调用 chat/start,结合平台下发的凭据,用 SDK 获取 RTC 房间并收发音频。聊天结束继续调用 chat/end。
新设备不再调用 activation-attempts,也不发送 SDK_REGISTER activation-results。旧结果接口保留用于尚未上报完成的旧固件任务恢复。
## 5. 已注册旧设备的一次性迁移
旧固件可能只把火山密钥保存在本机,平台只有“已注册”记录。此时返回 migration_required,**不能重新注册这台设备**。
更新后的固件读取本机原凭据,调用:
`POST https://82.157.201.114/api/v1/device/voice-credentials`
输入:
```json
{
"instance_id":"<本机原实例>",
"product_key":"<本机原产品>",
"device_name":"<本机原火山设备名>",
"device_secret":"<本机原火山密钥>",
"RTCAppID":"<本机原应用ID>"
}
```
输出 data:`{"accepted":true}`。然后重新调用 voice-runtime。
平台只接受本设备已完成注册、身份匹配的旧凭据;相同内容重试不会重复写入,不允许覆盖平台已有的不同凭据。如果旧设备密钥已丢失,或注册结果不确定,需要运维核对,不能伪造凭据或盲目注册。
## 6. 接入与发布说明
以上设备 HTTP 接口均使用已有 DEVREQ1 请求头和 `{ok,code,request_id,server_time_ms,data}` 外层。设备凭据不经过小程序/蓝牙,不打印日志。平台凭据沿用既有加密存储格式。
后端 API 和 worker 已部署新服务器,旧 IP 暂时转发到新服务器。尚需用受控实物验证真实火山注册、设备持久化、SDK 初始化和实际通话。已有设备先备份运行凭据,不能把测试用凭据写入实物。
@@ -0,0 +1,28 @@
# 平台代注册火山:本地验证记录
日期:2026-09-14。状态:代码已修改,未部署线上、未烧录硬件。
## 实现
- 后端在 ble-bind 请求内调用 DynamicRegister,激活 worker 仅作恢复兜底;复用已有 HTTPRegistrar 和凭据存储,不新增数据库表。
- 注册执行前持久化 SERVER_REGISTER 记录;超时或崩溃后转待核对,不自动重复注册。
- ble-bind 成功直接返回 status、instance_id、product_key、device_name、device_secret、RTCAppID;不再返回 voice_status / voice。先提交绑定事务,再等待取参(上限 35 秒)。失败明确报错,保留已完成绑定,原令牌可重试。
- 固件 1.5.2-sync-bind.1 在绑定响应处检查并保存 5 个字段,请求超时 45 秒,保存失败不报告蓝牙成功。启动仍调用 voice-runtime 获取配置编号,SDK 初始化后发送 activation-acks;不调用 volc_device_register。
- 旧设备已注册且凭据只保存在本机时,通过 voice-credentials 迁回。未经注册设备不能导入;不同凭据不能覆盖已有值。
- 旧 DEVICE 注册流程仅留在兼容测试和历史结果补报中;生产配置默认使用服务器执行。
## 已验证
- onboarding 与 hardwareprovision 全包数据库集成测试通过,使用本次新建的隔离测试数据库、模拟火山注册器,不连接真实火山。
- 新增六组服务器测试:绑定后取参/配置与初始化确认、注册不确定结果、并发 worker 与解绑/重绑、旧设备凭据迁移、拒绝未注册导入及设备侧执行、进程崩溃执行锁恢复。
- 同步绑定新增四组数据库测试:HTTP 注册错误不假成功且不重复注册、并发 worker 等待超时后原令牌恢复与 5 字段响应、等待期间解绑不下发凭据、暂停后恢复原绑定。日志:`.tmp-bind-sync-tests/integration-onboarding.log` 和 `integration-hardwareprovision.log`。
- onboarding、hardwareprovision、callmemory、httpapi、contractgovernance 单元测试通过,API 和 worker 编译通过。
- 真实 C 适配代码宿主机测试通过,覆盖服务器凭据读取、preparing、缺失 RTCAppID 拒绝、旧凭据迁移;测试断言设备不调用供应商注册。原聊天有效期与结束补报测试仍通过。
- 同步绑定 C 测试增加:缺少 RTCAppID 不保存、字段齐全保存 NVS、NVS 提交失败向调用方返回错误。
- ESP-IDF 固件编译通过;没有改本次设备的蓝牙名称,也没有烧录实物。
## 尚未验证
没有调用真实火山注册接口,不消耗联调设备 License;没有修改线上现有设备绑定和凭据。部署前仍需在受控联调设备上验证真实供应商响应、保存、SDK 初始化、进房和音频通话。
先发布后端 API/worker,再更新固件。MQTT 与 OTA 仍为文档中的待实现接口,不在此次注册迁移代码范围。
+11
View File
@@ -0,0 +1,11 @@
# 纯语音模式的设备音量控制
保留现有端到端语音模型,在已验签的实时字幕回调中识别用户的短调音请求,不需要新增方舟模型接入点或模型工具调用。此实现属于语音指令识别,不是纯语音模型原生 Function Calling。
支持“大点儿声”“声音大一点”“把音量调高一点”“声音太小了”,以及对应的调小表达。每次增减 10,范围 0–100。“不要大点声”、引用别人说话和一般叙述不匹配;只处理用户的确定字幕,不处理机器人字幕或未确定的识别结果。
回调在同一数据库事务中保存字幕、更新设备目标音量、记录操作。同一对话轮次只执行一次,重复回调及不同序号的重复片段不累加。调用必须属于当前仍绑定的设备、未结束且授权未过期的对话。迟到的历史字幕仍按现有规则存储,但不再调音量。字幕存储授权仍沿用现有回调约束。
现有固件每约 5 秒同步配置,通话中也能应用音量;实际执行结果通过设备的 reported volume/revision 查看,记录中的 `requested` 不表示扬声器已经完成调整。直接更新持久化的目标配置,后续同步不会把语音调整覆盖回旧值。名称、待机设置和其他配置保持原值。纯语音模型的口头回答本身不能作为执行成功的凭证。
验证:`internal/callmemory` 在隔离 PostgreSQL 数据库的完整测试通过,包括并发重复回调、音量上下限、否定句、机器人字幕、未确定识别和通话结束/授权过期。新回调服务已部署为 `0.99.11-rc.spoken-volume1`,未验签请求返回 401。API、网页和其他运行容器未重启,COM6 保持固件 `1.7.0-controls.10`。部署前 COM6 的目标和上报音量均为 22;用户真实说话后的音量变化待实机确认。
+36
View File
@@ -0,0 +1,36 @@
# SaaS 验证码弹窗修复(2026-09-20)
## 结果
2026-09-20 22:22(Asia/Shanghai)发布 Web 镜像 `data-terminal-cloud-web:0.98.2-rc.captcha-csp1`。已在真实线上 `/login` 密码登录页面检查:点击登录后显示腾讯云滑块拼图,取消后显示“已取消验证”。未完成拼图、未发送短信,不声称真实票据验证及账号登录全流程已验证。
## 根因与改动
旧 CSP 仅允许腾讯云入口脚本,未允许验证码 2.0 动态加载的 CDN 脚本、样式和 blob worker。隔离诊断页复现到以下拦截:
- `style-src-elem`:`turing.captcha.gtimg.com/1/tgJCapCss.*.css` 及 `turing.captcha.qcloud.com` 样式回退。
- `script-src-elem`:`turing.captcha.gtimg.com/1/dy-jy3.js`、`tgJCap.*.js`。
- `worker-src`:`blob:`。
仅向 style-src 添加 `https://turing.captcha.gtimg.com`、`https://turing.captcha.qcloud.com`,向 script-src 添加 `https://turing.captcha.gtimg.com`,新增 `worker-src 'self' blob:`。保留其余 CSP;未允许内联脚本、unsafe-eval 或任意脚本域名。
本地源码 `deploy/web.nginx.conf` 已同步修复;原有未上线 `/f/` 路由未混入本次发布。
## 发布隔离
- 准备期间检测到线上先更新为 `0.98.0-rc.trusted-login1`,又更新为 `0.98.1-rc.home1`。旧基线发布被保护检查中止,没有覆盖用户的新功能。
- 最终以 `0.98.1-rc.home1` Web 镜像和原 Nginx 配置为基线,仅替换 `default.conf`。HTML/静态应用资源保持不变。
- API/worker 仍为 `0.98.0-rc.trusted-login1`,容器 ID 未变、readiness 正常。没有数据库迁移、密钥修改或环境变量变更。
- 验证码策略保持 `tencent|image|2`(SaaS 腾讯云、小程序图形)。
- current 指向 `/opt/data-terminal-cloud/releases/0.98.2-rc.captcha-csp1`。
- 发布包 SHA-256:`e29a60747ffdbc866a864919b185fb0a8228dce5f0b40a048a799963fa9a20cf`。
- 服务器 manifest:当前 release 下 `captcha-csp-release-manifest.json`。
## 验证与回退
- `node --test test/tencent-captcha-csp.test.mjs`:3 项通过。指定旧配置时按预期失败,能检出资源域名和 worker 权限遗漏。
- 使用真实腾讯 SDK 的本地隔离诊断页触发 widget_ready、显示拼图,未出现 CSP 拦截;未接入业务登录/短信接口。
- 新 Web 镜像 `nginx -t` 通过;公网 `/login` 返回完整匹配的新 CSP,HTTP 200。
- 在新线上页面用合成测试输入触发弹窗,确认可见拼图;没有代做验证码,没有发送短信。
- 后续部署完整保留原 compose 各层,最后加 `deploy/server/compose.captcha-csp.yaml`。
- 如需回退,仅用 `compose.captcha-csp-rollback.yaml` 替代最后的 CSP 覆盖层,重建 Web 并重载 gateway;确认后将 current 指回 `0.98.1-rc.home1`。旧 release 和镜像已保留,不要回退数据库或 API。
+57
View File
@@ -0,0 +1,57 @@
# 登录验证码切换(2026-09-20)
## 2026-09-30:SaaS 已上线本地拼图滑块
家庭与运营网页登录已发布 `0.99.19-rc.local-slide1`,策略为 SaaS=`slide`、mini=`image`、version=4。采用固定版本 GoCaptcha v2.0.5 / assets v1.0.7 和 go-captcha-react 1.0.5,自有 API 生成图片及校验位置,无运行时第三方验证码请求。交互为弹出拼图、拖动对齐,再继续短信或密码登录。滑块挑战及一次性票据共用原 2 分钟期限,刷新、错误提交、过期和重复票据均受到服务端校验。实现和许可见 `local-slide-captcha.md` 及 `third_party/local-slide-captcha/NOTICE.md`。
发布仅更新 API/Web,应用兼容迁移 74 后再启用 slide。其他容器 ID、端口、挂载、运行环境和 Web runtime-config 摘要均核对保持一致。策略切换由真实 SSH 运维来源 service 同事务写审计,请求号 `local-slide-maintenance-6ce4f8e0-8429-4078-8053-f2ca2bb4255d`。源码以线上 release18 为基线,只发布本功能 28 个文件;同时修补扫描发现的既有 gRPC/WebSocket 两个依赖版本。
精确候选的 42 项 PostgreSQL/Redis 隔离集成、10 项 React 组件/API 测试、Linux API 和前端构建均通过,0 SKIP。govulncheck 复扫无应用可达报告。线上匿名接口核对两 Web 端返回 slide,小程序旧兼容接口仍为 image;真实 Chrome 已观察弹窗、过期提示、换图恢复与取消,未自动解题或发送短信。实测截图、冻结清单、发布回执和检查结果位于工作区外 `.local-evidence/local-slide-captcha-20260930`。真实账号登录及 OTA 完成状态需另行确认,不能由验证码显示正确推断。
## 2026-09-30:启用本地开源图片验证码
按用户要求,线上 SaaS(家庭端及运营端)已从 `tencent` 切为 `image`;小程序继续使用 `image`,策略版本从 2 递增为 3。复用项目已接入的 `github.com/dchest/captcha v1.1.0`,在自有 API 服务内生成和校验 6 位数字 PNG,不需要腾讯验证码套餐、密钥或 SDK。短信发送仍使用原短信渠道。
此次只切换现有策略,API/Web/worker 镜像及设备固件均未重新部署。授权 SSH 运维事务先锁定并核对旧版本,再修改 SaaS 模式、递增版本,并同时写入 `login.captcha.policy.update` 审计;actor 为真实运维来源 `service`,未冒用登录用户。运维审计请求号为 `local-captcha-20260930-59239939-4513-4e7c-9c11-57f7f7f4f52b`。
家庭端真实登录页已检查短信/密码两种模式均显示图片,“换一张”返回新的 PNG,2 分钟到期提示可见,刷新后恢复有效状态。答案不在 API 响应中,仍以会话和挑战 ID 绑定的 HMAC 存入 Redis,验证时原子消费;策略切换使旧挑战失效,需重新获取。现有 CSRF、登录及短信限流、新浏览器短信验证规则继续生效。
腾讯服务套餐是否到期没有确认依据;本次结论为已替换登录时使用的验证服务,不把统一的失败提示视为供应商到期证明。实测证据保存于工作区外 `.local-evidence/local-captcha-20260930`;真实账号登录和远程 OTA 仍需用户完成图片及账号验证后继续。
## 2026-09-20 的实现范围与状态(历史记录)
只实现「现有数字图形验证码 / 腾讯云验证码」,不包含自部署滑块。2026-09-20 已部署线上版本 `0.97.1-rc.tencent-captcha1`,两端仍保持 image 默认模式。上传的真实配置已通过 API/worker 非 root 加载校验,腾讯云只读查询确认对应应用存在且 SaaS 为 web、小程序为 mini。尚未发布微信小程序,也未进行人工拼图和真实云端票据校验;配置可加载、应用查询成功不等于真实验证全流程已通过。发布和回退记录见 `tencent-captcha-release-20260920.md`。
管理端入口:运行配置 → 验证码设置。SaaS(包括运营登录)与微信小程序分别选择模式,默认均为 image。配置全平台生效,需要 ops_admin;普通运营角色不可读写。修改使用版本锁并写审计;不接收、保存或展示密钥。
历史更新:2026-09-20 22:22 已发布 Web `0.98.2-rc.captcha-csp1`,修复腾讯云动态 CSS/JS/Worker 被 CSP 拦截而无法弹窗的问题,真实线上登录页可显示拼图。当时策略为 SaaS=tencent、小程序=image(version=2),该次 CSP 修复未修改策略;2026-09-30 已切回上述本地模式。详见 `tencent-captcha-csp-fix-20260920.md`。
## 配置及部署
填写 `deploy/server/tencent-captcha-template/tencent-captcha.json.example` 中的六个字符串。可编辑副本位于 `tmp/tencent-captcha.json`(已被 Git 忽略)。详细填写与插件授权步骤见该模板目录 README。
服务器文件:`/etc/data-terminal-cloud/voice-runtime/tencent-captcha.json`,root:65532、640。容器通过现有只读挂载读取 `/run/secrets/voice-runtime/tencent-captcha.json`。也支持环境变量 TENCENT_CAPTCHA_CONFIG_FILE 指定绝对路径。仅 api/worker 读取。填写 JSON 不会自动切换模式;重启服务加载配置后由管理端保存模式。
部署需先应用迁移 71,并更新 API、worker、Web 镜像。Web 镜像必须包含修改后的 `deploy/web.nginx.conf`,否则旧 CSP 会阻止腾讯云 SDK 和 iframe。后续发布以线上 0.97.0-rc.tencent-sms1 为基线,只覆盖本功能文件;本地存在其他页面样式改动,不能混入本次发布。不要替换现有服务器的短信密钥或环境文件。
小程序需授权插件 `wx1fe8d9a3cb067a75`(腾讯验证码 2.1.5),重新编译、真机验证和提交发布;旧版客户端未发布前不要切换 mini_mode。回退方式为管理员将两端改回 image;若凭据被误改造成登录不可用,可由有权限的运维在数据库中将 login_captcha_policy 的相应模式恢复 image 并递增 version。不要在腾讯云控制台启用尚未接入的可选「CaptchaAppId 强制校验」。
## 验证流程
- 原 /captcha 接口由服务器选择模式;image 返回 PNG,tencent 只返回对应端公开 CaptchaAppId、随机 captcha_id 和两分钟有效期,不返回密钥或答案。
- 挑战存 Redis,绑定会话、客户端类型及策略版本;刷新替换旧挑战,每次校验均消费挑战。
- Web 使用动态加载 TJCaptcha.js 返回 ticket+randstr,后端调用 DescribeCaptchaResult。
- 微信插件返回 ticket,后端调用 DescribeCaptchaMiniResult。两端 AppId/密钥不可混用。
- 短信发送及密码登录请求携带 captcha_ticket(Web 另带 captcha_randstr);保留原 captcha_code 支持 image。模式由服务器决定,客户端不能选择降级。
- 只接受云端 CaptchaCode=1;Web 还拒绝非零 EvilLevel。拒绝 trerror 容灾票据、错误、超时、重复票据;腾讯云请求不自动重试。票据摘要用 Redis SetNX 全局去重十分钟。
- 用户 IP 从直接连接或受信任反向代理链提取,忽略来自不可信连接的转发头。保留短信全局、手机号、会话、IP 限额及发送幂等性。
## 验证记录
- Go 全仓编译通过(`go test ./... -run '^$'`)。
- 配置文件解析、独立 AppId、腾讯云 SDK 分流、失败关闭、受信代理 IP 单测通过。
- 使用隔离 PostgreSQL/Redis、完整迁移 1–71 和 local_seed,`go test ./internal/onboarding -count=1` 通过;包含切换权限、未配置禁止启用、版本冲突、审计、跨端及过期挑战拒绝、票据重放、短信幂等。
- 现有 phone-password-login 浏览器测试通过;新增 tencent-captcha 浏览器测试覆盖取消、容灾票据拒绝、短信/密码分流、切回图片、管理端保存与刷新。腾讯 SDK/API 使用测试替身,不产生验证码或短信费用。
- 小程序 117 个测试、build、check、原生 WXML/WXSS 编译通过;新增插件回调、取消、错误、密码登录和页面离开测试。
官方参考:[Web](https://cloud.tencent.com/document/product/1110/36841)、[微信插件](https://cloud.tencent.com/document/product/1110/49319)、[Web 服务端](https://cloud.tencent.com/document/api/1110/36926)、[小程序服务端](https://cloud.tencent.com/document/product/1110/75490)。
+50
View File
@@ -0,0 +1,50 @@
# 腾讯云验证码发布记录(2026-09-20)
## 已发布
- 生产:`https://82.157.201.114`;管理端入口:运行配置 → 验证码设置。
- 版本:`0.97.1-rc.tencent-captcha1`,约 21:31(Asia/Shanghai)发布。
- 以线上 `0.97.0-rc.tencent-sms1` 源码为基线,只叠加验证码实现。未带入本地其他后台页面样式、固件或设备功能改动。运行配置页仅增加两个视图按钮和验证码设置面板,保留原发布工作台。
- API、worker、Web 镜像已更新,gateway 仅重载;数据库迁移 71 已应用、dirty=false。
- `/opt/data-terminal-cloud/current` → `/opt/data-terminal-cloud/releases/0.97.1-rc.tencent-captcha1`。
- 发布包 SHA-256:`cef0dbad61c9315ee26d6966c9c45f29db45b7ad319dad13046a87495bb29609`。
- 当前策略:`saas_mode=image`、`mini_mode=image`、`version=1`。未开启腾讯云模式、未发送真实短信、未自动完成任何真实 CAPTCHA。
## 配置和安全
- 使用用户上传的 `/etc/data-terminal-cloud/voice-runtime/tencent-captcha.json`,内容未修改、未复制到本地、未输出日志。
- 已收紧为 root:65532、0640;原上传内容备份在 `/var/backups/data-terminal-cloud/tencent-captcha-upload-20260920.json`,0600。
- API/worker 复用只读 secrets 挂载,唯一新增环境键为 `TENCENT_CAPTCHA_CONFIG_FILE`;既有短信配置未修改。
- API/worker 非 root 配置加载检查通过;原短信功能仍开启。
- 腾讯云 DescribeCaptchaUserAllAppId 查询确认两套应用属于当前凭据可访问的账号:SaaS channel=web,小程序 channel=mini;DescribeCaptchaAppIdInfo 均返回 code=0。
- 此检查不代表 AppSecretKey、真实票据校验权限、套餐或人工验证全流程均已验证,后续仍需手动联调。
- Web CSP 已允许官方 SDK/iframe 域名,未开放内联脚本;公网 JS/CSS 中未发现配置文件内的 API 密钥或应用密钥。
## 验证
- 发布候选的配置/Tencent SDK/IP 单元测试通过。
- 发布候选在新建隔离数据库、迁移 1–71、local_seed 下,完整 `go test ./internal/onboarding -count=1` 通过。
- 隔离测试最初缺少 JSON fixture;补齐后复用测试库又触发已有固定手机号测试冲突。重建本任务专用测试库后全包通过,测试未指向生产。
- Linux amd64 API/worker 编译、Web OpenAPI 生成及生产构建通过。
- 发布候选运行原 phone-password-login 9 项、新 tencent-captcha 4 项浏览器检查通过;腾讯 SDK/API 使用 mock,不产生真实验证或短信费用。
- `go test ./... -run '^$'` 在候选的 devicefleet 旧测试失败(fakeStore 缺少 OpenDownload、StartBroker 签名不匹配);在原线上 SMS 基线候选复现相同失败,属于未修改的已有测试不一致。本次未改写设备功能来消除该失败,不声称全仓测试通过。
- 线上 SaaS、管理端、小程序客户/运营端共四个登录入口均返回 image 模式 PNG,短信开关仍开启。
- 未登录访问验证码策略接口返回 401;Web 新资源 200、CSP 检查通过。
- API/worker readiness 正常,API/worker/Web 运行且重启计数均为 0,公网版本正确。
- 本任务专用的本地 PostgreSQL/Redis 测试容器、临时数据已清理;未删除用户或生产数据。
## 尚需人工完成
1. 管理员在运行配置 → 验证码设置把 SaaS 切至腾讯云;另开登录页手动完成腾讯云验证并测试登录/短信流程。保存前保留已登录的管理端会话,以便改回 image。
2. 小程序需在微信平台授权官方插件 `wx1fe8d9a3cb067a75`(2.1.5),使用 `C:/Users/19926/WeChatProjects/voicelink` 已更新源码重新编译、真机测试并发布后,再启用小程序腾讯云模式。本轮未提交或发布小程序。
3. 不要启用尚未实现的 CaptchaAppId 强制鉴权选项。
## 备份和回退
- 发布前加密备份:`/var/backups/data-terminal-cloud/before-0.97.1-rc.tencent-captcha1.dump.enc`(0600),已完整恢复到临时验证库并验证成功;验证库随即删除。
- 保留上版镜像和 release;migration 71 为新增表,旧镜像可兼容保留该表。
- 优先只将验证码模式切回 image。如果无法登录,授权运维可将 login_captcha_policy 相应字段恢复 image 并递增 version;不要删除账号或修改密码。
- 整体镜像回退使用新 release 的 `deploy/server/compose.captcha-rollback.yaml`,并保留完整 compose 层:base、override、subtitle、onboarding、migration、fleet、admin-ui、admin-ui2、tencent-sms、captcha-rollback。只重建 api/worker/web,重载 gateway;健康检查通过后将 current 指回旧 release。不要执行 down 或删除生产卷。
- 后续正常发布必须保留 `deploy/server/compose.tencent-captcha.yaml` 最后覆盖层,避免使用过时的镜像。
官方说明:[Web 接入](https://cloud.tencent.com/document/product/1110/36841)、[小程序插件](https://cloud.tencent.com/document/product/1110/49319)。
+51
View File
@@ -0,0 +1,51 @@
# 腾讯云验证码短信接入
本项目的 SaaS 家庭端、管理端、设备绑定入口和小程序原生登录共用一套服务端短信发送器。前端只提交手机号、图片验证码和短信验证码,不持有腾讯云密钥。服务端沿用现有 6 位随机码、5 分钟有效、一次性消费、最多 5 次验证、60 秒重发间隔、每手机号每小时最多 5 次,以及会话和 IP 限流。
## 腾讯云侧准备
在 [短信控制台](https://console.cloud.tencent.com/smsv2) 开通国内短信,并创建短信应用、实名认证资质、已审核通过的签名和“验证码短信”正文模板。模板支持验证码变量,以及可选的有效分钟数变量;参数顺序由下方 `template_params` 配置。取得 SDK AppID、签名**名称**、各用途的模板 ID。在 [API 密钥管理](https://console.cloud.tencent.com/cam/capi) 创建仅供服务端使用的密钥,并给该身份授予发送短信所需的最小权限。
## 服务端单文件配置(推荐)
填写 `deploy/server/tencent-sms-template/tencent-sms.json.example`,保存为 `tencent-sms.json`,上传到服务器 `/etc/data-terminal-cloud/voice-runtime/tencent-sms.json`。其中包含 SecretId、SecretKey、短信 SDK AppID、签名名称和四个模板 ID:`login`、`register`、`change_password`、`change_phone`。AppKey 不使用。
默认模板参数只有验证码。若正文还包含有效分钟数,在同一个 JSON 文件中设置 `"template_params": {"register": ["code", "expires_minutes"]}`;按数组顺序传入验证码和实际有效期 `5`,不填写真实验证码。当前腾讯云账号的注册模板已确认是两个变量,另外三个模板均为一个变量。
现有服务器 Compose 已只读挂载该目录。API 和 worker 默认自动读取容器内 `/run/secrets/voice-runtime/tencent-sms.json`;不存在时沿用原有环境变量配置。也可用 `TENCENT_SMS_CONFIG_FILE` 指定其他容器内绝对路径,显式指定的文件缺失会拒绝启动。单文件存在时,其 `enabled` 和短信配置优先于旧版环境变量;callbacks 等其他服务不读取此文件。支持 UTF-8 BOM,格式错误的报错不会包含密钥内容。
部署本次服务端代码及 `000070_sms_purpose` 迁移后,重启 API 和 worker 加载该文件。不会因为仅上传文件就自动启用或热加载。
家庭端/小程序的注册与登录共用入口,服务端按手机号凭据是否存在选择注册或登录模板。管理端登录始终用登录模板。SaaS 的“忘记密码”发送 `purpose=CHANGE_PASSWORD`,使用修改密码模板;验证后沿用已有的登录会话与密码设置流程。请求号同时绑定手机号和请求用途,不能跨用途重放发送。
`change_phone` 已支持独立配置和在发送器中按用途选模板;当前没有更换手机号的业务接口和页面,登录接口不接受 `CHANGE_PHONE`。新增换号功能时必须单独验证当前账号、旧/新手机号并绑定验证用途,不能把换号验证码当登录凭据。
## 兼容旧版环境变量与分文件配置
API 与 worker 使用同一组配置。生产环境把 `SecretId` 和 `SecretKey` 分别放在只读密钥文件中,不放进仓库、前端、小程序、公开日志或 Compose 明文环境变量:
```dotenv
ONBOARDING_SMS_LOGIN_ENABLED=true
ONBOARDING_SMS_PROVIDER=tencent
TENCENT_SMS_SECRET_ID_FILE=/run/secrets/voice-runtime/tencent-sms-secret-id
TENCENT_SMS_SECRET_KEY_FILE=/run/secrets/voice-runtime/tencent-sms-secret-key
TENCENT_SMS_SDK_APP_ID=<短信应用 SDK AppID>
TENCENT_SMS_SIGN_NAME=<审核通过的签名名称>
TENCENT_SMS_TEMPLATE_ID=<审核通过的验证码模板 ID>
```
仍须保留原有 `ONBOARDING_MASTER_KEY`、`ONBOARDING_ENTRY_URL` 和可信代理配置。`ONBOARDING_SMS_PROVIDER=gateway` 保留原自建 HTTPS 网关模式。未配置完整时不要开启 `ONBOARDING_SMS_LOGIN_ENABLED`;启动校验会拒绝缺少必需参数的配置。当前腾讯云适配器只向 `+86` 中国大陆手机发送,避免把国内模板误用到国际号码。
腾讯云 SDK 使用官方 API v2021-01-11 的 `SendSms`,每次传一个手机号,并按对应用途配置传入验证码及可选的有效分钟数参数;检查单号码 `SendStatusSet.Code == "Ok"` 后才将平台短信挑战标记为已发送。`SessionContext` 放入挑战 UUID 供发送记录关联,但腾讯云并不以它作为幂等键。平台对于超时等不确定结果**不会自动重试**,避免重复发送和计费;用户需等待重发间隔后重新申请。
## 接口
- SaaS 家庭端:现有 `GET /api/v1/web/login/session`、`POST /api/v1/web/login/sms/send`、`POST /api/v1/web/login/sms/verify`,网页界面无需改动。
- SaaS 管理端:现有 `/api/v1/web/operator/login/...` 对应接口,只有已预先绑定手机号的管理员能登录,不会通过短信自动创建管理员。
- 小程序:先 `POST /api/v1/mini/login/session` 取得 `customer.` 或 `operator.` 登录令牌;后续请求带 `Authorization: MiniLogin <login_token>` 与 `X-Request-ID: <UUID v4>`。调用 `POST /api/v1/mini/login/captcha` 获取图片验证码,再调用 `POST /api/v1/mini/login/sms/send`(请求体与网页 `SMSSendInput` 相同,现有登录页发送 `purpose=LOGIN`,服务端自动选择注册或登录模板),最后调用 `POST /api/v1/mini/login/sms/verify`,成功后获得 `Mini <access_token>`。无需浏览器 Cookie。响应中的 `sms_enabled` 可用于隐藏不可用的短信入口。
微信小程序页面源码位于独立的 `voicelink` 项目,登录页已接入手机号、图片验证码、短信验证码及 60 秒重发交互。已有微信授权/密码登录保持不变。小程序与本服务端需要分别构建和发布。
## 上线前验证
配置审核通过的签名/模板及密钥后,在测试手机号上实际发送一条,确认腾讯云控制台的发送记录、手机收到的内容、网页和小程序验证成功、错误验证码与重复验证失败,再逐步开启正式流量。不要用固定验证码或把验证码记录到日志。线上部署前应单独检查 Compose 最终环境变量,并避免把密钥内容打印到终端。
+16
View File
@@ -0,0 +1,16 @@
# 腾讯云短信上线记录(2026-09-20)
- 线上版本:`0.97.0-rc.tencent-sms1`,服务地址 `https://82.157.201.114`。
- 发布目录:`/opt/data-terminal-cloud/releases/0.97.0-rc.tencent-sms1`,`current` 已切换。
- 以线上 `0.96.3-rc.admin-ui2` 源码为基线,仅叠加腾讯云短信、短信用途、配置读取及登录页改动。没有把本地其他未发布页面修改带上线。
- 更新 API、worker 和 web;callbacks 与其他基础服务未重建。API、worker、web 均运行新版本,检查时重启次数为 0。
- 单文件配置:`/etc/data-terminal-cloud/voice-runtime/tencent-sms.json`,属主 `root`、属组 `65532`、权限 `0640`。
- 通过腾讯云只读 API 核验:密钥可用,四个模板及签名状态均为已生效。注册正文需要验证码和分钟数两个变量,已补充 `template_params.register=["code","expires_minutes"]`,其余三个模板使用一个验证码变量。
- 数据库迁移至 70,`dirty=false`。迁移前完成加密备份并恢复到隔离临时数据库验证;临时数据库验证后已删除,加密备份保留在 `/var/backups/data-terminal-cloud/before-0.97.0-rc.tencent-sms1.dump.enc`。
- 原始短信配置已备份到 `/var/backups/data-terminal-cloud/tencent-sms-before-0.97.0-rc.tencent-sms1.json`,权限 `0600`。
- 上线后检查:公开版本接口、家庭端/管理端短信登录开关、小程序两种账号的登录会话与图片验证码、网页 JS/CSS 均正常;网页包含 `CHANGE_PASSWORD` 用途。
- 本次未真实发送短信;尚未提供测试手机号。小程序前端未代替用户提交微信版本。换号业务接口和页面尚未实现,当前仅支持独立模板配置与发送器用途。
本次启动沿用原有八层 Compose,最后追加 `deploy/server/compose.tencent-sms.yaml`。完整配置文件路径可从新 API 容器的 `com.docker.compose.project.config_files` 标签取得,环境文件仍为本发布目录的 `deploy/server/.env`。
回滚覆盖层为本发布目录中的 `deploy/server/compose.sms-rollback.yaml`:API 回到 `0.96.2-rc.mqtt1`、worker 回到 `0.95.0-rc.device-admin1`、web 回到 `0.96.3-rc.admin-ui2`。迁移 70 仅新增默认值列,与旧版本兼容,无需为代码回滚删除短信挑战数据。
+14
View File
@@ -0,0 +1,14 @@
# 新设备短信验证与 6 位密码
版本:`0.98.0-rc.trusted-login1`,基于线上 `0.97.1-rc.tencent-captcha1`。
- 新密码接受 6–64 个 Unicode 字符,前后端与管理员手机号开通命令保持一致。
- 新浏览器首次密码登录,即使密码和人机验证正确,也只返回 `NEW_DEVICE_SMS_REQUIRED`。页面转到现有 `LOGIN` 短信流程,保留手机号和登录后的原始去向。
- 短信验证成功后签发 30 天、HttpOnly、Secure、SameSite=Lax 的设备验证 Cookie。家庭端与管理端隔离,验证绑定手机号和当前密码摘要。退出保留记录;换浏览器、清除 Cookie、到期或管理员重置密码后重新验证。
- 修改密码撤销其他设备的验证记录,当前浏览器已有且未过期的记录可保留。设备验证不能用于跳过密码、人机验证或短信找回密码。
- 迁移 72 新增 `trusted_login_device`。服务端只保存随机令牌的摘要,过期及已撤销记录由维护任务清理。迁移为新增表,旧版本可兼容回退。
- 小程序密码接口同样检查设备验证,短信成功响应返回 `trusted_device_token` 和 `trusted_device_expires_in`;客户端按服务地址、账号和 portal 保存。小程序源码已配套更新,需按原有微信发布流程发布客户端。
发布使用当前线上源代码加本次相关文件构建;保留现有短信配置、人机验证策略和其他业务页面。发布前执行加密数据库备份并在隔离数据库验证恢复。
验证包括 PostgreSQL/Redis 中的客户与管理端短信流程、设备证明到期/伪造/账号及角色隔离、密码变更撤销、原生接口防绕过;网页实际组件测试覆盖新设备提示、登录短信用途、5 位拒绝/6 位接受、找回密码及移动端布局。未代替用户收取真实短信或发布微信客户端。
+17
View File
@@ -0,0 +1,17 @@
# Viking 聊天记忆管理
页面入口:家庭门户 → 故事与记忆 → 聊天记忆(Viking),先选择长者。
数据直接来自现有 Viking collection 中的 `platform_companion_v1` 聊天命名空间,使用现有 tenant + 内部长者 ID 的 SHA256 用户标识。平台回忆整理的 `voicelink_managed_v1`、投影 `platform_companion_v2` 和其他助手数据不在此页展示或删除。
支持事件、画像检索,事件记录时间范围(北京时间),最近记录或语义查询,返回 100/500/1000 条,前端分页查看完整内容及原始字段。不使用聊天召回接口的 400 字/800 字裁剪。
权限复用家庭记忆权限:所有者默认可操作;其他成员须有查看和敏感记忆权限,删除另需修改权限。撤回个性化记忆授权不妨碍用户查看/清理已有数据。
删除调用火山单条 DeleteEvent / DeleteProfile。删除前服务端按长者、助手、类型重新查询并检查内容摘要;共享记录不展示。按记录加锁,先持久保存不含正文的审计请求,之后删除并重新检索核验缺席;删除超时/失败或检索达到上限均不报告成功。同一 operation_id 重试可核验不确定结果,参数变更被拒绝。
火山未提供此接口的 offset/cursor。检索条数是本次返回上限而非总数。删除核验读取最多 5000 条;达到上限不能证明缺席时拒绝报告删除成功。不会删除源聊天、平台故事、回忆录;当前聊天上下文可能仍保留,后续再次提到同一事实可能产生新记录。界面逐条确认并填写原因,不提供一键清库。
验证:54 项真实 PostgreSQL/Redis 隔离测试(0 跳过,模拟火山);原始内容与跨长者/助手过滤单元测试;浏览器验证搜索、北京时间、确认、失败重试、只读权限、读失败、切换长者竞态和手机布局。线上只读核验三位长者事件/画像查询成功、返回均为空,没有删除线上数据。真实火山删除没有操作业务记录验证。
官方依据:[搜索事件](https://docs.volcengine.com/docs/vector_database_vikingdb/Searcheventmemory-SearchEventMemory?lang=zh)、[搜索画像](https://docs.volcengine.com/docs/vector_database_vikingdb/Searchportraitmemory-SearchProfileMemory-1?lang=zh)、[删除事件](https://docs.volcengine.com/docs/vector_database_vikingdb/Deleteeventmemory-DeleteEvent?lang=zh)、[删除画像](https://docs.volcengine.com/docs/vector_database_vikingdb/Deleteportraitmemory-DeleteProfile?lang=zh)。
+58
View File
@@ -0,0 +1,58 @@
# 聊天接入阶段交付
时间:2026-09-14。此文档记录实际完成项,不表示已通过供应商/真机完整聊天验收。
## 当前版本:简化直连聊天
用户要求简化后,当前已上线 `0.88.0-rc.direct1`。以下新记录优先于本文旧版阶段记录。
- 设备业务接口缩减为 `POST /api/v1/device/chat/start` 和 `POST /api/v1/device/chat/end`;自动保活复用 start,不再调用 warmup/prepare/events/command-acks,也不上传字幕。
- 火山原生字幕入口:`POST /callbacks/v1/volcengine/chat?session_id=...`,会话独立签名、去重、授权检查和迟到回调隔离。现有小程序记录页沿用,不需要另改记录 API。
- 不再要求 H01–H12 profile 发布或人工远端停止证明才能开始下一通;本地 SDK 停止和火山远端状态仍区分保存,不伪造供应商证明。
- 沿用服务器现有豆包 S2S 参数文件。平台已授权记忆放进本次上下文,原生共享记忆库关闭;本版不是火山自动长期记忆同步。
- Go 全包测试通过;独立临时数据库的 callmemory/onboarding/httpapi 测试通过。真实 C 适配器 ASan/UBSan 测试通过:开始取参数、无字幕上传、结束重试不变、失败恢复、权限拒绝、租约过期、迟到响应不恢复音频。SDK 敏感日志检查通过。
- 发布时发现 callbacks 不挂载设备启动参数文件,已移除该服务不需要的配置项;最终 API、worker、callbacks 健康,三个公网接口拒绝未授权请求。完整发布清单及源码归档重新校验通过。
- 已烧录 app 分区,大小 3700048 字节,SHA-256 `0afece4ff2484cba17065d7c2350aff7b98fae94a32cb70b75df2f438facf6c5`,ELF 前缀 `61ceb73ac`。保留 NVS、Wi-Fi、绑定代次 9。
- 已完成真实火山设备注册:`SDK_REGISTER / SUCCESS_OBSERVED / 0`;设备初始化配置确认已到达后端,状态 `REGISTERED_OBSERVED`。这是真实设备注册,不是模拟结果,但仍不等于实际对话和字幕回调验收。
- 最终固件启动观察 55 秒:只有主动 USB 复位一次,没有额外重启;联网、注册、激活结果、初始化确认均返回 HTTP 200;启动任务栈余量 11880 字节。
- 刷机前应用备份:`firmware/echoear-platform-agent/.tmp-call-device/before-direct-chat-app.bin`(完整 5 MiB),前 3705696 字节哈希与上一版稳定程序一致。
- 已请用户轻触屏幕试聊并挂断;实际声音和火山字幕回传结果待确认。
## 以下为旧版阶段记录
## 已更新
- 后端已上线版本:`0.87.1-rc.call2`,部署目录 `/opt/data-terminal-cloud/releases/0.87.1-rc.call2`,数据库 schema 63,未新增迁移;发布后 API、worker、callbacks 健康检查通过,公网版本接口确认一致。
- 新接口:设备 `voice-runtime`,小程序 `memories/calls/query`、`memories/calls/turns`。
- 已有通话 prepare/heartbeat 增加最新 `voice_chat` 授权快照检查;没有更改任何用户的实际授权。
- 小程序源代码:`C:\Users\19926\WeChatProjects\peibanzhushou`。新聊天记录与旧版记录分别查看,列表分页、详情分页、隐藏页面后的迟到响应隔离。不代表已上传微信体验版或正式版。
- 固件新增 CALL-MEMORY-1.1 适配、RTC 构建、设备级凭据保存、许可/结果上报、首通事件、心跳、本地音频门控、文字事件重试、重启结束补偿。
## 验证
- Go 全包测试通过;使用独立临时数据库的 `callmemory`、`onboarding`、`httpapi` 集成测试通过。
- 小程序 31 项测试通过,8 个 WXML 和 9 个 WXSS 原生编译通过。
- 对真实 `call_client.c` 执行主机 ASan/UBSan 测试:租约过期、真实观察事件顺序、失败重试正文与 ID 不变、撤销保存后的内容清除、缓存上限、超大 JSON 缺口、迟到心跳不可恢复、结束清理。
- 供应商 SDK 的日志扫描覆盖包含 verbose 在内的日志等级,不输出会话正文、配置、Device Secret 或动态注册解密内容。
- 线上新增接口匿名请求被拒绝;API/worker/callbacks 健康检查通过;签名读取确认原设备仍处于绑定状态。
## 硬件升级与回退
- 设备:EchoEar ECHOEAR-S3 V1.2,COM6,MAC `b4:3a:45:16:34:a8`。
- 更新仅写应用分区 `0x10000`;未擦除 NVS、Wi-Fi、出厂身份、模型、屏幕资源或现有绑定。
- 已读取应用分区完整备份:`firmware/echoear-platform-agent/.tmp-call-device/before-call-app.bin`,5 MiB。
- 备份前 3146016 字节 SHA-256 与原稳定版本一致:`68f1dd9f8314079695e47c8940b05f95fb849339bcaf14955a496437143d9199`。
- 第一版 RTC 固件的真机验证发现 Wi-Fi DMA 内存不足触发重启;立即恢复上述原固件,25 秒观察中只有主动复位,无额外重启。
- 随后修改 station 模式静态 RX/TX 各 6 帧、动态 RX 16、缓存 TX 8、接收 BA 窗口 6;Wi-Fi 初始化失败改为错误返回,不再触发 abort 重启;纯 BLE 出厂身份可以直接进入正常联网流程,不依赖旧 UUID 凭据。
- 调整后的固件实测 55 秒:只有主动 USB 复位一次,无额外重启;启动登记及语音配置接口返回 200,warmup 返回预期的 `PROVIDER_PROFILE_UNVERIFIED`。内部空闲 RAM 77647 字节,启动任务剩余栈 11864 字节。
- 最后增加上传前权限字段:warmup/heartbeat 明确返回 `transcript_upload_allowed`,缺失或拒绝即禁止设备发送正文。
- 最终固件已写入并通过 esptool 内容校验:3705696 字节,SHA-256 `738edc55f481bd419d380d2519169bb7e9a82a2f57d547ca86918257424bb712`;ELF 前缀 `65b26cf6b`。公网签名查询确认设备仍已绑定。
- 最终固件再次观察 55 秒:仅主动 USB 复位一次,无额外重启;启动登记和语音配置同步均为 HTTP 200;warmup 明确返回 HTTP 409 / `PROVIDER_PROFILE_UNVERIFIED`,启动栈余量 11888 字节。尚未进入真实 RTC 通话,以上稳定性结论仅覆盖启动与配置检查。
## 未完成的验收/功能
当前线上没有已验证发布的 RTC profile,因此不会消耗供应商注册许可或开启聊天麦克风。
以下仍是阻塞/后续项:真实 profile 与 H01–H12 验证、供应商远端停止自动核对、连续多通实测、长字幕分片和修订、打断与播放完成的逐句证据。
不能将“固件编译成功/平台接口可用”说成“已与豆包完成实际对话并上传聊天记录”。
接口与实际通信步骤见 [voice-chat-flow.md](voice-chat-flow.md)。
+88
View File
@@ -0,0 +1,88 @@
# 简化聊天接口(DIRECT-CHAT-1)
本文保留旧版 DIRECT-CHAT-1 协议说明。当前实现以 [老人专属智能体与设备聊天接口](elder-agent-flow.md) 为准:每位老人一个智能体,S2S 配置由服务器提交火山,不再发给设备。
## 流程
1. 设备向后端请求本次聊天参数。
2. 设备将 params 交给火山 RTC SDK,直接和火山进行语音聊天。
3. 火山通过每通独立的回调地址给后端发送文字,后端保存,设备不上传文字。
4. 设备停止 SDK,通知后端本次聊天结束。
基础地址:`https://121.41.46.238`。设备接口使用已有设备身份鉴权和 HTTPS,不再增加会话加密/证明包。
## 1. 开始聊天
`POST /api/v1/device/chat/start`
设备生成本次唯一 session_id,重试时不变;boot_id 为本次开机编号。
```json
{"session_id":"<本次会话UUID>","boot_id":"<本次开机UUID>"}
```
成功响应的 data:
```json
{
"session_id": "<原会话UUID>",
"bot_id": "<火山智能体ID>",
"params": {
"PlatformTaskID": "<原会话UUID>",
"Config": {
"S2SConfig": "<后端现有豆包配置对象>",
"MemoryConfig": {"Enable": false},
"SubtitleConfig": {"SubtitleMode": 1, "DisableRTSSubtitle": true}
},
"AgentConfig": {
"WelcomeMessage": "你好,今天想聊些什么?",
"ServerMessageURLForRTS": "<本通专属回调地址>",
"ServerMessageSignatureForRTS": "<本通回调校验值>"
}
},
"lease_expires_at_ms": 0,
"expires_at_ms": 0
}
```
上面 S2SConfig 和时间戳为示意,不是可直接运行的完整配置。params 序列化后传给 SDK;PlatformTaskID 由设备 SDK 适配层用于 GetRTCConfig 的唯一任务编号。
记忆:本版读取平台当前已授权的记忆内容,放进 S2SConfig 的会话上下文;不启用火山共享原生记忆库,不给设备数据库密码。不能声称这一版支持火山自动长期记忆同步。
内部保活复用 start 接口,每 10 秒自动请求一次同一 session_id,参数保持不变。租约最长 45 秒;解绑、授权撤回、记忆撤销或失联时停止音频。小程序不参与这一步,没有新增 heartbeat 接口。
缺少 S2S 配置返回 CHAT_S2S_CONFIG_REQUIRED;无需发布原来的 H01–H12 profile。首次供应商设备注册仍只做一次,单独保存设备级凭据,不是每通聊天步骤。
## 2. 火山回传文字
`POST /callbacks/v1/volcengine/chat?session_id=<本次会话UUID>`
使用火山原生格式:
```json
{"message":"<Base64字幕消息>","signature":"<本通回调校验值>"}
```
后端验证 signature、解码字幕,按 session_id 保存。相同说话人、roundId、sequence 的重复文字只存一次;不能用“设备最新会话”猜测归属。结束后晚到的文字仍写回原会话。
收到每句最终文字就保存,不要求火山最后一次性返回整段记录。未知说话人保留 UNKNOWN,不冒充用户记忆。字幕没有完整性结束证明时,不宣称记录百分之百完整。
## 3. 结束聊天
`POST /api/v1/device/chat/end`
```json
{"session_id":"<本次会话UUID>","reason":"hangup","sdk_stopped":true}
```
reason 可为 hangup、start_failed、disconnected、reboot、timeout。sdk_stopped 只有 SDK 确认停止才为 true。
成功响应 data:`{"session_id":"<本次会话UUID>","ended":true}`。
失败自动重试;断电保留待结束编号,重新联网后补报。服务端已接受结束的会话不能重新启动。此响应表示平台已登记结束,不冒充火山远端停止证明,不再要求人工对账才能开下一通。
## 小程序记录
沿用上一版的 `/api/v1/memories/calls/query` 和 `/api/v1/memories/calls/turns`,小程序页面无需另换接口。
官方依据:[硬件动态配置](https://www.volcengine.com/docs/6348/1807452)、[实时字幕](https://www.volcengine.com/docs/6348/1337284)。部署、编译测试与真实火山对话实测是不同验证层次,实测结果以交付记录为准。

Some files were not shown because too many files have changed in this diff Show More