はじめに
Xで話題の、3COINSのビデオトランシーバー無線屋として気になったので、モニターモードで802.11フレームを丸ごとキャプチャしてどのように通信しているか調査してみました。
※本記事は所有する3COINSビデオトランシーバーの通信を、技術的な学習を目的として解析した記録です。他人宛ての無線通信を傍受し、その内容を漏らしたり窃用する行為は電波法第59条で禁じられています。同種の解析を行う場合は自分が所有する機器のみを対象とし、第三者の通信内容を取得・公開しないでください。
結論から言うと、Wi-Fi上で独自プロトコルのMJPEGと生PCMを流している実装でした。
以下、radiotap→802.11→IPv4→UDP→独自プロトコルの順に説明します。
機材とキャプチャ手順
専用アプリで「HG-WIFI_xxxx」というSSIDが7chで出ているのを確認。
めんどくさかったので、macOSの「ワイヤレス診断」(Wireless Diagnostics)のスニファ機能で7chをpcap形式で記録。通話を開始させて、しばらくパケットを記録しました。
キャプチャ概要
| 項目 | 値 |
|---|---|
| キャプチャ形式 | pcap (microsecond ts, little-endian) |
| linktype | 127 = LINKTYPE_IEEE802_11_RADIOTAP
|
| 容量 | 5.4 MB |
| パケット数 | 10,394 |
| 取得時間 | 約40秒 |
| 平均パケット長 | 506.88 byte |
| 平均レート | 258 pps / 1,047 kbps |
linktype 127 なので、各フレームは
[ radiotap header ][ 802.11 MAC ][ LLC/SNAP ][ IPv4 ][ UDP ][ payload ]
の順に並んでいます。Wi-Fi NICをモニターモード+チャンネル固定で動かして拾ったキャプチャと同じ構造です。
$ capinfos trans.pcap
File encapsulation: IEEE 802.11 plus radiotap radio header
Number of packets: 10 k
Capture duration: 40.248118 seconds
Data bit rate: 1,047 kbps
リンク層: radiotap / 802.11 / LLC/SNAP を剥がす
リンク層はpcapデータから IPv4 まで辿り着くための前処理として一応解説。使うだけならtsharkでデータだけ抽出できます。ビジュアルで見たければWiresharkで見れば良いです。
-
radiotap — Wi-Fi NIC が付ける可変長メタヘッダ(RSSI・チャネル等)。
pkt[2:4]の LE u16 が長さなので読み飛ばすだけ。 -
802.11 MAC — Frame Control の
type=2(Data) のみを対象に。MACヘッダ長は基本24B、ToDS&&FromDS なら +6、QoS Data (subtype bit 3) なら +2。今回のキャプチャは QoS Data 主体で 26B。 -
LLC/SNAP (RFC 1042) — Wi-Fi では Ethernet 型ヘッダではなく
AA AA 03 00 00 00 [EtherType]の8Bが入る。後続2BがEtherType。ここを誤解するとパースが全部ズレるので注意。0x0800= IPv4 を確認したら次へ。
実コードは末尾の実装スニペットを参照。本記事の主役はここから先のネットワーク層・トランスポート層・アプリ層なので、リンク層の詳細はここまで。
ネットワーク層・トランスポート層: IPv4 / UDP
ここはお馴染み。tsharkで一覧したところ、トランシーバー間のIPは 192.168.1.1 と 192.168.1.100 で、UDPポートはきれいに3系統に分かれていました。
| 送信元ポート → 宛先ポート | 用途 | サイズ |
|---|---|---|
| 11011 → 11013 (双方向) | 映像 | 1336B (中間), 末尾は可変 |
| 5008 → 5008 (双方向) | 音声 | 278B 固定 |
| 11012 → 11014 (双方向) | 制御/keep-alive | 28B 固定 |
$ tshark -r trans.pcap -Y 'udp' -T fields -e udp.length \
| sort -n | uniq -c | sort -rn | head
2339 1336
2213 278
84 28
...
「1336バイト」というのが妙に綺麗な値で、これはMTUへの収まりを意識した実装上の選択でしょう。tsharkが出す udp.length はUDPヘッダ込みなので、UDP実データは1328B。トータルで:
802.11 MAC(26) + LLC/SNAP(8) + IPv4(20) + UDP長(1336) = 1390 B
標準MTU 1500バイトに余裕を持って収まり、かつ 802.11 の Fragmentation Threshold (デフォルト2346) に届かない値で、リンク層での断片化を回避しています。
アプリケーション層: 独自プロトコル(映像)
ペイロードを覗く
$ tshark -r trans.pcap -Y 'udp.srcport==11011' -T fields -e data | head -7
81000604334f0100000003000000000000000001 ffd8ffdb00 ...
01010604334f0100000003000000000000000003 f32ab07a5d ...
01020604334f0100000003000000000000000007 5bf7133818 ...
01030604334f010000000300000000000000000f a4bbfee8ff ...
01040604334f010000000300000000000000001f fbc181e9eb ...
41050604334f010000000300000000000000003f f225cfa9fc ...
82000604874f0100000003000000000000000001 ffd8ffdb00 ...
^^^^ JPEG SOI
スペースで区切るといろいろ見えてきます。
- 先頭20バイトは独自ヘッダ(全パケット同じ位置に固定的なパターン)
-
その直後にJPEGバイト列(最初のパケットだけ
FFD8SOI、最後のパケットにはFFD9EOI が含まれる)
つまりMJPEGの素フレームを、独自20Bヘッダで断片化して送っているだけ。RTP/JPEG (RFC 2435) でもない独自プロトコルです。
20バイトヘッダ仕様
| Offset | Size | フィールド | 内容 |
|---|---|---|---|
| 0 | 1 | flags / frame_lo | 上位nibble: 0x8=先頭, 0x0=中間, 0x4=末尾 / 下位nibble: フレーム連番mod16 |
| 1 | 1 | frag_seq | フレーム内フラグメント連番(0始まり) |
| 2 | 2 | proto_ver? | 観測値 0x0604, 0x0704
|
| 4 | 4 | frame_id (LE u32) | フレーム毎に増分 |
| 8 | 4 | const | 固定 0x03000000
|
| 12 | 4 | padding | 固定 0x00000000
|
| 16 | 4 | frag_bitmask (BE u32) | 累積ビットマスク |
注目: frag_bitmask の正体
byte 16-19の4バイトは frag_seq と連動して
| frag_seq | bitmask |
|---|---|
| 0 | 00 00 00 01 |
| 1 | 00 00 00 03 |
| 2 | 00 00 00 07 |
| 3 | 00 00 00 0f |
| 4 | 00 00 00 1f |
| 5 | 00 00 00 3f |
つまり 2^(seq+1) - 1。「ここまで送ったフラグメントの累積マスク」を送信側が毎パケット同梱しています。
これは無線屋的に面白いポイントで、ACK無しのUDPなのに受信側が単発で欠落検出できるようになっているということ。累積マスクが 2^(seq+1)-1 なので、ある1パケットを受け取った瞬間、その時点までに送信側が出したはずのseq集合(=立っているはずのビット)が即座にわかります。受信側が自分で持っている「実際に受け取ったseqのビットマスク」と送信側マスクをXORするだけで、ロスしたseqが浮かびます。アプリ層で安価にロス検知できる設計と言えます。
フレーム再構築
1. UDPペイロード先頭20Bを剥がす
2. frame_id でグループ化
3. frag_seq 順にソートして連結
4. 先頭 FFD8、末尾 FFD9 で完全なJPEGになる
実際にやると、40秒間の片方向ストリームから 320x240 baseline JPEG が 156〜183枚復元できました。実効フレームレートは8〜9 fps程度。
$ file frame_00000_00014f33.jpg
JPEG image data, baseline, precision 8, 320x240, components 3
アプリケーション層: 独自プロトコル(音声)
パケット構造
音声側(UDP 5008)は全パケット270byte固定(UDP長278から8B減算)で、フラグメンテーションすら無し。
00: seq_a (LE u16)
02: seq_b (LE u16)
04: timestamp (LE u32)
08: session_id (4B, 送信元固有)
0C: counter (LE u16)
0E: 0x5480 (marker)
10: 0x5480 (marker)
12: 0x0000 (reserved)
14: 250 byte の生PCMサンプル
session_id が送信元IPごとに異なる値で固定 (01 41 b4 f7 vs 4d ac fe 2a)。MACアドレスと別に、アプリ層で対向相手を識別している雰囲気です。
ペイロードを聴く(無音問題)
抽出して 8kHz / 16-bit signed LE / mono でWAVに包んだのですが、再生してみるとほぼ完全な無音。サンプル部の**非ゼロバイト率を測ると 0.03〜0.07%**しかありませんでした。
192.168.1.1: packets=903 bytes=225750 非0率=0.03%
192.168.1.100: packets=910 bytes=227500 非0率=0.07%
ヘッダ部のseqは正常に進んでいるので、これはマイクOFF/PTT未押下時もストリームは流し続け、サンプル部は0埋めする実装の模様。RTPの DTX (Discontinuous Transmission) のようにパケットを止めるのではなく、等間隔送信を維持して受信側のジッタバッファ崩壊を避ける設計。
無線のリンク維持としても、こちらの方が「相手が生きてるか」をデータパケットで定期確認できて都合がよいのでしょう。
推定サンプルレート
- 50 packets/sec × 250 byte = 12,500 byte/sec
- 16-bit mono なら → サンプルレート 6,250 Hz
- 8-bit mono μ-law なら → サンプルレート 12,500 Hz
- パケット間隔から逆算すると 8 kHz/16bit の方が綺麗
PTTを押して喋っていなかったのでここは推定。
アプリケーション層: 制御チャネル(UDP 11012/11014)
最初は「ただのハートビート」と思って読み飛ばしていたんですが、よく見たら呼び出しと受諾の合図がここに乗っていました。
通話してないとき
20バイトの同じパケットが両方向から 1秒に1回流れているだけ:
03 ff 00 00 00 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00
普段はこれで「相手生きてる?」を確認しています。
通話を始めるとき
通話ボタンが押されてから映像が流れ始めるまでの間、ハートビートとは違うパケットが何回か飛び交います。これがシグナリング。
方向 先頭バイト 意味(推定)
========================================================
1.100→1.1 09 4b ... 呼び出し
1.1→1.100 59 4b ... 確認
... 数秒待つ ...
1.1→1.100 03 ff ... [ID] セッションIDを伝える
... 1秒待つ ...
1.1→1.100 0c 00 ... 「映像送るよ」
1.100→1.1 5c 00 ... 「OK」 ← ★これが受諾の合図
→ 75ms後、映像ストリーム開始
**5c 00 ... が「受信側のOK」**で、これを受け取った発信側がすぐに映像を送り始めます。
ペア構造の小ネタ
リクエスト/レスポンスの先頭バイトを並べると面白いパターンがあります。
呼び出し: 09 → 確認: 59 (+0x50)
映像要求: 0c → OK: 5c (+0x50)
要求パケットの先頭バイトに 0x50 を足しただけが応答パケット。応答だと分かれば良いだけなので、これで十分という割り切りでしょう。
通話中にも何か流れている
通話が始まったあとも、たまに 1.100 側から 44 バイトのメッセージが飛んできます (02 53 ..., 02 55 ..., 02 6f ... など)。状態通知か品質報告っぽいですが、詳細は未解析。
まとめ
「制御チャネル」と呼んでいた1ポートに、ハートビート + 呼び出しシグナリング + セッション中の通知が全部乗っかっている超ミニマル設計でした。ポートを分けたりせず、ペイロードの先頭バイトでメッセージ種別を表すのは、組み込みらしい潔さがあります。
無線屋視点で面白かったところ
-
MTU設計 — UDP長1336B(実データ1328B)は「802.11の通常Fragmentation Threshold (2346) より小さく、Ethernet MTU 1500 にも収まり、かつ大きめにバルク」という絶妙なライン。
-
アプリ層フラグメンテーション — JPEGのフレームサイズが1パケットに収まらないとき、リンク層の分割に任せずアプリ層で分割している。子機間直接通信や品質劣化に強い構成ですね。
-
累積ビットマスク — ACKに頼らず欠落検出だけを実装している。欠落フレームは捨てて次のフレームへ。MJPEGはフレーム独立なので1フレーム捨てても再生は破綻しない設計。
-
無音ストリーム — 音声側に DTX 相当の仕組みが無く、常時ストリームを流し続ける素朴な実装。電池には優しくないが、無線リンクの維持と受信側バッファ管理は楽になる。
実装スニペット
radiotap+802.11+LLC/SNAPを剥がしてUDPペイロードを取り出すだけの最小コード。pcapパーサも自作したのでscapy等は不要です。
def parse_pcap(path):
with open(path, "rb") as f:
magic, *_, linktype = struct.unpack("<IHHIIII", f.read(24))
assert linktype == 127 # radiotap
while True:
ph = f.read(16)
if len(ph) < 16: return
_, _, caplen, _ = struct.unpack("<IIII", ph)
pkt = f.read(caplen)
rt_len = struct.unpack("<H", pkt[2:4])[0]
d = pkt[rt_len:]
if ((d[0] >> 2) & 0x3) != 2: continue # not Data
to_ds = d[1] & 1
from_ds = (d[1] >> 1) & 1
has_qos = ((d[0] >> 4) & 0x8) != 0
mac_len = 24 + (6 if to_ds and from_ds else 0) + (2 if has_qos else 0)
llc = d[mac_len:mac_len + 8]
if llc[:3] != b"\xaa\xaa\x03": continue
if struct.unpack(">H", llc[6:8])[0] != 0x0800: continue
ip = d[mac_len + 8:]
ihl = (ip[0] & 0xF) * 4
if ip[9] != 17: continue # UDP
udp = ip[ihl:struct.unpack(">H", ip[2:4])[0]]
sport, dport, ulen, _ = struct.unpack(">HHHH", udp[:8])
yield sport, dport, udp[8:ulen]
まとめ
- IPv4 + UDP + 自家製20Bヘッダ + MJPEG/PCM という素朴な構造
- 1336B/PktのMTU設計、
2^(seq+1)-1の累積ビットマスクによる単発ロス検出、フレーム独立のMJPEGを活用したACK無し設計、と無線でロスありを前提にした実装の合理性が見える - 音声側はサンプル部0埋めの等間隔送信。DTXせずジッタバッファ維持を優先
接続周りはあまり追ってませんが、基本は紫さんがAPで192.168.1.1で動作。そこに繋ぎにいく感じでMACで制限している感じですね。送受信IP固定なので複数通話は難しそう。親用のモニターソフトは簡単に作れそうです。
WiFiを使って映像/音声伝送をこのように実装することができるんだなと、勉強になりました。この実装でビデオ通話とか簡単なトランシーバーを作ってみるのも面白いなと。
デバイス的には、PTT押すとついついマイクに口つけてしまうので、顔のパーツがドアップで映るのはちょっとなーと思いました(子供は気にしないかw



