mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 11:42:02 +08:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eac31f69f | ||
|
|
9653e16a68 | ||
|
|
d0c0852fbb | ||
|
|
c0a0c90cbd | ||
|
|
9e3483b836 | ||
|
|
132f445c29 | ||
|
|
4952cbaf19 | ||
|
|
099f0b1ca2 | ||
|
|
eecbc0fc13 | ||
|
|
2d81a071e5 | ||
|
|
3e35181583 | ||
|
|
c3a3f41a2c | ||
|
|
2b2b471cfb | ||
|
|
abb319068b | ||
|
|
51d7d8b8be | ||
|
|
f95714d9f0 | ||
|
|
7d52b2e2ea | ||
|
|
a2a8b3802d | ||
|
|
f4283f45a4 | ||
|
|
4784cb75e4 | ||
|
|
abc6bd1677 | ||
|
|
1c5288d783 | ||
|
|
6bcb54bd22 | ||
|
|
e20136a5ab | ||
|
|
c8fd3648ad | ||
|
|
6ef2d394d9 | ||
|
|
762a3b037d | ||
|
|
e09a906f93 | ||
|
|
95bf1a852e | ||
|
|
200f947b5d | ||
|
|
46ae0c81e2 | ||
|
|
779aa180ad | ||
|
|
ae26e3c863 | ||
|
|
eeb41159b7 | ||
|
|
24a10aa222 | ||
|
|
c119db4908 | ||
|
|
0db287bf55 | ||
|
|
e229f35777 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -30,6 +30,7 @@ Thumbs.db
|
||||
|
||||
# Build artifacts
|
||||
/dist/
|
||||
/build-staging
|
||||
|
||||
# Frontend (built files)
|
||||
/web/node_modules/
|
||||
@@ -41,3 +42,4 @@ CLAUDE.md
|
||||
secrets.toml
|
||||
.env
|
||||
/docs/
|
||||
web/package-lock.json
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "one-kvm"
|
||||
version = "0.1.6"
|
||||
version = "0.1.9"
|
||||
edition = "2021"
|
||||
authors = ["SilentWind"]
|
||||
description = "A open and lightweight IP-KVM solution written in Rust"
|
||||
|
||||
235
README.en.md
Normal file
235
README.en.md
Normal file
@@ -0,0 +1,235 @@
|
||||
<div align="center">
|
||||
<h1>One-KVM</h1>
|
||||
<p><strong>An open, lightweight IP-KVM stack in Rust — remote management down to BIOS level</strong></p>
|
||||
|
||||
<p><a href="README.md">简体中文</a> · <a href="README.en.md">English</a></p>
|
||||
|
||||
[](https://github.com/mofeng-git/One-KVM/releases)
|
||||
[](https://github.com/mofeng-git/One-KVM/stargazers)
|
||||
[](https://github.com/mofeng-git/One-KVM/network/members)
|
||||
[](https://github.com/mofeng-git/One-KVM/issues)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**One-KVM (Rust)** is a lightweight IP-KVM solution written in Rust. It lets you manage servers and workstations over the network, including at BIOS level.
|
||||
|
||||
Goals: an open, lightweight, easy-to-use IP-KVM stack.
|
||||
|
||||
- **Open**: not tied to one hardware recipe; runs across many setups.
|
||||
- **Lightweight**: shipped as a binary with minimal moving parts for deployment.
|
||||
- **Easy to use**: no hand-edited config files required; settings are done in the web UI.
|
||||
|
||||
> **One-KVM (Python)** is no longer maintained. If you still need it, see <https://github.com/mofeng-git/One-KVM/tree/python>.
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## Features
|
||||
|
||||
### Core
|
||||
|
||||
| Area | Capabilities |
|
||||
|------|----------------|
|
||||
| Video capture | HDMI USB / MIPI CSI / RK3588 HDMI IN; MJPEG and WebRTC (H.264 / H.265 / VP8 / VP9) |
|
||||
| Video encoding | VAAPI / QSV / RKMPP / V4L2 M2M hardware paths, with software fallback |
|
||||
| Keyboard & mouse | USB OTG HID or CH340 + CH9329 HID; absolute / relative mouse |
|
||||
| Virtual media | USB mass storage; ISO/IMG mount and Ventoy-style virtual USB |
|
||||
| ATX power | GPIO or USB relay; power and reset control |
|
||||
| Audio | ALSA capture + Opus (HTTP / WebRTC) |
|
||||
|
||||
The web UI supports visual configuration and Chinese/English locales. Built-ins include a web terminal (ttyd), intranet tunnel (gostc), P2P (EasyTier), RustDesk protocol (optional cross-platform remote access), and RTSP streaming.
|
||||
|
||||
## Installation
|
||||
|
||||
Release artifacts are on [GitHub Releases](https://github.com/mofeng-git/One-KVM/releases). Below are short paths for common setups. For **system requirements, hardware, Docker env vars, USB OTG**, and full troubleshooting, see the [One-KVM documentation](https://docs.one-kvm.cn/) (Chinese; use a translator if needed).
|
||||
|
||||
### Debian / Ubuntu (.deb)
|
||||
|
||||
Download a `one-kvm_*.deb` matching your CPU architecture from Releases, then from the directory containing the package:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install ./one-kvm_0.x.x_<arch>.deb
|
||||
```
|
||||
|
||||
Replace the version and architecture in the filename with your actual file name.
|
||||
|
||||
### Docker
|
||||
|
||||
Images:
|
||||
|
||||
- **one-kvm** — main app + ttyd
|
||||
- **one-kvm-full** — same plus optional extras (e.g. gostc, easytier-core)
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
docker run --name one-kvm -itd \
|
||||
--privileged=true --restart unless-stopped \
|
||||
-v /dev:/dev -v /sys:/sys \
|
||||
--net=host \
|
||||
silentwind0/one-kvm-full
|
||||
```
|
||||
|
||||
If pulls are slow, use the Aliyun mirror, e.g. `registry.cn-hangzhou.aliyuncs.com/silentwind/one-kvm-full` (and `registry.cn-hangzhou.aliyuncs.com/silentwind/one-kvm` for the slim image).
|
||||
|
||||
### fnOS NAS (Feiniu / 飞牛)
|
||||
|
||||
One-KVM is listed in the fnOS **app store**; search and install on your NAS.
|
||||
|
||||
### Web UI and first run
|
||||
|
||||
Open `http://<device-ip>:8080` in a browser (**8420** after fnOS install). The first visit runs initial setup.
|
||||
|
||||
## Reporting issues
|
||||
|
||||
If something breaks:
|
||||
|
||||
1. Open [GitHub Issues](https://github.com/mofeng-git/One-KVM/issues) or report in the project QQ group.
|
||||
2. Include **useful** error messages and steps to reproduce.
|
||||
3. Mention software version, hardware, and OS details.
|
||||
|
||||
## Sponsorship
|
||||
|
||||
One-KVM builds on many great open-source projects; a lot of time goes into testing and maintenance. If you find it useful, you can support development on **[Afdian (为爱发电)](https://afdian.com/a/silentwind)**.
|
||||
|
||||
### Thanks
|
||||
|
||||
<details>
|
||||
<summary><strong>Supporter list</strong></summary>
|
||||
|
||||
- 浩龙的电子嵌入式之路
|
||||
|
||||
- Tsuki
|
||||
|
||||
- H_xiaoming
|
||||
|
||||
- 0蓝蓝0
|
||||
|
||||
- fairybl
|
||||
|
||||
- Will
|
||||
|
||||
- 自.知
|
||||
|
||||
- 观棋不语٩ ི۶
|
||||
|
||||
- 爱发电用户_a57a4
|
||||
|
||||
- 爱发电用户_2c769
|
||||
|
||||
- 霜序
|
||||
|
||||
- 远方(闲鱼用户名:小远技术店铺)
|
||||
|
||||
- 爱发电用户_399fc
|
||||
|
||||
- 斐斐の
|
||||
|
||||
- 爱发电用户_09451
|
||||
|
||||
- 超高校级的錆鱼
|
||||
|
||||
- 爱发电用户_08cff
|
||||
|
||||
- guoke
|
||||
|
||||
- mgt
|
||||
|
||||
- 姜沢掵
|
||||
|
||||
- ui_beam
|
||||
|
||||
- 爱发电用户_c0dd7
|
||||
|
||||
- 爱发电用户_dnjK
|
||||
|
||||
- 忍者胖猪
|
||||
|
||||
- 永遠の願い
|
||||
|
||||
- 爱发电用户_GBrF
|
||||
|
||||
- 爱发电用户_fd65c
|
||||
|
||||
- 爱发电用户_vhNa
|
||||
|
||||
- 爱发电用户_Xu6S
|
||||
|
||||
- moss
|
||||
|
||||
- woshididi
|
||||
|
||||
- 爱发电用户_a0fd1
|
||||
|
||||
- 爱发电用户_f6bH
|
||||
|
||||
- 码农
|
||||
|
||||
- 爱发电用户_6639f
|
||||
|
||||
- jeron
|
||||
|
||||
- 爱发电用户_CN7y
|
||||
|
||||
- 爱发电用户_Up6w
|
||||
|
||||
- 爱发电用户_e3202
|
||||
|
||||
- 一语念白
|
||||
|
||||
- 云边
|
||||
|
||||
- 爱发电用户_5a711
|
||||
|
||||
- 爱发电用户_9a706
|
||||
|
||||
- T0m9ir1SUKI
|
||||
|
||||
- 爱发电用户_56d52
|
||||
|
||||
- 爱发电用户_3N6F
|
||||
|
||||
- DUSK
|
||||
|
||||
- 飘零
|
||||
|
||||
- .
|
||||
|
||||
- 饭太稀
|
||||
|
||||
- 葱
|
||||
|
||||
- MaxZ
|
||||
|
||||
- 爱发电用户_c5f33
|
||||
|
||||
- 爱发电用户_09386
|
||||
|
||||
- 爱发电用户_JT6c
|
||||
|
||||
- 爱发电用户_d3d9c
|
||||
|
||||
- ......
|
||||
|
||||
</details>
|
||||
|
||||
### Sponsors
|
||||
|
||||
**File hosting**
|
||||
|
||||
- **[Huang1111 public-interest program](https://pan.huang1111.cn/s/mxkx3T1)** — login-free downloads
|
||||
|
||||
**Cloud**
|
||||
|
||||
- **[林枫云](https://www.dkdun.cn)** — project server sponsorship
|
||||
|
||||

|
||||
|
||||
林枫云 offers premium network routes, high-frequency game servers, and high-bandwidth servers in China and abroad.
|
||||
86
README.md
86
README.md
@@ -2,8 +2,9 @@
|
||||
<h1>One-KVM</h1>
|
||||
<p><strong>Rust 编写的开放轻量 IP-KVM 解决方案,实现 BIOS 级远程管理</strong></p>
|
||||
|
||||
<p><a href="README.md">简体中文</a></p>
|
||||
<p><a href="README.md">简体中文</a> · <a href="README.en.md">English</a></p>
|
||||
|
||||
[](https://github.com/mofeng-git/One-KVM/releases)
|
||||
[](https://github.com/mofeng-git/One-KVM/stargazers)
|
||||
[](https://github.com/mofeng-git/One-KVM/network/members)
|
||||
[](https://github.com/mofeng-git/One-KVM/issues)
|
||||
@@ -15,57 +16,78 @@
|
||||
|
||||
**One-KVM Rust** 是一个用 Rust 编写的轻量级 IP-KVM 解决方案,可通过网络远程管理服务器和工作站,实现 BIOS 级远程控制。
|
||||
|
||||
项目目标:
|
||||
项目目标:提供一个开放、轻量、易用的 IPKVM 解决方案。
|
||||
|
||||
- **开放**:不绑定特定硬件配置,尽量适配常见 Linux 设备
|
||||
- **轻量**:单二进制分发,部署过程更简单
|
||||
- **易用**:网页界面完成设备与参数配置,无需手动改配置文件
|
||||
- **开放**:不绑定特定硬件配置,可在各类硬件环境中稳定运行。
|
||||
- **轻量**:以二进制文件形式分发,无繁杂的依赖项,部署过程简单。
|
||||
- **易用**:无需手动编辑配置文件,参数设置均可通过网页界面完成。
|
||||
|
||||
> **注意:** One-KVM Rust 目前仍处于开发早期阶段,功能与细节会快速迭代,欢迎体验与反馈。
|
||||
> **One-KVM Python** 已停止开发,如有需要可访问 <https://github.com/mofeng-git/One-KVM/tree/python>。
|
||||
|
||||
## 🔁 迁移说明
|
||||
<div align="center">
|
||||
|
||||
开发重心正在从 **One-KVM Python** 逐步转向 **One-KVM Rust**。
|
||||

|
||||
|
||||
- 如果你在使用 **One-KVM Python(基于 PiKVM)**,请查看 [One-KVM Python 文档](https://docs.one-kvm.cn/python/)
|
||||
- One-KVM Rust 相较于 One-KVM Python:**尚未完全适配 CSI HDMI 采集卡**、**不支持 VNC 访问**,仍处于开发早期阶段
|
||||
</div>
|
||||
|
||||
## 📊 功能介绍
|
||||
|
||||
### 核心功能
|
||||
|
||||
| 功能 | 说明 |
|
||||
| 功能 | 能力说明 |
|
||||
|------|------|
|
||||
| 视频采集 | HDMI USB 采集卡支持,提供 MJPEG / WebRTC(H.264/H.265/VP8/VP9) |
|
||||
| 视频采集 | HDMI USB /MIPI CSI/RK3588 HDMI IN 采集支持,提供 MJPEG / WebRTC(H.264/H.265/VP8/VP9) 视频流|
|
||||
| 视频编码 | VAAPI/QSV/RKMPP/V4L2M2M 硬件编码支持,以及软件编码兜底 |
|
||||
| 键鼠控制 | USB OTG HID 或 CH340 + CH9329 HID,支持绝对/相对鼠标模式 |
|
||||
| 虚拟媒体 | USB Mass Storage,支持 ISO/IMG 镜像挂载和 Ventoy 虚拟U盘模式 |
|
||||
| ATX 电源控制 | GPIO 控制电源/重启按钮 |
|
||||
| ATX 电源控制 | GPIO /USB 继电器,支持控制电源、重启按钮 |
|
||||
| 音频传输 | ALSA 采集 + Opus 编码(HTTP/WebRTC) |
|
||||
|
||||
### 硬件编码
|
||||
|
||||
支持自动检测和选择硬件加速:
|
||||
|
||||
- **VAAPI**:Intel/AMD GPU
|
||||
- **RKMPP**:Rockchip SoC
|
||||
- **V4L2 M2M**:通用硬件编码器
|
||||
- **软件编码**:CPU 编码
|
||||
|
||||
### 扩展能力
|
||||
|
||||
- Web UI 配置,多语言支持(中文/英文)
|
||||
- 内置 Web 终端(ttyd)、内网穿透支持(gostc)、P2P 组网支持(EasyTier)、RustDesk 协议集成(用于跨平台远程访问能力扩展)和 RTSP 视频流(用于视频推流)
|
||||
此外提供基于 Web UI 的可视化配置与中英文界面;并集成 Web 终端(ttyd)、内网穿透(gostc)、P2P 组网(EasyTier)、RustDesk 协议(扩展跨平台远程访问)以及 RTSP 推流等能力。
|
||||
|
||||
## ⚡ 安装使用
|
||||
|
||||
可以访问 [One-KVM Rust 文档站点](https://docs.one-kvm.cn/) 获取详细信息。
|
||||
构建产物见 [GitHub Releases](https://github.com/mofeng-git/One-KVM/releases)。以下为常见安装方式的简要步骤;**系统要求、硬件准备、Docker 环境变量与 USB OTG 等完整说明**请查阅 [One-KVM Rust 文档站点](https://docs.one-kvm.cn/)。
|
||||
|
||||
### 使用 deb 安装(Debian / Ubuntu)
|
||||
|
||||
从 Releases 下载与本机架构匹配的 `one-kvm_*.deb`,在包所在目录执行:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install ./one-kvm_0.x.x_<arch>.deb
|
||||
```
|
||||
|
||||
将文件名中的版本号与架构替换为实际下载的包名。
|
||||
|
||||
### 使用 Docker
|
||||
|
||||
镜像分为 **one-kvm**(One-KVM 主程序 + ttyd)与 **one-kvm-full**(另含 gostc、easytier-core 等可选扩展),按需选用。
|
||||
|
||||
```bash
|
||||
docker run --name one-kvm -itd \
|
||||
--privileged=true --restart unless-stopped \
|
||||
-v /dev:/dev -v /sys:/sys \
|
||||
--net=host \
|
||||
silentwind0/one-kvm-full
|
||||
```
|
||||
|
||||
拉取较慢时,可将镜像名替换为阿里云加速,例如 `registry.cn-hangzhou.aliyuncs.com/silentwind/one-kvm-full`(`one-kvm` 镜像同理,将 `silentwind0/one-kvm` 换为 `registry.cn-hangzhou.aliyuncs.com/silentwind/one-kvm`)。
|
||||
|
||||
### 飞牛 NAS
|
||||
|
||||
One-KVM 已上架飞牛 **应用市场**,在 NAS 上直接搜索安装即可。
|
||||
|
||||
### 访问 Web 与首次配置
|
||||
|
||||
浏览器访问 `http://<设备 IP>:8080`(飞牛 NAS 安装后为 8420 端口)。首次访问将引导完成初始配置。
|
||||
|
||||
## 报告问题
|
||||
|
||||
如果您发现了问题,请:
|
||||
1. 使用 [GitHub Issues](https://github.com/mofeng-git/One-KVM/issues) 报告,或加入 QQ 群聊反馈。
|
||||
2. 提供详细的错误信息和复现步骤
|
||||
3. 包含您的硬件配置和系统信息
|
||||
2. 提供有帮助的错误信息和复现步骤
|
||||
3. 包含您使用的软件版本、硬件配置和系统信息
|
||||
|
||||
## 赞助支持
|
||||
|
||||
@@ -88,8 +110,6 @@
|
||||
|
||||
- Will
|
||||
|
||||
- 浩龙的电子嵌入式之路
|
||||
|
||||
- 自.知
|
||||
|
||||
- 观棋不语٩ ི۶
|
||||
@@ -188,8 +208,6 @@
|
||||
|
||||
- 爱发电用户_JT6c
|
||||
|
||||
- MaxZ
|
||||
|
||||
- 爱发电用户_d3d9c
|
||||
|
||||
- ......
|
||||
@@ -205,7 +223,7 @@
|
||||
|
||||
**云服务商**
|
||||
|
||||
- **[林枫云](https://www.dkdun.cn)** - 赞助了本项目宁波大带宽服务器
|
||||
- **[林枫云](https://www.dkdun.cn)** - 赞助了本项目服务器
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ ARG TARGETPLATFORM
|
||||
# Install runtime dependencies in a single layer
|
||||
# All codec libraries (libx264, libx265, libopus) are now statically linked
|
||||
# Only hardware acceleration drivers and core system libraries remain dynamic
|
||||
RUN apt-get update && \
|
||||
RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
# Core runtime (all platforms) - no codec libs needed
|
||||
ca-certificates \
|
||||
@@ -24,7 +25,8 @@ RUN apt-get update && \
|
||||
# Platform-specific hardware acceleration
|
||||
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libva2 libva-drm2 libva-x11-2 libx11-6 libxcb1 libxau6 libxdmcp6 libmfx1; \
|
||||
libva2 libva-drm2 libva-x11-2 libx11-6 libxcb1 libxau6 libxdmcp6 libmfx1 \
|
||||
i965-va-driver-shaders intel-media-va-driver-non-free vainfo; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libdrm2 libva2; \
|
||||
|
||||
@@ -12,7 +12,8 @@ ARG TARGETPLATFORM
|
||||
# Install runtime dependencies in a single layer
|
||||
# All codec libraries (libx264, libx265, libopus) are now statically linked
|
||||
# Only hardware acceleration drivers and core system libraries remain dynamic
|
||||
RUN apt-get update && \
|
||||
RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
# Core runtime (all platforms) - no codec libs needed
|
||||
ca-certificates \
|
||||
@@ -24,7 +25,8 @@ RUN apt-get update && \
|
||||
# Platform-specific hardware acceleration
|
||||
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libva2 libva-drm2 libva-x11-2 libx11-6 libxcb1 libxau6 libxdmcp6 libmfx1; \
|
||||
libva2 libva-drm2 libva-x11-2 libx11-6 libxcb1 libxau6 libxdmcp6 libmfx1 \
|
||||
i965-va-driver-shaders intel-media-va-driver-non-free vainfo; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libdrm2 libva2; \
|
||||
|
||||
@@ -4,6 +4,68 @@
|
||||
|
||||
set -e
|
||||
|
||||
detect_intel_libva_driver() {
|
||||
if [ -n "${LIBVA_DRIVER_NAME:-}" ]; then
|
||||
echo "[INFO] Using preconfigured LIBVA_DRIVER_NAME=$LIBVA_DRIVER_NAME"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$(uname -m)" != "x86_64" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local devices=()
|
||||
if [ -n "${LIBVA_DEVICE:-}" ]; then
|
||||
devices=("$LIBVA_DEVICE")
|
||||
else
|
||||
shopt -s nullglob
|
||||
devices=(/dev/dri/renderD*)
|
||||
shopt -u nullglob
|
||||
fi
|
||||
|
||||
if [ ${#devices[@]} -eq 0 ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local device=""
|
||||
local node=""
|
||||
local vendor=""
|
||||
local driver=""
|
||||
|
||||
for device in "${devices[@]}"; do
|
||||
if [ ! -e "$device" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
node="$(basename "$device")"
|
||||
vendor=""
|
||||
if [ -r "/sys/class/drm/$node/device/vendor" ]; then
|
||||
vendor="$(cat "/sys/class/drm/$node/device/vendor")"
|
||||
fi
|
||||
|
||||
if [ -n "$vendor" ] && [ "$vendor" != "0x8086" ]; then
|
||||
echo "[INFO] Skipping VA-API probe for $device (vendor=$vendor)"
|
||||
continue
|
||||
fi
|
||||
|
||||
for driver in iHD i965; do
|
||||
if LIBVA_DRIVER_NAME="$driver" vainfo --display drm --device "$device" >/dev/null 2>&1; then
|
||||
export LIBVA_DRIVER_NAME="$driver"
|
||||
if [ -n "$vendor" ]; then
|
||||
echo "[INFO] Detected Intel VA-API driver '$driver' on $device (vendor=$vendor)"
|
||||
else
|
||||
echo "[INFO] Detected Intel VA-API driver '$driver' on $device"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
echo "[WARN] Unable to auto-detect an Intel VA-API driver; leaving LIBVA_DRIVER_NAME unset"
|
||||
}
|
||||
|
||||
detect_intel_libva_driver
|
||||
|
||||
# Start one-kvm with default options.
|
||||
# Additional options can be passed via environment variables.
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ Wants=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
# Example for older Intel GPUs:
|
||||
# Environment=LIBVA_DRIVER_NAME=i965
|
||||
ExecStart=/usr/bin/one-kvm
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@@ -126,7 +126,7 @@ EOF
|
||||
|
||||
# Create control file
|
||||
BASE_DEPS="libc6 (>= 2.31), libgcc-s1, libstdc++6, libasound2 (>= 1.1), libdrm2 (>= 2.4)"
|
||||
AMD64_DEPS="libva2 (>= 2.0), libva-drm2 (>= 2.10), libva-x11-2 (>= 2.10), libmfx1 (>= 21.1), libx11-6 (>= 1.6), libxcb1 (>= 1.14)"
|
||||
AMD64_DEPS="libva2 (>= 2.0), libva-drm2 (>= 2.10), libva-x11-2 (>= 2.10), libmfx1 (>= 21.1), libx11-6 (>= 1.6), libxcb1 (>= 1.14), i965-va-driver-shaders (>= 2.4), intel-media-va-driver-non-free (>= 21.1)"
|
||||
DEPS="$BASE_DEPS"
|
||||
if [ "$DEB_ARCH" = "amd64" ]; then
|
||||
DEPS="$DEPS, $AMD64_DEPS"
|
||||
|
||||
@@ -25,6 +25,7 @@ enum AVPixelFormat {
|
||||
AV_PIX_FMT_NV24 = 188,
|
||||
};
|
||||
|
||||
int av_get_pix_fmt(const char *name);
|
||||
int av_log_get_level(void);
|
||||
void av_log_set_level(int level);
|
||||
void hwcodec_set_av_log_callback();
|
||||
|
||||
@@ -271,6 +271,7 @@ extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
|
||||
*out_data = nullptr;
|
||||
*out_len = 0;
|
||||
*out_keyframe = 0;
|
||||
bool encoded = false;
|
||||
|
||||
av_packet_unref(ctx->dec_pkt);
|
||||
int ret = av_new_packet(ctx->dec_pkt, len);
|
||||
@@ -290,7 +291,7 @@ extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
|
||||
while (true) {
|
||||
ret = avcodec_receive_frame(ctx->dec_ctx, ctx->dec_frame);
|
||||
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
|
||||
return 0;
|
||||
return encoded ? 1 : 0;
|
||||
}
|
||||
if (ret < 0) {
|
||||
set_last_error(make_err("avcodec_receive_frame failed", ret));
|
||||
@@ -370,33 +371,40 @@ extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
|
||||
return -1;
|
||||
}
|
||||
|
||||
av_packet_unref(ctx->enc_pkt);
|
||||
ret = avcodec_receive_packet(ctx->enc_ctx, ctx->enc_pkt);
|
||||
if (ret == AVERROR(EAGAIN)) {
|
||||
av_frame_unref(ctx->dec_frame);
|
||||
return 0;
|
||||
}
|
||||
if (ret < 0) {
|
||||
set_last_error(make_err("avcodec_receive_packet failed", ret));
|
||||
av_frame_unref(ctx->dec_frame);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (ctx->enc_pkt->size > 0) {
|
||||
uint8_t *buf = (uint8_t*)malloc(ctx->enc_pkt->size);
|
||||
if (!buf) {
|
||||
set_last_error("malloc for output packet failed");
|
||||
while (true) {
|
||||
av_packet_unref(ctx->enc_pkt);
|
||||
ret = avcodec_receive_packet(ctx->enc_ctx, ctx->enc_pkt);
|
||||
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
|
||||
break;
|
||||
}
|
||||
if (ret < 0) {
|
||||
set_last_error(make_err("avcodec_receive_packet failed", ret));
|
||||
av_packet_unref(ctx->enc_pkt);
|
||||
av_frame_unref(ctx->dec_frame);
|
||||
return -1;
|
||||
}
|
||||
memcpy(buf, ctx->enc_pkt->data, ctx->enc_pkt->size);
|
||||
*out_data = buf;
|
||||
*out_len = ctx->enc_pkt->size;
|
||||
*out_keyframe = (ctx->enc_pkt->flags & AV_PKT_FLAG_KEY) ? 1 : 0;
|
||||
av_packet_unref(ctx->enc_pkt);
|
||||
av_frame_unref(ctx->dec_frame);
|
||||
return 1;
|
||||
|
||||
if (ctx->enc_pkt->size <= 0) {
|
||||
set_last_error("avcodec_receive_packet failed, pkt size is 0");
|
||||
av_packet_unref(ctx->enc_pkt);
|
||||
av_frame_unref(ctx->dec_frame);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!encoded) {
|
||||
uint8_t *buf = (uint8_t*)malloc(ctx->enc_pkt->size);
|
||||
if (!buf) {
|
||||
set_last_error("malloc for output packet failed");
|
||||
av_packet_unref(ctx->enc_pkt);
|
||||
av_frame_unref(ctx->dec_frame);
|
||||
return -1;
|
||||
}
|
||||
memcpy(buf, ctx->enc_pkt->data, ctx->enc_pkt->size);
|
||||
*out_data = buf;
|
||||
*out_len = ctx->enc_pkt->size;
|
||||
*out_keyframe = (ctx->enc_pkt->flags & AV_PKT_FLAG_KEY) ? 1 : 0;
|
||||
encoded = true;
|
||||
}
|
||||
}
|
||||
|
||||
av_frame_unref(ctx->dec_frame);
|
||||
|
||||
@@ -30,9 +30,15 @@ static int calculate_offset_length(int pix_fmt, int height, const int *linesize,
|
||||
*length = offset[1] + linesize[2] * height / 2;
|
||||
break;
|
||||
case AV_PIX_FMT_NV12:
|
||||
case AV_PIX_FMT_NV21:
|
||||
offset[0] = linesize[0] * height;
|
||||
*length = offset[0] + linesize[1] * height / 2;
|
||||
break;
|
||||
case AV_PIX_FMT_NV16:
|
||||
case AV_PIX_FMT_NV24:
|
||||
offset[0] = linesize[0] * height;
|
||||
*length = offset[0] + linesize[1] * height;
|
||||
break;
|
||||
case AV_PIX_FMT_YUYV422:
|
||||
case AV_PIX_FMT_YVYU422:
|
||||
case AV_PIX_FMT_UYVY422:
|
||||
@@ -41,6 +47,11 @@ static int calculate_offset_length(int pix_fmt, int height, const int *linesize,
|
||||
offset[0] = 0; // Only one plane
|
||||
*length = linesize[0] * height;
|
||||
break;
|
||||
case AV_PIX_FMT_RGB24:
|
||||
case AV_PIX_FMT_BGR24:
|
||||
offset[0] = 0; // Only one plane
|
||||
*length = linesize[0] * height;
|
||||
break;
|
||||
default:
|
||||
LOG_ERROR(std::string("unsupported pixfmt") + std::to_string(pix_fmt));
|
||||
return -1;
|
||||
@@ -397,9 +408,23 @@ private:
|
||||
const int *const offset) {
|
||||
switch (frame->format) {
|
||||
case AV_PIX_FMT_NV12:
|
||||
case AV_PIX_FMT_NV21:
|
||||
if (data_length <
|
||||
frame->height * (frame->linesize[0] + frame->linesize[1] / 2)) {
|
||||
LOG_ERROR(std::string("fill_frame: NV12 data length error. data_length:") +
|
||||
LOG_ERROR(std::string("fill_frame: NV12/NV21 data length error. data_length:") +
|
||||
std::to_string(data_length) +
|
||||
", linesize[0]:" + std::to_string(frame->linesize[0]) +
|
||||
", linesize[1]:" + std::to_string(frame->linesize[1]));
|
||||
return -1;
|
||||
}
|
||||
frame->data[0] = data;
|
||||
frame->data[1] = data + offset[0];
|
||||
break;
|
||||
case AV_PIX_FMT_NV16:
|
||||
case AV_PIX_FMT_NV24:
|
||||
if (data_length <
|
||||
frame->height * (frame->linesize[0] + frame->linesize[1])) {
|
||||
LOG_ERROR(std::string("fill_frame: NV16/NV24 data length error. data_length:") +
|
||||
std::to_string(data_length) +
|
||||
", linesize[0]:" + std::to_string(frame->linesize[0]) +
|
||||
", linesize[1]:" + std::to_string(frame->linesize[1]));
|
||||
@@ -436,6 +461,17 @@ private:
|
||||
}
|
||||
frame->data[0] = data;
|
||||
break;
|
||||
case AV_PIX_FMT_RGB24:
|
||||
case AV_PIX_FMT_BGR24:
|
||||
if (data_length < frame->height * frame->linesize[0]) {
|
||||
LOG_ERROR(std::string("fill_frame: RGB24/BGR24 data length error. data_length:") +
|
||||
std::to_string(data_length) +
|
||||
", linesize[0]:" + std::to_string(frame->linesize[0]) +
|
||||
", height:" + std::to_string(frame->height));
|
||||
return -1;
|
||||
}
|
||||
frame->data[0] = data;
|
||||
break;
|
||||
default:
|
||||
LOG_ERROR(std::string("fill_frame: unsupported format, ") +
|
||||
std::to_string(frame->format));
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
include!(concat!(env!("OUT_DIR"), "/ffmpeg_ffi.rs"));
|
||||
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
use std::{env, ffi::CString};
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum AVHWDeviceType {
|
||||
@@ -59,6 +59,22 @@ pub(crate) fn init_av_log() {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn resolve_pixel_format(name: &str, fallback: AVPixelFormat) -> i32 {
|
||||
let c_name = match CString::new(name) {
|
||||
Ok(name) => name,
|
||||
Err(_) => return fallback as i32,
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let resolved = av_get_pix_fmt(c_name.as_ptr());
|
||||
if resolved >= 0 {
|
||||
resolved
|
||||
} else {
|
||||
fallback as i32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ffmpeg_log_level() -> i32 {
|
||||
let raw = match env::var("ONE_KVM_FFMPEG_LOG") {
|
||||
Ok(value) => value,
|
||||
|
||||
@@ -15,12 +15,412 @@ use std::{
|
||||
slice,
|
||||
};
|
||||
|
||||
use super::Priority;
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
use crate::common::Driver;
|
||||
|
||||
/// Timeout for encoder test in milliseconds
|
||||
const TEST_TIMEOUT_MS: u64 = 3000;
|
||||
const PRIORITY_NVENC: i32 = 0;
|
||||
const PRIORITY_QSV: i32 = 1;
|
||||
const PRIORITY_AMF: i32 = 2;
|
||||
const PRIORITY_RKMPP: i32 = 3;
|
||||
const PRIORITY_VAAPI: i32 = 4;
|
||||
const PRIORITY_V4L2M2M: i32 = 5;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CandidateCodecSpec {
|
||||
name: &'static str,
|
||||
format: DataFormat,
|
||||
priority: i32,
|
||||
}
|
||||
|
||||
fn push_candidate(codecs: &mut Vec<CodecInfo>, candidate: CandidateCodecSpec) {
|
||||
codecs.push(CodecInfo {
|
||||
name: candidate.name.to_owned(),
|
||||
format: candidate.format,
|
||||
priority: candidate.priority,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_support_vaapi() -> bool {
|
||||
let entries = match std::fs::read_dir("/dev/dri") {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
entries.flatten().any(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.map(|name| name.starts_with("renderD"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn linux_support_vaapi() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_support_rkmpp() -> bool {
|
||||
extern "C" {
|
||||
fn linux_support_rkmpp() -> c_int;
|
||||
}
|
||||
|
||||
unsafe { linux_support_rkmpp() == 0 }
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn linux_support_rkmpp() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_support_v4l2m2m() -> bool {
|
||||
extern "C" {
|
||||
fn linux_support_v4l2m2m() -> c_int;
|
||||
}
|
||||
|
||||
unsafe { linux_support_v4l2m2m() == 0 }
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn linux_support_v4l2m2m() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
fn enumerate_candidate_codecs(ctx: &EncodeContext) -> Vec<CodecInfo> {
|
||||
use log::debug;
|
||||
|
||||
let mut codecs = Vec::new();
|
||||
let contains = |_vendor: Driver, _format: DataFormat| {
|
||||
// Without VRAM feature, we can't check SDK availability.
|
||||
// Keep the prefilter coarse and let FFmpeg validation do the real check.
|
||||
true
|
||||
};
|
||||
let (nv, amf, intel) = crate::common::supported_gpu(true);
|
||||
|
||||
debug!(
|
||||
"GPU support detected - NV: {}, AMF: {}, Intel: {}",
|
||||
nv, amf, intel
|
||||
);
|
||||
|
||||
if nv && contains(Driver::NV, H264) {
|
||||
push_candidate(
|
||||
&mut codecs,
|
||||
CandidateCodecSpec {
|
||||
name: "h264_nvenc",
|
||||
format: H264,
|
||||
priority: PRIORITY_NVENC,
|
||||
},
|
||||
);
|
||||
}
|
||||
if nv && contains(Driver::NV, H265) {
|
||||
push_candidate(
|
||||
&mut codecs,
|
||||
CandidateCodecSpec {
|
||||
name: "hevc_nvenc",
|
||||
format: H265,
|
||||
priority: PRIORITY_NVENC,
|
||||
},
|
||||
);
|
||||
}
|
||||
if intel && contains(Driver::MFX, H264) {
|
||||
push_candidate(
|
||||
&mut codecs,
|
||||
CandidateCodecSpec {
|
||||
name: "h264_qsv",
|
||||
format: H264,
|
||||
priority: PRIORITY_QSV,
|
||||
},
|
||||
);
|
||||
}
|
||||
if intel && contains(Driver::MFX, H265) {
|
||||
push_candidate(
|
||||
&mut codecs,
|
||||
CandidateCodecSpec {
|
||||
name: "hevc_qsv",
|
||||
format: H265,
|
||||
priority: PRIORITY_QSV,
|
||||
},
|
||||
);
|
||||
}
|
||||
if amf && contains(Driver::AMF, H264) {
|
||||
push_candidate(
|
||||
&mut codecs,
|
||||
CandidateCodecSpec {
|
||||
name: "h264_amf",
|
||||
format: H264,
|
||||
priority: PRIORITY_AMF,
|
||||
},
|
||||
);
|
||||
}
|
||||
if amf && contains(Driver::AMF, H265) {
|
||||
push_candidate(
|
||||
&mut codecs,
|
||||
CandidateCodecSpec {
|
||||
name: "hevc_amf",
|
||||
format: H265,
|
||||
priority: PRIORITY_AMF,
|
||||
},
|
||||
);
|
||||
}
|
||||
if linux_support_rkmpp() {
|
||||
debug!("RKMPP hardware detected, adding Rockchip encoders");
|
||||
push_candidate(
|
||||
&mut codecs,
|
||||
CandidateCodecSpec {
|
||||
name: "h264_rkmpp",
|
||||
format: H264,
|
||||
priority: PRIORITY_RKMPP,
|
||||
},
|
||||
);
|
||||
push_candidate(
|
||||
&mut codecs,
|
||||
CandidateCodecSpec {
|
||||
name: "hevc_rkmpp",
|
||||
format: H265,
|
||||
priority: PRIORITY_RKMPP,
|
||||
},
|
||||
);
|
||||
}
|
||||
if cfg!(target_os = "linux") && linux_support_vaapi() {
|
||||
push_candidate(
|
||||
&mut codecs,
|
||||
CandidateCodecSpec {
|
||||
name: "h264_vaapi",
|
||||
format: H264,
|
||||
priority: PRIORITY_VAAPI,
|
||||
},
|
||||
);
|
||||
push_candidate(
|
||||
&mut codecs,
|
||||
CandidateCodecSpec {
|
||||
name: "hevc_vaapi",
|
||||
format: H265,
|
||||
priority: PRIORITY_VAAPI,
|
||||
},
|
||||
);
|
||||
push_candidate(
|
||||
&mut codecs,
|
||||
CandidateCodecSpec {
|
||||
name: "vp8_vaapi",
|
||||
format: VP8,
|
||||
priority: PRIORITY_VAAPI,
|
||||
},
|
||||
);
|
||||
push_candidate(
|
||||
&mut codecs,
|
||||
CandidateCodecSpec {
|
||||
name: "vp9_vaapi",
|
||||
format: VP9,
|
||||
priority: PRIORITY_VAAPI,
|
||||
},
|
||||
);
|
||||
}
|
||||
if linux_support_v4l2m2m() {
|
||||
debug!("V4L2 M2M hardware detected, adding V4L2 encoders");
|
||||
push_candidate(
|
||||
&mut codecs,
|
||||
CandidateCodecSpec {
|
||||
name: "h264_v4l2m2m",
|
||||
format: H264,
|
||||
priority: PRIORITY_V4L2M2M,
|
||||
},
|
||||
);
|
||||
push_candidate(
|
||||
&mut codecs,
|
||||
CandidateCodecSpec {
|
||||
name: "hevc_v4l2m2m",
|
||||
format: H265,
|
||||
priority: PRIORITY_V4L2M2M,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
codecs.retain(|codec| {
|
||||
!(ctx.pixfmt == AVPixelFormat::AV_PIX_FMT_YUV420P as i32 && codec.name.contains("qsv"))
|
||||
});
|
||||
codecs
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ProbePolicy {
|
||||
max_attempts: usize,
|
||||
request_keyframe: bool,
|
||||
accept_any_output: bool,
|
||||
}
|
||||
|
||||
impl ProbePolicy {
|
||||
fn for_codec(codec_name: &str) -> Self {
|
||||
if codec_name.contains("v4l2m2m") {
|
||||
Self {
|
||||
max_attempts: 5,
|
||||
request_keyframe: true,
|
||||
accept_any_output: true,
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
max_attempts: 1,
|
||||
request_keyframe: false,
|
||||
accept_any_output: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_attempt(&self, encoder: &mut Encoder) {
|
||||
if self.request_keyframe {
|
||||
encoder.request_keyframe();
|
||||
}
|
||||
}
|
||||
|
||||
fn passed(&self, frames: &[EncodeFrame], elapsed_ms: u128) -> bool {
|
||||
if elapsed_ms >= TEST_TIMEOUT_MS as u128 {
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.accept_any_output {
|
||||
!frames.is_empty()
|
||||
} else {
|
||||
frames.len() == 1 && frames[0].key == 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn log_failed_probe_attempt(
|
||||
codec_name: &str,
|
||||
policy: ProbePolicy,
|
||||
attempt: usize,
|
||||
frames: &[EncodeFrame],
|
||||
elapsed_ms: u128,
|
||||
) {
|
||||
use log::debug;
|
||||
|
||||
if policy.accept_any_output {
|
||||
if frames.is_empty() {
|
||||
debug!(
|
||||
"Encoder {} test produced no output on attempt {}",
|
||||
codec_name, attempt
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
"Encoder {} test failed on attempt {} - frames: {}, timeout: {}ms",
|
||||
codec_name,
|
||||
attempt,
|
||||
frames.len(),
|
||||
elapsed_ms
|
||||
);
|
||||
}
|
||||
} else if frames.len() == 1 {
|
||||
debug!(
|
||||
"Encoder {} test failed on attempt {} - key: {}, timeout: {}ms",
|
||||
codec_name, attempt, frames[0].key, elapsed_ms
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
"Encoder {} test failed on attempt {} - wrong frame count: {}",
|
||||
codec_name,
|
||||
attempt,
|
||||
frames.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> bool {
|
||||
use log::debug;
|
||||
|
||||
debug!("Testing encoder: {}", codec.name);
|
||||
|
||||
let test_ctx = EncodeContext {
|
||||
name: codec.name.clone(),
|
||||
mc_name: codec.mc_name.clone(),
|
||||
..ctx.clone()
|
||||
};
|
||||
|
||||
match Encoder::new(test_ctx) {
|
||||
Ok(mut encoder) => {
|
||||
debug!("Encoder {} created successfully", codec.name);
|
||||
let policy = ProbePolicy::for_codec(&codec.name);
|
||||
let mut last_err: Option<i32> = None;
|
||||
|
||||
for attempt in 0..policy.max_attempts {
|
||||
let attempt_no = attempt + 1;
|
||||
policy.prepare_attempt(&mut encoder);
|
||||
|
||||
let pts = (attempt as i64) * 33;
|
||||
let start = std::time::Instant::now();
|
||||
match encoder.encode(yuv, pts) {
|
||||
Ok(frames) => {
|
||||
let elapsed = start.elapsed().as_millis();
|
||||
|
||||
if policy.passed(frames, elapsed) {
|
||||
if policy.accept_any_output {
|
||||
debug!(
|
||||
"Encoder {} test passed on attempt {} (frames: {})",
|
||||
codec.name,
|
||||
attempt_no,
|
||||
frames.len()
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
"Encoder {} test passed on attempt {}",
|
||||
codec.name, attempt_no
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
log_failed_probe_attempt(
|
||||
&codec.name,
|
||||
policy,
|
||||
attempt_no,
|
||||
frames,
|
||||
elapsed,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
last_err = Some(err);
|
||||
debug!(
|
||||
"Encoder {} test attempt {} returned error: {}",
|
||||
codec.name, attempt_no, err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Encoder {} test failed after retries{}",
|
||||
codec.name,
|
||||
last_err
|
||||
.map(|e| format!(" (last err: {})", e))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
debug!("Failed to create encoder {}", codec.name);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_software_fallback(codecs: &mut Vec<CodecInfo>) {
|
||||
use log::debug;
|
||||
|
||||
for fallback in CodecInfo::soft().into_vec() {
|
||||
if !codecs.iter().any(|codec| codec.format == fallback.format) {
|
||||
debug!(
|
||||
"Adding software {:?} encoder: {}",
|
||||
fallback.format, fallback.name
|
||||
);
|
||||
codecs.push(fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct EncodeContext {
|
||||
@@ -28,7 +428,7 @@ pub struct EncodeContext {
|
||||
pub mc_name: Option<String>,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub pixfmt: AVPixelFormat,
|
||||
pub pixfmt: i32,
|
||||
pub align: i32,
|
||||
pub fps: i32,
|
||||
pub gop: i32,
|
||||
@@ -83,7 +483,7 @@ impl Encoder {
|
||||
CString::new(mc_name.as_str()).map_err(|_| ())?.as_ptr(),
|
||||
ctx.width,
|
||||
ctx.height,
|
||||
ctx.pixfmt as c_int,
|
||||
ctx.pixfmt,
|
||||
ctx.align,
|
||||
ctx.fps,
|
||||
ctx.gop,
|
||||
@@ -185,305 +585,21 @@ impl Encoder {
|
||||
if !(cfg!(windows) || cfg!(target_os = "linux")) {
|
||||
return vec![];
|
||||
}
|
||||
let mut codecs: Vec<CodecInfo> = vec![];
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
{
|
||||
let contains = |_vendor: Driver, _format: DataFormat| {
|
||||
// Without VRAM feature, we can't check SDK availability
|
||||
// Just return true and let FFmpeg handle the actual detection
|
||||
true
|
||||
};
|
||||
let (_nv, amf, _intel) = crate::common::supported_gpu(true);
|
||||
debug!(
|
||||
"GPU support detected - NV: {}, AMF: {}, Intel: {}",
|
||||
_nv, amf, _intel
|
||||
);
|
||||
|
||||
#[cfg(windows)]
|
||||
if _intel && contains(Driver::MFX, H264) {
|
||||
codecs.push(CodecInfo {
|
||||
name: "h264_qsv".to_owned(),
|
||||
format: H264,
|
||||
priority: Priority::Best as _,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if _intel && contains(Driver::MFX, H265) {
|
||||
codecs.push(CodecInfo {
|
||||
name: "hevc_qsv".to_owned(),
|
||||
format: H265,
|
||||
priority: Priority::Best as _,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if _nv && contains(Driver::NV, H264) {
|
||||
codecs.push(CodecInfo {
|
||||
name: "h264_nvenc".to_owned(),
|
||||
format: H264,
|
||||
priority: Priority::Best as _,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if _nv && contains(Driver::NV, H265) {
|
||||
codecs.push(CodecInfo {
|
||||
name: "hevc_nvenc".to_owned(),
|
||||
format: H265,
|
||||
priority: Priority::Best as _,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if amf && contains(Driver::AMF, H264) {
|
||||
codecs.push(CodecInfo {
|
||||
name: "h264_amf".to_owned(),
|
||||
format: H264,
|
||||
priority: Priority::Best as _,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if amf {
|
||||
codecs.push(CodecInfo {
|
||||
name: "hevc_amf".to_owned(),
|
||||
format: H265,
|
||||
priority: Priority::Best as _,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
codecs.push(CodecInfo {
|
||||
name: "h264_vaapi".to_owned(),
|
||||
format: H264,
|
||||
priority: Priority::Good as _,
|
||||
..Default::default()
|
||||
});
|
||||
codecs.push(CodecInfo {
|
||||
name: "hevc_vaapi".to_owned(),
|
||||
format: H265,
|
||||
priority: Priority::Good as _,
|
||||
..Default::default()
|
||||
});
|
||||
codecs.push(CodecInfo {
|
||||
name: "vp8_vaapi".to_owned(),
|
||||
format: VP8,
|
||||
priority: Priority::Good as _,
|
||||
..Default::default()
|
||||
});
|
||||
codecs.push(CodecInfo {
|
||||
name: "vp9_vaapi".to_owned(),
|
||||
format: VP9,
|
||||
priority: Priority::Good as _,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Rockchip MPP hardware encoder support
|
||||
use std::ffi::c_int;
|
||||
extern "C" {
|
||||
fn linux_support_rkmpp() -> c_int;
|
||||
fn linux_support_v4l2m2m() -> c_int;
|
||||
}
|
||||
|
||||
if unsafe { linux_support_rkmpp() } == 0 {
|
||||
debug!("RKMPP hardware detected, adding Rockchip encoders");
|
||||
codecs.push(CodecInfo {
|
||||
name: "h264_rkmpp".to_owned(),
|
||||
format: H264,
|
||||
priority: Priority::Best as _,
|
||||
..Default::default()
|
||||
});
|
||||
codecs.push(CodecInfo {
|
||||
name: "hevc_rkmpp".to_owned(),
|
||||
format: H265,
|
||||
priority: Priority::Best as _,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// V4L2 Memory-to-Memory hardware encoder support (generic ARM)
|
||||
if unsafe { linux_support_v4l2m2m() } == 0 {
|
||||
debug!("V4L2 M2M hardware detected, adding V4L2 encoders");
|
||||
codecs.push(CodecInfo {
|
||||
name: "h264_v4l2m2m".to_owned(),
|
||||
format: H264,
|
||||
priority: Priority::Good as _,
|
||||
..Default::default()
|
||||
});
|
||||
codecs.push(CodecInfo {
|
||||
name: "hevc_v4l2m2m".to_owned(),
|
||||
format: H265,
|
||||
priority: Priority::Good as _,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// qsv doesn't support yuv420p
|
||||
codecs.retain(|c| {
|
||||
let ctx = ctx.clone();
|
||||
if ctx.pixfmt == AVPixelFormat::AV_PIX_FMT_YUV420P && c.name.contains("qsv") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
let mut res = vec![];
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
let codecs = enumerate_candidate_codecs(&ctx);
|
||||
|
||||
if let Ok(yuv) = Encoder::dummy_yuv(ctx.clone()) {
|
||||
for codec in codecs {
|
||||
// Skip if this format already exists in results
|
||||
if res
|
||||
.iter()
|
||||
.any(|existing: &CodecInfo| existing.format == codec.format)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
debug!("Testing encoder: {}", codec.name);
|
||||
|
||||
let c = EncodeContext {
|
||||
name: codec.name.clone(),
|
||||
mc_name: codec.mc_name.clone(),
|
||||
..ctx
|
||||
};
|
||||
|
||||
match Encoder::new(c) {
|
||||
Ok(mut encoder) => {
|
||||
debug!("Encoder {} created successfully", codec.name);
|
||||
let mut passed = false;
|
||||
let mut last_err: Option<i32> = None;
|
||||
let is_v4l2m2m = codec.name.contains("v4l2m2m");
|
||||
|
||||
let max_attempts = if is_v4l2m2m { 5 } else { 1 };
|
||||
for attempt in 0..max_attempts {
|
||||
if is_v4l2m2m {
|
||||
encoder.request_keyframe();
|
||||
}
|
||||
let pts = (attempt as i64) * 33; // 33ms is an approximation for 30 FPS (1000 / 30)
|
||||
let start = std::time::Instant::now();
|
||||
match encoder.encode(&yuv, pts) {
|
||||
Ok(frames) => {
|
||||
let elapsed = start.elapsed().as_millis();
|
||||
|
||||
if is_v4l2m2m {
|
||||
if !frames.is_empty() && elapsed < TEST_TIMEOUT_MS as _ {
|
||||
debug!(
|
||||
"Encoder {} test passed on attempt {} (frames: {})",
|
||||
codec.name,
|
||||
attempt + 1,
|
||||
frames.len()
|
||||
);
|
||||
res.push(codec.clone());
|
||||
passed = true;
|
||||
break;
|
||||
} else if frames.is_empty() {
|
||||
debug!(
|
||||
"Encoder {} test produced no output on attempt {}",
|
||||
codec.name,
|
||||
attempt + 1
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
"Encoder {} test failed on attempt {} - frames: {}, timeout: {}ms",
|
||||
codec.name,
|
||||
attempt + 1,
|
||||
frames.len(),
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
} else if frames.len() == 1 {
|
||||
if frames[0].key == 1 && elapsed < TEST_TIMEOUT_MS as _ {
|
||||
debug!(
|
||||
"Encoder {} test passed on attempt {}",
|
||||
codec.name,
|
||||
attempt + 1
|
||||
);
|
||||
res.push(codec.clone());
|
||||
passed = true;
|
||||
break;
|
||||
} else {
|
||||
debug!(
|
||||
"Encoder {} test failed on attempt {} - key: {}, timeout: {}ms",
|
||||
codec.name,
|
||||
attempt + 1,
|
||||
frames[0].key,
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
"Encoder {} test failed on attempt {} - wrong frame count: {}",
|
||||
codec.name,
|
||||
attempt + 1,
|
||||
frames.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
last_err = Some(err);
|
||||
debug!(
|
||||
"Encoder {} test attempt {} returned error: {}",
|
||||
codec.name,
|
||||
attempt + 1,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !passed {
|
||||
debug!(
|
||||
"Encoder {} test failed after retries{}",
|
||||
codec.name,
|
||||
last_err
|
||||
.map(|e| format!(" (last err: {})", e))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
debug!("Failed to create encoder {}", codec.name);
|
||||
}
|
||||
if validate_candidate(&codec, &ctx, &yuv) {
|
||||
res.push(codec);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!("Failed to generate dummy YUV data");
|
||||
}
|
||||
|
||||
// Add software encoders as fallback
|
||||
let soft_codecs = CodecInfo::soft();
|
||||
|
||||
// Add H264 software encoder if not already present
|
||||
if !res.iter().any(|c| c.format == H264) {
|
||||
if let Some(h264_soft) = soft_codecs.h264 {
|
||||
debug!("Adding software H264 encoder: {}", h264_soft.name);
|
||||
res.push(h264_soft);
|
||||
}
|
||||
}
|
||||
|
||||
// Add H265 software encoder if not already present
|
||||
if !res.iter().any(|c| c.format == H265) {
|
||||
if let Some(h265_soft) = soft_codecs.h265 {
|
||||
debug!("Adding software H265 encoder: {}", h265_soft.name);
|
||||
res.push(h265_soft);
|
||||
}
|
||||
}
|
||||
|
||||
// Add VP8 software encoder if not already present
|
||||
if !res.iter().any(|c| c.format == VP8) {
|
||||
if let Some(vp8_soft) = soft_codecs.vp8 {
|
||||
debug!("Adding software VP8 encoder: {}", vp8_soft.name);
|
||||
res.push(vp8_soft);
|
||||
}
|
||||
}
|
||||
|
||||
// Add VP9 software encoder if not already present
|
||||
if !res.iter().any(|c| c.format == VP9) {
|
||||
if let Some(vp9_soft) = soft_codecs.vp9 {
|
||||
debug!("Adding software VP9 encoder: {}", vp9_soft.name);
|
||||
res.push(vp9_soft);
|
||||
}
|
||||
}
|
||||
add_software_fallback(&mut res);
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use crate::common::DataFormat::{self, *};
|
||||
use crate::ffmpeg::{
|
||||
AVHWDeviceType::{self, *},
|
||||
AVPixelFormat,
|
||||
};
|
||||
use crate::ffmpeg::AVHWDeviceType::{self, *};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::ffi::c_int;
|
||||
|
||||
@@ -86,6 +83,40 @@ impl Default for CodecInfo {
|
||||
}
|
||||
|
||||
impl CodecInfo {
|
||||
pub fn software(format: DataFormat) -> Option<Self> {
|
||||
match format {
|
||||
H264 => Some(CodecInfo {
|
||||
name: "libx264".to_owned(),
|
||||
mc_name: Default::default(),
|
||||
format: H264,
|
||||
hwdevice: AV_HWDEVICE_TYPE_NONE,
|
||||
priority: Priority::Soft as _,
|
||||
}),
|
||||
H265 => Some(CodecInfo {
|
||||
name: "libx265".to_owned(),
|
||||
mc_name: Default::default(),
|
||||
format: H265,
|
||||
hwdevice: AV_HWDEVICE_TYPE_NONE,
|
||||
priority: Priority::Soft as _,
|
||||
}),
|
||||
VP8 => Some(CodecInfo {
|
||||
name: "libvpx".to_owned(),
|
||||
mc_name: Default::default(),
|
||||
format: VP8,
|
||||
hwdevice: AV_HWDEVICE_TYPE_NONE,
|
||||
priority: Priority::Soft as _,
|
||||
}),
|
||||
VP9 => Some(CodecInfo {
|
||||
name: "libvpx-vp9".to_owned(),
|
||||
mc_name: Default::default(),
|
||||
format: VP9,
|
||||
hwdevice: AV_HWDEVICE_TYPE_NONE,
|
||||
priority: Priority::Soft as _,
|
||||
}),
|
||||
AV1 => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prioritized(coders: Vec<CodecInfo>) -> CodecInfos {
|
||||
let mut h264: Option<CodecInfo> = None;
|
||||
let mut h265: Option<CodecInfo> = None;
|
||||
@@ -148,34 +179,10 @@ impl CodecInfo {
|
||||
|
||||
pub fn soft() -> CodecInfos {
|
||||
CodecInfos {
|
||||
h264: Some(CodecInfo {
|
||||
name: "libx264".to_owned(),
|
||||
mc_name: Default::default(),
|
||||
format: H264,
|
||||
hwdevice: AV_HWDEVICE_TYPE_NONE,
|
||||
priority: Priority::Soft as _,
|
||||
}),
|
||||
h265: Some(CodecInfo {
|
||||
name: "libx265".to_owned(),
|
||||
mc_name: Default::default(),
|
||||
format: H265,
|
||||
hwdevice: AV_HWDEVICE_TYPE_NONE,
|
||||
priority: Priority::Soft as _,
|
||||
}),
|
||||
vp8: Some(CodecInfo {
|
||||
name: "libvpx".to_owned(),
|
||||
mc_name: Default::default(),
|
||||
format: VP8,
|
||||
hwdevice: AV_HWDEVICE_TYPE_NONE,
|
||||
priority: Priority::Soft as _,
|
||||
}),
|
||||
vp9: Some(CodecInfo {
|
||||
name: "libvpx-vp9".to_owned(),
|
||||
mc_name: Default::default(),
|
||||
format: VP9,
|
||||
hwdevice: AV_HWDEVICE_TYPE_NONE,
|
||||
priority: Priority::Soft as _,
|
||||
}),
|
||||
h264: CodecInfo::software(H264),
|
||||
h265: CodecInfo::software(H265),
|
||||
vp8: CodecInfo::software(VP8),
|
||||
vp9: CodecInfo::software(VP9),
|
||||
av1: None,
|
||||
}
|
||||
}
|
||||
@@ -191,6 +198,23 @@ pub struct CodecInfos {
|
||||
}
|
||||
|
||||
impl CodecInfos {
|
||||
pub fn into_vec(self) -> Vec<CodecInfo> {
|
||||
let mut codecs = Vec::new();
|
||||
if let Some(codec) = self.h264 {
|
||||
codecs.push(codec);
|
||||
}
|
||||
if let Some(codec) = self.h265 {
|
||||
codecs.push(codec);
|
||||
}
|
||||
if let Some(codec) = self.vp8 {
|
||||
codecs.push(codec);
|
||||
}
|
||||
if let Some(codec) = self.vp9 {
|
||||
codecs.push(codec);
|
||||
}
|
||||
codecs
|
||||
}
|
||||
|
||||
pub fn serialize(&self) -> Result<String, ()> {
|
||||
match serde_json::to_string_pretty(self) {
|
||||
Ok(s) => Ok(s),
|
||||
@@ -207,7 +231,7 @@ impl CodecInfos {
|
||||
}
|
||||
|
||||
pub fn ffmpeg_linesize_offset_length(
|
||||
pixfmt: AVPixelFormat,
|
||||
pixfmt: i32,
|
||||
width: usize,
|
||||
height: usize,
|
||||
align: usize,
|
||||
@@ -220,7 +244,7 @@ pub fn ffmpeg_linesize_offset_length(
|
||||
length.resize(1, 0);
|
||||
unsafe {
|
||||
if ffmpeg_ram_get_linesize_offset_length(
|
||||
pixfmt as _,
|
||||
pixfmt,
|
||||
width as _,
|
||||
height as _,
|
||||
align as _,
|
||||
|
||||
@@ -34,10 +34,12 @@ fn generate_bindings(cpp_dir: &Path) {
|
||||
.allowlist_function("I420Copy")
|
||||
// I422 conversions
|
||||
.allowlist_function("I422ToI420")
|
||||
.allowlist_function("I444ToI420")
|
||||
// NV12/NV21 conversions
|
||||
.allowlist_function("NV12ToI420")
|
||||
.allowlist_function("NV21ToI420")
|
||||
.allowlist_function("NV12Copy")
|
||||
.allowlist_function("SplitUVPlane")
|
||||
// ARGB/BGRA conversions
|
||||
.allowlist_function("ARGBToI420")
|
||||
.allowlist_function("ARGBToNV12")
|
||||
@@ -53,6 +55,7 @@ fn generate_bindings(cpp_dir: &Path) {
|
||||
// YUV to RGB conversions
|
||||
.allowlist_function("I420ToRGB24")
|
||||
.allowlist_function("I420ToARGB")
|
||||
.allowlist_function("H444ToARGB")
|
||||
.allowlist_function("NV12ToRGB24")
|
||||
.allowlist_function("NV12ToARGB")
|
||||
.allowlist_function("YUY2ToARGB")
|
||||
|
||||
@@ -58,6 +58,15 @@ int I422ToI420(const uint8_t* src_y, int src_stride_y,
|
||||
uint8_t* dst_v, int dst_stride_v,
|
||||
int width, int height);
|
||||
|
||||
// I444 (YUV444P) -> I420 (YUV420P) with horizontal and vertical chroma downsampling
|
||||
int I444ToI420(const uint8_t* src_y, int src_stride_y,
|
||||
const uint8_t* src_u, int src_stride_u,
|
||||
const uint8_t* src_v, int src_stride_v,
|
||||
uint8_t* dst_y, int dst_stride_y,
|
||||
uint8_t* dst_u, int dst_stride_u,
|
||||
uint8_t* dst_v, int dst_stride_v,
|
||||
int width, int height);
|
||||
|
||||
// I420 -> NV12
|
||||
int I420ToNV12(const uint8_t* src_y, int src_stride_y,
|
||||
const uint8_t* src_u, int src_stride_u,
|
||||
@@ -94,6 +103,12 @@ int NV21ToI420(const uint8_t* src_y, int src_stride_y,
|
||||
uint8_t* dst_v, int dst_stride_v,
|
||||
int width, int height);
|
||||
|
||||
// Split interleaved UV plane into separate U and V planes
|
||||
void SplitUVPlane(const uint8_t* src_uv, int src_stride_uv,
|
||||
uint8_t* dst_u, int dst_stride_u,
|
||||
uint8_t* dst_v, int dst_stride_v,
|
||||
int width, int height);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// ARGB/BGRA conversions (32-bit RGB)
|
||||
// Note: libyuv uses ARGB to mean BGRA in memory (little-endian)
|
||||
@@ -180,6 +195,13 @@ int I420ToARGB(const uint8_t* src_y, int src_stride_y,
|
||||
uint8_t* dst_argb, int dst_stride_argb,
|
||||
int width, int height);
|
||||
|
||||
// H444 (BT.709 limited-range YUV444P) -> ARGB (BGRA)
|
||||
int H444ToARGB(const uint8_t* src_y, int src_stride_y,
|
||||
const uint8_t* src_u, int src_stride_u,
|
||||
const uint8_t* src_v, int src_stride_v,
|
||||
uint8_t* dst_argb, int dst_stride_argb,
|
||||
int width, int height);
|
||||
|
||||
// NV12 -> RGB24
|
||||
int NV12ToRGB24(const uint8_t* src_y, int src_stride_y,
|
||||
const uint8_t* src_uv, int src_stride_uv,
|
||||
|
||||
@@ -297,6 +297,93 @@ pub fn i422_to_i420_planar(
|
||||
))
|
||||
}
|
||||
|
||||
/// Convert I444 (YUV444P) to I420 (YUV420P) with separate planes and explicit strides
|
||||
/// This performs horizontal and vertical chroma downsampling using SIMD
|
||||
pub fn i444_to_i420_planar(
|
||||
src_y: &[u8],
|
||||
src_y_stride: i32,
|
||||
src_u: &[u8],
|
||||
src_u_stride: i32,
|
||||
src_v: &[u8],
|
||||
src_v_stride: i32,
|
||||
dst: &mut [u8],
|
||||
width: i32,
|
||||
height: i32,
|
||||
) -> Result<()> {
|
||||
if width % 2 != 0 || height % 2 != 0 {
|
||||
return Err(YuvError::InvalidDimensions);
|
||||
}
|
||||
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
let y_size = w * h;
|
||||
let uv_size = (w / 2) * (h / 2);
|
||||
|
||||
if dst.len() < i420_size(w, h) {
|
||||
return Err(YuvError::BufferTooSmall);
|
||||
}
|
||||
|
||||
call_yuv!(I444ToI420(
|
||||
src_y.as_ptr(),
|
||||
src_y_stride,
|
||||
src_u.as_ptr(),
|
||||
src_u_stride,
|
||||
src_v.as_ptr(),
|
||||
src_v_stride,
|
||||
dst.as_mut_ptr(),
|
||||
width,
|
||||
dst[y_size..].as_mut_ptr(),
|
||||
width / 2,
|
||||
dst[y_size + uv_size..].as_mut_ptr(),
|
||||
width / 2,
|
||||
width,
|
||||
height,
|
||||
))
|
||||
}
|
||||
|
||||
/// Split an interleaved UV plane into separate U and V planes using libyuv SIMD helpers.
|
||||
///
|
||||
/// `width` is the number of chroma samples per row, not the number of source bytes.
|
||||
pub fn split_uv_plane(
|
||||
src_uv: &[u8],
|
||||
src_stride_uv: i32,
|
||||
dst_u: &mut [u8],
|
||||
dst_stride_u: i32,
|
||||
dst_v: &mut [u8],
|
||||
dst_stride_v: i32,
|
||||
width: i32,
|
||||
height: i32,
|
||||
) -> Result<()> {
|
||||
if width <= 0 || height <= 0 {
|
||||
return Err(YuvError::InvalidDimensions);
|
||||
}
|
||||
|
||||
let width = width as usize;
|
||||
let height = height as usize;
|
||||
let src_required = (src_stride_uv as usize).saturating_mul(height);
|
||||
let dst_u_required = (dst_stride_u as usize).saturating_mul(height);
|
||||
let dst_v_required = (dst_stride_v as usize).saturating_mul(height);
|
||||
|
||||
if src_uv.len() < src_required || dst_u.len() < dst_u_required || dst_v.len() < dst_v_required {
|
||||
return Err(YuvError::BufferTooSmall);
|
||||
}
|
||||
|
||||
unsafe {
|
||||
SplitUVPlane(
|
||||
src_uv.as_ptr(),
|
||||
src_stride_uv,
|
||||
dst_u.as_mut_ptr(),
|
||||
dst_stride_u,
|
||||
dst_v.as_mut_ptr(),
|
||||
dst_stride_v,
|
||||
width as i32,
|
||||
height as i32,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// I420 <-> NV12 conversions
|
||||
// ============================================================================
|
||||
@@ -761,6 +848,41 @@ pub fn i420_to_bgra(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Resu
|
||||
))
|
||||
}
|
||||
|
||||
/// Convert H444 (BT.709 limited-range YUV444P) to BGRA.
|
||||
pub fn h444_to_bgra(
|
||||
src_y: &[u8],
|
||||
src_u: &[u8],
|
||||
src_v: &[u8],
|
||||
dst: &mut [u8],
|
||||
width: i32,
|
||||
height: i32,
|
||||
) -> Result<()> {
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
let plane_size = w * h;
|
||||
|
||||
if src_y.len() < plane_size || src_u.len() < plane_size || src_v.len() < plane_size {
|
||||
return Err(YuvError::BufferTooSmall);
|
||||
}
|
||||
|
||||
if dst.len() < argb_size(w, h) {
|
||||
return Err(YuvError::BufferTooSmall);
|
||||
}
|
||||
|
||||
call_yuv!(H444ToARGB(
|
||||
src_y.as_ptr(),
|
||||
width,
|
||||
src_u.as_ptr(),
|
||||
width,
|
||||
src_v.as_ptr(),
|
||||
width,
|
||||
dst.as_mut_ptr(),
|
||||
width * 4,
|
||||
width,
|
||||
height,
|
||||
))
|
||||
}
|
||||
|
||||
/// Convert NV12 to RGB24
|
||||
pub fn nv12_to_rgb24(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Result<()> {
|
||||
if width % 2 != 0 || height % 2 != 0 {
|
||||
|
||||
341
scripts/build-update-site.sh
Executable file
341
scripts/build-update-site.sh
Executable file
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 生成 One-KVM 在线升级静态站点并打包为可部署 tar.gz。
|
||||
# 输出目录结构:
|
||||
# <site_name>/v1/channels.json
|
||||
# <site_name>/v1/releases.json
|
||||
# <site_name>/v1/bin/<version>/one-kvm-<triple>
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
VERSION=""
|
||||
RELEASE_CHANNEL="stable"
|
||||
STABLE_VERSION=""
|
||||
BETA_VERSION=""
|
||||
PUBLISHED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
ARTIFACTS_DIR=""
|
||||
X86_64_BIN=""
|
||||
AARCH64_BIN=""
|
||||
ARMV7_BIN=""
|
||||
X86_64_SET=0
|
||||
AARCH64_SET=0
|
||||
ARMV7_SET=0
|
||||
SITE_NAME="one-kvm-update"
|
||||
OUTPUT_FILE=""
|
||||
OUTPUT_DIR="${PROJECT_ROOT}/dist"
|
||||
declare -a NOTES=()
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/build-update-site.sh --version <x.x.x> [options]
|
||||
|
||||
Required:
|
||||
--version <x.x.x> Release 版本号(如 0.1.10)
|
||||
|
||||
Artifact input (二选一,可混用):
|
||||
--artifacts-dir <dir> 自动扫描目录中的标准文件名:
|
||||
one-kvm-x86_64-unknown-linux-gnu
|
||||
one-kvm-aarch64-unknown-linux-gnu
|
||||
one-kvm-armv7-unknown-linux-gnueabihf
|
||||
--x86_64 <file> 指定 x86_64 二进制路径
|
||||
--aarch64 <file> 指定 aarch64 二进制路径
|
||||
--armv7 <file> 指定 armv7 二进制路径
|
||||
|
||||
Manifest options:
|
||||
--release-channel <stable|beta> releases.json 里该版本所属渠道,默认 stable
|
||||
--stable <x.x.x> channels.json 的 stable 指针,默认等于 --version
|
||||
--beta <x.x.x> channels.json 的 beta 指针,默认等于 --version
|
||||
--published-at <RFC3339> 发布时间,默认当前 UTC 时间
|
||||
--note <text> 发布说明,可重复传入多次
|
||||
|
||||
Output options:
|
||||
--site-name <name> 打包根目录名,默认 one-kvm-update
|
||||
--output-dir <dir> 输出目录(默认 <repo>/dist)
|
||||
--output <file.tar.gz> 输出包完整路径(优先级高于 --output-dir)
|
||||
|
||||
Other:
|
||||
-h, --help 显示帮助
|
||||
|
||||
Example:
|
||||
./scripts/build-update-site.sh \
|
||||
--version 0.1.10 \
|
||||
--artifacts-dir ./target/release \
|
||||
--release-channel stable \
|
||||
--stable 0.1.10 \
|
||||
--beta 0.1.11 \
|
||||
--note "修复 WebRTC 断流问题" \
|
||||
--note "优化 HID 输入延迟"
|
||||
EOF
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "Error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
local cmd="$1"
|
||||
command -v "$cmd" >/dev/null 2>&1 || fail "Missing required command: ${cmd}"
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s=${s//\\/\\\\}
|
||||
s=${s//\"/\\\"}
|
||||
s=${s//$'\n'/\\n}
|
||||
s=${s//$'\r'/\\r}
|
||||
s=${s//$'\t'/\\t}
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
is_valid_version() {
|
||||
local v="$1"
|
||||
[[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
}
|
||||
|
||||
is_valid_channel() {
|
||||
local c="$1"
|
||||
[[ "$c" == "stable" || "$c" == "beta" ]]
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--version)
|
||||
VERSION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--release-channel)
|
||||
RELEASE_CHANNEL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--stable)
|
||||
STABLE_VERSION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--beta)
|
||||
BETA_VERSION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--published-at)
|
||||
PUBLISHED_AT="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--note)
|
||||
NOTES+=("${2:-}")
|
||||
shift 2
|
||||
;;
|
||||
--artifacts-dir)
|
||||
ARTIFACTS_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--x86_64)
|
||||
X86_64_BIN="${2:-}"
|
||||
X86_64_SET=1
|
||||
shift 2
|
||||
;;
|
||||
--aarch64)
|
||||
AARCH64_BIN="${2:-}"
|
||||
AARCH64_SET=1
|
||||
shift 2
|
||||
;;
|
||||
--armv7)
|
||||
ARMV7_BIN="${2:-}"
|
||||
ARMV7_SET=1
|
||||
shift 2
|
||||
;;
|
||||
--site-name)
|
||||
SITE_NAME="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--output-dir)
|
||||
OUTPUT_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--output)
|
||||
OUTPUT_FILE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
fail "Unknown argument: $1 (use --help)"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
require_cmd sha256sum
|
||||
require_cmd stat
|
||||
require_cmd tar
|
||||
require_cmd mktemp
|
||||
|
||||
[[ -n "$VERSION" ]] || fail "--version is required"
|
||||
is_valid_version "$VERSION" || fail "Invalid --version: ${VERSION} (expected x.x.x)"
|
||||
is_valid_channel "$RELEASE_CHANNEL" || fail "Invalid --release-channel: ${RELEASE_CHANNEL}"
|
||||
|
||||
if [[ -z "$STABLE_VERSION" ]]; then
|
||||
STABLE_VERSION="$VERSION"
|
||||
fi
|
||||
if [[ -z "$BETA_VERSION" ]]; then
|
||||
BETA_VERSION="$VERSION"
|
||||
fi
|
||||
is_valid_version "$STABLE_VERSION" || fail "Invalid --stable: ${STABLE_VERSION}"
|
||||
is_valid_version "$BETA_VERSION" || fail "Invalid --beta: ${BETA_VERSION}"
|
||||
|
||||
if [[ -n "$ARTIFACTS_DIR" ]]; then
|
||||
[[ -d "$ARTIFACTS_DIR" ]] || fail "--artifacts-dir not found: ${ARTIFACTS_DIR}"
|
||||
[[ -n "$X86_64_BIN" ]] || X86_64_BIN="${ARTIFACTS_DIR}/one-kvm-x86_64-unknown-linux-gnu"
|
||||
[[ -n "$AARCH64_BIN" ]] || AARCH64_BIN="${ARTIFACTS_DIR}/one-kvm-aarch64-unknown-linux-gnu"
|
||||
[[ -n "$ARMV7_BIN" ]] || ARMV7_BIN="${ARTIFACTS_DIR}/one-kvm-armv7-unknown-linux-gnueabihf"
|
||||
fi
|
||||
|
||||
if [[ "$X86_64_SET" -eq 1 && ! -f "$X86_64_BIN" ]]; then
|
||||
fail "--x86_64 file not found: ${X86_64_BIN}"
|
||||
fi
|
||||
if [[ "$AARCH64_SET" -eq 1 && ! -f "$AARCH64_BIN" ]]; then
|
||||
fail "--aarch64 file not found: ${AARCH64_BIN}"
|
||||
fi
|
||||
if [[ "$ARMV7_SET" -eq 1 && ! -f "$ARMV7_BIN" ]]; then
|
||||
fail "--armv7 file not found: ${ARMV7_BIN}"
|
||||
fi
|
||||
|
||||
declare -A SRC_BY_TRIPLE=()
|
||||
if [[ -n "$X86_64_BIN" && -f "$X86_64_BIN" ]]; then
|
||||
SRC_BY_TRIPLE["x86_64-unknown-linux-gnu"]="$X86_64_BIN"
|
||||
fi
|
||||
if [[ -n "$AARCH64_BIN" && -f "$AARCH64_BIN" ]]; then
|
||||
SRC_BY_TRIPLE["aarch64-unknown-linux-gnu"]="$AARCH64_BIN"
|
||||
fi
|
||||
if [[ -n "$ARMV7_BIN" && -f "$ARMV7_BIN" ]]; then
|
||||
SRC_BY_TRIPLE["armv7-unknown-linux-gnueabihf"]="$ARMV7_BIN"
|
||||
fi
|
||||
|
||||
if [[ ${#SRC_BY_TRIPLE[@]} -eq 0 ]]; then
|
||||
fail "No artifact found. Provide --artifacts-dir or at least one of --x86_64/--aarch64/--armv7."
|
||||
fi
|
||||
|
||||
BUILD_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$BUILD_DIR"' EXIT
|
||||
|
||||
SITE_DIR="${BUILD_DIR}/${SITE_NAME}"
|
||||
V1_DIR="${SITE_DIR}/v1"
|
||||
BIN_DIR="${V1_DIR}/bin/${VERSION}"
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
declare -A SHA_BY_TRIPLE=()
|
||||
declare -A SIZE_BY_TRIPLE=()
|
||||
|
||||
TRIPLES=(
|
||||
"x86_64-unknown-linux-gnu"
|
||||
"aarch64-unknown-linux-gnu"
|
||||
"armv7-unknown-linux-gnueabihf"
|
||||
)
|
||||
|
||||
for triple in "${TRIPLES[@]}"; do
|
||||
src="${SRC_BY_TRIPLE[$triple]:-}"
|
||||
if [[ -z "$src" ]]; then
|
||||
continue
|
||||
fi
|
||||
[[ -f "$src" ]] || fail "Artifact not found for ${triple}: ${src}"
|
||||
|
||||
dest_name="one-kvm-${triple}"
|
||||
dest_path="${BIN_DIR}/${dest_name}"
|
||||
cp "$src" "$dest_path"
|
||||
|
||||
sha="$(sha256sum "$dest_path" | awk '{print $1}')"
|
||||
size="$(stat -c%s "$dest_path")"
|
||||
SHA_BY_TRIPLE["$triple"]="$sha"
|
||||
SIZE_BY_TRIPLE["$triple"]="$size"
|
||||
done
|
||||
|
||||
cat >"${V1_DIR}/channels.json" <<EOF
|
||||
{
|
||||
"stable": "${STABLE_VERSION}",
|
||||
"beta": "${BETA_VERSION}"
|
||||
}
|
||||
EOF
|
||||
|
||||
RELEASES_FILE="${V1_DIR}/releases.json"
|
||||
{
|
||||
echo '{'
|
||||
echo ' "releases": ['
|
||||
echo ' {'
|
||||
echo " \"version\": \"${VERSION}\","
|
||||
echo " \"channel\": \"${RELEASE_CHANNEL}\","
|
||||
echo " \"published_at\": \"${PUBLISHED_AT}\","
|
||||
|
||||
if [[ ${#NOTES[@]} -eq 0 ]]; then
|
||||
echo ' "notes": [],'
|
||||
else
|
||||
echo ' "notes": ['
|
||||
for i in "${!NOTES[@]}"; do
|
||||
esc_note="$(json_escape "${NOTES[$i]}")"
|
||||
if [[ "$i" -lt $((${#NOTES[@]} - 1)) ]]; then
|
||||
echo " \"${esc_note}\","
|
||||
else
|
||||
echo " \"${esc_note}\""
|
||||
fi
|
||||
done
|
||||
echo ' ],'
|
||||
fi
|
||||
|
||||
echo ' "artifacts": {'
|
||||
written=0
|
||||
for triple in "${TRIPLES[@]}"; do
|
||||
if [[ -z "${SHA_BY_TRIPLE[$triple]:-}" ]]; then
|
||||
continue
|
||||
fi
|
||||
url="/v1/bin/${VERSION}/one-kvm-${triple}"
|
||||
if [[ $written -eq 1 ]]; then
|
||||
echo ','
|
||||
fi
|
||||
cat <<EOF
|
||||
"${triple}": {
|
||||
"url": "${url}",
|
||||
"sha256": "${SHA_BY_TRIPLE[$triple]}",
|
||||
"size": ${SIZE_BY_TRIPLE[$triple]}
|
||||
}
|
||||
EOF
|
||||
written=1
|
||||
done
|
||||
echo
|
||||
echo ' }'
|
||||
echo ' }'
|
||||
echo ' ]'
|
||||
echo '}'
|
||||
} >"$RELEASES_FILE"
|
||||
|
||||
if [[ -n "$OUTPUT_FILE" ]]; then
|
||||
if [[ "$OUTPUT_FILE" != /* ]]; then
|
||||
OUTPUT_FILE="${PROJECT_ROOT}/${OUTPUT_FILE}"
|
||||
fi
|
||||
else
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
OUTPUT_FILE="${OUTPUT_DIR}/${SITE_NAME}-${VERSION}.tar.gz"
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$OUTPUT_FILE")"
|
||||
tar -C "$BUILD_DIR" -czf "$OUTPUT_FILE" "$SITE_NAME"
|
||||
|
||||
echo "Build complete:"
|
||||
echo " package: ${OUTPUT_FILE}"
|
||||
echo " site root in tar: ${SITE_NAME}/"
|
||||
echo " release version: ${VERSION}"
|
||||
echo " release channel: ${RELEASE_CHANNEL}"
|
||||
echo " channels: stable=${STABLE_VERSION}, beta=${BETA_VERSION}"
|
||||
echo " artifacts:"
|
||||
for triple in "${TRIPLES[@]}"; do
|
||||
if [[ -n "${SHA_BY_TRIPLE[$triple]:-}" ]]; then
|
||||
echo " - ${triple}: size=${SIZE_BY_TRIPLE[$triple]} sha256=${SHA_BY_TRIPLE[$triple]}"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
echo "Deploy example:"
|
||||
echo " tar -xzf \"${OUTPUT_FILE}\" -C /var/www/"
|
||||
echo " # then ensure nginx root points to /var/www/${SITE_NAME}"
|
||||
@@ -43,32 +43,93 @@ pub struct AtxController {
|
||||
}
|
||||
|
||||
impl AtxController {
|
||||
async fn init_components(inner: &mut AtxInner) {
|
||||
// Initialize power executor
|
||||
if inner.config.power.is_configured() {
|
||||
let mut executor = AtxKeyExecutor::new(inner.config.power.clone());
|
||||
if let Err(e) = executor.init().await {
|
||||
warn!("Failed to initialize power executor: {}", e);
|
||||
} else {
|
||||
info!(
|
||||
"Power executor initialized: {:?} on {} pin {}",
|
||||
inner.config.power.driver, inner.config.power.device, inner.config.power.pin
|
||||
);
|
||||
inner.power_executor = Some(executor);
|
||||
}
|
||||
}
|
||||
fn should_share_serial_device(power: &AtxKeyConfig, reset: &AtxKeyConfig) -> bool {
|
||||
power.is_configured()
|
||||
&& reset.is_configured()
|
||||
&& power.driver == super::types::AtxDriverType::Serial
|
||||
&& reset.driver == super::types::AtxDriverType::Serial
|
||||
&& !power.device.is_empty()
|
||||
&& power.device == reset.device
|
||||
&& power.baud_rate == reset.baud_rate
|
||||
}
|
||||
|
||||
// Initialize reset executor
|
||||
if inner.config.reset.is_configured() {
|
||||
let mut executor = AtxKeyExecutor::new(inner.config.reset.clone());
|
||||
if let Err(e) = executor.init().await {
|
||||
warn!("Failed to initialize reset executor: {}", e);
|
||||
} else {
|
||||
info!(
|
||||
"Reset executor initialized: {:?} on {} pin {}",
|
||||
inner.config.reset.driver, inner.config.reset.device, inner.config.reset.pin
|
||||
);
|
||||
inner.reset_executor = Some(executor);
|
||||
async fn init_components(inner: &mut AtxInner) {
|
||||
if Self::should_share_serial_device(&inner.config.power, &inner.config.reset) {
|
||||
match AtxKeyExecutor::open_shared_serial(
|
||||
&inner.config.power.device,
|
||||
inner.config.power.baud_rate,
|
||||
) {
|
||||
Ok(shared_serial) => {
|
||||
let mut power_executor = AtxKeyExecutor::new_with_shared_serial(
|
||||
inner.config.power.clone(),
|
||||
shared_serial.clone(),
|
||||
);
|
||||
if let Err(e) = power_executor.init().await {
|
||||
warn!("Failed to initialize power executor: {}", e);
|
||||
} else {
|
||||
info!(
|
||||
"Power executor initialized: {:?} on {} pin {}",
|
||||
inner.config.power.driver,
|
||||
inner.config.power.device,
|
||||
inner.config.power.pin
|
||||
);
|
||||
inner.power_executor = Some(power_executor);
|
||||
}
|
||||
|
||||
let mut reset_executor = AtxKeyExecutor::new_with_shared_serial(
|
||||
inner.config.reset.clone(),
|
||||
shared_serial,
|
||||
);
|
||||
if let Err(e) = reset_executor.init().await {
|
||||
warn!("Failed to initialize reset executor: {}", e);
|
||||
} else {
|
||||
info!(
|
||||
"Reset executor initialized: {:?} on {} pin {}",
|
||||
inner.config.reset.driver,
|
||||
inner.config.reset.device,
|
||||
inner.config.reset.pin
|
||||
);
|
||||
inner.reset_executor = Some(reset_executor);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to open shared serial device {} for ATX power/reset: {}",
|
||||
inner.config.power.device, e
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Initialize power executor
|
||||
if inner.config.power.is_configured() {
|
||||
let mut executor = AtxKeyExecutor::new(inner.config.power.clone());
|
||||
if let Err(e) = executor.init().await {
|
||||
warn!("Failed to initialize power executor: {}", e);
|
||||
} else {
|
||||
info!(
|
||||
"Power executor initialized: {:?} on {} pin {}",
|
||||
inner.config.power.driver,
|
||||
inner.config.power.device,
|
||||
inner.config.power.pin
|
||||
);
|
||||
inner.power_executor = Some(executor);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize reset executor
|
||||
if inner.config.reset.is_configured() {
|
||||
let mut executor = AtxKeyExecutor::new(inner.config.reset.clone());
|
||||
if let Err(e) = executor.init().await {
|
||||
warn!("Failed to initialize reset executor: {}", e);
|
||||
} else {
|
||||
info!(
|
||||
"Reset executor initialized: {:?} on {} pin {}",
|
||||
inner.config.reset.driver,
|
||||
inner.config.reset.device,
|
||||
inner.config.reset.pin
|
||||
);
|
||||
inner.reset_executor = Some(executor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,3 +323,49 @@ impl AtxController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::atx::AtxDriverType;
|
||||
|
||||
#[test]
|
||||
fn test_should_share_serial_device_true() {
|
||||
let power = AtxKeyConfig {
|
||||
driver: AtxDriverType::Serial,
|
||||
device: "/dev/ttyUSB0".to_string(),
|
||||
pin: 1,
|
||||
active_level: super::super::types::ActiveLevel::High,
|
||||
baud_rate: 9600,
|
||||
};
|
||||
let reset = AtxKeyConfig {
|
||||
driver: AtxDriverType::Serial,
|
||||
device: "/dev/ttyUSB0".to_string(),
|
||||
pin: 2,
|
||||
active_level: super::super::types::ActiveLevel::High,
|
||||
baud_rate: 9600,
|
||||
};
|
||||
|
||||
assert!(AtxController::should_share_serial_device(&power, &reset));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_share_serial_device_false_on_different_baud() {
|
||||
let power = AtxKeyConfig {
|
||||
driver: AtxDriverType::Serial,
|
||||
device: "/dev/ttyUSB0".to_string(),
|
||||
pin: 1,
|
||||
active_level: super::super::types::ActiveLevel::High,
|
||||
baud_rate: 9600,
|
||||
};
|
||||
let reset = AtxKeyConfig {
|
||||
driver: AtxDriverType::Serial,
|
||||
device: "/dev/ttyUSB0".to_string(),
|
||||
pin: 2,
|
||||
active_level: super::super::types::ActiveLevel::High,
|
||||
baud_rate: 115200,
|
||||
};
|
||||
|
||||
assert!(!AtxController::should_share_serial_device(&power, &reset));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use serialport::SerialPort;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{debug, info};
|
||||
@@ -16,6 +16,8 @@ use tracing::{debug, info};
|
||||
use super::types::{ActiveLevel, AtxDriverType, AtxKeyConfig};
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
pub type SharedSerialHandle = Arc<Mutex<Box<dyn SerialPort>>>;
|
||||
|
||||
/// Timing constants for ATX operations
|
||||
pub mod timing {
|
||||
use std::time::Duration;
|
||||
@@ -39,8 +41,8 @@ pub struct AtxKeyExecutor {
|
||||
gpio_handle: Mutex<Option<LineHandle>>,
|
||||
/// Cached USB relay file handle to avoid repeated open/close syscalls
|
||||
usb_relay_handle: Mutex<Option<File>>,
|
||||
/// Cached Serial port handle
|
||||
serial_handle: Mutex<Option<Box<dyn SerialPort>>>,
|
||||
/// Cached Serial port handle (can be shared across power/reset executors)
|
||||
serial_handle: Mutex<Option<SharedSerialHandle>>,
|
||||
initialized: AtomicBool,
|
||||
}
|
||||
|
||||
@@ -56,6 +58,26 @@ impl AtxKeyExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new executor with a pre-opened shared serial handle.
|
||||
pub fn new_with_shared_serial(config: AtxKeyConfig, serial_handle: SharedSerialHandle) -> Self {
|
||||
Self {
|
||||
config,
|
||||
gpio_handle: Mutex::new(None),
|
||||
usb_relay_handle: Mutex::new(None),
|
||||
serial_handle: Mutex::new(Some(serial_handle)),
|
||||
initialized: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a serial relay device and wrap it for shared use.
|
||||
pub fn open_shared_serial(device: &str, baud_rate: u32) -> Result<SharedSerialHandle> {
|
||||
let port = serialport::new(device, baud_rate)
|
||||
.timeout(Duration::from_millis(100))
|
||||
.open()
|
||||
.map_err(|e| AppError::Internal(format!("Serial port open failed: {}", e)))?;
|
||||
Ok(Arc::new(Mutex::new(port)))
|
||||
}
|
||||
|
||||
/// Check if this executor is configured
|
||||
pub fn is_configured(&self) -> bool {
|
||||
self.config.is_configured()
|
||||
@@ -181,14 +203,11 @@ impl AtxKeyExecutor {
|
||||
self.config.device, self.config.pin
|
||||
);
|
||||
|
||||
let baud_rate = self.config.baud_rate;
|
||||
|
||||
let port = serialport::new(&self.config.device, baud_rate)
|
||||
.timeout(Duration::from_millis(100))
|
||||
.open()
|
||||
.map_err(|e| AppError::Internal(format!("Serial port open failed: {}", e)))?;
|
||||
|
||||
*self.serial_handle.lock().unwrap() = Some(port);
|
||||
let existing_handle = self.serial_handle.lock().unwrap().as_ref().cloned();
|
||||
if existing_handle.is_none() {
|
||||
let shared = Self::open_shared_serial(&self.config.device, self.config.baud_rate)?;
|
||||
*self.serial_handle.lock().unwrap() = Some(shared);
|
||||
}
|
||||
|
||||
// Ensure relay is off initially
|
||||
self.send_serial_relay_command(false)?;
|
||||
@@ -337,10 +356,14 @@ impl AtxKeyExecutor {
|
||||
// OFF: A0 01 00 A1
|
||||
let cmd = [0xA0, channel, state, checksum];
|
||||
|
||||
let mut guard = self.serial_handle.lock().unwrap();
|
||||
let port = guard
|
||||
.as_mut()
|
||||
let serial_handle = self
|
||||
.serial_handle
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.ok_or_else(|| AppError::Internal("Serial relay not initialized".to_string()))?;
|
||||
let mut port = serial_handle.lock().unwrap();
|
||||
|
||||
port.write_all(&cmd)
|
||||
.map_err(|e| AppError::Internal(format!("Serial relay write failed: {}", e)))?;
|
||||
|
||||
@@ -93,11 +93,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_discover_devices() {
|
||||
let devices = discover_devices();
|
||||
// Just verify the function runs without error
|
||||
assert!(devices.gpio_chips.len() >= 0);
|
||||
assert!(devices.usb_relays.len() >= 0);
|
||||
assert!(devices.serial_ports.len() >= 0);
|
||||
let _devices = discover_devices();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -97,12 +97,14 @@ pub struct AudioFrame {
|
||||
}
|
||||
|
||||
impl AudioFrame {
|
||||
pub fn new(data: Bytes, config: &AudioConfig, sequence: u64) -> Self {
|
||||
/// One capture block: `sample_rate` must be the **hardware** rate (e.g. ALSA `actual_rate`).
|
||||
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self {
|
||||
let bps = 2 * channels;
|
||||
Self {
|
||||
samples: data.len() as u32 / config.bytes_per_sample(),
|
||||
samples: data.len() as u32 / bps,
|
||||
data,
|
||||
sample_rate: config.sample_rate,
|
||||
channels: config.channels,
|
||||
sample_rate,
|
||||
channels,
|
||||
sequence,
|
||||
timestamp: Instant::now(),
|
||||
}
|
||||
@@ -285,10 +287,17 @@ fn run_capture(
|
||||
.map(|h| h.get_rate().unwrap_or(config.sample_rate))
|
||||
.unwrap_or(config.sample_rate);
|
||||
|
||||
info!(
|
||||
"Audio capture configured: {}Hz {}ch (requested {}Hz)",
|
||||
actual_rate, config.channels, config.sample_rate
|
||||
);
|
||||
if actual_rate != config.sample_rate {
|
||||
info!(
|
||||
"ALSA sample rate differs from requested ({}Hz vs {}Hz); streamer will resample to 48000Hz for Opus",
|
||||
actual_rate, config.sample_rate
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"Audio capture configured: {}Hz {}ch (requested {}Hz)",
|
||||
actual_rate, config.channels, config.sample_rate
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare for capture
|
||||
pcm.prepare()
|
||||
@@ -296,9 +305,17 @@ fn run_capture(
|
||||
|
||||
let _ = state.send(CaptureState::Running);
|
||||
|
||||
// Allocate buffer - use u8 directly for zero-copy
|
||||
let frame_bytes = config.bytes_per_frame();
|
||||
let mut buffer = vec![0u8; frame_bytes];
|
||||
// Sized from actual period — `readi` may return up to ~one period of frames per call.
|
||||
let period_frames = pcm
|
||||
.hw_params_current()
|
||||
.ok()
|
||||
.and_then(|h| h.get_period_size().ok())
|
||||
.map(|f| f as usize)
|
||||
.unwrap_or(1024)
|
||||
.max(256);
|
||||
let buf_frames = period_frames.saturating_mul(4).max(2048);
|
||||
let bytes_per_frame = (config.channels as usize) * 2;
|
||||
let mut buffer = vec![0u8; buf_frames * bytes_per_frame];
|
||||
|
||||
// Capture loop
|
||||
while !stop_flag.load(Ordering::Relaxed) {
|
||||
@@ -337,8 +354,12 @@ fn run_capture(
|
||||
|
||||
// Directly use the buffer slice (already in correct byte format)
|
||||
let seq = sequence.fetch_add(1, Ordering::Relaxed);
|
||||
let frame =
|
||||
AudioFrame::new(Bytes::copy_from_slice(&buffer[..byte_count]), config, seq);
|
||||
let frame = AudioFrame::new_interleaved(
|
||||
Bytes::copy_from_slice(&buffer[..byte_count]),
|
||||
config.channels,
|
||||
actual_rate,
|
||||
seq,
|
||||
);
|
||||
|
||||
// Send to subscribers
|
||||
if frame_tx.receiver_count() > 0 {
|
||||
|
||||
@@ -13,7 +13,7 @@ use super::encoder::{OpusConfig, OpusFrame};
|
||||
use super::monitor::{AudioHealthMonitor, AudioHealthStatus};
|
||||
use super::streamer::{AudioStreamer, AudioStreamerConfig};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::events::{EventBus, SystemEvent};
|
||||
use crate::events::EventBus;
|
||||
|
||||
/// Audio quality presets
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
@@ -139,17 +139,15 @@ impl AudioController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set event bus for publishing audio events
|
||||
/// Set event bus for internal state notifications.
|
||||
pub async fn set_event_bus(&self, event_bus: Arc<EventBus>) {
|
||||
*self.event_bus.write().await = Some(event_bus.clone());
|
||||
// Also set event bus on the monitor for health notifications
|
||||
self.monitor.set_event_bus(event_bus).await;
|
||||
*self.event_bus.write().await = Some(event_bus);
|
||||
}
|
||||
|
||||
/// Publish an event to the event bus
|
||||
async fn publish_event(&self, event: SystemEvent) {
|
||||
/// Mark the device-info snapshot as stale.
|
||||
async fn mark_device_info_dirty(&self) {
|
||||
if let Some(ref bus) = *self.event_bus.read().await {
|
||||
bus.publish(event);
|
||||
bus.mark_device_info_dirty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,12 +205,6 @@ impl AudioController {
|
||||
config.device = device.to_string();
|
||||
}
|
||||
|
||||
// Publish event
|
||||
self.publish_event(SystemEvent::AudioDeviceSelected {
|
||||
device: device.to_string(),
|
||||
})
|
||||
.await;
|
||||
|
||||
info!("Audio device selected: {}", device);
|
||||
|
||||
// If streaming, restart with new device
|
||||
@@ -237,12 +229,6 @@ impl AudioController {
|
||||
streamer.set_bitrate(quality.bitrate()).await?;
|
||||
}
|
||||
|
||||
// Publish event
|
||||
self.publish_event(SystemEvent::AudioQualityChanged {
|
||||
quality: quality.to_string(),
|
||||
})
|
||||
.await;
|
||||
|
||||
info!(
|
||||
"Audio quality set to: {:?} ({}bps)",
|
||||
quality,
|
||||
@@ -290,11 +276,7 @@ impl AudioController {
|
||||
.report_error(Some(&config.device), &error_msg, "start_failed")
|
||||
.await;
|
||||
|
||||
self.publish_event(SystemEvent::AudioStateChanged {
|
||||
streaming: false,
|
||||
device: None,
|
||||
})
|
||||
.await;
|
||||
self.mark_device_info_dirty().await;
|
||||
|
||||
return Err(AppError::AudioError(error_msg));
|
||||
}
|
||||
@@ -306,12 +288,7 @@ impl AudioController {
|
||||
self.monitor.report_recovered(Some(&config.device)).await;
|
||||
}
|
||||
|
||||
// Publish event
|
||||
self.publish_event(SystemEvent::AudioStateChanged {
|
||||
streaming: true,
|
||||
device: Some(config.device),
|
||||
})
|
||||
.await;
|
||||
self.mark_device_info_dirty().await;
|
||||
|
||||
info!("Audio streaming started");
|
||||
Ok(())
|
||||
@@ -323,12 +300,7 @@ impl AudioController {
|
||||
streamer.stop().await?;
|
||||
}
|
||||
|
||||
// Publish event
|
||||
self.publish_event(SystemEvent::AudioStateChanged {
|
||||
streaming: false,
|
||||
device: None,
|
||||
})
|
||||
.await;
|
||||
self.mark_device_info_dirty().await;
|
||||
|
||||
info!("Audio streaming stopped");
|
||||
Ok(())
|
||||
@@ -408,9 +380,8 @@ impl AudioController {
|
||||
/// Update full configuration
|
||||
pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> {
|
||||
let was_streaming = self.is_streaming().await;
|
||||
let old_config = self.config.read().await.clone();
|
||||
|
||||
// Stop streaming if running
|
||||
// Stop streaming if running (device/quality/enabled may all change)
|
||||
if was_streaming {
|
||||
self.stop_streaming().await?;
|
||||
}
|
||||
@@ -418,26 +389,13 @@ impl AudioController {
|
||||
// Update config
|
||||
*self.config.write().await = new_config.clone();
|
||||
|
||||
// Restart streaming if it was running and still enabled
|
||||
if was_streaming && new_config.enabled {
|
||||
// Start whenever audio is enabled — not only when we were already streaming.
|
||||
// Otherwise PATCH /config/audio alone leaves enabled=true with no capture until
|
||||
// POST /audio/start, which races WebRTC reconnect and matches "apply twice" reports.
|
||||
if new_config.enabled {
|
||||
self.start_streaming().await?;
|
||||
}
|
||||
|
||||
// Publish events for changes
|
||||
if old_config.device != new_config.device {
|
||||
self.publish_event(SystemEvent::AudioDeviceSelected {
|
||||
device: new_config.device.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
if old_config.quality != new_config.quality {
|
||||
self.publish_event(SystemEvent::AudioQualityChanged {
|
||||
quality: new_config.quality.to_string(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod controller;
|
||||
pub mod device;
|
||||
pub mod encoder;
|
||||
pub mod monitor;
|
||||
pub mod resample;
|
||||
pub mod streamer;
|
||||
|
||||
pub use capture::{AudioCapturer, AudioConfig, AudioFrame};
|
||||
|
||||
@@ -3,16 +3,14 @@
|
||||
//! This module provides health monitoring for audio capture devices, including:
|
||||
//! - Device connectivity checks
|
||||
//! - Automatic reconnection on failure
|
||||
//! - Error tracking and notification
|
||||
//! - Error tracking
|
||||
//! - Log throttling to prevent log flooding
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::events::{EventBus, SystemEvent};
|
||||
use crate::utils::LogThrottler;
|
||||
|
||||
/// Audio health status
|
||||
@@ -58,19 +56,13 @@ impl Default for AudioMonitorConfig {
|
||||
/// Audio health monitor
|
||||
///
|
||||
/// Monitors audio device health and manages error recovery.
|
||||
/// Publishes WebSocket events when device status changes.
|
||||
pub struct AudioHealthMonitor {
|
||||
/// Current health status
|
||||
status: RwLock<AudioHealthStatus>,
|
||||
/// Event bus for notifications
|
||||
events: RwLock<Option<Arc<EventBus>>>,
|
||||
/// Log throttler to prevent log flooding
|
||||
throttler: LogThrottler,
|
||||
/// Configuration
|
||||
config: AudioMonitorConfig,
|
||||
/// Whether monitoring is active (reserved for future use)
|
||||
#[allow(dead_code)]
|
||||
running: AtomicBool,
|
||||
/// Current retry count
|
||||
retry_count: AtomicU32,
|
||||
/// Last error code (for change detection)
|
||||
@@ -83,10 +75,8 @@ impl AudioHealthMonitor {
|
||||
let throttle_secs = config.log_throttle_secs;
|
||||
Self {
|
||||
status: RwLock::new(AudioHealthStatus::Healthy),
|
||||
events: RwLock::new(None),
|
||||
throttler: LogThrottler::with_secs(throttle_secs),
|
||||
config,
|
||||
running: AtomicBool::new(false),
|
||||
retry_count: AtomicU32::new(0),
|
||||
last_error_code: RwLock::new(None),
|
||||
}
|
||||
@@ -97,24 +87,19 @@ impl AudioHealthMonitor {
|
||||
Self::new(AudioMonitorConfig::default())
|
||||
}
|
||||
|
||||
/// Set the event bus for broadcasting state changes
|
||||
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
|
||||
*self.events.write().await = Some(events);
|
||||
}
|
||||
|
||||
/// Report an error from audio operations
|
||||
///
|
||||
/// This method is called when an audio operation fails. It:
|
||||
/// 1. Updates the health status
|
||||
/// 2. Logs the error (with throttling)
|
||||
/// 3. Publishes a WebSocket event if the error is new or changed
|
||||
/// 3. Updates in-memory error state
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `device` - The audio device name (if known)
|
||||
/// * `reason` - Human-readable error description
|
||||
/// * `error_code` - Error code for programmatic handling
|
||||
pub async fn report_error(&self, device: Option<&str>, reason: &str, error_code: &str) {
|
||||
pub async fn report_error(&self, _device: Option<&str>, reason: &str, error_code: &str) {
|
||||
let count = self.retry_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
// Check if error code changed
|
||||
@@ -141,44 +126,17 @@ impl AudioHealthMonitor {
|
||||
error_code: error_code.to_string(),
|
||||
retry_count: count,
|
||||
};
|
||||
|
||||
// Publish event (only if error changed or first occurrence)
|
||||
if error_changed || count == 1 {
|
||||
if let Some(ref events) = *self.events.read().await {
|
||||
events.publish(SystemEvent::AudioDeviceLost {
|
||||
device: device.map(|s| s.to_string()),
|
||||
reason: reason.to_string(),
|
||||
error_code: error_code.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Report that a reconnection attempt is starting
|
||||
///
|
||||
/// Publishes a reconnecting event to notify clients.
|
||||
pub async fn report_reconnecting(&self) {
|
||||
let attempt = self.retry_count.load(Ordering::Relaxed);
|
||||
|
||||
// Only publish every 5 attempts to avoid event spam
|
||||
if attempt == 1 || attempt.is_multiple_of(5) {
|
||||
debug!("Audio reconnecting, attempt {}", attempt);
|
||||
|
||||
if let Some(ref events) = *self.events.read().await {
|
||||
events.publish(SystemEvent::AudioReconnecting { attempt });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Report that the device has recovered
|
||||
///
|
||||
/// This method is called when the audio device successfully reconnects.
|
||||
/// It resets the error state and publishes a recovery event.
|
||||
/// It resets the error state.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `device` - The audio device name
|
||||
pub async fn report_recovered(&self, device: Option<&str>) {
|
||||
pub async fn report_recovered(&self, _device: Option<&str>) {
|
||||
let prev_status = self.status.read().await.clone();
|
||||
|
||||
// Only report recovery if we were in an error state
|
||||
@@ -191,13 +149,6 @@ impl AudioHealthMonitor {
|
||||
self.throttler.clear("audio_");
|
||||
*self.last_error_code.write().await = None;
|
||||
*self.status.write().await = AudioHealthStatus::Healthy;
|
||||
|
||||
// Publish recovery event
|
||||
if let Some(ref events) = *self.events.read().await {
|
||||
events.publish(SystemEvent::AudioRecovered {
|
||||
device: device.map(|s| s.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
202
src/audio/resample.rs
Normal file
202
src/audio/resample.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
//! Resample capture PCM to 48 kHz stereo for Opus (fixed 20 ms / 960×2 samples).
|
||||
|
||||
const OUT_RATE: f64 = 48000.0;
|
||||
const OPUS_STEREO_SAMPLES: usize = 960 * 2;
|
||||
|
||||
enum PipelineState {
|
||||
/// Native 48 kHz interleaved stereo: only buffer and slice into 20 ms blocks (no float work).
|
||||
Stereo48kPassthrough,
|
||||
/// Other rates / mono: linear interpolation to 48 kHz stereo.
|
||||
Resample {
|
||||
in_rate: u32,
|
||||
in_channels: u32,
|
||||
next_out_frame: u64,
|
||||
buffer_start_frame: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Converts incoming interleaved PCM to 48 kHz stereo, then exposes fixed 960×2-sample chunks.
|
||||
pub struct Opus48kPcmBuffer {
|
||||
state: PipelineState,
|
||||
pending: Vec<i16>,
|
||||
}
|
||||
|
||||
impl Opus48kPcmBuffer {
|
||||
pub fn new(in_rate: u32, in_channels: u32) -> Self {
|
||||
let ch = in_channels.max(1);
|
||||
let rate = in_rate.max(1);
|
||||
let state = if rate == 48000 && ch == 2 {
|
||||
PipelineState::Stereo48kPassthrough
|
||||
} else {
|
||||
PipelineState::Resample {
|
||||
in_rate: rate,
|
||||
in_channels: ch,
|
||||
next_out_frame: 0,
|
||||
buffer_start_frame: 0,
|
||||
}
|
||||
};
|
||||
Self {
|
||||
state,
|
||||
pending: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// True when input is already 48 kHz stereo (no interpolation loop).
|
||||
#[cfg(test)]
|
||||
pub fn is_passthrough(&self) -> bool {
|
||||
matches!(self.state, PipelineState::Stereo48kPassthrough)
|
||||
}
|
||||
|
||||
/// Append one capture block (`sample_rate` must match the rate this buffer was built for).
|
||||
pub fn push_interleaved(&mut self, data: &[i16]) {
|
||||
self.pending.extend_from_slice(data);
|
||||
}
|
||||
|
||||
/// Drain as many 960×2 stereo S16LE samples (20 ms @ 48 kHz) as possible.
|
||||
pub fn pop_opus_frames(&mut self, out: &mut Vec<i16>) {
|
||||
match &mut self.state {
|
||||
PipelineState::Stereo48kPassthrough => {
|
||||
while self.pending.len() >= OPUS_STEREO_SAMPLES {
|
||||
out.extend_from_slice(&self.pending[..OPUS_STEREO_SAMPLES]);
|
||||
self.pending.drain(..OPUS_STEREO_SAMPLES);
|
||||
}
|
||||
}
|
||||
PipelineState::Resample {
|
||||
in_rate,
|
||||
in_channels,
|
||||
next_out_frame,
|
||||
buffer_start_frame,
|
||||
} => {
|
||||
let ch = *in_channels as usize;
|
||||
if ch == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
let batch_start = *next_out_frame;
|
||||
let mut block = Vec::with_capacity(OPUS_STEREO_SAMPLES);
|
||||
let mut complete = true;
|
||||
|
||||
for i in 0u64..960 {
|
||||
let k = batch_start + i;
|
||||
let p_abs = (k as f64) * (*in_rate as f64) / OUT_RATE;
|
||||
let f_abs = p_abs.floor() as u64;
|
||||
let frac = p_abs - f_abs as f64;
|
||||
|
||||
let f_rel = f_abs.saturating_sub(*buffer_start_frame) as usize;
|
||||
if f_rel + 1 >= self.pending.len() / ch {
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
|
||||
let base0 = f_rel * ch;
|
||||
let base1 = (f_rel + 1) * ch;
|
||||
|
||||
let (l, r) = if *in_channels >= 2 {
|
||||
let l0 = self.pending[base0] as f64;
|
||||
let l1 = self.pending[base1] as f64;
|
||||
let r0 = self.pending[base0 + 1] as f64;
|
||||
let r1 = self.pending[base1 + 1] as f64;
|
||||
(l0 + frac * (l1 - l0), r0 + frac * (r1 - r0))
|
||||
} else {
|
||||
let m0 = self.pending[base0] as f64;
|
||||
let m1 = self.pending[base1] as f64;
|
||||
let v = m0 + frac * (m1 - m0);
|
||||
(v, v)
|
||||
};
|
||||
|
||||
block.push(clamp_f64_to_i16(l));
|
||||
block.push(clamp_f64_to_i16(r));
|
||||
}
|
||||
|
||||
if !complete || block.len() != OPUS_STEREO_SAMPLES {
|
||||
break;
|
||||
}
|
||||
|
||||
out.extend_from_slice(&block);
|
||||
*next_out_frame = batch_start + 960;
|
||||
trim_resample_prefix(
|
||||
&mut self.pending,
|
||||
*in_rate,
|
||||
*next_out_frame,
|
||||
buffer_start_frame,
|
||||
ch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_resample_prefix(
|
||||
pending: &mut Vec<i16>,
|
||||
in_rate: u32,
|
||||
next_out_frame: u64,
|
||||
buffer_start_frame: &mut u64,
|
||||
ch: usize,
|
||||
) {
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let p_next = (next_out_frame as f64) * (in_rate as f64) / OUT_RATE;
|
||||
let need_abs = p_next.floor() as u64;
|
||||
let keep_from_abs = need_abs.saturating_sub(1);
|
||||
if keep_from_abs <= *buffer_start_frame {
|
||||
return;
|
||||
}
|
||||
|
||||
let drop_frames = (keep_from_abs - *buffer_start_frame) as usize;
|
||||
let drop_samples = drop_frames.saturating_mul(ch).min(pending.len());
|
||||
if drop_samples > 0 {
|
||||
pending.drain(0..drop_samples);
|
||||
*buffer_start_frame += drop_frames as u64;
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn clamp_f64_to_i16(v: f64) -> i16 {
|
||||
v.round().clamp(i16::MIN as f64, i16::MAX as f64) as i16
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn passthrough_48k_identity_tone_length() {
|
||||
let mut buf = Opus48kPcmBuffer::new(48000, 2);
|
||||
assert!(buf.is_passthrough());
|
||||
let mut chunk = vec![0i16; 960 * 2];
|
||||
for i in 0..960 {
|
||||
let s = (i as f32 * 0.1).sin() * 3000.0;
|
||||
chunk[2 * i] = s as i16;
|
||||
chunk[2 * i + 1] = s as i16;
|
||||
}
|
||||
buf.push_interleaved(&chunk);
|
||||
let mut out = Vec::new();
|
||||
buf.pop_opus_frames(&mut out);
|
||||
assert_eq!(out.len(), 960 * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsample_44k_to_48k_chunk() {
|
||||
let mut buf = Opus48kPcmBuffer::new(44100, 2);
|
||||
assert!(!buf.is_passthrough());
|
||||
let mut chunk = vec![0i16; 882 * 2];
|
||||
for i in 0..882 {
|
||||
chunk[2 * i] = (i as i16).wrapping_mul(10);
|
||||
chunk[2 * i + 1] = (i as i16).wrapping_mul(-7);
|
||||
}
|
||||
buf.push_interleaved(&chunk);
|
||||
let mut out = Vec::new();
|
||||
buf.pop_opus_frames(&mut out);
|
||||
assert_eq!(out.len(), 960 * 2, "expected one 20ms Opus block");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mono_48k_not_passthrough() {
|
||||
let buf = Opus48kPcmBuffer::new(48000, 1);
|
||||
assert!(!buf.is_passthrough());
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,12 @@ use std::time::Instant;
|
||||
use tokio::sync::{broadcast, watch, Mutex, RwLock};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use super::capture::{AudioCapturer, AudioConfig, CaptureState};
|
||||
use super::capture::{AudioCapturer, AudioConfig, AudioFrame, CaptureState};
|
||||
use super::encoder::{OpusConfig, OpusEncoder, OpusFrame};
|
||||
use super::resample::Opus48kPcmBuffer;
|
||||
use crate::error::{AppError, Result};
|
||||
use bytemuck;
|
||||
use bytes::Bytes;
|
||||
|
||||
/// Audio stream state
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
@@ -254,6 +257,9 @@ impl AudioStreamer {
|
||||
|
||||
info!("Audio stream task started");
|
||||
|
||||
let mut to_48k: Option<Opus48kPcmBuffer> = None;
|
||||
let mut queued_48k: Vec<i16> = Vec::new();
|
||||
|
||||
loop {
|
||||
// Check stop flag (atomic, no async lock needed)
|
||||
if stop_flag.load(Ordering::Relaxed) {
|
||||
@@ -273,27 +279,56 @@ impl AudioStreamer {
|
||||
|
||||
match recv_result {
|
||||
Ok(Ok(audio_frame)) => {
|
||||
// Encode to Opus
|
||||
let opus_result = {
|
||||
let mut enc_guard = encoder.lock().await;
|
||||
(*enc_guard)
|
||||
.as_mut()
|
||||
.map(|enc| enc.encode_frame(&audio_frame))
|
||||
if to_48k.is_none() {
|
||||
to_48k = Some(Opus48kPcmBuffer::new(
|
||||
audio_frame.sample_rate,
|
||||
audio_frame.channels,
|
||||
));
|
||||
}
|
||||
let pipeline = match to_48k.as_mut() {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
match opus_result {
|
||||
Some(Ok(opus_frame)) => {
|
||||
// Publish latest frame to subscribers
|
||||
if opus_tx.receiver_count() > 0 {
|
||||
let _ = opus_tx.send(Some(Arc::new(opus_frame)));
|
||||
let samples: &[i16] = match bytemuck::try_cast_slice(&audio_frame.data) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
warn!("Audio frame size not multiple of 2; skipping");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !samples.is_empty() {
|
||||
pipeline.push_interleaved(samples);
|
||||
}
|
||||
pipeline.pop_opus_frames(&mut queued_48k);
|
||||
|
||||
while queued_48k.len() >= 960 * 2 {
|
||||
let pcm_20ms =
|
||||
Bytes::copy_from_slice(bytemuck::cast_slice(&queued_48k[..960 * 2]));
|
||||
queued_48k.drain(..960 * 2);
|
||||
|
||||
let frame_48k = AudioFrame::new_interleaved(pcm_20ms, 2, 48000, 0);
|
||||
|
||||
let opus_result = {
|
||||
let mut enc_guard = encoder.lock().await;
|
||||
(*enc_guard)
|
||||
.as_mut()
|
||||
.map(|enc| enc.encode_frame(&frame_48k))
|
||||
};
|
||||
|
||||
match opus_result {
|
||||
Some(Ok(opus_frame)) => {
|
||||
if opus_tx.receiver_count() > 0 {
|
||||
let _ = opus_tx.send(Some(Arc::new(opus_frame)));
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
error!("Opus encode error: {}", e);
|
||||
}
|
||||
None => {
|
||||
warn!("Encoder not available");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
error!("Opus encode error: {}", e);
|
||||
}
|
||||
None => {
|
||||
warn!("Encoder not available");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,15 @@ impl SessionStore {
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// Delete all sessions for a specific user
|
||||
pub async fn delete_by_user_id(&self, user_id: &str) -> Result<u64> {
|
||||
let result = sqlx::query("DELETE FROM sessions WHERE user_id = ?1")
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// List all session IDs
|
||||
pub async fn list_ids(&self) -> Result<Vec<String>> {
|
||||
let rows: Vec<(String,)> = sqlx::query_as("SELECT id FROM sessions")
|
||||
|
||||
@@ -148,13 +148,11 @@ impl Default for OtgDescriptorConfig {
|
||||
pub enum OtgHidProfile {
|
||||
/// Full HID device set (keyboard + relative mouse + absolute mouse + consumer control)
|
||||
#[default]
|
||||
#[serde(alias = "full_no_msd")]
|
||||
Full,
|
||||
/// Full HID device set without MSD
|
||||
FullNoMsd,
|
||||
/// Full HID device set without consumer control
|
||||
#[serde(alias = "full_no_consumer_no_msd")]
|
||||
FullNoConsumer,
|
||||
/// Full HID device set without consumer control and MSD
|
||||
FullNoConsumerNoMsd,
|
||||
/// Legacy profile: only keyboard
|
||||
LegacyKeyboard,
|
||||
/// Legacy profile: only relative mouse
|
||||
@@ -163,9 +161,52 @@ pub enum OtgHidProfile {
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// OTG endpoint budget policy.
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Default)]
|
||||
pub enum OtgEndpointBudget {
|
||||
/// Derive a safe default from the selected UDC.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Limit OTG gadget functions to 5 endpoints.
|
||||
Five,
|
||||
/// Limit OTG gadget functions to 6 endpoints.
|
||||
Six,
|
||||
/// Do not impose a software endpoint budget.
|
||||
Unlimited,
|
||||
}
|
||||
|
||||
impl OtgEndpointBudget {
|
||||
pub fn default_for_udc_name(udc: Option<&str>) -> Self {
|
||||
if udc.is_some_and(crate::otg::configfs::is_low_endpoint_udc) {
|
||||
Self::Five
|
||||
} else {
|
||||
Self::Six
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolved(self, udc: Option<&str>) -> Self {
|
||||
match self {
|
||||
Self::Auto => Self::default_for_udc_name(udc),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn endpoint_limit(self, udc: Option<&str>) -> Option<u8> {
|
||||
match self.resolved(udc) {
|
||||
Self::Five => Some(5),
|
||||
Self::Six => Some(6),
|
||||
Self::Unlimited => None,
|
||||
Self::Auto => unreachable!("auto budget must be resolved before use"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OTG HID function selection (used when profile is Custom)
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct OtgHidFunctions {
|
||||
pub keyboard: bool,
|
||||
@@ -214,6 +255,26 @@ impl OtgHidFunctions {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.keyboard && !self.mouse_relative && !self.mouse_absolute && !self.consumer
|
||||
}
|
||||
|
||||
pub fn endpoint_cost(&self, keyboard_leds: bool) -> u8 {
|
||||
let mut endpoints = 0;
|
||||
if self.keyboard {
|
||||
endpoints += 1;
|
||||
if keyboard_leds {
|
||||
endpoints += 1;
|
||||
}
|
||||
}
|
||||
if self.mouse_relative {
|
||||
endpoints += 1;
|
||||
}
|
||||
if self.mouse_absolute {
|
||||
endpoints += 1;
|
||||
}
|
||||
if self.consumer {
|
||||
endpoints += 1;
|
||||
}
|
||||
endpoints
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OtgHidFunctions {
|
||||
@@ -223,12 +284,21 @@ impl Default for OtgHidFunctions {
|
||||
}
|
||||
|
||||
impl OtgHidProfile {
|
||||
pub fn from_legacy_str(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"full" | "full_no_msd" => Some(Self::Full),
|
||||
"full_no_consumer" | "full_no_consumer_no_msd" => Some(Self::FullNoConsumer),
|
||||
"legacy_keyboard" => Some(Self::LegacyKeyboard),
|
||||
"legacy_mouse_relative" => Some(Self::LegacyMouseRelative),
|
||||
"custom" => Some(Self::Custom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_functions(&self, custom: &OtgHidFunctions) -> OtgHidFunctions {
|
||||
match self {
|
||||
Self::Full => OtgHidFunctions::full(),
|
||||
Self::FullNoMsd => OtgHidFunctions::full(),
|
||||
Self::FullNoConsumer => OtgHidFunctions::full_no_consumer(),
|
||||
Self::FullNoConsumerNoMsd => OtgHidFunctions::full_no_consumer(),
|
||||
Self::LegacyKeyboard => OtgHidFunctions::legacy_keyboard(),
|
||||
Self::LegacyMouseRelative => OtgHidFunctions::legacy_mouse_relative(),
|
||||
Self::Custom => custom.clone(),
|
||||
@@ -243,10 +313,6 @@ impl OtgHidProfile {
|
||||
pub struct HidConfig {
|
||||
/// HID backend type
|
||||
pub backend: HidBackend,
|
||||
/// OTG keyboard device path
|
||||
pub otg_keyboard: String,
|
||||
/// OTG mouse device path
|
||||
pub otg_mouse: String,
|
||||
/// OTG UDC (USB Device Controller) name
|
||||
pub otg_udc: Option<String>,
|
||||
/// OTG USB device descriptor configuration
|
||||
@@ -255,9 +321,15 @@ pub struct HidConfig {
|
||||
/// OTG HID function profile
|
||||
#[serde(default)]
|
||||
pub otg_profile: OtgHidProfile,
|
||||
/// OTG endpoint budget policy
|
||||
#[serde(default)]
|
||||
pub otg_endpoint_budget: OtgEndpointBudget,
|
||||
/// OTG HID function selection (used when profile is Custom)
|
||||
#[serde(default)]
|
||||
pub otg_functions: OtgHidFunctions,
|
||||
/// Enable keyboard LED/status feedback for OTG keyboard
|
||||
#[serde(default)]
|
||||
pub otg_keyboard_leds: bool,
|
||||
/// CH9329 serial port
|
||||
pub ch9329_port: String,
|
||||
/// CH9329 baud rate
|
||||
@@ -270,12 +342,12 @@ impl Default for HidConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
backend: HidBackend::None,
|
||||
otg_keyboard: "/dev/hidg0".to_string(),
|
||||
otg_mouse: "/dev/hidg1".to_string(),
|
||||
otg_udc: None,
|
||||
otg_descriptor: OtgDescriptorConfig::default(),
|
||||
otg_profile: OtgHidProfile::default(),
|
||||
otg_endpoint_budget: OtgEndpointBudget::default(),
|
||||
otg_functions: OtgHidFunctions::default(),
|
||||
otg_keyboard_leds: false,
|
||||
ch9329_port: "/dev/ttyUSB0".to_string(),
|
||||
ch9329_baudrate: 9600,
|
||||
mouse_absolute: true,
|
||||
@@ -287,6 +359,62 @@ impl HidConfig {
|
||||
pub fn effective_otg_functions(&self) -> OtgHidFunctions {
|
||||
self.otg_profile.resolve_functions(&self.otg_functions)
|
||||
}
|
||||
|
||||
pub fn resolved_otg_udc(&self) -> Option<String> {
|
||||
crate::otg::configfs::resolve_udc_name(self.otg_udc.as_deref())
|
||||
}
|
||||
|
||||
pub fn resolved_otg_endpoint_budget(&self) -> OtgEndpointBudget {
|
||||
self.otg_endpoint_budget
|
||||
.resolved(self.resolved_otg_udc().as_deref())
|
||||
}
|
||||
|
||||
pub fn resolved_otg_endpoint_limit(&self) -> Option<u8> {
|
||||
self.otg_endpoint_budget
|
||||
.endpoint_limit(self.resolved_otg_udc().as_deref())
|
||||
}
|
||||
|
||||
pub fn effective_otg_keyboard_leds(&self) -> bool {
|
||||
self.otg_keyboard_leds && self.effective_otg_functions().keyboard
|
||||
}
|
||||
|
||||
pub fn constrained_otg_functions(&self) -> OtgHidFunctions {
|
||||
self.effective_otg_functions()
|
||||
}
|
||||
|
||||
pub fn effective_otg_required_endpoints(&self, msd_enabled: bool) -> u8 {
|
||||
let functions = self.effective_otg_functions();
|
||||
let mut endpoints = functions.endpoint_cost(self.effective_otg_keyboard_leds());
|
||||
if msd_enabled {
|
||||
endpoints += 2;
|
||||
}
|
||||
endpoints
|
||||
}
|
||||
|
||||
pub fn validate_otg_endpoint_budget(&self, msd_enabled: bool) -> crate::error::Result<()> {
|
||||
if self.backend != HidBackend::Otg {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let functions = self.effective_otg_functions();
|
||||
if functions.is_empty() {
|
||||
return Err(crate::error::AppError::BadRequest(
|
||||
"OTG HID functions cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let required = self.effective_otg_required_endpoints(msd_enabled);
|
||||
if let Some(limit) = self.resolved_otg_endpoint_limit() {
|
||||
if required > limit {
|
||||
return Err(crate::error::AppError::BadRequest(format!(
|
||||
"OTG selection requires {} endpoints, but the configured limit is {}",
|
||||
required, limit
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// MSD configuration
|
||||
@@ -575,7 +703,9 @@ impl StreamConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Web server configuration
|
||||
/// Web server configuration persisted in the database (includes on-disk TLS paths).
|
||||
///
|
||||
/// The HTTP API for `/api/config/web` uses `WebConfigResponse` instead: no path fields, includes `has_custom_cert`.
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::AppConfig;
|
||||
use crate::error::{AppError, Result};
|
||||
@@ -18,6 +19,8 @@ pub struct ConfigStore {
|
||||
/// Lock-free cache using ArcSwap for zero-cost reads
|
||||
cache: Arc<ArcSwap<AppConfig>>,
|
||||
change_tx: broadcast::Sender<ConfigChange>,
|
||||
/// Serializes `set` / `update` so concurrent PATCH handlers cannot clobber each other
|
||||
write_lock: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
/// Configuration change event
|
||||
@@ -59,6 +62,7 @@ impl ConfigStore {
|
||||
pool,
|
||||
cache,
|
||||
change_tx,
|
||||
write_lock: Arc::new(Mutex::new(())),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -191,6 +195,7 @@ impl ConfigStore {
|
||||
|
||||
/// Set entire configuration
|
||||
pub async fn set(&self, config: AppConfig) -> Result<()> {
|
||||
let _guard = self.write_lock.lock().await;
|
||||
Self::save_config_to_db(&self.pool, &config).await?;
|
||||
self.cache.store(Arc::new(config));
|
||||
|
||||
@@ -204,13 +209,13 @@ impl ConfigStore {
|
||||
|
||||
/// Update configuration with a closure
|
||||
///
|
||||
/// Note: This uses a read-modify-write pattern. For concurrent updates,
|
||||
/// the last write wins. This is acceptable for configuration changes
|
||||
/// which are infrequent and typically user-initiated.
|
||||
/// Uses read-modify-write under a mutex so concurrent `update` / `set` calls are serialized
|
||||
/// and merged correctly (each closure sees the latest stored config).
|
||||
pub async fn update<F>(&self, f: F) -> Result<()>
|
||||
where
|
||||
F: FnOnce(&mut AppConfig),
|
||||
{
|
||||
let _guard = self.write_lock.lock().await;
|
||||
// Load current config, clone it for modification
|
||||
let current = self.cache.load();
|
||||
let mut config = (**current).clone();
|
||||
|
||||
@@ -7,7 +7,7 @@ pub mod types;
|
||||
|
||||
pub use types::{
|
||||
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, MsdDeviceInfo, SystemEvent,
|
||||
VideoDeviceInfo,
|
||||
TtydDeviceInfo, VideoDeviceInfo,
|
||||
};
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
@@ -15,6 +15,39 @@ use tokio::sync::broadcast;
|
||||
/// Event channel capacity (ring buffer size)
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||
|
||||
const EXACT_TOPICS: &[&str] = &[
|
||||
"stream.mode_switching",
|
||||
"stream.state_changed",
|
||||
"stream.config_changing",
|
||||
"stream.config_applied",
|
||||
"stream.device_lost",
|
||||
"stream.reconnecting",
|
||||
"stream.recovered",
|
||||
"stream.webrtc_ready",
|
||||
"stream.stats_update",
|
||||
"stream.mode_changed",
|
||||
"stream.mode_ready",
|
||||
"webrtc.ice_candidate",
|
||||
"webrtc.ice_complete",
|
||||
"msd.upload_progress",
|
||||
"msd.download_progress",
|
||||
"system.device_info",
|
||||
"error",
|
||||
];
|
||||
|
||||
const PREFIX_TOPICS: &[&str] = &["stream.*", "webrtc.*", "msd.*", "system.*"];
|
||||
|
||||
fn make_sender() -> broadcast::Sender<SystemEvent> {
|
||||
let (tx, _rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||
tx
|
||||
}
|
||||
|
||||
fn topic_prefix(event_name: &str) -> Option<String> {
|
||||
event_name
|
||||
.split_once('.')
|
||||
.map(|(prefix, _)| format!("{}.*", prefix))
|
||||
}
|
||||
|
||||
/// Global event bus for broadcasting system events
|
||||
///
|
||||
/// The event bus uses tokio's broadcast channel to distribute events
|
||||
@@ -43,13 +76,31 @@ const EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||
/// ```
|
||||
pub struct EventBus {
|
||||
tx: broadcast::Sender<SystemEvent>,
|
||||
exact_topics: std::collections::HashMap<&'static str, broadcast::Sender<SystemEvent>>,
|
||||
prefix_topics: std::collections::HashMap<&'static str, broadcast::Sender<SystemEvent>>,
|
||||
device_info_dirty_tx: broadcast::Sender<()>,
|
||||
}
|
||||
|
||||
impl EventBus {
|
||||
/// Create a new event bus
|
||||
pub fn new() -> Self {
|
||||
let (tx, _rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||
Self { tx }
|
||||
let tx = make_sender();
|
||||
let exact_topics = EXACT_TOPICS
|
||||
.iter()
|
||||
.map(|topic| (*topic, make_sender()))
|
||||
.collect();
|
||||
let prefix_topics = PREFIX_TOPICS
|
||||
.iter()
|
||||
.map(|topic| (*topic, make_sender()))
|
||||
.collect();
|
||||
let (device_info_dirty_tx, _dirty_rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||
|
||||
Self {
|
||||
tx,
|
||||
exact_topics,
|
||||
prefix_topics,
|
||||
device_info_dirty_tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish an event to all subscribers
|
||||
@@ -57,6 +108,18 @@ impl EventBus {
|
||||
/// If there are no active subscribers, the event is silently dropped.
|
||||
/// This is by design - events are fire-and-forget notifications.
|
||||
pub fn publish(&self, event: SystemEvent) {
|
||||
let event_name = event.event_name();
|
||||
|
||||
if let Some(tx) = self.exact_topics.get(event_name) {
|
||||
let _ = tx.send(event.clone());
|
||||
}
|
||||
|
||||
if let Some(prefix) = topic_prefix(event_name) {
|
||||
if let Some(tx) = self.prefix_topics.get(prefix.as_str()) {
|
||||
let _ = tx.send(event.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// If no subscribers, send returns Err which is normal
|
||||
let _ = self.tx.send(event);
|
||||
}
|
||||
@@ -70,6 +133,35 @@ impl EventBus {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
/// Subscribe to a specific topic.
|
||||
///
|
||||
/// Supports exact event names, namespace wildcards like `stream.*`, and
|
||||
/// `*` for the full event stream.
|
||||
pub fn subscribe_topic(&self, topic: &str) -> Option<broadcast::Receiver<SystemEvent>> {
|
||||
if topic == "*" {
|
||||
return Some(self.tx.subscribe());
|
||||
}
|
||||
|
||||
if topic.ends_with(".*") {
|
||||
return self.prefix_topics.get(topic).map(|tx| tx.subscribe());
|
||||
}
|
||||
|
||||
self.exact_topics.get(topic).map(|tx| tx.subscribe())
|
||||
}
|
||||
|
||||
/// Mark the device-info snapshot as stale.
|
||||
///
|
||||
/// This is an internal trigger used to refresh the latest `system.device_info`
|
||||
/// snapshot without exposing another public WebSocket event.
|
||||
pub fn mark_device_info_dirty(&self) {
|
||||
let _ = self.device_info_dirty_tx.send(());
|
||||
}
|
||||
|
||||
/// Subscribe to internal device-info refresh triggers.
|
||||
pub fn subscribe_device_info_dirty(&self) -> broadcast::Receiver<()> {
|
||||
self.device_info_dirty_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Get the current number of active subscribers
|
||||
///
|
||||
/// Useful for monitoring and debugging.
|
||||
@@ -110,17 +202,50 @@ mod tests {
|
||||
|
||||
assert_eq!(bus.subscriber_count(), 2);
|
||||
|
||||
bus.publish(SystemEvent::SystemError {
|
||||
module: "test".to_string(),
|
||||
severity: "info".to_string(),
|
||||
message: "test message".to_string(),
|
||||
bus.publish(SystemEvent::StreamStateChanged {
|
||||
state: "ready".to_string(),
|
||||
device: Some("/dev/video0".to_string()),
|
||||
});
|
||||
|
||||
let event1 = rx1.recv().await.unwrap();
|
||||
let event2 = rx2.recv().await.unwrap();
|
||||
|
||||
assert!(matches!(event1, SystemEvent::SystemError { .. }));
|
||||
assert!(matches!(event2, SystemEvent::SystemError { .. }));
|
||||
assert!(matches!(event1, SystemEvent::StreamStateChanged { .. }));
|
||||
assert!(matches!(event2, SystemEvent::StreamStateChanged { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_subscribe_topic_exact() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe_topic("stream.state_changed").unwrap();
|
||||
|
||||
bus.publish(SystemEvent::StreamStateChanged {
|
||||
state: "ready".to_string(),
|
||||
device: None,
|
||||
});
|
||||
|
||||
let event = rx.recv().await.unwrap();
|
||||
assert!(matches!(event, SystemEvent::StreamStateChanged { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_subscribe_topic_prefix() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe_topic("stream.*").unwrap();
|
||||
|
||||
bus.publish(SystemEvent::StreamStateChanged {
|
||||
state: "ready".to_string(),
|
||||
device: None,
|
||||
});
|
||||
|
||||
let event = rx.recv().await.unwrap();
|
||||
assert!(matches!(event, SystemEvent::StreamStateChanged { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subscribe_topic_unknown() {
|
||||
let bus = EventBus::new();
|
||||
assert!(bus.subscribe_topic("unknown.topic").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -129,10 +254,9 @@ mod tests {
|
||||
assert_eq!(bus.subscriber_count(), 0);
|
||||
|
||||
// Should not panic when publishing with no subscribers
|
||||
bus.publish(SystemEvent::SystemError {
|
||||
module: "test".to_string(),
|
||||
severity: "info".to_string(),
|
||||
message: "test".to_string(),
|
||||
bus.publish(SystemEvent::StreamStateChanged {
|
||||
state: "ready".to_string(),
|
||||
device: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
//!
|
||||
//! Defines all event types that can be broadcast through the event bus.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::atx::PowerStatus;
|
||||
use crate::msd::MsdMode;
|
||||
use crate::hid::LedState;
|
||||
|
||||
// ============================================================================
|
||||
// Device Info Structures (for system.device_info event)
|
||||
@@ -45,12 +43,20 @@ pub struct HidDeviceInfo {
|
||||
pub backend: String,
|
||||
/// Whether backend is initialized and ready
|
||||
pub initialized: bool,
|
||||
/// Whether backend is currently online
|
||||
pub online: bool,
|
||||
/// Whether absolute mouse positioning is supported
|
||||
pub supports_absolute_mouse: bool,
|
||||
/// Whether keyboard LED/status feedback is enabled.
|
||||
pub keyboard_leds_enabled: bool,
|
||||
/// Last known keyboard LED state.
|
||||
pub led_state: LedState,
|
||||
/// Device path (e.g., serial port for CH9329)
|
||||
pub device: Option<String>,
|
||||
/// Error message if any, None if OK
|
||||
pub error: Option<String>,
|
||||
/// Error code if any, None if OK
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
/// MSD device information
|
||||
@@ -100,6 +106,15 @@ pub struct AudioDeviceInfo {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// ttyd status information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TtydDeviceInfo {
|
||||
/// Whether ttyd binary is available
|
||||
pub available: bool,
|
||||
/// Whether ttyd is currently running
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
/// Per-client statistics
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClientStats {
|
||||
@@ -275,89 +290,9 @@ pub enum SystemEvent {
|
||||
mode: String,
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// HID Events
|
||||
// ============================================================================
|
||||
/// HID backend state changed
|
||||
#[serde(rename = "hid.state_changed")]
|
||||
HidStateChanged {
|
||||
/// Backend type: "otg", "ch9329", "none"
|
||||
backend: String,
|
||||
/// Whether backend is initialized and ready
|
||||
initialized: bool,
|
||||
/// Error message if any, None if OK
|
||||
error: Option<String>,
|
||||
/// Error code for programmatic handling: "epipe", "eagain", "port_not_found", etc.
|
||||
error_code: Option<String>,
|
||||
},
|
||||
|
||||
/// HID backend is being switched
|
||||
#[serde(rename = "hid.backend_switching")]
|
||||
HidBackendSwitching {
|
||||
/// Current backend
|
||||
from: String,
|
||||
/// New backend
|
||||
to: String,
|
||||
},
|
||||
|
||||
/// HID device lost (device file missing or I/O error)
|
||||
#[serde(rename = "hid.device_lost")]
|
||||
HidDeviceLost {
|
||||
/// Backend type: "otg", "ch9329"
|
||||
backend: String,
|
||||
/// Device path that was lost (e.g., /dev/hidg0 or /dev/ttyUSB0)
|
||||
device: Option<String>,
|
||||
/// Human-readable reason for loss
|
||||
reason: String,
|
||||
/// Error code: "epipe", "eshutdown", "eagain", "enxio", "port_not_found", "io_error"
|
||||
error_code: String,
|
||||
},
|
||||
|
||||
/// HID device is reconnecting
|
||||
#[serde(rename = "hid.reconnecting")]
|
||||
HidReconnecting {
|
||||
/// Backend type: "otg", "ch9329"
|
||||
backend: String,
|
||||
/// Current retry attempt number
|
||||
attempt: u32,
|
||||
},
|
||||
|
||||
/// HID device has recovered after error
|
||||
#[serde(rename = "hid.recovered")]
|
||||
HidRecovered {
|
||||
/// Backend type: "otg", "ch9329"
|
||||
backend: String,
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// MSD (Mass Storage Device) Events
|
||||
// ============================================================================
|
||||
/// MSD state changed
|
||||
#[serde(rename = "msd.state_changed")]
|
||||
MsdStateChanged {
|
||||
/// Operating mode
|
||||
mode: MsdMode,
|
||||
/// Whether storage is connected to target
|
||||
connected: bool,
|
||||
},
|
||||
|
||||
/// Image has been mounted
|
||||
#[serde(rename = "msd.image_mounted")]
|
||||
MsdImageMounted {
|
||||
/// Image ID
|
||||
image_id: String,
|
||||
/// Image filename
|
||||
image_name: String,
|
||||
/// Image size in bytes
|
||||
size: u64,
|
||||
/// Mount as CD-ROM (read-only)
|
||||
cdrom: bool,
|
||||
},
|
||||
|
||||
/// Image has been unmounted
|
||||
#[serde(rename = "msd.image_unmounted")]
|
||||
MsdImageUnmounted,
|
||||
|
||||
/// File upload progress (for large file uploads)
|
||||
#[serde(rename = "msd.upload_progress")]
|
||||
MsdUploadProgress {
|
||||
@@ -392,132 +327,6 @@ pub enum SystemEvent {
|
||||
status: String,
|
||||
},
|
||||
|
||||
/// USB gadget connection status changed (host connected/disconnected)
|
||||
#[serde(rename = "msd.usb_status_changed")]
|
||||
MsdUsbStatusChanged {
|
||||
/// Whether host is connected to USB device
|
||||
connected: bool,
|
||||
/// USB device state from kernel (e.g., "configured", "not attached")
|
||||
device_state: String,
|
||||
},
|
||||
|
||||
/// MSD operation error (configfs, image mount, etc.)
|
||||
#[serde(rename = "msd.error")]
|
||||
MsdError {
|
||||
/// Human-readable reason for error
|
||||
reason: String,
|
||||
/// Error code: "configfs_error", "image_not_found", "mount_failed", "io_error"
|
||||
error_code: String,
|
||||
},
|
||||
|
||||
/// MSD has recovered after error
|
||||
#[serde(rename = "msd.recovered")]
|
||||
MsdRecovered,
|
||||
|
||||
// ============================================================================
|
||||
// ATX (Power Control) Events
|
||||
// ============================================================================
|
||||
/// ATX power state changed
|
||||
#[serde(rename = "atx.state_changed")]
|
||||
AtxStateChanged {
|
||||
/// Power status
|
||||
power_status: PowerStatus,
|
||||
},
|
||||
|
||||
/// ATX action was executed
|
||||
#[serde(rename = "atx.action_executed")]
|
||||
AtxActionExecuted {
|
||||
/// Action: "short", "long", "reset"
|
||||
action: String,
|
||||
/// When the action was executed
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Audio Events
|
||||
// ============================================================================
|
||||
/// Audio state changed (streaming started/stopped)
|
||||
#[serde(rename = "audio.state_changed")]
|
||||
AudioStateChanged {
|
||||
/// Whether audio is currently streaming
|
||||
streaming: bool,
|
||||
/// Current device (None if stopped)
|
||||
device: Option<String>,
|
||||
},
|
||||
|
||||
/// Audio device was selected
|
||||
#[serde(rename = "audio.device_selected")]
|
||||
AudioDeviceSelected {
|
||||
/// Selected device name
|
||||
device: String,
|
||||
},
|
||||
|
||||
/// Audio quality was changed
|
||||
#[serde(rename = "audio.quality_changed")]
|
||||
AudioQualityChanged {
|
||||
/// New quality setting: "voice", "balanced", "high"
|
||||
quality: String,
|
||||
},
|
||||
|
||||
/// Audio device lost (capture error or device disconnected)
|
||||
#[serde(rename = "audio.device_lost")]
|
||||
AudioDeviceLost {
|
||||
/// Audio device name (e.g., "hw:0,0")
|
||||
device: Option<String>,
|
||||
/// Human-readable reason for loss
|
||||
reason: String,
|
||||
/// Error code: "device_busy", "device_disconnected", "capture_error", "io_error"
|
||||
error_code: String,
|
||||
},
|
||||
|
||||
/// Audio device is reconnecting
|
||||
#[serde(rename = "audio.reconnecting")]
|
||||
AudioReconnecting {
|
||||
/// Current retry attempt number
|
||||
attempt: u32,
|
||||
},
|
||||
|
||||
/// Audio device has recovered after error
|
||||
#[serde(rename = "audio.recovered")]
|
||||
AudioRecovered {
|
||||
/// Audio device name
|
||||
device: Option<String>,
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// System Events
|
||||
// ============================================================================
|
||||
/// A device was added (hot-plug)
|
||||
#[serde(rename = "system.device_added")]
|
||||
SystemDeviceAdded {
|
||||
/// Device type: "video", "audio", "hid", etc.
|
||||
device_type: String,
|
||||
/// Device path
|
||||
device_path: String,
|
||||
/// Device name/description
|
||||
device_name: String,
|
||||
},
|
||||
|
||||
/// A device was removed (hot-unplug)
|
||||
#[serde(rename = "system.device_removed")]
|
||||
SystemDeviceRemoved {
|
||||
/// Device type
|
||||
device_type: String,
|
||||
/// Device path that was removed
|
||||
device_path: String,
|
||||
},
|
||||
|
||||
/// System error or warning
|
||||
#[serde(rename = "system.error")]
|
||||
SystemError {
|
||||
/// Module that generated the error: "stream", "hid", "msd", "atx"
|
||||
module: String,
|
||||
/// Severity: "warning", "error", "critical"
|
||||
severity: String,
|
||||
/// Error message
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Complete device information (sent on WebSocket connect and state changes)
|
||||
#[serde(rename = "system.device_info")]
|
||||
DeviceInfo {
|
||||
@@ -531,6 +340,8 @@ pub enum SystemEvent {
|
||||
atx: Option<AtxDeviceInfo>,
|
||||
/// Audio device information (None if audio not enabled)
|
||||
audio: Option<AudioDeviceInfo>,
|
||||
/// ttyd status information
|
||||
ttyd: TtydDeviceInfo,
|
||||
},
|
||||
|
||||
/// WebSocket error notification (for connection-level errors like lag)
|
||||
@@ -558,30 +369,8 @@ impl SystemEvent {
|
||||
Self::StreamModeReady { .. } => "stream.mode_ready",
|
||||
Self::WebRTCIceCandidate { .. } => "webrtc.ice_candidate",
|
||||
Self::WebRTCIceComplete { .. } => "webrtc.ice_complete",
|
||||
Self::HidStateChanged { .. } => "hid.state_changed",
|
||||
Self::HidBackendSwitching { .. } => "hid.backend_switching",
|
||||
Self::HidDeviceLost { .. } => "hid.device_lost",
|
||||
Self::HidReconnecting { .. } => "hid.reconnecting",
|
||||
Self::HidRecovered { .. } => "hid.recovered",
|
||||
Self::MsdStateChanged { .. } => "msd.state_changed",
|
||||
Self::MsdImageMounted { .. } => "msd.image_mounted",
|
||||
Self::MsdImageUnmounted => "msd.image_unmounted",
|
||||
Self::MsdUploadProgress { .. } => "msd.upload_progress",
|
||||
Self::MsdDownloadProgress { .. } => "msd.download_progress",
|
||||
Self::MsdUsbStatusChanged { .. } => "msd.usb_status_changed",
|
||||
Self::MsdError { .. } => "msd.error",
|
||||
Self::MsdRecovered => "msd.recovered",
|
||||
Self::AtxStateChanged { .. } => "atx.state_changed",
|
||||
Self::AtxActionExecuted { .. } => "atx.action_executed",
|
||||
Self::AudioStateChanged { .. } => "audio.state_changed",
|
||||
Self::AudioDeviceSelected { .. } => "audio.device_selected",
|
||||
Self::AudioQualityChanged { .. } => "audio.quality_changed",
|
||||
Self::AudioDeviceLost { .. } => "audio.device_lost",
|
||||
Self::AudioReconnecting { .. } => "audio.reconnecting",
|
||||
Self::AudioRecovered { .. } => "audio.recovered",
|
||||
Self::SystemDeviceAdded { .. } => "system.device_added",
|
||||
Self::SystemDeviceRemoved { .. } => "system.device_removed",
|
||||
Self::SystemError { .. } => "system.error",
|
||||
Self::DeviceInfo { .. } => "system.device_info",
|
||||
Self::Error { .. } => "error",
|
||||
}
|
||||
@@ -620,14 +409,6 @@ mod tests {
|
||||
device: Some("/dev/video0".to_string()),
|
||||
};
|
||||
assert_eq!(event.event_name(), "stream.state_changed");
|
||||
|
||||
let event = SystemEvent::MsdImageMounted {
|
||||
image_id: "123".to_string(),
|
||||
image_name: "ubuntu.iso".to_string(),
|
||||
size: 1024,
|
||||
cdrom: true,
|
||||
};
|
||||
assert_eq!(event.event_name(), "msd.image_mounted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,6 +10,7 @@ use tokio::process::{Child, Command};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use super::types::*;
|
||||
use crate::events::EventBus;
|
||||
|
||||
/// Maximum number of log lines to keep per extension
|
||||
const LOG_BUFFER_SIZE: usize = 200;
|
||||
@@ -31,6 +32,7 @@ pub struct ExtensionManager {
|
||||
processes: RwLock<HashMap<ExtensionId, ExtensionProcess>>,
|
||||
/// Cached availability status (checked once at startup)
|
||||
availability: HashMap<ExtensionId, bool>,
|
||||
event_bus: RwLock<Option<Arc<EventBus>>>,
|
||||
}
|
||||
|
||||
impl Default for ExtensionManager {
|
||||
@@ -51,6 +53,22 @@ impl ExtensionManager {
|
||||
Self {
|
||||
processes: RwLock::new(HashMap::new()),
|
||||
availability,
|
||||
event_bus: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set event bus for ttyd status notifications.
|
||||
pub async fn set_event_bus(&self, event_bus: Arc<EventBus>) {
|
||||
*self.event_bus.write().await = Some(event_bus);
|
||||
}
|
||||
|
||||
async fn mark_ttyd_status_dirty(&self, id: ExtensionId) {
|
||||
if id != ExtensionId::Ttyd {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(ref event_bus) = *self.event_bus.read().await {
|
||||
event_bus.mark_device_info_dirty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,17 +83,38 @@ impl ExtensionManager {
|
||||
return ExtensionStatus::Unavailable;
|
||||
}
|
||||
|
||||
let processes = self.processes.read().await;
|
||||
match processes.get(&id) {
|
||||
Some(proc) => {
|
||||
if let Some(pid) = proc.child.id() {
|
||||
ExtensionStatus::Running { pid }
|
||||
} else {
|
||||
ExtensionStatus::Stopped
|
||||
let mut processes = self.processes.write().await;
|
||||
let exited = {
|
||||
let Some(proc) = processes.get_mut(&id) else {
|
||||
return ExtensionStatus::Stopped;
|
||||
};
|
||||
|
||||
match proc.child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
tracing::info!("Extension {} exited with status {}", id, status);
|
||||
true
|
||||
}
|
||||
Ok(None) => {
|
||||
return match proc.child.id() {
|
||||
Some(pid) => ExtensionStatus::Running { pid },
|
||||
None => ExtensionStatus::Stopped,
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to query status for {}: {}", id, e);
|
||||
return match proc.child.id() {
|
||||
Some(pid) => ExtensionStatus::Running { pid },
|
||||
None => ExtensionStatus::Stopped,
|
||||
};
|
||||
}
|
||||
}
|
||||
None => ExtensionStatus::Stopped,
|
||||
};
|
||||
|
||||
if exited {
|
||||
processes.remove(&id);
|
||||
}
|
||||
|
||||
ExtensionStatus::Stopped
|
||||
}
|
||||
|
||||
/// Start an extension with the given configuration
|
||||
@@ -134,6 +173,8 @@ impl ExtensionManager {
|
||||
|
||||
let mut processes = self.processes.write().await;
|
||||
processes.insert(id, ExtensionProcess { child, logs });
|
||||
drop(processes);
|
||||
self.mark_ttyd_status_dirty(id).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -146,6 +187,8 @@ impl ExtensionManager {
|
||||
if let Err(e) = proc.child.kill().await {
|
||||
tracing::warn!("Failed to kill {}: {}", id, e);
|
||||
}
|
||||
drop(processes);
|
||||
self.mark_ttyd_status_dirty(id).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -237,6 +280,9 @@ impl ExtensionManager {
|
||||
|
||||
ExtensionId::Gostc => {
|
||||
let c = &config.gostc;
|
||||
if c.addr.trim().is_empty() {
|
||||
return Err("GOSTC server address is required".into());
|
||||
}
|
||||
if c.key.is_empty() {
|
||||
return Err("GOSTC client key is required".into());
|
||||
}
|
||||
@@ -248,10 +294,8 @@ impl ExtensionManager {
|
||||
args.push("--tls=true".to_string());
|
||||
}
|
||||
|
||||
// Add server address
|
||||
if !c.addr.is_empty() {
|
||||
args.extend(["-addr".to_string(), c.addr.clone()]);
|
||||
}
|
||||
// Server address (validated non-empty above)
|
||||
args.extend(["-addr".to_string(), c.addr.trim().to_string()]);
|
||||
|
||||
// Add client key
|
||||
args.extend(["-key".to_string(), c.key.clone()]);
|
||||
@@ -332,7 +376,11 @@ impl ExtensionManager {
|
||||
.filter_map(|id| {
|
||||
let should_run = match id {
|
||||
ExtensionId::Ttyd => config.ttyd.enabled,
|
||||
ExtensionId::Gostc => config.gostc.enabled && !config.gostc.key.is_empty(),
|
||||
ExtensionId::Gostc => {
|
||||
config.gostc.enabled
|
||||
&& !config.gostc.key.is_empty()
|
||||
&& !config.gostc.addr.trim().is_empty()
|
||||
}
|
||||
ExtensionId::Easytier => {
|
||||
config.easytier.enabled && !config.easytier.network_name.is_empty()
|
||||
}
|
||||
@@ -392,6 +440,7 @@ impl ExtensionManager {
|
||||
|
||||
if config.gostc.enabled
|
||||
&& !config.gostc.key.is_empty()
|
||||
&& !config.gostc.addr.trim().is_empty()
|
||||
&& self.check_available(ExtensionId::Gostc)
|
||||
{
|
||||
start_futures.push(Box::pin(async {
|
||||
|
||||
@@ -121,7 +121,7 @@ impl Default for TtydConfig {
|
||||
pub struct GostcConfig {
|
||||
/// Enable auto-start
|
||||
pub enabled: bool,
|
||||
/// Server address (e.g., gostc.mofeng.run)
|
||||
/// Server address (hostname or IP)
|
||||
pub addr: String,
|
||||
/// Client key from GOSTC management panel
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
@@ -134,7 +134,7 @@ impl Default for GostcConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
addr: "gostc.mofeng.run".to_string(),
|
||||
addr: String::new(),
|
||||
key: String::new(),
|
||||
tls: true,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::watch;
|
||||
|
||||
use super::otg::LedState;
|
||||
use super::types::{ConsumerEvent, KeyboardEvent, MouseEvent};
|
||||
use crate::error::Result;
|
||||
|
||||
@@ -75,12 +77,32 @@ impl HidBackendType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Current runtime status reported by a HID backend.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct HidBackendRuntimeSnapshot {
|
||||
/// Whether the backend has been initialized and can accept requests.
|
||||
pub initialized: bool,
|
||||
/// Whether the backend is currently online and communicating successfully.
|
||||
pub online: bool,
|
||||
/// Whether absolute mouse positioning is supported.
|
||||
pub supports_absolute_mouse: bool,
|
||||
/// Whether keyboard LED/status feedback is currently enabled.
|
||||
pub keyboard_leds_enabled: bool,
|
||||
/// Last known keyboard LED state.
|
||||
pub led_state: LedState,
|
||||
/// Screen resolution for absolute mouse mode.
|
||||
pub screen_resolution: Option<(u32, u32)>,
|
||||
/// Device identifier associated with the backend, if any.
|
||||
pub device: Option<String>,
|
||||
/// Current user-facing error, if any.
|
||||
pub error: Option<String>,
|
||||
/// Current programmatic error code, if any.
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
/// HID backend trait
|
||||
#[async_trait]
|
||||
pub trait HidBackend: Send + Sync {
|
||||
/// Get backend name
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Initialize the backend
|
||||
async fn init(&self) -> Result<()>;
|
||||
|
||||
@@ -104,22 +126,11 @@ pub trait HidBackend: Send + Sync {
|
||||
/// Shutdown the backend
|
||||
async fn shutdown(&self) -> Result<()>;
|
||||
|
||||
/// Perform backend health check.
|
||||
///
|
||||
/// Default implementation assumes backend is healthy.
|
||||
fn health_check(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
/// Get the current backend runtime snapshot.
|
||||
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot;
|
||||
|
||||
/// Check if backend supports absolute mouse positioning
|
||||
fn supports_absolute_mouse(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Get screen resolution (for absolute mouse)
|
||||
fn screen_resolution(&self) -> Option<(u32, u32)> {
|
||||
None
|
||||
}
|
||||
/// Subscribe to backend runtime changes.
|
||||
fn subscribe_runtime(&self) -> watch::Receiver<()>;
|
||||
|
||||
/// Set screen resolution (for absolute mouse)
|
||||
fn set_screen_resolution(&mut self, _width: u32, _height: u32) {}
|
||||
|
||||
1079
src/hid/ch9329.rs
1079
src/hid/ch9329.rs
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@
|
||||
//!
|
||||
//! Keyboard event (type 0x01):
|
||||
//! - Byte 1: Event type (0x00 = down, 0x01 = up)
|
||||
//! - Byte 2: Key code (USB HID usage code)
|
||||
//! - Byte 2: Canonical key code (stable One-KVM key id aligned with HID usage)
|
||||
//! - Byte 3: Modifiers bitmask
|
||||
//! - Bit 0: Left Ctrl
|
||||
//! - Bit 1: Left Shift
|
||||
@@ -38,7 +38,8 @@ use tracing::warn;
|
||||
|
||||
use super::types::ConsumerEvent;
|
||||
use super::{
|
||||
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent, MouseEventType,
|
||||
CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent,
|
||||
MouseEventType,
|
||||
};
|
||||
|
||||
/// Message types
|
||||
@@ -101,7 +102,13 @@ fn parse_keyboard_message(data: &[u8]) -> Option<HidChannelEvent> {
|
||||
}
|
||||
};
|
||||
|
||||
let key = data[1];
|
||||
let key = match CanonicalKey::from_hid_usage(data[1]) {
|
||||
Some(key) => key,
|
||||
None => {
|
||||
warn!("Unknown canonical keyboard key code: 0x{:02X}", data[1]);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let modifiers_byte = data[2];
|
||||
|
||||
let modifiers = KeyboardModifiers {
|
||||
@@ -119,7 +126,6 @@ fn parse_keyboard_message(data: &[u8]) -> Option<HidChannelEvent> {
|
||||
event_type,
|
||||
key,
|
||||
modifiers,
|
||||
is_usb_hid: true, // WebRTC/WebSocket HID channel sends USB HID usages
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -193,7 +199,12 @@ pub fn encode_keyboard_event(event: &KeyboardEvent) -> Vec<u8> {
|
||||
|
||||
let modifiers = event.modifiers.to_hid_byte();
|
||||
|
||||
vec![MSG_KEYBOARD, event_type, event.key, modifiers]
|
||||
vec![
|
||||
MSG_KEYBOARD,
|
||||
event_type,
|
||||
event.key.to_hid_usage(),
|
||||
modifiers,
|
||||
]
|
||||
}
|
||||
|
||||
/// Encode a mouse event to binary format (for sending to client if needed)
|
||||
@@ -242,10 +253,9 @@ mod tests {
|
||||
match event {
|
||||
HidChannelEvent::Keyboard(kb) => {
|
||||
assert!(matches!(kb.event_type, KeyEventType::Down));
|
||||
assert_eq!(kb.key, 0x04);
|
||||
assert_eq!(kb.key, CanonicalKey::KeyA);
|
||||
assert!(kb.modifiers.left_ctrl);
|
||||
assert!(!kb.modifiers.left_shift);
|
||||
assert!(kb.is_usb_hid);
|
||||
}
|
||||
_ => panic!("Expected keyboard event"),
|
||||
}
|
||||
@@ -270,7 +280,7 @@ mod tests {
|
||||
fn test_encode_keyboard() {
|
||||
let event = KeyboardEvent {
|
||||
event_type: KeyEventType::Down,
|
||||
key: 0x04,
|
||||
key: CanonicalKey::KeyA,
|
||||
modifiers: KeyboardModifiers {
|
||||
left_ctrl: true,
|
||||
left_shift: false,
|
||||
@@ -281,7 +291,6 @@ mod tests {
|
||||
right_alt: false,
|
||||
right_meta: false,
|
||||
},
|
||||
is_usb_hid: true,
|
||||
};
|
||||
|
||||
let encoded = encode_keyboard_event(&event);
|
||||
|
||||
409
src/hid/keyboard.rs
Normal file
409
src/hid/keyboard.rs
Normal file
@@ -0,0 +1,409 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use typeshare::typeshare;
|
||||
|
||||
/// Shared canonical keyboard key identifiers used across frontend and backend.
|
||||
///
|
||||
/// The enum names intentionally mirror `KeyboardEvent.code` style values so the
|
||||
/// browser, virtual keyboard, and HID backend can all speak the same language.
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum CanonicalKey {
|
||||
KeyA,
|
||||
KeyB,
|
||||
KeyC,
|
||||
KeyD,
|
||||
KeyE,
|
||||
KeyF,
|
||||
KeyG,
|
||||
KeyH,
|
||||
KeyI,
|
||||
KeyJ,
|
||||
KeyK,
|
||||
KeyL,
|
||||
KeyM,
|
||||
KeyN,
|
||||
KeyO,
|
||||
KeyP,
|
||||
KeyQ,
|
||||
KeyR,
|
||||
KeyS,
|
||||
KeyT,
|
||||
KeyU,
|
||||
KeyV,
|
||||
KeyW,
|
||||
KeyX,
|
||||
KeyY,
|
||||
KeyZ,
|
||||
Digit1,
|
||||
Digit2,
|
||||
Digit3,
|
||||
Digit4,
|
||||
Digit5,
|
||||
Digit6,
|
||||
Digit7,
|
||||
Digit8,
|
||||
Digit9,
|
||||
Digit0,
|
||||
Enter,
|
||||
Escape,
|
||||
Backspace,
|
||||
Tab,
|
||||
Space,
|
||||
Minus,
|
||||
Equal,
|
||||
BracketLeft,
|
||||
BracketRight,
|
||||
Backslash,
|
||||
Semicolon,
|
||||
Quote,
|
||||
Backquote,
|
||||
Comma,
|
||||
Period,
|
||||
Slash,
|
||||
CapsLock,
|
||||
F1,
|
||||
F2,
|
||||
F3,
|
||||
F4,
|
||||
F5,
|
||||
F6,
|
||||
F7,
|
||||
F8,
|
||||
F9,
|
||||
F10,
|
||||
F11,
|
||||
F12,
|
||||
PrintScreen,
|
||||
ScrollLock,
|
||||
Pause,
|
||||
Insert,
|
||||
Home,
|
||||
PageUp,
|
||||
Delete,
|
||||
End,
|
||||
PageDown,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
NumLock,
|
||||
NumpadDivide,
|
||||
NumpadMultiply,
|
||||
NumpadSubtract,
|
||||
NumpadAdd,
|
||||
NumpadEnter,
|
||||
Numpad1,
|
||||
Numpad2,
|
||||
Numpad3,
|
||||
Numpad4,
|
||||
Numpad5,
|
||||
Numpad6,
|
||||
Numpad7,
|
||||
Numpad8,
|
||||
Numpad9,
|
||||
Numpad0,
|
||||
NumpadDecimal,
|
||||
IntlBackslash,
|
||||
ContextMenu,
|
||||
F13,
|
||||
F14,
|
||||
F15,
|
||||
F16,
|
||||
F17,
|
||||
F18,
|
||||
F19,
|
||||
F20,
|
||||
F21,
|
||||
F22,
|
||||
F23,
|
||||
F24,
|
||||
ControlLeft,
|
||||
ShiftLeft,
|
||||
AltLeft,
|
||||
MetaLeft,
|
||||
ControlRight,
|
||||
ShiftRight,
|
||||
AltRight,
|
||||
MetaRight,
|
||||
}
|
||||
|
||||
impl CanonicalKey {
|
||||
/// Convert the canonical key to a stable wire code.
|
||||
///
|
||||
/// The wire code intentionally matches the USB HID usage for keyboard page
|
||||
/// keys so existing low-level behavior stays intact while the semantic type
|
||||
/// becomes explicit.
|
||||
pub const fn to_hid_usage(self) -> u8 {
|
||||
match self {
|
||||
Self::KeyA => 0x04,
|
||||
Self::KeyB => 0x05,
|
||||
Self::KeyC => 0x06,
|
||||
Self::KeyD => 0x07,
|
||||
Self::KeyE => 0x08,
|
||||
Self::KeyF => 0x09,
|
||||
Self::KeyG => 0x0A,
|
||||
Self::KeyH => 0x0B,
|
||||
Self::KeyI => 0x0C,
|
||||
Self::KeyJ => 0x0D,
|
||||
Self::KeyK => 0x0E,
|
||||
Self::KeyL => 0x0F,
|
||||
Self::KeyM => 0x10,
|
||||
Self::KeyN => 0x11,
|
||||
Self::KeyO => 0x12,
|
||||
Self::KeyP => 0x13,
|
||||
Self::KeyQ => 0x14,
|
||||
Self::KeyR => 0x15,
|
||||
Self::KeyS => 0x16,
|
||||
Self::KeyT => 0x17,
|
||||
Self::KeyU => 0x18,
|
||||
Self::KeyV => 0x19,
|
||||
Self::KeyW => 0x1A,
|
||||
Self::KeyX => 0x1B,
|
||||
Self::KeyY => 0x1C,
|
||||
Self::KeyZ => 0x1D,
|
||||
Self::Digit1 => 0x1E,
|
||||
Self::Digit2 => 0x1F,
|
||||
Self::Digit3 => 0x20,
|
||||
Self::Digit4 => 0x21,
|
||||
Self::Digit5 => 0x22,
|
||||
Self::Digit6 => 0x23,
|
||||
Self::Digit7 => 0x24,
|
||||
Self::Digit8 => 0x25,
|
||||
Self::Digit9 => 0x26,
|
||||
Self::Digit0 => 0x27,
|
||||
Self::Enter => 0x28,
|
||||
Self::Escape => 0x29,
|
||||
Self::Backspace => 0x2A,
|
||||
Self::Tab => 0x2B,
|
||||
Self::Space => 0x2C,
|
||||
Self::Minus => 0x2D,
|
||||
Self::Equal => 0x2E,
|
||||
Self::BracketLeft => 0x2F,
|
||||
Self::BracketRight => 0x30,
|
||||
Self::Backslash => 0x31,
|
||||
Self::Semicolon => 0x33,
|
||||
Self::Quote => 0x34,
|
||||
Self::Backquote => 0x35,
|
||||
Self::Comma => 0x36,
|
||||
Self::Period => 0x37,
|
||||
Self::Slash => 0x38,
|
||||
Self::CapsLock => 0x39,
|
||||
Self::F1 => 0x3A,
|
||||
Self::F2 => 0x3B,
|
||||
Self::F3 => 0x3C,
|
||||
Self::F4 => 0x3D,
|
||||
Self::F5 => 0x3E,
|
||||
Self::F6 => 0x3F,
|
||||
Self::F7 => 0x40,
|
||||
Self::F8 => 0x41,
|
||||
Self::F9 => 0x42,
|
||||
Self::F10 => 0x43,
|
||||
Self::F11 => 0x44,
|
||||
Self::F12 => 0x45,
|
||||
Self::PrintScreen => 0x46,
|
||||
Self::ScrollLock => 0x47,
|
||||
Self::Pause => 0x48,
|
||||
Self::Insert => 0x49,
|
||||
Self::Home => 0x4A,
|
||||
Self::PageUp => 0x4B,
|
||||
Self::Delete => 0x4C,
|
||||
Self::End => 0x4D,
|
||||
Self::PageDown => 0x4E,
|
||||
Self::ArrowRight => 0x4F,
|
||||
Self::ArrowLeft => 0x50,
|
||||
Self::ArrowDown => 0x51,
|
||||
Self::ArrowUp => 0x52,
|
||||
Self::NumLock => 0x53,
|
||||
Self::NumpadDivide => 0x54,
|
||||
Self::NumpadMultiply => 0x55,
|
||||
Self::NumpadSubtract => 0x56,
|
||||
Self::NumpadAdd => 0x57,
|
||||
Self::NumpadEnter => 0x58,
|
||||
Self::Numpad1 => 0x59,
|
||||
Self::Numpad2 => 0x5A,
|
||||
Self::Numpad3 => 0x5B,
|
||||
Self::Numpad4 => 0x5C,
|
||||
Self::Numpad5 => 0x5D,
|
||||
Self::Numpad6 => 0x5E,
|
||||
Self::Numpad7 => 0x5F,
|
||||
Self::Numpad8 => 0x60,
|
||||
Self::Numpad9 => 0x61,
|
||||
Self::Numpad0 => 0x62,
|
||||
Self::NumpadDecimal => 0x63,
|
||||
Self::IntlBackslash => 0x64,
|
||||
Self::ContextMenu => 0x65,
|
||||
Self::F13 => 0x68,
|
||||
Self::F14 => 0x69,
|
||||
Self::F15 => 0x6A,
|
||||
Self::F16 => 0x6B,
|
||||
Self::F17 => 0x6C,
|
||||
Self::F18 => 0x6D,
|
||||
Self::F19 => 0x6E,
|
||||
Self::F20 => 0x6F,
|
||||
Self::F21 => 0x70,
|
||||
Self::F22 => 0x71,
|
||||
Self::F23 => 0x72,
|
||||
Self::F24 => 0x73,
|
||||
Self::ControlLeft => 0xE0,
|
||||
Self::ShiftLeft => 0xE1,
|
||||
Self::AltLeft => 0xE2,
|
||||
Self::MetaLeft => 0xE3,
|
||||
Self::ControlRight => 0xE4,
|
||||
Self::ShiftRight => 0xE5,
|
||||
Self::AltRight => 0xE6,
|
||||
Self::MetaRight => 0xE7,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a wire code / USB HID usage to its canonical key.
|
||||
pub const fn from_hid_usage(usage: u8) -> Option<Self> {
|
||||
match usage {
|
||||
0x04 => Some(Self::KeyA),
|
||||
0x05 => Some(Self::KeyB),
|
||||
0x06 => Some(Self::KeyC),
|
||||
0x07 => Some(Self::KeyD),
|
||||
0x08 => Some(Self::KeyE),
|
||||
0x09 => Some(Self::KeyF),
|
||||
0x0A => Some(Self::KeyG),
|
||||
0x0B => Some(Self::KeyH),
|
||||
0x0C => Some(Self::KeyI),
|
||||
0x0D => Some(Self::KeyJ),
|
||||
0x0E => Some(Self::KeyK),
|
||||
0x0F => Some(Self::KeyL),
|
||||
0x10 => Some(Self::KeyM),
|
||||
0x11 => Some(Self::KeyN),
|
||||
0x12 => Some(Self::KeyO),
|
||||
0x13 => Some(Self::KeyP),
|
||||
0x14 => Some(Self::KeyQ),
|
||||
0x15 => Some(Self::KeyR),
|
||||
0x16 => Some(Self::KeyS),
|
||||
0x17 => Some(Self::KeyT),
|
||||
0x18 => Some(Self::KeyU),
|
||||
0x19 => Some(Self::KeyV),
|
||||
0x1A => Some(Self::KeyW),
|
||||
0x1B => Some(Self::KeyX),
|
||||
0x1C => Some(Self::KeyY),
|
||||
0x1D => Some(Self::KeyZ),
|
||||
0x1E => Some(Self::Digit1),
|
||||
0x1F => Some(Self::Digit2),
|
||||
0x20 => Some(Self::Digit3),
|
||||
0x21 => Some(Self::Digit4),
|
||||
0x22 => Some(Self::Digit5),
|
||||
0x23 => Some(Self::Digit6),
|
||||
0x24 => Some(Self::Digit7),
|
||||
0x25 => Some(Self::Digit8),
|
||||
0x26 => Some(Self::Digit9),
|
||||
0x27 => Some(Self::Digit0),
|
||||
0x28 => Some(Self::Enter),
|
||||
0x29 => Some(Self::Escape),
|
||||
0x2A => Some(Self::Backspace),
|
||||
0x2B => Some(Self::Tab),
|
||||
0x2C => Some(Self::Space),
|
||||
0x2D => Some(Self::Minus),
|
||||
0x2E => Some(Self::Equal),
|
||||
0x2F => Some(Self::BracketLeft),
|
||||
0x30 => Some(Self::BracketRight),
|
||||
0x31 => Some(Self::Backslash),
|
||||
0x33 => Some(Self::Semicolon),
|
||||
0x34 => Some(Self::Quote),
|
||||
0x35 => Some(Self::Backquote),
|
||||
0x36 => Some(Self::Comma),
|
||||
0x37 => Some(Self::Period),
|
||||
0x38 => Some(Self::Slash),
|
||||
0x39 => Some(Self::CapsLock),
|
||||
0x3A => Some(Self::F1),
|
||||
0x3B => Some(Self::F2),
|
||||
0x3C => Some(Self::F3),
|
||||
0x3D => Some(Self::F4),
|
||||
0x3E => Some(Self::F5),
|
||||
0x3F => Some(Self::F6),
|
||||
0x40 => Some(Self::F7),
|
||||
0x41 => Some(Self::F8),
|
||||
0x42 => Some(Self::F9),
|
||||
0x43 => Some(Self::F10),
|
||||
0x44 => Some(Self::F11),
|
||||
0x45 => Some(Self::F12),
|
||||
0x46 => Some(Self::PrintScreen),
|
||||
0x47 => Some(Self::ScrollLock),
|
||||
0x48 => Some(Self::Pause),
|
||||
0x49 => Some(Self::Insert),
|
||||
0x4A => Some(Self::Home),
|
||||
0x4B => Some(Self::PageUp),
|
||||
0x4C => Some(Self::Delete),
|
||||
0x4D => Some(Self::End),
|
||||
0x4E => Some(Self::PageDown),
|
||||
0x4F => Some(Self::ArrowRight),
|
||||
0x50 => Some(Self::ArrowLeft),
|
||||
0x51 => Some(Self::ArrowDown),
|
||||
0x52 => Some(Self::ArrowUp),
|
||||
0x53 => Some(Self::NumLock),
|
||||
0x54 => Some(Self::NumpadDivide),
|
||||
0x55 => Some(Self::NumpadMultiply),
|
||||
0x56 => Some(Self::NumpadSubtract),
|
||||
0x57 => Some(Self::NumpadAdd),
|
||||
0x58 => Some(Self::NumpadEnter),
|
||||
0x59 => Some(Self::Numpad1),
|
||||
0x5A => Some(Self::Numpad2),
|
||||
0x5B => Some(Self::Numpad3),
|
||||
0x5C => Some(Self::Numpad4),
|
||||
0x5D => Some(Self::Numpad5),
|
||||
0x5E => Some(Self::Numpad6),
|
||||
0x5F => Some(Self::Numpad7),
|
||||
0x60 => Some(Self::Numpad8),
|
||||
0x61 => Some(Self::Numpad9),
|
||||
0x62 => Some(Self::Numpad0),
|
||||
0x63 => Some(Self::NumpadDecimal),
|
||||
0x64 => Some(Self::IntlBackslash),
|
||||
0x65 => Some(Self::ContextMenu),
|
||||
0x68 => Some(Self::F13),
|
||||
0x69 => Some(Self::F14),
|
||||
0x6A => Some(Self::F15),
|
||||
0x6B => Some(Self::F16),
|
||||
0x6C => Some(Self::F17),
|
||||
0x6D => Some(Self::F18),
|
||||
0x6E => Some(Self::F19),
|
||||
0x6F => Some(Self::F20),
|
||||
0x70 => Some(Self::F21),
|
||||
0x71 => Some(Self::F22),
|
||||
0x72 => Some(Self::F23),
|
||||
0x73 => Some(Self::F24),
|
||||
0xE0 => Some(Self::ControlLeft),
|
||||
0xE1 => Some(Self::ShiftLeft),
|
||||
0xE2 => Some(Self::AltLeft),
|
||||
0xE3 => Some(Self::MetaLeft),
|
||||
0xE4 => Some(Self::ControlRight),
|
||||
0xE5 => Some(Self::ShiftRight),
|
||||
0xE6 => Some(Self::AltRight),
|
||||
0xE7 => Some(Self::MetaRight),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn is_modifier(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::ControlLeft
|
||||
| Self::ShiftLeft
|
||||
| Self::AltLeft
|
||||
| Self::MetaLeft
|
||||
| Self::ControlRight
|
||||
| Self::ShiftRight
|
||||
| Self::AltRight
|
||||
| Self::MetaRight
|
||||
)
|
||||
}
|
||||
|
||||
pub const fn modifier_bit(self) -> Option<u8> {
|
||||
match self {
|
||||
Self::ControlLeft => Some(0x01),
|
||||
Self::ShiftLeft => Some(0x02),
|
||||
Self::AltLeft => Some(0x04),
|
||||
Self::MetaLeft => Some(0x08),
|
||||
Self::ControlRight => Some(0x10),
|
||||
Self::ShiftRight => Some(0x20),
|
||||
Self::AltRight => Some(0x40),
|
||||
Self::MetaRight => Some(0x80),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
//! USB HID keyboard key codes mapping
|
||||
//!
|
||||
//! This module provides mapping between JavaScript key codes and USB HID usage codes.
|
||||
//! Reference: USB HID Usage Tables 1.12, Section 10 (Keyboard/Keypad Page)
|
||||
|
||||
/// USB HID key codes (Usage Page 0x07)
|
||||
#[allow(dead_code)]
|
||||
pub mod usb {
|
||||
// Letters A-Z (0x04 - 0x1D)
|
||||
pub const KEY_A: u8 = 0x04;
|
||||
pub const KEY_B: u8 = 0x05;
|
||||
pub const KEY_C: u8 = 0x06;
|
||||
pub const KEY_D: u8 = 0x07;
|
||||
pub const KEY_E: u8 = 0x08;
|
||||
pub const KEY_F: u8 = 0x09;
|
||||
pub const KEY_G: u8 = 0x0A;
|
||||
pub const KEY_H: u8 = 0x0B;
|
||||
pub const KEY_I: u8 = 0x0C;
|
||||
pub const KEY_J: u8 = 0x0D;
|
||||
pub const KEY_K: u8 = 0x0E;
|
||||
pub const KEY_L: u8 = 0x0F;
|
||||
pub const KEY_M: u8 = 0x10;
|
||||
pub const KEY_N: u8 = 0x11;
|
||||
pub const KEY_O: u8 = 0x12;
|
||||
pub const KEY_P: u8 = 0x13;
|
||||
pub const KEY_Q: u8 = 0x14;
|
||||
pub const KEY_R: u8 = 0x15;
|
||||
pub const KEY_S: u8 = 0x16;
|
||||
pub const KEY_T: u8 = 0x17;
|
||||
pub const KEY_U: u8 = 0x18;
|
||||
pub const KEY_V: u8 = 0x19;
|
||||
pub const KEY_W: u8 = 0x1A;
|
||||
pub const KEY_X: u8 = 0x1B;
|
||||
pub const KEY_Y: u8 = 0x1C;
|
||||
pub const KEY_Z: u8 = 0x1D;
|
||||
|
||||
// Numbers 1-9, 0 (0x1E - 0x27)
|
||||
pub const KEY_1: u8 = 0x1E;
|
||||
pub const KEY_2: u8 = 0x1F;
|
||||
pub const KEY_3: u8 = 0x20;
|
||||
pub const KEY_4: u8 = 0x21;
|
||||
pub const KEY_5: u8 = 0x22;
|
||||
pub const KEY_6: u8 = 0x23;
|
||||
pub const KEY_7: u8 = 0x24;
|
||||
pub const KEY_8: u8 = 0x25;
|
||||
pub const KEY_9: u8 = 0x26;
|
||||
pub const KEY_0: u8 = 0x27;
|
||||
|
||||
// Control keys
|
||||
pub const KEY_ENTER: u8 = 0x28;
|
||||
pub const KEY_ESCAPE: u8 = 0x29;
|
||||
pub const KEY_BACKSPACE: u8 = 0x2A;
|
||||
pub const KEY_TAB: u8 = 0x2B;
|
||||
pub const KEY_SPACE: u8 = 0x2C;
|
||||
pub const KEY_MINUS: u8 = 0x2D;
|
||||
pub const KEY_EQUAL: u8 = 0x2E;
|
||||
pub const KEY_LEFT_BRACKET: u8 = 0x2F;
|
||||
pub const KEY_RIGHT_BRACKET: u8 = 0x30;
|
||||
pub const KEY_BACKSLASH: u8 = 0x31;
|
||||
pub const KEY_HASH: u8 = 0x32; // Non-US # and ~
|
||||
pub const KEY_SEMICOLON: u8 = 0x33;
|
||||
pub const KEY_APOSTROPHE: u8 = 0x34;
|
||||
pub const KEY_GRAVE: u8 = 0x35;
|
||||
pub const KEY_COMMA: u8 = 0x36;
|
||||
pub const KEY_PERIOD: u8 = 0x37;
|
||||
pub const KEY_SLASH: u8 = 0x38;
|
||||
pub const KEY_CAPS_LOCK: u8 = 0x39;
|
||||
|
||||
// Function keys F1-F12
|
||||
pub const KEY_F1: u8 = 0x3A;
|
||||
pub const KEY_F2: u8 = 0x3B;
|
||||
pub const KEY_F3: u8 = 0x3C;
|
||||
pub const KEY_F4: u8 = 0x3D;
|
||||
pub const KEY_F5: u8 = 0x3E;
|
||||
pub const KEY_F6: u8 = 0x3F;
|
||||
pub const KEY_F7: u8 = 0x40;
|
||||
pub const KEY_F8: u8 = 0x41;
|
||||
pub const KEY_F9: u8 = 0x42;
|
||||
pub const KEY_F10: u8 = 0x43;
|
||||
pub const KEY_F11: u8 = 0x44;
|
||||
pub const KEY_F12: u8 = 0x45;
|
||||
|
||||
// Special keys
|
||||
pub const KEY_PRINT_SCREEN: u8 = 0x46;
|
||||
pub const KEY_SCROLL_LOCK: u8 = 0x47;
|
||||
pub const KEY_PAUSE: u8 = 0x48;
|
||||
pub const KEY_INSERT: u8 = 0x49;
|
||||
pub const KEY_HOME: u8 = 0x4A;
|
||||
pub const KEY_PAGE_UP: u8 = 0x4B;
|
||||
pub const KEY_DELETE: u8 = 0x4C;
|
||||
pub const KEY_END: u8 = 0x4D;
|
||||
pub const KEY_PAGE_DOWN: u8 = 0x4E;
|
||||
pub const KEY_RIGHT_ARROW: u8 = 0x4F;
|
||||
pub const KEY_LEFT_ARROW: u8 = 0x50;
|
||||
pub const KEY_DOWN_ARROW: u8 = 0x51;
|
||||
pub const KEY_UP_ARROW: u8 = 0x52;
|
||||
|
||||
// Numpad
|
||||
pub const KEY_NUM_LOCK: u8 = 0x53;
|
||||
pub const KEY_NUMPAD_DIVIDE: u8 = 0x54;
|
||||
pub const KEY_NUMPAD_MULTIPLY: u8 = 0x55;
|
||||
pub const KEY_NUMPAD_MINUS: u8 = 0x56;
|
||||
pub const KEY_NUMPAD_PLUS: u8 = 0x57;
|
||||
pub const KEY_NUMPAD_ENTER: u8 = 0x58;
|
||||
pub const KEY_NUMPAD_1: u8 = 0x59;
|
||||
pub const KEY_NUMPAD_2: u8 = 0x5A;
|
||||
pub const KEY_NUMPAD_3: u8 = 0x5B;
|
||||
pub const KEY_NUMPAD_4: u8 = 0x5C;
|
||||
pub const KEY_NUMPAD_5: u8 = 0x5D;
|
||||
pub const KEY_NUMPAD_6: u8 = 0x5E;
|
||||
pub const KEY_NUMPAD_7: u8 = 0x5F;
|
||||
pub const KEY_NUMPAD_8: u8 = 0x60;
|
||||
pub const KEY_NUMPAD_9: u8 = 0x61;
|
||||
pub const KEY_NUMPAD_0: u8 = 0x62;
|
||||
pub const KEY_NUMPAD_DECIMAL: u8 = 0x63;
|
||||
|
||||
// Additional keys
|
||||
pub const KEY_NON_US_BACKSLASH: u8 = 0x64;
|
||||
pub const KEY_APPLICATION: u8 = 0x65; // Context menu
|
||||
pub const KEY_POWER: u8 = 0x66;
|
||||
pub const KEY_NUMPAD_EQUAL: u8 = 0x67;
|
||||
|
||||
// F13-F24
|
||||
pub const KEY_F13: u8 = 0x68;
|
||||
pub const KEY_F14: u8 = 0x69;
|
||||
pub const KEY_F15: u8 = 0x6A;
|
||||
pub const KEY_F16: u8 = 0x6B;
|
||||
pub const KEY_F17: u8 = 0x6C;
|
||||
pub const KEY_F18: u8 = 0x6D;
|
||||
pub const KEY_F19: u8 = 0x6E;
|
||||
pub const KEY_F20: u8 = 0x6F;
|
||||
pub const KEY_F21: u8 = 0x70;
|
||||
pub const KEY_F22: u8 = 0x71;
|
||||
pub const KEY_F23: u8 = 0x72;
|
||||
pub const KEY_F24: u8 = 0x73;
|
||||
|
||||
// Modifier keys (these are handled separately in the modifier byte)
|
||||
pub const KEY_LEFT_CTRL: u8 = 0xE0;
|
||||
pub const KEY_LEFT_SHIFT: u8 = 0xE1;
|
||||
pub const KEY_LEFT_ALT: u8 = 0xE2;
|
||||
pub const KEY_LEFT_META: u8 = 0xE3;
|
||||
pub const KEY_RIGHT_CTRL: u8 = 0xE4;
|
||||
pub const KEY_RIGHT_SHIFT: u8 = 0xE5;
|
||||
pub const KEY_RIGHT_ALT: u8 = 0xE6;
|
||||
pub const KEY_RIGHT_META: u8 = 0xE7;
|
||||
}
|
||||
|
||||
/// JavaScript key codes (event.keyCode / event.code)
|
||||
#[allow(dead_code)]
|
||||
pub mod js {
|
||||
// Letters
|
||||
pub const KEY_A: u8 = 65;
|
||||
pub const KEY_B: u8 = 66;
|
||||
pub const KEY_C: u8 = 67;
|
||||
pub const KEY_D: u8 = 68;
|
||||
pub const KEY_E: u8 = 69;
|
||||
pub const KEY_F: u8 = 70;
|
||||
pub const KEY_G: u8 = 71;
|
||||
pub const KEY_H: u8 = 72;
|
||||
pub const KEY_I: u8 = 73;
|
||||
pub const KEY_J: u8 = 74;
|
||||
pub const KEY_K: u8 = 75;
|
||||
pub const KEY_L: u8 = 76;
|
||||
pub const KEY_M: u8 = 77;
|
||||
pub const KEY_N: u8 = 78;
|
||||
pub const KEY_O: u8 = 79;
|
||||
pub const KEY_P: u8 = 80;
|
||||
pub const KEY_Q: u8 = 81;
|
||||
pub const KEY_R: u8 = 82;
|
||||
pub const KEY_S: u8 = 83;
|
||||
pub const KEY_T: u8 = 84;
|
||||
pub const KEY_U: u8 = 85;
|
||||
pub const KEY_V: u8 = 86;
|
||||
pub const KEY_W: u8 = 87;
|
||||
pub const KEY_X: u8 = 88;
|
||||
pub const KEY_Y: u8 = 89;
|
||||
pub const KEY_Z: u8 = 90;
|
||||
|
||||
// Numbers (top row)
|
||||
pub const KEY_0: u8 = 48;
|
||||
pub const KEY_1: u8 = 49;
|
||||
pub const KEY_2: u8 = 50;
|
||||
pub const KEY_3: u8 = 51;
|
||||
pub const KEY_4: u8 = 52;
|
||||
pub const KEY_5: u8 = 53;
|
||||
pub const KEY_6: u8 = 54;
|
||||
pub const KEY_7: u8 = 55;
|
||||
pub const KEY_8: u8 = 56;
|
||||
pub const KEY_9: u8 = 57;
|
||||
|
||||
// Function keys
|
||||
pub const KEY_F1: u8 = 112;
|
||||
pub const KEY_F2: u8 = 113;
|
||||
pub const KEY_F3: u8 = 114;
|
||||
pub const KEY_F4: u8 = 115;
|
||||
pub const KEY_F5: u8 = 116;
|
||||
pub const KEY_F6: u8 = 117;
|
||||
pub const KEY_F7: u8 = 118;
|
||||
pub const KEY_F8: u8 = 119;
|
||||
pub const KEY_F9: u8 = 120;
|
||||
pub const KEY_F10: u8 = 121;
|
||||
pub const KEY_F11: u8 = 122;
|
||||
pub const KEY_F12: u8 = 123;
|
||||
|
||||
// Control keys
|
||||
pub const KEY_BACKSPACE: u8 = 8;
|
||||
pub const KEY_TAB: u8 = 9;
|
||||
pub const KEY_ENTER: u8 = 13;
|
||||
pub const KEY_SHIFT: u8 = 16;
|
||||
pub const KEY_CTRL: u8 = 17;
|
||||
pub const KEY_ALT: u8 = 18;
|
||||
pub const KEY_PAUSE: u8 = 19;
|
||||
pub const KEY_CAPS_LOCK: u8 = 20;
|
||||
pub const KEY_ESCAPE: u8 = 27;
|
||||
pub const KEY_SPACE: u8 = 32;
|
||||
pub const KEY_PAGE_UP: u8 = 33;
|
||||
pub const KEY_PAGE_DOWN: u8 = 34;
|
||||
pub const KEY_END: u8 = 35;
|
||||
pub const KEY_HOME: u8 = 36;
|
||||
pub const KEY_LEFT: u8 = 37;
|
||||
pub const KEY_UP: u8 = 38;
|
||||
pub const KEY_RIGHT: u8 = 39;
|
||||
pub const KEY_DOWN: u8 = 40;
|
||||
pub const KEY_INSERT: u8 = 45;
|
||||
pub const KEY_DELETE: u8 = 46;
|
||||
|
||||
// Punctuation
|
||||
pub const KEY_SEMICOLON: u8 = 186;
|
||||
pub const KEY_EQUAL: u8 = 187;
|
||||
pub const KEY_COMMA: u8 = 188;
|
||||
pub const KEY_MINUS: u8 = 189;
|
||||
pub const KEY_PERIOD: u8 = 190;
|
||||
pub const KEY_SLASH: u8 = 191;
|
||||
pub const KEY_GRAVE: u8 = 192;
|
||||
pub const KEY_LEFT_BRACKET: u8 = 219;
|
||||
pub const KEY_BACKSLASH: u8 = 220;
|
||||
pub const KEY_RIGHT_BRACKET: u8 = 221;
|
||||
pub const KEY_APOSTROPHE: u8 = 222;
|
||||
|
||||
// Numpad
|
||||
pub const KEY_NUMPAD_0: u8 = 96;
|
||||
pub const KEY_NUMPAD_1: u8 = 97;
|
||||
pub const KEY_NUMPAD_2: u8 = 98;
|
||||
pub const KEY_NUMPAD_3: u8 = 99;
|
||||
pub const KEY_NUMPAD_4: u8 = 100;
|
||||
pub const KEY_NUMPAD_5: u8 = 101;
|
||||
pub const KEY_NUMPAD_6: u8 = 102;
|
||||
pub const KEY_NUMPAD_7: u8 = 103;
|
||||
pub const KEY_NUMPAD_8: u8 = 104;
|
||||
pub const KEY_NUMPAD_9: u8 = 105;
|
||||
pub const KEY_NUMPAD_MULTIPLY: u8 = 106;
|
||||
pub const KEY_NUMPAD_ADD: u8 = 107;
|
||||
pub const KEY_NUMPAD_SUBTRACT: u8 = 109;
|
||||
pub const KEY_NUMPAD_DECIMAL: u8 = 110;
|
||||
pub const KEY_NUMPAD_DIVIDE: u8 = 111;
|
||||
|
||||
// Lock keys
|
||||
pub const KEY_NUM_LOCK: u8 = 144;
|
||||
pub const KEY_SCROLL_LOCK: u8 = 145;
|
||||
|
||||
// Windows keys
|
||||
pub const KEY_META_LEFT: u8 = 91;
|
||||
pub const KEY_META_RIGHT: u8 = 92;
|
||||
pub const KEY_CONTEXT_MENU: u8 = 93;
|
||||
}
|
||||
|
||||
/// JavaScript keyCode to USB HID keyCode mapping table
|
||||
/// Using a fixed-size array for O(1) lookup instead of HashMap
|
||||
/// Index = JavaScript keyCode, Value = USB HID keyCode (0 means unmapped)
|
||||
static JS_TO_USB_TABLE: [u8; 256] = {
|
||||
let mut table = [0u8; 256];
|
||||
|
||||
// Letters A-Z (JS 65-90 -> USB 0x04-0x1D)
|
||||
let mut i = 0u8;
|
||||
while i < 26 {
|
||||
table[(65 + i) as usize] = usb::KEY_A + i;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Numbers 1-9, 0 (JS 49-57, 48 -> USB 0x1E-0x27)
|
||||
table[49] = usb::KEY_1; // 1
|
||||
table[50] = usb::KEY_2; // 2
|
||||
table[51] = usb::KEY_3; // 3
|
||||
table[52] = usb::KEY_4; // 4
|
||||
table[53] = usb::KEY_5; // 5
|
||||
table[54] = usb::KEY_6; // 6
|
||||
table[55] = usb::KEY_7; // 7
|
||||
table[56] = usb::KEY_8; // 8
|
||||
table[57] = usb::KEY_9; // 9
|
||||
table[48] = usb::KEY_0; // 0
|
||||
|
||||
// Function keys F1-F12 (JS 112-123 -> USB 0x3A-0x45)
|
||||
table[112] = usb::KEY_F1;
|
||||
table[113] = usb::KEY_F2;
|
||||
table[114] = usb::KEY_F3;
|
||||
table[115] = usb::KEY_F4;
|
||||
table[116] = usb::KEY_F5;
|
||||
table[117] = usb::KEY_F6;
|
||||
table[118] = usb::KEY_F7;
|
||||
table[119] = usb::KEY_F8;
|
||||
table[120] = usb::KEY_F9;
|
||||
table[121] = usb::KEY_F10;
|
||||
table[122] = usb::KEY_F11;
|
||||
table[123] = usb::KEY_F12;
|
||||
|
||||
// Control keys
|
||||
table[13] = usb::KEY_ENTER; // Enter
|
||||
table[27] = usb::KEY_ESCAPE; // Escape
|
||||
table[8] = usb::KEY_BACKSPACE; // Backspace
|
||||
table[9] = usb::KEY_TAB; // Tab
|
||||
table[32] = usb::KEY_SPACE; // Space
|
||||
table[20] = usb::KEY_CAPS_LOCK; // Caps Lock
|
||||
|
||||
// Punctuation (JS codes vary by browser/layout)
|
||||
table[189] = usb::KEY_MINUS; // -
|
||||
table[187] = usb::KEY_EQUAL; // =
|
||||
table[219] = usb::KEY_LEFT_BRACKET; // [
|
||||
table[221] = usb::KEY_RIGHT_BRACKET; // ]
|
||||
table[220] = usb::KEY_BACKSLASH; // \
|
||||
table[186] = usb::KEY_SEMICOLON; // ;
|
||||
table[222] = usb::KEY_APOSTROPHE; // '
|
||||
table[192] = usb::KEY_GRAVE; // `
|
||||
table[188] = usb::KEY_COMMA; // ,
|
||||
table[190] = usb::KEY_PERIOD; // .
|
||||
table[191] = usb::KEY_SLASH; // /
|
||||
|
||||
// Navigation keys
|
||||
table[45] = usb::KEY_INSERT;
|
||||
table[46] = usb::KEY_DELETE;
|
||||
table[36] = usb::KEY_HOME;
|
||||
table[35] = usb::KEY_END;
|
||||
table[33] = usb::KEY_PAGE_UP;
|
||||
table[34] = usb::KEY_PAGE_DOWN;
|
||||
|
||||
// Arrow keys
|
||||
table[39] = usb::KEY_RIGHT_ARROW;
|
||||
table[37] = usb::KEY_LEFT_ARROW;
|
||||
table[40] = usb::KEY_DOWN_ARROW;
|
||||
table[38] = usb::KEY_UP_ARROW;
|
||||
|
||||
// Numpad
|
||||
table[144] = usb::KEY_NUM_LOCK;
|
||||
table[111] = usb::KEY_NUMPAD_DIVIDE;
|
||||
table[106] = usb::KEY_NUMPAD_MULTIPLY;
|
||||
table[109] = usb::KEY_NUMPAD_MINUS;
|
||||
table[107] = usb::KEY_NUMPAD_PLUS;
|
||||
table[96] = usb::KEY_NUMPAD_0;
|
||||
table[97] = usb::KEY_NUMPAD_1;
|
||||
table[98] = usb::KEY_NUMPAD_2;
|
||||
table[99] = usb::KEY_NUMPAD_3;
|
||||
table[100] = usb::KEY_NUMPAD_4;
|
||||
table[101] = usb::KEY_NUMPAD_5;
|
||||
table[102] = usb::KEY_NUMPAD_6;
|
||||
table[103] = usb::KEY_NUMPAD_7;
|
||||
table[104] = usb::KEY_NUMPAD_8;
|
||||
table[105] = usb::KEY_NUMPAD_9;
|
||||
table[110] = usb::KEY_NUMPAD_DECIMAL;
|
||||
|
||||
// Special keys
|
||||
table[19] = usb::KEY_PAUSE;
|
||||
table[145] = usb::KEY_SCROLL_LOCK;
|
||||
table[93] = usb::KEY_APPLICATION; // Context menu
|
||||
|
||||
// Modifier keys
|
||||
table[17] = usb::KEY_LEFT_CTRL;
|
||||
table[16] = usb::KEY_LEFT_SHIFT;
|
||||
table[18] = usb::KEY_LEFT_ALT;
|
||||
table[91] = usb::KEY_LEFT_META; // Left Windows/Command
|
||||
table[92] = usb::KEY_RIGHT_META; // Right Windows/Command
|
||||
|
||||
table
|
||||
};
|
||||
|
||||
/// Convert JavaScript keyCode to USB HID keyCode
|
||||
///
|
||||
/// Uses a fixed-size lookup table for O(1) performance.
|
||||
/// Returns None if the key code is not mapped.
|
||||
#[inline]
|
||||
pub fn js_to_usb(js_code: u8) -> Option<u8> {
|
||||
let usb_code = JS_TO_USB_TABLE[js_code as usize];
|
||||
if usb_code != 0 {
|
||||
Some(usb_code)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a key code is a modifier key
|
||||
pub fn is_modifier_key(usb_code: u8) -> bool {
|
||||
(0xE0..=0xE7).contains(&usb_code)
|
||||
}
|
||||
|
||||
/// Get modifier bit for a modifier key
|
||||
pub fn modifier_bit(usb_code: u8) -> Option<u8> {
|
||||
match usb_code {
|
||||
usb::KEY_LEFT_CTRL => Some(0x01),
|
||||
usb::KEY_LEFT_SHIFT => Some(0x02),
|
||||
usb::KEY_LEFT_ALT => Some(0x04),
|
||||
usb::KEY_LEFT_META => Some(0x08),
|
||||
usb::KEY_RIGHT_CTRL => Some(0x10),
|
||||
usb::KEY_RIGHT_SHIFT => Some(0x20),
|
||||
usb::KEY_RIGHT_ALT => Some(0x40),
|
||||
usb::KEY_RIGHT_META => Some(0x80),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_letter_mapping() {
|
||||
assert_eq!(js_to_usb(65), Some(usb::KEY_A)); // A
|
||||
assert_eq!(js_to_usb(90), Some(usb::KEY_Z)); // Z
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_number_mapping() {
|
||||
assert_eq!(js_to_usb(48), Some(usb::KEY_0));
|
||||
assert_eq!(js_to_usb(49), Some(usb::KEY_1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_modifier_key() {
|
||||
assert!(is_modifier_key(usb::KEY_LEFT_CTRL));
|
||||
assert!(is_modifier_key(usb::KEY_RIGHT_SHIFT));
|
||||
assert!(!is_modifier_key(usb::KEY_A));
|
||||
}
|
||||
}
|
||||
500
src/hid/mod.rs
500
src/hid/mod.rs
@@ -15,14 +15,13 @@ pub mod backend;
|
||||
pub mod ch9329;
|
||||
pub mod consumer;
|
||||
pub mod datachannel;
|
||||
pub mod keymap;
|
||||
pub mod monitor;
|
||||
pub mod keyboard;
|
||||
pub mod otg;
|
||||
pub mod types;
|
||||
pub mod websocket;
|
||||
|
||||
pub use backend::{HidBackend, HidBackendType};
|
||||
pub use monitor::{HidHealthMonitor, HidHealthStatus, HidMonitorConfig};
|
||||
pub use backend::{HidBackend, HidBackendRuntimeSnapshot, HidBackendType};
|
||||
pub use keyboard::CanonicalKey;
|
||||
pub use otg::LedState;
|
||||
pub use types::{
|
||||
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent,
|
||||
@@ -33,7 +32,7 @@ pub use types::{
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HidInfo {
|
||||
/// Backend name
|
||||
pub name: &'static str,
|
||||
pub name: String,
|
||||
/// Whether backend is initialized
|
||||
pub initialized: bool,
|
||||
/// Whether absolute mouse positioning is supported
|
||||
@@ -42,21 +41,103 @@ pub struct HidInfo {
|
||||
pub screen_resolution: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// Unified HID runtime state used by snapshots and events.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HidRuntimeState {
|
||||
/// Whether a backend is configured and expected to exist.
|
||||
pub available: bool,
|
||||
/// Stable backend key: "otg", "ch9329", "none".
|
||||
pub backend: String,
|
||||
/// Whether the backend is currently initialized and operational.
|
||||
pub initialized: bool,
|
||||
/// Whether the backend is currently online.
|
||||
pub online: bool,
|
||||
/// Whether absolute mouse positioning is supported.
|
||||
pub supports_absolute_mouse: bool,
|
||||
/// Whether keyboard LED/status feedback is enabled.
|
||||
pub keyboard_leds_enabled: bool,
|
||||
/// Last known keyboard LED state.
|
||||
pub led_state: LedState,
|
||||
/// Screen resolution for absolute mouse mode.
|
||||
pub screen_resolution: Option<(u32, u32)>,
|
||||
/// Device path associated with the backend, if any.
|
||||
pub device: Option<String>,
|
||||
/// Current user-facing error, if any.
|
||||
pub error: Option<String>,
|
||||
/// Current programmatic error code, if any.
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
impl HidRuntimeState {
|
||||
fn from_backend_type(backend_type: &HidBackendType) -> Self {
|
||||
Self {
|
||||
available: !matches!(backend_type, HidBackendType::None),
|
||||
backend: backend_type.name_str().to_string(),
|
||||
initialized: false,
|
||||
online: false,
|
||||
supports_absolute_mouse: false,
|
||||
keyboard_leds_enabled: false,
|
||||
led_state: LedState::default(),
|
||||
screen_resolution: None,
|
||||
device: device_for_backend_type(backend_type),
|
||||
error: None,
|
||||
error_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_backend(backend_type: &HidBackendType, snapshot: HidBackendRuntimeSnapshot) -> Self {
|
||||
Self {
|
||||
available: !matches!(backend_type, HidBackendType::None),
|
||||
backend: backend_type.name_str().to_string(),
|
||||
initialized: snapshot.initialized,
|
||||
online: snapshot.online,
|
||||
supports_absolute_mouse: snapshot.supports_absolute_mouse,
|
||||
keyboard_leds_enabled: snapshot.keyboard_leds_enabled,
|
||||
led_state: snapshot.led_state,
|
||||
screen_resolution: snapshot.screen_resolution,
|
||||
device: snapshot
|
||||
.device
|
||||
.or_else(|| device_for_backend_type(backend_type)),
|
||||
error: snapshot.error,
|
||||
error_code: snapshot.error_code,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_error(
|
||||
backend_type: &HidBackendType,
|
||||
current: &Self,
|
||||
reason: impl Into<String>,
|
||||
error_code: impl Into<String>,
|
||||
) -> Self {
|
||||
let mut next = current.clone();
|
||||
next.available = !matches!(backend_type, HidBackendType::None);
|
||||
next.backend = backend_type.name_str().to_string();
|
||||
next.initialized = false;
|
||||
next.online = false;
|
||||
next.keyboard_leds_enabled = false;
|
||||
next.led_state = LedState::default();
|
||||
next.device = device_for_backend_type(backend_type);
|
||||
next.error = Some(reason.into());
|
||||
next.error_code = Some(error_code.into());
|
||||
next
|
||||
}
|
||||
}
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::events::EventBus;
|
||||
use crate::otg::OtgService;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
const HID_EVENT_QUEUE_CAPACITY: usize = 64;
|
||||
const HID_EVENT_SEND_TIMEOUT_MS: u64 = 30;
|
||||
const HID_HEALTH_CHECK_INTERVAL_MS: u64 = 1000;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum HidEvent {
|
||||
@@ -75,9 +156,9 @@ pub struct HidController {
|
||||
/// Backend type (mutable for reload)
|
||||
backend_type: Arc<RwLock<HidBackendType>>,
|
||||
/// Event bus for broadcasting state changes (optional)
|
||||
events: tokio::sync::RwLock<Option<Arc<crate::events::EventBus>>>,
|
||||
/// Health monitor for error tracking and recovery
|
||||
monitor: Arc<HidHealthMonitor>,
|
||||
events: Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
|
||||
/// Unified HID runtime state.
|
||||
runtime_state: Arc<RwLock<HidRuntimeState>>,
|
||||
/// HID event queue sender (non-blocking)
|
||||
hid_tx: mpsc::Sender<HidEvent>,
|
||||
/// HID event queue receiver (moved into worker on first start)
|
||||
@@ -88,10 +169,10 @@ pub struct HidController {
|
||||
pending_move_flag: Arc<AtomicBool>,
|
||||
/// Worker task handle
|
||||
hid_worker: Mutex<Option<JoinHandle<()>>>,
|
||||
/// Health check task handle
|
||||
hid_health_checker: Mutex<Option<JoinHandle<()>>>,
|
||||
/// Backend availability fast flag
|
||||
backend_available: AtomicBool,
|
||||
/// Backend runtime subscription task handle
|
||||
runtime_worker: Mutex<Option<JoinHandle<()>>>,
|
||||
/// Backend initialization fast flag
|
||||
backend_available: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl HidController {
|
||||
@@ -103,24 +184,24 @@ impl HidController {
|
||||
Self {
|
||||
otg_service,
|
||||
backend: Arc::new(RwLock::new(None)),
|
||||
backend_type: Arc::new(RwLock::new(backend_type)),
|
||||
events: tokio::sync::RwLock::new(None),
|
||||
monitor: Arc::new(HidHealthMonitor::with_defaults()),
|
||||
backend_type: Arc::new(RwLock::new(backend_type.clone())),
|
||||
events: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
runtime_state: Arc::new(RwLock::new(HidRuntimeState::from_backend_type(
|
||||
&backend_type,
|
||||
))),
|
||||
hid_tx,
|
||||
hid_rx: Mutex::new(Some(hid_rx)),
|
||||
pending_move: Arc::new(parking_lot::Mutex::new(None)),
|
||||
pending_move_flag: Arc::new(AtomicBool::new(false)),
|
||||
hid_worker: Mutex::new(None),
|
||||
hid_health_checker: Mutex::new(None),
|
||||
backend_available: AtomicBool::new(false),
|
||||
runtime_worker: Mutex::new(None),
|
||||
backend_available: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set event bus for broadcasting state changes
|
||||
pub async fn set_event_bus(&self, events: Arc<crate::events::EventBus>) {
|
||||
*self.events.write().await = Some(events.clone());
|
||||
// Also set event bus on the monitor for health notifications
|
||||
self.monitor.set_event_bus(events).await;
|
||||
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
|
||||
*self.events.write().await = Some(events);
|
||||
}
|
||||
|
||||
/// Initialize the HID backend
|
||||
@@ -128,16 +209,15 @@ impl HidController {
|
||||
let backend_type = self.backend_type.read().await.clone();
|
||||
let backend: Arc<dyn HidBackend> = match backend_type {
|
||||
HidBackendType::Otg => {
|
||||
// Request HID functions from OtgService
|
||||
let otg_service = self
|
||||
.otg_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::Internal("OtgService not available".into()))?;
|
||||
|
||||
info!("Requesting HID functions from OtgService");
|
||||
let handles = otg_service.enable_hid().await?;
|
||||
let handles = otg_service.hid_device_paths().await.ok_or_else(|| {
|
||||
AppError::Config("OTG HID paths are not available".to_string())
|
||||
})?;
|
||||
|
||||
// Create OtgBackend from handles (no longer manages gadget itself)
|
||||
info!("Creating OTG HID backend from device paths");
|
||||
Arc::new(otg::OtgBackend::from_handles(handles)?)
|
||||
}
|
||||
@@ -157,13 +237,28 @@ impl HidController {
|
||||
}
|
||||
};
|
||||
|
||||
backend.init().await?;
|
||||
if let Err(e) = backend.init().await {
|
||||
self.backend_available.store(false, Ordering::Release);
|
||||
let error_state = {
|
||||
let backend_type = self.backend_type.read().await.clone();
|
||||
let current = self.runtime_state.read().await.clone();
|
||||
HidRuntimeState::with_error(
|
||||
&backend_type,
|
||||
¤t,
|
||||
format!("Failed to initialize HID backend: {}", e),
|
||||
"init_failed",
|
||||
)
|
||||
};
|
||||
self.apply_runtime_state(error_state).await;
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
*self.backend.write().await = Some(backend);
|
||||
self.backend_available.store(true, Ordering::Release);
|
||||
self.sync_runtime_state_from_backend().await;
|
||||
|
||||
// Start HID event worker (once)
|
||||
self.start_event_worker().await;
|
||||
self.start_health_checker().await;
|
||||
self.restart_runtime_worker().await;
|
||||
|
||||
info!("HID backend initialized: {:?}", backend_type);
|
||||
Ok(())
|
||||
@@ -172,20 +267,24 @@ impl HidController {
|
||||
/// Shutdown the HID backend and release resources
|
||||
pub async fn shutdown(&self) -> Result<()> {
|
||||
info!("Shutting down HID controller");
|
||||
self.stop_health_checker().await;
|
||||
self.stop_runtime_worker().await;
|
||||
|
||||
// Close the backend
|
||||
*self.backend.write().await = None;
|
||||
self.backend_available.store(false, Ordering::Release);
|
||||
|
||||
// If OTG backend, notify OtgService to disable HID
|
||||
let backend_type = self.backend_type.read().await.clone();
|
||||
if matches!(backend_type, HidBackendType::Otg) {
|
||||
if let Some(ref otg_service) = self.otg_service {
|
||||
info!("Disabling HID functions in OtgService");
|
||||
otg_service.disable_hid().await?;
|
||||
if let Some(backend) = self.backend.write().await.take() {
|
||||
if let Err(e) = backend.shutdown().await {
|
||||
warn!("Error shutting down HID backend: {}", e);
|
||||
}
|
||||
}
|
||||
self.backend_available.store(false, Ordering::Release);
|
||||
let backend_type = self.backend_type.read().await.clone();
|
||||
let mut shutdown_state = HidRuntimeState::from_backend_type(&backend_type);
|
||||
if matches!(backend_type, HidBackendType::None) {
|
||||
shutdown_state.available = false;
|
||||
} else {
|
||||
shutdown_state.error = Some("HID backend stopped".to_string());
|
||||
shutdown_state.error_code = Some("shutdown".to_string());
|
||||
}
|
||||
self.apply_runtime_state(shutdown_state).await;
|
||||
|
||||
info!("HID controller shutdown complete");
|
||||
Ok(())
|
||||
@@ -241,7 +340,7 @@ impl HidController {
|
||||
|
||||
/// Check if backend is available
|
||||
pub async fn is_available(&self) -> bool {
|
||||
self.backend.read().await.is_some()
|
||||
self.backend_available.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Get backend type
|
||||
@@ -251,60 +350,29 @@ impl HidController {
|
||||
|
||||
/// Get backend info
|
||||
pub async fn info(&self) -> Option<HidInfo> {
|
||||
let backend = self.backend.read().await;
|
||||
backend.as_ref().map(|b| HidInfo {
|
||||
name: b.name(),
|
||||
initialized: true,
|
||||
supports_absolute_mouse: b.supports_absolute_mouse(),
|
||||
screen_resolution: b.screen_resolution(),
|
||||
let state = self.runtime_state.read().await.clone();
|
||||
if !state.available {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(HidInfo {
|
||||
name: state.backend,
|
||||
initialized: state.initialized,
|
||||
supports_absolute_mouse: state.supports_absolute_mouse,
|
||||
screen_resolution: state.screen_resolution,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get current state as SystemEvent
|
||||
pub async fn current_state_event(&self) -> crate::events::SystemEvent {
|
||||
let backend = self.backend.read().await;
|
||||
let backend_type = self.backend_type().await;
|
||||
let (backend_name, initialized) = match backend.as_ref() {
|
||||
Some(b) => (b.name(), true),
|
||||
None => (backend_type.name_str(), false),
|
||||
};
|
||||
|
||||
// Include error information from monitor
|
||||
let (error, error_code) = match self.monitor.status().await {
|
||||
HidHealthStatus::Error {
|
||||
reason, error_code, ..
|
||||
} => (Some(reason), Some(error_code)),
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
crate::events::SystemEvent::HidStateChanged {
|
||||
backend: backend_name.to_string(),
|
||||
initialized,
|
||||
error,
|
||||
error_code,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the health monitor reference
|
||||
pub fn monitor(&self) -> &Arc<HidHealthMonitor> {
|
||||
&self.monitor
|
||||
}
|
||||
|
||||
/// Get current health status
|
||||
pub async fn health_status(&self) -> HidHealthStatus {
|
||||
self.monitor.status().await
|
||||
}
|
||||
|
||||
/// Check if the HID backend is healthy
|
||||
pub async fn is_healthy(&self) -> bool {
|
||||
self.monitor.is_healthy().await
|
||||
/// Get current HID runtime state snapshot.
|
||||
pub async fn snapshot(&self) -> HidRuntimeState {
|
||||
self.runtime_state.read().await.clone()
|
||||
}
|
||||
|
||||
/// Reload the HID backend with new type
|
||||
pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> {
|
||||
info!("Reloading HID backend: {:?}", new_backend_type);
|
||||
self.backend_available.store(false, Ordering::Release);
|
||||
self.stop_health_checker().await;
|
||||
self.stop_runtime_worker().await;
|
||||
|
||||
// Shutdown existing backend first
|
||||
if let Some(backend) = self.backend.write().await.take() {
|
||||
@@ -329,9 +397,8 @@ impl HidController {
|
||||
}
|
||||
};
|
||||
|
||||
// Request HID functions from OtgService
|
||||
match otg_service.enable_hid().await {
|
||||
Ok(handles) => {
|
||||
match otg_service.hid_device_paths().await {
|
||||
Some(handles) => {
|
||||
// Create OtgBackend from handles
|
||||
match otg::OtgBackend::from_handles(handles) {
|
||||
Ok(backend) => {
|
||||
@@ -343,29 +410,18 @@ impl HidController {
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to initialize OTG backend: {}", e);
|
||||
// Cleanup: disable HID in OtgService
|
||||
if let Err(e2) = otg_service.disable_hid().await {
|
||||
warn!(
|
||||
"Failed to cleanup HID after init failure: {}",
|
||||
e2
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to create OTG backend: {}", e);
|
||||
// Cleanup: disable HID in OtgService
|
||||
if let Err(e2) = otg_service.disable_hid().await {
|
||||
warn!("Failed to cleanup HID after creation failure: {}", e2);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to enable HID in OtgService: {}", e);
|
||||
None => {
|
||||
warn!("OTG HID paths are not available");
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -403,27 +459,22 @@ impl HidController {
|
||||
|
||||
*self.backend.write().await = new_backend;
|
||||
|
||||
if matches!(new_backend_type, HidBackendType::None) {
|
||||
*self.backend_type.write().await = HidBackendType::None;
|
||||
self.apply_runtime_state(HidRuntimeState::from_backend_type(&HidBackendType::None))
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.backend.read().await.is_some() {
|
||||
info!("HID backend reloaded successfully: {:?}", new_backend_type);
|
||||
self.backend_available.store(true, Ordering::Release);
|
||||
self.start_event_worker().await;
|
||||
self.start_health_checker().await;
|
||||
|
||||
// Update backend_type on success
|
||||
*self.backend_type.write().await = new_backend_type.clone();
|
||||
|
||||
// Reset monitor state on successful reload
|
||||
self.monitor.reset().await;
|
||||
|
||||
// Publish HID state changed event
|
||||
let backend_name = new_backend_type.name_str().to_string();
|
||||
self.publish_event(crate::events::SystemEvent::HidStateChanged {
|
||||
backend: backend_name,
|
||||
initialized: true,
|
||||
error: None,
|
||||
error_code: None,
|
||||
})
|
||||
.await;
|
||||
self.sync_runtime_state_from_backend().await;
|
||||
self.restart_runtime_worker().await;
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -433,14 +484,14 @@ impl HidController {
|
||||
// Update backend_type even on failure (to reflect the attempted change)
|
||||
*self.backend_type.write().await = new_backend_type.clone();
|
||||
|
||||
// Publish event with initialized=false
|
||||
self.publish_event(crate::events::SystemEvent::HidStateChanged {
|
||||
backend: new_backend_type.name_str().to_string(),
|
||||
initialized: false,
|
||||
error: Some("Failed to initialize HID backend".to_string()),
|
||||
error_code: Some("init_failed".to_string()),
|
||||
})
|
||||
.await;
|
||||
let current = self.runtime_state.read().await.clone();
|
||||
let error_state = HidRuntimeState::with_error(
|
||||
&new_backend_type,
|
||||
¤t,
|
||||
"Failed to initialize HID backend",
|
||||
"init_failed",
|
||||
);
|
||||
self.apply_runtime_state(error_state).await;
|
||||
|
||||
Err(AppError::Internal(
|
||||
"Failed to reload HID backend".to_string(),
|
||||
@@ -448,11 +499,20 @@ impl HidController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish event to event bus if available
|
||||
async fn publish_event(&self, event: crate::events::SystemEvent) {
|
||||
if let Some(events) = self.events.read().await.as_ref() {
|
||||
events.publish(event);
|
||||
}
|
||||
async fn apply_runtime_state(&self, next: HidRuntimeState) {
|
||||
apply_runtime_state(&self.runtime_state, &self.events, next).await;
|
||||
}
|
||||
|
||||
async fn sync_runtime_state_from_backend(&self) {
|
||||
let backend_opt = self.backend.read().await.clone();
|
||||
apply_backend_runtime_state(
|
||||
&self.backend_type,
|
||||
&self.runtime_state,
|
||||
&self.events,
|
||||
self.backend_available.as_ref(),
|
||||
backend_opt.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn start_event_worker(&self) {
|
||||
@@ -468,8 +528,6 @@ impl HidController {
|
||||
};
|
||||
|
||||
let backend = self.backend.clone();
|
||||
let monitor = self.monitor.clone();
|
||||
let backend_type = self.backend_type.clone();
|
||||
let pending_move = self.pending_move.clone();
|
||||
let pending_move_flag = self.pending_move_flag.clone();
|
||||
|
||||
@@ -481,19 +539,13 @@ impl HidController {
|
||||
None => break,
|
||||
};
|
||||
|
||||
process_hid_event(event, &backend, &monitor, &backend_type).await;
|
||||
process_hid_event(event, &backend).await;
|
||||
|
||||
// After each event, flush latest move if pending
|
||||
if pending_move_flag.swap(false, Ordering::AcqRel) {
|
||||
let move_event = { pending_move.lock().take() };
|
||||
if let Some(move_event) = move_event {
|
||||
process_hid_event(
|
||||
HidEvent::Mouse(move_event),
|
||||
&backend,
|
||||
&monitor,
|
||||
&backend_type,
|
||||
)
|
||||
.await;
|
||||
process_hid_event(HidEvent::Mouse(move_event), &backend).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -502,84 +554,43 @@ impl HidController {
|
||||
*worker_guard = Some(handle);
|
||||
}
|
||||
|
||||
async fn start_health_checker(&self) {
|
||||
let mut checker_guard = self.hid_health_checker.lock().await;
|
||||
if checker_guard.is_some() {
|
||||
return;
|
||||
}
|
||||
async fn restart_runtime_worker(&self) {
|
||||
self.stop_runtime_worker().await;
|
||||
|
||||
let backend = self.backend.clone();
|
||||
let backend_opt = self.backend.read().await.clone();
|
||||
let Some(backend) = backend_opt else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut runtime_rx = backend.subscribe_runtime();
|
||||
let runtime_state = self.runtime_state.clone();
|
||||
let events = self.events.clone();
|
||||
let backend_available = self.backend_available.clone();
|
||||
let backend_type = self.backend_type.clone();
|
||||
let monitor = self.monitor.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut ticker =
|
||||
tokio::time::interval(Duration::from_millis(HID_HEALTH_CHECK_INTERVAL_MS));
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
|
||||
let backend_opt = backend.read().await.clone();
|
||||
let Some(active_backend) = backend_opt else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let backend_name = backend_type.read().await.name_str().to_string();
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || active_backend.health_check()).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(())) => {
|
||||
if monitor.is_error().await {
|
||||
monitor.report_recovered(&backend_name).await;
|
||||
}
|
||||
}
|
||||
Ok(Err(AppError::HidError {
|
||||
backend,
|
||||
reason,
|
||||
error_code,
|
||||
})) => {
|
||||
monitor
|
||||
.report_error(&backend, None, &reason, &error_code)
|
||||
.await;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
monitor
|
||||
.report_error(
|
||||
&backend_name,
|
||||
None,
|
||||
&format!("HID health check failed: {}", e),
|
||||
"health_check_failed",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
monitor
|
||||
.report_error(
|
||||
&backend_name,
|
||||
None,
|
||||
&format!("HID health check task failed: {}", e),
|
||||
"health_check_join_failed",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if runtime_rx.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
apply_backend_runtime_state(
|
||||
&backend_type,
|
||||
&runtime_state,
|
||||
&events,
|
||||
backend_available.as_ref(),
|
||||
Some(backend.as_ref()),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
*checker_guard = Some(handle);
|
||||
*self.runtime_worker.lock().await = Some(handle);
|
||||
}
|
||||
|
||||
async fn stop_health_checker(&self) {
|
||||
let handle_opt = {
|
||||
let mut checker_guard = self.hid_health_checker.lock().await;
|
||||
checker_guard.take()
|
||||
};
|
||||
|
||||
if let Some(handle) = handle_opt {
|
||||
async fn stop_runtime_worker(&self) {
|
||||
if let Some(handle) = self.runtime_worker.lock().await.take() {
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,25 +633,37 @@ impl HidController {
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_hid_event(
|
||||
event: HidEvent,
|
||||
backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>,
|
||||
monitor: &Arc<HidHealthMonitor>,
|
||||
async fn apply_backend_runtime_state(
|
||||
backend_type: &Arc<RwLock<HidBackendType>>,
|
||||
runtime_state: &Arc<RwLock<HidRuntimeState>>,
|
||||
events: &Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
|
||||
backend_available: &AtomicBool,
|
||||
backend: Option<&dyn HidBackend>,
|
||||
) {
|
||||
let backend_kind = backend_type.read().await.clone();
|
||||
let next = match backend {
|
||||
Some(backend) => HidRuntimeState::from_backend(&backend_kind, backend.runtime_snapshot()),
|
||||
None => HidRuntimeState::from_backend_type(&backend_kind),
|
||||
};
|
||||
backend_available.store(next.initialized, Ordering::Release);
|
||||
apply_runtime_state(runtime_state, events, next).await;
|
||||
}
|
||||
|
||||
async fn process_hid_event(event: HidEvent, backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>) {
|
||||
let backend_opt = backend.read().await.clone();
|
||||
let backend = match backend_opt {
|
||||
Some(b) => b,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let backend_for_send = backend.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
futures::executor::block_on(async move {
|
||||
match event {
|
||||
HidEvent::Keyboard(ev) => backend.send_keyboard(ev).await,
|
||||
HidEvent::Mouse(ev) => backend.send_mouse(ev).await,
|
||||
HidEvent::Consumer(ev) => backend.send_consumer(ev).await,
|
||||
HidEvent::Reset => backend.reset().await,
|
||||
HidEvent::Keyboard(ev) => backend_for_send.send_keyboard(ev).await,
|
||||
HidEvent::Mouse(ev) => backend_for_send.send_mouse(ev).await,
|
||||
HidEvent::Consumer(ev) => backend_for_send.send_consumer(ev).await,
|
||||
HidEvent::Reset => backend_for_send.reset().await,
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -652,25 +675,9 @@ async fn process_hid_event(
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
if monitor.is_error().await {
|
||||
let backend_type = backend_type.read().await;
|
||||
monitor.report_recovered(backend_type.name_str()).await;
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
if let AppError::HidError {
|
||||
ref backend,
|
||||
ref reason,
|
||||
ref error_code,
|
||||
} = e
|
||||
{
|
||||
if error_code != "eagain_retry" {
|
||||
monitor
|
||||
.report_error(backend, None, reason, error_code)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
warn!("HID event processing failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -680,3 +687,34 @@ impl Default for HidController {
|
||||
Self::new(HidBackendType::None, None)
|
||||
}
|
||||
}
|
||||
|
||||
fn device_for_backend_type(backend_type: &HidBackendType) -> Option<String> {
|
||||
match backend_type {
|
||||
HidBackendType::Ch9329 { port, .. } => Some(port.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_runtime_state(
|
||||
runtime_state: &Arc<RwLock<HidRuntimeState>>,
|
||||
events: &Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
|
||||
next: HidRuntimeState,
|
||||
) {
|
||||
let changed = {
|
||||
let mut guard = runtime_state.write().await;
|
||||
if *guard == next {
|
||||
false
|
||||
} else {
|
||||
*guard = next.clone();
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(events) = events.read().await.as_ref() {
|
||||
events.mark_device_info_dirty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
//! HID device health monitoring
|
||||
//!
|
||||
//! This module provides health monitoring for HID devices, including:
|
||||
//! - Device connectivity checks
|
||||
//! - Automatic reconnection on failure
|
||||
//! - Error tracking and notification
|
||||
//! - Log throttling to prevent log flooding
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::events::{EventBus, SystemEvent};
|
||||
use crate::utils::LogThrottler;
|
||||
|
||||
/// HID health status
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub enum HidHealthStatus {
|
||||
/// Device is healthy and operational
|
||||
#[default]
|
||||
Healthy,
|
||||
/// Device has an error, attempting recovery
|
||||
Error {
|
||||
/// Human-readable error reason
|
||||
reason: String,
|
||||
/// Error code for programmatic handling
|
||||
error_code: String,
|
||||
/// Number of recovery attempts made
|
||||
retry_count: u32,
|
||||
},
|
||||
/// Device is disconnected
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
/// HID health monitor configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HidMonitorConfig {
|
||||
/// Health check interval in milliseconds
|
||||
pub check_interval_ms: u64,
|
||||
/// Retry interval when device is lost (milliseconds)
|
||||
pub retry_interval_ms: u64,
|
||||
/// Maximum retry attempts before giving up (0 = infinite)
|
||||
pub max_retries: u32,
|
||||
/// Log throttle interval in seconds
|
||||
pub log_throttle_secs: u64,
|
||||
/// Recovery cooldown in milliseconds (suppress logs after recovery)
|
||||
pub recovery_cooldown_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for HidMonitorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
check_interval_ms: 1000,
|
||||
retry_interval_ms: 1000,
|
||||
max_retries: 0, // infinite retry
|
||||
log_throttle_secs: 5,
|
||||
recovery_cooldown_ms: 1000, // 1 second cooldown after recovery
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HID health monitor
|
||||
///
|
||||
/// Monitors HID device health and manages error recovery.
|
||||
/// Publishes WebSocket events when device status changes.
|
||||
pub struct HidHealthMonitor {
|
||||
/// Current health status
|
||||
status: RwLock<HidHealthStatus>,
|
||||
/// Event bus for notifications
|
||||
events: RwLock<Option<Arc<EventBus>>>,
|
||||
/// Log throttler to prevent log flooding
|
||||
throttler: LogThrottler,
|
||||
/// Configuration
|
||||
config: HidMonitorConfig,
|
||||
/// Whether monitoring is active (reserved for future use)
|
||||
#[allow(dead_code)]
|
||||
running: AtomicBool,
|
||||
/// Current retry count
|
||||
retry_count: AtomicU32,
|
||||
/// Last error code (for change detection)
|
||||
last_error_code: RwLock<Option<String>>,
|
||||
/// Last recovery timestamp (milliseconds since start, for cooldown)
|
||||
last_recovery_ms: AtomicU64,
|
||||
/// Start instant for timing
|
||||
start_instant: Instant,
|
||||
}
|
||||
|
||||
impl HidHealthMonitor {
|
||||
/// Create a new HID health monitor with the specified configuration
|
||||
pub fn new(config: HidMonitorConfig) -> Self {
|
||||
let throttle_secs = config.log_throttle_secs;
|
||||
Self {
|
||||
status: RwLock::new(HidHealthStatus::Healthy),
|
||||
events: RwLock::new(None),
|
||||
throttler: LogThrottler::with_secs(throttle_secs),
|
||||
config,
|
||||
running: AtomicBool::new(false),
|
||||
retry_count: AtomicU32::new(0),
|
||||
last_error_code: RwLock::new(None),
|
||||
last_recovery_ms: AtomicU64::new(0),
|
||||
start_instant: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new HID health monitor with default configuration
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(HidMonitorConfig::default())
|
||||
}
|
||||
|
||||
/// Set the event bus for broadcasting state changes
|
||||
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
|
||||
*self.events.write().await = Some(events);
|
||||
}
|
||||
|
||||
/// Report an error from HID operations
|
||||
///
|
||||
/// This method is called when an HID operation fails. It:
|
||||
/// 1. Updates the health status
|
||||
/// 2. Logs the error (with throttling and cooldown respect)
|
||||
/// 3. Publishes a WebSocket event if the error is new or changed
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `backend` - The HID backend type ("otg" or "ch9329")
|
||||
/// * `device` - The device path (if known)
|
||||
/// * `reason` - Human-readable error description
|
||||
/// * `error_code` - Error code for programmatic handling
|
||||
pub async fn report_error(
|
||||
&self,
|
||||
backend: &str,
|
||||
device: Option<&str>,
|
||||
reason: &str,
|
||||
error_code: &str,
|
||||
) {
|
||||
let count = self.retry_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
// Check if we're in cooldown period after recent recovery
|
||||
let current_ms = self.start_instant.elapsed().as_millis() as u64;
|
||||
let last_recovery = self.last_recovery_ms.load(Ordering::Relaxed);
|
||||
let in_cooldown =
|
||||
last_recovery > 0 && current_ms < last_recovery + self.config.recovery_cooldown_ms;
|
||||
|
||||
// Check if error code changed
|
||||
let error_changed = {
|
||||
let last = self.last_error_code.read().await;
|
||||
last.as_ref().map(|s| s.as_str()) != Some(error_code)
|
||||
};
|
||||
|
||||
// Log with throttling (skip if in cooldown period unless error type changed)
|
||||
let throttle_key = format!("hid_{}_{}", backend, error_code);
|
||||
if !in_cooldown && (error_changed || self.throttler.should_log(&throttle_key)) {
|
||||
warn!(
|
||||
"HID {} error: {} (code: {}, attempt: {})",
|
||||
backend, reason, error_code, count
|
||||
);
|
||||
}
|
||||
|
||||
// Update last error code
|
||||
*self.last_error_code.write().await = Some(error_code.to_string());
|
||||
|
||||
// Update status
|
||||
*self.status.write().await = HidHealthStatus::Error {
|
||||
reason: reason.to_string(),
|
||||
error_code: error_code.to_string(),
|
||||
retry_count: count,
|
||||
};
|
||||
|
||||
// Publish event (only if error changed or first occurrence, and not in cooldown)
|
||||
if !in_cooldown && (error_changed || count == 1) {
|
||||
if let Some(ref events) = *self.events.read().await {
|
||||
events.publish(SystemEvent::HidDeviceLost {
|
||||
backend: backend.to_string(),
|
||||
device: device.map(|s| s.to_string()),
|
||||
reason: reason.to_string(),
|
||||
error_code: error_code.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Report that a reconnection attempt is starting
|
||||
///
|
||||
/// Publishes a reconnecting event to notify clients.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `backend` - The HID backend type
|
||||
pub async fn report_reconnecting(&self, backend: &str) {
|
||||
let attempt = self.retry_count.load(Ordering::Relaxed);
|
||||
|
||||
// Only publish every 5 attempts to avoid event spam
|
||||
if attempt == 1 || attempt.is_multiple_of(5) {
|
||||
debug!("HID {} reconnecting, attempt {}", backend, attempt);
|
||||
|
||||
if let Some(ref events) = *self.events.read().await {
|
||||
events.publish(SystemEvent::HidReconnecting {
|
||||
backend: backend.to_string(),
|
||||
attempt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Report that the device has recovered
|
||||
///
|
||||
/// This method is called when the HID device successfully reconnects.
|
||||
/// It resets the error state and publishes a recovery event.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `backend` - The HID backend type
|
||||
pub async fn report_recovered(&self, backend: &str) {
|
||||
let prev_status = self.status.read().await.clone();
|
||||
|
||||
// Only report recovery if we were in an error state
|
||||
if prev_status != HidHealthStatus::Healthy {
|
||||
let retry_count = self.retry_count.load(Ordering::Relaxed);
|
||||
|
||||
// Set cooldown timestamp
|
||||
let current_ms = self.start_instant.elapsed().as_millis() as u64;
|
||||
self.last_recovery_ms.store(current_ms, Ordering::Relaxed);
|
||||
|
||||
// Only log and publish events if there were multiple retries
|
||||
// (avoid log spam for transient single-retry recoveries)
|
||||
if retry_count > 1 {
|
||||
debug!("HID {} recovered after {} retries", backend, retry_count);
|
||||
|
||||
// Publish recovery event
|
||||
if let Some(ref events) = *self.events.read().await {
|
||||
events.publish(SystemEvent::HidRecovered {
|
||||
backend: backend.to_string(),
|
||||
});
|
||||
|
||||
// Also publish state changed to indicate healthy state
|
||||
events.publish(SystemEvent::HidStateChanged {
|
||||
backend: backend.to_string(),
|
||||
initialized: true,
|
||||
error: None,
|
||||
error_code: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Reset state (always reset, even for single-retry recoveries)
|
||||
self.retry_count.store(0, Ordering::Relaxed);
|
||||
*self.last_error_code.write().await = None;
|
||||
*self.status.write().await = HidHealthStatus::Healthy;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current health status
|
||||
pub async fn status(&self) -> HidHealthStatus {
|
||||
self.status.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get the current retry count
|
||||
pub fn retry_count(&self) -> u32 {
|
||||
self.retry_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Check if the monitor is in an error state
|
||||
pub async fn is_error(&self) -> bool {
|
||||
matches!(*self.status.read().await, HidHealthStatus::Error { .. })
|
||||
}
|
||||
|
||||
/// Check if the monitor is healthy
|
||||
pub async fn is_healthy(&self) -> bool {
|
||||
matches!(*self.status.read().await, HidHealthStatus::Healthy)
|
||||
}
|
||||
|
||||
/// Reset the monitor to healthy state without publishing events
|
||||
///
|
||||
/// This is useful during initialization.
|
||||
pub async fn reset(&self) {
|
||||
self.retry_count.store(0, Ordering::Relaxed);
|
||||
*self.last_error_code.write().await = None;
|
||||
*self.status.write().await = HidHealthStatus::Healthy;
|
||||
self.throttler.clear_all();
|
||||
}
|
||||
|
||||
/// Get the configuration
|
||||
pub fn config(&self) -> &HidMonitorConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Check if we should continue retrying
|
||||
///
|
||||
/// Returns `false` if max_retries is set and we've exceeded it.
|
||||
pub fn should_retry(&self) -> bool {
|
||||
if self.config.max_retries == 0 {
|
||||
return true; // Infinite retry
|
||||
}
|
||||
self.retry_count.load(Ordering::Relaxed) < self.config.max_retries
|
||||
}
|
||||
|
||||
/// Get the retry interval
|
||||
pub fn retry_interval(&self) -> Duration {
|
||||
Duration::from_millis(self.config.retry_interval_ms)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HidHealthMonitor {
|
||||
fn default() -> Self {
|
||||
Self::with_defaults()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_initial_status() {
|
||||
let monitor = HidHealthMonitor::with_defaults();
|
||||
assert!(monitor.is_healthy().await);
|
||||
assert!(!monitor.is_error().await);
|
||||
assert_eq!(monitor.retry_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_report_error() {
|
||||
let monitor = HidHealthMonitor::with_defaults();
|
||||
|
||||
monitor
|
||||
.report_error("otg", Some("/dev/hidg0"), "Device not found", "enoent")
|
||||
.await;
|
||||
|
||||
assert!(monitor.is_error().await);
|
||||
assert_eq!(monitor.retry_count(), 1);
|
||||
|
||||
if let HidHealthStatus::Error {
|
||||
reason,
|
||||
error_code,
|
||||
retry_count,
|
||||
} = monitor.status().await
|
||||
{
|
||||
assert_eq!(reason, "Device not found");
|
||||
assert_eq!(error_code, "enoent");
|
||||
assert_eq!(retry_count, 1);
|
||||
} else {
|
||||
panic!("Expected Error status");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_report_recovered() {
|
||||
let monitor = HidHealthMonitor::with_defaults();
|
||||
|
||||
// First report an error
|
||||
monitor
|
||||
.report_error("ch9329", None, "Port not found", "port_not_found")
|
||||
.await;
|
||||
assert!(monitor.is_error().await);
|
||||
|
||||
// Then report recovery
|
||||
monitor.report_recovered("ch9329").await;
|
||||
assert!(monitor.is_healthy().await);
|
||||
assert_eq!(monitor.retry_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retry_count_increments() {
|
||||
let monitor = HidHealthMonitor::with_defaults();
|
||||
|
||||
for i in 1..=5 {
|
||||
monitor.report_error("otg", None, "Error", "io_error").await;
|
||||
assert_eq!(monitor.retry_count(), i);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_should_retry_infinite() {
|
||||
let monitor = HidHealthMonitor::new(HidMonitorConfig {
|
||||
max_retries: 0, // infinite
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
for _ in 0..100 {
|
||||
monitor.report_error("otg", None, "Error", "io_error").await;
|
||||
assert!(monitor.should_retry());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_should_retry_limited() {
|
||||
let monitor = HidHealthMonitor::new(HidMonitorConfig {
|
||||
max_retries: 3,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert!(monitor.should_retry());
|
||||
|
||||
monitor.report_error("otg", None, "Error", "io_error").await;
|
||||
assert!(monitor.should_retry()); // 1 < 3
|
||||
|
||||
monitor.report_error("otg", None, "Error", "io_error").await;
|
||||
assert!(monitor.should_retry()); // 2 < 3
|
||||
|
||||
monitor.report_error("otg", None, "Error", "io_error").await;
|
||||
assert!(!monitor.should_retry()); // 3 >= 3
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reset() {
|
||||
let monitor = HidHealthMonitor::with_defaults();
|
||||
|
||||
monitor.report_error("otg", None, "Error", "io_error").await;
|
||||
assert!(monitor.is_error().await);
|
||||
|
||||
monitor.reset().await;
|
||||
assert!(monitor.is_healthy().await);
|
||||
assert_eq!(monitor.retry_count(), 0);
|
||||
}
|
||||
}
|
||||
480
src/hid/otg.rs
480
src/hid/otg.rs
@@ -1,10 +1,12 @@
|
||||
//! OTG USB Gadget HID backend
|
||||
//!
|
||||
//! This backend uses Linux USB Gadget API to emulate USB HID devices.
|
||||
//! It creates and manages three HID devices:
|
||||
//! - hidg0: Keyboard (8-byte reports, with LED feedback)
|
||||
//! - hidg1: Relative Mouse (4-byte reports)
|
||||
//! - hidg2: Absolute Mouse (6-byte reports)
|
||||
//! It opens the HID gadget device nodes created by `OtgService`.
|
||||
//! Depending on the configured OTG profile, this may include:
|
||||
//! - hidg0: Keyboard
|
||||
//! - hidg1: Relative Mouse
|
||||
//! - hidg2: Absolute Mouse
|
||||
//! - hidg3: Consumer Control Keyboard
|
||||
//!
|
||||
//! Requirements:
|
||||
//! - USB OTG/Device controller (UDC)
|
||||
@@ -20,16 +22,20 @@
|
||||
use async_trait::async_trait;
|
||||
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::os::unix::io::AsFd;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
use tracing::{debug, info, trace, warn};
|
||||
|
||||
use super::backend::HidBackend;
|
||||
use super::keymap;
|
||||
use super::backend::{HidBackend, HidBackendRuntimeSnapshot};
|
||||
use super::types::{
|
||||
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType,
|
||||
};
|
||||
@@ -46,7 +52,7 @@ enum DeviceType {
|
||||
}
|
||||
|
||||
/// Keyboard LED state
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct LedState {
|
||||
/// Num Lock LED
|
||||
pub num_lock: bool,
|
||||
@@ -124,24 +130,36 @@ pub struct OtgBackend {
|
||||
mouse_abs_dev: Mutex<Option<File>>,
|
||||
/// Consumer control device file
|
||||
consumer_dev: Mutex<Option<File>>,
|
||||
/// Whether keyboard LED/status feedback is enabled.
|
||||
keyboard_leds_enabled: bool,
|
||||
/// Current keyboard state
|
||||
keyboard_state: Mutex<KeyboardReport>,
|
||||
/// Current mouse button state
|
||||
mouse_buttons: AtomicU8,
|
||||
/// Last known LED state (using parking_lot::RwLock for sync access)
|
||||
led_state: parking_lot::RwLock<LedState>,
|
||||
led_state: Arc<parking_lot::RwLock<LedState>>,
|
||||
/// Screen resolution for absolute mouse (using parking_lot::RwLock for sync access)
|
||||
screen_resolution: parking_lot::RwLock<Option<(u32, u32)>>,
|
||||
/// UDC name for state checking (e.g., "fcc00000.usb")
|
||||
udc_name: parking_lot::RwLock<Option<String>>,
|
||||
udc_name: Arc<parking_lot::RwLock<Option<String>>>,
|
||||
/// Whether the backend has been initialized.
|
||||
initialized: AtomicBool,
|
||||
/// Whether the device is currently online (UDC configured and devices accessible)
|
||||
online: AtomicBool,
|
||||
/// Last backend error state.
|
||||
last_error: parking_lot::RwLock<Option<(String, String)>>,
|
||||
/// Last error log time for throttling (using parking_lot for sync)
|
||||
last_error_log: parking_lot::Mutex<std::time::Instant>,
|
||||
/// Error count since last successful operation (for log throttling)
|
||||
error_count: AtomicU8,
|
||||
/// Consecutive EAGAIN count (for offline threshold detection)
|
||||
eagain_count: AtomicU8,
|
||||
/// Runtime change notifier.
|
||||
runtime_notify_tx: watch::Sender<()>,
|
||||
/// Runtime monitor stop flag.
|
||||
runtime_worker_stop: Arc<AtomicBool>,
|
||||
/// Runtime monitor thread.
|
||||
runtime_worker: Mutex<Option<thread::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
/// Write timeout in milliseconds (same as JetKVM's hidWriteTimeout)
|
||||
@@ -153,6 +171,7 @@ impl OtgBackend {
|
||||
/// This is the ONLY way to create an OtgBackend - it no longer manages
|
||||
/// the USB gadget itself. The gadget must already be set up by OtgService.
|
||||
pub fn from_handles(paths: HidDevicePaths) -> Result<Self> {
|
||||
let (runtime_notify_tx, _runtime_notify_rx) = watch::channel(());
|
||||
Ok(Self {
|
||||
keyboard_path: paths.keyboard,
|
||||
mouse_rel_path: paths.mouse_relative,
|
||||
@@ -162,18 +181,59 @@ impl OtgBackend {
|
||||
mouse_rel_dev: Mutex::new(None),
|
||||
mouse_abs_dev: Mutex::new(None),
|
||||
consumer_dev: Mutex::new(None),
|
||||
keyboard_leds_enabled: paths.keyboard_leds_enabled,
|
||||
keyboard_state: Mutex::new(KeyboardReport::default()),
|
||||
mouse_buttons: AtomicU8::new(0),
|
||||
led_state: parking_lot::RwLock::new(LedState::default()),
|
||||
led_state: Arc::new(parking_lot::RwLock::new(LedState::default())),
|
||||
screen_resolution: parking_lot::RwLock::new(Some((1920, 1080))),
|
||||
udc_name: parking_lot::RwLock::new(None),
|
||||
udc_name: Arc::new(parking_lot::RwLock::new(paths.udc)),
|
||||
initialized: AtomicBool::new(false),
|
||||
online: AtomicBool::new(false),
|
||||
last_error: parking_lot::RwLock::new(None),
|
||||
last_error_log: parking_lot::Mutex::new(std::time::Instant::now()),
|
||||
error_count: AtomicU8::new(0),
|
||||
eagain_count: AtomicU8::new(0),
|
||||
runtime_notify_tx,
|
||||
runtime_worker_stop: Arc::new(AtomicBool::new(false)),
|
||||
runtime_worker: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
fn notify_runtime_changed(&self) {
|
||||
let _ = self.runtime_notify_tx.send(());
|
||||
}
|
||||
|
||||
fn clear_error(&self) {
|
||||
let mut error = self.last_error.write();
|
||||
if error.is_some() {
|
||||
*error = None;
|
||||
self.notify_runtime_changed();
|
||||
}
|
||||
}
|
||||
|
||||
fn record_error(&self, reason: impl Into<String>, error_code: impl Into<String>) {
|
||||
let reason = reason.into();
|
||||
let error_code = error_code.into();
|
||||
let was_online = self.online.swap(false, Ordering::Relaxed);
|
||||
let mut error = self.last_error.write();
|
||||
let changed = error.as_ref() != Some(&(reason.clone(), error_code.clone()));
|
||||
*error = Some((reason, error_code));
|
||||
drop(error);
|
||||
if was_online || changed {
|
||||
self.notify_runtime_changed();
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_online(&self) {
|
||||
let was_online = self.online.swap(true, Ordering::Relaxed);
|
||||
let mut error = self.last_error.write();
|
||||
let cleared_error = error.take().is_some();
|
||||
drop(error);
|
||||
if !was_online || cleared_error {
|
||||
self.notify_runtime_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Log throttled error message (max once per second)
|
||||
fn log_throttled_error(&self, msg: &str) {
|
||||
let mut last_log = self.last_error_log.lock();
|
||||
@@ -237,13 +297,16 @@ impl OtgBackend {
|
||||
*self.udc_name.write() = Some(udc.to_string());
|
||||
}
|
||||
|
||||
/// Check if the UDC is in "configured" state
|
||||
///
|
||||
/// This is based on PiKVM's `__is_udc_configured()` method.
|
||||
/// The UDC state file indicates whether the USB host has enumerated and configured the gadget.
|
||||
pub fn is_udc_configured(&self) -> bool {
|
||||
let udc_name = self.udc_name.read();
|
||||
if let Some(ref udc) = *udc_name {
|
||||
fn read_udc_configured(udc_name: &parking_lot::RwLock<Option<String>>) -> bool {
|
||||
let current_udc = udc_name.read().clone().or_else(Self::find_udc);
|
||||
if let Some(udc) = current_udc {
|
||||
{
|
||||
let mut guard = udc_name.write();
|
||||
if guard.as_ref() != Some(&udc) {
|
||||
*guard = Some(udc.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let state_path = format!("/sys/class/udc/{}/state", udc);
|
||||
match fs::read_to_string(&state_path) {
|
||||
Ok(content) => {
|
||||
@@ -253,26 +316,22 @@ impl OtgBackend {
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Failed to read UDC state from {}: {}", state_path, e);
|
||||
// If we can't read the state, assume it might be configured
|
||||
// to avoid blocking operations unnecessarily
|
||||
true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No UDC name set, try to auto-detect
|
||||
if let Some(udc) = Self::find_udc() {
|
||||
drop(udc_name);
|
||||
*self.udc_name.write() = Some(udc.clone());
|
||||
let state_path = format!("/sys/class/udc/{}/state", udc);
|
||||
fs::read_to_string(&state_path)
|
||||
.map(|s| s.trim().to_lowercase() == "configured")
|
||||
.unwrap_or(true)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the UDC is in "configured" state
|
||||
///
|
||||
/// This is based on PiKVM's `__is_udc_configured()` method.
|
||||
/// The UDC state file indicates whether the USB host has enumerated and configured the gadget.
|
||||
pub fn is_udc_configured(&self) -> bool {
|
||||
Self::read_udc_configured(&self.udc_name)
|
||||
}
|
||||
|
||||
/// Find the first available UDC
|
||||
fn find_udc() -> Option<String> {
|
||||
let udc_path = PathBuf::from("/sys/class/udc");
|
||||
@@ -286,11 +345,6 @@ impl OtgBackend {
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if device is online
|
||||
pub fn is_online(&self) -> bool {
|
||||
self.online.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Ensure a device is open and ready for I/O
|
||||
///
|
||||
/// This method is based on PiKVM's `__ensure_device()` pattern:
|
||||
@@ -308,12 +362,13 @@ impl OtgBackend {
|
||||
let path = match path_opt {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
return Err(AppError::HidError {
|
||||
let err = AppError::HidError {
|
||||
backend: "otg".to_string(),
|
||||
reason: "Device disabled".to_string(),
|
||||
error_code: "disabled".to_string(),
|
||||
});
|
||||
};
|
||||
self.record_error("Device disabled", "disabled");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -328,10 +383,11 @@ impl OtgBackend {
|
||||
);
|
||||
*dev = None;
|
||||
}
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
let reason = format!("Device not found: {}", path.display());
|
||||
self.record_error(reason.clone(), "enoent");
|
||||
return Err(AppError::HidError {
|
||||
backend: "otg".to_string(),
|
||||
reason: format!("Device not found: {}", path.display()),
|
||||
reason,
|
||||
error_code: "enoent".to_string(),
|
||||
});
|
||||
}
|
||||
@@ -346,12 +402,16 @@ impl OtgBackend {
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to reopen HID device {}: {}", path.display(), e);
|
||||
self.record_error(
|
||||
format!("Failed to reopen HID device {}: {}", path.display(), e),
|
||||
"not_opened",
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.online.store(true, Ordering::Relaxed);
|
||||
self.mark_online();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -372,8 +432,8 @@ impl OtgBackend {
|
||||
}
|
||||
|
||||
/// Convert I/O error to HidError with appropriate error code
|
||||
fn io_error_to_hid_error(e: std::io::Error, operation: &str) -> AppError {
|
||||
let error_code = match e.raw_os_error() {
|
||||
fn io_error_code(e: &std::io::Error) -> &'static str {
|
||||
match e.raw_os_error() {
|
||||
Some(32) => "epipe", // EPIPE - broken pipe
|
||||
Some(108) => "eshutdown", // ESHUTDOWN - transport endpoint shutdown
|
||||
Some(11) => "eagain", // EAGAIN - resource temporarily unavailable
|
||||
@@ -382,7 +442,11 @@ impl OtgBackend {
|
||||
Some(5) => "eio", // EIO - I/O error
|
||||
Some(2) => "enoent", // ENOENT - no such file or directory
|
||||
_ => "io_error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn io_error_to_hid_error(e: std::io::Error, operation: &str) -> AppError {
|
||||
let error_code = Self::io_error_code(&e);
|
||||
|
||||
AppError::HidError {
|
||||
backend: "otg".to_string(),
|
||||
@@ -438,7 +502,7 @@ impl OtgBackend {
|
||||
let data = report.to_bytes();
|
||||
match self.write_with_timeout(file, &data) {
|
||||
Ok(true) => {
|
||||
self.online.store(true, Ordering::Relaxed);
|
||||
self.mark_online();
|
||||
self.reset_error_count();
|
||||
debug!("Sent keyboard report: {:02X?}", data);
|
||||
Ok(())
|
||||
@@ -454,10 +518,13 @@ impl OtgBackend {
|
||||
match error_code {
|
||||
Some(108) => {
|
||||
// ESHUTDOWN - endpoint closed, need to reopen device
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
self.eagain_count.store(0, Ordering::Relaxed);
|
||||
debug!("Keyboard ESHUTDOWN, closing for recovery");
|
||||
*dev = None;
|
||||
self.record_error(
|
||||
format!("Failed to write keyboard report: {}", e),
|
||||
"eshutdown",
|
||||
);
|
||||
Err(Self::io_error_to_hid_error(
|
||||
e,
|
||||
"Failed to write keyboard report",
|
||||
@@ -469,9 +536,12 @@ impl OtgBackend {
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
self.eagain_count.store(0, Ordering::Relaxed);
|
||||
warn!("Keyboard write error: {}", e);
|
||||
self.record_error(
|
||||
format!("Failed to write keyboard report: {}", e),
|
||||
Self::io_error_code(&e),
|
||||
);
|
||||
Err(Self::io_error_to_hid_error(
|
||||
e,
|
||||
"Failed to write keyboard report",
|
||||
@@ -507,7 +577,7 @@ impl OtgBackend {
|
||||
let data = [buttons, dx as u8, dy as u8, wheel as u8];
|
||||
match self.write_with_timeout(file, &data) {
|
||||
Ok(true) => {
|
||||
self.online.store(true, Ordering::Relaxed);
|
||||
self.mark_online();
|
||||
self.reset_error_count();
|
||||
trace!("Sent relative mouse report: {:02X?}", data);
|
||||
Ok(())
|
||||
@@ -521,10 +591,13 @@ impl OtgBackend {
|
||||
|
||||
match error_code {
|
||||
Some(108) => {
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
self.eagain_count.store(0, Ordering::Relaxed);
|
||||
debug!("Relative mouse ESHUTDOWN, closing for recovery");
|
||||
*dev = None;
|
||||
self.record_error(
|
||||
format!("Failed to write mouse report: {}", e),
|
||||
"eshutdown",
|
||||
);
|
||||
Err(Self::io_error_to_hid_error(
|
||||
e,
|
||||
"Failed to write mouse report",
|
||||
@@ -535,9 +608,12 @@ impl OtgBackend {
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
self.eagain_count.store(0, Ordering::Relaxed);
|
||||
warn!("Relative mouse write error: {}", e);
|
||||
self.record_error(
|
||||
format!("Failed to write mouse report: {}", e),
|
||||
Self::io_error_code(&e),
|
||||
);
|
||||
Err(Self::io_error_to_hid_error(
|
||||
e,
|
||||
"Failed to write mouse report",
|
||||
@@ -580,7 +656,7 @@ impl OtgBackend {
|
||||
];
|
||||
match self.write_with_timeout(file, &data) {
|
||||
Ok(true) => {
|
||||
self.online.store(true, Ordering::Relaxed);
|
||||
self.mark_online();
|
||||
self.reset_error_count();
|
||||
Ok(())
|
||||
}
|
||||
@@ -593,10 +669,13 @@ impl OtgBackend {
|
||||
|
||||
match error_code {
|
||||
Some(108) => {
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
self.eagain_count.store(0, Ordering::Relaxed);
|
||||
debug!("Absolute mouse ESHUTDOWN, closing for recovery");
|
||||
*dev = None;
|
||||
self.record_error(
|
||||
format!("Failed to write mouse report: {}", e),
|
||||
"eshutdown",
|
||||
);
|
||||
Err(Self::io_error_to_hid_error(
|
||||
e,
|
||||
"Failed to write mouse report",
|
||||
@@ -607,9 +686,12 @@ impl OtgBackend {
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
self.eagain_count.store(0, Ordering::Relaxed);
|
||||
warn!("Absolute mouse write error: {}", e);
|
||||
self.record_error(
|
||||
format!("Failed to write mouse report: {}", e),
|
||||
Self::io_error_code(&e),
|
||||
);
|
||||
Err(Self::io_error_to_hid_error(
|
||||
e,
|
||||
"Failed to write mouse report",
|
||||
@@ -648,7 +730,7 @@ impl OtgBackend {
|
||||
// Send release (0x0000)
|
||||
let release = [0u8, 0u8];
|
||||
let _ = self.write_with_timeout(file, &release);
|
||||
self.online.store(true, Ordering::Relaxed);
|
||||
self.mark_online();
|
||||
self.reset_error_count();
|
||||
Ok(())
|
||||
}
|
||||
@@ -660,9 +742,12 @@ impl OtgBackend {
|
||||
let error_code = e.raw_os_error();
|
||||
match error_code {
|
||||
Some(108) => {
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
debug!("Consumer control ESHUTDOWN, closing for recovery");
|
||||
*dev = None;
|
||||
self.record_error(
|
||||
format!("Failed to write consumer report: {}", e),
|
||||
"eshutdown",
|
||||
);
|
||||
Err(Self::io_error_to_hid_error(
|
||||
e,
|
||||
"Failed to write consumer report",
|
||||
@@ -673,8 +758,11 @@ impl OtgBackend {
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
warn!("Consumer control write error: {}", e);
|
||||
self.record_error(
|
||||
format!("Failed to write consumer report: {}", e),
|
||||
Self::io_error_code(&e),
|
||||
);
|
||||
Err(Self::io_error_to_hid_error(
|
||||
e,
|
||||
"Failed to write consumer report",
|
||||
@@ -697,49 +785,204 @@ impl OtgBackend {
|
||||
self.send_consumer_report(event.usage)
|
||||
}
|
||||
|
||||
/// Read keyboard LED state (non-blocking)
|
||||
pub fn read_led_state(&self) -> Result<Option<LedState>> {
|
||||
let mut dev = self.keyboard_dev.lock();
|
||||
if let Some(ref mut file) = *dev {
|
||||
let mut buf = [0u8; 1];
|
||||
match file.read(&mut buf) {
|
||||
Ok(1) => {
|
||||
let state = LedState::from_byte(buf[0]);
|
||||
// Update LED state (using parking_lot RwLock)
|
||||
*self.led_state.write() = state;
|
||||
Ok(Some(state))
|
||||
}
|
||||
Ok(_) => Ok(None), // No data available
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
|
||||
Err(e) => Err(AppError::Internal(format!(
|
||||
"Failed to read LED state: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get last known LED state
|
||||
pub fn led_state(&self) -> LedState {
|
||||
*self.led_state.read()
|
||||
}
|
||||
|
||||
fn build_runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
|
||||
let initialized = self.initialized.load(Ordering::Relaxed);
|
||||
let mut online = initialized && self.online.load(Ordering::Relaxed);
|
||||
let mut error = self.last_error.read().clone();
|
||||
|
||||
if initialized && !self.check_devices_exist() {
|
||||
online = false;
|
||||
let missing = self.get_missing_devices();
|
||||
error = Some((
|
||||
format!("HID device node missing: {}", missing.join(", ")),
|
||||
"enoent".to_string(),
|
||||
));
|
||||
} else if initialized && !self.is_udc_configured() {
|
||||
online = false;
|
||||
error = Some((
|
||||
"UDC is not in configured state".to_string(),
|
||||
"udc_not_configured".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
HidBackendRuntimeSnapshot {
|
||||
initialized,
|
||||
online,
|
||||
supports_absolute_mouse: self.mouse_abs_path.as_ref().is_some_and(|p| p.exists()),
|
||||
keyboard_leds_enabled: self.keyboard_leds_enabled,
|
||||
led_state: self.led_state(),
|
||||
screen_resolution: *self.screen_resolution.read(),
|
||||
device: self.udc_name.read().clone(),
|
||||
error: error.as_ref().map(|(reason, _)| reason.clone()),
|
||||
error_code: error.as_ref().map(|(_, code)| code.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_keyboard_led_once(
|
||||
file: &mut Option<File>,
|
||||
path: &PathBuf,
|
||||
led_state: &Arc<parking_lot::RwLock<LedState>>,
|
||||
) -> bool {
|
||||
if file.is_none() {
|
||||
match OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(path)
|
||||
{
|
||||
Ok(opened) => {
|
||||
*file = Some(opened);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Failed to open OTG keyboard LED listener {}: {}",
|
||||
path.display(),
|
||||
err
|
||||
);
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(file_ref) = file.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut pollfd = [PollFd::new(
|
||||
file_ref.as_fd(),
|
||||
PollFlags::POLLIN | PollFlags::POLLERR | PollFlags::POLLHUP,
|
||||
)];
|
||||
|
||||
match poll(&mut pollfd, PollTimeout::from(500u16)) {
|
||||
Ok(0) => false,
|
||||
Ok(_) => {
|
||||
let Some(revents) = pollfd[0].revents() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if revents.contains(PollFlags::POLLERR) || revents.contains(PollFlags::POLLHUP) {
|
||||
*file = None;
|
||||
return true;
|
||||
}
|
||||
|
||||
if !revents.contains(PollFlags::POLLIN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 1];
|
||||
match file_ref.read(&mut buf) {
|
||||
Ok(1) => {
|
||||
let next = LedState::from_byte(buf[0]);
|
||||
let mut guard = led_state.write();
|
||||
if *guard == next {
|
||||
false
|
||||
} else {
|
||||
*guard = next;
|
||||
true
|
||||
}
|
||||
}
|
||||
Ok(_) => false,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => false,
|
||||
Err(err) => {
|
||||
warn!("OTG keyboard LED listener read failed: {}", err);
|
||||
*file = None;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("OTG keyboard LED listener poll failed: {}", err);
|
||||
*file = None;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_runtime_worker(&self) {
|
||||
let mut worker = self.runtime_worker.lock();
|
||||
if worker.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.runtime_worker_stop.store(false, Ordering::Relaxed);
|
||||
let stop = self.runtime_worker_stop.clone();
|
||||
let keyboard_leds_enabled = self.keyboard_leds_enabled;
|
||||
let keyboard_path = self.keyboard_path.clone();
|
||||
let led_state = self.led_state.clone();
|
||||
let udc_name = self.udc_name.clone();
|
||||
let runtime_notify_tx = self.runtime_notify_tx.clone();
|
||||
|
||||
let handle = thread::Builder::new()
|
||||
.name("otg-runtime-monitor".to_string())
|
||||
.spawn(move || {
|
||||
let mut last_udc_configured = Some(Self::read_udc_configured(&udc_name));
|
||||
let mut keyboard_led_file: Option<File> = None;
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let mut changed = false;
|
||||
|
||||
let current_udc_configured = Self::read_udc_configured(&udc_name);
|
||||
if last_udc_configured != Some(current_udc_configured) {
|
||||
last_udc_configured = Some(current_udc_configured);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if keyboard_leds_enabled {
|
||||
if let Some(path) = keyboard_path.as_ref() {
|
||||
changed |= Self::poll_keyboard_led_once(
|
||||
&mut keyboard_led_file,
|
||||
path,
|
||||
&led_state,
|
||||
);
|
||||
} else {
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
} else {
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
|
||||
if changed {
|
||||
let _ = runtime_notify_tx.send(());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
match handle {
|
||||
Ok(handle) => {
|
||||
*worker = Some(handle);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to spawn OTG runtime monitor: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_runtime_worker(&self) {
|
||||
self.runtime_worker_stop.store(true, Ordering::Relaxed);
|
||||
if let Some(handle) = self.runtime_worker.lock().take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HidBackend for OtgBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"OTG USB Gadget"
|
||||
}
|
||||
|
||||
async fn init(&self) -> Result<()> {
|
||||
info!("Initializing OTG HID backend");
|
||||
|
||||
// Auto-detect UDC name for state checking
|
||||
if let Some(udc) = Self::find_udc() {
|
||||
info!("Auto-detected UDC: {}", udc);
|
||||
self.set_udc_name(&udc);
|
||||
// Auto-detect UDC name for state checking only if OtgService did not provide one
|
||||
if self.udc_name.read().is_none() {
|
||||
if let Some(udc) = Self::find_udc() {
|
||||
info!("Auto-detected UDC: {}", udc);
|
||||
self.set_udc_name(&udc);
|
||||
}
|
||||
} else if let Some(udc) = self.udc_name.read().clone() {
|
||||
info!("Using configured UDC: {}", udc);
|
||||
}
|
||||
|
||||
// Wait for devices to appear (they should already exist from OtgService)
|
||||
@@ -812,24 +1055,22 @@ impl HidBackend for OtgBackend {
|
||||
}
|
||||
|
||||
// Mark as online if all devices opened successfully
|
||||
self.online.store(true, Ordering::Relaxed);
|
||||
self.initialized.store(true, Ordering::Relaxed);
|
||||
self.notify_runtime_changed();
|
||||
self.start_runtime_worker();
|
||||
self.mark_online();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
|
||||
// Convert JS keycode to USB HID if needed (skip if already USB HID)
|
||||
let usb_key = if event.is_usb_hid {
|
||||
event.key
|
||||
} else {
|
||||
keymap::js_to_usb(event.key).unwrap_or(event.key)
|
||||
};
|
||||
let usb_key = event.key.to_hid_usage();
|
||||
|
||||
// Handle modifier keys separately
|
||||
if keymap::is_modifier_key(usb_key) {
|
||||
if event.key.is_modifier() {
|
||||
let mut state = self.keyboard_state.lock();
|
||||
|
||||
if let Some(bit) = keymap::modifier_bit(usb_key) {
|
||||
if let Some(bit) = event.key.modifier_bit() {
|
||||
match event.event_type {
|
||||
KeyEventType::Down => state.modifiers |= bit,
|
||||
KeyEventType::Up => state.modifiers &= !bit,
|
||||
@@ -925,6 +1166,8 @@ impl HidBackend for OtgBackend {
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> Result<()> {
|
||||
self.stop_runtime_worker();
|
||||
|
||||
// Reset before closing
|
||||
self.reset().await?;
|
||||
|
||||
@@ -935,49 +1178,30 @@ impl HidBackend for OtgBackend {
|
||||
*self.consumer_dev.lock() = None;
|
||||
|
||||
// Gadget cleanup is handled by OtgService, not here
|
||||
self.initialized.store(false, Ordering::Relaxed);
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
self.clear_error();
|
||||
self.notify_runtime_changed();
|
||||
|
||||
info!("OTG backend shutdown");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn health_check(&self) -> Result<()> {
|
||||
if !self.check_devices_exist() {
|
||||
let missing = self.get_missing_devices();
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
return Err(AppError::HidError {
|
||||
backend: "otg".to_string(),
|
||||
reason: format!("HID device node missing: {}", missing.join(", ")),
|
||||
error_code: "enoent".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if !self.is_udc_configured() {
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
return Err(AppError::HidError {
|
||||
backend: "otg".to_string(),
|
||||
reason: "UDC is not in configured state".to_string(),
|
||||
error_code: "udc_not_configured".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
self.online.store(true, Ordering::Relaxed);
|
||||
Ok(())
|
||||
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
|
||||
self.build_runtime_snapshot()
|
||||
}
|
||||
|
||||
fn supports_absolute_mouse(&self) -> bool {
|
||||
self.mouse_abs_path.as_ref().is_some_and(|p| p.exists())
|
||||
fn subscribe_runtime(&self) -> watch::Receiver<()> {
|
||||
self.runtime_notify_tx.subscribe()
|
||||
}
|
||||
|
||||
async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
|
||||
self.send_consumer_report(event.usage)
|
||||
}
|
||||
|
||||
fn screen_resolution(&self) -> Option<(u32, u32)> {
|
||||
*self.screen_resolution.read()
|
||||
}
|
||||
|
||||
fn set_screen_resolution(&mut self, width: u32, height: u32) {
|
||||
*self.screen_resolution.write() = Some((width, height));
|
||||
self.notify_runtime_changed();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -994,6 +1218,10 @@ pub fn is_otg_available() -> bool {
|
||||
/// Implement Drop for OtgBackend to close device files
|
||||
impl Drop for OtgBackend {
|
||||
fn drop(&mut self) {
|
||||
self.runtime_worker_stop.store(true, Ordering::Relaxed);
|
||||
if let Some(handle) = self.runtime_worker.get_mut().take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
// Close device files
|
||||
// Note: Gadget cleanup is handled by OtgService, not here
|
||||
*self.keyboard_dev.lock() = None;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::keyboard::CanonicalKey;
|
||||
|
||||
/// Keyboard event type
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -105,34 +107,29 @@ pub struct KeyboardEvent {
|
||||
/// Event type (down/up)
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: KeyEventType,
|
||||
/// Key code (USB HID usage code or JavaScript key code)
|
||||
pub key: u8,
|
||||
/// Canonical keyboard key identifier shared across frontend and backend
|
||||
pub key: CanonicalKey,
|
||||
/// Modifier keys state
|
||||
#[serde(default)]
|
||||
pub modifiers: KeyboardModifiers,
|
||||
/// If true, key is already USB HID code (skip js_to_usb conversion)
|
||||
#[serde(default)]
|
||||
pub is_usb_hid: bool,
|
||||
}
|
||||
|
||||
impl KeyboardEvent {
|
||||
/// Create a key down event (JS keycode, needs conversion)
|
||||
pub fn key_down(key: u8, modifiers: KeyboardModifiers) -> Self {
|
||||
/// Create a key down event
|
||||
pub fn key_down(key: CanonicalKey, modifiers: KeyboardModifiers) -> Self {
|
||||
Self {
|
||||
event_type: KeyEventType::Down,
|
||||
key,
|
||||
modifiers,
|
||||
is_usb_hid: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a key up event (JS keycode, needs conversion)
|
||||
pub fn key_up(key: u8, modifiers: KeyboardModifiers) -> Self {
|
||||
/// Create a key up event
|
||||
pub fn key_up(key: CanonicalKey, modifiers: KeyboardModifiers) -> Self {
|
||||
Self {
|
||||
event_type: KeyEventType::Up,
|
||||
key,
|
||||
modifiers,
|
||||
is_usb_hid: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
255
src/main.rs
255
src/main.rs
@@ -1,13 +1,14 @@
|
||||
use std::collections::HashSet;
|
||||
use std::io::Write;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use clap::{Parser, ValueEnum};
|
||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
use futures::{stream::FuturesUnordered, StreamExt};
|
||||
use rustls::crypto::{ring, CryptoProvider};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use one_kvm::atx::AtxController;
|
||||
@@ -18,7 +19,7 @@ use one_kvm::events::EventBus;
|
||||
use one_kvm::extensions::ExtensionManager;
|
||||
use one_kvm::hid::{HidBackendType, HidController};
|
||||
use one_kvm::msd::MsdController;
|
||||
use one_kvm::otg::{configfs, OtgService};
|
||||
use one_kvm::otg::OtgService;
|
||||
use one_kvm::rtsp::RtspService;
|
||||
use one_kvm::rustdesk::RustDeskService;
|
||||
use one_kvm::state::AppState;
|
||||
@@ -49,6 +50,10 @@ enum LogLevel {
|
||||
#[command(name = "one-kvm")]
|
||||
#[command(version, about = "A open and lightweight IP-KVM solution", long_about = None)]
|
||||
struct CliArgs {
|
||||
/// User management commands
|
||||
#[command(subcommand)]
|
||||
command: Option<CliCommand>,
|
||||
|
||||
/// Listen address (overrides database config)
|
||||
#[arg(short = 'a', long, value_name = "ADDRESS")]
|
||||
address: Option<String>,
|
||||
@@ -86,6 +91,24 @@ struct CliArgs {
|
||||
verbose: u8,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum CliCommand {
|
||||
/// Manage local users
|
||||
User(UserCommand),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct UserCommand {
|
||||
#[command(subcommand)]
|
||||
action: UserAction,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum UserAction {
|
||||
/// Set password for the single local user (interactive terminal prompt)
|
||||
SetPassword,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Parse command line arguments
|
||||
@@ -101,9 +124,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::info!("Starting One-KVM v{}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// Determine data directory (CLI arg takes precedence)
|
||||
let data_dir = args.data_dir.unwrap_or_else(get_data_dir);
|
||||
let data_dir = args.data_dir.clone().unwrap_or_else(get_data_dir);
|
||||
tracing::info!("Data directory: {}", data_dir.display());
|
||||
|
||||
// Run one-off CLI command and exit.
|
||||
if let Some(command) = args.command {
|
||||
run_cli_command(command, data_dir).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Ensure data directory exists
|
||||
tokio::fs::create_dir_all(&data_dir).await?;
|
||||
|
||||
@@ -319,32 +348,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
let otg_service = Arc::new(OtgService::new());
|
||||
tracing::info!("OTG Service created");
|
||||
|
||||
// Pre-enable OTG functions to avoid gadget recreation (prevents kernel crashes)
|
||||
let will_use_otg_hid = matches!(config.hid.backend, config::HidBackend::Otg);
|
||||
let will_use_msd = config.msd.enabled;
|
||||
|
||||
if will_use_otg_hid {
|
||||
let mut hid_functions = config.hid.effective_otg_functions();
|
||||
if let Some(udc) = configfs::resolve_udc_name(config.hid.otg_udc.as_deref()) {
|
||||
if configfs::is_low_endpoint_udc(&udc) && hid_functions.consumer {
|
||||
tracing::warn!(
|
||||
"UDC {} has low endpoint resources, disabling consumer control",
|
||||
udc
|
||||
);
|
||||
hid_functions.consumer = false;
|
||||
}
|
||||
}
|
||||
if let Err(e) = otg_service.update_hid_functions(hid_functions).await {
|
||||
tracing::warn!("Failed to apply HID functions: {}", e);
|
||||
}
|
||||
if let Err(e) = otg_service.enable_hid().await {
|
||||
tracing::warn!("Failed to pre-enable HID: {}", e);
|
||||
}
|
||||
}
|
||||
if will_use_msd {
|
||||
if let Err(e) = otg_service.enable_msd().await {
|
||||
tracing::warn!("Failed to pre-enable MSD: {}", e);
|
||||
}
|
||||
// Reconcile OTG once from the persisted config so controllers only consume its result.
|
||||
if let Err(e) = otg_service.apply_config(&config.hid, &config.msd).await {
|
||||
tracing::warn!("Failed to apply OTG config: {}", e);
|
||||
}
|
||||
|
||||
// Create HID controller based on config
|
||||
@@ -576,6 +582,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
data_dir.clone(),
|
||||
);
|
||||
|
||||
extensions.set_event_bus(events.clone()).await;
|
||||
|
||||
// Start RustDesk service if enabled
|
||||
if let Some(ref service) = rustdesk {
|
||||
if let Err(e) = service.start().await {
|
||||
@@ -646,6 +654,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::info!("Extension health check task started");
|
||||
}
|
||||
|
||||
state.publish_device_info().await;
|
||||
|
||||
// Start device info broadcast task
|
||||
// This monitors state change events and broadcasts DeviceInfo to all clients
|
||||
spawn_device_info_broadcaster(state.clone(), events);
|
||||
@@ -784,6 +794,81 @@ fn get_data_dir() -> PathBuf {
|
||||
PathBuf::from("/etc/one-kvm")
|
||||
}
|
||||
|
||||
async fn run_cli_command(command: CliCommand, data_dir: PathBuf) -> anyhow::Result<()> {
|
||||
tokio::fs::create_dir_all(&data_dir).await?;
|
||||
let db_path = data_dir.join("one-kvm.db");
|
||||
let config_store = ConfigStore::new(&db_path).await?;
|
||||
let users = UserStore::new(config_store.pool().clone());
|
||||
let sessions = SessionStore::new(config_store.pool().clone(), 0);
|
||||
|
||||
match command {
|
||||
CliCommand::User(user) => run_user_action(user.action, &users, &sessions).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_user_action(
|
||||
action: UserAction,
|
||||
users: &UserStore,
|
||||
sessions: &SessionStore,
|
||||
) -> anyhow::Result<()> {
|
||||
match action {
|
||||
UserAction::SetPassword => set_user_password(users, sessions).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_user_password(users: &UserStore, sessions: &SessionStore) -> anyhow::Result<()> {
|
||||
let all = users.list().await?;
|
||||
let user = match all.len() {
|
||||
0 => anyhow::bail!("No local user exists yet; complete setup in the web UI first."),
|
||||
1 => &all[0],
|
||||
_ => anyhow::bail!(
|
||||
"Expected exactly one local user (single-user design), found {}. Remove extra users from the database or contact support.",
|
||||
all.len()
|
||||
),
|
||||
};
|
||||
|
||||
let new_password = read_new_password_interactive()?;
|
||||
if new_password.len() < 4 {
|
||||
anyhow::bail!("Password must be at least 4 characters");
|
||||
}
|
||||
|
||||
users.update_password(&user.id, &new_password).await?;
|
||||
let revoked = sessions.delete_by_user_id(&user.id).await?;
|
||||
|
||||
tracing::info!(
|
||||
"Password updated for user '{}' and {} sessions revoked",
|
||||
user.username,
|
||||
revoked
|
||||
);
|
||||
println!(
|
||||
"Password updated for user '{}' (revoked {} sessions).",
|
||||
user.username, revoked
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_new_password_interactive() -> anyhow::Result<String> {
|
||||
let once = |label: &str| -> anyhow::Result<String> {
|
||||
print!("{}", label);
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
let mut line = String::new();
|
||||
std::io::stdin().read_line(&mut line)?;
|
||||
let s = line.trim_end_matches(['\r', '\n']).to_string();
|
||||
if s.is_empty() {
|
||||
anyhow::bail!("Password cannot be empty");
|
||||
}
|
||||
Ok(s)
|
||||
};
|
||||
|
||||
let a = once("New password: ")?;
|
||||
let b = once("Confirm password: ")?;
|
||||
if a != b {
|
||||
anyhow::bail!("Passwords do not match");
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
/// Resolve bind IPs from config, preferring bind_addresses when set.
|
||||
fn resolve_bind_addresses(web: &config::WebConfig) -> anyhow::Result<Vec<IpAddr>> {
|
||||
let raw_addrs = if !web.bind_addresses.is_empty() {
|
||||
@@ -854,12 +939,86 @@ fn generate_self_signed_cert() -> anyhow::Result<rcgen::CertifiedKey<rcgen::KeyP
|
||||
/// Spawn a background task that monitors state change events
|
||||
/// and broadcasts DeviceInfo to all WebSocket clients with debouncing
|
||||
fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
|
||||
use one_kvm::events::SystemEvent;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let mut rx = events.subscribe();
|
||||
enum DeviceInfoTrigger {
|
||||
Event,
|
||||
Lagged { topic: &'static str, count: u64 },
|
||||
}
|
||||
|
||||
const DEVICE_INFO_TOPICS: &[&str] = &[
|
||||
"stream.state_changed",
|
||||
"stream.config_applied",
|
||||
"stream.mode_ready",
|
||||
];
|
||||
const DEBOUNCE_MS: u64 = 100;
|
||||
|
||||
let (trigger_tx, mut trigger_rx) = mpsc::unbounded_channel();
|
||||
|
||||
for topic in DEVICE_INFO_TOPICS {
|
||||
let Some(mut rx) = events.subscribe_topic(topic) else {
|
||||
tracing::warn!(
|
||||
"DeviceInfo broadcaster missing topic subscription: {}",
|
||||
topic
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let trigger_tx = trigger_tx.clone();
|
||||
let topic_name = *topic;
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(_) => {
|
||||
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||
if trigger_tx
|
||||
.send(DeviceInfoTrigger::Lagged {
|
||||
topic: topic_name,
|
||||
count,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let mut dirty_rx = events.subscribe_device_info_dirty();
|
||||
let trigger_tx = trigger_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match dirty_rx.recv().await {
|
||||
Ok(()) => {
|
||||
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||
if trigger_tx
|
||||
.send(DeviceInfoTrigger::Lagged {
|
||||
topic: "device_info_dirty",
|
||||
count,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut last_broadcast = Instant::now() - Duration::from_millis(DEBOUNCE_MS);
|
||||
let mut pending_broadcast = false;
|
||||
@@ -869,32 +1028,24 @@ fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
|
||||
let recv_result = if pending_broadcast {
|
||||
let remaining =
|
||||
DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64);
|
||||
tokio::time::timeout(Duration::from_millis(remaining), rx.recv()).await
|
||||
tokio::time::timeout(Duration::from_millis(remaining), trigger_rx.recv()).await
|
||||
} else {
|
||||
Ok(rx.recv().await)
|
||||
Ok(trigger_rx.recv().await)
|
||||
};
|
||||
|
||||
match recv_result {
|
||||
Ok(Ok(event)) => {
|
||||
let should_broadcast = matches!(
|
||||
event,
|
||||
SystemEvent::StreamStateChanged { .. }
|
||||
| SystemEvent::StreamConfigApplied { .. }
|
||||
| SystemEvent::StreamModeReady { .. }
|
||||
| SystemEvent::HidStateChanged { .. }
|
||||
| SystemEvent::MsdStateChanged { .. }
|
||||
| SystemEvent::AtxStateChanged { .. }
|
||||
| SystemEvent::AudioStateChanged { .. }
|
||||
);
|
||||
if should_broadcast {
|
||||
pending_broadcast = true;
|
||||
}
|
||||
}
|
||||
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(n))) => {
|
||||
tracing::warn!("DeviceInfo broadcaster lagged by {} events", n);
|
||||
Ok(Some(DeviceInfoTrigger::Event)) => {
|
||||
pending_broadcast = true;
|
||||
}
|
||||
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => {
|
||||
Ok(Some(DeviceInfoTrigger::Lagged { topic, count })) => {
|
||||
tracing::warn!(
|
||||
"DeviceInfo broadcaster lagged by {} events on topic {}",
|
||||
count,
|
||||
topic
|
||||
);
|
||||
pending_broadcast = true;
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::info!("Event bus closed, stopping DeviceInfo broadcaster");
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,6 @@ use super::types::{DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MsdMo
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::otg::{MsdFunction, MsdLunConfig, OtgService};
|
||||
|
||||
/// USB Gadget path (system constant)
|
||||
const GADGET_PATH: &str = "/sys/kernel/config/usb_gadget/one-kvm";
|
||||
|
||||
/// MSD Controller
|
||||
pub struct MsdController {
|
||||
/// OTG Service reference
|
||||
@@ -83,9 +80,11 @@ impl MsdController {
|
||||
warn!("Failed to create ventoy directory: {}", e);
|
||||
}
|
||||
|
||||
// 2. Request MSD function from OtgService
|
||||
info!("Requesting MSD function from OtgService");
|
||||
let msd_func = self.otg_service.enable_msd().await?;
|
||||
// 2. Get active MSD function from OtgService
|
||||
info!("Fetching MSD function from OtgService");
|
||||
let msd_func = self.otg_service.msd_function().await.ok_or_else(|| {
|
||||
AppError::Internal("MSD function is not active in OtgService".to_string())
|
||||
})?;
|
||||
|
||||
// 3. Store function handle
|
||||
*self.msd_function.write().await = Some(msd_func);
|
||||
@@ -115,15 +114,6 @@ impl MsdController {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current state as SystemEvent
|
||||
pub async fn current_state_event(&self) -> crate::events::SystemEvent {
|
||||
let state = self.state.read().await;
|
||||
crate::events::SystemEvent::MsdStateChanged {
|
||||
mode: state.mode.clone(),
|
||||
connected: state.connected,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current MSD state
|
||||
pub async fn state(&self) -> MsdState {
|
||||
self.state.read().await.clone()
|
||||
@@ -131,9 +121,7 @@ impl MsdController {
|
||||
|
||||
/// Set event bus for broadcasting state changes
|
||||
pub async fn set_event_bus(&self, events: std::sync::Arc<crate::events::EventBus>) {
|
||||
*self.events.write().await = Some(events.clone());
|
||||
// Also set event bus on the monitor for health notifications
|
||||
self.monitor.set_event_bus(events).await;
|
||||
*self.events.write().await = Some(events);
|
||||
}
|
||||
|
||||
/// Publish an event to the event bus
|
||||
@@ -143,6 +131,12 @@ impl MsdController {
|
||||
}
|
||||
}
|
||||
|
||||
async fn mark_device_info_dirty(&self) {
|
||||
if let Some(ref bus) = *self.events.read().await {
|
||||
bus.mark_device_info_dirty();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if MSD is available
|
||||
pub async fn is_available(&self) -> bool {
|
||||
self.state.read().await.available
|
||||
@@ -195,7 +189,7 @@ impl MsdController {
|
||||
MsdLunConfig::disk(image.path.clone(), read_only)
|
||||
};
|
||||
|
||||
let gadget_path = PathBuf::from(GADGET_PATH);
|
||||
let gadget_path = self.active_gadget_path().await?;
|
||||
if let Some(ref msd) = *self.msd_function.read().await {
|
||||
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
|
||||
let error_msg = format!("Failed to configure LUN: {}", e);
|
||||
@@ -230,20 +224,7 @@ impl MsdController {
|
||||
self.monitor.report_recovered().await;
|
||||
}
|
||||
|
||||
// Publish events
|
||||
self.publish_event(crate::events::SystemEvent::MsdImageMounted {
|
||||
image_id: image.id.clone(),
|
||||
image_name: image.name.clone(),
|
||||
size: image.size,
|
||||
cdrom,
|
||||
})
|
||||
.await;
|
||||
|
||||
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
|
||||
mode: MsdMode::Image,
|
||||
connected: true,
|
||||
})
|
||||
.await;
|
||||
self.mark_device_info_dirty().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -282,7 +263,7 @@ impl MsdController {
|
||||
// Configure LUN as read-write disk
|
||||
let config = MsdLunConfig::disk(self.drive_path.clone(), false);
|
||||
|
||||
let gadget_path = PathBuf::from(GADGET_PATH);
|
||||
let gadget_path = self.active_gadget_path().await?;
|
||||
if let Some(ref msd) = *self.msd_function.read().await {
|
||||
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
|
||||
let error_msg = format!("Failed to configure LUN: {}", e);
|
||||
@@ -314,12 +295,7 @@ impl MsdController {
|
||||
self.monitor.report_recovered().await;
|
||||
}
|
||||
|
||||
// Publish event
|
||||
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
|
||||
mode: MsdMode::Drive,
|
||||
connected: true,
|
||||
})
|
||||
.await;
|
||||
self.mark_device_info_dirty().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -336,7 +312,7 @@ impl MsdController {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let gadget_path = PathBuf::from(GADGET_PATH);
|
||||
let gadget_path = self.active_gadget_path().await?;
|
||||
if let Some(ref msd) = *self.msd_function.read().await {
|
||||
msd.disconnect_lun_async(&gadget_path, 0).await?;
|
||||
}
|
||||
@@ -351,15 +327,7 @@ impl MsdController {
|
||||
drop(state);
|
||||
drop(_op_guard);
|
||||
|
||||
// Publish events
|
||||
self.publish_event(crate::events::SystemEvent::MsdImageUnmounted)
|
||||
.await;
|
||||
|
||||
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
|
||||
mode: MsdMode::None,
|
||||
connected: false,
|
||||
})
|
||||
.await;
|
||||
self.mark_device_info_dirty().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -543,6 +511,13 @@ impl MsdController {
|
||||
downloads.keys().cloned().collect()
|
||||
}
|
||||
|
||||
async fn active_gadget_path(&self) -> Result<PathBuf> {
|
||||
self.otg_service
|
||||
.gadget_path()
|
||||
.await
|
||||
.ok_or_else(|| AppError::Internal("OTG gadget path is not available".to_string()))
|
||||
}
|
||||
|
||||
/// Shutdown the controller
|
||||
pub async fn shutdown(&self) -> Result<()> {
|
||||
info!("Shutting down MSD controller");
|
||||
@@ -552,11 +527,7 @@ impl MsdController {
|
||||
warn!("Error disconnecting during shutdown: {}", e);
|
||||
}
|
||||
|
||||
// 2. Notify OtgService to disable MSD
|
||||
info!("Disabling MSD function in OtgService");
|
||||
self.otg_service.disable_msd().await?;
|
||||
|
||||
// 3. Clear local state
|
||||
// 2. Clear local state
|
||||
*self.msd_function.write().await = None;
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
@@ -3,15 +3,13 @@
|
||||
//! This module provides health monitoring for MSD operations, including:
|
||||
//! - ConfigFS operation error tracking
|
||||
//! - Image mount/unmount error tracking
|
||||
//! - Error notification
|
||||
//! - Error state tracking
|
||||
//! - Log throttling to prevent log flooding
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::events::{EventBus, SystemEvent};
|
||||
use crate::utils::LogThrottler;
|
||||
|
||||
/// MSD health status
|
||||
@@ -46,21 +44,12 @@ impl Default for MsdMonitorConfig {
|
||||
|
||||
/// MSD health monitor
|
||||
///
|
||||
/// Monitors MSD operation health and manages error notifications.
|
||||
/// Publishes WebSocket events when operation status changes.
|
||||
/// Monitors MSD operation health and manages error state.
|
||||
pub struct MsdHealthMonitor {
|
||||
/// Current health status
|
||||
status: RwLock<MsdHealthStatus>,
|
||||
/// Event bus for notifications
|
||||
events: RwLock<Option<Arc<EventBus>>>,
|
||||
/// Log throttler to prevent log flooding
|
||||
throttler: LogThrottler,
|
||||
/// Configuration
|
||||
#[allow(dead_code)]
|
||||
config: MsdMonitorConfig,
|
||||
/// Whether monitoring is active (reserved for future use)
|
||||
#[allow(dead_code)]
|
||||
running: AtomicBool,
|
||||
/// Error count (for tracking)
|
||||
error_count: AtomicU32,
|
||||
/// Last error code (for change detection)
|
||||
@@ -73,10 +62,7 @@ impl MsdHealthMonitor {
|
||||
let throttle_secs = config.log_throttle_secs;
|
||||
Self {
|
||||
status: RwLock::new(MsdHealthStatus::Healthy),
|
||||
events: RwLock::new(None),
|
||||
throttler: LogThrottler::with_secs(throttle_secs),
|
||||
config,
|
||||
running: AtomicBool::new(false),
|
||||
error_count: AtomicU32::new(0),
|
||||
last_error_code: RwLock::new(None),
|
||||
}
|
||||
@@ -87,17 +73,12 @@ impl MsdHealthMonitor {
|
||||
Self::new(MsdMonitorConfig::default())
|
||||
}
|
||||
|
||||
/// Set the event bus for broadcasting state changes
|
||||
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
|
||||
*self.events.write().await = Some(events);
|
||||
}
|
||||
|
||||
/// Report an error from MSD operations
|
||||
///
|
||||
/// This method is called when an MSD operation fails. It:
|
||||
/// 1. Updates the health status
|
||||
/// 2. Logs the error (with throttling)
|
||||
/// 3. Publishes a WebSocket event if the error is new or changed
|
||||
/// 3. Updates in-memory error state
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
@@ -129,22 +110,12 @@ impl MsdHealthMonitor {
|
||||
reason: reason.to_string(),
|
||||
error_code: error_code.to_string(),
|
||||
};
|
||||
|
||||
// Publish event (only if error changed or first occurrence)
|
||||
if error_changed || count == 1 {
|
||||
if let Some(ref events) = *self.events.read().await {
|
||||
events.publish(SystemEvent::MsdError {
|
||||
reason: reason.to_string(),
|
||||
error_code: error_code.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Report that the MSD has recovered from error
|
||||
///
|
||||
/// This method is called when an MSD operation succeeds after errors.
|
||||
/// It resets the error state and publishes a recovery event.
|
||||
/// It resets the error state.
|
||||
pub async fn report_recovered(&self) {
|
||||
let prev_status = self.status.read().await.clone();
|
||||
|
||||
@@ -158,11 +129,6 @@ impl MsdHealthMonitor {
|
||||
self.throttler.clear_all();
|
||||
*self.last_error_code.write().await = None;
|
||||
*self.status.write().await = MsdHealthStatus::Healthy;
|
||||
|
||||
// Publish recovery event
|
||||
if let Some(ref events) = *self.events.read().await {
|
||||
events.publish(SystemEvent::MsdRecovered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
@@ -29,6 +30,42 @@ pub fn is_configfs_available() -> bool {
|
||||
Path::new(CONFIGFS_PATH).exists()
|
||||
}
|
||||
|
||||
/// Ensure libcomposite support is available for USB gadget operations.
|
||||
///
|
||||
/// This is a best-effort runtime fallback for systems where `libcomposite`
|
||||
/// is built as a module and not loaded yet. It does not try to mount configfs;
|
||||
/// mounting remains an explicit system responsibility.
|
||||
pub fn ensure_libcomposite_loaded() -> Result<()> {
|
||||
if is_configfs_available() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !Path::new("/sys/module/libcomposite").exists() {
|
||||
let status = Command::new("modprobe")
|
||||
.arg("libcomposite")
|
||||
.status()
|
||||
.map_err(|e| {
|
||||
AppError::Internal(format!("Failed to run modprobe libcomposite: {}", e))
|
||||
})?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(AppError::Internal(format!(
|
||||
"modprobe libcomposite failed with status {}",
|
||||
status
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if is_configfs_available() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Internal(
|
||||
"libcomposite is not available after modprobe; check configfs mount and kernel support"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Find available UDC (USB Device Controller)
|
||||
pub fn find_udc() -> Option<String> {
|
||||
let udc_path = Path::new("/sys/class/udc");
|
||||
|
||||
@@ -7,14 +7,15 @@ use super::configfs::{
|
||||
create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file,
|
||||
};
|
||||
use super::function::{FunctionMeta, GadgetFunction};
|
||||
use super::report_desc::{CONSUMER_CONTROL, KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE};
|
||||
use super::report_desc::{
|
||||
CONSUMER_CONTROL, KEYBOARD, KEYBOARD_WITH_LED, MOUSE_ABSOLUTE, MOUSE_RELATIVE,
|
||||
};
|
||||
use crate::error::Result;
|
||||
|
||||
/// HID function type
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HidFunctionType {
|
||||
/// Keyboard (no LED feedback)
|
||||
/// Uses 1 endpoint: IN
|
||||
/// Keyboard
|
||||
Keyboard,
|
||||
/// Relative mouse (traditional mouse movement)
|
||||
/// Uses 1 endpoint: IN
|
||||
@@ -28,7 +29,7 @@ pub enum HidFunctionType {
|
||||
}
|
||||
|
||||
impl HidFunctionType {
|
||||
/// Get endpoints required for this function type
|
||||
/// Get the base endpoint cost for this function type.
|
||||
pub fn endpoints(&self) -> u8 {
|
||||
match self {
|
||||
HidFunctionType::Keyboard => 1,
|
||||
@@ -59,7 +60,7 @@ impl HidFunctionType {
|
||||
}
|
||||
|
||||
/// Get report length in bytes
|
||||
pub fn report_length(&self) -> u8 {
|
||||
pub fn report_length(&self, _keyboard_leds: bool) -> u8 {
|
||||
match self {
|
||||
HidFunctionType::Keyboard => 8,
|
||||
HidFunctionType::MouseRelative => 4,
|
||||
@@ -69,9 +70,15 @@ impl HidFunctionType {
|
||||
}
|
||||
|
||||
/// Get report descriptor
|
||||
pub fn report_desc(&self) -> &'static [u8] {
|
||||
pub fn report_desc(&self, keyboard_leds: bool) -> &'static [u8] {
|
||||
match self {
|
||||
HidFunctionType::Keyboard => KEYBOARD,
|
||||
HidFunctionType::Keyboard => {
|
||||
if keyboard_leds {
|
||||
KEYBOARD_WITH_LED
|
||||
} else {
|
||||
KEYBOARD
|
||||
}
|
||||
}
|
||||
HidFunctionType::MouseRelative => MOUSE_RELATIVE,
|
||||
HidFunctionType::MouseAbsolute => MOUSE_ABSOLUTE,
|
||||
HidFunctionType::ConsumerControl => CONSUMER_CONTROL,
|
||||
@@ -98,15 +105,18 @@ pub struct HidFunction {
|
||||
func_type: HidFunctionType,
|
||||
/// Cached function name (avoids repeated allocation)
|
||||
name: String,
|
||||
/// Whether keyboard LED/status feedback is enabled.
|
||||
keyboard_leds: bool,
|
||||
}
|
||||
|
||||
impl HidFunction {
|
||||
/// Create a keyboard function
|
||||
pub fn keyboard(instance: u8) -> Self {
|
||||
pub fn keyboard(instance: u8, keyboard_leds: bool) -> Self {
|
||||
Self {
|
||||
instance,
|
||||
func_type: HidFunctionType::Keyboard,
|
||||
name: format!("hid.usb{}", instance),
|
||||
keyboard_leds,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +126,7 @@ impl HidFunction {
|
||||
instance,
|
||||
func_type: HidFunctionType::MouseRelative,
|
||||
name: format!("hid.usb{}", instance),
|
||||
keyboard_leds: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +136,7 @@ impl HidFunction {
|
||||
instance,
|
||||
func_type: HidFunctionType::MouseAbsolute,
|
||||
name: format!("hid.usb{}", instance),
|
||||
keyboard_leds: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +146,7 @@ impl HidFunction {
|
||||
instance,
|
||||
func_type: HidFunctionType::ConsumerControl,
|
||||
name: format!("hid.usb{}", instance),
|
||||
keyboard_leds: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,11 +194,14 @@ impl GadgetFunction for HidFunction {
|
||||
)?;
|
||||
write_file(
|
||||
&func_path.join("report_length"),
|
||||
&self.func_type.report_length().to_string(),
|
||||
&self.func_type.report_length(self.keyboard_leds).to_string(),
|
||||
)?;
|
||||
|
||||
// Write report descriptor
|
||||
write_bytes(&func_path.join("report_desc"), self.func_type.report_desc())?;
|
||||
write_bytes(
|
||||
&func_path.join("report_desc"),
|
||||
self.func_type.report_desc(self.keyboard_leds),
|
||||
)?;
|
||||
|
||||
debug!(
|
||||
"Created HID function: {} at {}",
|
||||
@@ -232,14 +248,15 @@ mod tests {
|
||||
assert_eq!(HidFunctionType::MouseRelative.endpoints(), 1);
|
||||
assert_eq!(HidFunctionType::MouseAbsolute.endpoints(), 1);
|
||||
|
||||
assert_eq!(HidFunctionType::Keyboard.report_length(), 8);
|
||||
assert_eq!(HidFunctionType::MouseRelative.report_length(), 4);
|
||||
assert_eq!(HidFunctionType::MouseAbsolute.report_length(), 6);
|
||||
assert_eq!(HidFunctionType::Keyboard.report_length(false), 8);
|
||||
assert_eq!(HidFunctionType::Keyboard.report_length(true), 8);
|
||||
assert_eq!(HidFunctionType::MouseRelative.report_length(false), 4);
|
||||
assert_eq!(HidFunctionType::MouseAbsolute.report_length(false), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hid_function_names() {
|
||||
let kb = HidFunction::keyboard(0);
|
||||
let kb = HidFunction::keyboard(0, false);
|
||||
assert_eq!(kb.name(), "hid.usb0");
|
||||
assert_eq!(kb.device_path(), PathBuf::from("/dev/hidg0"));
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::error::{AppError, Result};
|
||||
const REBIND_DELAY_MS: u64 = 300;
|
||||
|
||||
/// USB Gadget device descriptor configuration
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GadgetDescriptor {
|
||||
pub vendor_id: u16,
|
||||
pub product_id: u16,
|
||||
@@ -131,8 +131,8 @@ impl OtgGadgetManager {
|
||||
|
||||
/// Add keyboard function
|
||||
/// Returns the expected device path (e.g., /dev/hidg0)
|
||||
pub fn add_keyboard(&mut self) -> Result<PathBuf> {
|
||||
let func = HidFunction::keyboard(self.hid_instance);
|
||||
pub fn add_keyboard(&mut self, keyboard_leds: bool) -> Result<PathBuf> {
|
||||
let func = HidFunction::keyboard(self.hid_instance, keyboard_leds);
|
||||
let device_path = func.device_path();
|
||||
self.add_function(Box::new(func))?;
|
||||
self.hid_instance += 1;
|
||||
@@ -245,12 +245,8 @@ impl OtgGadgetManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bind gadget to UDC
|
||||
pub fn bind(&mut self) -> Result<()> {
|
||||
let udc = Self::find_udc().ok_or_else(|| {
|
||||
AppError::Internal("No USB Device Controller (UDC) found".to_string())
|
||||
})?;
|
||||
|
||||
/// Bind gadget to a specific UDC
|
||||
pub fn bind(&mut self, udc: &str) -> Result<()> {
|
||||
// Recreate config symlinks before binding to avoid kernel gadget issues after rebind
|
||||
if let Err(e) = self.recreate_config_links() {
|
||||
warn!("Failed to recreate gadget config links before bind: {}", e);
|
||||
@@ -258,7 +254,7 @@ impl OtgGadgetManager {
|
||||
|
||||
info!("Binding gadget to UDC: {}", udc);
|
||||
write_file(&self.gadget_path.join("UDC"), &udc)?;
|
||||
self.bound_udc = Some(udc);
|
||||
self.bound_udc = Some(udc.to_string());
|
||||
std::thread::sleep(std::time::Duration::from_millis(REBIND_DELAY_MS));
|
||||
|
||||
Ok(())
|
||||
@@ -504,7 +500,7 @@ mod tests {
|
||||
let mut manager = OtgGadgetManager::with_config("test", 8);
|
||||
|
||||
// Keyboard uses 1 endpoint
|
||||
let _ = manager.add_keyboard();
|
||||
let _ = manager.add_keyboard(false);
|
||||
assert_eq!(manager.endpoint_allocator.used(), 1);
|
||||
|
||||
// Mouse uses 1 endpoint each
|
||||
|
||||
@@ -32,4 +32,4 @@ pub use hid::{HidFunction, HidFunctionType};
|
||||
pub use manager::{wait_for_hid_devices, OtgGadgetManager};
|
||||
pub use msd::{MsdFunction, MsdLunConfig};
|
||||
pub use report_desc::{KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE};
|
||||
pub use service::{HidDevicePaths, OtgService, OtgServiceState};
|
||||
pub use service::{HidDevicePaths, OtgDesiredState, OtgService, OtgServiceState};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! HID Report Descriptors
|
||||
|
||||
/// Keyboard HID Report Descriptor (no LED output - saves 1 endpoint)
|
||||
/// Keyboard HID Report Descriptor (no LED output)
|
||||
/// Report format (8 bytes input):
|
||||
/// [0] Modifier keys (8 bits)
|
||||
/// [1] Reserved
|
||||
@@ -34,6 +34,53 @@ pub const KEYBOARD: &[u8] = &[
|
||||
0xC0, // End Collection
|
||||
];
|
||||
|
||||
/// Keyboard HID Report Descriptor with LED output support.
|
||||
/// Input report format (8 bytes):
|
||||
/// [0] Modifier keys (8 bits)
|
||||
/// [1] Reserved
|
||||
/// [2-7] Key codes (6 keys)
|
||||
/// Output report format (1 byte):
|
||||
/// [0] Num Lock / Caps Lock / Scroll Lock / Compose / Kana
|
||||
pub const KEYBOARD_WITH_LED: &[u8] = &[
|
||||
0x05, 0x01, // Usage Page (Generic Desktop)
|
||||
0x09, 0x06, // Usage (Keyboard)
|
||||
0xA1, 0x01, // Collection (Application)
|
||||
// Modifier keys input (8 bits)
|
||||
0x05, 0x07, // Usage Page (Key Codes)
|
||||
0x19, 0xE0, // Usage Minimum (224) - Left Control
|
||||
0x29, 0xE7, // Usage Maximum (231) - Right GUI
|
||||
0x15, 0x00, // Logical Minimum (0)
|
||||
0x25, 0x01, // Logical Maximum (1)
|
||||
0x75, 0x01, // Report Size (1)
|
||||
0x95, 0x08, // Report Count (8)
|
||||
0x81, 0x02, // Input (Data, Variable, Absolute) - Modifier byte
|
||||
// Reserved byte
|
||||
0x95, 0x01, // Report Count (1)
|
||||
0x75, 0x08, // Report Size (8)
|
||||
0x81, 0x01, // Input (Constant) - Reserved byte
|
||||
// LED output bits
|
||||
0x95, 0x05, // Report Count (5)
|
||||
0x75, 0x01, // Report Size (1)
|
||||
0x05, 0x08, // Usage Page (LEDs)
|
||||
0x19, 0x01, // Usage Minimum (1)
|
||||
0x29, 0x05, // Usage Maximum (5)
|
||||
0x91, 0x02, // Output (Data, Variable, Absolute)
|
||||
// LED padding
|
||||
0x95, 0x01, // Report Count (1)
|
||||
0x75, 0x03, // Report Size (3)
|
||||
0x91, 0x01, // Output (Constant)
|
||||
// Key array (6 bytes)
|
||||
0x95, 0x06, // Report Count (6)
|
||||
0x75, 0x08, // Report Size (8)
|
||||
0x15, 0x00, // Logical Minimum (0)
|
||||
0x26, 0xFF, 0x00, // Logical Maximum (255)
|
||||
0x05, 0x07, // Usage Page (Key Codes)
|
||||
0x19, 0x00, // Usage Minimum (0)
|
||||
0x2A, 0xFF, 0x00, // Usage Maximum (255)
|
||||
0x81, 0x00, // Input (Data, Array) - Key array (6 keys)
|
||||
0xC0, // End Collection
|
||||
];
|
||||
|
||||
/// Relative Mouse HID Report Descriptor (4 bytes report)
|
||||
/// Report format:
|
||||
/// [0] Buttons (5 bits) + padding (3 bits)
|
||||
@@ -155,6 +202,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_report_descriptor_sizes() {
|
||||
assert!(!KEYBOARD.is_empty());
|
||||
assert!(!KEYBOARD_WITH_LED.is_empty());
|
||||
assert!(!MOUSE_RELATIVE.is_empty());
|
||||
assert!(!MOUSE_ABSOLUTE.is_empty());
|
||||
assert!(!CONSUMER_CONTROL.is_empty());
|
||||
|
||||
@@ -1,39 +1,18 @@
|
||||
//! OTG Service - unified gadget lifecycle management
|
||||
//!
|
||||
//! This module provides centralized management for USB OTG gadget functions.
|
||||
//! It solves the ownership problem where both HID and MSD need access to the
|
||||
//! same USB gadget but should be independently configurable.
|
||||
//!
|
||||
//! Architecture:
|
||||
//! ```text
|
||||
//! ┌─────────────────────────┐
|
||||
//! │ OtgService │
|
||||
//! │ ┌───────────────────┐ │
|
||||
//! │ │ OtgGadgetManager │ │
|
||||
//! │ └───────────────────┘ │
|
||||
//! │ ↓ ↓ │
|
||||
//! │ ┌─────┐ ┌─────┐ │
|
||||
//! │ │ HID │ │ MSD │ │
|
||||
//! │ └─────┘ └─────┘ │
|
||||
//! └─────────────────────────┘
|
||||
//! ↑ ↑
|
||||
//! HidController MsdController
|
||||
//! ```
|
||||
//! It is the single owner of the USB gadget desired state and reconciles
|
||||
//! ConfigFS to match that state.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
|
||||
use super::msd::MsdFunction;
|
||||
use crate::config::{OtgDescriptorConfig, OtgHidFunctions};
|
||||
use crate::config::{HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions};
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
/// Bitflags for requested functions (lock-free)
|
||||
const FLAG_HID: u8 = 0b01;
|
||||
const FLAG_MSD: u8 = 0b10;
|
||||
|
||||
/// HID device paths
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HidDevicePaths {
|
||||
@@ -41,6 +20,8 @@ pub struct HidDevicePaths {
|
||||
pub mouse_relative: Option<PathBuf>,
|
||||
pub mouse_absolute: Option<PathBuf>,
|
||||
pub consumer: Option<PathBuf>,
|
||||
pub udc: Option<String>,
|
||||
pub keyboard_leds_enabled: bool,
|
||||
}
|
||||
|
||||
impl HidDevicePaths {
|
||||
@@ -62,6 +43,59 @@ impl HidDevicePaths {
|
||||
}
|
||||
}
|
||||
|
||||
/// Desired OTG gadget state derived from configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OtgDesiredState {
|
||||
pub udc: Option<String>,
|
||||
pub descriptor: GadgetDescriptor,
|
||||
pub hid_functions: Option<OtgHidFunctions>,
|
||||
pub keyboard_leds: bool,
|
||||
pub msd_enabled: bool,
|
||||
pub max_endpoints: u8,
|
||||
}
|
||||
|
||||
impl Default for OtgDesiredState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
udc: None,
|
||||
descriptor: GadgetDescriptor::default(),
|
||||
hid_functions: None,
|
||||
keyboard_leds: false,
|
||||
msd_enabled: false,
|
||||
max_endpoints: super::endpoint::DEFAULT_MAX_ENDPOINTS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OtgDesiredState {
|
||||
pub fn from_config(hid: &HidConfig, msd: &MsdConfig) -> Result<Self> {
|
||||
let hid_functions = if hid.backend == HidBackend::Otg {
|
||||
let functions = hid.constrained_otg_functions();
|
||||
Some(functions)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
hid.validate_otg_endpoint_budget(msd.enabled)?;
|
||||
|
||||
Ok(Self {
|
||||
udc: hid.resolved_otg_udc(),
|
||||
descriptor: GadgetDescriptor::from(&hid.otg_descriptor),
|
||||
hid_functions,
|
||||
keyboard_leds: hid.effective_otg_keyboard_leds(),
|
||||
msd_enabled: msd.enabled,
|
||||
max_endpoints: hid
|
||||
.resolved_otg_endpoint_limit()
|
||||
.unwrap_or(super::endpoint::DEFAULT_MAX_ENDPOINTS),
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn hid_enabled(&self) -> bool {
|
||||
self.hid_functions.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// OTG Service state
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct OtgServiceState {
|
||||
@@ -71,19 +105,23 @@ pub struct OtgServiceState {
|
||||
pub hid_enabled: bool,
|
||||
/// Whether MSD function is enabled
|
||||
pub msd_enabled: bool,
|
||||
/// Bound UDC name
|
||||
pub configured_udc: Option<String>,
|
||||
/// HID device paths (set after gadget setup)
|
||||
pub hid_paths: Option<HidDevicePaths>,
|
||||
/// HID function selection (set after gadget setup)
|
||||
pub hid_functions: Option<OtgHidFunctions>,
|
||||
/// Whether keyboard LED/status feedback is enabled.
|
||||
pub keyboard_leds_enabled: bool,
|
||||
/// Applied endpoint budget.
|
||||
pub max_endpoints: u8,
|
||||
/// Applied descriptor configuration
|
||||
pub descriptor: Option<GadgetDescriptor>,
|
||||
/// Error message if setup failed
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// OTG Service - unified gadget lifecycle management
|
||||
///
|
||||
/// This service owns the OtgGadgetManager and provides a high-level interface
|
||||
/// for enabling/disabling HID and MSD functions. It ensures proper coordination
|
||||
/// between the two subsystems and handles gadget lifecycle management.
|
||||
pub struct OtgService {
|
||||
/// The underlying gadget manager
|
||||
manager: Mutex<Option<OtgGadgetManager>>,
|
||||
@@ -91,12 +129,8 @@ pub struct OtgService {
|
||||
state: RwLock<OtgServiceState>,
|
||||
/// MSD function handle (for runtime LUN configuration)
|
||||
msd_function: RwLock<Option<MsdFunction>>,
|
||||
/// Requested functions flags (atomic, lock-free read/write)
|
||||
requested_flags: AtomicU8,
|
||||
/// Requested HID function set
|
||||
hid_functions: RwLock<OtgHidFunctions>,
|
||||
/// Current descriptor configuration
|
||||
current_descriptor: RwLock<GadgetDescriptor>,
|
||||
/// Desired OTG state
|
||||
desired: RwLock<OtgDesiredState>,
|
||||
}
|
||||
|
||||
impl OtgService {
|
||||
@@ -106,41 +140,7 @@ impl OtgService {
|
||||
manager: Mutex::new(None),
|
||||
state: RwLock::new(OtgServiceState::default()),
|
||||
msd_function: RwLock::new(None),
|
||||
requested_flags: AtomicU8::new(0),
|
||||
hid_functions: RwLock::new(OtgHidFunctions::default()),
|
||||
current_descriptor: RwLock::new(GadgetDescriptor::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if HID is requested (lock-free)
|
||||
#[inline]
|
||||
fn is_hid_requested(&self) -> bool {
|
||||
self.requested_flags.load(Ordering::Acquire) & FLAG_HID != 0
|
||||
}
|
||||
|
||||
/// Check if MSD is requested (lock-free)
|
||||
#[inline]
|
||||
fn is_msd_requested(&self) -> bool {
|
||||
self.requested_flags.load(Ordering::Acquire) & FLAG_MSD != 0
|
||||
}
|
||||
|
||||
/// Set HID requested flag (lock-free)
|
||||
#[inline]
|
||||
fn set_hid_requested(&self, requested: bool) {
|
||||
if requested {
|
||||
self.requested_flags.fetch_or(FLAG_HID, Ordering::Release);
|
||||
} else {
|
||||
self.requested_flags.fetch_and(!FLAG_HID, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set MSD requested flag (lock-free)
|
||||
#[inline]
|
||||
fn set_msd_requested(&self, requested: bool) {
|
||||
if requested {
|
||||
self.requested_flags.fetch_or(FLAG_MSD, Ordering::Release);
|
||||
} else {
|
||||
self.requested_flags.fetch_and(!FLAG_MSD, Ordering::Release);
|
||||
desired: RwLock::new(OtgDesiredState::default()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,258 +180,119 @@ impl OtgService {
|
||||
self.state.read().await.hid_paths.clone()
|
||||
}
|
||||
|
||||
/// Get current HID function selection
|
||||
pub async fn hid_functions(&self) -> OtgHidFunctions {
|
||||
self.hid_functions.read().await.clone()
|
||||
}
|
||||
|
||||
/// Update HID function selection
|
||||
pub async fn update_hid_functions(&self, functions: OtgHidFunctions) -> Result<()> {
|
||||
if functions.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"OTG HID functions cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
{
|
||||
let mut current = self.hid_functions.write().await;
|
||||
if *current == functions {
|
||||
return Ok(());
|
||||
}
|
||||
*current = functions;
|
||||
}
|
||||
|
||||
// If HID is active, recreate gadget with new function set
|
||||
if self.is_hid_requested() {
|
||||
self.recreate_gadget().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get MSD function handle (for LUN configuration)
|
||||
pub async fn msd_function(&self) -> Option<MsdFunction> {
|
||||
self.msd_function.read().await.clone()
|
||||
}
|
||||
|
||||
/// Enable HID functions
|
||||
///
|
||||
/// This will create the gadget if not already created, add HID functions,
|
||||
/// and bind the gadget to UDC.
|
||||
pub async fn enable_hid(&self) -> Result<HidDevicePaths> {
|
||||
info!("Enabling HID functions via OtgService");
|
||||
|
||||
// Mark HID as requested (lock-free)
|
||||
self.set_hid_requested(true);
|
||||
|
||||
// Check if already enabled and function set unchanged
|
||||
let requested_functions = self.hid_functions.read().await.clone();
|
||||
{
|
||||
let state = self.state.read().await;
|
||||
if state.hid_enabled && state.hid_functions.as_ref() == Some(&requested_functions) {
|
||||
if let Some(ref paths) = state.hid_paths {
|
||||
info!("HID already enabled, returning existing paths");
|
||||
return Ok(paths.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recreate gadget with both HID and MSD if needed
|
||||
self.recreate_gadget().await?;
|
||||
|
||||
// Get HID paths from state
|
||||
let state = self.state.read().await;
|
||||
state
|
||||
.hid_paths
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::Internal("HID paths not set after gadget setup".to_string()))
|
||||
/// Apply desired OTG state derived from the current application config.
|
||||
pub async fn apply_config(&self, hid: &HidConfig, msd: &MsdConfig) -> Result<()> {
|
||||
let desired = OtgDesiredState::from_config(hid, msd)?;
|
||||
self.apply_desired_state(desired).await
|
||||
}
|
||||
|
||||
/// Disable HID functions
|
||||
///
|
||||
/// This will unbind the gadget, remove HID functions, and optionally
|
||||
/// recreate the gadget with only MSD if MSD is still enabled.
|
||||
pub async fn disable_hid(&self) -> Result<()> {
|
||||
info!("Disabling HID functions via OtgService");
|
||||
|
||||
// Mark HID as not requested (lock-free)
|
||||
self.set_hid_requested(false);
|
||||
|
||||
// Check if HID is enabled
|
||||
/// Apply a fully materialized desired OTG state.
|
||||
pub async fn apply_desired_state(&self, desired: OtgDesiredState) -> Result<()> {
|
||||
{
|
||||
let state = self.state.read().await;
|
||||
if !state.hid_enabled {
|
||||
info!("HID already disabled");
|
||||
return Ok(());
|
||||
}
|
||||
let mut current = self.desired.write().await;
|
||||
*current = desired;
|
||||
}
|
||||
|
||||
// Recreate gadget without HID (or destroy if MSD also disabled)
|
||||
self.recreate_gadget().await
|
||||
self.reconcile_gadget().await
|
||||
}
|
||||
|
||||
/// Enable MSD function
|
||||
///
|
||||
/// This will create the gadget if not already created, add MSD function,
|
||||
/// and bind the gadget to UDC.
|
||||
pub async fn enable_msd(&self) -> Result<MsdFunction> {
|
||||
info!("Enabling MSD function via OtgService");
|
||||
|
||||
// Mark MSD as requested (lock-free)
|
||||
self.set_msd_requested(true);
|
||||
|
||||
// Check if already enabled
|
||||
{
|
||||
let state = self.state.read().await;
|
||||
if state.msd_enabled {
|
||||
let msd = self.msd_function.read().await;
|
||||
if let Some(ref func) = *msd {
|
||||
info!("MSD already enabled, returning existing function");
|
||||
return Ok(func.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recreate gadget with both HID and MSD if needed
|
||||
self.recreate_gadget().await?;
|
||||
|
||||
// Get MSD function
|
||||
let msd = self.msd_function.read().await;
|
||||
msd.clone().ok_or_else(|| {
|
||||
AppError::Internal("MSD function not set after gadget setup".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Disable MSD function
|
||||
///
|
||||
/// This will unbind the gadget, remove MSD function, and optionally
|
||||
/// recreate the gadget with only HID if HID is still enabled.
|
||||
pub async fn disable_msd(&self) -> Result<()> {
|
||||
info!("Disabling MSD function via OtgService");
|
||||
|
||||
// Mark MSD as not requested (lock-free)
|
||||
self.set_msd_requested(false);
|
||||
|
||||
// Check if MSD is enabled
|
||||
{
|
||||
let state = self.state.read().await;
|
||||
if !state.msd_enabled {
|
||||
info!("MSD already disabled");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Recreate gadget without MSD (or destroy if HID also disabled)
|
||||
self.recreate_gadget().await
|
||||
}
|
||||
|
||||
/// Recreate the gadget with currently requested functions
|
||||
///
|
||||
/// This is called whenever the set of enabled functions changes.
|
||||
/// It will:
|
||||
/// 1. Check if recreation is needed (function set changed)
|
||||
/// 2. If needed: cleanup existing gadget
|
||||
/// 3. Create new gadget with requested functions
|
||||
/// 4. Setup and bind
|
||||
async fn recreate_gadget(&self) -> Result<()> {
|
||||
// Read requested flags atomically (lock-free)
|
||||
let hid_requested = self.is_hid_requested();
|
||||
let msd_requested = self.is_msd_requested();
|
||||
let hid_functions = if hid_requested {
|
||||
self.hid_functions.read().await.clone()
|
||||
} else {
|
||||
OtgHidFunctions::default()
|
||||
};
|
||||
async fn reconcile_gadget(&self) -> Result<()> {
|
||||
let desired = self.desired.read().await.clone();
|
||||
|
||||
info!(
|
||||
"Recreating gadget with: HID={}, MSD={}",
|
||||
hid_requested, msd_requested
|
||||
"Reconciling OTG gadget: HID={}, MSD={}, UDC={:?}",
|
||||
desired.hid_enabled(),
|
||||
desired.msd_enabled,
|
||||
desired.udc
|
||||
);
|
||||
|
||||
// Check if gadget already matches requested state
|
||||
{
|
||||
let state = self.state.read().await;
|
||||
let functions_match = if hid_requested {
|
||||
state.hid_functions.as_ref() == Some(&hid_functions)
|
||||
} else {
|
||||
state.hid_functions.is_none()
|
||||
};
|
||||
if state.gadget_active
|
||||
&& state.hid_enabled == hid_requested
|
||||
&& state.msd_enabled == msd_requested
|
||||
&& functions_match
|
||||
&& state.hid_enabled == desired.hid_enabled()
|
||||
&& state.msd_enabled == desired.msd_enabled
|
||||
&& state.configured_udc == desired.udc
|
||||
&& state.hid_functions == desired.hid_functions
|
||||
&& state.keyboard_leds_enabled == desired.keyboard_leds
|
||||
&& state.max_endpoints == desired.max_endpoints
|
||||
&& state.descriptor.as_ref() == Some(&desired.descriptor)
|
||||
{
|
||||
info!("Gadget already has requested functions, skipping recreate");
|
||||
info!("OTG gadget already matches desired state");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup existing gadget
|
||||
{
|
||||
let mut manager = self.manager.lock().await;
|
||||
if let Some(mut m) = manager.take() {
|
||||
info!("Cleaning up existing gadget before recreate");
|
||||
info!("Cleaning up existing gadget before OTG reconcile");
|
||||
if let Err(e) = m.cleanup() {
|
||||
warn!("Error cleaning up existing gadget: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear MSD function
|
||||
*self.msd_function.write().await = None;
|
||||
|
||||
// Update state to inactive
|
||||
{
|
||||
let mut state = self.state.write().await;
|
||||
state.gadget_active = false;
|
||||
state.hid_enabled = false;
|
||||
state.msd_enabled = false;
|
||||
state.configured_udc = None;
|
||||
state.hid_paths = None;
|
||||
state.hid_functions = None;
|
||||
state.keyboard_leds_enabled = false;
|
||||
state.max_endpoints = super::endpoint::DEFAULT_MAX_ENDPOINTS;
|
||||
state.descriptor = None;
|
||||
state.error = None;
|
||||
}
|
||||
|
||||
// If nothing requested, we're done
|
||||
if !hid_requested && !msd_requested {
|
||||
info!("No functions requested, gadget destroyed");
|
||||
if !desired.hid_enabled() && !desired.msd_enabled {
|
||||
info!("OTG desired state is empty, gadget removed");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if OTG is available
|
||||
if !Self::is_available() {
|
||||
let error = "OTG not available: ConfigFS not mounted or no UDC found".to_string();
|
||||
let mut state = self.state.write().await;
|
||||
state.error = Some(error.clone());
|
||||
if let Err(e) = super::configfs::ensure_libcomposite_loaded() {
|
||||
warn!("Failed to ensure libcomposite is available: {}", e);
|
||||
}
|
||||
|
||||
if !OtgGadgetManager::is_available() {
|
||||
let error = "OTG not available: ConfigFS not mounted".to_string();
|
||||
self.state.write().await.error = Some(error.clone());
|
||||
return Err(AppError::Internal(error));
|
||||
}
|
||||
|
||||
// Create new gadget manager with current descriptor
|
||||
let descriptor = self.current_descriptor.read().await.clone();
|
||||
let udc = desired.udc.clone().ok_or_else(|| {
|
||||
let error = "OTG not available: no UDC found".to_string();
|
||||
AppError::Internal(error)
|
||||
})?;
|
||||
|
||||
let mut manager = OtgGadgetManager::with_descriptor(
|
||||
super::configfs::DEFAULT_GADGET_NAME,
|
||||
super::endpoint::DEFAULT_MAX_ENDPOINTS,
|
||||
descriptor,
|
||||
desired.max_endpoints,
|
||||
desired.descriptor.clone(),
|
||||
);
|
||||
|
||||
let mut hid_paths = None;
|
||||
|
||||
// Add HID functions if requested
|
||||
if hid_requested {
|
||||
if hid_functions.is_empty() {
|
||||
let error = "HID functions set is empty".to_string();
|
||||
let mut state = self.state.write().await;
|
||||
state.error = Some(error.clone());
|
||||
return Err(AppError::BadRequest(error));
|
||||
}
|
||||
|
||||
let mut paths = HidDevicePaths::default();
|
||||
if let Some(hid_functions) = desired.hid_functions.clone() {
|
||||
let mut paths = HidDevicePaths {
|
||||
udc: Some(udc.clone()),
|
||||
keyboard_leds_enabled: desired.keyboard_leds,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if hid_functions.keyboard {
|
||||
match manager.add_keyboard() {
|
||||
match manager.add_keyboard(desired.keyboard_leds) {
|
||||
Ok(kb) => paths.keyboard = Some(kb),
|
||||
Err(e) => {
|
||||
let error = format!("Failed to add keyboard HID function: {}", e);
|
||||
let mut state = self.state.write().await;
|
||||
state.error = Some(error.clone());
|
||||
self.state.write().await.error = Some(error.clone());
|
||||
return Err(AppError::Internal(error));
|
||||
}
|
||||
}
|
||||
@@ -442,8 +303,7 @@ impl OtgService {
|
||||
Ok(rel) => paths.mouse_relative = Some(rel),
|
||||
Err(e) => {
|
||||
let error = format!("Failed to add relative mouse HID function: {}", e);
|
||||
let mut state = self.state.write().await;
|
||||
state.error = Some(error.clone());
|
||||
self.state.write().await.error = Some(error.clone());
|
||||
return Err(AppError::Internal(error));
|
||||
}
|
||||
}
|
||||
@@ -454,8 +314,7 @@ impl OtgService {
|
||||
Ok(abs) => paths.mouse_absolute = Some(abs),
|
||||
Err(e) => {
|
||||
let error = format!("Failed to add absolute mouse HID function: {}", e);
|
||||
let mut state = self.state.write().await;
|
||||
state.error = Some(error.clone());
|
||||
self.state.write().await.error = Some(error.clone());
|
||||
return Err(AppError::Internal(error));
|
||||
}
|
||||
}
|
||||
@@ -466,8 +325,7 @@ impl OtgService {
|
||||
Ok(consumer) => paths.consumer = Some(consumer),
|
||||
Err(e) => {
|
||||
let error = format!("Failed to add consumer HID function: {}", e);
|
||||
let mut state = self.state.write().await;
|
||||
state.error = Some(error.clone());
|
||||
self.state.write().await.error = Some(error.clone());
|
||||
return Err(AppError::Internal(error));
|
||||
}
|
||||
}
|
||||
@@ -477,8 +335,7 @@ impl OtgService {
|
||||
debug!("HID functions added to gadget");
|
||||
}
|
||||
|
||||
// Add MSD function if requested
|
||||
let msd_func = if msd_requested {
|
||||
let msd_func = if desired.msd_enabled {
|
||||
match manager.add_msd() {
|
||||
Ok(func) => {
|
||||
debug!("MSD function added to gadget");
|
||||
@@ -486,8 +343,7 @@ impl OtgService {
|
||||
}
|
||||
Err(e) => {
|
||||
let error = format!("Failed to add MSD function: {}", e);
|
||||
let mut state = self.state.write().await;
|
||||
state.error = Some(error.clone());
|
||||
self.state.write().await.error = Some(error.clone());
|
||||
return Err(AppError::Internal(error));
|
||||
}
|
||||
}
|
||||
@@ -495,25 +351,19 @@ impl OtgService {
|
||||
None
|
||||
};
|
||||
|
||||
// Setup gadget
|
||||
if let Err(e) = manager.setup() {
|
||||
let error = format!("Failed to setup gadget: {}", e);
|
||||
let mut state = self.state.write().await;
|
||||
state.error = Some(error.clone());
|
||||
self.state.write().await.error = Some(error.clone());
|
||||
return Err(AppError::Internal(error));
|
||||
}
|
||||
|
||||
// Bind to UDC
|
||||
if let Err(e) = manager.bind() {
|
||||
let error = format!("Failed to bind gadget to UDC: {}", e);
|
||||
let mut state = self.state.write().await;
|
||||
state.error = Some(error.clone());
|
||||
// Cleanup on failure
|
||||
if let Err(e) = manager.bind(&udc) {
|
||||
let error = format!("Failed to bind gadget to UDC {}: {}", udc, e);
|
||||
self.state.write().await.error = Some(error.clone());
|
||||
let _ = manager.cleanup();
|
||||
return Err(AppError::Internal(error));
|
||||
}
|
||||
|
||||
// Wait for HID devices to appear
|
||||
if let Some(ref paths) = hid_paths {
|
||||
let device_paths = paths.existing_paths();
|
||||
if !device_paths.is_empty() && !wait_for_hid_devices(&device_paths, 2000).await {
|
||||
@@ -521,103 +371,36 @@ impl OtgService {
|
||||
}
|
||||
}
|
||||
|
||||
// Store manager and update state
|
||||
{
|
||||
*self.manager.lock().await = Some(manager);
|
||||
}
|
||||
|
||||
{
|
||||
*self.msd_function.write().await = msd_func;
|
||||
}
|
||||
*self.manager.lock().await = Some(manager);
|
||||
*self.msd_function.write().await = msd_func;
|
||||
|
||||
{
|
||||
let mut state = self.state.write().await;
|
||||
state.gadget_active = true;
|
||||
state.hid_enabled = hid_requested;
|
||||
state.msd_enabled = msd_requested;
|
||||
state.hid_enabled = desired.hid_enabled();
|
||||
state.msd_enabled = desired.msd_enabled;
|
||||
state.configured_udc = Some(udc);
|
||||
state.hid_paths = hid_paths;
|
||||
state.hid_functions = if hid_requested {
|
||||
Some(hid_functions)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
state.hid_functions = desired.hid_functions;
|
||||
state.keyboard_leds_enabled = desired.keyboard_leds;
|
||||
state.max_endpoints = desired.max_endpoints;
|
||||
state.descriptor = Some(desired.descriptor);
|
||||
state.error = None;
|
||||
}
|
||||
|
||||
info!("Gadget created successfully");
|
||||
info!("OTG gadget reconciled successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the descriptor configuration
|
||||
///
|
||||
/// This updates the stored descriptor and triggers a gadget recreation
|
||||
/// if the gadget is currently active.
|
||||
pub async fn update_descriptor(&self, config: &OtgDescriptorConfig) -> Result<()> {
|
||||
let new_descriptor = GadgetDescriptor {
|
||||
vendor_id: config.vendor_id,
|
||||
product_id: config.product_id,
|
||||
device_version: super::configfs::DEFAULT_USB_BCD_DEVICE,
|
||||
manufacturer: config.manufacturer.clone(),
|
||||
product: config.product.clone(),
|
||||
serial_number: config
|
||||
.serial_number
|
||||
.clone()
|
||||
.unwrap_or_else(|| "0123456789".to_string()),
|
||||
};
|
||||
|
||||
// Update stored descriptor
|
||||
*self.current_descriptor.write().await = new_descriptor;
|
||||
|
||||
// If gadget is active, recreate it with new descriptor
|
||||
let state = self.state.read().await;
|
||||
if state.gadget_active {
|
||||
drop(state); // Release read lock before calling recreate
|
||||
info!("Descriptor changed, recreating gadget");
|
||||
self.force_recreate_gadget().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Force recreate the gadget (used when descriptor changes)
|
||||
async fn force_recreate_gadget(&self) -> Result<()> {
|
||||
// Cleanup existing gadget
|
||||
{
|
||||
let mut manager = self.manager.lock().await;
|
||||
if let Some(mut m) = manager.take() {
|
||||
info!("Cleaning up existing gadget for descriptor change");
|
||||
if let Err(e) = m.cleanup() {
|
||||
warn!("Error cleaning up existing gadget: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear MSD function
|
||||
*self.msd_function.write().await = None;
|
||||
|
||||
// Update state to inactive
|
||||
{
|
||||
let mut state = self.state.write().await;
|
||||
state.gadget_active = false;
|
||||
state.hid_enabled = false;
|
||||
state.msd_enabled = false;
|
||||
state.hid_paths = None;
|
||||
state.hid_functions = None;
|
||||
state.error = None;
|
||||
}
|
||||
|
||||
// Recreate with current requested functions
|
||||
self.recreate_gadget().await
|
||||
}
|
||||
|
||||
/// Shutdown the OTG service and cleanup all resources
|
||||
pub async fn shutdown(&self) -> Result<()> {
|
||||
info!("Shutting down OTG service");
|
||||
|
||||
// Mark nothing as requested (lock-free)
|
||||
self.requested_flags.store(0, Ordering::Release);
|
||||
{
|
||||
let mut desired = self.desired.write().await;
|
||||
*desired = OtgDesiredState::default();
|
||||
}
|
||||
|
||||
// Cleanup gadget
|
||||
let mut manager = self.manager.lock().await;
|
||||
if let Some(mut m) = manager.take() {
|
||||
if let Err(e) = m.cleanup() {
|
||||
@@ -625,7 +408,6 @@ impl OtgService {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear state
|
||||
*self.msd_function.write().await = None;
|
||||
{
|
||||
let mut state = self.state.write().await;
|
||||
@@ -645,11 +427,26 @@ impl Default for OtgService {
|
||||
|
||||
impl Drop for OtgService {
|
||||
fn drop(&mut self) {
|
||||
// Gadget cleanup is handled by OtgGadgetManager's Drop
|
||||
debug!("OtgService dropping");
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&OtgDescriptorConfig> for GadgetDescriptor {
|
||||
fn from(config: &OtgDescriptorConfig) -> Self {
|
||||
Self {
|
||||
vendor_id: config.vendor_id,
|
||||
product_id: config.product_id,
|
||||
device_version: super::configfs::DEFAULT_USB_BCD_DEVICE,
|
||||
manufacturer: config.manufacturer.clone(),
|
||||
product: config.product.clone(),
|
||||
serial_number: config
|
||||
.serial_number
|
||||
.clone()
|
||||
.unwrap_or_else(|| "0123456789".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -657,8 +454,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_service_creation() {
|
||||
let _service = OtgService::new();
|
||||
// Just test that creation doesn't panic
|
||||
let _ = OtgService::is_available(); // Depends on environment
|
||||
let _ = OtgService::is_available();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -498,6 +498,9 @@ async fn stream_video_interleaved(
|
||||
let mut h265_payloader = H265Payloader::new();
|
||||
let mut ctrl_read_buf = [0u8; RTSP_BUF_SIZE];
|
||||
let mut ctrl_buffer = Vec::with_capacity(RTSP_BUF_SIZE);
|
||||
// RTP timestamps must increase; pts_ms is often 0 for many frames (capture→encode jitter),
|
||||
// which yields a flat RTP timestamp and breaks VLC/ffplay.
|
||||
let mut last_rtp_timestamp: u32 = 0;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -529,7 +532,11 @@ async fn stream_video_interleaved(
|
||||
update_parameter_sets(&mut params, &frame);
|
||||
}
|
||||
|
||||
let rtp_timestamp = pts_to_rtp_timestamp(frame.pts_ms);
|
||||
let rtp_timestamp = monotonic_rtp_timestamp(
|
||||
frame.pts_ms,
|
||||
&mut last_rtp_timestamp,
|
||||
frame.duration,
|
||||
);
|
||||
|
||||
let payloads: Vec<Bytes> = match rtsp_codec {
|
||||
RtspCodec::H264 => h264_payloader
|
||||
@@ -1128,6 +1135,25 @@ fn pts_to_rtp_timestamp(pts_ms: i64) -> u32 {
|
||||
((pts_ms as u64 * RTP_CLOCK_RATE as u64) / 1000) as u32
|
||||
}
|
||||
|
||||
/// 90 kHz ticks per frame from nominal duration (at least 1).
|
||||
fn rtp_timestamp_increment(frame_duration: Duration) -> u32 {
|
||||
let inc = (frame_duration.as_secs_f64() * f64::from(RTP_CLOCK_RATE)).round() as u32;
|
||||
inc.max(1)
|
||||
}
|
||||
|
||||
/// Prefer PTS-based RTP time when it advances; otherwise step by `frame_duration` in 90 kHz units.
|
||||
fn monotonic_rtp_timestamp(pts_ms: i64, last: &mut u32, frame_duration: Duration) -> u32 {
|
||||
let from_pts = pts_to_rtp_timestamp(pts_ms);
|
||||
let inc = rtp_timestamp_increment(frame_duration);
|
||||
let ts = if from_pts > *last {
|
||||
from_pts
|
||||
} else {
|
||||
last.wrapping_add(inc)
|
||||
};
|
||||
*last = ts;
|
||||
ts
|
||||
}
|
||||
|
||||
fn generate_session_id() -> String {
|
||||
let mut rng = rand::rng();
|
||||
let value: u64 = rng.random();
|
||||
@@ -1199,6 +1225,28 @@ mod tests {
|
||||
assert_eq!(response.status(), rtsp::StatusCode::MethodNotAllowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotonic_rtp_timestamp_steps_when_pts_stays_zero() {
|
||||
let d = Duration::from_millis(33);
|
||||
let mut last = 0u32;
|
||||
let a = monotonic_rtp_timestamp(0, &mut last, d);
|
||||
let b = monotonic_rtp_timestamp(0, &mut last, d);
|
||||
let c = monotonic_rtp_timestamp(0, &mut last, d);
|
||||
assert!(a > 0);
|
||||
assert!(b > a);
|
||||
assert!(c > b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotonic_rtp_timestamp_uses_pts_when_it_advances() {
|
||||
let d = Duration::from_millis(33);
|
||||
let mut last = 0u32;
|
||||
let a = monotonic_rtp_timestamp(1000, &mut last, d);
|
||||
assert_eq!(a, 90_000);
|
||||
let b = monotonic_rtp_timestamp(2000, &mut last, d);
|
||||
assert_eq!(b, 180_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_sdp_h264_is_parseable_with_expected_video_attributes() {
|
||||
let config = RtspConfig::default();
|
||||
|
||||
@@ -22,7 +22,7 @@ use tokio::sync::{broadcast, mpsc, Mutex};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::audio::AudioController;
|
||||
use crate::hid::{HidController, KeyEventType, KeyboardEvent, KeyboardModifiers};
|
||||
use crate::hid::{CanonicalKey, HidController, KeyEventType, KeyboardEvent, KeyboardModifiers};
|
||||
use crate::video::codec_constraints::{
|
||||
encoder_codec_to_id, encoder_codec_to_video_codec, video_codec_to_encoder_codec,
|
||||
};
|
||||
@@ -652,22 +652,22 @@ impl Connection {
|
||||
// H264 is preferred because it has the best hardware encoder support (RKMPP, VAAPI, etc.)
|
||||
// and most RustDesk clients support H264 hardware decoding
|
||||
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H264)
|
||||
&& registry.is_format_available(VideoEncoderType::H264, false)
|
||||
&& registry.is_codec_available(VideoEncoderType::H264)
|
||||
{
|
||||
return VideoEncoderType::H264;
|
||||
}
|
||||
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H265)
|
||||
&& registry.is_format_available(VideoEncoderType::H265, false)
|
||||
&& registry.is_codec_available(VideoEncoderType::H265)
|
||||
{
|
||||
return VideoEncoderType::H265;
|
||||
}
|
||||
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP8)
|
||||
&& registry.is_format_available(VideoEncoderType::VP8, false)
|
||||
&& registry.is_codec_available(VideoEncoderType::VP8)
|
||||
{
|
||||
return VideoEncoderType::VP8;
|
||||
}
|
||||
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP9)
|
||||
&& registry.is_format_available(VideoEncoderType::VP9, false)
|
||||
&& registry.is_codec_available(VideoEncoderType::VP9)
|
||||
{
|
||||
return VideoEncoderType::VP9;
|
||||
}
|
||||
@@ -784,7 +784,7 @@ impl Connection {
|
||||
}
|
||||
|
||||
let registry = EncoderRegistry::global();
|
||||
if registry.is_format_available(new_codec, false) {
|
||||
if registry.is_codec_available(new_codec) {
|
||||
info!(
|
||||
"Client requested codec switch: {:?} -> {:?}",
|
||||
self.negotiated_codec, new_codec
|
||||
@@ -1121,16 +1121,16 @@ impl Connection {
|
||||
// Check which encoders are available (include software fallback)
|
||||
let h264_available = constraints
|
||||
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H264)
|
||||
&& registry.is_format_available(VideoEncoderType::H264, false);
|
||||
&& registry.is_codec_available(VideoEncoderType::H264);
|
||||
let h265_available = constraints
|
||||
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H265)
|
||||
&& registry.is_format_available(VideoEncoderType::H265, false);
|
||||
&& registry.is_codec_available(VideoEncoderType::H265);
|
||||
let vp8_available = constraints
|
||||
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP8)
|
||||
&& registry.is_format_available(VideoEncoderType::VP8, false);
|
||||
&& registry.is_codec_available(VideoEncoderType::VP8);
|
||||
let vp9_available = constraints
|
||||
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP9)
|
||||
&& registry.is_format_available(VideoEncoderType::VP9, false);
|
||||
&& registry.is_codec_available(VideoEncoderType::VP9);
|
||||
|
||||
info!(
|
||||
"Server encoding capabilities: H264={}, H265={}, VP8={}, VP9={}",
|
||||
@@ -1328,15 +1328,13 @@ impl Connection {
|
||||
);
|
||||
let caps_down = KeyboardEvent {
|
||||
event_type: KeyEventType::Down,
|
||||
key: 0x39, // USB HID CapsLock
|
||||
key: CanonicalKey::CapsLock,
|
||||
modifiers: KeyboardModifiers::default(),
|
||||
is_usb_hid: true,
|
||||
};
|
||||
let caps_up = KeyboardEvent {
|
||||
event_type: KeyEventType::Up,
|
||||
key: 0x39,
|
||||
key: CanonicalKey::CapsLock,
|
||||
modifiers: KeyboardModifiers::default(),
|
||||
is_usb_hid: true,
|
||||
};
|
||||
if let Err(e) = hid.send_keyboard(caps_down).await {
|
||||
warn!("Failed to send CapsLock down: {}", e);
|
||||
@@ -1351,7 +1349,7 @@ impl Connection {
|
||||
if let Some(kb_event) = convert_key_event(ke) {
|
||||
debug!(
|
||||
"Converted to HID: key=0x{:02X}, event_type={:?}, modifiers={:02X}",
|
||||
kb_event.key,
|
||||
kb_event.key.to_hid_usage(),
|
||||
kb_event.event_type,
|
||||
kb_event.modifiers.to_hid_byte()
|
||||
);
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
use super::protocol::hbb::message::key_event as ke_union;
|
||||
use super::protocol::{ControlKey, KeyEvent, MouseEvent};
|
||||
use crate::hid::{
|
||||
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent as OneKvmMouseEvent,
|
||||
MouseEventType,
|
||||
CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton,
|
||||
MouseEvent as OneKvmMouseEvent, MouseEventType,
|
||||
};
|
||||
use protobuf::Enum;
|
||||
|
||||
@@ -217,11 +217,11 @@ pub fn convert_key_event(event: &KeyEvent) -> Option<KeyboardEvent> {
|
||||
// Handle control keys
|
||||
if let Some(ke_union::Union::ControlKey(ck)) = &event.union {
|
||||
if let Some(key) = control_key_to_hid(ck.value()) {
|
||||
let key = CanonicalKey::from_hid_usage(key)?;
|
||||
return Some(KeyboardEvent {
|
||||
event_type,
|
||||
key,
|
||||
modifiers,
|
||||
is_usb_hid: true, // Already converted to USB HID code
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -230,11 +230,11 @@ pub fn convert_key_event(event: &KeyEvent) -> Option<KeyboardEvent> {
|
||||
if let Some(ke_union::Union::Chr(chr)) = &event.union {
|
||||
// chr contains USB HID scancode on Windows, X11 keycode on Linux
|
||||
if let Some(key) = keycode_to_hid(*chr) {
|
||||
let key = CanonicalKey::from_hid_usage(key)?;
|
||||
return Some(KeyboardEvent {
|
||||
event_type,
|
||||
key,
|
||||
modifiers,
|
||||
is_usb_hid: true, // Already converted to USB HID code
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -608,6 +608,6 @@ mod tests {
|
||||
|
||||
let kb_event = result.unwrap();
|
||||
assert_eq!(kb_event.event_type, KeyEventType::Down);
|
||||
assert_eq!(kb_event.key, 0x28); // Return key USB HID code
|
||||
assert_eq!(kb_event.key, CanonicalKey::Enter);
|
||||
}
|
||||
}
|
||||
|
||||
72
src/state.rs
72
src/state.rs
@@ -1,5 +1,5 @@
|
||||
use std::{collections::VecDeque, sync::Arc};
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use tokio::sync::{broadcast, watch, RwLock};
|
||||
|
||||
use crate::atx::AtxController;
|
||||
use crate::audio::AudioController;
|
||||
@@ -7,9 +7,9 @@ use crate::auth::{SessionStore, UserStore};
|
||||
use crate::config::ConfigStore;
|
||||
use crate::events::{
|
||||
AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, MsdDeviceInfo, SystemEvent,
|
||||
VideoDeviceInfo,
|
||||
TtydDeviceInfo, VideoDeviceInfo,
|
||||
};
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::extensions::{ExtensionId, ExtensionManager};
|
||||
use crate::hid::HidController;
|
||||
use crate::msd::MsdController;
|
||||
use crate::otg::OtgService;
|
||||
@@ -58,6 +58,8 @@ pub struct AppState {
|
||||
pub extensions: Arc<ExtensionManager>,
|
||||
/// Event bus for real-time notifications
|
||||
pub events: Arc<EventBus>,
|
||||
/// Latest device info snapshot for WebSocket clients
|
||||
device_info_tx: watch::Sender<Option<SystemEvent>>,
|
||||
/// Online update service
|
||||
pub update: Arc<UpdateService>,
|
||||
/// Shutdown signal sender
|
||||
@@ -89,6 +91,8 @@ impl AppState {
|
||||
shutdown_tx: broadcast::Sender<()>,
|
||||
data_dir: std::path::PathBuf,
|
||||
) -> Arc<Self> {
|
||||
let (device_info_tx, _device_info_rx) = watch::channel(None);
|
||||
|
||||
Arc::new(Self {
|
||||
config,
|
||||
sessions,
|
||||
@@ -103,6 +107,7 @@ impl AppState {
|
||||
rtsp: Arc::new(RwLock::new(rtsp)),
|
||||
extensions,
|
||||
events,
|
||||
device_info_tx,
|
||||
update,
|
||||
shutdown_tx,
|
||||
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
|
||||
@@ -120,6 +125,11 @@ impl AppState {
|
||||
self.shutdown_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Subscribe to the latest device info snapshot.
|
||||
pub fn subscribe_device_info(&self) -> watch::Receiver<Option<SystemEvent>> {
|
||||
self.device_info_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Record revoked session IDs (bounded queue)
|
||||
pub async fn remember_revoked_sessions(&self, session_ids: Vec<String>) {
|
||||
if session_ids.is_empty() {
|
||||
@@ -147,12 +157,13 @@ impl AppState {
|
||||
/// Uses tokio::join! to collect all device info in parallel for better performance.
|
||||
pub async fn get_device_info(&self) -> SystemEvent {
|
||||
// Collect all device info in parallel
|
||||
let (video, hid, msd, atx, audio) = tokio::join!(
|
||||
let (video, hid, msd, atx, audio, ttyd) = tokio::join!(
|
||||
self.collect_video_info(),
|
||||
self.collect_hid_info(),
|
||||
self.collect_msd_info(),
|
||||
self.collect_atx_info(),
|
||||
self.collect_audio_info(),
|
||||
self.collect_ttyd_info(),
|
||||
);
|
||||
|
||||
SystemEvent::DeviceInfo {
|
||||
@@ -161,13 +172,14 @@ impl AppState {
|
||||
msd,
|
||||
atx,
|
||||
audio,
|
||||
ttyd,
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish DeviceInfo event to all connected WebSocket clients
|
||||
pub async fn publish_device_info(&self) {
|
||||
let device_info = self.get_device_info().await;
|
||||
self.events.publish(device_info);
|
||||
let _ = self.device_info_tx.send(Some(device_info));
|
||||
}
|
||||
|
||||
/// Collect video device information
|
||||
@@ -178,32 +190,19 @@ impl AppState {
|
||||
|
||||
/// Collect HID device information
|
||||
async fn collect_hid_info(&self) -> HidDeviceInfo {
|
||||
let info = self.hid.info().await;
|
||||
let backend_type = self.hid.backend_type().await;
|
||||
let state = self.hid.snapshot().await;
|
||||
|
||||
match info {
|
||||
Some(hid_info) => HidDeviceInfo {
|
||||
available: true,
|
||||
backend: hid_info.name.to_string(),
|
||||
initialized: hid_info.initialized,
|
||||
supports_absolute_mouse: hid_info.supports_absolute_mouse,
|
||||
device: match backend_type {
|
||||
crate::hid::HidBackendType::Ch9329 { ref port, .. } => Some(port.clone()),
|
||||
_ => None,
|
||||
},
|
||||
error: None,
|
||||
},
|
||||
None => HidDeviceInfo {
|
||||
available: false,
|
||||
backend: backend_type.name_str().to_string(),
|
||||
initialized: false,
|
||||
supports_absolute_mouse: false,
|
||||
device: match backend_type {
|
||||
crate::hid::HidBackendType::Ch9329 { ref port, .. } => Some(port.clone()),
|
||||
_ => None,
|
||||
},
|
||||
error: Some("HID backend not available".to_string()),
|
||||
},
|
||||
HidDeviceInfo {
|
||||
available: state.available,
|
||||
backend: state.backend,
|
||||
initialized: state.initialized,
|
||||
online: state.online,
|
||||
supports_absolute_mouse: state.supports_absolute_mouse,
|
||||
keyboard_leds_enabled: state.keyboard_leds_enabled,
|
||||
led_state: state.led_state,
|
||||
device: state.device,
|
||||
error: state.error,
|
||||
error_code: state.error_code,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +212,7 @@ impl AppState {
|
||||
let msd = msd_guard.as_ref()?;
|
||||
|
||||
let state = msd.state().await;
|
||||
let error = msd.monitor().error_message().await;
|
||||
Some(MsdDeviceInfo {
|
||||
available: state.available,
|
||||
mode: match state.mode {
|
||||
@@ -223,7 +223,7 @@ impl AppState {
|
||||
.to_string(),
|
||||
connected: state.connected,
|
||||
image_id: state.current_image.map(|img| img.id),
|
||||
error: None,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -266,4 +266,14 @@ impl AppState {
|
||||
error: status.error,
|
||||
})
|
||||
}
|
||||
|
||||
/// Collect ttyd status information
|
||||
async fn collect_ttyd_info(&self) -> TtydDeviceInfo {
|
||||
let status = self.extensions.status(ExtensionId::Ttyd).await;
|
||||
|
||||
TtydDeviceInfo {
|
||||
available: self.extensions.check_available(ExtensionId::Ttyd),
|
||||
running: status.is_running(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,64 @@
|
||||
//! Manages video frame distribution and per-client statistics.
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use bytes::Bytes;
|
||||
use parking_lot::Mutex as ParkingMutex;
|
||||
use parking_lot::RwLock as ParkingRwLock;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::video::encoder::traits::{Encoder, EncoderConfig};
|
||||
use crate::video::encoder::JpegEncoder;
|
||||
use crate::video::format::PixelFormat;
|
||||
use crate::video::format::{PixelFormat, Resolution};
|
||||
use crate::video::VideoFrame;
|
||||
|
||||
/// Cached "no signal" placeholder JPEG (640×360 dark-gray image).
|
||||
/// Generated once on first use and reused for all NoSignal frames.
|
||||
static NO_SIGNAL_JPEG: OnceLock<Bytes> = OnceLock::new();
|
||||
|
||||
/// Generate a minimal "no signal" JPEG (640×360, dark gray background).
|
||||
/// Uses turbojpeg directly to produce a valid JPEG without additional deps.
|
||||
fn generate_no_signal_jpeg() -> Bytes {
|
||||
const W: usize = 640;
|
||||
const H: usize = 360;
|
||||
|
||||
let y_size = W * H;
|
||||
let uv_size = y_size / 4;
|
||||
let mut i420 = vec![0u8; y_size + uv_size * 2];
|
||||
|
||||
// Y = 32 (dark gray, above the 16 black floor so it is clearly visible)
|
||||
i420[..y_size].fill(32);
|
||||
// U and V = 128 (neutral chroma → no colour tint)
|
||||
i420[y_size..].fill(128);
|
||||
|
||||
match turbojpeg::Compressor::new() {
|
||||
Ok(mut compressor) => {
|
||||
let _ = compressor.set_quality(70);
|
||||
let yuv = turbojpeg::YuvImage {
|
||||
pixels: i420.as_slice(),
|
||||
width: W,
|
||||
height: H,
|
||||
align: 1,
|
||||
subsamp: turbojpeg::Subsamp::Sub2x2,
|
||||
};
|
||||
match compressor.compress_yuv_to_vec(yuv) {
|
||||
Ok(jpeg) => Bytes::from(jpeg),
|
||||
Err(_) => Bytes::new(),
|
||||
}
|
||||
}
|
||||
Err(_) => Bytes::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a reference to the cached no-signal JPEG bytes.
|
||||
fn no_signal_jpeg() -> &'static Bytes {
|
||||
NO_SIGNAL_JPEG.get_or_init(generate_no_signal_jpeg)
|
||||
}
|
||||
|
||||
/// Client ID type (UUID string)
|
||||
pub type ClientId = String;
|
||||
|
||||
@@ -318,6 +362,12 @@ impl MjpegStreamHandler {
|
||||
PixelFormat::Nv12 => encoder
|
||||
.encode_nv12(frame.data(), sequence)
|
||||
.map_err(|e| format!("NV12 encode failed: {}", e))?,
|
||||
PixelFormat::Nv16 => encoder
|
||||
.encode_nv16(frame.data(), sequence)
|
||||
.map_err(|e| format!("NV16 encode failed: {}", e))?,
|
||||
PixelFormat::Nv24 => encoder
|
||||
.encode_nv24(frame.data(), sequence)
|
||||
.map_err(|e| format!("NV24 encode failed: {}", e))?,
|
||||
PixelFormat::Rgb24 => encoder
|
||||
.encode_rgb(frame.data(), sequence)
|
||||
.map_err(|e| format!("RGB encode failed: {}", e))?,
|
||||
@@ -348,6 +398,34 @@ impl MjpegStreamHandler {
|
||||
let _ = self.frame_notify.send(());
|
||||
}
|
||||
|
||||
/// Push a "no signal" placeholder JPEG to all connected MJPEG clients.
|
||||
///
|
||||
/// Unlike `set_offline()`, this keeps the stream marked as **online** so
|
||||
/// that HTTP clients remain connected and see the placeholder image instead
|
||||
/// of a black/empty screen. Call this whenever the capture thread enters
|
||||
/// the `NoSignal` state.
|
||||
pub fn push_no_signal_placeholder(&self) {
|
||||
let jpeg = no_signal_jpeg();
|
||||
if jpeg.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let frame = VideoFrame::new(
|
||||
jpeg.clone(),
|
||||
Resolution::new(640, 360),
|
||||
PixelFormat::Mjpeg,
|
||||
0,
|
||||
self.sequence.fetch_add(1, Ordering::Relaxed),
|
||||
);
|
||||
|
||||
// Store as current frame so late-joining clients get it immediately.
|
||||
self.current_frame.store(Arc::new(Some(frame)));
|
||||
// Ensure stream is marked online so the HTTP handler keeps iterating.
|
||||
self.online.store(true, Ordering::SeqCst);
|
||||
// Wake up waiting HTTP clients.
|
||||
let _ = self.frame_notify.send(());
|
||||
}
|
||||
|
||||
/// Set stream online (called when streaming starts)
|
||||
pub fn set_online(&self) {
|
||||
self.online.store(true, Ordering::SeqCst);
|
||||
|
||||
@@ -1,700 +0,0 @@
|
||||
//! MJPEG Streamer - High-level MJPEG/HTTP streaming manager
|
||||
//!
|
||||
//! This module provides a unified interface for MJPEG streaming mode,
|
||||
//! integrating video capture, MJPEG distribution, and WebSocket HID.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! MjpegStreamer
|
||||
//! |
|
||||
//! +-- VideoCapturer (V4L2 video capture)
|
||||
//! +-- MjpegStreamHandler (HTTP multipart video)
|
||||
//! +-- WsHidHandler (WebSocket HID)
|
||||
//! ```
|
||||
//!
|
||||
//! Note: Audio WebSocket is handled separately by audio_ws.rs (/api/ws/audio)
|
||||
|
||||
use crate::utils::LogThrottler;
|
||||
use crate::video::v4l2r_capture::V4l2rCaptureStream;
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::audio::AudioController;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::events::{EventBus, SystemEvent};
|
||||
use crate::hid::HidController;
|
||||
use crate::video::capture::{CaptureConfig, VideoCapturer};
|
||||
use crate::video::device::{enumerate_devices, find_best_device, VideoDeviceInfo};
|
||||
use crate::video::format::{PixelFormat, Resolution};
|
||||
use crate::video::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
|
||||
|
||||
use super::mjpeg::MjpegStreamHandler;
|
||||
use super::ws_hid::WsHidHandler;
|
||||
|
||||
/// Minimum valid frame size for capture
|
||||
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
|
||||
/// Validate JPEG header every N frames to reduce overhead
|
||||
const JPEG_VALIDATE_INTERVAL: u64 = 30;
|
||||
|
||||
/// MJPEG streamer configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MjpegStreamerConfig {
|
||||
/// Device path (None = auto-detect)
|
||||
pub device_path: Option<PathBuf>,
|
||||
/// Desired resolution
|
||||
pub resolution: Resolution,
|
||||
/// Desired format
|
||||
pub format: PixelFormat,
|
||||
/// Desired FPS
|
||||
pub fps: u32,
|
||||
/// JPEG quality (1-100)
|
||||
pub jpeg_quality: u8,
|
||||
}
|
||||
|
||||
impl Default for MjpegStreamerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
device_path: None,
|
||||
resolution: Resolution::HD1080,
|
||||
format: PixelFormat::Mjpeg,
|
||||
fps: 30,
|
||||
jpeg_quality: 80,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MJPEG streamer state
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MjpegStreamerState {
|
||||
/// Not initialized
|
||||
Uninitialized,
|
||||
/// Ready but not streaming
|
||||
Ready,
|
||||
/// Actively streaming
|
||||
Streaming,
|
||||
/// No video signal
|
||||
NoSignal,
|
||||
/// Error occurred
|
||||
Error,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MjpegStreamerState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
MjpegStreamerState::Uninitialized => write!(f, "uninitialized"),
|
||||
MjpegStreamerState::Ready => write!(f, "ready"),
|
||||
MjpegStreamerState::Streaming => write!(f, "streaming"),
|
||||
MjpegStreamerState::NoSignal => write!(f, "no_signal"),
|
||||
MjpegStreamerState::Error => write!(f, "error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MJPEG streamer statistics
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MjpegStreamerStats {
|
||||
/// Current state
|
||||
pub state: String,
|
||||
/// Current device path
|
||||
pub device: Option<String>,
|
||||
/// Video resolution
|
||||
pub resolution: Option<(u32, u32)>,
|
||||
/// Video format
|
||||
pub format: Option<String>,
|
||||
/// Current FPS
|
||||
pub fps: u32,
|
||||
/// MJPEG client count
|
||||
pub mjpeg_clients: u64,
|
||||
/// WebSocket HID client count
|
||||
pub ws_hid_clients: usize,
|
||||
}
|
||||
|
||||
/// MJPEG Streamer
|
||||
///
|
||||
/// High-level manager for MJPEG/HTTP streaming mode.
|
||||
/// Integrates video capture, MJPEG distribution, and WebSocket HID.
|
||||
pub struct MjpegStreamer {
|
||||
// === Video ===
|
||||
config: RwLock<MjpegStreamerConfig>,
|
||||
capturer: RwLock<Option<Arc<VideoCapturer>>>,
|
||||
mjpeg_handler: Arc<MjpegStreamHandler>,
|
||||
current_device: RwLock<Option<VideoDeviceInfo>>,
|
||||
state: RwLock<MjpegStreamerState>,
|
||||
|
||||
// === Audio (controller reference only, WS handled by audio_ws.rs) ===
|
||||
audio_controller: RwLock<Option<Arc<AudioController>>>,
|
||||
audio_enabled: AtomicBool,
|
||||
|
||||
// === HID ===
|
||||
ws_hid_handler: Arc<WsHidHandler>,
|
||||
hid_controller: RwLock<Option<Arc<HidController>>>,
|
||||
|
||||
// === Control ===
|
||||
start_lock: tokio::sync::Mutex<()>,
|
||||
direct_stop: AtomicBool,
|
||||
direct_active: AtomicBool,
|
||||
direct_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
events: RwLock<Option<Arc<EventBus>>>,
|
||||
config_changing: AtomicBool,
|
||||
}
|
||||
|
||||
impl MjpegStreamer {
|
||||
/// Create a new MJPEG streamer
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
config: RwLock::new(MjpegStreamerConfig::default()),
|
||||
capturer: RwLock::new(None),
|
||||
mjpeg_handler: Arc::new(MjpegStreamHandler::new()),
|
||||
current_device: RwLock::new(None),
|
||||
state: RwLock::new(MjpegStreamerState::Uninitialized),
|
||||
audio_controller: RwLock::new(None),
|
||||
audio_enabled: AtomicBool::new(false),
|
||||
ws_hid_handler: WsHidHandler::new(),
|
||||
hid_controller: RwLock::new(None),
|
||||
start_lock: tokio::sync::Mutex::new(()),
|
||||
direct_stop: AtomicBool::new(false),
|
||||
direct_active: AtomicBool::new(false),
|
||||
direct_handle: Mutex::new(None),
|
||||
events: RwLock::new(None),
|
||||
config_changing: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create with specific config
|
||||
pub fn with_config(config: MjpegStreamerConfig) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
config: RwLock::new(config),
|
||||
capturer: RwLock::new(None),
|
||||
mjpeg_handler: Arc::new(MjpegStreamHandler::new()),
|
||||
current_device: RwLock::new(None),
|
||||
state: RwLock::new(MjpegStreamerState::Uninitialized),
|
||||
audio_controller: RwLock::new(None),
|
||||
audio_enabled: AtomicBool::new(false),
|
||||
ws_hid_handler: WsHidHandler::new(),
|
||||
hid_controller: RwLock::new(None),
|
||||
start_lock: tokio::sync::Mutex::new(()),
|
||||
direct_stop: AtomicBool::new(false),
|
||||
direct_active: AtomicBool::new(false),
|
||||
direct_handle: Mutex::new(None),
|
||||
events: RwLock::new(None),
|
||||
config_changing: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Configuration and Setup
|
||||
// ========================================================================
|
||||
|
||||
/// Set event bus for broadcasting state changes
|
||||
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
|
||||
*self.events.write().await = Some(events);
|
||||
}
|
||||
|
||||
/// Set audio controller (for reference, WebSocket handled by audio_ws.rs)
|
||||
pub async fn set_audio_controller(&self, audio: Arc<AudioController>) {
|
||||
*self.audio_controller.write().await = Some(audio);
|
||||
info!("MjpegStreamer: Audio controller set");
|
||||
}
|
||||
|
||||
/// Set HID controller
|
||||
pub async fn set_hid_controller(&self, hid: Arc<HidController>) {
|
||||
*self.hid_controller.write().await = Some(hid.clone());
|
||||
self.ws_hid_handler.set_hid_controller(hid);
|
||||
info!("MjpegStreamer: HID controller set");
|
||||
}
|
||||
|
||||
/// Enable or disable audio
|
||||
pub fn set_audio_enabled(&self, enabled: bool) {
|
||||
self.audio_enabled.store(enabled, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Check if audio is enabled
|
||||
pub fn is_audio_enabled(&self) -> bool {
|
||||
self.audio_enabled.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// State and Status
|
||||
// ========================================================================
|
||||
|
||||
/// Get current state
|
||||
pub async fn state(&self) -> MjpegStreamerState {
|
||||
*self.state.read().await
|
||||
}
|
||||
|
||||
/// Check if config is currently being changed
|
||||
pub fn is_config_changing(&self) -> bool {
|
||||
self.config_changing.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Get current device info
|
||||
pub async fn current_device(&self) -> Option<VideoDeviceInfo> {
|
||||
self.current_device.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get statistics
|
||||
pub async fn stats(&self) -> MjpegStreamerStats {
|
||||
let state = *self.state.read().await;
|
||||
let device = self.current_device.read().await;
|
||||
let config = self.config.read().await;
|
||||
|
||||
let (resolution, format) = {
|
||||
if self.direct_active.load(Ordering::Relaxed) {
|
||||
(
|
||||
Some((config.resolution.width, config.resolution.height)),
|
||||
Some(config.format.to_string()),
|
||||
)
|
||||
} else if let Some(ref cap) = *self.capturer.read().await {
|
||||
let _ = cap;
|
||||
(
|
||||
Some((config.resolution.width, config.resolution.height)),
|
||||
Some(config.format.to_string()),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
|
||||
MjpegStreamerStats {
|
||||
state: state.to_string(),
|
||||
device: device.as_ref().map(|d| d.path.display().to_string()),
|
||||
resolution,
|
||||
format,
|
||||
fps: config.fps,
|
||||
mjpeg_clients: self.mjpeg_handler.client_count(),
|
||||
ws_hid_clients: self.ws_hid_handler.client_count(),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Handler Access
|
||||
// ========================================================================
|
||||
|
||||
/// Get MJPEG handler for HTTP streaming
|
||||
pub fn mjpeg_handler(&self) -> Arc<MjpegStreamHandler> {
|
||||
self.mjpeg_handler.clone()
|
||||
}
|
||||
|
||||
/// Get WebSocket HID handler
|
||||
pub fn ws_hid_handler(&self) -> Arc<WsHidHandler> {
|
||||
self.ws_hid_handler.clone()
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Initialization
|
||||
// ========================================================================
|
||||
|
||||
/// Initialize with auto-detected device
|
||||
pub async fn init_auto(self: &Arc<Self>) -> Result<()> {
|
||||
let best = find_best_device()?;
|
||||
self.init_with_device(best).await
|
||||
}
|
||||
|
||||
/// Initialize with specific device
|
||||
pub async fn init_with_device(self: &Arc<Self>, device: VideoDeviceInfo) -> Result<()> {
|
||||
info!(
|
||||
"MjpegStreamer: Initializing with device: {}",
|
||||
device.path.display()
|
||||
);
|
||||
|
||||
let config = self.config.read().await.clone();
|
||||
self.mjpeg_handler.set_jpeg_quality(config.jpeg_quality);
|
||||
|
||||
// Create capture config
|
||||
let capture_config = CaptureConfig {
|
||||
device_path: device.path.clone(),
|
||||
resolution: config.resolution,
|
||||
format: config.format,
|
||||
fps: config.fps,
|
||||
buffer_count: 4,
|
||||
timeout: std::time::Duration::from_secs(5),
|
||||
jpeg_quality: config.jpeg_quality,
|
||||
};
|
||||
|
||||
// Create capturer
|
||||
let capturer = Arc::new(VideoCapturer::new(capture_config));
|
||||
|
||||
// Store device and capturer
|
||||
*self.current_device.write().await = Some(device);
|
||||
*self.capturer.write().await = Some(capturer);
|
||||
*self.state.write().await = MjpegStreamerState::Ready;
|
||||
|
||||
self.publish_state_change().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Streaming Control
|
||||
// ========================================================================
|
||||
|
||||
/// Start streaming
|
||||
pub async fn start(self: &Arc<Self>) -> Result<()> {
|
||||
let _lock = self.start_lock.lock().await;
|
||||
|
||||
if self.config_changing.load(Ordering::SeqCst) {
|
||||
return Err(AppError::VideoError(
|
||||
"Config change in progress".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let state = *self.state.read().await;
|
||||
if state == MjpegStreamerState::Streaming {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let device = self
|
||||
.current_device
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::VideoError("Not initialized".to_string()))?;
|
||||
|
||||
let config = self.config.read().await.clone();
|
||||
|
||||
self.direct_stop.store(false, Ordering::SeqCst);
|
||||
self.direct_active.store(true, Ordering::SeqCst);
|
||||
|
||||
let streamer = self.clone();
|
||||
let handle = tokio::task::spawn_blocking(move || {
|
||||
streamer.run_direct_capture(device.path, config);
|
||||
});
|
||||
*self.direct_handle.lock().await = Some(handle);
|
||||
|
||||
// Note: Audio WebSocket is handled separately by audio_ws.rs (/api/ws/audio)
|
||||
|
||||
*self.state.write().await = MjpegStreamerState::Streaming;
|
||||
self.mjpeg_handler.set_online();
|
||||
|
||||
self.publish_state_change().await;
|
||||
info!("MjpegStreamer: Streaming started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop streaming
|
||||
pub async fn stop(&self) -> Result<()> {
|
||||
let state = *self.state.read().await;
|
||||
if state != MjpegStreamerState::Streaming {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.direct_stop.store(true, Ordering::SeqCst);
|
||||
|
||||
if let Some(handle) = self.direct_handle.lock().await.take() {
|
||||
let _ = handle.await;
|
||||
}
|
||||
self.direct_active.store(false, Ordering::SeqCst);
|
||||
|
||||
// Stop capturer (legacy path)
|
||||
if let Some(ref cap) = *self.capturer.read().await {
|
||||
let _ = cap.stop().await;
|
||||
}
|
||||
|
||||
// Set offline
|
||||
self.mjpeg_handler.set_offline();
|
||||
*self.state.write().await = MjpegStreamerState::Ready;
|
||||
|
||||
self.publish_state_change().await;
|
||||
info!("MjpegStreamer: Streaming stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if streaming
|
||||
pub async fn is_streaming(&self) -> bool {
|
||||
*self.state.read().await == MjpegStreamerState::Streaming
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Configuration Updates
|
||||
// ========================================================================
|
||||
|
||||
/// Apply video configuration
|
||||
///
|
||||
/// This stops the current stream, reconfigures the capturer, and restarts.
|
||||
pub async fn apply_config(self: &Arc<Self>, config: MjpegStreamerConfig) -> Result<()> {
|
||||
info!("MjpegStreamer: Applying config: {:?}", config);
|
||||
|
||||
self.config_changing.store(true, Ordering::SeqCst);
|
||||
|
||||
// Stop current stream
|
||||
self.stop().await?;
|
||||
|
||||
// Disconnect all MJPEG clients
|
||||
self.mjpeg_handler.disconnect_all_clients();
|
||||
|
||||
// Release capturer
|
||||
*self.capturer.write().await = None;
|
||||
|
||||
// Update config
|
||||
*self.config.write().await = config.clone();
|
||||
self.mjpeg_handler.set_jpeg_quality(config.jpeg_quality);
|
||||
|
||||
// Re-initialize if device path is set
|
||||
if let Some(ref path) = config.device_path {
|
||||
let devices = enumerate_devices()?;
|
||||
let device = devices
|
||||
.into_iter()
|
||||
.find(|d| d.path == *path)
|
||||
.ok_or_else(|| {
|
||||
AppError::VideoError(format!("Device not found: {}", path.display()))
|
||||
})?;
|
||||
|
||||
self.init_with_device(device).await?;
|
||||
}
|
||||
|
||||
self.config_changing.store(false, Ordering::SeqCst);
|
||||
self.publish_state_change().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Internal
|
||||
// ========================================================================
|
||||
|
||||
/// Publish state change event
|
||||
async fn publish_state_change(&self) {
|
||||
if let Some(ref events) = *self.events.read().await {
|
||||
let state = *self.state.read().await;
|
||||
let device = self.current_device.read().await;
|
||||
|
||||
events.publish(SystemEvent::StreamStateChanged {
|
||||
state: state.to_string(),
|
||||
device: device.as_ref().map(|d| d.path.display().to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct capture loop for MJPEG mode (single loop, no broadcast)
|
||||
fn run_direct_capture(self: Arc<Self>, device_path: PathBuf, config: MjpegStreamerConfig) {
|
||||
const MAX_RETRIES: u32 = 5;
|
||||
const RETRY_DELAY_MS: u64 = 200;
|
||||
|
||||
let handle = tokio::runtime::Handle::current();
|
||||
let mut last_state = MjpegStreamerState::Streaming;
|
||||
|
||||
let mut set_state = |new_state: MjpegStreamerState| {
|
||||
if new_state != last_state {
|
||||
handle.block_on(async {
|
||||
*self.state.write().await = new_state;
|
||||
self.publish_state_change().await;
|
||||
});
|
||||
last_state = new_state;
|
||||
}
|
||||
};
|
||||
|
||||
let mut stream_opt: Option<V4l2rCaptureStream> = None;
|
||||
let mut last_error: Option<String> = None;
|
||||
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
if self.direct_stop.load(Ordering::Relaxed) {
|
||||
self.direct_active.store(false, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
|
||||
match V4l2rCaptureStream::open(
|
||||
&device_path,
|
||||
config.resolution,
|
||||
config.format,
|
||||
config.fps,
|
||||
4,
|
||||
Duration::from_secs(2),
|
||||
) {
|
||||
Ok(stream) => {
|
||||
stream_opt = Some(stream);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("busy") || err_str.contains("resource") {
|
||||
warn!(
|
||||
"Device busy on attempt {}/{}, retrying in {}ms...",
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
RETRY_DELAY_MS
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
|
||||
last_error = Some(err_str);
|
||||
continue;
|
||||
}
|
||||
last_error = Some(err_str);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut stream = match stream_opt {
|
||||
Some(stream) => stream,
|
||||
None => {
|
||||
error!(
|
||||
"Failed to open device {:?}: {}",
|
||||
device_path,
|
||||
last_error.unwrap_or_else(|| "unknown error".to_string())
|
||||
);
|
||||
set_state(MjpegStreamerState::Error);
|
||||
self.mjpeg_handler.set_offline();
|
||||
self.direct_active.store(false, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let resolution = stream.resolution();
|
||||
let pixel_format = stream.format();
|
||||
let stride = stream.stride();
|
||||
|
||||
info!(
|
||||
"Capture format: {}x{} {:?} stride={}",
|
||||
resolution.width, resolution.height, pixel_format, stride
|
||||
);
|
||||
|
||||
let buffer_pool = Arc::new(FrameBufferPool::new(8));
|
||||
let mut signal_present = true;
|
||||
let mut validate_counter: u64 = 0;
|
||||
let capture_error_throttler = LogThrottler::with_secs(5);
|
||||
let mut suppressed_capture_errors: HashMap<String, u64> = HashMap::new();
|
||||
|
||||
let classify_capture_error = |err: &std::io::Error| -> String {
|
||||
let message = err.to_string();
|
||||
if message.contains("dqbuf failed") && message.contains("EINVAL") {
|
||||
"capture_dqbuf_einval".to_string()
|
||||
} else if message.contains("dqbuf failed") {
|
||||
"capture_dqbuf".to_string()
|
||||
} else {
|
||||
format!("capture_{:?}", err.kind())
|
||||
}
|
||||
};
|
||||
|
||||
while !self.direct_stop.load(Ordering::Relaxed) {
|
||||
let mut owned = buffer_pool.take(MIN_CAPTURE_FRAME_SIZE);
|
||||
let meta = match stream.next_into(&mut owned) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) => {
|
||||
if e.kind() == io::ErrorKind::TimedOut {
|
||||
if signal_present {
|
||||
signal_present = false;
|
||||
set_state(MjpegStreamerState::NoSignal);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_device_lost = match e.raw_os_error() {
|
||||
Some(6) => true, // ENXIO
|
||||
Some(19) => true, // ENODEV
|
||||
Some(5) => true, // EIO
|
||||
Some(32) => true, // EPIPE
|
||||
Some(108) => true, // ESHUTDOWN
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_device_lost {
|
||||
error!("Video device lost: {} - {}", device_path.display(), e);
|
||||
set_state(MjpegStreamerState::Error);
|
||||
self.mjpeg_handler.set_offline();
|
||||
self.direct_active.store(false, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
|
||||
let key = classify_capture_error(&e);
|
||||
if capture_error_throttler.should_log(&key) {
|
||||
let suppressed = suppressed_capture_errors.remove(&key).unwrap_or(0);
|
||||
if suppressed > 0 {
|
||||
error!("Capture error: {} (suppressed {} repeats)", e, suppressed);
|
||||
} else {
|
||||
error!("Capture error: {}", e);
|
||||
}
|
||||
} else {
|
||||
let counter = suppressed_capture_errors.entry(key).or_insert(0);
|
||||
*counter = counter.saturating_add(1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let frame_size = meta.bytes_used;
|
||||
if frame_size < MIN_CAPTURE_FRAME_SIZE {
|
||||
continue;
|
||||
}
|
||||
|
||||
validate_counter = validate_counter.wrapping_add(1);
|
||||
if pixel_format.is_compressed()
|
||||
&& validate_counter.is_multiple_of(JPEG_VALIDATE_INTERVAL)
|
||||
&& !VideoFrame::is_valid_jpeg_bytes(&owned[..frame_size])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
owned.truncate(frame_size);
|
||||
let frame = VideoFrame::from_pooled(
|
||||
Arc::new(FrameBuffer::new(owned, Some(buffer_pool.clone()))),
|
||||
resolution,
|
||||
pixel_format,
|
||||
stride,
|
||||
meta.sequence,
|
||||
);
|
||||
|
||||
if !signal_present {
|
||||
signal_present = true;
|
||||
set_state(MjpegStreamerState::Streaming);
|
||||
}
|
||||
|
||||
self.mjpeg_handler.update_frame(frame);
|
||||
}
|
||||
|
||||
self.direct_active.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MjpegStreamer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
config: RwLock::new(MjpegStreamerConfig::default()),
|
||||
capturer: RwLock::new(None),
|
||||
mjpeg_handler: Arc::new(MjpegStreamHandler::new()),
|
||||
current_device: RwLock::new(None),
|
||||
state: RwLock::new(MjpegStreamerState::Uninitialized),
|
||||
audio_controller: RwLock::new(None),
|
||||
audio_enabled: AtomicBool::new(false),
|
||||
ws_hid_handler: WsHidHandler::new(),
|
||||
hid_controller: RwLock::new(None),
|
||||
start_lock: tokio::sync::Mutex::new(()),
|
||||
direct_stop: AtomicBool::new(false),
|
||||
direct_active: AtomicBool::new(false),
|
||||
direct_handle: Mutex::new(None),
|
||||
events: RwLock::new(None),
|
||||
config_changing: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mjpeg_streamer_creation() {
|
||||
let streamer = MjpegStreamer::new();
|
||||
assert!(!streamer.is_config_changing());
|
||||
assert!(!streamer.is_audio_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mjpeg_streamer_config_default() {
|
||||
let config = MjpegStreamerConfig::default();
|
||||
assert_eq!(config.resolution, Resolution::HD1080);
|
||||
assert_eq!(config.format, PixelFormat::Mjpeg);
|
||||
assert_eq!(config.fps, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mjpeg_streamer_state_display() {
|
||||
assert_eq!(MjpegStreamerState::Streaming.to_string(), "streaming");
|
||||
assert_eq!(MjpegStreamerState::Ready.to_string(), "ready");
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,11 @@
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `MjpegStreamer` - High-level MJPEG streaming manager
|
||||
//! - `MjpegStreamHandler` - HTTP multipart MJPEG video streaming
|
||||
//! - `WsHidHandler` - WebSocket HID input handler
|
||||
|
||||
pub mod mjpeg;
|
||||
pub mod mjpeg_streamer;
|
||||
pub mod ws_hid;
|
||||
|
||||
pub use mjpeg::{ClientGuard, MjpegStreamHandler};
|
||||
pub use mjpeg_streamer::{
|
||||
MjpegStreamer, MjpegStreamerConfig, MjpegStreamerState, MjpegStreamerStats,
|
||||
};
|
||||
pub use ws_hid::WsHidHandler;
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
//! V4L2 video capture implementation
|
||||
//!
|
||||
//! Provides async video capture using memory-mapped buffers.
|
||||
|
||||
use bytes::Bytes;
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::{watch, Mutex};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::format::{PixelFormat, Resolution};
|
||||
use super::frame::VideoFrame;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::utils::LogThrottler;
|
||||
use crate::video::v4l2r_capture::V4l2rCaptureStream;
|
||||
|
||||
/// Default number of capture buffers (reduced from 4 to 2 for lower latency)
|
||||
const DEFAULT_BUFFER_COUNT: u32 = 2;
|
||||
/// Default capture timeout in seconds
|
||||
const DEFAULT_TIMEOUT: u64 = 2;
|
||||
/// Minimum valid frame size (bytes)
|
||||
const MIN_FRAME_SIZE: usize = 128;
|
||||
|
||||
/// Video capturer configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CaptureConfig {
|
||||
/// Device path
|
||||
pub device_path: PathBuf,
|
||||
/// Desired resolution
|
||||
pub resolution: Resolution,
|
||||
/// Desired pixel format
|
||||
pub format: PixelFormat,
|
||||
/// Desired frame rate (0 = max available)
|
||||
pub fps: u32,
|
||||
/// Number of capture buffers
|
||||
pub buffer_count: u32,
|
||||
/// Capture timeout
|
||||
pub timeout: Duration,
|
||||
/// JPEG quality (1-100, for MJPEG sources with hardware quality control)
|
||||
pub jpeg_quality: u8,
|
||||
}
|
||||
|
||||
impl Default for CaptureConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
device_path: PathBuf::from("/dev/video0"),
|
||||
resolution: Resolution::HD1080,
|
||||
format: PixelFormat::Mjpeg,
|
||||
fps: 30,
|
||||
buffer_count: DEFAULT_BUFFER_COUNT,
|
||||
timeout: Duration::from_secs(DEFAULT_TIMEOUT),
|
||||
jpeg_quality: 80,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CaptureConfig {
|
||||
/// Create config for a specific device
|
||||
pub fn for_device(path: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
device_path: path.as_ref().to_path_buf(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Set resolution
|
||||
pub fn with_resolution(mut self, width: u32, height: u32) -> Self {
|
||||
self.resolution = Resolution::new(width, height);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set format
|
||||
pub fn with_format(mut self, format: PixelFormat) -> Self {
|
||||
self.format = format;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set frame rate
|
||||
pub fn with_fps(mut self, fps: u32) -> Self {
|
||||
self.fps = fps;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture statistics
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CaptureStats {
|
||||
/// Current FPS (calculated)
|
||||
pub current_fps: f32,
|
||||
}
|
||||
|
||||
/// Video capturer state
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CaptureState {
|
||||
/// Not started
|
||||
Stopped,
|
||||
/// Starting (initializing device)
|
||||
Starting,
|
||||
/// Running and capturing
|
||||
Running,
|
||||
/// No signal from source
|
||||
NoSignal,
|
||||
/// Error occurred
|
||||
Error,
|
||||
/// Device was lost (disconnected)
|
||||
DeviceLost,
|
||||
}
|
||||
|
||||
/// Async video capturer
|
||||
pub struct VideoCapturer {
|
||||
config: CaptureConfig,
|
||||
state: Arc<watch::Sender<CaptureState>>,
|
||||
state_rx: watch::Receiver<CaptureState>,
|
||||
stats: Arc<Mutex<CaptureStats>>,
|
||||
stop_flag: Arc<AtomicBool>,
|
||||
capture_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
/// Last error that occurred (device path, reason)
|
||||
last_error: Arc<parking_lot::RwLock<Option<(String, String)>>>,
|
||||
}
|
||||
|
||||
impl VideoCapturer {
|
||||
/// Create a new video capturer
|
||||
pub fn new(config: CaptureConfig) -> Self {
|
||||
let (state_tx, state_rx) = watch::channel(CaptureState::Stopped);
|
||||
|
||||
Self {
|
||||
config,
|
||||
state: Arc::new(state_tx),
|
||||
state_rx,
|
||||
stats: Arc::new(Mutex::new(CaptureStats::default())),
|
||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
||||
capture_handle: Mutex::new(None),
|
||||
last_error: Arc::new(parking_lot::RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current capture state
|
||||
pub fn state(&self) -> CaptureState {
|
||||
*self.state_rx.borrow()
|
||||
}
|
||||
|
||||
/// Subscribe to state changes
|
||||
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
|
||||
self.state_rx.clone()
|
||||
}
|
||||
|
||||
/// Get last error (device path, reason)
|
||||
pub fn last_error(&self) -> Option<(String, String)> {
|
||||
self.last_error.read().clone()
|
||||
}
|
||||
|
||||
/// Clear last error
|
||||
pub fn clear_error(&self) {
|
||||
*self.last_error.write() = None;
|
||||
}
|
||||
|
||||
/// Get capture statistics
|
||||
pub async fn stats(&self) -> CaptureStats {
|
||||
self.stats.lock().await.clone()
|
||||
}
|
||||
|
||||
/// Get config
|
||||
pub fn config(&self) -> &CaptureConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Start capturing in background
|
||||
pub async fn start(&self) -> Result<()> {
|
||||
let current_state = self.state();
|
||||
// Already running or starting - nothing to do
|
||||
if current_state == CaptureState::Running || current_state == CaptureState::Starting {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(
|
||||
"Starting capture on {:?} at {}x{} {}",
|
||||
self.config.device_path,
|
||||
self.config.resolution.width,
|
||||
self.config.resolution.height,
|
||||
self.config.format
|
||||
);
|
||||
|
||||
// Set Starting state immediately to prevent concurrent start attempts
|
||||
let _ = self.state.send(CaptureState::Starting);
|
||||
|
||||
// Clear any previous error
|
||||
*self.last_error.write() = None;
|
||||
|
||||
self.stop_flag.store(false, Ordering::SeqCst);
|
||||
|
||||
let config = self.config.clone();
|
||||
let state = self.state.clone();
|
||||
let stats = self.stats.clone();
|
||||
let stop_flag = self.stop_flag.clone();
|
||||
let last_error = self.last_error.clone();
|
||||
|
||||
let handle = tokio::task::spawn_blocking(move || {
|
||||
capture_loop(config, state, stats, stop_flag, last_error);
|
||||
});
|
||||
|
||||
*self.capture_handle.lock().await = Some(handle);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop capturing
|
||||
pub async fn stop(&self) -> Result<()> {
|
||||
info!("Stopping capture");
|
||||
self.stop_flag.store(true, Ordering::SeqCst);
|
||||
|
||||
if let Some(handle) = self.capture_handle.lock().await.take() {
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
let _ = self.state.send(CaptureState::Stopped);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if capturing
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.state() == CaptureState::Running
|
||||
}
|
||||
|
||||
/// Get the latest frame (if any receivers would get it)
|
||||
pub fn latest_frame(&self) -> Option<VideoFrame> {
|
||||
// This is a bit tricky with broadcast - we'd need to track internally
|
||||
// For now, callers should use subscribe()
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Main capture loop (runs in blocking thread)
|
||||
fn capture_loop(
|
||||
config: CaptureConfig,
|
||||
state: Arc<watch::Sender<CaptureState>>,
|
||||
stats: Arc<Mutex<CaptureStats>>,
|
||||
stop_flag: Arc<AtomicBool>,
|
||||
error_holder: Arc<parking_lot::RwLock<Option<(String, String)>>>,
|
||||
) {
|
||||
let result = run_capture(&config, &state, &stats, &stop_flag);
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
let _ = state.send(CaptureState::Stopped);
|
||||
}
|
||||
Err(AppError::VideoDeviceLost { device, reason }) => {
|
||||
error!("Video device lost: {} - {}", device, reason);
|
||||
// Store the error for recovery handling
|
||||
*error_holder.write() = Some((device, reason));
|
||||
let _ = state.send(CaptureState::DeviceLost);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Capture error: {}", e);
|
||||
let _ = state.send(CaptureState::Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_capture(
|
||||
config: &CaptureConfig,
|
||||
state: &watch::Sender<CaptureState>,
|
||||
stats: &Arc<Mutex<CaptureStats>>,
|
||||
stop_flag: &AtomicBool,
|
||||
) -> Result<()> {
|
||||
// Retry logic for device busy errors
|
||||
const MAX_RETRIES: u32 = 5;
|
||||
const RETRY_DELAY_MS: u64 = 200;
|
||||
|
||||
let mut last_error = None;
|
||||
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
if stop_flag.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stream = match V4l2rCaptureStream::open(
|
||||
&config.device_path,
|
||||
config.resolution,
|
||||
config.format,
|
||||
config.fps,
|
||||
config.buffer_count,
|
||||
config.timeout,
|
||||
) {
|
||||
Ok(stream) => stream,
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("busy") || err_str.contains("resource") {
|
||||
warn!(
|
||||
"Device busy on attempt {}/{}, retrying in {}ms...",
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
RETRY_DELAY_MS
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(RETRY_DELAY_MS));
|
||||
last_error = Some(AppError::VideoError(format!(
|
||||
"Failed to open device {:?}: {}",
|
||||
config.device_path, e
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
return Err(AppError::VideoError(format!(
|
||||
"Failed to open device {:?}: {}",
|
||||
config.device_path, e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
return run_capture_inner(config, state, stats, stop_flag, stream);
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
AppError::VideoError("Failed to open device after all retries".to_string())
|
||||
}))
|
||||
}
|
||||
|
||||
/// Inner capture function after device is successfully opened
|
||||
fn run_capture_inner(
|
||||
config: &CaptureConfig,
|
||||
state: &watch::Sender<CaptureState>,
|
||||
stats: &Arc<Mutex<CaptureStats>>,
|
||||
stop_flag: &AtomicBool,
|
||||
mut stream: V4l2rCaptureStream,
|
||||
) -> Result<()> {
|
||||
let resolution = stream.resolution();
|
||||
let pixel_format = stream.format();
|
||||
let stride = stream.stride();
|
||||
info!(
|
||||
"Capture format: {}x{} {:?} stride={}",
|
||||
resolution.width, resolution.height, pixel_format, stride
|
||||
);
|
||||
|
||||
let _ = state.send(CaptureState::Running);
|
||||
info!("Capture started");
|
||||
|
||||
// FPS calculation variables
|
||||
let mut fps_frame_count = 0u64;
|
||||
let mut fps_window_start = Instant::now();
|
||||
let fps_window_duration = Duration::from_secs(1);
|
||||
let mut scratch = Vec::new();
|
||||
let capture_error_throttler = LogThrottler::with_secs(5);
|
||||
let mut suppressed_capture_errors: HashMap<String, u64> = HashMap::new();
|
||||
|
||||
let classify_capture_error = |err: &std::io::Error| -> String {
|
||||
let message = err.to_string();
|
||||
if message.contains("dqbuf failed") && message.contains("EINVAL") {
|
||||
"capture_dqbuf_einval".to_string()
|
||||
} else if message.contains("dqbuf failed") {
|
||||
"capture_dqbuf".to_string()
|
||||
} else {
|
||||
format!("capture_{:?}", err.kind())
|
||||
}
|
||||
};
|
||||
|
||||
// Main capture loop
|
||||
while !stop_flag.load(Ordering::Relaxed) {
|
||||
let meta = match stream.next_into(&mut scratch) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) => {
|
||||
if e.kind() == io::ErrorKind::TimedOut {
|
||||
warn!("Capture timeout - no signal?");
|
||||
let _ = state.send(CaptureState::NoSignal);
|
||||
|
||||
// Wait a bit before retrying
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for device loss errors
|
||||
let is_device_lost = match e.raw_os_error() {
|
||||
Some(6) => true, // ENXIO - No such device or address
|
||||
Some(19) => true, // ENODEV - No such device
|
||||
Some(5) => true, // EIO - I/O error (device removed)
|
||||
Some(32) => true, // EPIPE - Broken pipe
|
||||
Some(108) => true, // ESHUTDOWN - Transport endpoint shutdown
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_device_lost {
|
||||
let device_path = config.device_path.display().to_string();
|
||||
error!("Video device lost: {} - {}", device_path, e);
|
||||
return Err(AppError::VideoDeviceLost {
|
||||
device: device_path,
|
||||
reason: e.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let key = classify_capture_error(&e);
|
||||
if capture_error_throttler.should_log(&key) {
|
||||
let suppressed = suppressed_capture_errors.remove(&key).unwrap_or(0);
|
||||
if suppressed > 0 {
|
||||
error!("Capture error: {} (suppressed {} repeats)", e, suppressed);
|
||||
} else {
|
||||
error!("Capture error: {}", e);
|
||||
}
|
||||
} else {
|
||||
let counter = suppressed_capture_errors.entry(key).or_insert(0);
|
||||
*counter = counter.saturating_add(1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Use actual bytes used, not buffer size
|
||||
let frame_size = meta.bytes_used;
|
||||
|
||||
// Validate frame
|
||||
if frame_size < MIN_FRAME_SIZE {
|
||||
debug!(
|
||||
"Dropping small frame: {} bytes (bytesused={})",
|
||||
frame_size, meta.bytes_used
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update state if was no signal
|
||||
if *state.borrow() == CaptureState::NoSignal {
|
||||
let _ = state.send(CaptureState::Running);
|
||||
}
|
||||
|
||||
// Update FPS calculation
|
||||
if let Ok(mut s) = stats.try_lock() {
|
||||
fps_frame_count += 1;
|
||||
let elapsed = fps_window_start.elapsed();
|
||||
|
||||
if elapsed >= fps_window_duration {
|
||||
// Calculate FPS from the completed window
|
||||
s.current_fps = (fps_frame_count as f32 / elapsed.as_secs_f32()).max(0.0);
|
||||
// Reset for next window
|
||||
fps_frame_count = 0;
|
||||
fps_window_start = Instant::now();
|
||||
} else if elapsed.as_millis() > 100 && fps_frame_count > 0 {
|
||||
// Provide partial estimate if we have at least 100ms of data
|
||||
s.current_fps = (fps_frame_count as f32 / elapsed.as_secs_f32()).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
if *state.borrow() == CaptureState::NoSignal {
|
||||
let _ = state.send(CaptureState::Running);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Capture stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate JPEG frame data
|
||||
#[cfg(test)]
|
||||
fn is_valid_jpeg(data: &[u8]) -> bool {
|
||||
if data.len() < 125 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check start marker (0xFFD8)
|
||||
let start_marker = ((data[0] as u16) << 8) | data[1] as u16;
|
||||
if start_marker != 0xFFD8 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check end marker
|
||||
let end = data.len();
|
||||
let end_marker = ((data[end - 2] as u16) << 8) | data[end - 1] as u16;
|
||||
|
||||
// Valid end markers: 0xFFD9, 0xD900, 0x0000 (padded)
|
||||
matches!(end_marker, 0xFFD9 | 0xD900 | 0x0000)
|
||||
}
|
||||
|
||||
/// Frame grabber for one-shot capture
|
||||
pub struct FrameGrabber {
|
||||
device_path: PathBuf,
|
||||
}
|
||||
|
||||
impl FrameGrabber {
|
||||
/// Create a new frame grabber
|
||||
pub fn new(device_path: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
device_path: device_path.as_ref().to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture a single frame
|
||||
pub async fn grab(&self, resolution: Resolution, format: PixelFormat) -> Result<VideoFrame> {
|
||||
let device_path = self.device_path.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || grab_single_frame(&device_path, resolution, format))
|
||||
.await
|
||||
.map_err(|e| AppError::VideoError(format!("Grab task failed: {}", e)))?
|
||||
}
|
||||
}
|
||||
|
||||
fn grab_single_frame(
|
||||
device_path: &Path,
|
||||
resolution: Resolution,
|
||||
format: PixelFormat,
|
||||
) -> Result<VideoFrame> {
|
||||
let mut stream = V4l2rCaptureStream::open(
|
||||
device_path,
|
||||
resolution,
|
||||
format,
|
||||
0,
|
||||
2,
|
||||
Duration::from_secs(DEFAULT_TIMEOUT),
|
||||
)?;
|
||||
let actual_resolution = stream.resolution();
|
||||
let actual_format = stream.format();
|
||||
let actual_stride = stream.stride();
|
||||
let mut scratch = Vec::new();
|
||||
|
||||
// Try to get a valid frame (skip first few which might be bad)
|
||||
for attempt in 0..5 {
|
||||
match stream.next_into(&mut scratch) {
|
||||
Ok(meta) => {
|
||||
if meta.bytes_used >= MIN_FRAME_SIZE {
|
||||
return Ok(VideoFrame::new(
|
||||
Bytes::copy_from_slice(&scratch[..meta.bytes_used]),
|
||||
actual_resolution,
|
||||
actual_format,
|
||||
actual_stride,
|
||||
0,
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) if attempt == 4 => {
|
||||
return Err(AppError::VideoError(format!("Failed to grab frame: {}", e)));
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
Err(AppError::VideoError(
|
||||
"Failed to capture valid frame".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_jpeg() {
|
||||
// Valid JPEG header and footer
|
||||
let mut data = vec![0xFF, 0xD8]; // SOI
|
||||
data.extend(vec![0u8; 200]); // Content
|
||||
data.extend([0xFF, 0xD9]); // EOI
|
||||
|
||||
assert!(is_valid_jpeg(&data));
|
||||
|
||||
// Invalid - too small
|
||||
assert!(!is_valid_jpeg(&[0xFF, 0xD8, 0xFF, 0xD9]));
|
||||
|
||||
// Invalid - wrong header
|
||||
let mut bad = vec![0x00, 0x00];
|
||||
bad.extend(vec![0u8; 200]);
|
||||
assert!(!is_valid_jpeg(&bad));
|
||||
}
|
||||
}
|
||||
@@ -190,87 +190,70 @@ pub struct PixelConverter {
|
||||
resolution: Resolution,
|
||||
/// Output buffer (reused across conversions)
|
||||
output_buffer: Yuv420pBuffer,
|
||||
/// Scratch buffer for split chroma planes when converting semiplanar 4:2:2 / 4:4:4 input.
|
||||
uv_split_buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl PixelConverter {
|
||||
/// Create a new converter for YUYV → YUV420P
|
||||
pub fn yuyv_to_yuv420p(resolution: Resolution) -> Self {
|
||||
fn new(src_format: PixelFormat, dst_format: PixelFormat, resolution: Resolution) -> Self {
|
||||
let max_uv_plane_size = (resolution.width * resolution.height) as usize;
|
||||
Self {
|
||||
src_format: PixelFormat::Yuyv,
|
||||
dst_format: PixelFormat::Yuv420,
|
||||
src_format,
|
||||
dst_format,
|
||||
resolution,
|
||||
output_buffer: Yuv420pBuffer::new(resolution),
|
||||
uv_split_buffer: vec![0u8; max_uv_plane_size * 2],
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new converter for YUYV → YUV420P
|
||||
pub fn yuyv_to_yuv420p(resolution: Resolution) -> Self {
|
||||
Self::new(PixelFormat::Yuyv, PixelFormat::Yuv420, resolution)
|
||||
}
|
||||
|
||||
/// Create a new converter for UYVY → YUV420P
|
||||
pub fn uyvy_to_yuv420p(resolution: Resolution) -> Self {
|
||||
Self {
|
||||
src_format: PixelFormat::Uyvy,
|
||||
dst_format: PixelFormat::Yuv420,
|
||||
resolution,
|
||||
output_buffer: Yuv420pBuffer::new(resolution),
|
||||
}
|
||||
Self::new(PixelFormat::Uyvy, PixelFormat::Yuv420, resolution)
|
||||
}
|
||||
|
||||
/// Create a new converter for YVYU → YUV420P
|
||||
pub fn yvyu_to_yuv420p(resolution: Resolution) -> Self {
|
||||
Self {
|
||||
src_format: PixelFormat::Yvyu,
|
||||
dst_format: PixelFormat::Yuv420,
|
||||
resolution,
|
||||
output_buffer: Yuv420pBuffer::new(resolution),
|
||||
}
|
||||
Self::new(PixelFormat::Yvyu, PixelFormat::Yuv420, resolution)
|
||||
}
|
||||
|
||||
/// Create a new converter for NV12 → YUV420P
|
||||
pub fn nv12_to_yuv420p(resolution: Resolution) -> Self {
|
||||
Self {
|
||||
src_format: PixelFormat::Nv12,
|
||||
dst_format: PixelFormat::Yuv420,
|
||||
resolution,
|
||||
output_buffer: Yuv420pBuffer::new(resolution),
|
||||
}
|
||||
Self::new(PixelFormat::Nv12, PixelFormat::Yuv420, resolution)
|
||||
}
|
||||
|
||||
/// Create a new converter for NV21 → YUV420P
|
||||
pub fn nv21_to_yuv420p(resolution: Resolution) -> Self {
|
||||
Self {
|
||||
src_format: PixelFormat::Nv21,
|
||||
dst_format: PixelFormat::Yuv420,
|
||||
resolution,
|
||||
output_buffer: Yuv420pBuffer::new(resolution),
|
||||
}
|
||||
Self::new(PixelFormat::Nv21, PixelFormat::Yuv420, resolution)
|
||||
}
|
||||
|
||||
/// Create a new converter for NV16 → YUV420P
|
||||
pub fn nv16_to_yuv420p(resolution: Resolution) -> Self {
|
||||
Self::new(PixelFormat::Nv16, PixelFormat::Yuv420, resolution)
|
||||
}
|
||||
|
||||
/// Create a new converter for NV24 → YUV420P
|
||||
pub fn nv24_to_yuv420p(resolution: Resolution) -> Self {
|
||||
Self::new(PixelFormat::Nv24, PixelFormat::Yuv420, resolution)
|
||||
}
|
||||
|
||||
/// Create a new converter for YVU420 → YUV420P (swap U and V planes)
|
||||
pub fn yvu420_to_yuv420p(resolution: Resolution) -> Self {
|
||||
Self {
|
||||
src_format: PixelFormat::Yvu420,
|
||||
dst_format: PixelFormat::Yuv420,
|
||||
resolution,
|
||||
output_buffer: Yuv420pBuffer::new(resolution),
|
||||
}
|
||||
Self::new(PixelFormat::Yvu420, PixelFormat::Yuv420, resolution)
|
||||
}
|
||||
|
||||
/// Create a new converter for RGB24 → YUV420P
|
||||
pub fn rgb24_to_yuv420p(resolution: Resolution) -> Self {
|
||||
Self {
|
||||
src_format: PixelFormat::Rgb24,
|
||||
dst_format: PixelFormat::Yuv420,
|
||||
resolution,
|
||||
output_buffer: Yuv420pBuffer::new(resolution),
|
||||
}
|
||||
Self::new(PixelFormat::Rgb24, PixelFormat::Yuv420, resolution)
|
||||
}
|
||||
|
||||
/// Create a new converter for BGR24 → YUV420P
|
||||
pub fn bgr24_to_yuv420p(resolution: Resolution) -> Self {
|
||||
Self {
|
||||
src_format: PixelFormat::Bgr24,
|
||||
dst_format: PixelFormat::Yuv420,
|
||||
resolution,
|
||||
output_buffer: Yuv420pBuffer::new(resolution),
|
||||
}
|
||||
Self::new(PixelFormat::Bgr24, PixelFormat::Yuv420, resolution)
|
||||
}
|
||||
|
||||
/// Convert a frame and return reference to the output buffer
|
||||
@@ -304,6 +287,12 @@ impl PixelConverter {
|
||||
AppError::VideoError(format!("libyuv conversion failed: {}", e))
|
||||
})?;
|
||||
}
|
||||
(PixelFormat::Nv16, PixelFormat::Yuv420) => {
|
||||
self.convert_nv16_to_yuv420p(input)?;
|
||||
}
|
||||
(PixelFormat::Nv24, PixelFormat::Yuv420) => {
|
||||
self.convert_nv24_to_yuv420p(input)?;
|
||||
}
|
||||
(PixelFormat::Rgb24, PixelFormat::Yuv420) => {
|
||||
libyuv::rgb24_to_i420(input, self.output_buffer.as_bytes_mut(), width, height)
|
||||
.map_err(|e| {
|
||||
@@ -429,6 +418,102 @@ impl PixelConverter {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert NV16 (4:2:2 semiplanar) → YUV420P using libyuv split + I422 downsample
|
||||
fn convert_nv16_to_yuv420p(&mut self, nv16: &[u8]) -> Result<()> {
|
||||
let width = self.resolution.width as usize;
|
||||
let height = self.resolution.height as usize;
|
||||
let y_size = width * height;
|
||||
let uv_size = y_size;
|
||||
|
||||
if nv16.len() < y_size + uv_size {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"NV16 data too small: {} < {}",
|
||||
nv16.len(),
|
||||
y_size + uv_size
|
||||
)));
|
||||
}
|
||||
|
||||
let src_uv = &nv16[y_size..y_size + uv_size];
|
||||
let chroma_plane_size = y_size / 2;
|
||||
let (u_plane_422, rest) = self.uv_split_buffer.split_at_mut(chroma_plane_size);
|
||||
let (v_plane_422, _) = rest.split_at_mut(chroma_plane_size);
|
||||
|
||||
libyuv::split_uv_plane(
|
||||
src_uv,
|
||||
width as i32,
|
||||
u_plane_422,
|
||||
(width / 2) as i32,
|
||||
v_plane_422,
|
||||
(width / 2) as i32,
|
||||
(width / 2) as i32,
|
||||
height as i32,
|
||||
)
|
||||
.map_err(|e| AppError::VideoError(format!("libyuv NV16 split failed: {}", e)))?;
|
||||
|
||||
libyuv::i422_to_i420_planar(
|
||||
&nv16[..y_size],
|
||||
width as i32,
|
||||
u_plane_422,
|
||||
(width / 2) as i32,
|
||||
v_plane_422,
|
||||
(width / 2) as i32,
|
||||
self.output_buffer.as_bytes_mut(),
|
||||
width as i32,
|
||||
height as i32,
|
||||
)
|
||||
.map_err(|e| AppError::VideoError(format!("libyuv NV16→I420 failed: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert NV24 (4:4:4 semiplanar) → YUV420P using libyuv split + I444 downsample
|
||||
fn convert_nv24_to_yuv420p(&mut self, nv24: &[u8]) -> Result<()> {
|
||||
let width = self.resolution.width as usize;
|
||||
let height = self.resolution.height as usize;
|
||||
let y_size = width * height;
|
||||
let uv_size = y_size * 2;
|
||||
|
||||
if nv24.len() < y_size + uv_size {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"NV24 data too small: {} < {}",
|
||||
nv24.len(),
|
||||
y_size + uv_size
|
||||
)));
|
||||
}
|
||||
|
||||
let src_uv = &nv24[y_size..y_size + uv_size];
|
||||
let chroma_plane_size = y_size;
|
||||
let (u_plane_444, rest) = self.uv_split_buffer.split_at_mut(chroma_plane_size);
|
||||
let (v_plane_444, _) = rest.split_at_mut(chroma_plane_size);
|
||||
|
||||
libyuv::split_uv_plane(
|
||||
src_uv,
|
||||
(width * 2) as i32,
|
||||
u_plane_444,
|
||||
width as i32,
|
||||
v_plane_444,
|
||||
width as i32,
|
||||
width as i32,
|
||||
height as i32,
|
||||
)
|
||||
.map_err(|e| AppError::VideoError(format!("libyuv NV24 split failed: {}", e)))?;
|
||||
|
||||
libyuv::i444_to_i420_planar(
|
||||
&nv24[..y_size],
|
||||
width as i32,
|
||||
u_plane_444,
|
||||
width as i32,
|
||||
v_plane_444,
|
||||
width as i32,
|
||||
self.output_buffer.as_bytes_mut(),
|
||||
width as i32,
|
||||
height as i32,
|
||||
)
|
||||
.map_err(|e| AppError::VideoError(format!("libyuv NV24→I420 failed: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate YUV420P buffer size for a given resolution
|
||||
@@ -519,6 +604,16 @@ impl Nv12Converter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new converter for NV24 → NV12
|
||||
pub fn nv24_to_nv12(resolution: Resolution) -> Self {
|
||||
Self {
|
||||
src_format: PixelFormat::Nv24,
|
||||
resolution,
|
||||
output_buffer: Nv12Buffer::new(resolution),
|
||||
i420_buffer: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a frame and return reference to the output buffer
|
||||
pub fn convert(&mut self, input: &[u8]) -> Result<&[u8]> {
|
||||
let width = self.resolution.width as i32;
|
||||
@@ -553,6 +648,16 @@ impl Nv12Converter {
|
||||
)?;
|
||||
return Ok(self.output_buffer.as_bytes());
|
||||
}
|
||||
PixelFormat::Nv24 => {
|
||||
let dst = self.output_buffer.as_bytes_mut();
|
||||
Self::convert_nv24_to_nv12_with_dims(
|
||||
self.resolution.width as usize,
|
||||
self.resolution.height as usize,
|
||||
input,
|
||||
dst,
|
||||
)?;
|
||||
return Ok(self.output_buffer.as_bytes());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -635,6 +740,57 @@ impl Nv12Converter {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn convert_nv24_to_nv12_with_dims(
|
||||
width: usize,
|
||||
height: usize,
|
||||
input: &[u8],
|
||||
dst: &mut [u8],
|
||||
) -> Result<()> {
|
||||
let y_size = width * height;
|
||||
let uv_size_nv24 = y_size * 2;
|
||||
let uv_size_nv12 = y_size / 2;
|
||||
|
||||
if input.len() < y_size + uv_size_nv24 {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"NV24 data too small: {} < {}",
|
||||
input.len(),
|
||||
y_size + uv_size_nv24
|
||||
)));
|
||||
}
|
||||
|
||||
dst[..y_size].copy_from_slice(&input[..y_size]);
|
||||
|
||||
let src_uv = &input[y_size..y_size + uv_size_nv24];
|
||||
let dst_uv = &mut dst[y_size..y_size + uv_size_nv12];
|
||||
let dst_rows = height / 2;
|
||||
|
||||
for row in 0..dst_rows {
|
||||
let src_row0 = &src_uv[row * 2 * width * 2..row * 2 * width * 2 + width * 2];
|
||||
let src_row1 =
|
||||
&src_uv[(row * 2 + 1) * width * 2..(row * 2 + 1) * width * 2 + width * 2];
|
||||
let dst_row = &mut dst_uv[row * width..row * width + width];
|
||||
|
||||
for pair in 0..(width / 2) {
|
||||
let src_idx0 = pair * 4;
|
||||
let src_idx1 = src_idx0 + 2;
|
||||
let dst_idx = pair * 2;
|
||||
|
||||
dst_row[dst_idx] = ((src_row0[src_idx0] as u32
|
||||
+ src_row0[src_idx1] as u32
|
||||
+ src_row1[src_idx0] as u32
|
||||
+ src_row1[src_idx1] as u32)
|
||||
/ 4) as u8;
|
||||
dst_row[dst_idx + 1] = ((src_row0[src_idx0 + 1] as u32
|
||||
+ src_row0[src_idx1 + 1] as u32
|
||||
+ src_row1[src_idx0 + 1] as u32
|
||||
+ src_row1[src_idx1 + 1] as u32)
|
||||
/ 4) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get output buffer length
|
||||
pub fn output_len(&self) -> usize {
|
||||
self.output_buffer.len()
|
||||
|
||||
@@ -6,7 +6,10 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
use v4l2r::bindings::{v4l2_frmivalenum, v4l2_frmsizeenum};
|
||||
use v4l2r::bindings::{
|
||||
v4l2_bt_timings, v4l2_dv_timings, v4l2_frmivalenum, v4l2_frmsizeenum, v4l2_streamparm,
|
||||
V4L2_DV_BT_656_1120,
|
||||
};
|
||||
use v4l2r::ioctl::{
|
||||
self, Capabilities, Capability as V4l2rCapability, FormatIterator, FrmIvalTypes, FrmSizeTypes,
|
||||
};
|
||||
@@ -14,6 +17,7 @@ use v4l2r::nix::errno::Errno;
|
||||
use v4l2r::{Format as V4l2rFormat, QueueType};
|
||||
|
||||
use super::format::{PixelFormat, Resolution};
|
||||
use super::is_rk_hdmirx_driver;
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
const DEVICE_PROBE_TIMEOUT_MS: u64 = 400;
|
||||
@@ -57,11 +61,11 @@ pub struct FormatInfo {
|
||||
pub struct ResolutionInfo {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: Vec<u32>,
|
||||
pub fps: Vec<f64>,
|
||||
}
|
||||
|
||||
impl ResolutionInfo {
|
||||
pub fn new(width: u32, height: u32, fps: Vec<u32>) -> Self {
|
||||
pub fn new(width: u32, height: u32, fps: Vec<f64>) -> Self {
|
||||
Self { width, height, fps }
|
||||
}
|
||||
|
||||
@@ -143,7 +147,11 @@ impl VideoDevice {
|
||||
read_write: flags.contains(Capabilities::READWRITE),
|
||||
};
|
||||
|
||||
let formats = self.enumerate_formats()?;
|
||||
let formats = if is_rk_hdmirx_driver(&caps.driver, &caps.card) {
|
||||
self.enumerate_current_format_only()?
|
||||
} else {
|
||||
self.enumerate_formats()?
|
||||
};
|
||||
|
||||
// Determine if this is likely an HDMI capture card
|
||||
let is_capture_card = Self::detect_capture_card(&caps.card, &caps.driver, &formats);
|
||||
@@ -176,6 +184,15 @@ impl VideoDevice {
|
||||
// Try to convert FourCC to our PixelFormat
|
||||
if let Some(format) = PixelFormat::from_v4l2r(desc.pixelformat) {
|
||||
let resolutions = self.enumerate_resolutions(desc.pixelformat)?;
|
||||
let is_current_format = self.current_active_format() == Some(format);
|
||||
|
||||
if resolutions.is_empty() && !is_current_format {
|
||||
debug!(
|
||||
"Skipping format {:?} ({}): not usable for current active mode",
|
||||
desc.pixelformat, desc.description
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
formats.push(FormatInfo {
|
||||
format,
|
||||
@@ -196,9 +213,38 @@ impl VideoDevice {
|
||||
Ok(formats)
|
||||
}
|
||||
|
||||
fn enumerate_current_format_only(&self) -> Result<Vec<FormatInfo>> {
|
||||
let current = self.get_format()?;
|
||||
let Some(format) = PixelFormat::from_v4l2r(current.pixelformat) else {
|
||||
debug!(
|
||||
"Current active format {:?} is not supported by One-KVM, falling back to full enumeration",
|
||||
current.pixelformat
|
||||
);
|
||||
return self.enumerate_formats();
|
||||
};
|
||||
|
||||
let description = self
|
||||
.format_description(current.pixelformat)
|
||||
.unwrap_or_else(|| format.to_string());
|
||||
|
||||
let mut resolutions = self.enumerate_resolutions(current.pixelformat)?;
|
||||
if resolutions.is_empty() {
|
||||
if let Some(current_mode) = self.current_mode_resolution_info() {
|
||||
resolutions.push(current_mode);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(vec![FormatInfo {
|
||||
format,
|
||||
resolutions,
|
||||
description,
|
||||
}])
|
||||
}
|
||||
|
||||
/// Enumerate resolutions for a specific format
|
||||
fn enumerate_resolutions(&self, fourcc: v4l2r::PixelFormat) -> Result<Vec<ResolutionInfo>> {
|
||||
let mut resolutions = Vec::new();
|
||||
let mut should_fallback_to_current_mode = false;
|
||||
|
||||
let mut index = 0u32;
|
||||
loop {
|
||||
@@ -241,7 +287,15 @@ impl VideoDevice {
|
||||
e,
|
||||
v4l2r::ioctl::FrameSizeError::IoctlError(err) if err == Errno::EINVAL
|
||||
);
|
||||
if !is_einval {
|
||||
let is_unsupported = matches!(
|
||||
e,
|
||||
v4l2r::ioctl::FrameSizeError::IoctlError(err)
|
||||
if matches!(err, Errno::ENOTTY | Errno::ENOSYS | Errno::EOPNOTSUPP)
|
||||
);
|
||||
if is_unsupported && resolutions.is_empty() {
|
||||
should_fallback_to_current_mode = true;
|
||||
}
|
||||
if !is_einval && !is_unsupported {
|
||||
debug!("Failed to enumerate frame sizes for {:?}: {}", fourcc, e);
|
||||
}
|
||||
break;
|
||||
@@ -249,6 +303,23 @@ impl VideoDevice {
|
||||
}
|
||||
}
|
||||
|
||||
if should_fallback_to_current_mode {
|
||||
if let Some(resolution) = self.current_mode_resolution_info() {
|
||||
if self.format_works_for_resolution(fourcc, resolution.width, resolution.height) {
|
||||
debug!(
|
||||
"Falling back to current active mode for {:?}: {}x{} @ {:?} fps",
|
||||
fourcc, resolution.width, resolution.height, resolution.fps
|
||||
);
|
||||
resolutions.push(resolution);
|
||||
} else {
|
||||
debug!(
|
||||
"Skipping current-mode fallback for {:?}: TRY_FMT rejected {}x{}",
|
||||
fourcc, resolution.width, resolution.height
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by resolution (largest first)
|
||||
resolutions.sort_by(|a, b| (b.width * b.height).cmp(&(a.width * a.height)));
|
||||
resolutions.dedup_by(|a, b| a.width == b.width && a.height == b.height);
|
||||
@@ -262,8 +333,9 @@ impl VideoDevice {
|
||||
fourcc: v4l2r::PixelFormat,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<Vec<u32>> {
|
||||
) -> Result<Vec<f64>> {
|
||||
let mut fps_list = Vec::new();
|
||||
let mut should_fallback_to_current_mode = false;
|
||||
|
||||
let mut index = 0u32;
|
||||
loop {
|
||||
@@ -274,15 +346,18 @@ impl VideoDevice {
|
||||
if let Some(interval) = interval.intervals() {
|
||||
match interval {
|
||||
FrmIvalTypes::Discrete(fraction) => {
|
||||
if fraction.numerator > 0 {
|
||||
let fps = fraction.denominator / fraction.numerator;
|
||||
if fraction.numerator > 0 && fraction.denominator > 0 {
|
||||
let fps =
|
||||
fraction.denominator as f64 / fraction.numerator as f64;
|
||||
fps_list.push(fps);
|
||||
}
|
||||
}
|
||||
FrmIvalTypes::StepWise(step) => {
|
||||
if step.max.numerator > 0 {
|
||||
let min_fps = step.max.denominator / step.max.numerator;
|
||||
let max_fps = step.min.denominator / step.min.numerator;
|
||||
if step.max.numerator > 0 && step.max.denominator > 0 {
|
||||
let min_fps =
|
||||
step.max.denominator as f64 / step.max.numerator as f64;
|
||||
let max_fps =
|
||||
step.min.denominator as f64 / step.min.numerator as f64;
|
||||
fps_list.push(min_fps);
|
||||
if max_fps != min_fps {
|
||||
fps_list.push(max_fps);
|
||||
@@ -298,7 +373,15 @@ impl VideoDevice {
|
||||
e,
|
||||
v4l2r::ioctl::FrameIntervalsError::IoctlError(err) if err == Errno::EINVAL
|
||||
);
|
||||
if !is_einval {
|
||||
let is_unsupported = matches!(
|
||||
e,
|
||||
v4l2r::ioctl::FrameIntervalsError::IoctlError(err)
|
||||
if matches!(err, Errno::ENOTTY | Errno::ENOSYS | Errno::EOPNOTSUPP)
|
||||
);
|
||||
if is_unsupported && fps_list.is_empty() {
|
||||
should_fallback_to_current_mode = true;
|
||||
}
|
||||
if !is_einval && !is_unsupported {
|
||||
debug!(
|
||||
"Failed to enumerate frame intervals for {:?} {}x{}: {}",
|
||||
fourcc, width, height, e
|
||||
@@ -309,8 +392,11 @@ impl VideoDevice {
|
||||
}
|
||||
}
|
||||
|
||||
fps_list.sort_by(|a, b| b.cmp(a));
|
||||
fps_list.dedup();
|
||||
if should_fallback_to_current_mode {
|
||||
fps_list.extend(self.current_mode_fps());
|
||||
}
|
||||
|
||||
normalize_fps_list(&mut fps_list);
|
||||
Ok(fps_list)
|
||||
}
|
||||
|
||||
@@ -426,6 +512,115 @@ impl VideoDevice {
|
||||
&self.fd
|
||||
}
|
||||
|
||||
fn current_mode_resolution_info(&self) -> Option<ResolutionInfo> {
|
||||
let (width, height) = self
|
||||
.current_dv_timings_mode()
|
||||
.map(|(width, height, _)| (width, height))
|
||||
.or_else(|| self.current_format_resolution())?;
|
||||
Some(ResolutionInfo::new(width, height, self.current_mode_fps()))
|
||||
}
|
||||
|
||||
fn current_mode_fps(&self) -> Vec<f64> {
|
||||
let mut fps = Vec::new();
|
||||
|
||||
if let Some(frame_rate) = self.current_parm_fps() {
|
||||
fps.push(frame_rate);
|
||||
}
|
||||
|
||||
if let Some((_, _, Some(frame_rate))) = self.current_dv_timings_mode() {
|
||||
fps.push(frame_rate);
|
||||
}
|
||||
|
||||
normalize_fps_list(&mut fps);
|
||||
fps
|
||||
}
|
||||
|
||||
fn current_parm_fps(&self) -> Option<f64> {
|
||||
let queue = self.capture_queue_type().ok()?;
|
||||
let params: v4l2_streamparm = ioctl::g_parm(&self.fd, queue).ok()?;
|
||||
let capture = unsafe { params.parm.capture };
|
||||
let timeperframe = capture.timeperframe;
|
||||
if timeperframe.numerator == 0 || timeperframe.denominator == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(timeperframe.denominator as f64 / timeperframe.numerator as f64)
|
||||
}
|
||||
|
||||
fn current_dv_timings_mode(&self) -> Option<(u32, u32, Option<f64>)> {
|
||||
let timings = ioctl::query_dv_timings::<v4l2_dv_timings>(&self.fd)
|
||||
.or_else(|_| ioctl::g_dv_timings::<v4l2_dv_timings>(&self.fd))
|
||||
.ok()?;
|
||||
|
||||
if timings.type_ != V4L2_DV_BT_656_1120 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let bt = unsafe { timings.__bindgen_anon_1.bt };
|
||||
if bt.width == 0 || bt.height == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((bt.width, bt.height, dv_timings_fps(&bt)))
|
||||
}
|
||||
|
||||
/// Query current DV timings resolution for runtime change detection.
|
||||
///
|
||||
/// Returns the active resolution reported by DV timings (used by CSI/HDMI bridges
|
||||
/// such as TC358743, rk_hdmirx, etc.). Returns `None` when the device does not
|
||||
/// support DV timings or no signal is detected.
|
||||
pub fn query_dv_timings_resolution(&self) -> Option<Resolution> {
|
||||
let (w, h, _fps) = self.current_dv_timings_mode()?;
|
||||
Some(Resolution::new(w, h))
|
||||
}
|
||||
|
||||
fn current_format_resolution(&self) -> Option<(u32, u32)> {
|
||||
let format = self.get_format().ok()?;
|
||||
if format.width == 0 || format.height == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((format.width, format.height))
|
||||
}
|
||||
|
||||
fn current_active_format(&self) -> Option<PixelFormat> {
|
||||
let format = self.get_format().ok()?;
|
||||
PixelFormat::from_v4l2r(format.pixelformat)
|
||||
}
|
||||
|
||||
fn format_description(&self, fourcc: v4l2r::PixelFormat) -> Option<String> {
|
||||
let queue = self.capture_queue_type().ok()?;
|
||||
FormatIterator::new(&self.fd, queue)
|
||||
.find(|desc| desc.pixelformat == fourcc)
|
||||
.map(|desc| desc.description)
|
||||
}
|
||||
|
||||
fn format_works_for_resolution(
|
||||
&self,
|
||||
fourcc: v4l2r::PixelFormat,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> bool {
|
||||
let queue = match self.capture_queue_type() {
|
||||
Ok(queue) => queue,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let mut fmt = match ioctl::g_fmt::<V4l2rFormat>(&self.fd, queue) {
|
||||
Ok(fmt) => fmt,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
fmt.width = width;
|
||||
fmt.height = height;
|
||||
fmt.pixelformat = fourcc;
|
||||
|
||||
let actual = match ioctl::try_fmt::<_, V4l2rFormat>(&self.fd, (queue, &fmt)) {
|
||||
Ok(actual) => actual,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
actual.pixelformat == fourcc && actual.width == width && actual.height == height
|
||||
}
|
||||
|
||||
fn capture_queue_type(&self) -> Result<QueueType> {
|
||||
let caps = self.capabilities()?;
|
||||
if caps.video_capture {
|
||||
@@ -588,6 +783,36 @@ fn extract_uevent_value(content: &str, key: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn dv_timings_fps(bt: &v4l2_bt_timings) -> Option<f64> {
|
||||
let total_width = bt.width + bt.hfrontporch + bt.hsync + bt.hbackporch;
|
||||
let total_height = if bt.interlaced != 0 {
|
||||
bt.height
|
||||
+ bt.vfrontporch
|
||||
+ bt.vsync
|
||||
+ bt.vbackporch
|
||||
+ bt.il_vfrontporch
|
||||
+ bt.il_vsync
|
||||
+ bt.il_vbackporch
|
||||
} else {
|
||||
bt.height + bt.vfrontporch + bt.vsync + bt.vbackporch
|
||||
};
|
||||
|
||||
if bt.pixelclock == 0 || total_width == 0 || total_height == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(bt.pixelclock as f64 / total_width as f64 / total_height as f64)
|
||||
}
|
||||
|
||||
fn normalize_fps_list(fps_list: &mut Vec<f64>) {
|
||||
fps_list.retain(|fps| fps.is_finite() && *fps > 0.0);
|
||||
for fps in fps_list.iter_mut() {
|
||||
*fps = (*fps * 100.0).round() / 100.0;
|
||||
}
|
||||
fps_list.sort_by(|a, b| b.total_cmp(a));
|
||||
fps_list.dedup_by(|a, b| (*a - *b).abs() < 0.01);
|
||||
}
|
||||
|
||||
/// Find the best video device for KVM use
|
||||
pub fn find_best_device() -> Result<VideoDeviceInfo> {
|
||||
let devices = enumerate_devices()?;
|
||||
|
||||
@@ -13,10 +13,12 @@ use std::sync::Once;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use hwcodec::common::{Quality, RateControl};
|
||||
use hwcodec::ffmpeg::AVPixelFormat;
|
||||
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
|
||||
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||
|
||||
use super::detect_best_codec_for_format;
|
||||
use super::registry::EncoderBackend;
|
||||
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::video::format::{PixelFormat, Resolution};
|
||||
@@ -69,21 +71,17 @@ impl std::fmt::Display for H264EncoderType {
|
||||
}
|
||||
|
||||
/// Map codec name to encoder type
|
||||
fn codec_name_to_type(name: &str) -> H264EncoderType {
|
||||
if name.contains("nvenc") {
|
||||
H264EncoderType::Nvenc
|
||||
} else if name.contains("qsv") {
|
||||
H264EncoderType::Qsv
|
||||
} else if name.contains("amf") {
|
||||
H264EncoderType::Amf
|
||||
} else if name.contains("vaapi") {
|
||||
H264EncoderType::Vaapi
|
||||
} else if name.contains("rkmpp") {
|
||||
H264EncoderType::Rkmpp
|
||||
} else if name.contains("v4l2m2m") {
|
||||
H264EncoderType::V4l2M2m
|
||||
} else {
|
||||
H264EncoderType::Software
|
||||
impl From<EncoderBackend> for H264EncoderType {
|
||||
fn from(backend: EncoderBackend) -> Self {
|
||||
match backend {
|
||||
EncoderBackend::Nvenc => H264EncoderType::Nvenc,
|
||||
EncoderBackend::Qsv => H264EncoderType::Qsv,
|
||||
EncoderBackend::Amf => H264EncoderType::Amf,
|
||||
EncoderBackend::Vaapi => H264EncoderType::Vaapi,
|
||||
EncoderBackend::Rkmpp => H264EncoderType::Rkmpp,
|
||||
EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m,
|
||||
EncoderBackend::Software => H264EncoderType::Software,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +195,7 @@ pub fn get_available_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
||||
mc_name: None,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
pixfmt: AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||
pixfmt: resolve_pixel_format("yuv420p", AVPixelFormat::AV_PIX_FMT_YUV420P),
|
||||
align: 1,
|
||||
fps: 30,
|
||||
gop: 30,
|
||||
@@ -215,21 +213,15 @@ pub fn get_available_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
||||
pub fn detect_best_encoder(width: u32, height: u32) -> (H264EncoderType, Option<String>) {
|
||||
let encoders = get_available_encoders(width, height);
|
||||
|
||||
if encoders.is_empty() {
|
||||
if let Some((encoder_type, codec_name)) =
|
||||
detect_best_codec_for_format(&encoders, hwcodec::common::DataFormat::H264, |_| true)
|
||||
{
|
||||
info!("Best H.264 encoder: {} ({})", codec_name, encoder_type);
|
||||
(encoder_type, Some(codec_name))
|
||||
} else {
|
||||
warn!("No H.264 encoders available from hwcodec");
|
||||
return (H264EncoderType::None, None);
|
||||
(H264EncoderType::None, None)
|
||||
}
|
||||
|
||||
// Find H264 encoder (not H265)
|
||||
for codec in &encoders {
|
||||
if codec.format == hwcodec::common::DataFormat::H264 {
|
||||
let encoder_type = codec_name_to_type(&codec.name);
|
||||
info!("Best H.264 encoder: {} ({})", codec.name, encoder_type);
|
||||
return (encoder_type, Some(codec.name.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
(H264EncoderType::None, None)
|
||||
}
|
||||
|
||||
/// Encoded frame from hwcodec (cloned for ownership)
|
||||
@@ -252,9 +244,6 @@ pub struct H264Encoder {
|
||||
codec_name: String,
|
||||
/// Frame counter
|
||||
frame_count: u64,
|
||||
/// YUV420P buffer for input (reserved for future use)
|
||||
#[allow(dead_code)]
|
||||
yuv_buffer: Vec<u8>,
|
||||
/// Required YUV buffer length from hwcodec
|
||||
yuv_length: i32,
|
||||
}
|
||||
@@ -284,16 +273,17 @@ impl H264Encoder {
|
||||
let height = config.base.resolution.height;
|
||||
|
||||
// Select pixel format based on config
|
||||
let pixfmt = match config.input_format {
|
||||
H264InputFormat::Nv12 => AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
H264InputFormat::Nv21 => AVPixelFormat::AV_PIX_FMT_NV21,
|
||||
H264InputFormat::Nv16 => AVPixelFormat::AV_PIX_FMT_NV16,
|
||||
H264InputFormat::Nv24 => AVPixelFormat::AV_PIX_FMT_NV24,
|
||||
H264InputFormat::Yuv420p => AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||
H264InputFormat::Yuyv422 => AVPixelFormat::AV_PIX_FMT_YUYV422,
|
||||
H264InputFormat::Rgb24 => AVPixelFormat::AV_PIX_FMT_RGB24,
|
||||
H264InputFormat::Bgr24 => AVPixelFormat::AV_PIX_FMT_BGR24,
|
||||
let (pixfmt_name, pixfmt_fallback) = match config.input_format {
|
||||
H264InputFormat::Nv12 => ("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
|
||||
H264InputFormat::Nv21 => ("nv21", AVPixelFormat::AV_PIX_FMT_NV21),
|
||||
H264InputFormat::Nv16 => ("nv16", AVPixelFormat::AV_PIX_FMT_NV16),
|
||||
H264InputFormat::Nv24 => ("nv24", AVPixelFormat::AV_PIX_FMT_NV24),
|
||||
H264InputFormat::Yuv420p => ("yuv420p", AVPixelFormat::AV_PIX_FMT_YUV420P),
|
||||
H264InputFormat::Yuyv422 => ("yuyv422", AVPixelFormat::AV_PIX_FMT_YUYV422),
|
||||
H264InputFormat::Rgb24 => ("rgb24", AVPixelFormat::AV_PIX_FMT_RGB24),
|
||||
H264InputFormat::Bgr24 => ("bgr24", AVPixelFormat::AV_PIX_FMT_BGR24),
|
||||
};
|
||||
let pixfmt = resolve_pixel_format(pixfmt_name, pixfmt_fallback);
|
||||
|
||||
info!(
|
||||
"Creating H.264 encoder: {} at {}x{} @ {} kbps (input: {:?})",
|
||||
@@ -321,7 +311,7 @@ impl H264Encoder {
|
||||
})?;
|
||||
|
||||
let yuv_length = inner.length;
|
||||
let encoder_type = codec_name_to_type(codec_name);
|
||||
let encoder_type = H264EncoderType::from(EncoderBackend::from_codec_name(codec_name));
|
||||
|
||||
info!(
|
||||
"H.264 encoder created: {} (type: {}, buffer_length: {}, input_format: {:?})",
|
||||
@@ -334,7 +324,6 @@ impl H264Encoder {
|
||||
encoder_type,
|
||||
codec_name: codec_name.to_string(),
|
||||
frame_count: 0,
|
||||
yuv_buffer: vec![0u8; yuv_length as usize],
|
||||
yuv_length,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,10 +11,11 @@ use std::sync::Once;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use hwcodec::common::{DataFormat, Quality, RateControl};
|
||||
use hwcodec::ffmpeg::AVPixelFormat;
|
||||
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
|
||||
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||
|
||||
use super::detect_best_codec_for_format;
|
||||
use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
||||
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
|
||||
use crate::error::{AppError, Result};
|
||||
@@ -197,7 +198,7 @@ pub fn get_available_h265_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
||||
mc_name: None,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
|
||||
align: 1,
|
||||
fps: 30,
|
||||
gop: 30,
|
||||
@@ -221,43 +222,25 @@ pub fn get_available_h265_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
||||
pub fn detect_best_h265_encoder(width: u32, height: u32) -> (H265EncoderType, Option<String>) {
|
||||
let encoders = get_available_h265_encoders(width, height);
|
||||
|
||||
if encoders.is_empty() {
|
||||
warn!("No H.265 encoders available");
|
||||
return (H265EncoderType::None, None);
|
||||
}
|
||||
|
||||
// Prefer hardware encoders over software (libx265)
|
||||
// Hardware priority: NVENC > QSV > AMF > VAAPI > RKMPP > V4L2 M2M > Software
|
||||
let codec = encoders
|
||||
.iter()
|
||||
.find(|e| !e.name.contains("libx265"))
|
||||
.or_else(|| encoders.first())
|
||||
.unwrap();
|
||||
|
||||
let encoder_type = if codec.name.contains("nvenc") {
|
||||
H265EncoderType::Nvenc
|
||||
} else if codec.name.contains("qsv") {
|
||||
H265EncoderType::Qsv
|
||||
} else if codec.name.contains("amf") {
|
||||
H265EncoderType::Amf
|
||||
} else if codec.name.contains("vaapi") {
|
||||
H265EncoderType::Vaapi
|
||||
} else if codec.name.contains("rkmpp") {
|
||||
H265EncoderType::Rkmpp
|
||||
} else if codec.name.contains("v4l2m2m") {
|
||||
H265EncoderType::V4l2M2m
|
||||
if let Some((encoder_type, codec_name)) =
|
||||
detect_best_codec_for_format(&encoders, DataFormat::H265, |codec| {
|
||||
!codec.name.contains("libx265")
|
||||
})
|
||||
{
|
||||
info!("Selected H.265 encoder: {} ({})", codec_name, encoder_type);
|
||||
(encoder_type, Some(codec_name))
|
||||
} else {
|
||||
H265EncoderType::Software // Default to software for unknown
|
||||
};
|
||||
|
||||
info!("Selected H.265 encoder: {} ({})", codec.name, encoder_type);
|
||||
(encoder_type, Some(codec.name.clone()))
|
||||
warn!("No H.265 encoders available");
|
||||
(H265EncoderType::None, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if H265 hardware encoding is available
|
||||
pub fn is_h265_available() -> bool {
|
||||
let registry = EncoderRegistry::global();
|
||||
registry.is_format_available(VideoEncoderType::H265, true)
|
||||
registry.is_codec_available(VideoEncoderType::H265)
|
||||
}
|
||||
|
||||
/// Encoded frame from hwcodec (cloned for ownership)
|
||||
@@ -268,7 +251,7 @@ pub struct HwEncodeFrame {
|
||||
pub key: i32,
|
||||
}
|
||||
|
||||
/// H.265 encoder using hwcodec (hardware only)
|
||||
/// H.265 encoder using hwcodec
|
||||
pub struct H265Encoder {
|
||||
/// hwcodec encoder instance
|
||||
inner: HwEncoder,
|
||||
@@ -327,24 +310,57 @@ impl H265Encoder {
|
||||
let height = config.base.resolution.height;
|
||||
|
||||
// Software encoders (libx265) require YUV420P, hardware encoders use NV12 or YUYV422
|
||||
let (pixfmt, actual_input_format) = if is_software {
|
||||
(AVPixelFormat::AV_PIX_FMT_YUV420P, H265InputFormat::Yuv420p)
|
||||
let (pixfmt_name, pixfmt_fallback, actual_input_format) = if is_software {
|
||||
(
|
||||
"yuv420p",
|
||||
AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||
H265InputFormat::Yuv420p,
|
||||
)
|
||||
} else {
|
||||
match config.input_format {
|
||||
H265InputFormat::Nv12 => (AVPixelFormat::AV_PIX_FMT_NV12, H265InputFormat::Nv12),
|
||||
H265InputFormat::Nv21 => (AVPixelFormat::AV_PIX_FMT_NV21, H265InputFormat::Nv21),
|
||||
H265InputFormat::Nv16 => (AVPixelFormat::AV_PIX_FMT_NV16, H265InputFormat::Nv16),
|
||||
H265InputFormat::Nv24 => (AVPixelFormat::AV_PIX_FMT_NV24, H265InputFormat::Nv24),
|
||||
H265InputFormat::Yuv420p => {
|
||||
(AVPixelFormat::AV_PIX_FMT_YUV420P, H265InputFormat::Yuv420p)
|
||||
}
|
||||
H265InputFormat::Yuyv422 => {
|
||||
(AVPixelFormat::AV_PIX_FMT_YUYV422, H265InputFormat::Yuyv422)
|
||||
}
|
||||
H265InputFormat::Rgb24 => (AVPixelFormat::AV_PIX_FMT_RGB24, H265InputFormat::Rgb24),
|
||||
H265InputFormat::Bgr24 => (AVPixelFormat::AV_PIX_FMT_BGR24, H265InputFormat::Bgr24),
|
||||
H265InputFormat::Nv12 => (
|
||||
"nv12",
|
||||
AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
H265InputFormat::Nv12,
|
||||
),
|
||||
H265InputFormat::Nv21 => (
|
||||
"nv21",
|
||||
AVPixelFormat::AV_PIX_FMT_NV21,
|
||||
H265InputFormat::Nv21,
|
||||
),
|
||||
H265InputFormat::Nv16 => (
|
||||
"nv16",
|
||||
AVPixelFormat::AV_PIX_FMT_NV16,
|
||||
H265InputFormat::Nv16,
|
||||
),
|
||||
H265InputFormat::Nv24 => (
|
||||
"nv24",
|
||||
AVPixelFormat::AV_PIX_FMT_NV24,
|
||||
H265InputFormat::Nv24,
|
||||
),
|
||||
H265InputFormat::Yuv420p => (
|
||||
"yuv420p",
|
||||
AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||
H265InputFormat::Yuv420p,
|
||||
),
|
||||
H265InputFormat::Yuyv422 => (
|
||||
"yuyv422",
|
||||
AVPixelFormat::AV_PIX_FMT_YUYV422,
|
||||
H265InputFormat::Yuyv422,
|
||||
),
|
||||
H265InputFormat::Rgb24 => (
|
||||
"rgb24",
|
||||
AVPixelFormat::AV_PIX_FMT_RGB24,
|
||||
H265InputFormat::Rgb24,
|
||||
),
|
||||
H265InputFormat::Bgr24 => (
|
||||
"bgr24",
|
||||
AVPixelFormat::AV_PIX_FMT_BGR24,
|
||||
H265InputFormat::Bgr24,
|
||||
),
|
||||
}
|
||||
};
|
||||
let pixfmt = resolve_pixel_format(pixfmt_name, pixfmt_fallback);
|
||||
|
||||
info!(
|
||||
"Creating H.265 encoder: {} at {}x{} @ {} kbps (input: {:?})",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! JPEG encoder implementation
|
||||
//!
|
||||
//! Provides JPEG encoding for raw video frames (YUYV, NV12, RGB, BGR)
|
||||
//! Provides JPEG encoding for raw video frames (YUYV, NV12, NV16, NV24, RGB, BGR)
|
||||
//! Uses libyuv for SIMD-accelerated color space conversion to I420,
|
||||
//! then turbojpeg for direct YUV encoding (skips internal color conversion).
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::video::format::{PixelFormat, Resolution};
|
||||
///
|
||||
/// Encoding pipeline (all SIMD accelerated):
|
||||
/// ```text
|
||||
/// YUYV/NV12/BGR24/RGB24 ──libyuv──> I420 ──turbojpeg──> JPEG
|
||||
/// YUYV/NV12/NV16/NV24/BGR24/RGB24 ──libyuv──> I420 ──turbojpeg──> JPEG
|
||||
/// ```
|
||||
///
|
||||
/// Note: This encoder is NOT thread-safe due to turbojpeg limitations.
|
||||
@@ -24,6 +24,10 @@ pub struct JpegEncoder {
|
||||
compressor: turbojpeg::Compressor,
|
||||
/// I420 buffer for YUV encoding (Y + U + V planes)
|
||||
i420_buffer: Vec<u8>,
|
||||
/// Scratch buffer for split chroma planes when converting semiplanar 4:2:2 / 4:4:4 input.
|
||||
uv_split_buffer: Vec<u8>,
|
||||
/// BGRA buffer used when a source format needs explicit YUV matrix expansion before JPEG.
|
||||
bgra_buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl JpegEncoder {
|
||||
@@ -34,6 +38,8 @@ impl JpegEncoder {
|
||||
let height = resolution.height as usize;
|
||||
// I420: Y = width*height, U = width*height/4, V = width*height/4
|
||||
let i420_size = width * height * 3 / 2;
|
||||
let max_uv_plane_size = width * height;
|
||||
let bgra_size = width * height * 4;
|
||||
|
||||
let mut compressor = turbojpeg::Compressor::new().map_err(|e| {
|
||||
AppError::VideoError(format!("Failed to create turbojpeg compressor: {}", e))
|
||||
@@ -47,6 +53,8 @@ impl JpegEncoder {
|
||||
config,
|
||||
compressor,
|
||||
i420_buffer: vec![0u8; i420_size],
|
||||
uv_split_buffer: vec![0u8; max_uv_plane_size * 2],
|
||||
bgra_buffer: vec![0u8; bgra_size],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,6 +101,36 @@ impl JpegEncoder {
|
||||
))
|
||||
}
|
||||
|
||||
/// Encode BGRA buffer to JPEG using turbojpeg's RGB path.
|
||||
#[inline]
|
||||
fn encode_bgra_to_jpeg(&mut self, sequence: u64) -> Result<EncodedFrame> {
|
||||
let width = self.config.resolution.width as usize;
|
||||
let height = self.config.resolution.height as usize;
|
||||
|
||||
self.compressor
|
||||
.set_subsamp(turbojpeg::Subsamp::Sub2x2)
|
||||
.map_err(|e| AppError::VideoError(format!("Failed to set JPEG subsampling: {}", e)))?;
|
||||
|
||||
let image = turbojpeg::Image {
|
||||
pixels: self.bgra_buffer.as_slice(),
|
||||
width,
|
||||
pitch: width * 4,
|
||||
height,
|
||||
format: turbojpeg::PixelFormat::BGRA,
|
||||
};
|
||||
|
||||
let jpeg_data = self
|
||||
.compressor
|
||||
.compress_to_vec(image)
|
||||
.map_err(|e| AppError::VideoError(format!("JPEG compression failed: {}", e)))?;
|
||||
|
||||
Ok(EncodedFrame::jpeg(
|
||||
Bytes::from(jpeg_data),
|
||||
self.config.resolution,
|
||||
sequence,
|
||||
))
|
||||
}
|
||||
|
||||
/// Encode YUYV (YUV422) frame to JPEG
|
||||
pub fn encode_yuyv(&mut self, data: &[u8], sequence: u64) -> Result<EncodedFrame> {
|
||||
let width = self.config.resolution.width as usize;
|
||||
@@ -135,6 +173,101 @@ impl JpegEncoder {
|
||||
self.encode_i420_to_jpeg(sequence)
|
||||
}
|
||||
|
||||
/// Encode NV16 frame to JPEG
|
||||
pub fn encode_nv16(&mut self, data: &[u8], sequence: u64) -> Result<EncodedFrame> {
|
||||
let width = self.config.resolution.width as usize;
|
||||
let height = self.config.resolution.height as usize;
|
||||
let y_size = width * height;
|
||||
let uv_size = y_size;
|
||||
let expected_size = y_size + uv_size;
|
||||
|
||||
if data.len() < expected_size {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"NV16 data too small: {} < {}",
|
||||
data.len(),
|
||||
expected_size
|
||||
)));
|
||||
}
|
||||
|
||||
let src_uv = &data[y_size..expected_size];
|
||||
let chroma_plane_size = y_size / 2;
|
||||
let (u_plane_422, rest) = self.uv_split_buffer.split_at_mut(chroma_plane_size);
|
||||
let (v_plane_422, _) = rest.split_at_mut(chroma_plane_size);
|
||||
|
||||
libyuv::split_uv_plane(
|
||||
src_uv,
|
||||
width as i32,
|
||||
u_plane_422,
|
||||
(width / 2) as i32,
|
||||
v_plane_422,
|
||||
(width / 2) as i32,
|
||||
(width / 2) as i32,
|
||||
height as i32,
|
||||
)
|
||||
.map_err(|e| AppError::VideoError(format!("libyuv NV16 split failed: {}", e)))?;
|
||||
|
||||
libyuv::i422_to_i420_planar(
|
||||
&data[..y_size],
|
||||
width as i32,
|
||||
u_plane_422,
|
||||
(width / 2) as i32,
|
||||
v_plane_422,
|
||||
(width / 2) as i32,
|
||||
&mut self.i420_buffer,
|
||||
width as i32,
|
||||
height as i32,
|
||||
)
|
||||
.map_err(|e| AppError::VideoError(format!("libyuv NV16→I420 failed: {}", e)))?;
|
||||
|
||||
self.encode_i420_to_jpeg(sequence)
|
||||
}
|
||||
|
||||
/// Encode NV24 frame to JPEG
|
||||
pub fn encode_nv24(&mut self, data: &[u8], sequence: u64) -> Result<EncodedFrame> {
|
||||
let width = self.config.resolution.width as usize;
|
||||
let height = self.config.resolution.height as usize;
|
||||
let y_size = width * height;
|
||||
let uv_size = y_size * 2;
|
||||
let expected_size = y_size + uv_size;
|
||||
|
||||
if data.len() < expected_size {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"NV24 data too small: {} < {}",
|
||||
data.len(),
|
||||
expected_size
|
||||
)));
|
||||
}
|
||||
|
||||
let src_uv = &data[y_size..expected_size];
|
||||
let chroma_plane_size = y_size;
|
||||
let (u_plane_444, rest) = self.uv_split_buffer.split_at_mut(chroma_plane_size);
|
||||
let (v_plane_444, _) = rest.split_at_mut(chroma_plane_size);
|
||||
|
||||
libyuv::split_uv_plane(
|
||||
src_uv,
|
||||
(width * 2) as i32,
|
||||
u_plane_444,
|
||||
width as i32,
|
||||
v_plane_444,
|
||||
width as i32,
|
||||
width as i32,
|
||||
height as i32,
|
||||
)
|
||||
.map_err(|e| AppError::VideoError(format!("libyuv NV24 split failed: {}", e)))?;
|
||||
|
||||
libyuv::h444_to_bgra(
|
||||
&data[..y_size],
|
||||
u_plane_444,
|
||||
v_plane_444,
|
||||
&mut self.bgra_buffer,
|
||||
width as i32,
|
||||
height as i32,
|
||||
)
|
||||
.map_err(|e| AppError::VideoError(format!("libyuv NV24(H444)→BGRA failed: {}", e)))?;
|
||||
|
||||
self.encode_bgra_to_jpeg(sequence)
|
||||
}
|
||||
|
||||
/// Encode RGB24 frame to JPEG
|
||||
pub fn encode_rgb(&mut self, data: &[u8], sequence: u64) -> Result<EncodedFrame> {
|
||||
let width = self.config.resolution.width as usize;
|
||||
@@ -192,6 +325,8 @@ impl crate::video::encoder::traits::Encoder for JpegEncoder {
|
||||
match self.config.input_format {
|
||||
PixelFormat::Yuyv | PixelFormat::Yvyu => self.encode_yuyv(data, sequence),
|
||||
PixelFormat::Nv12 => self.encode_nv12(data, sequence),
|
||||
PixelFormat::Nv16 => self.encode_nv16(data, sequence),
|
||||
PixelFormat::Nv24 => self.encode_nv24(data, sequence),
|
||||
PixelFormat::Rgb24 => self.encode_rgb(data, sequence),
|
||||
PixelFormat::Bgr24 => self.encode_bgr(data, sequence),
|
||||
_ => Err(AppError::VideoError(format!(
|
||||
@@ -211,6 +346,8 @@ impl crate::video::encoder::traits::Encoder for JpegEncoder {
|
||||
PixelFormat::Yuyv
|
||||
| PixelFormat::Yvyu
|
||||
| PixelFormat::Nv12
|
||||
| PixelFormat::Nv16
|
||||
| PixelFormat::Nv24
|
||||
| PixelFormat::Rgb24
|
||||
| PixelFormat::Bgr24
|
||||
)
|
||||
|
||||
@@ -3,17 +3,21 @@
|
||||
//! This module provides video encoding capabilities including:
|
||||
//! - JPEG encoding for raw frames (YUYV, NV12, etc.)
|
||||
//! - H264 encoding (hardware + software)
|
||||
//! - H265 encoding (hardware only)
|
||||
//! - VP8 encoding (hardware only - VAAPI)
|
||||
//! - VP9 encoding (hardware only - VAAPI)
|
||||
//! - H265 encoding (hardware + software)
|
||||
//! - VP8 encoding (hardware + software)
|
||||
//! - VP9 encoding (hardware + software)
|
||||
//! - WebRTC video codec abstraction
|
||||
//! - Encoder registry for automatic detection
|
||||
|
||||
use hwcodec::common::DataFormat;
|
||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||
|
||||
pub mod codec;
|
||||
pub mod h264;
|
||||
pub mod h265;
|
||||
pub mod jpeg;
|
||||
pub mod registry;
|
||||
pub mod self_check;
|
||||
pub mod traits;
|
||||
pub mod vp8;
|
||||
pub mod vp9;
|
||||
@@ -28,18 +32,53 @@ pub use codec::{CodecFrame, VideoCodec, VideoCodecConfig, VideoCodecFactory, Vid
|
||||
|
||||
// Encoder registry
|
||||
pub use registry::{AvailableEncoder, EncoderBackend, EncoderRegistry, VideoEncoderType};
|
||||
pub use self_check::{
|
||||
build_hardware_self_check_runtime_error, run_hardware_self_check, VideoEncoderSelfCheckCell,
|
||||
VideoEncoderSelfCheckCodec, VideoEncoderSelfCheckResponse, VideoEncoderSelfCheckRow,
|
||||
};
|
||||
|
||||
// H264 encoder
|
||||
pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
|
||||
|
||||
// H265 encoder (hardware only)
|
||||
// H265 encoder
|
||||
pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat};
|
||||
|
||||
// VP8 encoder (hardware only)
|
||||
// VP8 encoder
|
||||
pub use vp8::{VP8Config, VP8Encoder, VP8EncoderType, VP8InputFormat};
|
||||
|
||||
// VP9 encoder (hardware only)
|
||||
// VP9 encoder
|
||||
pub use vp9::{VP9Config, VP9Encoder, VP9EncoderType, VP9InputFormat};
|
||||
|
||||
// JPEG encoder
|
||||
pub use jpeg::JpegEncoder;
|
||||
|
||||
pub(crate) fn select_codec_for_format<F>(
|
||||
encoders: &[CodecInfo],
|
||||
format: DataFormat,
|
||||
preferred: F,
|
||||
) -> Option<&CodecInfo>
|
||||
where
|
||||
F: Fn(&CodecInfo) -> bool,
|
||||
{
|
||||
encoders
|
||||
.iter()
|
||||
.find(|codec| codec.format == format && preferred(codec))
|
||||
.or_else(|| encoders.iter().find(|codec| codec.format == format))
|
||||
}
|
||||
|
||||
pub(crate) fn detect_best_codec_for_format<T, F>(
|
||||
encoders: &[CodecInfo],
|
||||
format: DataFormat,
|
||||
preferred: F,
|
||||
) -> Option<(T, String)>
|
||||
where
|
||||
T: From<EncoderBackend>,
|
||||
F: Fn(&CodecInfo) -> bool,
|
||||
{
|
||||
select_codec_for_format(encoders, format, preferred).map(|codec| {
|
||||
(
|
||||
T::from(EncoderBackend::from_codec_name(&codec.name)),
|
||||
codec.name.clone(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use hwcodec::common::{DataFormat, Quality, RateControl};
|
||||
use hwcodec::ffmpeg::AVPixelFormat;
|
||||
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
|
||||
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||
|
||||
@@ -28,6 +29,10 @@ pub enum VideoEncoderType {
|
||||
}
|
||||
|
||||
impl VideoEncoderType {
|
||||
pub const fn ordered() -> [Self; 4] {
|
||||
[Self::H264, Self::H265, Self::VP8, Self::VP9]
|
||||
}
|
||||
|
||||
/// Convert to hwcodec DataFormat
|
||||
pub fn to_data_format(&self) -> DataFormat {
|
||||
match self {
|
||||
@@ -68,17 +73,6 @@ impl VideoEncoderType {
|
||||
VideoEncoderType::VP9 => "VP9",
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this format requires hardware-only encoding
|
||||
/// H264 supports software fallback, others require hardware
|
||||
pub fn hardware_only(&self) -> bool {
|
||||
match self {
|
||||
VideoEncoderType::H264 => false,
|
||||
VideoEncoderType::H265 => true,
|
||||
VideoEncoderType::VP8 => true,
|
||||
VideoEncoderType::VP9 => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VideoEncoderType {
|
||||
@@ -210,14 +204,84 @@ pub struct EncoderRegistry {
|
||||
}
|
||||
|
||||
impl EncoderRegistry {
|
||||
fn detect_encoders_with_timeout(ctx: EncodeContext, timeout: Duration) -> Vec<CodecInfo> {
|
||||
use std::sync::mpsc;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("ffmpeg-encoder-detect".to_string())
|
||||
.spawn(move || {
|
||||
let result = HwEncoder::available_encoders(ctx, None);
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
let Ok(handle) = handle else {
|
||||
warn!("Failed to spawn encoder detection thread");
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
match rx.recv_timeout(timeout) {
|
||||
Ok(encoders) => {
|
||||
let _ = handle.join();
|
||||
encoders
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
warn!(
|
||||
"Encoder detection timed out after {}ms, skipping hardware detection",
|
||||
timeout.as_millis()
|
||||
);
|
||||
std::thread::spawn(move || {
|
||||
let _ = handle.join();
|
||||
});
|
||||
Vec::new()
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
let _ = handle.join();
|
||||
warn!("Encoder detection thread exited unexpectedly");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn register_software_fallbacks(&mut self) {
|
||||
info!("Registering software encoders...");
|
||||
|
||||
for format in VideoEncoderType::ordered() {
|
||||
let encoders = self.encoders.entry(format).or_default();
|
||||
if encoders.iter().any(|encoder| !encoder.is_hardware) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let codec_name = match format {
|
||||
VideoEncoderType::H264 => "libx264",
|
||||
VideoEncoderType::H265 => "libx265",
|
||||
VideoEncoderType::VP8 => "libvpx",
|
||||
VideoEncoderType::VP9 => "libvpx-vp9",
|
||||
};
|
||||
|
||||
encoders.push(AvailableEncoder {
|
||||
format,
|
||||
codec_name: codec_name.to_string(),
|
||||
backend: EncoderBackend::Software,
|
||||
priority: 100,
|
||||
is_hardware: false,
|
||||
});
|
||||
|
||||
debug!(
|
||||
"Registered software encoder: {} for {} (priority: {})",
|
||||
codec_name, format, 100
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the global registry instance
|
||||
///
|
||||
/// The registry is initialized lazily on first access with 1920x1080 detection.
|
||||
/// The registry is initialized lazily on first access with 1280x720 detection.
|
||||
pub fn global() -> &'static Self {
|
||||
static INSTANCE: OnceLock<EncoderRegistry> = OnceLock::new();
|
||||
INSTANCE.get_or_init(|| {
|
||||
let mut registry = EncoderRegistry::new();
|
||||
registry.detect_encoders(1920, 1080);
|
||||
registry.detect_encoders(1280, 720);
|
||||
registry
|
||||
})
|
||||
}
|
||||
@@ -245,7 +309,7 @@ impl EncoderRegistry {
|
||||
mc_name: None,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
|
||||
align: 1,
|
||||
fps: 30,
|
||||
gop: 30,
|
||||
@@ -257,32 +321,11 @@ impl EncoderRegistry {
|
||||
};
|
||||
|
||||
const DETECT_TIMEOUT_MS: u64 = 5000;
|
||||
|
||||
// Get all available encoders from hwcodec with a hard timeout
|
||||
let all_encoders = {
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
info!("Encoder detection timeout: {}ms", DETECT_TIMEOUT_MS);
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let ctx_clone = ctx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = HwEncoder::available_encoders(ctx_clone, None);
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
match rx.recv_timeout(Duration::from_millis(DETECT_TIMEOUT_MS)) {
|
||||
Ok(encoders) => encoders,
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"Encoder detection timed out after {}ms, skipping hardware detection",
|
||||
DETECT_TIMEOUT_MS
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
info!("Encoder detection timeout: {}ms", DETECT_TIMEOUT_MS);
|
||||
let all_encoders = Self::detect_encoders_with_timeout(
|
||||
ctx.clone(),
|
||||
Duration::from_millis(DETECT_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
info!("Found {} encoders from hwcodec", all_encoders.len());
|
||||
|
||||
@@ -305,32 +348,7 @@ impl EncoderRegistry {
|
||||
encoders.sort_by_key(|e| e.priority);
|
||||
}
|
||||
|
||||
// Register software encoders as fallback
|
||||
info!("Registering software encoders...");
|
||||
let software_encoders = [
|
||||
(VideoEncoderType::H264, "libx264", 100),
|
||||
(VideoEncoderType::H265, "libx265", 100),
|
||||
(VideoEncoderType::VP8, "libvpx", 100),
|
||||
(VideoEncoderType::VP9, "libvpx-vp9", 100),
|
||||
];
|
||||
|
||||
for (format, codec_name, priority) in software_encoders {
|
||||
self.encoders
|
||||
.entry(format)
|
||||
.or_default()
|
||||
.push(AvailableEncoder {
|
||||
format,
|
||||
codec_name: codec_name.to_string(),
|
||||
backend: EncoderBackend::Software,
|
||||
priority,
|
||||
is_hardware: false,
|
||||
});
|
||||
|
||||
debug!(
|
||||
"Registered software encoder: {} for {} (priority: {})",
|
||||
codec_name, format, priority
|
||||
);
|
||||
}
|
||||
self.register_software_fallbacks();
|
||||
|
||||
// Log summary
|
||||
for (format, encoders) in &self.encoders {
|
||||
@@ -370,6 +388,10 @@ impl EncoderRegistry {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn best_available_encoder(&self, format: VideoEncoderType) -> Option<&AvailableEncoder> {
|
||||
self.best_encoder(format, false)
|
||||
}
|
||||
|
||||
/// Get all encoders for a format
|
||||
pub fn encoders_for_format(&self, format: VideoEncoderType) -> &[AvailableEncoder] {
|
||||
self.encoders
|
||||
@@ -405,31 +427,17 @@ impl EncoderRegistry {
|
||||
self.best_encoder(format, hardware_only).is_some()
|
||||
}
|
||||
|
||||
pub fn is_codec_available(&self, format: VideoEncoderType) -> bool {
|
||||
self.best_available_encoder(format).is_some()
|
||||
}
|
||||
|
||||
/// Get available formats for user selection
|
||||
///
|
||||
/// Returns formats that are actually usable based on their requirements:
|
||||
/// - H264: Available if any encoder exists (hardware or software)
|
||||
/// - H265/VP8/VP9: Available only if hardware encoder exists
|
||||
pub fn selectable_formats(&self) -> Vec<VideoEncoderType> {
|
||||
let mut formats = Vec::new();
|
||||
|
||||
// H264 - supports software fallback
|
||||
if self.is_format_available(VideoEncoderType::H264, false) {
|
||||
formats.push(VideoEncoderType::H264);
|
||||
}
|
||||
|
||||
// H265/VP8/VP9 - hardware only
|
||||
for format in [
|
||||
VideoEncoderType::H265,
|
||||
VideoEncoderType::VP8,
|
||||
VideoEncoderType::VP9,
|
||||
] {
|
||||
if self.is_format_available(format, true) {
|
||||
formats.push(format);
|
||||
}
|
||||
}
|
||||
|
||||
formats
|
||||
VideoEncoderType::ordered()
|
||||
.into_iter()
|
||||
.filter(|format| self.is_codec_available(*format))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get detection resolution
|
||||
@@ -534,11 +542,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hardware_only_requirement() {
|
||||
assert!(!VideoEncoderType::H264.hardware_only());
|
||||
assert!(VideoEncoderType::H265.hardware_only());
|
||||
assert!(VideoEncoderType::VP8.hardware_only());
|
||||
assert!(VideoEncoderType::VP9.hardware_only());
|
||||
fn test_codec_ordering() {
|
||||
assert_eq!(
|
||||
VideoEncoderType::ordered(),
|
||||
[
|
||||
VideoEncoderType::H264,
|
||||
VideoEncoderType::H265,
|
||||
VideoEncoderType::VP8,
|
||||
VideoEncoderType::VP9,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
335
src/video/encoder/self_check.rs
Normal file
335
src/video/encoder/self_check.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
use serde::Serialize;
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{
|
||||
EncoderRegistry, H264Config, H264Encoder, H265Config, H265Encoder, VP8Config, VP8Encoder,
|
||||
VP9Config, VP9Encoder, VideoEncoderType,
|
||||
};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::video::format::{PixelFormat, Resolution};
|
||||
|
||||
const SELF_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const SELF_CHECK_FRAME_ATTEMPTS: u64 = 3;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct VideoEncoderSelfCheckCodec {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct VideoEncoderSelfCheckCell {
|
||||
pub codec_id: &'static str,
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub elapsed_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct VideoEncoderSelfCheckRow {
|
||||
pub resolution_id: &'static str,
|
||||
pub resolution_label: &'static str,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub cells: Vec<VideoEncoderSelfCheckCell>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct VideoEncoderSelfCheckResponse {
|
||||
pub current_hardware_encoder: String,
|
||||
pub codecs: Vec<VideoEncoderSelfCheckCodec>,
|
||||
pub rows: Vec<VideoEncoderSelfCheckRow>,
|
||||
}
|
||||
|
||||
pub fn run_hardware_self_check() -> VideoEncoderSelfCheckResponse {
|
||||
let registry = EncoderRegistry::global();
|
||||
let codecs = codec_columns();
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for (resolution_id, resolution_label, resolution) in test_resolutions() {
|
||||
let mut cells = Vec::new();
|
||||
|
||||
for codec in test_codecs() {
|
||||
let cell = match registry.best_encoder(codec, true) {
|
||||
Some(encoder) => run_single_check(codec, resolution, encoder.codec_name.clone()),
|
||||
None => unsupported_cell(codec),
|
||||
};
|
||||
|
||||
cells.push(cell);
|
||||
}
|
||||
|
||||
rows.push(VideoEncoderSelfCheckRow {
|
||||
resolution_id,
|
||||
resolution_label,
|
||||
width: resolution.width,
|
||||
height: resolution.height,
|
||||
cells,
|
||||
});
|
||||
}
|
||||
|
||||
VideoEncoderSelfCheckResponse {
|
||||
current_hardware_encoder: current_hardware_encoder(registry),
|
||||
codecs,
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_hardware_self_check_runtime_error() -> VideoEncoderSelfCheckResponse {
|
||||
let codecs = codec_columns();
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for (resolution_id, resolution_label, resolution) in test_resolutions() {
|
||||
let cells = test_codecs()
|
||||
.into_iter()
|
||||
.map(|codec| VideoEncoderSelfCheckCell {
|
||||
codec_id: codec_id(codec),
|
||||
ok: false,
|
||||
elapsed_ms: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
rows.push(VideoEncoderSelfCheckRow {
|
||||
resolution_id,
|
||||
resolution_label,
|
||||
width: resolution.width,
|
||||
height: resolution.height,
|
||||
cells,
|
||||
});
|
||||
}
|
||||
|
||||
VideoEncoderSelfCheckResponse {
|
||||
current_hardware_encoder: "None".to_string(),
|
||||
codecs,
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
fn codec_columns() -> Vec<VideoEncoderSelfCheckCodec> {
|
||||
test_codecs()
|
||||
.into_iter()
|
||||
.map(|codec| VideoEncoderSelfCheckCodec {
|
||||
id: codec_id(codec),
|
||||
name: match codec {
|
||||
VideoEncoderType::H265 => "H.265",
|
||||
_ => codec.display_name(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn test_codecs() -> [VideoEncoderType; 4] {
|
||||
[
|
||||
VideoEncoderType::H264,
|
||||
VideoEncoderType::H265,
|
||||
VideoEncoderType::VP8,
|
||||
VideoEncoderType::VP9,
|
||||
]
|
||||
}
|
||||
|
||||
fn test_resolutions() -> [(&'static str, &'static str, Resolution); 4] {
|
||||
[
|
||||
("720p", "720p", Resolution::HD720),
|
||||
("1080p", "1080p", Resolution::HD1080),
|
||||
("2k", "2K", Resolution::new(2560, 1440)),
|
||||
("4k", "4K", Resolution::UHD4K),
|
||||
]
|
||||
}
|
||||
|
||||
fn codec_id(codec: VideoEncoderType) -> &'static str {
|
||||
match codec {
|
||||
VideoEncoderType::H264 => "h264",
|
||||
VideoEncoderType::H265 => "h265",
|
||||
VideoEncoderType::VP8 => "vp8",
|
||||
VideoEncoderType::VP9 => "vp9",
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_cell(codec: VideoEncoderType) -> VideoEncoderSelfCheckCell {
|
||||
VideoEncoderSelfCheckCell {
|
||||
codec_id: codec_id(codec),
|
||||
ok: false,
|
||||
elapsed_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_single_check(
|
||||
codec: VideoEncoderType,
|
||||
resolution: Resolution,
|
||||
codec_name_ffmpeg: String,
|
||||
) -> VideoEncoderSelfCheckCell {
|
||||
let started = Instant::now();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let thread_codec_name = codec_name_ffmpeg.clone();
|
||||
|
||||
let spawn_result = std::thread::Builder::new()
|
||||
.name(format!(
|
||||
"encoder-self-check-{}-{}x{}",
|
||||
codec_id(codec),
|
||||
resolution.width,
|
||||
resolution.height
|
||||
))
|
||||
.spawn(move || {
|
||||
let _ = tx.send(run_smoke_test(codec, resolution, &thread_codec_name));
|
||||
});
|
||||
|
||||
if let Err(e) = spawn_result {
|
||||
let _ = e;
|
||||
return VideoEncoderSelfCheckCell {
|
||||
codec_id: codec_id(codec),
|
||||
ok: false,
|
||||
elapsed_ms: Some(started.elapsed().as_millis() as u64),
|
||||
};
|
||||
}
|
||||
|
||||
match rx.recv_timeout(SELF_CHECK_TIMEOUT) {
|
||||
Ok(Ok(())) => VideoEncoderSelfCheckCell {
|
||||
codec_id: codec_id(codec),
|
||||
ok: true,
|
||||
elapsed_ms: Some(started.elapsed().as_millis() as u64),
|
||||
},
|
||||
Ok(Err(_)) => VideoEncoderSelfCheckCell {
|
||||
codec_id: codec_id(codec),
|
||||
ok: false,
|
||||
elapsed_ms: Some(started.elapsed().as_millis() as u64),
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => VideoEncoderSelfCheckCell {
|
||||
codec_id: codec_id(codec),
|
||||
ok: false,
|
||||
elapsed_ms: Some(started.elapsed().as_millis() as u64),
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => VideoEncoderSelfCheckCell {
|
||||
codec_id: codec_id(codec),
|
||||
ok: false,
|
||||
elapsed_ms: Some(started.elapsed().as_millis() as u64),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn current_hardware_encoder(registry: &EncoderRegistry) -> String {
|
||||
let backends = registry
|
||||
.available_backends()
|
||||
.into_iter()
|
||||
.filter(|backend| backend.is_hardware())
|
||||
.map(|backend| backend.display_name().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if backends.is_empty() {
|
||||
"None".to_string()
|
||||
} else {
|
||||
backends.join("/")
|
||||
}
|
||||
}
|
||||
|
||||
fn run_smoke_test(
|
||||
codec: VideoEncoderType,
|
||||
resolution: Resolution,
|
||||
codec_name_ffmpeg: &str,
|
||||
) -> Result<()> {
|
||||
match codec {
|
||||
VideoEncoderType::H264 => run_h264_smoke_test(resolution, codec_name_ffmpeg),
|
||||
VideoEncoderType::H265 => run_h265_smoke_test(resolution, codec_name_ffmpeg),
|
||||
VideoEncoderType::VP8 => run_vp8_smoke_test(resolution, codec_name_ffmpeg),
|
||||
VideoEncoderType::VP9 => run_vp9_smoke_test(resolution, codec_name_ffmpeg),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_h264_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
|
||||
let mut encoder = H264Encoder::with_codec(
|
||||
H264Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
|
||||
codec_name_ffmpeg,
|
||||
)?;
|
||||
encoder.request_keyframe();
|
||||
let frame = build_nv12_test_frame(resolution, encoder.yuv_info().2 as usize);
|
||||
|
||||
for sequence in 0..SELF_CHECK_FRAME_ATTEMPTS {
|
||||
let frames = encoder.encode_raw(&frame, pts_ms(sequence))?;
|
||||
if frames.iter().any(|frame| !frame.data.is_empty()) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(AppError::VideoError(
|
||||
"Encoder produced no output after multiple frames".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn run_h265_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
|
||||
let mut encoder = H265Encoder::with_codec(
|
||||
H265Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
|
||||
codec_name_ffmpeg,
|
||||
)?;
|
||||
encoder.request_keyframe();
|
||||
let frame = build_nv12_test_frame(resolution, encoder.buffer_info().2 as usize);
|
||||
|
||||
for sequence in 0..SELF_CHECK_FRAME_ATTEMPTS {
|
||||
let frames = encoder.encode_raw(&frame, pts_ms(sequence))?;
|
||||
if frames.iter().any(|frame| !frame.data.is_empty()) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(AppError::VideoError(
|
||||
"Encoder produced no output after multiple frames".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn run_vp8_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
|
||||
let mut encoder = VP8Encoder::with_codec(
|
||||
VP8Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
|
||||
codec_name_ffmpeg,
|
||||
)?;
|
||||
let frame = build_nv12_test_frame(resolution, encoder.buffer_info().2 as usize);
|
||||
|
||||
for sequence in 0..SELF_CHECK_FRAME_ATTEMPTS {
|
||||
let frames = encoder.encode_raw(&frame, pts_ms(sequence))?;
|
||||
if frames.iter().any(|frame| !frame.data.is_empty()) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(AppError::VideoError(
|
||||
"Encoder produced no output after multiple frames".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn run_vp9_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
|
||||
let mut encoder = VP9Encoder::with_codec(
|
||||
VP9Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
|
||||
codec_name_ffmpeg,
|
||||
)?;
|
||||
let frame = build_nv12_test_frame(resolution, encoder.buffer_info().2 as usize);
|
||||
|
||||
for sequence in 0..SELF_CHECK_FRAME_ATTEMPTS {
|
||||
let frames = encoder.encode_raw(&frame, pts_ms(sequence))?;
|
||||
if frames.iter().any(|frame| !frame.data.is_empty()) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(AppError::VideoError(
|
||||
"Encoder produced no output after multiple frames".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_nv12_test_frame(resolution: Resolution, buffer_length: usize) -> Vec<u8> {
|
||||
let minimum_length = PixelFormat::Nv12.frame_size(resolution).unwrap_or(0);
|
||||
let mut frame = vec![0x80; buffer_length.max(minimum_length)];
|
||||
let y_plane_len = (resolution.width * resolution.height) as usize;
|
||||
let fill_len = y_plane_len.min(frame.len());
|
||||
frame[..fill_len].fill(0x10);
|
||||
frame
|
||||
}
|
||||
|
||||
fn bitrate_kbps_for_resolution(resolution: Resolution) -> u32 {
|
||||
match resolution.width {
|
||||
0..=1280 => 4_000,
|
||||
1281..=1920 => 8_000,
|
||||
1921..=2560 => 12_000,
|
||||
_ => 20_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn pts_ms(sequence: u64) -> i64 {
|
||||
((sequence * 1000) / 30) as i64
|
||||
}
|
||||
@@ -11,10 +11,11 @@ use std::sync::Once;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use hwcodec::common::{DataFormat, Quality, RateControl};
|
||||
use hwcodec::ffmpeg::AVPixelFormat;
|
||||
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
|
||||
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||
|
||||
use super::detect_best_codec_for_format;
|
||||
use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
||||
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
|
||||
use crate::error::{AppError, Result};
|
||||
@@ -132,7 +133,7 @@ pub fn get_available_vp8_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
||||
mc_name: None,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
|
||||
align: 1,
|
||||
fps: 30,
|
||||
gop: 30,
|
||||
@@ -156,32 +157,24 @@ pub fn get_available_vp8_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
||||
pub fn detect_best_vp8_encoder(width: u32, height: u32) -> (VP8EncoderType, Option<String>) {
|
||||
let encoders = get_available_vp8_encoders(width, height);
|
||||
|
||||
if encoders.is_empty() {
|
||||
warn!("No VP8 encoders available");
|
||||
return (VP8EncoderType::None, None);
|
||||
}
|
||||
|
||||
// Prefer hardware encoders (VAAPI) over software (libvpx)
|
||||
let codec = encoders
|
||||
.iter()
|
||||
.find(|e| e.name.contains("vaapi"))
|
||||
.or_else(|| encoders.first())
|
||||
.unwrap();
|
||||
|
||||
let encoder_type = if codec.name.contains("vaapi") {
|
||||
VP8EncoderType::Vaapi
|
||||
if let Some((encoder_type, codec_name)) =
|
||||
detect_best_codec_for_format(&encoders, DataFormat::VP8, |codec| {
|
||||
codec.name.contains("vaapi")
|
||||
})
|
||||
{
|
||||
info!("Selected VP8 encoder: {} ({})", codec_name, encoder_type);
|
||||
(encoder_type, Some(codec_name))
|
||||
} else {
|
||||
VP8EncoderType::Software // Default to software for unknown
|
||||
};
|
||||
|
||||
info!("Selected VP8 encoder: {} ({})", codec.name, encoder_type);
|
||||
(encoder_type, Some(codec.name.clone()))
|
||||
warn!("No VP8 encoders available");
|
||||
(VP8EncoderType::None, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if VP8 hardware encoding is available
|
||||
pub fn is_vp8_available() -> bool {
|
||||
let registry = EncoderRegistry::global();
|
||||
registry.is_format_available(VideoEncoderType::VP8, true)
|
||||
registry.is_codec_available(VideoEncoderType::VP8)
|
||||
}
|
||||
|
||||
/// Encoded frame from hwcodec (cloned for ownership)
|
||||
@@ -192,7 +185,7 @@ pub struct HwEncodeFrame {
|
||||
pub key: i32,
|
||||
}
|
||||
|
||||
/// VP8 encoder using hwcodec (hardware only - VAAPI)
|
||||
/// VP8 encoder using hwcodec
|
||||
pub struct VP8Encoder {
|
||||
/// hwcodec encoder instance
|
||||
inner: HwEncoder,
|
||||
@@ -251,16 +244,25 @@ impl VP8Encoder {
|
||||
let height = config.base.resolution.height;
|
||||
|
||||
// Software encoders (libvpx) require YUV420P, hardware (VAAPI) uses NV12
|
||||
let (pixfmt, actual_input_format) = if is_software {
|
||||
(AVPixelFormat::AV_PIX_FMT_YUV420P, VP8InputFormat::Yuv420p)
|
||||
let (pixfmt_name, pixfmt_fallback, actual_input_format) = if is_software {
|
||||
(
|
||||
"yuv420p",
|
||||
AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||
VP8InputFormat::Yuv420p,
|
||||
)
|
||||
} else {
|
||||
match config.input_format {
|
||||
VP8InputFormat::Nv12 => (AVPixelFormat::AV_PIX_FMT_NV12, VP8InputFormat::Nv12),
|
||||
VP8InputFormat::Yuv420p => {
|
||||
(AVPixelFormat::AV_PIX_FMT_YUV420P, VP8InputFormat::Yuv420p)
|
||||
VP8InputFormat::Nv12 => {
|
||||
("nv12", AVPixelFormat::AV_PIX_FMT_NV12, VP8InputFormat::Nv12)
|
||||
}
|
||||
VP8InputFormat::Yuv420p => (
|
||||
"yuv420p",
|
||||
AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||
VP8InputFormat::Yuv420p,
|
||||
),
|
||||
}
|
||||
};
|
||||
let pixfmt = resolve_pixel_format(pixfmt_name, pixfmt_fallback);
|
||||
|
||||
info!(
|
||||
"Creating VP8 encoder: {} at {}x{} @ {} kbps (input: {:?})",
|
||||
|
||||
@@ -11,10 +11,11 @@ use std::sync::Once;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use hwcodec::common::{DataFormat, Quality, RateControl};
|
||||
use hwcodec::ffmpeg::AVPixelFormat;
|
||||
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
|
||||
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||
|
||||
use super::detect_best_codec_for_format;
|
||||
use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
||||
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
|
||||
use crate::error::{AppError, Result};
|
||||
@@ -132,7 +133,7 @@ pub fn get_available_vp9_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
||||
mc_name: None,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
|
||||
align: 1,
|
||||
fps: 30,
|
||||
gop: 30,
|
||||
@@ -156,32 +157,24 @@ pub fn get_available_vp9_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
||||
pub fn detect_best_vp9_encoder(width: u32, height: u32) -> (VP9EncoderType, Option<String>) {
|
||||
let encoders = get_available_vp9_encoders(width, height);
|
||||
|
||||
if encoders.is_empty() {
|
||||
warn!("No VP9 encoders available");
|
||||
return (VP9EncoderType::None, None);
|
||||
}
|
||||
|
||||
// Prefer hardware encoders (VAAPI) over software (libvpx-vp9)
|
||||
let codec = encoders
|
||||
.iter()
|
||||
.find(|e| e.name.contains("vaapi"))
|
||||
.or_else(|| encoders.first())
|
||||
.unwrap();
|
||||
|
||||
let encoder_type = if codec.name.contains("vaapi") {
|
||||
VP9EncoderType::Vaapi
|
||||
if let Some((encoder_type, codec_name)) =
|
||||
detect_best_codec_for_format(&encoders, DataFormat::VP9, |codec| {
|
||||
codec.name.contains("vaapi")
|
||||
})
|
||||
{
|
||||
info!("Selected VP9 encoder: {} ({})", codec_name, encoder_type);
|
||||
(encoder_type, Some(codec_name))
|
||||
} else {
|
||||
VP9EncoderType::Software // Default to software for unknown
|
||||
};
|
||||
|
||||
info!("Selected VP9 encoder: {} ({})", codec.name, encoder_type);
|
||||
(encoder_type, Some(codec.name.clone()))
|
||||
warn!("No VP9 encoders available");
|
||||
(VP9EncoderType::None, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if VP9 hardware encoding is available
|
||||
pub fn is_vp9_available() -> bool {
|
||||
let registry = EncoderRegistry::global();
|
||||
registry.is_format_available(VideoEncoderType::VP9, true)
|
||||
registry.is_codec_available(VideoEncoderType::VP9)
|
||||
}
|
||||
|
||||
/// Encoded frame from hwcodec (cloned for ownership)
|
||||
@@ -192,7 +185,7 @@ pub struct HwEncodeFrame {
|
||||
pub key: i32,
|
||||
}
|
||||
|
||||
/// VP9 encoder using hwcodec (hardware only - VAAPI)
|
||||
/// VP9 encoder using hwcodec
|
||||
pub struct VP9Encoder {
|
||||
/// hwcodec encoder instance
|
||||
inner: HwEncoder,
|
||||
@@ -251,16 +244,25 @@ impl VP9Encoder {
|
||||
let height = config.base.resolution.height;
|
||||
|
||||
// Software encoders (libvpx-vp9) require YUV420P, hardware (VAAPI) uses NV12
|
||||
let (pixfmt, actual_input_format) = if is_software {
|
||||
(AVPixelFormat::AV_PIX_FMT_YUV420P, VP9InputFormat::Yuv420p)
|
||||
let (pixfmt_name, pixfmt_fallback, actual_input_format) = if is_software {
|
||||
(
|
||||
"yuv420p",
|
||||
AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||
VP9InputFormat::Yuv420p,
|
||||
)
|
||||
} else {
|
||||
match config.input_format {
|
||||
VP9InputFormat::Nv12 => (AVPixelFormat::AV_PIX_FMT_NV12, VP9InputFormat::Nv12),
|
||||
VP9InputFormat::Yuv420p => {
|
||||
(AVPixelFormat::AV_PIX_FMT_YUV420P, VP9InputFormat::Yuv420p)
|
||||
VP9InputFormat::Nv12 => {
|
||||
("nv12", AVPixelFormat::AV_PIX_FMT_NV12, VP9InputFormat::Nv12)
|
||||
}
|
||||
VP9InputFormat::Yuv420p => (
|
||||
"yuv420p",
|
||||
AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||
VP9InputFormat::Yuv420p,
|
||||
),
|
||||
}
|
||||
};
|
||||
let pixfmt = resolve_pixel_format(pixfmt_name, pixfmt_fallback);
|
||||
|
||||
info!(
|
||||
"Creating VP9 encoder: {} at {}x{} @ {} kbps (input: {:?})",
|
||||
|
||||
@@ -1,444 +0,0 @@
|
||||
//! H264 video encoding pipeline for WebRTC streaming
|
||||
//!
|
||||
//! This module provides a complete H264 encoding pipeline that connects:
|
||||
//! 1. Video capture (YUYV/MJPEG from V4L2)
|
||||
//! 2. Pixel conversion (YUYV → YUV420P) or JPEG decode
|
||||
//! 3. H264 encoding (via hwcodec)
|
||||
//! 4. RTP packetization and WebRTC track output
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::{broadcast, watch, Mutex};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::video::convert::Nv12Converter;
|
||||
use crate::video::encoder::h264::{H264Config, H264Encoder};
|
||||
use crate::video::format::{PixelFormat, Resolution};
|
||||
use crate::webrtc::rtp::{H264VideoTrack, H264VideoTrackConfig};
|
||||
|
||||
/// H264 pipeline configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct H264PipelineConfig {
|
||||
/// Input resolution
|
||||
pub resolution: Resolution,
|
||||
/// Input pixel format (YUYV, NV12, etc.)
|
||||
pub input_format: PixelFormat,
|
||||
/// Target bitrate in kbps
|
||||
pub bitrate_kbps: u32,
|
||||
/// Target FPS
|
||||
pub fps: u32,
|
||||
/// GOP size (keyframe interval in frames)
|
||||
pub gop_size: u32,
|
||||
/// Track ID for WebRTC
|
||||
pub track_id: String,
|
||||
/// Stream ID for WebRTC
|
||||
pub stream_id: String,
|
||||
}
|
||||
|
||||
impl Default for H264PipelineConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
resolution: Resolution::HD720,
|
||||
input_format: PixelFormat::Yuyv,
|
||||
bitrate_kbps: 8000,
|
||||
fps: 30,
|
||||
gop_size: 30,
|
||||
track_id: "video0".to_string(),
|
||||
stream_id: "one-kvm-stream".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// H264 pipeline statistics
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct H264PipelineStats {
|
||||
/// Current encoding FPS
|
||||
pub current_fps: f32,
|
||||
}
|
||||
|
||||
/// H264 video encoding pipeline
|
||||
pub struct H264Pipeline {
|
||||
config: H264PipelineConfig,
|
||||
/// H264 encoder instance
|
||||
encoder: Arc<Mutex<Option<H264Encoder>>>,
|
||||
/// NV12 converter (for BGR24/RGB24/YUYV → NV12)
|
||||
nv12_converter: Arc<Mutex<Option<Nv12Converter>>>,
|
||||
/// WebRTC video track
|
||||
video_track: Arc<H264VideoTrack>,
|
||||
/// Pipeline statistics
|
||||
stats: Arc<Mutex<H264PipelineStats>>,
|
||||
/// Running state
|
||||
running: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl H264Pipeline {
|
||||
/// Create a new H264 pipeline
|
||||
pub fn new(config: H264PipelineConfig) -> Result<Self> {
|
||||
info!(
|
||||
"Creating H264 pipeline: {}x{} @ {} kbps, {} fps",
|
||||
config.resolution.width, config.resolution.height, config.bitrate_kbps, config.fps
|
||||
);
|
||||
|
||||
// Determine encoder input format based on pipeline input
|
||||
// NV12 is optimal for VAAPI, use it for all formats
|
||||
// VAAPI encoders typically only support NV12 input
|
||||
let encoder_input_format = crate::video::encoder::h264::H264InputFormat::Nv12;
|
||||
|
||||
// Create H264 encoder with appropriate input format
|
||||
let encoder_config = H264Config {
|
||||
base: crate::video::encoder::traits::EncoderConfig::h264(
|
||||
config.resolution,
|
||||
config.bitrate_kbps,
|
||||
),
|
||||
bitrate_kbps: config.bitrate_kbps,
|
||||
gop_size: config.gop_size,
|
||||
fps: config.fps,
|
||||
input_format: encoder_input_format,
|
||||
};
|
||||
|
||||
let encoder = H264Encoder::new(encoder_config)?;
|
||||
info!(
|
||||
"H264 encoder created: {} ({}) with {:?} input",
|
||||
encoder.codec_name(),
|
||||
encoder.encoder_type(),
|
||||
encoder_input_format
|
||||
);
|
||||
|
||||
// Create NV12 converter based on input format
|
||||
// All formats are converted to NV12 for VAAPI encoder
|
||||
let nv12_converter = match config.input_format {
|
||||
// NV12 input - direct passthrough
|
||||
PixelFormat::Nv12 => {
|
||||
info!("NV12 input: direct passthrough to encoder");
|
||||
None
|
||||
}
|
||||
|
||||
// YUYV (4:2:2 packed) → NV12
|
||||
PixelFormat::Yuyv => {
|
||||
info!("YUYV input: converting to NV12");
|
||||
Some(Nv12Converter::yuyv_to_nv12(config.resolution))
|
||||
}
|
||||
|
||||
// RGB24 → NV12
|
||||
PixelFormat::Rgb24 => {
|
||||
info!("RGB24 input: converting to NV12");
|
||||
Some(Nv12Converter::rgb24_to_nv12(config.resolution))
|
||||
}
|
||||
|
||||
// BGR24 → NV12
|
||||
PixelFormat::Bgr24 => {
|
||||
info!("BGR24 input: converting to NV12");
|
||||
Some(Nv12Converter::bgr24_to_nv12(config.resolution))
|
||||
}
|
||||
|
||||
// MJPEG/JPEG input - not supported (requires libjpeg for decoding)
|
||||
PixelFormat::Mjpeg | PixelFormat::Jpeg => {
|
||||
return Err(AppError::VideoError(
|
||||
"MJPEG input format not supported in this build".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
_ => {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"Unsupported input format for H264 pipeline: {}",
|
||||
config.input_format
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// Create WebRTC video track
|
||||
let track_config = H264VideoTrackConfig {
|
||||
track_id: config.track_id.clone(),
|
||||
stream_id: config.stream_id.clone(),
|
||||
resolution: config.resolution,
|
||||
bitrate_kbps: config.bitrate_kbps,
|
||||
fps: config.fps,
|
||||
profile_level_id: None, // Let browser negotiate the best profile
|
||||
};
|
||||
let video_track = Arc::new(H264VideoTrack::new(track_config));
|
||||
|
||||
let (running_tx, _) = watch::channel(false);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
encoder: Arc::new(Mutex::new(Some(encoder))),
|
||||
nv12_converter: Arc::new(Mutex::new(nv12_converter)),
|
||||
video_track,
|
||||
stats: Arc::new(Mutex::new(H264PipelineStats::default())),
|
||||
running: running_tx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the WebRTC video track
|
||||
pub fn video_track(&self) -> Arc<H264VideoTrack> {
|
||||
self.video_track.clone()
|
||||
}
|
||||
|
||||
/// Get current statistics
|
||||
pub async fn stats(&self) -> H264PipelineStats {
|
||||
self.stats.lock().await.clone()
|
||||
}
|
||||
|
||||
/// Check if pipeline is running
|
||||
pub fn is_running(&self) -> bool {
|
||||
*self.running.borrow()
|
||||
}
|
||||
|
||||
/// Start the encoding pipeline
|
||||
///
|
||||
/// This starts a background task that receives raw frames from the receiver,
|
||||
/// encodes them to H264, and sends them to the WebRTC track.
|
||||
pub async fn start(&self, mut frame_rx: broadcast::Receiver<Vec<u8>>) {
|
||||
if *self.running.borrow() {
|
||||
warn!("H264 pipeline already running");
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = self.running.send(true);
|
||||
info!(
|
||||
"Starting H264 pipeline (input format: {})",
|
||||
self.config.input_format
|
||||
);
|
||||
|
||||
let encoder = self.encoder.lock().await.take();
|
||||
let nv12_converter = self.nv12_converter.lock().await.take();
|
||||
let video_track = self.video_track.clone();
|
||||
let stats = self.stats.clone();
|
||||
let config = self.config.clone();
|
||||
let mut running_rx = self.running.subscribe();
|
||||
|
||||
// Spawn encoding task
|
||||
tokio::spawn(async move {
|
||||
let mut encoder = match encoder {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
error!("No encoder available");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut nv12_converter = nv12_converter;
|
||||
let mut frame_count: u64 = 0;
|
||||
let mut last_fps_time = Instant::now();
|
||||
let mut fps_frame_count: u64 = 0;
|
||||
|
||||
// Flag for one-time warnings
|
||||
let mut size_mismatch_warned = false;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
|
||||
_ = running_rx.changed() => {
|
||||
if !*running_rx.borrow() {
|
||||
info!("H264 pipeline stopping");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
result = frame_rx.recv() => {
|
||||
match result {
|
||||
Ok(raw_frame) => {
|
||||
let start = Instant::now();
|
||||
|
||||
// Validate frame size for uncompressed formats
|
||||
if let Some(expected_size) = config.input_format.frame_size(config.resolution) {
|
||||
if raw_frame.len() != expected_size && !size_mismatch_warned {
|
||||
warn!(
|
||||
"Frame size mismatch: got {} bytes, expected {} for {} {}x{}",
|
||||
raw_frame.len(),
|
||||
expected_size,
|
||||
config.input_format,
|
||||
config.resolution.width,
|
||||
config.resolution.height
|
||||
);
|
||||
size_mismatch_warned = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to NV12 for VAAPI encoder
|
||||
// BGR24/RGB24/YUYV -> NV12 (via NV12 converter)
|
||||
// NV12 -> pass through
|
||||
//
|
||||
// Optimized: avoid unnecessary allocations and copies
|
||||
frame_count += 1;
|
||||
fps_frame_count += 1;
|
||||
let pts_ms = (frame_count * 1000 / config.fps as u64) as i64;
|
||||
|
||||
let encode_result = if let Some(ref mut conv) = nv12_converter {
|
||||
// BGR24/RGB24/YUYV input - convert to NV12
|
||||
// Optimized: pass reference directly without copy
|
||||
match conv.convert(&raw_frame) {
|
||||
Ok(nv12_data) => encoder.encode_raw(nv12_data, pts_ms),
|
||||
Err(e) => {
|
||||
error!("NV12 conversion failed: {}", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// NV12 input - pass reference directly
|
||||
encoder.encode_raw(&raw_frame, pts_ms)
|
||||
};
|
||||
|
||||
match encode_result {
|
||||
Ok(frames) => {
|
||||
if !frames.is_empty() {
|
||||
let frame = &frames[0];
|
||||
let is_keyframe = frame.key == 1;
|
||||
|
||||
// Send to WebRTC track
|
||||
let duration = Duration::from_millis(
|
||||
1000 / config.fps as u64
|
||||
);
|
||||
|
||||
if let Err(e) = video_track
|
||||
.write_frame(&frame.data, duration, is_keyframe)
|
||||
.await
|
||||
{
|
||||
error!("Failed to write frame to track: {}", e);
|
||||
} else {
|
||||
let _ = start;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Encoding failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Update FPS every second
|
||||
if last_fps_time.elapsed() >= Duration::from_secs(1) {
|
||||
let mut s = stats.lock().await;
|
||||
s.current_fps = fps_frame_count as f32
|
||||
/ last_fps_time.elapsed().as_secs_f32();
|
||||
fps_frame_count = 0;
|
||||
last_fps_time = Instant::now();
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
let _ = n;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
info!("Frame channel closed, stopping H264 pipeline");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("H264 pipeline task exited");
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop the encoding pipeline
|
||||
pub fn stop(&self) {
|
||||
if *self.running.borrow() {
|
||||
let _ = self.running.send(false);
|
||||
info!("Stopping H264 pipeline");
|
||||
}
|
||||
}
|
||||
|
||||
/// Request a keyframe (force IDR)
|
||||
pub async fn request_keyframe(&self) {
|
||||
// Note: hwcodec doesn't support on-demand keyframe requests
|
||||
// The encoder will produce keyframes based on GOP size
|
||||
debug!("Keyframe requested (will occur at next GOP boundary)");
|
||||
}
|
||||
|
||||
/// Update bitrate dynamically
|
||||
pub async fn set_bitrate(&self, bitrate_kbps: u32) -> Result<()> {
|
||||
if let Some(ref mut encoder) = *self.encoder.lock().await {
|
||||
encoder.set_bitrate(bitrate_kbps)?;
|
||||
info!("H264 pipeline bitrate updated to {} kbps", bitrate_kbps);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for H264 pipeline configuration
|
||||
pub struct H264PipelineBuilder {
|
||||
config: H264PipelineConfig,
|
||||
}
|
||||
|
||||
impl H264PipelineBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: H264PipelineConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolution(mut self, resolution: Resolution) -> Self {
|
||||
self.config.resolution = resolution;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn input_format(mut self, format: PixelFormat) -> Self {
|
||||
self.config.input_format = format;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bitrate_kbps(mut self, bitrate: u32) -> Self {
|
||||
self.config.bitrate_kbps = bitrate;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn fps(mut self, fps: u32) -> Self {
|
||||
self.config.fps = fps;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn gop_size(mut self, gop: u32) -> Self {
|
||||
self.config.gop_size = gop;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn track_id(mut self, id: &str) -> Self {
|
||||
self.config.track_id = id.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn stream_id(mut self, id: &str) -> Self {
|
||||
self.config.stream_id = id.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<H264Pipeline> {
|
||||
H264Pipeline::new(self.config)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for H264PipelineBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_pipeline_config_default() {
|
||||
let config = H264PipelineConfig::default();
|
||||
assert_eq!(config.resolution, Resolution::HD720);
|
||||
assert_eq!(config.bitrate_kbps, 8000);
|
||||
assert_eq!(config.fps, 30);
|
||||
assert_eq!(config.gop_size, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pipeline_builder() {
|
||||
let builder = H264PipelineBuilder::new()
|
||||
.resolution(Resolution::HD1080)
|
||||
.bitrate_kbps(4000)
|
||||
.fps(60)
|
||||
.input_format(PixelFormat::Yuyv);
|
||||
|
||||
assert_eq!(builder.config.resolution, Resolution::HD1080);
|
||||
assert_eq!(builder.config.bitrate_kbps, 4000);
|
||||
assert_eq!(builder.config.fps, 60);
|
||||
assert_eq!(builder.config.input_format, PixelFormat::Yuyv);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
//!
|
||||
//! This module provides V4L2 video capture, encoding, and streaming functionality.
|
||||
|
||||
pub mod capture;
|
||||
pub mod codec_constraints;
|
||||
pub mod convert;
|
||||
pub mod decoder;
|
||||
@@ -10,25 +9,26 @@ pub mod device;
|
||||
pub mod encoder;
|
||||
pub mod format;
|
||||
pub mod frame;
|
||||
pub mod h264_pipeline;
|
||||
pub mod shared_video_pipeline;
|
||||
pub mod stream_manager;
|
||||
pub mod streamer;
|
||||
pub mod v4l2r_capture;
|
||||
pub mod video_session;
|
||||
|
||||
pub use capture::VideoCapturer;
|
||||
pub use convert::{PixelConverter, Yuv420pBuffer};
|
||||
pub use device::{VideoDevice, VideoDeviceInfo};
|
||||
pub use encoder::{H264Encoder, H264EncoderType, JpegEncoder};
|
||||
pub use format::PixelFormat;
|
||||
pub use frame::VideoFrame;
|
||||
pub use h264_pipeline::{H264Pipeline, H264PipelineBuilder, H264PipelineConfig};
|
||||
pub use shared_video_pipeline::{
|
||||
EncodedVideoFrame, SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats,
|
||||
};
|
||||
pub use stream_manager::VideoStreamManager;
|
||||
pub use streamer::{Streamer, StreamerState};
|
||||
pub use video_session::{
|
||||
CodecInfo, VideoSessionInfo, VideoSessionManager, VideoSessionManagerConfig, VideoSessionState,
|
||||
};
|
||||
|
||||
pub(crate) fn is_rk_hdmirx_driver(driver: &str, card: &str) -> bool {
|
||||
driver.eq_ignore_ascii_case("rk_hdmirx") || card.eq_ignore_ascii_case("rk_hdmirx")
|
||||
}
|
||||
|
||||
pub(crate) fn is_rk_hdmirx_device(device: &device::VideoDeviceInfo) -> bool {
|
||||
is_rk_hdmirx_driver(&device.driver, &device.card)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
663
src/video/shared_video_pipeline/encoder_state.rs
Normal file
663
src/video/shared_video_pipeline/encoder_state.rs
Normal file
@@ -0,0 +1,663 @@
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::video::convert::{Nv12Converter, PixelConverter};
|
||||
use crate::video::decoder::MjpegTurboDecoder;
|
||||
use crate::video::encoder::h264::{H264Config, H264Encoder, H264InputFormat};
|
||||
use crate::video::encoder::h265::{H265Config, H265Encoder, H265InputFormat};
|
||||
use crate::video::encoder::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
||||
use crate::video::encoder::traits::EncoderConfig;
|
||||
use crate::video::encoder::vp8::{VP8Config, VP8Encoder};
|
||||
use crate::video::encoder::vp9::{VP9Config, VP9Encoder};
|
||||
use crate::video::format::{PixelFormat, Resolution};
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
use hwcodec::ffmpeg_hw::{
|
||||
last_error_message as ffmpeg_hw_last_error, HwMjpegH26xConfig, HwMjpegH26xPipeline,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
use super::SharedVideoPipelineConfig;
|
||||
|
||||
pub(super) struct EncoderThreadState {
|
||||
pub(super) encoder: Option<Box<dyn VideoEncoderTrait + Send>>,
|
||||
pub(super) mjpeg_decoder: Option<MjpegDecoderKind>,
|
||||
pub(super) nv12_converter: Option<Nv12Converter>,
|
||||
pub(super) yuv420p_converter: Option<PixelConverter>,
|
||||
pub(super) encoder_needs_yuv420p: bool,
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
pub(super) ffmpeg_hw_pipeline: Option<HwMjpegH26xPipeline>,
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
pub(super) ffmpeg_hw_enabled: bool,
|
||||
pub(super) fps: u32,
|
||||
pub(super) codec: VideoEncoderType,
|
||||
pub(super) input_format: PixelFormat,
|
||||
}
|
||||
|
||||
pub(super) trait VideoEncoderTrait: Send {
|
||||
fn encode_raw(&mut self, data: &[u8], pts_ms: i64) -> Result<Vec<EncodedFrame>>;
|
||||
fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()>;
|
||||
fn codec_name(&self) -> &str;
|
||||
fn request_keyframe(&mut self);
|
||||
}
|
||||
|
||||
pub(super) struct EncodedFrame {
|
||||
pub(super) data: Vec<u8>,
|
||||
pub(super) key: i32,
|
||||
}
|
||||
|
||||
struct H264EncoderWrapper(H264Encoder);
|
||||
|
||||
impl VideoEncoderTrait for H264EncoderWrapper {
|
||||
fn encode_raw(&mut self, data: &[u8], pts_ms: i64) -> Result<Vec<EncodedFrame>> {
|
||||
let frames = self.0.encode_raw(data, pts_ms)?;
|
||||
Ok(frames
|
||||
.into_iter()
|
||||
.map(|f| EncodedFrame {
|
||||
data: f.data,
|
||||
key: f.key,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
||||
self.0.set_bitrate(bitrate_kbps)
|
||||
}
|
||||
|
||||
fn codec_name(&self) -> &str {
|
||||
self.0.codec_name()
|
||||
}
|
||||
|
||||
fn request_keyframe(&mut self) {
|
||||
self.0.request_keyframe()
|
||||
}
|
||||
}
|
||||
|
||||
struct H265EncoderWrapper(H265Encoder);
|
||||
|
||||
impl VideoEncoderTrait for H265EncoderWrapper {
|
||||
fn encode_raw(&mut self, data: &[u8], pts_ms: i64) -> Result<Vec<EncodedFrame>> {
|
||||
let frames = self.0.encode_raw(data, pts_ms)?;
|
||||
Ok(frames
|
||||
.into_iter()
|
||||
.map(|f| EncodedFrame {
|
||||
data: f.data,
|
||||
key: f.key,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
||||
self.0.set_bitrate(bitrate_kbps)
|
||||
}
|
||||
|
||||
fn codec_name(&self) -> &str {
|
||||
self.0.codec_name()
|
||||
}
|
||||
|
||||
fn request_keyframe(&mut self) {
|
||||
self.0.request_keyframe()
|
||||
}
|
||||
}
|
||||
|
||||
struct VP8EncoderWrapper(VP8Encoder);
|
||||
|
||||
impl VideoEncoderTrait for VP8EncoderWrapper {
|
||||
fn encode_raw(&mut self, data: &[u8], pts_ms: i64) -> Result<Vec<EncodedFrame>> {
|
||||
let frames = self.0.encode_raw(data, pts_ms)?;
|
||||
Ok(frames
|
||||
.into_iter()
|
||||
.map(|f| EncodedFrame {
|
||||
data: f.data,
|
||||
key: f.key,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
||||
self.0.set_bitrate(bitrate_kbps)
|
||||
}
|
||||
|
||||
fn codec_name(&self) -> &str {
|
||||
self.0.codec_name()
|
||||
}
|
||||
|
||||
fn request_keyframe(&mut self) {}
|
||||
}
|
||||
|
||||
struct VP9EncoderWrapper(VP9Encoder);
|
||||
|
||||
impl VideoEncoderTrait for VP9EncoderWrapper {
|
||||
fn encode_raw(&mut self, data: &[u8], pts_ms: i64) -> Result<Vec<EncodedFrame>> {
|
||||
let frames = self.0.encode_raw(data, pts_ms)?;
|
||||
Ok(frames
|
||||
.into_iter()
|
||||
.map(|f| EncodedFrame {
|
||||
data: f.data,
|
||||
key: f.key,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
||||
self.0.set_bitrate(bitrate_kbps)
|
||||
}
|
||||
|
||||
fn codec_name(&self) -> &str {
|
||||
self.0.codec_name()
|
||||
}
|
||||
|
||||
fn request_keyframe(&mut self) {}
|
||||
}
|
||||
|
||||
pub(super) enum MjpegDecoderKind {
|
||||
Turbo(MjpegTurboDecoder),
|
||||
}
|
||||
|
||||
impl MjpegDecoderKind {
|
||||
pub(super) fn decode(&mut self, data: &[u8]) -> Result<Vec<u8>> {
|
||||
match self {
|
||||
MjpegDecoderKind::Turbo(decoder) => decoder.decode_to_rgb(data),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_encoder_state(
|
||||
config: &SharedVideoPipelineConfig,
|
||||
) -> Result<EncoderThreadState> {
|
||||
let registry = EncoderRegistry::global();
|
||||
|
||||
let get_codec_name =
|
||||
|format: VideoEncoderType, backend: Option<EncoderBackend>| -> Option<String> {
|
||||
match backend {
|
||||
Some(b) => registry
|
||||
.encoder_with_backend(format, b)
|
||||
.map(|e| e.codec_name.clone()),
|
||||
None => registry
|
||||
.best_available_encoder(format)
|
||||
.map(|e| e.codec_name.clone()),
|
||||
}
|
||||
};
|
||||
|
||||
let needs_mjpeg_decode = config.input_format.is_compressed();
|
||||
let is_rkmpp_available = registry
|
||||
.encoder_with_backend(VideoEncoderType::H264, EncoderBackend::Rkmpp)
|
||||
.is_some();
|
||||
let use_yuyv_direct =
|
||||
is_rkmpp_available && !needs_mjpeg_decode && config.input_format == PixelFormat::Yuyv;
|
||||
let use_rkmpp_direct = is_rkmpp_available
|
||||
&& !needs_mjpeg_decode
|
||||
&& matches!(
|
||||
config.input_format,
|
||||
PixelFormat::Yuyv
|
||||
| PixelFormat::Yuv420
|
||||
| PixelFormat::Rgb24
|
||||
| PixelFormat::Bgr24
|
||||
| PixelFormat::Nv12
|
||||
| PixelFormat::Nv16
|
||||
| PixelFormat::Nv21
|
||||
| PixelFormat::Nv24
|
||||
);
|
||||
|
||||
if use_yuyv_direct {
|
||||
info!("RKMPP backend detected with YUYV input, enabling YUYV direct input optimization");
|
||||
} else if use_rkmpp_direct {
|
||||
info!(
|
||||
"RKMPP backend detected with {} input, enabling direct input optimization",
|
||||
config.input_format
|
||||
);
|
||||
}
|
||||
|
||||
let selected_codec_name = match config.output_codec {
|
||||
VideoEncoderType::H264 => {
|
||||
if use_rkmpp_direct {
|
||||
get_codec_name(VideoEncoderType::H264, Some(EncoderBackend::Rkmpp)).ok_or_else(
|
||||
|| AppError::VideoError("RKMPP backend not available for H.264".to_string()),
|
||||
)?
|
||||
} else if let Some(ref backend) = config.encoder_backend {
|
||||
get_codec_name(VideoEncoderType::H264, Some(*backend)).ok_or_else(|| {
|
||||
AppError::VideoError(format!("Backend {:?} does not support H.264", backend))
|
||||
})?
|
||||
} else {
|
||||
get_codec_name(VideoEncoderType::H264, None)
|
||||
.ok_or_else(|| AppError::VideoError("No H.264 encoder available".to_string()))?
|
||||
}
|
||||
}
|
||||
VideoEncoderType::H265 => {
|
||||
if use_rkmpp_direct {
|
||||
get_codec_name(VideoEncoderType::H265, Some(EncoderBackend::Rkmpp)).ok_or_else(
|
||||
|| AppError::VideoError("RKMPP backend not available for H.265".to_string()),
|
||||
)?
|
||||
} else if let Some(ref backend) = config.encoder_backend {
|
||||
get_codec_name(VideoEncoderType::H265, Some(*backend)).ok_or_else(|| {
|
||||
AppError::VideoError(format!("Backend {:?} does not support H.265", backend))
|
||||
})?
|
||||
} else {
|
||||
get_codec_name(VideoEncoderType::H265, None)
|
||||
.ok_or_else(|| AppError::VideoError("No H.265 encoder available".to_string()))?
|
||||
}
|
||||
}
|
||||
VideoEncoderType::VP8 => {
|
||||
if let Some(ref backend) = config.encoder_backend {
|
||||
get_codec_name(VideoEncoderType::VP8, Some(*backend)).ok_or_else(|| {
|
||||
AppError::VideoError(format!("Backend {:?} does not support VP8", backend))
|
||||
})?
|
||||
} else {
|
||||
get_codec_name(VideoEncoderType::VP8, None)
|
||||
.ok_or_else(|| AppError::VideoError("No VP8 encoder available".to_string()))?
|
||||
}
|
||||
}
|
||||
VideoEncoderType::VP9 => {
|
||||
if let Some(ref backend) = config.encoder_backend {
|
||||
get_codec_name(VideoEncoderType::VP9, Some(*backend)).ok_or_else(|| {
|
||||
AppError::VideoError(format!("Backend {:?} does not support VP9", backend))
|
||||
})?
|
||||
} else {
|
||||
get_codec_name(VideoEncoderType::VP9, None)
|
||||
.ok_or_else(|| AppError::VideoError("No VP9 encoder available".to_string()))?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
let is_rkmpp_encoder = selected_codec_name.contains("rkmpp");
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
if needs_mjpeg_decode
|
||||
&& is_rkmpp_encoder
|
||||
&& matches!(
|
||||
config.output_codec,
|
||||
VideoEncoderType::H264 | VideoEncoderType::H265
|
||||
)
|
||||
{
|
||||
info!(
|
||||
"Initializing FFmpeg HW MJPEG->{} pipeline (no fallback)",
|
||||
config.output_codec
|
||||
);
|
||||
let pipeline = HwMjpegH26xPipeline::new(HwMjpegH26xConfig {
|
||||
decoder: "mjpeg_rkmpp".to_string(),
|
||||
encoder: selected_codec_name.clone(),
|
||||
width: config.resolution.width as i32,
|
||||
height: config.resolution.height as i32,
|
||||
fps: config.fps as i32,
|
||||
bitrate_kbps: config.bitrate_kbps() as i32,
|
||||
gop: config.gop_size() as i32,
|
||||
thread_count: 1,
|
||||
})
|
||||
.map_err(|e| {
|
||||
let detail = if e.is_empty() {
|
||||
ffmpeg_hw_last_error()
|
||||
} else {
|
||||
e
|
||||
};
|
||||
AppError::VideoError(format!(
|
||||
"FFmpeg HW MJPEG->{} init failed: {}",
|
||||
config.output_codec, detail
|
||||
))
|
||||
})?;
|
||||
info!("Using FFmpeg HW MJPEG->{} pipeline", config.output_codec);
|
||||
return Ok(EncoderThreadState {
|
||||
encoder: None,
|
||||
mjpeg_decoder: None,
|
||||
nv12_converter: None,
|
||||
yuv420p_converter: None,
|
||||
encoder_needs_yuv420p: false,
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
ffmpeg_hw_pipeline: Some(pipeline),
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
ffmpeg_hw_enabled: true,
|
||||
fps: config.fps,
|
||||
codec: config.output_codec,
|
||||
input_format: config.input_format,
|
||||
});
|
||||
}
|
||||
|
||||
let (mjpeg_decoder, pipeline_input_format) = if needs_mjpeg_decode {
|
||||
info!(
|
||||
"MJPEG input detected, using TurboJPEG decoder ({} -> RGB24)",
|
||||
config.input_format
|
||||
);
|
||||
(
|
||||
Some(MjpegDecoderKind::Turbo(MjpegTurboDecoder::new(
|
||||
config.resolution,
|
||||
)?)),
|
||||
PixelFormat::Rgb24,
|
||||
)
|
||||
} else {
|
||||
(None, config.input_format)
|
||||
};
|
||||
|
||||
let encoder: Box<dyn VideoEncoderTrait + Send> = match config.output_codec {
|
||||
VideoEncoderType::H264 => {
|
||||
let codec_name = selected_codec_name.clone();
|
||||
let direct_input_format = h264_direct_input_format(&codec_name, pipeline_input_format);
|
||||
let input_format = direct_input_format.unwrap_or_else(|| {
|
||||
if codec_name.contains("libx264") {
|
||||
H264InputFormat::Yuv420p
|
||||
} else {
|
||||
H264InputFormat::Nv12
|
||||
}
|
||||
});
|
||||
|
||||
if use_rkmpp_direct {
|
||||
info!(
|
||||
"Creating H264 encoder with RKMPP backend for {} direct input (codec: {})",
|
||||
config.input_format, codec_name
|
||||
);
|
||||
} else if let Some(ref backend) = config.encoder_backend {
|
||||
info!(
|
||||
"Creating H264 encoder with backend {:?} (codec: {})",
|
||||
backend, codec_name
|
||||
);
|
||||
}
|
||||
|
||||
let encoder = H264Encoder::with_codec(
|
||||
H264Config {
|
||||
base: EncoderConfig::h264(config.resolution, config.bitrate_kbps()),
|
||||
bitrate_kbps: config.bitrate_kbps(),
|
||||
gop_size: config.gop_size(),
|
||||
fps: config.fps,
|
||||
input_format,
|
||||
},
|
||||
&codec_name,
|
||||
)?;
|
||||
info!("Created H264 encoder: {}", encoder.codec_name());
|
||||
Box::new(H264EncoderWrapper(encoder))
|
||||
}
|
||||
VideoEncoderType::H265 => {
|
||||
let codec_name = selected_codec_name.clone();
|
||||
let direct_input_format = h265_direct_input_format(&codec_name, pipeline_input_format);
|
||||
let input_format = direct_input_format.unwrap_or_else(|| {
|
||||
if codec_name.contains("libx265") {
|
||||
H265InputFormat::Yuv420p
|
||||
} else {
|
||||
H265InputFormat::Nv12
|
||||
}
|
||||
});
|
||||
|
||||
if use_rkmpp_direct {
|
||||
info!(
|
||||
"Creating H265 encoder with RKMPP backend for {} direct input (codec: {})",
|
||||
config.input_format, codec_name
|
||||
);
|
||||
} else if let Some(ref backend) = config.encoder_backend {
|
||||
info!(
|
||||
"Creating H265 encoder with backend {:?} (codec: {})",
|
||||
backend, codec_name
|
||||
);
|
||||
}
|
||||
|
||||
let encoder = H265Encoder::with_codec(
|
||||
H265Config {
|
||||
base: EncoderConfig {
|
||||
resolution: config.resolution,
|
||||
input_format: config.input_format,
|
||||
quality: config.bitrate_kbps(),
|
||||
fps: config.fps,
|
||||
gop_size: config.gop_size(),
|
||||
},
|
||||
bitrate_kbps: config.bitrate_kbps(),
|
||||
gop_size: config.gop_size(),
|
||||
fps: config.fps,
|
||||
input_format,
|
||||
},
|
||||
&codec_name,
|
||||
)?;
|
||||
info!("Created H265 encoder: {}", encoder.codec_name());
|
||||
Box::new(H265EncoderWrapper(encoder))
|
||||
}
|
||||
VideoEncoderType::VP8 => {
|
||||
let codec_name = selected_codec_name.clone();
|
||||
if let Some(ref backend) = config.encoder_backend {
|
||||
info!(
|
||||
"Creating VP8 encoder with backend {:?} (codec: {})",
|
||||
backend, codec_name
|
||||
);
|
||||
}
|
||||
let encoder = VP8Encoder::with_codec(
|
||||
VP8Config::low_latency(config.resolution, config.bitrate_kbps()),
|
||||
&codec_name,
|
||||
)?;
|
||||
info!("Created VP8 encoder: {}", encoder.codec_name());
|
||||
Box::new(VP8EncoderWrapper(encoder))
|
||||
}
|
||||
VideoEncoderType::VP9 => {
|
||||
let codec_name = selected_codec_name.clone();
|
||||
if let Some(ref backend) = config.encoder_backend {
|
||||
info!(
|
||||
"Creating VP9 encoder with backend {:?} (codec: {})",
|
||||
backend, codec_name
|
||||
);
|
||||
}
|
||||
let encoder = VP9Encoder::with_codec(
|
||||
VP9Config::low_latency(config.resolution, config.bitrate_kbps()),
|
||||
&codec_name,
|
||||
)?;
|
||||
info!("Created VP9 encoder: {}", encoder.codec_name());
|
||||
Box::new(VP9EncoderWrapper(encoder))
|
||||
}
|
||||
};
|
||||
|
||||
let codec_name = encoder.codec_name();
|
||||
let use_direct_input = if codec_name.contains("rkmpp") {
|
||||
matches!(
|
||||
pipeline_input_format,
|
||||
PixelFormat::Yuyv
|
||||
| PixelFormat::Yuv420
|
||||
| PixelFormat::Rgb24
|
||||
| PixelFormat::Bgr24
|
||||
| PixelFormat::Nv12
|
||||
| PixelFormat::Nv16
|
||||
| PixelFormat::Nv21
|
||||
| PixelFormat::Nv24
|
||||
)
|
||||
} else if codec_name.contains("libx264") {
|
||||
matches!(
|
||||
pipeline_input_format,
|
||||
PixelFormat::Nv12 | PixelFormat::Nv16 | PixelFormat::Nv21 | PixelFormat::Yuv420
|
||||
)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let needs_yuv420p = if codec_name.contains("libx264") {
|
||||
!matches!(
|
||||
pipeline_input_format,
|
||||
PixelFormat::Nv12 | PixelFormat::Nv16 | PixelFormat::Nv21 | PixelFormat::Yuv420
|
||||
)
|
||||
} else {
|
||||
codec_name.contains("libvpx") || codec_name.contains("libx265")
|
||||
};
|
||||
|
||||
info!(
|
||||
"Encoder {} needs {} format",
|
||||
codec_name,
|
||||
if use_direct_input {
|
||||
"direct"
|
||||
} else if needs_yuv420p {
|
||||
"YUV420P"
|
||||
} else {
|
||||
"NV12"
|
||||
}
|
||||
);
|
||||
info!(
|
||||
"Initializing input format handler for: {} -> {}",
|
||||
pipeline_input_format,
|
||||
if use_direct_input {
|
||||
"direct"
|
||||
} else if needs_yuv420p {
|
||||
"YUV420P"
|
||||
} else {
|
||||
"NV12"
|
||||
}
|
||||
);
|
||||
|
||||
let (nv12_converter, yuv420p_converter) = converters_for_pipeline(
|
||||
config.resolution,
|
||||
pipeline_input_format,
|
||||
use_yuyv_direct,
|
||||
use_direct_input,
|
||||
needs_yuv420p,
|
||||
)?;
|
||||
|
||||
Ok(EncoderThreadState {
|
||||
encoder: Some(encoder),
|
||||
mjpeg_decoder,
|
||||
nv12_converter,
|
||||
yuv420p_converter,
|
||||
encoder_needs_yuv420p: needs_yuv420p,
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
ffmpeg_hw_pipeline: None,
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
ffmpeg_hw_enabled: false,
|
||||
fps: config.fps,
|
||||
codec: config.output_codec,
|
||||
input_format: config.input_format,
|
||||
})
|
||||
}
|
||||
|
||||
fn h264_direct_input_format(
|
||||
codec_name: &str,
|
||||
input_format: PixelFormat,
|
||||
) -> Option<H264InputFormat> {
|
||||
if codec_name.contains("rkmpp") {
|
||||
match input_format {
|
||||
PixelFormat::Yuyv => Some(H264InputFormat::Yuyv422),
|
||||
PixelFormat::Yuv420 => Some(H264InputFormat::Yuv420p),
|
||||
PixelFormat::Rgb24 => Some(H264InputFormat::Rgb24),
|
||||
PixelFormat::Bgr24 => Some(H264InputFormat::Bgr24),
|
||||
PixelFormat::Nv12 => Some(H264InputFormat::Nv12),
|
||||
PixelFormat::Nv16 => Some(H264InputFormat::Nv16),
|
||||
PixelFormat::Nv21 => Some(H264InputFormat::Nv21),
|
||||
PixelFormat::Nv24 => Some(H264InputFormat::Nv24),
|
||||
_ => None,
|
||||
}
|
||||
} else if codec_name.contains("libx264") {
|
||||
match input_format {
|
||||
PixelFormat::Nv12 => Some(H264InputFormat::Nv12),
|
||||
PixelFormat::Nv16 => Some(H264InputFormat::Nv16),
|
||||
PixelFormat::Nv21 => Some(H264InputFormat::Nv21),
|
||||
PixelFormat::Yuv420 => Some(H264InputFormat::Yuv420p),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn h265_direct_input_format(
|
||||
codec_name: &str,
|
||||
input_format: PixelFormat,
|
||||
) -> Option<H265InputFormat> {
|
||||
if codec_name.contains("rkmpp") {
|
||||
match input_format {
|
||||
PixelFormat::Yuyv => Some(H265InputFormat::Yuyv422),
|
||||
PixelFormat::Yuv420 => Some(H265InputFormat::Yuv420p),
|
||||
PixelFormat::Rgb24 => Some(H265InputFormat::Rgb24),
|
||||
PixelFormat::Bgr24 => Some(H265InputFormat::Bgr24),
|
||||
PixelFormat::Nv12 => Some(H265InputFormat::Nv12),
|
||||
PixelFormat::Nv16 => Some(H265InputFormat::Nv16),
|
||||
PixelFormat::Nv21 => Some(H265InputFormat::Nv21),
|
||||
PixelFormat::Nv24 => Some(H265InputFormat::Nv24),
|
||||
_ => None,
|
||||
}
|
||||
} else if codec_name.contains("libx265") {
|
||||
match input_format {
|
||||
PixelFormat::Yuv420 => Some(H265InputFormat::Yuv420p),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn converters_for_pipeline(
|
||||
resolution: Resolution,
|
||||
input_format: PixelFormat,
|
||||
use_yuyv_direct: bool,
|
||||
use_direct_input: bool,
|
||||
needs_yuv420p: bool,
|
||||
) -> Result<(Option<Nv12Converter>, Option<PixelConverter>)> {
|
||||
if use_yuyv_direct {
|
||||
info!("YUYV direct input enabled for RKMPP, skipping format conversion");
|
||||
return Ok((None, None));
|
||||
}
|
||||
if use_direct_input {
|
||||
info!("Direct input enabled, skipping format conversion");
|
||||
return Ok((None, None));
|
||||
}
|
||||
if needs_yuv420p {
|
||||
return match input_format {
|
||||
PixelFormat::Yuv420 => {
|
||||
info!("Using direct YUV420P input (no conversion)");
|
||||
Ok((None, None))
|
||||
}
|
||||
PixelFormat::Yuyv => {
|
||||
info!("Using YUYV->YUV420P converter");
|
||||
Ok((None, Some(PixelConverter::yuyv_to_yuv420p(resolution))))
|
||||
}
|
||||
PixelFormat::Nv12 => {
|
||||
info!("Using NV12->YUV420P converter");
|
||||
Ok((None, Some(PixelConverter::nv12_to_yuv420p(resolution))))
|
||||
}
|
||||
PixelFormat::Nv21 => {
|
||||
info!("Using NV21->YUV420P converter");
|
||||
Ok((None, Some(PixelConverter::nv21_to_yuv420p(resolution))))
|
||||
}
|
||||
PixelFormat::Nv16 => {
|
||||
info!("Using NV16->YUV420P converter");
|
||||
Ok((None, Some(PixelConverter::nv16_to_yuv420p(resolution))))
|
||||
}
|
||||
PixelFormat::Nv24 => {
|
||||
info!("Using NV24->YUV420P converter");
|
||||
Ok((None, Some(PixelConverter::nv24_to_yuv420p(resolution))))
|
||||
}
|
||||
PixelFormat::Rgb24 => {
|
||||
info!("Using RGB24->YUV420P converter");
|
||||
Ok((None, Some(PixelConverter::rgb24_to_yuv420p(resolution))))
|
||||
}
|
||||
PixelFormat::Bgr24 => {
|
||||
info!("Using BGR24->YUV420P converter");
|
||||
Ok((None, Some(PixelConverter::bgr24_to_yuv420p(resolution))))
|
||||
}
|
||||
_ => Err(AppError::VideoError(format!(
|
||||
"Unsupported input format for software encoding: {}",
|
||||
input_format
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
match input_format {
|
||||
PixelFormat::Nv12 => {
|
||||
info!("Using direct NV12 input (no conversion)");
|
||||
Ok((None, None))
|
||||
}
|
||||
PixelFormat::Yuyv => {
|
||||
info!("Using YUYV->NV12 converter");
|
||||
Ok((Some(Nv12Converter::yuyv_to_nv12(resolution)), None))
|
||||
}
|
||||
PixelFormat::Nv21 => {
|
||||
info!("Using NV21->NV12 converter");
|
||||
Ok((Some(Nv12Converter::nv21_to_nv12(resolution)), None))
|
||||
}
|
||||
PixelFormat::Nv16 => {
|
||||
info!("Using NV16->NV12 converter");
|
||||
Ok((Some(Nv12Converter::nv16_to_nv12(resolution)), None))
|
||||
}
|
||||
PixelFormat::Nv24 => {
|
||||
info!("Using NV24->NV12 converter");
|
||||
Ok((Some(Nv12Converter::nv24_to_nv12(resolution)), None))
|
||||
}
|
||||
PixelFormat::Yuv420 => {
|
||||
info!("Using YUV420P->NV12 converter");
|
||||
Ok((Some(Nv12Converter::yuv420_to_nv12(resolution)), None))
|
||||
}
|
||||
PixelFormat::Rgb24 => {
|
||||
info!("Using RGB24->NV12 converter");
|
||||
Ok((Some(Nv12Converter::rgb24_to_nv12(resolution)), None))
|
||||
}
|
||||
PixelFormat::Bgr24 => {
|
||||
info!("Using BGR24->NV12 converter");
|
||||
Ok((Some(Nv12Converter::bgr24_to_nv12(resolution)), None))
|
||||
}
|
||||
_ => Err(AppError::VideoError(format!(
|
||||
"Unsupported input format for hardware encoding: {}",
|
||||
input_format
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
//! │
|
||||
//! ├── MJPEG Mode
|
||||
//! │ └── Streamer ──► MjpegStreamHandler
|
||||
//! │ (Future: MjpegStreamer with WsAudio/WsHid)
|
||||
//! │
|
||||
//! └── WebRTC Mode
|
||||
//! └── WebRtcStreamer ──► H264SessionManager
|
||||
@@ -39,6 +38,7 @@ use crate::hid::HidController;
|
||||
use crate::stream::MjpegStreamHandler;
|
||||
use crate::video::codec_constraints::StreamCodecConstraints;
|
||||
use crate::video::format::{PixelFormat, Resolution};
|
||||
use crate::video::is_rk_hdmirx_device;
|
||||
use crate::video::streamer::{Streamer, StreamerState};
|
||||
use crate::webrtc::WebRtcStreamer;
|
||||
|
||||
@@ -211,21 +211,7 @@ impl VideoStreamManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Configure WebRTC capture source after initialization
|
||||
let (device_path, resolution, format, fps, jpeg_quality) =
|
||||
self.streamer.current_capture_config().await;
|
||||
info!(
|
||||
"WebRTC capture config after init: {}x{} {:?} @ {}fps",
|
||||
resolution.width, resolution.height, format, fps
|
||||
);
|
||||
self.webrtc_streamer
|
||||
.update_video_config(resolution, format, fps)
|
||||
.await;
|
||||
if let Some(device_path) = device_path {
|
||||
self.webrtc_streamer
|
||||
.set_capture_device(device_path, jpeg_quality)
|
||||
.await;
|
||||
}
|
||||
self.sync_webrtc_capture_source("after init").await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -351,11 +337,17 @@ impl VideoStreamManager {
|
||||
}
|
||||
}
|
||||
|
||||
self.sync_webrtc_capture_source("for WebRTC ensure").await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_webrtc_capture_source(&self, reason: &str) {
|
||||
let (device_path, resolution, format, fps, jpeg_quality) =
|
||||
self.streamer.current_capture_config().await;
|
||||
info!(
|
||||
"Configuring WebRTC capture: {}x{} {:?} @ {}fps",
|
||||
resolution.width, resolution.height, format, fps
|
||||
"Syncing WebRTC capture source {}: {}x{} {:?} @ {}fps",
|
||||
reason, resolution.width, resolution.height, format, fps
|
||||
);
|
||||
self.webrtc_streamer
|
||||
.update_video_config(resolution, format, fps)
|
||||
@@ -364,9 +356,9 @@ impl VideoStreamManager {
|
||||
self.webrtc_streamer
|
||||
.set_capture_device(device_path, jpeg_quality)
|
||||
.await;
|
||||
} else {
|
||||
warn!("No capture device configured while syncing WebRTC capture source");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Internal implementation of mode switching (called with lock held)
|
||||
@@ -412,8 +404,11 @@ impl VideoStreamManager {
|
||||
}
|
||||
}
|
||||
StreamMode::WebRTC => {
|
||||
info!("Closing all WebRTC sessions");
|
||||
let closed = self.webrtc_streamer.close_all_sessions().await;
|
||||
info!("Closing all WebRTC sessions and releasing capture device");
|
||||
let closed = self
|
||||
.webrtc_streamer
|
||||
.close_all_sessions_and_release_device()
|
||||
.await;
|
||||
if closed > 0 {
|
||||
info!("Closed {} WebRTC sessions", closed);
|
||||
}
|
||||
@@ -436,7 +431,8 @@ impl VideoStreamManager {
|
||||
device.formats.iter().map(|f| f.format).collect();
|
||||
|
||||
// If current format is not MJPEG and device supports MJPEG, switch to it
|
||||
if current_format != PixelFormat::Mjpeg
|
||||
if !is_rk_hdmirx_device(&device)
|
||||
&& current_format != PixelFormat::Mjpeg
|
||||
&& available_formats.contains(&PixelFormat::Mjpeg)
|
||||
{
|
||||
info!("Auto-switching to MJPEG format for MJPEG mode");
|
||||
@@ -471,22 +467,7 @@ impl VideoStreamManager {
|
||||
}
|
||||
}
|
||||
|
||||
let (device_path, resolution, format, fps, jpeg_quality) =
|
||||
self.streamer.current_capture_config().await;
|
||||
info!(
|
||||
"Configuring WebRTC capture pipeline: {}x{} {:?} @ {}fps",
|
||||
resolution.width, resolution.height, format, fps
|
||||
);
|
||||
self.webrtc_streamer
|
||||
.update_video_config(resolution, format, fps)
|
||||
.await;
|
||||
if let Some(device_path) = device_path {
|
||||
self.webrtc_streamer
|
||||
.set_capture_device(device_path, jpeg_quality)
|
||||
.await;
|
||||
} else {
|
||||
warn!("No capture device configured for WebRTC");
|
||||
}
|
||||
self.sync_webrtc_capture_source("for WebRTC mode").await;
|
||||
|
||||
let codec = self.webrtc_streamer.current_video_codec().await;
|
||||
let is_hardware = self.webrtc_streamer.is_hardware_encoding().await;
|
||||
@@ -532,17 +513,30 @@ impl VideoStreamManager {
|
||||
device_path, format, resolution.width, resolution.height, fps, mode
|
||||
);
|
||||
|
||||
if mode == StreamMode::WebRTC {
|
||||
// Stop the shared pipeline before replacing the capture source so WebRTC
|
||||
// sessions do not stay attached to a stale frame source.
|
||||
self.webrtc_streamer
|
||||
.update_video_config(resolution, format, fps)
|
||||
.await;
|
||||
info!("WebRTC streamer config updated (pipeline stopped, sessions closed)");
|
||||
}
|
||||
|
||||
// Apply to streamer (handles video capture)
|
||||
self.streamer
|
||||
.apply_video_config(device_path, format, resolution, fps)
|
||||
.await?;
|
||||
|
||||
if mode != StreamMode::WebRTC {
|
||||
if let Err(e) = self.start().await {
|
||||
error!("Failed to start streamer after config change: {}", e);
|
||||
} else {
|
||||
info!("Streamer started after config change");
|
||||
}
|
||||
}
|
||||
|
||||
// Update WebRTC config if in WebRTC mode
|
||||
if mode == StreamMode::WebRTC {
|
||||
self.webrtc_streamer
|
||||
.update_video_config(resolution, format, fps)
|
||||
.await;
|
||||
|
||||
let (device_path, actual_resolution, actual_format, actual_fps, jpeg_quality) =
|
||||
self.streamer.current_capture_config().await;
|
||||
if actual_format != format || actual_resolution != resolution || actual_fps != fps {
|
||||
@@ -590,19 +584,7 @@ impl VideoStreamManager {
|
||||
self.streamer.init_auto().await?;
|
||||
}
|
||||
|
||||
// Synchronize WebRTC config with current capture config
|
||||
let (device_path, resolution, format, fps, jpeg_quality) =
|
||||
self.streamer.current_capture_config().await;
|
||||
self.webrtc_streamer
|
||||
.update_video_config(resolution, format, fps)
|
||||
.await;
|
||||
if let Some(device_path) = device_path {
|
||||
self.webrtc_streamer
|
||||
.set_capture_device(device_path, jpeg_quality)
|
||||
.await;
|
||||
} else {
|
||||
warn!("No capture device configured for WebRTC");
|
||||
}
|
||||
self.sync_webrtc_capture_source("before start").await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -747,24 +729,10 @@ impl VideoStreamManager {
|
||||
}
|
||||
|
||||
// 2. Synchronize WebRTC config with capture config
|
||||
let (device_path, resolution, format, fps, jpeg_quality) =
|
||||
self.streamer.current_capture_config().await;
|
||||
tracing::info!(
|
||||
"Connecting encoded frame subscription: {}x{} {:?} @ {}fps",
|
||||
resolution.width,
|
||||
resolution.height,
|
||||
format,
|
||||
fps
|
||||
);
|
||||
self.webrtc_streamer
|
||||
.update_video_config(resolution, format, fps)
|
||||
let (device_path, _, _, _, _) = self.streamer.current_capture_config().await;
|
||||
self.sync_webrtc_capture_source("for encoded frame subscription")
|
||||
.await;
|
||||
if let Some(device_path) = device_path {
|
||||
self.webrtc_streamer
|
||||
.set_capture_device(device_path, jpeg_quality)
|
||||
.await;
|
||||
} else {
|
||||
tracing::warn!("No capture device configured for encoded frames");
|
||||
if device_path.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -816,6 +784,61 @@ impl VideoStreamManager {
|
||||
self.webrtc_streamer.request_keyframe().await
|
||||
}
|
||||
|
||||
/// Notify frontend about a codec-only switch (WebRTC mode unchanged, codec changed).
|
||||
///
|
||||
/// `set_video_codec` already rebuilt the pipeline synchronously, so we just
|
||||
/// emit the events the frontend waits on: `StreamModeChanged`, `WebRTCReady`,
|
||||
/// and `StreamModeReady`.
|
||||
///
|
||||
/// Events are spawned asynchronously so the HTTP response (carrying the
|
||||
/// `transition_id`) reaches the client before the WebSocket events, giving
|
||||
/// the frontend time to call `registerTransition()` first.
|
||||
pub async fn notify_codec_switch(
|
||||
self: &Arc<Self>,
|
||||
transition_id: &str,
|
||||
new_codec_str: &str,
|
||||
previous_codec_str: &str,
|
||||
) {
|
||||
let manager = Arc::clone(self);
|
||||
let transition_id = transition_id.to_string();
|
||||
let new_codec = new_codec_str.to_string();
|
||||
let prev_codec = previous_codec_str.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Small yield to ensure the HTTP response is flushed first.
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
manager
|
||||
.publish_event(SystemEvent::StreamModeChanged {
|
||||
transition_id: Some(transition_id.clone()),
|
||||
mode: new_codec.clone(),
|
||||
previous_mode: prev_codec.clone(),
|
||||
})
|
||||
.await;
|
||||
|
||||
let is_hardware = manager.webrtc_streamer.is_hardware_encoding().await;
|
||||
manager
|
||||
.publish_event(SystemEvent::WebRTCReady {
|
||||
transition_id: Some(transition_id.clone()),
|
||||
codec: new_codec.clone(),
|
||||
hardware: is_hardware,
|
||||
})
|
||||
.await;
|
||||
|
||||
manager
|
||||
.publish_event(SystemEvent::StreamModeReady {
|
||||
transition_id: transition_id.clone(),
|
||||
mode: new_codec.clone(),
|
||||
})
|
||||
.await;
|
||||
|
||||
info!(
|
||||
"Codec switch notified: {} -> {} (transition: {})",
|
||||
prev_codec, new_codec, transition_id
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Publish event to event bus
|
||||
async fn publish_event(&self, event: SystemEvent) {
|
||||
if let Some(ref events) = *self.events.read().await {
|
||||
|
||||
@@ -11,9 +11,10 @@ use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
|
||||
use super::device::{enumerate_devices, find_best_device, VideoDeviceInfo};
|
||||
use super::device::{enumerate_devices, find_best_device, VideoDevice, VideoDeviceInfo};
|
||||
use super::format::{PixelFormat, Resolution};
|
||||
use super::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
|
||||
use super::is_rk_hdmirx_device;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::events::{EventBus, SystemEvent};
|
||||
use crate::stream::MjpegStreamHandler;
|
||||
@@ -269,24 +270,7 @@ impl Streamer {
|
||||
.find(|d| d.path.to_string_lossy() == device_path)
|
||||
.ok_or_else(|| AppError::VideoError("Video device not found".to_string()))?;
|
||||
|
||||
// Validate format
|
||||
let fmt_info = device
|
||||
.formats
|
||||
.iter()
|
||||
.find(|f| f.format == format)
|
||||
.ok_or_else(|| AppError::VideoError("Requested format not supported".to_string()))?;
|
||||
|
||||
// Validate resolution
|
||||
if !fmt_info.resolutions.is_empty()
|
||||
&& !fmt_info
|
||||
.resolutions
|
||||
.iter()
|
||||
.any(|r| r.width == resolution.width && r.height == resolution.height)
|
||||
{
|
||||
return Err(AppError::VideoError(
|
||||
"Requested resolution not supported".to_string(),
|
||||
));
|
||||
}
|
||||
let (format, resolution) = self.resolve_capture_config(&device, format, resolution)?;
|
||||
|
||||
// IMPORTANT: Disconnect all MJPEG clients FIRST before stopping capture
|
||||
// This prevents race conditions where clients try to reconnect and reopen the device
|
||||
@@ -385,6 +369,14 @@ impl Streamer {
|
||||
device: &VideoDeviceInfo,
|
||||
preferred: PixelFormat,
|
||||
) -> Result<PixelFormat> {
|
||||
if is_rk_hdmirx_device(device) {
|
||||
return device
|
||||
.formats
|
||||
.first()
|
||||
.map(|f| f.format)
|
||||
.ok_or_else(|| AppError::VideoError("No supported formats found".to_string()));
|
||||
}
|
||||
|
||||
// Check if preferred format is available
|
||||
if device.formats.iter().any(|f| f.format == preferred) {
|
||||
return Ok(preferred);
|
||||
@@ -411,6 +403,14 @@ impl Streamer {
|
||||
.find(|f| &f.format == format)
|
||||
.ok_or_else(|| AppError::VideoError("Format not found".to_string()))?;
|
||||
|
||||
if is_rk_hdmirx_device(device) {
|
||||
return Ok(format_info
|
||||
.resolutions
|
||||
.first()
|
||||
.map(|r| r.resolution())
|
||||
.unwrap_or(preferred));
|
||||
}
|
||||
|
||||
// Check if preferred resolution is available
|
||||
if format_info.resolutions.is_empty()
|
||||
|| format_info
|
||||
@@ -429,6 +429,17 @@ impl Streamer {
|
||||
.ok_or_else(|| AppError::VideoError("No resolutions available".to_string()))
|
||||
}
|
||||
|
||||
fn resolve_capture_config(
|
||||
&self,
|
||||
device: &VideoDeviceInfo,
|
||||
requested_format: PixelFormat,
|
||||
requested_resolution: Resolution,
|
||||
) -> Result<(PixelFormat, Resolution)> {
|
||||
let format = self.select_format(device, requested_format)?;
|
||||
let resolution = self.select_resolution(device, &format, requested_resolution)?;
|
||||
Ok((format, resolution))
|
||||
}
|
||||
|
||||
/// Restart capture for recovery (direct capture path)
|
||||
async fn restart_capture(self: &Arc<Self>) -> Result<()> {
|
||||
self.direct_stop.store(false, Ordering::SeqCst);
|
||||
@@ -608,12 +619,22 @@ impl Streamer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Direct capture loop for MJPEG mode (single loop, no broadcast)
|
||||
fn run_direct_capture(self: Arc<Self>, device_path: PathBuf, config: StreamerConfig) {
|
||||
/// Direct capture loop for MJPEG mode.
|
||||
///
|
||||
/// The outer `'session` loop allows "soft restarts": when no signal has been
|
||||
/// detected for `NOSIGNAL_SOFT_RESTART_SECS` the capture stream is closed and
|
||||
/// re-opened (re-probing format/resolution) without going through the full
|
||||
/// DeviceLost recovery path. This handles the common CSI/HDMI-bridge case where
|
||||
/// the source switches resolution and the driver requires a new `s_fmt` call.
|
||||
fn run_direct_capture(self: Arc<Self>, device_path: PathBuf, _initial_config: StreamerConfig) {
|
||||
const MAX_RETRIES: u32 = 5;
|
||||
const RETRY_DELAY_MS: u64 = 200;
|
||||
const IDLE_STOP_DELAY_SECS: u64 = 5;
|
||||
const BUFFER_COUNT: u32 = 2;
|
||||
/// After this many seconds without signal, close+re-open the device.
|
||||
const NOSIGNAL_SOFT_RESTART_SECS: u64 = 8;
|
||||
/// Placeholder frame re-send interval while in NoSignal state (iterations of 100 ms).
|
||||
const NOSIGNAL_PLACEHOLDER_INTERVAL: u32 = 10; // every ~1 s
|
||||
|
||||
let handle = tokio::runtime::Handle::current();
|
||||
let mut last_state = StreamerState::Streaming;
|
||||
@@ -628,222 +649,369 @@ impl Streamer {
|
||||
}
|
||||
};
|
||||
|
||||
let mut stream_opt: Option<V4l2rCaptureStream> = None;
|
||||
let mut last_error: Option<String> = None;
|
||||
// How many soft-restart cycles have been attempted (for exponential back-off).
|
||||
let mut no_signal_restart_count: u32 = 0;
|
||||
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
'session: loop {
|
||||
if self.direct_stop.load(Ordering::Relaxed) {
|
||||
self.direct_active.store(false, Ordering::SeqCst);
|
||||
return;
|
||||
break 'session;
|
||||
}
|
||||
|
||||
match V4l2rCaptureStream::open(
|
||||
&device_path,
|
||||
config.resolution,
|
||||
config.format,
|
||||
config.fps,
|
||||
BUFFER_COUNT,
|
||||
Duration::from_secs(2),
|
||||
) {
|
||||
Ok(stream) => {
|
||||
stream_opt = Some(stream);
|
||||
break;
|
||||
// Re-read config at the start of each session so that a re_init_device()
|
||||
// call (from a previous soft-restart or recovery) is reflected here.
|
||||
let config = handle.block_on(async { self.config.read().await.clone() });
|
||||
|
||||
// ── Open the capture stream ─────────────────────────────────────────
|
||||
let mut stream_opt: Option<V4l2rCaptureStream> = None;
|
||||
let mut last_error: Option<String> = None;
|
||||
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
if self.direct_stop.load(Ordering::Relaxed) {
|
||||
self.direct_active.store(false, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("busy") || err_str.contains("resource") {
|
||||
warn!(
|
||||
"Device busy on attempt {}/{}, retrying in {}ms...",
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
RETRY_DELAY_MS
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
|
||||
|
||||
match V4l2rCaptureStream::open(
|
||||
&device_path,
|
||||
config.resolution,
|
||||
config.format,
|
||||
config.fps,
|
||||
BUFFER_COUNT,
|
||||
Duration::from_secs(2),
|
||||
) {
|
||||
Ok(stream) => {
|
||||
stream_opt = Some(stream);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("busy") || err_str.contains("resource") {
|
||||
warn!(
|
||||
"Device busy on attempt {}/{}, retrying in {}ms...",
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
RETRY_DELAY_MS
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
|
||||
last_error = Some(err_str);
|
||||
continue;
|
||||
}
|
||||
last_error = Some(err_str);
|
||||
continue;
|
||||
}
|
||||
last_error = Some(err_str);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut stream = match stream_opt {
|
||||
Some(stream) => stream,
|
||||
None => {
|
||||
error!(
|
||||
"Failed to open device {:?}: {}",
|
||||
device_path,
|
||||
last_error.unwrap_or_else(|| "unknown error".to_string())
|
||||
);
|
||||
self.mjpeg_handler.set_offline();
|
||||
set_state(StreamerState::Error);
|
||||
self.direct_active.store(false, Ordering::SeqCst);
|
||||
self.current_fps.store(0, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let resolution = stream.resolution();
|
||||
let pixel_format = stream.format();
|
||||
let stride = stream.stride();
|
||||
|
||||
info!(
|
||||
"Capture format: {}x{} {:?} stride={}",
|
||||
resolution.width, resolution.height, pixel_format, stride
|
||||
);
|
||||
|
||||
let buffer_pool = Arc::new(FrameBufferPool::new(BUFFER_COUNT.max(4) as usize));
|
||||
let mut signal_present = true;
|
||||
let mut validate_counter: u64 = 0;
|
||||
let mut idle_since: Option<std::time::Instant> = None;
|
||||
|
||||
let mut fps_frame_count: u64 = 0;
|
||||
let mut last_fps_time = std::time::Instant::now();
|
||||
let capture_error_throttler = LogThrottler::with_secs(5);
|
||||
let mut suppressed_capture_errors: HashMap<String, u64> = HashMap::new();
|
||||
|
||||
let classify_capture_error = |err: &std::io::Error| -> String {
|
||||
let message = err.to_string();
|
||||
if message.contains("dqbuf failed") && message.contains("EINVAL") {
|
||||
"capture_dqbuf_einval".to_string()
|
||||
} else if message.contains("dqbuf failed") {
|
||||
"capture_dqbuf".to_string()
|
||||
} else {
|
||||
format!("capture_{:?}", err.kind())
|
||||
}
|
||||
};
|
||||
|
||||
while !self.direct_stop.load(Ordering::Relaxed) {
|
||||
let mjpeg_clients = self.mjpeg_handler.client_count();
|
||||
if mjpeg_clients == 0 {
|
||||
if idle_since.is_none() {
|
||||
idle_since = Some(std::time::Instant::now());
|
||||
trace!("No active video consumers, starting idle timer");
|
||||
} else if let Some(since) = idle_since {
|
||||
if since.elapsed().as_secs() >= IDLE_STOP_DELAY_SECS {
|
||||
info!(
|
||||
"No active video consumers for {}s, stopping capture",
|
||||
IDLE_STOP_DELAY_SECS
|
||||
);
|
||||
self.mjpeg_handler.set_offline();
|
||||
set_state(StreamerState::Ready);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if idle_since.is_some() {
|
||||
trace!("Video consumers active, resetting idle timer");
|
||||
idle_since = None;
|
||||
}
|
||||
|
||||
let mut owned = buffer_pool.take(MIN_CAPTURE_FRAME_SIZE);
|
||||
let meta = match stream.next_into(&mut owned) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::TimedOut {
|
||||
if signal_present {
|
||||
signal_present = false;
|
||||
self.mjpeg_handler.set_offline();
|
||||
set_state(StreamerState::NoSignal);
|
||||
self.current_fps.store(0, Ordering::Relaxed);
|
||||
fps_frame_count = 0;
|
||||
last_fps_time = std::time::Instant::now();
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_device_lost = match e.raw_os_error() {
|
||||
Some(6) => true, // ENXIO
|
||||
Some(19) => true, // ENODEV
|
||||
Some(5) => true, // EIO
|
||||
Some(32) => true, // EPIPE
|
||||
Some(108) => true, // ESHUTDOWN
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_device_lost {
|
||||
error!("Video device lost: {} - {}", device_path.display(), e);
|
||||
self.mjpeg_handler.set_offline();
|
||||
handle.block_on(async {
|
||||
*self.last_lost_device.write().await =
|
||||
Some(device_path.display().to_string());
|
||||
*self.last_lost_reason.write().await = Some(e.to_string());
|
||||
});
|
||||
set_state(StreamerState::DeviceLost);
|
||||
handle.block_on(async {
|
||||
let streamer = Arc::clone(&self);
|
||||
tokio::spawn(async move {
|
||||
streamer.start_device_recovery_internal().await;
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
let key = classify_capture_error(&e);
|
||||
if capture_error_throttler.should_log(&key) {
|
||||
let suppressed = suppressed_capture_errors.remove(&key).unwrap_or(0);
|
||||
if suppressed > 0 {
|
||||
error!("Capture error: {} (suppressed {} repeats)", e, suppressed);
|
||||
} else {
|
||||
error!("Capture error: {}", e);
|
||||
}
|
||||
} else {
|
||||
let counter = suppressed_capture_errors.entry(key).or_insert(0);
|
||||
*counter = counter.saturating_add(1);
|
||||
}
|
||||
continue;
|
||||
let mut stream = match stream_opt {
|
||||
Some(stream) => stream,
|
||||
None => {
|
||||
error!(
|
||||
"Failed to open device {:?}: {}",
|
||||
device_path,
|
||||
last_error.unwrap_or_else(|| "unknown error".to_string())
|
||||
);
|
||||
self.mjpeg_handler.set_offline();
|
||||
set_state(StreamerState::Error);
|
||||
break 'session;
|
||||
}
|
||||
};
|
||||
|
||||
let frame_size = meta.bytes_used;
|
||||
if frame_size < MIN_CAPTURE_FRAME_SIZE {
|
||||
continue;
|
||||
}
|
||||
let resolution = stream.resolution();
|
||||
let pixel_format = stream.format();
|
||||
let stride = stream.stride();
|
||||
|
||||
validate_counter = validate_counter.wrapping_add(1);
|
||||
if pixel_format.is_compressed()
|
||||
&& validate_counter.is_multiple_of(JPEG_VALIDATE_INTERVAL)
|
||||
&& !VideoFrame::is_valid_jpeg_bytes(&owned[..frame_size])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
owned.truncate(frame_size);
|
||||
let frame = VideoFrame::from_pooled(
|
||||
Arc::new(FrameBuffer::new(owned, Some(buffer_pool.clone()))),
|
||||
resolution,
|
||||
pixel_format,
|
||||
stride,
|
||||
meta.sequence,
|
||||
info!(
|
||||
"Capture format: {}x{} {:?} stride={}",
|
||||
resolution.width, resolution.height, pixel_format, stride
|
||||
);
|
||||
|
||||
if !signal_present {
|
||||
signal_present = true;
|
||||
self.mjpeg_handler.set_online();
|
||||
set_state(StreamerState::Streaming);
|
||||
let buffer_pool = Arc::new(FrameBufferPool::new(BUFFER_COUNT.max(4) as usize));
|
||||
let mut signal_present = true;
|
||||
let mut validate_counter: u64 = 0;
|
||||
let mut idle_since: Option<std::time::Instant> = None;
|
||||
|
||||
let mut fps_frame_count: u64 = 0;
|
||||
let mut last_fps_time = std::time::Instant::now();
|
||||
let capture_error_throttler = LogThrottler::with_secs(5);
|
||||
let mut suppressed_capture_errors: HashMap<String, u64> = HashMap::new();
|
||||
|
||||
let classify_capture_error = |err: &std::io::Error| -> String {
|
||||
let message = err.to_string();
|
||||
if message.contains("dqbuf failed") && message.contains("EINVAL") {
|
||||
"capture_dqbuf_einval".to_string()
|
||||
} else if message.contains("dqbuf failed") {
|
||||
"capture_dqbuf".to_string()
|
||||
} else {
|
||||
format!("capture_{:?}", err.kind())
|
||||
}
|
||||
};
|
||||
|
||||
// None = signal is present; Some(Instant) = when signal was first lost.
|
||||
let mut no_signal_since: Option<std::time::Instant> = None;
|
||||
// Counter for periodic placeholder pushes during NoSignal.
|
||||
let mut nosignal_placeholder_counter: u32 = 0;
|
||||
// Whether the inner 'capture loop should trigger a soft restart.
|
||||
let mut need_soft_restart = false;
|
||||
|
||||
// ── Inner capture loop ──────────────────────────────────────────────
|
||||
'capture: while !self.direct_stop.load(Ordering::Relaxed) {
|
||||
let mjpeg_clients = self.mjpeg_handler.client_count();
|
||||
if mjpeg_clients == 0 {
|
||||
if idle_since.is_none() {
|
||||
idle_since = Some(std::time::Instant::now());
|
||||
trace!("No active video consumers, starting idle timer");
|
||||
} else if let Some(since) = idle_since {
|
||||
if since.elapsed().as_secs() >= IDLE_STOP_DELAY_SECS {
|
||||
info!(
|
||||
"No active video consumers for {}s, stopping capture",
|
||||
IDLE_STOP_DELAY_SECS
|
||||
);
|
||||
self.mjpeg_handler.set_offline();
|
||||
set_state(StreamerState::Ready);
|
||||
break 'capture;
|
||||
}
|
||||
}
|
||||
} else if idle_since.is_some() {
|
||||
trace!("Video consumers active, resetting idle timer");
|
||||
idle_since = None;
|
||||
}
|
||||
|
||||
let mut owned = buffer_pool.take(MIN_CAPTURE_FRAME_SIZE);
|
||||
let meta = match stream.next_into(&mut owned) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::TimedOut {
|
||||
if signal_present {
|
||||
signal_present = false;
|
||||
// Don't call set_offline() – instead keep the MJPEG stream
|
||||
// alive by pushing a placeholder frame so clients stay
|
||||
// connected and see the "no signal" image.
|
||||
self.mjpeg_handler.push_no_signal_placeholder();
|
||||
set_state(StreamerState::NoSignal);
|
||||
no_signal_since = Some(std::time::Instant::now());
|
||||
self.current_fps.store(0, Ordering::Relaxed);
|
||||
fps_frame_count = 0;
|
||||
last_fps_time = std::time::Instant::now();
|
||||
nosignal_placeholder_counter = 0;
|
||||
} else {
|
||||
// Already in NoSignal – re-send placeholder periodically so
|
||||
// the HTTP keepalive timer does not expire.
|
||||
nosignal_placeholder_counter =
|
||||
nosignal_placeholder_counter.wrapping_add(1);
|
||||
if nosignal_placeholder_counter >= NOSIGNAL_PLACEHOLDER_INTERVAL {
|
||||
nosignal_placeholder_counter = 0;
|
||||
self.mjpeg_handler.push_no_signal_placeholder();
|
||||
}
|
||||
|
||||
// Soft-restart after exponential back-off.
|
||||
if let Some(since) = no_signal_since {
|
||||
let backoff_secs = NOSIGNAL_SOFT_RESTART_SECS
|
||||
.saturating_mul(2u64.pow(no_signal_restart_count.min(2)))
|
||||
.min(30);
|
||||
if since.elapsed().as_secs() >= backoff_secs {
|
||||
info!(
|
||||
"NoSignal for {}s, attempting soft restart (attempt {})",
|
||||
backoff_secs,
|
||||
no_signal_restart_count + 1
|
||||
);
|
||||
need_soft_restart = true;
|
||||
break 'capture;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
continue 'capture;
|
||||
}
|
||||
|
||||
let is_device_lost = match e.raw_os_error() {
|
||||
Some(6) => true, // ENXIO
|
||||
Some(19) => true, // ENODEV
|
||||
Some(5) => true, // EIO
|
||||
Some(32) => true, // EPIPE
|
||||
Some(108) => true, // ESHUTDOWN
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_device_lost {
|
||||
error!("Video device lost: {} - {}", device_path.display(), e);
|
||||
self.mjpeg_handler.set_offline();
|
||||
handle.block_on(async {
|
||||
*self.last_lost_device.write().await =
|
||||
Some(device_path.display().to_string());
|
||||
*self.last_lost_reason.write().await = Some(e.to_string());
|
||||
});
|
||||
set_state(StreamerState::DeviceLost);
|
||||
handle.block_on(async {
|
||||
let streamer = Arc::clone(&self);
|
||||
tokio::spawn(async move {
|
||||
streamer.start_device_recovery_internal().await;
|
||||
});
|
||||
});
|
||||
break 'capture;
|
||||
}
|
||||
|
||||
let key = classify_capture_error(&e);
|
||||
if capture_error_throttler.should_log(&key) {
|
||||
let suppressed = suppressed_capture_errors.remove(&key).unwrap_or(0);
|
||||
if suppressed > 0 {
|
||||
error!("Capture error: {} (suppressed {} repeats)", e, suppressed);
|
||||
} else {
|
||||
error!("Capture error: {}", e);
|
||||
}
|
||||
} else {
|
||||
let counter = suppressed_capture_errors.entry(key).or_insert(0);
|
||||
*counter = counter.saturating_add(1);
|
||||
}
|
||||
continue 'capture;
|
||||
}
|
||||
};
|
||||
|
||||
let frame_size = meta.bytes_used;
|
||||
if frame_size < MIN_CAPTURE_FRAME_SIZE {
|
||||
continue 'capture;
|
||||
}
|
||||
|
||||
validate_counter = validate_counter.wrapping_add(1);
|
||||
if pixel_format.is_compressed()
|
||||
&& validate_counter.is_multiple_of(JPEG_VALIDATE_INTERVAL)
|
||||
&& !VideoFrame::is_valid_jpeg_bytes(&owned[..frame_size])
|
||||
{
|
||||
continue 'capture;
|
||||
}
|
||||
|
||||
owned.truncate(frame_size);
|
||||
let frame = VideoFrame::from_pooled(
|
||||
Arc::new(FrameBuffer::new(owned, Some(buffer_pool.clone()))),
|
||||
resolution,
|
||||
pixel_format,
|
||||
stride,
|
||||
meta.sequence,
|
||||
);
|
||||
|
||||
if !signal_present {
|
||||
signal_present = true;
|
||||
no_signal_since = None;
|
||||
no_signal_restart_count = 0;
|
||||
// Stream was kept online (placeholder pushes), just update state.
|
||||
set_state(StreamerState::Streaming);
|
||||
}
|
||||
|
||||
self.mjpeg_handler.update_frame(frame);
|
||||
|
||||
fps_frame_count += 1;
|
||||
let fps_elapsed = last_fps_time.elapsed();
|
||||
if fps_elapsed >= std::time::Duration::from_secs(1) {
|
||||
let current_fps = fps_frame_count as f32 / fps_elapsed.as_secs_f32();
|
||||
fps_frame_count = 0;
|
||||
last_fps_time = std::time::Instant::now();
|
||||
self.current_fps
|
||||
.store((current_fps * 100.0) as u32, Ordering::Relaxed);
|
||||
}
|
||||
} // 'capture
|
||||
|
||||
// ── After inner loop ────────────────────────────────────────────────
|
||||
// The stream is dropped here, releasing the device FD.
|
||||
drop(stream);
|
||||
|
||||
if self.direct_stop.load(Ordering::Relaxed) {
|
||||
break 'session;
|
||||
}
|
||||
|
||||
self.mjpeg_handler.update_frame(frame);
|
||||
|
||||
fps_frame_count += 1;
|
||||
let fps_elapsed = last_fps_time.elapsed();
|
||||
if fps_elapsed >= std::time::Duration::from_secs(1) {
|
||||
let current_fps = fps_frame_count as f32 / fps_elapsed.as_secs_f32();
|
||||
fps_frame_count = 0;
|
||||
last_fps_time = std::time::Instant::now();
|
||||
self.current_fps
|
||||
.store((current_fps * 100.0) as u32, Ordering::Relaxed);
|
||||
if !need_soft_restart {
|
||||
// Normal exit (idle / device-lost / stop).
|
||||
break 'session;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Soft restart path ───────────────────────────────────────────────
|
||||
no_signal_restart_count = no_signal_restart_count.saturating_add(1);
|
||||
|
||||
// Re-probe the device to pick up a changed resolution/format.
|
||||
match VideoDevice::open_readonly(&device_path).and_then(|d| d.info()) {
|
||||
Ok(device_info) => {
|
||||
handle.block_on(async {
|
||||
let fmt;
|
||||
let res;
|
||||
{
|
||||
let cfg = self.config.read().await;
|
||||
fmt = self
|
||||
.select_format(&device_info, cfg.format)
|
||||
.unwrap_or(cfg.format);
|
||||
res = self
|
||||
.select_resolution(&device_info, &fmt, cfg.resolution)
|
||||
.unwrap_or(cfg.resolution);
|
||||
}
|
||||
{
|
||||
let mut cfg = self.config.write().await;
|
||||
cfg.format = fmt;
|
||||
cfg.resolution = res;
|
||||
}
|
||||
*self.current_device.write().await = Some(device_info);
|
||||
info!(
|
||||
"Soft restart: re-probed device → {}x{} {:?}",
|
||||
res.width, res.height, fmt
|
||||
);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Soft restart: failed to re-probe device: {}", e);
|
||||
// Brief wait before retrying to avoid spinning.
|
||||
let wait = 2u64.pow(no_signal_restart_count.min(3));
|
||||
std::thread::sleep(Duration::from_secs(wait));
|
||||
}
|
||||
}
|
||||
|
||||
// Reset no_signal_since so the back-off timer is fresh for the new session.
|
||||
// no_signal_since will be re-set if the new session immediately times out.
|
||||
|
||||
// Continue 'session → re-open V4l2rCaptureStream with updated config.
|
||||
} // 'session
|
||||
|
||||
self.direct_active.store(false, Ordering::SeqCst);
|
||||
self.current_fps.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Check if streaming
|
||||
/// Check if streaming (or in NoSignal state — capture thread is still running)
|
||||
pub async fn is_streaming(&self) -> bool {
|
||||
self.state().await == StreamerState::Streaming
|
||||
matches!(
|
||||
self.state().await,
|
||||
StreamerState::Streaming | StreamerState::NoSignal
|
||||
)
|
||||
}
|
||||
|
||||
/// Re-probe a device and update the stored config/device info.
|
||||
///
|
||||
/// Called during recovery or after a NoSignal soft restart so that a
|
||||
/// resolution / format change on the source side is picked up before
|
||||
/// the capture stream is re-opened.
|
||||
pub async fn re_init_device(self: &Arc<Self>, device_path: &str) -> Result<()> {
|
||||
let device = VideoDevice::open_readonly(device_path)
|
||||
.map_err(|e| AppError::VideoError(format!("Cannot open device for re-init: {}", e)))?;
|
||||
let device_info = device.info()?;
|
||||
|
||||
let (format, resolution) = {
|
||||
let config = self.config.read().await;
|
||||
let fmt = self
|
||||
.select_format(&device_info, config.format)
|
||||
.unwrap_or(config.format);
|
||||
let res = self
|
||||
.select_resolution(&device_info, &fmt, config.resolution)
|
||||
.unwrap_or(config.resolution);
|
||||
(fmt, res)
|
||||
};
|
||||
|
||||
{
|
||||
let mut cfg = self.config.write().await;
|
||||
cfg.format = format;
|
||||
cfg.resolution = resolution;
|
||||
}
|
||||
*self.current_device.write().await = Some(device_info);
|
||||
|
||||
info!(
|
||||
"Device re-initialized: {}x{} {:?}",
|
||||
resolution.width, resolution.height, format
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get stream statistics
|
||||
@@ -985,6 +1153,15 @@ impl Streamer {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-probe device to pick up resolution/format changes
|
||||
if let Err(e) = streamer.re_init_device(&device_path).await {
|
||||
debug!(
|
||||
"Failed to re-probe device format (attempt {}): {}",
|
||||
attempt, e
|
||||
);
|
||||
// Don't skip – device exists, try restart anyway
|
||||
}
|
||||
|
||||
// Try to restart capture
|
||||
match streamer.restart_capture().await {
|
||||
Ok(_) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ use v4l2r::ioctl::{
|
||||
QBuffer, QueryBuffer, V4l2Buffer,
|
||||
};
|
||||
use v4l2r::memory::{MemoryType, MmapHandle};
|
||||
use v4l2r::nix::errno::Errno;
|
||||
use v4l2r::{Format as V4l2rFormat, PixelFormat as V4l2rPixelFormat, QueueType};
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
@@ -91,8 +92,11 @@ impl V4l2rCaptureStream {
|
||||
});
|
||||
|
||||
if fps > 0 {
|
||||
if let Err(e) = set_fps(&fd, queue, fps) {
|
||||
warn!("Failed to set hardware FPS: {}", e);
|
||||
match set_fps(&fd, queue, fps) {
|
||||
Ok(()) => {}
|
||||
Err(ioctl::GParmError::IoctlError(err))
|
||||
if matches!(err, Errno::ENOTTY | Errno::ENOSYS | Errno::EOPNOTSUPP) => {}
|
||||
Err(e) => warn!("Failed to set hardware FPS: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +262,7 @@ impl Drop for V4l2rCaptureStream {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_fps(fd: &File, queue: QueueType, fps: u32) -> Result<()> {
|
||||
fn set_fps(fd: &File, queue: QueueType, fps: u32) -> std::result::Result<(), ioctl::GParmError> {
|
||||
let mut params = unsafe { std::mem::zeroed::<v4l2_streamparm>() };
|
||||
params.type_ = queue as u32;
|
||||
params.parm = v4l2_streamparm__bindgen_ty_1 {
|
||||
@@ -271,7 +275,6 @@ fn set_fps(fd: &File, queue: QueueType, fps: u32) -> Result<()> {
|
||||
},
|
||||
};
|
||||
|
||||
let _actual: v4l2_streamparm = ioctl::s_parm(fd, params)
|
||||
.map_err(|e| AppError::VideoError(format!("Failed to set FPS: {}", e)))?;
|
||||
let _actual: v4l2_streamparm = ioctl::s_parm(fd, params)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,591 +0,0 @@
|
||||
//! Video session management with multi-codec support
|
||||
//!
|
||||
//! This module provides session management for video streaming with:
|
||||
//! - Multi-codec support (H264, H265, VP8, VP9)
|
||||
//! - Session lifecycle management
|
||||
//! - Dynamic codec switching
|
||||
//! - Statistics and monitoring
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::encoder::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
||||
use super::encoder::BitratePreset;
|
||||
use super::format::Resolution;
|
||||
use super::frame::VideoFrame;
|
||||
use super::shared_video_pipeline::{
|
||||
EncodedVideoFrame, SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats,
|
||||
};
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
/// Maximum concurrent video sessions
|
||||
const MAX_VIDEO_SESSIONS: usize = 8;
|
||||
|
||||
/// Video session state
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VideoSessionState {
|
||||
/// Session created but not started
|
||||
Created,
|
||||
/// Session is active and streaming
|
||||
Active,
|
||||
/// Session is paused
|
||||
Paused,
|
||||
/// Session is closing
|
||||
Closing,
|
||||
/// Session is closed
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VideoSessionState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
VideoSessionState::Created => write!(f, "Created"),
|
||||
VideoSessionState::Active => write!(f, "Active"),
|
||||
VideoSessionState::Paused => write!(f, "Paused"),
|
||||
VideoSessionState::Closing => write!(f, "Closing"),
|
||||
VideoSessionState::Closed => write!(f, "Closed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Video session information
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoSessionInfo {
|
||||
/// Session ID
|
||||
pub session_id: String,
|
||||
/// Current codec
|
||||
pub codec: VideoEncoderType,
|
||||
/// Session state
|
||||
pub state: VideoSessionState,
|
||||
/// Creation time
|
||||
pub created_at: Instant,
|
||||
/// Last activity time
|
||||
pub last_activity: Instant,
|
||||
/// Frames received
|
||||
pub frames_received: u64,
|
||||
/// Bytes received
|
||||
pub bytes_received: u64,
|
||||
}
|
||||
|
||||
/// Individual video session
|
||||
struct VideoSession {
|
||||
/// Session ID
|
||||
session_id: String,
|
||||
/// Codec for this session
|
||||
codec: VideoEncoderType,
|
||||
/// Session state
|
||||
state: VideoSessionState,
|
||||
/// Creation time
|
||||
created_at: Instant,
|
||||
/// Last activity time
|
||||
last_activity: Instant,
|
||||
/// Frame receiver
|
||||
frame_rx: Option<tokio::sync::mpsc::Receiver<std::sync::Arc<EncodedVideoFrame>>>,
|
||||
/// Stats
|
||||
frames_received: u64,
|
||||
bytes_received: u64,
|
||||
}
|
||||
|
||||
impl VideoSession {
|
||||
fn new(session_id: String, codec: VideoEncoderType) -> Self {
|
||||
let now = Instant::now();
|
||||
Self {
|
||||
session_id,
|
||||
codec,
|
||||
state: VideoSessionState::Created,
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
frame_rx: None,
|
||||
frames_received: 0,
|
||||
bytes_received: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn info(&self) -> VideoSessionInfo {
|
||||
VideoSessionInfo {
|
||||
session_id: self.session_id.clone(),
|
||||
codec: self.codec,
|
||||
state: self.state,
|
||||
created_at: self.created_at,
|
||||
last_activity: self.last_activity,
|
||||
frames_received: self.frames_received,
|
||||
bytes_received: self.bytes_received,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Video session manager configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoSessionManagerConfig {
|
||||
/// Default codec
|
||||
pub default_codec: VideoEncoderType,
|
||||
/// Default resolution
|
||||
pub resolution: Resolution,
|
||||
/// Bitrate preset
|
||||
pub bitrate_preset: BitratePreset,
|
||||
/// Default FPS
|
||||
pub fps: u32,
|
||||
/// Session timeout (seconds)
|
||||
pub session_timeout_secs: u64,
|
||||
/// Encoder backend (None = auto select best available)
|
||||
pub encoder_backend: Option<EncoderBackend>,
|
||||
}
|
||||
|
||||
impl Default for VideoSessionManagerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_codec: VideoEncoderType::H264,
|
||||
resolution: Resolution::HD720,
|
||||
bitrate_preset: BitratePreset::Balanced,
|
||||
fps: 30,
|
||||
session_timeout_secs: 300,
|
||||
encoder_backend: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Video session manager
|
||||
///
|
||||
/// Manages video encoding sessions with multi-codec support.
|
||||
/// A single encoder is shared across all sessions with the same codec.
|
||||
pub struct VideoSessionManager {
|
||||
/// Configuration
|
||||
config: VideoSessionManagerConfig,
|
||||
/// Active sessions
|
||||
sessions: RwLock<HashMap<String, VideoSession>>,
|
||||
/// Current pipeline (shared across sessions with same codec)
|
||||
pipeline: RwLock<Option<Arc<SharedVideoPipeline>>>,
|
||||
/// Current codec (active pipeline codec)
|
||||
current_codec: RwLock<Option<VideoEncoderType>>,
|
||||
/// Video frame source
|
||||
frame_source: RwLock<Option<broadcast::Receiver<VideoFrame>>>,
|
||||
}
|
||||
|
||||
impl VideoSessionManager {
|
||||
/// Create a new video session manager
|
||||
pub fn new(config: VideoSessionManagerConfig) -> Self {
|
||||
info!(
|
||||
"Creating video session manager with default codec: {}",
|
||||
config.default_codec
|
||||
);
|
||||
|
||||
Self {
|
||||
config,
|
||||
sessions: RwLock::new(HashMap::new()),
|
||||
pipeline: RwLock::new(None),
|
||||
current_codec: RwLock::new(None),
|
||||
frame_source: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with default configuration
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(VideoSessionManagerConfig::default())
|
||||
}
|
||||
|
||||
/// Set the video frame source
|
||||
pub async fn set_frame_source(&self, rx: broadcast::Receiver<VideoFrame>) {
|
||||
*self.frame_source.write().await = Some(rx);
|
||||
}
|
||||
|
||||
/// Get available codecs based on hardware capabilities
|
||||
pub fn available_codecs(&self) -> Vec<VideoEncoderType> {
|
||||
EncoderRegistry::global().selectable_formats()
|
||||
}
|
||||
|
||||
/// Check if a codec is available
|
||||
pub fn is_codec_available(&self, codec: VideoEncoderType) -> bool {
|
||||
let hardware_only = codec.hardware_only();
|
||||
EncoderRegistry::global().is_format_available(codec, hardware_only)
|
||||
}
|
||||
|
||||
/// Create a new video session
|
||||
pub async fn create_session(&self, codec: Option<VideoEncoderType>) -> Result<String> {
|
||||
let sessions = self.sessions.read().await;
|
||||
if sessions.len() >= MAX_VIDEO_SESSIONS {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"Maximum video sessions ({}) reached",
|
||||
MAX_VIDEO_SESSIONS
|
||||
)));
|
||||
}
|
||||
drop(sessions);
|
||||
|
||||
// Use specified codec or default
|
||||
let codec = codec.unwrap_or(self.config.default_codec);
|
||||
|
||||
// Verify codec is available
|
||||
if !self.is_codec_available(codec) {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"Codec {} is not available on this system",
|
||||
codec
|
||||
)));
|
||||
}
|
||||
|
||||
// Generate session ID
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// Create session
|
||||
let session = VideoSession::new(session_id.clone(), codec);
|
||||
|
||||
// Store session
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.insert(session_id.clone(), session);
|
||||
|
||||
info!("Video session created: {} (codec: {})", session_id, codec);
|
||||
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// Start a video session (subscribe to encoded frames)
|
||||
pub async fn start_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<tokio::sync::mpsc::Receiver<std::sync::Arc<EncodedVideoFrame>>> {
|
||||
// Ensure pipeline is running with correct codec
|
||||
self.ensure_pipeline_for_session(session_id).await?;
|
||||
|
||||
let mut sessions = self.sessions.write().await;
|
||||
let session = sessions
|
||||
.get_mut(session_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("Session not found: {}", session_id)))?;
|
||||
|
||||
// Get pipeline and subscribe
|
||||
let pipeline = self.pipeline.read().await;
|
||||
let pipeline = pipeline
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::VideoError("Pipeline not initialized".to_string()))?;
|
||||
|
||||
let rx = pipeline.subscribe();
|
||||
session.frame_rx = Some(pipeline.subscribe());
|
||||
session.state = VideoSessionState::Active;
|
||||
session.last_activity = Instant::now();
|
||||
|
||||
info!("Video session started: {}", session_id);
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// Ensure pipeline is running with correct codec for session
|
||||
async fn ensure_pipeline_for_session(&self, session_id: &str) -> Result<()> {
|
||||
let sessions = self.sessions.read().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("Session not found: {}", session_id)))?;
|
||||
let required_codec = session.codec;
|
||||
drop(sessions);
|
||||
|
||||
let current_codec = *self.current_codec.read().await;
|
||||
|
||||
// Check if we need to create or switch pipeline
|
||||
if current_codec != Some(required_codec) {
|
||||
self.switch_pipeline_codec(required_codec).await?;
|
||||
}
|
||||
|
||||
// Ensure pipeline is started
|
||||
let pipeline = self.pipeline.read().await;
|
||||
if let Some(ref pipe) = *pipeline {
|
||||
if !pipe.is_running() {
|
||||
// Need frame source to start
|
||||
let frame_rx = {
|
||||
let source = self.frame_source.read().await;
|
||||
source.as_ref().map(|rx| rx.resubscribe())
|
||||
};
|
||||
|
||||
if let Some(rx) = frame_rx {
|
||||
drop(pipeline);
|
||||
let pipeline = self.pipeline.read().await;
|
||||
if let Some(ref pipe) = *pipeline {
|
||||
pipe.start(rx).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Switch pipeline to different codec
|
||||
async fn switch_pipeline_codec(&self, codec: VideoEncoderType) -> Result<()> {
|
||||
info!("Switching pipeline to codec: {}", codec);
|
||||
|
||||
// Stop existing pipeline
|
||||
{
|
||||
let pipeline = self.pipeline.read().await;
|
||||
if let Some(ref pipe) = *pipeline {
|
||||
pipe.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// Create new pipeline config
|
||||
let pipeline_config = SharedVideoPipelineConfig {
|
||||
resolution: self.config.resolution,
|
||||
input_format: crate::video::format::PixelFormat::Mjpeg, // Common input
|
||||
output_codec: codec,
|
||||
bitrate_preset: self.config.bitrate_preset,
|
||||
fps: self.config.fps,
|
||||
encoder_backend: self.config.encoder_backend,
|
||||
};
|
||||
|
||||
// Create new pipeline
|
||||
let new_pipeline = SharedVideoPipeline::new(pipeline_config)?;
|
||||
|
||||
// Update state
|
||||
*self.pipeline.write().await = Some(new_pipeline);
|
||||
*self.current_codec.write().await = Some(codec);
|
||||
|
||||
info!("Pipeline switched to codec: {}", codec);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get session info
|
||||
pub async fn get_session(&self, session_id: &str) -> Option<VideoSessionInfo> {
|
||||
let sessions = self.sessions.read().await;
|
||||
sessions.get(session_id).map(|s| s.info())
|
||||
}
|
||||
|
||||
/// List all sessions
|
||||
pub async fn list_sessions(&self) -> Vec<VideoSessionInfo> {
|
||||
let sessions = self.sessions.read().await;
|
||||
sessions.values().map(|s| s.info()).collect()
|
||||
}
|
||||
|
||||
/// Pause a session
|
||||
pub async fn pause_session(&self, session_id: &str) -> Result<()> {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
let session = sessions
|
||||
.get_mut(session_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("Session not found: {}", session_id)))?;
|
||||
|
||||
session.state = VideoSessionState::Paused;
|
||||
session.last_activity = Instant::now();
|
||||
|
||||
debug!("Video session paused: {}", session_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resume a session
|
||||
pub async fn resume_session(&self, session_id: &str) -> Result<()> {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
let session = sessions
|
||||
.get_mut(session_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("Session not found: {}", session_id)))?;
|
||||
|
||||
session.state = VideoSessionState::Active;
|
||||
session.last_activity = Instant::now();
|
||||
|
||||
debug!("Video session resumed: {}", session_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close a session
|
||||
pub async fn close_session(&self, session_id: &str) -> Result<()> {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(mut session) = sessions.remove(session_id) {
|
||||
session.state = VideoSessionState::Closed;
|
||||
session.frame_rx = None;
|
||||
info!("Video session closed: {}", session_id);
|
||||
}
|
||||
|
||||
// If no more sessions, consider stopping pipeline
|
||||
if sessions.is_empty() {
|
||||
drop(sessions);
|
||||
self.maybe_stop_pipeline().await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop pipeline if no active sessions
|
||||
async fn maybe_stop_pipeline(&self) {
|
||||
let sessions = self.sessions.read().await;
|
||||
let has_active = sessions
|
||||
.values()
|
||||
.any(|s| s.state == VideoSessionState::Active);
|
||||
drop(sessions);
|
||||
|
||||
if !has_active {
|
||||
let pipeline = self.pipeline.read().await;
|
||||
if let Some(ref pipe) = *pipeline {
|
||||
pipe.stop();
|
||||
debug!("Pipeline stopped - no active sessions");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleanup stale/timed out sessions
|
||||
pub async fn cleanup_stale_sessions(&self) {
|
||||
let timeout = std::time::Duration::from_secs(self.config.session_timeout_secs);
|
||||
let now = Instant::now();
|
||||
|
||||
let stale_ids: Vec<String> = {
|
||||
let sessions = self.sessions.read().await;
|
||||
sessions
|
||||
.iter()
|
||||
.filter(|(_, s)| {
|
||||
(s.state == VideoSessionState::Paused || s.state == VideoSessionState::Created)
|
||||
&& now.duration_since(s.last_activity) > timeout
|
||||
})
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect()
|
||||
};
|
||||
|
||||
if !stale_ids.is_empty() {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
for id in stale_ids {
|
||||
info!("Removing stale video session: {}", id);
|
||||
sessions.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get session count
|
||||
pub async fn session_count(&self) -> usize {
|
||||
self.sessions.read().await.len()
|
||||
}
|
||||
|
||||
/// Get active session count
|
||||
pub async fn active_session_count(&self) -> usize {
|
||||
self.sessions
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.filter(|s| s.state == VideoSessionState::Active)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Get pipeline statistics
|
||||
pub async fn pipeline_stats(&self) -> Option<SharedVideoPipelineStats> {
|
||||
let pipeline = self.pipeline.read().await;
|
||||
if let Some(ref pipe) = *pipeline {
|
||||
Some(pipe.stats().await)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current active codec
|
||||
pub async fn current_codec(&self) -> Option<VideoEncoderType> {
|
||||
*self.current_codec.read().await
|
||||
}
|
||||
|
||||
/// Set bitrate for current pipeline
|
||||
pub async fn set_bitrate(&self, bitrate_kbps: u32) -> Result<()> {
|
||||
let pipeline = self.pipeline.read().await;
|
||||
if let Some(ref pipe) = *pipeline {
|
||||
pipe.set_bitrate(bitrate_kbps).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Request keyframe for all sessions
|
||||
pub async fn request_keyframe(&self) {
|
||||
// This would be implemented if encoders support forced keyframes
|
||||
warn!("Keyframe request not yet implemented");
|
||||
}
|
||||
|
||||
/// Change codec for a session (requires restart)
|
||||
pub async fn change_session_codec(
|
||||
&self,
|
||||
session_id: &str,
|
||||
new_codec: VideoEncoderType,
|
||||
) -> Result<()> {
|
||||
if !self.is_codec_available(new_codec) {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"Codec {} is not available",
|
||||
new_codec
|
||||
)));
|
||||
}
|
||||
|
||||
let mut sessions = self.sessions.write().await;
|
||||
let session = sessions
|
||||
.get_mut(session_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("Session not found: {}", session_id)))?;
|
||||
|
||||
let old_codec = session.codec;
|
||||
session.codec = new_codec;
|
||||
session.state = VideoSessionState::Created; // Require restart
|
||||
session.frame_rx = None;
|
||||
session.last_activity = Instant::now();
|
||||
|
||||
info!(
|
||||
"Session {} codec changed: {} -> {}",
|
||||
session_id, old_codec, new_codec
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get codec info
|
||||
pub fn get_codec_info(&self, codec: VideoEncoderType) -> Option<CodecInfo> {
|
||||
let registry = EncoderRegistry::global();
|
||||
let encoder = registry.best_encoder(codec, codec.hardware_only())?;
|
||||
|
||||
Some(CodecInfo {
|
||||
codec_type: codec,
|
||||
codec_name: encoder.codec_name.clone(),
|
||||
backend: encoder.backend.to_string(),
|
||||
is_hardware: encoder.is_hardware,
|
||||
})
|
||||
}
|
||||
|
||||
/// List all available codecs with their info
|
||||
pub fn list_codec_info(&self) -> Vec<CodecInfo> {
|
||||
self.available_codecs()
|
||||
.iter()
|
||||
.filter_map(|c| self.get_codec_info(*c))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Codec information
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CodecInfo {
|
||||
/// Codec type
|
||||
pub codec_type: VideoEncoderType,
|
||||
/// FFmpeg codec name
|
||||
pub codec_name: String,
|
||||
/// Backend (VAAPI, NVENC, etc.)
|
||||
pub backend: String,
|
||||
/// Whether this is hardware accelerated
|
||||
pub is_hardware: bool,
|
||||
}
|
||||
|
||||
impl Default for VideoSessionManager {
|
||||
fn default() -> Self {
|
||||
Self::with_defaults()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_session_state_display() {
|
||||
assert_eq!(VideoSessionState::Active.to_string(), "Active");
|
||||
assert_eq!(VideoSessionState::Closed.to_string(), "Closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_available_codecs() {
|
||||
let manager = VideoSessionManager::with_defaults();
|
||||
let codecs = manager.available_codecs();
|
||||
println!("Available codecs: {:?}", codecs);
|
||||
// H264 should always be available (software fallback)
|
||||
assert!(codecs.contains(&VideoEncoderType::H264));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codec_info() {
|
||||
let manager = VideoSessionManager::with_defaults();
|
||||
let info = manager.get_codec_info(VideoEncoderType::H264);
|
||||
if let Some(info) = info {
|
||||
println!(
|
||||
"H264: {} ({}, hardware={})",
|
||||
info.codec_name, info.backend, info.is_hardware
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,32 @@ use std::sync::Arc;
|
||||
|
||||
use crate::config::*;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::events::SystemEvent;
|
||||
use crate::rtsp::RtspService;
|
||||
use crate::state::AppState;
|
||||
use crate::video::codec_constraints::{
|
||||
enforce_constraints_with_stream_manager, StreamCodecConstraints,
|
||||
};
|
||||
|
||||
fn hid_backend_type(config: &HidConfig) -> crate::hid::HidBackendType {
|
||||
match config.backend {
|
||||
HidBackend::Otg => crate::hid::HidBackendType::Otg,
|
||||
HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 {
|
||||
port: config.ch9329_port.clone(),
|
||||
baud_rate: config.ch9329_baudrate,
|
||||
},
|
||||
HidBackend::None => crate::hid::HidBackendType::None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn reconcile_otg_from_store(state: &Arc<AppState>) -> Result<()> {
|
||||
let config = state.config.get();
|
||||
state
|
||||
.otg_service
|
||||
.apply_config(&config.hid, &config.msd)
|
||||
.await
|
||||
.map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e)))
|
||||
}
|
||||
|
||||
/// 应用 Video 配置变更
|
||||
pub async fn apply_video_config(
|
||||
state: &Arc<AppState>,
|
||||
@@ -45,73 +64,11 @@ pub async fn apply_video_config(
|
||||
|
||||
let resolution = crate::video::format::Resolution::new(new_config.width, new_config.height);
|
||||
|
||||
// Step 1: 更新 WebRTC streamer 配置(停止现有 pipeline 和 sessions)
|
||||
state
|
||||
.stream_manager
|
||||
.webrtc_streamer()
|
||||
.update_video_config(resolution, format, new_config.fps)
|
||||
.await;
|
||||
tracing::info!("WebRTC streamer config updated");
|
||||
|
||||
// Step 2: 应用视频配置到 streamer(重新创建 capturer)
|
||||
state
|
||||
.stream_manager
|
||||
.streamer()
|
||||
.apply_video_config(&device, format, resolution, new_config.fps)
|
||||
.await
|
||||
.map_err(|e| AppError::VideoError(format!("Failed to apply video config: {}", e)))?;
|
||||
tracing::info!("Video config applied to streamer");
|
||||
|
||||
// Step 3: 重启 streamer(仅 MJPEG 模式)
|
||||
if !state.stream_manager.is_webrtc_enabled().await {
|
||||
if let Err(e) = state.stream_manager.start().await {
|
||||
tracing::error!("Failed to start streamer after config change: {}", e);
|
||||
} else {
|
||||
tracing::info!("Streamer started after config change");
|
||||
}
|
||||
}
|
||||
|
||||
// 配置 WebRTC direct capture(所有模式统一配置)
|
||||
let (device_path, _resolution, _format, _fps, jpeg_quality) = state
|
||||
.stream_manager
|
||||
.streamer()
|
||||
.current_capture_config()
|
||||
.await;
|
||||
if let Some(device_path) = device_path {
|
||||
state
|
||||
.stream_manager
|
||||
.webrtc_streamer()
|
||||
.set_capture_device(device_path, jpeg_quality)
|
||||
.await;
|
||||
} else {
|
||||
tracing::warn!("No capture device configured for WebRTC");
|
||||
}
|
||||
|
||||
if state.stream_manager.is_webrtc_enabled().await {
|
||||
use crate::video::encoder::VideoCodecType;
|
||||
let codec = state
|
||||
.stream_manager
|
||||
.webrtc_streamer()
|
||||
.current_video_codec()
|
||||
.await;
|
||||
let codec_str = match codec {
|
||||
VideoCodecType::H264 => "h264",
|
||||
VideoCodecType::H265 => "h265",
|
||||
VideoCodecType::VP8 => "vp8",
|
||||
VideoCodecType::VP9 => "vp9",
|
||||
}
|
||||
.to_string();
|
||||
let is_hardware = state
|
||||
.stream_manager
|
||||
.webrtc_streamer()
|
||||
.is_hardware_encoding()
|
||||
.await;
|
||||
state.events.publish(SystemEvent::WebRTCReady {
|
||||
transition_id: None,
|
||||
codec: codec_str,
|
||||
hardware: is_hardware,
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!("Video config applied successfully");
|
||||
Ok(())
|
||||
@@ -188,56 +145,26 @@ pub async fn apply_hid_config(
|
||||
old_config: &HidConfig,
|
||||
new_config: &HidConfig,
|
||||
) -> Result<()> {
|
||||
// 检查 OTG 描述符是否变更
|
||||
let current_msd_enabled = state.config.get().msd.enabled;
|
||||
new_config.validate_otg_endpoint_budget(current_msd_enabled)?;
|
||||
|
||||
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
|
||||
let old_hid_functions = old_config.effective_otg_functions();
|
||||
let mut new_hid_functions = new_config.effective_otg_functions();
|
||||
|
||||
// Low-endpoint UDCs (e.g., musb) cannot handle consumer control endpoints reliably
|
||||
if new_config.backend == HidBackend::Otg {
|
||||
if let Some(udc) = crate::otg::configfs::resolve_udc_name(new_config.otg_udc.as_deref()) {
|
||||
if crate::otg::configfs::is_low_endpoint_udc(&udc) && new_hid_functions.consumer {
|
||||
tracing::warn!(
|
||||
"UDC {} has low endpoint resources, disabling consumer control",
|
||||
udc
|
||||
);
|
||||
new_hid_functions.consumer = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let old_hid_functions = old_config.constrained_otg_functions();
|
||||
let new_hid_functions = new_config.constrained_otg_functions();
|
||||
let hid_functions_changed = old_hid_functions != new_hid_functions;
|
||||
let keyboard_leds_changed =
|
||||
old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds();
|
||||
let endpoint_budget_changed =
|
||||
old_config.resolved_otg_endpoint_limit() != new_config.resolved_otg_endpoint_limit();
|
||||
|
||||
if new_config.backend == HidBackend::Otg && new_hid_functions.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"OTG HID functions cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 如果描述符变更且当前使用 OTG 后端,需要重建 Gadget
|
||||
if descriptor_changed && new_config.backend == HidBackend::Otg {
|
||||
tracing::info!("OTG descriptor changed, updating gadget...");
|
||||
if let Err(e) = state
|
||||
.otg_service
|
||||
.update_descriptor(&new_config.otg_descriptor)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to update OTG descriptor: {}", e);
|
||||
return Err(AppError::Config(format!(
|
||||
"OTG descriptor update failed: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
tracing::info!("OTG descriptor updated successfully");
|
||||
}
|
||||
|
||||
// 检查是否需要重载 HID 后端
|
||||
if old_config.backend == new_config.backend
|
||||
&& old_config.ch9329_port == new_config.ch9329_port
|
||||
&& old_config.ch9329_baudrate == new_config.ch9329_baudrate
|
||||
&& old_config.otg_udc == new_config.otg_udc
|
||||
&& !descriptor_changed
|
||||
&& !hid_functions_changed
|
||||
&& !keyboard_leds_changed
|
||||
&& !endpoint_budget_changed
|
||||
{
|
||||
tracing::info!("HID config unchanged, skipping reload");
|
||||
return Ok(());
|
||||
@@ -245,30 +172,27 @@ pub async fn apply_hid_config(
|
||||
|
||||
tracing::info!("Applying HID config changes...");
|
||||
|
||||
if new_config.backend == HidBackend::Otg
|
||||
&& (hid_functions_changed || old_config.backend != HidBackend::Otg)
|
||||
{
|
||||
let new_hid_backend = hid_backend_type(new_config);
|
||||
let transitioning_away_from_otg =
|
||||
old_config.backend == HidBackend::Otg && new_config.backend != HidBackend::Otg;
|
||||
|
||||
if transitioning_away_from_otg {
|
||||
state
|
||||
.otg_service
|
||||
.update_hid_functions(new_hid_functions.clone())
|
||||
.hid
|
||||
.reload(new_hid_backend.clone())
|
||||
.await
|
||||
.map_err(|e| AppError::Config(format!("OTG HID function update failed: {}", e)))?;
|
||||
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
|
||||
}
|
||||
|
||||
let new_hid_backend = match new_config.backend {
|
||||
HidBackend::Otg => crate::hid::HidBackendType::Otg,
|
||||
HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 {
|
||||
port: new_config.ch9329_port.clone(),
|
||||
baud_rate: new_config.ch9329_baudrate,
|
||||
},
|
||||
HidBackend::None => crate::hid::HidBackendType::None,
|
||||
};
|
||||
reconcile_otg_from_store(state).await?;
|
||||
|
||||
state
|
||||
.hid
|
||||
.reload(new_hid_backend)
|
||||
.await
|
||||
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
|
||||
if !transitioning_away_from_otg {
|
||||
state
|
||||
.hid
|
||||
.reload(new_hid_backend)
|
||||
.await
|
||||
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"HID backend reloaded successfully: {:?}",
|
||||
@@ -284,6 +208,12 @@ pub async fn apply_msd_config(
|
||||
old_config: &MsdConfig,
|
||||
new_config: &MsdConfig,
|
||||
) -> Result<()> {
|
||||
state
|
||||
.config
|
||||
.get()
|
||||
.hid
|
||||
.validate_otg_endpoint_budget(new_config.enabled)?;
|
||||
|
||||
tracing::info!("MSD config sent, checking if reload needed...");
|
||||
tracing::debug!("Old MSD config: {:?}", old_config);
|
||||
tracing::debug!("New MSD config: {:?}", new_config);
|
||||
@@ -323,6 +253,8 @@ pub async fn apply_msd_config(
|
||||
if new_msd_enabled {
|
||||
tracing::info!("(Re)initializing MSD...");
|
||||
|
||||
reconcile_otg_from_store(state).await?;
|
||||
|
||||
// Shutdown existing controller if present
|
||||
let mut msd_guard = state.msd.write().await;
|
||||
if let Some(msd) = msd_guard.as_mut() {
|
||||
@@ -358,6 +290,17 @@ pub async fn apply_msd_config(
|
||||
}
|
||||
*msd_guard = None;
|
||||
tracing::info!("MSD shutdown complete");
|
||||
|
||||
reconcile_otg_from_store(state).await?;
|
||||
}
|
||||
|
||||
let current_config = state.config.get();
|
||||
if current_config.hid.backend == HidBackend::Otg && old_msd_enabled != new_msd_enabled {
|
||||
state
|
||||
.hid
|
||||
.reload(crate::hid::HidBackendType::Otg)
|
||||
.await
|
||||
.map_err(|e| AppError::Config(format!("OTG HID reload failed: {}", e)))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -3,7 +3,8 @@ use crate::error::AppError;
|
||||
use crate::rtsp::RtspServiceStatus;
|
||||
use crate::rustdesk::config::RustDeskConfig;
|
||||
use crate::video::encoder::BitratePreset;
|
||||
use serde::Deserialize;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use typeshare::typeshare;
|
||||
|
||||
@@ -307,7 +308,9 @@ pub struct HidConfigUpdate {
|
||||
pub otg_udc: Option<String>,
|
||||
pub otg_descriptor: Option<OtgDescriptorConfigUpdate>,
|
||||
pub otg_profile: Option<OtgHidProfile>,
|
||||
pub otg_endpoint_budget: Option<OtgEndpointBudget>,
|
||||
pub otg_functions: Option<OtgHidFunctionsUpdate>,
|
||||
pub otg_keyboard_leds: Option<bool>,
|
||||
pub mouse_absolute: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -346,9 +349,15 @@ impl HidConfigUpdate {
|
||||
if let Some(profile) = self.otg_profile.clone() {
|
||||
config.otg_profile = profile;
|
||||
}
|
||||
if let Some(budget) = self.otg_endpoint_budget {
|
||||
config.otg_endpoint_budget = budget;
|
||||
}
|
||||
if let Some(ref functions) = self.otg_functions {
|
||||
functions.apply_to(&mut config.otg_functions);
|
||||
}
|
||||
if let Some(enabled) = self.otg_keyboard_leds {
|
||||
config.otg_keyboard_leds = enabled;
|
||||
}
|
||||
if let Some(absolute) = self.mouse_absolute {
|
||||
config.mouse_absolute = absolute;
|
||||
}
|
||||
@@ -652,6 +661,26 @@ impl AudioConfigUpdate {
|
||||
}
|
||||
|
||||
// ===== RustDesk Config =====
|
||||
|
||||
/// hbbs/hbbr `-k` relay key: standard Base64 encoding of exactly 32 bytes (typically 44 chars with padding).
|
||||
fn validate_rustdesk_relay_key(key: &str) -> Result<(), AppError> {
|
||||
let decoded = STANDARD.decode(key.as_bytes()).map_err(|_| {
|
||||
AppError::BadRequest(
|
||||
"Relay key must be standard Base64 (32 raw bytes, e.g. hbbs/hbbr -k output)".into(),
|
||||
)
|
||||
})?;
|
||||
if decoded.len() != 32 {
|
||||
return Err(AppError::BadRequest(
|
||||
format!(
|
||||
"Relay key must decode to exactly 32 bytes (got {} bytes after Base64 decode)",
|
||||
decoded.len()
|
||||
)
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RustDeskConfigUpdate {
|
||||
@@ -690,6 +719,12 @@ impl RustDeskConfigUpdate {
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(ref key) = self.relay_key {
|
||||
let trimmed = key.trim();
|
||||
if !trimmed.is_empty() {
|
||||
validate_rustdesk_relay_key(trimmed)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -708,10 +743,11 @@ impl RustDeskConfigUpdate {
|
||||
};
|
||||
}
|
||||
if let Some(ref key) = self.relay_key {
|
||||
config.relay_key = if key.is_empty() {
|
||||
let trimmed = key.trim();
|
||||
config.relay_key = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(key.clone())
|
||||
Some(trimmed.to_string())
|
||||
};
|
||||
}
|
||||
if let Some(ref password) = self.device_password {
|
||||
@@ -841,6 +877,45 @@ impl RtspConfigUpdate {
|
||||
}
|
||||
|
||||
// ===== Web Config =====
|
||||
|
||||
/// Web server settings returned by `GET` / `PATCH /api/config/web`.
|
||||
///
|
||||
/// Public API shape: certificate paths on disk are not exposed. The full stored model is `WebConfig` in `config::schema`.
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebConfigResponse {
|
||||
pub http_port: u16,
|
||||
pub https_port: u16,
|
||||
pub bind_addresses: Vec<String>,
|
||||
pub bind_address: String,
|
||||
pub https_enabled: bool,
|
||||
/// Whether a custom TLS certificate is active (non-empty cert + key paths in stored config).
|
||||
pub has_custom_cert: bool,
|
||||
}
|
||||
|
||||
impl WebConfigResponse {
|
||||
pub fn from_stored(web: &WebConfig) -> Self {
|
||||
let has_custom_cert = web
|
||||
.ssl_cert_path
|
||||
.as_deref()
|
||||
.map(|p| !p.is_empty())
|
||||
.unwrap_or(false)
|
||||
&& web
|
||||
.ssl_key_path
|
||||
.as_deref()
|
||||
.map(|p| !p.is_empty())
|
||||
.unwrap_or(false);
|
||||
Self {
|
||||
http_port: web.http_port,
|
||||
https_port: web.https_port,
|
||||
bind_addresses: web.bind_addresses.clone(),
|
||||
bind_address: web.bind_address.clone(),
|
||||
https_enabled: web.https_enabled,
|
||||
has_custom_cert,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WebConfigUpdate {
|
||||
@@ -849,6 +924,12 @@ pub struct WebConfigUpdate {
|
||||
pub bind_addresses: Option<Vec<String>>,
|
||||
pub bind_address: Option<String>,
|
||||
pub https_enabled: Option<bool>,
|
||||
/// PEM-encoded certificate content (must be provided together with ssl_key_pem)
|
||||
pub ssl_cert_pem: Option<String>,
|
||||
/// PEM-encoded private key content (must be provided together with ssl_cert_pem)
|
||||
pub ssl_key_pem: Option<String>,
|
||||
/// Set to true to remove the custom certificate and revert to self-signed
|
||||
pub clear_custom_cert: Option<bool>,
|
||||
}
|
||||
|
||||
impl WebConfigUpdate {
|
||||
@@ -875,6 +956,22 @@ impl WebConfigUpdate {
|
||||
return Err(AppError::BadRequest("Invalid bind address".into()));
|
||||
}
|
||||
}
|
||||
// Cert and key must be provided together (cryptographic validity is checked in the
|
||||
// handler via `RustlsConfig::from_pem`, same stack as the running HTTPS server).
|
||||
match (&self.ssl_cert_pem, &self.ssl_key_pem) {
|
||||
(Some(_cert), Some(_key)) => {}
|
||||
(Some(_), None) => {
|
||||
return Err(AppError::BadRequest(
|
||||
"ssl_key_pem is required when ssl_cert_pem is provided".into(),
|
||||
));
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
return Err(AppError::BadRequest(
|
||||
"ssl_cert_pem is required when ssl_key_pem is provided".into(),
|
||||
));
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -899,6 +996,8 @@ impl WebConfigUpdate {
|
||||
if let Some(enabled) = self.https_enabled {
|
||||
config.https_enabled = enabled;
|
||||
}
|
||||
// ssl_cert_pem, ssl_key_pem, clear_custom_cert are handled at the handler level
|
||||
// (they require async file I/O before updating config paths)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -980,4 +1079,30 @@ mod tests {
|
||||
|
||||
assert!(update.validate_with_current(¤t).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rustdesk_relay_key_accepts_hbbs_style_base64_32_bytes() {
|
||||
let update = RustDeskConfigUpdate {
|
||||
enabled: None,
|
||||
rendezvous_server: None,
|
||||
relay_server: None,
|
||||
relay_key: Some("pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=".to_string()),
|
||||
device_password: None,
|
||||
};
|
||||
assert!(update.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rustdesk_relay_key_rejects_non_32_byte_payload() {
|
||||
// Standard Base64 for 16 zero bytes (not 32).
|
||||
let not_32 = "AAAAAAAAAAAAAAAAAAAAAA==".to_string();
|
||||
let update = RustDeskConfigUpdate {
|
||||
enabled: None,
|
||||
rendezvous_server: None,
|
||||
relay_server: None,
|
||||
relay_key: Some(not_32),
|
||||
device_password: None,
|
||||
};
|
||||
assert!(update.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,103 @@
|
||||
//! Web 服务器配置 Handler
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::WebConfig;
|
||||
use crate::error::Result;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::types::WebConfigUpdate;
|
||||
use super::types::{WebConfigResponse, WebConfigUpdate};
|
||||
|
||||
/// 获取 Web 配置
|
||||
pub async fn get_web_config(State(state): State<Arc<AppState>>) -> Json<WebConfig> {
|
||||
Json(state.config.get().web.clone())
|
||||
pub async fn get_web_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Json<WebConfigResponse> {
|
||||
Json(WebConfigResponse::from_stored(&state.config.get().web))
|
||||
}
|
||||
|
||||
/// 更新 Web 配置
|
||||
/// 更新 Web 配置(支持 PEM 证书上传)
|
||||
pub async fn update_web_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<WebConfigUpdate>,
|
||||
) -> Result<Json<WebConfig>> {
|
||||
) -> Result<Json<WebConfigResponse>> {
|
||||
req.validate()?;
|
||||
|
||||
// Determine certificate path changes (requires async file I/O before config update)
|
||||
// Some(Some((cert, key))) = write new cert
|
||||
// Some(None) = clear custom cert
|
||||
// None = no cert change
|
||||
let cert_path_update: Option<Option<(String, String)>> =
|
||||
if let (Some(cert_pem), Some(key_pem)) = (&req.ssl_cert_pem, &req.ssl_key_pem) {
|
||||
RustlsConfig::from_pem(cert_pem.as_bytes().to_vec(), key_pem.as_bytes().to_vec())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::BadRequest(
|
||||
format!(
|
||||
"Invalid TLS certificate or private key (PEM must match what the HTTPS server can load): {e}"
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let cert_dir = state.data_dir().join("certs");
|
||||
tokio::fs::create_dir_all(&cert_dir)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to create cert dir: {e}")))?;
|
||||
let cert_path = cert_dir.join("custom.crt");
|
||||
let key_path = cert_dir.join("custom.key");
|
||||
tokio::fs::write(&cert_path, cert_pem.as_bytes())
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to write certificate: {e}")))?;
|
||||
tokio::fs::write(&key_path, key_pem.as_bytes())
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to write private key: {e}")))?;
|
||||
Some(Some((
|
||||
cert_path.to_string_lossy().into_owned(),
|
||||
key_path.to_string_lossy().into_owned(),
|
||||
)))
|
||||
} else if req.clear_custom_cert.unwrap_or(false) {
|
||||
let cert_dir = state.data_dir().join("certs");
|
||||
let _ = tokio::fs::remove_file(cert_dir.join("custom.crt")).await;
|
||||
let _ = tokio::fs::remove_file(cert_dir.join("custom.key")).await;
|
||||
Some(None)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
.update(move |config| {
|
||||
req.apply_to(&mut config.web);
|
||||
match cert_path_update {
|
||||
Some(Some((cert_path, key_path))) => {
|
||||
config.web.ssl_cert_path = Some(cert_path);
|
||||
config.web.ssl_key_path = Some(key_path);
|
||||
}
|
||||
Some(None) => {
|
||||
config.web.ssl_cert_path = None;
|
||||
config.web.ssl_key_path = None;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(Json(state.config.get().web.clone()))
|
||||
Ok(Json(WebConfigResponse::from_stored(&state.config.get().web)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rustls::crypto::{ring, CryptoProvider};
|
||||
|
||||
#[tokio::test]
|
||||
async fn rustls_accepts_rcgen_self_signed_pem() {
|
||||
let _ = CryptoProvider::install_default(ring::default_provider());
|
||||
let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
|
||||
let cert_pem = cert.cert.pem();
|
||||
let key_pem = cert.signing_key.serialize_pem();
|
||||
RustlsConfig::from_pem(cert_pem.into_bytes(), key_pem.into_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use typeshare::typeshare;
|
||||
|
||||
@@ -256,12 +256,14 @@ pub async fn update_gostc_config(
|
||||
let new_config = state.config.get();
|
||||
let is_enabled = new_config.extensions.gostc.enabled;
|
||||
let has_key = !new_config.extensions.gostc.key.is_empty();
|
||||
let has_addr = !new_config.extensions.gostc.addr.trim().is_empty();
|
||||
|
||||
if was_enabled && !is_enabled {
|
||||
state.extensions.stop(ExtensionId::Gostc).await.ok();
|
||||
} else if !was_enabled
|
||||
&& is_enabled
|
||||
&& has_key
|
||||
&& has_addr
|
||||
&& state.extensions.check_available(ExtensionId::Gostc)
|
||||
{
|
||||
state
|
||||
@@ -324,27 +326,3 @@ pub async fn update_easytier_config(
|
||||
|
||||
Ok(Json(new_config.extensions.easytier.clone()))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ttyd status for console (simplified)
|
||||
// ============================================================================
|
||||
|
||||
/// Simple ttyd status for console view
|
||||
#[typeshare]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TtydStatus {
|
||||
pub available: bool,
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
/// Get ttyd status for console view
|
||||
/// GET /api/extensions/ttyd/status
|
||||
pub async fn get_ttyd_status(State(state): State<Arc<AppState>>) -> Json<TtydStatus> {
|
||||
let mgr = &state.extensions;
|
||||
let status = mgr.status(ExtensionId::Ttyd).await;
|
||||
|
||||
Json(TtydStatus {
|
||||
available: mgr.check_available(ExtensionId::Ttyd),
|
||||
running: status.is_running(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ use tracing::{info, warn};
|
||||
use crate::auth::{Session, SESSION_COOKIE};
|
||||
use crate::config::{AppConfig, StreamMode};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::events::SystemEvent;
|
||||
use crate::state::AppState;
|
||||
use crate::update::{UpdateChannel, UpdateOverviewResponse, UpdateStatusResponse, UpgradeRequest};
|
||||
use crate::video::codec_constraints::codec_to_id;
|
||||
use crate::video::encoder::BitratePreset;
|
||||
use crate::video::encoder::{
|
||||
build_hardware_self_check_runtime_error, run_hardware_self_check, BitratePreset,
|
||||
VideoEncoderSelfCheckResponse,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Health & Info
|
||||
@@ -596,38 +598,14 @@ pub struct SetupRequest {
|
||||
pub hid_ch9329_baudrate: Option<u32>,
|
||||
pub hid_otg_udc: Option<String>,
|
||||
pub hid_otg_profile: Option<String>,
|
||||
pub hid_otg_endpoint_budget: Option<crate::config::OtgEndpointBudget>,
|
||||
pub hid_otg_keyboard_leds: Option<bool>,
|
||||
pub msd_enabled: Option<bool>,
|
||||
// Extension settings
|
||||
pub ttyd_enabled: Option<bool>,
|
||||
pub rustdesk_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
fn normalize_otg_profile_for_low_endpoint(config: &mut AppConfig) {
|
||||
if !matches!(config.hid.backend, crate::config::HidBackend::Otg) {
|
||||
return;
|
||||
}
|
||||
let udc = crate::otg::configfs::resolve_udc_name(config.hid.otg_udc.as_deref());
|
||||
let Some(udc) = udc else {
|
||||
return;
|
||||
};
|
||||
if !crate::otg::configfs::is_low_endpoint_udc(&udc) {
|
||||
return;
|
||||
}
|
||||
match config.hid.otg_profile {
|
||||
crate::config::OtgHidProfile::Full => {
|
||||
config.hid.otg_profile = crate::config::OtgHidProfile::FullNoConsumer;
|
||||
}
|
||||
crate::config::OtgHidProfile::FullNoMsd => {
|
||||
config.hid.otg_profile = crate::config::OtgHidProfile::FullNoConsumerNoMsd;
|
||||
}
|
||||
crate::config::OtgHidProfile::Custom => {
|
||||
if config.hid.otg_functions.consumer {
|
||||
config.hid.otg_functions.consumer = false;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn setup_init(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<SetupRequest>,
|
||||
@@ -701,32 +679,19 @@ pub async fn setup_init(
|
||||
config.hid.otg_udc = Some(udc);
|
||||
}
|
||||
if let Some(profile) = req.hid_otg_profile.clone() {
|
||||
config.hid.otg_profile = match profile.as_str() {
|
||||
"full" => crate::config::OtgHidProfile::Full,
|
||||
"full_no_msd" => crate::config::OtgHidProfile::FullNoMsd,
|
||||
"full_no_consumer" => crate::config::OtgHidProfile::FullNoConsumer,
|
||||
"full_no_consumer_no_msd" => crate::config::OtgHidProfile::FullNoConsumerNoMsd,
|
||||
"legacy_keyboard" => crate::config::OtgHidProfile::LegacyKeyboard,
|
||||
"legacy_mouse_relative" => crate::config::OtgHidProfile::LegacyMouseRelative,
|
||||
"custom" => crate::config::OtgHidProfile::Custom,
|
||||
_ => config.hid.otg_profile.clone(),
|
||||
};
|
||||
if matches!(config.hid.backend, crate::config::HidBackend::Otg) {
|
||||
match config.hid.otg_profile {
|
||||
crate::config::OtgHidProfile::Full
|
||||
| crate::config::OtgHidProfile::FullNoConsumer => {
|
||||
config.msd.enabled = true;
|
||||
}
|
||||
crate::config::OtgHidProfile::FullNoMsd
|
||||
| crate::config::OtgHidProfile::FullNoConsumerNoMsd
|
||||
| crate::config::OtgHidProfile::LegacyKeyboard
|
||||
| crate::config::OtgHidProfile::LegacyMouseRelative => {
|
||||
config.msd.enabled = false;
|
||||
}
|
||||
crate::config::OtgHidProfile::Custom => {}
|
||||
}
|
||||
if let Some(parsed) = crate::config::OtgHidProfile::from_legacy_str(&profile) {
|
||||
config.hid.otg_profile = parsed;
|
||||
}
|
||||
}
|
||||
if let Some(budget) = req.hid_otg_endpoint_budget {
|
||||
config.hid.otg_endpoint_budget = budget;
|
||||
}
|
||||
if let Some(enabled) = req.hid_otg_keyboard_leds {
|
||||
config.hid.otg_keyboard_leds = enabled;
|
||||
}
|
||||
if let Some(enabled) = req.msd_enabled {
|
||||
config.msd.enabled = enabled;
|
||||
}
|
||||
|
||||
// Extension settings
|
||||
if let Some(enabled) = req.ttyd_enabled {
|
||||
@@ -735,29 +700,18 @@ pub async fn setup_init(
|
||||
if let Some(enabled) = req.rustdesk_enabled {
|
||||
config.rustdesk.enabled = enabled;
|
||||
}
|
||||
|
||||
normalize_otg_profile_for_low_endpoint(config);
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Get updated config for HID reload
|
||||
let new_config = state.config.get();
|
||||
|
||||
if matches!(new_config.hid.backend, crate::config::HidBackend::Otg) {
|
||||
let mut hid_functions = new_config.hid.effective_otg_functions();
|
||||
if let Some(udc) = crate::otg::configfs::resolve_udc_name(new_config.hid.otg_udc.as_deref())
|
||||
{
|
||||
if crate::otg::configfs::is_low_endpoint_udc(&udc) && hid_functions.consumer {
|
||||
tracing::warn!(
|
||||
"UDC {} has low endpoint resources, disabling consumer control",
|
||||
udc
|
||||
);
|
||||
hid_functions.consumer = false;
|
||||
}
|
||||
}
|
||||
if let Err(e) = state.otg_service.update_hid_functions(hid_functions).await {
|
||||
tracing::warn!("Failed to apply HID functions during setup: {}", e);
|
||||
}
|
||||
if let Err(e) = state
|
||||
.otg_service
|
||||
.apply_config(&new_config.hid, &new_config.msd)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to apply OTG config during setup: {}", e);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
@@ -879,8 +833,10 @@ pub async fn update_config(
|
||||
let new_config: AppConfig = serde_json::from_value(merged)
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid config format: {}", e)))?;
|
||||
|
||||
let mut new_config = new_config;
|
||||
normalize_otg_profile_for_low_endpoint(&mut new_config);
|
||||
let new_config = new_config;
|
||||
new_config
|
||||
.hid
|
||||
.validate_otg_endpoint_budget(new_config.msd.enabled)?;
|
||||
|
||||
// Apply the validated config
|
||||
state.config.set(new_config.clone()).await?;
|
||||
@@ -908,297 +864,76 @@ pub async fn update_config(
|
||||
// Get new config for device reloading
|
||||
let new_config = state.config.get();
|
||||
|
||||
// Video config processing - always reload if section was sent
|
||||
if has_video {
|
||||
tracing::info!("Video config sent, applying settings...");
|
||||
|
||||
let device = new_config
|
||||
.video
|
||||
.device
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::BadRequest("video_device is required".to_string()))?;
|
||||
|
||||
// Map to PixelFormat/Resolution
|
||||
let format = new_config
|
||||
.video
|
||||
.format
|
||||
.as_ref()
|
||||
.and_then(|f| {
|
||||
serde_json::from_value::<crate::video::format::PixelFormat>(
|
||||
serde_json::Value::String(f.clone()),
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.unwrap_or(crate::video::format::PixelFormat::Mjpeg);
|
||||
let resolution =
|
||||
crate::video::format::Resolution::new(new_config.video.width, new_config.video.height);
|
||||
|
||||
// Step 1: Update WebRTC streamer config FIRST
|
||||
// This stops the shared pipeline and closes existing sessions BEFORE capturer is recreated
|
||||
// This ensures the pipeline won't be subscribed to a stale frame source
|
||||
state
|
||||
.stream_manager
|
||||
.webrtc_streamer()
|
||||
.update_video_config(resolution, format, new_config.video.fps)
|
||||
.await;
|
||||
tracing::info!("WebRTC streamer config updated (pipeline stopped, sessions closed)");
|
||||
|
||||
// Step 2: Apply video config to streamer (recreates capturer)
|
||||
if let Err(e) = state
|
||||
.stream_manager
|
||||
.streamer()
|
||||
.apply_video_config(&device, format, resolution, new_config.video.fps)
|
||||
.await
|
||||
if let Err(e) =
|
||||
config::apply::apply_video_config(&state, &old_config.video, &new_config.video).await
|
||||
{
|
||||
tracing::error!("Failed to apply video config: {}", e);
|
||||
// Rollback config on failure
|
||||
state.config.set((*old_config).clone()).await?;
|
||||
return Ok(Json(LoginResponse {
|
||||
success: false,
|
||||
message: Some(format!("Video configuration invalid: {}", e)),
|
||||
}));
|
||||
}
|
||||
tracing::info!("Video config applied successfully");
|
||||
|
||||
// Step 3: Start the streamer to begin capturing frames (MJPEG mode only)
|
||||
if !state.stream_manager.is_webrtc_enabled().await {
|
||||
// This is necessary because apply_video_config only creates the capturer but doesn't start it
|
||||
if let Err(e) = state.stream_manager.start().await {
|
||||
tracing::error!("Failed to start streamer after config change: {}", e);
|
||||
// Don't fail the request - the stream might start later when client connects
|
||||
} else {
|
||||
tracing::info!("Streamer started after config change");
|
||||
}
|
||||
}
|
||||
|
||||
// Configure WebRTC direct capture (all modes)
|
||||
let (device_path, _resolution, _format, _fps, jpeg_quality) = state
|
||||
.stream_manager
|
||||
.streamer()
|
||||
.current_capture_config()
|
||||
.await;
|
||||
if let Some(device_path) = device_path {
|
||||
state
|
||||
.stream_manager
|
||||
.webrtc_streamer()
|
||||
.set_capture_device(device_path, jpeg_quality)
|
||||
.await;
|
||||
} else {
|
||||
tracing::warn!("No capture device configured for WebRTC");
|
||||
}
|
||||
|
||||
if state.stream_manager.is_webrtc_enabled().await {
|
||||
use crate::video::encoder::VideoCodecType;
|
||||
let codec = state
|
||||
.stream_manager
|
||||
.webrtc_streamer()
|
||||
.current_video_codec()
|
||||
.await;
|
||||
let codec_str = match codec {
|
||||
VideoCodecType::H264 => "h264",
|
||||
VideoCodecType::H265 => "h265",
|
||||
VideoCodecType::VP8 => "vp8",
|
||||
VideoCodecType::VP9 => "vp9",
|
||||
}
|
||||
.to_string();
|
||||
let is_hardware = state
|
||||
.stream_manager
|
||||
.webrtc_streamer()
|
||||
.is_hardware_encoding()
|
||||
.await;
|
||||
state.events.publish(SystemEvent::WebRTCReady {
|
||||
transition_id: None,
|
||||
codec: codec_str,
|
||||
hardware: is_hardware,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Stream config processing (encoder backend, bitrate, etc.)
|
||||
if has_stream {
|
||||
tracing::info!("Stream config sent, applying encoder settings...");
|
||||
|
||||
// Update WebRTC streamer encoder backend
|
||||
let encoder_backend = new_config.stream.encoder.to_backend();
|
||||
tracing::info!(
|
||||
"Updating encoder backend to: {:?} (from config: {:?})",
|
||||
encoder_backend,
|
||||
new_config.stream.encoder
|
||||
);
|
||||
|
||||
state
|
||||
.stream_manager
|
||||
.webrtc_streamer()
|
||||
.update_encoder_backend(encoder_backend)
|
||||
.await;
|
||||
|
||||
// Update bitrate if changed
|
||||
state
|
||||
.stream_manager
|
||||
.webrtc_streamer()
|
||||
.set_bitrate_preset(new_config.stream.bitrate_preset)
|
||||
.await
|
||||
.ok(); // Ignore error if no active stream
|
||||
|
||||
tracing::info!(
|
||||
"Stream config applied: encoder={:?}, bitrate={}",
|
||||
new_config.stream.encoder,
|
||||
new_config.stream.bitrate_preset
|
||||
);
|
||||
if let Err(e) =
|
||||
config::apply::apply_stream_config(&state, &old_config.stream, &new_config.stream).await
|
||||
{
|
||||
tracing::error!("Failed to apply stream config: {}", e);
|
||||
state.config.set((*old_config).clone()).await?;
|
||||
return Ok(Json(LoginResponse {
|
||||
success: false,
|
||||
message: Some(format!("Stream configuration invalid: {}", e)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// HID config processing - always reload if section was sent
|
||||
if has_hid {
|
||||
tracing::info!("HID config sent, reloading HID backend...");
|
||||
|
||||
// Determine new backend type
|
||||
let new_hid_backend = match new_config.hid.backend {
|
||||
crate::config::HidBackend::Otg => crate::hid::HidBackendType::Otg,
|
||||
crate::config::HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 {
|
||||
port: new_config.hid.ch9329_port.clone(),
|
||||
baud_rate: new_config.hid.ch9329_baudrate,
|
||||
},
|
||||
crate::config::HidBackend::None => crate::hid::HidBackendType::None,
|
||||
};
|
||||
|
||||
// Reload HID backend - return success=false on error
|
||||
if let Err(e) = state.hid.reload(new_hid_backend).await {
|
||||
if let Err(e) =
|
||||
config::apply::apply_hid_config(&state, &old_config.hid, &new_config.hid).await
|
||||
{
|
||||
tracing::error!("HID reload failed: {}", e);
|
||||
// Rollback config on failure
|
||||
state.config.set((*old_config).clone()).await?;
|
||||
return Ok(Json(LoginResponse {
|
||||
success: false,
|
||||
message: Some(format!("HID configuration invalid: {}", e)),
|
||||
}));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"HID backend reloaded successfully: {:?}",
|
||||
new_config.hid.backend
|
||||
);
|
||||
}
|
||||
|
||||
// Audio config processing - always reload if section was sent
|
||||
if has_audio {
|
||||
tracing::info!("Audio config sent, applying settings...");
|
||||
|
||||
// Create audio controller config from new config
|
||||
let audio_config = crate::audio::AudioControllerConfig {
|
||||
enabled: new_config.audio.enabled,
|
||||
device: new_config.audio.device.clone(),
|
||||
quality: crate::audio::AudioQuality::from_str(&new_config.audio.quality),
|
||||
};
|
||||
|
||||
// Update audio controller
|
||||
if let Err(e) = state.audio.update_config(audio_config).await {
|
||||
tracing::error!("Audio config update failed: {}", e);
|
||||
// Don't rollback config for audio errors - it's not critical
|
||||
// Just log the error
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Audio config applied: enabled={}, device={}",
|
||||
new_config.audio.enabled,
|
||||
new_config.audio.device
|
||||
);
|
||||
}
|
||||
|
||||
// Also update WebRTC audio enabled state
|
||||
if let Err(e) = state
|
||||
.stream_manager
|
||||
.set_webrtc_audio_enabled(new_config.audio.enabled)
|
||||
.await
|
||||
if let Err(e) =
|
||||
config::apply::apply_audio_config(&state, &old_config.audio, &new_config.audio).await
|
||||
{
|
||||
tracing::warn!("Failed to update WebRTC audio state: {}", e);
|
||||
} else {
|
||||
tracing::info!("WebRTC audio enabled: {}", new_config.audio.enabled);
|
||||
}
|
||||
|
||||
// Reconnect audio sources for existing WebRTC sessions
|
||||
// This is needed because the audio controller was restarted with new config
|
||||
if new_config.audio.enabled {
|
||||
state.stream_manager.reconnect_webrtc_audio_sources().await;
|
||||
tracing::warn!("Audio config update failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// MSD config processing - reload if enabled state or directory changed
|
||||
if has_msd {
|
||||
tracing::info!("MSD config sent, checking if reload needed...");
|
||||
tracing::debug!("Old MSD config: {:?}", old_config.msd);
|
||||
tracing::debug!("New MSD config: {:?}", new_config.msd);
|
||||
|
||||
let old_msd_enabled = old_config.msd.enabled;
|
||||
let new_msd_enabled = new_config.msd.enabled;
|
||||
let msd_dir_changed = old_config.msd.msd_dir != new_config.msd.msd_dir;
|
||||
|
||||
tracing::info!(
|
||||
"MSD enabled: old={}, new={}",
|
||||
old_msd_enabled,
|
||||
new_msd_enabled
|
||||
);
|
||||
if msd_dir_changed {
|
||||
tracing::info!("MSD directory changed: {}", new_config.msd.msd_dir);
|
||||
if let Err(e) =
|
||||
config::apply::apply_msd_config(&state, &old_config.msd, &new_config.msd).await
|
||||
{
|
||||
tracing::error!("MSD initialization failed: {}", e);
|
||||
state.config.set((*old_config).clone()).await?;
|
||||
return Ok(Json(LoginResponse {
|
||||
success: false,
|
||||
message: Some(format!("MSD initialization failed: {}", e)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure MSD directories exist (msd/images, msd/ventoy)
|
||||
let msd_dir = new_config.msd.msd_dir_path();
|
||||
if let Err(e) = std::fs::create_dir_all(msd_dir.join("images")) {
|
||||
tracing::warn!("Failed to create MSD images directory: {}", e);
|
||||
}
|
||||
if let Err(e) = std::fs::create_dir_all(msd_dir.join("ventoy")) {
|
||||
tracing::warn!("Failed to create MSD ventoy directory: {}", e);
|
||||
}
|
||||
|
||||
let needs_reload = old_msd_enabled != new_msd_enabled || msd_dir_changed;
|
||||
if !needs_reload {
|
||||
tracing::info!(
|
||||
"MSD enabled state unchanged ({}) and directory unchanged, no reload needed",
|
||||
new_msd_enabled
|
||||
);
|
||||
} else if new_msd_enabled {
|
||||
tracing::info!("(Re)initializing MSD...");
|
||||
|
||||
// Shutdown existing controller if present
|
||||
let mut msd_guard = state.msd.write().await;
|
||||
if let Some(msd) = msd_guard.as_mut() {
|
||||
if let Err(e) = msd.shutdown().await {
|
||||
tracing::warn!("MSD shutdown failed: {}", e);
|
||||
}
|
||||
}
|
||||
*msd_guard = None;
|
||||
drop(msd_guard);
|
||||
|
||||
let msd = crate::msd::MsdController::new(
|
||||
state.otg_service.clone(),
|
||||
new_config.msd.msd_dir_path(),
|
||||
);
|
||||
if let Err(e) = msd.init().await {
|
||||
tracing::error!("MSD initialization failed: {}", e);
|
||||
// Rollback config on failure
|
||||
state.config.set((*old_config).clone()).await?;
|
||||
return Ok(Json(LoginResponse {
|
||||
success: false,
|
||||
message: Some(format!("MSD initialization failed: {}", e)),
|
||||
}));
|
||||
}
|
||||
|
||||
// Set event bus
|
||||
let events = state.events.clone();
|
||||
msd.set_event_bus(events).await;
|
||||
|
||||
// Store the initialized controller
|
||||
*state.msd.write().await = Some(msd);
|
||||
tracing::info!("MSD initialized successfully");
|
||||
} else {
|
||||
tracing::info!("MSD disabled in config, shutting down...");
|
||||
|
||||
let mut msd_guard = state.msd.write().await;
|
||||
if let Some(msd) = msd_guard.as_mut() {
|
||||
if let Err(e) = msd.shutdown().await {
|
||||
tracing::warn!("MSD shutdown failed: {}", e);
|
||||
}
|
||||
}
|
||||
*msd_guard = None;
|
||||
tracing::info!("MSD shutdown complete");
|
||||
if has_atx {
|
||||
if let Err(e) =
|
||||
config::apply::apply_atx_config(&state, &old_config.atx, &new_config.atx).await
|
||||
{
|
||||
tracing::error!("ATX configuration invalid: {}", e);
|
||||
state.config.set((*old_config).clone()).await?;
|
||||
return Ok(Json(LoginResponse {
|
||||
success: false,
|
||||
message: Some(format!("ATX configuration invalid: {}", e)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1266,7 +1001,7 @@ pub struct VideoFormat {
|
||||
pub struct VideoResolution {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: Vec<u32>,
|
||||
pub fps: Vec<f64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -1547,7 +1282,29 @@ pub async fn stream_mode_set(
|
||||
}
|
||||
}
|
||||
|
||||
// Set video codec if switching to WebRTC mode with specific codec
|
||||
let requested_mode_str = match (&new_mode, &video_codec) {
|
||||
(StreamMode::Mjpeg, _) => "mjpeg",
|
||||
(StreamMode::WebRTC, Some(VideoCodecType::H264)) => "h264",
|
||||
(StreamMode::WebRTC, Some(VideoCodecType::H265)) => "h265",
|
||||
(StreamMode::WebRTC, Some(VideoCodecType::VP8)) => "vp8",
|
||||
(StreamMode::WebRTC, Some(VideoCodecType::VP9)) => "vp9",
|
||||
(StreamMode::WebRTC, None) => "webrtc",
|
||||
};
|
||||
|
||||
// Detect codec-only switch: already in WebRTC mode, just changing codec.
|
||||
// switch_mode_transaction treats this as "no switch needed" since StreamMode
|
||||
// is still WebRTC, so we handle codec change + event emission here.
|
||||
let current_mode = state.stream_manager.current_mode().await;
|
||||
let prev_codec = state
|
||||
.stream_manager
|
||||
.webrtc_streamer()
|
||||
.current_video_codec()
|
||||
.await;
|
||||
|
||||
let codec_changed = video_codec.is_some_and(|c| c != prev_codec);
|
||||
let is_codec_only_switch =
|
||||
current_mode == StreamMode::WebRTC && new_mode == StreamMode::WebRTC && codec_changed;
|
||||
|
||||
if let Some(codec) = video_codec {
|
||||
info!("Setting WebRTC video codec to {:?}", codec);
|
||||
if let Err(e) = state
|
||||
@@ -1560,22 +1317,30 @@ pub async fn stream_mode_set(
|
||||
}
|
||||
}
|
||||
|
||||
// For codec-only switch, emit events directly instead of going through
|
||||
// switch_mode_transaction (which short-circuits when mode is unchanged).
|
||||
if is_codec_only_switch {
|
||||
let transition_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
state
|
||||
.stream_manager
|
||||
.notify_codec_switch(&transition_id, requested_mode_str, &codec_to_id(prev_codec))
|
||||
.await;
|
||||
|
||||
return Ok(Json(StreamModeResponse {
|
||||
success: true,
|
||||
mode: requested_mode_str.to_string(),
|
||||
transition_id: Some(transition_id),
|
||||
switching: false,
|
||||
message: Some(format!("Codec switched to {}", requested_mode_str)),
|
||||
}));
|
||||
}
|
||||
|
||||
let tx = state
|
||||
.stream_manager
|
||||
.switch_mode_transaction(new_mode.clone())
|
||||
.await?;
|
||||
|
||||
// Return the requested codec identifier (for UI display). The actual active mode
|
||||
// may differ if the request was rejected due to an in-progress switch.
|
||||
let requested_mode_str = match (&new_mode, &video_codec) {
|
||||
(StreamMode::Mjpeg, _) => "mjpeg",
|
||||
(StreamMode::WebRTC, Some(VideoCodecType::H264)) => "h264",
|
||||
(StreamMode::WebRTC, Some(VideoCodecType::H265)) => "h265",
|
||||
(StreamMode::WebRTC, Some(VideoCodecType::VP8)) => "vp8",
|
||||
(StreamMode::WebRTC, Some(VideoCodecType::VP9)) => "vp9",
|
||||
(StreamMode::WebRTC, None) => "webrtc",
|
||||
};
|
||||
|
||||
let active_mode_str = match state.stream_manager.current_mode().await {
|
||||
StreamMode::Mjpeg => "mjpeg".to_string(),
|
||||
StreamMode::WebRTC => {
|
||||
@@ -1798,7 +1563,7 @@ pub async fn stream_codecs_list() -> Json<AvailableCodecsResponse> {
|
||||
});
|
||||
|
||||
// Check H264 availability (supports software fallback)
|
||||
let h264_encoder = registry.best_encoder(VideoEncoderType::H264, false);
|
||||
let h264_encoder = registry.best_available_encoder(VideoEncoderType::H264);
|
||||
codecs.push(VideoCodecInfo {
|
||||
id: "h264".to_string(),
|
||||
name: "H.264 / WebRTC".to_string(),
|
||||
@@ -1809,7 +1574,7 @@ pub async fn stream_codecs_list() -> Json<AvailableCodecsResponse> {
|
||||
});
|
||||
|
||||
// Check H265 availability (now supports software too)
|
||||
let h265_encoder = registry.best_encoder(VideoEncoderType::H265, false);
|
||||
let h265_encoder = registry.best_available_encoder(VideoEncoderType::H265);
|
||||
codecs.push(VideoCodecInfo {
|
||||
id: "h265".to_string(),
|
||||
name: "H.265 / WebRTC".to_string(),
|
||||
@@ -1820,7 +1585,7 @@ pub async fn stream_codecs_list() -> Json<AvailableCodecsResponse> {
|
||||
});
|
||||
|
||||
// Check VP8 availability (now supports software too)
|
||||
let vp8_encoder = registry.best_encoder(VideoEncoderType::VP8, false);
|
||||
let vp8_encoder = registry.best_available_encoder(VideoEncoderType::VP8);
|
||||
codecs.push(VideoCodecInfo {
|
||||
id: "vp8".to_string(),
|
||||
name: "VP8 / WebRTC".to_string(),
|
||||
@@ -1831,7 +1596,7 @@ pub async fn stream_codecs_list() -> Json<AvailableCodecsResponse> {
|
||||
});
|
||||
|
||||
// Check VP9 availability (now supports software too)
|
||||
let vp9_encoder = registry.best_encoder(VideoEncoderType::VP9, false);
|
||||
let vp9_encoder = registry.best_available_encoder(VideoEncoderType::VP9);
|
||||
codecs.push(VideoCodecInfo {
|
||||
id: "vp9".to_string(),
|
||||
name: "VP9 / WebRTC".to_string(),
|
||||
@@ -1848,6 +1613,15 @@ pub async fn stream_codecs_list() -> Json<AvailableCodecsResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Run hardware encoder smoke tests across common resolutions/codecs.
|
||||
pub async fn video_encoder_self_check() -> Json<VideoEncoderSelfCheckResponse> {
|
||||
let response = tokio::task::spawn_blocking(run_hardware_self_check)
|
||||
.await
|
||||
.unwrap_or_else(|_| build_hardware_self_check_runtime_error());
|
||||
|
||||
Json(response)
|
||||
}
|
||||
|
||||
/// Query parameters for MJPEG stream
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct MjpegStreamQuery {
|
||||
@@ -2299,8 +2073,14 @@ pub struct HidStatus {
|
||||
pub available: bool,
|
||||
pub backend: String,
|
||||
pub initialized: bool,
|
||||
pub online: bool,
|
||||
pub supports_absolute_mouse: bool,
|
||||
pub keyboard_leds_enabled: bool,
|
||||
pub led_state: crate::hid::LedState,
|
||||
pub screen_resolution: Option<(u32, u32)>,
|
||||
pub device: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -3061,19 +2841,19 @@ pub async fn hid_otg_self_check(State(state): State<Arc<AppState>>) -> Json<OtgS
|
||||
|
||||
/// Get HID status
|
||||
pub async fn hid_status(State(state): State<Arc<AppState>>) -> Json<HidStatus> {
|
||||
let info = state.hid.info().await;
|
||||
let hid = state.hid.snapshot().await;
|
||||
Json(HidStatus {
|
||||
available: info.is_some(),
|
||||
backend: info
|
||||
.as_ref()
|
||||
.map(|i| i.name.to_string())
|
||||
.unwrap_or_else(|| "none".to_string()),
|
||||
initialized: info.as_ref().map(|i| i.initialized).unwrap_or(false),
|
||||
supports_absolute_mouse: info
|
||||
.as_ref()
|
||||
.map(|i| i.supports_absolute_mouse)
|
||||
.unwrap_or(false),
|
||||
screen_resolution: info.and_then(|i| i.screen_resolution),
|
||||
available: hid.available,
|
||||
backend: hid.backend,
|
||||
initialized: hid.initialized,
|
||||
online: hid.online,
|
||||
supports_absolute_mouse: hid.supports_absolute_mouse,
|
||||
keyboard_leds_enabled: hid.keyboard_leds_enabled,
|
||||
led_state: hid.led_state,
|
||||
screen_resolution: hid.screen_resolution,
|
||||
device: hid.device,
|
||||
error: hid.error,
|
||||
error_code: hid.error_code,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
.route("/stream/bitrate", post(handlers::stream_set_bitrate))
|
||||
.route("/stream/codecs", get(handlers::stream_codecs_list))
|
||||
.route("/stream/constraints", get(handlers::stream_constraints_get))
|
||||
.route(
|
||||
"/video/encoder/self-check",
|
||||
get(handlers::video_encoder_self_check),
|
||||
)
|
||||
// WebRTC endpoints
|
||||
.route("/webrtc/session", post(handlers::webrtc_create_session))
|
||||
.route("/webrtc/offer", post(handlers::webrtc_offer))
|
||||
@@ -192,10 +196,6 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
"/extensions/ttyd/config",
|
||||
patch(handlers::extensions::update_ttyd_config),
|
||||
)
|
||||
.route(
|
||||
"/extensions/ttyd/status",
|
||||
get(handlers::extensions::get_ttyd_status),
|
||||
)
|
||||
.route(
|
||||
"/extensions/gostc/config",
|
||||
patch(handlers::extensions::update_gostc_config),
|
||||
|
||||
@@ -201,7 +201,7 @@ pub fn placeholder_html() -> &'static str {
|
||||
<h1>One-KVM</h1>
|
||||
<p>Frontend not built yet.</p>
|
||||
<p>Please build the frontend or access the API directly.</p>
|
||||
<div class="version">v0.1.6</div>
|
||||
<div class="version">v0.1.9</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#
|
||||
|
||||
247
src/web/ws.rs
247
src/web/ws.rs
@@ -16,12 +16,122 @@ use axum::{
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::{sync::mpsc, task::JoinHandle};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::events::SystemEvent;
|
||||
use crate::state::AppState;
|
||||
|
||||
enum BusMessage {
|
||||
Event(SystemEvent),
|
||||
Lagged { topic: String, count: u64 },
|
||||
}
|
||||
|
||||
fn normalize_topics(topics: &[String]) -> Vec<String> {
|
||||
let mut normalized = topics.to_vec();
|
||||
normalized.sort();
|
||||
normalized.dedup();
|
||||
|
||||
if normalized.iter().any(|topic| topic == "*") {
|
||||
return vec!["*".to_string()];
|
||||
}
|
||||
|
||||
normalized
|
||||
.into_iter()
|
||||
.filter(|topic| {
|
||||
if topic.ends_with(".*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Some((prefix, _)) = topic.split_once('.') else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let wildcard = format!("{}.*", prefix);
|
||||
!topics.iter().any(|candidate| candidate == &wildcard)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_device_info_topic(topic: &str) -> bool {
|
||||
matches!(topic, "*" | "system.*" | "system.device_info")
|
||||
}
|
||||
|
||||
fn rebuild_event_tasks(
|
||||
state: &Arc<AppState>,
|
||||
topics: &[String],
|
||||
event_tx: &mpsc::UnboundedSender<BusMessage>,
|
||||
event_tasks: &mut Vec<JoinHandle<()>>,
|
||||
) {
|
||||
for task in event_tasks.drain(..) {
|
||||
task.abort();
|
||||
}
|
||||
|
||||
let topics = normalize_topics(topics);
|
||||
let mut device_info_task_added = false;
|
||||
for topic in topics {
|
||||
if is_device_info_topic(&topic) && !device_info_task_added {
|
||||
let state = state.clone();
|
||||
let mut rx = state.subscribe_device_info();
|
||||
let event_tx = event_tx.clone();
|
||||
event_tasks.push(tokio::spawn(async move {
|
||||
let snapshot = state.get_device_info().await;
|
||||
if event_tx.send(BusMessage::Event(snapshot)).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
if rx.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(snapshot) = rx.borrow().clone() {
|
||||
if event_tx.send(BusMessage::Event(snapshot)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
device_info_task_added = true;
|
||||
}
|
||||
|
||||
if is_device_info_topic(&topic) && topic != "*" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(mut rx) = state.events.subscribe_topic(&topic) else {
|
||||
warn!("Client subscribed to unknown topic: {}", topic);
|
||||
continue;
|
||||
};
|
||||
|
||||
let event_tx = event_tx.clone();
|
||||
let topic_name = topic.clone();
|
||||
event_tasks.push(tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
if event_tx.send(BusMessage::Event(event)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||
if event_tx
|
||||
.send(BusMessage::Lagged {
|
||||
topic: topic_name.clone(),
|
||||
count,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Client-to-server message
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", content = "payload")]
|
||||
@@ -50,16 +160,12 @@ pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<AppState>>
|
||||
/// Handle WebSocket connection
|
||||
async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
|
||||
// Subscribe to event bus
|
||||
let mut event_rx = state.events.subscribe();
|
||||
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
|
||||
let mut event_tasks: Vec<JoinHandle<()>> = Vec::new();
|
||||
|
||||
// Track subscribed topics (default: none until client subscribes)
|
||||
let mut subscribed_topics: Vec<String> = vec![];
|
||||
|
||||
// Flag to send device info after first subscribe
|
||||
let mut device_info_sent = false;
|
||||
|
||||
info!("WebSocket client connected");
|
||||
|
||||
// Heartbeat interval (30 seconds)
|
||||
@@ -73,18 +179,13 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
if let Err(e) = handle_client_message(&text, &mut subscribed_topics).await {
|
||||
warn!("Failed to handle client message: {}", e);
|
||||
}
|
||||
|
||||
// Send device info after first subscribe
|
||||
if !device_info_sent && !subscribed_topics.is_empty() {
|
||||
let device_info = state.get_device_info().await;
|
||||
if let Ok(json) = serialize_event(&device_info) {
|
||||
if sender.send(Message::Text(json.into())).await.is_err() {
|
||||
warn!("Failed to send device info to client");
|
||||
break;
|
||||
}
|
||||
}
|
||||
device_info_sent = true;
|
||||
} else {
|
||||
rebuild_event_tasks(
|
||||
&state,
|
||||
&subscribed_topics,
|
||||
&event_tx,
|
||||
&mut event_tasks,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Ping(_))) => {
|
||||
@@ -109,28 +210,29 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
||||
// Receive event from event bus
|
||||
event = event_rx.recv() => {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
Some(BusMessage::Event(event)) => {
|
||||
// Filter event based on subscribed topics
|
||||
if should_send_event(&event, &subscribed_topics) {
|
||||
if let Ok(json) = serialize_event(&event) {
|
||||
if sender.send(Message::Text(json.into())).await.is_err() {
|
||||
warn!("Failed to send event to client, disconnecting");
|
||||
break;
|
||||
}
|
||||
if let Ok(json) = serialize_event(&event) {
|
||||
if sender.send(Message::Text(json.into())).await.is_err() {
|
||||
warn!("Failed to send event to client, disconnecting");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("WebSocket client lagged by {} events", n);
|
||||
Some(BusMessage::Lagged { topic, count }) => {
|
||||
warn!(
|
||||
"WebSocket client lagged by {} events on topic {}",
|
||||
count, topic
|
||||
);
|
||||
// Send error notification to client using SystemEvent::Error
|
||||
let error_event = SystemEvent::Error {
|
||||
message: format!("Lagged by {} events", n),
|
||||
message: format!("Lagged by {} events", count),
|
||||
};
|
||||
if let Ok(json) = serialize_event(&error_event) {
|
||||
let _ = sender.send(Message::Text(json.into())).await;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
None => {
|
||||
warn!("Event bus closed");
|
||||
break;
|
||||
}
|
||||
@@ -147,6 +249,10 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
||||
}
|
||||
}
|
||||
|
||||
for task in event_tasks {
|
||||
task.abort();
|
||||
}
|
||||
|
||||
info!("WebSocket handler exiting");
|
||||
}
|
||||
|
||||
@@ -176,21 +282,6 @@ async fn handle_client_message(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if an event should be sent based on subscribed topics
|
||||
fn should_send_event(event: &SystemEvent, topics: &[String]) -> bool {
|
||||
if topics.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fast path: check for wildcard subscription (avoid String allocation)
|
||||
if topics.iter().any(|t| t == "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if event matches any subscribed topic
|
||||
topics.iter().any(|topic| event.matches_topic(topic))
|
||||
}
|
||||
|
||||
/// Serialize event to JSON string
|
||||
fn serialize_event(event: &SystemEvent) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(event)
|
||||
@@ -199,53 +290,49 @@ fn serialize_event(event: &SystemEvent) -> Result<String, serde_json::Error> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::events::SystemEvent;
|
||||
|
||||
#[test]
|
||||
fn test_should_send_event_wildcard() {
|
||||
let event = SystemEvent::StreamStateChanged {
|
||||
state: "streaming".to_string(),
|
||||
device: None,
|
||||
};
|
||||
fn test_normalize_topics_dedupes_and_sorts() {
|
||||
let topics = vec![
|
||||
"stream.state_changed".to_string(),
|
||||
"stream.state_changed".to_string(),
|
||||
"system.device_info".to_string(),
|
||||
];
|
||||
|
||||
assert!(should_send_event(&event, &["*".to_string()]));
|
||||
assert_eq!(
|
||||
normalize_topics(&topics),
|
||||
vec![
|
||||
"stream.state_changed".to_string(),
|
||||
"system.device_info".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_send_event_prefix() {
|
||||
let event = SystemEvent::StreamStateChanged {
|
||||
state: "streaming".to_string(),
|
||||
device: None,
|
||||
};
|
||||
|
||||
assert!(should_send_event(&event, &["stream.*".to_string()]));
|
||||
assert!(!should_send_event(&event, &["msd.*".to_string()]));
|
||||
fn test_normalize_topics_wildcard_wins() {
|
||||
let topics = vec!["*".to_string(), "stream.state_changed".to_string()];
|
||||
assert_eq!(normalize_topics(&topics), vec!["*".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_send_event_exact() {
|
||||
let event = SystemEvent::StreamStateChanged {
|
||||
state: "streaming".to_string(),
|
||||
device: None,
|
||||
};
|
||||
fn test_normalize_topics_drops_exact_when_prefix_exists() {
|
||||
let topics = vec![
|
||||
"stream.*".to_string(),
|
||||
"stream.state_changed".to_string(),
|
||||
"system.device_info".to_string(),
|
||||
];
|
||||
|
||||
assert!(should_send_event(
|
||||
&event,
|
||||
&["stream.state_changed".to_string()]
|
||||
));
|
||||
assert!(!should_send_event(
|
||||
&event,
|
||||
&["stream.config_changed".to_string()]
|
||||
));
|
||||
assert_eq!(
|
||||
normalize_topics(&topics),
|
||||
vec!["stream.*".to_string(), "system.device_info".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_send_event_empty_topics() {
|
||||
let event = SystemEvent::StreamStateChanged {
|
||||
state: "streaming".to_string(),
|
||||
device: None,
|
||||
};
|
||||
|
||||
assert!(!should_send_event(&event, &[]));
|
||||
fn test_is_device_info_topic_matches_expected_topics() {
|
||||
assert!(is_device_info_topic("system.device_info"));
|
||||
assert!(is_device_info_topic("system.*"));
|
||||
assert!(is_device_info_topic("*"));
|
||||
assert!(!is_device_info_topic("stream.*"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,12 +37,6 @@ const H265_NAL_SPS: u8 = 33;
|
||||
const H265_NAL_PPS: u8 = 34;
|
||||
const H265_NAL_AUD: u8 = 35;
|
||||
const H265_NAL_FILLER: u8 = 38;
|
||||
#[allow(dead_code)]
|
||||
const H265_NAL_SEI_PREFIX: u8 = 39; // PREFIX_SEI_NUT
|
||||
#[allow(dead_code)]
|
||||
const H265_NAL_SEI_SUFFIX: u8 = 40; // SUFFIX_SEI_NUT
|
||||
#[allow(dead_code)]
|
||||
const H265_NAL_AP: u8 = 48; // Aggregation Packet
|
||||
const H265_NAL_FU: u8 = 49; // Fragmentation Unit
|
||||
|
||||
/// H.265 NAL header size
|
||||
@@ -51,11 +45,6 @@ const H265_NAL_HEADER_SIZE: usize = 2;
|
||||
/// FU header size (1 byte after NAL header)
|
||||
const H265_FU_HEADER_SIZE: usize = 1;
|
||||
|
||||
/// Fixed PayloadHdr for FU packets: Type=49, LayerID=0, TID=1
|
||||
/// This matches the rtp crate's FRAG_PAYLOAD_HDR
|
||||
#[allow(dead_code)]
|
||||
const FU_PAYLOAD_HDR: [u8; 2] = [0x62, 0x01];
|
||||
|
||||
/// Fixed PayloadHdr for AP packets: Type=48, LayerID=0, TID=1
|
||||
/// This matches the rtp crate's AGGR_PAYLOAD_HDR
|
||||
const AP_PAYLOAD_HDR: [u8; 2] = [0x60, 0x01];
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//!
|
||||
//! Architecture:
|
||||
//! ```text
|
||||
//! VideoCapturer (MJPEG/YUYV)
|
||||
//! V4L2 capture
|
||||
//! |
|
||||
//! v
|
||||
//! SharedVideoPipeline (decode -> convert -> encode)
|
||||
|
||||
@@ -262,8 +262,6 @@ impl Default for AudioTrackConfig {
|
||||
|
||||
/// Audio track for WebRTC streaming
|
||||
pub struct AudioTrack {
|
||||
#[allow(dead_code)]
|
||||
config: AudioTrackConfig,
|
||||
/// RTP track
|
||||
track: Arc<TrackLocalStaticRTP>,
|
||||
/// Running flag
|
||||
@@ -284,7 +282,6 @@ impl AudioTrack {
|
||||
let (running_tx, _) = watch::channel(false);
|
||||
|
||||
Self {
|
||||
config,
|
||||
track,
|
||||
running: Arc::new(running_tx),
|
||||
}
|
||||
|
||||
@@ -221,23 +221,11 @@ impl WebRtcStreamer {
|
||||
use crate::video::encoder::registry::EncoderRegistry;
|
||||
|
||||
let registry = EncoderRegistry::global();
|
||||
let mut codecs = vec![];
|
||||
|
||||
// H264 always available (has software fallback)
|
||||
codecs.push(VideoCodecType::H264);
|
||||
|
||||
// Check hardware codecs
|
||||
if registry.is_format_available(VideoEncoderType::H265, true) {
|
||||
codecs.push(VideoCodecType::H265);
|
||||
}
|
||||
if registry.is_format_available(VideoEncoderType::VP8, true) {
|
||||
codecs.push(VideoCodecType::VP8);
|
||||
}
|
||||
if registry.is_format_available(VideoEncoderType::VP9, true) {
|
||||
codecs.push(VideoCodecType::VP9);
|
||||
}
|
||||
|
||||
codecs
|
||||
VideoEncoderType::ordered()
|
||||
.into_iter()
|
||||
.filter(|codec| registry.is_codec_available(*codec))
|
||||
.map(Self::encoder_type_to_codec_type)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convert VideoCodecType to VideoEncoderType
|
||||
@@ -250,6 +238,15 @@ impl WebRtcStreamer {
|
||||
}
|
||||
}
|
||||
|
||||
fn encoder_type_to_codec_type(codec: VideoEncoderType) -> VideoCodecType {
|
||||
match codec {
|
||||
VideoEncoderType::H264 => VideoCodecType::H264,
|
||||
VideoEncoderType::H265 => VideoCodecType::H265,
|
||||
VideoEncoderType::VP8 => VideoCodecType::VP8,
|
||||
VideoEncoderType::VP9 => VideoCodecType::VP9,
|
||||
}
|
||||
}
|
||||
|
||||
fn should_stop_pipeline(session_count: usize, subscriber_count: usize) -> bool {
|
||||
session_count == 0 && subscriber_count == 0
|
||||
}
|
||||
@@ -577,7 +574,7 @@ impl WebRtcStreamer {
|
||||
VideoCodecType::VP9 => VideoEncoderType::VP9,
|
||||
};
|
||||
EncoderRegistry::global()
|
||||
.best_encoder(codec_type, false)
|
||||
.best_available_encoder(codec_type)
|
||||
.map(|e| e.is_hardware)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -790,6 +787,22 @@ impl WebRtcStreamer {
|
||||
count
|
||||
}
|
||||
|
||||
/// Close all sessions and wait for the video pipeline to fully release the
|
||||
/// capture device. Use this when the caller needs the V4L2 device immediately
|
||||
/// afterwards (e.g. switching to MJPEG mode).
|
||||
pub async fn close_all_sessions_and_release_device(&self) -> usize {
|
||||
let count = self.close_all_sessions().await;
|
||||
|
||||
if let Some(ref pipeline) = *self.video_pipeline.read().await {
|
||||
pipeline
|
||||
.stop_and_wait(std::time::Duration::from_secs(3))
|
||||
.await;
|
||||
}
|
||||
*self.video_pipeline.write().await = None;
|
||||
|
||||
count
|
||||
}
|
||||
|
||||
/// Get session count
|
||||
pub async fn session_count(&self) -> usize {
|
||||
self.sessions.read().await.len()
|
||||
|
||||
@@ -1,566 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
One-KVM benchmark script (Windows-friendly).
|
||||
|
||||
Measures FPS + CPU usage across:
|
||||
- input pixel formats (capture card formats)
|
||||
- output codecs (mjpeg/h264/h265/vp8/vp9)
|
||||
- resolution/FPS matrix
|
||||
- encoder backends (software/hardware)
|
||||
|
||||
Requirements:
|
||||
pip install requests websockets playwright
|
||||
playwright install
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
import websockets
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
|
||||
SESSION_COOKIE = "one_kvm_session"
|
||||
DEFAULT_MATRIX = [
|
||||
(1920, 1080, 30),
|
||||
(1920, 1080, 60),
|
||||
(1280, 720, 30),
|
||||
(1280, 720, 60),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Case:
|
||||
input_format: str
|
||||
output_codec: str
|
||||
encoder: Optional[str]
|
||||
width: int
|
||||
height: int
|
||||
fps: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
input_format: str
|
||||
output_codec: str
|
||||
encoder: str
|
||||
width: int
|
||||
height: int
|
||||
fps: int
|
||||
avg_fps: float
|
||||
avg_cpu: float
|
||||
note: str = ""
|
||||
|
||||
|
||||
class KvmClient:
|
||||
def __init__(self, base_url: str, username: str, password: str) -> None:
|
||||
self.base = base_url.rstrip("/")
|
||||
self.s = requests.Session()
|
||||
self.login(username, password)
|
||||
|
||||
def login(self, username: str, password: str) -> None:
|
||||
r = self.s.post(f"{self.base}/api/auth/login", json={"username": username, "password": password})
|
||||
r.raise_for_status()
|
||||
|
||||
def get_cookie(self) -> str:
|
||||
return self.s.cookies.get(SESSION_COOKIE, "")
|
||||
|
||||
def get_video_config(self) -> Dict:
|
||||
r = self.s.get(f"{self.base}/api/config/video")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def get_stream_config(self) -> Dict:
|
||||
r = self.s.get(f"{self.base}/api/config/stream")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def get_devices(self) -> Dict:
|
||||
r = self.s.get(f"{self.base}/api/devices")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def get_codecs(self) -> Dict:
|
||||
r = self.s.get(f"{self.base}/api/stream/codecs")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def patch_video(self, device: Optional[str], fmt: str, w: int, h: int, fps: int) -> None:
|
||||
payload: Dict[str, object] = {"format": fmt, "width": w, "height": h, "fps": fps}
|
||||
if device:
|
||||
payload["device"] = device
|
||||
r = self.s.patch(f"{self.base}/api/config/video", json=payload)
|
||||
r.raise_for_status()
|
||||
|
||||
def patch_stream(self, encoder: Optional[str]) -> None:
|
||||
if encoder is None:
|
||||
return
|
||||
r = self.s.patch(f"{self.base}/api/config/stream", json={"encoder": encoder})
|
||||
r.raise_for_status()
|
||||
|
||||
def set_mode(self, mode: str) -> None:
|
||||
r = self.s.post(f"{self.base}/api/stream/mode", json={"mode": mode})
|
||||
r.raise_for_status()
|
||||
|
||||
def get_mode(self) -> Dict:
|
||||
r = self.s.get(f"{self.base}/api/stream/mode")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def wait_mode_ready(self, mode: str, timeout_sec: int = 20) -> None:
|
||||
deadline = time.time() + timeout_sec
|
||||
while time.time() < deadline:
|
||||
data = self.get_mode()
|
||||
if not data.get("switching") and data.get("mode") == mode:
|
||||
return
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError(f"mode switch timeout: {mode}")
|
||||
|
||||
def start_stream(self) -> None:
|
||||
r = self.s.post(f"{self.base}/api/stream/start")
|
||||
r.raise_for_status()
|
||||
|
||||
def stop_stream(self) -> None:
|
||||
r = self.s.post(f"{self.base}/api/stream/stop")
|
||||
r.raise_for_status()
|
||||
|
||||
def cpu_sample(self) -> float:
|
||||
r = self.s.get(f"{self.base}/api/info")
|
||||
r.raise_for_status()
|
||||
return float(r.json()["device_info"]["cpu_usage"])
|
||||
|
||||
def close_webrtc_session(self, session_id: str) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
self.s.post(f"{self.base}/api/webrtc/close", json={"session_id": session_id})
|
||||
|
||||
|
||||
class MjpegStream:
|
||||
def __init__(self, url: str, cookie: str) -> None:
|
||||
self._stop = threading.Event()
|
||||
self._resp = requests.get(url, stream=True, headers={"Cookie": f"{SESSION_COOKIE}={cookie}"})
|
||||
self._thread = threading.Thread(target=self._reader, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _reader(self) -> None:
|
||||
try:
|
||||
for chunk in self._resp.iter_content(chunk_size=4096):
|
||||
if self._stop.is_set():
|
||||
break
|
||||
if not chunk:
|
||||
time.sleep(0.01)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
self._stop.set()
|
||||
try:
|
||||
self._resp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def parse_matrix(values: Optional[List[str]]) -> List[Tuple[int, int, int]]:
|
||||
if not values:
|
||||
return DEFAULT_MATRIX
|
||||
result: List[Tuple[int, int, int]] = []
|
||||
for item in values:
|
||||
# WIDTHxHEIGHT@FPS
|
||||
part = item.strip().lower()
|
||||
if "@" not in part or "x" not in part:
|
||||
raise ValueError(f"invalid matrix item: {item}")
|
||||
res_part, fps_part = part.split("@", 1)
|
||||
w_str, h_str = res_part.split("x", 1)
|
||||
result.append((int(w_str), int(h_str), int(fps_part)))
|
||||
return result
|
||||
|
||||
|
||||
def avg(values: Iterable[float]) -> float:
|
||||
vals = list(values)
|
||||
return sum(vals) / len(vals) if vals else 0.0
|
||||
|
||||
|
||||
def normalize_format(fmt: str) -> str:
|
||||
return fmt.strip().upper()
|
||||
|
||||
|
||||
def select_device(devices: Dict, preferred: Optional[str]) -> Optional[Dict]:
|
||||
video_devices = devices.get("video", [])
|
||||
if preferred:
|
||||
for d in video_devices:
|
||||
if d.get("path") == preferred:
|
||||
return d
|
||||
return video_devices[0] if video_devices else None
|
||||
|
||||
|
||||
def build_supported_map(device: Dict) -> Dict[str, Dict[Tuple[int, int], List[int]]]:
|
||||
supported: Dict[str, Dict[Tuple[int, int], List[int]]] = {}
|
||||
for fmt in device.get("formats", []):
|
||||
fmt_name = normalize_format(fmt.get("format", ""))
|
||||
res_map: Dict[Tuple[int, int], List[int]] = {}
|
||||
for res in fmt.get("resolutions", []):
|
||||
key = (int(res.get("width", 0)), int(res.get("height", 0)))
|
||||
fps_list = [int(f) for f in res.get("fps", [])]
|
||||
res_map[key] = fps_list
|
||||
supported[fmt_name] = res_map
|
||||
return supported
|
||||
|
||||
|
||||
def is_combo_supported(
|
||||
supported: Dict[str, Dict[Tuple[int, int], List[int]]],
|
||||
fmt: str,
|
||||
width: int,
|
||||
height: int,
|
||||
fps: int,
|
||||
) -> bool:
|
||||
res_map = supported.get(fmt)
|
||||
if not res_map:
|
||||
return False
|
||||
fps_list = res_map.get((width, height), [])
|
||||
return fps in fps_list
|
||||
|
||||
|
||||
async def mjpeg_sample(
|
||||
base_url: str,
|
||||
cookie: str,
|
||||
client_id: str,
|
||||
duration_sec: float,
|
||||
cpu_sample_fn,
|
||||
) -> Tuple[float, float]:
|
||||
mjpeg_url = f"{base_url}/api/stream/mjpeg?client_id={client_id}"
|
||||
stream = MjpegStream(mjpeg_url, cookie)
|
||||
ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/ws"
|
||||
|
||||
fps_samples: List[float] = []
|
||||
cpu_samples: List[float] = []
|
||||
|
||||
# discard first cpu sample (needs delta)
|
||||
cpu_sample_fn()
|
||||
|
||||
try:
|
||||
async with websockets.connect(ws_url, extra_headers={"Cookie": f"{SESSION_COOKIE}={cookie}"}) as ws:
|
||||
start = time.time()
|
||||
while time.time() - start < duration_sec:
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=1.0)
|
||||
except asyncio.TimeoutError:
|
||||
msg = None
|
||||
|
||||
if msg:
|
||||
data = json.loads(msg)
|
||||
if data.get("type") == "stream.stats_update":
|
||||
clients = data.get("clients_stat", {})
|
||||
if client_id in clients:
|
||||
fps = float(clients[client_id].get("fps", 0))
|
||||
fps_samples.append(fps)
|
||||
|
||||
cpu_samples.append(float(cpu_sample_fn()))
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
return avg(fps_samples), avg(cpu_samples)
|
||||
|
||||
|
||||
async def webrtc_sample(
|
||||
base_url: str,
|
||||
cookie: str,
|
||||
duration_sec: float,
|
||||
cpu_sample_fn,
|
||||
headless: bool,
|
||||
) -> Tuple[float, float, str]:
|
||||
fps_samples: List[float] = []
|
||||
cpu_samples: List[float] = []
|
||||
session_id = ""
|
||||
|
||||
# discard first cpu sample (needs delta)
|
||||
cpu_sample_fn()
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=headless)
|
||||
context = await browser.new_context()
|
||||
await context.add_cookies([{
|
||||
"name": SESSION_COOKIE,
|
||||
"value": cookie,
|
||||
"url": base_url,
|
||||
"path": "/",
|
||||
}])
|
||||
page = await context.new_page()
|
||||
await page.goto(base_url + "/", wait_until="domcontentloaded")
|
||||
|
||||
await page.evaluate(
|
||||
"""
|
||||
async (base) => {
|
||||
const pc = new RTCPeerConnection();
|
||||
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||
pc.onicecandidate = async (e) => {
|
||||
if (e.candidate && window.__sid) {
|
||||
await fetch(base + "/api/webrtc/ice", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: window.__sid, candidate: e.candidate })
|
||||
});
|
||||
}
|
||||
};
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
const resp = await fetch(base + "/api/webrtc/offer", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sdp: offer.sdp })
|
||||
});
|
||||
const ans = await resp.json();
|
||||
window.__sid = ans.session_id;
|
||||
await pc.setRemoteDescription({ type: "answer", sdp: ans.sdp });
|
||||
(ans.ice_candidates || []).forEach(c => pc.addIceCandidate(c));
|
||||
window.__kvmStats = { pc, lastTs: 0, lastFrames: 0 };
|
||||
}
|
||||
""",
|
||||
base_url,
|
||||
)
|
||||
|
||||
try:
|
||||
await page.wait_for_function(
|
||||
"window.__kvmStats && window.__kvmStats.pc && window.__kvmStats.pc.connectionState === 'connected'",
|
||||
timeout=15000,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
start = time.time()
|
||||
while time.time() - start < duration_sec:
|
||||
fps = await page.evaluate(
|
||||
"""
|
||||
async () => {
|
||||
const s = window.__kvmStats;
|
||||
const report = await s.pc.getStats();
|
||||
let fps = 0;
|
||||
for (const r of report.values()) {
|
||||
if (r.type === "inbound-rtp" && r.kind === "video") {
|
||||
if (r.framesPerSecond) {
|
||||
fps = r.framesPerSecond;
|
||||
} else if (r.framesDecoded && s.lastTs) {
|
||||
const dt = (r.timestamp - s.lastTs) / 1000.0;
|
||||
const df = r.framesDecoded - s.lastFrames;
|
||||
fps = dt > 0 ? df / dt : 0;
|
||||
}
|
||||
s.lastTs = r.timestamp;
|
||||
s.lastFrames = r.framesDecoded || s.lastFrames;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return fps;
|
||||
}
|
||||
"""
|
||||
)
|
||||
fps_samples.append(float(fps))
|
||||
cpu_samples.append(float(cpu_sample_fn()))
|
||||
await asyncio.sleep(1)
|
||||
|
||||
session_id = await page.evaluate("window.__sid || ''")
|
||||
await browser.close()
|
||||
|
||||
return avg(fps_samples), avg(cpu_samples), session_id
|
||||
|
||||
|
||||
async def run_case(
|
||||
client: KvmClient,
|
||||
device: Optional[str],
|
||||
case: Case,
|
||||
duration_sec: float,
|
||||
warmup_sec: float,
|
||||
headless: bool,
|
||||
) -> Result:
|
||||
client.patch_video(device, case.input_format, case.width, case.height, case.fps)
|
||||
|
||||
if case.output_codec != "mjpeg":
|
||||
client.patch_stream(case.encoder)
|
||||
|
||||
client.set_mode(case.output_codec)
|
||||
client.wait_mode_ready(case.output_codec)
|
||||
|
||||
client.start_stream()
|
||||
time.sleep(warmup_sec)
|
||||
|
||||
note = ""
|
||||
if case.output_codec == "mjpeg":
|
||||
avg_fps, avg_cpu = await mjpeg_sample(
|
||||
client.base,
|
||||
client.get_cookie(),
|
||||
client_id=f"bench-{int(time.time() * 1000)}",
|
||||
duration_sec=duration_sec,
|
||||
cpu_sample_fn=client.cpu_sample,
|
||||
)
|
||||
else:
|
||||
avg_fps, avg_cpu, session_id = await webrtc_sample(
|
||||
client.base,
|
||||
client.get_cookie(),
|
||||
duration_sec=duration_sec,
|
||||
cpu_sample_fn=client.cpu_sample,
|
||||
headless=headless,
|
||||
)
|
||||
if session_id:
|
||||
client.close_webrtc_session(session_id)
|
||||
else:
|
||||
note = "no-session-id"
|
||||
|
||||
client.stop_stream()
|
||||
|
||||
return Result(
|
||||
input_format=case.input_format,
|
||||
output_codec=case.output_codec,
|
||||
encoder=case.encoder or "n/a",
|
||||
width=case.width,
|
||||
height=case.height,
|
||||
fps=case.fps,
|
||||
avg_fps=avg_fps,
|
||||
avg_cpu=avg_cpu,
|
||||
note=note,
|
||||
)
|
||||
|
||||
|
||||
def write_csv(results: List[Result], path: str) -> None:
|
||||
with open(path, "w", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["input_format", "output_codec", "encoder", "width", "height", "fps", "avg_fps", "avg_cpu", "note"])
|
||||
for r in results:
|
||||
w.writerow([r.input_format, r.output_codec, r.encoder, r.width, r.height, r.fps, f"{r.avg_fps:.2f}", f"{r.avg_cpu:.2f}", r.note])
|
||||
|
||||
|
||||
def write_md(results: List[Result], path: str) -> None:
|
||||
lines = [
|
||||
"| input_format | output_codec | encoder | width | height | fps | avg_fps | avg_cpu | note |",
|
||||
"|---|---|---|---:|---:|---:|---:|---:|---|",
|
||||
]
|
||||
for r in results:
|
||||
lines.append(
|
||||
f"| {r.input_format} | {r.output_codec} | {r.encoder} | {r.width} | {r.height} | {r.fps} | {r.avg_fps:.2f} | {r.avg_cpu:.2f} | {r.note} |"
|
||||
)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="One-KVM benchmark (FPS + CPU)")
|
||||
parser.add_argument("--base-url", required=True, help="e.g. http://192.168.1.50")
|
||||
parser.add_argument("--username", required=True)
|
||||
parser.add_argument("--password", required=True)
|
||||
parser.add_argument("--device", help="video device path, e.g. /dev/video0")
|
||||
parser.add_argument("--input-formats", help="comma list, e.g. MJPEG,YUYV,NV12")
|
||||
parser.add_argument("--output-codecs", help="comma list, e.g. mjpeg,h264,h265,vp8,vp9")
|
||||
parser.add_argument("--encoder-backends", help="comma list, e.g. software,auto,vaapi,nvenc,qsv,amf,rkmpp,v4l2m2m")
|
||||
parser.add_argument("--matrix", action="append", help="repeatable WIDTHxHEIGHT@FPS, e.g. 1920x1080@30")
|
||||
parser.add_argument("--duration", type=float, default=30.0, help="sample duration seconds (default 30)")
|
||||
parser.add_argument("--warmup", type=float, default=3.0, help="warmup seconds before sampling")
|
||||
parser.add_argument("--csv", default="bench_results.csv")
|
||||
parser.add_argument("--md", default="bench_results.md")
|
||||
parser.add_argument("--headless", action="store_true", help="run browser headless (default: headful)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
base_url = args.base_url.strip()
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
base_url = "http://" + base_url
|
||||
client = KvmClient(base_url, args.username, args.password)
|
||||
|
||||
devices = client.get_devices()
|
||||
video_cfg = client.get_video_config()
|
||||
device_path = args.device or video_cfg.get("device")
|
||||
device_info = select_device(devices, device_path)
|
||||
if not device_info:
|
||||
print("No video device found.", file=sys.stderr)
|
||||
return 2
|
||||
device_path = device_info.get("path")
|
||||
|
||||
supported_map = build_supported_map(device_info)
|
||||
|
||||
if args.input_formats:
|
||||
input_formats = [normalize_format(f) for f in args.input_formats.split(",") if f.strip()]
|
||||
else:
|
||||
input_formats = list(supported_map.keys())
|
||||
|
||||
matrix = parse_matrix(args.matrix)
|
||||
|
||||
codecs_info = client.get_codecs()
|
||||
available_codecs = {c["id"] for c in codecs_info.get("codecs", []) if c.get("available")}
|
||||
available_codecs.add("mjpeg")
|
||||
|
||||
if args.output_codecs:
|
||||
output_codecs = [c.strip().lower() for c in args.output_codecs.split(",") if c.strip()]
|
||||
else:
|
||||
output_codecs = sorted(list(available_codecs))
|
||||
|
||||
if args.encoder_backends:
|
||||
encoder_backends = [e.strip().lower() for e in args.encoder_backends.split(",") if e.strip()]
|
||||
else:
|
||||
encoder_backends = ["software", "auto"]
|
||||
|
||||
cases: List[Case] = []
|
||||
for fmt in input_formats:
|
||||
for (w, h, fps) in matrix:
|
||||
if not is_combo_supported(supported_map, fmt, w, h, fps):
|
||||
continue
|
||||
for codec in output_codecs:
|
||||
if codec not in available_codecs:
|
||||
continue
|
||||
if codec == "mjpeg":
|
||||
cases.append(Case(fmt, codec, None, w, h, fps))
|
||||
else:
|
||||
for enc in encoder_backends:
|
||||
cases.append(Case(fmt, codec, enc, w, h, fps))
|
||||
|
||||
print(f"Total cases: {len(cases)}")
|
||||
results: List[Result] = []
|
||||
|
||||
for idx, case in enumerate(cases, 1):
|
||||
print(f"[{idx}/{len(cases)}] {case.input_format} {case.output_codec} {case.encoder or 'n/a'} {case.width}x{case.height}@{case.fps}")
|
||||
try:
|
||||
result = asyncio.run(
|
||||
run_case(
|
||||
client,
|
||||
device=device_path,
|
||||
case=case,
|
||||
duration_sec=args.duration,
|
||||
warmup_sec=args.warmup,
|
||||
headless=args.headless,
|
||||
)
|
||||
)
|
||||
results.append(result)
|
||||
print(f" -> avg_fps={result.avg_fps:.2f}, avg_cpu={result.avg_cpu:.2f}")
|
||||
except Exception as exc:
|
||||
results.append(
|
||||
Result(
|
||||
input_format=case.input_format,
|
||||
output_codec=case.output_codec,
|
||||
encoder=case.encoder or "n/a",
|
||||
width=case.width,
|
||||
height=case.height,
|
||||
fps=case.fps,
|
||||
avg_fps=0.0,
|
||||
avg_cpu=0.0,
|
||||
note=f"error: {exc}",
|
||||
)
|
||||
)
|
||||
print(f" -> error: {exc}")
|
||||
|
||||
write_csv(results, args.csv)
|
||||
write_md(results, args.md)
|
||||
print(f"Saved: {args.csv}, {args.md}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "web",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.9",
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, watch } from 'vue'
|
||||
import { KeepAlive, onMounted, watch } from 'vue'
|
||||
import { RouterView, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
@@ -56,5 +56,10 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<KeepAlive v-if="authStore.isAuthenticated">
|
||||
<component :is="Component" v-if="route.name === 'Console'" />
|
||||
</KeepAlive>
|
||||
<component :is="Component" v-if="route.name !== 'Console' || !authStore.isAuthenticated" />
|
||||
</RouterView>
|
||||
</template>
|
||||
|
||||
@@ -30,7 +30,8 @@ import type {
|
||||
GostcConfigUpdate,
|
||||
EasytierConfig,
|
||||
EasytierConfigUpdate,
|
||||
TtydStatus,
|
||||
WebConfigResponse,
|
||||
WebConfigUpdate,
|
||||
} from '@/types/generated'
|
||||
|
||||
import { request } from './request'
|
||||
@@ -236,11 +237,6 @@ export const extensionsApi = {
|
||||
logs: (id: string, lines = 100) =>
|
||||
request<ExtensionLogs>(`/extensions/${id}/logs?lines=${lines}`),
|
||||
|
||||
/**
|
||||
* 获取 ttyd 状态(简化版,用于控制台)
|
||||
*/
|
||||
getTtydStatus: () => request<TtydStatus>('/extensions/ttyd/status'),
|
||||
|
||||
/**
|
||||
* 更新 ttyd 配置
|
||||
*/
|
||||
@@ -390,36 +386,24 @@ export const rtspConfigApi = {
|
||||
}
|
||||
|
||||
// ===== Web 服务器配置 API =====
|
||||
// `/config/web` 使用 `WebConfigResponse` / `WebConfigUpdate`(由 typeshare 自 Rust 生成)。
|
||||
|
||||
/** Web 服务器配置 */
|
||||
export interface WebConfig {
|
||||
http_port: number
|
||||
https_port: number
|
||||
bind_addresses: string[]
|
||||
bind_address: string
|
||||
https_enabled: boolean
|
||||
}
|
||||
/** REST `/config/web` 响应(`WebConfigResponse` 别名,兼容旧命名) */
|
||||
export type WebConfig = WebConfigResponse
|
||||
|
||||
/** Web 服务器配置更新 */
|
||||
export interface WebConfigUpdate {
|
||||
http_port?: number
|
||||
https_port?: number
|
||||
bind_addresses?: string[]
|
||||
bind_address?: string
|
||||
https_enabled?: boolean
|
||||
}
|
||||
export type { WebConfigUpdate }
|
||||
|
||||
export const webConfigApi = {
|
||||
/**
|
||||
* 获取 Web 服务器配置
|
||||
*/
|
||||
get: () => request<WebConfig>('/config/web'),
|
||||
get: () => request<WebConfigResponse>('/config/web'),
|
||||
|
||||
/**
|
||||
* 更新 Web 服务器配置
|
||||
* 更新 Web 服务器配置(含可选的证书上传)
|
||||
*/
|
||||
update: (config: WebConfigUpdate) =>
|
||||
request<WebConfig>('/config/web', {
|
||||
request<WebConfigResponse>('/config/web', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(config),
|
||||
}),
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
// API client for One-KVM backend
|
||||
|
||||
import { request, ApiError } from './request'
|
||||
import type { CanonicalKey } from '@/types/generated'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
request<{ success: boolean; message?: string }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
request<{ success: boolean; message?: string }>(
|
||||
'/auth/login',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
},
|
||||
{ toastOnError: false },
|
||||
),
|
||||
|
||||
logout: () =>
|
||||
request<{ success: boolean }>('/auth/logout', { method: 'POST' }),
|
||||
@@ -85,6 +90,9 @@ export const systemApi = {
|
||||
hid_ch9329_baudrate?: number
|
||||
hid_otg_udc?: string
|
||||
hid_otg_profile?: string
|
||||
hid_otg_endpoint_budget?: string
|
||||
hid_otg_keyboard_leds?: boolean
|
||||
msd_enabled?: boolean
|
||||
encoder_backend?: string
|
||||
audio_device?: string
|
||||
ttyd_enabled?: boolean
|
||||
@@ -177,6 +185,31 @@ export interface StreamConstraintsResponse {
|
||||
current_mode: string
|
||||
}
|
||||
|
||||
export interface VideoEncoderSelfCheckCodec {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface VideoEncoderSelfCheckCell {
|
||||
codec_id: string
|
||||
ok: boolean
|
||||
elapsed_ms?: number | null
|
||||
}
|
||||
|
||||
export interface VideoEncoderSelfCheckRow {
|
||||
resolution_id: string
|
||||
resolution_label: string
|
||||
width: number
|
||||
height: number
|
||||
cells: VideoEncoderSelfCheckCell[]
|
||||
}
|
||||
|
||||
export interface VideoEncoderSelfCheckResponse {
|
||||
current_hardware_encoder: string
|
||||
codecs: VideoEncoderSelfCheckCodec[]
|
||||
rows: VideoEncoderSelfCheckRow[]
|
||||
}
|
||||
|
||||
export const streamApi = {
|
||||
status: () =>
|
||||
request<{
|
||||
@@ -217,6 +250,9 @@ export const streamApi = {
|
||||
getConstraints: () =>
|
||||
request<StreamConstraintsResponse>('/stream/constraints'),
|
||||
|
||||
encoderSelfCheck: () =>
|
||||
request<VideoEncoderSelfCheckResponse>('/video/encoder/self-check'),
|
||||
|
||||
setBitratePreset: (bitrate_preset: import('@/types/generated').BitratePreset) =>
|
||||
request<{ success: boolean; message?: string }>('/stream/bitrate', {
|
||||
method: 'POST',
|
||||
@@ -299,8 +335,20 @@ export const hidApi = {
|
||||
available: boolean
|
||||
backend: string
|
||||
initialized: boolean
|
||||
online: boolean
|
||||
supports_absolute_mouse: boolean
|
||||
keyboard_leds_enabled: boolean
|
||||
led_state: {
|
||||
num_lock: boolean
|
||||
caps_lock: boolean
|
||||
scroll_lock: boolean
|
||||
compose: boolean
|
||||
kana: boolean
|
||||
}
|
||||
screen_resolution: [number, number] | null
|
||||
device: string | null
|
||||
error: string | null
|
||||
error_code: string | null
|
||||
}>('/hid/status'),
|
||||
|
||||
otgSelfCheck: () =>
|
||||
@@ -325,7 +373,7 @@ export const hidApi = {
|
||||
}>
|
||||
}>('/hid/otg/self-check'),
|
||||
|
||||
keyboard: async (type: 'down' | 'up', key: number, modifier?: number) => {
|
||||
keyboard: async (type: 'down' | 'up', key: CanonicalKey, modifier?: number) => {
|
||||
await ensureHidConnection()
|
||||
const event: HidKeyboardEvent = {
|
||||
type: type === 'down' ? 'keydown' : 'keyup',
|
||||
@@ -644,6 +692,7 @@ export {
|
||||
type RtspConfigUpdate,
|
||||
type RtspStatusResponse,
|
||||
type WebConfig,
|
||||
type WebConfigUpdate,
|
||||
} from './config'
|
||||
|
||||
// 导出生成的类型
|
||||
|
||||
@@ -22,6 +22,12 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import {
|
||||
ClipboardPaste,
|
||||
HardDrive,
|
||||
@@ -74,6 +80,7 @@ const emit = defineEmits<{
|
||||
(e: 'openTerminal'): void
|
||||
}>()
|
||||
|
||||
// Desktop toolbar popover/dialog state
|
||||
const pasteOpen = ref(false)
|
||||
const atxOpen = ref(false)
|
||||
const videoPopoverOpen = ref(false)
|
||||
@@ -81,13 +88,52 @@ const hidPopoverOpen = ref(false)
|
||||
const audioPopoverOpen = ref(false)
|
||||
const msdDialogOpen = ref(false)
|
||||
const extensionOpen = ref(false)
|
||||
|
||||
// Mobile Sheet state — opened from the overflow menu.
|
||||
// We use Sheet (bottom drawer) instead of Popover because Popover relies on an
|
||||
// anchor element that is hidden / clipped on small screens, causing it to
|
||||
// immediately close after opening.
|
||||
const mobileAtxOpen = ref(false)
|
||||
const mobilePasteOpen = ref(false)
|
||||
|
||||
// Timestamps used to suppress spurious "interact-outside" events that arrive
|
||||
// within ~300 ms of the Sheet opening (e.g. delayed synthetic pointer events
|
||||
// from the same touch gesture that opened the overflow menu).
|
||||
const mobileAtxOpenTime = ref(0)
|
||||
const mobilePasteOpenTime = ref(0)
|
||||
|
||||
const OPEN_GUARD_MS = 350
|
||||
|
||||
const guardOutside = (openTime: number, e: Event) => {
|
||||
if (Date.now() - openTime < OPEN_GUARD_MS) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
// On mobile, clicking a DropdownMenuItem generates pointer events that can
|
||||
// immediately dismiss any overlay opened in the same tick. Close the dropdown
|
||||
// first, then open the target after a short delay.
|
||||
const openFromOverflow = (setter: () => void) => {
|
||||
overflowMenuOpen.value = false
|
||||
setTimeout(setter, 50)
|
||||
}
|
||||
|
||||
const openMobileAtx = () => openFromOverflow(() => {
|
||||
mobileAtxOpen.value = true
|
||||
mobileAtxOpenTime.value = Date.now()
|
||||
})
|
||||
|
||||
const openMobilePaste = () => openFromOverflow(() => {
|
||||
mobilePasteOpen.value = true
|
||||
mobilePasteOpenTime.value = Date.now()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-2 px-4 py-1.5">
|
||||
<!-- Left side buttons -->
|
||||
<div class="flex flex-wrap items-center gap-1.5 w-full sm:flex-1 sm:min-w-0">
|
||||
<div class="flex items-center px-2 sm:px-4 py-1 sm:py-1.5">
|
||||
<!-- Left side buttons — overflow hidden so it never pushes into right side -->
|
||||
<div class="flex items-center gap-0.5 sm:gap-1.5 flex-1 min-w-0 overflow-hidden">
|
||||
<!-- Video Config - Always visible -->
|
||||
<VideoConfigPopover
|
||||
v-model:open="videoPopoverOpen"
|
||||
@@ -95,7 +141,7 @@ const extensionOpen = ref(false)
|
||||
@update:video-mode="emit('update:videoMode', $event)"
|
||||
/>
|
||||
|
||||
<!-- Audio Config - Always visible -->
|
||||
<!-- Audio Config - Always visible (xs shows icon only) -->
|
||||
<AudioConfigPopover v-model:open="audioPopoverOpen" />
|
||||
|
||||
<!-- HID Config - Always visible -->
|
||||
@@ -105,112 +151,123 @@ const extensionOpen = ref(false)
|
||||
@update:mouse-mode="emit('toggleMouseMode')"
|
||||
/>
|
||||
|
||||
<!-- Virtual Media (MSD) - Hidden on small screens, shown in overflow -->
|
||||
<!-- Also hidden when HID backend is CH9329 (no USB gadget support) -->
|
||||
<TooltipProvider v-if="showMsd" class="hidden sm:block">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="msdDialogOpen = true">
|
||||
<HardDrive class="h-4 w-4" />
|
||||
<span class="hidden md:inline">{{ t('actionbar.virtualMedia') }}</span>
|
||||
<!-- Virtual Media (MSD) - Hidden below md, shown in overflow -->
|
||||
<div v-if="showMsd" class="hidden md:block">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="msdDialogOpen = true">
|
||||
<HardDrive class="h-4 w-4" />
|
||||
<span class="hidden lg:inline">{{ t('actionbar.virtualMedia') }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ t('actionbar.virtualMediaTip') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<!-- ATX Power Control - Hidden below md; shown as Sheet on mobile -->
|
||||
<div class="hidden md:block">
|
||||
<Popover v-model:open="atxOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<Power class="h-4 w-4" />
|
||||
<span class="hidden lg:inline">{{ t('actionbar.power') }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ t('actionbar.virtualMediaTip') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[min(280px,90vw)] p-0" align="start">
|
||||
<AtxPopover
|
||||
@close="atxOpen = false"
|
||||
@power-short="emit('powerShort')"
|
||||
@power-long="emit('powerLong')"
|
||||
@reset="emit('reset')"
|
||||
@wol="(mac) => emit('wol', mac)"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- ATX Power Control - Hidden on small screens -->
|
||||
<Popover v-model:open="atxOpen" class="hidden sm:block">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<Power class="h-4 w-4" />
|
||||
<span class="hidden md:inline">{{ t('actionbar.power') }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[280px] p-0" align="start">
|
||||
<AtxPopover
|
||||
@close="atxOpen = false"
|
||||
@power-short="emit('powerShort')"
|
||||
@power-long="emit('powerLong')"
|
||||
@reset="emit('reset')"
|
||||
@wol="(mac) => emit('wol', mac)"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Paste Text - Hidden on small screens -->
|
||||
<Popover v-model:open="pasteOpen" class="hidden md:block">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<ClipboardPaste class="h-4 w-4" />
|
||||
<span class="hidden lg:inline">{{ t('actionbar.paste') }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[400px] p-0" align="start">
|
||||
<PasteModal @close="pasteOpen = false" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<!-- Paste Text - Hidden below lg; shown as Sheet on mobile -->
|
||||
<div class="hidden lg:block">
|
||||
<Popover v-model:open="pasteOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<ClipboardPaste class="h-4 w-4" />
|
||||
<span class="hidden xl:inline">{{ t('actionbar.paste') }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[min(400px,90vw)] p-0" align="start">
|
||||
<PasteModal @close="pasteOpen = false" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side buttons -->
|
||||
<div class="flex items-center gap-1.5 w-full justify-end sm:w-auto sm:ml-auto shrink-0">
|
||||
<!-- Extension Menu - Hidden on small screens -->
|
||||
<Popover v-model:open="extensionOpen" class="hidden lg:block">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<Cable class="h-4 w-4" />
|
||||
<span class="hidden xl:inline">{{ t('actionbar.extension') }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-1" align="start">
|
||||
<div class="space-y-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full justify-start gap-2 h-8"
|
||||
:disabled="!props.ttydRunning"
|
||||
@click="extensionOpen = false; emit('openTerminal')"
|
||||
>
|
||||
<Terminal class="h-4 w-4" />
|
||||
{{ t('extensions.ttyd.title') }}
|
||||
<!-- Right side buttons — always shrink-0, never compressed -->
|
||||
<div class="flex items-center gap-0.5 sm:gap-1.5 shrink-0 ml-1 sm:ml-2">
|
||||
<!-- Extension Menu - Hidden below xl -->
|
||||
<div class="hidden xl:block">
|
||||
<Popover v-model:open="extensionOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<Cable class="h-4 w-4" />
|
||||
{{ t('actionbar.extension') }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-1" align="start">
|
||||
<div class="space-y-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full justify-start gap-2 h-8"
|
||||
:disabled="!props.ttydRunning"
|
||||
@click="extensionOpen = false; emit('openTerminal')"
|
||||
>
|
||||
<Terminal class="h-4 w-4" />
|
||||
{{ t('extensions.ttyd.title') }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Settings - Hidden on small screens -->
|
||||
<TooltipProvider class="hidden lg:block">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
|
||||
<Settings class="h-4 w-4" />
|
||||
<span class="hidden xl:inline">{{ t('actionbar.settings') }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ t('actionbar.settingsTip') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<!-- Settings - Hidden below xl -->
|
||||
<div class="hidden xl:block">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
|
||||
<Settings class="h-4 w-4" />
|
||||
{{ t('actionbar.settings') }}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ t('actionbar.settingsTip') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<!-- Connection Stats - Hidden on very small screens -->
|
||||
<TooltipProvider class="hidden sm:block">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="emit('toggleStats')">
|
||||
<BarChart3 class="h-4 w-4" />
|
||||
<span class="hidden xl:inline">{{ t('actionbar.stats') }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ t('actionbar.statsTip') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<!-- Connection Stats - Hidden below md -->
|
||||
<div class="hidden md:block">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="emit('toggleStats')">
|
||||
<BarChart3 class="h-4 w-4" />
|
||||
<span class="hidden xl:inline">{{ t('actionbar.stats') }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ t('actionbar.statsTip') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div class="h-5 w-px bg-slate-200 dark:bg-slate-700 hidden sm:block" />
|
||||
<div class="h-5 w-px bg-slate-200 dark:bg-slate-700 hidden md:block" />
|
||||
|
||||
<!-- Virtual Keyboard - Always visible (important for mobile) -->
|
||||
<TooltipProvider>
|
||||
@@ -219,10 +276,10 @@ const extensionOpen = ref(false)
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 gap-1.5 text-xs"
|
||||
class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
||||
@click="emit('toggleVirtualKeyboard')"
|
||||
>
|
||||
<Keyboard class="h-4 w-4" />
|
||||
<Keyboard class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
<span class="hidden xl:inline">{{ t('actionbar.keyboard') }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -239,10 +296,10 @@ const extensionOpen = ref(false)
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 gap-1.5 text-xs"
|
||||
class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
||||
@click="emit('toggleFullscreen')"
|
||||
>
|
||||
<Maximize class="h-4 w-4" />
|
||||
<Maximize class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
<span class="hidden xl:inline">{{ t('actionbar.fullscreen') }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -252,52 +309,52 @@ const extensionOpen = ref(false)
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<!-- Overflow Menu - Shows hidden items on small screens -->
|
||||
<!-- Overflow Menu - Shows hidden items on smaller screens -->
|
||||
<DropdownMenu v-model:open="overflowMenuOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 lg:hidden">
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-8 p-0 xl:hidden">
|
||||
<MoreHorizontal class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-48">
|
||||
<!-- MSD - Mobile only, hidden when CH9329 backend -->
|
||||
<DropdownMenuItem v-if="showMsd" class="sm:hidden" @click="msdDialogOpen = true; overflowMenuOpen = false">
|
||||
<!-- MSD - Below md, hidden when CH9329 backend -->
|
||||
<DropdownMenuItem v-if="showMsd" class="md:hidden" @click="openFromOverflow(() => msdDialogOpen = true)">
|
||||
<HardDrive class="h-4 w-4 mr-2" />
|
||||
{{ t('actionbar.virtualMedia') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- ATX - Mobile only -->
|
||||
<DropdownMenuItem class="sm:hidden" @click="atxOpen = true; overflowMenuOpen = false">
|
||||
<!-- ATX - Opens a Sheet on mobile (below md) -->
|
||||
<DropdownMenuItem class="md:hidden" @click="openMobileAtx">
|
||||
<Power class="h-4 w-4 mr-2" />
|
||||
{{ t('actionbar.power') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Paste - Tablet and below -->
|
||||
<DropdownMenuItem class="md:hidden" @click="pasteOpen = true; overflowMenuOpen = false">
|
||||
<!-- Paste - Opens a Sheet on mobile (below lg) -->
|
||||
<DropdownMenuItem class="lg:hidden" @click="openMobilePaste">
|
||||
<ClipboardPaste class="h-4 w-4 mr-2" />
|
||||
{{ t('actionbar.paste') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator class="lg:hidden" />
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<!-- Stats - Mobile only -->
|
||||
<DropdownMenuItem class="sm:hidden" @click="emit('toggleStats'); overflowMenuOpen = false">
|
||||
<!-- Stats - Below md -->
|
||||
<DropdownMenuItem class="md:hidden" @click="openFromOverflow(() => emit('toggleStats'))">
|
||||
<BarChart3 class="h-4 w-4 mr-2" />
|
||||
{{ t('actionbar.stats') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Extension - Tablet and below -->
|
||||
<!-- Extension - Below xl -->
|
||||
<DropdownMenuItem
|
||||
class="lg:hidden"
|
||||
class="xl:hidden"
|
||||
:disabled="!props.ttydRunning"
|
||||
@click="emit('openTerminal'); overflowMenuOpen = false"
|
||||
@click="openFromOverflow(() => emit('openTerminal'))"
|
||||
>
|
||||
<Terminal class="h-4 w-4 mr-2" />
|
||||
{{ t('extensions.ttyd.title') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Settings - Tablet and below -->
|
||||
<DropdownMenuItem class="lg:hidden" @click="router.push('/settings'); overflowMenuOpen = false">
|
||||
<!-- Settings - Below xl -->
|
||||
<DropdownMenuItem class="xl:hidden" @click="openFromOverflow(() => router.push('/settings'))">
|
||||
<Settings class="h-4 w-4 mr-2" />
|
||||
{{ t('actionbar.settings') }}
|
||||
</DropdownMenuItem>
|
||||
@@ -309,4 +366,41 @@ const extensionOpen = ref(false)
|
||||
|
||||
<!-- MSD Dialog -->
|
||||
<MsdDialog v-if="showMsd" v-model:open="msdDialogOpen" />
|
||||
|
||||
<!-- Mobile ATX Sheet — used when ATX is opened from the overflow menu.
|
||||
A Sheet avoids the Popover anchor-positioning issues on mobile. -->
|
||||
<Sheet v-model:open="mobileAtxOpen">
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
class="max-h-[90dvh] overflow-y-auto"
|
||||
@pointer-down-outside="(e) => guardOutside(mobileAtxOpenTime, e)"
|
||||
@interact-outside="(e) => guardOutside(mobileAtxOpenTime, e)"
|
||||
>
|
||||
<SheetHeader class="mb-2">
|
||||
<SheetTitle>{{ t('actionbar.power') }}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<AtxPopover
|
||||
@close="mobileAtxOpen = false"
|
||||
@power-short="emit('powerShort')"
|
||||
@power-long="emit('powerLong')"
|
||||
@reset="emit('reset')"
|
||||
@wol="(mac) => emit('wol', mac)"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<!-- Mobile Paste Sheet — used when Paste is opened from the overflow menu. -->
|
||||
<Sheet v-model:open="mobilePasteOpen">
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
class="max-h-[90dvh] overflow-y-auto"
|
||||
@pointer-down-outside="(e) => guardOutside(mobilePasteOpenTime, e)"
|
||||
@interact-outside="(e) => guardOutside(mobilePasteOpenTime, e)"
|
||||
>
|
||||
<SheetHeader class="mb-2">
|
||||
<SheetTitle>{{ t('actionbar.paste') }}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<PasteModal @close="mobilePasteOpen = false" />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</template>
|
||||
|
||||
@@ -1,50 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||
import BrandMark from '@/components/BrandMark.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Monitor,
|
||||
Settings,
|
||||
LogOut,
|
||||
Sun,
|
||||
Moon,
|
||||
Languages,
|
||||
Menu,
|
||||
} from 'lucide-vue-next'
|
||||
import { setLanguage } from '@/i18n'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const systemStore = useSystemStore()
|
||||
|
||||
const navItems = computed(() => [
|
||||
{ path: '/', name: 'Console', icon: Monitor, label: t('nav.console') },
|
||||
{ path: '/settings', name: 'Settings', icon: Settings, label: t('nav.settings') },
|
||||
])
|
||||
|
||||
function toggleTheme() {
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
document.documentElement.classList.toggle('dark', !isDark)
|
||||
localStorage.setItem('theme', isDark ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
function toggleLanguage() {
|
||||
const newLang = locale.value === 'zh-CN' ? 'en-US' : 'zh-CN'
|
||||
setLanguage(newLang)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
@@ -55,62 +40,38 @@ async function handleLogout() {
|
||||
<div class="h-screen h-dvh flex flex-col bg-background overflow-hidden">
|
||||
<!-- Header -->
|
||||
<header class="shrink-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div class="flex h-14 items-center px-4 max-w-full">
|
||||
<div class="flex h-11 sm:h-14 items-center px-3 sm:px-4 max-w-full">
|
||||
<!-- Logo -->
|
||||
<RouterLink to="/" class="flex items-center gap-2 font-semibold">
|
||||
<Monitor class="h-5 w-5" />
|
||||
<RouterLink to="/" class="flex items-center gap-1.5 sm:gap-2 font-semibold">
|
||||
<BrandMark size="sm" />
|
||||
<span class="hidden sm:inline">One-KVM</span>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="hidden md:flex items-center gap-1 ml-6">
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md transition-colors"
|
||||
:class="route.path === item.path
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'"
|
||||
>
|
||||
<component :is="item.icon" class="h-4 w-4" />
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<!-- Right Side -->
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<div class="flex items-center gap-1 sm:gap-2 ml-auto">
|
||||
<!-- Version Badge -->
|
||||
<span v-if="systemStore.version" class="hidden sm:inline text-xs text-muted-foreground">
|
||||
v{{ systemStore.version }}
|
||||
</span>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<Button variant="ghost" size="icon" :aria-label="t('common.toggleTheme')" @click="toggleTheme">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.toggleTheme')" @click="toggleTheme">
|
||||
<Sun class="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon class="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span class="sr-only">{{ t('common.toggleTheme') }}</span>
|
||||
</Button>
|
||||
|
||||
<!-- Language Toggle -->
|
||||
<Button variant="ghost" size="icon" :aria-label="t('common.toggleLanguage')" @click="toggleLanguage">
|
||||
<Languages class="h-4 w-4" />
|
||||
<span class="sr-only">{{ t('common.toggleLanguage') }}</span>
|
||||
</Button>
|
||||
<LanguageToggleButton />
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child class="md:hidden">
|
||||
<Button variant="ghost" size="icon" :aria-label="t('common.menu')">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.menu')">
|
||||
<Menu class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem v-for="item in navItems" :key="item.path" @click="router.push(item.path)">
|
||||
<component :is="item.icon" class="h-4 w-4 mr-2" />
|
||||
{{ item.label }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="handleLogout">
|
||||
<LogOut class="h-4 w-4 mr-2" />
|
||||
{{ t('nav.logout') }}
|
||||
@@ -119,7 +80,7 @@ async function handleLogout() {
|
||||
</DropdownMenu>
|
||||
|
||||
<!-- Logout Button (Desktop) -->
|
||||
<Button variant="ghost" size="icon" class="hidden md:flex" :aria-label="t('nav.logout')" @click="handleLogout">
|
||||
<Button variant="ghost" size="icon" class="hidden md:flex h-8 w-8" :aria-label="t('nav.logout')" @click="handleLogout">
|
||||
<LogOut class="h-4 w-4" />
|
||||
<span class="sr-only">{{ t('nav.logout') }}</span>
|
||||
</Button>
|
||||
|
||||
@@ -123,8 +123,7 @@ async function applyConfig() {
|
||||
}
|
||||
|
||||
await audioApi.start()
|
||||
// Note: handleAudioStateChanged in ConsoleView will handle the connection
|
||||
// when it receives the audio.state_changed event with streaming=true
|
||||
// ConsoleView will react when system.device_info reflects streaming=true.
|
||||
} catch (startError) {
|
||||
// Audio start failed - config was saved but streaming not started
|
||||
console.info('[AudioConfig] Audio start failed:', startError)
|
||||
@@ -170,12 +169,12 @@ watch(() => props.open, (isOpen) => {
|
||||
<template>
|
||||
<Popover :open="open" @update:open="emit('update:open', $event)">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<Volume2 class="h-4 w-4" />
|
||||
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
|
||||
<Volume2 class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
<span class="hidden sm:inline">{{ t('actionbar.audioConfig') }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[320px] p-3" align="start">
|
||||
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-medium">{{ t('actionbar.audioConfig') }}</h4>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user