編集履歴一覧に戻る
t-kondoyのアイコン画像

t-kondoy が 2026年01月30日22時18分20秒 に編集

コメント無し

本文の変更

# Spresense×LoRa通信で作る登山用GPSトラッカー *これはSpresenseコンテスト2025の作品になります* ## はじめに Spresenseコンテストは毎年参加するようにしています。 理由はいくつかあるのですが、1番の目的は**自身の知識・技術の向上**と**他の参加者からの刺激**を貰うこと。 私はグータラなので、一定の期間で何かを作り上げる、いわば背水の陣で自身を追い詰めないと出来ない人間なのです。 なので毎年SONY様、elchika様には感謝しかありません。 今回はSpresenseが2台になったので**LoRa通信**に手を出してみることにしました。 最近は夫婦で登山に行くので、**登山時のお互いのGPS情報を表示する機器**を作ることが目標です。 ## LoRa通信とは LoRa(Long Range)は、LPWA(Low Power Wide Area)と呼ばれる低消費電力・長距離通信技術の一つです。 ### 主な特徴 - **長距離通信**: 見通しの良い環境で最大10km以上の通信が可能 - **低消費電力**: 電池駆動で長期間動作可能 - **通信費無料**: 免許不要の920MHz帯を使用 ← ここ熱い!! - **低データレート**: テキストデータ程度の小さなデータ向き Wi-FiやBluetoothと比べると通信速度は遅いですが、その分**圧倒的に長い距離を省電力で通信できる**のが魅力です。 個人的には通信費がかからずに最大10km通信(かなり開けてないとダメ)できるのはすごいと思っています。 勿論データ量が限られるのでテキストデータのみですが、それでもすごい技術です。 また実際に完成した製品をモバイルバッテリー(10000mA)で動作チェックしましたが**2日以上余裕で持つ**感じです。 ### 参考リンク - [LoRaWANとは? - センスウェイ](https://www.senseway.net/technical-information/what-is-lorawan/) - [LoRaとは - EASEL](https://easel5.com/technical-guide/about-lora/) --- ## 使用部材 - **Spresenseメインボード** - **Spresense拡張ボード** - **ILI9341搭載2.8インチSPI制御タッチパネル付TFT液晶 MSP2807** - **LoRaボード** - **LoRa通信用アンテナ** - 3Dプリンタ用フィラメント - MicroSDHC 32GB - USBケーブル(MicroB) - ネオジムマグネット(セリアφ6mm/2.5mm厚) ※太字はモニター試供品です。 --- ## 回路接続 ![回路接続図](https://camo.elchika.com/b6af399b9bd72482c270e33762094cb6cf7fd18e/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f37343262393632352d373139662d343934302d613061642d3030333032623965366136662f37363762666338302d303430642d343063342d383930302d396136366135326539646264/) 接続図はシンプルなので省略しました。 液晶に関しては**ILI9341を使用する場合、メイン拡張ボードを3.3Vに設定しておく**のを忘れないようにしましょう。 LoRaボードもメインボードに上から挿すだけで非常に簡単です。 --- ## 登山と遭難について 登山時遭難者数の平均は年間約3,000人。 ヤマレコやYAMAP等のアプリの他にも、遭難時に見つけてもらえるビーコン等を持って登山を楽しむ人が多いと思いますが、そこそこのお金がやはりかかってしまいます。 **Spresense + LoRAならお金がかからず長距離での通信が可能**なので、よいソリューションが出てくると良いなと思います。 > 参考: [令和6年における山岳遭難の概況 - 警察庁](https://www.npa.go.jp/publications/statistics/safetylife/r06_sangakusounan_gaikyou.pdf) --- ## 工夫したポイント ### UIデザインにこだわった 今回はUIデザインに拘ってみました。 最近Macでターミナル画面の作成にハマっているので、今回も**ハッカーのターミナル画面**をイメージして制作しました。 個人的にはすごく気に入っています。 ![ターミナル風UI画面1](https://camo.elchika.com/fa08d0b5906914811bb34a6a625314340868b421/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f37343262393632352d373139662d343934302d613061642d3030333032623965366136662f32666537343631332d393337342d346435332d616563302d656435663763363232633066/) ![ターミナル風UI画面2](https://camo.elchika.com/a1440a03841f409cad11c6e6cab353bee92071e2/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f37343262393632352d373139662d343934302d613061642d3030333032623965366136662f62346236366137392d353062372d343031312d613531642d383936306661633664326563/) ![グリーンとブルーで区別](https://camo.elchika.com/311c2cfb801aaedad592c921e729821ae8e72ac7/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f37343262393632352d373139662d343934302d613061642d3030333032623965366136662f62343534366335642d303863632d346238392d616363662d366335373237663439343062/)

-

![動作画面](https://camo.elchika.com/1a0c3dddf744c928e1941acdd45927ef0438791a/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f37343262393632352d373139662d343934302d613061642d3030333032623965366136662f65383961646565642d323861372d343031312d386336652d353862616331386536616236/)

+

![起動画面とブルー画面](https://camo.elchika.com/ae668b1b5a86ece3b621371b22eacf9c665c2261/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f37343262393632352d373139662d343934302d613061642d3030333032623965366136662f37303934663636322d633136632d346432342d383931612d653432396130373332333061/)

デバイスIDで画面のカラーも分けています。 - DEVICE0: グリーン - DEVICE1: ブルー パッと見て判別できるようにしました。 ### シンプル操作を実現 リチウムイオンバッテリーとUSB-C充電ボードを準備していましたが、以下の理由からモバイルバッテリー駆動に変更しました。 - バッテリーの残量を取得できない - LoRa通信が安定しない - 実際に使用する際は充電しないと使えないのは運用上良くない またボタン類も一切搭載せず、**モバイルバッテリーに挿したら自動的に起動してGPSの取得・LoRa通信を行う**仕組みになっています。 これにより子供でもお年寄りでも何も考えずに使用可能です。 ### 自動GPXログ記録 ![GPXログ記録例](https://camo.elchika.com/f4d50074fa991e5ee7396b29c8806d0d9a034115/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f37343262393632352d373139662d343934302d613061642d3030333032623965366136662f62343430663264372d386431322d346632362d386136662d633837303662623635313564/) **MicroSDを自動判別**しますので、MicroSDを挿していたらGPS情報を記録してGPX形式で保存します。登山後の自分の登山記録を正確にプロットできる仕組みです。 これも挿せば勝手にやってくれるので便利です。現状はGPSの補足が4つ以上ないとプロットしない設定にしています。 ※画像は実際のプロットですが個人情報保護の為ぼかしてます。 --- ## 3Dモデル モデルはFusionでサクッと現物合わせで作りました。 この辺りは自分の初期仕様が曖昧だった為にいつまでもデザインが決まらず行き当たりばったりになりました。反省です。 ![3Dモデル](https://camo.elchika.com/782fb70d940707a96fee5a3045a9a76102c13d94/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f37343262393632352d373139662d343934302d613061642d3030333032623965366136662f34666365316161312d373733342d343666312d383532392d393765613534633963616365/) 当初はボディとフタの取り付けにネオジムマグネットを埋め込んで印刷していたのですが(見た目が良い!)、思いの外磁力が弱くなってしまったので通常通り穴を開けて接着剤で固定しました。 フタの磁力はかなり強いので外れる事はありません。 6mm、2.5mm厚のマグネットで6.2mm、2.8mmの穴にする事で**ジャストフィット**でした。BambulabA1miniで印刷。 Fusionのモデルは以下に置いてます。 https://a360.co/3Z2Bkei --- ## ソースコード 基本送信側も受信側も同じコードで**相互に位置情報を送信・受信する仕組み**です。 デバイスIDだけ0と1で分けることで相互受信します。 また位置情報から**方角と距離を経度緯度から割り出す**ようにしています。 ### 主な機能 - **GPS位置情報の取得**: Spresense内蔵のGNSSモジュールを使用 - **LoRa通信**: 2台のデバイス間で位置情報を送受信 - **距離・方角計算**: Haversine公式を使用した正確な計算 - **GPX自動記録**: MicroSD挿入時に自動でログを記録 - **ターミナル風UI**: レトロなハッカー風デザイン ```cpp:SPRESENSE_LoRa_GPS /* * GPS Terminal for Spresense + ILI9341 Display + LoRa * Based on Sony GNSS sample code * * 2台のデバイス間でGPS位置を送受信し、距離・方角を表示 */ #include "SPI.h" #include "Adafruit_GFX.h" #include "Adafruit_ILI9341.h" #include <GNSS.h> #include "spresense_e220900t22s_jp_lib.h" #include <SDHCI.h> // ============================================================ // デバイス設定(0 または 1 に変更して2台を区別) // ============================================================ #define DEVICE_ID 1 // デバイス0: 毎分0秒に送信、デバイス1: 毎分30秒に送信 // ============================================================ // 安定運用モード設定 // ============================================================ #define STABLE_MODE 1 // 1=安定運用、0=実験運用 // ============================================================ // デバッグ用:LoRaを無効化してGPS単体テスト // ============================================================ #define DISABLE_LORA 0 // 1=LoRa無効、0=LoRa有効 // ============================================================ // LoRa運用モード // ============================================================ #define LORA_USE_WOR_MODE 1 // 1=WOR送受信で運用、0=ノーマルモード // ピン定義 #define TFT_CS 10 #define TFT_DC 9 #define TFT_RST 8 #define T_CS 7 // ディスプレイオブジェクト Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC); // GNSSオブジェクト(サンプルと同じ方法) static SpGnss Gnss; // LoRaオブジェクト static CLoRa lora; static struct RecvFrameE220900T22SJP_t recvData; static struct LoRaConfigItem_t loraConfig; static bool loraReady = false; // レトロカラー定義(DEVICEごとに完全区別) #if DEVICE_ID == 0 #define RETRO_GREEN 0x07E8 #define RETRO_DARK 0x0280 #define RETRO_AMBER 0xFD60 #define RETRO_CYAN 0x07FF #else // DEVICE1: 青系テーマ #define RETRO_GREEN 0x001F // Blue #define RETRO_DARK 0x0010 // Dark navy #define RETRO_AMBER 0x07FF // Cyan (data) #define RETRO_CYAN 0xF81F // Magenta (accent) #endif // グローバル変数 static unsigned long loopCount = 0; // サンプルと同じ待機方式(GNSS更新が来るまで待つ) static const int GNSS_UPDATE_TIMEOUT_MS = -1; static const int GNSS_RESTART_CYCLE = 60 * 5; // 5分ごとにGNSSを再起動 // 安定運用向けの閾値 static const unsigned long POSITION_STALE_MS = 20 * 1000; // 自分の位置が古い判定 static const unsigned long REMOTE_STALE_MS = 180 * 1000; // 相手位置が古い判定 static const int GPX_FLUSH_INTERVAL_POINTS = 12; // 12点ごとにフラッシュ(5秒間隔なら1分) static const unsigned long LORA_INIT_DELAY_MS = 180 * 1000; // 起動後3分はLoRa初期化を遅らせる static const int MIN_SATELLITES_FOR_FIX = 4; // 3D Fixの目安 static const int MIN_SATELLITES_FOR_LORA = 4; // LoRa開始の目安 static const int MIN_SATELLITES_FOR_LOG = 4; // GPX記録開始の目安 static const unsigned long LORA_POLL_INTERVAL_MS = 200; // LoRa受信のポーリング間隔 // 通信設定 static const int SEND_INTERVAL_SEC = 60; // 送信間隔(秒) static const int SEND_OFFSET_SEC = 30; // デバイス1の送信オフセット // 自分のGPSデータ static double myLatitude = 0.0; static double myLongitude = 0.0; static bool myPosValid = false; static unsigned long lastFixTimeMs = 0; // 相手のGPSデータ static double remoteLatitude = 0.0; static double remoteLongitude = 0.0; static bool remotePosValid = false; static unsigned long lastRecvTime = 0; static int lastRssi = 0; // SDカード関連 static SDClass SD; static File gpxFile; static bool sdAvailable = false; static bool gpxFileOpen = false; static char gpxFileName[32]; static unsigned long gpxPointCount = 0; static const int GPX_RECORD_INTERVAL_SEC = 5; // 記録間隔(秒) static int lastRecordSec = -1; static int gpxFlushCounter = 0; static unsigned long lastLoraPollMs = 0; // 関数プロトタイプ void drawRetroFrame(); void drawInitialScreen(); void updateDisplay(SpNavData *pNavData); bool isGnssTimeValid(const SpNavData *pNavData); bool isPositionUsable(const SpNavData *pNavData, int minSatellites); // LoRa関連 bool initLoRa(); void sendGpsData(double lat, double lon); bool receiveGpsData(); bool isMyTransmitTime(int second); // 距離・方角計算 double calculateDistance(double lat1, double lon1, double lat2, double lon2); double calculateBearing(double lat1, double lon1, double lat2, double lon2); const char* bearingToDirection(double bearing); // コンパス描画 void drawCompass(int centerX, int centerY, int radius, double bearing, bool valid); // SDカード・GPX記録 bool initSD(); bool createGpxFile(SpNavData *pNavData); void writeGpxPoint(SpNavData *pNavData); void closeGpxFile(); void setup() { int error_flag = 0; Serial.begin(115200); // ★重要:ハードウェア初期化を待機 sleep(3); Serial.println("GPS Terminal Starting..."); // LED初期化(動作確認用) ledOn(PIN_LED0); ledOn(PIN_LED1); ledOn(PIN_LED2); ledOn(PIN_LED3); // ディスプレイ初期化 tft.begin(); tft.fillScreen(ILI9341_BLACK); tft.setTextColor(RETRO_GREEN); tft.setTextSize(2); tft.setCursor(30, 80); tft.println("INITIALIZING"); tft.setCursor(60, 110); tft.println("GNSS..."); tft.setTextSize(1); tft.setCursor(30, 150); tft.println("Please wait..."); // ★重要:デバッグモードを設定(サンプルと同じ) Gnss.setDebugMode(PrintInfo); // GNSS初期化 int result = Gnss.begin(); if (result != 0) { Serial.println("Gnss begin error!!"); tft.setTextColor(ILI9341_RED); tft.setCursor(30, 180); tft.println("GNSS BEGIN ERROR!"); error_flag = 1; } else { Serial.println("Gnss begin OK"); // ★衛星システム選択(GNSSOK.cpp と同じ) // まずはGPSのみで動作確認 Gnss.select(GPS); // 測位開始(サンプルと同じ COLD_START) result = Gnss.start(COLD_START); if (result != 0) { Serial.println("Gnss start error!!"); tft.setTextColor(ILI9341_RED); tft.setCursor(30, 180); tft.println("GNSS START ERROR!"); error_flag = 1; } else { Serial.println("Gnss setup OK"); tft.setTextColor(RETRO_GREEN); tft.setCursor(30, 180); tft.println("GNSS OK! Searching..."); } } // LED消灯 ledOff(PIN_LED0); ledOff(PIN_LED1); ledOff(PIN_LED2); ledOff(PIN_LED3); if (error_flag == 1) { ledOn(PIN_LED3); // エラーLED while(1) { delay(1000); } // 停止 } // LoRa初期化(安定運用のため遅延実行) #if DISABLE_LORA == 0 tft.setCursor(30, 200); tft.setTextColor(RETRO_GREEN); tft.setTextSize(1); tft.println("LoRa init pending..."); tft.setCursor(30, 220); tft.print("Wait GPS Fix / ID: "); tft.println(DEVICE_ID); #else tft.setCursor(30, 200); tft.setTextColor(RETRO_AMBER); tft.setTextSize(1); tft.println("LoRa DISABLED (debug mode)"); #endif // SDカード初期化 tft.setCursor(30, 240); tft.setTextColor(RETRO_GREEN); tft.setTextSize(1); tft.println("Initializing SD Card..."); if (!initSD()) { tft.setTextColor(RETRO_AMBER); tft.setCursor(30, 260); tft.println("SD Card not available"); tft.println(" (Recording disabled)"); // SDカードなしでも続行(エラーにしない) } else { tft.setTextColor(RETRO_GREEN); tft.setCursor(30, 260); tft.println("SD Card OK!"); } delay(2000); // 初期画面を描画 drawInitialScreen(); } void drawRetroFrame() { tft.drawRect(0, 0, 240, 320, RETRO_GREEN); tft.drawRect(2, 2, 236, 316, RETRO_GREEN); for (int i = 0; i < 4; i++) { tft.fillRect(8 + i*4, 8, 3, 3, RETRO_GREEN); tft.fillRect(240 - 20 + i*4, 8, 3, 3, RETRO_GREEN); tft.fillRect(8 + i*4, 309, 3, 3, RETRO_GREEN); tft.fillRect(240 - 20 + i*4, 309, 3, 3, RETRO_GREEN); } tft.drawFastHLine(8, 24, 224, RETRO_GREEN); } void drawInitialScreen() { tft.fillScreen(RETRO_DARK); drawRetroFrame(); tft.setCursor(40, 10); tft.setTextColor(RETRO_GREEN); tft.setTextSize(1); tft.print("* GPS+LoRa TERMINAL *"); // デバイスID表示 tft.setCursor(12, 30); tft.setTextColor(RETRO_AMBER); tft.setTextSize(1); tft.print("DEVICE:"); tft.print(DEVICE_ID); tft.setCursor(80, 30); tft.setTextColor(RETRO_GREEN); tft.print("YUK_KND"); tft.drawFastHLine(12, 42, 216, RETRO_GREEN); // === 自分の位置セクション === tft.setTextColor(RETRO_CYAN); tft.setTextSize(1); tft.setCursor(12, 48); tft.print("[MY POSITION]"); tft.setTextColor(RETRO_GREEN); tft.setCursor(12, 60); tft.print("SAT:"); tft.setCursor(60, 60); tft.print("FIX:"); tft.setCursor(12, 72); tft.print("LAT:"); tft.setCursor(12, 84); tft.print("LON:"); tft.drawFastHLine(12, 98, 216, RETRO_GREEN); // === 相手の位置セクション === tft.setTextColor(RETRO_CYAN); tft.setCursor(12, 104); tft.print("[REMOTE POSITION]"); tft.setTextColor(RETRO_GREEN); tft.setCursor(12, 116); tft.print("LAT:"); tft.setCursor(12, 128); tft.print("LON:"); tft.setCursor(12, 140); tft.print("RSSI:"); tft.drawFastHLine(12, 154, 216, RETRO_GREEN); // === 距離・方角セクション(コンパス付き) === tft.setTextColor(RETRO_CYAN); tft.setCursor(12, 160); tft.print("[NAVIGATION]"); tft.setTextColor(RETRO_GREEN); tft.setCursor(12, 175); tft.print("DIST:"); tft.setCursor(12, 195); tft.print("DIR:"); // コンパス外枠を初期描画(右側に配置) drawCompass(180, 192, 32, 0, false); tft.drawFastHLine(12, 235, 216, RETRO_GREEN); // === 日本時刻(JST) === tft.setTextColor(RETRO_GREEN); tft.setCursor(12, 242); tft.print("JST:"); } void updateDisplay(SpNavData *pNavData) { // ループカウント tft.fillRect(180, 30, 50, 10, RETRO_DARK); tft.setCursor(180, 30); tft.setTextColor(RETRO_GREEN); tft.setTextSize(1); tft.print("#"); tft.print(loopCount); bool myFresh = myPosValid && (millis() - lastFixTimeMs < POSITION_STALE_MS); bool remoteFresh = remotePosValid && (millis() - lastRecvTime < REMOTE_STALE_MS); // === 自分の位置 === // 衛星数 tft.fillRect(36, 60, 20, 10, RETRO_DARK); tft.setCursor(36, 60); tft.setTextColor(RETRO_AMBER); tft.setTextSize(1); tft.print(pNavData->numSatellites); // Fix状態 tft.fillRect(90, 60, 50, 10, RETRO_DARK); tft.setCursor(90, 60); if (pNavData->posDataExist && pNavData->posFixMode != FixInvalid) { tft.setTextColor(0x07E0); tft.print("OK"); ledOn(PIN_LED1); } else if (pNavData->posFixMode == FixInvalid) { tft.setTextColor(RETRO_AMBER); tft.print("NO-FIX"); ledOff(PIN_LED1); } else { tft.setTextColor(RETRO_AMBER); tft.print("WAIT"); ledOff(PIN_LED1); } // 自分の緯度 tft.fillRect(36, 72, 180, 10, RETRO_DARK); tft.setCursor(36, 72); tft.setTextColor(RETRO_AMBER); tft.setTextSize(1); if (pNavData->posDataExist) { tft.print(pNavData->latitude, 6); } else { if (myPosValid) { tft.print(myLatitude, 6); tft.setCursor(210, 72); tft.print("L"); } else { tft.print("---.------"); } } // 自分の経度 tft.fillRect(36, 84, 180, 10, RETRO_DARK); tft.setCursor(36, 84); tft.setTextColor(RETRO_AMBER); if (pNavData->posDataExist) { tft.print(pNavData->longitude, 6); } else { if (myPosValid) { tft.print(myLongitude, 6); tft.setCursor(210, 84); tft.print("L"); } else { tft.print("---.------"); } } // === 相手の位置 === // 相手の緯度 tft.fillRect(36, 116, 180, 10, RETRO_DARK); tft.setCursor(36, 116); if (remotePosValid) { tft.setTextColor(RETRO_AMBER); tft.print(remoteLatitude, 6); if (!remoteFresh) { tft.setCursor(210, 116); tft.print("L"); } } else { tft.setTextColor(RETRO_AMBER); tft.print("WAITING..."); } // 相手の経度 tft.fillRect(36, 128, 180, 10, RETRO_DARK); tft.setCursor(36, 128); if (remotePosValid) { tft.setTextColor(RETRO_AMBER); tft.print(remoteLongitude, 6); if (!remoteFresh) { tft.setCursor(210, 128); tft.print("L"); } } else { tft.setTextColor(RETRO_AMBER); tft.print("---"); } // RSSI表示 tft.fillRect(48, 140, 100, 10, RETRO_DARK); tft.setCursor(48, 140); if (remotePosValid) { tft.setTextColor(RETRO_AMBER); tft.print(lastRssi); tft.print(" dBm"); } else { tft.setTextColor(RETRO_AMBER); tft.print("---"); } // === 距離・方角・コンパス === double bearing = 0; bool navValid = myFresh && remoteFresh; // 距離表示 tft.fillRect(42, 175, 90, 16, RETRO_DARK); tft.setCursor(42, 175); if (navValid) { double distance = calculateDistance(myLatitude, myLongitude, remoteLatitude, remoteLongitude); bearing = calculateBearing(myLatitude, myLongitude, remoteLatitude, remoteLongitude); tft.setTextColor(RETRO_AMBER); tft.setTextSize(2); if (distance >= 1000) { tft.print(distance / 1000.0, 1); tft.setTextSize(1); tft.print("km"); } else { tft.print((int)distance); tft.setTextSize(1); tft.print("m"); } } else { tft.setTextColor(RETRO_AMBER); tft.setTextSize(1); tft.print("---"); } // 方角(数値) tft.fillRect(36, 195, 90, 16, RETRO_DARK); tft.setCursor(36, 195); if (navValid) { tft.setTextColor(RETRO_AMBER); tft.setTextSize(2); tft.print((int)bearing); tft.setTextSize(1); tft.print("d"); tft.print(bearingToDirection(bearing)); } else { tft.setTextColor(RETRO_AMBER); tft.setTextSize(1); tft.print("---"); } // コンパス描画(右側) drawCompass(180, 192, 32, bearing, navValid); // JST時刻(UTC+9) tft.fillRect(36, 242, 180, 10, RETRO_DARK); tft.setCursor(36, 242); tft.setTextColor(RETRO_AMBER); tft.setTextSize(1); char timeStr[25]; if (isGnssTimeValid(pNavData)) { // UTC→JST変換(+9時間) int jstYear = pNavData->time.year; int jstMonth = pNavData->time.month; int jstDay = pNavData->time.day; int jstHour = pNavData->time.hour + 9; // 日付繰り上げ処理 if (jstHour >= 24) { jstHour -= 24; jstDay++; // 月ごとの最大日数 int daysInMonth[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; // 閏年判定 if ((jstYear % 4 == 0 && jstYear % 100 != 0) || (jstYear % 400 == 0)) { daysInMonth[2] = 29; } if (jstDay > daysInMonth[jstMonth]) { jstDay = 1; jstMonth++; if (jstMonth > 12) { jstMonth = 1; jstYear++; } } } sprintf(timeStr, "%04d-%02d-%02d %02d:%02d:%02d", jstYear, jstMonth, jstDay, jstHour, pNavData->time.minute, pNavData->time.sec); } else { sprintf(timeStr, "----/--/-- --:--:--"); } tft.print(timeStr); // ステータスメッセージ tft.fillRect(12, 258, 216, 30, RETRO_DARK); tft.setCursor(12, 258); tft.setTextSize(1); if (pNavData->numSatellites == 0) { tft.setTextColor(RETRO_AMBER); tft.print(">SEARCHING SATELLITES..."); } else if (pNavData->posFixMode == FixInvalid) { tft.setTextColor(RETRO_AMBER); tft.print(">NO FIX ("); tft.print(pNavData->numSatellites); tft.print(" sats)"); } else if (!pNavData->posDataExist) { tft.setTextColor(RETRO_AMBER); tft.print(">ACQUIRING..."); } else { tft.setTextColor(RETRO_GREEN); tft.print(">POS LOCKED!"); } // LoRa状態 tft.setCursor(12, 273); tft.setTextColor(RETRO_GREEN); if (remotePosValid) { unsigned long elapsed = (millis() - lastRecvTime) / 1000; tft.print(">LORA: RECV "); tft.print(elapsed); tft.print("s ago"); } else { tft.print(">LORA: WAITING..."); } // 下部ステータス(SD記録状態を含む) tft.fillRect(12, 293, 216, 12, RETRO_DARK); tft.setCursor(12, 293); tft.setTextColor(RETRO_GREEN); tft.setTextSize(1); tft.print(">DEV"); tft.print(DEVICE_ID); // SD記録状態 if (gpxFileOpen) { tft.print(" REC:"); tft.print(gpxPointCount); } else if (sdAvailable) { tft.print(" SD:WAIT"); } else { tft.print(" SD:--"); } } void loop() { static int LastPrintMin = 0; static int lastTransmitSec = -1; static bool transmitted = false; static int gnssLoopCount = 0; loopCount++; // LED点滅(動作確認) static int ledState = 0; if (ledState) { ledOn(PIN_LED0); ledState = 0; } else { ledOff(PIN_LED0); ledState = 1; } // ★重要:サンプルと同じ方法でGNSSデータを取得 if (Gnss.waitUpdate(GNSS_UPDATE_TIMEOUT_MS)) { SpNavData NavData; Gnss.getNavData(&NavData); // 自分の位置を保存 if (isPositionUsable(&NavData, MIN_SATELLITES_FOR_FIX)) { myLatitude = NavData.latitude; myLongitude = NavData.longitude; myPosValid = true; lastFixTimeMs = millis(); // GPXファイルがまだ作成されていなければ作成 if (sdAvailable && !gpxFileOpen && NavData.numSatellites >= MIN_SATELLITES_FOR_LOG) { createGpxFile(&NavData); } // 定期的にGPXにポイントを記録 if (gpxFileOpen && NavData.numSatellites >= MIN_SATELLITES_FOR_LOG) { int currentSec = NavData.time.sec; // GPX_RECORD_INTERVAL_SEC秒ごとに記録 if (currentSec % GPX_RECORD_INTERVAL_SEC == 0 && currentSec != lastRecordSec) { writeGpxPoint(&NavData); lastRecordSec = currentSec; } } } // 画面更新 updateDisplay(&NavData); // LoRa初期化(衛星が見え始めるか、起動後一定時間で実行) #if DISABLE_LORA == 0 if (!loraReady) { if ((isPositionUsable(&NavData, MIN_SATELLITES_FOR_LORA)) || millis() > LORA_INIT_DELAY_MS) { if (initLoRa()) { loraReady = true; Serial.println("LoRa ready"); } else { Serial.println("LoRa init retry later"); } } } #endif // 時分割送信処理 #if DISABLE_LORA == 0 int currentSec = NavData.time.sec; // 送信タイミングの判定(毎分リセット) if (currentSec == 0 || currentSec == 30) { transmitted = false; } // 自分の送信タイミングで、まだ送信していなければ送信 if (loraReady && isMyTransmitTime(currentSec) && !transmitted && myPosValid) { sendGpsData(myLatitude, myLongitude); transmitted = true; lastTransmitSec = currentSec; } #endif // 毎分、衛星情報をシリアルに出力 if (NavData.time.minute != LastPrintMin) { Serial.print("numSatellites: "); Serial.println(NavData.numSatellites); LastPrintMin = NavData.time.minute; } // シリアル出力 char buf[128]; snprintf(buf, sizeof(buf), "%04d/%02d/%02d %02d:%02d:%02d, numSat:%2d, ", NavData.time.year, NavData.time.month, NavData.time.day, NavData.time.hour, NavData.time.minute, NavData.time.sec, NavData.numSatellites); Serial.print(buf); // posFixModeの詳細を出力 Serial.print("FixMode:"); Serial.print(NavData.posFixMode); Serial.print(", "); if (NavData.posFixMode == FixInvalid) { Serial.print("No-Fix, "); } else { Serial.print("Fix, "); } if (NavData.posDataExist == 0) { Serial.println("No Position"); } else { Serial.print("Lat="); Serial.print(NavData.latitude, 6); Serial.print(", Lon="); Serial.println(NavData.longitude, 6); } } else { Serial.println("data not update"); } // 常に受信チェック(非ブロッキング) #if DISABLE_LORA == 0 if (loraReady) { unsigned long nowMs = millis(); if (nowMs - lastLoraPollMs >= LORA_POLL_INTERVAL_MS) { // LoRa受信はブロッキングなので、データがある時だけ呼ぶ if (Serial2.available() > 0) { receiveGpsData(); } lastLoraPollMs = nowMs; } } #endif // GNSSの定期再起動(安定運用では無効) #if STABLE_MODE == 0 gnssLoopCount++; if (gnssLoopCount >= GNSS_RESTART_CYCLE) { int error_flag = 0; Serial.println("GNSS restart cycle..."); if (Gnss.stop() != 0) { Serial.println("Gnss stop error!!"); error_flag = 1; } else if (Gnss.end() != 0) { Serial.println("Gnss end error!!"); error_flag = 1; } else { Serial.println("Gnss stop OK."); } if (Gnss.begin() != 0) { Serial.println("Gnss begin error!!"); error_flag = 1; } else if (Gnss.start(COLD_START) != 0) { Serial.println("Gnss start error!!"); error_flag = 1; } else { Serial.println("Gnss restart OK."); } gnssLoopCount = 0; if (error_flag == 1) { ledOn(PIN_LED3); } } #endif } bool isGnssTimeValid(const SpNavData *pNavData) { if (pNavData->time.year < 2000 || pNavData->time.year > 2099) { return false; } if (pNavData->time.month < 1 || pNavData->time.month > 12) { return false; } if (pNavData->time.day < 1 || pNavData->time.day > 31) { return false; } if (pNavData->time.hour < 0 || pNavData->time.hour > 23) { return false; } if (pNavData->time.minute < 0 || pNavData->time.minute > 59) { return false; } if (pNavData->time.sec < 0 || pNavData->time.sec > 59) { return false; } return true; } bool isPositionUsable(const SpNavData *pNavData, int minSatellites) { if (!pNavData->posDataExist) { return false; } if (pNavData->posFixMode == FixInvalid) { return false; } if (pNavData->numSatellites < minSatellites) { return false; } return true; } // ============================================================ // LoRa関連関数 // ============================================================ bool initLoRa() { // LoRa設定値 loraConfig = { 0x0000, // own_address 0 0b011, // baud_rate 9600 bps 0b10000, // air_data_rate SF:9 BW:125 0b00, // subpacket_size 200 0b1, // rssi_ambient_noise_flag 有効 0b0, // transmission_pause_flag 有効 0b01, // transmitting_power 13 dBm 0x00, // own_channel 0 0b1, // rssi_byte_flag 有効 0b1, // transmission_method_type 固定送信モード 0b0, // lbt_flag 有効 0b011, // wor_cycle 2000 ms 0x0000, // encryption_key 0 0x0000, // target_address 0 0x00 // target_channel 0 }; // E220-900T22S(JP)へのLoRa初期設定 if (lora.InitLoRaModule(loraConfig)) { Serial.println("LoRa init error!"); return false; } Serial.println("LoRa init OK"); // 運用モードへ移行 #if LORA_USE_WOR_MODE == 1 lora.SwitchToWORReceivingMode(); Serial.println("LoRa switched to WOR receiving mode"); #else lora.SwitchToNormalMode(); Serial.println("LoRa switched to normal mode"); #endif return true; } // GPSデータを送信(パケット形式: "GPS,<device_id>,<lat>,<lon>\n") void sendGpsData(double lat, double lon) { char packet[64]; // パケット作成(小数点以下6桁で十分な精度) snprintf(packet, sizeof(packet), "GPS,%d,%.6f,%.6f\n", DEVICE_ID, lat, lon); Serial.print("Sending: "); Serial.print(packet); #if LORA_USE_WOR_MODE == 1 lora.SwitchToWORSendingMode(); #endif if (lora.SendFrame(loraConfig, (uint8_t *)packet, strlen(packet)) == 0) { Serial.println("LoRa send OK"); ledOn(PIN_LED2); // 送信成功LED delay(100); ledOff(PIN_LED2); } else { Serial.println("LoRa send FAILED"); } #if LORA_USE_WOR_MODE == 1 lora.SwitchToWORReceivingMode(); #endif } // GPSデータを受信してパース bool receiveGpsData() { if (lora.RecieveFrame(&recvData) == 0) { // データ受信成功 char recvStr[200] = {0}; memcpy(recvStr, recvData.recv_data, recvData.recv_data_len); Serial.print("Received: "); Serial.println(recvStr); Serial.print("RSSI: "); Serial.print(recvData.rssi); Serial.println(" dBm"); // パケットをパース(形式: "GPS,<device_id>,<lat>,<lon>\n") int recvDeviceId; double recvLat, recvLon; if (sscanf(recvStr, "GPS,%d,%lf,%lf", &recvDeviceId, &recvLat, &recvLon) == 3) { // 自分以外のデバイスからのデータのみ受け入れ if (recvDeviceId != DEVICE_ID) { remoteLatitude = recvLat; remoteLongitude = recvLon; remotePosValid = true; lastRecvTime = millis(); lastRssi = recvData.rssi; Serial.print("Remote position updated: "); Serial.print(remoteLatitude, 6); Serial.print(", "); Serial.println(remoteLongitude, 6); ledOn(PIN_LED3); // 受信成功LED delay(100); ledOff(PIN_LED3); return true; } } } return false; } // 自分の送信タイミングかどうか判定 bool isMyTransmitTime(int second) { // デバイス0: 毎分0秒 // デバイス1: 毎分30秒 int myOffset = DEVICE_ID * SEND_OFFSET_SEC; // 送信タイミングの前後2秒を許容 return (second >= myOffset && second < myOffset + 3); } // ============================================================ // 距離・方角計算関数 // ============================================================ // 2点間の距離を計算(メートル単位、Haversine公式) double calculateDistance(double lat1, double lon1, double lat2, double lon2) { const double R = 6371000.0; // 地球の半径(メートル) const double toRad = PI / 180.0; double dLat = (lat2 - lat1) * toRad; double dLon = (lon2 - lon1) * toRad; double a = sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * toRad) * cos(lat2 * toRad) * sin(dLon / 2) * sin(dLon / 2); double c = 2 * atan2(sqrt(a), sqrt(1 - a)); return R * c; } // 2点間の方角を計算(度数、北=0度、時計回り) double calculateBearing(double lat1, double lon1, double lat2, double lon2) { const double toRad = PI / 180.0; const double toDeg = 180.0 / PI; double dLon = (lon2 - lon1) * toRad; double lat1Rad = lat1 * toRad; double lat2Rad = lat2 * toRad; double y = sin(dLon) * cos(lat2Rad); double x = cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); double bearing = atan2(y, x) * toDeg; // 0-360度に正規化 return fmod(bearing + 360.0, 360.0); } // 方角を方位文字列に変換 const char* bearingToDirection(double bearing) { if (bearing >= 337.5 || bearing < 22.5) return "N"; if (bearing >= 22.5 && bearing < 67.5) return "NE"; if (bearing >= 67.5 && bearing < 112.5) return "E"; if (bearing >= 112.5 && bearing < 157.5) return "SE"; if (bearing >= 157.5 && bearing < 202.5) return "S"; if (bearing >= 202.5 && bearing < 247.5) return "SW"; if (bearing >= 247.5 && bearing < 292.5) return "W"; if (bearing >= 292.5 && bearing < 337.5) return "NW"; return "?"; } // ============================================================ // コンパス描画関数 // ============================================================ void drawCompass(int centerX, int centerY, int radius, double bearing, bool valid) { // コンパス領域をクリア tft.fillRect(centerX - radius - 10, centerY - radius - 10, radius * 2 + 20, radius * 2 + 20, RETRO_DARK); // 外円(コンパス枠) tft.drawCircle(centerX, centerY, radius, RETRO_GREEN); tft.drawCircle(centerX, centerY, radius - 1, RETRO_GREEN); // 方位ラベル(N/E/S/W) tft.setTextColor(RETRO_GREEN); tft.setTextSize(1); // N(上) tft.setCursor(centerX - 3, centerY - radius - 9); tft.print("N"); // E(右) tft.setCursor(centerX + radius + 3, centerY - 3); tft.print("E"); // S(下) tft.setCursor(centerX - 3, centerY + radius + 2); tft.print("S"); // W(左) tft.setCursor(centerX - radius - 9, centerY - 3); tft.print("W"); // 中心点 tft.fillCircle(centerX, centerY, 3, RETRO_GREEN); // 方位目盛り(8方向の小さなマーク) for (int i = 0; i < 8; i++) { double angle = i * 45.0 * PI / 180.0; int x1 = centerX + (radius - 5) * sin(angle); int y1 = centerY - (radius - 5) * cos(angle); int x2 = centerX + (radius - 2) * sin(angle); int y2 = centerY - (radius - 2) * cos(angle); tft.drawLine(x1, y1, x2, y2, RETRO_GREEN); } if (!valid) { // データなしの場合は矢印を描画しない return; } // 矢印を描画(相手の方向を指す) double angleRad = bearing * PI / 180.0; // 矢印の先端 int tipX = centerX + (radius - 8) * sin(angleRad); int tipY = centerY - (radius - 8) * cos(angleRad); // 矢印の根元 int baseX = centerX + 5 * sin(angleRad + PI); int baseY = centerY - 5 * cos(angleRad + PI); // 矢印の羽(左右) int leftX = centerX + 8 * sin(angleRad - 2.5); int leftY = centerY - 8 * cos(angleRad - 2.5); int rightX = centerX + 8 * sin(angleRad + 2.5); int rightY = centerY - 8 * cos(angleRad + 2.5); // 矢印本体(線) tft.drawLine(baseX, baseY, tipX, tipY, RETRO_AMBER); tft.drawLine(baseX + 1, baseY, tipX + 1, tipY, RETRO_AMBER); // 矢印の先端(三角形) int arrowHeadLen = 10; int ah1X = tipX - arrowHeadLen * sin(angleRad - 0.4); int ah1Y = tipY + arrowHeadLen * cos(angleRad - 0.4); int ah2X = tipX - arrowHeadLen * sin(angleRad + 0.4); int ah2Y = tipY + arrowHeadLen * cos(angleRad + 0.4); tft.fillTriangle(tipX, tipY, ah1X, ah1Y, ah2X, ah2Y, RETRO_AMBER); } // ============================================================ // SDカード・GPX記録関数 // ============================================================ bool initSD() { // SDカードの初期化 if (!SD.begin()) { Serial.println("SD Card initialization failed!"); sdAvailable = false; return false; } Serial.println("SD Card initialized."); sdAvailable = true; return true; } bool createGpxFile(SpNavData *pNavData) { if (!sdAvailable) return false; // ファイル名を生成(日時ベース) snprintf(gpxFileName, sizeof(gpxFileName), "TRK%02d%02d%02d%02d.gpx", pNavData->time.month, pNavData->time.day, pNavData->time.hour, pNavData->time.minute); // ファイルを開く(新規作成) gpxFile = SD.open(gpxFileName, FILE_WRITE); if (!gpxFile) { Serial.print("Failed to create GPX file: "); Serial.println(gpxFileName); return false; } Serial.print("Created GPX file: "); Serial.println(gpxFileName); // GPXヘッダーを書き込み gpxFile.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"); gpxFile.println("<gpx version=\"1.1\" creator=\"GPS Terminal\""); gpxFile.println(" xmlns=\"http://www.topografix.com/GPX/1/1\">"); gpxFile.println(" <trk>"); gpxFile.print(" <name>Track "); gpxFile.print(pNavData->time.year); gpxFile.print("-"); gpxFile.print(pNavData->time.month); gpxFile.print("-"); gpxFile.print(pNavData->time.day); gpxFile.println("</name>"); gpxFile.println(" <trkseg>"); gpxFile.flush(); gpxFileOpen = true; gpxPointCount = 0; gpxFlushCounter = 0; return true; } void writeGpxPoint(SpNavData *pNavData) { if (!gpxFileOpen || !gpxFile) return; // トラックポイントを書き込み gpxFile.print(" <trkpt lat=\""); gpxFile.print(pNavData->latitude, 6); gpxFile.print("\" lon=\""); gpxFile.print(pNavData->longitude, 6); gpxFile.println("\">"); // 高度 gpxFile.print(" <ele>"); gpxFile.print(pNavData->altitude, 1); gpxFile.println("</ele>"); // 時刻(ISO 8601形式) char timeStr[32]; snprintf(timeStr, sizeof(timeStr), "%04d-%02d-%02dT%02d:%02d:%02dZ", pNavData->time.year, pNavData->time.month, pNavData->time.day, pNavData->time.hour, pNavData->time.minute, pNavData->time.sec); gpxFile.print(" <time>"); gpxFile.print(timeStr); gpxFile.println("</time>"); gpxFile.println(" </trkpt>"); gpxPointCount++; gpxFlushCounter++; // 一定数ごとにフラッシュ(SD寿命と安定性のバランス) if (gpxFlushCounter >= GPX_FLUSH_INTERVAL_POINTS) { gpxFile.flush(); gpxFlushCounter = 0; } Serial.print("GPX point recorded: #"); Serial.println(gpxPointCount); } void closeGpxFile() { if (!gpxFileOpen || !gpxFile) return; // GPXフッターを書き込み gpxFile.println(" </trkseg>"); gpxFile.println(" </trk>"); gpxFile.println("</gpx>"); gpxFile.close(); gpxFileOpen = false; Serial.print("GPX file closed: "); Serial.print(gpxFileName); Serial.print(" ("); Serial.print(gpxPointCount); Serial.println(" points)"); } ``` --- ## 苦労したこと ### GPS捕捉の困難さ GPSのアンテナを購入できなかったので、正直家の中ではGNSSで中々取得してくれませんでした。 なので**窓際に持って行く必要があり**、デスクのMacで書き込んで窓際に持っていく作業の繰り返しでした。 またGPSの捕捉には少し時間がかかるので、**コードにミスがあるのか捕捉できていないのかの判別が難しかった**です。 長いUSBケーブルがあれば机に座ったまま窓際にSpresenseを持って行きシリアルモニターで確認できますが、無いとかなり面倒です。 ### LoRa通信の安定化 LoRa通信は更に面倒でした。同様にLoRaに関しても相互通信までに時間がかかりました(都会だからか?) サンプルコードで動いてからGPSのコードと合わせると動かなくなって(初期化のタイミング等の可能性)、**原因の切り分けがとにかく大変**で路頭に迷いながら徹夜をした日が何度かありました。 平日の仕事が、かなり辛かったです。 同じものを2つ作るだけだ!って思って挑戦しましたが、こんなに大変だとは... **通信系は本当難しい**。やってる人まじリスペクトです。 --- ## 反省点 Spresenseには**地磁気センサーがありません**。 本当なら方角がわかれば液晶内の方位コンパスを動かして、どの方角に相手がいるのか分かるようにしたかった。 今はコンパスやスマホで北を確認しないといけないので少し手間です。 地磁気センサーを購入したかったのですが、なぜか売り切れというか販売終了で手に入りませんでした。残念。 SPRESENSE向けマルチIMU Add-onボード [CXD5602PWBIMU1J]を購入すれば良いのでしょうけどめっちゃ高い。。。汗 [https://www.switch-science.com/products/10181](url) --- ## おわりに 今年もとても貴重な体験になりました。ありがとうございました。 登山の安全性向上に少しでも貢献できる作品になれば嬉しいです。 今後は地磁気センサーの追加や、更なる省電力化、通信の安定性向上などに取り組んでいきたいと思います。 最後まで読んでいただき、ありがとうございました!