概述
本章将结合前面几章的内容,在实现单片机设备和网页设备通讯的基础上加入数字孪生可视化交互的功能,即将“开门”这一动作作为本文的实验对象,通过舵机转动模拟实体门的开启与关闭,网页设备导入gld/gltf格式的3d模型并赋予其变化的能力,最后实现:(1)可以通过软件app控制舵机的转动并能够映射到网页设备,使得3d模型相对应的“门”实现开关的动作;(2)可以在网页设备上点击“门”部件从而实现开关得动作并相对应地控制舵机地转动;(3)可以在网页设备的测试框里输入相应的信息从而控制“门”的开关和舵机的转动。
上面几章的内容包括:
(二)stm32单片机连接阿里云生活物联网平台/物联网平台(附代码)_生活物联网平台和物联网平台-CSDN博客
五、设备与设备之间实现可视化交互
1.导入Three.js软件包
three.js是一个WebGL引擎,基于JavaScript,可直接运行GPU驱动游戏与图形驱动应用于浏览器。其库提供的特性与API以绘制3D场景于浏览器。可以用它创建各种三维场景,包括了摄像机、光影、材质等各种对象。
网上有关于three.js的教学资源较少,可以通过bilibili学习也可以依靠chatgpt一步步完善。B站可以看Three.js智慧城市Web3D可视化( 物联网 数字孪生)_哔哩哔哩_bilibili,csdn可以看Threejs入门-安装教程_安装three.js-CSDN博客,three.js的官网为:Three.js – JavaScript 3D Library,three.js的中文网为:Three.js中文网
本文用到的three.js库包括GLTFLoader(用于加载 GLTF/GLB 3D 模型文件。)、DRACOLoader(用于加载和解码 Draco 压缩的几何数据,通常与 GLTFLoader
结合使用。)
// 引入Three.js
import * as THREE from 'three';
// 引入glTF模型加载库GLTFLoader.js
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from '../public/three/examples/jsm/loaders/DRACOLoader.js';
2.导入gltf/glb格式的3D模型
本文将导入gltf格式的3d模型,建模软件使用到了blender,参考安装教程:blender的下载安装和配置中文环境_blender下载安装-CSDN博客 。也可以在爱给网上找到现成的模型,最终导出gltf格式的文件即可,本文将用到一栋房子的3d模型作为呈现对象,点击“门”模块并将其名字改为“door”,后面将会用到。
首先加载3D模型,然后遍历场景中的所有子对象以找到“门”模块,由于我想让门能够沿着它的右边沿旋转,因此将其中心点更改到相应的一边,最后将加载的场景添加到模型中。
let doorObject = null;
let doorPivot = null; // 全局变量
loader.load(path, function (gltf) {
gltf.scene.traverse((child) => {
if (child.name === 'door') {
doorObject = child;
}
});
if (doorObject) {
// 创建 pivot 对象并设置为门的旋转中心
doorPivot = new THREE.Object3D();
model.add(doorPivot);
doorPivot.add(doorObject);
const doorBox = new THREE.Box3().setFromObject(doorObject);
const doorSize = doorBox.getSize(new THREE.Vector3());
const pivotPosition = new THREE.Vector3();
doorBox.getCenter(pivotPosition);
pivotPosition.x += doorSize.x / 2;
doorPivot.position.copy(pivotPosition);
doorObject.position.sub(pivotPosition);
}
model.add(gltf.scene);//加载的场景添加到模型中
});
3.控制“门”的转动
“门”的转动作为本文的实验对象,实现的效果是沿着它的右边沿向外旋转90度。
//控制门转动
function rotateDoor(isOpen) {
if (!doorObject || !doorPivot) {
console.error('doorObject 或 doorPivot 未加载完成');
return;
}
const targetRotation = isOpen ? Math.PI / 2 : 0;
// 执行旋转动画
new TWEEN.Tween(doorPivot.rotation)
.to({ y: targetRotation }, 1000)
.easing(TWEEN.Easing.Quadratic.InOut)
.onComplete(() => {
door_isopen = isOpen === 1;
})
.start();
}
4.单片机控制舵机转动并映射到网页设备
首先通过云智能APP或者物联网平台给单片机发送转动舵机的指令,舵机转动之后单片机会回传一条相同的信息并通过云产品流转功能映射到网页设备。
单片接收到的信息是:
MQTTSUBRECV:0,"/sys/a1IJcdV6AvN/test/thing/service/property/set",100,{"method":"thing.service.property.set","id":"636319551","params":{"DoorSwitch":0},"version":"1.0.0"}
我们需要提取的信息是"params":{"DoorSwitch":0},首先导入json.c文件和对应头文件,下载地址为https://github.com/DaveGamble/cJSON,提取其中的json信息:
{"method":"thing.service.property.set","id":"636319551","params":{"DoorSwitch":0},"version":"1.0.0"}
接着解析json字符串,最后得到"DoorSwitch"中的值并控制舵机和实现信息的云产品流转。
void Get_ESP82666_Cmd(char *cmd) {
char *json_start, *json_end;
char json_str[1024]; // 假设 JSON 字符串不会超过 1024 字节
cJSON *json;
cJSON *items, *params;
cJSON *doorSwitch;
cJSON *value;
int doorSwitchValue;
int json_length;
// 提取 JSON 字符串,假设 JSON 字符串以 '{' 开始并以 '}' 结束
json_start = strchr(cmd, '{');
json_end = strrchr(cmd, '}');
if (json_start == NULL || json_end == NULL || json_start > json_end) {
printf("JSON start or end not found\n");
return;
}
// 复制 JSON 部分到 json_str
json_length = json_end - json_start + 1;
if (json_length >= sizeof(json_str)) {
printf("JSON string too long\n");
return;
}
strncpy(json_str, json_start, json_length);
json_str[json_length] = '\0'; // 确保字符串以 '\0' 结尾
// 再次打印以确保 JSON 部分已正确复制
printf("Extracted JSON: %s\n", json_str);
json = cJSON_Parse(json_str);
if (json == NULL) {
const char *error_ptr = cJSON_GetErrorPtr();
if (error_ptr != NULL) {
printf("JSON parsing error before: %s\n", error_ptr);
} else {
printf("JSON parsing error\n");
}
return;
}
if (cJSON_HasObjectItem(json, "method") && cJSON_HasObjectItem(json, "params")) {
// 处理第二种格式 {"method":"thing.service.property.set","id":"1039896517","params":{"DoorSwitch":0},"version":"1.0.0"}
params = cJSON_GetObjectItem(json, "params");
if (params == NULL) {
printf("No 'params' object in JSON\n");
cJSON_Delete(json);
return;
}
doorSwitch = cJSON_GetObjectItem(params, "DoorSwitch");
if (doorSwitch == NULL || !cJSON_IsNumber(doorSwitch)) {
printf("No 'DoorSwitch' object in params or it is not a number\n");
cJSON_Delete(json);
return;
}
doorSwitchValue = doorSwitch->valueint;
printf("doorswitch: %d\n", doorSwitchValue);
if (doorSwitchValue == 1) {
if(connect_flag==1){//判断单片机连上wifi的标志位
Servo_Control(180); // 模拟开门
MQTT_PUB(aliyun_device_topic_update, open, 23.5, "true"); // 发布消息给网页设备,温度后续添加了dht11模块后可以更改
}
} else if (doorSwitchValue == 0) {
if(connect_flag==1){//判断单片机连上wifi的标志位
Servo_Control(90); // 模拟关门
MQTT_PUB(aliyun_device_topic_update, close, 23.5, "false"); // 发布消息给网页设备,温度后续添加了dht11模块后可以更改
}
}
}
cJSON_Delete(json);
}
网页设备接收到的消息为:
收到来自 /sys/a1VxoApLdIE/client/thing/service/property/set 的消息 {"deviceType":"switch","iotId":"j3F9yb0nr9DPxJIsfAG4000000","requestId":"99119635","checkFailedData":{"mess":{"code":5092,"time":1719209159020,"message":"property not found","value":"false"}},"productKey":"a1IJcdV6AvN","gmtCreate":1719209159026,"deviceName":"test","items":{"temp":{"time":1719209159020,"value":23.5},"powerstate":{"time":1719209159020,"value":0}}}
提取其中的"powerstate":{"time":1719209159020,"value":0},判断powerstate的value值从而控制“门”的开关。
client.value.on('message', (topic, message) => {
console.log('收到来自', topic, '的消息', message.toString());
const messageobject = JSON.parse(message.toString());
const items = messageobject.items;
if (items) {
paramsData.value = items; // 把 items 赋值给 paramsData
recvData.value = message.toString();
console.log('提取到的params数据', paramsData);
if (paramsData.value.powerstate && paramsData.value.powerstate.value === 1) {
rotateDoor(1);
} else if (paramsData.value.powerstate && paramsData.value.powerstate.value === 0) {
rotateDoor(0);
}
} else {
console.error('消息中没有 items 属性');
}
});
5.网页设备控制舵机转动
5.1.通过点击“门”模块控制舵机
首先在js中设置点击事件,当点击“门”模块时发送相对应的信息并控制“门”的开关。
function onMouseClick(event) {
const mouse = new THREE.Vector2();
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
const selected = intersects[0].object;
console.log("Clicked object:", selected.name); // 确认对象名称
if (selected.isMesh && selected.name === 'door') {
console.log('selected');
if (door_isopen) {
publishDoor(0);
rotateDoor(0);
} else {
publishDoor(1);
rotateDoor(1);
}
}
}
}
window.addEventListener('click', onMouseClick, false);
单片机串口助手收到的信息为:
MQTTSUBRECV:0,"/sys/a1IJcdV6AvN/test/thing/service/property/set",240,{"deviceType":"sensor","iotId":"BZJqpWzUTpFEMeoEKN8O000000","requestId":"1719211929235","checkFailedData":{},"productKey":"a1VxoApLdIE","gmtCreate":1719212056835,"deviceName":"client","items":{"DoorSwitch":{"time":1719212056828,"value":1}}}
我们先提取json信息:
{"deviceType":"sensor","iotId":"BZJqpWzUTpFEMeoEKN8O000000","requestId":"1719211929235","checkFailedData":{},"productKey":"a1VxoApLdIE","gmtCreate":1719212056835,"deviceName":"client","items":{"DoorSwitch":{"time":1719212056828,"value":1}}},然后解析json信息,最后得到"DoorSwitch"的value值。
由于来自app的json信息和来自云产品流转的json信息格式不同,所以提取方式与第4小节有些许差异。
void Get_ESP82666_Cmd(char *cmd) {
char *json_start, *json_end;
char json_str[1024]; // 假设 JSON 字符串不会超过 1024 字节
cJSON *json;
cJSON *items, *params;
cJSON *doorSwitch;
cJSON *value;
int doorSwitchValue;
int json_length;
// 提取 JSON 字符串,假设 JSON 字符串以 '{' 开始并以 '}' 结束
json_start = strchr(cmd, '{');
json_end = strrchr(cmd, '}');
if (json_start == NULL || json_end == NULL || json_start > json_end) {
printf("JSON start or end not found\n");
return;
}
// 复制 JSON 部分到 json_str
json_length = json_end - json_start + 1;
if (json_length >= sizeof(json_str)) {
printf("JSON string too long\n");
return;
}
strncpy(json_str, json_start, json_length);
json_str[json_length] = '\0'; // 确保字符串以 '\0' 结尾
// 再次打印以确保 JSON 部分已正确复制
printf("Extracted JSON: %s\n", json_str);
json = cJSON_Parse(json_str);
if (json == NULL) {
const char *error_ptr = cJSON_GetErrorPtr();
if (error_ptr != NULL) {
printf("JSON parsing error before: %s\n", error_ptr);
} else {
printf("JSON parsing error\n");
}
return;
}
if (cJSON_HasObjectItem(json, "items")){
// 处理第一种格式
items = cJSON_GetObjectItem(json, "items");
if (items == NULL) {
printf("No 'items' object in JSON\n");
cJSON_Delete(json);
return;
}
doorSwitch = cJSON_GetObjectItem(items, "DoorSwitch");
if (doorSwitch == NULL) {
printf("No 'DoorSwitch' object in items\n");
cJSON_Delete(json);
return;
}
value = cJSON_GetObjectItem(doorSwitch, "value");
if (value == NULL || !cJSON_IsNumber(value)) {
printf("No 'value' object in DoorSwitch or it is not a number\n");
cJSON_Delete(json);
return;
}
doorSwitchValue = value->valueint;
printf("doorswitch: %d\n", doorSwitchValue);
if (doorSwitchValue == 1) {
Servo_Control(180); // 模拟开门
} else if (doorSwitchValue == 0) {
Servo_Control(90); // 模拟关门
}
}
cJSON_Delete(json);
}
5.2.通过信息框发布消息控制舵机
在html设置点击按钮
<div class="container">
<button @click="doPublish" class="publish-btn">发布消息</button>
</div>
点击事件之后将输入框的内容一起发送给物联网平台和单片机设备。
const doPublish = () => {
const { topic, qos } = publication;
payloadJson.params.mess = publishMessage.value;
payloadJson.params.DoorSwitch = publishdoorswitch.value;
payloadJson.params.temp = publishtemp.value;
const payload = JSON.stringify(payloadJson);
rotateDoor(payloadJson.params.DoorSwitch);//发送消息之后根据DoorSwitch的值控制门的开关
client.value.publish(topic, payload, qos, (error) => {
if (error) {
console.log('发布失败', error);
} else {
console.log('发布成功', payload);
}
// 清除输入内容
publishMessage.value = '';
publishdoorswitch.value = null;
publishtemp.value = null;
});
};
最后单片机获取消息并提取标志信息后便控制舵机的转动,过程与5.1小节所描述的一致。
6.遇到的困难及其解决方案
6.1.导入的模型不完整
网页设备导入模型之后,发现原本在blender中对称的部分没办法显示出来,目前还没清楚具体的原因。
6.2.单片机无法处理来自网页设备的json信息
由于来自网页设备的json信息字节较多,使用 cJSON_Parse()
之后解析失败。
后面发现是因为 cJSON 解析需要用到的内存比较大,溢出导致解析失败。适当扩大堆和栈的空间即可。