S-Shimizu が 2026年01月31日11時50分05秒 に編集
コメント無し
本文の変更
# 概要 猫のゴロゴロには、ストレスの解消や傷の回復といった効果があるといわれており、それはまさに癒しの魔法と言えるのではないでしょうか。 そんなゴロゴロの再現に挑戦してみました。 骨伝導スピーカを使って、猫のゴロゴロを音と振動で表現しています。 さらに、カメラで撮影した画像を元にねこまみれにするサービスを追加しました。 *本記事ではサーバ側の構成・動作については割愛します。 --- # 構成と実機動作 ごろごろを出すパペット部と、撮影を行うカメラ部で構成しています。  ## ごろごろ動作 1. センサで温湿度を計測 2. 環境センサのデータを、Spresenseを介してLTEでサーバに送信 3. (サーバ側動作:環境センサのデータを元に再生するべきファイルを選び、情報をサーバーにアップ) 4. サーバにアップされている情報を読み取り、その情報に従ったmp3ファイルを再生。 5. 骨伝導スピーカーを介して再生するので、音と振動となって出力される。 1~5を繰り返す ## ねこまみれ動作 1. パペットに内蔵されたタクトスイッチが押されると、MQTTをPublishする。 2. カメラは、MQTTサーバと定期的に交信し、MQTTリクエストに応じて撮影を行う。 3. 撮影した画像をサーバに送る 4. (サーバ側動作:送られてきた画像を元に生成AIで猫まみれになった画像を作成、HTMLで表示する) --- # 各部ハードウェア構成 ## パペット部 ### 使ったもの - Spresense メインボード - Spresense LTE拡張ボード - 温湿度センサユニット(BME280モジュール) - ステレオD級オーディオアンプモジュール(PAM8403使用)[https://eleshop.jp/shop/g/gF81125/] - microSDカード x1(猫のゴロゴロ音をmp3で保存しておきます) - LTE-MのSIM カード - 骨伝導スピーカー - その他筐体部品(トタンプレート、タミヤ製ユニバーサルプレートなど) - 猫型パペット(見た目) ### 構成 - SpresenseメインボードとLTE拡張ボードの組み合わせを使用 - 温湿度センサをSpresenseとSPIで接続、通信 - LTE拡張ボードのオーディオ端子から、D級アンプを介して骨伝導スピーカーに接続。 骨伝導スピーカーが貼りついたトタンプレートが振動し、振動と音でごろごろが出力されます。  ## カメラ部 ### 使ったもの - Spresense メインボード - Spresense LTE拡張ボード - SpresenseHDRカメラボード - LTE-MのSIM カード - 筐体(猫型、3Dプリンタで作成) ### 構成 - 電気的な構成はSpresenseメインボード、LTE拡張ボード、HDRカメラボードを仕様通り接続しています。 - 筐体を猫型して、首輪の位置にカメラレンズが来ることを意識しています。  --- # ソフトウェア ## パペット部 パペット部のSpresenseはオーディオ再生、LTE通信、環境センサ動作、GPIO制御を並列で行う必要あるため、マルチコアを採用しました。 とはいえ、オーディオ再生とLTE通信はメインコアでしか動作できないので再生と再生の合間に通信を行うような処理にしています。 ### マルチコア処理 以下のようにコアを割り当てています。 メインコア:オーディオ+LTE制御 サブコア2:環境センサ サブコア3:GPS制御(屋内で使っていたので実装したものの情報拾えず結局使えていなかった・・) サブコア4:GPIO制御 ### オーディオ機能(メインコア) SDカード上のMP3をトラック管理し、Spresenseのオーディオ機能でループ再生します。 #### トラック定義 ```cpp static constexpr uint8_t kTrackCount = 6; static const char* kTrackFiles[kTrackCount] = { "0_default.mp3", "1_neoki_new.mp3", "2_kitai_new.mp3", "3_kyukei.mp3", "4_asobo.mp3", "5_kyoumi.mp3", }; ``` SDカード直下にこれらファイル名で配置しています。 #### 再生状態の管理変数 ```cpp uint8_t g_playing = 0; // 現在再生中 uint8_t g_desired = 0; // 次に切り替えたい(曲末で反映) File g_mp3; ``` ポイントは **「今鳴っている曲 `g_playing`」と「次に鳴らしたい曲 `g_desired`」を分けている**ことです。 ループ中に `g_desired` が変わっても、**曲末までは切り替えず**安定動作を狙っています。 #### Audioの初期化と切替 Spresense Audioは、運用中にエラーが出たり状態が崩れたりしやすいことがあるため、切替前に「止める→Ready→Codecロード→再開」という手順を共通化しています。 ```cpp static void setOutputAndVolume() { theAudio->setPlayerMode(AS_SETPLAYER_OUTPUTDEVICE_SPHP, AS_SP_DRV_MODE_LINEOUT); theAudio->setVolume(volumeset[g_desired]); } ``` - `volumeset[]` 配列で **トラックごとに音量**を変えています - `g_desired` に合わせる設計なので、**次に鳴る曲の音量**が反映されます 音量を大きくしすぎると音が割れてしまうので、音が割れないギリギリを狙って調整しました。 #### MP3デコーダロード ```cpp static void loadMp3Decoder() { theAudio->initPlayer(AudioClass::Player0, AS_CODECTYPE_MP3, kBinPath, AS_SAMPLINGRATE_AUTO, AS_CHANNEL_STEREO); } ``` `kBinPath="/mnt/sd0/BIN"` に AudioライブラリのBINを配置しています。 #### 切替前に毎回“Ready”へ戻す ```cpp static void ensureReadyAndCodec() { //<中略> MQTT処理 // theAudio->stopPlayer(AudioClass::Player0); theAudio->setReadyMode(); setOutputAndVolume(); loadMp3Decoder(); } ``` この関数が「安全に切替するための共通手順」です。 切り替えのタイミングで必要に応じてMQTT Publishも行います。 #### ファイルオープン ```cpp static bool openTrack(uint8_t track) { if (track >= kTrackCount) track = 0; if (g_mp3) g_mp3.close(); g_mp3 = theSD.open(kTrackFiles[track]); ... } ``` - すでに開いている `g_mp3` は必ず closeするようにします。 #### writeFrames失敗時処理 ```cpp static bool startFromOpenFile() { g_mp3.seek(0); err_t err = theAudio->writeFrames(AudioClass::Player0, g_mp3); if (err != AUDIOLIB_ECODE_OK) { loadMp3Decoder(); err = theAudio->writeFrames(AudioClass::Player0, g_mp3); ... } delay(60); err = theAudio->startPlayer(AudioClass::Player0); ... } ``` - `writeFrames()` が失敗するケースを想定して **Codec再ロード→再試行**の処理を入れています。 #### 曲末でのみ「切替」または「同曲リピート」 ```cpp err_t ret = theAudio->writeFrames(AudioClass::Player0, g_mp3); if (ret == AUDIOLIB_ECODE_FILEEND) { if (g_desired != g_playing) { ok = switchTo(g_desired); } else { ok = repeatSame(); } sense_trigger = 1; } ``` - `AUDIOLIB_ECODE_FILEEND` のときだけ切替処理をする - 切替後に `sense_trigger=1` を立てて、次曲開始後のタイミングで計測&通信を行います。 --- ### センサ+LTE機能(メインコア) サブコアから取得したセンサ値をLTEを通してサーバにPOSTし、レスポンスを「次トラック番号」にします。
#### ヘッダ部設定 HTTP通信およびMQTT通信で使用する変数を設定します ```cpp // LTE情報 #define APP_LTE_APN "apn_name" //replace with your apn name #define APP_LTE_USER_NAME "user" // replace with your username #define APP_LTE_PASSWORD "password" // replace with your password #define APP_LTE_IP_TYPE (LTE_NET_IPTYPE_V4V6) #define APP_LTE_AUTH_TYPE (LTE_NET_AUTHTYPE_CHAP) #define APP_LTE_RAT (LTE_NET_RAT_CATM) ``` 使用するSIMカードの設定に従います。 ``` static const char server[] = "hostname"; // replace with server hostname static const char path[] = "/goro"; // replace with pathname static const int port = 80; //HTTP port static const char MQTT_HOST[] = "hostname"; // replace with server hostname static const int MQTT_PORT = 8883; // TLS static const char MQTT_TOPIC[] = "spresense/test"; // replace with pashname static const char MQTT_USER[] = "user"; // replace with server username static const char MQTT_PASS[] = "pass";// replace with server userpassword ``` #### ルート証明書 - `ISRG Root X1` を `setCACert()` に設定しています 各種サーバに接続するための設定を記述しています。 HTTPとMQTTで接続先が(同じサーバは使用していますが)異なるので、それぞれの変数を設定しておきます。
#### SubCoreからセンサ値を取得 ```cpp sensor_data_t BME280_get(void){ MP.Send(sndid, snddata, subcore); MP.RecvTimeout(1000); MP.Recv(&rcvid, &rcvdata, subcore); return *rcvdata; } ``` - `MP.Send()` で SubCore 側に要求を投げる - `MP.Recv()` で `sensor_data_t*` を受け取る #### HTTPでJSONを投げて、レスポンスを「次トラック番号」にする ```cpp int LTE_communication(float temp, float humi, float pres){ sprintf(sendData,"{\"key\": ... ,\"data\": { ... }}", temp, humi, pres); HttpClient http(client, server, port); http.post(path, contentType, sendData); int statusCode = http.responseStatusCode(); String response = http.responseBody(); return atoi(response.c_str()); } ``` - JSONをPOST - レスポンス本文を `atoi()` し、その整数を `g_desired` に入れる運用 (例:サーバが `"3"` を返したら次は3番トラック) - サーバ側の処理については割愛します
#### MQTT 接続 まず、MQTTサーバに接続します ``` /bool mqttConnect() { tlsCli.setCACert(ISRG_ROOT_X1_PEM); tlsCli.setTimeout(30000);
mqtt.setServer(MQTT_HOST, MQTT_PORT); mqtt.setKeepAlive(60); mqtt.setBufferSize(512);
char cid[32]; snprintf(cid, sizeof(cid), "sender-%lu", millis());
if (!mqtt.connect(cid, MQTT_USER, MQTT_PASS)) { return false; } return true; }
## MP3再生を曲末で切替しつつ、SubCoreセンサ取得+LTE(HTTP/MQTT)連携するスケッチ解説
void mqttEnsureConnected() { if (mqtt.connected()) return; while (!mqttConnect()) { Serial.println(F("Retry MQTT in 5s ...")); delay(5000); } } ```
Spresense(MainCore)で **SD上のMP3を再生**し、**曲の終端でのみトラックを切替**するプレイヤーを作ります。さらに、**SubCoreからBME280相当のセンサ値を取得**し、**LTEでHTTP送信して次に再生するトラック番号を受け取る**流れを入れています。加えて、**MQTT(TLS)で“shutter”をpublish**する処理も含めています。
#### MQTT publish ``` void sendShutter() { mqttEnsureConnected();
- 音声:AudioライブラリでMP3再生(SDカード) - 切替:`g_desired` を更新し、**曲末で反映** - SubCore:`MP` を使ってセンサデータを受信 - LTE:HTTPでJSONをPOSTし、レスポンスを「次トラック番号」として採用 - MQTT:TLS接続で `spresense/test` に `shutter` を publish(retainあり)
const char payload[] = "shutter"; bool ok = mqtt.publish(MQTT_TOPIC, (const uint8_t*)payload, strlen(payload), true); // retain = true Serial.printf("[PUB] shutter %s\n", ok ? "OK" : "NG"); } ``` MQTTサーバに接続後、シャッターを切る指令をPublishします
--- ### MainCore(このスケッチ) - SDから `0_default.mp3` などを開いて再生 - `writeFrames()` をループで回して再生を継続 - `AUDIOLIB_ECODE_FILEEND`(曲末)になったら - `g_desired != g_playing` → 次の曲へ切替 - 同じなら同じ曲を開き直してリピート - 曲末のタイミングで `sense_trigger=1` にし、次ループでセンサ取得+通信を実施 ### SubCore(別途書き込みが必要) - `MP.Send/Recv` に応答し、`sensor_data_t{temperature,humidity,pressure}` を返す --- --- --- ### MQTT(TLS):shutter を publish(retainあり) TLSクライアントにルート証明書(ISRG Root X1)を設定して MQTT over TLS (8883) に接続します。 - `mqttEnsureConnected()`:未接続なら接続し直す - `sendShutter()`:`payload="shutter"` を `MQTT_TOPIC` に publish このスケッチでは `ensureReadyAndCodec()` 内で一定周期に `digitalRead(9)` を見て `sendShutter()` を呼ぶ仕組みが入っています。 --- ### 12. setup():SD / Audio / SubCore / LTE / MQTT / 初回再生 重要な流れ:
### setup()関数 以下の流れで実行しています
1. SD begin(カードが入るまで待つ) 2. Audio begin → 出力・音量設定 → MP3デコーダロード 3. SubCore複数起動(2,3,4) 4. LTE attach(失敗しても継続) 5. MQTT接続 6. トラック0を開いて再生開始
---
### loop()関数
### loop():再生継続 → 曲末検出 → 切替 or リピート → 次回センサ送信予約
```cpp err_t ret = theAudio->writeFrames(AudioClass::Player0, g_mp3);
if (ret == AUDIOLIB_ECODE_FILEEND) { if (g_desired != g_playing) switchTo(g_desired); else repeatSame();
sense_trigger = 1; } ```
- `writeFrames()` が回り続けることで再生が進む - 曲末コードが返ったら切替処理 - その後 `sense_trigger=1` を立て、**次のループ冒頭で**センサ取得→HTTP通信→次曲決定、という流れ
### センサ情報取得(サブコア2)
#### BME280の接続(SPI3 + CS) ```cpp #define BME280_CS_PIN 7 // SPI3_CS1(D07)想定 #define SPI_CLOCK 1000000 // 1MHz ``` - CSピンを自前で `digitalWrite()` してSPI転送の前後でLOW/HIGHします - SpresenseのSPI3に接続しているので、`SPI3.begin()` を使っています。
#### setup()関数 マルチコアの開始とBME280の初期化、補正データの読み出しを行います
###4.1.3. 環境センサ
##### MPを開始(SubCore側) ```cpp MP.begin(); ``` SubCoreがMP通信可能な状態になります。
##### BME280の動作設定(単発測定ベース)
- `CONFIG(0xF5)` に `0x00` を書き込み - 単発測定 / フィルタなし / SPI 4線式 - `CTRL_MEAS(0xF4)` に `0x24` を書き込み - 温度・気圧 oversampling x1 / スリープ - `CTRL_HUM(0xF2)` に `0x01` を書き込み - 湿度 oversampling x1
##### 補正係数(キャリブレーション)を読む BME280は生データ(adc_T/adc_P/adc_H)を、そのまま実値にできません。チップ内部の補正係数(dig_T*, dig_P*, dig_H*)を読み出して補正計算します。 - 0x88 から温度・気圧系の補正データを取得 - 0xE1 から湿度系の補正データを取得 - 取得したバイト列を `dig_T1..` などへ組み立てています
コードは末尾の全体を参照ください
###4.1.4. GPIO制御
#### loop()関数
測定→補正→MainCore要求を受けて返信を行います。
##### 1回測定を開始(CTRL_MEASへ書く)
## 4. 2 カメラ部
```cpp SPI3.transfer(CTRL_MEAS & 0x7F); SPI3.transfer(0x25); // x1 + forced(1回測定) -> 測定後スリープ delay(10); ``` `0x25` により「forced mode」で1回測定し終えたらスリープに戻ります。 ##### 生データ読み出し(0xF7〜) ```cpp SPI3.transfer(0xF7 | 0x80); for (i=0; i<8; i++) dac[i] = SPI3.transfer(0x00); ``` - 0xF7から8バイト読み、気圧/温度/湿度のadc値へ展開します ##### 補正計算して実値へ ```cpp pres_cal = BME280_compensate_P_int32(adc_P); temp_cal = BME280_compensate_T_int32(adc_T); humi_cal = bme280_compensate_H_int32(adc_H); pres = pres_cal / 100.0; temp = temp_cal / 100.0; humi = humi_cal / 1024.0; ``` - `t_fine` は温度補正で更新され、湿度・気圧補正でも使われます(BME280の定番仕様) ##### MainCoreからの要求を待って、返す ```cpp ret = MP.Recv(&msgid, &msgdata); ... ret = MP.Send(msgid, &snddata); ``` - `MP.Recv()` で「要求(msgid)」を受ける - 同じ `msgid` を使って `MP.Send()` で応答する、という形になっています ### GPIO制御(サブコア4) #### タクトスイッチ処理(方針) - タクトスイッチをGPIO3に設定、プルアップします。 - タクトスイッチが押されたらMQTTをPublishするのですが、タクトスイッチ押下とMQTT Publishのタイミングが異なるため、GPIO9を使ってラッチさせます。 - このため、MP.Recv, MP.Sendは使用していません。 コードは末尾を参照してください。 ## カメラ部
カメラ部では - サーバにMQTTメッセージが到着しているかを確認する。 - MQTTがサーバに到着していたらカメラで写真を撮影する - 撮影した画像をサーバに送る の処理を行っています。
## 7. コード(貼り付け枠) ### 7.1 全体(メイン)
### 設定項目(各自の環境に合わせて変更) #### LTE(APN)
```cpp
// ここにメインコードを貼り付け
#define APP_LTE_APN "apn_name" //replace with your apn name #define APP_LTE_USER_NAME "user" // replace with your username #define APP_LTE_PASSWORD "password" // replace with your password #define APP_LTE_IP_TYPE (LTE_NET_IPTYPE_V4V6) #define APP_LTE_AUTH_TYPE (LTE_NET_AUTHTYPE_CHAP) #define APP_LTE_RAT (LTE_NET_RAT_CATM)
```
### 7.2 オーディオ再生部(必要なら分割して貼る)
#### HTTPSアップロード先
```cpp
// ここにAudio関連コードを貼り付け
static const char HOST[] = "hostname"; // replace with server hostname static const int HTTPS_PORT = 443; static const char PATH[] = "/upload/image"; // replace with pathname
```
### 7.3 MQTT受信・コマンド処理部
#### MQTT(MQTTS)ブローカー
```cpp
// ここにMQTT関連コードを貼り付け
static const char MQTT_HOST[] = "hostname"; // replace with server hostname static const int MQTT_PORT = 8883; static const char MQTT_TOPIC_SUB[] = "spresense/test"; static const char MQTT_TOPIC_ACK[] = "spresense/ack"; static const char MQTT_USER[] = "user"; // replace with server username static const char MQTT_PASS[] = "pass"; // replace with server userpassword
```
### 7.4 カメラ撮影部
### APIキー(共通鍵)
```cpp
// ここにCamera関連コードを貼り付け
static const char API_KEY[] = "techseeker-nekogorodoh";
```
- HTTPS側のmultipartで `key` として送信します(簡易認証用途)
### 7.5 設定値(SSID/ブローカ/トピック等)
### ルート証明書 - `ISRG Root X1` を `setCACert()` に設定しています(Let’s Encrypt系の検証用) #### MQTTメッセージ仕様(トリガ) - Publish:`spresense/test`(加えて`spresense/#`も購読) - ペイロード文字列に **`shutter` が含まれていれば撮影** - JSONでもOK(例:`{"cmd":"shutter"}`) - 余計な空白があっても `trim()` して判定 ACKは `spresense/ack` に以下を送ります(retain=true): - 起動時:`online` - 成功:`ok` - 失敗:`ng` - 撮影失敗:`capture_failed` #### HTTPSアップロード仕様(multipart/form-data) POST `https://<HOST>/upload/image` multipartの中身: - `key`: APIキー文字列 - `file`: JPEG(`photo.jpg`、`Content-Type: image/jpeg`) レスポンスはJSONを想定し、`result` フィールドがあれば表示します。 #### 実装上のポイント(このコードの工夫部分だけ) - **RTC同期(lte.getTime)** TLSで証明書検証するため、時刻ずれ対策として起動時にRTCを合わせます。 - **MQTT受信バッファ拡張** ```cpp mqttClient.setBufferSize(512); ``` 受信が256B超のケースで詰まるのを避ける保険。 - **トピックずれ対策でワイルドカード購読** ```cpp mqttClient.subscribe("spresense/test"); mqttClient.subscribe("spresense/#"); ``` - **連打防止(簡易デバウンス)** ```cpp const unsigned long MIN_INTERVAL_MS = 2000; ``` `shutter`が短時間に複数回飛んでも2秒以内は無視。 - **HTTPS送信後に明示的にstop** ```cpp tlsHttp.stop(); ``` セッション残りや接続資源の詰まりを避ける目的。 # コード ## パペット部 メインコア
```cpp
// ここに設定値(環境依存)を貼り付け
// ここにコードを貼り付け
```
## パペット部 サブコア2 ```cpp // ここにコードを貼り付け ``` ## パペット部 サブコア4 ```cpp // ここにコードを貼り付け ```
## カメラ部 ```cpp // ここにコードを貼り付け ```