"""固件库 + OTA 命令(板侧 C2,firmware/main/ota.h;需求 §10.8「C2 定稿的 OTA」)。 流程:网页上传 .bin → 这里解析 ESP 镜像头、算 sha256、存成 `/.bin` → nginx 7765(rack/deploy/nginx-phonerail-fw.conf,**不过门户**)把它按这个名字放出去 → `ota` 命令经 MQTT/TLS 带 url + sha256 + size,板子自己下载、哈希对上才切分区。 服务器这一侧先把能拦的拦掉,免得一次推送在板子上下完 1 MB 才报错: · 不是 ESP 镜像(首字节 0xE9)/ 没有 app 描述(0xABCD5432)→ 拒; · project_name 不是 `phonerail`(传错了别的项目的 .bin)→ 拒(板子那头也会核一遍); · 大于一个槽(3 MB)→ 拒。 **板子的校验仍是唯一权威**,这里只是早点告诉操作者。 ⚠️ `reboot=True` = 板子校验通过后立刻复位 = **五台手机硬断电一次**(EN 在复位时被下拉)。 界面必须二次确认;这里不替人做这个决定。 """ import hashlib import os import re import struct from typing import List, Optional from .db import Db, now_ms SLOT_BYTES = 0x300000 # firmware/main/ota.h OTA_SLOT_BYTES URL_MAX = 64 # OTA_URL_MAX(命令 JSON 字符串上限) PROJECT = "phonerail" SHA_RE = re.compile(r"^[0-9a-f]{64}$") # esp_app_desc_t 紧跟在镜像头(24 B)+ 第一个段头(8 B)之后 _DESC = 32 _DESC_MAGIC = 0xABCD5432 class FwError(ValueError): """镜像不能收 / 推送请求不对(中文说明)。""" def _cstr(b: bytes) -> str: return b.split(b"\0", 1)[0].decode("ascii", "replace") def parse_image(data: bytes) -> dict: """ESP32 app 镜像 → {version, project, built, idf}。不是本工程的 app 镜像抛 FwError。""" if not 0 < len(data) <= SLOT_BYTES: raise FwError("镜像大小 %d 字节,必须在 1~%d 之间(一个 OTA 槽)" % (len(data), SLOT_BYTES)) if len(data) < _DESC + 256 or data[0] != 0xE9: raise FwError("不是 ESP 固件镜像(首字节应为 0xE9):要传 build/phonerail.bin,不是 .elf / 合并镜像") magic = struct.unpack_from(" URL_MAX: raise ValueError("--fw-base 太长:URL 会超过板子的 %d 字节上限" % URL_MAX) os.makedirs(self.dir, exist_ok=True) @staticmethod def name_for(sha: str) -> str: return sha[:16] + ".bin" # nginx 7765 只放行这种名字 def url_for(self, sha: str) -> str: return "%s/%s" % (self.base, self.name_for(sha)) def add(self, data: bytes, filename: str, user: str) -> dict: info = parse_image(data) sha = hashlib.sha256(data).hexdigest() path = os.path.join(self.dir, self.name_for(sha)) tmp = os.path.join(self.dir, "." + self.name_for(sha) + ".tmp") with open(tmp, "wb") as f: f.write(data) os.chmod(tmp, 0o644) # nginx(www-data)要读得到 os.replace(tmp, path) self.db.x("INSERT OR IGNORE INTO firmware (sha256, version, project, built, idf, size, filename, " "uploaded_by, uploaded) VALUES (?,?,?,?,?,?,?,?,?)", (sha, info["version"], info["project"], info["built"], info["idf"], len(data), re.split(r"[\\/]", filename or "")[-1][:128], user, now_ms())) # 两种分隔符都切:服务器是 Linux return self.get(sha) def get(self, sha: str) -> Optional[dict]: rows = self.db.q("SELECT * FROM firmware WHERE sha256=?", (sha,)) if not rows: return None d = dict(rows[0]) d["url"] = self.url_for(sha) d["present"] = os.path.isfile(os.path.join(self.dir, self.name_for(sha))) return d def list(self) -> List[dict]: return [self.get(r["sha256"]) for r in self.db.q("SELECT sha256 FROM firmware ORDER BY uploaded DESC")] def delete(self, sha: str) -> bool: if self.get(sha) is None: return False try: os.remove(os.path.join(self.dir, self.name_for(sha))) except FileNotFoundError: pass self.db.x("DELETE FROM firmware WHERE sha256=?", (sha,)) return True def command(self, sha: str, reboot) -> dict: """推送请求 → 发给板子的 `ota` 命令(不含 seq)。""" if not isinstance(sha, str) or not SHA_RE.match(sha): raise FwError("sha256 必须是 64 位小写十六进制") if not isinstance(reboot, bool): raise FwError("reboot 必须是 true/false") fw = self.get(sha) if fw is None: raise FwError("固件库里没有这个镜像") if not fw["present"]: raise FwError("镜像文件不在下载目录里了(被手动删过?):重新上传") return {"cmd": "ota", "url": fw["url"], "sha256": sha, "size": fw["size"], "reboot": reboot}