今回購入したもの
Nano Pi NEO が2台あって、各1台ずつ使いたかったので2台セットのものを選択。
2026/02/08 現在、1699円(税込・送料込)なので、1個当たり850円…安すぎるだろw
OLEDの接続
配線は I2C だと4本で済むから楽。
SPIだと6、7本必要になる。速いけど。けど、今回は速度は求めてないので I2C で何ら問題なし。
で、ディスプレイ側の端子は以下のようになっていた。
たまに、GND と VCC の位置が逆だったり、SCL と SDA の位置が逆だったりするので現物確認は必須。

VCC と GNC は別から取ることにしたけど、Nano Pi NEO 上の端子からも撮れる。
割と電源周りと信号ラインが固まってるので I2C は使いやすい。

I2C の有効化
配線に間違いがなければ Nano Pi NEO の電源を入れる。
起動したら I2C を有効にするわけだけど、Armbian は ラズパイと同じような armbian-config という設定ツールがあるので、それを実行する。
$ armbian-config
と実行して少し待つと以下の画面が出るので、System を選んで Enter

次は Kernel

さらに次は DT0001 – Manage device tree overlays

有効化したい機能一覧が現れるので、i2c0 にカーソルを移動してスペース。
*マークがつくので TAB を押して Save に移動し、Enter

その後、再起動を促すメッセージが出るので、素直に再起動。
Pythonの仮想環境準備
再起動後、とりあえず、仮想環境用の用意だけしておく。
$ sudo apt install python3.13-venv
$ python -m venv venvDirName
他に必要なもののインストール
ちょっと不要なものもありそうだけど、とりあえず入れてしまえ!
てな感じで書いてるけど、まぁ、重いものでもないので大丈夫でしょう。
そして、仮想環境を activate してから luma のインストールで準備完了。
$ sudo apt install i2c-tools
$ sudo apt install build-essential python3-dev
$ sudo apt install libjpeg-dev zlib1g-dev libfreetype6-dev liblcms2-dev libopenjp2-7-dev libtiff5-dev
$ cd venvDirName
$ source ./bin/activate
(venvDirName)$ ./bin/pip install luma.oled
I2C アドレス確認:落とし穴
販売サイト上の画像を見てみると…

アドレスの初期設定が 0x78 になっているかのように見えるし、現物の基盤にも同じ記載があった。
あった、けど、これ、ウソやねん…
が、まぁ、怒ってはいけない。
こんなのは当たり前。
大事なのは実機で確認すること。
$ sudo i2cdetect -y 0
0 1 2 3 4 5 6 7 8 9 a b c d e f
00: -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- 3c -- -- --
40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
70: -- -- -- -- -- -- -- --
はい、といことで、0x78 ではなく 0x3C を使いましょう。
Hello World
配線OK、必要なもの入った、アドレスもわかったならあとはこんにちはコードでも。
from luma.oled.device import sh1106
from luma.core.interface.serial import i2c
from luma.core.render import canvas
from PIL import ImageFont
import time
# I2Cの設定
serial = i2c(port=0, address=0x3C)
device = sh1106(serial)
# 描画
with canvas(device) as draw:
draw.rectangle(device.bounding_box, outline="white", fill="black")
draw.text((10, 10), "NanoPi NEO", fill="white")
draw.text((10, 30), "Hello World !!", fill="white")
time.sleep(10)
って、これだとちょっと文字が大きすぎるし、サイズは自分で指定したい。
となると以下のようにすると変更できる。
from luma.oled.device import sh1106
from luma.core.interface.serial import i2c
from luma.core.render import canvas
from PIL import ImageFont
import time
# I2Cの設定
serial = i2c(port=0, address=0x3C)
device = sh1106(serial)
# フォント設定
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 10)
except IOError:
# フォントが見つからない場合はデフォルトを使用(サイズ変更不可)
font = ImageFont.load_default()
# 描画
with canvas(device) as draw:
draw.rectangle(device.bounding_box, outline="white", fill="black")
draw.text((10, 10), "NanoPi NEO", font=font, fill="white")
draw.text((10, 30), "Hello World !!", font=font, fill="white")
time.sleep(10)
表示させるだけならここまででOK:以下は蛇足
OLED に映している内容を MediaMTX を使って映像として配信
実機では IP や CPU 温度を取得して OLED に表示、ということをやっているけど、OLED だと実際にその場に見に行かないと確認できない。
離れた場所ではどうしようか…
ということで、その OLED の内容をそのまま動画として配信してしまおう!
いや、無駄だってことはわかってるんですよ。
IPやCPU温度を取得できているなら、それは html にでも書きだして見せるなりした方がリソース的には楽だし。
なんでわざわざ OLED に表示している内容を映像にエンコードしてそれを配信するのか!?
…特に理由はなくて、やってみたかったからw
尚、以前、ラズパイで MediaMTX を動かしていたけど、受配信にパスワードをかけたいとかの設定はそっちの記事をご参照のこと。
以下では、本当にシンプルにローカルで使うときのための設定のみに絞ってる。
MediaMTX のインストールと設定
以下 Git からダウンロード。
mediamtx_v1.16.1_linux_armv7.tar.gz が該当。
※mediaMTX は更新が早いので、バージョン番号は変わってるかも。
とりあえずテストなので任意のディレクトリに解凍する。
今回はホームディレクトリ直下に mediaMtx というのを作ってその中に保存した。
ダウンロードするファイルは使っている SBC に合わせて適宜選択を。
Nano Pi NEO だと下記の通り armv7 が該当のものになる。
$ tar zxfv mediamtx_v1.16.1_linux_armv7.tar.gz
ファイルが3つ解凍されるだけ。シンプル。
続けて設定ファイルを編集する。ファイル名は mediamtx.yml
とりあえず最後にある paths セクションを以下のようにしてみた。
paths:
# example:
# my_camera:
# source: rtsp://my_camera
sysmon:
source: publisher
こんだけ…
OLEDへの描画とFFmpegでの配信コード
ファイル名は何でもいいけど、とりあえず sysmon.py にしてみた。
from luma.oled.device import sh1106
from luma.core.interface.serial import i2c
from luma.core.render import canvas
from luma.core.virtual import viewport
from PIL import Image, ImageDraw, ImageFont
import time, psutil, socket, subprocess, threading
# I2Cの設定
serial = i2c(port=0, address=0x3C)
device = sh1106(serial)
virtual = viewport(device, width=128, height=244)
img_bytes = None
# フォント設定
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
except IOError:
font = ImageFont.load_default()
print("font not found")
class StatusMonitor:
def __init__(self):
# 初期値設定…面倒なので全部 Checking... w
self.pyIP = "Checking..."
self.wiIP = "Checking..."
self.cpuTemp = "Checking..."
self.mem = "Checking..."
self.disk = "Checking..."
self.uptime = "Checking..."
# IPアドレス取得
def get_ip(self, ifname):
addrs = psutil.net_if_addrs()
if ifname in addrs:
for addr in addrs[ifname]:
if addr.family == socket.AF_INET:
return addr.address
return "No IP"
# Uptime取得
def get_uptime(self):
# 起動時間と現在時間の差で uptime を計算
uptime_seconds = time.time() - psutil.boot_time()
# 日、時間、分に直す
days = int(uptime_seconds // (24 * 3600))
hours = int((uptime_seconds % (24 * 3600)) // 3600)
minutes = int((uptime_seconds % 3600) // 60)
if days > 0:
return f"{days}d {hours}h {minutes}m"
else:
return f"{hours}h {minutes}m"
# CPU温度取得
def get_cputemp(self):
temp = open("/sys/class/thermal/thermal_zone0/temp").read()
return float(temp)/1000
# 情報更新メソッド
def update(self):
while True:
self.pyIP = self.get_ip("end0")
self.wiIP = self.get_ip("[WiFi]")
self.cpuTemp = self.get_cputemp()
self.mem = psutil.virtual_memory().percent
self.disk = psutil.disk_usage('/').percent
self.uptime = self.get_uptime()
time.sleep(60)
# 別スレッドで動かすよ
def start(self):
get_thread = threading.Thread(target=self.update, daemon=True)
get_thread.start()
class FFmpegWorker():
def __init__(self):
self.process = None
self.running = True
self.command = [
'ffmpeg',
'-y',
'-f', 'rawvideo',
'-vcodec', 'rawvideo',
'-pix_fmt', 'rgb24',
'-s', '128x64',
'-r', '5', # フレームレート変更したら -g の値も適宜変更 この値の2倍で良いかな
'-i', '-',
'-c:v', 'libx264',
'-pix_fmt', 'yuv420p',
'-preset', 'ultrafast',
'-tune', 'zerolatency',
'-g', '10', # これ入れないと動かなかった…(動画のIフレームのタイミング)
'-f', 'rtsp',
'rtsp://[IP Address]:8554/sysmon'
]
# ffmpegプロセスを開始させるだけのメソッド
def proc_start(self):
return subprocess.Popen(self.command, stdin=subprocess.PIPE)
# フレーム送信
def update(self):
while self.running:
# プロセスチェック:ffmpegが止まってたらいったんプロセス切って5秒後に再起動
if self.process is None or self.process.poll() is not None:
if self.process:
print("5秒後にffmpeg処理開始")
self.process.stdin.close()
time.sleep(5)
try:
self.process = self.proc_start()
except Exception as e:
print(f"ffmpeg処理開始に失敗した: {e}")
time.sleep(10)
continue
# 映像送信
if img_bytes != None:
try:
self.process.stdin.write(img_bytes)
self.process.stdin.flush()
except (BrokenPipeError, OSError):
print("ネットワーク切れた? ffmpeg処理が止まった")
self.process.terminate()
time.sleep(0.2) # command の -r の値を見て、1フレーム当たりの時間を設定
# スレッド開始
def start(self):
ud_thread = threading.Thread(target=self.update, daemon=True)
ud_thread.start()
# リソース開放
def stop(self):
self.process.stdin.close()
self.process.wait()
# 各クラスのインスタンス作成と開始
sm = StatusMonitor()
sm.start()
fe = FFmpegWorker()
fe.start()
# メイン処理
lh = 15 # LineHeight
scy = 32 # スクロール時のY座標
time.sleep(5) # なくてもいいけど、別スレッドで動く情報取得が終わるのに十分な待ち時間とした
try:
while True:
with canvas(virtual) as draw:
draw.text((0, lh * 4 + 5), f"PyIP: {sm.pyIP}", font=font, fill="white")
draw.text((0, lh * 5 + 5), f"WiIP: {sm.wiIP}", font=font, fill="white")
draw.text((0, lh * 6 + 5), f"CPU Temp: {sm.cpuTemp}C", font=font, fill="white")
draw.text((0, lh * 7 + 5), f"MEM: {sm.mem}%", font=font, fill="white")
draw.text((0, lh * 8 + 5), f"Disk: {sm.disk}%", font=font, fill="white")
draw.text((0, lh * 9 + 5), f"Uptime: {sm.uptime}", font=font, fill="white")
# 縦スクロール
scy += 1
if scy > 156: # OLED縦解像度 64 + 表示行数 6 x LineHeight 15 + 余白 4
scy = 0
virtual.set_position((0, scy))
# ffmpeg で MediaMTX に送るための フレーム作成
current_view = virtual._backing_image.crop((0, scy, 128, scy + 64)) # 今の表示内容を切り出し
img_bytes = current_view.convert("RGB").tobytes() # ffmpegが扱えるRGBにしてbytes化
#time.sleep(0.02) # 最初は入れてたけど、なくても同じかも
except KeyboardInterrupt:
fe.stop() # 使用してるリソース開放
print("System monitor stop")
実行してみる
(venvDirName)$ ~/mediaMtx/mediamtx
(venvDirName)$ sudo ./bin/python sysmon.py
これで、OLED に各ステータス表示がされ、さらにその内容が FFmpeg によって映像化されてMediaMTXに送られる。
MediaMTX が映像を受け取ると、それが RTSP や WebRTC 配信される。
なので、同じネットワークにある他の PC で VLC で再生してみたり、ブラウザでアクセスしてみたりすると、OLED に表示されている内容と同じ内容が動画として得られる。
まぁ、Nano Pi NEO の処理能力が低いので、多少の遅延はあるけど、WebRTC なら1秒未満なので割と低遅延と言えそうかな。
VLC は ネットワークキャッシュを 1000ms に設定しているので、WebRTC よりも遅延しているように見えてしまうのは仕方ないw

この記事にコメントしてみる