Tokiwaのアイコン画像
Tokiwa 2026年01月30日作成 (2026年01月30日更新) © MIT
製作品 製作品 閲覧数 49
Tokiwa 2026年01月30日作成 (2026年01月30日更新) © MIT 製作品 製作品 閲覧数 49

「行ってみたら誰もいなかった…」をSPRESENSEで解決!インフラ班が本気で作る部室可視化システム

「行ってみたら誰もいなかった…」をSPRESENSEで解決!インフラ班が本気で作る部室可視化システム

1.はじめに

私たちは、豊橋技術科学大学コンピュータクラブ(TUTCC)のインフラ班です。

私たちのサークルは、ゲーム制作、WEB、IoTなど多種多様な専門分野を持つメンバーが揃っています。しかし、近年ある課題に直面していました。それは、各々が個人での活動に特化しすぎてしまい、サークルとしての横の繋がりが希薄になっているという点です。

部室は存在するものの、「誰が来ているかわからない」「行ってみたけど誰もいなくて寂しかった」「空気が淀んでいて集中できない」といった小さな心理的・環境的ハードルが、メンバーの足を部室から遠ざけていました。

そこで私たちは、インフラ班として、部室をサークルメンバー同士の交流の場とすることを目的とした監視・可視化システムを構築しました。本プロジェクトは、プライバシーに配慮しつつ、部室の賑わいと快適さをリアルタイムで提供することを目指したものです。

2.システム概要

本システムは、エッジAIによる解析と堅牢なバックエンドサーバーを組み合わせた環境モニタリングシステムです。

徹底したプライバシー保護(エッジ内完結)

カメラを使用するシステムにおいて、最大の懸念点は「部員が監視されていると感じる」ことです。本システムでは、カメラ画像はSPRESENSEのメモリ内でAI推論(人数計測)に使用されるだけであり、画像データの保存や外部への送信は一切行いません。 送信されるのは「1」や「2」といった人数の数値データのみであるため、個人の特定を排除しつつ、部室の混雑状況だけを抽出することに成功しました。

閉じたネットワークによる高セキュリティ

エッジデバイスとサーバーPC間の通信は、部室内のLAN内で完結しています。データの送信を外部クラウドに頼らず、部室内の閉じたWi-Fi通信を利用することで、重要なログデータの漏洩リスクを最小限に抑えています。また、インターネットからの不要なインバウンド通信を必要としないため、大学という厳格なネットワーク環境下でも安全かつ安定した運用が可能です。

3つの主要機能

  1. エッジAIによる賑わいの抽出:SPRESENSEによるリアルタイム人数計測
  2. 環境センサーによる快適さの計測:CO2センサーで換気状態を可視化
  3. シームレスな情報共有:Discordへの即時通知と、Grafanaによる時系列グラフ表示

3.システム構成図

本システムは、エッジ側での推論とサーバー側でのデータ処理を組み合わせたアーキテクチャを採用しています。

システム全体は、部室内に設置された「SPRESENSE Extension System」と「部内サーバPC」の2つのユニットで構成されています。

エッジ側では、カメラとCO2センサーから得られる生の情報をSPRESENSEでの処理によって数値データへと変換します。この際、画像データは推論のためだけにメモリ上で展開され、外部へは一切流出しないエッジ完結型の設計をしました。

こうして正規化されたデータはJSON形式でサーバPCへと送信されます。サーバ側では、Podmanによるコンテナ仮想化技術を活用し、APIサーバー、データベース、可視化ツールの3つの機能を論理的に分離しています。これにより、各コンポーネントの独立性を保ち、メンテナンスの容易さを実現しました。最終的な出力先として、部員が日常的に利用するDiscordと、詳細な分析を可能にするGrafanaを使い分けることで、情報の即時通知とデータ活用の両立を図っています。
システム構成図

4.使用物品・技術スタック

ハードウェア

  • メインボード: Sony SPRESENSE CXD5602PWBMAIN1
  • 拡張基盤: SPRESENSE 拡張ボード CXD5602PWBEXT1 + Wi-Fi Add-on (iS110B)
  • カメラ: SPRESENSE カメラボード CXD5602PWBCAM1
  • センサー: MH-Z19E (CO2センサー)

ソフトウェア

  • エッジ側: C++ (Arduino環境)
  • サーバー側: Go 1.22 (Echo / GORM)
  • データベース: PostgreSQL 15
  • インフラ: Podman / Podman Compose (Rootless環境)
  • 可視化・通知: Grafana, Discord Webhook

5.エッジデバイス(SPRESENSE)での実装

5-1.ハードウェアの全体構成

本システムは、SPRESENSE を中心に「カメラで人数推定」「CO2センサーで濃度計測」「Wi-Fiでサーバへ送信」を行う構成となっています。

  • 本体:SPRESENSE(CXD5602)

    マルチコアMCUを用い、カメラ処理・AI推論・UARTセンサー取得・ネットワーク送信を同一ボード上で実行します。

  • 拡張:SPRESENSE拡張ボード

    5V系センサー(MH-Z19E)への給電や周辺接続を容易にし、Wi-Fiアドオンボードも拡張ボード経由で接続します。

  • カメラ:ISX012カメラボード

    SPRESENSEのカメラインターフェースで直接接続し、画像を取り込み人物検出に用います。

  • CO2センサー:MH-Z19E

    UART(Serial2)で9バイトコマンド/応答のプロトコルを用いてCO2濃度(ppm)を取得します。

  • Wi-Fi:iS110B(Telit GS2200系)

    SPIで接続し、HTTP/1.1のPOSTでJSONを送信します。
    実際のハードウェア
    以下に回路図を示します。
    回路図(1)
    回路図(2)

5-2.送信の形式・方法

送信は「TCP接続 → HTTP POSTを手組み → JSON送信 → レスポンス受信 → 接続クローズ」という流れで実装します。

Content-Type: application/json と Connection: close を付け、本文長を Content-Length に入れて送ります。

  • メソッド:POST(HTTP/1.1)
  • 認証:Bearerトークン(Authorizationヘッダ)
  • 送信:AtCmd_SendBulkData() で一括送信(ATコマンドでHTTPヘッダ+JSON本文をまとめて送る)

通信の信頼性対策

iS110B(GS2200系)は内部で複数ソケット(接続ID = CID)を管理しており、接続や切断が不完全な状態で次の送信に入ると CIDの不整合(mismatch) や送信失敗が起きやすいです。

そのため本システムでは「毎回、通信状態を初期化してから送る」ことを前提に送信手順を構成します。

送信処理の全体手順

  1. Wi-Fi接続を確認し、切れていれば再接続する(送信の前提条件を満たす)。
  2. 送信前に全ソケットをクローズする(前回の残骸を消してCIDのズレを防ぐ)。
  3. TCP接続を作る(CIDを新しく取得し、以後はそのCIDだけを使う)。
  4. HTTP POSTを送信する(people_count と co2_ppm をJSONで送る)。
  5. レスポンスを読む(タイムアウトを設け、待ち続けない)。
  6. 終了時も全ソケットをクローズする(次回送信のために状態を確実にリセットする)。

5-3.CO2センサ(MH-Z19E)

MH-Z19Eは UART で読み出し、固定の9バイトコマンドを送って9バイト応答を受け取る方式です。取得手順は以下になります。

  • UART初期化:Serial2.begin(9600)
  • 送信:9バイトの readCommand を送る。
  • 受信:9バイト揃うまで待つ(タイムアウト 1.5秒)。
  • 検証:先頭2バイトとチェックサム一致を確認する。
  • 算出:ppm = response[2] * 256 + response[3]

5-4.AIモデル(FOMO)

FOMOとは

FOMOは Edge Impulse が「制約デバイスでの物体検出」を目的に設計し、公式に提供しているアルゴリズム/モデル構成です。

参考:Edge Impulse公式ドキュメント「FOMO」

https://docs.edgeimpulse.com/studio/projects/learning-blocks/blocks/object-detection/fomo

従来のSSD/YOLO系は、物体ごとにバウンディングボックス(位置+サイズ)を推定するため、計算量やメモリ負荷が大きくなりやすいです。FOMOは、推論の出力を「重心ベースの検出(centroids)」として扱い、位置と個数を得ることに焦点を当てます。

なぜFOMOか

本システムで必要なのは「現在の人数」という単一の指標であり、各人物の位置や大きさの詳細は不要です。下表に示す通り、FOMOは他の物体検出モデルと比較して圧倒的に軽量であり、SPRESENSEのような限られたリソース環境でリアルタイム推論を可能にします。重心ベースで個数を出力する設計のため、人数カウントという要件に対して過不足のないモデル構成となっています。

項目 FOMO MobileNet SSD YOLOv5
メモリ使用量 約77KB 約11MB 約20MB
推論速度 高速(~50ms) 中速(~200ms) 低速(~500ms)
出力形式 重心座標 バウンディングボックス バウンディングボックス

作成したモデル

本システムで使用するモデルは Edge Impulse 上で作成したFOMOモデルであり、Roboflow で公開されているオープンソースのデータセットを用いて「人の頭」を検出するように学習させたものです(学習画像は約2000枚)。

この設計により、人物全身ではなく「頭」を検出対象にすることで、室内の固定カメラ環境における人数推定を行いやすくしています。

推論手順

処理負荷を制御するため、カメラ画像をそのまま推論に渡さず、推論入力を 48×48のグレースケール に正規化する設計としています。

この入力設計に合わせ、元画像(QVGA相当)から中心領域を切り出して縮小し、さらにグレースケール化してAI入力を生成します。

推論は次のデータフローで成立します。

  1. カメラからフレームを取得する。
  2. 画像の中心を切り出して縮小し、48×48を作る(切り出し・リサイズはSPRESENSE側機能で実行する)。
  3. 48×48をグレースケールへ変換し、推論入力バッファに格納する。
  4. Edge Impulseの推論関数を呼び出し、検出結果(候補とスコア)を得る。
  5. 検出結果から閾値0.30以上の個数をカウントする。

検出結果の例

以下は、実際にFOMOモデルで人物検出を行った結果です。検出された各人物の頭部に重心が表示され、信頼度スコアと共に位置が示されています。
実際の検出
この例では、画像内の2名の人物が検出されており、各検出ポイントにスコアが表示されています。閾値0.30以上のスコアを持つ検出のみがカウントされ、people_countとしてサーバーに送信されます。

5-5.デバイスに書き込むソースコード

ソースコード一式は同一ディレクトリに配置し、ビルド・実行します。

  • 機密情報の保護 (config.h)
    パスワードやAPIキーなどの機密情報を含む config.h ファイルはGit管理外としています。記事内でも公開せず、代わりに設定項目のみを記載した config.h.example のようなダミーファイルを用いることで安全性を担保しています。
  • AIモデルについて(human_head_detection_inferencing.h)
    今回は人間の頭を検出するモデルhuman_head_detection_inferencing.hを使用していますが、Edge Impulseで学習した48×48入力のFOMOモデルであれば、任意のモデルに差し替えて利用できます。

aicamera_main.ino

// 部室状態検知システム - メイン制御(CO2/人数の計測と送信) #include <Arduino.h> #include "network_handler.h" #include "co2_sensor.h" #include "vision_processor.h" uint32_t last_send_time = 0; const uint32_t SEND_INTERVAL_MS = 60000; void setup() { Serial.begin(115200); while (!Serial); Serial.println(F("Initializing...")); // センサー初期化 init_co2_sensor(); // Wi-Fi初期化(失敗時は停止) if (!init_network()) { Serial.println(F("Network init failed")); while (1) delay(1000); } // カメラと推論初期化(失敗時は停止) if (!init_vision()) { Serial.println(F("Vision init failed")); while (1) delay(1000); } Serial.println(F("System ready")); // 起動直後に最初の送信が走るように開始時刻を調整 last_send_time = millis() - SEND_INTERVAL_MS + 20000; } void loop() { uint32_t now = millis(); // 一定間隔で測定→送信 if (now - last_send_time >= SEND_INTERVAL_MS) { int ppm = get_co2_ppm(); int people = run_head_detection(); Serial.print(F("CO2: ")); Serial.print(ppm); Serial.print(F("ppm, People: ")); Serial.println(people); send_sensor_data(people, ppm); last_send_time = millis(); } delay(100); }

config.h.example

#ifndef CONFIG_H #define CONFIG_H // Wi-Fi設定 #define AP_SSID "your_wifi_ssid" #define PASSPHRASE "your_wifi_password" // サーバー設定 #define HTTP_SRVR_IP "192.168.x.x" #define HTTP_PORT "8000" #define API_KEY "your_api_key_here" // パス設定 #define HTTP_POST_PATH "/api/v1/room-status" #endif

vision_processor.h

// 部室状態検知システム - カメラ推論(宣言) #pragma once #include <stdbool.h> bool init_vision(void); int run_head_detection(void);

vision_processor.cpp

// 部室状態検知システム - カメラ推論による人数検出 #include <human_head_detection_inferencing.h> #define EI_CLASSIFIER_ALLOCATION_HEAP 1 #define EI_CLASSIFIER_ALLOCATION_STATIC 0 #include <Arduino.h> #include <Camera.h> #include <human_head_detection_inferencing.h> #include "vision_processor.h" #define IMG_SIZE 48 // 入力フレームサイズ(QVGA) #define RAW_W CAM_IMGSIZE_QVGA_H // 320 #define RAW_H CAM_IMGSIZE_QVGA_V // 240 // 縮小倍率は2の冪分割のみ対応 #define SCALE_FACTOR 4 #define CLIP_W (IMG_SIZE * SCALE_FACTOR) // 192 #define CLIP_H (IMG_SIZE * SCALE_FACTOR) // 192 #define OFF_X ((RAW_W - CLIP_W) / 2) // 64 #define OFF_Y ((RAW_H - CLIP_H) / 2) // 24 // 変換後のフレーム保持 static CamImage g_sized; static uint8_t g_gray[IMG_SIZE * IMG_SIZE]; // 1フレーム要求/準備のフラグ static volatile bool g_req = false; static volatile bool g_ready = false; static const float MIN_SCORE = 0.30f; static inline float mono_to_ei_pixel(uint8_t mono) { return (float)((mono << 16) | (mono << 8) | mono); } static int ei_get_data(size_t offset, size_t length, float *out_ptr) { // Edge Impulse用の読み出し関数 for (size_t i = 0; i < length; i++) { size_t ix = offset + i; uint8_t mono = (ix < (IMG_SIZE * IMG_SIZE)) ? g_gray[ix] : 0; out_ptr[i] = mono_to_ei_pixel(mono); } return 0; } static void CamCB(CamImage img) { // 1フレーム取得時のコールバック if (!img.isAvailable()) return; if (!g_req) return; g_req = false; CamErr e = img.clipAndResizeImageByHW( g_sized, OFF_X, OFF_Y, OFF_X + CLIP_W - 1, OFF_Y + CLIP_H - 1, IMG_SIZE, IMG_SIZE ); if (e != CAM_ERR_SUCCESS) { Serial.print("clipAndResizeImageByHW err="); Serial.println((int)e); return; } e = g_sized.convertPixFormat(CAM_IMAGE_PIX_FMT_GRAY); if (e != CAM_ERR_SUCCESS) { Serial.print("convertPixFormat(GRAY) err="); Serial.println((int)e); return; } memcpy(g_gray, g_sized.getImgBuff(), IMG_SIZE * IMG_SIZE); g_ready = true; } bool init_vision(void) { // 省メモリ設定で開始 CamErr err = theCamera.begin(1, CAM_VIDEO_FPS_5, RAW_W, RAW_H, CAM_IMAGE_PIX_FMT_YUV422); if (err != CAM_ERR_SUCCESS) { Serial.print("Camera begin err="); Serial.println((int)err); return false; } err = theCamera.startStreaming(true, CamCB); if (err != CAM_ERR_SUCCESS) { Serial.print("startStreaming err="); Serial.println((int)err); return false; } return true; } int run_head_detection(void) { g_ready = false; g_req = true; // 1フレーム取得待ち(タイムアウトあり) uint32_t t0 = millis(); while (!g_ready) { if (millis() - t0 > 2000) { g_req = false; return 0; } delay(5); } g_ready = false; signal_t signal; signal.total_length = IMG_SIZE * IMG_SIZE; signal.get_data = &ei_get_data; // 推論実行 ei_impulse_result_t result = {0}; EI_IMPULSE_ERROR e = run_classifier(&signal, &result, false); if (e != EI_IMPULSE_OK) { Serial.print("run_classifier err="); Serial.println((int)e); return 0; } int count = 0; #if EI_CLASSIFIER_OBJECT_DETECTION == 1 // 検出結果のカウント bool any = (result.bounding_boxes_count > 0) && (result.bounding_boxes[0].value > 0); if (any) { for (uint32_t i = 0; i < result.bounding_boxes_count; i++) { if (result.bounding_boxes[i].value >= MIN_SCORE) count++; } } #endif return count; }

network_handler.h

// 部室状態検知システム - ネットワーク送信(宣言) #ifndef NETWORK_HANDLER_H #define NETWORK_HANDLER_H #include <Arduino.h> #include "config.h" bool init_network(); void send_sensor_data(int count, int ppm); void cleanup_all_connections(); #endif

network_handler.cpp

// 部室状態検知システム - ネットワーク送信(Wi-Fi/HTTP) #include "network_handler.h" #include <SPI.h> #include <TelitWiFi.h> // Wi-Fiモジュール制御 TelitWiFi gs2200; TWIFI_Params gsparams; // モジュール側ソケットを一括リセット void cleanup_all_connections() { Serial.println(F("[Net] Resetting sockets...")); AtCmd_NCLOSEALL(); delay(500); } // Wi-Fi初期化と接続 bool init_network() { Serial.println(F("[Net] Initializing GS2200...")); Init_GS2200_SPI_type(iS110B_TypeC); gsparams.mode = ATCMD_MODE_STATION; gsparams.psave = ATCMD_PSAVE_DEFAULT; if (gs2200.begin(gsparams)) { Serial.println(F("[Net] ERR: GS2200 begin failed")); return false; } cleanup_all_connections(); Serial.print(F("[Net] Connecting to SSID: ")); Serial.println(AP_SSID); if (gs2200.activate_station(AP_SSID, PASSPHRASE)) { Serial.println(F("[Net] ERR: WiFi Association failed")); return false; } Serial.println(F("[Net] WiFi Connected!")); return true; } // Wi-Fi状態を確認し、必要なら再接続 bool check_and_reconnect_wifi() { ATCMD_NetworkStatus status; if (AtCmd_NSTAT(&status) != ATCMD_RESP_OK || status.connected == 0) { Serial.println(F("[Net] WiFi lost. Reconnecting...")); if (gs2200.activate_station(AP_SSID, PASSPHRASE)) { Serial.println(F("[Net] Reconnect failed")); return false; } delay(2000); } return true; } // センサーデータをHTTPで送信 void send_sensor_data(int count, int ppm) { char cid = 255; bool tcp_connected = false; // Wi-Fi接続の確認 if (!check_and_reconnect_wifi()) return; // 接続前に既存ソケットをクリア cleanup_all_connections(); // TCP接続(最大3回リトライ) for (int i = 0; i < 3; i++) { cid = 255; if (AtCmd_NCTCP((char*)HTTP_SRVR_IP, (char*)HTTP_PORT, &cid) == ATCMD_RESP_OK) { tcp_connected = true; Serial.print(F("[Net] TCP Connected. CID=")); Serial.println((int)cid); break; } else { Serial.print(F("[Net] TCP Connect failed. Retry: ")); Serial.println(i + 1); cleanup_all_connections(); delay(1500); } } if (!tcp_connected) { Serial.println(F("[Net] ERR: TCP Failed.")); return; } // JSON本文とHTTPリクエストを構成 String body = "{\"people_count\": " + String(count) + ", \"co2_ppm\": " + String(ppm) + "}"; String request = "POST " + String(HTTP_POST_PATH) + " HTTP/1.1\r\n"; request += "Host: " + String(HTTP_SRVR_IP) + ":" + String(HTTP_PORT) + "\r\n"; request += "Authorization: Bearer " + String(API_KEY) + "\r\n"; request += "Content-Type: application/json\r\n"; request += "Content-Length: " + String(body.length()) + "\r\n"; request += "Connection: close\r\n\r\n"; request += body; Serial.println(F("[Net] Sending...")); if (AtCmd_SendBulkData(cid, (char*)request.c_str(), request.length()) != ATCMD_RESP_OK) { Serial.println(F("[Net] ERR: Send failed")); AtCmd_NCLOSEALL(); return; } Serial.println(F("[Net] Sent. Waiting for response...")); // サーバー応答を受信(タイムアウトあり) Serial.println(F("--- Server Response Start ---")); uint32_t start_time = millis(); const uint32_t TIMEOUT_MS = 10000; uint8_t rx_buffer[128]; bool received_any = false; bool response_complete = false; delay(500); while (millis() - start_time < TIMEOUT_MS) { int len = gs2200.read(cid, rx_buffer, sizeof(rx_buffer) - 1); if (len > 0) { received_any = true; rx_buffer[len] = 0; Serial.print((char*)rx_buffer); } else { if (received_any) { delay(100); if (gs2200.read(cid, rx_buffer, 1) == 0) { response_complete = true; break; } } else { delay(100); } } } if (response_complete || received_any) { Serial.println(F("\n[Net] Response received successfully.")); } else { Serial.println(F("\n[Net] Warning: No response (Timeout).")); } Serial.println(F("--- Server Response End ---")); // 終了処理 Serial.println(F("[Net] Closing connection...")); AtCmd_NCLOSEALL(); delay(1000); }

co2_sensor.h

// 部室状態検知システム - CO2センサー制御(宣言) #ifndef CO2_SENSOR_H #define CO2_SENSOR_H #include <Arduino.h> void init_co2_sensor(); int get_co2_ppm(); #endif

co2_sensor.cpp

// 部室状態検知システム - CO2センサーによるppm検出 #include "co2_sensor.h" // センサー読出しコマンド(固定) const uint8_t readCommand[9] = {0xFF, 0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00, 0x79}; static uint8_t getCheckSum(const uint8_t *packet) { // 応答パケットの簡易チェックサム uint8_t checksum = 0; for (int i = 1; i < 8; i++) checksum += packet[i]; return (0xff - checksum) + 1; } void init_co2_sensor() { // CO2センサーはSerial2を使用 Serial2.begin(9600); } int get_co2_ppm() { // 受信バッファをクリア while(Serial2.available()) Serial2.read(); Serial2.write(readCommand, 9); uint8_t response[9]; uint32_t start_ms = millis(); // 応答待ち(タイムアウト) while (Serial2.available() < 9) { if (millis() - start_ms > 1500) return -1; delay(10); } Serial2.readBytes(response, 9); // ヘッダとチェックサムを確認 if (response[0] == 0xFF && response[1] == 0x86 && getCheckSum(response) == response[8]) { return (int)response[2] * 256 + (int)response[3]; } return -1; }

6.サーバーでの実装

サーバーサイドは、SPRESENSEから送られてくるデータの受け取りから、データベースへの蓄積、そしてユーザーへの通知までを自動化するパイプラインとして機能します。

6-1.APIインターフェース

エッジとサーバー間の通信は、軽量なJSON形式を用いたHTTP POST通信で行われます。

  • エンドポイントPOST /api/v1/room-status
  • データ形式
{ "people_count": 2, "co2_ppm": 850 }
  • 認証
    不正な書き込みを防止するため、HTTPヘッダーに独自のAPIキーを含める簡易認証を実装しました。サーバー側が正しく受信すると 201 Created を返し、エッジ側はこのレスポンスを確認することで送信の成否を判断します。

6-2.コンテナ間データフロー

サーバー内部では、Podmanによって分離された3つのコンテナが協調して動作しています。

  1. APIサーバー(Go/Echo)
    エッジからデータを受信すると、まずデータベース(PostgreSQL)に記録を行います。その後、非同期で通知判定ロジックを実行します。
  2. データベース(PostgreSQL)
    センサーログと通知履歴の2つのテーブルを管理します。APIサーバーからの書き込みと、Grafanaからの読み取りを同時に処理するデータハブとして機能します。
  3. 可視化ツール(Grafana)
    データベースから直接データを参照し、部員向けにリアルタイムのダッシュボードを公開します。

6-3.通知判定ロジック

部員が最も頻繁に利用する情報源として、Discordへのプッシュ通知機能を実装しました。APIサーバーからDiscordの Webhook API を呼び出すことで、リアルタイムな情報共有を実現しています。

通知の判定条件

サーバー側で「前回の状態」を保持することで、意味のある変化のみを通知します。

  • 入室通知
    現在人数 > 0 かつ 現在人数 > 前回人数 の場合に送信。単に人がいる状態を知らせるのではなく、「誰かが新しく部室に来た」というイベントを捉えて通知します。
  • 換気アラート
    CO2濃度 > 1000ppm かつ 前回の通知から30分以上経過 した場合に送信。数値が閾値付近で変動して通知が連投されるのを防ぐため、30分間のインターバルを設けています。

APIサーバーからDiscordへのインターフェース

通知の際、GoサーバーはDiscordのWebhook URLに対して以下の構造のJSONをPOSTします。

{ "username": "部室監視員", "content": "🚪 **入室通知**\n部室に人が入室しました!\n現在の人数: **2人**" }

このように、サーバー側でメッセージの組み立て(絵文字の付与や書式整え)を行うことで、エッジ側の処理をシンプルに保ちつつ、ユーザーにとって読みやすい通知を実現しました。

通知メッセージの例

🚪 入室通知
部室に人が入室しました!
現在の人数: 2人

⚠ 換気アラート
CO2濃度が高くなっています!
現在のCO2濃度: 1312 ppm
換気を行ってください。

6-4.24時間稼働を支えるシステム設計

部室の共有サーバPCという制約の中で、安定したサービスを提供するための工夫です。

  • Rootless Podmanによる運用
    管理者権限(root)を必要としない構成にすることで、セキュリティリスクを抑えつつ、サーバーOSの環境を汚さずにシステムを構築しました。
  • Systemdによる自動復旧
    サーバーPCの再起動時にも自動でシステムが立ち上がるよう、ユーザー権限でのSystemdユニットとして登録しています。また、loginctl enable-linger 設定により、サーバーからログアウトした後もサービスが止まることなく動き続けるサーバーとしての自立性を確保しました。
  • Go言語の採用
    実行バイナリが非常に軽量なGo言語を採用し、コンテナサイズを最小化。リソース消費を抑えつつ、高速なレスポンスを実現しています。

6-5.ディレクトリ構成とソースコード

ディレクトリ構成を以下に示します。

club-room-monitor/ ├── app/ # APIサーバー (Go言語) │ ├── Dockerfile # マルチステージビルド定義 │ ├── go.mod # 依存関係管理 │ ├── go.sum │ └── main.go # メインロジック ├── db/ │ └── init/ │ └── 00_init.sql # データベース初期化 ├── .env # 環境変数 (秘密情報) ├── .gitignore # 管理除外設定 └── compose.yaml # コンテナオーケストレーション

なお、ソースコードの管理・公開においてはセキュリティと可読性に配慮しています。

  • 機密情報の保護 (.env)
    パスワードやAPIキーなどの機密情報を含む .env ファイルはGit管理外としています。記事内でも公開せず、代わりに設定項目のみを記載した .env.example のようなダミーファイルを用いることで安全性を担保しています。
  • 依存関係ファイルの扱い (go.sum)go.sum は機械的に生成されるチェックサムであり行数が多いため、本記事では省略します。Dockerfile 内ではビルド効率化のために COPY go.mod go.sum ./ と記述していますが、再現環境でビルドする際は go mod tidy コマンドを実行して自動生成することを前提としています。

Dockerfile

# 部室状態検知システム - マルチステージビルドDockerfile # コンテナサイズを最小化するため、ビルドステージと実行ステージを分離 # ======================================== # ビルドステージ # ======================================== FROM golang:1.22-alpine AS builder # ビルドに必要なパッケージをインストール RUN apk add --no-cache git WORKDIR /build # 依存関係のダウンロード (キャッシュ効率化のため先に実行) COPY go.mod go.sum* ./ RUN go mod download # ソースコードをコピーしてビルド COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/server main.go # ======================================== # 実行ステージ # ======================================== FROM alpine:latest # タイムゾーン設定とCA証明書のインストール RUN apk add --no-cache tzdata ca-certificates # JSTタイムゾーンを設定 ENV TZ=Asia/Tokyo WORKDIR /app # ビルドステージから実行バイナリをコピー COPY --from=builder /app/server . # アプリケーションポート EXPOSE 8000 # 非rootユーザーで実行 (セキュリティ対策) RUN adduser -D -u 1000 appuser USER appuser # アプリケーション起動 CMD ["./server"]

go.mod

module club-room-monitor go 1.22 require ( github.com/joho/godotenv v1.5.1 github.com/labstack/echo/v4 v4.12.0 gorm.io/driver/postgres v1.5.9 gorm.io/gorm v1.25.12 ) require ( github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.5.5 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect golang.org/x/crypto v0.22.0 // indirect golang.org/x/net v0.24.0 // indirect golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.5.0 // indirect )

main.go

// 部室状態検知システム - バックエンドAPIサーバー // Sony SPRESENSEから送信される環境データ(人数・CO2濃度)を受信・蓄積し、 // 条件に応じてDiscordへ通知を送信する package main import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os" "time" "github.com/joho/godotenv" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "gorm.io/driver/postgres" "gorm.io/gorm" ) // ======================================== // GORMモデル定義 // ======================================== // RoomLog は部室の環境ログを表す構造体 type RoomLog struct { ID uint `gorm:"primaryKey" json:"id"` RecordedAt time.Time `gorm:"default:current_timestamp;index" json:"recorded_at"` PeopleCount int `json:"people_count"` Co2Ppm int `json:"co2_ppm"` } // TableName はGORMで使用するテーブル名を指定 func (RoomLog) TableName() string { return "room_logs" } // NotificationHistory は通知履歴を表す構造体 (スパム防止用) type NotificationHistory struct { ID uint `gorm:"primaryKey" json:"id"` SentAt time.Time `gorm:"default:current_timestamp" json:"sent_at"` AlertType string `gorm:"index" json:"alert_type"` // "entrance" または "ventilation" } // TableName はGORMで使用するテーブル名を指定 func (NotificationHistory) TableName() string { return "notification_history" } // ======================================== // リクエスト/レスポンス構造体 // ======================================== // RoomStatusRequest はデバイスからのリクエストボディ type RoomStatusRequest struct { PeopleCount int `json:"people_count" validate:"required,min=0"` Co2Ppm int `json:"co2_ppm" validate:"required,min=0"` } // ======================================== // グローバル変数 // ======================================== var db *gorm.DB // ======================================== // メイン関数 // ======================================== func main() { // 環境変数の読み込み (.envファイルが存在する場合) _ = godotenv.Load() // データベース接続 initDatabase() // Echoインスタンス作成 e := echo.New() // ミドルウェア設定 e.Use(middleware.Logger()) e.Use(middleware.Recover()) // APIキー認証ミドルウェア apiKey := os.Getenv("API_SECRET_KEY") if apiKey == "" { log.Fatal("API_SECRET_KEY 環境変数が設定されていません") } // APIルート (認証付き) apiGroup := e.Group("/api/v1") apiGroup.Use(middleware.KeyAuth(func(key string, c echo.Context) (bool, error) { return key == apiKey, nil })) apiGroup.POST("/room-status", handleRoomStatus) // ヘルスチェック (認証なし) e.GET("/health", func(c echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) }) // サーバー起動 log.Println("部室状態検知APIサーバーを起動します (ポート: 8000)") e.Logger.Fatal(e.Start(":8000")) } // ======================================== // データベース初期化 // ======================================== func initDatabase() { dbHost := os.Getenv("DB_HOST") dbPort := os.Getenv("DB_PORT") dbUser := os.Getenv("DB_USER") dbPassword := os.Getenv("DB_PASSWORD") dbName := os.Getenv("DB_NAME") // 接続情報のバリデーション if dbHost == "" || dbPort == "" || dbUser == "" || dbPassword == "" || dbName == "" { log.Fatal("データベース接続に必要な環境変数が設定されていません") } dsn := fmt.Sprintf( "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable TimeZone=Asia/Tokyo", dbHost, dbPort, dbUser, dbPassword, dbName, ) var err error // 接続リトライ (コンテナ起動順序の問題に対応) for i := 0; i < 30; i++ { db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) if err == nil { log.Println("データベースに接続しました") return } log.Printf("データベース接続待機中... (%d/30)", i+1) time.Sleep(2 * time.Second) } log.Fatalf("データベース接続に失敗しました: %v", err) } // ======================================== // APIハンドラー // ======================================== // handleRoomStatus は部室状態を受信して保存するエンドポイント func handleRoomStatus(c echo.Context) error { // リクエストボディのバインド req := new(RoomStatusRequest) if err := c.Bind(req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "リクエストボディの解析に失敗しました", }) } // バリデーション if req.PeopleCount < 0 || req.Co2Ppm < 0 { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "people_countとco2_ppmは0以上である必要があります", }) } // 現在時刻を取得 (JST) now := time.Now() // RoomLogの作成と保存 roomLog := RoomLog{ RecordedAt: now, PeopleCount: req.PeopleCount, Co2Ppm: req.Co2Ppm, } if err := db.Create(&roomLog).Error; err != nil { log.Printf("ログ保存エラー: %v", err) return c.JSON(http.StatusInternalServerError, map[string]string{ "error": "ログの保存に失敗しました", }) } log.Printf("ログ保存完了: ID=%d, 人数=%d, CO2=%dppm", roomLog.ID, roomLog.PeopleCount, roomLog.Co2Ppm) // 非同期で通知判定を実行 (即座にレスポンスを返すため) go processNotifications(roomLog) // 成功レスポンス return c.JSON(http.StatusCreated, map[string]interface{}{ "message": "ログを保存しました", "id": roomLog.ID, }) } // ======================================== // 通知判定ロジック (非同期) // ======================================== // processNotifications は通知条件を判定し、必要に応じてDiscordへ通知を送信 func processNotifications(currentLog RoomLog) { // 1つ前のログを取得 var previousLog RoomLog result := db.Where("id < ?", currentLog.ID).Order("id DESC").First(&previousLog) // 前回のログが存在しない場合は通知しない if result.Error != nil { log.Println("前回のログが存在しないため、通知判定をスキップします") return } // 入室アラート判定 checkEntranceAlert(currentLog, previousLog) // 換気アラート判定 checkVentilationAlert(currentLog, previousLog) } // checkEntranceAlert は入室アラートを判定して通知 func checkEntranceAlert(current, previous RoomLog) { // 条件: 現在人数 > 0 かつ 現在人数 > 前回人数 if current.PeopleCount > 0 && current.PeopleCount > previous.PeopleCount { message := fmt.Sprintf("🚪 **入室通知**\n部室に人が入室しました!\n現在の人数: **%d人**", current.PeopleCount) sendDiscordNotification(message) log.Printf("入室通知を送信しました: %d人 -> %d人", previous.PeopleCount, current.PeopleCount) } } // checkVentilationAlert は換気アラートを判定して通知 (スパム防止付き) func checkVentilationAlert(current, previous RoomLog) { // 条件1: 現在人数 > 0 かつ CO2濃度 > 1000ppm if current.PeopleCount <= 0 || current.Co2Ppm <= 1000 { return } // 条件2: 閾値またぎ または 30分経過 shouldNotify := false if previous.Co2Ppm <= 1000 { // 閾値をまたいだ場合は通知 shouldNotify = true } else { // 30分経過しているか確認 shouldNotify = checkVentilationCooldown() } if !shouldNotify { return } // 通知送信 message := fmt.Sprintf("⚠️ **換気アラート**\nCO2濃度が高くなっています!\n現在のCO2濃度: **%d ppm**\n換気を行ってください。", current.Co2Ppm) sendDiscordNotification(message) // 通知履歴に記録 notification := NotificationHistory{ SentAt: time.Now(), AlertType: "ventilation", } if err := db.Create(&notification).Error; err != nil { log.Printf("通知履歴の保存に失敗しました: %v", err) } log.Printf("換気アラートを送信しました: CO2=%dppm", current.Co2Ppm) } // checkVentilationCooldown は換気通知のクールダウン (30分) を確認 func checkVentilationCooldown() bool { var lastNotification NotificationHistory result := db.Where("alert_type = ?", "ventilation").Order("sent_at DESC").First(&lastNotification) // 通知履歴がない場合は通知OK if result.Error != nil { return true } // 30分経過しているか確認 cooldownDuration := 30 * time.Minute return time.Since(lastNotification.SentAt) >= cooldownDuration } // ======================================== // Discord通知 // ======================================== // DiscordWebhookPayload はDiscord Webhookへ送信するペイロード type DiscordWebhookPayload struct { Content string `json:"content"` } // sendDiscordNotification はDiscord Webhookへメッセージを送信 func sendDiscordNotification(message string) { webhookURL := os.Getenv("DISCORD_WEBHOOK_URL") if webhookURL == "" { log.Println("DISCORD_WEBHOOK_URL が設定されていないため、通知をスキップします") return } payload := DiscordWebhookPayload{Content: message} jsonData, err := json.Marshal(payload) if err != nil { log.Printf("Discord通知ペイロードの作成に失敗しました: %v", err) return } resp, err := http.Post(webhookURL, "application/json", bytes.NewBuffer(jsonData)) if err != nil { log.Printf("Discord通知の送信に失敗しました: %v", err) return } defer resp.Body.Close() if resp.StatusCode >= 400 { log.Printf("Discord通知がエラーを返しました: ステータスコード %d", resp.StatusCode) return } log.Println("Discord通知を送信しました") }

00_init.sql

-- 部室状態検知システム - データベース初期化SQL -- PostgreSQL 15用 -- room_logs テーブル: 部室の環境ログを保存 CREATE TABLE IF NOT EXISTS room_logs ( id BIGSERIAL PRIMARY KEY, recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, people_count INTEGER NOT NULL, co2_ppm INTEGER NOT NULL ); -- recorded_at にインデックスを作成 (時系列クエリの高速化) CREATE INDEX IF NOT EXISTS idx_room_logs_recorded_at ON room_logs(recorded_at); -- notification_history テーブル: 通知履歴を保存 (スパム防止用) CREATE TABLE IF NOT EXISTS notification_history ( id BIGSERIAL PRIMARY KEY, sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, alert_type VARCHAR(20) NOT NULL ); -- alert_type にインデックスを作成 (通知タイプ別の検索高速化) CREATE INDEX IF NOT EXISTS idx_notification_history_alert_type ON notification_history(alert_type);

.env.example

# 部室状態検知システム - 環境変数テンプレート # このファイルをコピペして .env にリネームし、実際の値を設定してください # データベース設定 DB_USER=user_name DB_PASSWORD=password DB_NAME=db_name # Discord Webhook URL (通知用) # 参考: https://support.discord.com/hc/ja/articles/228383668 DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/your_webhook_id/your_webhook_token # API認証キー (デバイスからのリクエスト認証用) API_SECRET_KEY=your_secret_key

.gitignore

--- --- Server_Implementation_Spec_Go.md .env .env.* !.env.example --- Go言語のビルド生成物 --- コンパイルされたバイナリファイル app/main *.exe *.test *.out --- OSやエディタの自動生成ファイル --- .DS_Store Thumbs.db .vscode/ .idea/ *.swp --- コンテナのデータディレクトリ --- ※もしcompose.yamlでローカルパスを指定している場合用 db_data/ grafana_data/ pg_data/

compose.yaml

# 部室状態検知システム - コンテナ構成定義 # Podman Compose用 services: # APIサーバー (Go/Echo) app: build: context: ./app ports: - "8000:8000" depends_on: - db restart: always env_file: - .env environment: - DB_HOST=db - DB_PORT=5432 - DB_USER=${DB_USER} - DB_PASSWORD=${DB_PASSWORD} - DB_NAME=${DB_NAME} - DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL} - API_SECRET_KEY=${API_SECRET_KEY} # データベース (PostgreSQL) db: image: postgres:15-alpine ports: - "5432:5432" env_file: - .env environment: - POSTGRES_USER=${DB_USER} - POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_DB=${DB_NAME} volumes: - db_data:/var/lib/postgresql/data - ./db/init:/docker-entrypoint-initdb.d restart: always # ダッシュボード (Grafana) grafana: image: grafana/grafana-oss:latest ports: - "3000:3000" user: root volumes: - grafana_data:/var/lib/grafana restart: always # ボリューム定義 volumes: db_data: grafana_data:

7.動作検証

システムを実際に部室へ導入し、運用を行った結果を示します。

Discord通知による状況把握

人数の通知
CO2の通知
入室検知やCO2濃度の閾値超過が発生した際、Discordへ即座に通知が届くことを確認しました。

Grafanaによる可視化

現在の部室状態
部室状態の推移
蓄積されたデータはGrafanaによってリアルタイムにグラフ化されています。直近の状況を一目で把握できる表示と、傾向分析が可能な時系列グラフの双方により、部室の環境が正しくモニタリングされていることが確認できました。

以上の通り、エッジでのデータ取得からサーバーでの処理、そしてユーザーへのフィードバックまで、システム全体が設計通りに安定稼働しています。

8.おわりに

今回は「SPRESENSE活用コンテスト」への参加を機に、私たちのサークルが抱える課題に対し技術の力でアプローチを試みました。

システムの本格稼働は始まったばかりであり、長期的な変化を評価するには至っていませんが、Discordでの通知をきっかけに部室へ足を運ぶメンバーが現れるなど、部内のコミュニケーション活性化に向けた確かな手応えを感じています。

今後の展望

今後は、システムをより使いやすく、よりサークル運営に貢献できる形へと進化させていきたいと考えています。

  1. ダッシュボードへのアクセス性の向上
    現在は学内ネットワークに閉じた運用を行っていますが、大学の厳格なネットワークポリシーを考慮しつつ、Firebaseなどのセキュアなクラウドサービスを中継させることで、学外からでも安全に部室の状況を確認できる仕組みの導入を検討しています。
  2. データ活用によるイベント企画
    蓄積されたデータを分析し、部員が集まりやすい時間帯や曜日を特定することで、LT会やゲーム大会などの部内イベントをより効果的に開催するための指標として活用していきたいです。

開発を通じて、SPRESENSEが持つエッジAIの計算能力と省電力性のバランスの良さに改めて可能性を感じました。今回は環境モニタリングという用途でしたが、まだまだ引き出せるポテンシャルがあると感じています。来年度以降も、この小さなボードの可能性を探求し続けていきたいと考えています。

最後に、開発に協力してくれたインフラ班のメンバー、テスト運用に参加してくれたサークルの仲間たち、そしてこのような貴重な開発の機会を与えてくださったコンテスト関係者の皆様に、心より感謝申し上げます。

ログインしてコメントを投稿する