前置知识:了解 ESP32-C3 的基本能力(第 1 章),理解 Activity 框架(第 8 章),熟悉流式处理思想(第 10-11 章)。 本章目标:理解 CrossPoint Reader 如何利用 ESP32-C3 内置的 WiFi 实现 Web 传书、WebDAV 同步、在线书库浏览和跨设备阅读进度同步。

一台电子书阅读器如果只能通过 USB 传书,用起来会很不方便。CrossPoint Reader 充分利用了 ESP32-C3 内置的 WiFi 能力,把一颗 10 元的芯片变成了一台功能齐全的网络设备——它既是 Web 服务器,又是 WebDAV 服务器,还是 OPDS 客户端和 KOReader 同步客户端。

12.1 ESP32-C3 的 WiFi 能力

硬件基础

ESP32-C3 内置了完整的 WiFi 4(802.11 b/g/n)射频模块,工作在 2.4GHz 频段。“内置”意味着不需要额外的 WiFi 芯片或天线模块——射频收发器和基带处理器都集成在同一颗芯片内部。PCB 上只需要一小段走线天线就够了。

两种工作模式

ESP32-C3 的 WiFi 支持两种模式:
模式全称作用类比
STAStation连接到一个已有的 WiFi 路由器你的手机连家里的 WiFi
APAccess Point自己变成一个 WiFi 热点你手机开热点给别人用
CrossPoint Reader 两种模式都用到了:
  • STA 模式:连接家里的 WiFi 路由器,访问互联网——下载书籍、检查更新、同步进度
  • AP 模式:在没有 WiFi 路由器的环境下(比如旅途中),阅读器自己创建热点,手机连上后就能传书

WiFi 的内存开销

WiFi 不是免费的。ESP32-C3 的 WiFi 协议栈(包括 TCP/IP 栈)运行时大约需要 50KB RAM。在 380KB 的总内存中,这是一笔不小的开销——超过 13%。 这就是为什么 CrossPoint 不会一直开着 WiFi。只有在用户明确进入”WiFi 传书”或”在线书库”等需要网络的功能时,才会启动 WiFi 模块。阅读书籍时 WiFi 是完全关闭的,省电的同时也释放了这 50KB 内存给排版引擎使用。

12.2 内置 Web 服务器

你可能觉得”Web 服务器”是 Nginx、Apache 那样的大型软件。但在嵌入式世界中,一个 Web 服务器可以非常轻量——CrossPoint 在芯片上运行了一个完整的 HTTP 服务器,占用很少的资源。

服务器架构

// src/network/CrossPointWebServer.cpp
void CrossPointWebServer::begin() {
  // ===== 页面路由 =====
  webServer.on("/", HTTP_GET, [this]() { servePage(HomePage); });
  webServer.on("/files", HTTP_GET, [this]() { servePage(FilesPage); });
  webServer.on("/settings", HTTP_GET, [this]() { servePage(SettingsPage); });

  // ===== REST API =====
  webServer.on("/api/files", HTTP_GET, [this]() { handleFileList(); });
  webServer.on("/api/upload", HTTP_POST,
    [this]() { handleUploadComplete(); },   // 上传完成回调
    [this]() { handleUploadChunk(); });      // 每收到一个 4KB 分块就回调一次
  webServer.on("/api/download", HTTP_GET, [this]() { handleDownload(); });
  webServer.on("/api/settings", HTTP_GET, [this]() { handleGetSettings(); });
  webServer.on("/api/settings", HTTP_POST, [this]() { handleSetSettings(); });

  webServer.begin();  // 开始监听 HTTP:80 端口
}
如果你有过 Node.js / Express 或 Python / Flask 的经验,这段代码的结构应该很熟悉——定义路由、绑定处理函数、启动监听。区别只是这里的”服务器”跑在一颗比你指甲盖还小的芯片上。

HTML/JS 管理界面嵌入固件

用户通过浏览器访问阅读器时看到的管理页面(文件列表、设置面板),其 HTML 和 JavaScript 代码并不存放在 SD 卡上,而是直接编译进了固件里 具体做法是在编译时,用构建脚本把 HTML/CSS/JS 文件转成 C 语言的字节数组:
index.html → const uint8_t homepage_html[] = {0x3C, 0x21, 0x44, ...};
这样做的好处是管理界面和固件版本永远匹配,不存在”页面版本和 API 版本不一致”的问题。代价是固件体积增大——但这些页面都很小,压缩后通常只有几 KB。

手机传书的完整流程

用户从手机上传一本书到阅读器的流程是这样的:
1. 阅读器启动 WiFi(AP 或 STA 模式)
2. 阅读器启动 Web 服务器(监听 80 端口)
3. 用户在手机浏览器输入阅读器的 IP 地址
4. 浏览器加载管理页面(HTML/JS 从固件中返回)
5. 用户在页面上点击"上传",选择 EPUB 文件
6. 浏览器通过 POST /api/upload 发送文件
7. 阅读器以 4KB 分块接收,每块直接写入 SD 卡
8. 上传完成,阅读器刷新文件列表
注意第 7 步的 4KB 分块上传——服务器不会等文件完全上传完才开始写入 SD 卡。每收到 4KB 数据就立即写入,内存中始终只保留一个 4KB 缓冲区。这意味着即使上传一本 100MB 的漫画书,也只需要 4KB 的上传缓冲区。

12.3 AP 热点 + DNS 劫持:自动弹出管理页面

当你用手机连上公共 WiFi(比如机场或酒店的网络)时,通常会自动弹出一个登录页面。CrossPoint Reader 借用了同样的机制——当手机连上阅读器的 AP 热点后,会自动弹出管理页面。

实现代码

// src/activities/webserver/CrossPointWebServerActivity.cpp
void CrossPointWebServerActivity::startAccessPoint() {
  WiFi.mode(WIFI_AP);
  WiFi.softAP(AP_SSID, nullptr, AP_CHANNEL, false, AP_MAX_CONNECTIONS);
  // 参数说明:热点名称, 无密码, WiFi 信道, 不隐藏SSID, 最大连接数

  MDNS.begin(AP_HOSTNAME);  // 注册 mDNS:crosspoint.local

  // 关键:启动 DNS 服务器,劫持所有域名解析
  dnsServer = new DNSServer();
  dnsServer->setErrorReplyCode(DNSReplyCode::NoError);
  dnsServer->start(DNS_PORT, "*", apIP);  // "*" = 所有域名都解析到设备 IP
}

DNS 劫持原理

这个”自动弹出”是怎么实现的?它利用了手机操作系统的 Captive Portal 检测机制:
正常的 WiFi 连接流程:
  手机连上 WiFi → DNS 查询 "connectivitycheck.gstatic.com" → 得到真实 IP
  → 访问成功 → 系统判定"网络正常"

连接 CrossPoint AP 时:
  手机连上热点 → DNS 查询 "connectivitycheck.gstatic.com"
  → DNS 服务器把所有域名都解析到阅读器 IP(例如 192.168.4.1)
  → 手机访问检测 URL,得到的不是预期响应
  → 系统判定"需要登录" → 自动弹出内置浏览器
  → 浏览器访问任意 URL → 全部被导向阅读器的管理页面
简而言之,阅读器运行了一个”说谎”的 DNS 服务器——无论手机查询什么域名,它都回答”我就是那个服务器”。手机系统检测到 WiFi 需要登录,就自动弹出了浏览器。 MDNS.begin(AP_HOSTNAME) 则注册了一个本地域名 crosspoint.local,在支持 mDNS 的设备上可以用这个域名代替 IP 地址访问。

12.4 WebDAV 服务器:让 Calibre 无线传书

什么是 WebDAV

WebDAV(Web Distributed Authoring and Versioning)是 HTTP 协议的扩展,增加了文件管理能力。如果说 HTTP 只能”看网页”,WebDAV 则能”像操作网盘一样管理远程文件”——创建目录、上传文件、下载文件、删除、重命名、移动。 你可能用过的坚果云、Box、部分 NAS 系统都支持 WebDAV 协议。

为什么需要 WebDAV

CrossPoint Reader 的 Web 管理界面已经可以传书了,为什么还需要 WebDAV?因为 Calibre——全球最流行的电子书管理软件——支持通过 WebDAV 与设备同步书库。 有了 WebDAV,用户可以在 Calibre 中管理书籍(转格式、编辑元数据、整理分类),然后一键同步到阅读器,不需要手动一本一本上传。

协议实现

CrossPoint 实现了 WebDAV 协议中最核心的操作:
// src/network/WebDAVHandler.cpp
void WebDAVHandler::setup(WebServer& s) {
  // WebDAV 要求支持的 HTTP 方法:
  // OPTIONS   — 客户端询问"你支持哪些操作"
  // PROPFIND  — 获取文件/目录的属性(类似 ls -l)
  // GET       — 下载文件
  // HEAD      — 获取文件元信息(不下载内容)
  // PUT       — 上传/覆盖文件
  // DELETE    — 删除文件或目录
  // MKCOL     — 创建目录(Make Collection)
  // MOVE      — 移动/重命名
  // COPY      — 复制
  // LOCK      — 锁定文件(防止并发修改)
  // UNLOCK    — 解锁
}

原子文件操作:防止写入半截

文件上传是最容易出问题的环节。想象一下:你正在上传一本 10MB 的 EPUB,传到 5MB 时突然 WiFi 断了。如果直接写入目标文件,现在 SD 卡上就有一个损坏的 5MB 文件——既不能读,又占空间。 CrossPoint 的解决方案是临时文件 + 原子重命名
void WebDAVHandler::handlePut(WebServer& s) {
  // 1. 先写入临时文件(文件名加 .davtmp 后缀)
  std::string tmpPath = path + ".davtmp";
  Storage.openFileForWrite("DAV", tmpPath, file);

  // 2. 接收数据并写入临时文件
  while (hasMoreData()) {
    size_t received = s.readBytes(buffer, sizeof(buffer));
    file.write(buffer, received);
  }
  file.close();

  // 3. 上传完成后,原子替换目标文件
  Storage.rename(tmpPath.c_str(), path.c_str());

  // 4. 如果是 EPUB 文件且有旧版缓存,清除缓存
  clearEpubCacheIfNeeded(path);
}
关键在第 3 步的 rename() 操作。在大多数文件系统中,重命名是原子操作——要么完全成功,要么完全失败,不存在”重命名了一半”的中间状态。所以:
  • 上传途中断连 → 只有 .davtmp 文件受损,目标文件(如果存在旧版本)完好无损
  • 上传完成 → rename 一步替换,不会出现”目标文件内容只有一半”的情况
这种模式在服务器开发中很常见,CrossPoint 把它搬到了嵌入式设备上。

LOCK/UNLOCK:善意的谎言

WebDAV 规范要求支持文件锁定——防止两个客户端同时修改同一个文件。但 CrossPoint 是单用户设备,不存在并发修改的问题。所以它选择了最简单的实现方式——返回假令牌
void WebDAVHandler::handleLock(WebServer& s) {
  // 不真的加锁,只是返回一个固定的锁令牌
  s.sendHeader("Lock-Token", "<urn:uuid:dummy-lock-token>");
  s.send(200, "application/xml", dummyLockXml);
}
客户端(比如 Calibre)收到 200 状态码和锁令牌,会认为加锁成功,然后正常进行文件操作。UNLOCK 请求也照单全收。 这种做法在嵌入式系统中很常见:只实现协议中真正需要的部分,对不需要的部分返回”看起来正确”的响应。 完整实现所有功能既不必要,也会浪费宝贵的 Flash 空间来存放代码。

12.5 OPDS 在线书库

什么是 OPDS

OPDS(Open Publication Distribution System)是一种专为电子书设计的目录协议,基于 Atom XML 格式。你可以把它理解为”给电子书用的 RSS”——一个标准化的方式来发布和发现电子书资源。 很多在线书库(如 Project Gutenberg、Calibre 内容服务器等)都支持 OPDS。用户在阅读器上配置 OPDS 源的 URL,就可以直接浏览和下载书籍,不需要电脑中转。

流式解析 HTTP 响应

OPDS 的响应是 XML 格式,可能很大(一个分类页面可能包含数百本书的信息)。CrossPoint 不会把整个响应下载到内存中再解析,而是边下载边解析 它使用了一个巧妙的技巧——让 OPDS 解析器实现 Arduino 的 Print 接口:
// lib/OpdsParser/OpdsParser.cpp

// OpdsParser 继承自 Print,可以作为 HTTP 响应的"输出目标"
size_t OpdsParser::write(const uint8_t* xmlData, size_t length) {
  size_t remaining = length;
  const uint8_t* pos = xmlData;

  while (remaining > 0) {
    // 从 Expat 获取一块 1KB 的内部缓冲区
    void* buf = XML_GetBuffer(parser, 1024);
    size_t toRead = std::min(remaining, (size_t)1024);

    // 将 HTTP 响应数据复制到 Expat 缓冲区
    memcpy(buf, pos, toRead);

    // 增量解析——Expat 每解析完一个 XML 元素就回调通知
    XML_ParseBuffer(parser, toRead, 0);

    pos += toRead;
    remaining -= toRead;
  }
  return length;
}
数据流向如下:
OPDS 服务器

  │ HTTP 响应(Atom XML)

HTTPClient 接收数据

  │ 调用 opdsParser.write(data, len)

OpdsParser(实现了 Print 接口)

  │ 每 1KB 喂给 Expat

Expat XML 解析器

  │ 回调:发现一本新书 / 一个新分类 / 一个下载链接

更新 UI(显示书目列表)
整个过程中,内存里只有 1KB 的 XML 处理缓冲区和若干已解析的书目条目。如果一个 OPDS 页面列出了 100 本书,CrossPoint 也不需要同时把 100 本书的信息全部存在内存中——可以分页显示,只保留当前屏幕上可见的条目。 这里复用了第 11 章介绍过的 Expat 增量解析模式,充分体现了”同一种技术在不同场景下的一致应用”。

12.6 KOReader 跨设备同步

使用场景

KOReader 是另一个流行的开源电子书阅读软件,可以运行在 Kindle、Kobo、Android 等各种设备上。它有一个”阅读进度同步”功能:你在 Kindle 上读到第 42 页,切换到手机继续读时,KOReader 自动帮你翻到第 42 页。 CrossPoint Reader 实现了兼容 KOReader 的同步协议。这意味着你可以在 CrossPoint 和 KOReader 设备之间无缝切换阅读。

同步协议

KOReader 的同步服务器是一个简单的 REST API。CrossPoint 作为客户端,只需要实现两个操作:获取进度更新进度
// lib/KOReaderSync/KOReaderSyncClient.cpp

// 认证:每个请求都附带用户名和 MD5 密码哈希
void addAuthHeaders(HTTPClient& http) {
  http.addHeader("x-auth-user", KOREADER_STORE.getUsername().c_str());
  http.addHeader("x-auth-key", KOREADER_STORE.getMd5Password().c_str());
}

// 获取某本书在其他设备上的最新阅读进度
Error getProgress(const std::string& docHash, KOReaderProgress& out) {
  // docHash 是书籍文件的哈希值,用于跨设备识别"同一本书"
  HTTPClient http;
  http.begin(serverUrl + "/syncs/progress/" + docHash);
  addAuthHeaders(http);

  int code = http.GET();
  if (code == 200) {
    // 解析 JSON 响应
    JsonDocument doc;
    deserializeJson(doc, http.getString());
    out.percentage = doc["percentage"].as<float>();    // 阅读百分比
    out.progress = doc["progress"].as<std::string>();  // XPath 精确位置
  }
  return toError(code);
}

// 上传当前阅读进度到服务器
Error updateProgress(const KOReaderProgress& progress) {
  HTTPClient http;
  http.begin(serverUrl + "/syncs/progress");
  addAuthHeaders(http);

  JsonDocument doc;
  doc["document"] = progress.document;       // 书籍标识
  doc["progress"] = progress.progress;       // XPath 位置
  doc["percentage"] = progress.percentage;   // 阅读百分比
  doc["device"] = "CrossPoint";              // 设备标识

  String body;
  serializeJson(doc, body);
  http.PUT(body);
  return toError(http.GET());
}

阅读位置的表示方式

注意 progress 字段使用的是 XPath 格式,例如 /body/div[3]/p[7]——表示”第 3 个 div 中的第 7 个段落”。 为什么不直接用页码?因为不同设备的屏幕大小不同,同一章节的分页结果也不同。CrossPoint 在 4.7 英寸墨水屏上可能把一章分成 20 页,而 Kindle Paperwhite 在 6 英寸屏幕上可能只分成 15 页。“第 10 页”在两台设备上对应的内容完全不同。 而 XPath 指向的是文档结构中的特定位置,与屏幕大小无关。收到同步进度后,CrossPoint 需要把 XPath 转换为本机的页码——这就用到了 section.bin 缓存中存储的”锚点到页码映射”(第 11 章介绍过)。

12.7 架构总览:网络功能如何共存

最后让我们用一张全景图来理解 CrossPoint 的网络功能是如何组织在一起的:
                    ┌──────────────────────────────────┐
                    │       CrossPointWebServerActivity │
                    │       (管理 WiFi 和所有网络服务)   │
                    └─────────────┬────────────────────┘

              ┌───────────────────┼───────────────────┐
              │                   │                   │
     WiFi AP 模式            WiFi STA 模式       WiFi STA 模式
     (无路由器)              (有路由器)           (有路由器)
              │                   │                   │
    ┌─────────┴──────────┐       │                   │
    │                    │       │                   │
DNS 劫持          mDNS 注册     │                   │
(自动弹出页面)  (crosspoint.local)│                   │
    │                    │       │                   │
    └────────┬───────────┘       │                   │
             │                   │                   │
    ┌────────┴───────────────────┴───┐               │
    │       Web 服务器 (HTTP:80)      │               │
    │  ┌──────────┐  ┌────────────┐ │               │
    │  │ 管理页面  │  │  REST API  │ │               │
    │  │ (HTML/JS) │  │(文件/设置) │ │               │
    │  └──────────┘  └────────────┘ │               │
    ├────────────────────────────────┤               │
    │     WebDAV 服务器               │               │
    │  (Calibre 无线传书)             │               │
    └────────────────────────────────┘               │

                              ┌───────────────────────┘

                 ┌────────────┴──────────────┐
                 │                           │
          OPDS 客户端              KOReader 同步客户端
       (浏览在线书库)            (跨设备进度同步)
几个关键的设计决策:
  1. Web 服务器和 WebDAV 服务器在设备本地运行(阅读器是服务端),手机/电脑是客户端
  2. OPDS 和 KOReader 同步中阅读器是客户端,访问远程服务器
  3. 所有网络功能只在用户主动进入网络页面时启用,阅读时关闭 WiFi 节省内存和电量
  4. 所有网络数据传输都使用流式处理,不在内存中缓存完整响应

本章要点

  1. ESP32-C3 内置 WiFi 4(802.11 b/g/n),支持 AP(热点)和 STA(连接路由器)两种模式。WiFi 协议栈运行开销约 50KB RAM,仅在需要时启用。
  2. 内置 Web 服务器运行在 HTTP:80 端口,提供管理页面(HTML/JS 编译进固件)和 REST API。文件上传使用 4KB 分块接收,直接写入 SD 卡。
  3. AP 模式 + DNS 劫持实现了”连上热点自动弹出管理页面”的体验。原理是 DNS 服务器将所有域名解析到设备 IP,触发手机的 Captive Portal 检测。
  4. WebDAV 服务器实现了文件管理协议的核心操作(PROPFIND、GET、PUT、DELETE、MKCOL 等),使 Calibre 可以无线管理设备上的书库。上传使用临时文件加原子重命名确保数据安全,LOCK/UNLOCK 返回假令牌兼容客户端。
  5. OPDS 客户端通过流式解析 Atom XML 来浏览在线书库。OpdsParser 实现 Print 接口,HTTP 响应数据直接流入 XML 解析器,内存中只需 1KB 缓冲区。
  6. KOReader 同步通过 REST API 实现跨设备阅读进度同步。位置使用 XPath 表示以避免不同屏幕尺寸的分页差异。
  7. 所有网络功能遵循两个原则:按需启用(不用时关闭 WiFi)和流式处理(不在内存中缓存完整数据)。

下一章预告:第 13 章我们将探索电源管理与续航优化——CPU 如何动态调频、深度睡眠是怎么实现的、电池电量怎么检测,以及如何让一块小电池撑过数周的阅读时间。