D10 - 「数位×IN×OUT」

电子助教:「这个标题...我闻到了停刊的味道... (́⊙◞౪◟⊙‵)」


这个章节开始我们要建立「数位功能 I/O 视窗」。

何谓数位讯号

简单来说就是 0 与 1,只有开与关两种状态的讯号。问题来了,所以到底要怎麽用电压表示 0、1?电压不是可以连续变化吗?

将连续变化的电压定义为 0 或 1,这个过程我们称之为「逻辑电压准位」。

以 Arduino Uno 为例,若输入电压在 0.5 到 1.5 V 之间,则判断为 0;3 到 5.5 V 之间,则判断为 1。

D10 - 数位讯号不确定区间-01.png

D10 - 数位讯号不确定区间-02.png

D10 - 数位讯号不确定区间-03.png

1.5 到 3 V 这个区间称之为「不确定」区间,意思是如果输入电压在这之间,Arduino Uno 不能保证读取到的状态到底是 0 还是 1。

Untitled

若有兴趣想了解更深入的说明,可以参考以下连结。

【Maker电子学】一次搞懂逻辑准位与电压

建立 Firmata 转换工具

protocol 我们可以知道脚位模式等等资讯都是数值代号,无法直觉阅读,所以我们建立一个转换 firmata 资讯的工具,并设计每个模式对应的颜色(使用 Quasar Color Palette)。

src\script\utils\firmata.utils.js

const pinModeDefinition = [
  {
    code: 0x00,
    key: 'digitalInput',
    name: 'Digital Input',
    color: 'light-blue-3',
  },
  {
    code: 0x01,
    key: 'digitalOutput',
    name: 'Digital Output',
    color: 'cyan-3',
  },
  {
    code: 0x02,
    key: 'analogInput',
    name: 'Analog Input',
    color: 'red-4',
  },
  {
    code: 0x03,
    key: 'pwm',
    name: 'PWM',
    color: 'light-green-4',
  },
  {
    code: 0x04,
    key: 'servo',
    name: 'Servo',
    color: 'blue-5',
  },
  {
    code: 0x05,
    key: 'shift',
    name: 'Shift',
    color: 'purple-3',
  },
  {
    code: 0x06,
    key: 'i2c',
    name: 'I2C',
    color: 'green-4',
  },
  {
    code: 0x07,
    key: 'onewire',
    name: 'Onewire',
    color: 'indigo-4',
  },
  {
    code: 0x08,
    key: 'stepper',
    name: 'Stepper',
    color: 'lime-4',
  },
  {
    code: 0x09,
    key: 'encoder',
    name: 'Encoder',
    color: 'yellow-4',
  },
  {
    code: 0x0A,
    key: 'serial',
    name: 'Serial',
    color: 'amber-5',
  },

  {
    code: 0x0B,
    key: 'inputPullup',
    name: 'Input Pullup',
    color: 'teal-3',
  },
  {
    code: 0x0C,
    key: 'spi',
    name: 'SPI',
    color: 'amber-4',
  },
  {
    code: 0x0D,
    key: 'sonar',
    name: 'Sonar',
    color: 'orange-4',
  },
  {
    code: 0x0E,
    key: 'tone',
    name: 'Tone',
    color: 'deep-orange-4',
  },
  {
    code: 0x0F,
    key: 'dht',
    name: 'DHT',
    color: 'brown-3',
  },
];

export const PinMode = {
  /** 数位输入 : 0x00 */
  DIGITAL_INPUT: 0x00,
  /** 数位输出 : 0x01 */
  DIGITAL_OUTPUT: 0x01,
  /** 类比输入 : 0x02 */
  ANALOG_INPUT: 0x02,
  /** PWM : 0x03 */
  PWM: 0x03,
  /** 数位上拉输入 : 0x0B */
  INPUT_PULLUP: 0x0B,
}

export default {
  getDefineByCode(mode) {
    const target = pinModeDefinition.find((item) => item.code === mode);
    if (!target) {
      return null;
    }

    return target;
  },
};

建立数位 I/O 视窗

window-example.vue 复制一份後改个名字,建立 src\components\window-digital-io.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 功能'
)
</template>

<style lang="sass">
.window-digital-io
  width: 330px
  height: 440px
</style>

<script>
import BaseWindow from '@/components/base-window.vue';

export default {
  name: 'WindowDigitalIo',
  components: {
    'base-window': BaseWindow,
  },
  props: {
    pos: {
      type: Object,
      default() {
        return {
          x: 0,
          y: 0,
        };
      },
    },
  },
  data() {
    return {
      id: this.$vnode.key,
    };
  },
  provide() {
    return {
      id: this.id,
    };
  },
  computed: {},
  watch: {},
  created() {},
  mounted() {},
  methods: {},
};
</script>

可以观察到 props 中的 pos 在每个 wnidow 都会使用,所以抽离、独立成 mixin。

src\mixins\mixin-window.js

/**
 * 标准 window 共用内容
 */

export default {
  props: {
    pos: {
      type: Object,
      default() {
        return {
          x: 0,
          y: 0,
        };
      },
    },
  },
}

window-digital-io.vue 加入 mixin-window.js 并移除 props 原本的 pos

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

import BaseWindow from '@/components/base-window.vue';
import mixinWindow from '@/mixins/mixin-window';

export default {
  name: 'WindowDigitalIo',
  components: {
    'base-window': BaseWindow,
  },
  mixins: [mixinWindow],
  props: {},
  data() {
    return {
      id: this.$vnode.key,
    };
  },
  provide() {
    return {
      id: this.id,
    };
  },
  computed: {},
  watch: {},
  created() {},
  mounted() {},
  methods: {},
};

回到 app.vue,将右键选单内的「范例视窗」改为新增「数位 I/O 视窗」,并引入组件。

src\app.vue <template lang="pug">

.screen(@click='handleClick')
  // ...

	// 右键选单
  q-menu(context-menu, content-class='border-radius-s')
    q-list.min-w-260px
      q-item(@click='addWindow("window-digital-io")', clickable, v-close-popup)
        q-item-section
          | 新增「数位 I/O 视窗」

src\app.vue <script>

// ...

import WindowDigitalIo from '@/components/window-digital-io.vue';

export default {
  name: 'App',
  components: {
    'dialog-system-setting': DialogSystemSetting,
    'window-digital-io': WindowDigitalIo,
  },
  // ...
};

加入视窗内容

稍微规划一下 UI 呈现。

D10 - window-digital-io 线框.png

首先需要设计「选择 pin 脚的下拉选单」,功能预计如下:

  • option 需要显示该脚位支援的功能模式
  • select 可以输入数字,用於快速搜寻「脚位编号」或「功能模式名称」
  • 可以使用 v-model 绑定选择数值,也可以选取後 emit 选择项目。

建立 base-select-pin.vue,使用并魔改 Quasar Select 组件

src\components\base-select-pin.vue <template lang="pug">

q-select.text-shadow.base-select-pin(
  :value='value',
  use-input,
  :bg-color='color',
  :color='color',
  :clearable='clearable',
  :options='filterOptions',
  :placeholder='placeholderText',
  :input-debounce='0',
  :option-label='calcOptionLabel',
  rounded,
  outlined,
  hide-dropdown-icon,
  dense,
  input-class='text-center font-black placeholder-black',
  popup-content-class='border-radius-m',
  @filter='filterFn'
)
  template(v-slot:no-option)
    // 替换 option 为空时,显示的内容
    q-item.py-10px.border-b-1.text-red.text-center
      q-item-section(v-if='pins.length === 0')
        q-item-label
          | 无脚位资料
      q-item-section(v-else)
        q-item-label
          | 无符合关键字的脚位

  template(v-slot:option='{ opt }')
    // 自定 option 内容
    q-item.py-10px.border-b-1(
      @click='handleClick(opt)',
      dense,
      clickable,
      v-close-popup,
      :key='opt.number'
    )
      // 显示脚位编号
      q-item-section(avatar)
        q-item-label.c-row.items-end.font-orbitron.w-50px.text-grey-8
          .text-14px.mr-4px.text-grey
            | Pin
          .text-20px.font-100
            | {{ opt.label }}

      // 显示脚位模式
      q-item-section
        q-item-label
          q-chip.text-shadow-md.font-700(
            v-for='chip in opt.chips',
            rounded,
            size='md',
            :color='chip.color',
            text-color='white',
            :key='chip.name'
          )
            | {{ chip.name }}

src\components\base-select-pin.vue <style lang="sass">

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

.base-select-pin
  position: relative
  .q-field__control
    &::before
      border: none !important

src\components\base-select-pin.vue <script>

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

import firmataUtils from '@/script/utils/firmata.utils';

export default {
  name: 'BaseSelectPin',
  components: {},
  props: {
    value: {
      type: Object,
      default() {
        return null;
      },
    },

    /** 候选脚位
     * @type {PinInfo[]}
     */
    pins: {
      type: Array,
      default() {
        return [];
      },
    },

    placeholder: {
      type: String,
      default: '选择新增脚位',
    },
    color: {
      type: String,
      default: 'blue-grey-4',
    },
    clearable: {
      type: Boolean,
      default: false,
    },
  },
  data() {
    return {
			// 过滤完成的 options
      filterOptions: [],
    };
  },
  computed: {
    options() {
      /** @type {PinInfo[]} */
      const pins = this.pins;

      const options = pins.map((pin) => {
        const chips = pin.capabilities.map((capability) =>
          firmataUtils.getDefineByCode(capability.mode)
        );

        return {
          label: pin.number,
          chips,
          value: pin,
        };
      });

      return options;
    },

    placeholderText() {
      if (!this.value) {
        return this.placeholder;
      }

      return '';
    },
  },
  watch: {},
  created() {},
  mounted() {},
  methods: {
    handleClick(option) {
      const pin = option.value;
      if (!pin) {
        return;
      }

      this.$emit('selected', pin);

      // 更新 v-model 绑定数值
      this.$emit('input', pin);
    },

    /** 计算 Label 显示文字
     * @param {PinInfo} pin
     */
    calcOptionLabel(pin) {
      if (!pin) {
        return '';
      }

      return `Pin ${pin.number}`;
    },

		/**
     * Quasar Select 过滤功能
		 * https://v1.quasar.dev/vue-components/select#filtering-and-autocomplete
		 */
    filterFn(keyWord, update) {
      if (!keyWord) {
        update(() => {
          this.filterOptions = this.options;
        });
        return;
      }

      update(() => {
        // 根据关键字过滤
        const regex = new RegExp(keyWord, 'i');

        this.filterOptions = this.options.filter((option) => {
          const pinNum = option.label;
          const chips = option.chips;

          // 搜寻脚位模式名称
          const matchChip = chips.some((chip) => {
            return regex.test(chip.name);
          });

          return regex.test(pinNum) || matchChip;
        });
      });
    },
  },
};

window-digital-io.vue 引入 base-select-pin.vue

D10 - 建立 base-select-pin.vue.gif

接着便是提供脚位清单资料作为 base-select-pin.vue 的 options 显示。

window-digital-io.vuecomputed 增加 supportPins,提供支援数位功能脚位清单。

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

// ...

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

export default {
  name: 'WindowDigitalIo',
  // ...
  computed: {
		...mapState({
      boardPins: (state) => state.board.info.pins,
    }),

		// 支援功能的脚位
    supportPins() {
      /** @type {PinInfo[]} */
      const boardPins = this.boardPins;

      return boardPins.filter((pin) => {
        const hasDigitalFcn = pin.capabilities.some((capability) =>
          [DIGITAL_INPUT, DIGITAL_OUTPUT, INPUT_PULLUP].includes(
            capability.mode
          )
        );

        return hasDigitalFcn;
      });
    },
  },
  // ...
};

supportPins 输入 base-select-pin.vue

src\components\window-digital-io.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')

尝试看看效果。

D10 - base-select-pin 显示 option.gif

储存建立脚位

接着增加新增脚位的部分:

  • 增加 existPins 变数,储存目前已建立脚位
  • 绑定 base-select-pin.vueselected 事件,接收被选择的脚位。

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

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

// ...

export default {
  name: 'WindowDigitalIo',
	// ...
	data() {
    return {
      id: this.$vnode.key,

      /** @type {PinInfo[]} */
      existPins: [],
    };
  },
  // ...
  methods: {
    /** 新增脚位
     * @param {PinInfo} pin
     */
    addPin(pin) {
      if (!pin) {
        return;
      }

      this.existPins.push(pin);
    },
    /** 移除脚位
     * @param {PinInfo} pin
     */
    deletePin(pin) {
      if (!pin) {
        return;
      }

      const index = this.existPins.findIndex(
        (existPin) => existPin.number === pin.number
      );
      this.existPins.splice(index, 1);
    },

    /** 接收错误讯息 */
    handleErr(msg) {
      this.$q.notify({
        type: 'negative',
        message: msg,
      });
    },
  },
};

src\components\window-digital-io.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'
  )

D10 - 透过 base-select-pin.vue 新增脚位.gif

可以看到成功在 existPins 增加对应的脚位。

但是有一个严重的问题,就是「可以重复新增脚位」,不只单一视窗内有此问题,不同视窗也会发生脚位占用问题,所以必须将已使用的脚位储存至 Vuex,并进行限制。

window.store.js 新增脚位占用相关功能:

  • Window 物件新增 occupiedPins,纪录目前已占用脚位。
  • mutations 新增 addOccupiedPindeleteOccupiedPin 处理占用脚位新增与移除。
  • getters 新增 occupiedPins 列出所有被占用的脚位。

src\store\modules\window.store.js

/**
 * 管理视窗相关资料
 */

/**
 * @typedef {import('vuex').Module} Module
 * 
 * @typedef {import('@/types/type').Window} Window
 * @typedef {import('@/types/type').OccupiedPin} OccupiedPin
 */

// ...

/** @type {Module} */
const self = {
  // ...
  mutations: {
    // ...

    /** Window 新增占用脚位 */
    addOccupiedPin(state, { id, pin }) {
      /** @type {Window[]} */
      const windows = state.list;

      const target = windows.find((window) => window.id === id);
      if (!target) {
        console.error(`[ window.store addOccupiedPin ] window 不存在,id : `, id);
        return;
      }

      target.occupiedPins.push(pin);
    },

    /** Window 移除占用脚位 */
    deleteOccupiedPin(state, { id, pin }) {
      /** @type {Window[]} */
      const windows = state.list;

      const target = windows.find((window) => window.id === id);
      if (!target) {
        console.error(`[ window.store deleteOccupiedPin ] window 不存在,id : `, id);
        return;
      }

      const targetPinIndex = target.occupiedPins.findIndex(({ number }) =>
        number === pin.number
      );
      if (targetPinIndex < 0) {
        return;
      }

      target.occupiedPins.splice(targetPinIndex, 1);
    },
  },
  // ...
  getters: {
    // ...

    /** 列出所有被占用的脚位
     * @return {OccupiedPin[]}
     */
    occupiedPins: (state) => {
      /** @type {Window[]} */
      const windows = state.list;

      // 找出有占用脚位的 window
      const occupiedPinWindows = windows.filter(({ occupiedPins }) =>
        occupiedPins.length !== 0
      );

      const occupiedPins = occupiedPinWindows.reduce((acc, window) => {
        const { component, id } = window;

        window.occupiedPins.forEach((pin) => {

          acc.push({
            info: pin,
            occupier: {
              component, id,
            },
          });
        });

        return acc;
      }, []);

      return occupiedPins;
    },
  },
  // ...
};

export default self;

window-digital-io.vueaddPin()deletePin() 分别呼叫 window.store.jsaddOccupiedPin()deleteOccupiedPin()

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

// ...

export default {
  name: 'WindowDigitalIo',
  // ...
  methods: {
    /** 新增脚位
     * @param {PinInfo} pin
     */
    addPin(pin) {
      if (!pin) {
        return;
      }

      this.$store.commit('window/addOccupiedPin', {
        id: this.id,
        pin,
      });

      this.existPins.push(pin);
    },
    /** 移除脚位
     * @param {PinInfo} pin
     */
    deletePin(pin) {
      if (!pin) {
        return;
      }

      this.$store.commit('window/deleteOccupiedPin', {
        id: this.id,
        pin,
      });

      const index = this.existPins.findIndex(
        (existPin) => existPin.number === pin.number
      );
      this.existPins.splice(index, 1);
    },

    // ...
  },
};

尝试看看有没有成功提交占用脚位。

D10 - 向 Vuex 提交占用脚位.gif

接着在 base-select-pin.vue 取得 occupiedPins,并限制 options 内容。

  • option 加入 disable
  • option item 加入 disable 样式。
  • 点选 disable 选项时,发出错误讯息

src\components\base-select-pin.vue <script>

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

import { mapGetters } from 'vuex';

import firmataUtils from '@/script/utils/firmata.utils';

export default {
  name: 'BaseSelectPin',
  // ...
  computed: {
    ...mapGetters({
      occupiedPins: 'window/occupiedPins',
    }),

    options() {
      /** @type {PinInfo[]} */
      const pins = this.pins;

      /** @type {OccupiedPin[]} */
      const occupiedPins = this.occupiedPins;

      const options = pins.map((pin) => {
        const chips = pin.capabilities.map((capability) =>
          firmataUtils.getDefineByCode(capability.mode)
        );

        // 若此 pin 出现在 occupiedPins 中,则 disable 为 true
        const disable = occupiedPins.some(
          (occupiedPin) => occupiedPin.info.number === pin.number
        );

        return {
          label: pin.number,
          chips,
          value: pin,
          disable,
        };
      });

      return options;
    },

    // ...
  },
  // ...
  methods: {
    handleClick(option) {
      /** @type {PinInfo} */
      const pin = option.value;
      if (!pin) {
        return;
      }

      if (option.disable) {
        this.$emit('err', `「${pin.number} 号脚位」已被占用`);
        return;
      }

      this.$emit('selected', pin);

      // 更新 v-model 绑定数值
      this.$emit('input', pin);
    },

    // ...
  },
};

src\components\base-select-pin.vue <template lang="pug">

q-select.text-shadow.base-select-pin(
  // ...
)
  // ...

  template(v-slot:option='{ opt }')
    // 自定 option 内容
    q-item.py-10px.border-b-1(
      // ...
      :class='{ "cursor-not-allowed opacity-40": opt.disable }'
    )
      // ...

试试看效果。

D10 - base-select-pin 限制已被占用脚位选项.gif

成功阻止重复新增脚位,世界恢复和平!ᕕ( ゚ ∀。)ᕗ

下一步我们要来实际建立 I/O 控制组件,显示、控制真实的数位讯号。

总结

  • 成功建立数位 I/O 视窗。
  • 建立 base-select-pin 组件,用於选择脚位。
  • 将 Firmata 回传之脚位清单透过 base-select-pin 显示。

以上程序码已同步至 GitLab,大家可以前往下载:

GitLab - D10


<<:  Day.16 应用中学习- 资料库操作 ( golang / sql )

>>:  Day16-策略不只用在兵法 什麽是策略(Strategy)

Day 29 middleware - thunk

第 29 天 ! 剩~两~天~! 昨天已经把整个 redux 的流程给接起来了, 从 store 读...

网拍的创业回亿:管理与经营(一)

我很重视客户的意见与收货速度。 当时我是网路拍卖的创办人,与客户约定好了要五天内到货。为了达成目标,...

Day 19【ERC-721】用兵之道在一个奇字,2999兵分2999路

【前言】 之前在 Day 3 有介绍过 Non-fungible Token(非同质化代币)与相关...

爬虫怎麽爬 从零开始的爬虫自学 DAY18 python网路爬虫开爬-1网页抓取

前言 各位早安,书接上回我们已经搞定接下来会用到的套件的安装了,套件是很强大的工具可以帮助我们简化很...

Day 22 - 网路的运行方式

如何在网际网路宣告真实的 IP?这或许是很多人的疑问。 我们之前有说过 BGP 是需要 ASN 来宣...