既然已经透过 Serial API 取得 Port 存取权限了,再来我们就要来接收并解析资料了。
若每个需要串列通讯资料的地方都要写一次读取相关的程序,会导致程序不好维护,所以我们在此将建立一个模组,负责处理串列通讯资料。
此模组的功能需求为:
建立 port-transceiver.js
模组。
使用观察者模式,让物件可以发出事件。
继承 EventEmitter2
,用法说明详见文档。
透过 debounce 处理资料接收。
持续有资料接收时不会发送事件,等到超过指定时间後再发出事件。
伫列排程发送资料。
以免不同来源资料在过短时间内同时送出,让 MCU 解析命令发生错误。
透过 Serial API 读取、发送资料。
src\script\modules\port-transceiver.js
import EventEmitter2 from 'eventemitter2';
import to from 'safe-await';
import { debounce } from 'lodash-es';
export default class extends EventEmitter2.EventEmitter2 {
port = null;
reader = null;
receiveBuffer = [];
writer = null;
writeTimer = null;
cmdsQueue = []; // 命令伫列
options = {
/** 命令发送最小间距(ms) */
writeInterval: 10,
/** Reader 完成读取资料之 debounce 时间
* 由於 Firmata 采样频率(Sampling Interval)预设是 19ms 一次
* 所以只要设定小於 19ms 数值都行,这里取个整数,预设为 10ms
*
* [参考文件 : Firmata Sampling Interval](https://github.com/firmata/protocol/blob/master/protocol.md#sampling-interval)
*/
readEmitDebounce: 10,
};
/** debounce 原理与相关资料可以参考以下连结
*
* [Debounce 和 Throttle](https://ithelp.ithome.com.tw/articles/10222749)
*/
debounce = {
finishReceive: null,
};
constructor(port) {
super();
// 检查是否有 open Method
if (!this.port?.open) {
throw new TypeError('无效的 Serial Port 物件');
}
this.port = port;
this.debounce.finishReceive = debounce(() => {
this.finishReceive();
}, this.options.readEmitDebounce);
this.start().catch((err) => {
// console.error(`[ PortTransceiver start ] err : `, err);
this.emit('err', err);
});
}
/** 开启发送伫列并监听 Port 资料 */
async start() {
if (!this?.port?.open) {
return Promise.reject(new Error('Port 无法开启'));
}
const [err] = await to(this.port.open({ baudRate: 57600 }));
if (err) {
return Promise.reject(err);
}
this.emit('opened');
this.startReader();
this.startWriter();
}
/** 关闭 Port */
stop() {
this.removeAllListeners();
clearInterval(this.writeTimer);
this.reader?.releaseLock?.();
this.writer?.releaseLock?.();
this.port?.close?.();
}
/** Serial.Reader 开始读取资料
*
* 参考资料:
* [W3C](https://wicg.github.io/serial/#readable-attribute)
* [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#reading_data_from_a_port)
*/
async startReader() {
const port = this.port;
if (port.readable.locked) {
return;
}
try {
this.reader = port.readable.getReader();
for (; ;) {
const { value, done } = await this.reader.read();
if (done) {
break;
}
// console.log(`[ startReader ] value : `, value);
this.receiveBuffer.push(...value);
this.debounce.finishReceive();
}
} catch (err) {
this.stop();
this.emit('err', err);
} finally {
this.reader?.releaseLock();
}
}
/** 完成接收,emit 已接收资料 */
finishReceive() {
this.emit('data', this.receiveBuffer);
}
/** 取得 Serial.Writer 并开启发送伫列
*
* 参考资料:
* [W3C](https://wicg.github.io/serial/#writable-attribute)
*/
startWriter() {
this.writeTimer = setInterval(() => {
if (this.cmdsQueue.length === 0) {
return;
}
this.writer = this.port.writable.getWriter();
const cmd = this.cmdsQueue.shift();
this.write(cmd.values);
// console.log(`write : `, cmd.values);
}, this.options.writeInterval);
}
/** 透过 Serial.Writer 发送资料 */
async write(data) {
// console.log(`[ write ] data : `, data);
await this.writer.write(new Uint8Array(data));
this.writer.releaseLock();
}
}
接着在 Vuex 引入 port-transceiver.js
模组,让使用者选择 Port 成功後,同时建立 port-transceiver
物件。
src\store\modules\core.store.js
/**
* 管理 Port 物件、系统主要设定
*/
/**
* @typedef {import('vuex').Module} Module
*/
import PortTransceiver from '@/script/modules/port-transceiver'
/** @type {Module} */
const self = {
namespaced: true,
state: () => ({
port: null,
/** @type {PortTransceiver} */
transceiver: null,
}),
mutations: {
setPort(state, port) {
state.transceiver?.stop?.();
state.port = port;
if (!port) {
state.transceiver = null;
return;
}
state.transceiver = new PortTransceiver(port);
},
},
actions: {
},
modules: {
},
};
export default self;
在 src\app.vue <script>
引入 Vuex 中的 transceiver
物件,试试看有没有成功发出资料。
/**
* @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
*/
import { mapState } from 'vuex';
import DialogSystemSetting from '@/components/dialog-system-setting.vue';
export default {
name: 'App',
components: {
'dialog-system-setting': DialogSystemSetting,
},
data() {
return {};
},
computed: {
...mapState({
/** @type {PortTransceiver} */
portTransceiver: (state) => state.core.transceiver,
}),
},
watch: {
/** 侦测 portTransceiver 变化,如果为有效物件,则注册监听事件
* @param {PortTransceiver} transceiver
*/
portTransceiver(transceiver) {
if (!transceiver) {
return;
}
transceiver.on('data', (data) => {
console.log(`[ transceiver on data ] data : `, data);
});
},
},
created() {},
mounted() {},
methods: {},
};
选择 Port 之後,会建立 portTransceiver 物件,并监听 data
事件,效果如下:
将注册 transceiver 事件的程序包装成一个 Method 为 initTransceiver()
,并加入 Notify 讯息,提示目前状态,让使用体验好一点。
这里以「// ...」省略没有变动的程序码,减少干扰。
src\app.vue <script>
/**
* @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
*/
import { mapState } from 'vuex';
import DialogSystemSetting from '@/components/dialog-system-setting.vue';
export default {
name: 'App',
// ...
watch: {
/** 侦测 portTransceiver 变化,如果为有效物件,则进行初始化
* @param {PortTransceiver} transceiver
*/
portTransceiver(transceiver) {
if (!transceiver) {
return;
}
this.initTransceiver();
},
},
// ...
methods: {
initTransceiver() {
/** @type {PortTransceiver} */
const portTransceiver = this.portTransceiver;
/** 提示使用者正在等待 Firmata 回应
* 产生一个可关闭的 Notify,用於後续处理
* [Notify API](https://v1.quasar.dev/quasar-plugins/notify#programmatically-closing)
*/
const dismiss = this.$q.notify({
type: 'ongoing',
message: '等待 Board 启动...',
});
portTransceiver.on('data', (data) => {
dismiss();
console.log(`[ initTransceiver on data ] data : `, data);
});
portTransceiver.on('err', (err) => {
dismiss();
// 若发生错误,则清空选择 Port
this.$store.commit('core/setPort', null);
// 显示错误讯息
/** @type {string} */
const msg = err.toString();
if (msg.includes('Failed to open serial port')) {
this.$q.notify({
type: 'negative',
message: '开启 Port 失败,请确认 Port 没有被占用',
});
return;
}
this.$q.notify({
type: 'negative',
message: `开启 Port 发生错误:${err}`,
});
});
},
},
};
仔细比对会发现 console.log
印出来的内容与 D04 分析的内容相同,接下来让我们进入解析资料环节。
建立 Firmata 模组,用於将接收到的数值解析成对应的资料。
首先新增 firmata responce 的资料集,功能需求为:
key
:此回应资料的 key。eventName
:此资料对应触发的 event 名称。matcher()
:用来判断回应资料是否符合。getData()
:将回应资料转为 Firmata 资料。src\script\firmata\response-define.js
export default [
// firmwareName: 韧体名称与版本
{
key: 'firmwareName',
eventName: 'info',
/**
* @param {number[]} res
*/
matcher(res) {
// 回传 Boolean 表示是否相符
},
/**
* @param {number[]} values
*/
getData(values) {
// 依照 D04 分析过程设计程序。
// 取得特徵起点
const index = values.lastIndexOf(0x79);
const major = values[index + 1];
const minor = values[index + 2];
const nameBytes = values.slice(index + 3, -1);
/** 在 D04 内容中可以知道 MSB 都是 0
* 所以去除 0 之後,将剩下的 byte 都转为字元後合并
* 最後的结果就会是完整的名称
*/
const firmwareName = nameBytes
.filter((byte) => byte !== 0)
.map((byte) => String.fromCharCode(byte))
.join('');
return {
ver: `${major}.${minor}`,
firmwareName
}
},
},
]
所以「matcher()
判断回应资料是否符合」的部分要怎麽做呢?这里我们用最简单直接的办法,直接将数值矩阵转为字串後,判断有没有含有相符字串。(简单暴力 ヽ(́◕◞౪◟◕‵)ノ)
大家可以自行实作速度更快的演算法 (ง •̀_•́)ง
建立 src\script\utils\utils.js
集中各类运算功能。
/** 判断 Array 是否包含另一特徵 Array
* @param {Number[]} array
* @param {Number[]} feature
* @return {Number[]}
*/
export function matchFeature(array, feature) {
const arrayString = array.join();
const featureString = feature.join();
return arrayString.includes(featureString);
}
实作 matcher()
内容
src\script\firmata\response-define.js
import { matchFeature } from '@/script/utils/utils';
export default [
// firmwareName: 韧体名称与版本
{
key: 'firmwareName',
eventName: 'info',
/**
* @param {number[]} res
*/
matcher(res) {
// 回应开头一定为 F0 79
const featureBytes = [0xF0, 0x79];
return matchFeature(res, featureBytes);
},
// ...
},
]
建立 firmata.js
模组并引入 response-define.js
。
功能需求:
src\script\firmata\firmata.js
/**
* @typedef {Object} ResponseParseResult 回应资料解析结果
* @property {string} key 回应 key
* @property {string} eventName 事件名称
* @property {number[]} oriBytes 原始回应值
* @property {Object} data 解析完成资料
*/
import responsesDefines from '@/script/firmata/response-define';
export default {
/** 解析回应资料
* @param {Number[]} res 接收数值
*/
parseResponse(res) {
// 找出符合回应
const matchResDefines = responsesDefines.filter((define) =>
define.matcher(res)
);
if (matchResDefines.length === 0) {
return [];
}
const results = matchResDefines.map((resDefine) => {
const data = resDefine.getData(res);
const { key, eventName } = resDefine;
/** @type {ResponseParseResult} */
const result = {
key,
eventName,
oriBytes: res,
data,
}
return result;
});
return results;
},
}
接着在 port-transceiver.js
引入 firmata.js
,并修改 finishReceive()
Method 内容。
src\script\modules\port-transceiver.js
import EventEmitter2 from 'eventemitter2';
import to from 'safe-await';
import { debounce } from 'lodash-es';
import firmata from '@/script/firmata/firmata';
export default class extends EventEmitter2.EventEmitter2 {
// ...
finishReceive() {
// 解析回应内容
const results = firmata.parseResponse(this.receiveBuffer);
if (results.length === 0) {
this.receiveBuffer.length = 0;
return;
}
// emit 所有解析结果
results.forEach(({ key, eventName, data }) => {
// 若 key 为 firmwareName 表示刚启动,emit ready 事件
if (key === 'firmwareName') {
this.emit('ready', data);
}
this.emit(eventName, data);
});
this.receiveBuffer.length = 0;
}
// ...
}
最後回到 app.vue
,我们调整一下刚刚 initTransceiver()
内容。
src\app.vue <script>
/**
* @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
*/
import { mapState } from 'vuex';
import DialogSystemSetting from '@/components/dialog-system-setting.vue';
export default {
name: 'App',
// ...
methods: {
initTransceiver() {
/** @type {PortTransceiver} */
const portTransceiver = this.portTransceiver;
/** 提示使用者正在等待 Firmata 回应
* 产生一个透过程序关闭的 Notify,用於後续处理
* [Notify API](https://v1.quasar.dev/quasar-plugins/notify#programmatically-closing)
*/
const dismiss = this.$q.notify({
type: 'ongoing',
message: '等待 Board 启动...',
});
// 接收 ready 事件
portTransceiver.on('ready', (data) => {
dismiss();
const ver = data.ver;
const firmwareName = data.firmwareName;
this.$q.notify({
type: 'positive',
message: `初始化成功,韧体名称「${firmwareName}」,版本:「${ver}」`,
});
});
// 接收 err 事件
portTransceiver.on('err', (err) => {
dismiss();
// 若发生错误,则清空选择 Port
this.$store.commit('core/setPort', null);
// 显示错误讯息
/** @type {string} */
const msg = err.toString();
if (msg.includes('Failed to open serial port')) {
this.$q.notify({
type: 'negative',
message: '开启 Port 失败,请确认 Port 没有被占用',
});
return;
}
this.$q.notify({
type: 'negative',
message: `开启 Port 发生错误:${err}`,
});
});
},
},
};
至此,我们成功取得 Firmata 回传之「版本号」与「韧体名称」了!
接着将 Firmata 取得的资料储存至 Vuex,让所有组件都能取得。
建立 src\store\modules\board.store.js
/**
* 管理 Firmata 版本、Pin 清单等等 MCU 开发版相关资料
*/
/**
* @typedef {import('vuex').Module} Module
*/
import { merge } from 'lodash-es';
/** @type {Module} */
const self = {
namespaced: true,
state: () => ({
info: {
ver: null,
firmwareName: null,
pins: [],
analogPinMap: {},
},
}),
mutations: {
setInfo(state, info) {
merge(state.info, info);
},
},
actions: {
},
modules: {
},
};
export default self;
并在 Vuex 引入。
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
import core from './modules/core.store';
import board from './modules/board.store';
export default new Vuex.Store({
state: {
},
mutations: {
},
actions: {
},
modules: {
core, board
},
});
回到 app.vue
,在 initTransceiver()
增加 on('info')
事件。
src\app.vue <script>
/**
* @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
*/
import { mapState } from 'vuex';
import DialogSystemSetting from '@/components/dialog-system-setting.vue';
export default {
name: 'App',
// ...
methods: {
initTransceiver() {
/** @type {PortTransceiver} */
const portTransceiver = this.portTransceiver;
// ...
portTransceiver.on('ready', (data) => {
// ...
});
// 接收 info 事件
portTransceiver.on('info', (info) => {
// 储存至 Vuex
this.$store.commit('board/setInfo', info);
});
portTransceiver.on('err', (err) => {
// ...
});
},
},
};
透过 Vue 之 Chrome 外挂,检查看看有没有成功存至 Vuex 中。
成功!( ´ ▽ ` )ノ
好不容易取得资料,当然是要显示出来才行。◝( •ω• )◟
让我们前往 app.vue
,将「版本号」与「韧体名称」显示在画面右下角。
src\app.vue <template lang="pug">
screen
.info
| firmwareName - v0.0
dialog-system-setting
src\app.vue <style lang="sass">
@import '@/styles/quasar.variables.sass'
.screen
position: absolute
width: 100%
height: 100%
display: flex
justify-content: center
align-items: center
background: linear-gradient(160deg, rgba($teal-1, 0.2), $blue-grey-1)
color: $grey
.info
position: absolute
right: 0px
bottom: 0px
display: flex
text-align: right
padding: 14px
letter-spacing: 1.5px
font-size: 14px
color: $grey
效果如下。
看起来 OK,接着从 Vuex 中取得实际资料,并让 firmwareName
为空时不显示。
src\app.vue <template lang="pug">
.screen
.info(v-if='firmwareName')
| {{ firmwareName }} - v{{ ver }}
dialog-system-setting
src\app.vue <script>
/**
* @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
*/
import { mapState } from 'vuex';
import DialogSystemSetting from '@/components/dialog-system-setting.vue';
export default {
name: 'App',
// ...
computed: {
...mapState({
/** @type {PortTransceiver} */
portTransceiver: (state) => state.core.transceiver,
ver: (state) => state.board.info.ver,
firmwareName: (state) => state.board.info.firmwareName,
}),
},
// ...
};
最後换个看起来科幻一点的字体,看起来比较厉害。ԅ(´∀` ԅ)
字体从 Google Font 寻找
在 global.sass
新增一个字体用 Class
src\styles\global.sass
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&display=swap')
// 引入变数
@import '@/styles/quasar.variables.sass'
.c-row
display: flex
.c-col
display: flex
flex-direction: column
.border-radius-m
border-radius: $border-radius-m !important
.border-radius-s
border-radius: $border-radius-s !important
// 滚动条
::-webkit-scrollbar
width: 3px
height: 3px
::-webkit-scrollbar-track
padding: 5px
border-radius: 7.5px
::-webkit-scrollbar-thumb
border-radius: 7.5px
.font-orbitron
font-family: 'Orbitron'
加上替换字体的 Class
src\app.vue <template lang="pug">
.screen
.info.font-orbitron(v-if='firmwareName')
| {{ firmwareName }} - v{{ ver }}
dialog-system-setting
看起来应该有比较酷一点吧... (´・ω・`)
port-transceiver.js
接收 COM Port 资料。以上程序码已同步至 GitLab,大家可以前往下载:
<<: [铁人赛 Day06] React 中如何拦截网站 Runtime 错误?- Error boundaries
>>: 使用MLFlow tracking功能比较training结果
读取指定储存泛范围的资料 建立get_rows()函式读取指定范围的资料 iter_rows()取出...
这个系列是制作讯号灯,制作出一些简单的讯号灯,当作我们判断的依据。这些灯号之後还可以做出更为精细的比...
看日常分享: AwesomeCS FB 看技术文章: AwesomeCS Wiki 笔者最近在阅读...
=x= 🌵 Sign In page 後台登入密码验证。 验证流程介绍 : 📌 使用者於登入页面输入...
推荐扩充套件 Color Highlight 这边跟大家推荐 Color Highlight 这个扩...