虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

Web NFCを試してみました 〜 動作確認とアプリケーション作成 〜

皆さんいかがお過ごしですか?ラボのおっくんです。
気温が高い時期になってきました。室内にいても気が付くと脱水から熱中症になることもあるそうですので、お気を付けください。

今回は、Web NFC を試してみましたので、そちらをレポートします。

最終的には、次のものができました。

f:id:toranoana-lab:20200727141916g:plain

以下順を追って説明します。

実行環境

開発環境

  • OS:macOS Catalina 10.15.4
  • Chrome 84.0.4147.89

確認用デバイス

  • OS:Android バージョン 10
  • Chrome 83.0.4103.106

NFC を備えたスマートフォンでないといけませんので、ご注意ください。

WebNFC とは

WebNFCは、「Google Chrome 81」のベータ版から追加された実験的な機能です。
NFC(Near Field Communication : 近距離無線通信)タグをブラウザから読み書きすることができます。

デバッグ環境準備

開発に当たり、開発環境で立ち上げた Web サーバーを Android が参照できるようにする必要があります。

まず、Android を開発者モードにします。

Android Studio - デバイスの開発者向けオプションを設定するに記載があります。

developer.android.com

続いて、Android を開発環境に接続します。

Chrome DevTools - Android 端末のリモート デバッグを行うを参照して、リモートデバッグできる環境を用意します。

developers.google.com

開発環境から、Android で立ち上げた Chrome を参照できるようになったら、準備完了です。

開発中機能の有効化

WebNFC は、現在のChromeではstableな機能ではありません。
有効化する必要があります。
以下の手順で実行します。

  1. Android の Chrome を開く
  2. chrome://flagsを開く
  3. Experimental Web Platform featuresを有効化する。

Experimental Web Platform featuresを有効化した、chrome://flagsの画面は、以下の通りです。

f:id:toranoana-lab:20200727133622p:plain

実装準備

開発環境に Web サーバーを用意します。
私の記事では、度々登場するhttp-serverを使用します。

www.npmjs.com

以下の通り操作します。

# 任意のディレクトリにて
npm init -y

npm install http-server --save-dev

mkdir public

npx http-server

こちらで開発環境にサーバーが用意できたので、上記の操作で作成した public ディレクトリにそれぞれファイルを作成します。

参考資料

今回の実装は、W3C Community Group Draft Report - Web NFCを参考に進めます。
サンプルコードが豊富なので、全編英語ではあるものの進めやすいと思います。

w3c.github.io

NFC タグの読み取り

まずシンプルに読み取りを行ってみます。

publicディレクトリに、以下のファイルを作成します。

(reader.html)

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/bulma@0.9.0/css/bulma.min.css"
    />
  </head>
  <body>
    <section class="hero">
      <div class="container is-fullwidth">
        <div class="hero-body">
          <div class="container">
            <h1 class="title">
              NFC Reader
            </h1>
          </div>
        </div>
      </div>
      <div class="container">
        <div class="columns is-mobile" id="message"></div>
      </div>
    </section>

    <section>
      <div class="container">
        <div class="columns is-mobile">
          <div class="column is-3">State</div>
          <div class="column is-3" id="status"></div>
        </div>
      </div>
    </section>
    <hr />
    <section>
      <div class="container">
        <div class="row" id="result">
          <table class="table is-bordered is-fullwidth">
            <tbody>
              <tr>
                <td>type</td>
                <td id="type"></td>
              </tr>
              <tr>
                <td>Serial Number</td>
                <td id="serialnumber"></td>
              </tr>
              <tr>
                <td>timeStamp</td>
                <td id="timeStamp"></td>
              </tr>
            </tbody>
          </table>
        </div>
      </div>
    </section>

    <script
      src="https://code.jquery.com/jquery-3.5.1.slim.min.js"
      integrity="sha256-4+XzXVhsDmqanXGHaHvgh1gMQKX40OUvDEBTu8JcmNs="
      crossorigin="anonymous"
    ></script>
    <script src="reader_app.js"></script>
  </body>
</html>

(reader_app.js)

const reader = new NDEFReader();

reader
  .scan()
  .then(() => {
    reader.onerror = (event) => {
      updateStatus(`Error: ${error}`);
    };
    reader.onreading = (event) => {
      updateResult(event);
    };
  })
  .catch((error) => {
    updateStatus(`Error! Scan failed to start: ${error}.`);
  });

const updateStatus = (str) => {
  $("#status").empty();
  $("#status").text(str);
};

const updateResult = (obj) => {
  //表示を一旦削除
  $("#serialnumber").empty();
  $("#type").empty();
  $("#timeStamp").empty();

  if (!obj) {
    return obj;
  }
  
  //ステータス表記を更新
  updateStatus("Success");

  //読み取り内容に基づき内容を更新
  $("#type").text(obj.type);
  $("#serialnumber").text(obj.serialNumber);
  $("#timeStamp").text(obj.timeStamp);
};

ポイントになるのは、NDEFReaderです。
こちらを呼び出し、scan()を実行することで、NFC 読み取りデバイスを Chrome で拘束し、本体側機能を呼び出さないようにすることができます。

結果を表示するにあたり、DOM の加工を行うので jQuery と、CSS フレームワークとしてBULMAを導入しています。

bulma.io

動作の様子が次のようになります。

f:id:toranoana-lab:20200727141616g:plain

NFC タグの書き込み

今度は、NFC タグに書き込みを行ってみます。

publicディレクトリに、以下のファイルを作成します。

(writer.html)

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/bulma@0.9.0/css/bulma.min.css"
    />
  </head>
  <body>
    <section class="hero">
      <div class="container is-fullwidth">
        <div class="hero-body">
          <div class="container">
            <h1 class="title">
              NFC Writer
            </h1>
          </div>
        </div>
      </div>
      <div class="container">
        <div class="columns is-mobile" id="status"></div>
      </div>
    </section>
    <hr />
    <section>
      <div class="container">
        <div class="columns">
          <div class="column is-full">
            <button class="button is-large is-fullwidth" id="writetext">
              Text
            </button>
          </div>
          <div class="column is-full">
            <button class="button is-large is-fullwidth" id="writejson">
              Json
            </button>
          </div>
          <div class="column is-full">
            <button class="button is-large is-fullwidth" id="writeurl">
              URL
            </button>
          </div>
          <div class="column is-full">
            <button class="button is-large is-fullwidth" id="writeimage">
              Image
            </button>
          </div>
        </div>
      </div>
    </section>

    <script
      src="https://code.jquery.com/jquery-3.5.1.slim.min.js"
      integrity="sha256-4+XzXVhsDmqanXGHaHvgh1gMQKX40OUvDEBTu8JcmNs="
      crossorigin="anonymous"
    ></script>
    <script src="writer_app.js"></script>
  </body>
</html>

(writer_app.js)

const reader = new NDEFReader();
const writer = new NDEFWriter();

//ブラウザがNFCデバイスを拘束するために、scan()を実行しておく
reader.scan();

//プレーンテキストを書き込み
const writeDataText = async () => {
  const writer = new NDEFWriter();
  writer
    .write({
      records: [{ recordType: "text", data: "Test text message." }],
    })
    .then(() => {
      updateStatus("Text written.");
    })
    .catch((_) => {
      updateStatus("Text Write failed.");
    });
};

//JSONを書き込み
const writeDataJson = () => {
  const writer = new NDEFWriter();
  const encoder = new TextEncoder();

  const encoded_text = encoder.encode(
    JSON.stringify({
      name: "toralab",
      type: "company",
    })
  );

  writer
    .write({
      records: [
        {
          recordType: "mime",
          mediaType: "application/json",
          data: encoded_text,
        },
      ],
    })
    .then(() => {
      updateStatus("Json written.");
    })
    .catch((_) => {
      updateStatus("Json Write failed.");
    });
};

//URLを書き込み
const writeDataUrl = () => {
  const writer = new NDEFWriter();
  writer
    .write({
      records: [
        { recordType: "url", data: "https://toranoana-lab.hatenablog.com/" },
      ],
    })
    .then(() => {
      updateStatus("Url written.");
    })
    .catch((_) => {
      updateStatus("Url Write failed.");
    });
};

//画像を書き込み
const writeDataImage = async () => {
  const writer = new NDEFWriter();

  const imageblob = await (await fetch("image.png")).arrayBuffer();

  console.log(imageblob);

  writer
    .write({
      records: [
        { recordType: "mime", mediaType: "image/png", data: imageblob },
      ],
    })
    .then(() => {
      updateStatus("Image written.");
    })
    .catch((_) => {
      updateStatus("Image Write failed.");
    });
};

const updateStatus = (str) => {
  $("#status").empty();
  $("#status").text(str);
};

//イベント割り当て
$("#writetext").click(writeDataText);
$("#writejson").click(writeDataJson);
$("#writeurl").click(writeDataUrl);
$("#writeimage").click(writeDataImage);

ポイントになるのは、NDEFWriterです。
こちらを呼び出し、write()を実行することで、NFC 書き込みデバイスを Chrome で拘束し、本体側機能を呼び出さないようにすることができます。
ただし、書き込み後にデバイスを解放するために、書き込んだ瞬間に本体側のデバイスが NFC タグを読み取ってしまうということが起きます。

なので、NDEFReader.scan()を実行し、読み取りデバイスも拘束しておきます。

上記のサンプルコードでは、プレーンテキスト・JSON・URL・画像を書き込みを行えます。
画像については、NFC タグの容量から高精細な画像を書き込むことはできません。
手元にあった NFC タグは容量 492 バイトでした。

書き込むことのできた画像は、以下の通りです。

f:id:toranoana-lab:20200727134202p:plain

非常に小さな画像しか取り扱うことができません。
もし、画像を扱うなら URL を書き込んで、画像へ誘導するのが現実的な運用ではないかと思います。

実際に動かしてみたのが次の動画です。
テキスト、JSON、URL の順に書き込んで、最後に読み取ったタグのURLを基にとらラボサイトを開きます。

f:id:toranoana-lab:20200727141705g:plain

アプリケーション試作

ここまで、タグの読み書きを試しました。
最後に、アプリケーションを作ってみます。

以下の要件を満たすことを目指します。

  • 赤メイドちゃん・青メイドちゃんを示すデータを NFC タグに書き込むことができる
  • 赤メイドちゃんもしくは、青メイドちゃんを示すデータを 3 回連続で読み込むと、それぞれに対応した、画像を表示する。

要件を満たすアプリを以下の通り実装しました。

(maidchan.html)

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/bulma@0.9.0/css/bulma.min.css"
    />
  </head>
  <body>
    <section class="hero">
      <div class="container is-fullwidth">
        <div class="hero-body">
          <div class="container">
            <h1 class="title">
              Maidchan Checker
            </h1>
          </div>
        </div>
      </div>
    </section>
    <section class="section">
      <div class="container is-fullwidth" id="images">
        <img src="img/blank.png" class="is-fullwidth" />
        <img src="img/blank.png" class="is-fullwidth" />
        <img src="img/blank.png" class="is-fullwidth" />
      </div>
    </section>
    <div id="modal" class="modal">
      <div class="modal-background"></div>
      <div class="modal-content" id="modal_content"></div>
      <button
        class="modal-close is-large"
        aria-label="close"
        id="close_modal"
      ></button>
    </div>
    <section class="section">
      <div class="container is-fullwidth">
        <div class="columns is-mobile">
          <div class="column">
            <button
              type="button"
              class="button is-fullwidth is-medium is-danger"
              id="write_red"
            >
              赤メイドちゃん
            </button>
          </div>
          <div class="column">
            <button
              type="button"
              class="button is-fullwidth is-medium is-info"
              id="write_blue"
            >
              青メイドちゃん
            </button>
          </div>
        </div>
      </div>
    </section>
    <script
      src="https://code.jquery.com/jquery-3.5.1.slim.min.js"
      integrity="sha256-4+XzXVhsDmqanXGHaHvgh1gMQKX40OUvDEBTu8JcmNs="
      crossorigin="anonymous"
    ></script>
    <script src="maidchan_app.js"></script>
  </body>
</html>

(maidchan_app.js)

const reader = new NDEFReader();
const writer = new NDEFWriter();

const readedList = [
  { type: "blank", serial: "00:00:00:00:00:00" },
  { type: "blank", serial: "00:00:00:00:00:00" },
  { type: "blank", serial: "00:00:00:00:00:00" },
];

//NFCタグの読み込み処理
reader
  .scan()
  .then(() => {
    reader.onerror = (event) => {
      modalPopUp(`Read Error: ${event}`, "danger");
    };
    reader.onreading = (event) => {
      dataCheck(event);
    };
  })
  .catch((error) => {
    modalPopUp(`Error! Scan failed to start: ${error}.`, "danger");
  });

//読み込んだデータのチェック処理
const dataCheck = (event) => {
  //書き込まれたデータが無い場合にエラーとして終了
  if (event.message.records[0] == null) {
    modalPopUp(`Read Error: No record`, "danger");
    pushlist();
    updateList();
    return;
  }

  const { data, mediaType, recordType } = event.message.records[0];

  //書き込まれたデータがjsonで無い場合にエラーとして終了
  if (!(recordType === "mime" && mediaType === "application/json")) {
    modalPopUp("Read Error: not Json", "danger");
    pushlist();
    updateList();
    return;
  }

  //データをjsonとしてデコード
  const decoder = new TextDecoder();
  const json = JSON.parse(decoder.decode(data));

  //読み込み済みデータを配列に追加
  pushlist(json.type, event.serialNumber);

  //表示を更新
  updateList();
};

//読み込み済みタグ情報の追加
const pushlist = (type = "blank", serial = "00:00:00:00:00:00") => {
  readedList.push({ type: type, serial: serial });
};

//読み込み済みタグ情報に基づく、表示の更新
const updateList = () => {
  while (true) {
    if (readedList.length > 3) {
      readedList.shift();
    } else {
      break;
    }
  }

  let str = "";

  readedList.forEach((data) => {
    str += `<img src="img/${data.type}.png" class="is-fullwidth" />`;
  });

  $("#images").empty();
  $("#images").html(str);

  //読み取り済みのシリアルコードに同じものがあれば終了
  if (
    readedList[0].serial == readedList[1].serial ||
    readedList[0].serial == readedList[2].serial
  ) {
    return;
  }

  //読み取り済みのtypeがblankではなくすべて同じであればモーダルを表示
  if (
    readedList[0].type != "blank" &&
    readedList[0].type == readedList[1].type &&
    readedList[0].type == readedList[2].type
  ) {
    modalPopUp(
      `<img src="img/sp-${readedList[0].type}.png" class="is-fullwidth" />`,
      "light",
      false
    );

    return;
  }
};

//NFCタグへのJSON形式データを書き込む
const writeData = (str) => {
  console.log(str);
  const writer = new NDEFWriter();
  const encoder = new TextEncoder();

  const encoded_text = encoder.encode(
    JSON.stringify({
      type: str,
    })
  );

  writer
    .write({
      records: [
        {
          recordType: "mime",
          mediaType: "application/json",
          data: encoded_text,
        },
      ],
    })
    .then(() => {
      modalPopUp(`Maidchan ${str} written.`, "info");
    })
    .catch(() => {
      modalPopUp("Json Write failed.", "danger");
    });
};

//モーダル表示
const modalPopUp = (html, color, autoClose = true) => {
  const str = `<div class="notification is-${color}">${html}</div>`;

  $("#modal").addClass("is-active");
  $("#modal_content").html(str);

  if (autoClose) {
    setTimeout(() => {
      modalClose();
    }, 1000);
  }
};

//モーダル閉じる
const modalClose = () => {
  $("#modal").removeClass("is-active");
};

//イベント割り当て
$("#close_modal").click(() => {
  modalClose();
});

$("#write_red").click(() => {
  writeData("red");
});
$("#write_blue").click(() => {
  writeData("blue");
});

動作の様子は、以下のようになります。

f:id:toranoana-lab:20200727141916g:plain

今回使用したのは、カード型の NFC タグですがシール型のものなども、比較的安価に入手することが可能です。
NFC タグを使ったハックは調べると開発に関わる情報を問わずたくさん見つかるので、興味があればぜひ調べてみてください。

カードにかざすと特定のアクションを行うことができるアプリケーションを作る手段があるというのは、カードをスラッシュしたりアドベントしたりカードで召還していた世代だからだと思いますが、実用性以上のワクワクがありますね。

P.S

8月8日には定例開催している会社説明会をオンライン開催します。
どなたでも参加できるので、とらラボがどんなところか聞いてみたいという人は是非ご参加ください。

yumenosora.connpass.com

虎の穴ラボでの開発に少しでも興味を持っていただけた方は、採用説明会やカジュアル面談という場でもっと深くお話しすることもできます。ぜひお気軽に申し込みいただければ幸いです。
カジュアル面談では虎の穴ラボのエンジニアが、開発プロセスの内容であったり、「今期何見ました?」といったオタクトークから業務の話まで何でもお応えします。

カジュアル面談や採用情報はこちらをご確認ください。
yumenosora.co.jp

また、毎週火曜、木曜にはTora-Lab Meetup!と称して虎の穴ラボのエンジニア・採用担当とお話できる機会を設けさせていただくことになりました。
虎の穴ラボに興味がある、エンジニアや採用担当に質問したいことがある、などどなたでもご参加下さい。
news.toranoana.jp

さらに、弊社では新型コロナウイルス感染症終息後もフルリモートを継続導入することになりました!
地方在住のまま働きたい人など、上記Meetupやカジュアル面談、面接すべてリモート対応していますので、ご興味のある方はぜひいずれか応募してみてください! prtimes.jp