Help us understand the problem. What is going on with this article?

LINE Things なわとび

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

サーバ側ソース

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

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

image.png

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away