LINE Thingsを使った新次元のなわとび
2019/07/13 に開催された ProtoOut LINE Things ハッカソンで作成しました
サービスの魅力
- LINEアプリさえ入っていればカウントしてくれる
- 対象が子供から大人まで幅広い層で使える
- 利用ユーザーの敷居が低い
- スマホを置いた状態でカウントしてくれる(スポーツジム向け)
- たまに音声で応援してくれる!
ProtoOut LINE Things ハッカソン以降から発展させた点
1.LIFF画面の作成
2.カウントした値で消費カロリーの計算
3.カウントした値で判定して音声(mp3)で応援
今後の予定
obnizからM5STICK-Cに切り替えてなわとびの小型化
M5STICK-CをAliExpressで注文しましたが7/31時点でもまだ届かず…
技術仕様
ハードウェア
- obniz × 1
- ロードセル × 1
- なわとび(縄の部分だけ) 100円ぐらい
- オクトタツ(なわとびの持つところ) × 2
キングジムさんの倒れないペンケース 1個¥950
https://www.kingjim.co.jp/sp/octotatsu/ - モバイルバッテリー(縦長)
持ち手の選定
obniz、モバイルバッテリー、ロードセルを入れる為、ちょうど良いのがなかなかない。
ソフトウェア
- Node.js
- Messaging API
- LINE Things
- Firebase
- ngrok
デモ
動画は別撮りなのでカウントタイミング合ってないです…
https://youtu.be/LKrhviLLYHs
サーバ側ソース
LIFF側 JS用コード
liff.js
// User service UUID: Change this to your generated service UUID
const USER_SERVICE_UUID = '[USER_SERVICE_UUID]';
// LED, Button
// User service characteristics
const LED_CHARACTERISTIC_UUID = 'E9062E71-9E62-4BC6-B0D3-35CDCD9B027B';
const BTN_CHARACTERISTIC_UUID = '62FBD229-6EDD-4D1A-B554-5C4E1BB29169';
// PSDI Service UUID: Fixed value for Developer Trial
const PSDI_SERVICE_UUID = 'E625601E-9E55-4597-A598-76018A0D293D';
// Device ID
const PSDI_CHARACTERISTIC_UUID = '26E2B12B-85F0-4F3F-9FDD-91D114270E6E';
// UI settings
let ledState = false; // true: LED on, false: LED off
let clickCount = 0;
// -------------- //
// On window load //
// -------------- //
window.onload = function (e) {
// init で初期化。基本情報を取得。
liff.init(function (data) {
initializeApp(data);
});
};
//多分使ってない
function initializeApp(data) {
document.getElementById('languagefield').textContent = data.language;
document.getElementById('viewtypefield').textContent = data.context.viewType;
document.getElementById('useridfield').textContent = data.context.userId;
document.getElementById('utouidfield').textContent = data.context.utouId;
document.getElementById('roomidfield').textContent = data.context.roomId;
document.getElementById('groupidfield').textContent = data.context.groupId;
}
// ----------------- //
// Handler functions //
// ----------------- //
const SOUND_PATH = "https://[お疲れ様の音声].mp3";
function handlerToggleLed() {
ledState = !ledState;
uiToggleLedButton(ledState);
liffToggleDeviceLedState(ledState);
}
// ------------ //
// UI functions //
// ------------ //
function uiToggleLedButton(state) {
const el = document.getElementById("btn-led-toggle");
el.innerText = state ? "トレーニング開始" : "トレーニング終了";
const elCal = document.getElementById("cal-count");
const elCount = document.getElementById("click-count");
if (state) {
el.classList.add("led-on");
liff.sendMessages([
{
type:'text',
text: elCount.textContent + '飛んだよー' + elCal.textContent + "消費したよ"
}
])
.then(() => {
console.log('message sent');
})
.catch((err) => {
console.log('error', err);
});
liff.closeWindow();
} else {
el.classList.remove("led-on");
elCal.innerText = "0kcal";
elCount.innerText = "0";
}
}
function uiCountPressButton() {
clickCount++;
const el = document.getElementById("click-count");
const elCal = document.getElementById("cal-count");
const elGender = document.getElementById("gender");
const elWeight = document.getElementById("weight-text");
const elAge = document.getElementById("age-text");
//const elweight = document.getElementById("weight-text");
//if (clickCount % 5 ==0) {
if (clickCount == 10) {
//10回
var audio1 = new Audio("https://[音声1].mp3");
//audio1.play();
//10回
var audio2 = new Audio("https://[音声2].mp3");
//audio2.play();
//経過
var audio3 = new Audio("https://[音声3].mp3");
//audio3.play();
var audio4 = new Audio("https://[音声4].mp3");
audio1.play();
audio1.addEventListener("ended",function(){
audio2.play();
audio2.addEventListener("ended", function(){
audio3.play();
audio3.addEventListener("ended", function(){
audio4.play();
}, false);
}, false);
}, false);
//var audio = new Audio(SOUND_PATH);
//audio.play();
}
if (clickCount == 20 || clickCount == 30 || clickCount == 40 || clickCount == 50) {
var audio4 = new Audio("https://[がんばれ音声].mp3");
audio4.play();
}
//体重【kg】×0.1532×時間【分】×補正係数
//75×0.1532×10×0.96
el.innerText = clickCount + "回";
// form要素内のラジオボタングループ(name="hoge")を取得
var radioNodeList = elGender.genders ;
// 選択状態の値(value)を取得 (Bが選択状態なら"b"が返る)
var selGender = radioNodeList.value ;
if( selGender == "male") {
if(elAge.value >= 30 || elAge.value <= 39 ){
elCal.innerText = Math.round(elWeight.value * 0.1532 * (clickCount/2/60) * 0.96) + "kcal" ;
}
}else {
elCal.innerText = Math.round(elWeight.value * 0.1532 * (clickCount/2/60) * 0.87) + "kcal" ;
}
}
function uiToggleStateButton(pressed) {
const el = document.getElementById("btn-state");
if (pressed) {
//el.classList.add("pressed");
//el.innerText = "Pressed";
} else {
//el.classList.remove("pressed");
//el.innerText = "Released";
}
}
function uiToggleDeviceConnected(connected) {
const elStatus = document.getElementById("status");
const elControls = document.getElementById("controls");
elStatus.classList.remove("error");
if (connected) {
// Hide loading animation
uiToggleLoadingAnimation(false);
// Show status connected
elStatus.classList.remove("inactive");
elStatus.classList.add("success");
elStatus.innerText = "LINEと接続中";
// Show controls
elControls.classList.remove("hidden");
} else {
// Show loading animation
uiToggleLoadingAnimation(true);
// Show status disconnected
elStatus.classList.remove("success");
elStatus.classList.add("inactive");
elStatus.innerText = "LINEと未接続";
// Hide controls
elControls.classList.add("hidden");
}
}
function uiToggleLoadingAnimation(isLoading) {
const elLoading = document.getElementById("loading-animation");
if (isLoading) {
// Show loading animation
elLoading.classList.remove("hidden");
} else {
// Hide loading animation
elLoading.classList.add("hidden");
}
}
function uiStatusError(message, showLoadingAnimation) {
uiToggleLoadingAnimation(showLoadingAnimation);
const elStatus = document.getElementById("status");
const elControls = document.getElementById("controls");
// Show status error
elStatus.classList.remove("success");
elStatus.classList.remove("inactive");
elStatus.classList.add("error");
elStatus.innerText = message;
// Hide controls
elControls.classList.add("hidden");
}
function makeErrorMsg(errorObj) {
return "Error\n" + errorObj.code + "\n" + errorObj.message;
}
// -------------- //
// LIFF functions //
// -------------- //
function initializeApp() {
liff.init(() => initializeLiff(), error => uiStatusError(makeErrorMsg(error), false));
}
function initializeLiff() {
liff.initPlugins(['bluetooth']).then(() => {
liffCheckAvailablityAndDo(() => liffRequestDevice());
}).catch(error => {
uiStatusError(makeErrorMsg(error), false);
});
}
function liffCheckAvailablityAndDo(callbackIfAvailable) {
// Check Bluetooth availability
liff.bluetooth.getAvailability().then(isAvailable => {
if (isAvailable) {
uiToggleDeviceConnected(false);
callbackIfAvailable();
} else {
uiStatusError("Bluetooth not available", true);
setTimeout(() => liffCheckAvailablityAndDo(callbackIfAvailable), 10000);
}
}).catch(error => {
uiStatusError(makeErrorMsg(error), false);
});;
}
function liffRequestDevice() {
liff.bluetooth.requestDevice().then(device => {
liffConnectToDevice(device);
}).catch(error => {
uiStatusError(makeErrorMsg(error), false);
});
}
function liffConnectToDevice(device) {
device.gatt.connect().then(() => {
//document.getElementById("device-name").innerText = device.name;
//document.getElementById("device-id").innerText = device.id;
// Show status connected
uiToggleDeviceConnected(true);
// Get service
device.gatt.getPrimaryService(USER_SERVICE_UUID).then(service => {
liffGetUserService(service);
}).catch(error => {
uiStatusError(makeErrorMsg(error), false);
});
device.gatt.getPrimaryService(PSDI_SERVICE_UUID).then(service => {
liffGetPSDIService(service);
}).catch(error => {
uiStatusError(makeErrorMsg(error), false);
});
// Device disconnect callback
const disconnectCallback = () => {
// Show status disconnected
uiToggleDeviceConnected(false);
// Remove disconnect callback
device.removeEventListener('gattserverdisconnected', disconnectCallback);
// Reset LED state
ledState = false;
// Reset UI elements
uiToggleLedButton(false);
uiToggleStateButton(false);
// Try to reconnect
initializeLiff();
};
device.addEventListener('gattserverdisconnected', disconnectCallback);
}).catch(error => {
uiStatusError(makeErrorMsg(error), false);
});
}
function liffGetUserService(service) {
// Button pressed state
service.getCharacteristic(BTN_CHARACTERISTIC_UUID).then(characteristic => {
liffGetButtonStateCharacteristic(characteristic);
}).catch(error => {
uiStatusError(makeErrorMsg(error), false);
});
// Toggle LED
service.getCharacteristic(LED_CHARACTERISTIC_UUID).then(characteristic => {
window.ledCharacteristic = characteristic;
// Switch off by default
liffToggleDeviceLedState(false);
}).catch(error => {
uiStatusError(makeErrorMsg(error), false);
});
}
function liffGetPSDIService(service) {
// Get PSDI value
service.getCharacteristic(PSDI_CHARACTERISTIC_UUID).then(characteristic => {
return characteristic.readValue();
}).then(value => {
// Byte array to hex string
const psdi = new Uint8Array(value.buffer)
.reduce((output, byte) => output + ("0" + byte.toString(16)).slice(-2), "");
//20190715
//document.getElementById("device-psdi").innerText = psdi;
}).catch(error => {
uiStatusError(makeErrorMsg(error), false);
});
}
function liffGetButtonStateCharacteristic(characteristic) {
// Add notification hook for button state
// (Get notified when button state changes)
characteristic.startNotifications().then(() => {
characteristic.addEventListener('characteristicvaluechanged', e => {
const val = (new Uint8Array(e.target.value.buffer))[0];
if (val > 0) {
// press
uiToggleStateButton(true);
} else {
// release
uiToggleStateButton(false);
uiCountPressButton();
}
});
}).catch(error => {
uiStatusError(makeErrorMsg(error), false);
});
}
function liffToggleDeviceLedState(state) {
// on: 0x01
// off: 0x00
window.ledCharacteristic.writeValue(
state ? new Uint8Array([0x01]) : new Uint8Array([0x00])
).catch(error => {
uiStatusError(makeErrorMsg(error), false);
});
}
LIFF側 HTMLソース
index.html
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
<script src="https://d.line-scdn.net/liff/1.0/sdk.js"></script>
<script src="liff.js"></script>
<link rel="stylesheet" type="text/css" href="liff.css" />
</head>
<body>
<!-- Title -->
<h2>LINE Things なわとび</h2>
<hr />
<img id="loading-animation" class="" src="loading.gif" height="14px" />
<span id="status" class="inactive">LINEに接続中</span>
<hr />
<div id="controls" class="hidden">
<!-- Device Info Table -->
<table class="device-info-table">
<tr>
<th>体重:</th>
<td><input type="text" class="liffinput" name="weight" maxlength="3" value="63" id="weight-text"> kg
</td>
</tr>
<tr>
<th>年齢:</th>
<td><input type="text" class="liffinput" name="age" maxlength="3" id="age-text" value="18"> 才
</td>
</tr>
<tr>
<th>性別:</th>
<td>
<form id="gender">
<input type="radio" name="genders" class="liffRadio" value="male" checked="checked">男性
<input type="radio" name="genders" class="liffRadio" value="female">女性
</form>
</td>
</tr>
<tr>
<th class="device-info-cell device-info-key"><img src="icon/ico_jumprope.png" alt="飛んだ回数" ></th>
<td class="device-info-cell device-info-val" id="click-count">0回</td>
</tr>
<tr>
<th class="device-info-cell device-info-key"><img src="icon/ico_barn.png" alt="消費カロリー" ></th>
<td class="device-info-cell device-info-val" id="cal-count">0kcal</td>
</tr>
</table>
<hr />
<!-- Toggle LED -->
<p>
<button id="btn-led-toggle" class="btn btn-led-toggle" onclick="handlerToggleLed()">トレーニング終了</button>
</p>
<hr />
</div>
</body>
</html>
LIFF側 CSS
liff.css
body, table {
font-size: 14pt;
font-family: "Montserrat", sans-serif;
margin: 0pt;
text-align: center;
padding-top: 18pt;
background-color: #0099ff;
color: #ffffff;
}
p {
padding: 0pt 14pt;
}
input.liffinput {
font-size: 14pt;
font-family: "Montserrat", sans-serif;
text-align: center;
width: 50px;
}
input.liffRadio {
width: 2em;
height: 2em;
}
.btn {
padding: 2pt;
font-size: 14pt;
text-align: center;
text-decoration: none;
display: inline-block;
}
.btn-led-toggle {
box-shadow: 0 4pt 8pt 0 rgba(0,0,0,0.2), 0 3pt 10pt 0 rgba(0,0,0,0.19);
color: #f8f9fa; /* Light */
background-color: #e30e2a; /* Tranig-End Red */
width: 190pt;
font-weight: bold;
height: 40pt;
border: 2pt solid black;
border-radius: 8pt;
}
.btn-led-toggle:active {
transform: translateY(4pt);
box-shadow: 0 2pt 4pt 0 rgba(0,0,0,0.2), 0 1pt 5pt 0 rgba(0,0,0,0.19);
background-color: #e30e2a; /* Tranig-End Red */
}
.led-on, .led-on:active {
background-color: #16C464; /* LINE Green */
}
.btn-state {
box-shadow: 0pt 6pt #999;
border-radius: 4pt;
margin-bottom: 8pt;
width: 110pt;
border: 1pt solid #6c757d; /* Gray */
color: #6c757d; /* Gray */
background-color: #f8f9fa; /* Light */
}
.btn-state:active, .pressed {
color: #343a40; /* Dark */
border: 1pt solid #343a40; /* Dark */
box-shadow: 0 2pt #666;
transform: translateY(4pt);
}
.inactive {
color: #6c757d; /* Gray */
}
.success {
color: #ffffff; /* White LINE Green */
}
.error {
color: #dc3545; /* Red */
}
.hidden {
display: none;
}
.device-info-table {
width: 80%;
margin: auto;
padding: 0;
}
.device-info-cell {
text-align: left;
vertical-align: middle;
padding-bottom: 8px;
}
.device-info-key {
padding-right: 10px;
word-wrap: none;
}
.device-info-val {
padding-left: 10px;
word-wrap: break-word;
max-width: 200px;
font-family: monospace, monospace;
}