技術約9分で読めます

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

いけさん目次

秋葉原のイオシスで、丸い画面がついたデジタル缶バッジを見つけたので買ってきた。
Beamboxの「Niji Badge」というシリーズで、スマホの画像やGIFを転送して胸元に表示できるガジェットらしい。

検証環境はWindows 11 + Python 3.14。
BLEライブラリはbleakを使う。

開封

Beambox Niji Badgeの箱

パッケージは「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ケーブルのアイコンが点滅表示された。

充電中に表示されるUSBアイコン

同時にデバイスマネージャーには「BR28 UDISK」というUSBマスストレージが出てきた。
ベンダーIDを見るとVID_4C4A(杰理科技 / Jieli Technology)。中身はJieli系のBluetooth SoCらしい。

ただしこのディスク、Get-Diskで見るとOperationalStatus: No Media。中身にアクセスできない。
Jieli系チップでよくある、ファームウェア用のダミーディスクだと思われる。つまりUSBはほぼ充電専用で、画像をドラッグ&ドロップで送る経路ではなさそうだった。

ボタンを調べる

本体には電源ボタンの横にもう1つボタンがある。
何度か押して挙動を確認した。

最初に出てきたのはこの画面。

Photo Sharing画面

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

もう一度押すと別の画面に切り替わった。

Scan to download画面

こちらはアプリのダウンロード用QRコードとペアリング待ち受け画面。
説明書を確認すると、短押しでこのペアリングモードに入る仕様だった。

BLEで直接掴む

公式アプリを使わずに画像を送りたい。
まずはこのペアリング画面を出した状態でBLEスキャンをかけてみる。

バッジの電源を入れただけの状態では、周囲の無名デバイスに紛れて全く見分けがつかなかった。
名前もサービスUUIDもアドバタイズパケットに載っていない。

ペアリング画面(Scan to download)を出した状態でスキャンし直すと、アドバタイズ名E-badgeのデバイスが見つかった。
このバッジ、常時アドバタイズしているわけではなく、ペアリング画面を出しているときだけ名乗りを上げる作りらしい。

接続してGATTサービスを列挙すると、カスタムサービスが1つだけ見えた。

UUID(末尾4桁)プロパティ用途
01F0-サービス本体
01F1write-without-responseアプリ→デバイス書き込み
01F2notifyデバイス→アプリ通知

シンプルな構成。1つのwriteキャラクタリスティクスと1つのnotifyキャラクタリスティクスだけで全部やり取りする。

デバイスの生存報告を読む

接続してnotifyを購読すると、何も送っていないのに定期的にデータが飛んできた。

a00d0000637b2274797065223a31332c22616c6c7370616365223a31363338342c...

先頭5バイトがヘッダーで、そのあとにテキストのJSONが続いている。デコードするとこう。

{"type":13,"allspace":16384,"freespace":5592,"devname":"","size":"368,368","time_mode":1,"brand":0}

size368,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アクティベート確認
0x02OTAパッケージ
0x03起動アニメーション
0x04ウォッチフェイス
0x05GIFアニメーション
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枚だけ包んだ最小構成はこうなる。

オフセットサイズ内容
04Bマジックナンバー 0x12345678(LE)
44Bメタ値(フレーム数×16+24)
84Bフレーム数
124Bフレーム間隔(ms)
1612B名前文字列(例: output/3000m
284Bファイル全長−1
3216Bアドレステーブル(フレーム名12B+オフセット4B)
4832Bフレームのサブヘッダー
80〜可変JPEGバイナリ本体

フレームのサブヘッダー(上の表のオフセット48から32バイト)はこう。

オフセットサイズ内容
+04B自分自身のオフセット
+44B次フレームのオフセット
+81Bフォーマット(0x0B固定)
+91B圧縮フラグ(0x00
+102B予約
+122B
+142B高さ
+164BJPEGデータのオフセット
+204BJPEGデータ長
+248B予約

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を受信]

テスト画像で確認

まずシンプルな赤背景に緑の四角、青い丸だけの画像で試した。

RGB三色のテスト画像が正しく表示された

色も位置も正確に出た。緑の縁にうっすら見える黄色いフリンジは、色の境界がはっきりしたJPEGでよく出る圧縮アーティファクトで、プロトコルとは関係ない。

type=6では何をやっても真っ黒だったのが、コンテナ形式とtype=5に変えただけで一発で直った。

かなちゃんを表示

テストが通ったので、本命のドット絵を送った。

Beambox Niji Badgeにかなちゃんのドット絵が表示された

公式アプリを一度も起動せずに、Windows PCのPythonから直接BLEで画像を書き込むところまでできた。

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