るちtechブログ

とあるサイトを作っています

AtomS3R-AI Chatbotを使ってStack-Wan-Chanを作るためのレシピ🐕

概要

Stack-Chan Minimalベースの4足歩行ロボを作ります。

github.com

備考

  • 文章は人間が一生懸命タイピングしてます。
  • コードについて
    • ベースとなる会話部分は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を使って作りました

完成図

必要な部品 (購入するもの)

品名 数量 リンク
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を使っています。アフィリエイトリンクではないです。

組み立ての前にはんだづけ

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.hlocal_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のループボタンを押すと歩き出します!!かわい〜!

↓歩いてる様子↓

ちなみに接続している時にモーションの再生・ループ再生をするとサイト内のロボも一緒に動きます。 逆に、モーションの再生・ループ再生をしてない時にはサイト内のハンドルでロボットの動きを操作できます。 操作に癖がありますがモーションを作ったりアレンジしたりもできます。

最後に

TwitterのTLで流行っていたスタックチャンをみて「面白そうだけど2万円もするし、すぐ売り切れちゃうな〜」と思って調べていた時に見つけたのがA-Uta氏のStackChan_Minimalでした。このリポジトリがあったからこそAtomS3R AI Chatbotにファームウェアを書き込んでTTSなどの環境を整えるだけですぐに動くものが作れたと思います。この場を借りてA-Uta氏に感謝申し上げます。

※ 現在はまだ会話と移動が別々の実装となっているのですが中間テストが終わり次第(6/4〜)実装したバージョンに差し替えます。

参考

github.com github.com spotmicroai.readthedocs.io

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,
});

短いですが、以上です。

参考

github.com

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を使うと実装が難しそうでした。 どうにか実装できないかな、というのがこの記事です。

やりたいこと

  1. 条件付きのユーザーのみ(Policyにて設定)がファイルを閲覧できる
  2. 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でトラックできないなどの問題もあります。しかしこれで十分という場合もあると思います。

https://www.reddit.com/r/Supabase/comments/1o10a7a/supabase_emails_are_ugly_so_heres_an_open_source/

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ライフを!

参考

supabase.com