[小ネタ]水が配達されるボタンを作ってみた

[小ネタ]水が配達されるボタンを作ってみた

ボタン一つで水の補充が頼める「水配達ボタン」を作ってみました。
2026.11.04

水が配達されるボタンを作ってみた

某国の大統領執務室にはダイエットコーラが出てくるボタンが存在するそうです。
ロマンがあるボタンですね。

ところ変わってクラスメソッドでは水とコーヒーは飲み放題です。
水については、ウォーターサーバーで提供されており、最後のボトル利用者がSlackにて追加ボトルを依頼する運用となっています。

先日、追加ボトルの依頼を失念する失態を犯しました。
反省と再発防止を兼ねて、水が配達されるボタンを作成しました。

かつて存在した「Amazon Dash Button」

ボタンを押すだけで日用品が届く、というと「Amazon Dash Button」を思い出す方も多いのではないでしょうか。

日本では2016年12月5日に、プライム会員向けに500円(税込)で発売されました。初回の注文代金が500円引きになるため、実質無料で手に入ったことも話題になりました。

使い方はとてもシンプルです。

  1. スマホアプリでボタンをWi-Fiに接続する
  2. 洗剤やシャンプー、飲料などが切れそうになったら、ボタンを押す

残念ながら、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軸姿勢センサ

https://docs.m5stack.com/ja/core/StickS3

ボタン、画面、スピーカー、Wi-Fiがひとつにまとまっているため、「押す」「状態を見る」「結果を音で知らせる」「ネットワークにつなぐ」が、追加の部品なしで作れます。今回は、Arduinoの開発環境でプログラムを書きました。

注意点

  • Wi-Fiは2.4GHzのみの対応です。
  • 背面がマグネットになっていますが、力が弱いためUSBケーブルを接続するとケーブルの重みで落ちます。
  • バッテリーが搭載されていますが、今回の利用状況では1時間ほどでバッテリー切れとなります。常時ケーブル給電が必須です。
  • 昨今の事情により値段がお高めです。

不満点を記述してしまいましたが、シンプルなIoTボタンという意味ではとても良いです。

全体の仕組み

仕組みは、次の流れです。

[ボタン押下] → HTTPS POST → Slackワークフロー実行(Webhookトリガー) → 事務の方がボトル配達を発注 → 水配達

M5StickでやっていることはSlackのワークフローをキックするだけです。

  1. M5StickS3は、オフィスのWi-Fiに接続して待機します。
  2. ボタンを1回押すと、画面に「もう一度押すと送信」と表示されます。(ポカよけ)
  3. 5秒以内にもう一度押すと、SlackのWebhook URLに、HTTPSでPOSTします。
  4. Slackのワークフローが起動し、追加ボトルの依頼がSlackに通知されます。
  5. 成功・失敗は、画面とブザーで知らせます。

プログラムソース

プログラムは、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のワークフロー

  1. Slackのワークフロービルダーで、開始方法を「Webhookから」にしたワークフローを作る。
  2. ワークフローのステップに、依頼を通知する処理を追加する。
  3. ワークフローを公開して、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

書き込み

  1. M5StickS3の側面にあるリセットボタンを、約2秒長押しします。内部の緑LEDが点滅したら、ダウンロードモードに入っています。
  2. USBケーブルでPCにつないで、ポートを確認します。
  3. 書き込みます。
arduino-cli board list
arduino-cli compile --upload -p <ポート> --fqbn m5stack:esp32:m5stack_sticks3 water_button

書き込みが終わると、自動で再起動します。

発注してみた

実際にボタンを押してみます。

  1. 待機中は、暗い画面に「水ボタン」と表示されています。
  2. ボタンを1回押すと、画面が点灯して、「もう一度押すと送信」と表示されます。
  3. もう一度押すと、送信されます。
  4. 成功すると、緑の画面と成功音で知らせます。
  5. Slackのワークフローが起動し、依頼が通知されます。
  6. 事務の方がボトルを発注し、水が配達されます。

おわりに

ということで、水が届くボタンを作ってみた でした。
今は水しか出てきませんが、そのうちコーラやランチも出てくるのではないかと期待しています。

余談ですがクラスメソッドでは「めそ庫」という仕組みがあり、条件が揃えば常備されているアルコール・ソフトドリンクが無料で飲めます。
また、拠点によってはランチが提供されています(私が所属する名古屋オフィスではまだ未実施)

ボタン一つで〜 とはいきませんが、とても良い福利厚生が整っています。
ご興味のある方は、弊社の採用担当まで。

参考


製造業のクラウド活用とデジタル化を支援します

クラスメソッドの専門家による包括的なクラウド導入とデジタル化支援で、製造業の業務効率を最大化しましょう。AWSの導入から運用、最適化まで、最新技術と豊富な知見であらゆる課題に対応します。生産ラインのデジタル化やデータ活用、IoTの導入事例もございます。ぜひ、弊社の実績をご覧ください。

製造業界での支援内容を見る

この記事をシェアする

関連記事