故障定位与升级 / 可维护设备实验发布 内容更新

ESP32 OTA 升级怎么回退?模拟坏包、断网和新固件自检失败

用factory基线、A/B槽与显式自检故障练习恢复;实际构建、本机TLS和S3 QEMU分别留证,真实无线下载、断电与板上恢复待验证。

读完能做什么
建立同次镜像、启动状态和恢复日志的对应关系,能区分半包、镜像拒绝和候选自检失败。
开始之前
能在固定IDF环境构建并保存同次源码/配置/ELF/原log;先完成下列前置,使用独立实验板和已核备份。
所需器材
Espressif ESP32-S3-DevKitC-1 v1.1 / WROOM-1-N8R8、USB数据线与电脑、固定ESP-IDF5.5.5和Python3.12主机工具;自有受控2.4GHz网络、原项目/完整Flash及配置备份、私密证据目录;无外部负载、自有HTTPS更新电脑、可配置的LAN DNS和允许的NTP服务;独立更新实验板
版本组合
固件基线:ESP-IDF5.5.5 / b774170ff46c393eeb5e495ea37936038d3f4f4f目标:S3-DevKitC-1 v1.1 N8R8,DIO80/8MiB;PSRAM关闭
适用范围

ESP32-S3-DevKitC-1 v1.1 / ESP32-S3-WROOM-1-N8R8;IDF5.5.5,8MiB Flash/DIO80,PSRAM明确关闭;USB-to-UART115200。下载README包含独立构建、运行、失败恢复和许可。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/maintenance-systems-sources.json;28份固定官方源新取,27份SDK/子模块与安装逐字节一致,板资料为固定官方仓库正文。验证日期:
构建 / 配置已通过
docs/esp32-evidence/maintenance-systems-final-builds.json;最终真实构建、源码快照、实际配置/ELF/app/map及原log SHA独立归档。对象:ESP32-S3 / N8R8计划版本:ESP-IDF5.5.5验证日期:
仿真已通过
docs/esp32-evidence/r05-qemu-final.json;官方QEMU9.2.2实际esp32s3预装槽五场景通过,原图-snapshot后SHA不变;未仿真WiFi HTTPS或物理掉电。验证日期:
本站实板待验证
未执行本例实体烧录/运行/测量/恢复;下载观察CSV仅表头。

构建通过只说明指定环境中的代码或配置通过检查;实物功能、功耗和稳定性以对应的实板记录为准。

01 / 可维护设备实验

先保留能启动的基线,再让候选接受检查

这篇针对自己构建的项目,沿E09分区与R04 TLS前提继续;小智成品固件仍按原升级清单处理。完整下载包含factory基线、候选通过/拒绝配置、HTTPS服务、主机夹具和QEMU图生成器。默认占位不会连接网络,也不自动升级。

指定S3 v1.1 N8R8、Flash8MiB/DIO80、PSRAM关闭,桥接USB-to-UART115200,不接原生USB/JTAG或外部负载。四最终配置实际构建成功;电脑TLS和QEMU分别验证,实体升级/RESET/断电/恢复仍无实测。

参考:[1] 固定OTA文档:回滚状态与有效回退镜像条件[2] esp_ota_ops.h:确认、回退及错误返回[7] 固定官方DevKitC-1 v1.1:N8R8、供电、USB口与J1针脚

02 / 可维护设备实验

刷分区以前,保存原布局和完整8MiB

本例nvs为0x9000/0x6000,otadata为0xf000/0x2000,factory从0x20000起,大小0x180000;ota_0从0x1a0000、ota_1从0x320000起,各0x180000(1.5MiB)。不能把一份merged bin当OTA app上传,也不能只因Flash有8MiB就忽略槽限制。

下载README列出固定esptool4.12.0的flash_id、read_flash全8MiB、SHA核对和受条件约束的恢复命令。先保存原项目分区偏移、原NVS与完整Flash;未知旧布局/加密状态先停在只读调查,不以erase-flash清除错误。

构建角色app字节实际用途
baseline514800初刷完整factory基线
candidate pass514800以app.bin作HTTPS候选
candidate fail514784仅PENDING自检显式失败
compile-only network908064确认真实网络分支被链接;不是联网结果

参考:[1] 固定OTA文档:回滚状态与有效回退镜像条件[2] esp_ota_ops.h:确认、回退及错误返回[8] otatool:官方离线分区与OTA数据工具

03 / 可维护设备实验

候选配置必须在独立目录构建

按README先构建baseline,再使用sdkconfig.defaults与candidate-pass/fail.defaults分别构建。CANDIDATE控制角色标签;REJECT_SELFTEST只在候选且实际PENDING时令自检失败,不是每次启动都重启的坏固件。所有镜像都保留同次ELF/map、配置、分区、源码和原log。

先把自己的公共CA和私有网络配置分别带入三份固件,再构建用于板验的版本。本站的成功构建默认仍含占位,不是已配好你的网络;镜像格式/校验和不等于生产签名链,也不抵御恶意发布者。

代码 / 配置
# POSIX shell · 解压工程根、已进入IDF5.5.5环境
export LAB_PROJECT="$PWD"
export LAB_PASS="$HOME/esp32-labs/r05-pass"
python "$IDF_PATH/tools/idf.py" -C "$LAB_PROJECT" -B "$LAB_PASS" -D SDKCONFIG="$LAB_PASS/sdkconfig" -D 'SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.candidate-pass.defaults' build

参考:[1] 固定OTA文档:回滚状态与有效回退镜像条件[2] esp_ota_ops.h:确认、回退及错误返回

04 / 可维护设备实验

HTTPS端点要有真实可解析的证书身份

在项目树外运行tools/generate_lab.py --out 新私密目录 --name r05-lab.test,生成新CA和叶证书;只复制lab_ca.pem到main/server_ca.pem。服务器用--bind YOUR_OWN_LAN_IP而不是不存在的--host参数,--image指向候选r05_maintenance.bin。

让自有LAN DNS把r05-lab.test解析到电脑;只改电脑hosts或curl --resolve不能替ESP32解析。URL为https://r05-lab.test:8443,无末尾斜杠,SAN与URL相同,允许LAN8443入站。叶证书30天、CA365天,读者重新生成;密钥留树外0600文件,不使用关闭验证绕过域名/CA错误。本例受信服务只返回固定200,不测试重定向链。

代码 / 配置
# POSIX shell · 独立Python3.12主机环境,已安装tools/requirements.txt
export R05_PRIVATE="$HOME/esp32-private/r05-lab-new"
python tools/generate_lab.py --out "$R05_PRIVATE" --name r05-lab.test
cp "$R05_PRIVATE/lab_ca.pem" main/server_ca.pem
python tools/ota_server.py --cert "$R05_PRIVATE/server_cert.pem" --key "$R05_PRIVATE/server.key" --image "$LAB_PASS/r05_maintenance.bin" --bind YOUR_OWN_LAN_IP --port 8443

参考:[3] esp_https_ota.h:begin/perform/finish/abort所有权[4] HTTPS OTA实现:完整性、镜像结束与选中槽[6] esp_netif SNTP:等待信号量与同步事件

05 / 可维护设备实验

等待联网、时间和下载,各自有自己的边界

IDF终端在外部私有sdkconfig中填写WPA2 SSID1..31字节、密码8..63、URL与允许的NTP。IP等待15s,显式SNTP等待15s;代码缓存的是成功等待事件的本机观察时刻,至1h后再等待,并非经认证的NTP同步年龄证明。2026年下限仅粗筛,证书验证仍决定有效期。

timeout_ms=3000是网络操作超时;60s是调用之间的软deadline,单次阻塞可能越过它。只在完整性检查和finish均成功后重启,失败保留旧运行镜像;不将download字节、HTTP200或API调用成功等同于新固件已经健康。

参考:[3] esp_https_ota.h:begin/perform/finish/abort所有权[4] HTTPS OTA实现:完整性、镜像结束与选中槽[6] esp_netif SNTP:等待信号量与同步事件

06 / 可维护设备实验

按坏包、半包、自检失败顺序留下证据

先刷完整baseline并保存BOOT=factory/READY。服务端指pass候选时,独立输入ota bad、ota cut、ota good:bad是非镜像体,cut声明全长后只发一半,good是完整app。记录错误码、字节、前后运行槽与同次固件SHA,坏包/半包不能被写成升级成功。

再把服务端指向fail候选,ota good后应在PENDING的10s自检里请求回退;用pass在PENDING窗口按RESET应回退,VALID确认后RESET应保留候选。实物断电与RESET不同,单独用实验板/受控电源做;这些实体步骤本轮未执行。

  1. 每项前确认旧有效镜像与备份存在,保存开始条件。
  2. 只发一条完整命令,收集直到READY或后续BOOT的原log。
  3. 异常先保留证据,再按RESET/ROM下载模式及匹配备份恢复;不先自动清NVS。

参考:[1] 固定OTA文档:回滚状态与有效回退镜像条件[2] esp_ota_ops.h:确认、回退及错误返回[5] 固定bootloader:NEW/PENDING/ABORTED处理

07 / 可维护设备实验

把下载、QEMU和硬件分别写入验收

实际本机TLS三端点验证正确CA/域名、good同次app字节、bad魔数不合法、cut读取不完整;它不证明ESP32识别或拒绝了这些响应。实际完整perform_ota外部API桩七分支核begin/abort/finish/restart所有权,没有模拟TLS校验证书或Flash。

官方QEMU9.2.2的esp32s3实际启动五场景通过:候选确认、候选拒绝回factory、PENDING复位回factory、VALID再复位继续OTA、预装候选魔数破坏回factory。图是预装NEW元数据与app,绝不烧到实体;-snapshot后原图SHA不变,未运行WiFi HTTPS和物理掉电。

参考:[1] 固定OTA文档:回滚状态与有效回退镜像条件[3] esp_https_ota.h:begin/perform/finish/abort所有权[4] HTTPS OTA实现:完整性、镜像结束与选中槽[5] 固定bootloader:NEW/PENDING/ABORTED处理

08 / 可维护设备实验

完整固件和命令/时限模型

以下与下载main完整同字节。begin失败不再abort;已finish后也不再abort。自检确认只发生在实际PENDING,不给factory强加OTA状态。

代码 / 配置
// ---- main/main.c ----
/* SPDX-License-Identifier: MIT */
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <fcntl.h>
#include <inttypes.h>
#include <time.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "esp_log.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "esp_event.h"
#include "esp_netif.h"
#include "esp_netif_sntp.h"
#include "esp_wifi.h"
#include "esp_https_ota.h"
#include "esp_ota_ops.h"
#include "nvs_flash.h"
#include "sdkconfig.h"
#include "ota_policy.h"
static const char*TAG="R05";
static EventGroupHandle_t events;
static bool network_started,sntp_started;
static int64_t last_sync_ms=-1;
extern const char ca_start[] asm("_binary_server_ca_pem_start");
static void network_event(void*arg,esp_event_base_t base,int32_t id,void*data){
 (void)arg;(void)data;
 if(base==IP_EVENT&&id==IP_EVENT_STA_GOT_IP)xEventGroupSetBits(events,1);
 if(base==WIFI_EVENT&&id==WIFI_EVENT_STA_DISCONNECTED)xEventGroupClearBits(events,1);
}
static bool network_ready(void){
 if(strstr(CONFIG_R05_SSID,"REPLACE_")||strstr(CONFIG_R05_PASSWORD,"REPLACE_")||strstr(CONFIG_R05_URL,"REPLACE_")||strstr(ca_start,"REPLACE_")){
  ESP_LOGW(TAG,"CONFIG_MISSING: own SSID/password/HTTPS origin/public CA required; values omitted");return false;
 }
 if(strncmp(CONFIG_R05_URL,"https://",8)){ESP_LOGE(TAG,"HTTPS_REQUIRED");return false;}
 if(!strlen(CONFIG_R05_SSID)||strlen(CONFIG_R05_SSID)>31||strlen(CONFIG_R05_PASSWORD)<8||strlen(CONFIG_R05_PASSWORD)>63){ESP_LOGE(TAG,"CONFIG_LENGTH_REJECT: example requires SSID1..31 bytes and WPA2 password8..63; values omitted");return false;}
 if(!network_started){
  events=xEventGroupCreate();if(!events)return false;
  ESP_ERROR_CHECK(esp_netif_init());ESP_ERROR_CHECK(esp_event_loop_create_default());esp_netif_create_default_wifi_sta();
  wifi_init_config_t init=WIFI_INIT_CONFIG_DEFAULT();ESP_ERROR_CHECK(esp_wifi_init(&init));
  ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT,ESP_EVENT_ANY_ID,network_event,NULL));
  ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT,IP_EVENT_STA_GOT_IP,network_event,NULL));
  ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM));wifi_config_t wi={0};
  snprintf((char*)wi.sta.ssid,sizeof(wi.sta.ssid),"%s",CONFIG_R05_SSID);snprintf((char*)wi.sta.password,sizeof(wi.sta.password),"%s",CONFIG_R05_PASSWORD);
  wi.sta.threshold.authmode=WIFI_AUTH_WPA2_PSK;
  ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA,&wi));ESP_ERROR_CHECK(esp_wifi_start());network_started=true;
 }
 if(!(xEventGroupGetBits(events)&1)){
  esp_err_t e=esp_wifi_connect();if(e!=ESP_OK&&e!=ESP_ERR_WIFI_CONN){ESP_LOGE(TAG,"CONNECT_REQUEST=%s",esp_err_to_name(e));return false;}
  if(!(xEventGroupWaitBits(events,1,pdFALSE,pdTRUE,pdMS_TO_TICKS(15000))&1)){ESP_LOGE(TAG,"CONNECT_TIMEOUT; current app retained; retry command later");return false;}
 }
 if(!sntp_started){esp_sntp_config_t sc=ESP_NETIF_SNTP_DEFAULT_CONFIG(CONFIG_R05_SNTP);sc.wait_for_sync=true;ESP_ERROR_CHECK(esp_netif_sntp_init(&sc));sntp_started=true;}
 if(last_sync_ms<0||esp_timer_get_time()/1000-last_sync_ms>=3600000){
  if(esp_netif_sntp_sync_wait(pdMS_TO_TICKS(15000))!=ESP_OK){ESP_LOGE(TAG,"SNTP_WAIT_TIMEOUT; no insecure fallback");return false;}
  last_sync_ms=esp_timer_get_time()/1000;
 }
 if(time(NULL)<1767225600){ESP_LOGE(TAG,"CLOCK_RANGE_REJECT");return false;}return true;
}
static void perform_ota(const char*path){
 if(!network_ready())return;
 char url[256];int n=snprintf(url,sizeof(url),"%s%s",CONFIG_R05_URL,path);if(n<0||n>=(int)sizeof(url)){ESP_LOGE(TAG,"URL_TOO_LONG");return;}
 esp_http_client_config_t hc={.url=url,.cert_pem=ca_start,.timeout_ms=3000,.keep_alive_enable=false,.skip_cert_common_name_check=false};
 esp_https_ota_config_t oc={.http_config=&hc};esp_https_ota_handle_t handle=NULL;
 int64_t began=esp_timer_get_time()/1000;esp_err_t err=esp_https_ota_begin(&oc,&handle);
 if(err!=ESP_OK){ESP_LOGE(TAG,"BEGIN_REJECT=%s current app retained",esp_err_to_name(err));return;}
 do{
  if(ota_deadline(began,esp_timer_get_time()/1000)){err=ESP_ERR_TIMEOUT;break;}
  err=esp_https_ota_perform(handle);vTaskDelay(pdMS_TO_TICKS(1));
 }while(err==ESP_ERR_HTTPS_OTA_IN_PROGRESS);
 /* Network operation timeout is not a hard total deadline: each call can block. */
 if(err!=ESP_OK||!esp_https_ota_is_complete_data_received(handle)){
  ESP_LOGE(TAG,"DOWNLOAD_REJECT=%s bytes=%d; abort keeps existing boot selection",esp_err_to_name(err),esp_https_ota_get_image_len_read(handle));
  esp_err_t a=esp_https_ota_abort(handle);ESP_LOGI(TAG,"ABORT=%s",esp_err_to_name(a));return;
 }
 err=esp_https_ota_finish(handle); /* validates image and selects next boot only on success */
 if(err!=ESP_OK){ESP_LOGE(TAG,"FINISH_REJECT=%s; no success/restart claimed",esp_err_to_name(err));return;}
 ESP_LOGW(TAG,"IMAGE_SELECTED: restarting; pending candidate must self-test before VALID");fflush(stdout);vTaskDelay(pdMS_TO_TICKS(200));esp_restart();
}
static bool self_test(void){
 /* Only software/NVS availability: not Wi-Fi, peripherals or power certification. */
 uint8_t*p=malloc(1024);if(!p)return false;memset(p,0xA5,1024);bool ok=true;for(size_t i=0;i<1024;i++)if(p[i]!=0xA5)ok=false;free(p);
#if CONFIG_R05_REJECT_SELFTEST
 ok=false;
#endif
 return ok;
}
void app_main(void){
 const esp_partition_t*run=esp_ota_get_running_partition();esp_ota_img_states_t state=ESP_OTA_IMG_UNDEFINED;esp_err_t query=esp_ota_get_state_partition(run,&state);
 ESP_LOGI(TAG,"BOOT partition=%s subtype=%d query=%s state=%d role=%s reset=%d",run->label,run->subtype,esp_err_to_name(query),state,
#if CONFIG_R05_CANDIDATE
 "candidate",
#else
 "baseline",
#endif
 esp_reset_reason());
 esp_err_t e=nvs_flash_init();if(e!=ESP_OK){ESP_LOGE(TAG,"NVS=%s no auto erase; pending image left unconfirmed",esp_err_to_name(e));return;}
 if(query==ESP_OK&&state==ESP_OTA_IMG_PENDING_VERIFY){
  ESP_LOGW(TAG,"PENDING_SELFTEST: 10s observation; reset here leaves image unconfirmed");vTaskDelay(pdMS_TO_TICKS(10000));
  if(!self_test()){
   ESP_LOGE(TAG,"SELFTEST_REJECT: requesting rollback (not anti-rollback/eFuse)");fflush(stdout);e=esp_ota_mark_app_invalid_rollback_and_reboot();ESP_LOGE(TAG,"ROLLBACK_REQUEST_RETURNED=%s; do not mark valid",esp_err_to_name(e));return;
  }
  e=esp_ota_mark_app_valid_cancel_rollback();ESP_LOGI(TAG,"SELFTEST_ACCEPT mark-valid=%s",esp_err_to_name(e));if(e!=ESP_OK)return;
 }else ESP_LOGI(TAG,"NOT_PENDING: no state confirmation requested");
 fcntl(fileno(stdin),F_SETFL,O_NONBLOCK);OtaLine line={0};char cmd[32];
 ESP_LOGI(TAG,"READY: explicit complete commands ota good / ota bad / ota cut. Default placeholders never connect.");
 for(;;){int ch;while((ch=getchar())!=EOF){if(ota_line(&line,ch,cmd)){const char*path=ota_path(cmd);if(path)perform_ota(path);else ESP_LOGW(TAG,"UNKNOWN_COMMAND");}}clearerr(stdin);vTaskDelay(pdMS_TO_TICKS(20));}
}

// ---- main/ota_policy.h ----
/* SPDX-License-Identifier: MIT */
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#include <string.h>
typedef struct {char data[32];size_t used;bool discard;} OtaLine;
/* Complete CR/LF-delimited commands only; overflow/control chars drain line. */
static inline bool ota_line(OtaLine*s,int c,char out[32]){
 if(c=='\r'||c=='\n'){bool ok=!s->discard&&s->used>0;if(ok){memcpy(out,s->data,s->used);out[s->used]=0;}s->used=0;s->discard=false;return ok;}
 if(c<32||c>126||s->used>=31){s->discard=true;return false;}
 if(!s->discard){s->data[s->used++]=(char)c;}
 return false;
}
static inline bool ota_deadline(int64_t start,int64_t now){return now<start||now-start>=60000;}
static inline const char*ota_path(const char*command){
 if(!strcmp(command,"ota good"))return "/good.bin";
 if(!strcmp(command,"ota bad"))return "/bad.bin";
 if(!strcmp(command,"ota cut"))return "/cut.bin";
 return NULL;
}

参考:[2] esp_ota_ops.h:确认、回退及错误返回[3] esp_https_ota.h:begin/perform/finish/abort所有权[4] HTTPS OTA实现:完整性、镜像结束与选中槽[6] esp_netif SNTP:等待信号量与同步事件

09 / 可维护设备实验

完整端点与仅供QEMU的预装工具

ota_server只服务三个固定路径,限制app魔数与单槽大小;qemu_flash按固定版本布局生成8MiB实验图与输入SHA清单。后者不是设备刷写或无线升级工具,文件新建不覆盖。

代码 / 配置
# ---- tools/ota_server.py ----
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Owned local TLS fixture: same candidate app, invalid magic, interrupted body."""
import argparse,http.server,ssl,pathlib,socket
p=argparse.ArgumentParser();p.add_argument('--cert',required=True);p.add_argument('--key',required=True);p.add_argument('--image',required=True);p.add_argument('--bind',default='127.0.0.1');p.add_argument('--port',type=int,default=8443);a=p.parse_args();image=pathlib.Path(a.image).read_bytes()
if not 32<=len(image)<=0x180000 or image[0]!=0xe9:p.error('candidate app .bin expected, <=one 0x180000-byte slot; never merged image')
class Handler(http.server.BaseHTTPRequestHandler):
 def do_GET(self):
  if self.path not in ('/good.bin','/bad.bin','/cut.bin'):self.send_error(404);return
  body=image if self.path!='/bad.bin'else b'not-an-ESP32-image\n';self.send_response(200);self.send_header('Content-Type','application/octet-stream');self.send_header('Content-Length',str(len(body)));self.end_headers()
  if self.path=='/cut.bin':self.wfile.write(body[:len(body)//2]);self.wfile.flush();self.close_connection=True;self.connection.shutdown(socket.SHUT_RDWR)
  else:self.wfile.write(body)
s=http.server.ThreadingHTTPServer((a.bind,a.port),Handler);c=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER);c.minimum_version=ssl.TLSVersion.TLSv1_2;c.load_cert_chain(a.cert,a.key);s.socket=c.wrap_socket(s.socket,server_side=True);print('OWNED_TLS_FIXTURE',s.server_address,'candidate_bytes',len(image),flush=True)
try:s.serve_forever()
except KeyboardInterrupt:pass
finally:s.server_close()

# ---- tools/qemu_flash.py ----
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Build ONLY a local S3 QEMU fixture, not a flash/OTA command for a board."""
import argparse,pathlib,struct,binascii,hashlib,json
p=argparse.ArgumentParser();p.add_argument('--baseline-build',required=True);p.add_argument('--candidate-build',required=True);p.add_argument('--out',required=True);a=p.parse_args();b=pathlib.Path(a.baseline_build);c=pathlib.Path(a.candidate_build);out=pathlib.Path(a.out)
parts=[(0,b/'bootloader/bootloader.bin'),(0x8000,b/'partition_table/partition-table.bin'),(0x20000,b/'r05_maintenance.bin'),(0x1a0000,c/'r05_maintenance.bin')];flash=bytearray(b'\xff'*(8*1024*1024));rows=[]
for at,f in parts:
 data=f.read_bytes()
 if f.name=='r05_maintenance.bin'and(len(data)>0x180000 or data[0]!=0xe9):p.error('app image/slot mismatch')
 flash[at:at+len(data)]=data;rows.append({'offset':at,'source':str(f),'bytes':len(data),'sha256':hashlib.sha256(data).hexdigest()})
# Fixed SDK5.5.5 esp_ota_select_entry_t; seq1 selects ota_0, stateNEW, CRC covers seq only.
seq=struct.pack('<I',1);entry=struct.pack('<I20sII',1,b'\xff'*20,0,binascii.crc32(seq,0xffffffff)&0xffffffff);flash[0xf000:0xf020]=entry
with out.open('xb')as f:f.write(flash)
(out.with_suffix('.json')).write_text(json.dumps({'scope':'Preloaded QEMU-only flash fixture, NOT an HTTPS update or Wi-Fi result. Use -snapshot; never flash this on a board.','inputs':rows,'otaEntryState':'NEW=0, sequence1, crc32(sequence,0xffffffff); other copy erased','imageSHA256':hashlib.sha256(flash).hexdigest()},indent=2)+'\n');print('QEMU_ONLY_IMAGE',out)

参考:[1] 固定OTA文档:回滚状态与有效回退镜像条件[5] 固定bootloader:NEW/PENDING/ABORTED处理[8] otatool:官方离线分区与OTA数据工具

10 / 可维护设备实验

下一步是按空表做一次真实恢复

ota-observations.csv仅表头。待核真实无线下载/错误拒绝、PENDING窗口RESET、VALID持久状态、受控断电与备份回写;本机结果不能代填。失败记录启动槽、错误码、固件与ELF SHA、时间和原log引用,再决定恢复动作。

恢复链成立后进入R06故障注入与长观察;升级能回退仍不代表设备能稳定放一周。

参考:[1] 固定OTA文档:回滚状态与有效回退镜像条件[2] esp_ota_ops.h:确认、回退及错误返回[7] 固定官方DevKitC-1 v1.1:N8R8、供电、USB口与J1针脚

BACK TO THE SOURCE

资料来源与版本说明

保留原计划ID、slug、M5阶段和用途。固定来源鲜取;编译、主机服务/逻辑、QEMU与实体结果分别记录。实际实体观察表仍空。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. 固定OTA文档:回滚状态与有效回退镜像条件
  2. esp_ota_ops.h:确认、回退及错误返回
  3. esp_https_ota.h:begin/perform/finish/abort所有权
  4. HTTPS OTA实现:完整性、镜像结束与选中槽
  5. 固定bootloader:NEW/PENDING/ABORTED处理
  6. esp_netif SNTP:等待信号量与同步事件
  7. 固定官方DevKitC-1 v1.1:N8R8、供电、USB口与J1针脚
  8. otatool:官方离线分区与OTA数据工具