Physical Web
Physical Webという言葉を聞いたことがあるでしょうか?Physical WebはGoogleが提唱している、簡単に言えば物理的なモノにURLを付与するための規格です。具体的にはEddystoneという規格を実装したビーコンが機器に対応するURLを定期的に周囲に発信し、そのビーコンにスマホのウェブブラウザ(現在はChromeのみ)が反応して通知を表示するようにすることでモノのURLを実現します。
Physical Webパーキングメーター
今年のChrome Dev Summitの会場にはPhysical Webを使用したデモがいくつか設置されていました。その1つがPhysical Webパーキングメーターです。そのデモでは「駐車枠に車を止めてパーキングメーターに近づくとスマホに通知が届き、そのままスマホからパーキングメーターの操作や支払いができる」というシナリオを実際に体験することができました。(ただし"駐車枠に車を止めて"の部分は脳内で補完する必要があります)
Chrome Dev SummitのおみやげとしてBeaconを頂いたので、今回はこのPhysical Webパーキングメーターもどきを作ってみます。
Physical Webパーキングメーターもどき
パーキングメーターウェブアプリ作成
ラップトップで動作するパーキングメーターっぽいウェブアプリと、スマホで動作するパーキングメータークライアントっぽいアプリを作成します。クライアントからパーキングメーターを操作できればなんでもよく、普通のウェブアプリでPhysical Webの仕様には関係ない上、作りも適当なので説明は省略します。一応ソースコードは一番に下においておきます。
ビーコンを設定
ちなみにビーコンはこの真ん中の白い奴です。
ビーコンに近付いたらスマホでパーキングメータークライアントが開くようにするために、まずビーコンにそのURLを設定しなければいけません。AndroidにBEEKS Beacon Makerというビーコンを設定するためのアプリをインストールして起動します。
スマホをビーコンに近づけてしばらく待つと緑のアイコンが出てくるのでクリックします。
右上のCONNECTボタンをクリックしてビーコンに接続して
CONF.EDDYSTONEボタンをクリックして設定ダイアログを開き、Physical Webパーキングメータークライアントもどきの短縮URLを入力します。ビーコンに設定できるURLのサイズは非常に短いので基本的に何らかの短縮URLサービスを利用する必要があります。
- パーキングメータークライアントもどき: https://physical-web-modoki.firebaseapp.com/index.html#<パーキングメーターID>
- 今回使用した短縮URL: https://goo.gl/VO8x9l
はじめてビーコンを使用する場合はCONF.ADVERTISEMENTSボタンもクリックして電波の強度やURLを発信する周期を設定しておきます。とりあえず半径12メートル程度のエリアに2回/秒ほど発信するよう設定しました。
これでビーコンの設定は終了です。
ビーコンを受信
あとはChrome開発者版でPhysical Webフラグを有効にしてスマホをビーコンに近づければ何かいい感じのことが起こるはずですが、なぜかうまくいかないのでひとまずここはPhysical Webアプリをインストールしておきます。
デモ
準備
- パーキングメーターもどき: https://physical-web-modoki.firebaseapp.com/meter.html
ラップトップでパーキングメーターもどきアプリを立ち上げて近くにビーコンを置き、スマホではPhysical Webを立ち上げておきます。
これが我が家のパーキングメーター。
ごっこ遊び
(脳内で)自動車を駐車して、ポケットにスマホを入れたまま、パーキングメーター(ビーコンを近くにおいたラップトップ)に近づきます。
Physical Webアプリ(本来であればブラウザ)がビーコンを受信してリンクが表示されました。
リンクをクリックするとブラウザが開き、Physical Webパーキングメータークライアントもどきが立ち上がります。
・・・
本来はこのままスマホでスマホでクライアントアプリを開いてラップトップ側のパーキングメーターを操作する想定ですが、説明が面倒くさかったので、両方をラップトップで起動して操作しているところをスマホで撮影しました。ここから先は左側の画面はスマホだと思って動画を見てください。
・・・
ということで、自動車を駐車してパーキングメーターに近づくとパーキングメータークライアントもどきが立ち上がり、パーキングメーターもどきをスマホから操作するというごっこ遊びができるようになりました。
おしまい。
ソースコード
パーキングメーター
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<title>Physical Webパーキングメータもどき</title>
<style>
* {
margin: 0;
padding: 0;
}
h1 {
font-size: 120%;
text-transform: uppercase;
color: white;
text-align: center;
padding-top: 2vh;
height: 10vh;
}
#timer {
color: white;
text-align: center;
height: 30vh;
padding-top: 5vh;
font-size: 20vh;
}
#screen {
width: 100vh;
height: 100vh;
border-radius: 50vh;
background-color: black;
overflow: hidden;
}
#status {
width: 100vh;
height: 20vh;
}
#status.init {
background: lightGreen;
}
#status.paid {
background: lightGreen;
}
#status.parking {
background: red;
}
#reset {
position: absolute;
bottom: 5vh;
left: 40vh;
width: 20vh;
height: 20vh;
border-radius: 10vh;
background-color: white;
border: none;
text-transform: uppercase;
font-size: 150%;
}
</style>
</head>
<body>
<div id="screen">
<h1>Meter #<span id="meter-id"></span></h1>
<div id="timer"></div>
<div id="status" class="init"></div>
<button id="reset">Reset</button>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.0.0-alpha1/jquery.min.js"></script>
<script src="https://cdn.firebase.com/js/client/2.3.2/firebase.js"></script>
<script>
var firebaseRef = new Firebase("https://physical-web-modoki.firebaseio.com");
var meterId = location.hash.replace('#', '');
if (!meterId) {
meterId = Math.floor(Math.random() * 1000);
location.hash = '' + meterId;
}
$('#meter-id').text(meterId);
var meterRef = firebaseRef.child('meter-' + meterId);
meterRef.child('timer').on('value', function(snapshot) {
var timer = snapshot.val() || '00:00';
$('#timer').text(timer);
});
var timerId;
meterRef.child('status').on('value', function(snapshot) {
stat = snapshot.val();
console.log(stat);
console.dir(stat);
if (stat.name === 'init') {
$('#status').addClass('init');
$('#status').removeClass('paid');
$('#status').removeClass('parking');
}
else if (stat.name === 'paid') {
$('#status').addClass('paid');
$('#status').removeClass('init');
$('#status').removeClass('parking');
setTimeout(function() {
meterRef.child('status').set({
name: 'parking',
user: stat.user,
start: +new Date
});
});
}
else if (stat.name === 'parking') {
$('#status').addClass('parking');
$('#status').removeClass('init');
$('#status').removeClass('paid');
var timeParts = $('#timer').text().split(':').map(function(v) {
return parseInt(v, 10);
});
var maxTime = (timeParts[0] * 60 + timeParts[1]) * 1000;
var startTime = stat.start;
function timer() {
var currentTime = +new Date
var diffTime = currentTime - startTime;
var remainingTime = Math.floor((maxTime - diffTime) / 1000);
if (remainingTime <= 0) {
reset();
}
else {
var min = Math.floor(remainingTime / 60);
var sec = remainingTime - min * 60;
min = (min < 10 ? '0' : '') + min;
sec = (sec < 10 ? '0' : '') + sec;
meterRef.child('timer').set(min + ':' + sec);
timerId = setTimeout(timer, 1000);
}
}
timer();
}
});
function reset() {
if (timerId) clearTimeout(timerId);
meterRef.set({
name: 'Test Parking #' + meterId,
status: {name: 'init'},
timer: '00:00'
});
}
meterRef.once('value', function(snapshot) {
if (!snapshot.val()) {
reset();
}
});
$('#reset').click(reset);
</script>
</body>
</html>
クライアント
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
<title>Physical Webパーキングメータもどき</title>
<style>
* {
margin: 0;
padding: 0;
}
button {
border: none;
width: 100%;
}
button:active {
border: none;
outline: none;
}
header {
padding: 20px;
font-size: 250%;
color: gray;
border-bottom: 1px solid gray;
}
section {
text-align: center;
}
h1 {
color: lightGray;
font-size: 200%;
}
#timer {
font-size: 400%;
}
#increase, #decrease {
background: none;
font-weight: bold;
font-size: 350%;
transform: scale(5, 1);
color: lightGray;
margin: 10px;
}
#pay {
background: lightGreen;
font-weight: bold;
font-size: 300%;
color: white;
padding: 40px;
}
#pay.parking {
background: red;
}
</style>
</head>
<body>
<header>
</header>
<section>
<button id="increase">∧</button>
<h1>Meter #<span id="meter-id"></span></h1>
<article id="timer"></article>
<button id="decrease">∨</button>
<button id="pay">Pay<span id="amount"><span></button>
</section>
<footer>
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.0.0-alpha1/jquery.min.js"></script>
<script src="https://cdn.firebase.com/js/client/2.3.2/firebase.js"></script>
<script>
var username;
var status;
var startTime;
var firebaseRef = new Firebase("https://physical-web-modoki.firebaseio.com");
var timer;
var INCREASES = {
'00:00': '10:00',
'10:00': '20:00',
'20:00': '30:00',
'30:00': '40:00',
'40:00': '50:00',
'50:00': '60:00',
'60:00': '60:00',
};
var DECREASES = {
'00:00': '00:00',
'10:00': '00:00',
'20:00': '10:00',
'30:00': '20:00',
'40:00': '30:00',
'50:00': '40:00',
'60:00': '50:00',
};
var AMOUNTS = {
'00:00': '',
'10:00': ' $5',
'20:00': ' $10',
'30:00': ' $15',
'40:00': ' $20',
'50:00': ' $25',
'60:00': ' $30',
};
function setButtonsEnabled(bool) {
var val = !bool;
$('#increase').prop('disabled', val);
$('#decrease').prop('disabled', val);
$('#pay').prop('disabled', val);
}
function start(authData) {
username = authData.twitter.username;
var meterId = location.hash.replace('#', '');
if (!meterId) {
alert('set meter id');
}
$('#meter-id').text(meterId);
var meterRef = firebaseRef.child('meter-' + meterId);
meterRef.child('name').on('value', function(snapshot) {
var name = snapshot.val();
$('header').text(name);
});
meterRef.child('timer').on('value', function(snapshot) {
timer = snapshot.val() || '00:00';
$('#timer').text(timer);
});
meterRef.child('status').on('value', function(snapshot) {
stat = snapshot.val() || 'init';
if (stat.name === 'init') {
setButtonsEnabled(true);
$('#pay').removeClass('parking');
$('#pay').html('Pay<span id="amount"><span>');
}
else if (stat.name === 'paid') {
setButtonsEnabled(false);
}
else if (stat.name === 'parking') {
setButtonsEnabled(false);
$('#pay').addClass('parking');
$('#pay').text('Paid');
}
});
$('#increase').click(function() {
timer = INCREASES[timer];
meterRef.update({'timer': timer});
$('#amount').text(AMOUNTS[timer]);
});
$('#decrease').click(function() {
timer = DECREASES[timer];
meterRef.update({'timer': timer});
$('#amount').text(AMOUNTS[timer]);
});
$('#pay').click(function() {
meterRef.update({'status': {
name: 'paid',
user: username
}});
});
}
var authData = firebaseRef.getAuth();
if (!authData) {
firebaseRef.authWithOAuthPopup("twitter", function(error, authData) {
if (error) {
console.log(error);
alert(error);
}
else if (authData) {
console.log(authData);
start(authData);
}
});
}
else {
start(authData);
}
</script>
</body>
</html>