服务器网页版上位机设计 03 上位机 (完结)
本设计主要涉及三个方面: 服务器,网页版,上位机.
书接上回,介绍完网页页面的设计,现在来说说上位机的功能设计.
也就是
js
文件的内容编写.
1.获取html对象
-
在网页设计时,设置了4个按钮,现在为它们添加回调函数.
-
首先需要在
js
中获取html的元素.有多种方法,我选择类似css
类选择器的方法,这是为了和我的css
筛选方式相同.
参考: << 【JavaScript】 JS中获取HTML元素值的三种方法_L5Butterfly的博客-CSDN博客_js 获取元素值 >>
- 在
1.js
文件中添加如下代码:
// 通过选择器获取一个元素
const f1 = document.querySelector('div.d11 > form');
const f2 = document.querySelector('div.d12 > div.d21 > form');
const f3 = document.querySelector('div.d12 > div.d22 > form');
- 然后就可以通过这3个对象获取元素了.想打印看看是什么内容?可以直接在浏览器控制台中输入代码,这个控制台其实就是
js
环境,类似python
,逐行编译. - 输入
f1
就能看到提示,第一行代表有创建这个变量.第二和第三行代表输入历史值,我之前测试时有输入过,现在快捷显示.然后左边的页面能看到<form>
标签元素高亮了,发现并没有包裹所有表单元素,因为我们没有设置大小,也不需要设置.
- 当你按下回车,会发现打印的就是
html
内的文本内容(没想到居然就是文本内容),再把鼠标移动到个别元素上,会再次高亮个别元素.
- 那么怎么方便明了的获取
<form>
内的元素呢?可以直接通过html
中设置的name
元素,获取到个别元素.不需要再通过选择器等方法获取了.而且很关键的是,这个name
元素是字符串的形式,是支持中文的.用起来也很像访问字典数据. - 例如,输入
f1["波特率"]
就能获取到波特率的元素.非常方便.接下来就可以通过这个方法获取按钮元素,然后添加回调函数了.
2.添加回调函数
- 在
js
中对html
进行操作,这一行为有个专业术语:DOM
,(Document Object Model
) 译为文档对象模型.
参考: << HTML DOM 教程 | 菜鸟教程 (runoob.com) >>
- 想介绍的东西都有多,不过不跑题了,就说添加回调函数,也就是按下按键时,触发的函数.关键词是
onclick
.有两种方法,一种是直接为onclick
属性赋值,另一个是指定为onclick
赋值.(我一开始用后者,回来才知道前者,所以都介绍一下.)
参考: << HTML DOM 事件 | 菜鸟教程 (runoob.com) >>
// 直接赋值,简单方便
f1["端口号"].onclick = function(){};
// 指定赋值,灵活
f1["端口号"].addEventListener("click", function(){});
- 这里选择使用前者,方便快捷.添加代码前,先规划好每个按钮的不同状态,一开始要先将框架定下来,之后再一点点添加内容.这样代码写起来才不会乱.
f1[“开关键”] -> 打开 / 关闭
f2[“按钮”] -> 开始读取 / 停止读取
f3[“按钮”] -> 开始发送 / 停止发送
f1[“端口号”] -> 显示当前端口ID
(本来想做个选择端口的功能的,但是打开端口时就已经包含了选择功能,
虽然可以分开,但是因为无法识别端口名字,只能获取到端口id,所以选择端口只能看着一堆数字也分辨不出来.所以干脆就不分开了.然后这个端口按钮就只是用来查看已经连接的端口id)
- 设计好大概框架后,就可以写代码了,在
1.js
中继续添加如下代码:
// 按钮的回调函数
f1["端口号"].onclick = function()
{
if (f1["开关键"].value == "打开")
{
alert(`未打开串口`); // 弹窗:提示没有打开串口
}
else if (f1["开关键"].value == "关闭")
{
alert(`已打开串口\n${2048}`); // 弹窗:反馈已打开串口的信息
}
else
{
new Error(this); // 意料之外,报错
}
};
f1["开关键"].onclick = function()
{
if (f1["开关键"].value == "打开")
{
console.log(`开启串口`); // 以下调用 开启串口 函数
f1["开关键"].value = "关闭"; // 执行完毕,最后修改按钮文本
}
else if (f1["开关键"].value == "关闭")
{
console.log(`关闭串口`); // 以下调用 关闭串口 函数
f1["开关键"].value = "打开"; // 执行完毕,最后修改按钮文本
}
else
{
new Error(this); // 意料之外,报错
}
};
f2["按钮"].onclick = function()
{
if (f2["按钮"].value == "开始接收")
{
console.log(`开始接收`); // 以下调用 开始接收 函数
f2["按钮"].value = "停止接收"; // 执行完毕,最后修改按钮文本
}
else if (f2["按钮"].value == "停止接收")
{
console.log(`停止接收`); // 以下调用 停止接收 函数
f2["按钮"].value = "开始接收"; // 执行完毕,最后修改按钮文本
}
else
{
new Error(this); // 意料之外,报错
}
};
f3["按钮"].onclick = function()
{
if (f3["按钮"].value == "开始发送")
{
console.log(`开始发送`); // 以下调用 开始发送 函数
f3["按钮"].value = "停止发送"; // 执行完毕,最后修改按钮文本
}
else if (f3["按钮"].value == "停止发送")
{
console.log(`停止发送`); // 以下调用 停止发送 函数
f3["按钮"].value = "开始发送"; // 执行完毕,最后修改按钮文本
}
else
{
new Error(this); // 意料之外,报错
}
};
- 上面代码中还用到了
new Error(this)
,这个是反馈行号用的,可以自行在控制台输入查看效果.
参考: << js获取当前代码行号_60rzvvbj的博客-CSDN博客_js获取当前行数 >>
(其实在浏览器中,随便打印输出点东西就可以了,因为控制台会显示执行的行号,还能快捷跳转查看)
3.Web Serial API
一哟一哟,终于开始正片内容了.我们将开始网页访问串口的功能实现.以下内容写在2.js
文件中.在上一篇讲网页设计时已经完成脚本导入的步骤了.所以现在2个文件可以互相访问.没错,并不是只有1.js
能访问2.js
,而是二者可以互相访问,这也是使用requirejs
的一大好处.- 推荐一系列教程,大部分其实都是源自官方的手册翻译.大同小异.
推荐参考:
<< Web Serial API,web端通过串口与硬件通信 - 掘金 (juejin.cn) >>
<< 什么,网页也能直接与硬件通信?Web Serial API!|8月更文挑战 - 掘金 (juejin.cn) >>
- 在开始前还需要先准备一下串口工具,因为手头可能没有能提供串口连接的硬件设备.使用虚拟串口做实验更加方便.使用
VSPD
创建虚拟串口,然后网页版上位机连接一个,串口调试助手连接一个.
推荐参考:
<< Virtual Serial Port Driver 10 破解版 - 星光的博客 (starxg.com) >> (半天找不到免安装,就安装一下吧)
<< 友善串口调试助手下载与安装 - 知乎 (zhihu.com) >> (找了个可以免安装的)
3.1.异步&延时
- 这里介绍一个重要概念,异步.为了防止程序堵塞,浏览器的串口模块是需要异步使用的.使用关键词
async
和await
,使用的时候只需要注意两点即可,1)不能用返回值,2)await
必须在async
函数内使用.
- 在串口通讯时,也很难避免会用到延时等待.在
js
中很特殊,没有直接的sleep
等待函数.只有一个创建异步的setTimeout
函数,能创建一个x毫秒后执行的异步函数.不过可以自行嵌套创建.
参考: << javascript里的sleep()方法_clschen的博客-CSDN博客_js sleep() >>
- 在
2.js
中添加如下内容:
// 延时函数,异步中调用
const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay));
// 调用时要加上 await,代表只能在异步函数 async 中使用.
// await sleep(1000);
- 如果是需要多段延时等待的话,
sleep
使用会很方便,如果只是一段的话,还是直接使用setTimeout
会更加直接,也不需要使用await
关键词.
3.2.打开串口
推荐参考: << Web Serial API (wicg.github.io) >>
4.4 open() method
;
- 在
2.js
中添加如下内容:
/* 说明: 全局变量
*/
let port_g = null; // 储存端口对象
let reader_g = null; // 储存读对象
let writer_g = null; // 储存写对象
/* 说明: 选择端口,并打开
* 参数: 波特率
*/
async function open(baudRate)
{
if (!("serial" in navigator)) // 保护措施
{
console.log(`本浏览器不支持串口功能.`);
return; // 跳过剩余内容
}
console.log(`选择端口`);
try
{
console.log(`选择成功`);
port_g = await navigator.serial.requestPort();
}
catch
{
console.log(`选择失败`);
port_g = null;
}
if (port_g != null) // 保护措施
{
console.log(`打开端口`);
await port_g.open({
baudRate: baudRate, // 波特率
dataBits: 8, // 数据位
stopBits: 1, // 停止位
parity: 'none', // 奇偶校验
bufferSize = 255, // 缓冲区大小
flowControl: 'none' // 流控模式
});
}
}
- 需要注意的有3点: 1)选择端口和打开端口都是带有
await
关键词的异步函数,所以要使用async
函数.2)选择端口时需要使用try
关键词,因为如果没有在弹窗中选择端口时,会引发错误.3)open()
函数的传参是字典,其中波特率是没有默认值的,其他参数都有默认值.不写也可以. - 然后在
1.js
文件内的按钮回调函数中调用.
...
f1["开关键"].onclick = function()
{
if (f1["开关键"].value == "打开")
{
console.log(`开启串口`); // 以下调用 开启串口 函数
open(f1["波特率"].options[f1["波特率"].selectedIndex].value); // 取出波特率,然后打开串口
f1["开关键"].value = "关闭"; // 执行完毕,最后修改按钮文本
}
...
- 这个时候发现一个bug,虽然不选择端口后控制台不会报错,但是按钮的文本却发生了改变.如果打开失败,应该是不改变文本.
- 如果是以前c语言的思维,应该有个返回值,成功返回真,失败返回假.但是因为异步函数的原因,返回值并不好用.结合
js
的特点,用传入函数的方式解决. - 将
2.js
文件修改成如下:
async function open(baudRate, fun)
{
...
if (port_g != null) // 保护措施
{
console.log(`打开端口`);
await port_g.open({
baudRate: baudRate, // 波特率
dataBits: 8, // 数据位
parity: 'none', // 奇偶校验
stopBits: 1, // 停止位
flowControl: 'none' // 流控模式
});
}
if (port_g != null) // 保护措施
{
fun(); // 执行完毕,最后附带执行
}
}
- 将
1.js
文件修改成如下:
...
f1["开关键"].onclick = function()
{
if (f1["开关键"].value == "打开")
{
console.log(`开启串口`); // 以下调用 开启串口 函数
open(f1["波特率"].options[f1["波特率"].selectedIndex].value, ()=>
{
f1["开关键"].value = "关闭"; // 执行完毕,最后修改按钮文本
}); // 取出波特率,然后打开串口
}
...
- 这样就完美许多了,而且很灵活,
fun()
函数的调用甚至可以根据需要而变动.
3.3.端口信息
推荐参考: << Web Serial API (wicg.github.io) >>
4.3 getInfo() method
;<< JavaScript中判断一个对象是否为一个类的实例_oldjwu的博客-CSDN博客_js 判断是否是实例 >>
- 在
2.js
中添加如下内容:
/* 说明: 获取端口信息
* 参数: 无
*/
function getInfo(fun)
{
if (port_g.constructor == SerialPort) // 保护措施,判断对象是否为类的实例
{
let usbVendorId = port_g.getInfo()["usbVendorId"]; // 供应商 ID
if (usbVendorId == undefined)
{
usbVendorId = "未定义"; // 虚拟串口或部分杂牌usb设备可能没有设置这个参数
}
let usbProductId = port_g.getInfo()["usbProductId"]; // 产品 ID
if (usbProductId == undefined)
{
usbProductId = "未定义";
}
fun(usbVendorId, usbProductId);
}
}
- 获取usb设备id的主要作用其实是用在打开端口上.目前我的设计是选择授权哪个端口然后就打开哪个端口.官方推荐的设计是,可以授权多个端口,只要网页不关闭授权就有效.然后记录ID,在打开端口时可以通过ID过滤器选择打开哪个端口.(
我个人感觉这样好麻烦啊,还是整一对一比较方便,一般人使用应该不会有一拖多的情况吧.) - 这2个usb设备id都可以在pc的
设备管理器-端口(COM和LPT)
中查看得到.不过因为我的是虚拟串口,且我忘记是哪个选项了,所以没找到.如果你有硬件设备的话可以找找.验证验证.
- 另外注意,
getInfo()
是不需要await
关键字的,所以可以不需要async
.如果要加上也可以.这里我还是继续采用fun()
传参的方式. - 将
1.js
文件修改成如下:(箭头函数真方便)
...
f1["端口号"].onclick = function()
{
if (f1["开关键"].value == "打开")
{
alert(`未打开串口`); // 提示没有打开串口
}
else if (f1["开关键"].value == "关闭")
{
getInfo((usbVendorId, usbProductId)=>
{
alert(`已打开串口:
供应商 ID : ${usbVendorId}
产品 ID : ${usbProductId}`); // 反馈已打开串口的信息
});
}
...
3.4.写数据
推荐参考: << Web Serial API (wicg.github.io) >>
4.6 writable attribute
;
- 在
2.js
中添加如下内容:
/* 说明: 写数据
* 参数: 数据
*/
async function write(data, fun)
{
if (port_g.constructor == SerialPort) // 保护措施
{
const encoder = new TextEncoder(); // 创建实例
writer_g = port_g.writable.getWriter(); // 创建写对象
writer_g.write(encoder.encode(data)); // 开始发送
// writer_g.close(); // 写终止,停止发送内容,这里用不到,循环发送时才可能用到,后面讲解
writer_g.releaseLock(); // 写完毕,允许稍后关闭端口
writer_g = null; // 清空变量
console.log(data);
fun(); // 执行完毕,额外执行
}
}
- 将
1.js
文件修改成如下:
...
f3["按钮"].onclick = function()
{
if (f3["按钮"].value == "开始发送")
{
console.log(`开始发送`); // 以下调用 开始发送 函数
f3["按钮"].value = "停止发送"; // 开始执行,修改按钮文本
write(f3["发送"].value, ()=>
{
console.log(`停止发送`);
f3["按钮"].value = "开始发送"; // 执行完毕,重置文本
});
}
else if (f3["按钮"].value == "停止发送")
{
console.log(`停止发送`); // 以下调用 停止发送 函数
// 暂不写手动关闭发送的功能,所以不可以手动将按钮从"停止发送"改为"开始发送".
// f3["按钮"].value = "开始发送"; // 使用 disabled 属性禁用按钮,或是直接屏蔽这一行,也能起到同样效果
}
...
- 注意,记得调用
writer_g.releaseLock()
,如果没有调用这个函数,将无法关闭端口.而调用这个之前写任务必须结束,如果没有结束,需要调用writer_g.close()
函数来手动结束写任务.不过如果只是发送一段数据,一般不会用得到.应该是在循环的 写数据流 中会用到.大概. - 总得来说简单的写数据实现挺容易的,以下是效果图.网页端选择
COM1
.
3.5.读数据
推荐参考: << Web Serial API (wicg.github.io) >>
4.5 readable attribute
- 在
2.js
中添加如下内容:
/* 说明: 读数据
* 参数: 最大读取时间
*/
async function read(time, fun)
{
let data = []; // 储存接收数据
if (port_g.constructor == SerialPort) // 保护措施
{
if (port_g.readable) // 判断是否可用,只执行一次,不使用 while
{
reader_g = port_g.readable.getReader(); // 创建读对象
setTimeout(()=>
{
if (reader_g != null)
{
reader_g.cancel(); // 读终止,停止读任务,
}
},time); // 达到最大读取时间后手动关闭
console.log(`读取开始`);
try
{
while (true)
{
const { value, done } = await reader_g.read(); // 执行读任务
if (done) // 读取成功或失败时会返回,不然一直阻塞在死循环
{
console.log(`读取失败,一般是手动关闭了读任务`);
break;
}
else
{
for(let i=0; i<value.length; i++) // 连续读取到的数据可能是二维数组或一维数组,解包成一维后再组合数组
{
data = data.concat(value[i]); // 读取成功,保存读取内容,组合数组
}
}
}
}
catch (error)
{
console.log(`读取失败,一般是端口断开连接`);
}
finally
{
console.log(`读取结束`);
reader_g.releaseLock(); // 允许稍后关闭端口
reader_g = null; // 清空变量
}
}
console.log(data);
fun(data); // 执行完毕,额外执行
}
}
- 注意注意注意,和写数据类似,读数据,同样需要调用
releaseLock()
,以便之后能关闭端口.而调用前提是读任务已经结束.和写任务不一样,读任务是在while中一直循环的,因为经常不知道要读取的长度,( 不过在一些情况下是固定长度读取的,比如自定义协议之类的 )为此,必须要手动调用cancel()
关闭读任务,使得其能执行break
退出while
.
注意注意注意,关闭写任务使用的是
close()
,关闭读任务使用的是cancel()
;
- 自定义情况下手动关闭读任务,最常见的就是设定时间,超时关闭.这时用到前面所说的
setTimeout()
函数了.
我之前学习时时一直没找到
cancel()
,导致我一直无法正常关闭端口,在这个上面栽了大跟头.可能是我教程没看清,所以特意重点说明.
-
另一点需要注意的,读取到的数据是数组,而且每次读取到的长度不一定,且可能是二维数组,所以遍历一维后使用
concat()
组合数组的方式保存数据.再另外,读取的内容是字符ASCII
格式的十进制形式. -
将
1.js
文件修改成如下:
...
f2["按钮"].onclick = function()
{
if (f2["按钮"].value == "开始接收")
{
console.log(`开始接收`); // 以下调用 开始接收 函数
f2["按钮"].value = "停止接收"; // 开始执行,修改按钮文本
read(3000, (data)=>
{
f2["接收"].value = data; // 将接收内容显示在多行文本框
f2["按钮"].value = "开始接收"; // 执行完毕,最后修改按钮文本
});
}
else if (f2["按钮"].value == "停止接收")
{
console.log(`停止接收`); // 以下调用 停止接收 函数
if (reader_g != null) // 手动关闭
{
reader_g.cancel(); // 读终止,停止读任务,
}
// f2["按钮"].value = "开始接收"; // 不需要这里执行,上面执行
}
...
- 现在,点击读取按钮后,就会读取3秒,超时会自动关闭读取,改变文本.也可以提前手动关闭读取.
- 以下是试验效果,我读取了2次,第一次读取等待超时,第二次读取提前手动关闭,49,50,51是123的
ASCII
值,13是换行符.
3.6.关闭串口
推荐参考: << Web Serial API (wicg.github.io) >>
4.9 close() method
;
- 在
2.js
中添加如下内容:
/* 说明: 关闭串口
* 参数: 无
*/
async function close(fun)
{
if (port_g.constructor == SerialPort) // 保护措施
{
if (reader_g != null) // 如果没有清空对象
{
reader_g.cancel(); // 就关闭任务
}
if (writer_g != null) // 同上
{
writer_g.close();
}
await sleep(200); // 等待一会,确保读写任务关闭,
await port_g.close(); // 关闭串口
fun(); // 执行完毕
}
}
- 注意,关闭端口之前一定要先关闭读写任务.为了确保异步函数执行结束,就调用延时等待,也是上面介绍过的.起到阻塞性的延时等待.
注意注意注意,关闭写任务使用的是
close()
,关闭读任务使用的是cancel()
;而关闭串口使用的又是close()
;逆天,为什么要这样区别命名啊?
- 将
1.js
文件修改成如下:
...
else if (f1["开关键"].value == "关闭")
{
console.log(`关闭串口`); // 以下调用 关闭串口 函数
close(()=>
{
f1["开关键"].value = "打开"; // 执行完毕,最后修改按钮文本
}); // 关闭端口
}
...
- 最后效果如下图,开始接收后就立刻关闭串口,接收理想的提前结束.
4.总结
- 好了,以上就算是这个系列的全部内容了.从零开始,完整的介绍了一个web上位机小项目.希望能帮到你.
也算是填了一个我以前的坑.之前也写过一个python+qt的上位机,用于图像接受的,参加智能车比赛时用.本来打算也写个教程的.不过后来嫌弃太简单就没写了(就是懒).那个项目唯一折腾我的就是不知道怎么在qt里显示连贯图片,也就是视频.找了许久才找对方向.其他东西都没什么特别的.
- 现在工作后愈发不想奋斗,只想摸鱼.下班就想玩游戏和睡觉.如果工作中有什么有趣的项目,而且有闲情逸致的话,再记录.