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
@@ -3,6 +3,10 @@
## EchoEar 平台版固件
**2026-10-04 清库后重新创建设备、USB 烧录与初始化,见 [操作说明](../../docs/device-reinitialize-and-flash-20261004.md)。** 新工具 `tools/device_prepare.py` 直接读取后台下载的烧录 JSON,`tools/device_flash.py` 提供默认预览、完整备份与分区校验。
**当前 COM6 联调版本与操作步骤见 [TEST-COM6.md](./TEST-COM6.md)。** 下方较早的开机激活、热点配网和 v3 说明仅供历史兼容参考;当前新流程使用 BLE 绑定凭据及 direct chat,不再上报开机注册和 SDK 激活确认。
本目录是在火山官方 Embedded Kit 2.0 基础上适配的 ESP32-S3 EchoEar 固件。它使用低负载 WebSocket 方案,并将设备身份、动态注册和会话配置统一接入自有 SaaS。
安全边界:
@@ -0,0 +1,153 @@
# COM6 注册与聊天测试固件
设备已于 2026-10-04 13:03:22(北京时间)通过真实 SaaS OTA 安装 `1.7.0-controls.27 / code 11`,用于修复 RTC 通话沿用旧计数器而自行结束的问题。首次 OTA 返回 `ESP_ERR_NO_MEM`,旧 controls26 保留;经实际 SaaS 重启后,重试任务 `be8c5931-d629-4361-ab23-6c304044cd59` 成功。13:03:56 与 13:04:54 两次只读观察确认新版本运行、心跳推进、配置 19/19、音量 56、名称和绑定保留。用户明确授权麦克风测试后,已完成一次约 110 秒的有界 RTC 通话,越过原超时边界后仍保持通话,随后主动结束并收到停止回执。
## 2026-10-04 controls27 RTC 通话误结束修复与有界实测
旧聊天循环每轮等待一秒后增加 `wait_time`,累计 60 轮便退出。RTC 的音频、状态和字幕路径不能一致地重置这个原本用于 WebSocket 的计数器,因此它会变成约一分钟的 RTC 通话时限;循环中的请求耗时也会延长实际墙钟时间,并非精确 60 秒。controls27 仅让 RTC 跳过该旧计数器,保留 WebSocket 的 60 轮超时,以及主动挂断、供应商断开、平台授权租约和平台结束指令。5403 项冻结输入只改变 `volc_conv.c` 和版本头,RTC 收发、播放器、配置、依赖和分区未变。
提取实际 C 的主机测试验证 RTC 连续 10000 轮不因缺少活动重置而超时、不增加计数器,同时保留 WebSocket 第 60 轮超时和独立停止条件;这是无墙钟等待的逻辑测试,不是两小时实物通话。三份字节相同的 RTC/PCM 测试复用 controls26 已通过的 ASan/UBSan 结果,更新后的诊断测试通过普通执行及两次 UBSan。Windows ASan 独立 smoke 仍在进入 main 前失败,第四项如实记录为不可用。ESP-IDF 构建、冻结输入及独立审查通过。
固件 `artifacts/firmware/1.7.0-controls.27/echoear-1.7.0-controls.27.bin` 为 3,772,544 字节,SHA256 `bbce917e79310b7c36d3311727f8bf347a3031dd8f44816674798c803f55b5df`。维护发布采用 service/null 审计,HTTPS 回读哈希一致。真实安装和稳定复查证据为本次证据目录中的 `ota27-core.json`、`ota27-stable.json` 与 `saas-controls27-installed.jpg`。
首次通话测试因缺少麦克风传输授权,在命令执行前被自动审批拦截,未触发通话;用户随后明确授权了 110 秒测试。实际通话 `828a88a1-d438-4d56-8ad2-88078700b56b` 仅发起一次 `diag-say`,没有重复补播、麦克风抑制或临时优先级覆盖。串口在采样开始后 2.968 秒确认加入房间,房间哈希与两份云端快照一致;13:12:08 同一通话已持续 95.087966 秒,仍为 TALKING,租约剩余 41.84 秒。加入房间后超过 60 秒仍有 22 份正数上行报告,合计 748 帧、发送失败为 0;这些是向 SDK 提交音频的统计,不代表语音识别或听感质量。
采样第 110.047 秒按计划发送 `diag-stop`,距加入房间 107.079 秒;110.656 秒收到结束确认,之前未观察到旧超时退出或提前停止。云端于 13:12:24 确认同一通话 ENDED,总时长 110.335403 秒,SDK 停止及本地音频关闭;13:12:41 设备在线空闲,首次介绍完成数仍为 1。`lifetime-analysis.json` 的针对性运行检查全部通过,分析与原始数字证据哈希已核对,未保存正文或音频。本次只验证一通有界通话跨过原超时边界并可正常主动结束,不宣称所有未来掉线原因已排除,也不代替长期自然聊天或扬声器听感验收。
## 2026-10-04 controls26 接收任务调度修复与实际升级(历史)
设备于 2026-10-04 12:27:39(北京时间)通过真实 SaaS OTA 安装 `1.7.0-controls.26 / code 10`。成功任务 `e7dc7921-da9a-4b5a-a572-0989075229b6`;升级后两次只读观察确认心跳推进、配置 19/19、音量 56、名称和绑定哈希保留。RTC 接收任务常态优先级下限为 6,正常通话实测自动生效,麦克风上行及停止回执正常。用户确认此前 controls25 的两次独立高优先级回放“这两次都没啥问题”;该听感反馈不代替 controls26 的长期实际聊天验收。
controls25 的 AB、BA 及两次独立高优先级试验均完成房间哈希对照,云端确认 ENDED、SDK 停止和本地音频关闭。排除首个请求窗口及切换边界前后 1 秒后,AB 的 SDK 回调加权均值从 2726.14 μs 降至 42.83 μs,最大值从 41760 μs 降至 282 μs;BA 从高优先级还原后,均值由 39.91 μs 升至 3577.17 μs,最大值由 296 μs 升至 30181 μs。pending-send 阶段耗时随优先级变化,证据支持低优先级 SDK 回调被解码 worker 抢占,增加返回前的调度等待;这些是包含抢占等待的墙钟耗时。用户反馈 AB 第二遍更连贯;BA 只听到一次,不能算完整听感对照;之后两个独立高优先级通话均反馈没有问题。
controls26 将当前 `VolcRTCMain` 任务的基础优先级下限设为 6,仅由 SDK 回调所属任务设置自身;已高于 6 的优先级保留,不使用历史任务指针。设置适用于正常通话和重新创建的 SDK 任务,旧 USB 临时优先级指令不再改变下限。保留有界压缩包队列、PSRAM 解码任务、播放器、麦克风和通话结束逻辑。5403 项冻结输入只有 RTC C、聊天适配 C、USB 命令 C 和版本头四项改变,依赖、配置和分区不变。
三份字节完全相同的队列、PCM 和 consumed 解码 C 测试复用 controls25 已执行的 GCC ASan/UBSan 结果;新增优先级/诊断 C 测试通过两次 UBSan 和普通执行。Windows ASan 独立 smoke 程序在进入 main 前崩溃,因此第四项 ASan 记录为不可用,不宣称通过。ESP-IDF 构建及制品校验通过,独立审查 READY。
固件 `artifacts/firmware/1.7.0-controls.26/echoear-1.7.0-controls.26.bin` 为 3,772,544 字节,SHA256 `304d57fba171d3902d950b37c17704752ad6dd71645f27a746d5e8c4d92a8606`。维护发布以 service/null 审计,HTTPS 回读与冻结包一致,生产容器保持原实例;设备安装使用现有 SaaS 页面。首次 OTA `7827dd3a-c951-4ade-9740-ae03951d3b73` 下载完整,但校验辅助任务分配返回 `0x101`,尚未切换启动分区,原固件保留。设备空闲重启后再次 OTA 成功;首次心跳前 OTA 能力暂为 false,后续恢复 true。成功下载/写入/校验合计 42055 ms,不把首次失败隐藏为成功。
升级后的普通 `diag-say` 通话未使用麦克风抑制或临时高优先级指令。实物记录 SDK 基础优先级从 3 自动变为 6,15 秒快照 priority/base 均为 6、观测年龄 58 ms;麦克风上行帧数持续为正。23 秒请求结束后,实际通话 `a01321c8-4a64-43f4-9ce4-0d779302ed8a` 的房间哈希与串口匹配,云端确认 ENDED、sdk_stopped、local_audio_off,首次介绍仍仅完成一次。数字采集不保存正文或音频。
上述测量不证明所有停顿消失:正常测试仍出现接收间隔及播放器重新预缓冲。PCM 可包含静音,TTS wrapper 返回 0 不证明实际播完,接收间隔不能直接视为网络丢包,软件队列空也不等于扬声器 DMA 已断音。controls25 的听感反馈和 controls26 的默认设置/收音/结束验证分别记录;未声称 controls26 的长期双向聊天听感已验收。完整实际升级与证据索引见制品目录 `verification.json`。
下方 controls24 及更早记录保留为历史证据。
## 2026-10-04 controls24 语音断续调查(历史)
RTC SDK 文档要求同步回调避免耗时工作,原实现直接在收包回调解码并转交 PCM。controls24 采用 48 个完整压缩包的有界队列,独立工作任务解码,停止和打断按 generation 隔离;PSRAM 中保存包和任务栈,低内部内存下避免 WithCaps 任务自删额外分配。实际生产 C 的队列、consumed 解码、PCM 分包三份测试通过 GCC ASan/UBSan,包括 10 组生命周期与边界测试;独立审查通过,ESP-IDF 构建成功。5403 项任务输入中只有 RTC C 和版本头两项改变,配置和依赖保持不变。
制品为 `artifacts/firmware/1.7.0-controls.24/echoear-1.7.0-controls.24.bin`,3,770,064 字节,SHA256 `270009b693e400c6a6b93dd7cd8a71982e31c7a1a0ad85fa074db3303c66e593`。先以 service/null 审计发布 READY,并完成 HTTPS 逐字节回读,再经实际 SaaS 安装;未重建云端容器。验收记录为该制品目录的 `verification.json`。
同一 USB 固定句请求的 55 秒采样中,解码最大墙钟耗时由 43 ms 降至 8 ms,压缩队列峰值 1、溢出与解码错误均为 0。但更新后仍有播放缓冲耗空,最大收包间隔为 1475 ms,SDK 回调最大墙钟耗时为 515 ms。两轮供给比 0.640 / 0.575 混合正常开场、LLM 生成及句间停顿,不能当作丢包率或音质回退证明。下一轮将直接固定 TTS、隔离麦克风回传并测量任务调度;尚未录音或证明扬声器连续播放。
设备于 2026-10-02 23:48:27(北京时间)经真实 SaaS 在线升级成功,当时运行 `1.7.0-controls.23 / code 7`。升级后两次观察间隔 54 秒,心跳持续推进,绑定、配置版本 19/19、音量 56 和运行模式保留。首次安装在下载完成后的内部校验任务创建阶段因内存不足失败,旧固件保留;设备空闲时重启后再次升级成功。目标硬件是 EchoEar ESP32-S3 V1.2,16 MB Flash / 16 MB PSRAM,每个应用槽为 4 MiB。下方旧版本记录保留作为历史证据。
## 2026-10-02 圆屏通话状态
保留眼睛动画,在上方显示 32px 中文:待机、正在连接、通话中、正在结束、通话结束、连接失败、连接中断。语音 SDK 报告连接成功才显示“通话中”;一段回答完成仍保持通话。本地收音和播放停止后显示结束状态,约两秒后回到待机;慢速云端结束上报的耗时计入该停留时间。
显示调用只提交状态,原显示任务处理字形与刷新,不在语音回调中等待屏幕。字库沿用原有字形,离线放大并转为当前 GFX 兼容格式。实际状态任务和字体解析器均通过 GCC ASan/UBSan;ESP-IDF 5.5.1 编译成功。上述主机检查不代替实物圆屏、声音和供应商旧房间回调的验收。
升级包为 `artifacts/firmware/1.7.0-controls.23/echoear-1.7.0-controls.23.bin`,3,767,520 字节,SHA256 `e88ff30446453072b4c2eeb06731e64a66e6bb29b43bfca3874998efec37a0f4`。云端发布记录 `980152d7-e6f4-4a33-ac41-e5f99872a652`,完整 HTTPS 下载回读与冻结包逐字节及 SHA256 一致,使用 service/null 维护审计,发布时未发起升级;随后在真实 SaaS 页面完成安装。成功任务 `896d6654-5ec8-4289-afbb-7c5171b55d64`,实际固件、心跳、配置与绑定验收见 `artifacts/firmware/1.7.0-controls.23/verification.json`。SaaS“陪伴设备”概况和详情均有“固件升级”入口。圆屏实际观感与声音仍需实物检查,未把 OTA 成功当作显示和音频验收。
本次完整备份后仅写活动 `ota_0 / 0x10000`,哈希校验通过;蓝牙只读交互通过,5 分钟未提交配网后实际恢复原 Wi-Fi。20:53:59 北京时间云端确认在线、MQTT 和 OTA 支持正常,原绑定、音量 56、配置版本 19/19 保留。操作和验证边界见 [远程升级交付记录](../../docs/device-remote-ota-20260930.md)。
## 2026-09-22 重新配网启动修复
进入配网分支时,原 24 KiB 启动栈仍占用内部 RAM;Wi-Fi 初始化后只剩约 41.6 KiB,出现蓝牙 `Malloc failed` 和屏幕 SPI 刷新失败。启动日志曾提前打印“配网可用”,但实际扫描不到设备。
现在将板级/配网启动与联网/语音启动分开:前者使用 8 KiB 内部栈,只有正常联网模式才分配 24 KiB 的网络启动栈。板级启动必须保留内部栈,屏幕资源 mmap 会冻结缓存,不能直接把该栈移到 PSRAM。蓝牙广播配置/启动结果也单独记录,不再把初始化完成当成广播成功。
COM6 实测:编译及应用分区烧录校验通过;BLE 初始化前内部空闲 RAM 为 57,959 字节,启动栈余量 5,224 字节;观察窗口内未出现蓝牙分配失败、屏幕 SPI 刷新失败或崩溃。电脑实际扫描并连接 `vl-ECHOEAR-S3-26091601`,协议 v2 的 `get_info` 返回本机编号 `KP01-020026091601`,`get_status` 返回 `idle`。只写入 `0x10000` 应用分区,保留绑定与 Wi-Fi。应用 SHA256:`e3d47837179bcf76e93bcd03195e3ad21dbd58daea31a14cd54d8840da817856`。
操作:正常运行时,持续按住圆屏底部中间区域约 3 秒(起点 x=60..300、y=280..335),进入 BLE 配网;不是长按机身电源键。小程序选择“重新配网”,搜索以上名称,发送 2.4 GHz Wi-Fi 后轻触圆屏确认。重新配网不需要解绑。窗口约 5 分钟,过期后重新开启。
用户随后确认手机端操作已完成、可以绑定。平台复查:有效绑定数 1,MQTT 已连接,实际固件 `1.7.0-controls.10`,上报配置版本与目标版本均为 15,音量均为 22,待执行指令为 0;最近状态上报为 `2026-09-22T13:27:45.993773Z`。手机操作、设备重新联网与配置同步已确认;真实麦克风/扬声器对话仍需单独验证。
## 2026-09-16 声音断续修复
问题在 RTC 接收时钟配置,不是本次测试中的 Wi-Fi 信号或解码失败。Opus 接收端原来设置 `r_sample_rate=16000`、`r_samples_per_frame=320`,实物每约 60 ms 才回调一包,但每包解码只有 20 ms PCM。每秒只提供约 340 ms 声音,缓冲耗尽后持续断续。
接收端改为 `r_sample_rate=48000`、`r_samples_per_frame=960`。Opus RTP 使用 48 kHz 时间戳时钟,和本地解码/扬声器的 16 kHz PCM 是两个概念;不应把扬声器采样率也改为 48 kHz。依据:[RFC 7587,第 4.1 节](https://datatracker.ietf.org/doc/html/rfc7587#section-4.1)。现有上行编码参数保持不变。
实物对照测试:
下表为修正前与修正后首通约 25 秒测试的对照,非所有通话的最低缓冲保证。
| 指标 | 修改前 | 修改接收配置后 |
| --- | --- | --- |
| 下行包数 | 约 17 包/秒 | 约 50 包/秒 |
| 每秒解码出的音频时长 | 约 340 ms | 约 1000 ms |
| 播放队列 | 持续空队列 | 约 420–450 ms 缓冲 |
| 解码/播放器写入错误 | 0 / 0 | 0 / 0 |
同时补充解码器 consumed 循环,防止忽略未消费的输入,以及每秒一次的收包/播放统计。单独修改 consumed 循环不能解决本次断续;接收时钟修改后的实物对照才确认音频供给速率恢复。
第二通测试持续约 60 秒后主动挂断,55 个下行统计窗口合计 55,910 ms,解码音频 55,380 ms,供给比 0.991,解码错误 0;平台确认结束,未出现崩溃或重启。期间观测到最大 339 ms 的收包间隔和一次 260 ms 间隔,软件播放队列随后耗空,因此不能宣称彻底消除所有短时断续。没有基于 RSSI 判断该停顿来自 Wi-Fi,也未在缺乏证据时修改路由器或平台配置。待用户确认听感,若仍断续,再针对短时抖动和补充缓冲定位。
回归检查:实际 C PCM 分帧、RTC JSON(包含接收 48000/960 断言)、解码 consumed 循环及边界测试均在 ASan/UBSan 下通过;敏感日志检查通过。COM6 应用烧录哈希校验通过。第二通过滤日志位于受限目录 `audio-fix-verification.log`,不包含音频或会话正文。
本次只烧录 `0x10000` 应用分区,保留当前绑定、Wi-Fi 和火山凭据。原应用备份在受限目录的 `before-audio-fix-app.bin`。固定测试语句可以验证数据链路,最终扬声器听感及麦克风真实对话仍需人工确认。
## 本次实物
- 串口:COM6
- MAC:`B4:3A:45:16:34:A8`
- 平台设备编号:`KP01-020026091601`
- 平台公开 ID:`623a4e54-bdc4-4c98-b03e-27267d3ff360`
- 蓝牙名称:`vl-ECHOEAR-S3-26091601`
- 后端:`https://82.157.201.114`
2026-09-16 按用户要求改为全新逻辑测试设备:`020026091601` 是本板专用测试身份,不是实测 MAC;物理 MAC 不变。平台重新登记并生成专属密钥,工厂分区换成新身份,普通 NVS 已清空(Wi-Fi、旧火山凭据、配网及聊天缓存)。旧设备 `KP01-B43A451634A8` 已解绑,历史记录及火山旧注册记录保留,不在这次测试中复用;家庭和“姥姥”保留。不得把本板身份或 Flash 复制到其他设备。
## 已实现的流程
1. 无配网记录、手动请求配网或开机确认平台已解绑时,进入 BLE 配网。
2. 小程序通过固定 GATT Service `8e7a2000-6f4b-4a2d-9c31-7d824f5e6100` 搜索,发送 `get_info` 获取设备编号和型号。
3. 小程序发送 `configure`(Wi-Fi 与 bind_token),设备返回 received。轻点设备屏幕确认后连接 Wi-Fi。
4. 非空令牌调用 `/api/v1/device/ble-bind`,保存返回的 instance_id、product_key、device_name、device_secret、RTCAppID。空令牌只调用 `/api/v1/device/ble-state` 确认仍绑定,再换 Wi-Fi。
5. 绑定成功后保存配置,约 15 秒后重启。小程序通过 `get_status` 查看成功/失败。
6. 正常开机使用已保存的火山凭据,在本机初始化 SDK;不调用开机 register、voice-runtime、activation-results 或 activation-acks。凭据丢失时才用 `/api/v1/device/voice-config` 恢复。
7. 轻触屏幕或唤醒后调用 `/api/v1/device/chat/start`。每次新聊天生成新 session_id;同一通每 10 秒续期,平台拒绝或授权到期停止聊天。
8. 将 bot_id、params 和已保存的火山凭据交给现有 RTC SDK。模型、智能体和记忆配置由平台管理;聊天文字由火山回调平台,不从设备另传一份。
9. 停止 SDK 后调用 `/api/v1/device/chat/end`。断网保存待上报结束记录,重启补报 reboot;不把未确认的停止谎报成功。
BLE 使用现有明文 JSON 协议:2 字节小端长度头,分片最多 20 字节,TX 使用 Indicate。Wi-Fi 密码和火山凭据不写日志。HTTP 继续沿用平台要求的 DEVREQ1 签名和校时,不改变服务器接口。
## 人工测试
1. 小程序登录,创建家庭和测试老人。
2. 添加 `vl-ECHOEAR-S3-26091601`,填写 2.4 GHz Wi-Fi,发送后轻点设备圆屏确认。
3. 等小程序显示成功和设备重启,再轻触圆屏开始对话。
4. 说一句话,确认设备有声音;结束后查看小程序的聊天记录。
5. 平台解绑后重启设备,应回到 BLE 配网,而不是用旧老人身份继续聊天。
BLE 配网窗口约 5 分钟。窗口过期时重启后重新搜索。未绑定时不会自动创建老人,也不会静默绑定到其他家庭。
开发联调命令(仅 USB 串口、本机主动触发):`diag-say` 请求固定测试语句,`diag-stop` 挂断,`diag-state` 读取运行诊断。它们不能代替真实的麦克风、扬声器和回调验收。
## 构建与烧录
```powershell
$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 build
```
首次更新只烧录应用到 `0x10000`。随后为全新设备测试,备份当前配置,擦除普通 NVS `0x9000/0x6000`,向工厂分区 `0xe4e000/0x6000` 写入新身份并校验成功。应用、唤醒模型、屏幕资源保持不变。分区不一致的其他设备不能直接照抄这些偏移。
## 注册版本的验证记录(1.6.0,重置当时状态)
- 编译链接成功:应用 3,705,056 字节,小于 5 MB 应用分区。
- 实际 C 聊天适配测试通过:绑定凭据保存、缓存开机、缺失凭据恢复、无旧激活接口、解绑拒绝、续期和结束重试。
- 实际 C 蓝牙接口测试通过:读取信息、配网、绑定/仅换 Wi-Fi、重试、超时和非法输入。
- SDK 敏感日志检查通过。
- COM6 烧录校验成功;串口实测显示正确 BLE 名称,启动任务剩余栈 21,496 字节。
- 新身份重置后实测 BLE 为 `vl-ECHOEAR-S3-26091601`、状态 idle;平台状态 CLAIMABLE,绑定数、激活任务数、火山凭据数、聊天数均为 0。火山注册留到用户首次绑定时触发,未提前注册。
- 真实小程序配网绑定、双向语音与火山回调:等待人工绑定后继续验证,尚未标记通过。
完整旧 Flash 已备份在本机受限目录 `E:\Codex\story\.local-evidence\com6-test-20260916\before-flash-16mb.bin`,SHA256 为 `b6e39391e79a10bedd16f7e2e097c267078741d18c68fbf0366d090800fbb51f`。备份包含私有凭据,不可公开分享。
本次重置前最新分区另备份为同目录 `before-new-device-nvs.bin` 和 `before-new-device-factory.bin`;服务器数据库备份为 `/root/com6-new-device-20260916/before-new-device.dump`。恢复板上配置与恢复绑定关系是两回事,不要直接恢复整个数据库覆盖后续人工测试数据。新初始化私有文件存放于本机受限目录,未写入本文。
本次只验收注册、配网绑定与聊天;不把 MQTT 在线状态、OTA 或其他型号硬件宣称为已实测。
@@ -16,6 +16,8 @@ set(VOLC_SERVICE_INCS "${CMAKE_CURRENT_LIST_DIR}/../service/inc"
set(VOLC_SERVICE_SRCS "${CMAKE_CURRENT_LIST_DIR}/../service/src/volc_manager_service.c"
"${CMAKE_CURRENT_LIST_DIR}/../service/conv_service/src/volc_conv_service.c"
"${CMAKE_CURRENT_LIST_DIR}/../service/conv_service/src/volc_conv.c"
"${CMAKE_CURRENT_LIST_DIR}/../service/conv_service/src/call_display_state.c"
"${CMAKE_CURRENT_LIST_DIR}/../service/conv_service/src/call_voice_end.c"
"${CMAKE_CURRENT_LIST_DIR}/../service/function_call_service/src/volc_function_call_service.c"
"${CMAKE_CURRENT_LIST_DIR}/../service/function_call_service/src/volc_local_function_list.c"
"${CMAKE_CURRENT_LIST_DIR}/../service/local_logic_service/src/local_logic_service.c"
@@ -15,6 +15,7 @@ set(VOLC_ESP_SRCS "${CMAKE_CURRENT_LIST_DIR}/../platform/src/espressif/volc_hal.
"${CMAKE_CURRENT_LIST_DIR}/../platform/src/espressif/basic_board.c"
)
set(VOLC_ESP_DISPLAY_SRCS "${CMAKE_CURRENT_LIST_DIR}/../platform/src/espressif/lvgl_source/echoear_font.c"
"${CMAKE_CURRENT_LIST_DIR}/../platform/src/espressif/lvgl_source/echoear_call_status_font.c"
"${CMAKE_CURRENT_LIST_DIR}/../platform/src/espressif/lvgl_source/img_app_aiPhone.c"
)
set(VOLC_APP_SRCS ${VOLC_FRAMEWORK_SRCS}
@@ -39,7 +40,7 @@ idf_component_register(
SRCS ${VOLC_APP_SRCS}
INCLUDE_DIRS ${VOLC_APP_INCS}
REQUIRES volc_conv_ai
PRIV_REQUIRES lvgl button av_processor esp_board_manager hw_init esp_mmap_assets esp_emote_gfx esp_vocat_base_control
PRIV_REQUIRES nvs_flash lvgl button av_processor esp_board_manager hw_init esp_mmap_assets esp_emote_gfx esp_vocat_base_control
)
# Compiler flags
@@ -60,6 +60,7 @@ volc_hal_display_t volc_hal_display_create(volc_hal_display_config_t* config);
* @return volc_hal_display_t Display instance pointer
*/
int volc_hal_display_set_brightness(volc_hal_display_t display, int brightness);
int volc_hal_display_get_brightness(volc_hal_display_t display);
/**
* @brief Destroy a Display instance
@@ -82,4 +83,4 @@ int volc_hal_display_set_content(volc_hal_display_t display, volc_hal_display_ob
}
#endif
#endif
#endif
@@ -48,6 +48,10 @@ int volc_hal_set_audio_player_volume(volc_hal_player_t player, int volume);
* @return the volume
*/
int volc_hal_get_audio_player_volume(volc_hal_player_t player);
// Board codec exists while idle; cloud volume must not depend on a temporary
// conversation player, which is created and destroyed for each call.
int volc_hal_set_device_volume(int volume);
int volc_hal_get_device_volume(void);
/**
* @brief Destroy a player instance
@@ -90,6 +94,10 @@ int volc_hal_player_play_data(volc_hal_player_t player, const void* data, int le
void volc_hal_player_discard_pending(volc_hal_player_t player);
/* Allow a fully received short response to start without filling the prebuffer. */
void volc_hal_player_finish_segment(volc_hal_player_t player);
/* Conversation task only, while it owns a live player. Includes feeder tail. */
bool volc_hal_player_voice_quiet(volc_hal_player_t player, unsigned quiet_ms);
/* Set before start. NULL leaves non-call audio unrestricted. */
void volc_hal_player_set_authorization_gate(volc_hal_player_t player, bool (*allowed)(void));
#ifdef __cplusplus
}
@@ -0,0 +1,166 @@
/* Generated by tools/generate_call_status_font.py; do not hand edit.
* Source SHA256: abfffdf0cd809cf2e3cd9425988b8945786fb5166bf4c5e91d2b6e1fb3839f74
* 2x nearest-neighbor glyphs; packed 1bpp, GFX 1/256-pixel advance.
* The original source font's leading notice is preserved below.
*/
/*******************************************************************************
* Size: 16 px
* Bpp: 1
* Opts: --bpp 1 --size 16 --no-compress --stride 1 --align 1 --font 方正兰亭纤黑.TTF --range 32-126,19968-40869,12288-12544,65280-65312 --format lvgl -o zh_font.c
******************************************************************************/
#ifdef LVGL_VERSION_MAJOR
#error "Build this font in its own GFX-only translation unit"
#endif
#include "widget/gfx_font_lvgl.h"
#if LVGL_VERSION_MAJOR != 8 || LV_FONT_FMT_TXT_LARGE != 1
#error "Unexpected GFX font compatibility ABI"
#endif
_Static_assert(sizeof(lv_font_fmt_txt_glyph_dsc_t) == 16, "GFX LARGE glyph ABI required");
static const uint8_t status_bitmap[] = {
/* U+003F ? */
15,240,15,240,48,12,48,12,192,15,192,15,192,3,192,3,0,15,0,15,0,12,0,12,
0,240,0,240,3,192,3,192,3,0,3,0,3,0,3,0,0,0,0,0,0,0,0,0,
3,0,3,0,
/* U+4E2D 中 */
0,12,0,0,3,0,0,0,192,0,0,48,0,0,12,0,0,3,0,15,255,255,255,255,
255,255,192,12,0,240,3,0,60,0,192,15,0,48,3,192,12,0,240,3,0,60,0,192,
15,0,48,3,255,255,255,255,255,255,252,0,192,15,0,48,3,0,12,0,0,3,0,0,
0,192,0,0,48,0,0,12,0,0,3,0,0,0,192,0,0,48,0,0,12,0,0,3,
0,0,
/* U+5728 在 */
0,12,0,0,0,48,0,0,3,0,0,0,12,0,0,63,255,255,252,255,255,255,240,3,
0,0,0,12,0,0,0,192,48,0,3,0,192,0,48,3,0,0,192,12,0,3,0,48,
0,12,0,192,0,243,255,255,3,207,255,252,51,0,48,0,204,0,192,0,48,3,0,0,
192,12,0,3,0,48,0,12,0,192,0,48,3,0,0,192,12,0,3,0,48,0,12,0,
192,0,63,255,255,192,255,255,255,3,0,0,0,12,0,0,0,
/* U+5931 失 */
0,3,0,0,0,12,0,0,48,48,0,0,192,192,0,3,3,0,0,12,12,0,0,255,
255,255,3,255,255,252,60,3,0,0,240,12,0,3,0,48,0,12,0,192,0,0,3,0,
0,0,12,0,15,255,255,255,255,255,255,255,0,3,0,0,0,12,0,0,0,204,0,0,
3,48,0,0,12,192,0,0,51,0,0,3,3,0,0,12,12,0,3,192,15,0,15,0,
60,3,192,0,15,207,0,0,63,192,0,0,3,0,0,0,0,
/* U+5F85 待 */
0,0,48,0,0,0,192,0,48,3,0,0,192,12,0,15,0,48,0,60,0,192,0,192,
255,255,3,3,255,252,48,0,48,0,192,0,192,12,60,3,0,48,240,12,0,3,63,255,
252,12,255,255,240,192,0,0,3,0,0,0,60,0,3,0,240,0,12,12,195,255,255,243,
15,255,255,12,0,3,0,48,0,12,0,192,192,48,3,3,0,192,12,3,3,0,48,12,
12,0,192,48,48,3,0,192,192,12,0,3,0,48,0,12,0,192,15,240,3,0,63,192,
/* U+63A5 接 */
12,0,48,0,192,3,0,12,0,48,0,192,3,0,12,63,255,240,195,255,255,255,12,3,
207,240,192,60,12,3,3,0,192,48,48,12,255,255,240,207,255,255,12,0,0,0,192,0,
0,63,3,0,3,240,48,0,204,255,255,252,207,255,255,12,12,3,0,192,192,48,12,12,
3,0,192,192,48,12,15,12,0,192,240,192,12,0,252,0,192,15,192,12,15,195,192,192,
252,60,240,240,0,63,15,0,3,
/* U+65AD 断 */
192,192,0,12,12,0,0,192,192,255,252,12,15,255,204,204,192,12,204,204,0,204,204,192,
12,204,204,0,192,192,192,12,12,12,0,192,192,192,12,12,12,0,207,252,255,252,255,207,
255,195,192,192,204,60,12,12,195,240,192,204,63,12,12,204,204,192,204,204,204,12,240,192,
192,207,12,12,12,192,192,192,204,12,12,12,192,3,0,204,0,48,12,255,255,0,207,255,
240,12,0,15,0,192,0,240,12,
/* U+673A 机 */
12,0,0,0,48,0,0,0,192,63,240,3,0,255,192,12,3,3,0,48,12,12,15,252,
48,48,63,240,192,192,12,3,3,0,48,12,12,0,192,48,48,3,0,192,192,63,3,3,
0,252,12,12,3,204,48,48,15,48,192,192,204,3,3,3,48,12,12,12,192,48,48,51,
0,192,192,12,3,3,0,48,12,12,0,192,192,48,195,3,0,195,12,12,3,12,48,48,
12,48,195,0,48,195,12,0,195,12,192,3,240,51,0,15,192,
/* U+675F 束 */
0,3,0,0,0,12,0,15,255,255,255,255,255,255,255,0,3,0,0,0,12,0,0,0,
48,0,0,0,192,0,15,255,255,192,63,255,255,3,0,48,12,12,0,192,48,48,3,0,
192,192,12,3,3,0,48,12,12,0,192,48,15,255,255,192,63,255,255,0,0,252,0,0,
3,240,0,0,51,48,0,0,204,192,0,12,48,192,0,48,195,0,3,3,3,192,12,12,
15,15,192,48,3,255,0,192,15,0,3,0,0,0,12,0,0,
/* U+6B63 正 */
63,255,255,252,255,255,255,240,0,12,0,0,0,48,0,0,0,192,0,0,3,0,0,0,
12,0,0,0,48,0,3,0,192,0,12,3,0,0,48,12,0,0,192,48,0,3,0,255,
240,12,3,255,192,48,12,0,0,192,48,0,3,0,192,0,12,3,0,0,48,12,0,0,
192,48,0,3,0,192,0,12,3,0,0,48,12,0,0,192,48,0,255,255,255,255,255,255,
255,240,
/* U+7ED3 结 */
3,0,12,0,12,0,48,0,48,0,192,0,192,3,0,12,15,255,252,48,63,255,240,192,
0,192,3,0,3,0,48,192,12,0,195,0,48,15,252,0,192,63,240,3,0,3,15,255,
240,12,63,255,192,192,0,0,3,0,0,0,12,0,0,0,48,0,0,3,60,63,255,12,
240,255,252,60,3,0,48,240,12,0,192,0,48,3,0,0,192,12,3,243,0,48,15,204,
0,195,192,63,255,15,0,255,252,0,3,0,48,0,12,0,192,
/* U+8BDD 话 */
48,0,0,0,192,0,0,3,3,255,255,12,15,255,252,12,0,48,0,48,0,192,0,0,
3,0,0,0,12,0,0,255,255,252,3,255,255,255,192,3,0,63,0,12,0,12,0,48,
0,48,0,192,0,192,3,0,3,0,12,0,12,15,255,240,48,63,255,192,192,192,3,3,
3,0,12,12,12,0,48,48,48,0,192,192,192,3,3,3,0,12,12,204,0,48,51,48,
0,192,240,255,255,3,195,255,252,0,12,0,48,0,48,0,192,
/* U+8D25 败 */
0,0,12,0,0,0,12,0,63,252,12,0,63,252,12,0,48,12,48,0,48,12,48,0,
51,12,63,255,51,12,63,255,51,12,48,48,51,12,48,48,51,12,240,48,51,12,240,48,
51,15,48,48,51,15,48,48,51,12,48,48,51,12,48,48,51,12,48,192,51,12,48,192,
51,12,12,192,51,12,12,192,51,12,12,192,51,12,12,192,12,192,3,0,12,192,3,0,
12,60,12,192,12,60,12,192,60,0,48,48,60,0,48,48,240,3,192,15,240,3,192,15,
0,3,0,0,0,3,0,0,
/* U+8FDE 连 */
48,0,192,0,192,3,0,0,192,12,0,3,0,48,0,12,63,255,252,48,255,255,240,0,
48,0,0,0,192,0,0,3,12,0,0,12,48,0,0,192,192,0,3,3,0,252,12,12,
3,240,48,48,0,195,255,255,195,15,255,255,12,0,12,0,48,0,48,0,192,0,192,3,
0,3,0,12,255,255,252,51,255,255,240,192,0,192,3,0,3,0,12,0,12,0,48,0,
48,3,60,0,0,12,240,0,0,192,255,255,255,3,255,255,240,
/* U+901A 通 */
60,63,255,240,240,255,255,192,240,48,12,3,192,192,48,3,0,255,0,12,3,252,0,3,
255,255,0,15,255,252,252,48,48,51,240,192,192,192,195,255,255,3,15,255,252,12,48,48,
48,48,192,192,192,195,3,3,3,12,12,12,12,63,255,240,48,255,255,192,195,3,3,3,
12,12,12,12,48,48,48,48,192,192,195,243,0,63,15,204,0,252,192,255,255,255,3,255,
255,240,
};
static const lv_font_fmt_txt_glyph_dsc_t status_glyphs[] = {
{0}, /* glyph zero is reserved */
{0,4960,16,26,2,-2}, /* ? */
{52,8192,26,30,2,-4}, /* 中 */
{150,8192,30,30,0,-4}, /* 在 */
{263,8192,30,30,2,-4}, /* 失 */
{376,8192,30,32,2,-4}, /* 待 */
{496,8192,28,30,2,-4}, /* 接 */
{601,8192,28,30,2,-4}, /* 断 */
{706,8192,30,30,2,-4}, /* 机 */
{819,8192,30,30,0,-4}, /* 束 */
{932,8192,30,26,0,-2}, /* 正 */
{1030,8192,30,30,0,-4}, /* 结 */
{1143,8192,30,30,0,-4}, /* 话 */
{1256,8192,32,32,0,-6}, /* 败 */
{1384,8192,30,30,0,-4}, /* 连 */
{1497,8192,30,26,0,-2}, /* 通 */
};
#define STATUS_CMAP(cp, id) { .range_start=(cp), .range_length=1, .glyph_id_start=(id), .unicode_list=NULL, .glyph_id_ofs_list=NULL, .list_length=0, .type=LV_FONT_FMT_TXT_CMAP_FORMAT0_TINY }
static const lv_font_fmt_txt_cmap_t status_cmaps[] = {
STATUS_CMAP(0x003F, 1),
STATUS_CMAP(0x4E2D, 2),
STATUS_CMAP(0x5728, 3),
STATUS_CMAP(0x5931, 4),
STATUS_CMAP(0x5F85, 5),
STATUS_CMAP(0x63A5, 6),
STATUS_CMAP(0x65AD, 7),
STATUS_CMAP(0x673A, 8),
STATUS_CMAP(0x675F, 9),
STATUS_CMAP(0x6B63, 10),
STATUS_CMAP(0x7ED3, 11),
STATUS_CMAP(0x8BDD, 12),
STATUS_CMAP(0x8D25, 13),
STATUS_CMAP(0x8FDE, 14),
STATUS_CMAP(0x901A, 15),
};
#undef STATUS_CMAP
static const lv_font_fmt_txt_dsc_t status_font_dsc = {
.glyph_bitmap=status_bitmap, .glyph_dsc=status_glyphs, .cmaps=status_cmaps,
.kern_dsc=NULL, .kern_scale=0, .cmap_num=15, .bpp=1,
.kern_classes=0, .bitmap_format=0, .cache=NULL
};
static const lv_font_t status_font = {
.get_glyph_dsc=lv_font_get_glyph_dsc_fmt_txt, .get_glyph_bitmap=lv_font_get_bitmap_fmt_txt,
.line_height=38, .base_line=10, .subpx=LV_FONT_SUBPX_NONE, .static_bitmap=1,
.underline_position=-6, .underline_thickness=2, .dsc=&status_font_dsc, .fallback=NULL, .user_data=NULL
};
const void *echoear_call_status_font(void) { return &status_font; }
@@ -0,0 +1,14 @@
/* Generated by tools/generate_call_status_font.py; do not hand edit.
* Source SHA256: abfffdf0cd809cf2e3cd9425988b8945786fb5166bf4c5e91d2b6e1fb3839f74
* 2x nearest-neighbor glyphs; packed 1bpp, GFX 1/256-pixel advance.
* The original source font's leading notice is preserved below.
*/
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
/* GFX compatibility font; deliberately exposes no LVGL types. */
const void *echoear_call_status_font(void);
#ifdef __cplusplus
}
#endif
@@ -2,6 +2,10 @@
// SPDX-License-Identifier: Apache-2.0
#include <stddef.h>
#include <stdatomic.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "volc_hal.h"
#include "volc_hal_capture.h"
@@ -39,8 +43,10 @@ typedef struct {
volc_hal_capture_data_cb_t data_cb; // Data callback function
volc_hal_audio_wakeup_cb_t audio_wakeup_cb; // audio wakeup cb
void* user_data; // User data pointer
volc_osal_tid_t capture_thread;
volatile bool is_started;
TaskHandle_t capture_thread;
SemaphoreHandle_t capture_stopped;
uint8_t *read_buf;
atomic_bool is_started;
} volc_hal_capture_impl_t;
extern basic_board_periph_t global_periph;
@@ -61,13 +67,12 @@ static void __volc_audio_capture_task(void *arg)
volc_hal_capture_impl_t *impl = (volc_hal_capture_impl_t *)arg;
LOGI("capture task started...");
int read_size = __volc_capture_get_default_read_size(impl);
uint8_t *read_buf = volc_osal_malloc(read_size);
// mem_assert(read_buf);
uint8_t *read_buf = impl->read_buf;
while(impl->is_started) {
int ret = audio_recorder_read_data(read_buf, read_size);
if (ret > 0 && impl->data_cb && impl->audio_capture_config.state == CAPTURE) {
if (impl->is_started && ret > 0 && impl->data_cb && impl->audio_capture_config.state == CAPTURE) {
#ifdef CONFIG_VOLC_AUDIO_G711A
volc_hal_frame_info_t frame_info = {
.data_type = VOLC_AUDIO_DATA_TYPE_G711A,
@@ -83,13 +88,10 @@ static void __volc_audio_capture_task(void *arg)
#endif
}
}
// audio_recorder_pause();
if (impl->capture_thread) {
volc_osal_thread_destroy(impl->capture_thread);
impl->capture_thread = NULL;
}
volc_osal_free(read_buf);
volc_osal_thread_exit(NULL);
// Stop owns deletion with the original WithCaps handle. No old reader may
// survive into the next recorder, or erase a newly created task's handle.
xSemaphoreGive(impl->capture_stopped);
for (;;) vTaskSuspend(NULL);
}
volc_hal_capture_t volc_hal_capture_create(volc_hal_capture_config_t *config)
@@ -100,6 +102,9 @@ volc_hal_capture_t volc_hal_capture_create(volc_hal_capture_config_t *config)
return NULL;
}
volc_hal_capture_impl_t *capture = (volc_hal_capture_impl_t *)volc_osal_calloc(1, sizeof(volc_hal_capture_impl_t));
if (!capture) return NULL;
capture->capture_stopped = xSemaphoreCreateBinary();
if (!capture->capture_stopped) { volc_osal_free(capture); return NULL; }
// config
capture->is_started = false;
capture->media_type = config->media_type;
@@ -132,53 +137,37 @@ volc_hal_capture_t volc_hal_capture_create(volc_hal_capture_config_t *config)
return (volc_hal_capture_t)capture;
}
static void __start_audio_capture(volc_hal_capture_impl_t *capture)
static int __start_audio(volc_hal_capture_impl_t *capture, bool wakeup)
{
// audio_pipeline_unlink(impl->audio_capture_config.audio_pipeline);
av_processor_afe_config_t afe_config = DEFAULT_AV_PROCESSOR_AFE_CONFIG();
afe_config.ai_mode_wakeup = false;
afe_config.ai_mode_wakeup = wakeup;
audio_recorder_config_t recorder_config = DEFAULT_AUDIO_RECORDER_CONFIG();
memcpy((void *)&recorder_config.encoder_cfg, &capture->audio_capture_config.recorder_cfg, sizeof(av_processor_encoder_config_t));
memcpy(&recorder_config.afe_config, &afe_config, sizeof(av_processor_afe_config_t));
recorder_config.recorder_event_cb = capture->audio_wakeup_cb;
recorder_config.recorder_ctx = NULL;
audio_recorder_open(&recorder_config);
volc_osal_thread_param_t param = {0};
snprintf(param.name, sizeof(param.name), "%s", "volc_capture_task");
param.stack_size = 4 * 1024;
param.priority = 5;
param.stack_in_ext = 1;
capture->read_buf = volc_osal_malloc(__volc_capture_get_default_read_size(capture));
if (!capture->read_buf) return -1;
if (audio_recorder_open(&recorder_config) != ESP_OK) {
volc_osal_free(capture->read_buf); capture->read_buf = NULL;
return -1;
}
capture->is_started = true;
capture->audio_capture_config.state = CAPTURE;
esp_err_t ret = volc_osal_thread_create(&capture->capture_thread, &param, __volc_audio_capture_task, capture);
LOGI("capture task start... mode: CAPTURE");
return;
}
static void __start_audio_wake(volc_hal_capture_impl_t *capture)
{
av_processor_afe_config_t afe_config = DEFAULT_AV_PROCESSOR_AFE_CONFIG();
afe_config.ai_mode_wakeup = true;
audio_recorder_config_t recorder_config = DEFAULT_AUDIO_RECORDER_CONFIG();
memcpy((void *)&recorder_config.encoder_cfg, &capture->audio_capture_config.recorder_cfg, sizeof(av_processor_encoder_config_t));
memcpy(&recorder_config.afe_config, &afe_config, sizeof(av_processor_afe_config_t));
recorder_config.recorder_event_cb = capture->audio_wakeup_cb;
recorder_config.recorder_ctx = NULL;
audio_recorder_open(&recorder_config);
volc_osal_thread_param_t param = {0};
snprintf(param.name, sizeof(param.name), "%s", "volc_capture_task");
param.stack_size = 4 * 1024;
param.priority = 5;
param.stack_in_ext = 1;
capture->is_started = true;
capture->audio_capture_config.state = WAKEUP;
esp_err_t ret = volc_osal_thread_create(&capture->capture_thread, &param, __volc_audio_capture_task, capture);
LOGI("capture task start... mode: WAKEUP");
return;
capture->audio_capture_config.state = wakeup ? WAKEUP : CAPTURE;
// RTC Opus encoding needs the larger stack only during conversation.
if (xTaskCreatePinnedToCoreWithCaps(__volc_audio_capture_task, "volc_capture_task",
wakeup ? 4*1024 : 48*1024, capture, 5, &capture->capture_thread,
1, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT) != pdPASS) {
capture->is_started = false;
capture->capture_thread = NULL;
audio_recorder_close();
capture->audio_capture_config.state = INITED;
volc_osal_free(capture->read_buf); capture->read_buf = NULL;
return -1;
}
LOGI("capture task start... mode: %s", wakeup ? "WAKEUP" : "CAPTURE");
return 0;
}
int volc_hal_capture_start(volc_hal_capture_t capture,volc_hal_audio_capture_mode_e mode)
@@ -191,13 +180,11 @@ int volc_hal_capture_start(volc_hal_capture_t capture,volc_hal_audio_capture_mod
switch (mode)
{
case VOLC_AUDIO_MODE_WAKEUP:
__start_audio_wake(impl);
break;
return __start_audio(impl, true);
case VOLC_AUDIO_MODE_CAPTURE:
__start_audio_capture(impl);
break;
return __start_audio(impl, false);
default:
break;
return -1;
}
}
return 0;
@@ -217,6 +204,12 @@ int volc_hal_capture_stop(volc_hal_capture_t capture)
// audio_recorder_pause();
// audio_recorder_resume();
audio_recorder_close();
if (impl->capture_thread) {
xSemaphoreTake(impl->capture_stopped, portMAX_DELAY);
vTaskDeleteWithCaps(impl->capture_thread);
impl->capture_thread = NULL;
}
volc_osal_free(impl->read_buf); impl->read_buf = NULL;
impl->audio_capture_config.state = INITED;
break;
case VOLC_MEDIA_TYPE_VIDEO:
@@ -252,6 +245,7 @@ void volc_hal_capture_destroy(volc_hal_capture_t capture)
default:
break;
}
vSemaphoreDelete(impl->capture_stopped);
volc_osal_free(impl);
}
@@ -3,7 +3,7 @@
#include "volc_hal.h"
#include "volc_hal_display.h"
#include "volc_lvgl_source.h"
#include "echoear_call_status_font.h"
#include "hw_init.h"
#include "esp_lcd_panel_ops.h"
#include "esp_mmap_assets.h"
@@ -17,6 +17,7 @@
#include <stddef.h>
#include "volc_osal.h"
#include <string.h>
#include <stdatomic.h>
#include "basic_board.h"
#include "mmap_generate_eaf.h"
@@ -31,6 +32,8 @@ typedef struct volc_hal_display_impl {
gfx_obj_t *s_label;
gfx_obj_t *s_subtitle;
gfx_obj_t *s_setup_hint;
atomic_int requested_status;
int rendered_status;
esp_lcd_panel_io_handle_t display_io_handle;
SemaphoreHandle_t flush_done;
int s_current_emote;
@@ -40,6 +43,10 @@ typedef struct volc_hal_display_impl {
static volc_hal_display_impl_t* global_display_impl = NULL;
extern basic_board_periph_t global_periph;
static const char *const call_status_texts[] = {
"", "待机", "正在连接", "通话中", "正在结束", "通话结束", "连接失败", "连接中断"
};
static esp_lv_adapter_rotation_t __get_configured_rotation(void)
{
#if CONFIG_EXAMPLE_DISPLAY_ROTATION_0
@@ -157,6 +164,43 @@ static esp_err_t __create_animation(void)
return ESP_OK;
}
/* Keep status outside the eyes, in the safe area of the round panel. GFX
* caches the glyph mask; update it only when the status actually changes. */
static esp_err_t __create_status_label(void)
{
gfx_emote_lock(global_display_impl->s_gfx);
gfx_obj_t *label = gfx_label_create(global_display_impl->s_gfx);
if (!label) {
gfx_emote_unlock(global_display_impl->s_gfx);
return ESP_ERR_NO_MEM;
}
global_display_impl->s_label = label;
gfx_obj_set_size(label, 240, 42);
gfx_obj_align(label, GFX_ALIGN_TOP_MID, 0, 58);
gfx_label_set_font(label, (gfx_font_t)echoear_call_status_font());
gfx_label_set_text_align(label, GFX_TEXT_ALIGN_CENTER);
gfx_label_set_long_mode(label, GFX_LABEL_LONG_CLIP);
gfx_label_set_color(label, GFX_COLOR_HEX(0xFFFFFF));
gfx_label_set_bg_color(label, GFX_COLOR_HEX(0x000000));
gfx_label_set_bg_enable(label, true);
gfx_obj_set_visible(label, false);
esp_err_t ret = gfx_label_set_text(label, "");
gfx_emote_unlock(global_display_impl->s_gfx);
return ret;
}
static void __render_status(volc_hal_display_impl_t *display)
{
int requested = atomic_load(&display->requested_status);
if (requested == display->rendered_status) return;
gfx_emote_lock(display->s_gfx);
if (gfx_label_set_text(display->s_label, call_status_texts[requested]) == ESP_OK) {
gfx_obj_set_visible(display->s_label, requested != 0);
display->rendered_status = requested;
}
gfx_emote_unlock(display->s_gfx);
}
static void __emote_task(void *arg)
{
/* Start animation playback */
@@ -166,6 +210,7 @@ static void __emote_task(void *arg)
/* The display task owns animation playback for the lifetime of the app. */
while (1) {
__render_status(global_display_impl);
vTaskDelay(pdMS_TO_TICKS(100));
}
}
@@ -267,7 +312,10 @@ volc_hal_display_t volc_hal_display_create(volc_hal_display_config_t* config)
* animation instead; application status text explains the setup state.
*/
global_display_impl->s_current_emote = MMAP_EAF_HAPPY_EAF;
atomic_init(&global_display_impl->requested_status, 0);
global_display_impl->rendered_status = -1;
__create_animation();
ESP_ERROR_CHECK(__create_status_label());
volc_osal_thread_param_t param = {0};
snprintf(param.name, sizeof(param.name), "%s", "volc_capture_task");
param.stack_size = 8 * 1024;
@@ -282,36 +330,31 @@ volc_hal_display_t volc_hal_display_create(volc_hal_display_config_t* config)
return (volc_hal_display_t)global_display_impl;
}
static int display_backlight(volc_hal_display_t display, periph_ledc_handle_t **handle, uint32_t *maximum)
{
if(!display || esp_board_manager_get_device_handle("lcd_brightness",(void **)handle)!=ESP_OK || !*handle)return -1;
dev_ledc_ctrl_config_t *device=NULL;periph_ledc_config_t *config=NULL;
if(esp_board_manager_get_device_config("lcd_brightness",(void *)&device)!=ESP_OK || !device)return -1;
if(esp_board_manager_get_periph_config(device->ledc_name,(void **)&config)!=ESP_OK || !config)return -1;
if(config->duty_resolution<1 || config->duty_resolution>20)return -1;
*maximum=(1U<<(uint32_t)config->duty_resolution)-1;
return 0;
}
int volc_hal_display_set_brightness(volc_hal_display_t display, int brightness)
{
if(display == NULL) return -1;
if (brightness > 100) {
brightness = 100;
}
if (brightness < 0) {
brightness = 0;
}
periph_ledc_handle_t *ledc_handle = NULL;
esp_board_manager_get_device_handle("lcd_brightness", (void **)&ledc_handle);
dev_ledc_ctrl_config_t *dev_ledc_cfg = NULL;
esp_err_t config_ret = esp_board_manager_get_device_config("lcd_brightness", (void*)&dev_ledc_cfg);
if (config_ret != ESP_OK) {
return -1;
}
// ESP_LOGI(TAG, "dev_ledc_cfg.ledc_name: %s, name: %s, type: %s", dev_ledc_cfg->ledc_name, dev_ledc_cfg->name, dev_ledc_cfg->type);
periph_ledc_config_t *ledc_config = NULL;
esp_board_manager_get_periph_config(dev_ledc_cfg->ledc_name, (void**)&ledc_config);
uint32_t duty = (brightness * ((1 << (uint32_t)ledc_config->duty_resolution) - 1)) / 100;
// ESP_LOGI(TAG, "duty_cycle: %" PRIu32 ", speed_mode: %d, channel: %d, duty_resolution: %d", duty, ledc_handle->speed_mode, ledc_handle->channel, ledc_config->duty_resolution);
// ESP_BOARD_RETURN_ON_ERROR(ledc_set_duty(ledc_handle->speed_mode, ledc_handle->channel, duty), TAG, "LEDC set duty failed");
ledc_set_duty(ledc_handle->speed_mode, ledc_handle->channel, duty);
// ESP_BOARD_RETURN_ON_ERROR(ledc_update_duty(ledc_handle->speed_mode, ledc_handle->channel), TAG, "LEDC update duty failed");
ledc_update_duty(ledc_handle->speed_mode, ledc_handle->channel);
return 0;
// return bsp_display_brightness_set(brightness);
periph_ledc_handle_t *handle=NULL;uint32_t maximum=0;
if(display_backlight(display,&handle,&maximum))return -1;
if(brightness<0)brightness=0;if(brightness>100)brightness=100;
if(ledc_set_duty(handle->speed_mode,handle->channel,brightness*maximum/100)!=ESP_OK)return -1;
return ledc_update_duty(handle->speed_mode,handle->channel)==ESP_OK?0:-1;
}
int volc_hal_display_get_brightness(volc_hal_display_t display)
{
periph_ledc_handle_t *handle=NULL;uint32_t maximum=0;
if(display_backlight(display,&handle,&maximum))return -1;
uint32_t duty=ledc_get_duty(handle->speed_mode,handle->channel);
if(duty>maximum)return -1;
return (duty*100+maximum/2)/maximum;
}
void volc_hal_display_destroy(volc_hal_display_t display)
@@ -339,7 +382,17 @@ int volc_hal_display_set_content(volc_hal_display_t display, volc_hal_display_ob
volc_hal_display_impl_t* global_display_impl = (volc_hal_display_impl_t*)(g_hal_context->display_handle);
if(obj == VOLC_DISPLAY_OBJ_STATUS && type == VOLC_DISPLAY_TEXT){
ESP_LOGI("DISPLAY", "Status: %s", (const char *)content);
if (!content) return -1;
int requested = 0;
for (int i = 1; i < sizeof(call_status_texts) / sizeof(call_status_texts[0]); ++i) {
if (strcmp(content, call_status_texts[i]) == 0) { requested = i; break; }
}
if (strcmp(content, "轻触屏幕开始聊天") == 0 ||
strcmp(content, "轻触屏幕或说嗨,乐鑫") == 0) requested = 1;
/* RTC callbacks never wait on the renderer's full-frame lock. Other
* setup text keeps its previous behavior, with no subtitle rendering. */
atomic_store(&global_display_impl->requested_status, requested);
return 0;
}
if(obj == VOLC_DISPLAY_OBJ_SUBTITLE && type == VOLC_DISPLAY_TEXT){
ESP_LOGI("DISPLAY", "Subtitle: %s", (const char *)content);
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
#include <stddef.h>
#include <stdatomic.h>
#include "volc_hal.h"
#include "volc_hal_player.h"
@@ -38,17 +39,33 @@ typedef struct {
audio_player_config_t audio_player_config;
video_player_config_t video_player_config;
};
volatile bool is_started;
atomic_bool is_started;
atomic_int feeder_stop_phase;
StreamBufferHandle_t pcm_stream;
StaticStreamBuffer_t stream_state;
uint8_t *stream_storage;
SemaphoreHandle_t worker_stopped;
volatile bool discard_pending;
volatile bool segment_complete;
bool (*authorization_gate)(void);
atomic_bool write_in_progress;
atomic_uint last_voice_write_ms;
} volc_player_impl_t;
extern basic_board_periph_t global_periph;
static int stop_feeder_once(volc_player_impl_t *impl)
{
int expected = 0;
if (atomic_compare_exchange_strong(&impl->feeder_stop_phase, &expected, 1)) {
int ret = audio_feeder_stop();
atomic_store(&impl->feeder_stop_phase, 2);
return ret;
}
while (atomic_load(&impl->feeder_stop_phase) == 1) vTaskDelay(1);
return 0;
}
static void pcm_playback_task(void *arg)
{
volc_player_impl_t *impl = arg;
@@ -56,7 +73,22 @@ static void pcm_playback_task(void *arg)
bool primed = false;
uint64_t first_byte_ms = 0;
uint64_t last_feed_ms = 0;
bool authorization_closed = false;
uint64_t report_ms=volc_osal_get_time_ms();
unsigned fed_bytes=0,max_write_ms=0,write_errors=0;
while (impl->is_started) {
uint64_t tick_ms=volc_osal_get_time_ms();
if(tick_ms-report_ms>=1000){
LOGI("PLAYBACK stats span_ms=%u fed_ms=%u queued_ms=%u max_write_ms=%u errors=%u primed=%d",(unsigned)(tick_ms-report_ms),fed_bytes/32,(unsigned)xStreamBufferBytesAvailable(impl->pcm_stream)/32,max_write_ms,write_errors,primed);
report_ms=tick_ms;fed_bytes=max_write_ms=write_errors=0;
}
// Runs in the audio task even while the control task is blocked on HTTP.
if (authorization_closed || (impl->authorization_gate && !impl->authorization_gate())) {
if (!authorization_closed) { stop_feeder_once(impl); authorization_closed = true; }
while (xStreamBufferReceive(impl->pcm_stream, pcm, sizeof(pcm), 0)) {}
vTaskDelay(pdMS_TO_TICKS(10));
continue;
}
if (impl->discard_pending) {
while (xStreamBufferReceive(impl->pcm_stream, pcm, sizeof(pcm), 0)) {}
impl->discard_pending = false;
@@ -80,8 +112,10 @@ static void pcm_playback_task(void *arg)
LOGI("PLAYBACK start source=16000Hz mono PCM16 buffered=%u wait_ms=%u complete=%d",
(unsigned)available, (unsigned)(volc_osal_get_time_ms() - first_byte_ms), impl->segment_complete);
}
size_t count = xStreamBufferReceive(impl->pcm_stream, pcm, sizeof(pcm), pdMS_TO_TICKS(30));
atomic_store(&impl->write_in_progress, true);
size_t count = xStreamBufferReceive(impl->pcm_stream, pcm, sizeof(pcm), 0);
if (!count) {
atomic_store(&impl->write_in_progress, false);
/* An empty ingress queue does not mean the speaker has run dry:
* the downstream feeder may still hold 256 ms of PCM. Keep feeding
* arriving chunks immediately instead of inserting another prefill
@@ -91,13 +125,35 @@ static void pcm_playback_task(void *arg)
first_byte_ms = 0;
LOGI("PLAYBACK idle after %u ms without PCM", PCM_REPRIME_IDLE_MS);
}
vTaskDelay(pdMS_TO_TICKS(10));
continue;
}
if (audio_feeder_feed_data(pcm, count) == ESP_OK) {
if (impl->authorization_gate && !impl->authorization_gate()) {
atomic_store(&impl->write_in_progress, false);
continue;
}
uint64_t magnitude = 0;
for (size_t i = 0; i + 1 < count; i += 2) {
int16_t sample;
memcpy(&sample, pcm + i, sizeof(sample));
magnitude += sample < 0 ? -(int)sample : sample;
}
bool voiced = count >= 2 && magnitude / (count / 2) >= 64;
if (voiced) atomic_store(&impl->last_voice_write_ms, (uint32_t)volc_osal_get_time_ms());
// A full 1024-byte chunk represents 32 ms at 16 kHz PCM16. Allow
// the sink to consume it without dropping audio at a 20 ms timeout.
uint64_t write_ms=volc_osal_get_time_ms();
if (audio_feeder_feed_data_timeout(pcm, count, 50) == ESP_OK) {
fed_bytes+=count;
last_feed_ms = volc_osal_get_time_ms();
} else if (impl->is_started) {
write_errors++;
LOGE("PLAYBACK feeder write failed");
}
unsigned elapsed_write=(unsigned)(volc_osal_get_time_ms()-write_ms);
if (voiced) atomic_store(&impl->last_voice_write_ms, (uint32_t)volc_osal_get_time_ms());
atomic_store(&impl->write_in_progress, false);
if(elapsed_write>max_write_ms)max_write_ms=elapsed_write;
}
xSemaphoreGive(impl->worker_stopped);
vTaskDelete(NULL);
@@ -115,10 +171,7 @@ volc_hal_player_t volc_hal_player_create(volc_hal_player_config_t* config)
impl->media_type = config->media_type;
switch (impl->media_type) {
case VOLC_MEDIA_TYPE_AUDIO:
if(global_periph.play_dev){
esp_codec_dev_set_out_vol(global_periph.play_dev, DEFAULT_AUDIO_VOL);
}
impl->audio_player_config.volume = DEFAULT_AUDIO_VOL;
impl->audio_player_config.volume = volc_hal_get_device_volume();
#if (CONFIG_VOLC_AUDIO_G711A)
impl->audio_player_config.feeder_cfg.format = AV_PROCESSOR_FORMAT_ID_G711A;
impl->audio_player_config.feeder_cfg.params.g711.audio_info.sample_rate = 8000;
@@ -168,14 +221,24 @@ volc_hal_player_t volc_hal_player_create(volc_hal_player_config_t* config)
return (volc_hal_player_t)impl;
}
int volc_hal_set_device_volume(int volume){
if(volume<0 || volume>100 || !global_periph.play_dev)return -1;
return esp_codec_dev_set_out_vol(global_periph.play_dev,volume);
}
int volc_hal_get_device_volume(void){
int volume=-1;
if(!global_periph.play_dev || esp_codec_dev_get_out_vol(global_periph.play_dev,&volume)!=ESP_OK)return -1;
return volume;
}
int volc_hal_set_audio_player_volume(volc_hal_player_t player, int volume){
volc_player_impl_t* impl = (volc_player_impl_t*)player;
if (impl == NULL) {
return -1;
}
if(impl->media_type == VOLC_MEDIA_TYPE_AUDIO){
impl->audio_player_config.volume = volume;
return esp_codec_dev_set_out_vol(global_periph.play_dev, volume);
int result=volc_hal_set_device_volume(volume);
if (result==ESP_OK) impl->audio_player_config.volume = volume;
return result;
} else if(impl->media_type == VOLC_MEDIA_TYPE_VIDEO){
LOGE("media type %d not supported set volume", impl->media_type);
return -1;
@@ -187,7 +250,7 @@ int volc_hal_get_audio_player_volume(volc_hal_player_t player){
if (impl == NULL) {
return -1;
}
return impl->audio_player_config.volume;
return volc_hal_get_device_volume();
}
void volc_hal_player_destroy(volc_hal_player_t player)
@@ -231,6 +294,9 @@ int volc_hal_player_start(volc_hal_player_t player)
case VOLC_MEDIA_TYPE_AUDIO:
if (audio_feeder_run() != ESP_OK) return -1;
xStreamBufferReset(impl->pcm_stream);
atomic_store(&impl->feeder_stop_phase, 0);
atomic_store(&impl->write_in_progress, false);
atomic_store(&impl->last_voice_write_ms, (uint32_t)volc_osal_get_time_ms());
impl->is_started = true;
if (xTaskCreatePinnedToCore(pcm_playback_task, "pcm_playback", 4096, impl, 5, NULL, 1) != pdPASS) {
impl->is_started = false;
@@ -260,7 +326,7 @@ int volc_hal_player_stop(volc_hal_player_t player)
case VOLC_MEDIA_TYPE_AUDIO:
impl->is_started = false;
/* Stop the feeder first to release any in-flight blocking write. */
int ret = audio_feeder_stop();
int ret = stop_feeder_once(impl);
xSemaphoreTake(impl->worker_stopped, portMAX_DELAY);
return ret;
break;
@@ -313,3 +379,19 @@ void volc_hal_player_finish_segment(volc_hal_player_t player)
volc_player_impl_t *impl = player;
if (impl && impl->is_started) impl->segment_complete = true;
}
bool volc_hal_player_voice_quiet(volc_hal_player_t player, unsigned quiet_ms)
{
volc_player_impl_t *impl = player;
if (!impl || !impl->is_started || !impl->pcm_stream) return false;
uint32_t now = (uint32_t)volc_osal_get_time_ms();
return !atomic_load(&impl->write_in_progress) &&
xStreamBufferBytesAvailable(impl->pcm_stream) == 0 &&
now - atomic_load(&impl->last_voice_write_ms) >= quiet_ms;
}
void volc_hal_player_set_authorization_gate(volc_hal_player_t player, bool (*allowed)(void))
{
volc_player_impl_t *impl = player;
if (impl && !impl->is_started) impl->authorization_gate = allowed;
}
@@ -0,0 +1,40 @@
#ifndef CALL_DISPLAY_STATE_H
#define CALL_DISPLAY_STATE_H
#include <stdbool.h>
#include <stdint.h>
typedef enum {
CALL_DISPLAY_IDLE,
CALL_DISPLAY_CONNECTING,
CALL_DISPLAY_TALKING,
CALL_DISPLAY_ENDING,
CALL_DISPLAY_ENDED,
CALL_DISPLAY_FAILED,
CALL_DISPLAY_INTERRUPTED,
} call_display_phase_t;
typedef enum {
CALL_DISPLAY_STOP_NORMAL,
CALL_DISPLAY_STOP_FAILED,
CALL_DISPLAY_STOP_INTERRUPTED,
} call_display_stop_t;
// Owned by the conversation task. SDK callbacks only latch atomic observations;
// they never render text or mutate this lifecycle while the task is stopping.
typedef struct {
call_display_phase_t phase;
unsigned generation;
bool connected;
uint64_t terminal_until_ms;
} call_display_state_t;
unsigned call_display_begin(call_display_state_t *state);
void call_display_connected(call_display_state_t *state, unsigned generation);
void call_display_stopping(call_display_state_t *state, unsigned generation);
void call_display_finished(call_display_state_t *state, unsigned generation,
call_display_stop_t reason, bool audio_stopped, uint64_t now_ms);
bool call_display_idle_if_due(call_display_state_t *state, unsigned generation, uint64_t now_ms);
const char *call_display_text(const call_display_state_t *state);
#endif
@@ -0,0 +1,19 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
/* Owned by the conversation task. SDK callbacks only publish atomic counters. */
typedef struct {
bool pending;
bool reply_complete;
uint32_t requested_ms;
unsigned finish_baseline;
} call_voice_end_t;
bool call_voice_end_command(const char *text);
void call_voice_end_reset(call_voice_end_t *state);
void call_voice_end_user(call_voice_end_t *state, const char *text,
uint32_t now_ms, unsigned finish_serial);
bool call_voice_end_due(const call_voice_end_t *state, uint32_t now_ms,
unsigned finish_serial, bool playback_quiet,
bool user_speaking);
@@ -12,6 +12,7 @@ typedef struct {
} volc_conv_service_t;
void conv_ai_service_task(void *pvParameters);
void conv_ai_service_prepare_task(void);
void conv_ai_service_task_stop();
void conv_ai_service_init();
volc_conv_service_t get_conv_ai_service();
@@ -0,0 +1,57 @@
#include "call_display_state.h"
unsigned call_display_begin(call_display_state_t *state)
{
++state->generation;
state->phase = CALL_DISPLAY_CONNECTING;
state->connected = false;
state->terminal_until_ms = 0;
return state->generation;
}
void call_display_connected(call_display_state_t *state, unsigned generation)
{
if (state->generation == generation && state->phase == CALL_DISPLAY_CONNECTING) {
state->connected = true;
state->phase = CALL_DISPLAY_TALKING;
}
}
void call_display_stopping(call_display_state_t *state, unsigned generation)
{
if (state->generation == generation &&
(state->phase == CALL_DISPLAY_CONNECTING || state->phase == CALL_DISPLAY_TALKING)) {
state->phase = CALL_DISPLAY_ENDING;
}
}
void call_display_finished(call_display_state_t *state, unsigned generation,
call_display_stop_t reason, bool audio_stopped, uint64_t now_ms)
{
if (state->generation != generation || state->phase != CALL_DISPLAY_ENDING || !audio_stopped) return;
state->phase = reason == CALL_DISPLAY_STOP_NORMAL ? CALL_DISPLAY_ENDED :
state->connected ? CALL_DISPLAY_INTERRUPTED : CALL_DISPLAY_FAILED;
state->terminal_until_ms = now_ms + 2000;
}
bool call_display_idle_if_due(call_display_state_t *state, unsigned generation, uint64_t now_ms)
{
if (state->generation != generation ||
(state->phase != CALL_DISPLAY_ENDED && state->phase != CALL_DISPLAY_FAILED &&
state->phase != CALL_DISPLAY_INTERRUPTED) || now_ms < state->terminal_until_ms) return false;
state->phase = CALL_DISPLAY_IDLE;
return true;
}
const char *call_display_text(const call_display_state_t *state)
{
switch (state->phase) {
case CALL_DISPLAY_CONNECTING: return "正在连接";
case CALL_DISPLAY_TALKING: return "通话中";
case CALL_DISPLAY_ENDING: return "正在结束";
case CALL_DISPLAY_ENDED: return "通话结束";
case CALL_DISPLAY_FAILED: return "连接失败";
case CALL_DISPLAY_INTERRUPTED: return "连接中断";
default: return "待机";
}
}
@@ -0,0 +1,98 @@
#include "call_voice_end.h"
#include <stddef.h>
#include <string.h>
/* Match a whole short request, never a substring in a story, quote, question
* or negated instruction. This intentionally is a bounded command vocabulary. */
bool call_voice_end_command(const char *text)
{
if (!text) return false;
char normalized[256];
size_t n = 0;
static const char *punctuation[] = {",", "。", "!", "、"};
for (const char *p = text; *p;) {
bool skipped = false;
for (size_t i = 0; i < sizeof(punctuation) / sizeof(*punctuation); ++i) {
size_t len = strlen(punctuation[i]);
if (!strncmp(p, punctuation[i], len)) { p += len; skipped = true; break; }
}
if (skipped) continue;
if (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n' ||
*p == ',' || *p == '.' || *p == '!') { ++p; continue; }
/* Preserve question marks, quotation marks and all other words. */
if (n + 1 >= sizeof(normalized)) return false;
normalized[n++] = *p++;
}
normalized[n] = 0;
static const char *prefixes[] = {
"好了", "好啦", "好的", "嗯", "那就", "那", "我们", "咱们",
"今天", "现在", "就先", "就", "先", "请", "麻烦你", "谢谢你", "谢谢"
};
const char *p = normalized;
for (unsigned pass = 0; pass < 8; ++pass) {
bool found = false;
for (size_t i = 0; i < sizeof(prefixes) / sizeof(*prefixes); ++i) {
size_t len = strlen(prefixes[i]);
if (!strncmp(p, prefixes[i], len)) { p += len; found = true; break; }
}
if (!found) break;
}
static const char *commands[] = {
"拜拜", "再见", "下次再聊", "下回再聊", "回头再聊",
"聊到这里", "聊到这儿", "聊到这", "不聊了", "我不聊了",
"结束聊天", "结束对话", "结束通话", "结束这次聊天", "结束本次通话",
"挂断电话", "挂断通话", "挂断", "挂了", "我挂了", "我先挂了",
"我先走了", "我要休息了", "我去休息了"
};
static const char *tails[] = {
"吧", "把", "啦", "了", "啊", "呀", "哦", "哈", "谢谢你", "谢谢",
"拜拜", "再见", "下次再聊", "下回再聊", "回头再聊"
};
for (size_t i = 0; i < sizeof(commands) / sizeof(*commands); ++i) {
size_t len = strlen(commands[i]);
if (strncmp(p, commands[i], len)) continue;
const char *tail = p + len;
for (unsigned pass = 0; *tail && pass < 8; ++pass) {
bool found = false;
for (size_t j = 0; j < sizeof(tails) / sizeof(*tails); ++j) {
size_t tlen = strlen(tails[j]);
if (!strncmp(tail, tails[j], tlen)) { tail += tlen; found = true; break; }
}
if (!found) break;
}
if (!*tail) return true;
}
return false;
}
void call_voice_end_reset(call_voice_end_t *state)
{
memset(state, 0, sizeof(*state));
}
void call_voice_end_user(call_voice_end_t *state, const char *text,
uint32_t now_ms, unsigned finish_serial)
{
if (!call_voice_end_command(text)) {
/* A later complete user utterance cancels the pending goodbye. */
call_voice_end_reset(state);
} else if (!state->pending) {
state->pending = true;
state->reply_complete = false;
state->requested_ms = now_ms;
state->finish_baseline = finish_serial;
}
/* Repeated subtitles must not extend the stop deadline. */
}
bool call_voice_end_due(const call_voice_end_t *state, uint32_t now_ms,
unsigned finish_serial, bool playback_quiet,
bool user_speaking)
{
if (!state->pending || user_speaking) return false;
uint32_t elapsed = now_ms - state->requested_ms;
/* Missing response events/audio must not leave an ended chat open forever. */
if (elapsed >= 12000u) return true;
return elapsed >= 1500u && playback_quiet &&
(state->reply_complete || finish_serial != state->finish_baseline);
}
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
#include <stdio.h>
#include <string.h>
#include <stdatomic.h>
#include "aios.h"
#include "cJSON.h"
@@ -10,6 +11,9 @@
#include "util/volc_log.h"
#include "volc_service_common.h"
#include "volc_conv.h"
#include "call_display_state.h"
#include "call_voice_end.h"
#include "esp_gmf_afe.h"
#include "volc_hal.h"
#include "volc_hal_capture.h"
#include "volc_hal_display.h"
@@ -20,12 +24,18 @@
#include "mmap_generate_eaf.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#include "freertos/idf_additions.h"
extern int8_t get_rssi(void);
#if __cplusplus
extern "C" {
#endif
/* Opus RTP uses a 48 kHz clock in BOTH directions (RFC 7587). We submit
* externally encoded 20 ms packets, so the SDK must timestamp them with
* 960 ticks. The separate PCM encoder/decoder still uses 16 kHz / 320 samples;
* using that PCM rate for RTP compresses the transmitted audio timeline. */
#define CONV_AI_CONFIG_FORMAT "{\
\"ver\": 1,\
\"iot\": {\
@@ -50,32 +60,150 @@ extern "C" {
\"{\\\"debug\\\":{\\\"log_to_console\\\":1}}\",\
\"{\\\"rtc\\\":{\\\"access\\\":{\\\"concurrent_requests\\\":1}}}\",\
\"{\\\"rtc\\\":{\\\"ice\\\":{\\\"concurrent_agents\\\":1}}}\",\
\"{\\\"rtc\\\":{\\\"thread\\\":{\\\"stack_in_ext\\\":1}}}\",\
\"{\\\"rtc\\\":{\\\"thread\\\":{\\\"stack_in_ext\\\":1,\\\"stack_size\\\":49152}}}\",\
\"{\\\"rtc\\\":{\\\"network\\\":{\\\"enable_audio_jitter2\\\":0}}}\",\
\"{\\\"rtc\\\":{\\\"report\\\":{\\\"enable\\\":0}}}\",\
\"{\\\"audio\\\":{\\\"codec\\\":{\\\"pcma\\\":{\\\"s_samples_per_frame\\\":480}}}}\"\
\"{\\\"audio\\\":{\\\"codec\\\":{\\\"opus\\\":{\\\"s_sample_rate\\\":48000,\\\"s_samples_per_frame\\\":960,\\\"r_sample_rate\\\":48000,\\\"r_samples_per_frame\\\":960}}}}\"\
]\
}\
}"
#define CONV_SERVICE_TIMEOUT 60
/* CALL_IDLE_BEGIN: callbacks only update atomic activity, never stop the SDK.
* Continuous microphone/silence packets and lease renewals are not activity.
* Unsigned monotonic milliseconds also handle the 32-bit clock wrap. */
static atomic_uint last_call_activity_ms;
static atomic_bool local_speech_active;
static void call_idle_activity(void)
{
atomic_store(&last_call_activity_ms, (uint32_t)volc_osal_get_time_ms());
}
static bool call_idle_expired(void)
{
uint32_t now = (uint32_t)volc_osal_get_time_ms();
return (uint32_t)(now - atomic_load(&last_call_activity_ms)) >= CONV_SERVICE_TIMEOUT * 1000u;
}
static void call_idle_capture_event(void *event, void *user_data)
{
(void)user_data;
const esp_gmf_afe_evt_t *vad = event;
if (!vad) return;
if (vad->type == ESP_GMF_AFE_EVT_VAD_START || vad->type == ESP_GMF_AFE_EVT_VAD_END) {
atomic_store(&local_speech_active, vad->type == ESP_GMF_AFE_EVT_VAD_START);
call_idle_activity();
}
}
static bool call_idle_pcm_active(const void *data, size_t bytes)
{
if (!data || bytes < sizeof(int16_t) || (bytes & 1)) return false;
const int16_t *pcm = data;
uint64_t magnitude = 0;
size_t samples = bytes / sizeof(*pcm);
for (size_t i = 0; i < samples; ++i) {
magnitude += pcm[i] < 0 ? -(int)pcm[i] : pcm[i];
}
// RTC sends decoded silence continuously, even when the agent is quiet.
// Measure synthesized audio before the user's speaker volume is applied.
return magnitude / samples >= 64;
}
/* CALL_IDLE_END */
static char config_buf[1024] = {0};
static volc_conv_service_t conv_service = {0};
static volatile bool is_stop = false;
static volatile bool provider_disconnected = false;
static atomic_bool is_stop = true;
static atomic_bool stop_requested = false;
static atomic_bool provider_disconnected = false;
static atomic_bool display_connected = false;
static atomic_bool user_stop_requested = false;
static call_display_state_t call_display;
static volc_hal_display_t global_display = NULL;
static bool session_started_reported = false;
static QueueHandle_t platform_report_queue;
static volatile bool diagnostic_say_pending;
static call_voice_end_t voice_end;
static atomic_uint voice_finish_serial;
static atomic_uint last_voice_received_ms;
/* VOICE_DIAGNOSTIC_BEGIN: local USB requests only. TTS keeps microphone uplink
* suppressed until this call ends or an explicit diag-say restores it. */
enum { DIAGNOSTIC_NONE, DIAGNOSTIC_SAY, DIAGNOSTIC_TTS, DIAGNOSTIC_PENDING_MASK = 3,
DIAGNOSTIC_MUTE = 4, DIAGNOSTIC_CLOSING = 8, DIAGNOSTIC_ACTIVE = 16 };
static atomic_uint diagnostic_state;
extern bool platform_runtime_rtc(void);
bool conv_ai_service_diagnostic_active(void)
{
return (atomic_load(&diagnostic_state) & DIAGNOSTIC_ACTIVE) != 0;
}
void conv_ai_service_diagnostic_reset(void)
{
atomic_fetch_and(&diagnostic_state, DIAGNOSTIC_CLOSING);
}
static void diagnostic_close(void)
{
atomic_store(&diagnostic_state, DIAGNOSTIC_CLOSING);
}
static void diagnostic_prepare(void)
{
atomic_fetch_and(&diagnostic_state, ~DIAGNOSTIC_CLOSING);
}
static bool diagnostic_uplink_suppressed(void)
{
return (atomic_load(&diagnostic_state) & DIAGNOSTIC_MUTE) != 0;
}
static unsigned diagnostic_take_pending(void)
{
return atomic_fetch_and(&diagnostic_state, ~DIAGNOSTIC_PENDING_MASK) & DIAGNOSTIC_PENDING_MASK;
}
static void diagnostic_request(int mode)
{
unsigned state = atomic_load(&diagnostic_state);
do {
if ((state & DIAGNOSTIC_CLOSING) && xTaskGetHandle("conv_ai")) {
LOGI("PLAYBACK diagnostic request rejected: call closing");
return;
}
} while (!atomic_compare_exchange_weak(&diagnostic_state, &state,
(unsigned)mode | DIAGNOSTIC_ACTIVE |
(mode == DIAGNOSTIC_TTS ? DIAGNOSTIC_MUTE : 0)));
/* Repeating the fixed passage reuses the existing room. */
if (!xTaskGetHandle("conv_ai") && aios_event_pub(VOLC_SERVICE_AI_CONVERSATION, NULL, NULL) != 0) {
conv_ai_service_diagnostic_reset();
}
}
void conv_ai_service_diagnostic_say(void)
{
diagnostic_say_pending = true;
aios_event_pub(VOLC_SERVICE_AI_CONVERSATION, NULL, NULL);
diagnostic_request(DIAGNOSTIC_SAY);
}
void conv_ai_service_diagnostic_tts(void)
{
diagnostic_request(DIAGNOSTIC_TTS);
}
static void diagnostic_send_tts(void)
{
static const char *passage = "这是连续语音播放测试。请您听一听,这段声音是否清楚、连贯,中间有没有突然停顿。我们用自然的语速,把这段话平稳地读完。";
int sent = platform_runtime_rtc()
? volc_send_text_to_agent(conv_service.engine, passage, VOLC_AGENT_TYPE_TTS, 1) : -2;
LOGI("PLAYBACK diagnostic TTS requested ret=%d mic_suppressed=1", sent);
if (sent != 0) conv_ai_service_diagnostic_reset();
conv_service.wait_time = 0;
call_idle_activity();
}
/* VOICE_DIAGNOSTIC_END */
/* Implemented by the platform client in the top-level firmware component. */
extern const char *platform_runtime_instance_id(void);
extern const char *platform_runtime_product_key(void);
@@ -83,12 +211,39 @@ extern const char *platform_runtime_device_name(void);
extern const char *platform_runtime_device_secret(void);
extern int platform_conversation_begin(void);
extern const char *platform_last_error_code(void);
extern const char *call_client_start_error(void);
extern const char *platform_conversation_bot_id(void);
extern const char *platform_conversation_update_json(void);
extern int platform_conversation_started(const char *provider_connection_ref);
extern int platform_conversation_poll(void);
extern bool platform_conversation_audio_authorized(void);
extern int platform_conversation_turn(const char *speaker, const char *source_id, const char *text);
extern void platform_conversation_finish(bool failed, const char *error_code);
extern bool platform_runtime_rtc(void);
extern const char *platform_runtime_rtc_app_id(void);
extern void platform_runtime_engine_ready(bool ready);
extern void platform_conversation_observe(const char *type);
extern void platform_conversation_stop_result(bool started,int result);
static atomic_bool rtc_connected;
extern const char *volc_current_user_id(volc_engine_t handle,bool remote);
static void publish_call_display(unsigned generation)
{
if (call_display.generation != generation) return;
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_STATUS,
VOLC_DISPLAY_TEXT, call_display_text(&call_display));
}
static void observe_call_connection(unsigned generation)
{
if (atomic_load(&display_connected)) call_display_connected(&call_display, generation);
publish_call_display(generation);
}
static bool conversation_output_allowed(void)
{
return !stop_requested && platform_conversation_audio_authorized();
}
volc_conv_service_t get_conv_ai_service()
{
@@ -98,11 +253,13 @@ volc_conv_service_t get_conv_ai_service()
// volc_hal_file_t* audioFile = NULL;
// int a_cnt = 0;
static void __audio_capture_cb(volc_hal_capture_t capture, const void* data, int len, volc_hal_frame_info_t* frame_info){
if (diagnostic_uplink_suppressed()) return;
volc_audio_frame_info_t info = {0};
info.commit = false;
info.data_type = frame_info->data_type;
if(!is_stop)
if(!is_stop && !stop_requested && platform_conversation_audio_authorized())
{
if (atomic_load(&local_speech_active)) call_idle_activity();
static uint64_t report_ms, magnitude;
static unsigned samples, peak, frames, failures, max_send_ms;
const int16_t *pcm = data;
@@ -140,6 +297,9 @@ static void __on_volc_event(volc_engine_t handle, volc_event_t *event, void *use
{
case VOLC_EV_CONNECTED:
LOGI("Volc Engine connected");
call_idle_activity();
if (!stop_requested) atomic_store(&display_connected, true);
if(platform_runtime_rtc()){atomic_store(&rtc_connected,true);platform_conversation_observe("MEDIA_CONNECTED");}
break;
case VOLC_EV_DISCONNECTED:
LOGI( "Volc Engine disconnected");
@@ -154,33 +314,34 @@ static void __on_volc_event(volc_engine_t handle, volc_event_t *event, void *use
static void __on_volc_conversation_status(volc_engine_t handle, volc_conv_status_e status, void *user_data)
{
LOGI("AUDIO conversation status=%d",(int)status);
// Turn events are not call lifecycle events. A delayed ANSWER_FINISH must
// neither end the call nor overwrite its terminal status.
if (stop_requested) return;
if (status != VOLC_CONV_STATUS_LISTENING) call_idle_activity();
int index = MMAP_EAF_HAPPY_EAF;
switch (status)
{
case VOLC_CONV_STATUS_LISTENING:
index = MMAP_EAF_EMOTION_BLINK_FAST_284_126_EAF;
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_MAIN,VOLC_DISPLAY_IMAGE,&index);
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"智能体聆听中");
break;
case VOLC_CONV_STATUS_THINKING:
index = MMAP_EAF_EMOTION_DIZZY_284_126_EAF;
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_MAIN,VOLC_DISPLAY_IMAGE,&index);
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"智能体思考中");
break;
case VOLC_CONV_STATUS_ANSWERING:
index = MMAP_EAF_HAPPY_EAF;
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_MAIN,VOLC_DISPLAY_IMAGE,&index);
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"智能体说话中");
break;
case VOLC_CONV_STATUS_INTERRUPTED:
{
volc_hal_context_t *hal = volc_get_global_hal_context();
if (hal) volc_hal_player_discard_pending(hal->player_handle[VOLC_HAL_PLAYER_AUDIO]);
}
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"智能体被打断");
break;
case VOLC_CONV_STATUS_ANSWER_FINISH:
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"智能体说话完成");
atomic_fetch_add(&voice_finish_serial, 1);
break;
default:
break;
@@ -204,7 +365,13 @@ static void __on_volc_audio_data(volc_engine_t handle, const void *data_ptr, siz
LOGE("player pipeline is NULL");
return;
}
if(!is_stop){
if(!is_stop && !stop_requested && platform_conversation_audio_authorized()){
if (info_ptr && info_ptr->data_type == VOLC_AUDIO_DATA_TYPE_PCM &&
call_idle_pcm_active(data_ptr, data_len)) {
call_idle_activity();
atomic_store(&last_voice_received_ms, (uint32_t)volc_osal_get_time_ms());
}
if(data_len)platform_conversation_observe("FIRST_AUDIO_RECEIVED");
volc_hal_player_play_data(player_handle, data_ptr, data_len);
}
}
@@ -241,13 +408,15 @@ static void __on_subtitle_message_received(const cJSON* root) {
cJSON* text_obj = cJSON_GetObjectItem(obji, "text");
cJSON* definite_obj = cJSON_GetObjectItem(obji, "definite");
char* sub = cJSON_GetStringValue(text_obj);
if(!sub)continue;
if (sub[0] && !stop_requested) call_idle_activity();
if(sub_offset<0||(size_t)sub_offset>strlen(sub))sub_offset=0;
// print_string(sub + sub_offset -1);
if(cJSON_IsTrue(definite_obj)){
sub_offset = sub_offset == 0 ? 0 : sub_offset -1;
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_SUBTITLE,VOLC_DISPLAY_TEXT,sub + sub_offset);
sub_offset = 0;
aios_event_pub(VOLC_LOCAL_LOGIC_PROCESS_SUBTITLE, sub, NULL);
} else if(strlen(sub) - sub_offset > 18){
sub_offset = sub_offset == 0 ? 0 : sub_offset -1;
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_SUBTITLE,VOLC_DISPLAY_TEXT,sub + sub_offset);
@@ -259,6 +428,62 @@ static void __on_subtitle_message_received(const cJSON* root) {
}
}
/* Called only by the conversation task, never from an SDK callback. */
static struct {
char text[512];
double round, sequence;
bool active, complete, overflow;
} voice_user_subtitle;
static void process_voice_end_transcript(const char *speaker, const char *text, bool complete)
{
if (!complete || !text || !*text || is_stop || stop_requested) return;
if (!strcmp(speaker, "subject")) {
call_voice_end_user(&voice_end, text, (uint32_t)volc_osal_get_time_ms(),
atomic_load(&voice_finish_serial));
} else if (!strcmp(speaker, "agent") && voice_end.pending) {
voice_end.reply_complete = true;
}
}
static void process_voice_end_rtc_user(const cJSON *line)
{
cJSON *round = cJSON_GetObjectItemCaseSensitive(line, "roundId");
cJSON *sequence = cJSON_GetObjectItemCaseSensitive(line, "sequence");
const char *text = cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(line, "text"));
if (!cJSON_IsNumber(round) || !cJSON_IsNumber(sequence) || !text ||
!cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(line, "definite")) || is_stop || stop_requested) return;
if (voice_user_subtitle.active && round->valuedouble == voice_user_subtitle.round &&
sequence->valuedouble <= voice_user_subtitle.sequence) return;
if (!voice_user_subtitle.active || round->valuedouble != voice_user_subtitle.round || voice_user_subtitle.complete) {
memset(&voice_user_subtitle, 0, sizeof(voice_user_subtitle));
voice_user_subtitle.active = true;
voice_user_subtitle.round = round->valuedouble;
}
voice_user_subtitle.sequence = sequence->valuedouble;
size_t used = strlen(voice_user_subtitle.text), len = strlen(text);
if (used + len + 1 >= sizeof(voice_user_subtitle.text)) voice_user_subtitle.overflow = true;
if (!voice_user_subtitle.overflow) {
memcpy(voice_user_subtitle.text + used, text, len + 1);
}
if (cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(line, "paragraph"))) {
voice_user_subtitle.complete = true;
/* Overflow disables matching but still cancels a pending goodbye. */
process_voice_end_transcript("subject", voice_user_subtitle.overflow ? "continue" : voice_user_subtitle.text, true);
}
}
static bool voice_end_ready(void)
{
if (!voice_end.pending) return false;
volc_hal_context_t *hal = volc_get_global_hal_context();
uint32_t now = (uint32_t)volc_osal_get_time_ms();
bool quiet = now - atomic_load(&last_voice_received_ms) >= 1200u && hal &&
volc_hal_player_voice_quiet(hal->player_handle[VOLC_HAL_PLAYER_AUDIO], 600u);
return call_voice_end_due(&voice_end, now, atomic_load(&voice_finish_serial),
quiet, atomic_load(&local_speech_active));
}
/* HTTPS reporting must never run inside the WebSocket receive mutex. */
static void process_platform_reports(void)
{
@@ -266,6 +491,24 @@ static void process_platform_reports(void)
while (platform_report_queue && xQueueReceive(platform_report_queue, &root, 0) == pdTRUE) {
cJSON *type_obj = cJSON_GetObjectItemCaseSensitive(root, "type");
const char *type = cJSON_IsString(type_obj) ? type_obj->valuestring : "";
if (!strcmp(type,"subtitle") && platform_runtime_rtc()) {
cJSON *line=NULL;cJSON_ArrayForEach(line,cJSON_GetObjectItemCaseSensitive(root,"data")){
const char *text=cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(line,"text"));
const char *user=cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(line,"userId"));
cJSON *seq=cJSON_GetObjectItemCaseSensitive(line,"sequence");
if(text&&user&&cJSON_IsNumber(seq)&&cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(line,"definite"))){
char key[240];snprintf(key,sizeof(key),"%s:%.0f",user,seq->valuedouble);
const char *local=volc_current_user_id(conv_service.engine,false),*remote=volc_current_user_id(conv_service.engine,true);
const char *speaker=local&&!strcmp(user,local)?"subject":remote&&!strcmp(user,remote)?"agent":"unknown";
/* definite is a final clause; paragraph confirms the whole
* utterance, so "拜拜,不对,我还想聊" cannot end early. */
if (!strcmp(speaker, "subject")) process_voice_end_rtc_user(line);
else process_voice_end_transcript(speaker, text,
cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(line,"paragraph")));
(void)platform_conversation_turn(speaker,key,text);
}
}
} else
if ((strcmp(type, "session.updated") == 0 || strcmp(type, "session.created") == 0) && !session_started_reported) {
cJSON *session = cJSON_GetObjectItemCaseSensitive(root, "session");
cJSON *id = cJSON_IsObject(session) ? cJSON_GetObjectItemCaseSensitive(session, "id") : NULL;
@@ -280,12 +523,14 @@ static void process_platform_reports(void)
cJSON *text = cJSON_GetObjectItemCaseSensitive(root, "transcript");
cJSON *source = cJSON_GetObjectItemCaseSensitive(root, "event_id");
if (cJSON_IsString(text) && text->valuestring[0]) {
process_voice_end_transcript("subject", text->valuestring, true);
(void)platform_conversation_turn("subject", cJSON_IsString(source) ? source->valuestring : "subject", text->valuestring);
}
} else if (strcmp(type, "response.audio_transcript.done") == 0) {
cJSON *text = cJSON_GetObjectItemCaseSensitive(root, "transcript");
cJSON *source = cJSON_GetObjectItemCaseSensitive(root, "response_id");
if (cJSON_IsString(text) && text->valuestring[0]) {
process_voice_end_transcript("agent", text->valuestring, true);
(void)platform_conversation_turn("agent", cJSON_IsString(source) ? source->valuestring : "agent", text->valuestring);
}
}
@@ -293,9 +538,19 @@ static void process_platform_reports(void)
}
}
static bool subtitle_has_final_clause(const cJSON *root)
{
cJSON *line = NULL;
cJSON_ArrayForEach(line, cJSON_GetObjectItemCaseSensitive(root, "data")) {
if (cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(line, "definite"))) return true;
}
return false;
}
static void __on_volc_message_data(volc_engine_t handle, const void *message, size_t size, volc_message_info_t *info_ptr, void *user_data)
{
static char message_buffer[4096];
if(size>=sizeof(message_buffer)-1){platform_conversation_observe("GAP_DECLARED");return;}
if (size > 0 && size < sizeof(message_buffer) - 1) {
memcpy(message_buffer, message, size);
message_buffer[size] = 0;
@@ -306,9 +561,10 @@ static void __on_volc_message_data(volc_engine_t handle, const void *message, si
if (standard_protocol) {
const char *type = cJSON_GetStringValue(cJSON_GetObjectItem(root, "type"));
if (type && (!strcmp(type, "error") || !strcmp(type, "conversation.item.created"))) {
LOGI("Provider control event: %s", message_buffer);
LOGI("Provider control event type: %s", type);
}
if (type && !strcmp(type, "response.audio.done") && !is_stop) {
atomic_fetch_add(&voice_finish_serial, 1);
/* Flush the final sub-frame through the PCM decoder. */
static const uint8_t silence[2048] = {0};
volc_hal_context_t *hal = volc_get_global_hal_context();
@@ -320,8 +576,10 @@ static void __on_volc_message_data(volc_engine_t handle, const void *message, si
}
if (type && (strstr(type, "speech_") || strstr(type, "response."))) {
conv_service.wait_time = 0;
if (!stop_requested) call_idle_activity();
}
bool report = type && (!strcmp(type, "session.created") || !strcmp(type, "session.updated") ||
(!strcmp(type, "subtitle") && platform_runtime_rtc() && subtitle_has_final_clause(root)) ||
!strcmp(type, "conversation.item.input_audio_transcription.completed") ||
!strcmp(type, "response.audio_transcript.done"));
if (report && platform_report_queue && xQueueSend(platform_report_queue, &root, 0) == pdTRUE) {
@@ -333,6 +591,13 @@ static void __on_volc_message_data(volc_engine_t handle, const void *message, si
} else if (message_buffer[0] == 's' && message_buffer[1] == 'u' && message_buffer[2] == 'b' && message_buffer[3] == 'v') {
__on_subtitle_message_received(root);
conv_service.wait_time = 0;
/* Interim ASR updates must not fill the bounded control queue
* and evict the actual completed goodbye. Display still gets all. */
if(platform_runtime_rtc() && subtitle_has_final_clause(root)){
if(platform_report_queue&&xQueueSend(platform_report_queue,&root,0)==pdTRUE)root=NULL;
else platform_conversation_observe("GAP_DECLARED");
}
cJSON_Delete(root);
} else if (message_buffer[0] == 't' && message_buffer[1] == 'o' && message_buffer[2] == 'o' && message_buffer[3] == 'l') {
// function calling 消息
aios_event_pub(VOLC_FUNCTION_CALL_EXEC, root, NULL);
@@ -360,7 +625,16 @@ void conv_ai_service_init()
.on_volc_message_data = __on_volc_message_data,
};
int ret = volc_create(&conv_service.engine, config_buf, &conv_service.volc_event_handler, &conv_service);
cJSON *runtime=cJSON_Parse(config_buf);
if(platform_runtime_rtc()){
cJSON_AddStringToObject(cJSON_GetObjectItem(runtime,"iot"),"rtc_app_id",platform_runtime_rtc_app_id());
cJSON_SetNumberValue(cJSON_GetObjectItem(cJSON_GetObjectItem(cJSON_GetObjectItem(runtime,"rtc"),"audio"),"codec"),1);
}
else cJSON_DeleteItemFromObjectCaseSensitive(runtime,"rtc");
char *runtime_config=cJSON_PrintUnformatted(runtime);cJSON_Delete(runtime);
int ret = volc_create(&conv_service.engine, runtime_config, &conv_service.volc_event_handler, &conv_service);
if(runtime_config){memset(runtime_config,0,strlen(runtime_config));free(runtime_config);}
platform_runtime_engine_ready(ret==0);
if (ret != 0) {
LOGE("volc_create failed, ret: %d", ret);
return;
@@ -369,14 +643,42 @@ void conv_ai_service_init()
}
}
void conv_ai_service_prepare_task(void)
{
// Called synchronously by the serialized manager before creating the task.
diagnostic_prepare();
stop_requested = false;
is_stop = true;
atomic_store(&user_stop_requested, false);
atomic_store(&display_connected, false);
atomic_store(&local_speech_active, false);
call_voice_end_reset(&voice_end);
memset(&voice_user_subtitle, 0, sizeof(voice_user_subtitle));
atomic_store(&voice_finish_serial, 0);
atomic_store(&last_voice_received_ms, (uint32_t)volc_osal_get_time_ms());
/* The previous SDK has stopped before the manager admits a new call. */
cJSON *stale;
while (platform_report_queue && xQueueReceive(platform_report_queue, &stale, 0) == pdTRUE) cJSON_Delete(stale);
call_idle_activity();
call_display_begin(&call_display);
}
void conv_ai_service_task(void *pvParameters)
{
LOGI("TASK_DIAGNOSTIC name=conv_ai present=1 priority=%u core=%d",
(unsigned)uxTaskPriorityGet(NULL), (int)xTaskGetCoreID(NULL));
volc_hal_context_t* g_hal_context = volc_get_global_hal_context();
char* status_str = NULL;
bool provider_started = false;
bool audio_started = false;
bool local_audio_stopped = true;
call_display_stop_t display_stop = CALL_DISPLAY_STOP_FAILED;
unsigned display_generation = call_display.generation;
char *params = NULL;
is_stop = true;
session_started_reported = false;
provider_disconnected = false;
atomic_store(&rtc_connected,false);
if (g_hal_context == NULL) {
LOGE("volc_get_global_hal_context failed");
status_str = "系统未初始化,ai对话连接失败";
@@ -384,8 +686,9 @@ void conv_ai_service_task(void *pvParameters)
}
global_display = g_hal_context->display_handle;
int ret = 0;
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT, "ai对话创建中");
publish_call_display(display_generation);
if (stop_requested) goto CONV_AI_QUIT;
ret = platform_conversation_begin();
if (ret != 0) {
LOGE("platform_conversation_begin failed, ret: %d", ret);
@@ -393,8 +696,14 @@ void conv_ai_service_task(void *pvParameters)
if (strcmp(platform_last_error_code(), "FIRMWARE_UPDATE_REQUIRED") == 0) {
status_str = "平台要求更新固件";
}
if (strcmp(call_client_start_error(), "DEVICE_UNBOUND") == 0) {
status_str = "设备没有绑定人员,请先绑定";
} else if (strcmp(call_client_start_error(), "VOICE_AGENT_NOT_READY") == 0) {
status_str = "老人的语音助手尚未就绪";
}
goto CONV_AI_QUIT;
}
if (stop_requested || !platform_conversation_audio_authorized()) goto CONV_AI_QUIT;
/* Capture may run while the WebSocket is still handshaking. Hold frames
* until volc_start has moved the engine into STARTED state. */
@@ -404,7 +713,7 @@ void conv_ai_service_task(void *pvParameters)
volc_hal_capture_config_t capture_audio_config = {0};
capture_audio_config.media_type = VOLC_MEDIA_TYPE_AUDIO;
capture_audio_config.data_cb = __audio_capture_cb;
// capture_audio_config.audio_wakeup_cb = (volc_hal_audio_wakeup_cb_t)rec_engine_cb;
capture_audio_config.audio_wakeup_cb = call_idle_capture_event;
capture_audio_config.user_data = g_hal_context;
volc_hal_capture_t capture_handle = g_hal_context->capture_handle[VOLC_HAL_PLAYER_AUDIO];
@@ -413,9 +722,13 @@ void conv_ai_service_task(void *pvParameters)
capture_handle = g_hal_context->capture_handle[VOLC_HAL_PLAYER_AUDIO];
}
audio_started = true;
volc_hal_capture_stop(g_hal_context->capture_handle[VOLC_HAL_CAPTURE_AUDIO]);
volc_hal_capture_update_config(capture_handle, &capture_audio_config);
volc_hal_capture_start(g_hal_context->capture_handle[VOLC_HAL_PLAYER_AUDIO], VOLC_AUDIO_MODE_CAPTURE);
if (!capture_handle ||
volc_hal_capture_stop(capture_handle) != 0 ||
volc_hal_capture_update_config(capture_handle, &capture_audio_config) != 0 ||
volc_hal_capture_start(capture_handle, VOLC_AUDIO_MODE_CAPTURE) != 0) {
status_str = "收音启动失败";
goto CONV_AI_QUIT;
}
volc_hal_player_config_t player_audio_config = {0};
player_audio_config.media_type = VOLC_MEDIA_TYPE_AUDIO;
@@ -423,20 +736,22 @@ void conv_ai_service_task(void *pvParameters)
g_hal_context->player_handle[VOLC_HAL_PLAYER_AUDIO] = volc_hal_player_create(&player_audio_config);
}
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_STATUS, VOLC_DISPLAY_TEXT, "ai对话连接中");
publish_call_display(display_generation);
volc_hal_player_set_authorization_gate(g_hal_context->player_handle[VOLC_HAL_PLAYER_AUDIO], conversation_output_allowed);
volc_hal_player_start(g_hal_context->player_handle[VOLC_HAL_PLAYER_AUDIO]);
// step 2: start ai conversation
const char *session_update = platform_conversation_update_json();
size_t params_size = strlen(session_update) + 64;
char *params = volc_osal_malloc(params_size);
params = volc_osal_malloc(params_size);
if (params == NULL) {
status_str = "会话配置内存不足";
goto CONV_AI_QUIT;
}
snprintf(params, params_size, "{\"audio\":{\"codec\":0},\"session_update\":%s}", session_update);
if(platform_runtime_rtc())snprintf(params,params_size,"%s",session_update);
else snprintf(params, params_size, "{\"audio\":{\"codec\":0},\"session_update\":%s}", session_update);
volc_opt_t opt = {
.mode = VOLC_MODE_WS,
.mode = platform_runtime_rtc()?VOLC_MODE_RTC:VOLC_MODE_WS,
.bot_id = (char *)platform_conversation_bot_id(),
.params = params,
};
@@ -444,46 +759,91 @@ void conv_ai_service_task(void *pvParameters)
aios_event_pub(VOLC_LOCAL_LOGIC_PLAY_WELCOME, NULL, NULL);
#endif
ret = volc_start(conv_service.engine, &opt);
volc_osal_free(params);
if (ret != 0) {
LOGE("volc_start failed, ret: %d", ret);
status_str = "ai对话连接失败,准备退出";
goto CONV_AI_QUIT;
}
provider_started = true;
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_STATUS, VOLC_DISPLAY_TEXT, "ai对话已连接");
call_idle_activity();
if (stop_requested || !platform_conversation_audio_authorized()) goto CONV_AI_QUIT;
// A successful start request does not prove that the media transport joined.
observe_call_connection(display_generation);
is_stop = false;
while (!is_stop && !provider_disconnected) {
while (!is_stop && !stop_requested && !provider_disconnected && platform_conversation_audio_authorized()) {
observe_call_connection(display_generation);
if(platform_runtime_rtc()&&atomic_load(&rtc_connected))session_started_reported=true;
process_platform_reports();
if (session_started_reported && platform_conversation_poll()) {
status_str = "平台已结束本次对话";
if (voice_end_ready()) {
LOGI("VOICE_END user request completed");
status_str = "本次对话已结束";
atomic_store(&user_stop_requested, true);
display_stop = CALL_DISPLAY_STOP_NORMAL;
goto CONV_AI_QUIT;
}
if (diagnostic_say_pending && session_started_reported) {
diagnostic_say_pending = false;
if ((platform_runtime_rtc()||session_started_reported) && platform_conversation_poll()) {
status_str = "平台已结束本次对话";
display_stop = atomic_load(&display_connected) ? CALL_DISPLAY_STOP_NORMAL : CALL_DISPLAY_STOP_FAILED;
goto CONV_AI_QUIT;
}
if ((atomic_load(&diagnostic_state) & DIAGNOSTIC_PENDING_MASK) != DIAGNOSTIC_NONE && session_started_reported && (!platform_runtime_rtc() || volc_current_user_id(conv_service.engine,true))) {
unsigned diagnostic = diagnostic_take_pending();
if (diagnostic == DIAGNOSTIC_TTS) {
diagnostic_send_tts();
} else if (diagnostic == DIAGNOSTIC_SAY) {
char request[640];
snprintf(request, sizeof(request), "{\"event_id\":\"diag-%llu\",\"type\":\"conversation.item.create\",\"item\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"请只说这句话:你好,这是语音播放测试。现在使用自然的语速,连续清楚地说完这句话。\"}],\"interrupt_mode\":1}}", (unsigned long long)volc_osal_get_time_ms());
volc_message_info_t info = {.is_binary = false};
int sent = volc_send_message(conv_service.engine, request, strlen(request), &info);
int sent = platform_runtime_rtc()
? volc_send_text_to_agent(conv_service.engine,"请只说这句话:你好,这是语音播放测试。",VOLC_AGENT_TYPE_LLM,1)
: volc_send_message(conv_service.engine, request, strlen(request), &info);
LOGI("PLAYBACK diagnostic sentence requested ret=%d", sent);
const char *respond = "{\"type\":\"response.create\",\"response\":{\"modalities\":[\"text\",\"audio\"]}}";
volc_send_message(conv_service.engine, respond, strlen(respond), &info);
if (!platform_runtime_rtc()) volc_send_message(conv_service.engine, respond, strlen(respond), &info);
conv_service.wait_time = 0;
call_idle_activity();
}
}
sleep(1);
conv_service.wait_time++;
if(conv_service.wait_time >= CONV_SERVICE_TIMEOUT){
if (call_idle_expired()) {
LOGI("CALL_IDLE timeout seconds=%d", CONV_SERVICE_TIMEOUT);
status_str = "ai对话已超时,准备退出";
display_stop = atomic_load(&display_connected) ? CALL_DISPLAY_STOP_NORMAL : CALL_DISPLAY_STOP_FAILED;
goto CONV_AI_QUIT;
}
}
status_str = "ai对话已断开";
display_stop = atomic_load(&provider_disconnected) ? CALL_DISPLAY_STOP_INTERRUPTED :
atomic_load(&display_connected) ? CALL_DISPLAY_STOP_NORMAL : CALL_DISPLAY_STOP_FAILED;
CONV_AI_QUIT:
// Latch a connection that arrived immediately before hangup, without
// publishing a misleading new TALKING frame while teardown has started.
if (atomic_load(&display_connected)) call_display_connected(&call_display, display_generation);
is_stop = true;
// volc_hal_file_close(audioFile);
volc_stop(conv_service.engine);
stop_requested = true;
diagnostic_close();
call_display_stopping(&call_display, display_generation);
publish_call_display(display_generation);
// Local privacy boundary precedes SDK shutdown and all HTTP requests.
if (g_hal_context && audio_started) {
local_audio_stopped = volc_hal_capture_stop(g_hal_context->capture_handle[VOLC_HAL_CAPTURE_AUDIO]) == 0;
if (g_hal_context->player_handle[VOLC_HAL_PLAYER_AUDIO]) {
volc_hal_player_discard_pending(g_hal_context->player_handle[VOLC_HAL_PLAYER_AUDIO]);
if (volc_hal_player_stop(g_hal_context->player_handle[VOLC_HAL_PLAYER_AUDIO]) != 0) local_audio_stopped = false;
}
}
int stop_result=volc_stop(conv_service.engine);
platform_conversation_stop_result(provider_started,stop_result);
if (atomic_load(&user_stop_requested)) display_stop = CALL_DISPLAY_STOP_NORMAL;
if (provider_started && stop_result != 0) display_stop = CALL_DISPLAY_STOP_INTERRUPTED;
// Show the local terminal state before an end-report HTTP request can block.
call_display_finished(&call_display, display_generation, display_stop,
local_audio_stopped, volc_osal_get_time_ms());
publish_call_display(display_generation);
// The SDK may retain opt.params until stop has released the session.
if (params) { volc_osal_free(params); params = NULL; }
process_platform_reports();
platform_conversation_finish(!provider_started || !session_started_reported,
!provider_started ? "VOLC_START_FAILED" : (!session_started_reported ? "SESSION_UPDATE_NOT_CONFIRMED" : NULL));
@@ -497,25 +857,40 @@ CONV_AI_QUIT:
}
}
if(status_str != NULL){
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_STATUS, VOLC_DISPLAY_TEXT, status_str);
}
if (status_str != NULL) LOGI("Call finished: %s", status_str);
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_SUBTITLE, VOLC_DISPLAY_TEXT, "");
conv_service.wait_time = 0;
// just show the status: Ai对话已断开
sleep(1);
// No detached timer: the old task owns this brief terminal display. Restore
// idle before publishing OVER, so a new call can never be overwritten by an
// old timeout. Slow end-report requests already count toward the hold time.
if (local_audio_stopped) {
while (!call_display_idle_if_due(&call_display, display_generation, volc_osal_get_time_ms())) {
vTaskDelay(pdMS_TO_TICKS(50));
}
publish_call_display(display_generation);
}
if (g_hal_context && audio_started) {
volc_hal_capture_start(g_hal_context->capture_handle[VOLC_HAL_CAPTURE_AUDIO],VOLC_AUDIO_MODE_WAKEUP);
}
aios_event_pub(VOLC_SERVICE_AI_CONVERSATION_OVER, NULL, NULL);
#ifdef ESP_PLATFORM
// The manager releases this WithCaps task from another task's context.
// Plain vTaskDelete(NULL) does not free WithCaps stacks/TCBs.
for (;;) vTaskSuspend(NULL);
#else
volc_osal_thread_exit(NULL);
#endif
}
void conv_ai_service_task_stop(void)
{
// sleep for AI Agent say goodbye
sleep(1);
atomic_store(&user_stop_requested, true);
stop_requested = true;
is_stop = true;
diagnostic_close();
// Capture callbacks drop frames immediately. The independent audio worker
// closes its feeder through the authorization gate, including blocked HTTP.
// Only the conversation task destroys handles; hook callbacks never race it.
}
#if __cplusplus
@@ -11,6 +11,11 @@
#include "volc_service_common.h"
#include "volc_conv_service.h"
#include "volc_conv.h"
#ifdef ESP_PLATFORM
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#endif
extern void conv_ai_service_diagnostic_reset(void);
#if __cplusplus
extern "C" {
@@ -31,6 +36,14 @@ static aios_ret_t __state_conversation(volc_conv_service_manager_t * const me, a
}
volc_hal_display_t global_display = g_hal_context->display_handle;
switch (e->id) {
case VOLC_SERVICE_AI_CONVERSATION_TOGGLE:
// This manager owns admission. Connecting and stopping tasks remain
// active until OVER, so repeated taps cannot create another call.
if (me->conv_thread_id != NULL) {
conv_ai_service_task_stop();
return AIOS_Ret_Handled;
}
/* fall through */
case VOLC_SERVICE_AI_CONVERSATION_START:
if (me->conv_thread_id == NULL) {
volc_osal_thread_param_t param = {0};
@@ -39,7 +52,10 @@ static aios_ret_t __state_conversation(volc_conv_service_manager_t * const me, a
param.stack_size = 8*1024;
param.priority = 5;
param.stack_in_ext = 1;
volc_osal_thread_create(&me->conv_thread_id, &param, conv_ai_service_task,NULL);
conv_ai_service_prepare_task();
if (volc_osal_thread_create(&me->conv_thread_id, &param, conv_ai_service_task,NULL) != 0) {
conv_ai_service_diagnostic_reset();
}
}
return AIOS_Ret_Handled;
case VOLC_SERVICE_AI_CONVERSATION_INTERRUPT:
@@ -48,12 +64,20 @@ static aios_ret_t __state_conversation(volc_conv_service_manager_t * const me, a
case VOLC_SERVICE_AI_CONVERSATION_QUIT:
if (me->conv_thread_id != NULL) {
conv_ai_service_task_stop();
me->conv_thread_id = NULL;
// The old task owns SDK teardown and reports OVER after cleanup.
// Keep admission closed until then, including rapid hook toggles.
}
return AIOS_Ret_Handled;
// notice the Ai_Conversation over by task
case VOLC_SERVICE_AI_CONVERSATION_OVER:
if (me->conv_thread_id != NULL) {
#ifdef ESP_PLATFORM
// OSAL stores the native handle in an allocated handle slot.
// OVER is the worker's final action; reclaim its WithCaps
// stack and TCB before admitting another conversation.
vTaskDeleteWithCaps(*(TaskHandle_t *)me->conv_thread_id);
#endif
volc_osal_thread_destroy(me->conv_thread_id);
me->conv_thread_id = NULL;
}
return AIOS_Ret_Handled;
@@ -65,6 +89,7 @@ static aios_ret_t __state_conversation(volc_conv_service_manager_t * const me, a
static aios_ret_t __state_init(volc_conv_service_manager_t * const me, aios_event_t const * const e)
{
AIOS_EVENT_SUB(VOLC_SERVICE_AI_CONVERSATION_START); // 订阅事件
AIOS_EVENT_SUB(VOLC_SERVICE_AI_CONVERSATION_TOGGLE);
AIOS_EVENT_SUB(VOLC_SERVICE_AI_CONVERSATION_INTERRUPT); // 订阅事件
AIOS_EVENT_SUB(VOLC_SERVICE_AI_CONVERSATION_QUIT); // 订阅事件
AIOS_EVENT_SUB(VOLC_SERVICE_AI_CONVERSATION_OVER); // 订阅事件
@@ -14,6 +14,7 @@ enum {
VOLC_SERVICE_AI_CONVERSATION_INTERRUPT = 101,
VOLC_SERVICE_AI_CONVERSATION_QUIT = 102, // control the Ai_Conversation task quit
VOLC_SERVICE_AI_CONVERSATION_OVER = 103, // notice the Ai_Conversation over by task
VOLC_SERVICE_AI_CONVERSATION_TOGGLE = 104, // screen tap: start idle or stop active
// function_call event:
VOLC_FUNCTION_CALL_EXEC = 200,
@@ -21,7 +21,6 @@ typedef struct volc_local_logic_service {
} volc_local_logic_service_t;
volc_local_logic_service_t g_volc_local_logic_service;
static int has_quit = 0;
static void __play_welcome(){
volc_hal_context_t* g_hal_context = volc_get_global_hal_context();
if(g_hal_context == NULL) return;
@@ -42,50 +41,20 @@ static void __play_welcome(){
return;
}
static int __has_substring(const char *haystack, const char *needle) {
// 处理边界:子串为空串,按惯例返回包含(可根据需求调整)
if (needle[0] == '\0') {
return 1;
}
// strstr 返回非 NULL 表示找到子串
return strstr(haystack, needle) != NULL ? 1 : 0;
}
static void __parser_and_process_subtitle(char *subtitle)
{
volc_conv_service_t conv_service = get_conv_ai_service();
if (__has_substring(subtitle, "退下吧") || __has_substring(subtitle, "拜拜"))
{
if (!has_quit)
{
volc_send_text_to_agent(conv_service.engine, "好的拜拜", VOLC_AGENT_TYPE_TTS, 1);
has_quit = 1;
}
}
if (__has_substring(subtitle, "好的拜拜"))
{
aios_event_pub(VOLC_SERVICE_AI_CONVERSATION_QUIT, NULL, NULL);
has_quit = 0;
}
}
static aios_ret_t __state_conversation(volc_local_logic_service_t * const me, aios_event_t const * const e)
{
switch (e->id) {
case VOLC_LOCAL_LOGIC_PLAY_WELCOME:
__play_welcome();
return AIOS_Ret_Handled;
case VOLC_LOCAL_LOGIC_PROCESS_SUBTITLE:
__parser_and_process_subtitle(e->data);
return AIOS_Ret_Handled;
}
}
static aios_ret_t __state_init(volc_local_logic_service_t * const me, aios_event_t const * const e)
{
AIOS_EVENT_SUB(VOLC_LOCAL_LOGIC_PLAY_WELCOME); // 订阅事件
AIOS_EVENT_SUB(VOLC_LOCAL_LOGIC_PROCESS_SUBTITLE); // 订阅事件
// Voice ending is handled by the conversation task with speaker identity,
// complete user transcripts and playback drain; never by substring events.
return AIOS_TRAN(__state_conversation);
}
@@ -28,6 +28,8 @@
#include "av_pipeline_pool.h"
#include "esp_gmf_fifo.h"
#include "esp_codec_dev.h"
#include <stdatomic.h>
#include "freertos/semphr.h"
#include "esp_gmf_new_databus.h"
#include "esp_vad.h"
@@ -104,6 +106,18 @@ typedef struct {
static audio_manager_t audio_manager;
static audio_recorder_t audio_recorder;
static atomic_bool microphone_blocked, recorder_closing, recorder_suspended;
static StaticSemaphore_t recorder_mutex_storage;
static SemaphoreHandle_t recorder_mutex;
static portMUX_TYPE recorder_init_lock=portMUX_INITIALIZER_UNLOCKED;
static SemaphoreHandle_t recorder_control_lock(void) {
portENTER_CRITICAL(&recorder_init_lock);
if(!recorder_mutex)recorder_mutex=xSemaphoreCreateMutexStatic(&recorder_mutex_storage);
portEXIT_CRITICAL(&recorder_init_lock);
return recorder_mutex;
}
static atomic_uint microphone_inflight, microphone_reads;
static atomic_uint recorder_readers;
static audio_feeder_t audio_feeder;
static audio_playback_t audio_playback;
static audio_prompt_t audio_prompt;
@@ -163,8 +177,10 @@ static inline esp_opus_enc_frame_duration_t convert_opus_enc_frame_duration(int
static int audio_render_write_hdlr(uint8_t *pcm_data, uint32_t len, void *ctx)
{
ESP_LOGD(TAG, "Audio render write handler, data size: %ld", len);
esp_codec_dev_write(audio_manager.config.play_dev, pcm_data, len);
return ESP_OK;
int result=esp_codec_dev_write(audio_manager.config.play_dev, pcm_data, len);
extern void platform_conversation_observe(const char *type);
if(result==ESP_OK&&len)platform_conversation_observe("FIRST_PLAYBACK_STARTED");
return result;
}
static int playback_out_data_callback(uint8_t *data, int data_size, void *ctx)
@@ -240,7 +256,18 @@ static int recorder_outport_release_write(void *handle, esp_gmf_data_bus_block_t
static int recorder_inport_acquire_read(void *handle, esp_gmf_data_bus_block_t *blk, int wanted_size, int block_ticks)
{
// Increment before checking the gate, so standby can await every read that
// crossed the gate. While suspended no microphone samples enter the AFE.
for (;;) {
atomic_fetch_add(&microphone_inflight,1);
if (!atomic_load(&microphone_blocked)) break;
atomic_fetch_sub(&microphone_inflight,1);
if(atomic_load(&recorder_closing)){blk->valid_size=0;return ESP_GMF_IO_FAIL;}
vTaskDelay(pdMS_TO_TICKS(10));
}
int read_ret = esp_codec_dev_read(audio_manager.config.rec_dev, blk->buf, wanted_size);
atomic_fetch_add(&microphone_reads,1);
atomic_fetch_sub(&microphone_inflight,1);
if (read_ret != ESP_CODEC_DEV_OK) {
ESP_LOGE(TAG, "Microphone read failed: %d", read_ret);
blk->valid_size = 0;
@@ -531,6 +558,10 @@ static void recorder_cleanup_on_error(void)
esp_gmf_afe_manager_destroy(audio_recorder.afe_manager);
audio_recorder.afe_manager = NULL;
}
if (audio_recorder.pool) {
av_audio_recorder_pool_deinit(audio_recorder.pool);
audio_recorder.pool = NULL;
}
}
static esp_err_t recorder_configure_all_encoders(void)
@@ -662,7 +693,9 @@ static int feeder_outport_release_write(void *handle, esp_gmf_data_bus_block_t *
if (audio_manager.enable_mixer) {
esp_audio_render_stream_write(audio_manager.stream[AUDIO_RENDER_STREAM_FEEDER], blk->buf, blk->valid_size);
} else {
esp_codec_dev_write(audio_manager.config.play_dev, blk->buf, blk->valid_size);
int result=esp_codec_dev_write(audio_manager.config.play_dev, blk->buf, blk->valid_size);
extern void platform_conversation_observe(const char *type);
if(result==ESP_OK&&blk->valid_size)platform_conversation_observe("FIRST_PLAYBACK_STARTED");
}
return blk->valid_size;
}
@@ -1122,7 +1155,7 @@ esp_err_t audio_prompt_play(const char *url)
return ESP_OK;
}
esp_err_t audio_recorder_open(audio_recorder_config_t *config)
static esp_err_t recorder_open_internal(audio_recorder_config_t *config)
{
ESP_LOGI(TAG, "Opening audio recorder...");
@@ -1170,10 +1203,24 @@ esp_err_t audio_recorder_open(audio_recorder_config_t *config)
cleanup_and_exit:
ESP_LOGE(TAG, "Failed to open audio recorder, cleaning up...");
atomic_store(&recorder_closing,true);
recorder_cleanup_on_error();
atomic_store(&recorder_closing,false);
return ESP_FAIL;
}
esp_err_t audio_recorder_open(audio_recorder_config_t *config)
{
SemaphoreHandle_t lock=recorder_control_lock();xSemaphoreTake(lock,portMAX_DELAY);
esp_err_t e=recorder_open_internal(config);
atomic_store(&recorder_suspended,false);
if(e==ESP_OK && atomic_load(&microphone_blocked)) {
e=esp_gmf_afe_manager_suspend(audio_recorder.afe_manager,true)==ESP_GMF_ERR_OK?ESP_OK:ESP_FAIL;
atomic_store(&recorder_suspended,e==ESP_OK);
}
xSemaphoreGive(lock);return e;
}
esp_err_t audio_recorder_pause(void)
{
if (audio_recorder.state == AUDIO_RUN_STATE_CLOSED) {
@@ -1227,7 +1274,7 @@ esp_err_t audio_recorder_get_afe_manager_handle(esp_gmf_afe_manager_handle_t *af
}
esp_err_t audio_recorder_close(void)
static esp_err_t recorder_close_internal(void)
{
if (audio_recorder.state == AUDIO_RUN_STATE_CLOSED) {
ESP_LOGW(TAG, "Audio recorder is already closed");
@@ -1241,8 +1288,10 @@ esp_err_t audio_recorder_close(void)
if (audio_recorder.pipe) {
esp_gmf_fifo_abort(audio_recorder.fifo);
esp_gmf_pipeline_stop(audio_recorder.pipe);
audio_recorder.pipe = NULL;
}
// Abort wakes blocked readers. Keep the FIFO and pipeline alive until each
// reader has released its block; retain the pipeline handle for destroy.
while (atomic_load(&recorder_readers)) vTaskDelay(1);
if (audio_recorder.task) {
esp_gmf_task_deinit(audio_recorder.task);
audio_recorder.task = NULL;
@@ -1277,20 +1326,31 @@ esp_err_t audio_recorder_close(void)
return ESP_OK;
}
esp_err_t audio_recorder_close(void)
{
SemaphoreHandle_t lock=recorder_control_lock();xSemaphoreTake(lock,portMAX_DELAY);
atomic_store(&recorder_closing,true);
esp_err_t e=recorder_close_internal();
atomic_store(&recorder_closing,false);
xSemaphoreGive(lock);return e;
}
esp_err_t audio_recorder_read_data(uint8_t *data, int data_size)
{
if (!data || data_size <= 0) {
ESP_LOGE(TAG, "Invalid parameters for recorder read");
return ESP_FAIL;
}
if (audio_recorder.state == AUDIO_RUN_STATE_CLOSED || !audio_recorder.fifo) {
ESP_LOGE(TAG, "Recorder not available for reading");
atomic_fetch_add(&recorder_readers, 1);
if (atomic_load(&recorder_closing) || audio_recorder.state == AUDIO_RUN_STATE_CLOSED || !audio_recorder.fifo) {
atomic_fetch_sub(&recorder_readers, 1);
return ESP_FAIL;
}
esp_gmf_data_bus_block_t blk = {0};
int ret = esp_gmf_fifo_acquire_read(audio_recorder.fifo, &blk, data_size, portMAX_DELAY);
if (ret < 0) {
// ESP_LOGE(TAG, "Failed to acquire read from recorder FIFO (%d)", ret);
atomic_fetch_sub(&recorder_readers, 1);
return ret;
}
int bytes_to_copy = blk.valid_size;
@@ -1299,15 +1359,37 @@ esp_err_t audio_recorder_read_data(uint8_t *data, int data_size)
bytes_to_copy = data_size;
}
memcpy(data, blk.buf, bytes_to_copy);
esp_gmf_fifo_release_read(audio_recorder.fifo, &blk, portMAX_DELAY);
#if CONFIG_MEDIA_DUMP_ENABLE && CONFIG_MEDIA_DUMP_AUDIO_AFTER_AEC
media_dump_feed(NULL, (uint8_t *)blk.buf, blk.valid_size);
#endif /* CONFIG_MEDIA_DUMP_ENABLE && CONFIG_MEDIA_DUMP_AUDIO_AFTER_AEC */
esp_gmf_fifo_release_read(audio_recorder.fifo, &blk, portMAX_DELAY);
atomic_fetch_sub(&recorder_readers, 1);
return bytes_to_copy;
}
esp_err_t audio_feeder_feed_data(uint8_t *data, int data_size)
{
return audio_feeder_feed_data_timeout(data, data_size, UINT32_MAX);
}
esp_err_t audio_recorder_set_standby(bool standby)
{
SemaphoreHandle_t lock=recorder_control_lock();xSemaphoreTake(lock,portMAX_DELAY);
atomic_store(&microphone_blocked,standby);
esp_err_t e=ESP_OK;
if(audio_recorder.afe_manager && esp_gmf_afe_manager_suspend(audio_recorder.afe_manager,standby)!=ESP_GMF_ERR_OK)e=ESP_FAIL;
atomic_store(&recorder_suspended,standby && e==ESP_OK);
if(standby && e==ESP_OK) {
for(int i=0;i<100 && atomic_load(&microphone_inflight);i++)vTaskDelay(pdMS_TO_TICKS(10));
if(atomic_load(&microphone_inflight))e=ESP_ERR_TIMEOUT;
}
xSemaphoreGive(lock);return e;
}
bool audio_recorder_is_standby(void) { return atomic_load(&microphone_blocked) && atomic_load(&recorder_suspended) && !atomic_load(&microphone_inflight); }
uint32_t audio_recorder_microphone_reads(void) { return atomic_load(&microphone_reads); }
esp_err_t audio_feeder_feed_data_timeout(uint8_t *data, int data_size, uint32_t timeout_ms)
{
TickType_t wait_ticks = timeout_ms == UINT32_MAX ? portMAX_DELAY : pdMS_TO_TICKS(timeout_ms);
if (!data || data_size <= 0) {
ESP_LOGE(TAG, "Invalid parameters for playback feed");
return ESP_FAIL;
@@ -1319,17 +1401,17 @@ esp_err_t audio_feeder_feed_data(uint8_t *data, int data_size)
esp_gmf_data_bus_block_t blk = {0};
if (audio_feeder.config.decoder_cfg.format == AV_PROCESSOR_FORMAT_ID_PCM) {
int ret = esp_gmf_db_acquire_write(audio_feeder.fifo, &blk, data_size, portMAX_DELAY);
int ret = esp_gmf_db_acquire_write(audio_feeder.fifo, &blk, data_size, wait_ticks);
if (ret < 0) {
ESP_LOGE(TAG, "Failed to acquire write to playback FIFO (0x%x)", ret);
return ESP_FAIL;
}
blk.buf = (uint8_t *)data;
blk.valid_size = data_size;
ret = esp_gmf_db_release_write(audio_feeder.fifo, &blk, portMAX_DELAY);
ret = esp_gmf_db_release_write(audio_feeder.fifo, &blk, wait_ticks);
ESP_GMF_RET_ON_NOT_OK(TAG, ret, {return ESP_FAIL;}, "Failed to release write to playback FIFO");
} else {
int ret = esp_gmf_db_acquire_write(audio_feeder.fifo, &blk, data_size, portMAX_DELAY);
int ret = esp_gmf_db_acquire_write(audio_feeder.fifo, &blk, data_size, wait_ticks);
if (ret < 0) {
ESP_LOGE(TAG, "Failed to acquire write to playback FIFO (0x%x)", ret);
return ESP_FAIL;
@@ -1338,7 +1420,7 @@ esp_err_t audio_feeder_feed_data(uint8_t *data, int data_size)
memcpy(blk.buf, data, bytes_to_copy);
blk.valid_size = bytes_to_copy;
ret = esp_gmf_db_release_write(audio_feeder.fifo, &blk, portMAX_DELAY);
ret = esp_gmf_db_release_write(audio_feeder.fifo, &blk, wait_ticks);
ESP_GMF_RET_ON_NOT_OK(TAG, ret, {return ESP_FAIL;}, "Failed to release write to playback FIFO");
}
return ESP_OK;
@@ -251,6 +251,10 @@ esp_err_t audio_recorder_pause(void);
* - Other Appropriate esp_err_t error code on failure
*/
esp_err_t audio_recorder_resume(void);
// Pause microphone reads and AFE wake detection without destroying audio tasks.
esp_err_t audio_recorder_set_standby(bool standby);
bool audio_recorder_is_standby(void);
uint32_t audio_recorder_microphone_reads(void);
/**
* @brief Get the AFE manager handle of the audio recorder
@@ -333,6 +337,8 @@ esp_err_t audio_feeder_stop(void);
* - Other Appropriate esp_err_t error code on failure
*/
esp_err_t audio_feeder_feed_data(uint8_t *data, int data_size);
/* Bounded queue waits for clients with a local audio authorization deadline. */
esp_err_t audio_feeder_feed_data_timeout(uint8_t *data, int data_size, uint32_t timeout_ms);
/**
* @brief Open the audio playback module
@@ -36,7 +36,7 @@ endif()
idf_component_register(
SRCS ${VOLC_SRCS}
INCLUDE_DIRS ${VOLC_INCS}
REQUIRES mbedtls json lwip esp_netif
REQUIRES mbedtls json lwip esp_netif esp_audio_codec
)
if(CONFIG_VOLC_RTC_MODE)
@@ -98,7 +98,7 @@ int volc_device_register(volc_iot_info_t* info, char** output)
char* json_str = cJSON_PrintUnformatted(root);
snprintf(url, sizeof(url), "%s%s?%s&%s", VOLC_IOT_HOST, VOLC_DYNAMIC_REGISTER_PATH, VOLC_API_ACTION_DYNAMIC_REGISTER, VOLC_API_VERSION_QUERY_PARAM);
LOGD("url: %s, body: %s", url, json_str);
LOGD("device registration request prepared");
char* response = volc_http_post(url, json_str, strlen(json_str));
if (response == NULL) {
@@ -140,8 +140,8 @@ int volc_device_register(volc_iot_info_t* info, char** output)
err_out_label:
HAL_SAFE_FREE(payload);
HAL_SAFE_FREE(response);
HAL_SAFE_FREE(root);
HAL_SAFE_FREE(response_json);
cJSON_Delete(root);
cJSON_Delete(response_json);
HAL_SAFE_FREE(signature);
HAL_SAFE_FREE(json_str);
return ret;
@@ -170,17 +170,19 @@ int volc_get_rtc_config(volc_iot_info_t* info, int audio_codec, const char* bot_
if(params){
cJSON* params_config = cJSON_Parse(params);
cJSON* agent_config = cJSON_CreateObject();
cJSON* agent_config = NULL;
volc_json_read_object(params_config,"AgentConfig",&agent_config);
cJSON_AddItemToObject(root, "agent_config", agent_config);
if (agent_config) cJSON_AddItemToObject(root, "agent_config", agent_config);
cJSON* config = cJSON_CreateObject();
cJSON* config = NULL;
volc_json_read_object(params_config,"Config",&config);
cJSON_AddItemToObject(root, "config", config);
// Omit Config when using the elder's already-configured bot defaults.
if (config) cJSON_AddItemToObject(root, "config", config);
cJSON_Delete(params_config);
}
char* json_str = cJSON_PrintUnformatted(root);
snprintf(url, sizeof(url), "%s%s?%s&%s", VOLC_IOT_HOST, VOLC_GET_RTC_CONFIG_PATH, VOLC_API_ACTION_GET_RTC_CONFIG, VOLC_API_VERSION_QUERY_PARAM);
LOGI("url: %s, body: %s", url, json_str);
LOGI("RTC configuration request prepared");
char* response = volc_http_post(url, json_str, strlen(json_str));
if (response == NULL) {
LOGE("Failed to get response from server");
@@ -193,7 +195,12 @@ int volc_get_rtc_config(volc_iot_info_t* info, int audio_codec, const char* bot_
volc_json_read_string(response_json, "Result.Token", &room_info->rtc_opt.p_token);
volc_json_read_string(response_json, "Result.TaskID", &room_info->task_id);
if (room_info->rtc_opt.p_channel_name == NULL || room_info->rtc_opt.p_uid == NULL || room_info->rtc_opt.p_token == NULL || room_info->task_id == NULL) {
LOGE("Failed to get RTC config from server: %s", response);
char *provider_code = NULL;
volc_json_read_string(response_json, "ResponseMetadata.Error.Code", &provider_code);
LOGE("RTC config rejected code=%s fields room=%d user=%d token=%d task=%d", provider_code ? provider_code : "NO_ERROR_CODE",
room_info->rtc_opt.p_channel_name != NULL, room_info->rtc_opt.p_uid != NULL,
room_info->rtc_opt.p_token != NULL, room_info->task_id != NULL);
HAL_SAFE_FREE(provider_code);
ret = -1;
goto err_out_label;
}
@@ -26,6 +26,7 @@ void volc_rtc_destroy(volc_rtc_t rtc);
int volc_rtc_start(volc_rtc_t rtc, const char* bot_id, volc_iot_info_t* iot_info, const char* params);
int volc_rtc_stop(volc_rtc_t rtc);
const char *volc_rtc_user_id(volc_rtc_t rtc,bool remote);
int volc_rtc_send(volc_rtc_t rtc, const void* data, int size, volc_data_info_t* data_info);
@@ -6,6 +6,30 @@
#include <string.h>
#include <unistd.h>
#include <inttypes.h>
#include <stdatomic.h>
#ifdef ESP_PLATFORM
#include "esp_opus_enc.h"
#include "esp_opus_dec.h"
#include "esp_heap_caps.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "freertos/idf_additions.h"
/* Own complete transport packets: SDK callback memory is borrowed. Storage and
* the decoder task stack use PSRAM; only small RTOS control objects use DRAM. */
#define RTC_RX_PACKET_MAX 4096
#define RTC_RX_PACKET_COUNT 48
#define RTC_RX_ROOM_MAX 256
typedef struct {
uint32_t generation;
uint32_t arrival_ms;
size_t size;
uint8_t data[RTC_RX_PACKET_MAX];
} rtc_rx_packet_t;
#endif
#include "volc_osal.h"
#include "util/volc_list.h"
@@ -18,12 +42,17 @@
#define MAGIC_OFFSET 8
const char* interrupt_str = "{\"Command\":\"interrupt\"}";
// Optional platform control hook. Audio remains on the RTC transport. Invoke
// before joining/publishing so the cloud recorder can capture the opening turn.
extern int volc_platform_recording_prepare(const char *app, const char *room, const char *user) __attribute__((weak));
typedef struct {
bool b_pipeline_started;
bool b_user_joined;
bool b_channel_joined;
bool b_first_keyframe_received;
bool b_fini;
atomic_bool b_fini;
atomic_bool closing;
bool b_audio_publish;
bool b_video_publish;
bool b_audio_subscribe;
@@ -40,8 +69,46 @@ typedef struct {
volc_data_cb data_callback;
byte_rtc_engine_t rtc;
byte_rtc_event_handler_t event_handler;
uint8_t pcm_frame[640]; // 20 ms, 16 kHz, mono PCM16
size_t pcm_used;
uint64_t rx_report_ms, rx_previous_ms;
unsigned rx_frames, rx_pcm_bytes, rx_gap_ms, rx_decode_ms, rx_errors;
#ifdef ESP_PLATFORM
void *opus_encoder;
void *opus_decoder;
uint8_t *opus_packet;
int opus_packet_size;
uint8_t decoded_pcm[3840]; // Maximum 120 ms at 16 kHz, mono PCM16.
rtc_rx_packet_t *rx_packets;
QueueHandle_t rx_free, rx_pending;
SemaphoreHandle_t rx_work_lock, rx_worker_stopped;
TaskHandle_t rx_worker;
atomic_bool rx_accepting, rx_shutdown;
atomic_uint rx_generation, rx_producers;
atomic_uint rx_callbacks, rx_callback_us, rx_callback_max_us;
atomic_uint rx_free_max_us, rx_copy_max_us, rx_send_max_us, rx_post_max_us;
atomic_uint rx_overflow, rx_oversized, rx_stale, rx_peak;
char rx_room[RTC_RX_ROOM_MAX];
uint32_t rx_decoder_generation;
unsigned rx_applied_generation;
unsigned rx_decode_total_us;
#endif
} rtc_impl_t;
#ifdef ESP_PLATFORM
static int __rtc_rx_init(rtc_impl_t *rtc);
static int __rtc_rx_start(rtc_impl_t *rtc, const char *room);
static void __rtc_rx_stop(rtc_impl_t *rtc);
static void __rtc_rx_destroy(rtc_impl_t *rtc);
#endif
static void __clear_room_info(rtc_impl_t *rtc) {
HAL_SAFE_FREE(rtc->info.rtc_opt.p_channel_name);
HAL_SAFE_FREE(rtc->info.rtc_opt.p_uid);
HAL_SAFE_FREE(rtc->info.rtc_opt.p_token);
HAL_SAFE_FREE(rtc->info.task_id);
}
static bool __is_first_keyframe_not_received(rtc_impl_t* rtc, int is_key_frame)
{
return (!rtc->b_first_keyframe_received && !is_key_frame);
@@ -80,7 +147,7 @@ static int __rtc_start(rtc_impl_t* rtc, volc_rtc_option_t* option)
{
volc_opt_t* p_opt = NULL;
byte_rtc_room_options_t room_opt = {0};
if (!rtc || !option) {
if (!rtc || !option || !option->p_channel_name || !option->p_uid || rtc->b_pipeline_started) {
LOGE("rtc or option is NULL");
return -1;
}
@@ -89,43 +156,74 @@ static int __rtc_start(rtc_impl_t* rtc, volc_rtc_option_t* option)
room_opt.auto_publish_video = rtc->b_video_publish;
room_opt.auto_subscribe_audio = rtc->b_audio_subscribe;
room_opt.auto_subscribe_video = rtc->b_video_subscribe;
LOGI("Joining channel: %s, uid: %s, token: %s, vpub: %d, vsub: %d, apub: %d, asub: %d", option->p_channel_name, option->p_uid, option->p_token, (int)room_opt.auto_publish_video, (int)room_opt.auto_subscribe_video, (int)room_opt.auto_publish_audio, (int)room_opt.auto_subscribe_audio);
#ifndef ESP_PLATFORM
rtc->rx_report_ms=rtc->rx_previous_ms=0;
rtc->rx_frames=rtc->rx_pcm_bytes=rtc->rx_gap_ms=rtc->rx_decode_ms=rtc->rx_errors=0;
#endif
rtc->p_channel_name = strdup(option->p_channel_name);
rtc->p_user_id = strdup(option->p_uid);
if (!rtc->p_channel_name || !rtc->p_user_id) {
HAL_SAFE_FREE(rtc->p_channel_name);
HAL_SAFE_FREE(rtc->p_user_id);
return -1;
}
#ifdef ESP_PLATFORM
if (rtc->opus_encoder && __rtc_rx_start(rtc, option->p_channel_name) != 0) {
HAL_SAFE_FREE(rtc->p_channel_name);
HAL_SAFE_FREE(rtc->p_user_id);
return -1;
}
#endif
rtc->b_pipeline_started = true;
LOGI("Joining RTC channel: vpub=%d vsub=%d apub=%d asub=%d", (int)room_opt.auto_publish_video, (int)room_opt.auto_subscribe_video, (int)room_opt.auto_publish_audio, (int)room_opt.auto_subscribe_audio);
int ret = byte_rtc_join_room(rtc->rtc, option->p_channel_name, option->p_uid, option->p_token, &room_opt);
if (ret != 0) {
LOGE("Failed to join room: %d", ret);
#ifdef ESP_PLATFORM
__rtc_rx_stop(rtc);
#endif
rtc->b_pipeline_started = rtc->b_channel_joined = rtc->b_user_joined = false;
HAL_SAFE_FREE(rtc->p_channel_name);
HAL_SAFE_FREE(rtc->p_user_id);
return ret;
}
rtc->b_pipeline_started = true;
rtc->p_channel_name = strdup(option->p_channel_name);
rtc->p_user_id = strdup(option->p_uid);
return 0;
}
static void __rtc_stop(rtc_impl_t* rtc)
static int __rtc_stop(rtc_impl_t* rtc)
{
if (!rtc) {
LOGE("rtc instance is NULL");
return;
return -1;
}
#ifdef ESP_PLATFORM
/* Close delivery even when leave_room subsequently fails. */
__rtc_rx_stop(rtc);
#endif
if (!rtc->b_pipeline_started) {
LOGI("pipeline not started");
return;
return 0;
}
int ret = byte_rtc_leave_room(rtc->rtc, rtc->p_channel_name);
if (ret != 0) {
LOGE("Failed to leave room: %d", ret);
return;
return ret;
}
rtc->b_pipeline_started = false;
rtc->pcm_used = 0;
rtc->b_channel_joined = false;
rtc->b_user_joined = false;
HAL_SAFE_FREE(rtc->p_channel_name);
HAL_SAFE_FREE(rtc->p_user_id);
HAL_SAFE_FREE(rtc->p_remote_user_id);
return;
return 0;
}
static void __send_data_2_user(rtc_impl_t* rtc, const void* data, int data_len, volc_data_info_t* info) {
if (rtc->data_callback) {
if (!atomic_load(&rtc->closing) && rtc->data_callback) {
rtc->data_callback(rtc->context, data, data_len, info);
}
}
@@ -137,7 +235,7 @@ static void _register_message_router(rtc_impl_t* rtc, volc_msg_cb callback)
static void _send_message_2_user(rtc_impl_t* rtc, volc_msg_t* msg)
{
if (rtc->message_callback) {
if (!atomic_load(&rtc->closing) && rtc->message_callback) {
rtc->message_callback(rtc->context, msg);
}
}
@@ -164,7 +262,7 @@ static int _on_conversion_status_message_parsed(uint8_t* message, rtc_impl_t* rt
volc_json_read_int(root, "Stage.Code", &c);
volc_json_read_string(root, "ErrorInfo.Reason", &error_reason);
if(error_reason != NULL) {
LOGW("ErrorInfo.Reason %s",error_reason);
LOGW("RTC error detail withheld");
if(strcmp(error_reason, "preParamCheck:AI license duration quota exceeded") == 0) {
msg.code = VOLC_MSG_QUOTA_EXCEEDED;
msg.data.conv_status = 0;
@@ -177,8 +275,52 @@ static int _on_conversion_status_message_parsed(uint8_t* message, rtc_impl_t* rt
return c;
}
/* RTC_SELF_PRIORITY_BEGIN: the SDK must not yield to its priority-5 decoder
* while publishing a packet. Apply a base-priority floor in the SDK's own
* callbacks, without borrowing task handles or lowering a higher base. */
#ifdef ESP_PLATFORM
#define RTC_TASK_PRIORITY_FLOOR 6
static atomic_bool diagnostic_rtc_seen;
static atomic_uint diagnostic_rtc_effective_priority, diagnostic_rtc_base, diagnostic_rtc_observed_ms;
static atomic_int diagnostic_rtc_core;
void volc_rtc_diagnostic_snapshot(void)
{
bool seen = atomic_load(&diagnostic_rtc_seen);
unsigned base = atomic_load(&diagnostic_rtc_base);
LOGI("RTC_TASK_SNAPSHOT seen=%d priority=%u base=%u core=%d floor=%u observed_age_ms=%u floor_met=%d",
seen, atomic_load(&diagnostic_rtc_effective_priority), base, atomic_load(&diagnostic_rtc_core), RTC_TASK_PRIORITY_FLOOR,
seen ? (unsigned)((uint32_t)(esp_timer_get_time()/1000) - atomic_load(&diagnostic_rtc_observed_ms)) : 0,
seen && base >= RTC_TASK_PRIORITY_FLOOR);
}
static void __rtc_diagnostic_priority_apply(void)
{
if (strcmp(pcTaskGetName(NULL), "VolcRTCMain")) return;
TaskStatus_t status;
vTaskGetInfo(NULL, &status, pdFALSE, eInvalid);
UBaseType_t before = uxTaskPriorityGet(NULL);
UBaseType_t desired = status.uxBasePriority < RTC_TASK_PRIORITY_FLOOR ? RTC_TASK_PRIORITY_FLOOR : status.uxBasePriority;
if (status.uxBasePriority != desired) vTaskPrioritySet(NULL, desired);
if (!atomic_load(&diagnostic_rtc_seen) || status.uxBasePriority != desired) {
LOGI("RTC_PRIORITY_FLOOR before=%u base_before=%u after=%u base_after=%u floor=%u core=%d",
(unsigned)before, (unsigned)status.uxBasePriority, (unsigned)uxTaskPriorityGet(NULL), (unsigned)desired, RTC_TASK_PRIORITY_FLOOR,
(int)xTaskGetCoreID(NULL));
}
atomic_store(&diagnostic_rtc_effective_priority, uxTaskPriorityGet(NULL));
atomic_store(&diagnostic_rtc_base, desired);
atomic_store(&diagnostic_rtc_core, (int)xTaskGetCoreID(NULL));
atomic_store(&diagnostic_rtc_observed_ms, (uint32_t)(esp_timer_get_time()/1000));
atomic_store(&diagnostic_rtc_seen, true);
}
#else
static void __rtc_diagnostic_priority_apply(void) {}
#endif
/* RTC_SELF_PRIORITY_END */
static void _on_join_channel_success(byte_rtc_engine_t engine, const char* channel, int elapsed_ms, bool rejoin)
{
__rtc_diagnostic_priority_apply();
volc_msg_t msg = {0};
LOGI("join channel success %s elapsed %d ms\n", channel, elapsed_ms);
rtc_impl_t* rtc = (rtc_impl_t*) byte_rtc_get_user_data(engine);
@@ -196,8 +338,9 @@ static void _on_user_joined(byte_rtc_engine_t engine, const char* channel, const
volc_msg_t msg = {0};
LOGI("remote user joined %s:%s elapsed %d ms\n", channel, user_name, elapsed_ms);
rtc->b_user_joined = true;
HAL_SAFE_FREE(rtc->p_remote_user_id);
rtc->p_remote_user_id = strdup(user_name);
rtc->b_user_joined = rtc->p_remote_user_id != NULL;
msg.code = VOLC_MSG_USER_JOINED;
_send_message_2_user(rtc, &msg);
@@ -205,6 +348,7 @@ static void _on_user_joined(byte_rtc_engine_t engine, const char* channel, const
static void _on_user_offline(byte_rtc_engine_t engine, const char* channel, const char* user_name, int reason)
{
__rtc_diagnostic_priority_apply();
rtc_impl_t* rtc = (rtc_impl_t*) byte_rtc_get_user_data(engine);
volc_msg_t msg = {0};
LOGI("remote user offline %s:%s reason %d\n", channel, user_name, reason);
@@ -224,17 +368,296 @@ static void _on_user_mute_video(byte_rtc_engine_t engine, const char* channel, c
LOGD("remote user mute video %s:%s %d\n", channel, user_name, muted);
};
#ifdef ESP_PLATFORM
/* RTC_RX_HOST_BEGIN: host tests extract these production functions verbatim. */
static void __rtc_rx_max(atomic_uint *value, unsigned candidate)
{
unsigned old = atomic_load(value);
while (old < candidate && !atomic_compare_exchange_weak(value, &old, candidate)) {}
}
static bool __rtc_rx_current(rtc_impl_t *rtc, unsigned generation)
{
return atomic_load(&rtc->rx_accepting) && generation == atomic_load(&rtc->rx_generation);
}
/* No decoder, allocator, logging, or waiting on the SDK's synchronous thread. */
static void __rtc_rx_enqueue(rtc_impl_t *rtc, const char *room, const void *data, size_t size)
{
uint64_t started = esp_timer_get_time();
atomic_fetch_add(&rtc->rx_producers, 1);
unsigned generation = atomic_load(&rtc->rx_generation);
if (!atomic_load(&rtc->rx_accepting) || !room || strcmp(room, rtc->rx_room)) {
atomic_fetch_add(&rtc->rx_stale, 1);
goto done;
}
if (!data || !size || size > RTC_RX_PACKET_MAX) {
atomic_fetch_add(&rtc->rx_oversized, 1);
goto done;
}
unsigned slot;
uint64_t stage = esp_timer_get_time();
BaseType_t acquired = xQueueReceive(rtc->rx_free, &slot, 0);
__rtc_rx_max(&rtc->rx_free_max_us, (unsigned)(esp_timer_get_time() - stage));
if (acquired != pdTRUE) {
atomic_fetch_add(&rtc->rx_overflow, 1);
goto done;
}
stage = esp_timer_get_time();
rtc_rx_packet_t *packet = &rtc->rx_packets[slot];
packet->generation = generation;
packet->arrival_ms = (uint32_t)(started / 1000);
packet->size = size;
memcpy(packet->data, data, size);
__rtc_rx_max(&rtc->rx_copy_max_us, (unsigned)(esp_timer_get_time() - stage));
if (!__rtc_rx_current(rtc, generation)) {
atomic_fetch_add(&rtc->rx_stale, 1);
xQueueSend(rtc->rx_free, &slot, 0);
} else {
stage = esp_timer_get_time();
BaseType_t sent = xQueueSend(rtc->rx_pending, &slot, 0);
__rtc_rx_max(&rtc->rx_send_max_us, (unsigned)(esp_timer_get_time() - stage));
stage = esp_timer_get_time();
if (sent != pdTRUE) {
atomic_fetch_add(&rtc->rx_overflow, 1);
xQueueSend(rtc->rx_free, &slot, 0);
} else {
__rtc_rx_max(&rtc->rx_peak, (unsigned)uxQueueMessagesWaiting(rtc->rx_pending));
}
__rtc_rx_max(&rtc->rx_post_max_us, (unsigned)(esp_timer_get_time() - stage));
}
done: ;
unsigned elapsed = (unsigned)(esp_timer_get_time() - started);
atomic_fetch_add(&rtc->rx_callbacks, 1);
atomic_fetch_add(&rtc->rx_callback_us, elapsed);
__rtc_rx_max(&rtc->rx_callback_max_us, elapsed);
atomic_fetch_sub(&rtc->rx_producers, 1);
}
/* The worker serializes the service's PCM discard with its own delivery. A
* generation check alone has a check/emit race if the worker is preempted. */
static void __rtc_rx_interrupt(rtc_impl_t *rtc)
{
atomic_fetch_add(&rtc->rx_generation, 1);
}
/* Only the worker calls this while holding rx_work_lock. */
static void __rtc_rx_apply_interrupt(rtc_impl_t *rtc)
{
unsigned generation = atomic_load(&rtc->rx_generation);
if (rtc->rx_applied_generation == generation) return;
rtc->rx_applied_generation = generation;
if (atomic_load(&rtc->rx_accepting)) {
volc_msg_t msg = {.code=VOLC_MSG_CONV_STATUS};
msg.data.conv_status = VOLC_CONV_STATUS_INTERRUPTED;
_send_message_2_user(rtc, &msg);
}
}
/* A transport callback may contain more than one encoded frame. The decoder
* reports consumed input separately from decoded PCM; do not discard its tail. */
static int __rtc_decode_opus(rtc_impl_t *rtc, const void *data, size_t length, volc_data_info_t *info, unsigned generation)
{
esp_audio_dec_in_raw_t raw = {.buffer=(uint8_t *)data, .len=length};
int total=0;
while(raw.len && __rtc_rx_current(rtc, generation)) {
esp_audio_dec_out_frame_t frame={.buffer=rtc->decoded_pcm,.len=sizeof(rtc->decoded_pcm)};
esp_audio_dec_info_t decoded_info={0};
raw.consumed=0;
int ret=esp_opus_dec_decode(rtc->opus_decoder,&raw,&frame,&decoded_info);
if(ret!=ESP_AUDIO_ERR_OK || !raw.consumed || raw.consumed>raw.len || frame.decoded_size>sizeof(rtc->decoded_pcm)) {
rtc->rx_errors++;
LOGE("RTC Opus decode failed code=%d remaining=%u consumed=%u",ret,(unsigned)raw.len,(unsigned)raw.consumed);
return total;
}
if(frame.decoded_size && __rtc_rx_current(rtc, generation)) {
info->info.audio.data_type=VOLC_AUDIO_DATA_TYPE_PCM;
__send_data_2_user(rtc,frame.buffer,frame.decoded_size,info);
total+=frame.decoded_size;
}
raw.buffer+=raw.consumed;
raw.len-=raw.consumed;
}
return total;
}
static int __rtc_rx_decoder_reset(rtc_impl_t *rtc, unsigned generation)
{
if (rtc->opus_decoder) esp_opus_dec_close(rtc->opus_decoder);
rtc->opus_decoder = NULL;
esp_opus_dec_cfg_t config = ESP_OPUS_DEC_CONFIG_DEFAULT();
config.sample_rate = 16000;
config.channel = 1;
config.frame_duration = ESP_OPUS_DEC_FRAME_DURATION_120_MS;
if (esp_opus_dec_open(&config, sizeof(config), &rtc->opus_decoder) != ESP_AUDIO_ERR_OK) {
rtc->rx_errors++;
return -1;
}
rtc->rx_decoder_generation = generation;
return 0;
}
static void __rtc_rx_report(rtc_impl_t *rtc)
{
uint64_t now = volc_osal_get_time_ms();
if (!rtc->rx_report_ms) rtc->rx_report_ms = now;
if (now - rtc->rx_report_ms < 1000) return;
LOGI("DOWNLINK span_ms=%u frames=%u pcm_ms=%u max_gap_ms=%u max_decode_ms=%u errors=%u",
(unsigned)(now-rtc->rx_report_ms), rtc->rx_frames, rtc->rx_pcm_bytes/32,
rtc->rx_gap_ms, rtc->rx_decode_ms, rtc->rx_errors);
LOGI("RTC_RX callbacks=%u callback_total_us=%u callback_max_us=%u decode_total_us=%u queued=%u peak=%u overflow=%u oversized=%u stale=%u",
atomic_exchange(&rtc->rx_callbacks,0), atomic_exchange(&rtc->rx_callback_us,0),
atomic_exchange(&rtc->rx_callback_max_us,0), rtc->rx_decode_total_us,
(unsigned)uxQueueMessagesWaiting(rtc->rx_pending), atomic_exchange(&rtc->rx_peak,0),
atomic_exchange(&rtc->rx_overflow,0), atomic_exchange(&rtc->rx_oversized,0), atomic_exchange(&rtc->rx_stale,0));
/* Wall time includes preemption after a queue wakes another task. These are
* per-stage maxima, not CPU costs or additive parts of the same callback. */
LOGI("RTC_RX_STAGE free_max_us=%u copy_max_us=%u send_max_us=%u post_max_us=%u worker_priority=%u worker_core=%d",
atomic_exchange(&rtc->rx_free_max_us,0), atomic_exchange(&rtc->rx_copy_max_us,0),
atomic_exchange(&rtc->rx_send_max_us,0), atomic_exchange(&rtc->rx_post_max_us,0),
(unsigned)uxTaskPriorityGet(NULL), (int)xTaskGetCoreID(NULL));
rtc->rx_report_ms = now;
rtc->rx_frames=rtc->rx_pcm_bytes=rtc->rx_gap_ms=rtc->rx_decode_ms=rtc->rx_errors=rtc->rx_decode_total_us=0;
}
static void __rtc_rx_process(rtc_impl_t *rtc, unsigned slot)
{
rtc_rx_packet_t *packet = &rtc->rx_packets[slot];
xSemaphoreTake(rtc->rx_work_lock, portMAX_DELAY);
__rtc_rx_apply_interrupt(rtc);
if (__rtc_rx_current(rtc, packet->generation)) {
unsigned gap = rtc->rx_previous_ms ? (uint32_t)(packet->arrival_ms - (uint32_t)rtc->rx_previous_ms) : 0;
rtc->rx_previous_ms = packet->arrival_ms;
if (gap > rtc->rx_gap_ms) rtc->rx_gap_ms = gap;
rtc->rx_frames++;
uint64_t started = esp_timer_get_time();
if ((rtc->opus_decoder && rtc->rx_decoder_generation == packet->generation) ||
__rtc_rx_decoder_reset(rtc, packet->generation) == 0) {
volc_data_info_t info = {.type=VOLC_DATA_TYPE_AUDIO};
rtc->rx_pcm_bytes += __rtc_decode_opus(rtc, packet->data, packet->size, &info, packet->generation);
}
unsigned elapsed = (unsigned)(esp_timer_get_time()-started);
rtc->rx_decode_total_us += elapsed;
if (elapsed/1000 > rtc->rx_decode_ms) rtc->rx_decode_ms = elapsed/1000;
} else {
atomic_fetch_add(&rtc->rx_stale, 1);
}
__rtc_rx_report(rtc);
xSemaphoreGive(rtc->rx_work_lock);
xQueueSend(rtc->rx_free, &slot, 0);
}
static void __rtc_rx_task(void *arg)
{
rtc_impl_t *rtc = arg;
while (!atomic_load(&rtc->rx_shutdown)) {
xSemaphoreTake(rtc->rx_work_lock, portMAX_DELAY);
__rtc_rx_apply_interrupt(rtc);
xSemaphoreGive(rtc->rx_work_lock);
unsigned slot;
if (xQueueReceive(rtc->rx_pending, &slot, pdMS_TO_TICKS(20)) == pdTRUE) {
__rtc_rx_process(rtc, slot);
}
}
xSemaphoreGive(rtc->rx_worker_stopped);
/* Control deletes this task with its original WithCaps handle. Self-delete
* would allocate an IDF cleanup task, unsafe when internal RAM is scarce. */
for (;;) vTaskSuspend(NULL);
}
static int __rtc_rx_init(rtc_impl_t *rtc)
{
rtc->rx_packets = heap_caps_calloc(RTC_RX_PACKET_COUNT, sizeof(*rtc->rx_packets), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
rtc->rx_free = xQueueCreate(RTC_RX_PACKET_COUNT, sizeof(unsigned));
rtc->rx_pending = xQueueCreate(RTC_RX_PACKET_COUNT, sizeof(unsigned));
rtc->rx_work_lock = xSemaphoreCreateMutex();
rtc->rx_worker_stopped = xSemaphoreCreateBinary();
if (!rtc->rx_packets || !rtc->rx_free || !rtc->rx_pending || !rtc->rx_work_lock || !rtc->rx_worker_stopped) return -1;
for (unsigned i=0; i<RTC_RX_PACKET_COUNT; i++) xQueueSend(rtc->rx_free, &i, 0);
if (xTaskCreatePinnedToCoreWithCaps(__rtc_rx_task, "rtc_opus_rx", 16*1024, rtc, 5, &rtc->rx_worker,
tskNO_AFFINITY, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT) != pdPASS) {
rtc->rx_worker = NULL;
return -1;
}
return 0;
}
static void __rtc_rx_stop(rtc_impl_t *rtc)
{
atomic_store(&rtc->rx_accepting, false);
__rtc_rx_interrupt(rtc);
while (atomic_load(&rtc->rx_producers)) vTaskDelay(1);
if (!rtc->rx_work_lock) return;
/* Wait for a decoder/user callback already in progress. Future work checks
* accepting/generation under this lock, so nothing is emitted after return. */
xSemaphoreTake(rtc->rx_work_lock, portMAX_DELAY);
unsigned slot;
if (rtc->rx_pending && rtc->rx_free) {
while (xQueueReceive(rtc->rx_pending, &slot, 0) == pdTRUE) xQueueSend(rtc->rx_free, &slot, 0);
}
rtc->rx_applied_generation = atomic_load(&rtc->rx_generation);
xSemaphoreGive(rtc->rx_work_lock);
}
static int __rtc_rx_start(rtc_impl_t *rtc, const char *room)
{
if (!room || strlen(room) >= sizeof(rtc->rx_room) || !rtc->rx_worker) return -1;
__rtc_rx_stop(rtc);
xSemaphoreTake(rtc->rx_work_lock, portMAX_DELAY);
memcpy(rtc->rx_room, room, strlen(room)+1);
rtc->rx_report_ms=rtc->rx_previous_ms=0;
rtc->rx_frames=rtc->rx_pcm_bytes=rtc->rx_gap_ms=rtc->rx_decode_ms=rtc->rx_errors=rtc->rx_decode_total_us=0;
rtc->rx_applied_generation = atomic_load(&rtc->rx_generation);
atomic_store(&rtc->rx_accepting, true);
xSemaphoreGive(rtc->rx_work_lock);
return 0;
}
static void __rtc_rx_destroy(rtc_impl_t *rtc)
{
__rtc_rx_stop(rtc);
atomic_store(&rtc->rx_shutdown, true);
if (rtc->rx_worker) {
xSemaphoreTake(rtc->rx_worker_stopped, portMAX_DELAY);
vTaskDeleteWithCaps(rtc->rx_worker);
rtc->rx_worker = NULL;
}
if (rtc->rx_free) vQueueDelete(rtc->rx_free);
if (rtc->rx_pending) vQueueDelete(rtc->rx_pending);
if (rtc->rx_work_lock) vSemaphoreDelete(rtc->rx_work_lock);
if (rtc->rx_worker_stopped) vSemaphoreDelete(rtc->rx_worker_stopped);
heap_caps_free(rtc->rx_packets);
rtc->rx_packets = NULL;
rtc->rx_free = rtc->rx_pending = NULL;
rtc->rx_work_lock = rtc->rx_worker_stopped = NULL;
}
/* RTC_RX_HOST_END */
#endif
static void _on_audio_data(byte_rtc_engine_t engine, const char* channel, const char* user_name, uint16_t sent_ts, audio_data_type_e data_type,
const void* data_ptr, size_t data_len, const uint8_t* extra_info, size_t extra_info_size)
{
__rtc_diagnostic_priority_apply();
volc_data_info_t info = {0};
rtc_impl_t* rtc = (rtc_impl_t*)byte_rtc_get_user_data(engine);
#ifdef ESP_PLATFORM
if (rtc && data_type == AUDIO_DATA_TYPE_OPUS) {
__rtc_rx_enqueue(rtc, channel, data_ptr, data_len);
return;
}
#endif
if (NULL == rtc || !rtc->b_pipeline_started || !rtc->b_channel_joined || !rtc->b_user_joined) {
LOGE("pipeline not started or channel not joined or user not joined");
return;
}
info.type = VOLC_DATA_TYPE_AUDIO;
uint64_t now=volc_osal_get_time_ms();
if(!rtc->rx_report_ms)rtc->rx_report_ms=now;
unsigned gap=rtc->rx_previous_ms?(unsigned)(now-rtc->rx_previous_ms):0;
if(gap>rtc->rx_gap_ms)rtc->rx_gap_ms=gap;
rtc->rx_previous_ms=now;rtc->rx_frames++;
info.info.audio.data_type = (volc_audio_data_type_e) data_type;
// info.info.audio.sent_ts = sent_ts;
__send_data_2_user(rtc, data_ptr, data_len, &info);
@@ -268,7 +691,7 @@ static void _on_channel_error(byte_rtc_engine_t engine, const char* channel, int
{
volc_msg_t msg_data = {0};
rtc_impl_t* rtc = (rtc_impl_t*) byte_rtc_get_user_data(engine);
LOGE("channel error %s:%d %s\n", channel, code, msg ? msg : "");
LOGE("RTC channel error code=%d", code);
msg_data.code = code;
msg_data.data.msg = (char *)msg;
_send_message_2_user(rtc, &msg_data);
@@ -281,7 +704,7 @@ static void _on_global_error(byte_rtc_engine_t engine, int code, const char* mes
rtc->b_channel_joined = false;
rtc->b_first_keyframe_received = false;
LOGI("global error %d %s\n", code, message ? message : "");
LOGI("RTC global error code=%d", code);
msg_data.code = VOLC_MSG_DISCONNECTED;
_send_message_2_user(rtc, &msg_data);
};
@@ -309,7 +732,8 @@ static void _on_target_bitrate_changed(byte_rtc_engine_t engine, const char* cha
static void _on_token_privilege_will_expire(byte_rtc_engine_t engine, const char* token)
{
LOGI("\ntoken privilege will expire %s", token ? token : "");
(void)token;
LOGI("RTC token privilege will expire");
rtc_impl_t* rtc = (rtc_impl_t*) byte_rtc_get_user_data(engine);
volc_msg_t msg_data = {0};
msg_data.code = VOLC_MSG_TOKEN_EXPIRED;
@@ -319,6 +743,7 @@ static void _on_token_privilege_will_expire(byte_rtc_engine_t engine, const char
static void _on_message_received(byte_rtc_engine_t engine, const char* channel_name, const char* src, const uint8_t* message, int size, bool binary)
{
__rtc_diagnostic_priority_apply();
int ret = 0;
volc_msg_t msg = { 0 };
@@ -331,22 +756,47 @@ static void _on_message_received(byte_rtc_engine_t engine, const char* channel_n
LOGE("pipeline not started or channel not joined or user not joined");
return;
}
#ifdef ESP_PLATFORM
/* rx_room is only changed after in-flight SDK readers have left. */
bool rx_guard = false;
if (rtc->opus_encoder) {
atomic_fetch_add(&rtc->rx_producers, 1);
bool current_room = atomic_load(&rtc->rx_accepting) && channel_name && !strcmp(channel_name, rtc->rx_room);
if (!current_room) {
atomic_fetch_sub(&rtc->rx_producers, 1);
return;
}
rx_guard = true;
}
#endif
if (_is_target_message(message, MAGIC_CONV)) {
ret = _on_conversion_status_message_parsed((uint8_t *)message + MAGIC_OFFSET,rtc);
if(ret != -1){
#ifdef ESP_PLATFORM
if (ret == VOLC_CONV_STATUS_INTERRUPTED && rtc->opus_encoder) {
__rtc_rx_interrupt(rtc);
goto message_done;
}
#endif
msg.code = VOLC_MSG_CONV_STATUS;
msg.data.conv_status = ret;
_send_message_2_user(rtc, &msg);
}
return;
goto message_done;
}
__send_data_2_user(rtc, message, size, &info);
message_done:
#ifdef ESP_PLATFORM
if (rx_guard) atomic_fetch_sub(&rtc->rx_producers, 1);
#endif
return;
};
static void _on_message_send_result(byte_rtc_engine_t engine, const char* channel_name, int64_t msgid, int error, const char* extencontent)
{
LOGD("----------------------->MessageSendResult msg id %" PRId64 ", error %d, extencontent %s \n", msgid, error, extencontent ? extencontent : "");
__rtc_diagnostic_priority_apply();
LOGD("MessageSendResult id=%" PRId64 " error=%d", msgid, error);
};
static void _on_license_will_expire(byte_rtc_engine_t engine, int daysleft)
@@ -361,7 +811,7 @@ static void _on_license_will_expire(byte_rtc_engine_t engine, int daysleft)
static void _on_fini_notify(byte_rtc_engine_t engine)
{
rtc_impl_t* rtc = (rtc_impl_t*) byte_rtc_get_user_data(engine);
rtc->b_fini = true;
atomic_store(&rtc->b_fini, true);
}
/*
@@ -444,6 +894,7 @@ static int __rtc_init(rtc_impl_t* engine, cJSON* p_config)
}
engine->rtc = byte_rtc_create(engine->p_appid, &rtc_event_handler);
if (!engine->rtc) return -1;
byte_rtc_set_user_data(engine->rtc, (void*) engine);
byte_rtc_set_log_level(engine->rtc, log_level);
ret = volc_json_read_object(p_config, "params", &p_params);
@@ -455,16 +906,20 @@ static int __rtc_init(rtc_impl_t* engine, cJSON* p_config)
if (p_param && cJSON_IsString(p_param)) {
const char* param_str = cJSON_GetStringValue(p_param);
if (param_str && strlen(param_str) > 0) {
byte_rtc_set_params(engine->rtc, param_str);
LOGI("volc_rtc_create: set param[%d]: %s", i, param_str);
int param_ret = byte_rtc_set_params(engine->rtc, param_str);
LOGI("volc_rtc_create: set param[%d] result=%d", i, param_ret);
}
}
}
}
cJSON_Delete(p_params);
}
byte_rtc_init(engine->rtc);
byte_rtc_set_audio_codec(engine->rtc, __volc_to_rtc_audio_codec(engine->audio_codec));
ret = byte_rtc_init(engine->rtc);
LOGI("RTC init result=%d", ret);
if (ret != 0) return ret;
ret = byte_rtc_set_audio_codec(engine->rtc, __volc_to_rtc_audio_codec(engine->audio_codec));
LOGI("RTC codec=%d result=%d", engine->audio_codec, ret);
if (ret != 0) return ret;
byte_rtc_set_video_codec(engine->rtc, video_codec - 1); // -1 for default codec
return 0;
}
@@ -490,11 +945,28 @@ volc_rtc_t volc_rtc_create(const char* appid, void* context, cJSON* p_config, vo
goto err_out_label;
}
#ifdef ESP_PLATFORM
if (rtc->audio_codec == VOLC_AUDIO_CODEC_TYPE_OPUS) {
esp_opus_enc_config_t enc = ESP_OPUS_ENC_CONFIG_DEFAULT();
enc.sample_rate=16000; enc.channel=1; enc.bitrate=24000;
enc.complexity=0; enc.enable_vbr=true;
int input_size=0;
if (esp_opus_enc_open(&enc,sizeof(enc),&rtc->opus_encoder)!=ESP_AUDIO_ERR_OK ||
esp_opus_enc_get_frame_size(rtc->opus_encoder,&input_size,&rtc->opus_packet_size)!=ESP_AUDIO_ERR_OK ||
input_size != sizeof(rtc->pcm_frame) || rtc->opus_packet_size <= 0 ||
!(rtc->opus_packet=volc_osal_malloc(rtc->opus_packet_size)) || __rtc_rx_init(rtc) != 0) {
LOGE("RTC Opus codec initialization failed");
volc_rtc_destroy(rtc);
return NULL;
}
LOGI("RTC Opus ready: 16000 Hz mono, 20 ms uplink");
}
#endif
LOGD("rtc create success");
return (volc_rtc_t)rtc;
err_out_label:
HAL_SAFE_FREE(rtc->p_appid);
HAL_SAFE_FREE(rtc);
volc_rtc_destroy(rtc);
return NULL;
}
@@ -505,13 +977,31 @@ void volc_rtc_destroy(volc_rtc_t handle)
LOGE("rtc instance is NULL");
return;
}
atomic_store(&rtc->closing, true);
__rtc_stop(rtc);
byte_rtc_fini(rtc->rtc);
while (!rtc->b_fini) {
usleep(1000 * 10);
if (rtc->rtc) {
int fini_result = byte_rtc_fini(rtc->rtc);
if (fini_result != 0) {
/* The SDK only documents destroy as safe after fini_notify. Keep
* callback context alive rather than free memory it might access. */
LOGE("RTC cleanup deferred: fini failed code=%d", fini_result);
return;
}
while (!atomic_load(&rtc->b_fini)) {
usleep(1000 * 10);
}
}
byte_rtc_destroy(rtc->rtc);
if (rtc->rtc) byte_rtc_destroy(rtc->rtc);
__clear_room_info(rtc);
HAL_SAFE_FREE(rtc->p_appid);
HAL_SAFE_FREE(rtc->p_user_id);
#ifdef ESP_PLATFORM
__rtc_rx_destroy(rtc);
if (rtc->opus_encoder) esp_opus_enc_close(rtc->opus_encoder);
if (rtc->opus_decoder) esp_opus_dec_close(rtc->opus_decoder);
HAL_SAFE_FREE(rtc->opus_packet);
#endif
HAL_SAFE_FREE(rtc->p_channel_name);
HAL_SAFE_FREE(rtc->p_remote_user_id);
HAL_SAFE_FREE(rtc->p_token);
@@ -527,12 +1017,23 @@ int volc_rtc_start(volc_rtc_t rtc, const char* bot_id, volc_iot_info_t* iot_info
LOGE("rtc instance is NULL");
return -1;
}
char* task_id = "test";
if (volc_get_rtc_config(iot_info, __volc_to_rtc_audio_codec(rtc_impl->audio_codec), bot_id, task_id, &rtc_impl->info, params)) {
cJSON *request=cJSON_Parse(params);
const char *task_id=cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(request,"PlatformTaskID"));
if(!task_id||strlen(task_id)!=36){cJSON_Delete(request);return -1;}
// Never reuse a previous room/token if the next provider request fails.
__clear_room_info(rtc_impl);
int config_result=volc_get_rtc_config(iot_info, __volc_to_rtc_audio_codec(rtc_impl->audio_codec), bot_id, task_id, &rtc_impl->info, params);
cJSON_Delete(request);
if (config_result) {
__clear_room_info(rtc_impl);
LOGE("get rtc config failed");
return -1;
}
opt = &rtc_impl->info.rtc_opt;
if (volc_platform_recording_prepare) {
int recording_result=volc_platform_recording_prepare(rtc_impl->p_appid,opt->p_channel_name,opt->p_uid);
if (recording_result) LOGW("Cloud conversation recording not ready (%d)",recording_result);
}
return __rtc_start(rtc_impl, opt);
}
@@ -542,7 +1043,38 @@ int volc_rtc_stop(volc_rtc_t handle) {
LOGE("rtc instance is NULL");
return -1;
}
__rtc_stop(rtc);
return __rtc_stop(rtc);
}
const char *volc_rtc_user_id(volc_rtc_t handle,bool remote) {
rtc_impl_t *rtc=handle;if(!rtc)return NULL;return remote?rtc->p_remote_user_id:rtc->p_user_id;
}
static int __rtc_send_pcm(rtc_impl_t *rtc, const uint8_t *data, size_t size) {
audio_frame_info_t info = {.data_type = AUDIO_DATA_TYPE_PCM};
while (size) {
size_t take = sizeof(rtc->pcm_frame) - rtc->pcm_used;
if (take > size) take = size;
memcpy(rtc->pcm_frame + rtc->pcm_used, data, take);
rtc->pcm_used += take; data += take; size -= take;
if (rtc->pcm_used == sizeof(rtc->pcm_frame)) {
int ret;
#ifdef ESP_PLATFORM
if (rtc->opus_encoder) {
esp_audio_enc_in_frame_t in = {.buffer=rtc->pcm_frame,.len=sizeof(rtc->pcm_frame)};
esp_audio_enc_out_frame_t out = {.buffer=rtc->opus_packet,.len=rtc->opus_packet_size};
ret=esp_opus_enc_process(rtc->opus_encoder,&in,&out);
if(ret==ESP_AUDIO_ERR_OK){
info.data_type=AUDIO_DATA_TYPE_OPUS;
ret=byte_rtc_send_audio_data(rtc->rtc,rtc->p_channel_name,out.buffer,out.encoded_bytes,&info);
}
} else
#endif
ret = byte_rtc_send_audio_data(rtc->rtc,rtc->p_channel_name,rtc->pcm_frame,sizeof(rtc->pcm_frame),&info);
rtc->pcm_used = 0;
if (ret != 0) return ret;
}
}
return 0;
}
@@ -550,14 +1082,19 @@ int volc_rtc_send(volc_rtc_t handle, const void* data, int size, volc_data_info_
rtc_impl_t* rtc = (rtc_impl_t *)handle;
audio_frame_info_t audio_info = {0};
video_frame_info_t video_info = {0};
if (NULL == rtc || NULL == data || NULL == data_info) {
if (NULL == rtc || NULL == data || NULL == data_info || size <= 0) {
LOGE("input args is invalid, rtc(%p), data(%p), data_info(%p)", rtc, data, data_info);
return -1;
}
switch (data_info->type) {
case VOLC_DATA_TYPE_AUDIO: {
// Join is asynchronous. Do not enqueue media into the vendor
// transport until both room and agent are actually present.
if (!rtc->b_pipeline_started || !rtc->b_channel_joined || !rtc->b_user_joined) return 0;
if (data_info->info.audio.data_type == VOLC_AUDIO_DATA_TYPE_PCM && size > 0)
return __rtc_send_pcm(rtc,data,(size_t)size);
audio_info.data_type = (audio_data_type_e) data_info->info.audio.data_type;
byte_rtc_send_audio_data(rtc->rtc, rtc->p_channel_name, data, size, &audio_info);
return byte_rtc_send_audio_data(rtc->rtc, rtc->p_channel_name, data, size, &audio_info);
break;
}
case VOLC_DATA_TYPE_VIDEO: {
@@ -125,16 +125,16 @@ static void __ws_recv_data(ws_impl_t* ws, const char* data, int data_len)
p_json = cJSON_ParseWithLength(data, data_len);
if (!p_json) {
LOGE("Failed to parse json, data_len: %d, data: %s", data_len, data);
LOGE("Failed to parse json, data_len: %d", data_len);
return;
}
// LOGI("json: %s", data);
// Protocol payloads contain private text and must not be logged.
volc_json_read_string(p_json, "type", &p_type);
volc_json_read_string(p_json, "delta", &p_delta);
volc_json_read_string(p_json, "response.status", &p_status);
if (strcmp(p_type, "response.audio.delta") == 0 && p_delta) {
if (strlen(p_delta) == 0) {
LOGE("delta is empty, data: %s", data);
LOGE("delta is empty");
goto err_out_label;
}
if (NULL == ws || !ws->b_pipeline_started) {
@@ -182,11 +182,11 @@ static void __ws_recv_data(ws_impl_t* ws, const char* data, int data_len)
ws->conv_status = VOLC_CONV_STATUS_INTERRUPTED;
msg.data.conv_status = VOLC_CONV_STATUS_INTERRUPTED;
}
LOGI("data: %s", data);
LOGI("conversation status updated");
__send_message_2_user(ws, &msg);
} else {
if (strcmp(p_type, "session.created") == 0) {
LOGI("%s", data);
LOGI("provider session created");
} else if (strcmp(p_type, "response.audio_transcript.delta") == 0 || strcmp(p_type, "response.audio_transcript.done") == 0 || strcmp(p_type, "response.audio.done") == 0) {
volc_json_read_string(p_json, "response_id", &p_response_id);
if (__ws_drop_for_interrupted(ws, p_response_id)) {
@@ -471,7 +471,7 @@ static int __ws_input_audio_buffer_commit(ws_impl_t* ws) {
root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "type", "input_audio_buffer.commit");
char* json = cJSON_PrintUnformatted(root);
LOGD("json: %s", json);
LOGD("WebSocket message prepared");
cJSON_Delete(root);
ret = volc_ws_client_send_text(ws->client, json, strlen(json), 1000);
if (ret >= 0) {
@@ -500,7 +500,7 @@ static int __ws_response_create(ws_impl_t* ws) {
cJSON_AddItemToObject(response_obj, "modalities", modalities_array);
// 生成 JSON 字符串
const char* json = cJSON_PrintUnformatted(root);
LOGD("json: %s", json);
LOGD("WebSocket message prepared");
// 释放内存
cJSON_Delete(root);
ret = volc_ws_client_send_text(ws->client, json, strlen(json), 1000);
@@ -555,7 +555,7 @@ static void __ws_parse_params(const char* params, ws_params_t* out_params) {
}
p_json = cJSON_Parse(params);
if (!p_json) {
LOGW("Failed to parse json params: %s", params);
LOGW("Failed to parse json params");
return;
}
volc_json_read_int(p_json, "audio.codec", (int*)&out_params->audio_codec_type);
@@ -416,7 +416,7 @@ static int ws_read(volc_ws_client_t* client, char* buffer, int len, int timeout_
transport_ws_t* ws = client->ws_transport;
if (ws->frame_state.bytes_remaining <= 0) {
if ((rlen = ws_read_header(client, buffer, len, timeout_ms)) < 0) {
LOGE("Error reading header %d, %s\r\n", rlen, buffer);
LOGE("Error reading header, length=%d", rlen);
ws->frame_state.bytes_remaining = 0;
return rlen;
}
@@ -835,7 +835,7 @@ static int ws_connect(volc_ws_client_t* client, const char* host, int port, int
"Sec-WebSocket-Key: %s\r\n"
"Sec-WebSocket-Version: 13\r\n",
ws->path, host, host, user_agent_ptr, client_key);
LOGD("http request: %s", ws->buffer);
LOGD("WebSocket handshake prepared");
if (len <= 0 || len >= WS_BUFFER_SIZE) {
LOGE("Error in request generation, desired request len: %d, buffer size: %d\r\n", len, WS_BUFFER_SIZE);
@@ -871,7 +871,7 @@ static int ws_connect(volc_ws_client_t* client, const char* host, int port, int
r, len, WS_BUFFER_SIZE);
return -1;
}
LOGD("ws->buffer:\r\n%s", ws->buffer);
LOGD("WebSocket handshake sent");
if (ws_tcp_write(client, ws->buffer, len, timeout_ms) < 0) {
LOGE("Write FAIL");
}
@@ -879,12 +879,12 @@ static int ws_connect(volc_ws_client_t* client, const char* host, int port, int
int header_len = 0;
do {
if ((len = ws_tcp_read(client, ws->buffer + header_len, WS_BUFFER_SIZE - header_len, timeout_ms)) <= 0) {
LOGE("read FAIL for upgrade header:%s\r\n", ws->buffer);
LOGE("Failed to read upgrade header");
return -1;
}
header_len += len;
ws->buffer[header_len] = '\0';
LOGD("Read header chunk %d, current header size: %d, header: %s\r\n", len, header_len, ws->buffer);
LOGD("Read header chunk %d, current header size: %d", len, header_len);
} while (NULL == strstr(ws->buffer, "\r\n\r\n") && header_len < WS_BUFFER_SIZE);
char* server_key = get_http_header(ws->buffer, "Sec-WebSocket-Accept:");
@@ -1117,7 +1117,7 @@ static int __parse_url(volc_ws_client_t* client, char* url) {
return -1;
}
url_end = url + strlen(url);
LOGD("url: %s", url);
LOGD("WebSocket URL configured");
// 1. parse scheme
filed_end = strstr(pos, "://");
@@ -1209,7 +1209,7 @@ static int __parse_url(volc_ws_client_t* client, char* url) {
}
}
LOGD("scheme: %s, host: %s, port: %d, path: %s", client->scheme, client->host, (int)client->port, client->path);
LOGD("WebSocket endpoint: %s://%s:%d", client->scheme, client->host, (int)client->port);
return 0;
}
@@ -13,6 +13,7 @@
#include <mbedtls/sha256.h>
#include <mbedtls/md5.h>
#include <mbedtls/base64.h>
#include <mbedtls/platform_util.h>
#include "volc_osal.h"
#include "util/volc_base64.h"
@@ -53,63 +54,32 @@ static int __aes_cbc(bool encrypt, const unsigned char* key, unsigned char* iv,
int mode = encrypt ? MBEDTLS_AES_ENCRYPT : MBEDTLS_AES_DECRYPT;
mbedtls_aes_context aes_ctx;
mbedtls_aes_init(&aes_ctx);
mbedtls_aes_setkey_dec(&aes_ctx, key, key_bits);
mbedtls_aes_crypt_cbc(&aes_ctx, mode, ilen, iv, (const unsigned char *)input, output);
int result=encrypt?mbedtls_aes_setkey_enc(&aes_ctx,key,key_bits):mbedtls_aes_setkey_dec(&aes_ctx, key, key_bits);
if(!result)result=mbedtls_aes_crypt_cbc(&aes_ctx, mode, ilen, iv, (const unsigned char *)input, output);
mbedtls_aes_free(&aes_ctx);
return 0;
return result;
}
#define AES_KEY_SIZE 16 // AES key size in bytes
#define AES_KEY_SIZE_24 24
char* volc_aes_decode(const char* secret, const char* input, const bool partial_secret) {
size_t decoded_len = 0;
size_t secret_len = 0;
int key_bits = 0;
char* key = NULL;
unsigned char iv[16] = {0};
unsigned char* decoded_payload = NULL;
if (input == NULL || strlen(input) == 0) {
LOGE("Input is NULL or empty");
return NULL;
size_t key_len=partial_secret?16:24,decoded_len=0;
if(!secret||strlen(secret)<key_len||!input||!input[0]||strlen(input)>8192)return NULL;
size_t capacity=strlen(input)+1;
uint8_t *decoded=volc_osal_calloc(capacity,1),*output=volc_osal_calloc(capacity,1);
if(!decoded||!output){HAL_SAFE_FREE(decoded);HAL_SAFE_FREE(output);return NULL;}
unsigned char key[24]={0},iv[16];memcpy(key,secret,key_len);memcpy(iv,secret,16);
bool valid=mbedtls_base64_decode(decoded,capacity,&decoded_len,(const unsigned char*)input,strlen(input))==0&&decoded_len>0&&decoded_len%16==0;
if(valid)valid=__aes_cbc(false,key,iv,decoded,decoded_len,key_len*8,output)==0;
if(valid){size_t padding=output[decoded_len-1];valid=padding>=1&&padding<=16&&padding<=decoded_len;
if(valid){for(size_t i=decoded_len-padding;i<decoded_len;i++)if(output[i]!=padding)valid=false;
if(valid){size_t length=decoded_len-padding;valid=length>=16&&memchr(output,0,length)==NULL;output[length]=0;}}
}
if (partial_secret) {
// If partial secret is used, we only use the first 8 bytes of the key and iv
key = (char *)volc_osal_calloc(AES_KEY_SIZE, 1);
memcpy(key, secret, AES_KEY_SIZE);
key_bits = 128; // AES-128
} else {
secret_len = strlen(secret);
if (secret_len < AES_KEY_SIZE_24) {
LOGE("Secret length is less than 24, secret: %s", secret);
return NULL;
}
key = (char *)volc_osal_calloc(AES_KEY_SIZE_24, 1); // +1 for null terminator
memcpy(key, secret, AES_KEY_SIZE_24);
key_bits = 192; // AES-192
}
decoded_len = volc_base64_decoded_length((const uint8_t*)input, strlen(input));
decoded_payload = (unsigned char *)volc_osal_calloc(decoded_len + 1, 1);
if (!decoded_payload) {
LOGE("Failed to allocate memory for decoded payload");
return NULL;
}
volc_base64_decode((unsigned char *)decoded_payload, decoded_len, &decoded_len, (const unsigned char *)input, strlen(input));
LOGV("decoded payload: %s, len: %zu", decoded_payload, decoded_len);
uint8_t *output = volc_osal_calloc(decoded_len + 16, 1); // +1 for null terminator
memcpy(iv, secret, AES_KEY_SIZE); // Use the same secret for IV
__aes_cbc(false, (const unsigned char *)key, (unsigned char *)iv, (const unsigned char *)decoded_payload, decoded_len, key_bits, output);
_right_trim_pred(output, _url_trim_pred);
LOGV("trimmed decrypted payload: %s, len: %d", output, (int)strlen((const char*)output));
HAL_SAFE_FREE(decoded_payload);
HAL_SAFE_FREE(key);
return (char *)output;
mbedtls_platform_zeroize(key,sizeof(key));mbedtls_platform_zeroize(iv,sizeof(iv));
mbedtls_platform_zeroize(decoded,capacity);HAL_SAFE_FREE(decoded);
if(!valid){mbedtls_platform_zeroize(output,capacity);HAL_SAFE_FREE(output);return NULL;}
return (char*)output;
}
void volc_sha256_hmac(const unsigned char* key, int keylen, const unsigned char* input, int ilen, unsigned char* output, int* plen) {
@@ -30,7 +30,7 @@ char* volc_http_post(const char* uri, const char* post_data, int data_len)
}
webclient_response(session, (void**) &buffer, &res_len);
LOGD("url: %s, request: %s, response: %s", uri, post_data, buffer);
LOGD("HTTP exchange completed, status: %d", resp_status);
err_out_label:
if (session) {
webclient_close(session);
@@ -14,7 +14,8 @@
#if defined(ENABLE_RTC_MODE)
#include "VolcEngineRTCLite.h"
#include "volc_rtc.h"
#elif defined(ENABLE_WS_MODE)
#endif
#if defined(ENABLE_WS_MODE)
#include "volc_ws.h"
#endif
@@ -195,6 +196,7 @@ static int __config_iot_parse(cJSON* iot_config, volc_engine_impl_t* engine) {
* Product Secret is deliberately not accepted by the firmware path.
*/
ret |= volc_json_read_string(iot_config, "device_secret", &engine->info.device_secret);
(void)volc_json_read_string(iot_config,"rtc_app_id",&engine->info.rtc_app_id);
return ret == 0 ? 0 : -1;
}
@@ -263,7 +265,9 @@ int volc_create(volc_engine_t* handle, const char* config_json, volc_event_handl
cJSON* rtc_cfg = cJSON_GetObjectItem(config, "rtc");
if (rtc_cfg) {
#if defined(ENABLE_RTC_MODE)
if(!engine->info.rtc_app_id||!engine->info.rtc_app_id[0]){ret=VOLC_ERR_FAILED;goto err_out_label;}
engine->rtc = volc_rtc_create(engine->info.rtc_app_id, engine, rtc_cfg, __realtime_user_event_router, __realtime_data_router);
if(!engine->rtc){ret=VOLC_ERR_FAILED;goto err_out_label;}
#else
LOGW("RTC mode is not enabled");
#endif
@@ -365,6 +369,14 @@ int volc_start(volc_engine_t handle, volc_opt_t* opt) {
return ret;
}
const char *volc_current_user_id(volc_engine_t handle,bool remote) {
#if defined(ENABLE_RTC_MODE)
volc_engine_impl_t *engine=handle;return engine?volc_rtc_user_id(engine->rtc,remote):NULL;
#else
return NULL;
#endif
}
int volc_stop(volc_engine_t handle) {
int ret = 0;
volc_engine_impl_t* engine = (volc_engine_impl_t*)handle;
@@ -19,6 +19,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#if defined(MBEDTLS_USER_CONFIG_FILE)
#include MBEDTLS_USER_CONFIG_FILE
@@ -162,7 +163,7 @@ int mbedtls_client_connect(MbedTLSSession *session)
ret = mbedtls_net_connect(&session->server_fd, session->host, session->port,
MBEDTLS_NET_PROTO_TCP);
if (ret != 0) {
LOGE("mbedtls_net_connect error, return -0x%x", -ret);
LOGE("mbedtls_net_connect error, return -0x%x errno=%d", -ret, errno);
return ret;
}
@@ -30,7 +30,7 @@ dependencies:
type: service
version: 0.5.3
espressif/dl_fft:
component_hash: a955ca29581de2177ddf25759273bd83f082b3c805e735fe6ee105858643989b
component_hash: 3e8f7d34e04bfa6333f289a6d126745bebe0cee9311b81a6a5d106047f63ef65
dependencies:
- name: idf
require: private
@@ -38,7 +38,7 @@ dependencies:
source:
registry_url: https://components.espressif.com
type: service
version: 0.6.0
version: 0.7.0
espressif/esp-dsp:
component_hash: 619639efc18cfa361a9e423739b9b0ffc14991effc6c027f955c2f2c3bf1754b
dependencies:
@@ -241,7 +241,7 @@ dependencies:
type: service
version: 1.5.11
espressif/esp_h264:
component_hash: 054a781d7280b94f3d5c29d562ed1582e4c51e7f3ff5566d5d57058e80817805
component_hash: 3d60078419ceac61cd87579664ae6a5a9b9a42d6f2edf4e23d753af8d2a67da9
dependencies:
- name: idf
require: private
@@ -253,7 +253,7 @@ dependencies:
- esp32s3
- esp32s31
- esp32p4
version: 1.4.0
version: 1.4.1
espressif/esp_image_effects:
component_hash: a35037db02862d3cef6f72c25f422ae62da6e6c88219ce03c9ea24e0315ceaaa
dependencies:
@@ -564,14 +564,14 @@ dependencies:
- esp32c61
version: 1.4.1
espressif/esp_video_codec:
component_hash: 4a531eadff5a79a646838cb262472c58af5231a8c161223da5bbfdf95b5b0dcc
component_hash: e717c04f9dc491a3fefd655500d7009a107391127e6903c4005bfec0bbd3c29c
dependencies:
- name: espressif/esp_h264
registry_url: https://components.espressif.com
require: private
rules:
- if: target in [esp32p4, esp32s3, esp32s31]
version: ^1.4.0
version: ^1.3.0
- name: espressif/esp_new_jpeg
registry_url: https://components.espressif.com
require: private
@@ -584,7 +584,7 @@ dependencies:
source:
registry_url: https://components.espressif.com/
type: service
version: 0.5.6
version: 0.5.7
espressif/freetype:
component_hash: aa9f7657a69b2b1c1f49044017fc424081d4e0fe6e97834cb43883548d44e3ca
dependencies:
@@ -3,7 +3,7 @@
file(GLOB APP_SRC "./echoear_app/*.c")
set(COMPONENT_SRCS "conv_ai_embedded_kit.c configuration_ap.c network.c platform_client.c" )
set(COMPONENT_SRCS "conv_ai_embedded_kit.c configuration_ap.c network.c platform_client.c call_client.c ble_setup.c ble_cloud.c ble_wifi.c device_control.c device_ota.c device_power.c device_screen_wake.c device_reset.c" )
if (CONFIG_PRINT_TASK_INFO)
add_definitions(-DPRINT_TASK_INFO)
@@ -12,7 +12,7 @@ endif()
set(COMPONENT_ADD_INCLUDEDIRS .)
set(COMPONENT_EMBED_TXTFILES assets/wifi_configuration_ap.html)
set(COMPONENT_REQUIRES esp_http_client esp_http_server esp_wifi esp-tls mbedtls nvs_flash json espressif__gmf_ai_audio espressif__esp_board_manager espressif__esp_lcd_touch)
set(COMPONENT_REQUIRES av_processor app_update mqtt esp_http_client esp_http_server esp_wifi esp-tls mbedtls nvs_flash json bt protocomm esp_timer espressif__gmf_ai_audio espressif__esp_board_manager espressif__esp_lcd_touch)
register_component()
@@ -1,13 +1,11 @@
menu "Example Configuration"
choice AI_AGENT_MODE
prompt "Volcano RTC/WS mode"
default VOLC_WS_MODE
config VOLC_WS_MODE
bool "enable volcano ws mode"
endchoice
default y
config VOLC_RTC_MODE
bool "enable volcano RTC mode"
default y
menu "Develop Configuration"
@@ -0,0 +1,240 @@
#include "ble_cloud.h"
#include "call_client.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include "cJSON.h"
#include "esp_crt_bundle.h"
#include "esp_random.h"
#include "esp_timer.h"
#include "mbedtls/base64.h"
#include "mbedtls/md.h"
#include "mbedtls/sha256.h"
#include "mbedtls/platform_util.h"
#include "nvs.h"
#include "nvs_flash.h"
static char factory_id[18], model[33], origin[160];
static uint8_t device_key[32];
static uint32_t key_version;
static bool configured;
static char voice_boot[37], time_challenge[37];
const char *ble_cloud_origin(void) { return origin; }
const char *ble_cloud_boot_id(void) { if (!voice_boot[0]) ble_random_uuid(voice_boot); return voice_boot; }
const char *ble_cloud_challenge_id(void) { return time_challenge; }
typedef struct { char data[4097]; size_t len; } response_t;
static esp_err_t response_event(esp_http_client_event_t *event)
{
response_t *r = event->user_data;
if (event->event_id == HTTP_EVENT_ON_DATA && event->data_len > 0) {
if (r->len + event->data_len >= sizeof(r->data)) return ESP_ERR_INVALID_SIZE;
memcpy(r->data + r->len, event->data, event->data_len);
r->len += event->data_len;
r->data[r->len] = 0;
}
return ESP_OK;
}
static const char *string(cJSON *obj, const char *key)
{
cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key);
return cJSON_IsString(v) ? v->valuestring : "";
}
static esp_err_t getstr(nvs_handle_t nvs, const char *key, char *out, size_t len)
{ return nvs_get_str(nvs, key, out, &len); }
esp_err_t ble_cloud_init(void)
{
if (configured) return ESP_OK;
esp_err_t err = nvs_flash_init_partition("ble_factory");
if (err != ESP_OK) return err; // Never erase factory credentials on an init error.
nvs_handle_t nvs;
err = nvs_open_from_partition("ble_factory", "identity", NVS_READONLY, &nvs);
if (err != ESP_OK) return err;
size_t len = sizeof(device_key);
err = getstr(nvs,"factory_id",factory_id,sizeof(factory_id));
if (err == ESP_OK) err = getstr(nvs,"model",model,sizeof(model));
if (err == ESP_OK) err = getstr(nvs,"origin",origin,sizeof(origin));
if (err == ESP_OK) err = nvs_get_blob(nvs,"device_key",device_key,&len);
if (err == ESP_OK && len != 32) err = ESP_ERR_INVALID_SIZE;
if (err == ESP_OK) err = nvs_get_u32(nvs,"key_version",&key_version);
nvs_close(nvs);
if (err != ESP_OK || strlen(factory_id) != 17 || strncmp(factory_id,"KP01-",5) ||
strspn(factory_id+5,"0123456789ABCDEF") != 12 || !model[0] || !key_version ||
strncmp(origin,"https://",8) || strchr(origin+8,'/') || strchr(origin,'@') ||
strpbrk(origin,"\r\n?#")) { mbedtls_platform_zeroize(device_key,32); return ESP_ERR_INVALID_ARG; }
// Move this project's old server address without rewriting factory keys.
if (!strcmp(origin,"https://121.41.46.238"))
snprintf(origin,sizeof(origin),"https://82.157.201.114");
configured = true;
return ESP_OK;
}
bool ble_cloud_configured(void) { return configured; }
const char *ble_factory_id(void) { return factory_id; }
const char *ble_factory_model(void) { return model; }
esp_err_t ble_factory_security(uint8_t salt[16], uint8_t verifier[384])
{
nvs_handle_t nvs;
esp_err_t err = nvs_open_from_partition("ble_factory","identity",NVS_READONLY,&nvs);
if (err != ESP_OK) return err;
size_t len = 16;
err = nvs_get_blob(nvs,"salt",salt,&len);
if (err == ESP_OK && len != 16) err = ESP_ERR_INVALID_SIZE;
len = 384;
if (err == ESP_OK) err = nvs_get_blob(nvs,"verifier",verifier,&len);
if (err == ESP_OK && len != 384) err = ESP_ERR_INVALID_SIZE;
nvs_close(nvs);
return err;
}
void ble_random_uuid(char out[37])
{
uint8_t b[16]; esp_fill_random(b,16); b[6]=(b[6]&15)|64; b[8]=(b[8]&63)|128;
snprintf(out,37,"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",b[0],b[1],b[2],b[3],b[4],b[5],b[6],b[7],b[8],b[9],b[10],b[11],b[12],b[13],b[14],b[15]);
}
static void hex(const uint8_t *in, size_t n, char *out)
{ for(size_t i=0;i<n;i++) snprintf(out+i*2,3,"%02x",in[i]); }
static void mac(const char *input, char out[65])
{
uint8_t h[32];
mbedtls_md_hmac(mbedtls_md_info_from_type(MBEDTLS_MD_SHA256),device_key,32,(const uint8_t*)input,strlen(input),h);
hex(h,32,out); mbedtls_platform_zeroize(h,32);
}
static void nonce(char out[23])
{
uint8_t b[16], encoded[25]; size_t len;
esp_fill_random(b,16); mbedtls_base64_encode(encoded,sizeof(encoded),&len,b,16);
for(size_t i=0;i<22;i++) out[i]=encoded[i]=='+'?'-':encoded[i]=='/'?'_':encoded[i];
out[22]=0;
}
esp_err_t ble_cloud_bootstrap(const char *client_nonce, char **response, char password[65])
{
if(!configured||!client_nonce||strlen(client_nonce)!=22||strspn(client_nonce,"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")!=22)return ESP_ERR_INVALID_ARG;
char server_nonce[23],transcript[256],proof[65];nonce(server_nonce);
snprintf(transcript,sizeof(transcript),"BLE-PROOF1\n%s\n%s\n%lu\n%s\n%s",factory_id,model,(unsigned long)key_version,client_nonce,server_nonce);
mac(transcript,proof);
snprintf(transcript,sizeof(transcript),"BLE-PASS1\n%s\n%s\n%lu\n%s\n%s",factory_id,model,(unsigned long)key_version,client_nonce,server_nonce);
mac(transcript,password);
cJSON *out=cJSON_CreateObject();if(!out)return ESP_ERR_NO_MEM;
cJSON_AddStringToObject(out,"device_id",factory_id);cJSON_AddStringToObject(out,"model",model);
cJSON_AddNumberToObject(out,"key_version",key_version);
cJSON_AddStringToObject(out,"client_nonce",client_nonce);cJSON_AddStringToObject(out,"server_nonce",server_nonce);cJSON_AddStringToObject(out,"proof",proof);
*response=cJSON_PrintUnformatted(out);cJSON_Delete(out);
mbedtls_platform_zeroize(transcript,sizeof(transcript));
return *response?ESP_OK:ESP_ERR_NO_MEM;
}
esp_err_t ble_cloud_headers(esp_http_client_handle_t client,const char *method,const char *path,const char *body,const char *request_id)
{
if (!configured) return ESP_ERR_INVALID_STATE;
char timestamp[24], version[16], random[23], digest[65], signature[65], auth[86], canonical[768];
struct timeval tv; gettimeofday(&tv,NULL);
snprintf(timestamp,sizeof(timestamp),"%lld",(long long)tv.tv_sec*1000+tv.tv_usec/1000);
snprintf(version,sizeof(version),"%lu",(unsigned long)key_version);
nonce(random); uint8_t hash[32]; mbedtls_sha256((const uint8_t*)body,strlen(body),hash,0); hex(hash,32,digest);
int n=snprintf(canonical,sizeof(canonical),"DEVREQ1\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s",method,path,factory_id,version,timestamp,random,request_id,digest);
if(n<0 || n>=sizeof(canonical)) return ESP_ERR_INVALID_SIZE;
mac(canonical,signature); snprintf(auth,sizeof(auth),"Device-HMAC-SHA256 %s",signature);
esp_err_t err=esp_http_client_set_header(client,"X-Device-ID",factory_id);
if(err==ESP_OK) err=esp_http_client_set_header(client,"X-Key-Version",version);
if(err==ESP_OK) err=esp_http_client_set_header(client,"X-Timestamp-Ms",timestamp);
if(err==ESP_OK) err=esp_http_client_set_header(client,"X-Nonce",random);
if(err==ESP_OK) err=esp_http_client_set_header(client,"X-Request-ID",request_id);
if(err==ESP_OK) err=esp_http_client_set_header(client,"Authorization",auth);
return err;
}
static esp_err_t post(const char *path,const char *body,const char *request_id,response_t *out,int *status,bool signed_request)
{
char url[256];
// TIME1 is the sole HTTP endpoint: its response is bound to a fresh nonce
// and authenticated with the factory key. Every credential-bearing call is HTTPS.
if(!signed_request && !strcmp(path,"/api/v1/device/time-challenges"))snprintf(url,sizeof(url),"http://%s%s",origin+8,path);
else snprintf(url,sizeof(url),"%s%s",origin,path);
esp_http_client_config_t cfg={.url=url,.timeout_ms=!strcmp(path,"/api/v1/device/ble-bind")?45000:15000,.crt_bundle_attach=esp_crt_bundle_attach,
.event_handler=response_event,.user_data=out,.disable_auto_redirect=true,.buffer_size=1024,.buffer_size_tx=1536};
esp_http_client_handle_t client=esp_http_client_init(&cfg);
if(!client) return ESP_ERR_NO_MEM;
esp_http_client_set_method(client,HTTP_METHOD_POST);
esp_http_client_set_header(client,"Content-Type","application/json");
esp_http_client_set_post_field(client,body,strlen(body));
esp_err_t err=signed_request?ble_cloud_headers(client,"POST",path,body,request_id):ESP_OK;
if(err==ESP_OK) err=esp_http_client_perform(client);
*status=esp_http_client_get_status_code(client); esp_http_client_cleanup(client);
return err;
}
esp_err_t ble_cloud_sync_time(void)
{
const char *boot=ble_cloud_boot_id(); char random[23], body[256]; nonce(random);
snprintf(body,sizeof(body),"{\"device_id\":\"%s\",\"key_version\":%lu,\"boot_id\":\"%s\",\"client_nonce\":\"%s\"}",factory_id,(unsigned long)key_version,boot,random);
response_t *r=calloc(1,sizeof(*r)); if(!r) return ESP_ERR_NO_MEM;
int status=0; int64_t start=esp_timer_get_time();
esp_err_t err=post("/api/v1/device/time-challenges",body,NULL,r,&status,false);
if(err!=ESP_OK || status!=200){free(r);return ESP_FAIL;}
cJSON *root=cJSON_Parse(r->data);
const char *payload=string(root,"payload_b64"), *signature=string(root,"mac_hex");
char expected[65], canonical[3600];
if(strlen(payload)>3400 || strlen(signature)!=64){cJSON_Delete(root);free(r);return ESP_FAIL;}
snprintf(canonical,sizeof(canonical),"TIME1\n%s",payload); mac(canonical,expected);
unsigned mismatch=0; for(int i=0;i<64;i++) mismatch|=expected[i]^signature[i];
uint8_t decoded[2600]; char encoded[3500]; size_t len=strlen(payload),decoded_len=0;
memcpy(encoded,payload,len);
for(size_t i=0;i<len;i++){if(encoded[i]=='-')encoded[i]='+';else if(encoded[i]=='_')encoded[i]='/';}
while(len%4) encoded[len++]='=';
if(mismatch || mbedtls_base64_decode(decoded,sizeof(decoded)-1,&decoded_len,(uint8_t*)encoded,len)!=0){cJSON_Delete(root);free(r);return ESP_FAIL;}
decoded[decoded_len]=0; cJSON *p=cJSON_Parse((char*)decoded); cJSON *time=cJSON_GetObjectItemCaseSensitive(p,"server_time_ms");
bool valid=!strcmp(string(p,"device_id"),factory_id)&&!strcmp(string(p,"boot_id"),boot)&&!strcmp(string(p,"client_nonce"),random)&&cJSON_IsNumber(time)&&time->valuedouble>1700000000000.0&&esp_timer_get_time()-start<15000000;
if(valid){int64_t ms=(int64_t)time->valuedouble;struct timeval tv={.tv_sec=ms/1000,.tv_usec=(ms%1000)*1000};settimeofday(&tv,NULL);snprintf(time_challenge,sizeof(time_challenge),"%s",string(p,"challenge_id"));}
cJSON_Delete(p);cJSON_Delete(root);free(r);return valid?ESP_OK:ESP_FAIL;
}
esp_err_t ble_cloud_bind(const char *token,const char *request_id)
{
if(!configured) return ESP_ERR_INVALID_STATE;
if(ble_cloud_sync_time()!=ESP_OK) return ESP_FAIL;
char body[80];snprintf(body,sizeof(body),"{\"bind_token\":\"%s\"}",token);
response_t *r=calloc(1,sizeof(*r));if(!r)return ESP_ERR_NO_MEM;
int status=0;esp_err_t err=post("/api/v1/device/ble-bind",body,request_id,r,&status,true);
if(err==ESP_OK){
cJSON *root=cJSON_Parse(r->data),*data=cJSON_GetObjectItemCaseSensitive(root,"data");
if(status==200&&cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root,"ok"))&&!strcmp(string(data,"status"),"success")) {
err=call_client_save_bind_voice(data);
// Incomplete responses/storage failures retain the same pending
// token for retry; they do not mean the account binding was denied.
if(err!=ESP_OK)err=ESP_FAIL;
}
else if(status>=400&&status<500&&status!=408&&status!=429) err=ESP_ERR_INVALID_RESPONSE;
else err=ESP_FAIL;
cJSON *secret=cJSON_GetObjectItemCaseSensitive(data,"device_secret");
if(cJSON_IsString(secret))mbedtls_platform_zeroize(secret->valuestring,strlen(secret->valuestring));
cJSON_Delete(root);
}
mbedtls_platform_zeroize(r,sizeof(*r));free(r);return err;
}
esp_err_t ble_cloud_check_bound(const char *request_id)
{
if(!configured)return ESP_ERR_INVALID_STATE;
if(ble_cloud_sync_time()!=ESP_OK)return ESP_FAIL;
response_t *r=calloc(1,sizeof(*r));if(!r)return ESP_ERR_NO_MEM;
int status=0;esp_err_t err=post("/api/v1/device/ble-state","{}",request_id,r,&status,true);
if(err==ESP_OK){
cJSON *root=cJSON_Parse(r->data),*data=cJSON_GetObjectItemCaseSensitive(root,"data"),*bound=cJSON_GetObjectItemCaseSensitive(data,"bound");
if(status==200&&cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root,"ok"))&&cJSON_IsBool(bound))err=cJSON_IsTrue(bound)?ESP_OK:ESP_ERR_INVALID_RESPONSE;
else if(status>=400&&status<500&&status!=408&&status!=429)err=ESP_ERR_INVALID_RESPONSE;
else err=ESP_FAIL;
cJSON_Delete(root);
}
mbedtls_platform_zeroize(r,sizeof(*r));free(r);return err;
}
char *ble_cloud_decode(const char *domain,const char *encoded,const char *signature)
{
if(!configured || !domain || !encoded || !signature || strlen(signature)!=64 || strlen(encoded)>32768 ||
strspn(encoded,"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")!=strlen(encoded))return NULL;
size_t n=strlen(encoded),d=strlen(domain),out_len=0;
char *canonical=malloc(n+d+2),*padded=calloc(1,n+5),*out=calloc(1,n+1);
if(!canonical||!padded||!out){free(canonical);free(padded);free(out);return NULL;}
snprintf(canonical,n+d+2,"%s\n%s",domain,encoded);
char expected[65];mac(canonical,expected);unsigned mismatch=0;
for(int i=0;i<64;i++)mismatch|=expected[i]^signature[i];
memcpy(padded,encoded,n);for(size_t i=0;i<n;i++){if(padded[i]=='-')padded[i]='+';else if(padded[i]=='_')padded[i]='/';}
size_t len=n;while(len%4)padded[len++]='=';
bool ok=!mismatch&&mbedtls_base64_decode((uint8_t*)out,n,&out_len,(uint8_t*)padded,len)==0;
free(canonical);free(padded);if(!ok){free(out);return NULL;}out[out_len]=0;return out;
}
@@ -0,0 +1,27 @@
#pragma once
#include "esp_err.h"
#include "esp_http_client.h"
#include <stdbool.h>
#include <stdint.h>
// Factory credentials occupy a separate partition; existing Wi-Fi/voice NVS stays intact.
esp_err_t ble_cloud_init(void);
const char *ble_factory_id(void);
const char *ble_factory_model(void);
const char *ble_cloud_origin(void);
const char *ble_cloud_boot_id(void);
const char *ble_cloud_challenge_id(void);
esp_err_t ble_cloud_sync_time(void);
// Authenticates original base64url bytes before decoding, without exporting the key.
char *ble_cloud_decode(const char *domain, const char *encoded, const char *signature);
esp_err_t ble_factory_security(uint8_t salt[16], uint8_t verifier[384]);
// Fresh BLE-link bootstrap. Password is returned only to the local SRP worker.
esp_err_t ble_cloud_bootstrap(const char *client_nonce, char **response, char password[65]);
void ble_random_uuid(char out[37]);
bool ble_cloud_configured(void);
esp_err_t ble_cloud_headers(esp_http_client_handle_t client, const char *method,
const char *path, const char *body, const char *request_id);
// ESP_OK = committed, ESP_ERR_INVALID_RESPONSE = definite refusal; other errors are uncertain.
esp_err_t ble_cloud_bind(const char *token, const char *request_id);
// Wi-Fi-only provisioning verifies an existing binding without changing it.
esp_err_t ble_cloud_check_bound(const char *request_id);
@@ -0,0 +1,25 @@
#ifndef VOICELINK_BLE_NAME_H
#define VOICELINK_BLE_NAME_H
#include <stdio.h>
#include <string.h>
// Legacy scan response: 31 bytes minus AD length/type = 29 name bytes.
// "vl-" (3) + model (<=17 UTF-8 bytes) + "-" (1) + ID (8).
#define VOICELINK_BLE_NAME_CAPACITY 30
static inline void voicelink_ble_name(char out[VOICELINK_BLE_NAME_CAPACITY],
const char *model, const char *factory_id)
{
size_t model_len = strlen(model);
if (model_len > 17) {
model_len = 17;
// Do not truncate inside a UTF-8 code point.
while (model_len && ((unsigned char)model[model_len] & 0xc0) == 0x80)
model_len--;
}
size_t id_len = strlen(factory_id);
const char *suffix = id_len >= 8 ? factory_id + id_len - 8 : factory_id;
snprintf(out, VOICELINK_BLE_NAME_CAPACITY, "vl-%.*s-%s",
(int)model_len, model, suffix);
}
#endif
@@ -0,0 +1,388 @@
#include "ble_setup.h"
#include "ble_cloud.h"
#include "ble_name.h"
#include "ble_wifi.h"
#include "device_reset.h"
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include "esp_netif.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "host/ble_hs.h"
#include "host/util/util.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
#include "mbedtls/platform_util.h"
#include "nvs.h"
#include "volc_hal.h"
#define MAX_FRAME 2050
#define NO_CONN BLE_HS_CONN_HANDLE_NONE
static const char *TAG="ble_setup";
// BLE UUID byte arrays are little endian.
#define BLE_UUID(last) BLE_UUID128_INIT(0x00,0x61,0x5e,0x4f,0x82,0x7d,0x31,0x9c,0x2d,0x4a,0x4b,0x6f,last,0x20,0x7a,0x8e)
static const ble_uuid128_t service_uuid=BLE_UUID(0),rx_uuid=BLE_UUID(1),tx_uuid=BLE_UUID(2);
static uint16_t tx_handle;
static uint8_t own_address_type;
static atomic_uint connection=NO_CONN, epoch;
static atomic_bool subscribed, enabled;
static atomic_bool window_closed;
static int64_t window_end, fragment_start, fragment_last;
static uint8_t rx_buffer[MAX_FRAME];
static size_t rx_len;
static struct ble_npl_callout watchdog;
typedef struct { unsigned generation; uint16_t conn; uint8_t kind; size_t len; int64_t started; uint8_t data[MAX_FRAME]; } record_t;
typedef struct { unsigned generation; int status; } ack_t;
static QueueHandle_t records,acks;
static SemaphoreHandle_t state_mutex;
static TaskHandle_t binding_task;
typedef struct { uint32_t version; char ssid[33],password[64],token[44],press[37]; } job_t;
static job_t job;
typedef enum { IDLE,RECEIVED,WIFI_CONNECTING,BINDING,SUCCESS,FAILED } state_t;
static state_t state;
static const char *state_names[]={"idle","received","wifi_connecting","binding","success","failed"};
static const char *failure_message="";
static int64_t confirm_until;
static bool recover_saved_wifi, timeout_handled;
static void show(const char *text)
{
volc_hal_context_t *ctx=volc_get_global_hal_context();
if(ctx&&ctx->display_handle) volc_hal_display_set_content(ctx->display_handle,VOLC_DISPLAY_OBJ_SUBTITLE,VOLC_DISPLAY_TEXT,(void*)text);
}
static void set_state(state_t value,const char *message)
{
xSemaphoreTake(state_mutex,portMAX_DELAY);state=value;failure_message=message?message:"";xSemaphoreGive(state_mutex);
const char *display[]={"Bluetooth setup","Tap to confirm WiFi","Connecting WiFi","Checking account","WiFi setup saved","Setup failed; retry"};
const char *reset_display[]={"Bluetooth setup","Tap to ERASE settings and WiFi","Connecting WiFi","Authorizing factory reset","Settings erased; restarting","Reset failed; retry"};
show(job.version==3?reset_display[value]:display[value]); ESP_LOGI(TAG,"state=%s",state_names[value]);
}
static esp_err_t pending_save(bool keep)
{
nvs_handle_t nvs;esp_err_t err=nvs_open("ble_setup",NVS_READWRITE,&nvs);if(err!=ESP_OK)return err;
err=keep?nvs_set_blob(nvs,"pending",&job,sizeof(job)):nvs_erase_key(nvs,"pending");
if(err==ESP_ERR_NVS_NOT_FOUND)err=ESP_OK;
if(err==ESP_OK)err=nvs_commit(nvs);
nvs_close(nvs);return err;
}
static esp_err_t complete_save(void)
{
nvs_handle_t nvs;esp_err_t err=nvs_open("ble_setup",NVS_READWRITE,&nvs);if(err!=ESP_OK)return err;
err=nvs_set_u8(nvs,"completed",1);
if(err==ESP_OK)err=nvs_set_u8(nvs,"protocol",2);
if(err==ESP_OK)err=nvs_erase_key(nvs,"pending");
if(err==ESP_ERR_NVS_NOT_FOUND)err=ESP_OK;
if(err==ESP_OK)err=nvs_commit(nvs);
nvs_close(nvs);return err;
}
static esp_err_t save_wifi(void)
{
nvs_handle_t nvs;esp_err_t err=nvs_open("wifi",NVS_READWRITE,&nvs);if(err!=ESP_OK)return err;
err=nvs_set_str(nvs,"ssid",job.ssid);
if(err==ESP_OK)err=nvs_set_str(nvs,"password",job.password);
if(err==ESP_OK)err=nvs_set_u8(nvs,"setup_requested",0);
if(err==ESP_OK)err=nvs_commit(nvs);
nvs_close(nvs);return err;
}
static void binding_worker(void *arg)
{
bool resume=arg!=NULL;
for(;;){
if(!resume)ulTaskNotifyTake(pdTRUE,portMAX_DELAY);
resume=false;
// The confirmation event and all retry inputs reach flash before HTTP.
if(pending_save(true)!=ESP_OK){set_state(FAILED,"设备保存失败,请重试");continue;}
set_state(WIFI_CONNECTING,NULL);
esp_err_t wifi_err=ble_wifi_connect(job.ssid,job.password);
if(wifi_err!=ESP_OK){
ESP_LOGE(TAG,"Wi-Fi setup failed: %s",esp_err_to_name(wifi_err));
const char *message=wifi_err==ESP_ERR_NO_MEM?"设备内存不足,未能启动 Wi-Fi,请重试":
wifi_err==ESP_ERR_TIMEOUT?"Wi-Fi 连接失败,请检查 2.4 GHz 网络和密码":"设备 Wi-Fi 初始化失败,请重试";
if(pending_save(false)==ESP_OK)set_state(FAILED,message);
else set_state(BINDING,"设备保存失败,请重新启动恢复任务");
continue;
}
set_state(BINDING,NULL);
for(;;){
if(!ble_wifi_connected()&&ble_wifi_connect(job.ssid,job.password)!=ESP_OK){
vTaskDelay(pdMS_TO_TICKS(10000));continue;
}
bool resetting=job.version==3;
esp_err_t err=resetting?device_reset_from_setup(job.press):job.token[0]?ble_cloud_bind(job.token,job.press):ble_cloud_check_bound(job.press);
if(err==ESP_OK){
// Retain the pending record until both commits complete; a power
// cut here replays the same token, never creates another binding.
if(!resetting&&(save_wifi()!=ESP_OK||complete_save()!=ESP_OK)){vTaskDelay(pdMS_TO_TICKS(5000));continue;}
set_state(SUCCESS,NULL);vTaskDelay(pdMS_TO_TICKS(15000));esp_restart();
}
if(err==ESP_ERR_INVALID_RESPONSE){
if(pending_save(false)==ESP_OK)set_state(FAILED,resetting?"重置未完成,请先在原账号解绑;通话或升级结束后再试":job.token[0]?"平台拒绝绑定,请查询账号归属或重新申请":"设备已解绑,请重新搜索并绑定账号");
break;
}
// Transport/5xx/429 uncertainty is not a failed binding. The same
// token and persisted press ID are retried with a fresh signed nonce.
vTaskDelay(pdMS_TO_TICKS(10000));
}
}
}
bool ble_setup_confirm(void)
{
if(!atomic_load(&enabled))return false;
xSemaphoreTake(state_mutex,portMAX_DELAY);
if(state==RECEIVED&&esp_timer_get_time()>=confirm_until){
state=FAILED;failure_message="确认超时,请重新发送配网信息";
}
if(esp_timer_get_time()>=window_end&&(state==IDLE||state==FAILED)){
xSemaphoreGive(state_mutex);return false;
}
bool accept=state==RECEIVED&&esp_timer_get_time()<confirm_until;
if(accept){ble_random_uuid(job.press);state=WIFI_CONNECTING;}
xSemaphoreGive(state_mutex);
if(accept)xTaskNotifyGive(binding_task);
return true;
}
static void recover_wifi_after_timeout(void)
{
if(!atomic_load(&enabled)||timeout_handled||esp_timer_get_time()<window_end)return;
xSemaphoreTake(state_mutex,portMAX_DELAY);
if(state==RECEIVED&&esp_timer_get_time()>=confirm_until){
state=FAILED;failure_message="确认超时,请重新发送配网信息";
}
bool idle=state==IDLE||state==FAILED;
xSemaphoreGive(state_mutex);
if(!idle)return; // A confirmed/persisted job continues even after BLE closes.
timeout_handled=true;
if(!recover_saved_wifi)return; // First-time setup has no known-good Wi-Fi.
nvs_handle_t nvs;size_t pending_len=0;
if(nvs_open("ble_setup",NVS_READONLY,&nvs)!=ESP_OK)return;
esp_err_t err=nvs_get_blob(nvs,"pending",NULL,&pending_len);nvs_close(nvs);
if(err!=ESP_ERR_NVS_NOT_FOUND)return; // Preserve confirmed work or uncertain storage.
err=nvs_open("wifi",NVS_READWRITE,&nvs);
if(err==ESP_OK){
err=nvs_set_u8(nvs,"setup_requested",0);
if(err==ESP_OK)err=nvs_commit(nvs);
nvs_close(nvs);
}
if(err==ESP_OK){
ESP_LOGI(TAG,"Setup window expired; restoring saved Wi-Fi");
show("Setup timed out; restoring WiFi");esp_restart();
}else{
ESP_LOGE(TAG,"Cannot restore saved Wi-Fi: %s",esp_err_to_name(err));
show("WiFi restore failed; restart");
}
}
static bool valid_password(const char *p)
{
size_t len=strlen(p);if(len<8||len>63)return false;
for(size_t i=0;i<len;i++)if((uint8_t)p[i]<32||(uint8_t)p[i]>126)return false;
return true;
}
static const char *json_string(cJSON *root,const char *key)
{cJSON *v=cJSON_GetObjectItemCaseSensitive(root,key);return cJSON_IsString(v)?v->valuestring:NULL;}
static esp_err_t endpoint(uint32_t session,const uint8_t *input,ssize_t len,uint8_t **output,ssize_t *outlen,void *arg)
{
if(len<=0||len>2048||memchr(input,0,len))return ESP_ERR_INVALID_ARG;
// Require a single JSON object; cJSON alone otherwise accepts trailing data.
char *text=calloc(1,len+1);if(!text)return ESP_ERR_NO_MEM;memcpy(text,input,len);
if(strstr(text,"\\u0000")){mbedtls_platform_zeroize(text,len);free(text);return ESP_ERR_INVALID_ARG;}
const char *end=NULL;cJSON *root=cJSON_ParseWithOpts(text,&end,true);const char *cmd=json_string(root,"cmd");
if(!cJSON_IsObject(root)||!cmd){cJSON_Delete(root);mbedtls_platform_zeroize(text,len);free(text);return ESP_ERR_INVALID_ARG;}
bool valid=true;unsigned count=0;cJSON *item;
cJSON_ArrayForEach(item,root){
count++;if(!item->string||!cJSON_IsString(item))valid=false;
for(cJSON *prev=root->child;prev!=item;prev=prev->next)if(prev->string&&item->string&&!strcmp(prev->string,item->string))valid=false;
if(item->string&&strcmp(item->string,"cmd")&&strcmp(item->string,"wifi_ssid")&&strcmp(item->string,"wifi_password")&&strcmp(item->string,"bind_token"))valid=false;
}
cJSON *result=cJSON_CreateObject();
if(!valid||!result)goto invalid;
if(!strcmp(cmd,"get_info")&&count==1){cJSON_AddStringToObject(result,"device_id",ble_factory_id());cJSON_AddStringToObject(result,"model",ble_factory_model());cJSON_AddNumberToObject(result,"protocol_version",2);cJSON_AddBoolToObject(result,"factory_reset_supported",true);}
else if((!strcmp(cmd,"get_status")&&count==1)||((!strcmp(cmd,"configure")||!strcmp(cmd,"factory_reset"))&&count==4)){
const char *error=NULL;xSemaphoreTake(state_mutex,portMAX_DELAY);
if(state==RECEIVED&&esp_timer_get_time()>=confirm_until){state=FAILED;failure_message="确认超时,请重新发送配网信息";}
if(!strcmp(cmd,"configure")||!strcmp(cmd,"factory_reset")){
bool resetting=!strcmp(cmd,"factory_reset");
const char *ssid=json_string(root,"wifi_ssid"),*password=json_string(root,"wifi_password"),*token=json_string(root,"bind_token");
if(!ssid||!ssid[0]||strlen(ssid)>32||!password||!valid_password(password)||!token||(token[0]&&(strlen(token)!=43||strspn(token,"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")!=43)))error="配网字段格式不正确";
else if(resetting&&token[0])error="恢复出厂不能携带绑定凭据";
else if(job.version==(resetting?3:2)&&state!=FAILED&&!strcmp(job.token,token)){
if(strcmp(job.ssid,ssid)||strcmp(job.password,password))error="同一绑定凭据不能修改配网内容";
}else if(state==RECEIVED||state==WIFI_CONNECTING||state==BINDING||state==SUCCESS)error="设备已有配网任务,请先查询结果";
else if(esp_timer_get_time()>=window_end)error="蓝牙配网窗口已关闭,请重新长按开启";
else{
mbedtls_platform_zeroize(&job,sizeof(job));job.version=resetting?3:2;
strcpy(job.ssid,ssid);strcpy(job.password,password);strcpy(job.token,token);
state=RECEIVED;failure_message="";confirm_until=esp_timer_get_time()+60000000;
show(resetting?"Tap to ERASE settings and WiFi":"Tap screen to confirm WiFi");
}
}
cJSON_AddStringToObject(result,"status",error?"failed":state_names[state]);
cJSON_AddStringToObject(result,"operation",job.version==3?"factory_reset":"configure");
if(error||state==FAILED)cJSON_AddStringToObject(result,"message",error?error:failure_message);
xSemaphoreGive(state_mutex);
}else goto invalid;
*output=(uint8_t*)cJSON_PrintUnformatted(result);*outlen=*output?strlen((char*)*output):0;
cJSON_Delete(root);cJSON_Delete(result);mbedtls_platform_zeroize(text,len);free(text);
return *output?ESP_OK:ESP_ERR_NO_MEM;
invalid:
cJSON_Delete(root);cJSON_Delete(result);mbedtls_platform_zeroize(text,len);free(text);return ESP_ERR_INVALID_ARG;
}
static void terminate_link(void)
{unsigned handle=atomic_load(&connection);if(handle!=NO_CONN)ble_gap_terminate(handle,BLE_ERR_REM_USER_CONN_TERM);}
static int gatt_access(uint16_t conn,uint16_t attr,struct ble_gatt_access_ctxt *ctx,void *arg)
{
if(ctx->op!=BLE_GATT_ACCESS_OP_WRITE_CHR||!atomic_load(&subscribed))return BLE_ATT_ERR_WRITE_NOT_PERMITTED;
uint16_t len=OS_MBUF_PKTLEN(ctx->om);
if(!len||len>20||rx_len+len>MAX_FRAME){terminate_link();return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;}
if(!rx_len)fragment_start=esp_timer_get_time();
fragment_last=esp_timer_get_time();
if(os_mbuf_copydata(ctx->om,0,len,rx_buffer+rx_len)){terminate_link();return BLE_ATT_ERR_UNLIKELY;}
rx_len+=len;if(rx_len<2)return 0;
size_t expected=2+rx_buffer[0]+((size_t)rx_buffer[1]<<8);
if(expected<3||expected>MAX_FRAME||rx_len>expected){terminate_link();return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;}
if(rx_len==expected){
record_t *record=calloc(1,sizeof(*record));if(!record)return BLE_ATT_ERR_INSUFFICIENT_RES;
record->generation=atomic_load(&epoch);record->conn=conn;record->kind=2;record->len=rx_len;record->started=fragment_start;memcpy(record->data,rx_buffer,rx_len);
rx_len=0;fragment_start=fragment_last=0;
if(xQueueSend(records,&record,0)!=pdTRUE){free(record);terminate_link();return BLE_ATT_ERR_INSUFFICIENT_RES;}
}
return 0;
}
static const struct ble_gatt_svc_def services[]={
{.type=BLE_GATT_SVC_TYPE_PRIMARY,.uuid=&service_uuid.u,.characteristics=(struct ble_gatt_chr_def[]){
{.uuid=&rx_uuid.u,.access_cb=gatt_access,.flags=BLE_GATT_CHR_F_WRITE},
{.uuid=&tx_uuid.u,.access_cb=gatt_access,.flags=BLE_GATT_CHR_F_INDICATE,.val_handle=&tx_handle}, {0}}},{0}};
static int gap_event(struct ble_gap_event *event,void *arg);
static void advertise(void)
{
if(esp_timer_get_time()>=window_end)return;
struct ble_hs_adv_fields adv={.flags=BLE_HS_ADV_F_DISC_GEN|BLE_HS_ADV_F_BREDR_UNSUP,.uuids128=(ble_uuid128_t*)&service_uuid,.num_uuids128=1,.uuids128_is_complete=1};
char name[VOICELINK_BLE_NAME_CAPACITY];voicelink_ble_name(name,ble_factory_model(),ble_factory_id());
struct ble_hs_adv_fields scan={.name=(uint8_t*)name,.name_len=strlen(name),.name_is_complete=1};
int rc=ble_gap_adv_set_fields(&adv);
if(!rc)rc=ble_gap_adv_rsp_set_fields(&scan);
if(rc){ESP_LOGE(TAG,"Cannot configure BLE advertising: %d",rc);return;}
struct ble_gap_adv_params params={.conn_mode=BLE_GAP_CONN_MODE_UND,.disc_mode=BLE_GAP_DISC_MODE_GEN};
rc=ble_gap_adv_start(own_address_type,NULL,5000,&params,gap_event,NULL);
if(rc)ESP_LOGE(TAG,"Cannot start BLE advertising: %d",rc);
else ESP_LOGI(TAG,"BLE advertising started");
}
static void queue_link(uint8_t kind,uint16_t conn,unsigned gen)
{
record_t *r=calloc(1,sizeof(*r));if(!r){terminate_link();return;}
r->kind=kind;r->conn=conn;r->generation=gen;
if(xQueueSend(records,&r,0)!=pdTRUE){free(r);terminate_link();}
}
static int gap_event(struct ble_gap_event *event,void *arg)
{
switch(event->type){
case BLE_GAP_EVENT_CONNECT:
if(event->connect.status){advertise();break;}
atomic_store(&connection,event->connect.conn_handle);atomic_store(&subscribed,false);rx_len=0;fragment_start=0;
queue_link(0,event->connect.conn_handle,atomic_fetch_add(&epoch,1)+1);break;
case BLE_GAP_EVENT_DISCONNECT:
queue_link(1,event->disconnect.conn.conn_handle,atomic_load(&epoch));
atomic_store(&connection,NO_CONN);atomic_store(&subscribed,false);rx_len=0;fragment_start=0;advertise();break;
case BLE_GAP_EVENT_SUBSCRIBE:if(event->subscribe.attr_handle==tx_handle)atomic_store(&subscribed,event->subscribe.cur_indicate);break;
case BLE_GAP_EVENT_NOTIFY_TX:
if(event->notify_tx.indication&&event->notify_tx.status!=0){ack_t ack={.generation=atomic_load(&epoch),.status=event->notify_tx.status};xQueueSend(acks,&ack,0);}break;
case BLE_GAP_EVENT_ADV_COMPLETE:advertise();break;
default:break;
}
return 0;
}
static void watchdog_tick(struct ble_npl_event *event)
{
int64_t now=esp_timer_get_time();
if(fragment_start&&(now-fragment_start>45000000||now-fragment_last>10000000))terminate_link();
if(now>=window_end&&!atomic_exchange(&window_closed,true)){
if(ble_gap_adv_active())ble_gap_adv_stop();
terminate_link();
}
ble_npl_callout_reset(&watchdog,ble_npl_time_ms_to_ticks32(1000));
}
static void on_sync(void)
{
int rc=ble_hs_util_ensure_addr(0);
if(!rc)rc=ble_hs_id_infer_auto(0,&own_address_type);
if(rc){ESP_LOGE(TAG,"Cannot initialize BLE address: %d",rc);return;}
ble_npl_callout_init(&watchdog,nimble_port_get_dflt_eventq(),watchdog_tick,NULL);
ble_npl_callout_reset(&watchdog,ble_npl_time_ms_to_ticks32(1000));advertise();
}
static void host_task(void *arg){nimble_port_run();nimble_port_freertos_deinit();}
static bool send_frame(record_t *r,const uint8_t *data,size_t len)
{
uint8_t frame[MAX_FRAME];if(!len||len>2048)return false;
frame[0]=len&255;frame[1]=len>>8;memcpy(frame+2,data,len);
ack_t ack;while(xQueueReceive(acks,&ack,0)==pdTRUE){}
int64_t start=r->started;
for(size_t at=0;at<len+2;at+=20){
if(r->generation!=atomic_load(&epoch)||r->conn!=atomic_load(&connection)||!atomic_load(&subscribed)||esp_timer_get_time()-start>45000000)return false;
size_t size=len+2-at;if(size>20)size=20;
struct os_mbuf *mbuf=ble_hs_mbuf_from_flat(frame+at,size);if(!mbuf)return false;
if(ble_gatts_indicate_custom(r->conn,tx_handle,mbuf))return false;
if(xQueueReceive(acks,&ack,pdMS_TO_TICKS(10000))!=pdTRUE||ack.generation!=r->generation||ack.status!=BLE_HS_EDONE)return false;
}
return true;
}
static void protocol_worker(void *arg)
{
for(;;){
recover_wifi_after_timeout();
record_t *r=NULL;if(xQueueReceive(records,&r,pdMS_TO_TICKS(1000))!=pdTRUE)continue;
if(r->kind==2&&r->generation==atomic_load(&epoch)&&r->conn==atomic_load(&connection)){
uint8_t *out=NULL;ssize_t len=0;
esp_err_t err=endpoint(r->generation,r->data+2,r->len-2,&out,&len,NULL);
if(err!=ESP_OK||!send_frame(r,out,len))terminate_link();
if(out){mbedtls_platform_zeroize(out,len);free(out);}
}
mbedtls_platform_zeroize(r,sizeof(*r));free(r);
}
}
bool ble_setup_start_if_needed(void)
{
if(ble_cloud_init()!=ESP_OK)return false;
nvs_handle_t nvs;uint8_t requested=0;char ssid[33]={0};size_t len=sizeof(ssid);
if(nvs_open("wifi",NVS_READONLY,&nvs)==ESP_OK){nvs_get_u8(nvs,"setup_requested",&requested);nvs_get_str(nvs,"ssid",ssid,&len);nvs_close(nvs);}
bool pending=false;uint8_t completed=0,protocol=0;
if(nvs_open("ble_setup",NVS_READONLY,&nvs)==ESP_OK){len=sizeof(job);pending=nvs_get_blob(nvs,"pending",&job,&len)==ESP_OK&&len==sizeof(job)&&(job.version==1||job.version==2||job.version==3);nvs_get_u8(nvs,"completed",&completed);nvs_close(nvs);}
if(nvs_open("ble_setup",NVS_READONLY,&nvs)==ESP_OK){nvs_get_u8(nvs,"protocol",&protocol);nvs_close(nvs);}
if(!pending)mbedtls_platform_zeroize(&job,sizeof(job));
if(completed&&protocol==2&&!requested&&ssid[0]&&!pending)return false;
recover_saved_wifi=completed&&protocol==2&&ssid[0]&&!pending;
// Refuse malformed persisted records instead of sending unbounded strings.
if(pending&&(!memchr(job.ssid,0,sizeof(job.ssid))||!memchr(job.password,0,sizeof(job.password))||job.token[43]||job.press[36]||(job.version==3&&job.token[0])))return false;
state_mutex=xSemaphoreCreateMutex();records=xQueueCreate(4,sizeof(record_t*));acks=xQueueCreate(4,sizeof(ack_t));
if(!state_mutex||!records||!acks)return false;
// Reserve the small internal Wi-Fi buffers before BLE allocations.
// This does not connect or send data; a physical tap is still required.
esp_err_t wifi_err=ble_wifi_prepare();
if(wifi_err!=ESP_OK)ESP_LOGW(TAG,"Wi-Fi prepare deferred: %s",esp_err_to_name(wifi_err));
if(pending)state=BINDING;
if(xTaskCreateWithCaps(binding_worker,"ble_bind",16384,pending?(void*)1:NULL,5,&binding_task,MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT)!=pdPASS)return true;
if(xTaskCreateWithCaps(protocol_worker,"ble_protocol",12288,NULL,5,NULL,MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT)!=pdPASS)return true;
window_end=esp_timer_get_time()+300000000;
ESP_LOGI(TAG,"Before BLE init: internal_free=%u largest=%u",(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL));
if(nimble_port_init()!=ESP_OK){show("Bluetooth init failed; restart");return true;}
ble_svc_gap_init();ble_svc_gatt_init();
if(ble_gatts_count_cfg(services)||ble_gatts_add_svcs(services)){
show("Bluetooth service failed; restart");return true;
}
char name[VOICELINK_BLE_NAME_CAPACITY];voicelink_ble_name(name,ble_factory_model(),ble_factory_id());ble_svc_gap_device_name_set(name);
ble_hs_cfg.sync_cb=on_sync;nimble_port_freertos_init(host_task);atomic_store(&enabled,true);
if(!pending)set_state(IDLE,NULL);
ESP_LOGI(TAG,"BLE initialized; waiting for host sync: %s model=%s",name,ble_factory_model());return true;
}
@@ -0,0 +1,6 @@
#pragma once
#include <stdbool.h>
// Returns true when BLE owns startup (including pending binding recovery).
bool ble_setup_start_if_needed(void);
// Called only for a physical short tap. Returns true if the tap is consumed.
bool ble_setup_confirm(void);
@@ -0,0 +1,168 @@
#include "ble_wifi.h"
#include <string.h>
#include "esp_event.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "esp_wifi_default.h"
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "freertos/task.h"
#define CONNECTED BIT0
#define DISCONNECTED BIT1
#define STOPPED BIT2
static const char *TAG = "ble_wifi";
static esp_netif_t *station;
static EventGroupHandle_t events;
static esp_event_handler_instance_t wifi_handler, ip_handler;
static bool driver_ready, prepared, started, stopping;
static void wifi_event(void *arg, esp_event_base_t base, int32_t id, void *data)
{
(void)arg;
if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
xEventGroupSetBits(events, CONNECTED);
ESP_LOGI(TAG, "Wi-Fi obtained IP");
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
xEventGroupClearBits(events, CONNECTED);
xEventGroupSetBits(events, DISCONNECTED);
const wifi_event_sta_disconnected_t *event = data;
// Never log the SSID, password, binding token, or raw event payload.
ESP_LOGW(TAG, "Wi-Fi disconnected; reason=%u", event ? event->reason : 0);
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_STOP) {
xEventGroupClearBits(events, CONNECTED);
xEventGroupSetBits(events, STOPPED);
}
}
esp_err_t ble_wifi_prepare(void)
{
if (prepared) return ESP_OK;
// Retain each successfully initialized resource across retries. In
// particular an esp_wifi_init failure must not recreate WIFI_STA or leak
// another event group on the next screen confirmation.
if (!events) {
events = xEventGroupCreate();
if (!events) return ESP_ERR_NO_MEM;
}
if (!station) {
// Do not adopt/destroy an interface owned by the normal voice flow.
if (esp_netif_get_handle_from_ifkey("WIFI_STA")) return ESP_ERR_INVALID_STATE;
esp_netif_config_t config = ESP_NETIF_DEFAULT_WIFI_STA();
station = esp_netif_new(&config);
if (!station) return ESP_ERR_NO_MEM;
// The convenience create_default_wifi_sta() aborts on allocation or
// duplicate-key failure. Use its checked building blocks instead.
esp_err_t err = esp_netif_attach_wifi_station(station);
if (err == ESP_OK) err = esp_wifi_set_default_wifi_sta_handlers();
if (err != ESP_OK) {
esp_netif_destroy_default_wifi(station);
station = NULL;
return err;
}
}
if (!driver_ready) {
wifi_init_config_t config = WIFI_INIT_CONFIG_DEFAULT();
// Provisioning sends a few small HTTP requests, not a voice stream.
// Static RX/TX buffers require internal RAM even with 8 MB PSRAM.
// Keep normal voice-mode sdkconfig performance settings unchanged.
config.static_rx_buf_num = 4;
config.dynamic_rx_buf_num = 8;
config.static_tx_buf_num = 4;
config.cache_tx_buf_num = 4;
config.ampdu_rx_enable = 0;
config.ampdu_tx_enable = 0;
config.amsdu_tx_enable = 0;
ESP_LOGI(TAG, "Preparing Wi-Fi: internal_free=%u largest=%u rx=%d tx=%d",
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL),
config.static_rx_buf_num, config.static_tx_buf_num);
esp_err_t err = esp_wifi_init(&config);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Wi-Fi driver init failed: %s", esp_err_to_name(err));
return err;
}
driver_ready = true;
}
esp_err_t err;
if (!wifi_handler) {
err = esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID,
wifi_event, NULL, &wifi_handler);
if (err != ESP_OK) return err;
}
if (!ip_handler) {
err = esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP,
wifi_event, NULL, &ip_handler);
if (err != ESP_OK) return err;
}
err = esp_wifi_set_storage(WIFI_STORAGE_RAM);
if (err == ESP_OK) err = esp_wifi_set_mode(WIFI_MODE_STA);
if (err != ESP_OK) return err;
prepared = true;
ESP_LOGI(TAG, "Wi-Fi prepared: internal_free=%u largest=%u",
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL));
return ESP_OK;
}
static esp_err_t stop_station(void)
{
if (!started) return ESP_OK;
// Drain the previous attempt's events before accepting a new Wi-Fi job.
// Otherwise a delayed DISCONNECTED/GOT_IP can affect the next attempt.
if (!stopping) {
esp_err_t err = esp_wifi_stop();
if (err != ESP_OK) return err;
stopping = true;
}
if (!(xEventGroupWaitBits(events, STOPPED, pdFALSE, pdFALSE,
pdMS_TO_TICKS(5000)) & STOPPED)) return ESP_ERR_TIMEOUT;
started = false;
stopping = false;
xEventGroupClearBits(events, CONNECTED | DISCONNECTED | STOPPED);
return ESP_OK;
}
bool ble_wifi_connected(void)
{
return events && (xEventGroupGetBits(events) & CONNECTED);
}
esp_err_t ble_wifi_connect(const char *ssid, const char *password)
{
if (!ssid || !ssid[0] || strlen(ssid) > 32 || !password || strlen(password) > 63)
return ESP_ERR_INVALID_ARG;
esp_err_t err = ble_wifi_prepare();
if (err != ESP_OK) return err;
err = stop_station();
if (err != ESP_OK) return err;
wifi_config_t config = {0};
memcpy(config.sta.ssid, ssid, strlen(ssid));
memcpy(config.sta.password, password, strlen(password));
config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
config.sta.scan_method = WIFI_ALL_CHANNEL_SCAN;
config.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL;
err = esp_wifi_set_config(WIFI_IF_STA, &config);
if (err != ESP_OK) return err;
for (int attempt = 0; attempt < 3; ++attempt) {
xEventGroupClearBits(events, CONNECTED | DISCONNECTED | STOPPED);
err = esp_wifi_start();
if (err != ESP_OK) return err;
started = true;
err = esp_wifi_connect();
if (err == ESP_OK) {
EventBits_t bits = xEventGroupWaitBits(events, CONNECTED | DISCONNECTED,
pdFALSE, pdFALSE, pdMS_TO_TICKS(10000));
if (bits & CONNECTED) return ESP_OK;
err = ESP_ERR_TIMEOUT;
}
esp_err_t stop_err = stop_station();
if (stop_err != ESP_OK) return stop_err;
if (attempt < 2) vTaskDelay(pdMS_TO_TICKS(500));
}
return err;
}
@@ -0,0 +1,9 @@
#pragma once
#include <stdbool.h>
#include "esp_err.h"
// Called by startup before the binding worker is created, then by that worker
// only. Preparing reserves Wi-Fi memory but does not start/associate the radio.
esp_err_t ble_wifi_prepare(void);
esp_err_t ble_wifi_connect(const char *ssid, const char *password);
bool ble_wifi_connected(void);
@@ -0,0 +1,396 @@
// Direct chat: fetch parameters, talk to Volcano, report end. Subtitles come from Volcano.
#include "call_client.h"
#include "firmware_version.h"
#include "../components/volc_conv_ai/src/base/volc_device_manager.h"
#include "ble_cloud.h"
#include "cJSON.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "mbedtls/platform_util.h"
#include "mbedtls/sha256.h"
#include "nvs.h"
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#define AUDIO_PROFILE "echoear-pcm16-16k-v1"
#define FIRMWARE DEVICE_FIRMWARE_VERSION
#define QUEUE_SIZE 12
typedef struct {
unsigned version;
int64_t binding, config;
platform_volc_credential_t credential;
char activation[37], attempt[37], result_id[37], rtc_app[129];
int pending; // 1=contact possible; 2=successful registration saved; 3=unknown
} voice_store_t;
typedef struct {
unsigned version;
char session[37], boot[37], end_id[37], reason[24];
bool finished, failed, sdk_stopped;
} call_store_t;
static voice_store_t voice;
static call_store_t call;
static atomic_bool audio_allowed;
static atomic_uint lease_deadline;
static char *params;
static char bot[201];
static bool boot_loaded, retry_started, engine_ready, sdk_initialized, sdk_stopped;
static bool maintenance;
static atomic_bool standby_mode;
static int64_t renew_due;
static StaticSemaphore_t state_mutex_storage;
static SemaphoreHandle_t state_mutex;
static const char *TAG = "call_client";
static char start_error[80];
const char *call_client_start_error(void) { return start_error; }
static void retry_task(void *unused);
static void finish_pending(void);
static const char *str(const cJSON *j, const char *name) {
const cJSON *v = cJSON_GetObjectItemCaseSensitive(j, name);
return cJSON_IsString(v) ? v->valuestring : "";
}
static int64_t num(const cJSON *j, const char *name) {
const cJSON *v = cJSON_GetObjectItemCaseSensitive(j, name);
return cJSON_IsNumber(v) ? (int64_t)v->valuedouble : -1;
}
static int64_t wall(void) {
struct timeval t;
gettimeofday(&t, NULL);
return (int64_t)t.tv_sec * 1000 + t.tv_usec / 1000;
}
static int64_t mono(void) { return esp_timer_get_time() / 1000; }
static esp_err_t persist(const char *key, const void *data, size_t len) {
nvs_handle_t n;
esp_err_t e = nvs_open("voice", NVS_READWRITE, &n);
if (e != ESP_OK)
return e;
e = data ? nvs_set_blob(n, key, data, len) : nvs_erase_key(n, key);
if (e == ESP_ERR_NVS_NOT_FOUND)
e = ESP_OK;
if (e == ESP_OK)
e = nvs_commit(n);
nvs_close(n);
return e;
}
static void restore(const char *key, void *data, size_t len) {
nvs_handle_t n;
if (nvs_open("voice", NVS_READONLY, &n) != ESP_OK)
return;
size_t size = len;
if (nvs_get_blob(n, key, data, &size) != ESP_OK || size != len)
memset(data, 0, len);
nvs_close(n);
}
// Save the five fields from ble-bind before reporting BLE success. Boot still
// obtains the current binding/config metadata for SDK initialization and ACK.
esp_err_t call_client_save_bind_voice(const cJSON *data) {
voice_store_t saved = {.version=1};
const char *instance=str(data,"instance_id"), *product=str(data,"product_key"),
*name=str(data,"device_name"), *secret=str(data,"device_secret"), *app=str(data,"RTCAppID");
if (!instance[0] || strlen(instance)>=sizeof(saved.credential.instance_id) ||
!product[0] || strlen(product)>=sizeof(saved.credential.product_key) ||
!name[0] || strlen(name)>=sizeof(saved.credential.device_name) ||
strlen(secret)<16 || strlen(secret)>=sizeof(saved.credential.device_secret) ||
!app[0] || strlen(app)>=sizeof(saved.rtc_app)) return ESP_ERR_INVALID_RESPONSE;
snprintf(saved.credential.instance_id,sizeof(saved.credential.instance_id),"%s",instance);
snprintf(saved.credential.product_key,sizeof(saved.credential.product_key),"%s",product);
snprintf(saved.credential.device_name,sizeof(saved.credential.device_name),"%s",name);
snprintf(saved.credential.device_secret,sizeof(saved.credential.device_secret),"%s",secret);
snprintf(saved.rtc_app,sizeof(saved.rtc_app),"%s",app);
esp_err_t err=persist("credential",&saved,sizeof(saved));
mbedtls_platform_zeroize(&saved,sizeof(saved));
return err;
}
static esp_err_t post(const char *path, cJSON *body, const char *rid, cJSON **out) {
char *raw = cJSON_PrintUnformatted(body), *response = NULL;
if (!raw)
return ESP_ERR_NO_MEM;
esp_err_t e = platform_request("POST", path, raw, rid, &response);
// An idle keep-alive connection may have been closed by the gateway. Chat
// requests are keyed by session_id, so retry once on a transport failure.
if (e != ESP_OK && !platform_last_error_code()[0] && strstr(path,"/device/chat/")) {
if (response) { mbedtls_platform_zeroize(response,strlen(response)); free(response); response=NULL; }
e = platform_request("POST",path,raw,rid,&response);
}
mbedtls_platform_zeroize(raw,strlen(raw));
free(raw);
if (e == ESP_OK) {
cJSON *root = cJSON_Parse(response), *data = cJSON_GetObjectItemCaseSensitive(root, "data");
if (!cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root, "ok")) || !cJSON_IsObject(data))
e = ESP_ERR_INVALID_RESPONSE;
else if (out) {
*out = cJSON_Duplicate(data, true);
cJSON *server_time = cJSON_GetObjectItemCaseSensitive(root,"server_time_ms");
if (*out && cJSON_IsNumber(server_time))
cJSON_AddNumberToObject(*out,"_server_time_ms",server_time->valuedouble);
}
cJSON_Delete(root);
}
if (response) {
mbedtls_platform_zeroize(response, strlen(response));
free(response);
}
return e;
}
const platform_volc_credential_t *call_client_credential(void) { return &voice.credential; }
const char *call_client_rtc_app_id(void) { return voice.rtc_app; }
static bool saved_voice_valid(void) {
return voice.version == 1 &&
memchr(voice.credential.instance_id, 0, sizeof(voice.credential.instance_id)) &&
memchr(voice.credential.product_key, 0, sizeof(voice.credential.product_key)) &&
memchr(voice.credential.device_name, 0, sizeof(voice.credential.device_name)) &&
memchr(voice.credential.device_secret, 0, sizeof(voice.credential.device_secret)) &&
memchr(voice.rtc_app, 0, sizeof(voice.rtc_app)) &&
voice.credential.instance_id[0] && voice.credential.product_key[0] &&
voice.credential.device_name[0] && strlen(voice.credential.device_secret) >= 16 &&
voice.rtc_app[0];
}
esp_err_t call_client_boot(void) {
start_error[0] = 0;
if (!state_mutex) state_mutex = xSemaphoreCreateMutexStatic(&state_mutex_storage);
if (!state_mutex) return ESP_ERR_NO_MEM;
atomic_store(&audio_allowed, false);
if (!boot_loaded) {
restore("credential", &voice, sizeof(voice));
restore("direct_call", &call, sizeof(call));
if (call.version != 2 || !memchr(call.session,0,sizeof(call.session)) ||
!memchr(call.end_id,0,sizeof(call.end_id))) memset(&call,0,sizeof(call));
if (call.session[0]) {
call.finished = true; call.failed = true; call.sdk_stopped = false;
snprintf(call.reason,sizeof(call.reason),"reboot");
}
boot_loaded = true;
}
if (ble_cloud_sync_time() != ESP_OK) return ESP_FAIL;
if (call.finished) {
finish_pending();
if (call.session[0]) return ESP_ERR_INVALID_STATE;
}
// A retained credential is not proof of a current household/elder binding.
cJSON *j=cJSON_CreateObject(), *data=NULL;
esp_err_t e=post("/api/v1/device/ble-state",j,NULL,&data);
cJSON_Delete(j);
if (e!=ESP_OK) { cJSON_Delete(data); return e; }
bool bound=cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(data,"bound"));
cJSON_Delete(data); data=NULL;
if (!bound) { snprintf(start_error,sizeof(start_error),"DEVICE_UNBOUND"); return ESP_ERR_INVALID_STATE; }
// Normal boot uses the five fields saved from ble-bind. Recovery only.
if (!saved_voice_valid()) {
j=cJSON_CreateObject();
e=post("/api/v1/device/voice-config",j,NULL,&data); cJSON_Delete(j);
if (e==ESP_OK && !strcmp(str(data,"voice_status"),"ready")) {
e=call_client_save_bind_voice(cJSON_GetObjectItemCaseSensitive(data,"voice"));
} else if(e==ESP_OK) {
snprintf(start_error,sizeof(start_error),"%s",
!strcmp(str(data,"voice_status"),"preparing") ? "VOICE_PREPARING" : "VOICE_RECOVERY_REQUIRED");
e=ESP_ERR_INVALID_STATE;
}
cJSON *secret=cJSON_GetObjectItemCaseSensitive(cJSON_GetObjectItemCaseSensitive(data,"voice"),"device_secret");
if(cJSON_IsString(secret))mbedtls_platform_zeroize(secret->valuestring,strlen(secret->valuestring));
cJSON_Delete(data);
if(e!=ESP_OK)return e;
}
if (!retry_started) {
if(xTaskCreate(retry_task,"call_sync",8192,NULL,4,NULL)!=pdPASS)return ESP_ERR_NO_MEM;
retry_started=true;
}
ESP_LOGI(TAG,"Direct chat ready; firmware=%s credentials=platform-issued",FIRMWARE);
return ESP_OK;
}
void call_client_engine_ready(bool initialized) {
sdk_initialized=initialized;
engine_ready=initialized; // Local SDK initialization, no platform activation ACK.
}
bool call_client_audio_allowed(void) {
if (!atomic_load(&audio_allowed)) return false;
if ((int32_t)(atomic_load(&lease_deadline) - (uint32_t)mono()) <= 0) {
atomic_store(&audio_allowed, false);
return false;
}
return true;
}
bool call_client_is_engine_ready(void) { return engine_ready; }
const char *call_client_bot_id(void) { return bot; }
const char *call_client_params(void) { return params ? params : "{}"; }
// No device transcript queue or event upload: provider callbacks own records.
void call_client_observe(const char *type) { (void)type; }
int volc_platform_recording_prepare(const char *app, const char *room, const char *user) {
if (!app || !room || !user || strlen(app)>128 || strlen(room)>128 || strlen(user)>128 || !state_mutex) return ESP_ERR_INVALID_ARG;
char session[37]={0};
xSemaphoreTake(state_mutex,portMAX_DELAY);
if (call.session[0] && !call.finished && call_client_audio_allowed()) snprintf(session,sizeof(session),"%s",call.session);
xSemaphoreGive(state_mutex);
if (!session[0]) return ESP_ERR_INVALID_STATE;
cJSON *body=cJSON_CreateObject(), *data=NULL;
if (!body) return ESP_ERR_NO_MEM;
cJSON_AddStringToObject(body,"session_id",session);
cJSON_AddStringToObject(body,"device_id",ble_factory_id());
cJSON_AddStringToObject(body,"rtc_app_id",app);
cJSON_AddStringToObject(body,"room_id",room);
cJSON_AddStringToObject(body,"rtc_user_id",user);
esp_err_t result=post("/api/v1/device/chat/recording",body,NULL,&data);
ESP_LOGI(TAG,"Cloud conversation recording: %s",result==ESP_OK ? str(data,"recording") : "unavailable");
cJSON_Delete(data);cJSON_Delete(body);
return result;
}
esp_err_t call_client_turn(const char *speaker, const char *id, const char *text) {
(void)speaker; (void)id; (void)text; return ESP_OK;
}
static esp_err_t fetch_parameters(bool initial) {
int64_t requested_at = mono();
cJSON *j = cJSON_CreateObject(), *data = NULL;
cJSON_AddStringToObject(j, "session_id", call.session);
cJSON_AddStringToObject(j, "device_id", ble_factory_id());
cJSON_AddStringToObject(j, "boot_id", call.boot);
esp_err_t e = post("/api/v1/device/chat/start", j, NULL, &data);
cJSON_Delete(j);
// A slow reply cannot reopen an expired local authorization.
if (!initial && !call_client_audio_allowed()) e = ESP_ERR_TIMEOUT;
if (e == ESP_OK) {
// Compare server timestamps with each other: boot-time clock skew must
// not reject a valid 45-second lease. Subtract the full request duration.
int64_t server_now = num(data,"_server_time_ms");
int64_t lease = num(data,"lease_expires_at_ms") - (server_now > 0 ? server_now : wall());
if (server_now > 0) lease -= mono() - requested_at;
cJSON *p = cJSON_GetObjectItemCaseSensitive(data, "params");
if (strcmp(str(data,"session_id"),call.session) || !cJSON_IsObject(p) ||
lease <= 0 || lease > 45000 || num(data,"expires_at_ms") <= (server_now > 0 ? server_now : wall()) ||
!str(data,"bot_id")[0]) e = ESP_ERR_INVALID_RESPONSE;
else {
if (initial) {
char *encoded = cJSON_PrintUnformatted(p);
if (!encoded || strlen(encoded) > 16384) {
free(encoded); e = ESP_ERR_NO_MEM;
} else {
free(params); params = encoded;
snprintf(bot, sizeof(bot), "%s", str(data,"bot_id"));
}
}
if (e == ESP_OK) {
atomic_store(&lease_deadline, (uint32_t)(mono() + lease));
atomic_store(&audio_allowed, true);
}
}
}
cJSON_Delete(data);
renew_due = mono() + 10000;
if (e != ESP_OK && initial) atomic_store(&audio_allowed, false);
return e;
}
static void finish_pending(void) {
if (!call.session[0] || !call.finished) return;
atomic_store(&audio_allowed, false);
cJSON *j = cJSON_CreateObject();
cJSON_AddStringToObject(j,"session_id",call.session);
cJSON_AddStringToObject(j,"device_id",ble_factory_id());
cJSON_AddStringToObject(j,"reason",call.reason[0] ? call.reason : "disconnected");
cJSON_AddBoolToObject(j,"sdk_stopped",call.sdk_stopped);
esp_err_t e = post("/api/v1/device/chat/end",j,call.end_id,NULL);
cJSON_Delete(j);
if (e == ESP_OK && persist("direct_call",NULL,0) == ESP_OK) memset(&call,0,sizeof(call));
if (params) {
mbedtls_platform_zeroize(params,strlen(params)); free(params); params=NULL;
}
ESP_LOGI(TAG,"chat end acknowledged=%d",e == ESP_OK);
}
esp_err_t call_client_begin(void) {
start_error[0] = '\0';
if (!state_mutex) return ESP_ERR_INVALID_STATE;
if (!engine_ready && sdk_initialized) call_client_engine_ready(true);
if (!engine_ready) return ESP_ERR_INVALID_STATE;
xSemaphoreTake(state_mutex,portMAX_DELAY);
if (call.finished) finish_pending();
if (maintenance || atomic_load(&standby_mode) || call.session[0]) { xSemaphoreGive(state_mutex); return ESP_ERR_INVALID_STATE; }
memset(&call,0,sizeof(call)); call.version=2;
ble_random_uuid(call.session); ble_random_uuid(call.end_id);
snprintf(call.boot,sizeof(call.boot),"%s",ble_cloud_boot_id());
sdk_stopped=false;
esp_err_t e = persist("direct_call",&call,sizeof(call));
if (e == ESP_OK) e=fetch_parameters(true);
if (e != ESP_OK) {
snprintf(start_error,sizeof(start_error),"%s",platform_last_error_code());
call.finished=true; call.failed=true;
snprintf(call.reason,sizeof(call.reason),"start_failed");
persist("direct_call",&call,sizeof(call)); finish_pending();
}
xSemaphoreGive(state_mutex); return e;
}
int call_client_poll(void) {
if (!call_client_audio_allowed()) return 1;
xSemaphoreTake(state_mutex,portMAX_DELAY);
if (mono() >= renew_due) {
esp_err_t e=fetch_parameters(false);
// A known refusal is terminal. Transient transport failures get only
// the remaining previously granted time, never a new local lease.
if (e != ESP_OK) {
const char *code=platform_last_error_code();
if (code && (!strcmp(code,"CHAT_ENDED") || !strcmp(code,"DEVICE_BINDING_CHANGED") ||
!strcmp(code,"VOICE_CHAT_CONSENT_REQUIRED") || !strcmp(code,"DEVICE_NOT_READY") ||
!strcmp(code,"MEMORY_CONSENT_REVOKED") || !strcmp(code,"DEVICE_UNBOUND") ||
!strcmp(code,"VOICE_AGENT_NOT_READY"))) atomic_store(&audio_allowed,false);
}
}
int stopped=!call_client_audio_allowed();
xSemaphoreGive(state_mutex); return stopped;
}
void call_client_stop_result(bool started,int result) { sdk_stopped=started && result==0; }
void call_client_finish(bool failed) {
atomic_store(&audio_allowed,false);
if (!state_mutex) return;
xSemaphoreTake(state_mutex,portMAX_DELAY);
if (call.session[0] && !call.finished) {
call.finished=true; call.failed=failed; call.sdk_stopped=sdk_stopped;
snprintf(call.reason,sizeof(call.reason),"%s",failed ? "disconnected" : "hangup");
persist("direct_call",&call,sizeof(call));
}
finish_pending();
xSemaphoreGive(state_mutex);
}
static void retry_task(void *unused) {
(void)unused;
for (;;) {
vTaskDelay(pdMS_TO_TICKS(10000));
if (xSemaphoreTake(state_mutex,0)==pdTRUE) {
if (call.finished) finish_pending();
xSemaphoreGive(state_mutex);
}
}
}
bool call_client_busy(void) {
if (!state_mutex || xSemaphoreTake(state_mutex,0)!=pdTRUE) return true;
bool busy=maintenance || call.session[0]!=0;
xSemaphoreGive(state_mutex);
return busy;
}
bool call_client_standby(void) { return atomic_load(&standby_mode); }
esp_err_t call_client_set_standby(bool standby) {
if (!state_mutex) { atomic_store(&standby_mode,standby); return ESP_OK; }
xSemaphoreTake(state_mutex,portMAX_DELAY);
if (call.session[0] || !maintenance) { xSemaphoreGive(state_mutex); return ESP_ERR_INVALID_STATE; }
atomic_store(&standby_mode,standby);
xSemaphoreGive(state_mutex);
return ESP_OK;
}
bool call_client_enter_maintenance(void) {
if (!state_mutex || xSemaphoreTake(state_mutex,0)!=pdTRUE) return false;
bool allowed=!maintenance && !call.session[0];
if (allowed) maintenance=true;
xSemaphoreGive(state_mutex);
return allowed;
}
void call_client_leave_maintenance(void) {
if (!state_mutex) return;
xSemaphoreTake(state_mutex,portMAX_DELAY);
maintenance=false;
xSemaphoreGive(state_mutex);
}
@@ -0,0 +1,31 @@
#pragma once
#include "esp_err.h"
#include <stdbool.h>
#include "platform_client.h"
#include "cJSON.h"
esp_err_t call_client_save_bind_voice(const cJSON *data);
esp_err_t platform_request(const char *method,const char *path,const char *body,const char *request_id,char **out);
esp_err_t call_client_boot(void);
const char *call_client_start_error(void);
const platform_volc_credential_t *call_client_credential(void);
const char *call_client_rtc_app_id(void);
void call_client_engine_ready(bool ready);
bool call_client_is_engine_ready(void);
esp_err_t call_client_begin(void);
bool call_client_audio_allowed(void);
const char *call_client_bot_id(void);
const char *call_client_params(void);
int call_client_poll(void);
// Compatibility hooks. Direct chat receives transcripts through provider callbacks;
// these functions never upload text or perform HTTP in an SDK/audio callback.
void call_client_observe(const char *type);
esp_err_t call_client_turn(const char *speaker,const char *id,const char *text);
void call_client_stop_result(bool started,int result);
void call_client_finish(bool failed);
bool call_client_busy(void);
// Atomically exclude new conversations while a disruptive command executes.
bool call_client_enter_maintenance(void);
void call_client_leave_maintenance(void);
bool call_client_standby(void);
// Called before startup, or while the caller owns the maintenance exclusion.
esp_err_t call_client_set_standby(bool standby);
@@ -8,8 +8,12 @@
#include <unistd.h>
#include <time.h>
#include <inttypes.h>
#include <stdatomic.h>
#include "esp_board_manager.h"
#include "dev_lcd_touch_i2c.h"
#include "ble_setup.h"
#include "device_reset.h"
#include "ble_cloud.h"
#include "esp_event.h"
#include "esp_log.h"
@@ -46,13 +50,29 @@
#include "esp_gmf_afe.h"
#include "mmap_generate_eaf.h"
#include "platform_client.h"
#include "call_client.h"
#include "device_control.h"
#include "device_power.h"
#include "device_ota.h"
#include "freertos/idf_additions.h"
#include "hal/usb_serial_jtag_ll.h"
#include "lwip/sockets.h"
#define STATS_TASK_PRIO 5
static const char *TAG = "VolcConvAI";
static volatile bool is_interrupt = false;
static volatile bool conversation_service_ready = false;
extern void conv_ai_service_diagnostic_say(void);
extern void conv_ai_service_diagnostic_tts(void);
extern void volc_rtc_diagnostic_snapshot(void);
/* RTC_PRIORITY_DIAGNOSTIC_BEGIN: the production scheduling floor has no USB
* override. Retired experiment commands acknowledge rejection explicitly. */
static void diagnostic_rtc_priority(bool high)
{
ESP_LOGI(TAG, "RTC_PRIORITY requested_high=%d accepted=0 reason=fixed_floor floor=6", high);
}
/* RTC_PRIORITY_DIAGNOSTIC_END */
/* A local USB diagnostic command lets service tests run without repeated taps.
* It does not run automatically or accept commands over the network. */
@@ -70,6 +90,28 @@ static void usb_diagnostic_task(void *arg)
ESP_LOGI(TAG, "USB diagnostic: requesting fixed spoken sentence");
conv_ai_service_diagnostic_say();
}
if (conversation_service_ready && !strcmp(command, "diag-tts")) {
ESP_LOGI(TAG, "USB diagnostic: requesting fixed TTS passage with microphone uplink suppressed");
conv_ai_service_diagnostic_tts();
}
if (!strcmp(command, "diag-rtc-high")) diagnostic_rtc_priority(true);
if (!strcmp(command, "diag-rtc-normal")) diagnostic_rtc_priority(false);
if (conversation_service_ready && !strcmp(command, "diag-stop")) {
ESP_LOGI(TAG, "USB diagnostic: ending test conversation");
aios_event_pub(VOLC_SERVICE_AI_CONVERSATION_QUIT, NULL, NULL);
}
if (!strcmp(command, "diag-state")) {
unsigned sockets=0;
for(int fd=LWIP_SOCKET_OFFSET;fd<FD_SETSIZE;fd++){
int type;socklen_t length=sizeof(type);
if(getsockopt(fd,SOL_SOCKET,SO_TYPE,&type,&length)==0)sockets++;
}
ESP_LOGI(TAG,"Network diagnostic: sockets=%u limit=%u",sockets,(unsigned)CONFIG_LWIP_MAX_SOCKETS);
ESP_LOGI(TAG,"RTC diagnostic: internal_free=%u largest=%u",
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT),
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT));
volc_rtc_diagnostic_snapshot();
}
length = 0;
} else if (length < sizeof(command) - 1) {
command[length++] = ch;
@@ -91,6 +133,8 @@ void session_init(){
void Task(void* data){
aios_init(VOLC_SERVICE_EVENT_MAX); // AIOS初始化
session_init();
if(call_client_is_engine_ready())device_ota_boot_ready();
else ESP_LOGE(TAG,"Voice engine failed startup; OTA image remains unconfirmed");
conversation_service_ready = true;
xTaskCreate(usb_diagnostic_task, "usb_diagnostic", 3072, NULL, 3, NULL);
aios_run(); // AIOS启动
@@ -165,7 +209,7 @@ static void rec_engine_cb(void *event, void *user_data)
esp_gmf_afe_evt_t * e = (esp_gmf_afe_evt_t *)event;
if (ESP_GMF_AFE_EVT_WAKEUP_START == e->type) {
if (conversation_service_ready) {
if (conversation_service_ready && !call_client_standby()) {
ESP_LOGI(TAG, "Wake word detected; requesting conversation");
volc_hal_context_t *ctx = volc_get_global_hal_context();
if (ctx && ctx->display_handle) {
@@ -179,11 +223,34 @@ static void rec_engine_cb(void *event, void *user_data)
return;
}
/* SCREEN_CALL_TAP_BEGIN: debounce only touch bounce, never impose the old
* five-second start-only guard on a deliberate hangup. */
static void screen_call_tap(TickType_t now)
{
static TickType_t last_tap;
static bool tapped;
if (tapped && (TickType_t)(now - last_tap) < pdMS_TO_TICKS(300)) return;
if (call_client_standby()) {
if (device_control_request_screen_wake()) {
last_tap = now;
tapped = true;
ESP_LOGI(TAG, "Screen tapped; requesting wake without starting a conversation");
}
return;
}
if (aios_event_pub(VOLC_SERVICE_AI_CONVERSATION_TOGGLE, NULL, NULL) == 0) {
last_tap = now;
tapped = true;
ESP_LOGI(TAG, "Screen tapped; toggling conversation");
}
}
/* SCREEN_CALL_TAP_END */
static void wifi_setup_touch_task(void *arg)
{
dev_lcd_touch_i2c_handles_t *touch = arg;
TickType_t pressed_at = 0;
TickType_t last_conversation_request = 0;
TickType_t last_setup_tap = 0;
uint16_t pressed_x = 0;
uint16_t pressed_y = 0;
bool holding = false;
@@ -199,25 +266,30 @@ static void wifi_setup_touch_task(void *arg)
TickType_t held_for = xTaskGetTickCount() - pressed_at;
ESP_LOGI(TAG, "Touch released at (%u,%u) after %u ms",
pressed_x, pressed_y, (unsigned)(held_for * portTICK_PERIOD_MS));
if (held_for >= pdMS_TO_TICKS(50) && held_for < pdMS_TO_TICKS(1500) && ble_setup_confirm()) {
holding = false;
vTaskDelay(pdMS_TO_TICKS(50));
continue;
}
// A double tap is discoverable and cannot be confused with the
// physical power-key long press. Never interrupt a voice call.
if (!conversation_service_ready && ble_cloud_configured() &&
held_for >= pdMS_TO_TICKS(50) && held_for < pdMS_TO_TICKS(600)) {
TickType_t now = xTaskGetTickCount();
if (last_setup_tap && now-last_setup_tap < pdMS_TO_TICKS(800)) {
nvs_handle_t setup_nvs;
if (nvs_open("wifi",NVS_READWRITE,&setup_nvs)==ESP_OK) {
esp_err_t err=nvs_set_u8(setup_nvs,"setup_requested",1);
if(err==ESP_OK)err=nvs_commit(setup_nvs);
nvs_close(setup_nvs);
if(err==ESP_OK)esp_restart();
}
}
last_setup_tap=now;
}
if (held_for >= pdMS_TO_TICKS(50) &&
held_for < pdMS_TO_TICKS(1500) && conversation_service_ready) {
TickType_t now = xTaskGetTickCount();
if (last_conversation_request != 0 &&
now - last_conversation_request < pdMS_TO_TICKS(5000)) {
ESP_LOGI(TAG, "Ignoring repeated conversation tap");
holding = false;
vTaskDelay(pdMS_TO_TICKS(50));
continue;
}
last_conversation_request = now;
ESP_LOGI(TAG, "Screen tapped; requesting conversation");
volc_hal_context_t *ctx = volc_get_global_hal_context();
if (ctx && ctx->display_handle) {
int index = MMAP_EAF_EMOTION_BLINK_FAST_284_126_EAF;
volc_hal_display_set_content(ctx->display_handle, VOLC_DISPLAY_OBJ_MAIN,
VOLC_DISPLAY_IMAGE, &index);
}
aios_event_pub(VOLC_SERVICE_AI_CONVERSATION, NULL, NULL);
screen_call_tap(xTaskGetTickCount());
}
}
holding = false;
@@ -280,6 +352,16 @@ void hal_level_init()
// vTaskDelay(pdMS_TO_TICKS(2000));
}
static void application_network_start(volc_hal_display_t global_display);
static void application_network_boot_task(void *arg)
{
application_network_start((volc_hal_display_t)arg);
ESP_LOGI(TAG, "Network startup finished; stack headroom: %u bytes",
(unsigned)uxTaskGetStackHighWaterMark(NULL));
vTaskDelete(NULL);
}
static void application_boot(void)
{
// xTaskCreate(&sys_monitor_task, "sys_monitor_task", 4096, NULL, 5, NULL);
@@ -302,14 +384,33 @@ static void application_boot(void)
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND)
{
ESP_LOGW(TAG, "Erasing NVS flash to fix corruption");
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
ESP_LOGE(TAG, "NVS requires recovery; preserving Wi-Fi and device credentials");
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_SUBTITLE,
VOLC_DISPLAY_TEXT, "Storage recovery required");
return;
}
ESP_ERROR_CHECK(ret);
if (device_reset_boot_cleanup()!=ESP_OK) {
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_SUBTITLE,VOLC_DISPLAY_TEXT,"Reset recovery required");
return;
}
device_ota_boot_guard();
ESP_ERROR_CHECK(platform_client_init());
ESP_ERROR_CHECK(esp_netif_init());
wifi_setup_button_init();
if (ble_setup_start_if_needed()) return;
// TLS/audio startup needs a large stack, but BLE provisioning does not.
// Allocate it only for normal operation, after choosing the boot mode.
if (xTaskCreate(application_network_boot_task, "network_boot", 24 * 1024,
global_display, 5, NULL) != pdPASS) {
ESP_LOGE(TAG, "Cannot allocate network startup task");
}
}
static void application_network_start(volc_hal_display_t global_display)
{
esp_err_t ret;
if(device_power_boot()!=ESP_OK){ESP_LOGE(TAG,"Standby state restore failed; preserving microphone gate");return;}
uint8_t station_mac[6] = {0};
char setup_status[96] = "等待配网";
@@ -324,6 +425,15 @@ static void application_boot(void)
bool connected = configure_network();
if (connected == false)
{
if(network_last_error()!=ESP_OK){
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"网络初始化失败,请重启");
return;
}
if (ble_cloud_configured()) {
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"旧 Wi-Fi 不可用,正在开启蓝牙配网");
esp_restart();
return;
}
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_STATUS,
VOLC_DISPLAY_TEXT, setup_status);
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_SUBTITLE,
@@ -333,6 +443,42 @@ static void application_boot(void)
}
initialize_sntp();
wait_for_time_sync();
if (ble_cloud_configured()) {
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"正在同步语音服务");
ret=call_client_boot();
while(ret!=ESP_OK && !strcmp(call_client_start_error(),"VOICE_PREPARING")) {
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"平台正在准备语音服务");
vTaskDelay(pdMS_TO_TICKS(5000));
ret=call_client_boot();
}
if(ret!=ESP_OK){
const char *message="语音服务未就绪,请稍后重启";
if(!strcmp(platform_last_error_code(),"PROVIDER_PROFILE_UNVERIFIED"))message="语音配置待验证";
if(!strcmp(platform_last_error_code(),"DEVICE_UNBOUND") || !strcmp(call_client_start_error(),"DEVICE_UNBOUND")) {
message="请先在小程序绑定设备";
// Platform unbind invalidates cached credentials. Restart once
// into BLE setup; keep factory identity and Wi-Fi unchanged.
nvs_handle_t setup_nvs;
if(nvs_open("wifi",NVS_READWRITE,&setup_nvs)==ESP_OK) {
esp_err_t saved=nvs_set_u8(setup_nvs,"setup_requested",1);
if(saved==ESP_OK)saved=nvs_commit(setup_nvs);
nvs_close(setup_nvs);
if(saved==ESP_OK) {
ESP_LOGI(TAG,"Device unbound; entering BLE setup");
esp_restart();
}
}
}
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,message);
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_SUBTITLE,VOLC_DISPLAY_TEXT,"双击屏幕重新配网");
return;
}
hal_level_init();
if(xTaskCreateWithCaps(&Task,"aios_task",12288,NULL,5,NULL,MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT)!=pdPASS)
ESP_LOGE(TAG,"Cannot allocate AI service task");
if(device_control_start()!=ESP_OK)ESP_LOGE(TAG,"Device control worker unavailable");
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"轻触屏幕开始聊天");return;
}
if (!platform_identity_is_configured()) {
ESP_LOGE(TAG, "Platform identity is missing; erase Wi-Fi NVS or use the setup portal");
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_STATUS, VOLC_DISPLAY_TEXT,
@@ -361,14 +507,15 @@ static void application_boot(void)
}
hal_level_init();
ESP_LOGI(TAG, "Startup stack headroom after audio: %u bytes", (unsigned)uxTaskGetStackHighWaterMark(NULL));
xTaskCreate(&Task, "aios_task", 8192, NULL, 5, NULL);
if(xTaskCreateWithCaps(&Task,"aios_task",12288,NULL,5,NULL,MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT)!=pdPASS)
ESP_LOGE(TAG,"Cannot allocate AI service task");
int index = MMAP_EAF_HAPPY_EAF;
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_MAIN,VOLC_DISPLAY_IMAGE,&index);
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"轻触屏幕或说嗨,乐鑫");
}
/* Network signing/TLS and audio startup exceed the default 4 KiB main stack.
* Keep this memory available only during startup and free it on every exit. */
/* Board/setup startup keeps internal RAM available for BLE and LCD DMA.
* Network signing/TLS gets its separate larger stack only in voice mode. */
static void application_boot_task(void *arg)
{
application_boot();
@@ -379,7 +526,7 @@ static void application_boot_task(void *arg)
void app_main(void)
{
if (xTaskCreate(application_boot_task, "platform_boot", 24 * 1024,
if (xTaskCreate(application_boot_task, "platform_boot", 8 * 1024,
NULL, 5, NULL) != pdPASS) {
ESP_LOGE(TAG, "Cannot allocate platform startup task");
}
@@ -0,0 +1,322 @@
#include "device_control.h"
#include "device_ota.h"
#include "device_power.h"
#include "device_screen_wake.h"
#include "ble_cloud.h"
#include "call_client.h"
#include "firmware_version.h"
#include "cJSON.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/idf_additions.h"
#include "mqtt_client.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "volc_hal.h"
#include "volc_hal_player.h"
#include "mbedtls/platform_util.h"
#include <stdatomic.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
static const char *TAG="device_control";
static esp_mqtt_client_handle_t mqtt;
static atomic_bool connected;
static atomic_bool stopping;
static char status_topic[180], ota_topic[180];
static int64_t applied_revision;
static char device_display_name[97];
static bool started;
static TaskHandle_t control_worker;
static const char *text(const cJSON *j,const char *key) {
const cJSON *v=cJSON_GetObjectItemCaseSensitive(j,key);
return cJSON_IsString(v)?v->valuestring:"";
}
static void secret_free(char *s) {
if(s){mbedtls_platform_zeroize(s,strlen(s));free(s);}
}
static esp_err_t post(const char *path,cJSON *body,cJSON **data) {
char *raw=cJSON_PrintUnformatted(body),*response=NULL;
if(!raw)return ESP_ERR_NO_MEM;
esp_err_t err=platform_request("POST",path,raw,NULL,&response);
secret_free(raw);
if(err==ESP_OK) {
cJSON *root=response?cJSON_Parse(response):NULL;
const cJSON *value=cJSON_GetObjectItemCaseSensitive(root,"data");
if(!cJSON_IsObject(value))err=ESP_ERR_INVALID_RESPONSE;
else if(data){*data=cJSON_Duplicate(value,true);if(!*data)err=ESP_ERR_NO_MEM;}
cJSON_Delete(root);
}
secret_free(response);
return err;
}
static esp_err_t save_result(const char *result) {
nvs_handle_t n;
esp_err_t err=nvs_open("control",NVS_READWRITE,&n);
if(err!=ESP_OK)return err;
err=result?nvs_set_str(n,"result",result):nvs_erase_key(n,"result");
if(err==ESP_ERR_NVS_NOT_FOUND)err=ESP_OK;
if(err==ESP_OK)err=nvs_commit(n);
nvs_close(n);return err;
}
// Persist terminal receipts before sending them. Commands are delivered at
// least once; an offline receipt must not cause another reboot on redelivery.
static bool flush_result(void) {
nvs_handle_t n;
esp_err_t err=nvs_open("control",NVS_READONLY,&n);
// Only an absent namespace means no pending receipt. I/O failures must
// block fetching another command, or a pending reboot may run again.
if(err==ESP_ERR_NVS_NOT_FOUND)return true;
if(err!=ESP_OK)return false;
size_t len=0;err=nvs_get_str(n,"result",NULL,&len);
if(err==ESP_ERR_NVS_NOT_FOUND){nvs_close(n);return true;}
if(err!=ESP_OK || len<2 || len>4096){nvs_close(n);return false;}
char *raw=malloc(len);
if(!raw){nvs_close(n);return false;}
err=nvs_get_str(n,"result",raw,&len);nvs_close(n);
cJSON *j=err==ESP_OK?cJSON_ParseWithLength(raw,len):NULL;free(raw);
if(!j)return false;
// A reboot succeeds only after the boot ID changes, never before restart.
const char *boot=text(j,"_reboot_from");
if(boot[0] && !strcmp(boot,ble_cloud_boot_id())){cJSON_Delete(j);return false;}
cJSON_DeleteItemFromObjectCaseSensitive(j,"_reboot_from");
err=post("/api/v1/device/control/ack",j,NULL);cJSON_Delete(j);
if(err==ESP_OK || !strcmp(platform_last_error_code(),"DEVICE_COMMAND_STATE_CONFLICT"))
return save_result(NULL)==ESP_OK;
return false;
}
static esp_err_t ack(const char *id,const char *status,cJSON *result,bool reboot) {
cJSON *j=cJSON_CreateObject();
cJSON_AddStringToObject(j,"command_id",id);cJSON_AddStringToObject(j,"status",status);
cJSON_AddItemToObject(j,"result_summary",result?cJSON_Duplicate(result,true):cJSON_CreateObject());
esp_err_t err;
if(!strcmp(status,"ACKNOWLEDGED"))err=post("/api/v1/device/control/ack",j,NULL);
else {
if(reboot)cJSON_AddStringToObject(j,"_reboot_from",ble_cloud_boot_id());
char *raw=cJSON_PrintUnformatted(j);err=raw?save_result(raw):ESP_ERR_NO_MEM;free(raw);
if(err==ESP_OK && !reboot)flush_result();
}
cJSON_Delete(j);return err;
}
static esp_err_t apply_config(const cJSON *config,bool maintenance_held) {
cJSON *revision=cJSON_GetObjectItemCaseSensitive(config,"revision");
cJSON *desired=cJSON_GetObjectItemCaseSensitive(config,"desired_config");
if(!cJSON_IsNumber(revision) || revision->valuedouble<1 || !cJSON_IsObject(desired))return ESP_ERR_INVALID_RESPONSE;
const cJSON *category=NULL;
cJSON_ArrayForEach(category,desired) {
// Do not claim that arbitrary categories were applied by this firmware.
if(!strcmp(category->string,"voice_runtime") && cJSON_IsString(category) &&
!strcmp(category->valuestring,"volc_hardware_agent"))continue;
if(strcmp(category->string,"volume") && strcmp(category->string,"identity") && strcmp(category->string,"power"))return ESP_ERR_NOT_SUPPORTED;
}
const cJSON *power=cJSON_GetObjectItemCaseSensitive(desired,"power");
const cJSON *mode=cJSON_GetObjectItemCaseSensitive(power,"mode");
if(power && (!cJSON_IsObject(power) || cJSON_GetArraySize(power)!=1 || !cJSON_IsString(mode) || (strcmp(mode->valuestring,"active") && strcmp(mode->valuestring,"standby"))))return ESP_ERR_INVALID_RESPONSE;
const cJSON *identity=cJSON_GetObjectItemCaseSensitive(desired,"identity");
const cJSON *name=cJSON_GetObjectItemCaseSensitive(identity,"display_name");
if(identity) {
if(!cJSON_IsObject(identity) || cJSON_GetArraySize(identity)!=1 || !cJSON_IsString(name))return ESP_ERR_INVALID_RESPONSE;
size_t len=strlen(name->valuestring);
if(len>=sizeof(device_display_name) || (len && (name->valuestring[0]==' ' || name->valuestring[len-1]==' ')))return ESP_ERR_INVALID_RESPONSE;
for(size_t i=0;i<len;i++)if((unsigned char)name->valuestring[i]<32 || (unsigned char)name->valuestring[i]==127)return ESP_ERR_INVALID_RESPONSE;
}
cJSON *volume=cJSON_GetObjectItemCaseSensitive(desired,"volume");
cJSON *speaker=cJSON_GetObjectItemCaseSensitive(volume,"speaker");
if(volume) {
if(!cJSON_IsNumber(speaker) || speaker->valuedouble!=speaker->valueint || speaker->valueint<0 || speaker->valueint>100)return ESP_ERR_INVALID_RESPONSE;
if(volc_hal_get_device_volume()!=speaker->valueint){
if(volc_hal_set_device_volume(speaker->valueint)!=ESP_OK)return ESP_FAIL;
}
nvs_handle_t n;
if(nvs_open("control",NVS_READWRITE,&n)!=ESP_OK)return ESP_FAIL;
int32_t stored=-1;nvs_get_i32(n,"speaker",&stored);
esp_err_t e=ESP_OK;
if(stored!=speaker->valueint){e=nvs_set_i32(n,"speaker",speaker->valueint);if(e==ESP_OK)e=nvs_commit(n);}
nvs_close(n);if(e!=ESP_OK)return e;
}
if(identity && strcmp(device_display_name,name->valuestring)) {
nvs_handle_t n;
if(nvs_open("control",NVS_READWRITE,&n)!=ESP_OK)return ESP_FAIL;
esp_err_t e=nvs_set_str(n,"display_name",name->valuestring);
if(e==ESP_OK)e=nvs_commit(n);
char readback[sizeof(device_display_name)];size_t size=sizeof(readback);
if(e==ESP_OK)e=nvs_get_str(n,"display_name",readback,&size);
nvs_close(n);
if(e!=ESP_OK || strcmp(readback,name->valuestring))return ESP_FAIL;
memcpy(device_display_name,readback,size);
}
if(power && device_power_apply(mode->valuestring,maintenance_held)!=ESP_OK)return ESP_FAIL;
applied_revision=(int64_t)revision->valuedouble;
return ESP_OK;
}
static cJSON *diagnostics(void) {
cJSON *j=cJSON_CreateObject(),*audio=cJSON_AddObjectToObject(j,"audio"),
*network=cJSON_AddObjectToObject(j,"network"),*storage=cJSON_AddObjectToObject(j,"storage");
int volume=volc_hal_get_device_volume();
cJSON_AddBoolToObject(audio,"codec_ready",volume>=0);
cJSON_AddNumberToObject(audio,"speaker",volume);
cJSON_AddStringToObject(audio,"scope","codec state; microphone and speaker listening test requires user");
wifi_ap_record_t ap={0};bool online=esp_wifi_sta_get_ap_info(&ap)==ESP_OK;
cJSON_AddBoolToObject(network,"connected",online);
if(online)cJSON_AddNumberToObject(network,"rssi",ap.rssi);
nvs_stats_t stats={0};esp_err_t e=nvs_get_stats(NULL,&stats);
cJSON_AddBoolToObject(storage,"nvs_readable",e==ESP_OK);
if(e==ESP_OK)cJSON_AddNumberToObject(storage,"free_entries",stats.free_entries);
// Check an actual write/commit/read path in our own namespace only.
nvs_handle_t n;bool write_ok=false;uint32_t readback=0,nonce=esp_random();
if(nvs_open("control",NVS_READWRITE,&n)==ESP_OK) {
write_ok=nvs_set_u32(n,"probe",nonce)==ESP_OK && nvs_commit(n)==ESP_OK &&
nvs_get_u32(n,"probe",&readback)==ESP_OK && readback==nonce;
nvs_erase_key(n,"probe");nvs_commit(n);nvs_close(n);
}
cJSON_AddBoolToObject(storage,"write_verified",write_ok);
cJSON_AddItemToObject(j,"power",device_power_report());
cJSON_AddNumberToObject(j,"internal_heap_free",heap_caps_get_free_size(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT));
cJSON_AddNumberToObject(j,"uptime_ms",esp_timer_get_time()/1000);
return j;
}
static void command(const cJSON *cmd,const cJSON *config) {
const char *id=text(cmd,"id"),*type=text(cmd,"type");
if(strlen(id)!=36 || !type[0])return;
if(!call_client_enter_maintenance())return;
if(ack(id,"ACKNOWLEDGED",NULL,false)!=ESP_OK){call_client_leave_maintenance();return;}
cJSON *result=NULL;bool success=false;
if(!strcmp(type,"reboot")) {
if(ack(id,"SUCCEEDED",NULL,true)==ESP_OK){device_control_prepare_restart();vTaskDelay(pdMS_TO_TICKS(100));esp_restart();}
} else if(!strcmp(type,"diagnostics")) { result=diagnostics();success=result!=NULL; }
else if(!strcmp(type,"apply_config"))success=apply_config(config,true)==ESP_OK;
if(!result) {
result=cJSON_CreateObject();
cJSON_AddStringToObject(result,"message",success?"Configuration applied":"Command unsupported or hardware operation failed");
}
ack(id,success?"SUCCEEDED":"FAILED",result,false);cJSON_Delete(result);
call_client_leave_maintenance();
}
void device_control_prepare_restart(void) {
atomic_store(&stopping,true);
atomic_store(&connected,false);
// stop sends MQTT DISCONNECT and closes the transport before returning.
// Otherwise a fast reboot can leave stale presence until the next session.
if(mqtt && esp_mqtt_client_stop(mqtt)!=ESP_OK)
ESP_LOGW(TAG,"MQTT shutdown failed; presence will expire normally");
}
void device_control_heartbeat(void) {
if(!mqtt || atomic_load(&stopping) || !atomic_load(&connected))return;
cJSON *j=cJSON_CreateObject();
cJSON_AddBoolToObject(j,"online",true);
cJSON_AddStringToObject(j,"firmware_version",device_ota_version());
cJSON_AddNumberToObject(j,"version_code",device_ota_version_code());
cJSON_AddBoolToObject(j,"ota_supported",device_ota_supported());
char *raw=cJSON_PrintUnformatted(j);
if(raw){esp_mqtt_client_enqueue(mqtt,status_topic,raw,0,1,0,false);free(raw);}
cJSON_Delete(j);
}
static void mqtt_event(void *arg,esp_event_base_t base,int32_t id,void *event_data) {
static char incoming[1536];
static size_t used,expected;
esp_mqtt_event_handle_t e=event_data;
if(id==MQTT_EVENT_CONNECTED){esp_mqtt_client_subscribe(e->client,ota_topic,1);}
else if(id==MQTT_EVENT_SUBSCRIBED){atomic_store(&connected,true);device_control_heartbeat();}
else if(id==MQTT_EVENT_DISCONNECTED)atomic_store(&connected,false);
else if(id==MQTT_EVENT_DATA){
if(e->current_data_offset==0){
used=expected=0;
if(e->topic_len!=(int)strlen(ota_topic) || memcmp(e->topic,ota_topic,e->topic_len) ||
e->total_data_len<1 || e->total_data_len>=sizeof(incoming))return;
expected=e->total_data_len;
}
if(!expected || e->current_data_offset!=(int)used || e->data_len<0 || used+e->data_len>expected){used=expected=0;return;}
memcpy(incoming+used,e->data,e->data_len);used+=e->data_len;
if(used==expected){incoming[used]=0;cJSON *j=cJSON_Parse(incoming);
device_ota_request(text(j,"task_id"));cJSON_Delete(j);used=expected=0;}
}
}
static esp_err_t start_mqtt(void) {
cJSON *body=cJSON_CreateObject(),*j=NULL;
esp_err_t err=post("/api/v1/device/mqtt-config",body,&j);cJSON_Delete(body);
if(err!=ESP_OK)return err;
const char *broker=text(j,"broker_url"),*client=text(j,"client_id"),*user=text(j,"username"),*password=text(j,"password");
if(!broker[0] || !client[0] || !password[0] || strlen(text(j,"status_topic"))>=sizeof(status_topic) || strlen(text(j,"ota_topic"))>=sizeof(ota_topic)) {cJSON_Delete(j);return ESP_ERR_INVALID_RESPONSE;}
snprintf(status_topic,sizeof(status_topic),"%s",text(j,"status_topic"));
snprintf(ota_topic,sizeof(ota_topic),"%s",text(j,"ota_topic"));
esp_mqtt_client_config_t cfg={.broker.address.uri=broker,
.credentials.client_id=client,.credentials.username=user,.credentials.authentication.password=password,
.session.keepalive=60,.session.last_will={.topic=status_topic,.msg="{\"online\":false}",.qos=1,.retain=0},
.task.stack_size=4096,.buffer.size=2048,.buffer.out_size=512,.outbox.limit=4096};
mqtt=esp_mqtt_client_init(&cfg);
if(password[0])mbedtls_platform_zeroize((char*)password,strlen(password));
cJSON_Delete(j);
if(!mqtt)return ESP_ERR_NO_MEM;
esp_mqtt_client_register_event(mqtt,ESP_EVENT_ANY_ID,mqtt_event,NULL);
err=esp_mqtt_client_start(mqtt);
if(err!=ESP_OK){esp_mqtt_client_destroy(mqtt);mqtt=NULL;}
return err;
}
static void control_task(void *arg) {
int64_t mqtt_due=0,heartbeat_due=0,diagnostic_due=0;
for(;;) {
int64_t now=esp_timer_get_time()/1000;
if (device_screen_wake_poll(applied_revision) != ESP_OK)
ESP_LOGW(TAG, "Screen wake pending; local hardware/storage operation will retry");
if(!mqtt && now>=mqtt_due){start_mqtt();mqtt_due=now+30000;}
if(now>=heartbeat_due){device_control_heartbeat();heartbeat_due=now+30000;}
device_ota_poll();
int speaker=volc_hal_get_device_volume();
if(now>=diagnostic_due){
ESP_LOGI(TAG,"state mqtt=%d speaker=%d revision=%lld ota=%d internal_free=%u",
atomic_load(&connected),speaker,(long long)applied_revision,device_ota_supported(),
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT));
diagnostic_due=now+30000;
}
wifi_ap_record_t ap={0};
if(speaker>=0 && esp_wifi_sta_get_ap_info(&ap)==ESP_OK && flush_result()) {
cJSON *j=cJSON_CreateObject(),*data=NULL;
cJSON_AddStringToObject(j,"firmware_version",device_ota_version());
cJSON_AddNumberToObject(j,"reported_revision",applied_revision);
cJSON_AddNumberToObject(j,"speaker",speaker);
cJSON_AddStringToObject(j,"display_name",device_display_name);
cJSON_AddItemToObject(j,"power",device_power_report());
cJSON_AddNumberToObject(j,"rssi",ap.rssi);
cJSON_AddBoolToObject(j,"busy",call_client_busy());
device_screen_wake_add_report(j);
esp_err_t e=post("/api/v1/device/control/sync",j,&data);cJSON_Delete(j);
if(e==ESP_OK && device_screen_wake_ack(data)==ESP_OK) {
cJSON *config=cJSON_GetObjectItemCaseSensitive(data,"configuration");
apply_config(config,false);
cJSON *commands=cJSON_GetObjectItemCaseSensitive(data,"commands");
const cJSON *item=cJSON_GetArrayItem(commands,0);
if(item)command(item,config);
} else if(!strcmp(platform_last_error_code(),"CONFIG_REVISION_CONFLICT"))applied_revision=0;
cJSON_Delete(data);
}
ulTaskNotifyTake(pdTRUE,pdMS_TO_TICKS(5000));
}
}
esp_err_t device_control_start(void) {
if(started)return ESP_OK;
esp_err_t wake_init = device_screen_wake_init();
if (wake_init != ESP_OK) return wake_init;
nvs_handle_t n;
if(nvs_open("control",NVS_READONLY,&n)==ESP_OK){
int32_t volume;
if(nvs_get_i32(n,"speaker",&volume)==ESP_OK && volume>=0 && volume<=100)volc_hal_set_device_volume(volume);
size_t size=sizeof(device_display_name);
if(nvs_get_str(n,"display_name",device_display_name,&size)!=ESP_OK)device_display_name[0]=0;
nvs_close(n);
}
if(xTaskCreateWithCaps(control_task,"device_control",12288,NULL,3,&control_worker,MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT)!=pdPASS)return ESP_ERR_NO_MEM;
started=true;ESP_LOGI(TAG,"Device control worker started");return ESP_OK;
}
bool device_control_request_screen_wake(void) {
if (!started || !control_worker || atomic_load(&stopping)) return false;
device_screen_wake_request();
xTaskNotifyGive(control_worker);
return true;
}
@@ -0,0 +1,10 @@
#pragma once
#include "esp_err.h"
#include <stdbool.h>
// Starts after network and factory identity initialization. Uses a PSRAM stack.
esp_err_t device_control_start(void);
void device_control_heartbeat(void);
// Call from an application task, never from the MQTT event handler.
void device_control_prepare_restart(void);
// The touch task queues a wake; the control worker restores power and syncs it.
bool device_control_request_screen_wake(void);
@@ -0,0 +1,385 @@
#include "device_ota.h"
#include "device_control.h"
#include "ble_cloud.h"
#include "call_client.h"
#include "firmware_version.h"
#include "cJSON.h"
#include "esp_app_desc.h"
#include "esp_crt_bundle.h"
#include "esp_http_client.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_partition.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"
#include "freertos/idf_additions.h"
#include "mbedtls/sha256.h"
#include "mbedtls/platform_util.h"
#include "nvs.h"
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/time.h>
#include <math.h>
typedef struct {
uint32_t format,phase,address,size; // 1=download,2=boot pending,3=success,4=failed,0=reported
int64_t code;
char id[37],version[81],sha[65],message[100];
} ota_state_t;
typedef struct {uint32_t format,address;int64_t code;char version[81];} installed_t;
static ota_state_t state;
static installed_t installed;
static esp_timer_handle_t boot_timer;
static QueueHandle_t requests;
static StaticQueue_t queue_state;
static uint8_t queue_storage[37];
static bool ready;
static bool storage_ready;
static char retry_id[37];
static unsigned manifest_retries;
typedef enum {OTA_FLASH_END,OTA_FLASH_BOOT,OTA_FLASH_ROLLBACK} ota_flash_op_t;
static esp_err_t ota_internal_flash_call(ota_flash_op_t op,esp_ota_handle_t handle,const esp_partition_t *target,bool *completed);
static const char *TAG="device_ota";
static const char *str(const cJSON *j,const char *k){cJSON *v=cJSON_GetObjectItemCaseSensitive(j,k);return cJSON_IsString(v)?v->valuestring:"";}
static int64_t number(const cJSON *j,const char *k){
cJSON *v=cJSON_GetObjectItemCaseSensitive(j,k);
if(!cJSON_IsNumber(v) || !isfinite(v->valuedouble) || v->valuedouble<0 ||
v->valuedouble>9007199254740991.0 || floor(v->valuedouble)!=v->valuedouble)return -1;
return (int64_t)v->valuedouble;
}
static void wipe(char *v){if(v){mbedtls_platform_zeroize(v,strlen(v));free(v);}}
static esp_err_t post(const char *path,cJSON *j,cJSON **data){
char *body=cJSON_PrintUnformatted(j),*raw=NULL;if(!body)return ESP_ERR_NO_MEM;
esp_err_t e=platform_request("POST",path,body,NULL,&raw);wipe(body);
if(e==ESP_OK){cJSON *root=raw?cJSON_Parse(raw):NULL;const cJSON *v=cJSON_GetObjectItemCaseSensitive(root,"data");
if(!cJSON_IsObject(v))e=ESP_ERR_INVALID_RESPONSE;
else if(data){*data=cJSON_Duplicate(v,true);if(!*data)e=ESP_ERR_NO_MEM;}
cJSON_Delete(root);
}wipe(raw);return e;
}
static esp_err_t persist(const char *key,const void *data,size_t size){
nvs_handle_t n;esp_err_t e=nvs_open("control",NVS_READWRITE,&n);if(e!=ESP_OK)return e;
e=nvs_set_blob(n,key,data,size);if(e==ESP_OK)e=nvs_commit(n);nvs_close(n);return e;
}
static esp_err_t load_result(const char *key,void *data,size_t size){
nvs_handle_t n;esp_err_t e=nvs_open("control",NVS_READONLY,&n);if(e!=ESP_OK)return e;
size_t actual=size;e=nvs_get_blob(n,key,data,&actual);nvs_close(n);
return e==ESP_OK && actual!=size?ESP_ERR_INVALID_SIZE:e;
}
static esp_err_t save(void){return persist("ota",&state,sizeof(state));}
static bool installed_key(const esp_partition_t *part,char key[16]){
if(!part || part->subtype<ESP_PARTITION_SUBTYPE_APP_OTA_MIN || part->subtype>ESP_PARTITION_SUBTYPE_APP_OTA_MAX)return false;
snprintf(key,16,"installed%u",(unsigned)(part->subtype-ESP_PARTITION_SUBTYPE_APP_OTA_MIN));return true;
}
static bool load_installed(const esp_partition_t *running){
char key[16];memset(&installed,0,sizeof(installed));if(!installed_key(running,key))return false;
esp_err_t err=load_result(key,&installed,sizeof(installed));
// Keep the old single-slot record intact: an older fallback image still
// reads it. New images record each slot separately before boot confirmation.
bool legacy=err==ESP_ERR_NVS_NOT_FOUND;
if(legacy)err=load_result("installed",&installed,sizeof(installed));
if(err==ESP_ERR_NVS_NOT_FOUND){memset(&installed,0,sizeof(installed));return true;}
if(err!=ESP_OK || installed.format!=1 || installed.code<=0 || installed.code>9007199254740991LL ||
!installed.version[0] || !memchr(installed.version,0,sizeof(installed.version))){memset(&installed,0,sizeof(installed));return false;}
if(installed.address!=running->address){memset(&installed,0,sizeof(installed));return legacy;}
return true;
}
static void hash_hex(const uint8_t hash[32],char hex[65]){for(int i=0;i<32;i++)sprintf(hex+2*i,"%02x",hash[i]);hex[64]=0;}
static bool flash_matches(const esp_partition_t *part){
if(!part || state.size<1024 || state.size>part->size)return false;
unsigned char *buffer=malloc(4096),hash[32]={0};if(!buffer)return false;
mbedtls_sha256_context sha;mbedtls_sha256_init(&sha);bool ok=mbedtls_sha256_starts(&sha,0)==0;
for(uint32_t offset=0;ok && offset<state.size;offset+=4096){
size_t n=state.size-offset;if(n>4096)n=4096;
ok=esp_partition_read(part,offset,buffer,n)==ESP_OK && mbedtls_sha256_update(&sha,buffer,n)==0;
}
if(ok)ok=mbedtls_sha256_finish(&sha,hash)==0;
mbedtls_sha256_free(&sha);free(buffer);char hex[65];hash_hex(hash,hex);return ok && !strcmp(hex,state.sha);
}
static void boot_timeout(void *arg){ESP_LOGE(TAG,"New firmware health deadline expired; restarting for rollback");esp_restart();}
void device_ota_boot_guard(void){
ready=false;storage_ready=true;
retry_id[0]=0;manifest_retries=0;
requests=xQueueCreateStatic(1,37,queue_storage,&queue_state);
esp_err_t journal=load_result("ota",&state,sizeof(state));
if(journal==ESP_ERR_NVS_NOT_FOUND)memset(&state,0,sizeof(state));
else if(journal!=ESP_OK || state.format!=1 || state.phase>4 || !memchr(state.id,0,sizeof(state.id)) ||
!memchr(state.version,0,sizeof(state.version)) || !memchr(state.sha,0,sizeof(state.sha)) || !memchr(state.message,0,sizeof(state.message))){memset(&state,0,sizeof(state));storage_ready=false;}
const esp_partition_t *running=esp_ota_get_running_partition();
if(!load_installed(running))storage_ready=false;
esp_ota_img_states_t image;
if(running && esp_ota_get_state_partition(running,&image)==ESP_OK && image==ESP_OTA_IMG_PENDING_VERIFY){
esp_timer_create_args_t args={.callback=boot_timeout,.name="ota_health"};
if(esp_timer_create(&args,&boot_timer)!=ESP_OK || esp_timer_start_once(boot_timer,180000000)!=ESP_OK){
ESP_LOGE(TAG,"Cannot arm OTA health deadline; restoring previous image");
ota_internal_flash_call(OTA_FLASH_ROLLBACK,0,NULL,NULL);
esp_restart();
}
}
}
void device_ota_boot_ready(void){
// An unreadable record is not equivalent to a factory image with version 0.
// Leave OTA disabled; a pending image retains its rollback deadline.
if(!storage_ready)return;
const esp_partition_t *running=esp_ota_get_running_partition();
esp_ota_img_states_t image;
esp_err_t image_state=running?esp_ota_get_state_partition(running,&image):ESP_ERR_NOT_FOUND;
bool pending=image_state==ESP_OK && image==ESP_OTA_IMG_PENDING_VERIFY;
// Establish a valid fallback for the first image flashed over USB. Without
// this entry, the first OTA would have no previously confirmed A/B slot.
if(running && image_state==ESP_ERR_NOT_FOUND && running->subtype>=ESP_PARTITION_SUBTYPE_APP_OTA_MIN && running->subtype<=ESP_PARTITION_SUBTYPE_APP_OTA_MAX){
if(ota_internal_flash_call(OTA_FLASH_BOOT,0,running,NULL)!=ESP_OK || esp_ota_mark_app_valid_cancel_rollback()!=ESP_OK)return;
}
if(state.phase==2){
if(running && running->address==state.address && flash_matches(running)){
installed_t value={.format=1,.address=running->address,.code=state.code};
snprintf(value.version,sizeof(value.version),"%s",state.version);
char key[16];if(!installed_key(running,key) || persist(key,&value,sizeof(value))!=ESP_OK)return;
if(pending && esp_ota_mark_app_valid_cancel_rollback()!=ESP_OK)return;
installed=value;state.phase=3;
ESP_LOGI(TAG,"OTA boot verified: slot=0x%lx version_code=%lld",(unsigned long)running->address,(long long)state.code);
}else{state.phase=4;snprintf(state.message,sizeof(state.message),"Boot verification failed or previous firmware restored");
if(pending){save();if(ota_internal_flash_call(OTA_FLASH_ROLLBACK,0,NULL,NULL)==ESP_OK)esp_restart();return;}}
if(save()!=ESP_OK)return;
}else if(state.phase==1){state.phase=4;snprintf(state.message,sizeof(state.message),"Download interrupted before installation");if(save()!=ESP_OK)return;}
else if(pending){
// Untracked images cannot claim an OTA task; normal health checks still
// allow an intentional serial-flashed build to become bootable.
if(esp_ota_mark_app_valid_cancel_rollback()!=ESP_OK)return;
}
if(boot_timer){esp_timer_stop(boot_timer);esp_timer_delete(boot_timer);boot_timer=NULL;}
ready=true;
if(running)ESP_LOGI(TAG,"OTA ready: slot=0x%lx firmware=%s version_code=%lld",(unsigned long)running->address,device_ota_version(),(long long)device_ota_version_code());
}
bool device_ota_supported(void){return ready && esp_ota_get_next_update_partition(NULL)!=NULL;}
const char *device_ota_version(void){return installed.code>0?installed.version:DEVICE_FIRMWARE_VERSION;}
int64_t device_ota_version_code(void){return installed.code>0?installed.code:0;}
void device_ota_request(const char *id){if(requests && id && strlen(id)==36){char value[37];memcpy(value,id,37);xQueueSend(requests,value,0);}}
static bool report(const char *status){
cJSON *j=cJSON_CreateObject();cJSON_AddStringToObject(j,"task_id",state.id);cJSON_AddStringToObject(j,"status",status);
if(!strcmp(status,"success")){cJSON_AddStringToObject(j,"firmware_version",state.version);cJSON_AddNumberToObject(j,"version_code",state.code);}
if(state.message[0])cJSON_AddStringToObject(j,"message",state.message);
esp_err_t err=post("/api/v1/device/ota/report",j,NULL);cJSON_Delete(j);
// The server may already have committed a terminal receipt whose response
// was lost. A finished task must not block every future remote upgrade.
return err==ESP_OK || (!strcmp(platform_last_error_code(),"OTA_FINISHED") && strcmp(status,"accepted"));
}
static bool same_origin_download(const char *url){
const char *origin=ble_cloud_origin();size_t n=strlen(origin);
return !strncmp(origin,"https://",8) && !strncmp(url,origin,n) && !strncmp(url+n,"/f/",3) &&
strlen(url)<256 && !strchr(url,'\r') && !strchr(url,'\n');
}
typedef struct {
int64_t start_us,begin_us,read_us,write_us,sha_us,end_us;
int64_t read_max_us,write_max_us,sha_max_us;
uint32_t reads,writes,partial_reads,eagain_reads;
} ota_perf_t;
static unsigned long ota_ms(int64_t us){return us>0?(unsigned long)(us/1000):0;}
static void ota_elapsed(int64_t started,int64_t *sum,int64_t *maximum){
int64_t elapsed=esp_timer_get_time()-started;
*sum+=elapsed;if(maximum && elapsed>*maximum)*maximum=elapsed;
}
static esp_err_t ota_http_timeout(esp_http_client_handle_t client,int64_t deadline){
int64_t remaining=deadline-esp_timer_get_time();if(remaining<1000)return ESP_ERR_TIMEOUT;
int timeout_ms=remaining/1000;if(timeout_ms>20000)timeout_ms=20000;
return esp_http_client_set_timeout_ms(client,timeout_ms);
}
typedef struct {
ota_flash_op_t op;
esp_ota_handle_t handle;
const esp_partition_t *target;
SemaphoreHandle_t done;
esp_err_t result;
} ota_flash_job_t;
static void ota_flash_worker(void *arg){
ota_flash_job_t *job=arg;
switch(job->op){
case OTA_FLASH_END:job->result=esp_ota_end(job->handle);break;
case OTA_FLASH_BOOT:job->result=esp_ota_set_boot_partition(job->target);break;
case OTA_FLASH_ROLLBACK:job->result=esp_ota_mark_app_invalid_rollback();break;
default:job->result=ESP_ERR_INVALID_ARG;break;
}
xSemaphoreGive(job->done);
for(;;)vTaskSuspend(NULL);
}
static esp_err_t ota_internal_flash_call(ota_flash_op_t op,esp_ota_handle_t handle,const esp_partition_t *target,bool *completed){
if(completed)*completed=false;
SemaphoreHandle_t done=xSemaphoreCreateBinary();
if(!done)return ESP_ERR_NO_MEM;
ota_flash_job_t job={.op=op,.handle=handle,.target=target,.done=done,.result=ESP_FAIL};
TaskHandle_t worker=NULL;
// Image verification remaps flash and freezes caches. The control worker
// has a PSRAM stack, so end, boot selection and rollback need a DRAM stack.
// SDK WithCaps deletion suspends and joins a non-current task before freeing
// its stack/TCB; the worker never deletes itself or allocates a cleanup task.
if(xTaskCreateWithCaps(ota_flash_worker,"ota_flash",8192,&job,3,&worker,
MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT)!=pdPASS){vSemaphoreDelete(done);return ESP_ERR_NO_MEM;}
xSemaphoreTake(done,portMAX_DELAY);
vTaskDeleteWithCaps(worker);
vSemaphoreDelete(done);
if(completed)*completed=true;
return job.result;
}
static esp_err_t download(const char *url,const esp_partition_t *target){
if(!same_origin_download(url))return ESP_ERR_INVALID_ARG;
const esp_partition_t *running=esp_ota_get_running_partition();
if(!target || !running || target->address==running->address || state.size<1024 || state.size>target->size)return ESP_ERR_INVALID_SIZE;
ota_perf_t perf={.start_us=esp_timer_get_time()};
const int64_t deadline=perf.start_us+900000000;
// Flash cannot directly program from PSRAM on SPI1: the SDK otherwise
// copies through a 32-byte temporary buffer. Keep the write buffer in DRAM.
// Match the SDK's 8KiB flash write chunk without exhausting internal heap.
size_t buf_size=8192;unsigned char *buf=NULL;
for(;buf_size>=4096;buf_size/=2){
buf=heap_caps_malloc(buf_size,MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT);if(buf)break;
}
if(!buf)return ESP_ERR_NO_MEM;
esp_http_client_config_t cfg={.url=url,.crt_bundle_attach=esp_crt_bundle_attach,.timeout_ms=20000,
.disable_auto_redirect=true,.buffer_size=16384,.buffer_size_tx=512,.keep_alive_enable=false};
esp_http_client_handle_t client=esp_http_client_init(&cfg);if(!client){heap_caps_free(buf);return ESP_ERR_NO_MEM;}
esp_ota_handle_t handle=0;bool active=false;
unsigned char hash[32];mbedtls_sha256_context sha;mbedtls_sha256_init(&sha);
const char *stage="open";
esp_err_t err=ota_http_timeout(client,deadline);
if(err==ESP_OK)err=esp_http_client_open(client,0);
if(err==ESP_OK && esp_timer_get_time()>=deadline){stage="deadline";err=ESP_ERR_TIMEOUT;}
if(err==ESP_OK){stage="headers";err=ota_http_timeout(client,deadline);}
if(err==ESP_OK){int64_t length=esp_http_client_fetch_headers(client);
if(esp_http_client_get_status_code(client)!=200 || length!=(int64_t)state.size)err=ESP_ERR_INVALID_SIZE;}
if(err==ESP_OK && esp_timer_get_time()>=deadline){stage="deadline";err=ESP_ERR_TIMEOUT;}
if(err==ESP_OK){
stage="begin";int64_t started=esp_timer_get_time();
// A known length lets the SDK combine aligned 4KiB sectors into 64KiB
// erases before receiving the body. Never erase the running A/B slot.
err=esp_ota_begin(target,state.size,&handle);active=handle!=0;
ota_elapsed(started,&perf.begin_us,NULL);
// The SDK may assign its handle before an erase failure; abort it below.
}
if(err==ESP_OK && esp_timer_get_time()>=deadline){stage="deadline";err=ESP_ERR_TIMEOUT;}
if(err==ESP_OK){stage="sha.init";int64_t started=esp_timer_get_time();
if(mbedtls_sha256_starts(&sha,0)!=0)err=ESP_FAIL;
ota_elapsed(started,&perf.sha_us,&perf.sha_max_us);}
uint32_t total=0;int64_t heartbeat_due=0,progress_due=perf.start_us+30000000;
ESP_LOGI(TAG,"OTA buffer: internal=%u free_internal=%u largest_internal=%u erase_ms=%lu",
(unsigned)buf_size,(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT),
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT),ota_ms(perf.begin_us));
unsigned stalled_reads=0;int read_rc=0,socket_errno=0;
while(err==ESP_OK && total<state.size){
if(esp_timer_get_time()>=heartbeat_due){device_control_heartbeat();heartbeat_due=esp_timer_get_time()+30000000;}
stage="timeout";err=ota_http_timeout(client,deadline);
if(err!=ESP_OK){if(err==ESP_ERR_TIMEOUT)stage="deadline";break;}
size_t wanted=state.size-total;if(wanted>buf_size)wanted=buf_size;
stage="read";int64_t started=esp_timer_get_time();
int count=esp_http_client_read(client,(char*)buf,wanted);read_rc=count;perf.reads++;
int64_t read_before=perf.read_us;
ota_elapsed(started,&perf.read_us,&perf.read_max_us);
socket_errno=count<=0?esp_http_client_get_errno(client):0;
if(count>0 && (size_t)count<wanted)perf.partial_reads++;
if(count==-ESP_ERR_HTTP_EAGAIN)perf.eagain_reads++;
if(perf.read_us-read_before>=1000000)ESP_LOGI(TAG,"OTA slow read: off=%lu wanted=%u rc=%d ms=%lu",
(unsigned long)total,(unsigned)wanted,count,ota_ms(perf.read_us-read_before));
if(esp_timer_get_time()>=deadline){stage="deadline";err=ESP_ERR_TIMEOUT;break;}
// A timed-out read leaves this connection and write offset intact.
// Three consecutive 20s waits bound a stall; real progress resets it.
if(count==-ESP_ERR_HTTP_EAGAIN){if(++stalled_reads<3)continue;err=ESP_ERR_HTTP_EAGAIN;break;}
if(count<=0){err=ESP_FAIL;break;}
stalled_reads=0;stage="write";
started=esp_timer_get_time();err=esp_ota_write(handle,buf,count);perf.writes++;
ota_elapsed(started,&perf.write_us,&perf.write_max_us);
if(err!=ESP_OK)break;
total+=count;
stage="sha.upd";started=esp_timer_get_time();if(mbedtls_sha256_update(&sha,buf,count)!=0)err=ESP_FAIL;
ota_elapsed(started,&perf.sha_us,&perf.sha_max_us);
if(err==ESP_OK && esp_timer_get_time()>=deadline){stage="deadline";err=ESP_ERR_TIMEOUT;}
int64_t now=esp_timer_get_time();
if(now>=progress_due){
ESP_LOGI(TAG,"OTA progress: bytes=%lu/%lu ms=%lu read_ms=%lu write_ms=%lu",
(unsigned long)total,(unsigned long)state.size,ota_ms(now-perf.start_us),ota_ms(perf.read_us),ota_ms(perf.write_us));
progress_due=now+30000000;
}
}
if(err==ESP_OK){stage="sha.end";char hex[65];int64_t started=esp_timer_get_time();if(mbedtls_sha256_finish(&sha,hash)!=0)err=ESP_FAIL;
else{hash_hex(hash,hex);if(strcmp(hex,state.sha))err=ESP_ERR_INVALID_CRC;
else ESP_LOGI(TAG,"OTA image digest verified: bytes=%lu",(unsigned long)total);}
ota_elapsed(started,&perf.sha_us,&perf.sha_max_us);}
if(err==ESP_OK && esp_timer_get_time()>=deadline){stage="deadline";err=ESP_ERR_TIMEOUT;}
// Release TLS and the DRAM write buffer before allocating the temporary
// verification stack, keeping the receive and verification peaks separate.
mbedtls_sha256_free(&sha);heap_caps_free(buf);buf=NULL;
esp_http_client_close(client);esp_http_client_cleanup(client);client=NULL;
if(err==ESP_OK && esp_timer_get_time()>=deadline){stage="deadline";err=ESP_ERR_TIMEOUT;}
if(err==ESP_OK){stage="end";int64_t started=esp_timer_get_time();bool completed=false;
err=ota_internal_flash_call(OTA_FLASH_END,handle,NULL,&completed);if(completed)active=false;
ota_elapsed(started,&perf.end_us,NULL);}
int64_t elapsed=esp_timer_get_time()-perf.start_us;
if(err==ESP_OK && elapsed>=900000000){stage="deadline";err=ESP_ERR_TIMEOUT;}
ESP_LOGI(TAG,"OTA timing: bytes=%lu total_ms=%lu erase_ms=%lu read_ms=%lu/%lu write_ms=%lu/%lu sha_ms=%lu/%lu end_ms=%lu reads=%lu writes=%lu partial=%lu eagain=%lu",
(unsigned long)total,ota_ms(elapsed),ota_ms(perf.begin_us),ota_ms(perf.read_us),ota_ms(perf.read_max_us),
ota_ms(perf.write_us),ota_ms(perf.write_max_us),ota_ms(perf.sha_us),ota_ms(perf.sha_max_us),ota_ms(perf.end_us),
(unsigned long)perf.reads,(unsigned long)perf.writes,(unsigned long)perf.partial_reads,(unsigned long)perf.eagain_reads);
if(err!=ESP_OK)snprintf(state.message,sizeof(state.message),
"OTA %s e=0x%x off=%lu/%lu rc=%d errno=%d; original retained",stage,(unsigned)err,
(unsigned long)total,(unsigned long)state.size,read_rc,socket_errno);
else snprintf(state.message,sizeof(state.message),"OTA ms=%lu erase=%lu read=%lu write=%lu sha=%lu buf=%u",
ota_ms(elapsed),ota_ms(perf.begin_us),ota_ms(perf.read_us),ota_ms(perf.write_us),ota_ms(perf.sha_us),(unsigned)buf_size);
if(active)esp_ota_abort(handle);
return err;
}
void device_ota_poll(void){
if(!ready)return;
if(state.phase==3 || state.phase==4){
if(!report(state.phase==3?"success":"failed"))return;
uint32_t previous=state.phase;state.phase=0;
if(save()!=ESP_OK){state.phase=previous;return;}
ESP_LOGI(TAG,"OTA terminal receipt settled");
}
char id[37];if(!requests || xQueueReceive(requests,id,0)!=pdTRUE)return;
if(!strcmp(id,state.id))return;
if(strcmp(id,retry_id)){memcpy(retry_id,id,sizeof(retry_id));manifest_retries=0;}
bool idle=call_client_enter_maintenance();
cJSON *j=cJSON_CreateObject(),*manifest=NULL;cJSON_AddStringToObject(j,"task_id",id);
esp_err_t err=post("/api/v1/device/ota/manifest",j,&manifest);cJSON_Delete(j);
if(err!=ESP_OK){
// MQTT QoS acknowledges delivery before this worker fetches HTTPS. A
// transient HTTP error must not silently discard the only wakeup.
// Re-fetch at most twice; the server still enforces the original 30s
// acceptance deadline and no URL/digest from MQTT is ever trusted.
if(strcmp(platform_last_error_code(),"OTA_TASK_UNAVAILABLE") && ++manifest_retries<=2)device_ota_request(id);
if(idle)call_client_leave_maintenance();
return;
}
retry_id[0]=0;manifest_retries=0;
const esp_partition_t *target=esp_ota_get_next_update_partition(NULL);
const char *digest=str(manifest,"sha256"),*version=str(manifest,"version");
int64_t code=number(manifest,"version_code"),size=number(manifest,"size");
struct timeval now;gettimeofday(&now,NULL);
bool valid=target && size>=1024 && size<=target->size && code>device_ota_version_code() &&
!strcmp(id,str(manifest,"task_id")) && !strcmp(ble_factory_model(),str(manifest,"model")) &&
strlen(digest)==64 && strspn(digest,"0123456789abcdef")==64 && strlen(version)>0 && strlen(version)<81 &&
(int64_t)now.tv_sec*1000+now.tv_usec/1000<number(manifest,"expires_at_ms") && same_origin_download(str(manifest,"download_url"));
if(!valid || !idle){
memset(&state,0,sizeof(state));state.format=1;state.phase=4;
snprintf(state.id,sizeof(state.id),"%s",id);
snprintf(state.message,sizeof(state.message),"%s",idle?"Image incompatible, too large, obsolete or expired":"Device busy; conversation was not interrupted");
save();cJSON_Delete(manifest);if(idle)call_client_leave_maintenance();return;
}
memset(&state,0,sizeof(state));state.format=1;state.phase=1;state.address=target->address;state.size=size;state.code=code;
snprintf(state.id,sizeof(state.id),"%s",id);snprintf(state.version,sizeof(state.version),"%s",version);snprintf(state.sha,sizeof(state.sha),"%s",digest);
err=save();
if(err==ESP_OK && !report("accepted"))err=ESP_FAIL;
if(err==ESP_OK)ESP_LOGI(TAG,"OTA accepted: target_slot=0x%lx bytes=%lu version_code=%lld",(unsigned long)target->address,(unsigned long)state.size,(long long)state.code);
if(err==ESP_OK)err=download(str(manifest,"download_url"),target);
cJSON_Delete(manifest);
if(err==ESP_OK){state.phase=2;err=save();
if(err!=ESP_OK)snprintf(state.message,sizeof(state.message),"OTA journal failed: %s; original retained",esp_err_to_name(err));}
if(err==ESP_OK){err=ota_internal_flash_call(OTA_FLASH_BOOT,0,target,NULL);
if(err!=ESP_OK)snprintf(state.message,sizeof(state.message),"OTA boot selection failed: %s; original retained",esp_err_to_name(err));}
if(err==ESP_OK){ESP_LOGI(TAG,"Verified image installed; restarting for boot validation");device_control_prepare_restart();esp_restart();}
state.phase=4;if(!state.message[0])snprintf(state.message,sizeof(state.message),"OTA failed: %s; original firmware retained",esp_err_to_name(err));save();
call_client_leave_maintenance();
}
@@ -0,0 +1,12 @@
#pragma once
#include "esp_err.h"
#include <stdbool.h>
#include <stdint.h>
void device_ota_boot_guard(void);
void device_ota_boot_ready(void);
bool device_ota_supported(void);
const char *device_ota_version(void);
int64_t device_ota_version_code(void);
void device_ota_poll(void);
// MQTT is a wakeup only; the manifest is fetched over factory-authenticated TLS.
void device_ota_request(const char *task_id);
@@ -0,0 +1,76 @@
#include "device_power.h"
#include "call_client.h"
#include "audio_processor.h"
#include "volc_hal.h"
#include "nvs.h"
#include <string.h>
static int active_brightness=80;
esp_err_t device_power_boot(void) {
nvs_handle_t n;uint8_t standby=0;
esp_err_t e=nvs_open("control",NVS_READONLY,&n);
if(e==ESP_OK) {
e=nvs_get_u8(n,"standby",&standby);nvs_close(n);
if(e!=ESP_OK && e!=ESP_ERR_NVS_NOT_FOUND)return e;
} else if(e!=ESP_ERR_NVS_NOT_FOUND)return e;
if(standby>1)return ESP_ERR_INVALID_STATE;
e=call_client_set_standby(standby!=0);
if(e==ESP_OK)e=audio_recorder_set_standby(standby!=0);
volc_hal_context_t *ctx=volc_get_global_hal_context();
if(e==ESP_OK && standby && (!ctx || volc_hal_display_set_brightness(ctx->display_handle,0)))return ESP_FAIL;
return e;
}
esp_err_t device_power_apply(const char *mode, bool maintenance_held) {
if(!mode || (strcmp(mode,"active") && strcmp(mode,"standby")))return ESP_ERR_INVALID_ARG;
bool standby=!strcmp(mode,"standby");
if(!maintenance_held && !call_client_enter_maintenance())return ESP_ERR_INVALID_STATE;
esp_err_t e=ESP_OK;
volc_hal_context_t *ctx=volc_get_global_hal_context();
int brightness=ctx?volc_hal_display_get_brightness(ctx->display_handle):-1;
if(brightness<0){e=ESP_FAIL;goto done;}
if(standby && !call_client_standby() && brightness>0)active_brightness=brightness;
// Keep calls excluded until the microphone and display have both changed.
if(standby) {
e=call_client_set_standby(true);
if(e==ESP_OK && !audio_recorder_is_standby())e=audio_recorder_set_standby(true);
if(e==ESP_OK && brightness!=0 && volc_hal_display_set_brightness(ctx->display_handle,0))e=ESP_FAIL;
} else {
if(brightness==0 && volc_hal_display_set_brightness(ctx->display_handle,active_brightness))e=ESP_FAIL;
if(e==ESP_OK)e=audio_recorder_set_standby(false);
if(e==ESP_OK)e=call_client_set_standby(false);
}
if(e!=ESP_OK)goto done;
brightness=volc_hal_display_get_brightness(ctx->display_handle);
if(brightness<0 || (standby ? brightness!=0 || !audio_recorder_is_standby() : brightness==0 || audio_recorder_is_standby())){e=ESP_FAIL;goto done;}
nvs_handle_t n;e=nvs_open("control",NVS_READWRITE,&n);
if(e==ESP_OK) {
uint8_t stored=2;
esp_err_t read=nvs_get_u8(n,"standby",&stored);
if(read!=ESP_OK && read!=ESP_ERR_NVS_NOT_FOUND)e=read;
else if(stored!=(uint8_t)standby) {
e=nvs_set_u8(n,"standby",standby);
if(e==ESP_OK)e=nvs_commit(n);
if(e==ESP_OK)e=nvs_get_u8(n,"standby",&stored);
if(e==ESP_OK && stored!=(uint8_t)standby)e=ESP_FAIL;
}
nvs_close(n);
}
done:
if(!maintenance_held)call_client_leave_maintenance();
return e;
}
cJSON *device_power_report(void) {
volc_hal_context_t *ctx=volc_get_global_hal_context();
int brightness=ctx?volc_hal_display_get_brightness(ctx->display_handle):-1;
bool microphone=audio_recorder_is_standby(),standby=call_client_standby();
const char *mode=standby && microphone && brightness==0?"standby":!standby && !microphone && brightness>0?"active":"transition";
cJSON *j=cJSON_CreateObject();
cJSON_AddStringToObject(j,"mode",mode);
cJSON_AddBoolToObject(j,"microphone_paused",microphone);
cJSON_AddNumberToObject(j,"backlight_percent",brightness);
cJSON_AddNumberToObject(j,"microphone_reads",audio_recorder_microphone_reads());
return j;
}
@@ -0,0 +1,8 @@
#pragma once
#include "esp_err.h"
#include "cJSON.h"
#include <stdbool.h>
// Restore the microphone/call gate before audio initialization.
esp_err_t device_power_boot(void);
esp_err_t device_power_apply(const char *mode, bool maintenance_held);
cJSON *device_power_report(void);
@@ -0,0 +1,122 @@
#include "device_reset.h"
#include "call_client.h"
#include "device_ota.h"
#include "ble_cloud.h"
#include "cJSON.h"
#include "nvs.h"
#include "mbedtls/platform_util.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
typedef struct { uint32_t version,phase; char body[512]; } reset_record_t;
// phase 1: request persisted, result uncertain; 2: cloud committed, cleanup
// pending; 3: local cleanup committed. Never delete this recovery namespace.
static esp_err_t load(reset_record_t *r) {
memset(r,0,sizeof(*r));nvs_handle_t n;
esp_err_t e=nvs_open("factory_reset",NVS_READONLY,&n);
if(e==ESP_ERR_NVS_NOT_FOUND)return ESP_OK;
if(e!=ESP_OK)return e;
size_t size=sizeof(*r);e=nvs_get_blob(n,"record",r,&size);nvs_close(n);
if(e==ESP_ERR_NVS_NOT_FOUND){memset(r,0,sizeof(*r));return ESP_OK;}
if(e==ESP_OK && (size!=sizeof(*r)||r->version!=1||r->phase<1||r->phase>3||!memchr(r->body,0,sizeof(r->body))))return ESP_ERR_INVALID_STATE;
return e;
}
static esp_err_t save(const reset_record_t *r) {
nvs_handle_t n;esp_err_t e=nvs_open("factory_reset",NVS_READWRITE,&n);
if(e!=ESP_OK)return e;
e=nvs_set_blob(n,"record",r,sizeof(*r));if(e==ESP_OK)e=nvs_commit(n);
reset_record_t check;size_t size=sizeof(check);
if(e==ESP_OK)e=nvs_get_blob(n,"record",&check,&size);
if(e==ESP_OK && (size!=sizeof(check)||memcmp(r,&check,size)))e=ESP_FAIL;
nvs_close(n);return e;
}
static esp_err_t clear_keys(const char *space,const char *const *keys) {
nvs_handle_t n;esp_err_t e=nvs_open(space,NVS_READWRITE,&n);if(e!=ESP_OK)return e;
if(!keys)e=nvs_erase_all(n);
else for(int i=0;keys[i]&&e==ESP_OK;i++) {
e=nvs_erase_key(n,keys[i]);if(e==ESP_ERR_NVS_NOT_FOUND)e=ESP_OK;
}
if(e==ESP_OK)e=nvs_commit(n);
nvs_close(n);return e;
}
static esp_err_t cleanup(reset_record_t *r) {
// Keep firmware/OTA metadata and both forms of permanent device identity.
const char *const control[]={"speaker","display_name","standby","result","probe",NULL};
const char *const platform[]={"instance_id","product_key","device_name","volc_secret","pending_conv",NULL};
esp_err_t e=clear_keys("voice",NULL);
if(e==ESP_OK)e=clear_keys("platform",platform);
if(e==ESP_OK)e=clear_keys("control",control);
if(e==ESP_OK)e=clear_keys("ble_setup",NULL);
if(e==ESP_OK)e=clear_keys("wifi",NULL);
if(e==ESP_OK){r->phase=3;e=save(r);} // A cut before this commit repeats cleanup.
return e;
}
esp_err_t device_reset_boot_cleanup(void) {
reset_record_t r;esp_err_t e=load(&r);
if(e==ESP_OK && r.phase==2){e=save(&r);if(e==ESP_OK)e=cleanup(&r);}
return e;
}
static void wipe(char *s){if(s){mbedtls_platform_zeroize(s,strlen(s));free(s);}}
static esp_err_t post(const char *path,const char *body,cJSON **data) {
char *raw=NULL;esp_err_t e=platform_request("POST",path,body,NULL,&raw);
if(e==ESP_OK) {
cJSON *root=raw?cJSON_Parse(raw):NULL,*value=cJSON_GetObjectItemCaseSensitive(root,"data");
if(!cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root,"ok"))||!cJSON_IsObject(value))e=ESP_ERR_INVALID_RESPONSE;
else {*data=cJSON_Duplicate(value,true);if(!*data)e=ESP_ERR_NO_MEM;}
cJSON_Delete(root);
}
wipe(raw);return e;
}
static const char *str(const cJSON *j,const char *key) {
cJSON *v=cJSON_GetObjectItemCaseSensitive(j,key);return cJSON_IsString(v)?v->valuestring:"";
}
static bool response_matches(const cJSON *data,const cJSON *request) {
cJSON *actual=cJSON_GetObjectItemCaseSensitive(data,"reset_counter"),*expected=cJSON_GetObjectItemCaseSensitive(request,"reset_counter");
if(strcmp(str(data,"reset_id"),str(request,"reset_id")) || strcmp(str(data,"activation_state"),"CLAIMABLE") ||
strcmp(str(data,"license_action"),"RETAINED") || !cJSON_IsNumber(actual)||!cJSON_IsNumber(expected)||actual->valuedouble!=expected->valuedouble)return false;
const char *required[]={"clear_volc_credential","clear_user_context","clear_session_state","clear_network_credentials","reboot_to_claimable"};
cJSON *actions=cJSON_GetObjectItemCaseSensitive(data,"local_actions");
if(!cJSON_IsArray(actions)||cJSON_GetArraySize(actions)!=5)return false;
for(int i=0;i<5;i++) {int count=0;const cJSON *a=NULL;cJSON_ArrayForEach(a,actions)if(cJSON_IsString(a)&&!strcmp(a->valuestring,required[i]))count++;if(count!=1)return false;}
return true;
}
esp_err_t device_reset_from_setup(const char *physical_event_id) {
if(!physical_event_id||strlen(physical_event_id)!=36||!ble_cloud_configured())return ESP_ERR_INVALID_ARG;
reset_record_t r;esp_err_t e=load(&r);if(e!=ESP_OK)return e;
if(r.phase==2){e=save(&r);return e==ESP_OK?cleanup(&r):e;}
// Same confirmed BLE job may retry after local cleanup finished.
cJSON *request=r.phase?cJSON_Parse(r.body):NULL;
if(r.phase==3 && request && !strcmp(str(request,"reset_id"),physical_event_id)){cJSON_Delete(request);return ESP_OK;}
cJSON_Delete(request);request=NULL;
// BLE setup boots before normal SNTP/call initialization. Establish signed
// server time before DEVREQ1 timestamps and the durable occurred_at value.
if(ble_cloud_sync_time()!=ESP_OK)return ESP_FAIL;
if(r.phase!=1) {
cJSON *state=NULL;e=post("/api/v1/device/factory-reset/state","{}",&state);
if(e!=ESP_OK){cJSON_Delete(state);return e;}
cJSON *counter=cJSON_GetObjectItemCaseSensitive(state,"reset_counter");
if(!cJSON_IsNumber(counter)||counter->valuedouble<0||counter->valuedouble>=9007199254740991.0||counter->valuedouble!=(double)(int64_t)counter->valuedouble){cJSON_Delete(state);return ESP_ERR_INVALID_RESPONSE;}
double next=counter->valuedouble+1;cJSON_Delete(state);
time_t now=time(NULL);struct tm utc;char occurred[32];
if(now<1700000000 || !gmtime_r(&now,&utc) || !strftime(occurred,sizeof(occurred),"%Y-%m-%dT%H:%M:%SZ",&utc))return ESP_ERR_INVALID_STATE;
request=cJSON_CreateObject();if(!request)return ESP_ERR_NO_MEM;
cJSON_AddStringToObject(request,"reset_id",physical_event_id);cJSON_AddNumberToObject(request,"reset_counter",next);
cJSON_AddStringToObject(request,"occurred_at",occurred);cJSON_AddStringToObject(request,"trigger","device_menu");
cJSON_AddStringToObject(request,"firmware_version",device_ota_version());cJSON_AddNumberToObject(request,"pending_turn_count",0);
char *body=cJSON_PrintUnformatted(request);cJSON_Delete(request);request=NULL;
if(!body)return ESP_ERR_NO_MEM;
if(strlen(body)>=sizeof(r.body)){wipe(body);return ESP_ERR_INVALID_SIZE;}
memset(&r,0,sizeof(r));r.version=1;r.phase=1;strcpy(r.body,body);wipe(body);
if((e=save(&r))!=ESP_OK)return e;
}
request=cJSON_Parse(r.body);if(!request)return ESP_ERR_INVALID_STATE;
cJSON *response=NULL;e=post("/api/v1/device/factory-reset",r.body,&response);
if(e==ESP_OK && !response_matches(response,request))e=ESP_ERR_INVALID_RESPONSE;
cJSON_Delete(request);cJSON_Delete(response);
if(e!=ESP_OK)return e; // Keep the same signed body for uncertain retries.
r.phase=2;if((e=save(&r))!=ESP_OK)return e;
return cleanup(&r);
}
@@ -0,0 +1,9 @@
#pragma once
#include "esp_err.h"
// Run immediately after NVS initialization, before BLE, audio or voice tasks.
// Completes only a reset already authorized by the cloud and durably recorded.
esp_err_t device_reset_boot_cleanup(void);
// BLE provisioning worker only: after a fresh physical confirmation and Wi-Fi
// connection. That boot mode never starts call/control/OTA workers.
esp_err_t device_reset_from_setup(const char *physical_event_id);
@@ -0,0 +1,100 @@
#include "device_screen_wake.h"
#include "device_power.h"
#include "call_client.h"
#include "esp_random.h"
#include "nvs.h"
#include <stdatomic.h>
#include <stdio.h>
#include <string.h>
typedef struct { uint32_t version; int64_t revision; char id[37]; } wake_event_t;
static wake_event_t pending;
static atomic_bool requested;
static bool awake;
static esp_err_t save_pending(bool keep)
{
nvs_handle_t n;
esp_err_t err = nvs_open("control", NVS_READWRITE, &n);
if (err != ESP_OK) return err;
err = keep ? nvs_set_blob(n, "screen_wake", &pending, sizeof(pending))
: nvs_erase_key(n, "screen_wake");
if (err == ESP_ERR_NVS_NOT_FOUND) err = ESP_OK;
if (err == ESP_OK) err = nvs_commit(n);
nvs_close(n);
return err;
}
esp_err_t device_screen_wake_init(void)
{
memset(&pending, 0, sizeof(pending));
awake = false;
atomic_store(&requested, false);
nvs_handle_t n;
esp_err_t err = nvs_open("control", NVS_READONLY, &n);
if (err == ESP_ERR_NVS_NOT_FOUND) return ESP_OK;
if (err != ESP_OK) return err;
size_t size = sizeof(pending);
err = nvs_get_blob(n, "screen_wake", &pending, &size);
nvs_close(n);
if (err == ESP_ERR_NVS_NOT_FOUND) { memset(&pending, 0, sizeof(pending)); return ESP_OK; }
if (err != ESP_OK) return err;
if (size != sizeof(pending) || pending.version != 1 || pending.revision <= 0 ||
pending.id[36] != 0 || strlen(pending.id) != 36) return ESP_ERR_INVALID_STATE;
return ESP_OK;
}
void device_screen_wake_request(void) { atomic_store(&requested, true); }
esp_err_t device_screen_wake_poll(int64_t applied_revision)
{
if (atomic_exchange(&requested, false) && !pending.version && call_client_standby()) {
// Retain a tap during initial configuration until its revision is known.
// An offline tap after initialization needs no network access.
if (applied_revision <= 0) { atomic_store(&requested, true); return ESP_OK; }
uint32_t r[4];
esp_fill_random(r, sizeof(r));
pending.version = 1;
pending.revision = applied_revision;
snprintf(pending.id, sizeof(pending.id), "%08lx-%04x-4%03x-%04x-%04x%08lx",
(unsigned long)r[0], (unsigned)(r[1] >> 16), (unsigned)(r[1] & 0xfff),
(unsigned)((r[2] >> 16 & 0x3fff) | 0x8000), (unsigned)(r[2] & 0xffff),
(unsigned long)r[3]);
esp_err_t err = save_pending(true);
if (err != ESP_OK) {
memset(&pending, 0, sizeof(pending));
atomic_store(&requested, true);
return err;
}
}
if (pending.version && !awake) {
esp_err_t err = device_power_apply("active", false);
if (err != ESP_OK) return err;
awake = true;
}
return ESP_OK;
}
void device_screen_wake_add_report(cJSON *report)
{
if (!pending.version || !awake) return;
cJSON *event = cJSON_AddObjectToObject(report, "screen_wake");
cJSON_AddStringToObject(event, "id", pending.id);
cJSON_AddNumberToObject(event, "revision", pending.revision);
}
esp_err_t device_screen_wake_ack(const cJSON *response)
{
if (!pending.version || !awake) return ESP_OK;
const cJSON *event = cJSON_GetObjectItemCaseSensitive(response, "screen_wake");
const cJSON *id = cJSON_GetObjectItemCaseSensitive(event, "id");
const cJSON *revision = cJSON_GetObjectItemCaseSensitive(event, "revision");
const cJSON *result = cJSON_GetObjectItemCaseSensitive(event, "result");
if (!cJSON_IsString(id) || strcmp(id->valuestring, pending.id) ||
!cJSON_IsNumber(revision) || revision->valuedouble != pending.revision ||
!cJSON_IsString(result) || (strcmp(result->valuestring, "applied") &&
strcmp(result->valuestring, "superseded"))) return ESP_ERR_INVALID_RESPONSE;
esp_err_t err = save_pending(false);
if (err == ESP_OK) { memset(&pending, 0, sizeof(pending)); awake = false; }
return err;
}
@@ -0,0 +1,12 @@
#pragma once
#include <stdint.h>
#include "esp_err.h"
#include "cJSON.h"
// Only request() runs on the touch task. State/NVS and network work belong to
// the control worker; the physical wake survives a reboot or lost HTTP reply.
esp_err_t device_screen_wake_init(void);
void device_screen_wake_request(void);
esp_err_t device_screen_wake_poll(int64_t applied_revision);
void device_screen_wake_add_report(cJSON *report);
esp_err_t device_screen_wake_ack(const cJSON *response);
@@ -0,0 +1,2 @@
#pragma once
#define DEVICE_FIRMWARE_VERSION "1.7.0-controls.32"
+88 -4
View File
@@ -9,11 +9,14 @@
#include <esp_event.h>
#include <esp_wifi.h>
#include <esp_log.h>
#include <esp_heap_caps.h>
#include <esp_timer.h>
#include <nvs.h>
#include <nvs_flash.h>
#include "configuration_ap.h"
#include "platform_client.h"
#include "ble_cloud.h"
#define TAG "NETWORK"
@@ -21,8 +24,33 @@
#define WIFI_EVENT_FAILED BIT1
#define WIFI_EVENT_STARTED BIT2
#define MAX_RECONNECT_COUNT 5
#define RECONNECT_DELAY_US (10 * 1000 * 1000)
static network_t network;
static esp_err_t network_error;
static esp_timer_handle_t reconnect_timer;
static bool reconnect_enabled;
esp_err_t network_last_error(void) { return network_error; }
static void reconnect_saved_network(void *arg)
{
(void)arg;
if (!reconnect_enabled ||
(xEventGroupGetBits(network.event_group) & WIFI_EVENT_CONNECTED)) return;
esp_err_t err = esp_wifi_connect();
if (err != ESP_OK) {
ESP_LOGW(TAG, "WiFi reconnect deferred: %s", esp_err_to_name(err));
esp_timer_start_once(reconnect_timer, RECONNECT_DELAY_US);
}
}
static void schedule_reconnect(void)
{
// Fast retries recover a short interruption; slower retries continue for as
// long as the saved network is unavailable, including a phone hotspot.
if (reconnect_enabled && reconnect_timer && !esp_timer_is_active(reconnect_timer))
esp_timer_start_once(reconnect_timer, RECONNECT_DELAY_US);
}
int8_t get_rssi()
{
@@ -49,6 +77,8 @@ void handler_event_wifi_st(void *arg, esp_event_base_t event_base, int32_t event
else if (event_id == WIFI_EVENT_STA_DISCONNECTED)
{
xEventGroupClearBits(network.event_group, WIFI_EVENT_CONNECTED);
network.ip_address[0] = '\0';
if (!reconnect_enabled) return;
if (network.reconnect_count < MAX_RECONNECT_COUNT)
{
/* Retry the selected AP first, but allow another AP if it is no longer
@@ -62,35 +92,47 @@ void handler_event_wifi_st(void *arg, esp_event_base_t event_base, int32_t event
ESP_LOGW(TAG, "Preferred AP unavailable; falling back to SSID scan");
}
}
esp_wifi_connect();
esp_err_t err = esp_wifi_connect();
network.reconnect_count++;
ESP_LOGI(TAG, "Reconnecting WiFi (attempt %d)", network.reconnect_count);
if (err != ESP_OK) schedule_reconnect();
}
else
{
xEventGroupSetBits(network.event_group, WIFI_EVENT_FAILED);
ESP_LOGI(TAG, "WiFi connection failed");
schedule_reconnect();
ESP_LOGI(TAG, "WiFi unavailable; retrying every 10 seconds");
}
}
else if (event_id == WIFI_EVENT_STA_STOP)
{
reconnect_enabled = false;
if (reconnect_timer) esp_timer_stop(reconnect_timer);
xEventGroupClearBits(network.event_group, WIFI_EVENT_CONNECTED | WIFI_EVENT_STARTED);
network.ip_address[0] = '\0';
}
}
void handler_event_ip_st(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data)
{
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
network.reconnect_count = 0;
if (reconnect_timer) esp_timer_stop(reconnect_timer);
esp_ip4addr_ntoa(&event->ip_info.ip, network.ip_address, sizeof(network.ip_address));
ESP_LOGI(TAG, "Got IP: %s", network.ip_address);
xEventGroupClearBits(network.event_group, WIFI_EVENT_FAILED);
xEventGroupSetBits(network.event_group, WIFI_EVENT_CONNECTED);
}
bool is_connected()
{
return xEventGroupGetBits(network.event_group) & WIFI_EVENT_CONNECTED;
return network.event_group && (xEventGroupGetBits(network.event_group) & WIFI_EVENT_CONNECTED);
}
bool configure_network()
{
network_error=ESP_OK;
// Create the event group
network.ssid[0] = '\0';
@@ -98,6 +140,15 @@ bool configure_network()
network.reconnect_count = 0;
network.ip_address[0] = '\0';
network.event_group = xEventGroupCreate();
if(!network.event_group){network_error=ESP_ERR_NO_MEM;return false;}
if (!reconnect_timer) {
const esp_timer_create_args_t timer_config = {
.callback = reconnect_saved_network,
.name = "wifi_reconnect"
};
network_error = esp_timer_create(&timer_config, &reconnect_timer);
if (network_error != ESP_OK) return false;
}
// Get ssid and password from NVS
nvs_handle_t nvs_handle;
@@ -143,7 +194,25 @@ bool configure_network()
// Initialize the WiFi stack in station mode
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
// RTC and TLS share internal RAM with the Wi-Fi DMA buffers. Six static
// RX/TX frames retain streaming capacity without reserving 32 DMA frames.
cfg.static_rx_buf_num=6;
cfg.static_tx_buf_num=6;
// Dynamic RX buffers prefer PSRAM; match the 32KiB TCP receive window.
cfg.dynamic_rx_buf_num=32;
cfg.cache_tx_buf_num=8;
cfg.rx_ba_win=6;
ESP_LOGI(TAG,"WiFi init internal_free=%u largest=%u rx=%d tx=%d",
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL),cfg.static_rx_buf_num,cfg.static_tx_buf_num);
network_error=esp_wifi_init(&cfg);
if(network_error!=ESP_OK){
ESP_LOGE(TAG,"WiFi initialization unavailable: %s",esp_err_to_name(network_error));
esp_event_handler_instance_unregister(WIFI_EVENT,ESP_EVENT_ANY_ID,instance_any_id);
esp_event_handler_instance_unregister(IP_EVENT,IP_EVENT_STA_GOT_IP,instance_got_ip);
esp_netif_destroy_default_wifi(station_netif);
return false; // No abort/reboot loop on insufficient internal RAM.
}
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM));
@@ -158,6 +227,7 @@ bool configure_network()
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
// Start the WiFi stack
reconnect_enabled = true;
ESP_ERROR_CHECK(esp_wifi_start());
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
xEventGroupWaitBits(network.event_group, WIFI_EVENT_STARTED, pdFALSE, pdFALSE, pdMS_TO_TICKS(5000));
@@ -190,6 +260,8 @@ bool configure_network()
// Reset the WiFi stack
// 取消注册事件处理程序
reconnect_enabled = false;
esp_timer_stop(reconnect_timer);
ESP_ERROR_CHECK(esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, instance_any_id));
ESP_ERROR_CHECK(esp_event_handler_instance_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP, instance_got_ip));
ESP_ERROR_CHECK(esp_wifi_stop());
@@ -205,6 +277,18 @@ bool configure_network()
if (!is_connected())
{
if (ble_cloud_configured()) {
// Completed BLE devices need the same phone flow when taken away from
// their old Wi-Fi. Reboot to release normal-mode buffers before NimBLE.
network_error = nvs_open("wifi", NVS_READWRITE, &nvs_handle);
if (network_error == ESP_OK) {
network_error = nvs_set_u8(nvs_handle, "setup_requested", 1);
if (network_error == ESP_OK) network_error = nvs_commit(nvs_handle);
nvs_close(nvs_handle);
}
ESP_LOGI(TAG, "Saved WiFi unavailable; requesting Bluetooth setup");
return false;
}
configure_ap();
ESP_LOGI(TAG, "WiFi not connected, starting AP");
return false;
@@ -8,10 +8,11 @@ typedef struct
{
EventGroupHandle_t event_group;
char ssid[32];
char ssid[33];
char password[64];
int reconnect_count;
char ip_address[16];
} network_t;
bool configure_network();
bool configure_network();
esp_err_t network_last_error(void);
@@ -1,9 +1,12 @@
#include "platform_client.h"
#include "ble_cloud.h"
#include "call_client.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <stdatomic.h>
#include "cJSON.h"
#include "esp_crt_bundle.h"
@@ -21,7 +24,7 @@
#include "freertos/task.h"
#define PLATFORM_API_ORIGIN "https://121.41.46.238"
#define PLATFORM_RESPONSE_MAX (32 * 1024)
#define PLATFORM_RESPONSE_MAX (40 * 1024)
#define PLATFORM_FIRMWARE_VERSION "1.2.2-echoear-companion.3"
#define PLATFORM_HARDWARE_AGENT_SDK_VERSION "2.0.0"
@@ -53,6 +56,15 @@ static SemaphoreHandle_t s_http_mutex;
static unsigned s_http_connection_count;
static uint32_t s_heartbeat_interval_ms = 15000;
static TickType_t s_heartbeat_due;
static atomic_uint s_local_lease_deadline;
static atomic_bool s_local_lease_active;
bool platform_conversation_audio_authorized(void)
{
if (ble_cloud_configured()) return call_client_audio_allowed();
return atomic_load(&s_local_lease_active) &&
(int32_t)(atomic_load(&s_local_lease_deadline) - xTaskGetTickCount()) > 0;
}
typedef struct {
uint32_t version;
@@ -159,7 +171,7 @@ esp_err_t platform_identity_save(const char *device_id, const char *device_secre
bool platform_identity_is_configured(void)
{
return strlen(s_device_id) == 36 && strlen(s_device_secret) >= 32;
return ble_cloud_configured() || (strlen(s_device_id) == 36 && strlen(s_device_secret) >= 32);
}
const char *platform_hardware_id(void)
@@ -264,10 +276,10 @@ static esp_err_t platform_request_impl(const char *method, const char *path, con
const char *idempotency_key, char **response_out)
{
s_last_error_code[0] = '\0';
if (!platform_identity_is_configured()) return ESP_ERR_INVALID_STATE;
if (!platform_identity_is_configured() && !ble_cloud_configured()) return ESP_ERR_INVALID_STATE;
if (body == NULL) body = "";
char url[384];
snprintf(url, sizeof(url), "%s%s", PLATFORM_API_ORIGIN, path);
snprintf(url, sizeof(url), "%s%s", ble_cloud_configured()?ble_cloud_origin():PLATFORM_API_ORIGIN, path);
response_buffer_t response = {
.data = calloc(1, PLATFORM_RESPONSE_MAX), .capacity = PLATFORM_RESPONSE_MAX,
};
@@ -281,6 +293,7 @@ static esp_err_t platform_request_impl(const char *method, const char *path, con
.buffer_size = 2048,
.buffer_size_tx = 2048,
.keep_alive_enable = true,
.disable_auto_redirect = true,
};
if (!s_http_client) s_http_client = esp_http_client_init(&config);
esp_http_client_handle_t client = s_http_client;
@@ -300,12 +313,19 @@ static esp_err_t platform_request_impl(const char *method, const char *path, con
timestamp_now(timestamp);
random_hex(nonce, 16);
sha256_hex(body, digest);
if (ble_cloud_configured()) {
char request_id[37]; if (!idempotency_key) { ble_random_uuid(request_id); idempotency_key=request_id; }
if (err == ESP_OK) err=ble_cloud_headers(client,method,path,body,idempotency_key);
// Header values are copied by ESP-IDF; keep the idempotency pointer valid below.
idempotency_key=NULL;
} else {
if (err == ESP_OK) err = signature_header(method, path, timestamp, nonce, digest, signature);
if (err == ESP_OK) err = esp_http_client_set_header(client, "X-Device-Id", s_device_id);
if (err == ESP_OK) err = esp_http_client_set_header(client, "X-Device-Timestamp", timestamp);
if (err == ESP_OK) err = esp_http_client_set_header(client, "X-Device-Nonce", nonce);
if (err == ESP_OK) err = esp_http_client_set_header(client, "X-Device-Content-SHA256", digest);
if (err == ESP_OK) err = esp_http_client_set_header(client, "X-Device-Signature", signature);
}
if (err == ESP_OK) err = esp_http_client_set_header(client, "Accept", "application/json");
if (err == ESP_OK && idempotency_key != NULL) err = esp_http_client_set_header(client, "Idempotency-Key", idempotency_key);
if (err == ESP_OK && body[0] != '\0') {
@@ -331,6 +351,7 @@ static esp_err_t platform_request_impl(const char *method, const char *path, con
cJSON *root = cJSON_Parse(response.data);
cJSON *error = cJSON_GetObjectItemCaseSensitive(root, "error");
cJSON *code = cJSON_GetObjectItemCaseSensitive(error, "code");
if (!cJSON_IsString(code)) code=cJSON_GetObjectItemCaseSensitive(root,"code");
if (cJSON_IsString(code) && code->valuestring) {
size_t length = strlen(code->valuestring);
if (length > 0 && length < sizeof(s_last_error_code) &&
@@ -351,7 +372,7 @@ static esp_err_t platform_request_impl(const char *method, const char *path, con
return err;
}
static esp_err_t platform_request(const char *method, const char *path, const char *body,
esp_err_t platform_request(const char *method, const char *path, const char *body,
const char *idempotency_key, char **response_out)
{
if (!s_http_mutex) return ESP_ERR_INVALID_STATE;
@@ -673,45 +694,65 @@ void platform_agent_session_free(platform_agent_session_t *session)
memset(session, 0, sizeof(*session));
}
const char *platform_runtime_instance_id(void) { return s_volc.instance_id; }
const char *platform_runtime_product_key(void) { return s_volc.product_key; }
const char *platform_runtime_device_name(void) { return s_volc.device_name; }
const char *platform_runtime_device_secret(void) { return s_volc.device_secret; }
const char *platform_runtime_instance_id(void) { return ble_cloud_configured()?call_client_credential()->instance_id:s_volc.instance_id; }
const char *platform_runtime_product_key(void) { return ble_cloud_configured()?call_client_credential()->product_key:s_volc.product_key; }
const char *platform_runtime_device_name(void) { return ble_cloud_configured()?call_client_credential()->device_name:s_volc.device_name; }
const char *platform_runtime_device_secret(void) { return ble_cloud_configured()?call_client_credential()->device_secret:s_volc.device_secret; }
bool platform_runtime_rtc(void) { return ble_cloud_configured(); }
const char *platform_runtime_rtc_app_id(void) { return call_client_rtc_app_id(); }
void platform_runtime_engine_ready(bool ready) { call_client_engine_ready(ready); }
void platform_conversation_observe(const char *type) { call_client_observe(type); }
void platform_conversation_stop_result(bool started,int result) { call_client_stop_result(started,result); }
esp_err_t platform_conversation_begin(void)
{
if (ble_cloud_configured()) return call_client_begin();
atomic_store(&s_local_lease_active, false);
platform_agent_session_free(&s_active_session);
ESP_RETURN_ON_ERROR(recover_pending_session(), TAG, "recover interrupted session");
s_event_sequence = 0;
s_turn_sequence = 0;
return platform_create_agent_session(&s_active_session);
TickType_t requested_at = xTaskGetTickCount();
esp_err_t result = platform_create_agent_session(&s_active_session);
if (result == ESP_OK) {
atomic_store(&s_local_lease_deadline, requested_at + pdMS_TO_TICKS(45000));
atomic_store(&s_local_lease_active, true);
s_heartbeat_due = requested_at + pdMS_TO_TICKS(5000);
}
return result;
}
const char *platform_conversation_bot_id(void) { return s_active_session.bot_id; }
const char *platform_conversation_update_json(void) { return s_active_session.session_update_json; }
const char *platform_conversation_bot_id(void) { return ble_cloud_configured()?call_client_bot_id():s_active_session.bot_id; }
const char *platform_conversation_update_json(void) { return ble_cloud_configured()?call_client_params():s_active_session.session_update_json; }
esp_err_t platform_conversation_started(const char *provider_connection_ref)
{
if (ble_cloud_configured()) { call_client_observe("MEDIA_CONNECTED"); return ESP_OK; }
if (provider_connection_ref == NULL || provider_connection_ref[0] == '\0') return ESP_ERR_INVALID_ARG;
esp_err_t err = platform_report_session_started(&s_active_session, provider_connection_ref);
s_heartbeat_due = xTaskGetTickCount() + pdMS_TO_TICKS(s_heartbeat_interval_ms);
s_heartbeat_due = xTaskGetTickCount() + pdMS_TO_TICKS(5000);
return err;
}
int platform_conversation_poll(void)
{
if (!s_active_session.conversation_id[0]) return 1;
if (ble_cloud_configured()) return call_client_poll();
if (!s_active_session.conversation_id[0] || !platform_conversation_audio_authorized()) return 1;
TickType_t now = xTaskGetTickCount();
if ((int32_t)(now - s_heartbeat_due) < 0) return 0;
esp_err_t err = platform_report_session_event(&s_active_session, "session_updated", NULL);
s_heartbeat_due = xTaskGetTickCount() + pdMS_TO_TICKS(err == ESP_OK ? s_heartbeat_interval_ms : 5000);
if (err == ESP_ERR_INVALID_STATE) return 1;
s_heartbeat_due = xTaskGetTickCount() + pdMS_TO_TICKS(5000);
if (err == ESP_ERR_INVALID_STATE) { atomic_store(&s_local_lease_active, false); return 1; }
// Do not let an HTTP request that outlived the existing deadline resurrect audio.
if (!platform_conversation_audio_authorized()) { atomic_store(&s_local_lease_active, false); return 1; }
if (err == ESP_OK) atomic_store(&s_local_lease_deadline, now + pdMS_TO_TICKS(45000));
if (err != ESP_OK) ESP_LOGW(TAG, "PLATFORM_HEARTBEAT failed=%s", esp_err_to_name(err));
return 0;
}
esp_err_t platform_conversation_turn(const char *speaker, const char *source_id, const char *text)
{
if (ble_cloud_configured()) return call_client_turn(speaker,source_id,text);
if (s_active_session.native_subtitle_primary) return ESP_OK;
if (speaker == NULL || text == NULL || text[0] == '\0' || s_active_session.conversation_id[0] == '\0') return ESP_ERR_INVALID_ARG;
char timestamp[32], path[256], key[96], random[33];
@@ -758,6 +799,8 @@ esp_err_t platform_conversation_turn(const char *speaker, const char *source_id,
void platform_conversation_finish(bool failed, const char *error_code)
{
if (ble_cloud_configured()) { call_client_finish(failed); return; }
atomic_store(&s_local_lease_active,false);
if (s_active_session.conversation_id[0]) {
esp_err_t err = platform_report_session_event(&s_active_session,
failed ? "session_failed" : "session_ended",
@@ -56,6 +56,7 @@ const char *platform_conversation_bot_id(void);
const char *platform_conversation_update_json(void);
esp_err_t platform_conversation_started(const char *provider_connection_ref);
int platform_conversation_poll(void);
bool platform_conversation_audio_authorized(void);
esp_err_t platform_conversation_turn(const char *speaker, const char *source_id, const char *text);
void platform_conversation_finish(bool failed, const char *error_code);
@@ -2,8 +2,12 @@
# Note: if you have increased the bootloader size, make sure to update the offsets to avoid overlap
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 5M,
model, data, spiffs, , 4152K,
coredump, data, coredump,, 64K
storage, data, littlefs, , 2M,
eaf, data, spiffs, , 3200K,
ota_0, app, ota_0, 0x10000, 4M,
ota_1, app, ota_1, 0x410000, 4M,
otadata, data, ota, 0x810000, 0x2000,
model, data, spiffs, 0x812000, 512K,
# Keep existing diagnostic, storage, display and factory-identity addresses.
coredump, data, coredump, 0x91e000, 64K,
storage, data, littlefs, 0x92e000, 2M,
eaf, data, spiffs, 0xb2e000, 3200K,
ble_factory, data, nvs, 0xe4e000, 0x6000,
1 # Name, Type, SubType, Offset, Size, Flags
2 # Note: if you have increased the bootloader size, make sure to update the offsets to avoid overlap
3 nvs, data, nvs, 0x9000, 0x6000,
4 phy_init, data, phy, 0xf000, 0x1000,
5 factory, app, factory, 0x10000, 5M, ota_0, app, ota_0, 0x10000, 4M,
6 model, data, spiffs, , 4152K, ota_1, app, ota_1, 0x410000, 4M,
7 coredump, data, coredump,, 64K otadata, data, ota, 0x810000, 0x2000,
8 storage, data, littlefs, , 2M, model, data, spiffs, 0x812000, 512K,
9 eaf, data, spiffs, , 3200K, # Keep existing diagnostic, storage, display and factory-identity addresses.
10 coredump, data, coredump, 0x91e000, 64K,
11 storage, data, littlefs, 0x92e000, 2M,
12 eaf, data, spiffs, 0xb2e000, 3200K,
13 ble_factory, data, nvs, 0xe4e000, 0x6000,
+340 -9
View File
@@ -421,7 +421,8 @@ CONFIG_BOOTLOADER_PROJECT_VER=1
#
# Application Rollback
#
# CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE is not set
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
# CONFIG_BOOTLOADER_APP_ANTI_ROLLBACK is not set
# end of Application Rollback
#
@@ -599,6 +600,7 @@ CONFIG_PARTITION_TABLE_MD5=y
# Example Configuration
#
CONFIG_VOLC_WS_MODE=y
CONFIG_VOLC_RTC_MODE=y
#
# Develop Configuration
@@ -1033,16 +1035,297 @@ CONFIG_APPTRACE_LOCK_ENABLE=y
#
# Bluetooth
#
# CONFIG_BT_ENABLED is not set
CONFIG_BT_ENABLED=y
# CONFIG_BT_BLUEDROID_ENABLED is not set
CONFIG_BT_NIMBLE_ENABLED=y
# CONFIG_BT_CONTROLLER_ONLY is not set
CONFIG_BT_CONTROLLER_ENABLED=y
# CONFIG_BT_CONTROLLER_DISABLED is not set
#
# NimBLE Options
#
# CONFIG_BT_NIMBLE_MEM_ALLOC_MODE_INTERNAL is not set
CONFIG_BT_NIMBLE_MEM_ALLOC_MODE_EXTERNAL=y
# CONFIG_BT_NIMBLE_MEM_ALLOC_MODE_DEFAULT is not set
# CONFIG_BT_NIMBLE_LOG_LEVEL_NONE is not set
# CONFIG_BT_NIMBLE_LOG_LEVEL_ERROR is not set
# CONFIG_BT_NIMBLE_LOG_LEVEL_WARNING is not set
CONFIG_BT_NIMBLE_LOG_LEVEL_INFO=y
# CONFIG_BT_NIMBLE_LOG_LEVEL_DEBUG is not set
CONFIG_BT_NIMBLE_LOG_LEVEL=1
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
CONFIG_BT_NIMBLE_MAX_BONDS=3
CONFIG_BT_NIMBLE_MAX_CCCDS=8
CONFIG_BT_NIMBLE_L2CAP_COC_MAX_NUM=0
CONFIG_BT_NIMBLE_PINNED_TO_CORE_0=y
# CONFIG_BT_NIMBLE_PINNED_TO_CORE_1 is not set
CONFIG_BT_NIMBLE_PINNED_TO_CORE=0
CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=4096
CONFIG_BT_NIMBLE_ROLE_CENTRAL=y
CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=y
CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y
CONFIG_BT_NIMBLE_ROLE_OBSERVER=y
CONFIG_BT_NIMBLE_GATT_CLIENT=y
CONFIG_BT_NIMBLE_GATT_SERVER=y
# CONFIG_BT_NIMBLE_NVS_PERSIST is not set
# CONFIG_BT_NIMBLE_SMP_ID_RESET is not set
CONFIG_BT_NIMBLE_SECURITY_ENABLE=y
CONFIG_BT_NIMBLE_SM_LEGACY=y
CONFIG_BT_NIMBLE_SM_SC=y
# CONFIG_BT_NIMBLE_SM_SC_DEBUG_KEYS is not set
CONFIG_BT_NIMBLE_LL_CFG_FEAT_LE_ENCRYPTION=y
CONFIG_BT_NIMBLE_SM_LVL=0
CONFIG_BT_NIMBLE_SM_SC_ONLY=0
CONFIG_BT_NIMBLE_PRINT_ERR_NAME=y
# CONFIG_BT_NIMBLE_DEBUG is not set
# CONFIG_BT_NIMBLE_DYNAMIC_SERVICE is not set
CONFIG_BT_NIMBLE_SVC_GAP_DEVICE_NAME="nimble"
CONFIG_BT_NIMBLE_GAP_DEVICE_NAME_MAX_LEN=31
CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=256
CONFIG_BT_NIMBLE_ATT_MAX_PREP_ENTRIES=64
CONFIG_BT_NIMBLE_SVC_GAP_APPEARANCE=0
#
# Memory Settings
#
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=12
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_SIZE=256
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=24
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_SIZE=320
CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=24
CONFIG_BT_NIMBLE_TRANSPORT_ACL_SIZE=255
CONFIG_BT_NIMBLE_TRANSPORT_EVT_SIZE=70
CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=30
CONFIG_BT_NIMBLE_TRANSPORT_EVT_DISCARD_COUNT=8
CONFIG_BT_NIMBLE_L2CAP_COC_SDU_BUFF_COUNT=1
# end of Memory Settings
CONFIG_BT_NIMBLE_GATT_MAX_PROCS=4
# CONFIG_BT_NIMBLE_HS_FLOW_CTRL is not set
CONFIG_BT_NIMBLE_RPA_TIMEOUT=900
# CONFIG_BT_NIMBLE_MESH is not set
CONFIG_BT_NIMBLE_CRYPTO_STACK_MBEDTLS=y
CONFIG_BT_NIMBLE_HS_STOP_TIMEOUT_MS=2000
CONFIG_BT_NIMBLE_ENABLE_CONN_REATTEMPT=y
CONFIG_BT_NIMBLE_MAX_CONN_REATTEMPT=3
# CONFIG_BT_NIMBLE_HANDLE_REPEAT_PAIRING_DELETION is not set
CONFIG_BT_NIMBLE_50_FEATURE_SUPPORT=y
CONFIG_BT_NIMBLE_LL_CFG_FEAT_LE_2M_PHY=y
CONFIG_BT_NIMBLE_LL_CFG_FEAT_LE_CODED_PHY=y
# CONFIG_BT_NIMBLE_EXT_ADV is not set
CONFIG_BT_NIMBLE_EXT_SCAN=y
CONFIG_BT_NIMBLE_ENABLE_PERIODIC_SYNC=y
CONFIG_BT_NIMBLE_MAX_PERIODIC_SYNCS=0
# CONFIG_BT_NIMBLE_GATT_CACHING is not set
# CONFIG_BT_NIMBLE_INCL_SVC_DISCOVERY is not set
CONFIG_BT_NIMBLE_WHITELIST_SIZE=12
# CONFIG_BT_NIMBLE_TEST_THROUGHPUT_TEST is not set
# CONFIG_BT_NIMBLE_BLUFI_ENABLE is not set
CONFIG_BT_NIMBLE_USE_ESP_TIMER=y
CONFIG_BT_NIMBLE_LEGACY_VHCI_ENABLE=y
# CONFIG_BT_NIMBLE_BLE_GATT_BLOB_TRANSFER is not set
#
# BLE Services
#
CONFIG_BT_NIMBLE_PROX_SERVICE=y
CONFIG_BT_NIMBLE_ANS_SERVICE=y
CONFIG_BT_NIMBLE_CTS_SERVICE=y
CONFIG_BT_NIMBLE_HTP_SERVICE=y
CONFIG_BT_NIMBLE_IPSS_SERVICE=y
CONFIG_BT_NIMBLE_TPS_SERVICE=y
CONFIG_BT_NIMBLE_IAS_SERVICE=y
CONFIG_BT_NIMBLE_LLS_SERVICE=y
CONFIG_BT_NIMBLE_SPS_SERVICE=y
CONFIG_BT_NIMBLE_HR_SERVICE=y
# CONFIG_BT_NIMBLE_HID_SERVICE is not set
CONFIG_BT_NIMBLE_BAS_SERVICE=y
# CONFIG_BT_NIMBLE_SVC_BAS_BATTERY_LEVEL_NOTIFY is not set
CONFIG_BT_NIMBLE_DIS_SERVICE=y
# CONFIG_BT_NIMBLE_SVC_DIS_MANUFACTURER_NAME is not set
# CONFIG_BT_NIMBLE_SVC_DIS_SERIAL_NUMBER is not set
# CONFIG_BT_NIMBLE_SVC_DIS_HARDWARE_REVISION is not set
# CONFIG_BT_NIMBLE_SVC_DIS_FIRMWARE_REVISION is not set
# CONFIG_BT_NIMBLE_SVC_DIS_SOFTWARE_REVISION is not set
# CONFIG_BT_NIMBLE_SVC_DIS_SYSTEM_ID is not set
# CONFIG_BT_NIMBLE_SVC_DIS_PNP_ID is not set
# CONFIG_BT_NIMBLE_SVC_DIS_INCLUDED is not set
CONFIG_BT_NIMBLE_GAP_SERVICE=y
#
# GAP Appearance write permissions
#
# CONFIG_BT_NIMBLE_SVC_GAP_APPEAR_WRITE is not set
CONFIG_BT_NIMBLE_SVC_GAP_APPEAR_WRITE_PERM=0
CONFIG_BT_NIMBLE_SVC_GAP_APPEAR_WRITE_PERM_ENC=0
CONFIG_BT_NIMBLE_SVC_GAP_APPEAR_WRITE_PERM_ATHN=0
CONFIG_BT_NIMBLE_SVC_GAP_APPEAR_WRITE_PERM_ATHR=0
# end of GAP Appearance write permissions
CONFIG_BT_NIMBLE_SVC_GAP_CAR_CHAR_NOT_SUPP=y
# CONFIG_BT_NIMBLE_SVC_GAP_CAR_NOT_SUPP is not set
# CONFIG_BT_NIMBLE_SVC_GAP_CAR_SUPP is not set
CONFIG_BT_NIMBLE_SVC_GAP_CENT_ADDR_RESOLUTION=-1
#
# GAP device name write permissions
#
# CONFIG_BT_NIMBLE_SVC_GAP_NAME_WRITE is not set
# end of GAP device name write permissions
#
# Peripheral Preferred Connection Parameters (PPCP) settings
#
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_MAX_CONN_INTERVAL=0
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_MIN_CONN_INTERVAL=0
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SLAVE_LATENCY=0
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SUPERVISION_TMO=0
# end of Peripheral Preferred Connection Parameters (PPCP) settings
CONFIG_BT_NIMBLE_SVC_GAP_NAME_WRITE_PERM=0
CONFIG_BT_NIMBLE_SVC_GAP_NAME_WRITE_PERM_ENC=0
CONFIG_BT_NIMBLE_SVC_GAP_NAME_WRITE_PERM_AUTHEN=0
CONFIG_BT_NIMBLE_SVC_GAP_NAME_WRITE_PERM_AUTHOR=0
# CONFIG_BT_NIMBLE_SVC_GAP_GATT_SECURITY_LEVEL is not set
# CONFIG_BT_NIMBLE_SVC_GAP_RPA_ONLY is not set
# end of BLE Services
# CONFIG_BT_NIMBLE_VS_SUPPORT is not set
# CONFIG_BT_NIMBLE_ENC_ADV_DATA is not set
# CONFIG_BT_NIMBLE_HIGH_DUTY_ADV_ITVL is not set
# CONFIG_BT_NIMBLE_HOST_ALLOW_CONNECT_WITH_SCAN is not set
# CONFIG_BT_NIMBLE_HOST_QUEUE_CONG_CHECK is not set
# CONFIG_BT_NIMBLE_GATTC_PROC_PREEMPTION_PROTECT is not set
# CONFIG_BT_NIMBLE_GATTC_AUTO_PAIR is not set
#
# Host-controller Transport
#
CONFIG_UART_HW_FLOWCTRL_DISABLE=y
# CONFIG_UART_HW_FLOWCTRL_CTS_RTS is not set
CONFIG_BT_NIMBLE_HCI_UART_FLOW_CTRL=0
CONFIG_BT_NIMBLE_HCI_UART_RTS_PIN=19
CONFIG_BT_NIMBLE_HCI_UART_CTS_PIN=23
# end of Host-controller Transport
CONFIG_BT_NIMBLE_EATT_CHAN_NUM=0
# CONFIG_BT_NIMBLE_SUBRATE is not set
# end of NimBLE Options
#
# Controller Options
#
CONFIG_BT_CTRL_MODE_EFF=1
CONFIG_BT_CTRL_BLE_MAX_ACT=6
CONFIG_BT_CTRL_BLE_MAX_ACT_EFF=6
CONFIG_BT_CTRL_BLE_STATIC_ACL_TX_BUF_NB=0
CONFIG_BT_CTRL_PINNED_TO_CORE_0=y
# CONFIG_BT_CTRL_PINNED_TO_CORE_1 is not set
CONFIG_BT_CTRL_PINNED_TO_CORE=0
CONFIG_BT_CTRL_HCI_MODE_VHCI=y
# CONFIG_BT_CTRL_HCI_MODE_UART_H4 is not set
CONFIG_BT_CTRL_HCI_TL=1
CONFIG_BT_CTRL_ADV_DUP_FILT_MAX=30
CONFIG_BT_BLE_CCA_MODE_NONE=y
# CONFIG_BT_BLE_CCA_MODE_HW is not set
# CONFIG_BT_BLE_CCA_MODE_SW is not set
CONFIG_BT_BLE_CCA_MODE=0
CONFIG_BT_CTRL_HW_CCA_VAL=75
CONFIG_BT_CTRL_HW_CCA_EFF=0
CONFIG_BT_CTRL_CE_LENGTH_TYPE_ORIG=y
# CONFIG_BT_CTRL_CE_LENGTH_TYPE_CE is not set
# CONFIG_BT_CTRL_CE_LENGTH_TYPE_SD is not set
CONFIG_BT_CTRL_CE_LENGTH_TYPE_EFF=0
CONFIG_BT_CTRL_TX_ANTENNA_INDEX_0=y
# CONFIG_BT_CTRL_TX_ANTENNA_INDEX_1 is not set
CONFIG_BT_CTRL_TX_ANTENNA_INDEX_EFF=0
CONFIG_BT_CTRL_RX_ANTENNA_INDEX_0=y
# CONFIG_BT_CTRL_RX_ANTENNA_INDEX_1 is not set
CONFIG_BT_CTRL_RX_ANTENNA_INDEX_EFF=0
# CONFIG_BT_CTRL_DFT_TX_POWER_LEVEL_N24 is not set
# CONFIG_BT_CTRL_DFT_TX_POWER_LEVEL_N21 is not set
# CONFIG_BT_CTRL_DFT_TX_POWER_LEVEL_N18 is not set
# CONFIG_BT_CTRL_DFT_TX_POWER_LEVEL_N15 is not set
# CONFIG_BT_CTRL_DFT_TX_POWER_LEVEL_N12 is not set
# CONFIG_BT_CTRL_DFT_TX_POWER_LEVEL_N9 is not set
# CONFIG_BT_CTRL_DFT_TX_POWER_LEVEL_N6 is not set
# CONFIG_BT_CTRL_DFT_TX_POWER_LEVEL_N3 is not set
# CONFIG_BT_CTRL_DFT_TX_POWER_LEVEL_N0 is not set
# CONFIG_BT_CTRL_DFT_TX_POWER_LEVEL_P3 is not set
# CONFIG_BT_CTRL_DFT_TX_POWER_LEVEL_P6 is not set
CONFIG_BT_CTRL_DFT_TX_POWER_LEVEL_P9=y
# CONFIG_BT_CTRL_DFT_TX_POWER_LEVEL_P12 is not set
# CONFIG_BT_CTRL_DFT_TX_POWER_LEVEL_P15 is not set
# CONFIG_BT_CTRL_DFT_TX_POWER_LEVEL_P18 is not set
# CONFIG_BT_CTRL_DFT_TX_POWER_LEVEL_P20 is not set
CONFIG_BT_CTRL_DFT_TX_POWER_LEVEL_EFF=11
CONFIG_BT_CTRL_BLE_ADV_REPORT_FLOW_CTRL_SUPP=y
CONFIG_BT_CTRL_BLE_ADV_REPORT_FLOW_CTRL_NUM=100
CONFIG_BT_CTRL_BLE_ADV_REPORT_DISCARD_THRSHOLD=20
CONFIG_BT_CTRL_BLE_SCAN_DUPL=y
CONFIG_BT_CTRL_SCAN_DUPL_TYPE_DEVICE=y
# CONFIG_BT_CTRL_SCAN_DUPL_TYPE_DATA is not set
# CONFIG_BT_CTRL_SCAN_DUPL_TYPE_DATA_DEVICE is not set
CONFIG_BT_CTRL_SCAN_DUPL_TYPE=0
CONFIG_BT_CTRL_SCAN_DUPL_CACHE_SIZE=100
CONFIG_BT_CTRL_DUPL_SCAN_CACHE_REFRESH_PERIOD=0
# CONFIG_BT_CTRL_BLE_MESH_SCAN_DUPL_EN is not set
# CONFIG_BT_CTRL_COEX_PHY_CODED_TX_RX_TLIM_EN is not set
CONFIG_BT_CTRL_COEX_PHY_CODED_TX_RX_TLIM_DIS=y
CONFIG_BT_CTRL_COEX_PHY_CODED_TX_RX_TLIM_EFF=0
#
# MODEM SLEEP Options
#
# CONFIG_BT_CTRL_MODEM_SLEEP is not set
# end of MODEM SLEEP Options
CONFIG_BT_CTRL_SLEEP_MODE_EFF=0
CONFIG_BT_CTRL_SLEEP_CLOCK_EFF=0
CONFIG_BT_CTRL_HCI_TL_EFF=1
# CONFIG_BT_CTRL_AGC_RECORRECT_EN is not set
# CONFIG_BT_CTRL_SCAN_BACKOFF_UPPERLIMITMAX is not set
# CONFIG_BT_BLE_ADV_DATA_LENGTH_ZERO_AUX is not set
CONFIG_BT_CTRL_CHAN_ASS_EN=y
CONFIG_BT_CTRL_LE_PING_EN=y
#
# BLE disconnects when Instant Passed (0x28) occurs
#
# CONFIG_BT_CTRL_BLE_LLCP_CONN_UPDATE is not set
# CONFIG_BT_CTRL_BLE_LLCP_CHAN_MAP_UPDATE is not set
# CONFIG_BT_CTRL_BLE_LLCP_PHY_UPDATE is not set
# end of BLE disconnects when Instant Passed (0x28) occurs
# CONFIG_BT_CTRL_RUN_IN_FLASH_ONLY is not set
CONFIG_BT_CTRL_DTM_ENABLE=y
CONFIG_BT_CTRL_BLE_MASTER=y
# CONFIG_BT_CTRL_BLE_TEST is not set
CONFIG_BT_CTRL_BLE_SCAN=y
CONFIG_BT_CTRL_BLE_SECURITY_ENABLE=y
CONFIG_BT_CTRL_BLE_ADV=y
# CONFIG_BT_CTRL_CHECK_CONNECT_IND_ACCESS_ADDRESS is not set
#
# Controller debug log Options (Experimental)
#
# end of Controller debug log Options (Experimental)
# end of Controller Options
#
# Common Options
#
CONFIG_BT_ALARM_MAX_NUM=50
# CONFIG_BT_BLE_LOG_SPI_OUT_ENABLED is not set
# CONFIG_BT_BLE_LOG_UHCI_OUT_ENABLED is not set
# end of Common Options
# CONFIG_BT_HCI_LOG_DEBUG_EN is not set
# end of Bluetooth
# CONFIG_BLE_MESH is not set
#
# Console Library
#
@@ -1173,7 +1456,8 @@ CONFIG_ESP_TLS_DYN_BUF_STRATEGY_SUPPORTED=y
# Wireless Coexistence
#
CONFIG_ESP_COEX_ENABLED=y
# CONFIG_ESP_COEX_EXTERNAL_COEXIST_ENABLE is not set
CONFIG_ESP_COEX_SW_COEXIST_ENABLE=y
# CONFIG_ESP_COEX_POWER_MANAGEMENT is not set
# CONFIG_ESP_COEX_GPIO_DEBUG is not set
# end of Wireless Coexistence
@@ -2052,7 +2336,7 @@ CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES=y
CONFIG_LWIP_TIMERS_ONDEMAND=y
CONFIG_LWIP_ND6=y
# CONFIG_LWIP_FORCE_ROUTER_FORWARDING is not set
CONFIG_LWIP_MAX_SOCKETS=10
CONFIG_LWIP_MAX_SOCKETS=16
# CONFIG_LWIP_USE_ONLY_LWIP_SELECT is not set
# CONFIG_LWIP_SO_LINGER is not set
CONFIG_LWIP_SO_REUSE=y
@@ -2116,7 +2400,7 @@ CONFIG_LWIP_TCP_MSL=60000
CONFIG_LWIP_TCP_FIN_WAIT_TIMEOUT=20000
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=32768
CONFIG_LWIP_TCP_WND_DEFAULT=32768
CONFIG_LWIP_TCP_RECVMBOX_SIZE=6
CONFIG_LWIP_TCP_RECVMBOX_SIZE=24
CONFIG_LWIP_TCP_ACCEPTMBOX_SIZE=6
CONFIG_LWIP_TCP_QUEUE_OOSEQ=y
CONFIG_LWIP_TCP_OOSEQ_TIMEOUT=6
@@ -2659,6 +2943,11 @@ CONFIG_WL_SECTOR_SIZE=4096
#
CONFIG_WIFI_PROV_SCAN_MAX_ENTRIES=16
CONFIG_WIFI_PROV_AUTOSTOP_TIMEOUT=30
# CONFIG_WIFI_PROV_BLE_BONDING is not set
CONFIG_WIFI_PROV_BLE_SEC_CONN=y
# CONFIG_WIFI_PROV_BLE_FORCE_ENCRYPTION is not set
# CONFIG_WIFI_PROV_BLE_NOTIFY is not set
# CONFIG_WIFI_PROV_KEEP_BLE_ON_AFTER_PROV is not set
CONFIG_WIFI_PROV_STA_ALL_CHANNEL_SCAN=y
# CONFIG_WIFI_PROV_STA_FAST_SCAN is not set
# end of Wi-Fi Provisioning Manager
@@ -3467,7 +3756,8 @@ CONFIG_IDF_EXPERIMENTAL_FEATURES=y
# Deprecated options for backward compatibility
# CONFIG_APP_BUILD_TYPE_ELF_RAM is not set
# CONFIG_NO_BLOBS is not set
# CONFIG_APP_ROLLBACK_ENABLE is not set
CONFIG_APP_ROLLBACK_ENABLE=y
# CONFIG_APP_ANTI_ROLLBACK is not set
# CONFIG_LOG_BOOTLOADER_LEVEL_NONE is not set
# CONFIG_LOG_BOOTLOADER_LEVEL_ERROR is not set
# CONFIG_LOG_BOOTLOADER_LEVEL_WARN is not set
@@ -3499,8 +3789,49 @@ CONFIG_STACK_CHECK_NONE=y
# CONFIG_ESP32_APPTRACE_DEST_TRAX is not set
CONFIG_ESP32_APPTRACE_DEST_NONE=y
CONFIG_ESP32_APPTRACE_LOCK_ENABLE=y
# CONFIG_EXTERNAL_COEX_ENABLE is not set
# CONFIG_ESP_WIFI_EXTERNAL_COEXIST_ENABLE is not set
# CONFIG_BLUEDROID_ENABLED is not set
CONFIG_NIMBLE_ENABLED=y
# CONFIG_NIMBLE_MEM_ALLOC_MODE_INTERNAL is not set
CONFIG_NIMBLE_MEM_ALLOC_MODE_EXTERNAL=y
# CONFIG_NIMBLE_MEM_ALLOC_MODE_DEFAULT is not set
CONFIG_NIMBLE_MAX_CONNECTIONS=1
CONFIG_NIMBLE_MAX_BONDS=3
CONFIG_NIMBLE_MAX_CCCDS=8
CONFIG_NIMBLE_L2CAP_COC_MAX_NUM=0
CONFIG_NIMBLE_PINNED_TO_CORE_0=y
# CONFIG_NIMBLE_PINNED_TO_CORE_1 is not set
CONFIG_NIMBLE_PINNED_TO_CORE=0
CONFIG_NIMBLE_TASK_STACK_SIZE=4096
CONFIG_BT_NIMBLE_TASK_STACK_SIZE=4096
CONFIG_NIMBLE_ROLE_CENTRAL=y
CONFIG_NIMBLE_ROLE_PERIPHERAL=y
CONFIG_NIMBLE_ROLE_BROADCASTER=y
CONFIG_NIMBLE_ROLE_OBSERVER=y
# CONFIG_NIMBLE_NVS_PERSIST is not set
CONFIG_NIMBLE_SM_LEGACY=y
CONFIG_NIMBLE_SM_SC=y
# CONFIG_NIMBLE_SM_SC_DEBUG_KEYS is not set
CONFIG_BT_NIMBLE_SM_SC_LVL=0
# CONFIG_NIMBLE_DEBUG is not set
CONFIG_NIMBLE_SVC_GAP_DEVICE_NAME="nimble"
CONFIG_NIMBLE_GAP_DEVICE_NAME_MAX_LEN=31
CONFIG_NIMBLE_ATT_PREFERRED_MTU=256
CONFIG_NIMBLE_SVC_GAP_APPEARANCE=0
CONFIG_BT_NIMBLE_MSYS1_BLOCK_COUNT=12
CONFIG_BT_NIMBLE_ACL_BUF_COUNT=24
CONFIG_BT_NIMBLE_ACL_BUF_SIZE=255
CONFIG_BT_NIMBLE_HCI_EVT_BUF_SIZE=70
CONFIG_BT_NIMBLE_HCI_EVT_HI_BUF_COUNT=30
CONFIG_BT_NIMBLE_HCI_EVT_LO_BUF_COUNT=8
# CONFIG_NIMBLE_HS_FLOW_CTRL is not set
CONFIG_NIMBLE_RPA_TIMEOUT=900
# CONFIG_NIMBLE_MESH is not set
CONFIG_NIMBLE_CRYPTO_STACK_MBEDTLS=y
# CONFIG_BT_NIMBLE_COEX_PHY_CODED_TX_RX_TLIM_EN is not set
CONFIG_BT_NIMBLE_COEX_PHY_CODED_TX_RX_TLIM_DIS=y
CONFIG_SW_COEXIST_ENABLE=y
CONFIG_ESP32_WIFI_SW_COEXIST_ENABLE=y
CONFIG_ESP_WIFI_SW_COEXIST_ENABLE=y
# CONFIG_CAM_CTLR_DVP_CAM_ISR_IRAM_SAFE is not set
# CONFIG_GPTIMER_ISR_IRAM_SAFE is not set
# CONFIG_MCPWM_ISR_IRAM_SAFE is not set
@@ -3628,7 +3959,7 @@ CONFIG_TCP_MSS=1440
CONFIG_TCP_MSL=60000
CONFIG_TCP_SND_BUF_DEFAULT=32768
CONFIG_TCP_WND_DEFAULT=32768
CONFIG_TCP_RECVMBOX_SIZE=6
CONFIG_TCP_RECVMBOX_SIZE=24
CONFIG_TCP_QUEUE_OOSEQ=y
CONFIG_TCP_OVERSIZE_MSS=y
# CONFIG_TCP_OVERSIZE_QUARTER_MSS is not set
@@ -1,6 +1,15 @@
#
# Serial flasher config
#
CONFIG_BT_ENABLED=y
CONFIG_VOLC_WS_MODE=y
CONFIG_VOLC_RTC_MODE=y
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
CONFIG_BT_NIMBLE_MEM_ALLOC_MODE_EXTERNAL=y
CONFIG_ESP_PROTOCOMM_SUPPORT_SECURITY_VERSION_2=y
# CONFIG_ESP_PROTOCOMM_SUPPORT_SECURITY_VERSION_0 is not set
# CONFIG_ESP_PROTOCOMM_SUPPORT_SECURITY_VERSION_1 is not set
CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
CONFIG_ESPTOOLPY_FLASH_SAMPLE_MODE_STR=y
CONFIG_ESPTOOLPY_FLASHFREQ_80M=y
@@ -66,3 +75,8 @@ CONFIG_HTTPD_PURGE_BUF_LEN=32
CONFIG_HTTPD_SERVER_EVENT_POST_TIMEOUT=2000
# end of HTTP Server
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
CONFIG_LWIP_MAX_SOCKETS=16
# Match a 32KiB TCP RX window: 32768 / 1440 MSS + 2 packets.
CONFIG_LWIP_TCP_RECVMBOX_SIZE=24
@@ -124,3 +124,6 @@ CONFIG_SR_WN_WN9_HILEXIN=y
CONFIG_EXAMPLE_LCD_INTERFACE_QSPI=y
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=32768
CONFIG_LWIP_TCP_WND_DEFAULT=32768
# Match a 32KiB TCP RX window: 32768 / 1440 MSS + 2 packets.
CONFIG_LWIP_TCP_RECVMBOX_SIZE=24
@@ -0,0 +1,56 @@
// Compile the real endpoint with the exact ESP-IDF cJSON implementation on a host.
const fs = require('node:fs'), path = require('node:path');
const src=fs.readFileSync(path.join(__dirname,'../main/ble_setup.c'),'utf8');
const jsonDir=path.join(process.env.IDF_PATH||'E:/Codex/toolchains/esp-idf-v5.5.1','components/json/cJSON');
const jsonHeader=fs.readFileSync(path.join(jsonDir,'cJSON.h'),'utf8');
const jsonSource=fs.readFileSync(path.join(jsonDir,'cJSON.c'),'utf8').replace('#include "cJSON.h"','');
const globals=src.slice(src.indexOf('typedef struct { uint32_t version;'),src.indexOf('static void show('));
const endpoint=src.slice(src.indexOf('static bool valid_password('),src.indexOf('static void terminate_link('));
process.stdout.write(jsonHeader+'\n'+jsonSource+`
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <sys/types.h>
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_ERR_INVALID_ARG 1
#define ESP_ERR_NO_MEM 2
#define portMAX_DELAY 0
static int state_mutex;
static int64_t now=1,window_end=300000000;
static int64_t esp_timer_get_time(void){return now;}
static void xSemaphoreTake(int m,int t){(void)m;(void)t;}
static void xSemaphoreGive(int m){(void)m;}
static void mbedtls_platform_zeroize(void *p,size_t n){memset(p,0,n);}
static void show(const char *s){(void)s;}
static const char *ble_factory_id(void){return "KP01-B43A451634A8";}
static const char *ble_factory_model(void){return "ECHOEAR-S3";}
`+globals+endpoint+`
static cJSON *run(const char *text){uint8_t *out=NULL;ssize_t len=0;assert(endpoint(0,(uint8_t*)text,strlen(text),&out,&len,NULL)==ESP_OK);assert(out&&len==(ssize_t)strlen((char*)out));cJSON *j=cJSON_Parse((char*)out);free(out);return j;}
static void status_is(const char *request,const char *expected){cJSON *r=run(request);assert(!strcmp(json_string(r,"status"),expected));cJSON_Delete(r);}
int main(void){
const char *wifi="{\\"cmd\\":\\"configure\\",\\"wifi_ssid\\":\\"Home\\",\\"wifi_password\\":\\"TestOnly123\\",\\"bind_token\\":\\"\\"}";
cJSON *r=run("{\\"cmd\\":\\"get_info\\"}");assert(cJSON_GetObjectItem(r,"protocol_version")->valueint==2);assert(!strcmp(json_string(r,"device_id"),ble_factory_id()));cJSON_Delete(r);
status_is("{\\"cmd\\":\\"get_status\\"}","idle");
status_is(wifi,"received");assert(job.version==2&&job.token[0]==0);int64_t deadline=confirm_until;
status_is(wifi,"received");assert(confirm_until==deadline);
status_is("{\\"cmd\\":\\"configure\\",\\"wifi_ssid\\":\\"Other\\",\\"wifi_password\\":\\"TestOnly123\\",\\"bind_token\\":\\"\\"}","failed");assert(!strcmp(job.ssid,"Home"));
now=confirm_until;status_is("{\\"cmd\\":\\"get_status\\"}","failed");
status_is(wifi,"received");state=SUCCESS;status_is(wifi,"success");state=FAILED;
status_is("{\\"cmd\\":\\"configure\\",\\"wifi_ssid\\":\\"Home\\",\\"wifi_password\\":\\"TestOnly123\\",\\"bind_token\\":\\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\\"}","received");assert(strlen(job.token)==43);
status_is(wifi,"failed");assert(strlen(job.token)==43);state=FAILED;
status_is("{\\"cmd\\":\\"configure\\",\\"wifi_ssid\\":\\"Home\\",\\"wifi_password\\":\\"short\\",\\"bind_token\\":\\"\\"}","failed");
status_is("{\\"cmd\\":\\"configure\\",\\"wifi_ssid\\":\\"Home\\",\\"wifi_password\\":\\"TestOnly123\\",\\"bind_token\\":\\"short\\"}","failed");
const char *bad[]={"{\\"cmd\\":\\"get_info\\",\\"cmd\\":\\"get_info\\"}","{\\"cmd\\":\\"get_info\\",\\"extra\\":\\"x\\"}","{\\"cmd\\":\\"get_info\\"}{}","{\\"cmd\\":\\"unknown\\"}"};
for(size_t i=0;i<sizeof(bad)/sizeof(bad[0]);i++){uint8_t *out=NULL;ssize_t len=0;assert(endpoint(0,(uint8_t*)bad[i],strlen(bad[i]),&out,&len,NULL)==ESP_ERR_INVALID_ARG);}
state=FAILED;
const char *reset="{\\\"cmd\\\":\\\"factory_reset\\\",\\\"wifi_ssid\\\":\\\"Home\\\",\\\"wifi_password\\\":\\\"TestOnly123\\\",\\\"bind_token\\\":\\\"\\\"}";
status_is(reset,"received");assert(job.version==3&&job.press[0]==0&&job.token[0]==0);
status_is(reset,"received");status_is(wifi,"failed");assert(job.version==3);
r=run("{\\\"cmd\\\":\\\"get_info\\\"}");assert(cJSON_IsTrue(cJSON_GetObjectItem(r,"factory_reset_supported")));cJSON_Delete(r);
state=FAILED;
status_is("{\\\"cmd\\\":\\\"factory_reset\\\",\\\"wifi_ssid\\\":\\\"Home\\\",\\\"wifi_password\\\":\\\"TestOnly123\\\",\\\"bind_token\\\":\\\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\\\"}","failed");
now=window_end;status_is(wifi,"failed");status_is(reset,"failed");
puts("PASS: plaintext C endpoint: info, bind/WiFi-only, idempotency, conflict, retries, timeout, malformed input");return 0;
}
`.replace(/\\\\"/g,'\\"'));
@@ -0,0 +1,69 @@
// Test the actual physical confirmation and expired-window recovery functions.
const fs = require('node:fs'), path = require('node:path');
const src = fs.readFileSync(path.join(__dirname, '../main/ble_setup.c'), 'utf8');
const globals = src.slice(src.indexOf('typedef struct { uint32_t version;'), src.indexOf('static void show('));
const functions = src.slice(src.indexOf('bool ble_setup_confirm('), src.indexOf('static bool valid_password('));
process.stdout.write(`#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <stdatomic.h>
#define portMAX_DELAY 0
#define ESP_OK 0
#define ESP_FAIL 1
#define ESP_ERR_NVS_NOT_FOUND 2
#define NVS_READONLY 0
#define NVS_READWRITE 1
typedef int esp_err_t;
typedef int nvs_handle_t;
static int state_mutex,binding_task;
static atomic_bool enabled;
static int64_t now,window_end;
static int restarted,notified,setup_requested,commits;
static bool pending,storage_error;
static void xSemaphoreTake(int m,int t){(void)m;(void)t;}
static void xSemaphoreGive(int m){(void)m;}
static int64_t esp_timer_get_time(void){return now;}
static void ble_random_uuid(char *out){strcpy(out,"40000000-0000-4000-8000-000000000001");}
static void xTaskNotifyGive(int t){(void)t;notified++;}
static esp_err_t nvs_open(const char *name,int mode,nvs_handle_t *out){(void)mode;*out=!strcmp(name,"ble_setup")?1:2;return storage_error?ESP_FAIL:ESP_OK;}
static esp_err_t nvs_get_blob(nvs_handle_t n,const char *key,void *out,size_t *len){assert(n==1&&!strcmp(key,"pending")&&!out);*len=pending?100:0;return pending?ESP_OK:ESP_ERR_NVS_NOT_FOUND;}
static esp_err_t nvs_set_u8(nvs_handle_t n,const char *key,uint8_t value){assert(n==2&&!strcmp(key,"setup_requested"));setup_requested=value;return ESP_OK;}
static esp_err_t nvs_commit(nvs_handle_t n){assert(n==2);commits++;return ESP_OK;}
static void nvs_close(nvs_handle_t n){(void)n;}
static void show(const char *text){(void)text;}
static void esp_restart(void){restarted++;}
static const char *esp_err_to_name(esp_err_t err){(void)err;return "mock";}
static const char *TAG="ble_setup";
static void mock_log(const char *tag,const char *format,...){(void)tag;(void)format;}
#define ESP_LOGI(...) mock_log(__VA_ARGS__)
#define ESP_LOGE(...) mock_log(__VA_ARGS__)
` + globals + functions + `
static void reset(void){
memset(&job,0,sizeof(job));enabled=true;recover_saved_wifi=true;timeout_handled=false;
now=1;window_end=300000000;confirm_until=60000000;state=IDLE;failure_message="";
pending=storage_error=false;restarted=notified=commits=0;setup_requested=1;
}
int main(void){
reset();recover_wifi_after_timeout();assert(!restarted&&setup_requested==1);
now=window_end;recover_wifi_after_timeout();assert(restarted==1&&setup_requested==0&&commits==1);
recover_wifi_after_timeout();assert(restarted==1);
reset();state=RECEIVED;now=confirm_until;assert(ble_setup_confirm()&&state==FAILED&&!notified);
now=window_end;assert(!ble_setup_confirm());recover_wifi_after_timeout();assert(restarted==1);
reset();state=RECEIVED;assert(ble_setup_confirm()&&state==WIFI_CONNECTING&&notified==1&&strlen(job.press)==36);
now=window_end;recover_wifi_after_timeout();assert(!restarted&&setup_requested==1);
const state_t busy[]={RECEIVED,WIFI_CONNECTING,BINDING,SUCCESS};
for(size_t i=0;i<sizeof(busy)/sizeof(busy[0]);i++){
reset();state=busy[i];confirm_until=window_end+60000000;now=window_end;recover_wifi_after_timeout();assert(!restarted&&!timeout_handled&&setup_requested==1);
}
reset();pending=true;now=window_end;recover_wifi_after_timeout();assert(!restarted&&setup_requested==1);
reset();recover_saved_wifi=false;now=window_end;recover_wifi_after_timeout();assert(!restarted&&setup_requested==1);
reset();storage_error=true;now=window_end;recover_wifi_after_timeout();assert(!restarted&&setup_requested==1);
reset();enabled=false;now=window_end;recover_wifi_after_timeout();assert(!restarted&&!timeout_handled);
reset();state=BINDING;now=window_end;recover_wifi_after_timeout();assert(!restarted);
state=FAILED;recover_wifi_after_timeout();assert(restarted==1&&setup_requested==0);
puts("PASS production BLE: confirmation timeout/reopen, unattended window restores saved WiFi, pending/first-time/storage/busy guards, one restart");
return 0;
}
`);
@@ -0,0 +1,208 @@
// Host SDK shim: only SDK calls are mocked; the driver lifecycle under test is
// inserted verbatim from main/ble_wifi.c by ble-wifi-host.cjs.
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_ERR_NO_MEM 1
#define ESP_ERR_INVALID_STATE 2
#define ESP_ERR_INVALID_ARG 3
#define ESP_ERR_TIMEOUT 4
#define ESP_FAIL 5
#define BIT0 1
#define BIT1 2
#define BIT2 4
#define pdFALSE 0
#define pdMS_TO_TICKS(x) (x)
#define MALLOC_CAP_INTERNAL 1
#define ESP_EVENT_ANY_ID (-1)
#define WIFI_EVENT 1
#define IP_EVENT 2
#define IP_EVENT_STA_GOT_IP 1
#define WIFI_EVENT_STA_DISCONNECTED 2
#define WIFI_EVENT_STA_STOP 3
#define WIFI_STORAGE_RAM 1
#define WIFI_MODE_STA 1
#define WIFI_IF_STA 0
#define WIFI_AUTH_WPA2_PSK 3
#define WIFI_ALL_CHANNEL_SCAN 1
#define WIFI_CONNECT_AP_BY_SIGNAL 1
typedef int esp_event_base_t;
typedef int *esp_event_handler_instance_t;
typedef unsigned EventBits_t;
typedef EventBits_t *EventGroupHandle_t;
typedef struct { int placeholder; } esp_netif_t;
typedef struct { int placeholder; } esp_netif_config_t;
#define ESP_NETIF_DEFAULT_WIFI_STA() ((esp_netif_config_t){0})
typedef struct { unsigned reason; } wifi_event_sta_disconnected_t;
typedef struct {
int static_rx_buf_num, dynamic_rx_buf_num, static_tx_buf_num, cache_tx_buf_num;
int ampdu_rx_enable, ampdu_tx_enable, amsdu_tx_enable;
} wifi_init_config_t;
#define WIFI_INIT_CONFIG_DEFAULT() ((wifi_init_config_t){16,32,16,32,1,1,0})
typedef struct { struct {
char ssid[32], password[64];
struct { int authmode; } threshold;
int scan_method, sort_method;
} sta; } wifi_config_t;
static void mock_log(const char *tag, const char *format, ...) {(void)tag; (void)format;}
#define ESP_LOGI(...) mock_log(__VA_ARGS__)
#define ESP_LOGW(...) mock_log(__VA_ARGS__)
#define ESP_LOGE(...) mock_log(__VA_ARGS__)
static const char *esp_err_to_name(esp_err_t err) {(void)err; return "mock";}
static unsigned heap_caps_get_free_size(int cap) {(void)cap; return 50000;}
static unsigned heap_caps_get_largest_free_block(int cap) {(void)cap; return 30000;}
enum point { NONE, GROUP, NEW_NETIF, ATTACH, DEFAULT_HANDLERS, DRIVER,
WIFI_HANDLER, IP_HANDLER, STORAGE, MODE, CONFIG, START, CONNECT, STOP, POINTS };
static int calls[POINTS], fail_point, failures, destroys, foreign;
static bool should_connect, emit_disconnect, stop_ack;
static EventBits_t bits;
static esp_netif_t mock_station, other_station;
static int handler_handles[2];
static wifi_config_t last_config;
static void (*callback)(void *, esp_event_base_t, int32_t, void *);
static bool fail(enum point point) {
calls[point]++;
if (fail_point == (int)point && failures) {failures--; return true;}
return false;
}
static EventGroupHandle_t xEventGroupCreate(void) {return fail(GROUP) ? NULL : &bits;}
static EventBits_t xEventGroupGetBits(EventGroupHandle_t e) {assert(e); return *e;}
static EventBits_t xEventGroupSetBits(EventGroupHandle_t e, EventBits_t b) {return *e |= b;}
static EventBits_t xEventGroupClearBits(EventGroupHandle_t e, EventBits_t b) {EventBits_t old=*e; *e &= ~b; return old;}
static EventBits_t xEventGroupWaitBits(EventGroupHandle_t e, EventBits_t b, int clear, int all, int ticks) {
(void)b; (void)clear; (void)all; (void)ticks; return *e;
}
static void vTaskDelay(int ticks) {(void)ticks;}
static esp_netif_t *esp_netif_get_handle_from_ifkey(const char *key) {
assert(!strcmp(key, "WIFI_STA")); return foreign ? &other_station : NULL;
}
static esp_netif_t *esp_netif_new(const esp_netif_config_t *c) {
(void)c; return fail(NEW_NETIF) ? NULL : &mock_station;
}
static esp_err_t esp_netif_attach_wifi_station(esp_netif_t *n) {assert(n); return fail(ATTACH) ? ESP_FAIL : ESP_OK;}
static esp_err_t esp_wifi_set_default_wifi_sta_handlers(void) {return fail(DEFAULT_HANDLERS) ? ESP_FAIL : ESP_OK;}
static void esp_netif_destroy_default_wifi(esp_netif_t *n) {assert(n == &mock_station); destroys++;}
static esp_err_t esp_wifi_init(const wifi_init_config_t *c) {
assert(c->static_rx_buf_num == 4 && c->static_tx_buf_num == 4);
assert(c->dynamic_rx_buf_num >= c->static_rx_buf_num && c->cache_tx_buf_num == 4);
assert(!c->ampdu_rx_enable && !c->ampdu_tx_enable && !c->amsdu_tx_enable);
return fail(DRIVER) ? ESP_ERR_NO_MEM : ESP_OK;
}
static esp_err_t esp_event_handler_instance_register(esp_event_base_t base, int id,
void (*cb)(void *, esp_event_base_t, int32_t, void *), void *arg, esp_event_handler_instance_t *instance) {
(void)id; (void)arg;
if (fail(base == WIFI_EVENT ? WIFI_HANDLER : IP_HANDLER)) return ESP_ERR_NO_MEM;
callback = cb; *instance = &handler_handles[base == WIFI_EVENT ? 0 : 1]; return ESP_OK;
}
static esp_err_t esp_wifi_set_storage(int storage) {assert(storage == WIFI_STORAGE_RAM); return fail(STORAGE) ? ESP_FAIL : ESP_OK;}
static esp_err_t esp_wifi_set_mode(int mode) {assert(mode == WIFI_MODE_STA); return fail(MODE) ? ESP_FAIL : ESP_OK;}
static esp_err_t esp_wifi_set_config(int iface, const wifi_config_t *c) {
assert(iface == WIFI_IF_STA); last_config = *c; return fail(CONFIG) ? ESP_FAIL : ESP_OK;
}
static esp_err_t esp_wifi_start(void) {return fail(START) ? ESP_ERR_NO_MEM : ESP_OK;}
static esp_err_t esp_wifi_connect(void) {
if (fail(CONNECT)) return ESP_FAIL;
if (should_connect) callback(NULL, IP_EVENT, IP_EVENT_STA_GOT_IP, NULL);
else if (emit_disconnect) {wifi_event_sta_disconnected_t e = {201}; callback(NULL, WIFI_EVENT, WIFI_EVENT_STA_DISCONNECTED, &e);}
return ESP_OK;
}
static esp_err_t esp_wifi_stop(void) {
if (fail(STOP)) return ESP_FAIL;
if (stop_ack) callback(NULL, WIFI_EVENT, WIFI_EVENT_STA_STOP, NULL);
return ESP_OK;
}
/* PRODUCTION_SOURCE */
static void reset(void) {
station=NULL; events=NULL; wifi_handler=NULL; ip_handler=NULL;
driver_ready=false; prepared=false; started=false; stopping=false;
memset(calls,0,sizeof(calls)); memset(&last_config,0,sizeof(last_config));
fail_point=NONE; failures=0; destroys=0; foreign=0; bits=0;
should_connect=true; emit_disconnect=false; stop_ack=true; callback=NULL;
}
int main(void) {
unsigned tests=0;
reset();
assert(!ble_wifi_connected());
assert(ble_wifi_prepare()==ESP_OK && ble_wifi_prepare()==ESP_OK);
assert(calls[DRIVER]==1 && calls[NEW_NETIF]==1 && calls[GROUP]==1);
assert(calls[START]==0 && calls[CONNECT]==0); tests++;
// Every initialization failure is recoverable, without duplicate owners.
for (enum point p=GROUP; p<=MODE; ++p) {
reset(); fail_point=p; failures=1;
assert(ble_wifi_prepare()!=ESP_OK);
assert(ble_wifi_prepare()==ESP_OK);
assert(ble_wifi_prepare()==ESP_OK);
assert(calls[NEW_NETIF] <= ((p==NEW_NETIF || p==ATTACH || p==DEFAULT_HANDLERS) ? 2 : 1));
assert(calls[GROUP] == (p==GROUP ? 2 : 1));
assert(calls[DRIVER] == (p==DRIVER ? 2 : 1));
assert(destroys == (p==ATTACH || p==DEFAULT_HANDLERS ? 1 : 0));
tests++;
}
reset(); fail_point=DRIVER; failures=10;
for (int i=0; i<10; ++i) assert(ble_wifi_prepare()==ESP_ERR_NO_MEM);
assert(ble_wifi_prepare()==ESP_OK);
assert(calls[NEW_NETIF]==1 && calls[GROUP]==1 && calls[DEFAULT_HANDLERS]==1); tests++;
reset(); foreign=1;
assert(ble_wifi_prepare()==ESP_ERR_INVALID_STATE);
assert(calls[NEW_NETIF]==0 && destroys==0); tests++;
reset(); assert(ble_wifi_connect("test-network","test-password")==ESP_OK);
assert(ble_wifi_connected());
assert(!strcmp(last_config.sta.ssid,"test-network"));
assert(!strcmp(last_config.sta.password,"test-password"));
assert(last_config.sta.threshold.authmode==WIFI_AUTH_WPA2_PSK);
assert(ble_wifi_connect("new-network","new-password")==ESP_OK);
assert(calls[DRIVER]==1 && calls[NEW_NETIF]==1 && calls[STOP]==1);
assert(!strcmp(last_config.sta.ssid,"new-network")); tests++;
for (enum point p=CONFIG; p<=CONNECT; ++p) {
reset(); fail_point=p; failures=3;
while(failures) assert(ble_wifi_connect("test-network","test-password")!=ESP_OK);
assert(ble_wifi_connect("test-network","test-password")==ESP_OK);
assert(calls[DRIVER]==1 && calls[NEW_NETIF]==1 && calls[GROUP]==1); tests++;
}
for (int disconnect=0; disconnect<2; ++disconnect) {
reset(); should_connect=false; emit_disconnect=disconnect;
assert(ble_wifi_connect("test-network","test-password")==ESP_ERR_TIMEOUT);
assert(!ble_wifi_connected() && calls[START]==3 && calls[STOP]==3);
should_connect=true;
assert(ble_wifi_connect("test-network","test-password")==ESP_OK);
assert(calls[NEW_NETIF]==1 && calls[DRIVER]==1); tests++;
}
reset(); assert(ble_wifi_connect("test-network","test-password")==ESP_OK);
wifi_event_sta_disconnected_t e={201};
callback(NULL,WIFI_EVENT,WIFI_EVENT_STA_DISCONNECTED,&e);
assert(!ble_wifi_connected()); tests++;
reset(); assert(ble_wifi_connect("test-network","test-password")==ESP_OK);
fail_point=STOP; failures=1;
assert(ble_wifi_connect("test-network","test-password")==ESP_FAIL);
assert(ble_wifi_connect("test-network","test-password")==ESP_OK);
assert(calls[DRIVER]==1); tests++;
reset(); assert(ble_wifi_connect("test-network","test-password")==ESP_OK);
stop_ack=false;
assert(ble_wifi_connect("test-network","test-password")==ESP_ERR_TIMEOUT);
assert(ble_wifi_connect("test-network","test-password")==ESP_ERR_TIMEOUT);
assert(calls[STOP]==1);
assert(calls[CONNECT]==1);
callback(NULL,WIFI_EVENT,WIFI_EVENT_STA_STOP,NULL); stop_ack=true;
assert(ble_wifi_connect("test-network","test-password")==ESP_OK); tests++;
reset(); assert(ble_wifi_connect(NULL,"password")==ESP_ERR_INVALID_ARG);
assert(ble_wifi_connect("","password")==ESP_ERR_INVALID_ARG);
assert(ble_wifi_connect("network",NULL)==ESP_ERR_INVALID_ARG);
assert(calls[NEW_NETIF]==0); tests++;
printf("PASS: %u Wi-Fi lifecycle/fault-injection cases (production C)\n",tests);
return 0;
}
@@ -0,0 +1,9 @@
// Emit a host test translation unit using the actual production C functions.
// Usage: node tests/ble-wifi-host.cjs | cc -x c -std=c11 -Wall -Wextra -Werror \
// -fsanitize=address,undefined -o /tmp/ble-wifi-test -
const fs = require('node:fs');
const path = require('node:path');
const source = fs.readFileSync(path.join(__dirname, '../main/ble_wifi.c'), 'utf8');
const harness = fs.readFileSync(path.join(__dirname, 'ble-wifi-host.c'), 'utf8');
process.stdout.write(harness.replace('/* PRODUCTION_SOURCE */',
source.replace(/^#include[^\n]*$/gm, '') + '\n'));
@@ -0,0 +1,154 @@
// Executes the actual device adapter against deterministic transport/NVS/clock
// doubles. These tests are NOT supplier release evidence or hardware acceptance.
const fs=require('node:fs'),path=require('node:path');
const base=path.join(__dirname,'..');
const jsonDir=path.join(process.env.IDF_PATH||'E:/Codex/toolchains/esp-idf-v5.5.1','components/json/cJSON');
const strip=s=>s.replace(/^#include[^\n]*$/gm,'').replace(/^#pragma once/gm,'');
const sdk=strip(fs.readFileSync(path.join(base,'components/volc_conv_ai/src/base/volc_device_manager.h'),'utf8'));
const version=strip(fs.readFileSync(path.join(base,'main/firmware_version.h'),'utf8'));
process.stdout.write(fs.readFileSync(path.join(jsonDir,'cJSON.h'),'utf8')+'\n'+fs.readFileSync(path.join(jsonDir,'cJSON.c'),'utf8').replace('#include "cJSON.h"','')+`
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdatomic.h>
#include <sys/time.h>
typedef int esp_err_t;
typedef int nvs_handle_t;
typedef int StaticSemaphore_t;
typedef int SemaphoreHandle_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define ESP_ERR_NO_MEM 2
#define ESP_ERR_INVALID_STATE 3
#define ESP_ERR_INVALID_RESPONSE 4
#define ESP_ERR_INVALID_SIZE 5
#define ESP_ERR_TIMEOUT 6
#define ESP_ERR_INVALID_ARG 7
#define ESP_ERR_NVS_NOT_FOUND 7
#define NVS_READWRITE 1
#define NVS_READONLY 0
#define pdTRUE 1
#define pdPASS 1
#define portMAX_DELAY 0
#define pdMS_TO_TICKS(x) (x)
#define ESP_LOGI(...) ((void)0)
static int64_t test_us=1000000;
static int64_t esp_timer_get_time(void){return test_us;}
static int xSemaphoreCreateMutexStatic(void *p){return 1;}
static int xSemaphoreTake(int m,int t){return pdTRUE;}
static void xSemaphoreGive(int m){}
static void vTaskDelay(int t){}
static int xTaskCreate(void(*f)(void*),const char*n,int stack,void*a,int priority,void*out){return pdPASS;}
static int nvs_open(const char *s,int mode,int *n){*n=1;return 0;}
static int credential_writes, storage_error; static unsigned char saved_record[2048]; static size_t saved_len;
static int nvs_set_blob(int n,const char *key,const void *data,size_t len){if(!strcmp(key,"credential")){credential_writes++;if(!storage_error){assert(len<=sizeof(saved_record));memcpy(saved_record,data,len);saved_len=len;}}return 0;}
static int nvs_get_blob(int n,const char *key,void *data,size_t *len){if(!strcmp(key,"credential")&&saved_len==*len){memcpy(data,saved_record,saved_len);return 0;}return ESP_ERR_NVS_NOT_FOUND;}
static int nvs_erase_key(int n,const char *key){return 0;}
static int nvs_commit(int n){return storage_error;}
static void nvs_close(int n){}
static void mbedtls_platform_zeroize(void*p,size_t n){memset(p,0,n);}
static void mbedtls_sha256(const unsigned char*p,size_t n,unsigned char*out,int x){memset(out,0,32);for(size_t i=0;i<n;i++)out[i%32]^=p[i];}
static bool ble_cloud_configured(void){return true;}
static const char*ble_factory_id(void){return "KP01-000000000001";}
static const char*ble_factory_model(void){return "ECHOEAR-S3";}
static const char*ble_cloud_boot_id(void){return "11111111-1111-4111-8111-111111111111";}
static const char*ble_cloud_challenge_id(void){return ble_cloud_boot_id();}
static int ble_cloud_sync_time(void){return 0;}
static char *test_config;
static int config_mode, imported, supplier_calls;
static char *ble_cloud_decode(const char*d,const char*e,const char*s){return test_config?strdup(test_config):NULL;}
static unsigned uuid_counter;
static void ble_random_uuid(char out[37]){snprintf(out,37,"11111111-1111-4111-8111-%012u",++uuid_counter);}
`+sdk+'\n'+version+`
int volc_device_register(volc_iot_info_t*i,char**o){supplier_calls++;return -1;}
`+strip(fs.readFileSync(path.join(base,'main/platform_client.h'),'utf8'))+'\n'+strip(fs.readFileSync(path.join(base,'main/call_client.h'),'utf8'))+`
static int response_mode, requests;
static char *last_body;
static char last_id[37];
static int64_t request_advance;
const char *platform_last_error_code(void) {return response_mode==2 ? "CHAT_ENDED" : "";}
esp_err_t platform_request(const char*method,const char*path,const char*body,const char*rid,char**out){
requests++; free(last_body);last_body=strdup(body);snprintf(last_id,37,"%s",rid?rid:"");
if(response_mode) return ESP_FAIL;
cJSON *j=cJSON_Parse(body),*root=cJSON_CreateObject(),*data=cJSON_AddObjectToObject(root,"data");cJSON_AddBoolToObject(root,"ok",true);
assert(!strstr(path,"/voice-runtime")&&!strstr(path,"/activation-")&&!strstr(path,"/device/register"));
if(strstr(path,"/ble-state"))cJSON_AddBoolToObject(data,"bound",config_mode!=4);
if(strstr(path,"/voice-config")){
imported++;
cJSON_AddStringToObject(data,"voice_status",config_mode==1?"preparing":"ready");
cJSON *iot=cJSON_AddObjectToObject(data,"voice");
cJSON_AddStringToObject(iot,"instance_id","instance");cJSON_AddStringToObject(iot,"product_key","product");
cJSON_AddStringToObject(iot,"device_name",ble_factory_id());cJSON_AddStringToObject(iot,"device_secret","fixture-device-secret-123456789");
if(config_mode!=3)cJSON_AddStringToObject(iot,"RTCAppID","rtc-app");
}
if(strstr(path,"/chat/start")){
struct timeval tv;gettimeofday(&tv,NULL);int64_t now=(int64_t)tv.tv_sec*1000+tv.tv_usec/1000+2000;
cJSON_AddNumberToObject(root,"server_time_ms",now);
cJSON_AddStringToObject(data,"session_id",cJSON_GetStringValue(cJSON_GetObjectItem(j,"session_id")));
cJSON_AddStringToObject(data,"bot_id","bot");cJSON_AddObjectToObject(data,"params");
cJSON_AddNumberToObject(data,"lease_expires_at_ms",now+45000);cJSON_AddNumberToObject(data,"expires_at_ms",now+3600000);
test_us+=request_advance*1000;
}
if(out)*out=cJSON_PrintUnformatted(root);cJSON_Delete(j);cJSON_Delete(root);return 0;
}
`+strip(fs.readFileSync(path.join(base,'main/call_client.c'),'utf8'))+`
static void reset(void){
free(params);params=NULL;memset(&call,0,sizeof(call));call.version=2;
snprintf(call.session,37,"%s",ble_cloud_boot_id());snprintf(call.boot,37,"%s",ble_cloud_boot_id());
snprintf(call.end_id,37,"%s",ble_cloud_boot_id());test_us=1000000;
atomic_store(&audio_allowed,true);atomic_store(&lease_deadline,mono()+45000);
response_mode=requests=0;request_advance=0;state_mutex=1;engine_ready=true;sdk_stopped=false;
}
int main(void){
cJSON *bound=cJSON_CreateObject();
cJSON_AddStringToObject(bound,"instance_id","instance");cJSON_AddStringToObject(bound,"product_key","product");
cJSON_AddStringToObject(bound,"device_name","device");cJSON_AddStringToObject(bound,"device_secret","fixture-device-secret-123456789");
assert(call_client_save_bind_voice(bound)==ESP_ERR_INVALID_RESPONSE);assert(credential_writes==0);
cJSON_AddStringToObject(bound,"RTCAppID","rtc-app");
assert(call_client_save_bind_voice(bound)==ESP_OK);assert(credential_writes==1);
storage_error=ESP_FAIL;assert(call_client_save_bind_voice(bound)==ESP_FAIL);storage_error=0;cJSON_Delete(bound);
reset();assert(call_client_audio_allowed());test_us+=45000000;assert(!call_client_audio_allowed());
reset();assert(volc_platform_recording_prepare("app","room","device")==ESP_OK);
assert(strstr(last_body,"rtc_app_id")&&strstr(last_body,"room_id")&&strstr(last_body,"rtc_user_id"));
assert(!strstr(last_body,"device_secret")&&!strstr(last_body,"audio"));
int recorded_requests=requests;call.finished=true;
assert(volc_platform_recording_prepare("app","room","device")==ESP_ERR_INVALID_STATE);assert(requests==recorded_requests);
reset();response_mode=1;assert(volc_platform_recording_prepare("app","room","device")!=ESP_OK);assert(call_client_audio_allowed());
reset();assert(fetch_parameters(true)==0);assert(params && !strcmp(bot,"bot"));
reset();request_advance=46000;assert(fetch_parameters(false)!=0);assert(!call_client_audio_allowed());
reset();int before=requests;assert(call_client_turn("subject","1","private text")==0);assert(requests==before);
reset();call.finished=true;call.sdk_stopped=true;strcpy(call.reason,"hangup");response_mode=1;
finish_pending();assert(call.session[0]);char *original=strdup(last_body);char rid[37];strcpy(rid,last_id);
response_mode=0;finish_pending();assert(!call.session[0]);assert(!strcmp(original,last_body)&&!strcmp(rid,last_id));free(original);
reset();response_mode=2;renew_due=0;assert(call_client_poll()==1);assert(!call_client_audio_allowed());
reset();memset(&call,0,sizeof(call));response_mode=1;assert(call_client_begin()!=0);assert(call.finished&&call.session[0]);
response_mode=0;finish_pending();assert(!call.session[0]);
reset();memset(&call,0,sizeof(call));memset(&voice,0,sizeof(voice));boot_loaded=false;config_mode=0;imported=0;
assert(call_client_boot()==ESP_OK);assert(!strcmp(voice.rtc_app,"rtc-app"));assert(imported==0);assert(supplier_calls==0);
before=requests;call_client_engine_ready(true);assert(engine_ready);assert(requests==before);
config_mode=4;assert(call_client_boot()==ESP_ERR_INVALID_STATE);assert(!strcmp(call_client_start_error(),"DEVICE_UNBOUND"));assert(!call_client_audio_allowed());
config_mode=0;memset(&voice,0,sizeof(voice));saved_len=0;boot_loaded=false;
assert(call_client_boot()==ESP_OK);assert(imported==1);assert(supplier_calls==0);
memset(&voice,0,sizeof(voice));saved_len=0;boot_loaded=false;config_mode=1;
assert(call_client_boot()==ESP_ERR_INVALID_STATE);assert(!strcmp(call_client_start_error(),"VOICE_PREPARING"));
config_mode=3;assert(call_client_boot()==ESP_ERR_INVALID_RESPONSE);assert(supplier_calls==0);
config_mode=0;assert(call_client_boot()==ESP_OK);
// A disruptive operation atomically excludes a new call, including a race
// between command delivery and the user's next screen tap.
memset(&call,0,sizeof(call));call_client_engine_ready(true);
assert(!call_client_busy());assert(call_client_enter_maintenance());
assert(call_client_busy());assert(call_client_begin()==ESP_ERR_INVALID_STATE);
assert(call_client_set_standby(true)==ESP_OK);
call_client_leave_maintenance();assert(!call_client_busy());
assert(call_client_standby());assert(call_client_begin()==ESP_ERR_INVALID_STATE);
assert(call_client_set_standby(false)==ESP_ERR_INVALID_STATE);
assert(call_client_enter_maintenance());assert(call_client_set_standby(false)==ESP_OK);
call_client_leave_maintenance();assert(!call_client_standby());
strcpy(call.session,"11111111-1111-4111-8111-111111111111");
assert(call_client_busy());assert(!call_client_enter_maintenance());
free(test_config);test_config=NULL;
reset();free(params);free(last_body);puts("PASS direct chat C: bind credential persistence, cached boot, recovery only, no activation handshake, parameter fetch, stable end retry, unbound refusal and lease expiry");return 0;
}
`);
@@ -0,0 +1,243 @@
// Emit a standalone host TU containing the actual production state machine,
// SDK event/status callbacks and conversation task. No device or cloud access.
const fs = require('node:fs'), path = require('node:path');
const root = path.resolve(__dirname, '..');
const inc = fs.readFileSync(path.join(root, 'components/application/service/conv_service/inc/call_display_state.h'), 'utf8');
const state = fs.readFileSync(path.join(root, 'components/application/service/conv_service/src/call_display_state.c'), 'utf8').replace('#include "call_display_state.h"', '');
const task = fs.readFileSync(path.join(root, 'components/application/service/conv_service/src/volc_conv.c'), 'utf8');
const voiceHeader = fs.readFileSync(path.join(root, 'components/application/service/conv_service/inc/call_voice_end.h'), 'utf8').replace('#pragma once', '');
const voiceState = fs.readFileSync(path.join(root, 'components/application/service/conv_service/src/call_voice_end.c'), 'utf8').replace('#include "call_voice_end.h"', '');
function section(first, next) {
const start = task.indexOf(first), end = task.indexOf(next, start + first.length);
if (start < 0 || end < 0) throw new Error(`production section missing: ${first}`);
return task.slice(start, end);
}
process.stdout.write(inc + '\n' + state + '\n' + voiceHeader + '\n' + voiceState + '\n' + String.raw`
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdatomic.h>
typedef void *volc_engine_t;
typedef void *volc_hal_display_t;
typedef void *volc_hal_capture_t;
typedef struct {int code;} volc_event_t;
typedef enum {VOLC_CONV_STATUS_LISTENING,VOLC_CONV_STATUS_THINKING,VOLC_CONV_STATUS_ANSWERING,VOLC_CONV_STATUS_INTERRUPTED,VOLC_CONV_STATUS_ANSWER_FINISH} volc_conv_status_e;
typedef struct {void *display_handle,*capture_handle[1],*player_handle[1];} volc_hal_context_t;
typedef struct {int media_type;void (*data_cb)(void);void (*audio_wakeup_cb)(void*,void*);void *user_data;} volc_hal_capture_config_t;
typedef struct {int type;} esp_gmf_afe_evt_t;
#define ESP_GMF_AFE_EVT_VAD_START 1
#define ESP_GMF_AFE_EVT_VAD_END 2
typedef struct {int media_type;} volc_hal_player_config_t;
typedef struct {int mode;char *bot_id,*params;} volc_opt_t;
typedef struct {bool is_binary;} volc_message_info_t;
typedef struct {void *engine;int wait_time;} volc_conv_service_t;
#define VOLC_EV_CONNECTED 1
#define VOLC_EV_DISCONNECTED 2
#define VOLC_DISPLAY_OBJ_STATUS 0
#define VOLC_DISPLAY_OBJ_MAIN 1
#define VOLC_DISPLAY_OBJ_SUBTITLE 2
#define VOLC_DISPLAY_TEXT 0
#define VOLC_DISPLAY_IMAGE 1
#define MMAP_EAF_HAPPY_EAF 0
#define MMAP_EAF_EMOTION_BLINK_FAST_284_126_EAF 1
#define MMAP_EAF_EMOTION_DIZZY_284_126_EAF 2
#define VOLC_HAL_PLAYER_AUDIO 0
#define VOLC_HAL_CAPTURE_AUDIO 0
#define VOLC_MEDIA_TYPE_AUDIO 0
#define VOLC_AUDIO_MODE_CAPTURE 0
#define VOLC_AUDIO_MODE_WAKEUP 1
#define VOLC_MODE_RTC 1
#define VOLC_MODE_WS 2
#define VOLC_AGENT_TYPE_LLM 0
#define VOLC_SERVICE_AI_CONVERSATION_OVER 1
#define CONV_SERVICE_TIMEOUT 60
#define LOGI(...) do { if (0) printf(__VA_ARGS__); } while (0)
#define LOGE(...) do { if (0) printf(__VA_ARGS__); } while (0)
#define pdMS_TO_TICKS(value) (value)
enum {NORMAL,DELAYED_CONNECTION,START_FAILURE,BEGIN_FAILURE,REMOTE_DROP,SLOW_END_ACK,AUDIO_STOP_FAILURE,RESPONSE_FINISHED,TIMEOUT_WITHOUT_CONNECTION,IDLE_CONNECTED,ACTIVE_LONG_CALL,VOICE_GOODBYE,VOICE_RETRACT,VOICE_MISSING_REPLY};
static int scenario,polls,frames,ended_frames,interrupted_frames,failed_frames,ending_frames,talking_frames,over_count;
static uint64_t now_ms;
static char text[64];
static bool audio_stopped,output_stopped,sdk_stopped;
static atomic_bool is_stop=true,stop_requested=false,provider_disconnected=false,rtc_connected=false,display_connected=false,user_stop_requested=false;
static call_display_state_t call_display;
static volc_hal_display_t global_display;
static bool session_started_reported,diagnostic_say_pending;
static call_voice_end_t voice_end;
static atomic_uint voice_finish_serial,last_voice_received_ms;
typedef struct {double valuedouble;} cJSON;
static cJSON *cJSON_GetObjectItemCaseSensitive(const cJSON *j,const char *k){(void)j;(void)k;return NULL;}
static const char *cJSON_GetStringValue(const cJSON *j){(void)j;return NULL;}
static bool cJSON_IsNumber(const cJSON *j){return j!=NULL;}
static bool cJSON_IsTrue(const cJSON *j){(void)j;return false;}
static void *platform_report_queue;
static int xQueueReceive(void *q,cJSON **out,unsigned ticks){(void)q;(void)out;(void)ticks;return 0;}
static void cJSON_Delete(cJSON *root){(void)root;}
#define pdTRUE 1
enum { DIAGNOSTIC_NONE, DIAGNOSTIC_SAY, DIAGNOSTIC_TTS, DIAGNOSTIC_PENDING_MASK=3 };
static atomic_uint diagnostic_state;
static void diagnostic_prepare(void){}
static void diagnostic_close(void){}
static unsigned diagnostic_take_pending(void){return 0;}
static void diagnostic_send_tts(void){}
static unsigned uxTaskPriorityGet(void *task){(void)task;return 5;}
static int xTaskGetCoreID(void *task){(void)task;return 0;}
static volc_conv_service_t conv_service;
static volc_hal_context_t hal={(void*)1,{(void*)2},{(void*)3}};
static void __on_volc_event(volc_engine_t,volc_event_t*,void*);
static void __on_volc_conversation_status(volc_engine_t,volc_conv_status_e,void*);
void conv_ai_service_task_stop(void);
static uint64_t volc_osal_get_time_ms(void){return now_ms;}
` + section('/* CALL_IDLE_BEGIN:', '/* CALL_IDLE_END */') + String.raw`
static void vTaskDelay(unsigned ms){now_ms+=ms;}
static void sleep(unsigned seconds){now_ms+=seconds*1000;}
static void volc_osal_thread_exit(void *unused){(void)unused;}
static void *volc_osal_malloc(size_t size){return malloc(size);}
static void volc_osal_free(void *p){free(p);}
static volc_hal_context_t *volc_get_global_hal_context(void){return &hal;}
static int volc_hal_display_set_content(void *display,int obj,int type,const void *content){
(void)display;(void)type;
if(obj!=VOLC_DISPLAY_OBJ_STATUS)return 0;
snprintf(text,sizeof(text),"%s",(const char*)content);frames++;
if(!strcmp(text,"通话中")){assert(atomic_load(&display_connected));talking_frames++;}
if(!strcmp(text,"正在结束"))ending_frames++;
if(!strcmp(text,"通话结束")||!strcmp(text,"连接失败")||!strcmp(text,"连接中断")){
assert(audio_stopped&&output_stopped); // never claim ended before local shutdown
if(!strcmp(text,"通话结束"))ended_frames++;
if(!strcmp(text,"连接失败"))failed_frames++;
if(!strcmp(text,"连接中断"))interrupted_frames++;
}
return 0;
}
static void platform_conversation_observe(const char *type){(void)type;}
static bool platform_runtime_rtc(void){return true;}
static bool platform_conversation_audio_authorized(void){return true;}
static int platform_conversation_begin(void){return scenario==BEGIN_FAILURE?-1:0;}
static const char *platform_last_error_code(void){return "";}
static const char *call_client_start_error(void){return "";}
static const char *platform_conversation_update_json(void){return "{}";}
static const char *platform_conversation_bot_id(void){return "test";}
static const char *volc_current_user_id(void *engine,bool remote){(void)engine;(void)remote;return "test";}
static void process_platform_reports(void){}
static void platform_conversation_stop_result(bool started,int result){(void)started;(void)result;}
static void platform_conversation_finish(bool failed,const char *error){
(void)failed;(void)error;
if(scenario==AUDIO_STOP_FAILURE){assert(!strcmp(text,"正在结束"));return;}
assert(ended_frames+failed_frames+interrupted_frames>0); // visible before HTTP ACK
assert(sdk_stopped);
if(scenario==SLOW_END_ACK)now_ms+=5000;
}
static bool conversation_output_allowed(void){return !stop_requested;}
static bool volc_hal_player_voice_quiet(void *player,unsigned ms){(void)player;(void)ms;return true;}
` + section('/* Called only by the conversation task,', '/* HTTPS reporting') + String.raw`
static void __audio_capture_cb(void){}
static void *volc_hal_capture_create(const volc_hal_capture_config_t *cfg){(void)cfg;return (void*)2;}
static int volc_hal_capture_stop(void *capture){(void)capture;if(stop_requested&&scenario==AUDIO_STOP_FAILURE)return -1;audio_stopped=true;return 0;}
static int volc_hal_capture_update_config(void *capture,const volc_hal_capture_config_t *cfg){(void)capture;(void)cfg;return 0;}
static int volc_hal_capture_start(void *capture,int mode){(void)capture;if(mode==VOLC_AUDIO_MODE_CAPTURE)audio_stopped=false;return 0;}
static void *volc_hal_player_create(const volc_hal_player_config_t *cfg){(void)cfg;return (void*)3;}
static void volc_hal_player_set_authorization_gate(void *player,bool(*gate)(void)){(void)player;(void)gate;}
static void volc_hal_player_start(void *player){(void)player;output_stopped=false;}
static int volc_hal_player_stop(void *player){(void)player;output_stopped=true;return 0;}
static void volc_hal_player_discard_pending(void *player){(void)player;}
static void volc_hal_player_destroy(void *player){(void)player;}
static int volc_start(void *engine,const volc_opt_t *opt){
(void)engine;(void)opt;
if(scenario==START_FAILURE)return -1;
if(scenario!=DELAYED_CONNECTION&&scenario!=TIMEOUT_WITHOUT_CONNECTION){volc_event_t event={VOLC_EV_CONNECTED};__on_volc_event(NULL,&event,NULL);}
return 0;
}
static int volc_stop(void *engine){(void)engine;sdk_stopped=true;return 0;}
static int platform_conversation_poll(void){
polls++;
if(scenario==VOICE_GOODBYE||scenario==VOICE_RETRACT||scenario==VOICE_MISSING_REPLY){
if(polls==1)process_voice_end_transcript("subject","就聊到这吧",true);
if(scenario==VOICE_GOODBYE&&polls==2)__on_volc_conversation_status(NULL,VOLC_CONV_STATUS_ANSWER_FINISH,NULL);
if(scenario==VOICE_RETRACT&&polls==2)process_voice_end_transcript("subject","等等,我还想聊",true);
if(scenario==VOICE_RETRACT&&polls==3){assert(!voice_end.pending);conv_ai_service_task_stop();}
assert(polls<15);return 0;
}
if(scenario==IDLE_CONNECTED){
__on_volc_conversation_status(NULL,VOLC_CONV_STATUS_LISTENING,NULL);
return 0; // Persistent listening + cloud renewals must still time out.
}
if(scenario==ACTIVE_LONG_CALL){
if(polls<=150&&polls%30==1)call_idle_activity();
return 0; // Activity keeps a call alive beyond 60s, then silence ends it.
}
if(scenario==TIMEOUT_WITHOUT_CONNECTION){assert(!talking_frames);return 0;}
if(scenario==DELAYED_CONNECTION){assert(!talking_frames);assert(!strcmp(text,"正在连接"));}
if(scenario==REMOTE_DROP){volc_event_t event={VOLC_EV_DISCONNECTED};__on_volc_event(NULL,&event,NULL);}
else if(scenario==RESPONSE_FINISHED&&polls==1){
__on_volc_conversation_status(NULL,VOLC_CONV_STATUS_ANSWER_FINISH,NULL);
assert(!stop_requested);assert(!strcmp(text,"通话中"));assert(!ended_frames);
}else conv_ai_service_task_stop();
return 0;
}
static int volc_send_text_to_agent(void *engine,const char *text,int type,int mode){(void)engine;(void)text;(void)type;(void)mode;return 0;}
static int volc_send_message(void *engine,const char *data,size_t len,const volc_message_info_t *info){(void)engine;(void)data;(void)len;(void)info;return 0;}
static void aios_event_pub(int type,void *a,void *b){
(void)a;(void)b;assert(type==VOLC_SERVICE_AI_CONVERSATION_OVER);over_count++;
if(scenario!=AUDIO_STOP_FAILURE)assert(!strcmp(text,"待机"));
}
` + section('static void publish_call_display(', '\nstatic bool conversation_output_allowed(')
+ section('static void __on_volc_event(', '\nstatic void __on_volc_audio_data(')
+ section('void conv_ai_service_prepare_task(', '\n#if __cplusplus') + String.raw`
static void reset(int next){
scenario=next;polls=frames=ended_frames=interrupted_frames=failed_frames=ending_frames=talking_frames=over_count=0;
now_ms=10000;text[0]=0;audio_stopped=output_stopped=true;sdk_stopped=false;
memset(&call_display,0,sizeof(call_display));memset(&conv_service,0,sizeof(conv_service));
hal.player_handle[0]=(void*)3;global_display=hal.display_handle;diagnostic_say_pending=false;
conv_ai_service_prepare_task();
}
int main(void){
// State transitions are fenced by generation, even if a former terminal
// deadline or delayed CONNECTED observation is delivered after a new begin.
call_display_state_t state={0};unsigned first=call_display_begin(&state);
call_display_connected(&state,first);call_display_stopping(&state,first);
call_display_finished(&state,first,CALL_DISPLAY_STOP_NORMAL,false,100);
assert(state.phase==CALL_DISPLAY_ENDING);
call_display_finished(&state,first,CALL_DISPLAY_STOP_NORMAL,true,100);
assert(!call_display_idle_if_due(&state,first,2099));
unsigned next=call_display_begin(&state);
assert(!call_display_idle_if_due(&state,first,9999));call_display_connected(&state,first);
assert(state.phase==CALL_DISPLAY_CONNECTING);call_display_connected(&state,next);assert(state.phase==CALL_DISPLAY_TALKING);
for(int test=NORMAL;test<=VOICE_MISSING_REPLY;test++){
reset(test);conv_ai_service_task(NULL);assert(over_count==1);assert(ending_frames);
if(test==START_FAILURE||test==BEGIN_FAILURE||test==TIMEOUT_WITHOUT_CONNECTION){assert(failed_frames&& !talking_frames && !ended_frames);}
else if(test==REMOTE_DROP){assert(interrupted_frames&&!ended_frames);}
else if(test==AUDIO_STOP_FAILURE){assert(!ended_frames&&!failed_frames&&!interrupted_frames);}
else{assert(ended_frames&&!failed_frames&&!interrupted_frames);}
if(test!=AUDIO_STOP_FAILURE){assert(call_display.phase==CALL_DISPLAY_IDLE);assert(now_ms>=12000);}
if(test==IDLE_CONNECTED||test==TIMEOUT_WITHOUT_CONNECTION)assert(polls==60);
if(test==ACTIVE_LONG_CALL)assert(polls==180);
if(test==VOICE_GOODBYE)assert(polls==2&&atomic_load(&user_stop_requested));
if(test==VOICE_RETRACT)assert(polls==3);
if(test==VOICE_MISSING_REPLY)assert(polls==12&&atomic_load(&user_stop_requested));
// Late supplier turn callbacks after teardown cannot overwrite idle/end.
char before[sizeof(text)];strcpy(before,text);
__on_volc_conversation_status(NULL,VOLC_CONV_STATUS_ANSWER_FINISH,NULL);
__on_volc_conversation_status(NULL,VOLC_CONV_STATUS_LISTENING,NULL);
assert(!strcmp(before,text));
}
// Actual VAD callback records both edges, and starts a new quiet window.
int16_t quiet[320]={0}, comfort[320], speech[320];
for(int i=0;i<320;i++){comfort[i]=(i&1)?-20:20;speech[i]=(i&1)?-500:500;}
assert(!call_idle_pcm_active(quiet,sizeof(quiet)));
assert(!call_idle_pcm_active(comfort,sizeof(comfort)));
assert(call_idle_pcm_active(speech,sizeof(speech)));
assert(!call_idle_pcm_active(NULL,640)&&!call_idle_pcm_active(speech,639));
reset(NORMAL);
esp_gmf_afe_evt_t vad={ESP_GMF_AFE_EVT_VAD_START};
now_ms+=59000;call_idle_capture_event(&vad,NULL);
assert(atomic_load(&local_speech_active)&&!call_idle_expired());
vad.type=ESP_GMF_AFE_EVT_VAD_END;now_ms+=90000;call_idle_capture_event(&vad,NULL);
assert(!atomic_load(&local_speech_active)&&!call_idle_expired());
now_ms+=59999;assert(!call_idle_expired());now_ms++;assert(call_idle_expired());
now_ms=UINT32_MAX-1000ull;call_idle_activity();
now_ms+=59999;assert(!call_idle_expired());now_ms++;assert(call_idle_expired());
puts("PASS call display + idle: actual task/callbacks, connected-only talking, local shutdown before terminal, start failure, provider drop, answer-finish continuity, slow end ACK, failed local stop, 60s quiet timeout, 180s activity call, VAD edges, silence/comfort PCM rejection, clock wrap, generation-fenced hold");
return 0;
}
`);
@@ -0,0 +1,134 @@
// Compile actual capture lifecycle and recorder close/read functions with
// concurrent workers. No device, microphone or provider access.
const fs=require('node:fs'),path=require('node:path');
const root=path.resolve(__dirname,'..');
const capture=fs.readFileSync(path.join(root,'components/application/platform/src/espressif/volc_hal_capture.c'),'utf8').replace(/^#include .*$/gm,'');
const recorder=fs.readFileSync(path.join(root,'components/av_processor/audio_processor.c'),'utf8');
function section(first,last){const a=recorder.indexOf(first),b=recorder.indexOf(last,a);if(a<0||b<0)throw Error(first);return recorder.slice(a,b);}
process.stdout.write(String.raw`
#include <assert.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <time.h>
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define ESP_LOGI(...) ((void)0)
#define ESP_LOGW(...) ((void)0)
#define ESP_LOGE(...) ((void)0)
#define LOGI(...) ((void)0)
#define LOGE(...) ((void)0)
#define pdMS_TO_TICKS(x) (x)
#define portMAX_DELAY 0
#define MALLOC_CAP_SPIRAM 1
#define MALLOC_CAP_8BIT 2
#define pdPASS 1
#define VOLC_MEDIA_TYPE_AUDIO 0
#define VOLC_MEDIA_TYPE_VIDEO 1
#define VOLC_HAL_CAPTURE_AUDIO 0
#define VOLC_AUDIO_DATA_TYPE_PCM 0
#define AV_PROCESSOR_FORMAT_ID_PCM 0
typedef int volc_media_type_e;
typedef enum {VOLC_AUDIO_MODE_WAKEUP,VOLC_AUDIO_MODE_CAPTURE} volc_hal_audio_capture_mode_e;
typedef void *volc_hal_capture_t;
typedef struct {int data_type;void *user_data;} volc_hal_frame_info_t;
typedef void (*volc_hal_capture_data_cb_t)(void*,const void*,int,volc_hal_frame_info_t*);
typedef void (*volc_hal_audio_wakeup_cb_t)(void*,void*);
typedef struct {int media_type;volc_hal_capture_data_cb_t data_cb;volc_hal_audio_wakeup_cb_t audio_wakeup_cb;void *user_data;} volc_hal_capture_config_t;
typedef struct {void *capture_handle[1];} volc_hal_context_t;
static volc_hal_context_t hal;
static volc_hal_context_t *volc_get_global_hal_context(void){return &hal;}
typedef struct {void *rec_dev;} basic_board_periph_t;
basic_board_periph_t global_periph;
static void esp_codec_dev_set_in_gain(void *d,float g){(void)d;(void)g;}
typedef struct {int format;struct {struct {struct {int sample_rate,sample_bits,channels,frame_duration;} audio_info;} g711;} params;} av_processor_encoder_config_t;
typedef struct {bool ai_mode_wakeup;} av_processor_afe_config_t;
typedef struct {av_processor_encoder_config_t encoder_cfg;av_processor_afe_config_t afe_config;volc_hal_audio_wakeup_cb_t recorder_event_cb;void *recorder_ctx;} audio_recorder_config_t;
#define DEFAULT_AV_PROCESSOR_AFE_CONFIG() {0}
#define DEFAULT_AUDIO_RECORDER_CONFIG() {0}
typedef unsigned UBaseType_t;
static UBaseType_t uxTaskPriorityGet(void *t){(void)t;return 5;}
static void vTaskPrioritySet(void *t,unsigned p){(void)t;(void)p;}
static void vTaskDelay(unsigned ticks){(void)ticks;struct timespec t={0,1000000};nanosleep(&t,NULL);}
typedef struct {pthread_mutex_t mutex;pthread_cond_t cond;int value;} *SemaphoreHandle_t;
static unsigned semaphores,tasks,allocations,pipelines;
static SemaphoreHandle_t xSemaphoreCreateBinary(void){
SemaphoreHandle_t s=calloc(1,sizeof(*s));assert(s);pthread_mutex_init(&s->mutex,NULL);pthread_cond_init(&s->cond,NULL);semaphores++;return s;
}
static int xSemaphoreTake(SemaphoreHandle_t s,unsigned timeout){(void)timeout;pthread_mutex_lock(&s->mutex);while(!s->value)pthread_cond_wait(&s->cond,&s->mutex);s->value--;pthread_mutex_unlock(&s->mutex);return 1;}
static void xSemaphoreGive(SemaphoreHandle_t s){pthread_mutex_lock(&s->mutex);s->value++;pthread_cond_signal(&s->cond);pthread_mutex_unlock(&s->mutex);}
static void vSemaphoreDelete(SemaphoreHandle_t s){pthread_mutex_destroy(&s->mutex);pthread_cond_destroy(&s->cond);free(s);semaphores--;}
typedef struct {pthread_t thread;void (*fn)(void*);void *arg;} *TaskHandle_t;
static bool task_fail,open_fail,buffer_fail;
static void *worker(void *p){TaskHandle_t t=p;t->fn(t->arg);assert(0);return NULL;}
static int xTaskCreatePinnedToCoreWithCaps(void (*fn)(void*),const char *name,unsigned stack,void *arg,unsigned priority,TaskHandle_t *h,int core,unsigned caps){
(void)name;(void)stack;(void)priority;(void)core;(void)caps;if(task_fail)return 0;
TaskHandle_t t=calloc(1,sizeof(*t));assert(t);t->fn=fn;t->arg=arg;*h=t;tasks++;assert(!pthread_create(&t->thread,NULL,worker,t));return pdPASS;
}
static void vTaskSuspend(void *h){assert(!h);pthread_exit(NULL);}
static void vTaskDeleteWithCaps(TaskHandle_t t){assert(!pthread_join(t->thread,NULL));free(t);tasks--;}
static void *volc_osal_malloc(size_t n){if(buffer_fail)return NULL;void *p=malloc(n);assert(p);allocations++;return p;}
static void *volc_osal_calloc(size_t n,size_t s){void *p=calloc(n,s);assert(p);allocations++;return p;}
static void volc_osal_free(void *p){if(p){free(p);allocations--;}}
enum {AUDIO_RUN_STATE_CLOSED,AUDIO_RUN_STATE_PLAYING};
typedef struct {int valid_size;void *buf;} esp_gmf_data_bus_block_t;
typedef struct {pthread_mutex_t mutex;pthread_cond_t cond;bool aborted,supply;int borrowers;char data[1920];} fifo_t;
static struct {int state;void *pipe,*task,*afe_cfg,*afe_manager,*pool;fifo_t *fifo;void *cb,*ctx;} audio_recorder;
static atomic_bool recorder_closing;
static atomic_uint recorder_readers,frames;
static SemaphoreHandle_t control;
static SemaphoreHandle_t recorder_control_lock(void){return control;}
static atomic_uint fifo_waiters;
static int esp_gmf_fifo_acquire_read(fifo_t *f,esp_gmf_data_bus_block_t *b,int n,unsigned timeout){
(void)timeout;pthread_mutex_lock(&f->mutex);atomic_fetch_add(&fifo_waiters,1);
while(!f->aborted&&!f->supply)pthread_cond_wait(&f->cond,&f->mutex);
atomic_fetch_sub(&fifo_waiters,1);
int ret=-1;if(!f->aborted){f->supply=false;f->borrowers++;b->valid_size=n;b->buf=f->data;ret=0;}
pthread_mutex_unlock(&f->mutex);return ret;
}
static void esp_gmf_fifo_release_read(fifo_t *f,esp_gmf_data_bus_block_t *b,unsigned t){(void)b;(void)t;pthread_mutex_lock(&f->mutex);assert(f->borrowers==1);f->borrowers--;pthread_mutex_unlock(&f->mutex);}
static void esp_gmf_fifo_abort(fifo_t *f){pthread_mutex_lock(&f->mutex);f->aborted=true;pthread_cond_broadcast(&f->cond);pthread_mutex_unlock(&f->mutex);}
static void esp_gmf_pipeline_stop(void *p){assert(p);}
static void esp_gmf_pipeline_destroy(void *p){assert(p);free(p);pipelines--;}
static void esp_gmf_task_deinit(void *p){assert(p);free(p);}
static void esp_gmf_fifo_destroy(fifo_t *f){assert(!atomic_load(&recorder_readers)&&!f->borrowers);pthread_mutex_destroy(&f->mutex);pthread_cond_destroy(&f->cond);free(f);}
static void afe_config_free(void *p){free(p);}
static void esp_gmf_afe_manager_destroy(void *p){free(p);}
static void av_audio_recorder_pool_deinit(void *p){free(p);}
`+section('static void recorder_cleanup_on_error(void)','static esp_err_t recorder_configure_all_encoders(void)')+section('static esp_err_t recorder_close_internal(void)','esp_err_t audio_feeder_feed_data(uint8_t')+String.raw`
static int audio_recorder_open(audio_recorder_config_t *c){
(void)c;assert(audio_recorder.state==AUDIO_RUN_STATE_CLOSED);
audio_recorder.pipe=malloc(1);pipelines++;audio_recorder.task=malloc(1);audio_recorder.afe_cfg=malloc(1);audio_recorder.afe_manager=malloc(1);audio_recorder.pool=malloc(1);
audio_recorder.fifo=calloc(1,sizeof(fifo_t));assert(audio_recorder.fifo);pthread_mutex_init(&audio_recorder.fifo->mutex,NULL);pthread_cond_init(&audio_recorder.fifo->cond,NULL);audio_recorder.fifo->supply=true;
if(open_fail){recorder_cleanup_on_error();assert(!audio_recorder.pool&&!audio_recorder.pipe&&!audio_recorder.task&&!audio_recorder.fifo&&!audio_recorder.afe_cfg&&!audio_recorder.afe_manager);return -1;}
audio_recorder.state=AUDIO_RUN_STATE_PLAYING;return 0;
}
`+capture+String.raw`
static void captured(void *c,const void *b,int n,volc_hal_frame_info_t *f){(void)c;(void)b;(void)n;(void)f;atomic_fetch_add(&frames,1);}
int main(void){
control=xSemaphoreCreateBinary();xSemaphoreGive(control);
volc_hal_capture_config_t cfg={.media_type=VOLC_MEDIA_TYPE_AUDIO,.data_cb=captured};
volc_hal_capture_t c=volc_hal_capture_create(&cfg);assert(c);
for(unsigned i=0;i<100;i++){
assert(!volc_hal_capture_start(c,i%2?VOLC_AUDIO_MODE_CAPTURE:VOLC_AUDIO_MODE_WAKEUP));
while(!atomic_load(&fifo_waiters))vTaskDelay(1);
assert(pipelines==1&&tasks==1);
assert(!volc_hal_capture_stop(c));assert(!pipelines&&!tasks&&!recorder_readers&&!fifo_waiters);
assert(!((volc_hal_capture_impl_t*)c)->capture_thread);
assert(!volc_hal_capture_update_config(c,&cfg));assert(!volc_hal_capture_stop(c));
}
task_fail=true;assert(volc_hal_capture_start(c,VOLC_AUDIO_MODE_CAPTURE)==-1);assert(!pipelines&&!tasks);task_fail=false;
open_fail=true;assert(volc_hal_capture_start(c,VOLC_AUDIO_MODE_CAPTURE)==-1);assert(!pipelines&&!tasks);open_fail=false;
buffer_fail=true;assert(volc_hal_capture_start(c,VOLC_AUDIO_MODE_CAPTURE)==-1);buffer_fail=false;
assert(!volc_hal_capture_start(c,VOLC_AUDIO_MODE_CAPTURE));while(!fifo_waiters)vTaskDelay(1);
volc_hal_capture_destroy(c);assert(!hal.capture_handle[0]&&!pipelines&&!tasks&&!allocations);
vSemaphoreDelete(control);assert(!semaphores&&frames>=50);
puts("PASS 100 concurrent capture/wakeup restarts: blocked reader abort and join, pipeline/WithCaps/handle/buffer cleanup, duplicate stop, open/buffer/task failure recovery, destroy");
return 0;
}
`);
@@ -0,0 +1,66 @@
// Compile production configuration/URL policy functions against hardware and
// NVS doubles. Hardware acceptance remains a separate COM6 check.
const fs=require('fs'),path=require('path');
const base=path.resolve(__dirname,'..');
const json=path.join(process.env.IDF_PATH||'E:/Codex/toolchains/esp-idf-v5.5.1','components/json/cJSON');
const source=fs.readFileSync(path.join(base,'main/device_control.c'),'utf8');
const apply=source.slice(source.indexOf('static esp_err_t apply_config('),source.indexOf('static cJSON *diagnostics('));
const ota=fs.readFileSync(path.join(base,'main/device_ota.c'),'utf8');
const url=ota.slice(ota.indexOf('static bool same_origin_download('),ota.indexOf('static esp_err_t download('));
process.stdout.write(fs.readFileSync(path.join(json,'cJSON.h'),'utf8')+'\n'+fs.readFileSync(path.join(json,'cJSON.c'),'utf8').replace('#include "cJSON.h"','')+`
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
typedef int esp_err_t;
typedef int nvs_handle_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define ESP_ERR_INVALID_RESPONSE 1
#define ESP_ERR_NOT_SUPPORTED 2
#define NVS_READWRITE 1
static int64_t applied_revision;
static char device_display_name[97], stored_name[97], staged_name[97];
static int read_error;
static int power_error;
static int device_power_apply(const char *mode,bool held){return power_error;}
static int hardware_volume=70,stored=-1,staged=-1,codec_error,commit_error,commits;
static int volc_hal_get_device_volume(void){return hardware_volume;}
static int volc_hal_set_device_volume(int n){if(codec_error)return -1;hardware_volume=n;return 0;}
static int nvs_open(const char *s,int mode,int *n){*n=1;return 0;}
static int nvs_get_i32(int n,const char *k,int32_t *v){*v=stored;return stored<0?-1:0;}
static int nvs_set_i32(int n,const char *k,int32_t v){staged=v;return 0;}
static int nvs_commit(int n){if(commit_error)return -1;commits++;stored=staged;strcpy(stored_name,staged_name);return 0;}
static int nvs_set_str(int n,const char *k,const char *s){strcpy(staged_name,s);return 0;}
static int nvs_get_str(int n,const char *k,char *out,size_t *size){if(read_error)return -1;size_t len=strlen(stored_name)+1;if(*size<len)return -1;memcpy(out,stored_name,len);*size=len;return 0;}
static void nvs_close(int n){}
static const char *ble_cloud_origin(void){return "https://82.157.201.114";}
`+apply+'\n'+url+`
static int apply(const char *s){cJSON *j=cJSON_Parse(s);int e=apply_config(j,false);cJSON_Delete(j);return e;}
int main(void){
assert(apply("{\\"revision\\":2,\\"desired_config\\":{\\"volume\\":{\\"speaker\\":60},\\"voice_runtime\\":\\"volc_hardware_agent\\"}}" )==0);
assert(hardware_volume==60 && stored==60 && applied_revision==2 && commits==1);
assert(apply("{\\"revision\\":2,\\"desired_config\\":{\\"volume\\":{\\"speaker\\":60}}}")==0);assert(commits==1);
codec_error=1;assert(apply("{\\"revision\\":3,\\"desired_config\\":{\\"volume\\":{\\"speaker\\":45}}}")!=0);
assert(hardware_volume==60 && applied_revision==2);codec_error=0;
commit_error=1;assert(apply("{\\"revision\\":3,\\"desired_config\\":{\\"volume\\":{\\"speaker\\":45}}}")!=0);
assert(hardware_volume==45 && stored==60 && applied_revision==2);commit_error=0;
assert(apply("{\\"revision\\":3,\\"desired_config\\":{\\"volume\\":{\\"speaker\\":45}}}")==0);assert(stored==45 && applied_revision==3);
assert(apply("{\\"revision\\":4,\\"desired_config\\":{\\"volume\\":{\\"speaker\\":-1}}}")!=0);
assert(apply("{\\"revision\\":4,\\"desired_config\\":{\\"volume\\":{\\"speaker\\":100.5}}}")!=0);
assert(apply("{\\"revision\\":4,\\"desired_config\\":{\\"volume\\":{\\"speaker\\":50},\\"unsupported\\":{}}}")!=0);
assert(hardware_volume==45 && applied_revision==3);
commit_error=1;assert(apply("{\\"revision\\":4,\\"desired_config\\":{\\"identity\\":{\\"display_name\\":\\"客厅陪伴设备\\"}}}")!=0);assert(device_display_name[0]==0 && applied_revision==3);commit_error=0;
read_error=1;assert(apply("{\\"revision\\":4,\\"desired_config\\":{\\"identity\\":{\\"display_name\\":\\"客厅陪伴设备\\"}}}")!=0);assert(device_display_name[0]==0 && applied_revision==3);read_error=0;
assert(apply("{\\"revision\\":4,\\"desired_config\\":{\\"identity\\":{\\"display_name\\":\\"客厅陪伴设备\\"}}}")==0);assert(!strcmp(device_display_name,"客厅陪伴设备") && applied_revision==4);
int previous_commits=commits;assert(apply("{\\"revision\\":4,\\"desired_config\\":{\\"identity\\":{\\"display_name\\":\\"客厅陪伴设备\\"}}}")==0);assert(commits==previous_commits);
assert(apply("{\\"revision\\":5,\\"desired_config\\":{\\"identity\\":{\\"display_name\\":\\"名名名名名名名名名名名名名名名名名名名名名名名名名名名名名名名名名\\"}}}")!=0);assert(applied_revision==4);
assert(apply("{\\"revision\\":5,\\"desired_config\\":{\\"identity\\":{\\"display_name\\":\\"尾部空格 \\"}}}")!=0);assert(applied_revision==4);
assert(apply("{\\"revision\\":5,\\"desired_config\\":{\\"identity\\":{\\"display_name\\":\\"\\"}}}")==0);assert(device_display_name[0]==0 && stored_name[0]==0 && applied_revision==5);
assert(same_origin_download("https://82.157.201.114/f/ticket"));
assert(!same_origin_download("http://82.157.201.114/f/ticket"));
assert(!same_origin_download("https://82.157.201.114.evil.test/f/ticket"));
assert(!same_origin_download("https://82.157.201.114@evil.test/f/ticket"));
assert(!same_origin_download("https://82.157.201.114/other/ticket"));
puts("PASS production C: idle codec volume, NVS durability, revision accuracy, invalid config and HTTPS OTA origin policy");return 0;
}
`);
@@ -0,0 +1,602 @@
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <sys/time.h>
#include <setjmp.h>
#include <openssl/sha.h>
typedef int esp_err_t;
typedef int nvs_handle_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define ESP_ERR_NO_MEM 1
#define ESP_ERR_INVALID_RESPONSE 2
#define ESP_ERR_NOT_FOUND 3
#define ESP_ERR_INVALID_ARG 4
#define ESP_ERR_INVALID_SIZE 5
#define ESP_ERR_TIMEOUT 6
#define ESP_ERR_INVALID_CRC 7
#define ESP_ERR_NVS_NOT_FOUND 8
#define ESP_ERR_HTTP_EAGAIN 0x7007
#define NVS_READONLY 0
#define NVS_READWRITE 1
#define DEVICE_FIRMWARE_VERSION "host-original"
#define ESP_PARTITION_SUBTYPE_APP_OTA_MIN 16
#define ESP_PARTITION_SUBTYPE_APP_OTA_MAX 31
#define ESP_OTA_IMG_PENDING_VERIFY 1
#define ESP_OTA_IMG_VALID 2
#define ESP_LOGI(...) ((void)0)
#define ESP_LOGE(...) ((void)0)
#define ESP_LOGW(...) ((void)0)
#define MALLOC_CAP_INTERNAL (1U<<11)
#define MALLOC_CAP_8BIT (1U<<2)
#define OTA_WITH_SEQUENTIAL_WRITES 0
#define pdTRUE 1
#define pdPASS 1
#define portMAX_DELAY UINT32_MAX
typedef struct { uint32_t address,size,subtype; } esp_partition_t;
typedef int esp_ota_img_states_t;
typedef int esp_ota_handle_t;
static const esp_partition_t parts[]={{0x10000,131072,16},{0x410000,131072,17}};
static uint8_t flash[2][131072],image_bytes[65536+123];
static size_t image_size=2048;
static int running,boot_partition,image_states[2],mark_error,boot_error,end_error;
static int begun,written,aborted,restarts,rollbacks,marked,downloaded,reported_success,reported_failed,accepted;
static int manifest_requests,manifest_error,report_error,busy,maintenance,leaves,http_status,header_size,short_read,read_error;
static int timer_create_error,timer_start_error,timer_armed,timer_deleted;
static int64_t monotonic,clock_step;
static int read_script[64],read_script_len,read_script_index,read_calls,read_timeout_ms,http_errno;
static size_t read_at_offsets[64],read_wanted[64],write_sizes[64];
static int timeout_values[64],timeout_count,write_calls,write_fail_on,write_fail_code;
static int64_t read_advances[64],heartbeat_advance,write_advance,begin_advance,open_advance,header_advance,end_advance;
static int64_t sha_start_advance,sha_update_advance,sha_finish_advance;
static int begin_error,begin_error_handle,open_error,http_init_error,sha_error_stage;
static int opened,headers_fetched,closed,client_cleaned,client_live,ota_handle_live,ended,boot_sets,transfer_first_read_call;
static size_t begin_size;
static const esp_partition_t *begin_target;
static unsigned alloc_calls,heap_fail_first,heap_frees,heap_live;
static size_t alloc_sizes[8],allocated_size;
static unsigned alloc_caps[8];
static void *allocated_buffer;
static size_t read_offset,write_offset;
static char last_report[16];
static char last_report_message[100];
static cJSON *manifest_json;
static jmp_buf restart_jump;
static bool allow_restart;
// Execute the real production worker synchronously when the parent takes the
// semaphore. Suspend escapes with longjmp; parent must join/delete before reset.
// This proves SDK-call context and ownership, not ESP32 cache/MMU behavior.
typedef struct fake_sem { bool given; } *SemaphoreHandle_t;
typedef struct fake_task { void (*entry)(void *); void *arg; bool suspended; } *TaskHandle_t;
static TaskHandle_t live_worker;
static SemaphoreHandle_t live_sem;
static bool internal_worker_context;
static jmp_buf worker_suspend_jump;
static int sem_attempts,sem_created,sem_deleted,task_attempts,task_created,task_deleted;
static int sem_fail_on,task_fail_on,worker_runs,sem_gives,sem_takes,sha_live,rollback_error;
static int64_t cleanup_advance;
static SemaphoreHandle_t xSemaphoreCreateBinary(void){
assert(!internal_worker_context&&!live_sem&&!live_worker);
if(++sem_attempts==sem_fail_on)return NULL;
live_sem=calloc(1,sizeof(*live_sem));assert(live_sem);sem_created++;return live_sem;
}
static int xTaskCreateWithCaps(void (*entry)(void *),const char *name,unsigned stack,void *arg,unsigned priority,TaskHandle_t *result,unsigned caps){
assert(!internal_worker_context&&live_sem&&!live_worker);
assert(!strcmp(name,"ota_flash")&&stack==8192&&priority==3&&caps==(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT));
// The real TLS client, internal write buffer and SHA context must be released
// before the temporary 8KiB DRAM stack allocation, including low-memory cases.
assert(!client_live&&!heap_live&&!sha_live);
if(++task_attempts==task_fail_on)return 0;
live_worker=calloc(1,sizeof(*live_worker));assert(live_worker);
live_worker->entry=entry;live_worker->arg=arg;*result=live_worker;task_created++;return pdPASS;
}
static int xSemaphoreGive(SemaphoreHandle_t sem){
assert(internal_worker_context&&sem==live_sem&&!sem->given);sem->given=true;sem_gives++;return pdTRUE;
}
static void vTaskSuspend(TaskHandle_t task){
assert(internal_worker_context&&task==NULL&&live_worker&&live_sem->given);
live_worker->suspended=true;longjmp(worker_suspend_jump,1);
}
static int xSemaphoreTake(SemaphoreHandle_t sem,unsigned timeout){
assert(!internal_worker_context&&sem==live_sem&&live_worker&&timeout==portMAX_DELAY);
assert(!sem->given);worker_runs++;internal_worker_context=true;
if(!setjmp(worker_suspend_jump)){live_worker->entry(live_worker->arg);assert(!"worker returned without suspend");}
internal_worker_context=false;assert(sem->given&&live_worker->suspended);sem_takes++;return pdTRUE;
}
static void vTaskDeleteWithCaps(TaskHandle_t task){
assert(!internal_worker_context&&task==live_worker&&task->suspended&&live_sem->given);
free(task);live_worker=NULL;task_deleted++;
}
static void vSemaphoreDelete(SemaphoreHandle_t sem){
assert(!internal_worker_context&&sem==live_sem&&!live_worker);
free(sem);live_sem=NULL;sem_deleted++;
}
static void assert_worker_cleanup(void){
assert(!live_worker&&!live_sem&&!internal_worker_context);
assert(task_created==task_deleted&&sem_created==sem_deleted);
assert(worker_runs==sem_gives&&sem_gives==sem_takes&&sem_takes==task_deleted);
}
static const char *platform_error="";
static const char *task="40000000-0000-4000-8000-000000000001";
static struct {char key[16];unsigned char bytes[512];size_t size;} records[8];
static int record_count,fail_save_phase,fail_installed,fail_readback;
static bool fail_receipt_commit;
static unsigned char staged[512];static size_t staged_size;static char staged_key[16];
static void *heap_caps_malloc(size_t size,unsigned caps){
assert(alloc_calls<8);alloc_sizes[alloc_calls]=size;alloc_caps[alloc_calls]=caps;
if(++alloc_calls<=heap_fail_first)return NULL;
assert(!heap_live);allocated_buffer=malloc(size);assert(allocated_buffer);allocated_size=size;heap_live++;return allocated_buffer;
}
static void heap_caps_free(void *buffer){
if(!buffer)return;assert(heap_live==1&&buffer==allocated_buffer);free(buffer);allocated_buffer=NULL;heap_live--;heap_frees++;
}
static int nvs_open(const char *space,int mode,int *handle){assert(!strcmp(space,"control"));*handle=1;return 0;}
static void nvs_close(int n){}
static int nvs_set_blob(int n,const char *key,const void *bytes,size_t size){assert(size<=sizeof(staged)&&strlen(key)<sizeof(staged_key));strcpy(staged_key,key);memcpy(staged,bytes,size);staged_size=size;return 0;}
static int nvs_commit(int n){
if(!strcmp(staged_key,"ota")){uint32_t phase;memcpy(&phase,staged+4,4);if((fail_save_phase&&phase==(unsigned)fail_save_phase)||(fail_receipt_commit&&phase==0))return ESP_FAIL;}
if(!strncmp(staged_key,"installed",9)&&fail_installed)return ESP_FAIL;
int i;for(i=0;i<record_count;i++)if(!strcmp(records[i].key,staged_key))break;
assert(i<8);if(i==record_count)record_count++;strcpy(records[i].key,staged_key);memcpy(records[i].bytes,staged,staged_size);records[i].size=staged_size;return 0;
}
static int nvs_get_blob(int n,const char *key,void *bytes,size_t *size){
for(int i=0;i<record_count;i++)if(!strcmp(records[i].key,key)){if(*size<records[i].size)return ESP_ERR_INVALID_SIZE;*size=records[i].size;memcpy(bytes,records[i].bytes,*size);if(fail_readback)return ESP_FAIL;return 0;}return ESP_ERR_NVS_NOT_FOUND;
}
typedef struct {char item[37];bool occupied;} StaticQueue_t;
typedef StaticQueue_t *QueueHandle_t;
static QueueHandle_t xQueueCreateStatic(int n,int size,uint8_t *storage,StaticQueue_t *q){assert(n==1&&size==37);q->occupied=false;return q;}
static int xQueueSend(QueueHandle_t q,const void *item,int timeout){if(q->occupied)return 0;memcpy(q->item,item,37);q->occupied=true;return pdTRUE;}
static int xQueueReceive(QueueHandle_t q,void *item,int timeout){if(!q->occupied)return 0;memcpy(item,q->item,37);q->occupied=false;return pdTRUE;}
typedef struct {void (*callback)(void *);const char *name;} esp_timer_create_args_t;
typedef void *esp_timer_handle_t;
static void (*timer_callback)(void *);
static int esp_timer_create(const esp_timer_create_args_t *a,esp_timer_handle_t *h){if(timer_create_error)return ESP_FAIL;*h=(void*)1;timer_callback=a->callback;return 0;}
static int esp_timer_start_once(esp_timer_handle_t h,int64_t us){assert(us==180000000);if(timer_start_error)return ESP_FAIL;timer_armed=1;return 0;}
static int esp_timer_stop(esp_timer_handle_t h){timer_armed=0;return 0;}
static int esp_timer_delete(esp_timer_handle_t h){timer_deleted++;return 0;}
static int64_t esp_timer_get_time(void){monotonic+=clock_step;return monotonic;}
static const esp_partition_t *esp_ota_get_running_partition(void){return &parts[running];}
static const esp_partition_t *esp_ota_get_next_update_partition(const esp_partition_t *p){return &parts[1-running];}
static int esp_ota_get_state_partition(const esp_partition_t *p,int *value){*value=image_states[p==&parts[1]];return *value<0?ESP_ERR_NOT_FOUND:0;}
static int esp_ota_set_boot_partition(const esp_partition_t *p){assert(internal_worker_context&&!heap_live&&!client_live&&!sha_live);boot_sets++;if(boot_error)return ESP_FAIL;boot_partition=p==&parts[1];return 0;}
static int esp_ota_mark_app_valid_cancel_rollback(void){if(mark_error)return ESP_FAIL;marked++;image_states[running]=ESP_OTA_IMG_VALID;return 0;}
static void esp_restart(void){assert_worker_cleanup();restarts++;assert(allow_restart);longjmp(restart_jump,1);}
static int esp_ota_mark_app_invalid_rollback(void){assert(internal_worker_context);rollbacks++;if(rollback_error)return ESP_FAIL;boot_partition=1-running;return 0;}
static int esp_partition_read(const esp_partition_t *p,uint32_t offset,void *bytes,size_t size){assert(offset+size<=p->size);memcpy(bytes,flash[p==&parts[1]]+offset,size);return 0;}
static int esp_ota_begin(const esp_partition_t *p,size_t size,esp_ota_handle_t *handle){
assert(p==&parts[1-running]&&read_calls==transfer_first_read_call&&!ota_handle_live);begun++;begin_target=p;begin_size=size;write_offset=0;
assert(size>=1024&&size<=p->size);monotonic+=begin_advance;
if(!begin_error||begin_error_handle){*handle=(p==&parts[1])+1;ota_handle_live=*handle;}
if(begin_error)return begin_error;
size_t erased=(size+4095)&~(size_t)4095;assert(erased<=p->size);memset(flash[p==&parts[1]],0xff,erased);return 0;
}
static int esp_ota_write(esp_ota_handle_t handle,const void *bytes,size_t size){
assert(handle==ota_handle_live&&handle-1!=running&&write_calls<64);write_sizes[write_calls++]=size;monotonic+=write_advance;
if(write_fail_on&&write_calls==write_fail_on)return write_fail_code;
assert(write_offset+size<=parts[handle-1].size);memcpy(flash[handle-1]+write_offset,bytes,size);write_offset+=size;written++;return 0;
}
static int esp_ota_end(esp_ota_handle_t handle){assert(internal_worker_context&&!heap_live&&!client_live&&!sha_live);assert(handle==ota_handle_live);ended++;monotonic+=end_advance;ota_handle_live=0;return end_error?ESP_FAIL:0;}
static int esp_ota_abort(esp_ota_handle_t handle){assert(handle==ota_handle_live);ota_handle_live=0;aborted++;return 0;}
static const char *esp_err_to_name(int err){return "INJECTED_FAILURE";}
static int esp_crt_bundle_attach(void *v){return 0;}
typedef struct {const char *url;int (*crt_bundle_attach)(void *);int timeout_ms,disable_auto_redirect,buffer_size,buffer_size_tx,keep_alive_enable;} esp_http_client_config_t;
typedef void *esp_http_client_handle_t;
static void *esp_http_client_init(const esp_http_client_config_t *cfg){assert(cfg->disable_auto_redirect&&cfg->crt_bundle_attach&&cfg->buffer_size==16384&&!client_live);read_offset=0;transfer_first_read_call=read_calls;read_timeout_ms=cfg->timeout_ms;downloaded++;if(http_init_error)return NULL;client_live=1;return (void*)1;}
static int esp_http_client_open(void *h,int length){assert(h==(void*)1&&client_live);opened++;monotonic+=open_advance;return open_error;}
static int64_t esp_http_client_fetch_headers(void *h){assert(h==(void*)1&&client_live);headers_fetched++;monotonic+=header_advance;return header_size;}
static int esp_http_client_get_status_code(void *h){return http_status;}
static int esp_http_client_set_timeout_ms(void *h,int timeout_ms){assert(h==(void*)1&&client_live&&timeout_ms>0&&timeout_ms<=20000&&timeout_count<64);timeout_values[timeout_count++]=timeout_ms;read_timeout_ms=timeout_ms;return 0;}
static int esp_http_client_get_errno(void *h){assert(h==(void*)1);return http_errno;}
static int esp_http_client_read(void *h,char *buf,size_t size){
assert(h==(void*)1&&client_live&&read_calls<64&&heap_live&&buf==allocated_buffer&&size<=allocated_size);int index=read_calls++;read_at_offsets[index]=read_offset;read_wanted[index]=size;
int result=read_error?-1:short_read?0:read_script_index<read_script_len?read_script[read_script_index++]:(int)size;
http_errno=result==-ESP_ERR_HTTP_EAGAIN?11:result<0?5:0;
monotonic+=read_advances[index];if(result==-ESP_ERR_HTTP_EAGAIN)monotonic+=(int64_t)read_timeout_ms*1000;
if(result<=0)return result;
assert((size_t)result<=size&&read_offset+(size_t)result<=image_size);memcpy(buf,image_bytes+read_offset,result);read_offset+=result;return result;
}
static void esp_http_client_close(void *h){assert(h==(void*)1&&client_live);closed++;}
static void esp_http_client_cleanup(void *h){assert(h==(void*)1&&client_live);client_live=0;client_cleaned++;monotonic+=cleanup_advance;}
typedef SHA256_CTX mbedtls_sha256_context;
static void mbedtls_sha256_init(mbedtls_sha256_context *ctx){sha_live++;memset(ctx,0,sizeof(*ctx));}
static void mbedtls_sha256_free(mbedtls_sha256_context *ctx){assert(sha_live>0);sha_live--;}
static int mbedtls_sha256_starts(mbedtls_sha256_context *ctx,int is224){monotonic+=sha_start_advance;if(sha_error_stage==1)return -1;return SHA256_Init(ctx)?0:-1;}
static int mbedtls_sha256_update(mbedtls_sha256_context *ctx,const void *bytes,size_t size){monotonic+=sha_update_advance;if(sha_error_stage==2)return -1;return SHA256_Update(ctx,bytes,size)?0:-1;}
static int mbedtls_sha256_finish(mbedtls_sha256_context *ctx,unsigned char hash[32]){monotonic+=sha_finish_advance;if(sha_error_stage==3)return -1;return SHA256_Final(hash,ctx)?0:-1;}
static void mbedtls_platform_zeroize(void *bytes,size_t size){memset(bytes,0,size);}
static bool call_client_enter_maintenance(void){if(busy)return false;assert(!maintenance);maintenance=1;return true;}
static void call_client_leave_maintenance(void){assert(maintenance);maintenance=0;leaves++;}
static void device_control_heartbeat(void){monotonic+=heartbeat_advance;heartbeat_advance=0;}
static void device_control_prepare_restart(void){}
static const char *ble_cloud_origin(void){return "https://platform.example";}
static const char *ble_factory_model(void){return "ECHOEAR-S3";}
static const char *platform_last_error_code(void){return platform_error;}
const char *device_ota_version(void);
int64_t device_ota_version_code(void);
static int platform_request(const char *method,const char *path,const char *body,const char *id,char **raw){
assert(!strcmp(method,"POST"));*raw=NULL;cJSON *request=cJSON_Parse(body);assert(request);
assert(!strcmp(cJSON_GetObjectItem(request,"task_id")->valuestring,task));
if(!strcmp(path,"/api/v1/device/ota/manifest")){manifest_requests++;cJSON_Delete(request);if(manifest_error)return ESP_FAIL;cJSON *root=cJSON_CreateObject();cJSON_AddItemToObject(root,"data",cJSON_Duplicate(manifest_json,true));*raw=cJSON_PrintUnformatted(root);cJSON_Delete(root);return 0;}
assert(!strcmp(path,"/api/v1/device/ota/report"));const char *status=cJSON_GetObjectItem(request,"status")->valuestring;snprintf(last_report,sizeof(last_report),"%s",status);
const cJSON *message=cJSON_GetObjectItem(request,"message");snprintf(last_report_message,sizeof(last_report_message),"%s",cJSON_IsString(message)?message->valuestring:"");
if(!strcmp(status,"accepted"))accepted++;else if(!strcmp(status,"success"))reported_success++;else if(!strcmp(status,"failed"))reported_failed++;else assert(false);
cJSON_Delete(request);if(report_error)return ESP_FAIL;*raw=strdup("{\"data\":{}}");return 0;
}
/* PRODUCTION_OTA */
static void reboot_to(int slot){
running=slot;maintenance=0;ready=false;boot_timer=NULL;requests=NULL;timer_armed=0;timer_callback=NULL;memset(&state,0,sizeof(state));memset(&installed,0,sizeof(installed));
device_ota_boot_guard();
}
static void fresh(void){
assert(!heap_live&&!client_live&&!ota_handle_live&&!sha_live);assert_worker_cleanup();
sem_attempts=sem_created=sem_deleted=task_attempts=task_created=task_deleted=0;
sem_fail_on=task_fail_on=worker_runs=sem_gives=sem_takes=rollback_error=0;cleanup_advance=0;
task="40000000-0000-4000-8000-000000000001";
cJSON_Delete(manifest_json);manifest_json=NULL;
running=boot_partition=record_count=begun=written=aborted=restarts=rollbacks=marked=downloaded=reported_success=reported_failed=accepted=manifest_requests=0;
mark_error=boot_error=end_error=manifest_error=report_error=busy=maintenance=leaves=short_read=read_error=timer_create_error=timer_start_error=timer_deleted=0;
fail_save_phase=fail_installed=fail_readback=0;fail_receipt_commit=false;last_report[0]=0;monotonic=0;clock_step=1000;
read_script_len=read_script_index=read_calls=timeout_count=write_calls=write_fail_on=http_errno=0;write_fail_code=95;
heartbeat_advance=write_advance=begin_advance=open_advance=header_advance=end_advance=sha_start_advance=sha_update_advance=sha_finish_advance=0;
begin_error=open_error=http_init_error=sha_error_stage=opened=headers_fetched=closed=client_cleaned=ended=boot_sets=transfer_first_read_call=0;begin_error_handle=1;begin_target=NULL;begin_size=0;
alloc_calls=heap_fail_first=heap_frees=0;allocated_size=0;allocated_buffer=NULL;image_size=2048;
memset(alloc_sizes,0,sizeof(alloc_sizes));memset(alloc_caps,0,sizeof(alloc_caps));memset(write_sizes,0,sizeof(write_sizes));memset(read_wanted,0,sizeof(read_wanted));
memset(read_script,0,sizeof(read_script));memset(read_at_offsets,0,sizeof(read_at_offsets));memset(read_advances,0,sizeof(read_advances));last_report_message[0]=0;
platform_error="";
image_states[0]=ESP_OTA_IMG_VALID;image_states[1]=ESP_OTA_IMG_PENDING_VERIFY;
for(size_t i=0;i<sizeof(image_bytes);i++)image_bytes[i]=(i*31+17)%251;
memset(flash,0,sizeof(flash));header_size=image_size;http_status=200;
unsigned char hash[32];char hex[65];SHA256(image_bytes,image_size,hash);hash_hex(hash,hex);
struct timeval now;gettimeofday(&now,NULL);
manifest_json=cJSON_CreateObject();cJSON_AddStringToObject(manifest_json,"task_id",task);cJSON_AddStringToObject(manifest_json,"model","ECHOEAR-S3");
cJSON_AddStringToObject(manifest_json,"version","new-11");cJSON_AddNumberToObject(manifest_json,"version_code",11);cJSON_AddNumberToObject(manifest_json,"size",image_size);
cJSON_AddStringToObject(manifest_json,"sha256",hex);cJSON_AddStringToObject(manifest_json,"download_url","https://platform.example/f/test-ticket");cJSON_AddNumberToObject(manifest_json,"expires_at_ms",(double)now.tv_sec*1000+30000);
reboot_to(0);device_ota_boot_ready();assert(ready);
}
static void set_string(const char *key,const char *value){assert(cJSON_ReplaceItemInObject(manifest_json,key,cJSON_CreateString(value)));}
static void set_number(const char *key,double value){assert(cJSON_ReplaceItemInObject(manifest_json,key,cJSON_CreateNumber(value)));}
static void install(void){device_ota_request(task);allow_restart=true;if(!setjmp(restart_jump))device_ota_poll();allow_restart=false;}
static void boot_ready_allow_rollback(void){allow_restart=true;if(!setjmp(restart_jump))device_ota_boot_ready();allow_restart=false;}
static void original_record(void){installed_t old={.format=1,.address=parts[0].address,.code=10};strcpy(old.version,"old-10");assert(persist("installed",&old,sizeof(old))==0);reboot_to(0);device_ota_boot_ready();assert(device_ota_version_code()==10);}
static void test_normal(void){
fresh();original_record();install();assert(restarts==1&&boot_partition==1&&state.phase==2&&accepted==1);assert(!memcmp(flash[1],image_bytes,image_size));
reboot_to(1);assert(timer_armed);device_ota_boot_ready();assert(ready&&state.phase==3&&device_ota_version_code()==11&&marked==1&&!timer_armed);
device_ota_poll();assert(reported_success==1&&state.phase==0);int prior=manifest_requests;device_ota_request(task);device_ota_poll();assert(prior==manifest_requests&&downloaded==1);
reboot_to(1);device_ota_boot_ready();assert(device_ota_version_code()==11);
}
static void test_rejections(void){
for(int i=0;i<8;i++){fresh();switch(i){case 0:set_string("model","wrong");break;case 1:set_number("size",999999);break;case 2:set_number("version_code",0);break;case 3:set_number("expires_at_ms",1);break;case 4:set_string("sha256","bad");break;case 5:set_string("download_url","https://evil.example/f/test-ticket");break;case 6:set_string("task_id","wrong");break;case 7:busy=1;break;}
install();assert(!begun&&!restarts&&boot_partition==0&&state.phase==4&&!maintenance);device_ota_poll();assert(reported_failed==1);}
fresh();manifest_error=1;install();assert(!begun&&!restarts&&!maintenance);
}
static void test_download_failures(void){
for(int i=0;i<7;i++){fresh();switch(i){case 0:image_bytes[0]^=1;break;case 1:http_status=302;break;case 2:header_size--;break;case 3:short_read=1;break;case 4:read_error=1;break;case 5:end_error=1;break;case 6:clock_step=500000000;break;}
install();assert(!restarts&&boot_partition==0&&state.phase==4&&!maintenance);device_ota_poll();assert(reported_failed==1);}
for(int phase=1;phase<=2;phase++){fresh();fail_save_phase=phase;install();assert(!restarts&&boot_partition==0&&!maintenance);if(phase==1)assert(!accepted&&!begun);}
fresh();report_error=1;install();assert(!begun&&!restarts&&!maintenance);
fresh();boot_error=1;install();assert(!restarts&&boot_partition==0&&state.phase==4);
}
static void script_reads(const int *values,size_t count){assert(count<=64);memcpy(read_script,values,count*sizeof(*values));read_script_len=count;}
static void assert_download_receipt(const char *detail){
assert(state.phase==4&&!restarts&&boot_partition==0&&!maintenance&&aborted==1);
assert(strstr(state.message,detail)&&strlen(state.message)<sizeof(state.message));
assert(!strstr(state.message,"https://")&&!strstr(state.message,"test-ticket"));
char expected[sizeof(state.message)];strcpy(expected,state.message);device_ota_poll();
assert(reported_failed==1&&!strcmp(last_report_message,expected)&&state.phase==0);
}
static void test_read_recovery(void){
fresh();clock_step=0;const int reads[]={-ESP_ERR_HTTP_EAGAIN,-ESP_ERR_HTTP_EAGAIN,512,-ESP_ERR_HTTP_EAGAIN,-ESP_ERR_HTTP_EAGAIN,1536};script_reads(reads,6);
install();assert(restarts==1&&boot_partition==1&&state.phase==2&&downloaded==1&&begun==1&&read_calls==6&&written==2);
assert(!memcmp(flash[1],image_bytes,image_size)&&read_offset==image_size&&write_offset==image_size);
assert(read_at_offsets[0]==0&&read_at_offsets[1]==0&&read_at_offsets[2]==0&&read_at_offsets[3]==512&&read_at_offsets[4]==512&&read_at_offsets[5]==512);
assert(timeout_count==8);for(int i=0;i<timeout_count;i++)assert(timeout_values[i]==20000);
assert(monotonic==80000000&&!aborted&&strstr(state.message,"OTA ms="));
}
static void test_read_stall_limit(void){
fresh();clock_step=0;const int reads[]={-ESP_ERR_HTTP_EAGAIN,-ESP_ERR_HTTP_EAGAIN,-ESP_ERR_HTTP_EAGAIN,2048};script_reads(reads,4);
install();assert(read_calls==3&&downloaded==1&&begun==1&&!written&&read_offset==0&&write_offset==0&&monotonic==60000000);
assert_download_receipt("OTA read e=0x7007 off=0/2048 rc=-28679 errno=11");
}
static void test_read_eof_and_error(void){
const int failures[]={0,-1,-0x7005};
for(size_t i=0;i<sizeof(failures)/sizeof(failures[0]);i++){
fresh();clock_step=0;const int reads[]={512,failures[i],1536};script_reads(reads,3);install();
assert(read_calls==2&&written==1&&downloaded==1&&read_offset==512&&write_offset==512);
char detail[80];snprintf(detail,sizeof(detail),"OTA read e=0xffffffff off=512/2048 rc=%d errno=%d",failures[i],failures[i]<0?5:0);
assert_download_receipt(detail);
}
}
static void test_write_failure_offset(void){
fresh();clock_step=0;const int reads[]={512,512,1024};script_reads(reads,3);write_fail_on=2;install();
assert(read_calls==2&&write_calls==2&&written==1&&read_offset==1024&&write_offset==512);
assert(!memcmp(flash[1],image_bytes,512)&&flash[1][512]==0xff);
assert_download_receipt("OTA write e=0x5f off=512/2048 rc=512 errno=0");
}
static void test_read_deadline(void){
// A pending heartbeat must not extend the fixed 900-second budget.
fresh();clock_step=0;heartbeat_advance=900000001;install();
assert(!read_calls&&timeout_count==2&&!written);assert_download_receipt("OTA deadline e=0x6 off=0/2048 rc=0 errno=0");
// The last wait is clipped to the remaining 500ms and cannot be retried.
fresh();clock_step=0;heartbeat_advance=899500000;const int stalled[]={-ESP_ERR_HTTP_EAGAIN,2048};script_reads(stalled,2);install();
assert(read_calls==1&&timeout_count==3&&timeout_values[2]==500&&monotonic==900000000&&!written);
assert_download_receipt("OTA deadline e=0x6 off=0/2048 rc=-28679 errno=11");
// Even a positive read that finishes at the deadline cannot start a write.
fresh();clock_step=0;heartbeat_advance=899500000;const int positive[]={512,1536};script_reads(positive,2);read_advances[0]=500000;install();
assert(read_calls==1&&timeout_values[2]==500&&read_offset==512&&!written&&write_offset==0);
assert_download_receipt("OTA deadline e=0x6 off=0/2048 rc=512 errno=0");
// If a write reaches the deadline, diagnostics count only completed writes.
fresh();clock_step=0;script_reads(positive,2);write_advance=900000000;install();
assert(read_calls==1&&written==1&&write_offset==512&&monotonic==900000000);
assert_download_receipt("OTA deadline e=0x6 off=512/2048 rc=512 errno=0");
}
static void test_long_download(void){
fresh();clock_step=0;const int reads[]={1024,1024};script_reads(reads,2);read_advances[0]=read_advances[1]=350000000;install();
assert(restarts==1&&boot_partition==1&&state.phase==2&&monotonic==700000000&&downloaded==1&&begun==1&&written==2&&!aborted);
assert(!memcmp(flash[1],image_bytes,image_size)&&strstr(state.message,"OTA ms="));
}
static void assert_resources_clean(unsigned allocations,unsigned frees,int aborts,int clients){
assert(alloc_calls==allocations&&heap_frees==frees&&!heap_live&&!client_live&&!ota_handle_live);
assert(aborted==aborts&&closed==clients&&client_cleaned==clients);
}
static void assert_failed_safe(const char *detail,unsigned allocations,unsigned frees,int aborts,int clients){
assert(state.phase==4&&!restarts&&!boot_sets&&boot_partition==0&&!maintenance);
assert(strstr(state.message,detail)&&!strstr(state.message,"OTA ms=")&&!strstr(state.message,"https://")&&!strstr(state.message,"test-ticket"));
assert_resources_clean(allocations,frees,aborts,clients);
char expected[sizeof(state.message)];strcpy(expected,state.message);device_ota_poll();
assert(reported_failed==1&&!strcmp(last_report_message,expected)&&state.phase==0);
}
static void test_known_size_preerase(void){
fresh();clock_step=0;memset(flash[0],0xa7,sizeof(flash[0]));install();
assert(restarts==1&&boot_partition==1&&boot_sets==1&&begun==1&&begin_target==&parts[1]&&begin_size==image_size);
assert(opened==1&&headers_fetched==1&&read_calls==1&&read_wanted[0]==image_size&&write_sizes[0]==image_size);
assert(alloc_sizes[0]==8192&&alloc_caps[0]==(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT));
assert(!memcmp(flash[1],image_bytes,image_size));
for(size_t i=0;i<sizeof(flash[0]);i++)assert(flash[0][i]==0xa7);
for(size_t i=image_size;i<4096;i++)assert(flash[1][i]==0xff);
assert(flash[1][4096]==0);assert_resources_clean(1,1,0,1);
}
static void test_target_guard(void){
for(int i=0;i<5;i++){
fresh();clock_step=0;state.size=image_size;
esp_partition_t alias=parts[0];const esp_partition_t *target=&parts[1];
if(i==0)target=&parts[0];else if(i==1)target=&alias;else if(i==2)target=NULL;
else if(i==3)state.size=1023;else state.size=parts[1].size+1;
assert(download("https://platform.example/f/test-ticket",target)==ESP_ERR_INVALID_SIZE);
assert(!begun&&!downloaded&&!opened&&!headers_fetched&&!read_calls&&!written&&!restarts&&!boot_sets);
assert_resources_clean(0,0,0,0);
}
}
static void test_begin_error_and_deadline(void){
for(int returns_handle=0;returns_handle<2;returns_handle++){
fresh();clock_step=0;begin_error=ESP_FAIL;begin_error_handle=returns_handle;begin_advance=7000;install();
assert(begun==1&&begin_size==image_size&&!read_calls&&!written&&!ended&&monotonic==7000);
assert_failed_safe("OTA begin e=0xffffffff off=0/2048 rc=0 errno=0",1,1,returns_handle,1);
}
// Preerase consumes the same fixed budget, rather than creating a new one.
fresh();clock_step=0;begin_advance=900000000;install();
assert(begun==1&&!read_calls&&!written&&!ended&&monotonic==900000000&&timeout_count==2);
assert_failed_safe("OTA deadline e=0x6 off=0/2048 rc=0 errno=0",1,1,1,1);
// Only 500ms remains after erase; the first read cannot obtain a fresh 20s.
fresh();clock_step=0;begin_advance=899500000;const int stalled[]={-ESP_ERR_HTTP_EAGAIN,2048};script_reads(stalled,2);install();
assert(read_calls==1&&timeout_count==3&&timeout_values[0]==20000&&timeout_values[1]==20000&&timeout_values[2]==500&&!written);
assert_failed_safe("OTA deadline e=0x6 off=0/2048 rc=-28679 errno=11",1,1,1,1);
}
static void test_setup_deadlines(void){
// Neither open nor headers gets to reset the deadline or begin erasing late.
fresh();clock_step=0;open_advance=900000000;install();
assert(opened==1&&!headers_fetched&&!begun&&!read_calls&&timeout_count==1);
assert_failed_safe("OTA deadline e=0x6 off=0/2048 rc=0 errno=0",1,1,0,1);
fresh();clock_step=0;header_advance=900000000;install();
assert(opened==1&&headers_fetched==1&&!begun&&!read_calls&&timeout_count==2);
assert_failed_safe("OTA deadline e=0x6 off=0/2048 rc=0 errno=0",1,1,0,1);
fresh();clock_step=0;open_advance=899500000;header_advance=500000;install();
assert(timeout_count==2&&timeout_values[0]==20000&&timeout_values[1]==500&&!begun&&!read_calls);
assert_failed_safe("OTA deadline e=0x6 off=0/2048 rc=0 errno=0",1,1,0,1);
// SHA finalization and SDK end are inside the budget even after all writes.
fresh();clock_step=0;sha_finish_advance=900000000;install();
assert(written==1&&write_offset==image_size&&!ended);
assert_failed_safe("OTA deadline e=0x6 off=2048/2048",1,1,1,1);
fresh();clock_step=0;end_advance=900000000;install();assert(ended==1&&written==1);
assert_failed_safe("OTA deadline e=0x6 off=2048/2048",1,1,0,1);
}
static void test_buffer_fallbacks(void){
for(unsigned failures=0;failures<2;failures++){
fresh();clock_step=0;heap_fail_first=failures;image_size=sizeof(image_bytes);header_size=image_size;
unsigned char digest[32];char hex[65];SHA256(image_bytes,image_size,digest);hash_hex(digest,hex);set_number("size",image_size);set_string("sha256",hex);
install();size_t chunk=failures?4096:8192;unsigned count=(image_size+chunk-1)/chunk;
assert(restarts==1&&boot_partition==1&&begun==1&&begin_size==image_size&&read_calls==(int)count&&written==(int)count);
assert(allocated_size==chunk&&alloc_sizes[0]==8192&&(!failures||alloc_sizes[1]==4096));
for(unsigned i=0;i<alloc_calls;i++)assert(alloc_caps[i]==(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT));
for(unsigned i=0;i<count;i++){size_t expected=i+1<count?chunk:image_size-i*chunk;assert(read_wanted[i]==expected&&write_sizes[i]==expected);}
assert(read_offset==image_size&&write_offset==image_size&&!memcmp(flash[1],image_bytes,image_size));
char expected[20];snprintf(expected,sizeof(expected),"buf=%u",(unsigned)chunk);assert(strstr(state.message,expected));
assert_resources_clean(failures+1,1,0,1);
// Verify the large image through the real post-boot flash SHA path too.
reboot_to(1);device_ota_boot_ready();assert(ready&&state.phase==3&&device_ota_version_code()==11);device_ota_poll();assert(reported_success==1);
}
fresh();clock_step=0;heap_fail_first=2;install();
assert(!downloaded&&!opened&&!headers_fetched&&!begun&&!read_calls&&!written);
assert(alloc_sizes[0]==8192&&alloc_sizes[1]==4096);assert_failed_safe("OTA failed:",2,0,0,0);
}
static void test_failure_cleanup(void){
fresh();clock_step=0;http_init_error=1;install();assert(downloaded==1&&!opened&&!begun);assert_failed_safe("OTA failed:",1,1,0,0);
fresh();clock_step=0;open_error=ESP_FAIL;install();assert(opened==1&&!headers_fetched&&!begun);assert_failed_safe("OTA open",1,1,0,1);
for(int bad_header=0;bad_header<2;bad_header++){
fresh();clock_step=0;if(bad_header)header_size--;else http_status=302;install();
assert(headers_fetched==1&&!begun&&!read_calls);assert_failed_safe("OTA headers",1,1,0,1);
}
for(int sha_stage=1;sha_stage<=3;sha_stage++){
fresh();clock_step=0;sha_error_stage=sha_stage;install();assert(begun==1&&!ended);
assert_failed_safe(sha_stage==1?"OTA sha.init":sha_stage==2?"OTA sha.upd":"OTA sha.end",1,1,1,1);
}
fresh();clock_step=0;image_bytes[0]^=1;install();assert(!ended);assert_failed_safe("OTA sha.end e=0x7",1,1,1,1);
fresh();clock_step=0;end_error=1;install();assert(ended==1);assert_failed_safe("OTA end",1,1,0,1);
// Successful download metrics must never conceal later journal or boot failure.
fresh();clock_step=0;fail_save_phase=2;install();assert(ended==1);assert_failed_safe("OTA journal failed:",1,1,0,1);
fresh();clock_step=0;boot_error=1;install();assert(ended==1&&boot_sets==1&&state.phase==4&&!restarts&&boot_partition==0);
assert(strstr(state.message,"OTA boot selection failed:")&&!strstr(state.message,"OTA ms="));assert_resources_clean(1,1,0,1);
}
static void test_success_metrics(void){
fresh();clock_step=0;begin_advance=7000;open_advance=11000;header_advance=13000;end_advance=19000;write_advance=5000;sha_update_advance=3000;
const int reads[]={1024,1024};script_reads(reads,2);read_advances[0]=17000;read_advances[1]=23000;install();
const char *expected="OTA ms=106 erase=7 read=40 write=10 sha=6 buf=8192";
assert(restarts==1&&state.phase==2&&monotonic==106000&&!strcmp(state.message,expected));assert_resources_clean(1,1,0,1);
reboot_to(1);assert(!strcmp(state.message,expected));device_ota_boot_ready();assert(state.phase==3&&!strcmp(state.message,expected));
device_ota_poll();assert(reported_success==1&&!strcmp(last_report_message,expected)&&state.phase==0);
reboot_to(1);device_ota_boot_ready();assert(device_ota_version_code()==11&&!strcmp(state.message,expected));
}
static void test_power_and_boot(void){
fresh();original_record();install();reboot_to(0);device_ota_boot_ready();assert(state.phase==4&&ready&&device_ota_version_code()==10);device_ota_poll();assert(reported_failed==1);
fresh();install();flash[1][0]^=1;reboot_to(1);boot_ready_allow_rollback();assert(rollbacks==1&&boot_partition==0&&!ready);
fresh();install();reboot_to(1);assert(timer_callback&&timer_armed);allow_restart=true;if(!setjmp(restart_jump))timer_callback(NULL);allow_restart=false;assert(restarts==2&&!ready);
fresh();fail_save_phase=2;install();reboot_to(0);fail_save_phase=0;device_ota_boot_ready();assert(ready&&state.phase==4);
}
static void test_metadata_failure(void){
fresh();original_record();install();reboot_to(1);mark_error=1;device_ota_boot_ready();assert(!ready&&timer_armed);mark_error=0;
reboot_to(0);device_ota_boot_ready();assert(ready&&state.phase==4);assert(device_ota_version_code()==10);assert(!strcmp(device_ota_version(),"old-10"));
// Once both slots have been used, failing the next candidate still restores
// the exact version metadata for the previous, already updated slot.
fresh();original_record();install();reboot_to(1);device_ota_boot_ready();device_ota_poll();
task="40000000-0000-4000-8000-000000000002";set_string("task_id",task);set_string("version","new-12");set_number("version_code",12);
install();image_states[0]=ESP_OTA_IMG_PENDING_VERIFY;reboot_to(0);mark_error=1;device_ota_boot_ready();assert(!ready);mark_error=0;
reboot_to(1);device_ota_boot_ready();assert(ready&&device_ota_version_code()==11&&!strcmp(device_ota_version(),"new-11"));
fresh();original_record();install();reboot_to(1);fail_installed=1;device_ota_boot_ready();assert(!ready&&timer_armed);fail_installed=0;reboot_to(0);device_ota_boot_ready();assert(device_ota_version_code()==10);
}
static void test_timer_failure(void){
for(int i=0;i<2;i++){fresh();install();if(i)timer_start_error=1;else timer_create_error=1;
allow_restart=true;if(!setjmp(restart_jump))reboot_to(1);allow_restart=false;assert(rollbacks==1&&boot_partition==0&&!ready);}
}
static void test_numeric_validation(void){
for(int i=0;i<3;i++){fresh();set_number("version_code",i==0?11.5:i==1?1e100:INFINITY);install();assert(!begun&&!restarts);}
}
static void test_corrupt_records(void){
fresh();original_record();fail_readback=1;reboot_to(0);device_ota_boot_ready();assert(!ready&&!device_ota_supported());device_ota_request(task);device_ota_poll();assert(!begun);
fresh();install();for(int i=0;i<record_count;i++)if(!strcmp(records[i].key,"ota"))records[i].size=3;
reboot_to(1);device_ota_boot_ready();assert(!ready&&timer_armed);assert(!marked);
}
static void test_receipt_and_manifest_recovery(void){
fresh();manifest_error=1;install();assert(manifest_requests==1&&!begun&&!maintenance);
manifest_error=0;allow_restart=true;if(!setjmp(restart_jump))device_ota_poll();allow_restart=false;
assert(manifest_requests==2&&restarts==1&&boot_partition==1);
fresh();manifest_error=1;install();device_ota_poll();device_ota_poll();device_ota_poll();
assert(manifest_requests==3&&!begun&&!maintenance);
fresh();manifest_error=1;platform_error="OTA_TASK_UNAVAILABLE";install();device_ota_poll();assert(manifest_requests==1);
fresh();install();reboot_to(1);device_ota_boot_ready();
// Force the terminal receipt's phase=0 commit to fail and retain the journal.
fail_receipt_commit=true;device_ota_poll();assert(state.phase==3);
fail_receipt_commit=false;device_ota_poll();assert(state.phase==0);
fresh();install();reboot_to(1);device_ota_boot_ready();
report_error=1;platform_error="OTA_FINISHED";device_ota_poll();assert(state.phase==0);
fresh();install();reboot_to(1);device_ota_boot_ready();report_error=1;
device_ota_poll();assert(state.phase==3);report_error=0;device_ota_poll();assert(state.phase==0);
}
static void test_internal_finalize(void){
// The first internal operation finalizes an active OTA handle. Allocation
// failure must abort it, whereas an SDK end failure has consumed it already.
fresh();clock_step=0;sem_fail_on=1;install();
assert(ended==0&&aborted==1&&task_attempts==0&&state.phase==4&&boot_partition==0&&!restarts);
assert_resources_clean(1,1,1,1);assert_worker_cleanup();
fresh();clock_step=0;task_fail_on=1;install();
assert(ended==0&&aborted==1&&sem_created==1&&sem_deleted==1&&task_created==0&&state.phase==4&&boot_partition==0&&!restarts);
assert_resources_clean(1,1,1,1);assert_worker_cleanup();
fresh();clock_step=0;end_error=1;install();
assert(ended==1&&aborted==0&&boot_sets==0&&task_created==1&&state.phase==4&&boot_partition==0&&!restarts);
assert_resources_clean(1,1,0,1);assert_worker_cleanup();
// The second operation selects the candidate. It must never abort an ended
// SDK handle or restart after either allocation or boot-selection failure.
fresh();clock_step=0;sem_fail_on=2;install();
assert(ended==1&&aborted==0&&boot_sets==0&&task_created==1&&state.phase==4&&boot_partition==0&&!restarts);
assert(strstr(state.message,"OTA boot selection failed:"));assert_worker_cleanup();
fresh();clock_step=0;task_fail_on=2;install();
assert(ended==1&&aborted==0&&boot_sets==0&&task_created==1&&sem_created==2&&state.phase==4&&boot_partition==0&&!restarts);
assert_worker_cleanup();
fresh();clock_step=0;boot_error=1;install();
assert(ended==1&&aborted==0&&boot_sets==1&&task_created==2&&state.phase==4&&boot_partition==0&&!restarts);
assert_worker_cleanup();
fresh();clock_step=0;install();
assert(ended==1&&boot_sets==1&&restarts==1&&boot_partition==1&&state.phase==2&&task_created==2);
assert_resources_clean(1,1,0,1);assert_worker_cleanup();
// A global deadline reached while releasing TLS prevents verification.
fresh();clock_step=0;cleanup_advance=900000000;install();
assert(ended==0&&aborted==1&&task_attempts==0&&state.phase==4&&strstr(state.message,"OTA deadline"));
assert_resources_clean(1,1,1,1);assert_worker_cleanup();
// If verification consumed the handle before its deadline expires, the
// caller records timeout and retains the old slot without aborting it again.
fresh();clock_step=0;end_advance=900000000;install();
assert(ended==1&&aborted==0&&boot_sets==0&&state.phase==4&&boot_partition==0&&strstr(state.message,"OTA deadline"));
assert_worker_cleanup();
// Completion distinguishes a worker allocation error from a completed SDK
// operation error; callers use this to maintain the handle's ownership.
fresh();bool completed=true;sem_fail_on=1;
assert(ota_internal_flash_call(OTA_FLASH_BOOT,0,&parts[0],&completed)==ESP_ERR_NO_MEM&&!completed);assert_worker_cleanup();
fresh();completed=true;task_fail_on=1;
assert(ota_internal_flash_call(OTA_FLASH_BOOT,0,&parts[0],&completed)==ESP_ERR_NO_MEM&&!completed);assert_worker_cleanup();
fresh();completed=false;boot_error=1;
assert(ota_internal_flash_call(OTA_FLASH_BOOT,0,&parts[0],&completed)==ESP_FAIL&&completed);assert_worker_cleanup();
}
static void test_internal_boot_and_rollback(void){
// First serial-installed image establishes its running slot as fallback.
fresh();clock_step=0;image_states[0]=-1;reboot_to(0);device_ota_boot_ready();
assert(ready&&boot_sets==1&&marked==1&&boot_partition==0&&task_created==1);assert_worker_cleanup();
fresh();image_states[0]=-1;reboot_to(0);task_fail_on=1;device_ota_boot_ready();
assert(!ready&&boot_sets==0&&marked==0&&boot_partition==0);assert_worker_cleanup();
fresh();image_states[0]=-1;reboot_to(0);boot_error=1;device_ota_boot_ready();
assert(!ready&&boot_sets==1&&marked==0&&boot_partition==0);assert_worker_cleanup();
// Corrupt candidate executes rollback internally; reset happens in parent
// only after both worker and semaphore have been deleted.
fresh();install();flash[1][0]^=1;reboot_to(1);boot_ready_allow_rollback();
assert(rollbacks==1&&boot_partition==0&&restarts==2&&!ready&&task_created==3);assert_worker_cleanup();
fresh();install();flash[1][0]^=1;reboot_to(1);rollback_error=1;device_ota_boot_ready();
assert(rollbacks==1&&boot_partition==1&&restarts==1&&!ready&&timer_armed&&state.phase==4);assert_worker_cleanup();
for(int fail=0;fail<2;fail++){
fresh();install();flash[1][0]^=1;reboot_to(1);
if(fail)task_fail_on=task_attempts+1;else sem_fail_on=sem_attempts+1;
device_ota_boot_ready();assert(rollbacks==0&&boot_partition==1&&restarts==1&&!ready&&timer_armed);assert_worker_cleanup();
}
// Failed health-timer setup must use the same safe worker, then restart even
// if explicit rollback fails: the bootloader owns pending-image rollback.
for(int fail=0;fail<2;fail++){
fresh();install();timer_create_error=1;rollback_error=fail;allow_restart=true;
if(!setjmp(restart_jump))reboot_to(1);allow_restart=false;
assert(rollbacks==1&&restarts==2&&!ready&&boot_partition==(fail?1:0));assert_worker_cleanup();
}
fresh();bool completed=false;rollback_error=1;
assert(ota_internal_flash_call(OTA_FLASH_ROLLBACK,0,NULL,&completed)==ESP_FAIL&&completed);
assert(rollbacks==1&&!restarts&&boot_partition==0);assert_worker_cleanup();
}
int main(int argc,char **argv){
const char *pick=argc>1?argv[1]:"all";
if(!strcmp(pick,"all")||!strcmp(pick,"normal"))test_normal();
if(!strcmp(pick,"all")||!strcmp(pick,"rejections"))test_rejections();
if(!strcmp(pick,"all")||!strcmp(pick,"download"))test_download_failures();
if(!strcmp(pick,"all")||!strcmp(pick,"read-recovery"))test_read_recovery();
if(!strcmp(pick,"all")||!strcmp(pick,"read-stall"))test_read_stall_limit();
if(!strcmp(pick,"all")||!strcmp(pick,"read-eof"))test_read_eof_and_error();
if(!strcmp(pick,"all")||!strcmp(pick,"write-offset"))test_write_failure_offset();
if(!strcmp(pick,"all")||!strcmp(pick,"deadline"))test_read_deadline();
if(!strcmp(pick,"all")||!strcmp(pick,"long-download"))test_long_download();
if(!strcmp(pick,"all")||!strcmp(pick,"preerase"))test_known_size_preerase();
if(!strcmp(pick,"all")||!strcmp(pick,"target-guard"))test_target_guard();
if(!strcmp(pick,"all")||!strcmp(pick,"erase-deadline"))test_begin_error_and_deadline();
if(!strcmp(pick,"all")||!strcmp(pick,"setup-deadline"))test_setup_deadlines();
if(!strcmp(pick,"all")||!strcmp(pick,"buffer"))test_buffer_fallbacks();
if(!strcmp(pick,"all")||!strcmp(pick,"cleanup"))test_failure_cleanup();
if(!strcmp(pick,"all")||!strcmp(pick,"metrics"))test_success_metrics();
if(!strcmp(pick,"all")||!strcmp(pick,"power"))test_power_and_boot();
if(!strcmp(pick,"all")||!strcmp(pick,"metadata"))test_metadata_failure();
if(!strcmp(pick,"all")||!strcmp(pick,"timer"))test_timer_failure();
if(!strcmp(pick,"all")||!strcmp(pick,"numeric"))test_numeric_validation();
if(!strcmp(pick,"all")||!strcmp(pick,"corrupt"))test_corrupt_records();
if(!strcmp(pick,"all")||!strcmp(pick,"recovery"))test_receipt_and_manifest_recovery();
if(!strcmp(pick,"all")||!strcmp(pick,"internal-finalize"))test_internal_finalize();
if(!strcmp(pick,"all")||!strcmp(pick,"internal-boot-rollback"))test_internal_boot_and_rollback();
assert_worker_cleanup();assert(!heap_live&&!client_live&&!ota_handle_live&&!sha_live);cJSON_Delete(manifest_json);printf("PASS production OTA: %s\n",pick);return 0;
}
@@ -0,0 +1,7 @@
// Compile the complete production OTA state machine with real SHA-256 and
// injected ESP partition, HTTP, NVS, timer and restart failures.
const fs=require('fs'),path=require('path');
const root=path.resolve(__dirname,'..'),json=path.join(process.env.IDF_PATH||'E:/Codex/toolchains/esp-idf-v5.5.1','components/json/cJSON');
const source=fs.readFileSync(path.join(root,'main/device_ota.c'),'utf8').replace(/^#include[^\n]*\n/gm,'');
const harness=fs.readFileSync(path.join(__dirname,'device-ota-host.c'),'utf8');
process.stdout.write(fs.readFileSync(path.join(json,'cJSON.h'),'utf8')+'\n'+fs.readFileSync(path.join(json,'cJSON.c'),'utf8').replace('#include "cJSON.h"','')+'\n'+harness.replace('/* PRODUCTION_OTA */',source));
@@ -0,0 +1,56 @@
// Execute the production power transition code with failing hardware/NVS doubles.
const fs=require('fs'), path=require('path');
const root=path.resolve(__dirname,'..');
const json=path.join(process.env.IDF_PATH||'E:/Codex/toolchains/esp-idf-v5.5.1','components/json/cJSON');
const source=fs.readFileSync(path.join(root,'main/device_power.c'),'utf8').replace(/^#include[^\n]*\n/gm,'');
process.stdout.write(fs.readFileSync(path.join(json,'cJSON.h'),'utf8')+'\n'+fs.readFileSync(path.join(json,'cJSON.c'),'utf8').replace('#include "cJSON.h"','')+`
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
typedef int esp_err_t;
typedef int nvs_handle_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define ESP_ERR_INVALID_ARG 1
#define ESP_ERR_INVALID_STATE 2
#define ESP_ERR_NVS_NOT_FOUND 3
#define NVS_READONLY 0
#define NVS_READWRITE 1
typedef struct {void *display_handle;} volc_hal_context_t;
static volc_hal_context_t context={(void *)1};
static bool held,busy,standby,paused;
static int brightness=80,stored=-1,staged=-1,commits,storage_error,audio_error,display_error;
static bool call_client_enter_maintenance(void){if(held||busy)return false;held=true;return true;}
static void call_client_leave_maintenance(void){held=false;}
static int call_client_set_standby(bool value){if(busy)return ESP_ERR_INVALID_STATE;standby=value;return 0;}
static bool call_client_standby(void){return standby;}
static int audio_recorder_set_standby(bool value){if(audio_error)return -1;paused=value;return 0;}
static bool audio_recorder_is_standby(void){return paused;}
static uint32_t audio_recorder_microphone_reads(void){return 42;}
static volc_hal_context_t *volc_get_global_hal_context(void){return &context;}
static int volc_hal_display_get_brightness(void *display){return brightness;}
static int volc_hal_display_set_brightness(void *display,int value){if(display_error)return -1;brightness=value;return 0;}
static int nvs_open(const char *name,int mode,int *n){*n=1;return 0;}
static int nvs_get_u8(int n,const char *key,uint8_t *value){if(stored<0)return ESP_ERR_NVS_NOT_FOUND;*value=stored;return 0;}
static int nvs_set_u8(int n,const char *key,uint8_t value){staged=value;return 0;}
static int nvs_commit(int n){if(storage_error)return -1;stored=staged;commits++;return 0;}
static void nvs_close(int n){}
`+source+`
static void expect_mode(const char *mode){cJSON *j=device_power_report();assert(!strcmp(cJSON_GetObjectItem(j,"mode")->valuestring,mode));cJSON_Delete(j);}
int main(void){
assert(device_power_boot()==0);assert(!standby&&!paused);expect_mode("active");
busy=true;assert(device_power_apply("standby",false)!=0);assert(!standby&&!paused&&brightness==80&&commits==0);busy=false;
assert(device_power_apply("off",false)!=0);
assert(device_power_apply("standby",false)==0);assert(standby&&paused&&brightness==0&&stored==1&&!held);expect_mode("standby");
int previous=commits;assert(device_power_apply("standby",false)==0);assert(commits==previous);
held=true;assert(device_power_apply("active",true)==0);assert(held&&!standby&&!paused&&brightness==80&&stored==0);held=false;
display_error=1;assert(device_power_apply("standby",false)!=0);expect_mode("transition");assert(stored==0&&!held);display_error=0;
assert(device_power_apply("active",false)==0);
audio_error=1;assert(device_power_apply("standby",false)!=0);assert(stored==0&&!held);audio_error=0;
storage_error=1;assert(device_power_apply("standby",false)!=0);expect_mode("standby");assert(stored==0&&!held);storage_error=0;
assert(device_power_apply("standby",false)==0);assert(stored==1);
standby=paused=false;brightness=80;assert(device_power_boot()==0);expect_mode("standby");
assert(device_power_apply("active",false)==0);expect_mode("active");
puts("PASS power transitions: busy exclusion, real state, failure paths, persistent standby, default wake and no repeated flash writes");
}
`);
@@ -0,0 +1,63 @@
// Production durable command receipts with NVS and lost-response injection.
const fs=require('fs'),path=require('path');
const source=fs.readFileSync(path.join(__dirname,'../main/device_control.c'),'utf8');
const json=path.join(process.env.IDF_PATH||'E:/Codex/toolchains/esp-idf-v5.5.1','components/json/cJSON');
const receipts=source.slice(source.indexOf('static esp_err_t save_result('),source.indexOf('static esp_err_t apply_config('));
process.stdout.write(fs.readFileSync(path.join(json,'cJSON.h'),'utf8')+'\n'+fs.readFileSync(path.join(json,'cJSON.c'),'utf8').replace('#include "cJSON.h"','')+`
#include <assert.h>
#include <stdbool.h>
typedef int esp_err_t;
typedef int nvs_handle_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define ESP_ERR_NO_MEM -2
#define ESP_ERR_NVS_NOT_FOUND 1
#define NVS_READONLY 0
#define NVS_READWRITE 1
static bool namespace_exists;
static char durable[4096],staged[4096];
static int open_error,size_error,read_error,commit_error,write_error,posts,network_error;
static const char *boot_id="boot-A",*error_code="";
static const char *id="40000000-0000-4000-8000-000000000001";
static int nvs_open(const char *ns,int mode,nvs_handle_t *n){
assert(!strcmp(ns,"control"));if(open_error)return ESP_FAIL;
if(!namespace_exists&&mode==NVS_READONLY)return ESP_ERR_NVS_NOT_FOUND;
namespace_exists=true;strcpy(staged,durable);*n=1;return ESP_OK;
}
static int nvs_set_str(int n,const char *key,const char *value){assert(!strcmp(key,"result"));if(write_error)return ESP_FAIL;assert(strlen(value)<sizeof(staged));strcpy(staged,value);return ESP_OK;}
static int nvs_erase_key(int n,const char *key){assert(!strcmp(key,"result"));if(write_error)return ESP_FAIL;staged[0]=0;return ESP_OK;}
static int nvs_commit(int n){if(commit_error)return ESP_FAIL;strcpy(durable,staged);return ESP_OK;}
static int nvs_get_str(int n,const char *key,char *out,size_t *size){
assert(!strcmp(key,"result"));if(!durable[0])return ESP_ERR_NVS_NOT_FOUND;
if(!out){if(size_error)return ESP_FAIL;*size=strlen(durable)+1;return ESP_OK;}
if(read_error)return ESP_FAIL;assert(*size>=strlen(durable)+1);strcpy(out,durable);*size=strlen(durable)+1;return ESP_OK;
}
static void nvs_close(int n){}
static const char *ble_cloud_boot_id(void){return boot_id;}
static const char *platform_last_error_code(void){return error_code;}
static const char *text(const cJSON *j,const char *k){const cJSON *v=cJSON_GetObjectItemCaseSensitive(j,k);return cJSON_IsString(v)?v->valuestring:"";}
static int post(const char *path,cJSON *j,cJSON **out){
assert(!strcmp(path,"/api/v1/device/control/ack"));assert(!strcmp(text(j,"command_id"),id));
assert(!cJSON_GetObjectItemCaseSensitive(j,"_reboot_from"));posts++;return network_error?ESP_FAIL:ESP_OK;
}
`+receipts+`
static void reset(void){namespace_exists=false;durable[0]=staged[0]=0;open_error=size_error=read_error=commit_error=write_error=posts=network_error=0;boot_id="boot-A";error_code="";}
static void pending(void){assert(ack(id,"SUCCEEDED",NULL,true)==ESP_OK);assert(durable[0]&&posts==0);}
int main(int argc,char **argv){
reset();assert(flush_result());
// Unknown storage state must block command fetching, never mean no receipt.
if(argc>1&&!strcmp(argv[1],"open-error")){open_error=1;assert(!flush_result());puts("PASS NVS read failure blocks commands");return 0;}
reset();pending();assert(!flush_result()&&posts==0);
boot_id="boot-B";network_error=1;assert(!flush_result()&&durable[0]&&posts==1);
// A lost ACK response and a failed local erase both retain the same receipt.
network_error=0;commit_error=1;assert(!flush_result()&&durable[0]&&posts==2);
commit_error=0;assert(flush_result()&&!durable[0]&&posts==3);assert(flush_result()&&posts==3);
for(int fault=0;fault<3;fault++){reset();pending();boot_id="boot-B";if(fault==0)open_error=1;if(fault==1)size_error=1;if(fault==2)read_error=1;assert(!flush_result()&&posts==0&&durable[0]);}
reset();namespace_exists=true;strcpy(durable,"not JSON");assert(!flush_result()&&posts==0);
reset();write_error=1;assert(ack(id,"SUCCEEDED",NULL,true)!=ESP_OK&&posts==0&&!durable[0]);
reset();commit_error=1;assert(ack(id,"SUCCEEDED",NULL,true)!=ESP_OK&&posts==0&&!durable[0]);
reset();pending();boot_id="boot-B";network_error=1;error_code="DEVICE_COMMAND_STATE_CONFLICT";assert(flush_result()&&!durable[0]);
reset();network_error=1;assert(ack(id,"SUCCEEDED",NULL,false)==ESP_OK&&durable[0]);network_error=0;assert(flush_result()&&!durable[0]);
puts("PASS production receipts: boot guard, lost responses, durable retries and NVS failures");return 0;
}
`);
@@ -0,0 +1,75 @@
// Execute the actual reset implementation with cloud/NVS fault injection.
const fs=require('fs'),path=require('path');
const root=path.resolve(__dirname,'..'),json=path.join(process.env.IDF_PATH||'E:/Codex/toolchains/esp-idf-v5.5.1','components/json/cJSON');
const source=fs.readFileSync(path.join(root,'main/device_reset.c'),'utf8').replace(/^#include[^\n]*\n/gm,'');
process.stdout.write(fs.readFileSync(path.join(json,'cJSON.h'),'utf8')+'\n'+fs.readFileSync(path.join(json,'cJSON.c'),'utf8').replace('#include "cJSON.h"','')+`
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
typedef int esp_err_t;
typedef int nvs_handle_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define ESP_ERR_NVS_NOT_FOUND 1
#define ESP_ERR_INVALID_STATE 2
#define ESP_ERR_INVALID_ARG 3
#define ESP_ERR_INVALID_RESPONSE 4
#define ESP_ERR_NO_MEM 5
#define ESP_ERR_INVALID_SIZE 6
#define NVS_READONLY 0
#define NVS_READWRITE 1
static const char *spaces[]={"factory_reset","voice","platform","control","ble_setup","wifi"};
static unsigned char durable[1024],staged[1024];static size_t durable_len,staged_len;
static int erased,posts,deny_state,deny_reset,bad_response,fail_phase,fail_clear,fail_save_once;
static char first_body[512];
static void mbedtls_platform_zeroize(void *p,size_t n){memset(p,0,n);}
static bool ble_cloud_configured(void){return true;}
static int ble_cloud_sync_time(void){return 0;}
static const char *device_ota_version(void){return "1.7.0-controls.test";}
static int nvs_open(const char *space,int mode,int *n){for(int i=0;i<6;i++)if(!strcmp(space,spaces[i])){*n=i;return 0;}assert(!"forbidden namespace");return -1;}
static void nvs_close(int n){}
static int nvs_set_blob(int n,const char *key,const void *v,size_t size){assert(n==0&&!strcmp(key,"record")&&size<=sizeof(staged));memcpy(staged,v,size);staged_len=size;return 0;}
static int nvs_get_blob(int n,const char *key,void *v,size_t *size){assert(n==0&&!strcmp(key,"record"));if(!durable_len)return ESP_ERR_NVS_NOT_FOUND;assert(*size>=durable_len);memcpy(v,durable,durable_len);*size=durable_len;return 0;}
static int nvs_commit(int n){
if(n==0){uint32_t phase=0;memcpy(&phase,staged+4,4);if(fail_save_once&&phase==(unsigned)fail_phase){fail_save_once=0;return -1;}memcpy(durable,staged,staged_len);durable_len=staged_len;}
return 0;
}
static int nvs_erase_all(int n){assert(n==1||n==4||n==5);if(fail_clear==n)return -1;erased|=1<<n;return 0;}
static int nvs_erase_key(int n,const char *key){assert(n==2||n==3);assert(strcmp(key,"device_id")&&strcmp(key,"device_secret")&&strcmp(key,"ota")&&strcmp(key,"installed"));if(fail_clear==n)return -1;erased|=1<<n;return 0;}
static int platform_request(const char *method,const char *path,const char *body,const char *id,char **raw){
assert(!strcmp(method,"POST"));*raw=NULL;
if(!strcmp(path,"/api/v1/device/factory-reset/state")){if(deny_state)return ESP_ERR_INVALID_RESPONSE;*raw=strdup("{\\"ok\\":true,\\"data\\":{\\"reset_counter\\":0}}");return 0;}
assert(!strcmp(path,"/api/v1/device/factory-reset"));posts++;
if(first_body[0])assert(!strcmp(first_body,body));else strcpy(first_body,body);
if(deny_reset)return ESP_FAIL;
cJSON *request=cJSON_Parse(body);assert(request);
const char *rid=cJSON_GetObjectItem(request,"reset_id")->valuestring;
char response[1024];snprintf(response,sizeof(response),"{\\"ok\\":true,\\"data\\":{\\"reset_id\\":\\"%s\\",\\"reset_counter\\":%d,\\"activation_state\\":\\"CLAIMABLE\\",\\"license_action\\":\\"RETAINED\\",\\"local_actions\\":[\\"clear_volc_credential\\",\\"clear_user_context\\",\\"clear_session_state\\",\\"clear_network_credentials\\",\\"reboot_to_claimable\\"]}}",rid,bad_response?2:1);
*raw=strdup(response);cJSON_Delete(request);return 0;
}
`+source+`
static const char *rid="40000000-0000-4000-8000-000000000001";
static void fresh(void){memset(durable,0,sizeof(durable));memset(staged,0,sizeof(staged));durable_len=staged_len=0;erased=posts=deny_state=deny_reset=bad_response=fail_phase=fail_clear=fail_save_once=0;first_body[0]=0;}
static int phase(void){reset_record_t r;assert(load(&r)==0);return r.phase;}
int main(void){
fresh();assert(device_reset_boot_cleanup()==0&&erased==0);
deny_state=1;assert(device_reset_from_setup(rid)!=0&&erased==0&&posts==0&&phase()==0);
fresh();deny_reset=1;assert(device_reset_from_setup(rid)!=0&&erased==0&&phase()==1);assert(device_reset_boot_cleanup()==0&&erased==0);
deny_reset=0;assert(device_reset_from_setup(rid)==0&&phase()==3&&erased==62&&posts==2);
assert(device_reset_from_setup(rid)==0&&posts==2); // Lost BLE acknowledgement.
fresh();bad_response=1;assert(device_reset_from_setup(rid)!=0&&erased==0&&phase()==1);
fresh();fail_phase=1;fail_save_once=1;assert(device_reset_from_setup(rid)!=0&&erased==0&&posts==0);
fresh();fail_phase=2;fail_save_once=1;assert(device_reset_from_setup(rid)!=0&&erased==0&&phase()==1);
assert(device_reset_from_setup(rid)==0&&phase()==3&&posts==2);
fresh();fail_clear=5;assert(device_reset_from_setup(rid)!=0&&phase()==2&&erased==30); // Cut before Wi-Fi cleanup.
fail_clear=0;assert(device_reset_boot_cleanup()==0&&phase()==3&&erased==62&&posts==1);
fresh();fail_phase=3;fail_save_once=1;assert(device_reset_from_setup(rid)!=0&&phase()==2&&erased==62);
assert(device_reset_boot_cleanup()==0&&phase()==3&&posts==1);
fresh();durable_len=5;assert(device_reset_boot_cleanup()!=0&&erased==0);
puts("PASS production reset: authorization, exact replay, durable approval, interrupted cleanup, preserved identity/OTA");
}
`);
@@ -0,0 +1,48 @@
// Exercise the production reboot command with persisted-receipt and MQTT spies.
const fs=require('fs'),path=require('path');
const source=fs.readFileSync(path.join(__dirname,'../main/device_control.c'),'utf8');
const json=path.join(process.env.IDF_PATH||'E:/Codex/toolchains/esp-idf-v5.5.1','components/json/cJSON');
const command=source.slice(source.indexOf('static void command('),source.indexOf('void device_control_prepare_restart('));
const shutdown=source.slice(source.indexOf('void device_control_prepare_restart('),source.indexOf('void device_control_heartbeat('));
process.stdout.write(fs.readFileSync(path.join(json,'cJSON.h'),'utf8')+'\n'+fs.readFileSync(path.join(json,'cJSON.c'),'utf8').replace('#include "cJSON.h"','')+`
#include <assert.h>
#include <stdbool.h>
#include <stdatomic.h>
#include <setjmp.h>
#define ESP_OK 0
#define ESP_LOGW(...) ((void)0)
#define pdMS_TO_TICKS(x) (x)
typedef int esp_err_t;
static void *mqtt;
static atomic_bool connected,stopping;
static int busy,ack_failure,persist_failure,stop_failure,acknowledged,persisted,stopped,restarted,left;
static jmp_buf restart_jump;
static bool call_client_enter_maintenance(void){return !busy;}
static void call_client_leave_maintenance(void){left++;}
static const char *text(const cJSON *j,const char *k){const cJSON *v=cJSON_GetObjectItemCaseSensitive(j,k);return cJSON_IsString(v)?v->valuestring:"";}
static int ack(const char *id,const char *status,cJSON *result,bool reboot){
if(!strcmp(status,"ACKNOWLEDGED")){acknowledged++;return ack_failure;}
if(reboot){assert(!strcmp(status,"SUCCEEDED"));if(persist_failure)return -1;persisted++;return 0;}
assert(strcmp(status,"SUCCEEDED"));return 0;
}
static int esp_mqtt_client_stop(void *client){assert(persisted);assert(!atomic_load(&connected)&&atomic_load(&stopping));stopped++;return stop_failure;}
static void vTaskDelay(int ticks){assert(ticks==100);}
static void esp_restart(void){assert(persisted&&atomic_load(&stopping));if(mqtt)assert(stopped==1);restarted++;longjmp(restart_jump,1);}
static cJSON *diagnostics(void){return NULL;}
static int apply_config(const cJSON *j,bool held){return -1;}
`+shutdown+command+`
static void run(void){
cJSON *j=cJSON_Parse("{\\"id\\":\\"40000000-0000-4000-8000-000000000001\\",\\"type\\":\\"reboot\\"}");
if(!setjmp(restart_jump))command(j,NULL);cJSON_Delete(j);
}
static void reset(void){mqtt=(void*)1;connected=true;stopping=false;busy=ack_failure=persist_failure=stop_failure=acknowledged=persisted=stopped=restarted=left=0;}
int main(void){
reset();run();assert(acknowledged==1&&persisted==1&&stopped==1&&restarted==1&&!left);
reset();busy=1;run();assert(!acknowledged&&!stopped&&!restarted);
reset();ack_failure=-1;run();assert(!persisted&&!stopped&&!restarted&&left==1);
reset();persist_failure=-1;run();assert(!persisted&&!stopped&&!restarted&&left==1);
reset();mqtt=NULL;run();assert(restarted==1&&!stopped);
reset();stop_failure=-1;run();assert(restarted==1&&stopped==1);
puts("PASS production reboot: persist before disconnect, busy/ACK/storage guards, MQTT stop failure");return 0;
}
`);
@@ -0,0 +1,169 @@
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_FAIL 1
#define ESP_ERR_NO_MEM 2
#define BIT0 1
#define BIT1 2
#define BIT2 4
#define pdFALSE 0
#define pdMS_TO_TICKS(x) (x)
#define MALLOC_CAP_INTERNAL 1
#define ESP_EVENT_ANY_ID (-1)
#define WIFI_EVENT 1
#define IP_EVENT 2
#define WIFI_EVENT_STA_START 1
#define WIFI_EVENT_STA_DISCONNECTED 2
#define WIFI_EVENT_STA_STOP 3
#define IP_EVENT_STA_GOT_IP 1
#define WIFI_MODE_STA 1
#define WIFI_STORAGE_RAM 1
#define WIFI_IF_STA 0
#define WIFI_ALL_CHANNEL_SCAN 1
#define WIFI_CONNECT_AP_BY_SIGNAL 1
#define WIFI_AUTH_OPEN 0
#define WIFI_PS_NONE 0
#define NVS_READONLY 0
#define NVS_READWRITE 1
#define ESP_ERROR_CHECK(x) assert((x)==ESP_OK)
#define bzero(p,n) memset(p,0,n)
typedef unsigned EventBits_t;
typedef EventBits_t *EventGroupHandle_t;
typedef int esp_event_base_t;
typedef int esp_event_handler_instance_t;
typedef int esp_netif_t;
typedef int nvs_handle_t;
typedef struct { EventGroupHandle_t event_group; char ssid[33],password[64]; int reconnect_count; char ip_address[16]; } network_t;
typedef struct { struct { int ip; } ip_info; } ip_event_got_ip_t;
typedef struct { int8_t rssi; uint8_t primary,bssid[6]; } wifi_ap_record_t;
typedef struct { int static_rx_buf_num,static_tx_buf_num,dynamic_rx_buf_num,cache_tx_buf_num,rx_ba_win; } wifi_init_config_t;
#define WIFI_INIT_CONFIG_DEFAULT() ((wifi_init_config_t){0})
typedef struct { struct { char ssid[32],password[64]; int scan_method,sort_method; struct {int authmode;} threshold; bool bssid_set; uint8_t bssid[6],channel; } sta; } wifi_config_t;
typedef struct { uint8_t *ssid; } wifi_scan_config_t;
typedef struct { bool active; void (*callback)(void *); void *arg; } mock_timer_t;
typedef mock_timer_t *esp_timer_handle_t;
typedef struct { void (*callback)(void *); void *arg; const char *name; } esp_timer_create_args_t;
static EventBits_t event_bits;
static mock_timer_t timer;
static wifi_config_t current_config;
static bool initial_connected,ble_configured,identity_configured,timer_create_failure,commit_failure;
static char saved_ssid[33],saved_password[64];
static uint8_t saved_setup_requested;
static int connect_error,connect_calls,ap_calls,stop_calls,deinit_calls,netif_destroyed,timer_starts;
void handler_event_wifi_st(void *,esp_event_base_t,int32_t,void *);
void handler_event_ip_st(void *,esp_event_base_t,int32_t,void *);
static void mock_log(const char *tag,const char *format,...) {(void)tag;(void)format;}
#define ESP_LOGI(...) mock_log(__VA_ARGS__)
#define ESP_LOGW(...) mock_log(__VA_ARGS__)
#define ESP_LOGE(...) mock_log(__VA_ARGS__)
static const char *esp_err_to_name(int err) {(void)err;return "mock error";}
static unsigned heap_caps_get_free_size(int cap) {(void)cap;return 64000;}
static unsigned heap_caps_get_largest_free_block(int cap) {(void)cap;return 32000;}
static EventGroupHandle_t xEventGroupCreate(void) {event_bits=0;return &event_bits;}
static EventBits_t xEventGroupGetBits(EventGroupHandle_t group) {return *group;}
static EventBits_t xEventGroupSetBits(EventGroupHandle_t group,EventBits_t bits) {return *group|=bits;}
static EventBits_t xEventGroupClearBits(EventGroupHandle_t group,EventBits_t bits) {EventBits_t old=*group;*group&=~bits;return old;}
static EventBits_t xEventGroupWaitBits(EventGroupHandle_t group,EventBits_t bits,int clear,int all,int ticks) {
(void)clear;(void)all;(void)ticks;
if(bits==(BIT0|BIT1)) {
if(initial_connected) {ip_event_got_ip_t e={{1}};handler_event_ip_st(NULL,IP_EVENT,IP_EVENT_STA_GOT_IP,&e);}
else for(int i=0;i<6;i++) handler_event_wifi_st(NULL,WIFI_EVENT,WIFI_EVENT_STA_DISCONNECTED,NULL);
}
return *group;
}
static esp_err_t esp_timer_create(const esp_timer_create_args_t *args,esp_timer_handle_t *out) {
if(timer_create_failure)return ESP_ERR_NO_MEM;
timer.callback=args->callback;timer.arg=args->arg;*out=&timer;return ESP_OK;
}
static esp_err_t esp_timer_start_once(esp_timer_handle_t handle,uint64_t micros) {assert(handle&&!handle->active&&micros==10000000);handle->active=true;timer_starts++;return ESP_OK;}
static esp_err_t esp_timer_stop(esp_timer_handle_t handle) {handle->active=false;return ESP_OK;}
static bool esp_timer_is_active(esp_timer_handle_t handle) {return handle->active;}
static esp_err_t nvs_open(const char *name,int mode,nvs_handle_t *handle) {assert(!strcmp(name,"wifi"));(void)mode;*handle=1;return ESP_OK;}
static esp_err_t nvs_get_u8(nvs_handle_t handle,const char *key,uint8_t *value) {(void)handle;assert(!strcmp(key,"setup_requested"));*value=saved_setup_requested;return ESP_OK;}
static esp_err_t nvs_get_str(nvs_handle_t handle,const char *key,char *value,size_t *len) {(void)handle;const char *s=!strcmp(key,"ssid")?saved_ssid:saved_password;assert(strlen(s)<*len);strcpy(value,s);return ESP_OK;}
static esp_err_t nvs_set_u8(nvs_handle_t handle,const char *key,uint8_t value) {(void)handle;assert(!strcmp(key,"setup_requested"));saved_setup_requested=value;return ESP_OK;}
static esp_err_t nvs_commit(nvs_handle_t handle) {(void)handle;return commit_failure?ESP_FAIL:ESP_OK;}
static void nvs_close(nvs_handle_t handle) {(void)handle;}
static bool platform_identity_is_configured(void) {return identity_configured;}
static bool ble_cloud_configured(void) {return ble_configured;}
static void configure_ap(void) {ap_calls++;}
static esp_err_t esp_netif_init(void) {return ESP_OK;}
static esp_err_t esp_event_handler_instance_register(esp_event_base_t base,int id,void (*cb)(void *,esp_event_base_t,int32_t,void *),void *arg,esp_event_handler_instance_t *instance) {(void)base;(void)id;(void)cb;(void)arg;*instance=1;return ESP_OK;}
static esp_err_t esp_event_handler_instance_unregister(esp_event_base_t base,int id,esp_event_handler_instance_t instance) {(void)base;(void)id;(void)instance;return ESP_OK;}
static esp_netif_t *esp_netif_create_default_wifi_sta(void) {static int station;return &station;}
static void esp_netif_destroy_default_wifi(esp_netif_t *station) {assert(station);netif_destroyed++;}
static esp_err_t esp_wifi_init(wifi_init_config_t *cfg) {assert(cfg->static_rx_buf_num==6&&cfg->static_tx_buf_num==6);return ESP_OK;}
static esp_err_t esp_wifi_set_mode(int mode) {(void)mode;return ESP_OK;}
static esp_err_t esp_wifi_set_storage(int mode) {(void)mode;return ESP_OK;}
static esp_err_t esp_wifi_set_ps(int mode) {(void)mode;return ESP_OK;}
static esp_err_t esp_wifi_set_config(int iface,const wifi_config_t *cfg) {(void)iface;current_config=*cfg;return ESP_OK;}
static esp_err_t esp_wifi_get_config(int iface,wifi_config_t *cfg) {(void)iface;*cfg=current_config;return ESP_OK;}
static esp_err_t esp_wifi_start(void) {handler_event_wifi_st(NULL,WIFI_EVENT,WIFI_EVENT_STA_START,NULL);return ESP_OK;}
static esp_err_t esp_wifi_stop(void) {stop_calls++;return ESP_OK;}
static esp_err_t esp_wifi_deinit(void) {deinit_calls++;return ESP_OK;}
static esp_err_t esp_wifi_connect(void) {connect_calls++;return connect_error;}
static esp_err_t esp_wifi_sta_get_ap_info(wifi_ap_record_t *record) {memset(record,0,sizeof(*record));record->rssi=-42;record->primary=6;return ESP_OK;}
static esp_err_t esp_wifi_scan_start(wifi_scan_config_t *scan,bool block) {(void)scan;(void)block;return ESP_FAIL;}
static esp_err_t esp_wifi_scan_get_ap_records(uint16_t *count,wifi_ap_record_t *records) {(void)records;*count=0;return ESP_OK;}
static void esp_ip4addr_ntoa(const int *address,char *out,size_t len) {(void)address;snprintf(out,len,"192.168.1.2");}
/* PRODUCTION_SOURCE */
static void reset(void) {
memset(&network,0,sizeof(network));memset(&timer,0,sizeof(timer));memset(&current_config,0,sizeof(current_config));
network_error=ESP_OK;reconnect_timer=NULL;reconnect_enabled=false;
event_bits=0;initial_connected=true;ble_configured=true;identity_configured=true;
timer_create_failure=commit_failure=false;saved_setup_requested=0;
strcpy(saved_ssid,"saved-network");strcpy(saved_password,"saved-password");
connect_error=connect_calls=ap_calls=stop_calls=deinit_calls=netif_destroyed=timer_starts=0;
}
static void disconnected(void) {handler_event_wifi_st(NULL,WIFI_EVENT,WIFI_EVENT_STA_DISCONNECTED,NULL);}
static void fire_timer(void) {assert(timer.active);timer.active=false;timer.callback(timer.arg);}
int main(void) {
reset();assert(configure_network());assert(is_connected()&&!ap_calls&&!timer.active);
current_config.sta.bssid_set=true;current_config.sta.channel=6;
int base=connect_calls;
for(int i=0;i<5;i++)disconnected();
assert(connect_calls==base+5&&!is_connected()&&!timer.active);
assert(!current_config.sta.bssid_set&&current_config.sta.channel==0);
disconnected();assert(timer.active&&(event_bits&BIT1)&&!network.ip_address[0]);
for(int i=0;i<20;i++) {fire_timer();disconnected();assert(timer.active);}
assert(connect_calls==base+25); // Recover even after more than 5 attempts.
ip_event_got_ip_t ip={{1}};handler_event_ip_st(NULL,IP_EVENT,IP_EVENT_STA_GOT_IP,&ip);
assert(is_connected()&&!(event_bits&BIT1)&&!timer.active&&network.reconnect_count==0);
base=connect_calls;timer.callback(NULL);assert(connect_calls==base); // Late timer after IP cannot reconnect.
for(int i=0;i<6;i++)disconnected();assert(timer.active);
handler_event_wifi_st(NULL,WIFI_EVENT,WIFI_EVENT_STA_STOP,NULL);
base=connect_calls;disconnected();timer.callback(NULL);
assert(!timer.active&&!reconnect_enabled&&!is_connected()&&connect_calls==base);
reset();assert(configure_network());connect_error=ESP_FAIL;disconnected();assert(timer.active);
fire_timer();assert(timer.active);connect_error=ESP_OK;fire_timer();assert(!timer.active);
reset();initial_connected=false;
assert(!configure_network()&&network_last_error()==ESP_OK);
assert(saved_setup_requested==1&&!ap_calls&&stop_calls==1&&deinit_calls==1&&netif_destroyed==1);
assert(!timer.active&&!reconnect_enabled&&!strcmp(saved_ssid,"saved-network")&&!strcmp(saved_password,"saved-password"));
base=connect_calls;timer.callback(NULL);assert(connect_calls==base); // No driver call after teardown.
reset();initial_connected=false;ble_configured=false;
assert(!configure_network()&&ap_calls==1&&saved_setup_requested==0);
reset();initial_connected=false;commit_failure=true;
assert(!configure_network()&&network_last_error()==ESP_FAIL&&!ap_calls);
reset();saved_ssid[0]=0;
assert(!configure_network()&&saved_setup_requested==1&&!ap_calls&&!stop_calls);
reset();timer_create_failure=true;
assert(!configure_network()&&network_last_error()==ESP_ERR_NO_MEM&&!connect_calls&&!ap_calls);
puts("PASS production network: prolonged disconnect, BSSID fallback, delayed retry, IP recovery, stop guards, BLE boot fallback, credentials preserved, commit failure, legacy AP");
return 0;
}
@@ -0,0 +1,5 @@
// Compile the production network driver against SDK spies; no firmware logic is copied.
const fs = require('node:fs'), path = require('node:path');
const source = fs.readFileSync(path.join(__dirname, '../main/network.c'), 'utf8');
const harness = fs.readFileSync(path.join(__dirname, 'network-reconnect-host.c'), 'utf8');
process.stdout.write(harness.replace('/* PRODUCTION_SOURCE */', source.replace(/^#include[^\n]*$/gm, '')));
@@ -0,0 +1,19 @@
// Contract G02 requires source-level protection even with verbose logging enabled.
const {test}=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const path=require('node:path');
const root=path.resolve(__dirname,'../components/volc_conv_ai/src');
function sources(dir){return fs.readdirSync(dir,{withFileTypes:true}).flatMap(x=>x.isDirectory()?sources(path.join(dir,x.name)):x.name.endsWith('.c')?[path.join(dir,x.name)]:[]);}
test('vendor SDK does not log credential, transcript, config, or HTTP payload buffers',()=>{
const unsafe=[];
for(const file of sources(root)){
const code=fs.readFileSync(file,'utf8').replace(/^\s*\/\/[^\n]*/gm,'');
for(const match of code.matchAll(/\bLOG[VDEIWF]\s*\(([^;]+)\);/g)){
// Match arguments separately: diagnostic words in a constant message are safe.
const args=match[1].replace(/"(?:\\.|[^"\\])*"/g,'');
if(/(?<!\.)\b(?:secret|params|param_str|post_data|json_str|json|buffer|token|response|data|extencontent|error_reason|message|decoded_payload|output)\b|->p_token\b/.test(args)&&/%(?:\.\*|\d*)s/.test(match[1]))unsafe.push(path.relative(root,file)+': '+match[0]);
}
}
assert.deepEqual(unsafe,[],'private buffers must not be passed to SDK log macros');
});
@@ -0,0 +1,44 @@
// Exercise the actual downlink decoder loop with a decoder that consumes one
// encoded frame per invocation, including coalesced frames and invalid progress.
const fs=require('node:fs'), path=require('node:path');
const src=fs.readFileSync(path.join(__dirname,'../components/volc_conv_ai/src/transports/high_quality/src/volc_rtc.c'),'utf8');
const fn=src.slice(src.indexOf('static int __rtc_decode_opus('),src.indexOf('\nstatic int __rtc_rx_decoder_reset('));
if(!fn.startsWith('static int __rtc_decode_opus(')||!fn.trimEnd().endsWith('}'))throw new Error('decoder function extraction failed');
process.stdout.write(`
#include <assert.h>
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#define ESP_AUDIO_ERR_OK 0
#define VOLC_AUDIO_DATA_TYPE_PCM 2
#define LOGE(...) ((void)0)
typedef struct {void *opus_decoder;uint8_t decoded_pcm[3840];unsigned rx_errors;} rtc_impl_t;
typedef struct {struct {struct {int data_type;} audio;} info;} volc_data_info_t;
typedef struct {uint8_t *buffer;uint32_t len,consumed;} esp_audio_dec_in_raw_t;
typedef struct {uint8_t *buffer;uint32_t len,decoded_size;} esp_audio_dec_out_frame_t;
typedef struct {int unused;} esp_audio_dec_info_t;
static int mode,calls,sends;static uint8_t output[16384];static unsigned output_size;
static int __rtc_rx_current(rtc_impl_t *rtc,unsigned generation){(void)rtc;return generation==1;}
static int esp_opus_dec_decode(void *decoder,esp_audio_dec_in_raw_t *in,esp_audio_dec_out_frame_t *out,esp_audio_dec_info_t *info){
calls++;
if(mode==1)return -1;
if(mode==2){in->consumed=0;return 0;}
if(mode==3){in->consumed=in->len+1;return 0;}
if(mode==4){in->consumed=1;out->decoded_size=out->len+1;return 0;}
in->consumed=1;out->decoded_size=640;memset(out->buffer,in->buffer[0],640);return 0;
}
static void __send_data_2_user(rtc_impl_t *rtc,void *data,unsigned size,volc_data_info_t *info){
assert(info->info.audio.data_type==VOLC_AUDIO_DATA_TYPE_PCM);assert(output_size+size<=sizeof(output));memcpy(output+output_size,data,size);output_size+=size;sends++;
}
${fn}
int main(void){
for(unsigned n=1;n<=12;n++){rtc_impl_t rtc={0};volc_data_info_t info={0};uint8_t packet[12];for(unsigned i=0;i<n;i++)packet[i]=i;
mode=calls=sends=output_size=0;assert(__rtc_decode_opus(&rtc,packet,n,&info,1)==640*(int)n);assert(calls==(int)n && sends==(int)n && rtc.rx_errors==0);
for(unsigned i=0;i<n;i++)for(unsigned j=0;j<640;j++)assert(output[i*640+j]==i);
}
for(mode=1;mode<=4;mode++){rtc_impl_t rtc={0};volc_data_info_t info={0};uint8_t packet[3]={1,2,3};calls=sends=output_size=0;
assert(__rtc_decode_opus(&rtc,packet,3,&info,1)==0);assert(calls==1 && sends==0 && rtc.rx_errors==1);}
puts("RTC downlink decoder frame consumption and bounds: PASS");
}
`);
@@ -0,0 +1,42 @@
// Compile actual PCM packetizer and runtime JSON with ASan/UBSan on a C host.
const fs=require('node:fs'),path=require('node:path');
const root=path.resolve(__dirname,'..');
const read=p=>fs.readFileSync(path.join(root,p),'utf8');
const src=read('components/volc_conv_ai/src/transports/high_quality/src/volc_rtc.c');
const fn=src.slice(src.indexOf('static int __rtc_send_pcm('),src.indexOf('\nint volc_rtc_send('));
const conv=read('components/application/service/conv_service/src/volc_conv.c');
const config=conv.slice(conv.indexOf('#define CONV_AI_CONFIG_FORMAT'),conv.indexOf('#define CONV_SERVICE_TIMEOUT'));
const jsonDir=path.join(process.env.IDF_PATH||'E:/Codex/toolchains/esp-idf-v5.5.1','components/json/cJSON');
process.stdout.write(fs.readFileSync(path.join(jsonDir,'cJSON.h'),'utf8')+'\n'+fs.readFileSync(path.join(jsonDir,'cJSON.c'),'utf8').replace('#include "cJSON.h"','')+`
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#define ESP_PLATFORM 1
#define ESP_AUDIO_ERR_OK 0
#define AUDIO_DATA_TYPE_PCM 5
#define AUDIO_DATA_TYPE_OPUS 1
typedef struct {int data_type;} audio_frame_info_t;
typedef struct {uint8_t *buffer;uint32_t len;} esp_audio_enc_in_frame_t;
typedef struct {uint8_t *buffer;uint32_t len,encoded_bytes;} esp_audio_enc_out_frame_t;
typedef struct {void *rtc; const char *p_channel_name; uint8_t pcm_frame[640]; size_t pcm_used;void *opus_encoder;uint8_t *opus_packet;int opus_packet_size;} rtc_impl_t;
static uint8_t received[64000]; static size_t used; static int fail;
static int esp_opus_enc_process(void *h,esp_audio_enc_in_frame_t *in,esp_audio_enc_out_frame_t *out){assert(in->len==640 && out->len==640);memcpy(out->buffer,in->buffer,640);out->encoded_bytes=640;return 0;}
static int byte_rtc_send_audio_data(void *r,const char *ch,const void *p,size_t n,audio_frame_info_t *info) {
assert(n==640 && (info->data_type==AUDIO_DATA_TYPE_PCM || info->data_type==AUDIO_DATA_TYPE_OPUS)); if(fail)return -7;
assert(used+n<=sizeof(received)); memcpy(received+used,p,n); used+=n; return 0;
}
`+fn+'\n'+config+`
int main(void) {
uint8_t input[64000]; for(size_t i=0;i<sizeof(input);i++)input[i]=(uint8_t)i;
for(int encoded=0;encoded<2;encoded++)for(size_t chunk=1;chunk<=4097;chunk+=137) {
uint8_t packet[640];rtc_impl_t rtc={.opus_encoder=encoded?(void*)1:NULL,.opus_packet=packet,.opus_packet_size=sizeof(packet)}; used=0;
for(size_t pos=0;pos<sizeof(input);) {size_t n=sizeof(input)-pos;if(n>chunk)n=chunk;assert(__rtc_send_pcm(&rtc,input+pos,n)==0);pos+=n;}
assert(rtc.pcm_used==0 && used==sizeof(input));assert(!memcmp(input,received,sizeof(input)));
}
rtc_impl_t rtc={0};fail=1;assert(__rtc_send_pcm(&rtc,input,1920)==-7);assert(rtc.pcm_used==0);fail=0;
char buf[1024];int n=snprintf(buf,sizeof(buf),CONV_AI_CONFIG_FORMAT,"instance","product","secret","device");assert(n>0 && (size_t)n<sizeof(buf));
cJSON *cfg=cJSON_Parse(buf);assert(cfg);cJSON *params=cJSON_GetObjectItem(cJSON_GetObjectItem(cfg,"rtc"),"params");assert(cJSON_IsArray(params));
int opus=0;cJSON *p; cJSON_ArrayForEach(p,params) {cJSON *j=cJSON_Parse(cJSON_GetStringValue(p));assert(j);cJSON *a=cJSON_GetObjectItem(cJSON_GetObjectItem(cJSON_GetObjectItem(j,"audio"),"codec"),"opus");if(a){opus++;assert(cJSON_GetNumberValue(cJSON_GetObjectItem(a,"s_sample_rate"))==48000);assert(cJSON_GetNumberValue(cJSON_GetObjectItem(a,"s_samples_per_frame"))==960);assert(cJSON_GetNumberValue(cJSON_GetObjectItem(a,"r_sample_rate"))==48000);assert(cJSON_GetNumberValue(cJSON_GetObjectItem(a,"r_samples_per_frame"))==960);}cJSON_Delete(j);}
assert(opus==1);cJSON_Delete(cfg);puts("RTC PCM packetizer and runtime JSON: PASS");return 0;
}
`);
@@ -0,0 +1,96 @@
// Exercise the production touch debounce and serialized conversation manager.
const fs = require('node:fs'), path = require('node:path');
const root = path.resolve(__dirname, '..');
const touch = fs.readFileSync(path.join(root, 'main/conv_ai_embedded_kit.c'), 'utf8');
const manager = fs.readFileSync(path.join(root, 'components/application/service/conv_service/src/volc_conv_service.c'), 'utf8');
function extract(text, first, next) {
const a = text.indexOf(first), b = text.indexOf(next, a);
if (a < 0 || b < 0) throw new Error('Missing production section: ' + first);
return text.slice(a, b);
}
process.stdout.write(String.raw`
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
typedef uint32_t TickType_t;
typedef int aios_ret_t;
#define ESP_PLATFORM 1
typedef void *TaskHandle_t;
typedef struct {int id;} aios_event_t;
typedef struct {void *conv_thread_id;} volc_conv_service_manager_t;
typedef struct {void *display_handle;} volc_hal_context_t;
typedef void *volc_hal_display_t;
typedef struct {char name[32];int stack_size,priority,stack_in_ext;} volc_osal_thread_param_t;
enum {VOLC_SERVICE_AI_CONVERSATION_START=100,VOLC_SERVICE_AI_CONVERSATION_INTERRUPT,
VOLC_SERVICE_AI_CONVERSATION_QUIT,VOLC_SERVICE_AI_CONVERSATION_OVER,VOLC_SERVICE_AI_CONVERSATION_TOGGLE};
enum {AIOS_Ret_NotHandled,AIOS_Ret_Handled};
#define VOLC_DISPLAY_OBJ_STATUS 0
#define VOLC_DISPLAY_TEXT 0
#define pdMS_TO_TICKS(x) (x)
#define ESP_LOGI(...) ((void)0)
static volc_hal_context_t hal;
static bool create_fail,publish_fail,hal_missing;
static bool standby,wake_accept=true;
static unsigned wakes;
static bool call_client_standby(void){return standby;}
static bool device_control_request_screen_wake(void){if(!wake_accept)return false;wakes++;return true;}
static unsigned starts,stops,prepares,resets,events,reaps;
static int task_data;
static void *handle=&task_data;
static volc_conv_service_manager_t state;
static volc_hal_context_t *volc_get_global_hal_context(void){return hal_missing?NULL:&hal;}
static void volc_hal_display_set_content(void *h,int o,int t,const char *c){(void)h;(void)o;(void)t;(void)c;}
static void conv_ai_service_prepare_task(void){prepares++;}
static void conv_ai_service_task(void *p){(void)p;}
static void conv_ai_service_task_stop(void){stops++;}
static void conv_ai_service_diagnostic_reset(void){resets++;}
static void volc_osal_thread_destroy(void *h){assert(h==&handle);}
static void vTaskDeleteWithCaps(TaskHandle_t h){assert(h==&task_data);reaps++;}
static int volc_osal_thread_create(void **h,volc_osal_thread_param_t *p,void (*f)(void*),void *arg){
(void)p;(void)f;(void)arg;starts++;if(create_fail)return -1;*h=&handle;return 0;
}
` + extract(manager, 'static aios_ret_t __state_conversation(', '\nstatic aios_ret_t __state_init(') + String.raw`
static int aios_event_pub(int id,void *a,void *b){
assert(id==VOLC_SERVICE_AI_CONVERSATION_TOGGLE&&!a&&!b);events++;
if(publish_fail)return -1;
aios_event_t e={id};assert(__state_conversation(&state,&e)==AIOS_Ret_Handled);return 0;
}
` + extract(touch, '/* SCREEN_CALL_TAP_BEGIN:', '/* SCREEN_CALL_TAP_END */') + String.raw`
int main(void){
screen_call_tap(0);assert(starts==1&&prepares==1&&state.conv_thread_id);
screen_call_tap(100);screen_call_tap(299);assert(events==1&&!stops);
// A second deliberate tap stops even within the former five-second guard.
screen_call_tap(300);assert(stops==1&&starts==1&&state.conv_thread_id);
// Connecting/stopping retain admission; rapid repeated taps cannot restart.
screen_call_tap(600);screen_call_tap(900);assert(stops==3&&starts==1);
aios_event_t over={VOLC_SERVICE_AI_CONVERSATION_OVER};
__state_conversation(&state,&over);assert(!state.conv_thread_id);
screen_call_tap(1200);assert(starts==2&&state.conv_thread_id);
aios_event_t quit={VOLC_SERVICE_AI_CONVERSATION_QUIT};
__state_conversation(&state,&quit);assert(stops==4&&state.conv_thread_id);
__state_conversation(&state,&over);
create_fail=true;screen_call_tap(1500);assert(starts==3&&resets==1&&!state.conv_thread_id);
create_fail=false;screen_call_tap(1800);assert(starts==4&&state.conv_thread_id);
__state_conversation(&state,&over);
// Failed event delivery does not consume the debounce window.
publish_fail=true;screen_call_tap(2100);assert(starts==4);
publish_fail=false;screen_call_tap(2101);assert(starts==5&&state.conv_thread_id);
// Unsigned tick rollover preserves the short debounce.
screen_call_tap(UINT32_MAX-100);assert(stops==5);
screen_call_tap(100);assert(stops==5);
screen_call_tap(199);assert(stops==6&&starts==5);
assert(reaps==3);
// A sleeping screen consumes the tap as wake, never as call toggle.
__state_conversation(&state,&over);
unsigned old_events=events,old_starts=starts;
standby=true;screen_call_tap(500);assert(wakes==1&&events==old_events&&starts==old_starts);
standby=false;screen_call_tap(501);assert(events==old_events);
screen_call_tap(800);assert(events==old_events+1&&starts==old_starts+1);
__state_conversation(&state,&over);
standby=true;wake_accept=false;screen_call_tap(1100);assert(wakes==1);
wake_accept=true;screen_call_tap(1101);assert(wakes==2&&starts==old_starts+1);
puts("PASS screen tap: idle start, second tap hangup at 300ms, connecting/closing admission, repeated stop, OVER reopen, create failure, delivery retry, tick rollover");
return 0;
}
`);
@@ -0,0 +1,59 @@
// Exercise the real durable wake state machine with failed hardware/NVS/replies.
const fs=require('node:fs'), path=require('node:path');
const root=path.resolve(__dirname,'..');
const json=path.join(process.env.IDF_PATH||'E:/Codex/toolchains/esp-idf-v5.5.1','components/json/cJSON');
const source=fs.readFileSync(path.join(root,'main/device_screen_wake.c'),'utf8').replace(/^#include[^\n]*\n/gm,'');
process.stdout.write(fs.readFileSync(path.join(json,'cJSON.h'),'utf8')+'\n'+fs.readFileSync(path.join(json,'cJSON.c'),'utf8').replace('#include "cJSON.h"','')+String.raw`
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdatomic.h>
typedef int esp_err_t,nvs_handle_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define ESP_ERR_NVS_NOT_FOUND 1
#define ESP_ERR_INVALID_STATE 2
#define ESP_ERR_INVALID_RESPONSE 3
#define NVS_READONLY 0
#define NVS_READWRITE 1
static bool standby,storage_fail,power_fail,present,staged_present;
static unsigned calls,commits,nonce;
static unsigned char stored[256],staged[256];
static size_t stored_size,staged_size;
static bool call_client_standby(void){return standby;}
static int device_power_apply(const char *mode,bool held){assert(!strcmp(mode,"active")&&!held);calls++;if(power_fail)return -1;standby=false;return 0;}
static void esp_fill_random(void *out,size_t size){uint32_t *p=out;for(size_t i=0;i<size/4;i++)p[i]=++nonce;}
static int nvs_open(const char *name,int mode,int *n){assert(!strcmp(name,"control"));*n=1;return 0;}
static int nvs_get_blob(int n,const char *key,void *out,size_t *size){if(!present)return ESP_ERR_NVS_NOT_FOUND;assert(*size>=stored_size);memcpy(out,stored,stored_size);*size=stored_size;return 0;}
static int nvs_set_blob(int n,const char *key,const void *data,size_t size){assert(size<sizeof(staged));memcpy(staged,data,size);staged_size=size;staged_present=true;return 0;}
static int nvs_erase_key(int n,const char *key){staged_present=false;return present?0:ESP_ERR_NVS_NOT_FOUND;}
static int nvs_commit(int n){if(storage_fail)return -1;memcpy(stored,staged,staged_size);stored_size=staged_size;present=staged_present;commits++;return 0;}
static void nvs_close(int n){}
`+source+String.raw`
static cJSON *report(void){cJSON *r=cJSON_CreateObject();device_screen_wake_add_report(r);return r;}
static cJSON *receipt(const char *result){cJSON *r=report(),*w=cJSON_GetObjectItem(r,"screen_wake");assert(w);cJSON_AddStringToObject(w,"result",result);return r;}
int main(void){
assert(device_screen_wake_init()==0);
device_screen_wake_request();assert(device_screen_wake_poll(4)==0&&calls==0&&!present);
standby=true;device_screen_wake_request();assert(device_screen_wake_poll(0)==0&&calls==0&&!present);
assert(device_screen_wake_poll(4)==0&&!standby&&present&&calls==1&&commits==1);
char id[37];strcpy(id,pending.id);assert(id[14]=='4'&&id[19]=='8');
device_screen_wake_request();assert(device_screen_wake_poll(4)==0&&!strcmp(id,pending.id)&&commits==1);
cJSON *r=cJSON_CreateObject();assert(device_screen_wake_ack(r)==ESP_ERR_INVALID_RESPONSE);cJSON_Delete(r);
r=receipt("applied");storage_fail=true;assert(device_screen_wake_ack(r)!=0&&present);
storage_fail=false;assert(device_screen_wake_ack(r)==0&&!present);cJSON_Delete(r);
// Failed persistence does not activate the microphone or display.
standby=true;storage_fail=true;device_screen_wake_request();unsigned before=calls;
assert(device_screen_wake_poll(5)!=0&&calls==before&&standby);
storage_fail=false;power_fail=true;assert(device_screen_wake_poll(5)!=0&&present&&standby);
r=report();assert(!cJSON_GetObjectItem(r,"screen_wake"));cJSON_Delete(r);
power_fail=false;assert(device_screen_wake_poll(5)==0&&!standby);strcpy(id,pending.id);
// An offline wake survives reboot, and its original revision/ID is retained.
standby=true;assert(device_screen_wake_init()==0);assert(device_screen_wake_poll(0)==0&&!standby&&!strcmp(id,pending.id)&&pending.revision==5);
r=receipt("superseded");assert(device_screen_wake_ack(r)==0&&!present);cJSON_Delete(r);
assert(device_screen_wake_poll(8)==0&&!present);
((wake_event_t*)stored)->version=2;present=true;stored_size=sizeof(wake_event_t);
assert(device_screen_wake_init()==ESP_ERR_INVALID_STATE);
puts("PASS screen wake: wake-only tap, startup intent, durable offline/reboot intent, duplicate tap, failed persistence/hardware/ACK, revision-fenced receipts");
}
`);
@@ -0,0 +1,80 @@
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import unittest
TOOLS = Path(__file__).resolve().parents[1] / "tools"
sys.path.insert(0, str(TOOLS))
import device_prepare as prepare
class PrepareTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.credential = self.root / "credential.json"
self.value = {"factory_id": "KP01-0123456789AB", "model": "ECHOEAR-S3",
"origin": "https://example.com", "key_version": 1,
"device_key_hex": "01" * 32}
self.save()
def save(self):
self.credential.write_text(json.dumps(self.value), encoding="utf-8-sig")
def test_accepts_actual_web_download(self):
self.assertEqual(prepare.read_credential(self.credential)["device_id"], self.value["factory_id"])
def test_legacy_id(self):
self.value["device_id"] = self.value.pop("factory_id")
self.save()
self.assertEqual(prepare.read_credential(self.credential)["device_id"], self.value["device_id"])
def test_rejects_conflicting_identity(self):
self.value["device_id"] = "KP01-AAAAAAAAAAAA"
self.save()
with self.assertRaises(ValueError):
prepare.read_credential(self.credential)
def test_rejects_bad_configuration(self):
for field, value in [("model", "different-board"), ("key_version", True),
("key_version", 0), ("device_key_hex", "bad"),
("origin", "http://example.com"),
("origin", "https://user:password@example.com"),
("origin", "https://example.com/path"),
("origin", "https://example.com:invalid")]:
original = self.value[field]
self.value[field] = value
self.save()
with self.subTest(field=field, value=value), self.assertRaises(ValueError):
prepare.read_credential(self.credential)
self.value[field] = original
def test_mac_independent_of_factory_id(self):
self.assertEqual(prepare.normalize_mac("B4-3A-45-16-34-A8"), "b4:3a:45:16:34:a8")
@unittest.skipUnless(importlib.util.find_spec("esp_idf_nvs_partition_gen"), "NVS generator unavailable")
def test_real_nvs_generation_and_no_overwrite(self):
output = self.root / "private"
manifest = prepare.prepare(self.credential, output, "B4:3A:45:16:34:A8")
image = (output / "ble_factory.bin").read_bytes()
self.assertEqual(len(image), 0x6000)
self.assertIn(bytes.fromhex(self.value["device_key_hex"]), image)
self.assertEqual(manifest["expected_mac"], "b4:3a:45:16:34:a8")
self.assertNotIn("device_key_hex", json.dumps(manifest))
self.assertNotIn(self.value["device_key_hex"], (output / "setup-label.json").read_text())
if (TOOLS.parent / "build" / "flasher_args.json").is_file():
import device_flash
plan = device_flash.build_plan(TOOLS.parent / "build", output,
identity_only=True, reset_runtime=True,
expect_mac="B4:3A:45:16:34:A8")
self.assertEqual({item.name for item in plan.images}, {"reset-runtime", "ble_factory"})
self.assertEqual(next(item.data for item in plan.images if item.name == "ble_factory"), image)
with self.assertRaises(FileExistsError):
prepare.prepare(self.credential, output, "B4:3A:45:16:34:A8")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,210 @@
"""Offline tests for the GUI bridge; serial/device I/O is always mocked."""
import io
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
TOOLS = Path(__file__).resolve().parents[1] / "tools"
sys.path.insert(0, str(TOOLS))
import flash_app_bridge as bridge
from test_device_flash import MAC, SimulatedEsptool, make_build
class BridgeTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix=".bridge-test-", dir=Path(__file__).parent)
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name).resolve()
self.build = self.root / "build"
make_build(self.build)
self.key = "ab" * 32
self.password = "private-setup-password"
self.credential = self.root / "credential.json"
self.info = {"factory_id": "KP01-123456789ABC", "model": "ECHOEAR-S3", "key_version": 1,
"origin": "https://example.com", "device_key_hex": self.key}
self.credential.write_text(json.dumps(self.info), encoding="utf-8")
self.request = {"credential_path": str(self.credential), "build_dir": str(self.build),
"mode": "identity", "expected_mac": MAC, "port": "COM6",
"output_root": str(self.root / "output")}
self.events = []
for owner, name, replacement in [
(bridge.device_prepare, "private_directory", lambda path: path.mkdir(parents=True, mode=0o700)),
(bridge.device_flash, "private_directory", lambda path: path.mkdir(parents=True, mode=0o700)),
(bridge.device_prepare, "prepare", self.fake_prepare),
]:
item = patch.object(owner, name, side_effect=replacement)
item.start()
self.addCleanup(item.stop)
def fake_prepare(self, credential, output, expected_mac):
info = bridge.device_prepare.read_credential(credential)
output.mkdir()
data = b"\xab" * bridge.device_flash.FACTORY_SIZE
(output / "ble_factory.bin").write_bytes(data)
(output / "factory.csv").write_text("device_key," + self.key)
(output / "setup-label.json").write_text(json.dumps({"setup_password": self.password}))
manifest = {"schema_version": 1, "chip": "esp32s3", "flash_size": bridge.device_flash.FLASH_SIZE,
"device_id": info["device_id"], "model": info["model"], "expected_mac": expected_mac,
"image": {"file": "ble_factory.bin", "offset": bridge.device_flash.FACTORY_OFFSET,
"size": len(data), "sha256": bridge.device_flash.sha256(data)}}
(output / "manifest.json").write_text(json.dumps(manifest))
return manifest
def preview(self, **overrides):
bridge.preview({**self.request, **overrides}, self.events.append)
return self.events[-1]
def assert_blocked(self, request, code):
with patch.object(bridge.device_flash, "execute_plan") as execute:
with self.assertRaises(bridge.BridgeError) as raised:
bridge.flash(request, self.events.append)
self.assertEqual(raised.exception.code, code)
execute.assert_not_called()
def test_preview_is_offline_and_secret_free(self):
with patch.object(bridge.device_flash, "execute_plan") as execute, patch.object(bridge, "hidden_run") as run:
result = self.preview()
execute.assert_not_called()
run.assert_not_called()
self.assertEqual(result["event"], "preview")
summary = result["summary"]
self.assertEqual(summary["mode"], "identity")
self.assertIsNone(summary["firmware_version"])
self.assertEqual(summary["reference_firmware_version"], "1.7.0-controls.28")
self.assertEqual({image["name"] for image in summary["regions"]}, {"ble_factory", "reset-runtime"})
text = json.dumps(self.events)
self.assertNotIn(self.key, text)
self.assertNotIn(self.password, text)
session = bridge.load_session(Path(result["session_file"]))
self.assertEqual(session["port"], "COM6")
self.assertEqual(session["expected_mac"], MAC)
self.assertNotIn(self.key, json.dumps(session))
def test_full_preview_has_complete_regions_and_version(self):
result = self.preview(mode="full")
self.assertEqual(result["summary"]["firmware_version"], "1.7.0-controls.28")
self.assertEqual(len(result["summary"]["regions"]), 8)
def test_bad_build_refuses_to_create_factory(self):
(self.build / "app.bin").write_bytes(b"not firmware")
with self.assertRaises(bridge.BridgeError) as raised:
self.preview()
self.assertEqual(raised.exception.code, "invalid_build")
self.assertFalse((self.root / "output").exists())
def test_bad_credential_refuses_to_create_factory(self):
self.info["device_key_hex"] = "invalid key"
self.credential.write_text(json.dumps(self.info))
with self.assertRaises(bridge.BridgeError) as raised:
self.preview()
self.assertEqual(raised.exception.code, "invalid_credential")
self.assertFalse((self.root / "output").exists())
def test_flash_requires_literal_true_confirmation(self):
session = self.preview()["session_file"]
for value in (None, False, 1, "true"):
with self.subTest(value=value):
self.assert_blocked({"session_file": session, "execute": value}, "confirmation_required")
def test_port_and_mac_bound_to_preview(self):
session = self.preview()["session_file"]
for override in ({"port": "COM7"}, {"expected_mac": "11:22:33:44:55:66"}):
with self.subTest(override=override):
self.assert_blocked({"session_file": session, "execute": True, **override}, "port_mismatch")
def test_frozen_inputs_changes_block_before_hardware(self):
for filename in ("credential.json", "build/app.bin", "build/flasher_args.json", "factory/ble_factory.bin", "factory/setup-label.json"):
with self.subTest(filename=filename):
session_path = Path(self.preview()["session_file"])
path = session_path.parent / filename if filename.startswith("factory/") else self.root / filename
original = path.read_bytes()
try:
path.write_bytes(original + b"changed")
self.assert_blocked({"session_file": str(session_path), "execute": True}, "stale_preview")
finally:
path.write_bytes(original)
def test_expiry_blocks_before_hardware(self):
session = self.preview()["session_file"]
with patch.object(bridge.time, "time", return_value=bridge.time.time() + bridge.SESSION_TTL + 1):
self.assert_blocked({"session_file": session, "execute": True}, "stale_preview")
def test_modified_session_cannot_change_target(self):
session = Path(self.preview()["session_file"])
value = json.loads(session.read_text())
value["port"] = "COM42"
session.write_text(json.dumps(value))
self.assert_blocked({"session_file": str(session), "execute": True}, "stale_preview")
def test_cannot_supply_arbitrary_command_or_runner(self):
session = self.preview()["session_file"]
for field in ("command", "python", "baud", "factory_dir", "backup_dir"):
self.assert_blocked({"session_file": session, "execute": True, field: "evil"}, "invalid_request")
def test_success_runs_existing_backup_write_verify_and_consumes_session(self):
session = self.preview()["session_file"]
runner = SimulatedEsptool()
with patch.object(bridge, "hidden_run", side_effect=runner):
bridge.flash({"session_file": session, "execute": True}, self.events.append)
self.assertEqual(runner.commands, ["version", "read_mac", "flash_id", "get_security_info", "read_flash", "write_flash", "verify_flash"])
self.assertEqual(self.events[-1]["event"], "complete")
self.assertTrue((Path(session).parent / "backup" / "before-16mb.bin").is_file())
self.assertIn("backup", [item.get("stage") for item in self.events])
self.assertIn("writing", [item.get("stage") for item in self.events])
self.assertIn("verifying", [item.get("stage") for item in self.events])
self.assert_blocked({"session_file": session, "execute": True}, "stale_preview")
def test_device_mac_mismatch_never_writes(self):
session = self.preview()["session_file"]
runner = SimulatedEsptool(mac="11:22:33:44:55:66")
with patch.object(bridge, "hidden_run", side_effect=runner):
with self.assertRaises(bridge.BridgeError) as raised:
bridge.flash({"session_file": session, "execute": True}, self.events.append)
self.assertEqual(raised.exception.code, "flash_failed")
self.assertNotIn("write_flash", runner.commands)
def test_backup_failure_never_writes(self):
session = self.preview()["session_file"]
runner = SimulatedEsptool(short_backup=True)
with patch.object(bridge, "hidden_run", side_effect=runner):
with self.assertRaises(bridge.BridgeError):
bridge.flash({"session_file": session, "execute": True}, self.events.append)
self.assertNotIn("write_flash", runner.commands)
def test_main_outputs_json_lines_and_redacts_exception(self):
session = self.preview()["session_file"]
request_file = self.root / "request.json"
request_file.write_text(json.dumps({"session_file": session, "execute": True}))
output = io.StringIO()
with patch.object(bridge.device_flash, "execute_plan", side_effect=RuntimeError(self.key + self.password)):
code = bridge.main(["flash", "--request", str(request_file)], output=output)
self.assertEqual(code, 2)
rows = [json.loads(line) for line in output.getvalue().splitlines()]
self.assertEqual(rows[-1]["code"], "flash_failed")
self.assertNotIn(self.key, output.getvalue())
self.assertNotIn(self.password, output.getvalue())
def test_ports_enumeration_does_not_open_serial(self):
import serial
from serial.tools import list_ports
port = types.SimpleNamespace(device="COM6", description="USB", hwid="USB VID:PID", serial_number="SERIAL")
with patch.object(serial, "Serial") as open_serial, patch.object(list_ports, "comports", return_value=[port]):
bridge.enumerate_ports({}, self.events.append)
open_serial.assert_not_called()
self.assertEqual(self.events[-1]["ports"][0]["serial_number"], "SERIAL")
def test_windows_child_processes_are_hidden(self):
if bridge.os.name != "nt":
self.skipTest("Windows-only subprocess flag")
with patch.object(bridge.subprocess, "run") as run:
bridge.hidden_run(["example"], capture_output=True)
self.assertTrue(run.call_args.kwargs["creationflags"] & subprocess.CREATE_NO_WINDOW)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,105 @@
// Actual command recognizer, message/report parsing and stop-readiness boundary.
const fs = require('node:fs'), path = require('node:path');
const root = path.resolve(__dirname, '..');
const read = p => fs.readFileSync(path.join(root, p), 'utf8');
const src = read('components/application/service/conv_service/src/volc_conv.c');
const json = path.join('E:/Codex/toolchains/esp-idf-v5.5.1/components/json/cJSON');
function section(start, end) {
const a = src.indexOf(start), b = src.indexOf(end, a + start.length);
if (a < 0 || b < 0) throw new Error('Missing production section ' + start);
return src.slice(a, b);
}
process.stdout.write(read('components/application/service/conv_service/inc/call_voice_end.h').replace('#pragma once', '') + '\n' +
read('components/application/service/conv_service/src/call_voice_end.c').replace('#include "call_voice_end.h"', '') + '\n' +
fs.readFileSync(path.join(json, 'cJSON.h'), 'utf8') + '\n' +
fs.readFileSync(path.join(json, 'cJSON.c'), 'utf8').replace('#include "cJSON.h"', '') + String.raw`
#include <assert.h>
#include <stdatomic.h>
#include <stdio.h>
typedef void *volc_engine_t;
typedef void *volc_hal_player_t;
typedef struct {int unused;} volc_message_info_t;
typedef struct {void *player_handle[1];} volc_hal_context_t;
static volc_hal_context_t hal={{(void*)1}};
static struct {void *engine;int wait_time;} conv_service;
static bool is_stop,stop_requested,session_started_reported,rtc=true,player_quiet=true;
static call_voice_end_t voice_end;
static atomic_uint voice_finish_serial,last_voice_received_ms;
static atomic_bool local_speech_active;
static uint64_t now_ms;
static cJSON *queue[8];static unsigned head,tail,turns,round_id;
static void *platform_report_queue=(void*)1;
#define pdTRUE 1
#define VOLC_HAL_PLAYER_AUDIO 0
#define VOLC_FUNCTION_CALL_EXEC 1
#define VOLC_FUNCTION_CALL_TRIGGER 2
#define LOGI(...) ((void)0)
#define LOGE(...) ((void)0)
static uint64_t volc_osal_get_time_ms(void){return now_ms;}
static bool platform_runtime_rtc(void){return rtc;}
static volc_hal_context_t *volc_get_global_hal_context(void){return &hal;}
static bool volc_hal_player_voice_quiet(void *p,unsigned ms){assert(p&&(ms==600));return player_quiet;}
static void volc_hal_player_play_data(void *p,const void *d,size_t n){(void)p;(void)d;(void)n;}
static void volc_hal_player_finish_segment(void *p){(void)p;}
static void call_idle_activity(void){}
static void platform_conversation_observe(const char *type){(void)type;}
static void __on_subtitle_message_received(const cJSON *j){(void)j;}
static const char *volc_current_user_id(void *e,bool remote){(void)e;return remote?"bot":"user";}
static int platform_conversation_turn(const char *s,const char *id,const char *text){assert(s&&id&&text);turns++;return 0;}
static int platform_conversation_started(const char *ref){assert(ref);return 0;}
static void aios_event_pub(int id,cJSON *j,void *u){(void)id;(void)u;cJSON_Delete(j);}
static int xQueueSend(void *q,cJSON **p,unsigned ticks){(void)q;(void)ticks;if(tail-head==8)return 0;queue[tail++%8]=*p;return pdTRUE;}
static int xQueueReceive(void *q,cJSON **p,unsigned ticks){(void)q;(void)ticks;if(head==tail)return 0;*p=queue[head++%8];return pdTRUE;}
` + section('/* Called only by the conversation task,', '\nvoid conv_ai_service_init()') + String.raw`
static void subtitle(const char *user,const char *text,bool definite,bool paragraph){
cJSON *j=cJSON_CreateObject(),*list=cJSON_AddArrayToObject(j,"data"),*line=cJSON_CreateObject();
cJSON_AddStringToObject(j,"type","subtitle");cJSON_AddItemToArray(list,line);
cJSON_AddStringToObject(line,"userId",user);cJSON_AddStringToObject(line,"text",text);
cJSON_AddNumberToObject(line,"sequence",tail);cJSON_AddBoolToObject(line,"definite",definite);cJSON_AddBoolToObject(line,"paragraph",paragraph);
cJSON_AddNumberToObject(line,"roundId",++round_id);
char *s=cJSON_PrintUnformatted(j);cJSON_Delete(j);char b[1024]="subv0000";
assert(strlen(s)+8<sizeof(b));strcpy(b+8,s);free(s);
__on_volc_message_data(NULL,b,strlen(b),NULL,NULL);
assert(!voice_end.pending); // receive callback never stops or mutates the FSM
process_platform_reports();
}
static void reset(void){call_voice_end_reset(&voice_end);memset(&voice_user_subtitle,0,sizeof(voice_user_subtitle));now_ms=1000;atomic_store(&voice_finish_serial,0);atomic_store(&last_voice_received_ms,0);atomic_store(&local_speech_active,false);is_stop=stop_requested=false;player_quiet=true;}
int main(void){
const char *yes[]={"拜拜吧","拜拜","再见了","就聊到这把","就聊到这吧","今天就聊到这里吧","好了,今天我们就先聊到这儿吧。谢谢你,拜拜!","那就先不聊了","请挂断电话","结束这次聊天吧","我先走了","我要休息了","下次再聊"};
const char *no[]={"不要拜拜","先别挂断","不要结束聊天","他说拜拜","她跟我说再见","你会说拜拜吗","拜拜是什么意思","就聊到这里吗?","\"拜拜\"","‘拜拜’","拜拜?","昨天就聊到这吧","好的拜拜之后继续聊","我还不想结束聊天","不聊了才怪","拜拜,不对,我还想聊","谢谢","今天的天气很好",NULL};
for(size_t i=0;i<sizeof(yes)/sizeof(*yes);i++)assert(call_voice_end_command(yes[i]));
for(size_t i=0;i<sizeof(no)/sizeof(*no);i++)assert(!call_voice_end_command(no[i]));
char huge[2048];memset(huge,'x',sizeof(huge));huge[sizeof(huge)-1]=0;assert(!call_voice_end_command(huge));
reset();subtitle("bot","拜拜吧",true,true);assert(!voice_end.pending);
subtitle("other","拜拜吧",true,true);assert(!voice_end.pending);
subtitle("user","拜拜吧",false,true);assert(!voice_end.pending);
const char *interim="{\"type\":\"subtitle\",\"data\":[{\"userId\":\"user\",\"sequence\":3,\"roundId\":2,\"definite\":false,\"paragraph\":false,\"text\":\"拜拜\"}]}";
for(int i=0;i<100;i++)__on_volc_message_data(NULL,interim,strlen(interim),NULL,NULL);
assert(head==tail&&!voice_end.pending); // partial floods cannot consume control slots
subtitle("user","拜拜吧",true,false);assert(!voice_end.pending);
subtitle("user","拜拜,不对,我还想聊",true,true);assert(!voice_end.pending);
cJSON *part=cJSON_CreateObject();cJSON_AddNumberToObject(part,"roundId",++round_id);cJSON_AddNumberToObject(part,"sequence",1);cJSON_AddBoolToObject(part,"definite",true);cJSON_AddBoolToObject(part,"paragraph",false);cJSON_AddStringToObject(part,"text","你跟我说一句,");
process_voice_end_rtc_user(part);assert(!voice_end.pending);
cJSON_SetNumberValue(cJSON_GetObjectItem(part,"sequence"),2);cJSON_SetBoolValue(cJSON_GetObjectItem(part,"paragraph"),true);cJSON_SetValuestring(cJSON_GetObjectItem(part,"text"),"再见");
process_voice_end_rtc_user(part);assert(!voice_end.pending);cJSON_Delete(part);
subtitle("user","就聊到这把",true,true);assert(voice_end.pending&&!voice_end_ready());
uint32_t deadline=voice_end.requested_ms;now_ms=2000;
call_voice_end_user(&voice_end,"就聊到这把",now_ms,0);assert(voice_end.requested_ms==deadline);
atomic_store(&voice_finish_serial,1);now_ms=2600;player_quiet=false;assert(!voice_end_ready());
player_quiet=true;atomic_store(&last_voice_received_ms,2500);assert(!voice_end_ready());
now_ms=3800;assert(voice_end_ready());atomic_store(&local_speech_active,true);assert(!voice_end_ready());
process_voice_end_transcript("subject","等一下,我还想问个问题",true);assert(!voice_end.pending);
reset();subtitle("user","拜拜",true,true);now_ms=12999;assert(!voice_end_ready());now_ms=13000;assert(voice_end_ready());
reset();subtitle("user","拜拜",true,true);is_stop=true;process_voice_end_transcript("subject","再见",true); // teardown cannot arm another close
call_voice_end_reset(&voice_end);process_voice_end_transcript("subject","拜拜",true);assert(!voice_end.pending);
reset();now_ms=UINT32_MAX-500;call_voice_end_user(&voice_end,"拜拜",now_ms,0);now_ms+=12000;assert(call_voice_end_due(&voice_end,(uint32_t)now_ms,0,false,false));
reset();rtc=false;
const char *msg="{\"type\":\"conversation.item.input_audio_transcription.completed\",\"event_id\":\"e1\",\"transcript\":\"拜拜吧\"}";
__on_volc_message_data(NULL,msg,strlen(msg),NULL,NULL);assert(!voice_end.pending);process_platform_reports();assert(voice_end.pending);
const char *done="{\"type\":\"response.audio.done\"}";
__on_volc_message_data(NULL,done,strlen(done),NULL,NULL);assert(atomic_load(&voice_finish_serial)==1);
now_ms+=2000;assert(voice_end_ready());
puts("PASS voice end: actual RTC/WS parsing, complete user-only commands, negation/quotes/questions, playback wait, cancel, duplicate deadline, 12s fallback, clock wrap, teardown fencing");
return 0;
}
`);
@@ -0,0 +1,69 @@
// Execute the actual PCM worker and quiet check with synthetic RTOS/codec I/O.
const fs=require('node:fs'),path=require('node:path');
const src=fs.readFileSync(path.join(__dirname,'../components/application/platform/src/espressif/volc_hal_player.c'),'utf8');
const section=(a,b)=>{const x=src.indexOf(a),y=src.indexOf(b,x+a.length);if(x<0||y<0)throw new Error(a);return src.slice(x,y);};
process.stdout.write(String.raw`
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
#include <stdatomic.h>
#include <string.h>
typedef void *volc_hal_player_t;
typedef struct {uint8_t data[65536];size_t used;} stream_t;
typedef struct {
atomic_bool is_started,write_in_progress;
atomic_uint last_voice_write_ms;
stream_t *pcm_stream;void *worker_stopped;
bool discard_pending,segment_complete;
bool (*authorization_gate)(void);
} volc_player_impl_t;
#define LOGI(...) do { if (0) printf(__VA_ARGS__); } while (0)
#define LOGE(...) do { if (0) printf(__VA_ARGS__); } while (0)
#define ESP_OK 0
#define PCM_PREBUFFER_BYTES 24000
#define PCM_PREBUFFER_WAIT_MS 1500
#define PCM_REPRIME_IDLE_MS 600
#define pdMS_TO_TICKS(x) (x)
static uint64_t now_ms,limit_ms;
static unsigned written,stops;
static bool feed_fail;
static volc_player_impl_t *running;
static uint64_t volc_osal_get_time_ms(void){return now_ms;}
static size_t xStreamBufferBytesAvailable(stream_t *s){return s->used;}
static size_t xStreamBufferReceive(stream_t *s,void *out,size_t n,unsigned wait){assert(wait==0);if(n>s->used)n=s->used;memcpy(out,s->data,n);memmove(s->data,s->data+n,s->used-n);s->used-=n;return n;}
static int stop_feeder_once(volc_player_impl_t *p){(void)p;stops++;return 0;}
static bool volc_hal_player_voice_quiet(volc_hal_player_t,unsigned);
static int audio_feeder_feed_data_timeout(const void *data,size_t count,unsigned timeout){
assert(data&&count&&timeout==50);
/* A dequeued last chunk is still in flight and cannot permit hangup. */
assert(atomic_load(&running->write_in_progress));assert(!volc_hal_player_voice_quiet(running,600));
now_ms+=count/32;written+=count;return feed_fail?-1:0;
}
static void vTaskDelay(unsigned ms){now_ms+=ms;if(now_ms>=limit_ms)atomic_store(&running->is_started,false);}
static void xSemaphoreGive(void *s){(void)s;}
static void vTaskDelete(void *t){assert(!t);}
`+section('static void pcm_playback_task(', '\nvolc_hal_player_t volc_hal_player_create(')+
section('bool volc_hal_player_voice_quiet(', '\nvoid volc_hal_player_set_authorization_gate(')+String.raw`
static bool deny(void){return false;}
int main(void){
stream_t stream={0};volc_player_impl_t player={.pcm_stream=&stream,.segment_complete=true};
running=&player;now_ms=1000;atomic_store(&player.is_started,true);atomic_store(&player.last_voice_write_ms,1000);
assert(!volc_hal_player_voice_quiet(&player,600));
for(size_t i=0;i<4096;i+=2){int16_t s=(i&2)?-500:500;memcpy(stream.data+i,&s,2);}stream.used=4096;
now_ms=3000;assert(!volc_hal_player_voice_quiet(&player,600));limit_ms=3400;
pcm_playback_task(&player);assert(written==4096&&stream.used==0);
uint32_t last=atomic_load(&player.last_voice_write_ms);assert(last>=3128&&last<3400);
atomic_store(&player.is_started,true);now_ms=last+599;assert(!volc_hal_player_voice_quiet(&player,600));now_ms++;assert(volc_hal_player_voice_quiet(&player,600));
memset(stream.data,0,640);stream.used=640;written=0;limit_ms=now_ms+200;
pcm_playback_task(&player);assert(written==640&&atomic_load(&player.last_voice_write_ms)==last);
atomic_store(&player.is_started,true);now_ms+=1000;assert(volc_hal_player_voice_quiet(&player,600));
stream.used=640;player.authorization_gate=deny;limit_ms=now_ms+100;pcm_playback_task(&player);assert(stops==1&&stream.used==0);
atomic_store(&player.is_started,true);player.authorization_gate=NULL;player.discard_pending=true;stream.used=640;limit_ms=now_ms+100;written=0;
pcm_playback_task(&player);assert(!player.discard_pending&&stream.used==0&&written==0);
atomic_store(&player.is_started,true);atomic_store(&player.last_voice_write_ms,UINT32_MAX-500u);now_ms=(uint64_t)UINT32_MAX+100;assert(volc_hal_player_voice_quiet(&player,600));
assert(!volc_hal_player_voice_quiet(NULL,600));
puts("PASS voice playback: actual PCM worker, queued/in-flight audio, audible tail grace, continuous silent PCM, authorization/discard, monotonic clock wrap");
}
`);
@@ -0,0 +1,84 @@
"""Create a per-device NVS image and setup label; never print device secrets.
Use with the *existing* physical_confirm inventory credential, not a newly
invented server key. Output is private production material, not source code.
"""
import argparse
import csv
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import subprocess
import sys
from urllib.parse import urlsplit
N = int(''.join([
'FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E',
'3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE',
'9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED5290770',
'96966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF69558171839',
'95497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6',
'E1E4C7ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864D87602733EC86A64521F2B18177B200CBBE117577A615D6C77',
'0988C0BAD946E208E24FA074E5AB3143DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF']), 16)
def security_material(username, password, salt):
"""Match esp_srp.c: hash raw salt and SHA512 bytes (preserve leading zeros)."""
inner = hashlib.sha512(f'{username}:{password}'.encode()).digest()
x = int.from_bytes(hashlib.sha512(salt + inner).digest(), 'big')
return pow(5, x, N).to_bytes(384, 'big')
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--device-id', required=True)
p.add_argument('--model', default='ECHOEAR-S3')
p.add_argument('--origin', required=True)
p.add_argument('--key-version', type=int, required=True)
p.add_argument('--device-key-file', type=Path, required=True, help='64 hex chars or controlled migration credential JSON')
p.add_argument('--output', type=Path, required=True, help='new private directory; refuses overwrite')
a = p.parse_args()
origin = urlsplit(a.origin)
if (not re.fullmatch(r'KP01-[0-9A-F]{12}', a.device_id) or
not re.fullmatch(r'[A-Za-z0-9_.-]{1,32}', a.model) or
not 1 <= a.key_version <= 0xffffffff or len(a.origin) > 150 or
origin.scheme != 'https' or not origin.hostname or origin.username or
origin.path or origin.query or origin.fragment):
p.error('invalid identity, model, key version or HTTPS origin')
key_hex = a.device_key_file.read_text(encoding='ascii').strip()
if key_hex.startswith('{'):
identity = json.loads(key_hex)
if identity.get('device_id') != a.device_id or identity.get('key_version') != a.key_version:
p.error('credential identity does not match the requested factory image')
key_hex = identity.get('device_key_hex', '')
if not re.fullmatch(r'[0-9a-fA-F]{64}', key_hex):
p.error('device key must be exactly 32 bytes in hexadecimal')
a.output.mkdir(parents=True, exist_ok=False, mode=0o700)
password = secrets.token_urlsafe(16)
salt = secrets.token_bytes(16)
verifier = security_material(a.device_id, password, salt)
values = [('identity', 'namespace', '', ''),
('factory_id', 'data', 'string', a.device_id),
('model', 'data', 'string', a.model),
('origin', 'data', 'string', a.origin),
('key_version', 'data', 'u32', a.key_version),
('device_key', 'data', 'hex2bin', key_hex),
('salt', 'data', 'hex2bin', salt.hex()),
('verifier', 'data', 'hex2bin', verifier.hex())]
csv_path = a.output / 'factory.csv'
with csv_path.open('x', encoding='utf-8', newline='') as f:
writer = csv.writer(f); writer.writerow(('key', 'type', 'encoding', 'value')); writer.writerows(values)
os.chmod(csv_path, 0o600)
subprocess.run([sys.executable, '-m', 'esp_idf_nvs_partition_gen', 'generate',
str(csv_path.resolve()), str((a.output / 'ble_factory.bin').resolve()), '0x6000'], check=True)
label = a.output / 'setup-label.json'
label.write_text(json.dumps({'device_id': a.device_id, 'model': a.model, 'setup_password': password}, ensure_ascii=False, indent=2), encoding='utf-8')
os.chmod(label, 0o600); os.chmod(a.output / 'ble_factory.bin', 0o600)
print(f'Created private NVS image and label in {a.output.resolve()}. No device key is present in the label.')
if __name__ == '__main__':
main()
@@ -0,0 +1,86 @@
"""One bounded COM6 reboot; observe real MQTT lifecycle and durable command ACK.
Requires the existing, COM6-restricted operator helper in the API container.
Records operational metadata only. Does not open serial or change ownership.
"""
import datetime
import json
from pathlib import Path
import subprocess
REMOTE = r'''
import datetime,json,subprocess,time,uuid
p=json.loads(subprocess.check_output(['docker','inspect','data-terminal-cloud-postgres-1']))[0]
env=dict(x.split('=',1) for x in p['Config']['Env'] if '=' in x)
psql=['docker','exec',p['Id'],'psql','-U',env['POSTGRES_USER'],'-d',env['POSTGRES_DB'],'-At','-v','ON_ERROR_STOP=1','-c']
query="""SELECT json_build_object(
'utc',now(),'connected',p.connected,'online',COALESCE(p.connected AND p.last_seen_at>now()-interval '90 seconds',false),
'session',p.connection_id,'heartbeat',p.last_seen_at,'firmware',t.firmware_version,
'desired_revision',t.desired_revision,'reported_revision',t.reported_revision,
'speaker',t.reported_config->'volume'->'speaker','power',t.reported_config->'power'->>'mode',
'bound',(SELECT count(*) FROM device_binding WHERE device_id=d.id AND unbound_at IS NULL),
'pending',(SELECT count(*) FROM device_command WHERE device_id=d.id AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at>now()),
'busy',EXISTS(SELECT 1 FROM call_session WHERE device_id=d.id AND call_state IN ('PREPARING','STARTING','TALKING','STOPPING')) OR
EXISTS(SELECT 1 FROM conversation WHERE device_id=d.id AND status NOT IN ('COMPLETED','FAILED','EXPIRED')) OR
EXISTS(SELECT 1 FROM fleet_ota WHERE device_id=d.id AND status IN ('pending','accepted')))
FROM device d JOIN fleet_presence p ON p.device_id=d.id JOIN device_twin t ON t.device_id=d.id
WHERE d.public_id='623a4e54-bdc4-4c98-b03e-27267d3ff360' AND d.factory_device_id='KP01-020026091601' AND d.status='registered'"""
def state():
return json.loads(subprocess.check_output(psql+[query],text=True,timeout=10))
def emit(key,value):
print(json.dumps({key:value}),flush=True)
initial=state()
assert initial['bound']==1 and not initial['busy'] and initial['pending']==0 and initial['online'], 'COM6 not idle/online or binding changed'
assert initial['desired_revision']==initial['reported_revision'] and initial['power']=='active', 'COM6 config unsettled or standby'
session=initial.pop('session');emit('initial',initial)
start=time.monotonic()
command=subprocess.Popen(['docker','exec','data-terminal-cloud-api-1','/tmp/com6-power-verify','reboot'],stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
first=command.stdout.readline().strip()
assert first, 'reboot helper did not return a command ID'
receipt=json.loads(first);uuid.UUID(receipt['command_id']);emit('command',receipt)
observations=[];offline=False;reconnected=False
while time.monotonic()-start<100:
current=state();changed=current.pop('session')!=session
current['session_changed']=changed;current['elapsed_sec']=round(time.monotonic()-start,3)
observations.append(current)
offline=offline or not current['online']
reconnected=reconnected or (changed and current['online'])
if len(observations)==1 or (len(observations)>1 and (current['online'],changed)!=(observations[-2]['online'],observations[-2]['session_changed'])):
emit('transition',current)
if command.poll() is not None and reconnected and current['pending']==0 and current['desired_revision']==current['reported_revision']:
break
time.sleep(.25)
output,error=command.communicate(timeout=15)
receipts=[json.loads(line) for line in output.splitlines() if line.strip()]
terminal=receipts[-1] if receipts else {}
emit('observations',observations)
emit('result',{'exit':command.returncode,'command_id':receipt['command_id'],'status':terminal.get('status'),
'delivery_attempts':terminal.get('delivery_attempts'),'offline_observed':offline,'reconnected':reconnected,
'settings_preserved':current['speaker']==initial['speaker'] and current['power']==initial['power'] and current['bound']==initial['bound'] and current['desired_revision']==initial['desired_revision'] and current['reported_revision']==current['desired_revision'],
'error':error.strip()})
assert command.returncode==0 and terminal.get('status')=='SUCCEEDED' and reconnected, 'reboot/reconnect failed'
assert current['speaker']==initial['speaker'] and current['power']==initial['power'] and current['bound']==1 and current['desired_revision']==initial['desired_revision'] and current['reported_revision']==current['desired_revision']
# A very short reboot may reconnect before TCP detects the old peer is gone.
# Report the observation faithfully; do not call it an offline pass.
'''
if __name__ == '__main__':
stamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
evidence = Path('E:/Codex/story/.local-evidence/com6-controls-20260922') / ('reconnect-' + stamp + '.jsonl')
ssh = ['ssh', '-i', 'E:/Codex/story/.local-evidence/account-reset-18642041700-20260916/migration-key.pem',
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', 'root@82.157.201.114', 'python3 -u -']
process = subprocess.Popen(ssh, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding='utf-8')
process.stdin.write(REMOTE)
process.stdin.close()
with evidence.open('w', encoding='utf-8') as out:
for line in process.stdout:
out.write(line)
out.flush()
if '"observations"' not in line:
print(line.strip(), flush=True)
error = process.stderr.read()
result = process.wait()
if error:
print(error, flush=True)
print(json.dumps({'evidence': str(evidence), 'exit': result}), flush=True)
raise SystemExit(result)
@@ -0,0 +1,51 @@
"""COM6-only fixed-sentence smoke test; retain numeric/audio-state evidence only."""
import datetime,json,pathlib,re,serial,subprocess,time
probe = """
import json,subprocess
p=json.loads(subprocess.check_output(['docker','inspect','data-terminal-cloud-postgres-1']))[0]
e=dict(x.split('=',1) for x in p['Config']['Env'] if '=' in x)
q=\"SELECT json_build_object('bound',(SELECT count(*) FROM device_binding WHERE device_id=d.id AND unbound_at IS NULL),'busy',EXISTS(SELECT 1 FROM call_session WHERE device_id=d.id AND call_state IN ('PREPARING','STARTING','TALKING','STOPPING')) OR EXISTS(SELECT 1 FROM conversation WHERE device_id=d.id AND status NOT IN ('COMPLETED','FAILED','EXPIRED')) OR EXISTS(SELECT 1 FROM fleet_ota WHERE device_id=d.id AND status IN ('pending','accepted'))) FROM device d WHERE public_id='623a4e54-bdc4-4c98-b03e-27267d3ff360' AND factory_device_id='KP01-020026091601' AND status='registered'\"
print(subprocess.check_output(['docker','exec',p['Id'],'psql','-U',e['POSTGRES_USER'],'-d',e['POSTGRES_DB'],'-At','-v','ON_ERROR_STOP=1','-c',q],text=True).strip())
"""
result=subprocess.run(['ssh','-i','E:/Codex/story/.local-evidence/account-reset-18642041700-20260916/migration-key.pem','-o','BatchMode=yes','-o','ConnectTimeout=10','root@82.157.201.114','python3 -'],input=probe,text=True,capture_output=True,check=True)
state=json.loads(result.stdout)
if state['busy'] or state['bound']!=1:raise SystemExit('COM6 is busy or binding changed; test not started')
port=serial.Serial();port.port='COM6';port.baudrate=115200;port.timeout=.2;port.dtr=False;port.rts=False
port.open()
patterns=[r'RTC diagnostic: .*',r'USB diagnostic: .*',r'chat end acknowledged=\d+',
r'Network diagnostic: sockets=\d+ limit=\d+',
r'mbedtls_net_connect error, return -0x[0-9a-f]+ errno=\d+',
r'platform_conversation_begin failed, ret: -?\d+',r'volc_start failed, ret: -?\d+',
r'PLATFORM_HTTP method=\w+ status=\d+ elapsed_ms=\d+ new_connection=\d+',
r'Failed to allocate \d+ bytes',r'Cannot allocate memory',
r'get rtc config failed',r'engine start failed: -?\d+',r'engine is not in CREATED state',r'bot id is invalid',
r'engine handle\([a-zA-Z0-9()]+\) or bot id\([a-zA-Z0-9()]+\) is NULL',r'rtc instance is NULL',
r'connect failed, https client (?:context|connect) return: -0x[0-9a-f]+',
r'RTC configuration request prepared',
r'Failed to join room: -?\d+',r'Failed to get response from server',
r'webclient POST request failed, response\(-?\d+\) error',
r'RTC config rejected code=[A-Za-z_0-9.]+ fields room=\d user=\d token=\d task=\d',
r'PLAYBACK (?:stats .*|start .*|idle .*|diagnostic sentence requested ret=-?\d+|feeder write failed|queue overflow.*)',
r'DOWNLINK span_ms=.*',r'AUDIO conversation status=-?\d+',r'device_control: state .*',
r'Platform error code: [A-Z_0-9]+',r'Guru Meditation.*',r'Stack canary.*',r'CORRUPT HEAP.*']
allow=re.compile('|'.join('('+p+')' for p in patterns))
lines=[];stopped=False
try:
port.write(b'\ndiag-say\n');start=time.monotonic()
print('COM6 fixed-sentence test started; no transcript captured',flush=True)
diagnosed=False
while time.monotonic()-start<55:
if not diagnosed and time.monotonic()-start>=15:port.write(b'diag-state\n');diagnosed=True
if not stopped and time.monotonic()-start>=40:port.write(b'diag-stop\n');stopped=True
raw=port.readline().decode('utf-8','replace').strip()
found=allow.search(raw)
if found:
line=found.group(0)[:700];lines.append(line);print(line,flush=True)
finally:
if not stopped:port.write(b'diag-stop\n')
port.close()
stamp=datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
path=pathlib.Path('E:/Codex/story/.local-evidence/com6-controls-20260922')/('voice-'+stamp+'.log')
path.write_text('\n'.join(lines)+'\n',encoding='utf-8')
print(json.dumps({'evidence':str(path),'lines':len(lines),'downlink':any(x.startswith('DOWNLINK ') for x in lines),'playback':any(x.startswith('PLAYBACK stats ') for x in lines),'hangup_ack':any('chat end acknowledged=1' in x for x in lines)}),flush=True)
@@ -0,0 +1,448 @@
"""Plan or safely flash the EchoEar ESP32-S3 16 MiB A/B firmware.
No serial port is opened unless the command is ``flash --execute``. All writes
require a matching physical MAC, a fresh full-flash backup, and verified input
images. The device remains in its bootloader after completion: reset it manually.
Full USB firmware writes require --reset-runtime and subsequent Wi-Fi provisioning
to clear old OTA version/journal metadata. Identity-only writes may preserve NVS.
Requires Python 3.10+; hardware commands additionally need esptool 4.x or 5.x.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path, PureWindowsPath
import re
import struct
import subprocess
import sys
from device_prepare import private_directory
PROJECT = Path(__file__).resolve().parents[1]
FLASH_SIZE = 0x1000000
FACTORY_OFFSET = 0xE4E000
FACTORY_SIZE = 0x6000
NVS_OFFSET = 0x9000
NVS_SIZE = 0x6000
TABLE_OFFSET = 0x8000
SECTOR = 0x1000
# name: type, subtype, offset, size, flags. Exact supported 16 MiB A/B layout.
LAYOUT = {
"nvs": (1, 2, 0x9000, 0x6000, 0),
"phy_init": (1, 1, 0xF000, 0x1000, 0),
"ota_0": (0, 0x10, 0x10000, 0x400000, 0),
"ota_1": (0, 0x11, 0x410000, 0x400000, 0),
"otadata": (1, 0, 0x810000, 0x2000, 0),
"model": (1, 0x82, 0x812000, 0x80000, 0),
"coredump": (1, 3, 0x91E000, 0x10000, 0),
"storage": (1, 0x83, 0x92E000, 0x200000, 0),
"eaf": (1, 0x82, 0xB2E000, 0x320000, 0),
"ble_factory": (1, 2, FACTORY_OFFSET, FACTORY_SIZE, 0),
}
REGIONS = {
"bootloader": (0, 0x8000),
"partition-table": (TABLE_OFFSET, SECTOR),
"app": (0x10000, 0x400000),
"otadata": (0x810000, 0x2000),
"model": (0x812000, 0x80000),
"eaf": (0xB2E000, 0x320000),
}
class FlashError(ValueError):
"""An input or hardware safety check failed."""
def require(condition, message):
if not condition:
raise FlashError(message)
def number(value):
require(type(value) in (str, int), "Expected an integer or hexadecimal string")
try:
return int(value, 0) if isinstance(value, str) else value
except ValueError as exc:
raise FlashError("Invalid integer in manifest") from exc
def normalize_mac(value):
require(isinstance(value, str), "MAC must be a string")
compact = re.sub("[:-]", "", value or "")
require(bool(re.fullmatch(r"[0-9a-fA-F]{12}", compact)), "MAC must contain 12 hex digits")
require(compact.lower() not in ("000000000000", "ffffffffffff"), "Invalid MAC address")
return ":".join(compact[i:i + 2].upper() for i in range(0, 12, 2))
def sha256(data):
return hashlib.sha256(data).hexdigest()
def load_json(path):
try:
value = json.loads(path.read_text(encoding="utf-8-sig"))
except (OSError, ValueError) as exc:
raise FlashError(f"Cannot read JSON: {path}") from exc
require(isinstance(value, dict), f"Expected a JSON object: {path}")
return value
def object_field(parent, name):
value = parent.get(name)
require(isinstance(value, dict), f"Missing or invalid object: {name}")
return value
def contained_file(directory, relative):
require(isinstance(relative, str) and relative, "Missing image filename")
win = PureWindowsPath(relative)
require(not win.drive and not win.is_absolute() and not Path(relative).is_absolute(),
"Absolute image paths are not allowed in manifests")
require(".." not in win.parts and ".." not in Path(relative).parts,
"Image path must not escape its build/factory directory")
path = (directory / relative).resolve()
require(path.is_relative_to(directory.resolve()), "Image symlink escapes its directory")
require(path.is_file(), f"Image is missing: {path}")
return path
def partition_table(data):
"""Validate IDF's 32-byte partition records and the mandatory MD5 record."""
require(len(data) in (0xC00, 0x1000), "Partition table must be 3072 or 4096 bytes")
result = {}
for pos in range(0, 0xC00, 32):
record = data[pos:pos + 32]
if record[:16] == b"\xeb\xeb" + b"\xff" * 14:
require(record[16:] == hashlib.md5(data[:pos]).digest(), "Partition-table MD5 mismatch")
require(data[pos + 32:] == b"\xff" * (len(data) - pos - 32),
"Unsupported signed or nonempty partition-table padding")
require(result == LAYOUT, "Partition layout is not the supported 16 MiB / 4 MiB A/B layout")
return result
require(record[:2] == b"\xaa\x50", "Missing partition-table entry or MD5 record")
_, kind, subtype, offset, size, label, flags = struct.unpack("<HBBII16sI", record)
try:
name = label.split(b"\0", 1)[0].decode("ascii")
except UnicodeError as exc:
raise FlashError("Non-ASCII partition name") from exc
require(name and name not in result, "Duplicate or empty partition name")
require(offset % SECTOR == 0 and size > 0 and size % SECTOR == 0,
"Unaligned partition")
require(offset >= 0x9000 and offset + size <= FLASH_SIZE, "Partition out of flash bounds")
for old in result.values():
require(offset + size <= old[2] or offset >= old[2] + old[3], "Overlapping partitions")
result[name] = (kind, subtype, offset, size, flags)
raise FlashError("Missing partition-table MD5")
def esp_image(data, application=False):
"""Validate ESP image segments, XOR checksum, appended SHA256 and chip ID."""
require(len(data) >= 64 and data[0] == 0xE9, "Not an ESP executable image")
require(1 <= data[1] <= 16, "Invalid ESP image segment count")
require(struct.unpack_from("<H", data, 12)[0] == 9, "Image is not for ESP32-S3")
require(data[2] == 3 and data[3] == 0x4F, "Image must target DOUT / 16MB / 80MHz")
require(data[23] == 1, "ESP image must include its validation SHA256")
position, checksum = 24, 0xEF
for _ in range(data[1]):
require(position + 8 <= len(data), "Truncated ESP segment header")
size = struct.unpack_from("<I", data, position + 4)[0]
position += 8
require(size > 0 and position + size <= len(data), "Truncated or empty ESP segment")
for byte in data[position:position + size]:
checksum ^= byte
position += size
checksum_pos = (position // 16 + 1) * 16 - 1
require(checksum_pos + 33 == len(data), "Unexpected ESP image tail or signature")
require(data[checksum_pos] == checksum, "ESP image checksum mismatch")
require(data[-32:] == hashlib.sha256(data[:-32]).digest(), "ESP image SHA256 mismatch")
if not application:
return {}
require(struct.unpack_from("<I", data, 32)[0] == 0xABCD5432, "App descriptor is missing")
def cstring(offset, length):
return data[offset:offset + length].split(b"\0", 1)[0].decode("utf-8", errors="replace")
project = cstring(80, 32)
require(project == "VolcRTCDemo", "Image is not the EchoEar VolcRTCDemo application")
versions = set(re.findall(rb"\b\d+\.\d+\.\d+-controls\.\d+\b", data))
require(len(versions) == 1, "Application must contain one identifiable controls firmware version")
return {"project": project, "firmware_version": next(iter(versions)).decode("ascii"),
"idf_app_version": cstring(48, 32), "idf_version": cstring(144, 32),
"hardware_acceptance": "not_verified_by_this_tool"}
@dataclass
class Image:
name: str
offset: int
data: bytes
source: str
def summary(self):
return {"name": self.name, "offset": hex(self.offset), "size": len(self.data),
"sha256": sha256(self.data), "source": self.source}
@dataclass
class Plan:
images: list[Image]
application: dict
factory: dict | None
identity_only: bool
reset_runtime: bool
def summary(self):
return {"mode": "identity-only" if self.identity_only else "full-firmware",
"chip": "esp32s3", "flash_size": FLASH_SIZE,
"application": {"action": "retained_on_device", "firmware_version": None}
if self.identity_only else self.application,
"reference_build": self.application if self.identity_only else None,
"factory": self.factory,
"reset_runtime": self.reset_runtime,
"execute_requires_reset_runtime": not self.identity_only,
"runtime_effect": "Erase Wi-Fi, binding, session and OTA state; BLE provisioning required"
if self.reset_runtime else "Preserve Wi-Fi, binding and session state",
"images": [image.summary() for image in self.images],
"completion": "Verify written bytes, stay in bootloader; manual reset required"}
def build_plan(build_dir, factory_dir=None, identity_only=False, reset_runtime=False, expect_mac=None):
build_dir = Path(build_dir).resolve()
manifest = load_json(build_dir / "flasher_args.json")
require(object_field(manifest, "extra_esptool_args").get("chip") == "esp32s3", "Build target must be esp32s3")
settings = object_field(manifest, "flash_settings")
require(settings.get("flash_mode") == "dout" and settings.get("flash_freq") == "80m"
and settings.get("flash_size") in ("detect", "16MB"), "Unsupported build flash settings")
flash_files = manifest.get("flash_files")
require(isinstance(flash_files, dict) and len(flash_files) == len(REGIONS),
"Build must contain exactly bootloader, app, partition-table, otadata, model and eaf")
mapped = {number(key): value for key, value in flash_files.items()}
require(len(mapped) == len(REGIONS), "Duplicate flash offsets")
images, app_info = [], {}
for name, (offset, maximum) in REGIONS.items():
entry = object_field(manifest, name)
require(number(entry.get("offset")) == offset and mapped.get(offset) == entry.get("file"),
f"Build metadata for {name} disagrees with supported address")
require(entry.get("encrypted") in (False, "false"), "Encrypted build images are unsupported")
path = contained_file(build_dir, entry.get("file"))
data = path.read_bytes()
require(0 < len(data) <= maximum, f"{name} image does not fit its allocated region")
if name == "partition-table":
partition_table(data)
elif name == "bootloader":
esp_image(data)
elif name == "app":
app_info = esp_image(data, application=True)
elif name == "otadata":
require(len(data) == maximum and data == b"\xff" * maximum,
"Expected the IDF initial empty OTA selection image")
if not identity_only:
images.append(Image(name, offset, data, str(path)))
factory_summary = None
if factory_dir:
factory_dir = Path(factory_dir).resolve()
factory = load_json(factory_dir / "manifest.json")
require(factory.get("schema_version") == 1 and factory.get("chip") == "esp32s3"
and number(factory.get("flash_size")) == FLASH_SIZE, "Unsupported factory manifest")
require(isinstance(factory.get("device_id"), str)
and bool(re.fullmatch(r"KP01-[0-9A-F]{12}", factory["device_id"])),
"Invalid factory device ID")
mac = normalize_mac(factory.get("expected_mac"))
if expect_mac:
require(mac == normalize_mac(expect_mac), "Factory manifest MAC does not match --expect-mac")
entry = object_field(factory, "image")
require(entry.get("file") == "ble_factory.bin" and number(entry.get("offset")) == FACTORY_OFFSET
and number(entry.get("size")) == FACTORY_SIZE, "Incorrect factory image location/size")
path = contained_file(factory_dir, entry.get("file"))
data = path.read_bytes()
require(len(data) == FACTORY_SIZE and sha256(data) == entry.get("sha256"),
"Factory image size or SHA256 mismatch")
require(data != b"\xff" * FACTORY_SIZE and data != b"\0" * FACTORY_SIZE,
"Factory image is empty")
images.append(Image("ble_factory", FACTORY_OFFSET, data, str(path)))
factory_summary = {"device_id": factory["device_id"], "expected_mac": mac}
require(not identity_only or factory_summary is not None, "--identity-only requires --factory-dir")
if reset_runtime:
images.append(Image("reset-runtime", NVS_OFFSET, b"\xff" * NVS_SIZE, "explicit --reset-runtime"))
images.sort(key=lambda image: image.offset)
previous_end = 0
for image in images:
end = (image.offset + len(image.data) + SECTOR - 1) // SECTOR * SECTOR
require(image.offset % SECTOR == 0 and image.offset >= previous_end and end <= FLASH_SIZE,
"Flash regions or their erase sectors overlap")
previous_end = end
return Plan(images, app_info, factory_summary, identity_only, reset_runtime)
class Esptool:
def __init__(self, python, port, baud, runner=subprocess.run):
self.python, self.port, self.baud, self.runner = python, port, baud, runner
self.major = 4
self.first = True
def invoke(self, args):
result = self.runner([self.python, "-X", "utf8", "-m", "esptool"] + args,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace", check=False)
if result.returncode:
raise FlashError(f"esptool failed ({result.returncode}); no later commands were run:\n{result.stdout}")
return result.stdout
def check_version(self):
output = self.invoke(["version"])
match = re.search(r"(?<!\d)([45])\.\d+(?:\.\d+)?", output)
require(bool(match), "Install esptool 4.x or 5.x in the selected Python environment")
self.major = int(match.group(1))
def command(self, command, *args):
before = "default_reset" if self.first else "no_reset"
self.first = False
prefix = ["--chip", "esp32s3", "--port", self.port, "--baud", str(self.baud),
"--before", before, "--after", "no_reset"]
if self.major == 5:
prefix = [x.replace("_", "-") if x in ("default_reset", "no_reset") else x for x in prefix]
command = command.replace("_", "-")
args = tuple(x.replace("_", "-") if x.startswith("--flash_") else x for x in args)
return self.invoke(prefix + [command] + list(args))
def save_json(path, value):
with path.open("x", encoding="utf-8") as output:
json.dump(value, output, ensure_ascii=True, indent=2)
output.write("\n")
os.chmod(path, 0o600)
def execute_plan(plan, *, python, port, expect_mac, backup_dir, baud=460800, runner=subprocess.run):
require(plan.identity_only or plan.reset_runtime,
"Full USB firmware flashing requires --reset-runtime to remove stale OTA version/journal metadata; "
"Wi-Fi must be provisioned again. For an ordinary update preserving settings, use cloud OTA.")
require(bool(port) and not port.startswith("-") and "\0" not in port, "An explicit serial port is required")
require(baud in (115200, 230400, 460800, 921600), "Unsupported baud rate")
expected = normalize_mac(expect_mac)
if plan.factory:
require(plan.factory["expected_mac"] == expected, "Factory manifest MAC mismatch")
backup_dir = Path(backup_dir).resolve()
require(not backup_dir.exists(), "Backup directory already exists; select a new private directory")
tool = Esptool(python, port, baud, runner)
tool.check_version()
private_directory(backup_dir)
save_json(backup_dir / "plan.json", plan.summary())
# Capture exact validated bytes, avoiding input mutation between planning and writing.
staged = []
for image in plan.images:
target = backup_dir / (image.name + ".bin")
with target.open("xb") as output:
output.write(image.data)
os.chmod(target, 0o600)
staged.extend([hex(image.offset), str(target)])
log = []
def command(name, *args):
try:
output = tool.command(name, *args)
except FlashError as exc:
log.append({"command": name, "error": str(exc)})
raise
log.append({"command": name, "output": output})
print(f"Completed {name}", flush=True)
return output
try:
probe = command("read_mac")
macs = re.findall(r"(?im)^\s*MAC:\s*([0-9a-f]{2}(?::[0-9a-f]{2}){5})\b", probe)
require({normalize_mac(mac) for mac in macs} == {expected},
"Connected device MAC does not match --expect-mac")
require(bool(re.search(r"ESP32[- ]?S3", probe, re.I)), "Connected device is not ESP32-S3")
flash = command("flash_id")
require(bool(re.search(r"Detected flash size:\s*16\s*MB\b", flash, re.I)), "Connected device flash is not 16MB")
security = command("get_security_info")
require(bool(re.search(r"Secure Boot:\s*Disabled", security, re.I))
and bool(re.search(r"Flash Encryption:\s*Disabled", security, re.I)),
"Secure Boot / flash encryption is enabled or could not be confirmed disabled")
backup = backup_dir / "before-16mb.bin"
command("read_flash", "0x0", hex(FLASH_SIZE), str(backup))
require(backup.is_file() and backup.stat().st_size == FLASH_SIZE, "Incomplete full-flash backup; refusing to write")
original = backup.read_bytes()
os.chmod(backup, 0o600)
save_json(backup_dir / "backup-manifest.json", {
"schema_version": 1, "created_at": datetime.now(timezone.utc).isoformat(),
"expected_mac": expected, "chip": "esp32s3", "flash_size": FLASH_SIZE,
"file": backup.name, "size": len(original), "sha256": sha256(original),
})
table = original[TABLE_OFFSET:TABLE_OFFSET + 0xC00]
if table == b"\xff" * 0xC00:
require(not plan.identity_only and plan.factory is not None,
"Blank partition table needs full firmware and --factory-dir")
else:
partition_table(table)
preserved = []
for name, offset, size, enabled in (
("preserved-runtime", NVS_OFFSET, NVS_SIZE, not plan.reset_runtime),
("preserved-factory", FACTORY_OFFSET, FACTORY_SIZE, plan.factory is None)):
if enabled:
target = backup_dir / (name + ".bin")
with target.open("xb") as output:
output.write(original[offset:offset + size])
os.chmod(target, 0o600)
preserved.extend([hex(offset), str(target)])
# Recheck staged bytes immediately before the first destructive command.
for index, image in enumerate(plan.images):
require(Path(staged[2 * index + 1]).read_bytes() == image.data, "Staged image changed; refusing to write")
command("write_flash", "--flash_mode", "keep", "--flash_size", "keep", "--flash_freq", "keep", *staged)
command("verify_flash", *(staged + preserved))
result = {"status": "written_and_verified", "expected_mac": expected,
"backup_dir": str(backup_dir),
"firmware_version": None if plan.identity_only else plan.application["firmware_version"],
"firmware_action": "preserved" if plan.identity_only else "written",
"hardware_acceptance": "not_tested", "next_step": "Manually reset device to boot firmware"}
save_json(backup_dir / "result.json", result)
return result
finally:
save_json(backup_dir / "esptool-log.json", log)
def parser():
result = argparse.ArgumentParser(description=__doc__)
result.add_argument("command", nargs="?", default="plan", choices=("plan", "ports", "flash"))
result.add_argument("--build-dir", type=Path, default=PROJECT / "build")
result.add_argument("--factory-dir", type=Path)
result.add_argument("--identity-only", action="store_true")
result.add_argument("--reset-runtime", action="store_true", help="Required for full USB firmware writes; erase ordinary NVS including Wi-Fi, binding, session and OTA metadata")
result.add_argument("--execute", action="store_true")
result.add_argument("--port")
result.add_argument("--expect-mac")
result.add_argument("--backup-dir", type=Path)
result.add_argument("--python", default=sys.executable, help="Python with esptool 4.x or 5.x and pyserial installed")
result.add_argument("--baud", type=int, choices=(115200, 230400, 460800, 921600), default=460800)
return result
def main(argv=None):
cli = parser()
args = cli.parse_args(argv)
try:
require(not args.execute or args.command == "flash", "--execute is only valid with the flash command")
if args.command == "ports":
code = ("import json; from serial.tools import list_ports; "
"print(json.dumps([{'port':p.device,'description':p.description,'hwid':p.hwid} "
"for p in list_ports.comports()],ensure_ascii=True,indent=2))")
subprocess.run([args.python, "-X", "utf8", "-c", code], check=True)
return 0
plan = build_plan(args.build_dir, args.factory_dir, args.identity_only, args.reset_runtime, args.expect_mac)
if args.command == "flash":
require(args.execute and args.port and args.expect_mac and args.backup_dir,
"flash requires --execute --port --expect-mac --backup-dir; use plan for an offline preview")
result = execute_plan(plan, python=args.python, port=args.port, expect_mac=args.expect_mac,
backup_dir=args.backup_dir, baud=args.baud)
else:
result = {"status": "plan_only_no_hardware_access", **plan.summary()}
print(json.dumps(result, ensure_ascii=True, indent=2))
return 0
except (ValueError, OSError, subprocess.SubprocessError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,162 @@
"""Convert the online burn-in JSON to a private EchoEar factory NVS image.
No network or serial access. No cloud credentials or Wi-Fi belong in this image.
"""
import argparse
import csv
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import subprocess
import sys
from urllib.parse import urlsplit
from ble_factory import security_material
FACTORY_OFFSET = 0xE4E000
FACTORY_SIZE = 0x6000
def normalize_mac(value):
compact = value.replace(":", "").replace("-", "")
if not re.fullmatch(r"[0-9a-fA-F]{12}", compact) or compact.lower() in ("000000000000", "ffffffffffff"):
raise ValueError("Expected MAC must contain exactly 12 hex digits")
return ":".join(compact[i:i + 2] for i in range(0, 12, 2)).lower()
def read_credential(path):
if path.stat().st_size > 16384:
raise ValueError("Credential JSON exceeds 16 KiB")
try:
item = json.loads(path.read_text(encoding="utf-8-sig"))
except (ValueError, UnicodeError):
raise ValueError("Invalid credential JSON; download the burn-in file again") from None
if not isinstance(item, dict):
raise ValueError("Credential must be a JSON object")
factory_id = item.get("factory_id", item.get("device_id"))
if item.get("factory_id") and item.get("device_id") and item["factory_id"] != item["device_id"]:
raise ValueError("Conflicting factory_id and device_id")
if not isinstance(factory_id, str) or not re.fullmatch(r"KP01-[0-9A-F]{12}", factory_id):
raise ValueError("Invalid factory_id; use the online burn-in JSON")
# The cloud ID is random: it is deliberately NOT compared to the eFuse MAC.
model = item.get("model")
if model != "ECHOEAR-S3":
raise ValueError("This tool supports model ECHOEAR-S3 only")
key = item.get("device_key_hex")
if not isinstance(key, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", key):
raise ValueError("device_key_hex must be 32 bytes in hexadecimal")
version = item.get("key_version")
if type(version) is not int or not 1 <= version <= 0xFFFFFFFF:
raise ValueError("Invalid key_version")
origin = item.get("origin")
if not isinstance(origin, str) or not origin.isascii() or len(origin) > 150 or any(c.isspace() for c in origin):
raise ValueError("Invalid HTTPS origin")
try:
url = urlsplit(origin)
port = url.port
except ValueError:
raise ValueError("Invalid HTTPS origin") from None
if (url.scheme != "https" or not url.hostname or url.username or url.password or
url.path or url.query or url.fragment or "\\" in origin or
not re.fullmatch(r"[A-Za-z0-9.:-]+", url.netloc) or (port is not None and not 1 <= port <= 65535)):
raise ValueError("origin must be HTTPS scheme + host + optional port, without a path")
return {"device_id": factory_id, "model": model, "origin": origin,
"key_version": version, "device_key_hex": key.lower()}
def private_directory(path):
"""Create a new directory and restrict access before writing secrets."""
path.mkdir(parents=True, exist_ok=False, mode=0o700)
if os.name == "nt":
who = subprocess.run(["whoami", "/user", "/fo", "csv", "/nh"],
capture_output=True, check=True)
sid = re.search(rb"S-1-[0-9-]+", who.stdout)
if not sid:
raise ValueError("Cannot determine the current Windows user SID")
subprocess.run(["icacls", str(path), "/inheritance:r", "/grant:r",
"*" + sid.group().decode("ascii") + ":(OI)(CI)F"],
capture_output=True, check=True)
else:
os.chmod(path, 0o700)
def private_json(path, value):
with path.open("x", encoding="utf-8", newline="\n") as stream:
json.dump(value, stream, ensure_ascii=False, indent=2)
stream.write("\n")
os.chmod(path, 0o600)
def prepare(credential, output, expected_mac):
item = read_credential(credential)
expected_mac = normalize_mac(expected_mac)
output = output.resolve()
# Fail on a missing generator before creating a partially populated directory.
import importlib.util
if importlib.util.find_spec("esp_idf_nvs_partition_gen") is None:
raise ValueError("Missing esp-idf-nvs-partition-gen; use the ESP-IDF Python environment")
private_directory(output)
password, salt = secrets.token_urlsafe(16), secrets.token_bytes(16)
verifier = security_material(item["device_id"], password, salt)
csv_path = output / "factory.csv"
rows = [("identity", "namespace", "", ""),
("factory_id", "data", "string", item["device_id"]),
("model", "data", "string", item["model"]),
("origin", "data", "string", item["origin"]),
("key_version", "data", "u32", item["key_version"]),
("device_key", "data", "hex2bin", item["device_key_hex"]),
("salt", "data", "hex2bin", salt.hex()),
("verifier", "data", "hex2bin", verifier.hex())]
with csv_path.open("x", encoding="utf-8", newline="") as stream:
writer = csv.writer(stream)
writer.writerow(("key", "type", "encoding", "value"))
writer.writerows(rows)
os.chmod(csv_path, 0o600)
image_path = output / "ble_factory.bin"
# Never print generator output: failures could contain a CSV value/secret.
generated = subprocess.run([sys.executable, "-m", "esp_idf_nvs_partition_gen", "generate",
str(csv_path), str(image_path), hex(FACTORY_SIZE)], capture_output=True)
if generated.returncode:
raise ValueError("NVS generation failed; private partial output was kept, choose a new output directory on retry")
image = image_path.read_bytes()
os.chmod(image_path, 0o600)
if len(image) != FACTORY_SIZE:
raise ValueError("Generator returned an unexpected factory partition size")
private_json(output / "setup-label.json", {"device_id": item["device_id"],
"model": item["model"], "setup_password": password})
manifest = {"schema_version": 1, "chip": "esp32s3", "flash_size": 0x1000000,
"device_id": item["device_id"], "model": item["model"],
"origin": item["origin"], "key_version": item["key_version"],
"expected_mac": expected_mac,
"image": {"file": "ble_factory.bin", "offset": FACTORY_OFFSET,
"size": FACTORY_SIZE, "sha256": hashlib.sha256(image).hexdigest()}}
private_json(output / "manifest.json", manifest)
return manifest
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--credential", required=True, type=Path, help="Online *-burn-in-vN.json")
parser.add_argument("--output", required=True, type=Path, help="NEW private directory (refuses overwrite)")
parser.add_argument("--expect-mac", required=True, help="Physical board eFuse MAC, not the cloud factory ID")
args = parser.parse_args()
try:
manifest = prepare(args.credential, args.output, args.expect_mac)
except ValueError as exc:
# Validation messages are constant strings; they never embed supplied values.
print("Preparation failed: " + str(exc), file=sys.stderr)
return 1
except (OSError, subprocess.SubprocessError):
print("Preparation failed. Use ESP-IDF Python and a NEW writable output folder; check file permissions. Existing private files were kept.", file=sys.stderr)
return 1
print(json.dumps({"result": "prepared_not_flashed", "device_id": manifest["device_id"],
"origin": manifest["origin"], "expected_mac": manifest["expected_mac"],
"output": str(args.output.resolve())}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,807 @@
// Build with the Windows .NET Framework C# compiler; no third-party UI packages.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
using System.Windows.Forms;
namespace EchoEarFlasher
{
internal static class Program
{
[STAThread]
private static int Main(string[] args)
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
bool offline = args.Length == 2 && (args[0] == "--render-preview" || args[0] == "--ui-smoke-test");
using (FlashForm form = new FlashForm(offline))
{
if (offline)
{
if (args[0] == "--render-preview") form.RenderPreview(args[1]);
else return form.RunSmokeTest(args[1]);
return 0;
}
if (args.Length != 0) return 2;
Application.Run(form);
}
return 0;
}
}
internal sealed class PortItem
{
internal string Port;
internal string Description;
internal bool Known;
public override string ToString() { return Port + " · " + Description; }
}
internal sealed class BridgeResult
{
internal int ExitCode;
internal Dictionary<string, object> Payload;
internal string Error;
}
internal sealed class FlashForm : Form
{
private static readonly Color Navy = Color.FromArgb(21, 40, 65);
private static readonly Color Teal = Color.FromArgb(0, 126, 128);
private static readonly Color TextColor = Color.FromArgb(35, 51, 69);
private static readonly Color Muted = Color.FromArgb(106, 122, 137);
private static readonly Color Line = Color.FromArgb(224, 233, 238);
private const string KnownMac = "B4:3A:45:16:34:A8";
private const string AdminUrl = "https://82.157.201.114/maintenance?area=devices";
private readonly bool offline;
private readonly JavaScriptSerializer json = new JavaScriptSerializer();
private readonly List<Control> inputs = new List<Control>();
private readonly List<Panel> cards = new List<Panel>();
private string bridgePath;
private string sessionFile;
private string lastBackup;
private Dictionary<string, object> previewSummary;
private bool busy;
private bool loading;
private Panel workspace;
private FlowLayoutPanel stack;
private Panel advancedCard;
private TextBox credential;
private TextBox mac;
private TextBox build;
private TextBox python;
private TextBox output;
private ComboBox ports;
private RadioButton identity;
private RadioButton full;
private CheckBox acknowledged;
private Button refresh;
private Button previewButton;
private Button flashButton;
private Button advancedToggle;
private Button backupButton;
private Label sidebarPort;
private Label sidebarStage;
private Label stageDetail;
private Label status;
private Label summaryLine;
private Label summaryPlatform;
private Label summaryFirmware;
private Label previewBadge;
private RichTextBox log;
private DataGridView regions;
private ProgressBar progress;
internal FlashForm(bool offlineMode)
{
offline = offlineMode;
loading = true;
Text = "EchoEar 设备烧录助手";
Font = new Font("Microsoft YaHei UI", 9F);
BackColor = Color.FromArgb(242, 246, 249);
ForeColor = TextColor;
ClientSize = new Size(1120, 838);
MinimumSize = new Size(920, 650);
StartPosition = FormStartPosition.CenterScreen;
AutoScaleMode = AutoScaleMode.Dpi;
BuildSidebar();
workspace = new Panel { Dock = DockStyle.Fill, Padding = new Padding(24, 20, 18, 14) };
Controls.Add(workspace);
workspace.BringToFront();
Panel heading = new Panel { Dock = DockStyle.Top, Height = 74 };
heading.Controls.Add(LabelAt("设备初始化与固件烧录", 0, 0, 730, 34, 21, Navy, true));
heading.Controls.Add(LabelAt("导入后台烧录信息,确认设备身份,再备份和写入。", 1, 41, 730, 25, 10, Muted, false));
stack = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, WrapContents = false, AutoScroll = true, Padding = new Padding(0, 3, 2, 8) };
workspace.Controls.Add(stack);
workspace.Controls.Add(heading);
BuildConfiguration();
BuildAdvanced();
BuildPreview();
BuildExecution();
FindDefaults();
WireChanges();
loading = false;
Resize += delegate { ResizeCards(); };
Shown += async delegate { ResizeCards(); if (!offline) await RefreshPorts(); };
FormClosing += delegate(object sender, FormClosingEventArgs e) {
if (busy) { e.Cancel = true; SetStatus("操作正在进行,请等待结束后关闭窗口。", false); }
};
ResizeCards();
UpdateActions();
}
private Label LabelAt(string text, int x, int y, int width, int height, float size, Color color, bool bold)
{
return new Label { Text = text, Location = new Point(x, y), Size = new Size(width, height), Font = new Font("Microsoft YaHei UI", size, bold ? FontStyle.Bold : FontStyle.Regular), ForeColor = color, AutoEllipsis = true, TextAlign = ContentAlignment.MiddleLeft };
}
private Button ButtonAt(string text, int x, int y, int width, bool primary)
{
Button button = new Button { Text = text, Location = new Point(x, y), Size = new Size(width, 34), FlatStyle = FlatStyle.Flat, BackColor = primary ? Teal : Color.White, ForeColor = primary ? Color.White : TextColor, Cursor = Cursors.Hand, UseVisualStyleBackColor = false };
button.FlatAppearance.BorderColor = primary ? Teal : Line;
button.FlatAppearance.BorderSize = 1;
return button;
}
private TextBox InputAt(Panel parent, int x, int y, int width)
{
TextBox input = new TextBox { Location = new Point(x, y), Width = width, BorderStyle = BorderStyle.FixedSingle, Font = new Font("Microsoft YaHei UI", 9.5F), BackColor = Color.White, Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right };
parent.Controls.Add(input);
inputs.Add(input);
return input;
}
private Panel Card(int height)
{
Panel panel = new Panel { BackColor = Color.White, Height = height, Width = 816, Margin = new Padding(0, 0, 0, 12) };
panel.Paint += delegate(object sender, PaintEventArgs e) { using (Pen p = new Pen(Line)) e.Graphics.DrawRectangle(p, 0, 0, panel.Width - 1, panel.Height - 1); };
cards.Add(panel);
stack.Controls.Add(panel);
return panel;
}
private void BuildSidebar()
{
Panel side = new Panel { Dock = DockStyle.Left, Width = 222, Height = ClientSize.Height, BackColor = Navy };
side.Controls.Add(LabelAt("ECHOEAR", 24, 27, 176, 32, 22, Color.White, true));
side.Controls.Add(LabelAt("设备烧录助手", 25, 69, 175, 24, 11, Color.FromArgb(177, 204, 221), false));
Panel accent = new Panel { BackColor = Color.FromArgb(54, 196, 182), Location = new Point(25, 113), Size = new Size(37, 4) };
side.Controls.Add(accent);
side.Controls.Add(LabelAt("当前设备", 25, 149, 172, 24, 9, Color.FromArgb(161, 181, 201), false));
side.Controls.Add(LabelAt("EchoEar S3", 25, 176, 174, 30, 17, Color.White, true));
side.Controls.Add(LabelAt("ESP32-S3 / 16 MB", 25, 210, 176, 28, 10, Color.FromArgb(205, 220, 232), false));
sidebarPort = LabelAt("串口 · 尚未选择", 25, 247, 176, 28, 10, Color.FromArgb(87, 216, 198), true);
side.Controls.Add(sidebarPort);
side.Controls.Add(LabelAt("操作状态", 25, 314, 172, 24, 9, Color.FromArgb(161, 181, 201), false));
sidebarStage = LabelAt("等待导入", 25, 345, 174, 32, 17, Color.White, true);
side.Controls.Add(sidebarStage);
stageDetail = LabelAt("选择后台下载的 JSON\n然后检查并生成预览。", 25, 387, 172, 76, 10, Color.FromArgb(205, 220, 232), false);
stageDetail.AutoEllipsis = false;
side.Controls.Add(stageDetail);
Label flow = LabelAt("01 导入与检查\n\n02 备份与写入\n\n03 重启与配网", 25, 503, 173, 136, 10, Color.FromArgb(178, 201, 218), false);
side.Controls.Add(flow);
Button admin = ButtonAt("打开设备管理后台 ↗", 24, 700, 174, false);
admin.Anchor = AnchorStyles.Left | AnchorStyles.Bottom;
admin.BackColor = Color.FromArgb(31, 57, 82);
admin.ForeColor = Color.White;
admin.FlatAppearance.BorderColor = Color.FromArgb(57, 80, 101);
admin.Location = new Point(24, ClientSize.Height - 97);
admin.Click += delegate { OpenTarget(AdminUrl); };
side.Controls.Add(admin);
Label foot = LabelAt("本地操作 · 密钥不显示", 25, ClientSize.Height - 50, 175, 24, 9, Color.FromArgb(161, 181, 201), false);
foot.Anchor = AnchorStyles.Left | AnchorStyles.Bottom;
side.Controls.Add(foot);
Controls.Add(side);
}
private void BuildConfiguration()
{
Panel card = Card(213);
card.Controls.Add(LabelAt("1 选择烧录内容", 18, 11, 350, 27, 12, Navy, true));
identity = new RadioButton { Text = "重新初始化(保留固件)", Checked = true, Location = new Point(19, 47), Size = new Size(235, 28) };
full = new RadioButton { Text = "完整固件 + 初始化", Location = new Point(272, 47), Size = new Size(224, 28) };
inputs.Add(identity); inputs.Add(full);
card.Controls.Add(identity); card.Controls.Add(full);
card.Controls.Add(LabelAt("烧录信息", 19, 91, 80, 26, 9, Muted, false));
credential = InputAt(card, 104, 93, 586);
Button browse = ButtonAt("选择 JSON", 704, 88, 94, false);
browse.Anchor = AnchorStyles.Top | AnchorStyles.Right;
browse.Click += delegate { ChooseFile(credential, "后台烧录信息 (*.json)|*.json", "选择后台下载的烧录信息 JSON"); };
inputs.Add(browse); card.Controls.Add(browse);
card.Controls.Add(LabelAt("设备串口", 19, 135, 80, 26, 9, Muted, false));
ports = new ComboBox { Location = new Point(104, 136), Size = new Size(315, 28), DropDownStyle = ComboBoxStyle.DropDownList, Anchor = AnchorStyles.Top | AnchorStyles.Left };
inputs.Add(ports); card.Controls.Add(ports);
refresh = ButtonAt("刷新", 432, 131, 70, false);
refresh.Click += async delegate { await RefreshPorts(); };
inputs.Add(refresh); card.Controls.Add(refresh);
card.Controls.Add(LabelAt("设备 MAC", 517, 135, 79, 26, 9, Muted, false));
mac = InputAt(card, 600, 136, 197);
mac.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
mac.Text = KnownMac;
card.Controls.Add(LabelAt("连接设备 USB;自动识别已知设备,其余串口请手动选择。", 104, 171, 682, 27, 9, Muted, false));
}
private void BuildAdvanced()
{
advancedCard = Card(39);
advancedToggle = ButtonAt("▸ 高级设置:固件目录、Python、备份位置", 10, 2, 510, false);
advancedToggle.FlatAppearance.BorderSize = 0;
advancedToggle.TextAlign = ContentAlignment.MiddleLeft;
advancedToggle.Click += delegate {
bool expanding = advancedCard.Height < 100;
advancedCard.Height = expanding ? 188 : 39;
advancedToggle.Text = (expanding ? "▾" : "▸") + " 高级设置:固件目录、Python、备份位置";
ResizeCards();
};
inputs.Add(advancedToggle); advancedCard.Controls.Add(advancedToggle);
advancedCard.Controls.Add(LabelAt("固件目录", 19, 48, 80, 26, 9, Muted, false));
build = InputAt(advancedCard, 104, 50, 586);
Button buildBrowse = ButtonAt("选择目录", 704, 45, 94, false);
buildBrowse.Anchor = AnchorStyles.Top | AnchorStyles.Right;
buildBrowse.Click += delegate { ChooseFolder(build, "选择包含 flasher_args.json 的固件 build 目录"); };
inputs.Add(buildBrowse); advancedCard.Controls.Add(buildBrowse);
advancedCard.Controls.Add(LabelAt("Python", 19, 91, 80, 26, 9, Muted, false));
python = InputAt(advancedCard, 104, 93, 586);
Button pythonBrowse = ButtonAt("选择程序", 704, 88, 94, false);
pythonBrowse.Anchor = AnchorStyles.Top | AnchorStyles.Right;
pythonBrowse.Click += delegate { ChooseFile(python, "Python 程序 (python.exe)|python.exe|应用程序 (*.exe)|*.exe", "选择已安装 esptool、pyserial 的 Python"); };
inputs.Add(pythonBrowse); advancedCard.Controls.Add(pythonBrowse);
advancedCard.Controls.Add(LabelAt("备份位置", 19, 134, 80, 26, 9, Muted, false));
output = InputAt(advancedCard, 104, 136, 586);
Button outputBrowse = ButtonAt("选择目录", 704, 131, 94, false);
outputBrowse.Anchor = AnchorStyles.Top | AnchorStyles.Right;
outputBrowse.Click += delegate { ChooseFolder(output, "选择备份与初始化文件的保存位置"); };
inputs.Add(outputBrowse); advancedCard.Controls.Add(outputBrowse);
}
private void BuildPreview()
{
Panel card = Card(231);
card.Controls.Add(LabelAt("2 检查写入预览", 18, 10, 350, 28, 12, Navy, true));
previewBadge = LabelAt("尚未生成", 669, 12, 128, 24, 9, Muted, true);
previewBadge.TextAlign = ContentAlignment.MiddleRight;
previewBadge.Anchor = AnchorStyles.Top | AnchorStyles.Right;
card.Controls.Add(previewBadge);
summaryLine = LabelAt("设备编号 — 型号 —", 19, 44, 779, 24, 9, TextColor, false);
summaryPlatform = LabelAt("平台地址 —", 19, 70, 779, 24, 9, Muted, false);
summaryFirmware = LabelAt("固件 保留设备当前固件", 19, 96, 779, 24, 9, Teal, true);
foreach (Label label in new[] { summaryLine, summaryPlatform, summaryFirmware }) { label.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; card.Controls.Add(label); }
regions = new DataGridView { Location = new Point(19, 128), Size = new Size(778, 83), Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right, BackgroundColor = Color.White, BorderStyle = BorderStyle.None, ReadOnly = true, AllowUserToAddRows = false, AllowUserToDeleteRows = false, AllowUserToResizeRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, SelectionMode = DataGridViewSelectionMode.FullRowSelect, CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal, GridColor = Line, EnableHeadersVisualStyles = false, ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.DisableResizing, ColumnHeadersHeight = 27, MultiSelect = false };
regions.ColumnHeadersDefaultCellStyle = new DataGridViewCellStyle { BackColor = Color.FromArgb(242, 246, 249), ForeColor = Muted, Font = new Font("Microsoft YaHei UI", 8.5F) };
regions.DefaultCellStyle = new DataGridViewCellStyle { BackColor = Color.White, ForeColor = TextColor, SelectionBackColor = Color.FromArgb(225, 244, 241), SelectionForeColor = TextColor, Font = new Font("Microsoft YaHei UI", 8.5F), Padding = new Padding(2) };
regions.RowTemplate.Height = 26;
regions.Columns.Add("name", "写入区域"); regions.Columns.Add("offset", "起始地址"); regions.Columns.Add("size", "大小"); regions.Columns.Add("hash", "SHA-256 校验摘要");
regions.Columns[0].FillWeight = 26; regions.Columns[1].FillWeight = 19; regions.Columns[2].FillWeight = 19; regions.Columns[3].FillWeight = 36;
card.Controls.Add(regions);
}
private void BuildExecution()
{
Panel card = Card(239);
acknowledged = new CheckBox { Text = "我已了解:会清除旧 Wi-Fi 和语音运行配置,完成后需重新配网。", Location = new Point(19, 12), Size = new Size(775, 29), Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right };
acknowledged.CheckedChanged += delegate { UpdateActions(); };
inputs.Add(acknowledged); card.Controls.Add(acknowledged);
previewButton = ButtonAt("1 检查并生成预览", 19, 50, 178, false);
previewButton.Click += async delegate { await PreparePreview(); };
card.Controls.Add(previewButton);
flashButton = ButtonAt("2 开始烧录", 210, 50, 178, true);
flashButton.Enabled = false;
flashButton.Click += async delegate { await Flash(); };
card.Controls.Add(flashButton);
backupButton = ButtonAt("打开备份文件夹", 639, 50, 158, false);
backupButton.Anchor = AnchorStyles.Top | AnchorStyles.Right;
backupButton.Click += delegate {
string path = String.IsNullOrEmpty(lastBackup) ? output.Text.Trim() : lastBackup;
if (Directory.Exists(path)) OpenTarget(path);
else
{
string parent = Path.GetDirectoryName(path);
string available = Directory.Exists(parent) ? parent : output.Text.Trim();
if (Directory.Exists(available)) OpenTarget(available);
SetStatus("当前操作尚未生成备份;已打开可用的会话或保存目录。", false);
}
};
card.Controls.Add(backupButton);
progress = new ProgressBar { Location = new Point(19, 97), Size = new Size(778, 4), Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right, Style = ProgressBarStyle.Blocks, Value = 0, MarqueeAnimationSpeed = 0 };
card.Controls.Add(progress);
status = LabelAt("准备就绪。检查只生成预览,不会写入设备。", 19, 109, 778, 27, 9, Muted, false);
status.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
card.Controls.Add(status);
log = new RichTextBox { Location = new Point(19, 146), Size = new Size(778, 75), Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right, ReadOnly = true, BorderStyle = BorderStyle.None, BackColor = Color.FromArgb(246, 248, 250), ForeColor = Muted, Font = new Font("Microsoft YaHei UI", 8.5F), DetectUrls = false, ScrollBars = RichTextBoxScrollBars.Vertical };
card.Controls.Add(log);
}
private void ResizeCards()
{
if (stack == null) return;
// Configuration remains readable; horizontal scrolling is available at small sizes/high DPI.
int width = Math.Max(816, stack.ClientSize.Width - 23);
stack.SuspendLayout();
foreach (Panel panel in cards) panel.Width = width;
stack.ResumeLayout();
}
private void FindDefaults()
{
string executableRoot = AppDomain.CurrentDomain.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
string distributedBridge = Path.Combine(executableRoot, "engine", "flash_app_bridge.py");
bool distributed = File.Exists(distributedBridge);
if (distributed) bridgePath = distributedBridge;
else
{
DirectoryInfo cursor = new DirectoryInfo(executableRoot);
for (int depth = 0; cursor != null && depth < 8; depth++, cursor = cursor.Parent)
{
string candidate = Path.Combine(cursor.FullName, "flash_app_bridge.py");
if (File.Exists(candidate)) { bridgePath = candidate; break; }
candidate = Path.Combine(cursor.FullName, "tools", "flash_app_bridge.py");
if (File.Exists(candidate)) { bridgePath = candidate; break; }
}
}
string projectRoot = bridgePath == null ? executableRoot : Directory.GetParent(Path.GetDirectoryName(bridgePath)).FullName;
build.Text = Path.Combine(distributed ? executableRoot : projectRoot, distributed ? "firmware-build" : "build");
output.Text = distributed ? Path.Combine(executableRoot, "backups") : Path.Combine(projectRoot, "backups", "gui");
python.Text = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".espressif", "python_env", "idf5.5_py3.12_env", "Scripts", "python.exe");
string portablePython = Path.Combine(executableRoot, "python", "python.exe");
if (File.Exists(portablePython)) python.Text = portablePython;
}
private void WireChanges()
{
foreach (TextBox input in new[] { credential, mac, build, python, output }) input.TextChanged += delegate { InvalidatePreview(); };
identity.CheckedChanged += delegate { InvalidatePreview(); };
full.CheckedChanged += delegate { InvalidatePreview(); };
ports.SelectedIndexChanged += delegate {
PortItem port = ports.SelectedItem as PortItem;
sidebarPort.Text = port == null ? "串口 · 尚未选择" : "串口 · " + port.Port;
InvalidatePreview();
};
}
private void InvalidatePreview()
{
if (loading) return;
bool wasValid = !String.IsNullOrEmpty(sessionFile);
sessionFile = null;
previewSummary = null;
acknowledged.Checked = false;
previewBadge.Text = "需要重新检查";
previewBadge.ForeColor = Muted;
summaryLine.Text = "设备编号 — 型号 —";
summaryPlatform.Text = "平台地址 —";
summaryFirmware.Text = identity.Checked ? "固件 保留设备当前固件" : "固件 检查后显示待写入版本;文件校验通过不等于真机验收";
regions.Rows.Clear();
if (wasValid) SetStatus("配置已更改,请重新检查并生成预览。", false);
UpdateActions();
}
private void UpdateActions()
{
if (flashButton == null) return;
flashButton.Enabled = !busy && !String.IsNullOrEmpty(sessionFile) && acknowledged.Checked;
flashButton.BackColor = flashButton.Enabled ? Teal : Color.FromArgb(223, 230, 235);
flashButton.ForeColor = flashButton.Enabled ? Color.White : Muted;
flashButton.FlatAppearance.BorderColor = flashButton.Enabled ? Teal : Color.FromArgb(210, 220, 227);
previewButton.Enabled = !busy;
backupButton.Enabled = !busy;
}
private void SetBusy(bool value, string stage)
{
busy = value;
foreach (Control input in inputs) input.Enabled = !value;
UseWaitCursor = value;
progress.Style = value ? ProgressBarStyle.Marquee : ProgressBarStyle.Blocks;
progress.MarqueeAnimationSpeed = value ? 35 : 0;
if (!value) progress.Value = 0;
if (!String.IsNullOrEmpty(stage)) sidebarStage.Text = stage;
UpdateActions();
}
private void SetStatus(string message, bool error)
{
status.Text = message;
status.ForeColor = error ? Color.FromArgb(175, 64, 47) : Muted;
}
private void AppendLog(string message)
{
if (log.TextLength > 28000) log.Clear();
log.AppendText(DateTime.Now.ToString("HH:mm:ss") + " " + message.Replace("\r", " ").Replace("\n", " ") + Environment.NewLine);
log.SelectionStart = log.TextLength;
log.ScrollToCaret();
}
private void ChooseFile(TextBox destination, string filter, string title)
{
using (OpenFileDialog dialog = new OpenFileDialog { Filter = filter, Title = title, CheckFileExists = true })
if (dialog.ShowDialog(this) == DialogResult.OK) destination.Text = dialog.FileName;
}
private void ChooseFolder(TextBox destination, string title)
{
using (FolderBrowserDialog dialog = new FolderBrowserDialog { Description = title, SelectedPath = Directory.Exists(destination.Text) ? destination.Text : "" })
if (dialog.ShowDialog(this) == DialogResult.OK) destination.Text = dialog.SelectedPath;
}
private void OpenTarget(string target)
{
try { Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); }
catch { SetStatus("无法打开,请在资源管理器或浏览器中手动打开。", true); }
}
private bool CheckEngine()
{
if (offline) { SetStatus("当前为离线界面验证模式,已禁用设备操作。", true); return false; }
if (String.IsNullOrEmpty(bridgePath) || !File.Exists(bridgePath)) { SetStatus("未找到烧录引擎 flash_app_bridge.py,请使用完整程序包。", true); return false; }
if (!File.Exists(python.Text.Trim()))
{
advancedCard.Height = 188;
advancedToggle.Text = "▾ 高级设置:固件目录、Python、备份位置";
SetStatus("请选择 Python 程序:高级设置中需使用已安装 esptool、pyserial 的环境。", true);
return false;
}
return true;
}
private async Task RefreshPorts()
{
if (busy || !CheckEngine()) return;
InvalidatePreview();
SetBusy(true, "识别串口");
SetStatus("正在枚举 USB 串口…", false);
BridgeResult result = await RunBridge("ports", new Dictionary<string, object>(), "ports");
if (result.ExitCode == 0 && result.Payload != null)
{
ports.Items.Clear();
PortItem known = null;
foreach (object entry in Items(result.Payload, "ports"))
{
Dictionary<string, object> item = entry as Dictionary<string, object>;
if (item == null) continue;
string serial = Regex.Replace(Get(item, "serial_number"), "[^a-fA-F0-9]", "").ToUpperInvariant();
int vid = 0; Int32.TryParse(Get(item, "vid"), out vid);
PortItem port = new PortItem { Port = Get(item, "port"), Description = Get(item, "description"), Known = vid == 0x303A && serial == "B43A451634A8" };
if (String.IsNullOrEmpty(port.Port)) continue;
if (port.Known) { known = port; port.Description += " · 已知设备"; }
ports.Items.Add(port);
}
if (known != null) ports.SelectedItem = known;
SetStatus(known != null ? "已识别已知设备。选择后台 JSON 后生成预览。" : (ports.Items.Count > 0 ? "找到串口,请手动选择目标设备并核对 MAC。" : "未发现串口,请检查 USB 线与设备连接后刷新。"), false);
AppendLog(known != null ? "已识别已知设备串口 " + known.Port + "。" : "串口列表已刷新;未自动选择未知设备。");
}
else ReportFailure(result, "串口识别失败");
SetBusy(false, "等待导入");
}
private async Task PreparePreview()
{
if (busy || !CheckEngine()) return;
PortItem selected = ports.SelectedItem as PortItem;
if (!File.Exists(credential.Text.Trim())) { SetStatus("请先选择后台下载的烧录信息 JSON。", true); return; }
if (selected == null) { SetStatus("请选择目标设备串口。", true); return; }
if (!Regex.IsMatch(mac.Text.Trim(), "^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$")) { SetStatus("设备 MAC 格式应为 B4:3A:45:16:34:A8。", true); return; }
if (!Directory.Exists(build.Text.Trim())) { SetStatus("固件参考目录不存在,请在高级设置中选择 build 目录。", true); return; }
if (String.IsNullOrWhiteSpace(output.Text)) { SetStatus("请选择备份保存位置。", true); return; }
Dictionary<string, object> request = new Dictionary<string, object> {
{ "credential_path", credential.Text.Trim() }, { "build_dir", build.Text.Trim() }, { "mode", identity.Checked ? "identity" : "full" },
{ "expected_mac", mac.Text.Trim().ToUpperInvariant() }, { "output_root", output.Text.Trim() }, { "port", selected.Port }
};
InvalidatePreview();
SetBusy(true, "检查信息");
SetStatus("正在检查烧录信息和文件,生成写入预览…", false);
AppendLog("开始检查。本步骤不会写入设备。");
BridgeResult result = await RunBridge("preview", request, "preview");
if (result.ExitCode == 0 && result.Payload != null)
{
Dictionary<string, object> summary = Dict(result.Payload, "summary");
string session = Get(result.Payload, "session_file");
if (summary == null || String.IsNullOrEmpty(session)) { result.Error = "烧录引擎未返回完整预览,请重新检查。"; ReportFailure(result, "检查失败"); }
else
{
ApplyPreview(session, summary);
SetStatus("预览已生成。请核对设备和区域,再勾选配网提示。", false);
AppendLog("预览检查完成;尚未开始备份或写入。");
}
}
else ReportFailure(result, "检查失败");
SetBusy(false, String.IsNullOrEmpty(sessionFile) ? "检查失败" : "预览就绪");
stageDetail.Text = String.IsNullOrEmpty(sessionFile) ? "根据下方提示修正后\n重新检查。" : "核对设备身份与写入内容。\n开始烧录前还会再次确认。";
}
private void ApplyPreview(string session, Dictionary<string, object> summary)
{
previewSummary = summary;
sessionFile = session;
// A new attempt must never send the user to an earlier device's backup.
lastBackup = Get(summary, "backup_dir");
acknowledged.Checked = false;
previewBadge.Text = "检查通过 · 待确认";
previewBadge.ForeColor = Teal;
summaryLine.Text = "设备编号 " + Get(summary, "device_id") + " 型号 " + Get(summary, "model");
summaryPlatform.Text = "平台 " + Get(summary, "origin") + " 密钥版本 " + Get(summary, "key_version");
summaryFirmware.Text = Get(summary, "mode") == "identity" ? "固件 保留设备当前固件 · 只更新身份并清除运行配置" : "固件 将写入 " + Get(summary, "firmware_version") + "(尚未实机验收)· 文件校验不代表真机验收";
regions.Rows.Clear();
foreach (object entry in Items(summary, "regions"))
{
Dictionary<string, object> region = entry as Dictionary<string, object>;
if (region == null) continue;
string hash = Get(region, "sha256");
string offset = Get(region, "offset");
long numeric; if (Int64.TryParse(offset, out numeric)) offset = "0x" + numeric.ToString("X");
string size = Get(region, "size");
if (Int64.TryParse(size, out numeric)) size = numeric.ToString("N0") + " B";
int index = regions.Rows.Add(Get(region, "name"), offset, size, hash.Length > 20 ? hash.Substring(0, 20) + "…" : hash);
regions.Rows[index].Cells[3].ToolTipText = hash;
}
regions.ClearSelection();
UpdateActions();
}
private async Task Flash()
{
if (offline || busy || String.IsNullOrEmpty(sessionFile) || previewSummary == null || !acknowledged.Checked) return;
string confirmation = "请确认即将操作的设备:\n\n串口:" + Get(previewSummary, "port") + "\n目标 MAC:" + Get(previewSummary, "expected_mac") + "\n设备编号:" + Get(previewSummary, "device_id") + "\n模式:" + (Get(previewSummary, "mode") == "identity" ? "重新初始化,保留现有固件" : "完整固件与初始化") + "\n\n会先读取实际 MAC 并与目标核对,完整备份后才写入。\n会清除旧 Wi-Fi 和语音运行配置,完成后需重新配网。\n\n操作期间请勿拔掉 USB 或断电。是否开始?";
if (MessageBox.Show(this, confirmation, "确认设备并开始烧录", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2) != DialogResult.Yes) return;
string approvedSession = sessionFile;
SetBusy(true, "准备烧录");
SetStatus("正在核对设备并备份,请保持 USB 连接…", false);
stageDetail.Text = "按实际步骤显示状态。\n操作期间请保持连接。";
AppendLog("用户已确认。开始核对硬件身份、备份和写入。");
BridgeResult result = await RunBridge("flash", new Dictionary<string, object> { { "session_file", approvedSession }, { "execute", true } }, "complete");
InvalidatePreview();
if (IsVerifiedCompletion(result))
{
Dictionary<string, object> completed = Dict(result.Payload, "result");
lastBackup = Get(completed, "backup_dir");
SetBusy(false, "烧录完成");
stageDetail.Text = "请手动重启设备。\n随后在家庭端重新蓝牙配网。";
previewBadge.Text = "烧录完成"; previewBadge.ForeColor = Teal;
SetStatus("完成并校验通过。请手动重启设备,再通过家庭端蓝牙重新配网。", false);
AppendLog("备份、写入和校验完成。请手动重启设备并重新配网。");
MessageBox.Show(this, "烧录与校验已完成。\n\n1. 手动重启设备。\n2. 在家庭端选择老人,重新进行蓝牙配网。\n3. 使用 2.4 GHz Wi-Fi,按设备提示完成确认。\n\n备份可通过“打开备份文件夹”查看。", "设备已准备好", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
else
{
if (result.ExitCode == 0 && String.IsNullOrEmpty(result.Error)) result.Error = "引擎未确认写入与校验成功完成,必须重新检查设备状态。";
ReportFailure(result, "烧录未完成");
SetBusy(false, "烧录失败");
stageDetail.Text = "保留设备连接并查看提示。\n重试前需要重新生成预览。";
}
}
private static bool IsVerifiedCompletion(BridgeResult result)
{
return result != null && result.ExitCode == 0 && String.IsNullOrEmpty(result.Error) &&
Get(result.Payload, "event") == "complete" && Get(Dict(result.Payload, "result"), "status") == "written_and_verified";
}
private void ReportFailure(BridgeResult result, string heading)
{
string message = String.IsNullOrEmpty(result.Error) ? "引擎未成功完成(退出码 " + result.ExitCode + ")。请检查环境、USB 连接和文件后重试。" : result.Error;
SetStatus(heading + ":" + message, true);
AppendLog(heading + ":" + message);
}
private async Task<BridgeResult> RunBridge(string command, Dictionary<string, object> request, string expectedEvent)
{
string pythonPath = python.Text.Trim();
string engine = bridgePath;
return await Task.Run(delegate {
BridgeResult result = new BridgeResult { ExitCode = -1 };
string requestFile = Path.Combine(Path.GetTempPath(), "echoear-request-" + Guid.NewGuid().ToString("N") + ".json");
try
{
// Request data contains paths and the public MAC only; never copy credential JSON here.
File.WriteAllText(requestFile, json.Serialize(request), new UTF8Encoding(false));
ProcessStartInfo start = new ProcessStartInfo {
FileName = pythonPath, Arguments = QuoteWindows("-u") + " " + QuoteWindows(engine) + " " + QuoteWindows(command) + " --request " + QuoteWindows(requestFile),
WorkingDirectory = Path.GetDirectoryName(engine), UseShellExecute = false, CreateNoWindow = true,
RedirectStandardOutput = true, RedirectStandardError = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8
};
start.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8";
using (Process process = new Process { StartInfo = start })
{
process.Start();
// Drain stderr without exposing raw dependency output that could contain credentials.
Task stderr = Task.Run(delegate { while (process.StandardError.ReadLine() != null) { } });
string line;
while ((line = process.StandardOutput.ReadLine()) != null)
{
if (line.Length > 1024 * 1024) { result.Error = "烧录引擎返回了过大的响应。"; continue; }
Dictionary<string, object> data;
try { data = new JavaScriptSerializer().Deserialize<Dictionary<string, object>>(line); }
catch { result.Error = "烧录引擎响应格式错误;原始输出已隐藏。"; continue; }
if (data == null) continue;
string kind = Get(data, "event");
if (kind == "phase")
{
string message = Get(data, "message");
string stage = Get(data, "stage");
Ui(delegate { SetStatus(message, false); AppendLog(message); sidebarStage.Text = StageName(stage); stageDetail.Text = message; });
}
else if (kind == expectedEvent) result.Payload = data;
else if (kind == "error") result.Error = Get(data, "message");
}
process.WaitForExit();
stderr.Wait();
result.ExitCode = process.ExitCode;
if (!String.IsNullOrEmpty(result.Error) || result.Payload == null) { result.Payload = null; if (result.ExitCode == 0) result.ExitCode = -1; }
}
}
catch { result.Error = "无法运行烧录引擎。请检查 Python 环境、程序文件以及目录读写权限。"; result.ExitCode = -1; result.Payload = null; }
finally { try { if (File.Exists(requestFile)) File.Delete(requestFile); } catch { } }
return result;
});
}
private void Ui(Action action)
{
if (IsDisposed || !IsHandleCreated) return;
try { BeginInvoke(action); } catch (InvalidOperationException) { }
}
private static string StageName(string stage)
{
switch (stage.ToLowerInvariant())
{
case "validating": return "检查信息";
case "preparing": return "生成预览";
case "ready": return "预览就绪";
case "checking": return "核对设备";
case "backup": return "完整备份";
case "writing": return "写入设备";
case "verifying": return "校验写入";
default: return "正在处理";
}
}
private static string QuoteWindows(string value)
{
if (value == null) value = "";
StringBuilder result = new StringBuilder("\"");
int backslashes = 0;
foreach (char character in value)
{
if (character == '\\') { backslashes++; continue; }
if (character == '"') { result.Append('\\', backslashes * 2 + 1); result.Append('"'); }
else { result.Append('\\', backslashes); result.Append(character); }
backslashes = 0;
}
result.Append('\\', backslashes * 2);
result.Append('"');
return result.ToString();
}
private static string Get(Dictionary<string, object> data, string key)
{
object value; return data != null && data.TryGetValue(key, out value) && value != null ? Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) : "";
}
private static Dictionary<string, object> Dict(Dictionary<string, object> data, string key)
{
object value; return data != null && data.TryGetValue(key, out value) ? value as Dictionary<string, object> : null;
}
private static IEnumerable Items(Dictionary<string, object> data, string key)
{
object value; return data != null && data.TryGetValue(key, out value) && value is IEnumerable ? (IEnumerable)value : new object[0];
}
private Dictionary<string, object> DemoSummary()
{
return new Dictionary<string, object> {
{ "mode", "identity" }, { "port", "COM6" }, { "expected_mac", KnownMac }, { "device_id", "DEMO-ECHOEAR-001(演示)" }, { "model", "ECHOEAR-S3" },
{ "origin", "https://82.157.201.114" }, { "key_version", 1 }, { "reset_runtime", true }, { "firmware_version", null }, { "backup_dir", @"C:\offline-demo\current-session\backup" },
{ "regions", new object[] {
new Dictionary<string, object> { { "name", "ble_factory" }, { "offset", "0xe4e000" }, { "size", 24576 }, { "sha256", "c38bf520abf240ae9e0b0000000000000000000000000000000000000000000000" } },
new Dictionary<string, object> { { "name", "reset-runtime" }, { "offset", "0x9000" }, { "size", 24576 }, { "sha256", "d18942e3789d23fdf64c0000000000000000000000000000000000000000000000" } }
} }
};
}
internal void RenderPreview(string path)
{
if (!offline) throw new InvalidOperationException("Offline only");
loading = true;
ports.Items.Add(new PortItem { Port = "COM6", Description = "EchoEar S3 · 演示", Known = true });
ports.SelectedIndex = 0;
credential.Text = @"C:\Users\demo\Downloads\echoear-device.json";
loading = false;
ApplyPreview("offline-demo-only", DemoSummary());
sidebarPort.Text = "串口 · COM6";
sidebarStage.Text = "预览就绪";
stageDetail.Text = "演示界面 · 未连接设备\n核对信息后再开始。";
SetStatus("演示界面:仅展示操作流程,未读取或写入任何设备。", false);
AppendLog("演示预览已生成。实际烧录前会核对 MAC 并完整备份。");
ClientSize = new Size(1120, 900);
// Native combo/grid rendering needs a visible handle. The offline-only window stays
// off screen, never activates, and does not appear in the taskbar or run the bridge.
ShowInTaskbar = false;
StartPosition = FormStartPosition.Manual;
Location = new Point(-30000, -30000);
IntPtr ignored = Handle;
CreateChildHandles(this);
Show();
PerformLayout();
ResizeCards();
Application.DoEvents();
string absolute = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(absolute));
using (Bitmap bitmap = new Bitmap(Width, Height)) { DrawToBitmap(bitmap, new Rectangle(0, 0, Width, Height)); bitmap.Save(absolute, ImageFormat.Png); }
Hide();
}
protected override bool ShowWithoutActivation { get { return offline; } }
private static void CreateChildHandles(Control parent)
{
foreach (Control child in parent.Controls) { child.CreateControl(); IntPtr handle = child.Handle; CreateChildHandles(child); }
}
internal int RunSmokeTest(string path)
{
List<object> checks = new List<object>();
bool passed = true;
Action<string, bool> check = delegate(string name, bool ok) { checks.Add(new Dictionary<string, object> { { "name", name }, { "passed", ok } }); passed &= ok; };
check("initial_flash_disabled", !flashButton.Enabled);
ApplyPreview("offline-session", DemoSummary());
check("preview_requires_acknowledgement", !flashButton.Enabled);
acknowledged.Checked = true;
check("acknowledged_preview_enables_flash", flashButton.Enabled);
credential.Text = "changed.json";
check("credential_change_invalidates_preview", sessionFile == null && !flashButton.Enabled && !acknowledged.Checked);
ApplyPreview("offline-session", DemoSummary()); acknowledged.Checked = true;
mac.Text = "B4:3A:45:16:34:A9";
check("mac_change_invalidates_preview", sessionFile == null && !flashButton.Enabled);
ApplyPreview("offline-session", DemoSummary()); acknowledged.Checked = true;
full.Checked = true;
check("mode_change_invalidates_preview", sessionFile == null && !flashButton.Enabled);
ApplyPreview("offline-session", DemoSummary()); acknowledged.Checked = true;
SetBusy(true, "测试");
check("busy_disables_actions_and_inputs", !flashButton.Enabled && !previewButton.Enabled && inputs.All(delegate(Control input) { return !input.Enabled; }));
FormClosingEventArgs close = new FormClosingEventArgs(CloseReason.UserClosing, false);
OnFormClosing(close);
check("busy_blocks_window_close", close.Cancel);
SetBusy(false, "测试完成");
check("idle_restores_inputs", inputs.All(delegate(Control input) { return input.Enabled; }));
InvalidatePreview();
check("retry_requires_new_preview", !flashButton.Enabled && sessionFile == null);
check("quotes_trailing_backslash", QuoteWindows("C:\\path with spaces\\") == "\"C:\\path with spaces\\\\\"");
check("quotes_embedded_quote", QuoteWindows("a\"b") == "\"a\\\"b\"");
Dictionary<string, object> verifiedResult = new Dictionary<string, object> {
{ "status", "written_and_verified" }, { "expected_mac", KnownMac }, { "backup_dir", @"C:\offline-demo\backup" },
{ "firmware_version", null }, { "firmware_action", "preserved" }, { "next_step", "reboot_and_provision" }
};
BridgeResult completion = new BridgeResult { ExitCode = 0, Payload = new Dictionary<string, object> { { "event", "complete" }, { "result", verifiedResult } } };
check("real_backend_completion_status_accepted", IsVerifiedCompletion(completion));
verifiedResult["status"] = "ok";
check("generic_success_status_rejected", !IsVerifiedCompletion(completion));
verifiedResult["status"] = "written_and_verified";
completion.ExitCode = 1;
check("nonzero_exit_rejects_complete_payload", !IsVerifiedCompletion(completion));
completion.ExitCode = 0;
completion.Error = "protocol_error";
check("error_rejects_complete_payload", !IsVerifiedCompletion(completion));
lastBackup = @"C:\offline-demo\previous-device\backup";
ApplyPreview("new-session", DemoSummary());
check("new_preview_replaces_previous_backup_target", lastBackup == @"C:\offline-demo\current-session\backup");
InvalidatePreview();
check("failed_attempt_retains_current_backup_target", lastBackup == @"C:\offline-demo\current-session\backup");
check("bridge_stages_map_to_real_phases", StageName("writing") == "写入设备" && StageName("preparing") == "生成预览" && StageName("checking") == "核对设备" && StageName("ready") == "预览就绪" && StageName("verifying") == "校验写入");
string absolute = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(absolute));
File.WriteAllText(absolute, json.Serialize(new Dictionary<string, object> { { "passed", passed }, { "checks", checks }, { "hardware_accessed", false }, { "background_processes_started", 0 } }), new UTF8Encoding(false));
return passed ? 0 : 1;
}
}
}
@@ -0,0 +1,47 @@
param(
[string]$OutputDirectory = (Join-Path $PSScriptRoot 'dist'),
[string]$BuildDirectory = (Join-Path $PSScriptRoot '..\..\build')
)
$ErrorActionPreference = 'Stop'
$firmwareRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
$sourcePath = Join-Path $PSScriptRoot 'EchoEarFlasher.cs'
$compiler = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'
if (-not (Test-Path -LiteralPath $compiler)) { throw 'Windows .NET Framework C# compiler is required.' }
$outputPath = [IO.Path]::GetFullPath($OutputDirectory)
$buildPath = (Resolve-Path -LiteralPath $BuildDirectory).Path
$manifestPath = Join-Path $buildPath 'flasher_args.json'
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
if ($manifest.extra_esptool_args.chip -ne 'esp32s3') { throw 'The build must target ESP32-S3.' }
$null = New-Item -ItemType Directory -Path $outputPath -Force
$enginePath = Join-Path $outputPath 'engine'
$packageBuild = Join-Path $outputPath 'firmware-build'
$null = New-Item -ItemType Directory -Path $enginePath -Force
$null = New-Item -ItemType Directory -Path $packageBuild -Force
# Allowlist only application code; never copy private credentials or backups.
foreach ($name in @('ble_factory.py', 'device_prepare.py', 'device_flash.py', 'flash_app_bridge.py')) {
Copy-Item -LiteralPath (Join-Path $firmwareRoot "tools\$name") -Destination (Join-Path $enginePath $name) -Force
}
Copy-Item -LiteralPath $manifestPath -Destination (Join-Path $packageBuild 'flasher_args.json') -Force
foreach ($entry in $manifest.flash_files.PSObject.Properties) {
$relative = [string]$entry.Value
if ([IO.Path]::IsPathRooted($relative) -or ($relative -split '[/\\]') -contains '..') { throw 'Unsafe image path.' }
$imagePath = [IO.Path]::GetFullPath((Join-Path $buildPath $relative))
if (-not $imagePath.StartsWith($buildPath.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) { throw 'Image escapes build directory.' }
$target = Join-Path $packageBuild $relative
$null = New-Item -ItemType Directory -Path (Split-Path -Parent $target) -Force
Copy-Item -LiteralPath $imagePath -Destination $target -Force
}
$executable = Join-Path $outputPath 'EchoEarFlasher.exe'
& $compiler /nologo /target:winexe /platform:x64 /optimize+ /codepage:65001 `
/r:System.dll /r:System.Core.dll /r:System.Drawing.dll /r:System.Windows.Forms.dll /r:System.Web.Extensions.dll `
"/out:$executable" $sourcePath
if ($LASTEXITCODE -ne 0) { throw 'Application compilation failed.' }
$guide = Join-Path $PSScriptRoot '使用说明.txt'
if (Test-Path -LiteralPath $guide) { Copy-Item -LiteralPath $guide -Destination $outputPath -Force }
$records = Get-ChildItem -LiteralPath $outputPath -File -Recurse | Where-Object { $_.FullName -ne (Join-Path $outputPath 'package-files.json') } | ForEach-Object {
[ordered]@{ file = $_.FullName.Substring($outputPath.Length + 1); size = $_.Length; sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() }
}
[ordered]@{ application = 'EchoEar 烧录助手'; hardware = 'ESP32-S3 / 16MB'; built_at = (Get-Date).ToString('o'); files = @($records) } |
ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $outputPath 'package-files.json') -Encoding UTF8
Write-Output "Built: $executable"
@@ -0,0 +1,45 @@
EchoEar 烧录助手 — Windows 图形界面
启动
双击 EchoEarFlasher.exe。engine 和 firmware-build 文件夹必须与程序放在一起。
这台电脑已具备所需 Python 环境,无需打开终端。
适用设备:原 COM6 的 EchoEar ESP32-S3,16MB Flash,MAC B4:3A:45:16:34:A8。
恢复迁移后连接失败的设备
1. 打开“线上设备管理”,用运维账号登录。
2. 创建设备,型号 ECHOEAR-S3,下载 KP01-…-burn-in-v1.json。
3. 在助手里选择该 JSON,确认串口 COM6 和硬件 MAC。
4. 模式保持“重新初始化(保留固件)”。
5. 点击检查/预览,核对出厂编号、平台地址、写入区域。
6. 确认将清除旧 Wi-Fi 和语音配置,点击开始烧录并在确认窗口核对目标。
7. 等待检查设备 → 完整16MB备份 → 写入 → 校验完成。
8. 成功后手动复位或断电重启,再到家庭端/小程序重新蓝牙配网与绑定老人。
预览只处理电脑上的文件,不打开设备串口。只有点击开始烧录并确认才会写设备。
烧录过程中不要拔线或关闭程序;界面显示实际阶段,不显示虚构的进度百分比。
失败时查看提示,备份保留;重新检查并预览后再重试。
完整固件模式
需要重新刷整机固件时,选择完整固件模式。它会写配套Bootloader、分区表、
应用、OTA启动信息、语音模型和屏幕资源,同时清理旧运行状态,之后必须重新配网。
本包包含项目现有1.7.0-controls.28构建:离线文件校验通过,尚未完成实机验收。
本次云端清库后的身份恢复推荐保留现有固件,无需升级到.28。
完整模式必须使用一整套构建目录,不能随意选择单个应用.bin作为整机镜像。
初始化信息
设备编号、型号、平台地址和设备密钥来自后台JSON,无需手填密钥。
家庭Wi-Fi、账号归属和老人通过家庭端/小程序配置;不要写入火山主账号AK/SK。
本次云端已经清空记录,因此可按新设备重新建立身份。对于仍在使用的其他设备,
本地清配置不会替代云端解绑,需先处理归属。
文件与依赖
生成的工厂身份、完整Flash备份含设备秘密,保存在界面指定的本地私有目录。
程序不会把密钥显示在界面日志,不会自动上传备份,不会执行整片擦除。
本版本使用电脑上已安装的Python运行烧录引擎,不是自带Python的独立运行包。
默认查找:%USERPROFILE%\.espressif\python_env\idf5.5_py3.12_env\Scripts\python.exe。
若换电脑,可在高级设置选择Python 3.10+解释器;该解释器需有
esptool 4.x/5.x、esp-idf-nvs-partition-gen、pyserial。
本机已验证依赖版本:esptool 4.12.0、esp-idf-nvs-partition-gen 0.1.9、pyserial 3.5。
备份目录及初始化材料请妥善保存,勿提交Git或公开分享。
程序和模拟测试通过不等于真机烧录验收;本次交付没有替用户执行烧录。
@@ -0,0 +1,26 @@
"""Migrate this backed-up COM6 board to A/B without erasing identity or Wi-Fi."""
import hashlib,json,pathlib,subprocess,sys
project=pathlib.Path(__file__).resolve().parents[1]
evidence=pathlib.Path('E:/Codex/story/.local-evidence/com6-controls-20260922')
backup=evidence/'before-controls-16mb.bin'
assert backup.stat().st_size==0x1000000
assert hashlib.sha256(backup.read_bytes()).hexdigest()=='df426d931d3208ea3714f3393e65c06de75da31606dd6668d9afa027e4d0a187'
tool='E:/Codex/toolchains/esp-idf-v5.5.1/components/esptool_py/esptool/esptool.py'
base=[sys.executable,tool,'--chip','esp32s3','--port','COM6','--baud','921600']
probe=subprocess.check_output(base+['read_mac'],text=True,stderr=subprocess.STDOUT)
assert 'b4:3a:45:16:34:a8' in probe.lower(),'wrong physical board'
files=[(0x0,'bootloader/bootloader.bin',0x8000),(0x8000,'partition_table/partition-table.bin',0x1000),
(0x10000,'VolcRTCDemo.bin',0x400000),(0x810000,'ota_data_initial.bin',0x2000),
(0x812000,'srmodels/srmodels.bin',0x80000)]
args=[];manifest=[]
for offset,relative,maximum in files:
path=project/'build'/relative;data=path.read_bytes()
assert 0<len(data)<=maximum
assert not (offset<0x10000 and offset+len(data)>0x9000),'NVS overlap'
assert offset+len(data)<=0x892000,'protected resource overlap'
manifest.append({'offset':hex(offset),'file':relative,'size':len(data),'sha256':hashlib.sha256(data).hexdigest()})
args += [hex(offset),str(path)]
(evidence/'flash-manifest.json').write_text(json.dumps(manifest,indent=2))
with (evidence/'flash-controls.log').open('w',encoding='utf-8') as log:
subprocess.run(base+['write_flash','--flash_mode','dout','--flash_size','16MB','--flash_freq','80m']+args,stdout=log,stderr=subprocess.STDOUT,check=True)
print(json.dumps({'result':'flashed_and_verified','board':'B4:3A:45:16:34:A8','regions':manifest,'protected':'NVS, storage, display resources, factory identity'}))
@@ -0,0 +1,316 @@
"""JSONL-only bridge for the Windows EchoEar flashing app.
``ports`` and ``preview`` never open a serial port. ``flash`` accepts only a
short-lived, one-use preview session and an explicit execute=true acknowledgement.
No credentials, setup passwords or raw subprocess output are sent to the GUI.
"""
from __future__ import annotations
import argparse
import contextlib
from datetime import datetime, timezone
import hashlib
import hmac
import io
import json
import os
from pathlib import Path
import re
import secrets
import subprocess
import sys
import time
import device_flash
import device_prepare
PROJECT = Path(__file__).resolve().parents[1]
DEFAULT_OUTPUT = PROJECT / "backups" / "gui"
SESSION_TTL = 30 * 60
SESSION_FIELDS = {"schema_version", "created_at", "created_epoch", "expires_epoch",
"credential_path", "build_dir", "mode", "expected_mac", "port",
"factory_dir", "backup_dir", "input_hashes", "plan_hash", "signature"}
PREVIEW_FIELDS = {"credential_path", "build_dir", "mode", "expected_mac", "port", "output_root"}
FLASH_FIELDS = {"session_file", "execute", "port", "expected_mac"}
MESSAGES = {
"invalid_request": "输入无效。请检查烧录信息 JSON、固件目录、COM 端口和设备 MAC。",
"invalid_credential": "烧录信息 JSON 无效,请从线上设备管理后台重新下载。",
"invalid_build": "固件文件校验失败。请选择完整的 EchoEar ESP32-S3 16MB 构建目录。",
"preparation_failed": "初始化信息生成失败。请检查 ESP-IDF Python 环境和输出目录权限,再重新预览。",
"stale_preview": "预览已过期、已使用或输入文件发生变化。请重新预览后再烧录。",
"confirmation_required": "尚未确认执行。请在界面中确认清除本地 Wi-Fi、语音、会话和 OTA 状态后再开始。",
"port_mismatch": "设备端口或 MAC 与预览不一致。请重新预览。",
"ports_failed": "串口列表读取失败。请检查 Python 环境是否安装 pyserial。",
"flash_failed": "烧录未完成。请检查 USB 连接、设备 MAC 和串口占用;已生成的备份及诊断记录已保留。重新尝试前请重新预览。",
"internal_error": "操作失败,未向界面输出敏感诊断内容。请检查运行环境和文件权限。",
}
STAGES = {
"version": ("checking", "检查 esptool 版本"),
"read_mac": ("checking", "连接设备并核对芯片与 MAC"),
"flash_id": ("checking", "核对 16MB Flash 容量"),
"get_security_info": ("checking", "核对设备安全配置"),
"read_flash": ("backup", "正在完整备份 16MB Flash,请保持 USB 连接"),
"write_flash": ("writing", "备份完成,正在写入,请保持 USB 连接"),
"verify_flash": ("verifying", "正在校验设备中写入的数据"),
}
class BridgeError(ValueError):
def __init__(self, code):
self.code = code
super().__init__(MESSAGES[code])
def require(condition, code="invalid_request"):
if not condition:
raise BridgeError(code)
def canonical(value):
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
def digest(value):
return hashlib.sha256(canonical(value)).hexdigest()
def safe_path(value, code="invalid_request"):
require(isinstance(value, str) and value and len(value) < 4096 and "\0" not in value, code)
return Path(value).expanduser().resolve()
def read_object(path, code="invalid_request"):
try:
require(path.is_file() and path.stat().st_size <= 65536, code)
value = json.loads(path.read_text(encoding="utf-8-sig"))
require(isinstance(value, dict), code)
return value
except (OSError, ValueError, UnicodeError):
raise BridgeError(code) from None
def normalize_port(value):
require(isinstance(value, str) and re.fullmatch(r"COM[1-9][0-9]{0,4}", value, re.I))
return value.upper()
def hidden_run(args, **kwargs):
if os.name == "nt":
kwargs["creationflags"] = kwargs.get("creationflags", 0) | subprocess.CREATE_NO_WINDOW
return subprocess.run(args, **kwargs)
class _HiddenSubprocess:
"""Keep existing preparation/ACL helpers quiet without changing their source."""
def run(self, args, **kwargs):
return hidden_run(args, **kwargs)
def __getattr__(self, name):
return getattr(subprocess, name)
@contextlib.contextmanager
def hidden_preparation():
original = device_prepare.subprocess
device_prepare.subprocess = _HiddenSubprocess()
try:
yield
finally:
device_prepare.subprocess = original
def input_snapshot(credential, build, factory=None):
"""Include every reference build image even when retaining existing firmware."""
manifest = device_flash.load_json(build / "flasher_args.json")
files = [credential, build / "flasher_args.json"]
for name in device_flash.REGIONS:
entry = device_flash.object_field(manifest, name)
files.append(device_flash.contained_file(build, entry.get("file")))
if factory is not None:
files.extend(factory / name for name in ("manifest.json", "ble_factory.bin", "factory.csv", "setup-label.json"))
return {str(path): hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(set(files))}
def phase(emit, stage, message):
emit({"event": "phase", "stage": stage, "message": message})
def enumerate_ports(request, emit):
require(not request)
try:
from serial.tools import list_ports
ports = []
for item in list_ports.comports():
if not re.fullmatch(r"COM[1-9][0-9]{0,4}", item.device, re.I):
continue
ports.append({"port": item.device.upper(), **{
name: getattr(item, name, None) for name in
("description", "hwid", "serial_number", "manufacturer", "product", "vid", "pid")}})
ports.sort(key=lambda item: int(item["port"][3:]))
except Exception:
raise BridgeError("ports_failed") from None
emit({"event": "ports", "ports": ports})
def preview(request, emit):
require(set(request) <= PREVIEW_FIELDS)
phase(emit, "validating", "离线检查初始化信息和固件文件,不连接设备")
credential = safe_path(request.get("credential_path"))
build = safe_path(request.get("build_dir", str(PROJECT / "build")))
mode = request.get("mode", "identity")
require(mode in ("identity", "full"))
port = normalize_port(request.get("port"))
try:
mac = device_flash.normalize_mac(request.get("expected_mac"))
except ValueError:
raise BridgeError("invalid_request") from None
output = safe_path(request.get("output_root", str(DEFAULT_OUTPUT)))
try:
require(credential.is_file() and credential.stat().st_size <= 16384, "invalid_credential")
credential_hash = hashlib.sha256(credential.read_bytes()).hexdigest()
info = device_prepare.read_credential(credential)
except (ValueError, OSError):
raise BridgeError("invalid_credential") from None
try:
original_inputs = input_snapshot(credential, build)
require(original_inputs[str(credential)] == credential_hash, "stale_preview")
device_flash.build_plan(build, reset_runtime=True)
except BridgeError:
raise
except (ValueError, OSError):
raise BridgeError("invalid_build") from None
phase(emit, "preparing", "生成本机私有初始化文件和烧录预览")
now = time.time()
created_at = datetime.fromtimestamp(now, timezone.utc).isoformat()
directory = output / (datetime.fromtimestamp(now, timezone.utc).strftime("%Y%m%dT%H%M%SZ-") + secrets.token_hex(6))
factory, backup = directory / "factory", directory / "backup"
try:
with hidden_preparation():
device_prepare.private_directory(directory)
device_prepare.prepare(credential, factory, mac)
plan = device_flash.build_plan(build, factory, mode == "identity", True, mac)
require(original_inputs == input_snapshot(credential, build), "stale_preview")
frozen_inputs = input_snapshot(credential, build, factory)
session = {"schema_version": 1, "created_at": created_at, "created_epoch": now,
"expires_epoch": now + SESSION_TTL, "credential_path": str(credential),
"build_dir": str(build), "mode": mode, "expected_mac": mac, "port": port,
"factory_dir": str(factory), "backup_dir": str(backup),
"input_hashes": frozen_inputs, "plan_hash": digest(plan.summary())}
integrity_key = secrets.token_bytes(32)
key_path = directory / "integrity.key"
with key_path.open("xb") as stream:
stream.write(integrity_key)
os.chmod(key_path, 0o600)
session["signature"] = hmac.new(integrity_key, canonical(session), hashlib.sha256).hexdigest()
session_file = directory / "session.json"
device_prepare.private_json(session_file, session)
except BridgeError:
raise
except Exception:
raise BridgeError("preparation_failed") from None
summary = {"mode": mode, "port": port, "expected_mac": mac, "device_id": info["device_id"],
"model": info["model"], "origin": info["origin"], "key_version": info["key_version"],
"firmware_version": None if mode == "identity" else plan.application["firmware_version"],
"reference_firmware_version": plan.application["firmware_version"], "reset_runtime": True,
"runtime_effect": "清除本地 Wi-Fi、语音、会话和 OTA 状态;完成后须重新蓝牙配网。",
"regions": [{key: value for key, value in image.summary().items() if key != "source"}
for image in plan.images],
"factory_dir": str(factory), "backup_dir": str(backup), "created_at": created_at,
"expires_in_seconds": SESSION_TTL}
phase(emit, "ready", "预览完成。确认清除配网信息后方可开始烧录")
emit({"event": "preview", "session_file": str(session_file), "summary": summary})
def load_session(path):
require(path.name == "session.json", "stale_preview")
session = read_object(path, "stale_preview")
require(set(session) == SESSION_FIELDS and session.get("schema_version") == 1, "stale_preview")
signature = session.pop("signature")
try:
key = (path.parent / "integrity.key").read_bytes()
require(len(key) == 32 and isinstance(signature, str), "stale_preview")
require(hmac.compare_digest(signature, hmac.new(key, canonical(session), hashlib.sha256).hexdigest()), "stale_preview")
require(type(session["created_epoch"]) in (int, float) and type(session["expires_epoch"]) in (int, float), "stale_preview")
now = time.time()
require(session["created_epoch"] - 60 <= now < session["expires_epoch"], "stale_preview")
require(session["expires_epoch"] - session["created_epoch"] == SESSION_TTL, "stale_preview")
require(session["mode"] in ("identity", "full"), "stale_preview")
require(normalize_port(session["port"]) == session["port"], "stale_preview")
require(device_flash.normalize_mac(session["expected_mac"]) == session["expected_mac"], "stale_preview")
require(safe_path(session["factory_dir"]) == path.parent / "factory", "stale_preview")
require(safe_path(session["backup_dir"]) == path.parent / "backup", "stale_preview")
require(not (path.parent / "execute.json").exists(), "stale_preview")
except (OSError, ValueError, TypeError, KeyError):
raise BridgeError("stale_preview") from None
return session
def flash(request, emit):
require(set(request) <= FLASH_FIELDS)
require(request.get("execute") is True, "confirmation_required")
path = safe_path(request.get("session_file"), "stale_preview")
session = load_session(path)
if "port" in request:
require(normalize_port(request["port"]) == session["port"], "port_mismatch")
if "expected_mac" in request:
try:
require(device_flash.normalize_mac(request["expected_mac"]) == session["expected_mac"], "port_mismatch")
except device_flash.FlashError:
raise BridgeError("port_mismatch") from None
phase(emit, "validating", "重新核对已确认的文件、端口、MAC 和烧录范围")
credential = safe_path(session["credential_path"], "stale_preview")
build = safe_path(session["build_dir"], "stale_preview")
factory = safe_path(session["factory_dir"], "stale_preview")
try:
require(input_snapshot(credential, build, factory) == session["input_hashes"], "stale_preview")
device_prepare.read_credential(credential)
plan = device_flash.build_plan(build, factory, session["mode"] == "identity", True, session["expected_mac"])
require(digest(plan.summary()) == session["plan_hash"], "stale_preview")
require(input_snapshot(credential, build, factory) == session["input_hashes"], "stale_preview")
# Exclusive creation consumes the session before even the first probe.
device_prepare.private_json(path.parent / "execute.json", {"started_at": datetime.now(timezone.utc).isoformat()})
except (ValueError, OSError):
raise BridgeError("stale_preview") from None
def runner(args, **kwargs):
for argument in args:
stage = STAGES.get(argument.replace("-", "_"))
if stage:
phase(emit, *stage)
break
return hidden_run(args, **kwargs)
try:
# Existing CLI progress and raw tool output are intentionally not GUI logs.
with hidden_preparation(), contextlib.redirect_stdout(io.StringIO()):
result = device_flash.execute_plan(plan, python=sys.executable, port=session["port"],
expect_mac=session["expected_mac"], backup_dir=Path(session["backup_dir"]), runner=runner)
except Exception:
raise BridgeError("flash_failed") from None
result["next_step"] = "烧录和校验完成。请手动重启设备,然后在家庭端重新蓝牙配网和绑定。"
emit({"event": "complete", "result": result})
def main(argv=None, output=None):
output = output or sys.stdout
def emit(value):
output.write(json.dumps(value, ensure_ascii=True, separators=(",", ":")) + "\n")
output.flush()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=("ports", "preview", "flash"))
parser.add_argument("--request", required=True, type=Path)
try:
args = parser.parse_args(argv)
request = read_object(args.request)
{"ports": enumerate_ports, "preview": preview, "flash": flash}[args.command](request, emit)
return 0
except BridgeError as exc:
emit({"event": "error", "code": exc.code, "message": MESSAGES[exc.code]})
return 2
except Exception:
emit({"event": "error", "code": "internal_error", "message": MESSAGES["internal_error"]})
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""Extract the fixed call-status glyphs into the GFX compatibility font ABI.
No font converter, network, LVGL headers, or rasterizer is used. The source is
the checked-in uncompressed 1bpp font; Pillow is needed only with --preview.
Run --check to verify the committed generated files without modifying them.
"""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "components/application/platform/src/espressif/lvgl_source/echoear_font.c"
SOURCE_SHA256 = "abfffdf0cd809cf2e3cd9425988b8945786fb5166bf4c5e91d2b6e1fb3839f74"
FONT_C = ROOT / "components/application/platform/src/espressif/lvgl_source/echoear_call_status_font.c"
FONT_H = ROOT / "components/application/platform/src/espressif/lvgl_source/echoear_call_status_font.h"
HOST_TEST = ROOT / "tools/test_call_status_font.c"
CHARACTERS = "?待机正在连接通话中结束失败断"
STATUSES = ["待机", "正在连接", "通话中", "正在结束", "通话结束", "连接失败", "连接中断"]
SCALE = 2
def array_body(source: str, name: str) -> str:
match = re.search(r"\b" + re.escape(name) + r"\[\]\s*=\s*\{(.*?)\};", source, re.S)
if not match:
raise ValueError(f"missing source array {name}")
return match.group(1)
def numbers(body: str) -> list[int]:
body = re.sub(r"/\*.*?\*/", "", body, flags=re.S)
return [int(value, 0) for value in re.findall(r"0[xX][0-9a-fA-F]+|\b\d+\b", body)]
def fields(body: str) -> dict[str, str]:
return dict(re.findall(r"\.(\w+)\s*=\s*([^,}\s]+)", body))
def bitmap_pixels(data: bytes, width: int, height: int) -> list[int]:
return [(data[index // 8] >> (7 - index % 8)) & 1 for index in range(width * height)]
def pack_pixels(pixels: list[int]) -> bytes:
packed = bytearray((len(pixels) + 7) // 8)
for index, bit in enumerate(pixels):
packed[index // 8] |= bit << (7 - index % 8)
return bytes(packed)
def pixel_hash(pixels: list[int]) -> int:
value = 2166136261
for bit in pixels:
value = ((value ^ (255 if bit else 0)) * 16777619) & 0xFFFFFFFF
return value
def extract() -> tuple[str, list[dict], int, int]:
raw = SOURCE.read_bytes()
digest = hashlib.sha256(raw).hexdigest()
if digest != SOURCE_SHA256:
raise ValueError(f"source font changed: {digest}; review before updating the pinned hash")
source = raw.decode("utf-8").replace("\r\n", "\n")
original_header = re.match(r"/\*.*?\*/", source, re.S).group(0)
if not re.search(r"\.bpp\s*=\s*1\s*,", source) or not re.search(r"\.bitmap_format\s*=\s*0\s*,", source):
raise ValueError("expected uncompressed 1bpp source font")
bitmap_body = array_body(source, "glyph_bitmap")
bitmap = bytes(numbers(bitmap_body))
descriptors = [{key: int(value, 0) for key, value in fields(body).items()}
for body in re.findall(r"\{([^{}]*)\}", array_body(source, "glyph_dsc"))]
cmaps = [fields(body) for body in re.findall(r"\{([^{}]*)\}", array_body(source, "cmaps"))]
tables: dict[str, list[int]] = {}
def table(name: str) -> list[int]:
if name not in tables:
tables[name] = numbers(array_body(source, name))
return tables[name]
def glyph_id(codepoint: int) -> int:
for cmap in cmaps:
offset = codepoint - int(cmap["range_start"])
if not 0 <= offset < int(cmap["range_length"]):
continue
start = int(cmap["glyph_id_start"])
kind = cmap["type"].removeprefix("LV_FONT_FMT_TXT_CMAP_")
if kind == "FORMAT0_TINY":
return start + offset
if kind == "FORMAT0_FULL":
return start + table(cmap["glyph_id_ofs_list"])[offset]
offsets = table(cmap["unicode_list"])
if offset not in offsets:
continue
index = offsets.index(offset)
if kind == "SPARSE_TINY":
return start + index
if kind == "SPARSE_FULL":
return start + table(cmap["glyph_id_ofs_list"])[index]
raise ValueError(f"unsupported source cmap {kind}")
raise ValueError(f"missing source glyph U+{codepoint:04X}")
# Independently cross-check the cmap result against converter bitmap comments.
labelled_offsets = {}
cursor = 0
for match in re.finditer(r"/\* U\+([0-9A-Fa-f]+).*?\*/(.*?)(?=/\* U\+|\Z)", bitmap_body, re.S):
labelled_offsets[int(match.group(1), 16)] = cursor
cursor += len(numbers(match.group(2)))
assert cursor == len(bitmap)
glyphs = []
for char in sorted(set(CHARACTERS)):
codepoint = ord(char)
gid = glyph_id(codepoint)
original = descriptors[gid]
assert original["bitmap_index"] == labelled_offsets[codepoint], f"cmap/comment mismatch for {char}"
width, height = original["box_w"], original["box_h"]
size = (width * height + 7) // 8
assert 0 < size and original["bitmap_index"] + size <= len(bitmap)
original_pixels = bitmap_pixels(bitmap[original["bitmap_index"]:][:size], width, height)
pixels = [original_pixels[(y // SCALE) * width + x // SCALE]
for y in range(height * SCALE) for x in range(width * SCALE)]
packed = pack_pixels(pixels)
assert bitmap_pixels(packed, width * SCALE, height * SCALE) == pixels
assert sum(pixels) == sum(original_pixels) * SCALE * SCALE > 0
glyphs.append({"char": char, "codepoint": codepoint, "source_glyph_id": gid,
"width": width * SCALE, "height": height * SCALE,
"ofs_x": original["ofs_x"] * SCALE, "ofs_y": original["ofs_y"] * SCALE,
# GFX reads adv_w as 1/256 px, unlike source LVGL's 1/16 px.
"advance": original["adv_w"] * SCALE * 16,
"bitmap": packed, "pixels": pixels, "pixel_hash": pixel_hash(pixels)})
line_height = int(re.search(r"\.line_height\s*=\s*(\d+)", source).group(1)) * SCALE
baseline = int(re.search(r"\.base_line\s*=\s*(\d+)", source).group(1)) * SCALE
assert line_height <= 42
for glyph in glyphs:
top = line_height - baseline - glyph["height"] - glyph["ofs_y"]
assert 0 <= top and top + glyph["height"] <= line_height
by_char = {glyph["char"]: glyph for glyph in glyphs}
for status in STATUSES:
assert len(status) <= 4
assert sum(max(by_char[c]["advance"] >> 8, by_char[c]["width"] + by_char[c]["ofs_x"]) for c in status) <= 240
return original_header, glyphs, line_height, baseline
def generated_files(original_header: str, glyphs: list[dict], line_height: int, baseline: int) -> dict[Path, str]:
comment = ("/* Generated by tools/generate_call_status_font.py; do not hand edit.\n"
f" * Source SHA256: {SOURCE_SHA256}\n"
" * 2x nearest-neighbor glyphs; packed 1bpp, GFX 1/256-pixel advance.\n"
" * The original source font's leading notice is preserved below.\n */\n")
c = [comment, original_header, '\n#ifdef LVGL_VERSION_MAJOR\n#error "Build this font in its own GFX-only translation unit"\n#endif\n#include "widget/gfx_font_lvgl.h"\n',
'#if LVGL_VERSION_MAJOR != 8 || LV_FONT_FMT_TXT_LARGE != 1\n#error "Unexpected GFX font compatibility ABI"\n#endif\n_Static_assert(sizeof(lv_font_fmt_txt_glyph_dsc_t) == 16, "GFX LARGE glyph ABI required");\n',
'static const uint8_t status_bitmap[] = {']
cursor = 0
for glyph in glyphs:
glyph["bitmap_index"] = cursor
c.append(f' /* U+{glyph["codepoint"]:04X} {glyph["char"]} */')
values = list(glyph["bitmap"])
for start in range(0, len(values), 24):
c.append(' ' + ','.join(str(byte) for byte in values[start:start + 24]) + ',')
cursor += len(values)
c += ['};\n', 'static const lv_font_fmt_txt_glyph_dsc_t status_glyphs[] = {', ' {0}, /* glyph zero is reserved */']
for g in glyphs:
c.append(f' {{{g["bitmap_index"]},{g["advance"]},{g["width"]},{g["height"]},{g["ofs_x"]},{g["ofs_y"]}}}, /* {g["char"]} */')
c += ['};\n', '#define STATUS_CMAP(cp, id) { .range_start=(cp), .range_length=1, .glyph_id_start=(id), .unicode_list=NULL, .glyph_id_ofs_list=NULL, .list_length=0, .type=LV_FONT_FMT_TXT_CMAP_FORMAT0_TINY }',
'static const lv_font_fmt_txt_cmap_t status_cmaps[] = {']
c += [f' STATUS_CMAP(0x{g["codepoint"]:04X}, {index}),' for index, g in enumerate(glyphs, 1)]
c += ['};', '#undef STATUS_CMAP\n',
'static const lv_font_fmt_txt_dsc_t status_font_dsc = {',
' .glyph_bitmap=status_bitmap, .glyph_dsc=status_glyphs, .cmaps=status_cmaps,',
f' .kern_dsc=NULL, .kern_scale=0, .cmap_num={len(glyphs)}, .bpp=1,',
' .kern_classes=0, .bitmap_format=0, .cache=NULL', '};\n',
'static const lv_font_t status_font = {',
' .get_glyph_dsc=lv_font_get_glyph_dsc_fmt_txt, .get_glyph_bitmap=lv_font_get_bitmap_fmt_txt,',
f' .line_height={line_height}, .base_line={baseline}, .subpx=LV_FONT_SUBPX_NONE, .static_bitmap=1,',
' .underline_position=-6, .underline_thickness=2, .dsc=&status_font_dsc, .fallback=NULL, .user_data=NULL',
'};\n', 'const void *echoear_call_status_font(void) { return &status_font; }\n']
header = comment + '#pragma once\n#ifdef __cplusplus\nextern "C" {\n#endif\n/* GFX compatibility font; deliberately exposes no LVGL types. */\nconst void *echoear_call_status_font(void);\n#ifdef __cplusplus\n}\n#endif\n'
host = [comment, '#include <assert.h>\n#include <stdint.h>\n#include <stdio.h>\n#include "widget/gfx_font_internal.h"\n#include "echoear_call_status_font.h"\n',
'static const struct { uint32_t cp; int w, h, x, y, advance; uint32_t hash; } expected[] = {']
for g in glyphs:
host.append(f' {{0x{g["codepoint"]:04X},{g["width"]},{g["height"]},{g["ofs_x"]},{g["ofs_y"]},{g["advance"]},0x{g["pixel_hash"]:08X}}},')
host += ['};\n', 'int main(void) {', ' const void *font=echoear_call_status_font(); assert(gfx_is_lvgl_font(font));',
' gfx_font_ctx_t ctx={0}; gfx_font_lv_init_context(&ctx,font);',
f' assert(ctx.get_line_height(&ctx)=={line_height} && ctx.get_base_line(&ctx)=={baseline});',
' for (unsigned i=0;i<sizeof(expected)/sizeof(expected[0]);i++) {',
' gfx_glyph_dsc_t g={0}; assert(ctx.get_glyph_dsc(&ctx,&g,expected[i].cp,0));',
' assert(g.box_w==expected[i].w && g.box_h==expected[i].h);',
' assert(g.ofs_x==expected[i].x && g.ofs_y==expected[i].y && g.adv_w==(uint32_t)expected[i].advance);',
f' assert(g.bitmap_index+(g.box_w*g.box_h+7)/8 <= {cursor});',
' int top=ctx.adjust_baseline_offset(&ctx,&g); assert(top>=0 && top+g.box_h<=ctx.get_line_height(&ctx));',
' assert(ctx.get_advance_width(&ctx,&g)==32 || expected[i].cp==0x3F);',
' const uint8_t *bits=ctx.get_glyph_bitmap(&ctx,expected[i].cp,&g); assert(bits); uint32_t hash=2166136261u;',
' for(int y=0;y<g.box_h;y++) for(int x=0;x<g.box_w;x++) hash=(hash^ctx.get_pixel_value(&ctx,bits,x,y,g.box_w))*16777619u;',
' assert(hash==expected[i].hash);', ' }',
' /* Exhaustive Unicode lookup also exercises every cmap range-end boundary. */',
' for(uint32_t cp=0;cp<=0x10FFFF;cp++) {',
' int present=0; for(unsigned i=0;i<sizeof(expected)/sizeof(expected[0]);i++) present |= expected[i].cp==cp;',
' gfx_glyph_dsc_t g={0}; assert(ctx.get_glyph_dsc(&ctx,&g,cp,0)==(present!=0));', ' }',
' puts("GFX call-status font: ABI, coverage, exact pixels, metrics and Unicode boundaries PASS");', ' return 0;', '}\n']
return {FONT_C: '\n'.join(c), FONT_H: header, HOST_TEST: '\n'.join(host)}
def preview(path: Path, glyphs: list[dict], line_height: int, baseline: int) -> None:
from PIL import Image, ImageDraw
by_char = {g["char"]: g for g in glyphs}
panel = 360 # CONFIG_EXAMPLE_LCD_INTERFACE_QSPI: HW_LCD_H_RES / V_RES.
image = Image.new('RGB', (panel * 3, panel * 3), '#24272b')
draw = ImageDraw.Draw(image)
labels = STATUSES + ['?', '正在连接']
for index, label in enumerate(labels):
left, top = index % 3 * panel, index // 3 * panel
draw.ellipse((left, top, left + panel - 1, top + panel - 1), fill='#080b10', outline='#64707b')
draw.rectangle((left + 60, top + 58, left + 299, top + 99), outline='#28313b')
width = sum(max(by_char[c]['advance'] >> 8, by_char[c]['width'] + by_char[c]['ofs_x']) for c in label)
x, line_top = left + (panel - width) // 2, top + 58 + (42 - line_height) // 2
for char in label:
glyph = by_char[char]
y = line_top + line_height - baseline - glyph['height'] - glyph['ofs_y']
for py in range(glyph['height']):
for px in range(glyph['width']):
if glyph['pixels'][py * glyph['width'] + px]:
image.putpixel((x + glyph['ofs_x'] + px, y + py), (255, 255, 255))
x += max(glyph['advance'] >> 8, glyph['width'] + glyph['ofs_x'])
draw.text((left + 70, top + 286), f'status {index + 1} / 360x360 / 1:1 px', fill='#9faab6')
draw.text((left + 61, top + 306), 'GLYPH PREVIEW - NOT A SCREEN PHOTO', fill='#9faab6')
path.parent.mkdir(parents=True, exist_ok=True)
image.save(path)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true')
parser.add_argument('--preview', type=Path)
parser.add_argument('--report', type=Path)
args = parser.parse_args()
notice, glyphs, line_height, baseline = extract()
outputs = generated_files(notice, glyphs, line_height, baseline)
for path, text in outputs.items():
if args.check:
assert path.read_text(encoding='utf-8') == text, f'generated file differs: {path}'
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding='utf-8', newline='\n')
if args.preview:
preview(args.preview, glyphs, line_height, baseline)
report = {'source': str(SOURCE.relative_to(ROOT)), 'source_sha256': SOURCE_SHA256,
'glyph_count': len(glyphs), 'characters': ''.join(g['char'] for g in glyphs),
'statuses': STATUSES, 'bitmap_bytes': sum(len(g['bitmap']) for g in glyphs),
'line_height': line_height, 'base_line': baseline, 'scale': SCALE,
'files': {str(path.relative_to(ROOT)): {'bytes': path.stat().st_size, 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} for path in outputs},
'glyphs': [{key: value for key, value in g.items() if key not in ('pixels', 'bitmap')} for g in glyphs]}
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()

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