![[小ネタ]水が配達されるボタンを作ってみた](https://devio2024-media.developers.io/image/upload/f_auto,q_auto,w_3840/f_auto,q_auto/v1793766066/eyecatch/d98hwz5zouowniez0p1e.jpg)
[小ネタ]水が配達されるボタンを作ってみた
水が配達されるボタンを作ってみた
某国の大統領執務室にはダイエットコーラが出てくるボタンが存在するそうです。
ロマンがあるボタンですね。
ところ変わってクラスメソッドでは水とコーヒーは飲み放題です。
水については、ウォーターサーバーで提供されており、最後のボトル利用者がSlackにて追加ボトルを依頼する運用となっています。
先日、追加ボトルの依頼を失念する失態を犯しました。
反省と再発防止を兼ねて、水が配達されるボタンを作成しました。
かつて存在した「Amazon Dash Button」
ボタンを押すだけで日用品が届く、というと「Amazon Dash Button」を思い出す方も多いのではないでしょうか。
日本では2016年12月5日に、プライム会員向けに500円(税込)で発売されました。初回の注文代金が500円引きになるため、実質無料で手に入ったことも話題になりました。
使い方はとてもシンプルです。
- スマホアプリでボタンをWi-Fiに接続する
- 洗剤やシャンプー、飲料などが切れそうになったら、ボタンを押す
残念ながら、Amazon Dash Buttonは2019年2月28日に販売を終了しています。
今回作るのは、この「押すだけで補充が頼める」体験を、ウォーターサーバーのために再現するものです。
今回使うのは「M5StickS3」
ボタンの本体には、M5Stack社の「M5StickS3」を使いました。

M5StickS3は、手のひらに収まる小型のマイコンデバイスです。特徴は次のとおりです。
| 項目 | 内容 |
|---|---|
| SoC | ESP32-S3(デュアルコア、240MHz) |
| Flash | 8MB |
| 通信 | 2.4GHz Wi-Fi |
| ディスプレイ | 1.14インチLCD(135x240) |
| ボタン | 前面と側面 |
| 音声 | マイク+スピーカー |
| バッテリー | 内蔵(250mAh) |
| 電源・書き込み | USB Type-C |
| 開発環境 | Arduino / UiFlow2 / ESP-IDF / PlatformIO |
| その他 | 拡張インターフェース / 赤外線送受信 / 6軸姿勢センサ |
ボタン、画面、スピーカー、Wi-Fiがひとつにまとまっているため、「押す」「状態を見る」「結果を音で知らせる」「ネットワークにつなぐ」が、追加の部品なしで作れます。今回は、Arduinoの開発環境でプログラムを書きました。
注意点
- Wi-Fiは2.4GHzのみの対応です。
- 背面がマグネットになっていますが、力が弱いためUSBケーブルを接続するとケーブルの重みで落ちます。
- バッテリーが搭載されていますが、今回の利用状況では1時間ほどでバッテリー切れとなります。常時ケーブル給電が必須です。
- 昨今の事情により値段がお高めです。
不満点を記述してしまいましたが、シンプルなIoTボタンという意味ではとても良いです。
全体の仕組み
仕組みは、次の流れです。
[ボタン押下] → HTTPS POST → Slackワークフロー実行(Webhookトリガー) → 事務の方がボトル配達を発注 → 水配達
M5StickでやっていることはSlackのワークフローをキックするだけです。
- M5StickS3は、オフィスのWi-Fiに接続して待機します。
- ボタンを1回押すと、画面に「もう一度押すと送信」と表示されます。(ポカよけ)
- 5秒以内にもう一度押すと、SlackのWebhook URLに、HTTPSでPOSTします。
- Slackのワークフローが起動し、追加ボトルの依頼がSlackに通知されます。
- 成功・失敗は、画面とブザーで知らせます。
プログラムソース
プログラムは、Arduino向けのスケッチ1本(water_button.ino)です。
ソースの冒頭にある WIFI_SSID、WIFI_PASSWORD、WEBHOOK_URL の3つに、ご自身の環境の値を直接入力してください。
// 水ボタン: M5StickS3のAボタンを二度押しすると、Slackワークフロー(Webhookトリガー)を起動する
#include <M5Unified.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <time.h>
// ★ご自身の環境の値を入力してください
#define WIFI_SSID "ここにWi-FiのSSIDを入力"
#define WIFI_PASSWORD "ここにWi-Fiのパスワードを入力"
#define WEBHOOK_URL "ここにSlackワークフローのWebhook URLを入力"
// 最終実行時刻を保存するNVS(不揮発メモリ)の名前空間
static const char *NVS_NAMESPACE = "waterbtn";
// ESP32のSDKに組み込まれているルート証明書バンドル(HTTPSのサーバー証明書の検証に使う)
extern const uint8_t x509_crt_bundle_start[] asm("_binary_x509_crt_bundle_start");
extern const uint8_t x509_crt_bundle_end[] asm("_binary_x509_crt_bundle_end");
// ---- 動作の設定値 ----
static const uint32_t CONFIRM_TIMEOUT_MS = 5000; // 1回目の押下から、2回目を待つ時間
static const int MAX_ATTEMPTS = 3; // 送信の試行回数(リトライ含む)
static const uint32_t RETRY_INTERVAL_MS = 2000; // リトライの間隔
static const uint32_t HTTP_TIMEOUT_MS = 10000; // 1回の送信のタイムアウト
static const uint32_t RESULT_SUCCESS_MS = 3000; // 成功画面の表示時間
static const uint32_t RESULT_FAILURE_MS = 8000; // 失敗画面の表示時間
static const uint32_t WIFI_RETRY_MS = 10000; // Wi-Fi切断時の再接続の間隔
static const uint32_t NTP_RETRY_MS = 30000; // 時刻が取れないときの再試行の間隔
static const uint32_t NTP_RESYNC_MS = 6UL * 3600 * 1000; // 時刻の再同期の間隔(6時間)
static const uint32_t ALERT_SECONDS = 72UL * 3600; // 最終実行からこの時間は注意喚起の色にする
static const time_t VALID_TIME_MIN = 1700000000; // これより大きければ、時刻が同期済みとみなす
static const uint8_t SPEAKER_VOLUME = 200; // ブザーの音量(0-255)
static const uint32_t FAIL_TONE_COUNT = 5; // 失敗時のブザーの回数
static const uint32_t FAIL_TONE_INTERVAL_MS = 220; // 失敗時のブザーの間隔
// ---- 画面の設定値 ----
static const uint8_t BRIGHTNESS_NORMAL = 128; // 通常時の明るさ
static const uint8_t BACKLIGHT_PIN = 38; // バックライトのGPIO(M5StickS3)
// 待機中の暗さ(0-511のPWMのデューティ)。標準のsetBrightnessでは暗くできる下限があるため、直接指定する
static const uint32_t BACKLIGHT_DIM_DUTY = 13;
static const uint32_t BATTERY_REFRESH_MS = 30000; // バッテリー残量の更新間隔
static const uint16_t COLOR_NORMAL =TFT_BLACK;
static const uint16_t COLOR_ALERT = 0xFEA0; // 黄色: 72時間以内(依頼済みの注意喚起)
static const uint16_t COLOR_CONFIRM = 0x0212; // 青: 確認待ち
static const uint16_t COLOR_SUCCESS = 0x0440; // 緑: 成功
static const uint16_t COLOR_FAILURE = 0xB000; // 赤: 失敗
// 画面の状態
enum Screen { SCR_CONNECTING, SCR_RECONNECTING, SCR_TIME_SYNC, SCR_STANDBY, SCR_CONFIRM, SCR_SENDING, SCR_SUCCESS, SCR_FAILURE };
// ---- 実行中の状態 ----
static Preferences prefs;
static uint32_t lastSuccess = 0; // 最後に送信が成功した時刻(UNIX時刻)
static bool wifiEverConnected = false; // 一度でもWi-Fiにつながったか(「接続中」と「再接続中」の出し分け)
static bool wasConnected = false;
static bool timeSynced = false; // NTPで時刻を取得済みか
static uint32_t lastWifiRetry = 0;
static uint32_t lastNtpStart = 0;
static uint32_t confirmUntil = 0; // 確認待ちの期限
static bool confirming = false; // 1回目が押されて、確認待ちの状態か
static Screen resultScreen = SCR_STANDBY; // 成功・失敗画面の表示中は、その画面
static uint32_t resultUntil = 0;
static String resultDetail;
static String lastDrawKey; // 直前に描いた内容(同じ内容の再描画を避ける)
static int lastDim = -1;
static uint32_t failTonesLeft = 0; // 失敗ブザーの残り回数
static uint32_t nextToneAt = 0;
static String batteryText;
static uint32_t nextBatteryAt = 0;
// millis()の桁あふれを考慮して、期限に達したかを判定する
static bool reached(uint32_t now, uint32_t deadline) {
return (int32_t)(now - deadline) >= 0;
}
// 「最終実行 MM/DD HH:MM N時間前」の表示用文字列
static String lastRunText() {
if (lastSuccess == 0) return "未実行";
time_t t = lastSuccess;
struct tm tm;
localtime_r(&t, &tm);
time_t now = time(nullptr);
long hours = now > (time_t)lastSuccess ? (long)((now - lastSuccess) / 3600) : 0;
char buf[64];
snprintf(buf, sizeof(buf), "最終実行\n%02d/%02d %02d:%02d\n%ld時間前", tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, hours);
return String(buf);
}
// 最終実行から72時間以内か(背景色を変えて注意喚起するか)
static bool withinAlertWindow() {
if (lastSuccess == 0) return false;
time_t now = time(nullptr);
return now < (time_t)lastSuccess || (uint32_t)(now - lastSuccess) <= ALERT_SECONDS;
}
static const int MARGIN_X = 4;
// 画面の幅に収まるように、日本語も含めて文字単位で折り返して描画する
static int drawWrapped(const String &text, int y, float size) {
auto &d = M5.Display;
d.setTextSize(size);
const int lineH = d.fontHeight();
const int maxW = d.width() - MARGIN_X * 2;
String line;
auto flush = [&]() {
d.setCursor(MARGIN_X, y);
d.print(line);
y += lineH;
line = "";
};
for (unsigned i = 0; i < text.length();) {
// UTF-8の先頭バイトから、1文字のバイト数を求める
unsigned char c = text[i];
unsigned n = c < 0x80 ? 1 : c >= 0xF0 ? 4 : c >= 0xE0 ? 3 : 2;
String ch = text.substring(i, i + n);
i += n;
if (ch == "\n") {
flush();
continue;
}
if (d.textWidth((line + ch).c_str()) > maxW) {
// 英数字は、直前のスペースで折り返す
int sp = line.lastIndexOf(' ');
if (sp > 0) {
String rest = line.substring(sp + 1);
line = line.substring(0, sp);
flush();
line = rest;
} else {
flush();
}
if (ch == " ") continue;
}
line += ch;
}
if (line.length()) flush();
return y;
}
// バックライトの明るさを切り替える(状態が変わったときだけ設定する)
static void setBacklightOnce(bool dim) {
if (lastDim == dim) return;
lastDim = dim;
if (dim) {
ledcWrite(BACKLIGHT_PIN, BACKLIGHT_DIM_DUTY);
} else {
M5.Display.setBrightness(BRIGHTNESS_NORMAL);
}
}
// 画面を描画する。standbyがtrueのときは、「水ボタン」を大きく中央に出して暗くする
static void draw(uint16_t bg, uint16_t fg, const String &main, const String &sub, bool standby = false) {
setBacklightOnce(standby);
// 前回と同じ内容なら描き直さない(ちらつき防止)
String key = String(bg) + "|" + main + "|" + sub;
if (key == lastDrawKey) return;
lastDrawKey = key;
auto &d = M5.Display;
d.startWrite();
d.fillScreen(bg);
d.setFont(&lgfx::fonts::lgfxJapanGothic_16);
d.setTextColor(fg, bg);
d.setTextWrap(false, false);
if (standby) {
d.setTextSize(2.0f);
d.setTextDatum(middle_center);
d.drawString(main, d.width() / 2, d.height() / 2);
d.setTextDatum(top_left);
drawWrapped(sub, d.height() / 2 + 30, 1.0f);
} else {
int y = drawWrapped(main, 26, 1.5f);
drawWrapped(sub, y + 12, 1.0f);
}
d.endWrite();
}
// バッテリー残量の表示用文字列(30秒ごとに更新)
static String batteryStatusText() {
uint32_t now = millis();
if (batteryText.length() == 0 || reached(now, nextBatteryAt)) {
int level = M5.Power.getBatteryLevel();
if (level < 0 || level > 100) {
batteryText = "電池 --";
} else {
batteryText = "電池 " + String(level) + "%";
if (M5.Power.isCharging() == m5::Power_Class::is_charging) batteryText += "\n充電中";
}
nextBatteryAt = now + BATTERY_REFRESH_MS;
}
return batteryText;
}
// 状態に応じた画面を表示する
static void render(Screen s, const String &sub = "") {
switch (s) {
case SCR_CONNECTING: draw(COLOR_NORMAL, TFT_WHITE, "接続中", "Wi-Fi"); break;
case SCR_RECONNECTING: draw(COLOR_NORMAL, TFT_WHITE, "再接続中", "Wi-Fi切断"); break;
case SCR_TIME_SYNC: draw(COLOR_NORMAL, TFT_WHITE, "時刻同期中", ""); break;
case SCR_STANDBY: {
bool alert = withinAlertWindow();
draw(alert ? COLOR_ALERT : COLOR_NORMAL, alert ? TFT_BLACK : TFT_WHITE, "水ボタン", batteryStatusText(), true);
break;
}
case SCR_CONFIRM: draw(COLOR_CONFIRM, TFT_WHITE, "もう一度\n押すと送信", lastRunText()); break;
case SCR_SENDING: draw(COLOR_NORMAL, TFT_WHITE, "送信中", sub); break;
case SCR_SUCCESS: draw(COLOR_SUCCESS, TFT_WHITE, "送信成功", lastRunText()); break;
case SCR_FAILURE: draw(COLOR_FAILURE, TFT_WHITE, "送信失敗", sub); break;
}
}
// NTPによる時刻同期を開始する(日本時間)
static void startNtp() {
configTzTime("JST-9", "ntp.nict.jp");
lastNtpStart = millis();
}
// SlackのWebhookにPOSTする。成功ならtrue、失敗なら理由をdetailに入れてfalseを返す
static bool postWebhook(const String &payload, String &detail) {
WiFiClientSecure client;
client.setCACertBundle(x509_crt_bundle_start, x509_crt_bundle_end - x509_crt_bundle_start);
HTTPClient http;
http.setTimeout(HTTP_TIMEOUT_MS);
if (!http.begin(client, WEBHOOK_URL)) {
detail = "接続初期化エラー";
return false;
}
http.addHeader("Content-Type", "application/json");
int code = http.POST(payload);
if (code <= 0) {
detail = "通信エラー: " + HTTPClient::errorToString(code);
http.end();
return false;
}
String body = http.getString();
http.end();
body.replace(" ", "");
// 成功の定義: HTTP 200 かつ レスポンスが {"ok":true}
if (code == 200 && body.indexOf("\"ok\":true") >= 0) return true;
detail = "HTTP " + String(code);
if (code == 200) detail += " 応答不正";
return false;
}
// 送信する。失敗したら、間隔をあけて最大MAX_ATTEMPTS回まで繰り返す
static void sendWithRetry() {
String detail;
// 変数を定義していないワークフローなので、空のJSONを送る
const String payload = "{}";
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
render(SCR_SENDING, String(attempt) + "回目");
if (postWebhook(payload, detail)) {
// 成功したときだけ、最終実行時刻を更新して保存する
lastSuccess = (uint32_t)time(nullptr);
prefs.putUInt("last", lastSuccess);
resultScreen = SCR_SUCCESS;
resultUntil = millis() + RESULT_SUCCESS_MS;
M5.Speaker.tone(7000, 150); // 成功音: 高い音を1回
return;
}
if (attempt < MAX_ATTEMPTS) delay(RETRY_INTERVAL_MS);
}
resultDetail = detail;
resultScreen = SCR_FAILURE;
resultUntil = millis() + RESULT_FAILURE_MS;
failTonesLeft = FAIL_TONE_COUNT;
nextToneAt = millis();
}
// 失敗音: 低い音をFAIL_TONE_COUNT回鳴らす(待たずに、loop()から少しずつ進める)
static void updateTones() {
if (failTonesLeft == 0) return;
uint32_t now = millis();
if (!reached(now, nextToneAt)) return;
M5.Speaker.tone(500, 140);
failTonesLeft--;
nextToneAt = now + FAIL_TONE_INTERVAL_MS;
}
void setup() {
auto cfg = M5.config();
M5.begin(cfg);
M5.Display.setRotation(0); // 縦向き
setBacklightOnce(false);
M5.Speaker.setVolume(SPEAKER_VOLUME);
// 起動時にスピーカーを初期化しておく(最初の音が欠けるのを防ぐ)
M5.Speaker.begin();
// 前回までの最終実行時刻を読み出す
prefs.begin(NVS_NAMESPACE, false);
lastSuccess = prefs.getUInt("last", 0);
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
lastWifiRetry = millis();
render(SCR_CONNECTING);
}
void loop() {
M5.update();
uint32_t now = millis();
// Wi-Fiが切れていたら、一定間隔で再接続する
bool connected = WiFi.status() == WL_CONNECTED;
if (connected) {
wifiEverConnected = true;
if (!wasConnected) startNtp();
} else if (reached(now, lastWifiRetry + WIFI_RETRY_MS)) {
WiFi.disconnect();
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
lastWifiRetry = now;
}
wasConnected = connected;
// 時刻の同期状態を確認し、必要なら同期をやり直す
if (connected) {
if (!timeSynced && time(nullptr) > VALID_TIME_MIN) timeSynced = true;
if (!timeSynced && reached(now, lastNtpStart + NTP_RETRY_MS)) startNtp();
if (timeSynced && reached(now, lastNtpStart + NTP_RESYNC_MS)) startNtp();
}
updateTones();
bool pressed = M5.BtnA.wasPressed();
// 成功・失敗画面の表示中は、その画面を出し続ける(この間のボタンは無視)
if (resultScreen != SCR_STANDBY) {
if (reached(now, resultUntil)) {
resultScreen = SCR_STANDBY;
} else {
render(resultScreen, resultDetail);
delay(10);
return;
}
}
// Wi-Fiにつながっていない間と、時刻が未同期の間は、送信させない
if (!connected) {
confirming = false;
render(wifiEverConnected ? SCR_RECONNECTING : SCR_CONNECTING);
delay(10);
return;
}
if (!timeSynced) {
confirming = false;
render(SCR_TIME_SYNC);
delay(10);
return;
}
// 確認待ちのまま時間が過ぎたら、待機に戻す
if (confirming && reached(now, confirmUntil)) confirming = false;
// 二度押し: 1回目で確認待ちにし、確認待ち中の2回目で送信する(誤送信の防止)
if (pressed) {
if (!confirming) {
confirming = true;
confirmUntil = now + CONFIRM_TIMEOUT_MS;
} else {
confirming = false;
sendWithRetry();
return;
}
}
render(confirming ? SCR_CONFIRM : SCR_STANDBY);
delay(10);
}
ソースのポイント
- 状態の管理: 接続中、時刻同期中、待機、確認待ち、送信中、成功、失敗の状態を、
loop()の中で切り替えています。送信中以外は待たずに回すので、ボタンの入力やWi-Fiの再接続が止まりません。 - 二度押し: 1回目の押下で確認待ちにして、5秒以内の2回目で
sendWithRetry()を呼びます。5秒を過ぎると、待機に戻ります。 - リトライ: 送信に失敗したら、2秒おきに最大3回まで試行します。それでも失敗したら、失敗表示と低音5連のブザーで知らせます。
- 成功の判定: HTTPステータスが200で、レスポンスが
{"ok":true}のときを成功としています。ワークフローの中のステップが成功したかどうかは、ボタン側からは分かりません。 - TLS: ESP32のSDKに組み込まれているルート証明書を使って、サーバー証明書を検証しています。
- 時刻: NTPで時刻を同期し、日本時間で扱います。同期が終わるまでは、送信できません。
- 最終実行日時: 送信に成功した時刻を、本体のNVS(不揮発メモリ)に保存します。再起動しても残ります。
- 待機中の画面: 待機中はバックライトを暗くして、「水ボタン」とバッテリー残量だけを表示します。最後の依頼から72時間以内は、背景色が黄色になります。
ビルド・デプロイ手順
ここでは、arduino-cli を使った手順を紹介します。Arduino IDEでも、同様に作れます。
事前準備: Slackのワークフロー
- Slackのワークフロービルダーで、開始方法を「Webhookから」にしたワークフローを作る。
- ワークフローのステップに、依頼を通知する処理を追加する。
- ワークフローを公開して、Webhook URLを控える。
今回は、デバイスから値を渡さないので、変数の定義は不要です。なお、変数を定義すると、そのキーがリクエストにないときにワークフローが失敗します。
開発環境の準備
以下は、macOSの例です。
brew install arduino-cli
arduino-cli config init
arduino-cli config add board_manager.additional_urls https://static-cdn.m5stack.com/resource/arduino/package_m5stack_index.json
arduino-cli core update-index
arduino-cli core install m5stack:esp32
arduino-cli lib install M5Unified M5GFX
公式ドキュメントの動作要件は、ボードパッケージが3.2.5以上、M5Unifiedが0.2.12以上、M5GFXが0.2.18以上です。今回は、ボードパッケージ3.3.10、M5Unified 0.2.27、M5GFX 0.2.34で動作を確認しています。
ビルド
water_button フォルダに water_button.ino を置き、冒頭の3つの値(SSID、パスワード、Webhook URL)を入力してから、ビルドします。ボードは M5StickS3 です。
arduino-cli compile --fqbn m5stack:esp32:m5stack_sticks3 water_button
書き込み
- M5StickS3の側面にあるリセットボタンを、約2秒長押しします。内部の緑LEDが点滅したら、ダウンロードモードに入っています。
- USBケーブルでPCにつないで、ポートを確認します。
- 書き込みます。
arduino-cli board list
arduino-cli compile --upload -p <ポート> --fqbn m5stack:esp32:m5stack_sticks3 water_button
書き込みが終わると、自動で再起動します。
発注してみた
実際にボタンを押してみます。
- 待機中は、暗い画面に「水ボタン」と表示されています。

- ボタンを1回押すと、画面が点灯して、「もう一度押すと送信」と表示されます。

- もう一度押すと、送信されます。
- 成功すると、緑の画面と成功音で知らせます。
- Slackのワークフローが起動し、依頼が通知されます。

- 事務の方がボトルを発注し、水が配達されます。
おわりに
ということで、水が届くボタンを作ってみた でした。
今は水しか出てきませんが、そのうちコーラやランチも出てくるのではないかと期待しています。
余談ですがクラスメソッドでは「めそ庫」という仕組みがあり、条件が揃えば常備されているアルコール・ソフトドリンクが無料で飲めます。
また、拠点によってはランチが提供されています(私が所属する名古屋オフィスではまだ未実施)
ボタン一つで〜 とはいきませんが、とても良い福利厚生が整っています。
ご興味のある方は、弊社の採用担当まで。





