Integrating CSI Camera ON/OFF Control Features Utilizing PiKVM’s Standard Extension Specifications

In our PiKVM environment built on a Raspberry Pi 3 Model B, we added a CSI-connected camera module to check the status of the front LED indicators and physical switches on the server. After introducing ustreamer-csi as the video streaming daemon, video capture and streaming functioned normally.

However, the Raspberry Pi 3 has limitations in CPU performance and available memory (approx. 1GB). In addition to PiKVM’s native HDMI capture processing, keeping the CSI camera’s video streaming (such as MJPEG encoding) running continuously in the background can cause SoC heating and unnecessary resource consumption.

目次

Advertisement

Concept of On-Demand Power Control to Avoid Continuous Powering

For the purpose of checking the server’s physical status, there is no need to monitor the camera feed continuously. An on-demand operation—starting the camera only when needed, such as during server boot-up or status checks when troubleshooting, and stopping it immediately afterwards—is the most logical approach.

To achieve this while eliminating the hassle of manually executing commands via SSH, we planned to build an integrated interface that allows starting, stopping, and checking the camera stream with a single click directly from the PiKVM browser interface.

Achieving Both 1.5W Standby Power Reduction and Update Resilience

For this implementation, we set the following three items as quantitative goals and design requirements:

  • Reduction of Standby Power: Keeping the system-wide power consumption down to the baseline of 1.5W when the camera is stopped, cutting off extra power consumption during standby.
  • Single-Screen Completeness: Completing status checks (LED lamp display), start/stop operations, and navigation to the video stream within a single UI.
  • Maintenance of Update Resilience: Eliminating the risk of configuration loss due to package updates (pacman -Syu) by avoiding any modifications to the PiKVM core code (JavaScript and HTML) and fully complying with the standard plugin structure.

Power Consumption When the Screen Is Not Displayed and Undocumented Extension Specifications

As the design progressed, hardware and software challenges that needed to be addressed became apparent.

Power Consumption Occurring Even When the Screen Is Closed

Using a USB tester to measure the power consumption of the Raspberry Pi 3 yielded the following results:

  • When ustreamer-csi is stopped: Approx. 1.5W
  • When ustreamer-csi is running (browser not connected): Approx. 2.3 to 2.4W
  • While viewing the video stream (connected to port 8081): Approx. 2.9 to 3.0W

Even without the stream window open in a browser, simply having the service enabled continuously consumed about 0.8 to 0.9W of power due to supplying power to the CMOS sensor and standby processing by the ISP (Image Signal Processor). Controlling the process itself to stop is essential to suppress heat generation and power loss.

Balancing Undocumented Extension Specifications with Maintainability

PiKVM’s official documentation does not detail development procedures for adding custom applications as extensions to the top screen. Taking the easy approach of directly modifying the frontend (main.js or index.html) carries the risk of files being overwritten and initialized during core updates.

Default UI

Therefore, upon analyzing PiKVM’s internal structure, we discovered the existence of an extras plugin mechanism similar to standard features like webterm and vnc. By following this pattern, we adopted a policy of integrating the functionality safely without modifying any core code.

Integrating the Web Controller Utilizing PiKVM’s Standard Extras Structure

The implementation is entirely self-contained within PiKVM. We configured the environment using the following steps.

Configuring Service Control Privileges (sudoers)

To allow starting, stopping, and checking the status of ustreamer-csi from the web management app without a password, we deploy a dedicated sudoers configuration.

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

The contents of the configuration file /etc/sudoers.d/kvmd-camera-api are as follows.

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

Grant the appropriate permissions.

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

Creating a Lightweight Web Controller (Python)

We create camera_manager.py using only Python standard libraries to handle web page serving and backend control in a single file. There are no dependencies on external libraries.

vim /usr/local/bin/camera_manager.py

Write the following code into the file /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()

Grant execution permissions.

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

Making the Service Persistent via systemd

To have the script run automatically at OS startup, define a systemd service.

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

The contents of the configuration file /etc/systemd/system/camera-manager.service are as follows.

[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

Enable and start the service.

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

Placement in PiKVM’s Standard Extras Directory

Create the PiKVM plugin directory /usr/share/kvmd/extras/camera-manager and place the necessary definition files.

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

Meta Information File (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

Dedicated Icon (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 Reverse Proxy Configuration (nginx.ctx-server.conf)

Define static file serving for the SVG icon and a reverse proxy to the web management screen (port 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;
}

Applying and Protecting the Filesystem

Restart the PiKVM daemon and Nginx, and return the filesystem to read-only.

systemctl restart kvmd kvmd-nginx
ro

Measured Power Reduction and UI

Through the above setup, the “Camera Manager" icon is now automatically rendered on PiKVM’s top screen with a design matching the standard tiles.

# Status Power Consumption (Measured)
1 ustreamer-csi stopped (OFF) 1.5 to 1.6W
2 ustreamer-csi running (ON) 2.3 to 2.4 W
3 Stream screen displayed 2.9 to 3.0 W

Added camera manager

Clicking the tile on the top screen opens the /extras/camera-manager/ management panel. Pressing “Power ON" immediately changes the status lamp to green (Active), and the video stream (port 8081) can be checked from “Open Camera Stream". After checking, pressing “Power OFF" stops the service, reducing power consumption down to the 1.5W baseline.

PiKVM Camera Manager

Because no changes were made to PiKVM’s core JavaScript or HTML, concerns about configuration loss from future updates are eliminated, establishing a safe on-demand monitoring environment while preserving Raspberry Pi 3 resources.

Conclusion

When introducing a CSI camera to PiKVM on a Raspberry Pi 3, there was an issue where about 0.8 to 0.9W of power was continuously consumed even when the screen was not displayed. To solve this, we created a lightweight Python web controller and integrated it via PiKVM’s standard extras extension mechanism and an Nginx proxy. We built an environment that allows starting, stopping, and stream checking with a single click from the top screen without modifying core code, successfully achieving both standby power suppression down to 1.5W and high maintainability.