iT邦幫忙

2021 iThome 鐵人賽

DAY 13
0
Modern Web

你渴望連結嗎?將 Web 與硬體連上線吧!系列 第 13

D12 - 「數位×IN×OUT」:建立控制組件

  • 分享至 

  • xImage
  •  

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

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

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


再來就是實際建立透過 select 選擇的腳位,並建立相關 Firmata 功能。

建立數位控制組件

稍微規劃一下預期 UI 內容。

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

建立 window-digital-io-item.vue 組件,用來作為數位功能控制與顯示功能。

具體實現功能:

程式的部份為:

  • 列舉可用的數位模式
  • 可選擇使用的數位模式
  • 儲存目前數位數值

src\components\window-digital-io-item.vue <template lang="pug">

.c-row.q-0px.w-full
  .pin-number
    .text-20px
      | {{ pin.number }}
    q-btn.bg-white(
      @click='handleDelete',
      icon='r_delete',
      dense,
      flat,
      rounded,
      color='grey-5'
    )

  q-select.w-full(
    v-model='selectedCapabilitity',
    outlined,
    dense,
    rounded,
    emit-value,
    map-options,
    :options='capabilitityOptions',
    color='teal'
  )

  q-toggle(
    v-model='pinValue',
    color='teal-5',
    keep-color,
    checked-icon='r_bolt'
  )

src\components\window-digital-io-item.vue <style scoped lang="sass">

@import '@/styles/quasar.variables.sass'

.pin-number
  width: 36px
  padding: 10px 0px
  margin-right: 10px
  font-family: 'Orbitron'
  color: $grey
  text-align: center
  position: relative
  &:hover
    .q-btn
      pointer-events: auto
      opacity: 1
  .q-btn
    position: absolute
    top: 50%
    left: 50%
    transform: translate(-50%, -50%)
    pointer-events: none
    transition-duration: 0.4s
    opacity: 0

src\components\window-digital-io-item.vue <script>

/**
 * @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
 *
 * @typedef {import('@/types/type').PinInfo} PinInfo
 * @typedef {import('@/types/type').PinCapability} PinCapability
 */

import { mapState } from 'vuex';

import firmataUtils, { PinMode } from '@/script/utils/firmata.utils';

export default {
  name: 'WindowDigitalIoItem',
  components: {},
  props: {
    /** @type {PinInfo} */
    pin: {
      type: Object,
      required: true,
    },
  },
  data() {
    return {
      /** 數位功能種類 */
      digitalModes: [
        PinMode.DIGITAL_INPUT,
        PinMode.DIGITAL_OUTPUT,
        PinMode.INPUT_PULLUP,
      ],
      /** 選擇模式 */
      selectedCapabilitity: null,
      /** 腳位數值 */
      pinValue: false,
    };
  },
  computed: {
    ...mapState({
      /** @type {PortTransceiver} */
      portTransceiver: (state) => state.core.transceiver,
    }),

    /** 此腳位可用的模式 */
    usableCapabilities() {
      /** @type {PinInfo} */
      const pin = this.pin;

      const capabilities = pin.capabilities.filter((capabilitity) =>
        this.digitalModes.includes(capabilitity.mode)
      );

      return capabilities;
    },

    /** 腳位模式 select options */
    capabilitityOptions() {
      /** @type {PinCapability[]} */
      const usableCapabilities = this.usableCapabilities;

      return usableCapabilities.map((capabilitity) => {
        const { name: label } = firmataUtils.getDefineByCode(capabilitity.mode);

        return {
          label,
          value: capabilitity,
        };
      });
    },
  },
  watch: {},
  created() {},
  mounted() {},
  beforeDestroy() {},
  methods: {
    handleDelete() {},
  },
};

接著在 window-digital-io.vue 引入 window-digital-io-item.vue

src\components\window-digital-io.vue <script>

/**
 * @typedef {import('@/types/type').PinInfo} PinInfo
 */

import { mapState } from 'vuex';

import BaseWindow from '@/components/base-window.vue';
import BaseSelectPin from '@/components/base-select-pin.vue';
import WindowDigitalIoItem from '@/components/window-digital-io-item.vue';

//...

export default {
  name: 'WindowDigitalIo',
  components: {
    'base-window': BaseWindow,
    'base-select-pin': BaseSelectPin,
    'window-digital-io-item': WindowDigitalIoItem,
  },
  // ...
};

src\components\window-digital-io-item.vue <template lang="pug">

base-window.window-digital-io(
  :pos='pos',
  headerIconColor='teal-3',
  body-class='c-col p-20px pt-20px',
  title='數位 I/O 功能'
)
  base-select-pin(
    :pins='supportPins',
    color='teal-3',
    @selected='addPin',
    @err='handleErr'
  )

  q-scroll-area.pt-10px.h-300px.flex
    transition-group(name='list-complete', tag='div')
      window-digital-io-item.py-10px(
        v-for='pin in existPins',
        :pin='pin',
        :key='pin.number',
        @delete='deletePin'
      )
  • 使用 q-scroll-area 組件,保留未來滾動事件擴充性(文檔:Quasar Scroll Area)。
  • transition-group 讓項目建立、移除時有過渡動畫。

嘗試建立腳位看看。

D12 - 建立數位腳位控制項.gif

出現了!ヾ(◍'౪`◍)ノ゙

再來就是實作數位功能了!

數位輸出

在控制腳位數值之前,需要先設定腳位模式。

設定腳位模式

在「Control Messages Expansion」可以找到設定腳位模式的命令為:

0  set digital pin mode (0xF4) (MIDI Undefined)
1  set pin number (0-127)
2  mode (INPUT/OUTPUT/ANALOG/PWM/SERVO/I2C/ONEWIRE/STEPPER/ENCODER/SERIAL/PULLUP, 0/1/2/3/4/6/7/8/9/10/11)

由以上說明可以得知:

  • byte[0]:命令代號 0xF4
  • byte[1]:腳位編號。
  • byte[2]:模式編號。

也就是說,如果要設定 Pin 11(0x0B)為「數位輸出(0x01)」模式,則命令為 [ 0xF4, 0x0B, 0x01 ]

接著根據剛才的結論,在 cmd-define.js 新增設定腳位模式命令。

src\script\firmata\cmd-define.js

export default [
  //...

	// setMode: 設定腳位模式
  {
    key: 'setMode',
    getValue({ pin, mode }) {
      const cmds = [0xF4, pin, mode];
      return cmds;
    },
  },
]

設定數位腳位數值

最後則是設定數位腳位數值,在「Control Messages Expansion」可以找到設定腳位數值的命令為:

0  set digital pin value (0xF5) (MIDI Undefined)
1  set pin number (0-127)
2  value (LOW/HIGH, 0/1)

由以上說明可以得知:

  • byte[0]:命令代號 0xF5
  • byte[1]:腳位編號。
  • byte[2]:目標數值。高電位為 1、低電位為 0。

也就是說,若要設定 Pin 11(0x0B)為「數位輸出數值」為:

  • 「高電位(0x01)」,則命令為 [ 0xF5, 0x0B, 0x01 ]
  • 「低電位(0x00)」,則命令為 [ 0xF5, 0x0B, 0x00 ]

接著根據剛才的結論,在 cmd-define.js 新增設定腳位模式命令。

src\script\firmata\cmd-define.js

export default [
  //...

	// setMode: 設定腳位模式
  {
    key: 'setMode',
    getValue({ pin, mode }) {
      const cmds = [0xF4, pin, mode];
      return cmds;
    },
  },

	// setDigitalPinValue: 設定數位腳位數值
  {
    key: 'setDigitalPinValue',
    getValue({ pin, value }) {
      const level = value ? 0x01 : 0x00;
      return [0xF5, pin, level];
    },
  },
]

實作輸出功能

window-digital-io-item.vue 中偵測選擇模式變化,進行腳位模式設定。

src\components\window-digital-io-item.vue <script>

/**
 * @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
 *
 * @typedef {import('@/types/type').PinInfo} PinInfo
 * @typedef {import('@/types/type').PinCapability} PinCapability
 */

import { mapState } from 'vuex';

import firmataUtils, { PinMode } from '@/script/utils/firmata.utils';

export default {
  name: 'WindowDigitalIoItem',
  // ...
  watch: {
    /** 偵測選擇的腳位功能 */
    selectedCapabilitity() {
      this.initMode();
    },
  },
  // ...
  methods: {
    // ...

    /** 初始化指定模式 */
    initMode() {
      /** @type {PinInfo} */
      const pin = this.pin;

      /** @type {PortTransceiver} */
      const portTransceiver = this.portTransceiver;

      /** @type {PinCapability} */
      const selectedCapabilitity = this.selectedCapabilitity;

      const mode = selectedCapabilitity.mode;

      portTransceiver.addCmd('setMode', {
        pin: pin.number,
        mode,
      });
    },
  },
};

只要使用者選定腳位功能後,就會發送 setMode 命令設定腳位模式。

再來就是偵測開關變化,並將開關數值傳送至腳位數值。

src\components\window-digital-io-item.vue <script>

/**
 * @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
 *
 * @typedef {import('@/types/type').PinInfo} PinInfo
 * @typedef {import('@/types/type').PinCapability} PinCapability
 */

import { mapState } from 'vuex';

import firmataUtils, { PinMode } from '@/script/utils/firmata.utils';

export default {
  name: 'WindowDigitalIoItem',
  // ...
  watch: {
		/** 偵測選擇的腳位功能 */
    selectedCapabilitity() {
      this.initMode();
    },

    pinValue(value) {
      /** @type {PinInfo} */
      const pin = this.pin;

      /** @type {PortTransceiver} */
      const portTransceiver = this.portTransceiver;

      portTransceiver.addCmd('setDigitalPinValue', {
        pin: pin.number,
        value,
      });
    },
  },
  // ...
};

實測看看有沒有發送成功。

D12 - 數位輸出功能成功.gif

使用兩個 LED 試試看。

D12 - 多個數位輸出.gif

感覺真棒 ✧*。٩(ˊᗜˋ*)و✧*。

數位輸入

上拉輸入與數位輸入都是數位訊號輸入,所以解析回應的地方都一樣,差別在腳位模式不同而已。

設定模式命令在數位輸出時已經完成,所以只差解析數位資料回應而已!

這時候有一個問題「所以要如何取得腳位數值?」,最直覺的想法是不斷發送查詢命令、取得數值,不過 Firmata 有個貼心的設計,可以讓 MCU 偵測腳位狀態變化,自動回傳狀態。

在「Message Types」找到 report digital port。

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

可以看到 report digital port 的命令為 0xD0,根據說明,可以得知:

  • 狀態回報以 Port 為單位
  • 命令長度為 2 byte
  • byte[0] 為 0xD0 + port
  • byte[1] 為是否啟用

實際上 Uno 操作腳位狀態是操作 Port Register 數值,每個 Port 之 Register 為 1 byte,所以可以控制 8 隻腳位狀態。

不過這裡的 port 並非實際上 Register 編號,單純是依順序分組。
例如:Pin 0 至 Pin 7 為 Port 0,Pin 8 至 Pin 15 為 Port 1,以此類推。

詳細內容可以查看以下連結:
Arduino CC - Port Manipulation

取得例子:

  • 設定 Pin 4 為數位輸入。

    Pin 4 為 Port 0,所以 [ 0xD0 + 0, 0x01 ],得命令為 [ 0xD0, 0x01 ]

  • 設定 Pin 10 為數位輸入。

    Pin 10 為 Port 1,所以 [ 0xD0 + 1, 0x01 ],得命令為 [ 0xD1, 0x01 ]

讓我們調整一下剛才 cmd-define.js 中的 setMode 命令,讓模式為數位輸入時,自動加入「開啟自動回報命令」。

src\script\firmata\cmd-define.js

import { PinMode } from '@/script/utils/firmata.utils';
const { DIGITAL_INPUT, INPUT_PULLUP } = PinMode;

export default [
  // ..

  // setMode: 設定腳位模式
  {
    key: 'setMode',
    getValue({ pin, mode }) {
      const cmds = [0xF4, pin, mode];

      // Mode 如果為 Digital Input,加入開啟自動回報命令
      if ([DIGITAL_INPUT, INPUT_PULLUP].includes(mode)) {
        const port = 0xD0 + ((pin / 8) | 0);
        cmds.push(port, 0x01);
      }

      return cmds;
    },
  },

  // ...
]

嘗試看看發送命令後,有沒有未定義的資料回應。

D12 - 開啟數位輸入自動回報.gif

資料進來了!再來就是解析資料了。

response-define.js 新增定義 digitalMessage

src\script\firmata\response-define.js

/**
 * @typedef {import('@/types/type').DigitalResponseMessage} DigitalResponseMessage
 */

import { arraySplit, matchFeature } from '@/script/utils/utils';

export default [
  // ...

  // digitalMessage: 數位訊息回應
  {
    key: 'digitalMessage',
    eventName: 'data:digitalMessage',
    /**
     * @param {number[]} res 
     */
    matcher(res) {

    },
    /**
     * @param {number[]} values 
     * @return {DigitalResponseMessage[]}
     */
    getData(values) {

    },
  },
]

matcher()getData() 內容要怎麼實作呢?在「Data Message Expansion」可以找到數位回應資料的說明:

Two byte digital data format, second nibble of byte 0 gives the port number (eg 0x92 is the third port, port 2)

回應資料為:

0  digital data, 0x90-0x9F, (MIDI NoteOn, bud different data format)
1  digital pins 0-6 bitmask
2  digital pin 7 bitmask

也就是說數值開頭只要包含 0x90-0x9F 就表示為數位資料回應,所以 matcher() 為:

/**
 * @typedef {import('@/types/type').DigitalResponseMessage} DigitalResponseMessage
 */

import { arraySplit, matchFeature } from '@/script/utils/utils';

export default [
  // ...

  // digitalMessage: 數位訊息回應
  {
    key: 'digitalMessage',
    eventName: 'data:digitalMessage',
    /**
     * @param {number[]} res 
     */
		matcher(res) {
      const hasCmd = res.some((byte) => byte >= 0x90 && byte <= 0x9F);
      return hasCmd;
    },
    /**
     * @param {number[]} values 
     * @return {DigitalResponseMessage[]}
     */
    getData(values) {

    },
  },
]

getData() 的部份則是如以下需求:

  • 多個數位資料回報可能會再一起回傳。
  • 需要將數值依照 bitmask 合併。

就是 D04 提到的「分屍傳輸」,將大於 ,1 byte 的數值拆分傳輸,現在則是要組裝回來。

所以先在 utils.js 建立一個專門組裝數值的函數。

src\script\utils\utils.js

// ...

/** 將有效 Bytes 轉為數值
 * @param {number[]} bytes 有效位元矩陣。bytes[0] 為 LSB
 * @param {number} [bitsNum] 每 byte 有效位元數
 */
export function significantBytesToNumber(bytes, bitsNum = 7) {
  const number = bytes.reduce((acc, byte, index) => {
    const mesh = 2 ** bitsNum - 1;

    const validBits = byte & mesh;
    acc += (validBits << (bitsNum * index))

    return acc;
  }, 0);

  return number;
}
  • 從說明可知當初拆分是以 7 個一組,所以 bitsNum 參數預設 7

接著完成 getData() 部份。

/**
 * @typedef {import('@/types/type').DigitalResponseMessage} DigitalResponseMessage
 */

import { arraySplit, matchFeature, significantBytesToNumber } from '@/script/utils/utils';

export default [
  // ...

  // digitalMessage: 數位訊息回應
  {
    key: 'digitalMessage',
    eventName: 'data:digitalMessage',
    /**
     * @param {number[]} res 
     */
		matcher(res) {
      const hasCmd = res.some((byte) => byte >= 0x90 && byte <= 0x9F);
      return hasCmd;
    },
    /**
     * @param {number[]} values 
     * @return {DigitalResponseMessage[]}
     */
		getData(values) {
      // 取得所有特徵點位置
      const indexs = values.reduce((acc, byte, index) => {
        if (byte >= 0x90 && byte <= 0x9F) {
          acc.push(index);
        }
        return acc;
      }, []);

      /** @type {DigitalResponseMessage[]} */
      const responses = indexs.reduce((acc, index) => {
        const bytes = values.slice(index + 1, index + 3);

        const port = values[index] - 0x90;
        const value = significantBytesToNumber(bytes);

        acc.push({
          port, value,
        });
        return acc;
      }, []);

      return responses;
    },
  },
]

最後在 window-digital-io-item.vue 加入 portTransceiver.on('data:digitalMessage'),接收看看資料。

src\components\window-digital-io-item.vue <script>

// ...

export default {
  name: 'WindowDigitalIoItem',
  // ...
  methods: {
    handleDelete() {},

    /** 初始化指定模式 */
    initMode() {
      // ...

      portTransceiver.addCmd('setMode', {
        pin: pin.number,
        mode,
      });

      portTransceiver.on('data:digitalMessage', (data) => {
        console.log(`[ digitalMessage ] data : `, ...data);
      });
    },
  },
};

D12 - 成功解析數位回應資料.gif

成功接收資料!

最後來完善功能吧,首先 window-digital-io-item.vue 中的 initMode(),需要針對不同模式進行對應動作。

  • 數位輸入、上拉輸入模式需要進行監聽。
  • 新增 listener 儲存監聽器,並於組件銷毀或腳位模式切換時刪除監聽器。

src\components\window-digital-io-item.vue <script>

// ...
const { DIGITAL_INPUT, DIGITAL_OUTPUT, INPUT_PULLUP } = PinMode;

export default {
  name: 'WindowDigitalIoItem',
	// ...
	data() {
    return {
			// ...
      listener: null,
    };
  },
  // ...
	beforeDestroy() {
    this.listener?.off?.();
  },
  methods: {
    handleDelete() {},

    /** 初始化指定模式 */
    initMode() {
      // ...

			/** @type {PinCapability} */
      const selectedCapabilitity = this.selectedCapabilitity;

      const mode = selectedCapabilitity.mode;

      portTransceiver.addCmd('setMode', {
        pin: pin.number,
        mode,
      });

			// 數位輸入
      if ([INPUT_PULLUP, DIGITAL_INPUT].includes(mode)) {
        this.listener = portTransceiver.on(
          'data:digitalMessage',
          (data) => {
            console.log(`[ digitalMessage ] data : `, ...data);
          },
          {
            objectify: true,
          }
        );
        return;
      }

      // 數位輸出
      if ([DIGITAL_OUTPUT].includes(mode)) {
        this.listener?.off?.();
        this.pinValue = false;
        return;
      }

      console.error(`[ initMode ] 未定義模式 : `, mode);
    },
  },
};

接著讓解析後的數位資料反映在開關上,有以下需求:

  • 計算此腳位隸屬的 Port 號
  • 建立 handleData(),負責處理接收到的資料並儲存至 pinValue
    • 從 Port Value 取得指定 bit 資料
  • 腳位模式種類為輸入時,停止發送 pinValue 數值。

數位資料中的 value 是指整個 Port 的數值,也就是 8 隻腳的數值以二進位方式組合,關係如下圖:

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

utils.js 建立一個從數值中取得指定 bit 的函數。

src\script\utils\utils.js

// ...

/** 取得數值特定 Bit
 * @param {Number} number 
 * @param {Number} bitIndex bit Index。從最小位元並以 0 開始
 */
export function getBitWithNumber(number, bitIndex) {
  const mesh = 1 << bitIndex;
  const value = number & mesh;
  return !!value;
}

依照需求設計程式。

src\components\window-digital-io-item.vue <script>

// ...
const { DIGITAL_INPUT, DIGITAL_OUTPUT, INPUT_PULLUP } = PinMode;

export default {
  name: 'WindowDigitalIoItem',
	// ...
	computed: {
    // ...

    /** 此腳位所屬的 Port 編號 */
    pinPortNum() {
      /** @type {PinInfo} */
      const pin = this.pin;
      const port = (pin.number / 8) | 0;

      return port;
    },
  },
	watch: {
    // ...

    pinValue(value) {
      /** @type {PinInfo} */
      const pin = this.pin;

      /** @type {PortTransceiver} */
      const portTransceiver = this.portTransceiver;

      // 只有輸出模式才要設定數值
      /** @type {PinCapability} */
      const { mode } = this.selectedCapabilitity;
      if (mode !== DIGITAL_OUTPUT) return;

      portTransceiver.addCmd('setDigitalPinValue', {
        pin: pin.number,
        value,
      });
    },
  },
	// ...
  methods: {
    // ...

		/** 初始化指定模式 */
    initMode() {
      // ...

      // 數位輸入
      if ([INPUT_PULLUP, DIGITAL_INPUT].includes(mode)) {
        this.listener = portTransceiver.on(
          'data:digitalMessage',
          (data) => {
            this.handleData(data);
          },
          {
            objectify: true,
          }
        );
        return;
      }

      // ...
    },

		/** 處理數值
     * @param {DigitalResponseMessage[]} data
     */
    handleData(data) {
      // 取得最後一次數值即可
      /** @type {DigitalResponseMessage} */
      const target = findLast(data, ({ port }) => {
        return this.pinPortNum === port;
      });

      if (!target) return;

      const { value } = target;

      /** @type {PinInfo} */
      const pin = this.pin;
      const bitIndex = pin.number % 8;

      this.pinValue = getBitWithNumber(value, bitIndex);
    },
  },
};

最後增加一點 UX 和其他細節:

  • 腳位模式種類為輸入時,鎖定開關。
  • 完成刪除功能

src\components\window-digital-io-item.vue <script>

// ...
const { DIGITAL_INPUT, DIGITAL_OUTPUT, INPUT_PULLUP } = PinMode;

export default {
  name: 'WindowDigitalIoItem',
	// ...
	computed: {
		handleDelete() {
      this.$emit('delete', this.pin);
    },

    // ...

		/** 停用開關 */
    lockToggle() {
      /** @type {PinCapability} */
      const selectedCapabilitity = this.selectedCapabilitity;

      if (!selectedCapabilitity) {
        return true;
      }

      // 只有 OUTPUT 模式可以使用
      if (selectedCapabilitity.mode === DIGITAL_OUTPUT) {
        return false;
      }

      return true;
    },
  },
	// ..
};

src\components\window-digital-io-item.vue <template lang="pug">

.c-row.q-0px.w-full
  // ...

  q-toggle(
    v-model='pinValue',
    color='teal-5',
    keep-color,
    :disable='lockToggle',
    checked-icon='r_bolt'
  )

嘗試看看效果。

D12 - 完成數位控制功能.gif

到此成功完成「數位 I/O 視窗」了!

總結

  • 了解 Firmata 數位功能資料
  • 完成發送、解析數位設定與回應命令
  • 完成「數位 I/O 視窗」

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

GitLab - D12


上一篇
D11 - 「數位×IN×OUT」:Arduino 數位功能(digital)
下一篇
D13 - 「類比×電壓×輸入」
系列文
你渴望連結嗎?將 Web 與硬體連上線吧!33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言