WebNFC で遊びたいがゆえに、Google Pixel 3a XL を買いました。
今回は、WebNFC の動作を確認できたので、そんなまとめです。
最終的にこんなものができました。
音が流れます。ご注意ください。
目次
参考
WebNFC 有効化の方法
WebNFC は、現在デフォルトで有効な機能ではありません。
有効にする必要があります。
以下の手順で、有効化しましょう。
- Chrome を起動して、chrome://flagsを開く。
- Experimental Web Platform featureの項目を有効化する。
- Chrome を再起動する。
設定した終わった、chrome://flagsの画面は、以下のようになります。

実装 1(動作確認)
先に挙げたCommunity Group Draft Reportは、サンプルコードが豊富です。
16 種類あるうちから、いくつかピックアップして参考にしながら触ってみます。
準備
Android の開発者モード、Chrome 開発ツールを介して localhost で立ち上げた、web サーバーにアクセスさせます。
まず、localhost で起動する web サーバーを用意します。
以下の通り実行します。
| 12
 3
 4
 5
 
 | npm init -ynpm install http-server --save-dev
 mkdir public
 npx http-server
 
 
 | 
続いて、Android で開発マシンのlocalhost:8080を参照できるようにします。
- Android の開発者モードを有効化します。
 詳しい手順はこちらを参照。
 デバイスの開発者向けオプションを設定する
 
- Android を USB で接続し、web サーバーを起動するマシンで Chrome を起動します。 
- 開発者ツールを開き、- More tools->- Remote devicesを開きます。
  
 
- USB で接続した端末が見えるので、- inspectを押します
  
 
- USB で接続した端末の画面が見えます。
  
 ぱっと見タブレットモードで開いているだけに見えますが、手元の端末と連動しています。
 そちらを操作してみましょう。
 
ここまで出来たら、public以下に、html ファイルと js ファイルを作成します。
具体的な記述は、次の項目からです。
シンプルに読み込み
まずは、シンプルに NFC タグに書かれた内容を読み込んで、表示してみます。
public/test0.html| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 
 | <!DOCTYPE html><html>
 <head>
 <script
 src="https://code.jquery.com/jquery-3.5.1.slim.min.js"
 integrity="sha256-4+XzXVhsDmqanXGHaHvgh1gMQKX40OUvDEBTu8JcmNs="
 crossorigin="anonymous"
 ></script>
 <script src="app0.js"></script>
 </head>
 <body>
 <div id="message"></div>
 <button id="startbutton" onclick="scanStart()">Scan Start</button>
 </body>
 </html>
 
 | 
 
public/app0.js| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 
 | const reader = new NDEFReader();
 
 const scanstart = async () => {
 await reader.scan();
 
 reader.onerror = (event) => {
 $("#message").text("ERROR");
 };
 reader.onreading = (event) => {
 
 $("#message").empty();
 
 
 $("#message").append(event.serialNumber).append("</br>");
 $("#message").append(event.timeStamp).append("</br>");
 $("#message").append(event.type).append("</br>");
 console.log(event);
 };
 };
 
 
 scanstart();
 
 | 
 
こちらを動作させると、次のようになります。
それぞれのシリアルコードとタイムスタンプがそれぞれのカードで別のものを取得できています。
読み込みの停止
先の実装だと、一度scan()を実行すると、リロードでもしない限り本体側に NFC の読み取り機能を返すことができません。
今度は、NFC タグへの読み込みを停止してみます。
一度読み込んだら、5 秒後に読み込みを解除するようにしてみます。
public/test1.html| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 
 | <!DOCTYPE html><html>
 <head>
 <script
 src="https://code.jquery.com/jquery-3.5.1.slim.min.js"
 integrity="sha256-4+XzXVhsDmqanXGHaHvgh1gMQKX40OUvDEBTu8JcmNs="
 crossorigin="anonymous"
 ></script>
 <script src="app1.js"></script>
 </head>
 <body>
 <div id="message"></div>
 <button id="startbutton" onclick="scanStart()">Scan Start</button>
 <button onclick="scanEnd()">Scan End</button>
 </body>
 </html>
 
 | 
 
public/app1.js| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 
 | const reader = new NDEFReader();
 
 let controller = null;
 let count = 5;
 
 const scanStart = async () => {
 
 if (controller) {
 return;
 }
 controller = new AbortController();
 
 await reader.scan({ signal: controller.signal });
 
 reader.onerror = (event) => {
 $("#message").text("Error!");
 };
 reader.onreading = (event) => {
 
 $("#message").empty();
 
 
 $("#message").append("Readed!").append("</br>");
 $("#message").append(event.serialNumber).append("</br>");
 $("#message").append(event.timeStamp).append("</br>");
 $("#message").append(event.type).append("</br>");
 console.log(event);
 };
 
 $("#message").text("Device is Ready!");
 
 
 setTimeout(scanEnd, 5000);
 
 $("#startbutton").text(`${count}s`);
 countdown = setInterval(() => {
 count = count - 1;
 $("#startbutton").text(`${count}s`);
 }, 1000);
 
 setTimeout(() => {
 clearInterval(countdown);
 count = 5;
 $("#startbutton").text("Scan Start");
 }, 5100);
 };
 
 const scanEnd = () => {
 if (!controller) {
 return;
 }
 controller.abort();
 controller = null;
 $("#message").text("Device is Not Ready!");
 };
 
 | 
 
Scan Endボタンも用意したので、任意に停止もできます。
こちらを動作させると、次のようになります。
アプリで読み取りを開始し、カウントダウンが終わると本体側の NFC が反応するようになります。
シンプルに書き込み
ここまで読み込みをできたので、今度は書き込みをしてみます。
試しに、このブログの URL を書き込んでみます。
public/test2.html| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 
 | <!DOCTYPE html><html>
 <head>
 <script
 src="https://code.jquery.com/jquery-3.5.1.slim.min.js"
 integrity="sha256-4+XzXVhsDmqanXGHaHvgh1gMQKX40OUvDEBTu8JcmNs="
 crossorigin="anonymous"
 ></script>
 <script src="app2.js"></script>
 </head>
 <body>
 <div id="message"></div>
 <button id="startbutton" onclick="writeStart()">Write Start</button>
 </body>
 </html>
 
 | 
 
public/app2.js| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 
 | const writer = new NDEFWriter();
 
 const writeStart = async () => {
 try {
 await writer.write({
 records: [{ recordType: "url", data: "https://www.google.com/" }],
 });
 $("#message").text("Write Success");
 } catch (error) {
 $("#message").text("Error!");
 }
 };
 
 | 
 
こちらを動作させると、次のようになります。
書き込んだ瞬間に、本体側の NFC デバイスが、読み取りしてしまいました。
書き込み後に NFC 制御をデバイス本体にとられないようにする
先の動画のように、書き込んだ後すぐには本体側の NFC デバイスが反応してしまうので、バタバタした動きになります。
scan()を実行し、NFC デバイスを Chrome で掌握したまま書き込んでみます。
今回は、ページを開いたら、常にscan()を実行させます。
public/test3.html| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 
 | <!DOCTYPE html><html>
 <head>
 <script
 src="https://code.jquery.com/jquery-3.5.1.slim.min.js"
 integrity="sha256-4+XzXVhsDmqanXGHaHvgh1gMQKX40OUvDEBTu8JcmNs="
 crossorigin="anonymous"
 ></script>
 <script src="app3.js"></script>
 </head>
 <body>
 <div id="message"></div>
 <button id="startbutton" onclick="writeStart()">Write Start</button>
 </body>
 </html>
 
 | 
 
public/app3.js| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 
 | const writer = new NDEFWriter();const reader = new NDEFReader();
 
 const writeStart = async () => {
 try {
 await writer.write({
 records: [{ recordType: "url", data: "https://ccbaxy.xyz/blog/" }],
 });
 $("#message").text("Write Success");
 } catch (error) {
 $("#message").text("Error!");
 }
 };
 
 
 reader.scan();
 
 | 
 
こちらを動作させると、次のようになります。
アプリを起動すると、書き込んだ後も読み取りしなくなりました。
Chrome を閉じると、本体側の NFC デバイスが読み取りを行っています。
実装 2(アプリケーション試作)
ここまでで、NFC タグへの読み書きができるようになりました。
もう少し進めて、音と画面に動きをつけてみます。
とりあえずこんなものができました。
音が流れます。ご注意ください。
実装は以下の通りです。
読み込みアプリ
public/tatobareader.html| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 
 | <!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"
 />
 <script
 src="https://code.jquery.com/jquery-3.5.1.slim.min.js"
 integrity="sha256-4+XzXVhsDmqanXGHaHvgh1gMQKX40OUvDEBTu8JcmNs="
 crossorigin="anonymous"
 ></script>
 <script src="reader.js"></script>
 </head>
 
 <body>
 <section class="hero">
 <div class="container is-fullwidth">
 <div class="hero-body">
 <div class="container">
 <h1 class="title">Tatoba Reader</h1>
 </div>
 </div>
 </div>
 <div class="container is-fullwidth">
 <div class="columns is-mobile" id="message"></div>
 </div>
 </section>
 <section class="section">
 <div class="container is-fullwidth" id="images">
 <img src="img/blank.jpg" class="is-fullwidth" />
 <img src="img/blank.jpg" class="is-fullwidth" />
 <img src="img/blank.jpg" class="is-fullwidth" />
 </div>
 </section>
 
 <section class="section">
 <div class="container is-fullwidth">
 <div class="columns is-mobile">
 <button
 class="button is-large is-success is-fullwidth"
 id="startbutton"
 onclick="scanStart()"
 >
 Scanstart
 </button>
 <button
 class="button is-large is-black is-fullwidth"
 id="endbutton"
 onclick="scanEnd()"
 >
 Scan Stop
 </button>
 </div>
 </div>
 </section>
 <section>
 <div class="container is-fullwidth">
 <a class="button is-large is-link is-fullwidth" href="/tatobawriter"
 >Tatoba Writer</a
 >
 </div>
 </section>
 </body>
 </html>
 
 | 
 
public/reader.js| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 
 | const reader = new NDEFReader();let controller = null;
 const lorded_list = ["blank", "blank", "blank"];
 
 const music_list = ["taka", "tora", "batta", "blank", "tatoba"];
 const music_objects = [];
 
 const init_music = () => {
 music_list.forEach((title) => {
 const m_obj = new Audio();
 m_obj.preload = "auto";
 m_obj.src = `./music/${title}.mp3`;
 m_obj.load();
 music_objects.push(m_obj);
 });
 };
 
 const scanStart = async () => {
 
 if (controller) {
 return;
 }
 controller = new AbortController();
 
 await reader.scan({ signal: controller.signal });
 
 reader.onerror = () => {
 $("#message").text("Error!");
 music_objects[music_list.findIndex((item) => item === "blank")].play();
 };
 reader.onreading = (event) => {
 
 $("#message").empty();
 $("#images").empty();
 
 if (event.message.records[0] == null) {
 $("#message").html(
 `<div class="notification is-danger is-fullwidth">
 <h3 class="title is-4">ERROR</h3>
 </div>`
 );
 music_objects[music_list.findIndex((item) => item === "blank")].play();
 lorded_list.push("blank");
 } else {
 const { data, mediaType, recordType } = event.message.records[0];
 
 if (!(recordType === "mime" && mediaType === "application/json")) {
 $("#message").html(
 `<div class="notification is-danger is-fullwidth">
 <h3 class="title is-4">ERROR</h3>
 </div>`
 );
 music_objects[music_list.findIndex((item) => item === "blank")].play();
 lorded_list.push("blank");
 } else {
 $("#message").html(
 `<div class="notification is-primary is-fullwidth">
 <h3 class="title is-4">Loaded</h3>
 </div>`
 );
 const decoder = new TextDecoder();
 const json = JSON.parse(decoder.decode(data));
 
 music_objects[
 music_list.findIndex((item) => item === json.element)
 ].play();
 
 lorded_list.push(json.element);
 }
 }
 if (lorded_list.length > 3) {
 lorded_list.shift();
 }
 $("#images").empty();
 $("#images").html(
 `<img class="lazy" src="img/${convertFileName(
 lorded_list[0]
 )}" class="is-fullwidth" />
 <img class="lazy" src="img/${convertFileName(
 lorded_list[1]
 )}" class="is-fullwidth" />
 <img class="lazy" src="img/${convertFileName(
 lorded_list[2]
 )}" class="is-fullwidth" />`
 );
 if (
 lorded_list[0] === "taka" &&
 lorded_list[1] === "tora" &&
 lorded_list[2] === "batta"
 ) {
 setTimeout(() => {
 music_objects[music_list.findIndex((item) => item === "tatoba")].play();
 }, 1200);
 }
 };
 $("#message").html(
 `<div class="notification is-primary is-fullwidth">
 <h3 class="title is-4">Device is Ready</h3>
 </div>`
 );
 };
 
 const convertFileName = (element) => {
 if (element == "taka" || element == "tora" || element == "batta") {
 return `${element}.jpg`;
 }
 return "blank.jpg";
 };
 
 const scanEnd = () => {
 if (!controller) {
 return;
 }
 controller.abort();
 controller = null;
 $("#message").html(
 `<div class="notification is-primary is-fullwidth">
 <h3 class="title is-4">Device is Not Ready</h3>
 </div>`
 );
 };
 
 init_music();
 
 | 
 
対応したデータを持ったタグが正しい順番で読み込まれると、専用音声を再生します。
画像と音源は公式の音源を使うわけにもいかず、フリー素材と自前で歌ったものを加工して使用しました。
使用させていただいた素材はこちらです。
画像
ブザー音
書き込みアプリ
最初はシリアルコードで見分けようとも考えましたが、書き込みツールを作ってみました。
読み込ませるデータの書き込みには、こちらを用意しました。
public/tatobawriter.html| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 
 | <!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" />
 <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"
 integrity="sha256-4+XzXVhsDmqanXGHaHvgh1gMQKX40OUvDEBTu8JcmNs=" crossorigin="anonymous"></script>
 <script src="writer.js"></script>
 </head>
 
 <body>
 <section class="hero">
 <div class="container is-fullwidth">
 <div class="hero-body">
 <div class="container">
 <h1 class="title">
 Tatoba Writer
 </h1>
 </div>
 </div>
 </div>
 <div class="container is-fullwidth">
 <div class="columns is-mobile" id="message" >
 </div>
 </div>
 </section>
 <section class="section">
 <div class="container is-fullwidth">
 <div class="columns is-mobile">
 <button class="button is-large is-danger is-fullwidth" id="startbutton" onclick="writeStart('taka')">
 'TAKA' Write Start
 </button>
 </div>
 </section>
 <section class="section">
 <div class="container is-fullwidth">
 <div class="columns is-mobile">
 <button class="button is-large is-warning is-fullwidth" id="startbutton" onclick="writeStart('tora')">
 'TORA' Write Start
 </button>
 </div>
 </div>
 </div>
 </section>
 <section class="section">
 <div class="container is-fullwidth">
 <div class="columns is-mobile">
 <button class="button is-large is-success is-fullwidth" id="startbutton" onclick="writeStart('batta')">
 'BATTA' Write Start
 </button>
 </div>
 </div>
 </section>
 <section class="section">
 <div class="container is-fullwidth">
 <div class="columns is-mobile">
 <button class="button is-large is-black is-fullwidth" id="endbutton" onclick="writeEnd()">
 Write Stop
 </button>
 </div>
 </div>
 </section>
 <section>
 <div class="container is-fullwidth">
 <a class="button is-large is-link is-fullwidth" href="/tatobareader"
 >Tatoba Reader</a
 >
 </div>
 </section>
 </body>
 </html>
 
 | 
 
public/writer.js| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 
 | const writer = new NDEFWriter();const reader = new NDEFReader();
 
 let controller = null;
 
 const writeStart = async (text) => {
 if (controller != null) {
 return;
 }
 
 const encoder = new TextEncoder();
 
 $("#message").html(
 `<div class="notification is-info is-fullwidth">
 <h3 class="title is-4">Device is Ready</h3>
 </div>`
 );
 
 controller = new AbortController();
 
 try {
 await writer.write({
 signal: controller.signal,
 records: [
 {
 recordType: "mime",
 mediaType: "application/json",
 data: encoder.encode(JSON.stringify({ element: text })),
 },
 ],
 });
 $("#message").html(
 `<div class="notification is-primary is-fullwidth">
 <h3 class="title is-4">${text} Write Success.</h3>
 </div>`
 );
 controller.abort();
 controller = null;
 } catch (error) {
 $("#message").html(
 `<div class="notification is-danger is-fullwidth">
 <h3 class="title is-4">Error</h3>
 </div>`
 );
 }
 };
 
 const writeEnd = async () => {
 $("#message").html(
 `<div class="notification is-info is-fullwidth">
 <h3 class="title is-4">Write is end.</h3>
 </div>`
 );
 controller.abort();
 controller = null;
 };
 
 reader.scan();
 
 | 
 
書き込むデータをボタンで選択して、タカ・トラ・バッタのいずれかを書き込みます。
エラーのカードがトラに、トラのカードはバッタに代わりました。
NFC タグに json 形式データを書き込んでいます。
ブラウザの操作で、物理世界に干渉(NFC の操作が物理世界への干渉かという議論はあるとして)できるのはとても楽しいです。
タグにデータを書き込むことで、web の認証に NFC タグを使ったり、ソシャゲのアイテムに NFC タグカードを使ったりできそうです。
面白がって音源のために録音などしましたが、音源の加工が意外と面倒でした。
大体 10 年前からライダー玩具は 非接触 タグ使ってたんだからすごいですね。
多分オーズが初だったかな?違っていたら失礼しました。
ではでは。