LINE Things なわとび

#LINE Thingsを使った新次元のなわとび
__2019/07/13 に開催された ProtoOut LINE Things ハッカソンで作成しました__

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/399986/1efcaadf-51f0-c130-158f-d5a316ab90e4.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/399986/b2d5c5d3-e101-01ef-8257-1c4d84fc9216.png)

##サービスの魅力
- LINEアプリさえ入っていればカウントしてくれる
- 対象が子供から大人まで幅広い層で使える
- 利用ユーザーの敷居が低い
- スマホを置いた状態でカウントしてくれる（スポーツジム向け）
- たまに音声で応援してくれる！

##ProtoOut LINE Things ハッカソン以降から発展させた点
１．LIFF画面の作成
２．カウントした値で消費カロリーの計算
３．カウントした値で判定して音声(mp3)で応援


今後の予定
obnizからM5STICK-Cに切り替えてなわとびの小型化
M5STICK-CをAliExpressで注文しましたが7/31時点でもまだ届かず…


##技術仕様
###ハードウェア
- obniz × 1
- ロードセル  × 1
- なわとび(縄の部分だけ)  100円ぐらい
- オクトタツ(なわとびの持つところ) × 2  
キングジムさんの倒れないペンケース 1個¥950  
https://www.kingjim.co.jp/sp/octotatsu/
- モバイルバッテリー(縦長)

## 持ち手の選定
なるべくおしゃれな「持つところ」を探す！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/399986/6eb1c70a-0318-3655-fed6-35acf7080043.png)


###obniz、モバイルバッテリー、ロードセルを入れる為、ちょうど良いのがなかなかない。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/399986/68066a0f-8937-8013-5773-7141683ec988.png)


###ソフトウェア
- Node.js
- Messaging API
- LINE Things
- Firebase
- ngrok

##デモ
動画は別撮りなのでカウントタイミング合ってないです…
https://youtu.be/LKrhviLLYHs
[![IMAGE ALT TEXT HERE](http://img.youtube.com/vi/LKrhviLLYHs/0.jpg)](http://www.youtube.com/watch?v=LKrhviLLYHs)

## サーバ側ソース
https://github.com/iizuka2019/LINENAWATOBI

## 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;
}

```

##初回設定
### 1.LINE BoT用QRコードをLINEから読み取る
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/399986/e73a8c02-a720-0fae-e480-4a33f6d9baf6.png)

### 2.LINE Things有効用QRコードをLINEから読み取る
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/399986/0fc4f24c-adc2-4678-8c86-c7d4cb39feba.png)