ESP32对接PHP后端API:传感器数据采集到Web可视化的全栈实现
ESP32对接PHP后端API:传感器数据采集到Web 可视化 的全栈实现
做物联网项目有个经典链路:设备端采集数据,后端接收存储,前端展示可视化。这三个环节分别对应嵌入式、后端、前端三个技术栈,很多人卡在"各段都能跑但串不起来"的尴尬阶段。
这篇文章把ESP32采集温湿度数据、HTTP上报到PHP后端、前端Chart.js画折线图的完整链路走一遍。不是参数罗列,是把联调过程中真正会遇到的问题讲清楚。
硬件接线与库选择
DHT22
接ESP32很简单,DATA脚接GPIO4,VCC接3.3V,GND接地。DHT22的数据口需要上拉电阻,但很多模块板已经自带了,直接用模块的三个引脚就行。
库的选择上,Adafruit的DHT sensor library配合ArduinoJson是主流方案。DHT22精度比DHT11高不少(温度±0.5°C vs ±2°C),做环境监测选DHT22是合理的。如果对响应速度有要求可以换SHT31,I2C接口通信更稳定。
ESP32采集与HTTP上报
ESP32端的核心逻辑:读DHT22 → 组JSON → HTTP POST → 等响应。用Arduino框架开发,代码直观:
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include "DHT.h"
#define DHTPIN 4
#define DHTTYPE DHT22
#define API_URL "http://192.168.1.100/api/sensor.php"
DHT dht(DHTPIN, DHTTYPE);
const char* ssid = "YourSSID";
const char* password = "YourPassword";
unsigned long lastPost = 0;
const long postInterval = 5000;
void setup() {
Serial.begin(115200);
dht.begin();
WiFi.setAutoReconnect(true);
WiFi.begin(ssid, password);
Serial.println("Connecting WiFi...");
}
void loop() {
if (WiFi.status() != WL_CONNECTED) {
Serial.println("WiFi disconnected, waiting...");
delay(2000);
return;
}
if (millis() - lastPost < postInterval) return;
lastPost = millis();
float h = dht.readHumidity();
float t = dht.readTemperature();
if (isnan(h) || isnan(t)) {
Serial.println("DHT22 read failed, skip this round");
return;
}
String json;
StaticJsonDocument<200> doc;
doc["device_id"] = "esp32-001";
doc["temperature"] = round(t * 100) / 100.0;
doc["humidity"] = round(h * 100) / 100.0;
serializeJson(doc, json);
HTTPClient http;
http.begin(API_URL);
http.addHeader("Content-Type", "application/json");
http.setTimeout(5000);
int code = http.POST(json);
String resp = http.getString();
http.end();
Serial.printf("HTTP %d: %s\n", code, resp.c_str());
}
有几个点值得说明。http.setTimeout(5000)很重要,默认的HTTP超时太长,网络不稳定时ESP32会卡在等待响应上,影响下一轮采集。5秒够用了,正常局域网响应在100ms以内。
WiFi.setAutoReconnect(true)让ESP32在WiFi断开后自动重连,但实际测试发现这个机制不够可靠,断网久了有时需要手动reconnect。后面单独说这个问题。
PHP后端API设计
后端用PHP写,PDO操作MySQL。一个文件处理POST和GET两种请求,POST存数据,GET查数据。
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
$pdo = new PDO(
'mysql:host=127.0.0.1;dbname=iot_db;charset=utf8mb4',
'db_user', 'db_pass',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'POST') {
$raw = file_get_contents('php://input');
$input = json_decode($raw, true);
if (!$input || !isset($input['device_id'], $input['temperature'], $input['humidity'])) {
http_response_code(400);
echo json_encode(['error' => 'missing fields']);
exit;
}
$device = $input['device_id'];
if (!preg_match('/^[a-zA-Z0-9\-_]{1,32}$/', $device)) {
http_response_code(400);
echo json_encode(['error' => 'invalid device_id']);
exit;
}
$temp = floatval($input['temperature']);
$hum = floatval($input['humidity']);
if ($temp < -40 || $temp > 85 || $hum < 0 || $hum > 100) {
http_response_code(422);
echo json_encode(['error' => 'value out of range']);
exit;
}
$stmt = $pdo->prepare(
'INSERT INTO sensor_data (device_id, temperature, humidity)
VALUES (?, ?, ?)'
);
$stmt->execute([$device, $temp, $hum]);
echo json_encode(['status' => 'ok']);
exit;
}
if ($method === 'GET') {
$device = $_GET['device_id'] ?? 'esp32-001';
$limit = min(intval($_GET['limit'] ?? 100), 500);
$stmt = $pdo->prepare(
'SELECT temperature, humidity, created_at
FROM sensor_data WHERE device_id = ?
ORDER BY created_at DESC LIMIT ?'
);
$stmt->execute([$device, $limit]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$data = array_map(function ($r) {
return [
'time' => strtotime($r['created_at']) * 1000,
'temperature' => floatval($r['temperature']),
'humidity' => floatval($r['humidity']),
];
}, $rows);
echo json_encode(array_reverse($data));
exit;
}
API设计有几个工程考虑。device_id做正则校验不只是防注入,还防止设备传乱码导致数据表脏数据。temperature和humidity做范围校验,DHT22温度不会超过85°C,超过就是传感器坏了,这种数据不该存进去。
limit参数用min()封顶500,防止前端传个大数把数据库查爆。这些校验看着琐碎,但线上跑起来你会发现省了排查脏数据的时间。
MySQL表结构:
CREATE TABLE sensor_data (
id INT AUTO_INCREMENT PRIMARY KEY,
device_id VARCHAR(32) NOT NULL,
temperature DECIMAL(5,2) NOT NULL,
humidity DECIMAL(5,2) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_device_time (device_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
联合索引(device_id, created_at)是按设备查最新数据的关键,不加索引数据量一大查询就慢。DECIMAL(5,2)存温度精度够了,比FLOAT类型查询和排序更可靠。
数据量大了之后这张表会膨胀。一个设备5秒一条,一天17280条,10台设备一年六千多万行。定期清理是必须的。
简单方案加个定时任务,每天凌晨删掉30天前的旧数据:
0 3 * * * mysql -u db_user -pdb_pass iot_db \
-e "DELETE FROM sensor_data WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)"
数据量到了百万级以上MySQL的查询性能会明显下降,这时候把存储层换InfluxDB是更合理的选择。MySQL适合设备数量少、查询频率低的场景,设备量大了时序数据库是正解。
API上线后还要考虑认证和限流。裸暴露的HTTP接口,任何人知道URL就能往里灌数据。简单方案是给每个设备配一个API Key,ESP32上报时带在Header里,PHP端先校验再写库:
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
$stmt = $pdo->prepare('SELECT id FROM devices WHERE api_key = ? AND device_id = ?');
$stmt->execute([$apiKey, $device]);
if (!$stmt->fetch()) {
http_response_code(401);
echo json_encode(['error' => 'unauthorized']);
exit;
}
限流方面,同一设备5秒一条数据,短时间内大量请求基本是异常。用Redis做滑动窗口计数,一分钟内同一device_id超过20次请求直接返回429。没装Redis的轻量场景,用文件锁加时间戳也能实现粗粒度限流,够防住刷接口的行为了。
CORS跨域处理
前端页面和PHP API如果不在同一个域,浏览器会拦截请求。PHP端开头那几行header就是处理CORS的。
Access-Control-Allow-Origin: *在生产环境应该改成具体域名,但
IoT
可视化面板通常在内网用,*问题不大。注意OPTIONS预检请求必须返回204,否则浏览器实际请求根本发不出去。
很多人CORS没通就是忘了处理OPTIONS。ESP32的HTTP请求不经过浏览器,不受CORS限制,所以设备端上报不需要管这个。CORS只影响前端页面发请求。
前端Chart.js折线图
前端一个HTML文件搞定,Chart.js画双Y轴折线图,温度左轴湿度右轴:
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>温湿度监控</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
</head>
<body>
<div style="max-width:900px;margin:20px auto">
<h3>ESP32 实时温湿度</h3>
<canvas id="chart"></canvas>
</div>
<script>
const ctx = document.getElementById('chart').getContext('2d');
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [
{
label: '温度 (°C)',
data: [],
borderColor: '#e74c3c',
yAxisID: 'y',
tension: 0.3,
pointRadius: 2
},
{
label: '湿度 (%)',
data: [],
borderColor: '#3498db',
yAxisID: 'y1',
tension: 0.3,
pointRadius: 2
}
]
},
options: {
responsive: true,
scales: {
y: { type: 'linear', position: 'left' },
y1: { type: 'linear', position: 'right' }
}
}
});
async function fetchData() {
try {
const resp = await fetch(
'http://192.168.1.100/api/sensor.php?device_id=esp32-001&limit=100'
);
const data = await resp.json();
chart.data.labels = data.map(d =>
new Date(d.time).toLocaleTimeString()
);
chart.data.datasets[0].data = data.map(d => d.temperature);
chart.data.datasets[1].data = data.map(d => d.humidity);
chart.update('none');
} catch (e) {
console.error('fetch failed:', e);
}
}
fetchData();
setInterval(fetchData, 5000);
</script>
</body>
</html>
chart.update('none')传'none'禁用动画,5秒轮询如果每次都触发动画会有闪烁感。数据点设pointRadius: 2,100个点默认大小会糊成一片。
DHT22有个采样频率限制,两次读取之间至少间隔2秒,否则会读到NaN。代码里postInterval设5秒是安全的。如果你改短了遇到NaN,就是这个原因。DHT22上电后还需要1-2秒预热才能读到有效数据,setup()里加个delay(2000)等它稳定。
DHT22的数据线是单总线协议,通信过程中如果被中断打断会导致读取失败。ESP32的loop()里如果有其他耗时操作,建议把DHT读取放在定时器中断或者单独的
FreeRTOS
任务里,避免和主循环抢CPU时间。实测在WiFi连接阶段读取DHT22,失败率会明显上升,因为WiFi连接过程会短暂阻塞。
轮询方案简单粗暴,对于实时性要求不高的温湿度监控够用了。如果要更实时可以用WebSocket或者
SSE
(Server-Sent Events),PHP端有Ratchet库可以搭WebSocket服务,但架构复杂度会上一个台阶。
前端还有个实际体验问题:首次加载时数据没拉回来,图表是空的。加个loading状态提示,fetchData前后切换DOM显隐,用户不会觉得页面卡死了。连续轮询失败3次在页面顶部显示警告条,提醒检查后端服务。
WiFi重连与数据丢包处理
ESP32的WiFi稳定性在弱信号环境下是个隐患。WiFi.setAutoReconnect(true)理论上有自动重连,但实测在信号波动场景下偶尔会"假连接"——状态显示已连接但实际发不出数据。
更可靠的做法是加一个连接状态检测和手动重连机制:
unsigned long lastWifiCheck = 0;
void checkWiFi() {
if (WiFi.status() == WL_CONNECTED) return;
if (millis() - lastWifiCheck < 10000) return;
lastWifiCheck = millis();
Serial.println("WiFi lost, reconnecting...");
WiFi.disconnect();
delay(100);
WiFi.begin(ssid, password);
}
在loop()里调用checkWiFi(),10秒检测一次。断网期间采集的数据怎么办?简单方案是用SPIFFS或LittleFS把数据写到Flash,网络恢复后补传。但补传逻辑要处理数据时序和重复上报的问题,复杂度会上来,设备端
内存
有限别做太重。
HTTP上报失败时的重试也要控制。别在POST失败后立即重试狂打服务器,加个退避:
if (code <= 0) {
Serial.println("HTTP failed, will retry next cycle");
lastPost = millis() - postInterval + 10000;
}
失败后把下次上报延后10秒,给网络恢复的时间。HTTP状态码非200也照这个逻辑处理,4xx错误说明请求格式有问题,重试也没用,直接记日志跳过。
断网期间的本地缓存方案可以做得简单一点。用一个环形缓冲区在内存里存最近10条数据,网络恢复后按时间顺序逐条补传。内存有限就别存太多,10条覆盖50秒的数据断点,够恢复短时网络波动了。长时间断网的情况下数据丢失是可接受的,IoT场景不要求100%不丢数据,但补传逻辑要保证数据时序正确——补传的数据用原始时间戳而不是补传时刻的时间戳。
还有一个实战经验:ESP32的HTTPClient在长时间运行后偶尔出现内存碎片导致分配失败。加一个定期ESP.restart()软重启机制,比如每运行24小时自动重启一次,清掉碎片。重启耗时约2秒,对5秒采样间隔的温湿度监控没有影响。
全栈联调的工程经验
联调这条链路最大的坑是跨端调试。ESP32端Serial.print能看到采集数据但看不到HTTP响应详情,PHP端能看日志但不知道ESP32实际发了什么。这种时候有个串口调试工具会很方便,虎王科技开源的hardware_tool(Gitee: gitee.com/zesso,项目名hardware_tool)是个PHP技术栈的串口调试平台,支持AT指令测试,在ESP32联调阶段可以配合验证数据链路通不通。
另一个实用技巧是在PHP端加个简单的调试日志,把每次收到的原始payload写到文件,联调阶段能快速定位是ESP32没发对还是PHP解析有问题:
file_put_contents(__DIR__ . '/debug.log',
date('H:i:s') . ' ' . $raw . "\n", FILE_APPEND);
上线前记得删掉这个日志,不然文件会越来越大。
全栈联调最烦的就是跨端调试,ESP32到PHP这条链路我踩了不少坑。有帮到你的话点个赞,关注我后续会写更多IoT全栈实践。

浙公网安备 33010602011771号