iT邦幫忙

2021 iThome 鐵人賽

DAY 16
0
Modern Web

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

D15 - 「類比×電壓×輸入」:建立控制組件

  • 分享至 

  • xImage
  •  

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

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

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


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

(過程和建立 window-digital-io-item.vue 基本上相同。)

建立類比控制組件

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

https://ithelp.ithome.com.tw/upload/images/20240118/20140213GgtpA4IyVj.png

建立 window-analog-input-item.vue 組件,用來顯示類比數值。

具體實現功能:

程式的部份為:

  • 取得類比腳位映射
  • 刪除功能同 window-digital-io-item.vue
  • 儲存目前類比數值

src\components\window-analog-input-item.vue <template lang="pug">

.c-row.q-0px.items-center.w-full
  .pin-number
    .text-20px
      | {{ pin.number }}
    q-btn.bg-white(
      @click='handleDelete',
      icon='r_delete',
      dense,
      flat,
      rounded,
      color='grey-5'
    )
  q-slider.mx-20px(
    v-model='pinValue',
    readonly,
    color='red-3'
  )
  q-knob(
    v-model='pinValue',
    size='60px',
    show-value,
    readonly,
    color='red-3',
    track-color='grey-3',
    font-size='12px'
  )

src\components\window-analog-input-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-analog-input-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';

export default {
  name: 'WindowAnalogInputItem',
  components: {},
  props: {
    /** @type {PinInfo} */
    pin: {
      type: Object,
      required: true,
    },
  },
  data() {
    return {
      /** 腳位數值 */
      pinValue: 0,
    };
  },
  computed: {
    ...mapState({
      /** @type {PortTransceiver} */
      portTransceiver: (state) => state.core.transceiver,
    }),
  },
  watch: {},
  created() {},
  mounted() {},
  beforeDestroy() {},
  methods: {
    handleDelete() {
      this.$emit('delete', this.pin);
    },
  },
};

接著在 window-analog-input.vue 引入 window-analog-input-item.vue

src\components\window-analog-input.vue <script>

// ...

import BaseWindow from '@/components/base-window.vue';
import BaseSelectPin from '@/components/base-select-pin.vue';
import WindowAnalogInputItem from '@/components/window-analog-input-item.vue';

// ...

export default {
  name: 'WindowAnalogInput',
  components: {
    'base-window': BaseWindow,
    'base-select-pin': BaseSelectPin,
    'window-analog-input-item': WindowAnalogInputItem,
  },
  // ...
};

src\components\window-analog-input-item.vue <template lang="pug">

base-window.window-analog-input(
  :pos='pos',
  headerIconColor='red-3',
  body-class='c-col p-20px pt-20px',
  title='類比輸入功能'
)
  base-select-pin(
    :pins='supportPins',
    color='red-3',
    @selected='addPin',
    @err='handleErr'
  )

  q-scroll-area.pt-10px.h-300px.flex
    transition-group(name='list-complete', tag='div')
      window-analog-input-item.py-10px(
        v-for='pin in existPins',
        :pin='pin',
        :key='pin.number',
        @delete='deletePin'
      )

嘗試建立腳位看看。

D15 - 建立類比輸入控制項.gif

成功建立!接下來就是實作類比輸入功能了!

類比輸入

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

設定腳位模式

設定模式命令在數位輸出時已經完成,直接呼叫即可!

window-analog-input-item.vue 新增以下程式:

  • methods 新增 init(),初始化腳位相關功能。
  • computed 新增 valueMax(),計算類比數值最大值。
  • created() 呼叫 init()

src\components\window-analog-input-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 { PinMode } from '@/script/utils/firmata.utils';
const { ANALOG_INPUT } = PinMode;

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

		/** 數值最大值 */
    valueMax() {
      /** @type {PinInfo} */
      const pin = this.pin;

      const target = pin.capabilities.find(
        (capability) => capability.mode === ANALOG_INPUT
      );

      return 2 ** target.resolution - 1;
    },
  },
  // ...
	created() {
    this.init();
  },
  // ...
  methods: {
    // ...

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

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

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

測試看看。

D15 - 設定腳位為類比輸入模式.gif

會發現類比輸入不用像數位輸入那樣需要開啟自動回報,只要設定類比輸入模式後,就會自動回報數值。

最後就只剩解析數值的部分了!

解析回應數值

response-define.js 新增定義 analogMessage

src\script\firmata\response-define.js

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

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

export default [
  // ...

	// analogMessage: 類比訊息回應
  {
    key: 'analogMessage',
    eventName: 'data:analogMessage',
    /**
     * @param {number[]} res 
     */
    matcher(res) {
    },
    /**
     * @param {number[]} values 
     * @return {AnalogResponseMessage[]}
     */
    getData(values) {
    },
  },
]

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

Analog 14-bit data format

回應資料為:

0  analog pin, 0xE0-0xEF, (MIDI Pitch Wheel)
1  analog least significant 7 bits
2  analog most significant 7 bits

可以發現概念與 D12 分析的數位資料回應概念相同。

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

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

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

export default [
  // ...

	// analogMessage: 類比訊息回應
  {
    key: 'analogMessage',
    eventName: 'data:analogMessage',
    /**
     * @param {number[]} res 
     */
		matcher(res) {
			return res.some((byte) => byte >= 0xE0 && byte <= 0xEF);
    },
    /**
     * @param {number[]} values 
     * @return {AnalogResponseMessage[]}
     */
    getData(values) {

    },
  },
]

再來是 getData() 的實作。

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

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

export default [
  // ...

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

      const analogVals = indexs.reduce((acc, index) => {
        const valueBytes = values.slice(index + 1, index + 3);

        const analogPin = values[index] - 0xE0;
        const value = significantBytesToNumber(valueBytes);

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

      return analogVals;
    },
  },
]

接著在 window-analog-input-item.vueinit() 中加入監聽器,監聽數值回傳。

src\components\window-analog-input-item.vue <script>

// ...

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

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

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

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

      // 監聽 analogMessage
      const listener = portTransceiver.on(
        'data:analogMessage',
        (data) => {
          console.log(`[ analogMessage ] data : `, ...data);
        },
        { objectify: true }
      );

      // 組件銷毀時,取消監聽器。
      this.$once('hook:beforeDestroy', () => {
        listener.off();
      });
    },
  },
};

來嘗試看看有沒有解析成功。

D15 - 成功解析類比回應資料.gif

成功!這時候大家可能會有個問題「為甚麼 analogPin 是 0,我明明選 Pin 14 捏」。

還記得在 D08 取得的「類比腳位映射表 analogPinMap」嗎?這是因為 Firmata 回應類比訊息時,對應腳位的訊息是用「類比腳位編號」呈現,所以 window-analog-input-item.vue 還要取得 Vuex 中的 analogPinMap

src\components\window-analog-input-item.vue <script>

// ...

export default {
  name: 'WindowAnalogInputItem',
  // ...
  computed: {
    ...mapState({
      /** @type {PortTransceiver} */
      portTransceiver: (state) => state.core.transceiver,
      analogPinMap: (state) => state.board.info.analogPinMap,
    }),

		/** 映射後的編號 */
    analogPinMapNum() {
      /** @type {PinInfo} */
      const pin = this.pin;

      return this.analogPinMap?.[pin.number] ?? null;
    },

    // ...
  },
  // ...
};

最後一步一步完善功能吧!

  • 控制器加入最小最大值。
  • 建立 handleData(),將類比數值儲存至 pinValue

q-sliderq-knob 中加入 minmax

src\components\window-analog-input-item.vue <template lang="pug">

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

  q-slider.mx-20px(
    v-model='pinValue',
    readonly,
    color='red-3',
    :min='0',
    :max='valueMax'
  )
  q-knob(
    v-model='pinValue',
    size='60px',
    show-value,
    readonly,
    color='red-3',
    track-color='grey-3',
    font-size='12px',
    :min='0',
    :max='valueMax'
  )

建立 handleData()

src\components\window-analog-input-item.vue <script>

// ...

export default {
  name: 'WindowAnalogInputItem',
  // ...
	methods: {
    // ...

		init() {
      // ...

      // 監聽 analogMessage
      const listener = portTransceiver.on(
        'data:analogMessage',
        (data) => {
          this.handleData(data);
        },
        { objectify: true }
      );

      // ...
    },

    /** 處理數值
     * @param {AnalogResponseMessage[]} data
     */
    handleData(data) {
      if (isNil(this.analogPinMapNum)) {
        console.error(`[ ${this.$options.name} handleData ] 腳位映射值不存在`);
        return;
      }

      // 取得最後一個狀態
      /** @type {AnalogResponseMessage} */
      const target = findLast(
        data,
        ({ analogPin }) => this.analogPinMapNum === analogPin
      );
      if (!target) return;

      this.pinValue = target.value;
    },
  },
};

實測看看效果。

D15 - 取得並顯示類比輸入數值.gif

成功顯示類比輸入數值!✧*。٩(ˊᗜˋ*)و✧*。

不過仔細看會發現 Slider 與 Knob 怎麼有點卡卡的,那是因為 Firmata 回傳頻率是 19ms 一次,而 Slider 與 Knob 本身有過渡動畫,太過頻繁的變更反而會讓動畫不流暢。

所以讓我們加入 throttle 吧!( ´ ▽ ` )ノ

throttle 就是節流閥的意思,可以將原有的觸發頻率改成自訂的頻率,可用於節省效能等等作用。

詳細的展示與說明可以參考以下連結:
[javascript] throttle 與 debounce,處理頻繁的 callback 執行頻率

首先建立 throttle 使用的變數與功能。

src\components\window-analog-input-item.vue <script>

// ...

import { isNil, findLast, throttle } from 'lodash-es';

// ...

export default {
  name: 'WindowAnalogInputItem',
  // ...
  data() {
    return {
      /** 腳位數值 */
      pinValue: 0,

      throttle: {
        setPinValue: null,
      },
    };
  },
	// ...
	created() {
    this.init();

    this.throttle.setPinValue = throttle(this.setPinValue, 200);
  },
  // ...
  methods: {
    // ...

    setPinValue(value) {
      this.pinValue = value;
    },
  },
};

接著改寫 handleData(),儲存數值的部分。

src\components\window-analog-input-item.vue <script>

// ...

export default {
  name: 'WindowAnalogInputItem',
  // ...
  methods: {
    // ...

		/** 處理數值
     * @param {AnalogResponseMessage[]} data
     */
    handleData(data) {
      if (isNil(this.analogPinMapNum)) {
        console.error(`[ ${this.$options.name} handleData ] 腳位映射值不存在`);
        return;
      }

      // 取得最後一個狀態
      /** @type {AnalogResponseMessage} */
      const target = findLast(
        data,
        ({ analogPin }) => this.analogPinMapNum === analogPin
      );
      if (!target) return;

      this.throttle.setPinValue(target.value);
    },
  },
};

看看效果如何。

D15 - 類比輸入加入 throttle.gif

看起來好多了。(≖‿ゝ≖)✧

大家可以加上更多個可變電阻看看效果 ◝( •ω• )◟

至此我們成功完成「類比輸入視窗」了!

總結

  • 了解 Firmata 類比功能
  • 完成解析類比輸入回應命令
  • 完成「類比輸入視窗」

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

GitLab - D15


上一篇
D14 - 「類比×電壓×輸入」:Arduino 類比功能(analog)
下一篇
D16 - 「脈衝×寬度×調變」
系列文
你渴望連結嗎?將 Web 與硬體連上線吧!33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言