AtomS3R-AI Chatbotを使ってStack-Wan-Chanを作るためのレシピ🐕
概要
Stack-Chan Minimalベースの4足歩行ロボを作ります。
備考
- 文章は人間が一生懸命タイピングしてます。
- コードについて
- ベースとなる会話部分はA-Uta氏が書いてます
- その他差分は(使用量の多い順に)ChatGPT 5.5 xhigh(Codex), Composer 2.5(Cursor), Claude Sonnet 4.6 high(Cursor), ごくごく一部 Gemini Flash 3.5 Medium(Antigravity IDE)が書いています。
- 本体のモデリングは人間が一生懸命Fusion360を使って作りました
完成図
スタック(ワン)チャン pic.twitter.com/H4nySAvAdO
— るち (@Ruchi12377) May 25, 2026
スタック(ワン)チャン歩いた#RuchiDev #ツクリカタログ pic.twitter.com/tM0ZAjdU2n
— るち (@Ruchi12377) May 29, 2026
必要な部品 (購入するもの)
| 品名 | 数量 | リンク |
|---|---|---|
| ATOMS3R AI Chatbotキット | 1 | Switch Science |
| PCA9685 16ch PWMサーボドライバ | 1 | Switch Science |
| マイクロサーボ9g SG90 | 8 | 秋月電子 |
| GROVE互換ケーブル | 1 | Switch Science |
| ピンソケット 1×6 | 1 | Switch Science |
| 電池ボックス | 1 | 秋月電子 |
| GROVE 4ピンコネクタ | 1 | Switch Science |
| M2×5mm ねじ | 4 | 秋月電子 |
| 配線ケーブル (赤・黒・その他2色 AWG24〜26程度) | 適量 | — |
プリントする部品
以下のリポジトリにSTL形式のデータがあります。(ファイル名の末尾にx1と書いてあるものは1つ、x2と書いてあるものは2つ必要です。) github.com
まとめると以下です。
| STL | 印刷数 |
|---|---|
hardware/cases/Body_x1.stl |
1 |
hardware/cases/Leg_L_x2.stl |
2 |
hardware/cases/Leg_R_x2.stl |
2 |
hardware/cases/Foot_L_x2.stl |
2 |
hardware/cases/Foot_R_x2.stl |
2 |
PLAのフィラメントをBambu Lab A1で印刷しました。 以下のPLAを使っています。アフィリエイトリンクではないです。
- Amazon.co.jp: ELEGOO マットPLAフィラメント ブラック 1kg 1.75mm径 3Dプリンターフィラメント 高精度 1kgスプール(2.2ポンド) ほとんどの3Dプリンターに対応 : 産業・研究開発用品
- OVERTURE PLAフィラメント ホワイト 1.75mm 1kgスプール
組み立ての前にはんだづけ
GROVE 4ピンコネクタとGROVEケーブルを用意して、コネクタにケーブルを繋げます。どこにはんだづけすればいいのわかりやすくなります。
次に、15cmの適当な長さに切った赤黒緑青の銅線を用意します。 黒赤はそのままはんだ付けすればいいのですが、黄色は緑の銅線、白は青の銅線をはんだづけしてください。
(銅線の両端にあらかじめはんだ(予備はんだ)をつけておくとはんだづけしやすくなります。)

ピンソケット 1×6を用意して同様にはんだ付けしていきます。これもPCA9685にあらかじめつけておくとわかりやすいです。 画像の様に、上から黒、空き、緑、青、赤、赤の順にはんだづけしてください。本来外部電源(今回は電池)を使う場合、マイコンの電源をVCCのみに接続するのですが、動作が安定しなかったのでV+もはんだ付けしたら動作が安定する様になりました。理由がわからないので、詳しい人いたらコメントで教えてください。
はんだ付けはこれだけです。

組み立て
AtomS3Rの組み立て
まずAtomS3R Chatbotキットを取り出してください。
(Switch Scienceのサイトから引用)
4ピンと5ピンのところ同士をくっつけます。向きを間違えずに済むフェイルセーフで良いデザイン!

OK!

ボディの組み立て
サーボ x8と取り付けネジx16を取り出してください。
Footの凸とLegの穴にはめてください。きついと思いますが、手で何回かFootをぐるぐるさせるとスムーズになります。

その後に、切り欠きに合わせてケーブルをはめて、固定用のネジを絞めます。サーボの回転する部分がはめるときに干渉しますが、気合いではめます。また、一応モデルにφ1mmの穴が空いてるんですけど、ネジが上手く締まっていかない場合はリューターとかで削ると良いです。僕はドライバーでねじ込みました。(この時点でまだサーボホーンは付けないです)

同様にLegの凸をBodyの穴にはめてください。サーボをはめて固定するまでは前述の手順と全部同じです。サーボの回転する部分をはめる時こっちはかなり硬くてはめづらいので頑張ってください

以下の画像のようにジョイントの部分が外側、ホーンをつけるべき位置が内側になるように取り付けることに注意してください。

これを4回繰り返したら足の取り付けは完成です。まだホーンを付けてないので足がぶらぶらしてると思いますが一旦これでOKです。
PCA9685の取り付け
M2のネジx4とPCA9685が必要です。
ピンヘッダが付いてない方がAtomS3Rと同じになるような向きで配置します。

インサートナットを使うほどでもないですし、ナットを埋め込むのも小さくてサポート取るので精一杯になると思ったので、ネジを熱でねじ込みます。300°Cくらいのはんだごてで以下の画像の様に押し込みます。臭いので、換気をしてください。

このとき一緒に6ピンのケーブルも繋いでおいてください。 サーボのコネクタも繋ぎましょう。ピンソケットがある方からAtomS3Rがある方に向かってCH0~CH15と割り当てられています。(全部で16個のサーボ、またはPWMでLEDなどを制御できます)。
以下の様に配線するとリポジトリのサンプルがそのまま動かせて便利だと思います。
| CH | サーボ |
|---|---|
| CH0 | 右前 Foot |
| CH1 | 右前 Leg |
| CH2 | 右後 Foot |
| CH3 | 右後 Leg |
| CH4 | 左前 Foot |
| CH5 | 左前 Leg |
| CH6 | 左後 Foot |
| CH7 | 左後 Leg |
最後に電池ボックスをBodyの裏に両面テープで固定します。電線を引っ張ってきてPCA9685の外部電源用のターミナルに繋ぎます。向かって右が+(赤)で、向かって左が-(黒)です。

ファームウェアの書き込み
local_config.example.hをlocal_config.hにリネームしてからSSIDとPWを書き込んでください。
USBでAtomS3Rを繋いで以下のコマンドで書き込めます。ポート番号は適宜修正してください。
pio run -d /Users/ruchi/Documents/GitHub/StackChan_Minimal/walk_only_sample -e m5stack-atoms3 -t upload --upload-port /dev/cu.usbmodem*
足の固定(組み立てラスト!)
ファームウェアを書き込んだAtomS3RにUSBを繋いで、電池ボックスをOnにするとサーボが90°に固定されます。このタイミングでサーボホーンを全て取り付けます。ホーンの固定用ネジを閉める時にサーボに負荷がかかりすぎない様に注意してください。
ホーンを全てつけ終わったらいよいよAtomS3Rを正面の穴にはめて完成です!!!!!!!やったー!
歩かせる
AtomS3RにUSBを繋いでWiFi接続が完了すると数秒間ローカルのIPが表示されます。これを控えといてください。
次に、リポジトリ内の/walk_only_sample/robot_viewer.htmlを開き左上にさっき控えたIPアドレスを入れて接続ボタンを押すと接続されます。
下の方のタイムラインっぽいUIのループボタンを押すと歩き出します!!かわい〜!

↓歩いてる様子↓
スタック(ワン)チャン歩いた#RuchiDev #ツクリカタログ pic.twitter.com/tM0ZAjdU2n
— るち (@Ruchi12377) May 29, 2026
ちなみに接続している時にモーションの再生・ループ再生をするとサイト内のロボも一緒に動きます。 逆に、モーションの再生・ループ再生をしてない時にはサイト内のハンドルでロボットの動きを操作できます。 操作に癖がありますがモーションを作ったりアレンジしたりもできます。
最後に
TwitterのTLで流行っていたスタックチャンをみて「面白そうだけど2万円もするし、すぐ売り切れちゃうな〜」と思って調べていた時に見つけたのがA-Uta氏のStackChan_Minimalでした。このリポジトリがあったからこそAtomS3R AI Chatbotにファームウェアを書き込んでTTSなどの環境を整えるだけですぐに動くものが作れたと思います。この場を借りてA-Uta氏に感謝申し上げます。
※ 現在はまだ会話と移動が別々の実装となっているのですが中間テストが終わり次第(6/4〜)実装したバージョンに差し替えます。
参考
Cloudflare WorkerとPinoを組み合わせた時に発生するエラーを修正する
備考
- 文章は人間が一生懸命タイピングしてます。
- コードは一部Claude Sonnet 4.5が書いています
経緯
Cloudflare Wroker × Pino (× Next.JS)を使っていると毎回500エラーが発生してしまい、デプロイすると正しくページが表示されなくなる問題が発生しました。
Cloudflare上でObservabilityからログをみるとCloudflare Worker "Failed to publish diagnostics channel message" Errorと表示されていました。
結論としては、PinoなどでDiagnostic Channelsの処理に不整合が発生してるためで、console.logで出力する際にJSON.stringify()を使ってあげれば解決しました。
解決策
import type { LogFn } from "pino"; import pino from "pino"; const isDevelopment = process.env.NODE_ENV !== "production"; const pinoConfig = { level: isDevelopment ? "debug" : "info", browser: { asObject: false, write: (o: object) => { console.log(JSON.stringify(o)); }, }, hooks: { logMethod( this: unknown, args: Parameters<LogFn>, _method: LogFn, level: number, ) { const consoleFn = level >= 50 ? console.error : level >= 40 ? console.warn : level >= 20 ? console.log : console.debug; consoleFn(...args); }, }, }; export const logger = pino({ ...pinoConfig, });
短いですが、以上です。
参考
PlaywrightでTurnstileの出現を待機する
備考
- 文章は人間が一生懸命タイピングしてます。
経緯
PlaywightでTurnstileを使った認証ページをテストする時に、Turnstileのiframe取得がうまくいかないことがありました。結果として、適当に数秒待つというコードを使ってテストの実行時間が無駄に伸びている時がありました。そこで、TurnstileがSuccesになるまで待機するというコードを書いたので共有します。
コード
/** * Cloudflare Turnstileのiframeが出現するまで待機する関数 */ async function waitForTurnstileFrame( page: Page, timeout = 20000, ): Promise<Frame> { console.log("Waiting for Turnstile iframe to appear (polling)..."); const startTime = Date.now(); while (Date.now() - startTime < timeout) { const frame = page.frames().find((f) => f.url().includes("challenges.cloudflare.com")); if (frame) { console.log("Found Turnstile frame via polling."); return frame; } await page.waitForTimeout(500); // 500ms待機 } throw new Error(`Turnstile frame not found within ${timeout}ms`); } /** * Turnstileのトークンが生成され、inputに格納されるまで待機して値を返す関数 */ async function waitForTurnstileToken( page: Page, timeout = 30000, ): Promise<string> { console.log("Waiting for Turnstile token to be generated..."); const tokenInput = page.locator('input[id^="cf-chl-widget-"][id$="_response"]'); // 要素がDOMに出現するまで待機 await tokenInput.waitFor({ state: "attached", timeout }); // 値が空でなくなるまで待機 await page.waitForFunction( (el) => (el as HTMLInputElement).value !== "", await tokenInput.elementHandle(), { timeout }, ); const token = await tokenInput.inputValue(); return token; } /** * Turnstileが出てきてからトークン取得までをまとめて行う関数 */ export async function getTurnstileToken(page: Page, timeout = 30000): Promise<string> { // 1. iframeが出現するまで待機 await waitForTurnstileFrame(page, timeout); // 2. トークンが生成されるまで待機して取得 return await waitForTurnstileToken(page, timeout); }
短いですが以上です。
Macのおすすめ設定やアプリ
備考
- 文章は人間が一生懸命タイピングまたはWillowを使った音声入力(後述)をして友人に送ったDiscordの会話文を一番ChatGPTに修正させた表現を含みます。
- 一部リンクにリファラルリンクを含みます。
あいさつ
あけましておめでとうございました。 さて、皆さんはAppleの初売りでMacを買い、3万円分のギフトカードをゲットしました。
はじめに
私は新しくパソコンを買った時に絶対に移行をしません。理由は単純で、整理しきれてない古いフォルダ、ファイルを引き引き継がないで心機一転†俺の考えた最強のフォルダ構成†を作り直す方が手っ取り早いからです。
アプリ編
Raycast
Raycastは便利なアプリランチャーをはじめとした拡張機能がモリモリの多機能ツールです。 ⌘ Spaceに割り当てると便利なのですが、Spotlightと競合してるのでそこから直していきます。
システム設定 → キーボード → キーボードショートカット → Spotlightにて「Spotlight検索を表示」「Finder検索ウインドウを表示」のチェックを外します。
次に、Raycastの起動するためのホットキーを⌘ Spaceに設定します。
RaycastのSettings→Extensions→Window Managementにて、一旦全てのチェックボックスを外します。以下の機能だけで十分です。Windowsライクのウィンドウ操作ができて便利です。
| 機能 | ショートカット |
| --------------- | -------- |
| Left Half | ⌘ ← |
| Right Half | ⌘ → |
| Maximize | ⌘ ↑ |
| Reasonable Size | ⌘ ↓ |
Lunar
Lunarはディスプレイの明るさを変更するツールです。何が便利かというと、Mac本体から外部ディスプレイの明るさも変更することができます。さらに、Macの内蔵ディスプレイでXDRを有効にすると、デフォルトの一番暗い状態からもう一段階暗くすることができます。暗い部屋でYouTube見る時にディスプレイが明るすぎるんだよなぁという時にすごく便利です。

Willow Voice
Willow Voice は音声入力のアプリです。右シフトキーに割り当てると、バイブコーディングの時などに簡単に2ヶ国語以上で音声入力ができるようになります。エディタ内蔵の音声入力では英語音声しか入力できないことが多々あります。また、一つの言語しか認識できないため英語で音声入力をしたい時に一旦設定言語を変えないといけないなどの手間があります。しかしWillowを使うとこの問題を簡単に解決できます。
- Mac 起動音をオフ
- 日本語入力のライブ変換をオフ
- ディスプレイ設定を More Space に変更
- ホットコーナー左上を Mission Control に設定
- バッテリー表示を パーセンテージ表示 に変更
- 時計表示を 秒まで表示
- トラックパッドの押し込み強さを Light に変更
右ファンクションキーに 絵文字ピッカー を割り当て(Tahoeだとデフォルトの挙動かも)
ブラウザ拡張 G App Launcher を入れる
好み
Supabaseで制限付き公開リンクを実装する方法
備考
- 文章は人間が一生懸命タイピングしてます。
経緯
同じグループ内ではお互いのファイルが観れるけど、他のグループからはファイルが見れないということを実装しようとした時にpublicUrlを使うと実装が難しそうでした。 どうにか実装できないかな、というのがこの記事です。
やりたいこと
- 条件付きのユーザーのみ(Policyにて設定)がファイルを閲覧できる
- URLは固定する
方針
一旦APIを噛ませて、そいつに期限付きのsignedUrlを返させることで固定URLかつ、Policyによるアクセス制限を実装します。
APIのコード
import { NextResponse } from "next/server"; import { logger } from "@/libs/logger"; import { supabaseServerClient } from "@/supabase/supabaseServer"; export async function GET( _request: Request, { params }: { params: Promise<{ userId: string; filePath: string }> }, ) { try { // Supabaseクライアントを初期化し、認証を確認 const supabase = await supabaseServerClient(); const { data: user, error: authError } = await supabase.auth.getUser(); // 認証されていない場合はエラーを返す if (authError || !user) { logger.error( authError || new Error("Authentication failed"), "認証エラー", ); return new Response(null, { status: 401 }); } const { userId, filePath } = await params; // signed URLを取得(バケット名はプロジェクトに合わせて調整してください) const { data, error } = await supabase.storage .from("user_icons") // バケット名を適切に設定 .createSignedUrl(`${userId}/${filePath}`, 3600); // 1時間有効 if (error) { logger.error(error, "signed URLの取得中にエラーが発生しました"); return new Response(null, { status: 500 }); } // 成功レスポンスを返す return NextResponse.redirect(data.signedUrl); } catch (error) { logger.error(error, "signed URL取得中にエラーが発生しました"); return new Response(null, { status: 500 }); } }
ポイントはNextResponse.redirectでリダイレクトさせることです。
多分、ブラウザ側のキャッシュが切れるたびに毎回1時間の期限付きURLを生成したり、ブラウザのキャッシュが残ってる状態でURLの方の期限が切れてしまったら表示されないなどの問題があると思いますがとりあえず動いたのでよしとします。
SupabaseのメールをReact風に書く方法
備考
- 文章は人間が一生懸命タイピングしてます。
- この手法は既存のプロジェクトに途中から導入できます。
経緯
Supabaseのパスワードを変更した時などに使うメールを作成するときに素のHTMLやCSSを頑張って書く必要があります。 しかし、メインのページはNextJSだとかVueだとかモダンでイケてるフレームワークでゴリゴリ書いているのにメールのテンプレートは素のHTMLを書けなんて無理があります。 特にMUIなどのUIライブラリを使ってる場合CSSを今更書けなんてかったるいです。
便利ツールの紹介
他の素のHTMLを書きたくない有志がメールビルダーサイトを作成してくれています。ただしいちいち完成したHTMLファイルをダウンロードする必要があったり、メールテンプレートの変更をgitでトラックできないなどの問題もあります。しかしこれで十分という場合もあると思います。
- Redditのスレッド
https://www.reddit.com/r/Supabase/comments/1o10a7a/supabase_emails_are_ugly_so_heres_an_open_source/
- メールビルダーのURL www.supa-tools.com
React Email
今回使用するのはReact Emailというライブラリです。ReactとTypeScriptでコンポーネントを作成しHTMLで書き出せます。本来はメール配信ライブラリと組み合わせて使いますが、今回はReactで書いたものをHTMLに変換する機能を使ってSupabaseのメールを書き出せるようにしていきます。 react.email
やってみる
まずは必要なコンポーネントをインストールします。
npm install @react-email/components @react-email/render @react-email/tailwind
これで準備はOKなので実際にコードを書いて行きましょう。 今回は例としてconfirmation.htmlを作っていきます。
プロジェクトのルートにemails/confirmation.tsxを作成してください。
次に以下のコードをconfirmation.tsxにコピペしてください。
import { Body, Button, Container, Font, Head, Heading, Hr, Html, Img, pixelBasedPreset, Section, Tailwind, Text, } from "@react-email/components"; const palette = { primary: { main: "#2563eb", // 青 light: "#60a5fa", dark: "#1e40af", contrastText: "#FFFFFF", }, secondary: { main: "#facc15", // 黄色 light: "#fde047", dark: "#ca8a04", contrastText: "#1f2937", }, error: { main: "#BA1A1A", light: "#C74747", dark: "#821212", }, warning: { main: "#FF9800", }, }; export default function Page() { return ( <Html> <Head> <Font fontFamily="Roboto" fallbackFontFamily="Verdana" webFont={{ url: "https://fonts.gstatic.com/s/roboto/v27/KFOmCnqEu92Fr1Mu4mxKKTU1Kg.woff2", format: "woff2", }} fontWeight={400} fontStyle="normal" /> </Head> <Body className="bg-[#f4f5fb] font-sans text-[#1f2937]"> <Tailwind config={{ presets: [pixelBasedPreset], theme: { extend: { colors: { accent: palette.primary.main, accentContrast: palette.primary.contrastText, background: "#f4f5fb", body: "#0f172a", muted: "#6b7280", }, }, }, }} > <Section className="px-4 py-12"> <div className="mx-auto w-full max-w-[600px] text-center"> <Img src={"https://example.com/logo"} alt="App Name" className="mx-auto inline-block h-auto w-[132px]" style={{ display: "block", margin: "0 auto", maxWidth: "132px", }} /> </div> <Container className="mx-auto w-full max-w-[600px] rounded-2xl bg-white px-12 py-5 text-left shadow-[0_16px_40px_rgba(15,23,42,0.06)]"> <Heading>App Nameへようこそ!</Heading> <Text className="mt-5"> 以下のボタンからアカウント登録を完了すると、App Nameの機能をすぐにご利用いただけます。 </Text> <div className="mt-8"> <Button href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&next={{ .RedirectTo }}&type=signup" className="block rounded-xl bg-accent px-6 py-4 text-center font-semibold text-accentContrast" > 登録を完了する </Button> </div> <Text className="mt-8">App Nameチーム</Text> <Hr /> <Text className="m-0 mt-6 text-xs text-muted"> © 2025 App Name. All rights reserved. </Text> </Container> </Section> </Tailwind> </Body> </Html> ); }
これが正しく表示されるか確かめるためには以下のコマンドを実行します。
npx react-email dev
そしてlocalhost:3000を開きサイドバーからconfirmation.tsxを選択するとプレビューが表示されます。

HTMLを書き出す
Reactでメールのテンプレートを作成し、プレビューの確認もできたのでHTMLを書き出し、Supabaseへアップロードしてみましょう。
HTMLを書き出すにはrender関数を使います。コマンド一発で書き出せるようにしたいので、スクリプトを書いていきましょう。
emails/cli/renderer.tsxを作成し以下の内容をコピペしてください。
import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { render } from "@react-email/render"; import { program } from "commander"; import * as React from "react"; global.React = React; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const emailsDir = path.join(__dirname, "../../emails"); // Configure command-line arguments program.option( "-o, --output <path>", "Output directory", path.join(__dirname, "../../supabase/templates"), ); program.parse(process.argv); const options = program.opts(); const outputDir = options.output; // Check if the emails directory exists if (!fs.existsSync(emailsDir)) { console.error( "エラー: 'emails' ディレクトリが存在しません。正しいパスを確認してください。", ); process.exit(1); } const files = fs.readdirSync(emailsDir).filter((f) => f.endsWith(".tsx")); async function renderEmails() { for (const file of files) { const name = path.basename(file, path.extname(file)); const modulePath = path.join(emailsDir, file); try { // Dynamically import the component const { default: Component } = await import(modulePath); // Render the component to HTML const html = await render(Component()); const outPath = path.join(outputDir, `${name}.html`); fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, html); console.log(`Rendered: ${name}.html`); } catch (error) { console.error(`Failed to render: ${name}. Error:`, error); } } } renderEmails();
そしてpackage.jsonにコマンドを追加します。
"scripts": { "build:email": "node --import tsx ./emails/cli/renderer.ts" }
ここまで出来たらターミナルでnpm run build:emailを実行するとsupabase/templatesフォルダにHTMLが書き出されているはずです。
テンプレートのアップロード
Supabaseのテンプレート設定するためにプロジェクトのページに移動し、サイドバーからAuthentication/Emailsへ移動しそれぞれのHTMLをコピペしていきます。現在テンプレートのアップデートを自動で行う機能は提供されていないため、ここだけは手動でコピペする必要があります。GitHub Actionsとかでアップロードできたら便利でいいんですけどね...。

これでおしまいです。良きSupabaseライフを!
追記
テンプレートのロゴやフッターをテンプレートコンポーネントとし、メール本文のみを各ファイルでかき分けるようにするとコード量が減って良いです。

Supabaseで特定のユーザーになりきったテストを書く
備考
文章は人間が一生懸命タイピングしてます。
経緯
SupabaseのRLSのテストを書いてた時に、特定のユーザーに成り切る必要があったのですが、ChatGPTに聞いた時に出てくるコードが正しく動かなかったので備忘録です。
ここで特定のユーザーになり切るというのはauth.uid()が特定のユーザーのuuidを返すことを指しています。
問題のあるコード
ChatGPTにどうすればいいのか聞くと以下のようなコードが返ってきます。
SELECT set_config('request.jwt.claim.sub', '<特定のユーザーのuuid>', true); SELECT set_config('request.jwt.claim.role', 'authenticated', true);
一見よさそうに見えますがこれだとなぜか動かないです。
正しく動くコード
以下のコードだと正しくauth.uid()が帰ってくるようになります
set local role authenticated; set local request.jwt.claims to '{"role":"authenticated", "sub":"<特定のユーザーのuuid>"}';
これで特定のユーザーになりきってテストを実行できます!良きSupabaseライフを!
