Node.js与树莓派I2C通信实战:构建温度监控Web服务
1. 项目概述:用Node.js在树莓派上玩转I2C温度监控
最近在折腾一个智能家居的小项目,需要实时监控房间角落的温度,但又不想用那些成品模块,总觉得少了点动手的乐趣。手头正好有个闲置的树莓派Zero W和一块经典的DS1621温度传感器芯片,就琢磨着能不能用Node.js写个服务,既能通过I2C总线读取传感器数据,又能实时展示在网页上。这个组合听起来有点跨界——一个是擅长硬件交互的单板计算机,另一个则是以构建高性能网络服务见长的JavaScript运行时。但实际做下来发现,Node.js凭借其强大的i2c-bus库和事件驱动模型,在树莓派上操作I2C设备异常顺手,几行代码就能把传感器数据变成Web API,非常适合用来快速搭建物联网原型或者数据面板。
这个项目本质上是一个微型的“硬件即服务”案例。核心目标很明确:让树莓派通过I2C接口与DS1621传感器对话,读取其测量的温度值,然后利用Node.js内置的HTTP模块创建一个轻量级Web服务器,将温度数据以JSON格式或动态网页的形式推送给任何连接到同一局域网的浏览器或客户端。它省去了复杂的Apache或Nginx配置,一个脚本同时扮演了“设备驱动”和“Web后端”两个角色,功耗极低,非常适合7x24小时运行在角落里的树莓派Zero W上。
如果你是对物联网感兴趣的Web开发者,想了解如何让JavaScript跳出浏览器、直接操控硬件;或者是电子爱好者,希望为你的硬件项目快速添加一个网络监控界面,那么这个项目会是一个绝佳的起点。整个过程涉及Linux基础配置、I2C总线原理、Node.js硬件接口编程以及简单的Web开发,但我会一步步拆解,确保即便你之前没接触过树莓派或I2C,也能跟着做出来。
2. 核心思路与方案选型解析
2.1 为什么选择Node.js + 树莓派 + I2C这个组合?
首先得说说为什么是这三个元素的组合。树莓派作为一款流行的单板计算机,其GPIO(通用输入输出)引脚支持包括I2C在内的多种硬件通信协议,这为我们连接传感器提供了物理基础。而Node.js,虽然最初是为网络后端而生,但其非阻塞I/O和事件循环架构,恰好适合处理像读取传感器数据这种频率不高、但需要持续轮询或监听中断的IO密集型任务。你不需要像在传统单片机编程中那样死循环或处理复杂的中断服务程序,只需用setInterval定时读取,或者利用某些传感器的中断引脚特性(DS1621支持温度阈值中断)配合事件监听即可,代码逻辑非常清晰。
更重要的是,Node.js拥有一个极其活跃的生态系统。对于硬件交互,有i2c-bus这样成熟、底层的库,它提供了近乎原生的I2C总线访问能力;对于Web服务,其内置的http或express框架能快速搭建REST API或服务器渲染页面。这意味着你用一种语言(JavaScript)和一套工具(npm),就能打通从最底层的硬件信号读取到最上层的网络数据展示的整个链路,极大地简化了开发和部署的复杂度。
至于I2C总线本身,它是这个项目的通信基石。I2C是一种同步、半双工、多主从的串行通信总线,只需要两根线(SDA数据线和SCL时钟线)就能连接多个设备,每个设备有唯一的地址。DS1621的默认地址是0x48(可通过硬件引脚配置为其他地址),这就像它在I2C网络上的门牌号。选择I2C而不是SPI或UART,主要是因为它接线简单(仅需两根信号线加电源和地),并且树莓派Linux内核原生支持,通过/dev/i2c-1这样的设备文件就能轻松访问,无需额外编写内核模块。
2.2 硬件选型与成本考量:为何是DS1621和树莓派Zero W?
DS1621是一款非常经典的数字温度传感器,其输出是直接的数字值,无需像热敏电阻那样进行复杂的模拟-数字转换和校准。它的测量范围是-55°C到+125°C,精度为±0.5°C,对于室内环境监控完全够用。而且它本身就是一个完整的I2C从设备,内部集成了ADC和寄存器,我们只需要发送简单的命令字(如启动转换、读取温度值)就能获取数据,硬件电路极其简洁,只需要在电源引脚加个0.1uF的退耦电容即可稳定工作。
树莓派Zero W是我认为的“物联网终端神器”。它体积小巧(仅65mm x 30mm),功耗极低(空闲时约100mA,5V/0.5W),并且集成了Wi-Fi和蓝牙。低功耗意味着你可以用普通的移动电源或电池组让它连续工作好几天,非常适合部署在不易布线的地方。虽然它的计算性能(单核1GHz ARM11)不如Pi 3或4,但对于运行一个Node.js脚本、服务几个网页请求来说绰绰有余。当然,如果你手头是树莓派3B+或4B,整个过程完全一样,性能还会更强,只是功耗和体积会大一些。
这个组合的整体成本非常低:树莓派Zero W约10-15美元,DS1621传感器芯片约2-3美元,加上一些杜邦线和面包板,总成本可以控制在20美元以内。相比一些集成的物联网开发板或商业温湿度计,它给了你完全的软件控制权和硬件扩展能力。
注意:市面上有些DS1621是DIP-8封装(直插),有些是SO-8(贴片)。对于面包板实验,DIP封装更方便。购买时请确认是DS1621,而非DS18B20(单总线协议)或其他型号。
3. 硬件连接与树莓派系统配置详解
3.1 电路连接:从原理图到面包板
DS1621与树莓派Zero W的连接非常简单,只需要4根杜邦线(母对公)。树莓派Zero W的GPIO引脚排列与标准40针的树莓派一致,我们需要找到I2C1所使用的两个引脚:
- GPIO 2 (SDA1):对应物理引脚第3针。
- GPIO 3 (SCL1):对应物理引脚第5针。
此外,还需要为传感器提供电源和地:
- 3.3V电源:树莓派Zero W的物理引脚第1针或第17针提供3.3V输出。DS1621的工作电压范围是2.7V到5.5V,因此使用3.3V完全兼容,且与树莓派的GPIO逻辑电平匹配,无需电平转换。
- 接地 (GND):树莓派上任意一个GND引脚,例如物理引脚第6、9、14、20、25、30、34、39等。
具体接线如下:
- 将DS1621的VDD引脚(通常为第8脚)连接到树莓派的3.3V引脚。
- 将DS1621的GND引脚(通常为第4脚)连接到树莓派的任意GND引脚。
- 将DS1621的SDA引脚(通常为第5脚)连接到树莓派的GPIO 2 (SDA1)。
- 将DS1621的SCL引脚(通常为第6脚)连接到树莓派的GPIO 3 (SCL1)。
DS1621的其余引脚(A0, A1, A2用于设置I2C地址,Tout为温度报警输出,在此项目中我们暂不使用)可以悬空。为了电源稳定,建议在DS1621的VDD和GND引脚之间焊接或插接一个0.1µF的陶瓷电容,尽可能靠近芯片引脚。
实操心得:连接时务必在树莓派断电状态下进行。虽然I2C总线理论上支持热插拔,但带电操作杜邦线容易造成瞬间短路,可能损坏GPIO或传感器。接好后,最好用万用表通断档快速检查一下电源和地是否接反、SDA/SCL是否与相邻电源线短路,这是避免“冒烟测试”失败的关键一步。
3.2 树莓派系统准备:从烧录到基础服务启用
首先需要为树莓派准备操作系统。我推荐使用Raspberry Pi OS Lite (32-bit),这是一个没有图形桌面的精简版本,通过命令行管理,资源占用极小,非常适合服务器类应用。你可以从树莓派官网下载镜像,并使用 Raspberry Pi Imager 工具烧录到Micro SD卡中。在烧录前,Imager工具允许你进行一些预配置(点击设置图标):
- 设置主机名:如
temperature-pi。 - 启用SSH:勾选“Enable SSH”,并建议设置密码认证或导入你的SSH公钥。
- 配置Wi-Fi:填入你的国家、SSID和密码,这样树莓派启动后就能自动连接网络。
- 设置地区选项:时区、键盘布局等。
这些设置会被写入SD卡启动分区,省去了第一次启动后接显示器和键盘的麻烦。烧录完成后,将SD卡插入树莓派Zero W,上电启动。
等待约一分钟后,你需要找到树莓派在局域网中的IP地址。有多种方法:
- 登录你的路由器管理界面,查看DHCP客户端列表。
- 使用网络扫描工具,如
nmap(在另一台Linux/Mac电脑上:nmap -sn 192.168.1.0/24)或“Fing”这类手机APP。 - 如果树莓派连接了显示器,启动后输入
hostname -I命令查看。
获得IP地址后,就可以用SSH客户端连接了。Windows用户推荐使用 PuTTY ,macOS和Linux用户直接在终端使用ssh命令即可。例如:
ssh pi@192.168.1.100默认用户名是pi,密码是你之前通过Imager设置的,或者是经典的raspberry(如果未改)。
登录后,第一件事是更新系统并安装必要软件:
sudo apt update && sudo apt upgrade -y sudo apt install -y git nodejs npm检查Node.js和npm版本:
node -v npm -v较新版本的Raspberry Pi OS可能已经预装了Node.js,但版本可能较旧。如果版本低于14.x,建议通过NodeSource仓库安装更新的LTS版本。
3.3 启用I2C接口并安装硬件访问库
树莓派的I2C接口默认是禁用的,需要手动开启。
- 运行配置工具:
sudo raspi-config - 使用方向键选择
Interface Options->I2C。 - 当询问“Would you like the ARM I2C interface to be enabled?”时,选择
<Yes>。 - 完成后,选择
<Finish>,并选择重启。
重启后,SSH重新登录,验证I2C是否启用:
ls /dev/i2c*你应该能看到类似/dev/i2c-1的设备文件。i2c-1对应的是GPIO 2/3这组I2C。为了能在非root用户下访问I2C设备,将当前用户(pi)加入i2c组:
sudo usermod -aG i2c pi你需要注销并重新登录(或重启)才能使组权限生效。
接下来,安装Node.js的I2C库。我们将使用i2c-bus,它提供了异步和同步的API,功能强大:
npm install i2c-bus为了测试硬件连接是否正常,我们可以先使用一个命令行工具i2c-tools:
sudo apt install -y i2c-tools安装后,扫描I2C总线上的设备:
i2cdetect -y 1这条命令会扫描I2C-1总线(对应GPIO 2/3)上从地址0x03到0x77的所有设备。如果DS1621连接正确,你应该能在输出表格中看到地址0x48(或其他,如果A0/A1/A2引脚被上拉/下拉改变了地址)被标记为48或UU(如果已被驱动占用)。看到这个地址,就证明物理连接和I2C总线驱动是正常的。
4. Node.js与I2C通信的核心原理与代码实现
4.1 I2C总线通信基础与DS1621指令集
在编写代码前,必须理解Node.js如何通过i2c-bus库与I2C设备对话。I2C通信的本质是主设备(树莓派)向从设备(DS1621)的特定寄存器读写数据。每个操作都以一个7位设备地址(0x48)开始,后面跟着一个读/写位。i2c-bus库帮我们处理了底层的时序和协议,我们只需要关心发送什么命令(写入什么数据)和读取多少字节。
DS1621有几个关键的命令字(Command Byte):
0xAA:读取温度值。发送此命令后,主设备可以读取两个字节的温度数据。0xEE:开始温度转换。发送此命令,DS1621开始一次AD转换。0x22:停止温度转换。0xAC:访问配置寄存器。可以读取或写入配置字节,用于设置工作模式(如连续转换或单次转换)、设置温度阈值等。
DS1621的温度数据格式是9位精度,通过两个字节(16位)返回。具体格式如下:
- 字节1(高8位):包含温度的整数部分(8位,二进制补码形式)。例如,25°C对应
0x19。 - 字节2(低8位):只有最高位(bit 7)有效,表示0.5°C。如果该位为1,则表示温度有0.5°C的小数部分。其余位为0。
因此,温度计算公式为:温度 = 字节1的值 + (字节2 >> 7) * 0.5。如果字节1的最高位(bit 7)为1,则表示负数(二进制补码),需要先进行转换。在我们的示例中,为了简化,我们先处理正温度。
4.2 编写核心的传感器读取模块
首先,创建一个项目目录并初始化:
mkdir temperature-monitor && cd temperature-monitor npm init -y npm install i2c-bus然后,创建第一个脚本文件read_ds1621.js,实现最核心的温度读取功能:
const i2c = require('i2c-bus'); // DS1621的I2C地址 const DS1621_ADDR = 0x48; // 命令字 const CMD_READ_TEMP = 0xaa; const CMD_START_CONVERT = 0xee; const CMD_ACCESS_CONFIG = 0xac; /** * 从DS1621读取当前温度 * @param {number} busNumber - I2C总线编号,树莓派上通常为1 * @returns {Promise<number>} 解析为摄氏度温度值的Promise */ async function readTemperature(busNumber = 1) { // 异步打开I2C总线 const i2cBus = await i2c.openPromisified(busNumber); try { // 1. 发送开始转换命令(如果传感器未处于连续转换模式) // 对于DS1621,上电后默认是单次转换模式。 // 为了确保读到最新数据,我们先发送开始转换命令,然后等待转换完成。 // 转换时间典型值为500ms(9位精度时最大1秒)。 await i2cBus.sendByte(DS1621_ADDR, CMD_START_CONVERT); // 等待转换完成。更严谨的做法是轮询配置寄存器的DONE位。 // 这里为了简单,固定等待1秒。 await new Promise(resolve => setTimeout(resolve, 1000)); // 2. 发送读取温度命令 await i2cBus.sendByte(DS1621_ADDR, CMD_READ_TEMP); // 3. 读取两个字节的温度数据 const buffer = Buffer.alloc(2); await i2cBus.readI2cBlock(DS1621_ADDR, 0, buffer.length, buffer); // 4. 解析温度值 let tempHigh = buffer[0]; // 整数部分 let tempLow = buffer[1]; // 低字节,仅最高位有效 // 判断是否为负数(补码,最高位为1) let temperature; if (tempHigh & 0x80) { // 负数处理:先取反加1得到绝对值的二进制,再结合小数位 tempHigh = (~tempHigh) & 0xff; // 简单处理:本例假设温度为正,负数处理逻辑略复杂,此处先返回0 // 实际应用中需要完整实现补码转换 temperature = 0 - (tempHigh + ((tempLow >> 7) * 0.5)); } else { // 正数处理 temperature = tempHigh + ((tempLow >> 7) * 0.5); } return temperature; } catch (error) { console.error('读取温度失败:', error); throw error; // 将错误向上抛 } finally { // 确保关闭I2C总线,释放资源 await i2cBus.close(); } } // 示例:立即读取一次温度 (async () => { try { const temp = await readTemperature(1); console.log(`当前温度: ${temp.toFixed(1)} °C`); } catch (err) { console.error('执行失败:', err); } })();这个模块定义了一个readTemperature异步函数,它封装了与DS1621通信的全部细节:启动转换、等待、发送读命令、读取原始数据、解析为摄氏度。使用async/await语法让异步操作看起来像同步代码一样清晰。注意,我们使用了i2c.openPromisified,它返回一个支持Promise的I2C总线对象,这样可以用await来调用其方法。
重要提示:
i2c-bus库的readI2cBlock方法在读取时,实际上会先发送一个内部的“寄存器指针”字节(这里我们传入了0,但有些设备需要指定寄存器地址)。对于DS1621,在发送0xAA读温度命令后,其内部指针已经指向温度数据的高字节,所以直接读取两个字节即可。这是理解I2C设备数据手册的关键:每个命令都可能改变了内部指针的状态。
4.3 构建一个简单的HTTP服务器提供数据API
仅有读取温度的函数还不够,我们需要一个方式将数据暴露出去。接下来,我们创建一个Web服务器,它提供两个端点:一个返回JSON格式的纯数据API,另一个返回一个自动刷新的简单网页。创建文件server.js:
const http = require('http'); const url = require('url'); const { readTemperature } = require('./read_ds1621.js'); // 导入刚才写的模块 // 创建一个HTTP服务器 const server = http.createServer(async (req, res) => { const parsedUrl = url.parse(req.url, true); const pathname = parsedUrl.pathname; // 设置CORS头部,允许任何来源的请求(仅用于开发测试) res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET'); // 路由处理 if (pathname === '/api/temperature' && req.method === 'GET') { // 端点1: 返回JSON格式的温度数据 try { const temp = await readTemperature(1); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, temperature: temp, unit: 'celsius', timestamp: new Date().toISOString() })); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: error.message })); } } else if (pathname === '/' || pathname === '/index.html') { // 端点2: 返回一个简单的HTML页面,自动刷新显示温度 res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>DS1621 Temperature Monitor</title> <style> body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background-color: #f0f0f0; } .container { background: white; padding: 30px; border-radius: 10px; display: inline-block; box-shadow: 0 0 10px rgba(0,0,0,0.1); } h1 { color: #333; } #tempValue { font-size: 4em; font-weight: bold; color: #e74c3c; margin: 20px 0; } .unit { font-size: 0.5em; color: #777; } #lastUpdate { color: #95a5a6; margin-top: 20px; } .loading { color: #3498db; } </style> </head> <body> <div class="container"> <h1>🌡️ DS1621 Temperature Monitor</h1> <div id="tempDisplay"> <div id="tempValue" class="loading">Loading...</div> <div>Temperature</div> </div> <div id="lastUpdate">Last updated: <span id="updateTime">--</span></div> <p><small>Data updates every 5 seconds</small></p> </div> <script> function updateTemperature() { fetch('/api/temperature') .then(response => response.json()) .then(data => { const display = document.getElementById('tempValue'); const timeSpan = document.getElementById('updateTime'); if (data.success) { display.innerHTML = `${data.temperature.toFixed(1)} <span class="unit">°C</span>`; display.className = ''; timeSpan.textContent = new Date(data.timestamp).toLocaleTimeString(); } else { display.textContent = 'Error'; display.style.color = '#e74c3c'; } }) .catch(err => { console.error('Fetch error:', err); document.getElementById('tempValue').textContent = 'Connection Error'; }); } // 页面加载后立即更新一次 updateTemperature(); // 然后每5秒更新一次 setInterval(updateTemperature, 5000); </script> </body> </html> `); } else { // 404 处理 res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not Found'); } }); // 启动服务器,监听8080端口 const PORT = process.env.PORT || 8080; server.listen(PORT, () => { console.log(`✅ Server running at http://0.0.0.0:${PORT}`); console.log(` Local: http://localhost:${PORT}`); console.log(` Network: http://YOUR_PI_IP:${PORT}`); });这个服务器做了几件事:
- 创建HTTP服务器:使用Node.js内置的
http模块。 - 路由处理:
- 当访问
/api/temperature时,调用readTemperature函数,并将结果以JSON格式返回。这种设计便于其他程序(如手机APP、Home Assistant)通过API获取数据。 - 当访问根路径
/时,返回一个内嵌了JavaScript的HTML页面。这个页面会每5秒自动调用/api/temperature接口,并更新页面上的温度显示。
- 当访问
- 错误处理:在读取温度失败时,API会返回500错误和错误信息;前端页面也会显示错误状态。
- CORS设置:为了方便开发测试,我们设置了
Access-Control-Allow-Origin: *,允许任何网页访问API。在生产环境中,你应该将其替换为具体的域名,以增强安全性。
现在,将read_ds1621.js和server.js放在同一个目录下,然后运行:
node server.js如果一切正常,你将看到服务器启动的日志。此时,在你的电脑或手机浏览器中,输入树莓派的IP地址和端口(例如http://192.168.1.100:8080),就能看到温度监控页面了。页面初始显示“Loading...”,5秒内会更新为具体的温度值,并且之后每5秒自动刷新一次。
5. 深入优化:性能、稳定性与功能扩展
5.1 性能优化与资源管理
上面的基础版本虽然能工作,但在长期运行和资源管理上还有优化空间。主要问题有两个:一是每次HTTP请求都打开、关闭一次I2C总线,二是每次读取都重新启动一次温度转换并等待1秒,这会导致响应慢且效率低。
优化方案1:保持I2C总线长连接并缓存温度值对于长期运行的服务,我们应该在服务启动时打开I2C总线,并在整个生命周期内复用这个连接。同时,可以设置一个后台定时器(例如每2秒)主动读取温度并缓存,这样Web请求到来时能立即返回缓存的值,响应速度极快(毫秒级)。
创建优化版temperature_service.js:
const i2c = require('i2c-bus'); const http = require('http'); const DS1621_ADDR = 0x48; const CMD_READ_TEMP = 0xaa; const CMD_START_CONVERT = 0xee; const CMD_ACCESS_CONFIG = 0xac; const CMD_READ_CONFIG = 0xac; class TemperatureService { constructor(busNumber = 1, updateInterval = 2000) { this.busNumber = busNumber; this.updateInterval = updateInterval; this.i2cBus = null; this.currentTemperature = null; this.lastUpdateTime = null; this.isUpdating = false; this.updateTimer = null; } async init() { try { // 打开I2C总线并保持打开状态 this.i2cBus = await i2c.openPromisified(this.busNumber); console.log(`I2C bus ${this.busNumber} opened successfully.`); // 可选:配置DS1621为连续转换模式,这样它就会自动持续测量 // 写入配置寄存器:连续转换模式 (bit 0 = 0), 保持默认其他位 await this.i2cBus.writeByte(DS1621_ADDR, CMD_ACCESS_CONFIG, 0x00); // 发送开始转换命令,在连续模式下,只需发一次 await this.i2cBus.sendByte(DS1621_ADDR, CMD_START_CONVERT); console.log('DS1621 configured for continuous conversion.'); // 立即进行一次初始读取 await this.updateTemperature(); // 启动定时更新 this.startPeriodicUpdate(); return true; } catch (error) { console.error('Failed to initialize TemperatureService:', error); await this.cleanup(); throw error; } } async updateTemperature() { if (this.isUpdating || !this.i2cBus) return; this.isUpdating = true; try { // 发送读取温度命令 await this.i2cBus.sendByte(DS1621_ADDR, CMD_READ_TEMP); // 读取两个字节 const buffer = Buffer.alloc(2); await this.i2cBus.readI2cBlock(DS1621_ADDR, 0, buffer.length, buffer); let tempHigh = buffer[0]; let tempLow = buffer[1]; let temperature; // 简化的正温度解析(负数处理略) if (tempHigh & 0x80) { // 简单处理负数:本例假设环境温度为正,若为负则按补码计算 // 实际应完整实现补码转换,此处返回一个错误值或特殊值 temperature = null; // 表示读取异常 } else { temperature = tempHigh + ((tempLow >> 7) * 0.5); } if (temperature !== null) { this.currentTemperature = temperature; this.lastUpdateTime = new Date(); } } catch (error) { console.error('Error updating temperature:', error); } finally { this.isUpdating = false; } } startPeriodicUpdate() { if (this.updateTimer) clearInterval(this.updateTimer); this.updateTimer = setInterval(() => { this.updateTemperature(); }, this.updateInterval); console.log(`Periodic temperature update started (every ${this.updateInterval}ms).`); } getTemperature() { return { temperature: this.currentTemperature, lastUpdate: this.lastUpdateTime, success: this.currentTemperature !== null }; } async cleanup() { if (this.updateTimer) { clearInterval(this.updateTimer); this.updateTimer = null; } if (this.i2cBus) { await this.i2cBus.close(); console.log('I2C bus closed.'); } } } // 使用示例:创建服务并启动HTTP服务器 (async () => { const tempService = new TemperatureService(1, 2000); // 每2秒更新一次 try { await tempService.init(); const server = http.createServer((req, res) => { if (req.url === '/api/temperature' && req.method === 'GET') { const data = tempService.getTemperature(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ...data, timestamp: new Date().toISOString() })); } else { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Temperature Monitor Service. Use GET /api/temperature'); } }); const PORT = 8080; server.listen(PORT, () => { console.log(`🚀 Optimized server listening on port ${PORT}`); }); // 优雅关闭:捕获退出信号,清理资源 process.on('SIGINT', async () => { console.log('\nShutting down gracefully...'); await tempService.cleanup(); server.close(() => { console.log('Server closed.'); process.exit(0); }); }); } catch (error) { console.error('Service startup failed:', error); process.exit(1); } })();这个优化版做了重大改进:
- 单例与长连接:
TemperatureService类在初始化时打开I2C总线并保持打开,避免了频繁开关的开销。 - 后台轮询与缓存:设置一个
setInterval定时器,每2秒主动读取一次温度并更新内部缓存currentTemperature。Web API请求直接返回缓存值,响应速度极快。 - 连续转换模式:通过写入配置寄存器(
0xAC),将DS1621设置为连续转换模式。在这种模式下,传感器会自动持续进行AD转换,我们读取时总能拿到相对较新的数据,无需每次发送开始转换命令并等待1秒,将读取延迟从秒级降到毫秒级。 - 优雅关闭:监听
SIGINT信号(如Ctrl+C),在程序退出前主动关闭I2C总线和HTTP服务器,确保资源被正确释放。
5.2 异常处理与日志记录
一个健壮的服务必须能处理各种异常情况,并留下清晰的日志供排查。我们可以在上面的服务中添加更完善的错误处理和日志。
首先,安装一个简单的日志库,如winston或pino,这里为了轻量,我们使用内置的console并稍加封装,同时将错误写入文件:
// 在temperature_service.js开头添加 const fs = require('fs').promises; const path = require('path'); class Logger { constructor(logDir = './logs') { this.logDir = logDir; this.init(); } async init() { try { await fs.mkdir(this.logDir, { recursive: true }); } catch (err) { console.error('Could not create log directory:', err); } } async log(level, message, meta = {}) { const timestamp = new Date().toISOString(); const logEntry = `${timestamp} [${level.toUpperCase()}] ${message} ${Object.keys(meta).length ? JSON.stringify(meta) : ''}\n`; // 输出到控制台 console.log(logEntry.trim()); // 写入到文件(按日期) const dateStr = new Date().toISOString().split('T')[0]; const logFile = path.join(this.logDir, `temp_service_${dateStr}.log`); try { await fs.appendFile(logFile, logEntry); } catch (err) { console.error('Failed to write log file:', err); } } info(message, meta) { return this.log('info', message, meta); } error(message, meta) { return this.log('error', message, meta); } warn(message, meta) { return this.log('warn', message, meta); } } // 在TemperatureService构造函数中初始化logger // this.logger = new Logger();然后在TemperatureService的各个方法中,用this.logger.info()、this.logger.error()替换console.log和console.error。这样,所有的运行状态、温度读数、错误信息都会同时输出到控制台和按日期分割的日志文件中,便于后期排查问题。
对于I2C通信中可能出现的异常,如总线错误、设备无响应等,除了在catch块中记录日志外,还可以实现重试机制。例如,在updateTemperature方法中,如果连续多次读取失败,可以尝试重新初始化I2C总线或重启转换命令。
5.3 功能扩展思路
基础的温度读取和Web展示完成后,这个项目可以轻松扩展出更多实用功能:
1. 添加历史数据记录与图表展示使用轻量级数据库如SQLite(通过sqlite3npm包)或甚至直接写入JSON文件,定期(如每分钟)将温度值连同时间戳存储起来。然后,可以新增一个API端点(如/api/temperature/history?hours=24)来查询历史数据。前端可以使用Chart.js等库绘制温度变化曲线图。
2. 实现温度报警与通知在TemperatureService类中添加阈值检查逻辑。例如,设置一个高温报警阈值(如30°C)和低温报警阈值(如10°C)。每次更新温度后进行检查,如果超过阈值,且距离上次报警已过一段时间(防骚扰),则触发报警动作。报警动作可以是:
- 在服务器日志中记录错误。
- 向一个Webhook URL发送POST请求(可用于触发IFTTT、钉钉、Slack等通知)。
- 发送电子邮件(使用
nodemailer库)。 - 控制一个连接到树莓派的蜂鸣器或LED灯。
3. 支持多传感器与传感器抽象如果你有多个DS1621(通过设置不同的A0/A1/A2地址),或者想接入其他I2C传感器(如BMP280气压计、OLED显示屏),可以设计一个通用的Sensor基类或接口,然后为每种传感器实现具体的read()方法。TemperatureService管理一个传感器列表,定期读取所有传感器数据。Web API可以返回所有传感器的数据集合。
4. 提供配置界面与远程控制目前参数(如更新间隔、报警阈值)是硬编码在代码中的。可以创建一个简单的配置页面(另一个HTML文件),通过表单让用户修改这些参数,并保存到JSON配置文件中。服务器监听配置文件变化并动态应用新配置。这需要添加对应的POST API端点来处理配置更新。
5. 容器化部署使用Docker将整个应用打包成一个镜像。这能确保运行环境的一致性,简化部署。创建一个Dockerfile,基于node:16-slim镜像,复制代码,安装依赖,并设置启动命令。你甚至可以使用Docker Compose来编排多个服务(如本应用加上一个Grafana用于可视化)。
6. 部署、维护与常见问题排查
6.1 生产环境部署建议
在开发测试完成后,你可能希望这个服务能开机自启、稳定运行。有几种方法:
方法一:使用systemd(推荐)这是Linux系统标准的服务管理方式。创建一个服务文件:
sudo nano /etc/systemd/system/temperature-monitor.service内容如下:
[Unit] Description=Temperature Monitor Service After=network.target [Service] Type=simple User=pi WorkingDirectory=/home/pi/temperature-monitor ExecStart=/usr/bin/node /home/pi/temperature-monitor/temperature_service.js Restart=on-failure RestartSec=10 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target然后启用并启动服务:
sudo systemctl daemon-reload sudo systemctl enable temperature-monitor.service sudo systemctl start temperature-monitor.service # 查看状态和日志 sudo systemctl status temperature-monitor.service journalctl -u temperature-monitor.service -f使用systemd的好处是管理方便(start/stop/restart/status),并且能自动重启崩溃的服务。
方法二:使用进程管理工具PM2PM2是Node.js生态中非常流行的进程管理器,内置负载均衡、日志管理、监控等功能。
# 全局安装PM2 sudo npm install -g pm2 # 启动你的服务 pm2 start temperature_service.js --name temp-monitor # 设置开机自启 pm2 startup # 按照PM2输出的命令执行(通常是复制粘贴一行sudo命令) pm2 savePM2提供了丰富的命令:pm2 list查看进程,pm2 logs temp-monitor查看日志,pm2 monit图形化监控。
6.2 常见问题与排查技巧
即使按照步骤操作,也可能会遇到问题。下面是一个快速排查指南:
| 问题现象 | 可能原因 | 排查步骤 |
|---|---|---|
运行node server.js时报错:Error: EACCES: permission denied, open '/dev/i2c-1' | 当前用户没有访问I2C设备的权限。 | 1. 确认用户已加入i2c组:groups $USER,查看输出是否包含i2c。2. 如果不在组内: sudo usermod -aG i2c $USER,然后注销并重新登录。3. 检查设备文件权限: ls -l /dev/i2c-1,应显示crw-rw----,组为i2c。 |
i2cdetect -y 1扫描不到设备(地址0x48未出现) | 硬件连接问题、电源问题、传感器损坏或地址不对。 | 1.断电检查所有杜邦线连接是否牢固,SDA/SCL是否接反。 2. 用万用表测量DS1621的VCC和GND之间电压是否为3.3V左右。 3. 检查DS1621的A0, A1, A2引脚是否全部接地(默认地址0x48)。如果接了VCC,地址会改变。 4. 尝试扫描所有地址: i2cdetect -y 1 0x00 0x7f。5. 换一个I2C设备(如OLED屏)测试,确认树莓派I2C功能正常。 |
| Web页面能打开,但一直显示“Loading...”或“Error” | Node.js服务读取传感器失败,或网络端口不通。 | 1. 在树莓派上直接运行读取测试脚本:node read_ds1621.js,看是否能输出温度。2. 检查服务器是否在运行:`ps aux |
| 温度读数不准、跳动大或为0 | 电源噪声、传感器未校准、软件解析错误。 | 1.确保电源稳定:在DS1621的VCC和GND引脚之间并联一个0.1µF陶瓷电容,并尽可能靠近芯片引脚焊接。 2. 检查软件解析代码是否正确,特别是正负数和0.5位的处理。 3. 用 i2c-tools的i2cget命令手动读取原始值验证:sudo i2cget -y 1 0x48 0xAA w,会返回两个字节的十六进制数(如0x1900表示25.0°C)。4. 将传感器与已知准确的温度计放在一起对比。 |
| 服务运行一段时间后崩溃或无响应 | 内存泄漏、未处理的异常、I2C总线锁死。 | 1. 查看日志文件(如果配置了)或系统日志:journalctl -u temperature-monitor.service --since "1 hour ago"。2. 使用 htop或free -h命令监控内存使用情况。3. 在代码中确保所有Promise都有 .catch()处理,防止未捕获的异常导致进程退出。4. 考虑在 TemperatureService的updateTemperature方法中添加“看门狗”机制,如果连续多次失败,尝试重新初始化I2C总线。 |
| 无法通过Wi-Fi访问网页 | 树莓派和客户端不在同一网络,或防火墙限制。 | 1. 确认树莓派已连接Wi-Fi:ifconfig wlan0查看IP地址。2. 确认客户端设备(手机/电脑)和树莓派连接的是同一个局域网(同一路由器下)。 3. 在树莓派上ping客户端IP,在客户端ping树莓派IP,检查双向连通性。 4. 检查树莓派防火墙规则。 |
6.3 性能监控与维护脚本
为了让服务更稳健,可以编写一些简单的维护脚本:
健康检查脚本health_check.sh:
#!/bin/bash # 检查服务是否在运行 if ! pgrep -f "temperature_service.js" > /dev/null; then echo "温度监控服务未运行,正在重启..." cd /home/pi/temperature-monitor /usr/bin/node temperature_service.js >> /var/log/temp_monitor.log 2>&1 & fi # 检查API是否响应 API_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/temperature || echo "000") if [ "$API_RESPONSE" != "200" ]; then echo "API无响应 (HTTP $API_RESPONSE),重启服务..." pkill -f "temperature_service.js" sleep 2 cd /home/pi/temperature-monitor /usr/bin/node temperature_service.js >> /var/log/temp_monitor.log 2>&1 & fi然后通过cron定时任务每5分钟执行一次这个脚本:
crontab -e # 添加一行: */5 * * * * /home/pi/temperature-monitor/health_check.sh日志轮转配置:防止日志文件无限增大。可以编辑/etc/logrotate.d/temperature-monitor:
/home/pi/temperature-monitor/logs/*.log { daily missingok rotate 7 compress delaycompress notifempty create 644 pi pi }这样日志会每天轮转一次,保留最近7天的压缩副本。
经过以上步骤,你就拥有了一个从硬件连接到软件部署、从基础功能到优化扩展、从开发调试到生产维护的完整项目。它不仅仅是一个简单的温度读取脚本,而是一个具备一定鲁棒性、可维护性和扩展性的物联网服务原型。你可以基于这个框架,轻松替换其他I2C传感器,或者添加更复杂的业务逻辑,构建出属于自己的智能硬件应用。
