鉄道部屋計画

はじめに

2025年 自由研究

鉄道が大好きで、集めた鉄道グッズを「実際に動かす」部屋を作りたくて、次の4つの装置を製作しました。
このレポートでは、使った器具の型番配線図・配線表動作のしかた必要なプログラムをまとめています。
なるべくはんだ付けをせず、ブレッドボードとジャンパワイヤで組みました(外れ防止にグルーガンで固定)。

はんだ最小 ブレッドボード配線 GND共通 安全第一(低電圧) 再現しやすい部品

研究のねらい

作った装置(4つ)

1. 車掌マイク
PM240 → マイクアンプ → パワーアンプ → スピーカーで拡声
2. 発車メロディ
トグルONでループ、OFFで注意音。Pico 2 W + DFPlayer
3. ドア開閉スイッチ
「開/閉」で別音源再生。「閉」完了でLED点灯
4. Nゲージ マスコン
ジョイスティックで加減速/惰性/警笛/方向表示

このレポートの読み方

私は電子工作もプログラミングも初心者の中学1年生ですが、AIに相談しながら完成させました。
はんだ付けは必要最小限。ジャンパは配線図どおりにし、外れやすい場所はグルーガンで固定しています。
AIをつかえば、プラモデルより簡単でした。

1.車掌マイク — PM240(京王電鉄・独自4ピン)

数年前に京王電鉄が開催した朝市で購入した車掌マイク(2千円。お年玉で!)を使えるようにしました。
マイクの型番は、PM240。けれども一般的に販売されているPM240と違っていました。おそらく京王電鉄独自仕様に改造されていて謎の4ピンマイクです。
インターネットや秋葉原でこのマイクがささるコネクタを探したのですがみつかりませんでした。
そこでワニ口クリップにつないで使いました。
配線資料が無かったため、テスターで各ピンの役割(Mic Hot(切れ目を上にして上) / Mic GND(切れ目を上にして左) / PTT 接点(切れ目を上にして下))を調べました。
マイクからの音声出力(Mic Hot / Mic GND)をマイクアンプ → 小型アンプへ入れて、スピーカーから声を出します。
電源は、持ち歩きもできるよう9V乾電池。
9V乾電池 → 電源モジュール VKLSVAN MB102 で 5V/3.3V を生成して使っています。
音声は Elekit NT-5(マイクアンプ)PAM8403(小型アンプ) → スピーカーの順に増幅・出力します。
Elekit NT-5(マイクアンプ)、PAM8403(小型アンプ)は、購入してからはんだごてで組み立てる必要があったのでそこは父に手伝ってもらいました。

動作のしくみ

使用した機器

名前 型番/リンク 役割 備考
車掌マイク 京王電鉄 車掌マイク PM240(独自4ピン) 声 → 微弱な電気信号に変換。Mic Hot(信号)/ Mic GND(基準)/ PTT 接点。 PTT は「押している間だけ話す」ための接点。今回は音声経路のみ使用。
電源モジュール VKLSVAN MB102(3.3V/5V) 電池やUSBから安定した 5V / 3.3V を作る。機器ごとに最適電圧を供給。 5V→NT-5、3.3V→PAM8403。GNDは全機器で共通にする。
マイクアンプ Elekit NT-5 “小さすぎるマイク信号”を持ち上げる(ノイズに埋もれないレベルへ)。 IN/OUT は各1端子(±なし)。はんだ組立キット。出力は次段PAM8403へ。
パワーアンプ PAM8403 ボード スピーカーを鳴らせる電力に増幅。小さくても十分な音量。 左右のうちL(左)だけ使用BTL出力のためSPK−をGNDに接続しない
スピーカー 10cm 8Ω 10W 電気の振動→空気の振動にして音を出す。 簡易エンクロージャで音量・低音が安定。極性(+/−)を合わせる。

配線図

京王 PM240(独自4ピン) Mic Hot / Mic GND PTT 接点 A / B アンプ(Elekit NT-5 / PAM8403) NT-5:IN ← Mic Hot(±なし) NT-5:OUT → PAM8403 IN L PAM8403:LOUT+/− → SPK+/− スピーカー(専用)

配線詳細

元の機器 元の端子 つなぐ先の機器 つなぐ先端子 備考
京王 PM240 Mic Hot(切れ目を上にして上) マイクアンプ(Elekit NT-5) IN 音声信号(単線)。
京王 PM240 Mic GND(切れ目を上にして左) 電源モジュール MB102 GND(5V 側) 全機器で GND 共通。
京王 PM240 PTT(切れ目を上にして下) 電源モジュール MB102 3.3V 将来のミュート制御などに利用可能。
Elekit NT-5 VCC / GND 電源モジュール MB102 5V / GND 近くに 0.1µF を置くと安定。
Elekit NT-5 OUT PAM8403 INPUT L(IN L) L チャンネルに接続。
PAM8403 Power+ / Power− 電源モジュール MB102 3.3V / GND 3.3V動作で十分な音量。
PAM8403 LOUT+ / LOUT− スピーカー + / − BTL出力。SPK−をGNDに落とさない。

工夫と注意

2.発車メロディスイッチ — 春日電機 BSW215B3

春日電機 BSW215B3 の実物スイッチで、駅ホームの操作に近い動きを再現しました。
スイッチを ON にしている間は、発車メロディ(/01/001.mp3)をくり返し再生します。
スイッチを OFF に戻すと、注意音声(/02/001.mp3)を1回だけ再生します。
制御は Raspberry Pi Pico 2 W(MicroPython)、再生は DFPlayer mini と専用スピーカーです。
給電は Raspberry Pi Pico 2 W の VBUS(5V) → DFPlayer mini です。

最初は OFF にしても音が止まらないことがありました。
割り込み(スイッチの変化をすぐに検出)とデバウンス(チャタリング防止)を入れ、
必ず STOP → 再生 の順でコマンドを送るようにしたところ、安定しました。
DFPlayer mini のフォルダ/ファイル名の仕様(例:/01/001.mp3)に合わせて、microSD を準備しています。
発車メロディは種類が多く、どれにするか迷いました。今後は曲の切り替えボタンや音量調整、再生状態の監視にも挑戦したいです。

使用した機器

名前 型番/リンク 役割 備考
発車メロディスイッチ 春日電機 BSW215B3 レバーを下げるとON、戻すとOFF。ONの間だけメロディを流すきっかけを作る。 「押し下げ保持」なので実際の駅の感覚に近い。
マイコン Raspberry Pi Pico 2 W スイッチの変化をすばやく検出し、DFPlayerへ「再生/停止」などの命令を送る。 内部プルアップ入力を使い、配線が簡単。
MP3プレイヤー DFPlayer mini microSD内の音声ファイルを再生。メロディはループ、注意音は1回だけ。 FAT32。フォルダ/ファイル名のルールに注意(例:/01/001.mp3)。
スピーカー 10cm 8Ω 10W DFPlayerの出力を音として鳴らす。 小さな箱でもエンクロージャに入れると音がしっかりする。
microSDカード 32GB以下 / FAT32 メロディや注意音のファイルを保存。 ルール通りのフォルダ/連番で配置。
ジャンパワイヤ/ブレッドボード はんだ付けなしで配線。 外れ防止にグルーガンで固定。

配線図

BSW215B3 Raspberry Pi Pico 2 W DFPlayer mini スピーカー(専用)

配線詳細

元の機器 元の端子 つなぐ先の機器 つなぐ先端子 備考
Raspberry Pi Pico 2 W GP4 DFPlayer mini RX(直列 1kΩ) UART1、9600bps
Raspberry Pi Pico 2 W GP5 DFPlayer mini TX UART1、9600bps
Raspberry Pi Pico 2 W GP2 春日電機 BSW215B3 接点(片側 → GND) 内部プルアップ。押すと 0
DFPlayer mini VCC / GND Raspberry Pi Pico 2 W VBUS(5V)/ GND Pico から給電
DFPlayer mini SPK+ / SPK− スピーカー + / − はんだ付け
microSD カード DFPlayer mini microSD スロット /01/001.mp3、/02/001.mp3

プログラム制御

操作を現実の駅に近づけ、鳴りっぱなし/鳴らないなどの不具合を防ぐため、プログラムは「確実に反応」「確実に切替」を重視しています。
この装置は、スイッチのON/OFFという1ビットの情報をきっかけに、DFPlayerへ正しい順番で命令を送り、ON中はメロディを連続OFFにした瞬間は注意音を1回という動きを作ります。

① 起動時設定
UART1開始(9600bps)→ 音量設定。microSDの準備ができる時間を少し待つ。
② スイッチ入力
GP2を内部プルアップ。押すと0、離すと1立下り/立上りの両方で割り込み。
③ デバウンス
機械スイッチ特有のバタつきを 数msだけ無視して、正しい1回のON/OFFに整える。
④ ONになった瞬間
フォルダ再生(例:/01/001.mp3)→ 単曲ループに設定してメロディを流し続ける。
⑤ OFFになった瞬間
まず STOP を送って確実に止める → その直後に注意音(/02/001.mp3)を1回だけ再生。
⑥ コマンド間の小休止
STOP直後にすぐ再生すると落ちることがあるので、数十msだけ待ってから次の命令を送る。
⑦ 音量/曲管理
音量は定数で調整可能。メロディを複数用意した場合は 順番に回すこともできる。
⑧ フォルダ&名前のルール
DFPlayerは フォルダ名2桁/ファイル名3桁の固定。/01/001.mp3 のように並べる。

安定動作のための工夫

microSDの並べ方(重要)

主な調整パラメータ(役割と目安)
名前役割効果/注意
VOLUME音量(0~30)大きすぎると歪む。20前後が無難。
MELODY_FOLDERメロディのフォルダ番号(例:1 => /012桁フォルダに対応。
WARNING_FOLDER注意音のフォルダ番号(例:2 => /022桁フォルダに対応。
WARNING_TRACK注意音のファイル番号(例:1 => 001.mp33桁ファイル名に対応。
NUM_MELODIESメロディの曲数複数あるとONごとに順送り可能。
DEBOUNCE_MSチャタリング除去時間短すぎると誤反応、長すぎると反応が鈍い。
BAUDRATEDFPlayerとの通信速度標準は9600bps。
ねらいと理由まとめ:
(1) ON中は確実にループ(単曲ループ指定)→ 駅らしい一貫した鳴り方。
(2) OFFで即座に注意音へ(STOP→再生の順)→ 切替が分かりやすい。
(3) 誤動作しない(割り込み+デバウンス+短い待ち時間)→ 鳴りっぱなし/鳴らないを防止。
(4) 準備の手間を減らす(フォルダ/ファイルのルールを固定)→ SDの差替えだけで運用OK。
トラブル時チェック
  • フォルダ/ファイル名がルール通りか(/01/001.mp3 など)。
  • GNDがすべて共通か、Pico→DFのRXに1kΩを入れているか。
  • 配線の向き(TX⇄RX)が合っているか、ボーレートは9600か。
  • デバウンス値が小さすぎて誤検出していないか。

3.車掌ドア開閉スイッチ — 小糸製 SH290

小糸製 SH290 の「開/閉」スイッチで、車内ドアの開閉を音で再現しました。
はドアが開く音(0001.mp3)、はドアが閉じる音(0002.mp3)を再生します。
「閉」の再生が終わったら、戸閉ランプ(赤色 LED)を点灯します。解除で停止し、必要に応じて LED を消灯します。
制御は Raspberry Pi Pico 2 W(UART0 で DFPlayer mini と通信)です。

途中でスイッチを切り替えても、すぐに再生を切り替えられるようにしました。
方針は「STOP → 直後に目的のトラックを再生」という順番です。
古い金属部品なのでサビがあり、長期使用は様子を見ています。
今後は DFPlayer mini の BUSY ピン(LOW=再生中)を監視して、
「再生完了=LED 点灯」をより正確に判定したいです。さらに小さなドアをサーボモータで動かす改良も考えています。

配線図

SH290(開/閉) Raspberry Pi Pico 2 W GP15: 開 / GP14: 閉 GP13: LED DFPlayer mini スピーカー(専用)

使用した機器

名前 型番/リンク 役割 備考
車掌スイッチ 小糸製 SH290 「開」「閉」の2つの独立した接点を持ち、押している間だけON。 実物部品。接点の磨耗/サビに注意。
マイコン Raspberry Pi Pico 2 W 開/閉スイッチの状態を読み取り、DFPlayerの再生とLED点灯を制御。 内部プルアップ入力で配線が簡単。
MP3プレイヤー DFPlayer mini 「開く音」「閉まる音」のファイルを再生。 FAT32。/mp3 フォルダに 0001.mp3/0002.mp3 を置くのが確実。
赤 LED 赤 LED + 330Ω 戸閉表示(Pico GP13)。閉動作の完了で点灯。 極性と抵抗値に注意。
スピーカー 10cm 8Ω 10W 音声を出力。 小型箱でもエンクロージャで音が安定。

配線詳細

元の機器 元の端子 つなぐ先の機器 つなぐ先端子 備考
Raspberry Pi Pico 2 W GP0 / GP1 DFPlayer mini RX(直列 1kΩ)/ TX UART0、9600bps
Raspberry Pi Pico 2 W GP15(開)/ GP14(閉) 小糸製 SH290 各 NO 接点(COM → GND) 内部プルアップ。押すと 0
Raspberry Pi Pico 2 W GP13 LED 330Ω → LED → GND 戸閉ランプ
DFPlayer mini VCC / GND Raspberry Pi Pico 2 W VBUS(5V)/ GND VCC 近くに 470µF + 0.1µF
DFPlayer mini SPK+ / SPK− スピーカー + / − この装置専用
microSD カード DFPlayer mini microSD スロット /mp3/0001.mp3(開)/ /mp3/0002.mp3(閉)

プログラム制御

操作にすぐ反応しつつ、誤動作せずに戸閉ランプを正しく点けることを最優先にしています。
この装置は、「開」「閉」2つの接点を入力にして、DFPlayerへの命令とLED制御を組み合わせて動きます。
内部では 状態フラグタイマー を使い、「閉の音が鳴り終わったらLED点灯」を確実に行います。

① 起動時設定
UART0開始(9600bps)→ 音量設定。モジュール起動の安定化のために短い待ち時間を確保。
② 入力の取り方
GP15=開 / GP14=閉 を内部プルアップ。押すと0、離すと1で読み取り。
③ エッジ検出+デバウンス
「押した/離した」の瞬間だけをイベントにし、数msのチャタリングは無視。
④ 「開」を押したら
STOP → 少し待つ → 0001.mp3再生。LEDは消灯(開放状態)。
⑤ 「閉」を押したら
STOP → 少し待つ → 0002.mp3再生。
同時に close_playing=ON と開始時刻を記録(「閉の音」再生中フラグ)。
⑥ 「閉」の完了判定
方式A(現状):想定秒数CLOSE_DURATION)経過でLED点灯。
方式B(改良):DFのBUSYピン(LOW=再生中)を見て、再生が終わった瞬間にLED点灯。
⑦ 途中切替への対応
いつ押し替えても、まず STOP → 目的のトラックを再生。
これで 「閉」再生中でも即「開」へ切り替え可能。
⑧ コマンド間の待ち
DFPlayerが連続命令を取りこぼさないよう、STOP直後に数十msの小休止(PLAY_DELAY_MS)。
⑨ LED制御の考え方
閉が完了したらGP13をON(330Ω経由)。
開操作や停止時はOFFに戻す。

安定動作のための工夫

microSDの並べ方(推奨)

主な調整パラメータ(役割と目安)
名前役割効果/注意
VOLUME_LEVEL音量(0~30)大きすぎると歪む。20~28程度が実用。
CLOSE_DURATION「閉」音の想定長さ(秒)BUSY未使用時の完了判定に利用。
DEBOUNCE_MSチャタリング除去時間5~50msで調整。長すぎると鈍い。
POLL_MSスイッチ確認の間隔10ms前後で十分。小さすぎるとCPU負荷増。
PLAY_DELAY_MSSTOP→再生の間の待ち30~80ms。短すぎると取りこぼし。
ねらいと理由まとめ:
(1) 押したらすぐ切替(常に STOP→目的の再生)。
(2) 閉完了でLED(タイマー or BUSY監視)。
(3) 誤動作しない(デバウンス+待ち時間+配線保護)。
(4) 運用が簡単(/mp3/0001・0002 の固定ルール)。
改良メモ:DFPlayer mini の BUSY ピンを Raspberry Pi Pico 2 W の GP12 に接続し、LOW=再生中を監視すると、
トラック長に依存せず「閉」の終了判定ができ、LED 点灯を正確にできます。

4.Nゲージのマスコン — ジョイスティックで電車を動かす

このシステムは、実物パーツや市販モジュールを組み合わせ、「動く・鳴る・表示する」を一つの机上環境で再現します。 制御の中心は Arduino UNO R3(Nゲージ/表示/警笛トリガ)、音声は DFPlayer mini を各所で使用します。 電源は MB102(5V/3.3V) と、線路用の 昇降圧コンバータ(VM=9~11V)。GNDを共通化し、ノイズと誤動作を抑えています。

操作は、ジョイスティックで動かします。
本物の電車とは違い、ニュートラル(N)でモーターを止めると N ゲージはすぐ止まってしまいます。
そこで、レバーがニュートラル(N)あいだは「現在の速度を維持」するようにしました。
下に倒すと加速、上に倒すとブレーキです。

最高速度の上限は、加速中だけレバーの倒し量に応じて更新します。
急に変化しないように、ジャーク制限(加減速度の変化をなめらかに)と重さ係数を入れています。
方向切替(D8)は「完全停止&ニュートラル」のときだけ有効です。
切替先の色を3回点滅させてから、方向を変更します。
ジョイスティックを押し込むと、警笛がなります。

RGB LED は「前進=青、後進=赤、停止=緑」です。
LCD には速度・向き・警笛の状態を表示します。
速度メーターや、方向切り替えボタンも作りました。

安全への配慮:5V 系(マイコン・表示)と 9~11V 系(モータ)は電源ルートを分け、GNDは共通にしました。
配線の抜けやショートに注意し、作業中は電源を切って確認します。スピーカーの SPK− を GND に落とさない(BTL 出力)。

工夫した点

使用した機器

名前 型番/リンク 役割 備考
マイコン Arduino UNO R3 装置の頭脳。レバー位置を読み取り、線路への出力(PWM)やLED・LCD・警笛の命令を出す 資料が多く安定。割り込み200Hzでなめらかな制御
モータドライバ DRV8835 UNOの弱い信号を、線路へ流すモーター用の電力に変えて届ける(IN1/IN2にPWM) VM=9~11Vで使用。AOUTを線路へ
ジョイスティック KY-023 速度レバー。A0で倒し量を読み、押し込み(D7)は警笛ボタンとして使う 今回は縦方向のみ使用
RGB LED RGBLED 5mm PL9823-F5 状態表示。前進=青(D10)、後進=赤(D2)、停止=緑(D9)で今の状態が一目で分かる 各色に 220~330Ωの抵抗を入れて保護
LCD LCDディスプレイ (LCD1602A) 小さな文字の画面。速度・進行方向・警笛ON/OFFを表示 I2C接続(SDA/SCLの2本)。アドレスは 0x27(出ない時は0x3F)
MP3プレイヤー DFPlayer mini 音を鳴らす小さな再生装置。/mp3/0001.mp3 の警笛をUNOからの命令で再生 SoftwareSerialで制御。スピーカーを直接つなげる
電源モジュール VKLSVAN MB102(3.3V/5V) UNOやDFPlayerなどの5V(/3.3V)を作る 線路用電源とは分ける。GNDは全体で共通化
スイッチング AC アダプター 12V 5A AD-A120P500 家庭のコンセントから安定した12Vを作る 昇降圧コンバータの入力に使用。5Aの余裕で余力あり
昇降圧コンバータ DiyStudio 自動昇降圧 5.5–30V → 0.5–30V / 4A 12Vから線路用の電圧(VM=9~11V)を作って安定供給 LCDで10.5Vを目安に調整。GND共通を忘れない
デカップリング 0.1µF セラミック(104) ICの近くで電源の小さな揺れ(ノイズ)を吸収して誤動作を防ぐ DFPlayerやDRV8835のVCC近くに置く
平滑コンデンサ 25V 470µF(低 Z) 電源の大きめの波(リップル)を減らし、音や走行を安定 DFPlayerのVCC付近に推奨(必要に応じてDRV側にも)
スピーカー 10cm 8Ω 10W DFPlayerの音を出す この装置専用に接続。箱を作ると低音が出やすい

配線図

Arduino UNO R3 D3/D6 → DRV8835 IN1/IN2 A0: ジョイスティック D2/D9/D10: RGB DRV8835 VCC=5V / VM=9~11V 線路(AOUT1/2) I2C LCD 16x2 DFPlayer mini スピーカー(専用)

配線詳細

元の機器 元の端子 つなぐ先の機器 つなぐ先端子 備考
DRV8835 AIN1 Arduino UNO R3 D3 PWM
DRV8835 AIN2 Arduino UNO R3 D6 PWM
DRV8835 MODE GND IN/IN 固定
DRV8835 VCC MB102 5V ロジック 5V
DRV8835 VM 昇降圧コンバータ 9~11V VM は 11V 以下
DRV8835 AOUT1 / AOUT2 線路 + / − 逆なら入れ替え
ジョイスティック VRx Arduino UNO R3 A0 速度入力
ジョイスティック SW Arduino UNO R3 D7 押すと警笛
方向切替スイッチ 片側 Arduino UNO R3 D8 押すと 0(内部プルアップ)
方向切替スイッチ もう片側 GND
RGB LED(共通カソード) R Arduino UNO R3 D2 後進=赤
RGB LED(共通カソード) G Arduino UNO R3 D9 停止=緑
RGB LED(共通カソード) B Arduino UNO R3 D10 前進=青
RGB LED(共通カソード) 共通端子 GND 各色に 220~330Ω
DFPlayer mini RX / TX Arduino UNO R3 D4(直列 1kΩ)/ D5 SoftwareSerial
LCD(I2C 16x2) SDA / SCL Arduino UNO R3 A4 / A5 Addr 0x27(出なければ 0x3F)
全体 GND 全体 GND 全体で共通化
microSD カード DFPlayer mini microSD スロット /mp3/0001.mp3(警笛)

プログラム制御

操作したときに、本物の電車のような動き、またモーターを傷めないようにするために、プログラムでは急な変化を避けて少しずつ動かすことを最優先にしています。
このコントローラは、“内部速度”という1つの値を中心に動きます。レバー操作から「どれくらい増やす/減らす」を決め、200回/秒(5msごと)で少しずつ更新し、最後に線路の出力(PWM)と表示(km/h)へ変換します。
警笛(ホーン)は、押した直後に鳴らない/途中で途切れると実車感が壊れるため、起動直後の準備・ボタンのノイズ除去・念押し再送などを入れて確実に鳴り始めて、気持ちよく止まるようにしています。

① 入力を読む
レバー位置(A0)と中心値を読み、加速/維持/減速のゾーン判定をする(中央のゆらぎは無視)。 (DEAD_Nで手ブレを吸収)
② 目標の変化量を決める
倒し量を0~1に正規化→二乗カーブで形を整え、加速/減速の傾き(1秒あたりの増減量)を算出。 (小操作でも効き始める)
③ なめらか化
傾きの変化に上限(ジャーク制限)+重さ係数で、ガクっとしないようにする。
④ 内部速度を更新
5msごとに内部速度をちょっとずつ加算/減算し、0~MAXにクランプ。
⑤ 安全ガード
加速中だけ効く最高速キャップ、極低速の停止アシストを適用。
⑥ 出力&表示
内部速度→PWM(線路出力)へ変換。表示は低出力域を1km/h固定にしてピコピコ防止。
⑦ 周辺機能
完全停止&ニュートラル時だけ方向切替(LEDを点滅予告)。LCDは約7回/秒で更新。
⑧ 警笛(ホーン)
後述の①~⑨の工夫で「押せば確実に鳴り、離せば気持ちよく止まる」。

速度まわり(加速・減速・停止の工夫)

方向切替と安全

LED / LCD 表示

警笛(ホーン)— ①~⑨の工夫で「確実に鳴る&気持ちよく止まる」

  1. ① 起動猶予:電源投入直後は一定時間コマンドを送らない。DFPlayerの準備待ち。
  2. ② 無音の目覚まし:READY直後に音量0→瞬間再生→停止→音量復帰でデコーダを起こす。
  3. ③ デバウンス:ボタンのチャタリングを数ms無視して、1回の押し/離しに整える。
  4. ④ 連打ガード&最低保持前回からの最短間隔を守る。押してすぐ離しても最短時間は鳴る
  5. ⑤ 念押し再送(キック):押した直後の数秒だけ、TF選択/音量/Resume間隔を空けて再送して取りこぼし防止。
  6. ⑥ Resume活用:再生中に頭出ししないResumeを使って、鳴りはじめの欠けやブツ切れを回避。
  7. ⑦ フェードアウト:離したら音量を段階的に下げてから停止。耳にやさしくノイズも減る。
  8. ⑧ コマンド間隔ガード:DFPlayerは短すぎる連続コマンドを落とすことがある→最低間隔を空けて送信。
  9. ⑨ 保留再生:READY前に押されたら保留して、READYになったら自動再生(ただし短押し連打は無視)。
主な調整パラメータ一覧(役割と目安)
名前役割効果/注意
ACCEL_RATE_MIN/MAX加速の傾き(最小~最大)大きいほど立ち上がりが速い
BRAKE_RATE_MIN/MAX減速の傾き(最小~最大)大きいほど止まりが速い
MASS_ACCEL/BRAKE反応の鈍さ(重さ)大きいほど全体にゆっくり
JERK_RATE_PS2傾きの変化上限小さくするとより滑らか
DEAD_Nニュートラル幅大きいと手ブレを拾いにくい
KMH_HOLD_UNTIL_PWM表示だけ1km/h固定の上限PWM小さくすると早めに通常表示へ
MICRO_STOP_ASSIST停止アシストを効かせる内部速度域広げると0に吸い込みやすい
STOP_ASSIST_RATE_PS超低速域の強め減速大きいほどスッと止まる
DF_BOOT_GRACE_MS起動猶予短すぎると押しても鳴らないこと
HORN_FADE_MSフェード時間長いほど自然、短いほどキビキビ
HORN_KICK_WINDOW_MS念押し再送を続ける時間長いと安定◎だが通信負荷↑
HORN_KICK_INTERVAL_MS念押しの送信間隔短すぎは無効化されやすい
HORN_MIN_RETRIGGER_MS連打ガード短いとブツ切れが起きやすい
HORN_MIN_HOLD_MS最低保持短押しでも必ず鳴りが残る
ねらいと理由まとめ:
(1) 急に変えない(ジャーク/重さ/200Hz更新)→タイヤの空転やギクシャク感を防止。
(2) 止めやすく走り出しやすい(停止アシスト/ニュートラル)→狭いレイアウトでも扱いやすい。
(3) 確実に鳴って確実に止まる(念押し再送/フェード/連打ガード/起動猶予)→体験が途切れない。
(4) 安全最優先(方向切替の条件/上限キャップ)→モーターや車両にやさしい。

5.Raspberry Pi Pico 2 W のプログラム

  1. PC に Thonny を入れます。
  2. 初回は Raspberry Pi Pico 2 W に MicroPython を入れます(Thonny 右下の「Interpreter」の案内に従います)。
  3. Raspberry Pi Pico 2 W を USB 接続し、Thonny 右下でポートを選びます。
  4. このレポートの「7.プログラム」から必要なコードをコピーし、Thonny に貼って main.py として Pico へ保存します。
  5. microSD カードを装置ごとに1枚用意し、表のフォルダ構成で音声ファイルを入れます。
  6. 配線を確認して、Thonny の ▶ 実行 で動作確認します。

うまく動かない時:配線(GND 共通/1kΩ/電源 5V)、フォルダ名(01 / 02)やファイル名(001.mp3)を再確認します。

6.Arduino UNO R3 のプログラム

  1. 公式の Arduino IDE を入れます。
  2. 「ツール → ボード」で Arduino UNO を選びます。
  3. USB 接続後、「ツール → シリアルポート」で該当ポートを選びます。
  4. 「7.プログラム」から N ゲージのコードをコピーして、スケッチに貼ります。
  5. LiquidCrystal_I2C が無い場合は「スケッチ → ライブラリを管理」で追加(または #define USE_LCD 0)。
  6. 書き込みボタンで UNO へ転送します。5V系と VM(9~11V)を分け、GND 共通を確認します。

7.プログラム

1)車掌マイク

  「1.車掌マイク」は、マイコンを使っていないため、プログラム不要(マイク → アンプ → スピーカー)。

2)発車メロディスイッチ(MicroPython / Raspberry Pi Pico 2 W)

← 実装コードを表示
from machine import Pin, UART
import time

# ================= 設定セクション =================
VOLUME         = 25    # 音量 (0~30)
MELODY_FOLDER  = 1     # メロディ用フォルダ番号 "01"
WARNING_FOLDER = 2     # 警告音用フォルダ番号 "02"
WARNING_TRACK  = 1     # 警告音ファイル番号 (001.mp3)
NUM_MELODIES   = 1     # フォルダ01内のファイル数

SWITCH_PIN     = 2     # スイッチ入力 (GP2)
MP3_RX_PIN     = 5     # DFPlayer TX → Pico GP5 (UART1 RX)
MP3_TX_PIN     = 4     # DFPlayer RX ← Pico GP4 (UART1 TX) ※1kΩ直列
BAUDRATE       = 9600  # DFPlayer 通信ボーレート
DEBOUNCE_MS    = 3     # チャタリング除去時間 (ms)

# イベントフラグ定義
EVENT_NONE    = 0
EVENT_PRESS   = 1
EVENT_RELEASE = 2

# ================ DFPlayer コマンド生成関数 ================
def calc_checksum(cmd, p1=0, p2=0):
	total = 0xFF + 0x06 + cmd + 0x00 + p1 + p2
	chk   = -total & 0xFFFF
	return chk >> 8, chk & 0xFF

def send_cmd(cmd, p1=0, p2=0):
	hi, lo = calc_checksum(cmd, p1, p2)
	packet = bytearray([0x7E, 0xFF, 0x06, cmd, 0x00, p1, p2, hi, lo, 0xEF])
	uart.write(packet)

# ================ 初期化処理 ================
uart        = UART(1, BAUDRATE, tx=MP3_TX_PIN, rx=MP3_RX_PIN)
switch      = Pin(SWITCH_PIN, Pin.IN, Pin.PULL_UP)

current_track = 0
prev_state    = switch.value()
last_irq_ms   = 0
event_flag    = EVENT_NONE

# === IRQ ハンドラ:チャタリング除去後、イベントフラグのみセット ===
def switch_irq(pin):
	global prev_state, last_irq_ms, event_flag
	now = time.ticks_ms()
	if time.ticks_diff(now, last_irq_ms) < DEBOUNCE_MS:
		return
	last_irq_ms = now

	s = pin.value()
	if prev_state == 1 and s == 0:
		event_flag = EVENT_PRESS
	elif prev_state == 0 and s == 1:
		event_flag = EVENT_RELEASE
	prev_state = s

# 立下り/立上り両方で IRQ
switch.irq(trigger=Pin.IRQ_FALLING | Pin.IRQ_RISING, handler=switch_irq)

# DFPlayer 初期化シーケンス
print("=== DFPlayer 初期化 ===")
send_cmd(0x06, 0, VOLUME)   # 0x06 = set volume
print("音量セット:", VOLUME)
time.sleep_ms(200)
print("=== メインループ開始 ===\n")

# ================ メインループ ================
while True:
	if event_flag == EVENT_PRESS:
		# ■ OFF→ON:メロディをフォルダ再生+ループ
		current_track = (current_track % NUM_MELODIES) + 1
		print("再生: メロディ フォルダ", MELODY_FOLDER, "トラック", current_track)
		# 0x0F = playFolder(folder, track)
		send_cmd(0x0F, MELODY_FOLDER, current_track)
		time.sleep_ms(20)
		# 0x19 = single loop (再生中トラックを繰り返す)
		send_cmd(0x19)
		event_flag = EVENT_NONE

	elif event_flag == EVENT_RELEASE:
		# ■ ON→OFF:停止 → 警告音を1回再生
		print("停止: メロディ停止")
		send_cmd(0x16)  # 0x16 = stop
		time.sleep_ms(20)
		print("再生: 警告  フォルダ", WARNING_FOLDER, "トラック", WARNING_TRACK)
		send_cmd(0x0F, WARNING_FOLDER, WARNING_TRACK)
		event_flag = EVENT_NONE

	time.sleep_ms(5)

3)車掌ドア開閉スイッチ(MicroPython / Raspberry Pi Pico 2 W)

← 実装コードを表示
# 車掌ドア開閉スイッチ
# 配線イメージ(テキスト版)
# [DFPlayer VCC] ── 36 (3V3_OUT)
# [DFPlayer GND] ── 38 (GND)
# [DFPlayer RX ] ── 1  (GP0 / UART0 TX)
# [DFPlayer TX ] ── 2  (GP1 / UART0 RX)
# [DFPlayer SPK1] ── スピーカー+
# [DFPlayer SPK2] ── スピーカーー
#
# [開スイッチ NO ] ── 20 (GP15) ─┐
# [開スイッチ COM] ── 38 (GND)  ├─ プルアップ入力 (押すと0, 離すと1)
#                                  │
# [閉スイッチ NO ] ── 19 (GP14) ─┤
# [閉スイッチ COM] ── 38 (GND)  ├─ プルアップ入力 (押すと0, 離すと1)
#                                  │
# [LED アノード ] ── 17 (GP13) ──┐
# [LED カソード] ── 38 (GND)   ──┘
import machine
import time

# =============================================================================
# 定数設定
# =============================================================================
# DFPlayerモジュールの音量を設定します (0~30の範囲)
VOLUME_LEVEL    = 30
# 起動直後に再生する 1 曲目の長さ(秒)。実際の MP3 ファイルの長さに合わせてください。
TRACK1_DURATION = 3.0
# 「ドア閉め」動作で再生する 2 曲目(0002.mp3)の長さ(秒)
CLOSE_DURATION  = 5.0
# スイッチのチャタリング(高速にON/OFFを繰り返すノイズ)を防ぐデバウンス時間 (ミリ秒)
DEBOUNCE_MS     = 50
# メインループでスイッチの状態をチェックする間隔 (ミリ秒)
POLL_MS         = 10
# DFPlayer に「停止」コマンドを送ってから「再生」コマンドを送るまでの待ち時間 (ミリ秒)
PLAY_DELAY_MS   = 50

# =============================================================================
# ハードウェア(UART と GPIO)初期化
# =============================================================================
# UART0 の初期化: GP0 が TX (送信)、GP1 が RX (受信) になります
uart = machine.UART(0, 9600,
	tx=machine.Pin(0),  # GP0 → DFPlayer の RX
	rx=machine.Pin(1))  # GP1 ← DFPlayer の TX

# スイッチを内部プルアップ入力に設定します。
#   +端子 → GPIO (プログラムで読むピン)
#   -端子 → GND(グランド、0V基準)
# 押すと 0(push) → GND に接続、離すと 1(release) → 内部抵抗で引き上げられます
open_sw  = machine.Pin(15, machine.Pin.IN, machine.Pin.PULL_UP)   # ドア開スイッチ
close_sw = machine.Pin(14, machine.Pin.IN, machine.Pin.PULL_UP)   # ドア閉スイッチ

# 戸閉ランプ用の LED を GP13 に設定。1=点灯、0=消灯。
led      = machine.Pin(13, machine.Pin.OUT)

# =============================================================================
# 状態管理用変数
# =============================================================================
# 前回チェック時のスイッチ状態を保持します
#   prev_open: ドア開スイッチの前回値 (1=離している, 0=押している)
#   prev_close: ドア閉スイッチの前回値
prev_open     = open_sw.value()
prev_close    = close_sw.value()
# 最後にスイッチが変化(チャタリングを除いた)した時刻を保持します (ms)
last_open_ts  = 0
last_close_ts = 0

# 「ドア閉め」動作中かどうかを示すフラグ
#   close_playing = True の間は、再生完了を待ってから LED 点灯します
close_playing    = False
# 「ドア閉め」動作が始まった時刻を保持します (ms)
close_start_ts   = 0

# =============================================================================
# DFPlayer コマンド送信ヘルパー
# =============================================================================
def send_cmd(cmd, param):
	"""
	この関数は DFPlayer mini モジュールにコマンドを送ります。
	  - cmd   : コマンドコード (例 0x03=再生, 0x06=音量設定, 0x16=停止)
	  - param : トラック番号や音量レベルなどのパラメータ
	内部で「チェックサム」を計算し、正しい形式のバイト列を UART 経由で送信します。
	"""
	# 送信バッファを作成 (10バイト)
	buf = bytearray([
		0x7E, 0xFF, 0x06,   # ヘッダー:開始バイト, バージョン, 長さ
		cmd, 0x00,          # コマンド, 区分(常に0)
		(param>>8)&0xFF,    # パラメータ上位
		param&0xFF,         # パラメータ下位
		0x00, 0x00,         # チェックサム(後で埋める)
		0xEF                # 終了バイト
	])
	# チェックサムの計算: buf[1]~buf[6] の合計 + checksum = 0 (mod 0x10000)
	s   = sum(buf[1:7]) & 0xFFFF
	chk = (0 - s) & 0xFFFF
	buf[7] = (chk >> 8) & 0xFF
	buf[8] = chk & 0xFF

	# UART で送信
	uart.write(buf)
	# デバッグ表示(コマンドが送られたか確認)
	print(f"[DEBUG] send_cmd → CMD=0x{cmd:02X}, PARAM={param}")

# =============================================================================
# 起動時シーケンス(ノンブロッキング)
# =============================================================================
def startup():
	"""
	プログラム起動時に実行する初期シーケンスです。
	  1) LED を 3 回チカチカさせる(視覚フィードバック)
	  2) DFPlayer の起動完了を待機
	  3) 音量を設定
	  4) トラック1 を再生 (TRACK1_DURATION 秒間)
	  5) トラック2 を再生
	  6) 初期動作確認後にドア閉スイッチが押されていたら(ONなら)戸閉ランプを点灯
	  7) 初期スイッチ状態をログ出力
	再生待機中もメインループでスイッチをチェックし続けます。
	"""
	# 1) LED 3回チカチカ (0.2秒間隔)
	for _ in range(3):
		led.value(1)
		time.sleep(0.2)
		led.value(0)
		time.sleep(0.2)

	# 2) モジュール起動待ち(0.5秒)
	time.sleep(0.5)

	# 3) 音量設定
	print(f"[INIT] set volume to {VOLUME_LEVEL}")
	send_cmd(0x06, VOLUME_LEVEL)
	time.sleep(0.05)

	# 4) トラック1再生
	print(f"[INIT] play track1 for {TRACK1_DURATION}s")
	send_cmd(0x03, 1)
	start = time.ticks_ms()
	# ノンブロッキングで再生時間待機
	while time.ticks_diff(time.ticks_ms(), start) < int(TRACK1_DURATION * 1000):
		time.sleep_ms(POLL_MS)

	# 5) トラック2再生
	print("[INIT] play track2")
	send_cmd(0x03, 2)

	# 6) 初期動作確認後にドア閉スイッチが押されていたら(ONなら)戸閉ランプを点灯
	if close_sw.value() == 0:
		led.value(1)

	# 7) 初期スイッチ状態ログ
	print(f"[INIT] Open={open_sw.value()}, Close={close_sw.value()}")

# =============================================================================
# メインループ:スイッチのポーリング+エッジ検知
# =============================================================================
# まず起動シーケンスを実行
startup()
print("[DEBUG] Enter main loop")

# 無限ループでスイッチ状態を監視し、イベント発生時に処理します
while True:
	now       = time.ticks_ms()
	cur_open  = open_sw.value()   # 現在のドア開スイッチ状態 (1=離脱, 0=押下)
	cur_close = close_sw.value()  # 現在のドア閉スイッチ状態

	# -------------------------------------------------------------------------
	# ドア開スイッチのエッジ検知 (押下または解除)
	# -------------------------------------------------------------------------
	# 前回から値が変化していて、かつデバウンス時間を超えていればイベントとみなす
	if cur_open != prev_open and time.ticks_diff(now, last_open_ts) > DEBOUNCE_MS:
		last_open_ts = now
		prev_open    = cur_open

		if cur_open == 0:
			# 押下イベント: トラック1 を再生し、LED を消灯する
			print("[EVENT] Open pressed → play 0001.mp3, LED OFF")
			send_cmd(0x16, 0)            # 再生停止コマンド
			time.sleep_ms(PLAY_DELAY_MS) # 停止→再生の間を待機
			send_cmd(0x03, 1)            # 0001.mp3 を再生
			led.value(0)                 # LED 消灯
		else:
			# 解除イベント: 再生停止のみ
			print("[EVENT] Open released → stop")
			send_cmd(0x16, 0)

	# -------------------------------------------------------------------------
	# ドア閉スイッチのエッジ検知 (押下または解除)
	# -------------------------------------------------------------------------
	if cur_close != prev_close and time.ticks_diff(now, last_close_ts) > DEBOUNCE_MS:
		last_close_ts = now
		prev_close    = cur_close

		if cur_close == 0:
			# 押下イベント: トラック2 を再生し、再生完了後に LED 点灯する
			print("[EVENT] Close pressed → play 0002.mp3 (LED ON after finish)")
			send_cmd(0x16, 0)
			time.sleep_ms(PLAY_DELAY_MS)
			send_cmd(0x03, 2)
			close_playing  = True
			close_start_ts = now
		else:
			# 解除イベント: 再生停止 & LED 消灯
			print("[EVENT] Close released → stop, LED OFF")
			send_cmd(0x16, 0)
			led.value(0)
			close_playing = False

	# -------------------------------------------------------------------------
	# クローズ再生完了後に LED 点灯
	# -------------------------------------------------------------------------
	if close_playing and time.ticks_diff(now, close_start_ts) >= int(CLOSE_DURATION * 1000):
		led.value(1)
		print("[EVENT] Close playback finished → LED ON")
		close_playing = False

	# -------------------------------------------------------------------------
	# 高速ポーリング:次のチェックまで短時間スリープ
	# -------------------------------------------------------------------------
	time.sleep_ms(POLL_MS)

4)N ゲージ マスコン(Arduino UNO R3)

← 実装コードを表示

// ─────────────────────────────────────────────────────────────
// Nゲージコントローラ(高精細:内部速度0~99999)+ 方向切替フィードバック + 任意LCD
// 既存配線は変更しない:A1はVRYのまま/青LEDは D10
// 切替ボタンが有効なとき、切替先の色を3回点滅してから切替
// ログは“値が変わった時だけ”出力
// ─────────────────────────────────────────────────────────────

/* LCD使用フラグ(1=使う/0=使わない)
   範囲: 0 or 1(推奨: 1。ライブラリ未導入なら 0) */
#define USE_LCD 1	// 0 or 1

#include   // powf() 用
#include 		// ATmega328P(UNO系)用の割り込みヘッダ
#include 		// DFPlayer(音声モジュール)と通信するため

#if USE_LCD
	#include 					// I2C通信用
	#include 		// I2C接続LCD用
#endif

/* ==== 調整しやすい設定(“数値の意味/安全範囲/目安”を明記) ==== */

/* DFPlayerの音量
   範囲: 0~30(0=無音, 30=最大)
   推奨: 8~20(大きすぎると音が歪むため) */
const uint8_t DF_VOLUME = 30;			// デフォルト音量

/* 物理PWMの上限(モータへ送る強さの最大)
   範囲: 100~255(255=最大出力)
   推奨: 180~240(発熱を抑えたい時は下げる) */
const uint8_t PWM_HARD_MAX = 240;		// モータ保護のため255より少し下げる

/* 内部速度(0~99999)を使った計算の基準の最大値
   範囲: 10000~99999
   推奨: 50000~99999(細かく制御したいほど大きく) */
const int32_t MAX_MICRO = 99999;		// 速さの内部表現の最大値

/* 制御割り込みの回数(1秒に何回動かすか)
   200Hz = 1秒に200回(5msごと)
   範囲: 100~500(高いほどCPU負荷が増える)
   推奨: 200~300 */
const uint16_t TICK_HZ = 200;			// 制御の細かさ
const uint16_t TICK_MS = 1000 / TICK_HZ;	// 1回あたりのミリ秒

/* スティック操作量→加速・減速の“傾き”(1秒あたりどれだけ内部速度を変えるか)
   それぞれ最小~最大値を設定する(強く倒すほど最大に近づく)
   範囲目安:
     ACCEL_*: 500~120000
     BRAKE_*: 500~150000
   推奨目安:
     ACCEL_MIN 2000~8000 / ACCEL_MAX 30000~80000
     BRAKE_MIN 3000~10000 / BRAKE_MAX 50000~120000 */
const int32_t ACCEL_RATE_MIN = 2000;
const int32_t ACCEL_RATE_MAX = 50000;
const int32_t BRAKE_RATE_MIN = 3000;
const int32_t BRAKE_RATE_MAX = 70000;

/* “重さ”(慣性に似た効果)。数値が大きいほど反応がゆっくりになる(傾きを割る)
   範囲: 0.8~4.0
   推奨: 2.0~3.5(扱いやすい)
   ※加速と減速で別々に調整できる */
const float MASS_ACCEL = 4.0f;			// 加速側の重さ(大きいほどゆっくり加速)
const float MASS_BRAKE = 3.5f;			// 減速側の重さ(大きいほどゆっくり減速)

/* ジャーク制限(“傾き”自体の変化スピードの限界)
   数字が小さいほど急な変化を抑えて滑らかになる
   範囲: 20000~500000
   推奨: 100000~250000 */
const int32_t JERK_RATE_PS2 = 160000;

/* スティック中央とみなす幅(ブレを吸収する)
   範囲: 20~120(大きいほどニュートラルが広い)
   推奨: 50~90 */
int DEAD_N = 70;

/* 速度表示の“最高時速”(見かけの速度を何km/hまで表示するか)
   実際の電圧や速さとは独立(あくまで表示用)
   範囲: 60~300
   推奨: 100~160(レイアウトに合わせて) */
const int SPEED_MAX_KMH = 120;

/* 低出力のときの“表示だけ”のルール
   停止ではないのにPWMが小さい間は、常に 1km/h と表示しておく
   走り出しの表示がピコピコしない効果(出力は変えない)
   範囲: 0~(PWM_HARD_MAXの20%程度)
   推奨: 20~60 */
const uint8_t KMH_HOLD_UNTIL_PWM = 40;

/* 減速専用:停止アシスト(“1km/h表示域”に入ってからだけ効かせる)
   ・表示1km/hまでは既存の減速カーブをそのまま使う
   ・1km/h相当の低PWM域に入ったら、0へ“スーッ”と収束させる補助
   MICRO_STOP_ASSIST: 1km/h表示域に入ったと判定する内部速度のしきい値 */
const int32_t MICRO_STOP_ASSIST = ((int32_t)KMH_HOLD_UNTIL_PWM * (int32_t)MAX_MICRO + (PWM_HARD_MAX/2)) / (int32_t)PWM_HARD_MAX;
/*  STOP_ASSIST_RATE_PS: 低速域で使う強めの減速傾き(内部単位/秒)
     範囲目安: 80000~150000(大きいほど早く止まる)
     推奨初期値: 90000 から調整 */
const int32_t STOP_ASSIST_RATE_PS = 90000;

/* 高速域テーパ(加速だけ弱める)
   ACCEL_TAPER_START_KMH : ここを超えたら加速を徐々に弱め始める(例: 100)
   ACCEL_TAPER_MIN_GAIN  : 最高速付近での加速率の下限(0.0~1.0、例: 0.25=25%)
   ACCEL_TAPER_POWER     : 弱まり方のカーブ(1.0=直線, >1 きつめ, <1 ゆるめ)
   無効化したい場合は MIN_GAIN=1.0 か、START_KMH>=SPEED_MAX_KMH にしてください。*/
const int   ACCEL_TAPER_START_KMH = 100;  // 100km/h超えから効かせる
const float ACCEL_TAPER_MIN_GAIN  = 0.22f; // 最高速付近は加速22%まで低下(強め)
const float ACCEL_TAPER_POWER     = 0.60f; // 100超え直後からグッと効き始める

/* 内部単位へ変換(100km/h相当の内部速度)*/
const int32_t MICRO_TAPER_START =
	((int32_t)ACCEL_TAPER_START_KMH * (int32_t)MAX_MICRO + (SPEED_MAX_KMH/2)) / (int32_t)SPEED_MAX_KMH;

/* I2C LCDのアドレス
   値: 0x27 または 0x3F(モジュールによって異なる) */
const uint8_t LCD_ADDR = 0x27;			// 合わないと表示が出ない

/* 進行方向
   false=前進 / true=後進
   起動時の初期値 */
volatile bool REV_DIR = false;

/* スティック上下反転
   値: true なら上下を入れ替える(好みで設定)
   推奨: 実機の感覚に合わせる */
const bool AXIS_INVERT = true;

/* ==== ピン定義(配線どおり。数値は変えない) ==== */
// DRV8835(IN-IN)→ モータドライバのPWM入力ピン
const uint8_t PIN_IN1   = 3;	// 前進PWM(AIN1)
const uint8_t PIN_IN2   = 6;	// 後進PWM(AIN2)
// ジョイスティック
const uint8_t PIN_AXS   = A0;	// 速度入力(VRx)0~1023を読む
const uint8_t PIN_HRN   = 7;	// ホーンSW(押す=GND)
// 方向切替
const uint8_t PIN_DIRSW = 8;	// 押す=GND(INPUT_PULLUPにする)
// 方向ランプ(共通カソード:HIGHで点灯)
const uint8_t PIN_LED_R = 2;	// 赤=後進
const uint8_t PIN_LED_G = 9;	// 緑=前進
const uint8_t PIN_LED_B = 10;	// 青=停止・ニュートラル
/* DFPlayer(SoftwareSerial:RX=5, TX=4)
   ※Arduino側のD4から1kΩを入れてDFのRXへ接続(ノイズ軽減) */
SoftwareSerial mp3(5, 4);

/* ==== DFPlayer 初期化(電源直後は少し待ってから順番に設定) ==== */
/* 各待ち時間(ミリ秒)
   DF_WARMUP_MS: シリアル開始後に機器が安定するまで
   DF_RESET_WAIT_MS: リセット後の本体起動待ち
   DF_SELECT_WAIT_MS: 再生デバイス(SD)選択後の反映待ち
   範囲目安: それぞれ +50% 程度まで増やすと安全 */
const uint16_t DF_WARMUP_MS       = 800;
const uint16_t DF_RESET_WAIT_MS   = 1500;
const uint16_t DF_SELECT_WAIT_MS  = 150;

/* 初期化を省略するか(0=手順通り, 1=簡易)
   安定性重視なら 0 のまま */
#define DF_SKIP_INIT 0	// 0=順次初期化/1=簡易

/* DF本体の“起動猶予”
   電源投入すぐはコマンドを無視されやすいので、その時間は待つ
   範囲: 1000~5000ms
   推奨: 3000~4000ms */
const uint16_t DF_BOOT_GRACE_MS = 3500;
static uint32_t dfBootGraceUntil = 0;	// この時刻を過ぎたら通常動作へ

/* 初期化の進み具合(状態を数字で持つ) */
enum {
	DF_SM_IDLE = 0,		// 何もしない(内部用)
	DF_SM_WARMUP,		// UART開始直後の待ち
	DF_SM_WAIT_RESET,	// リセット後の待ち
	DF_SM_WAIT_SELECT,	// デバイス選択後の待ち
	DF_SM_READY			// すべて完了(再生OK)
};
static uint8_t  dfState   = DF_SM_IDLE;	// 今どの段階か
static uint32_t dfStateT0 = 0;			// 各段階の開始時刻を記録

/* READY になる前にホーンが押されたら“保留”しておき、READYになったら自動再生する */
static bool		hornPending = false;
static uint32_t	hornPendingSince = 0;
/* 保留の有効時間(長すぎると意図せず鳴るので上限をつける)
   範囲: 1000~10000ms
   推奨: 3000~6000ms */
const uint16_t	HORN_PENDING_TIMEOUT_MS = 5000;

/* ==== ホーン:押しやすく・止めやすく・途切れにくくするための設定 ==== */
/* 離してから音量0にするまでの時間(フェードアウト)
   範囲: 300~5000ms
   推奨: 1500~4000ms */
const uint16_t HORN_FADE_MS          = 3000;
/* フェードで音量コマンドを送る最小間隔
   範囲: 20~150ms(短いほど段階が細かい)
   推奨: 40~80ms */
const uint16_t HORN_FADE_STEP_MS     = 60;
/* 再生し始めに“聞こえない/欠ける”ことがあるので、短時間だけコマンドを念押しする
   ウィンドウ(全体時間)と送る間隔
   範囲: 窓 500~4000ms / 間隔 80~300ms
   推奨: 窓 1500~3000ms / 間隔 120~200ms */
const uint16_t HORN_KICK_WINDOW_MS   = 3000;
const uint16_t HORN_KICK_INTERVAL_MS = 150;
/* 押しボタンのチャタリング除去(ガタガタを無視する時間)
   範囲: 5~20ms
   推奨: 8~12ms */
const uint16_t HORN_DEBOUNCE_MS      = 8;
/* 連打しても短時間にコマンドを送られすぎないようにする最小間隔
   範囲: 20~200ms
   推奨: 40~120ms */
const uint16_t HORN_MIN_RETRIGGER_MS = 40;
/* ちょい押しでも、キック処理が最低1回は動くようにする保持時間
   範囲: 60~300ms
   推奨: 100~200ms */
const uint16_t HORN_MIN_HOLD_MS      = 120;

/* ==== “無音の目覚まし”(READY直後に1回だけ)====
   音量0→一瞬だけ再生→停止→音量戻す
   目的:デコーダを起こして、実際に押したときに確実に鳴るようにする */
static bool     dfPrimed = false;	// 実行済みフラグ
static uint8_t  dfPrimeStep = 0;	// 0=未開始,1=vol0,2=play,3=stop,4=restore
static uint32_t dfPrimeT0 = 0;		// 各手順の開始時刻
static uint8_t  dfPrimeSavedVol = 0;// 戻すために一時保存する音量

/* ==== 今の状態(動作中の値)==== */
volatile uint16_t centerRaw = 502;	// 起動時に測ったスティックの中央(生の値)
volatile uint16_t centerEff = 521;	// 反転設定を考慮した中央(AXIS_INVERTの結果)

volatile int32_t curMicro = 0;		// 内部速度(0~99999)
volatile int32_t capMicro = 0;		// 最高速の上限(加速でしか増えない)
volatile int32_t rateMicroPS = 0;	// いまの“傾き”(+=加速中, −=減速中)
volatile int32_t targetRatePS = 0;	// 入力から決まる“目標の傾き”
volatile int8_t  zone = 0;			// -1=減速 / 0=維持 / +1=加速(今どのゾーンか)

/* ホーンの細かい状態
   hornState: 0=停止 / 1=再生中 / 2=フェード中 */
static uint8_t  hornState = 0;
static uint32_t hornFadeStartMs = 0;	// フェードを始めた時刻
static int8_t   hornLastVolSent = -1;	// 直前に送った音量(同じ値は送らないため)
static uint32_t hornLastVolSentMs = 0;	// 音量を前回送った時刻
static uint32_t hornKickUntilMs = 0;	// キック(念押し)をいつまで続けるか
static uint32_t hornLastKickMs = 0;		// キックを最後に送った時刻
static uint32_t hornLastStartMs = 0;	// 直近の再生開始時刻(連打ガードに使う)
static uint32_t hornHoldUntilMs = 0;	// 最低保持の期限(この時刻までは止めない)
static bool     hornKickSentSelect = false;	// キック開始直後のTF選択を1回だけ送ったか
static bool     hornKickResumeSent = false;	// キック中にResumeを1回だけ送ったか

/* ==== 便利な関数:値を範囲におさめる(はみ出し防止) ==== */
static inline int clampi(int v, int lo, int hi){
	if(vhi) return hi;
	return v;
}

/* ==== DFへの送信が“詰まりすぎないように”間隔を空ける ==== */
/* DFはコマンドを短い間隔で連続すると無視することがあるため、
   最低でも約30msを空けてから次のコマンドを送るようにする。 */
static uint32_t dfLastTxMs = 0;
static inline void dfTxGapGuard(){
	uint32_t now = millis();
	uint32_t gap = now - dfLastTxMs;
	if (gap < 30){						// 30ms 未満なら
		delay(30 - gap);				// その差だけ待ってから送る(ISRの外でのみ使用)
	}
	dfLastTxMs = millis();				// 送信した時刻を更新
}

/* ==== DFPlayer:実際に送るコマンド群 ==== */
/* dfSend: DFに10バイトのコマンドを送る共通関数(チェックサム付き) */
static void dfSend(uint8_t cmd, uint16_t param){
	dfTxGapGuard();						// コマンドの間隔を守る
	uint8_t b[10]={0x7E,0xFF,0x06,cmd,0x00,(uint8_t)(param>>8),(uint8_t)param,0,0,0xEF};
	uint16_t s=0; for(int i=1;i<7;i++) s+=b[i]; uint16_t cs=0-s;
	b[7]=cs>>8; b[8]=cs&0xFF;
	for(int i=0;i<10;i++) mp3.write(b[i]);	// 1バイトずつ送る
}
/* 音量設定(確実に反映させたいとき:2回送る+少し待つ) */
static void setVol(uint8_t v){ if(v>30)v=30; for(int i=0;i<2;i++){ dfSend(0x06,v); delay(60);} }
/* 音量設定(動作中に素早く送る版) */
static void setVolQuick(uint8_t v){ if(v>30)v=30; dfSend(0x06, v); }
/* 再生:/mp3/0001.mp3 を頭から鳴らす(1回目の確実なスタート用) */
static void playHorn(){ dfSend(0x03,1); }
/* レジューム再生:再生中に送っても頭出ししない(ブツ切れ防止) */
static void resumeHorn(){ dfSend(0x0D,0); }
/* 停止:鳴っている音を止める */
static void stopHorn(){ dfSend(0x16,0); }
/* DF本体をリセット(電源入れ直しに近い) */
static void dfReset(){ dfSend(0x0C,0); }
/* 再生デバイスにTF(SDカード)を選ぶ(これをしないと再生できないことがある) */
static void dfSelectTF(){ dfSend(0x09,0x02); }

/* ==== DFPlayer 非ブロッキング初期化(待ちながら他の処理も進む) ==== */
/* 初期化を開始する(ステートマシンをWARMUPから動かす) */
static void dfInitStart(){
#if DF_SKIP_INIT
	dfState = DF_SM_READY;							// 省略する場合(今回は使わない)
#else
	dfState = DF_SM_WARMUP;							// 手順通りに進める
	dfStateT0 = millis();
#endif
	dfBootGraceUntil = millis() + DF_BOOT_GRACE_MS;	// 起動猶予の期限を決める
}
/* 初期化を“少しずつ進める”(一定時間が過ぎたら次の段階へ) */
static void dfInitTick(uint32_t now){
	switch(dfState){
		case DF_SM_IDLE:
		case DF_SM_READY:
			break;	// 何もしない
		case DF_SM_WARMUP:
			if ((uint32_t)(now - dfStateT0) >= DF_WARMUP_MS){
				dfReset();						// 本体リセット
				dfState = DF_SM_WAIT_RESET;		// 次の段階へ
				dfStateT0 = now;
			}
			break;
		case DF_SM_WAIT_RESET:
			if ((uint32_t)(now - dfStateT0) >= DF_RESET_WAIT_MS){
				dfSelectTF();					// SDカードを選ぶ
				dfState = DF_SM_WAIT_SELECT;	// 次の段階へ
				dfStateT0 = now;
			}
			break;
		case DF_SM_WAIT_SELECT:
			if ((uint32_t)(now - dfStateT0) >= DF_SELECT_WAIT_MS){
				setVolQuick(DF_VOLUME);			// 音量を設定
				dfState = DF_SM_READY;			// 初期化完了

				/* READYになった瞬間に“無音の目覚まし”を1回だけ始める */
				if (!dfPrimed){
					dfPrimeStep = 1;			// 次のloopで vol0 を送る
					dfPrimeSavedVol = DF_VOLUME;// 後で戻すために保存
					dfPrimeT0 = now;
				}
			}
			break;
	}
}

/* ==== タイマ1:200Hz CTC(一定の間隔で割り込みを発生させる設定) ==== */
static void setupTimer1_200Hz(){
	cli();							// 一時的に全割り込みを止める
	TCCR1A = 0;						// タイマ1の設定を初期化
	TCCR1B = 0;
	TCCR1B |= (1 << WGM12);			// CTCモード(指定カウントでリセット)
	// 16MHz / 64 = 250kHz → 200Hz: 250000/200 - 1 = 1249(この数でちょうど200Hz)
	OCR1A = 1249;					// 比較一致の値
	TIMSK1 |= (1 << OCIE1A);		// 比較一致Aで割り込みを発生させる
	TCCR1B |= (1 << CS11) | (1 << CS10);	// 分周64(タイマのカウント速度を決める)
	sei();							// 割り込みを再開
}

/* ==== LED制御(状態に合わせた色を点ける。HIGHで点灯) ==== */
static void setLedByState(bool stoppedNeutral, bool reverse){
	if (stoppedNeutral){
		// 停止&ニュートラル:青だけ点灯
		digitalWrite(PIN_LED_R, LOW);
		digitalWrite(PIN_LED_G, LOW);
		digitalWrite(PIN_LED_B, HIGH);
	}else{
		// 走行中:前進=緑, 後進=赤
		digitalWrite(PIN_LED_B, LOW);
		digitalWrite(PIN_LED_R, reverse ? HIGH : LOW);
		digitalWrite(PIN_LED_G, reverse ? LOW  : HIGH);
	}
}
/* 指定の色を点滅させる(times回、ONとOFFの時間を指定) */
static void blinkColor(bool R, bool G, bool B, uint8_t times, uint16_t onMs, uint16_t offMs){
	for(uint8_t i=0;i 0 && pwm_est < (int)KMH_HOLD_UNTIL_PWM){
			kmh = 1;	// 低出力域は 1km/h 固定
		}else{
			// “1km/hの地点”から上は、上限まで直線でつないで滑らかな表示にする
			int32_t micro_th = (int32_t)(((int32_t)KMH_HOLD_UNTIL_PWM * (int32_t)MAX_MICRO + (PWM_HARD_MAX/2)) / (int32_t)PWM_HARD_MAX);
			int kmh_th_lin = (int)((micro_th * (int32_t)SPEED_MAX_KMH + (MAX_MICRO/2)) / MAX_MICRO);
			int denom = SPEED_MAX_KMH - kmh_th_lin; if (denom < 1) denom = 1;
			int32_t num = (int32_t)(kmh_lin - kmh_th_lin) * (int32_t)(SPEED_MAX_KMH - 1);
			int32_t adj = 1 + (int32_t)(num / (int32_t)denom);
			if (adj < 1) adj = 1;
			if (adj > SPEED_MAX_KMH) adj = SPEED_MAX_KMH;
			kmh = (int)adj;	// 最終的な表示値
		}

		// 1行目:速度
		lcd.setCursor(0,0);
		char line1[17];
		snprintf(line1, sizeof(line1), "SPD:%3dkm/h    ", kmh);
		lcd.print(line1);
		// 2行目:方向とホーン状態
		lcd.setCursor(0,1);
		lcd.print(reverse ? "DIR:REV HORN:" : "DIR:FWD HORN:");
		lcd.print(hornActive ? "ON " : "OFF");
	}
#else
	static void lcdInit() {}
	static void updateLcd(int32_t, bool, bool) {}
#endif

/* ==== 200Hz割り込み:入力→傾き→ジャーク制限→内部速度更新 ==== */
/* 一定周期で呼ばれ、スティックの位置から“どれだけ加速/減速するか”を決め、内部速度を更新する */
ISR(TIMER1_COMPA_vect){
	// スティック位置(0~1023)を読む。反転設定がtrueなら上下を逆にする
	int raw = analogRead(PIN_AXS);              // 0..1023
	int ax  = AXIS_INVERT ? (1023 - raw) : raw;
	int C   = (int)centerEff;                   // 中央値(反転考慮後)

	// ゾーン判定:
	//   中央−DEAD_N未満 → 加速(+1)
	//   中央+DEAD_N超過 → 減速(-1)
	//   それ以外           → 維持(0)
	if (ax < C - DEAD_N){
		zone = +1;                              // 加速
		// 倒し量を 0..1 に正規化(小さいほど強い側)
		float denom = (float)(C - DEAD_N); if (denom < 1) denom = 1;
		float norm  = (float)((C - DEAD_N) - ax) / denom;
		if (norm < 0) norm = 0; if (norm > 1) norm = 1;
		// 小操作でも効くよう二乗で整形
		float shaped = norm * norm;
		// 加速側の目標傾きを決定 → “重さ”で減速
		int32_t slope = (int32_t)(ACCEL_RATE_MIN + (ACCEL_RATE_MAX - ACCEL_RATE_MIN) * shaped);
		slope = (int32_t)(slope / MASS_ACCEL);
		targetRatePS = slope;                    // 目標の傾き(+)

		// 最高速上限(cap)は“上がるときだけ”更新
		int32_t newCap = (int32_t)(norm * MAX_MICRO + 0.5f);
		if (newCap > capMicro) capMicro = newCap;

		// 高速域テーパ:加速中にしきい値を超えたら、目標傾きを徐々に減衰させる
		if (curMicro > MICRO_TAPER_START) {
			float d = (float)(MAX_MICRO - MICRO_TAPER_START); if (d < 1.0f) d = 1.0f;
			float x = (float)(curMicro - MICRO_TAPER_START) / d;        // 0..1
			if (x < 0.0f) x = 0.0f; if (x > 1.0f) x = 1.0f;
			// gain = 1 - (1 - MIN_GAIN) * x^POWER
			float gain = 1.0f - (1.0f - ACCEL_TAPER_MIN_GAIN) * powf(x, ACCEL_TAPER_POWER);
			if (gain < ACCEL_TAPER_MIN_GAIN) gain = ACCEL_TAPER_MIN_GAIN;
			targetRatePS = (int32_t)((float)targetRatePS * gain);
		}
	}
	else if (ax > C + DEAD_N){
		zone = -1;                              // 減速
		// 倒し量を 0..1 に正規化(大きいほど強い側)
		float denom = (float)(1023 - (C + DEAD_N)); if (denom < 1) denom = 1;
		float norm  = (float)(ax - (C + DEAD_N)) / denom;
		if (norm < 0) norm = 0; if (norm > 1) norm = 1;
		float shaped = norm * norm;
		// 減速側の目標傾き(−)→ “重さ”でマイルド化
		int32_t slope = (int32_t)(BRAKE_RATE_MIN + (BRAKE_RATE_MAX - BRAKE_RATE_MIN) * shaped);
		slope = (int32_t)(slope / MASS_BRAKE);
		targetRatePS = -slope;                   // 目標の傾き(−)
	}
	else{
		zone = 0;                                // 維持(ニュートラル)
		targetRatePS = 0;                        // 速度を保つ
	}

	// 停止アシスト:ブレーキ中かつ 1km/h 表示域に入ったら、
	// 目標傾きを“強めの減速”に上書き(既存より強い場合のみ)。
	// curMicro が小さいほど強く効くよう線形にスケール。
	if (zone < 0 && curMicro <= MICRO_STOP_ASSIST) {
		int32_t stopSlope = - (int32_t)(
			(((int64_t)STOP_ASSIST_RATE_PS * curMicro) + (MICRO_STOP_ASSIST/2)) / MICRO_STOP_ASSIST
		);
		if (targetRatePS > stopSlope) targetRatePS = stopSlope;
	}

	// ジャーク制限:傾きの変化を1回あたりの最大量におさえる(ガクっとしない)
	int32_t maxDeltaPerTick = JERK_RATE_PS2 / TICK_HZ;
	int32_t diff = targetRatePS - rateMicroPS;
	if (diff >  maxDeltaPerTick) diff =  maxDeltaPerTick;
	if (diff < -maxDeltaPerTick) diff = -maxDeltaPerTick;
	rateMicroPS += diff;                         // 傾きを少しずつ目標へ寄せる

	// 傾きに合わせて内部速度を更新(Δt=5ms)
	int32_t delta = (int32_t)((int64_t)rateMicroPS * TICK_MS / 1000);
	curMicro += delta;

	// 範囲チェックと cap(上限)
	if (curMicro < 0) curMicro = 0;
	if (curMicro > MAX_MICRO) curMicro = MAX_MICRO;
	// 加速中(傾きが+)のときだけ cap を適用
	if (rateMicroPS >= 0 && curMicro > capMicro) curMicro = capMicro;

	// 微小域の丸め:ブレーキ中のみ 0 に吸い込む(ニュートラルでは維持)
	// 残留 1km/h 表示がダラつかないよう、極小なら 0 にする
	if (zone < 0 && curMicro <= (MICRO_STOP_ASSIST/10)) {
		curMicro = 0; rateMicroPS = 0; capMicro = 0;
	}

	// 完全停止になったら片付け(capを0に戻すなど)
	if (curMicro == 0){
		capMicro = 0;
		if (zone <= 0){
			rateMicroPS = 0;
		}
	}
}

/* ==== 初期化(最初に1回だけ実行)==== */
void setup(){
	// モータ出力ピン(最初は停止)
	pinMode(PIN_IN1, OUTPUT); analogWrite(PIN_IN1, 0);
	pinMode(PIN_IN2, OUTPUT); analogWrite(PIN_IN2, 0);

	// 入力ピン(プルアップで未押下=HIGH / 押す=LOW になる)
	pinMode(PIN_HRN, INPUT_PULLUP);
	pinMode(PIN_DIRSW, INPUT_PULLUP);

	// LEDピン(最初は全部OFF)
	pinMode(PIN_LED_R, OUTPUT); digitalWrite(PIN_LED_R, LOW);
	pinMode(PIN_LED_G, OUTPUT); digitalWrite(PIN_LED_G, LOW);
	pinMode(PIN_LED_B, OUTPUT); digitalWrite(PIN_LED_B, LOW);

	// スティック中央を軽く平均(約200ms)→ 中央の揺れを小さくして正確に
	uint32_t t0 = millis(), acc = 0; uint16_t n = 0;
	while (millis() - t0 < 200){ acc += analogRead(PIN_AXS); n++; delay(2); }
	if (n) centerRaw = acc / n;
	centerEff = AXIS_INVERT ? (uint16_t)(1023 - centerRaw) : centerRaw;

	// DFPlayer:シリアル通信を始めて、非ブロッキング初期化を開始
	mp3.begin(9600);
	dfInitStart();

	// LCDの初期表示
	lcdInit();	// USE_LCD=0 のときは中身が空

	// シリアルモニタ(ログ出力用)
	Serial.begin(57600);
	Serial.print(F("CENTER(raw)=")); Serial.print(centerRaw);
	Serial.print(F("  C_eff="));     Serial.println(centerEff);

	// 一定周期の割り込み(200Hz)を開始
	setupTimer1_200Hz();

	// 初期LED表示:停止&ニュートラル=青点灯
	digitalWrite(PIN_LED_R, LOW);
	digitalWrite(PIN_LED_G, LOW);
	digitalWrite(PIN_LED_B, HIGH);
}

/* ==== ずっと繰り返す処理(毎回すばやく)==== */
void loop(){
	uint32_t now = millis();	// 今の時間(ミリ秒)を読む

	/* DFPlayer初期化の段階実行(READYになるまで順に進める) */
	dfInitTick(now);

	/* READYで、保留があれば自動再生(ボタン押下中 or 有効期限内) */
	if (dfState == DF_SM_READY && hornPending){
        // ボタンがまだ押されている、または保留が新しければ再生
		bool hornBtnNow = (digitalRead(PIN_HRN) == LOW);
		if ((now >= dfBootGraceUntil) && (hornBtnNow || (uint32_t)(now - hornPendingSince) <= HORN_PENDING_TIMEOUT_MS)){
			// 連打ガード:前回から十分あいているか
			if ((uint32_t)(now - hornLastStartMs) >= HORN_MIN_RETRIGGER_MS){
				// キック窓の初期化(最初の1回だけTF選択やResumeを送る準備)
				hornKickSentSelect = false;
				hornKickResumeSent = false;

				setVolQuick(DF_VOLUME);	// 音量を既定値へ
				playHorn();				// 1回目は“頭から確実に再生”
				hornState = 1;			// 再生中に遷移
				hornPending = false;	// 保留はクリア
				hornLastVolSent = DF_VOLUME;
				hornLastVolSentMs = now;
				hornKickUntilMs = now + HORN_KICK_WINDOW_MS;
				hornLastKickMs = now - HORN_KICK_INTERVAL_MS;	// すぐ次のキックができるように
				hornLastStartMs = now;
				hornHoldUntilMs = now + HORN_MIN_HOLD_MS;		// 最低保持の期限
			}else{
				// 最小間隔未満なら保留を破棄(暴発防止)
				hornPending = false;
			}
		}else{
			// 保留が古くなりすぎたら消す
			if ((uint32_t)(now - hornPendingSince) > HORN_PENDING_TIMEOUT_MS) hornPending = false;
		}
	}

	/* READY直後の“無音の目覚まし”を進める(1回だけ) */
	if (dfState == DF_SM_READY && !dfPrimed){
		switch(dfPrimeStep){
			case 1:	// 音量を0にする
				setVolQuick(0);
				dfPrimeStep = 2;
				dfPrimeT0 = now;
				break;
			case 2:	// 少し待ってから短く再生(デコーダを起こす)
				if ((uint32_t)(now - dfPrimeT0) >= 40){
					playHorn();
					dfPrimeStep = 3;
					dfPrimeT0 = now;
				}
				break;
			case 3:	// すぐに停止する(音はほぼ聞こえない)
				if ((uint32_t)(now - dfPrimeT0) >= 120){
					stopHorn();
					dfPrimeStep = 4;
					dfPrimeT0 = now;
				}
				break;
			case 4:	// 音量を元に戻して完了
				if ((uint32_t)(now - dfPrimeT0) >= 60){
					setVolQuick(dfPrimeSavedVol);
					dfPrimed = true;
					dfPrimeStep = 0;
				}
				break;
		}
	}

	/* 方向切替:完全停止&維持ゾーンの時だけ反転(間違って押しても安全) */
	static bool lastRead = HIGH, stable = HIGH; static uint32_t tchg = 0;
	bool raw = digitalRead(PIN_DIRSW);
	if (raw != lastRead){ lastRead = raw; tchg = now; }	// 変化した瞬間の時刻を覚える
	if (now - tchg > 25 && raw != stable){				// 25ms以上同じなら“確定”
		stable = raw;
		if (stable == LOW){								// ボタンが押された
			// 完全停止&維持ゾーン(ニュートラル)なら方向を切り替える
			noInterrupts();								// 割り込み中の値とぶつからないように一時停止
			int32_t cm = curMicro; int8_t z = zone; bool curRev = REV_DIR;
			interrupts();
			bool allowed = (cm == 0 && z == 0);
			if (allowed){
				bool nextRev = !curRev;					// 方向を反転
				if (nextRev){ blinkColor(true,false,false,3,160,120); }	// 次=後進→赤を点滅
				else         { blinkColor(false,true,false,3,160,120); }	// 次=前進→緑を点滅
				REV_DIR = nextRev;						// 実際に切り替える
				Serial.println(REV_DIR ? F("Direction: REVERSE") : F("Direction: FORWARD"));
			}else{
				Serial.println(F("Direction change ignored"));	// 走行中は無視
			}
		}
	}

	/* ==== ホーン処理:デバウンス→押下/離し検出→キック→フェード ==== */
	// 1) ボタン読み+デバウンス(ガタつきを無視して安定した状態にする)
	static bool		hornStable = false;
	static bool		hornLastRead = false;
	static uint32_t	hornDebT0 = 0;
	bool hornRaw = (digitalRead(PIN_HRN) == LOW);
	if (hornRaw != hornLastRead){ hornLastRead = hornRaw; hornDebT0 = now; }
	if ((uint32_t)(now - hornDebT0) >= HORN_DEBOUNCE_MS && hornStable != hornRaw){
		hornStable = hornRaw;
	}

	// 2) 立ち上がり(押された瞬間)と立ち下がり(離した瞬間)を検出
	bool hornBtn = hornStable;					// 安定化したボタン状態
	static bool hornBtnPrev = false;
	bool hornPressed  = (hornBtn && !hornBtnPrev);
	bool hornReleased = (!hornBtn && hornBtnPrev);
	hornBtnPrev = hornBtn;

	// 3) 押された瞬間の処理
	if (hornPressed){
		// “無音の目覚まし”中ならすぐ解除して通常の音量に戻す
		if (!dfPrimed){
			dfPrimed = true; dfPrimeStep = 0;
			setVolQuick(DF_VOLUME);
		}
		// フェード中にまた押されたら、いったん停止してから新しく再生へ
		if (hornState == 2){
			stopHorn();
			hornState = 0;
		}
		// READY前や起動猶予中は“保留”。READYなら最小間隔を満たしたら再生
		if ((now < dfBootGraceUntil) || (dfState != DF_SM_READY)){
			hornPending = true;
			hornPendingSince = now;
		}else{
			if ((uint32_t)(now - hornLastStartMs) >= HORN_MIN_RETRIGGER_MS){
				// キック窓の初期化(最初だけTF選択とResumeを送る準備)
				hornKickSentSelect = false;
				hornKickResumeSent = false;

				setVolQuick(DF_VOLUME);
				playHorn();						// 頭からスタート
				hornState = 1;					// 再生中
				hornLastVolSent = DF_VOLUME;
				hornLastVolSentMs = now;
				hornKickUntilMs = now + HORN_KICK_WINDOW_MS;
				hornLastKickMs = now - HORN_KICK_INTERVAL_MS;	// すぐキック可能に
				hornLastStartMs = now;
				hornHoldUntilMs = now + HORN_MIN_HOLD_MS;		// 最低保持
			}
		}
	}

	// 4) 押し続け直後の“キック再送”(一定時間だけ安定させる)
	//    すでに再生中に“再生(0x03)”を繰り返すと曲頭に戻ってブツブツ切れるため、
	//    最初の1回だけ Resume(0x0D) を送り、その後は音量の再送だけ行う。
	if (hornState == 1 && (int32_t)(hornKickUntilMs - now) > 0){
		if ((uint32_t)(now - hornLastKickMs) >= HORN_KICK_INTERVAL_MS){
			if (!hornKickSentSelect){			// 最初の1回だけデバイス選択を再送
				dfSelectTF();
				hornKickSentSelect = true;
			}
			setVolQuick(DF_VOLUME);				// 音量を念のため再送(無害)
			if (!hornKickResumeSent){			// Resumeは最初の1回だけ
				resumeHorn();
				hornKickResumeSent = true;
			}
			hornLastKickMs = now;				// 次のキックまでの時間測定
		}
	}

	// 5) 離された瞬間:最低保持時間が過ぎていればフェード開始
	if (hornReleased){
		if (hornState == 1){
			if ((int32_t)(hornHoldUntilMs - now) <= 0){
                // フェードへ移行(音量を少しずつ下げ、最後に停止)
				hornState = 2;
				hornFadeStartMs = now;
				hornLastVolSent = -1;
				hornLastVolSentMs = 0;
				hornKickUntilMs = 0;
			}
		}
	}

	// 6) 最低保持時間が過ぎていて、ボタンがすでに離されているならフェードへ
	if (hornState == 1 && !hornBtn && (int32_t)(now - hornHoldUntilMs) >= 0){
		hornState = 2;
		hornFadeStartMs = now;
		hornLastVolSent = -1;
		hornLastVolSentMs = 0;
		hornKickUntilMs = 0;
	}

	// 7) フェード中:経過時間に合わせて音量を下げ、終わったら停止
	if (hornState == 2){
		uint32_t elapsed = now - hornFadeStartMs;	// どれだけ時間がたったか
		if (elapsed >= HORN_FADE_MS){
			stopHorn();								// 最後に停止
			hornState = 0;
		}else{
			// DF_VOLUME から 0 まで、時間に比例して下げる(丸め誤差を真ん中で補正)
			int vol = DF_VOLUME - (int)(((uint32_t)DF_VOLUME * elapsed + (HORN_FADE_MS/2)) / HORN_FADE_MS);
			if (vol < 0) vol = 0;
			// 前回送ってから十分時間が経っていて、値が変わるときだけ送る
			if ((uint32_t)(now - hornLastVolSentMs) >= HORN_FADE_STEP_MS && vol != hornLastVolSent){
				setVolQuick((uint8_t)vol);
				hornLastVolSent = (int8_t)vol;
				hornLastVolSentMs = now;
			}
		}
	}

	// === 出力(内部速度→PWM)===
	// 割り込み中で書き換えられる値は、まとめて“安全に”取り出す
	noInterrupts();
	int32_t micro = curMicro;
	int8_t  zcopy  = zone;
	bool    rev    = REV_DIR;
	interrupts();

	// 内部速度(0~MAX_MICRO)を 0~PWM_HARD_MAX に変換してモータへ
	int pwm = (int)((micro * (int32_t)PWM_HARD_MAX + (MAX_MICRO/2)) / MAX_MICRO); // 四捨五入
	if (!rev){ analogWrite(PIN_IN1, pwm); analogWrite(PIN_IN2, 0); }	// 前進
	else     { analogWrite(PIN_IN1, 0);   analogWrite(PIN_IN2, pwm); }	// 後進

	// LED表示:停止&維持ゾーン=青、それ以外は前進=緑/後進=赤
	bool stoppedNeutral = (micro == 0 && zcopy == 0);
	setLedByState(stoppedNeutral, rev);

	// LCD更新:ホーンが再生中/フェード中なら ON 表示
	bool hornActive = (hornState != 0);
	updateLcd(micro, rev, hornActive);

	// ログ:値が変わった時だけ、まとめて表示(見やすく・軽く)
	static int32_t _micro = -1;
	static int     _pwm   = -1;
	static bool    _rev   = false;
	static bool    _stN   = true;
	static bool    _hornW = false;
	if (micro != _micro || pwm != _pwm || rev != _rev || stoppedNeutral != _stN || hornActive != _hornW) {
		Serial.print(F("MICRO=")); Serial.print(micro);
		Serial.print(F(" PWM="));  Serial.print(pwm);
		Serial.print(F(" DIR="));  Serial.print(rev ? F("REV") : F("FWD"));
		Serial.print(F(" ZONE=")); Serial.print(stoppedNeutral ? F("NEU") : F("RUN"));
		Serial.print(F(" HORN=")); Serial.println(hornActive ? F("ON ") : F("OFF"));
		_micro = micro; _pwm = pwm; _rev = rev; _stN = stoppedNeutral; _hornW = hornActive;
	}

	delay(2); // ほんの少し休ませる(CPUに余裕を持たせる)
}
				

まとめと今後の計画

好きな鉄道グッズを部屋で動かすために、配線の工夫とコードの調整を積み重ね、4つの装置として形にできました。
ここからさらに拡張していきます。たとえば、センサーを使った信号機や接近メロディを流す装置や、Nゲージにカメラを取り付けるなどしてみたいです。
またサーボモーターで動くドアの作成などに挑戦したいです。
それには今はもってない、3Dプリンターが必要になりそうです。