D07 - 听话,给我资料!

既然已经透过 Serial API 取得 Port 存取权限了,再来我们就要来接收并解析资料了。

建立资料收发模组

若每个需要串列通讯资料的地方都要写一次读取相关的程序,会导致程序不好维护,所以我们在此将建立一个模组,负责处理串列通讯资料。

此模组的功能需求为:

  • 使用 Serial API 选择之 Port 物件读写资料。
  • 自动解析 Firmata 回应值并主动通知。
  • 新增并发送 Firmata 命令。

建立 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 事件,效果如下:

D07 - PortTransceiver 回传资料.gif

将注册 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}`,
        });
      });
    },
  },
};

D07 - PortTransceiver 初始化 Notify.gif

仔细比对会发现 console.log 印出来的内容与 D04 分析的内容相同,接下来让我们进入解析资料环节。

建立 Firmata 模组

建立 Firmata 模组,用於将接收到的数值解析成对应的资料。

首先新增 firmata responce 的资料集,功能需求为:

  • 定义所有 firmata responce 内容
  • 设计 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}`,
        });
      });
    },
  },
};

D07 - port-transceiver ready 事件.gif

至此,我们成功取得 Firmata 回传之「版本号」与「韧体名称」了!

储存 Firmata 资料至 Vuex

接着将 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 中。

D07 - 将 Firmata 储存至 Vuex.gif

成功!( ´ ▽ ` )ノ

好不容易取得资料,当然是要显示出来才行。◝( •ω• )◟

让我们前往 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

效果如下。

Untitled

看起来 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,
    }),
  },
  // ...
};

D07 - 显示版本号与韧体名称.gif

最後换个看起来科幻一点的字体,看起来比较厉害。ԅ(´∀` ԅ)

字体从 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

看起来应该有比较酷一点吧... (´・ω・`)

Untitled

总结

  • 建立 port-transceiver.js 接收 COM Port 资料。
  • 成功解析 Firmata 资料并储存至 Vuex
  • 在桌面显示「版本号」与「韧体名称」并换上酷酷的字体 (´,,•ω•,,)

以上程序码已同步至 GitLab,大家可以前往下载:

GitLab - D07


<<:  [铁人赛 Day06] React 中如何拦截网站 Runtime 错误?- Error boundaries

>>:  使用MLFlow tracking功能比较training结果

学习Python纪录Day23 - 读取指定储存泛范围的资料

读取指定储存泛范围的资料 建立get_rows()函式读取指定范围的资料 iter_rows()取出...

【D32】结尾

这个系列是制作讯号灯,制作出一些简单的讯号灯,当作我们判断的依据。这些灯号之後还可以做出更为精细的比...

学习成为人体 PE Parser

看日常分享: AwesomeCS FB 看技术文章: AwesomeCS Wiki 笔者最近在阅读...

Day 5 - Using Argon2 for Password Verifying with ASP.NET Web Forms C# 使用 Argon2 验证密码

=x= 🌵 Sign In page 後台登入密码验证。 验证流程介绍 : 📌 使用者於登入页面输入...

DAY 30 好用的套件

推荐扩充套件 Color Highlight 这边跟大家推荐 Color Highlight 这个扩...