D12 - 「数位×IN×OUT」:建立控制组件

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

建立数位控制组件

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

D12 - window-digital-io-item 线框.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。

Untitled

可以看到 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 只脚的数值以二进位方式组合,关系如下图:

D12 - 解析 Port Value.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


<<:  Day 14 - AI-900 认证心得(2) - 考试

>>:  [Day26] 电脑有秘密档案不想被发现吗? 教你用图片伪装秘密档案!

KSP 的实作方向

这系列的文章不会讲完全部 KSP 的实作,毕竟我也还正在实作中,不过实作的方向应该是跟前几篇讲的差不...

IOS、Python自学心得30天 Day-28 上传图片到Firebase Storage

根据官方文件给的方法上传 https://firebase.google.com/docs/stor...

#2 JavaScript Crash Course 1

今天来教教 JavaScript 的基础语法:注解、变数、常数、回圈、条件、函式 以及 运算。 目的...

[PoEAA] Domain Logic Pattern - Table Module

本篇同步发布於个人Blog: [PoEAA] Domain Logic Pattern - Tabl...

Golang - Redis基本介绍

工作上没机会用到Redis 自己就搞一个来玩,以後工作说不定也会用到 Redis是什麽 官网:htt...