ElectronでNFCを使った書籍貸出管理を作ってみる

今この世の中は様々なカードが出回っており、企業でも「入出用カード」「勤怠管理カード」「SuicaやPasmo」「プリンタセキュリティ印刷用カード」などなど様々なカードが存在します。これらのカードはFelica(NFC Type F)やNFCと呼ばれ、中にidmと呼ばれる一意の情報が入っています。

これらのカード情報を利用して現在、Accessで書籍管理アプリを作り運用していますが、使用しているSheepSmartCard.dllが64bit VBAに対応しておらず、64bit主流の現在これを用いてVBAを使ったプログラムを作る事が出来ません。(2020年8月現在、なんと64bit対応版のSheepSmartCard64.dllがリリースされてました!!)

今回、Electronを利用してカードリーダを作成し、64bit環境で活用できる書籍管理アプリを作りなおしてみようと思います(貸出と返却の2機能)。

今回のプログラムは前回のElectronでMySQLへ接続するアプリを作るの続編となります。大多数のコードやモジュールはと同じです。それらについては前回の記事を参考にインストールしてみてください(特にkeytarは注意が必要です)。

今回使用するものとライブラリ

カードリーダーとドライバー

Node.jsモジュール

  • keytarモジュール – OS標準のパスワード管理システムを利用して、安全にパスワード等のやり取りをする
  • electron-store – 各種設定情報を格納する為のモジュール
  • Promise-MySQLモジュール – Promiseが使えるMySQLへアクセスする為のモジュール
  • jQueryモジュール – ElectronでjQueryを使えるようにする為のモジュール
  • electron-rebuild - nfc-pcscモジュールをリビルドするために使用します。
  • nfc-pcsc - Node.jsでPC/SC規格準拠のカードリーダーを読み書きする為のモジュール

※ただし注意点があって、nfc-pcscのライブラリであるpcscliteが原因で、Windows10 32bitでは、electron-rebuildを行ってもきちんと動作せずアプリ自体が起動しません(electron v3.0.0で検証してます)。これについての解決法は後述(nfc-pcscのモジュールをリビルドするを参照)。

HTML側で利用するライブラリ

事前準備

今回導入するnfc-pcscモジュールですが、RC-S380の読み書きの為のモジュールで、Windowsでは認識しましたが、Macでは認識せず・・・nfcpyでは使えましたが....

ということなので、今回のElectronアプリはWindows専用として構築します。他のカードリーダー(例:ACR122)などの場合には、macOSでもnfc-pcscが利用できるかもしれません。RC-S380はPC/SC規格準拠していても、nfc-pcscでは利用できませんでした。

図:nfcpyだときちんと認識するんです・・・

NFCモジュールを追加する

まずは、プロジェクトファイル作成、package.json作成、index.htmlおよびindex.jsを作成して置きます。ターミナルを起動して以下のコマンドで今回利用する予定のモジュールを入れておきましょう。ただし、ビルドに必要なvc++のbuild toolが2017でないと通らないっぽいので、ターミナルからnpm config set msvs_version 2017でセットしておく必要があるようだ。

npm install nfc-pcsc --save

これだけで、RC-S380リーダーを接続し、FelicaのカードやSuicaのカードをかざせば、idm情報が取得出来ます。

図:非接触でidmの取得が出来ました。

インストール時にエラー

npm i nfc-pcsc --saveでインストール時に、node-gypでエラーが出る事がある。下記のようなエラー(MSB4019というエラー

C:\Users\ユーザ名\Documents\ootebook\node_modules\@pokusew\pcsclite\build\pcsclite.vcxproj(21,3): error MSB4019: インポ ートさ
れたプロジェクト "C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\Common7\IDE\VC\VCTargets\Microsoft.Cpp.Defau
lt.props" が見つかりませんでした。<Import> 宣言のパスが正しいかどうか、およびファイルがディスクに存在しているかどうかを 確認してください。

実際に、Microsoft Build Tool 2017をインストール済みにも関わらずファイルが無いという。エラーとなってる.propsファイルを見ようと思った所、そもそもProfessional以下のフォルダではなく、実際に存在したのは、BuildToolsフォルダであった。つまり、ビルド用の設定パスが間違ってる(というか、Visual Studio 2017 Professionalでないとビルドできないようになってるのかな?)。どうも、pcsclite.vcxprojが参照してるレジストリのvctargetspathがオカシイ場合に発生するみたい。過去にVisual Studio 2017 Professionalでもインストールしてたかなぁ・・・

レジストリのHKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\MSBuild\ToolsVersions\4.0VCTargetsPathを文字列で作成し、値を$(MSBuildExtensionsPath32)\Microsoft.Cpp\v4.0\にすれば良いらしいが今回は以下の手順でクリアした。

ということで、このケースに遭遇した場合には、以下の手順でBuildToolsフォルダをコピーしてあげれば無事にインストールが可能

  1. C:\Program Files (x86)\Microsoft Visual Studio\2017\を開く
  2. BuildToolsフォルダがあるので、コピーする
  3. コピーしたフォルダをProfessionalにリネームする
  4. 再び、npm i nfc-pcsc --saveを実行して、インストールする

図:無事にインストールが出来た

モジュールをリビルドする

nfc-pcscモジュールはNode.jsではそのまま利用が可能ですが。Electronではそのままですとエラーが出て利用が出来ません。そこで、electron-rebuildを用いてリビルドをする必要があります。あらかじめこちらのサイトを参照して、electron-rebuildをインストールをしておきましょう。

リビルドする手順は以下の通り

  1. ターミナルを起動する
  2. プロジェクトフォルダに入る
  3. electron-rebuild -w nfc-pcscを実行し、リビルドする

ただし、Windows10 32bitではこれでは足りず、以下の作業を行う必要があります。pcscliteというモジュールがエラーの原因です。

  1. ターミナルを起動する
  2. プロジェクトフォルダに入り、node_modules⇒@pokusew⇒pcscliteに入る
  3. node-gyp configureを実行する
  4. プロジェクトフォルダ直下に戻り、electron-rebuild -w pcscliteを実行する
  5. リビルドが完了したら、RC-S380を接続した状態で、electron .を実行すること(未接続だと延々にアプリは起動しない)。
  6. Windows10 64bitでは同様の症状は確認されていない。32bitだけ挙動が異なるようだ。

使用するDBファイルとプロジェクトファイル

  • 前回使用したuseridテーブルfelica列(VARCHAR型)を追加し、各人のidm情報を格納しています。idmが一致しないと書籍を借りることができません。必ず全ユーザ分登録が必要です。
  • 新たに貸出記録用のテーブルであるrent_booksテーブルを追加しています。
  • また、書籍を廃棄する時に記録するdisp_booksテーブルを追加しています。
  • 貸し出せる書籍の様々な情報を格納したbookmasterテーブルを追加しています。

今回新規に追加したMySQL用のテーブルデータはこのファイルです。今回はHeidiSQLにてエクスポートしています。データの中身は空っぽです。

図:useridテーブルにfelica列の追加とidmの登録を忘れずに

fontawesomeについて

プログラム的には全く必要ないのですが、ちょっとだけ格好よくする為に、今回fontawesomeを導入しています。テキストボックスの中にバーコードのアイコンを表示したい為です。fontawesome free for the webをクリックしてダウンロードして作業をします。

  1. cssフォルダにall.cssを入れます。
  2. index.htmlと同じフォルダ内にwebfontsフォルダをコピーしておきます。
  3. index.htmlのヘッダに1.をロードするコードを追記します(<link rel="stylesheet" href="css/all.css" rel="stylesheet">
  4. 以下のスタイルシートを追加しておきます。
.fontawe:after{
        display:block;
        content:"\f02a";
        font-weight:900;
        font-family: "Font Awesome 5 Free";
        width:20px;
        height:20px;
}

これで、classがfontaweのものにバーコードのアイコンが入るようになります。アイコンはcontentにバックスラッシュ付きでUnicode指定してあげます。

図:バーコードがテキストボックスに表示された

flipclock.jsについて

これもプログラム的には全く必要のないものですが、時計の表示をする為に用いてるもので、昔のパタパタめくれる時計表記を実現するライブラリです。

  1. 今回は、exampleのtwenty-four-hour-clock.htmlを利用してみました。
  2. jsおよびcssフォルダにそれぞれファイルをflipclock.jsとflipclock.cssを格納しておく
  3. index.htmlにそれぞれロードするコードを追記
  4. 初期化コードは以下のようなもの。また、表示を大きくするために追加のスタイルシートを追記
var clock;

$(document).ready(function() {
    clock = $('.clock').FlipClock({
        clockFace: 'TwentyFourHourClock'
    });
});

追記するCSSで表示倍率等を調整

.clock{
    zoom:1.8;
    -moz-transform:scale(0.5);
    text-align:center;
    display:inline-block;
    width:auto;
}

このライブラリはヘッダではなくbody以下にscriptタグでロードさせる必要がある点に注意。

コマンドプロンプトの日本語が文字化け

普段はWindowsではなくmacOSで開発を行っているので、気にしていなかった事なのですが、Windowsのコマンドプロンプトで作業をしていると、console.logで出した文字や、サーバから受け取ったデータが文字化けます。これは、コマンドプロンプトの文字コードがUTF-8ではない事が原因で、以下のコマンドを入力して文字コードを変更してからであれば、正しく表示されます。

chcp 65001

元のShift-JISに戻す場合には

chcp 932

でオッケー。ただ毎回入れるのも面倒なので、自分はcmd.exeのショートカットに以下の細工をしてデフォルトでUTF-8にしています。

  • リンク先は%windir%\system32\cmd.exe /K "chcp 65001"とする
  • 作業フォルダは%HOMEDRIVE%%HOMEPATH%とする

図:ショートカットの設定

図:文字化けるとこうなる

DATETIME型のフィールドにNULL値を入れるのを許可する

MySQLはDATETIME型フィールドにNull値許可をしても、そのままでは空ではデータをInsertすることが出来ずにエラーとなります。これは初期値が厳格なモードになっており、おかしな日付形式とみなされ拒否されているからです。これを解除する方法は以下の通り。

  1. mysqld.cnfの中にsql_mode = ''を追記する
  2. 再起動する

これだけです。

反映させる前に、ターミナルからMySQLにログインして確認してみると良いです。確認するコマンドは以下の通り。

SELECT @@GLOBAL.sql_mode;
+--------------------------------------------+
| @@GLOBAL.sql_mode                          |
+--------------------------------------------+
| STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION |
+--------------------------------------------+

これが設定値を加えて再起動すると空になり、Nullでも入力ができるようになります。厳格モードがあることには一定の意味があるので、個人的にはダミーの日付とか入れていますが、利便性が悪いのは事実なので、この設定を入れておき、必ずUI側できっちりvalidationしておくことをお忘れなく。

ソースコード

NFC読み書き部分

//nfc関係モジュールの読み込み
const { NFC } = require('nfc-pcsc');
const nfc = new NFC(); 

//カードリーダー読み取り時
nfc.on('reader', reader => {
  //カードリーダー接続時
  mainWindow.webContents.send('message', `${reader.reader.name}  が接続されました。`);

  //カード読み取り時
    reader.on('card', card => {
    //idmだけを取り出す
    var nfc = card.uid;

    //レンダラプロセス側で処理をする
    mainWindow.webContents.send('nfcset', nfc);
    });

  //カードを外した時のイベント
    reader.on('card.off', card => {
        console.log(`${reader.reader.name}  card removed`, card);
    });

  //カード読み取り時エラー
    reader.on('error', err => {
        console.log(`${reader.reader.name}  an error occurred`, err);
    });

  //リーダーを外してしまった時
    reader.on('end', () => {
    mainWindow.webContents.send('message', `${reader.reader.name}  が外されました。`);
    });
});


//カードリーダ読み取り失敗時
nfc.on('error', err => {
    console.log('an error occurred', err);
});
  • 今回のメインディッシュ部分。とってもシンプル。常に後ろで待機しているので、レンダラプロセス側で何もせずとも、NFCカードリーダにFelicaをかざせば、自動応答し、レンダラプロセス側にダイアログが出る仕組みにしています。
  • Electron起動後にカードリーダを接続・取り外しを行うと、メインプロセス側で認識し応答しますので、それぞれのイベントに記述をしておくと良いでしょう。
  • idmなどのデータは、JSONでcardに返ってくるので、この中からuidを拾えば、idm情報を取得可能です。

メインプロセス側(index.js)

'use strict';

//標準モジュールの宣言
const electron = require('electron');
const { app } = require('electron');
const BrowserWindow = electron.BrowserWindow;

//Node.js側とHTML側で通信をするモジュール
const ipcMain = require('electron').ipcMain;

//追加モジュールの宣言
const keytar = require('keytar');
const mysql = require('promise-mysql');
const Store = require('electron-store');
const store = new Store();

//nfc関係モジュールの読み込み
const { NFC } = require('nfc-pcsc');
const nfc = new NFC();

// メインウィンドウはグローバル宣言
let mainWindow = null;
let setWindow = null;
let bksWindow = null;
let regWindow = null;
let dispWindow = null;

//二重起動の防止
const doubleboot = app.requestSingleInstanceLock();
if(!doubleboot){
  app.quit();
}

app.on('ready', function() {
  electron.session.defaultSession.clearCache(() => {})
  // メイン画面の表示。ウィンドウの幅、高さを指定できる
  mainWindow = new BrowserWindow({
  	'width': 1150,
  	'height': 750,
  	'autoHideMenuBar':true,
  	//nodeIntegrationを有効にしないとrenderProcessでrequireを使えない。v5.0.0ではデフォルトで廃止
  	webPreferences: {
        nodeIntegration: true
    },
    'icon': __dirname + '/images/books.png',
    'resizable':false,
    'fullscreenable':false,
    'fullscreen':false
  });

  //セッティングウィンドウ
  setWindow = new BrowserWindow({
    'width': 520,
  	'height': 400,
  	'autoHideMenuBar':true,
  	//nodeIntegrationを有効にしないとrenderProcessでrequireを使えない。v5.0.0ではデフォルトで廃止
  	webPreferences: {
        nodeIntegration: true
    },
    'icon': __dirname + '/images/set.png',
    'resizable':false,
    'fullscreenable':false,
    'fullscreen':false,
    'alwaysOnTop':true,
    'modal':true,
    'parent':mainWindow,
    'show':false,
    'closable':false
  });

  //初期ページの表示
  mainWindow.loadURL('file://' + __dirname + '/index.html');

  //キー管理情報が存在しているかどうかで判定
  setWindow.loadURL('file://' + __dirname + '/setting.html');
  if(store.get("id") == "undefined"){
    setWindow.show();  //idがないので最初から表示
  }

  //mainWindow.webContents.openDevTools();
  mainWindow.on('closed', function() {
    mainWindow = null;
  });
  setWindow.on('closed', function() {
    //キャッシュを捨てる
    electron.session.defaultSession.clearCache(() => {})
    setWindow = null;
  });
});

// 全てのウィンドウが閉じたときの処理
app.on('window-all-closed', () => {
  // macOSの時以外はアプリケーションを終了させます(osxだとドックに残る)
  //if (process.platform !== 'darwin') {
    app.quit();
  //}
});

//ウィンドウをコントロールする
ipcMain.on('closeset', function( event, args ){

  //コマンド名によって処理を開始
  switch(args){
    case "setting":
      //セッティングウィンドウを非表示にする
      setWindow.hide();
      break;
    case "open":
      //セッティングウィンドウを表示する
      setWindow.show();
      break;
    case "bookman":
      //書籍検索ウィンドウを表示する
      bksWindow = new BrowserWindow({
        'width':1100,
        'height':600,
        'autoHideMenuBar':true,
        webPreferences: {
            nodeIntegration: true
        },
        'resizable':true,
        'fullscreenable':false,
        'fullscreen':false,
        'parent':mainWindow,
        'show':false,
      });

      //書籍検索用ページの表示
      bksWindow.loadURL('file://' + __dirname + '/booksearch.html');

      //デベロッパーツールを有効化
      //bksWindow.webContents.openDevTools();

      bksWindow.on('closed', function() {
        //キャッシュを捨てる
        electron.session.defaultSession.clearCache(() => {})
        bksWindow = null;
      });

      bksWindow.show();
      break;
    case "regbook":
      //書籍登録ウィンドウを表示する
      regWindow = new BrowserWindow({
        'width':1100,
        'height':600,
        'autoHideMenuBar':true,
        webPreferences: {
            nodeIntegration: true
        },
        'resizable':true,
        'fullscreenable':false,
        'fullscreen':false,
        'parent':mainWindow,
        'show':false,
      });

      //書籍検索用ページの表示
      regWindow.loadURL('file://' + __dirname + '/regbooks.html');

      //デベロッパーツールを有効化
      //regWindow.webContents.openDevTools();

      regWindow.on('closed', function() {
        //キャッシュを捨てる
        electron.session.defaultSession.clearCache(() => {})
        regWindow = null;
      });

      regWindow.show();

      break;
    case "dispbook":
      //書籍廃棄登録ウィンドウを表示する
      dispWindow = new BrowserWindow({
        'width':1100,
        'height':600,
        'autoHideMenuBar':true,
        webPreferences: {
            nodeIntegration: true
        },
        'resizable':true,
        'fullscreenable':false,
        'fullscreen':false,
        'parent':mainWindow,
        'show':false,
      });

      //書籍検索用ページの表示
      dispWindow.loadURL('file://' + __dirname + '/dispbooks.html');

      //デベロッパーツールを有効化
      //dispWindow.webContents.openDevTools();

      dispWindow.on('closed', function() {
        //キャッシュを捨てる
        electron.session.defaultSession.clearCache(() => {})
        dispWindow = null;
      });

      dispWindow.show();
      break;
    default:
      break;
  }
});

//カードリーダー読み取り時
nfc.on('reader', reader => {
    //カードリーダー接続時
    console.log(`${reader.reader.name}  device attached`);
    mainWindow.webContents.send('message', `${reader.reader.name}  が接続されました。`);

    //カード読み取り時
    reader.on('card', card => {
    //idmだけを取り出す
    var nfc = card.uid;
        console.log(`${reader.reader.name}  card detected`, card);
    
    nfc = nfc.toUpperCase();

    //レンダラプロセス側で処理をする
    mainWindow.webContents.send('nfcset', nfc);
    });

  //カードを外した時のイベント
    reader.on('card.off', card => {
        console.log(`${reader.reader.name}  card removed`, card);
    });

  //カード読み取り時エラー
    reader.on('error', err => {
        console.log(`${reader.reader.name}  an error occurred`, err);
    });

  //リーダーを外してしまった時
    reader.on('end', () => {
        console.log(`${reader.reader.name}  device removed`);
    mainWindow.webContents.send('message', `${reader.reader.name}  が外されました。`);
    });
});


//カードリーダ読み取り失敗時
nfc.on('error', err => {
    console.log('an error occurred', err);
});

//keytar関係をコントロールする
ipcMain.on('keytar', function( event, args ){
  //配列データを受け取る
  var array = args;

  //サービス名を構築する
  var servicename = "bookman_" + array[2];

  //キーワードを保存する
  keytar.setPassword(servicename,array[2],array[3]);

  //他の情報はelectron-storeで保存する
  store.set("server",array[0]);
  store.set("dbname",array[1]);
  store.set("id",array[2]);

  //セッティングウィンドウを非表示にする
  setWindow.hide();
});

//MySQL読み書き用
ipcMain.on('async', function( event, args, args2){
  //引数に応じて処理を分岐
  switch(args){
    //書籍の貸し出し・返却用のスレッド
    case "bookkeeper":
      var flg = args2[0];
      var isbn = args2[1];
      var idm = args2[2];

      //idmナンバーが登録済みかどうかチェックする
      chkUserIdm(idm,function (ret){
        var json = ret;

        //ステータスで判定して処理
        switch(json.status){
          case "NOSET":
            //接続設定がないため繋がなかった場合の処理
            mainWindow.webContents.send('message', "DB接続設定がありませんよ");
            break;
          case "ERR":
            //エラーが発生した場合の処理
            mainWindow.webContents.send('message', json.error);
            break;
          case "OK":
            //レコード数が1以上ならば進む。0であれば登録無し
            var reccnt = ret.count;

            if(reccnt > 0){
              //レコードがあったので次に対象のISBNコードの書籍が登録済みかどうかチェックする
              bookexplorer(isbn,function (ret){
                var json = ret;

                //ステータスで判定して処理
                switch(json.status){
                  case "NOSET":
                    //接続設定がないため繋がなかった場合の処理
                    mainWindow.webContents.send('message', "DB接続設定がありませんよ");
                    break;
                  case "ERR":
                    //エラーが発生した場合の処理
                    mainWindow.webContents.send('message', json.error);
                    break;
                  case "OK":
                    //レコード数が1以上ならば進む。0であれば登録無し
                    var recbook = ret.count;
                    if(recbook > 0){
                      //処理フラグに応じて貸し出し・返却の登録
                      if(flg == 0){
                        //貸し出し処理の実行
                        bookrental([isbn,idm],function (ret){
                          var json = ret;
                          switch(json.status){
                            case "NOSET":
                              //接続設定がないため繋がなかった場合の処理
                              mainWindow.webContents.send('message', "DB接続設定がありませんよ");
                              break;
                            case "ERR":
                              //エラーが発生した場合の処理
                              mainWindow.webContents.send('msgend', json.msg);
                              break;
                            case "OK":
                              //完了メッセージを送る
                              event.sender.send('endbookman', json.msg);
                          }
                        });
                      }else{
                        //返却処理を実行
                        bookreturn([isbn,idm],function (ret){
                          var json = ret;
                          switch(json.status){
                            case "NOSET":
                              //接続設定がないため繋がなかった場合の処理
                              mainWindow.webContents.send('message', "DB接続設定がありませんよ");
                              break;
                            case "ERR":
                              //エラーが発生した場合の処理
                              mainWindow.webContents.send('msgend', json.msg);
                              break;
                            case "OK":
                              //完了メッセージを送る
                              event.sender.send('endbookman', json.msg);
                          }
                        });
                      }
                    }else{
                      mainWindow.webContents.send('message', "DBに該当の本は未登録です。");
                      return;
                    }
                }
              });
            }else{
              mainWindow.webContents.send('message', "DBに該当のFelicaナンバーが見つかりませんでした。");
              return;
            }

            break;
        }
      });
      break;
    case "dbname":
      //DB接続設定をもらう
      //レンダラー側に送りつける設定を集める
      var array = [];
      array.push(store.get("server"));
      array.push(store.get("dbname"));
      array.push(store.get("id"));

      //idを元にkeyを探索する
      if(store.get("id") != "undefined" || store.get("id") != null){
        var servicename = "bookman_" + store.get("id");

        if(keytar.findPassword(servicename)){
          const secret = keytar.getPassword(servicename,store.get("id"));
          secret.then((result) => {
              array.push(result);
              event.sender.send('init', array);
              return;
          });
        }else{
          array.push("");
          event.sender.send('init', array);
        }

      }else{
        array.push("");
        event.sender.send('init', array);
      }
      break;
  }
});

//実際の返却処理
function bookreturn(args,callback){
  var isbn = args[0];
  var felica = args[1];
  var seatnum = "";
  var connection;
  var retman = {};
  var pass = [];
  var result = "";
  var pafu = "";

  //サービス名を構築する
  var servicename = "bookman_" + store.get("id");

  //接続設定があるかないか判定
  if(store.get("id") == "undefined" || store.get("id") == null){
    //エラーでコールバックさせる
    retman.status = "NOSET";
    callback(retman);
    return;
  }

    var secret = keytar.getPassword(servicename,store.get("id"));
    secret.then((result) => {
        //パスワードを取得する
        pass = result;

        //MySQLに接続してデータを取得する
        //createConnectionでは接続が時々切れる
        mysql.createConnection({
            host: store.get("server"),
            port: 3306,
            user: store.get("id"),
            password: pass,
            database: "mhr"
        }).then(function(conn){
            //貸出記録の存在有無チェック
            connection = conn;

            //該当のIDでの登録数が何件かしらべる
            var result = connection.query("SELECT * FROM mhr.rent_books where felica = '" + felica + "' and isbn = " + isbn + " and rentflg = 0;");
            return result;
        
        }).then(function(rows){
            //データ件数を取得する
            var dlength = rows.length;
        
            //返り値を判定する
            if(dlength > 0){
                //0件なので貸し出し登録を追加する
                //貸し出し履歴を追加する
                var result = connection.query('update mhr.rent_books set returnday = ?, rentflg = ? where felica = ? and isbn = ?;',
                [new Date(),1,felica,isbn],
                    (err,result)=> {
                        //エラーが発生した場合
                        if (err) {
                            console.log("接続エラー");
                            retman.status = "ERR";
                            retman.msg = err;
                            console.log(err);
                            connection.end();
                            callback(retman);
                            return;
                        }

                        //取得データを返す
                        retman.status = "OK";
                        retman.msg = "返却処理が完了しました。";
                        callback(retman);
                        connection.end();
                        return;
                    }
                );
            }else{
                retman.status = "ERR";
                retman.msg = "該当のFelicaナンバーで対象の本の貸し出したままの履歴がありませんでした。既に返却済みか?貸出記録が失敗した可能性があります。";
                callback(retman);
                connection.end();
                return;
            }	  
        }).catch(function(error){
            if (connection && connection.end) connection.end();
            //logs out the error
            retman.status = "ERR";
            retman.msg = "接続エラーですよ。パスワードが違うとかサーバアドレス間違ってるとか、ありませんか?";
            callback(retman);
            return;
        });
    });
};

//実際の貸し出し処理
function bookrental(args,callback){
  var isbn = args[0];
  var felica = args[1];
  var seatnum = "";
  var connection;
  var retman = {};
  var pass = [];
  var result = "";
  var pafu = "";

  //サービス名を構築する
  var servicename = "bookman_" + store.get("id");

  //接続設定があるかないか判定
  if(store.get("id") == "undefined" || store.get("id") == null){
    //エラーでコールバックさせる
    retman.status = "NOSET";
    callback(retman);
    return;
  }

  var secret = keytar.getPassword(servicename,store.get("id"));
  secret.then((result) => {
      //パスワードを取得する
      pass = result;

      //MySQLに接続してデータを取得する
      //createConnectionでは接続が時々切れる
      mysql.createConnection({
          host: store.get("server"),
          port: 3306,
          user: store.get("id"),
          password: pass,
          database: "mhr"
      }).then(function(conn){
          connection = conn;

          //該当のIDでの登録数が何件かしらべる
          var result = connection.query("SELECT * FROM mhr.rent_books where felica = '" + felica + "' and isbn = " + isbn + " and rentflg = 0;");
          return result;

      }).then(function(rows){
          //データ件数を取得する
          var dlength = rows.length;

          //返り値を判定する
          if(dlength == 0){
             //0件なので貸し出し登録を追加する
             //貸し出し履歴を追加する
             var result = connection.query('insert into mhr.rent_books (felica,isbn,rentday,returnday,rentflg) values (?,?,?,?,?);',
               [felica,isbn,new Date(),new Date(),0],
               (err,result)=> {
                 //エラーが発生した場合
                 if (err) {
                   console.log("接続エラー");
                   retman.status = "ERR";
                   retman.msg = err;
                   console.log(err);
                   connection.end();
                   callback(retman);
                   return;
                 }

                 //取得データを返す
                 retman.status = "OK";
                 retman.msg = "貸し出し登録完了しました。";
                 callback(retman);
                 connection.end();
                 return;
               }
              );
          }else{
             retman.status = "ERR";
             retman.msg = "該当のFelicaナンバーで対象の本の貸し出したままの履歴があります。まずは返却処理をしてください。";
             callback(retman);
             connection.end();
             return;
          }
      }).catch(function(error){
          if (connection && connection.end) connection.end();
          //logs out the error
          retman.status = "ERR";
          retman.msg = "接続エラーですよ。パスワードが違うとかサーバアドレス間違ってるとか、ありませんか?";
          callback(retman);
          return;
      });
  });
}

//対象のISBNコードでbookmasterを検索する
function bookexplorer(args,callback){
  var connection;
  var retman = {};
  var pass = [];
  var result = ""

  //felicaナンバーを取得する
  var isbn = args;

  //サービス名を構築する
  var servicename = "bookman_" + store.get("id");

  //接続設定があるかないか判定
  if(store.get("id") == "undefined" || store.get("id") == null){
    //エラーでコールバックさせる
    retman.status = "NOSET";
    callback(retman);
    return;
  }

  //パスワードを取得する
  var secret = keytar.getPassword(servicename,store.get("id"));
  secret.then((result) => {
      //パスワードを取得する
      pass = result;

      //MySQLに接続してデータを取得する
      //createConnectionでは接続が時々切れる
      mysql.createConnection({
          host: store.get("server"),
          port: 3306,
          user: store.get("id"),
          password: pass,
          database: "mhr"
      }).then(function(conn){
          //レコード用変数
          var rlength = 0;    //レコードの数
          var record = ""     //レコードデータを格納する

          //クエリの実行
          connection = conn;
          var result = connection.query("SELECT * FROM mhr.bookmaster where isbn = " + isbn  + ";", function (err, rows, fields) {
            //エラーが発生した場合
            if (err) {
              console.log("接続エラー");
              retman.status = "ERR";
              retman.error = error;
              callback(retman);
              connection.end();
              return;
            }

            //レコードデータを格納する
            rlength = rows.length;

            //取得データを返す
            retman.status = "OK";
            retman.count = rlength;
            retman.recman = rows;
            console.log("OK");
            callback(retman);
            connection.end();
            return;
          });
      }).catch(function(error){
          if (connection && connection.end) connection.end();
          //logs out the error
          retman.status = "ERR";
          retman.error = "接続エラーですよ。パスワードが違うとかサーバアドレス間違ってるとか、ありませんか?";
          callback(retman);
          return;
      });
  });
}

//IDMナンバー登録済みのユーザがいるかどうかチェック
function chkUserIdm(args,callback){
  var connection;
  var retman = {};
  var pass = [];
  var result = ""

  //felicaナンバーを取得する
  var felica = args;

  //サービス名を構築する
  var servicename = "bookman_" + store.get("id");

  //接続設定があるかないか判定
  if(store.get("id") == "undefined" || store.get("id") == null){
    //エラーでコールバックさせる
    retman.status = "NOSET";
    callback(retman);
    return;
  }

  //パスワードを取得する
  var secret = keytar.getPassword(servicename,store.get("id"));
  secret.then((result) => {
      //パスワードを取得する
      pass = result;

      //MySQLに接続してデータを取得する
      //createConnectionでは接続が時々切れる
      mysql.createConnection({
          host: store.get("server"),
          port: 3306,
          user: store.get("id"),
          password: pass,
          database: "mhr"
      }).then(function(conn){
          //レコード用変数
          var rlength = 0;    //レコードの数
          var record = ""     //レコードデータを格納する

          //クエリの実行
          connection = conn;
          var result = connection.query("SELECT * FROM mhr.userid where felica = '" + felica  + "';", function (err, rows, fields) {
            //エラーが発生した場合
            if (err) {
              console.log("接続エラー");
              retman.status = "ERR";
              retman.error = error;
              callback(retman);
              connection.end();
              return;
            }

            //レコードデータを格納する
            rlength = rows.length;

            //取得データを返す
            retman.status = "OK";
            retman.count = rlength;
            retman.recman = rows;
            console.log("OK");
            callback(retman);
            connection.end();
            return;
          });
      }).catch(function(error){
          if (connection && connection.end) connection.end();
          //logs out the error
          retman.status = "ERR";
          retman.error = "接続エラーですよ。パスワードが違うとかサーバアドレス間違ってるとか、ありませんか?";
          callback(retman);
          return;
      });
  });
}
  • データベース接続は前回同様、promise-mysqlによる同期処理で接続させています。
  • ISBNコード登録の有無チェック、NFCのidm登録の有無チェック、貸出中書籍の有無チェックなどが入っています。
  • このプログラムには書籍検索、書籍登録、書籍廃棄の3つの機能はまだ未実装です。
  • keytarモジュールおよびnfc-pcscモジュールがきちんとネイティブ環境用にrebuildされていれば、パスワード取得、NFCカード情報取得が動きます。このあたりで止まってる場合、きちんとrebuildされていないのが原因です。
  • NFCであればFelicaでなくともidmの読み取りは可能だと思います。
  • rent_booksテーブルに登録時には、返却日データもダミーで登録しています。rentflgにて返却済みかどうかを判定させています。

レンダラプロセス側(index.html)

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <script>
      var $ = jQuery = require("jquery")
    </script>

    <script type="text/javascript" src="js/jquery-ui.min.js"></script>
    <script src="js/flipclock.js"></script>
    <link rel="stylesheet" href="css/jquery-ui.css">
    <link rel="stylesheet" href="css/flipclock.css">
    <link rel="stylesheet" href="css/all.css" rel="stylesheet">
    <style>
      .parent{
        position:relative;
        text-align:center;
      }

      .clock{
        zoom:1.8;
        -moz-transform:scale(0.5);
        text-align:center;
        display:inline-block;
        width:auto;
      }

      .yellow_line{
        background:rgba(0, 0, 0, 0) linear-gradient(transparent 60%, #ffffbc 0%) repeat scroll 0 0;
        font-size:60px;
      }

      input[type="number"] {
        width: 50%;
        height: 50px;
        font-size:20px;
        border: 2px solid #aaa;
        border-radius: 4px;
        margin: 8px 0;
        outline: none;
        padding: 8px;
        box-sizing: border-box;
        transition: 0.3s;
      }

      input[type="number"] {
        width: 100%;
        border: 2px solid #aaa;
        border-radius: 4px;
        margin: 8px 0;
        outline: none;
        padding: 8px;
        box-sizing: border-box;
        transition: 0.3s;
      }

      input[type="number"]:focus {
        border-color: dodgerBlue;
        box-shadow: 0 0 8px 0 dodgerBlue;
        
      }

      .inputWithIcon input[type="number"] {
        padding-left: 40px;
        background: url(clear.png) no-repeat left center;
        cursor: pointer;
      }

      .inputWithIcon {
        position: relative;
      }

      .inputWithIcon i {
        position: absolute;
        left: 0;
        top: 8px;
        padding: 15px 8px;
        color: #aaa;
        transition: 0.3s;
        font-size:20px;
      }

      .inputWithIcon input[type="number"]:focus + i {
        color: dodgerBlue;
      }

      .inputWithIcon.inputIconBg i {
        background-color: #aaa;
        color: #fff;
        padding: 9px 4px;
        border-radius: 4px 0 0 4px;
      }

      .inputWithIcon.inputIconBg input[type="number"]:focus + i {
        color: #fff;
        background-color: dodgerBlue;
      }

      .bg-1{
        text-align:center;
        display:inline-block;
      }
     
      .fontawe:after{
      	display:block;
        content:"\f02a";
        font-weight:900;
        font-family: "Font Awesome 5 Free";
        width:20px;
        height:20px;
      }

          input:required:invalid, input:focus:invalid {
            background-image: url(/images/invalid.png);
            background-position: right top;
            background-repeat: no-repeat;
            color: red;
          }
          input:required:valid {
            background-image: url(/images/valid.png);
            background-position: right top;
            background-repeat: no-repeat;
            color: green;
          }

    </style>
    <script src="index.js"></script>
    <script>
        
      //nfcのコードを格納するグローバル変数
      var nfcnum = "";

      //ダイアログ実行トリガー
      var trigger = 0;

      //ダイアログ表示用
      $(function() {
        $( "#dialog" ).dialog({
          autoOpen: false,
          width:450,
          height: 200,
          title: "書籍の貸し出し・返却",
          close : function(){
            console.log("trigger:" + trigger);
            //nfcnumはクリアする
            nfcnum = "";

            //progress表示クリア
            document.getElementById("boxconn").style.display = "block";
            document.getElementById("progress").style.display = "none";

            //triggerの値を見て処理を分岐
            if(trigger == 0){
              //右上の✕がクリックされただけなので、普通に閉じる
            }else{
              nfcnum = "";
              trigger = 0;

              console.log("aaa")
              //ISBNコードをクリアする
              $('#isbncode').val('');
            }
          },
          open: function( event, ui ) {
            trigger = 0;
          },
          modal: true,
          show: {
            effect: "explode",
            duration: 500
          },
          hide: {
            effect: "explode",
            duration: 500
          }
        });
      });

      $(function() {
          $( "input[type=submit], a, button" )
            .button()
            .click(function() {
            });
      });

      //データテーブル初期化
      $(document).ready(function() {
        $('#table_id').DataTable({
          "lengthChange":false
        });
      });

      // IPC通信を行う
      var ipcRenderer = require( 'electron' ).ipcRenderer;
      window.onload = function () {
        //受信レンダラーの準備
        testAsync();
      };

      //メインプロセス側からの非同期に通信を受信待機させる(1回だけ)
      function testAsync() {
        //各種雑多なメッセージを受け取る
        ipcRenderer.on('message', function(event,arg) {
          alert(arg);
          document.getElementById("boxconn").style.display = "block";
          document.getElementById("progress").style.display = "none";
          return;
        });

        //エラーメッセージを処理する
        ipcRenderer.on('msgend', function(event,arg) {
          alert(arg);
          document.getElementById("boxconn").style.display = "block";
          document.getElementById("progress").style.display = "none";
          return;
        });

        //エラーメッセージを処理する
        ipcRenderer.on('endbookman', function(event,arg) {
          alert(arg);
          document.getElementById("boxconn").style.display = "block";
          document.getElementById("progress").style.display = "none";
          trigger = 1;
          $( "#dialog" ).dialog( "close" );
          return;
        });

        //nfcのidmを受け取った時に処理をする
        ipcRenderer.on('nfcset', function(event,arg) {
          //ISBNコードが空の場合
          var isbn = document.getElementById("isbncode").value;
          if(isbn == null || isbn == ""){
            alert("ISBNコードが空っぽか、おかしい値ですよ");
            $("#isbncode").focus();
            return;
          }
          
          //入力値が数値のみかチェック
          if(isNaN(isbn)){
            alert("数字じゃない文字が入っていますよ");
            $("#isbncode").focus();
            return;
          }
          
          //入力値が13桁なのかチェック
          var inlength = isbn.length;
          if(inlength != 13){
            alert("ISBNコードの文字数が不正です。");
            $("#isbncode").focus();
            return;
          }

          //idmコードを受け取り、nfcnumへ格納する
          nfcnum = "";
          nfcnum = arg;

          //処理確認ダイアログを表示する
          $( "#dialog" ).dialog( "open" );
          $( "#dialog" ).dialog("moveToTop");
          document.getElementById("dialog").focus();
          return;
        });

      }

      //LocalStorageへのデータの挿入
      function setData(key, data){
        localStorage.setItem(key, data);
      }

      //LocalStorageからのデータの取得
      function getData(key){
        var ret = localStorage.getItem(key);

        //null値判定
        if(ret == null){
          return "";
        }else{
          return ret;
        }
      }

      //接続設定ウィンドウを表示する
      function setman(){
        //メインプロセスに引数を送信
        ipcRenderer.send('closeset', "open");
      }

      //書籍検索窓を表示する
      function booksearch(){
        //メインプロセスに引数を送信
        ipcRenderer.send('closeset','bookman')
      }

      //書籍検索窓を表示する
      function registrybook(){
        //メインプロセスに引数を送信
        ipcRenderer.send('closeset','regbook')
      }

      //書籍検索窓を表示する
      function dispobook(){
        //メインプロセスに引数を送信
        ipcRenderer.send('closeset','dispbook')
      }

      //日付データを整形して返す関数
      function getDateman(dateman){
      	var date = new Date(dateman);
      	var year = date.getFullYear();
      	var month = date.getMonth() + 1;
      	var date = date.getDate();
      	if (month < 10) {
      		month = "0" + month;
      	}
      	if (date < 10) {
      		date = "0" + date;
      	}
      	var strDate = year + "年" + month + "月" + date + "日";
      	return strDate;
      }

      //メインプロセス側に貸し出し・返却処理を加える
      function updatebooks(flg){
        //dialog close
        document.getElementById("boxconn").style.display = "none";
        document.getElementById("progress").style.display = "block";

        //メインプロセスに渡す引数を設定
        var array = [];
        var validata = document.getElementById("isbncode").value;
        array.push(flg);      //処理フラグ
        array.push(validata); //ISBNコード
        array.push(nfcnum);   //NFCのIDMナンバー

        //メインプロセスに処理を実行
        ipcRenderer.send('async', "bookkeeper",array);
      }


    </script>

    <title>大手町ブックセンター</title>
  </head>

  <body>
    <div>
      <button onClick='setman()' id="setwindow" class="action" title='接続設定を表示'><img src="images/set.png" width="16" heigh="16">&nbsp;接続設定</button>
      <button onClick='booksearch()' id="bkswindow" class="action" title='書籍検索窓を表示'>書籍の検索</button>
      <button onClick='registrybook()' id="regwindow" class="action" title='書籍登録窓を表示'>書籍の登録</button>
      <button onClick='dispobook()' id="dispwindow" class="action" title='書籍廃棄窓を表示'>書籍の廃棄</button>
      <hr>
    </div>

    <div>
      <br>
      <center>
        <p>
          <strong id="view_clock" class="yellow_line"></strong>
        </p>
        <div class="parent">
    		    <div class="clock" style="margin:2em;"></div>
        </div>

        <div class="inputWithIcon">
          <input type="number" id="isbncode" class="inputBox" placeholder="ISBNコード" pattern="^([0-9]{13,13})$" autofocus required> 
          <i class="fontawe" ></i>
        </div>

      </center>

      <!-- 処理確認ダイアログ -->
      <div id="dialog" title="Basic dialog">
        <p>
        スキャンした書籍は借りますか?それとも返却しますか?
        </p>
        <p></p>
        <div class='boxContainer' id="boxconn" style="display:block" align="center">
          <div class='box2'>
            <span><button onClick='updatebooks(0)' id="button1" style="font-size: 16px;vertical-align: middle" class="ponyo" title='借ります'><img src='images/icon_check2.png' />&nbsp; 貸し出し</button></span>
          </div>
          <div class='box'>
            <span><button onClick='updatebooks(1)' id="button2" style="font-size: 16px;vertical-align: middle" class="ponyo" title='返却します'><img src='images/cross.png' />&nbsp; 返却</button></span>
          </div>
        </div>

        <div id="progress" style="display:none" align="center">
          <span id="progress">送信中・・・<img border='0' src='images/spinner.gif' width='32' height='32'></span>
        </div>
      </div>

  		<script type="text/javascript">
  			var clock;

  			$(document).ready(function() {
  				clock = $('.clock').FlipClock({
  					clockFace: 'TwentyFourHourClock'
  				});
  			});

            //リアルタイム日付を表示する
            timerID = setInterval('clockman()',500); //0.5秒毎にclock()を実行

            //ISBNコードテキストボックスにフォーカス移動
            $("#isbncode").focus();

            function clockman(){
              document.getElementById("view_clock").innerHTML = getNow();
            }

            function getNow() {
                var now = new Date();
                var year = now.getFullYear();
                var mon = now.getMonth()+1; //1を足すこと
                var day = now.getDate();

                //出力用
                var s = year + "年" + mon + "月" + day + "日";
                return s;
            }
  		</script>

  	</div>
  </body>
</html>
  • ボタンなどを配置せず、テキストボックス1個だけのシンプル仕様です。
  • バーコードリーダーでISBNコードを読み取った後、NFCリーダーにFelicaをかざすとスタートです。
  • ISBNコードのValidationが正しければ、貸出・返却用のダイアログが出てきます。
  • 正しくメインプロセス側で貸出と返却の処理がなされれば、メインプロセス側からメッセージが出て、ダイアログは閉じます。
  • 事前に接続設定にて、サーバアドレスやID、パスワード、データベース名を登録する必要があります。
  • パスワードはkeytarによって、資格情報マネージャへ格納されます。

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)