D15 - 「类比×电压×输入」:建立控制组件

再来就是实际建立透过 select 选择的脚位,并建立相关 Firmata 功能。

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

建立类比控制组件

稍微规划一下预期 UI 内容。

D13 - window-analog-input 线框.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


<<:  【从零开始的Swift开发心路历程-Day17】简易订单系统Part1

>>:  Day 24:专案06 - 股市趋势图01 | 单月股市API、Pandas

[Day16] CH10:排序大家族——选择排序法

今天介绍的是第二种排序法是选择排序法(Selection Sort)。 选择排序法 将资料分成已排序...

30天学会 Python-Day20: 作用域

变数作用域 某变数的作用域代表某变数能够被使用的地方 以 Python 来说就是同个函式内,变数被建...

【Day8】[资料结构]-伫列Queue-实作

伫列(Queue)建立的方法 enqueue: 尾端新增元素 dequeue: 从前端移除元素 pe...

Day 10: Creational patterns - Singleton

目的 建立一个「唯一」物件,专责於服务只能单一连线的情境,例如跟资料库的沟通,同时确保全域内都可以呼...

当执行一个耗时较久动作时,提供良好的使用者体验

你我应该都有类似的不佳体验:点下一个按钮时,画面什麽也没有改变,你以为刚刚没点到,又再点了一次,发现...