PiKVM標準の拡張仕様を活用してCSIカメラのON/OFF制御機能を統合

Raspberry Pi 3 Model Bをベースに構築したPiKVM環境において、サーバ前面のLEDインジケータや物理スイッチの状態を確認するため、CSI接続のカメラモジュールを追加しました。映像配信デーモンとして ustreamer-csi を導入したところ、映像自体のキャプチャおよび配信は正常に行える状態となりました。

しかし、Raspberry Pi 3はCPU性能および搭載メモリ(約1GB)に制約があります。KVM本来のHDMIキャプチャ処理に加え、CSIカメラの映像ストリーミング(MJPEGエンコード等)を常時バックグラウンドで稼働させ続けると、SoCの発熱や不要なリソース消費を引き起こす要因となります。

目次

広告

常時給電を避けるオンデマンド電源制御の構想

サーバの物理状態を確認する用途において、カメラ映像を常時監視し続ける必要はありません。サーバの電源投入時や障害発生時のステータス確認時など、必要な瞬間だけカメラを起動し、確認が終わり次第停止させるオンデマンド運用が最も合理的です。

これを実現するため、SSH経由でコマンドを手動実行する手間を省き、PiKVMのブラウザ画面上からワンクリックでカメラの起動・停止・ストリーム確認を行える統合インターフェースの構築を計画しました。

待機電力1.5Wへの抑制とアップデート耐性の両立

本実装にあたり、以下の3点を定量的な目標および設計要件として設定しました。

  • 待機時電力の削減: カメラ停止時のシステム全体消費電力をベースラインである 1.5W に抑え、待機時の余分な電力消費をカットすること。
  • 1画面での完結性: ステータス確認(LEDランプ表示)、起動・停止操作、映像ストリームへの遷移を単一のUI上で完結させること。
  • アップデート耐性の維持: PiKVM本体のコード(JavaScriptやHTML)を一切改変せず、標準のプラグイン構造に完全準拠させることで、パッケージ更新(pacman -Syu)による設定消失リスクを排除すること。

画面未表示時の電力消費とドキュメント非公開の拡張仕様

設計を進める中で、ハードウェアとソフトウェアの両面で解決すべき課題が浮き彫りになりました。

画面を開いていなくても発生する電力消費

USBテスターを用いてRaspberry Pi 3の消費電力を実測したところ、以下の結果が得られました。

  • ustreamer-csi 停止時: 約1.5W
  • ustreamer-csi 起動中(ブラウザ未接続): 約2.3〜2.4W
  • 映像ストリーム表示中(8081ポート接続): 約2.9〜3.0W

ブラウザでストリーム画面を開いていなくても、サービスが有効化されているだけでCMOSセンサーへの給電とISP(画像信号プロセッサ)の待機処理により、常時約0.8〜0.9Wの電力が消費され続けていました。発熱や電力ロスを抑えるためには、プロセス自体を停止させる制御が不可欠です。

ドキュメント化されていない拡張仕様と保守性の両立

PiKVMの公式ドキュメントには、自作アプリをトップ画面へ拡張機能として追加するための開発手順が記載されていません。安易にフロントエンド(main.js や index.html)を直接改変する手法をとると、本体アップデート時にファイルが上書きされて初期化されるリスクがあります。

デフォルトのUI

そこで、PiKVMの内部構造を解析したところ、標準で備わっている webterm や vnc と同様の extras プラグイン機構が存在することが判明しました。この仕組みに倣って構築することで、本体コードを一切変更することなく安全に機能を統合する方針をとりました。

PiKVM標準のextras構造を活用したWebコントローラの統合

実装はすべてPiKVM内部で完結します。以下の手順で環境を構成しました。

サービス制御権限(sudoers)の設定

Web管理アプリからパスワードなしで ustreamer-csi の起動・停止・状態確認を行えるよう、専用のsudoers設定を配置します。

rw
vim /etc/sudoers.d/kvmd-camera-api

設定ファイル /etc/sudoers.d/kvmd-camera-api の内容は以下の通りです。

root ALL=(root) NOPASSWD: /usr/bin/systemctl start ustreamer-csi, /usr/bin/systemctl stop ustreamer-csi, /usr/bin/systemctl is-active ustreamer-csi

適切な権限を付与します。

chmod 440 /etc/sudoers.d/kvmd-camera-api

軽量Webコントローラ(Python)の作成

Python標準ライブラリのみを使用し、Web画面の配信とバックエンド制御を単一ファイルで処理する camera_manager.py を作成します。外部ライブラリへの依存はありません。

vim /usr/local/bin/camera_manager.py

ファイル /usr/local/bin/camera_manager.py に以下のコードを記述します。

import http.server
import subprocess
import json

PORT = 8082

HTML_PAGE = """<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>PiKVM Camera Manager</title>
    <img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-wp-preserve="%3Cstyle%3E%0A%20%20%20%20%20%20%20%20body%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20font-family%3A%20-apple-system%2C%20BlinkMacSystemFont%2C%20%22Segoe%20UI%22%2C%20Roboto%2C%20sans-serif%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20background-color%3A%20%231e1e24%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20color%3A%20%23e0e0e0%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20display%3A%20flex%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20justify-content%3A%20center%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20align-items%3A%20center%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20min-height%3A%20100vh%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20margin%3A%200%3B%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20.card%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20background-color%3A%20%232b2b36%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20padding%3A%2030px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20border-radius%3A%2012px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20box-shadow%3A%200%208px%2024px%20rgba(0%2C0%2C0%2C0.4)%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20width%3A%20340px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20text-align%3A%20center%3B%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20h1%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20font-size%3A%2020px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20margin-bottom%3A%2020px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20color%3A%20%23ffffff%3B%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20.status-container%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20display%3A%20flex%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20align-items%3A%20center%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20justify-content%3A%20center%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20gap%3A%2010px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20margin-bottom%3A%2025px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20font-size%3A%2016px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20font-weight%3A%20bold%3B%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20.lamp%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20width%3A%2014px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20height%3A%2014px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20border-radius%3A%2050%25%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20background-color%3A%20%23555%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20box-shadow%3A%200%200%204px%20rgba(0%2C0%2C0%2C0.5)%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20transition%3A%20background-color%200.3s%2C%20box-shadow%200.3s%3B%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20.lamp.active%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20background-color%3A%20%2328a745%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20box-shadow%3A%200%200%2010px%20%2328a745%3B%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20.lamp.inactive%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20background-color%3A%20%23dc3545%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20box-shadow%3A%200%200%2010px%20%23dc3545%3B%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20.btn-group%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20display%3A%20flex%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20gap%3A%2010px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20margin-bottom%3A%2020px%3B%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20button%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20flex%3A%201%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20padding%3A%2012px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20border%3A%20none%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20border-radius%3A%206px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20font-size%3A%2014px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20font-weight%3A%20bold%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20cursor%3A%20pointer%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20transition%3A%20opacity%200.2s%3B%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20button%3Ahover%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20opacity%3A%200.85%3B%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20.btn-on%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20background-color%3A%20%2328a745%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20color%3A%20white%3B%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20.btn-off%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20background-color%3A%20%23dc3545%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20color%3A%20white%3B%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20.btn-stream%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20display%3A%20block%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20width%3A%20100%25%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20box-sizing%3A%20border-box%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20padding%3A%2012px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20background-color%3A%20%23007acc%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20color%3A%20white%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20text-decoration%3A%20none%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20border-radius%3A%206px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20font-weight%3A%20bold%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20font-size%3A%2014px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20transition%3A%20opacity%200.2s%3B%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20.btn-stream%3Ahover%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20opacity%3A%200.85%3B%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%3C%2Fstyle%3E" data-mce-resize="false" data-mce-placeholder="1" class="mce-object mce-object-style" width="20" height="20" alt="&lt;style&gt;" />
</head>
<body>
    <div class="card">
        <h1>PiKVM Camera Manager</h1>
        <div class="status-container">
            <div id="status-lamp" class="lamp"></div>
            <span id="status-text">Checking...</span>
        </div>
        <div class="btn-group">
            <button class="btn-on" onclick="sendAction('start')">Power ON</button>
            <button class="btn-off" onclick="sendAction('stop')">Power OFF</button>
        </div>
        <a id="stream-link" class="btn-stream" href="#" target="_blank">Open Camera Stream</a>
    </div>

    <img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-wp-preserve="%3Cscript%3E%0A%20%20%20%20%20%20%20%20const%20host%20%3D%20window.location.hostname%3B%0A%20%20%20%20%20%20%20%20document.getElementById('stream-link').href%20%3D%20'http%3A%2F%2F'%20%2B%20host%20%2B%20'%3A8081%2F'%3B%0A%0A%20%20%20%20%20%20%20%20function%20updateStatus()%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20fetch('api%2Fstatus')%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20.then(res%20%3D%3E%20res.json())%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20.then(data%20%3D%3E%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20const%20lamp%20%3D%20document.getElementById('status-lamp')%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20const%20text%20%3D%20document.getElementById('status-text')%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20if%20(data.active)%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20lamp.className%20%3D%20'lamp%20active'%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20text.innerText%20%3D%20'Active%20(Streaming)'%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20text.style.color%20%3D%20'%2328a745'%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%20else%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20lamp.className%20%3D%20'lamp%20inactive'%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20text.innerText%20%3D%20'Inactive%20(Stopped)'%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20text.style.color%20%3D%20'%23dc3545'%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D)%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20.catch(()%20%3D%3E%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20const%20text%20%3D%20document.getElementById('status-text')%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20text.innerText%20%3D%20'Connection%20Error'%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20text.style.color%20%3D%20'%23aaa'%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D)%3B%0A%20%20%20%20%20%20%20%20%7D%0A%0A%20%20%20%20%20%20%20%20function%20sendAction(action)%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20fetch('api%2F'%20%2B%20action%2C%20%7B%20method%3A%20'POST'%20%7D)%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20.then(()%20%3D%3E%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20setTimeout(updateStatus%2C%20500)%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D)%3B%0A%20%20%20%20%20%20%20%20%7D%0A%0A%20%20%20%20%20%20%20%20updateStatus()%3B%0A%20%20%20%20%20%20%20%20setInterval(updateStatus%2C%203000)%3B%0A%20%20%20%20%3C%2Fscript%3E" data-mce-resize="false" data-mce-placeholder="1" class="mce-object mce-object-script" width="20" height="20" alt="&lt;script&gt;" />
</body>
</html>
"""

class CameraManagerHandler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/' or self.path == '/index.html':
            self.send_response(200)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.end_headers()
            self.wfile.write(HTML_PAGE.encode('utf-8'))
        elif self.path == '/api/status':
            res = subprocess.run(['sudo', 'systemctl', 'is-active', 'ustreamer-csi'], stdout=subprocess.PIPE, text=True)
            is_active = (res.stdout.strip() == 'active')
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(json.dumps({'active': is_active}).encode('utf-8'))
        else:
            self.send_response(404)
            self.end_headers()

    def do_POST(self):
        if self.path == '/api/start':
            subprocess.run(['sudo', 'systemctl', 'start', 'ustreamer-csi'])
            self.send_response(200)
            self.end_headers()
        elif self.path == '/api/stop':
            subprocess.run(['sudo', 'systemctl', 'stop', 'ustreamer-csi'])
            self.send_response(200)
            self.end_headers()
        else:
            self.send_response(404)
            self.end_headers()

    def log_message(self, format, *args):
        return

if __name__ == '__main__':
    server = http.server.HTTPServer(('0.0.0.0', PORT), CameraManagerHandler)
    server.serve_forever()

実行権限を付与します。

chmod +x /usr/local/bin/camera_manager.py

systemdによるサービス常駐化

スクリプトをOS起動時に自動実行させるため、systemdサービスを定義します。

vim /etc/systemd/system/camera-manager.service

設定ファイル /etc/systemd/system/camera-manager.service の内容は以下の通りです。

[Unit]
Description=PiKVM CSI Camera Manager Web App
After=network.target

[Service]
ExecStart=/usr/bin/python3 /usr/local/bin/camera_manager.py
Restart=always
User=root

[Install]
WantedBy=multi-user.target

サービスを有効化し、起動します。

systemctl daemon-reload
systemctl enable --now camera-manager

PiKVM標準のextrasディレクトリへの配置

PiKVMのプラグインディレクトリ /usr/share/kvmd/extras/camera-manager を作成し、必要な定義ファイルを配置します。

mkdir -p /usr/share/kvmd/extras/camera-manager

メタ情報ファイル(manifest.yaml)

vim /usr/share/kvmd/extras/camera-manager/manifest.yaml
name: Camera Manager
description: CSI Camera Power &amp;amp;amp;amp;amp; Stream Controller
icon: extras/camera-manager/camera.svg
path: extras/camera-manager
daemon: camera-manager
place: 15

専用アイコン(camera.svg)

vim /usr/share/kvmd/extras/camera-manager/camera.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="#888888">
    <path d="M149.1 64.8L138.7 96H64C28.7 96 0 124.7 0 160V416c0 35.3 28.7 64 64 64H448c35.3 64 64 64 64-64V160c0-35.3-28.7-64-64-64H373.3L362.9 64.8C356.4 45.2 338.1 32 317.4 32H194.6c-20.7 0-39 13.2-45.5 32.8zM256 192a96 96 0 1 1 0 192 96 96 0 1 1 0-192z"/>
</svg>

Nginxリバースプロキシ設定(nginx.ctx-server.conf)

SVGアイコンへの静的ファイル配信と、Web管理画面(ポート8082)へのリバースプロキシを定義します。

vim /usr/share/kvmd/extras/camera-manager/nginx.ctx-server.conf
location = /extras/camera-manager/camera.svg {
    alias /usr/share/kvmd/extras/camera-manager/camera.svg;
    include /etc/kvmd/nginx/loc-login.conf;
}

location /extras/camera-manager {
    rewrite ^/extras/camera-manager$ /extras/camera-manager/ permanent;
    proxy_pass http://127.0.0.1:8082/;
    include /etc/kvmd/nginx/loc-proxy.conf;
    include /etc/kvmd/nginx/loc-login.conf;
    include /etc/kvmd/nginx/loc-nocache.conf;
}

反映とファイルシステムの保護

PiKVMデーモンおよびNginxを再起動し、ファイルシステムを読み込み専用に戻します。

systemctl restart kvmd kvmd-nginx
ro

実測電力の低減とUI

以上の構築により、PiKVMのトップ画面に標準のタイルと同等のデザインで「Camera Manager」アイコンが自動描画されるようになりました。

# 状態 消費電力(実測値)
1 ustreamer-csi停止中(OFF) 1.5~1.6W
2 ustreamer-csi起動中(ON) 2.3 〜 2.4 W
3 ストリーム画面表示中 2.9 〜 3.0 W

camera managerを追加

トップ画面のタイルをクリックすると /extras/camera-manager/ の管理パネルが展開されます。「Power ON」を押すとステータスランプが即座に緑色(Active)へ変化し、「Open Camera Stream」から映像ストリーム(ポート8081)の確認が可能です。確認後は「Power OFF」を押すことでサービスが停止し、消費電力をベースラインである1.5Wまで低減させることができます。

PiKVM Camera Manager

PiKVM本体のJavaScriptやHTMLを一切変更していないため、今後のアップデートによる設定消失の懸念がなくなり、Raspberry Pi 3のリソースを保護しながら安全なオンデマンド監視環境を確立できました。

まとめ

Raspberry Pi 3上のPiKVMにCSIカメラを導入した際、画面未表示時でも常時約0.8〜0.9Wの電力を消費する課題がありました。そこでPythonによる軽量Webコントローラを作成し、PiKVM標準の extras 拡張機構とNginxプロキシを介して統合しました。本体コードを改変せず、トップ画面からワンクリックで起動・停止・ストリーム確認を行える環境を構築し、待機電力1.5Wへの抑制と高い保守性を両立しました。