LoginSignup
12
9

More than 3 years have passed since last update.

LINE Things なわとび

Last updated at Posted at 2019-07-17

LINE Thingsを使った新次元のなわとび

2019/07/13 に開催された ProtoOut LINE Things ハッカソンで作成しました

image.png

image.png

サービスの魅力

  • 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/
  • モバイルバッテリー(縦長)

持ち手の選定

なるべくおしゃれな「持つところ」を探す!
image.png

obniz、モバイルバッテリー、ロードセルを入れる為、ちょうど良いのがなかなかない。

image.png

ソフトウェア

  • Node.js
  • Messaging API
  • LINE Things
  • Firebase
  • ngrok

デモ

動画は別撮りなのでカウントタイミング合ってないです…
https://youtu.be/LKrhviLLYHs
IMAGE ALT TEXT HERE

サーバ側ソース

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

2.LINE Things有効用QRコードをLINEから読み取る

image.png

12
9
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
9