mkvのアイコン画像
mkv 2025年01月31日作成 (2025年01月31日更新) © GPL-3.0+
製作品 製作品 閲覧数 388
mkv 2025年01月31日作成 (2025年01月31日更新) © GPL-3.0+ 製作品 製作品 閲覧数 388

UPCYCLE ~サイクリングをジェスチャー認識で安全にするシステム~

UPCYCLE ~サイクリングをジェスチャー認識で安全にするシステム~

はじめに ~動機~

私は大学でサイクリング部に入っているのですが、走行中の転倒で多くの友人が怪我をすることを悲しく思っています。地面の段差に気づかず突っ込んでしまうなどの要因でロードバイクはすぐにバランスを崩してしまうのです。

そんなある日のサイクリング中、前を走っていた先輩が人差し指で地面を指し、指先が円弧を描くように小さく腕を回しています。
集団走行では前の人の背中で前方が見えないので、ハンドサインで先頭が後続に情報を伝達するのがしきたりです。
指を指して回すのは「ここ危ないから気をつけて通ってね」のサインです。

これを見て思いました。
このジェスチャー、めっちゃ6軸センサで検出できそうじゃね!?

こうして、危険な場所を通る前に注意を促すシステムを、「危険」のハンドサインをセンサーで検出しサーバーに蓄積することで実現できると考えました。

システム構成

今回のシステムではBLEで相互に通信する2台のSpresenseを子機と親機とし、子機は手首につけハンドサインの検出を行い、親機は自転車に装着しLTEでサーバーとの通信を行います。

親機は定期的にGNSSにより位置情報を、サーバーとの通信により危険な地点のリストを受け取ります。危険な地点に近づくとスピーカーでメロディを鳴らし通知します。

また、子機がジェスチャーを検知したことが親機に伝わると、親機はサーバーへ位置情報をアップロードします。この時もスピーカーでメロディを鳴らし通知します。

子機編

子機部品表

番号 名前 数量 備考/型番
1 Spresense メインボード 1 CXD5602PWBMAIN1
2 PHコネクタ 1 2ピンのもの
3 電池ケース 1 CH01-2032-ASTH150MM
4 ボタン電池 2 CR2032
5 BLEモジュール 1 BLE1507
6 IMUモジュール 1 F-TECH-001
7 子機ケース 1 自作
8 マジックバンド結束テープ 少々 MKT-1015-BK

レシピ

  1. 1に2をはんだづけします
  2. 3に4を入れ、2に接続します
  3. 5,6を画像のように1に装着します
    3
  4. 7のデータをリンクからダウンロードして印刷し、1~6を画像のように7へ入れます。
    4
  5. 8を用いて7のフタを閉じ、1~7を手首に装着します。
    キャプションを入力できます
  6. リンクから検知モデルをダウンロードするかご自身のジェスチャーに合わせて自作し、xmodem_writerなどでSpresenseに転送します。
  7. 下記のソースコードをArduino IDEからSpresenseに書きこみ、準備完了です。

ソースコード

子機

#include <Arduino.h> #include "BMI270_Arduino.h" // ★ BMI270用ライブラリ #include <Flash.h> #include <float.h> // floatの最大値・最小値の定義 #include <DNNRT.h> #include <stdio.h> #include <string.h> #include <stdlib.h> #include "BLE1507.h" // BLE接続設定 static BT_ADDR addr = {{0x19, 0x84, 0x06, 0x14, 0xAB, 0xCD}}; static char ble_name[BT_NAME_LEN] = "SPR-CENTRAL"; // 中央側デバイス名 BLE1507 *ble1507; struct ble_gattc_db_disc_char_s* nrw_char; // ---------------------------------------- // BMI270関連の設定/インスタンス/ヘルパー // ---------------------------------------- BMI270Class BMI270; void IMU_print_rslt(int8_t rslt) { switch (rslt) { case BMI2_OK: return; // Do nothing case BMI2_E_NULL_PTR: Serial.println("Error [" + String(rslt) + "] : Null pointer"); break; case BMI2_E_COM_FAIL: Serial.println("Error [" + String(rslt) + "] : Communication failure"); break; case BMI2_E_DEV_NOT_FOUND:Serial.println("Error [" + String(rslt) + "] : Device not found"); break; case BMI2_E_OUT_OF_RANGE: Serial.println("Error [" + String(rslt) + "] : Out of range"); break; case BMI2_E_ACC_INVALID_CFG: Serial.println("Error [" + String(rslt) + "] : Invalid accel configuration"); break; default: Serial.println("Error [" + String(rslt) + "] : Unknown error code"); break; } } // BMI270センサーの設定関数 (加速度&ジャイロを有効化) int8_t IMU_configure_sensor(int sense_rate, int gyro_range, int accl_range) { int8_t rslt; // 有効にするセンサー一覧 (加速度, ジャイロ) uint8_t sens_list[2] = { BMI2_ACCEL, BMI2_GYRO }; // センサー設定用構造体(2つ分) struct bmi2_sens_config config[2]; // (1) 加速度センサー設定 config[0].type = BMI2_ACCEL; // 出力データレート(ここでは 200Hz) config[0].cfg.acc.odr = BMI2_ACC_ODR_200HZ; // 重力レンジ(+/-2G, ...) switch (accl_range) { case 2: config[0].cfg.acc.range = BMI2_ACC_RANGE_2G; break; case 4: config[0].cfg.acc.range = BMI2_ACC_RANGE_4G; break; case 8: config[0].cfg.acc.range = BMI2_ACC_RANGE_8G; break; default: config[0].cfg.acc.range = BMI2_ACC_RANGE_16G; break; } // バンド幅パラメータ(4サンプル平均) config[0].cfg.acc.bwp = BMI2_ACC_NORMAL_AVG4; // フィルタパフォーマンスモード(高性能) config[0].cfg.acc.filter_perf = BMI2_PERF_OPT_MODE; // (2) ジャイロセンサー設定 config[1].type = BMI2_GYRO; // ジャイロの出力データレート(200Hz) config[1].cfg.gyr.odr = BMI2_GYR_ODR_200HZ; // ジャイロのレンジ(±125, ±250, ±500, ±1000, ±2000 deg/s) switch (gyro_range) { case 125: config[1].cfg.gyr.range = BMI2_GYR_RANGE_125; break; case 250: config[1].cfg.gyr.range = BMI2_GYR_RANGE_250; break; case 500: config[1].cfg.gyr.range = BMI2_GYR_RANGE_500; break; case 1000: config[1].cfg.gyr.range = BMI2_GYR_RANGE_1000; break; default: config[1].cfg.gyr.range = BMI2_GYR_RANGE_2000; break; } // ジャイロの帯域幅パラメータ(通常モード) config[1].cfg.gyr.bwp = BMI2_GYR_NORMAL_MODE; // フィルタ性能モード(高性能) config[1].cfg.gyr.filter_perf = BMI2_PERF_OPT_MODE; // BMI270に設定を適用 rslt = BMI270.set_sensor_config(config, 2); if (rslt != BMI2_OK) return rslt; // 加速度&ジャイロセンサーを有効化 rslt = BMI270.sensor_enable(sens_list, 2); return rslt; } // ---------------------------------------- // DNNRT: ニューラルネットワーク推論用 // ---------------------------------------- DNNRT dnnrt; // センサー出力更新周期 const int sense_rate = 200; // ジャイロセンサ範囲(±deg/sec) const int gyro_range = 500; // 加速度センサ範囲(±G) const int accl_range = 2; // Complementary Filter設定値 const float alpha = 0.94; // ---------------------------------------- // 静的バッファ (最新のsense_rate件を常に保持) // ---------------------------------------- static float rpm_roll[sense_rate]; static float rpm_pitch[sense_rate]; // 6軸データ: ジャイロXYZ(°/s) + 加速度XYZ(G) static float gyroX[sense_rate]; static float gyroY[sense_rate]; static float gyroZ[sense_rate]; static float accX[sense_rate]; static float accY[sense_rate]; static float accZ[sense_rate]; // RPM(回転数)計算用 inline float angv_to_rpm(float avel_deg_per_s) { // 1回転(360°)あたり60秒 -> 回転数(rpm) return avel_deg_per_s * 60.0f / 360.0f; } // 角度(°)に変換 inline float rad_to_deg(float r) { return r * 180.0f / M_PI; } // ----- BLEで送信する変数 ----- static int data = 0; // クラス1が0.9以上で検知されたらインクリメント void setup() { Serial.begin(115200); // --- (1) DNNRTモデル読み込み --- File nnbfile = Flash.open("resultv1.nnb"); if (!nnbfile) { Serial.println("model.nnb not found"); return; } int ret = dnnrt.begin(nnbfile); nnbfile.close(); if (ret < 0) { Serial.println("DNNRT error: " + String(ret)); return; } Serial.println("DNNRT Model loaded."); // --- (2) BMI270初期化 (I2C, セカンダリアドレス例) --- int8_t rslt = BMI270.begin(BMI270_I2C, BMI2_I2C_SEC_ADDR); IMU_print_rslt(rslt); if (rslt != BMI2_OK) { Serial.println("BMI270 initialization failed"); return; } // --- (3) 加速度/ジャイロ設定 --- rslt = IMU_configure_sensor(sense_rate, gyro_range, accl_range); IMU_print_rslt(rslt); if (rslt != BMI2_OK) { Serial.println("BMI270 config failed"); return; } // LEDピン設定 (必要に応じて) pinMode(LED0, OUTPUT); pinMode(LED1, OUTPUT); //pinMode(LED2, OUTPUT); digitalWrite(LED0, LOW); digitalWrite(LED1, LOW); //digitalWrite(LED2, LOW); Serial.println("BMI270 init done."); // --- (4) BLE初期化 (中央側) --- ble1507 = BLE1507::getInstance(); ble1507->beginCentral(ble_name, addr); // ここでは「SPR-PERIPHERAL」という名前のペリフェラルをスキャンして接続開始 // 実際の環境に合わせて名前や接続方法を変更してください ble1507->startScan("SPR-PERIPHERAL"); // MTU更新完了待ち (例: 必要に応じてwait) while (!ble1507->isMtuUpdated()) { // 必要に応じてタイムアウト処理などを入れる } nrw_char = ble1507->getCharacteristic(); // ペアリング (必要なら実行) ble1507->pairing(); Serial.println("BLE init done."); } void loop() { static unsigned long last_msec = 0; static float cmp_roll = 0.0f; static float cmp_pitch = 0.0f; static float last_cmp_roll = 0.0f; static float last_cmp_pitch = 0.0f; // ---------------------------------------- // 時間計測 (dt) // ---------------------------------------- unsigned long curr_msec = millis(); float dt = (float)(curr_msec - last_msec) / 1000.0f; last_msec = curr_msec; // dtが大きすぎ or 0ならスキップ if (dt > 0.1f || dt == 0.0f) { return; } // ---------------------------------------- // BMI270から加速度&ジャイロデータを取得 // ---------------------------------------- struct bmi2_sens_float sensor_data; int8_t rslt = BMI270.bmi2_get_sensor_float(&sensor_data); IMU_print_rslt(rslt); if (rslt != BMI2_OK) { return; } // 加速度[G] float accel_x = sensor_data.acc.x; float accel_y = sensor_data.acc.y; float accel_z = sensor_data.acc.z; // ジャイロ[°/s] float omega_x = sensor_data.gyr.x; float omega_y = sensor_data.gyr.y; float omega_z = sensor_data.gyr.z; // ロール・ピッチ角 (加速度のみから算出) float acc_roll = rad_to_deg(atan2(accel_y, accel_z)); float acc_pitch = rad_to_deg(atan2(-accel_x, sqrt(accel_y * accel_y + accel_z * accel_z))); // ---------------------------------------- // Complementary Filter (ロール, ピッチ角度) // ---------------------------------------- cmp_roll = alpha * (cmp_roll + omega_x * dt) + (1.0f - alpha) * acc_roll; cmp_pitch = alpha * (cmp_pitch + omega_y * dt) + (1.0f - alpha) * acc_pitch; // ロール/ピッチ角の変化量をrpmに変換 float rpm_cmp_roll = angv_to_rpm((cmp_roll - last_cmp_roll) / dt); float rpm_cmp_pitch = angv_to_rpm((cmp_pitch - last_cmp_pitch) / dt); last_cmp_roll = cmp_roll; last_cmp_pitch = cmp_pitch; // ---------------------------------------- // バッファをシフト // ---------------------------------------- for (int n = 1; n < sense_rate; ++n) { rpm_roll[n - 1] = rpm_roll[n]; rpm_pitch[n - 1] = rpm_pitch[n]; gyroX[n - 1] = gyroX[n]; gyroY[n - 1] = gyroY[n]; gyroZ[n - 1] = gyroZ[n]; accX[n - 1] = accX[n]; accY[n - 1] = accY[n]; accZ[n - 1] = accZ[n]; } // 新しい値を末尾に格納 rpm_roll[sense_rate - 1] = rpm_cmp_roll; rpm_pitch[sense_rate - 1] = rpm_cmp_pitch; gyroX[sense_rate - 1] = omega_x; gyroY[sense_rate - 1] = omega_y; gyroZ[sense_rate - 1] = omega_z; accX[sense_rate - 1] = accel_x; accY[sense_rate - 1] = accel_y; accZ[sense_rate - 1] = accel_z; // ---------------------------------------- // 回転速度しきい値判定 (80rpm) // ---------------------------------------- const float threshold = 80.0f; const int point = 20; // 適当にバッファの一部を参照する例 if ((fabs(rpm_roll[point - 2]) < threshold && fabs(rpm_roll[point]) > threshold) || (fabs(rpm_pitch[point - 2]) < threshold && fabs(rpm_pitch[point]) > threshold)) { // 最初のトリガーはセンサー起動直後なのでスキップする例 static bool bInit = true; if (bInit) { bInit = false; return; } // ---------------------------------------- // 軸ごとの正規化 -1.0 ~ +1.0 // ---------------------------------------- float maxVal, minVal, range; // ジャイロX maxVal = -FLT_MAX; minVal = FLT_MAX; for (int n = 0; n < sense_rate; ++n) { if (gyroX[n] > maxVal) maxVal = gyroX[n]; if (gyroX[n] < minVal) minVal = gyroX[n]; } range = fabs(maxVal) > fabs(minVal) ? fabs(maxVal) : fabs(minVal); for (int n = 0; n < sense_rate; ++n) { gyroX[n] = (range != 0.0f) ? (gyroX[n] / range) : 0.0f; } // ジャイロY maxVal = -FLT_MAX; minVal = FLT_MAX; for (int n = 0; n < sense_rate; ++n) { if (gyroY[n] > maxVal) maxVal = gyroY[n]; if (gyroY[n] < minVal) minVal = gyroY[n]; } range = fabs(maxVal) > fabs(minVal) ? fabs(maxVal) : fabs(minVal); for (int n = 0; n < sense_rate; ++n) { gyroY[n] = (range != 0.0f) ? (gyroY[n] / range) : 0.0f; } // ジャイロZ maxVal = -FLT_MAX; minVal = FLT_MAX; for (int n = 0; n < sense_rate; ++n) { if (gyroZ[n] > maxVal) maxVal = gyroZ[n]; if (gyroZ[n] < minVal) minVal = gyroZ[n]; } range = fabs(maxVal) > fabs(minVal) ? fabs(maxVal) : fabs(minVal); for (int n = 0; n < sense_rate; ++n) { gyroZ[n] = (range != 0.0f) ? (gyroZ[n] / range) : 0.0f; } // 加速度X maxVal = -FLT_MAX; minVal = FLT_MAX; for (int n = 0; n < sense_rate; ++n) { if (accX[n] > maxVal) maxVal = accX[n]; if (accX[n] < minVal) minVal = accX[n]; } range = fabs(maxVal) > fabs(minVal) ? fabs(maxVal) : fabs(minVal); for (int n = 0; n < sense_rate; ++n) { accX[n] = (range != 0.0f) ? (accX[n] / range) : 0.0f; } // 加速度Y maxVal = -FLT_MAX; minVal = FLT_MAX; for (int n = 0; n < sense_rate; ++n) { if (accY[n] > maxVal) maxVal = accY[n]; if (accY[n] < minVal) minVal = accY[n]; } range = fabs(maxVal) > fabs(minVal) ? fabs(maxVal) : fabs(minVal); for (int n = 0; n < sense_rate; ++n) { accY[n] = (range != 0.0f) ? (accY[n] / range) : 0.0f; } // 加速度Z maxVal = -FLT_MAX; minVal = FLT_MAX; for (int n = 0; n < sense_rate; ++n) { if (accZ[n] > maxVal) maxVal = accZ[n]; if (accZ[n] < minVal) minVal = accZ[n]; } range = fabs(maxVal) > fabs(minVal) ? fabs(maxVal) : fabs(minVal); for (int n = 0; n < sense_rate; ++n) { accZ[n] = (range != 0.0f) ? (accZ[n] / range) : 0.0f; } // ---------------------------------------- // DNNRTで推論を実行 (200行×6列) // ---------------------------------------- DNNVariable input(sense_rate * 6); float* pData = input.data(); // (行= n, 列= 0..5) // col=0:gyroX,1:gyroY,2:gyroZ,3:accX,4:accY,5:accZ for (int n = 0; n < sense_rate; ++n) { pData[n*6 + 0] = gyroX[n]; pData[n*6 + 1] = gyroY[n]; pData[n*6 + 2] = gyroZ[n]; pData[n*6 + 3] = accX[n]; pData[n*6 + 4] = accY[n]; pData[n*6 + 5] = accZ[n]; } // 推論実行 dnnrt.inputVariable(input, 0); dnnrt.forward(); // 結果取得 DNNVariable output = dnnrt.outputVariable(0); int index = output.maxIndex(); float confidence = output[index]; // LED初期化 digitalWrite(LED0, LOW); digitalWrite(LED1, LOW); // クラスが1 かつ 信頼度が0.9以上の場合 // → data++ してBLE送信 if (index == 1 && confidence > 0.9f) { // LED1点灯 (任意) digitalWrite(LED1, HIGH); // dataをインクリメントしてBLE送信 data++; const int len = 4; uint8_t buf[len]; // data(int)を4バイト配列に詰める例 for (int i = 0; i < len; i++) { buf[i] = (uint8_t)((data >> (8 * i)) & 0xFF); } // BLE書き込み (例: nrw_char->characteristic.char_valhandle) ble1507->writeCharacteristic(nrw_char->characteristic.char_valhandle, buf, len, false); Serial.println("Sent data over BLE: " + String(data)); } else { if (index == 0 && confidence > 0.9f) { digitalWrite(LED0, HIGH); } } // デバッグ出力 Serial.print("Class = "); Serial.print(index); Serial.print(", Conf = "); Serial.println(confidence, 4); // 多重検出を防ぐためバッファをクリア memset(rpm_roll, 0, sizeof(rpm_roll)); memset(rpm_pitch, 0, sizeof(rpm_pitch)); memset(gyroX, 0, sizeof(gyroX)); memset(gyroY, 0, sizeof(gyroY)); memset(gyroZ, 0, sizeof(gyroZ)); memset(accX, 0, sizeof(accX)); memset(accY, 0, sizeof(accY)); memset(accZ, 0, sizeof(accZ)); } // ---------------------------------------- // 周期(200Hz) // ---------------------------------------- delay(1000 / sense_rate); }

親機編

親機部品表

番号 名前 数量 備考/型番
1 Spresense メインボード 1 CXD5602PWBMAIN1
2 Spresense LTE 拡張ボード 1 CXD5602PWBLM1
3 BLEモジュール 1 BLE1507
4 simカード 1 動作確認SIM Listに記載のものが望ましい。
5 スピーカー 1 AUX入力が可能なお好みのもの
6 AUXケーブル 1 3.5mmのミニプラグのもの
7 モバイルバッテリー 1 低電流モードがあるものが望ましい
8 給電用USBケーブル 1 Spresenseとモバイルバッテリーに適合する口のもの
9 親機ケース 1 自作
10 マジックバンド結束テープ 少々 MKT-1015-BK

レシピ

  1. 1に2,3を画像のように装着します
    1
  2. 4を2に挿入します
  3. 9のデータをリンクからダウンロードして印刷し、1~4を画像のように9へ入れ、10でフタを閉じます
    3-1
    3-2
  4. 6で5と2を接続します
  5. 8で7と1を接続します
  6. 1~10をサイクルバッグに収納します
  7. 下記のソースコードをSpresenseに書きこみ、準備完了です。

ソースコード

親機

#include <Arduino.h> // ----- GNSS ----- #include <GNSS.h> // SpresenseのGNSSライブラリ // ----- BLE ----- #include "BLE1507.h" // SpresenseのBLEライブラリ // ----- LTE + HTTPS ----- #include <LTE.h> #include <LTETLSClient.h> #include <ArduinoHttpClient.h> #include <RTC.h> // 必要に応じて時刻同期 // ----- Audio for beep ----- #include <Audio.h> // SpresenseのAudioライブラリ // 座標を保持する簡易構造 struct Coordinate { double lat; double lon; }; /*************************************************** * 【GNSS 用データ構造とインスタンス】 ***************************************************/ static SpGnss Gnss; // GNSS位置情報を格納する構造体 typedef struct { bool valid; // trueなら位置情報が有効 double latitude; // 緯度 double longitude; // 経度 } GpsData_t; // グローバルで現在の位置情報を保持する GpsData_t currentPosition = { false, 0.0, 0.0 }; /*************************************************** * 【Audio / Beep 関連】 ***************************************************/ static AudioClass* theAudio = NULL; // 一度に再生する音符情報 typedef struct { int fs; // 周波数(Hz)、0なら終了 int time; // 演奏時間(ミリ秒) } Note; // メロディ1 (機能1で再生: POST成功時) static const Note data[] = { // ドレミファソラシド → ドシラソファミレド 的なサンプル {262, 500}, {294, 500}, {330, 500}, {349, 500}, {392, 500}, {440, 500}, {494, 500}, {523, 1000}, {523, 500}, {494, 500}, {440, 500}, {392, 500}, {349, 500}, {330, 500}, {294, 500}, {262, 1000}, {0, 0} }; // メロディ2 (機能2で再生: GET新規座標あり時) static const Note data2[] = { {440, 1000}, {440, 1000}, {494, 2000}, {440, 1000}, {440, 1000}, {494, 2000}, {440, 1000}, {494, 1000}, {523, 1000}, {494, 1000}, {440, 1000}, {494, 500}, {440, 500}, {349, 2000}, {0, 0} }; /** * @brief メロディ再生をブロッキングで行う簡易関数 * @param score 再生するNote配列(最後は {0,0} で終端) */ void playMelody(const Note* score) { if (!theAudio) { theAudio = AudioClass::getInstance(); } // Audioモジュールを初期化 theAudio->begin(); theAudio->setPlayerMode(AS_SETPLAYER_OUTPUTDEVICE_SPHP, 0, 0); // 配列を先頭から順に再生 for (int i = 0; ; i++) { if (score[i].fs == 0) { // 終端 break; } // ビープ音を鳴らす (on) theAudio->setBeep(1, -40, score[i].fs); usleep(score[i].time * 1000); // 演奏時間 // ビープ音を停止 theAudio->setBeep(0, 0, 0); usleep(100000); // 音符間の隙間(0.1秒) } // 終了処理 theAudio->setReadyMode(); theAudio->end(); } /*************************************************** * 【LTE 用設定】 ***************************************************/ // APN情報 #define APP_LTE_APN "iijmio.jp" #define APP_LTE_USER_NAME "mio@iij" // ユーザー名 #define APP_LTE_PASSWORD "iij" // パスワード #define APP_LTE_IP_TYPE (LTE_NET_IPTYPE_V4V6) #define APP_LTE_AUTH_TYPE (LTE_NET_AUTHTYPE_CHAP) #define APP_LTE_RAT (LTE_NET_RAT_CATM) // 接続先サーバ (Google Cloud Functions例) const char server[] = "us-central1-upcycle-448810.cloudfunctions.net"; const int port = 443; // 送信用パス String postPath = "/saveLocation"; // 位置情報を埋め込むためのJSON文字列(後で動的に書き換え) String postData = R"({"latitude":0.0,"longitude":0.0})"; // GET用パス(最後に?latitude=...,&longitude=...を付与して使う) String baseGetPath = "/getLocationsWithinRadius?radius=0.5"; // GET30秒ごとに行うためのタイマー unsigned long lastRequestTime = 0; const unsigned long intervalGet = 30000UL; // ルート証明書 (Google Trust Services / GTS Root R1) static const char googleRootCA[] PROGMEM = R"EOF( -----BEGIN CERTIFICATE----- MIIFCzCCAvOgAwIBAgIQf/AFoHxM3tEArZ1mpRB7mDANBgkqhkiG9w0BAQsFADBH MQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExM QzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMjMxMjEzMDkwMDAwWhcNMjkwMjIw MTQwMDAwWjA7MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNl cnZpY2VzMQwwCgYDVQQDEwNXUjIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK AoIBAQCp/5x/RR5wqFOfytnlDd5GV1d9vI+aWqxG8YSau5HbyfsvAfuSCQAWXqAc +MGr+XgvSszYhaLYWTwO0xj7sfUkDSbutltkdnwUxy96zqhMt/TZCPzfhyM1IKji aeKMTj+xWfpgoh6zySBTGYLKNlNtYE3pAJH8do1cCA8Kwtzxc2vFE24KT3rC8gIc LrRjg9ox9i11MLL7q8Ju26nADrn5Z9TDJVd06wW06Y613ijNzHoU5HEDy01hLmFX xRmpC5iEGuh5KdmyjS//V2pm4M6rlagplmNwEmceOuHbsCFx13ye/aoXbv4r+zgX FNFmp6+atXDMyGOBOozAKql2N87jAgMBAAGjgf4wgfswDgYDVR0PAQH/BAQDAgGG MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/ AgEAMB0GA1UdDgQWBBTeGx7teRXUPjckwyG77DQ5bUKyMDAfBgNVHSMEGDAWgBTk rysmcRorSCeFL1JmLO/wiRNxPjA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAKG GGh0dHA6Ly9pLnBraS5nb29nL3IxLmNydDArBgNVHR8EJDAiMCCgHqAchhpodHRw Oi8vYy5wa2kuZ29vZy9yL3IxLmNybDATBgNVHSAEDDAKMAgGBmeBDAECATANBgkq hkiG9w0BAQsFAAOCAgEARXWL5R87RBOWGqtY8TXJbz3S0DNKhjO6V1FP7sQ02hYS TL8Tnw3UVOlIecAwPJQl8hr0ujKUtjNyC4XuCRElNJThb0Lbgpt7fyqaqf9/qdLe SiDLs/sDA7j4BwXaWZIvGEaYzq9yviQmsR4ATb0IrZNBRAq7x9UBhb+TV+PfdBJT DhEl05vc3ssnbrPCuTNiOcLgNeFbpwkuGcuRKnZc8d/KI4RApW//mkHgte8y0YWu ryUJ8GLFbsLIbjL9uNrizkqRSvOFVU6xddZIMy9vhNkSXJ/UcZhjJY1pXAprffJB vei7j+Qi151lRehMCofa6WBmiA4fx+FOVsV2/7R6V2nyAiIJJkEd2nSi5SnzxJrl Xdaqev3htytmOPvoKWa676ATL/hzfvDaQBEcXd2Ppvy+275W+DKcH0FBbX62xevG iza3F4ydzxl6NJ8hk8R+dDXSqv1MbRT1ybB5W0k8878XSOjvmiYTDIfyc9acxVJr Y/cykHipa+te1pOhv7wYPYtZ9orGBV5SGOJm4NrB3K1aJar0RfzxC3ikr7Dyc6Qw qDTBU39CluVIQeuQRgwG3MuSxl7zRERDRilGoKb8uY45JzmxWuKxrfwT/478JuHU /oTxUFqOl2stKnn7QGTq8z29W+GgBLCXSBxC9epaHM0myFH/FJlniXJfHeytWt0= -----END CERTIFICATE----- )EOF"; // LTEライブラリ関連インスタンス LTE lteAccess; LTETLSClient tlsClient; HttpClient client(tlsClient, server, port); /*************************************************** * 【BLE 用設定】 ***************************************************/ #define UUID_SERVICE 0x3802 #define UUID_CHAR 0x4a02 static BT_ADDR addr = {{0x19, 0x84, 0x06, 0x14, 0xAB, 0xCD}}; static char ble_name[BT_NAME_LEN] = "SPR-PERIPHERAL"; BLE1507* ble1507; // 受信データ比較用 static int previousData = -1; /*************************************************** * 【GNSS関連関数】 ***************************************************/ // GNSS初期化 void setupGnss() { Serial.println("GNSS initialize..."); // GNSSデバイスアクティブ化 if (Gnss.begin() != 0) { Serial.println("Gnss.begin() error!"); while (1) { delay(1000); } } // GPSのみ選択 Gnss.select(GPS); // Cold Startで開始 if (Gnss.start(COLD_START) != 0) { Serial.println("Gnss.start() error!"); while (1) { delay(1000); } } Serial.println("GNSS setup OK"); } // GNSS位置情報取得 GpsData_t getGnssPosition() { GpsData_t gpsData; gpsData.valid = false; gpsData.latitude = 0.0; gpsData.longitude = 0.0; // GNSSからの更新があれば取得 if (Gnss.waitUpdate(0)) { SpNavData NavData; Gnss.getNavData(&NavData); if (NavData.posFixMode != FixInvalid && NavData.posDataExist) { gpsData.valid = true; gpsData.latitude = NavData.latitude; gpsData.longitude = NavData.longitude; } } return gpsData; } /*************************************************** * 【LTE + HTTPS関連】 ***************************************************/ String readFromSerial() { String str; while (true) { if (Serial.available() > 0) { int read_byte = Serial.read(); if (read_byte == '\n' || read_byte == '\r') { Serial.println(""); break; } Serial.print((char)read_byte); str += (char)read_byte; } } return str; } void readApnInformation(char apn[], LTENetworkAuthType *authtype, char user_name[], char password[]) { String read_buf; while (strlen(apn) == 0) { Serial.print("Enter Access Point Name:"); readFromSerial().toCharArray(apn, LTE_NET_APN_MAXLEN); } while (true) { Serial.print("Enter APN authentication type(CHAP, PAP, NONE):"); read_buf = readFromSerial(); if (read_buf.equals("NONE") == true) { *authtype = LTE_NET_AUTHTYPE_NONE; } else if (read_buf.equals("PAP") == true) { *authtype = LTE_NET_AUTHTYPE_PAP; } else if (read_buf.equals("CHAP") == true) { *authtype = LTE_NET_AUTHTYPE_CHAP; } else { Serial.println("No match authtype. type at CHAP, PAP, NONE."); continue; } break; } if (*authtype != LTE_NET_AUTHTYPE_NONE) { while (strlen(user_name) == 0) { Serial.print("Enter username:"); readFromSerial().toCharArray(user_name, LTE_NET_USER_MAXLEN); } while (strlen(password) == 0) { Serial.print("Enter password:"); readFromSerial().toCharArray(password, LTE_NET_PASSWORD_MAXLEN); } } } // LTE初期化 void setupLTE() { Serial.println("Starting HTTPS client with embedded rootCA..."); // APN設定 char apn[LTE_NET_APN_MAXLEN] = APP_LTE_APN; LTENetworkAuthType authtype = APP_LTE_AUTH_TYPE; char user_name[LTE_NET_USER_MAXLEN] = APP_LTE_USER_NAME; char password[LTE_NET_PASSWORD_MAXLEN] = APP_LTE_PASSWORD; if (strlen(APP_LTE_APN) == 0) { Serial.println("No APN preset. Please enter APN via Serial."); readApnInformation(apn, &authtype, user_name, password); } // APN表示 Serial.println("=========== APN information ==========="); Serial.print("APN : "); Serial.println(apn); Serial.print("Auth : "); Serial.println(authtype == LTE_NET_AUTHTYPE_CHAP ? "CHAP" : authtype == LTE_NET_AUTHTYPE_NONE ? "NONE" : "PAP"); if (authtype != LTE_NET_AUTHTYPE_NONE) { Serial.print("User : "); Serial.println(user_name); Serial.print("Pass : "); Serial.println(password); } Serial.println(); // モデム起動 & attach while (true) { if (lteAccess.begin() != LTE_SEARCHING) { Serial.println("Could not transition to LTE_SEARCHING."); for (;;) { delay(1000); } } if (lteAccess.attach(APP_LTE_RAT, apn, user_name, password, authtype, APP_LTE_IP_TYPE) == LTE_READY) { Serial.println("LTE attach succeeded."); break; } Serial.println("Attach error. Retry..."); lteAccess.shutdown(); delay(1000); } // 時刻同期 RTC.begin(); unsigned long currentTime; while (0 == (currentTime = lteAccess.getTime())) { delay(500); } RtcTime rtc(currentTime); Serial.printf("CurrentTime(LTE): %04d/%02d/%02d %02d:%02d:%02d\n", rtc.year(), rtc.month(), rtc.day(), rtc.hour(), rtc.minute(), rtc.second()); RTC.setTime(rtc); // ルート証明書設定 tlsClient.setCACert(googleRootCA, strlen(googleRootCA) + 1); } // 位置情報をJSON文字列へ変換するヘルパー String createLocationJson(double lat, double lon) { // {"latitude":..., "longitude":...} String json = "{\"latitude\":"; json += String(lat, 8); // 小数点以下8桁 json += ",\"longitude\":"; json += String(lon, 8); json += "}"; return json; } // POST送信 (/saveLocation) void doPostSaveLocation() { Serial.println("\n[HTTPS POST] => /saveLocation"); client.beginRequest(); client.post(postPath); client.sendHeader("Content-Type", "application/json"); client.sendHeader("Content-Length", postData.length()); client.beginBody(); client.print(postData); client.endRequest(); int statusCode = client.responseStatusCode(); String response = client.responseBody(); Serial.print("Status code: "); Serial.println(statusCode); Serial.print("Response : "); Serial.println(response); client.stop(); // 機能1: POSTが成功(200)ならメロディ(data)を再生 if (statusCode == 200) { Serial.println("POST succeeded! Playing melody1..."); playMelody(data); } } /*************************************************** * 【GET結果のパースと比較用】 ***************************************************/ // 前回のGETで取得した座標リスト static Coordinate prevCoords[32]; // 32個までとする(例) static int prevCoordCount = 0; // 文字列から座標リストを解析して取得する関数 int parseCoordinates(const String &response, Coordinate outArray[], int maxSize) { int count = 0; int startIndex = 0; while (true) { // "latitude":を探す int latPos = response.indexOf("\"latitude\":", startIndex); if (latPos < 0) { // 見つからなければ終了 break; } // そこから数字部分を切り出す int commaPos = response.indexOf(",", latPos); if (commaPos < 0) break; String latStr = response.substring(latPos + 11, commaPos); latStr.trim(); // "longitude":を探す int lonPos = response.indexOf("\"longitude\":", commaPos); if (lonPos < 0) break; int endPos = response.indexOf("}", lonPos); if (endPos < 0) break; String lonStr = response.substring(lonPos + 12, endPos); lonStr.trim(); // 変換 double latVal = latStr.toDouble(); double lonVal = lonStr.toDouble(); // 格納 outArray[count].lat = latVal; outArray[count].lon = lonVal; count++; if (count >= maxSize) break; // 次を検索するために更新 startIndex = endPos + 1; } return count; } // 座標がリストに含まれているか判定 bool coordinateExists(const Coordinate arr[], int arrCount, double lat, double lon) { for (int i = 0; i < arrCount; i++) { if (arr[i].lat == lat && arr[i].lon == lon) { return true; } } return false; } // GET送信 (/getLocationsWithinRadius) void doGetLocationsWithinRadius() { // 現在のGNSS位置をクエリに反映 String getPath = baseGetPath; getPath += "&latitude="; getPath += String(currentPosition.latitude, 6); getPath += "&longitude="; getPath += String(currentPosition.longitude, 6); Serial.println("\n[HTTPS GET] => " + getPath); client.get(getPath); int statusCode = client.responseStatusCode(); String response = client.responseBody(); Serial.print("Status code: "); Serial.println(statusCode); Serial.print("Response : "); Serial.println(response); client.stop(); if (statusCode != 200) { // エラーなら終了 return; } // 機能2: レスポンス内に含まれる座標リストをパース static Coordinate newCoords[32]; int newCount = parseCoordinates(response, newCoords, 32); // 前回になかった座標があるかチェック bool hasNewCoordinate = false; for (int i = 0; i < newCount; i++) { if (!coordinateExists(prevCoords, prevCoordCount, newCoords[i].lat, newCoords[i].lon)) { // 1つでも新しい座標があればフラグON hasNewCoordinate = true; break; } } // 新しい座標があった場合、メロディ(data2)再生 if (hasNewCoordinate) { Serial.println("Found new coordinate(s) in GET result! Playing melody2..."); playMelody(data2); } // 今回のリストを prevCoords にコピーして更新 prevCoordCount = newCount; for (int i = 0; i < newCount; i++) { prevCoords[i] = newCoords[i]; } } /*************************************************** * 【BLEコールバック】 ***************************************************/ void bleWriteCB(struct ble_gatt_char_s *ble_gatt_char) { if (ble_gatt_char->value.length < 1) return; int receivedData = ble_gatt_char->value.data[0]; Serial.print("BLE Received Value: "); Serial.println(receivedData); // カウントが変わったときのみトリガー if (receivedData != previousData) { Serial.println("Hello (BLE data changed)"); previousData = receivedData; // GNSSが有効なら、その座標をPOST if (currentPosition.valid) { postData = createLocationJson(currentPosition.latitude, currentPosition.longitude); doPostSaveLocation(); } else { Serial.println("No valid GNSS fix yet, cannot POST location."); } } } /*************************************************** * 【setup()】 ***************************************************/ void setup() { // シリアル初期化 Serial.begin(115200); while (!Serial) { ; } // BLE設定 ble1507 = BLE1507::getInstance(); ble1507->beginPeripheral(ble_name, addr, UUID_SERVICE, UUID_CHAR); ble1507->setWritePeripheralCallback(bleWriteCB); ble1507->startAdvertise(); Serial.println("BLE start"); // GNSS開始 setupGnss(); // LTE初期化 setupLTE(); // タイマー初期化 lastRequestTime = millis(); playMelody(data); } /*************************************************** * 【loop()】 ***************************************************/ void loop() { // 1) GNSS位置情報を5秒に1回更新 static unsigned long lastGnssUpdate = 0; if (millis() - lastGnssUpdate >= 5000) { lastGnssUpdate = millis(); currentPosition = getGnssPosition(); if (currentPosition.valid) { Serial.print("Fix, Lat="); Serial.print(currentPosition.latitude, 6); Serial.print(", Lon="); Serial.println(currentPosition.longitude, 6); } else { Serial.println("No valid fix or no update"); } } // 2) 30秒おきにGETリクエスト実行 if (millis() - lastRequestTime >= intervalGet) { lastRequestTime = millis(); if(currentPosition.valid)doGetLocationsWithinRadius(); } // delay(50); }

また、今回Google Cloud Functionsを用いて下記のコードでサーバーを用意しています。
ソースコードにはこのアドレスを初期設定で埋め込んでいるためすぐに他のユーザーと共通のデータを利用できます。

サーバー側コード

サーバー側コード

const admin = require("firebase-admin"); const functions = require("firebase-functions"); // Firebase Admin SDKの初期化 admin.initializeApp(); const db = admin.firestore(); // Haversine公式で距離を計算 function haversine(lat1, lon1, lat2, lon2) { const R = 6371; // 地球の半径 (km) const toRad = (x) => (x * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } // 位置情報をFirestoreに保存する関数 (POSTリクエスト) exports.saveLocation = functions.https.onRequest(async (req, res) => { if (req.method !== "POST") { return res.status(405).json({ success: false, message: "Method Not Allowed" }); } const { latitude, longitude } = req.body; if (!latitude || !longitude) { return res.status(400).json({ success: false, message: "Missing latitude or longitude" }); } try { const data = { latitude, longitude, timestamp: admin.firestore.FieldValue.serverTimestamp(), }; const docRef = await db.collection("locations").add(data); res.status(200).json({ success: true, message: "Location saved successfully!", id: docRef.id, }); } catch (error) { res.status(500).json({ success: false, message: "Failed to save location", error: error.message, }); } }); // 一定距離内の位置情報を取得する関数 (GETリクエスト) exports.getLocationsWithinRadius = functions.https.onRequest(async (req, res) => { if (req.method !== "GET") { return res.status(405).json({ success: false, message: "Method Not Allowed" }); } const { latitude, longitude, radius } = req.query; if (!latitude || !longitude || !radius) { return res.status(400).json({ success: false, message: "Missing latitude, longitude, or radius" }); } const centerLat = parseFloat(latitude); const centerLon = parseFloat(longitude); const radiusKm = parseFloat(radius); try { const snapshot = await db.collection("locations").get(); const locations = []; snapshot.forEach((doc) => { const data = doc.data(); const distance = haversine(centerLat, centerLon, data.latitude, data.longitude); if (distance <= radiusKm) { locations.push({ latitude: data.latitude, longitude: data.longitude, }); } }); res.status(200).json({ success: true, locations }); } catch (error) { res.status(500).json({ success: false, message: "Failed to retrieve locations", error: error.message, }); } });

成果と今後

求めていた機能が実現できている一方、ジェスチャーの認識精度が完全ではありません。
私以外のデータも含めたより大きな学習データを用意してモデルを改善したいと考えています。

日頃のサイクリングのなかで危険な地点情報が蓄積されていくことにワクワクします。将来はこのシステムをサイクリング界で流行らせて危険地点のデータベースを作成し、安全なサイクリングのためのルート探索システムなどを作成したいと企んでいます。

使用にあたって

実際にすぐ使っていただけるようにサーバーのurl等を埋め込んでいますが、予告なくサーバーを停止する可能性があります。その際は本文中のサーバー用スクリプトを利用してご自身でサーバーを立てていただくか、筆者までご連絡ください。
また、まず子機を起動し、1秒後に親機を起動するようにお願いします。

終わりに

このシステムの原型をMFTで展示しご好評いただきましたが、インターネット接続部分の制作に苦戦していました。今回、会場でいただいたアドバイスを元にLTE拡張ボードでインターネットに繋ぐ方法に変更し、ケースを小型化すると同時にコードも全て一新しました。
会場でアドバイスくださった皆様に心よりお礼申し上げます。

1
  • mkv さんが 2025/01/31 に 編集 をしました。 (メッセージ: 初版)
  • mkv さんが 2025/01/31 に 編集 をしました。 (メッセージ: 軽微な修正)
ログインしてコメントを投稿する