Merge branch 'ws2812b'

This commit is contained in:
Koichi Ito 2026-09-21 09:10:02 +09:00
commit 87d111e3d9
6 changed files with 748 additions and 41 deletions

View File

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

View File

@ -0,0 +1,71 @@
#include "NeoPixelController.h"
#include <Adafruit_NeoPixel.h>
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();
}

View File

@ -0,0 +1,27 @@
#pragma once
#include <Arduino.h>
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;
};

386
firmware/SensorLogger.cpp Normal file
View File

@ -0,0 +1,386 @@
#include "SensorLogger.h"
#include <Adafruit_AHTX0.h>
#include <BH1750.h>
#include <BLE2902.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <time.h>
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<time_t>(timestamp);
struct tm date;
localtime_r(&unixTimestamp, &date);
strftime(buffer, bufferSize, "%Y/%m/%d %H:%M:%S", &date);
return true;
}
// 時刻合わせ前は、起動してからの経過秒数を仮の日付として表示します。
int year = static_cast<int>(kInitialYear);
int month = 1;
int day = 1;
int64_t days = timestamp / kSecondsPerDay;
int seconds = static_cast<int>(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<int>(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<unsigned long>(mktime(&date));
}
if (epoch != 0) {
applyHistoricalTimeAdjustment(static_cast<int64_t>(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;
}

77
firmware/SensorLogger.h Normal file
View File

@ -0,0 +1,77 @@
#pragma once
#include <Arduino.h>
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;
};

View File

@ -1,57 +1,169 @@
// A2DPは、スマートフォンから音楽を受信するBluetoothの仕組みです。
#include "BluetoothA2DPSink.h" #include "BluetoothA2DPSink.h"
#include <Wire.h> #include <Wire.h>
#include <U8g2lib.h> #include <U8g2lib.h>
#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); U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE);
// このプログラムで使う機能の担当者を作ります。
BluetoothA2DPSink a2dp_sink; BluetoothA2DPSink a2dp_sink;
NeoPixelController neoPixelController;
SensorLogger sensorLogger;
// 表示用変数 // 音楽情報と画面更新に使う変数です。
String currentTitle = "未接続"; String currentTitle = "未接続";
String currentArtist = ""; 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<uint8_t>(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<uint8_t>(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<uint16_t>(codePoint));
cursorX += static_cast<uint8_t>(u8g2_GetGlyphWidth(
u8g2.getU8g2(), static_cast<uint16_t>(codePoint)));
} else {
// □の字形がフォントにない場合でも、四角形なら必ず表示できます。
u8g2.drawFrame(cursorX, y - kTofuSize, kTofuSize, kTofuSize);
cursorX += kTofuAdvance;
}
index += byteCount;
}
}
// OLEDの内容を現在の状態に合わせて描き直します。
void updateDisplay() { void updateDisplay() {
u8g2.clearBuffer(); u8g2.clearBuffer();
// -------------------------------------------------- // 音楽を再生していないときは、センサーの情報を表示します。
// 1. 音量表示エリア(最上部:英数フォント) 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<unsigned int>(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;
}
// 音楽を再生中は、音量と曲名を表示します。
// 上の部分は小さい英数字フォントで音量を表示します。
u8g2.setFont(u8g2_font_6x10_tf); u8g2.setFont(u8g2_font_6x10_tf);
// "Vol: 80%" のテキスト描画 // 例: 「Vol: 80%」という文字を作って表示します。
char volStr[16]; char volStr[16];
snprintf(volStr, sizeof(volStr), "Vol: %3d%%", currentVolumePercent); snprintf(volStr, sizeof(volStr), "Vol: %3d%%", currentVolumePercent);
u8g2.drawStr(0, 10, volStr); u8g2.drawStr(0, 10, volStr);
// 音量バーの描画 // 音量の割合を、0〜50ドットの長さに変換して棒グラフにします。
int barWidth = map(currentVolumePercent, 0, 100, 0, 50); // 50ピクセル幅に変換 int barWidth = map(currentVolumePercent, 0, 100, 0, 50);
u8g2.drawFrame(75, 2, 52, 9); // 外枠 u8g2.drawFrame(75, 2, 52, 9); // 棒グラフの外枠
u8g2.drawBox(76, 3, barWidth, 7); // バーの中身 u8g2.drawBox(76, 3, barWidth, 7); // 音量に応じた中身
u8g2.drawLine(0, 13, 128, 13); // 区切り線 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) { if (currentArtist.length() > 0) {
// アーティスト名も日本語フォントのまま描画します drawUtf8WithTofu(0, 54, currentArtist);
u8g2.drawUTF8(0, 54, currentArtist.c_str());
} }
u8g2.sendBuffer(); // 画面転送 // ここまでの描画内容を、実際のOLED画面へ送ります。
u8g2.sendBuffer();
} }
// メタデータ(曲名・アーティスト名)の受信コールバック // スマートフォンから曲名やアーティスト名を受け取ったときに呼ばれます。
void avrc_metadata_callback(uint8_t id, const uint8_t *text) { void avrc_metadata_callback(uint8_t id, const uint8_t *text) {
bool updated = false; bool updated = false;
@ -63,37 +175,54 @@ void avrc_metadata_callback(uint8_t id, const uint8_t *text) {
updated = true; updated = true;
} }
if (updated) { if (updated && bluetoothConnected && musicPlaying) {
updateDisplay(); updateDisplay();
} }
} }
// 音量変更時のコールバック関数 // スマートフォン側で音量が変わったときに呼ばれます。
void volume_changed_callback(int volume) { void volume_changed_callback(int volume) {
currentVolumePercent = map(volume, 0, 127, 0, 100); 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(); updateDisplay();
} }
void setup() { void setup() {
// setupは、電源を入れた直後に1回だけ実行されます。
Serial.begin(115200); Serial.begin(115200);
// SDAをGPIO 19、SCLをGPIO 32 に設定 neoPixelController.begin(18);
// (配線に合わせて Wire.begin(SDA_PIN, SCL_PIN) の順で指定します)
Wire.begin(19, 5);
// OLED初期化 // I2Cの配線を設定します。Wire.begin(SDA, SCL)の順です。
Wire.begin(21, 22);
// OLEDを使える状態にします。
u8g2.begin(); u8g2.begin();
u8g2.enableUTF8Print(); // UTF-8(日本語)描画を有効化 u8g2.enableUTF8Print(); // UTF-8(日本語)描画を有効化
// 初期画面 sensorLogger.begin(32);
u8g2.clearBuffer(); updateDisplay();
u8g2.setFont(u8g2_font_unifont_t_japanese1); lastDisplayUpdate = millis();
u8g2.drawUTF8(0, 20, "BTスピーカー5.2");
u8g2.setFont(u8g2_font_6x10_tf);
u8g2.drawStr(0, 40, "Ready...");
u8g2.sendBuffer();
// I2S ピン設定 (BCK: 27, WS: 25, DOUT: 26) // I2Sは、Bluetooth音声をアンプへ送るための通信方式です。
// BCK=27、WS=25、音声データ出力=DOUT=26に配線しています。
i2s_pin_config_t my_pin_config = { i2s_pin_config_t my_pin_config = {
.bck_io_num = 27, .bck_io_num = 27,
.ws_io_num = 25, .ws_io_num = 25,
@ -103,13 +232,25 @@ void setup() {
a2dp_sink.set_pin_config(my_pin_config); a2dp_sink.set_pin_config(my_pin_config);
// コールバック登録 // Bluetoothでイベントが起きたときの通知先を登録します。
a2dp_sink.set_avrc_metadata_callback(avrc_metadata_callback); a2dp_sink.set_avrc_metadata_callback(avrc_metadata_callback);
a2dp_sink.set_on_volumechange(volume_changed_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起動 // Bluetoothスピーカーとして起動します。
a2dp_sink.start("BT_Speaker5.2"); a2dp_sink.start(kA2dpDeviceName);
} }
void loop() { 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();
}
} }