ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于树莓派与WebSocket的实时温湿度监控系统实战

2026/8/20 3:57:21 拓冰建站 浏览量
基于树莓派与WebSocket的实时温湿度监控系统实战 1. 项目概述一个实时温湿度监控系统的诞生最近在工作室折腾一个智能花房的项目核心需求是实时监控几个关键区域的温度和湿度。市面上的成品要么太贵要么数据不开放要么界面丑得没法看。作为一个喜欢自己动手的开发者我决定用手边的树莓派Raspberry Pi和一些传感器结合Web技术栈自己搭建一套实时数据监控与可视化系统。这个项目的核心目标很简单让温湿度数据从传感器采集开始经过树莓派处理最终实时、美观地呈现在任何一台局域网内的设备浏览器上。听起来像是物联网IoT的经典入门案例对吧但这次我想玩点不一样的。我不想用传统的轮询Polling方式让前端每隔几秒就去问后端“数据更新了吗”这种方式的延迟和服务器压力在需要高实时性的场景下是硬伤。我选择了WebSocket协议来实现真正的全双工、低延迟通信。前端图表库则选用了Chart.js它轻量、灵活能轻松绘制出动态更新的折线图。整个系统跑在树莓派上一个低功耗、小巧但功能完整的Linux服务器完美契合长期稳定运行的需求。这个项目非常适合那些想深入理解实时Web应用、物联网数据流以及如何在资源受限的设备上部署全栈应用的开发者。无论你是想监控家里的环境、鱼缸的水温还是实验室的某个参数这套方案都能提供一个从硬件到软件、从数据采集到前端展示的完整参考。2. 系统架构与核心组件选型解析2.1 为什么是WebSocket而非轮询在决定通信协议时我首先排除了传统的HTTP轮询。想象一下前端每隔5秒发一次请求“后端有新数据吗”大部分时候后端都回答“没有。”这造成了大量的无效请求和网络带宽浪费更重要的是数据更新有最高5秒的延迟。对于温湿度监控虽然5秒可能也能接受但体验上总感觉“不跟手”。WebSocket则完全不同。它在客户端浏览器和服务器树莓派之间建立一条持久的、全双工的通信通道。连接一旦建立双方可以随时主动发送消息。对于本项目树莓派在采集到新的温湿度数据后可以立即通过这条通道“推”给所有正在浏览网页的客户端。客户端图表在收到消息的瞬间就能更新实现了真正的实时性延迟通常在毫秒级。这种“服务器主动推送”的模式是实时应用的首选。注意WebSocket连接在建立初期仍然使用HTTP协议进行一次“握手”握手成功后协议便升级为WebSocket。这意味着它需要后端服务器明确支持WebSocket协议。好在现代Web框架无论是Python的FastAPI、Django Channels还是Node.js的Socket.IO、ws库都对WebSocket提供了很好的支持。2.2 图表库的选择Chart.js的轻量之道前端可视化方案有很多从重量级的ECharts、D3.js到轻量级的Chart.js、Frappe Charts。我选择Chart.js主要基于以下几点考虑轻量且易上手Chart.js的包体积小API设计直观几行代码就能创建一个功能丰富的图表非常适合快速原型开发和嵌入式场景。动态更新友好Chart.js内置了流畅的数据更新和动画过渡功能。当通过WebSocket接收到新数据时只需将新数据点追加到图表数据集dataset中并调用chart.update()方法图表就会以动画形式平滑地移动到新位置视觉效果非常棒。足够的可定制性虽然不像D3.js那样能控制每一个像素但Chart.js提供了丰富的配置选项来调整图表的颜色、刻度、提示框tooltip、标题等足以满足一个监控仪表盘的美观需求。2.3 树莓派不只是个硬件网关在这个项目中树莓派扮演了多重核心角色数据采集器通过GPIO接口连接DHT22或BME280这类数字温湿度传感器运行Python脚本周期性读取数据。WebSocket服务器运行一个后端服务例如用Python的FastAPI或Tornado或者Node.js的ws库编写负责维护与所有浏览器的WebSocket连接并将采集到的数据广播出去。HTTP静态文件服务器同时要托管前端页面HTML、CSS、JavaScript文件。我们可以使用后端框架本身提供静态文件服务或者搭配一个轻量的Web服务器如Nginx。选择树莓派4B或更新的型号其计算能力和内存建议2GB或以上足以流畅运行这些服务。它的低功耗特性通常5V/3A供电也意味着可以7x24小时不间断运行而不用担心电费。2.4 整体数据流设计理解了核心组件整个系统的数据流就清晰了采集层Python脚本例如使用Adafruit_DHT或bme280库每隔一定间隔如2秒从传感器读取一次数据。服务层该脚本将读取到的数据包含时间戳、温度值、湿度值发送给运行在同一树莓派上的WebSocket服务器。这里可以通过进程间通信如Redis Pub/Sub、ZeroMQ或者更简单地让采集脚本直接作为WebSocket服务器的一部分在一个异步循环中同时处理采集和广播。传输层WebSocket服务器将接收到的新数据立即广播给所有已连接的WebSocket客户端即打开的浏览器标签页。展示层浏览器中的JavaScript代码通过WebSocket连接接收到新数据解析后调用Chart.js的API将新数据点添加到对应的温度、湿度折线图中图表动态更新。3. 硬件连接与后端服务搭建实操3.1 硬件准备与连接我使用的是树莓派4B和一款常见的DHT22温湿度传感器。连接非常简单只需要三根母对母杜邦线传感器引脚树莓派GPIO引脚功能VCC (正极)Pin 1 (3.3V)供电DATA (数据)Pin 7 (GPIO 4)数据信号GND (负极)Pin 6 (GND)接地实操心得务必确认使用3.3V供电树莓派的GPIO口工作电压是3.3V用5V供电可能会损坏传感器甚至树莓派。数据线可以连接除了一些有特殊功能的引脚如GPIO 2, 3用于I2C之外的任何数字GPIO代码中对应修改即可。3.2 后端服务搭建FastAPI WebSockets我选择Python的FastAPI框架来构建后端因为它异步性能好编写WebSocket端点非常简洁同时也能轻松提供前端静态文件服务。首先在树莓派上创建项目目录并安装依赖mkdir temp_humidity_monitor cd temp_humidity_monitor python3 -m venv venv source venv/bin/activate pip install fastapi uvicorn adafruit-circuitpython-dht pip install ‘uvicorn[standard]‘ # 用于生产环境由于树莓派系统可能缺少DHT传感器库的系统依赖可能需要先安装sudo apt-get update sudo apt-get install python3-pip python3-libgpiod接下来是核心的后端代码main.pyfrom fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse import asyncio import board import adafruit_dht import json from datetime import datetime from typing import List # 初始化传感器指定数据引脚为GPIO4 (对应BCM编码) # 注意Adafruit库使用BCM编码GPIO4对应物理引脚Pin 7 dht_device adafruit_dht.DHT22(board.D4) app FastAPI() # 管理所有活跃的WebSocket连接 class ConnectionManager: def __init__(self): self.active_connections: List[WebSocket] [] async def connect(self, websocket: WebSocket): await websocket.accept() self.active_connections.append(websocket) def disconnect(self, websocket: WebSocket): self.active_connections.remove(websocket) async def broadcast(self, message: str): # 向所有连接广播消息 for connection in self.active_connections: try: await connection.send_text(message) except: # 如果发送失败可能是连接已断开忽略即可 pass manager ConnectionManager() # 提供前端静态页面 app.mount(/static, StaticFiles(directorystatic), namestatic) app.get(/) async def read_index(): return FileResponse(static/index.html) # WebSocket端点 app.websocket(/ws) async def websocket_endpoint(websocket: WebSocket): await manager.connect(websocket) try: # 保持连接可以在这里处理客户端发来的消息本项目仅服务器推送 while True: # 我们不需要接收客户端数据但需要保持连接活跃 # 这里可以设置一个超时接收或者等待一个不会发生的消息 # 更简单的做法是直接等待靠外部任务来推送数据 await websocket.receive_text() except WebSocketDisconnect: manager.disconnect(websocket) # 后台任务周期性读取传感器数据并广播 async def sensor_data_task(): while True: try: temperature dht_device.temperature humidity dht_device.humidity if temperature is not None and humidity is not None: data_point { timestamp: datetime.now().isoformat(), temperature: round(temperature, 1), humidity: round(humidity, 1) } message json.dumps(data_point) await manager.broadcast(message) print(fBroadcast: {message}) else: print(Failed to retrieve data from sensor) except RuntimeError as error: # DHT传感器偶尔会读取失败这是正常的 print(fDHT read error: {error.args[0]}) await asyncio.sleep(2) continue except Exception as error: dht_device.exit() raise error # 每2秒读取一次 await asyncio.sleep(2) app.on_event(startup) async def startup_event(): # 启动后台传感器数据任务 asyncio.create_task(sensor_data_task()) if __name__ __main__: import uvicorn uvicorn.run(app, host0.0.0.0, port8000)代码关键点解析ConnectionManager类这是一个简单的连接池用于管理所有活跃的WebSocket连接方便进行广播。app.websocket(/ws)定义了WebSocket的接入点。客户端浏览器将通过ws://树莓派IP:8000/ws连接到这里。sensor_data_task异步函数这是一个在后台无限循环的任务。它每2秒尝试读取一次传感器数据如果成功就将数据构造成JSON格式包含ISO格式的时间戳然后通过manager.broadcast()发送给所有已连接的客户端。app.on_event(startup)FastAPI启动时会自动创建并运行这个后台任务使其与主Web服务并行。app.mount(/static, ...)和app.get(/)这部分将static目录挂载为静态资源路径并将根路径指向static/index.html。这样我们的前端文件就有了存放的地方。重要注意事项DHT系列传感器如DHT11, DHT22对时序要求严格且读取时容易因硬件原因失败抛出RuntimeError。代码中已经包含了异常处理遇到读取失败时会等待2秒后重试。在实际部署中你可能需要根据情况调整读取间隔过于频繁的读取如小于2秒会导致失败率升高。4. 前端实时图表与WebSocket交互实现4.1 前端项目结构在项目根目录下创建static文件夹用于存放所有前端资源。temp_humidity_monitor/ ├── main.py # 后端FastAPI应用 ├── static/ │ ├── index.html # 主页面 │ ├── style.css # 样式文件可选 │ └── app.js # 主要的JavaScript逻辑4.2 HTML骨架与Chart.js引入static/index.html文件负责构建页面骨架并引入必要的库。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title树莓派温湿度实时监控/title script srchttps://cdn.jsdelivr.net/npm/chart.js/script link relstylesheet hrefstyle.css /head body div classcontainer header h1️ 实时温湿度监控系统/h1 p数据来源树莓派 DHT22 | 更新时间span idlastUpdate--/span/p p连接状态span idstatus正在连接.../span/p /header main div classchart-container canvas idtempChart/canvas /div div classchart-container canvas idhumidityChart/canvas /div div classdata-panel div classcurrent-data h3当前读数/h3 div classdata-item span classlabel温度/span span idcurrentTemp classvalue--/span °C /div div classdata-item span classlabel湿度/span span idcurrentHumidity classvalue--/span % /div /div div classhistory-control h3图表控制/h3 label fordataPoints显示数据点数/label input typerange iddataPoints min10 max200 value50 span idpointsValue50/span button idclearBtn清空图表数据/button /div /div /main /div script srcapp.js/script /body /html4.3 JavaScript核心逻辑连接、接收与绘图static/app.js是整个前端的大脑它负责建立WebSocket连接、处理数据并驱动Chart.js更新。// 配置 const WS_SERVER_URL ws://${window.location.hostname}:8000/ws; // 自动获取当前主机名 const MAX_DATA_POINTS 200; // 图表最大显示点数 // 状态变量 let temperatureData []; let humidityData []; let labels []; let websocket null; let tempChart, humidityChart; // DOM 元素 const statusEl document.getElementById(status); const lastUpdateEl document.getElementById(lastUpdate); const currentTempEl document.getElementById(currentTemp); const currentHumidityEl document.getElementById(currentHumidity); const dataPointsSlider document.getElementById(dataPoints); const pointsValueEl document.getElementById(pointsValue); const clearBtn document.getElementById(clearBtn); // 初始化WebSocket连接 function initWebSocket() { websocket new WebSocket(WS_SERVER_URL); websocket.onopen function(event) { console.log(WebSocket连接已建立); statusEl.textContent 已连接 ✅; statusEl.style.color green; }; websocket.onmessage function(event) { // 收到服务器推送的新数据 const data JSON.parse(event.data); updateCharts(data); updateCurrentReadings(data); }; websocket.onerror function(error) { console.error(WebSocket错误:, error); statusEl.textContent 连接错误 ❌; statusEl.style.color red; }; websocket.onclose function(event) { console.log(WebSocket连接关闭尝试重连...); statusEl.textContent 连接断开正在重连...; statusEl.style.color orange; // 3秒后尝试重连 setTimeout(initWebSocket, 3000); }; } // 更新图表数据 function updateCharts(data) { const now new Date(data.timestamp); const timeLabel ${now.getHours().toString().padStart(2, 0)}:${now.getMinutes().toString().padStart(2, 0)}:${now.getSeconds().toString().padStart(2, 0)}; // 1. 更新数据数组 labels.push(timeLabel); temperatureData.push(data.temperature); humidityData.push(data.humidity); // 2. 应用数据点数量限制滑动窗口 const maxPoints parseInt(dataPointsSlider.value); if (labels.length maxPoints) { labels.shift(); // 移除最旧的标签 temperatureData.shift(); humidityData.shift(); } // 3. 更新图表 tempChart.data.labels labels; tempChart.data.datasets[0].data temperatureData; humidityChart.data.labels labels; humidityChart.data.datasets[0].data humidityData; // 4. 触发图表重绘带动画 tempChart.update(active); humidityChart.update(active); // 5. 更新最后更新时间显示 lastUpdateEl.textContent timeLabel; } // 更新“当前读数”面板 function updateCurrentReadings(data) { currentTempEl.textContent data.temperature; currentHumidityEl.textContent data.humidity; } // 初始化Chart.js图表 function initCharts() { const tempCtx document.getElementById(tempChart).getContext(2d); const humidityCtx document.getElementById(humidityChart).getContext(2d); const commonOptions { responsive: true, maintainAspectRatio: false, animation: { duration: 750, // 更新动画时长 easing: easeOutQuart }, plugins: { legend: { display: true }, tooltip: { mode: index, intersect: false } }, scales: { x: { display: true, title: { display: true, text: 时间 }, ticks: { maxTicksLimit: 10 // X轴最多显示10个刻度标签避免拥挤 } } } }; // 温度图表 tempChart new Chart(tempCtx, { type: line, data: { labels: labels, datasets: [{ label: 温度 (°C), data: temperatureData, borderColor: rgb(255, 99, 132), backgroundColor: rgba(255, 99, 132, 0.1), borderWidth: 2, tension: 0.4, // 使线条更平滑 fill: true }] }, options: { ...commonOptions, scales: { ...commonOptions.scales, y: { display: true, title: { display: true, text: 温度 (°C) } } } } }); // 湿度图表 humidityChart new Chart(humidityCtx, { type: line, data: { labels: labels, datasets: [{ label: 湿度 (%), data: humidityData, borderColor: rgb(54, 162, 235), backgroundColor: rgba(54, 162, 235, 0.1), borderWidth: 2, tension: 0.4, fill: true }] }, options: { ...commonOptions, scales: { ...commonOptions.scales, y: { display: true, title: { display: true, text: 湿度 (%) }, suggestedMin: 0, suggestedMax: 100 } } } }); } // 页面加载完成后初始化 document.addEventListener(DOMContentLoaded, function() { initCharts(); initWebSocket(); // 绑定滑块事件 dataPointsSlider.addEventListener(input, function() { pointsValueEl.textContent this.value; // 当滑块值改变时立即应用新的数据点限制 const maxPoints parseInt(this.value); if (labels.length maxPoints) { const removeCount labels.length - maxPoints; labels.splice(0, removeCount); temperatureData.splice(0, removeCount); humidityData.splice(0, removeCount); tempChart.update(); humidityChart.update(); } }); // 绑定清空按钮事件 clearBtn.addEventListener(click, function() { labels []; temperatureData []; humidityData []; tempChart.data.labels []; tempChart.data.datasets[0].data []; humidityChart.data.labels []; humidityChart.data.datasets[0].data []; tempChart.update(); humidityChart.update(); currentTempEl.textContent --; currentHumidityEl.textContent --; lastUpdateEl.textContent --; console.log(图表数据已清空); }); });4.4 基础样式美化为了让页面看起来更舒服可以添加一些简单的CSS (static/style.css)body { font-family: Segoe UI, Tahoma, Geneva, Verdana, sans-serif; margin: 0; padding: 20px; background-color: #f5f7fa; color: #333; } .container { max-width: 1200px; margin: 0 auto; background-color: white; border-radius: 12px; box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08); padding: 30px; } header { text-align: center; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 1px solid #eee; } header h1 { margin-top: 0; color: #2c3e50; } .chart-container { position: relative; height: 300px; margin-bottom: 30px; border: 1px solid #eaeaea; border-radius: 8px; padding: 15px; background-color: #fafafa; } .data-panel { display: flex; flex-wrap: wrap; gap: 40px; justify-content: space-around; margin-top: 20px; padding: 20px; background-color: #f8f9fa; border-radius: 8px; } .current-data, .history-control { flex: 1; min-width: 250px; } .data-item { margin-bottom: 15px; font-size: 1.1em; } .label { font-weight: bold; color: #555; } .value { font-size: 1.8em; font-weight: bold; color: #2c3e50; } #currentTemp.value { color: #e74c3c; /* 温度用红色强调 */ } #currentHumidity.value { color: #3498db; /* 湿度用蓝色强调 */ } input[typerange] { width: 80%; margin: 10px 0; } button { background-color: #3498db; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-size: 1em; margin-top: 10px; transition: background-color 0.2s; } button:hover { background-color: #2980b9; } #status { font-weight: bold; }5. 部署、运行与局域网访问5.1 启动服务与测试完成所有代码编写后在树莓派上启动服务cd /path/to/temp_humidity_monitor source venv/bin/activate python main.py如果一切正常你会看到Uvicorn服务器启动的日志类似INFO: Started server process [12345] INFO: Waiting for application startup. INFO: Application startup complete. INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRLC to quit)现在在树莓派本机的浏览器中打开http://localhost:8000你应该能看到监控页面。如果传感器连接正确几秒钟后图表就会开始动态更新。5.2 局域网内其他设备访问这是项目的精髓之一在同一个Wi-Fi网络下的任何设备手机、平板、电脑都能访问这个监控面板。获取树莓派的局域网IP地址在树莓派终端输入hostname -I会显示类似192.168.1.100的IP地址。在其他设备浏览器访问在手机或电脑的浏览器地址栏输入http://[树莓派IP]:8000例如http://192.168.1.100:8000。实操心得确保你的树莓派和访问设备连接在同一个局域网下。有时家庭路由器会开启“客户端隔离”功能这会阻止设备间互访。如果无法访问请检查路由器的相关设置。5.3 设置为系统服务开机自启为了让项目在树莓派开机后自动运行我们需要创建一个系统服务。创建一个服务文件sudo nano /etc/systemd/system/temp-monitor.service写入以下内容请根据你的实际项目路径修改WorkingDirectory和ExecStart[Unit] DescriptionTemperature and Humidity Monitor Web Service Afternetwork.target [Service] Typesimple Userpi WorkingDirectory/home/pi/temp_humidity_monitor EnvironmentPATH/home/pi/temp_humidity_monitor/venv/bin ExecStart/home/pi/temp_humidity_monitor/venv/bin/python main.py Restarton-failure RestartSec10 [Install] WantedBymulti-user.target启用并启动服务sudo systemctl daemon-reload sudo systemctl enable temp-monitor.service sudo systemctl start temp-monitor.service检查服务状态sudo systemctl status temp-monitor.service看到active (running)就表示成功了。现在即使重启树莓派服务也会自动启动。6. 常见问题排查与进阶优化6.1 传感器读取失败或数据为None这是DHT传感器最常见的问题。症状后端日志频繁打印Failed to retrieve data或DHT read error前端数据长时间不更新。排查检查接线确认VCC、DATA、GND三根线是否连接牢固是否接对了引脚。检查供电确保使用3.3V而非5V。可以尝试给传感器VCC和GND之间并联一个0.1uF的陶瓷电容以稳定电源。调整读取间隔在main.py的sensor_data_task函数中增加await asyncio.sleep(2)的等待时间比如改为3秒或5秒。DHT22两次读取之间至少需要2秒的间隔。尝试其他GPIO引脚有些GPIO引脚可能有特殊用途如GPIO2/3有上拉电阻换一个普通的数字引脚试试并记得在代码中修改引脚定义board.D4。更换传感器不排除传感器本身损坏的可能性。6.2 WebSocket连接失败或前端无法访问症状浏览器控制台报错WebSocket connection to ‘ws://...‘ failed或页面无法加载。排查检查服务是否运行在树莓派上运行sudo systemctl status temp-monitor.service或ps aux | grep python。检查防火墙树莓派默认的防火墙如ufw可能阻止了8000端口。确保端口开放sudo ufw allow 8000。检查IP地址确认你访问的IP地址是树莓派当前的正确局域网IP。检查app.js中的WS地址前端代码中WS_SERVER_URL使用了window.location.hostname这能自动获取当前页面的主机名。如果通过IP访问这通常是正确的。如果通过主机名如http://raspberrypi.local:8000访问请确保网络支持mDNSBonjour。查看后端日志后端启动时是否绑定到了0.0.0.0这很重要表示监听所有网络接口。6.3 图表性能与数据管理优化当系统长时间运行前端图表数据点会越来越多可能影响浏览器性能。已实现的优化我们通过滑块控制了显示的数据点数量MAX_DATA_POINTS这是一个“滑动窗口”只保留最新的N个点。进阶优化数据降采样Downsampling对于超长时间跨度如24小时的图表不需要每秒一个点。可以在前端或后端对历史数据进行聚合例如每小时只保留一个平均值或最大值/最小值再传给图表显示。这能大幅减少渲染压力。后端数据持久化当前数据只存在于内存中树莓派重启就丢失了。可以将数据存入SQLite数据库或简单的CSV/JSON文件。这样即使重启也能加载历史数据并可以实现按日、按周查看历史趋势的功能。这需要扩展后端API提供历史数据查询接口。多客户端连接管理当前的ConnectionManager是基础的列表。如果连接数非常多成百上千广播循环可能成为瓶颈。可以考虑使用asyncio.Queue或专门的发布-订阅库来优化广播效率。6.4 提升系统可靠性使用更稳定的传感器如果DHT22在您的环境中不稳定可以考虑升级到BME280或SHT31。这些是I2C或SPI接口的传感器精度更高通信更可靠但价格也稍贵接线和代码需要调整。添加看门狗Watchdog可以编写一个简单的脚本定期检查Web服务是否存活如果崩溃则自动重启。或者利用systemd服务的Restarton-failure配置它已经提供了一定的重启保障。日志记录将重要的系统事件如传感器读数、客户端连接/断开记录到文件中便于后期排查问题。Python的logging模块可以很方便地实现。这个项目从想法到实现打通了硬件感知、实时通信和前端可视化整个链路。它不仅仅是一个温湿度监控工具更是一个学习现代Web实时技术和物联网架构的绝佳样板。你可以基于此轻松扩展出监控其他传感器如光照、土壤湿度、空气质量、添加报警功能当温度超过阈值时发送通知、甚至远程控制设备如打开风扇等更多有趣的应用。