Beambox Niji Badgeを公式アプリなしでBluetooth直接制御して画像を表示した
目次

秋葉原のイオシスで、丸い画面がついたデジタル缶バッジを見つけたので買ってきた。
Beamboxの「Niji Badge」というシリーズで、スマホの画像やGIFを転送して胸元に表示できるガジェットらしい。
検証環境はWindows 11 + Python 3.14。
BLEライブラリはbleakを使う。
開封

パッケージは「beambox」ロゴと「E-Badge」の文字。
丸いディスプレイに「Niji」という吹き出しが表示されたサンプル画像が印刷されている。
箱の裏には仕様が書いてあった。
| 項目 | 値 |
|---|---|
| 製品名 | E-Badge(型番: Niji-Badge) |
| 画面サイズ | 1.53インチ |
| 解像度(表記) | 360×360 |
| 対応フォーマット | jpg / gif / png |
| バッテリー | 500mAh |
| 入力 | DC5V 0.5A |
| 製造元 | Avatronics Corporation Limited(深セン) |

「Original IP By Beambox Design」として、色違いのキャラクターが4体並んでいる。
バッジのカラー展開(Pink / Blue-purple / Yellow)ごとにキャラクターが変わる仕組みらしい。今回買った個体はBlue-purpleにチェックが入っていた。
中身一式を並べるとこう。

バッジ本体、ストラップ、USB充電ケーブル、保証書、ユーザーガイド(多言語、英語ページあり)が入っていた。
バッジ裏には安全ピンとバックル金具がついていて、缶バッジのように服やカバンに留められる。
USB経由の転送は無理そう
充電ケーブルをPCに挿すと、画面にUSBケーブルのアイコンが点滅表示された。
![]()
同時にデバイスマネージャーには「BR28 UDISK」というUSBマスストレージが出てきた。
ベンダーIDを見るとVID_4C4A(杰理科技 / Jieli Technology)。中身はJieli系のBluetooth SoCらしい。
ただしこのディスク、Get-Diskで見るとOperationalStatus: No Media。中身にアクセスできない。
Jieli系チップでよくある、ファームウェア用のダミーディスクだと思われる。つまりUSBはほぼ充電専用で、画像をドラッグ&ドロップで送る経路ではなさそうだった。
ボタンを調べる
本体には電源ボタンの横にもう1つボタンがある。
何度か押して挙動を確認した。
最初に出てきたのはこの画面。

「The other party press the Bluetooth button twice briefly to receive the share」とある。
バッジ同士を近づけてボタンを2回押すと、表示中の画像を直接転送し合えるP2P共有モードのようだ。
もう一度押すと別の画面に切り替わった。

こちらはアプリのダウンロード用QRコードとペアリング待ち受け画面。
説明書を確認すると、短押しでこのペアリングモードに入る仕様だった。
BLEで直接掴む
公式アプリを使わずに画像を送りたい。
まずはこのペアリング画面を出した状態でBLEスキャンをかけてみる。
バッジの電源を入れただけの状態では、周囲の無名デバイスに紛れて全く見分けがつかなかった。
名前もサービスUUIDもアドバタイズパケットに載っていない。
ペアリング画面(Scan to download)を出した状態でスキャンし直すと、アドバタイズ名E-badgeのデバイスが見つかった。
このバッジ、常時アドバタイズしているわけではなく、ペアリング画面を出しているときだけ名乗りを上げる作りらしい。
接続してGATTサービスを列挙すると、カスタムサービスが1つだけ見えた。
| UUID(末尾4桁) | プロパティ | 用途 |
|---|---|---|
01F0 | - | サービス本体 |
01F1 | write-without-response | アプリ→デバイス書き込み |
01F2 | notify | デバイス→アプリ通知 |
シンプルな構成。1つのwriteキャラクタリスティクスと1つのnotifyキャラクタリスティクスだけで全部やり取りする。
デバイスの生存報告を読む
接続してnotifyを購読すると、何も送っていないのに定期的にデータが飛んできた。
a00d0000637b2274797065223a31332c22616c6c7370616365223a31363338342c...
先頭5バイトがヘッダーで、そのあとにテキストのJSONが続いている。デコードするとこう。
{"type":13,"allspace":16384,"freespace":5592,"devname":"","size":"368,368","time_mode":1,"brand":0}
sizeが368,368になっている。箱の表記は360×360だったので、実際のフレームバッファは箱のスペックより一回り大きいらしい。
allspace/freespaceはKB単位のストレージ容量で、画像を送るたびにこの値が減っていくのをあとで確認できた。
ヘッダー部分も含めて構造を整理するとこう。
| バイト位置 | 内容 |
|---|---|
| 0 | ヘッダー 0xA0(デバイス→アプリ) |
| 1 | メッセージタイプ |
| 2-3 | 予約領域 |
| 4 | データ長(1バイト、最大255) |
| 5〜 | JSON本文 |
| 末尾1B | チェックサム |
チェックサムは全バイトの合計を256の補数にしたもの。
def checksum(data: bytes) -> int:
return (-sum(data)) & 0xFF
先頭からチェックサムの直前までの合計に、このチェックサム値を足すと256の倍数になる。実際に手元のデータで確かめても合っていた。
送信フレームを組み立てる
アプリ→デバイス方向のフレームは受信側と少し違う。
| バイト位置 | 内容 |
|---|---|
| 0 | ヘッダー 0xF1(アプリ→デバイス) |
| 1 | コマンドタイプ |
| 2-3 | 総パケット数(ビッグエンディアン) |
| 4-5 | 残りパケット数(カウントダウン) |
| 6-7 | このパケットのデータ長 |
| 8〜 | データ本体(最大496バイト) |
| 末尾1B | チェックサム |
コマンドタイプはいくつか種類があるらしいことが分かった。
| コード | 用途 |
|---|---|
0x01 | アクティベート確認 |
0x02 | OTAパッケージ |
0x03 | 起動アニメーション |
0x04 | ウォッチフェイス |
0x05 | GIFアニメーション |
0x06 | アルバム(写真) |
0x07 | バージョン取得 |
0x0D | 明るさ設定 |
名前的に一番それっぽいのは0x06(アルバム)。写真を送るならこれだろうと当たりをつけた。
データ本体は496バイトごとに分割する。最初のパケットだけ{"type":6,"data":というテキストのヘッダーが17バイト付き、最後のパケットだけ末尾に閉じ括弧}が1バイト付く。
def build_packets(payload: bytes, type_byte: int):
head = b'{"type":' + bytes([0x30 + type_byte]) + b',"data":'
tail = b'}'
n = len(payload)
total = ((n + 15) // 496 + 1) if (n - 479) > 0 else 1
packets = []
pos = 0
for i in range(total):
countdown = (total - 1) - i
if i == 0:
take = min(479, n)
chunk = head + payload[pos:pos + take]
elif i == total - 1:
chunk = payload[pos:] + tail
else:
take = min(496, n - pos)
chunk = payload[pos:pos + take]
pos += len(payload[pos:pos + take]) if i != total - 1 else len(payload) - pos
frame = (bytes([0xF1, type_byte])
+ total.to_bytes(2, "big")
+ countdown.to_bytes(2, "big")
+ len(chunk).to_bytes(2, "big")
+ chunk)
packets.append(frame + bytes([checksum(frame)]))
return packets
このフレームを368×368のJPEGに詰めてtype=6で送ってみた。
バッジからは{GetPacketSuccess}という応答が返り、ストレージの空き容量もJPEGのサイズ分きっちり減った。転送は成功している。
真っ黒
画面を見ると、真っ黒だった。

タッチで画像を切り替えると、ちゃんと新しい枠が増えている。転送とストレージ確保はできているのに、表示だけが真っ黒。
JPEGのデコードに失敗しているように見えた。
自分のPC上ではJPEGは問題なく開けたので、ファイル自体は壊れていない。
組み込み機によくある「JFIFヘッダーがないと読めないデコーダー」を疑って、標準的なJFIFヘッダー付きで再エンコードし直したが、結果は同じく真っ黒。
クロマサブサンプリング(4:2:0と4:4:4)を変えても変化なし。もっと単純な赤背景+緑の四角+青い丸だけの画像に差し替えても、やはり真っ黒だった。
JPEGの中身をいくら調整しても直らない。ここで方向転換した。
独自のバイナリコンテナだった
色々と試しているうちに、type=6(アルバム)に生のJPEGをそのまま流し込むのがそもそも間違いで、実際は独自のバイナリコンテナ形式でラップしてから送る必要があること、しかも使うべきコマンドタイプは0x05(GIFアニメーション)だということが分かった。静止画1枚もこの経路で送るらしい。
ファイル先頭に0x12345678(リトルエンディアン)が入るマジックナンバー付きのコンテナ形式だった。
JPEGを1枚だけ包んだ最小構成はこうなる。
| オフセット | サイズ | 内容 |
|---|---|---|
| 0 | 4B | マジックナンバー 0x12345678(LE) |
| 4 | 4B | メタ値(フレーム数×16+24) |
| 8 | 4B | フレーム数 |
| 12 | 4B | フレーム間隔(ms) |
| 16 | 12B | 名前文字列(例: output/3000m) |
| 28 | 4B | ファイル全長−1 |
| 32 | 16B | アドレステーブル(フレーム名12B+オフセット4B) |
| 48 | 32B | フレームのサブヘッダー |
| 80〜 | 可変 | JPEGバイナリ本体 |
フレームのサブヘッダー(上の表のオフセット48から32バイト)はこう。
| オフセット | サイズ | 内容 |
|---|---|---|
| +0 | 4B | 自分自身のオフセット |
| +4 | 4B | 次フレームのオフセット |
| +8 | 1B | フォーマット(0x0B固定) |
| +9 | 1B | 圧縮フラグ(0x00) |
| +10 | 2B | 予約 |
| +12 | 2B | 幅 |
| +14 | 2B | 高さ |
| +16 | 4B | JPEGデータのオフセット |
| +20 | 4B | JPEGデータ長 |
| +24 | 8B | 予約 |
1枚しかない場合、「次フレームのオフセット」は自分自身を指す。1コマだけのループするGIFとして扱われているようだ。
Pythonで組むとこう。
import struct
def build_bin_container(jpeg_frames: list[bytes], interval_ms: int, width: int, height: int):
n = len(jpeg_frames)
addr_table_len = n * 16
frame_data_start = addr_table_len + 32
meta = addr_table_len + 24
total_size = frame_data_start + sum(len(f) + 32 for f in jpeg_frames)
buf = bytearray(total_size)
struct.pack_into("<I", buf, 0, 0x12345678)
struct.pack_into("<I", buf, 4, meta)
struct.pack_into("<I", buf, 8, n)
struct.pack_into("<I", buf, 12, interval_ms)
name = f"output/{interval_ms}ms".encode()[:12]
buf[16:16 + len(name)] = name
frame_offsets = []
pos = frame_data_start
for jpeg in jpeg_frames:
frame_offsets.append(pos)
struct.pack_into("<I", buf, pos, pos) # 自分のオフセット
struct.pack_into("<B", buf, pos + 8, 11) # フォーマット
struct.pack_into("<H", buf, pos + 12, width)
struct.pack_into("<H", buf, pos + 14, height)
struct.pack_into("<I", buf, pos + 16, pos + 32) # JPEGオフセット
struct.pack_into("<I", buf, pos + 20, len(jpeg)) # JPEG長
buf[pos + 32:pos + 32 + len(jpeg)] = jpeg
pos += 32 + len(jpeg)
for i in range(n):
nxt = frame_offsets[i + 1] if i < n - 1 else frame_offsets[0]
struct.pack_into("<I", buf, frame_offsets[i] + 4, nxt)
entry = 32 + i * 16
struct.pack_into("<I", buf, entry + 12, frame_offsets[i])
struct.pack_into("<I", buf, 28, pos - 1)
return bytes(buf[:pos])
この関数で作ったバイナリを、さっきのbuild_packets(payload, type_byte=5)に渡してBLEに流す。
JPEG自体はwidth/heightを368×368にしてPillowで作り直した。
送信フローを図にするとこう。
graph TD
A[BLEスキャン] --> B{アドバタイズ名にbadgeを含む}
B -->|No| A
B -->|Yes| C[GATT接続]
C --> D[サービス 0x01F0 確認]
D --> E[notify 0x01F2 を購読]
E --> F[JPEGをBINコンテナに変換]
F --> G[496Bごとに分割してフレーム化]
G --> H["write-without-response で 0x01F1 へ送信"]
H --> I{全パケット送信済み}
I -->|No| H
I -->|Yes| J[GetPacketSuccessを受信]
テスト画像で確認
まずシンプルな赤背景に緑の四角、青い丸だけの画像で試した。

色も位置も正確に出た。緑の縁にうっすら見える黄色いフリンジは、色の境界がはっきりしたJPEGでよく出る圧縮アーティファクトで、プロトコルとは関係ない。
type=6では何をやっても真っ黒だったのが、コンテナ形式とtype=5に変えただけで一発で直った。
かなちゃんを表示
テストが通ったので、本命のドット絵を送った。

公式アプリを一度も起動せずに、Windows PCのPythonから直接BLEで画像を書き込むところまでできた。
USBはJieli系チップの充電専用ポートで、ドラッグ&ドロップでのファイル転送はできない。
画面上は360×360と表記されているが実際のフレームバッファは368×368。
そして写真を送るためのtype=6(アルバム)はデータを受け取って保存はするのに表示だけが真っ黒になり、実際に画面に出すにはtype=5(GIFアニメーション扱い)と、独自のバイナリコンテナへの変換が必要だった。
BLEでつないで画像を1枚流すだけの話だと思っていたら、思ったより手間のかかる話だった。