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で外出先からサーボを動かす。ヘッドレスセットアップからハードウェアPWM、WireCanalでの公開まで

こんにちはミシェルです!

わたしの手元に、初代の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本だけです
作業へらすため、ピンヘッダは3ピンずつ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 5Raspberry 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-Fi2.4GHz / 5GHz2.4GHzのみ。5GHz専用のSSIDでは永遠につながらない
電源USB-Cmicro USBの「PWR IN」側(基板の端)。内側のUSBポートはOTG用
初回起動約3分約5分(シングルコアで初回設定とファイルシステム拡張が遅い)
ホスト名raspberrypipizero にした。同じ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-servo

Raspberry Pi Imagerがまだ入っていなければwingetで入れます。前回のRaspberry Pi 5の記事と同じです。

winget install --id RaspberryPiFoundation.RaspberryPiImager --silent --accept-package-agreements --accept-source-agreements

OSイメージは公式のダウンロードサイトから取得します。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 0

SDカードをカードリーダーに挿し、ディスク番号を確認してから、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 1

UACのダイアログが出たら許可します。書き込みは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
Windows Terminalから ssh pi@pizero.local でログインした直後の画面
初回のSSHログイン。カーネルが armv6l であることと、初期パスワードのままだという警告が出ている

ログイン後の状態も確認しました。uname -avcgencmd measure_tempfree -mdf -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付属)を実行した結果をもとに描いたものです。

Raspberry Pi Zero W の40ピン配置図。1〜20番と21〜40番の2ブロック。各ブロックとも左列が奇数ピン、右列が偶数ピン。5V・3.3V・GND・GPIOを色分けし、ハードウェアPWMが使えるGPIO12・13・18・19に印
図1 Raspberry Pi Zero W の40ピン配置(左が1〜20番、右が21〜40番)。1番ピンはmicroSDスロット側の角

サーボ自体は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に切り替えるときに使えるピンだからです。

今回の配線図。サーボの信号を物理ピン32(GPIO12)に、GNDは物理ピン30で外部5V電源と共通にし、サーボの電源は外部電源から取る。外部電源の+5VとGNDの間に100µF電解(+を+5V側)と0.1µFセラミックを並列。はんだ付けした3ピンヘッダ2か所を濃く表示
図2 今回の配線。信号は32番、GNDは30番で外部電源と共通、サーボの電源は外部5V。ピンヘッダは3ピンのものを2か所だけはんだ付けした
実際の配線

今回使った2026-06-18版のRaspberry Pi OS Lite 32-bitには、Pythonの gpiozerolgpio が最初から入っています。追加インストールなしで、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_A

Windowsのリポジトリのフォルダから転送して、バックグラウンドで起動します。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のリポジトリのフォルダから、appsystemd を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   # ok

Zero 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に切り替えると、応答の pinGPIO12 = 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回で角度を指定できるようにしたときも、この画面側に触る必要はありません。

ブラウザで開いたサーボデモの画面。モード、GPIO12の状態、応答時間と、Software PWM / Hardware PWM / Off の3つのボタン
ブラウザで開いたところ。上のカードにモードとGPIO12の状態(pinctrlの生の出力)と応答時間、下に3つのボタン。

手順7: WireCanalのcanalを作って、Zero Wに公開URLをつける

ここからインターネット側に出します。使うのは当社のWireCanalです。Zero Wから外向きの接続を張るだけでHTTPSの公開URLがつく仕組みで、ルーターのポート開放も固定IPも要りません。無料プランで進めます。

app.wirecanal.com にログインして「新しいcanalを作成」を押すと、4ステップのウィザードが始まります。

WireCanalのダッシュボード。無料プランでcanalが0本の状態
作成前のダッシュボード。無料プランは1本まで作れる

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

canal作成ウィザードのステップ1。種類でHTTPを選ぶ
ステップ1。種類はHTTP(Webサーバーを HTTPS で公開)
canal作成ウィザードのステップ2。公開アドレスはおまかせサブドメイン
ステップ2。公開アドレスは「おまかせサブドメイン」
canal作成ウィザードのステップ3。転送先に localhost:18080 とメモを入力
ステップ3。転送先は localhost:18080。メモは自由記入
canal作成ウィザードのステップ4。内容を確認して作成する
ステップ4。公開URLがこの時点で決まる

作成すると、canal詳細に公開URLと接続ファイル wirecanal.json が表示されます。このファイルにはそのcanal用の接続キーが入っているので、「wirecanal.json をダウンロード」で手元に落とし、ダウンロードしたフォルダからZero Wのホームへ運びます。

scp .\wirecanal.json pi@pizero.local:~/

同じ画面の「Linux」タブに、次の手順で使うコマンドがそのまま出ています。

canal詳細画面。公開URL、wirecanal.json、Linux向けのセットアップ手順
作成直後のcanal詳細。接続キーは伏せてある。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 sh
WireCanal 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.target
sudo 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

ダッシュボード側も「接続中」に変わります。

canal詳細画面。ステータスが接続中になり、公開ホスト名と有効期限が表示されている
Agentが接続した後のcanal詳細。無料プランのcanalはAgentの接続がある間は自動延長される

インターネットの向こう側からcurlで叩く

はい!ここが今回のゴールです。

Zero Wの上で叩いたのと同じAPIを、手元のWindows PCから公開URLに向けて叩きます。PCはZero Wと同じ家のLANにいますが、公開URLはWireCanalのエッジ(東京)を経由するので、経路はインターネット越しです。URLが変わるだけで、コマンドはほぼそのままです。違いは、Windowsでは curl.exe と拡張子まで書くことです。Windows PowerShell 5.1では curlInvoke-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}

応答の pinGPIO12 = 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で開いた画面。Hardware PWM モードで GPIO12 = PWM0 と表示されている
PCのブラウザで公開URLを開き、Hardware PWMに切り替えたところ。GPIO12がPWM0になっている
スマホの画面幅で表示した画面。Software PWM モードで GPIO12 = output と表示されている
スマホの画面幅(390×844)でSoftware PWMに切り替えたところ。GPIO12はlgpioが普通の出力ピンとして使っている

再起動しても、公開URLまで自動で復帰した

APIサーバーとAgentの両方をsystemdで常駐させたので、再起動すれば何もしなくても戻るはずです。sudo systemctl reboot を実行した瞬間から時間を測りました。

検収項目実測結果
Agentの再接続再起動から83秒で「届けます」の行が出て接続
SSHの復帰再起動から約110秒。servo-api と wirecanal の両サービスが active
公開URLHTTP 200(応答0.11秒)
GPIO12再起動直後は pinctrl get 12PWM0(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を認識しないパーティション情報のキャッシュが古い管理者の diskpartrescan、または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から直接動かすところまでやります!

それでは、また次回お会いしましょう!

出典・参考

関連記事

Read more

【AI×CAD 第3回】「この形、抜けません」を設計中に知る。金型DFM(抜き勾配・アンダーカット・肉厚)をブラウザで自動チェック

【AI×CAD 第3回】「この形、抜けません」を設計中に知る。金型DFM(抜き勾配・アンダーカット・肉厚)をブラウザで自動チェック

抜き勾配・アンダーカット・局所的な肉厚の要注意箇所は、型開き方向を決めれば形状から機械的に一次チェックできます。わざと不良を仕込んだ樹脂ケースでCADASの金型DFMを実走し、要勾配11.8%・アンダーカット0.2%(横穴のみ)・肉厚中央値2.00mmが出るまでと、その判定の仕組みを解説します。

By Qualiteg コンサルティング
Claude Codeで急にgrepが承認待ちになる v2.1.259でRead denyルールがBashのgrepにも効くようになった

Claude Codeで急にgrepが承認待ちになる v2.1.259でRead denyルールがBashのgrepにも効くようになった

Claude Code を bypassPermissions で走らせているのに、今日から grep -r が承認待ちで止まるようになった。原因は 2026年9月2日リリースの v2.1.259 で、settings.json の Read deny ルールが Bash の grep にも当たるようになったこと。前日の v2.1.258 と並べて実測し、通る書き方と通らない書き方を表にまとめました。

By Qualiteg プロダクト開発部
日本語対応 LLMランキング2026 ~ベンチマーク分析レポート~(9月1日版)

日本語対応 LLMランキング2026 ~ベンチマーク分析レポート~(9月1日版)

はじめに 本レポートは、Nejumi Leaderboard 4のベンチマークデータ(2026/9/1版)に基づいて、日本語対応LLMの性能を総合的に分析したものです。 前回は 2026/7/10 版の分析レポート を公開しましたが、わずか2か月弱で首位が入れ替わり、 オープンモデルが総合3位に食い込む激アツの回となりました! (定期的に最新LLMランキングを更新してまいります。当社のX(旧Twitter)をフォローいただくことで更新情報を受け取り可能です) Nejumi Leaderboard 4は、日本語タスクにおけるLLMの性能を多角的に評価する信頼性の高いベンチマークとして知られています。汎用的言語性能(GLP)とアラインメント(ALT)の2軸で構成され、翻訳・要約・推論・コーディングから毒性・バイアス・真実性まで、幅広い観点をカバーしているのが特徴です。 本分析では、商用APIモデルとオープンモデルの両方を対象に、それぞれの特徴や傾向を詳しく見ていきます。 まず、今回の3大トピックを先にご紹介します。 * Claude Opus 5が総合スコア0.8720で

By Qualiteg プロダクト開発部, Qualiteg コンサルティング