edge lab LIVE! starts Tuesday in Munich. Grab a workshop seat and take home the board you build on. Code FRIEND-HACKSTER saves 20%. () Learn more!
Kazuhiro Hamaya
Published © CC BY

Kiboku: An Interactive IoT Fortune Gadget

A modern reinterpretation of an ancient Japanese divination ritual, transformed into an interactive IoT experience that brings a small momen

BeginnerFull instructions provided10 hours37
Kiboku: An Interactive IoT Fortune Gadget

Things used in this project

Hardware components

M5Stack Atom VoiceS3R
×1
WS2812b RGB LED Ring 7Bits
×7
STMicroelectronics VL53L0X
STMicroelectronics VL53L0X
×1

Software apps and online services

Arduino IDE
Arduino IDE
OpenAI API

Hand tools and fabrication machines

3D Printer (generic)
3D Printer (generic)

Story

Read more

Custom parts and enclosures

kiboku_260801

Enclosures

Schematics

Schematics(Visualize)

Photos and lines

Code

kiboku_260801

Arduino
This is made for M5Stack Atom VoiceS3R
#include <M5Unified.h>
#include <HTTPClient.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h> 
#include <AudioGeneratorMP3.h>
#include <AudioOutput.h>
#include <AudioFileSourcePROGMEM.h>

#include <Adafruit_NeoPixel.h>
#define PIN        39
#define NUMPIXELS  49
Adafruit_NeoPixel pixels(NUMPIXELS, PIN, NEO_GRB + NEO_KHZ800);

#include <Wire.h>
#include "Adafruit_VL53L0X.h"
#define I2C_SCL 5
#define I2C_SDA 6
Adafruit_VL53L0X lox = Adafruit_VL53L0X();
uint16_t distance = 4000;

class AudioOutputM5Speaker : public AudioOutput {
public:
  bool begin() override { return true; }

  bool ConsumeSample(int16_t sample[2]) override {
    buf[idx][pos++] = (sample[0] + sample[1]) / 2;
    if (pos >= BUF_SIZE) flush_();
    return true;
  }

  bool stop() override { flush_(); return true; }

private:
  void flush_() {
    if (pos == 0) return;
    // 再生キューが空くまで待つ = デコード速度を実時間に合わせる(これが最重要)
    while (M5.Speaker.isPlaying(0) > 1) { vTaskDelay(1); }
    M5.Speaker.playRaw(buf[idx], pos, hertz, false, 1, 0, false);
    idx ^= 1;   // 再生中のバッファを上書きしないよう交互に使う
    pos = 0;
  }

  static const int BUF_SIZE = 1024;
  int16_t buf[2][BUF_SIZE];   // ダブルバッファ
  int idx = 0;
  int pos = 0;
};

// HTTPClient の出力先として使う、PSRAM上のバッファに書き込むだけの Stream
class PsramBufferStream : public Stream {
public:
  PsramBufferStream(uint8_t* buf, size_t cap) : _buf(buf), _cap(cap) {}

  size_t write(uint8_t b) override {
    if (_len >= _cap) return 0;
    _buf[_len++] = b;
    return 1;
  }
  size_t write(const uint8_t* data, size_t size) override {
    if (_len + size > _cap) size = _cap - _len;   // あふれ防止
    if (size == 0) return 0;
    memcpy(_buf + _len, data, size);
    _len += size;
    return size;
  }
  size_t length() const { return _len; }

  // Stream の純粋仮想関数(読み出しには使わないのでダミー)
  int available() override { return 0; }
  int read() override      { return -1; }
  int peek() override      { return -1; }
  void flush() override    {}

private:
  uint8_t* _buf;
  size_t   _cap;
  size_t   _len = 0;
};


const char* ssid     = "YOURSSID";
const char* password = "YOURPASSWORD";
const char* apiKey   = "sk-YOURAPIKEY";
// API失敗時に読み上げる予備テキスト
const char* fallbackText = "あなたの未来を占いましょう。キボクによると現在占いを中止しています。";

// OpenAIに亀卜の占い結果テキストを生成させる。失敗したら空文字列を返す。
String generateFortuneText() {
  // リクエストボディを先に組み立てる(リトライで使い回すため)
  JsonDocument reqDoc;
  reqDoc["model"] = "gpt-4o-mini";
  reqDoc["max_tokens"] = 150;
  reqDoc["temperature"] = 1.2;  // 毎回違う占い結果になるよう高めに
  JsonArray messages = reqDoc["messages"].to<JsonArray>();
  JsonObject sys = messages.add<JsonObject>();
  sys["role"] = "system";
  sys["content"] =
      "あなたは亀卜(きぼく)の占い師です。相手の未来に起きる「良いこと」をひとつ占って告げます。"
      "必ず「あなたの未来を占いましょう。キボクによると」で始めてください。"
      "全体を100文字以内の1〜2文にまとめ、縁起の良い前向きな内容だけを話してください。"
      "記号・絵文字・改行は使わないでください。";
  JsonObject usr = messages.add<JsonObject>();
  usr["role"] = "user";
  usr["content"] = "私の未来を亀卜で占って、これから起きる良いことを教えてください。";

  String reqBody;
  serializeJson(reqDoc, reqBody);

  // 一時的なTLS接続失敗に備えて最大3回リトライ
  for (int attempt = 1; attempt <= 3; attempt++) {
    Serial.printf("占いテキスト生成中... (試行%d回目, free heap=%u)\n", attempt, ESP.getFreeHeap());

    WiFiClientSecure client;
    client.setInsecure();  // 証明書検証なしで接続(URL文字列版beginと同等の扱いを明示的に行う)

    HTTPClient http;
    http.setConnectTimeout(10000);  // 接続タイムアウト10秒
    http.setTimeout(25000);         // 応答待ちタイムアウト25秒(生成に数秒かかるため)
    if (!http.begin(client, "https://api.openai.com/v1/chat/completions")) {
      Serial.println("http.begin failed");
      delay(1000);
      continue;
    }
    http.addHeader("Authorization", String("Bearer ") + apiKey);
    http.addHeader("Content-Type", "application/json");

    int code = http.POST(reqBody);
    if (code == 200) {
      String resp = http.getString();
      http.end();

      JsonDocument resDoc;
      DeserializationError err = deserializeJson(resDoc, resp);
      if (err) {
        Serial.print("JSON parse error: ");
        Serial.println(err.c_str());
        return "";
      }
      const char* content = resDoc["choices"][0]["message"]["content"];
      if (!content) {
        Serial.println("応答にテキストがありません");
        Serial.println(resp);  // エラー内容確認用
        return "";
      }
      return String(content);
    }

    Serial.printf("Chat API error: %d (%s)\n", code, http.errorToString(code).c_str());
    if (code > 0) {
      // サーバーには届いている(401=キー不正, 429=利用制限 など)→ リトライしても無駄
      Serial.println(http.getString());  // エラー内容確認用
      http.end();
      return "";
    }
    http.end();
    delay(1000);
  }
  Serial.println("3回失敗しました");
  return "";
}

void setup() {
  Serial.begin(115200);
  delay(1000);
  Serial.println("Starting...");

  auto cfg = M5.config();
  M5.begin(cfg);
  Serial.println("M5 initialized");
  auto spk_cfg = M5.Speaker.config();
    spk_cfg.sample_rate    = 24000;  // サンプルレート
    spk_cfg.stereo         = false;  // モノ/ステレオ
    spk_cfg.magnification  = 16;     // 音量倍率
    spk_cfg.dma_buf_len    = 1024;    // DMAバッファ長
    spk_cfg.dma_buf_count  = 8;      // DMAバッファ数
  M5.Speaker.config(spk_cfg);
  M5.Speaker.begin();
  M5.Speaker.setVolume(48);
  Serial.println("Speaker initialized");

  pixels.begin(); // NeoPixelの初期化
  pixels.setBrightness(20);

  Serial.println("VL53L0X start");
  // M5Atom Echo S3R: SDA=6, SCL=5
  Wire.begin(I2C_SDA, I2C_SCL);
  if (!lox.begin(0x29, false, &Wire)) {
    Serial.println("VL53L0X が見つかりません。配線を確認してください。");
    while (1) {
      delay(10);
    }
  }
  Serial.println("VL53L0X ready");

  Serial.println("スタート");
    for(int i=0; i<NUMPIXELS; i++) {
      // pixels.Color(R, G, B) で色を指定(0〜255)
      pixels.setPixelColor(i, pixels.Color(255,255,255)); // white
      pixels.show();   // 設定を反映
      delay(30);      // 0.03秒待機
    }
    delay(300);
    pixels.clear();
    pixels.show();
    for(int i=0; i<NUMPIXELS; i++) {
      pixels.setPixelColor(i, pixels.Color(255,255,255)); // white
      pixels.show();   // 設定を反映
      delay(30);      // 0.03秒待機
    }
    delay(300);
    pixels.clear();
    pixels.show();
    delay(500);

  while(distance > 80 ){
    //距離測定
    VL53L0X_RangingMeasurementData_t measure;
    lox.rangingTest(&measure, false);
    if (measure.RangeStatus != 4) {
      distance = measure.RangeMilliMeter;
      Serial.print("距離1回目: ");
      Serial.print(distance);
      Serial.println(" mm");
    } else {
      distance = 4000;
      Serial.println("測定範囲外");
    }
    delay(50);
    //距離測定END
    for(int j=0; j<200; j++){
      pixels.setPixelColor(21, pixels.Color(j, j, j));
      pixels.setPixelColor(22, pixels.Color(j, j, j));
      pixels.setPixelColor(25, pixels.Color(j, j, j));
      pixels.show();
      delay(10);

      if (distance < 80) { // ループの途中でも、スイッチがOFFになったら即座に中断する処理
        pixels.clear();
        pixels.show();
        Serial.println("TRIGER_ON");
        break;          // forループを強制脱出
      }
    }

    lox.rangingTest(&measure, false);
    if (measure.RangeStatus != 4) {
      distance = measure.RangeMilliMeter;
      Serial.print("距離2回目: ");
      Serial.print(distance);
      Serial.println(" mm");
    } else {
      distance = 4000;
      Serial.println("測定範囲外");
    }
    delay(50);
    //距離測定END
    for(int k=199; k>0; k--){
      pixels.setPixelColor(21, pixels.Color(k, k, k));
      pixels.setPixelColor(22, pixels.Color(k, k, k));
      pixels.setPixelColor(25, pixels.Color(k, k, k));
      pixels.show();
      delay(10);
      if (distance < 80) { // ループの途中でも、スイッチがOFFになったら即座に中断する処理
        pixels.clear();
        pixels.show();
        Serial.println("TRIGER_ON");
        break;          // forループを強制脱出
      }
    }
  }

  delay(10);
  pixels.clear();
  pixels.setPixelColor(21, pixels.Color(255,0,0));
  pixels.setPixelColor(22, pixels.Color(255,0,0));
  pixels.setPixelColor(23, pixels.Color(255,0,0));
  pixels.setPixelColor(24, pixels.Color(255,0,0));
  pixels.setPixelColor(25, pixels.Color(255,0,0));
  pixels.setPixelColor(26, pixels.Color(255,0,0));
  pixels.setPixelColor(27, pixels.Color(255,0,0));
  pixels.show();
  delay(50);
  pixels.clear();
  pixels.show();

  WiFi.begin(ssid, password);
  Serial.println("Connecting WiFi...");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFi connected");

  // WiFi接続直後はDNSがまだ使えないことがあるため、名前解決できるまで待つ
  IPAddress apiIp;
  for (int i = 0; i < 20; i++) {
    if (WiFi.hostByName("api.openai.com", apiIp)) {
      Serial.print("DNS OK: ");
      Serial.println(apiIp);
      break;
    }
    Serial.println("DNS準備待ち...");
    delay(500);
  }

  // ランダムにLEDパターンを決定(テキストは選ばずLEDのみ)
  // 1〜12: 固定パターン / 13〜50: ランダム位置・ランダム色パターン
  int x = random(1,51);
  Serial.print("number=No.");
  Serial.println(x);
  if (x == 1){
    pixels.setPixelColor(6, pixels.Color(150, 75, 0));
    pixels.setPixelColor(0, pixels.Color(150, 75, 0));
    pixels.setPixelColor(3, pixels.Color(150, 75, 0));
    pixels.show();
  }if (x == 2){
    pixels.setPixelColor(7, pixels.Color(255, 0, 0));
    pixels.setPixelColor(8, pixels.Color(255, 0, 0));
    pixels.setPixelColor(11, pixels.Color(255, 0, 0));
    pixels.show();
  }if (x == 3){
    pixels.setPixelColor(14, pixels.Color(255,120, 0));
    pixels.setPixelColor(16, pixels.Color(255,120, 0));
    pixels.setPixelColor(19, pixels.Color(255,120, 0));
    pixels.show();
  }if (x == 4){
    pixels.setPixelColor(28, pixels.Color(255,180, 0));
    pixels.setPixelColor(31, pixels.Color(255,180, 0));
    pixels.setPixelColor(34, pixels.Color(255,180, 0));
    pixels.show();
  }if (x == 5){
    pixels.setPixelColor(35, pixels.Color(0, 255, 0));
    pixels.setPixelColor(36, pixels.Color(0, 255, 0));
    pixels.setPixelColor(39, pixels.Color(0, 255, 0));
    pixels.show();
  }if (x == 6){
    pixels.setPixelColor(42, pixels.Color(0, 0, 255));
    pixels.setPixelColor(44, pixels.Color(0, 0, 255));
    pixels.setPixelColor(47, pixels.Color(0, 0, 255));
    pixels.show();
  }if (x == 7){
    pixels.setPixelColor(21, pixels.Color(180, 0, 180));
    pixels.setPixelColor(22, pixels.Color(180, 0, 180));
    pixels.setPixelColor(25, pixels.Color(180, 0, 180));
    pixels.show();
  }if (x == 8){
    pixels.setPixelColor(6, pixels.Color(128,128,128));
    pixels.setPixelColor(0, pixels.Color(128,128,128));
    pixels.setPixelColor(3, pixels.Color(128,128,128));
    pixels.setPixelColor(28, pixels.Color(128,128,128));
    pixels.setPixelColor(31, pixels.Color(128,128,128));
    pixels.setPixelColor(34, pixels.Color(128,128,128));
    pixels.show();
  }if (x == 9){
    pixels.setPixelColor(42, pixels.Color(255,255,255));
    pixels.setPixelColor(44, pixels.Color(255,255,255));
    pixels.setPixelColor(47, pixels.Color(255,255,255));
    pixels.setPixelColor(14, pixels.Color(255,255,255));
    pixels.setPixelColor(16, pixels.Color(255,255,255));
    pixels.setPixelColor(19, pixels.Color(255,255,255));
    pixels.show();
  }if (x == 10){
    pixels.setPixelColor(7, pixels.Color(255,255,255));
    pixels.setPixelColor(8, pixels.Color(255,0,0));
    pixels.setPixelColor(11, pixels.Color(255,255,255));
    pixels.setPixelColor(36, pixels.Color(255,255,255));
    pixels.setPixelColor(35, pixels.Color(255,0,0));
    pixels.setPixelColor(39, pixels.Color(255,255,255));
    pixels.show();
  }if (x == 11){
    pixels.setPixelColor(21, pixels.Color(0,0,0));
    pixels.setPixelColor(22, pixels.Color(128,128,128));
    pixels.setPixelColor(23, pixels.Color(255,0,0));
    pixels.setPixelColor(24, pixels.Color(128,0,255));
    pixels.setPixelColor(25, pixels.Color(255,255,0));
    pixels.setPixelColor(26, pixels.Color(0,0,255));
    pixels.setPixelColor(27, pixels.Color(255,0,255));
    pixels.show();
  }if (x == 12){
    pixels.setPixelColor(0, pixels.Color(255,255,255));
    pixels.setPixelColor(7, pixels.Color(255,255,255));
    pixels.setPixelColor(14, pixels.Color(255,255,255));
    pixels.setPixelColor(21, pixels.Color(255,255,255));
    pixels.setPixelColor(28, pixels.Color(255,255,255));
    pixels.setPixelColor(35, pixels.Color(255,255,255));
    pixels.setPixelColor(42, pixels.Color(255,255,255));
    pixels.show();
  }if (x >= 13){
    // パターン13〜50: ランダムな場所を5〜6個、ランダムな色で点灯
    int count = random(5, 7);  // 5 または 6
    Serial.printf("ランダムパターン: %d個点灯\n", count);
    int used[6];  // 同じ場所を二度選ばないための記録
    for (int i = 0; i < count; i++) {
      int pos;
      bool dup;
      do {
        pos = random(NUMPIXELS);
        dup = false;
        for (int j = 0; j < i; j++) {
          if (used[j] == pos) dup = true;
        }
      } while (dup);
      used[i] = pos;
      // 色相をランダムに選んで鮮やかな色を作る(gamma32で自然な見た目に補正)
      uint32_t color = pixels.gamma32(pixels.ColorHSV(random(65536), 255, 255));
      pixels.setPixelColor(pos, color);
    }
    pixels.show();
  }

  // OpenAIに占い結果テキストを生成させる(失敗したら予備テキストを使用)
  String fortune = generateFortuneText();
  if (fortune.length() == 0) {
    Serial.println("生成失敗 → 予備テキストを使用");
    fortune = fallbackText;
  }
  Serial.print("占い結果: ");
  Serial.println(fortune);

  WiFiClientSecure client;
  client.setInsecure();  // 証明書検証なしで接続

  HTTPClient http;
  http.setConnectTimeout(10000);
  http.setTimeout(25000);
  if (!http.begin(client, "https://api.openai.com/v1/audio/speech")) {
    Serial.println("http.begin failed");
    ESP.restart();
  }
  http.addHeader("Authorization", String("Bearer ") + apiKey);
  http.addHeader("Content-Type", "application/json");


  // JSONはArduinoJsonで組み立てる(生成テキスト中の「"」等を自動でエスケープするため)
  JsonDocument ttsDoc;
  ttsDoc["model"] = "gpt-4o-mini-tts";
  ttsDoc["input"] = fortune;
  ttsDoc["voice"] = "shimmer";
  ttsDoc["instructions"] = "低いトーンでゆっくり話す";
  ttsDoc["response_format"] = "mp3";
  String body;
  serializeJson(ttsDoc, body);
  int code = http.POST(body);

 if (code == 200) {
  const int MAX_SIZE = 500 * 1024;
  uint8_t* mp3data = (uint8_t*)ps_malloc(MAX_SIZE);
  if (!mp3data) {
    Serial.println("ps_malloc failed - PSRAMが使えない");
    ESP.restart();   // ← return; から変更
  }

  unsigned long startTime = millis();

  PsramBufferStream sink(mp3data, MAX_SIZE);
  int written = http.writeToStream(&sink);  // chunkedを自動でほどき、本文終端で即座に戻る
  int received = sink.length();

  if (written < 0) {
    Serial.printf("Download error: %d (%s)\n", written, http.errorToString(written).c_str());
  }
  http.end();

  Serial.printf("Downloaded: %d bytes in %lums\n", received, millis() - startTime);

  if (received == 0) {
    Serial.println("No data received");
    free(mp3data);
    return;
  }

    // PSRAMから再生
    AudioFileSourcePROGMEM* src = new AudioFileSourcePROGMEM(mp3data, received);
    AudioOutputM5Speaker* out = new AudioOutputM5Speaker();
    AudioGeneratorMP3* mp3 = new AudioGeneratorMP3();

    out->SetRate(24000);
    if (!mp3->begin(src, out)) Serial.println("mp3->begin failed");

    unsigned long t0 = millis();
    while (mp3->isRunning()) {
      if (!mp3->loop()) mp3->stop();
      delay(1);
    }
    Serial.printf("再生ループ: %lums\n", millis() - t0);

    // ★キューに残った音が鳴り終わるまで待つ(delete out より前に!)
    while (M5.Speaker.isPlaying()) { delay(10); }

    delete mp3;
    delete out;      // ← ここで PCM バッファが消えるので、待ってからでないと音が切れる
    delete src;
    free(mp3data);
    M5.Speaker.end();
    Serial.println("Done!");

  } else {
    Serial.printf("HTTP error: %d\n", code);
    http.end();
  }

  delay(3000);
  pixels.clear();
  pixels.show();
  Serial.println("LED OFF");
  delay(100);
  ESP.restart();
}

void loop() {
}

Credits

Kazuhiro Hamaya
2 projects • 3 followers

Comments