M5StickC PLUS・SORACOM・Alexaでゴミ捨て忘れを防止する

:::note info
この記事は [SORACOM Advent Calendar 2024](https://qiita.com/advent-calendar/2024/soracom) の 17 日目の記事です。16 日目の記事は @keni_w さんの [さようなら #あのボタン](https://qiita.com/keni_w/items/d2ac4f4513dc80efcf15) でした。
:::

## 作ったきっかけ

日常のタスクの中で一番忘れたくないことは何ですか？私はゴミ捨てです。特に燃えるゴミを捨てそびれると残念な気持ちになるだけでなく、家族の信頼関係にも大きな影響がでます。

## 最初に作ったものと感じた課題

我が家には Alexa (Amazon Echo) がいて、タイマーやリマインド、買い物リストでフル活用しています。朝に数回 Alexa に「ゴミ捨てしましたか？」と言ってもらうことにしました。ノーコード万歳。

![image-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/10278523-e332-d07b-326b-ce88d119507e.png)


ゴミ捨て忘れについてはこれで達成したといっても良いかも知れません。ただ、なんだか味気なく感じました。ちゃんとできているときは褒めてもらうことで自己肯定感も上がるのではないかと思い、アドベントカレンダーを機に少し工夫をしてみることにしました。

## 作ったもの

ゴミ箱の重量を定期的にクラウドへ送り、またゴミ捨てをすべきタイミングで重量が減っているかを確認して結果をクラウドに保存することにしました。Alexa は少し後のタイミングでゴミ捨て結果を確認し、状況を発話します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/c0e7feac-6969-dd05-0c75-6286156bc77c.png)

ゴミ箱の重量モニタリングには、[M5StickC PLUS](https://docs.m5stack.com/en/core/m5stickc_plus) と [Scales Kit](https://docs.m5stack.com/en/app/scales_kit) を使いました。クラウドへのデータ保存には [SORACOM Arc](https://users.soracom.io/ja-jp/docs/arc/) と [SORACOM Harvest Data](https://users.soracom.io/ja-jp/docs/harvest/)、ゴミ捨てをしているかの判断には [SORACOM Flux](https://users.soracom.io/ja-jp/docs/flux/) を、ゴミ捨て結果の Alexa 向けのメッセージ格納には [SORACOM Arc のタグ](https://users.soracom.io/ja-jp/docs/air/set-tag/) を使いました。

## M5StickC PLUS から重量を SORACOM Harvest Data へ送る

[Scales Kit](https://docs.m5stack.com/en/app/scales_kit) の概要分には "you can create IoT based Weighing Scale in just a few minutes!" と書いてあったんですが、キッティングや [サンプルプログラム](https://github.com/m5stack/M5StickC-Plus/blob/master/examples/KIT/SCALES_KIT/SCALES_KIT.ino) の動作確認は本当に数分でできて感動しました。
SORACOM Harvest Data へデータを送る際は [WireGuard-ESP32-Arduino](https://github.com/ciniml/WireGuard-ESP32-Arduino) を使用します。

<details><summary>コード</summary>

```cpp
#include <Arduino.h>
#include <ArduinoHttpClient.h>
#include <M5StickCPlus.h>
#include "Client.h"
#include <WiFi.h>
#include <WireGuard-ESP32.h>
#include <ArduinoJson.h>
#include "HX711.h"

#define CONSOLE Serial
#define SEND_BY_TIME

#ifdef SEND_BY_TIME
  #define NEXT_PERIOD 30
#endif

WireGuard wg;

// Wi-Fi設定
char ssid[] = "YOUR_SSID";
char password[] = "YOUR_PASSWORD";

// WireGuard設定
char privateKey[] = "YOUR_PRIVATE_KEY";
IPAddress localIp(0, 0, 0, 0); // 10.23.45.67 の場合は 10, 23, 45, 67 と書く
char publicKey[] = "YOUR_PUBLIC_KEY";
char endpointAddress[] = "xxx.arc.soracom.io"; // 要書き換え
int endpointPort = 11010;

// HX711 related pin Settings.
#define LOADCELL_DOUT_PIN 33
#define LOADCELL_SCK_PIN  32

HX711 scale;

char ERR_MSG_BUF[100] = { '\0' };

String httpRequest(Client& client, String host, int port, String path, String method, String contentType, String requestBody) {
  CONSOLE.println("*** Requesting " + host + " ***");

  HttpClient httpClient(client, host, port);
  int err = httpClient.startRequest(path.c_str(), method.c_str(), contentType.c_str(), requestBody.length(), (const byte*)requestBody.c_str());
  if (err != 0) {
    sprintf(ERR_MSG_BUF, "[Failed to start request; err code %d]\n", err);
    CONSOLE.println(ERR_MSG_BUF);
    httpClient.stop();
    return "";
  }

  String responseBody = httpClient.responseBody();
  return responseBody;
}

void setupWiFiAndWireguard() {
  CONSOLE.print("Connecting to Wi-Fi...");
  bool done = true;
  WiFi.begin(ssid, password);
  while (done)
  {
    CONSOLE.print("WiFi connecting");
    auto last = millis();
    while (WiFi.status() != WL_CONNECTED && last + 5000 > millis())
    {
      delay(500);
      CONSOLE.print(".");
    }
    if (WiFi.status() == WL_CONNECTED)
    {
      done = false;
    }
    else
    {
      CONSOLE.println("retry");
      WiFi.disconnect();
      WiFi.reconnect();
    }
  }
  CONSOLE.println("\nWiFi connected.");

  CONSOLE.println();
  CONSOLE.print("IP address: ");
  CONSOLE.println(WiFi.localIP());
  CONSOLE.print("DNS: ");
  CONSOLE.println(WiFi.dnsIP());

  CONSOLE.println("Adjusting system time...");
  configTime(9 * 60 * 60, 0, "ntp.nict.jp", "ntp.jst.mfeed.ad.jp", "time.google.com");

  unsigned long m;
  struct tm timeInfo;

  m = millis();
  getLocalTime(&timeInfo);
  CONSOLE.printf("getLocalTime() %luMS\n", millis() - m);
  CONSOLE.printf("%04d/%02d/%02d %02d:%02d:%02d\n", timeInfo.tm_year + 1900, timeInfo.tm_mon + 1, timeInfo.tm_mday, timeInfo.tm_hour, timeInfo.tm_min, timeInfo.tm_sec);

  CONSOLE.println("Connected. Initializing WireGuard...");
  wg.begin(
      localIp,
      privateKey,
      endpointAddress,
      publicKey,
      endpointPort);
  delay(5000);
}

void setup() {
  M5.begin();

  scale.begin(LOADCELL_DOUT_PIN, LOADCELL_SCK_PIN);
  // The scale value is the adc value corresponding to 1g
  scale.set_scale(27.61f);  // set scale
  scale.tare();             // auto set offset

  CONSOLE.println("*** Setting up Wi-Fi & WireGuard (SORACOM Arc) ***");
  setupWiFiAndWireguard();

  CONSOLE.println("*** Setup completed ***");
}

char info[100];

void loop()
{
  CONSOLE.println("Execution start!");

  float weight = scale.get_units(10);
  int truncatedWeight = (int)weight
  JsonDocument payload;
  payload["weight_gram"] = truncatedWeight;
  Serial.printf("%.2f", truncatedWeight);
  sprintf(info, "%.2f", truncatedWeight);

  String payloadString;
  serializeJson(payload, payloadString);
  CONSOLE.println(">> Payload: " + payloadString);
  CONSOLE.println();

  WiFiClient client;
  httpRequest(client, "uni.soracom.io", 80, "/", "POST", "application/json", payloadString);

#ifdef SEND_BY_TIME
  CONSOLE.println("Waiting " + String(NEXT_PERIOD) + " seconds to next send period...");
  delay(NEXT_PERIOD * 1000);
#endif
}
```
</details>

こんな感じで設置しました。まずはこちらは我が家のゴミ箱です。棚に収まるタイプとなっています。

![IMG_1654.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/1d1fad2c-d9d3-2e6e-1253-ab9c0061cf10.jpeg)


ゴミ箱の下に、Scale kit と M5StickC PLUS を接続したセットを置いています。板とかを付けたほうが良さそうな気もしますが、一旦はこのように運用してみています。

![IMG_1655.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/255738ee-6248-044c-4bec-8b3dcff24b55.jpeg)

その後、Scale Kit の Weight Unit や M5StickC PLUS については両面テープで棚の裏に貼り付けました。

![IMG_1657.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/373e20b8-c04e-7d42-5290-348cbc60dbae.jpeg)


USB ケーブルの配線が一番悩ましかったです。右側は冷蔵庫。

![IMG_1658.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/9497bcd8-6d27-56f1-d52f-ea408a1a09d2.jpeg)


こんな感じでデータが取れました！

![Screenshot 2024-12-14 at 20.35.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/f9a117de-8d24-5699-5b0a-e7706c742e64.png)

## SORACOM Flux の設定

最近 SORACOM API アクションがリリースされたので、せっかくなので SORACOM Harvest Data のデータを SORACOM Flux から取ってみることにしました。

https://users.soracom.io/ja-jp/docs/flux/action-soracom-api/

アプリの全体像はこの様になっています。

![Screenshot 2024-12-14 at 20.47.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/600ef80b-f915-bbd5-8137-8f120e3eadd7.png)

以下のような流れです。

1. タイマーアクションで 5 分毎に起動
2. Condition で 8:25 - 8:45 のときだけ後続を実行
3. SORACOM API アクションで対象 SIM についてSORACOM Harvest Data のデータ取得 ([getDataEntries](https://users.soracom.io/ja-jp/tools/api/reference/#/DataEntry/getDataEntries) API)
4. AI アクションでデータをもとにゴミ捨て結果の判別、できていたら褒める、できていなければゴミ捨てを促すメッセージを作成
5. SORACOM API アクションで生成したメッセージを SIM のタグへ保存 ([putSimTags](https://users.soracom.io/ja-jp/tools/api/reference/#/Sim/putSimTags) API)

Harvest Data を取得するときは `/v1/data/Sim/{SIM_ID}?sort=desc&limit=48` のように書いて最新の 48 件を取得しています (AI へのプロンプトの文字数が4096 文字で、1 データあたり 80 文字程度、その他のプロンプトに 171 文字使っているため。171 + 80 * 48 = 4011)。この文字数制限がかなり難しいです。すべてのデータに `contentType` も入っているので。重量データだけを並べられていればもっと多くの件数のデータを入れられそうです。

AI へのプロンプトはこのように書いています。

```markdown
以下のデータは過去1時間におけるゴミ箱の重量(weight_gram)の推移。1分間に1000g以上の減少があれば、ごみ捨てがされたといえる。

${payload}

データからごみ捨てがされた様子が見られれば、ごみ捨てをできたことを褒める言葉を返して。そのような様子が見られなければ、ごみ捨てをするようアドバイスをして。メッセージは50文字以内で。
```

ここでメッセージを 50 文字以内にしているのは、SIM のタグに設定できるのが 512 バイト、かつ URL エンコード (パーセントエンコーディング) 後であるためです。また長すぎるメッセージを Alexa に喋らせるのも...というのもあります。

SIM へタグを保存するときは以下のような body を PUT しています。

```json
[
  {
    "tagName": "lastUpdatedTime",
    "tagValue": "${now()}"
  },
  {
    "tagName": "message",
    "tagValue": "${payload.output.text}"
  }
]
```

## Alexa の設定

Alexa についてははじめて触ったので現在の主流の方法であるかはわからないのですが、ひとまずチュートリアルのとおりにまずカスタムスキルを作成しました。

https://developer.amazon.com/ja-JP/alexa/alexa-skills-kit/get-deeper/tutorials-code-samples/build-an-engaging-alexa-skill

ベータテストで自身の Alexa アプリから利用できるようにし、自身の Amazon Echo で使えるようにしました。ここで注意なのが、ベータテストで利用する場合 3 ヶ月しか使えない点です。

その後、カスタムスキルから SORACOM API を呼び出せるように工夫しました。今回は追加のライブラリのインストールを不要としない https モジュールで書きました。

YOUR_AUTH_KEY、YOUR_AUTH_KEY_ID、YOUR_SIM_ID は自身のものに書き換えてください。ちゃんとやるなら自前の AWS Lambda を立てて AWS Secrets Manager を使うのが良いでしょう。

Auth Key (認証キー) は getSim API だけを許可する [SAM ユーザー](https://users.soracom.io/ja-jp/docs/sam/create-sam-user/)を作成して払い出しました。

<details><summary>コード</summary>

`js
const Alexa = require("ask-sdk-core");
const https = require("https"); // httpsモジュールを利用

// SORACOM API 認証情報
const authPayload = JSON.stringify({
  authKey:
    "YOUR_AUTH_KEY",
  authKeyId: "YOUR_AUTH_KEY_ID",
});

// 1つ目のAPI：認証
function authenticate() {
  return new Promise((resolve, reject) => {
    const options = {
      hostname: "g.api.soracom.io", // JP カバレッジの場合は api.soracom.io
      port: 443,
      path: "/v1/auth",
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Accept: "application/json",
        "Content-Length": authPayload.length,
      },
    };

    const req = https.request(options, (res) => {
      let data = "";

      res.on("data", (chunk) => {
        data += chunk;
      });

      res.on("end", () => {
        try {
          const parsedData = JSON.parse(data);
          resolve(parsedData); // apiKeyとtokenを返す
        } catch (error) {
          reject(`Failed to parse auth response: ${error.message}`);
        }
      });
    });

    req.on("error", (error) => {
      reject(`Error in auth request: ${error.message}`);
    });

    req.write(authPayload);
    req.end();
  });
}

// 2つ目のAPI：SIM情報取得
function fetchSIMInfo(apiKey, token, simId) {
  return new Promise((resolve, reject) => {
    const options = {
      hostname: "g.api.soracom.io",
      port: 443,
      path: `/v1/sims/${simId}`,
      method: "GET",
      headers: {
        Accept: "application/json",
        "X-Soracom-API-Key": apiKey,
        "X-Soracom-Token": token,
      },
    };

    const req = https.request(options, (res) => {
      let data = "";

      res.on("data", (chunk) => {
        data += chunk;
      });

      res.on("end", () => {
        try {
          const parsedData = JSON.parse(data);
          resolve(parsedData.tags.message); // tags.messageを返す
        } catch (error) {
          reject(`Failed to parse SIM info response: ${error.message}`);
        }
      });
    });

    req.on("error", (error) => {
      reject(`Error in SIM info request: ${error.message}`);
    });

    req.end();
  });
}

// メイン関数
async function getTagsMessage() {
  try {
    // 1つ目のAPIを呼び出し、認証情報を取得
    const authResponse = await authenticate();
    const { apiKey, token } = authResponse;

    // 2つ目のAPIを呼び出し、tags.messageを取得
    const simId = "YOUR_SIM_ID";
    const message = await fetchSIMInfo(apiKey, token, simId);

    console.log("Tags Message:", message);
    return message;
  } catch (error) {
    console.error("Error:", error);
    return null;
  }
}

const LaunchRequestHandler = {
  canHandle(handlerInput) {
    return (
      Alexa.getRequestType(handlerInput.requestEnvelope) === "LaunchRequest"
    );
  },
  async handle(handlerInput) {
    let speakOutput = "今日は燃えるゴミの日です。";
    try {
      speakOutput += await getTagsMessage(); // 非同期でIPを取得
    } catch (error) {
      console.log("Error:", error);
    }

    return handlerInput.responseBuilder.speak(speakOutput).getResponse();
  },
};
`
</details>

Alexa 側では、スマートフォンアプリで定型アクションを作成します。8:10、8:20、8:30、8:40 にカスタムスキルを実行するようにしています。(コード以外はチュートリアルのままなので、名称も「ケーキタイム」になっていました...。)

![IMG_D558ECE8A627-1.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125247/075fddc7-dd81-ac3a-76a7-9b4b4e07b51b.jpeg)


## 費用感

SORACOM Arc と SORACOM Harvest Data には無料枠があり、今回のユースケースでは無料枠に収まりそうです。正確な情報は料金ページを確認してください。

https://soracom.jp/services/arc/

https://soracom.jp/services/harvest/

SORACOM Flux については AI のクレジットに上限があるので注意が必要です。

https://soracom.jp/services/flux/

Alexa のカスタムスキルも Alexa-hosted スキルを用いたので料金はかからないようです。

https://developer.amazon.com/ja-JP/alexa/alexa-skills-kit/new/aws-promotional-credits

## 終わりに

M5StickC PLUS や Alexa のカスタムスキルははじめて使ったのですが、進化がすごいと感じました。一昔思い描いていた IoT が非常に簡単にできるようになったと感じます。またデータを生成 AI に渡してメッセージを生成してもらうのはレスポンスに変化が生まれて面白かったです。

実は完成したのが日曜日、ごみ捨てが月曜日、アドベントカレンダーの締切が火曜日だったので、このソリューションの良さがまだ十分に評価できていません。また機会があれば使ってみた感想などを発信しようと思います。