初步适配windows系统

This commit is contained in:
mofeng-git 2025-02-03 12:55:28 +08:00
parent 4f5daebf93
commit ddb4d752c0
24 changed files with 567 additions and 153 deletions

View File

@ -24,7 +24,7 @@ import asyncio
import threading
import dataclasses
import gpiod
#import gpiod
from . import aiotools
@ -79,46 +79,8 @@ class AioReader: # pylint: disable=too-many-instance-attributes
assert self.__loop
pins = sorted(self.__pins)
with gpiod.request_lines(
self.__path,
consumer=self.__consumer,
config={tuple(pins): gpiod.LineSettings(edge_detection=gpiod.line.Edge.BOTH)},
) as line_req:
line_req.wait_edge_events(0.1)
self.__values = {
pin: _DebouncedValue(
initial=bool(value.value),
debounce=self.__pins[pin].debounce,
notifier=self.__notifier,
loop=self.__loop,
)
for (pin, value) in zip(pins, line_req.get_values(pins))
}
self.__loop.call_soon_threadsafe(self.__notifier.notify)
while not self.__stop_event.is_set():
if line_req.wait_edge_events(1):
new: dict[int, bool] = {}
for event in line_req.read_edge_events():
(pin, value) = self.__parse_event(event)
new[pin] = value
for (pin, value) in new.items():
self.__values[pin].set(value)
else: # Timeout
# XXX: Лимит был актуален для 1.6. Надо проверить, поменялось ли это в 2.x.
# Размер буфера ядра - 16 эвентов на линии. При превышении этого числа,
# новые эвенты потеряются. Это не баг, это фича, как мне объяснили в LKML.
# Штош. Будем с этим жить и синхронизировать состояния при таймауте.
for (pin, value) in zip(pins, line_req.get_values(pins)):
self.__values[pin].set(bool(value.value)) # type: ignore
def __parse_event(self, event: gpiod.EdgeEvent) -> tuple[int, bool]:
if event.event_type == event.Type.RISING_EDGE:
return (event.line_offset, True)
elif event.event_type == event.Type.FALLING_EDGE:
return (event.line_offset, False)
raise RuntimeError(f"Invalid event {event} type: {event.type}")
class _DebouncedValue:

View File

@ -21,6 +21,7 @@
import os
import platform
import signal
import asyncio
import asyncio.subprocess
@ -38,11 +39,16 @@ async def run_process(
env: (dict[str, str] | None)=None,
) -> asyncio.subprocess.Process: # pylint: disable=no-member
if platform.system() != 'Windows':
preexec_fn=os.setpgrp
else:
preexec_fn=None # 或者选择适合 Windows 的其他方式
return (await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=(asyncio.subprocess.DEVNULL if err_to_null else asyncio.subprocess.STDOUT),
preexec_fn=os.setpgrp,
preexec_fn=preexec_fn,
env=env,
))
@ -117,6 +123,6 @@ def rename_process(suffix: str, prefix: str="kvmd") -> None:
def settle(name: str, suffix: str, prefix: str="kvmd") -> logging.Logger:
logger = get_logger(1)
logger.info("Started %s pid=%d", name, os.getpid())
os.setpgrp()
#os.setpgrp()
rename_process(suffix, prefix)
return logger

View File

@ -27,6 +27,7 @@ import ssl
import functools
import types
import typing
import platform
from typing import Callable
from typing import Awaitable
@ -56,8 +57,9 @@ def run(coro: Coroutine, final: (Coroutine | None)=None) -> None:
raise SystemExit()
loop = asyncio.get_event_loop()
loop.add_signal_handler(signal.SIGINT, sigint_handler)
loop.add_signal_handler(signal.SIGTERM, sigterm_handler)
if platform.system() != 'Windows':
loop.add_signal_handler(signal.SIGINT, sigint_handler)
loop.add_signal_handler(signal.SIGTERM, sigterm_handler)
main_task = loop.create_task(coro)
try:

View File

@ -181,12 +181,13 @@ def _init_config(config_path: str, override_options: list[str], **load_flags: bo
_patch_raw(raw_config)
config = make_config(raw_config, scheme)
if _patch_dynamic(raw_config, config, scheme, **load_flags):
config = make_config(raw_config, scheme)
return config
except (ConfigError, UnknownPluginError) as ex:
raise SystemExit(f"ConfigError: {ex}")
if _patch_dynamic(raw_config, config, scheme, **load_flags):
config = make_config(raw_config, scheme)
return config
def _patch_raw(raw_config: dict) -> None: # pylint: disable=too-many-branches

View File

@ -23,6 +23,7 @@
import os
import re
import asyncio
import sys
from typing import AsyncGenerator
@ -51,7 +52,7 @@ class ExtrasInfoSubmanager(BaseInfoSubmanager):
sui = sysunit.SystemdUnitInfo()
await sui.open()
except Exception as ex:
if not os.path.exists("/etc/kvmd/.docker_flag"):
if not os.path.exists("/etc/kvmd/.docker_flag") or not sys.platform.startswith('linux'):
get_logger(0).error("Can't open systemd bus to get extras state: %s", tools.efmt(ex))
sui = None
try:

View File

@ -169,7 +169,7 @@ class HwInfoSubmanager(BaseInfoSubmanager):
+ (st.steal + st.guest) / total * 100
)
except Exception as ex:
get_logger(0).error("Can't get CPU percent: %s", ex)
#get_logger(0).error("Can't get CPU percent: %s", ex)
return None
async def __get_mem(self) -> dict:
@ -218,7 +218,7 @@ class HwInfoSubmanager(BaseInfoSubmanager):
async def __parse_vcgencmd(self, arg: str, parser: Callable[[str], _RetvalT]) -> (_RetvalT | None):
cmd = [*self.__vcgencmd_cmd, arg]
try:
text = (await aioproc.read_process(cmd, err_to_null=True))[1]
text = "throttled=0x0"
except Exception:
get_logger(0).exception("Error while executing: %s", tools.cmdfmt(cmd))
return None

View File

@ -76,12 +76,14 @@ class SystemInfoSubmanager(BaseInfoSubmanager):
except Exception:
get_logger(0).exception("Can't get streamer info")
else:
try:
for line in features_text.split("\n"):
(status, name) = map(str.strip, line.split(" "))
features[name] = (status == "+")
except Exception:
get_logger(0).exception("Can't parse streamer features")
#try:
# print(features_text)
# for line in features_text.split("\n"):
# (status, name) = map(str.strip, line.split(" "))
# features[name] = (status == "+")
#except Exception:
# get_logger(0).exception("Can't parse streamer features")
pass
return {
"app": os.path.basename(path),
"version": version,

View File

@ -78,7 +78,7 @@ def _load_libtesseract() -> (ctypes.CDLL | None):
setattr(func, "argtypes", argtypes)
return lib
except Exception as ex:
warnings.warn(f"Can't load libtesseract: {ex}", RuntimeWarning)
#warnings.warn(f"Can't load libtesseract: {ex}", RuntimeWarning)
return None

View File

@ -38,8 +38,10 @@ from aiohttp.web import StreamResponse
import os
from aiohttp import ClientConnectionError
from aiohttp import ClientPayloadError
from aiohttp import ClientSession
from aiohttp import UnixConnector
from aiohttp import ClientTimeout
from urllib.parse import urlencode
@ -329,9 +331,11 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
socket_path = self.__streamer.get_path()
query_string = urlencode(request.query)
headers = request.headers.copy()
time_out = ClientTimeout(total=10)
try:
async with ClientSession(connector=UnixConnector(path=socket_path)) as session:
backend_url = f'http://localhost/stream?{query_string}' if query_string else 'http://localhost/stream'
#async with ClientSession(connector=UnixConnector(path=socket_path)) as session:
async with ClientSession() as session:
backend_url = f'http://localhost:8000/stream?{query_string}' if query_string else 'http://localhost:8000/stream'
async with session.get(backend_url, headers=headers) as resp:
response = StreamResponse(status=resp.status, reason=resp.reason, headers=resp.headers)
await response.prepare(request)
@ -341,7 +345,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
break
await response.write(chunk)
return response
except ClientConnectionError:
except (ClientConnectionError, ClientPayloadError, ConnectionResetError):
return Response(status=500, text="Client connection was closed")

View File

@ -20,6 +20,7 @@
# ========================================================================== #
import platform
import signal
import asyncio
import asyncio.subprocess
@ -302,7 +303,8 @@ class Streamer: # pylint: disable=too-many-instance-attributes
self.__notifier.notify(self.__ST_STREAMER)
get_logger(0).info("Installing SIGUSR2 streamer handler ...")
asyncio.get_event_loop().add_signal_handler(signal.SIGUSR2, signal_handler)
if platform.system() != 'Windows':
asyncio.get_event_loop().add_signal_handler(signal.SIGUSR2, signal_handler)
prev: dict = {}
while True:
@ -338,7 +340,7 @@ class Streamer: # pylint: disable=too-many-instance-attributes
session = self.__ensure_client_session()
try:
return (await session.get_state())
except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError):
except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError,TimeoutError,asyncio.CancelledError,asyncio.TimeoutError,asyncio.CancelledError):
pass
except Exception:
get_logger().exception("Invalid streamer response from /state")

View File

@ -75,12 +75,12 @@ class BaseHttpClient:
def _make_http_session(self, headers: dict[str, str] | None = None) -> aiohttp.ClientSession:
connector = None
#这里临时使用 socket ,后期考虑是否使用 http 方式
use_unix_socket = True
use_unix_socket = False
if use_unix_socket:
connector = aiohttp.UnixConnector(path=self.__unix_path)
base_url = "http://localhost:0" # 继续使用 Unix 域套接字
else:
base_url = "http://127.0.0.1:8001" # 使用指定的 IP 和端口
base_url = "http://127.0.0.1:8000" # 使用指定的 IP 和端口
#print("base_url:", base_url)
return aiohttp.ClientSession(

View File

@ -32,7 +32,7 @@ from typing import Generator
from typing import AsyncGenerator
import aiohttp
import ustreamer
#import ustreamer
from PIL import Image as PilImage

View File

@ -30,29 +30,8 @@ from .mappings import WebModifiers
# =====
def _load_libxkbcommon() -> ctypes.CDLL:
path = ctypes.util.find_library("xkbcommon")
if not path:
raise RuntimeError("Where is libxkbcommon?")
assert path
lib = ctypes.CDLL(path)
for (name, restype, argtypes) in [
("xkb_utf32_to_keysym", ctypes.c_uint32, [ctypes.c_uint32]),
]:
func = getattr(lib, name)
if not func:
raise RuntimeError(f"Where is libc.{name}?")
setattr(func, "restype", restype)
setattr(func, "argtypes", argtypes)
return lib
_libxkbcommon = _load_libxkbcommon()
def _ch_to_keysym(ch: str) -> int:
assert len(ch) == 1
return _libxkbcommon.xkb_utf32_to_keysym(ord(ch))
# =====
@ -84,10 +63,6 @@ def text_to_web_keys( # pylint: disable=too-many-branches
ch = "--"
if not ch.isprintable():
continue
try:
keys = symmap[_ch_to_keysym(ch)]
except Exception:
continue
for (modifiers, key) in keys.items():
if modifiers & SymmapModifiers.CTRL:

View File

@ -34,34 +34,4 @@ from ctypes import c_void_p
# =====
def _load_libc() -> ctypes.CDLL:
path = ctypes.util.find_library("c")
if not path:
raise RuntimeError("Where is libc?")
assert path
lib = ctypes.CDLL(path)
for (name, restype, argtypes) in [
("inotify_init", c_int, []),
("inotify_add_watch", c_int, [c_int, c_char_p, c_uint32]),
("inotify_rm_watch", c_int, [c_int, c_uint32]),
("renameat2", c_int, [c_int, c_char_p, c_int, c_char_p, c_uint]),
("free", c_int, [c_void_p]),
]:
func = getattr(lib, name)
if not func:
raise RuntimeError(f"Where is libc.{name}?")
setattr(func, "restype", restype)
setattr(func, "argtypes", argtypes)
return lib
_libc = _load_libc()
# =====
get_errno = ctypes.get_errno
inotify_init = _libc.inotify_init
inotify_add_watch = _libc.inotify_add_watch
inotify_rm_watch = _libc.inotify_rm_watch
renameat2 = _libc.renameat2
free = _libc.free
pass

View File

@ -52,8 +52,8 @@ def get_plugin_class(sub: str, name: str) -> type[BasePlugin]:
assert name
if name.startswith("_"):
raise UnknownPluginError(f"Unknown plugin '{sub}/{name}'")
try:
module = importlib.import_module(f"kvmd.plugins.{sub}.{name}")
except ModuleNotFoundError:
raise UnknownPluginError(f"Unknown plugin '{sub}/{name}'")
#try:
module = importlib.import_module(f"kvmd.plugins.{sub}.{name}")
#except ModuleNotFoundError:
#raise UnknownPluginError(f"Unknown plugin '{sub}/{name}'")
return getattr(module, "Plugin")

View File

@ -200,6 +200,7 @@ class Plugin(BaseHid, multiprocessing.Process): # pylint: disable=too-many-inst
while not self.__stop_event.is_set():
try:
self.__hid_loop()
time.sleep(1)
except Exception:
logger.exception("Unexpected error in the run loop")
time.sleep(1)
@ -222,6 +223,10 @@ class Plugin(BaseHid, multiprocessing.Process): # pylint: disable=too-many-inst
self.__process_cmd(conn, b"")
else:
self.__process_cmd(conn, cmd)
except KeyboardInterrupt:
get_logger(0).info("KeyboardInterrupt received, exiting HID loop.")
self.clear_events()
break
except Exception:
self.clear_events()
get_logger(0).exception("Unexpected error in the HID loop")

View File

@ -23,7 +23,7 @@
from typing import Callable
from typing import Any
import gpiod
#import gpiod
from ... import aiotools
from ... import aiogp

View File

@ -26,7 +26,7 @@ kvmd:
hid:
type: ch9329
device: /dev/ttyUSB0
device: COM7
speed: 115200
read_timeout: 0.3
@ -53,7 +53,7 @@ kvmd:
streamer:
resolution:
default: 1920x1080
default: 1280x720
forever: true
@ -64,32 +64,30 @@ kvmd:
h264_bitrate:
default: 8000
cmd:
- "kvmd_data/usr/bin/ustreamer"
- "--device=/dev/video0"
- "--persistent"
- "--format=mjpeg"
- "--resolution={resolution}"
- "--desired-fps={desired_fps}"
- "--drop-same-frames=30"
- "--last-as-blank=0"
- "--unix={unix}"
- "--unix-rm"
- "--unix-mode=777"
- "--exit-on-parent-death"
- "--notify-parent"
- "--no-log-colors"
- "--jpeg-sink=kvmd::ustreamer::jpeg"
- "--jpeg-sink-mode=0660"
- "--slowdown"
pre_start_cmd: kvmd_data/win/true.exe
post_stop_cmd: kvmd_data/win/true.exe
unix: kvmd_data/run/kvmd/ustreamer.sock
cmd:
- "C:/Users/mofen/miniconda3/python.exe"
- "ustreamer-win/ustreamer-win.py"
- "--device=0"
- "--resolution={resolution}"
- "--fps={desired_fps}"
- "--quality=100"
unix: http://localhost:8000
ipmi:
auth:
file: kvmd_data/etc/kvmd/ipmipasswd
pst:
remount_cmd:
- "kvmd_data/win/true.exe"
vnc:
keymap: kvmd_data/usr/share/kvmd/keymaps/en-us
mouse_output: usb
@ -110,10 +108,20 @@ vnc:
otgnet:
commands:
pre_start_cmd:
- "kvmd_data/win/true.exe"
post_stop_cmd:
- "kvmd_data/win/true.exe"
post_start_cmd:
- "/bin/true"
- "kvmd_data/win/true.exe"
pre_stop_cmd:
- "/bin/true"
- "kvmd_data/win/true.exe"
iface:
ip_cmd:
- "kvmd_data/win/true.exe"
firewall:
iptables_cmd:
- "kvmd_data/win/true.exe"
nginx:
http:
@ -123,4 +131,4 @@ nginx:
janus:
cmd:
- "/bin/true"
- "kvmd_data/win/true.exe"

View File

@ -899,7 +899,7 @@
</div>
<div id="stream-info"></div>
<button class="window-button-exit-full-tab">&#9660;</button>
<div class="stream-box-offline" id="stream-box"><img id="stream-image" src="/share/png/blank-stream.png">
<div class="stream-box-online" id="stream-box"><img id="stream-image" src="/share/png/blank-stream.png">
<video class="hidden" id="stream-video" disablePictureInPicture="true" autoplay playsinline muted></video>
<div id="stream-fullscreen-active"></div>
</div>

View File

@ -13,7 +13,7 @@ div(id="stream-window" class="window window-resizable")
div(id="stream-info")
button(class="window-button-exit-full-tab") &#9660;
div(id="stream-box" class="stream-box-offline")
div(id="stream-box" class="stream-box-online")
img(id="stream-image" src=`${png_dir}/blank-stream.png`)
video(id="stream-video" class="hidden" disablePictureInPicture="true" autoplay playsinline muted)
div(id="stream-fullscreen-active")

BIN
kvmd_data/win/true.exe Normal file

Binary file not shown.

46
tools/list_devices.py Normal file
View File

@ -0,0 +1,46 @@
import cv2
import serial.tools.list_ports
import os
import sys
# 隐藏 OpenCV 的错误输出
def suppress_opencv_warnings():
#cv2.utils.logging.setLogLevel(cv2.utils.logging.LOG_LEVEL_SILENT)
pass
def list_video_devices():
"""列出可用的视频设备及其名称"""
video_devices = []
for i in range(10): # 假设最多有10个视频设备
cap = cv2.VideoCapture(i)
if cap.isOpened():
device_name = cap.getBackendName() # 获取设备名称
video_devices.append((i, device_name))
cap.release()
return video_devices
def list_serial_ports():
"""列出可用的串口设备"""
return [port.device for port in serial.tools.list_ports.comports()]
def main():
suppress_opencv_warnings() # 调用函数以隐藏 OpenCV 的错误输出
print("可用的视频设备索引及名称:")
video_devices = list_video_devices()
if video_devices:
for index, name in video_devices:
print(f"视频设备索引: {index}, 名称: {name}")
else:
print("未找到视频设备。")
print("\n可用的串口设备:")
serial_ports = list_serial_ports()
if serial_ports:
for port in serial_ports:
print(f"串口设备: {port}")
else:
print("未找到串口设备。")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,233 @@
import asyncio
import threading
import time
import json
from collections import deque
from typing import List, Optional, Tuple, Union, Dict, Any
import aiohttp
import cv2
import logging
import numpy as np
from aiohttp import MultipartWriter, web
from aiohttp.web_runner import GracefulExit
class MjpegStream:
"""MJPEG video stream class for handling video frames and providing HTTP streaming service"""
def __init__(
self,
name: str,
size: Optional[Tuple[int, int]] = None,
quality: int = 50,
fps: int = 30,
host: str = "localhost",
port: int = 8000,
device_name: str = "Unknown Camera",
log_requests: bool = True
) -> None:
"""
Initialize MJPEG stream
Args:
name: Stream name
size: Video size (width, height)
quality: JPEG compression quality (1-100)
fps: Target frame rate
host: Server host address
port: Server port
device_name: Camera device name
log_requests: Whether to log stream requests
"""
self.name = name.lower().replace(" ", "_")
self.size = size
self.quality = max(1, min(quality, 100))
self.fps = fps
self._host = host
self._port = port
self._device_name = device_name
self.log_requests = log_requests
# Video frame and synchronization
self._frame = np.zeros((320, 240, 1), dtype=np.uint8)
self._lock = asyncio.Lock()
self._byte_frame_window = deque(maxlen=30)
self._bandwidth_last_modified_time = time.time()
self._is_online = True
self._last_frame_time = time.time()
# 设置日志级别为ERROR以隐藏HTTP请求日志
if not self.log_requests:
logging.getLogger('aiohttp.access').setLevel(logging.ERROR)
# Server setup
self._app = web.Application()
self._app.router.add_route("GET", f"/{self.name}", self._stream_handler)
self._app.router.add_route("GET", "/state", self._state_handler)
self._app.router.add_route("GET", "/", self._index_handler)
self._app.is_running = False
def set_frame(self, frame: np.ndarray) -> None:
"""Set the current video frame"""
self._frame = frame
self._last_frame_time = time.time()
self._is_online = True
def get_bandwidth(self) -> float:
"""Get current bandwidth usage (bytes/second)"""
if time.time() - self._bandwidth_last_modified_time >= 1:
self._byte_frame_window.clear()
return sum(self._byte_frame_window)
async def _process_frame(self) -> Tuple[np.ndarray, Dict[str, str]]:
"""Process video frame (resize and JPEG encode)"""
frame = cv2.resize(
self._frame, self.size or (self._frame.shape[1], self._frame.shape[0])
)
success, encoded = cv2.imencode(
".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, self.quality]
)
if not success:
raise ValueError("Error encoding frame")
self._byte_frame_window.append(len(encoded.tobytes()))
self._bandwidth_last_modified_time = time.time()
# Add KVMD-compatible header information
headers = {
"X-UStreamer-Online": str(self._is_online).lower(),
"X-UStreamer-Width": str(frame.shape[1]),
"X-UStreamer-Height": str(frame.shape[0]),
"X-UStreamer-Name": self._device_name,
"X-Timestamp": str(int(time.time() * 1000)),
"Cache-Control": "no-store",
"Pragma": "no-cache",
"Expires": "0",
}
return encoded, headers
async def _stream_handler(self, request: web.Request) -> web.StreamResponse:
"""Handle MJPEG stream requests"""
response = web.StreamResponse(
status=200,
reason="OK",
headers={"Content-Type": "multipart/x-mixed-replace;boundary=frame"}
)
await response.prepare(request)
if self.log_requests:
print(f"Stream request received: {request.path}")
while True:
await asyncio.sleep(1 / self.fps)
# Check if the device is online
if time.time() - self._last_frame_time > 5:
self._is_online = False
async with self._lock:
frame, headers = await self._process_frame()
with MultipartWriter("image/jpeg", boundary="frame") as mpwriter:
part = mpwriter.append(frame.tobytes(), {"Content-Type": "image/jpeg"})
for key, value in headers.items():
part.headers[key] = value
try:
await mpwriter.write(response, close_boundary=False)
except (ConnectionResetError, ConnectionAbortedError):
return web.Response(status=499)
await response.write(b"\r\n")
async def _state_handler(self, request: web.Request) -> web.Response:
"""Handle /state requests and return device status information"""
state = {
"result": {
"instance_id": "",
"encoder": {
"type": "CPU",
"quality": self.quality
},
"h264": {
"bitrate": 4875,
"gop": 60,
"online": self._is_online,
"fps": self.fps
},
"sinks": {
"jpeg": {
"has_clients": False
},
"h264": {
"has_clients": False
}
},
"source": {
"resolution": {
"width": self.size[0] if self.size else self._frame.shape[1],
"height": self.size[1] if self.size else self._frame.shape[0]
},
"online": self._is_online,
"desired_fps": self.fps,
"captured_fps": 0 # You can update this with actual captured fps if needed
},
"stream": {
"queued_fps": 2, # Placeholder value, update as needed
"clients": 1, # Placeholder value, update as needed
"clients_stat": {
"70bf63a507f71e47": {
"fps": 2, # Placeholder value, update as needed
"extra_headers": False,
"advance_headers": True,
"dual_final_frames": False,
"zero_data": False,
"key": "tIR9TtuedKIzDYZa" # Placeholder key, update as needed
}
}
}
}
}
return web.Response(
text=json.dumps(state),
content_type="application/json"
)
async def _index_handler(self, _: web.Request) -> web.Response:
"""Handle root path requests and display available streams"""
html = f"""
<h2>Available Video Streams:</h2>
<ul>
<li><a href='http://{self._host}:{self._port}/{self.name}'>/{self.name}</a></li>
<li><a href='http://{self._host}:{self._port}/state'>/state</a></li>
</ul>
"""
return web.Response(text=html, content_type="text/html")
def start(self) -> None:
"""Start the stream server"""
if not self._app.is_running:
threading.Thread(target=self._run_server, daemon=True).start()
self._app.is_running = True
print(f"\nVideo stream URL: http://{self._host}:{self._port}/{self.name}")
else:
print("\nServer is already running\n")
def stop(self) -> None:
"""Stop the stream server"""
if self._app.is_running:
self._app.is_running = False
print("\nStopping server...\n")
raise GracefulExit()
print("\nServer is not running\n")
def _run_server(self) -> None:
"""Run the server in a new thread"""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
runner = web.AppRunner(self._app)
loop.run_until_complete(runner.setup())
site = web.TCPSite(runner, self._host, self._port)
loop.run_until_complete(site.start())
loop.run_forever()

View File

@ -0,0 +1,197 @@
import argparse
import cv2
import logging
import platform
from mjpeg_stream import MjpegStream
def configure_logging():
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
return logging.getLogger(__name__)
def get_windows_cameras(logger):
"""Retrieve available camera devices on Windows system"""
from win32com.client import Dispatch
devices = []
try:
wmi = Dispatch("WbemScripting.SWbemLocator")
service = wmi.ConnectServer(".", "root\\cimv2")
items = service.ExecQuery("SELECT * FROM Win32_PnPEntity WHERE (PNPClass = 'Image' OR PNPClass = 'Camera')")
for item in items:
devices.append({
'name': item.Name,
'device_id': item.DeviceID
})
logger.info(f"Found camera device: {item.Name}")
except Exception as e:
logger.error(f"Error enumerating camera devices: {str(e)}")
return devices
def test_camera(index, logger):
"""Test if the camera is available"""
try:
cap = cv2.VideoCapture(index, cv2.CAP_DSHOW if platform.system() == "Windows" else cv2.CAP_ANY)
if cap.isOpened():
ret, _ = cap.read()
cap.release()
return ret
except Exception as e:
logger.debug(f"Error testing camera {index}: {str(e)}")
return False
def find_camera_by_name(camera_name, logger):
"""Find device index by camera name"""
if platform.system() != "Windows":
logger.warning("Finding camera by name is only supported on Windows")
return None
devices = get_windows_cameras(logger)
for device in devices:
if camera_name.lower() in device['name'].lower():
# Try to find an available index
for i in range(5): # Usually no more than 5 devices
if test_camera(i, logger):
logger.info(f"Found matching camera '{device['name']}' at index {i}")
return i
return None
def get_first_available_camera(logger):
"""Get the first available camera"""
for i in range(5):
if test_camera(i, logger):
return i
return None
def parse_arguments():
parser = argparse.ArgumentParser(description='MJPEG Stream Demonstration')
device_group = parser.add_mutually_exclusive_group()
device_group.add_argument('--device', type=int, help='Camera device index')
device_group.add_argument('--device-name', type=str, help='Camera device name (only supported on Windows)')
parser.add_argument('--resolution', type=str, default='640x480', help='Video resolution (e.g., 640x480)')
parser.add_argument('--quality', type=int, default=100, help='JPEG quality (1-100)')
parser.add_argument('--fps', type=int, default=30, help='Target FPS')
parser.add_argument('--host', type=str, default='localhost', help='Server address')
parser.add_argument('--port', type=int, default=8000, help='Server port')
args = parser.parse_args()
# Validate arguments
if args.quality < 1 or args.quality > 100:
raise ValueError("Quality must be between 1 and 100.")
if args.fps <= 0:
raise ValueError("FPS must be greater than 0.")
# Parse resolution
try:
width, height = map(int, args.resolution.split('x'))
except ValueError:
raise ValueError("Resolution must be in the format WIDTHxHEIGHT (e.g., 640x480).")
args.width = width
args.height = height
return args
def main():
logger = configure_logging()
args = parse_arguments()
# Determine which camera device to use
device_index = None
if args.device_name:
if platform.system() != "Windows":
logger.error("Specifying camera by name is only supported on Windows")
return
device_index = find_camera_by_name(args.device_name, logger)
if device_index is None:
logger.error(f"No available camera found with a name containing '{args.device_name}'")
return
elif args.device is not None:
if test_camera(args.device, logger):
device_index = args.device
else:
logger.warning(f"The specified device index {args.device} is not available")
if device_index is None:
device_index = get_first_available_camera(logger)
if device_index is None:
logger.error("No available camera devices were found")
return
logger.info(f"Using the first available camera device (index: {device_index})")
# Initialize the camera
try:
cap = cv2.VideoCapture(device_index, cv2.CAP_DSHOW if platform.system() == "Windows" else cv2.CAP_ANY)
if not cap.isOpened():
logger.error(f"Unable to open camera {device_index}")
return
# Set camera parameters
cap.set(cv2.CAP_PROP_FRAME_WIDTH, args.width)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, args.height)
# Verify camera settings
actual_width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
actual_height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
if actual_width != args.width or actual_height != args.height:
logger.warning(f"Actual resolution ({actual_width}x{actual_height}) does not match requested resolution ({args.width}x{args.height})")
# Test if we can read frames
ret, _ = cap.read()
if not ret:
logger.error("Unable to read video frames from the camera")
cap.release()
return
except Exception as e:
logger.error(f"Error initializing the camera: {str(e)}")
if 'cap' in locals():
cap.release()
return
# Create and start the video stream
try:
stream = MjpegStream(
name="stream",
size=(int(actual_width), int(actual_height)), # Use actual resolution
quality=args.quality,
fps=args.fps,
host=args.host,
port=args.port,
device_name=args.device_name or f"Camera {device_index}", # Add device name
log_requests=False # 设置为False以隐藏HTTP请求日志
)
stream.start()
logger.info(f"Video stream started: http://{args.host}:{args.port}/stream")
while True:
ret, frame = cap.read()
if not ret:
logger.error("Unable to read video frames")
break
stream.set_frame(frame)
except KeyboardInterrupt:
logger.info("User interrupt")
except Exception as e:
logger.error(f"An error occurred: {str(e)}")
finally:
logger.info("Cleaning up resources...")
try:
stream.stop()
except Exception as e:
logger.error(f"Error stopping the video stream: {str(e)}")
try:
cap.release()
except Exception as e:
logger.error(f"Error releasing the camera: {str(e)}")
cv2.destroyAllWindows()
logger.info("Program has exited")
if __name__ == "__main__":
main()