自己搭一个 ESP32 OTA 升级服务器
做嵌入式开发最烦的事之一,就是每次改完固件都要拔线、接 USB、刷写、再拔线。OTA(Over-The-Air)空中升级本来就是解决这个问题的。一开始图省事用了巴法云(Bemfa),结果免费版处处受限——主题数量有严格限制,OTA 固件有大小上限,稍微多用一些就提示需要升级付费版。再一看付费价格,同样的预算完全够买一台轻量云服务器。说好听叫免费,说难听就是个体验版套壳,正经项目根本没法用。一气之下干脆自己写了一个 Flask 服务,结果发现也没多复杂,功能还更自由。这篇文章记录了我从零搭建 ESP32 OTA 固件管理服务器的全过程,踩了不少坑,也积累了一些心得,分享出来给有同样需求的同学参考。
一、为什么要自己搭 OTA 服务器
做 LCKFB_OTA 项目的时候(ESP-IDF v5.5.2),需要远程推送固件更新。ESP-IDF 自带的 esp_https_ota 组件其实非常简单,本质上就是一个 HTTP(S) GET 请求下载 .bin 文件,然后写入 OTA 分区、重启。
ESP32 设备 ──GET /firmware.bin──► OTA HTTP 服务器
│
返回 .bin 文件
│
写入 OTA 分区,重启生效所以 OTA 服务器的核心需求其实只有一个:能稳定地把正确的 .bin 文件用 HTTP 返回给设备。剩下的都是管理界面的便利性问题。
市面上有巴法云、AWS IoT、阿里云 OTA 这些方案——巴法云免费版限制太多基本没法正经用,AWS/阿里云又太重,配置繁琐,还得担心收费。想到自己有台内网服务器,加上 frp 内网穿透,完全可以自己搭一个轻量的 Flask 服务,既能管固件又能直接给设备用。
二、OTA 原理:ESP-IDF 端是怎么工作的
在动手写服务器之前,先搞清楚 ESP32 这端是怎么拉固件的。
2.1 ESP-IDF 的 OTA 分区机制
ESP32 的 Flash 按分区表划分,OTA 升级用到的一般是:
ota_0:第一个 OTA 固件槽ota_1:第二个 OTA 固件槽otadata:记录当前应该从哪个槽启动
设备第一次启动从 factory 分区运行,OTA 更新时把新固件写入当前非活跃的槽,验证通过后更新 otadata,重启后从新槽启动。整个过程断电也不会变砖,这是 ESP-IDF OTA 机制的可靠性保证。
2.2 ESP32 端调用示例
使用 esp_https_ota 组件,代码极简:
#include "esp_https_ota.h"
// OTA 固件下载地址,指向自建服务器
#define OTA_URL "https://ota.example.com/dl/admin/LCKFB_OTA/firmware.bin"
void ota_task(void *param)
{
esp_http_client_config_t http_cfg = {
.url = OTA_URL,
.cert_pem = server_cert_pem_start, // HTTPS 需要提供 CA 根证书
};
esp_https_ota_config_t ota_cfg = { .http_config = &http_cfg };
esp_err_t ret = esp_https_ota(&ota_cfg);
if (ret == ESP_OK) {
esp_restart(); // 升级成功,重启生效
} else {
ESP_LOGE(TAG, "OTA 失败: %s", esp_err_to_name(ret));
}
vTaskDelete(NULL);
}关键点:
url指向服务器上固件的直接下载地址- HTTPS 需要在工程里嵌入 CA 证书(通过
component.mk或 CMake 的target_add_binary_data) esp_https_ota返回ESP_OK就说明固件下载并写入成功,直接重启即可
服务器这边的任务就是:GET 这个路径时,返回当前激活的 .bin 固件文件,响应头带上 Content-Type: application/octet-stream 就够了。
三、服务器设计:从 v1 到 v7 的迭代历程
我没有一开始就想清楚所有需求,而是边用边改,最后迭代了六个版本。这里按时间顺序梳理,也顺便记录每个版本解决的问题。
3.1 v1:能用就行(2026-06-08 初版)
第一版极简,单项目,只关心"设备能下到固件"这一件事:
- 端口:
8092 路由:
GET /firmware.bin→ 返回固件文件GET /→ 简单的管理界面POST /upload→ 上传新固件
目录结构:
firmware/ ├── active.bin # 当前激活的固件 └── uploaded/ # 上传历史
能跑起来,设备也能正常拉到固件。但问题很快就来了——我有不止一个 ESP32 项目,不同项目的固件不能混着放。
3.2 v2:多项目重构
这是最重要的一次重构,把单项目结构改成了多项目管理:
目录结构升级:
firmware/
└── LCKFB_OTA/
├── active.bin # 当前激活固件
├── active.json # 元数据
└── history/
├── firmware_20260612_022216.bin
└── firmware_20260612_022216.json路由升级:
| 方法 | 路径 | 功能 |
|---|---|---|
| GET | /project/<name> | 项目管理界面 |
| POST | /project/create | 创建新项目 |
| POST | /project/<name>/upload | 上传固件 |
| POST | /project/<name>/switch/<ts> | 切换到历史版本 |
| GET | /project/<name>/firmware.bin | 设备下载固件 |
旧的 /firmware.bin 保留为兼容重定向,不影响已有设备。UI 改成了左右两栏布局,左侧深蓝色侧边栏列出所有项目,右侧是当前项目的固件管理界面。
3.3 v3:Bug 修复 + UI 重设计
用了一段时间发现两个问题:
Bug 1:复制按钮在 HTTP 环境下失效
管理界面有个"复制下载地址"的按钮,用的是 navigator.clipboard.writeText()。结果在 HTTP(非 HTTPS)环境下这个 API 直接被浏览器禁掉了——这是浏览器的安全策略,Clipboard API 只能在安全上下文(HTTPS 或 localhost)下使用。
修复方案是降级到 document.execCommand('copy'):
function copyUrl(text) {
if (navigator.clipboard && window.isSecureContext) {
// HTTPS 环境优先用现代 API
navigator.clipboard.writeText(text);
} else {
// HTTP 环境降级方案
const el = document.createElement('textarea');
el.value = text;
el.style.position = 'absolute';
el.style.left = '-9999px';
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
}
}虽然 execCommand 已经被标记为 deprecated,但在 HTTP 场景下目前还是最兼容的方案。
Bug 2:没有项目删除功能
新增了项目删除功能,带确认弹窗(怕误删),后端直接 shutil.rmtree 删除整个项目目录。
UI 这版参考了 GitHub 和 Portainer 的风格重新设计,整体更干净了。
3.4 v4:ESP32 bin 文件自动解析
这是功能上的一次大提升。以前上传固件只知道文件大小,不知道这个 bin 是什么芯片的、Flash 配置是什么、是不是合法的 ESP32 镜像。
我写了一个 parse_bin_info(path) 函数,读取 ESP32 image header 自动解析固件信息。
ESP32 image header 格式
ESP32 固件的前几个字节是固定格式的 image header:
偏移 大小 字段
+0 1 magic (0xE9,这是 ESP32 的固件魔数)
+1 1 segment_count
+2 1 flash_mode
+3 1 flash_size_freq (高4位=size, 低4位=freq)
+4 4 entry_address (小端 uint32)
...
+20 2 chip_id (小端 uint16)chip_id 对应关系:
_CHIP_ID_MAP = {
0x0000: "ESP32",
0x0002: "ESP32-S2",
0x0005: "ESP32-C3",
0x0009: "ESP32-S3",
0x000A: "ESP32-H2",
0x000C: "ESP32-C2",
0x000D: "ESP32-C6",
0x0010: "ESP32-P4",
}解析函数核心逻辑:
_ESP_MAGIC = 0xE9
def parse_bin_info(path: Path) -> dict:
data = path.read_bytes()
info = {
"is_esp32": False,
"md5": hashlib.md5(data).hexdigest(),
"size_bytes": len(data),
"size_str": _fmt_size(len(data))
}
# 检查 magic byte,不对就当普通文件
if len(data) < 32 or data[0] != _ESP_MAGIC:
return info
seg_count = data[1]
flash_mode = data[2]
size_freq = data[3]
entry_point = struct.unpack_from("<I", data, 4)[0]
chip_id = struct.unpack_from("<H", data, 20)[0]
info.update({
"is_esp32": True,
"chip": _CHIP_ID_MAP.get(chip_id, f"Unknown(0x{chip_id:04X})"),
"segments": seg_count,
"entry_addr": f"0x{entry_point:08X}",
# flash_mode / size / freq 的映射省略,详见完整源码
})
return info解析结果以 .json 文件形式存储在固件旁边(save_bin_meta / load_bin_meta),避免每次页面刷新都重新读文件解析。
3.5 v5:解析 esp_app_desc_t + 自定义版本号
这是最有意思的一个版本,也是踩坑最惨的一个版本。
目标:从 bin 文件里读出版本号
ESP-IDF 编译时会把 esp_app_desc_t 结构体写入固件,里面包含:
app_version:在CMakeLists.txt里设置的版本号字符串project_name:项目名idf_ver:编译时用的 IDF 版本compile_time/compile_date:编译时间
如果能从上传的 bin 文件里把这些信息读出来,就不用手动填版本号了,管理界面显示的信息也更丰富。
esp_app_desc_t 结构体布局
esp_app_desc_t(IDF v5.x,小端,共 256 bytes):
偏移 大小 字段
+0 4 magic_word = 0xABCD5432
+4 4 secure_version
+8 8 reserv1[2]
+16 32 version[32] ← app_version
+48 32 project_name[32]
+80 16 time[16] ← 编译时间 (HH:MM:SS)
+96 16 date[16] ← 编译日期 (MMM DD YYYY)
+112 32 idf_ver[32]
+144 32 app_elf_sha256[32]
... (padding 到 256 bytes)找这个结构体的方法是:在整个 bin 文件的字节流里搜索 magic word 0xABCD5432 的小端字节序,找到了就从那个偏移开始按偏移读字段。
踩的最惨的坑:Magic Word 写错了
这个 bug 让我调了大半天。
一开始我把 magic word 写成了 0xABCD5AA5,代码大概是这样:
# ❌ 错误版本!
_APP_DESC_MAGIC = b"\xa5\x5a\xcd\xab" # 这是我脑子进水写的
pos = data.find(_APP_DESC_MAGIC)
# pos 永远是 -1,什么也读不出来data.find() 一直返回 -1,我以为是 bin 文件格式问题,或者是 IDF 版本差异,加了一大堆调试日志,折腾了很久。
后来我把 active.bin 用十六进制编辑器打开,在大概应该有 esp_app_desc_t 的位置附近扫了一眼,看到了几个字节:32 54 CD AB。
再往后跳 16 个字节,看到了版本字符串,清晰可读。
那这几个字节是什么?
0x32 = '2'(ASCII)
0x54 = 'T'(ASCII)
0xCD 0xAB → 在 GBK 编码下是汉字"瞳"十六进制编辑器旁边的文本预览显示的是 "2T瞳"。
我盯着这个"2T瞳"看了一会儿,突然反应过来——0x32 0x54 0xCD 0xAB 这不就是 0xABCD5432 的小端字节序吗!
# ✅ 正确版本
# ESP_APP_DESC_MAGIC_WORD = 0xABCD5432
# 小端字节序:0x32, 0x54, 0xCD, 0xAB
_APP_DESC_MAGIC = b"\x32\x54\xcd\xab"改完之后立刻就找到了,所有字段全部正确解析出来。
这件事给我的教训是:遇到 find() 永远返回 -1 时,先不要怀疑数据,先检查搜索模式有没有写对。字节序的问题特别容易犯,小端存储反直觉。
解析 esp_app_desc_t 的完整代码
def parse_bin_info(path: Path) -> dict:
data = path.read_bytes()
info = {
"is_esp32": False,
"md5": hashlib.md5(data).hexdigest(),
"size_bytes": len(data),
"size_str": _fmt_size(len(data))
}
if len(data) < 32 or data[0] != _ESP_MAGIC:
return info
# --- image header 解析(省略部分映射表)---
chip_id = struct.unpack_from("<H", data, 20)[0]
info.update({
"is_esp32": True,
"chip": _CHIP_ID_MAP.get(chip_id, f"Unknown(0x{chip_id:04X})"),
"entry_addr": f"0x{struct.unpack_from('<I', data, 4)[0]:08X}",
})
# --- 搜索 esp_app_desc_t ---
_APP_DESC_MAGIC = b"\x32\x54\xcd\xab" # 0xABCD5432 小端
pos = data.find(_APP_DESC_MAGIC)
if pos != -1 and pos + 256 <= len(data):
def _cstr(off, length):
"""从指定偏移读取 C 字符串(遇 \0 截断)"""
raw = data[pos + off : pos + off + length]
return raw.split(b"\x00")[0].decode("utf-8", errors="replace").strip()
info["app_version"] = _cstr(16, 32) # 偏移 +16
info["project_name"] = _cstr(48, 32) # 偏移 +48
info["idf_ver"] = _cstr(112, 32) # 偏移 +112
# 编译时间:time 在 +80,date 在 +96
t = _cstr(80, 16)
d = _cstr(96, 16)
info["compile_time"] = f"{d} {t}"
return infov5 还新增了上传表单的折叠区块,允许手动输入自定义版本号和备注,覆盖自动解析的结果。UI 上把中间栏一分为二,左侧显示解析出的固件信息,右侧是上传表单。
3.6 v6:多用户工作空间隔离 + 管理员面板
直到有朋友问我"你这个能不能让别人也用",我才意识到单用户模式的问题。之前的版本密码要么写死在环境变量里,要么所有用户共享同一个项目空间——一人上传的固件,别人看得见还能删,完全没有隔离。
v6 做了一次彻底的用户系统重构,主要解决三个问题:
① 密码不能暴露在配置文件里
旧版用 OTA_USERS 环境变量(写在 docker-compose.yml 中),密码明文可见,改密码还得改文件重建容器。v6 改成了文件存储 + 密码哈希:
def _hash_password(password: str) -> str:
"""SHA-256 + 16 字节随机盐"""
salt = secrets.token_hex(16)
h = hashlib.sha256((salt + password).encode()).hexdigest()
return f"{salt}${h}"
def _verify_password(password: str, stored: str) -> bool:
salt, h = stored.split("$", 1)
return hashlib.sha256((salt + password).encode()).hexdigest() == h用户数据存在 firmware/.users.json(在 volume 挂载的持久化目录中),格式如下:
{
"users": [
{
"username": "admin",
"password_hash": "a1b2c3...$d4e5f6...",
"role": "admin",
"quota_bytes": -1,
"created_at": "2026-06-21 16:00:00"
},
{
"username": "user1",
"password_hash": "e5f6g7...$h8i9j0...",
"role": "user",
"quota_bytes": 3221225472,
"created_at": "2026-06-21 16:30:00"
}
]
}密码是 salt$hash 格式,即使 .users.json 被拿到也无法反向破解。quota_bytes 为 -1 表示无限制(管理员默认),普通用户默认 3 GB(3 * 1024^3 = 3221225472)。原子写入策略(先写 .tmp 再 replace)防止并发写损坏。
② 首次部署需要设置向导
新部署首次访问会自动跳转到 /setup 页面,创建管理员账号。没有预设密码,部署者自己设。创建完成后 /setup 自动禁用,防止后续被利用。
③ 每个用户独立工作空间
最重要的改动——用户之间彻底隔离:
firmware/ # volume 持久化根目录
├── .users.json # 用户数据库(含密码哈希 + 配额)
├── admin/ # 管理员的工作空间
│ └── LCKFB_OTA/ # 管理员的项目
│ ├── active.bin
│ ├── active.json
│ └── history/
│ └── ...
└── user1/ # user1 的工作空间
└── ESP32_Demo/ # user1 的项目
├── active.bin
└── ...实现方式是所有用户请求通过 session['username'] 路由到各自的子目录,互不干扰。admin 看不到 user1 的项目,user1 也看不到 admin 的。
④ 管理员可以在网页管理用户
管理员访问 /admin/users 面板,可以直接:
- 添加新用户(指定用户名、密码、角色)
- 删除用户
- 重置用户密码
不需要改任何配置文件,也不需要重建容器,实时生效。
⑤ 固件下载 URL 变化
因为工作空间隔离,固件下载地址从单用户改为带用户名:
旧: GET /project/LCKFB_OTA/firmware.bin
新: GET /dl/admin/LCKFB_OTA/firmware.bin但旧路由保持向下兼容(自动重定向到当前登录用户的对应项目)。
⑥ 完整路由表(v6)
公开接口(无需登录):
GET /setup → 首次设置向导
POST /setup → 创建管理员
GET /login → 登录页
POST /login → 提交登录
GET /dl/<username>/<project>/firmware.bin→ ESP32 固件下载
需登录:
GET /project/<name> → 项目管理
POST /project/create → 创建项目
POST /project/<name>/upload → 上传固件
POST /project/<name>/switch/<ts> → 切换版本
POST /project/<name>/delete → 删除项目(管理员)
POST /project/<name>/delete-history/<ts> → 删除历史(管理员)
GET /change-password → 修改密码
管理员专属:
GET /admin/users → 用户管理面板
POST /admin/users/add → 添加用户
POST /admin/users/delete → 删除用户
POST /admin/users/reset-password → 重置密码
POST /admin/users/set-quota → 设置用户存储配额
兼容接口(保留):
GET /project/<name>/firmware.bin → 重定向到 /dl/...
GET /firmware.bin → 重定向到第一个项目权限矩阵:
| 操作 | admin | user | ESP32(无需登录) |
|---|---|---|---|
| 下载固件 | — | — | ✅ |
| 上传固件 | ✅ | ✅ | — |
| 切换版本 | ✅ | ✅ | — |
| 创建项目 | ✅ | ✅ | — |
| 删除项目 | ✅ | ❌ | — |
| 删除历史 | ✅ | ❌ | — |
| 管理用户 | ✅ | ❌ | — |
| 设置配额 | ✅ | ❌ | — |
| 修改密码 | ✅ | ✅ | — |
v6 之后项目算是一个比较完整的团队协作工具了——不同成员各管各的项目,管理员统一管控,不会互相干扰。这也是被巴法云的"单用户限制"逼出来的。
3.7 v7:存储配额管理
v6 把用户隔离搞定了,但新问题又来了——固件文件动辄几 MB,用户如果无节制上传,服务器磁盘迟早撑爆。尤其是多人共用一台服务器时,得给每个人设个上限。
v7 引入了存储配额系统,核心改动:
① 每个用户有独立的存储配额
在 .users.json 中为每个用户存储 quota_bytes 字段:
- 管理员:
-1(无限制) - 普通用户:默认
3221225472(3 GB),管理员可随时修改
def _add_user(username: str, password: str, role: str = "user") -> bool:
# ...
quota = -1 if role == "admin" else 3 * 1024 * 1024 * 1024
users.append({
"username": username,
"password_hash": _hash_password(password),
"role": role,
"quota_bytes": quota, # ← v7 新增
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
})② 存储用量实时计算 + 进度条展示
在管理员面板中,每个用户行展示当前存储占用和配额上限,以及可视化的进度条:
def _get_storage_usage(username: str) -> int:
"""递归遍历用户工作空间,累加所有文件大小。"""
ud = user_dir(username)
if not ud.exists():
return 0
total = 0
for f in ud.rglob("*"):
if f.is_file():
total += f.stat().st_size
return total进度条颜色按使用率变化:< 75% 绿色、75%~95% 黄色、≥ 95% 红色。管理员还可以在面板中直接修改每个用户的配额上限(输入 GB 数即可)。
③ 配额超限阻止上传 / 创建项目
所有写入操作在执行前都先检查配额:
def _check_quota(username: str, additional_bytes: int = 0) -> tuple:
"""返回 (ok, error_msg)。"""
quota = _get_user_quota(username)
if quota < 0: # 管理员无限制
return True, ""
current = _get_storage_usage(username)
if current + additional_bytes > quota:
used = _fmt_storage(current)
limit = _fmt_storage(quota)
return False, f"存储空间已满(已用 {used} / 限额 {limit}),无法上传或创建文件。请联系管理员扩容。"
return True, ""上传固件时用 Content-Length 预估文件大小,创建项目时按 0 字节检查(配额还有剩余即可)。
④ 管理员面板配额设置
新增 POST /admin/users/set-quota 路由,管理员在面板中直接修改任意用户的配额上限(输入 ≤ 0 视为无限制)。和增删用户一样,实时生效,无需重建容器。
v7 之后即使多人共用一台服务器,也不用担心磁盘被单一用户占满——每个人有自己的"空间额度",管理员可以灵活分配。从 v1 纯粹的"设备能下载固件"到现在的多用户工作空间 + 配额管控,项目算是补上了面向团队协作的最后一块短板。
四、核心源码解析
下面重点解析几个关键模块,完整源码约 2200 行(单文件 Flask 应用,Python 3.10+)。
4.1 项目与目录管理
BASE_DIR = Path(__file__).parent
FIRMWARE_DIR = BASE_DIR / "firmware"
PROJECT_RE = re.compile(r'^[A-Za-z0-9_\-]{1,64}$') # 项目名合法性校验
def _project_dir(name: str) -> Path:
"""返回项目目录,不创建"""
return FIRMWARE_DIR / name
def _history_dir(name: str) -> Path:
return _project_dir(name) / "history"
def _active_bin(name: str) -> Path:
return _project_dir(name) / "active.bin"
def _active_meta(name: str) -> Path:
return _project_dir(name) / "active.json"项目名通过正则校验,避免路径穿越(../ 之类的攻击)。每个项目独立目录,active.bin 是设备当前拉取的固件,history/ 保留历史版本。
4.2 上传固件路由
@app.route("/project/<name>/upload", methods=["POST"])
def upload_firmware(name: str):
# 1. 校验项目名
if not PROJECT_RE.match(name):
abort(400)
proj_dir = _project_dir(name)
if not proj_dir.exists():
abort(404)
# 2. 获取上传文件
f = request.files.get("firmware")
if not f or Path(f.filename).suffix.lower() not in ALLOWED_EXT:
abort(400, "请上传 .bin 文件")
# 3. 把旧 active.bin 移入 history
active = _active_bin(name)
if active.exists():
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
hist_dir = _history_dir(name)
hist_dir.mkdir(parents=True, exist_ok=True)
dest = hist_dir / f"firmware_{ts}.bin"
shutil.copy2(active, dest)
# 同时备份元数据
if _active_meta(name).exists():
shutil.copy2(_active_meta(name), hist_dir / f"firmware_{ts}.json")
# 4. 保存新固件
tmp = proj_dir / f"_upload_{os.getpid()}.tmp"
f.save(str(tmp))
tmp.rename(active)
# 5. 解析 bin 信息并保存元数据
meta = parse_bin_info(active)
# 支持手动覆盖版本号
custom_ver = request.form.get("custom_version", "").strip()
custom_note = request.form.get("note", "").strip()
if custom_ver:
meta["app_version"] = custom_ver
meta["note"] = custom_note
meta["upload_time"] = datetime.now().isoformat()
save_bin_meta(_active_meta(name), meta)
return redirect(url_for("project_page", name=name))几个细节值得注意:
- 上传文件先存为临时文件
_upload_<pid>.tmp,写完后再rename到active.bin,保证原子性,设备在拉固件期间不会读到半截文件 - 移入 history 时用时间戳命名,保证唯一性
parse_bin_info在这里被调用,解析结果直接存 JSON
4.3 设备下载固件路由(工作空间隔离版)
@app.route("/dl/<username>/<project>/firmware.bin")
def download_firmware_v6(username: str, project: str):
"""设备下载固件 — 公开接口,无需登录"""
if not PROJECT_RE.match(project) or not USERNAME_RE.match(username):
abort(400)
active = FIRMWARE_DIR / username / project / "active.bin"
if not active.exists():
abort(404)
return send_file(
active,
mimetype="application/octet-stream",
as_attachment=False,
download_name="firmware.bin"
)
# 旧路径兼容重定向
@app.route("/project/<name>/firmware.bin")
def download_firmware(name: str):
"""v2-v5 兼容:重定向到当前用户的固件"""
username = session.get("username")
if username:
return redirect(f"/dl/{username}/{name}/firmware.bin")
abort(404)v6 的下载路径包含了用户名(/dl/{username}/{project}/firmware.bin),因为每个用户有独立的工作空间。但这个路由不需要登录,ESP32 设备可以直接 GET。它只是 URL 路径上标识"这是谁的固件",不涉及权限校验。
4.4 版本切换
@app.route("/project/<name>/switch/<ts>", methods=["POST"])
def switch_version(name: str, ts: str):
"""将历史版本切换为 active"""
if not PROJECT_RE.match(name):
abort(400)
hist_bin = _history_dir(name) / f"firmware_{ts}.bin"
if not hist_bin.exists():
abort(404)
active = _active_bin(name)
# 把当前 active 移入 history(避免丢失)
if active.exists():
now_ts = datetime.now().strftime("%Y%m%d_%H%M%S")
shutil.copy2(active, _history_dir(name) / f"firmware_{now_ts}.bin")
if _active_meta(name).exists():
shutil.copy2(_active_meta(name),
_history_dir(name) / f"firmware_{now_ts}.json")
# 把历史版本复制到 active(不移动,保留历史)
shutil.copy2(hist_bin, active)
hist_meta = _history_dir(name) / f"firmware_{ts}.json"
if hist_meta.exists():
shutil.copy2(hist_meta, _active_meta(name))
return redirect(url_for("project_page", name=name))切换时先把当前 active 保存一份到 history(不然切换之后就找不回来了),再把目标历史版本复制到 active。用复制而不是移动,history 里保留完整记录。
4.5 用户认证与工作空间隔离(v6 新增)
认证装饰器
v6 使用了 Flask 的 session + 自定义装饰器模式来保护路由:
def require_auth(f=None, *, admin_only=False):
"""登录验证装饰器。admin_only=True 表示仅管理员可访问。"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
username = session.get("username")
if not username:
return redirect(url_for("login_page", next=request.url))
user = _find_user(username)
if not user:
session.clear()
return redirect(url_for("login_page"))
if admin_only and user.get("role") != "admin":
return "权限不足,需要管理员账号", 403
return func(*args, **kwargs)
return wrapper
return decorator
# 使用示例
@app.route("/admin/users")
@require_auth(admin_only=True) # 只有 admin 能访问
def admin_users():
...
@app.route("/project/<name>/upload", methods=["POST"])
@require_auth # 登录即可
def upload_firmware(name):
...工作空间隔离
每个用户请求到达时,通过 session['username'] 动态路由到各自的子目录:
def _user_workdir(username: str) -> Path:
"""返回用户的私有工作空间目录"""
return FIRMWARE_DIR / username
def _project_dir(username: str, name: str) -> Path:
return _user_workdir(username) / name所有固件操作(创建项目、上传、切换、删除)都限定在当前登录用户的工作空间内。用户在网页上看到的是"自己的项目列表",底层对应 firmware/{username}/ 下的内容。
首次设置向导
/setup 路由只在没有任何用户时可用,用于创建第一个管理员:
@app.route("/setup", methods=["GET", "POST"])
def setup_page():
# 已存在用户则禁用
if _load_users():
return redirect(url_for("login_page"))
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "").strip()
if _add_user(username, password, role="admin"):
return redirect(url_for("login_page", note="管理员创建成功,请登录"))
return SETUP_PAGE_HTML创建完成后 /setup 自动失效,后续访问直接跳转到登录页,防止被恶意利用。
4.6 存储配额管理(v7 新增)
配额系统的核心是三个函数:计算用量、查询配额、检查是否超限。
def _get_storage_usage(username: str) -> int:
"""递归遍历用户工作空间,累加所有文件大小。"""
ud = user_dir(username)
if not ud.exists():
return 0
total = 0
for f in ud.rglob("*"):
if f.is_file():
try:
total += f.stat().st_size
except OSError:
pass
return total
def _get_user_quota(username: str) -> int:
"""返回配额(字节),-1 = 无限制。"""
user = _find_user(username)
if not user:
return -1
quota = user.get("quota_bytes")
if quota is None:
return -1 if user.get("role") == "admin" else 3 * 1024 * 1024 * 1024
return quota
def _check_quota(username: str, additional_bytes: int = 0) -> tuple:
"""返回 (ok, error_msg)。"""
quota = _get_user_quota(username)
if quota < 0: # 管理员无限制
return True, ""
current = _get_storage_usage(username)
if current + additional_bytes > quota:
used = _fmt_storage(current)
limit = _fmt_storage(quota)
return False, f"存储空间已满(已用 {used} / 限额 {limit}),请联系管理员扩容。"
return True, ""在创建项目和上传固件的路由中,操作前先调用 _check_quota:
@app.route("/project/<name>/upload", methods=["POST"])
@require_auth
def upload_firmware(name: str):
# ...
# 用 Content-Length 预估上传大小,提前阻止
est_size = request.content_length or 0
ok_q, err_q = _check_quota(username, additional_bytes=est_size)
if not ok_q:
return redirect(url_for("project_page", name=name, error=err_q))
# ... 执行上传管理员通过 POST /admin/users/set-quota 设置配额(输入 GB 数,≤ 0 视为无限制)。面板中用进度条可视化展示每个用户的存储使用率:
# 进度条渲染(内联在 _render_admin_users 中)
if quota_bytes > 0:
pct = min(100, int(storage_bytes / quota_bytes * 100))
bar_color = "#f85149" if pct >= 95 else ("#d29922" if pct >= 75 else "#238636")
bar_html = (f'<div style="width:100%;height:4px;background:#30363d;'
f'border-radius:2px;margin-top:4px">'
f'<div style="width:{pct}%;height:100%;background:{bar_color};'
f'border-radius:2px;min-width:2px"></div></div>')配额系统虽然代码量不大,但补上了多用户服务器的最后一块短板——防止单个用户无节制占用磁盘空间。
五、整个项目的目录结构
ota_server/
├── ota_server.py # 主程序(单文件,所有路由 + 模板内联)
├── requirements.txt # flask>=2.3.0, werkzeug>=2.3.0
├── Dockerfile # Docker 镜像构建文件
├── docker-compose.yml # Docker Compose 编排配置
├── start.sh # Linux/macOS 一键启动
├── start.bat # Windows 一键启动
└── firmware/ # 运行时自动创建(Docker 部署时挂载到宿主机)
├── .users.json # 用户数据库(密码 SHA-256+盐 哈希存储,含配额字段)
├── admin/ # 管理员工作空间
│ └── LCKFB_OTA/ # 管理员的项目
│ ├── active.bin
│ ├── active.json
│ └── history/
│ └── ...
└── user1/ # 普通用户工作空间
└── ESP32_Demo/ # user1 的项目
├── active.bin
└── ...整个服务是单文件设计,HTML 模板用 Python 字符串内联,不依赖 Jinja2 模板文件,部署时只需要一个 .py 文件加上 requirements.txt,干净利落。
v7 关键设计:每个用户在 firmware/ 下有独立的子目录作为工作空间,互不可见。.users.json 存储用户账号、密码哈希和存储配额,管理员通过网页面板管理用户和配额,无需改配置文件。
六、部署:通过 frp 内网穿透把服务暴露到公网
6.1 本地运行
# 安装依赖
pip install flask werkzeug
# 启动
python ota_server.py
# 或者
OTA_PORT=8092 python ota_server.py
# 访问管理界面
# http://localhost:80926.2 frp 内网穿透配置
我用的是自己 VPS 上的 frp server,客户端配置如下:
# frpc.ini
[common]
server_addr = your-vps-ip
server_port = 7000
token = your-auth-token
[ota-server]
type = http
local_ip = 127.0.0.1
local_port = 8092
custom_domains = ota.yourdomain.comfrp server 端需要配置好 vhost_http_port(一般是 80)以及对应的域名解析。配置完成后,ESP32 设备直接访问 http://ota.yourdomain.com/project/<name>/firmware.bin 就能拉固件了。
注意:如果 ESP32 端用esp_https_ota,服务器需要是 HTTPS。可以在 frp server 前面挂一个 nginx 做 SSL 终止,或者用带 TLS 的 frp 配置。如果测试阶段用 HTTP 也可以,换用esp_http_client+ 手写 OTA 逻辑即可(稍微麻烦一些)。
6.3 后台保活(Linux)
# 简单方式:nohup
nohup python ota_server.py > ota.log 2>&1 &
echo $! > ota.pid
# 停止
kill $(cat ota.pid)如果想更稳定,可以写 systemd service:
# /etc/systemd/system/ota-server.service
[Unit]
Description=ESP32 OTA Server
After=network.target
[Service]
User=youruser
WorkingDirectory=/path/to/ota_server
ExecStart=/usr/bin/python3 ota_server.py
Restart=on-failure
RestartSec=5s
Environment=OTA_PORT=8092
[Install]
WantedBy=multi-user.targetsystemctl enable ota-server
systemctl start ota-server
systemctl status ota-server6.4 Docker 容器化部署(推荐)
如果你的服务器上装了 Docker,用 Compose 编排一键部署是最省心的方式。项目已自带 Dockerfile 和 docker-compose.yml,开箱即用。
Dockerfile 解析
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY ota_server.py .
RUN mkdir -p /app/firmware
EXPOSE 8092
ENV OTA_HOST=0.0.0.0
ENV OTA_PORT=8092
CMD ["python", "ota_server.py"]基于 python:3.11-slim,体积小,构建快。/app/firmware 作为数据目录,通过 volume 映射到宿主机,即使删除重建容器,固件数据也不会丢失。
docker-compose.yml 编排配置
services:
ota-server:
build: .
container_name: ota-server
restart: unless-stopped
ports:
- "8092:8092"
volumes:
- /opt/ota_data:/app/firmwarebuild: .:从当前目录的 Dockerfile 构建镜像restart: unless-stopped:服务器重启后容器自动拉起volumes:/opt/ota_data(宿主机)↔/app/firmware(容器内),固件数据、用户数据库全部持久化在这里
v7 变更:不再通过OTA_USERS环境变量配置密码。用户数据全部存储在firmware/.users.json中(密码 SHA-256+盐 哈希,含配额字段),首次访问/setup创建设置管理员账号,管理员通过网页面板管理用户和配额。
部署后的宿主机目录结构:
/opt/
├── ota_server/ # 源码 + Dockerfile + docker-compose.yml
│ └── ...
└── ota_data/ # 固件数据 + 用户数据库(volume 挂载)
├── .users.json # 用户数据库(密码哈希 + 配额存储)
├── admin/ # 管理员的工作空间
│ └── 项目A/
│ ├── active.bin
│ ├── active.json
│ └── history/
└── user1/ # user1 的独立工作空间
└── 项目B/
├── active.bin
└── history/通过 GMSSH 编排部署(全图形化,不用敲命令)
我用的是 GMSSH 客户端,其内置的 Docker 管理器可以直接通过编排模块一键部署。流程如下:
① 拉取代码到服务器
git clone https://gitee.com/wbh2024/esp32-ota-server-code.git /opt/ota_server② 创建编排
打开 GMSSH → Docker 管理器 → 编排 → 点击 创建编排:
| 字段 | 填写 |
|---|---|
| 项目目录 | /opt/ota_server |
| Compose 文件 | docker-compose.yml |
点确定后 GMSSH 会自动执行 docker compose build + docker compose up -d,构建日志实时可见。
| 步骤 | 日志关键信息 |
|---|---|
| 拉取基础镜像 | FROM python:3.11-slim |
| 安装依赖 | Successfully installed flask-3.1.3 werkzeug-3.1.8 ... |
| 构建镜像 | Image ota_server-ota-server Built |
| 启动容器 | Container ota-server Started |
| 完成 | ※Operator successful※ |
③ 放行端口
去云服务器控制台的安全组,添加一条入站规则:TCP 8092 允许 0.0.0.0/0。
④ 初始化配置
浏览器访问 http://你的服务器IP:8092,首次访问会自动跳转到 /setup 页面。输入管理员用户名和密码完成初始设置,之后即可登录管理界面。
常用维护操作
在 GMSSH 编排面板中,直接对 ota_server 编排项目操作:
- 停止:点击停止按钮
- 重启:点击重启按钮
- 更新代码后重建:SSH 终端里
cd /opt/ota_server && git pull,然后在编排面板停止并重新启动即可 - 查看日志:编排面板中点击容器名进入详情查看日志
对比传统部署:Docker 部署相比之前提到的 nohup / systemd 方式,优势在于环境隔离、一键启停、重启自动恢复,而且不依赖宿主机的 Python 版本。v6 的用户数据全部持久化在 volume 中,即使删除容器重建也不会丢失。强烈推荐生产环境使用。
七、效果展示
管理界面总览

左侧深蓝色侧边栏列出所有项目,点击切换;右侧为当前选中项目的固件管理区,顶部展示激活固件的详细信息,下方为历史版本列表。
当前激活固件信息

上传固件后自动解析并展示:芯片型号、固件版本、项目名、IDF 版本、编译时间、MD5 校验值、文件大小。支持一键复制固件下载地址。
历史版本管理

每次上传新固件时,旧版本自动归档到历史列表。支持一键切换到任意历史版本,也可单独删除不需要的版本。
固件上传

上传表单折叠设计,展开后选择 .bin 文件即可上传。支持手动填写自定义版本号和备注(可选),覆盖自动解析结果。创建项目

输入项目名称即可创建,项目名仅允许字母、数字、下划线和连字符。创建后自动生成对应的目录结构和固件下载路由。
ESP32 设备实测

ESP32 设备通过 esp_https_ota 组件请求自建服务器,串口日志显示固件下载进度、写入 OTA 分区完成,随后自动重启生效。八、当前状态与后续计划
项目目前已通过 Docker 在云服务器上部署运行,当前版本 v7。
核心功能:
- 多项目管理,左侧边栏切换项目
- 固件信息自动解析(芯片型号、版本号、编译时间、MD5、文件大小)
- 历史版本管理(列表查看、一键切换、单独删除)
- 自定义版本号和备注
- 固件下载地址一键复制
- 多用户工作空间隔离:不同用户互不可见
- 管理员面板:网页端增删用户、重置密码、设置存储配额
- 密码哈希存储:SHA-256+盐,不在配置文件中暴露
- 存储配额管理:每个用户独立配额上限,进度条可视化,超限阻止上传
- Docker 容器化部署:GMSSH 编排一键启停
后续如果有时间可能会加的功能:
- 版本检查 API:设备主动 poll,服务器返回当前版本号,设备判断是否需要升级(而不是每次都强制下载)
- Webhook 通知:上传新固件时推送消息到微信/钉钉
- 多设备上报:设备定期上报当前版本,管理界面能看到各设备的固件状态
九、总结
回头看,这个项目其实技术含量不算高,Flask + 文件操作,没有数据库,没有复杂的并发问题。但从 v1 一路迭代到 v7,从最初的"设备能下载固件就行"到现在的多用户工作空间、管理员面板、密码哈希存储、存储配额管理,已经是一个比较完整的团队工具了——被巴法云逼出来的轮子反倒比原版更顺手。整个折腾过程挺有收获的:
学到的东西:
- ESP32 固件格式的 image header 和
esp_app_desc_t结构,以后调试固件镜像问题会更有把握 - 字节序的坑(
0xABCD5432小端是\x32\x54\xcd\xab,不是直觉上的\xab\xcd\x54\x32),吃一堑长一智 - 浏览器安全上下文限制:Clipboard API、摄像头等敏感 API 在 HTTP 下会被禁,做 Web 工具时要注意
最值得记住的 Bug: 那个"2T瞳"让我哭笑不得,但也正是因为十六进制编辑器旁边的 GBK 解码字符,我才一眼认出了正确的 magic bytes。有时候调试就是这样,答案就在眼前,只是你没认出来。
如果你也在做 ESP32 项目需要 OTA,希望这篇文章对你有帮助。有问题欢迎在评论区讨论。
⚠️ 免责声明:本文及配套源码仅供学习交流使用。作者不对因使用本文所述代码或方法造成的任何设备损坏、数据丢失或其他损失承担责任。在生产环境中部署前,请充分测试并评估风险。
🤖 声明:本文内容及配套代码由 AI 辅助生成,经过人工校验与修改。如有疏漏,欢迎指正。
作者:Wangbeihong / 广州软件学院 嵌入式方向 / 2026年6月
评论
暂无评论