【esp32】ESP32s3 n16r8 MLX90640热成像代码
·
namespace fs { class FS; }
using namespace fs;
#include <Wire.h>
#include <Adafruit_MLX90640.h>
#include <TFT_eSPI.h>
#include <FastLED.h>
#include <WiFi.h>
#include <WebServer.h>
#include <FS.h>
#include <SPIFFS.h>
#ifndef DISABLE_BLE
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#endif
// ================= 📺 布局参数 =================
#define SCREEN_W 240
#define SCREEN_H 240
TFT_eSPI tft = TFT_eSPI(SCREEN_W, SCREEN_H);
#define IMG_W 144
#define IMG_H 108
#define IMG_X 40
#define IMG_Y 28
#define LEGEND_W 12
#define LEGEND_X (SCREEN_W - LEGEND_W - 4)
#define LEGEND_Y IMG_Y
#define LEGEND_H IMG_H
#define STATUS_H 24
#define PANEL_H 32
#define PANEL_Y (SCREEN_H - PANEL_H)
#define FONT_SM 1
// ================= 🔌 引脚 =================
const uint8_t btnPin = 1;
const uint8_t btnLedPin = 2;
const uint8_t ledPin = 42;
const uint8_t wsPin = 48;
#define NUM_LEDS 2
#define I2C_SDA 8
#define I2C_SCL 9
// ================= 🌡️ 传感器 =================
Adafruit_MLX90640 mlx;
float frame[768], smooth[768], interp[IMG_W * IMG_H];
float tMin = 0, tMax = 0, tMinFixed = -10, tMaxFixed = 50;
bool autoRange = true;
float centerTemp = 0.0, hotSpotTemp = -999, coldSpotTemp = 999;
int hotSpotX = -1, hotSpotY = -1;
CRGB leds[NUM_LEDS];
// FPS
uint32_t lastFrameTime = 0;
float currentFPS = 0.0;
const float FPS_FILTER = 0.3f;
// ================= 📡 WiFi + WebServer =================
const char* WIFI_SSID = "WIFI";//默认无密码,连接自己WIFI添加密码即可
IPAddress localIP;
bool wifiConnected = false;
uint32_t lastWifiCheck = 0;
WebServer server(80);
// ================= 🔌 USB CDC =================
uint32_t lastSerialSend = 0;
const uint32_t SERIAL_SEND_INTERVAL = 1000;
// ================= 🔵 BLE =================
#ifndef DISABLE_BLE
#define BLE_SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define BLE_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"
#define BLE_DEVICE_NAME "Thermal-Pro"
BLEServer *pServer = nullptr;
BLECharacteristic *pCharacteristic = nullptr;
bool bleConnected = false;
uint32_t lastBleSend = 0;
const uint32_t BLE_SEND_INTERVAL = 1000;
class MyServerCallbacks : public BLEServerCallbacks {
void onConnect(BLEServer* pServer) override { bleConnected = true; Serial.println("🔵 BLE Connected"); }
void onDisconnect(BLEServer* pServer) override { bleConnected = false; Serial.println("🔵 BLE Disconnected"); pServer->startAdvertising(); }
};
#endif
// ================= 🚨 报警 =================
float alertThreshold = 40.0;
bool alertActive = false;
uint32_t lastAlertTime = 0;
const uint32_t ALERT_COOLDOWN = 3000;
// ================= 🎨 色板 =================
uint16_t ironbow565[256];
void buildIronbowTable() {
for (int i = 0; i < 256; i++) {
uint8_t r, g, b;
if (i < 30) { r = 0; g = map(i, 0, 29, 0, 80); b = 255; }
else if (i < 80) { r = map(i, 30, 79, 0, 200); g = map(i, 30, 79, 80, 255); b = map(i, 30, 79, 255, 50); }
else if (i < 150) { r = 255; g = map(i, 80, 149, 255, 30); b = map(i, 80, 149, 50, 0); }
else { r = 255; g = map(i, 150, 255, 30, 255); b = map(i, 150, 255, 0, 200); }
ironbow565[i] = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
}
}
inline uint8_t tempToIndex(float t, float minT, float maxT) {
if (maxT - minT < 1.0f) maxT = minT + 10.0f;
return (uint8_t)constrain((t - minT) / (maxT - minT) * 255.0f, 0, 255);
}
// ================= 🔧 温度处理 =================
float clampTemp(float t) { return constrain(t, -40, 150); }
void calcSmartRange(float* data, int len, float* outMin, float* outMax) {
float minV = 999, maxV = -999;
for (int i = 0; i < len; i++) { float t = clampTemp(data[i]); if (t < minV) minV = t; if (t > maxV) maxV = t; }
float span = maxV - minV;
if (span < 5.0f) { float mid = (minV + maxV) * 0.5f; *outMin = mid - 10.0f; *outMax = mid + 10.0f; }
else { *outMin = minV - span * 0.05f; *outMax = maxV + span * 0.05f; }
}
void findHotColdSpots() {
hotSpotTemp = -999; coldSpotTemp = 999; hotSpotX = -1; hotSpotY = -1;
for (int i = 0; i < 768; i++) {
float t = clampTemp(frame[i]);
if (t > hotSpotTemp) { hotSpotTemp = t; hotSpotY = i / 32; hotSpotX = i % 32; }
if (t < coldSpotTemp) coldSpotTemp = t;
}
}
float avgCenterTemp() {
float sum = 0; int cx = 16, cy = 12;
for (int dy = -4; dy < 4; dy++) for (int dx = -4; dx < 4; dx++) sum += clampTemp(frame[(cy + dy) * 32 + (cx + dx)]);
return sum / 64.0f;
}
// ================= 🖼️ 图像处理 =================
void smoothFrame() {
const float w[3][3] = {{1,2,1},{2,4,2},{1,2,1}};
for (int y = 1; y < 23; y++) for (int x = 1; x < 31; x++) {
float sum = 0, wsum = 0;
for (int dy = -1; dy <= 1; dy++) for (int dx = -1; dx <= 1; dx++) { sum += frame[(y + dy) * 32 + (x + dx)] * w[dy + 1][dx + 1]; wsum += w[dy + 1][dx + 1]; }
smooth[y * 32 + x] = sum / wsum;
}
for (int i = 0; i < 32; i++) { smooth[i] = frame[i]; smooth[23 * 32 + i] = frame[23 * 32 + i]; smooth[i * 32] = frame[i * 32]; smooth[i * 32 + 31] = frame[i * 32 + 31]; }
}
void bicubicAdaptive() {
const float colRatio = 23.0f / (IMG_W - 1), rowRatio = 31.0f / (IMG_H - 1), a = -0.5f;
for (int sy = 0; sy < IMG_H; sy++) {
float sc = sy * rowRatio;
int c0 = constrain((int)sc - 1, 0, 30), c1 = constrain((int)sc, 0, 31), c2 = constrain((int)sc + 1, 0, 31), c3 = constrain((int)sc + 2, 0, 31);
float fc = sc - (int)sc;
float wc[4] = { a*fc*fc*fc - 2*a*fc*fc + a*fc, (a+2)*fc*fc*fc - (a+3)*fc*fc + 1, -(a+2)*fc*fc*fc + (2*a+3)*fc*fc - a*fc, -a*fc*fc*fc + a*fc*fc };
for (int sx = 0; sx < IMG_W; sx++) {
float sr = sx * colRatio;
int r0 = constrain((int)sr - 1, 0, 22), r1 = constrain((int)sr, 0, 23), r2 = constrain((int)sr + 1, 0, 23), r3 = constrain((int)sr + 2, 0, 23);
float fr = sr - (int)sr;
float wr[4] = { a*fr*fr*fr - 2*a*fr*fr + a*fr, (a+2)*fr*fr*fr - (a+3)*fr*fr + 1, -(a+2)*fr*fr*fr + (2*a+3)*fr*fr - a*fr, -a*fr*fr*fr + a*fr*fr };
bool edge = (r0==0||r3==23||c0==0||c3==31);
if (edge) {
int rr0 = constrain((int)sr, 0, 23), rr1 = min(rr0 + 1, 23), cc0 = constrain((int)sc, 0, 31), cc1 = min(cc0 + 1, 31);
float fr2 = sr - rr0, fc2 = sc - cc0;
float v00 = smooth[rr0*32+cc0], v01 = smooth[rr0*32+cc1], v10 = smooth[rr1*32+cc0], v11 = smooth[rr1*32+cc1];
interp[sy*IMG_W+sx] = v00 + fc2*(v01-v00) + fr2*(v10-v00 + fc2*(v11-v10-v01+v00));
} else {
float result = 0;
for (int m = 0; m < 4; m++) {
float rowSum = 0;
for (int n = 0; n < 4; n++) {
int rr = (m==0)?r0:(m==1)?r1:(m==2)?r2:r3, cc = (n==0)?c0:(n==1)?c1:(n==2)?c2:c3;
rowSum += smooth[rr*32+cc] * wc[n];
}
result += rowSum * wr[m];
}
interp[sy*IMG_W+sx] = result;
}
}
}
}
void applySharpen(float strength = 0.25f) {
static float sharp[IMG_W * IMG_H];
for (int y = 1; y < IMG_H - 1; y++) for (int x = 1; x < IMG_W - 1; x++) {
float c = interp[y*IMG_W+x], lap = 5*c - interp[(y-1)*IMG_W+x] - interp[(y+1)*IMG_W+x] - interp[y*IMG_W+(x-1)] - interp[y*IMG_W+(x+1)];
sharp[y*IMG_W+x] = c + strength * (c - lap * 0.25f);
}
for (int i = 0; i < IMG_W; i++) { sharp[i] = interp[i]; sharp[(IMG_H-1)*IMG_W+i] = interp[(IMG_H-1)*IMG_W+i]; }
for (int j = 0; j < IMG_H; j++) { sharp[j*IMG_W] = interp[j*IMG_W]; sharp[j*IMG_W+IMG_W-1] = interp[j*IMG_W+IMG_W-1]; }
memcpy(interp, sharp, sizeof(interp));
}
// ================= 🎨 UI 绘制 =================
static uint16_t rowBuf[IMG_W];
void drawThermal(float mn, float mx) {
tft.setAddrWindow(IMG_X, IMG_Y, IMG_W, IMG_H);
for (int y = 0; y < IMG_H; y++) { for (int x = 0; x < IMG_W; x++) rowBuf[x] = ironbow565[tempToIndex(interp[y*IMG_W+x], mn, mx)]; tft.pushColors(rowBuf, IMG_W, false); }
tft.endWrite();
}
void drawLegend(float mn, float mx) {
tft.fillRect(LEGEND_X - 3, LEGEND_Y - 3, LEGEND_W + 6, LEGEND_H + 6, 0x18E3);
tft.fillRect(LEGEND_X, LEGEND_Y, LEGEND_W, LEGEND_H, TFT_BLACK);
for (int i = 0; i < LEGEND_H - 2; i++) { uint8_t idx = map(i, 0, LEGEND_H - 3, 255, 0); tft.drawFastHLine(LEGEND_X + 2, LEGEND_Y + 2 + i, LEGEND_W - 4, ironbow565[idx]); }
tft.setTextColor(TFT_WHITE); tft.setTextSize(FONT_SM); tft.setTextDatum(TR_DATUM);
tft.drawFastHLine(LEGEND_X - 2, LEGEND_Y + 2, 4, TFT_WHITE); tft.drawFloat(mx, 0, LEGEND_X - 6, LEGEND_Y + 4);
tft.drawFastHLine(LEGEND_X - 2, LEGEND_Y + LEGEND_H/2, 4, TFT_WHITE); tft.drawFloat((mn+mx)*0.5f, 0, LEGEND_X - 6, LEGEND_Y + LEGEND_H/2 - 2);
tft.drawFastHLine(LEGEND_X - 2, LEGEND_Y + LEGEND_H - 3, 4, TFT_WHITE); tft.drawFloat(mn, 0, LEGEND_X - 6, LEGEND_Y + LEGEND_H - 8);
tft.setTextDatum(TL_DATUM);
}
void drawTopBar() {
tft.fillRect(0, 0, SCREEN_W, STATUS_H, 0x1082);
tft.drawLine(0, STATUS_H, SCREEN_W, STATUS_H, TFT_DARKGREY);
tft.setTextColor(TFT_WHITE); tft.setTextSize(2); tft.setTextDatum(TC_DATUM);
tft.drawString("THERMAL PRO", SCREEN_W / 2, 5);
tft.setTextSize(FONT_SM); tft.setTextDatum(TR_DATUM);
tft.setTextColor(autoRange ? TFT_GREEN : TFT_YELLOW);
tft.drawString(autoRange ? "AUTO" : "MANUAL", SCREEN_W - 4, 8);
tft.setTextDatum(TL_DATUM);
}
void drawHotMarker() {
if (hotSpotX < 0) return;
int sx = IMG_X + (int)(hotSpotY * (IMG_W - 1) / 23.0f + 0.5f), sy = IMG_Y + (int)(hotSpotX * (IMG_H - 1) / 31.0f + 0.5f);
sx = constrain(sx, IMG_X + 6, IMG_X + IMG_W - 6); sy = constrain(sy, IMG_Y + 6, IMG_Y + IMG_H - 6);
tft.drawLine(sx - 5, sy, sx + 5, sy, TFT_RED); tft.drawLine(sx, sy - 5, sx, sy + 5, TFT_RED); tft.fillCircle(sx, sy, 2, TFT_RED);
tft.setTextColor(TFT_WHITE); tft.setTextSize(FONT_SM); tft.setTextDatum(TL_DATUM);
int tx = sx + 8, ty = sy - 10; if (tx + 32 > IMG_X + IMG_W) tx = sx - 34; if (ty < IMG_Y + 4) ty = sy + 8;
tft.drawFloat(hotSpotTemp, 1, tx, ty); tft.drawString("C", tx + 28, ty);
}
void drawCrosshair() {
int cx = IMG_X + IMG_W / 2, cy = IMG_Y + IMG_H / 2, arm = 10;
tft.drawLine(max(cx - arm, IMG_X), cy, cx - 2, cy, TFT_WHITE); tft.drawLine(cx + 2, cy, min(cx + arm, IMG_X + IMG_W - 1), cy, TFT_WHITE);
tft.drawLine(cx, max(cy - arm, IMG_Y), cx, cy - 2, TFT_WHITE); tft.drawLine(cx, cy + 2, cx, min(cy + arm, IMG_Y + IMG_H - 1), TFT_WHITE);
tft.fillCircle(cx, cy, 2, TFT_WHITE);
}
void drawAvgTemp() {
int y = IMG_Y + IMG_H + 8, centerX = IMG_X + IMG_W / 2;
tft.fillRect(centerX - 35, y - 2, 70, 20, TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextSize(2); tft.setTextDatum(TC_DATUM);
tft.drawFloat(centerTemp, 1, centerX, y); tft.drawString("C", centerX + 54, y); tft.setTextDatum(TL_DATUM);
}
void drawBottomPanel() {
tft.fillRect(0, PANEL_Y, SCREEN_W, PANEL_H, 0x0841); tft.drawLine(0, PANEL_Y, SCREEN_W, PANEL_Y, TFT_DARKGREY);
int line1 = PANEL_Y + 6, line2 = PANEL_Y + 20;
tft.setTextColor(TFT_RED); tft.setTextSize(FONT_SM); tft.setTextDatum(TL_DATUM);
tft.drawString("MAX ", 6, line1); tft.drawFloat(hotSpotTemp, 1, 42, line1); tft.drawString("C", 78, line1);
tft.setTextColor(TFT_CYAN); tft.setTextDatum(TR_DATUM);
tft.drawString(" MIN", SCREEN_W - 6, line1); tft.drawFloat(coldSpotTemp, 1, SCREEN_W - 42, line1); tft.drawString("C", SCREEN_W - 6, line1); tft.setTextDatum(TL_DATUM);
tft.setTextColor(currentFPS >= 15 ? TFT_GREEN : TFT_YELLOW);
tft.drawString("FPS:", 6, line2); tft.drawFloat(currentFPS, 0, 30, line2);
tft.setTextColor(wifiConnected ? TFT_WHITE : TFT_DARKGREY); tft.setTextDatum(TR_DATUM);
if (wifiConnected) { tft.drawString("IP:", SCREEN_W - 90, line2); tft.setTextColor(TFT_CYAN); tft.drawString(localIP.toString().c_str(), SCREEN_W - 6, line2); }
else { tft.drawString("CCIT-WLAN", SCREEN_W - 6, line2); }
tft.setTextDatum(TL_DATUM);
}
void drawFrame() { tft.drawRect(IMG_X - 2, IMG_Y - 2, IMG_W + 4, IMG_H + 4, TFT_DARKGREY); tft.drawRect(IMG_X - 1, IMG_Y - 1, IMG_W + 2, IMG_H + 2, TFT_WHITE); }
// ================= 🌐 精简网页 =================
const char* WEB_PAGE = R"rawliteral(
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Thermal Pro</title>
<style>
body{background:#0b0d10;color:#c8ccd4;font-family:system-ui,sans-serif;padding:10px;margin:0}
.header{display:flex;justify-content:space-between;padding:8px 12px;background:#15181e;border-radius:8px;margin-bottom:10px}
.title{font-weight:600;color:#00e5ff}.badge{padding:2px 6px;border-radius:12px;background:rgba(0,229,255,0.1);color:#00e5ff;font-size:0.8rem}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.card{background:#15181e;border-radius:8px;padding:12px}
.card h3{margin:0 0 8px 0;color:#00e5ff;font-size:1rem}
.temp-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;text-align:center}
.temp-val{font-size:1.3rem;font-weight:700}.temp-max{color:#ff3b30}.temp-min{color:#7dd3fc}.temp-avg{color:#34c759}
.threshold{display:flex;gap:6px;margin-top:6px}
.threshold input{flex:1;padding:4px 8px;background:#0f1216;border:1px solid #2a2e36;border-radius:4px;color:#fff}
.threshold button{padding:4px 10px;background:#00e5ff;color:#000;border:none;border-radius:4px;font-weight:600;cursor:pointer}
.alert{padding:6px 10px;background:rgba(255,59,48,0.15);border:1px solid #ff3b30;border-radius:4px;color:#ff3b30;margin-top:8px;display:none}
.alert.show{display:block}
.info-list{list-style:none;font-size:0.85rem;margin:0;padding:0}
.info-list li{display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px dashed rgba(255,255,255,0.1)}
</style></head><body>
<div class="header"><div class="title">🔥 Thermal Pro</div><div><span class="badge" id="wifiBadge">📡</span><span class="badge" id="fpsBadge">FPS:--</span></div></div>
<div class="grid">
<div class="card"><h3>🌡️ Temperature</h3><div class="temp-grid">
<div><div style="color:#888;font-size:0.8rem">MAX</div><div class="temp-val temp-max" id="maxTemp">--.-</div><div style="color:#888;font-size:0.8rem">C</div></div>
<div><div style="color:#888;font-size:0.8rem">AVG</div><div class="temp-val temp-avg" id="avgTemp">--.-</div><div style="color:#888;font-size:0.8rem">C</div></div>
<div><div style="color:#888;font-size:0.8rem">MIN</div><div class="temp-val temp-min" id="minTemp">--.-</div><div style="color:#888;font-size:0.8rem">C</div></div>
</div><div class="threshold"><input type="number" id="thresholdInput" step="0.1" value="40.0"><button onclick="setThreshold()">Set</button></div><div class="alert" id="alertBox">⚠️ TEMP EXCEEDED!</div></div>
<div class="card"><h3>📊 System</h3><ul class="info-list">
<li><span>IP</span><span id="devIp">--.--.--.--</span></li><li><span>WiFi</span><span id="wifiStatus">--</span></li>
<li><span>USB</span><span style="color:#34c759">● Active</span></li><li><span>FPS</span><span id="frameRate">--</span></li>
<li><span>Mode</span><span id="rangeMode">AUTO</span></li><li><span>Threshold</span><span id="currentThreshold">40.0</span>C</li>
</ul></div></div><div style="text-align:center;padding:10px;color:#666;font-size:0.8rem">Thermal Pro | Auto-refresh 1s</div>
<script>
const API='/api';let alertActive=false;
async function fetchData(){try{const r=await fetch(API+'/data',{cache:'no-store'});const d=await r.json();
document.getElementById('maxTemp').textContent=d.hot.toFixed(1);
document.getElementById('avgTemp').textContent=d.avg.toFixed(1);
document.getElementById('minTemp').textContent=d.cold.toFixed(1);
document.getElementById('fpsBadge').textContent='FPS:'+d.fps.toFixed(0);
document.getElementById('frameRate').textContent=d.fps.toFixed(1)+' FPS';
document.getElementById('devIp').textContent=d.ip||'--';
document.getElementById('wifiStatus').textContent=d.wifi?'Connected':'Disconnected';
document.getElementById('rangeMode').textContent=d.auto?'AUTO':'MANUAL';
if(d.hot>d.threshold&&!alertActive){alertActive=true;document.getElementById('alertBox').classList.add('show');
fetch(API+'/alert',{method:'POST',body:JSON.stringify({msg:'ALERT:'+d.hot.toFixed(1)+'C'})}).catch(()=>{});
}else if(d.hot<=d.threshold&&alertActive){alertActive=false;document.getElementById('alertBox').classList.remove('show');}
}catch(e){console.warn('Error:',e)}}
function setThreshold(){const v=parseFloat(document.getElementById('thresholdInput').value);
if(!isNaN(v)&&v>=-40&&v<=150){fetch(API+'/threshold',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({threshold:v})})
.then(()=>{document.getElementById('currentThreshold').textContent=v}).catch(()=>{});}}
fetchData();setInterval(fetchData,1000);
</script></body></html>
)rawliteral";
// ================= 📡 API 接口 =================
void sendCorsHeaders() {
server.sendHeader("Access-Control-Allow-Origin", "*");
server.sendHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
server.sendHeader("Access-Control-Allow-Headers", "Content-Type");
server.sendHeader("Connection", "close");
server.sendHeader("Cache-Control", "no-cache, no-store, must-revalidate");
}
void handleRoot() {
Serial.println("🌐 [HTTP] GET /");
sendCorsHeaders();
server.send(200, "text/html", WEB_PAGE);
}
void handleApiData() {
Serial.println("🌐 [HTTP] GET /api/data");
sendCorsHeaders();
float h = isnan(hotSpotTemp) ? 0.0 : hotSpotTemp;
float a = isnan(centerTemp) ? 0.0 : centerTemp;
float c = isnan(coldSpotTemp) ? 0.0 : coldSpotTemp;
float f = isnan(currentFPS) ? 0.0 : currentFPS;
String json = "{";
json += "\"hot\":" + String(h, 1) + ",";
json += "\"avg\":" + String(a, 1) + ",";
json += "\"cold\":" + String(c, 1) + ",";
json += "\"fps\":" + String(f, 1) + ",";
json += "\"ip\":\"" + (wifiConnected ? localIP.toString() : "0.0.0.0") + "\",";
json += "\"wifi\":" + String(wifiConnected ? "true" : "false") + ",";
#ifndef DISABLE_BLE
json += "\"ble\":" + String(bleConnected ? "true" : "false") + ",";
#else
json += "\"ble\":\"disabled\",";
#endif
json += "\"auto\":" + String(autoRange ? "true" : "false") + ",";
json += "\"threshold\":" + String(alertThreshold, 1);
json += "}";
Serial.printf("📤 JSON: %s\n", json.c_str());
server.send(200, "application/json", json);
}
void handleSetThreshold() {
Serial.println("🌐 [HTTP] POST /api/threshold");
sendCorsHeaders();
if (server.hasArg("plain")) {
String body = server.arg("plain");
int start = body.indexOf("threshold");
if (start >= 0) {
int valStart = body.indexOf(":", start) + 1;
int valEnd = body.indexOf(",", valStart); if (valEnd < 0) valEnd = body.indexOf("}", valStart);
if (valStart < valEnd) {
float newThresh = body.substring(valStart, valEnd).toFloat();
if (newThresh >= -40 && newThresh <= 150) { alertThreshold = newThresh; Serial.printf("✅ Threshold: %.1f\n", alertThreshold); server.send(200, "application/json", "{\"ok\":true}"); return; }
}
}
}
server.send(400, "application/json", "{\"ok\":false}");
}
void handleAlert() {
Serial.println("🌐 [HTTP] POST /api/alert");
sendCorsHeaders();
if (server.hasArg("plain")) {
String body = server.arg("plain");
int msgStart = body.indexOf("msg");
if (msgStart >= 0) {
int valStart = body.indexOf(":", msgStart) + 2;
int valEnd = body.indexOf("\"", valStart);
if (valStart < valEnd) {
String alertMsg = body.substring(valStart, valEnd);
Serial.println("[ALERT] " + alertMsg);
#ifndef DISABLE_BLE
if (bleConnected && pCharacteristic) { pCharacteristic->setValue(("[ALERT] " + alertMsg).c_str()); pCharacteristic->notify(); }
#endif
}
}
}
server.send(200, "application/json", "{\"ok\":true}");
}
void setupWebServer() {
server.on("/", handleRoot);
server.on("/api/data", handleApiData);
server.on("/api/threshold", HTTP_POST, handleSetThreshold);
server.on("/api/alert", HTTP_POST, handleAlert);
server.begin();
Serial.println("🌐 WebServer started on port 80");
}
// ================= 🔌 USB CDC =================
void sendSerialData() {
if (millis() - lastSerialSend >= SERIAL_SEND_INTERVAL) {
lastSerialSend = millis();
Serial.printf("T:%.1f,%.1f,%.1f,%d,%d,%.1f,%d\n", centerTemp, hotSpotTemp, coldSpotTemp, hotSpotX, hotSpotY, alertThreshold, (hotSpotTemp > alertThreshold) ? 1 : 0);
}
}
// ================= 🔵 BLE =================
#ifndef DISABLE_BLE
void sendBleData() {
if (bleConnected && pCharacteristic && millis() - lastBleSend >= BLE_SEND_INTERVAL) {
lastBleSend = millis();
String msg = "T:" + String(centerTemp,1) + "," + String(hotSpotTemp,1) + "," + String(coldSpotTemp,1) + "," + hotSpotX + "," + hotSpotY + "," + String(alertThreshold,1) + "," + ((hotSpotTemp > alertThreshold) ? "1" : "0") + "\n";
pCharacteristic->setValue(msg.c_str());
pCharacteristic->notify();
}
}
void setupBLE() {
Serial.println("🔵 BLE: Initializing...");
delay(100);
BLEDevice::init(BLE_DEVICE_NAME);
pServer = BLEDevice::createServer();
pServer->setCallbacks(new MyServerCallbacks());
BLEService *pService = pServer->createService(BLE_SERVICE_UUID);
pCharacteristic = pService->createCharacteristic(BLE_CHAR_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_NOTIFY);
pCharacteristic->addDescriptor(new BLE2902());
pService->start();
BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(BLE_SERVICE_UUID);
pAdvertising->setScanResponse(true);
BLEDevice::startAdvertising();
Serial.println("🔵 BLE: Advertising '" + String(BLE_DEVICE_NAME) + "'");
}
#endif
// ================= 🚨 报警 =================
void checkAlert() {
if (hotSpotTemp > alertThreshold && millis() - lastAlertTime > ALERT_COOLDOWN) {
lastAlertTime = millis(); alertActive = true;
String alertMsg = "[ALERT] TEMP:" + String(hotSpotTemp,1) + "C > " + String(alertThreshold,1) + "C @ (" + hotSpotX + "," + hotSpotY + ")";
Serial.println(alertMsg);
#ifndef DISABLE_BLE
if (bleConnected && pCharacteristic) { pCharacteristic->setValue(alertMsg.c_str()); pCharacteristic->notify(); }
#endif
} else if (hotSpotTemp <= alertThreshold) { alertActive = false; }
}
// ================= 🚀 初始化 =================
void setup() {
Serial.begin(115200);
while(!Serial) delay(10);
Serial.println("\n🔥 Thermal Pro v4.1 | ESP32-S3");
// 🔌 WiFi
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID);
Serial.print("📡 WiFi: Connecting to "); Serial.println(WIFI_SSID);
uint32_t wifiStart = millis();
while (WiFi.status() != WL_CONNECTED && millis() - wifiStart < 8000) { delay(250); Serial.print("."); }
if (WiFi.status() == WL_CONNECTED) { localIP = WiFi.localIP(); wifiConnected = true; Serial.printf("\n✅ WiFi: %s\n", localIP.toString().c_str()); }
else { wifiConnected = false; Serial.println("\n⚠️ WiFi timeout"); }
setupWebServer();
#ifndef DISABLE_BLE
setupBLE();
#endif
// 传感器
Wire.begin(I2C_SDA, I2C_SCL, 400000);
Wire.setClock(1000000);
if (!mlx.begin(MLX90640_I2CADDR_DEFAULT, &Wire)) { while(1) { delay(100); tft.fillScreen(TFT_RED); } }
mlx.setRefreshRate(MLX90640_16_HZ);
Serial.println("🌡️ MLX90640 OK");
// 引脚
pinMode(btnPin, INPUT_PULLUP);
pinMode(btnLedPin, INPUT_PULLUP);
pinMode(ledPin, OUTPUT);
digitalWrite(ledPin, HIGH);
FastLED.addLeds<WS2812, wsPin, GRB>(leds, NUM_LEDS);
FastLED.setBrightness(100);
// 屏幕
tft.init(); tft.setRotation(2); tft.fillScreen(TFT_BLACK);
buildIronbowTable();
drawTopBar(); drawFrame(); drawLegend(tMinFixed, tMaxFixed);
Serial.println("🎨 UI Ready");
}
// ================= 🔄 主循环 =================
void loop() {
server.handleClient();
if (millis() - lastWifiCheck > 5000) {
lastWifiCheck = millis();
if (WiFi.status() != WL_CONNECTED) { wifiConnected = false; WiFi.begin(WIFI_SSID); }
else if (!wifiConnected) { localIP = WiFi.localIP(); wifiConnected = true; }
}
if (mlx.getFrame(frame) == 0) {
uint32_t now = millis();
float dt = (now - lastFrameTime) / 1000.0f;
lastFrameTime = now;
if (dt > 0.01f && dt < 1.0f) currentFPS = currentFPS * (1-FPS_FILTER) + (1.0f/dt) * FPS_FILTER;
if (autoRange) calcSmartRange(frame, 768, &tMin, &tMax);
else { tMin = tMinFixed; tMax = tMaxFixed; }
findHotColdSpots();
centerTemp = avgCenterTemp();
smoothFrame(); bicubicAdaptive(); applySharpen(0.25f);
drawThermal(tMin, tMax);
drawCrosshair();
drawHotMarker();
drawAvgTemp();
drawBottomPanel();
uint8_t hue = map(constrain(centerTemp, 15, 45), 15, 45, 180, 0);
leds[0] = leds[1] = CHSV(hue, 255, 150);
FastLED.show();
static bool ledState = true, lastBtnLed = HIGH;
static uint32_t btnLedDown = 0;
bool bl = digitalRead(btnLedPin);
if (bl == LOW && lastBtnLed == HIGH) btnLedDown = millis();
if (bl == HIGH && lastBtnLed == LOW && millis() - btnLedDown < 400) { ledState = !ledState; digitalWrite(ledPin, ledState ? HIGH : LOW); }
lastBtnLed = bl;
checkAlert();
sendSerialData();
#ifndef DISABLE_BLE
sendBleData();
#endif
}
static bool lastBtn = HIGH;
static uint32_t btnDown = 0;
bool b = digitalRead(btnPin);
if (b == LOW && lastBtn == HIGH) btnDown = millis();
if (b == HIGH && lastBtn == LOW && millis() - btnDown < 500) { autoRange = !autoRange; drawTopBar(); }
if (b == LOW && millis() - btnDown > 800) {
#ifdef TFT_BL
static bool bl = true; bl = !bl;
digitalWrite(TFT_BL, bl ? TFT_BACKLIGHT_ON : !TFT_BACKLIGHT_ON);
btnDown = millis();
#endif
}
lastBtn = b;
}
优化颜色校准
namespace fs { class FS; }
using namespace fs;
#include <Wire.h>
#include <Adafruit_MLX90640.h>
#include <TFT_eSPI.h>
#include <FastLED.h>
#include <WiFi.h>
#include <WebServer.h>
#include <FS.h>
#include <SPIFFS.h>
#ifndef DISABLE_BLE
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#endif
// ================= 📺 布局参数 =================
#define SCREEN_W 240
#define SCREEN_H 240
TFT_eSPI tft = TFT_eSPI(SCREEN_W, SCREEN_H);
#define IMG_W 144
#define IMG_H 108
#define IMG_X 40
#define IMG_Y 28
#define LEGEND_W 12
#define LEGEND_X (SCREEN_W - LEGEND_W - 4)
#define LEGEND_Y IMG_Y
#define LEGEND_H IMG_H
#define STATUS_H 24
#define PANEL_H 32
#define PANEL_Y (SCREEN_H - PANEL_H)
#define FONT_SM 1
// ================= 🔌 引脚 =================
const uint8_t btnPin = 1;
const uint8_t btnLedPin = 2;
const uint8_t ledPin = 42;
const uint8_t wsPin = 48;
#define NUM_LEDS 2
#define I2C_SDA 8
#define I2C_SCL 9
// ================= 🌡️ 传感器 =================
Adafruit_MLX90640 mlx;
float frame[768], smooth[768], interp[IMG_W * IMG_H];
float tMin = 0, tMax = 0, tMinFixed = -10, tMaxFixed = 60;
bool autoRange = true;
float centerTemp = 0.0, hotSpotTemp = -999, coldSpotTemp = 999;
int hotSpotX = -1, hotSpotY = -1;
CRGB leds[NUM_LEDS];
// FPS
uint32_t lastFrameTime = 0;
float currentFPS = 0.0;
const float FPS_FILTER = 0.3f;
// ================= 📡 WiFi + WebServer =================
const char* WIFI_SSID = "WIFI";
IPAddress localIP;
bool wifiConnected = false;
uint32_t lastWifiCheck = 0;
WebServer server(80);
// ================= 🔌 USB CDC =================
uint32_t lastSerialSend = 0;
const uint32_t SERIAL_SEND_INTERVAL = 1000;
// ================= 🔵 BLE =================
#ifndef DISABLE_BLE
#define BLE_SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define BLE_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"
#define BLE_DEVICE_NAME "Thermal-Pro"
BLEServer *pServer = nullptr;
BLECharacteristic *pCharacteristic = nullptr;
bool bleConnected = false;
uint32_t lastBleSend = 0;
const uint32_t BLE_SEND_INTERVAL = 1000;
class MyServerCallbacks : public BLEServerCallbacks {
void onConnect(BLEServer* pServer) override { bleConnected = true; Serial.println("🔵 BLE Connected"); }
void onDisconnect(BLEServer* pServer) override { bleConnected = false; Serial.println("🔵 BLE Disconnected"); pServer->startAdvertising(); }
};
#endif
// ================= 🚨 报警 =================
float alertThreshold = 40.0;
bool alertActive = false;
uint32_t lastAlertTime = 0;
const uint32_t ALERT_COOLDOWN = 3000;
// ================= 铁色热像仪配色================
uint16_t ironbow565[256];
void buildIronbowTable() {
for (int i = 0; i < 256; i++) {
uint8_t r, g, b;
if (i < 60) {
// 冷:深蓝
r = 0;
g = 0;
b = 255;
}
else if (i < 120) {
// 中冷:青
r = 0;
g = map(i, 60, 119, 0, 255);
b = 255;
}
else if (i < 180) {
// 常温:青 → 黄
r = map(i, 120, 179, 0, 255);
g = 255;
b = map(i, 120, 179, 255, 0);
}
else {
// 高温:黄 → 橙 → 红
r = 255;
g = map(i, 180, 255, 255, 40);
b = 0;
}
ironbow565[i] = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
}
}
// 温度映射
inline uint8_t tempToIndex(float t, float minT, float maxT) {
float range = maxT - minT;
if (range < 10.0f) range = 10.0f;
float val = (t - minT) / range;
val = val * 255.0f;
return (uint8_t)constrain(val, 0, 255);
}
// ================= 🔧 温度处理 =================
float clampTemp(float t) { return constrain(t, -40, 150); }
void calcSmartRange(float* data, int len, float* outMin, float* outMax) {
float minV = 999, maxV = -999;
for (int i = 0; i < len; i++) { float t = clampTemp(data[i]); if (t < minV) minV = t; if (t > maxV) maxV = t; }
float span = maxV - minV;
if (span < 5.0f) { float mid = (minV + maxV) * 0.5f; *outMin = mid - 10.0f; *outMax = mid + 10.0f; }
else { *outMin = minV - span * 0.05f; *outMax = maxV + span * 0.05f; }
}
void findHotColdSpots() {
hotSpotTemp = -999; coldSpotTemp = 999; hotSpotX = -1; hotSpotY = -1;
for (int i = 0; i < 768; i++) {
float t = clampTemp(frame[i]);
if (t > hotSpotTemp) { hotSpotTemp = t; hotSpotY = i / 32; hotSpotX = i % 32; }
if (t < coldSpotTemp) coldSpotTemp = t;
}
}
float avgCenterTemp() {
float sum = 0;
int cx = 16, cy = 12;
// 8x8=64个点,除以 64.0f
for (int dy = -4; dy < 4; dy++) {
for (int dx = -4; dx < 4; dx++) {
sum += clampTemp(frame[(cy + dy) * 32 + (cx + dx)]);
}
}
return sum / 64.0f;
}
// ================= 🖼️ 图像处理 =================
void smoothFrame() {
const float w[3][3] = {{1,2,1},{2,4,2},{1,2,1}};
for (int y = 1; y < 23; y++) for (int x = 1; x < 31; x++) {
float sum = 0, wsum = 0;
for (int dy = -1; dy <= 1; dy++) for (int dx = -1; dx <= 1; dx++) { sum += frame[(y + dy) * 32 + (x + dx)] * w[dy + 1][dx + 1]; wsum += w[dy + 1][dx + 1]; }
smooth[y * 32 + x] = sum / wsum;
}
for (int i = 0; i < 32; i++) { smooth[i] = frame[i]; smooth[23 * 32 + i] = frame[23 * 32 + i]; smooth[i * 32] = frame[i * 32]; smooth[i * 32 + 31] = frame[i * 32 + 31]; }
}
void bicubicAdaptive() {
const float colRatio = 23.0f / (IMG_W - 1), rowRatio = 31.0f / (IMG_H - 1), a = -0.5f;
for (int sy = 0; sy < IMG_H; sy++) {
float sc = sy * rowRatio;
int c0 = constrain((int)sc - 1, 0, 30), c1 = constrain((int)sc, 0, 31), c2 = constrain((int)sc + 1, 0, 31), c3 = constrain((int)sc + 2, 0, 31);
float fc = sc - (int)sc;
float wc[4] = { a*fc*fc*fc - 2*a*fc*fc + a*fc, (a+2)*fc*fc*fc - (a+3)*fc*fc + 1, -(a+2)*fc*fc*fc + (2*a+3)*fc*fc - a*fc, -a*fc*fc*fc + a*fc*fc };
for (int sx = 0; sx < IMG_W; sx++) {
float sr = sx * colRatio;
int r0 = constrain((int)sr - 1, 0, 22), r1 = constrain((int)sr, 0, 23), r2 = constrain((int)sr + 1, 0, 23), r3 = constrain((int)sr + 2, 0, 23);
float fr = sr - (int)sr;
float wr[4] = { a*fr*fr*fr - 2*a*fr*fr + a*fr, (a+2)*fr*fr*fr - (a+3)*fr*fr + 1, -(a+2)*fr*fr*fr + (2*a+3)*fr*fr - a*fr, -a*fr*fr*fr + a*fr*fr };
bool edge = (r0==0||r3==23||c0==0||c3==31);
if (edge) {
int rr0 = constrain((int)sr, 0, 23), rr1 = min(rr0 + 1, 23), cc0 = constrain((int)sc, 0, 31), cc1 = min(cc0 + 1, 31);
float fr2 = sr - rr0, fc2 = sc - cc0;
float v00 = smooth[rr0*32+cc0], v01 = smooth[rr0*32+cc1], v10 = smooth[rr1*32+cc0], v11 = smooth[rr1*32+cc1];
interp[sy*IMG_W+sx] = v00 + fc2*(v01-v00) + fr2*(v10-v00 + fc2*(v11-v10-v01+v00));
} else {
float result = 0;
for (int m = 0; m < 4; m++) {
float rowSum = 0;
for (int n = 0; n < 4; n++) {
int rr = (m==0)?r0:(m==1)?r1:(m==2)?r2:r3, cc = (n==0)?c0:(n==1)?c1:(n==2)?c2:c3;
rowSum += smooth[rr*32+cc] * wc[n];
}
result += rowSum * wr[m];
}
interp[sy*IMG_W+sx] = result;
}
}
}
}
void applySharpen(float strength = 0.25f) {
static float sharp[IMG_W * IMG_H];
for (int y = 1; y < IMG_H - 1; y++) for (int x = 1; x < IMG_W - 1; x++) {
float c = interp[y*IMG_W+x], lap = 5*c - interp[(y-1)*IMG_W+x] - interp[(y+1)*IMG_W+x] - interp[y*IMG_W+(x-1)] - interp[y*IMG_W+(x+1)];
sharp[y*IMG_W+x] = c + strength * (c - lap * 0.25f);
}
for (int i = 0; i < IMG_W; i++) { sharp[i] = interp[i]; sharp[(IMG_H-1)*IMG_W+i] = interp[(IMG_H-1)*IMG_W+i]; }
for (int j = 0; j < IMG_H; j++) { sharp[j*IMG_W] = interp[j*IMG_W]; sharp[j*IMG_W+IMG_W-1] = interp[j*IMG_W+IMG_W-1]; }
memcpy(interp, sharp, sizeof(interp));
}
// ================= 🎨 UI 绘制 =================
static uint16_t rowBuf[IMG_W];
void drawThermal(float mn, float mx) {
tft.setAddrWindow(IMG_X, IMG_Y, IMG_W, IMG_H);
for (int y = 0; y < IMG_H; y++) { for (int x = 0; x < IMG_W; x++) rowBuf[x] = ironbow565[tempToIndex(interp[y*IMG_W+x], mn, mx)]; tft.pushColors(rowBuf, IMG_W, false); }
tft.endWrite();
}
void drawLegend(float mn, float mx) {
tft.fillRect(LEGEND_X - 3, LEGEND_Y - 3, LEGEND_W + 6, LEGEND_H + 6, 0x18E3);
tft.fillRect(LEGEND_X, LEGEND_Y, LEGEND_W, IMG_H, TFT_BLACK);
for (int i = 0; i < LEGEND_H - 2; i++) { uint8_t idx = map(i, 0, LEGEND_H - 3, 255, 0); tft.drawFastHLine(LEGEND_X + 2, LEGEND_Y + 2 + i, LEGEND_W - 4, ironbow565[idx]); }
tft.setTextColor(TFT_WHITE); tft.setTextSize(FONT_SM); tft.setTextDatum(TR_DATUM);
tft.drawFastHLine(LEGEND_X - 2, LEGEND_Y + 2, 4, TFT_WHITE); tft.drawFloat(mx, 0, LEGEND_X - 6, LEGEND_Y + 4);
tft.drawFastHLine(LEGEND_X - 2, LEGEND_Y + LEGEND_H/2, 4, TFT_WHITE); tft.drawFloat((mn+mx)*0.5f, 0, LEGEND_X - 6, LEGEND_Y + LEGEND_H/2 - 2);
tft.drawFastHLine(LEGEND_X - 2, LEGEND_Y + LEGEND_H - 3, 4, TFT_WHITE); tft.drawFloat(mn, 0, LEGEND_X - 6, LEGEND_Y + LEGEND_H - 8);
tft.setTextDatum(TL_DATUM);
}
void drawTopBar() {
tft.fillRect(0, 0, SCREEN_W, STATUS_H, 0x1082);
tft.drawLine(0, STATUS_H, SCREEN_W, STATUS_H, TFT_DARKGREY);
tft.setTextColor(TFT_WHITE); tft.setTextSize(2); tft.setTextDatum(TC_DATUM);
tft.drawString("THERMAL PRO", SCREEN_W / 2, 5);
tft.setTextSize(FONT_SM); tft.setTextDatum(TR_DATUM);
tft.setTextColor(autoRange ? TFT_GREEN : TFT_YELLOW);
tft.drawString(autoRange ? "AUTO" : "MANUAL", SCREEN_W - 4, 8);
tft.setTextDatum(TL_DATUM);
}
void drawHotMarker() {
if (hotSpotX < 0) return;
int sx = IMG_X + (int)(hotSpotY * (IMG_W - 1) / 23.0f + 0.5f);
int sy = IMG_Y + (int)(hotSpotX * (IMG_H - 1) / 31.0f + 0.5f);
sx = constrain(sx, IMG_X + 6, IMG_X + IMG_W - 6);
sy = constrain(sy, IMG_Y + 6, IMG_Y + IMG_H - 6);
tft.drawLine(sx - 5, sy, sx + 5, sy, TFT_RED);
tft.drawLine(sx, sy - 5, sx, sy + 5, TFT_RED);
tft.fillCircle(sx, sy, 2, TFT_RED);
tft.setTextColor(TFT_WHITE); tft.setTextSize(FONT_SM); tft.setTextDatum(TL_DATUM);
int tx = sx + 8, ty = sy - 10; if (tx + 32 > IMG_X + IMG_W) tx = sx - 34; if (ty < IMG_Y + 4) ty = sy + 8;
tft.drawFloat(hotSpotTemp, 1, tx, ty); tft.drawString("C", tx + 28, ty);
}
void drawCrosshair() {
int cx = IMG_X + IMG_W / 2;
int cy = IMG_Y + IMG_H / 2;
int arm = 10;
tft.drawLine(cx, max(cy - arm, IMG_Y), cx, cy - 2, TFT_WHITE);
tft.drawLine(cx, cy + 2, cx, min(cy + arm, IMG_Y + IMG_H - 1), TFT_WHITE);
tft.drawLine(max(cx - arm, IMG_X), cy, cx - 2, cy, TFT_WHITE);
tft.drawLine(cx + 2, cy, min(cx + arm, IMG_X + IMG_W - 1), cy, TFT_WHITE);
tft.fillCircle(cx, cy, 2, TFT_WHITE);
}
void drawAvgTemp() {
int y = IMG_Y + IMG_H + 8;
int centerX = IMG_X + IMG_W / 2;
// 彻底清空温度区域背景,消除残影
tft.fillRect(centerX - 40, y - 5, 80, 26, TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setTextSize(2);
tft.setTextDatum(TC_DATUM);
tft.drawFloat(centerTemp, 1, centerX, y);
tft.drawString("C", centerX + 54, y);
tft.setTextDatum(TL_DATUM);
}
void drawBottomPanel() {
tft.fillRect(0, PANEL_Y, SCREEN_W, PANEL_H, 0x0841); tft.drawLine(0, PANEL_Y, SCREEN_W, PANEL_Y, TFT_DARKGREY);
int line1 = PANEL_Y + 6, line2 = PANEL_Y + 20;
tft.setTextColor(TFT_RED); tft.setTextSize(FONT_SM); tft.setTextDatum(TL_DATUM);
tft.drawString("MAX ", 6, line1); tft.drawFloat(hotSpotTemp, 1, 42, line1); tft.drawString("C", 78, line1);
tft.setTextColor(TFT_CYAN); tft.setTextDatum(TR_DATUM);
tft.drawString(" MIN", SCREEN_W - 6, line1); tft.drawFloat(coldSpotTemp, 1, SCREEN_W - 42, line1); tft.drawString("C", SCREEN_W - 6, line1); tft.setTextDatum(TL_DATUM);
tft.setTextColor(currentFPS >= 15 ? TFT_GREEN : TFT_YELLOW);
tft.drawString("FPS:", 6, line2); tft.drawFloat(currentFPS, 0, 30, line2);
tft.setTextColor(wifiConnected ? TFT_WHITE : TFT_DARKGREY); tft.setTextDatum(TR_DATUM);
if (wifiConnected) { tft.drawString("IP:", SCREEN_W - 90, line2); tft.setTextColor(TFT_CYAN); tft.drawString(localIP.toString().c_str(), SCREEN_W - 6, line2); }
else { tft.drawString("CCIT-WLAN", SCREEN_W - 6, line2); }
tft.setTextDatum(TL_DATUM);
}
void drawFrame() {
tft.drawRect(IMG_X - 2, IMG_Y - 2, IMG_W + 4, IMG_H + 4, TFT_DARKGREY);
tft.drawRect(IMG_X - 1, IMG_Y - 1, IMG_W + 2, IMG_H + 2, TFT_WHITE);
}
// ================= 🌐 精简网页 =================
const char* WEB_PAGE = R"rawliteral(
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Thermal Pro</title>
<style>
body{background:#0b0d10;color:#c8ccd4;font-family:system-ui,sans-serif;padding:10px;margin:0}
.header{display:flex;justify-content:space-between;padding:8px 12px;background:#15181e;border-radius:8px;margin-bottom:10px}
.title{font-weight:600;color:#00e5ff}.badge{padding:2px 6px;border-radius:12px;background:rgba(0,229,255,0.1);color:#00e5ff;font-size:0.8rem}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.card{background:#15181e;border-radius:8px;padding:12px}
.card h3{margin:0 0 8px 0;color:#00e5ff;font-size:1rem}
.temp-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;text-align:center}
.temp-val{font-size:1.3rem;font-weight:700}.temp-max{color:#ff3b30}.temp-min{color:#7dd3fc}.temp-avg{color:#34c759}
.threshold{display:flex;gap:6px;margin-top:6px}
.threshold input{flex:1;padding:4px 8px;background:#0f1216;border:1px solid #2a2e36;border-radius:4px;color:#fff}
.threshold button{padding:4px 10px;background:#00e5ff;color:#000;border:none;border-radius:4px;font-weight:600;cursor:pointer}
.alert{padding:6px 10px;background:rgba(255,59,48,0.15);border:1px solid #ff3b30;border-radius:4px;color:#ff3b30;margin-top:8px;display:none}
.alert.show{display:block}
.info-list{list-style:none;font-size:0.85rem;margin:0;padding:0}
.info-list li{display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px dashed rgba(255,255,255,0.1)}
</style></head><body>
<div class="header"><div class="title">🔥 Thermal Pro</div><div><span class="badge" id="wifiBadge">📡</span><span class="badge" id="fpsBadge">FPS:--</span></div></div>
<div class="grid">
<div class="card"><h3>🌡️ Temperature</h3><div class="temp-grid">
<div><div style="color:#888;font-size:0.8rem">MAX</div><div class="temp-val temp-max" id="maxTemp">--.-</div><div style="color:#888;font-size:0.8rem">C</div></div>
<div><div style="color:#888;font-size:0.8rem">AVG</div><div class="temp-val temp-avg" id="avgTemp">--.-</div><div style="color:#888;font-size:0.8rem">C</div></div>
<div><div style="color:#888;font-size:0.8rem">MIN</div><div class="temp-val temp-min" id="minTemp">--.-</div><div style="color:#888;font-size:0.8rem">C</div></div>
</div><div class="threshold"><input type="number" id="thresholdInput" step="0.1" value="40.0"><button onclick="setThreshold()">Set</button></div><div class="alert" id="alertBox">⚠️ TEMP EXCEEDED!</div></div>
<div class="card"><h3>📊 System</h3><ul class="info-list">
<li><span>IP</span><span id="devIp">--.--.--.--</span></li><li><span>WiFi</span><span id="wifiStatus">--</span></li>
<li><span>USB</span><span style="color:#34c759">● Active</span></li><li><span>FPS</span><span id="frameRate">--</span></li>
<li><span>Mode</span><span id="rangeMode">AUTO</span></li><li><span>Threshold</span><span id="currentThreshold">40.0</span>C</li>
</ul></div></div><div style="text-align:center;padding:10px;color:#666;font-size:0.8rem">Thermal Pro | Auto-refresh 1s</div>
<script>
const API='/api';let alertActive=false;
async function fetchData(){try{const r=await fetch(API+'/data',{cache:'no-store'});const d=await r.json();
document.getElementById('maxTemp').textContent=d.hot.toFixed(1);
document.getElementById('avgTemp').textContent=d.avg.toFixed(1);
document.getElementById('minTemp').textContent=d.cold.toFixed(1);
document.getElementById('fpsBadge').textContent='FPS:'+d.fps.toFixed(0);
document.getElementById('frameRate').textContent=d.fps.toFixed(1)+' FPS';
document.getElementById('devIp').textContent=d.ip||'--';
document.getElementById('wifiStatus').textContent=d.wifi?'Connected':'Disconnected';
document.getElementById('rangeMode').textContent=d.auto?'AUTO':'MANUAL';
if(d.hot>d.threshold&&!alertActive){alertActive=true;document.getElementById('alertBox').classList.add('show');
fetch(API+'/alert',{method:'POST',body:JSON.stringify({msg:'ALERT:'+d.hot.toFixed(1)+'C'})}).catch(()=>{});
}else if(d.hot<=d.threshold&&alertActive){alertActive=false;document.getElementById('alertBox').classList.remove('show');}
}catch(e){console.warn('Error:',e)}}
function setThreshold(){const v=parseFloat(document.getElementById('thresholdInput').value);
if(!isNaN(v)&&v>=-40&&v<=150){fetch(API+'/threshold',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({threshold:v})})
.then(()=>{document.getElementById('currentThreshold').textContent=v}).catch(()=>{});}}
fetchData();setInterval(fetchData,1000);
</script></body></html>
)rawliteral";
// ================= 📡 API 接口 =================
void sendCorsHeaders() {
server.sendHeader("Access-Control-Allow-Origin", "*");
server.sendHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
server.sendHeader("Access-Control-Allow-Headers", "Content-Type");
server.sendHeader("Connection", "close");
server.sendHeader("Cache-Control", "no-cache, no-store, must-revalidate");
}
void handleRoot() {
Serial.println("🌐 [HTTP] GET /");
sendCorsHeaders();
server.send(200, "text/html", WEB_PAGE);
}
void handleApiData() {
Serial.println("🌐 [HTTP] GET /api/data");
sendCorsHeaders();
float h = isnan(hotSpotTemp) ? 0.0 : hotSpotTemp;
float a = isnan(centerTemp) ? 0.0 : centerTemp;
float c = isnan(coldSpotTemp) ? 0.0 : coldSpotTemp;
float f = isnan(currentFPS) ? 0.0 : currentFPS;
String json = "{";
json += "\"hot\":" + String(h, 1) + ",";
json += "\"avg\":" + String(a, 1) + ",";
json += "\"cold\":" + String(c, 1) + ",";
json += "\"fps\":" + String(f, 1) + ",";
json += "\"ip\":\"" + (wifiConnected ? localIP.toString() : "0.0.0.0") + "\",";
json += "\"wifi\":" + String(wifiConnected ? "true" : "false") + ",";
#ifndef DISABLE_BLE
json += "\"ble\":" + String(bleConnected ? "true" : "false") + ",";
#else
json += "\"ble\":\"disabled\",";
#endif
json += "\"auto\":" + String(autoRange ? "true" : "false") + ",";
json += "\"threshold\":" + String(alertThreshold, 1);
json += "}";
Serial.printf("📤 JSON: %s\n", json.c_str());
server.send(200, "application/json", json);
}
void handleSetThreshold() {
Serial.println("🌐 [HTTP] POST /api/threshold");
sendCorsHeaders();
if (server.hasArg("plain")) {
String body = server.arg("plain");
int start = body.indexOf("threshold");
if (start >= 0) {
int valStart = body.indexOf(":", start) + 1;
int valEnd = body.indexOf(",", valStart); if (valEnd < 0) valEnd = body.indexOf("}", valStart);
if (valStart < valEnd) {
float newThresh = body.substring(valStart, valEnd).toFloat();
if (newThresh >= -40 && newThresh <= 150) { alertThreshold = newThresh; Serial.printf("✅ Threshold: %.1f\n", alertThreshold); server.send(200, "application/json", "{\"ok\":true}"); return; }
}
}
}
server.send(400, "application/json", "{\"ok\":false}");
}
void handleAlert() {
Serial.println("🌐 [HTTP] POST /api/alert");
sendCorsHeaders();
if (server.hasArg("plain")) {
String body = server.arg("plain");
int msgStart = body.indexOf("msg");
if (msgStart >= 0) {
int valStart = body.indexOf(":", msgStart) + 2;
int valEnd = body.indexOf("\"", valStart);
if (valStart < valEnd) {
String alertMsg = body.substring(valStart, valEnd);
Serial.println("[ALERT] " + alertMsg);
#ifndef DISABLE_BLE
if (bleConnected && pCharacteristic) { pCharacteristic->setValue(("[ALERT] " + alertMsg).c_str()); pCharacteristic->notify(); }
#endif
}
}
}
server.send(200, "application/json", "{\"ok\":true}");
}
void setupWebServer() {
server.on("/", handleRoot);
server.on("/api/data", handleApiData);
server.on("/api/threshold", HTTP_POST, handleSetThreshold);
server.on("/api/alert", HTTP_POST, handleAlert);
server.begin();
Serial.println("🌐 WebServer started on port 80");
}
// ================= 🔌 USB CDC =================
void sendSerialData() {
if (millis() - lastSerialSend >= SERIAL_SEND_INTERVAL) {
lastSerialSend = millis();
Serial.printf("T:%.1f,%.1f,%.1f,%d,%d,%.1f,%d\n", centerTemp, hotSpotTemp, coldSpotTemp, hotSpotX, hotSpotY, alertThreshold, (hotSpotTemp > alertThreshold) ? 1 : 0);
}
}
// ================= 🔵 BLE =================
#ifndef DISABLE_BLE
void sendBleData() {
if (bleConnected && pCharacteristic && millis() - lastBleSend >= BLE_SEND_INTERVAL) {
lastBleSend = millis();
String msg = "T:" + String(centerTemp,1) + "," + String(hotSpotTemp,1) + "," + String(coldSpotTemp,1) + "," + hotSpotX + "," + hotSpotY + "," + String(alertThreshold,1) + "," + ((hotSpotTemp > alertThreshold) ? "1" : "0") + "\n";
pCharacteristic->setValue(msg.c_str());
pCharacteristic->notify();
}
}
void setupBLE() {
Serial.println("🔵 BLE: Initializing...");
delay(10);
BLEDevice::init(BLE_DEVICE_NAME);
pServer = BLEDevice::createServer();
pServer->setCallbacks(new MyServerCallbacks());
BLEService *pService = pServer->createService(BLE_SERVICE_UUID);
pCharacteristic = pService->createCharacteristic(BLE_CHAR_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_NOTIFY);
pCharacteristic->addDescriptor(new BLE2902());
pService->start();
BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(BLE_SERVICE_UUID);
pAdvertising->setScanResponse(true);
BLEDevice::startAdvertising();
Serial.println("🔵 BLE: Advertising '" + String(BLE_DEVICE_NAME) + "'");
}
#endif
// ================= 🚨 报警 =================
void checkAlert() {
if (hotSpotTemp > alertThreshold && millis() - lastAlertTime > ALERT_COOLDOWN) {
lastAlertTime = millis();
alertActive = true;
leds[0] = leds[1] = CRGB::Red;
FastLED.show();
String alertMsg = "[ALERT] TEMP:" + String(hotSpotTemp,1) + "C > " + String(alertThreshold,1) + "C";
Serial.println(alertMsg);
#ifndef DISABLE_BLE
if (bleConnected && pCharacteristic) { pCharacteristic->setValue(alertMsg.c_str()); pCharacteristic->notify(); }
#endif
}
else if (hotSpotTemp <= alertThreshold) {
alertActive = false;
uint8_t hue = map(constrain(centerTemp, 15, 45), 15, 45, 180, 0);
leds[0] = leds[1] = CHSV(hue, 255, 150);
FastLED.show();
}
}
// ================= 🚀 初始化 =================
void setup() {
Serial.begin(115200);
while(!Serial) delay(10);
Serial.println("\n🔥 Thermal Pro v4.1 | ESP32-S3");
// 🔌 WiFi
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID);
Serial.print("📡 WiFi: Connecting to "); Serial.println(WIFI_SSID);
uint32_t wifiStart = millis();
while (WiFi.status() != WL_CONNECTED && millis() - wifiStart < 8000) { delay(250); Serial.print("."); }
if (WiFi.status() == WL_CONNECTED) { localIP = WiFi.localIP(); wifiConnected = true; Serial.printf("\n✅ WiFi: %s\n", localIP.toString().c_str()); }
else { wifiConnected = false; Serial.println("\n⚠️ WiFi timeout"); }
setupWebServer();
#ifndef DISABLE_BLE
setupBLE();
#endif
// 传感器
Wire.begin(I2C_SDA, I2C_SCL, 400000);
Wire.setClock(1000000);
if (!mlx.begin(MLX90640_I2CADDR_DEFAULT, &Wire)) { while(1) { delay(100); tft.fillScreen(TFT_RED); } }
mlx.setRefreshRate(MLX90640_16_HZ);
Serial.println("🌡️ MLX90640 OK");
// 引脚
pinMode(btnPin, INPUT_PULLUP);
pinMode(btnLedPin, INPUT_PULLUP);
pinMode(ledPin, OUTPUT);
digitalWrite(ledPin, HIGH);
FastLED.addLeds<WS2812, wsPin, GRB>(leds, NUM_LEDS);
FastLED.setBrightness(100);
// 屏幕
tft.init(); tft.setRotation(2); tft.fillScreen(TFT_BLACK);
buildIronbowTable();
drawTopBar(); drawFrame(); drawLegend(tMinFixed, tMaxFixed);
Serial.println("🎨 UI Ready");
}
// ================= 🔄 主循环 =================
void loop() {
server.handleClient();
if (millis() - lastWifiCheck > 5000) {
lastWifiCheck = millis();
if (WiFi.status() != WL_CONNECTED) { wifiConnected = false; WiFi.begin(WIFI_SSID); }
else if (!wifiConnected) { localIP = WiFi.localIP(); wifiConnected = true; }
}
if (mlx.getFrame(frame) == 0) {
uint32_t now = millis();
float dt = (now - lastFrameTime) / 1000.0f;
lastFrameTime = now;
if (dt > 0.01f && dt < 1.0f) currentFPS = currentFPS * (1-FPS_FILTER) + (1.0f/dt) * FPS_FILTER;
if (autoRange) calcSmartRange(frame, 768, &tMin, &tMax);
else { tMin = tMinFixed; tMax = tMaxFixed; }
findHotColdSpots();
centerTemp = avgCenterTemp();
smoothFrame(); bicubicAdaptive(); applySharpen(0.25f);
drawThermal(tMin, tMax);
drawCrosshair();
drawHotMarker();
drawAvgTemp();
drawBottomPanel();
uint8_t hue = map(constrain(centerTemp, 15, 45), 15, 45, 180, 0);
leds[0] = leds[1] = CHSV(hue, 255, 150);
FastLED.show();
static bool ledState = true, lastBtnLed = HIGH;
static uint32_t btnLedDown = 0;
bool bl = digitalRead(btnLedPin);
if (bl == LOW && lastBtnLed == HIGH) btnLedDown = millis();
if (bl == HIGH && lastBtnLed == LOW && millis() - btnLedDown < 400) { ledState = !ledState; digitalWrite(ledPin, ledState ? HIGH : LOW); }
lastBtnLed = bl;
checkAlert();
sendSerialData();
#ifndef DISABLE_BLE
sendBleData();
#endif
}
static bool lastBtn = HIGH;
static uint32_t btnDown = 0;
bool b = digitalRead(btnPin);
if (b == LOW && lastBtn == HIGH) btnDown = millis();
if (b == HIGH && lastBtn == LOW && millis() - btnDown < 500) { autoRange = !autoRange; drawTopBar(); }
if (b == LOW && millis() - btnDown > 800) {
#ifdef TFT_BL
static bool bl = true; bl = !bl;
digitalWrite(TFT_BL, bl ? TFT_BACKLIGHT_ON : !TFT_BACKLIGHT_ON);
btnDown = millis();
#endif
}
lastBtn = b;
}
更多推荐


所有评论(0)