格安WiFiカメラ(miSafes D304)で /dev/mem からフレームを抜いてブラウザ実時間視聴する
クラウド(P2P)経由の専用アプリでしか見られない格安WiFiカメラを、
ローカルLANのブラウザでリアルタイム視聴できるようにした記録です。
- カメラのISPが吐いた生フレームは 物理メモリ上に存在し、
/dev/memから読める(本機は物理0x4e9ae000に 1920×1088 YUV420)。 - 生データをSSHで吸い出してPCで変換…は 転送が遅すぎて実時間にならない(単コア+ISP割り込みで ~30KB/s)。
- 鍵は 「カメラの中で先にJPEG圧縮してから配る」。圧縮を挟むだけで転送量が激減し、実時間になる。
- 圧縮は カメラ内でJava(ImageIO) が担当。
/dev/memを読んで小さいグレースケールJPEGにして吐く。 - 配信は Caddy、表示は ブラウザが
live.jpgを連続取得するだけ。 -
システムファイルは一切改変せず(
/dev/memを読むだけ+SDから自作バイナリを実行、/optの純正はそのまま)。 - 単コアでも 480×270 グレースケールで 3.5〜4 fps 出る。
自分で所有する機器での学習・改造記録です。/dev/mem は強力なので自己責任で。
背景:なぜ自分でやることにしたのか
この手の格安WiFiカメラは、専用スマホアプリ+P2Pクラウド(TUTK系)で初期設定も視聴もする前提です。ところが手元の個体は…
- サービス/アプリがサポート終了していて、初期設定サーバもP2Pも生きていない。つまり普通にセットアップすることすらできない。本来の使い方が完全に詰んでいて、このままだとただの文鎮(e-waste)。
- そもそも動いていた頃も、自宅の室内映像が海外(中国)のクラウドサーバを経由する作り。素性のよく分からない海外サーバに家の中の映像を預けるのは、正直かなり気持ち悪い。
要するに 「使えない上に、(使えても)データを外に送られるのが嫌」。
でも分解して中を見ると、中身は普通のLinux+普通のISP。だったら答えは一つで、
クラウドを完全に排除して、ローカルLANだけで完結する“自分のカメラ”にしてしまえばいい。
これがこの一連の作業の動機です。方針はシンプルで、映像を一切インターネットに出さないこと。視聴もローカル、録画もローカル。
本記事はその「実時間視聴」の部分を扱い、後半では「ローカルにループ録画させる」ためのファームウェア解析も載せます。
デバイスと制約
| 項目 | 内容 |
|---|---|
| SoC | InfoTM iMAPx15(単コア ARM Cortex‑A5) |
| RAM | 約 200MB / rootfs は read‑only |
| センサ | AR0330(MIPI) → ISP(Felix) → H.265/JPEGエンコード+P2P を avserver が一括処理 |
| フレーム | ISPの出力が物理メモリに存在。/dev/mem から読める
|
| ネット | SSHのスループットが遅い(ISPのIRQ/DMAでCPU飽和時 ~30KB/s) |
全体像
1. フレームはどこにある?
avserver がセンサ→ISP→エンコードのパイプラインを回しています。
ISP(Felix)の出力フレームは物理メモリ上のバッファに置かれ、/dev/mem 経由でその物理アドレスを読めることが分かりました。
- 物理アドレス:
0x4e9ae000 - サイズ:
3133440バイト = 1920 × 1088 の YUV420(Y=1920×1088=2088960B + UV)
グレースケールにした理由:この機種のUV(色差)プレーンはタイル状(mosaic)に並んでいて素直に扱えません。
一方 Y(輝度)プレーンはそのまま画像になるので、Yだけ使う=グレースケールが一番手堅い。
(監視用途なら白黒で十分実用的)
/dev/mem を読むだけなので、カメラの純正プロセスやシステムファイルには一切触りません。
2. 素朴な方法の限界(そして転機)
最初はこうしました:
# SSH越しに /dev/mem を dd で吸い出して…
ssh root@cam "dd if=/dev/mem bs=4096 skip=... count=510" > frame.y
# …PC側(Python+Pillow)でJPEG化
これが 遅すぎる。生のYプレーンだけで約2MB、SSHのスループットが遅く(単コア+ISP割り込みでCPUが埋まる)、1フレームに数秒。実時間には程遠い。
ここでの気づきが全てでした:
「取り出したフレームを、カメラの中で別プログラムで圧縮してから配ればいいのでは?」
生データをネットワークに流すのをやめ、カメラ内で先にJPEG(数十KB)にする。転送量が1/50以下になり、一気に実時間になります。
3. カメラ内でJPEG圧縮(Java + ImageIO)
圧縮役は カメラ内で動かしたJava(ARM JRE8)。
uClibc環境でglibcリンクのJavaを動かす方法(glibc同梱+ELF interpを/tmp/ldにパッチ)は
Minecraftサーバー編 と同じ手法です。
Live.java(要点):
import java.io.*; import java.awt.image.*; import javax.imageio.*; import javax.imageio.stream.*;
public class Live {
public static void main(String[] a) throws Exception {
long phys = Long.decode(a[0]); // 0x4e9ae000
int W=1920, H=1088; // 元フレーム
int OW=Integer.parseInt(a[1]), OH=Integer.parseInt(a[2]); // 出力 例 480x270
float q = Integer.parseInt(a[3]) / 100f; // JPEG品質
String out = a[4]; // /mnt/mmc/live.jpg
byte[] row = new byte[W];
BufferedImage small = new BufferedImage(OW, OH, BufferedImage.TYPE_BYTE_GRAY);
byte[] sb = ((DataBufferByte) small.getRaster().getDataBuffer()).getData();
int[] cx = new int[OW];
for (int ox=0; ox<OW; ox++) cx[ox] = ox * W / OW; // 列の間引き先を先に計算
ImageWriter w = ImageIO.getImageWritersByFormatName("jpeg").next();
ImageWriteParam p = w.getDefaultWriteParam();
p.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
p.setCompressionQuality(q);
RandomAccessFile mem = new RandomAccessFile("/dev/mem", "r");
while (true) {
// ★ストライド読み: 全2MBを読まず、出力に必要な行だけ seek+read
for (int oy=0; oy<OH; oy++) {
mem.seek(phys + (long)((oy * H / OH)) * W);
mem.readFully(row, 0, W);
int orow = oy * OW;
for (int ox=0; ox<OW; ox++) sb[orow + ox] = row[cx[ox]]; // 手動ニアレストネイバー
}
// アトミック更新: tmpに書いてrename(配信側が半端なファイルを掴まない)
File ft = new File(out + ".tmp");
ImageOutputStream ios = new FileImageOutputStream(ft);
w.setOutput(ios);
w.write(null, new IIOImage(small, null, null), p);
ios.close();
ft.renameTo(new File(out));
}
}
}
fps最適化の肝:
-
drawImageで縮小せず、必要な行だけ/dev/memから読む(ストライド読み) + 手動で列を間引く。 - 全フレーム(2MB)を読むのをやめ、出力解像度(例 480×270)ぶんの行だけ触ることで 1.58fps → 3.5〜4fps に。
- 出力をいったん
.tmpに書いてrenameすることで、配信側が書きかけJPEGを掴まない。
4. Caddyで配信 → ブラウザでポーリング
配信は ファイルサーバー編 と同じ Caddy(Go静的バイナリ)で /mnt/mmc を配るだけ。
XDG_CONFIG_HOME=/tmp/caddy XDG_DATA_HOME=/tmp/caddy \
/mnt/mmc/caddy file-server --root /mnt/mmc --listen :8080 </dev/null > /mnt/mmc/caddy.log 2>&1 &
表示ページ index.html(live.jpg を連続取得してfps表示):
<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
<style>body{background:#111;margin:0;text-align:center;color:#bbb;font-family:sans-serif}
img{max-width:100%;max-height:90vh}#s{font-size:.85em;padding:4px}</style>
<div id=s>connecting...</div><img id=i>
<script>
let n=0, t=Date.now();
function load(){
let x=new Image();
x.onload=function(){ // 読めた瞬間に表示 → 次を取りに行く
document.getElementById('i').src=x.src; n++;
let fps=(n/((Date.now()-t)/1000)).toFixed(2);
document.getElementById('s').textContent='LIVE frame '+n+' ~'+fps+' fps';
setTimeout(load,120);
};
x.onerror=function(){setTimeout(load,600);};
x.src='/live.jpg?'+Date.now(); // キャッシュ回避のタイムスタンプ
}
load();
</script>
ポイントは img.onload チェーン。1枚読めたら即座に次を要求するので、
サーバ更新レートに自然に追従し、無駄なリクエストも出ません。あとは http://<IP>:8080/ を開くだけ。
5. 起動の組み立て(システムを触らない配慮)
testlauncher.sh のカメラモードでの流れ(要点):
start_camera() {
cp /mnt/mmc/gl2/ld-linux-armhf.so.3 /tmp/ld; chmod +x /tmp/ld # Java用glibcローダ
# 1) 純正 avserver を起動 → ISPがフレームを /dev/mem に生成 (バイナリは無改変)
/opt/ipnc/avserver_q3.bin </dev/null >/tmp/av.log 2>&1 &
# 2) SSHを最優先・重い処理を低優先度に (カメラ稼働中もSSHで入れるように)
renice -20 -p "$(cat /tmp/dropbear.pid)" # SSH最優先
renice 15 -p "$(cat /tmp/av.pid)" # avserver下げる
# 3) Caddy配信 → ISP起動待ち → Java圧縮ループ
"$CADDY" file-server --root /mnt/mmc --listen :8080 </dev/null >/mnt/mmc/caddy.log 2>&1 &
sleep 18
LD_LIBRARY_PATH="$GL2:$JRE/lib/aarch32:$JRE/lib/aarch32/jli:$JRE/lib/aarch32/server" \
"$JRE/bin/java" -Xmx48M -Djava.awt.headless=true -cp /mnt/mmc \
Live 0x4e9ae000 480 270 60 /mnt/mmc/live.jpg 0 </dev/null >/tmp/live.log 2>&1 &
renice 10 -p "$(cat /tmp/live.pid)"
}
-
/opt/ipncの純正バイナリはそのまま使い、フレーム生成だけ担わせる。読むのは/dev/mem。 - 自作物(
Live.class, interpパッチ済みjava)はSDから実行。書き換えが要る箇所はtmpfsに逃がす。 - OSDのタイムスタンプがズレる問題は、起動時に NTP同期して解決(
busybox ntpd -nq -p pool.ntp.org)。
6. ローカルにループ録画させる(ファームウェア解析と改変)
視聴だけでなく、クラウド無しでSDにループ録画もさせたい。純正はアプリ/クラウドの制御下でしか録画しないので、録画パイプラインの本体 avserver を解析しました。
幸運:シンボルが残っていた
avserver_q3.bin はセンサ/ISP/エンコード/P2Pまで全部入りの一枚岩バイナリ。しかも ストリップされておらず約13,000個のシンボルが残っていました。関数名が見えるのでリバースエンジニアリングが一気に楽に。capstone(Python)でThumbコードを逆アセンブルして追っていきます。
コマンド・ディスパッチを特定
内部のIPC処理に、msgid(0xC9系)で分岐する tbh(テーブル分岐ハーフワード)ジャンプテーブル を発見。ケースはおよそ 201〜215。中身を読むと役割が見えてきます:
| msgid | 意味 |
|---|---|
| 202 | ナイトモード切替 |
| 203 | SDカード挿入イベント → ここが録画開始のトリガ |
| 206 | 日時更新 |
変えた部分(バイナリパッチ)
録画をクラウド/アプリの許可に関係なく回すため、バイナリを直接パッチしました:
- 録画パスをスキップしている
cbz(ゼロ比較分岐)ゲートをnopで潰す - ジャンプテーブルを張り替えて、録画側のハンドラへ確実に通す
パッチ済みバイナリは SDカードから実行し、/opt/ipnc の純正 avserver_q3.bin はそのまま残す。走らせなければ完全に元通り=リバーシブルです。
正直な失敗談
録画ファイルを短く区切りたくて、0x1b3f6 にあった movw #0x259(=601) を「これがセグメント長だろう」と当て推量で 20 に書き換えたら…それは長さではありませんでした。avserverが暴走してCPUを食い尽くし、SSHのバナー交換すら通らなくなり、電源再投入以外で復帰不能に。
教訓:確証のない値を勘でパッチしない。相互参照や実挙動で意味を確定してからビットを触ること。
なお最終的な「実時間視聴」は純正avserverが吐いたフレームを /dev/mem から読むだけなので、この録画パッチ自体は必須ではありません。ただ、この解析で**バイナリの“地図”**ができたからこそ、フレームがメモリのどこに出るか当たりを付けて /dev/mem 作戦にたどり着けた——という順番でした。
7. ハマりどころ
| 症状 | 原因 | 対策 |
|---|---|---|
| PCで変換すると数秒/frame | 生データ(2MB)のSSH転送が遅い | カメラ内で先にJPEG圧縮してから配る |
| 色がぐちゃぐちゃ | UVプレーンがタイル状(mosaic) | Y(輝度)だけ使う=グレースケール |
| fpsが上がらない | 毎フレーム全2MB読んでいた | ストライド読み+手動間引き(必要な行だけ) |
| 配信画像が時々割れる | 書きかけJPEGを配信 | tmp+renameでアトミック更新 |
| カメラ稼働中にSSHが固まる | 単コア+ISP割り込みでCPU飽和 | dropbearをrenice -20/最悪は電源再投入 |
| OSDの時刻が2016年 | 時計未同期 | 起動時に NTP同期 |
| Javaが起動しない | uClibcにglibcローダ無し | glibc同梱+interpを/tmp/ldにパッチ(別記事参照) |
| avserver暴走でSSHロック | セグメント長と誤認して勘でパッチ | 確証のない値は触らない/復帰は電源再投入 |
8. まとめ
-
ISPのフレームは物理メモリに居て
/dev/memから読める。ここが入口。 -
実時間化の決め手は 「ネットに生データを流さない、カメラ内で先に圧縮する」。たったこれだけで世界が変わる。
-
圧縮は手近な Java(ImageIO) で十分。ストライド読み+間引きで単コアでも 3.5〜4fps。
-
配信は Caddy、表示は ブラウザの
img.onloadチェーン。どちらも拍子抜けするほど簡単。 -
そして全部 システムファイル無改変・リバーシブルで実現できる。
-
そして視聴も録画も、映像はLANの外に一切出さない。動機だった「クラウド送信が嫌」も同時に解決。
サポート終了で文鎮化しかけていた格安カメラが、**クラウドを完全に排除した“自分のローカルカメラ”**として復活しました。
「中身はLinux+普通のISP」なので、/dev/mem という一点さえ掴めば意外と何とかなります。
海外クラウド前提のIoT機器を、手元で飼い慣らす一例になれば幸いです。
環境: miSafes D304 / InfoTM iMAPx15 (Cortex‑A5, uClibc, RAM~200MB, Linux 3.10) / ARM JRE8 + Caddy