1000円台で筋トレの実行回数カウンターを作ってみた

はじめに


こんにちは。NTT西日本ルセントの福井です。

みなさんは、筋トレのときに「動画見ながらやりたいけど数をよく忘れる」「派手なエフェクトがないとモチベーションが維持できない」といったお悩みはないですか?
ないですか……。
であれば、何かのタスク専用のハードウェア/ソフトウェアに興味はありませんか?
ありますよね。

今回はそういった悩みや興味へのアプローチとして、筋トレ実行回数カウンター「マッスルカウンター Mk.1」を作ってみたのでご紹介します。

※本記事に記載されている情報は、あくまでも筆者個人の調査・経験に基づくものであり、所属組織を代表するものではありません。
また、本記事の内容は所属組織の業務に関係のない趣味的な内容で、技術情報の共有を目的としています。
本サイトは、LEGO®/レゴ®の商標所有者であるレゴグループの承認・許可・スポンサー契約を得て運営しているものではありません

「マッスルカウンター Mk.1」

対象読者


  • ものづくりに興味がある方
  • 専用のハードウェアにロマンを感じる方
  • Raspberry Pi Picoシリーズなどの汎用マイコンを買ったものの、放置している方

この記事で説明しないこと


  • 開発環境の準備方法
  • ハードウェア部品の調達先
  • 筋トレのメニュー

目的


私は最近筋トレを始めたのですが、1セット内で何回やったかよく忘れます。
それだけならいいのですが、ちょっとしたフラストレーションで習慣化に失敗することも非常に得意です。

そのような場合の個人的な解決策として「専用のハードウェアやソフトウェアを作ってしまう」というものがあります。
これはある種の特効薬で、下記の効果が見込めます。

  • 単純に便利だから習慣化しやすい
  • せっかく作ったものなので、使いたくなる
  • 飽きたとしてもこうして記事を作ってしまえば誰かの役に立つ可能性がある

これは、やるしかないですね。

要件と仕様


上記の目的から要件を下記のように固めました。

  • 出来るだけコンパクトに設計する
    • ハード側は汎用のマイコンを使い、コードはArduino言語*1で書く
    • 表示器はスマートフォンとし、Webアプリ1ページ構成とする
  • ソフトとハードを修正・分解・再利用がしやすいようにする
    • ハード側ケースにはLEGO®ブロックとキーボード用スイッチを利用する

ここから仕様は下記とします。

  1. マイコンとスマートフォンをBluetooth又は有線で接続。マイコンをキーボードとして認識させる。
  2. マイコンのスイッチが押されたとき、キー入力をスマートフォンに送信。
  3. スマートフォンのブラウザで開かれたWebアプリでキー入力を解釈し、値を増やす。

技術構成


ハードウェア

汎用マイコン
(この記事ではRaspberry Pi Pico W)
900~1200円前後
キースイッチ 1コ 100円以下
電池ボックス 1コ
(Bluetooth利用時のみ使用)
100円以下
ケース用LEGO®ブロック (今回は手持ちのものを使用)

ハードウェア側言語

  • Arduino言語

Webアプリ

  • Next.js 16
  • React 19
  • Tailwind CSS

実装手順


1. スイッチのはんだ付け

まず、スイッチをPico Wにはんだ付けします。
今回は位置的に接続しやすいので、GP16のピンとその近くのGNDを使います。

Pico Wのピンアサイン(公式のドキュメントから引用)

通常、間に抵抗を挟まないと電圧が不定となってしまい、ノイズの影響を受けてしまいますが、 実はRaspberry Pi Picoシリーズを含め、最近の汎用マイコンはプルアップ抵抗を内蔵しています。
しかもコード側から指定できるので、抵抗は不要です。

スイッチはなんでもいいのですが、せっかくなのでメカニカルキーボード用のキースイッチを使います。
押下時のカチャッとした音が大きいキースイッチのほうが押したい動機づけになるのでおすすめです。

メカニカルキーボード用のキースイッチ

2. コードの書き込みとテスト

下記コードをArduino IDEからPico Wに書き込みます。

(クリックで展開)

#include <KeyboardBT.h>  // Bluetoothキーボードライブラリ

// --- ユーザー設定 ---
const int switchPin = 16;        // スイッチを接続するピン番号
const long longPressTime = 500;  // 500ミリ秒以上押したら「長押し」と判定
const long debounceDelay = 50;   // 50ミリ秒のチャタリング防止時間

// --- 内部状態を管理する変数 ---
int switchState;                     // 現在のスイッチの状態 (HIGH or LOW)
int lastswitchState = HIGH;          // 前回のスイッチの状態
unsigned long lastDebounceTime = 0;  // 最後にチャタリングが検出された時刻
unsigned long switchPressTime = 0;   // スイッチが押された時刻
boolean longPressExecuted = false;   // 長押しが既に送信されたかどうかのフラグ

void setup() {
  Serial.begin(115200);
  pinMode(switchPin, INPUT_PULLUP);

  // Bluetoothキーボードを開始
  KeyboardBT.begin("BT Muscle Counter");
  Serial.println("Bluetoothキーボードを開始しました。接続待機中...");
  delay(2000);
  switchState = digitalRead(switchPin);
  lastswitchState = switchState;
}

void loop() {
  int reading = digitalRead(switchPin);

  // 最後に読み取った物理状態と異なる場合チャタリングの可能性
  if (reading != lastswitchState) {
    // デバウンスタイマーをリセット
    lastDebounceTime = millis();
  }

  if ((millis() - lastDebounceTime) > debounceDelay) {

    // 物理状態変化検知
    if (reading != switchState) {
      switchState = reading;

      // スイッチが押された瞬間
      if (switchState == LOW) {
        switchPressTime = millis();
        longPressExecuted = false;
        Serial.println("スイッチが押されました");
      }
      // スイッチが離された瞬間
      else {
        Serial.println("スイッチが離されました");
        // 長押し処理がされていない場合カウンタ追加キーを送信
        if (longPressExecuted == false) {
          Serial.println("短押し");
          KeyboardBT.write('KEY_F13');
        }
      }
    }
  }

  // スイッチが押され続けている場合
  if (switchState == LOW) {

    if ((millis() - switchPressTime > longPressTime) && (longPressExecuted == false)) {
      Serial.println("長押し");
      KeyboardBT.write('KEY_F14');
      longPressExecuted = true;  // 長押し処理を実行済みにする
    }
  }

  // 最後に読み取った物理状態を保存
  lastswitchState = reading;
}

このコードは以下の仕様で作りました。

  • スイッチを短く押した場合と長押しした場合でキー入力を出しわける
    • 後述のWebアプリでは短押しをカウントアップ、長押しをカウントダウンとして実装します。
  • KeyboardBTライブラリを使う
    • よくESP32で使われるBleKeyboardライブラリは、Pico Wではペアリングがほとんどできなかったです。
  • チャタリング防止のため、デバウンスタイムを入れる
    • スイッチの押下時には、微細な振動によって接点が複数回触れてしまうことがあります(チャタリング)。
    • 人間は運動しているとものすごく振動するので、チャタリングを防ぐためにスイッチ押下時に反応しない期間を作ります(デバウンスタイム)。

また、KeyboardBT.hKeyboard.hに置き換えれば有線でも動くと思います。

コード自体は標準的なものだと思いますが、重要な点が一つあります。

KeyboardBT.write('KEY_F13');

ここです。
PCでご覧になっている方はキーボードでF13キーを探してみてください。

F13キーがなかった方

今確認していただいた通り、F13キーとそれに続くF24キーまでのキーは一般的なキーボードにはほとんどありません。
しかし、プログラム的な扱いとしては存在していて、まれにShift+F(1~12)を押すことでそれに対応するキーを呼び出せるキーボードが存在します。
これを利用して「キー入力として認識はするが、実際に文字が入力されるわけではない」状態を作ります。

補足として、
- 後述のWebアプリができるまでのデバッグ時には通常のキーを割り当てておくと便利です。 - F1~F12キーにしない理由としては、それらのキーは何らかのショートカットに割り当てられていることが多いので、誤ってショートカットが入力されてしまう可能性を下げるためです。 - 13の理由として、F17キー以降のキーは環境によっては入力できないこと、MuscleのMが13番目のアルファベットであることがあります。

F13キーがあった方

ご存知の通り、F13キーとそれに続くF24キーまでのキーは一般的なキーボードにはほとんどありません。
どのようなキーボードをお使いか非常に興味があるので、もし良ければ記事末尾のシェアボタンからキーボードの情報を教えてください!

3. Webアプリの実装

WebアプリはNext.js、Tailwind CSSを使って実装しました。

メインページのコードを参考までに載せておきます。

page.tsx

'use client';

import { useState, useEffect } from 'react';
import Confetti from 'react-confetti';
import { useWindowSize } from '@react-hook/window-size';

export default function Home() {
  const [count, setCount] = useState(0);
  const [keyMode, setKeyMode] = useState<'MN' | 'F13F14'>('MN');
  const [showConfetti, setShowConfetti] = useState(false);
  const [confettiOrigin, setConfettiOrigin] = useState({ x: 0.5, y: 0.5 });
  const [width, height] = useWindowSize();

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (keyMode === 'MN') {
        if (event.key === 'M' || event.key === 'm') {
          setCount(prev => prev + 1);
          triggerConfetti();
        } else if (event.key === 'N' || event.key === 'n') {
          setCount(prev => prev - 1);
        }
      } else if (keyMode === 'F13F14') {
        if (event.key === 'F13' || event.code === 'F13') {
          setCount(prev => prev + 1);
          triggerConfetti();
        } else if (event.key === 'F14' || event.code === 'F14') {
          setCount(prev => prev - 1);
        }
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [keyMode]);

  const triggerConfetti = () => {
    const counterElement = document.getElementById('counter-display');
    if (counterElement) {
      const rect = counterElement.getBoundingClientRect();
      const x = (rect.left + rect.width / 2) / window.innerWidth;
      const y = (rect.top + rect.height / 2) / window.innerHeight;
      setConfettiOrigin({ x, y });
    }
    setShowConfetti(true);
    setTimeout(() => setShowConfetti(false), 500);
  };

  const increment = () => {
    setCount(prev => prev + 1);
    triggerConfetti();
  };
  
  const decrement = () => setCount(prev => prev - 1);
  const reset = () => setCount(0);
  const toggleKeyMode = () => setKeyMode(prev => prev === 'MN' ? 'F13F14' : 'MN');

  return (
    <div className="flex min-h-screen items-center justify-center bg-amber-100 p-4">
      {showConfetti && (
        <Confetti
          width={width}
          height={height}
          colors={['#FF8C00', '#FFA500', '#FFB84D', '#FF7F00', '#FFD700', '#FFA000']}
          numberOfPieces={150}
          gravity={0.6}
          confettiSource={{
            x: confettiOrigin.x * width,
            y: confettiOrigin.y * height,
            w: 10,
            h: 10,
          }}
          initialVelocityX={25}
          initialVelocityY={25}
          recycle={false}
          tweenDuration={150}
          opacity={0.8}
        />
      )}
      
      <main className="w-full max-w-md">
        <div className="bg-orange-300 text-white p-4 rounded-t-3xl shadow-lg shadow-white">
          <div className="text-center">
            <div className="text-2xl font-bold tabular-nums tracking-wider">
              マッスルカウンター
            </div>
          </div>
        </div>

        {/* メインカウンター部分 */}
        <div className="bg-white pt-6 shadow-2xl shadow-white border-x-2 border-orange-300">
          <div className="text-center">
              <div
                id="counter-display"
                className="text-8xl font-bold text-orange-600 tabular-nums leading-none"
              >
                {count}
              </div>
          </div>
        </div>

        {/* ボタン部分 */}
        <div className="bg-white p-6 rounded-b-3xl shadow-lg shadow-white border-x-2 border-b-2 border-orange-300">
          <div className="flex gap-3 mb-6">
            <button
              onClick={decrement}
              className="flex-1 py-4 text-2xl font-bold text-red-600 bg-orange-200 hover:bg-orange-400 active:bg-orange-500 transition-all duration-200 rounded-xl shadow-sm transform active:scale-95"
            >
              − 1
            </button>
            <button
              onClick={increment}
              className="flex-1 py-4 text-2xl font-bold text-white bg-green-400 hover:bg-green-500 active:bg-green-300 transition-all duration-200 rounded-xl shadow-sm transform active:scale-95"
            >
              + 1
            </button>
          </div>

          <div className="flex flex-col gap-3">
            <button
              onClick={reset}
              className="w-full py-3 text-lg font-semibold text-orange-700 bg-white hover:bg-orange-100 active:bg-orange-200 transition-all duration-200 rounded-lg shadow-md border-2 border-orange-300 transform active:scale-98"
            >
              リセット
            </button>
            
            <button
              onClick={toggleKeyMode}
              className="w-full py-3 text-sm font-medium text-orange-700 bg-orange-200 hover:bg-orange-100 active:bg-orange-200 transition-all duration-200 rounded-lg shadow-sm"
            >
              キーモード: {keyMode === 'MN' ? 'M/N' : 'F13/F14'}
            </button>

            <div className="text-xs text-orange-700 text-center mt-2">
              {keyMode === 'MN' ? (
                <>
                  <kbd className="px-2 py-1 bg-orange-200 rounded font-mono">M</kbd> で +1 / 
                  <kbd className="px-2 py-1 bg-orange-200 rounded font-mono ml-1">N</kbd> で -1
                </>
              ) : (
                <>
                  <kbd className="px-2 py-1 bg-orange-200 rounded font-mono">F13</kbd> で +1 / 
                  <kbd className="px-2 py-1 bg-orange-200 rounded font-mono ml-1">F14</kbd> で -1
                </>
              )}
            </div>
          </div>
        </div>
      </main>
    </div>
  );
}

実装時に気を付けることは、キー入力の判定にKeyboardEvent.keyプロパティを使用した場合、AndroidではF13~F24キーUnidentifiedと判定されてしまいますが、KeyboardEvent.codeプロパティであれば正常に判定できます。
これはAndroidが想定しているキーボードレイアウトにF13~F24キーが存在しないために物理キーからのパースに失敗しているためだと思われます。

4. ケースのビルド

私はもともと、自分しか使わないようなハードウェアを制作するとき3Dプリンタでケースを作ることがよくありました。 しかし製作途中での仕様の変更や、実際に使ってみて基板の位置やスイッチの配置を変えたくなったときに、設計をやり直して印刷を待つのはかなり面倒です。

その点、決定版になるまではLEGO®ブロックを採用しておけば、苦労が少なくて済みます。

この方法でケースを作るには、まず設計ソフトであるBrickLink Studioにマイコンの3DモデルデータをPartDesigner機能でインポートします。
Pico Wであれば公式のドキュメントで配布されています。

そのあと、基板を固定できるようにパーツを選定し、

タイルとプレートのパーツで覆えば完成です。 Studioの機能ですべて実際に存在するパーツを選定したので、既製品だけで作れました。

まとめ


実際に触れる・使えるモノができる達成感はひとしお

全体的に味付け薄目でサラッと書いてますが、ハードウェアが動く達成感はソフトウェア開発とはまた違った味わいがあります。
なんでもLLMに流れてしまう昨今、ハードウェアは人間に残されたフロンティアの一つかもしれません。

この記事で自分用ハードウェア製作に興味をもってくださる方が一人でもいれば幸いです。

執筆者


福井 凱理(NTT西日本ルセント)

商標


  • 「Raspberry Pi」はRaspberry Pi財団の登録商標です。
  • 「Arduino」はArduino SAの商標です。
  • 「LEGO」「レゴ」「BrickLink」はLEGO Group.の登録商標です。
  • 「Next.js」はVercel Inc.の商標です。
  • 「React」は、Meta Platforms, Inc.の商標または登録商標です。
  • 「Tailwind CSS」はTailwind Labs Inc.の商標です。
  • 「ESP32」は、Espressif Systems社の登録商標です。

*1:Arduino IDEとその環境で使用されるプログラミング言語を、本記事では「Arduino言語」と表記します。
公式サイトGetting Startedによると、「『Arduino プログラミング言語』としても知られるArduino APIは、C/C++ 言語に基づくいくつかの関数、変数、構造体で構成されています。(筆者訳)」とあり、またリファレンスにも「Arduino programming language」とあるためです。

© NTT WEST, Inc.