跳转至内容
  • 版块
  • 最新
  • 标签
  • 热门
  • 世界
  • 用户
  • 群组
皮肤
  • 浅色
  • 深色

折叠

半部电台

半部电台:用 ESP32-C6 做一台“附近通信”旋钮小设备——XidianFlipper 代码阅读笔记

已定时 已固定 已锁定 已移动 博客
ESP32-C6ESP-IDFBLE嵌入式
1 帖子 1 发布者 4 浏览
  • 从旧到新
  • 从新到旧
  • 最多赞同
回复
  • 在新帖中回复
登录后回复
此主题已被删除。只有拥有主题管理权限的用户可以查看。
  • N
    N
    niuguanchong
    编写于 最后由 编辑
    #1

    项目地址:qilaotie/XidianFlipper | 在线社区:半部小岛

    "半部电台起家"是西电校史里最常被提起的一句话。1931 年,红军第一部电台只能收、不能发,于是被叫作"半部电台"。这个项目借了这个名字,但它并不是一台真的无线电——它是一台基于 ESP32-C6 的旋钮小设备:靠 BLE 广播在几十米内和同伴互发消息、一起玩桌游、养一只电子宠物,顺便还能当 PPT 遥控器和 Wi-Fi 扫描器。

    本文是我读这个仓库源码后的笔记,重点放在它是怎么搭起来的,以及那些只有在真机上才会踩到的坑。


    一、硬件与工程基础

    项目 配置
    主控 ESP32-C6,4 MiB Flash
    屏幕 128×32 SSD1306 OLED(I2C,地址 0x78)
    交互 EC06 旋转编码器 + 独立按键 BACK
    声光 MCPWM 蜂鸣器、RMT 驱动 WS2812
    电源 GPIO4 集中管理负载开关 + ADC 电量检测
    工具链 ESP-IDF 5.5.4,CMake 工程

    工程按 firmware/(业务)与 components/(第三方与公共组件)两大块切分,业务目录再分为 app / config / drivers / protocol / radio / system / ui:

    firmware/
    ├── app/        # 消息、好友、Wi-Fi、无线工具、摩斯训练
    ├── config/     # constants.h / pins.h / defaults.h / log_config.h
    ├── drivers/    # ec06 编码器、rgb_led、rmt_ws2812、mcpwm_buzzer、adc_battery...
    ├── protocol/   # mesh_packet.c、mesh_crypto.c
    ├── radio/      # ble_radio.c、tool_hid.c
    ├── system/     # nvs_config、log_manager、power_mgmt、event_dispatcher
    └── ui/         # menu_pages、local_pages、party_pages、pet_page、dev_mode...
    components/
    ├── u8g2/                  # 图形库(含字模)
    ├── u8g2-hal-esp-idf/      # I2C bit-bang 回调
    └── oled_mirror/           # 把屏幕画面经 USB CDC 投到电脑
    

    初始化顺序是这份代码里最值得抄的部分

    firmware_init_all() 里每一步都有注释说明"为什么必须在这里",这不是装饰,是血泪:

    event_init();                 /* 1. 任何任务通信之前 */
    load_switch_init();           /* 1.5. GPIO4 总电源,任何外设之前 */
    log_manager_init();           /* 2.5. 事件之后、其他模块之前 */
    buzzer_init();  haptic_init();
    nvs_config_init();            /* 5. NVS 必须在 rgb_led 之前:
                                         否则 g_config 全零 → 亮度 0% */
    rgb_init();  rgb_led_init();
    ec06_init(&ec06_cfg);         /* 7. 编码器 ISR + 任务 */
    ...
    nvs_init();  ui_init();       /* OLED 提前亮,消除上电黑屏 */
    u8g2_Setup_ssd1306_i2c_128x32_univision_f(...);
    mesh_crypto_init();           /* 9. 加密必须在无线之前就绪 */
    ble_radio_init(mesh_notify_dispatch, NULL);  /* 10. NimBLE 主机任务 */
    quick_msg_init();  mesh_notify_init();
    

    几个细节:

    • 负载开关最先初始化。整块外设供电挂在 GPIO4 上,晚一步就是莫名其妙的 I2C 失败。
    • NVS 早于 RGB。配置从 Flash 读出来之前,结构体是零值,亮度按 0% 处理,用户看到的就是"灯不亮"。
    • OLED 抢先于 BLE。NimBLE 初始化会占用一截时间,先把屏幕点亮,用户感知到的开机时间就短了。
    • BLE 的致命错误有兜底:读 eFuse MAC 失败或设 BLE 地址失败时,红灯快闪 10 次、屏幕上画 FATAL ERR 0x01/0x02,然后死循环等看门狗。宁可停住,也不带着半残的协议栈跑。

    冷启动还有一段"开机图 2 秒 + 黑屏 1 秒"的过渡,而从 DeepSleep 按键唤醒时会跳过这段直接进 UI——g_wake_reason 判断一下就行,成本极低,体验差别很大。


    二、协议层:27 字节头 + 128 字节载荷

    firmware/protocol/mesh_packet.h 定义了整套私有协议 v3,线格式非常克制:

    #define MESH_MAGIC        0x4D45   /* 'M','E' */
    #define MESH_VERSION      3
    #define MESH_HEADER_SIZE  27
    #define MESH_PAYLOAD_MAX  128
    #define MESH_CRC_SIZE     2
    
    typedef struct {
        uint16_t magic;
        uint8_t  version;
        uint8_t  src_mac[6], dst_mac[6];   /* 用 MAC 直接寻址 */
        uint8_t  type, len, flags, channel;/* channel: 1~16 */
        uint32_t session_id, msg_id;       /* 会话 + 消息序号,做回执去重 */
        uint8_t  payload[MESH_PAYLOAD_MAX];
        uint16_t crc16;
    } mesh_packet_t;
    

    打包后最大 27 + 128 + 2 = 157 字节——刚好塞进 BLE 的传统广播/扩展广播里,不需要建连接。几个设计取舍:

    1. 用 MAC 当身份。src_mac/dst_mac 六字节,广播地址全 FF。好处是零配置、无路由表;代价是换设备就换身份,好友关系必须落 NVS(namespace = "mesh_cfg")。
    2. flags 一个字节塞了四件事:ACK_REQUEST、ROUTED、ANNOUNCEMENT、CUSTODY,再加两位跳数(MESH_HOP_MASK 0x0c,MESH_TOTAL_HOPS_MAX 3)。协议从"能通"进化到"能转发、能代存",头长度一字节没变。
    3. session_id + msg_id 撑起了回执与重试。发送状态机(已发 / 已收 / 超时)全靠这两个字段,MESH_TYPE_RECEIPT 就是回执报文。
    4. 18 种消息类型共享同一条管线:快捷消息、摩斯、好友请求、信标、回执、石头剪刀布、寻宝、反应赛、数字炸弹、宠物、协作、社交……加新玩法基本只是往枚举里加一个 MESH_TYPE_*。

    最值得称赞的是头文件第一行注释:

    /* BLE local messaging v3. Wire integers are little endian; CRC is not authentication. */

    CRC 不是认证。 这句话省掉了很多误解:这不是加密私聊,抓包就能读;游戏里也没有密码学防作弊。项目在 README、功能说明、工具说明里反复重申这一点——工程文档里肯把边界写清楚,比多堆十个功能更难得。


    三、通信载体:广播式"对讲",以及功耗的账

    设备之间不建 BLE 连接,全靠扫描广播收发。constants.h 里这几个数字决定了续航:

    #define SCAN_PERIOD_MS   5000   /* 扫描周期 5s */
    #define SCAN_WINDOW_MS   100    /* 扫描窗口 100ms → 2% 占空比 */
    #define COVER_ADV_MS     6000   /* 覆盖广播 6s ≥ 扫描周期 5s */
    #define BLE_ADV_INTERVAL_MIN_MS 100
    #define BLE_ADV_INTERVAL_MAX_MS 300
    
    • 扫描占空比只有 2%,所以平时几乎不耗电;代价是延迟——最坏要等一个扫描周期才收到消息。
    • COVER_ADV_MS > SCAN_PERIOD_MS 这条不等式是整个"对讲感"的关键:发送方要保证自己广播的持续时间覆盖住对方一整个扫描周期,否则就会出现"我明明发了对方没收到"的经典 bug。
    • DeepSleep 周期 5 秒,冷启动后留 15 秒宽限期禁止休眠——不然 USB 还没枚举完设备就睡了,串口工具上永远看不到设备。

    这套机制的能力边界也因此很明确:不需要路由器、SIM 卡或手机,但它也不是运营商短信,不是标准 Bluetooth Mesh。三跳中继是实验特性,默认关闭、重启不保留,要三台同版设备才能实测 A→B→C。


    四、界面:128×32 像素怎么装下一个系统

    屏幕只有 128×32,四行文字。项目直接移植了 WouoUI 的菜单框架(ui.layer / ui.state / ui.select[] / ui.fade),配合 u8g2 渲染。

    在这个尺寸上做 60 多个功能入口,靠的是三件事:

    • 分层菜单:主菜单按"消息 → 摩斯电台 → 学习 → 好友 → 趣玩 → 工具 → 设置 → 关于"排列,长描述走滚动。
    • 三套皮肤:经典像素、极简线框、终端,设置里切换、重启保留。
    • 中英双语开关:主菜单、设置、工具、趣玩全部双语,即时生效。中文用的是精简字库——128×32 的屏幕上,全量 CJK 点阵字模对 4 MiB Flash 来说并不划算。

    一个小而妙的组件:OLED Mirror

    components/oled_mirror 把每一帧画面通过 USB Serial/JTAG 发给电脑:

    usb_serial_jtag_driver_config_t usj_cfg = {
        .tx_buffer_size = 4096,   /* 整帧/日志/控制帧不被截断 */
        .rx_buffer_size = 256,
    };
    oled_mirror_begin(&u8g2, usb_cdc_send_callback);
    

    一帧 128×32/8 = 512 字节点阵,加 5 字节帧头正好 517 字节,所以 TX buffer 必须给到 4096 且写入超时只有 10 ms(USB 没插时静默丢弃,绝不阻塞渲染)。更妙的是它顺手 esp_log_set_vprintf() 接管了日志,让 ESP_LOGI 和画面走同一套帧格式——演示、答辩、录屏时不用举着放大镜看 128×32 的小屏。这个组件还单独做了 ENABLE_OLED_MIRROR 编译开关,不影响正式构建。


    五、功能盘点到什么程度

    不逐条罗列,只看"密度":

    • MESSAGES:快捷短语 10 条(单条上限 48 字符,见 QUICK_MESSAGE_MAX_LEN)、机身 ASCII 编辑、字符表情、六槽持久草稿箱、频道(1–16)、好友定向、收件箱与发送状态。
    • 摩斯:实时收发、自由练习、听音选字、跟练、听写。所有时序阈值都随 WPM 动态计算(morse_engine_set_wpm()),只保留固定值:20 ms 消抖、±30% 教学容差、600–900 ms 字母间隔、1500 ms 空闲自动发送、5000 ms TX 回 RX。
    • FUN 14 个入口:宠物、竖屏俄罗斯方块、贪吃蛇、答案之书、骰子、花旗骰、附近石头剪刀布、寻宝雷达、反应赛、数字炸弹、1–10 秒盲计时、玩法说明、灯光蜂鸣合奏、旅行护照。
    • 多人桌游 10 款:UNO、谁是卧底、达芬奇密码、骗子酒馆、情书、快艇骰子、阿瓦隆、一夜狼、狼人杀、井字棋。统一"创建 → 图形准备房间 → 开局进牌桌 → 结算回原房间重新准备"的流程,还支持多桌大厅、只读观战、邀请。
    • 宠物:四属性(饱食/心情/精力/清洁)、四阶段成长、三成年分支、七个内部小游戏、金币商店、背包装饰图鉴、双机拜访与对战、房间皮肤。没有死亡设定,喂食清洁睡觉全免费。
    • TOOLS 11 项:PPT/媒体/拍照 HID 遥控、Wi-Fi 连接、云同步、Wi-Fi 扫描、BLE/RSSI 扫描、内存状态、实验性自组网、网络诊断、队伍与社交。

    注意 HID 遥控期间电台通信会暂停——BLE 主机和外设角色抢同一套射频资源,这是硬约束,写在了手册里。


    六、最硬的一仗:分区迁移

    这是整个项目里我最想单独讲的部分,因为它是不可逆操作。

    原版固件的分区是老三槽布局,单个应用槽只有 1,245,184 字节。应用从 768,016 字节一路长到 1,527,808(当前发布包 1,739,152),三槽布局再也装不下:

    指标 冻结原版 迁移后
    单应用槽 1,245,184 B 2,097,152 B(2 MiB)
    应用镜像 768,016 B 1,739,152 B
    分区方案 旧三槽 + OTA factory-2m-usb-v1,取消两个 OTA 槽
    更新方式 OTA / USB 仅 USB

    关键约束是两处 NVS 的地址和大小绝对不能动:

    • nvs:0x9000 / 0x6000
    • nvs_sec:0x3b0000 / 0x6000

    因为它们存着 NVS v7 配置、storage ABI 1 的 UI 参数、宠物数据、游戏最高分和好友列表。工程里 docs/partition_migration.md 和 AGENTS.md 把流程钉死成几条:迁移前必须双读校验整片 Flash 备份 → 迁移工具必须验证设备身份、旧布局和备份 → 不能只刷分区表 → 完成后整片回读确认未授权区域没被写。

    结果也记录得很实在:COM9、COM10、COM15 三台设备分别完成双读整片备份、2 MiB 迁移和完整回读,确认 bootloader 未替换(COM15 因旧 bootloader 不同另行批准更新)、两处 NVS 及其他未授权区域不变。

    代价换取:没有 OTA 了。这很痛,但 factory 0x20000 / 0x200000 的单槽方案让应用可用空间从 1.2 MiB 涨到 2 MiB,功能密度才成为可能。工程在 README 里明确写了"迁移后仅支持 USB 更新,不支持 OTA",没有含糊。


    七、工程习惯:把"没验证"写进文档

    这份仓库最不像"学生作业"的地方,是它对自己的验收口径极其苛刻:

    • 构建产物带指纹:输出到 build/release/,包含 manifest.json、镜像、源码记录、validation/build.log 和 SHA-256,python tools/verify.py 独立核验。README 直接给应用哈希 21c39ff9...a916f3,不靠"我记得我编过"。
    • 模拟与实机分开报告:AGENTS.md 里那句"宿主模拟测试和实机验收分开报告;不得把模拟帧率、断电或回滚结果表述为实机通过",是很多项目缺的一条纪律。发布清单里 hardware_tested: false 一直保留着,哪怕已经上机测过一部分。
    • 能力边界逐条列出:RSSI 只表示信号冷热、不给方向距离;拿到 IPv4 不等于能上网;KEY SENT 不等于真的拍了照;云分享关闭不等于撤下,必须执行 REMOVE ONLINE。
    • 版本号不自作主张:扩展版固件字符串仍是 1.0.0-noautosleep,靠发行清单和 SHA-256 区分构建,明确"不把本扩展版误称为 V1.5"。

    顺带一个工程上的小坑:components/u8g2/csrc/u8g2_fonts.c 有 40 MB 源码,几万个字模。ESP-IDF 默认 -ffunction-sections -fdata-sections + 链接期 --gc-sections 会丢掉没引用的段,所以最终镜像不会爆炸,但编译时间和仓库体积是真的贵——这也是它把完整 u8g2 直接 vendor 进来要付的账。


    八、它不是什么

    作者写得很清楚,我照抄一遍,因为这几句比功能列表更有价值:

    • 没有 IR、NFC/RFID、Sub-GHz,不是完整 Flipper Zero 替代品;
    • 消息不是加密私聊,游戏没有密码学防作弊;
    • Wi-Fi 凭据未启用 NVS 加密,不要输入真实敏感信息;
    • 不是运营商短信,不是标准 Bluetooth Mesh,也不是互联网电台;
    • 云排行榜是设备自报的,不是防作弊比赛。

    一台 4 MiB Flash、128×32 屏幕、靠一颗旋钮操作的小设备,做到"附近能聊、能一起玩十款桌游、能养宠物、能当遥控器",靠的不是堆料,而是把每一字节的协议头、每一次扫描占空比、每一段 NVS 地址都算清楚。


    九、可以带走的三条经验

    1. 初始化顺序就是需求。事件系统、电源开关、NVS、屏幕、协议栈,谁在谁前面都有理由;写成注释,下一个人(或三个月后的你)会感谢自己。
    2. 协议头要预留成长空间。v3 的 27 字节头从"点对点消息"长到"三跳中继 + 代存 + 回执",头长度一次没变,全靠 flags 里的两位跳数和四种标记。前期省一点,后期就能改。
    3. 不可逆操作要有备份和验收清单。分区迁移那套"双读备份 → 验证身份/旧布局 → 只改授权区域 → 整片回读比对",比任何功能都更像一个真正的工程项目。

    如果对"半部电台"感兴趣,仓库里的 docs/ 有完整手册:功能简介、使用手册、宠物手册、桌游适配、分区迁移、发布与构建。也可以去半部小岛看看设备自报的排行榜和宠物展示柜。

    半部电台起家,靠的从来不是设备多好,而是有人愿意把能收的那一半,慢慢做成能发的那一半。

    1 条回复 最后回复
    0

    你好!看起来您对这段对话很感兴趣,但您还没有一个账号。

    厌倦了每次访问都刷到同样的帖子?您注册账号后,您下次访问时都将自动回到上次浏览的位置,并可选择接收新回复的通知(通过电子邮件或推送通知)。您还可以收藏帖子、为帖子点赞,以此向其他社区成员表达您的感谢。

    有了你的建议,这篇帖子会更精彩哦 💗

    注册 登录

    欢迎来到实验室论坛!
    • 登录

    • 没有帐号? 注册

    • 登录或注册以进行搜索。
    • 第一个帖子
      最后一个帖子
    0
    • 版块
    • 最新
    • 标签
    • 热门
    • 世界
    • 用户
    • 群组