第4章

M5Stackでセンサーアプリを作る

UIFlow 2.0とGASを使い、CoreS3-Liteの近接・音・揺れをGoogleスプレッドシートに記録して可視化します。

この章で学ぶこと

  • UIFlow 2.0でMicroPythonのセンサーロガーを動かす
  • Google Apps Script(GAS)でセンサーデータを受け取る
  • Googleスプレッドシートに記録し、イベント数と測定値をグラフ化する
  • 検知のしきい値を調整し、結果を確かめる

作るアプリ

M5Stack CoreS3-Liteに手を近づける、手をたたく、本体を軽く揺らすと、そのイベントをGoogleスプレッドシートに記録するアプリを作ります。本体には現在の値と送信結果を表示し、パソコンではグラフを確認します。

CoreS3-Liteの内蔵センサー
    ↓ UIFlow 2.0 / sensor_log.pyで読み取り・検知
Wi-Fi経由でJSONをPOST
    ↓
GASのウェブアプリ(コード.gs)
    ↓ 受信日時を付けて1行追加
Googleスプレッドシート「センサーログ」
    ├─ ログ:受信したデータ
    └─ 可視化:イベント数と種類別のグラフ

参考にするのはセンサーログのGoogleスプレッドシートです。配布された センサーログ は列構成の参考資料として扱います。この章で作成・操作するのはGoogleスプレッドシートです。

配布された sensor_log.py の処理をもとに、接続情報を各自で設定できるコードを掲載しています。GASは参考アプリで使っている受信コードを掲載し、保存テスト用の補助関数を別途紹介します。

記録する3種類のイベント

イベント 動作 測定値の意味
near センサーに手や物を近づける 近接センサーの生の値。距離のcmではない
sound 手をたたくなど、音を出す 約50msの音声から計算するRMS値。音圧レベルのdBではない
shake 本体を軽く揺らす 連続する加速度の読み取り値の差の大きさ。単位はg

このアプリは検知イベントのロガーです。すべての測定値を一定間隔で保存するわけではありません。グラフの点は「検知して記録した値」を表し、点がない時間を測定値ゼロと解釈しないようにします。

1. 準備するもの

  • M5Stack CoreS3-Lite
  • データ通信に対応したUSB Type-Cケーブルとパソコン
  • 2.4GHzのWi-Fi環境
  • UIFlow 2.0のファームウェアとUIFlow 2.0 Web IDE
  • GoogleスプレッドシートとGASを使えるGoogleアカウント

CoreS3-Liteには近接センサー(LTR-553)、マイク、IMUが内蔵されています。この章では外付けのセンサーユニットを使いません。別のM5Stack機種ではセンサーやピン配置が異なるため、このコードをそのまま使えるとは限りません。CoreS3-Liteの仕様

ファームウェアの準備は前の章「M5Stackの環境構築」を参照してください。使用したファームウェアのバージョンを控えておくと、エラーが起きたときに比較できます。

2. Googleスプレッドシートを用意する

  1. Googleスプレッドシートを新規作成し、ファイル名を センサーログ にします。参考シートをコピーして使っても構いません。
  2. 記録先のシートタブ名を ログ にします。ファイル名とシートタブ名は別のものです。
  3. 1行目に次の見出しを入力します。順番と表記をそろえてください。
  4. 「ファイル → 設定」で地域を日本、タイムゾーンを東京にします。
A1 B1 C1 D1
受信日時 端末名 イベント 測定値

記録後は、次のような表になります。以下は説明用の例です。

受信日時 端末名 イベント 測定値
2026/09/28 14:00:00 CoreS3-Lite near 205
2026/09/28 14:00:05 CoreS3-Lite sound 3346
2026/09/28 14:00:10 CoreS3-Lite shake 0.589

A列はGASが受信処理中に付ける日時です。センサーで検知した瞬間の厳密な時刻ではなく、通信時間の影響を受けます。D列は計算やグラフに使える数値として保存します。

コピーしたシートを使う場合は、自分の実験と区別できるよう、コピー内の既存データの2行目以降を削除して始めましょう。参考元の共有シートは編集しません。

3. GASで受信プログラムを作る

スプレッドシートIDと合言葉を設定する

  1. 自分のスプレッドシートで「拡張機能 → Apps Script」を開き、プロジェクト名を センサーログ受信 にします。
  2. 「プロジェクトの設定」でタイムゾーンを Asia/Tokyo にします。
  3. エディタにある初期の myFunction を、次のコード全体に置き換えて保存します。

以下は参考アプリで使用しているGASです。自分のシートで試すときは、先頭の SHEET_ID を自分のスプレッドシートURLの /d/ と /edit の間の文字列に変更してください。SECRET はPython側にも同じ値を設定します。スクリプト プロパティの設定は不要です。

「コード.gs」に貼り付ける

const SHEET_ID = "1C-5Tkca6xbWJScU_KPH9MsaWz8eYrMjQJxKbY0LUIYQ";
const SECRET = "test";

function doPost(e) {
  try {
    const data = JSON.parse(e.postData.contents);

    if (data.token !== SECRET) {
      return reply({ ok: false, error: "合言葉が違います" });
    }

    const events = ["test", "near", "sound", "shake"];
    if (!events.includes(data.event) ||
        typeof data.value !== "number" ||
        !Number.isFinite(data.value)) {
      return reply({ ok: false, error: "データ形式が違います" });
    }

    const sheet = SpreadsheetApp
      .openById(SHEET_ID)
      .getSheetByName("ログ");

    const lock = LockService.getScriptLock();
    lock.waitLock(10000);
    try {
      sheet.appendRow([
        new Date(),
        "CoreS3-Lite",
        data.event,
        data.value
      ]);
      SpreadsheetApp.flush();
    } finally {
      lock.releaseLock();
    }

    return reply({ ok: true });
  } catch (err) {
    console.error(err.stack || String(err));
    return reply({
      ok: false,
      error: String(err.message || err)
    });
  }
}

function reply(data) {
  return ContentService
    .createTextOutput(JSON.stringify(data))
    .setMimeType(ContentService.MimeType.JSON);
}

SECRET = "test" は動作確認用の合言葉です。自分で運用するときは推測しにくい値に変更し、Python側の SECRET もそろえます。スプレッドシートIDと、後で取得するGASのウェブアプリURLは別のものです。

GASはHTTPのPOSTを受けると doPost(e) を呼び出します。JSON本文は e.postData.contents に入ります。token が合言葉と一致し、event が4種類のいずれか、value が有限の数値なら、端末名と受信日時を補って保存します。GASウェブアプリの仕様

このコードはシートや見出しを自動作成しません。第2節の手順で ログ とA1:D1の見出しを先に用意してください。A列は「表示形式 → 数字 → 日時」、D列は数値として表示し、必要に応じて列幅を広げます。

同時に届いた要求の書き込みは getScriptLock() と waitLock(10000) で順番に処理します。ロック取得を最大10秒待ち、取得できない場合はエラーになります。これは同時実行の競合を防ぐ処理で、再送による二重登録を防ぐものではありません。LockService

保存処理をテストする

次の関数は教材用のテスト補助です。上のコードの末尾に追加して保存します。

function testDoPost() {
  const response = doPost({
    postData: {
      contents: JSON.stringify({ token: SECRET, event: "test", value: 1 })
    }
  });
  console.log(response.getContent());
}
  1. エディタ上部の関数選択で testDoPost を選び、「実行」を押します。自分が作ったコードと要求される権限を確認して、スプレッドシートへのアクセスを許可します。
  2. 実行ログに {"ok":true} が表示され、「ログ」に test・1 の行が1つ増えることを確認します。

doPost を直接「実行」するとHTTPの引数 e がないためエラーになります。テストには testDoPost を使います。この段階で確認できるのはGAS内部の保存処理までです。外部からの通信は次の公開設定とM5Stackからの送信で確かめます。

4. GASをウェブアプリとして公開する

  1. 「デプロイ → 新しいデプロイ」を開き、種類に「ウェブアプリ」を選びます。
  2. 「次のユーザーとして実行」は 自分 にします。
  3. 「アクセスできるユーザー」は 全員(Googleへのログインを必要としない設定)にします。M5StackはGoogleアカウントにログインせずに送信するためです。
  4. デプロイ後に表示される、末尾が /exec のウェブアプリURLを控えます。
  5. このURLを第6節でPythonの GAS_URL に設定し、M5StackからPOSTして保存を確認します。

学校・会社のアカウントでは管理者の設定により「全員」を選べない場合があります。その場合は利用可能なアカウントや公開方法を管理者に確認してください。

この公開設定はGASの受信口に対するものです。スプレッドシート自体を「全員が編集可能」にする必要はありません。GASは自分の権限で書き込み、POSTごとにトークンを照合します。

このGASには doGet がないため、URLをブラウザで開いてもJSONの成功応答は表示されません。「doGetが見つからない」という表示だけではPOSTの動作を判断できません。通信確認はM5Stackから行います。コードを修正した後は「デプロイを管理 → 編集 → 新しいバージョン」で更新します。同じデプロイを更新すればURLを維持できます。/dev はこの教材の端末送信には使いません。ウェブアプリのデプロイ

5. UIFlow 2.0でsensor_log.pyを作る

本体を接続する

  1. CoreS3-LiteをUSBでパソコンにつなぎ、UIFlow 2.0 Web IDEを開きます。
  2. Controllerの選択でCoreS3用の環境を選び、WebTerminalから本体のシリアルポートに接続します。
  3. Pythonの編集画面を開き、次の完成コード全体を貼り付けます。この章ではUIFlow 2.0のMicroPythonコード編集を使います。
  4. パソコン側にもUTF-8のテキストとして sensor_log.py の名前で保存します。末尾が .py.txt にならないようにしてください。

接続画面の操作はCoreS3のUIFlow 2.0公式手順を参照してください。.py はPythonソースです。ブロックのプロジェクトファイル .m5f2 として読み込むものではありません。Pythonを直接編集した後は、ブロックから再生成する前にソースを保存しておきます。

最初は本体だけで確認する

完成コードは SEND_ENABLED = False にしてあります。この状態ではシートへ送信せず、本体の表示と検知を確認できます。Wi-FiやGASの接続情報は送信を始める段階で設定します。

sensor_log.pyの完成コードを開く
import M5
from M5 import Widgets, Mic, Speaker, Imu
import network
import time
import math
import struct
import requests2
from hardware import I2C, Pin

WIFI_SSID = "YOUR_WIFI_SSID"
WIFI_PASSWORD = "YOUR_WIFI_PASSWORD"

GAS_URL = "https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec"
SECRET = "test"

# ===== 感度の初期値(設置場所で調整してください) =====
# ONを超えたら検知、OFF以下に戻った状態が続くと次を検知できます。
NEAR_ON, NEAR_OFF = 200, 120       # 近接センサー生値。cmではありません。
SOUND_ON, SOUND_OFF = 1500, 800   # 16bit音声のRMS。dBではありません。
SHAKE_ON, SHAKE_OFF = 0.35, 0.12  # 前回からの加速度ベクトル変化(g)
COOLDOWN_MS = 3000               # 同じイベントは最低3秒間隔
RELEASE_MS = 500                 # 解除条件が0.5秒続いてから再検知
SEND_ENABLED = False            # 最初は画面確認のみ。送信するときにTrueへ変更

labels = {}
wlan = None
proximity_bus = None
audio_buffer = bytearray(800)    # 400 samples / 8000Hz = 50ms、符号付き16bit
previous_accel = None
gates = {}
saved_count = 0
failed_count = 0
last_display = None


class EventGate:
    def __init__(self, on, off):
        if off >= on:
            raise ValueError("OFF must be lower than ON")
        self.on = on
        self.off = off
        self.active = False
        self.low_since = None
        self.last_event = None

    def update(self, value, now):
        if self.active:
            if value <= self.off:
                if self.low_since is None:
                    self.low_since = now
                elif time.ticks_diff(now, self.low_since) >= RELEASE_MS:
                    self.active = False
                    self.low_since = None
            else:
                self.low_since = None
            return False

        if value >= self.on:
            # クールダウン中の新しい動作も一度ラッチし、遅れて送信しない。
            self.active = True
            allowed = (self.last_event is None or
                       time.ticks_diff(now, self.last_event) >= COOLDOWN_MS)
            if allowed:
                self.last_event = now
                return True
        return False


def status(message):
    print(message)
    if "status" in labels:
        labels["status"].setText(message)


def connect_wifi():
    global wlan
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    if wlan.isconnected():
        return
    status("Connecting Wi-Fi...")
    wlan.connect(WIFI_SSID, WIFI_PASSWORD)
    started = time.ticks_ms()
    while not wlan.isconnected():
        M5.update()
        if time.ticks_diff(time.ticks_ms(), started) > 30000:
            raise RuntimeError("Wi-Fi timeout")
        time.sleep_ms(100)


def get_location(response):
    for key, value in response.headers.items():
        if isinstance(key, bytes):
            key = key.decode()
        if key.lower() == "location":
            return value.decode() if isinstance(value, bytes) else value
    return None


def send_event(event, value):
    # 記録済みで応答だけ失われた可能性があるため、自動再送はしません。
    connect_wifi()
    response = None
    try:
        response = requests2.post(
            GAS_URL,
            json={"token": SECRET, "event": event, "value": value},
            headers={"Content-Type": "application/json"},
            timeout=15
        )
        # requests2の版により自動追跡されます。未追跡の場合だけGET。
        for _ in range(3):
            if response.status_code not in (301, 302, 303):
                break
            target = get_location(response)
            if not target or not target.startswith(
                "https://script.googleusercontent.com/"
            ):
                raise RuntimeError("Check GAS access settings")
            response.close()
            response = None
            response = requests2.get(target, timeout=15)

        if response.status_code != 200:
            raise RuntimeError("HTTP " + str(response.status_code))
        try:
            result = response.json()
        except ValueError:
            print("Non-JSON response:", response.text[:300])
            raise RuntimeError("Response is not JSON")
        if not isinstance(result, dict) or result.get("ok") is not True:
            print("GAS response:", result)
            raise RuntimeError("GAS could not save event")
    finally:
        if response is not None:
            response.close()


def init_proximity():
    global proximity_bus
    # CoreS3 Lite 内蔵バス: I2C1 / SCL=11 / SDA=12 / LTR553=0x23
    proximity_bus = I2C(1, scl=Pin(11), sda=Pin(12), freq=100000)
    # 読み取りに失敗すればエラーにする(0を正常値として扱わない)。
    part_id = proximity_bus.readfrom_mem(0x23, 0x86, 1)[0]
    manufacturer = proximity_bus.readfrom_mem(0x23, 0x87, 1)[0]
    print("LTR553 IDs:", hex(part_id), hex(manufacturer))
    if manufacturer != 0x05 or (part_id & 0xF0) != 0x90:
        raise RuntimeError("LTR553 ID mismatch")
    control = proximity_bus.readfrom_mem(0x23, 0x81, 1)[0]
    proximity_bus.writeto_mem(0x23, 0x81, bytes([control & 0xFC]))
    proximity_bus.writeto_mem(0x23, 0x82, b"\x7b")  # 60kHz, 100%, 50mA
    proximity_bus.writeto_mem(0x23, 0x83, b"\x08")  # 8 pulses
    proximity_bus.writeto_mem(0x23, 0x84, b"\x00")  # 50ms
    proximity_bus.writeto_mem(0x23, 0x81, bytes([(control & 0xFC) | 0x03]))
    time.sleep_ms(150)


def read_proximity():
    data = proximity_bus.readfrom_mem(0x23, 0x8D, 2)
    return data[0] | ((data[1] & 0x07) << 8)


def read_shake():
    global previous_accel
    accel = Imu.getAccel()
    if previous_accel is None:
        previous_accel = accel
        return 0.0
    delta = math.sqrt(sum((accel[i] - previous_accel[i]) ** 2
                          for i in range(3)))
    previous_accel = accel
    return delta


def read_sound_and_shake():
    # 録音待ちの間もIMUを読み、短い揺れの取りこぼしを減らす。
    if not Mic.record(audio_buffer, 8000, False):
        raise RuntimeError("Mic.record failed")
    started = time.ticks_ms()
    peak_shake = 0.0
    while Mic.isRecording():
        M5.update()
        peak_shake = max(peak_shake, read_shake())
        if time.ticks_diff(time.ticks_ms(), started) > 2000:
            raise RuntimeError("Microphone timeout")
        time.sleep_ms(5)
    M5.update()
    peak_shake = max(peak_shake, read_shake())

    total = 0
    squares = 0
    count = len(audio_buffer) // 2
    for offset in range(0, len(audio_buffer), 2):
        sample = struct.unpack_from("<h", audio_buffer, offset)[0]
        total += sample
        squares += sample * sample
    mean = total / count
    rms = math.sqrt(max(0.0, squares / count - mean * mean))
    return int(rms), peak_shake


def setup():
    global gates, previous_accel
    M5.begin()
    Widgets.fillScreen(0x222222)
    for name, y, text in (
        ("title", 10, "CoreS3 Sensor Logger"),
        ("near", 50, "Near: --"),
        ("sound", 80, "Sound: --"),
        ("shake", 110, "Shake: --"),
        ("status", 150, "Starting..."),
        ("count", 185, "Saved: 0  Failed: 0")
    ):
        labels[name] = Widgets.Label(
            text, 8, y, 1.0, 0xFFFFFF, 0x222222, Widgets.FONTS.DejaVu18
        )
    if SEND_ENABLED:
        if not GAS_URL.startswith("https://script.google.com/macros/s/") or not GAS_URL.endswith("/exec"):
            raise ValueError("Set GAS_URL to the working /exec URL")
        connect_wifi()
    init_proximity()
    Speaker.end()
    if not Mic.begin():
        raise RuntimeError("Mic.begin failed")
    gates = {
        "near": EventGate(NEAR_ON, NEAR_OFF),
        "sound": EventGate(SOUND_ON, SOUND_OFF),
        "shake": EventGate(SHAKE_ON, SHAKE_OFF)
    }
    status("Warming up...")
    # マイク起動直後のノイズを捨てる。
    for _ in range(10):
        read_sound_and_shake()
    previous_accel = None
    status("Monitoring" if SEND_ENABLED else "Local only")


def loop():
    global last_display, previous_accel, saved_count, failed_count
    M5.update()
    sound, shake = read_sound_and_shake()
    near = read_proximity()
    now = time.ticks_ms()
    values = {"near": near, "sound": sound, "shake": round(shake, 3)}
    # 各イベントを先に判定。1回の観測で複数種類を記録可能。
    events = [(name, value) for name, value in values.items()
              if gates[name].update(value, now)]
    if last_display is None or time.ticks_diff(now, last_display) >= 250 or events:
        for name, value in values.items():
            labels[name].setText(name + ": " + str(value))
        print("near=%d sound=%d shake=%.3f" % (near, sound, shake))
        last_display = now

    for event, value in events:
        print("EVENT:", event, value)
        if not SEND_ENABLED:
            status("Detected: " + event)
            continue
        status("Sending: " + event)
        try:
            send_event(event, value)
            saved_count += 1
            status("Saved: " + event)
        except Exception as error:
            failed_count += 1
            print("Send error:", error)
            print("Event not confirmed; check sheet. No automatic retry.")
            status("Send error: " + event)
        labels["count"].setText("Saved: %d Failed: %d" % (saved_count, failed_count))

    if events and SEND_ENABLED:
        # 通信中は観測していないので、解除時間や加速度差を持ち越さない。
        previous_accel = None
        for gate in gates.values():
            gate.low_since = None
    time.sleep_ms(5)


if __name__ == "__main__":
    try:
        setup()
        while True:
            loop()
    except KeyboardInterrupt:
        status("Stopped")
    except Exception as error:
        import sys
        status("Error: check terminal")
        sys.print_exception(error)
    finally:
        Mic.end()

実行してセンサーを確認する

「Run Once」で実行します。コードには無限ループがあるため、Run Onceでもプログラムを転送した後は監視が続きます。画面に Local only と near・sound・shake の値が表示されたら、次の順に試します。

  1. 本体を机に置き、何もしないときの値を確認します。
  2. 近接センサーの前に手を近づけ、near が増えるか確認します。
  3. 手を離してから手をたたき、sound が変化するか確認します。
  4. 本体を軽く揺らし、shake が変化するか確認します。

検知時は Detected: near などが表示され、WebTerminalに EVENT: の行が出ます。送信無効のため、この段階では Saved は増えません。

Pythonコードの読み方

処理 主な役割
setup() 画面、近接センサー、マイク、検知の状態を初期化する
init_proximity() / read_proximity() 内蔵I2Cバス経由でLTR-553の近接値を読む
read_sound_and_shake() 音のRMS値と、録音待ち中の加速度差の最大値を求める
EventGate.update() 検知・解除・クールダウンを判定する
send_event() JSONをPOSTし、GASの応答を確認する
loop() 測定、画面更新、必要なイベントの送信を繰り返す

マイクでは800バイトのバッファに16bit・モノラルの400サンプルを記録します。8000Hzなので約50ms分です。サンプルの平均値を引いたRMSを求め、音の強さを比較する目安にしています。音声そのものはGASに送りません。UIFlow 2.0のMic API

揺れは3軸の加速度について「今回の値 − 前回の値」を求め、そのベクトルの大きさを計算しています。傾きを変えても反応する場合があり、振動の周波数や震度を測定するコードではありません。

感度を調整する

イベント 検知する値(ON以上) 解除する値(OFF以下)
near 200 120
sound 1500 800
shake 0.35 0.12

これらはサンプルの初期値です。机、周囲の音、物の色や位置などで反応が変わるため、本体の表示を見て調整します。

  • ONを上げると検知しにくくなり、下げると検知しやすくなります。
  • OFFは必ずONより小さくします。2つのしきい値を分けることで、境界付近で何度も反応するのを抑えます。
  • 一度検知すると、OFF以下の観測が0.5秒以上続くまで解除しません。
  • 同じ種類のイベントは最低3秒間隔です。クールダウン中に起きた動作を、後から遅れて送信することはありません。

手を近づけたまま3秒待っても、繰り返し記録されるわけではありません。一度手を離して解除し、前回から3秒以上空けて再び近づけます。しきい値は1種類ずつ変更して、変更前後の結果を比べましょう。

6. Googleスプレッドシートへ送信する

コード先頭の次の値を自分の環境に変更します。合言葉はGASの SECRET と同じ値です。サンプルでは両方とも "test" にしています。

WIFI_SSID = "自分のWi-Fi名"
WIFI_PASSWORD = "自分のWi-Fiパスワード"
GAS_URL = "https://script.google.com/macros/s/自分のデプロイID/exec"
SECRET = "test"  # GASのSECRETを変更した場合は同じ値にする

さらに SEND_ENABLED = True に変更して、保存後にもう一度実行します。Monitoring と表示されたら、手を近づけて送信を試してください。

本体から送るJSONは次の形です。value は文字列ではなく数値です。

{
  "token": "test",
  "event": "near",
  "value": 205
}

requests2.post(..., json=...) でGASへ送信します。GoogleのContent Serviceは応答の取得先を script.googleusercontent.com にリダイレクトするため、コードには必要に応じてその応答をGETする処理が含まれています。元のPOSTをもう一度送る処理ではありません。requests2、GAS Content Service

送信成功を3か所で確認する

  1. 本体に Saved: near などが表示され、Saved の件数が増える。
  2. GASの「実行数」で doPost の実行を確認できる。
  3. 自分の「ログ」シートに、受信日時・CoreS3-Lite・イベント名・数値の行が増える。

同じ観測で近接と音の両方が検知されると、2行記録される場合があります。GAS側の端末名は appendRow 内の "CoreS3-Lite" で固定しています。複数端末を区別するには、送信データにも端末IDを追加する設計が必要です。

GASの応答は、HTTP 200だけでなく本文の ok が true かを確認します。たとえば {"ok":false,"error":"合言葉が違います"} は保存失敗です。

Failed は「保存できたことを確認できなかった回数」です。 シートに保存された後で応答だけが失われることもあります。二重登録を避けるためサンプルは自動再送しません。まずシートを確認します。

動作を確認できたらUIFlow 2.0の「Run Always」で本体へ保存し、再起動後の動作も確認します。起動メニューが出る場合は保存したプログラムを選択し、自動起動させる場合は起動設定を確認してください。ボタン名や起動設定は公式手順を参照します。

7. Googleスプレッドシートで可視化する

イベント数を棒グラフにする

新しいシートタブを作り、名前を 可視化 にします。次の表を入力します。B2〜B4には式を入力してください。

セル 入力内容
A1 イベント
B1 件数
A2 near
A3 sound
A4 shake
B2 =COUNTIF('ログ'!C2:C,A2)
B3 =COUNTIF('ログ'!C2:C,A3)
B4 =COUNTIF('ログ'!C2:C,A4)
  1. 可視化!A1:B4 を選び、「挿入 → グラフ」を選びます。
  2. グラフの種類を「縦棒グラフ」にします。
  3. タイトルを「イベント別の記録件数」、縦軸を「件数」にします。
  4. グラフをA7付近に移動します。

列全体の C2:C を参照するので、新しいログも集計対象になります。test の行は含まれません。この件数は記録されたイベント数で、動作や音が実際に発生した総数とは限りません。Googleスプレッドシートのグラフの種類

種類別の測定値を取り出す

近接値、音のRMS、加速度差は単位と値の大きさが異なります。測定値は種類別に3つのグラフに分けます。

同じ「可視化」シートに、次の見出しと式を入力します。式は各列の先頭セルだけに入力し、展開先のセルは空けておきます。

セル 見出し
D1 受信日時(near)
E1 近接値(生値)
G1 受信日時(sound)
H1 音(RMS)
J1 受信日時(shake)
K1 加速度差(g)

D2に入力します。

=IFNA(FILTER({'ログ'!A2:A,'ログ'!D2:D},'ログ'!C2:C="near"),"")

G2に入力します。

=IFNA(FILTER({'ログ'!A2:A,'ログ'!D2:D},'ログ'!C2:C="sound"),"")

J2に入力します。

=IFNA(FILTER({'ログ'!A2:A,'ログ'!D2:D},'ログ'!C2:C="shake"),"")

FILTER は条件に合う行を取り出します。{…,…} は受信日時と測定値を横に並べた2列の配列です。まだ対象イベントがないときの #N/A だけを IFNA で空欄にしています。未検知をゼロとして補完していません。FILTER関数

D・G・J列の表示形式を「日時」にします。数式は地域「日本」を前提にしています。結果が広がる先に文字や別の式があると #REF! になるので、D2:E・G2:H・J2:Kには手入力しません。

時刻と測定値を散布図にする

  1. 可視化!D1:E をデータ範囲としてグラフを挿入し、種類を「散布図」にします。
  2. X軸を受信日時、系列を近接値にします。1行目は見出しとして使います。
  3. タイトルを「近接イベントの測定値」、横軸を「受信日時」、縦軸を「近接値(生値)」にします。
  4. 同様に G1:H と J1:K でも散布図を作り、それぞれ音(RMS)、加速度差(g)のグラフにします。
  5. データ表を隠さないよう、3つの散布図はM列以降の空いている場所へ移動します。

散布図はX軸・Y軸の数値の組を点で示すグラフです。日時はシート内で数値として扱われます。横軸が数値表示になったら、グラフの横軸の数値形式を日時に設定します。範囲は D1:E4 のような固定行数にせず、D1:E のように追加行を含めます。散布図の設定

このグラフは検知した値だけを表示します。点を結んで「その間も同じように変化していた」と読まないようにします。常時の変化を調べたい場合は、イベントとは別に一定間隔の測定データを保存するよう改造します。

8. 完成を確認する

次の順で1つずつ確認します。動作の間には解除とクールダウンの時間を取り、手を離す・静かにする・本体を止める状態を挟みます。

試すこと 確認する結果
手を近づける near の行とグラフの点が増える
手をたたく sound の行とグラフの点が増える
本体を軽く揺らす shake の行とグラフの点が増える
手を近づけたままにする 同じnearイベントが連続記録されない
手を離し、十分待って再び近づける 新しいnearイベントを記録できる
新しい行を追加する 棒グラフの件数と散布図が更新される

手をたたいた振動で shake も検知するなど、1回の動作が複数のセンサーに影響することがあります。必ず1動作1行になると考えず、表示値とログを見比べてください。

Wi-Fi接続やHTTP通信中はセンサーの観測が止まります。また、通信が長引くとGASの受信時刻にも間隔が空きます。短い動作をすべて記録する用途や、一定周期の計測を保証する用途には、そのままでは使えません。

うまく動かないとき

症状・表示 確認すること
Wi-Fi timeout SSID・パスワード、2.4GHzへの接続、電波状態を確認する
LTR553 ID mismatch / I2Cエラー CoreS3-Lite用の環境か確認する。内蔵バスはSCL=11、SDA=12、アドレス0x23。外付けポートと混同しない
Mic.begin failed / Mic.record failed UIFlow 2.0のファームウェアと使用機種を確認し、再起動する。別の録音・再生プログラムを同時に動かさない
検知が多すぎる・検知しない SEND_ENABLED = False で平常時と動作時の値を見て、ON/OFFを調整する
HTTPエラー / Response is not JSON /exec URL、全員アクセス、自分として実行、デプロイ更新を確認する。ログイン画面やHTMLが返っていないか端末ログを見る
合言葉が違います PythonとGASの SECRET が一致しているか確認する
データ形式が違います イベント名がnear・sound・shake・testのどれか、valueが数値か確認する
その他のエラーメッセージ 応答のerrorとGASの実行履歴を見る。SHEET_ID、ログ シートの存在、実行権限を確認する
ロック取得のタイムアウト 別の書き込みが終わらなかった。シートを確認し、送信頻度や端末数を減らして試す
unexpected keyword argument / AttributeError ファームウェアのAPI差異を確認する。特に requests2 のtimeout・headers・リダイレクト処理を確認する
Savedが増えない SEND_ENABLED = True か確認する。WebTerminalの送信エラーとシートの両方を見る
グラフに新しい点が出ない ログの増加、FILTERの結果、数値・日時の型、グラフの参照範囲を順番に確認する
数式が#REF!になる 配列の展開先を空ける。必要なら「可視化」シートの行数も追加する

API差異があるときは、まず公式の requests2 の単純なGETサンプルが動くかを確認して、センサーの問題と通信の問題を分けます。実機のファームウェアによる違いはUIFlow 2.0のAPIドキュメントと照合してください。

やってみよう

  1. 静かな場所と音のある場所で sound の平常値を比べ、それぞれに合うON/OFFを考えましょう。
  2. しきい値を1つだけ変え、同じ動作を5回試して、記録件数と測定値の違いを説明しましょう。
  3. イベント記録とは別に、一定間隔で近接値を保存すると何が分かるでしょうか。通信中に観測できない点も含め、測定と送信を分ける方法を考えましょう。

完成したら、sensor_log.py、GASのコード、自分のGoogleスプレッドシート、使用したファームウェアのバージョンとしきい値を手元に残します。共有するPythonからはWi-Fiパスワードとトークンを取り除いてください。