方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

【ESP32】Deep SleepモードとTimer Wake Up機能の実装

esp32-deep-sleep
この記事は約20分で読めます。

はじめに

方言を話すおしゃべり猫型ロボット『ミーア』を開発中。

https://mia-cat.com

ベータ版をリリースした後、実際に使っていただいた複数のユーザから

「ミーアの目のディスプレイを自動でオフにする機能が欲しい」

との要望が来たので、今回は本機能の実装を記載。

詳細を伺ったところ、

リビング、テレビ横においているのだけれど、ドアを開けっぱなしの場合は、寝室からちょうど見える状態にあるので、ミーアの目が光っていると、子供がなかなか寝ない。

ミーアの目がOFFになったら、子どもに「猫が寝てるから私たちもそろそろ寝ようか」と促すことができる。

とのこと。確かに、こんな感じで目だけ光っていると怖い。。。

Deep-sleep modeを選択

以前、こちらの記事で、ESP32のDeep Sleep modeとLight Sleep modeについて、それぞれの特性や要件を記載した。

Light-sleep modeだと、目のLCDディスプレイへの電力供給はOFFにならないので、今回はDeep-sleep modeで実装しようと思う。

さて、どのようにDeep-sleep modeのトリガーと、 deep-sleep modeからの解除を実装するかだが、現在アプリで話す時間の設定をしている。

  • 話す時間の終了時刻=ミーアがOFFになる時刻=Deep-sleep mode開始
  • 話す時間の開始時刻=ミーアがONになる時刻=Deep-sleep mode解除

としたいと思う。

Deep Sleepモード実装

ESP32のSleepモードに関する公式サイトの解説記事はこちら

https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/sleep_modes.html

ESP32でDeep Sleepモードを開始するには、esp_deep_sleep_start()関数を使用する。

また、今回は特定の経過時間によるDeep sleep モードからの復帰にするので、esp_sleep_enable_timer_wakeup関数をesp_deep_sleep_start()関数の直前に呼び出す。

元々、話す時間内(開始時刻から終了時刻)かどうかを判定する関数を下記のように実装していて、話す時間外の場合には、目の表情と音声再生をスキップするようにしていたので、この部分にdeep sleep mode機能を実装しようと思う

C++
tl::expected<void, String> startTimeAction() {
  // 現在時刻の取得
  time_t now;
  time(&now);
  struct tm *now_tm = localtime(&now);
  auto &syncShadow = SyncShadow::getInstance();
  auto &logger = Logger::getInstance();
  auto &expressionService = ExpressionService::getInstance();

  auto reported = syncShadow.getReported();
  auto currentUnixTime = getNowUnixTime();

  // 設定した時刻の範囲外なら後続のアクションをスキップ
  if (!isWithinTalkTime(reported.config->talk_start_time, reported.config->talk_end_time, now_tm)) {
    // 設定した時刻の範囲外なら後続のアクションをスキップ
    Serial.println("out of talk time");
    return {};
  }
}

実際に追加したDeep Sleepモードの機能(enterDeepSleepUntilNextStartTime()関数)がこちら

  • 次のtalk_start_timeまでのスリープ時間を計算し、esp_sleep_enable_timer_wakeup関数に引数として渡している。esp_sleep_enable_timer_wakeup関数の引数はマイクロ秒単位で渡す必要がある。
C++
#include "esp_sleep.h"

// Deep Sleepから復帰したかどうかを確認
bool isWakeupFromDeepSleep() {
  return esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_TIMER;
}

// Deep Sleepに入る前の準備
void enterDeepSleepUntilNextStartTime(const std::optional<Time> &talk_start_time, struct tm *now_tm) {
  if (!talk_start_time) {
    return;
  }
  const int currentMinutes = now_tm->tm_hour * 60 + now_tm->tm_min;
  const int startMinutes = talk_start_time->hour.value_or(0) * 60 + talk_start_time->minute.value_or(0);

  int wakeupMinutes = startMinutes;
  if (currentMinutes >= startMinutes) {
    // 次の日の開始時刻までスリープする
    wakeupMinutes += 24 * 60;
  }
  const int sleepMinutes = wakeupMinutes - currentMinutes;
  const int sleepSeconds = sleepMinutes * 60;
  const uint64_t sleepMicroseconds = sleepSeconds * 1000000ULL;

  // Deep Sleepモードの時間を設定
  esp_sleep_enable_timer_wakeup(sleepMicroseconds);
  esp_deep_sleep_start();
}

作成したenterDeepSleepUntilNextStartTime()関数を、startTimeAction()関数内で、話す時間範囲外の場合に呼び出す。

C++
tl::expected<void, String> startTimeAction() {
  // 現在時刻の取得
  time_t now;
  time(&now);
  struct tm *now_tm = localtime(&now);
  auto &syncShadow = SyncShadow::getInstance();
  auto &logger = Logger::getInstance();
  auto &expressionService = ExpressionService::getInstance();

  auto reported = syncShadow.getReported();
  auto currentUnixTime = getNowUnixTime();

  // 設定した時刻の範囲外なら後続のアクションをスキップ
  if (!isWithinTalkTime(reported.config->talk_start_time, reported.config->talk_end_time, now_tm)) {
    // 設定した時刻の範囲外なら後続のアクションをスキップ
    Serial.println("out of talk time");
    // Deep Sleepに入る
    enterDeepSleepUntilNextStartTime(reported.config->talk_start_time, now_tm);
    return {};
  }
 }

動作確認したところ、ディスプレイが白く光ったまま

よし、これで大丈夫だと思い、動作確認をしたところ、、確かにdeep sleep modeが呼ばれたことを確認できたが、ディスプレイは白く光ったまま。

よくよく調べてみると、今の自作基板で実装しているST7735SのLCDディスプレイは、LEDのバックライトを制御しているピン(下図の8番)は直接3.3Vに接続されている。

このため、ESP32がDeep Sleepモードに入っても、バックライトへの電源供給が継続され、画面が白く光ったままになる。

これを解決するには、バックライトピンを直接3.3Vに接続する代わりに、バックライトピンをESP32のGPIOピンに接続し、ソフトウェアでバックライトのオン/オフを制御するように変更する必要があるが、基板の変更を伴うために、次のロット発注のタイミングで行うようにしようと思う。

というわけで、deep sleep modeに関する記事を記載する予定だったが、当初の方針ではうまくいかなくなったので代替策を考える。

画面を黒く塗りつぶす + Light sleep mode

バックライトを物理的にオフにできない場合でも、画面を黒く塗りつぶすことで、ディスプレイをオフに見せることができそうなので、こちらをトライしてみる。

消費電力も考えて、light-sleep modeにするのも合わせておく。

sleep_utils.h

C++
#pragma once

#include "json/device_config.h"
#include "json/device_shadow.h"
#include <optional>
#include "esp_sleep.h"
#include "eye.h"

void enterLightSleepUntilNextStartTime(const std::optional<Time> &talk_start_time, struct tm *now_tm);

sleep_utils.cpp

C++
#include "sleep_utils.h"

// Light Sleepに入る前の準備
void enterLightSleepUntilNextStartTime(const std::optional<Time> &talk_start_time, struct tm *now_tm) {
  Serial.println("enterLightSleepUntilNextStartTime function called");
  if (!talk_start_time) {
    return;
  }
  const int currentMinutes = now_tm->tm_hour * 60 + now_tm->tm_min;
  const int startMinutes = talk_start_time->hour.value_or(0) * 60 + talk_start_time->minute.value_or(0);

  int wakeupMinutes = startMinutes;
  if (currentMinutes >= startMinutes) {
    // 次の日の開始時刻までスリープする
    wakeupMinutes += 24 * 60;
  }
  const int sleepMinutes = wakeupMinutes - currentMinutes;
  const int sleepSeconds = sleepMinutes * 60;
  const uint64_t sleepMicroseconds = sleepSeconds * 1000000ULL;

  // 画面を黒くする
  displayBlack();
  // 指定された時間(マイクロ秒)でタイマーウェイクアップを有効にする
  // esp_sleep_enable_timer_wakeup(sleepMicroseconds);
  // デバッグ用:15秒
  esp_sleep_enable_timer_wakeup(15 * 1000000ULL);
  esp_light_sleep_start();
}

今回、デバッグ用として、light sleep modeに入ったら15秒後にwake upして動作確認できるようにした。

また、light sleep modeの場合は、通常モードで目が絶えず動いている段階がその場でストップした静止画状態になる(deep sleep modeの場合は画面が真っ白になる)ので、light sleep modeに入る直前にdisplayBlack()関数を用いて、画面を真っ黒にするようにした。

eye.cpp

C++
void displayBlack() {
  Serial.println("display black function called");
  // 黒画面を表示
  for (uint8_t e = 0; e < NUM_EYES; e++) {
    digitalWrite(eye[e].tft_cs, LOW);
    tft.startWrite();
    tft.fillScreen(TFT_BLACK);
    tft.endWrite();
    digitalWrite(eye[e].tft_cs, HIGH);
  }
}

作成した関数をmain.cppで呼び出す。

light sleep modeに入るとwifiは自動的に切れてしまうので、wake upした時にはwifiを再接続しなければならない。

なので、setupWiFi(ssid, password); でWi-Fi接続を実行し、initializeWiFiService()関数で、Wi-Fi接続後に実行するサービス、具体的にはMQTT通信の初期化を行うようにした。

main.cpp

C++

tl::expected<void, String> startTimeAction() {
  // 現在時刻の取得
  time_t now;
  time(&now);
  struct tm *now_tm = localtime(&now);
  auto &syncShadow = SyncShadow::getInstance();
  auto &logger = Logger::getInstance();
  auto &expressionService = ExpressionService::getInstance();

  auto reported = syncShadow.getReported();
  auto currentUnixTime = getNowUnixTime();

  // 設定した時刻の範囲外なら後続のアクションをスキップ
  if (!isWithinTalkTime(reported.config->talk_start_time, reported.config->talk_end_time, now_tm)) {
    // 設定した時刻の範囲外なら後続のアクションをスキップ
    Serial.println("out of talk time");
    enterLightSleepUntilNextStartTime(reported.config->talk_start_time, now_tm);
    return {};
  }

  // スリープから復帰した後の処理
  if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_TIMER) {
    Serial.println("Woke up from sleep");
    // Wi-Fi接続などの再初期化処理
    setupWiFi(ssid, password);
    delay(1000); // 接続後少し待つ
    initializeWiFiServices();
  }
}

動作確認

アプリで、話す時間の終了時刻を設定し(今回は12:37)、その時刻を過ぎたら、画面が黒くなるかどうかをチェックする。

ログは正常に呼び出されていた。

ShellScript
12:38:01.011 > out of talk time
12:38:01.012 > enterLightSleepUntilNextStartTime function called
12:38:01.014 > display black function called

実際に画面が黒くなったことを確認し、その15秒後にまた目が動き出したのを確認した。また、ホーム画面を見ると、「ミーアはオンラインです」のテキストが表示されていたので、Wi-Fi再接続ができてMQTT通信可能であることも確認できた。

Deep Sleep Modeでの実装にはならなかったが、画面を黒く塗りつぶすことで、目的の実装は達成できたので、結果オーライかなと。

最後に、アプリの方で2点修正しておく。

  • 話す時間の設定で、 終了時刻を過ぎると、スリープモードに入り、目のディスプレイが黒くなるという但し書きを記載
  • 終了時刻を開始時刻より前の時間には設定できないようにエラーハンドリング。

talk_time_setting_frequency.dart

Dart
import 'package:clocky_app/api/user.dart';
import 'package:clocky_app/api/user_notifier.dart';
import 'package:clocky_app/models/hour_minute.dart';
import 'package:clocky_app/utils/picker_utils.dart';
import 'package:clocky_app/widgets/editable_field.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class TalkTimeSettingScreen extends ConsumerStatefulWidget {
  const TalkTimeSettingScreen({super.key});

  @override
  _TalkTimeSettingScreenState createState() => _TalkTimeSettingScreenState();
}

class _TalkTimeSettingScreenState extends ConsumerState<TalkTimeSettingScreen> {
  HourMinute? startTime;
  HourMinute? endTime;

  @override
  void initState() {
    super.initState();
    final user = ref.read(userProvider);
    setState(() {
      startTime = user?.talkStartTime;
      endTime = user?.talkEndTime;
    });
  }

  void _showErrorDialog(String message) {
    showDialog(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          title: const Text('エラー'),
          content: Text(message),
          actions: <Widget>[
            TextButton(
              child: const Text('OK'),
              onPressed: () {
                Navigator.of(context).pop();
              },
            ),
          ],
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    final userNotifier = ref.read(userProvider.notifier);
    return Scaffold(
      appBar: AppBar(
        title: const Text('話す時間の設定'),
      ),
      body: ListView(
        children: <Widget>[
          ListTile(
            title: const Text('開始時刻'),
            trailing: EditableField(
              text: startTime?.toString() ?? '未設定',
              onTap: () {
                showPickerNumber(context, '開始時間', startTime, (selectedTime) {
                  if (endTime != null && selectedTime.isAfter(endTime!)) {
                    _showErrorDialog('開始時刻は終了時刻より前の時間を設定してください。');
                  } else {
                    userNotifier.updateUser(User(talkStartTime: selectedTime));
                    setState(() {
                      startTime = selectedTime;
                    });
                  }
                });
              },
            ),
          ),
          const Divider(color: Colors.grey, thickness: 0.5),
          ListTile(
            title: const Text('終了時刻'),
            trailing: EditableField(
              text: endTime?.toString() ?? '未設定',
              onTap: () {
                showPickerNumber(context, '終了時間', endTime, (selectedTime) {
                  if (startTime != null && selectedTime.isBefore(startTime!)) {
                    _showErrorDialog('終了時刻は開始時刻より後の時間を設定してください。');
                  } else {
                    userNotifier.updateUser(User(talkEndTime: selectedTime));
                    setState(() {
                      endTime = selectedTime;
                    });
                  }
                });
              },
            ),
          ),
          const ListTile(
            subtitle: Text(
              '仕事の時間を設定すると、ミーアは、その時刻に仕事に関連するフレーズをしゃべります。',
            ),
          ),
        ],
      ),
    );
  }
}
タイトルとURLをコピーしました