一页“刚下载”的天气,可能已经旧了两小时
信息牌最容易让人误会的地方,是把HTTP200、数据更新时间和屏幕上的时间写成同一个“刚更新”。本篇保留三个时刻:OBS来自JSON的实际观察UTC,FETCH是本次启动最近字段校验成功的单调时刻,PAGE是开始绘制这一页的时间。下载不会改写OBS。
页面固定写SNAPSHOT。断网或断电后墨水仍可能留在上一页,因此“FRESH CACHE”只描述PAGE时刻判断,不能证明此刻在线。默认没有配置和有效缓存时明确显示NO WEATHER / NO TODO,不塞一条看起来正常的天气。自备数据与显式DEMO在纸面上也分开标。
- 做到这里,你应该看到
- 先能解释“数据旧了”和“这一页旧了”的区别,再接网络。
参考:[2] GDEY0213B74具体面板/SSD1680Z[6] Arduino3.3.12 HTTPClient[8] Arduino3.3.12 configTime
沿用D04的面板号、适配器修订和3V3接线
固定Good Display GDEY0213B74黑白面板,SSD1680Z,122×250可见、128×250 RAM;GxEPD2_213_GDEY0213B74全高缓冲4000B,rotation1逻辑画布250×122。只知道2.13寸不能换同名驱动。DESPI-C02必须是P3标2.2Ω/0.47Ω的当前板并选2.2Ω;旧Rev1.1资料的3Ω选项不作为本篇接线依据。
断电后可靠安装FPC/排针;没有组装条件先请具备条件者处理,不能松插。3V3→VCC,GND共地;SCK12、SDI11、CS5、DC4、RES6、BUSY10。CS和MCU侧BUSY各10kΩ上拉3V3;无MISO。可选BUSY串SPST用于受控故障,运行时只操作预装的信号开关,不拔FPC或供电。
| 适配器 | GPIO / v1.1排针 |
|---|---|
| VCC / GND | 3V3 J1-1 / GND J1-22 |
| SCK / SDI | 12 J1-18 / 11 J1-17 |
| CS / DC / RES | 5 J1-5 / 4 J1-4 / 6 J1-6 |
| BUSY高忙 | 10 J1-16;MCU侧10kΩ上拉 |
参考:[1] DevKitC-1 v1.1排针[2] GDEY0213B74具体面板/SSD1680Z[3] DESPI-C02当前2.2Ω修订[4] GxEPD2固定面板类1.6.9
先复建默认工程,再填自己的私有网络配置
固定Arduino CLI1.5.1、Arduino-ESP323.3.12、GxEPD21.6.9、GFX1.12.4、BusIO1.17.4、ArduinoJson7.4.2。下载目录含完整.ino、三个逻辑头、BusyBudget、版本锁、profile、服务端、测试、README、MIT与第三方许可。GxEPD2为GPL-3.0,链接后的固件分发须遵守该库条款,不能宣称整个固件只有MIT。
已有精确全局依赖时用README完整FQBN编译;sketch.yaml只有profiles,不默认重复安装工具链。IDE选S3 Dev Module、8MB Flash、OPI PSRAM、QIO、8M with spiffs、CDC On Boot Disabled、Hardware CDC and JTAG。默认分支已真实编译;D05_BUILD_NETWORK_CHECK公开占位分支也实际编译,但不能连网、不应上传作功能验证。
- 解压完整esp32-epaper-info-board目录,按README固定库版本。
- 执行arduino-cli compile --fqbn esp32:esp32:esp32s3:FlashSize=8M,PSRAM=opi,FlashMode=qio,PartitionScheme=default_8MB,CDCOnBoot=default,USBMode=hwcdc .,通过后用自己桥接串口上传并监视115200。
- 先看CONFIG NEEDED及180秒首次等待,确认默认不会谎报数据成功。
- 复制network_config_local.example.h为network_config_local.h,填写自己的SSID、8~63字节密码、http://局域网IP:8080/data与NTP主机/IP;私密文件不要提交。
// ---- network_config.h ----
// SPDX-License-Identifier: MIT
#pragma once
#if defined(D05_BUILD_NETWORK_CHECK)
static const char WIFI_SSID[]="DO_NOT_CONNECT_BUILD_ONLY";
static const char WIFI_PASSWORD[]="PUBLIC_COMPILE_ONLY_PASSWORD";
static const char INFO_URL[]="http://192.0.2.1:8080/data";
static const char NTP_SERVER[]="192.0.2.123";
#elif __has_include("network_config_local.h")
#include "network_config_local.h"
#else
static const char WIFI_SSID[]="REPLACE_WITH_2G4_SSID";
static const char WIFI_PASSWORD[]="REPLACE_WITH_PASSWORD";
static const char INFO_URL[]="";
static const char NTP_SERVER[]="";
#endif
// ---- network_config_local.example.h ----
// SPDX-License-Identifier: MIT
#pragma once
// Copy to network_config_local.h, edit locally; do not publish real credentials.
static const char WIFI_SSID[]="REPLACE_WITH_2G4_SSID";
static const char WIFI_PASSWORD[]="REPLACE_WITH_PASSWORD";
static const char INFO_URL[]="http://REPLACE_WITH_YOUR_SERVER_IP:8080/data";
static const char NTP_SERVER[]="REPLACE_WITH_YOUR_NTP_SERVER";
- 做到这里,你应该看到
- 默认无配置也应可启动;联网成功需由后续真实运行记录证明。
服务默认返回未配置,合成数据必须显式选择
下载server/test_server.py只用Python3标准库。默认python3 server/test_server.py监听127.0.0.1:8080,/data返回UNCONFIGURED,不能被另一块板访问。server/data.example.json也是字段说明空壳,不是有效天气。先按下表制作自己的标准JSON,数字不加引号,observed_at使用真实观测UTC秒;编辑待办不应重置天气的观测时刻。
用python3 server/test_server.py --bind 自己电脑LAN地址 --file /绝对路径/my-data.json,先在电脑curl该URL。/data保持文件原时间;只有显式--demo才生成source=DEMO的合成天气。旧/未来/缺/过大等故障路径对有效原数据的派生载荷标DEMO,不把人工注入时间当正常观测。仅在自己控制的可信LAN运行;这份服务没有TLS、身份认证或网页。
| 字段 | 要求 |
|---|---|
| schema | 数值1 |
| observed_at | uint32 UTC epoch,2020-01-01~2100-01-01 |
| temperature_c | 有限数值−50~60;显示0.1℃只是格式 |
| weather / todo | 可见ASCII 1~15 / 1~28字节,至少一个非空格 |
| source | MANUAL或DEMO |
# ---- server/test_server.py ----
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Trusted-LAN teaching source. Default is unconfigured; --demo is synthetic, never weather measurement."""
import argparse
import json
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
def payload(mode, demo, source_file):
if demo:
data = {"schema": 1, "observed_at": int(time.time()), "temperature_c": 23.5,
"weather": "CLOUDY", "todo": "Water plants", "source": "DEMO"}
elif source_file:
data = json.loads(Path(source_file).read_text(encoding="utf-8"))
else:
data = {"status": "UNCONFIGURED: use your data file or explicit --demo"}
if mode in {"stale", "future", "missing", "large"} and "observed_at" in data:
data["source"] = "DEMO" # Fault-injected derivative is not an unmodified manual observation.
if mode == "stale" and "observed_at" in data:
data["observed_at"] = int(time.time()) - 7200
if mode == "future" and "observed_at" in data:
data["observed_at"] = int(time.time()) + 3600
if mode == "missing":
data.pop("todo", None)
if mode == "large":
data["todo"] = "X" * 1500
return data
def handler(demo=False, source_file=None):
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
mode = self.path.split("?", 1)[0].strip("/")
if mode not in {"data", "stale", "future", "missing", "large", "slow", "malformed", "error"}:
self.send_error(404)
return
if mode == "slow":
time.sleep(3)
status = 503 if mode == "error" else 200
try:
body = b"{broken" if mode == "malformed" else json.dumps(payload(mode, demo, source_file), ensure_ascii=True).encode()
except (OSError, ValueError) as error:
status, body = 500, json.dumps({"error": str(error)}).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass # Expected when a timeout-test client already left.
def log_message(self, *_):
pass
return Handler
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--bind", default="127.0.0.1", help="Use own LAN address only when intentionally allowing the board")
parser.add_argument("--port", type=int, default=8080)
group = parser.add_mutually_exclusive_group()
group.add_argument("--demo", action="store_true", help="explicit synthetic weather/todo, source always DEMO")
group.add_argument("--file", help="your edited JSON; observed_at is observation time, never rewritten by /data")
args = parser.parse_args()
print(f"Listening on {args.bind}:{args.port}; source={'SYNTHETIC DEMO' if args.demo else args.file or 'UNCONFIGURED'}", flush=True)
ThreadingHTTPServer((args.bind, args.port), handler(args.demo, args.file)).serve_forever()
if __name__ == "__main__":
main()
- 做到这里,你应该看到
- 电脑实际看到自己的原时间,或清晰的DEMO标签;不把示例数字说成测量结果。
完整对象通过检查,才替换载荷
HTTP仅收200和正Content-Length≤1024;Transfer-Encoding除identity外明确拒绝,不自动跳转。读取正文前限制大小,再用ArduinoJson7.4.2、嵌套深度3解析;检查根对象、类型、范围、文本字节数、内嵌NUL和非ASCII。自定义Reader共享游标,根对象之后只允许空白,避免库读到一个对象就停时接受额外垃圾。
先在候选Payload完成全部校验,最后一次赋给out。缺字段、503、超大正文或无效JSON不覆盖最后载荷。合法但旧的JSON可以成为新载荷,并明确显示STALE;不能偷偷改observed_at让它变新。浮点转换成十分之一度不是对传感器精度的保证,十进制恰好半值还受二进制浮点表示影响。
// ---- InfoModel.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <string.h>
namespace info {
constexpr size_t BODY_LIMIT=1024;
struct Payload {uint32_t observed=0;int16_t tenths=0;bool demo=false;char weather[16]={},todo[32]={};};
inline bool textOk(const char* b,size_t n,size_t limit){if(!b||n<1||n>limit||strlen(b)!=n)return false;bool visible=false;for(size_t i=0;i<n;++i){if((unsigned char)b[i]<32||(unsigned char)b[i]>126)return false;if(b[i]!=' ')visible=true;}return visible;}
enum Fetch{CONFIG_NEEDED,OFFLINE,CONNECTING,HTTP_ERROR,BODY_ERROR,BAD_FIELDS,OK};
inline const char* fetchName(Fetch f){switch(f){case CONFIG_NEEDED:return "CONFIG NEEDED";case OFFLINE:return "OFFLINE";case CONNECTING:return "CONNECTING";case HTTP_ERROR:return "HTTP ERROR";case BODY_ERROR:return "BODY ERROR";case BAD_FIELDS:return "BAD FIELDS";case OK:return "OK";}return "?";}
enum Cache{EMPTY,LOADED,SAVED,SAVE_FAILED,BAD_CACHE};
inline const char* cacheName(Cache c){switch(c){case EMPTY:return "NONE";case LOADED:return "LOADED";case SAVED:return "SAVED";case SAVE_FAILED:return "ERROR";case BAD_CACHE:return "BAD";}return "?";}
struct State {Cache cache=EMPTY;int httpCode=0;bool have=false,timeTrusted=false,receivedThisBoot=false;Payload payload;Fetch fetch=CONFIG_NEEDED;uint64_t syncedAt=0,receivedAt=0;};
inline bool trusted(const State& s,uint64_t now){return s.timeTrusted&&now>=s.syncedAt&&now-s.syncedAt<=7200000;}
inline void expireTrust(State& s,uint64_t now){if(s.timeTrusted&&!trusted(s,now))s.timeTrusted=false;}
inline const char* status(const State& s,uint64_t mono,uint32_t epoch){
if(!s.have)return "NO CACHE";
if(!trusted(s,mono))return "TIME UNKNOWN";
if(uint64_t(s.payload.observed)>uint64_t(epoch)+5)return "FUTURE CACHE";
if(epoch>=s.payload.observed&&epoch-s.payload.observed>900)return "STALE CACHE";
if(s.fetch==OFFLINE||s.fetch==CONNECTING)return "OFFLINE CACHE";
if(s.fetch!=OK)return "FETCH ERR CACHE";
return "FRESH CACHE";
}
inline int64_t ageSeconds(const State& s,uint64_t mono,uint32_t epoch){if(!s.have||!trusted(s,mono))return -1;if(epoch<s.payload.observed)return s.payload.observed-epoch<=5?0:-1;return int64_t(epoch-s.payload.observed);}
inline uint32_t crc32(const uint8_t* b,size_t n){uint32_t c=0xffffffff;for(size_t i=0;i<n;++i){c^=b[i];for(int k=0;k<8;++k)c=(c&1)?(c>>1)^0xedb88320u:c>>1;}return ~c;}
constexpr size_t CACHE_SIZE=128;
inline void put32(uint8_t* b,uint32_t n){for(int i=0;i<4;++i)b[i]=uint8_t(n>>(8*i));}
inline uint32_t get32(const uint8_t* b){uint32_t n=0;for(int i=0;i<4;++i)n|=uint32_t(b[i])<<(8*i);return n;}
inline bool payloadOk(const Payload& p){return p.observed>=1577836800u&&p.observed<=4102444800u&&p.tenths>=-500&&p.tenths<=600&&textOk(p.weather,strnlen(p.weather,sizeof p.weather),15)&&textOk(p.todo,strnlen(p.todo,sizeof p.todo),28);}
inline void encode(const Payload& p,uint8_t b[CACHE_SIZE]){memset(b,0,CACHE_SIZE);memcpy(b,"D05C",4);b[4]=1;b[5]=p.demo;b[6]=CACHE_SIZE;put32(b+8,p.observed);uint16_t t=uint16_t(p.tenths);b[12]=t;b[13]=t>>8;memcpy(b+16,p.weather,strlen(p.weather));memcpy(b+32,p.todo,strlen(p.todo));put32(b+124,crc32(b,124));}
inline bool decode(const uint8_t* b,size_t n,Payload& out){
if(!b||n!=CACHE_SIZE||memcmp(b,"D05C",4)||b[4]!=1||b[5]>1||b[6]!=CACHE_SIZE||b[7]||b[14]||b[15]||get32(b+124)!=crc32(b,124))return false;
for(size_t i=64;i<124;++i)if(b[i])return false;
if(!memchr(b+16,0,16)||!memchr(b+32,0,32))return false;
Payload p;p.observed=get32(b+8);uint16_t raw=uint16_t(b[12])|(uint16_t(b[13])<<8);p.tenths=int16_t(raw>=32768?int(raw)-65536:int(raw));p.demo=b[5];memcpy(p.weather,b+16,16);memcpy(p.todo,b+32,32);
if(!payloadOk(p))return false;
out=p;return true;
}
}
// ---- JsonPayload.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <ArduinoJson.h>
#include <math.h>
#include "InfoModel.h"
namespace info {
// This reader shares its cursor so the bytes after the parsed object can be checked.
struct Reader {const char* data;size_t length;size_t* at;int read(){return *at<length?(unsigned char)data[(*at)++]:-1;}size_t readBytes(char* out,size_t n){size_t k=0;while(k<n&&*at<length)out[k++]=data[(*at)++];return k;}};
inline bool parse(const char* body,size_t length,Payload& out){
if(!body||length==0||length>BODY_LIMIT)return false;
JsonDocument doc;size_t at=0;Reader reader{body,length,&at};
auto error=deserializeJson(doc,reader,DeserializationOption::NestingLimit(3));
if(error||!doc.is<JsonObject>())return false;
for(;at<length;++at)if(body[at]!=' '&&body[at]!='\r'&&body[at]!='\n'&&body[at]!='\t')return false;
if(!doc["schema"].is<unsigned>()||doc["schema"].as<unsigned>()!=1||!doc["observed_at"].is<uint32_t>()||!doc["temperature_c"].is<double>())return false;
double t=doc["temperature_c"].as<double>();if(!isfinite(t)||t< -50||t>60)return false;
if(!doc["weather"].is<const char*>()||!doc["todo"].is<const char*>()||!doc["source"].is<const char*>())return false;
JsonString w=doc["weather"].as<JsonString>(),todo=doc["todo"].as<JsonString>(),src=doc["source"].as<JsonString>();
if(!textOk(w.c_str(),w.size(),15)||!textOk(todo.c_str(),todo.size(),28)||!textOk(src.c_str(),src.size(),6))return false;
if(strcmp(src.c_str(),"DEMO")&&strcmp(src.c_str(),"MANUAL"))return false;
Payload p;p.observed=doc["observed_at"].as<uint32_t>();p.tenths=int16_t(lround(t*10));p.demo=!strcmp(src.c_str(),"DEMO");memcpy(p.weather,w.c_str(),w.size());memcpy(p.todo,todo.c_str(),todo.size());
if(!payloadOk(p))return false;
out=p;return true;
}
}
缓存内容可恢复,时钟信任不能跨启动继承
Preferences使用d05-v1/cache的128字节D05C v1记录,含固定长度、范围和CRC32;只在编码内容改变时putBytes,并检查返回长度。存储失败显示STORE ERROR,RAM载荷仍可用。CRC检出常见损坏,不提供认证。Arduino3.3.12的核心初始化遇NVS_NO_FREE_PAGES或NEW_VERSION会擦除分区重建,换分区或主动擦写也会丢缓存;因此它是可失效的缓存,不能承诺永久保存。
本次启动收到SNTP通知、且单调时间距通知≤2小时才称时钟可用。回调只把通知投队列;工作任务先收通知再读当前时刻。不使用getLocalTime阻塞等待;没有NTP也能下载,但显示TIME UNKNOWN。重启读缓存先不信任系统时间,避免1970或旧系统时刻冒充新鲜。通知/信任年龄/接收年龄使用esp_timer的64位单调毫秒,并明确清掉到期信任;只有新通知才能恢复,不能在32位millis整圈后复活。普通SNTP未认证,结论依赖可信LAN及正确服务器。
| 优先状态 | 判定 |
|---|---|
| NO CACHE | 无有效载荷 |
| TIME UNKNOWN | 本次未同步 / 距同步超过2小时 |
| FUTURE CACHE | 观测领先当前UTC超过5秒 |
| STALE CACHE | 数据年龄>900秒;900秒整仍可新鲜 |
| OFFLINE CACHE | 尚新鲜但Wi-Fi断开/连接中 |
| FETCH ERR CACHE | 尚新鲜且在线,最近获取失败或未获取 |
| FRESH CACHE | 时间可信、年龄≤900秒、最近校验成功 |
参考:[7] Preferences3.3.12 putBytes[8] Arduino3.3.12 configTime[9] Arduino3.3.12初始化NVS的擦除条件
网络工作任务与唯一的面板调用者
工作任务负责Wi-Fi、HTTP、NTP通知与Preferences,长度1队列发布最新快照。默认占位不发网络请求;配置后每次连接等15秒,再按2/4/8/16/30秒退避,关闭自动重连以免两个策略抢控制。WiFi.persistent(false)在启动STA前设定,凭据来自本地RAM配置;串口不打印密码。正常上线立即获取,此后每60秒轮询。每次从在线转掉线都重建Retry截止,避免连续在线超过2^31毫秒后把旧截止误当未来。
连接超时1500ms、HTTP读取间隙1500ms,正文读取循环另有2秒预算;不是端到端硬截止,DNS、逐行响应头、调度与库内部操作可能更长。主循环独占屏幕,180秒门禁从每次实际尝试起算;每次重启也先等180秒。这个桌面作品保持MCU运行,并未测量整板省电。
// ---- BusyBudget.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
struct BusyBudget {
static constexpr uint32_t limitMs=8000;
uint32_t started=0;bool active=false;bool fault=false;
void begin(uint32_t now){started=now;active=true;fault=false;}
void observe(uint32_t now,bool busy){if(active&&busy&&uint32_t(now-started)>=limitMs)fault=true;}
bool finish(uint32_t now,bool busy){observe(now,busy);active=false;if(busy)fault=true;return !fault;}
};
inline bool refresh_due(uint32_t now,uint32_t last,bool attempted){return !attempted||uint32_t(now-last)>=180000;}
// ---- Retry.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
namespace info {
struct Retry {
bool attempting=false;uint32_t attemptAt=0,nextTry=0,backoff=2000;
void dropped(uint32_t now){attempting=false;nextTry=now;backoff=2000;}
bool timedOut(uint32_t now)const{return attempting&&uint32_t(now-attemptAt)>=15000;}
void failed(uint32_t now){attempting=false;nextTry=now+backoff;backoff=backoff>=16000?30000:backoff*2;}
bool due(uint32_t now)const{return !attempting&&int32_t(now-nextTry)>=0;}
void began(uint32_t now){attempting=true;attemptAt=now;}
void online(){attempting=false;backoff=2000;}
};
}
// ---- esp32-epaper-info-board.ino ----
// SPDX-License-Identifier: MIT
#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <SPI.h>
#include <GxEPD2_BW.h>
#include <gdey/GxEPD2_213_GDEY0213B74.h>
#include <esp_sntp.h>
#include <esp_timer.h>
#include <time.h>
#include "network_config.h"
#include "JsonPayload.h"
#include "BusyBudget.h"
#include "Retry.h"
constexpr int EPD_SCK=12,EPD_MOSI=11,EPD_CS=5,EPD_DC=4,EPD_RES=6,EPD_BUSY=10;
constexpr uint32_t REFRESH_MS=180000;
SPIClass epdSPI(FSPI);
GxEPD2_BW<GxEPD2_213_GDEY0213B74,GxEPD2_213_GDEY0213B74::HEIGHT>
display(GxEPD2_213_GDEY0213B74(EPD_CS,EPD_DC,EPD_RES,EPD_BUSY));
QueueHandle_t snapshots,ntpNotices;
BusyBudget busyBudget;
bool displayHalted=false;
uint32_t lastAttempt=0;
uint64_t monoMs(){return uint64_t(esp_timer_get_time()/1000);}
void timeSynced(struct timeval*){uint64_t at=monoMs();xQueueOverwrite(ntpNotices,&at);}
void busyCallback(const void*){busyBudget.observe(millis(),digitalRead(EPD_BUSY)==HIGH);delay(1);}
bool networkConfigured(){return strlen(WIFI_SSID)>0&&strlen(WIFI_SSID)<=32&&strcmp(WIFI_SSID,"REPLACE_WITH_2G4_SSID")&&strlen(WIFI_PASSWORD)>=8&&strlen(WIFI_PASSWORD)<=63&&strcmp(WIFI_PASSWORD,"REPLACE_WITH_PASSWORD")&&strlen(INFO_URL)>7&&strlen(INFO_URL)<=200&&!strncmp(INFO_URL,"http://",7);}
bool timeConfigured(){return strlen(NTP_SERVER)>0&&strlen(NTP_SERVER)<=128&&strncmp(NTP_SERVER,"REPLACE_",8)&&!strstr(NTP_SERVER,"://");}
info::Fetch fetchPayload(info::Payload& out,int& status){
NetworkClient tcp;HTTPClient http;http.setConnectTimeout(1500);http.setTimeout(1500);http.setFollowRedirects(HTTPC_DISABLE_FOLLOW_REDIRECTS);http.setReuse(false);
if(!http.begin(tcp,INFO_URL)){status=-1000;return info::HTTP_ERROR;}
const char* keys[]={"Transfer-Encoding"};http.collectHeaders(keys,1);
http.addHeader("Accept","application/json");status=http.GET();
if(status!=200){http.end();return info::HTTP_ERROR;}
String encoding=http.header("Transfer-Encoding");encoding.trim();if(encoding.length()&&!encoding.equalsIgnoreCase("identity")){http.end();return info::BODY_ERROR;}
int length=http.getSize();if(length<=0||length>int(info::BODY_LIMIT)){http.end();return info::BODY_ERROR;}
char body[info::BODY_LIMIT+1];size_t used=0;uint32_t started=millis();NetworkClient* stream=http.getStreamPtr();
if(!stream){http.end();return info::BODY_ERROR;}
while(used<size_t(length)&&uint32_t(millis()-started)<2000){
int available=stream->available();
if(available>0){size_t want=size_t(available);if(want>size_t(length)-used)want=size_t(length)-used;int n=stream->read((uint8_t*)body+used,want);if(n>0)used+=size_t(n);}
else if(!stream->connected())break;
delay(1);
}
http.end();if(used!=size_t(length))return info::BODY_ERROR;body[used]=0;
return info::parse(body,used,out)?info::OK:info::BAD_FIELDS;
}
void worker(void*){
info::State state;Preferences prefs;bool storeReady=prefs.begin("d05-v1",false);uint8_t lastStored[info::CACHE_SIZE]={};bool stored=false;
if(!storeReady){state.cache=info::SAVE_FAILED;Serial.println("STORE ERROR: cache is RAM only");}
else{size_t len=prefs.getBytesLength("cache");if(len){if(len==sizeof lastStored&&prefs.getBytes("cache",lastStored,sizeof lastStored)==sizeof lastStored&&info::decode(lastStored,len,state.payload)){state.have=true;state.cache=info::LOADED;stored=true;Serial.println("BOOT CACHE: payload restored, time untrusted until this boot syncs");}else{state.cache=info::BAD_CACHE;Serial.println("BAD CACHE: ignored, no default weather inserted");}}}
bool configured=networkConfigured(),ntpStarted=false,wasOnline=false;
info::Retry retry;
uint32_t lastPoll=0,lastReport=0;
if(configured){WiFi.persistent(false);WiFi.mode(WIFI_STA);WiFi.setAutoReconnect(false);}else Serial.println("CONFIG NEEDED: edit network_config_local.h; no Wi-Fi/HTTP attempt");
xQueueOverwrite(snapshots,&state);
for(;;){
uint64_t notice;if(xQueueReceive(ntpNotices,¬ice,0)==pdTRUE){state.timeTrusted=true;state.syncedAt=notice;Serial.println("TIME SYNC NOTICE: observed this boot; server not authenticated");}
uint32_t now=millis();uint64_t mono=monoMs();info::expireTrust(state,mono);bool online=configured&&WiFi.status()==WL_CONNECTED;
if(!configured)state.fetch=info::CONFIG_NEEDED;
else if(!online){
state.fetch=info::OFFLINE;
if(wasOnline){retry.dropped(now);Serial.println("WIFI lost: new retry timebase");}
if(retry.timedOut(now)){WiFi.disconnect(false,false);retry.failed(now);Serial.println("WIFI attempt deadline; backoff scheduled");}
if(retry.due(now)){WiFi.begin(WIFI_SSID,WIFI_PASSWORD);retry.began(now);state.fetch=info::CONNECTING;}
}else{
retry.online();
if(!wasOnline){Serial.print("ONLINE IP=");Serial.println(WiFi.localIP());}
if(!ntpStarted&&timeConfigured()){sntp_set_time_sync_notification_cb(timeSynced);configTime(0,0,NTP_SERVER);ntpStarted=true;}
if(!wasOnline||uint32_t(now-lastPoll)>=60000){
lastPoll=now;info::Payload candidate;state.fetch=fetchPayload(candidate,state.httpCode);
if(state.fetch==info::OK){
state.payload=candidate;state.have=true;state.receivedThisBoot=true;state.receivedAt=monoMs();
uint8_t encoded[info::CACHE_SIZE];info::encode(candidate,encoded);
if(storeReady&&(!stored||memcmp(encoded,lastStored,sizeof encoded))){if(prefs.putBytes("cache",encoded,sizeof encoded)==sizeof encoded){memcpy(lastStored,encoded,sizeof encoded);stored=true;state.cache=info::SAVED;}else state.cache=info::SAVE_FAILED;}
}
Serial.printf("FETCH code=%d result=%s retained_payload=%u observed=%lu source=%s\n",state.httpCode,info::fetchName(state.fetch),state.have,(unsigned long)state.payload.observed,state.have?(state.payload.demo?"DEMO":"MANUAL"):"NONE");
}
}
xQueueOverwrite(snapshots,&state);
if(uint32_t(now-lastReport)>=10000){lastReport=now;Serial.printf("STATE %s fetch=%s store=%s time_trusted=%u; physical page is a dated snapshot\n",info::status(state,monoMs(),uint32_t(time(nullptr))),info::fetchName(state.fetch),info::cacheName(state.cache),info::trusted(state,monoMs()));}
wasOnline=online;vTaskDelay(pdMS_TO_TICKS(200));
}
}
void lineAt(int y,const char* s){display.setCursor(5,y);display.print(s);}
void utc(uint32_t epoch,char out[24]){time_t t=epoch;struct tm tm;if(!gmtime_r(&t,&tm)||!strftime(out,24,"%Y-%m-%d %H:%MZ",&tm))strcpy(out,"NA");}
bool preflight(){uint32_t start=millis();while(digitalRead(EPD_BUSY)==HIGH&&uint32_t(millis()-start)<2000)delay(1);return digitalRead(EPD_BUSY)==LOW;}
void render(const info::State& s){
if(!preflight()){displayHalted=true;Serial.println("DISPLAY STOP: BUSY high before call; no display transaction");return;}
uint64_t mono=monoMs();uint32_t epoch=uint32_t(time(nullptr));bool clockOk=info::trusted(s,mono);char page[24]="UPTIME ONLY",observed[24]="NA";if(clockOk)utc(epoch,page);if(s.have)utc(s.payload.observed,observed);
uint32_t started=millis();busyBudget.begin(started);display.init(115200,true,10,false);display.setRotation(1);display.setFullWindow();display.setTextColor(GxEPD_BLACK);display.setTextSize(1);display.setTextWrap(false);
display.firstPage();
do{
char b[64];display.fillScreen(GxEPD_WHITE);display.drawRect(0,0,250,122,GxEPD_BLACK);
snprintf(b,sizeof b,"SNAPSHOT %s",info::status(s,mono,epoch));lineAt(4,b);
if(clockOk)snprintf(b,sizeof b,"PAGE %s",page);else snprintf(b,sizeof b,"PAGE UPTIME %llu S",(unsigned long long)(mono/1000));lineAt(17,b);
if(s.have){int t=s.payload.tenths;unsigned a=t<0?unsigned(-t):unsigned(t);snprintf(b,sizeof b,"%s %s %s%u.%u C",s.payload.demo?"DEMO":"MANUAL",s.payload.weather,t<0?"-":"",a/10,a%10);lineAt(30,b);snprintf(b,sizeof b,"TODO %s",s.payload.todo);lineAt(43,b);}else{lineAt(30,"NO WEATHER / NO TODO");lineAt(43,"Configure your own JSON source");}
snprintf(b,sizeof b,"OBS %s",observed);lineAt(56,b);
int64_t age=info::ageSeconds(s,mono,epoch);if(age<0)snprintf(b,sizeof b,"AGE@PAGE UNKNOWN");else snprintf(b,sizeof b,"AGE@PAGE %lld S",(long long)age);lineAt(69,b);
if(s.receivedThisBoot)snprintf(b,sizeof b,"FETCH AGE@PAGE %llu S",(unsigned long long)((mono>=s.receivedAt?mono-s.receivedAt:0)/1000));else snprintf(b,sizeof b,"FETCH AGE UNKNOWN / BOOT CACHE");lineAt(82,b);
snprintf(b,sizeof b,"HTTP %d %s",s.httpCode,info::fetchName(s.fetch));lineAt(95,b);
snprintf(b,sizeof b,"CLOCK %s STORE %s",clockOk?"OK":"UNKNOWN",info::cacheName(s.cache));lineAt(108,b);
}while(display.nextPage());
uint32_t elapsed=millis()-started;
if(!busyBudget.finish(millis(),digitalRead(EPD_BUSY)==HIGH)){displayHalted=true;Serial.printf("DISPLAY STOP after API returned call_ms=%lu; no further panel calls\n",(unsigned long)elapsed);return;}
Serial.printf("PANEL_API_RETURN_BUSY_LOW call_ms=%lu; optical completion not measured\n",(unsigned long)elapsed);
busyBudget.begin(millis());display.hibernate();if(!busyBudget.finish(millis(),digitalRead(EPD_BUSY)==HIGH)){displayHalted=true;Serial.println("DISPLAY STOP: hibernate result fault");}
}
void setup(){
Serial.begin(115200);delay(300);pinMode(EPD_BUSY,INPUT);digitalWrite(EPD_CS,HIGH);pinMode(EPD_CS,OUTPUT);
snapshots=xQueueCreate(1,sizeof(info::State));ntpNotices=xQueueCreate(1,sizeof(uint64_t));if(!snapshots||!ntpNotices){displayHalted=true;Serial.println("STOP queue allocation");return;}
if(!epdSPI.begin(EPD_SCK,-1,EPD_MOSI,EPD_CS)){displayHalted=true;Serial.println("STOP SPI begin");return;}
display.epd2.selectSPI(epdSPI,SPISettings(4000000,MSBFIRST,SPI_MODE0));display.epd2.setBusyCallback(busyCallback);
if(xTaskCreate(worker,"json_worker",8192,nullptr,2,nullptr)!=pdPASS){displayHalted=true;Serial.println("STOP worker allocation");return;}
lastAttempt=millis();Serial.println("D05 READY: first panel attempt after 180s; all pages are dated snapshots, not live status");
}
void loop(){
if(!displayHalted&&uint32_t(millis()-lastAttempt)>=REFRESH_MS){info::State s;if(xQueuePeek(snapshots,&s,0)==pdTRUE){lastAttempt=millis();render(s);}}
delay(10);
}
参考:[5] GxEPD2固定忙等待边界[6] Arduino3.3.12 HTTPClient[7] Preferences3.3.12 putBytes[8] Arduino3.3.12 configTime
按可重复输入检查旧、错、慢与离线
异常实验先以--demo启动服务,或用已满足字段的自备文件;故障派生页保留DEMO标签。每次切换私有INFO_URL路径重新编译上传,不擦NVS,等待180秒门禁。为证明“错误不覆盖”,先/data成功一次再换错输入;只从空缓存启动不能证明保留旧值。
另做AP断电/恢复与首次离线重启。关服务不等于断Wi-Fi,缺字段不等于DNS错误;串口FETCH code/result与纸面PAGE时刻分别记。/stale和/future需要可信时钟才能判断年龄;未同步时TIME UNKNOWN正是预期。
| 输入 | 应该观察到什么 |
|---|---|
| /data | 原观测时间、MANUAL或DEMO来源 |
| /stale / /future | 两小时前 / 一小时后;STALE / FUTURE |
| /missing / /malformed | BAD FIELDS,保留最近有效载荷 |
| /large | BODY ERROR,正文>1024B |
| /slow | 服务头前等3秒;ESP超时错误码和总耗时待实录 |
| /error | HTTP503→HTTP ERROR |
| 关闭AP、重启 | OFFLINE或TIME UNKNOWN;缓存存在性如实显示 |
- 如果卡住了
- 始终NO CACHE先检查服务是否仍为UNCONFIGURED、字段是否叫observed_at;不要把字段改成下载时间绕过旧数据判断。
先写清测量对象,再填写刷新耗时
固定库的_waitWhileBusy每次约10秒预算;超时打印Busy Timeout并从void等待返回,内部高层调用仍可能继续命令。8秒BusyBudget只是观察器,不能取消库调用。预检BUSY高2秒则不发事务;调用中发现异常后,要等高层真正返回才停止后续面板调用,且不再hibernate。正常返回且BUSY低才记录PANEL_API_RETURN_BUSY_LOW call_ms并hibernate。
作者没有实板,刷新耗时、残影、低温表现及掉电保留都没有数字。执行者在observations.csv写环境温度、供电、驱动类、PAGE/OBS、API起止与BUSY波形;用视频记录最后一次可见闪烁,分别写API耗时和光学完成时间。断电后1分钟/5分钟实拍也单列。故障后断电核FPC/P3/供电再RESET,不快速循环刷新试运气。
- 做到这里,你应该看到
- 日志“返回”只证明软件边界;照片/波形才能补齐纸面观察。
参考:[2] GDEY0213B74具体面板/SSD1680Z[3] DESPI-C02当前2.2Ω修订[4] GxEPD2固定面板类1.6.9[5] GxEPD2固定忙等待边界
下载、实际检查和下一步
默认与公开占位网络变体的Arduino真实构建均通过,输入SHA、完整命令和原日志在display-project-builds.json。10组同源host包含缺字段/错误类型/NUL/非ASCII/尾随垃圾、年龄/未来容差、64位时钟跨32位边界/到期不复活与128B缓存1024个位逐一翻转;另4组固件同源Retry头检查长在线掉线时基、15秒/短回绕/退避上限,8组真实Python loopback检查默认未配置、文件原时间、显式DEMO及故障路由。后者是Python客户端,不是ESP HTTPClient、无线、SNTP或屏幕仿真。
下载目录README给完整命令、接线、故障与恢复,observations.csv仍只有列头。先完成真实/data→错误→离线→恢复→重启的记录,再考虑更复杂布局。下一篇用实体旋钮做菜单,不依赖触摸屏;返回D04可单独排除面板驱动故障。
- 做到这里,你应该看到
- 当前结论:源码核对/真实构建/主机逻辑与loopback通过;真实LAN、NTP、纸面仍待测。
资料来源与版本说明
已核固定上游与实际安装源、真实构建及同源逻辑;实板效果未测,构建和host不替代屏幕观察。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。