どうもカッツプロダクション代表のカッツです!
今回はずっとやりたかったRaspberry Pi Pico W2でNゲージを動かすやつ、やってみました。
個人的にはまだ完全完成とは言えないんですが、ピン周りの格闘を乗り越えてNゲージの車輪が走るところまではいけました!! 今回はその記録です。
もろもろ基礎となる回路はこちらを参考にしました、本当にありがとうございました!!
愛知環状鉄道!自分は秋田なんで北上線派です!
https://hub.uni-face.co.jp/n-gauge-model-train-with-a-raspberry-pi
目次
必要なもの
- モータードライバー
- Raspberry Pi Pico W2(これはお好みでなんでもよいかも)
- ハンダゴテ
- モータードライバーの半田付け必要なので
- パソコン
- Thonnyというアプリをインストール(Picoにファイルを転送する為)
あるとかなり便利なもの
- ジャンパ線
- 電子部品同士を一時的に接続(短絡)するための線
- ブレッドボード
- 半田付けを行わずにラズパイや電子部品をジャンパー線を穴に差し込むだけで、簡単に回路テストができる便利な基板
【重要】初っ端からどハマりモータードライバー(TB6612)との格闘と解決
かるく参考サイトを見た感じふむふむ簡単だなと思い配線してテスターで軽く動作テストと、、、と思ったら
どハマりTT
モータードライバー TB6612 との格闘開始です。
信号は出てるのにモーターが回らない?!
テスターで計測すると、Picoから速度信号(GP0)はちゃんと出ているんですよ。なのに、正転・逆転の信号(GP1, GP2)が0Vのままでモーターがうんともすんとも言わない状態が発生しました、、、
原因の特定!実はモータードライバーでなくてGP1とGP2が死んでいた?!
なんとGP1・GP2が死んでいたんです、、、こんなことあるの今のご時世に?!って思いましたTT
しかもやっかいなのが、スクリプト側でチェックしても一見大丈夫そうに見えるんですよ。「コードは正しいはずなのになぜ動かない」という状態でしばらく詰まりました(笑)。
臨時的に電圧チェック用のスクリプトを組んで、テスターで地道に1ピンずつ電圧を計測して、はじめて判明したんですよね。GP1・GP2が完全に死んでいることが、、、
from machine import Pin
import time
test_pin = Pin(2, Pin.OUT) //ここの番号を変えて物理チェック
print("ONにして")
test_pin.value(1)
このコードはThonnyというアプリを使ってPicoに転送しました。ThonnyはMicroPythonとの相性がよくて、Raspberry Pi系のホビー工作では定番中の定番です。ファイルをそのままPico上に保存できるので、わりと直感的に使えます。
ひとつ重要なポイントで、ファイル名をmain.pyにするとPicoの電源を入れた瞬間に自動実行が始まります。テスト中はtest_main.pyとか別の名前にしておくのが無難です。
スクリプト上は問題なく見えてもハード的に死んでいるピンがある、というのは初心者には罠すぎます!!

改めて死んだピンを回避して接続
死んでいるピンは使えないので、割り当てを以下に変更しました。
| 役割 | 変更前 | 変更後 |
|---|---|---|
| AIN1(正転) | GP1 | GP11 |
| AIN2(逆転) | GP2 | GP12 |
| PWMA(速度) | GP0 | GP13 |
改めまして再接続。GP11・12・13に変更したところ、無事にきちんと動きました!!
電圧系が動いた!!
Nゲージコントローラー回路図の概要
ここはショートして火花が散ったり、機材が破損することがあるので十分に注意してください!
バチ!っと経験済みですが火傷も感電もせず無事生きてます 笑
制御側

| ラズパイ Pico 2 W | AE-TB6612 | 備考 |
|---|---|---|
| GP13 | PWMA | 速度制御 PWM |
| GP11 | AIN1 | 方向制御 |
| GP12 | AIN2 | 方向制御 |
| 3.3V | VCC | ロジック電源 |
| GND | GND | 共通GND(外部電源GNDも) |
| — | STBY | VCC直結(常時ON) |
電源入力・出力側

| ピン名 | 接続先 | 備考 |
|---|---|---|
| VM | 12V電源 +極 | モーター駆動電源(直流) |
| GND | 12V電源 −極 | パワーGND(Pico GNDと共通) |
| AO1 | Nゲージ レール +極 | 車両進行方向に依存 |
| AO2 | Nゲージ レール −極 | 車両進行方向に依存 |
Nゲージコントローラーの仕様策定
ハード側の目処が立ってきたので、次はスマホのブラウザから動かせる「多機能パワーパック」の設計を考えました。
実装した仕様はこんな感じです。PicoなのでMicroPython想定です。
| 項目 | 仕様 |
|---|---|
| 最大電圧 | 10V(12V補助電源を想定し、最大Duty比を制限) |
| 方向切り替え | リバース(進行方向)切り替えあり |
| 緊急停止 | 非常停止ボタンあり |
| 通信方式 | Ajax(Fetch API)式。ページ遷移なしで高速応答 |
10V制限にしているのはNゲージの車両保護+趣味です。12Vフルで流すと一部の車両で脱線転覆などやばいことになるので+暴走が嫌いなので(笑)。
通信方式:APモードかUSB有線か
APモード(FlashAir風)
Pico自体が専用のSSIDを飛ばす方式。どこにでも持ち運べるロマンがあります。ただ、スマホの自動切断仕様やWi-Fiの電波干渉による「一瞬のカクつき・遅延」のリスクが常につきまとう。
有線(Web Serial API)
ChromeブラウザからUSB経由でPicoを直接叩く方式。Wi-Fiの混雑やエラー処理から完全に解放される。1ミリの遅延もない超安定動作が可能で、現代のWeb工作では大本命候補。
まずはUSB有線で動作を確認する選択肢もあったんですが、やっぱりコードレスのロマンには勝てない(笑)。 ということで、最初から本命のAPモード(Wi-Fi)一択でAI先生にプロンプトを投げることにしました!
Web Serial APIって知ってますか。ChromeブラウザからUSBシリアル通信を直接叩ける仕様で、ドライバいらず・インストールいらずでマイコンをブラウザから操作できるやつです。これが現代のWeb工作で割と最強なんですよね〜!!(wikiより補足しつつ)
Nゲージコントローラーのサンプルコード
ここは今の時代(2026年的にも)ヴァイブコーディングでやりましょうね〜でもどう動かすか仕組み等を知らないとまともなプロンプトは打てないので事前の勉強はある程度は必要かもですね汗 AIは使う人間によりポテンシャルが大きく変わりますね。
AIさんに投げるプロンプトはこちらにしました。
# 用途 スマホ(webブラウザベース)で制御できるNゲージコントローラー直流DC10v~0で制御する。 # 仕様など |項目|仕様| |-|-| |最大電圧|10V(12V補助電源を想定し、最大Duty比を制限)| |ピン周り|pwm=13,GP11はAIN1,GP12あAIN2を利用| |方向切り替え|リバース(進行方向)切り替えあり| |緊急停止|非常停止ボタンあり| |HTTP通信方式|Ajax(Fetch API)式。ページ遷移なしで高速応答 **メモリを節約するため**| |スマホとの通信方式|APモード いわゆるflasair風でpicoがホストになる| **LEDチカで動作状況の確認ができる様にしてください** **wifi周りの?極力バグが少なくなる構成で** **pico w2前提なのでmicropythonでお願いします** **1ファイルにまとめるようにしてください**
プロンプトを投げるAIさんはなんでもいいですよ。いい感じの多分コードが吐き出されます。
こちら自分の環境で出力されたコードです。
このコードをラズパイにコピペ!
from machine import Pin, PWM
import network
import socket
import time
import re
# ==========================================
# 1. 各種設定
# ==========================================
SSID = "Pico2W_Train"
MAX_DUTY = 54612
# ==========================================
# 2. ハードウェア初期設定
# ==========================================
motor_in1 = Pin(11, Pin.OUT) # AIN1
motor_in2 = Pin(12, Pin.OUT) # AIN2
motor_pwm = PWM(Pin(13)) # PWMA
# 初期状態は停止
motor_in1.value(0)
motor_in2.value(0)
motor_pwm.freq(1000)
motor_pwm.duty_u16(0)
led = Pin("LED", Pin.OUT)
# ==========================================
# 3. モーター制御ロジック
# ==========================================
def control_motor(direction, speed_percent):
duty = int((speed_percent / 100.0) * MAX_DUTY)
if direction == "fwd":
motor_in1.value(1)
motor_in2.value(0)
motor_pwm.duty_u16(duty)
elif direction == "rev":
motor_in1.value(0)
motor_in2.value(1)
motor_pwm.duty_u16(duty)
else:
motor_in1.value(1)
motor_in2.value(1)
motor_pwm.duty_u16(0)
# ==========================================
# 4. WebUI HTML & JavaScript
# ==========================================
html_template = """<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nゲージ コントローラー</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; background: #222; color: #fff; padding: 20px; }
.container { max-width: 400px; margin: 0 auto; background: #333; padding: 20px; border-radius: 10px; box-shadow: 0 4px 10px rgba(0,0,0,0.5); }
h1 { font-size: 1.5rem; color: #00ffcc; }
.panel { margin: 20px 0; }
input[type="range"] { width: 100%; height: 15px; border-radius: 5px; background: #555; outline: none; -webkit-appearance: none; }
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 30px; height: 30px; border-radius: 50%; background: #00ffcc; cursor: pointer; }
.btn { display: inline-block; width: 45%; padding: 15px 0; margin: 5px 2%; font-size: 1.1rem; font-weight: bold; border: none; border-radius: 5px; cursor: pointer; color: white; }
.btn-fwd { background: #28a745; }
.btn-rev { background: #dc3545; }
.btn-stop { background: #ffc107; color: #000; width: 94%; margin-top: 15px; }
.btn.active { border: 3px solid #fff; box-shadow: 0 0 15px #00ffcc; }
.status-val { font-size: 2rem; font-weight: bold; color: #00ffcc; margin: 10px 0; }
</style>
</head>
<body>
<div class="container">
<h1>N-SCALE CONTROLLER</h1>
<div class="panel">
<div>速度ダイヤル</div>
<div class="status-val"><span id="speed-display">0</span> %</div>
<input type="range" id="speed-slider" min="0" max="100" value="0">
</div>
<div class="panel">
<div>進行方向 (リバーススイッチ)</div>
<button class="btn btn-fwd" id="btn-fwd" onclick="setDirection('fwd')">前進 (FWD)</button>
<button class="btn btn-rev" id="btn-rev" onclick="setDirection('rev')">後進 (REV)</button>
<button class="btn btn-stop active" id="btn-stop" onclick="setDirection('stop')">非常停止</button>
</div>
</div>
<script>
let currentDir = "stop";
let currentSpeed = 0;
const slider = document.getElementById('speed-slider');
const speedDisplay = document.getElementById('speed-display');
slider.addEventListener('input', (e) => {
currentSpeed = e.target.value;
speedDisplay.innerText = currentSpeed;
sendCmd();
});
function setDirection(dir) {
currentDir = dir;
document.querySelectorAll('.btn').forEach(b => b.classList.remove('active'));
document.getElementById(`btn-${dir}`).classList.add('active');
if(dir === 'stop') {
currentSpeed = 0;
slider.value = 0;
speedDisplay.innerText = 0;
}
sendCmd();
}
function sendCmd() {
fetch(`/ajax?dir=${currentDir}&speed=${currentSpeed}`)
.then(response => response.text())
.catch(err => console.log("通信エラー:", err));
}
</script>
</body>
</html>
"""
# ==========================================
# 5. Wi-Fi アクセスポイント(AP)モード初期化
# ==========================================
ap = network.WLAN(network.AP_IF)
ap.active(False) # 最初は完全にOFFにしてリセット
time.sleep(0.5)
ap.active(True) # 電波送信ON
ap.config(essid=SSID, security=0)
led.value(1) # 稼働中はLED点灯
print("\n--- APモード起動完了 ---")
print("SSID: ", SSID)
print("URL : http://192.168.4.1")
print("※プログラムを停止すると自動でWi-FiはOFFになります。\n")
# ==========================================
# 6. メインループ(サーバー処理 & 安全クリーンアップ)
# ==========================================
addr = socket.getaddrinfo('192.168.4.1', 80)[0][-1]
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(addr)
s.listen(1)
# try-finally構文で、停止時に必ずWi-FiをOFFにする
try:
while True:
try:
cl, addr_client = s.accept()
request = cl.recv(1024).decode('utf-8')
first_line = request.split('\r\n')[0] if request else ""
if "/ajax" in first_line:
dir_match = re.search(r'dir=([^& ]+)', first_line)
speed_match = re.search(r'speed=([0-9]+)', first_line)
if dir_match and speed_match:
control_motor(dir_match.group(1), int(speed_match.group(1)))
cl.send('HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nOK')
elif "GET / " in first_line or "GET /?" in first_line:
cl.send('HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n')
cl.send(html_template)
else:
cl.send('HTTP/1.1 404 Not Found\r\n\r\n')
cl.close()
except Exception as e:
print("通信エラー(スキップします):", e)
if 'cl' in locals():
cl.close()
finally:
# ─── ここが自動シャットダウン処理 ───
print("\n--- プログラム停止を検知しました ---")
print("安全のためモーターを緊急停止します。")
control_motor("stop", 0) # モーター停止
print("Wi-Fiアクセスポイントを完全にOFFにします...")
if 's' in locals():
s.close() # サーバーソケットを閉じる
ap.active(False) # Wi-Fiチップの電波を完全に停止!
led.value(0) # 本体LED消灯
print("シャットダウン完了。電波は消去されました。")
さきほど言及したThonnyというアプリを使ってPicoに転送(Thonnyの使いかたはググった方が良いかもAIさんでも良いですが)!
ちなみにヴァイブコーディングで周波数指定するの忘れて1000Hzが指定されてて可聴域なので『キーン』って音が出ますが20kHZにすれば消えます。が、奇跡的にこの音が本物の車両ぽいので良い気もしました笑(実物の電車も殆どがPWM制御なので)
実際の回路とテストコードで走らせてみた!
実際に走らせたてみたデモ動画です。
ブラウザで動くってなんだか不思議〜
気づいたこと
GP11・12・13ピンへの変更+ジャンパピンでの接続で、しっかり動くことが確認できました!! 10V制限のテストコードでNゲージの車輪が走った瞬間はちょっとテンション上がりましたね〜!!
実際に組んで走らせてみて、WebUIってすごいな、、、と改めて思いました。スマホのブラウザからNゲージをコントロールできる、という体験がとにかく面白ーい!!
で、やってみてわかったのがいかに転送容量を減らすかが重要かということです。Ajax(Fetch API)でちまちまデータをやり取りする設計にしているんですが、これが正解だと実感しました。
Webサイトでも画像やCSS・JSの容量圧縮が話題になりますよね。でも組み込みのWebアプリはそれよりさらにシビアで、ほんのちょっとのレスポンスの重さがモーターの動きに直結するんですよ。「なんで容量小さくしないといけないの」が、Nゲージを動かすと体で理解できる笑。
WEB屋として体感的にはわかってたんですが! まさしくWebとハードウェアの学びが交差するやつですね!!