kintone show+case unlimited ~ 私の舞台裏 ~
「すごくない」kintone Advent Calendar 2024に参加してます。
kintone show+case unlimitedに出場
小堀リーダーのお誘いで、チームいもキャンOB・OG倶楽部を結成、kintone show+case unlimitedに出場させていただきました。
内容は、kintone×IoT!
M5Stackで工場機器のシグナルライト点灯を検知し、記録・通知する発表でした。
もちろん、センサーは光に限らず、温度、スイッチ等色々なものに応用可能です。
この分野には既に製品化されたものもありますが、kintone×IoTなら低コストでニッチな要望にも対応可能です︕
また、本格的なパッケージを「導入するか否か」の判断にも使えると思うので、「発表の場で広く知らしめる」ことには意味があると思います。
毎週の打ち合わせと予選通過
チームはみんな遠方なので、打ち合わせは毎週、ウェブ会議で行いました。
その結果、「工場のシグナルライトの点灯を検知する事例」でアピールすることにしようとなり、小堀リーダーがM5Stackやセンサーを購入し、kintoneとの連携も作成。
そして、予選会。何とか予選通過を勝ち取ることができ、決勝に出場できることとなりました♪
さて、自分はチームに何で貢献しよう?
実は自分、アイデア出し程度で、システム部分にはあんまり関わってなかったんですね。
なので、「折角出場するんだから、なんか自分もやってみたい!」ってことで、「リアルなシグナルライトを作ってみよう!」ってことにしたんです。
システムの根幹部分には直接関わらないので、「自分で最低限からストレッチゴールまで、色々目標決められるな」ってのもありました。
シグナルライトを買ったけど……
シグナルライト自体は、インターネットに数千円で売られていました。ただ、それは3本のリード線がむき出しになってて、12Vで赤と緑を点灯させるだけのもの。このままじゃ、光りっぱなしです。
しかも、このシグナルライト5Vじゃないんだ……。普通のモバイルバッテリーとかじゃ光らないから少し面倒そう……
点滅をM5Stackで制御してみよう
これを機会に、M5Stackを使ってみたいと思ったので、M5CoreS3っていう、ちょっと良さそうな機種を買ってみることにしました。
https://docs.m5stack.com/ja/core/CoreS3
この機種、なんと単体でカメラやスピーカーや色んなセンサが内蔵されてるんです。あと、下に電源ユニットがくっついてて、内蔵バッテリー、USB充給電、9~24Vの幅のある電源入力と色んな場面で活躍できるっていうリッチな構成です(実は、ここがフラグだったんですが……)。
シグナルライトらしくするには、点滅と音楽!そう、エリーゼのためにとか乙女の祈りとか、関係者が聞いたらトラウマを呼び覚ましそうなあの電子音です。
実はあの音、株式会社パトライトが販売しているパトライト(株式会社パトライトが商標登録した、いわゆるシグナルライト)が発する音でして、その音もきっちりJASRACに登録されてたりします。
なので、今回は、自分で音楽ソフトを使ってそれらしいのを作成しました。データはrawなwave形式ですが、実際にプログラムに組み込むときは、配列としてデータ化してます。
プログラム作成にあたって
シグナルライトのプログラム作成にあたっては、こんな感じでストレッチゴール的に、段階的に目標を決めていました。
赤のシグナルライトを点滅させる。
赤と緑のシグナルライトをM5CoreS3のタッチパネルで制御して、自由に点滅させる。
点滅と同時に音を鳴らす。
鳴らす音を音楽にして、シグナルライトと連動させる。
音楽を複数にして選択できるようにする。ついでにボリューム調整とかもつけちゃう。
今回の発表では使わないけど、光センサも付けてセンサの反応で制御しよう。
Wi-Fiに接続して、kintoneにレコードを登録しよう。(ここまでは期間中に実装できず)
M5Stackの言語はC言語。
実は自分、C言語ってトラウマでして、出た当初に「ポインタとかよう分からん!」って逃げてたんですよねぇw
というわけで、今回の開発はChatGPTくんと二人三脚で、C言語を勉強しながら行っていきました。
完成物
できたものはこれ。
まずは構成図。
実際には、PDから12Vを取り出すために、USB-PDトリガー回路を挟んでます。シグナルライトが5Vだったら楽だったんですが、どうやら自動車のLEDポジショニングランプとかの部品を流用してるっぽい感じでした。
そしてプログラム。
メインプログラムのみコードを記載してます。残りのaudio_?.hは、音楽ファイルです。
#include <M5CoreS3.h>
#include "audio_1.h"
#include "audio_2.h"
#include "audio_3.h"
// 定数定義
constexpr int RELAY_PIN_1 = 8; // RED
constexpr int RELAY_PIN_2 = 9; // GREEN
constexpr int VOLUME_MAX = 160;
constexpr int LAMP_TOGGLE_INTERVAL = 600;
constexpr int SONG_COUNT = 3;
constexpr int SAMPLE_RATE = 44100;
// グローバル変数
static int currentVolume = 0; // 160までが音割れしにくい
static bool isMusicOn = false;
static bool isLampOn = false;
static int lampColor = 0; // 0: RED, 1: GREEN, 2: BOTH, 3: ALTERNATE
static size_t cursor_index = 0;
static unsigned long lastToggleTime = 0;
static int lastBatteryLevel = -1; // 前回のバッテリー残量
// 曲データ
extern const uint8_t audio_1[806400];
extern const uint8_t audio_2[793799];
extern const uint8_t audio_3[389120];
const uint8_t* songs[] = { audio_1, audio_2, audio_3 };
const size_t songSizes[] = { sizeof(audio_1), sizeof(audio_2), sizeof(audio_3) };
int currentSong = 0; // 初期曲
// メニュー
static char menu1Title[20];
static char menu2Title[20];
static char menu3Title[20];
static char menu4Title[20] = "MUTE";
static char menu5Title[20];
static int menu_x = 2;
static int menu_y = 100;
static int menu_w = 236;
static int menu_h = 30;
static int menu_padding = 36;
// 音楽再生・停止処理
void toggleMusic() {
if (CoreS3.Speaker.isPlaying(0)) {
CoreS3.Speaker.stop(0);
isMusicOn = false;
isLampOn = false;
} else {
CoreS3.Speaker.playRaw(songs[currentSong], songSizes[currentSong] / sizeof(songs[currentSong][0]), SAMPLE_RATE, false, 1, 0, true);
isMusicOn = true;
}
snprintf(menu1Title, sizeof(menu1Title), "toggle ALERT : %s", isMusicOn ? "ON" : "OFF");
CoreS3.Display.startWrite();
draw_menu(cursor_index, true);
CoreS3.Display.endWrite();
digitalWrite(RELAY_PIN_1, isLampOn ? HIGH : LOW);
digitalWrite(RELAY_PIN_2, isLampOn ? HIGH : LOW);
CoreS3.Display.setCursor(70, 70); // 位置を指定
CoreS3.Display.setTextSize(1);
CoreS3.Display.setTextColor(TFT_WHITE);
CoreS3.Display.printf("Lamp: ");
CoreS3.Display.setTextColor((lampColor == 0 || lampColor == 2 || lampColor == 3) ? (isLampOn ? TFT_RED : TFT_BLACK) :TFT_BLACK);
CoreS3.Display.print("*");
CoreS3.Display.print(" ");
CoreS3.Display.setTextColor((lampColor == 1 || lampColor == 2 || lampColor == 3) ? (isLampOn ? TFT_GREEN : TFT_BLACK) : TFT_BLACK);
CoreS3.Display.print("*");
}
// 曲の切り替え
void selectNextSong() {
currentSong = (currentSong + 1) % SONG_COUNT;
snprintf(menu2Title, sizeof(menu2Title), "select SONG : %d", currentSong + 1);
CoreS3.Display.startWrite();
draw_menu(cursor_index, true);
CoreS3.Display.endWrite();
CoreS3.Speaker.playRaw(songs[currentSong], songSizes[currentSong] / sizeof(songs[currentSong][0]), SAMPLE_RATE, false, 1, 0, true);
if (!isMusicOn) {
CoreS3.Speaker.stop(0);
}
}
// 音量を上げる処理
void increaseVolume() {
currentVolume = std::min(currentVolume + 10, VOLUME_MAX);
snprintf(menu3Title, sizeof(menu3Title), "VOLUME : %d", currentVolume);
CoreS3.Display.startWrite();
draw_menu(cursor_index, true);
CoreS3.Display.endWrite();
CoreS3.Speaker.setVolume(currentVolume);
}
// ミュート処理
void muteVolume() {
currentVolume = 0;
snprintf(menu3Title, sizeof(menu3Title), "VOLUME : %d", currentVolume);
CoreS3.Display.startWrite();
draw_menu(2, false);
CoreS3.Display.endWrite();
CoreS3.Speaker.setVolume(currentVolume);
}
// ランプの色を切り替える処理
void toggleLampColor() {
lampColor = (lampColor + 1) % 4; // 0: RED, 1: GREEN, 2: BOTH, 3: ALTERNATE
const char* colorStr = (lampColor == 0) ? " RED " : (lampColor == 1) ? "GREEN" : (lampColor == 2) ? " BOTH" : "ALTERNATE";
snprintf(menu5Title, sizeof(menu5Title), "COLOR : %s", colorStr);
CoreS3.Display.startWrite();
draw_menu(4, false);
CoreS3.Display.endWrite();
}
// ランプの状態を切り替える処理
void toggleLampIfPlaying() {
if (CoreS3.Speaker.isPlaying(0)) {
unsigned long currentTime = millis();
if (currentTime - lastToggleTime >= LAMP_TOGGLE_INTERVAL) {
isLampOn = !isLampOn;
lastToggleTime = currentTime;
switch (lampColor) {
case 0: // RED
digitalWrite(RELAY_PIN_1, isLampOn ? HIGH : LOW);
digitalWrite(RELAY_PIN_2, LOW);
break;
case 1: // GREEN
digitalWrite(RELAY_PIN_1, LOW);
digitalWrite(RELAY_PIN_2, isLampOn ? HIGH : LOW);
break;
case 2: // BOTH
digitalWrite(RELAY_PIN_1, isLampOn ? HIGH : LOW);
digitalWrite(RELAY_PIN_2, isLampOn ? HIGH : LOW);
break;
case 3: // ALTERNATE
digitalWrite(RELAY_PIN_1, isLampOn ? HIGH : LOW);
digitalWrite(RELAY_PIN_2, !isLampOn ? HIGH : LOW);
break;
}
CoreS3.Display.setCursor(70, 70); // 位置を指定
CoreS3.Display.setTextSize(1);
CoreS3.Display.setTextColor(TFT_WHITE);
CoreS3.Display.printf("Lamp: ");
CoreS3.Display.setTextColor((lampColor == 0 || lampColor == 2 || (lampColor == 3 && isLampOn)) ? (isLampOn ? TFT_RED : TFT_BLACK) : TFT_BLACK);
CoreS3.Display.print("*");
CoreS3.Display.print(" ");
CoreS3.Display.setTextColor((lampColor == 1 || lampColor == 2 || (lampColor == 3 && !isLampOn)) ? (isLampOn ? TFT_GREEN : lampColor == 3 ? TFT_GREEN : TFT_BLACK) : TFT_BLACK);
CoreS3.Display.print("*");
}
}
}
// バッテリー残量表示処理
void displayBatteryLevel() {
int batteryLevel = CoreS3.Power.getBatteryLevel();
if (batteryLevel != lastBatteryLevel) { // 前回のバッテリー残量と異なる場合のみ描画
lastBatteryLevel = batteryLevel;
CoreS3.Display.setTextSize(1);
CoreS3.Display.setTextColor(TFT_WHITE);
CoreS3.Display.fillRect(160, 19, 75, 19, TFT_BLACK); // 前の描画を消すために黒で塗りつぶす
CoreS3.Display.setCursor(160, 30); // バッテリー残量の位置を指定
CoreS3.Display.printf("B:%d%%", batteryLevel);
}
}
// メニュー項目の構造体
struct menu_item_t {
char* title;
void (*func)(bool);
};
// メニュー配列
static menu_item_t menus[] = {
{ menu1Title, [](bool holding) {
if (!holding) toggleMusic();
} },
{ menu2Title, [](bool) {
selectNextSong();
} },
{ menu3Title, [](bool) {
increaseVolume();
} },
{ menu4Title, [](bool) {
muteVolume();
} },
{ menu5Title, [](bool) {
toggleLampColor();
} },
};
static constexpr const size_t menu_count = sizeof(menus) / sizeof(menus[0]);
// メニュー表示処理
void draw_menu(size_t index, bool focus) {
CoreS3.Display.startWrite();
auto baseColor = CoreS3.Display.getBaseColor();
CoreS3.Display.setColor(focus ? baseColor : ~baseColor);
CoreS3.Display.drawRect(menu_x, menu_y + index * menu_padding, menu_w,
menu_h);
CoreS3.Display.drawRect(menu_x + 1, menu_y + index * menu_padding + 1,
menu_w - 2, menu_h - 2);
CoreS3.Display.setColor(focus ? ~baseColor : baseColor);
CoreS3.Display.fillRect(menu_x + 2, menu_y + index * menu_padding + 2,
menu_w - 4, menu_h - 4);
CoreS3.Display.setTextDatum(textdatum_t::middle_center);
CoreS3.Display.setTextColor(focus ? baseColor : ~baseColor,
focus ? ~baseColor : baseColor);
CoreS3.Display.drawString(menus[index].title, menu_x + (menu_w >> 1),
menu_y + index * menu_padding + (menu_h >> 1));
CoreS3.Display.endWrite();
}
// メニュー選択処理
void select_menu(size_t index) {
CoreS3.Display.startWrite();
draw_menu(cursor_index, false);
cursor_index = index;
draw_menu(cursor_index, true);
CoreS3.Display.endWrite();
}
// メニューを進める
void move_menu(bool back = false) {
if (back) {
select_menu((cursor_index ? cursor_index : menu_count) - 1);
} else {
select_menu((cursor_index + 1) % menu_count);
}
}
// メニューの操作(押し続ける動作)
void hold_menu(bool holding) {
if (menus[cursor_index].func != nullptr) {
menus[cursor_index].func(holding);
}
}
void setup(void) {
auto cfg = M5.config();
CoreS3.begin(cfg);
pinMode(RELAY_PIN_1, OUTPUT);
pinMode(RELAY_PIN_2, OUTPUT);
CoreS3.Speaker.begin();
CoreS3.Speaker.setVolume(currentVolume);
if (CoreS3.Display.width() > CoreS3.Display.height()) {
CoreS3.Display.setRotation(CoreS3.Display.getRotation() ^ 1);
}
if (CoreS3.Display.width() < 100) {
menu_x = 0;
menu_y = 10;
menu_w = CoreS3.Display.width() - 8;
} else {
CoreS3.Display.setFont(&fonts::DejaVu18);
}
menu_padding = (CoreS3.Display.height() - menu_y) / menu_count;
menu_h = menu_padding - 2;
// 初期メニュー設定
snprintf(menu1Title, sizeof(menu1Title), "toggle ALERT : %s", isMusicOn ? "ON" : "OFF");
snprintf(menu2Title, sizeof(menu2Title), "select SONG : %d", currentSong + 1);
snprintf(menu3Title, sizeof(menu3Title), "VOLUME : %d", currentVolume);
snprintf(menu5Title, sizeof(menu5Title), "COLOR : %s", lampColor == 0 ? " RED " : lampColor == 1 ? "GREEN" : lampColor == 2 ? " BOTH" : "ALTERNATE");
// 画面の初期表示設定
CoreS3.Display.setEpdMode(epd_mode_t::epd_fastest);
CoreS3.Display.fillScreen(TFT_DARKGRAY);
CoreS3.Display.setCursor(0, 0);
CoreS3.Display.print("CTRL SIGNAL TOWER");
// メニューを描画
CoreS3.Display.startWrite();
for (size_t i = 0; i < menu_count; i++) {
draw_menu(i, i == cursor_index);
}
CoreS3.Display.endWrite();
// 初期ランプ表示設定
CoreS3.Display.setCursor(70, 70); // 位置を指定
CoreS3.Display.setTextSize(1);
CoreS3.Display.setTextColor(TFT_WHITE);
CoreS3.Display.printf("Lamp: ");
CoreS3.Display.setTextColor(TFT_BLACK);
CoreS3.Display.print("*");
CoreS3.Display.print(" ");
CoreS3.Display.print("*");
// 初期バッテリー残量表示
displayBatteryLevel();
}
void loop(void) {
CoreS3.delay(5);
CoreS3.update();
// ランプの切り替えチェック
toggleLampIfPlaying();
// バッテリー残量表示の更新
displayBatteryLevel();
// 音楽がオンの場合のみ、ループ再生をチェック
if (isMusicOn && !CoreS3.Speaker.isPlaying(0)) {
CoreS3.Speaker.playRaw(songs[currentSong], songSizes[currentSong] / sizeof(songs[currentSong][0]), 44100, false, 1, 0, true);
}
// タッチ入力の処理
auto touch_count = CoreS3.Touch.getCount();
for (size_t i = 0; i < touch_count; i++) {
auto detail = CoreS3.Touch.getDetail(i);
if (((size_t)detail.x - menu_x) < menu_w) {
size_t index = (detail.y - menu_y) / menu_padding;
if (index < menu_count) {
if (detail.wasPressed()) {
select_menu(index);
} else if (index == cursor_index) {
if (detail.wasClicked()) {
hold_menu(false);
} else if (detail.isHolding()) {
hold_menu(true);
}
}
}
}
}
}
うん!上出来上出来!目標のほとんどを達成できました。
リハーサル
そして臨んだ全体リハーサル。機器も問題なく動き、「いざ本番!」だったんですが、最後に一回だけ、シグナルライトがうまく動かなかったことがあったんですよね。
なので、夜、ホテルに戻ってから、もう一度リハーサルを実施。ここから悲劇が始まります。
動かない!
正常動作しているのに、シグナルライトが点かない!リレーはカチカチいってるけど、なんか少し音が小さい。プログラムは正しく動作してそうだけど、リレーの調子が悪い?!
夜中に原因の検証が始まります。
今回、すっかり完成してたので、持ってきてたのはドライバーだけ。検証しようにもテスターもリード線もニッパーも、とにかく道具がない!
とりあえず必要なのは、検証用に電気を流すリード線。しかし、夜なので空いてるのはホテルのコンビニだけ。
ってことで、コンビニに行ったところ、ハサミとUSBケーブルとビニールテープを発見。カッターが無いのは残念ですが、最低限、リード線を作って被膜を剥くことくらいはできそう!
というわけで、夜中にベッドの上で分解作業が始まりました。
一番考えられるのは、リレーの劣化。あんまりやらかした記憶は無いですが、リレーを高速でON-OFFしたりすると、接点が固着したり劣化したりするんです。
なので、まずは、シグナルライトが単体で点灯するかを試してみました。結果はOK……。
……ということは、リレーの故障ってパターンが濃厚!
どうしよう……
この時点で軽く絶望しました。いや、リレーの替えとか流石に持ってないしw
まさか、明日の朝一から秋葉原に走る訳にもいかんし……(そもそも、朝一から個別リハーサルだったんですがw)
「シグナルライト自体、光らすのをあきらめたら?どのみち本筋とは関係ない演出だし……」と、優しい悪魔が耳元でささやき始めました。
が……、「せめて見た目だけでもなんとかしたい!よし、自分でリード線を手で接触させて点滅させよう!」と頭の中で何かが囁き、作業を開始。
結局、夜中の三時くらいまでかかって、たまたま持ってた100均のソーイングセットの箱を土台にしたりして、操作盤をつくりました(せめて工具があればまだよかったんですが、ハサミとビニテとUSBケーブルだけって状況だったんで、時間がめちゃくちゃかかりました)。
操作盤っていっても、リード線むき出しで、指でくっつけるというものすごく原始的なものですが。
ただ、USBのケーブルの中にあるリード線って、「より線」なんですよねぇ。すごく細い銅線が寄り集まったコード。柔らかくていいんですが、先っぽがばらけちゃうので、スイッチ代わりに指で接触させるのには向きません。「単線」なら曲げ加工して、よりスイッチらしくできたんですが……
そして当日リハーサル
そして夜は明け、個別リハーサル。何とかうまくいきましたが、所詮、より線で作った間に合わせのスイッチ。ヘロヘロですし、線を指で動かして接触を入り切りして点滅させるのも大変だし、いつ、より線が金属劣化でちぎれるかわからない状態でした。
どうしようかと、控室で色々考えてたんですが(事務所の紳士さん曰く、鬼気迫る雰囲気だったらしいw ちなみに小堀リーダーは、黙々とトークの練習をしてました。)、幕張メッセの近くにセリアがあることが判明!流石にダイソーみたいに工具や半田ごてとかはなさそうですが、何かつかえそうなものがあるかも……
救世主(いや物だが……)が!
というわけで、見つけてきたのがこれ!
おもちゃの信号機!
電池を使うおもちゃってことは、必ずスイッチになる部分があるはず。最悪おもちゃの中へリード線を這わせれば……という一縷の望みだったんですが、分解してみるとなんと大当たり!
マイクロスイッチが2個も入ってる!
まさに救世主でした。
せいぜい、バネと接点がおもちゃの中にあるだけだろうから、リード線をこのおもちゃに繋ごうと思ってたんですが、まさか、スイッチそのものが入ってるとは!
半田ごてはないからきちんとした接続はできないですが、リード線ぐるぐる巻きにしてテープで止めればなんとかなるはず!
なんとか完成
できたものがこちらです。
養生テープぐるぐる巻きの武骨な操作盤ですが、それぞれのスイッチを押すと、きちんと赤と緑のライトを制御可能に!これなら、点滅させるのも楽ですし、より線の接点と違って、突然ちぎれたりする心配がありません。
というわけで、本番は、演台の裏で自分が点滅回路になるという荒業で、なんとか凌いだのでした。
おわりに
今回の件が、直接何かの役に立つわけではないのですが、「ピンチでも諦めずに、手元にある何かでなんとかしてみよう!」っていう原点を思い出せた気がしました。
自分、昔からこんな感じで、ピンチの時に訳の分からないことをするんですよね(自分はウサビッチのプーチンモードと呼んでますw)。
全然「すごくない」し、「かっこよくない」んですが、これからも色々頑張っていきたいと思えた経験でした。
おまけ
CybozuDaysの帰りに、秋葉原に寄ってM5CoreS3用のセンサその他を買い足し、今回の原因の故障個所を特定してみました。
予想に反し、故障個所はリレーではなく、電源ユニット!実は、この電源ユニットにリレーに信号を送るコネクタがあるんですが、ここの出力電圧が低くなってた……
電源ユニットを外し、別の機能のユニットを取り付けることで、問題なく動作するようになりました……って、こんなん本番前日に分かるかい!
ガチで本番に臨むときは、予備機を持ってくくらいの準備をした方が良い。そんな学びを得たイベントでした。チャンチャン!
あれ?
書き終えて、これkintoneの話なのか?とw
kintoneに関するイベントでの、自分的アップデートってことにさせてください♪