diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e4cf9ec..f3a5159 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,6 +41,11 @@ jobs: - name: Install U8g2 library run: arduino-cli lib install "U8g2" + - name: Install sensor libraries + run: | + arduino-cli lib install "Adafruit AHTX0" + arduino-cli lib install "BH1750" + # コンパイル実行 (output-dir を指定して構成ファイルを確実に生成) - name: Compile Sketch run: | diff --git a/firmware/NeoPixelController.cpp b/firmware/NeoPixelController.cpp new file mode 100644 index 0000000..4e77f5f --- /dev/null +++ b/firmware/NeoPixelController.cpp @@ -0,0 +1,71 @@ +#include "NeoPixelController.h" + +#include + +namespace { +// 今回は1個のNeoPixelを使います。 +constexpr uint16_t kCount = 1; +// 音楽再生中に順番に表示する色です。値はRGBの順です。 +constexpr uint32_t kColors[] = { + 0xFF0000, 0xFF8000, 0xFFFF00, 0x00FF00, + 0x0000FF, 0x4B0082, 0x8F00FF +}; +constexpr uint8_t kColorCount = sizeof(kColors) / sizeof(kColors[0]); + +Adafruit_NeoPixel pixels(kCount, 0, NEO_GRB + NEO_KHZ800); +} + +void NeoPixelController::begin(uint8_t pin) { + // LEDの接続先と明るさを設定し、停止中の色を表示します。 + pixels.setPin(pin); + pixels.begin(); + pixels.setBrightness(255); + pixels.setPixelColor(0, kColors[kStoppedColorIndex]); + pixels.show(); +} + +void NeoPixelController::setPlaying(bool playing) { + isPlaying_ = playing; + + if (isPlaying_) { + // 再生開始時刻を記録します。ここから色の変化を始めます。 + transitionStart_ = millis(); + lastUpdate_ = transitionStart_; + return; + } + + // 停止中は、決めておいた色に戻します。 + colorIndex_ = kStoppedColorIndex; + pixels.setPixelColor(0, kColors[colorIndex_]); + pixels.show(); +} + +void NeoPixelController::update() { + // 再生中でない、または更新間隔が短すぎるときは何もしません。 + if (!isPlaying_ || millis() - lastUpdate_ < kUpdateIntervalMs) { + return; + } + + unsigned long now = millis(); + lastUpdate_ = now; + unsigned long transitionElapsed = now - transitionStart_; + if (transitionElapsed >= kTransitionMs) { + // 色の切り替えが終わったら、次の色へ進みます。 + colorIndex_ = (colorIndex_ + transitionElapsed / kTransitionMs) % kColorCount; + transitionStart_ = now; + transitionElapsed = 0; + } + + uint8_t nextColorIndex = (colorIndex_ + 1) % kColorCount; + uint32_t currentColor = kColors[colorIndex_]; + uint32_t nextColor = kColors[nextColorIndex]; + // 現在の色と次の色を混ぜることで、急に変わらず滑らかに変化させます。 + uint8_t red = ((currentColor >> 16) * (kTransitionMs - transitionElapsed) + + (nextColor >> 16) * transitionElapsed) / kTransitionMs; + uint8_t green = (((currentColor >> 8) & 0xFF) * (kTransitionMs - transitionElapsed) + + ((nextColor >> 8) & 0xFF) * transitionElapsed) / kTransitionMs; + uint8_t blue = ((currentColor & 0xFF) * (kTransitionMs - transitionElapsed) + + (nextColor & 0xFF) * transitionElapsed) / kTransitionMs; + pixels.setPixelColor(0, red, green, blue); + pixels.show(); +} \ No newline at end of file diff --git a/firmware/NeoPixelController.h b/firmware/NeoPixelController.h new file mode 100644 index 0000000..29b4b20 --- /dev/null +++ b/firmware/NeoPixelController.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +class NeoPixelController { +public: + // NeoPixelを使い始める準備をします。 + void begin(uint8_t pin); + // 音楽再生中かどうかを設定します。 + void setPlaying(bool playing); + // 時間の経過に合わせてLEDを更新します。 + void update(); + +private: + // 音楽停止中に表示する色(配列の4番目、緑)です。 + static constexpr uint8_t kStoppedColorIndex = 3; + // 色を計算する間隔です。短いほどなめらかになります。 + static constexpr unsigned long kUpdateIntervalMs = 30; + // 1色から次の色へ変わる時間です。 + static constexpr unsigned long kTransitionMs = 3000; + + // 現在のLEDの状態を保存します。 + bool isPlaying_ = false; + uint8_t colorIndex_ = 0; + unsigned long lastUpdate_ = 0; + unsigned long transitionStart_ = 0; +}; \ No newline at end of file diff --git a/firmware/SensorLogger.cpp b/firmware/SensorLogger.cpp new file mode 100644 index 0000000..ee9f35d --- /dev/null +++ b/firmware/SensorLogger.cpp @@ -0,0 +1,386 @@ +#include "SensorLogger.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +// BLEサービスと、その中の2本の通信路を識別する番号です。 +// UUIDは、Bluetooth上で機能を区別するための長い名前です。 +constexpr char kNusServiceUuid[] = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"; +constexpr char kNusRxUuid[] = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E"; +constexpr char kNusTxUuid[] = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"; +constexpr char kBleDeviceNamePrefix[] = "EnvLog"; +constexpr int64_t kInitialYear = 1700; +constexpr int64_t kSecondsPerDay = 86400; + +// センサー本体と、センサーが使える状態かどうかを保存します。 +Adafruit_AHTX0 aht20; +BH1750 bh1750; +bool aht20Ready = false; +bool bh1750Ready = false; + +bool isLeapYear(int year) { + // 4年ごと。ただし100年ごとは除き、400年ごとはうるう年です。 + return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; +} +} + +// BLEの受信側の処理です。アプリがデータを書き込むと呼ばれます。 +class SensorLoggerCallbacks : public BLECharacteristicCallbacks { +public: + explicit SensorLoggerCallbacks(SensorLogger* logger) : logger_(logger) {} + + void onWrite(BLECharacteristic* characteristic) override { + // 受信した文字をSensorLoggerへ渡します。 + logger_->receiveBluetoothData(characteristic->getValue().c_str()); + } + +private: + SensorLogger* logger_; +}; + +// BLEの接続と切断を知らせる処理です。 +class SensorLoggerServerCallbacks : public BLEServerCallbacks { +public: + explicit SensorLoggerServerCallbacks(SensorLogger* logger) : logger_(logger) {} + + void onConnect(BLEServer*) override { + // 接続中は、画面に接続中であることを表示できます。 + logger_->bluetoothConnected_ = true; + } + + void onDisconnect(BLEServer* server) override { + // 切断されたら、次の接続を待つために広告を再開します。 + logger_->bluetoothConnected_ = false; + logger_->commandBuffer_ = ""; + logger_->lastAdvertise_ = millis(); + server->startAdvertising(); + } + +private: + SensorLogger* logger_; +}; + +void SensorLogger::begin(uint8_t soilPin) { + // センサーとBLE通信を使い始めるための初期設定です。 + soilPin_ = soilPin; + pinMode(soilPin_, INPUT); + analogReadResolution(12); + + // センサーの初期化に成功したかを保存します。 + aht20Ready = aht20.begin(); + bh1750Ready = bh1750.begin(BH1750::CONTINUOUS_HIGH_RES_MODE); + + // ESP32固有の番号を名前に加え、同じ名前の機器と区別します。 + uint64_t mac = ESP.getEfuseMac(); + char name[14]; + snprintf(name, sizeof(name), "%s-%05llX", kBleDeviceNamePrefix, mac & 0xFFFFF); + bluetoothName_ = name; + BLEDevice::init(bluetoothName_.c_str()); + + // BLEサーバーと、センサー通信用のサービスを作ります。 + server_ = BLEDevice::createServer(); + server_->setCallbacks(new SensorLoggerServerCallbacks(this)); + BLEService* service = server_->createService(kNusServiceUuid); + // RXはアプリからESP32へ命令を送る通路です。 + BLECharacteristic* rxCharacteristic = service->createCharacteristic( + kNusRxUuid, BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR); + rxCharacteristic->setCallbacks(new SensorLoggerCallbacks(this)); + // TXはESP32からアプリへログを送る通路です。 + txCharacteristic_ = service->createCharacteristic( + kNusTxUuid, BLECharacteristic::PROPERTY_NOTIFY); + txCharacteristic_->addDescriptor(new BLE2902()); + service->start(); + + // BLE広告は「この機器が近くにいます」と周囲へ知らせる仕組みです。 + BLEAdvertising* advertising = BLEDevice::getAdvertising(); + advertising->addServiceUUID(kNusServiceUuid); + advertising->setScanResponse(true); + // A2DP開始後に共通のBluetooth名が変わっても、BLE名を返せるようにします。 + BLEAdvertisementData scanResponseData; + scanResponseData.setName(bluetoothName_.c_str()); + advertising->setScanResponseData(scanResponseData); + advertising->setMinPreferred(0x06); + advertising->setMinPreferred(0x12); + BLEDevice::startAdvertising(); + + // 起動直後にも1回計測して、画面に値を表示できるようにします。 + readAndStore(); + lastMeasure_ = millis(); + lastAdvertise_ = millis(); +} + +bool SensorLogger::update() { + // loopから繰り返し呼ばれ、通信と定期計測を担当します。 + processBluetooth(); + + unsigned long now = millis(); + if (!bluetoothConnected_ && now - lastAdvertise_ >= kAdvertiseRetryIntervalMs) { + // 接続されていなければ、定期的にBLE広告を出し直します。 + lastAdvertise_ = now; + BLEDevice::startAdvertising(); + } + + if (now - lastMeasure_ >= kMeasureIntervalMs) { + // 計測間隔を過ぎたら、新しいログを保存します。 + lastMeasure_ = now; + readAndStore(); + return true; + } + return false; +} + +void SensorLogger::refreshSensors() { + // センサーを読み直します。ログには保存せず、画面表示だけを更新します。 + SensorReading reading = {}; + sensors_event_t humidityEvent; + sensors_event_t temperatureEvent; + + if (aht20Ready) { + aht20.getEvent(&humidityEvent, &temperatureEvent); + reading.temperature = temperatureEvent.temperature; + reading.humidity = humidityEvent.relative_humidity; + } + if (bh1750Ready) { + reading.illuminance = bh1750.readLightLevel(); + } + reading.soilMoisture = analogRead(soilPin_); + reading.timestamp = latest_.timestamp; + latest_ = reading; +} + +const SensorReading& SensorLogger::latest() const { + return latest_; +} + +bool SensorLogger::hasReading() const { + return logCount_ != 0; +} + +const char* SensorLogger::bluetoothName() const { + return bluetoothName_.c_str(); +} + +bool SensorLogger::isBluetoothConnected() const { + return bluetoothConnected_; +} + +size_t SensorLogger::logCount() const { + return logCount_; +} + +bool SensorLogger::formatTimestamp(int64_t timestamp, char* buffer, size_t bufferSize) const { + // 秒数を、人が読みやすい「年/月/日 時:分:秒」に変換します。 + if (bufferSize == 0) { + return false; + } + + if (timeSynchronized_) { + // 時刻合わせ済みなら、ESP32の標準時刻変換を使います。 + time_t unixTimestamp = static_cast(timestamp); + struct tm date; + localtime_r(&unixTimestamp, &date); + strftime(buffer, bufferSize, "%Y/%m/%d %H:%M:%S", &date); + return true; + } + + // 時刻合わせ前は、起動してからの経過秒数を仮の日付として表示します。 + int year = static_cast(kInitialYear); + int month = 1; + int day = 1; + int64_t days = timestamp / kSecondsPerDay; + int seconds = static_cast(timestamp % kSecondsPerDay); + while (days >= (isLeapYear(year) ? 366 : 365)) { + days -= isLeapYear(year) ? 366 : 365; + ++year; + } + const int daysInMonth[] = { + 31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, + 31, 31, 30, 31, 30, 31 + }; + while (days >= daysInMonth[month - 1]) { + days -= daysInMonth[month - 1]; + ++month; + } + day += static_cast(days); + snprintf(buffer, bufferSize, "%04d/%02d/%02d %02d:%02d:%02d", + year, month, day, seconds / 3600, (seconds / 60) % 60, seconds % 60); + return true; +} + +bool SensorLogger::getCurrentTime(char* buffer, size_t bufferSize) const { + return formatTimestamp(currentTimestamp(), buffer, bufferSize); +} + +void SensorLogger::readAndStore() { + // センサーを読み取り、最新値とログの両方へ保存します。 + sensors_event_t humidityEvent; + sensors_event_t temperatureEvent; + SensorReading reading = {}; + + if (aht20Ready) { + aht20.getEvent(&humidityEvent, &temperatureEvent); + reading.temperature = temperatureEvent.temperature; + reading.humidity = humidityEvent.relative_humidity; + } + if (bh1750Ready) { + reading.illuminance = bh1750.readLightLevel(); + } + reading.soilMoisture = analogRead(soilPin_); + reading.timestamp = currentTimestamp(); + latest_ = reading; + + // 配列が満杯になったら、一番古いデータから上書きします。 + size_t writeIndex = (logStart_ + logCount_) % kMaxLogEntries; + if (logCount_ == kMaxLogEntries) { + log_[logStart_] = reading; + logStart_ = (logStart_ + 1) % kMaxLogEntries; + } else { + log_[writeIndex] = reading; + ++logCount_; + } +} + +void SensorLogger::processBluetooth() { + // 受信した文字を改行ごとの命令に分けて実行します。 + while (commandBuffer_.indexOf('\n') >= 0 || commandBuffer_.indexOf('\r') >= 0) { + int lineEnd = commandBuffer_.indexOf('\n'); + int carriageReturn = commandBuffer_.indexOf('\r'); + if (lineEnd < 0 || (carriageReturn >= 0 && carriageReturn < lineEnd)) { + lineEnd = carriageReturn; + } + String command = commandBuffer_.substring(0, lineEnd); + commandBuffer_.remove(0, lineEnd + 1); + if (command.length() != 0) { + processCommand(command); + } + } +} + +void SensorLogger::receiveBluetoothData(const String& data) { + // BLEで届いた文字を命令バッファへ追加します。 + for (size_t index = 0; index < data.length(); ++index) { + char character = data[index]; + if (character == '\n' || character == '\r') { + commandBuffer_ += character; + } else if (commandBuffer_.length() < 64) { + commandBuffer_ += character; + } + } +} + +void SensorLogger::applyHistoricalTimeAdjustment(int64_t epoch) { + // 時刻合わせ前に保存したログへ、正しい時刻との差を加えます。 + if (historicalAdjustmentApplied_ || timeSynchronized_) { + return; + } + + const int64_t elapsedSeconds = millis() / 1000UL; + historicalEpochOffset_ = epoch - elapsedSeconds; + for (size_t index = 0; index < logCount_; ++index) { + size_t logIndex = (logStart_ + index) % kMaxLogEntries; + const int64_t storedSeconds = log_[logIndex].timestamp; + if (storedSeconds <= elapsedSeconds) { + log_[logIndex].timestamp = storedSeconds + historicalEpochOffset_; + } + } + historicalAdjustmentApplied_ = true; +} + +void SensorLogger::processCommand(const String& command) { + // アプリから届いた命令を判定します。 + if (command == "GET_LOG") { + // 保存済みのログを順番に送ります。 + sendLog(); + } else if (command == "CLEAR_LOG") { + // ログを空にして、完了通知を送ります。 + clearLog(); + txCharacteristic_->setValue("OK_CLEARED\n"); + txCharacteristic_->notify(); + } else if (command.startsWith("TIME:")) { + // アプリから受け取った時刻で、時計を合わせます。 + unsigned long epoch = command.substring(5).toInt(); + unsigned int year; + unsigned int month; + unsigned int day; + unsigned int hour; + unsigned int minute; + unsigned int second; + if (sscanf(command.c_str(), "TIME:[%u,%u,%u,%u,%u,%u]", + &year, &month, &day, &hour, &minute, &second) == 6) { + struct tm date = {}; + date.tm_year = year - 1900; + date.tm_mon = month - 1; + date.tm_mday = day; + date.tm_hour = hour; + date.tm_min = minute; + date.tm_sec = second; + epoch = static_cast(mktime(&date)); + } + if (epoch != 0) { + applyHistoricalTimeAdjustment(static_cast(epoch)); + epochOffset_ = epoch - millis() / 1000UL; + timeSynchronized_ = true; + txCharacteristic_->setValue("OK_TIME\n"); + txCharacteristic_->notify(); + } else { + txCharacteristic_->setValue("ERROR_TIME\n"); + txCharacteristic_->notify(); + } + } +} + +void SensorLogger::sendLog() { + // 保存したログを1行ずつ作り、BLE通知で送ります。 + for (size_t index = 0; index < logCount_; ++index) { + size_t logIndex = (logStart_ + index) % kMaxLogEntries; + const SensorReading& reading = log_[logIndex]; + char timestamp[32]; + char line[160]; + const bool timestampValid = formatTimestamp(reading.timestamp, timestamp, sizeof(timestamp)); + if (!timestampValid) { + timestamp[0] = '\0'; + } + snprintf(line, sizeof(line), "%s,%.2f,%.2f,%u,%.2f,%s\n", + timestamp, + reading.temperature, + reading.humidity, + reading.soilMoisture, + reading.illuminance, + bluetoothName_.c_str()); + sendNotification(line); + delay(20); + } + sendNotification("END_LOG\n"); +} + +void SensorLogger::sendNotification(const char* data) { + // BLE通知には一度に送れる長さの制限があるため、短く分割します。 + constexpr size_t kNotificationSize = 20; + uint8_t notification[kNotificationSize]; + size_t length = strlen(data); + for (size_t offset = 0; offset < length; offset += kNotificationSize) { + size_t chunkSize = min(kNotificationSize, length - offset); + memcpy(notification, data + offset, chunkSize); + txCharacteristic_->setValue(notification, chunkSize); + txCharacteristic_->notify(); + delay(10); + } +} + +void SensorLogger::clearLog() { + // 配列の中身を消す代わりに、件数を0にして空として扱います。 + logStart_ = 0; + logCount_ = 0; +} + +int64_t SensorLogger::currentTimestamp() const { + // 現在の経過秒数に時刻の差を足して、現在時刻を返します。 + int64_t elapsedSeconds = millis() / 1000UL; + return timeSynchronized_ ? epochOffset_ + elapsedSeconds : elapsedSeconds; +} diff --git a/firmware/SensorLogger.h b/firmware/SensorLogger.h new file mode 100644 index 0000000..b37274b --- /dev/null +++ b/firmware/SensorLogger.h @@ -0,0 +1,77 @@ +#pragma once + +#include + +class BLECharacteristic; +class BLEServer; + +struct SensorReading { + int64_t timestamp; // 計測した時刻(秒) + float temperature; // 温度(℃) + float humidity; // 湿度(%) + float illuminance; // 明るさ(lux) + uint16_t soilMoisture; // 土壌センサーの値 +}; + +class SensorLogger { +public: + static constexpr size_t kMaxLogEntries = 2000; + + // センサー、BLE、保存データを使い始める準備をします。 + void begin(uint8_t soilPin); + // BLEの受信と、一定時間ごとの計測を行います。 + bool update(); + // センサーを読み取り、画面表示用の最新値を更新します。 + void refreshSensors(); + // 最後に読み取ったセンサー値を返します。 + const SensorReading& latest() const; + // 1件以上のログが保存されているかを返します。 + bool hasReading() const; + // 保存されているログの件数を返します。 + size_t logCount() const; + // 現在時刻を文字列にしてbufferへ書き込みます。 + bool getCurrentTime(char* buffer, size_t bufferSize) const; + // 秒数を年月日と時刻の文字列に変換します。 + bool formatTimestamp(int64_t timestamp, char* buffer, size_t bufferSize) const; + // BLEで表示する名前を返します。 + const char* bluetoothName() const; + // BLE機器が接続中かを返します。 + bool isBluetoothConnected() const; + +private: + // センサーを読み取る間隔(現在は1時間)です。 + static constexpr unsigned long kMeasureIntervalMs = 3600000UL; + // BLE接続がないとき、広告を再開する間隔です。 + static constexpr unsigned long kAdvertiseRetryIntervalMs = 500UL; + + // 内部処理用の関数です。 + void readAndStore(); + void processBluetooth(); + void processCommand(const String& command); + void receiveBluetoothData(const String& data); + void sendNotification(const char* data); + void sendLog(); + void clearLog(); + void applyHistoricalTimeAdjustment(int64_t epoch); + int64_t currentTimestamp() const; + + BLEServer* server_ = nullptr; // BLEサーバー本体 + BLECharacteristic* txCharacteristic_ = nullptr; // アプリへ通知する通路 + SensorReading log_[kMaxLogEntries] = {}; // 最大2000件の計測ログ + SensorReading latest_ = {}; // 最新の計測値 + size_t logStart_ = 0; // 一番古いログの位置 + size_t logCount_ = 0; // 現在のログ件数 + uint8_t soilPin_ = 0; // 土壌センサーを接続したGPIO番号 + unsigned long lastMeasure_ = 0; // 最後に計測した時刻 + unsigned long lastAdvertise_ = 0; // 最後にBLE広告を開始した時刻 + int64_t epochOffset_ = 0; // 現在時刻を合わせるための差 + int64_t historicalEpochOffset_ = 0; // 過去ログに加える時刻の差 + bool timeSynchronized_ = false; // 時刻合わせが済んだか + bool historicalAdjustmentApplied_ = false; // 過去ログの補正が済んだか + bool bluetoothConnected_ = false; // BLE接続中か + String bluetoothName_; // BLEに表示する名前 + String commandBuffer_; // BLEから受け取った未処理の文字 + + friend class SensorLoggerCallbacks; + friend class SensorLoggerServerCallbacks; +}; diff --git a/firmware/firmware.ino b/firmware/firmware.ino index c82f9c3..5b0ba36 100644 --- a/firmware/firmware.ino +++ b/firmware/firmware.ino @@ -1,57 +1,169 @@ +// A2DPは、スマートフォンから音楽を受信するBluetoothの仕組みです。 #include "BluetoothA2DPSink.h" #include #include +#include "NeoPixelController.h" +#include "SensorLogger.h" -// I2C接続のOLED設定 (SSD1306 128x64) +// スマートフォンのBluetooth一覧に表示されるスピーカー名です。 +const char kA2dpDeviceName[] = "BT_Speaker5.5"; + +// I2Cで接続した128x64ドットのOLED画面です。 U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE); +// このプログラムで使う機能の担当者を作ります。 BluetoothA2DPSink a2dp_sink; +NeoPixelController neoPixelController; +SensorLogger sensorLogger; -// 表示用変数 +// 音楽情報と画面更新に使う変数です。 String currentTitle = "未接続"; String currentArtist = ""; -int currentVolumePercent = 0; // 音量 (0 ~ 100%) +int currentVolumePercent = 0; // 音量(0〜100%) +bool bluetoothConnected = false; +bool musicPlaying = false; +unsigned long lastDisplayUpdate = 0; -// 画面全体を再描画する関数 +// UTF-8の文字列を1文字ずつ描画します。 +// フォントにない文字は、フォントに頼らず四角形を直接描きます。 +void drawUtf8WithTofu(uint8_t x, uint8_t y, const String& text) { + constexpr uint8_t kTofuSize = 12; + constexpr uint8_t kTofuAdvance = 16; + uint8_t cursorX = x; + + for (size_t index = 0; index < text.length();) { + const uint8_t firstByte = static_cast(text[index]); + size_t byteCount = 0; + uint32_t codePoint = 0; + + // UTF-8の先頭バイトから、1文字のバイト数を判定します。 + if (firstByte < 0x80) { + codePoint = firstByte; + byteCount = 1; + } else if ((firstByte & 0xE0) == 0xC0 && index + 1 < text.length()) { + codePoint = firstByte & 0x1F; + byteCount = 2; + } else if ((firstByte & 0xF0) == 0xE0 && index + 2 < text.length()) { + codePoint = firstByte & 0x0F; + byteCount = 3; + } else if ((firstByte & 0xF8) == 0xF0 && index + 3 < text.length()) { + codePoint = firstByte & 0x07; + byteCount = 4; + } else { + // 壊れたUTF-8も、表示できない文字としてトーフを描きます。 + u8g2.drawFrame(cursorX, y - kTofuSize, kTofuSize, kTofuSize); + cursorX += kTofuAdvance; + ++index; + continue; + } + + // 2バイト目以降をつなげて、Unicodeの文字コードを作ります。 + bool valid = true; + for (size_t offset = 1; offset < byteCount; ++offset) { + const uint8_t nextByte = static_cast(text[index + offset]); + if ((nextByte & 0xC0) != 0x80) { + valid = false; + break; + } + codePoint = (codePoint << 6) | (nextByte & 0x3F); + } + + if (!valid) { + u8g2.drawFrame(cursorX, y - kTofuSize, kTofuSize, kTofuSize); + cursorX += kTofuAdvance; + ++index; + continue; + } + + // フォントに文字があれば、実際のグリフを描画します。 + // 16ビットを超える文字は、このフォントでは表示できないためトーフにします。 + if (codePoint <= 0xFFFF && u8g2_IsGlyph(u8g2.getU8g2(), codePoint)) { + u8g2.drawGlyph(cursorX, y, static_cast(codePoint)); + cursorX += static_cast(u8g2_GetGlyphWidth( + u8g2.getU8g2(), static_cast(codePoint))); + } else { + // □の字形がフォントにない場合でも、四角形なら必ず表示できます。 + u8g2.drawFrame(cursorX, y - kTofuSize, kTofuSize, kTofuSize); + cursorX += kTofuAdvance; + } + + index += byteCount; + } +} + +// OLEDの内容を現在の状態に合わせて描き直します。 void updateDisplay() { u8g2.clearBuffer(); + + // 音楽を再生していないときは、センサーの情報を表示します。 + if (!bluetoothConnected || !musicPlaying) { + u8g2.setFont(u8g2_font_6x10_tf); + const SensorReading& reading = sensorLogger.latest(); + + char sensorLine[32]; + char timeLine[24]; + if (sensorLogger.isBluetoothConnected()) { + u8g2.setDrawColor(1); + u8g2.drawBox(0, 0, 128, 10); + u8g2.setDrawColor(0); + } + u8g2.drawStr(0, 9, sensorLogger.bluetoothName()); + u8g2.setDrawColor(1); + + if (sensorLogger.getCurrentTime(timeLine, sizeof(timeLine))) { + u8g2.drawStr(0, 20, timeLine); + } else { + u8g2.drawStr(0, 20, "RTC: --/--/-- --:--:--"); + } + + snprintf(sensorLine, sizeof(sensorLine), "Log: %u", static_cast(sensorLogger.logCount())); + u8g2.drawStr(0, 31, sensorLine); + + snprintf(sensorLine, sizeof(sensorLine), "T:%5.1fC H:%5.1f%%", + reading.temperature, reading.humidity); + u8g2.drawStr(0, 42, sensorLine); + + snprintf(sensorLine, sizeof(sensorLine), "Light: %6.1f lx", reading.illuminance); + u8g2.drawStr(0, 53, sensorLine); + + snprintf(sensorLine, sizeof(sensorLine), "Soil: %4u", reading.soilMoisture); + u8g2.drawStr(0, 64, sensorLine); + u8g2.sendBuffer(); + return; + } - // -------------------------------------------------- - // 1. 音量表示エリア(最上部:英数フォント) - // -------------------------------------------------- + // 音楽を再生中は、音量と曲名を表示します。 + // 上の部分は小さい英数字フォントで音量を表示します。 u8g2.setFont(u8g2_font_6x10_tf); - // "Vol: 80%" のテキスト描画 + // 例: 「Vol: 80%」という文字を作って表示します。 char volStr[16]; snprintf(volStr, sizeof(volStr), "Vol: %3d%%", currentVolumePercent); u8g2.drawStr(0, 10, volStr); - // 音量バーの描画 - int barWidth = map(currentVolumePercent, 0, 100, 0, 50); // 50ピクセル幅に変換 - u8g2.drawFrame(75, 2, 52, 9); // 外枠 - u8g2.drawBox(76, 3, barWidth, 7); // バーの中身 + // 音量の割合を、0〜50ドットの長さに変換して棒グラフにします。 + int barWidth = map(currentVolumePercent, 0, 100, 0, 50); + u8g2.drawFrame(75, 2, 52, 9); // 棒グラフの外枠 + u8g2.drawBox(76, 3, barWidth, 7); // 音量に応じた中身 u8g2.drawLine(0, 13, 128, 13); // 区切り線 - // -------------------------------------------------- - // 2. 曲名・アーティスト表示エリア(日本語フォント) - // -------------------------------------------------- - // 日本語フォントに切り替え (美咲フォント UTF-8) - u8g2.setFont(u8g2_font_unifont_t_japanese1); + // 下の部分は、日本語を含む曲名とアーティスト名を表示します。 + u8g2.setFont(u8g2_font_unifont_t_japanese1); - // 曲名の表示 (y=32) - u8g2.drawUTF8(0, 32, currentTitle.c_str()); + // フォントにない文字は、四角形のトーフとして表示します。 + drawUtf8WithTofu(0, 32, currentTitle); - // アーティスト名の表示 (y=54) + // アーティスト名が空でないときだけ表示します。 if (currentArtist.length() > 0) { - // アーティスト名も日本語フォントのまま描画します - u8g2.drawUTF8(0, 54, currentArtist.c_str()); + drawUtf8WithTofu(0, 54, currentArtist); } - u8g2.sendBuffer(); // 画面転送 + // ここまでの描画内容を、実際のOLED画面へ送ります。 + u8g2.sendBuffer(); } -// メタデータ(曲名・アーティスト名)の受信コールバック +// スマートフォンから曲名やアーティスト名を受け取ったときに呼ばれます。 void avrc_metadata_callback(uint8_t id, const uint8_t *text) { bool updated = false; @@ -63,37 +175,54 @@ void avrc_metadata_callback(uint8_t id, const uint8_t *text) { updated = true; } - if (updated) { + if (updated && bluetoothConnected && musicPlaying) { updateDisplay(); } } -// 音量変更時のコールバック関数 +// スマートフォン側で音量が変わったときに呼ばれます。 void volume_changed_callback(int volume) { currentVolumePercent = map(volume, 0, 127, 0, 100); + if (bluetoothConnected && musicPlaying) { + updateDisplay(); + } +} + +void audio_state_changed_callback(esp_a2d_audio_state_t state, void *) { + // 音声が流れ始めたかどうかを保存し、LEDと画面を更新します。 + musicPlaying = state == ESP_A2D_AUDIO_STATE_STARTED; + neoPixelController.setPlaying(musicPlaying); + updateDisplay(); +} + +void connection_state_changed_callback(esp_a2d_connection_state_t state, void *) { + // スマートフォンとの接続状態を保存します。 + bluetoothConnected = state == ESP_A2D_CONNECTION_STATE_CONNECTED; + if (!bluetoothConnected) { + musicPlaying = false; + } updateDisplay(); } void setup() { + // setupは、電源を入れた直後に1回だけ実行されます。 Serial.begin(115200); - // SDAをGPIO 19、SCLをGPIO 32 に設定 - // (配線に合わせて Wire.begin(SDA_PIN, SCL_PIN) の順で指定します) - Wire.begin(19, 5); + neoPixelController.begin(18); + + // I2Cの配線を設定します。Wire.begin(SDA, SCL)の順です。 + Wire.begin(21, 22); - // OLED初期化 + // OLEDを使える状態にします。 u8g2.begin(); u8g2.enableUTF8Print(); // UTF-8(日本語)描画を有効化 - // 初期画面 - u8g2.clearBuffer(); - u8g2.setFont(u8g2_font_unifont_t_japanese1); - u8g2.drawUTF8(0, 20, "BTスピーカー5.2"); - u8g2.setFont(u8g2_font_6x10_tf); - u8g2.drawStr(0, 40, "Ready..."); - u8g2.sendBuffer(); + sensorLogger.begin(32); + updateDisplay(); + lastDisplayUpdate = millis(); - // I2S ピン設定 (BCK: 27, WS: 25, DOUT: 26) + // I2Sは、Bluetooth音声をアンプへ送るための通信方式です。 + // BCK=27、WS=25、音声データ出力=DOUT=26に配線しています。 i2s_pin_config_t my_pin_config = { .bck_io_num = 27, .ws_io_num = 25, @@ -103,13 +232,25 @@ void setup() { a2dp_sink.set_pin_config(my_pin_config); - // コールバック登録 + // Bluetoothでイベントが起きたときの通知先を登録します。 a2dp_sink.set_avrc_metadata_callback(avrc_metadata_callback); a2dp_sink.set_on_volumechange(volume_changed_callback); + a2dp_sink.set_on_audio_state_changed(audio_state_changed_callback); + a2dp_sink.set_on_connection_state_changed(connection_state_changed_callback); - // Bluetooth起動 - a2dp_sink.start("BT_Speaker5.2"); + // Bluetoothスピーカーとして起動します。 + a2dp_sink.start(kA2dpDeviceName); } void loop() { + // loopは何度も繰り返し実行されます。 + neoPixelController.update(); + bool newReading = sensorLogger.update(); + unsigned long now = millis(); + if ((!bluetoothConnected || !musicPlaying) + && (newReading || now - lastDisplayUpdate >= 1000UL)) { + lastDisplayUpdate = now; + sensorLogger.refreshSensors(); + updateDisplay(); + } } \ No newline at end of file