LLMを使ってLED Matrixを制御する!
はじめに
昨今、Physical AIやEmbodied AIという言葉を毎日のように耳にします。
ソフトウェアで閉じた世界から遂に物理世界に染み出してくるAI、ワクワクしてきますね〜!
自分もなにか取り組んでみたいな〜と思うところですが、高価なロボットは流石に買えず、、、
ということで今回は安価で身近なハードウェア、LED MatrixをLLMから制御してみます!
システム構成
今回のシステムはこちらです。
システム構成図
LLM Chatソフトウェアで使ったモデルは現時点で無料の gemini-2.0-flash-exp
です。
LED Matrixはこちら、LED制御用マイコンはM5Stack Atom Liteを使いました。
実装
LED Matrixの外装作成と配線
LED Matrixは眩しいですw
光を抑えるため、まずは3Dプリンタで外装を作るところからやってみました。設計ソフトはFusionです。こんな感じで印刷して
印刷した各パーツ
完成!!
外装表
外装裏
配線に関しては ws2812
などでググるとたくさんでてくるので割愛しますが、僕はAtom Liteのgroveコネクタから出てる5V/GND/26番ピンをLED Matrix基板にはんだ付けしました。
M5Stack Atom Liteのファームウェア
Atom LiteはPCからシリアル通信で送られてくるLED制御コマンドを受信して実際にLED Matrixを点灯させる役割です。今回のLED Matrixは8x8、RGB各1Byteですので、コマンド仕様を以下のように定めました。
RGBRGBRGB...RGB
3 * 8 * 8 = 192Byte
これを踏まえ以下のように実装しました。
M5Stack Atom Liteのファームウェアコード
#include <M5Atom.h>
#include <Adafruit_NeoPixel.h>
Adafruit_NeoPixel pixels(64, 26, NEO_GRB + NEO_KHZ800);
#define DATA_SIZE 192
#define MATRIX_SIZE 64
uint8_t rgb_data[DATA_SIZE];
void setup()
{
M5.begin(true, false, false);
Serial.begin(115200);
while (!Serial)
;
Serial.println("Ready to receive RGB data");
pixels.begin();
// 消灯
for (int i = 0; i < MATRIX_SIZE; i++)
{
int r = 0;
int g = 0;
int b = 0;
pixels.setPixelColor(i, pixels.Color(r, g, b));
}
pixels.show();
}
void loop()
{
// PCからのシリアル通信のデータを受信
if (Serial.available() >= DATA_SIZE)
{
Serial.readBytes(rgb_data, DATA_SIZE);
for (int i = 0; i < DATA_SIZE; i += 3)
{
int r = rgb_data[i + 0];
int g = rgb_data[i + 1];
int b = rgb_data[i + 2];
int led_id = int(i / 3);
pixels.setPixelColor(led_id, pixels.Color(r, g, b));
}
pixels.show();
}
}
PC側のソフトウェア
LLM ChatソフトウェアについてはLangchain + Streamlitで自作し、MCPクライアントも機能します(長くなるので、今回こちらについての解説は割愛します。近日執筆予定)。なお、今回はMCPを使って実装するので、Claude-Desktopでも今回の実装を試してもらうことができます。(MCP自体については解説記事がたくさんあるので割愛)。
今回のMCPサーバ(emotional_led_matrixという名前にしました)の役割はLLMがこの値で動かして!!と渡してきた引数を受け取りAtom Liteとやり取りするためのコマンド仕様に沿って変換して送信することです。まずポイントとなるのが、引数の定義です。これはLLMとのインタフェースになるため、LLM的に使いやすい定義にしてあげないとダメっぽいです。
初手の実装では、以下のように単純にRGBRGB...RGBと192個のリストで渡してもらえばいいかな〜?って思ったのですが、これだとこの関数を呼び出す際にどこかでスタックする事態に(結局原因は不明、MCPってデバッグしづらくないですか?)。引数に渡す値の個数が分からなくてLLMが混乱していたのかな、、、
async def emotional_led_matrix(inputs: list[int]) -> str:
ということで最終的にはより厳密にと以下で定義しました。
@dataclass
class Color:
r: int
g: int
b: int
fields_defs = [(f"v{i}", Color) for i in range(64)]
LedMatrixInput = make_dataclass("LedMatrixInput", fields_defs)
async def emotional_led_matrix(input: LedMatrixInput) -> str:
若干分かりづらいですが、実態としては以下と同じです。
class LedMatrixInput:
v0: Color
v1: Color
v2: Color
...
v63: Color
そして受け取ったこれらの値をいい感じに変換した後、pyserialパッケージを使ってUSB-シリアル通信でAtom Liteに送信します。
LED制御用MCPサーバのソースコード
from dataclasses import dataclass, fields, make_dataclass
import serial
from mcp.server.fastmcp import FastMCP
LED_MAX_BRIGHTNESS = 32
BRIGHTNESS_DIVIDER = 2
@dataclass
class Color:
r: int
g: int
b: int
fields_defs = [(f"v{i}", Color) for i in range(64)]
LedMatrixInput = make_dataclass("LedMatrixInput", fields_defs)
mcp = FastMCP("emotional_led_matrix")
@mcp.tool()
# async def emotional_led_matrix(values: list[int]) -> str:
async def emotional_led_matrix(input: LedMatrixInput) -> str:
"""
You have 8x8, 64 total RGB full color LED matrix.
With this LED you can express emotions, write letters and draw pictures.
The LEDs are lined up as follows.
v0, v1, v2, v3, v4, v5, v6, v7 \n
v8, v9, v10, v11, v12, v13, v14, v15\n
v16, v17, v18, v19, v20, v21, v22, v23\n
v24, v25, v26, v27, v28, v29, v30, v31\n
v32, v33, v34, v35, v36, v37, v38, v39\n
v40, v41, v42, v43, v44, v45, v46, v47\n
v48, v49, v50, v51, v52, v53, v54, v55\n
v56, v57, v58, v59, v60, v61, v62, v63\n
Args:
The LedMatrixInput class has 64 Color classes, each with a r(red), g(green), and b(blue) field,
corresponding to the R, G, and B values of a single LED.
The range of r, g, b values is as follows.
r(red): 0 ~ 255
g(green): 0 ~ 255
b(blue): 0 ~ 255
Returns:
Result of command execution.
"""
ser = serial.Serial("/dev/ttyUSB0", 115200)
rgb_data: list[int] = []
for f in fields(input):
value = getattr(input, f.name)
r = int(value.r // BRIGHTNESS_DIVIDER)
g = int(value.g // BRIGHTNESS_DIVIDER)
b = int(value.b // BRIGHTNESS_DIVIDER)
rgb_data.extend([r, g, b])
ser.write(bytearray(rgb_data))
ser.close()
return "Success!!"
if __name__ == "__main__":
mcp.run(transport="stdio")
実際に動かしてみる
さぁ、いよいよ動かしてみます!改めてですが、使ったモデルは gemini-2.0-flash-exp
です。
まずは全部赤色にするようにお願いしてみましょう。ちゃんと赤色にしてくれました!
次はレインボーにするようにお願いしてみました。こちらもちゃんと出来てますね!
なんとなく、LLMが自分の気持ちをLEDで表現してくれたら面白いかもな〜ってことで。
好奇心と少しの興奮を青と緑で表現してくれました。
そして最後に数字を描いてもらいます。こちらはイマイチな結果ですねw
9を描いてもらうようにお願いしたが、、、
ここで気になるのが、他のモデルだとどうなんだろう?
ってことで、Claude 3.7 sonnetでもやってみました。こちらはChatソフトウェアとしてClaud-Desktopを使っています。当たり前ですが、前章で作ったMCPサーバがそのまま使えてしまいます、標準化って凄い〜!ちなみにclaude_desktop_config.jsonは以下のように設定しています。
{
"mcpServers": {
"emotional_led_matrix": {
# uvを使って環境構築しているのでuvコマンドへのパスを記載。なぜかフルパスじゃないと動かない
"command": "/home/username/.pyenv/shims/uv",
"args": [
"--directory",
# led_emotional_mcp_server.pyが置いてあるディレクトリのフルパス
"/path/to/script",
"run",
# 前章で実装したMCPサーバ
"led_emotional_mcp_server.py"
]
}
}
}
数字の描画と今の気持ちをLEDで表現するようにお願いしてみたところ、geminiよりもいい感じにやってくれました!Claudeはgeminiよりもこういう表現が上手いんですね〜
完全に9だ!!
まとめ
今回はPhysical AIの第一歩として、LED MatrixをLLMで制御してみました。
こうやってLLMが自分の気持ちなどを物理デバイスで表現してくれるようになると、よりAIが身近に感じられますね。次はアニメーション表現に対応してみたいな〜
Discussion