iT邦幫忙

2021 iThome 鐵人賽

DAY 8
0

本系列文已改編成書「Arduino 自造趣:結合 JavaScript x Vue x Phaser 輕鬆打造個人遊戲機」,本書改用 Vue3 與 TypeScript 全面重構且加上更詳細的說明,

在此感謝 iT 邦幫忙、博碩文化與編輯小 p 的協助,歡迎大家前往購書,鱈魚在此感謝大家 (。・∀・)。

若想 DIY 卻不知道零件去哪裡買的讀者,可以參考此連結 。( •̀ ω •́ )✧


既然已經透過 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

效果如下。
https://ithelp.ithome.com.tw/upload/images/20240116/20140213jPnb2GFEWn.png

看起來 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

看起來應該有比較酷一點吧... (´・ω・`)

https://ithelp.ithome.com.tw/upload/images/20240116/20140213jUFYBN2rQ4.png

總結

  • 建立 port-transceiver.js 接收 COM Port 資料。
  • 成功解析 Firmata 資料並儲存至 Vuex
  • 在桌面顯示「版本號」與「韌體名稱」並換上酷酷的字體 (´,,•ω•,,)

以上程式碼已同步至 GitLab,大家可以前往下載:

GitLab - D07


上一篇
D06 - Web Serial API 初體驗
下一篇
D08 - 今晚,我想來點「腳位清單」加「功能模式」,配「類比映射表」
系列文
你渴望連結嗎?將 Web 與硬體連上線吧!33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言