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

sakuragawa が 2026年01月26日17時24分21秒 に編集

コメント無し

本文の変更

# はじめに こちらの開発物は下記の2名の方々と協力し、共同で制作したものになります!! rsny wagrics ## そもそもサイクルコンピュータって? サイクルコンピュータとは、サイクリングをより楽しく、快適にするアイテムで、走行中に速度を表示したり、時間を計測したり出来ます!! お高いやつだと、ナビ機能なども付いていたりして便利です。 ## 問題提起 そんな自転車を楽しむ人々にとって、いろんなメリットのあるサイクルコンピュータですが、それには課題点もあります。 それは **充電がめんどくさい** ということです。 自転車に乗るたびにいちいち取り外して、充電して、次乗るときにまた持って取り付けて、、、、 なんてやってるうちに面倒になって、せっかく買ったのに使わなくなり、埃を被ってしまいます。(※体験談) フル充電でも、何度か長距離で乗っていると、途中で切れてしまうこともあるため、自転車につけっぱなしという訳にもいきません。 どうにかしてこの問題を解決する方法はないでしょうか。 ## というわけで 前述した問題を解決するため、我々が開発したのが、 **自転車で発電した電力でそのまま動く、Spresense製のサイクルコンピュータ** です!! Spresenseには、 - 低消費電力 - 標準でマルチGNSS機能内蔵 という特徴を備えているため、とてもサイクルコンピュータ向きであるうえ、 自転車に発電機を取り付ければ、そのまま電力供給が可能であるという点において、サイクルコンピュータと発電機の相性が非常にマッチしていると考え、開発に至りました!!! # 結果 最初に結果を示します。 技術的な詳細などは、後述します。 @[youtube](https://youtu.be/JyMZFAqjr6I) カメラがちょっとぶれていますが、~~ちょっとじゃない~~ 走行中の速度が表示されているのが確認できるかと思います。 また、走行中もずっと発電機の電力だけで動作しています!!! # 部品 開発に必要な部品は以下となります。 | 部品名 | 備考 | |:---:|:---| | [Spresenseメインボード](https://akizukidenshi.com/catalog/g/g114584/) | サイクルコンピュータ本体の中核となります。 | | [Spresense 拡張ボード](https://akizukidenshi.com/catalog/g/g114585/) | 3.3 Vの出力を出すために採用です。 | | [Arduino用 ユニバーサルプロトシールド](https://akizukidenshi.com/catalog/g/g107555/) | 基板の実装に使いました! | | [自転車 (パンゲア ロビンソン)](https://www.amazon.co.jp/PANGAEA-ROBINSON-%E3%82%B3%E3%83%B3%E3%83%91%E3%82%AF%E3%83%88%E6%8A%98%E3%82%8A%E3%81%9F%E3%81%9F%E3%81%BF%E8%87%AA%E8%BB%A2%E8%BB%8A-%E3%82%B7%E3%83%9E%E3%83%8E6%E6%AE%B5%E5%A4%89%E9%80%9F%E6%A9%9F%E6%90%AD%E8%BC%89-%E3%83%90%E3%82%B9%E3%82%B1%E3%83%83%E3%83%88%E6%A8%99%E6%BA%96%E8%A3%85%E5%82%99/dp/B071F791N8) | 部室内で放置されていたものを使用。 | | [自転車チェーン発電機](https://www.amazon.co.jp/dp/B0BVKF6FQ6?ref=ppx_yo2ov_dt_b_fed_asin_title) | デフォルトでUSB(直流)が付いていたため、こちらを選択しました。 | | 5 V一定にするための定圧回路 | 発電機から出力された電圧を一定にさせるために必要な回路です。こちらも開発したものになります。 | # 製作品概要 ## サイクルコンピュータ本体 以下が、制作したサイクルコンピュータ本体の画像になります。 ![サイクルコンピュータ本体](https://camo.elchika.com/09832ff8b50d507250fc73effebe5db8468cbf51/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f64633930613361362d393734392d343933312d623463302d6638313137363766383133652f65353762383766362d626536372d343933322d613362382d333330623335653937653638/) 基板上での部品配置は、次のようになっております!! | 各部品 | 役割 | |:---:|:---| | [OLEDディスプレイ](https://akizukidenshi.com/catalog/g/g112031/) | 画面の出力装置 / 小さめですが、3.3 Vで動作するため、消費電力を抑えたいという観点からこちらの表示装置を採用。 | | 左のボタン | 機能切り替えボタン。タクトスイッチです。 | | 右のボタン | タイマー機能の使用の際に使う、スタート・ストップボタン。 / その他 | | 右上の赤色LED | 電源ランプ | | 右下のケーブル | 供給されてる電圧の確認用ケーブル | 本体の配線図、および回路図を以下に示します。シールド上に回路を収めるため、シンプルでコンパクトな構造となっております!! ![本体の配線図](https://camo.elchika.com/542f97e2c62896327e2797c94bab4173fb26a89d/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f64633930613361362d393734392d343933312d623463302d6638313137363766383133652f36643631636535382d396466392d343463342d393662362d666434623339353563666436/) ![本体の回路図](https://camo.elchika.com/da4cc308a02ce370c5183890750e76900da61a30/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f64633930613361362d393734392d343933312d623463302d6638313137363766383133652f31386132396339352d643532342d346163612d383363632d353431353936633765323238/) ## 全体像 自転車に取り付けた際の全体像は以下になります!! ![マシンの全体像](https://camo.elchika.com/50b6e243719d930892b81436d548fad684babe03/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f64633930613361362d393734392d343933312d623463302d6638313137363766383133652f62643837636638362d323538342d346333622d383730642d353139313361636636373763/) ### 発電機の固定 発電機は、テープを用いて、走行中に動かないように固定しています。 本来であれば発電機に付属の説明書通りに取り付けを行うだけで良いのですが、 使用した自転車が折り畳み式なので小さく、取り付けのスペースが狭いという問題から、位置によっては走行中にペダルと干渉してしまいます。:sweat_drops: 見た目を損なってしまうというデメリットがあるため、発電機の固定方法に関しては今後の課題といたします。 ![発電機の固定の様子](https://camo.elchika.com/d0e08991226f13f8b7dd27789c4d34a6f27b3d90/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f64633930613361362d393734392d343933312d623463302d6638313137363766383133652f37383038306366362d656237302d343239642d393663372d346266333036343437336366/) ### 定圧回路の固定 定圧回路は、保護のために加工したプラスチックケース内に入れ、シートチューブ下部に固定しています。 ![定圧回路の固定の様子](https://camo.elchika.com/b35bce376c11d71ef88409801e924ee4b36e03fc/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f64633930613361362d393734392d343933312d623463302d6638313137363766383133652f63343137383263312d333234662d343737382d393231662d333766323237383534656366/) ### ケーブルの固定 また、発電機から定圧回路、本体に至るまでの電源ケーブルは、 走行の阻害とならないように、自転車のボディの要所で、結束バンドを用いて固定致しました!! ![ケーブル固定の様子](https://camo.elchika.com/872b1a2c700b7c0d9acef265558cd9a4320f8575/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f64633930613361362d393734392d343933312d623463302d6638313137363766383133652f30386363383736632d353636612d343965392d613464302d633761643562346238326265/) ### 本体の固定 サイクルコンピュータ本体は、 - バイク用のスマホホルダー - 卓上スマホスタンド を組み合わせて、走行中に振動が加わっても外れないよう固定しています!! ![本体の固定の様子](https://camo.elchika.com/57016f18df214b3be9f77b089959d9796e141d1f/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f64633930613361362d393734392d343933312d623463302d6638313137363766383133652f33343132623136352d386335342d346638622d383935392d613165663131353835333839/) ## 定圧回路 ### 概要 発電機にデータシートが無かったのでテスターでUSB Type-A出力電圧を測定したところ、USBポートから6 Vが出力されていました。~~あれ、USB Type-Aの電源出力って5 Vでは??~~ Spresenseの最大入力電圧は5.5 Vっぽいので、このままSpresenseに繋げるとおそらく壊れます。 このこととバッテリーが発電機についていることを考慮して、ざっくりと次のような回路仕様としました。 - 6 Vから5 Vへ変換すること - 出力が安定な電源とすること - バッテリーが減ったらSpresenseに通知すること 仕様決定後、仕様から回路を設計し、必要な部品を秋月電子通商様で購入してユニバーサル基板上ではんだ付けしました。 定圧回路の画像を以下に示します。 ![定圧回路](https://camo.elchika.com/9de6915def7cd0f48c76c1aa4bf63df0f61e1d0f/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f64633930613361362d393734392d343933312d623463302d6638313137363766383133652f63656238633062372d333937322d343039342d393432352d393234633066353863343866/) ### 回路図 回路図を以下に示します。 ![定圧回路の回路図](https://camo.elchika.com/67ddf332651106cf57bb1bcfa77f9d1141cd587b/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f64633930613361362d393734392d343933312d623463302d6638313137363766383133652f39393235626637392d613932662d346239612d626134322d333431663262366330623635/) ### 回路の説明 回路は、 1. 発電機入力を低損失レギュレータで5 Vに降圧 2. DCDCコンバータモジュールで5 VとしてUSBで出力 という2段構成となっています。 ここで、回路に詳しい方なら **「レギュレータとDCDCコンバータモジュール、どっちか片方でよくない?」** と思うかもしれません。 その通りです。裏話をすると、当初はDCDCコンバータモジュールのみで5 Vを出力しようとしていました。ただ、回路テストの直前でモジュールの最大入力電圧が5.5 Vと気づきました。 そのため、モジュールキットで上手くいかなかったとき用に購入していた低損失レギュレータ(最大入力電圧14 V)を急遽回路に挿入し、発電機の6 Vに対応させました。 結果としては、発電機が異常な電圧を出力したとしても14 Vまではレギュレータで降圧でき、 発電機内のバッテリー残量低下で出力が5 V程度になったとしてもDCDCコンバータモジュールで5 Vに昇圧できるため、どちらか片方を用いるよりも良い回路ができたと考えています。 主な要素の他に、レギュレータ、DCDCコンバータモジュールに並列してパスコンを入れています。 (DCDCコンバータモジュール内部にもパスコンは入っているので、レギュレータとDCDCモジュールの接続部にはパスコンを入れていません。) また、バッテリーの残量検知として降圧前の電圧を抵抗で分圧し、XHコネクタ接続先のSpresenseのADCで分圧電圧を検知することにより、電圧検知をしています。 # 動作の流れ 動作の流れは以下となっています。 1. 自転車を漕ぐと発電機により電圧が生じます。 2. しかし使用した発電機は、標準で6 Vを出力するものなので、Spresenseの定格電圧である5 Vを超えてしまっています。 3. そこで、定圧回路を媒介させることで、5 V一定に変換します。 4. そして、5 Vの安定電圧がSpresense、つまりサイクルコンピュータ本体へと供給されます!! ![システム構成](https://camo.elchika.com/c9e750b1fee38f0cbc1687f9cf5236ec9368be86/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f64633930613361362d393734392d343933312d623463302d6638313137363766383133652f31343636636633352d613331302d343265342d616233332d623762353630323738623165/) # 機能紹介 サイクルコンピュータに必要な基本的な機能は取り揃えていると思います!! こちらのサイクルコンピュータは、 **3つのモード** があって、左のボタンでモードを切り替えて使用することができます。 ![3モードの概要図](https://camo.elchika.com/f70378895d9a7ddc61cd09b2609163d9da5056b3/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f64633930613361362d393734392d343933312d623463302d6638313137363766383133652f36343731653139362d663932302d346663342d383866342d386430656661333830343736/) 例えば、モード1では、1つの画面に速度とタイマーの2つの数字が表示されていて、 2つの機能を1つの画面に集約することで、 **画面の切り替え回数を抑える** ことができます!! ## 画面の見方 表示部分の見方を説明します。 表示部分は下図のようになっております!!! ![表示部分の見方](https://camo.elchika.com/e6ca426b1f6c6c2e8c8c99c99e6b69cef0ba9f81/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f64633930613361362d393734392d343933312d623463302d6638313137363766383133652f30643163643639342d366337352d343231302d383664302d656536663433313738656230/) | 番号 | 説明 | |:---:|:---| | ① | GNSSのFIXを示します。「WAIT」となってる時は、GNSSの取得を待っている状態で、取得できたら、「2D」か「3D」の表示へと変化します:bangbang: | | ② | 上側の数字(機能1)が何を表示しているかを示します。「SPD」が速度、「AVG」が平均速度、「MAX」が最大速度を示します | | ③ | 下側の数字(機能2)が何を表示しているのかをを示します。「Time」がタイマー、「Odo」が総走行距離、「Clock」が現在時刻を表示します | | ④ | 各モードに対応した機能の1つ目が表示されます。画像では、現在の速度が表示されています。 | | ⑤ | 各モードに対応した機能の2つ目が表示されます。画像では、タイマー(ストップウォッチ)が表示されています。 | 続いて、機能の詳細について述べます!! ## モード1:速度&タイマー機能 モード1は速度&タイマー機能になります! 起動時はこの画面が表示されます。 上側に現在の速度、下側にタイマー(ストップウォッチ)が表示されています!! ![速度&タイマー画面](https://camo.elchika.com/eaf57cdffd009f27cae51b153bd303682ce13841/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f64633930613361362d393734392d343933312d623463302d6638313137363766383133652f62303530383266322d336666332d346464312d613439342d383730316430323334363936/) |数値|計算方法| |:--:|:--:| |速度[km/h]|GNSSから`velocity`を取得| |タイマー[mm:ss]|`millis()`を使用| 左のボタンでストップ・再スタートすることができ、2つのボタンを同時押しすることで、タイマーをリセットすることが出来ます!! ## モード2:平均速度&総走行距離の機能 モード2は平均速度&総走行距離表示の機能になります! 上側に現在の速度、下側にタイマー(ストップウォッチ)が表示されています!! ![平均速度&総走行距離の画面](https://camo.elchika.com/680dc441e6208e1e90c9788febf6611eae9c23b8/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f64633930613361362d393734392d343933312d623463302d6638313137363766383133652f65346230366433352d636337622d346133612d626262382d353931373831653735656238/) 右上の「Odo」というのは、「Odometer(累計距離計測器)」のことです。 |数値|計算方法| |:--:|:--:| |平均速度[km/h]|総走行距離/走行時間| |総走行距離[km/h]|メインループごとに速度×経過時間を加算| タイマー機能の際と同様に、2つのボタンを同時押しすることで、平均速度と総走行距離をリセットできます!! ## モード3:最大速度&現在時刻の機能 モード3は最大速度&現在時刻の表示機能になります。 上側に現在の最大速度、下側に現在時刻が表示されています!! ![最大速度&現在時刻の画面](https://camo.elchika.com/d15025dd937008e97e95aae64dfb45fa35045bf5/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f64633930613361362d393734392d343933312d623463302d6638313137363766383133652f31316563363933352d333237342d346435302d616234392d323938343137616234393536/) |数値|計算方法| |:--:|:--:| |最大速度[km/h]|メインループごとに速度から最大のものを計算| |現在時刻[hh:mm]|GNSSから取得| 2つのボタンを同時に押せば、最大速度をリセットできます!! ## その他の機能 その他の機能を紹介します!! ### 全リセット機能 2つのボタンを同時に押して、そのまま長押しすると、画面が一瞬暗転して、再描画された後、 **全ての計測 (タイマー、平均速度、総走行距離、最大速度) がリセット** されます!! ### データ保存機能

-

計測した値 (タイマー、平均速度、総走行距離、最大速度) は、一定間隔でフラッシュメモリに書き込まれるので、電源が落ちてもそれらの値は保持されます!!

+

計測した値 (平均速度、総走行距離、最大速度) は、一定間隔でフラッシュメモリに書き込まれるので、電源が落ちてもそれらの値は保持されます!!

ですので、リセットしない限りは、これまでの累計を記録しながらサイクリングを楽しむことができます!! # Q. 結局、発電機だけでずっと動くの?? ## A. 長時間停止しない限り、おそらく半永久的に動きます!! まず、自転車が停止した状態では、サイクルコンピュータの電源は入っていません。 その状態から、自転車のペダルを回転させると、電圧が生じて、約4.8秒後にSpresenseメインボード、つまりサイクルコンピュータ本体が起動します。 この際、すぐにペダルを止めた場合は、起動してから約30秒後に電源が消えてしまいます。 しかし、**しばらくペダルを回し続けた後に、ペダルの回転を止めた場合には、その分起動し続ける時間も長くなっております。** ## 検証 検証として、**約2秒に1回転のペース**で、ペダルの回転時間と、対応した駆動時間を測定しました。自転車のギアは4です。 始点の4.8秒は、ペダルを回転させてからディスプレイが表示されるまでの時間です。 ![ペダルの回転時間-駆動時間](https://camo.elchika.com/8e2d0d8882c915c1611dfbd2456f3b00fb237de4/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f64633930613361362d393734392d343933312d623463302d6638313137363766383133652f39393362333233382d393232612d343038352d623761302d653138383238383331383832/) 以上の結果から、通常のサイクリングとして走っていた場合には、多少信号待ちが生じたとしても、電源が切れることは無いかと思われます!! また、開発にあたり何度も試走しておりましたが、これまでに走行途中で本体の電源が切れるいうことはなかったため、長時間停止しない限りは、電源が落ちることは無いと考えて良いかと思います!! # 今後の課題 今後の課題としては - 電圧の検知がプログラム上ではまだ未実装 - サイクルコンピュータ本体にケースが無く、基板がちょっと剥き出し。 - 明るい場所だと右上の赤色LEDが点いているか、いないかわかりづらい。 などがありますので、改善していきたいと思います。 また、プログラム次第で今後も機能は追加できるので、普通のサイクルコンピュータにはない面白い機能とか追加できたら楽しそうだなーと思います!!! # ソースコード サイクルコンピュータ本体の全ソースコードです!! ```cpp:Spresense-CycleComputer.ino #include "App.h" App app; void setup() { LowPower.begin(); LowPower.clockMode(CLOCK_MODE_32MHz); app.begin(); } void loop() { app.update(); } ``` ``` cpp:BatteryMonitor.h #include "Config.h" #include <Arduino.h> class BatteryMonitor { public: void begin() { pinMode(Config::Pins::LOW_BATT_LED, OUTPUT); } float update() { const int rawValue = analogRead(Config::Pins::VOLTAGE_SENSE); const float voltageRatio = rawValue / Config::Voltage::ADC_MAX_VALUE; const float voltage = voltageRatio * Config::Voltage::REFERENCE_VOLTAGE; const bool exceedsThreshold = Config::Voltage::REFERENCE_VOLTAGE < voltage; digitalWrite(Config::Pins::LOW_BATT_LED, exceedsThreshold ? LOW : HIGH); return voltage; } }; ``` ``` cpp:DataStore.h #include "Config.h" #include "SaveData.h" #include <EEPROM.h> #include <cmath> struct DataStore { static constexpr unsigned long SAVE_INTERVAL_MS = Config::Storage::SAVE_INTERVAL_MS; static inline SaveData load() { SaveData saveData; EEPROM.get(Config::Storage::EEPROM_ADDR, saveData); if (saveData.isValid() && !std::isnan(saveData.totalDistance) && saveData.totalDistance >= 0) return saveData; return SaveData(); } static inline void save(const SaveData &saveData) { EEPROM.put(Config::Storage::EEPROM_ADDR, saveData); } static inline void clear() { SaveData emptyData; emptyData.magic = 0; emptyData.updateCRC(); EEPROM.put(Config::Storage::EEPROM_ADDR, emptyData); } }; ``` ``` cpp:SaveData.h #include "TripData.h" #include <Arduino.h> #include <stddef.h> struct SaveData { public: // START: 保存データ float totalDistance = 0; unsigned long movingTime = 0; float maxSpeed = 0; float voltage = 0; uint32_t magic = MAGIC_NUMBER; uint32_t crc = 0; // crcは必ずデータの最後尾に配置する // END: 保存データ private: static constexpr uint32_t MAGIC_NUMBER = 0xC001BABE; public: SaveData() { updateCRC(); } SaveData(const TripData &tripData, float batteryVoltage) : totalDistance(tripData.distance), movingTime(tripData.time.moving), maxSpeed(tripData.speed.max), voltage(batteryVoltage) { updateCRC(); } TripData toTripData() const { return TripData(totalDistance, movingTime, maxSpeed); } void updateCRC() { crc = calculateCRC(); } bool isValid() const { const bool magicMatches = magic == MAGIC_NUMBER; const bool crcMatches = calculateCRC() == crc; return magicMatches && crcMatches; } bool operator==(const SaveData &other) const { const bool totalDistanceEqual = totalDistance == other.totalDistance; const bool movingTimeEqual = movingTime == other.movingTime; const bool maxSpeedEqual = maxSpeed == other.maxSpeed; const bool voltageEqual = voltage == other.voltage; return totalDistanceEqual && movingTimeEqual && maxSpeedEqual && voltageEqual; } bool operator!=(const SaveData &other) const { return !(*this == other); } private: uint32_t calculateCRC() const { uint32_t checksum = 0xFFFFFFFF; const uint8_t *dataPointer = (const uint8_t *)this; for (size_t byteIndex = 0; byteIndex < offsetof(SaveData, crc); byteIndex++) { checksum ^= dataPointer[byteIndex]; for (int bitIndex = 0; bitIndex < 8; bitIndex++) checksum = (checksum >> 1) ^ (checksum & 1 ? 0xEDB88320 : 0); } return ~checksum; } }; ``` ``` cpp:TripData.h #include "Config.h" #include <Arduino.h> #include <GNSS.h> #include <RTC.h> struct Clock { inline void begin() { RTC.begin(); } inline void sync(const SpGnssTime &gnssTime) { if (gnssTime.year < Config::Time::MIN_VALID_YEAR) return; RtcTime rtcTime(gnssTime.year, gnssTime.month, gnssTime.day, gnssTime.hour, gnssTime.minute, gnssTime.sec); RTC.setTime(rtcTime); } inline RtcTime now() { return RTC.getTime(); } }; struct GnssData { SpNavData navData; bool updated; }; constexpr float MS_TO_HOUR = 3600000.0f; struct TripData { enum class ActivityState { Stopped, Moving }; struct Speed { float current, max, avg; } speed = {0.0f, 0.0f, 0.0f}; float distance = 0.0f; struct Time { unsigned long elapsed, moving; } time = {0, 0}; ActivityState activityState = ActivityState::Stopped; bool timerPaused = false; SpFixMode fixMode = FixInvalid; unsigned long lastUpdate = 0; float distResidue = 0.0f; float weightedSpeedSum = 0.0f; // Σ(speed × deltaTime) for avg Clock clock; TripData() = default; TripData(float totalDistance, unsigned long movingTime, float maxSpeed) { distance = totalDistance; time.moving = movingTime; speed.max = maxSpeed; } TripData(const TripData &previous, const GnssData &gnssData, unsigned long currentTime) : TripData(previous) { if (gnssData.updated && gnssData.navData.posFixMode >= static_cast<int>(SpFixMode::Fix2D)) { clock.sync(gnssData.navData.time); } if (lastUpdate == 0) { lastUpdate = currentTime; return; } unsigned long deltaTime = currentTime - lastUpdate; if (gnssData.updated) { fixMode = (SpFixMode)gnssData.navData.posFixMode; float rawSpeed = gnssData.navData.velocity * 3.6f; float smoothedSpeed = Config::Gnss::SPEED_SMOOTHING * rawSpeed + (1.0f - Config::Gnss::SPEED_SMOOTHING) * speed.current; bool validFix = (fixMode >= static_cast<int>(SpFixMode::Fix2D)); bool moving = validFix && (smoothedSpeed > Config::Gnss::MIN_MOVING_SPEED_KMH); speed.current = moving ? smoothedSpeed : 0.0f; speed.max = max(speed.max, speed.current); activityState = moving ? ActivityState::Moving : ActivityState::Stopped; } else if (currentTime - lastUpdate > Config::Gnss::SIGNAL_TIMEOUT_MS) { speed.current = 0.0f; activityState = ActivityState::Stopped; } if (!timerPaused) time.elapsed += deltaTime; if (activityState == ActivityState::Moving) { time.moving += deltaTime; weightedSpeedSum += speed.current * deltaTime; speed.avg = weightedSpeedSum / time.moving; distResidue += speed.current * (deltaTime / MS_TO_HOUR); while (distResidue >= 0.001f) { distance += 0.001f; distResidue -= 0.001f; } } lastUpdate = currentTime; } bool isMoving() const { return activityState == ActivityState::Moving; } bool isPaused() const { return timerPaused; } void togglePause() { timerPaused = !timerPaused; } void clearAllData() { *this = TripData(); } void clearMaxSpeed() { speed.max = 0; } void clearAvgOdo() { activityState = ActivityState::Stopped; timerPaused = false; speed.current = speed.avg = 0.0f; weightedSpeedSum = 0.0f; time.elapsed = time.moving = 0; distResidue = 0.0f; } bool operator!=(const TripData &other) const { const bool speedChanged = fabsf(speed.current - other.speed.current) > 0.05f; const bool distanceChanged = fabsf(distance - other.distance) > 0.001f; const bool elapsedChanged = time.elapsed != other.time.elapsed; const bool stateChanged = (activityState != other.activityState) || (timerPaused != other.timerPaused); const bool fixModeChanged = fixMode != other.fixMode; return speedChanged || distanceChanged || elapsedChanged || stateChanged || fixModeChanged; } }; ``` ``` cpp:DisplayFrame.h #include "Config.h" #include "TripData.h" #include <Arduino.h> #include <cstring> #include <stdio.h> enum class Mode { SPD_TIM, AVG_ODO, MAX_CLK }; struct Header { const char *fixStatus = ""; const char *modeSpeed = ""; const char *modeTime = ""; bool operator==(const Header &other) const { return fixStatus == other.fixStatus && modeSpeed == other.modeSpeed && modeTime == other.modeTime; } }; struct Item { char value[16] = {0}; const char *unit = ""; bool operator==(const Item &other) const { return strcmp(value, other.value) == 0 && unit == other.unit; } }; struct DisplayFrame { Header header; Item main, sub; DisplayFrame() = default; DisplayFrame(const TripData &state, const GnssData &gnss, const RtcTime &clock, Mode mode) { static const char *FIX_LABELS[] = {"WAIT", "2D", "3D"}; const SpFixMode fixMode = (SpFixMode)gnss.navData.posFixMode; header.fixStatus = (fixMode >= 1 && fixMode <= 3) ? FIX_LABELS[fixMode - 1] : FIX_LABELS[0]; struct ModeConfiguration { const char *speedLabel, *timeLabel, *mainUnit, *subUnit; }; static const ModeConfiguration MODE_CONFIGS[] = { {"SPD", "Time", "km/h", ""}, {"AVG", "Odo", "km/h", "km"}, {"MAX", "Clock", "km/h", ""}, }; const auto &modeConfig = MODE_CONFIGS[(int)mode]; header.modeSpeed = modeConfig.speedLabel; header.modeTime = modeConfig.timeLabel; main.unit = modeConfig.mainUnit; sub.unit = modeConfig.subUnit; const bool paused = state.isPaused() && ((millis() / Config::UI::BLINK_INTERVAL_MS) % 2 == 0); switch (mode) { case Mode::SPD_TIM: snprintf(main.value, sizeof(main.value), "%4.1f", state.speed.current); if (paused) strcpy(sub.value, ""), sub.unit = ""; else { unsigned long totalSeconds = state.time.elapsed / 1000; unsigned long hours = totalSeconds / 3600; unsigned long minutes = (totalSeconds % 3600) / 60; unsigned long seconds = totalSeconds % 60; if (hours > 0) snprintf(sub.value, sizeof(sub.value), "%lu:%02lu:%02lu", hours, minutes, seconds); else snprintf(sub.value, sizeof(sub.value), "%02lu:%02lu", minutes, seconds); } return; case Mode::AVG_ODO: snprintf(main.value, sizeof(main.value), "%4.1f", state.speed.avg); snprintf(sub.value, sizeof(sub.value), "%5.2f", state.distance); return; case Mode::MAX_CLK: snprintf(main.value, sizeof(main.value), "%4.1f", state.speed.max); int displayHour = (clock.year() >= Config::Time::MIN_VALID_YEAR) ? (clock.hour() + Config::Time::TIMEZONE_OFFSET_HOURS) % 24 : clock.hour(); snprintf(sub.value, sizeof(sub.value), "%02d:%02d", displayHour, clock.minute()); return; } } bool operator==(const DisplayFrame &other) const { return header == other.header && main == other.main && sub == other.sub; } bool operator!=(const DisplayFrame &other) const { return !(*this == other); } }; ``` ``` cpp:Input.h #include "Config.h" struct Button { const int pin; bool pressed = false, held = false; enum { High, WaitLow, Low, WaitHigh } state = High; unsigned long lastChangeTime = 0; Button(int pinNumber) : pin(pinNumber) {} inline void begin() { pinMode(pin, INPUT_PULLUP); state = digitalRead(pin) ? High : Low; } inline void update() { pressed = false; bool rawState = digitalRead(pin); unsigned long currentTime = millis(); switch (state) { case High: if (!rawState) { state = WaitLow; lastChangeTime = currentTime; } break; case WaitLow: if (rawState) state = High; else if (currentTime - lastChangeTime > Config::Button::DEBOUNCE_MS) { state = Low; pressed = true; } break; case Low: if (rawState) { state = WaitHigh; lastChangeTime = currentTime; } break; case WaitHigh: if (!rawState) state = Low; else if (currentTime - lastChangeTime > Config::Button::DEBOUNCE_MS) state = High; break; } held = (state == Low || state == WaitHigh); } }; class Input { public: enum class Event { NONE, SELECT, PAUSE, RESET, RESET_LONG }; private: enum class State { Idle, SinglePressed, DoubleStarted, DoubleLongPressed }; Button buttonSelect, buttonPause; State currentState = State::Idle; Event pendingEvent = Event::NONE; unsigned long lastEventTime = 0; public: Input(int selectPin, int pausePin) : buttonSelect(selectPin), buttonPause(pausePin) {} void begin() { buttonSelect.begin(); buttonPause.begin(); } Event update() { buttonSelect.update(); buttonPause.update(); unsigned long currentTime = millis(); switch (currentState) { case State::Idle: if (buttonSelect.pressed && buttonPause.pressed) { changeState(State::DoubleStarted, currentTime); return Event::NONE; } if (buttonSelect.pressed) { pendingEvent = Event::SELECT; changeState(State::SinglePressed, currentTime); return Event::NONE; } if (buttonPause.pressed) { pendingEvent = Event::PAUSE; changeState(State::SinglePressed, currentTime); return Event::NONE; } break; case State::SinglePressed: if ((pendingEvent == Event::SELECT && buttonPause.pressed) || (pendingEvent == Event::PAUSE && buttonSelect.pressed)) { changeState(State::DoubleStarted, currentTime); return Event::NONE; } if (currentTime - lastEventTime > Config::Button::SINGLE_PRESS_MS) { changeState(State::Idle, currentTime); return pendingEvent; } break; case State::DoubleStarted: if (!buttonSelect.held || !buttonPause.held) { changeState(State::Idle, currentTime); return Event::RESET; } if (currentTime - lastEventTime > Config::Button::LONG_PRESS_MS) { changeState(State::DoubleLongPressed, currentTime); return Event::RESET_LONG; } break; case State::DoubleLongPressed: if (!buttonSelect.held && !buttonPause.held) changeState(State::Idle, currentTime); break; } return Event::NONE; } private: void changeState(State newState, unsigned long eventTime) { currentState = newState; lastEventTime = eventTime; } }; ``` ``` cpp:Renderer.h #include "Config.h" #include "DisplayFrame.h" #include <Adafruit_GFX.h> #include <Adafruit_SSD1306.h> #include <Wire.h> class Renderer { private: Adafruit_SSD1306 display; struct TextBounds { int16_t x, y; uint16_t width, height; }; inline TextBounds getTextBounds(const char *text) { TextBounds bounds; display.getTextBounds(text, 0, 0, &bounds.x, &bounds.y, &bounds.width, &bounds.height); return bounds; } public: Renderer() : display(Config::Display::WIDTH, Config::Display::HEIGHT, &Wire, -1) {} inline bool begin() { if (!display.begin(SSD1306_SWITCHCAPVCC, Config::Display::ADDRESS)) return false; display.clearDisplay(); display.display(); return true; } inline void render(const DisplayFrame &frame) { display.clearDisplay(); drawHeader(frame.header); drawItem(frame.main, 30, 3, 1, false); drawItem(frame.sub, 64, 2, 1, true); display.display(); } inline void resetDisplay() { display.clearDisplay(); display.setTextSize(1); const char *message = "RESETTING..."; TextBounds bounds = getTextBounds(message); display.setCursor((Config::Display::WIDTH - bounds.width) / 2, (Config::Display::HEIGHT - bounds.height) / 2); display.print(message); display.display(); delay(500); begin(); } private: inline void drawHeader(const Header &header) { display.setTextSize(1); display.setTextColor(WHITE); display.setCursor(0, 0); display.print(header.fixStatus); TextBounds bounds = getTextBounds(header.modeSpeed); display.setCursor((Config::Display::WIDTH - bounds.width) / 2, 0); display.print(header.modeSpeed); bounds = getTextBounds(header.modeTime); display.setCursor(Config::Display::WIDTH - bounds.width, 0); display.print(header.modeTime); display.drawLine(0, 10, Config::Display::WIDTH, 10, WHITE); } inline void drawItem(const Item &item, int16_t yPosition, uint8_t valueTextSize, uint8_t unitTextSize, bool alignBottom) { display.setTextSize(valueTextSize); const TextBounds valueBounds = getTextBounds(item.value); int16_t totalWidth = valueBounds.width; TextBounds unitBounds = {0, 0, 0, 0}; if (item.unit[0]) { display.setTextSize(unitTextSize); unitBounds = getTextBounds(item.unit); totalWidth += 4 + unitBounds.width; } const int16_t xPosition = (Config::Display::WIDTH - totalWidth) / 2; const int16_t valueY = alignBottom ? (yPosition - valueBounds.height) : (yPosition - valueBounds.height / 2); const int16_t unitY = alignBottom ? (yPosition - unitBounds.height) : (yPosition + valueBounds.height / 2 - unitBounds.height); display.setTextSize(valueTextSize); display.setCursor(xPosition, valueY); display.print(item.value); if (item.unit[0]) { display.setTextSize(unitTextSize); display.setCursor(xPosition + valueBounds.width + 4, unitY); display.print(item.unit); } } }; ``` ``` cpp:App.h #include "Config.h" #include "BatteryMonitor.h" #include "DataStore.h" #include "TripData.h" #include "DisplayFrame.h" #include "Input.h" #include "Renderer.h" #include <GNSS.h> #include <LowPower.h> template <typename T> struct DoubleBuffer { T buffers[2]; int index = 0; T &current() { return buffers[index]; } void initialize(const T &value) { buffers[0] = buffers[1] = value; } bool apply(const T &newValue) { index = 1 - index; buffers[index] = newValue; return buffers[index] != buffers[1 - index]; } }; class App { private: SpGnss gnss; DataStore store; BatteryMonitor batteryMonitor; Input input; Renderer renderer; Mode mode = Mode::SPD_TIM; DoubleBuffer<TripData> trip; DoubleBuffer<DisplayFrame> frame; DoubleBuffer<SaveData> save; unsigned long now = 0, lastUi = 0, lastSave = 0; GnssData curGnss = {}; Input::Event curBtn = Input::Event::NONE; public: App() : input(Config::Pins::BUTTON_SELECT, Config::Pins::BUTTON_PAUSE) {} void begin() { Serial.begin(115200); bool gnssInitialized = (gnss.begin() == 0); if (gnssInitialized) { gnss.select(GPS); gnss.select(GLONASS); gnss.select(QZ_L1CA); gnss.select(QZ_L1S); gnssInitialized = (gnss.start(COLD_START) == 0); } if (!renderer.begin() || !gnssInitialized) { LowPower.begin(); LowPower.deepSleep(0); } input.begin(); batteryMonitor.begin(); SaveData savedData = store.load(); trip.initialize(savedData.toTripData()); trip.current().clock.begin(); save.initialize(savedData); } void update() { static unsigned long nextLoop = millis(); while (millis() < nextLoop) delay(1); nextLoop += 33; now = millis(); curBtn = input.update(); curGnss.updated = (gnss.waitUpdate(0) == 1); if (curGnss.updated) gnss.getNavData(&curGnss.navData); if (curBtn != Input::Event::NONE) handleButton(); trip.apply(TripData(trip.current(), curGnss, now)); if (curBtn != Input::Event::NONE || now - lastUi >= Config::UI::UPDATE_INTERVAL_MS) { if (frame.apply(DisplayFrame(trip.current(), curGnss, trip.current().clock.now(), mode))) { renderer.render(frame.current()); lastUi = now; } } if (now - lastSave >= DataStore::SAVE_INTERVAL_MS && !curGnss.updated) { if (save.apply(SaveData(trip.current(), batteryMonitor.update()))) store.save(save.current()); lastSave = now; } } private: void handleButton() { auto &tripState = trip.current(); switch (curBtn) { case Input::Event::SELECT: mode = static_cast<Mode>((static_cast<int>(mode) + 1) % 3); return; case Input::Event::PAUSE: tripState.togglePause(); return; case Input::Event::RESET: if (mode == Mode::SPD_TIM) tripState.clearAvgOdo(); if (mode == Mode::MAX_CLK) tripState.clearMaxSpeed(); if (mode == Mode::AVG_ODO) tripState.clearAllData(); return; case Input::Event::RESET_LONG: tripState.clearAllData(); store.clear(); renderer.resetDisplay(); frame.initialize({}); save.initialize(SaveData(tripState, 0)); return; default: return; } } }; ``` ``` cpp:Config.h #include <Arduino.h> namespace Config { // ハードウェアピン設定 namespace Pins { constexpr int BUTTON_SELECT = PIN_D09; // モード切替ボタン constexpr int BUTTON_PAUSE = PIN_D04; // 一時停止ボタン constexpr int VOLTAGE_SENSE = PIN_A5; // バッテリー電圧監視用ADC constexpr int LOW_BATT_LED = PIN_D00; // 低電圧警告LED } // namespace Pins // OLED ディスプレイ設定 namespace Display { constexpr int WIDTH = 128; // OLED横幅 (ピクセル) constexpr int HEIGHT = 64; // OLED縦幅 (ピクセル) constexpr int ADDRESS = 0x3C; // I2Cアドレス (SSD1306標準) } // namespace Display // ボタン入力設定 namespace Button { constexpr unsigned long DEBOUNCE_MS = 20; // チャタリング防止時間 constexpr unsigned long SINGLE_PRESS_MS = 30; // シングルプレス判定時間 constexpr unsigned long LONG_PRESS_MS = 3000; // 長押し判定時間 } // namespace Button // GNSS設定 namespace Gnss { constexpr unsigned long SIGNAL_TIMEOUT_MS = 3000; // GNSS信号ロスト判定時間 constexpr float MIN_MOVING_SPEED_KMH = 4.0f; // 移動判定の最低速度(GPSドリフト対策) constexpr float SPEED_SMOOTHING = 0.7f; // EMA平滑化係数 (0.0-1.0、小さいほど滑らか) } // namespace Gnss // データ保存設定 namespace Storage { constexpr unsigned long SAVE_INTERVAL_MS = 30000; // 自動保存間隔 (30秒) constexpr unsigned long EEPROM_ADDR = 0; // EEPROM保存先アドレス constexpr float MAX_VALID_DISTANCE_KM = 1000000.0f; // 距離データ有効範囲 } // namespace Storage // 電圧監視設定 namespace Voltage { constexpr float LOW_THRESHOLD = 1.0f; // 低電圧警告閾値 (V) constexpr float REFERENCE_VOLTAGE = 3.3f; // ADC基準電圧 (V) constexpr float ADC_MAX_VALUE = 1023.0f; // ADC最大値 (10bit) } // namespace Voltage // UI更新設定 namespace UI { constexpr unsigned long UPDATE_INTERVAL_MS = 500; // UI更新間隔 constexpr unsigned long BLINK_INTERVAL_MS = 500; // 点滅間隔 constexpr int MODE_COUNT = 3; // 表示モード数 } // namespace UI // 時刻設定 namespace Time { constexpr int TIMEZONE_OFFSET_HOURS = 9; // JST = UTC + 9 constexpr int MIN_VALID_YEAR = 2026; // GPS時刻の有効判定年 } // namespace Time } // namespace Config ``` # おわりに 我々は、大学のコンピュータサークルに所属しており、 みんなで何か作ろう、となった際にこちらのコンテストを見かけたのが、開発のきっかけでした。 とりあえずやってみようの精神で開発を始めましたが、紆余曲折ありつつも形にすることができて嬉しく思います!! 数ヶ月かけて、皆で協力して1つの物を作り上げるというのは、非常にいい経験になりましたし、得られたものが多くあって、大変ながらもやって良かったと思いました!!