WebNFCを試そう

WebNFC で遊びたいがゆえに、Google Pixel 3a XL を買いました。
今回は、WebNFC の動作を確認できたので、そんなまとめです。

最終的にこんなものができました。
音が流れます。ご注意ください。

目次

参考

WebNFC 有効化の方法

WebNFC は、現在デフォルトで有効な機能ではありません。
有効にする必要があります。

以下の手順で、有効化しましょう。

  1. Chrome を起動して、chrome://flagsを開く。
  2. Experimental Web Platform featureの項目を有効化する。
  3. Chrome を再起動する。

設定した終わった、chrome://flagsの画面は、以下のようになります。

実装 1(動作確認)

先に挙げたCommunity Group Draft Reportは、サンプルコードが豊富です。
16 種類あるうちから、いくつかピックアップして参考にしながら触ってみます。

準備

Android の開発者モード、Chrome 開発ツールを介して localhost で立ち上げた、web サーバーにアクセスさせます。

まず、localhost で起動する web サーバーを用意します。

以下の通り実行します。

1
2
3
4
5
npm init -y
npm install http-server --save-dev
mkdir public
npx http-server
# localhost:8080番で、サーバーが起動します。

続いて、Android で開発マシンのlocalhost:8080を参照できるようにします。

  • Android の開発者モードを有効化します。
    詳しい手順はこちらを参照。
    デバイスの開発者向けオプションを設定する

  • Android を USB で接続し、web サーバーを起動するマシンで Chrome を起動します。

  • 開発者ツールを開き、More tools->Remote devicesを開きます。

  • USB で接続した端末が見えるので、inspectを押します

  • USB で接続した端末の画面が見えます。

    ぱっと見タブレットモードで開いているだけに見えますが、手元の端末と連動しています。
    そちらを操作してみましょう。

ここまで出来たら、public以下に、html ファイルと js ファイルを作成します。
具体的な記述は、次の項目からです。

シンプルに読み込み

まずは、シンプルに NFC タグに書かれた内容を読み込んで、表示してみます。

public/test0.html
1
2
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// WebNFCの読み込みには、NDEFReaderを使う。
const reader = new NDEFReader();

const scanstart = async () => {
await reader.scan();

reader.onerror = (event) => {
$("#message").text("ERROR");
};
reader.onreading = (event) => {
//一度要素を空に
$("#message").empty();

//NFCタグから取得できるものから3種類を選定
$("#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
1
2
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
1
2
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();
// NFCデバイスの解放のためにAbortControllerをつかう。
//使いまわせないので、都度作成する。
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();

//NFCタグから取得できるものから3種類を選定
$("#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!");

//3秒後に読み込みモードを解除する。
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
1
2
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
1
2
3
4
5
6
7
8
9
10
11
12
13
// WebNFCの読み込みには、NDEFWriterを使う。
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
1
2
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
1
2
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!");
}
};

//NFCデバイスを掌握しておくためにscan()を実行しておく
reader.scan();

こちらを動作させると、次のようになります。

アプリを起動すると、書き込んだ後も読み取りしなくなりました。
Chrome を閉じると、本体側の NFC デバイスが読み取りを行っています。

実装 2(アプリケーション試作)

ここまでで、NFC タグへの読み書きができるようになりました。
もう少し進めて、音と画面に動きをつけてみます。

とりあえずこんなものができました。
音が流れます。ご注意ください。

実装は以下の通りです。

読み込みアプリ

public/tatobareader.html
1
2
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
1
2
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
1
2
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
1
2
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 年前からライダー玩具は 非接触 タグ使ってたんだからすごいですね。
多分オーズが初だったかな?違っていたら失礼しました。

ではでは。