diff --git a/.clang-format b/.clang-format index 85274f3..2afd186 100644 --- a/.clang-format +++ b/.clang-format @@ -1,7 +1,7 @@ BasedOnStyle: LLVM IndentWidth: 2 ColumnLimit: 100 -AllowShortFunctionsOnASingleLine: Empty +AllowShortFunctionsOnASingleLine: All BreakBeforeBraces: Attach AllowShortBlocksOnASingleLine: Always AllowShortIfStatementsOnASingleLine: AllIfsAndElse diff --git a/Spresense-CycleComputer.ino b/Spresense-CycleComputer.ino index dc80217..54221d2 100644 --- a/Spresense-CycleComputer.ino +++ b/Spresense-CycleComputer.ino @@ -1,5 +1,3 @@ -#include - #include "src/App.h" App app; @@ -10,6 +8,4 @@ void setup() { app.begin(); } -void loop() { - app.update(); -} +void loop() { app.update(); } diff --git a/docs/requirements.md b/docs/requirements.md deleted file mode 100644 index f291ed3..0000000 --- a/docs/requirements.md +++ /dev/null @@ -1,62 +0,0 @@ -# サイクルコンピュータ 要件定義書 - -## 1. 概要 - -Sony Spresense を使用したサイクルコンピュータの開発プロジェクト。GPS 情報から取得した「速度」と、現在時刻を表示する「時計」の機能を持ち、ボタン操作でこれらを切り替えて表示するデバイスを製作する。 - -## 2. システム構成 - -### 2.1 ハードウェア - -- **マイコンボード**: Sony Spresense (メインボード + 必要に応じて拡張ボード) -- **GPS/GNSS**: Spresense 内蔵 GNSS 機能を使用 -- **ディスプレイ**: LCD モジュール (SPI/I2C 接続などを想定) -- **入力インターフェース**: タクトスイッチ (プッシュボタン) x 1 -- **電源**: USB 給電 - -### 2.2 ソフトウェア開発環境 - -- **プラットフォーム**: Arduino IDE -- **言語**: C++ - -## 3. 機能要件 - -### 3.1 速度計測・表示機能 (Speed Mode) - -- **データソース**: GNSS 衛星からの測位データ (RMC/VTG センテンス等) -- **処理**: - - GNSS から対地速度(knots)を取得し、時速(km/h)に換算する。 - - 値が更新されるたびにディスプレイ上の数値を更新する。 - - 停止時(速度が極めて小さい場合)は `0.0 km/h` と表示する。 -- **表示**: - - 現在速度を大きく表示する。 - - 単位(km/h)を併記する。 - -### 3.2 時刻表示機能 (Time Mode) - -- **データソース**: GNSS 衛星からの時刻データ (UTC) -- **処理**: - - 取得した UTC 時刻を日本標準時 (JST: UTC+9) に変換して保持する。 - - GNSS 未測位時は、RTC(もしあれば)または直前の時刻からのカウントアップで補完する考慮が必要だが、初期フェーズでは GNSS 同期時のみ正確な表示とする。 -- **表示**: - - `HH:MM` または `HH:MM:SS` 形式で現在時刻を表示する。 - -### 3.3 モード切替機能 - -- **入力**: 物理ボタンの押下(立ち上がり/立ち下がり検出) -- **動作**: ボタンを押すたびに以下のモードを循環する。 - 1. **Speed Mode** (速度表示) - 2. **Time Mode** (時刻表示) - -## 4. 非機能要件 - -- **リアルタイム性**: 走行中の速度変化に追従できるよう、ディスプレイの更新はスムーズに行う(GNSS の更新レートに依存、通常 1Hz)。 -- **視認性**: 屋外での使用を想定し、直射日光下でも視認可能な UI レイアウト(大きなフォント、高コントラスト)を心がける。 -- **拡張性**: 将来的に「走行距離」「平均速度」「最高速度」などの項目を追加しやすいクラス設計(State Pattern 等)を採用する。 - -## 5. クラス設計・構成案 (現状のコードベースに基づく) - -- **`LCDDriver`**: ディスプレイ描画の抽象化レイヤー。文字列や数値の描画を担当。 -- **`Button`**: チャタリング処理を含めたボタン入力の管理。 -- **`ModeManager`**: 現在の表示モード(速度/時計)を管理し、ボタンイベントに応じて状態遷移を行う。 -- **`GPSWrapper` (新規提案)**: Spresense の GNSS ライブラリをラップし、速度(km/h)や時刻(JST)を簡単に取得できるインターフェースを提供する。 diff --git a/src/App.h b/src/App.h index 152bda3..e4b868c 100644 --- a/src/App.h +++ b/src/App.h @@ -1,67 +1,122 @@ #pragma once -#include "domain/Clock.h" -#include "domain/Trip.h" -#include "hardware/Gnss.h" -#include "hardware/OLED.h" -#include "ui/Frame.h" +#include "Config.h" +#include "domain/BatteryMonitor.h" +#include "domain/DataStore.h" +#include "domain/TripData.h" +#include "ui/DisplayFrame.h" #include "ui/Input.h" -#include "ui/Mode.h" #include "ui/Renderer.h" +#include +#include + +template struct DoubleBuffer { + T buffers[2]; + int index = 0; + T ¤t() { 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: - OLED oled; - Input input; - Gnss gnss; - Mode mode; - Trip trip; - Clock clock; - Renderer renderer; + SpGnss gnss; + DataStore store; + BatteryMonitor batteryMonitor; + Input input; + Renderer renderer; + Mode mode = Mode::SPD_TIM; + DoubleBuffer trip; + DoubleBuffer frame; + DoubleBuffer 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() { - oled.begin(); + Serial.begin(115200); + bool gnssInitialized = (gnss.begin() == 0); + if (gnssInitialized) { + gnss.select(GPS); + gnss.select(GLONASS); + gnss.select(QZ_L1CA); + gnssInitialized = (gnss.start(COLD_START) == 0); + } + + if (!renderer.begin() || !gnssInitialized) { + LowPower.begin(); + LowPower.deepSleep(0); + } + input.begin(); - gnss.begin(); - trip.begin(); + batteryMonitor.begin(); + SaveData savedData = store.load(); + trip.initialize(savedData.toTripData()); + trip.current().clock.begin(); + save.initialize(savedData); } void update() { - handleInput(); + static unsigned long nextLoop = millis(); + while (millis() < nextLoop) delay(1); + nextLoop += 33; - gnss.update(); - const SpNavData &navData = gnss.getNavData(); + now = millis(); + curBtn = input.update(); + curGnss.updated = (gnss.waitUpdate(0) == 1); + if (curGnss.updated) gnss.getNavData(&curGnss.navData); - trip.update(navData, millis()); - clock.update(navData); + if (curBtn != Input::Event::NONE) handleButton(); + trip.apply(TripData(trip.current(), curGnss, now)); - Frame frame(trip, clock, mode.get(), (SpFixMode)navData.posFixMode); - renderer.render(oled, frame); + 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 handleInput() { - switch (input.update()) { - case Input::ID::SELECT: - mode.next(); + void handleButton() { + auto &tripState = trip.current(); + + switch (curBtn) { + case Input::Event::SELECT: + mode = static_cast((static_cast(mode) + 1) % 3); return; - case Input::ID::PAUSE: - trip.pause(); + + case Input::Event::PAUSE: + tripState.togglePause(); return; - case Input::ID::RESET: - switch (mode.get()) { - case Mode::ID::SPD_TIME: - trip.resetTime(); - break; - case Mode::ID::AVG_ODO: - trip.resetOdometerAndMovingTime(); - break; - default: - break; - } + + 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; - case Input::ID::NONE: + + default: return; } } diff --git a/src/Config.h b/src/Config.h index 038eb5a..0a15c63 100644 --- a/src/Config.h +++ b/src/Config.h @@ -4,53 +4,60 @@ namespace Config { -constexpr unsigned long DEBOUNCE_DELAY_MS = 50; -constexpr unsigned long DISPLAY_UPDATE_INTERVAL_MS = 100; - +// ハードウェアピン設定 +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 JST_OFFSET = 9; -constexpr int VALID_YEAR_START = 2025; - +constexpr int TIMEZONE_OFFSET_HOURS = 9; // JST = UTC + 9 +constexpr int MIN_VALID_YEAR = 2026; // GPS時刻の有効判定年 } // namespace Time -namespace Pin { - -constexpr int BTN_A = PIN_D09; -constexpr int BTN_B = PIN_D04; -constexpr int WARN_LED = PIN_D00; - -} // namespace Pin - -namespace OLED { - -constexpr int WIDTH = 128; -constexpr int HEIGHT = 64; -constexpr int ADDRESS = 0x3C; - -} // namespace OLED - -namespace Renderer { - -constexpr int16_t HEADER_HEIGHT = 12; -constexpr int16_t HEADER_TEXT_SIZE = 1; - -} // namespace Renderer - -constexpr float MIN_MOVING_SPEED_KMH = 0.001f; - -namespace Odometer { - -constexpr float MIN_ABS = 1e-6f; -constexpr float MIN_DELTA = 0.002f; -constexpr float MAX_DELTA = 1.0f; - -} // namespace Odometer - -namespace Input { - -constexpr unsigned long SIMULTANEOUS_DELAY_MS = 50; - -} // namespace Input - } // namespace Config diff --git a/src/domain/BatteryMonitor.h b/src/domain/BatteryMonitor.h new file mode 100644 index 0000000..6aedd83 --- /dev/null +++ b/src/domain/BatteryMonitor.h @@ -0,0 +1,18 @@ +#pragma once + +#include "../Config.h" +#include + +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; + } +}; diff --git a/src/domain/Clock.h b/src/domain/Clock.h deleted file mode 100644 index 6994563..0000000 --- a/src/domain/Clock.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include - -#include "../Config.h" - -class Clock { -public: - struct Time { - int hour = 0; - int minute = 0; - int second = 0; - }; - -private: - Time time; - int year = 0; - -public: - void update(const SpNavData &navData) { - year = navData.time.year; - time.hour = (navData.time.hour + Config::Time::JST_OFFSET + 24) % 24; - time.minute = navData.time.minute; - time.second = navData.time.sec; - } - - Time getTime() const { - if (year < Config::Time::VALID_YEAR_START) return Time(); - return time; - } -}; diff --git a/src/domain/DataStore.h b/src/domain/DataStore.h new file mode 100644 index 0000000..91a90a2 --- /dev/null +++ b/src/domain/DataStore.h @@ -0,0 +1,30 @@ +#pragma once + +#include "../Config.h" +#include "SaveData.h" +#include +#include + +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); + } +}; diff --git a/src/domain/Odometer.h b/src/domain/Odometer.h deleted file mode 100644 index 2361efe..0000000 --- a/src/domain/Odometer.h +++ /dev/null @@ -1,63 +0,0 @@ -#pragma once - -#include - -#include "../Config.h" - -class Odometer { -private: - float totalKm = 0.0f; - float lastLat = 0.0f; - float lastLon = 0.0f; - bool hasLastCoord = false; - -public: - void update(float lat, float lon, bool isMoving) { - if (fabsf(lat) < Config::Odometer::MIN_ABS && fabsf(lon) < Config::Odometer::MIN_ABS) { - return; // 無効な値を避ける - } - - if (!hasLastCoord) { - lastLat = lat; - lastLon = lon; - hasLastCoord = true; - return; - } - - if (isMoving) { - const float deltaKm = planarDistanceKm(lastLat, lastLon, lat, lon); - const bool isDeltaValid = - Config::Odometer::MIN_DELTA < deltaKm && deltaKm < Config::Odometer::MAX_DELTA; - if (isDeltaValid) totalKm += deltaKm; // GPS ノイズ対策 - } - - lastLat = lat; - lastLon = lon; - } - - void reset() { - totalKm = 0.0f; - lastLat = 0.0f; - lastLon = 0.0f; - hasLastCoord = false; - } - - float getTotalDistance() const { - return totalKm; - } - -private: - static constexpr float toRad(float degrees) { - return degrees * PI / 180.0f; - } - - static float planarDistanceKm(float lat1, float lon1, float lat2, float lon2) { - constexpr float R = 6378137.0f; // WGS84 [m] - const float latRad = toRad((lat1 + lat2) / 2.0f); - const float dLat = toRad(lat2 - lat1); - const float dLon = toRad(lon2 - lon1); - const float x = dLon * cosf(latRad) * R; - const float y = dLat * R; - return sqrtf(x * x + y * y) / 1000.0f; // km - } -}; diff --git a/src/domain/SaveData.h b/src/domain/SaveData.h new file mode 100644 index 0000000..cd65195 --- /dev/null +++ b/src/domain/SaveData.h @@ -0,0 +1,59 @@ +#pragma once + +#include "TripData.h" +#include +#include + +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; + } +}; diff --git a/src/domain/Speedometer.h b/src/domain/Speedometer.h deleted file mode 100644 index 2da5cc6..0000000 --- a/src/domain/Speedometer.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -class Speedometer { -private: - struct Speed { - float curKmh = 0.0f; - float maxKmh = 0.0f; - float avgKmh = 0.0f; - }; - - Speed speed; - -public: - void update(float curKmh, unsigned long movingTimeMs, float totalKm) { - speed.curKmh = curKmh; - if (speed.maxKmh < speed.curKmh) speed.maxKmh = speed.curKmh; - if (0 < movingTimeMs) speed.avgKmh = totalKm / (movingTimeMs / (60.0f * 60.0f * 1000.0f)); - } - - float getCur() const { - return speed.curKmh; - } - - float getMax() const { - return speed.maxKmh; - } - - float getAvg() const { - return speed.avgKmh; - } -}; diff --git a/src/domain/Stopwatch.h b/src/domain/Stopwatch.h deleted file mode 100644 index c90c3c6..0000000 --- a/src/domain/Stopwatch.h +++ /dev/null @@ -1,44 +0,0 @@ -#pragma once - -class Stopwatch { -private: - struct Duration { - unsigned long movingTimeMs = 0; - unsigned long totalTimeMs = 0; - }; - - Duration duration; - bool isPaused = false; - -public: - void update(bool isMoving, unsigned long dt) { - if (isMoving) duration.movingTimeMs += dt; - if (!isPaused) duration.totalTimeMs += dt; - } - - void resetTotalTime() { - duration.totalTimeMs = 0; - } - - void resetMovingTime() { - duration.movingTimeMs = 0; - } - - void reset() { - resetTotalTime(); - resetMovingTime(); - } - - void pause() { - if (isPaused) isPaused = false; - else isPaused = true; - } - - unsigned long getMovingTimeMs() const { - return duration.movingTimeMs; - } - - unsigned long getElapsedTimeMs() const { - return duration.totalTimeMs; - } -}; diff --git a/src/domain/Trip.h b/src/domain/Trip.h deleted file mode 100644 index 16cf78b..0000000 --- a/src/domain/Trip.h +++ /dev/null @@ -1,63 +0,0 @@ -#pragma once - -#include - -#include "Odometer.h" -#include "Speedometer.h" -#include "Stopwatch.h" - -class Trip { -public: - Speedometer speedometer; - Odometer odometer; - Stopwatch stopwatch; - -private: - unsigned long lastMillis; - bool hasLastMillis; - -public: - void begin() { - reset(); - } - - void update(const SpNavData &navData, unsigned long currentMillis) { - const float rawKmh = navData.velocity * 60.0f * 60.0f / 1000.0f; - const bool hasFix = navData.posFixMode != FixInvalid; - const bool isMoving = hasFix && (Config::MIN_MOVING_SPEED_KMH < rawKmh); // GPS ノイズ対策 - const float speedKmh = isMoving ? rawKmh : 0.0f; - - if (!hasLastMillis) { - lastMillis = currentMillis; - hasLastMillis = true; - return; - } - - const unsigned long dt = currentMillis - lastMillis; - lastMillis = currentMillis; - - stopwatch.update(isMoving, dt); - if (hasFix) odometer.update(navData.latitude, navData.longitude, isMoving); - speedometer.update(speedKmh, stopwatch.getMovingTimeMs(), odometer.getTotalDistance()); - } - - void resetTime() { - stopwatch.resetTotalTime(); - lastMillis = 0; - hasLastMillis = false; - } - - void resetOdometerAndMovingTime() { - odometer.reset(); - stopwatch.resetMovingTime(); - } - - void reset() { - resetTime(); - resetOdometerAndMovingTime(); - } - - void pause() { - stopwatch.pause(); - } -}; diff --git a/src/domain/TripData.h b/src/domain/TripData.h new file mode 100644 index 0000000..410923a --- /dev/null +++ b/src/domain/TripData.h @@ -0,0 +1,127 @@ +#pragma once + +#include "../Config.h" +#include +#include +#include + +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(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(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; + } +}; diff --git a/src/hardware/Button.h b/src/hardware/Button.h deleted file mode 100644 index 9953e4e..0000000 --- a/src/hardware/Button.h +++ /dev/null @@ -1,53 +0,0 @@ -#pragma once - -#include - -#include "../Config.h" - -class Button { -private: - const int pinNumber; - bool stablePinLevel; - bool lastPinLevel; - unsigned long lastDebounceTime; - -public: - Button(int pin) : pinNumber(pin) {} - - void begin() { - pinMode(pinNumber, INPUT_PULLUP); - stablePinLevel = digitalRead(pinNumber); - lastPinLevel = stablePinLevel; - lastDebounceTime = millis(); - } - - bool isPressed() { - const bool rawPinLevel = digitalRead(pinNumber); - bool pressed = false; - - if (rawPinLevel != lastPinLevel) resetDebounceTimer(); - - if (hasDebounceTimePassed()) { - if (stablePinLevel != rawPinLevel) { - if (rawPinLevel == LOW) pressed = true; - stablePinLevel = rawPinLevel; - } - } - - lastPinLevel = rawPinLevel; - return pressed; - } - - bool isHeld() const { - return stablePinLevel == LOW; - } - -private: - void resetDebounceTimer() { - lastDebounceTime = millis(); - } - - bool hasDebounceTimePassed() const { - return Config::DEBOUNCE_DELAY_MS < (millis() - lastDebounceTime); - } -}; diff --git a/src/hardware/Gnss.h b/src/hardware/Gnss.h deleted file mode 100644 index 61e8123..0000000 --- a/src/hardware/Gnss.h +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include -#include - -class Gnss { -private: - SpGnss gnss; - SpNavData navData; - -public: - Gnss() { - memset(&navData, 0, sizeof(navData)); - } - - bool begin() { - if (gnss.begin() != 0) return false; - gnss.select(GPS); - gnss.select(GLONASS); - gnss.select(GALILEO); - gnss.select(QZ_L1CA); - gnss.select(QZ_L1S); - if (gnss.start(COLD_START) != 0) return false; - return true; - } - - bool update() { - if (gnss.waitUpdate(0) != 1) return false; - gnss.getNavData(&navData); - return true; - } - - const SpNavData &getNavData() const { - return navData; - } -}; diff --git a/src/hardware/OLED.h b/src/hardware/OLED.h deleted file mode 100644 index 3e1639b..0000000 --- a/src/hardware/OLED.h +++ /dev/null @@ -1,72 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "../Config.h" - -class OLED { -public: - struct Rect { - int16_t x; - int16_t y; - uint16_t w; - uint16_t h; - }; - -private: - Adafruit_SSD1306 ssd1306; - -public: - OLED() : ssd1306(Config::OLED::WIDTH, Config::OLED::HEIGHT, &Wire, -1) {} - - bool begin() { - if (!ssd1306.begin(SSD1306_SWITCHCAPVCC, Config::OLED::ADDRESS)) return false; - ssd1306.clearDisplay(); - ssd1306.display(); - return true; - } - - void clear() { - ssd1306.clearDisplay(); - } - - void display() { - ssd1306.display(); - } - - void setTextSize(int size) { - ssd1306.setTextSize(size); - } - - void setTextColor(int color) { - ssd1306.setTextColor(color); - } - - void setCursor(int x, int y) { - ssd1306.setCursor(x, y); - } - - void print(const char *text) { - ssd1306.print(text); - } - - void drawLine(int x0, int y0, int x1, int y1, int color) { - ssd1306.drawLine(x0, y0, x1, y1, color); - } - - Rect getTextBounds(const char *string) { - Rect rect; - ssd1306.getTextBounds(string, 0, 0, &rect.x, &rect.y, &rect.w, &rect.h); - return rect; - } - - int getWidth() const { - return Config::OLED::WIDTH; - } - - int getHeight() const { - return Config::OLED::HEIGHT; - } -}; diff --git a/src/ui/DisplayFrame.h b/src/ui/DisplayFrame.h new file mode 100644 index 0000000..278f8c0 --- /dev/null +++ b/src/ui/DisplayFrame.h @@ -0,0 +1,96 @@ +#pragma once + +#include "../Config.h" +#include "../domain/TripData.h" +#include +#include +#include + +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); } +}; diff --git a/src/ui/Formatter.h b/src/ui/Formatter.h deleted file mode 100644 index 781ac71..0000000 --- a/src/ui/Formatter.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include - -#include "../domain/Clock.h" - -class Formatter { -public: - static void formatSpeed(float speedKmh, char *buffer, size_t size) { - snprintf(buffer, size, "%4.1f", speedKmh); - } - - static void formatDistance(float distanceKm, char *buffer, size_t size) { - snprintf(buffer, size, "%5.2f", distanceKm); - } - - static void formatTime(const Clock::Time time, char *buffer, size_t size) { - snprintf(buffer, size, "%02d:%02d", time.hour, time.minute); - } - - static void formatDuration(unsigned long millis, char *buffer, size_t size) { - const unsigned long seconds = millis / 1000; - const unsigned long h = seconds / 3600; - const unsigned long m = (seconds % 3600) / 60; - const unsigned long s = seconds % 60; - - if (0 < h) snprintf(buffer, size, "%lu:%02lu:%02lu", h, m, s); - else snprintf(buffer, size, "%02lu:%02lu", m, s); - } -}; diff --git a/src/ui/Frame.h b/src/ui/Frame.h deleted file mode 100644 index 552f362..0000000 --- a/src/ui/Frame.h +++ /dev/null @@ -1,98 +0,0 @@ -#pragma once - -#include -#include - -#include "../domain/Clock.h" -#include "../domain/Trip.h" -#include "Formatter.h" -#include "Mode.h" - -struct Frame { - struct Item { - char value[16] = ""; - char unit[16] = ""; - - bool operator==(const Item &other) const { - return strcmp(value, other.value) == 0 && strcmp(unit, other.unit) == 0; - } - }; - - struct Header { - char fixStatus[8] = ""; - char modeSpeed[8] = ""; - char modeTime[8] = ""; - - bool operator==(const Header &other) const { - const bool fixStatusEq = strcmp(fixStatus, other.fixStatus) == 0; - const bool modeSpeedEq = strcmp(modeSpeed, other.modeSpeed) == 0; - const bool modeTimeEq = strcmp(modeTime, other.modeTime) == 0; - return fixStatusEq && modeSpeedEq && modeTimeEq; - } - }; - - Header header; - Item main; - Item sub; - - Frame() = default; - - bool operator==(const Frame &other) const { - return header == other.header && main == other.main && sub == other.sub; - } - - Frame(Trip &trip, Clock &clock, Mode::ID modeId, SpFixMode fixMode) { - getModeData(trip, clock, modeId); - - switch (fixMode) { - case FixInvalid: - strcpy(header.fixStatus, "WAIT"); - break; - case Fix2D: - strcpy(header.fixStatus, "2D"); - break; - case Fix3D: - strcpy(header.fixStatus, "3D"); - break; - default: - strcpy(header.fixStatus, ""); - break; - } - } - -private: - void getModeData(Trip &trip, Clock &clock, Mode::ID modeId) { - switch (modeId) { - case Mode::ID::SPD_TIME: - strcpy(header.modeSpeed, "SPD"); - strcpy(header.modeTime, "Time"); - Formatter::formatSpeed(trip.speedometer.getCur(), main.value, sizeof(main.value)); - strcpy(main.unit, "km/h"); - Formatter::formatDuration(trip.stopwatch.getElapsedTimeMs(), sub.value, sizeof(sub.value)); - strcpy(sub.unit, ""); - break; - case Mode::ID::AVG_ODO: - strcpy(header.modeSpeed, "AVG"); - strcpy(header.modeTime, "Odo"); - Formatter::formatSpeed(trip.speedometer.getAvg(), main.value, sizeof(main.value)); - strcpy(main.unit, "km/h"); - Formatter::formatDistance(trip.odometer.getTotalDistance(), sub.value, sizeof(sub.value)); - strcpy(sub.unit, "km"); - break; - case Mode::ID::MAX_CLOCK: - strcpy(header.modeSpeed, "MAX"); - strcpy(header.modeTime, "Clock"); - Formatter::formatSpeed(trip.speedometer.getMax(), main.value, sizeof(main.value)); - strcpy(main.unit, "km/h"); - Formatter::formatTime(clock.getTime(), sub.value, sizeof(sub.value)); - strcpy(sub.unit, ""); - break; - default: - strcpy(main.value, "ERROR"); - strcpy(main.unit, ""); - strcpy(sub.value, ""); - strcpy(sub.unit, ""); - break; - } - } -}; diff --git a/src/ui/Input.h b/src/ui/Input.h index 053a0ee..43c455c 100644 --- a/src/ui/Input.h +++ b/src/ui/Input.h @@ -1,72 +1,134 @@ #pragma once #include "../Config.h" -#include "../hardware/Button.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 ID { - NONE, - SELECT, - PAUSE, - RESET, - }; + enum class Event { NONE, SELECT, PAUSE, RESET, RESET_LONG }; private: - Button btnSelect; - Button btnPause; - - ID pendingEvent = ID::NONE; - unsigned long pendingTime = 0; + enum class State { Idle, SinglePressed, DoubleStarted, DoubleLongPressed }; + Button buttonSelect, buttonPause; + State currentState = State::Idle; + Event pendingEvent = Event::NONE; + unsigned long lastEventTime = 0; public: - Input() : btnSelect(Config::Pin::BTN_A), btnPause(Config::Pin::BTN_B) {} + Input(int selectPin, int pausePin) : buttonSelect(selectPin), buttonPause(pausePin) {} void begin() { - btnSelect.begin(); - btnPause.begin(); + buttonSelect.begin(); + buttonPause.begin(); } - ID update() { - const bool selectPressed = btnSelect.isPressed(); - const bool pausePressed = btnPause.isPressed(); - const unsigned long now = millis(); + Event update() { + buttonSelect.update(); + buttonPause.update(); + unsigned long currentTime = millis(); - if ((selectPressed && (pausePressed || btnPause.isHeld())) || - (pausePressed && (selectPressed || btnSelect.isHeld()))) { - pendingEvent = ID::NONE; - return ID::RESET; - } - - if (pendingEvent != ID::NONE) { - const bool otherPressed = pendingEvent == ID::SELECT && pausePressed; - const bool otherPressed2 = pendingEvent == ID::PAUSE && selectPressed; - if (otherPressed || otherPressed2) { - pendingEvent = ID::NONE; - return ID::RESET; + 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; - if (Config::Input::SIMULTANEOUS_DELAY_MS <= now - pendingTime) { - ID confirmed = pendingEvent; - pendingEvent = ID::NONE; - return confirmed; + 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; - return ID::NONE; - } + 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; - if (selectPressed) { - pendingEvent = ID::SELECT; - pendingTime = now; - return ID::NONE; + case State::DoubleLongPressed: + if (!buttonSelect.held && !buttonPause.held) changeState(State::Idle, currentTime); + break; } - if (pausePressed) { - pendingEvent = ID::PAUSE; - pendingTime = now; - return ID::NONE; - } + return Event::NONE; + } - return ID::NONE; +private: + void changeState(State newState, unsigned long eventTime) { + currentState = newState; + lastEventTime = eventTime; } }; diff --git a/src/ui/Mode.h b/src/ui/Mode.h deleted file mode 100644 index 4563a84..0000000 --- a/src/ui/Mode.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -class Mode { -public: - enum class ID { SPD_TIME, AVG_ODO, MAX_CLOCK, Count }; - -private: - ID currentID = ID::SPD_TIME; - -public: - void next() { - const int count = static_cast(ID::Count); - currentID = static_cast((static_cast(currentID) + 1) % count); - } - - ID get() const { - return currentID; - } -}; diff --git a/src/ui/Renderer.h b/src/ui/Renderer.h index ec35c68..0cf7ab1 100644 --- a/src/ui/Renderer.h +++ b/src/ui/Renderer.h @@ -1,100 +1,97 @@ #pragma once -#include -#include - -#include "../hardware/OLED.h" -#include "Frame.h" +#include "../Config.h" +#include "DisplayFrame.h" +#include +#include +#include class Renderer { private: - Frame lastFrame; - bool firstRender = true; - -public: - void render(OLED &oled, Frame &frame) { - if (!firstRender && frame == lastFrame) return; + Adafruit_SSD1306 display; - firstRender = false; - lastFrame = frame; + struct TextBounds { + int16_t x, y; + uint16_t width, height; + }; - oled.clear(); - drawHeader(oled, frame); - drawMainArea(oled, frame); - oled.display(); + inline TextBounds getTextBounds(const char *text) { + TextBounds bounds; + display.getTextBounds(text, 0, 0, &bounds.x, &bounds.y, &bounds.width, &bounds.height); + return bounds; } -private: - void drawHeader(OLED &oled, const Frame &frame) { - oled.setTextSize(Config::Renderer::HEADER_TEXT_SIZE); - oled.setTextColor(WHITE); - - drawTextLeft(oled, 0, frame.header.fixStatus); - drawTextCenter(oled, 0, frame.header.modeSpeed); - drawTextRight(oled, 0, frame.header.modeTime); +public: + Renderer() : display(Config::Display::WIDTH, Config::Display::HEIGHT, &Wire, -1) {} - int16_t lineY = Config::Renderer::HEADER_HEIGHT - 2; - oled.drawLine(0, lineY, oled.getWidth(), lineY, WHITE); + inline bool begin() { + if (!display.begin(SSD1306_SWITCHCAPVCC, Config::Display::ADDRESS)) return false; + display.clearDisplay(); + display.display(); + return true; } - void drawMainArea(OLED &oled, const Frame &frame) { - const int16_t headerH = Config::Renderer::HEADER_HEIGHT; - const int16_t screenH = oled.getHeight(); - - drawItem(oled, frame.main, headerH + 14, 3, 1, false); - drawItem(oled, frame.sub, screenH, 2, 1, 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(); } - void drawItem(OLED &oled, const Frame::Item &item, int16_t y, uint8_t valSize, uint8_t unitSize, - bool alignBottom) { - const int16_t spacing = 4; - - oled.setTextSize(valSize); - OLED::Rect valRect = oled.getTextBounds(item.value); - oled.setTextSize(unitSize); - OLED::Rect unitRect = oled.getTextBounds(item.unit); - - int16_t totalW = valRect.w; - if (0 < strlen(item.unit)) totalW += spacing + unitRect.w; - - int16_t startX = (oled.getWidth() - totalW) / 2; - - int16_t valY; - int16_t unitY; - - if (alignBottom) { - valY = y - valRect.h; - unitY = y - unitRect.h; - } else { - valY = y - valRect.h / 2; - unitY = (y + valRect.h / 2) - unitRect.h; - } - - oled.setTextSize(valSize); - oled.setCursor(startX, valY); - oled.print(item.value); - - if (0 < strlen(item.unit)) { - oled.setTextSize(unitSize); - oled.setCursor(startX + valRect.w + spacing, unitY); - oled.print(item.unit); - } + 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(); } - void drawTextLeft(OLED &oled, int16_t y, const char *text) { - oled.setCursor(0, y); - oled.print(text); +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); } - void drawTextCenter(OLED &oled, int16_t y, const char *text) { - OLED::Rect rect = oled.getTextBounds(text); - oled.setCursor((oled.getWidth() - rect.w) / 2, y); - oled.print(text); - } + 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; + } - void drawTextRight(OLED &oled, int16_t y, const char *text) { - OLED::Rect rect = oled.getTextBounds(text); - oled.setCursor(oled.getWidth() - rect.w, y); - oled.print(text); + 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); + } } }; diff --git a/tests/host/CMakeLists.txt b/tests/host/CMakeLists.txt deleted file mode 100644 index d6fea1a..0000000 --- a/tests/host/CMakeLists.txt +++ /dev/null @@ -1,32 +0,0 @@ -cmake_minimum_required(VERSION 3.14) -project(SpresenseCycleComputerTests) - -include(FetchContent) -FetchContent_Declare( - googletest - GIT_REPOSITORY https://github.com/google/googletest.git - GIT_TAG v1.14.0 -) -FetchContent_MakeAvailable(googletest) - -set(TEST_SOURCES - mocks/MockGlobals.cpp - mocks/MockLibs.cpp -) - -add_executable(run_tests - ${TEST_SOURCES} -) - -target_include_directories(run_tests PRIVATE - mocks - ../../src - . -) - -target_link_libraries(run_tests GTest::gmock_main) -target_compile_options(run_tests PRIVATE -Wall -Wextra -pedantic) -target_compile_definitions(run_tests PRIVATE UNIT_TEST) - -include(GoogleTest) -gtest_discover_tests(run_tests) diff --git a/tests/host/mocks/Adafruit_GFX.h b/tests/host/mocks/Adafruit_GFX.h index 031cdf2..faaa049 100644 --- a/tests/host/mocks/Adafruit_GFX.h +++ b/tests/host/mocks/Adafruit_GFX.h @@ -5,6 +5,8 @@ #include "Arduino.h" +#define WHITE 1 + class Adafruit_GFX { public: Adafruit_GFX(int16_t w, int16_t h); diff --git a/tests/host/mocks/Adafruit_SSD1306.h b/tests/host/mocks/Adafruit_SSD1306.h index 66325e6..dccb3d7 100644 --- a/tests/host/mocks/Adafruit_SSD1306.h +++ b/tests/host/mocks/Adafruit_SSD1306.h @@ -11,8 +11,10 @@ class Adafruit_SSD1306 : public Adafruit_GFX { public: Adafruit_SSD1306(int16_t w, int16_t h, TwoWire *twi = &Wire, int8_t rst_pin = -1); + static bool mockBeginResult; - bool begin(uint8_t switchvcc = SSD1306_SWITCHCAPVCC, uint8_t i2caddr = 0, bool reset = true, bool periphBegin = true); + bool begin(uint8_t switchvcc = SSD1306_SWITCHCAPVCC, uint8_t i2caddr = 0, bool reset = true, + bool periphBegin = true); void display(); void clearDisplay(); void invertDisplay(bool i); @@ -22,10 +24,11 @@ class Adafruit_SSD1306 : public Adafruit_GFX { void setTextSize(uint8_t s); void setTextColor(uint16_t c); void setCursor(int16_t x, int16_t y); - void print(const String &s); + void print(const std::string &s); void print(const char *s); - void println(const String &s); + void println(const std::string &s); void println(const char *s); - void getTextBounds(const String &str, int16_t x, int16_t y, int16_t *x1, int16_t *y1, uint16_t *w, uint16_t *h); + void getTextBounds(const std::string &str, int16_t x, int16_t y, int16_t *x1, int16_t *y1, + uint16_t *w, uint16_t *h); }; diff --git a/tests/host/mocks/Arduino.h b/tests/host/mocks/Arduino.h index 37135c2..7fd5c9e 100644 --- a/tests/host/mocks/Arduino.h +++ b/tests/host/mocks/Arduino.h @@ -10,6 +10,8 @@ #include #include +#define PI 3.1415926535897932384626433832795 + // Mock basic types using std::abs; using std::max; @@ -59,6 +61,12 @@ inline void delay(unsigned long ms) { #define PIN_D13 13 #define PIN_D14 14 #define PIN_D15 15 +#define PIN_A0 16 +#define PIN_A1 17 +#define PIN_A2 18 +#define PIN_A3 19 +#define PIN_A4 20 +#define PIN_A5 21 // Pin Modes #define INPUT 0 @@ -84,6 +92,7 @@ inline void shiftOut(uint8_t dataPin, uint8_t clockPin, uint8_t bitOrder, uint8_ // GPIO State Map (Pin -> State) extern std::map _mock_pin_states; +extern std::map _mock_analog_values; inline void pinMode(int pin, int mode) { (void)pin; @@ -100,11 +109,23 @@ inline void digitalWrite(int pin, int val) { _mock_pin_states[pin] = val; } +inline int analogRead(int pin) { + if (_mock_analog_values.find(pin) != _mock_analog_values.end()) { + return _mock_analog_values[pin]; + } + return 512; // Return middle value +} + // Helper to set pin state for tests inline void setPinState(int pin, int state) { _mock_pin_states[pin] = state; } +// Helper to set analog value for tests +inline void setAnalogReadValue(int pin, int value) { + _mock_analog_values[pin] = value; +} + // Serial Mock class SerialMock { public: diff --git a/tests/host/mocks/DisplayLogger.h b/tests/host/mocks/DisplayLogger.h new file mode 100644 index 0000000..3f79f7b --- /dev/null +++ b/tests/host/mocks/DisplayLogger.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +struct DrawCall { + enum class Type { + Pixel, + Line, + Rect, + FillRect, + Circle, + Text, + Cursor, + TextSize, + TextColor, + Display, + Clear + }; + Type type; + int16_t x0, y0, x1, y1, color, size, r; + std::string text; +}; + +class DisplayLogger { +public: + static std::vector calls; + static void log(DrawCall call) { + calls.push_back(call); + } + static void clear() { + calls.clear(); + } +}; diff --git a/tests/host/mocks/EEPROM.h b/tests/host/mocks/EEPROM.h new file mode 100644 index 0000000..4702664 --- /dev/null +++ b/tests/host/mocks/EEPROM.h @@ -0,0 +1,37 @@ +#pragma once +#include +#include + +struct EEPROMClass { + uint8_t buffer[1024]; + + uint32_t writeCount = 0; + + EEPROMClass() { + std::memset(buffer, 0, sizeof(buffer)); + writeCount = 0; + } + + void clearWriteCount() { + writeCount = 0; + } + + template T &get(int idx, T &t) { + if (idx + sizeof(T) <= sizeof(buffer)) { + std::memcpy(&t, &buffer[idx], sizeof(T)); + } else { + std::memset(&t, 0, sizeof(T)); + } + return t; + } + + template const T &put(int idx, const T &t) { + if (idx + sizeof(T) <= sizeof(buffer)) { + std::memcpy(&buffer[idx], &t, sizeof(T)); + writeCount++; + } + return t; + } +}; + +extern EEPROMClass EEPROM; diff --git a/tests/host/mocks/GNSS.h b/tests/host/mocks/GNSS.h index 32b54c1..a9197ff 100644 --- a/tests/host/mocks/GNSS.h +++ b/tests/host/mocks/GNSS.h @@ -10,8 +10,9 @@ #define QZ_L1S 4 #define COLD_START 0 #define HOT_START 1 +typedef int SpStartMode; -enum SpGnssFixType { FixInvalid = 0, Fix2D = 1, Fix3D = 2 }; +enum SpGnssFixType { FixInvalid = 1, Fix2D = 2, Fix3D = 3 }; typedef SpGnssFixType SpFixMode; struct SpNavTime { @@ -23,6 +24,7 @@ struct SpNavTime { int sec; int usec; }; +typedef SpNavTime SpGnssTime; struct SpNavData { SpNavTime time; @@ -32,6 +34,14 @@ struct SpNavData { double longitude; float altitude; // not used but good to have int numSatellites; + + /** + * @brief Helper to move coordinates by meters (approximate) + */ + void moveByMeters(float meters) { + // Approx 111,111 meters per degree of latitude + latitude += (double)meters / 111111.0; + } }; class SpGnss { @@ -46,4 +56,6 @@ class SpGnss { // Mock control static SpNavTime mockTimeData; static float mockVelocityData; + static int mockBeginResult; + static int mockStartResult; }; diff --git a/tests/host/mocks/Gnss.h b/tests/host/mocks/Gnss.h index 32b54c1..5e8d225 100644 --- a/tests/host/mocks/Gnss.h +++ b/tests/host/mocks/Gnss.h @@ -11,7 +11,7 @@ #define COLD_START 0 #define HOT_START 1 -enum SpGnssFixType { FixInvalid = 0, Fix2D = 1, Fix3D = 2 }; +enum SpGnssFixType { FixInvalid = 1, Fix2D = 2, Fix3D = 3 }; typedef SpGnssFixType SpFixMode; struct SpNavTime { diff --git a/tests/host/mocks/LowPower.h b/tests/host/mocks/LowPower.h new file mode 100644 index 0000000..c1ccc56 --- /dev/null +++ b/tests/host/mocks/LowPower.h @@ -0,0 +1,9 @@ +#pragma once + +class LowPowerClass { +public: + void begin() {} + void deepSleep(unsigned long seconds) {} +}; + +extern LowPowerClass LowPower; diff --git a/tests/host/mocks/MockGlobals.cpp b/tests/host/mocks/MockGlobals.cpp index b602c0d..e26b2fa 100644 --- a/tests/host/mocks/MockGlobals.cpp +++ b/tests/host/mocks/MockGlobals.cpp @@ -1,5 +1,12 @@ #include "Arduino.h" +#include "EEPROM.h" +#include "LowPower.h" +#include "RTC.h" unsigned long _mock_millis = 0; std::map _mock_pin_states; +std::map _mock_analog_values; SerialMock Serial; +EEPROMClass EEPROM; +RtcClass RTC; +LowPowerClass LowPower; diff --git a/tests/host/mocks/MockLibs.cpp b/tests/host/mocks/MockLibs.cpp index 2f96692..d057bf2 100644 --- a/tests/host/mocks/MockLibs.cpp +++ b/tests/host/mocks/MockLibs.cpp @@ -1,11 +1,17 @@ +#include +#include #include -#include +#include #include "Adafruit_GFX.h" #include "Adafruit_SSD1306.h" +#include "DisplayLogger.h" #include "GNSS.h" #include "Wire.h" +// --- DisplayLogger --- +std::vector DisplayLogger::calls; + // --- Wire --- TwoWire Wire; @@ -15,13 +21,15 @@ void TwoWire::begin() { // --- Adafruit_GFX --- Adafruit_GFX::Adafruit_GFX(int16_t w, int16_t h) { - // Mock implementation (void)w; (void)h; } // --- Adafruit_SSD1306 --- -Adafruit_SSD1306::Adafruit_SSD1306(int16_t w, int16_t h, TwoWire *twi, int8_t rst_pin) : Adafruit_GFX(w, h) { +bool Adafruit_SSD1306::mockBeginResult = true; + +Adafruit_SSD1306::Adafruit_SSD1306(int16_t w, int16_t h, TwoWire *twi, int8_t rst_pin) + : Adafruit_GFX(w, h) { (void)twi; (void)rst_pin; } @@ -31,15 +39,15 @@ bool Adafruit_SSD1306::begin(uint8_t switchvcc, uint8_t i2caddr, bool reset, boo (void)i2caddr; (void)reset; (void)periphBegin; - return true; // Success + return mockBeginResult; } void Adafruit_SSD1306::display() { - // implementation + DisplayLogger::log({DrawCall::Type::Display, 0, 0, 0, 0, 0, 0, 0, ""}); } void Adafruit_SSD1306::clearDisplay() { - // implementation + DisplayLogger::log({DrawCall::Type::Clear, 0, 0, 0, 0, 0, 0, 0, ""}); } void Adafruit_SSD1306::invertDisplay(bool i) { @@ -51,43 +59,40 @@ void Adafruit_SSD1306::dim(bool dim) { } void Adafruit_SSD1306::drawPixel(int16_t x, int16_t y, uint16_t color) { - (void)x; - (void)y; - (void)color; + DisplayLogger::log({DrawCall::Type::Pixel, x, y, 0, 0, (int16_t)color, 0, 0, ""}); } void Adafruit_SSD1306::setTextSize(uint8_t s) { - (void)s; + DisplayLogger::log({DrawCall::Type::TextSize, 0, 0, 0, 0, 0, (int16_t)s, 0, ""}); } void Adafruit_SSD1306::setTextColor(uint16_t c) { - (void)c; + DisplayLogger::log({DrawCall::Type::TextColor, 0, 0, 0, 0, (int16_t)c, 0, 0, ""}); } void Adafruit_SSD1306::setCursor(int16_t x, int16_t y) { - (void)x; - (void)y; + DisplayLogger::log({DrawCall::Type::Cursor, x, y, 0, 0, 0, 0, 0, ""}); } -void Adafruit_SSD1306::print(const String &s) { - // std::cout << "OLED print: " << s.c_str() << std::endl; +// Adafruit_SSD1306 implementation +void Adafruit_SSD1306::print(const std::string &s) { + DisplayLogger::log({DrawCall::Type::Text, 0, 0, 0, 0, 0, 0, 0, s}); } void Adafruit_SSD1306::print(const char *s) { - // std::cout << "OLED print: " << s << std::endl; + if (s) DisplayLogger::log({DrawCall::Type::Text, 0, 0, 0, 0, 0, 0, 0, s}); } -void Adafruit_SSD1306::println(const String &s) { - // std::cout << "OLED println: " << s.c_str() << std::endl; +void Adafruit_SSD1306::println(const std::string &s) { + DisplayLogger::log({DrawCall::Type::Text, 0, 0, 0, 0, 0, 0, 0, s + "\n"}); } void Adafruit_SSD1306::println(const char *s) { - // std::cout << "OLED println: " << s << std::endl; + if (s) DisplayLogger::log({DrawCall::Type::Text, 0, 0, 0, 0, 0, 0, 0, std::string(s) + "\n"}); } -void Adafruit_SSD1306::getTextBounds(const String &str, int16_t x, int16_t y, int16_t *x1, int16_t *y1, uint16_t *w, uint16_t *h) { - // Mock logic to return some reasonable bounds - // Assume 6x8 chars for size 1 +void Adafruit_SSD1306::getTextBounds(const std::string &str, int16_t x, int16_t y, int16_t *x1, + int16_t *y1, uint16_t *w, uint16_t *h) { *x1 = x; *y1 = y; *w = str.length() * 6; @@ -96,47 +101,35 @@ void Adafruit_SSD1306::getTextBounds(const String &str, int16_t x, int16_t y, in // Adafruit_GFX implementations void Adafruit_GFX::drawLine(int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint16_t color) { - (void)x0; - (void)y0; - (void)x1; - (void)y1; - (void)color; - // Mock implementation + DisplayLogger::log({DrawCall::Type::Line, x0, y0, x1, y1, (int16_t)color, 0, 0, ""}); } void Adafruit_GFX::drawRect(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t color) { - (void)x; - (void)y; - (void)w; - (void)h; - (void)color; - // Mock implementation + DisplayLogger::log( + {DrawCall::Type::Rect, x, y, (int16_t)(x + w), (int16_t)(y + h), (int16_t)color, 0, 0, ""}); } void Adafruit_GFX::fillRect(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t color) { - (void)x; - (void)y; - (void)w; - (void)h; - (void)color; - // Mock implementation + DisplayLogger::log({DrawCall::Type::FillRect, x, y, (int16_t)(x + w), (int16_t)(y + h), + (int16_t)color, 0, 0, ""}); } void Adafruit_GFX::drawCircle(int16_t x0, int16_t y0, int16_t r, uint16_t color) { - (void)x0; - (void)y0; - (void)r; - (void)color; - // Mock implementation + DisplayLogger::log({DrawCall::Type::Circle, x0, y0, 0, 0, (int16_t)color, 0, (int16_t)r, ""}); } // --- GNSS --- SpNavTime SpGnss::mockTimeData = {2023, 10, 1, 12, 30, 0, 0}; float SpGnss::mockVelocityData = 5.5f; +int SpGnss::mockBeginResult = 0; +int SpGnss::mockStartResult = 0; + +extern unsigned long _mock_millis; + int SpGnss::begin() { - return 0; + return mockBeginResult; } int SpGnss::start(int mode) { (void)mode; - return 0; + return mockStartResult; } int SpGnss::stop() { return 0; @@ -146,7 +139,8 @@ void SpGnss::select(int satelliteSystem) { } bool SpGnss::waitUpdate(int timeout) { (void)timeout; - return true; + if (_mock_millis > 0 && (_mock_millis % 1000 == 0)) { return true; } + return false; } void SpGnss::getNavData(SpNavData *navData) { if (navData) { diff --git a/tests/host/mocks/RTC.h b/tests/host/mocks/RTC.h new file mode 100644 index 0000000..df7a5b2 --- /dev/null +++ b/tests/host/mocks/RTC.h @@ -0,0 +1,30 @@ +#pragma once + +class RtcTime { + int _y, _m, _d, _h, _mi, _s; + +public: + RtcTime(int y = 2024, int m = 1, int d = 1, int h = 0, int mi = 0, int s = 0) + : _y(y), _m(m), _d(d), _h(h), _mi(mi), _s(s) {} + int year() const { return _y; } + void year(int v) { _y = v; } + int month() const { return _m; } + void month(int v) { _m = v; } + int day() const { return _d; } + void day(int v) { _d = v; } + int hour() const { return _h; } + void hour(int v) { _h = v; } + int minute() const { return _mi; } + void minute(int v) { _mi = v; } + int second() const { return _s; } + void second(int v) { _s = v; } +}; + +class RtcClass { +public: + void begin() {} + void setTime(RtcTime) {} + RtcTime getTime() { return RtcTime(); } +}; + +extern RtcClass RTC; diff --git a/tests/host/mocks/Wire.h b/tests/host/mocks/Wire.h index 9596f3a..2f6fdec 100644 --- a/tests/host/mocks/Wire.h +++ b/tests/host/mocks/Wire.h @@ -5,6 +5,7 @@ class TwoWire { public: void begin(); + void setClock(uint32_t freq) {} }; extern TwoWire Wire;