2023年11月06日
使用 Node.js、React.js 和 Raspberry Pi 进行远程控制
简单而廉价的单板计算机(SBC)的出现是物联网世界的一个重要推动因素,为工业、家庭、医疗等领域的控制系统和设备的开发提供了可能性。现在,每个人都可以为自己的需求开发所需的东西,为公共项目的发展做出贡献,并使用他人开发的产品。
在本文中,我们将开发一个控制系统,用于管理基本的园艺活动,如浇水、照明等。为了使我们的应用程序更加灵活和可扩展,我们将其开发为一个分层的分布式系统,由松散耦合的组件通过标准(在我们的情况下是REST)协议进行通信。我们将使用广为人知的企业技术Node.js和React.js,以及树莓派Zero设备作为我们应用程序的传感器层。
我们的传感器层组件的主要功能是控制我们花园中执行主要活动的设备。假设我们需要控制浇水电路和照明电路,即打开/关闭水泵和室外灯。首先,我们连接所有硬件单元,然后开发必要的软件来使硬件运行起来。
硬件设置:继电器板连接,电路组装
对于我们的两个设备,我们可以使用Raspberry Pi Zero W SBC和SB Components Zero Relay继电器HAT(‘Hardware Attached on Top’)。每个HAT继电器都有NC(常闭)和NO(常开)接点。我们需要我们的浇水和照明电路仅在需要打开水泵和灯时才关闭(打开),因此我们将电路端连接到NO和COM接点,如图1.1所示。
软件设置:操作系统和库安装,控制软件开发
提供所有硬件组件连接后,我们可以添加软件使系统运行。首先,我们需要在树莓派设备上安装操作系统。有几种方法可以做到这一点;可能最舒适的方法是使用Raspberry Pi Imager。使用此应用程序,我们可以下载适当的操作系统并将其写入SD卡以启动SBC;我们可以从安装菜单中使用Raspberry Pi OS(32位)。
假设您的树莓派装备有适当的操作系统并且可以访问命令行,我们可以准备所有必要的软件。我们的组件有两个任务:暴露一个API以接受控制继电器的命令并将这些命令传递给继电器。让我们从实现API开始,这是大多数现代企业应用程序的常见任务。
控制API的实现
正如我们之前讨论的,我们将使用REST协议在我们的组件之间进行通信,因此我们需要为控制接口暴露REST端点。考虑到我们的树莓派Zero计算机的受限配置,我们应该尽可能轻量地实现API。对于这种情况,一个合适的技术是Node.js框架。本质上,它是一个JavaScript引擎,提供了在服务器端运行JavaScript代码的可能性。由于其设计,Node.js特别适用于构建Web应用程序,即处理互联网上的请求并返回处理后的数据和视图的应用程序。我们将在我们的Node.js应用程序中使用Express Web框架来简化请求处理。假设我们的系统中已经运行了Node.js,我们可以开始实现控制API。让我们创建一个Web控制器,它将是我们组件的主要控制单元。我们可以为每个设备继电器实现三个端点 - 打开、关闭和状态端点。为我们的应用程序创建一个Node包是个好主意,因此我们在树莓派根目录下创建一个新目录“smartgarden”,并在该目录中运行以下命令,以安装所有必要的依赖项并创建包描述符:
pi@raspberrypiZero:~/smartgarden $ npm install express --save
pi@raspberrypiZero:~/smartgarden $ npm install http --save
我们从以下基本脚本开始,并逐渐添加所有必要的功能。
清单2.1. smartgarden/outdoorController.js:组件Web API
const express = require("express"); const http = require("http"); var app = express(); app.use((req, res, next) => { res.append('Access-Control-Allow-Origin', ['*']); res.append('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE'); res.append('Access-Control-Allow-Headers', 'Content-Type'); next(); }); var server = new http.Server(app); app.get("/water/on", (req, res) => { console.log("Watering switched on"); }); app.get("/water/off", (req, res) => { console.log("Watering switched off"); }); app.get("/water/status", (req, res) => { console.log("TODO return the watering relay status"); }); app.get("/light/on", (req, res) => { console.log("Light switched on"); }); app.get("/light/off", (req, res) => { console.log("Light switched off"); }); app.get("/light/status", (req, res) => { console.log("TODO return the light relay status"); }); server.listen(3030, () => {console.log("Outdoor controller listens to port 3030")});
我们可以使用以下命令运行应用程序:
pi@raspberrypiZero:~ $ node smartgarden/outdoorController.js
终端中应该显示以下消息:
Outdoor controller listens to port 3030
当访问定义的端点时,我们应该看到相应的输出,例如,“Light switched off”对于/light/off端点。如果是这种情况,那就意味着我们的控制API正在工作!这很好,但实际上,它现在并没有做任何有用的工作。让我们通过添加内容来修复它,以便将命令传递给物理设备,即继电器。
将命令传递给物理设备
从JavaScript应用程序内部与物理设备通信有几种方法。在这里,我们将使用Johnny-Five JavaScript库。
Johnny-Five是JavaScript机器人和物联网平台,适用于许多平台,包括树莓派。它支持各种设备,如继电器、传感器、舵机等。
假设您的树莓派上已安装了Node和npm工具,您可以使用以下命令安装Johnny-Five库:
pi@raspberrypiZero:~/smartgarden $ npm install johnny-five --save
此外,我们需要安装raspi-io包。Raspi IO是Johnny-Five Node.js机器人平台的I/O插件,它使Johnny-Five能够控制树莓派上的硬件。
pi@raspberrypiZero:~/smartgarden $ npm install raspi-io --save
为了测试安装,我们可以运行以下脚本:
const Raspi = require('raspi-io').RaspiIO; const five = require('johnny-five'); const board = new five.Board({ io: new Raspi() }); board.on('ready', () => { // Create an Led on pin 7 (GPIO4) on P1 and strobe it on/off // Optionally set the speed; defaults to 100ms (new five.Led('P1-7')).strobe(); });
由于Raspbian中与GPIO交互的权限较为保守,您需要使用sudo来执行此脚本。如果我们的安装成功,我们应该看到LED以默认频率100毫秒闪烁。
现在,我们可以像清单2.2中所示那样为我们的控制器添加Johnny-Five支持。
清单2.2. 启用继电器控制的smartgarden/outdoorController.js
const express = require("express"); const http = require("http"); const five = require("johnny-five"); const { RaspiIO } = require('raspi-io'); var app = express(); app.use((req, res, next) => { res.append('Access-Control-Allow-Origin', ['*']); res.append('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE'); res.append('Access-Control-Allow-Headers', 'Content-Type'); next(); }); var server = new http.Server(app); const board = new five.Board({io: new RaspiIO(), repl: false}); board.on("ready", function() { var relay1 = new five.Relay({pin:"GPIO22",type: "NO"}); var relay2 = new five.Relay({pin:"GPIO5",type: "NO"}); app.get("/water/:action", (req, res) => { switch(req.params.action) { case 'on': relay1.close(); res.send(relay1.value.toString()); break; case 'off': relay1.open(); res.send(relay1.value.toString()); break; case 'status': res.send(relay1.value.toString()); break; default: console.log('Unknown command: ' + req.params.action); res.sendStatus(400); } }); app.get("/light/:action", (req, res) => { switch(req.params.action) { case 'on': relay2.close(); res.send(relay2.value.toString()); break; case 'off': relay2.open(); res.send(relay2.value.toString()); break; case 'status': res.send(relay2.value.toString()); break; default: console.log('Unknown command: ' + req.params.action); res.sendStatus(400); } }); server.listen(3030, () => { console.log('Outdoor controller listens to port 3030'); }); });
我们可以使用以下命令运行更新后的应用程序:
pi@raspberrypiZero:~ $ sudo node smartgarden/outdoorController.js
如果我们现在访问端点,我们应该看到相应的继电器打开和关闭。如果是这种情况,那就意味着我们的控制器组件正常工作!
干得好!但是通过在http客户端中键入REST请求发送命令并不是很方便。为此,提供一个GUI将是个不错的选择。
命令用户界面的开发
有各种选项可用于实现控制界面,我们将使用React.js来完成这项任务。React.js是一个轻量级的JavaScript框架,专注于创建基于组件的Web UI,这对于我们这种分布式组件应用程序的情况是一个正确的选择。为了充分利用React,我们可以安装create-react-app工具:
npm install -g create-react-app
然后,我们可以为我们的前端内容创建一个JavaScript应用程序。假设我们在项目根目录中,我们可以运行以下命令:
.../project-root>create-react-app smartgarden
这个命令将创建一个新的文件夹('smartgarden'),其中包含一个准备就绪的原型React应用程序。现在,我们可以进入该目录并运行以下命令:
.../project-root>cd smartgarden
.../project-root/smartgarden>npm start
这将在http://localhost:3000上的新浏览器中启动应用程序。这是一个简单但完全功能的前端应用程序,我们可以将其用作创建我们的UI的原型。React支持组件层次结构,其中每个组件都可以有一个状态,并且状态可以在相关组件之间共享。此外,可以通过向组件传递属性来自定义每个组件的行为。因此,我们可以开发主组件,它作为显示屏或表单的占位符,用于执行相应操作。此外,为了加快开发速度并使我们的UI看起来更加常用和用户友好,我们将使用MUI组件库,这是最受欢迎的React组件库之一。我们可以使用以下命令安装该库: npm install @mui/material @emotion/react @emotion/styled
为了将所有配置设置放在一个地方,我们创建了一个配置类,并将其放在特定的配置目录中:
**清单 2.3. configuration.js:包含所有应用程序设置的配置类。**
```javascript
class Configuration {
WATERING_ON_PATH = "/water/on";
WATERING_OFF_PATH = "/water/off";
WATERING_STATUS_PATH = "/water/status";
LIGHT_ON_PATH = "/light/on";
LIGHT_OFF_PATH = "/light/off";
LIGHT_STATUS_PATH = "/light/status";
CONTROLLER_URL = process.env.REACT_APP_CONTROLLER_URL ? process.env.REACT_APP_CONTROLLER_URL :
window.CONTROLLER_URL ? window.CONTROLLER_URL : "http://localhost:3030";
}
export default Configuration;
配置类包含控制器服务器的URL和控制端点的路径。对于控制器URL,我们提供了两种外部配置的可能性和一个默认值,在我们的情况下是http://localhost:3030。您可以将其替换为您的控制器服务器的相应URL。
将所有相关功能放在一个地方是个好主意。将我们的功能放在一个服务后面,该服务公开了某些API,可以确保我们的应用程序更灵活和可测试。因此,我们创建了一个控制服务类,该类实现了与控制器服务器进行数据交换的所有基本操作,并将这些操作公开为所有React组件的方法。为了使我们的UI更具响应性,我们将方法实现为异步的。只要API不变,我们就可以自由更改实现,而不会影响任何消费者。我们的服务可以如下所示。
清单 2.4. services/ControlService.js – 用于与传感器层通信的API。
import Configuration from './configuration'; class ControlService { constructor() { this.config = new Configuration(); } async switchWatering(switchOn) { console.log("ControlService.switchWatering():"); let actionUrl = this.config.CONTROLLER_URL + (switchOn ? this.config.WATERING_ON_PATH : this.config.WATERING_OFF_PATH); return fetch(actionUrl ,{ method: "GET", mode: "cors" }) .then(response => { if (!response.ok) { this.handleResponseError(response); } return response.text(); }).then(result => { return result; }).catch(error => { this.handleError(error); }); } async switchLight(switchOn) { console.log("ControlService.switchLight():"); let actionUrl = this.config.CONTROLLER_URL + (switchOn ? this.config.LIGHT_ON_PATH : this.config.LIGHT_OFF_PATH); return fetch(actionUrl ,{ method: "GET", mode: "cors" }) .then(response => { if (!response.ok) { this.handleResponseError(response); } return response.text(); }).then(result => { return result; }).catch(error => { this.handleError(error); }); } handleResponseError(response) { throw new Error("HTTP error, status = " + response.status); } handleError(error) { console.log(error.message); } } export default ControlService;
为了封装控制功能,我们创建了控制面板组件,如清单 2.5 所示。为了保持代码结构化,我们将组件放入“components”文件夹中。
清单 2.5. components/ControlPanel.js:包含用于控制花园设备的UI元素的React组件。
import React, { useEffect, useState } from 'react'; import Switch from '@mui/material/Switch'; import ControlService from '../ControlService'; import Configuration from '../configuration'; function ControlPanel() { const controlService = new ControlService(); const [checked1, setChecked1] = useState(false); const [checked2, setChecked2] = useState(false); useEffect(() => { const config = new Configuration(); const fetchData = async () => { try { let response = await fetch(config.CONTROLLER_URL + config.WATERING_STATUS_PATH); const isWateringActive = await response.text(); setChecked1(isWateringActive === "1" ? true : false); response = await fetch(config.CONTROLLER_URL + config.LIGHT_STATUS_PATH); const isLightActive = await response.text(); setChecked2(isLightActive === "1" ? true : false); } catch (error) { console.log("error", error); } }; fetchData(); }, []); const handleWatering = (event) => { controlService.switchWatering(event.target.checked).then(result => { setChecked1(result === "1" ? true : false); }); }; const handleLight = (event) => { controlService.switchLight(event.target.checked).then(result => { setChecked2(result === "1" ? true : false); }); }; return( <React.Fragment> <div> <label htmlFor='device1'> <span>Watering</span> <Switch id="1" name="device1" checked={checked1} onChange={handleWatering} /> </label> <label htmlFor='device2'> <span>Light</span> <Switch id="2" name="device2" checked={checked2} onChange={handleLight}/> </label> </div> </React.Fragment> ); } export default ControlPanel;
使用create-react-app工具生成的内容,我们可以将app.js的内容更改如下。
清单 2.6. 基本UI组件
import './App.css'; import React from 'react'; import AppBar from '@mui/material/AppBar'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import ControlPanel from './components/ControlPanel'; import { createTheme, ThemeProvider } from '@mui/material/styles'; function App() { const theme = createTheme( { palette: { primary: { main: '#1b5e20', }, secondary: { main: '#689f38', }, }, }); return ( <div className="App"> <ThemeProvider theme={theme}> <AppBar position="static"> <Toolbar> <Typography variant="h5">Smart Garden</Typography> </Toolbar> </AppBar> <ControlPanel/> </ThemeProvider> </div> ); } export default App;
现在,是时候测试我们的UI应用程序了。可能需要将CONTROLLER_URL配置参数设置为Raspberry Pi设备的IP地址,该设备运行着室外控制器后端应用程序,类似于“http://192.168.nn.nn:3030”。启动应用程序后,它将在新的浏览器选项卡中打开。
如果我们的室外控制器应用程序运行在连接到控制电路的Raspberry Pi设备上(参见图 1.1),现在我们可以打开和关闭浇水和照明电路。我们应该看到类似于图 2.1 所示的屏幕。
如果是这种情况,那么你成功了!恭喜你!现在,您拥有一个可用于各种设备和设备的远程控制系统。此外,我们可以将传感器层控制器包集成到任何系统中,并从UI和业务逻辑组件与其REST端点进行通信。
本文的源代码可在*GitHub*上找到。