Raspberry Pi Zero Wで外出先からサーボを動かす。ヘッドレスセットアップからハードウェアPWM、WireCanalでの公開まで
2017年の初代Raspberry Pi Zero Wにサーボをつなぎ、Windows PCだけでヘッドレスセットアップして、インターネット越しにcurl一発でサーボを動かすまでの全手順です。ソフトウェアPWMで震えるサーボをハードウェアPWMで止め、その差をHTTPのAPIで切り替えられるようにして、WireCanalの無料プランでポート開放なしに公開しました。書き込み184秒・起動5分・切替1秒など、すべて実測値つきです。
こんにちはミシェルです!
わたしの手元に、初代のRaspberry Pi Zero Wがあります。
シングルコアのARMv6、メモリ512MB、Wi-Fiは2.4GHzだけ。2017年に出た小さなボードで、いまのRaspberry Pi 5と並べると、まるで世代が違います。
この小さなボードにサーボモーターを1つつないで、Windows PCだけでヘッドレスセットアップし、インターネット越しにcurl一発でサーボを動かすところまでやってみました。
ちなみに、私ひとりの手柄ではなく、当社エンジニアの多大なるサポートでここまでこぎつけました♪
結論から言うと、SDカードの書き込みからインターネット越しにサーボが動くまで、モニターもキーボードもポート開放もなしで完走できました。
途中で1つ、面白い問題にぶつかりました。ソフトウェアPWMで動かしたサーボが、小刻みにぶるぶる震えるのです。ソフトウェアで作るPWMのタイミングの揺れが原因とみられ、SoC内蔵のハードウェアPWMに切り替えたらぴたりと止まりました。今回はこの「震えるPWM」と「震えないPWM」をHTTPのAPIで切り替えられるようにしてあります。震えの差を、インターネット越しにcurlやスマホで確かめられる作りです。
当ブログでは、Raspberry Pi 5をモニターなしでセットアップする記事から始まる三部作と、3,750円のLuckfox Pico Mを外出先から使う記事で、小さなLinuxボードを外から使う手順を書いてきました。今回はその流れで、いちばん古くていちばん非力なボードを現役に戻します。この記事の数字はすべて、2026年9月2日から3日にかけて手元の環境(Zero W Rev 1.1・Raspberry Pi OS Lite 32-bit)で実測した値です。microSDカードやネットワーク環境によって所要時間は変わります。
用意するもの
- Raspberry Pi Zero W(初代・Rev 1.1)。ピンヘッダは別売りなので、今回は40ピン全部ではなく、使うピンのまわりに3ピンのピンヘッダを2か所だけはんだ付けしました。外部電源化した現在の配線で使うのは30番(GND)と32番(GPIO12)の2本だけです

- microSDカード。今回は64GB(初回起動時に全容量まで自動拡張されます)
- micro USBの電源。基板端の「PWR IN」側に挿します
- 小型のホビーサーボ1つ(今回はSG92R)と、ジャンパーワイヤ
- サーボ用の外部電源。5V・2A以上のスイッチング電源と、外部電源の+5VとGNDの間に並列に入れる100µFの電解コンデンサ(定格電圧は5Vを上回るもの。余裕を見て10V以上を選んでもよく、今回使ったのは定格50Vのもの)、0.1µFのセラミックコンデンサ
- Windows 11のPCとUSBカードリーダー
- Git for Windows。同梱のopensslでパスワードのハッシュを作ります
モニターもHDMIケーブルも使いません。作業はすべてWindowsのPowerShellとSSHで進めます。
Zero WはRaspberry Pi 5と何が違うか。先に押さえる5点
手順そのものはRaspberry Pi 5のときとほぼ同じですが、Zero W固有の違いを知らないまま進めると、SDカードを挿しても起動しなかったり、Wi-Fiにつながらなかったりします。先に違いだけまとめます。
| 項目 | Raspberry Pi 5 | Raspberry Pi Zero W |
|---|---|---|
| OSイメージ | 64-bitデスクトップ版(raspios_arm64_latest) | 32-bit版が必須(ARMv6なので64-bit版のRaspberry Pi OSは対象外)。今回はメモリ512MBでヘッドレス運用するので、32-bitのLite版(raspios_lite_armhf_latest)を選んだ |
| Wi-Fi | 2.4GHz / 5GHz | 2.4GHzのみ。5GHz専用のSSIDでは永遠につながらない |
| 電源 | USB-C | micro USBの「PWR IN」側(基板の端)。内側のUSBポートはOTG用 |
| 初回起動 | 約3分 | 約5分(シングルコアで初回設定とファイルシステム拡張が遅い) |
| ホスト名 | raspberrypi | pizero にした。同じLANにRaspberry Pi 5がいると raspberrypi.local が衝突するため |
動作確認した環境は、書き込みソフトがRaspberry Pi Imager 2.0.8、書き込んだOSがRaspberry Pi OS Lite 32-bit(Debian 13 trixieベース、カーネル6.18)、作業PCがWindows 11 Pro(Git for Windowsを導入済み)です。
手順1: サンプルコードを取得して、32-bit LiteのOSイメージをダウンロードする
この記事で使うスクリプトとコードは、すべてGitHubのリポジトリに置いてあります。まずWindows側で取得します。以降の手順は、このフォルダをカレントディレクトリにして進めます。
mkdir C:\qualiteg_examples -Force
cd C:\qualiteg_examples
git clone https://github.com/qualiteg/wirecanal-iot-demo-raspberry-pi-zero-w-servo.git
cd wirecanal-iot-demo-raspberry-pi-zero-w-servoRaspberry Pi Imagerがまだ入っていなければwingetで入れます。前回のRaspberry Pi 5の記事と同じです。
winget install --id RaspberryPiFoundation.RaspberryPiImager --silent --accept-package-agreements --accept-source-agreementsOSイメージは公式のダウンロードサイトから取得します。Zero W向けのURLは raspios_lite_armhf_latest です。Raspberry Pi 5のときの raspios_arm64_latest と一文字違いなので、ここを間違えると起動しないSDカードができあがります。
OSイメージは、書き込みスクリプトと同じ setup フォルダに置きます(スクリプトは自分と同じフォルダのイメージを書き込みます)。
curl.exe -L --fail -o setup\raspios-lite-armhf.img.xz https://downloads.raspberrypi.com/raspios_lite_armhf_latest実体は 2026-06-18-raspios-trixie-armhf-lite.img.xz で、圧縮524MB、展開すると2,552MBでした。ダウンロードは1分ほどです。xz圧縮のまま書き込みに使えるので、自分で展開する必要はありません。
手順2: 設定ファイルを1つ編集して、SDカードに書き込む
Raspberry Pi 5の記事では、firstrun.sh を手で書き、Imagerのコマンドを手で組み立てました。今回は同じことをするPowerShellスクリプトを2本に分けました。設定だけを書く setup/pizero-config.ps1 と、書き込みを実行する setup/flash-pizero.ps1 です。編集するのは設定側だけです。
setup/pizero-config.ps1(全文。SSIDとパスワードはご自身のものに置き換えてください)
# ===== Raspberry Pi Zero W headless setup: settings =====
# Edit this file, then run flash-pizero.ps1.
# NOTE: the Zero W has 2.4 GHz Wi-Fi only. A 5 GHz-only SSID will never connect.
$WifiSsid = 'YOUR_SSID'
$WifiPassword = 'YOUR_WIFI_PASSWORD'
$WifiCountry = 'JP'
$Hostname = 'pizero' # a name that does not collide with raspberrypi.local
$UserName = 'pi'
$UserPassword = 'raspberry' # change it with `passwd` right after the first login
$Timezone = 'Asia/Tokyo'
$Keymap = 'jp'
$ImageFile = 'raspios-lite-armhf.img.xz' # the Zero W needs the 32-bit (armhf) image書き込みスクリプトのほうは、やることが4つあります。書き込み先ディスクの安全確認、パスワードのハッシュ化、firstrun.sh の生成、Imager CLIの起動です。Wi-Fiのパスワードにシングルクォートが入っていてもfirstrun.shが壊れないように、値はシェル用にクォートしてから埋め込みます。中身の要点を抜粋します。
setup/flash-pizero.ps1(要点の抜粋。全文はGitHubのリポジトリを見てください)
# Quote a value for a POSIX shell single-quoted string ( ' becomes '\'' )
function Q([string]$v) { "'" + $v.Replace("'", "'\''") + "'" }
# --- 1. Confirm the target disk (NVMe, system/boot disks and anything over 256 GB are refused) ---
$disk = Get-Disk -Number $DiskNumber
if ($disk.BusType -eq 'NVMe' -or $disk.IsSystem -or $disk.IsBoot) {
throw "Disk $DiskNumber ($($disk.FriendlyName)) is a system disk. Aborting."
}
# --- 2. Hash the password (openssl bundled with Git for Windows) ---
$hash = (& $openssl passwd -6 $UserPassword).Trim()
# --- 3. Generate firstrun.sh (LF line endings, no BOM; CRLF breaks the first boot) ---
$firstrun = $firstrun.Replace("`r`n", "`n")
[IO.File]::WriteAllText("$PSScriptRoot\firstrun.sh", $firstrun, (New-Object System.Text.UTF8Encoding($false)))
# --- 4. Write with Imager CLI, elevated (physical disk = \\.\PhysicalDriveN) ---
$imgArgs = @('--cli', '--debug', '--disable-eject',
'--first-run-script', "`"$PSScriptRoot\firstrun.sh`"",
'--log-file', "`"$logFile`"",
"`"$PSScriptRoot\$ImageFile`"", "\\.\PhysicalDrive$DiskNumber")
Start-Process -FilePath $imager -ArgumentList $imgArgs -Verb RunAs -Wait生成される firstrun.sh は、Raspberry Pi OSに標準で入っているヘルパーを呼ぶだけの短いものです。ホスト名、SSH、ユーザー、Wi-Fi、キーボード配列、タイムゾーンをまとめて設定し、最後に自分自身を消して通常起動に戻します。
firstrun.sh(スクリプトが生成するもの・全文)
#!/bin/bash
set +e
/usr/lib/raspberrypi-sys-mods/imager_custom set_hostname 'pizero'
/usr/lib/raspberrypi-sys-mods/imager_custom enable_ssh
/usr/lib/userconf-pi/userconf 'pi' '$6$…(openssl passwd -6 で生成したハッシュ)'
/usr/lib/raspberrypi-sys-mods/imager_custom set_wlan 'YOUR_SSID' 'YOUR_WIFI_PASSWORD' 'JP'
/usr/lib/raspberrypi-sys-mods/imager_custom set_keymap 'jp'
/usr/lib/raspberrypi-sys-mods/imager_custom set_timezone 'Asia/Tokyo'
rm -f /boot/firstrun.sh /boot/firmware/firstrun.sh
sed -i 's| systemd.run.*||g' /boot/cmdline.txt /boot/firmware/cmdline.txt 2>/dev/null
exit 0SDカードをカードリーダーに挿し、ディスク番号を確認してから、setup フォルダで実行します。
cd setup
Get-Disk
# Number FriendlyName BusType SizeGB
# ------ ------------ ------- ------
# 0 CT4000P3PSSD8 NVMe 3726
# 1 Generic- SD/MMC/MS PRO USB 59.5
.\flash-pizero.ps1 -DiskNumber 1UACのダイアログが出たら許可します。書き込みは107秒、読み戻しの検証は67秒で、合わせて184秒で終わりました。ログの最後に succeeded が出ていれば成功です。
[DEBUG] Write done in 107 seconds
[DEBUG] Verify hash: "235aae6e32f40eb294b6485f99232d9ea5b6ee0251c8dc40e370177fac4754c2"
[DEBUG] Verify done in 66.945 seconds
[DEBUG] writeFile: updateDirEntry succeeded for "firstrun.sh"
[DEBUG] writeFile: updateDirEntry succeeded for "cmdline.txt"
[DEBUG] PerformanceStats: Cycle ended, state: "succeeded"ここで1つ、実際に踏んだ罠を書いておきます。最初の書き込みは、ログが「Drive added」の直後で終わり、エラーも出ないまま4秒で終了しました。原因は書き込み先の指定が \.\PhysicalDrive1 とバックスラッシュ1本欠けになっていたことです。Imagerは「Destination drive is not in list of removable volumes」で終了するのですが、このメッセージは標準エラー出力にしか出ず、GUIアプリとしてビルドされているためコンソールにも --log-file にも残りません。書き込み先は \\.\PhysicalDriveN と、バックスラッシュ2本で書いてください。
手順3: 電源を入れて5分待ち、SSHでつなぐ
SDカードをZero Wに挿し、基板端の「PWR IN」側のmicro USBに電源をつなぎます。緑のLEDが不規則に点滅している間は起動処理中です。初回はファイルシステムの拡張と firstrun.sh の実行と自動再起動が走るので、Raspberry Pi 5より長く、5分ほど待ちます。
実測のタイムラインです。18時45分に電源を入れ、18時50分07秒にLAN上へRaspberry Piのアドレスが現れ、18時50分39秒にSSHでログインできました。
ssh pi@pizero.local
ログイン後の状態も確認しました。uname -a、vcgencmd measure_temp、free -m、df -h の出力の抜粋です。
Linux pizero 6.18.34+rpt-rpi-v6 #1 Raspbian 1:6.18.34-1+rpt1 (2026-06-09) armv6l GNU/Linux
temp=40.6'C
total used free shared buff/cache available
Mem: 426Mi 116Mi 244Mi 2.4Mi 116Mi 310Mi
/dev/mmcblk0p2 59G 2.1G 54G 4% /64GBカードの全域がルートファイルシステムに拡張済みで、メモリは426MBのうち116MBを使用、CPU温度は40.6度でした。pizero.local という名前はmDNSで解決され、Windows 11は標準で対応しているので追加のソフトは要りません。
Zero Wは電源を入れてからLANに出てくるまで5分近くかかるので、名前が引けないときは ping pizero.local を繰り返すより、PC側で arp -a を見てRaspberry PiのMACアドレス(今回の個体では b8-27-eb で始まっていました)を探すほうが早く見つかります。
初期パスワードのままだとログインのたびに警告が出るので、最初のログインで passwd を実行して変えておいてください。
手順4: サーボをGPIO12につないで、まずソフトウェアPWMで動かす
ここからが本題です。まずZero Wのピン配置を図1に載せておきます。実機で pinout コマンド(gpiozero付属)を実行した結果をもとに描いたものです。

サーボ自体は3線です。信号線をGPIO12(物理ピン32)へ、電源とGNDは外部の5V電源(今回は5V・2Aのスイッチング電源)へつなぎます。さらに、PWM信号の基準電位をそろえるため、外部電源のマイナス側をZero Wの物理ピン30のGNDにも接続して共通GNDにします。接続は信号、外部+5V→サーボ、外部GND→サーボ、外部GND→Zero Wの30番の4系統です。外部電源のプラスはZero Wの5Vピン(物理ピン2・4)には接続しません。共通にするのはGNDだけです。サーボが動くたびに電源が揺れるのを抑えるため、外部電源の+5VとGNDの間に100µFの電解コンデンサと0.1µFのセラミックコンデンサを並列に入れました。
電解コンデンサは有極性なので、プラスを+5V、マイナスをGNDへつなぎます(定格電圧は5Vより高いものを使います)。最初はZero Wの5Vピン(物理ピン2)から給電していて、小型のホビーサーボ1つなら実測上は動きましたが、サーボの電源はZero Wと分けたほうが安全なので外部電源に切り替えました。3.3Vのピンからサーボに給電してはいけません。GPIO12を選んだのには理由があって、後でハードウェアPWMに切り替えるときに使えるピンだからです。


今回使った2026-06-18版のRaspberry Pi OS Lite 32-bitには、Pythonの gpiozero と lgpio が最初から入っています。追加インストールなしで、gpiozeroの Servo クラスをlgpioのピンファクトリで使うと、50HzのソフトウェアPWMでサーボが動きます。1.0msと2.0msのパルス幅を2秒ごとに切り替える、往復するだけのスクリプトです。
app/sweep2.py(全文)
#!/usr/bin/env python3
"""Sweep a servo on GPIO12 between two positions every 2 s using SOFTWARE PWM (gpiozero + lgpio, 50 Hz).
Software PWM timing can vary under Linux scheduling; on this single-core Zero W the servo visibly trembled.
Compare with sweep2_hw.py (hardware PWM).
"""
import time, signal, sys
from gpiozero import Servo
from gpiozero.pins.lgpio import LGPIOFactory
PIN = 12
# 1.0 ms / 2.0 ms pulses are the two end positions (the usual range for hobby servos).
# For a wider swing use min_pulse_width=0.5/1000, max_pulse_width=2.5/1000.
servo = Servo(PIN, pin_factory=LGPIOFactory(),
min_pulse_width=1.0/1000, max_pulse_width=2.0/1000, frame_width=20/1000)
POS_A, POS_B = -1.0, 1.0 # -1 = min_pulse_width, +1 = max_pulse_width
INTERVAL = 2.0
def stop(*_):
servo.detach() # stop the signal (servo relaxes)
sys.exit(0)
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
print(f"GPIO{PIN}: {POS_A} <-> {POS_B} every {INTERVAL}s (Ctrl+C / SIGTERM to stop)", flush=True)
pos = POS_A
while True:
servo.value = pos
print(time.strftime("%H:%M:%S"), "pos", pos, flush=True)
time.sleep(INTERVAL)
pos = POS_B if pos == POS_A else POS_AWindowsのリポジトリのフォルダから転送して、バックグラウンドで起動します。Zero W側には先に置き場所を作っておきます。
ssh pi@pizero.local "mkdir -p ~/servo"
scp app\sweep2.py app\sweep2_hw.py pi@pizero.local:servo/
ssh pi@pizero.local 'cd servo && (nohup python3 sweep2.py > sweep2.log 2>&1 &)'サーボは動きました。2秒ごとに右へ、左へ。ただ、止まっている位置で小刻みにぶるぶる震えています。
以下の動画の前半でご確認いただけます。ぶるぶるふるえています。
これがソフトウェアPWMのジッタです。ソフトウェアで作るPWMはCPUがタイミングを刻んでパルスを作るので、Linuxのスケジューリングや他の処理の影響で、パルスの立ち上がりと立ち下がりのタイミングが揺れることがあります。今回のZero Wでは、その影響とみられる小刻みな震えが目視できました。パルス幅の揺れそのものはオシロスコープで測っていないので、ここで数字は出しません。手元の環境ではgpiozeroも起動時に PWMSoftwareFallback という警告を出して、ソフトウェアPWMで動いていることを教えてくれました。
もうひとつ、ここで踏んだ小さな罠を書いておきます。止めるときに pkill -f sweep2.py と書くと、SSH経由で実行しているシェル自身のコマンド列にも sweep2.py が含まれるので自分を殺してしまい、sshが終了コード255で黙って切れます。完全一致の pkill -xf "python3 sweep2.py" にしてください。
手順5: ハードウェアPWMに切り替えて、震えを止める
GPIO12は、SoCのPWM0という回路につながるピンです。デバイスツリーのオーバーレイでこれを有効にすると、パルスをCPUではなくSoC内蔵のPWM回路が作るようになり、CPU負荷やスケジューリングの影響を受けにくくなります。追加パッケージは要らず、設定を1行足して再起動するだけです。pin=12 の12はBCMのGPIO番号で、物理ピン番号(32)ではありません。
echo 'dtoverlay=pwm,pin=12,func=4' | sudo tee -a /boot/firmware/config.txt
sudo reboot再起動後、今回の環境では /sys/class/pwm/pwmchip0 が現れ、pinctrl get 12 の表示が GPIO12 = PWM0 に変わります。あとはsysfsに数字を書き込むだけでサーボが動きます。piユーザーはgpioグループに入っているので、sudoは要りません。
echo 0 > /sys/class/pwm/pwmchip0/export
sleep 0.3 # pwm0 ディレクトリと gpio グループの権限が付くのを待つ
echo 20000000 > /sys/class/pwm/pwmchip0/pwm0/period # 20ms = 50Hz
echo 1500000 > /sys/class/pwm/pwmchip0/pwm0/duty_cycle # 1.5ms = 中央
echo 1 > /sys/class/pwm/pwmchip0/pwm0/enable往復スクリプトのハードウェアPWM版は、gpiozeroの代わりにこのsysfsを書くだけです。
app/sweep2_hw.py(抜粋。全文はGitHubのリポジトリを見てください)
CHIP = "/sys/class/pwm/pwmchip0"
PERIOD_NS = 20_000_000 # 20ms = 50Hz
PULSE_A_NS = 1_000_000 # 1.0ms
PULSE_B_NS = 2_000_000 # 2.0ms
def w(path, val):
with open(path, "w") as f:
f.write(str(val))
w(f"{CHIP}/pwm0/period", PERIOD_NS)
w(f"{CHIP}/pwm0/duty_cycle", PULSE_A_NS)
w(f"{CHIP}/pwm0/enable", 1)
pulse = PULSE_A_NS
while True:
w(f"{CHIP}/pwm0/duty_cycle", pulse)
time.sleep(INTERVAL)
pulse = PULSE_B_NS if pulse == PULSE_A_NS else PULSE_A_NS手順4と同じ置き場所から、バックグラウンドで起動します。スクリプトは起動時にいったん enable=0 にしてから周期とパルス幅を書き込み、止めるとき(SIGTERM)にも enable=0 にして信号を止めます。
cd ~/servo && (nohup python3 sweep2_hw.py > sweep2_hw.log 2>&1 &)
# 止めるとき(完全一致で指定する。理由は手順4の罠と同じ)
pkill -xf "python3 sweep2_hw.py"これで震えは止まりました。
先ほどの動画の後半をご覧くださいませ。
2秒ごとにカチッと動いて、ぴたりと止まります。pinctrl get 12 の出力と、sysfsの値を確認したときの実測です。次の手順のAPIサーバーに進む前に、上の pkill で止めておいてください。GPIO12を握ったままのプロセスが残っていると、APIサーバーが同じピンを使えません。
12: a0 -- | lo // GPIO12 = PWM0
period=20000000 duty_cycle=1000000 enable=1ちなみに、公式のオーバーレイ一覧によると、ハードウェアPWMを出せる代表的なピンはGPIO12とGPIO18(PWM0)、GPIO13とGPIO19(PWM1)です。オンボードのアナログ音声出力も両方のPWMチャンネルを使うので、音声と併用するときは注意が要ります。今回のLite版のヘッドレス構成では関係ありません。それ以外のピンでジッタを抑えてサーボを動かしたいなら、DMAでタイミングを作る pigpio を入れる手があります。今回は追加インストールなしで済ませたかったので、ハードウェアPWMのピンを選びました。
手順6: 「震える」と「震えない」を切り替えるAPIサーバーを立てる
震えの差を、手元のPCやスマホから自分の指で確かめられるようにしたくなりました。そこで、ソフトウェアPWMとハードウェアPWMをHTTPのAPIで切り替える小さなサーバーをZero Wに置きます。HTTPサーバーの部分はPythonの標準ライブラリだけで実装し、GPIOの制御には今回使ったOSイメージに入っているgpiozeroとlgpioを使うので、追加インストールはありません。
APIは2本だけです。
| エンドポイント | 役割 |
|---|---|
GET /api/status | いまのモード、GPIO12の状態(pinctrl get 12 の生の出力)、CPU温度などをJSONで返す |
POST /api/mode | {"mode":"soft"} / {"mode":"hard"} / {"mode":"off"} を受けてモードを切り替え、反映後の状態を返す |
どのモードでも1.0msと2.0msのパルスを2秒ごとに往復するので、動きは同じで、震えの差だけが見えます。第2回では、ここに角度を指定するAPIを足して、最後にはMCPサーバーにしてChatGPTから直接動かすところまでやる予定です。
実装で気をつけたのは、同じGPIO12でモードを往復させる順番です。ソフトウェアPWMに入るとlgpioがピンを普通の出力ピンに変えてしまうので、ハードウェアPWMに戻すときは pinctrl set 12 a0 でピンの機能をPWM0に戻してからsysfsを有効にします。この1行がないと、sysfsに書いても何も出ません。
app/api_server.py(モード切替の部分・抜粋。全文はGitHubのリポジトリを見てください)
def _enter(self, mode):
if mode == "soft":
self._factory = LGPIOFactory()
self._servo = Servo(PIN, pin_factory=self._factory,
min_pulse_width=PULSE_A_NS / 1e9, max_pulse_width=PULSE_B_NS / 1e9,
frame_width=PERIOD_NS / 1e9)
elif mode == "hard":
pinctrl("set", str(PIN), "a0") # lgpio leaves the pin as input/output: put it back to PWM0
if not os.path.exists(PWM_DIR):
sysfs_write(f"{PWM_CHIP}/export", PWM_CH)
time.sleep(0.3) # wait for udev to apply the gpio-group permissions
sysfs_write(f"{PWM_DIR}/enable", 0)
sysfs_write(f"{PWM_DIR}/period", PERIOD_NS)
sysfs_write(f"{PWM_DIR}/duty_cycle", self.pulse_ns)
sysfs_write(f"{PWM_DIR}/enable", 1)
def _leave(self, mode):
if mode == "soft":
self._servo.detach(); self._servo.close(); self._factory.close()
elif mode == "hard":
sysfs_write(f"{PWM_DIR}/enable", 0)GPIOを触るのはワーカースレッド1本だけにして、HTTP側は「次のモードはこれ」と要求を置くだけにしてあります。同時に複数のリクエストが来てもGPIOの操作が交錯しないようにするためです。待ち受けは 127.0.0.1:18080 だけで、LANには直接公開しません。外からのアクセスは次の手順でWireCanalのAgentがこのloopbackへ届けてくれるので、ルーターやファイアウォールでインターネット向けの受信ポートを開ける必要がないのです。
ここでもZero Wならではの罠がありました。最初の版では、ソフトウェアPWMへ切り替えるAPIの応答が古いモードのまま返ってきました。原因はgpiozeroの初回importがZero Wでは数秒かかることで、切り替え完了を待つ時間を超えていたのです。起動時にimportを済ませておくように直して解決しました。
systemdで常駐させます。piユーザーで動かせばgpioグループの権限でsysfsに書けます。
systemd/servo-api.service(全文)
[Unit]
Description=Pi Zero W servo demo API server (127.0.0.1:18080, hosts web UI)
After=network.target
[Service]
User=pi
WorkingDirectory=/home/pi/servo_demo
ExecStart=/usr/bin/python3 /home/pi/servo_demo/api_server.py
Restart=always
RestartSec=3
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.targetまずWindowsのリポジトリのフォルダから、app と systemd をZero Wへ転送します。
ssh pi@pizero.local "mkdir -p ~/servo_src"
scp -r app systemd pi@pizero.local:servo_src/ここからはZero W側です。
cd ~/servo_src
mkdir -p ~/servo_demo && cp -r app/api_server.py app/web ~/servo_demo/
sudo install -m 644 systemd/servo-api.service /etc/systemd/system/servo-api.service
sudo systemctl daemon-reload
sudo systemctl enable --now servo-api
curl -s http://127.0.0.1:18080/healthz # okZero Wの上で、まずローカルのcurlで叩いてみます。状態の取得はこうです。
curl -s http://127.0.0.1:18080/api/status{"mode": "off", "requested": "off", "pulse_ms": 1.0, "switches": 0, "since": 1788363789.7433455, "last_error": "", "pin": "12: ip -- | lo // GPIO12 = input", "board": {"hostname": "pizero", "arch": "armv6l", "model": "Raspberry Pi Zero W Rev 1.1", "cpu_temp_c": 41.2, "uptime_s": 8829}, "server_uptime_s": 103}モードの切り替えはPOSTです。ハードウェアPWMに切り替えると、応答の pin が GPIO12 = PWM0 になって、サーボが往復を始めます。
curl -s -X POST -H "Content-Type: application/json" -d '{"mode":"hard"}' http://127.0.0.1:18080/api/mode{"mode": "hard", "requested": "hard", "pulse_ms": 2.0, "switches": 1, "since": 1788364513.3218658, "last_error": "", "pin": "12: a0 -- | lo // GPIO12 = PWM0", "board": {"hostname": "pizero", "arch": "armv6l", "model": "Raspberry Pi Zero W Rev 1.1", "cpu_temp_c": 41.2, "uptime_s": 9464}, "server_uptime_s": 738}このとき pinctrl get 12 とsysfsの値を見ると、PWM0が有効になって20msの周期で2.0msのパルスが出ています。
$ pinctrl get 12
12: a0 -- | lo // GPIO12 = PWM0
$ cat /sys/class/pwm/pwmchip0/pwm0/period /sys/class/pwm/pwmchip0/pwm0/duty_cycle /sys/class/pwm/pwmchip0/pwm0/enable
20000000 2000000 1止めるときは {"mode":"off"} を送るだけです。
curl -s -X POST -H "Content-Type: application/json" -d '{"mode":"off"}' http://127.0.0.1:18080/api/modeおまけとして、同じサーバーがブラウザ向けの小さな画面も返します。画面はJavaScriptからこのAPIをfetchで呼んでいるだけで、GPIOのことは何も知りません。第2回で角度を指定できるようにしたときも、この画面側に触る必要はありません。

手順7: WireCanalのcanalを作って、Zero Wに公開URLをつける
ここからインターネット側に出します。使うのは当社のWireCanalです。Zero Wから外向きの接続を張るだけでHTTPSの公開URLがつく仕組みで、ルーターのポート開放も固定IPも要りません。無料プランで進めます。
app.wirecanal.com にログインして「新しいcanalを作成」を押すと、4ステップのウィザードが始まります。

種類はHTTP、公開アドレスは「おまかせサブドメイン」、転送先にZero WのAPIサーバーの localhost:18080 を入れます。




作成すると、canal詳細に公開URLと接続ファイル wirecanal.json が表示されます。このファイルにはそのcanal用の接続キーが入っているので、「wirecanal.json をダウンロード」で手元に落とし、ダウンロードしたフォルダからZero Wのホームへ運びます。
scp .\wirecanal.json pi@pizero.local:~/同じ画面の「Linux」タブに、次の手順で使うコマンドがそのまま出ています。

手順8: Zero WにAgentを入れて、systemdで常駐させる
Zero W側の作業は、公式のLinuxセットアップガイドのとおりです。CPUの種類(今回はarmv6l)はインストーラが自動で判定するので、Raspberry Pi 5のときと同じ1行で入ります。
sudo mkdir -p /opt/wirecanal && cd /opt/wirecanal
curl -fsSL https://download.wirecanal.com/install.sh | sudo shWireCanal Agent v.0.18.3 をダウンロードしています...
wirecanal v.0.18.3 powered by Qualiteg Inc. https://qualiteg.com
インストール完了: /opt/wirecanal/wirecanal
次の手順:
1. ダッシュボード(https://app.wirecanal.com)の canal 詳細から wirecanal.json を
ダウンロードして、wirecanal と同じフォルダに置く
2. 起動: ./wirecanal -config wirecanal.json入ったのは、ランタイム不要の静的リンクされた32-bit ARMのバイナリで、9.8MBでした。
$ file /opt/wirecanal/wirecanal
/opt/wirecanal/wirecanal: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked手順7でホームに運んだ wirecanal.json を同じディレクトリに置き、専用ユーザーとsystemdの雛形を用意します。/opt/wirecanal はroot所有なので、ホームから sudo install でコピーします。雛形は公式ガイドに載っているものをそのまま使いました。
sudo install -m 600 ~/wirecanal.json /opt/wirecanal/wirecanal.json
sudo useradd --system --home /opt/wirecanal --shell /usr/sbin/nologin wirecanal
sudo chown -R wirecanal: /opt/wirecanal/etc/systemd/system/wirecanal.service(全文)
[Unit]
Description=WireCanal Agent
After=network-online.target
Wants=network-online.target
[Service]
User=wirecanal
WorkingDirectory=/opt/wirecanal
ExecStart=/opt/wirecanal/wirecanal -config /opt/wirecanal/wirecanal.json
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now wirecanal
journalctl -u wirecanal -f起動ログです。起動から2秒で「届けます」の行が出て、開通しました。
wirecanal v.0.18.3 powered by Qualiteg Inc. https://qualiteg.com
wirecanal: 自動更新が有効です(無効化は wirecanal.json に "auto_update": false)
wirecanal: canal d7t2: https://5jk3hvty.ja000.wirecanal.com へのアクセスをローカルの転送先 localhost:18080 に届けます
wirecanal 0.18.3: connecting tenant=d7t2 mode=http forward_target=localhost:18080ダッシュボード側も「接続中」に変わります。

インターネットの向こう側からcurlで叩く
はい!ここが今回のゴールです。
Zero Wの上で叩いたのと同じAPIを、手元のWindows PCから公開URLに向けて叩きます。PCはZero Wと同じ家のLANにいますが、公開URLはWireCanalのエッジ(東京)を経由するので、経路はインターネット越しです。URLが変わるだけで、コマンドはほぼそのままです。違いは、Windowsでは curl.exe と拡張子まで書くことです。Windows PowerShell 5.1では curl が Invoke-WebRequest の別名になっていて本物のcurlが隠れるためで、手順1のダウンロードで curl.exe と書いたのも同じ理由です。
curl.exe -s https://5jk3hvty.ja000.wirecanal.com/api/status{"mode": "off", "requested": "off", "pulse_ms": 1.0, "switches": 0, "since": 1788363789.7433455, "last_error": "", "pin": "12: ip -- | lo // GPIO12 = input", "board": {"hostname": "pizero", "arch": "armv6l", "model": "Raspberry Pi Zero W Rev 1.1", "cpu_temp_c": 41.2, "uptime_s": 8829}, "server_uptime_s": 103}モードの切り替えも同じです。手元のPCから、ハードウェアPWMで往復を始めさせます。
curl.exe -s -X POST -H "Content-Type: application/json" -d '{"mode":"hard"}' https://5jk3hvty.ja000.wirecanal.com/api/mode{"mode": "hard", "requested": "hard", "pulse_ms": 1.0, "switches": 1, "since": 1788364513.3218658, "last_error": "", "pin": "12: a0 -- | hi // GPIO12 = PWM0", "board": {"hostname": "pizero", "arch": "armv6l", "model": "Raspberry Pi Zero W Rev 1.1", "cpu_temp_c": 39.5, "uptime_s": 9449}, "server_uptime_s": 723}応答の pin が GPIO12 = PWM0 になり、Zero Wの隣でサーボがカチッと動き始めました。ソフトウェアPWMに切り替えると GPIO12 = output になって、震えながら往復します。
curl.exe -s -X POST -H "Content-Type: application/json" -d '{"mode":"soft"}' https://5jk3hvty.ja000.wirecanal.com/api/mode{"mode": "soft", "requested": "soft", "pulse_ms": 1.0, "switches": 2, "since": 1788364534.6957178, "last_error": "", "pin": "12: op -- -- | lo // GPIO12 = output", "board": {"hostname": "pizero", "arch": "armv6l", "model": "Raspberry Pi Zero W Rev 1.1", "cpu_temp_c": 40.1, "uptime_s": 9470}, "server_uptime_s": 744}最後に止めます。
curl.exe -s -X POST -H "Content-Type: application/json" -d '{"mode":"off"}' https://5jk3hvty.ja000.wirecanal.com/api/mode上の書き方はPowerShell 7のものです。Windows PowerShell 5.1では、JSONの中の二重引用符が curl.exe に渡る前に外されてしまうので、-d '{\"mode\":\"hard\"}' のようにバックスラッシュでエスケープしてください。5.1でそのまま渡すと、APIから {"error": "mode must be one of off/soft/hard"} が返ってくるのを実測しました(7では同じ書き方で通ります)。
{"mode": "off", "requested": "off", "pulse_ms": 2.0, "switches": 3, "since": 1788364558.2063227, "last_error": "", "pin": "12: ip -- | lo // GPIO12 = input", "board": {"hostname": "pizero", "arch": "armv6l", "model": "Raspberry Pi Zero W Rev 1.1", "cpu_temp_c": 39.0, "uptime_s": 9494}, "server_uptime_s": 768}curlで叩けるということは、スクリプトからでもcronからでもCIからでも叩けるということです。第2回でMCPサーバーにするのも、この延長線上です。
Zero W側のAgentのログには、外からのアクセスがそのまま記録されていました。
wirecanal: access: 2026-09-02 21:23:43 xxx.xxx.xxx.xxx GET / → 200
wirecanal: access: 2026-09-02 21:23:43 xxx.xxx.xxx.xxx GET /api/status → 200
wirecanal: access: 2026-09-02 21:23:44 xxx.xxx.xxx.xxx POST /api/mode → 200ブラウザで公開URLを開けば、さきほどの画面がそのまま出ます。スマホの画面幅でもボタンが指で押せる大きさになるように作ってあるので、iPhoneから「Software PWM」と「Hardware PWM」を押し比べると、震えの差が指先で分かります。


再起動しても、公開URLまで自動で復帰した
APIサーバーとAgentの両方をsystemdで常駐させたので、再起動すれば何もしなくても戻るはずです。sudo systemctl reboot を実行した瞬間から時間を測りました。
| 検収項目 | 実測結果 |
|---|---|
| Agentの再接続 | 再起動から83秒で「届けます」の行が出て接続 |
| SSHの復帰 | 再起動から約110秒。servo-api と wirecanal の両サービスが active |
| 公開URL | HTTP 200(応答0.11秒) |
| GPIO12 | 再起動直後は pinctrl get 12 が PWM0(config.txtのオーバーレイは再起動後も有効で、APIサーバーは停止モードではピンに触らない)。一度ソフトウェアPWMを使うと、lgpioが解放したあとは停止モードでも input になる(本文のJSONがそれ) |
手を触れずに公開URLまで戻りました。電源を抜いて入れ直す場合の時間は今回は測っていませんが、同じsystemdの設定で自動起動する構成なので、目安として電源を入れてから2分ほど待ってからURLを開いてください。
今回の実験の諸元をまとめる
| 項目 | 実測値 |
|---|---|
| OSイメージ | 圧縮524MB、展開2,552MB(32-bit Lite・trixie) |
| SDカード書き込み | 書き込み107秒+検証67秒=184秒 |
| 電源ONからSSHログインまで | 約5分(自動再起動込み) |
| Agentのバイナリ | 9.8MB・静的リンク・armv6lを自動判定 |
| Agent起動から開通まで | 2秒 |
| ブラウザからの応答時間 | 80〜120ms(状態取得API) |
| モード切替の反映 | 約1秒 |
| 再起動から公開復帰まで | 83秒でAgent接続、約110秒で全サービス稼働 |
| メモリ・温度(アイドル時) | 426MB中116MB使用、CPU 37〜40度 |
ハマりどころと回避策
| 症状 | 原因 | 回避策 |
|---|---|---|
| Imagerが4秒で何もせず終了し、エラーも出ない | 書き込み先が \.\PhysicalDrive1 とバックスラッシュ1本欠け。拒否メッセージは標準エラー出力にしか出ない | \\.\PhysicalDriveN と書く。ログに startWrite の行がなければ引数の段階で落ちている |
| 書き込み後にWindowsがbootfsを認識しない | パーティション情報のキャッシュが古い | 管理者の diskpart で rescan、またはSDカードを抜き差しする |
| pizero.local が引けない | Zero WがLANに出てくるまで5分近くかかる | 待つ。arp -a でRaspberry PiのMACを探すと早い(今回の個体では b8-27-eb で始まっていた) |
| サーボが小刻みに震える | ソフトウェアPWMのタイミングの揺れ(今回のZero Wで目視) | dtoverlay=pwm,pin=12,func=4 でハードウェアPWMに切り替える |
| それでも震える | 電源不足や電圧降下の可能性。Zero Wの5Vピンから給電していると、サーボが動くたびに電圧が落ちることがある | サーボ用に別の5V電源を用意し、GNDだけZero Wと共通にする(今回の配線がこの形) |
pkill -f でsshが黙って切れる | 自分のシェルのコマンド列にもパターンが含まれ、自分を殺す | 完全一致の pkill -xf "python3 sweep2.py" にする |
| ソフトからハードに戻すとサーボが動かない | lgpioがピンを出力・入力に変えたまま | pinctrl set 12 a0 でPWM0に戻してからsysfsを有効にする |
| APIの最初の切替が古いモードで返る | gpiozeroの初回importがZero Wでは数秒かかる | 起動時にimportを済ませておく |
2017年のボードでも、外から届くサーボになった
Raspberry Pi Zero Wは、シングルコアでメモリ512MB、Wi-Fiは2.4GHzだけのボードです。それでも、SDカードの書き込みからインターネット越しにサーボが動くまでに必要だったのは、この記事に書いた手順だけでした。ルーターやファイアウォールでインターネット向けの受信ポートは最後まで開けていません(APIサーバーが待ち受けるのはZero W上の 127.0.0.1:18080 だけです)し、WireCanalは無料プランのままです。
Raspberry Pi 5のときと違ったのは、OSを32-bit版にすることと、初回起動を5分待つことと、サーボの震えをハードウェアPWMで止めることでした。逆に言うと、Agentのやること自体は同じで、install.sh がarmv6lを判定してバイナリを置き、wirecanal.jsonを隣に置いて起動する。それだけです。
今回はサーボ1つでしたが、GPIOなどから制御できる機器にも、この構成をそのまま応用できます。
第2回では、角度を指定できるAPIを足してcurlで好きな角度に動かし、最後にこのZero WをMCPサーバーにしてChatGPTから直接動かすところまでやります!
それでは、また次回お会いしましょう!
出典・参考
- Raspberry Pi Imager(公式ダウンロードページ)
- Raspberry Pi OS イメージ一覧(公式ダウンロードサイト。Lite 32-bit版もここ)
- config.txt のリファレンス(Raspberry Pi 公式ドキュメント)
- デバイスツリーオーバーレイの一覧 README(raspberrypi/firmware・GitHub。pwm オーバーレイの pin と func の組み合わせ、音声との併用の注意もここ)
- gpiozero の Servo クラス(公式ドキュメント)
- サンプルコード一式(GitHub: qualiteg/wirecanal-iot-demo-raspberry-pi-zero-w-servo・記事執筆時点のコミット)
- WireCanal Linuxセットアップガイド(Agentのインストールとsystemd常駐の手順)
- WireCanal IoTデバイス対応ページ(対応チップ+Linuxの組み合わせと、対応チップを搭載したデバイスの例)