FeliCaのデータ読取を行うkintoneプラグインの作成
2025年1月4日

FeliCaのデータ読取を行うkintoneプラグインの作成

56,603文字(読了まで約142分)

2025年あけましておめでとうございます。 家庭の事情から、この年末年始はあまり外に出るわけにも行かず、かといって家にいても暇という中、なにか勉強しようと取り組んだものです。

つい最近、たまたまXか何かでWebUSBというものがあることを知り、またFeliCaリーダーであるPaSoRiが利用できるという記事を見かけました。 私自身、職場での職員カードとしてFeliCaカードの導入に携わったことがあったので、多少FeliCaの仕様に明るく、やってみるかという気になり勢いでPaSoRiを購入。

また実用的には、読み取ったデータをどこかに保存しなければならないのですが、手軽には、最近良く触っているkintoneに取り込むことができないかな?と考えました。

  • 「なぜ職員カードの読み取りを?」(出来らぁ!)

ということなのですが、職員カードの読み取りができれば、会議やイベント等への出欠を簡単に取ることができるようになるなと考えてのことです。 実は元々、同期の職員から

「災害対応訓練など、大量の職員が一同に参加する場面での出欠を取るのが大変で、職員カード読取でできたら楽なんだけど・・・」、「こういう製品あるから、買って貸し出してくれたらうれしいな」

といった相談を受けたことがあり、当時は「言うて要るなら自分の部署で買っては?」などと冷たく考えており、「予算があったら買ってみてもいいのかな・・・」などとも思っていたものの、それからはや幾年月・・・

ふとWebUSBの記事を見かけて思い出したというわけです。

  • WebUSB APIを利用し、RC-S300からFeliCaカードのデータを読み取る機能を用意

    • IDmのみとするか、可能であればFeliCaカードのメモリからもデータ(職員番号)を取得
  • 読み取ったデータは、kintone上の項目へ格納する。

    • 用途的に、読取待受状態→1枚読み取ったらすぐに保存→次の読取待受状態へ、という流れを想定し、読取待受状態へ自動的に移れる仕組みとする
    • 読み取った職員番号から、kintoneのルックアップで職員マスタ等から氏名・所属等の属性を取得し、表示可能とする
  • 機能はkintoneプラグインとして実装し、汎用性をもたせる。

    • プラグイン化のメリット
      • kintoneはjavascriptでカスタマイズすることが可能ですが、保存先フィールドの指定などが決め打ちで記述することになってしまう(設定変更にはプログラム修正が必要)
      • カスタマイズを汎用化させたプラグインという形式にパッケージングすることで、カスタマイズの設定画面を用意することができ、プラグインを適用するアプリごとに設定を切り替えて再利用することが可能
      • kintoneアプリへの保存先項目やFeliCaカードからの読取設定(サービスコード等)などを、プラグインの設定で保持できるようにする
    • 設定画面を作る必要があるが、比較的人気のありそうなreact-hook-formを用いて作る。
    • バリデーションにはreact-hook-formと連携可能なzodを用いる。
  • という技術要素の構成上、基本的に全てtypescriptでの実装を行います。

  • 注意点として、kintoneの拡張はjavascriptで作成するため、WebUSBの操作はアプリケーション利用者に全て筒抜けであると考えてください。セキュリティ的に高いわけではありませんので、利用シーンは選ぶと思います。

エディタはVSCodeとし、devcontainer上に開発環境を作ります。

devcontainerが何なのか、ここでは詳述はしませんが、開発マシン内にdockerコンテナを用意し、環境を分離することで、

  • 開発マシンを汚さない
  • (docker engineさえあれば)ポータブルな開発環境 とできるものです。

nodeとtypescriptが使えれば十分かと思われたため、

  • debianベースのコンテナイメージ
  • typescriptのfeature(devcontainerでは、開発によく利用される環境のセットをfeatureとして追加できるようになっている)

で用意しました。

・・・が、ここで早速罠がありました(後述)

開発環境の申込み

kintoneのサービス提供元であるサイボウズさんでは、カスタマイズやプラグイン作成を行う開発者向けに、kintoneの開発者ライセンスを提供されています。

kintone 開発者ライセンス(開発環境)

kintone 開発者ライセンス(開発環境)

cybozu developer network
https://cybozu.dev/ja/kintone/developer-license/

上記サイトから、メールアドレスで申し込むだけです。 しばらくするとメールが届き、環境をつかえるようになりました(1時間もかからなかった?と思います)。

kintoneでは申し込んだ環境ごとに、xxxxxxx.cybozu.comといったドメインが割当てられます。 初期では乱数のドメイン名が割り当てられますが、好みで変えることも可能です。

動作確認用アプリの作成

動作確認用のアプリを作っておきます。

  • カード読込プラグインを設定するREADED_CARDアプリ
  • IDmのマスタ(IDM_MASTER)
  • 職員マスタ(STAFF_MASTER) を用意します。

READED_CARDのidm及びstaff_noはFeliCaから読み出した値を設定し、IDM_MASTERまたはSTAFF_MASTERをルックアップして追加の属性を取得する想定です。

erDiagram RC[READED_CARD]{ string idm string staff_no } SM[STAFF_MASTER]{ string staff_no string staff_name string dept_name } IM[IDM_MASTER]{ string idm string staff_no } RC o|--|| SM: "ルックアップで参照" RC o|--|| IM: "ルックアップで参照"

typscript自体は、devcontainerのfeatureで導入しましたが、モジュールバンドラについては、viteを導入しました。

初期セットアップには、npx create-viteすればよいと思います。

また、リンター/フォーマッタとしてbiomeを導入しました(VSCode拡張機能のbiomeも導入し、保存時にフォーマットされるようにしました)。

このあたりはいろんな記事がありますので、各自調べていただければと思います。

viteでReact×TypeScript環境を爆速で作る最小版 - Qiita

viteでReact×TypeScript環境を爆速で作る最小版 - Qiita


javascriptカスタマイズの動作確認

プラグインとしてパッケージングする前段階のPOC(概念実証)として、いったん普通のjavascriptカスタマイズを作成して検証してみるのが早いです。 ただ、javascriptカスタマイズの開発作業においても、都度、kintoneアプリにプログラムをアップロードするのはなかなか煩雑です。

kintoneアプリのjavascriptカスタマイズの設定では、ファイルをアップロードする方法以外に、javascriptファイルをURLで参照することも可能となっています。 このため開発中の動作確認時は、開発マシン上のWebサーバを起動し、kintoneからはURLでlocalhostを指定し、バンドルしたjavascriptを参照させればスムーズになります。

flowchart TB subgraph 開発マシン direction TB browser[ブラウザ] afct[ソースコード] lmsv[ローカルサーバ] end subgraph kintone環境 direction TB app[アプリ] end afct--ビルド・配置-->lmsv browser--①アクセス-->app--②htmlを返す(ローカルサーバのjsを指定)-->browser--③jsを取得-->lmsv

ただ、vite dev時に開発マシン上で起動するローカルのWebサーバでは、この時のバンドル成果物はメモリ上にあるようで、直接参照することが困難です。 このため、ここでは開発中でもいったんvite buildし、バンドル成果物をファイルとして出力したうえで、vite devでローカルサーバを起動する形としました。 なお、vite dev時はpublicディレクトリがローカルwebサーバ上で公開されるため、ここにバンドル成果物を出力させることにしました。

また加えて、kintoneアプリからjavascriptを参照するにあたっては、httpsとする必要があるため、ローカルWebサーバのhttps対応を行いました。

※https用の自己署名証明書の作成については、ここでは説明を割愛します。

./vite.config.ts
import fs from "node:fs";
import path, { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
 
export default defineConfig({
  plugins: [tsconfigPaths()],
  build: {
    rollupOptions: {
      input: { customize: "src/customize.tsx" },
      output: {
        format: "iife", <----- kintoneのカスタマイズは、即時実行関数として作成するため
        dir: "public", <----- 開発サーバで扱えるよう、publicディレクトリの下にビルド後のファイルを生成
        entryFileNames: "[name].js",
      },
    },
  },
  server: {
    https: {    <----- https対応のための設定
      cert: fs.readFileSync(
        path.resolve(__dirname, "certificate/localhost.crt"),
      ),
      key: fs.readFileSync(
        path.resolve(__dirname, "certificate/localhost.key"),
      ),
    },
    // publicディレクトリを監視対象に(更新時に開発サーバで読み込み直す)
    watch: {
      ignored: ["!**/public/**"],
    },
  },
});
./package.json
  "scripts" :{
    "dev": "concurrently 'vite build --watch' 'vite dev'"
  }

プラグイン開発用設定

詳細はサイボウズさんのチュートリアルを参照いただきたいですが、設定画面用にhtml・CSS・javascriptを用意することになります。またカスタマイズ用のCSS・javascriptも用意します。 これらのファイルがどの用途か?ということはマニフェストファイルmanifest.jsonを用意して規定します。

プロジェクト配下にpluginディレクトリを作成し、その中に、各種リソースを配置する構成(下図)を取りました。

./plugin
├── css
│   ├── 51-modern-default.css  <--- kintoneのデザインに合わせるためのCSS(サイボウズ社製)
│   └── config.css
├── html
│   └── config.html
├── image
│   └── check.png
├── js
│   ├── config.js
│   └── desktop.js
└── manifest.json
manifest.json
{
  "manifest_version": 1,
  "version": "1.0.3",
  "type": "APP",
  "name": {
    "ja": "RC-S300 FeliCaリーダー kintoneプラグイン",
    "en": "RC-S300 FeliCa reader kintone plug-in"
  },
  "description": {
    "ja": "RC-S300を用いて、WebUSBによりカードを読込可能するkintoneプラグインです。",
    "en": "RC-S300 FeliCa reader kintone plug-in"
  },
  "icon": "image/icon.png",
  "desktop": {                  <-----PC用のカスタマイズ用
    "js": ["js/desktop.js"],
    "css": ["css/51-modern-default.css", "css/config.css"]
  },
  "config": {                   <----- プラグインの設定用
    "html": "html/config.html",
    "js": ["js/config.js"],
    "css": ["css/51-modern-default.css", "css/config.css"]
  }
}

ここでのjavascriptは、typescriptで記述したソースコードからトランスパイル&バンドルされるものです。 生成された成果物を、上記pluginディレクトリ下に配置できるようにします。

先ほど、javascriptファイルはpublicディレクトリ配下に出力されるようにしたため、出力後にコピーするようnpmスクリプトで対応しました。

また、javascriptはカスタマイズ用 と プラグイン設定画面用 の2種のバンドルが必要です。 色々調べましたが、どうもvite.config.tsで即時実行関数(iife)を指定した場合、複数のエントリポイントを用意できないようです。 このため、vite.config.tsを2パターン用意し、npmスクリプトで各々を指定したビルドを行うこととしました。

configビルド用のvite config

./vite.plugin.config.ts
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
 
export default defineConfig({
  plugins: [tsconfigPaths()],
  build: {
    emptyOutDir: false,
    rollupOptions: {
      input: { config: "src/config.tsx" }, // ビルドの起点
      output: {
        format: "iife", // 即時実行関数
        dir: "public", // 開発サーバで扱えるよう、publicディレクトリの下にビルド後のファイルを生成
        entryFileNames: "[name].js",
      },
    },
  },
});
./package.json
  "scripts" :{
    "build": "vite build && cp ./public/customize.js ./plugin/js/desktop.js && vite build --config vite.plugin.config.js && cp ./public/config.js ./plugin/js/config.js",
    "dev": "concurrently 'vite build --watch' 'vite dev'"
  }

これもサイボウズさんのチュートリアルに記載がありますが、以下のツールを導入します。

  • プラグインをひとまとめのzipにするためのツールkintone-plugin-packer
  • パッケージ化したプラグインをkintone環境にアップロードするkintone-plugin-uploader

kintone-plugin-packerの初回実行時には、(ランダムな名称).ppkファイルが生成されます。 2回目以降の実行時に、パラメータでこのファイルを指定することで、同一プラグインのバージョンアップ版であるとみなされます。 (ppkを指定しない場合、kintone上では別の新しいプラグインとして認識されてしまいます。)

kintone-plugin-uploaderではkintone環境にアクセスするためのドメインやユーザ・パスワード情報が必要となります。 これらの情報については、直接npmスクリプトに記述するとよくないので、git管理外としたファイル.secretsを作成し、その中で定義しました。

./package.json
  "scripts" :{
    "build": "export $(cat .secrets | xargs) && vite build && cp ./public/customize.js ./plugin/js/desktop.js && vite build --config vite.plugin.config.js && cp ./public/config.js ./plugin/js/config.js && kintone-plugin-packer plugin --out dist/plugin.zip --ppk $KINTONE_PLUGIN_PPK",
  }

ここまでで大体の開発環境設定は完了かと思いきや、kintone-plugin-uploaderの実行でエラーが出ます。 どうやらkintone-plugin-uploaderでの操作は、ヘッドレス(画面なし)で動作するchromeブラウザを操作するnpmパッケージpuppeteerで行っているらしく、その動作のため、様々なライブラリが依存関係として必要ということが判明しました。 特に今回、コンテナ環境で開発しているため、ライブラリの不足があるようです。 ※開発マシンのホスト環境上で実行すると特に問題なく動きました。

この問題については、readmeにもpuppeteerのトラブルシューティングを参照するようにとなっています。

js-sdk/packages/plugin-uploader at main · kintone/js-sdk

js-sdk/packages/plugin-uploader at main · kintone/js-sdk

GitHub
https://github.com/kintone/js-sdk/tree/main/packages/plugin-uploader

この内容や様々な記事を参照し、いろいろと試しましたが、結果的には以下を導入することで動作するようになりました。

./.devcontainer/devcontainer.json
{
  "name": "Debian",
  "dockerComposeFile": ["./docker-compose.yml"],
  "service": "dev",
  "mounts": [
    "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind,consistency=delegated"
  ],
  "workspaceFolder": "${localWorkspaceFolder}",
  "postStartCommand": ".devcontainer/postStartCommand",
  "features": {
    "ghcr.io/devcontainers-contrib/features/typescript:2": {}
  }
}
./.devcontainer/docker-compose.yml
version: "3"
 
services:
  dev:
    build:
      context: .
      dockerfile: Dockerfile
    command: [ "sleep", "infinity" ]
    cap_add:
      - SYS_ADMIN
./.devcontainer/Dockerfile
FROM "mcr.microsoft.com/devcontainers/base:bullseye"
 
SHELL ["/bin/bash", "-c"]
 
RUN apt update
RUN apt install curl gnupg2 -y
 
RUN apt update \
  && apt install -y wget gnupg \
  && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
  && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
  && apt update \
  && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 \
  --no-install-recommends \
  && rm -rf /var/lib/apt/lists/*
 
RUN apt update && apt install -y \
    libnss3 \
    libasound2 \
    libatk1.0-0 \
    libatk-bridge2.0-0 \
    libcups2 \
    libdbus-1-3 \
    libgdk-pixbuf2.0-0 \
    libnspr4 \
    libxcomposite1 \
    libxrandr2 \
    libxdamage1 \
    libxkbcommon0 \
    libgbm1 \
    libpango-1.0-0 \
    libwayland-client0 \
    libwayland-cursor0 \
    libwayland-egl1 \
    libcurl4 \
    libexpat1 \
    libfontconfig1 \
    && apt-get clean && rm -rf /var/lib/apt/lists/*
 
# Puppeteerが勝手にChromiumを入れるのを防ぐ
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true 
 
USER vscode
./.devconainer/postStartCommand
# chrome-headless のインストール
npx puppeteer browsers install chrome-headless-shell

ただ、それでも安定しないケースがありました・・・ リソースの問題?かもしれません。いかんせんpuppeteerでマクロ操作的な仕組みでやっていますので、安定は望めません。

更新予定(プラグイン更新のREST APIがある?)

この問題について検索してみると、どうも2024年にプラグイン関係のREST APIが追加されているようです。 (もっと早く調べればよかった!)

kintone プラグインアップロード処理 - Qiita

kintone プラグインアップロード処理 - Qiita


TODO: APIの利用については、後ほど試して追記します

私の場合、chromebookを用いて開発作業を行っていますので、前提として、chrome ウェブストアから、Smart Card Connectorを導入しておきます。

PaSoRi RCS-300への命令

カードリーダとしてはPaSoRi RCS-300を用います。

no image

https://www.sony.co.jp/Products/felica/consumer/products/RC-S300.html


この機器をプログラムから操作するための方法について、全然、公式的な情報が無いのですが、以下の記事にいろいろとまとめられています。

JavaScript Web APIs USBDevice を使って FeliCa リーダー/ライターを操作してみました。(その1)

JavaScript Web APIs USBDevice を使って FeliCa リーダー/ライターを操作してみました。(その1)

有限会社さくらシステム
https://sakura-system.com/?p=2892

この内容を参考にして、RCS-300へのコネクト、オープン、クローズ等の命令を実装していきました(ほぼそのままの内容をtypescript向けに)。

FeliCaへの命令

上記記事では FeliCa Lite-Sというカードを操作していますが、今回はFeliCaスタンダードを対象に操作するプログラムを作成していきます。 といってもpolling命令は記事のそのままでした。

JavaScript Web APIs USBDevice を使って FeliCa リーダー/ライターを操作してみました。(その5:FeliCa カードへのアクセス)

JavaScript Web APIs USBDevice を使って FeliCa リーダー/ライターを操作してみました。(その5:FeliCa カードへのアクセス)

有限会社さくらシステム
https://sakura-system.com/?p=3120

これに加えて、今回、以下の「FeliCaカード ユーザーズマニュアル 抜粋版」にある

no image

https://www.sony.co.jp/Products/felica/business/tech-support/


  • request service
  • read without encryption

を実装しました。

以上を踏まえ、以下のような形

./src/lib/WebUsbCardReader.ts
export type BlockListParam = {
  accessMode: "normal" | "purse-cashback";
  blockNoStart: number;
  blockNoEnd: number;
};
 
export type ReadServiceParam = {
  serviceCode: string;
  blockListParam: BlockListParam;
};
 
export class WebUsbCardReader {
  // 処理連番
  private seq = 0;
 
  private s300Commands = {
    startransparent: new Uint8Array([
      0xff, 0x50, 0x00, 0x00, 0x02, 0x81, 0x00, 0x00,
    ]),
    turnOn: new Uint8Array([0xff, 0x50, 0x00, 0x00, 0x02, 0x84, 0x00, 0x00]),
    endTransparent: new Uint8Array([
      0xff, 0x50, 0x00, 0x00, 0x02, 0x82, 0x00, 0x00,
    ]),
    turnOff: new Uint8Array([0xff, 0x50, 0x00, 0x00, 0x02, 0x83, 0x00, 0x00]),
  };
 
  /**
   * constructor (private)
   * @param usbDevice
   */
  private constructor(
    private usbDevice: USBDevice,
    private isDebug = false,
  ) {}
 
  /**
   * デバイスに接続
   * @returns
   */
  static async connect(isDebug?: boolean) {
    const deviceFilters: USBDeviceFilter[] = [
      { vendorId: 1356, productId: 3528 }, // SONY PaSoRi RC-S300/S
      { vendorId: 1356, productId: 3529 }, // SONY PaSoRi RC-S300/P
    ];
 
    let usbDevice: USBDevice | undefined = undefined;
 
    // ペアリング設定済みデバイスのUSBDeviceインスタンス取得
    const ud = await navigator.usb.getDevices();
    if (ud.length > 0) {
      for (const dev of ud) {
        const td = deviceFilters.find(
          (deviceFilter) =>
            dev.vendorId === deviceFilter.vendorId &&
            dev.productId === deviceFilter.productId,
        );
        if (td !== undefined) {
          usbDevice = dev;
        }
      }
    }
 
    // USB機器をペアリングフローから選択しデバイスのUSBDeviceインスタンス取得
    if (!usbDevice) {
      try {
        usbDevice = await navigator.usb.requestDevice({
          filters: deviceFilters,
        });
      } catch (e: unknown) {
        if (!(e instanceof DOMException)) throw e;
        return undefined;
      }
    }
 
    return new WebUsbCardReader(usbDevice, isDebug);
  }
 
  /**
   * USB設定の取得
   * @returns
   */
  private getUsbConfigration() {
    const getEndPoint = (argInterface: USBInterface, argVal: "in" | "out") => {
      let retVal: USBEndpoint | undefined = undefined;
      for (const val of argInterface.alternate.endpoints) {
        if (val.direction === argVal) {
          retVal = val;
        }
      }
      return retVal;
    };
 
    if (!this.usbDevice.configuration)
      throw new Error("configurationがありません");
 
    const inEndpoint = getEndPoint(
      this.usbDevice.configuration.interfaces[
        this.usbDevice.configuration.configurationValue
      ],
      "in",
    );
    if (!inEndpoint)
      throw new Error("入力USBエンドポイントが取得できませんでした");
 
    const outEndpoint = getEndPoint(
      this.usbDevice.configuration.interfaces[
        this.usbDevice.configuration.configurationValue
      ],
      "out",
    );
    if (!outEndpoint)
      throw new Error("出力USBエンドポイントが取得できませんでした");
 
    return {
      confValue: this.usbDevice.configuration.configurationValue,
      interfaceNum:
        this.usbDevice.configuration.interfaces[
          this.usbDevice.configuration.configurationValue
        ].interfaceNumber, // インターフェイス番号
      endPointInNum: inEndpoint.endpointNumber,
      endPointInPacketSize: inEndpoint.packetSize,
      endPointOutNum: outEndpoint.endpointNumber,
      endPointOutPacketSize: outEndpoint.packetSize,
    };
  }
 
  /**
   * デバイスのオープン
   * @returns
   */
  private async openDevice() {
    const usbConfiguration = this.getUsbConfigration();
 
    await this.usbDevice.open();
    await this.usbDevice.selectConfiguration(usbConfiguration.confValue); // USBデバイスの構成を選択
    await this.usbDevice.claimInterface(usbConfiguration.interfaceNum); // USBデバイスの指定インターフェイスを排他アクセスにする
 
    await this.sendUsb(
      this.s300Commands.endTransparent,
      "End Transeparent Session",
    );
    await this.recvUsb(64);
 
    await this.sendUsb(
      this.s300Commands.startransparent,
      "Start Transeparent Session",
    );
    await this.recvUsb(64);
 
    await this.sendUsb(this.s300Commands.turnOff, "Turn Off RF");
    await this.sleep(50);
    await this.recvUsb(64);
    await this.sleep(50);
 
    await this.sendUsb(this.s300Commands.turnOn, "Turn On RF");
    await this.sleep(50);
    await this.recvUsb(64);
    await this.sleep(50);
 
    return;
  }
 
  /**
   * デバイスのクローズ
   * @returns
   */
  private async closeDevice() {
    const usbConfiguration = this.getUsbConfigration();
 
    await this.sendUsb(this.s300Commands.turnOff, "Turn Off RF");
    await this.sleep(50);
    await this.recvUsb(64);
    await this.sleep(50);
 
    await this.sendUsb(
      this.s300Commands.endTransparent,
      "End Transeparent Session",
    );
    await this.recvUsb(64);
 
    await this.usbDevice.releaseInterface(usbConfiguration.interfaceNum); // USBデバイスの指定インターフェイスを排他アクセスを解放する
    await this.usbDevice.close(); // USBデバイスセッション終了
 
    return;
  }
 
  /**
   * スリープ
   * @param msec
   * @returns
   */
  private async sleep(msec: number) {
    return new Promise((resolve) => setTimeout(resolve, msec));
  }
 
  /**
   * UInt8Arrayを16進数表記へ変換
   * @param argData
   * @returns
   */
  private arrayToHex(argData: Uint8Array, trim = false) {
    let retVal = "";
    for (const val of argData) {
      let str = val.toString(16);
      str = val < 0x10 ? `0${str}` : str;
      retVal += `${str.toUpperCase()} `;
    }
    if (trim) {
      return retVal.replaceAll(" ", "");
    }
    return retVal;
  }
 
  /**
   * DataViewをUInt8Arrayへ変換
   * @param argData
   * @returns
   */
  private binArrayToHex(argData: DataView | undefined) {
    if (!argData) return "";
 
    let retVal = "";
    for (let idx = 0; idx < argData.byteLength; idx++) {
      const bt = argData.getUint8(idx);
      let str = bt.toString(16);
      str = bt < 0x10 ? `0${str}` : str;
      retVal += `${str.toUpperCase()} `;
    }
    return retVal;
  }
 
  /**
   * Dataviewから配列への変換
   * @param argData
   * @returns
   */
  private dataviewToArray = (argData: DataView) => {
    // new Uint8Array([])
    const retVal = new Uint8Array(argData.byteLength);
    for (let i = 0; i < argData.byteLength; ++i) {
      retVal[i] = argData.getUint8(i);
    }
    return retVal;
  };
 
  /**
   * 16進数の文字列を1バイトずつ数値の配列に変換
   * @param hexString
   * @returns
   */
  private hexStringToByteArray(hexString: string): number[] {
    if (!/^([0-9a-fA-F]{2})+$/.test(hexString)) {
      throw new Error(
        "Invalid input. The string must be hexadecimal characters .",
      );
    }
 
    const byteArray: number[] = [];
    for (let i = 0; i < hexString.length; i += 2) {
      const byte = hexString.slice(i, i + 2);
      byteArray.push(Number.parseInt(byte, 16));
    }
    return byteArray;
  }
 
  /**
   * PasoRiのリクエストヘッダー付与
   * @param argData
   * @returns
   */
  private addReqHeader = (argData: Uint8Array) => {
    const dataLen = argData.length;
    const SLOTNUMBER = 0x00;
 
    const retVal = new Uint8Array(10 + dataLen);
 
    retVal[0] = 0x6b; // ヘッダー作成
    retVal[1] = 255 & dataLen; // length をリトルエンディアン
    retVal[2] = (dataLen >> 8) & 255;
    retVal[3] = (dataLen >> 16) & 255;
    retVal[4] = (dataLen >> 24) & 255;
    retVal[5] = SLOTNUMBER; // タイムスロット番号
    retVal[6] = ++this.seq; // 認識番号
 
    0 !== dataLen && retVal.set(argData, 10); // コマンド追加
 
    return retVal;
  };
 
  /**
   * PasoRiへの送信操作
   * @param argLength
   * @returns
   */
  private async sendUsb(argData: Uint8Array, argProc = "") {
    const usbConfiguration = this.getUsbConfigration();
    const rdData = this.addReqHeader(argData);
    await this.usbDevice.transferOut(usbConfiguration.endPointOutNum, rdData);
 
    if (this.isDebug) {
      console.log(argProc);
      console.log(`Send:${this.arrayToHex(rdData)}`);
    }
  }
 
  /**
   * PasoRiの応答取得
   * @param argLength
   * @returns
   */
  private async recvUsb(argLength: number) {
    const usbConfiguration = this.getUsbConfigration();
    const res = await this.usbDevice.transferIn(
      usbConfiguration.endPointInNum,
      argLength,
    );
 
    if (this.isDebug) {
      const resStr = this.binArrayToHex(res.data);
      console.log(`Recieve : ${resStr}`);
    }
 
    return res;
  }
 
  /**
   * FeliCaのポーリング操作
   * @returns
   */
  private async felicaPolling() {
    const timeoutPerRun = 100;
    const pollingCommand = [0x00, 0xff, 0xff, 0x01, 0x00]; // ポーリング コマンド
 
    const response = await this.felicaOperation(
      pollingCommand,
      timeoutPerRun,
      "polling",
    );
 
    if (!response) return undefined;
 
    return {
      idm: this.arrayToHex(response.data.slice(0, 8), true),
      systemCode: this.arrayToHex(response.data.slice(16, 18), true),
    };
  }
 
  /**
   * FeliCa RequestService
   * @returns
   */
  private async felicaRequestService(idm: string, nodeCodeList: number[]) {
    const timeoutPerRun = 100;
    const codeCommand = [0x02];
    if (
      nodeCodeList.length % 2 !== 0 ||
      nodeCodeList.length < 2 ||
      nodeCodeList.length > 64
    )
      throw new Error("ノードコードリストの桁数が不適切です");
    const nodeCount = nodeCodeList.length / 2;
 
    const command = codeCommand
      .concat(this.hexStringToByteArray(idm))
      .concat([nodeCount])
      .concat(nodeCodeList);
 
    const response = await this.felicaOperation(
      command,
      timeoutPerRun,
      "RequestService",
    );
 
    if (!response) return undefined;
 
    return {
      idm: this.arrayToHex(response.data.slice(0, 8), true),
      nodeCount: this.arrayToHex(response.data.slice(8, 9)),
      nodeKeyVerList: this.arrayToHex(response.data.slice(9)),
    };
  }
 
  /**
   * ブロックリストの構成
   * @param param
   * @param serviceListOrder
   * @returns
   */
  private constructBlkList(param: BlockListParam, serviceListOrder: number) {
    if (param.blockNoEnd > 0xffff) throw new Error("blockCountが不正です");
    if (serviceListOrder > 0xff) throw new Error("serviceListOrderが不正です");
 
    const blockSize = param.blockNoEnd > 0xff ? 3 : 2;
 
    let d0 = 0b00000000;
    if (blockSize === 2) d0 += 0b10000000;
    if (param.accessMode === "purse-cashback") d0 += 0b00010000;
    d0 += serviceListOrder;
 
    const result: number[] = [];
    for (let i = param.blockNoStart; i <= param.blockNoEnd; i++) {
      const blkListElement = new Uint8Array(blockSize);
      blkListElement[0] = d0;
      blkListElement[1] = i & 0xff; // 下位バイト
      if (blockSize === 3) {
        blkListElement[2] = (i >> 8) & 0xff; // 上位バイト
      }
      result.push(...blkListElement);
    }
    return result;
  }
 
  /**
   * FeliCaのデータ読み取り(暗号化なし)
   * @returns
   */
  private async felicaReadWithoutEncryption(
    idm: string,
    params: ReadServiceParam[],
  ) {
    const timeoutPerRun = 100;
 
    if (params.length === 0 || params.length > 16)
      throw new Error(
        "paramsが不正です。対象のサービスは1〜16個の範囲で指定してください",
      );
 
    // コマンドコード
    const commandCode = [0x06];
    // IDm
    const idmByteArray = this.hexStringToByteArray(idm);
    // サービス数
    const serviceCount = [params.length];
    // サービスコードリストを構成
    const serviceCodeList = params.reduce((acc, cur) => {
      if (cur.serviceCode.length !== 4)
        throw new Error(
          `サービスコードリストの桁数が不適切です:${cur.serviceCode}`,
        );
      acc.push(...this.hexStringToByteArray(cur.serviceCode));
      return acc;
    }, [] as number[]);
    // ブロック数、ブロックリストを構成
    const { blockCount, blockList } = params.reduce(
      (acc, cur, idx) => {
        const blocks = this.constructBlkList(
          {
            accessMode: cur.blockListParam.accessMode,
            blockNoStart: cur.blockListParam.blockNoStart,
            blockNoEnd: cur.blockListParam.blockNoEnd,
          },
          idx,
        );
        return {
          blockList: acc.blockList.concat(blocks),
          blockCount:
            acc.blockCount +
            cur.blockListParam.blockNoEnd -
            cur.blockListParam.blockNoStart +
            1,
        };
      },
      { blockList: [], blockCount: 0 } as {
        blockList: number[];
        blockCount: number;
      },
    );
 
    const readWithoutEncryptionCommand = commandCode
      .concat(idmByteArray)
      .concat(serviceCount)
      .concat(serviceCodeList)
      .concat([blockCount])
      .concat(blockList);
 
    const response = await this.felicaOperation(
      readWithoutEncryptionCommand,
      timeoutPerRun,
      "ReadWithoutEncryption",
    );
 
    if (!response) return undefined;
 
    return {
      idm: this.arrayToHex(response.data.slice(0, 8), true),
      statusFlag1: this.arrayToHex(response.data.slice(8, 9)),
      statusFlag2: this.arrayToHex(response.data.slice(9, 10)),
      blockSize: this.arrayToHex(response.data.slice(10, 11)),
      blockData: this.arrayToHex(response.data.slice(11), true),
    };
  }
 
  /**
   * FeliCaの操作
   * @returns
   */
  private async felicaOperation(
    felicaCommand: number[],
    timeout: number,
    description: string,
  ) {
    const wrappedCommand = await this.wrapCTXIns(felicaCommand, timeout);
    await this.sendUsb(wrappedCommand, description);
    const cTXResponse = await this.recvUsb(64);
    return await this.unwrapCTXResponse(cTXResponse);
  }
 
  /**
   * PasoriのcommunicateThruEX命令内に、FeliCaコマンドを埋め込む
   * @param felicaCommand FeliCaコマンド
   * @param timeout タイムアウト(ミリ秒)
   * @returns
   */
  private async wrapCTXIns(felicaCommand: number[], timeout: number) {
    const communicateThruEX = [0xff, 0x50, 0x00, 0x01, 0x00]; // RC-S300 コマンド communicateThruEX
    const communicateThruEXFooter = [0x00, 0x00, 0x00]; // RC-S300 コマンド communicateThruEX フッター
    const felicaHeader = [0x5f, 0x46, 0x04]; // FeliCa リクエストヘッダー
    const felicaOption = [0x95, 0x82]; // FeliCa リクエストオプション
    const felicaTimeout = timeout * 1e3; // タイムアウト(マイクロ秒)
 
    const felicaCommandLength = felicaCommand.length + 1;
 
    // FeliCa Lite-S リクエストヘッダーを付加
    const felicaReq = [...felicaHeader]; // リクエストヘッダー
    felicaReq.push(
      255 & felicaTimeout,
      (felicaTimeout >> 8) & 255,
      (felicaTimeout >> 16) & 255,
      (felicaTimeout >> 24) & 255,
    ); // タイムアウト <<リトルエンディアン>> 4バイト
    felicaReq.push(...felicaOption);
    felicaReq.push((felicaCommandLength >> 8) & 255, 255 & felicaCommandLength); // コマンドレングス
    felicaReq.push(felicaCommandLength); // リクエストコマンド
    felicaReq.push(...felicaCommand); // リクエストコマンド
 
    // communicateThruEX コマンド作成
    const felicaReqLen = felicaReq.length;
    const cTX = [...communicateThruEX];
    cTX.push((felicaReqLen >> 8) & 255, 255 & felicaReqLen); // リクエストレングス
    cTX.push(...felicaReq);
    cTX.push(...communicateThruEXFooter);
 
    return new Uint8Array(cTX);
  }
 
  /**
   * communicateThruEX レスポンスからFeliCa応答データを取り出す
   * @param cTXResponse
   * @returns
   */
  private async unwrapCTXResponse(cTXResponse: USBInTransferResult) {
    // データがない場合は終了
    if (!cTXResponse.data) return undefined;
 
    // レスポンスデータから 0x97 の位置を求める
    // (0x97 の次にデータ長が設定されている) ※128バイト以上は未考慮
    const data = this.dataviewToArray(cTXResponse.data);
    const v = data.indexOf(0x97);
    if (v < 0) return undefined;
 
    // データ長の情報からレスポンスコードとデータを切り分けて返す
    const w = v + 1;
    const length = data[w];
    const allData = data.slice(w + 1, w + 1 + length);
    return {
      length: length,
      responseCode: allData[1],
      data: allData.slice(2, allData.length + 1),
    };
  }
 
  /**
   * IDm読み取り
   * @returns
   */
  public async polling(maxTryCount = 10) {
    let tryCount = 0;
    let response: Awaited<ReturnType<typeof this.felicaPolling>> = undefined;
 
    try {
      while (!response && tryCount < maxTryCount) {
        tryCount++;
        await this.openDevice();
        response = await this.felicaPolling();
        await this.closeDevice();
        if (!response) await this.sleep(1000);
      }
    } catch (e: unknown) {
      if (this.usbDevice.opened) await this.closeDevice();
      throw e;
    }
 
    return response;
  }
 
  /**
   * Service確認
   * @returns
   */
  public async requestService(nodeCodeList: number[]) {
    try {
      const pollingResponse = await this.polling();
 
      if (!pollingResponse) {
        return undefined;
      }
 
      await this.openDevice();
      const result = await this.felicaRequestService(
        pollingResponse.idm,
        nodeCodeList,
      );
      await this.closeDevice();
 
      return result;
    } catch (e: unknown) {
      if (this.usbDevice.opened) await this.closeDevice();
      if (e instanceof Error) {
        alert(e.message);
        console.error(e.message);
      }
    }
  }
 
  /**
   * データ読み取り(暗号化無し)
   * @returns
   */
  public async readWithoutEncryption(params: ReadServiceParam[]) {
    try {
      const pollingResponse = await this.polling();
 
      if (!pollingResponse) {
        return undefined;
      }
 
      await this.openDevice();
      const result = await this.felicaReadWithoutEncryption(
        pollingResponse.idm,
        params,
      );
      // await this.closeDevice();
      this.closeDevice();
 
      return result;
    } catch (e: unknown) {
      if (this.usbDevice.opened) await this.closeDevice();
      if (e instanceof Error) {
        alert(e.message);
      }
    }
  }
}

サービスコードを探す

IDmの取得だけであれば、pollingコマンドで簡単に取得できるのですが、カード内のメモリを読み込むとなるとサービスコードの指定が必要となります。

FeliCaでは特定の機能で利用するデータをまとまりをサービスとして定義しており、サービスコードはそれを指定するものです。これがわからなければアクセスできません。

実はプログラムを作成している時点で、職員カードのサービスコードがわからない状態でした。これを(強引に)解析するため、以下の手順でサービスコードを探しました。

  • サービスコードを総当たりでrequest serviceコマンドを実行(0000〜FFFFまでの255*255とおり)
    • この段階では、暗号化しているサービスも反応を返します
  • 見つかったサービスコードに対し、read without encryptionコマンドを総当たりで実行し、データが取得できるサービスを特定

結果、職員番号を取得可能なサービスコードを特定することができました。

設定画面用にhtml・CSS・javascriptを用意します。

html

reactで実装しようとしていますので、htmlはreactコンポーネント配置用の親要素を持つのみの内容となります。

./plugin/html/config.html
<!-- たったこれだけ -->
<div id="plugin-container"/>

ちなみに、ここでidをrootにしてしまうと、kintone上の他のパーツと干渉してしまいますので注意してください。 (ハマりました)

css

reactのフォーム用ライブラリを使う(react-hook-form)

全体的に、reactによる実装を想定しているが、いざ設定画面を作ろうとしてみると、多々煩雑な部分があります。

例えば、

  • stateをいっぱい書く必要がある
  • 各項目で初期値設定を書く必要がある
  • 各項目で入力イベントを捉えてstateに反映する処理を書く必要がある

などでしょうか。

設定画面=Webフォームなので、reactでのフォーム作成に利用されるライブラリを検討しました。

  • react-json-schema-form
  • react-hook-form

今回の設定画面では、プラグイン設定対象のアプリから、IDm設定用のテキスト項目を選択肢から入力できるようにするなど、動的に入力候補値を設定するつもりです。

react-json-schema-formは、フォームのUIも含む生成が可能なようでしたが、こうした動的な設定要件への対応方法を調べたところ、そこだけ別のコンポーネントを作成するような形になっており、逆に複雑化してしまうように思われました。

一方でreact-hook-formでは、UIこそ開発者が設定する必要があるものの、stateの管理などが非常にシンプルな形となり、拡張性に富む、また、zodなどのvalidationスキーマライブラリとの連携が可能などのことから、こちらを選択しました。

条件付きスキーマバリデーションの実現

今回の機能では、

  • IDmだけを読み取るか、メモリを読み取るか、あるいは両方か
  • 一覧画面で利用するか、詳細画面で利用するか、あるいは両方か

といった違いで、必要な設定項目も変わってきます。

フォーム画面については、react-hook-formのwatchで取得した項目値に基づく動的な表示・非表示管理を行えばよいですが、 バリデーションについてはどうするかが課題でした。

これについて調べたところ、zodにdiscriminatedUnionという機能があり、特定の項目の値に基づき、スキーマを切り替えることが可能でしたため、これを採用しています。

React Hook Form で Zod を使う時の 5 つパターン

React Hook Form で Zod を使う時の 5 つパターン

azukiazusaのテックブログ2
https://azukiazusa.dev/blog/react-hook-form-zod-5-patterns

以上を踏まえ、以下のような形

./src/config.tsx
import { createRoot } from "react-dom/client";
import { App } from "./components/config/App";
 
(async (PLUGIN_ID) => {
  const rootEl = document.getElementById("plugin-container");
  if (!rootEl) throw new Error("plugin-container要素がありません");
  const root = createRoot(rootEl);
  root.render(<App PLUGIN_ID={PLUGIN_ID} />);
})(kintone.$PLUGIN_ID);
./src/components/App.tsx
import { restoreStorage, storeStorage } from "@/src/lib/utils";
import {
  type CardReaderPluginConfig,
  READ_TYPE_SELECTIONS,
  USE_CASE_TYPE_SELECTIONS,
  cardReaderPluginConfigSchema,
} from "@/src/types";
import { zodResolver } from "@hookform/resolvers/zod";
import { KintoneRestAPIClient } from "@kintone/rest-api-client";
import { useEffect, useState } from "react";
import { type SubmitHandler, useForm } from "react-hook-form";
import { KintoneLikeCheckBox } from "./KintoneLikeCheckBox";
import { KintoneLikeRadio } from "./KintoneLikeRadio";
import { KintoneLikeSelect } from "./KintoneLikeSelect";
import { KintoneLikeSingleText } from "./KintoneLikeSingleText";
 
export function App({ PLUGIN_ID }: { PLUGIN_ID: string }) {
  // 選択肢の項目用state
  const [textFields, setTextFields] = useState<
    { code: string; label: string }[]
  >([]);
  const [spaceFields, setSpaceFields] = useState<
    { code: string; label: string }[]
  >([]);
  const [viewNames, setViewNames] = useState<{ code: string; label: string }[]>(
    [],
  );
 
  // pluginに保存した設定情報を取得
  const config = restoreStorage(PLUGIN_ID, cardReaderPluginConfigSchema);
 
  // react-hook-form
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors },
    reset,
  } = useForm<CardReaderPluginConfig>({
    defaultValues: config,
    resolver: zodResolver(cardReaderPluginConfigSchema),
  });
 
  /**
   * フォーム内容送信処理
   * @param data
   */
  const onSubmit: SubmitHandler<CardReaderPluginConfig> = (data) => {
    storeStorage(data, () => {
      alert("保存しました。反映のため、アプリを更新してください");
      window.location.href = `../../flow?app=${kintone.app.getId()}`;
    });
  };
 
  // 動的制御用の監視項目
  const readType = watch("readConfig.readType");
  const useCaseType = watch("useCaseConfig.useCaseType");
 
  useEffect(() => {
    const app = kintone.app.getId();
    if (!app) throw new Error("appが取得できません。");
 
    // 選択肢の取得
    const fetchFieldsInfo = async () => {
      const client = new KintoneRestAPIClient();
      // カード読み込みボタンの設置場所として、アプリのスペース項目を取得
      const spaceFields = await (async () => {
        const url = kintone.api.url("/k/v1/preview/form.json", true);
        const body = {
          app: kintone.app.getId(),
        };
        const formSettings = (await kintone.api(url, "GET", body)) as {
          properties: {
            elementId: string;
            type: string;
          }[];
        };
        const spacers = formSettings.properties
          .filter((property) => property.type === "SPACER")
          .map((property) => {
            return { code: property.elementId, label: property.elementId };
          });
        return [{ code: "", label: "" }].concat(spacers);
      })();
 
      // IDm格納用項目の候補として、アプリの1行テキスト項目を取得
      const idmFields = await (async () => {
        const fields = await client.app.getFormFields({
          app: app,
          preview: true,
        });
        const singleLineTextFields = Object.entries(fields.properties)
          .filter(([_key, value]) => value.type === "SINGLE_LINE_TEXT")
          .map((entry) => {
            return { label: entry[1].label, code: entry[1].code };
          });
        // 空白行を追加
        return [{ code: "", label: "" }].concat(singleLineTextFields);
      })();
 
      // 自動登録用viewの候補として、アプリの1行テキスト項目を取得
      const viewNames = await (async () => {
        const response = await client.app.getViews({
          app: app,
          preview: true,
        });
        const viewNames = Object.entries(response.views).map((entry) => {
          return { label: entry[1].name, code: entry[1].name };
        });
        // 空白行を追加
        return [{ code: "", label: "" }].concat(viewNames);
      })();
 
      setSpaceFields(spaceFields);
      setTextFields(idmFields);
      setViewNames(viewNames);
 
      // 動的に候補値を取得したselectについて、表示を正しくするためresetする
      // (useForm時点ではselectのlabelが存在しないため正しく表示できない)
      reset();
    };
 
    fetchFieldsInfo();
  }, [reset]);
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <KintoneLikeRadio
        label="読取種別"
        description="カードから読み取るデータの種別を指定してください。"
        name="readConfig.readType"
        options={READ_TYPE_SELECTIONS}
        register={register}
        errors={errors}
        required
      />
 
      {(readType === "idm" || readType === "both") && (
        <>
          <p className="kintoneplugin-label">IDm読取設定】</p>
 
          <KintoneLikeSelect
            label="IDm設定用項目1"
            description="読み取ったIDmを編集するフィールドのフィールドコードを指定してください。"
            name="readConfig.idm.fieldCd1"
            options={textFields}
            register={register}
            errors={errors}
            required
          />
 
          <KintoneLikeSelect
            label="IDm設定用項目2"
            description="読み取ったIDmを編集するフィールドのフィールドコードを指定してください。"
            name="readConfig.idm.fieldCd2"
            options={textFields}
            register={register}
            errors={errors}
          />
        </>
      )}
      {(readType === "memory" || readType === "both") && (
        <>
          <p className="kintoneplugin-label">【メモリ読取設定】</p>
          <KintoneLikeSingleText
            label="データ名称"
            description="画面表示で利用するデータの名称を設定してください。"
            name="readConfig.memory.name"
            register={register}
            errors={errors}
          />
 
          <KintoneLikeSelect
            label="メモリデータ設定用項目1"
            description="読み取ったメモリデータを編集するフィールドのフィールドコードを指定してください。"
            name="readConfig.memory.fieldCd1"
            options={textFields}
            register={register}
            errors={errors}
            required
          />
 
          <KintoneLikeSelect
            label="メモリデータ設定用項目2"
            description="読み取ったメモリデータを編集するフィールドのフィールドコードを指定してください。"
            name="readConfig.memory.fieldCd2"
            options={textFields}
            register={register}
            errors={errors}
          />
 
          <KintoneLikeSingleText
            label="サービスコード"
            description="読み取るFeliCaカードのサービスコードを入力してください(4桁)。※2文字ずつの16進数表記('0B20'など) "
            name="readConfig.memory.serviceCode"
            register={register}
            errors={errors}
            required
          />
 
          <KintoneLikeSingleText
            label="ブロック開始位置"
            description="読み取るFeliCaカードのメモリブロック開始位置を入力してください。"
            name="readConfig.memory.block.start"
            register={register}
            errors={errors}
            required
          />
 
          <KintoneLikeSingleText
            label="ブロック終了位置"
            description="読み取るFeliCaカードのメモリブロック終了位置を入力してください。"
            name="readConfig.memory.block.end"
            register={register}
            errors={errors}
            required
          />
 
          <KintoneLikeSingleText
            label="データ切取開始位置"
            description="読み取ったブロックデータの切取開始位置を入力してください。"
            name="readConfig.memory.slice.start"
            register={register}
            errors={errors}
            required
          />
 
          <KintoneLikeSingleText
            label="データ切取終了位置"
            description="読み取ったブロックデータの切取終了位置を入力してください。"
            name="readConfig.memory.slice.end"
            register={register}
            errors={errors}
            required
          />
        </>
      )}
 
      <hr />
 
      <KintoneLikeRadio
        label="用途種別"
        description="カードリーダを利用する用途の種別を指定してください。"
        name="useCaseConfig.useCaseType"
        options={USE_CASE_TYPE_SELECTIONS}
        register={register}
        errors={errors}
        required
      />
 
      {(useCaseType === "list" || useCaseType === "both") && (
        <>
          <p className="kintoneplugin-label">【一覧用設定】</p>
          <KintoneLikeSelect
            label="カードリーダー実行用の一覧"
            description="カード読み取りを実行させる一覧の名前を指定してください。"
            name="useCaseConfig.list.viewName"
            options={viewNames}
            register={register}
            errors={errors}
            required
          />
 
          <KintoneLikeCheckBox
            label="登録前確認"
            description="読取結果登録時、登録前に確認ダイアログを表示するかどうかを指定してください。"
            name="useCaseConfig.list.confirmBeforeRegist"
            options={[{ code: "on", label: "表示する" }]}
            register={register}
            errors={errors}
            noSpecifyValue
          />
 
          <KintoneLikeCheckBox
            label="登録後通知"
            description="読取結果登録時、登録後に通知メッセージを表示するかどうかを指定してください。"
            name="useCaseConfig.list.notifyAfterRegist"
            options={[{ code: "on", label: "表示する" }]}
            register={register}
            errors={errors}
            noSpecifyValue
          />
        </>
      )}
 
      {(useCaseType === "record" || useCaseType === "both") && (
        <>
          <p className="kintoneplugin-label">【レコード用設定】</p>
          <KintoneLikeSelect
            label="カードリーダー実行用ボタンの配置スペース"
            description="カード読み取りの実行ボタンを配置するフォーム内のスペースを指定してください。"
            name="useCaseConfig.record.targetSpacer"
            options={spaceFields}
            register={register}
            errors={errors}
            required
          />
        </>
      )}
 
      <input
        className="kintoneplugin-button-normal"
        type="submit"
        title="設定を保存"
        value="設定を保存"
      />
    </form>
  );
}
./src/types.ts
import i18next from "i18next";
import { z } from "zod";
import { zodI18nMap } from "zod-i18n-map";
// Import your language translation files
import translation from "zod-i18n-map/locales/ja/zod.json";
// lng and resources key depend on your locale.
i18next.init({
  lng: "ja",
  resources: {
    ja: { zod: translation },
  },
});
z.setErrorMap(zodI18nMap);
 
/**
 * kintoneアプリのレコードを表す型
 */
export type KintoneRecord = {
  [fieldCode: string]: {
    value: unknown;
  };
};
 
/**
 *  0以上の整数スキーマ(preprocessにより入力文字列を数値とする)
 */
const geZeroIntSchema = z.preprocess((val) => {
  if (typeof val === "number") {
    return val;
  }
  return val ? Number(val) : undefined;
}, z.number().int().min(0));
 
/**
 * 一覧用途設定スキーマ
 */
const listUseCaseConfigSchema = z.object({
  viewName: z.string().nonempty(),
  confirmBeforeRegist: z.boolean().optional(),
  notifyAfterRegist: z.boolean().optional(),
});
 
/**
 * レコード用途設定スキーマ
 */
const recordUseCaseConfigSchema = z.object({
  targetSpacer: z.string().nonempty(),
});
 
/**
 * 用途種別設定スキーマ
 * useCaseTypeによって設定項目を切り替え(discriminatedUnion)
 */
const useCaseConfigSchema = z.discriminatedUnion("useCaseType", [
  z.object({
    useCaseType: z.literal("list"),
    list: listUseCaseConfigSchema,
  }),
  z.object({
    useCaseType: z.literal("record"),
    record: recordUseCaseConfigSchema,
  }),
  z.object({
    useCaseType: z.literal("both"),
    list: listUseCaseConfigSchema,
    record: recordUseCaseConfigSchema,
  }),
]);
 
/**
 *  利用用途種別の選択肢
 */
export const USE_CASE_TYPE_SELECTIONS = [
  { code: "list", label: "一覧のみ" },
  { code: "record", label: "レコードのみ" },
  { code: "both", label: "両方" },
] as const;
 
/**
 * IDm読み取り設定スキーマ
 */
const idmReadConfigSchema = z.object({
  fieldCd1: z.string().nonempty(),
  fieldCd2: z.string().optional(),
});
export type IdmReadConfig = z.infer<typeof idmReadConfigSchema>;
 
/**
 * メモリ読み取り設定スキーマ
 */
const memoryReadConfigSchema = z.object({
  name: z.string().default("読取データ"),
  serviceCode: z
    .string()
    .length(4)
    .regex(/^[1234567890ABCDEF]{4}$/),
  block: z.object({
    start: geZeroIntSchema,
    end: geZeroIntSchema,
  }),
  slice: z.object({
    start: geZeroIntSchema,
    end: geZeroIntSchema.optional(),
  }),
  fieldCd1: z.string().nonempty(),
  fieldCd2: z.string().optional(),
});
export type MemoryReadConfig = z.infer<typeof memoryReadConfigSchema>;
 
/**
 * 読み取り設定スキーマ
 * readTypeによって設定項目を切り替え(discriminatedUnion)
 */
const readConfigSchema = z.discriminatedUnion("readType", [
  z.object({
    readType: z.literal("idm"),
    idm: idmReadConfigSchema,
  }),
  z.object({
    readType: z.literal("memory"),
    memory: memoryReadConfigSchema,
  }),
  z.object({
    readType: z.literal("both"),
    idm: idmReadConfigSchema,
    memory: memoryReadConfigSchema,
  }),
]);
 
/**
 *  読取設定種別の選択肢
 */
export const READ_TYPE_SELECTIONS = [
  { code: "idm", label: "IDmのみ" },
  { code: "memory", label: "メモリのみ" },
  { code: "both", label: "両方" },
] as const;
 
/**
 * カードリーダープラグインの設定情報スキーマ
 */
export const cardReaderPluginConfigSchema = z.object({
  useCaseConfig: useCaseConfigSchema,
  readConfig: readConfigSchema,
});
 
/**
 * カードリーダープラグインの設定情報
 */
export type CardReaderPluginConfig = z.infer<
  typeof cardReaderPluginConfigSchema
>;

自動的にカードの読み込みを開始させる

利用シーン的に、登録結果を確認できるようにもしておきたい、また一方で、たて続けにカードを読み取りたいため、

  1. kintoneアプリの一覧画面を表示したら自動でカード待受状態に
  2. 読み取ったらREST APIで登録
  3. 登録完了したらリロードして一覧画面を表示
  4. 再度、自動でカード待受状態に

という流れを考えていました。

しかし、WebUSBではセキュリティ上、初回の機器接続を自動で行うのはできない仕様になっているそうです。ただ、これについては、すでに接続済み(ペアリング済み)だと問題はないよう。 このため、

  1. kintoneアプリの一覧画面を表示したら、初回はボタン操作でカード待受状態に
  2. ここで初回接続時は確認画面が表示される
  3. 読み取ったらREST APIで登録
  4. 登録完了したらリロードして一覧画面を表示。この際、URLパラメータにautorun=trueを付与
  5. 一覧画面表示後、autorun=trueが付与されている場合は、自動でカード待受状態に

という形にしました。

以上を踏まえ、以下のような形

./src/customize.tsx
import { createRoot } from "react-dom/client";
import { AppIndex, AppRecord } from "./components/customize/App";
import { restoreStorage } from "./lib/utils";
import { cardReaderPluginConfigSchema } from "./types";
 
((PLUGIN_ID) => {
  // 追加・編集画面表示後イベント
  kintone.events.on(
    [
      "app.record.edit.show",
      "app.record.create.show",
      "app.record.index.edit.show",
    ],
    (event) => {
      const config = restoreStorage(PLUGIN_ID, cardReaderPluginConfigSchema);
      if (
        !(
          config.useCaseConfig.useCaseType === "record" ||
          config.useCaseConfig.useCaseType === "both"
        )
      )
        return;
 
      const cardReaderBtnFieldCode = config.useCaseConfig.record.targetSpacer;
      const el = kintone.app.record.getSpaceElement(
        config.useCaseConfig.record.targetSpacer,
      );
      if (el) {
        const root = createRoot(el);
        root.render(<AppRecord PLUGIN_ID={PLUGIN_ID} />);
      } else {
        throw new Error(
          `カードリーダーボタン設置用の項目がありません:${cardReaderBtnFieldCode}`,
        );
      }
      return event;
    },
  );
 
  kintone.events.on(["app.record.index.show"], (event) => {
    const config = restoreStorage(PLUGIN_ID, cardReaderPluginConfigSchema);
    if (
      !(
        config.useCaseConfig.useCaseType === "list" ||
        config.useCaseConfig.useCaseType === "both"
      )
    )
      return;
 
    const targetViewName = config.useCaseConfig.list.viewName;
 
    if (event.viewName === targetViewName) {
      const el = kintone.app.getHeaderSpaceElement();
      if (el) {
        const root = createRoot(el);
        root.render(<AppIndex PLUGIN_ID={PLUGIN_ID} />);
      }
    }
    return event;
  });
})(kintone.$PLUGIN_ID);
./src/components/customize/App.tsx
import { WebUsbCardReader } from "@/src/lib/WebUsbCardReader";
import { hexToAscii, restoreStorage } from "@/src/lib/utils";
import {
  type KintoneRecord,
  type MemoryReadConfig,
  cardReaderPluginConfigSchema,
} from "@/src/types";
import { KintoneRestAPIClient } from "@kintone/rest-api-client";
import { useEffect, useState } from "react";
 
const client = new KintoneRestAPIClient();
 
/**
 * 	IDm読み込み処理
 */
const readIdm = async (webUsbCardreader: WebUsbCardReader) => {
  const readed = await webUsbCardreader.polling();
  return readed;
};
 
/**
 * 	IDm及びメモリの読み込み処理
 */
const readIdmAndMemory = async (
  webUsbCardreader: WebUsbCardReader,
  memoryReadConfig: MemoryReadConfig,
) => {
  const serviceCode = memoryReadConfig.serviceCode;
 
  const readResult = await webUsbCardreader.readWithoutEncryption([
    {
      serviceCode: serviceCode,
      blockListParam: {
        accessMode: "normal",
        blockNoStart: memoryReadConfig.block.start,
        blockNoEnd: memoryReadConfig.block.end,
      },
    },
  ]);
 
  if (readResult?.blockData) {
    const idm = readResult.idm;
 
    const trimmed = readResult.blockData.replaceAll(" ", "");
    const memoryData = hexToAscii(
      trimmed.slice(memoryReadConfig.slice.start, memoryReadConfig.slice.end),
    );
 
    return { idm, memoryData };
  }
  return undefined;
};
 
export function AppRecord({ PLUGIN_ID }: { PLUGIN_ID: string }) {
  // pluginに保存した設定情報を取得
  const config = restoreStorage(PLUGIN_ID, cardReaderPluginConfigSchema);
 
  /**
   * レコード編集処理
   * @param data 編集値
   * @param fieldCode1 編集先のフィールドコード1
   * @param fieldCode2 編集先のフィールドコード2
   */
  const editRecord = (
    data: string,
    fieldCode1: string,
    fieldCode2?: string,
  ) => {
    const record = kintone.app.record.get();
    record.record[fieldCode1].value = data;
    // ルックアップ項目の場合、参照先アプリから情報取得
    if (kintone.app.getLookupTargetAppId(fieldCode1) !== null) {
      record.record[fieldCode1].lookup = true;
    }
 
    if (fieldCode2) {
      record.record[fieldCode2].value = data;
      // ルックアップ項目の場合、参照先アプリから情報取得
      if (kintone.app.getLookupTargetAppId(fieldCode2) !== null) {
        record.record[fieldCode2].lookup = true;
      }
    }
 
    kintone.app.record.set(record);
  };
 
  /**
   * カード読み込み処理
   */
  const readbtnHandler = () => {
    // 読取種別に応じた処理を呼び出し
    const readCard = async () => {
      const webUsbCardreader = await WebUsbCardReader.connect(
        import.meta.env.VITE_WEBUSB_DEBUG === "true",
      );
      if (!webUsbCardreader) {
        alert("カードリーダーを接続してください。");
        return;
      }
 
      if (config.readConfig.readType === "idm") {
        const readed = await readIdm(webUsbCardreader);
        if (!readed) {
          alert("カード読み込みに失敗しました。");
          return;
        }
        editRecord(
          readed.idm,
          config.readConfig.idm.fieldCd1,
          config.readConfig.idm.fieldCd2,
        );
      } else {
        const readed = await readIdmAndMemory(
          webUsbCardreader,
          config.readConfig.memory,
        );
        if (!readed) {
          alert("カード読み込みに失敗しました。");
          return;
        }
        editRecord(
          readed.memoryData,
          config.readConfig.memory.fieldCd1,
          config.readConfig.memory.fieldCd2,
        );
 
        if (config.readConfig.readType === "both") {
          editRecord(
            readed.idm,
            config.readConfig.idm.fieldCd1,
            config.readConfig.idm.fieldCd2,
          );
        }
      }
    };
 
    readCard();
  };
 
  return (
    <div style={{ margin: "8px 16px" }}>
      <div>
        <button type="button" onClick={readbtnHandler}>
          カード読取
        </button>
      </div>
    </div>
  );
}
 
export function AppIndex({ PLUGIN_ID }: { PLUGIN_ID: string }) {
  const [message, setMessage] = useState("");
 
  // pluginに保存した設定情報を取得
  const config = restoreStorage(PLUGIN_ID, cardReaderPluginConfigSchema);
  const app = kintone.app.getId();
  if (!app) throw new Error("アプリケーションのIDが取得できません。");
 
  useEffect(() => {
    // クエリパラメータを取得
    const searchParams = new URLSearchParams(window.location.search);
    const autorun = searchParams.get("autorun");
    if (autorun === "true") {
      btnCardReaderClicked();
    }
  }, []);
 
  /**
   * カード読取処理
   */
  const btnCardReaderClicked = () => {
    const readCard = async () => {
      // カードリーダーへ接続
      setMessage("カードリーダーに接続中…");
      const webUsbCardreader = await WebUsbCardReader.connect(
        import.meta.env.VITE_WEBUSB_DEBUG === "true",
      );
 
      if (!webUsbCardreader) {
        alert("カードリーダーを接続してください。");
        setMessage("カードリーダーを接続してください。");
        return;
      }
 
      // カードを読取り
      setMessage("カードを置いてください。");
      let idm: string | undefined = "";
      let memory: string | undefined = "";
      while (!idm) {
        try {
          // IDm読み取り
          idm = (await readIdm(webUsbCardreader))?.idm;
          // memory読取設定の場合、続けて読み取りを行う
          if (
            (config.readConfig.readType === "memory" ||
              config.readConfig.readType === "both") &&
            idm
          ) {
            setMessage("カード読取中…。");
            memory = (
              await readIdmAndMemory(webUsbCardreader, config.readConfig.memory)
            )?.memoryData;
          }
        } catch (e: unknown) {
          setMessage(`エラーが発生しました:\n${(e as Error).message}`);
          throw e;
        }
      }
 
      // 読み取り結果のメッセージを編集
      const idmMessageContent = `${config.readConfig.readType !== "memory" ? `IDm : ${idm}` : ""}`;
      const memoryMessageContent = `${config.readConfig.readType !== "idm" ? `${config.readConfig.memory.name} : ${memory}` : ""}`;
      const messageContent =
        idmMessageContent +
        (idmMessageContent ? "\n" : "") +
        memoryMessageContent;
 
      // 登録確認
      if (
        config.useCaseConfig.useCaseType !== "record" &&
        config.useCaseConfig.list.confirmBeforeRegist
      ) {
        const confirmResult = confirm(
          `登録してよろしいですか?\n${messageContent}`,
        );
        if (!confirmResult) {
          window.location.reload();
          return;
        }
      }
 
      // 登録処理
      setMessage(`登録中… ${messageContent}`);
      const record: KintoneRecord = {};
      if (
        config.readConfig.readType === "idm" ||
        config.readConfig.readType === "both"
      ) {
        record[config.readConfig.idm.fieldCd1] = { value: idm };
        if (config.readConfig.idm.fieldCd2) {
          record[config.readConfig.idm.fieldCd2] = { value: idm };
        }
      }
      if (
        config.readConfig.readType === "memory" ||
        config.readConfig.readType === "both"
      ) {
        record[config.readConfig.memory.fieldCd1] = { value: memory };
        if (config.readConfig.memory.fieldCd2) {
          record[config.readConfig.memory.fieldCd2] = { value: memory };
        }
      }
 
      try {
        await client.record.addRecord({ app, record });
        if (
          config.useCaseConfig.useCaseType !== "record" &&
          config.useCaseConfig.list.notifyAfterRegist
        ) {
          alert(`登録が完了しました!\n${messageContent}`);
        }
        const autoRunUrl = `${window.location.href}&autorun=true`;
        window.location.href = autoRunUrl;
      } catch (e: unknown) {
        setMessage(`登録失敗 ${messageContent}`);
        alert("登録に失敗しました(重複登録の可能性があります)");
        window.location.reload();
      }
    };
 
    readCard();
  };
 
  return (
    <div style={{ margin: "8px 16px" }}>
      <button
        className="kintoneplugin-button-normal"
        type="button"
        onClick={btnCardReaderClicked}
      >
        カード読み取り開始
      </button>
      {message && <p className="message-normal-small">{message}</p>}
    </div>
  );
}

  • WebUSBAPIを用い、FeliCaのIDmとメモリを読み出すkintoneプラグインを作成しました。
  • プラグインの設定画面は、react-hook-formとzodを使って、簡潔・保守性の高い実装ができたと思います。今後も役立てたい。
  • WebUSBの操作は、低水準なデータの操作も多く、typescriptでこういう実装したのは初めてだったので手こずりました。

- コメント -

    このサイトではcookieを利用して、サイト訪問者の識別に利用します。 cookieの利用に同意いただくことで、サイト訪問者は記事のいいね機能等をご利用いただけます。 なお、サイト運営者はアクセス統計としてcookieの情報を利用する場合があります。