Author:teacherXue
一、远程控制
指令获得的方式
上一章节中我们实现了向MQTT服务器发送封装好的JSON数据。这个操作可以让应用端在任何地方通过互联网得以获得和显示数据。那么我们又该如何传输控制指令给MCU?MCU又该怎样接收指令呢?
还是拿之前的图来说话:
MCU发送数据——MQTT数据地址/(芯片ID)——应用端订阅
应用端发送指令——MQTT指令地址/(芯片ID)——MCU订阅指令
MCU发送数据时会带上自己的ID进行唯一区分,应用端订阅接收到数据后就会获得该MCU的ID,在订阅地址上都使用了芯片ID作为最后的区分。所以可以做到互相间的隔离,如果再对用户和地址绑定权限则可以进一步保证安全性,当然这些都是MQTT服务端设置,本例中同学们不用过于关注。
指令的处理
既然指令也来自于消息订阅,那么让MCU去MQTT服务器匹配自己的订阅地址订阅消息就好了,根据业务需求设置合理的订阅频率,频率不要过高,不要给服务器施加太大压力(主要我的服务器没有做集群和负载均衡,扛不住啊)。
MCU订阅地址有消息的时候,根据MQTT协议就可以触发callback回调函数,我们将指令处理步骤就写在回调函数里(应用端开发其实是一样的,收到数据处理后刷新显示)。
考虑实际场景
根据工程认证对本科毕业生的12条标准,重点考察的是学生解决复杂工程问题的能力。则我们必须站在用户的角度,保证可交付性,并从社会、环境、持续发展等方面来考虑项目。或许不完全,但必须要考虑。
说这么多,主要是多数的学生至少在第一次编码实现时在控灯方面直接保留了上一章节的灯控代码。那么会有什么问题呢?——思考10秒钟。
现在灯光可以远程控制了,但还有MCU端的物理控制。LED1按钮控制的灯光好办,因为使用的是非保持性开关。我们通过修改全局变量即可。但LED2使用了旋钮调光,远程调光后就会被物理旋钮值覆盖二失效。
怎么办?——再思考十秒钟。
先不用考虑代码,想一想可能的场景,怎样的操作可以满足用户的使用需求。代码可以后面想。我这里采取的场景模式是,远程调光就会生效,物理旋钮如果在0.1秒内有超过10单位的读数变化,则读取旋钮值,并放弃远程控制的设定。这样听起来是不是就合理了。
二、项目实现
现在我们来实现,通过在局域网内访问mcu芯片,通过网页对LED灯的开关进行控制,如下图。顺带也实现了温湿度的显示,看起来还是蛮实用的。
创建项目
1)新建项目Lot_mqtt_json_test_v3.0
2)修改串口波特率115200
3)扩展库如果和项目Lot_mqtt_json_test_v2.0相同的化,可以直接修改platformio.ini文件,追加lib_deps(下表没有包含WiFiManager)。
[env:esp12e]
platform = espressif8266
board = esp12e
framework = arduino
monitor_speed = 115200
lib_deps =
jchristensen/JC_Button@^2.1.2
bblanchon/ArduinoJson@^6.20.1
adafruit/DHT sensor library@^1.4.4
arkhipenko/TaskScheduler@^3.7.0
arduino-libraries/NTPClient@^3.2.1
knolleary/PubSubClient@^2.8
4)代码可以先全部复制十三章的内容,在此基础上进行完善。
接收控制指令
1)保持连接,因为有指令要接收,触发关灯操作等个5秒才关显然不合适。把保持MQTT连接移到LOOP中去,并将其从原来的发送消息的方法中去掉。
//保持mqtt连接
if (!client.connected())
{
reconnect();
}
client.loop();
2)创建订阅方法,主要是为了便于多任务运行。
//订阅指令
void subscribeMQTT(){
client.subscribe(inTopic);
}
3)增加T4的订阅指令的任务定义。
Task t4(100, TASK_FOREVER, &subscribeMQTT); // 任务名称t3,间隔5秒,一直执行。
4)并将其加到setup的任务链中。
// 初始化任务调度器,规划任务链
runner.init();
runner.addTask(t1);
runner.addTask(t2);
runner.addTask(t3);
runner.addTask(t4);
t1.enable();
t2.enable();
t3.enable();
t4.enable();
完善回调函数
callback回调函数上一章节写了个头,并在setup中进行了设定,其中 *payload参数就是收到的订阅消息,因为是回调函数,所以能触发一定有消息。以下代码是将收到的消息打印输出。
Serial.print("Message arrived [");
Serial.print(topic);
Serial.print("] ");
payload[length] = '\0'; // 追加字符串结束字符
for (int i = 0; i < length; i++)
{
Serial.print((char)payload[i]);
}
Serial.print("\n");
Serial.println();
1)指令格式
控制指令协议中我们仍然以JSON格式为规范,指令中包含要操控的设备列表,每个设备包含其MCU端的识别编号。每个设备包含控制项集合,包括控制项和值本身。控制项参考13章内的命名规范。各个设定项执行前先判断是否有该项目传入。
协议规范 |
{ //芯片ID "chip_id":value, //发送时间戳 "timestamp":value, //指令列表 "task_list":[ //指令1 { //要控制的外设编号,和MCU端外设匹配 "number":value, //控制项和控制值,处理时要先判断是否有值,再进行处理 "控制项1": value }, //指令2 { //要控制的外设编号,和MCU端外设匹配 "number": value, //控制项和控制值,处理时要先判断是否有值,再进行处理 "控制项1": value, //可以有多个控制项,控制项参考传感器数据定义协议 "控制项2": value } ]} |
案例 |
{ "chip_id": "xm_00749E03", "timestamp": 1677856857, "task_list": [{ "number": 1, "state": true }, { "number": 2, "state": true, "extent": 30 } ] } |
|
2)加载指令
因指令是JSON字符串,先将其反序列化为内存对象JSON_Buffer。
StaticJsonDocument<1024> JSON_Buffer;
DeserializationError error = deserializeJson(JSON_Buffer, payload);
3)解析指令
我们可以直接通过JSON_Buffer[“节点名称”]的方式直接得到值。JsonObject可以得到节点对象,如果值是集合则使用JsonArray接收,然后对其遍历。
if (error)
{
Serial.print(F("deserializeJson() failed: "));
Serial.println(error.c_str());
JSON_Buffer.clear();
return;
}
else
{
const char *id = JSON_Buffer["chip_id"]; // 获得芯片ID值
Serial.println("================Start===========================");
Serial.print(id);
//比较指令中的芯片ID和本芯片是否一致
if (strcmp(chipId, id) > 0)
{
return;
}
//一致则进行下一步的指令解析
Serial.println("is me ===================");
//获得指令集合
JsonArray ls = JSON_Buffer["task_list"];
Serial.print("ls:---");
Serial.println(ls.size());
// 遍历收到的指令列表
for (u8 i = 0; i < ls.size(); i++)
{
JsonObject sensor = ls[i];
u8 n = sensor["number"];
//根据设备编号进行匹配
switch (n)
{
//设备1为不可调光灯光
case 1:
// 该指令项未必被传递,处理前判断下,有没有该控制项的数据
if (String(sensor["state"] != "null"))
{
// 将开关状态存于全局控制项
led1state = sensor["state"];
Serial.println(led1state);
}
break;
//设备2为可调光灯光
case 2:
// 该指令项未必被传递,处理前判断下,有没有该控制项的数据
if (String(sensor["state"] != "null"))
{
// 将开关状态存于全局控制项
led2state = sensor["state"];
Serial.println(led2state);
}
if (String(sensor["extent"] != "null"))
{
//改变全局亮度值
dutyCycle = sensor["extent"];
appCtrl=true;//改变远程控制状态记录
Serial.printf("LED2_extent:%2d\n", dutyCycle);
}
break;
default:
break;
}
}
}
物理和远程控制共存
1)前面已经说了思路,因此我们需要记录是否进行过远程控制,以及判定在指定时间段内旋钮是否发生过改变,需要声明如下变量。
// 旋钮值和灯光亮度的保存
unsigned int knobValue = 0;//旋钮即时值
unsigned int knobOldValue = 0;//上一次旋钮读数
unsigned long knobOldTime = millis();//上一次旋钮读数登记时间
unsigned long knobCurrentTime = 0;//当前旋钮读数登记时间
bool appCtrl = false;//是否进行了远程控制
unsigned int dutyCycle = 0;
2)接收控制指令时,如果有亮度值控制,在改变了全局亮度值后,又改写了appCtrl状态。因此读取旋钮值在没有发生变化时保持远程控制的亮度值。但如果在一定时间内旋钮值发生了改变,说明其被触动了,这时读取旋钮值,并设置远程控制状态为否。直到下一次包含亮度的远程控制指令。
#include <Arduino.h>
#include <JC_Button.h>
#include <WiFiManager.h>
// 导入多任务库
#include <TaskScheduler.h>
#include <Adafruit_Sensor.h>
#include <DHT.h>
// 导入NTP网络时间服务扩展库
#include <NTPClient.h>
// 导入UDP扩展库,
#include <WiFiUdp.h>
// json对象处理类,6.x 和5.x不一样 ,这里用的是6.19
#include <ArduinoJson.h>
#include <PubSubClient.h> // mqtt消息订阅库
#define LED_1 D5
#define LED_2 D6
#define BUTTON_PIN D4
#define DHTPIN D3 // DHT温湿度传感器引脚
#define analogInPin A0 // 模拟输入引脚A0
#define DHTTYPE DHT22 // 声明DHT传感器类型
DHT dht(DHTPIN, DHTTYPE); // 创建DHT对象
Scheduler runner; // 任务调度器对象
Button myBtn(BUTTON_PIN); // 按钮对象
// 温湿度值全局保存
float t = 0.0;
float h = 0.0;
// led灯光状态
bool led1state = false;
bool led2state = false;
// 旋钮值和灯光亮度的保存
unsigned int knobValue = 0;//旋钮即时值
unsigned int knobOldValue = 0;//上一次旋钮读数
unsigned long knobOldTime = millis();//上一次旋钮读数登记时间
unsigned long knobCurrentTime = 0;//当前旋钮读数登记时间
bool appCtrl = false;//是否进行了远程控制
unsigned int dutyCycle = 0;
// 存放芯片ID的缓冲
char chipId[10];
// 消息发送缓冲,json最后字符串方式传输
char messageBuff[1024];
// mqtt服务器连接信息
// xue1024.tpddns.cn
const char *mqttServer = "xue1024.tpddns.cn"; // 个人mqtt服务器
const int mqttPort = 1883; // mqtt端口号
const char *mqttUser = "shixun_admin"; // 用户名
const char *mqttPassword = "teacherxue"; // 密码
// long mqtt_interval = 2000;
// long mqtt_comiit = 0;
// 传感器数据订阅地址,mcu向里面发数据,客户端去里面取数据
char outTopic[50]; // 发送地址
// 控制指令订阅地址,mcu订阅,客户端发送
char inTopic[50]; // 订阅地址缓存
// mqtt连接对象
WiFiClient espClient;
PubSubClient client(espClient);
// 创建UDP对象
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, "ntp.aliyun.com");
// 前置函数声明
void getTH();
void getKnob();
void getJson();
void callback(char *topic, byte *payload, unsigned int length); // mqtt回调函数
void reconnect(); // mqtt保持连接
void publishMQTT(); // 发送mqtt数据
void subscribeMQTT(); //订阅指令
Task t1(2000, TASK_FOREVER, &getTH); // 任务名称t1,间隔2秒一直执行.
Task t2(30, TASK_FOREVER, &getKnob); // 任务名称t2,间隔30毫秒,一直执行。
Task t3(5000, TASK_FOREVER, &publishMQTT); // 任务名称t3,间隔5秒,一直执行。
Task t4(1000, TASK_FOREVER, &subscribeMQTT); // 任务名称t3,间隔0.1秒,一直执行。
// Task t3(3000, TASK_FOREVER, &getJson);
void setup()
{
Serial.begin(115200);
pinMode(LED_1, OUTPUT);
pinMode(LED_2, OUTPUT);
// led1为关灯
digitalWrite(LED_1, LOW);
// led2使用pwm调光
analogWrite(LED_2, 0);
dht.begin(); // DHT传感器对象工作
myBtn.begin(); // button按钮运作
WiFiManager wm; // wifi管理对象,配网用
bool res;
// 拼接芯片的hostname
sprintf(chipId, "xm_%08X", ESP.getChipId());
res = wm.autoConnect(chipId, "12345678"); // 密码认证模式的AP
if (!res)
{
Serial.println("Failed to connect");
// ESP.restart();
}
else
{
// if you get here you have connected to the WiFi
WiFi.setHostname(chipId); // 从模式后设置设备名
Serial.println("connected...yeey :)");
}
// 初始化NTP时间服务器连接
timeClient.begin();
// 设置时区偏差,中国地区偏差+8
// GMT +1 = 3600
// GMT +8 = 28800
// GMT -1 = -3600
// GMT 0 = 0
timeClient.setTimeOffset(28800);
// 初始化任务调度器,规划任务链
runner.init();
runner.addTask(t1);
runner.addTask(t2);
runner.addTask(t3);
runner.addTask(t4);
t1.enable();
t2.enable();
t3.enable();
t4.enable();
//-------------- 订阅地址(xm替换为组号)------------------------
sprintf(inTopic, "iss/lot/xust_19/mcu/order/xm/%s/", chipId);
sprintf(outTopic, "iss/lot/xust_19/mcu/data/xm/%s/", chipId);
Serial.printf("inTopic:%s\n", inTopic);
Serial.printf("outTopic:%s\n", outTopic);
// mqtt连接
client.setServer(mqttServer, mqttPort);
client.setBufferSize(2048);//设置mqtt消息传输包的大小
client.setSocketTimeout(60);//设置mqtt连接超时
client.setCallback(callback);
//-----------------------------------------------
}
void loop()
{
//保持mqtt连接
if (!client.connected())
{
reconnect();
}
client.loop();
runner.execute(); // 执行任务
// 处理按钮事件
myBtn.read();
if (myBtn.wasPressed())
{
led1state = !led1state;
}
digitalWrite(LED_1, led1state);
//led2关灯状态在不改变全局亮度值的情况下直接pwm输出0
if(!led2state){
analogWrite(LED_2, 0);
}else{
analogWrite(LED_2, map(dutyCycle,0,100,0,255)); // 根据状态控灯
}
}
// 获得温湿度
void getTH()
{
h = dht.readHumidity();
// Read temperature as Celsius (the default)
t = dht.readTemperature();
}
// 旋钮操作
void getKnob()
{
//关灯时不做任何调整
if (!led2state)
{
return;
}
knobCurrentTime=millis();//旋钮当前时间
knobValue = analogRead(analogInPin); //读取旋钮值
//每隔0.1秒判断一下旋钮值是否改变
if(knobCurrentTime-knobOldTime>=100){
//登记旋钮值
knobOldValue=knobValue;
//登记时间
knobOldTime=knobCurrentTime;
}
//如果远程控制记录为真,且旋钮值指定时间内变化超过10
//改变远程控制状态weifalse
if(appCtrl && abs(long(knobOldValue-knobValue))>10){
appCtrl = false;
}
//如果远程控制状态为false才将旋钮值映射为亮度值
if(!appCtrl) {
//幅度类控制范围均0~100
//执行时进行实际映射
dutyCycle = map(knobValue, 15, 1008, 0, 100);
}
}
// 获得json封装的数据
void getJson()
{
//------------封装json对象传递----------------------------
/* 申明一个大小为1K的DynamicJsonDocument对象JSON_Buffer,
用于存储反序列化后的(即由字符串转换成JSON格式的)JSON报文,
*/
// DynamicJsonDocument doc(2048);
// 创建json对象
StaticJsonDocument<1024> doc;
// 创建json根节点对象
JsonObject root = doc.to<JsonObject>();
// root节点下创建子节点并赋值
root["protocol"] = "1.0";
root["chip_id"] = chipId;
root["chip_type"] = "ESP8266-12E";
root["product_line"] = "xust_19_teacher";
root["private_ip"] = WiFi.localIP();
root["public_ip"] = "";
timeClient.update();
root["timestamp"] = timeClient.getEpochTime();
// 创建json对象集合,存放该mcu节点下的所有传感器列表
JsonArray sensors = root.createNestedArray("sensor_list");
// 集合节点,创建子节点对象
// 1.不可调光主灯
JsonObject s1 = sensors.createNestedObject();
s1["number"] = 1;
s1["type"] = "amp_no";
s1["name"] = "厨房灯";
JsonObject s1_data = s1.createNestedObject("data");
JsonArray s1_data_ls = s1_data.createNestedArray("data_vals");
JsonObject s1_data1 = s1_data_ls.createNestedObject();
s1_data1["data_name"] = "state";
s1_data1["data_val"] = led1state;
// 2.可调光主灯
JsonObject s2 = sensors.createNestedObject();
s2["number"] = 2;
s2["type"] = "lamp_ctl";
s2["name"] = "主卧灯";
JsonObject s2_data = s2.createNestedObject("data");
JsonArray s2_data_ls = s2_data.createNestedArray("data_vals");
JsonObject s2_data1 = s2_data_ls.createNestedObject();
s2_data1["data_name"] = "state";
s2_data1["data_val"] = led2state;
JsonObject s2_data2 = s2_data_ls.createNestedObject();
s2_data2["data_name"] = "extent";
s2_data2["data_val"] = dutyCycle;
// 3.温湿度传感器
JsonObject s3 = sensors.createNestedObject();
s3["number"] = 3;
s3["type"] = "th";
s3["name"] = "温湿度";
JsonObject s3_data = s3.createNestedObject("data");
JsonArray s3_data_ls = s3_data.createNestedArray("data_vals");
JsonObject s3_data1 = s3_data_ls.createNestedObject();
s3_data1["data_name"] = "t_val";
s3_data1["data_val"] = t;
JsonObject s3_data2 = s3_data_ls.createNestedObject();
s3_data2["data_name"] = "h_val";
s3_data2["data_val"] = h;
// 序列化json数据,就是将json对象转为字符串放在messageBuff
serializeJson(doc, messageBuff);
}
// mqtt保持连接方法
void reconnect()
{
// Loop until we're reconnected
while (!client.connected())
{
Serial.print("Attempting MQTT connection...");
// Attempt to connect
// chipId 在这里是客户端连接的sessionid,同账户名下,不能相同,这里用芯片,固定配置下写死即可uc_01_keting_01
String clientId = String(chipId)+"-"+String(random(0xffff), HEX);
if (client.connect(clientId.c_str(), mqttUser, mqttPassword))
{
Serial.println("connected...");
}
else
{
Serial.print("failed, rc=");
Serial.print(client.state());
Serial.println(" try again in 5 seconds");
// Wait 5 seconds before retrying
delay(5000);
}
}
}
// 发布mqtt消息
void publishMQTT()
{
getJson();
// 发送mqtt数据
client.publish(outTopic, messageBuff, true);
Serial.print("messageBuff:");
Serial.println(messageBuff);
//关闭连接
client.endPublish();
// 发送完成后清除缓存数据
memset(messageBuff, 0, 1024);
}
//订阅指令
void subscribeMQTT(){
client.subscribe(inTopic);
}
// // 获得时间
// void get_time()
// {
// timeClient.update();
// epochTime = timeClient.getEpochTime();
// formattedTime = timeClient.getFormattedTime();
// int currentHour = timeClient.getHours();
// int currentMinute = timeClient.getMinutes();
// int currentSecond = timeClient.getSeconds();
// weekDay = weekDays[timeClient.getDay()];
// struct tm *ptm = gmtime((time_t *)&epochTime);
// int monthDay = ptm->tm_mday;
// int currentMonth = ptm->tm_mon + 1;
// String currentMonthName = months[currentMonth - 1];
// int currentYear = ptm->tm_year + 1900;
// currentDate = String(currentYear) + "-" + String(currentMonth) + "-" + String(monthDay);
// }
// mqtt回调函数
void callback(char *topic, byte *payload, unsigned int length)
{
Serial.print("Message arrived [");
Serial.print(topic);
Serial.print("] ");
payload[length] = '\0'; // 追加字符串结束字符
for (int i = 0; i < length; i++)
{
Serial.print((char)payload[i]);
}
Serial.print("\n");
Serial.println();
StaticJsonDocument<1024> JSON_Buffer;
// DynamicJsonDocument root(2048);
DeserializationError error = deserializeJson(JSON_Buffer, payload);//解析得到JSON对象
//先判断解析结果
if (error)
{
Serial.print(F("deserializeJson() failed: "));
Serial.println(error.c_str());
JSON_Buffer.clear();
return;
}
else
{
const char *id = JSON_Buffer["chip_id"]; // 获得芯片ID值
Serial.println("================Start===========================");
Serial.println(id);
//比较指令中的芯片ID和本芯片是否一致
if (strcmp(chipId, id) > 0)
{
return;
}
//一致则进行下一步的指令解析
Serial.println("is me ===================");
//获得指令集合
JsonArray ls = JSON_Buffer["task_list"];
Serial.print("ls:---");
Serial.println(ls.size());
// 遍历收到的指令列表
for (u8 i = 0; i < ls.size(); i++)
{
JsonObject sensor = ls[i];
u8 n = sensor["number"];
//根据设备编号进行匹配
switch (n)
{
//设备1为不可调光灯光
case 1:
// 该指令项未必被传递,处理前判断下,有没有该控制项的数据
if (String(sensor["state"])!= "null")
{
// 将开关状态存于全局控制项
led1state = sensor["state"];
Serial.print("LED1_state");
Serial.println(led1state);
}
break;
//设备2为可调光灯光
case 2:
// 该指令项未必被传递,处理前判断下,有没有该控制项的数据
if (String(sensor["state"])!= "null")
{
// 将开关状态存于全局控制项
led2state = sensor["state"];
Serial.print("LED2_state:");
Serial.println(String(sensor["state"]));
}
if (String(sensor["extent"]) != "null")
{
//改变全局亮度值
if(sensor["extent"]>10){
dutyCycle = sensor["extent"];
appCtrl=true;//改变远程控制状态记录
}
Serial.printf("LED2_extent:%2d\n", dutyCycle);
}
break;
default:
break;
}
}
}
}
运行并验证
1)串口调试器打印了订阅和发送的地址,根据你的实际情况确定。
inTopic:iss/lot/xust_19/mcu/order/xm/xm_00749E03/ outTopic:iss/lot/xust_19/mcu/data/xm/xm_00749E03/ |
2)Mqttx订阅iss/lot/xust_19/mcu/data/xm/xm_00749E03/,可以看到每五秒一次的数据。如果订阅iss/lot/xust_19/mcu/data/xm/#地址则会收到本组所有的MCU信息。
3)刚开始所有灯光应该都为关闭状态,尝试物理按钮开关开启LED1,可以看到收到的数据厨房灯光为true。
4)通过mqttx发送指令消息,关闭LED1,打开LED2并将其亮度设置为30,然后观察串口控制台打印信息。可以看到mcu几乎瞬间就反馈出了解析结果。同时灯光也发生了相应变化,不管旋钮在什么位置,并维持亮度不变。
5)稍微转动下旋钮,会发现LED2会从之前的固定亮度变成和旋钮同步。
修改旋钮读取类。
操作过程复杂,就不拍摄动图了。至此我们就完美的实现了MCU通过MQTT传输数据和进行控制的接口实现,剩下的就是采用不同的应用客户端,解析数据以及生成指令的问题了。