🚴‍♂️

2019年:【フィットネスバイク】のインターネットを作る!!

2021/09/11に公開

※注意)Qiitaからの移転で、2019年12月10日に投稿した記事なので、内容はかなり古いです

岡田悠さん執筆のオモコロ記事「エアロバイクをGoogleマップに連携して日本縦断の旅に出ます」のきっかけとなった記事です

取り急ぎ触ってみたい!という人に向けて『専用アダプター』をBOOTHで販売開始しました。匿名配送で安心の【あんしんBOOTHパック】対応です。 ※販売を停止しています

目的 (やりたいこと)

  • インターネットにつながる、フィットネスバイクを作りたい (↓こんな風に)
    実現したいイメージ

要は、フィットネスジム『FEELCYCLE』のようなエンターテインメント・バイクエクササイズを、「自宅に居ながらできる」ような世界を目指す感じです。(後述のPelotonとかと同じ思想)

背景・動機

数か月前に「勉強しながら運動したい」という思いから、テーブル付きのフィットネスバイクを購入したのですが、

ながらバイク4518
↑買ったやつ ※¥21,971 (12/09 23時半時点)
https://www.amazon.co.jp/gp/product/B077JPJZ3Y/
https://www.alinco.co.jp/product/fitness/detail/id=4162

ふと

  • 「トレーニング時間や走行距離なんかを、記録とるの面倒だな…」
  • 「自動でGoogleスプレッドシートに連携できたりしないかな」
  • 「できたらゲーム風にしたいな」
  • 「もっと言うと、みんなで一緒にフィットネスできないかな」

などと考えるようになり、Arduino使って Internet of FitnessBike 的な感じでやったら出来るんじゃないか、と考え、意外と近いところまで実現できたので、その覚書になります。

あと、これはとっても大事なことなのですが、今回作成するアダプターやアプリは、上記の「ながらバイク4518」でのみ、動作確認しています。 (他の製品は「メーターコードがない」「メーターコードがあっても仕様が異なる」等の可能性があるため、「ながらバイク4518」以外の動作保証はできません)[1]

構成図

構成は以下のようになります。

構成図

作り方

作りモノは、ざっくり以下の通り。

  • 専用アダプター
    • ハードウェア (arduinoマイコン、3Dプリント)
    • ソフトウェア (arduino IDE、Blender)
  • スマホアプリ (Android ※USBSerial,QRcode,Firestore,SQLite)
  • クラウド関連 (Firebase Cloud Firestore、Google Maps API)
  • Webアプリ (nginx, jquery ※StreetView,QRcode,Firestore,cookie)

改めてイラストにすると以下のような感じになります。

データフロー

専用アダプターを作る

フィットネスバイクから得られる回転情報を、スマホへ転送するハードウェアを作成します。

サイクルメーターの原理

まずは、サイクルメーターの原理を簡単にご説明。

ながらバイク4518のクランク内部を見ると、回転ドラムに付けられた磁石とスイッチが見つかります(下図)。
フィットネスバイク内部-クランクとドラム

このスイッチは、磁石(磁力)でスイッチが入る(通電する)ようになっており、ドラムの1点に磁石が取り付けられているので、ドラム1回転につき1度、スイッチがONになる仕様になっています。

このスイッチに接続された銅線は、メーターコードとして、フィットネスバイクに付属の表示メーターに接続され、ON/OFFの周期からドラムの回転数を判断し、そこから走行スピードと走行距離を割り出す仕組みになっています。

で、このメーターコードを付属の表示メーターではなく、Arduinoに接続して「回転データを取得して、スマホへ転送しよう」というのが、今回作成する専用アダプターの目的です。

具体的には、下図のような仕組みになります。

ONOFF原理

Arduinoの7番ピングランドをメータープラグに接続し、通常HIGH状態の7番ピンがLOW状態になったタイミングが1回転したタイミングとなります。

1回転にかかった時間(LOWタイミングの時間差)と、1回転で進むであろう距離(てきとうに決める)を用いれば、走行速度を求められます。

用意するものモノ

以下は、専用アダプターを作るにあたって必要な機材一覧です。
自分が使っているモノで、あくまで一例です。
他の機材でも、基本問題ないかと思います。

ハードウェアの組み立て

組み立て.png

用意した「Arduino nano 互換機」「ステレオジャック」「ワイヤ」「USB Mini-B/Type-C ケーブル」を組み立てます。
上の写真のように、ワイヤをはんだ付けしてください。
Arduino側は、7番PINとGNDです。ステレオジャックの方は、対角に並んでるピンへ接続すれば、どちらに接続してもOKです(通電チェックなので)。

アダプタ用の専用ケースは無くても良いのですが、3Dプリンタがある方は、ThingiverseにSTLファイル等をアップしましたので、良かったらプリントしてみてください。

BlenderはCadソフトではないとは分かっていながらも、Blenderに慣れるべくBlenderで作ったので、けっこう雑な作りの3Dモデルになってます。すんません…

Arduinoにスケッチを書き込む

書き込むスケッチは、下記のとおりです。

int PIN = 7;
int HighLow = LOW;
int mtr = 5.000; // 1回転で進む距離(m)
unsigned long int now = 0;
unsigned long int pre = 0;

void setup()
{
  Serial.begin(9600);
  pinMode(PIN, INPUT_PULLUP); // default High
}

void loop()
{
  now = millis();
  HighLow = digitalRead(PIN);
  if (HighLow == LOW) {
    if (pre > 0) {
      float d = now - pre;
      float km = mtr / 1000.000;
      float th = d / 3600000.000; // 1000 * 60 * 60
      int spd = km / th; // 速度の計算
      if ( spd > 0 and spd < 100 ) { // 正常な値の速度のみ
        Serial.print(spd); // シリアル出力、これをUSB経由でスマホが読み取る
        delay(400);
      }
    }
    pre = now;
  }
}

Arduino IDE のインストール方法等は、他の記事に譲りますが、HiLetgo Nanoを使う場合は、別途ドライバーをインストールする必要があります。

ドライバーが正常にインストールされていれば、下記のような感じでデバイスマネージャーに表示されるかと思います。※COM番号は、5以外の可能性もあります
COM_PORT.png

ドライバのインストールが上手くいかない場合など、その辺で躓きたくない場合は、少々お高いですが Arduino nano 正規品 であれば、その辺はスムースかと思います。
https://www.switch-science.com/catalog/2554/

書き込み設定は、下記で行けました。

  • ボード: Arduino Nano
  • プロセッサ: ATmega328P (Old Bootloader)
  • 書込装置: ArduinoISP

以上で、専用アダプターの作成は完了です。

専用スマホ(Android)アプリを作る

専用スマホ(Android)アプリの見た目は、↓こんな感じ。

キャプチャ

画面左下のボタンを押すとQRコードリーダーが表示され、WebアプリのQRコード(後述)を読み込むと、ユーザー情報がスマホ側にもセットされる(dummyユーザーから切り替わる)。

※Androidアプリはあまり作り慣れていないので、以下、非効率や不適切な部分が多いかもしれませんが、一応動いてはいる気配です

Android Studio上の作業

概要

Android アプリを作成するにあたっては、下記の要素を盛り込みました。

  • Usb Srial Reader: usbSerialForAndroid
    専用アダプタからUSB経由でのシリアル通信を受けつける
  • Cloud Data Store: Firebase Cloud Firestore
    走行距離データをクラウドのデータストアに保存
  • QRcode Reader: ZXing
    保存データはユーザー毎で、Webアプリ上のQRコード(ユーザー情報)を読み込む
  • Local Data Store: SQLiteOpenHelper
    ユーザー自体はWebアプリへの初回アクセス時に自動生成され、QRコード経由でスマホ側へ同期します

実際のコードなど

環境: Android Stuido 3.5.3

新規プロジェクト

  • 新規Project(Empty Activity)を作成。プロジェクト名などは任意で。
  • Minimum API level: **API 19: Android 4.4 (KitKat)**を選択
  • https://github.com/mik3y/usb-serial-for-android/releases から Source code (zip) をダウンロード
  • Zipファイルを展開し、usbSerialForAndroidフォルダを、プロジェクトのフォルダーへコピーする(下図)
    usbSerialCopy.png
  • 以下に、追加・変更するコード群を列挙します。
    bike.ie4.myapplication部分は、適宜変更してください。
settings.gradle
include ':app', ':usbSerialForAndroid'
rootProject.name='myapplication'
build.gradle
// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    repositories {
        google()
        jcenter()
        
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.5.3'
        classpath 'com.google.gms:google-services:4.3.2'
        
        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

allprojects {
    repositories {
        google()
        jcenter()
        
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

apply plugin: 'com.android.application'

android {
    compileSdkVersion 29
    buildToolsVersion "29.0.2"
    defaultConfig {
        applicationId "bike.ie4.myapplication"
        minSdkVersion 26
        targetSdkVersion 29
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        multiDexEnabled true
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

dependencies {
    implementation 'com.android.support:multidex:1.0.3'
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'androidx.appcompat:appcompat:1.0.2'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    implementation 'com.google.firebase:firebase-core:17.0.0'
    implementation 'com.google.firebase:firebase-analytics:17.2.0'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test:runner:1.1.1'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
    implementation project(':usbSerialForAndroid')
    implementation 'com.google.firebase:firebase-firestore:21.3.0'
    implementation 'com.journeyapps:zxing-android-embedded:3.6.0'
}

apply plugin: 'com.google.gms.google-services'
【新規ディレクトリ、新規ファイル作成】app\src\main\res\xml\device_filter.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- 0x0403 / 0x6001: FTDI FT232R UART -->
    <usb-device vendor-id="1027" product-id="24577" />
    
    <!-- 0x0403 / 0x6015: FTDI FT231X -->
    <usb-device vendor-id="1027" product-id="24597" />

    <!-- 0x2341 / Arduino -->
    <usb-device vendor-id="9025" />

    <!-- 0x16C0 / 0x0483: Teensyduino  -->
    <usb-device vendor-id="5824" product-id="1155" />

    <!-- 0x10C4 / 0xEA60: CP210x UART Bridge -->
    <usb-device vendor-id="4292" product-id="60000" />
    
    <!-- 0x067B / 0x2303: Prolific PL2303 -->
    <usb-device vendor-id="1659" product-id="8963" />
    
    <!-- 0x1a86 / 0x7523: Qinheng CH340 -->
    <usb-device vendor-id="6790" product-id="29987" />
</resources>
app\src\main\AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="bike.ie4.myapplication">
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity" android:screenOrientation="landscape">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <meta-data
                android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
                android:resource="@xml/device_filter" />
        </activity>
    </application>
</manifest>
usbSerialForAndroid\src\main\AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
      package="com.hoho.android.usbserial"
      android:versionCode="1"
      android:versionName="1.0">
</manifest>

↓このファイルは、後述のFirebase Cloud Firestoreで入手する設定ファイルになります。

【新規ファイル作成】app\google-services.json
{
  "project_info": {
    "project_number": "123456789000",
    "firebase_url": "https://ie4bike.firebaseio.com",
    "project_id": "ie4bike",
    "storage_bucket": "ie4bike.appspot.com"
  },
  "client": [
    {
      "client_info": {
        "mobilesdk_app_id": "1:123456789000:android:1234567890abfdef000000",
        "android_client_info": {
          "package_name": "bike.ie4.myapplication"
        }
      },
-------- snip --------
    }
  ],
  "configuration_version": "1"
}
app\src\main\res\layout\activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/messageTextView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:paddingTop="20dp"
        android:paddingStart="40dp">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="110dp"
            android:orientation="horizontal">
            <TextView
                android:id="@+id/labelSpd"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="0dp"
                android:text="SPD: "
                android:textSize="240px" />
            <TextView
                android:id="@+id/valSpd"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="0"
                android:textColor="#009900"
                android:textSize="240px" />
            <TextView
                android:id="@+id/labelSpdUnit"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="0dp"
                android:text=" km/h"
                android:textSize="80px" />
        </LinearLayout>

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="140dp"
            android:orientation="horizontal">
            <TextView
                android:id="@+id/labelDistance"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="0dp"
                android:text="Dist: "
                android:textSize="240px" />
            <TextView
                android:id="@+id/valDistance"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="0"
                android:textColor="#009900"
                android:textSize="240px" />
            <TextView
                android:id="@+id/labelDistanceUnit"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="0dp"
                android:text=" m"
                android:textSize="80px" />
        </LinearLayout>

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="111dp"
            android:orientation="horizontal">

            <Button
                android:id="@+id/button"
                android:layout_width="206dp"
                android:layout_height="60dp"
                android:text="QR Scan - Sync User" />

            <TextView
                android:id="@+id/textView"
                android:layout_width="140dp"
                android:textSize="60px"
                android:layout_height="wrap_content"
                android:text="   UserName: " />

            <TextView
                android:id="@+id/valUserName"
                android:textSize="80px"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:text="username" />
        </LinearLayout>

    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>
app\src\main\res\values\strings.xml
<resources>
    <string name="app_name">Internet of Fitness Bike</string>
</resources>
app\src\main\res\values\styles.xml
<resources>
    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="android:windowFullscreen">true</item>
        <item name="colorAccent">@color/colorAccent</item>
    </style>
</resources>
【新規ファイル作成】app\src\main\java\bike\ie4\app\DbOpenHelper.java
package bike.ie4.myapplication;

import android.content.ContentValues;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;

class DbOpenHelper extends SQLiteOpenHelper {

    private static final int DATABASE_VERSION = 1;
    private static final String DATABASE_NAME = "TestDB.db";
    private static final String TABLE_NAME = "testdb";
    private static final String _ID = "_id";
    private static final String COLUMN_NAME_USERID = "userid";
    private static final String COLUMN_NAME_USERNAME = "username";

    private static final String SQL_CREATE_ENTRIES =
            "CREATE TABLE " + TABLE_NAME + " (" +
                    _ID + " INTEGER PRIMARY KEY," +
                    COLUMN_NAME_USERID + " TEXT," +
                    COLUMN_NAME_USERNAME + " TEXT)";

    private static final String SQL_DELETE_ENTRIES =
            "DROP TABLE IF EXISTS " + TABLE_NAME;

    DbOpenHelper(Context context) {
        super(context, DATABASE_NAME, null, DATABASE_VERSION);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(
                SQL_CREATE_ENTRIES
        );
    }

    @Override
    public void onUpgrade(SQLiteDatabase db,
                          int oldVersion, int newVersion) {
        db.execSQL(
                SQL_DELETE_ENTRIES
        );
        onCreate(db);
    }

    public void onDowngrade(SQLiteDatabase db,
                            int oldVersion, int newVersion) {
        onUpgrade(db, oldVersion, newVersion);
    }

    public void saveData(SQLiteDatabase db, String userid, String username){
        ContentValues values = new ContentValues();
        values.put("userid", userid);
        values.put("username", username);
        db.replace("testdb", null, values);
    }
}
app\src\main\java\bike\ie4\myapplication\MainActivity.java
package bike.ie4.myapplication;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.hardware.usb.UsbDevice;
import android.hardware.usb.UsbDeviceConnection;
import android.hardware.usb.UsbManager;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.view.WindowManager;
import android.widget.TextView;
import java.util.Date;
import java.util.HashMap;
import java.io.*;
import java.util.List;
import java.util.Map;
import com.google.android.gms.tasks.OnFailureListener;
import com.google.android.gms.tasks.OnSuccessListener;
import com.google.firebase.Timestamp;
import com.google.firebase.firestore.FieldValue;
import com.google.firebase.firestore.FirebaseFirestore;
import com.google.firebase.firestore.SetOptions;
import com.google.zxing.integration.android.IntentIntegrator;
import com.google.zxing.integration.android.IntentResult;
import com.hoho.android.usbserial.driver.*;

public class MainActivity extends AppCompatActivity {

    private TextView mTextUserName;
    private TextView mTextSpdView;
    private TextView mTextDistanceView;

    private UsbManager mUsbManager;

    private UsbDevice mUsbDevice;

    private UsbSerialDriver usb;

    private UsbSerialPort port;

    private float spd;
    private int distance;
    private String userid = "dummy";
    private String username = "dummy";

    private DbOpenHelper helper;

    private FirebaseFirestore db = FirebaseFirestore.getInstance();
    private Map<String, Object> user = new HashMap<>();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);

        helper = new DbOpenHelper(getApplicationContext());

        setUserID();
        mTextUserName = (TextView) findViewById(R.id.valUserName);
        mTextUserName.setText(username);

        findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                new IntentIntegrator(MainActivity.this).initiateScan();
            }
        });
    }

    public void onResume() {
        super.onResume();

        mTextSpdView = (TextView) findViewById(R.id.valSpd);
        mTextDistanceView = (TextView) findViewById(R.id.valDistance);

        mUsbManager = (UsbManager) getSystemService(Context.USB_SERVICE);

        // Find all available drivers from attached devices.
        UsbManager manager = (UsbManager) getSystemService(Context.USB_SERVICE);
        List<UsbSerialDriver> availableDrivers = UsbSerialProber.getDefaultProber().findAllDrivers(manager);
        if (availableDrivers.isEmpty()) {
            Log.v("arduino", "drivers is empty!!");
            return;
        }

        // Open a connection to the first available driver.
        UsbSerialDriver driver = availableDrivers.get(0);
        mUsbDevice = driver.getDevice();
        if (!mUsbManager.hasPermission(mUsbDevice)) {
            mUsbManager.requestPermission(mUsbDevice,
                    PendingIntent.getBroadcast(MainActivity.this, 0, new Intent("start"), 0));
            return;
        }

        UsbDeviceConnection connection = manager.openDevice(mUsbDevice);
        if (connection == null) {
            Log.v("arduino", "connection null!!");
            // You probably need to call UsbManager.requestPermission(driver.getDevice(), ..)
            return;
        }

        // Read some data! Most have just one port (port 0).
        port = driver.getPorts().get(0);
        try {
            port.open(connection);
            port.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE);
            start_read_thread();
        } catch (IOException e) {
            // Deal with error.
        } finally {
        }
    }

    public void start_read_thread(){
        new Thread(new Runnable(){
            public void run(){
                String msg;
                spd = 0;
                distance = 0;
                boolean updFlg = false;
                try{
                    while(true){
                        byte buffer[] = new byte[16];
                        int numBytesRead = port.read(buffer, buffer.length);
                        if (numBytesRead > 0) {
                            msg = new String(buffer, 0, numBytesRead);
                            spd = Integer.parseInt(msg);
                            Log.v("arduino", Float.toString(spd));
                            distance += 3;
                            updFlg = true;
                        }else if (spd > 0){
                            spd -= 0.1;
                        }else{
                            spd = 0;
                        }
                        if (distance % 15 == 0 && updFlg) {
                            updFlg = false;
                            saveStore((int)spd, distance);
                        }
                        mTextDistanceView.post(new Runnable() {
                            @Override
                            public void run() {
                                mTextDistanceView.setText(Integer.toString(distance));
                            }
                        });
                        mTextSpdView.post(new Runnable() {
                            @Override
                            public void run() {
                                mTextSpdView.setText(Integer.toString((int)spd));
                            }
                        });
                        Thread.sleep(10);
                    }
                }
                catch(IOException e){
                    e.printStackTrace();
                }
                catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }

    private void saveStore(int spd, int dist){
        if ( spd == 0 ) { return; }
        Log.v("arduino", "saveStore: "+Float.toString(spd)+" / dist: "+dist);
        user.put("date", new Timestamp(new Date()));
        user.put("distance", FieldValue.increment(15));
        db.collection("cycling").document(userid).set(user, SetOptions.merge())
                .addOnSuccessListener(new OnSuccessListener() {
                    @Override
                    public void onSuccess(Object o) {
                        Log.d("arduino", "Document added !");
                    }
                })
                .addOnFailureListener(new OnFailureListener() {
                    @Override
                    public void onFailure(@NonNull Exception e) {
                        Log.w("arduino", "Error adding document", e);
                    }
                });
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        IntentResult result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data);
        if(result != null) {
            String qrstr = result.getContents();
            String[] qrcontents = qrstr.split(":", 0);
            if (qrcontents.length == 2) {
                username = qrcontents[0];
                userid = qrcontents[1];
                Log.d("readQR",userid);
                SQLiteDatabase db = helper.getWritableDatabase();
                helper.onUpgrade(db, 0, 0);
                helper.saveData(db, userid, username);
                mTextUserName.setText(username);
            }
        } else {
            super.onActivityResult(requestCode, resultCode, data);
        }
    }

    private void setUserID() {
        SQLiteDatabase db = helper.getReadableDatabase();
        Cursor cursor = db.query(
                "testdb",
                new String[] { "userid", "username" },
                null, null,
                null,null,null);
        int cnt = cursor.getCount();
        if (cnt > 0) {
            cursor.moveToFirst();
            int index_userid = cursor.getColumnIndex("userid");
            userid = cursor.getString(index_userid);
            int index_username = cursor.getColumnIndex("username");
            username = cursor.getString(index_username);
        }else{
            userid = "dummy";
            username = "dummy";
        }
    }

}
  • コードの追加・変更が出来たら、適宜SyncNowする
    SyncNow.png
  • 特に問題がなければ、上記の改修でBuildできるはずです。

クラウド側の準備

データストレージの準備 (Firebase Cloud Firestore)

  • FirebaseのDatabaes Cloud Firestore で、cyclingというコレクションを作成し、dummyという初期ドキュメントを設置します。
  • お試しなので、ルールは取り急ぎallow read, write: if true;としました。
  • ここで入手するファイルgoogle-services.jsonは、スマホアプリ側で利用します。

firestore.png

ストリートビューAPIの準備 (Google Map API)

Maps JavaScript API が有効なAPIキーを作成します。

MapsJSAPI.png

Webアプリの準備

最後に、フィットネスバイクに乗りながら楽しむWebアプリとして、「フィットネスバイクに連動して、ストリートビューが前進する」を作成しました。

ie4bike_web.png

以前話題になった「Street View Random Walker さまよえる私」にかなり影響を受けてます。
また、ユーザー名の自動生成のために 三毛猫さんの英語圏人名集をお借りしています。

初回アクセス時にユーザー名を自動登録しCookieに保存、ユーザー情報のQRコードを生成します。
上記で作成したスマホアプリでQRを読み込むと、ユーザー情報がスマホ側に伝わり、フィットネスバイクを数メートル分こぐと、ストリートビューが前進するようになります。

↑Web上のソースを直接見てもらった方が早いかもしれませんが、一応、ソースも記載しておきます。

ファイル構成
$ tree
.
|-- index.html
|-- js
|   |-- bike.js
|   |-- firebase_config.js
|   `-- username_list.txt
`-- style
    `-- bike.css
index.html
<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<title>Internet of (FitnessBike) - Street View Bike by ie4.bike</title>
		<link rel="stylesheet" type="text/css" href="//cdn.jsdelivr.net/npm/normalize.css@8.0.1/normalize.css">
		<link rel="stylesheet" type="text/css" href="style/bike.css">
	</head>
	<body>

		<div id="left">
			<div id="map"></div>
			<div id="comments"></div>
		</div>
		<div id="pano"></div>
		<div id="place"></div>
		<div id="qr"></div>

		<script src="https://code.jquery.com/jquery-3.3.1.js"></script>
		<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-cookie/1.4.1/jquery.cookie.min.js"></script>
		<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.qrcode/1.0/jquery.qrcode.min.js"></script>

		<script src="https://www.gstatic.com/firebasejs/5.8.1/firebase-app.js"></script>
		<script src="https://www.gstatic.com/firebasejs/5.8.1/firebase-firestore.js"></script>
		<script src="js/firebase_config.js"></script>
		<script src="js/bike.js"></script>
		<script 
				 src="https://maps.googleapis.com/maps/api/js?key=<Your Google Maps API Key>&libraries=places&callback=initialize">
		</script>

	</body>
</html>
js/bike.js
var panorama;
var map;
var userid;
var username;
var db;
var distance;
var predistance;

function initialize() {

	userid = $.cookie("userid");
	if (userid === undefined) {
		userid  = uuid();
		$.cookie("userid",  userid,  { expires: 10000 });
		db = firebase.firestore();
		saveUser("");
	}

	fbfsInit();

	getInitData()
	.then((startPlace) => {

		map = new google.maps.Map(document.getElementById('map'), {
			center: startPlace,
			zoom: 16,
		});

		panorama = new google.maps.StreetViewPanorama(
				document.getElementById('pano'), {
					position: startPlace,
					pov: {
						heading: startPlace.heading,
						pitch: 0
					}
				});

		map.setStreetView(panorama);

		$('#qr').qrcode({width: 100, height: 100, text: username + ":" + userid});

		getComment();

	});

}

function difference(link) {
	return Math.abs(panorama.pov.heading % 360 - link.heading);
}
function moveForward() {
	var curr;
	if (!panorama || panorama.links.length == 0) { return }
	for(i=0; i < panorama.links.length; i++) {
		if(curr == undefined) {
			curr = panorama.links[i];
		}
		if(difference(curr) > difference(panorama.links[i])) {
			curr = panorama.links[i];
		}
	}
	panorama.setPano(curr.pano);
	panorama.setPov({heading:curr.heading, pitch:0});
  var latlng = panorama.getLocation().latLng;
	map.setCenter(latlng);
	curr.lat = panorama.getLocation().latLng.lat();
	curr.lng = panorama.getLocation().latLng.lng();
	saveHistory(curr);
}

function toggleStreetView() {
	var toggle = panorama.getVisible();
	if (toggle == false) {
		panorama.setVisible(true);
	} else {
		panorama.setVisible(false);
	}
}

function uuid() {
  var uuid = "", i, random;
  for (i = 0; i < 32; i++) {
    random = Math.random() * 16 | 0;
    if (i == 8 || i == 12 || i == 16 || i == 20) {
      uuid += "-"
    }
    uuid += (i == 12 ? 4 : (i == 16 ? (random & 3 | 8) : random)).toString(16);
  }
  return uuid;
}

// Firebase Firestore
function fbfsInit() {
	db = firebase.firestore();
	var stateData = db.collection("cycling").doc(userid);
	stateData.onSnapshot(
		function(snapshot) {
			var data = snapshot.data();
			if (data === undefined) {
				saveUser("")
			} else {
				distance = data.distance;
				username = data.username;
				if (distance != predistance) {
					moveForward();
					predistance = distance;
				}
			}
		}
	);
}

function saveHistory(data) {

	if ( userid === undefined ) { return }

	data.date = new Date().getTime()

	var userDoc = db.collection("cycling").doc(userid).collection("history");
	userDoc.add(
		data
	);

}

async function getInitData() {

	if ( userid === undefined ) { return }

	var userDoc = db.collection("cycling").doc(userid).collection("history");
	var data;
  return await userDoc.orderBy("date", "desc").limit(1).get()
  .then(doc => {
    if (doc.docs.length == 0) {
			data = { lat: 34.4100282, lng: 133.2172176, heading: 216.96 };
      console.log('No history data! set default => ', data);
			return data;
    } else {
			data = doc.docs[0].data()
      console.log('history data:', data);
			return data;
    }
  })
  .catch(err => {
    console.log('Error getting history', err);
  });

}

function saveUser(name) {
	if ( userid === undefined ) { return }

	if (name == ""){
		var userNameList;
		$.ajax({
			url: 'js/username_list.txt',
			async: false
		})
		.done(data => {
			userNameList = data.split(/\r\n|\r|\n/);
		})
		.fail(res => {
			userNameList = ["dummy"]
		})
		var num = Math.floor( Math.random() * 10000 );
		name = userNameList[Math.floor(Math.random() * userNameList.length)] + num;
	}
	username = name;

	data = {}
	data.date = firebase.firestore.Timestamp.fromDate(new Date());
	data.username = username;

	var userDoc = db.collection("cycling").doc(userid);
	userDoc.set( data, { merge: true } );
}

function saveComment(cmt) {

	if ( userid === undefined ) { return }

	data = {}
	data.date = firebase.firestore.Timestamp.fromDate(new Date());
	data.comment = cmt;

	var userDoc = db.collection("cycling").doc(userid);
	userDoc.set( data, { merge: true } );

	return false;

}

async function getComment() {

	var agoDate = new Date();
	agoDate.setHours(agoDate.getHours() - 24);

	var form = '<form class="form" action="#" onSubmit="return saveComment(this.comment_body.value)">'
	         + 'かけごえ<br><input type="text" name="comment_body">'
	         + '<input type="button" value="投稿" onClick="saveComment(this.form.comment_body.value)">'
	         + '</form>';
	$("#comments").append(form);
	var ref = db.collection("cycling");
	var commentList = ref
			.where("date", ">", firebase.firestore.Timestamp.fromDate(agoDate));
	commentList.onSnapshot(
		function(snapshot) {
			$("#comments").empty();
			for (i = 0; i < snapshot.docs.length; i++) {
				var data = snapshot.docs[i].data();
				var line = '<div class="name">' + escapeHTML(data.username) + '</div>'
				         + '<div class="date">(' + dateformat(data.date) + ')</div>'
				if (data.comment) { line += '<div class="body">' + escapeHTML(data.comment) + '</div>';}
				var comment = '<div class="line">'+line+'</div>';
				$("#comments").append(comment);
			}
			$("#comments").append(form);
		}
	);
}

function dateformat(date) {

	date = date.toDate();
 
	var year  = date.getFullYear();
	var month = date.getMonth() + 1;
	var day   = date.getDate();
	var hour  = date.getHours();
	var min   = date.getMinutes();
 
  return month + "/" + day + " " + hour + ":" + min;
}

function escapeHTML(string) {
    let strings = {
        '&': '&amp;',
        '<': '&lt;',
        '>': '&gt;',
        '"': '&quot;',
        "'": '&#39;',
        '`': '&#x60;'
    };
    return String(string).replace(/[&<>"'`=\/]/g, function(s) {
        return strings[s];
    });
}
js/firebase_config.js
// このファイルは、Firebaseからダウンロードした設定ファイルに差し替えてください
var firebaseConfig = {
  apiKey: "XXX",
  authDomain: "xxx",
  databaseURL: "https://xxx.firebaseio.com",
  projectId: "xxx",
  storageBucket: "xxx.appspot.com",
  messagingSenderId: "xxxxxxxxx",
  appId: "1:xxxxxxxx:web:xxxxxxxxxxxxxx",
  measurementId: "X-XXXXXXXXXXXXXXX"
};
// Initialize Firebase
firebase.initializeApp(firebaseConfig);
js/username_list.txt
Aaron
Abby
Abe
Abel
Abiel
Abigail
Abijah
Abner
Abraham
Abram
...snip...
style/bike.css
html, body {
  height: 100%;
}

html, body, div, span, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
abbr, address, cite, code,
del, dfn, em, img, ins, kbd, q, samp,
small, strong, sub, sup, var,
b, i,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, figcaption, figure,
footer, header, hgroup, menu, nav, section, summary,
time, mark, audio, video {
    margin:0;
    padding:0;
    border:0;
    outline:0;
    font-size:100%;
    vertical-align:baseline;
    background:transparent;
}

ol, ul {
    list-style: none;
}
blockquote, q {
    quotes:none;
}
blockquote:before, blockquote:after,
q:before, q:after {
    content:'';
    content:none;
}

#left {
  float: left;
  height: 100%;
  width: 20%;
}

#map {
  float: left;
  height: 40%;
  width: 100%;
}

#comments {
  float: left;
	margin: 2px;
  height: 60%;
  width: 100%;
  overflow-x: hidden;
  overflow-y: scroll;
}

#comments .line {
	padding: 2px;
	display: block;
  float: left;
}
#comments .name {
  float: left;
	font-size: 11pt;
	color: #333;
}
#comments .date {
  float: left;
	margin-left: 10px;
	font-size: 8pt;
	color: #999;
}
#comments .body {
  float: left;
	margin-left: 10px;
	font-size: 11pt;
	width: 100%;
	color: #666;
}
#comments .form {
  float: left;
	height: 100px;
	margin-top: 30px;
	margin-left: 120px;
	font-size: 11pt;
	width: 100%;
	color: #aaa;
}
#comments .form input {
	display: block;
	width: 100px;
}

#pano {
  float: left;
  height: 100%;
  width: 80%;
}

#qr {
	position: fixed;
  bottom: 20px;
  left: 10px;
  width: 100px;
  height: 100px;
  background-color: rgba(0, 0, 0, 0.3);
  z-index: 1;
}

#map .gmnoprint {
  display: none;
}

.gm-fullscreen-control {
  display: none;
}

※ガーっと作ったので、全体的に雑です。レイアウト崩れ等、少しずつ直していこうかと…

余談

ビジネスターゲットの主戦場が「自宅」へ

コンシューマーの活動すべてが自宅内で完結する時代になってきました。
例えば、

  • [買い物] → EC(Amazon/楽天/公式サイト) ※自宅で買い物、試着・返品も可能
  • [映画鑑賞] → OTT動画(Netflix, Youtubeなど) ※マナーとか気にせず映画鑑賞
  • [運動] → ホームジム・Eジム (Ringfit、Pelotonなど) ※運動はしたいが、移動は面倒[2]
  • [食事] → フードデリバリー(UberEATS、出前館など) ※コンビニ行くのも面倒
  • [交流] → SNS、チャット(Facebook、LINEなど) ※着る服がない、メイクが面倒
  • [仕事] → テレワーク(総務省も推進。WebRTC) ※満員電車や片道〇時間に疲れた
  • [プライベート] → 自宅が唯一のプライベート空間 ※外はカメラだらけ、写り込みも嫌

などなど、外出するメリット・動機付けが低下していて(個人の感想です)、現代人の**『自宅に滞在する時間』が長く**なってきているのではないか、と考えています。なんの裏付けもありませんが。

急成長のフィットネスユニコーン企業『Peloton』

ちなみに、ここ数年で40億ドル規模の企業に急成長したPelotonも、自宅フィットネスをテーマにしたサービスを展開しています。(フィットネスバイク販売とサブスク型トレーニングサービスの合わせ技)

Peloton
参考:Appleを超えるブランド Peloton / 現代の聖職者はエアロバイクに乗る
Peloton については、↑この記事がすごく分かりやすかったです

今回つくった「Internet of FitnessBike」も、Pelotonの要素を取り入れるべく、なんちゃってSNS機能(トレーニング中のユーザー一覧表示※コメント機能付き)を取り入れてたりします。

売れまくりのフィットネスゲーム

Nintendo Switchの「Fit Boxing」や「リングフィット アドベンチャー」が盛り上がってるのも、もしかしたら自宅フィットネスの流れが来ているのが一因かもしれませんね。
(とっくの昔に既に来ていて、何を今更、かもですが…)

屋内サイクリングアプリ『Zwift』

恥ずかしながらブコメで知ったのですが、Zwiftなる**MMO型の自宅フィットネスゲーム?**があるらしく、Wikipedia見た感じ、かなり人気があるようです。
Zwift.png

今回作ったモノは、確かにPelotonよりもZwiftの方が、方向性は近そうです。

さらに余談

日本でも、10年以上前から似たようなことは実現・研究されていたようです。

♪ 楽しく運動 ♪ インターネット エアロバイク - 弘前大学
http://siva.cc.hirosaki-u.ac.jp/usr/koyama/bike/

ICT Challenge+R 2011 ファイナル。作品名:バーチャリ
https://www.youtube.com/watch?v=BIX34fBwKuI
https://www.youtube.com/watch?v=ajX1Rg0MjwI

「いかに**『誰でも簡単にはじめられる仕組み』**を作り出すか」が鍵になりそうなので(経済力や技術力を必要としない)、その辺に注力して、私も引き続き研究し創作を続けようかと思っています。

脚注
  1. 「ながらバイク4518」以外は動作検証できていませんが、とはいえ、ステレオプラグのメーターコードを採用している機種は少なくないと思います(何店舗か量販店を見て回った感じ)。 ↩︎

  2. 米国のデータ分析企業Dstilleryが750万人に上る匿名データを調査したところ、ジムまでの距離が短くなるほど、ジムに通う頻度が高くなることが明らかになった。(フィットネス市場の主戦場はジムから自宅へ。Eジム・ホームジムの台頭) ↩︎

Discussion