Compare commits

..

10 Commits

Author SHA1 Message Date
Koichi Ito 2e3cd4220a ハード未接続に対応 2026-10-09 16:07:24 +09:00
Koichi Ito b8eb7bafe8 SDA/SCLをプルアップ 2026-09-27 14:52:41 +09:00
Koichi Ito 8bc573069e 警告エラーをシリアルに出力 2026-09-27 14:28:24 +09:00
Koichi Ito d6105a8f9a BT_Speaker5.6 2026-09-27 13:43:52 +09:00
Koichi Ito 86c2ab9060 Wire.begin(21, 22) 2026-09-22 17:24:14 +09:00
Koichi Ito d0131a479b センサーが無くとも動作 2026-09-22 17:21:01 +09:00
Koichi Ito 37eaa5d112 I2Cが無くとも動作 2026-09-22 15:54:08 +09:00
Koichi Ito 74bbba5168 support 開発ボード I2C(19,5) 2026-09-22 15:42:26 +09:00
Koichi Ito 076cb99f9e update README.md 2026-09-21 10:43:18 +09:00
Koichi Ito 9f776a3a9f build.yml 修正 2026-09-21 09:18:00 +09:00
5 changed files with 168 additions and 49 deletions

View File

@ -45,6 +45,7 @@ jobs:
run: | run: |
arduino-cli lib install "Adafruit AHTX0" arduino-cli lib install "Adafruit AHTX0"
arduino-cli lib install "BH1750" arduino-cli lib install "BH1750"
arduino-cli lib install "Adafruit NeoPixel"
# コンパイル実行 (output-dir を指定して構成ファイルを確実に生成) # コンパイル実行 (output-dir を指定して構成ファイルを確実に生成)
- name: Compile Sketch - name: Compile Sketch

View File

@ -1,6 +1,14 @@
# arduino_esp32_a2dp # arduino_esp32_a2dp
ESP32-WROOM-32E を使用した、Arduino版 A2DP Bluetoothスピーカーのファームウェアリポジトリです。GitHub Actions を利用して、ブラウザ上だけでファームウェアの書き換えとビルドが行えます。 ESP32-WROOM-32E を使用した、Arduino版 A2DP Bluetoothスピーカーのファームウェアリポジトリです。
GitHub Actions を利用して、ブラウザ上だけでファームウェアの書き換えとビルドが行えます。
ESP32-WROOM-32E 用の Bluetooth スピーカー主な機能:
- NeoPixcel:デバイスの動作モードに合わせて発光色を変化
- 音楽再生時: 楽曲名、歌手名、音量を表示
- 音楽停止時: 各種センサー値(温度・湿度・照度・土壌水分)を表示
- 環境データ出力: 1時間ごとにデータを蓄積し、Webブラウザから Bluetooth 経由でデータをダウンロード(CSV形式で保存可能)
## 手順 ## 手順

View File

@ -6,6 +6,7 @@
#include <BLEDevice.h> #include <BLEDevice.h>
#include <BLEServer.h> #include <BLEServer.h>
#include <BLEUtils.h> #include <BLEUtils.h>
#include <Wire.h>
#include <time.h> #include <time.h>
namespace { namespace {
@ -24,6 +25,11 @@ BH1750 bh1750;
bool aht20Ready = false; bool aht20Ready = false;
bool bh1750Ready = false; bool bh1750Ready = false;
bool isI2cDevicePresent(uint8_t address) {
Wire.beginTransmission(address);
return Wire.endTransmission() == 0;
}
bool isLeapYear(int year) { bool isLeapYear(int year) {
// 4年ごと。ただし100年ごとは除き、400年ごとはうるう年です。 // 4年ごと。ただし100年ごとは除き、400年ごとはうるう年です。
return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
@ -52,6 +58,7 @@ public:
void onConnect(BLEServer*) override { void onConnect(BLEServer*) override {
// 接続中は、画面に接続中であることを表示できます。 // 接続中は、画面に接続中であることを表示できます。
logger_->bluetoothConnected_ = true; logger_->bluetoothConnected_ = true;
Serial.println("[INFO] BLE client connected");
} }
void onDisconnect(BLEServer* server) override { void onDisconnect(BLEServer* server) override {
@ -60,54 +67,77 @@ public:
logger_->commandBuffer_ = ""; logger_->commandBuffer_ = "";
logger_->lastAdvertise_ = millis(); logger_->lastAdvertise_ = millis();
server->startAdvertising(); server->startAdvertising();
Serial.println("[WARNING] BLE client disconnected; advertising restarted");
} }
private: private:
SensorLogger* logger_; SensorLogger* logger_;
}; };
void SensorLogger::begin(uint8_t soilPin) { void SensorLogger::begin(uint8_t soilPin, bool i2cConnected, bool bleDataConnected) {
// センサーとBLE通信を使い始めるための初期設定です。 // センサーとBLE通信を使い始めるための初期設定です。
soilPin_ = soilPin; soilPin_ = soilPin;
bleDataConnected_ = bleDataConnected;
pinMode(soilPin_, INPUT); pinMode(soilPin_, INPUT);
analogReadResolution(12); analogReadResolution(12);
// センサーの初期化に成功したかを保存します。 // センサーが接続されているときだけ初期化します。
aht20Ready = aht20.begin(); // 未接続時にライブラリへアクセスすると、不要なI2Cエラーや待ち時間が発生します。
bh1750Ready = bh1750.begin(BH1750::CONTINUOUS_HIGH_RES_MODE); if (i2cConnected) {
if (isI2cDevicePresent(0x38)) {
aht20Ready = aht20.begin();
if (!aht20Ready) {
Serial.println("[ERROR] AHT20 initialization failed");
}
} else {
Serial.println("[WARNING] AHT20 not detected at 0x38");
}
if (isI2cDevicePresent(0x23)) {
bh1750Ready = bh1750.begin(BH1750::CONTINUOUS_HIGH_RES_MODE);
if (!bh1750Ready) {
Serial.println("[ERROR] BH1750 initialization failed");
}
} else {
Serial.println("[WARNING] BH1750 not detected at 0x23");
}
}
// ESP32固有の番号を名前に加え、同じ名前の機器と区別します。 if (bleDataConnected_) {
uint64_t mac = ESP.getEfuseMac(); // ESP32固有の番号を名前に加え、同じ名前の機器と区別します。
char name[14]; uint64_t mac = ESP.getEfuseMac();
snprintf(name, sizeof(name), "%s-%05llX", kBleDeviceNamePrefix, mac & 0xFFFFF); char name[14];
bluetoothName_ = name; snprintf(name, sizeof(name), "%s-%05llX", kBleDeviceNamePrefix, mac & 0xFFFFF);
BLEDevice::init(bluetoothName_.c_str()); bluetoothName_ = name;
BLEDevice::init(bluetoothName_.c_str());
Serial.printf("[INFO] BLE initialized as '%s'\n", bluetoothName_.c_str());
// BLEサーバーと、センサー通信用のサービスを作ります。 // BLEサーバーと、センサー通信用のサービスを作ります。
server_ = BLEDevice::createServer(); server_ = BLEDevice::createServer();
server_->setCallbacks(new SensorLoggerServerCallbacks(this)); server_->setCallbacks(new SensorLoggerServerCallbacks(this));
BLEService* service = server_->createService(kNusServiceUuid); BLEService* service = server_->createService(kNusServiceUuid);
// RXはアプリからESP32へ命令を送る通路です。 // RXはアプリからESP32へ命令を送る通路です。
BLECharacteristic* rxCharacteristic = service->createCharacteristic( BLECharacteristic* rxCharacteristic = service->createCharacteristic(
kNusRxUuid, BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR); kNusRxUuid, BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR);
rxCharacteristic->setCallbacks(new SensorLoggerCallbacks(this)); rxCharacteristic->setCallbacks(new SensorLoggerCallbacks(this));
// TXはESP32からアプリへログを送る通路です。 // TXはESP32からアプリへログを送る通路です。
txCharacteristic_ = service->createCharacteristic( txCharacteristic_ = service->createCharacteristic(
kNusTxUuid, BLECharacteristic::PROPERTY_NOTIFY); kNusTxUuid, BLECharacteristic::PROPERTY_NOTIFY);
txCharacteristic_->addDescriptor(new BLE2902()); txCharacteristic_->addDescriptor(new BLE2902());
service->start(); service->start();
// BLE広告は「この機器が近くにいます」と周囲へ知らせる仕組みです。 // BLE広告は「この機器が近くにいます」と周囲へ知らせる仕組みです。
BLEAdvertising* advertising = BLEDevice::getAdvertising(); BLEAdvertising* advertising = BLEDevice::getAdvertising();
advertising->addServiceUUID(kNusServiceUuid); advertising->addServiceUUID(kNusServiceUuid);
advertising->setScanResponse(true); advertising->setScanResponse(true);
// A2DP開始後に共通のBluetooth名が変わっても、BLE名を返せるようにします。 // A2DP開始後に共通のBluetooth名が変わっても、BLE名を返せるようにします。
BLEAdvertisementData scanResponseData; BLEAdvertisementData scanResponseData;
scanResponseData.setName(bluetoothName_.c_str()); scanResponseData.setName(bluetoothName_.c_str());
advertising->setScanResponseData(scanResponseData); advertising->setScanResponseData(scanResponseData);
advertising->setMinPreferred(0x06); advertising->setMinPreferred(0x06);
advertising->setMinPreferred(0x12); advertising->setMinPreferred(0x12);
BLEDevice::startAdvertising(); BLEDevice::startAdvertising();
Serial.println("[INFO] BLE advertising started");
}
// 起動直後にも1回計測して、画面に値を表示できるようにします。 // 起動直後にも1回計測して、画面に値を表示できるようにします。
readAndStore(); readAndStore();
@ -117,10 +147,13 @@ void SensorLogger::begin(uint8_t soilPin) {
bool SensorLogger::update() { bool SensorLogger::update() {
// loopから繰り返し呼ばれ、通信と定期計測を担当します。 // loopから繰り返し呼ばれ、通信と定期計測を担当します。
processBluetooth(); if (bleDataConnected_) {
processBluetooth();
}
unsigned long now = millis(); unsigned long now = millis();
if (!bluetoothConnected_ && now - lastAdvertise_ >= kAdvertiseRetryIntervalMs) { if (bleDataConnected_ && !bluetoothConnected_
&& now - lastAdvertise_ >= kAdvertiseRetryIntervalMs) {
// 接続されていなければ、定期的にBLE広告を出し直します。 // 接続されていなければ、定期的にBLE広告を出し直します。
lastAdvertise_ = now; lastAdvertise_ = now;
BLEDevice::startAdvertising(); BLEDevice::startAdvertising();
@ -263,6 +296,10 @@ void SensorLogger::processBluetooth() {
} }
void SensorLogger::receiveBluetoothData(const String& data) { void SensorLogger::receiveBluetoothData(const String& data) {
if (!bleDataConnected_) {
return;
}
// BLEで届いた文字を命令バッファへ追加します。 // BLEで届いた文字を命令バッファへ追加します。
for (size_t index = 0; index < data.length(); ++index) { for (size_t index = 0; index < data.length(); ++index) {
char character = data[index]; char character = data[index];
@ -270,6 +307,8 @@ void SensorLogger::receiveBluetoothData(const String& data) {
commandBuffer_ += character; commandBuffer_ += character;
} else if (commandBuffer_.length() < 64) { } else if (commandBuffer_.length() < 64) {
commandBuffer_ += character; commandBuffer_ += character;
} else if (commandBuffer_.length() == 64) {
Serial.println("[ERROR] BLE command is too long and was truncated");
} }
} }
} }
@ -293,6 +332,10 @@ void SensorLogger::applyHistoricalTimeAdjustment(int64_t epoch) {
} }
void SensorLogger::processCommand(const String& command) { void SensorLogger::processCommand(const String& command) {
if (!bleDataConnected_ || txCharacteristic_ == nullptr) {
return;
}
// アプリから届いた命令を判定します。 // アプリから届いた命令を判定します。
if (command == "GET_LOG") { if (command == "GET_LOG") {
// 保存済みのログを順番に送ります。 // 保存済みのログを順番に送ります。
@ -329,13 +372,20 @@ void SensorLogger::processCommand(const String& command) {
txCharacteristic_->setValue("OK_TIME\n"); txCharacteristic_->setValue("OK_TIME\n");
txCharacteristic_->notify(); txCharacteristic_->notify();
} else { } else {
Serial.printf("[ERROR] Invalid TIME command: %s\n", command.c_str());
txCharacteristic_->setValue("ERROR_TIME\n"); txCharacteristic_->setValue("ERROR_TIME\n");
txCharacteristic_->notify(); txCharacteristic_->notify();
} }
} else {
Serial.printf("[WARNING] Unknown BLE command: %s\n", command.c_str());
} }
} }
void SensorLogger::sendLog() { void SensorLogger::sendLog() {
if (!bleDataConnected_ || txCharacteristic_ == nullptr) {
return;
}
// 保存したログを1行ずつ作り、BLE通知で送ります。 // 保存したログを1行ずつ作り、BLE通知で送ります。
for (size_t index = 0; index < logCount_; ++index) { for (size_t index = 0; index < logCount_; ++index) {
size_t logIndex = (logStart_ + index) % kMaxLogEntries; size_t logIndex = (logStart_ + index) % kMaxLogEntries;
@ -360,6 +410,10 @@ void SensorLogger::sendLog() {
} }
void SensorLogger::sendNotification(const char* data) { void SensorLogger::sendNotification(const char* data) {
if (!bleDataConnected_ || txCharacteristic_ == nullptr) {
return;
}
// BLE通知には一度に送れる長さの制限があるため、短く分割します。 // BLE通知には一度に送れる長さの制限があるため、短く分割します。
constexpr size_t kNotificationSize = 20; constexpr size_t kNotificationSize = 20;
uint8_t notification[kNotificationSize]; uint8_t notification[kNotificationSize];

View File

@ -18,7 +18,7 @@ public:
static constexpr size_t kMaxLogEntries = 2000; static constexpr size_t kMaxLogEntries = 2000;
// センサー、BLE、保存データを使い始める準備をします。 // センサー、BLE、保存データを使い始める準備をします。
void begin(uint8_t soilPin); void begin(uint8_t soilPin, bool i2cConnected, bool bleDataConnected);
// BLEの受信と、一定時間ごとの計測を行います。 // BLEの受信と、一定時間ごとの計測を行います。
bool update(); bool update();
// センサーを読み取り、画面表示用の最新値を更新します。 // センサーを読み取り、画面表示用の最新値を更新します。
@ -69,6 +69,7 @@ private:
bool timeSynchronized_ = false; // 時刻合わせが済んだか bool timeSynchronized_ = false; // 時刻合わせが済んだか
bool historicalAdjustmentApplied_ = false; // 過去ログの補正が済んだか bool historicalAdjustmentApplied_ = false; // 過去ログの補正が済んだか
bool bluetoothConnected_ = false; // BLE接続中か bool bluetoothConnected_ = false; // BLE接続中か
bool bleDataConnected_ = false; // BLEデータ送受信を使うか
String bluetoothName_; // BLEに表示する名前 String bluetoothName_; // BLEに表示する名前
String commandBuffer_; // BLEから受け取った未処理の文字 String commandBuffer_; // BLEから受け取った未処理の文字

View File

@ -1,3 +1,10 @@
// I2C機器(OLED・センサー)を接続していない場合は false にします。
const bool kI2cConnected = true; // I2C機器が接続されていない場合は false にします。
// BLEでのセンサーデータ送受信を使わない場合は false にします。
const bool kBleDataConnected = true; // BLEでのセンサーデータ送受信を使わない場合は false にします。
// NeoPixelの接続ピンです。未接続の場合は -1 にすると一切使用しません。
const int kNeoPixelPin = 18; // 使用しない場合は -1 に、通常は 18番ピンに接続します。
// A2DPは、スマートフォンから音楽を受信するBluetoothの仕組みです。 // A2DPは、スマートフォンから音楽を受信するBluetoothの仕組みです。
#include "BluetoothA2DPSink.h" #include "BluetoothA2DPSink.h"
#include <Wire.h> #include <Wire.h>
@ -6,7 +13,12 @@
#include "SensorLogger.h" #include "SensorLogger.h"
// スマートフォンのBluetooth一覧に表示されるスピーカー名です。 // スマートフォンのBluetooth一覧に表示されるスピーカー名です。
const char kA2dpDeviceName[] = "BT_Speaker5.5"; const char kA2dpDeviceName[] = "BT_Speaker5.7";
// I2C接続のSDA/SCLピンです。配線に合わせてここだけ変更します。
// 通常基板:SDA=21, SCL=22
// 開発用基板:SDA=19, SCL=5
const uint8_t kI2cSdaPin = 21;
const uint8_t kI2cSclPin = 22;
// I2Cで接続した128x64ドットのOLED画面です。 // I2Cで接続した128x64ドットのOLED画面です。
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE); U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE);
@ -23,6 +35,12 @@ int currentVolumePercent = 0; // 音量(0〜100%)
bool bluetoothConnected = false; bool bluetoothConnected = false;
bool musicPlaying = false; bool musicPlaying = false;
unsigned long lastDisplayUpdate = 0; unsigned long lastDisplayUpdate = 0;
bool oledReady = false;
bool isI2cDevicePresent(uint8_t address) {
Wire.beginTransmission(address);
return Wire.endTransmission() == 0;
}
// UTF-8の文字列を1文字ずつ描画します。 // UTF-8の文字列を1文字ずつ描画します。
// フォントにない文字は、フォントに頼らず四角形を直接描きます。 // フォントにない文字は、フォントに頼らず四角形を直接描きます。
@ -93,6 +111,10 @@ void drawUtf8WithTofu(uint8_t x, uint8_t y, const String& text) {
// OLEDの内容を現在の状態に合わせて描き直します。 // OLEDの内容を現在の状態に合わせて描き直します。
void updateDisplay() { void updateDisplay() {
if (!oledReady) {
return;
}
u8g2.clearBuffer(); u8g2.clearBuffer();
// 音楽を再生していないときは、センサーの情報を表示します。 // 音楽を再生していないときは、センサーの情報を表示します。
@ -173,6 +195,8 @@ void avrc_metadata_callback(uint8_t id, const uint8_t *text) {
} else if (id == ESP_AVRC_MD_ATTR_ARTIST) { } else if (id == ESP_AVRC_MD_ATTR_ARTIST) {
currentArtist = (char*)text; currentArtist = (char*)text;
updated = true; updated = true;
} else {
Serial.printf("[WARNING] Unsupported AVRCP metadata id: %u\n", id);
} }
if (updated && bluetoothConnected && musicPlaying) { if (updated && bluetoothConnected && musicPlaying) {
@ -191,13 +215,19 @@ void volume_changed_callback(int volume) {
void audio_state_changed_callback(esp_a2d_audio_state_t state, void *) { void audio_state_changed_callback(esp_a2d_audio_state_t state, void *) {
// 音声が流れ始めたかどうかを保存し、LEDと画面を更新します。 // 音声が流れ始めたかどうかを保存し、LEDと画面を更新します。
musicPlaying = state == ESP_A2D_AUDIO_STATE_STARTED; musicPlaying = state == ESP_A2D_AUDIO_STATE_STARTED;
neoPixelController.setPlaying(musicPlaying); Serial.printf("[INFO] Audio state changed: %d (playing=%s)\n",
state, musicPlaying ? "yes" : "no");
if (kNeoPixelPin >= 0) {
neoPixelController.setPlaying(musicPlaying);
}
updateDisplay(); updateDisplay();
} }
void connection_state_changed_callback(esp_a2d_connection_state_t state, void *) { void connection_state_changed_callback(esp_a2d_connection_state_t state, void *) {
// スマートフォンとの接続状態を保存します。 // スマートフォンとの接続状態を保存します。
bluetoothConnected = state == ESP_A2D_CONNECTION_STATE_CONNECTED; bluetoothConnected = state == ESP_A2D_CONNECTION_STATE_CONNECTED;
Serial.printf("[INFO] A2DP connection state changed: %d (connected=%s)\n",
state, bluetoothConnected ? "yes" : "no");
if (!bluetoothConnected) { if (!bluetoothConnected) {
musicPlaying = false; musicPlaying = false;
} }
@ -207,17 +237,39 @@ void connection_state_changed_callback(esp_a2d_connection_state_t state, void *)
void setup() { void setup() {
// setupは、電源を入れた直後に1回だけ実行されます。 // setupは、電源を入れた直後に1回だけ実行されます。
Serial.begin(115200); Serial.begin(115200);
Serial.println("[INFO] Firmware starting");
neoPixelController.begin(18); if (kNeoPixelPin >= 0) {
neoPixelController.begin(static_cast<uint8_t>(kNeoPixelPin));
}
// I2Cの配線を設定します。Wire.begin(SDA, SCL)の順です。 if (kI2cConnected) {
Wire.begin(21, 22); // I2Cの配線を設定します。Wire.begin(SDA, SCL)の順です。
// OLED未接続時もSDA/SCLが浮かないようにします。
pinMode(kI2cSdaPin, INPUT_PULLUP);
pinMode(kI2cSclPin, INPUT_PULLUP);
Wire.begin(kI2cSdaPin, kI2cSclPin);
Wire.setTimeOut(50);
// OLEDを使える状態にします。 // OLEDが接続されているときだけ初期化と描画を行います。
u8g2.begin(); uint8_t oledAddress = 0;
u8g2.enableUTF8Print(); // UTF-8(日本語)描画を有効化 if (isI2cDevicePresent(0x3C)) {
oledAddress = 0x3C;
} else if (isI2cDevicePresent(0x3D)) {
oledAddress = 0x3D;
}
if (oledAddress != 0) {
u8g2.setI2CAddress(oledAddress << 1);
u8g2.begin();
u8g2.enableUTF8Print(); // UTF-8(日本語)描画を有効化
oledReady = true;
Serial.printf("[INFO] OLED detected at 0x%02X\n", oledAddress);
} else {
Serial.println("[WARNING] OLED not detected at 0x3C or 0x3D");
}
}
sensorLogger.begin(32); sensorLogger.begin(32, kI2cConnected, kBleDataConnected);
updateDisplay(); updateDisplay();
lastDisplayUpdate = millis(); lastDisplayUpdate = millis();
@ -240,11 +292,14 @@ void setup() {
// Bluetoothスピーカーとして起動します。 // Bluetoothスピーカーとして起動します。
a2dp_sink.start(kA2dpDeviceName); a2dp_sink.start(kA2dpDeviceName);
Serial.printf("[INFO] A2DP started as '%s'\n", kA2dpDeviceName);
} }
void loop() { void loop() {
// loopは何度も繰り返し実行されます。 // loopは何度も繰り返し実行されます。
neoPixelController.update(); if (kNeoPixelPin >= 0) {
neoPixelController.update();
}
bool newReading = sensorLogger.update(); bool newReading = sensorLogger.update();
unsigned long now = millis(); unsigned long now = millis();
if ((!bluetoothConnected || !musicPlaying) if ((!bluetoothConnected || !musicPlaying)