Compare commits

...

98 Commits

Author SHA1 Message Date
mofeng-git
3ee3df77b8 chroe: 不再配置 iceCandidatePoolSize,沿用浏览器默认 2026-05-05 01:19:17 +08:00
mofeng-git
8ec2f25e82 chore: bump version to v0.2.0 2026-05-05 00:59:16 +08:00
mofeng-git
c27d3a6703 fix:改进atx usb 继电器适配;修复 webrtc 无法建立连接问题;网页样式优化 2026-05-05 00:52:16 +08:00
mofeng-git
6723f432a3 feat: 允许通过环境变量手动指定前端资源路径,删除 debug 分支默认资源路径 2026-05-04 17:53:27 +08:00
mofeng-git
12a3f1c947 feat: 增加设备丢失自恢复机制
增加音频设备丢失自恢复机制,完善视频设备丢失自恢复机制

降级部分日志级别,GOSTC key打印脱敏

代码格式化
2026-05-02 10:55:05 +08:00
mofeng-git
52754c862b feat: 优化网页消息提醒样式 2026-05-01 21:46:32 +08:00
mofeng-git
e51d243324 feat: 增加 MSD 虚拟盘文件路径编码 2026-05-01 21:27:03 +08:00
mofeng-git
a1ebd34083 feat: 外部扩展程序输出日志级别修改为 info 级别 2026-05-01 21:21:54 +08:00
mofeng-git
89b19ea7dd refactor: 修改为同步请求 2026-05-01 20:06:22 +08:00
mofeng-git
0d47d8395d refactor: 重构视频采集状态与错误分类公共逻辑 2026-05-01 17:56:56 +08:00
mofeng-git
d82c863f40 refactor: 精简依赖 2026-05-01 17:41:11 +08:00
mofeng-git
d8e7de74a6 refactor: 删除部分多余的代码和注释 2026-05-01 17:31:04 +08:00
SilentWind
74035f8e12 Merge pull request #247 from tedaimengtech/main
Update: Add CQU Mirror Information
2026-04-29 14:13:25 +08:00
tedaimeng
8d45186eba Add Mirror Download Services to README
Added a section for Mirror Download Services and updated sponsors.
2026-04-29 13:12:35 +08:00
tedaimeng
c484580b8f Update README with CQUMirror details
Added CQUMirror information and links to the README.
2026-04-29 13:09:23 +08:00
SilentWind
56bce7937c Add GNU General Public License v3 2026-04-29 10:38:30 +08:00
mofeng-git
07b982d1d2 feat: 完善 USB UVC 设备异常处理,添加 USB 设备复位功能 2026-04-27 16:37:04 +08:00
mofeng-git
9065e01225 feat: 优化控制台页面状态工具栏在不同宽度网页下的自适应能力 2026-04-25 20:32:44 +08:00
mofeng-git
cc3cc15774 refactor: 删除部分多余的 Ventoy 逻辑 2026-04-20 14:07:28 +08:00
mofeng-git
fcb39c73fc refactor: 删除未使用的公共 STUN/TURN 逻辑 2026-04-20 10:15:53 +08:00
mofeng-git
7c703b8b4b feat: 深入适配 RK628D CSI 采集卡的设备识别、参数读取、自恢复和音频采集 2026-04-19 11:26:21 +08:00
mofeng-git
8eac31f69f chore: bump version to v0.1.9
Made-with: Cursor
2026-04-12 20:43:44 +08:00
mofeng-git
9653e16a68 feat: CLI 改密、自定义 TLS、移动端适配与扩展校验
- 新增 one-kvm user set-password(交互式),改密后吊销该用户全部会话
- /api/config/web 支持 PEM 证书/密钥上传与清除,响应含 has_custom_cert
- 移动端:ActionBar 溢出菜单、ATX/粘贴底部 Sheet、BrandMark 与控制台等响应式优化
- GOSTC:校验服务器地址非空,管理器启动条件与 HTTP 热更新一致
- RustDesk:中继密钥 relay_key 校验为标准 Base64 且解码后恰好 32 字节
- StatusCard、InfoBar:合并精简冗余状态信息
2026-04-12 19:28:17 +08:00
mofeng-git
d0c0852fbb fix: 修复设置页 IPv6 URL 拼接问题 #241
统一处理 Settings 页面中的主机地址格式,避免 IPv6 场景下 RTSP 地址展示和重启后跳转 URL 因缺少方括号而失效。

Made-with: Cursor
2026-04-12 09:49:42 +08:00
mofeng-git
c0a0c90cbd fix: 修复 ATX 串口继电器共享设备失效 #233
当 power/reset 配置为同一串口设备时,改为复用同一个串口句柄,避免重复 open 导致 reset 不生效。

Made-with: Cursor
2026-04-11 22:31:17 +08:00
mofeng-git
9e3483b836 style: 统一代码格式
应用 fmt 对已暂存 Rust 文件进行格式化,保持代码风格一致并减少无意义差异。

Made-with: Cursor
2026-04-11 22:25:46 +08:00
mofeng-git
132f445c29 完善音频采集 2026-04-11 22:22:05 +08:00
mofeng-git
4952cbaf19 fix: 修复 RTSP 功能对 VLC 视频播放器的兼容性 #237 2026-04-11 21:55:34 +08:00
mofeng-git
099f0b1ca2 fix: 优化视频切换流畅性;修复 OTG HID 功能无法一次保存成功和页面未即刻生效问题 2026-04-11 21:20:54 +08:00
mofeng-git
eecbc0fc13 CSI 采集适配优化 2026-04-11 20:26:33 +08:00
mofeng-git
2d81a071e5 fix: 修复 MJPEG模式可用但状态显示离线的问题 2026-04-11 15:11:47 +08:00
mofeng-git
3e35181583 fix: 修复镜像列表滚动条问题 #238 2026-04-11 13:13:24 +08:00
mofeng-git
c3a3f41a2c 修改 .gitignore 2026-04-11 13:12:11 +08:00
mofeng-git
2b2b471cfb chore(release): bump version to v0.1.8 2026-04-01 21:30:41 +08:00
mofeng-git
abb319068b feat: 适配 RK 原生 HDMI IN 适配采集 2026-04-01 21:28:15 +08:00
mofeng-git
51d7d8b8be chore: bump version to v0.1.7 2026-03-28 22:45:39 +08:00
mofeng-git
f95714d9f0 删除无用测试 2026-03-28 22:41:35 +08:00
mofeng-git
7d52b2e2ea fix(otg): 优化运行时状态监测与未枚举提示 2026-03-28 22:06:53 +08:00
mofeng-git
a2a8b3802d feat(web): 优化视频格式与虚拟键盘显示 2026-03-28 21:34:22 +08:00
mofeng-git
f4283f45a4 refactor(otg): 简化运行时与设置逻辑 2026-03-28 21:09:10 +08:00
mofeng-git
4784cb75e4 调整登录页文案并改为点击切换语言 2026-03-28 20:47:29 +08:00
mofeng-git
abc6bd1677 feat(otg): 增加 libcomposite 自动加载兜底 2026-03-28 17:06:41 +08:00
mofeng-git
1c5288d783 优化控制台与设置页切换时的 WebRTC 会话保活与恢复逻辑 2026-03-27 11:29:27 +08:00
mofeng-git
6bcb54bd22 feat(web): 改为通过 WebSocket 推送 ttyd 状态并清理轮询与冗余接口 2026-03-27 10:49:04 +08:00
mofeng-git
e20136a5ab fix(web): 修复 WebRTC 首帧状态与视频状态判定 2026-03-27 10:44:59 +08:00
mofeng-git
c8fd3648ad refactor(video): 删除废弃视频流水线并收敛 MJPEG/WebRTC 编排与死代码 2026-03-27 08:21:14 +08:00
mofeng-git
6ef2d394d9 fix(video): 启动时丢弃前三帧无效 MJPEG 2026-03-26 23:27:42 +08:00
mofeng-git
762a3b037d chore(hid): 删除 CH9329 收发 trace 日志 2026-03-26 23:05:49 +08:00
mofeng-git
e09a906f93 refactor(hid): 统一 HID 键盘 CanonicalKey 语义并清理前端布局与输入链路冗余代码 2026-03-26 22:51:29 +08:00
mofeng-git
95bf1a852e feat(web): 登录页改为引导卡片样式并增加语言切换按钮 2026-03-26 22:45:28 +08:00
mofeng-git
200f947b5d fix(video): 修复 FFmpeg 硬编码 EAGAIN 刷屏并为编码错误增加日志节流 2026-03-26 22:30:53 +08:00
mofeng-git
46ae0c81e2 refactor(events): 将设备状态广播降级为快照同步并按需订阅 WebSocket 事件,顺带修复相关测试 2026-03-26 22:01:50 +08:00
mofeng-git
779aa180ad refactor: 重构部分事件检查逻辑,修复 ch9329 hid 状态显示异常 2026-03-26 12:33:24 +08:00
mofeng-git
ae26e3c863 feat: 支持自动检测因特尔 GPU 驱动类型 2026-03-25 20:27:26 +08:00
mofeng-git
eeb41159b7 build: 增加硬件编码所需驱动依赖 2026-03-22 20:19:30 +08:00
mofeng-git
24a10aa222 feat: 支持硬件编码能力测试,otg 自检修改为需要手动执行 2026-03-22 20:19:30 +08:00
mofeng-git
c119db4908 perf: 编码器探测测试分辨率由 1080p 调整为 720p 2026-03-22 20:19:30 +08:00
mofeng-git
0db287bf55 refactor: 重构 ffmpeg 编码器探测模块 2026-03-22 20:19:30 +08:00
mofeng-git
e229f35777 fix(web): 修复控制台全屏视频时鼠标定位偏移问题 2026-03-22 20:19:30 +08:00
SilentWind
df647b45cd Merge pull request #230 from a15355447898a/main
修复树莓派4B上 V4L2 编码时 WebRTC 无画面的问题
2026-03-02 19:05:32 +08:00
a15355447898a
b74659dcd4 refactor(video): restore v4l2r and remove temporary debug logs 2026-03-01 01:40:28 +08:00
a15355447898a
4f2fb534a4 fix(video): v4l path + webrtc h264 startup diagnostics 2026-03-01 01:24:26 +08:00
mofeng-git
bd17f8d0f8 chore: 更新版本号到 v0.1.6 2026-02-22 23:03:24 +08:00
mofeng-git
cee43795f8 fix: 添加前端电源状态显示 #226 2026-02-22 22:55:56 +08:00
mofeng-git
79d90ea703 docs: 更新版本号到 v0.1.5,更新 readme,删除落后的文档 2026-02-20 21:51:17 +08:00
mofeng-git
486db7b4aa feat(hid): 增加 HID 后端健康检查与错误码上报,完善前端掉线恢复状态同步及错误提示展示 2026-02-20 20:30:12 +08:00
mofeng-git
016c0d5dbb fix(atx): 完善串口继电器配置校验与前端防冲突 2026-02-20 15:36:08 +08:00
mofeng-git
6e2c6dea1c revert: remove non-ATX changes from #223 merge 2026-02-20 14:24:38 +08:00
SilentWind
078c4e4ea1 Merge pull request #223 from Fridayssheep/main
尝试添加了对串口协议的USB继电器的支持
2026-02-20 14:19:51 +08:00
SilentWind
6daa348c63 Merge branch 'main' into main 2026-02-20 14:19:38 +08:00
SilentWind
3341d8b5cd Merge pull request #225 from mofeng-git/pr-223-atx-only
feat(atx): merge serial relay support from #223
2026-02-20 14:14:30 +08:00
mofeng-git
251a1e00c4 feat(atx): merge serial relay support from #223 2026-02-20 14:11:00 +08:00
mofeng-git
ce622e4492 fix: 优化 WebRTC 建连流程、修复平台信息、修复虚拟键盘键值映射
- WebRTC:默认 mDNS 调整为 QueryOnly,Answer 阶段改为等待 ICE gathering complete(2.5s 超时),提升首次建连成功率与候选完整性
- WebRTC:前端建连流程增加阶段化状态与串行保护(connectInFlight/ready gate),优化配置变更后的重连时机与失败处理,减少竞态和无效重试
- Device:平台信息补充 `/proc/device-tree/model` 回退并统一展示为“处理器/平台”
- HID:键盘输入链路统一为 HID usage + modifier bitmask,修复虚拟键盘/宏/粘贴键值映射错误
2026-02-20 13:34:49 +08:00
mofeng-git
5f03971579 feat(web): 新增 HID OTG 自检接口与设置页环境诊断面板,并优化在线升级状态文案本地化及重启后自动刷新体验 2026-02-20 09:44:02 +08:00
mofeng-git
ba1b5224ff fix(web): 调整控制台HID状态卡片内容与弹层对齐 2026-02-11 22:16:54 +08:00
mofeng-git
ccaf4d1205 fix(rtsp): 修复清空用户名后仍触发认证导致 401 的问题 2026-02-11 20:55:20 +08:00
mofeng-git
6ed1cf5bef feat: HID串口优先ttyUSB并在视频设备名称后显示路径 2026-02-11 20:45:13 +08:00
mofeng-git
74411d354c fix: 修复卡片字体大小不一致 2026-02-11 20:00:49 +08:00
mofeng-git
3133db9c86 fix: 修复 rtsp 服务连接错误 2026-02-11 20:00:33 +08:00
mofeng-git
934dc48208 feat: 支持在线升级功能 2026-02-11 19:41:19 +08:00
mofeng-git
60b294e0ab feat: 增加WOL服务端历史记录并支持跨浏览器同步 2026-02-11 17:04:40 +08:00
mofeng-git
fb975875f1 feat: 新增 RTSP 设置菜单与配置面板 2026-02-11 17:01:25 +08:00
mofeng-git
f912c977d0 refactor: 移除 ttyd 扩展的“用户凭据”功能 2026-02-11 16:50:49 +08:00
mofeng-git
24c4002ef2 fix: 修复 RustDesk 中继选择逻辑,使“用户本地配置的 relay_server(非空)”在运行时优先生效 2026-02-11 16:29:47 +08:00
mofeng-git
a7143287d9 fix: 修复构建错误,构建时安装 rsync 2026-02-11 16:17:27 +08:00
mofeng-git
3824e57fc5 feat: 支持 rtsp 功能 2026-02-11 16:06:06 +08:00
Fridayssheep
ba6ec56cee fix: 修复了确认对话框操作的变量在点击时被重置的bug 2026-02-11 15:16:42 +08:00
Fridayssheep
21bea797e4 feat: 添加了对串口协议的继电器的支持 2026-02-11 13:06:05 +08:00
mofeng-git
261deb1303 refactor: 收敛单用户模型并优化可访问性与响应式体验
- 后端移除 is_admin 权限字段与相关逻辑,统一为单用户系统模型
- 修复会话过期清理的时间比较方式(改为 RFC3339 参数比较)
- /api/config 聚合配置增加敏感字段脱敏,避免暴露 TURN/RustDesk 密钥与密码
- 配置更新日志改为摘要,避免打印完整配置内容
- 前端修复可点击卡片语义与键盘可达,补齐图标按钮可访问名称
- 调整弹窗与抽屉的响应式尺寸,优化多端显示与交互
2026-02-10 22:30:52 +08:00
mofeng-git
394baca938 fix: 补齐 ATX 控制器缺失接口并完成全项目 clippy -D warnings 修复 2026-02-10 21:37:33 +08:00
SilentWind
7baf6fcf44 Merge pull request #214 from a15355447898a/main
支持v4l2编码,arm机器原生构建,允许初始化时禁用HID,修改README
2026-02-10 14:31:46 +08:00
SilentWind
04a2ff9724 Merge branch 'main' into main 2026-02-10 14:31:17 +08:00
mofeng-git
72eb2c450d feat: 迁移视频采集到 v4l2r,支持多平面设备并完善构建头文件
- 将 V4L2 采集依赖从 v4l 切换到 v4l2r

- 新增基于 v4l2r 的 mmap 采集实现,优先使用 VIDEO_CAPTURE_MPLANE

- 更新像素格式转换与设备枚举逻辑,探测阶段改为只读打开

- 增加采集错误日志节流,避免 dqbuf EINVAL 日志风暴

- 交叉编译镜像安装更新的 Linux 内核头文件供 bindgen 使用
2026-02-10 13:52:52 +08:00
mofeng
f8a031c90c feat: 支持树莓派 v4l2m2m 编码器探测 2026-02-09 14:54:46 +08:00
ayaya
176e71c79e 修改readme 2026-01-23 21:24:22 +08:00
a15355447898a
d78c6ed047 回滚docker构建脚本 2026-01-23 21:20:48 +08:00
a15355447898a
89072ad58d 支持v4l2编码,arm机器原生构建,docker镜像换archlinux,允许初始化时禁用HID 2026-01-23 17:11:19 +08:00
mofeng
e7d8c93bff docs: 新明 2026-01-20 19:53:15 +08:00
267 changed files with 27029 additions and 37449 deletions

3
.gitignore vendored
View File

@@ -30,6 +30,7 @@ Thumbs.db
# Build artifacts
/dist/
/build-staging
# Frontend (built files)
/web/node_modules/
@@ -40,3 +41,5 @@ CLAUDE.md
# Secrets (compile-time configuration)
secrets.toml
.env
/docs/
web/package-lock.json

View File

@@ -1,6 +1,6 @@
[package]
name = "one-kvm"
version = "0.1.4"
version = "0.2.0"
edition = "2021"
authors = ["SilentWind"]
description = "A open and lightweight IP-KVM solution written in Rust"
@@ -16,8 +16,8 @@ tokio-util = { version = "0.7", features = ["rt"] }
# Web framework
axum = { version = "0.8", features = ["ws", "multipart", "tokio"] }
axum-extra = { version = "0.12", features = ["typed-header", "cookie"] }
tower-http = { version = "0.6", features = ["fs", "cors", "trace", "compression-gzip"] }
axum-extra = { version = "0.12", features = ["cookie"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
# Database - Use bundled SQLite for static linking
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
@@ -28,7 +28,7 @@ serde_json = "1"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "tracing-log"] }
# Error handling
thiserror = "2"
@@ -40,17 +40,16 @@ rand = "0.9"
# Utilities
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
base64 = "0.22"
nix = { version = "0.30", features = ["fs", "net", "hostname", "poll"] }
# HTTP client (for URL downloads)
# Use rustls by default, but allow native-tls for systems with older GLIBC
reqwest = { version = "0.13", features = ["stream", "rustls"], default-features = false }
reqwest = { version = "0.13", features = ["stream", "rustls", "json"], default-features = false }
urlencoding = "2"
# Static file embedding
rust-embed = { version = "8", features = ["compression"] }
rust-embed = { version = "8", features = ["compression", "debug-embed"] }
mime_guess = "2"
# TLS/HTTPS
@@ -61,11 +60,11 @@ axum-server = { version = "0.8", features = ["tls-rustls"] }
# CLI argument parsing
clap = { version = "4", features = ["derive"] }
# Time
time = "0.3"
# Time (cookie max_age + RFC3339 timestamps)
time = { version = "0.3", features = ["serde", "formatting", "parsing"] }
# Video capture (V4L2)
v4l = "0.14"
v4l2r = "0.0.7"
# JPEG encoding (libjpeg-turbo, SIMD accelerated)
turbojpeg = "1.3"
@@ -91,6 +90,8 @@ arc-swap = "1.8"
# WebRTC
webrtc = "0.14"
rtp = "0.14"
rtsp-types = "0.1"
sdp-types = "0.1"
# Audio (ALSA capture + Opus encoding)
# Note: audiopus links to libopus.so (unavoidable for audio support)
@@ -115,7 +116,6 @@ hwcodec = { path = "libs/hwcodec" }
protobuf = { version = "3.7", features = ["with-bytes"] }
sodiumoxide = "0.2"
sha2 = "0.10"
# High-performance pixel format conversion (libyuv)
libyuv = { path = "res/vcpkg/libyuv" }
@@ -123,13 +123,10 @@ libyuv = { path = "res/vcpkg/libyuv" }
typeshare = "1.0"
[dev-dependencies]
tokio-test = "0.4"
tempfile = "3"
[build-dependencies]
protobuf-codegen = "3.7"
toml = "0.9"
cc = "1"
[profile.release]
opt-level = 3

674
LICENSE Normal file
View File

@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

238
README.en.md Normal file
View File

@@ -0,0 +1,238 @@
<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>
[![GitHub Release](https://img.shields.io/github/v/release/mofeng-git/One-KVM)](https://github.com/mofeng-git/One-KVM/releases)
[![GitHub stars](https://img.shields.io/github/stars/mofeng-git/One-KVM?style=social)](https://github.com/mofeng-git/One-KVM/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/mofeng-git/One-KVM?style=social)](https://github.com/mofeng-git/One-KVM/network/members)
[![GitHub issues](https://img.shields.io/github/issues/mofeng-git/One-KVM)](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">
![One-KVM web console](https://one-kvm.cn/hero-app-effect.png)
</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
**Mirror Download Services:**
- **[Chongqing University Open Source Software Mirror](https://mirrors.cqu.edu.cn/)** — provides mirror download services
**File hosting**
- **[Huang1111 public-interest program](https://pan.huang1111.cn/s/mxkx3T1)** — login-free downloads
**Cloud**
- **[林枫云](https://www.dkdun.cn)** — project server sponsorship
![林枫云](https://docs.one-kvm.cn/img/36076FEFF0898A80EBD5756D28F4076C.png)
林枫云 offers premium network routes, high-frequency game servers, and high-bandwidth servers in China and abroad.

View File

@@ -2,18 +2,12 @@
<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>
[![GitHub Release](https://img.shields.io/github/v/release/mofeng-git/One-KVM)](https://github.com/mofeng-git/One-KVM/releases)
[![GitHub stars](https://img.shields.io/github/stars/mofeng-git/One-KVM?style=social)](https://github.com/mofeng-git/One-KVM/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/mofeng-git/One-KVM?style=social)](https://github.com/mofeng-git/One-KVM/network/members)
[![GitHub issues](https://img.shields.io/github/issues/mofeng-git/One-KVM)](https://github.com/mofeng-git/One-KVM/issues)
<p>
<a href="docs/README.md">📖 技术文档</a>
<a href="#快速开始">⚡ 快速开始</a>
<a href="#功能介绍">📊 功能介绍</a>
<a href="#迁移说明">🔁 迁移说明</a>
</p>
</div>
---
@@ -22,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 Web 控制台界面](https://one-kvm.cn/hero-app-effect.png)
- 如果你在使用 **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 / WebRTCH.264/H.265/VP8/VP9 |
| 视频采集 | HDMI USB /MIPI CSI/RK3588 HDMI IN 采集支持,提供 MJPEG / WebRTCH.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 协议集成(用于跨平台远程访问能力扩展)
此外提供基于 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. 包含您使用的软件版本、硬件配置和系统信息
## 赞助支持
@@ -95,8 +110,6 @@
- Will
- 浩龙的电子嵌入式之路
- 自.知
- 观棋不语٩ ི۶
@@ -195,6 +208,8 @@
- 爱发电用户_JT6c
- 爱发电用户_d3d9c
- ......
</details>
@@ -203,13 +218,16 @@
本项目得到以下赞助商的支持:
**镜像下载服务:**
- **[重庆大学开源软件镜像站](https://mirrors.cqu.edu.cn/)** - 提供镜像站下载服务
**文件存储服务:**
- **[Huang1111公益计划](https://pan.huang1111.cn/s/mxkx3T1)** - 提供免登录下载服务
**云服务商**
- **[林枫云](https://www.dkdun.cn)** - 赞助了本项目宁波大带宽服务器
- **[林枫云](https://www.dkdun.cn)** - 赞助了本项目服务器
![林枫云](https://docs.one-kvm.cn/img/36076FEFF0898A80EBD5756D28F4076C.png)
林枫云主营国内外地域的精品线路业务服务器、高主频游戏服务器和大带宽服务器。
林枫云主营国内外地域的精品线路业务服务器、高主频游戏服务器和大带宽服务器。

View File

@@ -64,25 +64,6 @@ fn generate_secrets() {
pub mod ice {
/// Google public STUN server URL (hardcoded)
pub const STUN_SERVER: &str = "stun:stun.l.google.com:19302";
/// TURN server URLs - not provided, users must configure their own
pub const TURN_URLS: &str = "";
/// TURN authentication username
pub const TURN_USERNAME: &str = "";
/// TURN authentication password
pub const TURN_PASSWORD: &str = "";
/// Always returns true since we have STUN
pub const fn is_configured() -> bool {
true
}
/// Always returns false since TURN is not provided
pub const fn has_turn() -> bool {
false
}
}
/// RustDesk public server configuration - NOT PROVIDED

View File

@@ -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; \

View File

@@ -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; \

View File

@@ -3,9 +3,13 @@
FROM debian:11
# Linux headers used by v4l2r bindgen
ARG LINUX_HEADERS_VERSION=6.6
ARG LINUX_HEADERS_SHA256=
# Set Rustup mirrors (Aliyun)
ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \
RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup
#ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \
# RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup
# Install Rust toolchain
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -31,7 +35,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
meson \
ninja-build \
wget \
xz-utils \
file \
rsync \
gcc-aarch64-linux-gnu \
g++-aarch64-linux-gnu \
libc6-dev-arm64-cross \
@@ -47,10 +53,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libv4l-dev:arm64 \
libudev-dev:arm64 \
zlib1g-dev:arm64 \
linux-libc-dev:arm64 \
# Note: libjpeg-turbo, libyuv, libvpx, libx264, libx265, libopus are built from source below for static linking
libdrm-dev:arm64 \
&& rm -rf /var/lib/apt/lists/*
# Install newer V4L2 headers for v4l2r bindgen
RUN mkdir -p /opt/v4l2-headers \
&& wget -q https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-${LINUX_HEADERS_VERSION}.tar.xz -O /tmp/linux-headers.tar.xz \
&& if [ -n "$LINUX_HEADERS_SHA256" ]; then echo "$LINUX_HEADERS_SHA256 /tmp/linux-headers.tar.xz" | sha256sum -c -; fi \
&& tar -xf /tmp/linux-headers.tar.xz -C /tmp \
&& cd /tmp/linux-${LINUX_HEADERS_VERSION} \
&& make ARCH=arm64 headers_install INSTALL_HDR_PATH=/opt/v4l2-headers \
&& rm -rf /tmp/linux-${LINUX_HEADERS_VERSION} /tmp/linux-headers.tar.xz
ENV V4L2R_VIDEODEV2_H_PATH=/opt/v4l2-headers/include
# Build static libjpeg-turbo from source (cross-compile for ARM64)
RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libjpeg-turbo \
&& cd /tmp/libjpeg-turbo \

View File

@@ -3,9 +3,13 @@
FROM debian:11
# Linux headers used by v4l2r bindgen
ARG LINUX_HEADERS_VERSION=6.6
ARG LINUX_HEADERS_SHA256=
# Set Rustup mirrors (Aliyun)
ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \
RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup
#ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \
# RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup
# Install Rust toolchain
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -31,7 +35,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
meson \
ninja-build \
wget \
xz-utils \
file \
rsync \
gcc-arm-linux-gnueabihf \
g++-arm-linux-gnueabihf \
libc6-dev-armhf-cross \
@@ -46,10 +52,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libasound2-dev:armhf \
libv4l-dev:armhf \
libudev-dev:armhf \
linux-libc-dev:armhf \
zlib1g-dev:armhf \
libdrm-dev:armhf \
&& rm -rf /var/lib/apt/lists/*
# Install newer V4L2 headers for v4l2r bindgen
RUN mkdir -p /opt/v4l2-headers \
&& wget -q https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-${LINUX_HEADERS_VERSION}.tar.xz -O /tmp/linux-headers.tar.xz \
&& if [ -n "$LINUX_HEADERS_SHA256" ]; then echo "$LINUX_HEADERS_SHA256 /tmp/linux-headers.tar.xz" | sha256sum -c -; fi \
&& tar -xf /tmp/linux-headers.tar.xz -C /tmp \
&& cd /tmp/linux-${LINUX_HEADERS_VERSION} \
&& make ARCH=arm headers_install INSTALL_HDR_PATH=/opt/v4l2-headers \
&& rm -rf /tmp/linux-${LINUX_HEADERS_VERSION} /tmp/linux-headers.tar.xz
ENV V4L2R_VIDEODEV2_H_PATH=/opt/v4l2-headers/include
# Build static libjpeg-turbo from source (cross-compile for ARMv7)
RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libjpeg-turbo \
&& cd /tmp/libjpeg-turbo \

View File

@@ -3,9 +3,13 @@
FROM debian:11
# Linux headers used by v4l2r bindgen
ARG LINUX_HEADERS_VERSION=6.6
ARG LINUX_HEADERS_SHA256=
# Set Rustup mirrors (Aliyun)
ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \
RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup
#ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \
# RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup
# Install Rust toolchain
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -29,6 +33,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libclang-dev \
llvm \
wget \
xz-utils \
rsync \
# Autotools for libopus (requires autoreconf)
autoconf \
automake \
@@ -37,6 +43,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libasound2-dev \
libv4l-dev \
libudev-dev \
linux-libc-dev \
zlib1g-dev \
# Note: libjpeg-turbo, libx264, libx265, libopus are built from source below for static linking
libva-dev \
@@ -49,6 +56,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxdmcp-dev \
&& rm -rf /var/lib/apt/lists/*
# Install newer V4L2 headers for v4l2r bindgen
RUN mkdir -p /opt/v4l2-headers \
&& wget -q https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-${LINUX_HEADERS_VERSION}.tar.xz -O /tmp/linux-headers.tar.xz \
&& if [ -n "$LINUX_HEADERS_SHA256" ]; then echo "$LINUX_HEADERS_SHA256 /tmp/linux-headers.tar.xz" | sha256sum -c -; fi \
&& tar -xf /tmp/linux-headers.tar.xz -C /tmp \
&& cd /tmp/linux-${LINUX_HEADERS_VERSION} \
&& make ARCH=x86 headers_install INSTALL_HDR_PATH=/opt/v4l2-headers \
&& rm -rf /tmp/linux-${LINUX_HEADERS_VERSION} /tmp/linux-headers.tar.xz
ENV V4L2R_VIDEODEV2_H_PATH=/opt/v4l2-headers/include
# Build static libjpeg-turbo from source (needed by libyuv)
RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libjpeg-turbo \
&& cd /tmp/libjpeg-turbo \
@@ -208,4 +226,4 @@ RUN rustup target add x86_64-unknown-linux-gnu
# Configure environment for static linking
ENV PKG_CONFIG_ALLOW_CROSS=1\
FFMPEG_STATIC=1 \
LIBYUV_STATIC=1
LIBYUV_STATIC=1

View File

@@ -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.

View File

@@ -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

View File

@@ -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"

View File

@@ -1,130 +0,0 @@
# One-KVM 技术文档
本目录包含 One-KVM 项目的完整技术文档。
## 文档结构
```
docs/
├── README.md # 本文件 - 文档索引
├── system-architecture.md # 系统架构文档
├── tech-stack.md # 技术栈文档
└── modules/ # 模块文档
├── video.md # 视频模块
├── hid.md # HID 模块
├── otg.md # OTG 模块
├── msd.md # MSD 模块
├── atx.md # ATX 模块
├── audio.md # 音频模块
├── webrtc.md # WebRTC 模块
├── rustdesk.md # RustDesk 模块
├── auth.md # 认证模块
├── config.md # 配置模块
├── events.md # 事件模块
└── web.md # Web 模块
```
## 快速导航
### 核心文档
| 文档 | 描述 |
|------|------|
| [系统架构](./system-architecture.md) | 整体架构设计、数据流、模块依赖 |
| [技术栈](./tech-stack.md) | 使用的技术、库和开发规范 |
### 功能模块
| 模块 | 描述 | 关键文件 |
|------|------|---------|
| [Video](./modules/video.md) | 视频采集和编码 | `src/video/` |
| [HID](./modules/hid.md) | 键盘鼠标控制 | `src/hid/` |
| [OTG](./modules/otg.md) | USB Gadget 管理 | `src/otg/` |
| [MSD](./modules/msd.md) | 虚拟存储设备 | `src/msd/` |
| [ATX](./modules/atx.md) | 电源控制 | `src/atx/` |
| [Audio](./modules/audio.md) | 音频采集编码 | `src/audio/` |
| [WebRTC](./modules/webrtc.md) | WebRTC 流媒体 | `src/webrtc/` |
| [RustDesk](./modules/rustdesk.md) | RustDesk 协议集成 | `src/rustdesk/` |
### 基础设施
| 模块 | 描述 | 关键文件 |
|------|------|---------|
| [Auth](./modules/auth.md) | 认证和会话 | `src/auth/` |
| [Config](./modules/config.md) | 配置管理 | `src/config/` |
| [Events](./modules/events.md) | 事件系统 | `src/events/` |
| [Web](./modules/web.md) | HTTP API | `src/web/` |
## 架构概览
```
┌─────────────────────────────────────────────────────────────────┐
│ One-KVM System │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Web Frontend (Vue3) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Axum Web Server │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ AppState │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ Video │ │ HID │ │ MSD │ │ ATX │ │ │
│ │ │ Module │ │ Module │ │ Module │ │ Module │ │ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ Audio │ │ WebRTC │ │RustDesk│ │ Events │ │ │
│ │ │ Module │ │ Module │ │ Module │ │ Bus │ │ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Hardware Layer │ │
│ │ V4L2 │ USB OTG │ GPIO │ ALSA │ Network │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## 关键特性
- **单一二进制**: Web UI + 后端一体化部署
- **双流模式**: WebRTC (H264/H265/VP8/VP9) + MJPEG
- **USB OTG**: 虚拟键鼠、虚拟存储
- **硬件加速**: VAAPI/RKMPP/V4L2 M2M
- **RustDesk**: 跨平台远程访问
- **无配置文件**: SQLite 配置存储
## 目标平台
| 平台 | 架构 | 用途 |
|------|------|------|
| aarch64-unknown-linux-gnu | ARM64 | 主要目标 |
| armv7-unknown-linux-gnueabihf | ARMv7 | 备选 |
| x86_64-unknown-linux-gnu | x86-64 | 开发/测试 |
## 快速开始
```bash
# 构建前端
cd web && npm install && npm run build && cd ..
# 构建后端
cargo build --release
# 运行
./target/release/one-kvm --enable-https
```
## 相关链接
- [项目仓库](https://github.com/mofeng-git/One-KVM)
- [开发计划](./DEVELOPMENT_PLAN.md)
- [项目目标](./PROJECT_GOALS.md)

View File

@@ -1,484 +0,0 @@
# ATX 模块文档
## 1. 模块概述
ATX 模块提供电源控制功能,通过 GPIO 或 USB 继电器控制目标计算机的电源和重置按钮。
### 1.1 主要功能
- 电源按钮控制
- 重置按钮控制
- 电源 LED 状态监视
- Wake-on-LAN 支持
- 多后端支持 (GPIO/USB 继电器)
### 1.2 文件结构
```
src/atx/
├── mod.rs # 模块导出
├── controller.rs # AtxController (11KB)
├── executor.rs # 动作执行器 (10KB)
├── types.rs # 类型定义 (7KB)
├── led.rs # LED 监视 (5KB)
└── wol.rs # Wake-on-LAN (5KB)
```
---
## 2. 架构设计
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ ATX Architecture │
└─────────────────────────────────────────────────────────────────────────────┘
Web API
┌─────────────────┐
│ AtxController │
│ (controller.rs) │
└────────┬────────┘
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ Power │ │ Reset │ │ LED │
│Executor│ │Executor│ │Monitor │
└───┬────┘ └───┬────┘ └───┬────┘
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ GPIO │ │ GPIO │ │ GPIO │
│ or USB │ │ or USB │ │ Input │
│ Relay │ │ Relay │ │ │
└───┬────┘ └───┬────┘ └───┬────┘
│ │ │
└───────────┼─────────────┘
┌───────────────┐
│ Target PC │
│ (ATX Header) │
└───────────────┘
```
---
## 3. 核心组件
### 3.1 AtxController (controller.rs)
```rust
pub struct AtxController {
/// 电源按钮配置
power: Arc<AtxButton>,
/// 重置按钮配置
reset: Arc<AtxButton>,
/// LED 监视器
led_monitor: Arc<RwLock<Option<LedMonitor>>>,
/// WoL 控制器
wol: Arc<RwLock<Option<WolController>>>,
/// 当前状态
state: Arc<RwLock<AtxState>>,
/// 事件总线
events: Arc<EventBus>,
}
impl AtxController {
/// 创建控制器
pub fn new(config: &AtxConfig, events: Arc<EventBus>) -> Result<Self>;
/// 短按电源按钮 (开机/正常关机)
pub async fn power_short_press(&self) -> Result<()>;
/// 长按电源按钮 (强制关机)
pub async fn power_long_press(&self) -> Result<()>;
/// 按重置按钮
pub async fn reset_press(&self) -> Result<()>;
/// 获取电源状态
pub fn power_state(&self) -> PowerState;
/// 发送 WoL 魔术包
pub async fn wake_on_lan(&self, mac: &str) -> Result<()>;
/// 获取状态
pub fn state(&self) -> AtxState;
/// 重新加载配置
pub async fn reload(&self, config: &AtxConfig) -> Result<()>;
}
pub struct AtxState {
/// 是否可用
pub available: bool,
/// 电源是否开启
pub power_on: bool,
/// 最后操作时间
pub last_action: Option<DateTime<Utc>>,
/// 错误信息
pub error: Option<String>,
}
pub enum PowerState {
On,
Off,
Unknown,
}
```
### 3.2 AtxButton (executor.rs)
```rust
pub struct AtxButton {
/// 按钮名称
name: String,
/// 驱动类型
driver: AtxDriverType,
/// GPIO 句柄
gpio: Option<LineHandle>,
/// USB 继电器句柄
relay: Option<UsbRelay>,
/// 配置
config: AtxKeyConfig,
}
impl AtxButton {
/// 创建按钮
pub fn new(name: &str, config: &AtxKeyConfig) -> Result<Self>;
/// 短按 (100ms)
pub async fn short_press(&self) -> Result<()>;
/// 长按 (3000ms)
pub async fn long_press(&self) -> Result<()>;
/// 自定义按压时间
pub async fn press(&self, duration: Duration) -> Result<()>;
/// 设置输出状态
fn set_output(&self, high: bool) -> Result<()>;
}
pub enum AtxDriverType {
/// GPIO 直连
Gpio,
/// USB 继电器
UsbRelay,
/// 禁用
None,
}
```
### 3.3 LedMonitor (led.rs)
```rust
pub struct LedMonitor {
/// GPIO 引脚
pin: u32,
/// GPIO 句柄
line: LineHandle,
/// 当前状态
state: Arc<AtomicBool>,
/// 监视任务
monitor_task: Option<JoinHandle<()>>,
}
impl LedMonitor {
/// 创建监视器
pub fn new(config: &AtxLedConfig) -> Result<Self>;
/// 启动监视
pub fn start(&mut self, events: Arc<EventBus>) -> Result<()>;
/// 停止监视
pub fn stop(&mut self);
/// 获取当前状态
pub fn state(&self) -> bool;
}
```
### 3.4 WolController (wol.rs)
```rust
pub struct WolController {
/// 网络接口
interface: String,
/// 广播地址
broadcast_addr: SocketAddr,
}
impl WolController {
/// 创建控制器
pub fn new(interface: Option<&str>) -> Result<Self>;
/// 发送 WoL 魔术包
pub async fn wake(&self, mac: &str) -> Result<()>;
/// 构建魔术包
fn build_magic_packet(mac: &[u8; 6]) -> [u8; 102];
/// 解析 MAC 地址
fn parse_mac(mac: &str) -> Result<[u8; 6]>;
}
```
---
## 4. 配置
```rust
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct AtxConfig {
/// 是否启用
pub enabled: bool,
/// 电源按钮配置
pub power: AtxKeyConfig,
/// 重置按钮配置
pub reset: AtxKeyConfig,
/// LED 监视配置
pub led: AtxLedConfig,
/// WoL 配置
pub wol: WolConfig,
}
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct AtxKeyConfig {
/// 驱动类型
pub driver: AtxDriverType,
/// GPIO 芯片 (如 /dev/gpiochip0)
pub gpio_chip: Option<String>,
/// GPIO 引脚号
pub gpio_pin: Option<u32>,
/// USB 继电器设备
pub relay_device: Option<String>,
/// 继电器通道
pub relay_channel: Option<u8>,
/// 激活电平
pub active_level: ActiveLevel,
}
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct AtxLedConfig {
/// 是否启用
pub enabled: bool,
/// GPIO 芯片
pub gpio_chip: Option<String>,
/// GPIO 引脚号
pub gpio_pin: Option<u32>,
/// 激活电平
pub active_level: ActiveLevel,
}
pub enum ActiveLevel {
High,
Low,
}
impl Default for AtxConfig {
fn default() -> Self {
Self {
enabled: false,
power: AtxKeyConfig::default(),
reset: AtxKeyConfig::default(),
led: AtxLedConfig::default(),
wol: WolConfig::default(),
}
}
}
```
---
## 5. API 端点
| 端点 | 方法 | 描述 |
|------|------|------|
| `/api/atx/status` | GET | 获取 ATX 状态 |
| `/api/atx/power/short` | POST | 短按电源 |
| `/api/atx/power/long` | POST | 长按电源 |
| `/api/atx/reset` | POST | 按重置 |
| `/api/atx/wol` | POST | 发送 WoL |
### 响应格式
```json
// GET /api/atx/status
{
"available": true,
"power_on": true,
"last_action": "2024-01-15T10:30:00Z",
"error": null
}
// POST /api/atx/wol
// Request: { "mac": "00:11:22:33:44:55" }
{
"success": true
}
```
---
## 6. 硬件连接
### 6.1 GPIO 直连
```
One-KVM Device Target PC
┌─────────────┐ ┌─────────────┐
│ GPIO Pin │───────────────│ Power SW │
│ (Output) │ │ │
└─────────────┘ └─────────────┘
接线说明:
- GPIO 引脚连接到 ATX 电源按钮
- 使用光耦或继电器隔离 (推荐)
- 注意电平匹配
```
### 6.2 USB 继电器
```
One-KVM Device USB Relay Target PC
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ USB │───────────────│ Relay │──────────│ Power SW │
│ │ │ │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
优点:
- 完全隔离
- 无需担心电平问题
- 更安全
```
---
## 7. 事件
```rust
pub enum SystemEvent {
AtxStateChanged {
power_on: bool,
last_action: Option<String>,
error: Option<String>,
},
AtxActionPerformed {
action: String, // "power_short" | "power_long" | "reset" | "wol"
success: bool,
},
}
```
---
## 8. 错误处理
```rust
#[derive(Debug, thiserror::Error)]
pub enum AtxError {
#[error("ATX not available")]
NotAvailable,
#[error("GPIO error: {0}")]
GpioError(String),
#[error("Relay error: {0}")]
RelayError(String),
#[error("WoL error: {0}")]
WolError(String),
#[error("Invalid MAC address: {0}")]
InvalidMac(String),
#[error("Operation in progress")]
Busy,
}
```
---
## 9. 使用示例
```rust
let atx = AtxController::new(&config, events)?;
// 开机
atx.power_short_press().await?;
// 检查状态
tokio::time::sleep(Duration::from_secs(5)).await;
if atx.power_state() == PowerState::On {
println!("PC is now on");
}
// 强制关机
atx.power_long_press().await?;
// 重置
atx.reset_press().await?;
// Wake-on-LAN
atx.wake_on_lan("00:11:22:33:44:55").await?;
```
---
## 10. 常见问题
### Q: GPIO 无法控制?
1. 检查引脚配置
2. 检查权限 (`/dev/gpiochip*`)
3. 检查接线
### Q: LED 状态不正确?
1. 检查 active_level 配置
2. 检查 GPIO 输入模式
### Q: WoL 不工作?
1. 检查目标 PC BIOS 设置
2. 检查网卡支持
3. 检查网络广播

View File

@@ -1,463 +0,0 @@
# Audio 模块文档
## 1. 模块概述
Audio 模块负责音频采集和编码,支持 ALSA 采集和 Opus 编码。
### 1.1 主要功能
- ALSA 音频采集
- Opus 编码
- 多质量配置
- WebSocket/WebRTC 传输
### 1.2 文件结构
```
src/audio/
├── mod.rs # 模块导出
├── controller.rs # AudioController (15KB)
├── capture.rs # ALSA 采集 (12KB)
├── encoder.rs # Opus 编码 (8KB)
├── shared_pipeline.rs # 共享管道 (15KB)
├── monitor.rs # 健康监视 (11KB)
└── device.rs # 设备枚举 (8KB)
```
---
## 2. 架构设计
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Audio Architecture │
└─────────────────────────────────────────────────────────────────────────────┘
ALSA Device (hw:0,0)
│ PCM 48kHz/16bit/Stereo
┌─────────────────┐
│ AudioCapturer │
│ (capture.rs) │
└────────┬────────┘
┌─────────────────────────────────────────┐
│ SharedAudioPipeline │
│ ┌─────────────────────────────────┐ │
│ │ Opus Encoder │ │
│ │ 48kHz → 24-96 kbps │ │
│ └─────────────────────────────────┘ │
└────────────────┬────────────────────────┘
┌─────────┴─────────┐
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ WebSocket │ │ WebRTC │
│ Stream │ │ Audio Track │
└─────────────┘ └─────────────┘
```
---
## 3. 核心组件
### 3.1 AudioController (controller.rs)
```rust
pub struct AudioController {
/// 采集器
capturer: Arc<RwLock<Option<AudioCapturer>>>,
/// 共享管道
pipeline: Arc<SharedAudioPipeline>,
/// 配置
config: Arc<RwLock<AudioConfig>>,
/// 状态
state: Arc<RwLock<AudioState>>,
/// 事件总线
events: Arc<EventBus>,
}
impl AudioController {
/// 创建控制器
pub fn new(config: &AudioConfig, events: Arc<EventBus>) -> Result<Self>;
/// 启动音频
pub async fn start(&self) -> Result<()>;
/// 停止音频
pub async fn stop(&self) -> Result<()>;
/// 订阅音频帧
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame>;
/// 获取状态
pub fn status(&self) -> AudioStatus;
/// 设置质量
pub fn set_quality(&self, quality: AudioQuality) -> Result<()>;
/// 列出设备
pub fn list_devices(&self) -> Vec<AudioDeviceInfo>;
/// 重新加载配置
pub async fn reload(&self, config: &AudioConfig) -> Result<()>;
}
pub struct AudioStatus {
pub enabled: bool,
pub streaming: bool,
pub device: Option<String>,
pub sample_rate: u32,
pub channels: u16,
pub bitrate: u32,
pub error: Option<String>,
}
```
### 3.2 AudioCapturer (capture.rs)
```rust
pub struct AudioCapturer {
/// PCM 句柄
pcm: PCM,
/// 设备名
device: String,
/// 采样率
sample_rate: u32,
/// 通道数
channels: u16,
/// 帧大小
frame_size: usize,
/// 运行状态
running: AtomicBool,
}
impl AudioCapturer {
/// 打开设备
pub fn open(device: &str, config: &CaptureConfig) -> Result<Self>;
/// 读取音频帧
pub fn read_frame(&self) -> Result<Vec<i16>>;
/// 启动采集
pub fn start(&self) -> Result<()>;
/// 停止采集
pub fn stop(&self);
/// 是否运行中
pub fn is_running(&self) -> bool;
}
pub struct CaptureConfig {
pub sample_rate: u32, // 48000
pub channels: u16, // 2
pub frame_size: usize, // 960 (20ms)
pub buffer_size: usize, // 4800
}
```
### 3.3 OpusEncoder (encoder.rs)
```rust
pub struct OpusEncoder {
/// Opus 编码器
encoder: audiopus::Encoder,
/// 采样率
sample_rate: u32,
/// 通道数
channels: u16,
/// 帧大小
frame_size: usize,
/// 码率
bitrate: u32,
}
impl OpusEncoder {
/// 创建编码器
pub fn new(quality: AudioQuality) -> Result<Self>;
/// 编码 PCM 数据
pub fn encode(&mut self, pcm: &[i16]) -> Result<Vec<u8>>;
/// 设置码率
pub fn set_bitrate(&mut self, bitrate: u32) -> Result<()>;
/// 获取码率
pub fn bitrate(&self) -> u32;
/// 重置编码器
pub fn reset(&mut self) -> Result<()>;
}
```
### 3.4 SharedAudioPipeline (shared_pipeline.rs)
```rust
pub struct SharedAudioPipeline {
/// 采集器
capturer: Arc<RwLock<Option<AudioCapturer>>>,
/// 编码器
encoder: Arc<Mutex<OpusEncoder>>,
/// 广播通道
tx: broadcast::Sender<AudioFrame>,
/// 采集任务
capture_task: Arc<RwLock<Option<JoinHandle<()>>>>,
/// 配置
config: Arc<RwLock<AudioConfig>>,
}
impl SharedAudioPipeline {
/// 创建管道
pub fn new(config: &AudioConfig) -> Result<Self>;
/// 启动管道
pub async fn start(&self) -> Result<()>;
/// 停止管道
pub async fn stop(&self) -> Result<()>;
/// 订阅音频帧
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame>;
/// 获取统计
pub fn stats(&self) -> PipelineStats;
}
pub struct AudioFrame {
/// Opus 数据
pub data: Bytes,
/// 时间戳
pub timestamp: u64,
/// 帧序号
pub sequence: u64,
}
```
---
## 4. 音频质量
```rust
pub enum AudioQuality {
/// 24 kbps - 最低带宽
VeryLow,
/// 48 kbps - 低带宽
Low,
/// 64 kbps - 中等
Medium,
/// 96 kbps - 高质量
High,
}
impl AudioQuality {
pub fn bitrate(&self) -> u32 {
match self {
Self::VeryLow => 24000,
Self::Low => 48000,
Self::Medium => 64000,
Self::High => 96000,
}
}
}
```
---
## 5. 配置
```rust
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct AudioConfig {
/// 是否启用
pub enabled: bool,
/// 设备名
pub device: Option<String>,
/// 音频质量
pub quality: AudioQuality,
/// 自动启动
pub auto_start: bool,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
enabled: true,
device: None, // 使用默认设备
quality: AudioQuality::Medium,
auto_start: false,
}
}
}
```
---
## 6. API 端点
| 端点 | 方法 | 描述 |
|------|------|------|
| `/api/audio/status` | GET | 获取音频状态 |
| `/api/audio/start` | POST | 启动音频 |
| `/api/audio/stop` | POST | 停止音频 |
| `/api/audio/devices` | GET | 列出设备 |
| `/api/audio/quality` | GET | 获取质量 |
| `/api/audio/quality` | POST | 设置质量 |
| `/api/ws/audio` | WS | 音频流 |
### 响应格式
```json
// GET /api/audio/status
{
"enabled": true,
"streaming": true,
"device": "hw:0,0",
"sample_rate": 48000,
"channels": 2,
"bitrate": 64000,
"error": null
}
// GET /api/audio/devices
{
"devices": [
{
"name": "hw:0,0",
"description": "USB Audio Device",
"is_default": true
}
]
}
```
---
## 7. WebSocket 音频流
```javascript
// 连接 WebSocket
const ws = new WebSocket('/api/ws/audio');
ws.binaryType = 'arraybuffer';
// 初始化 Opus 解码器
const decoder = new OpusDecoder();
// 接收音频帧
ws.onmessage = (event) => {
const frame = new Uint8Array(event.data);
const pcm = decoder.decode(frame);
audioContext.play(pcm);
};
```
---
## 8. 事件
```rust
pub enum SystemEvent {
AudioStateChanged {
enabled: bool,
streaming: bool,
device: Option<String>,
error: Option<String>,
},
}
```
---
## 9. 错误处理
```rust
#[derive(Debug, thiserror::Error)]
pub enum AudioError {
#[error("Device not found: {0}")]
DeviceNotFound(String),
#[error("Device busy: {0}")]
DeviceBusy(String),
#[error("ALSA error: {0}")]
AlsaError(String),
#[error("Encoder error: {0}")]
EncoderError(String),
#[error("Not streaming")]
NotStreaming,
}
```
---
## 10. 使用示例
```rust
let controller = AudioController::new(&config, events)?;
// 启动音频
controller.start().await?;
// 订阅音频帧
let mut rx = controller.subscribe();
while let Ok(frame) = rx.recv().await {
// 处理 Opus 数据
send_to_client(frame.data);
}
// 停止
controller.stop().await?;
```
---
## 11. 常见问题
### Q: 找不到音频设备?
1. 检查 ALSA 配置
2. 运行 `arecord -l`
3. 检查权限
### Q: 音频延迟高?
1. 减小帧大小
2. 降低质量
3. 检查网络
### Q: 音频断断续续?
1. 增大缓冲区
2. 检查 CPU 负载
3. 使用更低质量

View File

@@ -1,340 +0,0 @@
# Auth 模块文档
## 1. 模块概述
Auth 模块提供用户认证和会话管理功能。
### 1.1 主要功能
- 用户管理
- 密码哈希 (Argon2)
- 会话管理
- 认证中间件
- 权限控制
### 1.2 文件结构
```
src/auth/
├── mod.rs # 模块导出
├── user.rs # 用户管理 (5KB)
├── session.rs # 会话管理 (4KB)
├── password.rs # 密码哈希 (1KB)
└── middleware.rs # 中间件 (4KB)
```
---
## 2. 核心组件
### 2.1 UserStore (user.rs)
```rust
pub struct UserStore {
db: Pool<Sqlite>,
}
impl UserStore {
/// 创建存储
pub async fn new(db: Pool<Sqlite>) -> Result<Self>;
/// 创建用户
pub async fn create_user(&self, user: &CreateUser) -> Result<User>;
/// 获取用户
pub async fn get_user(&self, id: &str) -> Result<Option<User>>;
/// 按用户名获取
pub async fn get_by_username(&self, username: &str) -> Result<Option<User>>;
/// 更新用户
pub async fn update_user(&self, id: &str, update: &UpdateUser) -> Result<()>;
/// 删除用户
pub async fn delete_user(&self, id: &str) -> Result<()>;
/// 列出用户
pub async fn list_users(&self) -> Result<Vec<User>>;
/// 验证密码
pub async fn verify_password(&self, username: &str, password: &str) -> Result<Option<User>>;
/// 更新密码
pub async fn update_password(&self, id: &str, new_password: &str) -> Result<()>;
/// 检查是否需要初始化
pub async fn needs_setup(&self) -> Result<bool>;
}
pub struct User {
pub id: String,
pub username: String,
pub role: UserRole,
pub created_at: DateTime<Utc>,
}
pub enum UserRole {
Admin,
User,
}
pub struct CreateUser {
pub username: String,
pub password: String,
pub role: UserRole,
}
```
### 2.2 SessionStore (session.rs)
```rust
pub struct SessionStore {
/// 会话映射
sessions: RwLock<HashMap<String, Session>>,
/// 会话超时
timeout: Duration,
}
impl SessionStore {
/// 创建存储
pub fn new(timeout: Duration) -> Self;
/// 创建会话
pub fn create_session(&self, user: &User) -> String;
/// 获取会话
pub fn get_session(&self, token: &str) -> Option<Session>;
/// 删除会话
pub fn delete_session(&self, token: &str);
/// 清理过期会话
pub fn cleanup_expired(&self);
/// 刷新会话
pub fn refresh_session(&self, token: &str) -> bool;
}
pub struct Session {
pub token: String,
pub user_id: String,
pub username: String,
pub role: UserRole,
pub created_at: Instant,
pub last_active: Instant,
}
```
### 2.3 密码哈希 (password.rs)
```rust
/// 哈希密码
pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)?
.to_string();
Ok(hash)
}
/// 验证密码
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(hash)?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
```
### 2.4 认证中间件 (middleware.rs)
```rust
pub async fn auth_middleware(
State(state): State<Arc<AppState>>,
cookies: Cookies,
request: Request,
next: Next,
) -> Response {
// 获取 session token
let token = cookies
.get("session_id")
.map(|c| c.value().to_string());
// 验证会话
let session = token
.and_then(|t| state.sessions.get_session(&t));
if let Some(session) = session {
// 将用户信息注入请求
let mut request = request;
request.extensions_mut().insert(session);
next.run(request).await
} else {
StatusCode::UNAUTHORIZED.into_response()
}
}
pub async fn admin_middleware(
session: Extension<Session>,
request: Request,
next: Next,
) -> Response {
if session.role == UserRole::Admin {
next.run(request).await
} else {
StatusCode::FORBIDDEN.into_response()
}
}
```
---
## 3. API 端点
| 端点 | 方法 | 权限 | 描述 |
|------|------|------|------|
| `/api/auth/login` | POST | Public | 登录 |
| `/api/auth/logout` | POST | User | 登出 |
| `/api/auth/check` | GET | User | 检查认证 |
| `/api/auth/password` | POST | User | 修改密码 |
| `/api/users` | GET | Admin | 列出用户 |
| `/api/users` | POST | Admin | 创建用户 |
| `/api/users/:id` | DELETE | Admin | 删除用户 |
| `/api/setup/init` | POST | Public | 初始化设置 |
### 请求/响应格式
```json
// POST /api/auth/login
// Request:
{
"username": "admin",
"password": "password123"
}
// Response:
{
"user": {
"id": "uuid",
"username": "admin",
"role": "admin"
}
}
// GET /api/auth/check
{
"authenticated": true,
"user": {
"id": "uuid",
"username": "admin",
"role": "admin"
}
}
```
---
## 4. 配置
```rust
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct AuthConfig {
/// 会话超时 (秒)
pub session_timeout_secs: u64,
/// 是否启用认证
pub enabled: bool,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
session_timeout_secs: 86400, // 24 小时
enabled: true,
}
}
}
```
---
## 5. 安全特性
### 5.1 密码存储
- Argon2id 哈希
- 随机盐值
- 不可逆
### 5.2 会话安全
- 随机 token (UUID v4)
- HTTPOnly Cookie
- 会话超时
- 自动清理
### 5.3 权限控制
- 两级权限: Admin / User
- 中间件检查
- 敏感操作需 Admin
---
## 6. 使用示例
```rust
// 创建用户
let user = users.create_user(&CreateUser {
username: "admin".to_string(),
password: "password123".to_string(),
role: UserRole::Admin,
}).await?;
// 验证密码
if let Some(user) = users.verify_password("admin", "password123").await? {
// 创建会话
let token = sessions.create_session(&user);
// 设置 Cookie
cookies.add(Cookie::build("session_id", token)
.http_only(true)
.path("/")
.finish());
}
// 获取会话
if let Some(session) = sessions.get_session(&token) {
println!("User: {}", session.username);
}
```
---
## 7. 错误处理
```rust
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("Invalid credentials")]
InvalidCredentials,
#[error("User not found")]
UserNotFound,
#[error("User already exists")]
UserExists,
#[error("Session expired")]
SessionExpired,
#[error("Permission denied")]
PermissionDenied,
#[error("Setup required")]
SetupRequired,
}
```

View File

@@ -1,755 +0,0 @@
# Config 模块文档
## 1. 模块概述
Config 模块提供配置管理功能,所有配置存储在 SQLite 数据库中,使用 ArcSwap 实现无锁读取,提供高性能配置访问。
### 1.1 主要功能
- SQLite 配置存储(持久化)
- 无锁配置读取ArcSwap
- 类型安全的配置结构
- 配置变更通知broadcast channel
- TypeScript 类型生成typeshare
- RESTful API按功能域分离
### 1.2 文件结构
```
src/config/
├── mod.rs # 模块导出
├── schema.rs # 配置结构定义(包含所有子配置)
└── store.rs # SQLite 存储与无锁缓存
```
---
## 2. 核心组件
### 2.1 ConfigStore (store.rs)
配置存储使用 **ArcSwap** 实现无锁读取,提供接近零成本的配置访问性能:
```rust
pub struct ConfigStore {
pool: Pool<Sqlite>,
/// 无锁缓存,使用 ArcSwap 实现零成本读取
cache: Arc<ArcSwap<AppConfig>>,
/// 配置变更通知通道
change_tx: broadcast::Sender<ConfigChange>,
}
impl ConfigStore {
/// 创建存储
pub async fn new(db_path: &Path) -> Result<Self>;
/// 获取当前配置(无锁,零拷贝)
///
/// 返回 Arc<AppConfig>,高效共享无需克隆
/// 这是一个无锁操作,开销极小
pub fn get(&self) -> Arc<AppConfig>;
/// 设置完整配置
pub async fn set(&self, config: AppConfig) -> Result<()>;
/// 使用闭包更新配置
///
/// 读-修改-写模式。并发更新时,最后的写入获胜。
/// 对于不频繁的用户触发配置更改来说是可接受的。
pub async fn update<F>(&self, f: F) -> Result<()>
where
F: FnOnce(&mut AppConfig);
/// 订阅配置变更事件
pub fn subscribe(&self) -> broadcast::Receiver<ConfigChange>;
/// 检查系统是否已初始化(无锁)
pub fn is_initialized(&self) -> bool;
/// 获取数据库连接池(用于会话管理)
pub fn pool(&self) -> &Pool<Sqlite>;
}
```
**性能特点**
- `get()` 是无锁读取操作,返回 `Arc<AppConfig>`,无需克隆
- 配置读取频率远高于写入ArcSwap 优化了读取路径
- 写入操作先持久化到数据库,再原子性更新内存缓存
- 使用 broadcast channel 通知配置变更,支持多订阅者
**数据库连接池配置**
```rust
SqlitePoolOptions::new()
.max_connections(2) // SQLite 单写模式2 个连接足够
.acquire_timeout(Duration::from_secs(5))
.idle_timeout(Duration::from_secs(300))
```
### 2.2 AppConfig (schema.rs)
主应用配置结构,包含所有子系统的配置:
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[typeshare]
pub struct AppConfig {
/// 初始设置是否完成
pub initialized: bool,
/// 认证配置
pub auth: AuthConfig,
/// 视频采集配置
pub video: VideoConfig,
/// HID键盘/鼠标)配置
pub hid: HidConfig,
/// MSD大容量存储配置
pub msd: MsdConfig,
/// ATX 电源控制配置
pub atx: AtxConfig,
/// 音频配置
pub audio: AudioConfig,
/// 流媒体配置
pub stream: StreamConfig,
/// Web 服务器配置
pub web: WebConfig,
/// 扩展配置ttyd, gostc, easytier
pub extensions: ExtensionsConfig,
/// RustDesk 远程访问配置
pub rustdesk: RustDeskConfig,
}
```
### 2.3 主要子配置结构
#### AuthConfig - 认证配置
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[typeshare]
pub struct AuthConfig {
/// 会话超时时间(秒)
pub session_timeout_secs: u32, // 默认 8640024小时
/// 启用双因素认证
pub totp_enabled: bool,
/// TOTP 密钥(加密存储)
pub totp_secret: Option<String>,
}
```
#### VideoConfig - 视频采集配置
```rust
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
#[typeshare]
pub struct VideoConfig {
/// 视频设备路径(如 /dev/video0
pub device: Option<String>,
/// 像素格式(如 "MJPEG", "YUYV", "NV12"
pub format: Option<String>,
/// 分辨率宽度
pub width: u32, // 默认 1920
/// 分辨率高度
pub height: u32, // 默认 1080
/// 帧率
pub fps: u32, // 默认 30
/// JPEG 质量1-100
pub quality: u32, // 默认 80
}
```
#### HidConfig - HID 配置
```rust
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
#[typeshare]
pub struct HidConfig {
/// HID 后端类型
pub backend: HidBackend, // Otg | Ch9329 | None
/// OTG 键盘设备路径
pub otg_keyboard: String, // 默认 "/dev/hidg0"
/// OTG 鼠标设备路径
pub otg_mouse: String, // 默认 "/dev/hidg1"
/// OTG UDCUSB 设备控制器)名称
pub otg_udc: Option<String>,
/// OTG USB 设备描述符配置
pub otg_descriptor: OtgDescriptorConfig,
/// CH9329 串口路径
pub ch9329_port: String, // 默认 "/dev/ttyUSB0"
/// CH9329 波特率
pub ch9329_baudrate: u32, // 默认 9600
/// 鼠标模式:绝对定位或相对定位
pub mouse_absolute: bool, // 默认 true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[typeshare]
pub struct OtgDescriptorConfig {
pub vendor_id: u16, // 默认 0x1d6bLinux Foundation
pub product_id: u16, // 默认 0x0104
pub manufacturer: String, // 默认 "One-KVM"
pub product: String, // 默认 "One-KVM USB Device"
pub serial_number: Option<String>,
}
```
#### StreamConfig - 流媒体配置
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[typeshare]
pub struct StreamConfig {
/// 流模式WebRTC | Mjpeg
pub mode: StreamMode,
/// 编码器类型
pub encoder: EncoderType, // Auto | Software | Vaapi | Nvenc | Qsv | Amf | Rkmpp | V4l2m2m
/// 码率预设Speed | Balanced | Quality
pub bitrate_preset: BitratePreset,
/// 自定义 STUN 服务器
pub stun_server: Option<String>, // 默认 "stun:stun.l.google.com:19302"
/// 自定义 TURN 服务器
pub turn_server: Option<String>,
/// TURN 用户名
pub turn_username: Option<String>,
/// TURN 密码(加密存储,不通过 API 暴露)
pub turn_password: Option<String>,
/// 无客户端时自动暂停
#[typeshare(skip)]
pub auto_pause_enabled: bool,
/// 自动暂停延迟(秒)
#[typeshare(skip)]
pub auto_pause_delay_secs: u64,
/// 客户端超时清理(秒)
#[typeshare(skip)]
pub client_timeout_secs: u64,
}
```
#### MsdConfig - 大容量存储配置
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[typeshare]
pub struct MsdConfig {
/// 启用 MSD 功能
pub enabled: bool, // 默认 true
/// ISO/IMG 镜像存储路径
pub images_path: String, // 默认 "./data/msd/images"
/// Ventoy 启动驱动器文件路径
pub drive_path: String, // 默认 "./data/msd/ventoy.img"
/// 虚拟驱动器大小MB最小 1024
pub virtual_drive_size_mb: u32, // 默认 1638416GB
}
```
#### AtxConfig - ATX 电源控制配置
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[typeshare]
pub struct AtxConfig {
/// 启用 ATX 功能
pub enabled: bool,
/// 电源按钮配置(短按和长按共用)
pub power: AtxKeyConfig,
/// 重置按钮配置
pub reset: AtxKeyConfig,
/// LED 检测配置(可选)
pub led: AtxLedConfig,
/// WOL 数据包使用的网络接口(空字符串 = 自动)
pub wol_interface: String,
}
```
#### AudioConfig - 音频配置
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[typeshare]
pub struct AudioConfig {
/// 启用音频采集
pub enabled: bool, // 默认 false
/// ALSA 设备名称
pub device: String, // 默认 "default"
/// 音频质量预设:"voice" | "balanced" | "high"
pub quality: String, // 默认 "balanced"
}
```
**注意**:采样率固定为 48000Hz声道固定为 2立体声这是 Opus 编码和 WebRTC 的最佳配置。
#### WebConfig - Web 服务器配置
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[typeshare]
pub struct WebConfig {
/// HTTP 端口
pub http_port: u16, // 默认 8080
/// HTTPS 端口
pub https_port: u16, // 默认 8443
/// 绑定地址
pub bind_address: String, // 默认 "0.0.0.0"
/// 启用 HTTPS
pub https_enabled: bool, // 默认 false
/// 自定义 SSL 证书路径
pub ssl_cert_path: Option<String>,
/// 自定义 SSL 密钥路径
pub ssl_key_path: Option<String>,
}
```
---
## 3. TypeScript 类型生成
使用 `#[typeshare]` 属性自动生成 TypeScript 类型:
```rust
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct VideoConfig {
pub device: Option<String>,
pub width: u32,
pub height: u32,
}
```
生成的 TypeScript
```typescript
export interface VideoConfig {
device?: string;
width: number;
height: number;
}
```
生成命令:
```bash
./scripts/generate-types.sh
# 或
typeshare src --lang=typescript --output-file=web/src/types/generated.ts
```
---
## 4. API 端点
所有配置端点均需要 **Admin** 权限,采用 RESTful 设计,按功能域分离。
### 4.1 全局配置
| 端点 | 方法 | 权限 | 描述 |
|------|------|------|------|
| `/api/config` | GET | Admin | 获取完整配置(敏感信息已过滤) |
| `/api/config` | POST | Admin | 更新完整配置(已废弃,请使用按域 PATCH |
### 4.2 分域配置端点
| 端点 | 方法 | 权限 | 描述 |
|------|------|------|------|
| `/api/config/video` | GET | Admin | 获取视频配置 |
| `/api/config/video` | PATCH | Admin | 更新视频配置(部分更新) |
| `/api/config/stream` | GET | Admin | 获取流配置 |
| `/api/config/stream` | PATCH | Admin | 更新流配置(部分更新) |
| `/api/config/hid` | GET | Admin | 获取 HID 配置 |
| `/api/config/hid` | PATCH | Admin | 更新 HID 配置(部分更新) |
| `/api/config/msd` | GET | Admin | 获取 MSD 配置 |
| `/api/config/msd` | PATCH | Admin | 更新 MSD 配置(部分更新) |
| `/api/config/atx` | GET | Admin | 获取 ATX 配置 |
| `/api/config/atx` | PATCH | Admin | 更新 ATX 配置(部分更新) |
| `/api/config/audio` | GET | Admin | 获取音频配置 |
| `/api/config/audio` | PATCH | Admin | 更新音频配置(部分更新) |
| `/api/config/web` | GET | Admin | 获取 Web 服务器配置 |
| `/api/config/web` | PATCH | Admin | 更新 Web 服务器配置(部分更新) |
### 4.3 RustDesk 配置端点
| 端点 | 方法 | 权限 | 描述 |
|------|------|------|------|
| `/api/config/rustdesk` | GET | Admin | 获取 RustDesk 配置 |
| `/api/config/rustdesk` | PATCH | Admin | 更新 RustDesk 配置 |
| `/api/config/rustdesk/status` | GET | Admin | 获取 RustDesk 服务状态 |
| `/api/config/rustdesk/password` | GET | Admin | 获取设备密码 |
| `/api/config/rustdesk/regenerate-id` | POST | Admin | 重新生成设备 ID |
| `/api/config/rustdesk/regenerate-password` | POST | Admin | 重新生成设备密码 |
### 4.4 请求/响应示例
#### 获取视频配置
```bash
GET /api/config/video
```
响应:
```json
{
"device": "/dev/video0",
"format": "MJPEG",
"width": 1920,
"height": 1080,
"fps": 30,
"quality": 80
}
```
#### 部分更新视频配置
```bash
PATCH /api/config/video
Content-Type: application/json
{
"width": 1280,
"height": 720,
"fps": 60
}
```
响应:更新后的完整 VideoConfig
```json
{
"device": "/dev/video0",
"format": "MJPEG",
"width": 1280,
"height": 720,
"fps": 60,
"quality": 80
}
```
**注意**
- 所有 PATCH 请求都支持部分更新,只需要提供要修改的字段
- 未提供的字段保持原有值不变
- 更新后返回完整的配置对象
- 配置变更会自动触发相关组件重载
---
## 5. 配置变更通知
ConfigStore 提供 broadcast channel 用于配置变更通知:
```rust
/// 配置变更事件
#[derive(Debug, Clone)]
pub struct ConfigChange {
pub key: String,
}
// 订阅配置变更
let mut rx = config_store.subscribe();
// 监听变更事件
while let Ok(change) = rx.recv().await {
println!("配置 {} 已更新", change.key);
// 重载相关组件
}
```
**工作流程**
1. 调用 `config_store.set()``config_store.update()`
2. 配置写入数据库(持久化)
3. 原子性更新内存缓存ArcSwap
4. 发送 `ConfigChange` 事件到 broadcast channel
5. 各组件的订阅者接收事件并执行重载逻辑
**组件重载示例**
```rust
// VideoStreamManager 监听配置变更
let mut config_rx = config_store.subscribe();
tokio::spawn(async move {
while let Ok(change) = config_rx.recv().await {
if change.key == "app_config" {
video_manager.reload().await;
}
}
});
```
---
## 6. 数据库结构
ConfigStore 使用 SQLite 存储配置和其他系统数据:
### 6.1 配置表
```sql
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
配置以 JSON 格式存储:
```sql
-- 应用配置
key: 'app_config'
value: '{"initialized": true, "video": {...}, "hid": {...}, ...}'
```
### 6.2 用户表
```sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
### 6.3 会话表
```sql
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
data TEXT
);
```
### 6.4 API 令牌表
```sql
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
permissions TEXT NOT NULL,
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used TEXT
);
```
**存储特点**
- 所有配置存储在单个 JSON 文本中(`app_config` key
- 每次配置更新都更新整个 JSON简化事务处理
- 使用 `ON CONFLICT` 实现 upsert 操作
- 连接池大小为 21 读 + 1 写),适合嵌入式环境
---
## 7. 使用示例
### 7.1 基本用法
```rust
use crate::config::ConfigStore;
use std::path::Path;
// 创建配置存储
let config_store = ConfigStore::new(Path::new("./data/config.db")).await?;
// 获取配置(无锁,零拷贝)
let config = config_store.get();
println!("视频设备: {:?}", config.video.device);
println!("是否已初始化: {}", config.initialized);
// 检查是否已初始化
if !config_store.is_initialized() {
println!("系统尚未初始化,请完成初始设置");
}
```
### 7.2 更新配置
```rust
// 方式 1: 使用闭包更新(推荐)
config_store.update(|config| {
config.video.width = 1280;
config.video.height = 720;
config.video.fps = 60;
}).await?;
// 方式 2: 整体替换
let mut new_config = (*config_store.get()).clone();
new_config.stream.mode = StreamMode::WebRTC;
new_config.stream.encoder = EncoderType::Rkmpp;
config_store.set(new_config).await?;
```
### 7.3 订阅配置变更
```rust
// 在组件中监听配置变更
let config_store = state.config.clone();
let mut rx = config_store.subscribe();
tokio::spawn(async move {
while let Ok(change) = rx.recv().await {
tracing::info!("配置 {} 已变更", change.key);
// 重新加载配置
let config = config_store.get();
// 执行重载逻辑
if let Err(e) = reload_component(&config).await {
tracing::error!("重载组件失败: {}", e);
}
}
});
```
### 7.4 在 Handler 中使用
```rust
use axum::{extract::State, Json};
use std::sync::Arc;
use crate::config::VideoConfig;
use crate::state::AppState;
// 获取视频配置
pub async fn get_video_config(
State(state): State<Arc<AppState>>
) -> Json<VideoConfig> {
let config = state.config.get();
Json(config.video.clone())
}
// 更新视频配置
pub async fn update_video_config(
State(state): State<Arc<AppState>>,
Json(update): Json<VideoConfig>,
) -> Result<Json<VideoConfig>> {
// 更新配置
state.config.update(|config| {
config.video = update;
}).await?;
// 返回更新后的配置
let config = state.config.get();
Ok(Json(config.video.clone()))
}
```
### 7.5 访问数据库连接池
```rust
// ConfigStore 还提供数据库连接池访问
// 用于用户管理、会话管理等功能
let pool = config_store.pool();
// 查询用户
let user: Option<User> = sqlx::query_as(
"SELECT * FROM users WHERE username = ?"
)
.bind(username)
.fetch_optional(pool)
.await?;
```
---
## 8. 默认配置
系统首次运行时会自动创建默认配置:
```rust
impl Default for AppConfig {
fn default() -> Self {
Self {
initialized: false, // 需要通过初始设置向导完成
auth: AuthConfig {
session_timeout_secs: 86400, // 24小时
totp_enabled: false,
totp_secret: None,
},
video: VideoConfig {
device: None, // 自动检测
format: None, // 自动检测或使用 MJPEG
width: 1920,
height: 1080,
fps: 30,
quality: 80,
},
stream: StreamConfig {
mode: StreamMode::Mjpeg,
encoder: EncoderType::Auto,
bitrate_preset: BitratePreset::Balanced,
stun_server: Some("stun:stun.l.google.com:19302".to_string()),
turn_server: None,
turn_username: None,
turn_password: None,
auto_pause_enabled: false,
auto_pause_delay_secs: 10,
client_timeout_secs: 30,
},
hid: HidConfig {
backend: HidBackend::None, // 需要用户手动启用
otg_keyboard: "/dev/hidg0".to_string(),
otg_mouse: "/dev/hidg1".to_string(),
otg_udc: None, // 自动检测
otg_descriptor: OtgDescriptorConfig::default(),
ch9329_port: "/dev/ttyUSB0".to_string(),
ch9329_baudrate: 9600,
mouse_absolute: true,
},
msd: MsdConfig {
enabled: true,
images_path: "./data/msd/images".to_string(),
drive_path: "./data/msd/ventoy.img".to_string(),
virtual_drive_size_mb: 16384, // 16GB
},
atx: AtxConfig {
enabled: false, // 需要用户配置硬件绑定
power: AtxKeyConfig::default(),
reset: AtxKeyConfig::default(),
led: AtxLedConfig::default(),
wol_interface: String::new(), // 自动检测
},
audio: AudioConfig {
enabled: false,
device: "default".to_string(),
quality: "balanced".to_string(),
},
web: WebConfig {
http_port: 8080,
https_port: 8443,
bind_address: "0.0.0.0".to_string(),
https_enabled: false,
ssl_cert_path: None,
ssl_key_path: None,
},
extensions: ExtensionsConfig::default(),
rustdesk: RustDeskConfig::default(),
}
}
}
```
**配置初始化流程**
1. 用户首次访问 Web UI系统检测到 `initialized = false`
2. 重定向到初始设置向导(`/setup`
3. 用户设置管理员账户、选择视频设备等
4. 完成设置后,`initialized` 设为 `true`
5. 后续可通过设置页面(`/settings`)修改各项配置

View File

@@ -1,353 +0,0 @@
# Events 模块文档
## 1. 模块概述
Events 模块提供事件总线功能,用于模块间通信和状态广播。
### 1.1 主要功能
- 事件发布/订阅
- 多订阅者广播
- WebSocket 事件推送
- 状态变更通知
### 1.2 文件结构
```
src/events/
└── mod.rs # EventBus 实现
```
---
## 2. 架构设计
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Event System │
└─────────────────────────────────────────────────────────────────────────────┘
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ Video │ │ HID │ │ Audio │
│ Module │ │ Module │ │ Module │
└───────┬────────┘ └───────┬────────┘ └───────┬────────┘
│ │ │
│ publish() │ publish() │ publish()
└──────────────────┼──────────────────┘
┌─────────────────────┐
│ EventBus │
│ (broadcast channel) │
└──────────┬──────────┘
┌─────────────────┼─────────────────┐
│ │ │
│ subscribe() │ subscribe() │
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ WebSocket │ │ DeviceInfo │ │ Internal │
│ Handler │ │ Broadcaster │ │ Tasks │
└────────────────┘ └────────────────┘ └────────────────┘
```
---
## 3. 核心组件
### 3.1 EventBus
```rust
pub struct EventBus {
/// 广播发送器
tx: broadcast::Sender<SystemEvent>,
}
impl EventBus {
/// 创建事件总线
pub fn new() -> Self {
let (tx, _) = broadcast::channel(1024);
Self { tx }
}
/// 发布事件
pub fn publish(&self, event: SystemEvent) {
let _ = self.tx.send(event);
}
/// 订阅事件
pub fn subscribe(&self) -> broadcast::Receiver<SystemEvent> {
self.tx.subscribe()
}
}
```
### 3.2 SystemEvent
```rust
#[derive(Clone, Debug, Serialize)]
pub enum SystemEvent {
// 视频事件
StreamStateChanged {
state: String,
device: Option<String>,
resolution: Option<Resolution>,
fps: Option<f32>,
},
VideoDeviceChanged {
added: Vec<String>,
removed: Vec<String>,
},
// HID 事件
HidStateChanged {
backend: String,
initialized: bool,
keyboard_connected: bool,
mouse_connected: bool,
mouse_mode: String,
error: Option<String>,
},
// MSD 事件
MsdStateChanged {
mode: String,
connected: bool,
image: Option<String>,
error: Option<String>,
},
MsdDownloadProgress {
download_id: String,
downloaded: u64,
total: u64,
speed: u64,
},
// ATX 事件
AtxStateChanged {
power_on: bool,
last_action: Option<String>,
error: Option<String>,
},
// 音频事件
AudioStateChanged {
enabled: bool,
streaming: bool,
device: Option<String>,
error: Option<String>,
},
// 配置事件
ConfigChanged {
section: String,
},
// 设备信息汇总
DeviceInfo {
video: VideoInfo,
hid: HidInfo,
msd: MsdInfo,
atx: AtxInfo,
audio: AudioInfo,
},
// 系统错误
SystemError {
module: String,
severity: String,
message: String,
},
// RustDesk 事件
RustDeskStatusChanged {
status: String,
device_id: Option<String>,
error: Option<String>,
},
RustDeskConnectionOpened {
connection_id: String,
peer_id: String,
},
RustDeskConnectionClosed {
connection_id: String,
peer_id: String,
reason: String,
},
}
```
---
## 4. 设备信息广播器
`main.rs` 中启动的后台任务:
```rust
pub fn spawn_device_info_broadcaster(
state: Arc<AppState>,
events: Arc<EventBus>,
) -> JoinHandle<()> {
tokio::spawn(async move {
let mut rx = events.subscribe();
let mut debounce = tokio::time::interval(Duration::from_millis(100));
let mut pending = false;
loop {
tokio::select! {
// 收到事件
result = rx.recv() => {
if result.is_ok() {
pending = true;
}
}
// 防抖定时器
_ = debounce.tick() => {
if pending {
pending = false;
// 收集设备信息
let device_info = state.get_device_info().await;
// 广播
events.publish(SystemEvent::DeviceInfo(device_info));
}
}
}
}
})
}
```
---
## 5. WebSocket 事件推送
```rust
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_ws(socket, state))
}
async fn handle_ws(mut socket: WebSocket, state: Arc<AppState>) {
let mut rx = state.events.subscribe();
loop {
tokio::select! {
// 发送事件给客户端
result = rx.recv() => {
if let Ok(event) = result {
let json = serde_json::to_string(&event).unwrap();
if socket.send(Message::Text(json)).await.is_err() {
break;
}
}
}
// 接收客户端消息
msg = socket.recv() => {
match msg {
Some(Ok(Message::Close(_))) | None => break,
_ => {}
}
}
}
}
}
```
---
## 6. 使用示例
### 6.1 发布事件
```rust
// 视频模块发布状态变更
events.publish(SystemEvent::StreamStateChanged {
state: "streaming".to_string(),
device: Some("/dev/video0".to_string()),
resolution: Some(Resolution { width: 1920, height: 1080 }),
fps: Some(30.0),
});
// HID 模块发布状态变更
events.publish(SystemEvent::HidStateChanged {
backend: "otg".to_string(),
initialized: true,
keyboard_connected: true,
mouse_connected: true,
mouse_mode: "absolute".to_string(),
error: None,
});
```
### 6.2 订阅事件
```rust
let mut rx = events.subscribe();
loop {
match rx.recv().await {
Ok(SystemEvent::StreamStateChanged { state, .. }) => {
println!("Stream state: {}", state);
}
Ok(SystemEvent::HidStateChanged { backend, .. }) => {
println!("HID backend: {}", backend);
}
Err(_) => break,
}
}
```
---
## 7. 前端事件处理
```typescript
// 连接 WebSocket
const ws = new WebSocket('/api/ws');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'StreamStateChanged':
updateStreamStatus(data);
break;
case 'HidStateChanged':
updateHidStatus(data);
break;
case 'MsdStateChanged':
updateMsdStatus(data);
break;
case 'DeviceInfo':
updateAllDevices(data);
break;
}
};
```
---
## 8. 最佳实践
### 8.1 事件粒度
- 使用细粒度事件便于精确更新
- DeviceInfo 用于初始化和定期同步
### 8.2 防抖
- 使用 100ms 防抖避免事件风暴
- 合并多个快速变更
### 8.3 错误处理
- 发布失败静默忽略 (fire-and-forget)
- 订阅者断开自动清理

File diff suppressed because it is too large Load Diff

View File

@@ -1,617 +0,0 @@
# MSD 模块文档
## 1. 模块概述
MSD (Mass Storage Device) 模块提供虚拟存储设备功能,允许将 ISO/IMG 镜像作为 USB 存储设备挂载到目标计算机。
### 1.1 主要功能
- ISO/IMG 镜像挂载
- 镜像下载管理
- Ventoy 多 ISO 启动盘
- 热插拔支持
- 下载进度追踪
### 1.2 文件结构
```
src/msd/
├── mod.rs # 模块导出
├── controller.rs # MsdController (20KB)
├── image.rs # 镜像管理 (21KB)
├── ventoy_drive.rs # Ventoy 驱动 (24KB)
├── monitor.rs # 健康监视 (9KB)
└── types.rs # 类型定义 (6KB)
```
---
## 2. 架构设计
### 2.1 整体架构
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ MSD Architecture │
└─────────────────────────────────────────────────────────────────────────────┘
Web API
┌─────────────────┐
│ MsdController │
│ (controller.rs) │
└────────┬────────┘
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌───────────┐ ┌───────────┐
│ Image │ │ Ventoy │ │ OTG │
│ Manager │ │ Drive │ │ Service │
│ (image.rs) │ │(ventoy.rs)│ │ │
└──────┬──────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌───────────┐ ┌───────────┐
│ /data/ │ │ exFAT │ │ MSD │
│ images/ │ │ Drive │ │ Function │
└─────────────┘ └───────────┘ └───────────┘
┌───────────────┐
│ Target PC │
│ (USB Drive) │
└───────────────┘
```
### 2.2 MSD 模式
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ MSD Modes │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Image Mode │
│ ┌───────────┐ │
│ │ ISO/IMG │ ──► MSD LUN ──► Target PC sees single drive │
│ │ File │ │
│ └───────────┘ │
│ 特点: │
│ - 单个镜像文件 │
│ - 直接挂载 │
│ - 适合系统安装 │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Ventoy Mode │
│ ┌───────────┐ │
│ │ ISO 1 │ │
│ ├───────────┤ ┌───────────┐ │
│ │ ISO 2 │ ──► │ Ventoy │ ──► Target PC sees bootable drive │
│ ├───────────┤ │ Drive │ with ISO selection menu │
│ │ ISO 3 │ └───────────┘ │
│ └───────────┘ │
│ 特点: │
│ - 多个 ISO 文件 │
│ - exFAT 文件系统 │
│ - 启动菜单选择 │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## 3. 核心组件
### 3.1 MsdController (controller.rs)
MSD 控制器主类。
```rust
pub struct MsdController {
/// 当前状态
state: Arc<RwLock<MsdState>>,
/// 镜像管理器
image_manager: Arc<ImageManager>,
/// Ventoy 驱动器
ventoy_drive: Arc<RwLock<Option<VentoyDrive>>>,
/// OTG 服务
otg_service: Arc<OtgService>,
/// MSD 函数句柄
msd_function: Arc<RwLock<Option<MsdFunction>>>,
/// 事件总线
events: Arc<EventBus>,
/// 数据目录
data_dir: PathBuf,
}
impl MsdController {
/// 创建控制器
pub async fn new(
otg_service: Arc<OtgService>,
data_dir: PathBuf,
events: Arc<EventBus>,
) -> Result<Arc<Self>>;
/// 获取状态
pub fn state(&self) -> MsdState;
/// 连接 MSD
pub async fn connect(&self) -> Result<()>;
/// 断开 MSD
pub async fn disconnect(&self) -> Result<()>;
/// 切换到镜像模式
pub async fn set_image(&self, image_id: &str) -> Result<()>;
/// 切换到 Ventoy 模式
pub async fn set_ventoy(&self) -> Result<()>;
/// 清除当前挂载
pub async fn clear(&self) -> Result<()>;
/// 列出镜像
pub fn list_images(&self) -> Vec<ImageInfo>;
/// 上传镜像
pub async fn upload_image(&self, name: &str, data: Bytes) -> Result<ImageInfo>;
/// 从 URL 下载镜像
pub async fn download_image(&self, url: &str) -> Result<String>;
/// 删除镜像
pub async fn delete_image(&self, image_id: &str) -> Result<()>;
/// 获取下载进度
pub fn get_download_progress(&self, download_id: &str) -> Option<DownloadProgress>;
}
pub struct MsdState {
/// 是否可用
pub available: bool,
/// 当前模式
pub mode: MsdMode,
/// 是否已连接
pub connected: bool,
/// 当前镜像信息
pub current_image: Option<ImageInfo>,
/// 驱动器信息
pub drive_info: Option<DriveInfo>,
/// 错误信息
pub error: Option<String>,
}
pub enum MsdMode {
/// 未激活
None,
/// 单镜像模式
Image,
/// Ventoy 模式
Drive,
}
```
### 3.2 ImageManager (image.rs)
镜像文件管理器。
```rust
pub struct ImageManager {
/// 镜像目录
images_dir: PathBuf,
/// 镜像列表缓存
images: RwLock<HashMap<String, ImageInfo>>,
/// 下载任务
downloads: RwLock<HashMap<String, DownloadTask>>,
/// HTTP 客户端
http_client: reqwest::Client,
}
impl ImageManager {
/// 创建管理器
pub fn new(images_dir: PathBuf) -> Result<Self>;
/// 扫描镜像目录
pub fn scan_images(&self) -> Result<Vec<ImageInfo>>;
/// 获取镜像信息
pub fn get_image(&self, id: &str) -> Option<ImageInfo>;
/// 添加镜像
pub async fn add_image(&self, name: &str, data: Bytes) -> Result<ImageInfo>;
/// 删除镜像
pub fn delete_image(&self, id: &str) -> Result<()>;
/// 开始下载
pub async fn start_download(&self, url: &str) -> Result<String>;
/// 取消下载
pub fn cancel_download(&self, download_id: &str) -> Result<()>;
/// 获取下载进度
pub fn get_download_progress(&self, download_id: &str) -> Option<DownloadProgress>;
/// 验证镜像文件
fn validate_image(path: &Path) -> Result<ImageFormat>;
}
pub struct ImageInfo {
/// 唯一 ID
pub id: String,
/// 文件名
pub name: String,
/// 文件大小
pub size: u64,
/// 格式
pub format: ImageFormat,
/// 创建时间
pub created_at: DateTime<Utc>,
/// 下载状态
pub download_status: Option<DownloadStatus>,
}
pub enum ImageFormat {
/// ISO 光盘镜像
Iso,
/// 原始磁盘镜像
Img,
/// 未知格式
Unknown,
}
pub struct DownloadProgress {
/// 已下载字节
pub downloaded: u64,
/// 总字节数
pub total: u64,
/// 下载速度 (bytes/sec)
pub speed: u64,
/// 预计剩余时间
pub eta_secs: u64,
/// 状态
pub status: DownloadStatus,
}
pub enum DownloadStatus {
Pending,
Downloading,
Completed,
Failed(String),
Cancelled,
}
```
### 3.3 VentoyDrive (ventoy_drive.rs)
Ventoy 可启动驱动器管理。
```rust
pub struct VentoyDrive {
/// 驱动器路径
drive_path: PathBuf,
/// 镜像路径
images: Vec<PathBuf>,
/// 容量
capacity: u64,
/// 已用空间
used: u64,
}
impl VentoyDrive {
/// 创建 Ventoy 驱动器
pub fn create(drive_path: PathBuf, capacity: u64) -> Result<Self>;
/// 添加 ISO
pub fn add_iso(&mut self, iso_path: &Path) -> Result<()>;
/// 移除 ISO
pub fn remove_iso(&mut self, name: &str) -> Result<()>;
/// 列出 ISO
pub fn list_isos(&self) -> Vec<String>;
/// 获取驱动器信息
pub fn info(&self) -> DriveInfo;
/// 获取驱动器路径
pub fn path(&self) -> &Path;
}
pub struct DriveInfo {
/// 容量
pub capacity: u64,
/// 已用空间
pub used: u64,
/// 可用空间
pub available: u64,
/// ISO 列表
pub isos: Vec<String>,
}
```
---
## 4. 类型定义
### 4.1 MSD 配置
```rust
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct MsdConfig {
/// 是否启用 MSD
pub enabled: bool,
/// 镜像目录
pub images_dir: Option<String>,
/// 默认模式
pub default_mode: MsdMode,
/// Ventoy 容量 (MB)
pub ventoy_capacity_mb: u32,
}
impl Default for MsdConfig {
fn default() -> Self {
Self {
enabled: true,
images_dir: None,
default_mode: MsdMode::None,
ventoy_capacity_mb: 4096, // 4GB
}
}
}
```
---
## 5. API 端点
| 端点 | 方法 | 描述 |
|------|------|------|
| `/api/msd/status` | GET | 获取 MSD 状态 |
| `/api/msd/connect` | POST | 连接 MSD |
| `/api/msd/disconnect` | POST | 断开 MSD |
| `/api/msd/images` | GET | 列出镜像 |
| `/api/msd/images` | POST | 上传镜像 |
| `/api/msd/images/:id` | DELETE | 删除镜像 |
| `/api/msd/images/download` | POST | 从 URL 下载 |
| `/api/msd/images/download/:id` | GET | 获取下载进度 |
| `/api/msd/images/download/:id` | DELETE | 取消下载 |
| `/api/msd/set-image` | POST | 设置当前镜像 |
| `/api/msd/set-ventoy` | POST | 设置 Ventoy 模式 |
| `/api/msd/clear` | POST | 清除挂载 |
### 响应格式
```json
// GET /api/msd/status
{
"available": true,
"mode": "image",
"connected": true,
"current_image": {
"id": "abc123",
"name": "ubuntu-22.04.iso",
"size": 4700000000,
"format": "iso"
},
"drive_info": null,
"error": null
}
// GET /api/msd/images
{
"images": [
{
"id": "abc123",
"name": "ubuntu-22.04.iso",
"size": 4700000000,
"format": "iso",
"created_at": "2024-01-15T10:30:00Z"
}
]
}
// POST /api/msd/images/download
// Request: { "url": "https://example.com/image.iso" }
// Response: { "download_id": "xyz789" }
// GET /api/msd/images/download/xyz789
{
"downloaded": 1234567890,
"total": 4700000000,
"speed": 12345678,
"eta_secs": 280,
"status": "downloading"
}
```
---
## 6. 事件
```rust
pub enum SystemEvent {
MsdStateChanged {
mode: MsdMode,
connected: bool,
image: Option<String>,
error: Option<String>,
},
MsdDownloadProgress {
download_id: String,
progress: DownloadProgress,
},
MsdDownloadComplete {
download_id: String,
image_id: String,
success: bool,
error: Option<String>,
},
}
```
---
## 7. 错误处理
```rust
#[derive(Debug, thiserror::Error)]
pub enum MsdError {
#[error("MSD not available")]
NotAvailable,
#[error("Already connected")]
AlreadyConnected,
#[error("Not connected")]
NotConnected,
#[error("Image not found: {0}")]
ImageNotFound(String),
#[error("Invalid image format: {0}")]
InvalidFormat(String),
#[error("Download failed: {0}")]
DownloadFailed(String),
#[error("Storage full")]
StorageFull,
#[error("OTG error: {0}")]
OtgError(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
```
---
## 8. 使用示例
### 8.1 挂载 ISO 镜像
```rust
let msd = MsdController::new(otg_service, data_dir, events).await?;
// 列出镜像
let images = msd.list_images();
println!("Available images: {:?}", images);
// 设置镜像
msd.set_image("abc123").await?;
// 连接到目标 PC
msd.connect().await?;
// 目标 PC 现在可以看到 USB 驱动器...
// 断开连接
msd.disconnect().await?;
```
### 8.2 从 URL 下载
```rust
// 开始下载
let download_id = msd.download_image("https://example.com/ubuntu.iso").await?;
// 监控进度
loop {
if let Some(progress) = msd.get_download_progress(&download_id) {
println!("Progress: {}%", progress.downloaded * 100 / progress.total);
if matches!(progress.status, DownloadStatus::Completed) {
break;
}
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
```
### 8.3 使用 Ventoy 模式
```rust
// 切换到 Ventoy 模式
msd.set_ventoy().await?;
// 获取驱动器信息
let state = msd.state();
if let Some(drive_info) = state.drive_info {
println!("Capacity: {} MB", drive_info.capacity / 1024 / 1024);
println!("ISOs: {:?}", drive_info.isos);
}
// 连接
msd.connect().await?;
```
---
## 9. 常见问题
### Q: 镜像无法挂载?
1. 检查镜像文件完整性
2. 确认文件格式正确
3. 检查存储空间
### Q: 目标 PC 不识别?
1. 检查 USB 连接
2. 尝试重新连接
3. 查看目标 PC 的设备管理器
### Q: 下载速度慢?
1. 检查网络连接
2. 使用更近的镜像源
3. 检查磁盘 I/O
### Q: Ventoy 启动失败?
1. 检查目标 PC BIOS 设置
2. 尝试不同的启动模式
3. 确认 ISO 文件支持 Ventoy

View File

@@ -1,667 +0,0 @@
# OTG 模块文档
## 1. 模块概述
OTG (On-The-Go) 模块负责管理 Linux USB Gadget为 HID 和 MSD 功能提供统一的 USB 设备管理。
### 1.1 主要功能
- USB Gadget 生命周期管理
- HID 函数配置 (键盘、鼠标)
- MSD 函数配置 (虚拟存储)
- ConfigFS 操作
- UDC 绑定/解绑
### 1.2 文件结构
```
src/otg/
├── mod.rs # 模块导出
├── service.rs # OtgService (17KB)
├── manager.rs # OtgGadgetManager (12KB)
├── hid.rs # HID Function (7KB)
├── msd.rs # MSD Function (14KB)
├── configfs.rs # ConfigFS 操作 (4KB)
├── endpoint.rs # 端点分配 (2KB)
└── report_desc.rs # HID 报告描述符 (6KB)
```
---
## 2. 架构设计
### 2.1 设计目标
解决 HID 和 MSD 共享同一个 USB Gadget 的所有权问题:
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ OTG Ownership Model │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────┐
│ OtgService │ ◄── 唯一所有者
│ (service.rs) │
└────────┬────────┘
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
enable_hid() enable_msd() 状态查询
│ │
└──────┬──────┘
┌─────────────────┐
│OtgGadgetManager │
│ (manager.rs) │
└────────┬────────┘
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
┌───────┐ ┌───────┐ ┌───────┐
│ HID │ │ MSD │ │ UDC │
│ Func │ │ Func │ │ Bind │
└───────┘ └───────┘ └───────┘
```
### 2.2 ConfigFS 结构
```
/sys/kernel/config/usb_gadget/one-kvm/
├── idVendor # 0x05ac (Apple)
├── idProduct # 0x0001
├── bcdDevice # 0x0100
├── bcdUSB # 0x0200
├── bMaxPacketSize0 # 64
├── strings/
│ └── 0x409/ # English
│ ├── manufacturer # "One-KVM"
│ ├── product # "KVM Device"
│ └── serialnumber # UUID
├── configs/
│ └── c.1/
│ ├── MaxPower # 500
│ ├── strings/
│ │ └── 0x409/
│ │ └── configuration # "Config 1"
│ └── (function symlinks)
├── functions/
│ ├── hid.usb0/ # 键盘
│ │ ├── protocol # 1 (keyboard)
│ │ ├── subclass # 1 (boot)
│ │ ├── report_length # 8
│ │ └── report_desc # (binary)
│ │
│ ├── hid.usb1/ # 相对鼠标
│ │ ├── protocol # 2 (mouse)
│ │ ├── subclass # 1 (boot)
│ │ ├── report_length # 4
│ │ └── report_desc # (binary)
│ │
│ ├── hid.usb2/ # 绝对鼠标
│ │ ├── protocol # 2 (mouse)
│ │ ├── subclass # 0 (none)
│ │ ├── report_length # 6
│ │ └── report_desc # (binary)
│ │
│ └── mass_storage.usb0/ # 虚拟存储
│ ├── stall # 1
│ └── lun.0/
│ ├── cdrom # 1 (ISO mode)
│ ├── ro # 1 (read-only)
│ ├── removable # 1
│ ├── nofua # 1
│ └── file # /path/to/image.iso
└── UDC # UDC 设备名
```
---
## 3. 核心组件
### 3.1 OtgService (service.rs)
OTG 服务主类,提供统一的 USB Gadget 管理接口。
```rust
pub struct OtgService {
/// Gadget 管理器
manager: Arc<Mutex<OtgGadgetManager>>,
/// 当前状态
state: Arc<RwLock<OtgServiceState>>,
/// HID 函数句柄
hid_function: Arc<RwLock<Option<HidFunction>>>,
/// MSD 函数句柄
msd_function: Arc<RwLock<Option<MsdFunction>>>,
/// 请求计数器 (lock-free)
pending_requests: AtomicU8,
}
impl OtgService {
/// 创建服务
pub fn new() -> Result<Self>;
/// 启用 HID 功能
pub async fn enable_hid(&self) -> Result<HidDevicePaths>;
/// 禁用 HID 功能
pub async fn disable_hid(&self) -> Result<()>;
/// 启用 MSD 功能
pub async fn enable_msd(&self) -> Result<MsdFunction>;
/// 禁用 MSD 功能
pub async fn disable_msd(&self) -> Result<()>;
/// 获取状态
pub fn state(&self) -> OtgServiceState;
/// 检查 HID 是否启用
pub fn is_hid_enabled(&self) -> bool;
/// 检查 MSD 是否启用
pub fn is_msd_enabled(&self) -> bool;
}
pub struct OtgServiceState {
/// Gadget 是否激活
pub gadget_active: bool,
/// HID 是否启用
pub hid_enabled: bool,
/// MSD 是否启用
pub msd_enabled: bool,
/// HID 设备路径
pub hid_paths: Option<HidDevicePaths>,
/// 错误信息
pub error: Option<String>,
}
pub struct HidDevicePaths {
pub keyboard: PathBuf, // /dev/hidg0
pub mouse_relative: PathBuf, // /dev/hidg1
pub mouse_absolute: PathBuf, // /dev/hidg2
}
```
### 3.2 OtgGadgetManager (manager.rs)
Gadget 生命周期管理器。
```rust
pub struct OtgGadgetManager {
/// Gadget 路径
gadget_path: PathBuf,
/// UDC 设备名
udc_name: Option<String>,
/// 是否已创建
created: bool,
/// 是否已绑定
bound: bool,
/// 端点分配器
endpoint_allocator: EndpointAllocator,
}
impl OtgGadgetManager {
/// 创建管理器
pub fn new() -> Result<Self>;
/// 创建 Gadget
pub fn create_gadget(&mut self, config: &GadgetConfig) -> Result<()>;
/// 销毁 Gadget
pub fn destroy_gadget(&mut self) -> Result<()>;
/// 绑定 UDC
pub fn bind_udc(&mut self) -> Result<()>;
/// 解绑 UDC
pub fn unbind_udc(&mut self) -> Result<()>;
/// 添加函数
pub fn add_function(&mut self, func: &dyn GadgetFunction) -> Result<()>;
/// 移除函数
pub fn remove_function(&mut self, func: &dyn GadgetFunction) -> Result<()>;
/// 链接函数到配置
pub fn link_function(&self, func: &dyn GadgetFunction) -> Result<()>;
/// 取消链接函数
pub fn unlink_function(&self, func: &dyn GadgetFunction) -> Result<()>;
/// 检测可用 UDC
fn detect_udc() -> Result<String>;
}
pub struct GadgetConfig {
pub name: String, // "one-kvm"
pub vendor_id: u16, // 0x05ac
pub product_id: u16, // 0x0001
pub manufacturer: String, // "One-KVM"
pub product: String, // "KVM Device"
pub serial: String, // UUID
}
```
### 3.3 HID Function (hid.rs)
```rust
pub struct HidFunction {
/// 键盘函数
keyboard: HidFunctionConfig,
/// 相对鼠标函数
mouse_relative: HidFunctionConfig,
/// 绝对鼠标函数
mouse_absolute: HidFunctionConfig,
}
pub struct HidFunctionConfig {
/// 函数名
pub name: String, // "hid.usb0"
/// 协议
pub protocol: u8, // 1=keyboard, 2=mouse
/// 子类
pub subclass: u8, // 1=boot, 0=none
/// 报告长度
pub report_length: u8,
/// 报告描述符
pub report_desc: Vec<u8>,
}
impl HidFunction {
/// 创建 HID 函数
pub fn new() -> Self;
/// 获取键盘报告描述符
pub fn keyboard_report_desc() -> Vec<u8>;
/// 获取相对鼠标报告描述符
pub fn mouse_relative_report_desc() -> Vec<u8>;
/// 获取绝对鼠标报告描述符
pub fn mouse_absolute_report_desc() -> Vec<u8>;
}
impl GadgetFunction for HidFunction {
fn name(&self) -> &str;
fn function_type(&self) -> &str; // "hid"
fn configure(&self, path: &Path) -> Result<()>;
}
```
### 3.4 MSD Function (msd.rs)
```rust
pub struct MsdFunction {
/// 函数名
name: String,
/// LUN 配置
luns: Vec<MsdLun>,
}
pub struct MsdLun {
/// LUN 编号
pub lun_id: u8,
/// 镜像文件路径
pub file: Option<PathBuf>,
/// 是否 CD-ROM 模式
pub cdrom: bool,
/// 是否只读
pub readonly: bool,
/// 是否可移除
pub removable: bool,
}
impl MsdFunction {
/// 创建 MSD 函数
pub fn new() -> Self;
/// 设置镜像文件
pub fn set_image(&mut self, path: &Path, cdrom: bool) -> Result<()>;
/// 清除镜像
pub fn clear_image(&mut self) -> Result<()>;
/// 弹出介质
pub fn eject(&mut self) -> Result<()>;
}
impl GadgetFunction for MsdFunction {
fn name(&self) -> &str;
fn function_type(&self) -> &str; // "mass_storage"
fn configure(&self, path: &Path) -> Result<()>;
}
```
### 3.5 ConfigFS 操作 (configfs.rs)
```rust
pub struct ConfigFs;
impl ConfigFs {
/// ConfigFS 根路径
const ROOT: &'static str = "/sys/kernel/config/usb_gadget";
/// 创建目录
pub fn mkdir(path: &Path) -> Result<()>;
/// 删除目录
pub fn rmdir(path: &Path) -> Result<()>;
/// 写入文件
pub fn write_file(path: &Path, content: &str) -> Result<()>;
/// 写入二进制文件
pub fn write_binary(path: &Path, data: &[u8]) -> Result<()>;
/// 读取文件
pub fn read_file(path: &Path) -> Result<String>;
/// 创建符号链接
pub fn symlink(target: &Path, link: &Path) -> Result<()>;
/// 删除符号链接
pub fn unlink(path: &Path) -> Result<()>;
/// 列出目录
pub fn list_dir(path: &Path) -> Result<Vec<String>>;
}
```
### 3.6 端点分配 (endpoint.rs)
```rust
pub struct EndpointAllocator {
/// 已使用的端点
used_endpoints: HashSet<u8>,
/// 最大端点数
max_endpoints: u8,
}
impl EndpointAllocator {
/// 创建分配器
pub fn new(max_endpoints: u8) -> Self;
/// 分配端点
pub fn allocate(&mut self, count: u8) -> Result<Vec<u8>>;
/// 释放端点
pub fn release(&mut self, endpoints: &[u8]);
/// 检查可用端点数
pub fn available(&self) -> u8;
}
```
### 3.7 报告描述符 (report_desc.rs)
```rust
pub struct ReportDescriptor;
impl ReportDescriptor {
/// 标准键盘报告描述符
pub fn keyboard() -> Vec<u8> {
vec![
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x06, // Usage (Keyboard)
0xA1, 0x01, // Collection (Application)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0xE0, // Usage Minimum (224)
0x29, 0xE7, // Usage Maximum (231)
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)
0x95, 0x01, // Report Count (1)
0x75, 0x08, // Report Size (8)
0x81, 0x01, // Input (Constant)
0x95, 0x06, // Report Count (6)
0x75, 0x08, // Report Size (8)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x65, // Logical Maximum (101)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0x00, // Usage Minimum (0)
0x29, 0x65, // Usage Maximum (101)
0x81, 0x00, // Input (Data, Array)
0xC0, // End Collection
]
}
/// 相对鼠标报告描述符
pub fn mouse_relative() -> Vec<u8>;
/// 绝对鼠标报告描述符
pub fn mouse_absolute() -> Vec<u8>;
}
```
---
## 4. 生命周期管理
### 4.1 初始化流程
```
OtgService::new()
├── 检测 UDC 设备
│ └── 读取 /sys/class/udc/
├── 创建 OtgGadgetManager
└── 初始化状态
enable_hid()
├── 检查 Gadget 是否存在
│ └── 如不存在,创建 Gadget
├── 创建 HID 函数
│ ├── hid.usb0 (键盘)
│ ├── hid.usb1 (相对鼠标)
│ └── hid.usb2 (绝对鼠标)
├── 配置函数
│ └── 写入报告描述符
├── 链接函数到配置
├── 绑定 UDC (如未绑定)
└── 等待设备节点出现
└── /dev/hidg0, hidg1, hidg2
```
### 4.2 清理流程
```
disable_hid()
├── 检查是否有其他函数使用
├── 如果只有 HID解绑 UDC
├── 取消链接 HID 函数
└── 删除 HID 函数目录
disable_msd()
├── 同上...
└── 如果没有任何函数,销毁 Gadget
```
---
## 5. 配置
### 5.1 OTG 配置
```rust
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct OtgConfig {
/// 是否启用 OTG
pub enabled: bool,
/// 厂商 ID
pub vendor_id: u16,
/// 产品 ID
pub product_id: u16,
/// 厂商名称
pub manufacturer: String,
/// 产品名称
pub product: String,
}
impl Default for OtgConfig {
fn default() -> Self {
Self {
enabled: true,
vendor_id: 0x05ac, // Apple
product_id: 0x0001,
manufacturer: "One-KVM".to_string(),
product: "KVM Device".to_string(),
}
}
}
```
---
## 6. 错误处理
```rust
#[derive(Debug, thiserror::Error)]
pub enum OtgError {
#[error("No UDC device found")]
NoUdcDevice,
#[error("Gadget already exists")]
GadgetExists,
#[error("Gadget not found")]
GadgetNotFound,
#[error("Function already exists: {0}")]
FunctionExists(String),
#[error("UDC busy")]
UdcBusy,
#[error("ConfigFS error: {0}")]
ConfigFsError(String),
#[error("Permission denied: {0}")]
PermissionDenied(String),
#[error("Device node not found: {0}")]
DeviceNodeNotFound(String),
}
```
---
## 7. 使用示例
### 7.1 启用 HID
```rust
let otg = OtgService::new()?;
// 启用 HID
let paths = otg.enable_hid().await?;
println!("Keyboard: {:?}", paths.keyboard);
println!("Mouse relative: {:?}", paths.mouse_relative);
println!("Mouse absolute: {:?}", paths.mouse_absolute);
// 使用设备...
// 禁用 HID
otg.disable_hid().await?;
```
### 7.2 启用 MSD
```rust
let otg = OtgService::new()?;
// 启用 MSD
let mut msd = otg.enable_msd().await?;
// 挂载 ISO
msd.set_image(Path::new("/data/ubuntu.iso"), true)?;
// 弹出
msd.eject()?;
// 禁用 MSD
otg.disable_msd().await?;
```
---
## 8. 常见问题
### Q: 找不到 UDC 设备?
1. 检查内核是否支持 USB Gadget
2. 加载必要的内核模块:
```bash
modprobe libcomposite
modprobe usb_f_hid
modprobe usb_f_mass_storage
```
3. 检查 `/sys/class/udc/` 目录
### Q: 权限错误?
1. 以 root 运行
2. 或配置 udev 规则
### Q: 设备节点不出现?
1. 检查 UDC 是否正确绑定
2. 查看 `dmesg` 日志
3. 检查 ConfigFS 配置
### Q: 目标 PC 不识别?
1. 检查 USB 线缆
2. 检查报告描述符
3. 使用 `lsusb` 确认设备

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,428 +0,0 @@
# Web 模块文档
## 1. 模块概述
Web 模块提供 HTTP API 和静态文件服务。
### 1.1 主要功能
- REST API
- WebSocket
- 静态文件服务
- 认证中间件
- CORS 支持
### 1.2 文件结构
```
src/web/
├── mod.rs # 模块导出
├── routes.rs # 路由定义 (9KB)
├── ws.rs # WebSocket (8KB)
├── audio_ws.rs # 音频 WebSocket (8KB)
├── static_files.rs # 静态文件 (6KB)
└── handlers/ # API 处理器
├── mod.rs
└── config/
├── mod.rs
├── apply.rs
├── types.rs
└── rustdesk.rs
```
---
## 2. 路由结构
### 2.1 公共路由 (无认证)
| 路由 | 方法 | 描述 |
|------|------|------|
| `/health` | GET | 健康检查 |
| `/auth/login` | POST | 登录 |
| `/setup` | GET | 获取设置状态 |
| `/setup/init` | POST | 初始化设置 |
### 2.2 用户路由 (需认证)
| 路由 | 方法 | 描述 |
|------|------|------|
| `/info` | GET | 系统信息 |
| `/devices` | GET | 设备列表 |
| `/stream/*` | * | 流控制 |
| `/webrtc/*` | * | WebRTC 信令 |
| `/hid/*` | * | HID 控制 |
| `/audio/*` | * | 音频控制 |
| `/ws` | WS | 事件 WebSocket |
| `/ws/audio` | WS | 音频 WebSocket |
### 2.3 管理员路由 (需 Admin)
| 路由 | 方法 | 描述 |
|------|------|------|
| `/config/*` | * | 配置管理 |
| `/msd/*` | * | MSD 操作 |
| `/atx/*` | * | ATX 控制 |
| `/extensions/*` | * | 扩展管理 |
| `/rustdesk/*` | * | RustDesk |
| `/users/*` | * | 用户管理 |
---
## 3. 路由定义
```rust
pub fn create_router(state: Arc<AppState>) -> Router {
Router::new()
// 公共路由
.route("/health", get(handlers::health))
.route("/auth/login", post(handlers::login))
.route("/setup", get(handlers::setup_status))
.route("/setup/init", post(handlers::setup_init))
// 用户路由
.nest("/api", user_routes())
// 管理员路由
.nest("/api/admin", admin_routes())
// 静态文件
.fallback(static_files::serve)
// 中间件
.layer(CorsLayer::permissive())
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http())
// 状态
.with_state(state)
}
fn user_routes() -> Router {
Router::new()
.route("/info", get(handlers::system_info))
.route("/devices", get(handlers::list_devices))
// 流控制
.route("/stream/status", get(handlers::stream_status))
.route("/stream/start", post(handlers::stream_start))
.route("/stream/stop", post(handlers::stream_stop))
.route("/stream/mjpeg", get(handlers::mjpeg_stream))
// WebRTC
.route("/webrtc/session", post(handlers::webrtc_create_session))
.route("/webrtc/offer", post(handlers::webrtc_offer))
.route("/webrtc/ice", post(handlers::webrtc_ice))
.route("/webrtc/close", post(handlers::webrtc_close))
// HID
.route("/hid/status", get(handlers::hid_status))
.route("/hid/reset", post(handlers::hid_reset))
// WebSocket
.route("/ws", get(handlers::ws_handler))
.route("/ws/audio", get(handlers::audio_ws_handler))
// 认证中间件
.layer(middleware::from_fn(auth_middleware))
}
fn admin_routes() -> Router {
Router::new()
// 配置
.route("/config", get(handlers::config::get_config))
.route("/config", patch(handlers::config::update_config))
// MSD
.route("/msd/status", get(handlers::msd_status))
.route("/msd/connect", post(handlers::msd_connect))
// ATX
.route("/atx/status", get(handlers::atx_status))
.route("/atx/power/short", post(handlers::atx_power_short))
// 认证中间件
.layer(middleware::from_fn(auth_middleware))
.layer(middleware::from_fn(admin_middleware))
}
```
---
## 4. 静态文件服务
```rust
#[derive(RustEmbed)]
#[folder = "web/dist"]
#[include = "*.html"]
#[include = "*.js"]
#[include = "*.css"]
#[include = "assets/*"]
struct Assets;
pub async fn serve(uri: Uri) -> impl IntoResponse {
let path = uri.path().trim_start_matches('/');
// 尝试获取文件
if let Some(content) = Assets::get(path) {
let mime = mime_guess::from_path(path)
.first_or_octet_stream();
return (
[(header::CONTENT_TYPE, mime.as_ref())],
content.data.into_owned(),
).into_response();
}
// SPA 回退到 index.html
if let Some(content) = Assets::get("index.html") {
return (
[(header::CONTENT_TYPE, "text/html")],
content.data.into_owned(),
).into_response();
}
StatusCode::NOT_FOUND.into_response()
}
```
---
## 5. WebSocket 处理
### 5.1 事件 WebSocket (ws.rs)
```rust
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_ws(socket, state))
}
async fn handle_ws(mut socket: WebSocket, state: Arc<AppState>) {
// 发送初始设备信息
let device_info = state.get_device_info().await;
let json = serde_json::to_string(&device_info).unwrap();
let _ = socket.send(Message::Text(json)).await;
// 订阅事件
let mut rx = state.events.subscribe();
loop {
tokio::select! {
// 发送事件
result = rx.recv() => {
if let Ok(event) = result {
let json = serde_json::to_string(&event).unwrap();
if socket.send(Message::Text(json)).await.is_err() {
break;
}
}
}
// 接收消息 (心跳/关闭)
msg = socket.recv() => {
match msg {
Some(Ok(Message::Ping(data))) => {
let _ = socket.send(Message::Pong(data)).await;
}
Some(Ok(Message::Close(_))) | None => break,
_ => {}
}
}
}
}
}
```
### 5.2 音频 WebSocket (audio_ws.rs)
```rust
pub async fn audio_ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_audio_ws(socket, state))
}
async fn handle_audio_ws(mut socket: WebSocket, state: Arc<AppState>) {
// 订阅音频帧
let mut rx = state.audio.subscribe();
loop {
tokio::select! {
// 发送音频帧
result = rx.recv() => {
if let Ok(frame) = result {
if socket.send(Message::Binary(frame.data.to_vec())).await.is_err() {
break;
}
}
}
// 处理关闭
msg = socket.recv() => {
match msg {
Some(Ok(Message::Close(_))) | None => break,
_ => {}
}
}
}
}
}
```
---
## 6. MJPEG 流
```rust
pub async fn mjpeg_stream(
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
let boundary = "frame";
// 订阅视频帧
let rx = state.stream_manager.subscribe_mjpeg();
// 创建流
let stream = async_stream::stream! {
let mut rx = rx;
while let Ok(frame) = rx.recv().await {
let header = format!(
"--{}\r\nContent-Type: image/jpeg\r\nContent-Length: {}\r\n\r\n",
boundary,
frame.data.len()
);
yield Ok::<_, std::io::Error>(Bytes::from(header));
yield Ok(frame.data.clone());
yield Ok(Bytes::from("\r\n"));
}
};
(
[(
header::CONTENT_TYPE,
format!("multipart/x-mixed-replace; boundary={}", boundary),
)],
Body::from_stream(stream),
)
}
```
---
## 7. 错误处理
```rust
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::AuthError => (StatusCode::UNAUTHORIZED, "Authentication failed"),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"),
AppError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden"),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.as_str()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.as_str()),
AppError::Internal(err) => {
tracing::error!("Internal error: {:?}", err);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error")
}
// ...
};
(status, Json(json!({ "error": message }))).into_response()
}
}
```
---
## 8. 请求提取器
```rust
// 从 Cookie 获取会话
pub struct AuthUser(pub Session);
#[async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let cookies = Cookies::from_request_parts(parts, state).await?;
let token = cookies
.get("session_id")
.map(|c| c.value().to_string())
.ok_or(AppError::Unauthorized)?;
let state = parts.extensions.get::<Arc<AppState>>().unwrap();
let session = state.sessions
.get_session(&token)
.ok_or(AppError::Unauthorized)?;
Ok(AuthUser(session))
}
}
```
---
## 9. 中间件
### 9.1 认证中间件
```rust
pub async fn auth_middleware(
State(state): State<Arc<AppState>>,
cookies: Cookies,
mut request: Request,
next: Next,
) -> Response {
let token = cookies
.get("session_id")
.map(|c| c.value().to_string());
if let Some(session) = token.and_then(|t| state.sessions.get_session(&t)) {
request.extensions_mut().insert(session);
next.run(request).await
} else {
StatusCode::UNAUTHORIZED.into_response()
}
}
```
### 9.2 Admin 中间件
```rust
pub async fn admin_middleware(
Extension(session): Extension<Session>,
request: Request,
next: Next,
) -> Response {
if session.role == UserRole::Admin {
next.run(request).await
} else {
StatusCode::FORBIDDEN.into_response()
}
}
```
---
## 10. HTTPS 支持
```rust
// 使用 axum-server 提供 TLS
let tls_config = RustlsConfig::from_pem_file(cert_path, key_path).await?;
axum_server::bind_rustls(addr, tls_config)
.serve(app.into_make_service())
.await?;
// 或自动生成自签名证书
let (cert, key) = generate_self_signed_cert()?;
let tls_config = RustlsConfig::from_pem(cert, key).await?;
```

View File

@@ -1,789 +0,0 @@
# WebRTC 模块文档
## 1. 模块概述
WebRTC 模块提供低延迟的实时音视频流传输,支持多种视频编码格式和 DataChannel HID 控制。
### 1.1 主要功能
- WebRTC 会话管理
- 多编码器支持 (H264/H265/VP8/VP9)
- 音频轨道 (Opus)
- DataChannel HID
- ICE/STUN/TURN 支持
### 1.2 文件结构
```
src/webrtc/
├── mod.rs # 模块导出
├── webrtc_streamer.rs # 统一管理器 (35KB)
├── universal_session.rs # 会话管理 (32KB)
├── unified_video_track.rs # 统一视频轨道 (15KB)
├── video_track.rs # 视频轨道 (19KB)
├── rtp.rs # RTP 打包 (24KB)
├── h265_payloader.rs # H265 RTP (15KB)
├── peer.rs # PeerConnection (17KB)
├── config.rs # 配置 (3KB)
├── signaling.rs # 信令 (5KB)
├── session.rs # 会话基类 (8KB)
└── track.rs # 轨道基类 (11KB)
```
---
## 2. 架构设计
### 2.1 整体架构
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ WebRTC Architecture │
└─────────────────────────────────────────────────────────────────────────────┘
Browser
│ HTTP Signaling
┌─────────────────┐
│ WebRtcStreamer │
│(webrtc_streamer)│
└────────┬────────┘
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Session │ │Session │ │Session │
│ 1 │ │ 2 │ │ N │
└───┬────┘ └───┬────┘ └───┬────┘
│ │ │
├───────────┼─────────────┤
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────┐
│ SharedVideoPipeline │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │H264 │ │H265 │ │VP8 │ │VP9 │ │
│ └─────┘ └─────┘ └─────┘ └─────┘ │
└─────────────────────────────────────┘
┌────────────────┐
│ VideoCapturer │
└────────────────┘
```
### 2.2 会话生命周期
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Session Lifecycle │
└─────────────────────────────────────────────────────────────────────────────┘
1. 创建会话
POST /webrtc/session
┌─────────────────┐
│ Create Session │
│ Generate ID │
└────────┬────────┘
{ session_id: "..." }
2. 发送 Offer
POST /webrtc/offer
{ session_id, codec, offer_sdp }
┌─────────────────┐
│ Process Offer │
│ Create Answer │
│ Setup Tracks │
└────────┬────────┘
{ answer_sdp, ice_candidates }
3. ICE 候选
POST /webrtc/ice
{ session_id, candidate }
┌─────────────────┐
│ Add ICE │
│ Candidate │
└─────────────────┘
4. 连接建立
┌─────────────────┐
│ DTLS Handshake │
│ SRTP Setup │
│ DataChannel │
└────────┬────────┘
开始传输视频/音频
5. 关闭会话
POST /webrtc/close
{ session_id }
┌─────────────────┐
│ Cleanup │
│ Release │
└─────────────────┘
```
---
## 3. 核心组件
### 3.1 WebRtcStreamer (webrtc_streamer.rs)
WebRTC 服务主类。
```rust
pub struct WebRtcStreamer {
/// 会话映射
sessions: Arc<RwLock<HashMap<String, Arc<UniversalSession>>>>,
/// 共享视频管道
video_pipeline: Arc<SharedVideoPipeline>,
/// 共享音频管道
audio_pipeline: Arc<SharedAudioPipeline>,
/// HID 控制器
hid: Arc<HidController>,
/// 配置
config: WebRtcConfig,
/// 事件总线
events: Arc<EventBus>,
}
impl WebRtcStreamer {
/// 创建流服务
pub async fn new(
video_pipeline: Arc<SharedVideoPipeline>,
audio_pipeline: Arc<SharedAudioPipeline>,
hid: Arc<HidController>,
config: WebRtcConfig,
events: Arc<EventBus>,
) -> Result<Self>;
/// 创建会话
pub async fn create_session(&self) -> Result<String>;
/// 处理 Offer
pub async fn process_offer(
&self,
session_id: &str,
offer: &str,
codec: VideoCodec,
) -> Result<OfferResponse>;
/// 添加 ICE 候选
pub async fn add_ice_candidate(
&self,
session_id: &str,
candidate: &str,
) -> Result<()>;
/// 关闭会话
pub async fn close_session(&self, session_id: &str) -> Result<()>;
/// 获取会话列表
pub fn list_sessions(&self) -> Vec<SessionInfo>;
/// 获取统计信息
pub fn get_stats(&self) -> WebRtcStats;
}
pub struct OfferResponse {
pub answer_sdp: String,
pub ice_candidates: Vec<String>,
}
pub struct WebRtcStats {
pub active_sessions: usize,
pub total_bytes_sent: u64,
pub avg_bitrate: u32,
}
```
### 3.2 UniversalSession (universal_session.rs)
单个 WebRTC 会话。
```rust
pub struct UniversalSession {
/// 会话 ID
id: String,
/// PeerConnection
peer: Arc<RTCPeerConnection>,
/// 视频轨道
video_track: Arc<UniversalVideoTrack>,
/// 音频轨道
audio_track: Option<Arc<dyn TrackLocal>>,
/// HID DataChannel
hid_channel: Arc<RwLock<Option<Arc<RTCDataChannel>>>>,
/// HID 处理器
hid_handler: Arc<HidDataChannelHandler>,
/// 状态
state: Arc<RwLock<SessionState>>,
/// 编码器类型
codec: VideoCodec,
}
impl UniversalSession {
/// 创建会话
pub async fn new(
id: String,
config: &WebRtcConfig,
video_pipeline: Arc<SharedVideoPipeline>,
audio_pipeline: Arc<SharedAudioPipeline>,
hid_handler: Arc<HidDataChannelHandler>,
codec: VideoCodec,
) -> Result<Self>;
/// 处理 Offer SDP
pub async fn handle_offer(&self, offer_sdp: &str) -> Result<String>;
/// 添加 ICE 候选
pub async fn add_ice_candidate(&self, candidate: &str) -> Result<()>;
/// 获取 ICE 候选
pub fn get_ice_candidates(&self) -> Vec<String>;
/// 关闭会话
pub async fn close(&self) -> Result<()>;
/// 获取状态
pub fn state(&self) -> SessionState;
/// 获取统计
pub fn stats(&self) -> SessionStats;
}
pub enum SessionState {
New,
Connecting,
Connected,
Disconnected,
Failed,
Closed,
}
pub struct SessionStats {
pub bytes_sent: u64,
pub packets_sent: u64,
pub bitrate: u32,
pub frame_rate: f32,
pub round_trip_time: Duration,
}
```
### 3.3 VideoTrack (video_track.rs)
视频轨道封装。
```rust
pub struct UniversalVideoTrack {
/// 轨道 ID
id: String,
/// 编码类型
codec: VideoCodec,
/// RTP 发送器
rtp_sender: Arc<RtpSender>,
/// 帧计数
frame_count: AtomicU64,
/// 统计
stats: Arc<RwLock<TrackStats>>,
}
impl UniversalVideoTrack {
/// 创建轨道
pub fn new(id: &str, codec: VideoCodec) -> Result<Self>;
/// 发送编码帧
pub async fn send_frame(&self, frame: &EncodedFrame) -> Result<()>;
/// 获取 RTP 参数
pub fn rtp_params(&self) -> RtpParameters;
/// 获取统计
pub fn stats(&self) -> TrackStats;
}
pub struct TrackStats {
pub frames_sent: u64,
pub bytes_sent: u64,
pub packets_sent: u64,
pub packet_loss: f32,
}
```
### 3.4 RTP 打包 (rtp.rs)
RTP 协议实现。
```rust
pub struct RtpPacketizer {
/// SSRC
ssrc: u32,
/// 序列号
sequence: u16,
/// 时间戳
timestamp: u32,
/// 负载类型
payload_type: u8,
/// 时钟频率
clock_rate: u32,
}
impl RtpPacketizer {
/// 创建打包器
pub fn new(codec: VideoCodec) -> Self;
/// 打包 H264 帧
pub fn packetize_h264(&mut self, frame: &[u8], keyframe: bool) -> Vec<Vec<u8>>;
/// 打包 VP8 帧
pub fn packetize_vp8(&mut self, frame: &[u8], keyframe: bool) -> Vec<Vec<u8>>;
/// 打包 VP9 帧
pub fn packetize_vp9(&mut self, frame: &[u8], keyframe: bool) -> Vec<Vec<u8>>;
/// 打包 Opus 帧
pub fn packetize_opus(&mut self, frame: &[u8]) -> Vec<u8>;
}
/// H264 NAL 单元分片
pub struct H264Fragmenter;
impl H264Fragmenter {
/// 分片大于 MTU 的 NAL
pub fn fragment(nal: &[u8], mtu: usize) -> Vec<Vec<u8>>;
/// 创建 STAP-A 聚合
pub fn aggregate(nals: &[&[u8]]) -> Vec<u8>;
}
```
### 3.5 H265 打包器 (h265_payloader.rs)
H265/HEVC RTP 打包。
```rust
pub struct H265Payloader {
/// MTU 大小
mtu: usize,
}
impl H265Payloader {
/// 创建打包器
pub fn new(mtu: usize) -> Self;
/// 打包 H265 帧
pub fn packetize(&self, frame: &[u8]) -> Vec<Vec<u8>>;
/// 分析 NAL 单元类型
fn get_nal_type(nal: &[u8]) -> u8;
/// 是否需要分片
fn needs_fragmentation(&self, nal: &[u8]) -> bool;
}
```
---
## 4. 信令协议
### 4.1 创建会话
```
POST /api/webrtc/session
Content-Type: application/json
{}
Response:
{
"session_id": "abc123-def456"
}
```
### 4.2 发送 Offer
```
POST /api/webrtc/offer
Content-Type: application/json
{
"session_id": "abc123-def456",
"video_codec": "h264",
"enable_audio": true,
"offer_sdp": "v=0\r\no=- ..."
}
Response:
{
"answer_sdp": "v=0\r\no=- ...",
"ice_candidates": [
"candidate:1 1 UDP ...",
"candidate:2 1 TCP ..."
]
}
```
### 4.3 ICE 候选
```
POST /api/webrtc/ice
Content-Type: application/json
{
"session_id": "abc123-def456",
"candidate": "candidate:1 1 UDP ..."
}
Response:
{
"success": true
}
```
### 4.4 关闭会话
```
POST /api/webrtc/close
Content-Type: application/json
{
"session_id": "abc123-def456"
}
Response:
{
"success": true
}
```
---
## 5. 配置
```rust
#[derive(Serialize, Deserialize)]
#[typeshare]
pub struct WebRtcConfig {
/// STUN 服务器
pub stun_servers: Vec<String>,
/// TURN 服务器
pub turn_servers: Vec<TurnServer>,
/// 默认编码器
pub default_codec: VideoCodec,
/// 码率 (kbps)
pub bitrate_kbps: u32,
/// GOP 大小
pub gop_size: u32,
/// 启用音频
pub enable_audio: bool,
/// 启用 DataChannel HID
pub enable_datachannel_hid: bool,
}
pub struct TurnServer {
pub url: String,
pub username: String,
pub password: String,
}
impl Default for WebRtcConfig {
fn default() -> Self {
Self {
stun_servers: vec!["stun:stun.l.google.com:19302".to_string()],
turn_servers: vec![],
default_codec: VideoCodec::H264,
bitrate_kbps: 2000,
gop_size: 60,
enable_audio: true,
enable_datachannel_hid: true,
}
}
}
```
---
## 6. DataChannel HID
### 6.1 消息格式
```javascript
// 键盘事件
{
"type": "keyboard",
"keys": ["KeyA", "KeyB"],
"modifiers": {
"ctrl": false,
"shift": true,
"alt": false,
"meta": false
}
}
// 鼠标事件
{
"type": "mouse",
"x": 16384,
"y": 16384,
"button": "left",
"event": "press"
}
// 鼠标模式
{
"type": "mouse_mode",
"mode": "absolute"
}
```
### 6.2 处理流程
```
DataChannel Message
┌─────────────────┐
│Parse JSON Event │
└────────┬────────┘
┌─────────────────┐
│HidDataChannel │
│ Handler │
└────────┬────────┘
┌─────────────────┐
│ HidController │
└────────┬────────┘
USB/Serial
```
---
## 7. 支持的编码器
| 编码器 | RTP 负载类型 | 时钟频率 | 硬件加速 |
|--------|-------------|---------|---------|
| H264 | 96 (动态) | 90000 | VAAPI/RKMPP/V4L2 |
| H265 | 97 (动态) | 90000 | VAAPI |
| VP8 | 98 (动态) | 90000 | VAAPI |
| VP9 | 99 (动态) | 90000 | VAAPI |
| Opus | 111 (动态) | 48000 | 无 (软件) |
---
## 8. 错误处理
```rust
#[derive(Debug, thiserror::Error)]
pub enum WebRtcError {
#[error("Session not found: {0}")]
SessionNotFound(String),
#[error("Session already exists")]
SessionExists,
#[error("Invalid SDP: {0}")]
InvalidSdp(String),
#[error("Codec not supported: {0}")]
CodecNotSupported(String),
#[error("ICE failed")]
IceFailed,
#[error("DTLS failed")]
DtlsFailed,
#[error("Track error: {0}")]
TrackError(String),
#[error("Connection closed")]
ConnectionClosed,
}
```
---
## 9. 使用示例
### 9.1 创建会话
```rust
let streamer = WebRtcStreamer::new(
video_pipeline,
audio_pipeline,
hid,
WebRtcConfig::default(),
events,
).await?;
// 创建会话
let session_id = streamer.create_session().await?;
// 处理 Offer
let response = streamer.process_offer(
&session_id,
&offer_sdp,
VideoCodec::H264,
).await?;
println!("Answer: {}", response.answer_sdp);
```
### 9.2 前端连接
```javascript
// 创建 PeerConnection
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// 创建 DataChannel
const hidChannel = pc.createDataChannel('hid');
// 创建 Offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 发送到服务器
const response = await fetch('/api/webrtc/offer', {
method: 'POST',
body: JSON.stringify({
session_id,
video_codec: 'h264',
offer_sdp: offer.sdp
})
});
const { answer_sdp, ice_candidates } = await response.json();
// 设置 Answer
await pc.setRemoteDescription({ type: 'answer', sdp: answer_sdp });
// 添加 ICE 候选
for (const candidate of ice_candidates) {
await pc.addIceCandidate({ candidate });
}
```
---
## 10. 管道重启机制
当码率或编码器配置变更时视频管道需要重启。WebRTC 模块实现了自动重连机制:
### 10.1 重启流程
```
用户修改码率/编码器
┌─────────────────────┐
│ set_bitrate_preset │
│ 1. 保存 frame_tx │ ← 关键:在停止前保存
│ 2. 停止旧管道 │
│ 3. 等待清理 │
│ 4. 恢复 frame_tx │
│ 5. 创建新管道 │
│ 6. 重连所有会话 │
└─────────────────────┘
所有 WebRTC 会话自动恢复
```
### 10.2 关键代码
```rust
pub async fn set_bitrate_preset(self: &Arc<Self>, preset: BitratePreset) -> Result<()> {
// 保存 frame_tx (监控任务会在管道停止后清除它)
let saved_frame_tx = self.video_frame_tx.read().await.clone();
// 停止管道
pipeline.stop();
tokio::time::sleep(Duration::from_millis(100)).await;
// 恢复 frame_tx 并重建管道
if let Some(tx) = saved_frame_tx {
*self.video_frame_tx.write().await = Some(tx.clone());
let pipeline = self.ensure_video_pipeline(tx).await?;
// 重连所有会话
for session in sessions {
session.start_from_video_pipeline(pipeline.subscribe(), ...).await;
}
}
}
```
---
## 11. 常见问题
### Q: 连接超时?
1. 检查 STUN/TURN 配置
2. 检查防火墙设置
3. 尝试使用 TURN 中继
### Q: 视频卡顿?
1. 降低分辨率/码率
2. 检查网络带宽
3. 使用硬件编码
### Q: 音频不同步?
1. 检查时间戳同步
2. 调整缓冲区大小
3. 使用 NTP 同步
### Q: 切换码率后视频静止?
1. 检查管道重启逻辑是否正确保存了 `video_frame_tx`
2. 确认会话重连成功
3. 查看日志中是否有 "Reconnecting session" 信息

View File

@@ -1,477 +0,0 @@
# hwcodec 技术架构报告
## 1. 项目概述
hwcodec 是一个基于 FFmpeg 的硬件视频编解码库,来源于 RustDesk 项目并针对 One-KVM 进行了深度定制优化。该库专注于 IP-KVM 场景,提供 Windows 和 Linux 平台的 GPU 加速视频编码能力。
### 1.1 项目位置
```
libs/hwcodec/
├── src/ # Rust 源代码
└── cpp/ # C++ 源代码
```
### 1.2 核心特性
- **多编解码格式支持**: H.264, H.265 (HEVC), VP8, VP9, MJPEG
- **硬件加速**: NVENC, AMF, Intel QSV (Windows), VAAPI, RKMPP, V4L2 M2M (Linux)
- **跨平台**: Windows, Linux (x86_64, ARM64, ARMv7)
- **低延迟优化**: 专为实时流媒体场景设计
- **Rust/C++ 混合架构**: Rust 提供安全的上层 APIC++ 实现底层编解码逻辑
- **IP-KVM 专用**: 解码仅支持 MJPEG采集卡输出格式编码支持多种硬件加速
## 2. 架构设计
### 2.1 整体架构图
```
┌─────────────────────────────────────────────────────────────┐
│ Rust API Layer │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ ffmpeg_ram module ││
│ │ (encode.rs + decode.rs) ││
│ └──────────────────────────┬──────────────────────────────┘│
├─────────────────────────────┼───────────────────────────────┤
│ │ │
│ FFI Bindings (bindgen) │
│ ▼ │
├─────────────────────────────────────────────────────────────┤
│ C++ Core Layer │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ ffmpeg_ram (encode/decode) ││
│ └──────────────────────────┬──────────────────────────────┘│
├─────────────────────────────┼───────────────────────────────┤
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ FFmpeg Libraries │ │
│ │ libavcodec │ libavutil │ libavformat │ libswscale │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
├─────────────────────────────┼───────────────────────────────┤
│ Hardware Acceleration Backends │
│ ┌────────┐ ┌─────┐ ┌─────┐ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │ NVENC │ │ AMF │ │ QSV │ │ VAAPI │ │ RKMPP │ │V4L2M2M│ │
│ └────────┘ └─────┘ └─────┘ └───────┘ └───────┘ └───────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 模块职责
| 模块 | 职责 | 关键文件 |
|------|------|----------|
| `ffmpeg_ram` | 基于 RAM 的软件/硬件编解码 | `src/ffmpeg_ram/` |
| `common` | 公共定义和 GPU 检测 | `src/common.rs` |
| `ffmpeg` | FFmpeg 日志和初始化 | `src/ffmpeg.rs` |
## 3. 模块详细分析
### 3.1 库入口 (lib.rs)
```rust
// libs/hwcodec/src/lib.rs
pub mod common;
pub mod ffmpeg;
pub mod ffmpeg_ram;
```
**功能**:
- 导出所有子模块
- 提供 C 日志回调函数
### 3.2 公共模块 (common.rs)
**核心类型**:
```rust
pub enum Driver {
NV, // NVIDIA
AMF, // AMD
MFX, // Intel
FFMPEG, // 软件编码
}
```
**GPU 检测函数**:
| 平台 | 检测函数 | 检测方式 |
|------|----------|----------|
| Linux | `linux_support_nv()` | 加载 libcuda.so + libnvidia-encode.so |
| Linux | `linux_support_amd()` | 检查 `libamfrt64.so.1` |
| Linux | `linux_support_intel()` | 检查 `libvpl.so`/`libmfx.so` |
| Linux | `linux_support_rkmpp()` | 检查 `/dev/mpp_service` |
| Linux | `linux_support_v4l2m2m()` | 检查 `/dev/video*` 设备 |
### 3.3 FFmpeg RAM 编码模块
#### 3.3.1 Rust 层 (src/ffmpeg_ram/)
**CodecInfo 结构体**:
```rust
pub struct CodecInfo {
pub name: String, // 编码器名称如 "h264_nvenc"
pub mc_name: Option<String>, // MediaCodec 名称 (Android)
pub format: DataFormat, // H264/H265/VP8/VP9/MJPEG
pub priority: i32, // 优先级 (Best=0, Good=1, Normal=2, Soft=3, Bad=4)
pub hwdevice: AVHWDeviceType, // 硬件设备类型
}
```
**EncodeContext 结构体**:
```rust
pub struct EncodeContext {
pub name: String, // 编码器名称
pub width: i32, // 视频宽度
pub height: i32, // 视频高度
pub pixfmt: AVPixelFormat, // 像素格式 (NV12/YUV420P)
pub align: i32, // 内存对齐
pub fps: i32, // 帧率
pub gop: i32, // GOP 大小
pub rc: RateControl, // 码率控制模式
pub quality: Quality, // 质量级别
pub kbs: i32, // 目标码率 (kbps)
pub q: i32, // 量化参数
pub thread_count: i32, // 线程数
}
```
**Encoder 类**:
```rust
pub struct Encoder {
codec: *mut c_void, // C++ 编码器指针
frames: *mut Vec<EncodeFrame>, // 编码输出帧
pub ctx: EncodeContext,
pub linesize: Vec<i32>, // 行大小
pub offset: Vec<i32>, // 平面偏移
pub length: i32, // 总数据长度
}
```
**核心方法**:
| 方法 | 功能 |
|------|------|
| `Encoder::new()` | 创建编码器实例 |
| `Encoder::encode()` | 编码一帧 YUV 数据 |
| `Encoder::set_bitrate()` | 动态调整码率 |
| `Encoder::request_keyframe()` | 请求下一帧为关键帧 |
| `Encoder::available_encoders()` | 检测系统可用编码器 |
#### 3.3.2 C++ 层 (cpp/ffmpeg_ram/)
**FFmpegRamEncoder 类** (ffmpeg_ram_encode.cpp):
```cpp
class FFmpegRamEncoder {
AVCodecContext *c_ = NULL; // FFmpeg 编码上下文
AVFrame *frame_ = NULL; // 输入帧
AVPacket *pkt_ = NULL; // 编码输出包
AVBufferRef *hw_device_ctx_; // 硬件设备上下文
AVFrame *hw_frame_ = NULL; // 硬件帧
bool force_keyframe_ = false; // 强制关键帧标志
// 主要方法
bool init(int *linesize, int *offset, int *length);
int encode(const uint8_t *data, int length, const void *obj, uint64_t ms);
int do_encode(AVFrame *frame, const void *obj, int64_t ms);
int set_hwframe_ctx(); // 设置硬件帧上下文
};
```
**编码流程**:
```
输入 YUV 数据
fill_frame() - 填充 AVFrame 数据指针
├──▶ (软件编码) 直接使用 frame_
└──▶ (硬件编码) av_hwframe_transfer_data() 传输到 GPU
使用 hw_frame_
avcodec_send_frame() - 发送帧到编码器
avcodec_receive_packet() - 获取编码数据
callback() - 回调输出
```
### 3.4 FFmpeg RAM 解码模块
**IP-KVM 专用设计**: 解码器仅支持 MJPEG 软件解码,因为 IP-KVM 场景中视频采集卡输出的是 MJPEG 格式。
**Decoder 类**:
```rust
pub struct Decoder {
codec: *mut c_void,
frames: *mut Vec<DecodeFrame>,
pub ctx: DecodeContext,
}
pub struct DecodeFrame {
pub pixfmt: AVPixelFormat,
pub width: i32,
pub height: i32,
pub data: Vec<Vec<u8>>, // Y, U, V 平面数据
pub linesize: Vec<i32>,
pub key: bool,
}
```
**available_decoders()**: 仅返回 MJPEG 软件解码器
```rust
pub fn available_decoders() -> Vec<CodecInfo> {
vec![CodecInfo {
name: "mjpeg".to_owned(),
format: MJPEG,
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Best as _,
..Default::default()
}]
}
```
**C++ 实现** (ffmpeg_ram_decode.cpp):
```cpp
class FFmpegRamDecoder {
AVCodecContext *c_ = NULL;
AVFrame *frame_ = NULL; // 解码输出帧
AVPacket *pkt_ = NULL;
int do_decode(const void *obj);
};
```
**解码流程**:
```
输入 MJPEG 数据
avcodec_send_packet() - 发送数据到解码器
avcodec_receive_frame() - 获取解码帧 (YUV420P)
callback() - 回调输出
```
## 4. 硬件加速支持
### 4.1 支持的硬件加速后端
| 后端 | 厂商 | 平台 | 编码器名称 |
|------|------|------|-----------|
| NVENC | NVIDIA | Windows/Linux | h264_nvenc, hevc_nvenc |
| AMF | AMD | Windows/Linux | h264_amf, hevc_amf |
| QSV | Intel | Windows | h264_qsv, hevc_qsv |
| VAAPI | 通用 | Linux | h264_vaapi, hevc_vaapi, vp8_vaapi, vp9_vaapi |
| RKMPP | Rockchip | Linux | h264_rkmpp, hevc_rkmpp |
| V4L2 M2M | ARM SoC | Linux | h264_v4l2m2m, hevc_v4l2m2m |
### 4.2 硬件检测逻辑 (Linux)
```cpp
// libs/hwcodec/cpp/common/platform/linux/linux.cpp
// NVIDIA 检测 - 简化的动态库检测
int linux_support_nv() {
void *handle = dlopen("libcuda.so.1", RTLD_LAZY);
if (!handle) handle = dlopen("libcuda.so", RTLD_LAZY);
if (!handle) return -1;
dlclose(handle);
handle = dlopen("libnvidia-encode.so.1", RTLD_LAZY);
if (!handle) handle = dlopen("libnvidia-encode.so", RTLD_LAZY);
if (!handle) return -1;
dlclose(handle);
return 0;
}
// AMD 检测 - 检查 AMF 运行时库
int linux_support_amd() {
void *handle = dlopen("libamfrt64.so.1", RTLD_LAZY);
if (!handle) return -1;
dlclose(handle);
return 0;
}
// Intel 检测 - 检查 VPL/MFX 库
int linux_support_intel() {
const char *libs[] = {"libvpl.so", "libmfx.so", ...};
// 任一成功加载则返回 0
}
// Rockchip MPP 检测 - 检查设备节点
int linux_support_rkmpp() {
if (access("/dev/mpp_service", F_OK) == 0) return 0;
if (access("/dev/rga", F_OK) == 0) return 0;
return -1;
}
// V4L2 M2M 检测 - 检查视频设备
int linux_support_v4l2m2m() {
const char *devices[] = {"/dev/video10", "/dev/video11", ...};
// 任一设备可打开则返回 0
}
```
### 4.3 编码器优先级系统
```rust
pub enum Priority {
Best = 0, // 最高优先级 (硬件加速)
Good = 1, // 良好 (VAAPI, 部分硬件)
Normal = 2, // 普通
Soft = 3, // 软件编码
Bad = 4, // 最低优先级
}
```
**优先级分配**:
| 编码器 | 优先级 |
|--------|--------|
| h264_nvenc, hevc_nvenc | Best (0) |
| h264_amf, hevc_amf | Best (0) |
| h264_qsv, hevc_qsv | Best (0) |
| h264_rkmpp, hevc_rkmpp | Best (0) |
| h264_vaapi, hevc_vaapi | Good (1) |
| h264_v4l2m2m, hevc_v4l2m2m | Good (1) |
| h264 (x264), hevc (x265) | Soft (3) |
### 4.4 低延迟优化配置
```cpp
// libs/hwcodec/cpp/common/util.cpp
bool set_lantency_free(void *priv_data, const std::string &name) {
// NVENC: 禁用延迟缓冲
if (name.find("nvenc") != std::string::npos) {
av_opt_set(priv_data, "delay", "0", 0);
}
// AMF: 设置查询超时
if (name.find("amf") != std::string::npos) {
av_opt_set(priv_data, "query_timeout", "1000", 0);
}
// QSV/VAAPI: 设置异步深度为 1
if (name.find("qsv") != std::string::npos ||
name.find("vaapi") != std::string::npos) {
av_opt_set(priv_data, "async_depth", "1", 0);
}
// libvpx: 实时模式
if (name.find("libvpx") != std::string::npos) {
av_opt_set(priv_data, "deadline", "realtime", 0);
av_opt_set_int(priv_data, "cpu-used", 6, 0);
av_opt_set_int(priv_data, "lag-in-frames", 0, 0);
}
return true;
}
```
## 5. 构建系统
### 5.1 Cargo.toml 配置
```toml
[package]
name = "hwcodec"
version = "0.8.0"
edition = "2021"
description = "Hardware video codec for IP-KVM (Windows/Linux)"
[features]
default = []
[dependencies]
log = "0.4"
serde_derive = "1.0"
serde = "1.0"
serde_json = "1.0"
[build-dependencies]
cc = "1.0" # C++ 编译
bindgen = "0.59" # FFI 绑定生成
```
### 5.2 构建流程 (build.rs)
```
build.rs
├── build_common()
│ ├── 生成 common_ffi.rs (bindgen)
│ ├── 编译平台相关 C++ 代码
│ └── 链接系统库 (stdc++)
└── ffmpeg::build_ffmpeg()
├── 生成 ffmpeg_ffi.rs
├── 链接 FFmpeg 库 (VCPKG 或 pkg-config)
└── build_ffmpeg_ram()
└── 编译 ffmpeg_ram_encode.cpp, ffmpeg_ram_decode.cpp
```
### 5.3 FFmpeg 链接方式
| 方式 | 平台 | 条件 |
|------|------|------|
| VCPKG 静态链接 | 跨平台 | 设置 `VCPKG_ROOT` 环境变量 |
| pkg-config 动态链接 | Linux | 默认方式 |
## 6. 与原版 hwcodec 的区别
针对 One-KVM IP-KVM 场景,对原版 RustDesk hwcodec 进行了以下简化:
### 6.1 移除的功能
| 移除项 | 原因 |
|--------|------|
| VRAM 模块 | IP-KVM 不需要 GPU 显存直接编解码 |
| Mux 模块 | IP-KVM 不需要录制到文件 |
| macOS 支持 | IP-KVM 目标平台不包含 macOS |
| Android 支持 | IP-KVM 目标平台不包含 Android |
| 外部 SDK | 简化构建,减少依赖 |
| 多格式解码 | IP-KVM 仅需 MJPEG 解码 |
### 6.2 保留的功能
| 保留项 | 用途 |
|--------|------|
| FFmpeg RAM 编码 | WebRTC 视频编码 |
| FFmpeg RAM 解码 | MJPEG 采集卡解码 |
| 硬件加速编码 | 低延迟高效编码 |
| 软件编码后备 | 无硬件加速时的兜底方案 |
### 6.3 代码量对比
| 指标 | 原版 | 简化版 | 减少 |
|------|------|--------|------|
| 外部 SDK | ~9MB | 0 | 100% |
| C++ 文件 | ~30 | ~10 | ~67% |
| Rust 模块 | 6 | 3 | 50% |
## 7. 总结
hwcodec 库通过 Rust/C++ 混合架构,在保证内存安全的同时实现了高性能的视频编解码。针对 One-KVM IP-KVM 场景的优化设计特点包括:
1. **精简的编解码器 API**: 解码仅支持 MJPEG编码支持多种硬件加速
2. **自动硬件检测**: 运行时自动检测并选择最优的硬件加速后端
3. **优先级系统**: 基于质量和性能为不同编码器分配优先级
4. **低延迟优化**: 针对实时流媒体场景进行了专门优化
5. **简化的构建系统**: 无需外部 SDK仅依赖系统 FFmpeg
6. **Windows/Linux 跨平台**: 支持 x86_64、ARM64、ARMv7 架构

View File

@@ -1,481 +0,0 @@
# hwcodec 编解码器 API 详解
## 1. 编码器 API
### 1.1 编码器初始化
#### EncodeContext 参数
```rust
pub struct EncodeContext {
pub name: String, // 编码器名称
pub mc_name: Option<String>, // MediaCodec 名称 (保留字段)
pub width: i32, // 视频宽度 (必须为偶数)
pub height: i32, // 视频高度 (必须为偶数)
pub pixfmt: AVPixelFormat, // 像素格式
pub align: i32, // 内存对齐 (通常为 0 或 32)
pub fps: i32, // 帧率
pub gop: i32, // GOP 大小 (关键帧间隔)
pub rc: RateControl, // 码率控制模式
pub quality: Quality, // 编码质量
pub kbs: i32, // 目标码率 (kbps)
pub q: i32, // 量化参数 (CQ 模式)
pub thread_count: i32, // 编码线程数
}
```
#### 参数说明
| 参数 | 类型 | 说明 | 推荐值 |
|------|------|------|--------|
| `name` | String | FFmpeg 编码器名称 | 见下表 |
| `width` | i32 | 视频宽度 | 1920 |
| `height` | i32 | 视频高度 | 1080 |
| `pixfmt` | AVPixelFormat | 像素格式 | NV12 / YUV420P |
| `align` | i32 | 内存对齐 | 0 (自动) |
| `fps` | i32 | 帧率 | 30 |
| `gop` | i32 | GOP 大小 | 30 (1秒) |
| `rc` | RateControl | 码率控制 | CBR / VBR |
| `quality` | Quality | 质量级别 | Medium |
| `kbs` | i32 | 码率 (kbps) | 2000-8000 |
| `thread_count` | i32 | 线程数 | 4 |
#### 编码器名称对照表
| 名称 | 格式 | 加速 | 平台 |
|------|------|------|------|
| `h264_nvenc` | H.264 | NVIDIA GPU | Windows/Linux |
| `hevc_nvenc` | H.265 | NVIDIA GPU | Windows/Linux |
| `h264_amf` | H.264 | AMD GPU | Windows/Linux |
| `hevc_amf` | H.265 | AMD GPU | Windows/Linux |
| `h264_qsv` | H.264 | Intel QSV | Windows |
| `hevc_qsv` | H.265 | Intel QSV | Windows |
| `h264_vaapi` | H.264 | VAAPI | Linux |
| `hevc_vaapi` | H.265 | VAAPI | Linux |
| `vp8_vaapi` | VP8 | VAAPI | Linux |
| `vp9_vaapi` | VP9 | VAAPI | Linux |
| `h264_rkmpp` | H.264 | Rockchip MPP | Linux |
| `hevc_rkmpp` | H.265 | Rockchip MPP | Linux |
| `h264_v4l2m2m` | H.264 | V4L2 M2M | Linux |
| `hevc_v4l2m2m` | H.265 | V4L2 M2M | Linux |
| `h264` | H.264 | 软件 (x264) | 全平台 |
| `hevc` | H.265 | 软件 (x265) | 全平台 |
| `libvpx` | VP8 | 软件 | 全平台 |
| `libvpx-vp9` | VP9 | 软件 | 全平台 |
| `mjpeg` | MJPEG | 软件 | 全平台 |
### 1.2 创建编码器
```rust
use hwcodec::ffmpeg_ram::encode::{Encoder, EncodeContext};
use hwcodec::ffmpeg::{AVPixelFormat};
use hwcodec::common::{RateControl, Quality};
let ctx = EncodeContext {
name: "h264_vaapi".to_string(),
mc_name: None,
width: 1920,
height: 1080,
pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
align: 0,
fps: 30,
gop: 30,
rc: RateControl::RC_CBR,
quality: Quality::Quality_Medium,
kbs: 4000,
q: 0,
thread_count: 4,
};
let encoder = Encoder::new(ctx)?;
println!("Linesize: {:?}", encoder.linesize);
println!("Offset: {:?}", encoder.offset);
println!("Buffer length: {}", encoder.length);
```
### 1.3 编码帧
```rust
// 准备 YUV 数据
let yuv_data: Vec<u8> = prepare_yuv_frame();
// 编码
let pts_ms: i64 = 0; // 时间戳 (毫秒)
match encoder.encode(&yuv_data, pts_ms) {
Ok(frames) => {
for frame in frames.iter() {
println!("Encoded: {} bytes, pts={}, key={}",
frame.data.len(), frame.pts, frame.key);
// 发送 frame.data
}
}
Err(code) => {
eprintln!("Encode error: {}", code);
}
}
```
### 1.4 动态调整码率
```rust
// 动态调整到 6000 kbps
encoder.set_bitrate(6000)?;
```
### 1.5 请求关键帧
```rust
// 下一帧强制编码为 IDR 帧
encoder.request_keyframe();
```
### 1.6 检测可用编码器
```rust
use hwcodec::ffmpeg_ram::encode::{Encoder, EncodeContext};
use hwcodec::ffmpeg_ram::CodecInfo;
let ctx = EncodeContext {
name: String::new(),
mc_name: None,
width: 1920,
height: 1080,
pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
align: 0,
fps: 30,
gop: 30,
rc: RateControl::RC_DEFAULT,
quality: Quality::Quality_Default,
kbs: 4000,
q: 0,
thread_count: 4,
};
let available_encoders = Encoder::available_encoders(ctx, None);
for encoder in available_encoders {
println!("Available: {} (format: {:?}, priority: {})",
encoder.name, encoder.format, encoder.priority);
}
```
## 2. 解码器 API
### 2.1 IP-KVM 专用设计
在 One-KVM IP-KVM 场景中,解码器仅支持 MJPEG 软件解码。这是因为视频采集卡输出的格式是 MJPEG不需要其他格式的硬件解码支持。
### 2.2 解码器初始化
#### DecodeContext 参数
```rust
pub struct DecodeContext {
pub name: String, // 解码器名称 ("mjpeg")
pub device_type: AVHWDeviceType, // 硬件设备类型 (NONE)
pub thread_count: i32, // 解码线程数
}
```
### 2.3 创建解码器
```rust
use hwcodec::ffmpeg_ram::decode::{Decoder, DecodeContext};
use hwcodec::ffmpeg::AVHWDeviceType;
let ctx = DecodeContext {
name: "mjpeg".to_string(),
device_type: AVHWDeviceType::AV_HWDEVICE_TYPE_NONE,
thread_count: 4,
};
let decoder = Decoder::new(ctx)?;
```
### 2.4 解码帧
```rust
// 输入 MJPEG 编码数据
let mjpeg_data: Vec<u8> = receive_mjpeg_frame();
match decoder.decode(&mjpeg_data) {
Ok(frames) => {
for frame in frames.iter() {
println!("Decoded: {}x{}, format={:?}, key={}",
frame.width, frame.height, frame.pixfmt, frame.key);
// 访问 YUV 数据
let y_plane = &frame.data[0];
let u_plane = &frame.data[1];
let v_plane = &frame.data[2];
}
}
Err(code) => {
eprintln!("Decode error: {}", code);
}
}
```
### 2.5 DecodeFrame 结构体
```rust
pub struct DecodeFrame {
pub pixfmt: AVPixelFormat, // 输出像素格式
pub width: i32, // 帧宽度
pub height: i32, // 帧高度
pub data: Vec<Vec<u8>>, // 平面数据 [Y, U, V] 或 [Y, UV]
pub linesize: Vec<i32>, // 每个平面的行字节数
pub key: bool, // 是否为关键帧
}
```
#### 像素格式与平面布局
| 像素格式 | 平面数 | data[0] | data[1] | data[2] |
|----------|--------|---------|---------|---------|
| `YUV420P` | 3 | Y | U | V |
| `YUVJ420P` | 3 | Y | U | V |
| `YUV422P` | 3 | Y | U | V |
| `NV12` | 2 | Y | UV (交错) | - |
| `NV21` | 2 | Y | VU (交错) | - |
### 2.6 获取可用解码器
```rust
use hwcodec::ffmpeg_ram::decode::Decoder;
let available_decoders = Decoder::available_decoders();
for decoder in available_decoders {
println!("Available: {} (format: {:?}, hwdevice: {:?})",
decoder.name, decoder.format, decoder.hwdevice);
}
// 输出:
// Available: mjpeg (format: MJPEG, hwdevice: AV_HWDEVICE_TYPE_NONE)
```
## 3. 码率控制模式
### 3.1 RateControl 枚举
```rust
pub enum RateControl {
RC_DEFAULT, // 使用编码器默认
RC_CBR, // 恒定码率
RC_VBR, // 可变码率
RC_CQ, // 恒定质量 (需设置 q 参数)
}
```
### 3.2 模式说明
| 模式 | 说明 | 适用场景 |
|------|------|----------|
| `RC_CBR` | 码率恒定,质量随场景变化 | 网络带宽受限 |
| `RC_VBR` | 质量优先,码率波动 | 本地存储 |
| `RC_CQ` | 恒定质量,码率波动大 | 质量敏感场景 |
### 3.3 各编码器支持情况
| 编码器 | CBR | VBR | CQ |
|--------|-----|-----|-----|
| nvenc | ✓ | ✓ | ✓ |
| amf | ✓ | ✓ (低延迟) | ✗ |
| qsv | ✓ | ✓ | ✗ |
| vaapi | ✓ | ✓ | ✗ |
## 4. 质量等级
### 4.1 Quality 枚举
```rust
pub enum Quality {
Quality_Default, // 使用编码器默认
Quality_High, // 高质量 (慢速)
Quality_Medium, // 中等质量 (平衡)
Quality_Low, // 低质量 (快速)
}
```
### 4.2 编码器预设映射
| 质量 | nvenc | amf | qsv |
|------|-------|-----|-----|
| High | - | quality | veryslow |
| Medium | p4 | balanced | medium |
| Low | p1 | speed | veryfast |
## 5. 错误处理
### 5.1 错误码
| 错误码 | 常量 | 说明 |
|--------|------|------|
| 0 | `HWCODEC_SUCCESS` | 成功 |
| -1 | `HWCODEC_ERR_COMMON` | 通用错误 |
| -2 | `HWCODEC_ERR_HEVC_COULD_NOT_FIND_POC` | HEVC 解码参考帧丢失 |
### 5.2 常见错误处理
```rust
match encoder.encode(&yuv_data, pts) {
Ok(frames) => {
// 处理编码帧
}
Err(-1) => {
eprintln!("编码失败,可能是输入数据格式错误");
}
Err(code) => {
eprintln!("未知错误: {}", code);
}
}
```
## 6. 最佳实践
### 6.1 编码器选择策略
```rust
fn select_best_encoder(
width: i32,
height: i32,
format: DataFormat
) -> Option<String> {
let ctx = EncodeContext {
width,
height,
pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
// ... 其他参数
};
let encoders = Encoder::available_encoders(ctx, None);
// 按优先级排序,选择最佳
encoders.into_iter()
.filter(|e| e.format == format)
.min_by_key(|e| e.priority)
.map(|e| e.name)
}
```
### 6.2 帧内存布局
```rust
// 获取 NV12 帧布局信息
let (linesize, offset, length) = ffmpeg_linesize_offset_length(
AVPixelFormat::AV_PIX_FMT_NV12,
1920,
1080,
0, // align
)?;
// 分配缓冲区
let mut buffer = vec![0u8; length as usize];
// 填充 Y 平面: buffer[0..offset[0]]
// 填充 UV 平面: buffer[offset[0]..length]
```
### 6.3 关键帧控制
```rust
let mut frame_count = 0;
loop {
// 每 30 帧强制一个关键帧
if frame_count % 30 == 0 {
encoder.request_keyframe();
}
encoder.encode(&yuv_data, pts)?;
frame_count += 1;
}
```
### 6.4 线程安全
```rust
// Decoder 实现了 Send + Sync
unsafe impl Send for Decoder {}
unsafe impl Sync for Decoder {}
// 可以安全地在多线程间传递
let decoder = Arc::new(Mutex::new(Decoder::new(ctx)?));
```
## 7. IP-KVM 典型使用场景
### 7.1 视频采集和转码流程
```
USB 采集卡 (MJPEG)
┌─────────────────┐
│ MJPEG Decoder │ ◄── Decoder::new("mjpeg")
│ (软件解码) │
└────────┬────────┘
│ YUV420P
┌─────────────────┐
│ H264 Encoder │ ◄── Encoder::new("h264_vaapi")
│ (硬件加速) │
└────────┬────────┘
│ H264 NAL
WebRTC 传输
```
### 7.2 完整示例
```rust
use hwcodec::ffmpeg_ram::decode::{Decoder, DecodeContext};
use hwcodec::ffmpeg_ram::encode::{Encoder, EncodeContext};
use hwcodec::ffmpeg::AVHWDeviceType;
// 创建 MJPEG 解码器
let decode_ctx = DecodeContext {
name: "mjpeg".to_string(),
device_type: AVHWDeviceType::AV_HWDEVICE_TYPE_NONE,
thread_count: 4,
};
let mut decoder = Decoder::new(decode_ctx)?;
// 检测并选择最佳编码器
let encode_ctx = EncodeContext {
name: String::new(),
width: 1920,
height: 1080,
// ...
};
let available = Encoder::available_encoders(encode_ctx.clone(), None);
let best_h264 = available.iter()
.filter(|e| e.format == DataFormat::H264)
.min_by_key(|e| e.priority)
.expect("No H264 encoder available");
// 使用最佳编码器创建实例
let encode_ctx = EncodeContext {
name: best_h264.name.clone(),
..encode_ctx
};
let mut encoder = Encoder::new(encode_ctx)?;
// 处理循环
loop {
let mjpeg_frame = capture_frame();
// 解码 MJPEG -> YUV
let decoded = decoder.decode(&mjpeg_frame)?;
// 编码 YUV -> H264
for frame in decoded {
let yuv_data = frame.data.concat();
let encoded = encoder.encode(&yuv_data, pts)?;
// 发送编码数据
for packet in encoded {
send_to_webrtc(packet.data);
}
}
}
```

View File

@@ -1,561 +0,0 @@
# hwcodec 硬件加速详解
## 1. 硬件加速架构
### 1.1 整体流程
```
┌─────────────────────────────────────────────────────────────┐
│ 应用层 (Rust) │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Encoder::available_encoders() → 自动检测可用硬件编码器 ││
│ └─────────────────────────────────────────────────────────┘│
└────────────────────────────┬────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 硬件检测层 (C++) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐│
│ │linux_ │ │linux_ │ │linux_ │ │linux_support_ ││
│ │support_nv│ │support_ │ │support_ │ │rkmpp/v4l2m2m ││
│ └────┬─────┘ │amd │ │intel │ └─────────┬────────┘│
│ │ └────┬─────┘ └────┬─────┘ │ │
└───────┼────────────┼────────────┼─────────────────┼─────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────────────┐
│ CUDA/ │ │ AMF │ │ VPL/MFX │ │ 设备节点检测 │
│ NVENC │ │ Runtime │ │ Library │ │ /dev/mpp_service │
│ 动态库 │ │ 动态库 │ │ 动态库 │ │ /dev/video* │
└───────────┘ └───────────┘ └───────────┘ └───────────────────┘
```
### 1.2 编码器测试验证
每个检测到的硬件编码器都会进行实际编码测试:
```rust
// libs/hwcodec/src/ffmpeg_ram/encode.rs
// 生成测试用 YUV 数据
let yuv = Encoder::dummy_yuv(ctx.clone())?;
// 尝试创建编码器并编码测试帧
match Encoder::new(c) {
Ok(mut encoder) => {
let start = std::time::Instant::now();
match encoder.encode(&yuv, 0) {
Ok(frames) => {
let elapsed = start.elapsed().as_millis();
// 验证: 必须产生 1 帧且为关键帧,且在超时时间内完成
if frames.len() == 1 && frames[0].key == 1
&& elapsed < TEST_TIMEOUT_MS {
res.push(codec);
}
}
Err(_) => { /* 编码失败,跳过 */ }
}
}
Err(_) => { /* 创建失败,跳过 */ }
}
```
## 2. NVIDIA NVENC/NVDEC
### 2.1 检测机制 (Linux)
使用简化的动态库检测方法,无需 CUDA SDK 依赖:
```cpp
// libs/hwcodec/cpp/common/platform/linux/linux.cpp
int linux_support_nv() {
// 检测 CUDA 运行时库
void *handle = dlopen("libcuda.so.1", RTLD_LAZY);
if (!handle) {
handle = dlopen("libcuda.so", RTLD_LAZY);
}
if (!handle) {
LOG_TRACE("NVIDIA: libcuda.so not found");
return -1;
}
dlclose(handle);
// 检测 NVENC 编码库
handle = dlopen("libnvidia-encode.so.1", RTLD_LAZY);
if (!handle) {
handle = dlopen("libnvidia-encode.so", RTLD_LAZY);
}
if (!handle) {
LOG_TRACE("NVIDIA: libnvidia-encode.so not found");
return -1;
}
dlclose(handle);
LOG_TRACE("NVIDIA: driver support detected");
return 0;
}
```
### 2.2 编码配置
```cpp
// libs/hwcodec/cpp/common/util.cpp
// NVENC 低延迟配置
if (name.find("nvenc") != std::string::npos) {
// 禁用编码延迟
av_opt_set(priv_data, "delay", "0", 0);
}
// GPU 选择
if (name.find("nvenc") != std::string::npos) {
av_opt_set_int(priv_data, "gpu", gpu_index, 0);
}
// 质量预设
switch (quality) {
case Quality_Medium:
av_opt_set(priv_data, "preset", "p4", 0);
break;
case Quality_Low:
av_opt_set(priv_data, "preset", "p1", 0);
break;
}
// 码率控制
av_opt_set(priv_data, "rc", "cbr", 0); // 或 "vbr"
```
### 2.3 环境变量
| 变量 | 说明 |
|------|------|
| `RUSTDESK_HWCODEC_NVENC_GPU` | 指定使用的 GPU 索引 (-1 = 自动) |
### 2.4 依赖库
- `libcuda.so` / `libcuda.so.1` - CUDA 运行时
- `libnvidia-encode.so` / `libnvidia-encode.so.1` - NVENC 编码器
## 3. AMD AMF
### 3.1 检测机制 (Linux)
```cpp
// libs/hwcodec/cpp/common/platform/linux/linux.cpp
int linux_support_amd() {
#if defined(__x86_64__) || defined(__aarch64__)
#define AMF_DLL_NAMEA "libamfrt64.so.1"
#else
#define AMF_DLL_NAMEA "libamfrt32.so.1"
#endif
void *handle = dlopen(AMF_DLL_NAMEA, RTLD_LAZY);
if (!handle) {
return -1; // AMF 不可用
}
dlclose(handle);
return 0; // AMF 可用
}
```
### 3.2 编码配置
```cpp
// libs/hwcodec/cpp/common/util.cpp
// AMF 低延迟配置
if (name.find("amf") != std::string::npos) {
av_opt_set(priv_data, "query_timeout", "1000", 0);
}
// 质量预设
switch (quality) {
case Quality_High:
av_opt_set(priv_data, "quality", "quality", 0);
break;
case Quality_Medium:
av_opt_set(priv_data, "quality", "balanced", 0);
break;
case Quality_Low:
av_opt_set(priv_data, "quality", "speed", 0);
break;
}
// 码率控制
av_opt_set(priv_data, "rc", "cbr", 0); // 恒定码率
av_opt_set(priv_data, "rc", "vbr_latency", 0); // 低延迟 VBR
```
### 3.3 依赖库
- `libamfrt64.so.1` (64位) 或 `libamfrt32.so.1` (32位)
## 4. Intel QSV/MFX
### 4.1 检测机制 (Linux)
```cpp
// libs/hwcodec/cpp/common/platform/linux/linux.cpp
int linux_support_intel() {
const char *libs[] = {
"libvpl.so", // oneVPL (新版)
"libmfx.so", // Media SDK
"libmfx-gen.so.1.2", // 新驱动
"libmfxhw64.so.1" // 旧版驱动
};
for (size_t i = 0; i < sizeof(libs) / sizeof(libs[0]); i++) {
void *handle = dlopen(libs[i], RTLD_LAZY);
if (handle) {
dlclose(handle);
return 0; // 找到可用库
}
}
return -1; // Intel MFX 不可用
}
```
### 4.2 编码配置
```cpp
// libs/hwcodec/cpp/common/util.cpp
// QSV 低延迟配置
if (name.find("qsv") != std::string::npos) {
av_opt_set(priv_data, "async_depth", "1", 0);
}
// QSV 特殊码率配置
if (name.find("qsv") != std::string::npos) {
c->rc_max_rate = c->bit_rate;
c->bit_rate--; // 实现 CBR 效果
}
// 质量预设
switch (quality) {
case Quality_High:
av_opt_set(priv_data, "preset", "veryslow", 0);
break;
case Quality_Medium:
av_opt_set(priv_data, "preset", "medium", 0);
break;
case Quality_Low:
av_opt_set(priv_data, "preset", "veryfast", 0);
break;
}
// 严格标准兼容性 (用于某些特殊设置)
c->strict_std_compliance = FF_COMPLIANCE_UNOFFICIAL;
```
### 4.3 限制
- QSV 不支持 `YUV420P` 像素格式,必须使用 `NV12`
- 在 One-KVM 简化版中仅 Windows 平台完全支持
## 5. VAAPI (Linux)
### 5.1 工作原理
VAAPI (Video Acceleration API) 是 Linux 上的通用硬件视频加速接口:
```
┌─────────────────────────────────────────────────────────────┐
│ Application │
├─────────────────────────────────────────────────────────────┤
│ FFmpeg libavcodec │
├─────────────────────────────────────────────────────────────┤
│ VAAPI (libva) │
├──────────────┬──────────────┬──────────────┬────────────────┤
│ Intel i965 │ Intel iHD │ AMD radeonsi │ NVIDIA VDPAU │
│ (Gen8-) │ (Gen9+) │ │ (via wrapper) │
├──────────────┴──────────────┴──────────────┴────────────────┤
│ Kernel DRM Driver │
├──────────────┬──────────────┬──────────────┬────────────────┤
│ i915 │ amdgpu │ nvidia │ ... │
└──────────────┴──────────────┴──────────────┴────────────────┘
```
### 5.2 编码配置
```cpp
// libs/hwcodec/cpp/common/util.cpp
// VAAPI 低延迟配置
if (name.find("vaapi") != std::string::npos) {
av_opt_set(priv_data, "async_depth", "1", 0);
}
```
### 5.3 硬件上下文初始化
```cpp
// libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp
// 检测 VAAPI 编码器
if (name_.find("vaapi") != std::string::npos) {
hw_device_type_ = AV_HWDEVICE_TYPE_VAAPI;
hw_pixfmt_ = AV_PIX_FMT_VAAPI;
}
// 创建硬件设备上下文
ret = av_hwdevice_ctx_create(&hw_device_ctx_, hw_device_type_,
NULL, // 使用默认设备
NULL, 0);
// 设置硬件帧上下文
set_hwframe_ctx();
// 分配硬件帧
hw_frame_ = av_frame_alloc();
av_hwframe_get_buffer(c_->hw_frames_ctx, hw_frame_, 0);
```
### 5.4 编码流程
```
输入 YUV (CPU 内存)
av_hwframe_transfer_data(hw_frame_, frame_, 0) // CPU → GPU
avcodec_send_frame(c_, hw_frame_) // 发送 GPU 帧
avcodec_receive_packet(c_, pkt_) // 获取编码数据
编码数据 (CPU 内存)
```
### 5.5 依赖库
- `libva.so` - VAAPI 核心库
- `libva-drm.so` - DRM 后端
- `libva-x11.so` - X11 后端 (可选)
## 6. Rockchip MPP
### 6.1 检测机制
```cpp
// libs/hwcodec/cpp/common/platform/linux/linux.cpp
int linux_support_rkmpp() {
// 检测 MPP 服务设备
if (access("/dev/mpp_service", F_OK) == 0) {
LOG_TRACE("RKMPP: Found /dev/mpp_service");
return 0; // MPP 可用
}
// 备用: 检测 RGA 设备
if (access("/dev/rga", F_OK) == 0) {
LOG_TRACE("RKMPP: Found /dev/rga");
return 0; // MPP 可能可用
}
LOG_TRACE("RKMPP: No Rockchip MPP device found");
return -1; // MPP 不可用
}
```
### 6.2 支持的编码器
| 编码器 | 优先级 | 说明 |
|--------|--------|------|
| `h264_rkmpp` | Best (0) | H.264 硬件编码 |
| `hevc_rkmpp` | Best (0) | H.265 硬件编码 |
### 6.3 适用设备
- Rockchip RK3328 (Onecloud, Chainedbox)
- Rockchip RK3399/RK3588 系列
- 其他 Rockchip SoC
## 7. V4L2 M2M
### 7.1 检测机制
```cpp
// libs/hwcodec/cpp/common/platform/linux/linux.cpp
int linux_support_v4l2m2m() {
const char *m2m_devices[] = {
"/dev/video10", // 常见 M2M 编码设备
"/dev/video11", // 常见 M2M 解码设备
"/dev/video0", // 某些 SoC 使用
};
for (size_t i = 0; i < sizeof(m2m_devices) / sizeof(m2m_devices[0]); i++) {
if (access(m2m_devices[i], F_OK) == 0) {
int fd = open(m2m_devices[i], O_RDWR | O_NONBLOCK);
if (fd >= 0) {
close(fd);
LOG_TRACE("V4L2 M2M: Found device " + m2m_devices[i]);
return 0; // V4L2 M2M 可用
}
}
}
LOG_TRACE("V4L2 M2M: No M2M device found");
return -1;
}
```
### 7.2 支持的编码器
| 编码器 | 优先级 | 说明 |
|--------|--------|------|
| `h264_v4l2m2m` | Good (1) | H.264 V4L2 编码 |
| `hevc_v4l2m2m` | Good (1) | H.265 V4L2 编码 |
### 7.3 适用设备
- 通用 ARM SoC (Allwinner, Amlogic 等)
- 支持 V4L2 M2M API 的设备
## 8. 硬件加速优先级
### 8.1 优先级定义
```rust
pub enum Priority {
Best = 0, // 专用硬件编码器
Good = 1, // 通用硬件加速
Normal = 2, // 基本硬件支持
Soft = 3, // 软件编码
Bad = 4, // 最低优先级
}
```
### 8.2 各编码器优先级
| 优先级 | 编码器 |
|--------|--------|
| Best (0) | nvenc, amf, qsv, rkmpp |
| Good (1) | vaapi, v4l2m2m |
| Soft (3) | x264, x265, libvpx |
### 8.3 选择策略
```rust
// libs/hwcodec/src/ffmpeg_ram/mod.rs
pub fn prioritized(coders: Vec<CodecInfo>) -> CodecInfos {
// 对于每种格式,选择优先级最高的编码器
for coder in coders {
match coder.format {
DataFormat::H264 => {
if h264.is_none() || h264.priority > coder.priority {
h264 = Some(coder);
}
}
// ... 其他格式类似
}
}
}
```
## 9. 故障排除
### 9.1 NVIDIA
```bash
# 检查 NVIDIA 驱动
nvidia-smi
# 检查 NVENC 支持
ls /dev/nvidia*
# 检查 CUDA 库
ldconfig -p | grep cuda
ldconfig -p | grep nvidia-encode
```
### 9.2 AMD
```bash
# 检查 AMD 驱动
lspci | grep AMD
# 检查 AMF 库
ldconfig -p | grep amf
```
### 9.3 Intel
```bash
# 检查 Intel 驱动
vainfo
# 检查 MFX 库
ldconfig -p | grep mfx
ldconfig -p | grep vpl
```
### 9.4 VAAPI
```bash
# 安装 vainfo
sudo apt install vainfo
# 检查 VAAPI 支持
vainfo
# 输出示例:
# libva info: VA-API version 1.14.0
# libva info: Trying to open /usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so
# vainfo: Driver version: Intel iHD driver for Intel(R) Gen Graphics
# vainfo: Supported profile and entrypoints
# VAProfileH264Main : VAEntrypointVLD
# VAProfileH264Main : VAEntrypointEncSlice
# ...
```
### 9.5 Rockchip MPP
```bash
# 检查 MPP 设备
ls -la /dev/mpp_service
ls -la /dev/rga
# 检查 MPP 库
ldconfig -p | grep rockchip_mpp
```
### 9.6 V4L2 M2M
```bash
# 列出 V4L2 设备
v4l2-ctl --list-devices
# 检查设备能力
v4l2-ctl -d /dev/video10 --all
```
## 10. 性能优化建议
### 10.1 编码器选择
1. **优先使用硬件编码**: NVENC > AMF > QSV > VAAPI > V4L2 M2M > 软件
2. **ARM 设备**: 优先检测 RKMPP其次 V4L2 M2M
3. **x86 设备**: 根据 GPU 厂商自动选择
### 10.2 低延迟配置
所有硬件编码器都启用了低延迟优化:
| 编码器 | 配置 |
|--------|------|
| NVENC | `delay=0` |
| AMF | `query_timeout=1000` |
| QSV | `async_depth=1` |
| VAAPI | `async_depth=1` |
| libvpx | `deadline=realtime`, `cpu-used=6` |
### 10.3 码率控制
- **实时流**: 推荐 CBR 模式,保证稳定码率
- **GOP 大小**: 建议 30-60 帧 (1-2秒),平衡延迟和压缩效率

View File

@@ -1,477 +0,0 @@
# hwcodec 构建系统与集成指南
## 1. 项目结构
```
libs/hwcodec/
├── Cargo.toml # 包配置
├── Cargo.lock # 依赖锁定
├── build.rs # 构建脚本
└── src/ # Rust 源码
├── lib.rs # 库入口
├── common.rs # 公共定义
├── ffmpeg.rs # FFmpeg 集成
└── ffmpeg_ram/ # RAM 编解码
├── mod.rs
├── encode.rs
└── decode.rs
└── cpp/ # C++ 源码
├── common/ # 公共代码
│ ├── log.cpp
│ ├── log.h
│ ├── util.cpp
│ ├── util.h
│ ├── callback.h
│ ├── common.h
│ └── platform/
│ ├── linux/
│ │ ├── linux.cpp
│ │ └── linux.h
│ └── win/
│ ├── win.cpp
│ └── win.h
├── ffmpeg_ram/ # FFmpeg RAM 实现
│ ├── ffmpeg_ram_encode.cpp
│ ├── ffmpeg_ram_decode.cpp
│ └── ffmpeg_ram_ffi.h
└── yuv/ # YUV 处理
└── yuv.cpp
```
## 2. Cargo 配置
### 2.1 Cargo.toml
```toml
[package]
name = "hwcodec"
version = "0.8.0"
edition = "2021"
description = "Hardware video codec for IP-KVM (Windows/Linux)"
[features]
default = []
[dependencies]
log = "0.4" # 日志
serde_derive = "1.0" # 序列化派生宏
serde = "1.0" # 序列化
serde_json = "1.0" # JSON 序列化
[build-dependencies]
cc = "1.0" # C++ 编译
bindgen = "0.59" # FFI 绑定生成
[dev-dependencies]
env_logger = "0.10" # 日志输出
```
### 2.2 与原版的区别
| 特性 | 原版 (RustDesk) | 简化版 (One-KVM) |
|------|-----------------|------------------|
| `vram` feature | ✓ | ✗ (已移除) |
| 外部 SDK | 需要 | 不需要 |
| 版本号 | 0.7.1 | 0.8.0 |
| 目标平台 | Windows/Linux/macOS/Android | Windows/Linux |
### 2.3 使用方式
```toml
# 在 One-KVM 项目中使用
[dependencies]
hwcodec = { path = "libs/hwcodec" }
```
## 3. 构建脚本详解 (build.rs)
### 3.1 主入口
```rust
fn main() {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut builder = Build::new();
// 1. 构建公共模块
build_common(&mut builder);
// 2. 构建 FFmpeg 相关模块
ffmpeg::build_ffmpeg(&mut builder);
// 3. 编译生成静态库
builder.static_crt(true).compile("hwcodec");
}
```
### 3.2 公共模块构建
```rust
fn build_common(builder: &mut Build) {
let common_dir = manifest_dir.join("cpp").join("common");
// 生成 FFI 绑定
bindgen::builder()
.header(common_dir.join("common.h"))
.header(common_dir.join("callback.h"))
.rustified_enum("*")
.generate()
.write_to_file(OUT_DIR.join("common_ffi.rs"));
// 平台相关代码
#[cfg(windows)]
builder.file(common_dir.join("platform/win/win.cpp"));
#[cfg(target_os = "linux")]
builder.file(common_dir.join("platform/linux/linux.cpp"));
// 工具代码
builder.files([
common_dir.join("log.cpp"),
common_dir.join("util.cpp"),
]);
}
```
### 3.3 FFmpeg 模块构建
```rust
mod ffmpeg {
pub fn build_ffmpeg(builder: &mut Build) {
// 生成 FFmpeg FFI 绑定
ffmpeg_ffi();
// 链接 FFmpeg 库
if let Ok(vcpkg_root) = std::env::var("VCPKG_ROOT") {
link_vcpkg(builder, vcpkg_root.into());
} else {
link_system_ffmpeg(builder); // pkg-config
}
// 链接系统库
link_os();
// 构建 FFmpeg RAM 模块
build_ffmpeg_ram(builder);
}
}
```
### 3.4 FFmpeg 链接方式
#### VCPKG (跨平台静态链接)
```rust
fn link_vcpkg(builder: &mut Build, path: PathBuf) -> PathBuf {
// 目标平台识别
let target = match (target_os, target_arch) {
("windows", "x86_64") => "x64-windows-static",
("linux", arch) => format!("{}-linux", arch),
_ => panic!("unsupported platform"),
};
let lib_path = path.join("installed").join(target).join("lib");
// 链接 FFmpeg 静态库
println!("cargo:rustc-link-search=native={}", lib_path);
["avcodec", "avutil", "avformat"].iter()
.for_each(|lib| println!("cargo:rustc-link-lib=static={}", lib));
}
```
#### pkg-config (Linux 动态链接)
```rust
fn link_system_ffmpeg(builder: &mut Build) {
let libs = ["libavcodec", "libavutil", "libavformat", "libswscale"];
for lib in &libs {
// 获取编译标志
let cflags = Command::new("pkg-config")
.args(["--cflags", lib])
.output()?;
// 获取链接标志
let libs = Command::new("pkg-config")
.args(["--libs", lib])
.output()?;
// 解析并应用
for flag in libs.split_whitespace() {
if flag.starts_with("-L") {
println!("cargo:rustc-link-search=native={}", &flag[2..]);
} else if flag.starts_with("-l") {
println!("cargo:rustc-link-lib={}", &flag[2..]);
}
}
}
}
```
### 3.5 系统库链接
```rust
fn link_os() {
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
let libs: Vec<&str> = match target_os.as_str() {
"windows" => vec!["User32", "bcrypt", "ole32", "advapi32"],
"linux" => vec!["drm", "X11", "stdc++", "z"],
_ => panic!("unsupported os"),
};
for lib in libs {
println!("cargo:rustc-link-lib={}", lib);
}
}
```
## 4. FFI 绑定生成
### 4.1 bindgen 配置
```rust
bindgen::builder()
.header("path/to/header.h")
.rustified_enum("*") // 生成 Rust 枚举
.parse_callbacks(Box::new(Callbacks)) // 自定义回调
.generate()
.write_to_file(OUT_DIR.join("ffi.rs"));
```
### 4.2 自定义派生
```rust
#[derive(Debug)]
struct CommonCallbacks;
impl bindgen::callbacks::ParseCallbacks for CommonCallbacks {
fn add_derives(&self, name: &str) -> Vec<String> {
// 为特定类型添加序列化支持
match name {
"DataFormat" | "SurfaceFormat" | "API" => {
vec!["Serialize".to_string(), "Deserialize".to_string()]
}
_ => vec![],
}
}
}
```
### 4.3 生成的文件
| 文件 | 来源 | 内容 |
|------|------|------|
| `common_ffi.rs` | `common.h`, `callback.h` | 枚举、常量、回调类型 |
| `ffmpeg_ffi.rs` | `ffmpeg_ffi.h` | FFmpeg 日志级别、函数 |
| `ffmpeg_ram_ffi.rs` | `ffmpeg_ram_ffi.h` | 编解码器函数 |
## 5. 平台构建指南
### 5.1 Linux 构建
```bash
# 安装 FFmpeg 开发库
sudo apt install libavcodec-dev libavformat-dev libavutil-dev libswscale-dev
# 安装其他依赖
sudo apt install libdrm-dev libx11-dev pkg-config
# 安装 clang (bindgen 需要)
sudo apt install clang libclang-dev
# 构建
cargo build --release -p hwcodec
```
### 5.2 Windows 构建 (VCPKG)
```powershell
# 安装 VCPKG
git clone https://github.com/microsoft/vcpkg
cd vcpkg
./bootstrap-vcpkg.bat
# 安装 FFmpeg
./vcpkg install ffmpeg:x64-windows-static
# 设置环境变量
$env:VCPKG_ROOT = "C:\path\to\vcpkg"
# 构建
cargo build --release -p hwcodec
```
### 5.3 交叉编译
```bash
# 安装 cross
cargo install cross --git https://github.com/cross-rs/cross
# ARM64 Linux
cross build --release -p hwcodec --target aarch64-unknown-linux-gnu
# ARMv7 Linux
cross build --release -p hwcodec --target armv7-unknown-linux-gnueabihf
```
## 6. 集成到 One-KVM
### 6.1 依赖配置
```toml
# Cargo.toml
[dependencies]
hwcodec = { path = "libs/hwcodec" }
```
### 6.2 使用示例
```rust
use hwcodec::ffmpeg_ram::encode::{Encoder, EncodeContext};
use hwcodec::ffmpeg_ram::decode::{Decoder, DecodeContext};
use hwcodec::ffmpeg::{AVPixelFormat, AVHWDeviceType};
// 检测可用编码器
let encoders = Encoder::available_encoders(ctx, None);
// 创建编码器
let encoder = Encoder::new(EncodeContext {
name: "h264_vaapi".to_string(),
width: 1920,
height: 1080,
pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
fps: 30,
gop: 30,
kbs: 4000,
// ...
})?;
// 编码
let frames = encoder.encode(&yuv_data, pts_ms)?;
// 创建 MJPEG 解码器 (IP-KVM 专用)
let decoder = Decoder::new(DecodeContext {
name: "mjpeg".to_string(),
device_type: AVHWDeviceType::AV_HWDEVICE_TYPE_NONE,
thread_count: 4,
})?;
// 解码
let frames = decoder.decode(&mjpeg_data)?;
```
### 6.3 日志集成
```rust
// hwcodec 使用 log crate与 One-KVM 日志系统兼容
use log::{debug, info, warn, error};
// C++ 层日志通过回调传递到 Rust
#[no_mangle]
pub extern "C" fn hwcodec_av_log_callback(level: i32, message: *const c_char) {
// 转发到 Rust log 系统
match level {
AV_LOG_ERROR => error!("{}", message),
AV_LOG_WARNING => warn!("{}", message),
AV_LOG_INFO => info!("{}", message),
AV_LOG_DEBUG => debug!("{}", message),
_ => {}
}
}
```
## 7. 故障排除
### 7.1 编译错误
**FFmpeg 未找到**:
```
error: pkg-config failed for libavcodec
```
解决: 安装 FFmpeg 开发库
```bash
sudo apt install libavcodec-dev libavformat-dev libavutil-dev libswscale-dev
```
**bindgen 错误**:
```
error: failed to run custom build command for `hwcodec`
```
解决: 安装 clang
```bash
sudo apt install clang libclang-dev
```
### 7.2 链接错误
**符号未定义**:
```
undefined reference to `av_log_set_level'
```
解决: 检查 FFmpeg 库链接顺序,确保 pkg-config 正确配置
**动态库未找到**:
```
error while loading shared libraries: libavcodec.so.59
```
解决:
```bash
sudo ldconfig
# 或设置 LD_LIBRARY_PATH
export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH
```
### 7.3 运行时错误
**硬件编码器不可用**:
```
Encoder h264_vaapi test failed
```
检查:
1. 驱动是否正确安装: `vainfo`
2. 权限是否足够: `ls -la /dev/dri/`
3. 用户是否在 video 组: `groups`
**解码失败**:
```
avcodec_receive_frame failed, ret = -11
```
解决: 这通常表示需要更多输入数据 (EAGAIN),是正常行为
## 8. 与原版 RustDesk hwcodec 的构建差异
### 8.1 移除的构建步骤
| 步骤 | 原因 |
|------|------|
| `build_mux()` | 移除了 Mux 模块 |
| `build_ffmpeg_vram()` | 移除了 VRAM 模块 |
| `sdk::build_sdk()` | 移除了外部 SDK 依赖 |
| macOS 框架链接 | 移除了 macOS 支持 |
| Android NDK 链接 | 移除了 Android 支持 |
### 8.2 简化的构建流程
```
原版构建流程:
build.rs
├── build_common()
├── ffmpeg::build_ffmpeg()
│ ├── build_ffmpeg_ram()
│ ├── build_ffmpeg_vram() [已移除]
│ └── build_mux() [已移除]
└── sdk::build_sdk() [已移除]
简化版构建流程:
build.rs
├── build_common()
└── ffmpeg::build_ffmpeg()
└── build_ffmpeg_ram()
```
### 8.3 优势
1. **更快的编译**: 无需编译外部 SDK 代码
2. **更少的依赖**: 无需下载 ~9MB 的外部 SDK
3. **更简单的维护**: 代码量减少约 67%
4. **更小的二进制**: 不包含未使用的功能

View File

@@ -1,69 +0,0 @@
# RustDesk 通信协议技术报告
## 概述
本报告详细分析 RustDesk 远程桌面软件的客户端与服务器之间的通信协议,包括 Rendezvous 服务器hbbs、Relay 服务器hbbr以及客户端之间的 P2P 连接机制。
## 文档结构
| 文档 | 内容 |
|------|------|
| [01-architecture.md](01-architecture.md) | 整体架构设计 |
| [02-rendezvous-protocol.md](02-rendezvous-protocol.md) | Rendezvous 服务器协议 |
| [03-relay-protocol.md](03-relay-protocol.md) | Relay 服务器协议 |
| [04-p2p-connection.md](04-p2p-connection.md) | P2P 连接流程 |
| [05-message-format.md](05-message-format.md) | 消息格式定义 |
| [06-encryption.md](06-encryption.md) | 加密机制 |
| [07-nat-traversal.md](07-nat-traversal.md) | NAT 穿透技术 |
| [08-onekvm-comparison.md](08-onekvm-comparison.md) | **One-KVM 实现对比分析** |
## 核心组件
### 1. Rendezvous Server (hbbs)
- **功能**: ID 注册、Peer 发现、NAT 类型检测、连接协调
- **端口**: 21116 (TCP/UDP), 21115 (NAT 测试), 21118 (WebSocket)
- **源文件**: `rustdesk-server/src/rendezvous_server.rs`
### 2. Relay Server (hbbr)
- **功能**: 当 P2P 连接失败时提供数据中转
- **端口**: 21117 (TCP), 21119 (WebSocket)
- **源文件**: `rustdesk-server/src/relay_server.rs`
### 3. 客户端 (RustDesk)
- **功能**: 远程桌面控制、文件传输、屏幕共享
- **核心模块**:
- `rendezvous_mediator.rs` - 与 Rendezvous 服务器通信
- `client.rs` - 客户端连接逻辑
- `server/connection.rs` - 被控端连接处理
## 协议栈
```
┌─────────────────────────────────────────┐
│ Application Layer │
│ (Video/Audio/Keyboard/Mouse/File) │
├─────────────────────────────────────────┤
│ Message Layer │
│ (Protobuf Messages) │
├─────────────────────────────────────────┤
│ Security Layer │
│ (Sodium: X25519 + ChaCha20) │
├─────────────────────────────────────────┤
│ Transport Layer │
│ (TCP/UDP/WebSocket/KCP) │
└─────────────────────────────────────────┘
```
## 关键技术特点
1. **混合连接模式**: 优先尝试 P2P 直连,失败后自动切换到 Relay 中转
2. **多协议支持**: TCP、UDP、WebSocket、KCP
3. **端到端加密**: 使用 libsodium 实现的 X25519 密钥交换和 ChaCha20-Poly1305 对称加密
4. **NAT 穿透**: 支持 UDP 打洞和 TCP 打洞技术
5. **服务器签名**: 可选的服务器公钥签名验证,防止中间人攻击
## 版本信息
- 分析基于 RustDesk 最新版本源码
- Protocol Buffer 版本: proto3
- 加密库: libsodium (sodiumoxide)

View File

@@ -1,218 +0,0 @@
# RustDesk 架构设计
## 系统架构图
```
┌──────────────────────┐
│ Rendezvous Server │
│ (hbbs) │
│ Port: 21116 │
└──────────┬───────────┘
┌──────────────────────────┼──────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Client A │ │ Client B │ │ Client C │
│ (控制端) │ │ (被控端) │ │ (被控端) │
└───────┬───────┘ └───────┬───────┘ └───────────────┘
│ │
│ P2P Connection │
│◄────────────────────────►│
│ │
│ (如果 P2P 失败) │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
└─►│ Relay Server │◄──────┘
│ (hbbr) │
│ Port: 21117 │
└───────────────┘
```
## 服务器组件详解
### Rendezvous Server (hbbs)
**监听端口:**
| 端口 | 协议 | 用途 |
|------|------|------|
| 21116 | TCP | 主要通信端口,处理 punch hole 请求 |
| 21116 | UDP | Peer 注册、NAT 类型检测 |
| 21115 | TCP | NAT 测试专用端口 |
| 21118 | WebSocket | Web 客户端支持 |
**核心数据结构:**
```rust
// rustdesk-server/src/rendezvous_server.rs:64-83
pub struct RendezvousServer {
tcp_punch: Arc<Mutex<HashMap<SocketAddr, Sink>>>, // TCP punch hole 连接
pm: PeerMap, // Peer 映射表
tx: Sender, // 消息发送通道
relay_servers: Arc<RelayServers>, // 可用 Relay 服务器列表
relay_servers0: Arc<RelayServers>, // 原始 Relay 服务器列表
rendezvous_servers: Arc<Vec<String>>, // Rendezvous 服务器列表
inner: Arc<Inner>, // 内部配置
}
struct Inner {
serial: i32, // 配置序列号
version: String, // 软件版本
software_url: String, // 软件更新 URL
mask: Option<Ipv4Network>, // LAN 掩码
local_ip: String, // 本地 IP
sk: Option<sign::SecretKey>, // 服务器签名密钥
}
```
**Peer 数据结构:**
```rust
// rustdesk-server/src/peer.rs:32-42
pub struct Peer {
pub socket_addr: SocketAddr, // 最后注册的地址
pub last_reg_time: Instant, // 最后注册时间
pub guid: Vec<u8>, // 数据库 GUID
pub uuid: Bytes, // 设备 UUID
pub pk: Bytes, // 公钥
pub info: PeerInfo, // Peer 信息
pub reg_pk: (u32, Instant), // 注册频率限制
}
```
### Relay Server (hbbr)
**监听端口:**
| 端口 | 协议 | 用途 |
|------|------|------|
| 21117 | TCP | 主要中转端口 |
| 21119 | WebSocket | Web 客户端支持 |
**核心特性:**
```rust
// rustdesk-server/src/relay_server.rs:40-44
static DOWNGRADE_THRESHOLD_100: AtomicUsize = AtomicUsize::new(66); // 降级阈值
static DOWNGRADE_START_CHECK: AtomicUsize = AtomicUsize::new(1_800_000); // 检测开始时间(ms)
static LIMIT_SPEED: AtomicUsize = AtomicUsize::new(32 * 1024 * 1024); // 限速(bit/s)
static TOTAL_BANDWIDTH: AtomicUsize = AtomicUsize::new(1024 * 1024 * 1024);// 总带宽
static SINGLE_BANDWIDTH: AtomicUsize = AtomicUsize::new(128 * 1024 * 1024);// 单连接带宽
```
## 客户端架构
### 核心模块
```
rustdesk/src/
├── rendezvous_mediator.rs # Rendezvous 服务器通信
├── client.rs # 控制端核心逻辑
├── server/
│ ├── mod.rs # 被控端服务
│ ├── connection.rs # 连接处理
│ ├── video_service.rs # 视频服务
│ ├── audio_service.rs # 音频服务
│ └── input_service.rs # 输入服务
├── common.rs # 通用函数(加密、解密)
└── platform/ # 平台特定代码
```
### RendezvousMediator
```rust
// rustdesk/src/rendezvous_mediator.rs:44-50
pub struct RendezvousMediator {
addr: TargetAddr<'static>, // 服务器地址
host: String, // 服务器主机名
host_prefix: String, // 主机前缀
keep_alive: i32, // 保活间隔
}
```
**两种连接模式:**
1. **UDP 模式** (默认):
- 用于 Peer 注册和心跳
- 更低延迟
- 可能被某些防火墙阻止
2. **TCP 模式**:
- 用于代理环境
- WebSocket 模式
- 更可靠
## 连接流程概述
### 被控端启动流程
```
1. 生成设备 ID 和密钥对
2. 连接 Rendezvous Server
3. 发送 RegisterPeer 消息
4. 如果需要,发送 RegisterPk 注册公钥
5. 定期发送心跳保持在线状态
6. 等待 PunchHole 或 RequestRelay 请求
```
### 控制端连接流程
```
1. 输入目标设备 ID
2. 连接 Rendezvous Server
3. 发送 PunchHoleRequest 消息
4. 根据响应决定连接方式:
a. 直连 (P2P): 使用 PunchHole 信息尝试打洞
b. 局域网: 使用 LocalAddr 信息直连
c. 中转: 通过 Relay Server 连接
5. 建立安全加密通道
6. 发送 LoginRequest 进行身份验证
7. 开始远程控制会话
```
## 数据流
### 视频流
```
被控端 控制端
│ │
│ VideoFrame (H264/VP9/...) │
├─────────────────────────────────►│
│ │
│ 加密 → 传输 → 解密 → 解码 → 显示 │
```
### 输入流
```
控制端 被控端
│ │
│ MouseEvent/KeyEvent │
├─────────────────────────────────►│
│ │
│ 加密 → 传输 → 解密 → 模拟输入 │
```
## 高可用设计
### 多服务器支持
- 客户端可配置多个 Rendezvous Server
- 自动选择延迟最低的服务器
- 连接失败时自动切换备用服务器
### Relay Server 选择
- 支持配置多个 Relay Server
- 轮询算法分配负载
- 定期检查 Relay Server 可用性
### 重连机制
```rust
// 连接超时和重试参数
const REG_INTERVAL: i64 = 12_000; // 注册间隔 12 秒
const REG_TIMEOUT: i32 = 30_000; // 注册超时 30 秒
const CONNECT_TIMEOUT: u64 = 18_000; // 连接超时 18 秒
```

View File

@@ -1,438 +0,0 @@
# Rendezvous 服务器协议
## 概述
Rendezvous Serverhbbs是 RustDesk 的核心协调服务器,负责:
- Peer ID 注册和发现
- 公钥存储和分发
- NAT 类型检测
- P2P 连接协调(打洞辅助)
- Relay Server 分配
## 协议消息定义
所有消息使用 Protocol Buffers 定义在 `protos/rendezvous.proto`
```protobuf
message RendezvousMessage {
oneof union {
RegisterPeer register_peer = 6;
RegisterPeerResponse register_peer_response = 7;
PunchHoleRequest punch_hole_request = 8;
PunchHole punch_hole = 9;
PunchHoleSent punch_hole_sent = 10;
PunchHoleResponse punch_hole_response = 11;
FetchLocalAddr fetch_local_addr = 12;
LocalAddr local_addr = 13;
ConfigUpdate configure_update = 14;
RegisterPk register_pk = 15;
RegisterPkResponse register_pk_response = 16;
SoftwareUpdate software_update = 17;
RequestRelay request_relay = 18;
RelayResponse relay_response = 19;
TestNatRequest test_nat_request = 20;
TestNatResponse test_nat_response = 21;
PeerDiscovery peer_discovery = 22;
OnlineRequest online_request = 23;
OnlineResponse online_response = 24;
KeyExchange key_exchange = 25;
HealthCheck hc = 26;
}
}
```
## 核心流程
### 1. Peer 注册流程
**客户端 → 服务器RegisterPeer**
```protobuf
message RegisterPeer {
string id = 1; // Peer ID (如 "123456789")
int32 serial = 2; // 配置序列号
}
```
**服务器处理逻辑:**
```rust
// rustdesk-server/src/rendezvous_server.rs:318-333
Some(rendezvous_message::Union::RegisterPeer(rp)) => {
if !rp.id.is_empty() {
log::trace!("New peer registered: {:?} {:?}", &rp.id, &addr);
self.update_addr(rp.id, addr, socket).await?;
// 如果服务器配置更新,发送 ConfigUpdate
if self.inner.serial > rp.serial {
let mut msg_out = RendezvousMessage::new();
msg_out.set_configure_update(ConfigUpdate {
serial: self.inner.serial,
rendezvous_servers: (*self.rendezvous_servers).clone(),
..Default::default()
});
socket.send(&msg_out, addr).await?;
}
}
}
```
**服务器 → 客户端RegisterPeerResponse**
```protobuf
message RegisterPeerResponse {
bool request_pk = 2; // 是否需要注册公钥
}
```
### 2. 公钥注册流程
当服务器检测到 Peer 的公钥为空或 IP 变化时,会请求注册公钥。
**客户端 → 服务器RegisterPk**
```protobuf
message RegisterPk {
string id = 1; // Peer ID
bytes uuid = 2; // 设备 UUID
bytes pk = 3; // Ed25519 公钥
string old_id = 4; // 旧 ID如果更换
}
```
**服务器处理逻辑:**
```rust
// rustdesk-server/src/rendezvous_server.rs:334-418
Some(rendezvous_message::Union::RegisterPk(rk)) => {
// 验证 UUID 和公钥
if rk.uuid.is_empty() || rk.pk.is_empty() {
return Ok(());
}
let id = rk.id;
let ip = addr.ip().to_string();
// ID 长度检查
if id.len() < 6 {
return send_rk_res(socket, addr, UUID_MISMATCH).await;
}
// IP 封锁检查
if !self.check_ip_blocker(&ip, &id).await {
return send_rk_res(socket, addr, TOO_FREQUENT).await;
}
// UUID 匹配验证
let peer = self.pm.get_or(&id).await;
// ... UUID 验证逻辑 ...
// 更新数据库
if changed {
self.pm.update_pk(id, peer, addr, rk.uuid, rk.pk, ip).await;
}
// 发送成功响应
msg_out.set_register_pk_response(RegisterPkResponse {
result: register_pk_response::Result::OK.into(),
..Default::default()
});
}
```
**服务器 → 客户端RegisterPkResponse**
```protobuf
message RegisterPkResponse {
enum Result {
OK = 0;
UUID_MISMATCH = 2;
ID_EXISTS = 3;
TOO_FREQUENT = 4;
INVALID_ID_FORMAT = 5;
NOT_SUPPORT = 6;
SERVER_ERROR = 7;
}
Result result = 1;
int32 keep_alive = 2; // 心跳间隔
}
```
### 3. Punch Hole 请求流程
当控制端要连接被控端时,首先发送 PunchHoleRequest。
**控制端 → 服务器PunchHoleRequest**
```protobuf
message PunchHoleRequest {
string id = 1; // 目标 Peer ID
NatType nat_type = 2; // 请求方的 NAT 类型
string licence_key = 3; // 许可证密钥
ConnType conn_type = 4; // 连接类型
string token = 5; // 认证令牌
string version = 6; // 客户端版本
}
enum NatType {
UNKNOWN_NAT = 0;
ASYMMETRIC = 1;
SYMMETRIC = 2;
}
enum ConnType {
DEFAULT_CONN = 0;
FILE_TRANSFER = 1;
PORT_FORWARD = 2;
RDP = 3;
VIEW_CAMERA = 4;
}
```
**服务器处理逻辑:**
```rust
// rustdesk-server/src/rendezvous_server.rs:674-765
async fn handle_punch_hole_request(...) {
// 1. 验证许可证密钥
if !key.is_empty() && ph.licence_key != key {
return Ok((PunchHoleResponse { failure: LICENSE_MISMATCH }, None));
}
// 2. 查找目标 Peer
if let Some(peer) = self.pm.get(&id).await {
let (elapsed, peer_addr) = peer.read().await;
// 3. 检查在线状态
if elapsed >= REG_TIMEOUT {
return Ok((PunchHoleResponse { failure: OFFLINE }, None));
}
// 4. 判断是否同一局域网
let same_intranet = (peer_is_lan && is_lan) ||
(peer_addr.ip() == addr.ip());
if same_intranet {
// 请求获取本地地址
msg_out.set_fetch_local_addr(FetchLocalAddr {
socket_addr: AddrMangle::encode(addr).into(),
relay_server,
});
} else {
// 发送 Punch Hole 请求给被控端
msg_out.set_punch_hole(PunchHole {
socket_addr: AddrMangle::encode(addr).into(),
nat_type: ph.nat_type,
relay_server,
});
}
return Ok((msg_out, Some(peer_addr)));
}
// Peer 不存在
Ok((PunchHoleResponse { failure: ID_NOT_EXIST }, None))
}
```
**服务器 → 被控端PunchHole 或 FetchLocalAddr**
```protobuf
message PunchHole {
bytes socket_addr = 1; // 控制端地址(编码)
string relay_server = 2; // Relay 服务器地址
NatType nat_type = 3; // 控制端 NAT 类型
}
message FetchLocalAddr {
bytes socket_addr = 1; // 控制端地址(编码)
string relay_server = 2; // Relay 服务器地址
}
```
### 4. 被控端响应流程
**被控端 → 服务器PunchHoleSent 或 LocalAddr**
```protobuf
message PunchHoleSent {
bytes socket_addr = 1; // 控制端地址
string id = 2; // 被控端 ID
string relay_server = 3; // Relay 服务器
NatType nat_type = 4; // 被控端 NAT 类型
string version = 5; // 客户端版本
}
message LocalAddr {
bytes socket_addr = 1; // 控制端地址
bytes local_addr = 2; // 被控端本地地址
string relay_server = 3; // Relay 服务器
string id = 4; // 被控端 ID
string version = 5; // 客户端版本
}
```
**服务器 → 控制端PunchHoleResponse**
```protobuf
message PunchHoleResponse {
bytes socket_addr = 1; // 被控端地址
bytes pk = 2; // 被控端公钥(已签名)
enum Failure {
ID_NOT_EXIST = 0;
OFFLINE = 2;
LICENSE_MISMATCH = 3;
LICENSE_OVERUSE = 4;
}
Failure failure = 3;
string relay_server = 4;
oneof union {
NatType nat_type = 5;
bool is_local = 6; // 是否为局域网连接
}
string other_failure = 7;
int32 feedback = 8;
}
```
### 5. Relay 请求流程
当 P2P 连接失败或 NAT 类型不支持打洞时,使用 Relay。
**客户端 → 服务器RequestRelay**
```protobuf
message RequestRelay {
string id = 1; // 目标 Peer ID
string uuid = 2; // 连接 UUID用于配对
bytes socket_addr = 3; // 本端地址
string relay_server = 4; // 指定的 Relay 服务器
bool secure = 5; // 是否使用加密
string licence_key = 6; // 许可证密钥
ConnType conn_type = 7; // 连接类型
string token = 8; // 认证令牌
}
```
**服务器 → 客户端RelayResponse**
```protobuf
message RelayResponse {
bytes socket_addr = 1; // 对端地址
string uuid = 2; // 连接 UUID
string relay_server = 3; // Relay 服务器地址
oneof union {
string id = 4; // 对端 ID
bytes pk = 5; // 对端公钥
}
string refuse_reason = 6; // 拒绝原因
string version = 7; // 版本
int32 feedback = 9;
}
```
## NAT 类型检测
**客户端 → 服务器TestNatRequest**
```protobuf
message TestNatRequest {
int32 serial = 1; // 配置序列号
}
```
**服务器 → 客户端TestNatResponse**
```protobuf
message TestNatResponse {
int32 port = 1; // 观测到的源端口
ConfigUpdate cu = 2; // 配置更新
}
```
NAT 检测原理:
1. 客户端同时向主端口21116和 NAT 测试端口21115发送请求
2. 比较两次响应中观测到的源端口
3. 如果端口一致,则为 ASYMMETRIC NAT适合打洞
4. 如果端口不一致,则为 SYMMETRIC NAT需要 Relay
## 地址编码
RustDesk 使用 `AddrMangle` 对 SocketAddr 进行编码:
```rust
// 编码示例
// IPv4: 4 bytes IP + 2 bytes port = 6 bytes
// IPv6: 16 bytes IP + 2 bytes port = 18 bytes
pub fn encode(addr: SocketAddr) -> Vec<u8>;
pub fn decode(bytes: &[u8]) -> SocketAddr;
```
## 安全机制
### 服务器签名
当服务器配置了私钥时,会对 Peer 的公钥进行签名:
```rust
// rustdesk-server/src/rendezvous_server.rs:1160-1182
async fn get_pk(&mut self, version: &str, id: String) -> Bytes {
if version.is_empty() || self.inner.sk.is_none() {
Bytes::new()
} else {
match self.pm.get(&id).await {
Some(peer) => {
let pk = peer.read().await.pk.clone();
// 使用服务器私钥签名 IdPk
sign::sign(
&IdPk { id, pk, ..Default::default() }
.write_to_bytes()
.unwrap_or_default(),
self.inner.sk.as_ref().unwrap(),
).into()
}
_ => Bytes::new(),
}
}
}
```
### IP 封锁
服务器实现了 IP 封锁机制防止滥用:
```rust
// rustdesk-server/src/rendezvous_server.rs:866-894
async fn check_ip_blocker(&self, ip: &str, id: &str) -> bool {
let mut lock = IP_BLOCKER.lock().await;
if let Some(old) = lock.get_mut(ip) {
// 每秒请求超过 30 次则封锁
if counter.0 > 30 {
return false;
}
// 每天超过 300 个不同 ID 则封锁
if counter.0.len() > 300 {
return !is_new;
}
}
true
}
```
## 时序图
### 完整连接建立流程
```
控制端 Rendezvous Server 被控端
│ │ │
│ PunchHoleRequest │ │
├──────────────────────►│ │
│ │ PunchHole │
│ ├──────────────────────►│
│ │ │
│ │ PunchHoleSent │
│ │◄──────────────────────┤
│ PunchHoleResponse │ │
│◄──────────────────────┤ │
│ │ │
│ ─────────── P2P Connection ──────────────────│
│◄─────────────────────────────────────────────►│
```

View File

@@ -1,318 +0,0 @@
# Relay 服务器协议
## 概述
Relay Serverhbbr是 RustDesk 的数据中转服务器,当 P2P 连接无法建立时(如双方都在 Symmetric NAT 后面),所有通信数据通过 Relay Server 转发。
## 服务器架构
### 监听端口
| 端口 | 协议 | 用途 |
|------|------|------|
| 21117 | TCP | 主要中转端口 |
| 21119 | WebSocket | Web 客户端支持 |
### 核心配置
```rust
// rustdesk-server/src/relay_server.rs:40-46
static DOWNGRADE_THRESHOLD_100: AtomicUsize = AtomicUsize::new(66); // 0.66
static DOWNGRADE_START_CHECK: AtomicUsize = AtomicUsize::new(1_800_000); // 30分钟 (ms)
static LIMIT_SPEED: AtomicUsize = AtomicUsize::new(32 * 1024 * 1024); // 32 Mb/s
static TOTAL_BANDWIDTH: AtomicUsize = AtomicUsize::new(1024 * 1024 * 1024);// 1024 Mb/s
static SINGLE_BANDWIDTH: AtomicUsize = AtomicUsize::new(128 * 1024 * 1024);// 128 Mb/s
const BLACKLIST_FILE: &str = "blacklist.txt";
const BLOCKLIST_FILE: &str = "blocklist.txt";
```
## 连接配对机制
### 配对原理
Relay Server 使用 UUID 来配对两个客户端的连接:
1. 第一个客户端连接并发送 `RequestRelay` 消息(包含 UUID
2. 服务器将该连接存储在等待队列中
3. 第二个客户端使用相同的 UUID 连接
4. 服务器将两个连接配对,开始转发数据
### 配对流程
```rust
// rustdesk-server/src/relay_server.rs:425-462
async fn make_pair_(stream: impl StreamTrait, addr: SocketAddr, key: &str, limiter: Limiter) {
let mut stream = stream;
if let Ok(Some(Ok(bytes))) = timeout(30_000, stream.recv()).await {
if let Ok(msg_in) = RendezvousMessage::parse_from_bytes(&bytes) {
if let Some(rendezvous_message::Union::RequestRelay(rf)) = msg_in.union {
// 验证许可证密钥
if !key.is_empty() && rf.licence_key != key {
log::warn!("Relay authentication failed from {}", addr);
return;
}
if !rf.uuid.is_empty() {
// 尝试查找配对
let mut peer = PEERS.lock().await.remove(&rf.uuid);
if let Some(peer) = peer.as_mut() {
// 找到配对,开始中转
log::info!("Relay request {} got paired", rf.uuid);
relay(addr, &mut stream, peer, limiter).await;
} else {
// 没找到,存储等待配对
log::info!("New relay request {} from {}", rf.uuid, addr);
PEERS.lock().await.insert(rf.uuid.clone(), Box::new(stream));
sleep(30.).await; // 等待 30 秒
PEERS.lock().await.remove(&rf.uuid); // 超时移除
}
}
}
}
}
}
```
## 数据转发
### 转发逻辑
```rust
// rustdesk-server/src/relay_server.rs:464-566
async fn relay(
addr: SocketAddr,
stream: &mut impl StreamTrait,
peer: &mut Box<dyn StreamTrait>,
total_limiter: Limiter,
) -> ResultType<()> {
let limiter = <Limiter>::new(SINGLE_BANDWIDTH.load(Ordering::SeqCst) as f64);
let blacklist_limiter = <Limiter>::new(LIMIT_SPEED.load(Ordering::SeqCst) as _);
loop {
tokio::select! {
// 从 peer 接收数据,发送给 stream
res = peer.recv() => {
if let Some(Ok(bytes)) = res {
// 带宽限制
if blacked || downgrade {
blacklist_limiter.consume(bytes.len() * 8).await;
} else {
limiter.consume(bytes.len() * 8).await;
}
total_limiter.consume(bytes.len() * 8).await;
stream.send_raw(bytes.into()).await?;
} else {
break;
}
},
// 从 stream 接收数据,发送给 peer
res = stream.recv() => {
if let Some(Ok(bytes)) = res {
// 带宽限制
limiter.consume(bytes.len() * 8).await;
total_limiter.consume(bytes.len() * 8).await;
peer.send_raw(bytes.into()).await?;
} else {
break;
}
},
_ = timer.tick() => {
// 超时检测
if last_recv_time.elapsed().as_secs() > 30 {
bail!("Timeout");
}
}
}
// 降级检测
if elapsed > DOWNGRADE_START_CHECK && total > elapsed * downgrade_threshold {
downgrade = true;
log::info!("Downgrade {}, exceed threshold", id);
}
}
Ok(())
}
```
### 原始模式
当两端都支持原始模式时,跳过 protobuf 解析以提高性能:
```rust
// rustdesk-server/src/relay_server.rs:440-444
if !stream.is_ws() && !peer.is_ws() {
peer.set_raw();
stream.set_raw();
log::info!("Both are raw");
}
```
## 带宽控制
### 多级限速
1. **总带宽限制**:整个服务器的总带宽
2. **单连接限制**:每个中转连接的带宽
3. **黑名单限速**:对黑名单 IP 的特殊限制
### 降级机制
当连接持续占用高带宽时,会触发降级:
```rust
// 条件:
// 1. 连接时间 > DOWNGRADE_START_CHECK (30分钟)
// 2. 平均带宽 > SINGLE_BANDWIDTH * 0.66
// 降级后使用 LIMIT_SPEED (32 Mb/s) 限速
if elapsed > DOWNGRADE_START_CHECK.load(Ordering::SeqCst)
&& !downgrade
&& total > elapsed * downgrade_threshold
{
downgrade = true;
}
```
## 安全控制
### 黑名单
用于限速特定 IP
```
# blacklist.txt
192.168.1.100
10.0.0.50
```
### 封锁名单
用于完全拒绝特定 IP
```
# blocklist.txt
1.2.3.4
5.6.7.8
```
### 运行时管理命令
通过本地 TCP 连接(仅限 localhost发送命令
```rust
// rustdesk-server/src/relay_server.rs:152-324
match fds.next() {
Some("h") => // 帮助
Some("blacklist-add" | "ba") => // 添加黑名单
Some("blacklist-remove" | "br") => // 移除黑名单
Some("blacklist" | "b") => // 查看黑名单
Some("blocklist-add" | "Ba") => // 添加封锁名单
Some("blocklist-remove" | "Br") => // 移除封锁名单
Some("blocklist" | "B") => // 查看封锁名单
Some("downgrade-threshold" | "dt") => // 设置降级阈值
Some("downgrade-start-check" | "t") => // 设置降级检测时间
Some("limit-speed" | "ls") => // 设置限速
Some("total-bandwidth" | "tb") => // 设置总带宽
Some("single-bandwidth" | "sb") => // 设置单连接带宽
Some("usage" | "u") => // 查看使用统计
}
```
## 协议消息
### RequestRelay
用于建立中转连接的请求消息:
```protobuf
message RequestRelay {
string id = 1; // 目标 Peer ID
string uuid = 2; // 连接 UUID配对用
bytes socket_addr = 3; // 本端地址
string relay_server = 4; // Relay 服务器
bool secure = 5; // 是否加密
string licence_key = 6; // 许可证密钥
ConnType conn_type = 7; // 连接类型
string token = 8; // 认证令牌
}
```
## 时序图
### 中转连接建立
```
客户端 A Relay Server 客户端 B
│ │ │
│ RequestRelay(uuid) │ │
├─────────────────────────►│ │
│ │ │
│ │ (存储等待配对) │
│ │ │
│ │ RequestRelay(uuid) │
│ │◄───────────────────────────┤
│ │ │
│ │ (配对成功) │
│ │ │
│ ◄────────── 数据转发 ─────────────────────────────────►│
│ │ │
```
### 数据转发
```
客户端 A Relay Server 客户端 B
│ │ │
│ ────[数据]───────► │ │
│ │ ────[数据]───────► │
│ │ │
│ │ ◄───[数据]──────── │
│ ◄───[数据]──────── │ │
│ │ │
```
## 性能优化
### 零拷贝
使用 `Bytes` 类型减少内存拷贝:
```rust
async fn send_raw(&mut self, bytes: Bytes) -> ResultType<()>;
```
### WebSocket 支持
支持 WebSocket 协议以穿越防火墙:
```rust
#[async_trait]
impl StreamTrait for tokio_tungstenite::WebSocketStream<TcpStream> {
async fn recv(&mut self) -> Option<Result<BytesMut, Error>> {
if let Some(msg) = self.next().await {
match msg {
Ok(tungstenite::Message::Binary(bytes)) => {
Some(Ok(bytes[..].into()))
}
// ...
}
}
}
}
```
## 监控指标
服务器跟踪以下指标:
| 指标 | 说明 |
|------|------|
| elapsed | 连接持续时间 (ms) |
| total | 总传输数据量 (bit) |
| highest | 最高瞬时速率 (kb/s) |
| speed | 当前速率 (kb/s) |
通过 `usage` 命令查看:
```
192.168.1.100:12345: 3600s 1024.00MB 50000kb/s 45000kb/s 42000kb/s
```

View File

@@ -1,424 +0,0 @@
# P2P 连接流程
## 概述
RustDesk 优先尝试建立 P2P 直连,只有在直连失败时才使用 Relay 中转。P2P 连接支持多种方式:
- TCP 打洞
- UDP 打洞KCP
- 局域网直连
- IPv6 直连
## 连接决策流程
```
开始连接
┌──────────────┐
│ 是否强制 Relay
└──────┬───────┘
是 │ 否
┌─────────┴─────────┐
▼ ▼
使用 Relay 检查 NAT 类型
┌──────────────┴──────────────┐
│ │
▼ ▼
双方都是对称 NAT 有一方是可穿透 NAT
│ │
是 │ │
▼ ▼
使用 Relay 尝试 P2P 连接
┌─────────────┴─────────────┐
│ │
▼ ▼
同一局域网? 不同网络
│ │
是 │ │
▼ ▼
局域网直连 尝试打洞
┌──────────────┴──────────────┐
│ │
▼ ▼
TCP 打洞成功? UDP 打洞成功?
│ │
是 │ 否 是 │ 否
▼ │ ▼ │
TCP P2P 连接 └───────────► KCP P2P 连接 │
使用 Relay
```
## 客户端连接入口
```rust
// rustdesk/src/client.rs:188-230
impl Client {
pub async fn start(
peer: &str,
key: &str,
token: &str,
conn_type: ConnType,
interface: impl Interface,
) -> ResultType<...> {
// 检查是否为 IP 直连
if hbb_common::is_ip_str(peer) {
return connect_tcp_local(check_port(peer, RELAY_PORT + 1), None, CONNECT_TIMEOUT).await;
}
// 检查是否为域名:端口格式
if hbb_common::is_domain_port_str(peer) {
return connect_tcp_local(peer, None, CONNECT_TIMEOUT).await;
}
// 通过 Rendezvous Server 连接
let (rendezvous_server, servers, _) = crate::get_rendezvous_server(1_000).await;
Self::_start_inner(peer, key, token, conn_type, interface, rendezvous_server, servers).await
}
}
```
## 被控端处理连接请求
### 处理 PunchHole 消息
```rust
// rustdesk/src/rendezvous_mediator.rs:554-619
async fn handle_punch_hole(&self, ph: PunchHole, server: ServerPtr) -> ResultType<()> {
let peer_addr = AddrMangle::decode(&ph.socket_addr);
let relay_server = self.get_relay_server(ph.relay_server);
// 判断是否需要 Relay
if ph.nat_type.enum_value() == Ok(NatType::SYMMETRIC)
|| Config::get_nat_type() == NatType::SYMMETRIC as i32
|| relay
{
// 使用 Relay
let uuid = Uuid::new_v4().to_string();
return self.create_relay(ph.socket_addr, relay_server, uuid, server, true, true).await;
}
// 尝试 UDP 打洞
if ph.udp_port > 0 {
peer_addr.set_port(ph.udp_port as u16);
self.punch_udp_hole(peer_addr, server, msg_punch).await?;
return Ok(());
}
// 尝试 TCP 打洞
log::debug!("Punch tcp hole to {:?}", peer_addr);
let socket = {
let socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?;
let local_addr = socket.local_addr();
// 关键步骤:尝试连接对方,让 NAT 建立映射
allow_err!(socket_client::connect_tcp_local(peer_addr, Some(local_addr), 30).await);
socket
};
// 发送 PunchHoleSent 告知 Rendezvous Server
let mut msg_out = Message::new();
msg_out.set_punch_hole_sent(PunchHoleSent {
socket_addr: ph.socket_addr,
id: Config::get_id(),
relay_server,
nat_type: nat_type.into(),
version: crate::VERSION.to_owned(),
});
socket.send_raw(msg_out.write_to_bytes()?).await?;
// 接受控制端连接
crate::accept_connection(server.clone(), socket, peer_addr, true).await;
Ok(())
}
```
### 处理 FetchLocalAddr局域网连接
```rust
// rustdesk/src/rendezvous_mediator.rs:481-552
async fn handle_intranet(&self, fla: FetchLocalAddr, server: ServerPtr) -> ResultType<()> {
let peer_addr = AddrMangle::decode(&fla.socket_addr);
let relay_server = self.get_relay_server(fla.relay_server.clone());
// 尝试局域网直连
if is_ipv4(&self.addr) && !relay && !config::is_disable_tcp_listen() {
if let Err(err) = self.handle_intranet_(fla.clone(), server.clone(), relay_server.clone()).await {
log::debug!("Failed to handle intranet: {:?}, will try relay", err);
} else {
return Ok(());
}
}
// 局域网直连失败,使用 Relay
let uuid = Uuid::new_v4().to_string();
self.create_relay(fla.socket_addr, relay_server, uuid, server, true, true).await
}
async fn handle_intranet_(&self, fla: FetchLocalAddr, server: ServerPtr, relay_server: String) -> ResultType<()> {
let peer_addr = AddrMangle::decode(&fla.socket_addr);
let mut socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?;
let local_addr = socket.local_addr();
// 发送本地地址给 Rendezvous Server
let mut msg_out = Message::new();
msg_out.set_local_addr(LocalAddr {
id: Config::get_id(),
socket_addr: AddrMangle::encode(peer_addr).into(),
local_addr: AddrMangle::encode(local_addr).into(),
relay_server,
version: crate::VERSION.to_owned(),
});
socket.send_raw(msg_out.write_to_bytes()?).await?;
// 接受连接
crate::accept_connection(server.clone(), socket, peer_addr, true).await;
Ok(())
}
```
## UDP 打洞 (KCP)
### 打洞原理
UDP 打洞利用 NAT 的端口映射特性:
1. A 向 Rendezvous Server 注册NAT 创建映射 `A_internal:port1 → A_external:port2`
2. B 同样注册,创建映射 `B_internal:port3 → B_external:port4`
3. A 向 B 的外部地址发送 UDP 包A 的 NAT 创建到 B 的映射
4. B 向 A 的外部地址发送 UDP 包B 的 NAT 创建到 A 的映射
5. 如果 NAT 不是 Symmetric 类型,双方的包可以到达对方
```rust
// rustdesk/src/rendezvous_mediator.rs:621-642
async fn punch_udp_hole(
&self,
peer_addr: SocketAddr,
server: ServerPtr,
msg_punch: PunchHoleSent,
) -> ResultType<()> {
let mut msg_out = Message::new();
msg_out.set_punch_hole_sent(msg_punch);
let (socket, addr) = new_direct_udp_for(&self.host).await?;
let data = msg_out.write_to_bytes()?;
// 发送到 Rendezvous Server
socket.send_to(&data, addr).await?;
// 多次尝试发送以增加成功率
let socket_cloned = socket.clone();
tokio::spawn(async move {
for _ in 0..2 {
let tm = (hbb_common::time_based_rand() % 20 + 10) as f32 / 1000.;
hbb_common::sleep(tm).await;
socket.send_to(&data, addr).await.ok();
}
});
// 等待对方连接
udp_nat_listen(socket_cloned.clone(), peer_addr, peer_addr, server).await?;
Ok(())
}
```
### KCP 协议
RustDesk 在 UDP 上使用 KCP 协议提供可靠传输:
```rust
// rustdesk/src/rendezvous_mediator.rs:824-851
async fn udp_nat_listen(
socket: Arc<tokio::net::UdpSocket>,
peer_addr: SocketAddr,
peer_addr_v4: SocketAddr,
server: ServerPtr,
) -> ResultType<()> {
socket.connect(peer_addr).await?;
// 执行 UDP 打洞
let res = crate::punch_udp(socket.clone(), true).await?;
// 建立 KCP 流
let stream = crate::kcp_stream::KcpStream::accept(
socket,
Duration::from_millis(CONNECT_TIMEOUT as _),
res,
).await?;
// 创建连接
crate::server::create_tcp_connection(server, stream.1, peer_addr_v4, true).await?;
Ok(())
}
```
## TCP 打洞
### 原理
TCP 打洞比 UDP 更难,因为 TCP 需要三次握手。基本思路:
1. A 和 B 都尝试同时向对方发起连接
2. 第一个 SYN 包会被对方的 NAT 丢弃(因为没有映射)
3. 但这个 SYN 包会在 A 的 NAT 上创建映射
4. 当 B 的 SYN 包到达 A 的 NAT 时,由于已有映射,会被转发给 A
5. 连接建立
### 实现
```rust
// rustdesk/src/rendezvous_mediator.rs:604-617
log::debug!("Punch tcp hole to {:?}", peer_addr);
let mut socket = {
let socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?;
let local_addr = socket.local_addr();
// 关键:使用相同的本地地址尝试连接对方
// 这会在 NAT 上创建映射,使对方的连接请求能够到达
allow_err!(socket_client::connect_tcp_local(peer_addr, Some(local_addr), 30).await);
socket
};
```
## Relay 连接
当 P2P 失败时,使用 Relay
```rust
// rustdesk/src/rendezvous_mediator.rs:434-479
async fn create_relay(
&self,
socket_addr: Vec<u8>,
relay_server: String,
uuid: String,
server: ServerPtr,
secure: bool,
initiate: bool,
) -> ResultType<()> {
let peer_addr = AddrMangle::decode(&socket_addr);
log::info!(
"create_relay requested from {:?}, relay_server: {}, uuid: {}, secure: {}",
peer_addr, relay_server, uuid, secure,
);
// 连接 Rendezvous Server 发送 RelayResponse
let mut socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?;
let mut msg_out = Message::new();
let mut rr = RelayResponse {
socket_addr: socket_addr.into(),
version: crate::VERSION.to_owned(),
..Default::default()
};
if initiate {
rr.uuid = uuid.clone();
rr.relay_server = relay_server.clone();
rr.set_id(Config::get_id());
}
msg_out.set_relay_response(rr);
socket.send(&msg_out).await?;
// 连接 Relay Server
crate::create_relay_connection(
server,
relay_server,
uuid,
peer_addr,
secure,
is_ipv4(&self.addr),
).await;
Ok(())
}
```
## IPv6 支持
RustDesk 优先尝试 IPv6 连接:
```rust
// rustdesk/src/rendezvous_mediator.rs:808-822
async fn start_ipv6(
peer_addr_v6: SocketAddr,
peer_addr_v4: SocketAddr,
server: ServerPtr,
) -> bytes::Bytes {
crate::test_ipv6().await;
if let Some((socket, local_addr_v6)) = crate::get_ipv6_socket().await {
let server = server.clone();
tokio::spawn(async move {
allow_err!(udp_nat_listen(socket.clone(), peer_addr_v6, peer_addr_v4, server).await);
});
return local_addr_v6;
}
Default::default()
}
```
## 连接状态机
```
┌─────────────────────────────────────────┐
│ │
▼ │
┌───────────┐ ┌────┴────┐
│ 等待连接 │──────PunchHoleRequest──────►│正在连接 │
└───────────┘ └────┬────┘
┌──────────────────────────────┼──────────────────────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌─────────────┐ ┌─────────────┐
│ P2P TCP │ │ P2P UDP/KCP │ │ Relay │
│ 连接中 │ │ 连接中 │ │ 连接中 │
└─────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
成功 │ 失败 成功 │ 失败 成功 │ 失败
│ │ │ │ │ │
▼ │ ▼ │ ▼ │
┌──────────┐│ ┌──────────┐│ ┌──────────┐│
│已连接 ││ │已连接 ││ │已连接 ││
│(直连) ││ │(UDP) ││ │(中转) ││
└──────────┘│ └──────────┘│ └──────────┘│
│ │ │
└──────────────►尝试 Relay◄───┘ │
│ │
└────────────────────────────────────────┘
```
## 直接连接模式
用户可以配置允许直接 TCP 连接(不经过 Rendezvous Server
```rust
// rustdesk/src/rendezvous_mediator.rs:727-792
async fn direct_server(server: ServerPtr) {
let mut listener = None;
let mut port = get_direct_port(); // 默认 21118
loop {
let disabled = !option2bool(OPTION_DIRECT_SERVER, &Config::get_option(OPTION_DIRECT_SERVER));
if !disabled && listener.is_none() {
match hbb_common::tcp::listen_any(port as _).await {
Ok(l) => {
listener = Some(l);
log::info!("Direct server listening on: {:?}", l.local_addr());
}
Err(err) => {
log::error!("Failed to start direct server: {}", err);
}
}
}
if let Some(l) = listener.as_mut() {
if let Ok(Ok((stream, addr))) = hbb_common::timeout(1000, l.accept()).await {
stream.set_nodelay(true).ok();
log::info!("direct access from {}", addr);
let server = server.clone();
tokio::spawn(async move {
crate::server::create_tcp_connection(server, stream, addr, false).await
});
}
}
}
}
```

View File

@@ -1,574 +0,0 @@
# 消息格式定义
## 概述
RustDesk 使用 Protocol Buffers (protobuf) 定义所有网络消息格式。主要有两个 proto 文件:
- `rendezvous.proto` - Rendezvous/Relay 服务器通信消息
- `message.proto` - 客户端之间通信消息
## Rendezvous 消息 (rendezvous.proto)
### 顶层消息
```protobuf
message RendezvousMessage {
oneof union {
RegisterPeer register_peer = 6;
RegisterPeerResponse register_peer_response = 7;
PunchHoleRequest punch_hole_request = 8;
PunchHole punch_hole = 9;
PunchHoleSent punch_hole_sent = 10;
PunchHoleResponse punch_hole_response = 11;
FetchLocalAddr fetch_local_addr = 12;
LocalAddr local_addr = 13;
ConfigUpdate configure_update = 14;
RegisterPk register_pk = 15;
RegisterPkResponse register_pk_response = 16;
SoftwareUpdate software_update = 17;
RequestRelay request_relay = 18;
RelayResponse relay_response = 19;
TestNatRequest test_nat_request = 20;
TestNatResponse test_nat_response = 21;
PeerDiscovery peer_discovery = 22;
OnlineRequest online_request = 23;
OnlineResponse online_response = 24;
KeyExchange key_exchange = 25;
HealthCheck hc = 26;
}
}
```
### 注册相关
```protobuf
// Peer 注册
message RegisterPeer {
string id = 1; // Peer ID
int32 serial = 2; // 配置序列号
}
message RegisterPeerResponse {
bool request_pk = 2; // 是否需要注册公钥
}
// 公钥注册
message RegisterPk {
string id = 1; // Peer ID
bytes uuid = 2; // 设备 UUID
bytes pk = 3; // Ed25519 公钥
string old_id = 4; // 旧 ID
}
message RegisterPkResponse {
enum Result {
OK = 0;
UUID_MISMATCH = 2;
ID_EXISTS = 3;
TOO_FREQUENT = 4;
INVALID_ID_FORMAT = 5;
NOT_SUPPORT = 6;
SERVER_ERROR = 7;
}
Result result = 1;
int32 keep_alive = 2;
}
```
### 连接协调相关
```protobuf
// 连接类型
enum ConnType {
DEFAULT_CONN = 0;
FILE_TRANSFER = 1;
PORT_FORWARD = 2;
RDP = 3;
VIEW_CAMERA = 4;
}
// NAT 类型
enum NatType {
UNKNOWN_NAT = 0;
ASYMMETRIC = 1; // 可打洞
SYMMETRIC = 2; // 需要中转
}
// Punch Hole 请求
message PunchHoleRequest {
string id = 1; // 目标 Peer ID
NatType nat_type = 2;
string licence_key = 3;
ConnType conn_type = 4;
string token = 5;
string version = 6;
}
// Punch Hole 响应
message PunchHoleResponse {
bytes socket_addr = 1; // 目标地址
bytes pk = 2; // 公钥(已签名)
enum Failure {
ID_NOT_EXIST = 0;
OFFLINE = 2;
LICENSE_MISMATCH = 3;
LICENSE_OVERUSE = 4;
}
Failure failure = 3;
string relay_server = 4;
oneof union {
NatType nat_type = 5;
bool is_local = 6;
}
string other_failure = 7;
int32 feedback = 8;
}
// 服务器转发给被控端
message PunchHole {
bytes socket_addr = 1; // 控制端地址
string relay_server = 2;
NatType nat_type = 3;
}
// 被控端发送给服务器
message PunchHoleSent {
bytes socket_addr = 1;
string id = 2;
string relay_server = 3;
NatType nat_type = 4;
string version = 5;
}
```
### Relay 相关
```protobuf
// Relay 请求
message RequestRelay {
string id = 1;
string uuid = 2; // 配对 UUID
bytes socket_addr = 3;
string relay_server = 4;
bool secure = 5;
string licence_key = 6;
ConnType conn_type = 7;
string token = 8;
}
// Relay 响应
message RelayResponse {
bytes socket_addr = 1;
string uuid = 2;
string relay_server = 3;
oneof union {
string id = 4;
bytes pk = 5;
}
string refuse_reason = 6;
string version = 7;
int32 feedback = 9;
}
```
## 会话消息 (message.proto)
### 顶层消息
```protobuf
message Message {
oneof union {
SignedId signed_id = 3;
PublicKey public_key = 4;
TestDelay test_delay = 5;
VideoFrame video_frame = 6;
LoginRequest login_request = 7;
LoginResponse login_response = 8;
Hash hash = 9;
MouseEvent mouse_event = 10;
AudioFrame audio_frame = 11;
CursorData cursor_data = 12;
CursorPosition cursor_position = 13;
uint64 cursor_id = 14;
KeyEvent key_event = 15;
Clipboard clipboard = 16;
FileAction file_action = 17;
FileResponse file_response = 18;
Misc misc = 19;
Cliprdr cliprdr = 20;
MessageBox message_box = 21;
SwitchSidesResponse switch_sides_response = 22;
VoiceCallRequest voice_call_request = 23;
VoiceCallResponse voice_call_response = 24;
PeerInfo peer_info = 25;
PointerDeviceEvent pointer_device_event = 26;
Auth2FA auth_2fa = 27;
MultiClipboards multi_clipboards = 28;
}
}
```
### 认证相关
```protobuf
// ID 和公钥
message IdPk {
string id = 1;
bytes pk = 2;
}
// 密钥交换
message PublicKey {
bytes asymmetric_value = 1; // X25519 公钥
bytes symmetric_value = 2; // 加密的对称密钥
}
// 签名的 ID
message SignedId {
bytes id = 1; // 签名的 IdPk
}
// 密码哈希挑战
message Hash {
string salt = 1;
string challenge = 2;
}
// 登录请求
message LoginRequest {
string username = 1;
bytes password = 2; // 加密的密码
string my_id = 4;
string my_name = 5;
OptionMessage option = 6;
oneof union {
FileTransfer file_transfer = 7;
PortForward port_forward = 8;
ViewCamera view_camera = 15;
}
bool video_ack_required = 9;
uint64 session_id = 10;
string version = 11;
OSLogin os_login = 12;
string my_platform = 13;
bytes hwid = 14;
}
// 登录响应
message LoginResponse {
oneof union {
string error = 1;
PeerInfo peer_info = 2;
}
bool enable_trusted_devices = 3;
}
// 2FA 认证
message Auth2FA {
string code = 1;
bytes hwid = 2;
}
```
### 视频相关
```protobuf
// 编码后的视频帧
message EncodedVideoFrame {
bytes data = 1;
bool key = 2; // 是否关键帧
int64 pts = 3; // 时间戳
}
message EncodedVideoFrames {
repeated EncodedVideoFrame frames = 1;
}
// 视频帧
message VideoFrame {
oneof union {
EncodedVideoFrames vp9s = 6;
RGB rgb = 7;
YUV yuv = 8;
EncodedVideoFrames h264s = 10;
EncodedVideoFrames h265s = 11;
EncodedVideoFrames vp8s = 12;
EncodedVideoFrames av1s = 13;
}
int32 display = 14; // 显示器索引
}
// 显示信息
message DisplayInfo {
sint32 x = 1;
sint32 y = 2;
int32 width = 3;
int32 height = 4;
string name = 5;
bool online = 6;
bool cursor_embedded = 7;
Resolution original_resolution = 8;
double scale = 9;
}
```
### 输入相关
```protobuf
// 鼠标事件
message MouseEvent {
int32 mask = 1; // 按钮掩码
sint32 x = 2;
sint32 y = 3;
repeated ControlKey modifiers = 4;
}
// 键盘事件
message KeyEvent {
bool down = 1; // 按下/释放
bool press = 2; // 单击
oneof union {
ControlKey control_key = 3;
uint32 chr = 4; // 字符码
uint32 unicode = 5; // Unicode
string seq = 6; // 字符序列
uint32 win2win_hotkey = 7;
}
repeated ControlKey modifiers = 8;
KeyboardMode mode = 9;
}
// 键盘模式
enum KeyboardMode {
Legacy = 0;
Map = 1;
Translate = 2;
Auto = 3;
}
// 控制键枚举(部分)
enum ControlKey {
Unknown = 0;
Alt = 1;
Backspace = 2;
CapsLock = 3;
Control = 4;
Delete = 5;
// ... 更多按键
CtrlAltDel = 100;
LockScreen = 101;
}
```
### 音频相关
```protobuf
// 音频格式
message AudioFormat {
uint32 sample_rate = 1;
uint32 channels = 2;
}
// 音频帧
message AudioFrame {
bytes data = 1; // Opus 编码数据
}
```
### 剪贴板相关
```protobuf
// 剪贴板格式
enum ClipboardFormat {
Text = 0;
Rtf = 1;
Html = 2;
ImageRgba = 21;
ImagePng = 22;
ImageSvg = 23;
Special = 31;
}
// 剪贴板内容
message Clipboard {
bool compress = 1;
bytes content = 2;
int32 width = 3;
int32 height = 4;
ClipboardFormat format = 5;
string special_name = 6;
}
message MultiClipboards {
repeated Clipboard clipboards = 1;
}
```
### 文件传输相关
```protobuf
// 文件操作
message FileAction {
oneof union {
ReadDir read_dir = 1;
FileTransferSendRequest send = 2;
FileTransferReceiveRequest receive = 3;
FileDirCreate create = 4;
FileRemoveDir remove_dir = 5;
FileRemoveFile remove_file = 6;
ReadAllFiles all_files = 7;
FileTransferCancel cancel = 8;
FileTransferSendConfirmRequest send_confirm = 9;
FileRename rename = 10;
ReadEmptyDirs read_empty_dirs = 11;
}
}
// 文件响应
message FileResponse {
oneof union {
FileDirectory dir = 1;
FileTransferBlock block = 2;
FileTransferError error = 3;
FileTransferDone done = 4;
FileTransferDigest digest = 5;
ReadEmptyDirsResponse empty_dirs = 6;
}
}
// 文件传输块
message FileTransferBlock {
int32 id = 1;
sint32 file_num = 2;
bytes data = 3;
bool compressed = 4;
uint32 blk_id = 5;
}
// 文件条目
message FileEntry {
FileType entry_type = 1;
string name = 2;
bool is_hidden = 3;
uint64 size = 4;
uint64 modified_time = 5;
}
```
### 杂项消息
```protobuf
message Misc {
oneof union {
ChatMessage chat_message = 4;
SwitchDisplay switch_display = 5;
PermissionInfo permission_info = 6;
OptionMessage option = 7;
AudioFormat audio_format = 8;
string close_reason = 9;
bool refresh_video = 10;
bool video_received = 12;
BackNotification back_notification = 13;
bool restart_remote_device = 14;
// ... 更多选项
}
}
// Peer 信息
message PeerInfo {
string username = 1;
string hostname = 2;
string platform = 3;
repeated DisplayInfo displays = 4;
int32 current_display = 5;
bool sas_enabled = 6;
string version = 7;
Features features = 9;
SupportedEncoding encoding = 10;
SupportedResolutions resolutions = 11;
string platform_additions = 12;
WindowsSessions windows_sessions = 13;
}
// 选项消息
message OptionMessage {
enum BoolOption {
NotSet = 0;
No = 1;
Yes = 2;
}
ImageQuality image_quality = 1;
BoolOption lock_after_session_end = 2;
BoolOption show_remote_cursor = 3;
BoolOption privacy_mode = 4;
BoolOption block_input = 5;
int32 custom_image_quality = 6;
BoolOption disable_audio = 7;
BoolOption disable_clipboard = 8;
BoolOption enable_file_transfer = 9;
SupportedDecoding supported_decoding = 10;
int32 custom_fps = 11;
// ... 更多选项
}
```
## 消息编码
### 长度前缀
TCP 传输时使用长度前缀编码:
```rust
// hbb_common/src/bytes_codec.rs
pub struct BytesCodec {
state: DecodeState,
raw: bool,
}
impl Decoder for BytesCodec {
type Item = BytesMut;
type Error = std::io::Error;
fn decode(&mut self, buf: &mut BytesMut) -> Result<Option<BytesMut>, Self::Error> {
if self.raw {
// 原始模式:直接返回数据
if buf.is_empty() {
Ok(None)
} else {
Ok(Some(buf.split()))
}
} else {
// 标准模式4 字节长度前缀 + 数据
match self.state {
DecodeState::Head => {
if buf.len() < 4 {
return Ok(None);
}
let len = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize;
buf.advance(4);
self.state = DecodeState::Data(len);
self.decode(buf)
}
DecodeState::Data(len) => {
if buf.len() < len {
return Ok(None);
}
let data = buf.split_to(len);
self.state = DecodeState::Head;
Ok(Some(data))
}
}
}
}
}
```
### 加密模式
当启用加密时,消息结构为:
```
┌─────────────┬─────────────┬─────────────────────────┐
│ Length(4) │ Nonce(8) │ Encrypted Data(N) │
└─────────────┴─────────────┴─────────────────────────┘
```

View File

@@ -1,342 +0,0 @@
# 加密机制
## 概述
RustDesk 使用 libsodium (sodiumoxide) 库实现端到端加密,主要包含:
- **Ed25519**: 用于身份签名和验证
- **X25519**: 用于密钥交换
- **ChaCha20-Poly1305**: 用于对称加密
## 密钥类型
### 1. 身份密钥对 (Ed25519)
用于 Peer 身份认证和签名:
```rust
// 生成密钥对
use sodiumoxide::crypto::sign;
let (pk, sk) = sign::gen_keypair();
// pk: sign::PublicKey (32 bytes)
// sk: sign::SecretKey (64 bytes)
```
### 2. 服务器签名密钥
Rendezvous Server 可以配置签名密钥,用于签名 Peer 公钥:
```rust
// rustdesk-server/src/rendezvous_server.rs:1185-1210
fn get_server_sk(key: &str) -> (String, Option<sign::SecretKey>) {
let mut out_sk = None;
let mut key = key.to_owned();
// 如果是 base64 编码的私钥
if let Ok(sk) = base64::decode(&key) {
if sk.len() == sign::SECRETKEYBYTES {
log::info!("The key is a crypto private key");
key = base64::encode(&sk[(sign::SECRETKEYBYTES / 2)..]); // 公钥部分
let mut tmp = [0u8; sign::SECRETKEYBYTES];
tmp[..].copy_from_slice(&sk);
out_sk = Some(sign::SecretKey(tmp));
}
}
// 如果是占位符,生成新密钥对
if key.is_empty() || key == "-" || key == "_" {
let (pk, sk) = crate::common::gen_sk(0);
out_sk = sk;
if !key.is_empty() {
key = pk;
}
}
if !key.is_empty() {
log::info!("Key: {}", key);
}
(key, out_sk)
}
```
### 3. 会话密钥 (X25519 + ChaCha20)
用于客户端之间的加密通信:
```rust
// hbb_common/src/tcp.rs:27-28
#[derive(Clone)]
pub struct Encrypt(pub Key, pub u64, pub u64);
// Key: secretbox::Key (32 bytes)
// u64: 发送计数器
// u64: 接收计数器
```
## 密钥交换流程
### 1. 身份验证
客户端首先交换签名的身份:
```protobuf
message IdPk {
string id = 1; // Peer ID
bytes pk = 2; // Ed25519 公钥
}
message SignedId {
bytes id = 1; // 签名的 IdPk (by server or self)
}
```
### 2. X25519 密钥交换
使用 X25519 ECDH 生成共享密钥:
```rust
// 生成临时密钥对
use sodiumoxide::crypto::box_;
let (our_pk, our_sk) = box_::gen_keypair();
// 计算共享密钥
let shared_secret = box_::curve25519xsalsa20poly1305::scalarmult(&our_sk, &their_pk);
// 派生对称密钥
let symmetric_key = secretbox::Key::from_slice(&shared_secret[..32]).unwrap();
```
### 3. 对称密钥消息
```protobuf
message PublicKey {
bytes asymmetric_value = 1; // X25519 公钥
bytes symmetric_value = 2; // 加密的对称密钥(用于额外安全)
}
```
## 会话加密
### 加密实现
```rust
// hbb_common/src/tcp.rs
impl Encrypt {
pub fn new(key: Key) -> Self {
Self(key, 0, 0) // 初始化计数器为 0
}
// 加密
pub fn enc(&mut self, data: &[u8]) -> Vec<u8> {
self.1 += 1; // 递增发送计数器
let nonce = self.get_nonce(self.1);
let encrypted = secretbox::seal(data, &nonce, &self.0);
// 格式: nonce (8 bytes) + encrypted data
let mut result = Vec::with_capacity(8 + encrypted.len());
result.extend_from_slice(&self.1.to_le_bytes());
result.extend_from_slice(&encrypted);
result
}
// 解密
pub fn dec(&mut self, data: &mut BytesMut) -> io::Result<()> {
if data.len() < 8 + secretbox::MACBYTES {
return Err(io::Error::new(io::ErrorKind::InvalidData, "too short"));
}
// 提取 nonce
let counter = u64::from_le_bytes(data[..8].try_into().unwrap());
// 防重放攻击检查
if counter <= self.2 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "replay attack"));
}
self.2 = counter;
let nonce = self.get_nonce(counter);
let plaintext = secretbox::open(&data[8..], &nonce, &self.0)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "decrypt failed"))?;
data.clear();
data.extend_from_slice(&plaintext);
Ok(())
}
fn get_nonce(&self, counter: u64) -> Nonce {
let mut nonce = [0u8; 24];
nonce[..8].copy_from_slice(&counter.to_le_bytes());
Nonce(nonce)
}
}
```
### 消息格式
加密后的消息结构:
```
┌──────────────────┬─────────────────────────────────────────┐
│ Counter (8B) │ Encrypted Data + MAC (N+16 bytes) │
└──────────────────┴─────────────────────────────────────────┘
```
## 密码验证
### 挑战-响应机制
被控端生成随机盐和挑战,控制端计算哈希响应:
```protobuf
message Hash {
string salt = 1; // 随机盐
string challenge = 2; // 随机挑战
}
```
### 密码处理
```rust
// 客户端计算密码哈希
fn get_password_hash(password: &str, salt: &str) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
hasher.update(salt.as_bytes());
hasher.finalize().to_vec()
}
// 发送加密的密码(使用对称密钥加密)
fn encrypt_password(password_hash: &[u8], symmetric_key: &Key) -> Vec<u8> {
secretbox::seal(password_hash, &nonce, symmetric_key)
}
```
## 服务器公钥验证
### 签名验证
如果 Rendezvous Server 配置了密钥,会签名 Peer 公钥:
```rust
// 服务器签名 IdPk
let signed_id_pk = sign::sign(
&IdPk { id, pk, ..Default::default() }
.write_to_bytes()?,
&server_sk,
);
// 客户端验证
fn verify_server_signature(signed_pk: &[u8], server_pk: &sign::PublicKey) -> Option<IdPk> {
if let Ok(verified) = sign::verify(signed_pk, server_pk) {
return IdPk::parse_from_bytes(&verified).ok();
}
None
}
```
### 客户端获取服务器公钥
```rust
pub async fn get_rs_pk(id: &str) -> ResultType<(String, sign::PublicKey)> {
// 从配置或 Rendezvous Server 获取公钥
let key = Config::get_option("key");
if !key.is_empty() {
if let Ok(pk) = base64::decode(&key) {
if pk.len() == sign::PUBLICKEYBYTES {
return Ok((key, sign::PublicKey::from_slice(&pk).unwrap()));
}
}
}
// ... 从服务器获取
}
```
## TCP 连接加密
### 安全 TCP 握手
```rust
// rustdesk/src/common.rs
pub async fn secure_tcp(conn: &mut Stream, key: &str) -> ResultType<()> {
// 1. 生成临时 X25519 密钥对
let (our_pk, our_sk) = box_::gen_keypair();
// 2. 发送我们的公钥
let mut msg = Message::new();
msg.set_public_key(PublicKey {
asymmetric_value: our_pk.0.to_vec().into(),
..Default::default()
});
conn.send(&msg).await?;
// 3. 接收对方公钥
let msg = conn.next_timeout(CONNECT_TIMEOUT).await?
.ok_or_else(|| anyhow!("timeout"))?;
let their_pk = msg.get_public_key();
// 4. 计算共享密钥
let shared = box_::curve25519xsalsa20poly1305::scalarmult(
&our_sk,
&box_::PublicKey::from_slice(&their_pk.asymmetric_value)?,
);
// 5. 设置加密
conn.set_key(secretbox::Key::from_slice(&shared[..32]).unwrap());
Ok(())
}
```
## 安全特性
### 1. 前向保密
每个会话使用临时密钥对,即使长期密钥泄露,历史会话仍然安全。
### 2. 重放攻击防护
使用递增计数器作为 nonce 的一部分,拒绝旧的或重复的消息。
### 3. 中间人攻击防护
- 服务器签名 Peer 公钥
- 可配置服务器公钥验证
### 4. 密码暴力破解防护
- 使用盐和多次哈希
- 服务器端限流
## 加密算法参数
| 算法 | 密钥大小 | Nonce 大小 | MAC 大小 |
|------|----------|------------|----------|
| Ed25519 | 64 bytes (private), 32 bytes (public) | N/A | 64 bytes |
| X25519 | 32 bytes | N/A | N/A |
| ChaCha20-Poly1305 | 32 bytes | 24 bytes | 16 bytes |
## 密钥生命周期
```
┌─────────────────────────────────────────────────────────────┐
│ 长期密钥 (Ed25519) │
│ ┌─────────────────┐ │
│ │ 设备首次启动时生成 │ │
│ │ 存储在配置文件中 │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 会话密钥 (X25519) │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 每次连接时生成 │───►│ 用于密钥协商 │ │
│ │ 临时密钥对 │ │ 派生对称密钥 │ │
│ └─────────────────┘ └─────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 对称密钥 (ChaCha20-Poly1305) │ │
│ │ 用于会话中的所有消息加密 │ │
│ │ 会话结束时销毁 │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```

View File

@@ -1,410 +0,0 @@
# NAT 穿透技术
## 概述
RustDesk 实现了多种 NAT 穿透技术,以在不同网络环境下建立 P2P 连接:
- NAT 类型检测
- UDP 打洞
- TCP 打洞
- Relay 中转(作为后备)
## NAT 类型
### 分类
```protobuf
enum NatType {
UNKNOWN_NAT = 0; // 未知
ASYMMETRIC = 1; // 非对称 NAT (Cone NAT) - 可打洞
SYMMETRIC = 2; // 对称 NAT - 通常需要 Relay
}
```
### NAT 类型说明
| 类型 | 描述 | 可打洞 |
|------|------|--------|
| Full Cone | 外部端口固定,任何外部主机可访问 | ✅ 最容易 |
| Restricted Cone | 外部端口固定,仅允许曾发送过数据的 IP | ✅ 容易 |
| Port Restricted Cone | 外部端口固定,仅允许曾发送过数据的 IP:Port | ✅ 可能 |
| Symmetric | 每个目标地址使用不同外部端口 | ❌ 困难 |
## NAT 类型检测
### 检测原理
RustDesk 使用双端口检测法:
1. 客户端向 Rendezvous Server 的主端口 (21116) 发送 TestNatRequest
2. 同时向 NAT 测试端口 (21115) 发送 TestNatRequest
3. 比较两次响应中观测到的源端口
```
客户端 Rendezvous Server
│ │
│ TestNatRequest ────────►│ Port 21116
│ │
│ TestNatRequest ────────►│ Port 21115
│ │
│◄──────── TestNatResponse │ (包含观测到的源端口)
│ │
│ │
│ 比较两次源端口 │
│ 相同 → ASYMMETRIC │
│ 不同 → SYMMETRIC │
```
### 实现代码
**客户端发送检测请求:**
```rust
// rustdesk/src/lib.rs
pub fn test_nat_type() {
tokio::spawn(async move {
let rendezvous_server = Config::get_rendezvous_servers().first().cloned();
if let Some(host) = rendezvous_server {
// 连接主端口
let host = check_port(&host, RENDEZVOUS_PORT);
// 连接 NAT 测试端口
let host2 = crate::increase_port(&host, -1);
// 发送测试请求
let mut msg = RendezvousMessage::new();
msg.set_test_nat_request(TestNatRequest {
serial: Config::get_serial(),
});
// 收集两次响应的端口
let port1 = send_and_get_port(&host, &msg).await;
let port2 = send_and_get_port(&host2, &msg).await;
// 判断 NAT 类型
let nat_type = if port1 == port2 {
NatType::ASYMMETRIC // 可打洞
} else {
NatType::SYMMETRIC // 需要 Relay
};
Config::set_nat_type(nat_type as i32);
}
});
}
```
**服务器响应:**
```rust
// rustdesk-server/src/rendezvous_server.rs:1080-1087
Some(rendezvous_message::Union::TestNatRequest(_)) => {
let mut msg_out = RendezvousMessage::new();
msg_out.set_test_nat_response(TestNatResponse {
port: addr.port() as _, // 返回观测到的源端口
..Default::default()
});
stream.send(&msg_out).await.ok();
}
```
## UDP 打洞
### 原理
UDP 打洞利用 NAT 的端口映射机制:
```
A (内网) B (内网)
│ │
│ ──► NAT_A ──► Internet ──► NAT_B ──► (丢弃) │
│ │
│ │
│ (NAT_A 创建了映射 A:port → A_ext:port_a) │
│ │
│ │
│ (丢弃) ◄── NAT_A ◄── Internet ◄── NAT_B ◄── │
│ │
│ │
│ (NAT_B 创建了映射 B:port → B_ext:port_b) │
│ │
│ ──► NAT_A ──► Internet ──► NAT_B ──► │
│ (NAT_A 的映射存在,包被转发) │
│ │
│ ◄── NAT_A ◄── Internet ◄── NAT_B ◄── │
│ (NAT_B 的映射存在,包被转发) │
│ │
│ ◄───────── 双向通信建立 ──────────► │
```
### 实现
**被控端打洞:**
```rust
// rustdesk/src/rendezvous_mediator.rs:621-642
async fn punch_udp_hole(
&self,
peer_addr: SocketAddr,
server: ServerPtr,
msg_punch: PunchHoleSent,
) -> ResultType<()> {
let mut msg_out = Message::new();
msg_out.set_punch_hole_sent(msg_punch);
// 创建 UDP socket
let (socket, addr) = new_direct_udp_for(&self.host).await?;
let data = msg_out.write_to_bytes()?;
// 发送到 Rendezvous Server会转发给控制端
socket.send_to(&data, addr).await?;
// 多次发送以增加成功率
let socket_cloned = socket.clone();
tokio::spawn(async move {
for _ in 0..2 {
let tm = (hbb_common::time_based_rand() % 20 + 10) as f32 / 1000.;
hbb_common::sleep(tm).await;
socket.send_to(&data, addr).await.ok();
}
});
// 等待对方连接
udp_nat_listen(socket_cloned, peer_addr, peer_addr, server).await?;
Ok(())
}
```
**UDP 监听和 KCP 建立:**
```rust
// rustdesk/src/rendezvous_mediator.rs:824-851
async fn udp_nat_listen(
socket: Arc<tokio::net::UdpSocket>,
peer_addr: SocketAddr,
peer_addr_v4: SocketAddr,
server: ServerPtr,
) -> ResultType<()> {
// 连接到对方地址
socket.connect(peer_addr).await?;
// 执行 UDP 打洞
let res = crate::punch_udp(socket.clone(), true).await?;
// 建立 KCP 可靠传输层
let stream = crate::kcp_stream::KcpStream::accept(
socket,
Duration::from_millis(CONNECT_TIMEOUT as _),
res,
).await?;
// 创建连接
crate::server::create_tcp_connection(server, stream.1, peer_addr_v4, true).await?;
Ok(())
}
```
### KCP 协议
RustDesk 在 UDP 上使用 KCP 提供可靠传输KCP 特点:
- 更激进的重传策略
- 更低的延迟
- 可配置的可靠性级别
## TCP 打洞
### 原理
TCP 打洞比 UDP 困难,因为 TCP 需要三次握手。技巧是让双方同时发起连接:
```
A NAT_A NAT_B B
│ │ │ │
│ ─── SYN ───────────────►│─────────│────► (丢弃,无映射) │
│ │ │ │
│ (NAT_A 创建到 B 的映射) │ │ │
│ │ │ │
│ (丢弃,无映射) ◄─────────│─────────│◄─── SYN ───────────── │
│ │ │ │
│ │ │ (NAT_B 创建到 A 的映射) │
│ │ │ │
│ ─── SYN ───────────────►│─────────│────► SYN ───────────► │
│ │ │ (映射存在,转发成功) │
│ │ │ │
│ ◄─── SYN+ACK ──────────│─────────│◄─── SYN+ACK ───────── │
│ │ │ │
│ ─── ACK ───────────────►│─────────│────► ACK ───────────► │
│ │ │ │
│ ◄─────────── 连接建立 ─────────────────────────────────────►│
```
### 实现
```rust
// rustdesk/src/rendezvous_mediator.rs:604-617
log::debug!("Punch tcp hole to {:?}", peer_addr);
let mut socket = {
// 1. 先连接 Rendezvous Server 获取本地地址
let socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?;
let local_addr = socket.local_addr();
// 2. 用相同的本地地址尝试连接对方
// 这会在 NAT 上创建映射
// 虽然连接会失败,但映射已建立
allow_err!(socket_client::connect_tcp_local(peer_addr, Some(local_addr), 30).await);
socket
};
// 3. 发送 PunchHoleSent 通知服务器
// 服务器会转发给控制端
let mut msg_out = Message::new();
msg_out.set_punch_hole_sent(msg_punch);
socket.send_raw(msg_out.write_to_bytes()?).await?;
// 4. 等待控制端连接
// 由于已有映射,控制端的连接可以成功
crate::accept_connection(server.clone(), socket, peer_addr, true).await;
```
## 局域网直连
### 检测同一局域网
```rust
// rustdesk-server/src/rendezvous_server.rs:721-728
let same_intranet: bool = !ws
&& (peer_is_lan && is_lan || {
match (peer_addr, addr) {
(SocketAddr::V4(a), SocketAddr::V4(b)) => a.ip() == b.ip(),
(SocketAddr::V6(a), SocketAddr::V6(b)) => a.ip() == b.ip(),
_ => false,
}
});
```
### 局域网连接流程
```
控制端 Rendezvous Server 被控端
│ │ │
│ PunchHoleRequest ────►│ │
│ │ │
│ │ (检测到同一局域网) │
│ │ │
│ │ FetchLocalAddr ──────►│
│ │ │
│ │◄────── LocalAddr ────────│
│ │ (包含被控端内网地址) │
│ │ │
│◄─ PunchHoleResponse ──│ │
│ (is_local=true) │ │
│ (socket_addr=内网地址)│ │
│ │ │
│ ─────────── 直接连接内网地址 ────────────────────►│
```
## IPv6 支持
IPv6 通常不需要 NAT 穿透,但 RustDesk 仍支持 IPv6 打洞以处理有状态防火墙:
```rust
// rustdesk/src/rendezvous_mediator.rs:808-822
async fn start_ipv6(
peer_addr_v6: SocketAddr,
peer_addr_v4: SocketAddr,
server: ServerPtr,
) -> bytes::Bytes {
crate::test_ipv6().await;
if let Some((socket, local_addr_v6)) = crate::get_ipv6_socket().await {
let server = server.clone();
tokio::spawn(async move {
allow_err!(udp_nat_listen(socket.clone(), peer_addr_v6, peer_addr_v4, server).await);
});
return local_addr_v6;
}
Default::default()
}
```
## 连接策略决策树
```
开始连接
┌───────────────┐
│ NAT 类型检测 │
└───────┬───────┘
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
ASYMMETRIC UNKNOWN SYMMETRIC
│ │ │
▼ ▼ │
┌──────────┐ ┌──────────┐ │
│ 尝试 UDP │ │ 尝试 TCP │ │
│ 打洞 │ │ 打洞 │ │
└────┬─────┘ └────┬─────┘ │
│ │ │
成功 │ 失败 成功 │ 失败 │
▼ │ ▼ │ │
┌────────┐│ ┌────────┐│ │
│UDP P2P ││ │TCP P2P ││ │
└────────┘│ └────────┘│ │
│ │ │
└───────┬───────┘ │
│ │
▼ │
┌───────────────┐ │
│ 使用 Relay │◄─────────┘
└───────────────┘
```
## 性能优化
### 多路径尝试
RustDesk 同时尝试多种连接方式,选择最快成功的:
```rust
// rustdesk/src/client.rs:342-364
let mut connect_futures = Vec::new();
// 同时尝试 UDP 和 TCP
if udp.0.is_some() {
connect_futures.push(Self::_start_inner(..., udp).boxed());
}
connect_futures.push(Self::_start_inner(..., (None, None)).boxed());
// 使用 select_ok 选择第一个成功的
match select_ok(connect_futures).await {
Ok(conn) => Ok(conn),
Err(e) => Err(e),
}
```
### 超时控制
```rust
const CONNECT_TIMEOUT: u64 = 18_000; // 18 秒
const REG_TIMEOUT: i32 = 30_000; // 30 秒
// 连接超时处理
if let Ok(Ok((stream, addr))) = timeout(CONNECT_TIMEOUT, socket.accept()).await {
// 连接成功
} else {
// 超时,尝试其他方式
}
```
## 常见问题和解决方案
| 问题 | 原因 | 解决方案 |
|------|------|----------|
| 双 Symmetric NAT | 两端都是对称 NAT | 使用 Relay |
| 防火墙阻止 UDP | 企业防火墙 | 使用 TCP 或 WebSocket |
| 端口预测失败 | NAT 端口分配不规律 | 多次尝试或使用 Relay |
| IPv6 不通 | ISP 或防火墙问题 | 回退到 IPv4 |

View File

@@ -1,401 +0,0 @@
# RustDesk 协议 vs One-KVM 实现对比分析
本文档对比分析 RustDesk 原始协议与 One-KVM 的实现差异。
## 1. 概述
One-KVM 作为 IP-KVM 解决方案,只实现了 RustDesk 协议的**被控端Controlled** 功能不实现控制端Controller功能。这是设计决策因为 KVM 设备只需要接收远程控制,不需要控制其他设备。
### 架构差异
| 方面 | RustDesk 原版 | One-KVM |
|------|---------------|---------|
| 角色 | 双向(控制端+被控端) | 单向(仅被控端) |
| 连接方式 | P2P + Relay | 仅 Relay (TCP) |
| NAT 穿透 | UDP/TCP 打洞 + TURN | 不支持 |
| 传输协议 | UDP/TCP | 仅 TCP |
## 2. 已实现功能
### 2.1 Rendezvous 协议 (hbbs 通信)
| 消息类型 | 实现状态 | 备注 |
|----------|----------|------|
| RegisterPeer | ✅ 已实现 | 注册设备到服务器 |
| RegisterPeerResponse | ✅ 已实现 | 处理注册响应 |
| RegisterPk | ✅ 已实现 | 注册公钥 |
| RegisterPkResponse | ✅ 已实现 | 处理公钥注册响应 |
| PunchHoleSent | ✅ 已实现 | 响应打洞请求 |
| FetchLocalAddr | ✅ 已实现 | 获取本地地址 |
| LocalAddr | ✅ 已实现 | 返回本地地址 |
| RequestRelay | ✅ 已实现 | 请求中继连接 |
| RelayResponse | ✅ 已实现 | 处理中继响应 |
| ConfigUpdate | ✅ 已实现 | 接收配置更新 |
**实现文件**: `src/rustdesk/rendezvous.rs` (~829 行)
```rust
// 核心结构
pub struct RendezvousMediator {
config: RustDeskConfig,
key_pair: KeyPair,
signing_key: SigningKeyPair,
socket: UdpSocket,
status: Arc<RwLock<RendezvousStatus>>,
// ...
}
```
### 2.2 连接协议 (客户端连接)
| 消息类型 | 实现状态 | 备注 |
|----------|----------|------|
| SignedId | ✅ 已实现 | 签名身份验证 |
| PublicKey | ✅ 已实现 | 公钥交换 |
| Hash | ✅ 已实现 | 哈希挑战响应 |
| LoginRequest | ✅ 已实现 | 登录认证 |
| LoginResponse | ✅ 已实现 | 登录响应 |
| TestDelay | ✅ 已实现 | 延迟测试 |
| VideoFrame | ✅ 已实现 | 视频帧发送 |
| AudioFrame | ✅ 已实现 | 音频帧发送 |
| CursorData | ✅ 已实现 | 光标图像 |
| CursorPosition | ✅ 已实现 | 光标位置 |
| MouseEvent | ✅ 已实现 | 鼠标事件接收 |
| KeyEvent | ✅ 已实现 | 键盘事件接收 |
**实现文件**: `src/rustdesk/connection.rs` (~1349 行)
```rust
// 连接状态机
pub enum ConnectionState {
WaitingForSignedId,
WaitingForPublicKey,
WaitingForHash,
WaitingForLogin,
Authenticated,
Streaming,
}
```
### 2.3 加密模块
| 功能 | 实现状态 | 备注 |
|------|----------|------|
| Curve25519 密钥对 | ✅ 已实现 | 用于加密 |
| Ed25519 签名密钥对 | ✅ 已实现 | 用于签名 |
| Ed25519 → Curve25519 转换 | ✅ 已实现 | 密钥派生 |
| XSalsa20-Poly1305 | ✅ 已实现 | 会话加密 (secretbox) |
| 密码哈希 | ✅ 已实现 | 单重/双重 SHA256 |
| 会话密钥协商 | ✅ 已实现 | 对称密钥派生 |
**实现文件**: `src/rustdesk/crypto.rs` (~468 行)
```rust
// 密钥对结构
pub struct KeyPair {
secret_key: [u8; 32], // Curve25519 私钥
public_key: [u8; 32], // Curve25519 公钥
}
pub struct SigningKeyPair {
secret_key: [u8; 64], // Ed25519 私钥
public_key: [u8; 32], // Ed25519 公钥
}
```
### 2.4 视频/音频流
| 编码格式 | 实现状态 | 备注 |
|----------|----------|------|
| H.264 | ✅ 已实现 | 主要格式 |
| H.265/HEVC | ✅ 已实现 | 高效编码 |
| VP8 | ✅ 已实现 | WebRTC 兼容 |
| VP9 | ✅ 已实现 | 高质量 |
| AV1 | ✅ 已实现 | 新一代编码 |
| Opus 音频 | ✅ 已实现 | 低延迟音频 |
**实现文件**: `src/rustdesk/frame_adapters.rs` (~316 行)
### 2.5 HID 事件
| 功能 | 实现状态 | 备注 |
|------|----------|------|
| 鼠标移动 | ✅ 已实现 | 绝对/相对坐标 |
| 鼠标按键 | ✅ 已实现 | 左/中/右键 |
| 鼠标滚轮 | ✅ 已实现 | 垂直滚动 |
| 键盘按键 | ✅ 已实现 | 按下/释放 |
| 控制键映射 | ✅ 已实现 | ControlKey → USB HID |
| X11 键码映射 | ✅ 已实现 | X11 → USB HID |
**实现文件**: `src/rustdesk/hid_adapter.rs` (~386 行)
### 2.6 协议帧编码
| 功能 | 实现状态 | 备注 |
|------|----------|------|
| BytesCodec | ✅ 已实现 | 变长帧编码 |
| 1-4 字节头 | ✅ 已实现 | 根据长度自动选择 |
| 最大 1GB 消息 | ✅ 已实现 | 与原版一致 |
**实现文件**: `src/rustdesk/bytes_codec.rs` (~253 行)
## 3. 未实现功能
### 3.1 NAT 穿透相关
| 功能 | 原因 |
|------|------|
| UDP 打洞 | One-KVM 仅使用 TCP 中继 |
| TCP 打洞 | 同上 |
| STUN/TURN | 不需要 NAT 类型检测 |
| TestNat | 同上 |
| P2P 直连 | 设计简化,仅支持中继 |
### 3.2 客户端发起功能
| 功能 | 原因 |
|------|------|
| PunchHole (发起) | KVM 只接收连接 |
| RelayRequest | 同上 |
| ConnectPeer | 同上 |
| OnlineRequest | 不需要查询其他设备 |
### 3.3 文件传输
| 功能 | 原因 |
|------|------|
| FileTransfer | 超出 KVM 功能范围 |
| FileAction | 同上 |
| FileResponse | 同上 |
| FileTransferBlock | 同上 |
### 3.4 高级功能
| 功能 | 原因 |
|------|------|
| 剪贴板同步 | 超出 KVM 功能范围 |
| 多显示器切换 | One-KVM 使用单一视频源 |
| 虚拟显示器 | 不适用 |
| 端口转发 | 超出 KVM 功能范围 |
| 语音通话 | 不需要 |
| RDP 输入 | 不需要 |
| 插件系统 | 不支持 |
| 软件更新 | One-KVM 有自己的更新机制 |
### 3.5 权限协商
| 功能 | 原因 |
|------|------|
| Option 消息 | One-KVM 假设完全控制权限 |
| 权限请求 | 同上 |
| PermissionInfo | 同上 |
## 4. 实现差异
### 4.1 连接模式
**RustDesk 原版:**
```
客户端 ──UDP打洞──> 被控端 (P2P 优先)
└──Relay──> 被控端 (回退)
```
**One-KVM:**
```
RustDesk客户端 ──TCP中继──> hbbr服务器 ──> One-KVM设备
```
One-KVM 只支持 TCP 中继连接,不支持 P2P 直连。这简化了实现,但可能增加延迟。
### 4.2 会话加密
**RustDesk 原版:**
- 支持 ChaCha20-Poly1305 (流式)
- 支持 XSalsa20-Poly1305 (secretbox)
- 动态协商加密方式
**One-KVM:**
- 仅支持 XSalsa20-Poly1305 (secretbox)
- 使用序列号作为 nonce
```rust
// One-KVM 的加密实现
fn encrypt_message(&mut self, plaintext: &[u8]) -> Vec<u8> {
let nonce = make_nonce(&self.send_nonce);
self.send_nonce = self.send_nonce.wrapping_add(1);
secretbox::seal(plaintext, &nonce, &self.session_key)
}
```
### 4.3 视频流方向
**RustDesk 原版:**
- 双向视频流(可控制和被控制)
- 远程桌面捕获
**One-KVM:**
- 单向视频流(仅发送)
- 从 V4L2 设备捕获
- 集成到 One-KVM 的 VideoStreamManager
```rust
// One-KVM 视频流集成
pub async fn start_video_stream(&self, state: &AppState) {
let stream_manager = &state.video_stream_manager;
// 从 One-KVM 的视频管理器获取帧
}
```
### 4.4 HID 事件处理
**RustDesk 原版:**
- 转发到远程系统的输入子系统
- 使用 enigo 或 uinput
**One-KVM:**
- 转发到 USB OTG/HID 设备
- 控制物理 KVM 目标机器
```rust
// One-KVM HID 适配
pub fn convert_mouse_event(event: &RustDeskMouseEvent) -> Option<OneKvmMouseEvent> {
// 转换 RustDesk 鼠标事件到 One-KVM HID 事件
}
pub fn convert_key_event(event: &RustDeskKeyEvent) -> Option<OneKvmKeyEvent> {
// 转换 RustDesk 键盘事件到 One-KVM HID 事件
}
```
### 4.5 配置管理
**RustDesk 原版:**
- 使用 TOML/JSON 配置文件
- 硬编码默认值
**One-KVM:**
- 集成到 SQLite 配置系统
- Web UI 管理
- 使用 typeshare 生成 TypeScript 类型
```rust
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RustDeskConfig {
pub enabled: bool,
pub rendezvous_server: String,
pub device_id: String,
// ...
}
```
### 4.6 设备 ID 生成
**RustDesk 原版:**
- 基于 MAC 地址和硬件信息
- 固定便携式 ID
**One-KVM:**
- 随机生成 9 位数字
- 存储在配置中
```rust
pub fn generate_device_id() -> String {
let mut rng = rand::thread_rng();
let id: u32 = rng.gen_range(100_000_000..999_999_999);
id.to_string()
}
```
## 5. 协议兼容性
### 5.1 完全兼容
| 功能 | 说明 |
|------|------|
| Rendezvous 注册 | 可与官方 hbbs 服务器通信 |
| 中继连接 | 可通过官方 hbbr 服务器中继 |
| 加密握手 | 与 RustDesk 客户端兼容 |
| 视频编码 | 支持所有主流编码格式 |
| HID 事件 | 接收标准 RustDesk 输入事件 |
### 5.2 部分兼容
| 功能 | 说明 |
|------|------|
| 密码认证 | 仅支持设备密码,不支持一次性密码 |
| 会话加密 | 仅 XSalsa20-Poly1305 |
### 5.3 不兼容
| 功能 | 说明 |
|------|------|
| P2P 连接 | 客户端必须通过中继连接 |
| 文件传输 | 不支持 |
| 剪贴板 | 不支持 |
## 6. 代码结构对比
### RustDesk 原版结构
```
rustdesk/
├── libs/hbb_common/ # 公共库
│ ├── protos/ # Protobuf 定义
│ └── src/
├── src/
│ ├── server/ # 被控端服务
│ ├── client/ # 控制端
│ ├── ui/ # 用户界面
│ └── rendezvous_mediator.rs
```
### One-KVM 结构
```
src/rustdesk/
├── mod.rs # 模块导出
├── config.rs # 配置类型 (~164 行)
├── crypto.rs # 加密模块 (~468 行)
├── bytes_codec.rs # 帧编码 (~253 行)
├── protocol.rs # 消息辅助 (~170 行)
├── rendezvous.rs # Rendezvous 中介 (~829 行)
├── connection.rs # 连接处理 (~1349 行)
├── hid_adapter.rs # HID 转换 (~386 行)
└── frame_adapters.rs # 视频/音频适配 (~316 行)
```
**总计**: ~3935 行代码
## 7. 总结
### 实现率统计
| 类别 | RustDesk 功能数 | One-KVM 实现数 | 实现率 |
|------|-----------------|----------------|--------|
| Rendezvous 协议 | 15+ | 10 | ~67% |
| 连接协议 | 30+ | 12 | ~40% |
| 加密功能 | 8 | 6 | 75% |
| 视频/音频 | 6 | 6 | 100% |
| HID 功能 | 6 | 6 | 100% |
### 设计理念
One-KVM 的 RustDesk 实现专注于 **IP-KVM 核心功能**:
1. **精简**: 只实现必要的被控端功能
2. **可靠**: 使用 TCP 中继保证连接稳定性
3. **集成**: 与 One-KVM 现有视频/HID 系统无缝集成
4. **安全**: 完整实现加密和认证机制
### 客户端兼容性
One-KVM 可与标准 RustDesk 客户端配合使用:
- RustDesk 桌面客户端 (Windows/macOS/Linux)
- RustDesk 移动客户端 (Android/iOS)
- RustDesk Web 客户端
只需确保:
1. 配置相同的 Rendezvous 服务器
2. 使用设备 ID 和密码连接
3. 客户端支持中继连接

View File

@@ -1,920 +0,0 @@
# One-KVM 系统架构文档
## 1. 项目概述
One-KVM 是一个用 Rust 编写的轻量级、开源 IP-KVM 解决方案。它提供 BIOS 级别的远程服务器管理能力,支持视频流、键鼠控制、虚拟存储、电源管理和音频等功能。
### 1.1 核心特性
- **单一二进制部署**Web UI + 后端一体化,无需额外配置文件
- **双流模式**:支持 WebRTCH264/H265/VP8/VP9和 MJPEG 两种流模式
- **USB OTG**:虚拟键鼠、虚拟存储、虚拟网卡
- **ATX 电源控制**GPIO/USB 继电器
- **RustDesk 协议集成**:支持跨平台访问
- **Vue3 SPA 前端**:支持中文/英文
- **SQLite 配置存储**:无需配置文件
### 1.2 目标平台
| 平台 | 架构 | 用途 |
|------|------|------|
| aarch64-unknown-linux-gnu | ARM64 | 主要目标Rockchip RK3328 等) |
| armv7-unknown-linux-gnueabihf | ARMv7 | 备选平台 |
| x86_64-unknown-linux-gnu | x86-64 | 开发/测试环境 |
---
## 2. 系统架构图
### 2.1 整体架构
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ One-KVM System │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Web Frontend (Vue3) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Console │ │ Settings │ │ Login │ │ Setup │ │ Virtual │ │ │
│ │ │ View │ │ View │ │ View │ │ View │ │ Keyboard │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ │ │ │
│ │ ┌─────────────────┴─────────────────┐ │ │
│ │ │ Pinia State Store │ │ │
│ │ └─────────────────┬─────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ API Client Layer │ │ │
│ │ │ HTTP REST │ WebSocket │ WebRTC Signaling │ MJPEG │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ HTTP/WS/WebRTC │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Axum Web Server (routes.rs) │ │
│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ │ Public │ │ User │ │ Admin │ │ Static │ │ │
│ │ │ Routes │ │ Routes │ │ Routes │ │ Files │ │ │
│ │ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ AppState (state.rs) │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Central State Hub │ │ │
│ │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │
│ │ │ │ConfigStore │ │SessionStore│ │ UserStore │ │ │ │
│ │ │ │ (SQLite) │ │ (Memory) │ │ (SQLite) │ │ │ │
│ │ │ └────────────┘ └────────────┘ └────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │
│ │ │ │ EventBus │ │ OtgService │ │ Extensions │ │ │ │
│ │ │ │ (Broadcast)│ │ (USB) │ │ Manager │ │ │ │
│ │ │ └────────────┘ └────────────┘ └────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────┼─────────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Video │ │ HID │ │ Audio │ │
│ │ Module │ │ Module │ │ Module │ │
│ ├────────────┤ ├────────────┤ ├────────────┤ │
│ │ Capture │ │ Controller │ │ Capture │ │
│ │ Encoder │ │ OTG Backend│ │ Encoder │ │
│ │ Streamer │ │ CH9329 │ │ Pipeline │ │
│ │ Pipeline │ │ Monitor │ │ (Opus) │ │
│ │ Manager │ │ DataChan │ │ Shared │ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ │ │ │ │
│ └───────────────────────────┼──────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────┼─────────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ MSD │ │ ATX │ │ RustDesk │ │
│ │ Module │ │ Module │ │ Module │ │
│ ├────────────┤ ├────────────┤ ├────────────┤ │
│ │ Controller │ │ Controller │ │ Service │ │
│ │ Image Mgr │ │ Executor │ │ Rendezvous │ │
│ │ Ventoy │ │ LED Monitor│ │ Connection │ │
│ │ Drive │ │ WOL │ │ Protocol │ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Hardware Layer │
├─────────────────────────────────────────────────────────────────────────────┤
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ V4L2 Video │ │ USB OTG │ │ GPIO │ │ ALSA │ │
│ │ Device │ │ Gadget │ │ Sysfs │ │ Audio │ │
│ │/dev/video* │ │ ConfigFS │ │ │ │ │ │
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 2.2 数据流架构
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Data Flow Overview │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────┐
│ Target PC │
└────────┬────────┘
┌────────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ HDMI Capture │ │ USB Port │ │ GPIO/Relay │
│ Card │ │ (OTG Mode) │ │ (ATX) │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ /dev/video0 │ │ /dev/hidg* │ │ /sys/class/ │
│ (V4L2) │ │ (USB Gadget) │ │ gpio/gpio* │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ One-KVM Application │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Video │ │ HID │ │ ATX │ │
│ │ Pipeline │ │ Controller │ │ Controller │ │
│ └─────┬───────┘ └─────┬───────┘ └─────┬───────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Event Bus │ │
│ │ (tokio broadcast channel) │ │
│ └───────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Web Server (Axum) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ MJPEG │ │ WebRTC │ │WebSocket │ │ │
│ │ │ Stream │ │ Stream │ │ Events │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ Client Browser │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Video │ │ Input │ │ Control │ │
│ │ Display │ │ Events │ │ Panel │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## 3. 模块依赖关系
### 3.1 模块层次图
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Application Layer │
├─────────────────────────────────────────────────────────────────────────────┤
│ main.rs ──► state.rs ──► web/routes.rs │
│ │ │
│ ┌───────────┼───────────┬───────────┬───────────┬───────────┐ │
│ │ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ ▼ │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │video/│ │ hid/ │ │ msd/ │ │ atx/ │ │audio/│ │webrtc│ │
│ └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ │
│ │ │ │ │ │ │ │
│ │ └──────────┼──────────┘ │ │ │
│ │ │ │ │ │
│ │ ┌─────▼─────┐ │ │ │
│ │ │ otg/ │ │ │ │
│ │ │ (OtgSvc) │ │ │ │
│ │ └───────────┘ │ │ │
│ │ │ │ │
│ └──────────────────────────────────────────┼──────────┘ │
│ │ │
├─────────────────────────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
├─────────────────────────────────────────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ config/ │ │ auth/ │ │ events/ │ │extensions│ │
│ │(ConfigSt)│ │(Session) │ │(EventBus)│ │(ExtMgr) │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ rustdesk/ (RustDeskService) │ │
│ │ connection.rs │ rendezvous.rs │ crypto.rs │ protocol.rs │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 3.2 依赖矩阵
| 模块 | 依赖的模块 |
|------|-----------|
| `main.rs` | state, config, auth, video, hid, msd, atx, audio, webrtc, web, rustdesk, events |
| `state.rs` | config, auth, video, hid, msd, atx, audio, webrtc, rustdesk, events, otg |
| `video/` | events, hwcodec (外部) |
| `hid/` | otg, events |
| `msd/` | otg, events |
| `atx/` | events |
| `audio/` | events |
| `webrtc/` | video, audio, hid, events |
| `web/` | state, auth, config, video, hid, msd, atx, audio, webrtc, events |
| `rustdesk/` | video, audio, hid, events |
| `otg/` | (无内部依赖) |
| `config/` | (无内部依赖) |
| `auth/` | config |
| `events/` | (无内部依赖) |
---
## 4. 核心组件详解
### 4.1 AppState (state.rs)
AppState 是整个应用的状态中枢,通过 `Arc` 包装的方式在所有 handler 之间共享。
```rust
pub struct AppState {
// 配置和存储
config: ConfigStore, // SQLite 配置存储
sessions: SessionStore, // 会话存储(内存)
users: UserStore, // SQLite 用户存储
// 核心服务
otg_service: Arc<OtgService>, // USB Gadget 统一管理HID/MSD 生命周期协调者)
stream_manager: Arc<VideoStreamManager>, // 视频流管理器MJPEG/WebRTC
hid: Arc<HidController>, // HID 控制器(键鼠控制)
msd: Arc<RwLock<Option<MsdController>>>, // MSD 控制器可选虚拟U盘
atx: Arc<RwLock<Option<AtxController>>>, // ATX 控制器(可选,电源控制)
audio: Arc<AudioController>, // 音频控制器ALSA + Opus
rustdesk: Arc<RwLock<Option<Arc<RustDeskService>>>>, // RustDesk可选远程访问
extensions: Arc<ExtensionManager>,// 扩展管理器ttyd, gostc, easytier
// 通信和生命周期
events: Arc<EventBus>, // 事件总线tokio broadcast channel
shutdown_tx: broadcast::Sender<()>, // 关闭信号
data_dir: PathBuf, // 数据目录
}
```
### 4.2 视频流管道
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Video Pipeline Architecture │
└─────────────────────────────────────────────────────────────────────────────┘
┌───────────────────┐
│ V4L2 Device │
│ /dev/video0 │
└─────────┬─────────┘
│ Raw MJPEG/YUYV/NV12
┌───────────────────┐
│ VideoCapturer │ ◄─── src/video/capture.rs
│ (capture.rs) │
└─────────┬─────────┘
│ VideoFrame
┌───────────────────────────────────────────────────────────────────────────┐
│ SharedVideoPipeline │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Decode Stage │ │
│ │ ┌─────────────┐ │ │
│ │ │ MJPEG → YUV │ turbojpeg / VAAPI │ │
│ │ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Convert Stage │ │
│ │ ┌─────────────┐ │ │
│ │ │YUV → Target │ libyuv (SIMD accelerated) │ │
│ │ │ Format │ │ │
│ │ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Encode Stage │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ H264 │ │ H265 │ │ VP8 │ │ VP9 │ │ │
│ │ │Encoder │ │Encoder │ │Encoder │ │Encoder │ │ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │
│ │ │ (VAAPI/RKMPP/V4L2 M2M/Software) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────┘
├──────────────────────────────┬──────────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ MJPEG Streamer │ │ WebRTC Streamer │ │ RustDesk Service │
│ (HTTP Stream) │ │ (RTP Packets) │ │ (P2P Stream) │
│ │ │ │ │ │
│ - HTTP/1.1 │ │ - DataChannel │ │ - TCP/UDP Relay │
│ - multipart/x- │ │ - SRTP │ │ - NaCl Encrypted │
│ mixed-replace │ │ - ICE/STUN/TURN │ │ - Rendezvous │
└───────────────────┘ └───────────────────┘ └───────────────────┘
│ │ │
▼ ▼ ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ Clients │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Browser │ │ Browser │ │ RustDesk Client │ │
│ │ (MJPEG) │ │ (WebRTC) │ │ (Desktop/Mobile) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────┘
```
### 4.3 OTG 服务架构
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ OTG Service Architecture │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ OtgService (service.rs) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Public Interface │ │
│ │ enable_hid() │ disable_hid() │ enable_msd() │ disable_msd() │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ OtgGadgetManager (manager.rs) │ │
│ │ ┌───────────────────────────────────────────────────────────────┐ │ │
│ │ │ Gadget Lifecycle │ │ │
│ │ │ create_gadget() │ destroy_gadget() │ bind_udc() │ unbind() │ │ │
│ │ └───────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────┼────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ HID Function │ │ MSD Function │ │ Endpoint Alloc │ │
│ │ (hid.rs) │ │ (msd.rs) │ │ (endpoint.rs) │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ConfigFS Operations │ │
│ │ /sys/kernel/config/usb_gadget/one-kvm/ │ │
│ │ ├── idVendor, idProduct, strings/ │ │
│ │ ├── configs/c.1/ │ │
│ │ │ └── functions/ (symlinks) │ │
│ │ └── functions/ │ │
│ │ ├── hid.usb0, hid.usb1, hid.usb2 │ │
│ │ └── mass_storage.usb0 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Linux Kernel │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ /dev/hidg* │ │ Mass Storage │ │
│ │ (HID devices) │ │ Backend │ │
│ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 4.4 事件系统架构
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Event System Architecture │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Event Producers │
├─────────────────────────────────────────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Video │ │ HID │ │ MSD │ │ ATX │ │ Audio │ │
│ │ Module │ │ Module │ │ Module │ │ Module │ │ Module │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │ │
│ └────────────┴────────────┼────────────┴────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ EventBus │ │
│ │ (tokio broadcast channel) │ │
│ │ ┌───────────────────────────────────────────────────────────────┐ │ │
│ │ │ SystemEvent Enum │ │ │
│ │ │ StreamStateChanged │ HidStateChanged │ MsdStateChanged │ │ │
│ │ │ AtxStateChanged │ AudioStateChanged │ DeviceInfo │ Error │ │ │
│ │ └───────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────┼─────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │WebSocket │ │ DeviceInfo│ │ Internal │ │
│ │ Clients │ │Broadcaster│ │ Tasks │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## 5. 初始化流程
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Application Startup Flow │
└─────────────────────────────────────────────────────────────────────────────┘
main()
├──► Parse CLI Arguments (clap)
│ - address, port, data_dir
│ - enable_https, ssl_cert, ssl_key
│ - verbosity (-v, -vv, -vvv)
├──► Initialize Logging (tracing)
├──► Create/Open SQLite Database
│ └─► ConfigStore::new()
│ └─► UserStore::new()
│ └─► SessionStore::new()
├──► Initialize Core Services
│ │
│ ├──► EventBus::new()
│ │ └─► Create tokio broadcast channel
│ │
│ ├──► OtgService::new()
│ │ └─► Detect UDC device (/sys/class/udc)
│ │ └─► Initialize OtgGadgetManager
│ │
│ ├──► HidController::new()
│ │ └─► Detect backend type (OTG/CH9329/None)
│ │ └─► Create controller with optional OtgService
│ │
│ ├──► HidController::init()
│ │ └─► Request HID function from OtgService
│ │ └─► Create HID devices (/dev/hidg0-3)
│ │ └─► Open device files with O_NONBLOCK
│ │ └─► Initialize HidHealthMonitor
│ │
│ ├──► MsdController::init() (if configured)
│ │ └─► Request MSD function from OtgService
│ │ └─► Create mass storage device
│ │ └─► Initialize Ventoy drive (if available)
│ │
│ ├──► AtxController::init() (if configured)
│ │ └─► Setup GPIO pins or USB relay
│ │
│ ├──► AudioController::init()
│ │ └─► Open ALSA device
│ │ └─► Initialize Opus encoder
│ │
│ ├──► VideoStreamManager::new()
│ │ └─► Initialize SharedVideoPipeline
│ │ └─► Setup encoder registry (H264/H265/VP8/VP9)
│ │ └─► Detect hardware acceleration (VAAPI/RKMPP/V4L2 M2M)
│ │
│ └──► RustDeskService::new() (if configured)
│ └─► Load/generate device ID and keys
│ └─► Connect to rendezvous server
├──► Create AppState
│ └─► Wrap all services in Arc<>
├──► Spawn Background Tasks
│ ├──► spawn_device_info_broadcaster()
│ ├──► extension_health_check_task()
│ └──► rustdesk_reconnect_task()
├──► Create Axum Router
│ └─► create_router(app_state)
└──► Start HTTP/HTTPS Server
└─► axum::serve() or axum_server with TLS
```
---
## 6. 目录结构
```
One-KVM-RUST/
├── src/ # Rust 源代码
│ ├── main.rs # 应用入口点
│ ├── lib.rs # 库导出
│ ├── state.rs # AppState 定义
│ ├── error.rs # 错误类型定义
│ │
│ ├── video/ # 视频模块
│ │ ├── mod.rs
│ │ ├── capture.rs # V4L2 采集
│ │ ├── streamer.rs # 视频流服务
│ │ ├── stream_manager.rs # 流管理器
│ │ ├── shared_video_pipeline.rs # 共享视频管道
│ │ ├── format.rs # 像素格式
│ │ ├── frame.rs # 视频帧
│ │ ├── convert.rs # 格式转换
│ │ └── encoder/ # 编码器
│ │ ├── mod.rs
│ │ ├── traits.rs
│ │ ├── h264.rs
│ │ ├── h265.rs
│ │ ├── vp8.rs
│ │ ├── vp9.rs
│ │ └── jpeg.rs
│ │
│ ├── hid/ # HID 模块
│ │ ├── mod.rs # HidController主控制器
│ │ ├── backend.rs # HidBackend trait 和 HidBackendType
│ │ ├── otg.rs # OTG 后端USB Gadget HID
│ │ ├── ch9329.rs # CH9329 串口后端
│ │ ├── consumer.rs # Consumer Control usage codes
│ │ ├── keymap.rs # JS keyCode → USB HID 转换表
│ │ ├── types.rs # 事件类型定义
│ │ ├── monitor.rs # HidHealthMonitor错误跟踪与恢复
│ │ ├── datachannel.rs # DataChannel 二进制协议解析
│ │ └── websocket.rs # WebSocket 二进制协议适配
│ │
│ ├── otg/ # USB OTG 模块
│ │ ├── mod.rs
│ │ ├── service.rs # OtgService
│ │ ├── manager.rs # GadgetManager
│ │ ├── hid.rs # HID Function
│ │ ├── msd.rs # MSD Function
│ │ ├── configfs.rs # ConfigFS 操作
│ │ ├── endpoint.rs # 端点分配
│ │ └── report_desc.rs # HID 报告描述符
│ │
│ ├── msd/ # MSD 模块
│ │ ├── mod.rs
│ │ ├── controller.rs # MsdController
│ │ ├── image.rs # 镜像管理
│ │ ├── ventoy_drive.rs # Ventoy 驱动
│ │ ├── monitor.rs # 健康监视
│ │ └── types.rs # 类型定义
│ │
│ ├── atx/ # ATX 模块
│ │ ├── mod.rs
│ │ ├── controller.rs # AtxController
│ │ ├── executor.rs # 动作执行器
│ │ ├── types.rs # 类型定义
│ │ ├── led.rs # LED 监视
│ │ └── wol.rs # Wake-on-LAN
│ │
│ ├── audio/ # 音频模块
│ │ ├── mod.rs
│ │ ├── controller.rs # AudioController
│ │ ├── capture.rs # ALSA 采集
│ │ ├── encoder.rs # Opus 编码
│ │ ├── shared_pipeline.rs # 共享管道
│ │ ├── monitor.rs # 健康监视
│ │ └── device.rs # 设备枚举
│ │
│ ├── webrtc/ # WebRTC 模块
│ │ ├── mod.rs
│ │ ├── webrtc_streamer.rs # WebRTC 管理器
│ │ ├── universal_session.rs # 会话管理
│ │ ├── video_track.rs # 视频轨道
│ │ ├── rtp.rs # RTP 打包
│ │ ├── h265_payloader.rs # H265 RTP
│ │ ├── peer.rs # PeerConnection
│ │ ├── config.rs # 配置
│ │ ├── signaling.rs # 信令
│ │ └── track.rs # 轨道基类
│ │
│ ├── auth/ # 认证模块
│ │ ├── mod.rs
│ │ ├── user.rs # 用户管理
│ │ ├── session.rs # 会话管理
│ │ ├── password.rs # 密码哈希
│ │ └── middleware.rs # Axum 中间件
│ │
│ ├── config/ # 配置模块
│ │ ├── mod.rs
│ │ ├── schema.rs # 配置结构定义
│ │ └── store.rs # SQLite 存储
│ │
│ ├── events/ # 事件模块
│ │ └── mod.rs # EventBus
│ │
│ ├── rustdesk/ # RustDesk 模块
│ │ ├── mod.rs # RustDeskService
│ │ ├── connection.rs # 连接管理
│ │ ├── rendezvous.rs # 渲染服务器通信
│ │ ├── crypto.rs # NaCl 加密
│ │ ├── config.rs # 配置
│ │ ├── hid_adapter.rs # HID 适配
│ │ ├── frame_adapters.rs # 帧格式转换
│ │ ├── protocol.rs # 协议包装
│ │ └── bytes_codec.rs # 帧编码
│ │
│ ├── extensions/ # 扩展模块
│ │ └── mod.rs # ExtensionManager
│ │
│ ├── web/ # Web 模块
│ │ ├── mod.rs
│ │ ├── routes.rs # 路由定义
│ │ ├── ws.rs # WebSocket
│ │ ├── audio_ws.rs # 音频 WebSocket
│ │ ├── static_files.rs # 静态文件
│ │ └── handlers/ # API 处理器
│ │ ├── mod.rs
│ │ └── config/
│ │
│ ├── stream/ # MJPEG 流
│ │ └── mod.rs
│ │
│ └── utils/ # 工具函数
│ └── mod.rs
├── web/ # Vue3 前端
│ ├── src/
│ │ ├── views/ # 页面组件
│ │ ├── components/ # UI 组件
│ │ ├── api/ # API 客户端
│ │ ├── stores/ # Pinia 状态
│ │ ├── router/ # 路由配置
│ │ ├── i18n/ # 国际化
│ │ └── types/ # TypeScript 类型
│ └── package.json
├── libs/ # 外部库
│ ├── hwcodec/ # 硬件视频编码
│ └── ventoy-img-rs/ # Ventoy 支持
├── protos/ # Protobuf 定义
│ ├── message.proto # RustDesk 消息
│ └── rendezvous.proto # RustDesk 渲染
├── docs/ # 文档
├── scripts/ # 脚本
├── Cargo.toml # Rust 配置
├── build.rs # 构建脚本
└── README.md
```
---
## 7. 安全架构
### 7.1 认证流程
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Authentication Flow │
└─────────────────────────────────────────────────────────────────────────────┘
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ Client │ │ Axum │ │ Auth │ │ SQLite │
│ Browser │ │ Server │ │ Module │ │ Database │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │ │
│ POST /auth/login │ │ │
│ {username, pass} │ │ │
│───────────────────►│ │ │
│ │ verify_user() │ │
│ │───────────────────►│ │
│ │ │ SELECT user │
│ │ │───────────────────►│
│ │ │◄───────────────────│
│ │ │ │
│ │ │ Argon2 verify │
│ │ │ ────────────► │
│ │ │ │
│ │ session_token │ │
│ │◄───────────────────│ │
│ │ │ │
│ Set-Cookie: │ │ │
│ session_id=token │ │ │
│◄───────────────────│ │ │
│ │ │ │
│ GET /api/... │ │ │
│ Cookie: session │ │ │
│───────────────────►│ │ │
│ │ validate_session()│ │
│ │───────────────────►│ │
│ │ user_info │ │
│ │◄───────────────────│ │
│ │ │ │
│ Response │ │ │
│◄───────────────────│ │ │
```
### 7.2 权限层级
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Permission Levels │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Public (No Auth) │
│ ├── GET /health │
│ ├── POST /auth/login │
│ ├── GET /setup │
│ └── POST /setup/init │
├─────────────────────────────────────────────────────────────────────────────┤
│ User (Authenticated) │
│ ├── GET /info (系统信息) │
│ ├── GET /devices (设备列表) │
│ ├── GET/POST /stream/* (流控制) │
│ ├── POST /webrtc/* (WebRTC 信令) │
│ ├── POST /hid/* (HID 控制) │
│ ├── POST /audio/* (音频控制) │
│ └── WebSocket endpoints (实时通信) │
├─────────────────────────────────────────────────────────────────────────────┤
│ Admin (Admin Role) │
│ ├── GET/PATCH /config/* (配置管理) │
│ ├── POST /msd/* (MSD 操作) │
│ ├── POST /atx/* (电源控制) │
│ ├── POST /extensions/* (扩展管理) │
│ ├── POST /rustdesk/* (RustDesk 配置) │
│ └── POST /users/* (用户管理) │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## 8. 部署架构
### 8.1 单机部署
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Single Binary Deployment │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ ARM64 Device (e.g., Rockchip RK3328) │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ one-kvm (single binary, ~15MB) │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Embedded Assets (rust-embed, gzip compressed) │ │ │
│ │ │ - index.html, app.js, app.css, assets/* │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Runtime Data (data_dir) │ │ │
│ │ │ - one-kvm.db (SQLite) │ │ │
│ │ │ - images/ (MSD images) │ │ │
│ │ │ - certs/ (SSL certificates) │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ Hardware Connections: │
│ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ │
│ │ HDMI Input │ │ USB OTG Port │ │ GPIO Header │ │
│ │ (/dev/video0) │ │ (USB Gadget) │ │ (ATX Control) │ │
│ └───────────────────┘ └───────────────────┘ └───────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│ USB Cable
┌─────────────────────────────────────────────────────────────────────────────┐
│ Target PC │
│ - Receives USB HID events (keyboard/mouse) │
│ - Provides HDMI video output │
│ - Can boot from virtual USB drive │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 8.2 网络拓扑
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Network Topology │
└─────────────────────────────────────────────────────────────────────────────┘
Internet
┌───────────┴───────────┐
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ RustDesk │ │ Client │
│ Server │ │ Browser │
│ (hbbs/hbbr) │ │ │
└───────────────┘ └───────────────┘
│ │
│ │
└───────────┬───────────┘
┌────┴────┐
│ Router │
│ NAT │
└────┬────┘
Local Network
┌───────────┴───────────┐
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ One-KVM │───────│ Target PC │
│ Device │ USB │ │
│ :8080/:8443 │ HID │ │
└───────────────┘ └───────────────┘
Access Methods:
1. Local: http://one-kvm.local:8080
2. HTTPS: https://one-kvm.local:8443
3. RustDesk: Via RustDesk client with device ID
```
---
## 9. 扩展点
### 9.1 添加新编码器
```rust
// 1. 实现 Encoder trait
impl Encoder for MyEncoder {
fn encode(&mut self, frame: &VideoFrame) -> Result<Vec<u8>>;
fn codec(&self) -> Codec;
fn bitrate(&self) -> u32;
// ...
}
// 2. 在 registry 中注册
encoder_registry.register("my-encoder", || Box::new(MyEncoder::new()));
```
### 9.2 添加新 HID 后端
```rust
// 1. 在 backend.rs 中定义新后端类型
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum HidBackendType {
Otg,
Ch9329 { port: String, baud_rate: u32 },
MyBackend { /* 配置参数 */ }, // 新增
None,
}
// 2. 实现 HidBackend trait
#[async_trait]
impl HidBackend for MyBackend {
fn name(&self) -> &'static str { "MyBackend" }
async fn init(&self) -> Result<()> { /* ... */ }
async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> { /* ... */ }
async fn send_mouse(&self, event: MouseEvent) -> Result<()> { /* ... */ }
async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> { /* ... */ }
async fn reset(&self) -> Result<()> { /* ... */ }
async fn shutdown(&self) -> Result<()> { /* ... */ }
fn supports_absolute_mouse(&self) -> bool { true }
fn screen_resolution(&self) -> Option<(u32, u32)> { None }
fn set_screen_resolution(&mut self, width: u32, height: u32) { /* ... */ }
}
// 3. 在 HidController::init() 中添加分支
match backend_type {
HidBackendType::MyBackend { /* params */ } => {
Box::new(MyBackend::new(/* params */)?)
}
// ...
}
```
### 9.3 添加新扩展
```rust
// 通过 ExtensionManager 管理外部进程
extension_manager.register("my-extension", ExtensionConfig {
command: "my-binary",
args: vec!["--port", "9000"],
health_check: HealthCheckConfig::Http { url: "http://localhost:9000/health" },
});
```
---
## 10. 参考资料
- [Axum Web Framework](https://github.com/tokio-rs/axum)
- [webrtc-rs](https://github.com/webrtc-rs/webrtc)
- [V4L2 Documentation](https://www.kernel.org/doc/html/latest/userspace-api/media/v4l/v4l2.html)
- [Linux USB Gadget](https://www.kernel.org/doc/html/latest/usb/gadget_configfs.html)
- [RustDesk Protocol](https://github.com/rustdesk/rustdesk)

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,9 @@ version = "0.8.0"
edition = "2021"
description = "Hardware video codec for IP-KVM (Windows/Linux)"
[package.metadata.cargo-machete]
ignored = ["serde"]
[features]
default = []
rkmpp = []
@@ -17,6 +20,3 @@ serde_json = "1.0"
[build-dependencies]
cc = "1.0"
bindgen = "0.59"
[dev-dependencies]
env_logger = "0.10"

View File

@@ -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();

View File

@@ -162,10 +162,19 @@ int linux_support_v4l2m2m() {
};
// Check common V4L2 M2M device paths used by various ARM SoCs
// /dev/video10 - Standard on many SoCs
// /dev/video11 - Standard on many SoCs (often decoder)
// /dev/video0 - Some platforms (like RPi) might use this
// /dev/video1 - Alternate RPi path
// /dev/video2 - Alternate path
// /dev/video32 - Some Allwinner/Rockchip legacy
const char *m2m_devices[] = {
"/dev/video10", // Common M2M encoder device
"/dev/video11", // Common M2M decoder device
"/dev/video0", // Some SoCs use video0 for M2M
"/dev/video10",
"/dev/video11",
"/dev/video0",
"/dev/video1",
"/dev/video2",
"/dev/video32",
};
for (size_t i = 0; i < sizeof(m2m_devices) / sizeof(m2m_devices[0]); i++) {

View File

@@ -147,11 +147,11 @@ bool set_lantency_free(void *priv_data, const std::string &name) {
// V4L2 M2M hardware encoder - minimize buffer latency
if (name.find("v4l2m2m") != std::string::npos) {
// Minimize number of output buffers for lower latency
if ((ret = av_opt_set_int(priv_data, "num_output_buffers", 2, 0)) < 0) {
if ((ret = av_opt_set_int(priv_data, "num_output_buffers", 4, 0)) < 0) {
LOG_WARN(std::string("v4l2m2m set num_output_buffers failed, ret = ") + av_err2str(ret));
// Not fatal
}
if ((ret = av_opt_set_int(priv_data, "num_capture_buffers", 2, 0)) < 0) {
if ((ret = av_opt_set_int(priv_data, "num_capture_buffers", 4, 0)) < 0) {
LOG_WARN(std::string("v4l2m2m set num_capture_buffers failed, ret = ") + av_err2str(ret));
// Not fatal
}
@@ -500,4 +500,4 @@ bool has_flag_could_not_find_ref_with_poc() {
extern "C" void hwcodec_set_flag_could_not_find_ref_with_poc() {
util_decode::g_flag_could_not_find_ref_with_poc = true;
}
}

View File

@@ -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);

View File

@@ -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));

View File

@@ -6,6 +6,7 @@
include!(concat!(env!("OUT_DIR"), "/ffmpeg_ffi.rs"));
use serde_derive::{Deserialize, Serialize};
use std::{env, ffi::CString};
#[derive(Debug, Eq, PartialEq, Clone, Copy, Serialize, Deserialize)]
pub enum AVHWDeviceType {
@@ -53,7 +54,52 @@ pub extern "C" fn hwcodec_av_log_callback(level: i32, message: *const std::os::r
pub(crate) fn init_av_log() {
static INIT: std::sync::Once = std::sync::Once::new();
INIT.call_once(|| unsafe {
av_log_set_level(AV_LOG_ERROR as i32);
av_log_set_level(parse_ffmpeg_log_level());
hwcodec_set_av_log_callback();
});
}
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,
Err(_) => return AV_LOG_ERROR as i32,
};
let value = raw.trim().to_ascii_lowercase();
if value.is_empty() {
return AV_LOG_ERROR as i32;
}
if let Ok(level) = value.parse::<i32>() {
return level;
}
match value.as_str() {
"quiet" => AV_LOG_QUIET as i32,
"panic" => AV_LOG_PANIC as i32,
"fatal" => AV_LOG_FATAL as i32,
"error" => AV_LOG_ERROR as i32,
"warn" | "warning" => AV_LOG_WARNING as i32,
"info" => AV_LOG_INFO as i32,
"verbose" => AV_LOG_VERBOSE as i32,
"debug" => AV_LOG_DEBUG as i32,
"trace" => AV_LOG_TRACE as i32,
_ => AV_LOG_ERROR as i32,
}
}

View File

@@ -31,8 +31,10 @@ unsafe impl Send for HwMjpegH26xPipeline {}
impl HwMjpegH26xPipeline {
pub fn new(config: HwMjpegH26xConfig) -> Result<Self, String> {
unsafe {
let dec = CString::new(config.decoder.as_str()).map_err(|_| "decoder name invalid".to_string())?;
let enc = CString::new(config.encoder.as_str()).map_err(|_| "encoder name invalid".to_string())?;
let dec = CString::new(config.decoder.as_str())
.map_err(|_| "decoder name invalid".to_string())?;
let enc = CString::new(config.encoder.as_str())
.map_err(|_| "encoder name invalid".to_string())?;
let ctx = ffmpeg_hw_mjpeg_h26x_new(
dec.as_ptr(),
enc.as_ptr(),

View File

@@ -1,8 +1,7 @@
use crate::{
ffmpeg::{init_av_log, AVPixelFormat},
ffmpeg_ram::{
ffmpeg_ram_decode, ffmpeg_ram_free_decoder, ffmpeg_ram_last_error,
ffmpeg_ram_new_decoder,
ffmpeg_ram_decode, ffmpeg_ram_free_decoder, ffmpeg_ram_last_error, ffmpeg_ram_new_decoder,
},
};
use std::{

View File

@@ -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,275 +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 max_attempts = 1;
for attempt in 0..max_attempts {
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 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
}

View File

@@ -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 _,

View File

@@ -45,4 +45,4 @@ pub use error::{Result, VentoyError};
pub use exfat::FileInfo;
pub use image::VentoyImage;
pub use partition::{parse_size, PartitionLayout};
pub use resources::{get_resource_dir, init_resources, is_initialized, required_files};
pub use resources::{init_resources, is_initialized, required_files};

View File

@@ -5,7 +5,7 @@
use crate::error::{Result, VentoyError};
use std::fs;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::sync::OnceLock;
/// Resource file names
@@ -151,13 +151,6 @@ pub fn get_ventoy_disk_img() -> Result<&'static [u8]> {
})
}
/// Get the resource directory path for a given data directory
///
/// Returns `{data_dir}/ventoy`
pub fn get_resource_dir(data_dir: &Path) -> PathBuf {
data_dir.join("ventoy")
}
/// List required resource files
pub fn required_files() -> &'static [&'static str] {
&[BOOT_IMG_NAME, CORE_IMG_NAME, VENTOY_DISK_IMG_NAME]
@@ -166,22 +159,6 @@ pub fn required_files() -> &'static [&'static str] {
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn create_test_resources(dir: &Path) {
// Create boot.img (512 bytes)
let mut boot = std::fs::File::create(dir.join(BOOT_IMG_NAME)).unwrap();
boot.write_all(&[0u8; 512]).unwrap();
// Create core.img (fake, 1KB)
let mut core = std::fs::File::create(dir.join(CORE_IMG_NAME)).unwrap();
core.write_all(&[0u8; 1024]).unwrap();
// Create ventoy.disk.img (fake, 1KB)
let mut ventoy = std::fs::File::create(dir.join(VENTOY_DISK_IMG_NAME)).unwrap();
ventoy.write_all(&[0u8; 1024]).unwrap();
}
#[test]
fn test_required_files() {
@@ -191,11 +168,4 @@ mod tests {
assert!(files.contains(&"core.img"));
assert!(files.contains(&"ventoy.disk.img"));
}
#[test]
fn test_get_resource_dir() {
let data_dir = Path::new("/var/lib/one-kvm");
let resource_dir = get_resource_dir(data_dir);
assert_eq!(resource_dir, PathBuf::from("/var/lib/one-kvm/ventoy"));
}
}

View File

@@ -8,5 +8,4 @@ license = "BSD-3-Clause"
[dependencies]
[build-dependencies]
cc = "1.0"
bindgen = "0.59"

View File

@@ -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")

View File

@@ -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,

View File

@@ -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
View 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}"

View File

@@ -8,11 +8,11 @@ use tracing::{debug, info, warn};
use super::executor::{timing, AtxKeyExecutor};
use super::led::LedSensor;
use super::types::{AtxKeyConfig, AtxLedConfig, AtxState, PowerStatus};
use super::types::{AtxAction, AtxKeyConfig, AtxLedConfig, AtxState, PowerStatus};
use crate::error::{AppError, Result};
/// ATX power control configuration
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct AtxControllerConfig {
/// Whether ATX is enabled
pub enabled: bool,
@@ -24,17 +24,6 @@ pub struct AtxControllerConfig {
pub led: AtxLedConfig,
}
impl Default for AtxControllerConfig {
fn default() -> Self {
Self {
enabled: false,
power: AtxKeyConfig::default(),
reset: AtxKeyConfig::default(),
led: AtxLedConfig::default(),
}
}
}
/// Internal state holding all ATX components
/// Grouped together to reduce lock acquisitions
struct AtxInner {
@@ -54,6 +43,134 @@ pub struct AtxController {
}
impl AtxController {
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
}
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);
}
}
}
// Initialize LED sensor
if inner.config.led.is_configured() {
let mut sensor = LedSensor::new(inner.config.led.clone());
if let Err(e) = sensor.init().await {
warn!("Failed to initialize LED sensor: {}", e);
} else {
info!(
"LED sensor initialized on {} pin {}",
inner.config.led.gpio_chip, inner.config.led.gpio_pin
);
inner.led_sensor = Some(sensor);
}
}
}
async fn shutdown_components(inner: &mut AtxInner) {
if let Some(executor) = inner.power_executor.as_mut() {
if let Err(e) = executor.shutdown().await {
warn!("Failed to shutdown power executor: {}", e);
}
}
inner.power_executor = None;
if let Some(executor) = inner.reset_executor.as_mut() {
if let Err(e) = executor.shutdown().await {
warn!("Failed to shutdown reset executor: {}", e);
}
}
inner.reset_executor = None;
if let Some(sensor) = inner.led_sensor.as_mut() {
if let Err(e) = sensor.shutdown().await {
warn!("Failed to shutdown LED sensor: {}", e);
}
}
inner.led_sensor = None;
}
/// Create a new ATX controller with the specified configuration
pub fn new(config: AtxControllerConfig) -> Self {
Self {
@@ -82,275 +199,173 @@ impl AtxController {
info!("Initializing ATX controller");
// 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);
}
}
Self::init_components(&mut inner).await;
// 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);
}
}
// Initialize LED sensor
if inner.config.led.is_configured() {
let mut sensor = LedSensor::new(inner.config.led.clone());
if let Err(e) = sensor.init().await {
warn!("Failed to initialize LED sensor: {}", e);
} else {
info!(
"LED sensor initialized on {} pin {}",
inner.config.led.gpio_chip, inner.config.led.gpio_pin
);
inner.led_sensor = Some(sensor);
}
}
info!("ATX controller initialized successfully");
Ok(())
}
/// Reload the ATX controller with new configuration
///
/// This is called when configuration changes and supports hot-reload.
pub async fn reload(&self, new_config: AtxControllerConfig) -> Result<()> {
info!("Reloading ATX controller with new configuration");
/// Reload ATX controller configuration
pub async fn reload(&self, config: AtxControllerConfig) -> Result<()> {
let mut inner = self.inner.write().await;
// Shutdown existing executors
self.shutdown_internal().await?;
info!("Reloading ATX controller configuration");
// Update configuration and re-initialize
{
let mut inner = self.inner.write().await;
inner.config = new_config;
// Shutdown existing components first, then rebuild with new config.
Self::shutdown_components(&mut inner).await;
inner.config = config;
if !inner.config.enabled {
info!("ATX disabled after reload");
return Ok(());
}
// Re-initialize
self.init().await?;
Self::init_components(&mut inner).await;
info!("ATX controller reloaded");
info!("ATX controller reloaded successfully");
Ok(())
}
/// Get current ATX state (single lock acquisition)
/// Shutdown ATX controller and release all resources
pub async fn shutdown(&self) -> Result<()> {
let mut inner = self.inner.write().await;
Self::shutdown_components(&mut inner).await;
info!("ATX controller shutdown complete");
Ok(())
}
/// Trigger a power action (short/long/reset)
pub async fn trigger_power_action(&self, action: AtxAction) -> Result<()> {
let inner = self.inner.read().await;
match action {
AtxAction::Short | AtxAction::Long => {
if let Some(executor) = &inner.power_executor {
let duration = match action {
AtxAction::Short => timing::SHORT_PRESS,
AtxAction::Long => timing::LONG_PRESS,
_ => unreachable!(),
};
executor.pulse(duration).await?;
} else {
return Err(AppError::Config(
"Power button not configured for ATX controller".to_string(),
));
}
}
AtxAction::Reset => {
if let Some(executor) = &inner.reset_executor {
executor.pulse(timing::RESET_PRESS).await?;
} else {
return Err(AppError::Config(
"Reset button not configured for ATX controller".to_string(),
));
}
}
}
Ok(())
}
/// Trigger a short power button press
pub async fn power_short(&self) -> Result<()> {
self.trigger_power_action(AtxAction::Short).await
}
/// Trigger a long power button press
pub async fn power_long(&self) -> Result<()> {
self.trigger_power_action(AtxAction::Long).await
}
/// Trigger a reset button press
pub async fn reset(&self) -> Result<()> {
self.trigger_power_action(AtxAction::Reset).await
}
/// Get the current power status using the LED sensor (if configured)
pub async fn power_status(&self) -> PowerStatus {
let inner = self.inner.read().await;
if let Some(sensor) = &inner.led_sensor {
match sensor.read().await {
Ok(status) => status,
Err(e) => {
debug!("Failed to read ATX LED sensor: {}", e);
PowerStatus::Unknown
}
}
} else {
PowerStatus::Unknown
}
}
/// Get a snapshot of the ATX state for API responses
pub async fn state(&self) -> AtxState {
let inner = self.inner.read().await;
let power_status = if let Some(sensor) = inner.led_sensor.as_ref() {
sensor.read().await.unwrap_or(PowerStatus::Unknown)
let power_status = if let Some(sensor) = &inner.led_sensor {
match sensor.read().await {
Ok(status) => status,
Err(e) => {
debug!("Failed to read ATX LED sensor: {}", e);
PowerStatus::Unknown
}
}
} else {
PowerStatus::Unknown
};
AtxState {
available: inner.config.enabled,
power_configured: inner
.power_executor
.as_ref()
.map(|e| e.is_initialized())
.unwrap_or(false),
reset_configured: inner
.reset_executor
.as_ref()
.map(|e| e.is_initialized())
.unwrap_or(false),
power_configured: inner.power_executor.is_some(),
reset_configured: inner.reset_executor.is_some(),
power_status,
led_supported: inner
.led_sensor
.as_ref()
.map(|s| s.is_initialized())
.unwrap_or(false),
led_supported: inner.led_sensor.is_some(),
}
}
/// Get current state as SystemEvent
pub async fn current_state_event(&self) -> crate::events::SystemEvent {
let state = self.state().await;
crate::events::SystemEvent::AtxStateChanged {
power_status: state.power_status,
}
}
/// Check if ATX is available
pub async fn is_available(&self) -> bool {
let inner = self.inner.read().await;
inner.config.enabled
}
/// Check if power button is configured and initialized
pub async fn is_power_ready(&self) -> bool {
let inner = self.inner.read().await;
inner
.power_executor
.as_ref()
.map(|e| e.is_initialized())
.unwrap_or(false)
}
/// Check if reset button is configured and initialized
pub async fn is_reset_ready(&self) -> bool {
let inner = self.inner.read().await;
inner
.reset_executor
.as_ref()
.map(|e| e.is_initialized())
.unwrap_or(false)
}
/// Short press power button (turn on or graceful shutdown)
pub async fn power_short(&self) -> Result<()> {
let inner = self.inner.read().await;
let executor = inner
.power_executor
.as_ref()
.ok_or_else(|| AppError::Internal("Power button not configured".to_string()))?;
info!(
"ATX: Short press power button ({}ms)",
timing::SHORT_PRESS.as_millis()
);
executor.pulse(timing::SHORT_PRESS).await
}
/// Long press power button (force power off)
pub async fn power_long(&self) -> Result<()> {
let inner = self.inner.read().await;
let executor = inner
.power_executor
.as_ref()
.ok_or_else(|| AppError::Internal("Power button not configured".to_string()))?;
info!(
"ATX: Long press power button ({}ms)",
timing::LONG_PRESS.as_millis()
);
executor.pulse(timing::LONG_PRESS).await
}
/// Press reset button
pub async fn reset(&self) -> Result<()> {
let inner = self.inner.read().await;
let executor = inner
.reset_executor
.as_ref()
.ok_or_else(|| AppError::Internal("Reset button not configured".to_string()))?;
info!(
"ATX: Press reset button ({}ms)",
timing::RESET_PRESS.as_millis()
);
executor.pulse(timing::RESET_PRESS).await
}
/// Get current power status from LED sensor
pub async fn power_status(&self) -> Result<PowerStatus> {
let inner = self.inner.read().await;
match inner.led_sensor.as_ref() {
Some(sensor) => sensor.read().await,
None => Ok(PowerStatus::Unknown),
}
}
/// Shutdown the ATX controller
pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down ATX controller");
self.shutdown_internal().await?;
info!("ATX controller shutdown complete");
Ok(())
}
/// Internal shutdown helper
async fn shutdown_internal(&self) -> Result<()> {
let mut inner = self.inner.write().await;
// Shutdown power executor
if let Some(mut executor) = inner.power_executor.take() {
executor.shutdown().await.ok();
}
// Shutdown reset executor
if let Some(mut executor) = inner.reset_executor.take() {
executor.shutdown().await.ok();
}
// Shutdown LED sensor
if let Some(mut sensor) = inner.led_sensor.take() {
sensor.shutdown().await.ok();
}
Ok(())
}
}
impl Drop for AtxController {
fn drop(&mut self) {
debug!("ATX controller dropped");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::atx::AtxDriverType;
#[test]
fn test_controller_config_default() {
let config = AtxControllerConfig::default();
assert!(!config.enabled);
assert!(!config.power.is_configured());
assert!(!config.reset.is_configured());
assert!(!config.led.is_configured());
}
#[test]
fn test_controller_creation() {
let controller = AtxController::disabled();
assert!(controller.inner.try_read().is_ok());
}
#[tokio::test]
async fn test_controller_disabled_state() {
let controller = AtxController::disabled();
let state = controller.state().await;
assert!(!state.available);
assert!(!state.power_configured);
assert!(!state.reset_configured);
}
#[tokio::test]
async fn test_controller_init_disabled() {
let controller = AtxController::disabled();
let result = controller.init().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_controller_is_available() {
let controller = AtxController::disabled();
assert!(!controller.is_available().await);
let config = AtxControllerConfig {
enabled: true,
..Default::default()
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 controller = AtxController::new(config);
assert!(controller.is_available().await);
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));
}
}

View File

@@ -4,10 +4,12 @@
//! Each executor handles one button (power or reset) with its own hardware binding.
use gpio_cdev::{Chip, LineHandle, LineRequestFlags};
use serialport::SerialPort;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::os::fd::AsRawFd;
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};
@@ -15,6 +17,12 @@ use tracing::{debug, info};
use super::types::{ActiveLevel, AtxDriverType, AtxKeyConfig};
use crate::error::{AppError, Result};
pub type SharedSerialHandle = Arc<Mutex<Box<dyn SerialPort>>>;
const USB_RELAY_MAX_CHANNEL: u8 = 8;
const USB_RELAY_REPORT_LEN: usize = 9;
const HIDIOCSFEATURE_9: libc::c_ulong = 0xC009_4806; // _IOC(_IOC_READ|_IOC_WRITE, 'H', 0x06, 9)
/// Timing constants for ATX operations
pub mod timing {
use std::time::Duration;
@@ -38,6 +46,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 (can be shared across power/reset executors)
serial_handle: Mutex<Option<SharedSerialHandle>>,
initialized: AtomicBool,
}
@@ -48,10 +58,31 @@ impl AtxKeyExecutor {
config,
gpio_handle: Mutex::new(None),
usb_relay_handle: Mutex::new(None),
serial_handle: Mutex::new(None),
initialized: AtomicBool::new(false),
}
}
/// 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()
@@ -69,9 +100,12 @@ impl AtxKeyExecutor {
return Ok(());
}
self.validate_runtime_config()?;
match self.config.driver {
AtxDriverType::Gpio => self.init_gpio().await?,
AtxDriverType::UsbRelay => self.init_usb_relay().await?,
AtxDriverType::Serial => self.init_serial().await?,
AtxDriverType::None => {}
}
@@ -79,6 +113,50 @@ impl AtxKeyExecutor {
Ok(())
}
fn validate_runtime_config(&self) -> Result<()> {
match self.config.driver {
AtxDriverType::Serial => {
if self.config.pin == 0 {
return Err(AppError::Config(
"Serial ATX channel must be 1-based (>= 1)".to_string(),
));
}
if self.config.pin > u8::MAX as u32 {
return Err(AppError::Config(format!(
"Serial ATX channel must be <= {}",
u8::MAX
)));
}
if self.config.baud_rate == 0 {
return Err(AppError::Config(
"Serial ATX baud_rate must be greater than 0".to_string(),
));
}
}
AtxDriverType::UsbRelay => {
if self.config.pin == 0 {
return Err(AppError::Config(
"USB relay channel must be 1-based (>= 1)".to_string(),
));
}
if self.config.pin > u8::MAX as u32 {
return Err(AppError::Config(format!(
"USB relay channel must be <= {}",
u8::MAX
)));
}
if self.config.pin > USB_RELAY_MAX_CHANNEL as u32 {
return Err(AppError::Config(format!(
"USB HID relay channel must be <= {}",
USB_RELAY_MAX_CHANNEL
)));
}
}
AtxDriverType::Gpio | AtxDriverType::None => {}
}
Ok(())
}
/// Initialize GPIO backend
async fn init_gpio(&mut self) -> Result<()> {
info!(
@@ -134,6 +212,29 @@ impl AtxKeyExecutor {
Ok(())
}
/// Initialize Serial relay backend
async fn init_serial(&self) -> Result<()> {
info!(
"Initializing Serial relay ATX executor on {} channel {}",
self.config.device, self.config.pin
);
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)?;
debug!(
"Serial relay channel {} configured successfully",
self.config.pin
);
Ok(())
}
/// Pulse the button for the specified duration
pub async fn pulse(&self, duration: Duration) -> Result<()> {
if !self.is_configured() {
@@ -147,6 +248,7 @@ impl AtxKeyExecutor {
match self.config.driver {
AtxDriverType::Gpio => self.pulse_gpio(duration).await,
AtxDriverType::UsbRelay => self.pulse_usb_relay(duration).await,
AtxDriverType::Serial => self.pulse_serial(duration).await,
AtxDriverType::None => Ok(()),
}
}
@@ -199,23 +301,128 @@ impl AtxKeyExecutor {
/// Send USB relay command using cached handle
fn send_usb_relay_command(&self, on: bool) -> Result<()> {
let channel = self.config.pin as u8;
let channel = u8::try_from(self.config.pin).map_err(|_| {
AppError::Config(format!(
"USB relay channel {} exceeds max {}",
self.config.pin,
u8::MAX
))
})?;
if channel == 0 {
return Err(AppError::Config(
"USB relay channel must be 1-based (>= 1)".to_string(),
));
}
if channel > USB_RELAY_MAX_CHANNEL {
return Err(AppError::Config(format!(
"USB HID relay channel must be <= {}",
USB_RELAY_MAX_CHANNEL
)));
}
// Standard HID relay command format
let cmd = if on {
[0x00, channel + 1, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00]
} else {
[0x00, channel + 1, 0xFD, 0x00, 0x00, 0x00, 0x00, 0x00]
};
let cmd = Self::build_usb_relay_command(channel, on);
let mut guard = self.usb_relay_handle.lock().unwrap();
let device = guard
.as_mut()
.ok_or_else(|| AppError::Internal("USB relay not initialized".to_string()))?;
device
.write_all(&cmd)
.map_err(|e| AppError::Internal(format!("USB relay write failed: {}", e)))?;
if let Err(feature_err) = Self::send_usb_relay_feature_report(device, &cmd) {
debug!(
"USB relay feature report failed ({}), falling back to hidraw write",
feature_err
);
device.write_all(&cmd).map_err(|write_err| {
AppError::Internal(format!(
"USB relay feature report failed: {}; raw write failed: {}",
feature_err, write_err
))
})?;
device
.flush()
.map_err(|e| AppError::Internal(format!("USB relay flush failed: {}", e)))?;
}
Ok(())
}
fn build_usb_relay_command(channel: u8, on: bool) -> [u8; USB_RELAY_REPORT_LEN] {
let mut cmd = [0x00; USB_RELAY_REPORT_LEN];
cmd[1] = if on { 0xFF } else { 0xFD };
cmd[2] = channel;
cmd
}
fn send_usb_relay_feature_report(
device: &File,
report: &[u8; USB_RELAY_REPORT_LEN],
) -> std::io::Result<()> {
// Linux hidraw feature reports include the report ID as the first byte.
let rc = unsafe { libc::ioctl(device.as_raw_fd(), HIDIOCSFEATURE_9, report.as_ptr()) };
if rc < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
}
/// Pulse Serial relay
async fn pulse_serial(&self, duration: Duration) -> Result<()> {
info!(
"Pulse serial relay on {} pin {}",
self.config.device, self.config.pin
);
// Turn relay on
self.send_serial_relay_command(true)?;
// Wait for duration
sleep(duration).await;
// Turn relay off
self.send_serial_relay_command(false)?;
Ok(())
}
/// Send Serial relay command using cached handle
fn send_serial_relay_command(&self, on: bool) -> Result<()> {
let channel = u8::try_from(self.config.pin).map_err(|_| {
AppError::Config(format!(
"Serial relay channel {} exceeds max {}",
self.config.pin,
u8::MAX
))
})?;
if channel == 0 {
return Err(AppError::Config(
"Serial relay channel must be 1-based (>= 1)".to_string(),
));
}
// LCUS-Type Protocol
// Frame: [StopByte(A0), Channel, State, Checksum]
// Checksum = A0 + channel + state
let state = if on { 1 } else { 0 };
let checksum = 0xA0u8.wrapping_add(channel).wrapping_add(state);
// Example for Channel 1:
// ON: A0 01 01 A2
// OFF: A0 01 00 A1
let cmd = [0xA0, channel, state, checksum];
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)))?;
port.flush()
.map_err(|e| AppError::Internal(format!("Serial relay flush failed: {}", e)))?;
Ok(())
}
@@ -237,6 +444,12 @@ impl AtxKeyExecutor {
// Release USB relay handle
*self.usb_relay_handle.lock().unwrap() = None;
}
AtxDriverType::Serial => {
// Ensure relay is off before closing handle
let _ = self.send_serial_relay_command(false);
// Release Serial relay handle
*self.serial_handle.lock().unwrap() = None;
}
AtxDriverType::None => {}
}
@@ -256,6 +469,12 @@ impl Drop for AtxKeyExecutor {
let _ = self.send_usb_relay_command(false);
}
*self.usb_relay_handle.lock().unwrap() = None;
// Ensure Serial relay is off and handle released
if self.config.driver == AtxDriverType::Serial && self.is_initialized() {
let _ = self.send_serial_relay_command(false);
}
*self.serial_handle.lock().unwrap() = None;
}
}
@@ -278,6 +497,7 @@ mod tests {
device: "/dev/gpiochip0".to_string(),
pin: 5,
active_level: ActiveLevel::High,
baud_rate: 9600,
};
let executor = AtxKeyExecutor::new(config);
assert!(executor.is_configured());
@@ -289,8 +509,22 @@ mod tests {
let config = AtxKeyConfig {
driver: AtxDriverType::UsbRelay,
device: "/dev/hidraw0".to_string(),
pin: 0,
pin: 1,
active_level: ActiveLevel::High, // Ignored for USB relay
baud_rate: 9600,
};
let executor = AtxKeyExecutor::new(config);
assert!(executor.is_configured());
}
#[test]
fn test_executor_with_serial_config() {
let config = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 1,
active_level: ActiveLevel::High, // Ignored
baud_rate: 9600,
};
let executor = AtxKeyExecutor::new(config);
assert!(executor.is_configured());
@@ -302,4 +536,86 @@ mod tests {
assert_eq!(timing::LONG_PRESS.as_millis(), 5000);
assert_eq!(timing::RESET_PRESS.as_millis(), 500);
}
#[test]
fn test_usb_relay_command_format() {
assert_eq!(
AtxKeyExecutor::build_usb_relay_command(1, true),
[0x00, 0xFF, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
);
assert_eq!(
AtxKeyExecutor::build_usb_relay_command(1, false),
[0x00, 0xFD, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
);
}
#[tokio::test]
async fn test_executor_init_rejects_serial_channel_zero() {
let config = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 0,
active_level: ActiveLevel::High,
baud_rate: 9600,
};
let mut executor = AtxKeyExecutor::new(config);
let err = executor.init().await.unwrap_err();
assert!(matches!(err, AppError::Config(_)));
}
#[tokio::test]
async fn test_executor_init_rejects_usb_relay_channel_zero() {
let config = AtxKeyConfig {
driver: AtxDriverType::UsbRelay,
device: "/dev/hidraw0".to_string(),
pin: 0,
active_level: ActiveLevel::High,
baud_rate: 9600,
};
let mut executor = AtxKeyExecutor::new(config);
let err = executor.init().await.unwrap_err();
assert!(matches!(err, AppError::Config(_)));
}
#[tokio::test]
async fn test_executor_init_rejects_usb_relay_channel_overflow() {
let config = AtxKeyConfig {
driver: AtxDriverType::UsbRelay,
device: "/dev/hidraw0".to_string(),
pin: USB_RELAY_MAX_CHANNEL as u32 + 1,
active_level: ActiveLevel::High,
baud_rate: 9600,
};
let mut executor = AtxKeyExecutor::new(config);
let err = executor.init().await.unwrap_err();
assert!(matches!(err, AppError::Config(_)));
}
#[tokio::test]
async fn test_executor_init_rejects_serial_channel_overflow() {
let config = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 256,
active_level: ActiveLevel::High,
baud_rate: 9600,
};
let mut executor = AtxKeyExecutor::new(config);
let err = executor.init().await.unwrap_err();
assert!(matches!(err, AppError::Config(_)));
}
#[tokio::test]
async fn test_executor_init_rejects_zero_serial_baud_rate() {
let config = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 1,
active_level: ActiveLevel::High,
baud_rate: 0,
};
let mut executor = AtxKeyExecutor::new(config);
let err = executor.init().await.unwrap_err();
assert!(matches!(err, AppError::Config(_)));
}
}

View File

@@ -15,6 +15,7 @@
//!
//! - **GPIO**: Uses Linux GPIO character device (/dev/gpiochipX) for direct hardware control
//! - **USB Relay**: Uses HID USB relay modules for isolated switching
//! - **Serial Relay**: Uses LCUS-style serial relay modules
//!
//! # Example
//!
@@ -28,12 +29,14 @@
//! device: "/dev/gpiochip0".to_string(),
//! pin: 5,
//! active_level: ActiveLevel::High,
//! baud_rate: 9600,
//! },
//! reset: AtxKeyConfig {
//! driver: AtxDriverType::UsbRelay,
//! device: "/dev/hidraw0".to_string(),
//! pin: 0,
//! active_level: ActiveLevel::High,
//! baud_rate: 9600,
//! },
//! led: Default::default(),
//! };
@@ -57,9 +60,25 @@ pub use types::{
};
pub use wol::send_wol;
fn hidraw_uevent_is_usb_relay(uevent: &str) -> bool {
let upper = uevent.to_ascii_uppercase();
upper.contains("000016C0:000005DF")
|| upper.contains("16C0:05DF")
|| upper.contains("PRODUCT=16C0/5DF")
|| upper.contains("USBRELAY")
|| upper.contains("USB RELAY")
}
fn is_usb_relay_hidraw(name: &str) -> bool {
let uevent_path = format!("/sys/class/hidraw/{}/device/uevent", name);
std::fs::read_to_string(uevent_path)
.map(|uevent| hidraw_uevent_is_usb_relay(&uevent))
.unwrap_or(false)
}
/// Discover available ATX devices on the system
///
/// Scans for GPIO chips and USB HID relay devices in a single pass.
/// Scans for GPIO chips, LCUS USB HID relay devices, and serial relay ports.
pub fn discover_devices() -> AtxDevices {
let mut devices = AtxDevices::default();
@@ -70,14 +89,17 @@ pub fn discover_devices() -> AtxDevices {
let name_str = name.to_string_lossy();
if name_str.starts_with("gpiochip") {
devices.gpio_chips.push(format!("/dev/{}", name_str));
} else if name_str.starts_with("hidraw") {
} else if name_str.starts_with("hidraw") && is_usb_relay_hidraw(&name_str) {
devices.usb_relays.push(format!("/dev/{}", name_str));
} else if name_str.starts_with("ttyUSB") || name_str.starts_with("ttyACM") {
devices.serial_ports.push(format!("/dev/{}", name_str));
}
}
}
devices.gpio_chips.sort();
devices.usb_relays.sort();
devices.serial_ports.sort();
devices
}
@@ -88,10 +110,21 @@ 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);
let _devices = discover_devices();
}
#[test]
fn test_hidraw_uevent_detects_usb_relay_id() {
assert!(hidraw_uevent_is_usb_relay(
"HID_ID=0003:000016C0:000005DF\nHID_NAME=www.dcttech.com USBRelay2\n"
));
}
#[test]
fn test_hidraw_uevent_rejects_unrelated_hid() {
assert!(!hidraw_uevent_is_usb_relay(
"HID_ID=0003:0000046D:0000C534\nHID_NAME=Logitech USB Receiver\n"
));
}
#[test]

View File

@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
use typeshare::typeshare;
/// Power status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PowerStatus {
/// Power is on
@@ -15,51 +15,38 @@ pub enum PowerStatus {
/// Power is off
Off,
/// Power status unknown (no LED connected)
#[default]
Unknown,
}
impl Default for PowerStatus {
fn default() -> Self {
Self::Unknown
}
}
/// Driver type for ATX key operations
#[typeshare]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum AtxDriverType {
/// GPIO control via Linux character device
Gpio,
/// USB HID relay module
UsbRelay,
/// Serial/COM port relay (taobao LCUS type)
Serial,
/// Disabled / Not configured
#[default]
None,
}
impl Default for AtxDriverType {
fn default() -> Self {
Self::None
}
}
/// Active level for GPIO pins
#[typeshare]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ActiveLevel {
/// Active high (default for most cases)
#[default]
High,
/// Active low (inverted)
Low,
}
impl Default for ActiveLevel {
fn default() -> Self {
Self::High
}
}
/// Configuration for a single ATX key (power or reset)
/// This is the "four-tuple" configuration: (driver, device, pin/channel, level)
#[typeshare]
@@ -74,10 +61,13 @@ pub struct AtxKeyConfig {
pub device: String,
/// Pin or channel number:
/// - For GPIO: GPIO pin number
/// - For USB Relay: relay channel (0-based)
/// - For USB Relay: relay channel (1-based)
/// - For Serial Relay (LCUS): relay channel (1-based)
pub pin: u32,
/// Active level (only applicable to GPIO, ignored for USB Relay)
pub active_level: ActiveLevel,
/// Baud rate for serial relay (start with 9600)
pub baud_rate: u32,
}
impl Default for AtxKeyConfig {
@@ -87,6 +77,7 @@ impl Default for AtxKeyConfig {
device: String::new(),
pin: 0,
active_level: ActiveLevel::High,
baud_rate: 9600,
}
}
}
@@ -100,7 +91,7 @@ impl AtxKeyConfig {
/// LED sensing configuration (optional)
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct AtxLedConfig {
/// Whether LED sensing is enabled
@@ -113,17 +104,6 @@ pub struct AtxLedConfig {
pub inverted: bool,
}
impl Default for AtxLedConfig {
fn default() -> Self {
Self {
enabled: false,
gpio_chip: String::new(),
gpio_pin: 0,
inverted: false,
}
}
}
impl AtxLedConfig {
/// Check if LED sensing is configured
pub fn is_configured(&self) -> bool {
@@ -132,7 +112,7 @@ impl AtxLedConfig {
}
/// ATX state information
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AtxState {
/// Whether ATX feature is available/enabled
pub available: bool,
@@ -146,18 +126,6 @@ pub struct AtxState {
pub led_supported: bool,
}
impl Default for AtxState {
fn default() -> Self {
Self {
available: false,
power_configured: false,
reset_configured: false,
power_status: PowerStatus::Unknown,
led_supported: false,
}
}
}
/// ATX power action request
#[derive(Debug, Clone, Deserialize)]
pub struct AtxPowerRequest {
@@ -185,6 +153,8 @@ pub struct AtxDevices {
pub gpio_chips: Vec<String>,
/// Available USB HID relay devices (/dev/hidraw*)
pub usb_relays: Vec<String>,
/// Available Serial ports (/dev/ttyUSB*)
pub serial_ports: Vec<String>,
}
impl Default for AtxDevices {
@@ -192,6 +162,7 @@ impl Default for AtxDevices {
Self {
gpio_chips: Vec::new(),
usb_relays: Vec::new(),
serial_ports: Vec::new(),
}
}
}
@@ -266,5 +237,6 @@ mod tests {
assert!(!state.power_configured);
assert!(!state.reset_configured);
assert_eq!(state.power_status, PowerStatus::Unknown);
assert!(!state.led_supported);
}
}

View File

@@ -10,7 +10,7 @@ use crate::error::{AppError, Result};
/// WOL magic packet structure:
/// - 6 bytes of 0xFF
/// - 16 repetitions of the target MAC address (6 bytes each)
/// Total: 6 + 16 * 6 = 102 bytes
/// Total: 6 + 16 * 6 = 102 bytes
const MAGIC_PACKET_SIZE: usize = 102;
/// Parse MAC address string into bytes
@@ -160,8 +160,8 @@ mod tests {
let packet = build_magic_packet(&mac);
// Check header (6 bytes of 0xFF)
for i in 0..6 {
assert_eq!(packet[i], 0xFF);
for byte in packet.iter().take(6) {
assert_eq!(*byte, 0xFF);
}
// Check MAC repetitions

View File

@@ -1,5 +1,3 @@
//! ALSA audio capture implementation
use alsa::pcm::{Access, Format, Frames, HwParams, State, IO};
use alsa::{Direction, ValueOr, PCM};
use bytes::Bytes;
@@ -14,30 +12,23 @@ use crate::error::{AppError, Result};
use crate::utils::LogThrottler;
use crate::{error_throttled, warn_throttled};
/// Audio capture configuration
#[derive(Debug, Clone)]
pub struct AudioConfig {
/// ALSA device name (e.g., "hw:0,0" or "default")
pub device_name: String,
/// Sample rate in Hz
pub sample_rate: u32,
/// Number of channels (1 = mono, 2 = stereo)
pub channels: u32,
/// Samples per frame (for Opus, typically 480 for 10ms at 48kHz)
pub frame_size: u32,
/// Buffer size in frames
pub buffer_frames: u32,
/// Period size in frames
pub period_frames: u32,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
device_name: "default".to_string(),
device_name: String::new(),
sample_rate: 48000,
channels: 2,
frame_size: 960, // 20ms at 48kHz (good for Opus)
frame_size: 960,
buffer_frames: 4096,
period_frames: 960,
}
@@ -45,71 +36,46 @@ impl Default for AudioConfig {
}
impl AudioConfig {
/// Create config for a specific device
pub fn for_device(device: &AudioDeviceInfo) -> Self {
let sample_rate = if device.sample_rates.contains(&48000) {
48000
} else {
*device.sample_rates.first().unwrap_or(&48000)
};
let channels = if device.channels.contains(&2) {
2
} else {
*device.channels.first().unwrap_or(&2)
};
Self {
device_name: device.name.clone(),
sample_rate,
channels,
frame_size: sample_rate / 50, // 20ms
..Default::default()
}
}
/// Bytes per sample (16-bit signed)
pub fn bytes_per_sample(&self) -> u32 {
2 * self.channels
}
/// Bytes per frame
pub fn bytes_per_frame(&self) -> usize {
(self.frame_size * self.bytes_per_sample()) as usize
}
}
/// Audio frame data
#[derive(Debug, Clone)]
pub struct AudioFrame {
/// Raw PCM data (S16LE interleaved)
pub data: Bytes,
/// Sample rate
pub sample_rate: u32,
/// Number of channels
pub channels: u32,
/// Number of samples per channel
pub samples: u32,
/// Frame sequence number
pub sequence: u64,
/// Capture timestamp
pub timestamp: Instant,
}
impl AudioFrame {
pub fn new(data: Bytes, config: &AudioConfig, sequence: u64) -> Self {
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(),
}
}
}
/// Audio capture state
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureState {
Stopped,
@@ -117,7 +83,6 @@ pub enum CaptureState {
Error,
}
/// ALSA audio capturer
pub struct AudioCapturer {
config: AudioConfig,
state: Arc<watch::Sender<CaptureState>>,
@@ -126,15 +91,13 @@ pub struct AudioCapturer {
stop_flag: Arc<AtomicBool>,
sequence: Arc<AtomicU64>,
capture_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
/// Log throttler to prevent log flooding
log_throttler: LogThrottler,
}
impl AudioCapturer {
/// Create a new audio capturer
pub fn new(config: AudioConfig) -> Self {
let (state_tx, state_rx) = watch::channel(CaptureState::Stopped);
let (frame_tx, _) = broadcast::channel(16); // Buffer size 16 for low latency
let (frame_tx, _) = broadcast::channel(16);
Self {
config,
@@ -148,28 +111,24 @@ impl AudioCapturer {
}
}
/// Get current 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()
}
/// Subscribe to audio frames
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
self.frame_tx.subscribe()
}
/// Start capturing
pub async fn start(&self) -> Result<()> {
if self.state() == CaptureState::Running {
return Ok(());
}
info!(
debug!(
"Starting audio capture on {} at {}Hz {}ch",
self.config.device_name, self.config.sample_rate, self.config.channels
);
@@ -184,21 +143,27 @@ impl AudioCapturer {
let log_throttler = self.log_throttler.clone();
let handle = tokio::task::spawn_blocking(move || {
capture_loop(
config,
state,
frame_tx,
stop_flag,
sequence,
log_throttler,
let result = run_capture(
&config,
&state,
&frame_tx,
&stop_flag,
&sequence,
&log_throttler,
);
if let Err(e) = result {
error_throttled!(log_throttler, "capture_error", "Audio capture error: {}", e);
let _ = state.send(CaptureState::Error);
} else {
let _ = state.send(CaptureState::Stopped);
}
});
*self.capture_handle.lock().await = Some(handle);
Ok(())
}
/// Stop capturing
pub async fn stop(&self) -> Result<()> {
info!("Stopping audio capture");
self.stop_flag.store(true, Ordering::SeqCst);
@@ -211,38 +176,11 @@ impl AudioCapturer {
Ok(())
}
/// Check if running
pub fn is_running(&self) -> bool {
self.state() == CaptureState::Running
}
}
/// Main capture loop
fn capture_loop(
config: AudioConfig,
state: Arc<watch::Sender<CaptureState>>,
frame_tx: broadcast::Sender<AudioFrame>,
stop_flag: Arc<AtomicBool>,
sequence: Arc<AtomicU64>,
log_throttler: LogThrottler,
) {
let result = run_capture(
&config,
&state,
&frame_tx,
&stop_flag,
&sequence,
&log_throttler,
);
if let Err(e) = result {
error_throttled!(log_throttler, "capture_error", "Audio capture error: {}", e);
let _ = state.send(CaptureState::Error);
} else {
let _ = state.send(CaptureState::Stopped);
}
}
fn run_capture(
config: &AudioConfig,
state: &watch::Sender<CaptureState>,
@@ -251,7 +189,6 @@ fn run_capture(
sequence: &AtomicU64,
log_throttler: &LogThrottler,
) -> Result<()> {
// Open ALSA device
let pcm = PCM::new(&config.device_name, Direction::Capture, false).map_err(|e| {
AppError::AudioError(format!(
"Failed to open audio device {}: {}",
@@ -259,7 +196,6 @@ fn run_capture(
))
})?;
// Configure hardware parameters
{
let hwp = HwParams::any(&pcm)
.map_err(|e| AppError::AudioError(format!("Failed to get HwParams: {}", e)))?;
@@ -286,30 +222,46 @@ fn run_capture(
.map_err(|e| AppError::AudioError(format!("Failed to apply hw params: {}", e)))?;
}
// Get actual configuration
let actual_rate = pcm
.hw_params_current()
.map(|h| h.get_rate().unwrap_or(config.sample_rate))
.unwrap_or(config.sample_rate);
let hw_now = pcm.hw_params_current().map_err(|e| {
AppError::AudioError(format!("Failed to read hw_params after apply: {}", e))
})?;
let actual_rate = hw_now
.get_rate()
.map_err(|e| AppError::AudioError(format!("Failed to read sample rate: {}", e)))?;
let actual_ch = hw_now
.get_channels()
.map_err(|e| AppError::AudioError(format!("Failed to read channels: {}", e)))?;
if actual_rate != 48_000 {
return Err(AppError::AudioError(format!(
"Audio capture requires 48000 Hz; device is {} Hz",
actual_rate
)));
}
if actual_ch != 2 {
return Err(AppError::AudioError(format!(
"Audio capture requires 2 channels (stereo); device has {}",
actual_ch
)));
}
debug!("Audio capture: 48000 Hz, 2 ch");
info!(
"Audio capture configured: {}Hz {}ch (requested {}Hz)",
actual_rate, config.channels, config.sample_rate
);
// Prepare for capture
pcm.prepare()
.map_err(|e| AppError::AudioError(format!("Failed to prepare PCM: {}", e)))?;
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];
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) {
// Check PCM state
match pcm.state() {
State::XRun => {
warn_throttled!(log_throttler, "xrun", "Audio buffer overrun, recovering");
@@ -328,9 +280,7 @@ fn run_capture(
_ => {}
}
// Get IO handle and read audio data directly as bytes
// Note: Use io() instead of io_checked() because USB audio devices
// typically don't support mmap, which io_checked() requires
// io_bytes: USB capture often lacks mmap (io_checked requires it).
let io: IO<u8> = pcm.io_bytes();
match io.readi(&mut buffer) {
@@ -339,15 +289,16 @@ fn run_capture(
continue;
}
// Calculate actual byte count
let byte_count = frames_read * config.channels as usize * 2;
// 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,
48_000,
seq,
);
// Send to subscribers
if frame_tx.receiver_count() > 0 {
if let Err(e) = frame_tx.send(frame) {
debug!("No audio receivers: {}", e);
@@ -355,15 +306,15 @@ fn run_capture(
}
}
Err(e) => {
// Check for buffer overrun (EPIPE = 32 on Linux)
let desc = e.to_string();
if desc.contains("EPIPE") || desc.contains("Broken pipe") {
// Buffer overrun
if is_device_lost_error(&desc) {
return Err(AppError::AudioError(format!(
"Audio device lost while reading {}: {}",
config.device_name, e
)));
} else if desc.contains("EPIPE") || desc.contains("Broken pipe") {
warn_throttled!(log_throttler, "buffer_overrun", "Audio buffer overrun");
let _ = pcm.prepare();
} else if desc.contains("No such device") || desc.contains("ENODEV") {
// Device disconnected - use longer throttle for this
error_throttled!(log_throttler, "no_device", "Audio read error: {}", e);
} else {
error_throttled!(log_throttler, "read_error", "Audio read error: {}", e);
}
@@ -374,3 +325,10 @@ fn run_capture(
info!("Audio capture stopped");
Ok(())
}
fn is_device_lost_error(desc: &str) -> bool {
desc.contains("No such device")
|| desc.contains("ENODEV")
|| desc.contains("ENXIO")
|| desc.contains("ESHUTDOWN")
}

View File

@@ -1,35 +1,38 @@
//! Audio controller for high-level audio management
//!
//! Provides device enumeration, selection, quality control, and streaming management.
//! Device selection, quality presets, streaming.
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tracing::info;
use tracing::{debug, info, warn};
use super::capture::AudioConfig;
use super::device::{enumerate_audio_devices_with_current, AudioDeviceInfo};
use super::device::{
enumerate_audio_devices, enumerate_audio_devices_with_current, find_best_audio_device,
AudioDeviceInfo,
};
use super::encoder::{OpusConfig, OpusFrame};
use super::monitor::{AudioHealthMonitor, AudioHealthStatus};
use super::streamer::{AudioStreamer, AudioStreamerConfig};
use super::monitor::AudioHealthMonitor;
use super::streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig};
use crate::error::{AppError, Result};
use crate::events::{EventBus, SystemEvent};
use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent};
const AUDIO_RECOVERY_RETRY_DELAY: Duration = Duration::from_secs(1);
type AudioRecoveredCallback = Arc<dyn Fn() + Send + Sync>;
/// Audio quality presets
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum AudioQuality {
/// Low bandwidth voice (32kbps)
Voice,
/// Balanced quality (64kbps) - default
#[default]
Balanced,
/// High quality audio (128kbps)
High,
}
impl AudioQuality {
/// Get the bitrate for this quality level
pub fn bitrate(&self) -> u32 {
match self {
AudioQuality::Voice => 32000,
@@ -38,16 +41,6 @@ impl AudioQuality {
}
}
/// Parse from string
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"voice" | "low" => AudioQuality::Voice,
"high" | "music" => AudioQuality::High,
_ => AudioQuality::Balanced,
}
}
/// Convert to OpusConfig
pub fn to_opus_config(&self) -> OpusConfig {
match self {
AudioQuality::Voice => OpusConfig::voice(),
@@ -57,6 +50,22 @@ impl AudioQuality {
}
}
impl FromStr for AudioQuality {
type Err = AppError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.trim().to_lowercase().as_str() {
"voice" => Ok(Self::Voice),
"balanced" => Ok(Self::Balanced),
"high" => Ok(Self::High),
_ => Err(AppError::BadRequest(format!(
"invalid audio quality {:?} (expected voice, balanced, or high)",
s.trim()
))),
}
}
}
impl std::fmt::Display for AudioQuality {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -67,17 +76,10 @@ impl std::fmt::Display for AudioQuality {
}
}
/// Audio controller configuration
///
/// Note: Sample rate is fixed at 48000Hz and channels at 2 (stereo).
/// These are optimal for Opus encoding and match WebRTC requirements.
#[derive(Debug, Clone)]
pub struct AudioControllerConfig {
/// Whether audio is enabled
pub enabled: bool,
/// Selected device name
pub device: String,
/// Audio quality preset
pub quality: AudioQuality,
}
@@ -85,76 +87,347 @@ impl Default for AudioControllerConfig {
fn default() -> Self {
Self {
enabled: false,
device: "default".to_string(),
device: String::new(),
quality: AudioQuality::Balanced,
}
}
}
/// Current audio status
#[derive(Debug, Clone, Serialize)]
pub struct AudioStatus {
/// Whether audio feature is enabled
pub enabled: bool,
/// Whether audio is currently streaming
pub streaming: bool,
/// Currently selected device
pub device: Option<String>,
/// Current quality preset
pub quality: AudioQuality,
/// Number of connected subscribers
pub subscriber_count: usize,
/// Error message if any
pub error: Option<String>,
}
/// Audio controller
///
/// High-level interface for audio management, providing:
/// - Device enumeration and selection
/// - Quality control
/// - Stream start/stop
/// - Status reporting
pub struct AudioController {
config: RwLock<AudioControllerConfig>,
streamer: RwLock<Option<Arc<AudioStreamer>>>,
devices: RwLock<Vec<AudioDeviceInfo>>,
event_bus: RwLock<Option<Arc<EventBus>>>,
last_error: RwLock<Option<String>>,
/// Health monitor for error tracking and recovery
config: Arc<RwLock<AudioControllerConfig>>,
streamer: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
devices: Arc<RwLock<Vec<AudioDeviceInfo>>>,
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
monitor: Arc<AudioHealthMonitor>,
recovery_in_progress: Arc<AtomicBool>,
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
}
impl AudioController {
/// Create a new audio controller with configuration
pub fn new(config: AudioControllerConfig) -> Self {
Self {
config: RwLock::new(config),
streamer: RwLock::new(None),
devices: RwLock::new(Vec::new()),
event_bus: RwLock::new(None),
last_error: RwLock::new(None),
monitor: Arc::new(AudioHealthMonitor::with_defaults()),
config: Arc::new(RwLock::new(config)),
streamer: Arc::new(RwLock::new(None)),
devices: Arc::new(RwLock::new(Vec::new())),
event_bus: Arc::new(RwLock::new(None)),
monitor: Arc::new(AudioHealthMonitor::new()),
recovery_in_progress: Arc::new(AtomicBool::new(false)),
recovered_callback: Arc::new(RwLock::new(None)),
}
}
/// Set event bus for publishing audio events
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) {
pub async fn set_recovered_callback(&self, callback: Arc<dyn Fn() + Send + Sync>) {
*self.recovered_callback.write().await = Some(callback);
}
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();
}
}
/// List available audio capture devices
async fn publish_state(
event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>,
state: &str,
device: Option<String>,
reason: Option<&str>,
next_retry_ms: Option<u64>,
) {
if let Some(ref bus) = *event_bus.read().await {
bus.publish(SystemEvent::StreamStateChanged {
state: state.to_string(),
device,
reason: reason.map(str::to_string),
next_retry_ms,
});
bus.mark_device_info_dirty();
}
}
async fn publish_device_lost(
event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>,
device: &str,
reason: &str,
) {
if let Some(ref bus) = *event_bus.read().await {
bus.publish(SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Audio,
device: device.to_string(),
reason: reason.to_string(),
});
}
}
async fn publish_reconnecting(
event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>,
device: &str,
attempt: u32,
) {
if let Some(ref bus) = *event_bus.read().await {
bus.publish(SystemEvent::StreamReconnecting {
device: device.to_string(),
attempt,
});
}
}
async fn publish_recovered(event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>, device: &str) {
if let Some(ref bus) = *event_bus.read().await {
bus.publish(SystemEvent::StreamRecovered {
device: device.to_string(),
});
}
}
fn select_recovery_device(
devices: &[AudioDeviceInfo],
preferred: &str,
) -> Option<AudioDeviceInfo> {
if !preferred.trim().is_empty() {
if let Some(device) = devices.iter().find(|d| d.name == preferred) {
return Some(device.clone());
}
}
devices
.iter()
.find(|d| d.is_hdmi && d.sample_rates.contains(&48_000) && d.channels.contains(&2))
.or_else(|| {
devices
.iter()
.find(|d| d.sample_rates.contains(&48_000) && d.channels.contains(&2))
})
.or_else(|| devices.first())
.cloned()
}
fn spawn_stream_monitor_from_parts(
config: Arc<RwLock<AudioControllerConfig>>,
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
monitor: Arc<AudioHealthMonitor>,
recovery_in_progress: Arc<AtomicBool>,
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
streamer: Arc<AudioStreamer>,
device: String,
) {
let mut state_rx = streamer.state_watch();
tokio::spawn(async move {
loop {
if state_rx.changed().await.is_err() {
return;
}
if *state_rx.borrow() != AudioStreamState::Error {
continue;
}
{
let current = streamer_slot.read().await;
if !current
.as_ref()
.is_some_and(|current| Arc::ptr_eq(current, &streamer))
{
return;
}
}
let reason = format!("Audio device lost: {}", device);
monitor.report_error(&reason, "device_lost").await;
Self::spawn_recovery_task_from_parts(
config,
streamer_slot,
event_bus,
monitor,
recovery_in_progress,
recovered_callback,
device,
reason,
);
return;
}
});
}
fn spawn_recovery_task_from_parts(
config: Arc<RwLock<AudioControllerConfig>>,
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
monitor: Arc<AudioHealthMonitor>,
recovery_in_progress: Arc<AtomicBool>,
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
lost_device: String,
reason: String,
) {
if recovery_in_progress.swap(true, Ordering::SeqCst) {
debug!("Audio recovery already in progress");
return;
}
tokio::spawn(async move {
warn!("Audio recovery started for {}: {}", lost_device, reason);
Self::publish_device_lost(&event_bus, &lost_device, &reason).await;
Self::publish_state(
&event_bus,
"device_lost",
Some(lost_device.clone()),
Some("audio_device_lost"),
Some(AUDIO_RECOVERY_RETRY_DELAY.as_millis() as u64),
)
.await;
let mut attempt = 0u32;
loop {
if !recovery_in_progress.load(Ordering::SeqCst) {
debug!("Audio recovery canceled");
return;
}
if streamer_slot
.read()
.await
.as_ref()
.is_some_and(|s| s.is_running())
{
recovery_in_progress.store(false, Ordering::SeqCst);
return;
}
let cfg = config.read().await.clone();
if !cfg.enabled {
recovery_in_progress.store(false, Ordering::SeqCst);
return;
}
attempt = attempt.saturating_add(1);
Self::publish_reconnecting(&event_bus, &lost_device, attempt).await;
Self::publish_state(
&event_bus,
"device_lost",
Some(lost_device.clone()),
Some("audio_reconnecting"),
Some(AUDIO_RECOVERY_RETRY_DELAY.as_millis() as u64),
)
.await;
tokio::time::sleep(AUDIO_RECOVERY_RETRY_DELAY).await;
let devices = match enumerate_audio_devices() {
Ok(devices) => devices,
Err(e) => {
debug!(
"Audio recovery enumerate failed (attempt {}): {}",
attempt, e
);
continue;
}
};
let Some(device) = Self::select_recovery_device(&devices, &cfg.device) else {
debug!("No audio devices found during recovery attempt {}", attempt);
continue;
};
let streamer_config = AudioStreamerConfig {
capture: AudioConfig {
device_name: device.name.clone(),
..Default::default()
},
opus: cfg.quality.to_opus_config(),
};
let new_streamer = Arc::new(AudioStreamer::with_config(streamer_config));
match new_streamer.start().await {
Ok(()) => {
{
let mut cfg = config.write().await;
cfg.device = device.name.clone();
}
*streamer_slot.write().await = Some(new_streamer.clone());
monitor.report_recovered().await;
Self::publish_recovered(&event_bus, &device.name).await;
if let Some(callback) = recovered_callback.read().await.clone() {
callback();
}
Self::publish_state(
&event_bus,
"streaming",
Some(device.name.clone()),
None,
None,
)
.await;
recovery_in_progress.store(false, Ordering::SeqCst);
info!(
"Audio device recovered with {} after {} attempts",
device.name, attempt
);
Self::spawn_stream_monitor_from_parts(
config,
streamer_slot,
event_bus,
monitor,
recovery_in_progress,
recovered_callback,
new_streamer,
device.name,
);
return;
}
Err(e) => {
debug!(
"Audio recovery start failed with {} (attempt {}): {}",
device.name, attempt, e
);
}
}
}
});
}
fn spawn_recovery_task(&self, lost_device: String, reason: String) {
Self::spawn_recovery_task_from_parts(
self.config.clone(),
self.streamer.clone(),
self.event_bus.clone(),
self.monitor.clone(),
self.recovery_in_progress.clone(),
self.recovered_callback.clone(),
lost_device,
reason,
);
}
fn spawn_stream_monitor(&self, streamer: Arc<AudioStreamer>, device: String) {
Self::spawn_stream_monitor_from_parts(
self.config.clone(),
self.streamer.clone(),
self.event_bus.clone(),
self.monitor.clone(),
self.recovery_in_progress.clone(),
self.recovered_callback.clone(),
streamer,
device,
);
}
pub async fn list_devices(&self) -> Result<Vec<AudioDeviceInfo>> {
// Get current device if streaming (it may be busy and unable to be opened)
let current_device = if self.is_streaming().await {
Some(self.config.read().await.device.clone())
} else {
@@ -166,55 +439,30 @@ impl AudioController {
Ok(devices)
}
/// Refresh device list and cache it
pub async fn refresh_devices(&self) -> Result<()> {
// Get current device if streaming (it may be busy and unable to be opened)
let current_device = if self.is_streaming().await {
Some(self.config.read().await.device.clone())
} else {
None
};
let devices = enumerate_audio_devices_with_current(current_device.as_deref())?;
*self.devices.write().await = devices;
Ok(())
}
/// Get cached device list
pub async fn get_cached_devices(&self) -> Vec<AudioDeviceInfo> {
self.devices.read().await.clone()
}
/// Select audio device
pub async fn select_device(&self, device: &str) -> Result<()> {
// Validate device exists
let devices = self.list_devices().await?;
let found = devices
.iter()
.any(|d| d.name == device || d.description.contains(device));
if !found && device != "default" {
if !found {
return Err(AppError::AudioError(format!(
"Audio device not found: {}",
device
)));
}
// Update config
{
let mut config = self.config.write().await;
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
if self.is_streaming().await {
self.stop_streaming().await?;
self.start_streaming().await?;
@@ -223,25 +471,16 @@ impl AudioController {
Ok(())
}
/// Set audio quality
pub async fn set_quality(&self, quality: AudioQuality) -> Result<()> {
// Update config
{
let mut config = self.config.write().await;
config.quality = quality;
}
// Update streamer if running
if let Some(ref streamer) = *self.streamer.read().await {
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,
@@ -250,90 +489,94 @@ impl AudioController {
Ok(())
}
/// Start audio streaming
pub async fn start_streaming(&self) -> Result<()> {
let config = self.config.read().await.clone();
if !config.enabled {
return Err(AppError::AudioError("Audio is disabled".to_string()));
{
let config = self.config.read().await;
if !config.enabled {
return Err(AppError::AudioError("Audio is disabled".to_string()));
}
}
// Check if already streaming
if self.is_streaming().await {
return Ok(());
}
info!("Starting audio streaming with device: {}", config.device);
// Clear any previous error
*self.last_error.write().await = None;
// Create streamer config (fixed 48kHz stereo)
let streamer_config = AudioStreamerConfig {
capture: AudioConfig {
device_name: config.device.clone(),
..Default::default()
},
opus: config.quality.to_opus_config(),
let mut select_error = None;
let (device_name, quality) = {
let mut cfg = self.config.write().await;
if cfg.device.trim().is_empty() {
match find_best_audio_device() {
Ok(best) => cfg.device = best.name,
Err(e) => {
select_error = Some(format!("Failed to select audio device: {}", e));
}
}
}
(cfg.device.clone(), cfg.quality)
};
if let Some(error_msg) = select_error {
self.monitor.report_error(&error_msg, "start_failed").await;
self.spawn_recovery_task("auto".to_string(), error_msg.clone());
self.mark_device_info_dirty().await;
return Err(AppError::AudioError(error_msg));
}
debug!("Starting audio streaming with device: {}", device_name);
self.monitor.prepare_retry_attempt();
let streamer_config = AudioStreamerConfig {
capture: AudioConfig {
device_name: device_name.clone(),
..Default::default()
},
opus: quality.to_opus_config(),
};
// Create and start streamer
let streamer = Arc::new(AudioStreamer::with_config(streamer_config));
if let Err(e) = streamer.start().await {
let error_msg = format!("Failed to start audio: {}", e);
*self.last_error.write().await = Some(error_msg.clone());
// Report error to health monitor
self.monitor
.report_error(Some(&config.device), &error_msg, "start_failed")
.await;
self.monitor.report_error(&error_msg, "start_failed").await;
self.spawn_recovery_task(device_name.clone(), error_msg.clone());
self.publish_event(SystemEvent::AudioStateChanged {
streaming: false,
device: None,
})
.await;
self.mark_device_info_dirty().await;
return Err(AppError::AudioError(error_msg));
}
let streamer_for_monitor = streamer.clone();
*self.streamer.write().await = Some(streamer);
self.spawn_stream_monitor(streamer_for_monitor, device_name.clone());
// Report recovery if we were in an error state
if self.monitor.is_error().await {
self.monitor.report_recovered(Some(&config.device)).await;
self.monitor.report_recovered().await;
}
// Publish event
self.publish_event(SystemEvent::AudioStateChanged {
streaming: true,
device: Some(config.device),
})
.await;
self.recovery_in_progress.store(false, Ordering::SeqCst);
self.mark_device_info_dirty().await;
info!("Audio streaming started");
Ok(())
}
/// Stop audio streaming
pub async fn stop_streaming(&self) -> Result<()> {
self.recovery_in_progress.store(false, Ordering::SeqCst);
if let Some(streamer) = self.streamer.write().await.take() {
streamer.stop().await?;
}
// Publish event
self.publish_event(SystemEvent::AudioStateChanged {
streaming: false,
device: None,
})
.await;
self.monitor.reset().await;
self.mark_device_info_dirty().await;
info!("Audio streaming stopped");
Ok(())
}
/// Check if currently streaming
pub async fn is_streaming(&self) -> bool {
if let Some(ref streamer) = *self.streamer.read().await {
streamer.is_running()
@@ -342,46 +585,37 @@ impl AudioController {
}
}
/// Get current status
pub async fn status(&self) -> AudioStatus {
let config = self.config.read().await;
let streaming = self.is_streaming().await;
let error = self.last_error.read().await.clone();
let (enabled, device_str, quality) = {
let c = self.config.read().await;
(c.enabled, c.device.clone(), c.quality)
};
let error = self.monitor.error_message().await;
let subscriber_count = if let Some(ref streamer) = *self.streamer.read().await {
streamer.stats().await.subscriber_count
let (streaming, subscriber_count) = if let Some(ref streamer) = *self.streamer.read().await
{
let streaming = streamer.is_running();
let subscriber_count = streamer.stats().subscriber_count;
(streaming, subscriber_count)
} else {
0
(false, 0)
};
AudioStatus {
enabled: config.enabled,
enabled,
streaming,
device: if streaming || config.enabled {
Some(config.device.clone())
device: if streaming || enabled {
Some(device_str)
} else {
None
},
quality: config.quality,
quality,
subscriber_count,
error,
}
}
/// Subscribe to Opus frames (for WebSocket clients)
pub fn subscribe_opus(&self) -> Option<tokio::sync::watch::Receiver<Option<Arc<OpusFrame>>>> {
// Use try_read to avoid blocking - this is called from sync context sometimes
if let Ok(guard) = self.streamer.try_read() {
guard.as_ref().map(|s| s.subscribe_opus())
} else {
None
}
}
/// Subscribe to Opus frames (async version)
pub async fn subscribe_opus_async(
&self,
) -> Option<tokio::sync::watch::Receiver<Option<Arc<OpusFrame>>>> {
pub async fn subscribe_opus(&self) -> Option<tokio::sync::mpsc::Receiver<Arc<OpusFrame>>> {
self.streamer
.read()
.await
@@ -389,7 +623,6 @@ impl AudioController {
.map(|s| s.subscribe_opus())
}
/// Enable or disable audio
pub async fn set_enabled(&self, enabled: bool) -> Result<()> {
{
let mut config = self.config.write().await;
@@ -404,61 +637,25 @@ impl AudioController {
Ok(())
}
/// 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
if was_streaming {
self.stop_streaming().await?;
}
// Update config
*self.config.write().await = new_config.clone();
// Restart streaming if it was running and still enabled
if was_streaming && new_config.enabled {
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(())
}
/// Shutdown the controller
pub async fn shutdown(&self) -> Result<()> {
self.stop_streaming().await
}
/// Get the health monitor reference
pub fn monitor(&self) -> &Arc<AudioHealthMonitor> {
&self.monitor
}
/// Get current health status
pub async fn health_status(&self) -> AudioHealthStatus {
self.monitor.status().await
}
/// Check if the audio is healthy
pub async fn is_healthy(&self) -> bool {
self.monitor.is_healthy().await
}
}
impl Default for AudioController {
@@ -480,12 +677,23 @@ mod tests {
#[test]
fn test_audio_quality_from_str() {
assert_eq!(AudioQuality::from_str("voice"), AudioQuality::Voice);
assert_eq!(AudioQuality::from_str("low"), AudioQuality::Voice);
assert_eq!(AudioQuality::from_str("balanced"), AudioQuality::Balanced);
assert_eq!(AudioQuality::from_str("high"), AudioQuality::High);
assert_eq!(AudioQuality::from_str("music"), AudioQuality::High);
assert_eq!(AudioQuality::from_str("unknown"), AudioQuality::Balanced);
assert_eq!(
"voice".parse::<AudioQuality>().unwrap(),
AudioQuality::Voice
);
assert_eq!(
"balanced".parse::<AudioQuality>().unwrap(),
AudioQuality::Balanced
);
assert_eq!("high".parse::<AudioQuality>().unwrap(), AudioQuality::High);
}
#[test]
fn test_audio_quality_from_str_rejects_aliases_and_unknown() {
assert!("low".parse::<AudioQuality>().is_err());
assert!("music".parse::<AudioQuality>().is_err());
assert!("unknown".parse::<AudioQuality>().is_err());
assert!("".parse::<AudioQuality>().is_err());
}
#[tokio::test]

View File

@@ -1,5 +1,3 @@
//! Audio device enumeration using ALSA
use alsa::pcm::HwParams;
use alsa::{Direction, PCM};
use serde::Serialize;
@@ -7,54 +5,30 @@ use tracing::{debug, info, warn};
use crate::error::{AppError, Result};
/// Audio device information
#[derive(Debug, Clone, Serialize)]
pub struct AudioDeviceInfo {
/// Device name (e.g., "hw:0,0" or "default")
pub name: String,
/// Human-readable description
pub description: String,
/// Card index
pub card_index: i32,
/// Device index
pub device_index: i32,
/// Supported sample rates
pub sample_rates: Vec<u32>,
/// Supported channel counts
pub channels: Vec<u32>,
/// Is this a capture device
pub is_capture: bool,
/// Is this an HDMI audio device (likely from capture card)
pub is_hdmi: bool,
/// USB bus info for matching with video devices (e.g., "1-1" from USB path)
pub usb_bus: Option<String>,
}
impl AudioDeviceInfo {
/// Get ALSA device name
pub fn alsa_name(&self) -> String {
format!("hw:{},{}", self.card_index, self.device_index)
}
}
/// Get USB bus info for an audio card by reading sysfs
/// Returns the USB port path like "1-1" or "1-2.3"
fn get_usb_bus_info(card_index: i32) -> Option<String> {
if card_index < 0 {
return None;
}
// Read the device symlink: /sys/class/sound/cardX/device -> ../../usb1/1-1/1-1:1.0
let device_path = format!("/sys/class/sound/card{}/device", card_index);
let link_target = std::fs::read_link(&device_path).ok()?;
let link_str = link_target.to_string_lossy();
// Extract USB port from path like "../../usb1/1-1/1-1:1.0" or "../../1-1/1-1:1.0"
// We want the "1-1" part (USB bus-port)
for component in link_str.split('/') {
// Match patterns like "1-1", "1-2", "1-1.2", "2-1.3.1"
if component.contains('-') && !component.contains(':') {
// Verify it looks like a USB port (starts with digit)
if component
.chars()
.next()
@@ -69,25 +43,16 @@ fn get_usb_bus_info(card_index: i32) -> Option<String> {
None
}
/// Enumerate available audio capture devices
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
enumerate_audio_devices_with_current(None)
}
/// Enumerate available audio capture devices, with option to include a currently-in-use device
///
/// # Arguments
/// * `current_device` - Optional device name that is currently in use. This device will be
/// included in the list even if it cannot be opened (because it's already open by us).
pub fn enumerate_audio_devices_with_current(
current_device: Option<&str>,
) -> Result<Vec<AudioDeviceInfo>> {
let mut devices = Vec::new();
// Try to enumerate cards
let cards = match alsa::card::Iter::new() {
i => i,
};
let cards = alsa::card::Iter::new();
for card_result in cards {
let card = match card_result {
@@ -104,104 +69,71 @@ pub fn enumerate_audio_devices_with_current(
debug!("Found audio card {}: {}", card_index, card_longname);
// Check if this looks like an HDMI capture device
let is_hdmi = card_longname.to_lowercase().contains("hdmi")
|| card_longname.to_lowercase().contains("capture")
|| card_longname.to_lowercase().contains("usb");
let long_lower = card_longname.to_lowercase();
let is_hdmi = long_lower.contains("hdmi")
|| long_lower.contains("capture")
|| long_lower.contains("usb");
// Get USB bus info for this card
let usb_bus = get_usb_bus_info(card_index);
// Try to open each device on this card for capture
for device_index in 0..8 {
let device_name = format!("hw:{},{}", card_index, device_index);
// Check if this is the currently-in-use device
let is_current_device = current_device == Some(device_name.as_str());
// Try to open for capture
let mut push_info =
|sample_rates: Vec<u32>, channels: Vec<u32>, description: String| {
devices.push(AudioDeviceInfo {
name: device_name.clone(),
description,
card_index,
device_index,
sample_rates,
channels,
is_capture: true,
is_hdmi,
usb_bus: usb_bus.clone(),
});
};
match PCM::new(&device_name, Direction::Capture, false) {
Ok(pcm) => {
// Query capabilities
let (sample_rates, channels) = query_device_caps(&pcm);
if !sample_rates.is_empty() && !channels.is_empty() {
devices.push(AudioDeviceInfo {
name: device_name,
description: format!("{} - Device {}", card_longname, device_index),
card_index,
device_index,
push_info(
sample_rates,
channels,
is_capture: true,
is_hdmi,
usb_bus: usb_bus.clone(),
});
format!("{} - Device {}", card_longname, device_index),
);
}
}
Err(_) => {
// Device doesn't exist or can't be opened for capture
// But if it's the current device, include it anyway (it's busy because we're using it)
if is_current_device {
debug!(
"Device {} is busy (in use by us), adding with default caps",
device_name
);
devices.push(AudioDeviceInfo {
name: device_name,
description: format!(
"{} - Device {} (in use)",
card_longname, device_index
),
card_index,
device_index,
// Use common default capabilities for HDMI capture devices
sample_rates: vec![44100, 48000],
channels: vec![2],
is_capture: true,
is_hdmi,
usb_bus: usb_bus.clone(),
});
push_info(
vec![44100, 48000],
vec![2],
format!("{} - Device {} (in use)", card_longname, device_index),
);
}
continue;
}
}
}
}
// Also check for "default" device
if let Ok(pcm) = PCM::new("default", Direction::Capture, false) {
let (sample_rates, channels) = query_device_caps(&pcm);
if !sample_rates.is_empty() {
devices.insert(
0,
AudioDeviceInfo {
name: "default".to_string(),
description: "Default Audio Device".to_string(),
card_index: -1,
device_index: -1,
sample_rates,
channels,
is_capture: true,
is_hdmi: false,
usb_bus: None,
},
);
}
}
info!("Found {} audio capture devices", devices.len());
Ok(devices)
}
/// Query device capabilities
fn query_device_caps(pcm: &PCM) -> (Vec<u32>, Vec<u32>) {
let hwp = match HwParams::any(pcm) {
Ok(h) => h,
Err(_) => return (vec![], vec![]),
};
// Common sample rates to check
let common_rates = [8000, 16000, 22050, 44100, 48000, 96000];
let mut supported_rates = Vec::new();
@@ -211,7 +143,6 @@ fn query_device_caps(pcm: &PCM) -> (Vec<u32>, Vec<u32>) {
}
}
// Check channel counts
let mut supported_channels = Vec::new();
for ch in 1..=8 {
if hwp.test_channels(ch).is_ok() {
@@ -222,8 +153,6 @@ fn query_device_caps(pcm: &PCM) -> (Vec<u32>, Vec<u32>) {
(supported_rates, supported_channels)
}
/// Find the best audio device for capture
/// Prefers HDMI/capture devices over built-in microphones
pub fn find_best_audio_device() -> Result<AudioDeviceInfo> {
let devices = enumerate_audio_devices()?;
@@ -233,23 +162,24 @@ pub fn find_best_audio_device() -> Result<AudioDeviceInfo> {
));
}
// First, look for HDMI/capture card devices that support 48kHz stereo
let mut first_48k_stereo: Option<&AudioDeviceInfo> = None;
for device in &devices {
if device.is_hdmi && device.sample_rates.contains(&48000) && device.channels.contains(&2) {
if !device.sample_rates.contains(&48000) || !device.channels.contains(&2) {
continue;
}
if device.is_hdmi {
info!("Selected HDMI audio device: {}", device.description);
return Ok(device.clone());
}
}
// Then look for any device supporting 48kHz stereo
for device in &devices {
if device.sample_rates.contains(&48000) && device.channels.contains(&2) {
info!("Selected audio device: {}", device.description);
return Ok(device.clone());
if first_48k_stereo.is_none() {
first_48k_stereo = Some(device);
}
}
if let Some(device) = first_48k_stereo {
info!("Selected audio device: {}", device.description);
return Ok(device.clone());
}
// Fall back to first device
let device = devices.into_iter().next().unwrap();
warn!(
"Using fallback audio device: {} (may not support optimal settings)",
@@ -264,10 +194,8 @@ mod tests {
#[test]
fn test_enumerate_devices() {
// This test may not find devices in CI environment
let result = enumerate_audio_devices();
println!("Audio devices: {:?}", result);
// Just verify it doesn't panic
assert!(result.is_ok());
}
}

View File

@@ -1,26 +1,19 @@
//! Opus audio encoder for WebRTC
//! Opus encoder.
use audiopus::coder::GenericCtl;
use audiopus::{coder::Encoder, Application, Bitrate, Channels, SampleRate};
use bytes::Bytes;
use std::time::Instant;
use tracing::info;
use tracing::debug;
use super::capture::AudioFrame;
use crate::error::{AppError, Result};
/// Opus encoder configuration
#[derive(Debug, Clone)]
pub struct OpusConfig {
/// Sample rate (must be 8000, 12000, 16000, 24000, or 48000)
pub sample_rate: u32,
/// Channels (1 or 2)
pub channels: u32,
/// Target bitrate in bps
pub bitrate: u32,
/// Application mode
pub application: OpusApplication,
/// Enable forward error correction
pub fec: bool,
}
@@ -29,7 +22,7 @@ impl Default for OpusConfig {
Self {
sample_rate: 48000,
channels: 2,
bitrate: 64000, // 64 kbps
bitrate: 64000,
application: OpusApplication::Audio,
fec: true,
}
@@ -37,7 +30,6 @@ impl Default for OpusConfig {
}
impl OpusConfig {
/// Create config for voice (lower latency)
pub fn voice() -> Self {
Self {
application: OpusApplication::Voip,
@@ -46,7 +38,6 @@ impl OpusConfig {
}
}
/// Create config for music (higher quality)
pub fn music() -> Self {
Self {
application: OpusApplication::Audio,
@@ -82,30 +73,18 @@ impl OpusConfig {
}
}
/// Opus application mode
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpusApplication {
/// Voice over IP
Voip,
/// General audio
Audio,
/// Low delay mode
LowDelay,
}
/// Encoded Opus frame
#[derive(Debug, Clone)]
pub struct OpusFrame {
/// Encoded Opus data
pub data: Bytes,
/// Duration in milliseconds
pub duration_ms: u32,
/// Sequence number
pub sequence: u64,
/// Timestamp
pub timestamp: Instant,
/// RTP timestamp (samples)
pub rtp_timestamp: u32,
}
impl OpusFrame {
@@ -118,20 +97,14 @@ impl OpusFrame {
}
}
/// Opus encoder
pub struct OpusEncoder {
config: OpusConfig,
encoder: Encoder,
/// Output buffer
output_buffer: Vec<u8>,
/// Frame counter for RTP timestamp
frame_count: u64,
/// Samples per frame
samples_per_frame: u32,
}
impl OpusEncoder {
/// Create a new Opus encoder
pub fn new(config: OpusConfig) -> Result<Self> {
let sample_rate = config.to_audiopus_sample_rate();
let channels = config.to_audiopus_channels();
@@ -140,7 +113,6 @@ impl OpusEncoder {
let mut encoder = Encoder::new(sample_rate, channels, application)
.map_err(|e| AppError::AudioError(format!("Failed to create Opus encoder: {:?}", e)))?;
// Configure encoder
encoder
.set_bitrate(Bitrate::BitsPerSecond(config.bitrate as i32))
.map_err(|e| AppError::AudioError(format!("Failed to set bitrate: {:?}", e)))?;
@@ -151,10 +123,7 @@ impl OpusEncoder {
.map_err(|e| AppError::AudioError(format!("Failed to enable FEC: {:?}", e)))?;
}
// Calculate samples per frame (20ms at sample_rate)
let samples_per_frame = config.sample_rate / 50;
info!(
debug!(
"Opus encoder created: {}Hz {}ch {}bps",
config.sample_rate, config.channels, config.bitrate
);
@@ -162,18 +131,11 @@ impl OpusEncoder {
Ok(Self {
config,
encoder,
output_buffer: vec![0u8; 4000], // Max Opus frame size
output_buffer: vec![0u8; 4000],
frame_count: 0,
samples_per_frame,
})
}
/// Create with default configuration
pub fn default_config() -> Result<Self> {
Self::new(OpusConfig::default())
}
/// Encode PCM audio data (S16LE interleaved)
pub fn encode(&mut self, pcm_data: &[i16]) -> Result<OpusFrame> {
let encoded_len = self
.encoder
@@ -182,7 +144,6 @@ impl OpusEncoder {
let samples = pcm_data.len() as u32 / self.config.channels;
let duration_ms = (samples * 1000) / self.config.sample_rate;
let rtp_timestamp = (self.frame_count * self.samples_per_frame as u64) as u32;
self.frame_count += 1;
@@ -190,27 +151,18 @@ impl OpusEncoder {
data: Bytes::copy_from_slice(&self.output_buffer[..encoded_len]),
duration_ms,
sequence: self.frame_count - 1,
timestamp: Instant::now(),
rtp_timestamp,
})
}
/// Encode from AudioFrame
///
/// Uses zero-copy conversion from bytes to i16 samples via bytemuck.
pub fn encode_frame(&mut self, frame: &AudioFrame) -> Result<OpusFrame> {
// Zero-copy: directly cast bytes to i16 slice
// AudioFrame.data is S16LE format, which matches native little-endian i16
let samples: &[i16] = bytemuck::cast_slice(&frame.data);
self.encode(samples)
}
/// Get encoder configuration
pub fn config(&self) -> &OpusConfig {
&self.config
}
/// Reset encoder state
pub fn reset(&mut self) -> Result<()> {
self.encoder
.reset_state()
@@ -219,7 +171,6 @@ impl OpusEncoder {
Ok(())
}
/// Set bitrate dynamically
pub fn set_bitrate(&mut self, bitrate: u32) -> Result<()> {
self.encoder
.set_bitrate(Bitrate::BitsPerSecond(bitrate as i32))
@@ -228,15 +179,6 @@ impl OpusEncoder {
}
}
/// Audio encoder statistics
#[derive(Debug, Clone, Default)]
pub struct EncoderStats {
pub frames_encoded: u64,
pub bytes_output: u64,
pub avg_frame_size: usize,
pub current_bitrate: u32,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -261,13 +203,12 @@ mod tests {
let config = OpusConfig::default();
let mut encoder = OpusEncoder::new(config).unwrap();
// 20ms of stereo silence at 48kHz
let silence = vec![0i16; 960 * 2];
let result = encoder.encode(&silence);
assert!(result.is_ok());
let frame = result.unwrap();
assert!(!frame.is_empty());
assert!(frame.len() < silence.len() * 2); // Should be compressed
assert!(frame.len() < silence.len() * 2);
}
}

View File

@@ -1,12 +1,4 @@
//! Audio capture and encoding module
//!
//! This module provides:
//! - ALSA audio capture
//! - Opus encoding for WebRTC
//! - Audio device enumeration
//! - Audio streaming pipeline
//! - High-level audio controller
//! - Device health monitoring
//! ALSA capture, Opus encode, device enumeration, streaming, controller, health monitor.
pub mod capture;
pub mod controller;
@@ -19,5 +11,5 @@ pub use capture::{AudioCapturer, AudioConfig, AudioFrame};
pub use controller::{AudioController, AudioControllerConfig, AudioQuality, AudioStatus};
pub use device::{enumerate_audio_devices, enumerate_audio_devices_with_current, AudioDeviceInfo};
pub use encoder::{OpusConfig, OpusEncoder, OpusFrame};
pub use monitor::{AudioHealthMonitor, AudioHealthStatus, AudioMonitorConfig};
pub use monitor::{AudioHealthMonitor, AudioHealthStatus};
pub use streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig};

View File

@@ -1,134 +1,58 @@
//! Audio device health monitoring
//!
//! This module provides health monitoring for audio capture devices, including:
//! - Device connectivity checks
//! - Automatic reconnection on failure
//! - Error tracking and notification
//! - Log throttling to prevent log flooding
//! Audio device health and logging throttle for repeated failures.
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
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
#[derive(Debug, Clone, PartialEq)]
const LOG_THROTTLE_SECS: u64 = 5;
#[derive(Debug, Clone, PartialEq, Default)]
pub enum AudioHealthStatus {
/// 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 or not available
Disconnected,
}
impl Default for AudioHealthStatus {
fn default() -> Self {
Self::Healthy
}
}
/// Audio health monitor configuration
#[derive(Debug, Clone)]
pub struct AudioMonitorConfig {
/// 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,
}
impl Default for AudioMonitorConfig {
fn default() -> Self {
Self {
retry_interval_ms: 1000,
max_retries: 0, // infinite retry
log_throttle_secs: 5,
}
}
}
/// 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)
last_error_code: RwLock<Option<String>>,
/// Hide `error_message` while a new capture attempt is in flight (internal error state unchanged).
suppress_display: AtomicBool,
}
impl AudioHealthMonitor {
/// Create a new audio health monitor with the specified configuration
pub fn new(config: AudioMonitorConfig) -> Self {
let throttle_secs = config.log_throttle_secs;
pub fn new() -> Self {
Self {
status: RwLock::new(AudioHealthStatus::Healthy),
events: RwLock::new(None),
throttler: LogThrottler::with_secs(throttle_secs),
config,
running: AtomicBool::new(false),
throttler: LogThrottler::with_secs(LOG_THROTTLE_SECS),
retry_count: AtomicU32::new(0),
last_error_code: RwLock::new(None),
suppress_display: AtomicBool::new(false),
}
}
/// Create a new audio health monitor with default configuration
pub fn with_defaults() -> Self {
Self::new(AudioMonitorConfig::default())
/// Clears the error string exposed via [`Self::error_message`] until the next outcome (`report_error` or recovery).
pub fn prepare_retry_attempt(&self) {
self.suppress_display.store(true, Ordering::Relaxed);
}
/// Set the event bus for broadcasting state changes
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
*self.events.write().await = Some(events);
}
pub async fn report_error(&self, reason: &str, error_code: &str) {
self.suppress_display.store(false, Ordering::Relaxed);
/// 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
///
/// # 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) {
let count = self.retry_count.fetch_add(1, Ordering::Relaxed) + 1;
// 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 (always log if error type changed)
let throttle_key = format!("audio_{}", error_code);
if error_changed || self.throttler.should_log(&throttle_key) {
warn!(
@@ -137,127 +61,53 @@ impl AudioHealthMonitor {
);
}
// Update last error code
*self.last_error_code.write().await = Some(error_code.to_string());
// Update status
*self.status.write().await = AudioHealthStatus::Error {
reason: reason.to_string(),
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 % 5 == 0 {
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.
///
/// # Arguments
///
/// * `device` - The audio device name
pub async fn report_recovered(&self, device: Option<&str>) {
pub async fn report_recovered(&self) {
let prev_status = self.status.read().await.clone();
// Only report recovery if we were in an error state
if prev_status != AudioHealthStatus::Healthy {
let retry_count = self.retry_count.load(Ordering::Relaxed);
info!("Audio recovered after {} retries", retry_count);
// Reset state
self.suppress_display.store(false, Ordering::Relaxed);
self.retry_count.store(0, Ordering::Relaxed);
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()),
});
}
}
}
/// Get the current health status
pub async fn status(&self) -> AudioHealthStatus {
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, AudioHealthStatus::Error { .. })
}
/// Check if the monitor is healthy
pub async fn is_healthy(&self) -> bool {
matches!(*self.status.read().await, AudioHealthStatus::Healthy)
}
/// Reset the monitor to healthy state without publishing events
///
/// This is useful during initialization.
pub async fn reset(&self) {
self.suppress_display.store(false, Ordering::Relaxed);
self.retry_count.store(0, Ordering::Relaxed);
*self.last_error_code.write().await = None;
*self.status.write().await = AudioHealthStatus::Healthy;
self.throttler.clear_all();
}
/// Get the configuration
pub fn config(&self) -> &AudioMonitorConfig {
&self.config
pub async fn status(&self) -> AudioHealthStatus {
self.status.read().await.clone()
}
/// 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
pub fn retry_count(&self) -> u32 {
self.retry_count.load(Ordering::Relaxed)
}
/// Get the retry interval
pub fn retry_interval(&self) -> Duration {
Duration::from_millis(self.config.retry_interval_ms)
pub async fn is_error(&self) -> bool {
matches!(*self.status.read().await, AudioHealthStatus::Error { .. })
}
/// Get the current error message if in error state
pub async fn error_message(&self) -> Option<String> {
if self.suppress_display.load(Ordering::Relaxed) {
return None;
}
match &*self.status.read().await {
AudioHealthStatus::Error { reason, .. } => Some(reason.clone()),
_ => None,
@@ -267,7 +117,7 @@ impl AudioHealthMonitor {
impl Default for AudioHealthMonitor {
fn default() -> Self {
Self::with_defaults()
Self::new()
}
}
@@ -277,32 +127,25 @@ mod tests {
#[tokio::test]
async fn test_initial_status() {
let monitor = AudioHealthMonitor::with_defaults();
assert!(monitor.is_healthy().await);
let monitor = AudioHealthMonitor::new();
assert!(!monitor.is_error().await);
assert_eq!(monitor.retry_count(), 0);
}
#[tokio::test]
async fn test_report_error() {
let monitor = AudioHealthMonitor::with_defaults();
let monitor = AudioHealthMonitor::new();
monitor
.report_error(Some("hw:0,0"), "Device not found", "device_disconnected")
.report_error("Device not found", "device_disconnected")
.await;
assert!(monitor.is_error().await);
assert_eq!(monitor.retry_count(), 1);
if let AudioHealthStatus::Error {
reason,
error_code,
retry_count,
} = monitor.status().await
{
if let AudioHealthStatus::Error { reason, error_code } = monitor.status().await {
assert_eq!(reason, "Device not found");
assert_eq!(error_code, "device_disconnected");
assert_eq!(retry_count, 1);
} else {
panic!("Expected Error status");
}
@@ -310,39 +153,52 @@ mod tests {
#[tokio::test]
async fn test_report_recovered() {
let monitor = AudioHealthMonitor::with_defaults();
let monitor = AudioHealthMonitor::new();
// First report an error
monitor
.report_error(Some("default"), "Capture failed", "capture_error")
.report_error("Capture failed", "capture_error")
.await;
assert!(monitor.is_error().await);
// Then report recovery
monitor.report_recovered(Some("default")).await;
assert!(monitor.is_healthy().await);
monitor.report_recovered().await;
assert!(!monitor.is_error().await);
assert_eq!(monitor.retry_count(), 0);
}
#[tokio::test]
async fn test_retry_count_increments() {
let monitor = AudioHealthMonitor::with_defaults();
let monitor = AudioHealthMonitor::new();
for i in 1..=5 {
monitor.report_error(None, "Error", "io_error").await;
monitor.report_error("Error", "io_error").await;
assert_eq!(monitor.retry_count(), i);
}
}
#[tokio::test]
async fn test_reset() {
let monitor = AudioHealthMonitor::with_defaults();
let monitor = AudioHealthMonitor::new();
monitor.report_error(None, "Error", "io_error").await;
monitor.report_error("Error", "io_error").await;
assert!(monitor.is_error().await);
monitor.reset().await;
assert!(monitor.is_healthy().await);
assert!(!monitor.is_error().await);
assert_eq!(monitor.retry_count(), 0);
}
#[tokio::test]
async fn test_prepare_retry_hides_error_until_next_failure() {
let monitor = AudioHealthMonitor::new();
monitor.report_error("bad", "e").await;
assert_eq!(monitor.error_message().await.as_deref(), Some("bad"));
monitor.prepare_retry_attempt();
assert!(monitor.is_error().await);
assert!(monitor.error_message().await.is_none());
monitor.report_error("still bad", "e").await;
assert_eq!(monitor.error_message().await.as_deref(), Some("still bad"));
}
}

View File

@@ -1,57 +1,36 @@
//! Audio streaming pipeline
//!
//! Coordinates audio capture and Opus encoding, distributing encoded
//! frames to multiple subscribers via broadcast channel.
//! ALSA 48 kHz stereo → Opus 20 ms frames, fan-out per subscriber.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{broadcast, watch, Mutex, RwLock};
use tracing::{error, info, warn};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::{broadcast, mpsc, watch, Mutex as AsyncMutex, RwLock};
use tracing::{debug, error, info, warn};
use super::capture::{AudioCapturer, AudioConfig, CaptureState};
use super::capture::{AudioCapturer, AudioConfig, AudioFrame, CaptureState};
use super::encoder::{OpusConfig, OpusEncoder, OpusFrame};
use crate::error::{AppError, Result};
use bytemuck;
use bytes::Bytes;
use std::time::Duration;
/// Audio stream state
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// 48 kHz stereo: 20 ms = 960 × 2 samples (S16LE).
const OPUS_STEREO_SAMPLES: usize = 960 * 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AudioStreamState {
/// Stream is stopped
#[default]
Stopped,
/// Stream is starting up
Starting,
/// Stream is running
Running,
/// Stream encountered an error
Error,
}
impl Default for AudioStreamState {
fn default() -> Self {
Self::Stopped
}
}
/// Audio streamer configuration
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct AudioStreamerConfig {
/// Audio capture configuration
pub capture: AudioConfig,
/// Opus encoder configuration
pub opus: OpusConfig,
}
impl Default for AudioStreamerConfig {
fn default() -> Self {
Self {
capture: AudioConfig::default(),
opus: OpusConfig::default(),
}
}
}
impl AudioStreamerConfig {
/// Create config for a specific device with default quality
pub fn for_device(device_name: &str) -> Self {
Self {
capture: AudioConfig {
@@ -62,90 +41,75 @@ impl AudioStreamerConfig {
}
}
/// Create config with specified bitrate
pub fn with_bitrate(mut self, bitrate: u32) -> Self {
self.opus.bitrate = bitrate;
self
}
}
/// Audio stream statistics
#[derive(Debug, Clone, Default)]
pub struct AudioStreamStats {
/// Frames encoded to Opus
/// Number of active subscribers
pub subscriber_count: usize,
}
/// Audio streamer
///
/// Manages the audio capture -> encode -> broadcast pipeline.
pub struct AudioStreamer {
config: RwLock<AudioStreamerConfig>,
state: watch::Sender<AudioStreamState>,
state_rx: watch::Receiver<AudioStreamState>,
capturer: RwLock<Option<Arc<AudioCapturer>>>,
encoder: Arc<Mutex<Option<OpusEncoder>>>,
opus_tx: watch::Sender<Option<Arc<OpusFrame>>>,
stats: Arc<Mutex<AudioStreamStats>>,
sequence: AtomicU64,
stream_start_time: RwLock<Option<Instant>>,
encoder: Arc<AsyncMutex<Option<OpusEncoder>>>,
opus_subscribers: Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
stop_flag: Arc<AtomicBool>,
}
impl AudioStreamer {
/// Create a new audio streamer with default configuration
pub fn new() -> Self {
Self::with_config(AudioStreamerConfig::default())
}
/// Create a new audio streamer with specified configuration
pub fn with_config(config: AudioStreamerConfig) -> Self {
let (state_tx, state_rx) = watch::channel(AudioStreamState::Stopped);
let (opus_tx, _opus_rx) = watch::channel(None);
Self {
config: RwLock::new(config),
state: state_tx,
state_rx,
capturer: RwLock::new(None),
encoder: Arc::new(Mutex::new(None)),
opus_tx,
stats: Arc::new(Mutex::new(AudioStreamStats::default())),
sequence: AtomicU64::new(0),
stream_start_time: RwLock::new(None),
encoder: Arc::new(AsyncMutex::new(None)),
opus_subscribers: Arc::new(Mutex::new(Vec::new())),
stop_flag: Arc::new(AtomicBool::new(false)),
}
}
/// Get current state
pub fn state(&self) -> AudioStreamState {
*self.state_rx.borrow()
}
/// Subscribe to state changes
pub fn state_watch(&self) -> watch::Receiver<AudioStreamState> {
self.state_rx.clone()
}
/// Subscribe to Opus frames
pub fn subscribe_opus(&self) -> watch::Receiver<Option<Arc<OpusFrame>>> {
self.opus_tx.subscribe()
pub fn subscribe_opus(&self) -> mpsc::Receiver<Arc<OpusFrame>> {
let (tx, rx) = mpsc::channel::<Arc<OpusFrame>>(128);
self.opus_subscribers.lock().unwrap().push(tx);
rx
}
/// Get number of active subscribers
pub fn subscriber_count(&self) -> usize {
self.opus_tx.receiver_count()
self.opus_subscribers
.lock()
.unwrap()
.iter()
.filter(|s| !s.is_closed())
.count()
}
/// Get current statistics
pub async fn stats(&self) -> AudioStreamStats {
let mut stats = self.stats.lock().await.clone();
stats.subscriber_count = self.subscriber_count();
stats
pub fn stats(&self) -> AudioStreamStats {
AudioStreamStats {
subscriber_count: self.subscriber_count(),
}
}
/// Update configuration (only when stopped)
pub async fn set_config(&self, config: AudioStreamerConfig) -> Result<()> {
if self.state() != AudioStreamState::Stopped {
return Err(AppError::AudioError(
@@ -156,12 +120,9 @@ impl AudioStreamer {
Ok(())
}
/// Update bitrate dynamically (can be done while streaming)
pub async fn set_bitrate(&self, bitrate: u32) -> Result<()> {
// Update config
self.config.write().await.opus.bitrate = bitrate;
// Update encoder if running
if let Some(ref mut encoder) = *self.encoder.lock().await {
encoder.set_bitrate(bitrate)?;
}
@@ -170,7 +131,6 @@ impl AudioStreamer {
Ok(())
}
/// Start the audio stream
pub async fn start(&self) -> Result<()> {
if self.state() == AudioStreamState::Running {
return Ok(());
@@ -189,42 +149,77 @@ impl AudioStreamer {
config.opus.bitrate
);
// Create capturer
let capturer = Arc::new(AudioCapturer::new(config.capture.clone()));
*self.capturer.write().await = Some(capturer.clone());
// Create encoder
let encoder = OpusEncoder::new(config.opus.clone())?;
*self.encoder.lock().await = Some(encoder);
// Start capture
capturer.start().await?;
// Reset stats
{
let mut stats = self.stats.lock().await;
*stats = AudioStreamStats::default();
let mut capture_state = capturer.state_watch();
let startup_result = tokio::time::timeout(Duration::from_secs(2), async {
loop {
let current_state = *capture_state.borrow();
match current_state {
CaptureState::Running => return Ok(()),
CaptureState::Error => {
return Err(AppError::AudioError(
"Audio capture failed to start".to_string(),
))
}
CaptureState::Stopped => {
if capture_state.changed().await.is_err() {
return Err(AppError::AudioError(
"Audio capture stopped during startup".to_string(),
));
}
}
}
}
})
.await;
match startup_result {
Ok(Ok(())) => {}
Ok(Err(e)) => {
let _ = capturer.stop().await;
*self.capturer.write().await = None;
*self.encoder.lock().await = None;
let _ = self.state.send(AudioStreamState::Error);
return Err(e);
}
Err(_) => {
let _ = capturer.stop().await;
*self.capturer.write().await = None;
*self.encoder.lock().await = None;
let _ = self.state.send(AudioStreamState::Error);
return Err(AppError::AudioError(
"Timed out waiting for audio capture to start".to_string(),
));
}
}
// Record start time
*self.stream_start_time.write().await = Some(Instant::now());
self.sequence.store(0, Ordering::SeqCst);
// Start encoding task
let capturer_for_task = capturer.clone();
let encoder = self.encoder.clone();
let opus_tx = self.opus_tx.clone();
let opus_subscribers = self.opus_subscribers.clone();
let state = self.state.clone();
let stop_flag = self.stop_flag.clone();
tokio::spawn(async move {
Self::stream_task(capturer_for_task, encoder, opus_tx, state, stop_flag).await;
Self::stream_task(
capturer_for_task,
encoder,
opus_subscribers,
state,
stop_flag,
)
.await;
});
Ok(())
}
/// Stop the audio stream
pub async fn stop(&self) -> Result<()> {
if self.state() == AudioStreamState::Stopped {
return Ok(());
@@ -232,84 +227,120 @@ impl AudioStreamer {
info!("Stopping audio stream");
// Signal stop
self.stop_flag.store(true, Ordering::SeqCst);
// Stop capturer
if let Some(ref capturer) = *self.capturer.read().await {
capturer.stop().await?;
}
// Clear resources
*self.capturer.write().await = None;
*self.encoder.lock().await = None;
*self.stream_start_time.write().await = None;
self.opus_subscribers.lock().unwrap().clear();
let _ = self.state.send(AudioStreamState::Stopped);
info!("Audio stream stopped");
Ok(())
}
/// Check if streaming
pub fn is_running(&self) -> bool {
self.state() == AudioStreamState::Running
}
/// Internal streaming task
async fn fanout_opus(
subscribers: &Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
frame: Arc<OpusFrame>,
) {
let txs: Vec<_> = {
let g = subscribers.lock().unwrap();
if g.is_empty() {
return;
}
g.clone()
};
for tx in &txs {
let _ = tx.send(frame.clone()).await;
}
if txs.iter().any(|tx| tx.is_closed()) {
let mut g = subscribers.lock().unwrap();
g.retain(|tx| !tx.is_closed());
}
}
async fn stream_task(
capturer: Arc<AudioCapturer>,
encoder: Arc<Mutex<Option<OpusEncoder>>>,
opus_tx: watch::Sender<Option<Arc<OpusFrame>>>,
encoder: Arc<AsyncMutex<Option<OpusEncoder>>>,
opus_subscribers: Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
state: watch::Sender<AudioStreamState>,
stop_flag: Arc<AtomicBool>,
) {
let mut pcm_rx = capturer.subscribe();
let _ = state.send(AudioStreamState::Running);
info!("Audio stream task started");
debug!("Audio stream task started (48 kHz stereo → Opus, mpsc fan-out)");
let mut pending: Vec<i16> = Vec::new();
loop {
// Check stop flag (atomic, no async lock needed)
if stop_flag.load(Ordering::Relaxed) {
break;
}
// Check capturer state
if capturer.state() == CaptureState::Error {
error!("Audio capture error, stopping stream");
let _ = state.send(AudioStreamState::Error);
break;
}
// Receive PCM frame with timeout
let recv_result =
tokio::time::timeout(std::time::Duration::from_secs(2), pcm_rx.recv()).await;
match recv_result {
Ok(Ok(audio_frame)) => {
// Encode to Opus
let opus_result = {
let mut enc_guard = encoder.lock().await;
if let Some(ref mut enc) = *enc_guard {
Some(enc.encode_frame(&audio_frame))
} else {
None
if audio_frame.sample_rate != 48_000 || audio_frame.channels != 2 {
warn!(
"Skip non48 kHz/stereo PCM ({} Hz, {} ch)",
audio_frame.sample_rate, audio_frame.channels
);
continue;
}
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() {
pending.extend_from_slice(samples);
}
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)));
while pending.len() >= OPUS_STEREO_SAMPLES {
let pcm_20ms = Bytes::copy_from_slice(bytemuck::cast_slice(
&pending[..OPUS_STEREO_SAMPLES],
));
pending.drain(..OPUS_STEREO_SAMPLES);
let frame_48k = AudioFrame::new_interleaved(pcm_20ms, 2, 48_000, 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)) => {
Self::fanout_opus(&opus_subscribers, Arc::new(opus_frame)).await;
}
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;
}
}
}
@@ -318,19 +349,23 @@ impl AudioStreamer {
break;
}
Ok(Err(broadcast::error::RecvError::Lagged(n))) => {
warn!("Audio receiver lagged by {} frames", n);
warn!("PCM receiver lagged by {} frames", n);
}
Err(_) => {
// Timeout - check if still capturing
if capturer.state() != CaptureState::Running {
info!("Audio capture stopped, ending stream task");
let _ = state.send(AudioStreamState::Error);
break;
}
}
}
}
let _ = state.send(AudioStreamState::Stopped);
if stop_flag.load(Ordering::Relaxed) {
let _ = state.send(AudioStreamState::Stopped);
} else {
opus_subscribers.lock().unwrap().clear();
}
info!("Audio stream task ended");
}
}

View File

@@ -8,20 +8,16 @@ use axum::{
use axum_extra::extract::CookieJar;
use std::sync::Arc;
use crate::error::ErrorResponse;
use crate::state::AppState;
use crate::web::ErrorResponse;
/// Session cookie name
pub const SESSION_COOKIE: &str = "one_kvm_session";
/// Extract session ID from request
pub fn extract_session_id(cookies: &CookieJar, headers: &axum::http::HeaderMap) -> Option<String> {
// First try cookie
if let Some(cookie) = cookies.get(SESSION_COOKIE) {
return Some(cookie.value().to_string());
}
// Then try Authorization header (Bearer token)
if let Some(auth_header) = headers.get(axum::http::header::AUTHORIZATION) {
if let Ok(auth_str) = auth_header.to_str() {
if let Some(token) = auth_str.strip_prefix("Bearer ") {
@@ -33,7 +29,6 @@ pub fn extract_session_id(cookies: &CookieJar, headers: &axum::http::HeaderMap)
None
}
/// Authentication middleware
pub async fn auth_middleware(
State(state): State<Arc<AppState>>,
cookies: CookieJar,
@@ -41,29 +36,23 @@ pub async fn auth_middleware(
next: Next,
) -> Result<Response, StatusCode> {
let raw_path = request.uri().path();
// When this middleware is mounted under /api, Axum strips the prefix for the inner router.
// Normalize the path so checks work whether it is mounted or not.
// Mounted under /api: inner path may lack prefix; normalize for whitelist checks.
let path = raw_path.strip_prefix("/api").unwrap_or(raw_path);
// Check if system is initialized
if !state.config.is_initialized() {
// Allow only setup-related endpoints when not initialized
if is_setup_public_endpoint(path) {
return Ok(next.run(request).await);
}
}
// Public endpoints that don't require auth
if is_public_endpoint(path) {
return Ok(next.run(request).await);
}
// Extract session ID
let session_id = extract_session_id(&cookies, request.headers());
if let Some(session_id) = session_id {
if let Ok(Some(session)) = state.sessions.get(&session_id).await {
// Add session to request extensions
request.extensions_mut().insert(session);
return Ok(next.run(request).await);
}
@@ -87,16 +76,10 @@ fn unauthorized_response(message: &str) -> Response {
(StatusCode::UNAUTHORIZED, Json(body)).into_response()
}
/// Check if endpoint is public (no auth required)
fn is_public_endpoint(path: &str) -> bool {
// Note: paths here are relative to /api since middleware is applied within the nested router
matches!(
path,
"/"
| "/auth/login"
| "/health"
| "/setup"
| "/setup/init"
"/" | "/auth/login" | "/health" | "/setup" | "/setup/init"
) || path.starts_with("/assets/")
|| path.starts_with("/static/")
|| path.ends_with(".js")
@@ -106,7 +89,6 @@ fn is_public_endpoint(path: &str) -> bool {
|| path.ends_with(".svg")
}
/// Setup-only endpoints allowed before initialization.
fn is_setup_public_endpoint(path: &str) -> bool {
matches!(
path,

View File

@@ -5,7 +5,6 @@ use argon2::{
use crate::error::{AppError, Result};
/// Hash a password using Argon2
pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
@@ -16,7 +15,6 @@ pub fn hash_password(password: &str) -> Result<String> {
.map_err(|e| AppError::Internal(format!("Password hashing failed: {}", e)))
}
/// Verify a password against a hash
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(hash)
.map_err(|e| AppError::Internal(format!("Invalid password hash: {}", e)))?;

View File

@@ -1,145 +1,104 @@
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Sqlite};
use std::collections::HashMap;
use std::sync::Arc;
use time::{Duration, OffsetDateTime};
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::error::Result;
/// Session data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub user_id: String,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub expires_at: OffsetDateTime,
pub data: Option<serde_json::Value>,
}
impl Session {
/// Check if session is expired
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
OffsetDateTime::now_utc() > self.expires_at
}
}
/// Session store backed by SQLite
#[derive(Clone)]
pub struct SessionStore {
pool: Pool<Sqlite>,
inner: Arc<RwLock<HashMap<String, Session>>>,
default_ttl: Duration,
}
impl SessionStore {
/// Create a new session store
pub fn new(pool: Pool<Sqlite>, ttl_secs: i64) -> Self {
pub fn new(ttl_secs: i64) -> Self {
Self {
pool,
inner: Arc::new(RwLock::new(HashMap::new())),
default_ttl: Duration::seconds(ttl_secs),
}
}
/// Create a new session
pub async fn create(&self, user_id: &str) -> Result<Session> {
let now = OffsetDateTime::now_utc();
let session = Session {
id: Uuid::new_v4().to_string(),
user_id: user_id.to_string(),
created_at: Utc::now(),
expires_at: Utc::now() + self.default_ttl,
created_at: now,
expires_at: now + self.default_ttl,
data: None,
};
sqlx::query(
r#"
INSERT INTO sessions (id, user_id, created_at, expires_at, data)
VALUES (?1, ?2, ?3, ?4, ?5)
"#,
)
.bind(&session.id)
.bind(&session.user_id)
.bind(session.created_at.to_rfc3339())
.bind(session.expires_at.to_rfc3339())
.bind(session.data.as_ref().map(|d| d.to_string()))
.execute(&self.pool)
.await?;
let mut guard = self.inner.write().await;
guard.insert(session.id.clone(), session.clone());
Ok(session)
}
/// Get a session by ID
pub async fn get(&self, session_id: &str) -> Result<Option<Session>> {
let row: Option<(String, String, String, String, Option<String>)> = sqlx::query_as(
"SELECT id, user_id, created_at, expires_at, data FROM sessions WHERE id = ?1",
)
.bind(session_id)
.fetch_optional(&self.pool)
.await?;
match row {
Some((id, user_id, created_at, expires_at, data)) => {
let session = Session {
id,
user_id,
created_at: DateTime::parse_from_rfc3339(&created_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
expires_at: DateTime::parse_from_rfc3339(&expires_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
data: data.and_then(|d| serde_json::from_str(&d).ok()),
};
if session.is_expired() {
self.delete(&session.id).await?;
Ok(None)
} else {
Ok(Some(session))
}
}
None => Ok(None),
let mut guard = self.inner.write().await;
let Some(session) = guard.get(session_id).cloned() else {
return Ok(None);
};
if session.is_expired() {
guard.remove(session_id);
return Ok(None);
}
Ok(Some(session))
}
/// Delete a session
pub async fn delete(&self, session_id: &str) -> Result<()> {
sqlx::query("DELETE FROM sessions WHERE id = ?1")
.bind(session_id)
.execute(&self.pool)
.await?;
let mut guard = self.inner.write().await;
guard.remove(session_id);
Ok(())
}
/// Delete all expired sessions
pub async fn cleanup_expired(&self) -> Result<u64> {
let result = sqlx::query("DELETE FROM sessions WHERE expires_at < datetime('now')")
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
let mut guard = self.inner.write().await;
let before = guard.len();
guard.retain(|_, s| !s.is_expired());
Ok((before - guard.len()) as u64)
}
/// Delete all sessions
pub async fn delete_all(&self) -> Result<u64> {
let result = sqlx::query("DELETE FROM sessions")
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
let mut guard = self.inner.write().await;
let n = guard.len() as u64;
guard.clear();
Ok(n)
}
/// List all session IDs
pub async fn list_ids(&self) -> Result<Vec<String>> {
let rows: Vec<(String,)> = sqlx::query_as("SELECT id FROM sessions")
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|(id,)| id).collect())
let guard = self.inner.read().await;
Ok(guard.keys().cloned().collect())
}
/// Extend session expiration
pub async fn extend(&self, session_id: &str) -> Result<()> {
let new_expires = Utc::now() + self.default_ttl;
sqlx::query("UPDATE sessions SET expires_at = ?1 WHERE id = ?2")
.bind(new_expires.to_rfc3339())
.bind(session_id)
.execute(&self.pool)
.await?;
let mut guard = self.inner.write().await;
if let Some(session) = guard.get_mut(session_id) {
if session.is_expired() {
guard.remove(session_id);
} else {
session.expires_at = OffsetDateTime::now_utc() + self.default_ttl;
}
}
Ok(())
}
}

View File

@@ -1,127 +1,99 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Sqlite};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use uuid::Uuid;
use super::password::{hash_password, verify_password};
use crate::error::{AppError, Result};
/// User row type from database
type UserRow = (String, String, String, i32, String, String);
type UserRow = (String, String, String);
/// User data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: String,
pub username: String,
#[serde(skip_serializing)]
pub password_hash: String,
pub is_admin: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl User {
/// Convert from database row to User
fn from_row(row: UserRow) -> Self {
let (id, username, password_hash, is_admin, created_at, updated_at) = row;
let (id, username, password_hash) = row;
Self {
id,
username,
password_hash,
is_admin: is_admin != 0,
created_at: DateTime::parse_from_rfc3339(&created_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
updated_at: DateTime::parse_from_rfc3339(&updated_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
}
}
}
/// User store backed by SQLite
#[derive(Clone)]
pub struct UserStore {
pool: Pool<Sqlite>,
}
impl UserStore {
/// Create a new user store
pub fn new(pool: Pool<Sqlite>) -> Self {
Self { pool }
}
/// Create a new user
pub async fn create(&self, username: &str, password: &str, is_admin: bool) -> Result<User> {
// Check if username already exists
if self.get_by_username(username).await?.is_some() {
return Err(AppError::BadRequest(format!(
"Username '{}' already exists",
username
)));
/// The single local user, or `None` if none exists. Errors if more than one row is present.
pub async fn single_user(&self) -> Result<Option<User>> {
let mut rows: Vec<UserRow> = sqlx::query_as(
"SELECT id, username, password_hash FROM users ORDER BY rowid ASC LIMIT 2",
)
.fetch_all(&self.pool)
.await?;
match rows.len() {
0 => Ok(None),
1 => Ok(Some(User::from_row(rows.remove(0)))),
_ => Err(AppError::Internal(
"Multiple user accounts in database; this build supports only one".to_string(),
)),
}
}
pub async fn create_first_user(&self, username: &str, password: &str) -> Result<User> {
if self.single_user().await?.is_some() {
return Err(AppError::BadRequest(
"A user account already exists".to_string(),
));
}
let password_hash = hash_password(password)?;
let now = Utc::now();
let user = User {
id: Uuid::new_v4().to_string(),
username: username.to_string(),
password_hash,
is_admin,
created_at: now,
updated_at: now,
};
sqlx::query(
r#"
INSERT INTO users (id, username, password_hash, is_admin, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
INSERT INTO users (id, username, password_hash)
VALUES (?1, ?2, ?3)
"#,
)
.bind(&user.id)
.bind(&user.username)
.bind(&user.password_hash)
.bind(user.is_admin as i32)
.bind(user.created_at.to_rfc3339())
.bind(user.updated_at.to_rfc3339())
.execute(&self.pool)
.await?;
Ok(user)
}
/// Get user by ID
pub async fn get(&self, user_id: &str) -> Result<Option<User>> {
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, username, password_hash, is_admin, created_at, updated_at FROM users WHERE id = ?1",
)
.bind(user_id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(User::from_row))
}
/// Get user by username
pub async fn get_by_username(&self, username: &str) -> Result<Option<User>> {
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, username, password_hash, is_admin, created_at, updated_at FROM users WHERE username = ?1",
)
.bind(username)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(User::from_row))
}
/// Verify user credentials
pub async fn verify(&self, username: &str, password: &str) -> Result<Option<User>> {
let user = match self.get_by_username(username).await? {
Some(user) => user,
let user = match self.single_user().await? {
Some(u) => u,
None => return Ok(None),
};
if user.username != username {
return Ok(None);
}
if verify_password(password, &user.password_hash)? {
Ok(Some(user))
} else {
@@ -129,15 +101,23 @@ impl UserStore {
}
}
/// Update user password
pub async fn update_password(&self, user_id: &str, new_password: &str) -> Result<()> {
let user = self
.single_user()
.await?
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
if user.id != user_id {
return Err(AppError::AuthError("Invalid session".to_string()));
}
let password_hash = hash_password(new_password)?;
let now = Utc::now();
let now = OffsetDateTime::now_utc();
let result =
sqlx::query("UPDATE users SET password_hash = ?1, updated_at = ?2 WHERE id = ?3")
.bind(&password_hash)
.bind(now.to_rfc3339())
.bind(now.format(&Rfc3339).expect("RFC3339 format"))
.bind(user_id)
.execute(&self.pool)
.await?;
@@ -149,47 +129,24 @@ impl UserStore {
Ok(())
}
/// Update username
pub async fn update_username(&self, user_id: &str, new_username: &str) -> Result<()> {
if let Some(existing) = self.get_by_username(new_username).await? {
if existing.id != user_id {
return Err(AppError::BadRequest(format!(
"Username '{}' already exists",
new_username
)));
}
let user = self
.single_user()
.await?
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
if user.id != user_id {
return Err(AppError::AuthError("Invalid session".to_string()));
}
let now = Utc::now();
let result =
sqlx::query("UPDATE users SET username = ?1, updated_at = ?2 WHERE id = ?3")
.bind(new_username)
.bind(now.to_rfc3339())
.bind(user_id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("User not found".to_string()));
if new_username == user.username {
return Ok(());
}
Ok(())
}
/// List all users
pub async fn list(&self) -> Result<Vec<User>> {
let rows: Vec<UserRow> = sqlx::query_as(
"SELECT id, username, password_hash, is_admin, created_at, updated_at FROM users ORDER BY created_at",
)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(User::from_row).collect())
}
/// Delete user by ID
pub async fn delete(&self, user_id: &str) -> Result<()> {
let result = sqlx::query("DELETE FROM users WHERE id = ?1")
let now = OffsetDateTime::now_utc();
let result = sqlx::query("UPDATE users SET username = ?1, updated_at = ?2 WHERE id = ?3")
.bind(new_username)
.bind(now.format(&Rfc3339).expect("RFC3339 format"))
.bind(user_id)
.execute(&self.pool)
.await?;
@@ -200,12 +157,4 @@ impl UserStore {
Ok(())
}
/// Check if any users exist
pub async fn has_users(&self) -> Result<bool> {
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&self.pool)
.await?;
Ok(count.0 > 0)
}
}

View File

@@ -1,5 +1,7 @@
mod persistence;
mod schema;
mod store;
pub use persistence::ConfigChange;
pub use schema::*;
pub use store::ConfigStore;

View File

@@ -0,0 +1,5 @@
/// Configuration change event
#[derive(Debug, Clone)]
pub struct ConfigChange {
pub key: String,
}

View File

@@ -1,16 +1,90 @@
use crate::video::encoder::BitratePreset;
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
// Re-export ExtensionsConfig from extensions module
// Re-export domain config types that are embedded in AppConfig.
// These are simple data types defined in their respective modules;
// keeping the re-export here is acceptable since they flow inward.
pub use crate::extensions::ExtensionsConfig;
// Re-export RustDeskConfig from rustdesk module
pub use crate::rustdesk::config::RustDeskConfig;
/// Bitrate preset for video encoding
///
/// Simplifies bitrate configuration by providing three intuitive presets
/// plus a custom option for advanced users.
#[typeshare]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", content = "value")]
#[derive(Default)]
pub enum BitratePreset {
/// Speed priority: 1 Mbps, lowest latency, smaller GOP
Speed,
/// Balanced: 4 Mbps, good quality/latency tradeoff
#[default]
Balanced,
/// Quality priority: 8 Mbps, best visual quality
Quality,
/// Custom bitrate in kbps (for advanced users)
Custom(u32),
}
impl BitratePreset {
/// Get bitrate value in kbps
pub fn bitrate_kbps(&self) -> u32 {
match self {
Self::Speed => 1000,
Self::Balanced => 4000,
Self::Quality => 8000,
Self::Custom(kbps) => *kbps,
}
}
/// Get recommended GOP size based on preset
pub fn gop_size(&self, fps: u32) -> u32 {
match self {
Self::Speed => (fps / 2).max(15),
Self::Balanced => fps,
Self::Quality => fps * 2,
Self::Custom(_) => fps,
}
}
/// Get quality preset name for encoder configuration
pub fn quality_level(&self) -> &'static str {
match self {
Self::Speed => "low",
Self::Balanced => "medium",
Self::Quality => "high",
Self::Custom(_) => "medium",
}
}
/// Create from kbps value, mapping to nearest preset or Custom
pub fn from_kbps(kbps: u32) -> Self {
match kbps {
0..=1500 => Self::Speed,
1501..=6000 => Self::Balanced,
6001..=10000 => Self::Quality,
_ => Self::Custom(kbps),
}
}
}
impl std::fmt::Display for BitratePreset {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Speed => write!(f, "Speed (1 Mbps)"),
Self::Balanced => write!(f, "Balanced (4 Mbps)"),
Self::Quality => write!(f, "Quality (8 Mbps)"),
Self::Custom(kbps) => write!(f, "Custom ({} kbps)", kbps),
}
}
}
/// Main application configuration
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct AppConfig {
/// Whether initial setup has been completed
pub initialized: bool,
@@ -34,24 +108,8 @@ pub struct AppConfig {
pub extensions: ExtensionsConfig,
/// RustDesk remote access settings
pub rustdesk: RustDeskConfig,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
initialized: false,
auth: AuthConfig::default(),
video: VideoConfig::default(),
hid: HidConfig::default(),
msd: MsdConfig::default(),
atx: AtxConfig::default(),
audio: AudioConfig::default(),
stream: StreamConfig::default(),
web: WebConfig::default(),
extensions: ExtensionsConfig::default(),
rustdesk: RustDeskConfig::default(),
}
}
/// RTSP streaming settings
pub rtsp: RtspConfig,
}
/// Authentication configuration
@@ -116,21 +174,17 @@ impl Default for VideoConfig {
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum HidBackend {
/// USB OTG HID gadget
Otg,
/// CH9329 serial HID controller
Ch9329,
/// Disabled
#[default]
None,
}
impl Default for HidBackend {
fn default() -> Self {
Self::None
}
}
/// OTG USB device descriptor configuration
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -163,15 +217,15 @@ impl Default for OtgDescriptorConfig {
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
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
@@ -180,15 +234,38 @@ pub enum OtgHidProfile {
Custom,
}
impl Default for OtgHidProfile {
fn default() -> Self {
Self::Full
/// 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 {
/// Resolve endpoint limit assuming a known budget variant (not Auto).
pub fn endpoint_limit_raw(&self) -> Option<u8> {
match self {
Self::Five => Some(5),
Self::Six => Some(6),
Self::Unlimited => None,
Self::Auto => None, // resolved via `HidConfig::resolved_otg_endpoint_limit`
}
}
}
/// 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,
@@ -237,6 +314,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 {
@@ -246,12 +343,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(),
@@ -266,10 +372,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
@@ -278,9 +380,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
@@ -293,12 +401,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,
@@ -307,9 +415,92 @@ impl Default for HidConfig {
}
impl HidConfig {
/// Resolve effective OTG HID functions from profile + custom selection.
/// Pure logic, no external dependency.
pub fn effective_otg_functions(&self) -> OtgHidFunctions {
self.otg_profile.resolve_functions(&self.otg_functions)
}
/// Whether keyboard LED feedback is effectively enabled.
pub fn effective_otg_keyboard_leds(&self) -> bool {
self.otg_keyboard_leds && self.effective_otg_functions().keyboard
}
/// Effective HID functions after applying all constraints.
pub fn constrained_otg_functions(&self) -> OtgHidFunctions {
self.effective_otg_functions()
}
/// Calculate required endpoint count for the current function selection.
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
}
/// Validate endpoint budget for the current OTG configuration (UDC-aware when budget is Auto).
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 resolved_limit = self.resolved_otg_endpoint_limit();
let required = self.effective_otg_required_endpoints(msd_enabled);
if let Some(limit) = resolved_limit {
if required > limit {
return Err(crate::error::AppError::BadRequest(format!(
"OTG selection requires {} endpoints, but the configured limit is {}",
required, limit
)));
}
}
Ok(())
}
/// Effective OTG UDC name (for change detection / service).
#[inline]
pub fn resolved_otg_udc(&self) -> Option<String> {
if self.backend != HidBackend::Otg {
return None;
}
self.otg_udc
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| crate::otg::OtgGadgetManager::find_udc())
}
/// Resolved endpoint limit used for OTG gadget allocator / validation.
#[inline]
pub fn resolved_otg_endpoint_limit(&self) -> Option<u8> {
if self.backend != HidBackend::Otg {
return None;
}
match self.otg_endpoint_budget {
OtgEndpointBudget::Five => Some(5),
OtgEndpointBudget::Six => Some(6),
OtgEndpointBudget::Unlimited => None,
OtgEndpointBudget::Auto => {
let udc = self.resolved_otg_udc().unwrap_or_default();
if crate::otg::configfs::is_low_endpoint_udc(&udc) {
Some(5)
} else {
Some(6)
}
}
}
}
}
/// MSD configuration
@@ -360,6 +551,7 @@ pub use crate::atx::{ActiveLevel, AtxDriverType, AtxKeyConfig, AtxLedConfig};
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct AtxConfig {
/// Enable ATX functionality
pub enabled: bool,
@@ -373,18 +565,6 @@ pub struct AtxConfig {
pub wol_interface: String,
}
impl Default for AtxConfig {
fn default() -> Self {
Self {
enabled: false,
power: AtxKeyConfig::default(),
reset: AtxKeyConfig::default(),
led: AtxLedConfig::default(),
wol_interface: String::new(),
}
}
}
impl AtxConfig {
/// Convert to AtxControllerConfig for the controller
pub fn to_controller_config(&self) -> crate::atx::AtxControllerConfig {
@@ -417,7 +597,7 @@ impl Default for AudioConfig {
fn default() -> Self {
Self {
enabled: false,
device: "default".to_string(),
device: String::new(),
quality: "balanced".to_string(),
}
}
@@ -427,16 +607,62 @@ impl Default for AudioConfig {
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum StreamMode {
/// WebRTC with H264/H265
WebRTC,
/// MJPEG over HTTP
#[default]
Mjpeg,
}
impl Default for StreamMode {
/// RTSP output codec
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum RtspCodec {
#[default]
H264,
H265,
}
/// RTSP configuration
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RtspConfig {
/// Enable RTSP output
pub enabled: bool,
/// Bind IP address
pub bind: String,
/// RTSP TCP listen port
pub port: u16,
/// Stream path (without leading slash)
pub path: String,
/// Allow only one client connection at a time
pub allow_one_client: bool,
/// Output codec (H264/H265)
pub codec: RtspCodec,
/// Optional username for authentication
pub username: Option<String>,
/// Optional password for authentication
#[typeshare(skip)]
pub password: Option<String>,
}
impl Default for RtspConfig {
fn default() -> Self {
Self::Mjpeg
Self {
enabled: false,
bind: "0.0.0.0".to_string(),
port: 8554,
path: "live".to_string(),
allow_one_client: true,
codec: RtspCodec::H264,
username: None,
password: None,
}
}
}
@@ -444,8 +670,10 @@ impl Default for StreamMode {
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum EncoderType {
/// Auto-detect best encoder
#[default]
Auto,
/// Software encoder (libx264)
Software,
@@ -463,28 +691,7 @@ pub enum EncoderType {
V4l2m2m,
}
impl Default for EncoderType {
fn default() -> Self {
Self::Auto
}
}
impl EncoderType {
/// Convert to EncoderBackend for registry queries
pub fn to_backend(&self) -> Option<crate::video::encoder::registry::EncoderBackend> {
use crate::video::encoder::registry::EncoderBackend;
match self {
EncoderType::Auto => None,
EncoderType::Software => Some(EncoderBackend::Software),
EncoderType::Vaapi => Some(EncoderBackend::Vaapi),
EncoderType::Nvenc => Some(EncoderBackend::Nvenc),
EncoderType::Qsv => Some(EncoderBackend::Qsv),
EncoderType::Amf => Some(EncoderBackend::Amf),
EncoderType::Rkmpp => Some(EncoderBackend::Rkmpp),
EncoderType::V4l2m2m => Some(EncoderBackend::V4l2m2m),
}
}
/// Get display name for UI
pub fn display_name(&self) -> &'static str {
match self {
@@ -551,23 +758,23 @@ impl Default for StreamConfig {
}
impl StreamConfig {
/// Check if using public ICE servers (user left fields empty)
/// Whether built-in / public ICE is used (no custom STUN or TURN URL configured).
pub fn is_using_public_ice_servers(&self) -> bool {
use crate::webrtc::config::public_ice;
self.stun_server
let no_custom_stun = self
.stun_server
.as_ref()
.map(|s| s.is_empty())
.unwrap_or(true)
&& self
.turn_server
.as_ref()
.map(|s| s.is_empty())
.unwrap_or(true)
&& public_ice::is_configured()
.map_or(true, |s| s.trim().is_empty());
let no_custom_turn = self
.turn_server
.as_ref()
.map_or(true, |s| s.trim().is_empty());
no_custom_stun && no_custom_turn
}
}
/// 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)]

View File

@@ -1,10 +1,10 @@
use arc_swap::ArcSwap;
use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite};
use std::path::Path;
use sqlx::{Pool, Sqlite};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::broadcast;
use tokio::sync::Mutex;
use super::persistence::ConfigChange;
use super::AppConfig;
use crate::error::{AppError, Result};
@@ -18,109 +18,27 @@ pub struct ConfigStore {
/// Lock-free cache using ArcSwap for zero-cost reads
cache: Arc<ArcSwap<AppConfig>>,
change_tx: broadcast::Sender<ConfigChange>,
}
/// Configuration change event
#[derive(Debug, Clone)]
pub struct ConfigChange {
pub key: String,
/// Serializes `set` / `update` so concurrent PATCH handlers cannot clobber each other
write_lock: Arc<Mutex<()>>,
}
impl ConfigStore {
/// Create a new configuration store
pub async fn new(db_path: &Path) -> Result<Self> {
// Ensure parent directory exists
if let Some(parent) = db_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let db_url = format!("sqlite:{}?mode=rwc", db_path.display());
let pool = SqlitePoolOptions::new()
// SQLite uses single-writer mode, 2 connections is sufficient for embedded devices
// One for reads, one for writes to avoid blocking
.max_connections(2)
// Set reasonable timeouts for embedded environments
.acquire_timeout(Duration::from_secs(5))
.idle_timeout(Duration::from_secs(300))
.connect(&db_url)
.await?;
// Initialize database schema
Self::init_schema(&pool).await?;
// Load or create default config
let config = Self::load_config(&pool).await?;
let cache = Arc::new(ArcSwap::from_pointee(config));
let (change_tx, _) = broadcast::channel(16);
pub fn new(pool: Pool<Sqlite>) -> Result<Self> {
// Load or create default config synchronously wrapper
// (actual DB load is async, handled in init())
Ok(Self {
pool,
cache,
change_tx,
cache: Arc::new(ArcSwap::from_pointee(AppConfig::default())),
change_tx: broadcast::channel(16).0,
write_lock: Arc::new(Mutex::new(())),
})
}
/// Initialize database schema
async fn init_schema(pool: &Pool<Sqlite>) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
data TEXT
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
permissions TEXT NOT NULL,
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used TEXT
)
"#,
)
.execute(pool)
.await?;
/// Load configuration from database (call after new())
pub async fn load(&self) -> Result<()> {
let config = Self::load_config(&self.pool).await?;
self.cache.store(Arc::new(config));
Ok(())
}
@@ -172,6 +90,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));
@@ -185,13 +104,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();
@@ -220,16 +139,12 @@ impl ConfigStore {
pub fn is_initialized(&self) -> bool {
self.cache.load().initialized
}
/// Get database pool for session management
pub fn pool(&self) -> &Pool<Sqlite> {
&self.pool
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::DatabasePool;
use tempfile::tempdir;
#[tokio::test]
@@ -237,7 +152,11 @@ mod tests {
let dir = tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = ConfigStore::new(&db_path).await.unwrap();
let db = DatabasePool::new(&db_path).await.unwrap();
db.init_schema().await.unwrap();
let store = ConfigStore::new(db.clone_pool()).unwrap();
store.load().await.unwrap();
// Check default config (now lock-free, no await needed)
let config = store.get();
@@ -258,7 +177,8 @@ mod tests {
assert_eq!(config.web.http_port, 9000);
// Create new store instance and verify persistence
let store2 = ConfigStore::new(&db_path).await.unwrap();
let store2 = ConfigStore::new(db.clone_pool()).unwrap();
store2.load().await.unwrap();
let config = store2.get();
assert!(config.initialized);
assert_eq!(config.web.http_port, 9000);

3
src/db/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
mod pool;
pub use pool::DatabasePool;

119
src/db/pool.rs Normal file
View File

@@ -0,0 +1,119 @@
use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite};
use std::path::Path;
use std::time::Duration;
use crate::error::Result;
#[derive(Clone)]
pub struct DatabasePool {
pool: Pool<Sqlite>,
}
impl DatabasePool {
pub async fn new(db_path: &Path) -> Result<Self> {
if let Some(parent) = db_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let db_url = format!("sqlite:{}?mode=rwc", db_path.display());
let pool = SqlitePoolOptions::new()
.max_connections(4)
.acquire_timeout(Duration::from_secs(5))
.idle_timeout(Duration::from_secs(300))
.connect(&db_url)
.await?;
Ok(Self { pool })
}
pub async fn init_schema(&self) -> Result<()> {
self.create_config_table().await?;
self.create_users_table().await?;
self.create_api_tokens_table().await?;
self.create_wol_history_table().await?;
Ok(())
}
async fn create_config_table(&self) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"#,
)
.execute(&self.pool)
.await?;
Ok(())
}
async fn create_users_table(&self) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"#,
)
.execute(&self.pool)
.await?;
Ok(())
}
async fn create_api_tokens_table(&self) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
permissions TEXT NOT NULL,
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used TEXT
)
"#,
)
.execute(&self.pool)
.await?;
Ok(())
}
async fn create_wol_history_table(&self) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS wol_history (
mac_address TEXT PRIMARY KEY,
updated_at INTEGER NOT NULL
)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS idx_wol_history_updated_at
ON wol_history(updated_at DESC)
"#,
)
.execute(&self.pool)
.await?;
Ok(())
}
pub fn pool(&self) -> &Pool<Sqlite> {
&self.pool
}
pub fn clone_pool(&self) -> Pool<Sqlite> {
self.pool.clone()
}
}

View File

@@ -1,12 +1,5 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use thiserror::Error;
/// Application-wide error type
#[derive(Error, Debug)]
pub enum AppError {
#[error("Authentication failed: {0}")]
@@ -15,17 +8,14 @@ pub enum AppError {
#[error("Not authenticated")]
Unauthorized,
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Not found: {0}")]
NotFound(String),
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Persistence error: {0}")]
Persistence(String),
#[error("Internal error: {0}")]
Internal(String),
@@ -42,8 +32,9 @@ pub enum AppError {
#[error("Video error: {0}")]
VideoError(String),
#[error("Video device lost [{device}]: {reason}")]
VideoDeviceLost { device: String, reason: String },
/// No input signal while opening capture; `kind` is `SignalStatus` as string (`from_str`).
#[error("Capture has no valid signal: {kind}")]
CaptureNoSignal { kind: String },
#[error("Audio error: {0}")]
AudioError(String),
@@ -62,37 +53,10 @@ pub enum AppError {
ServiceUnavailable(String),
}
/// Error response body (unified success format)
#[derive(Serialize)]
pub struct ErrorResponse {
pub success: bool,
pub message: String,
}
impl AppError {
fn status_code(&self) -> StatusCode {
// Always return 200 OK - success/failure is indicated by the success field
StatusCode::OK
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = self.status_code();
let body = ErrorResponse {
success: false,
message: self.to_string(),
};
tracing::error!(
error_type = std::any::type_name_of_val(&self),
error_message = %body.message,
"Request failed"
);
(status, Json(body)).into_response()
}
}
/// Result type alias for handlers
pub type Result<T> = std::result::Result<T, AppError>;
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
AppError::Persistence(err.to_string())
}
}

View File

@@ -1,78 +1,108 @@
//! Event system for real-time state notifications
//!
//! This module provides a global event bus for broadcasting system events
//! to WebSocket clients and other subscribers.
//! Event bus: [`SystemEvent`] fan-out to WebSocket subscribers and internal tasks.
pub mod types;
use self::types::EXACT_EVENT_TOPICS;
pub use types::{
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, MsdDeviceInfo, SystemEvent,
VideoDeviceInfo,
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, LedState, MsdDeviceInfo,
StreamDeviceLostKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo,
};
use tokio::sync::broadcast;
/// Event channel capacity (ring buffer size)
const EVENT_CHANNEL_CAPACITY: usize = 256;
/// Global event bus for broadcasting system events
///
/// The event bus uses tokio's broadcast channel to distribute events
/// to multiple subscribers. Events are delivered to all active subscribers.
///
/// # Example
///
/// ```no_run
/// use one_kvm::events::{EventBus, SystemEvent};
///
/// let bus = EventBus::new();
///
/// // Publish an event
/// bus.publish(SystemEvent::StreamStateChanged {
/// state: "streaming".to_string(),
/// device: Some("/dev/video0".to_string()),
/// });
///
/// // Subscribe to events
/// let mut rx = bus.subscribe();
/// tokio::spawn(async move {
/// while let Ok(event) = rx.recv().await {
/// println!("Received event: {:?}", event);
/// }
/// });
/// ```
fn collect_prefix_wildcards(exact: &[&'static str]) -> Vec<String> {
use std::collections::BTreeSet;
let mut segments = BTreeSet::new();
for name in exact {
if let Some((seg, _)) = name.split_once('.') {
segments.insert(seg);
}
}
segments.into_iter().map(|s| format!("{}.*", s)).collect()
}
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))
}
pub struct EventBus {
tx: broadcast::Sender<SystemEvent>,
exact_topics: std::collections::HashMap<&'static str, broadcast::Sender<SystemEvent>>,
prefix_topics: std::collections::HashMap<String, 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_EVENT_TOPICS
.iter()
.map(|topic| (*topic, make_sender()))
.collect();
let prefix_topics = collect_prefix_wildcards(EXACT_EVENT_TOPICS)
.into_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
///
/// 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) {
// If no subscribers, send returns Err which is normal
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) {
let _ = tx.send(event.clone());
}
}
let _ = self.tx.send(event);
}
/// Subscribe to events
///
/// Returns a receiver that will receive all future events.
/// The receiver uses a ring buffer, so if a subscriber falls too far
/// behind, it will receive a `Lagged` error and miss some events.
pub fn subscribe(&self) -> broadcast::Receiver<SystemEvent> {
self.tx.subscribe()
}
/// Get the current number of active subscribers
///
/// Useful for monitoring and debugging.
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())
}
pub fn mark_device_info_dirty(&self) {
let _ = self.device_info_dirty_tx.send(());
}
pub fn subscribe_device_info_dirty(&self) -> broadcast::Receiver<()> {
self.device_info_dirty_tx.subscribe()
}
pub fn subscriber_count(&self) -> usize {
self.tx.receiver_count()
}
@@ -96,6 +126,8 @@ mod tests {
bus.publish(SystemEvent::StreamStateChanged {
state: "streaming".to_string(),
device: Some("/dev/video0".to_string()),
reason: None,
next_retry_ms: None,
});
let event = rx.recv().await.unwrap();
@@ -110,17 +142,56 @@ 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()),
reason: None,
next_retry_ms: None,
});
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,
reason: None,
next_retry_ms: 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,
reason: None,
next_retry_ms: 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]
@@ -128,11 +199,11 @@ mod tests {
let bus = EventBus::new();
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,
reason: None,
next_retry_ms: None,
});
}
}

View File

@@ -1,547 +1,234 @@
//! System event types
//!
//! Defines all event types that can be broadcast through the event bus.
//! [`SystemEvent`] and device snapshot types (WebSocket / JSON).
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::atx::PowerStatus;
use crate::msd::MsdMode;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct LedState {
pub num_lock: bool,
pub caps_lock: bool,
pub scroll_lock: bool,
pub compose: bool,
pub kana: bool,
}
// ============================================================================
// Device Info Structures (for system.device_info event)
// ============================================================================
/// Video device information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoDeviceInfo {
/// Whether video device is available
pub available: bool,
/// Device path (e.g., /dev/video0)
pub device: Option<String>,
/// Pixel format (e.g., "MJPEG", "YUYV")
pub format: Option<String>,
/// Resolution (width, height)
pub resolution: Option<(u32, u32)>,
/// Frames per second
pub fps: u32,
/// Whether stream is currently active
pub online: bool,
/// Current streaming mode: "mjpeg", "h264", "h265", "vp8", or "vp9"
pub stream_mode: String,
/// Whether video config is currently being changed (frontend should skip mode sync)
pub config_changing: bool,
/// Error message if any, None if OK
pub error: Option<String>,
}
/// HID device information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HidDeviceInfo {
/// Whether HID backend is available
pub available: bool,
/// Backend type: "otg", "ch9329", "none"
pub backend: String,
/// Whether backend is initialized and ready
pub initialized: bool,
/// Whether absolute mouse positioning is supported
pub online: bool,
pub supports_absolute_mouse: bool,
/// Device path (e.g., serial port for CH9329)
pub keyboard_leds_enabled: bool,
pub led_state: LedState,
pub device: Option<String>,
/// Error message if any, None if OK
pub error: Option<String>,
pub error_code: Option<String>,
}
/// MSD device information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MsdDeviceInfo {
/// Whether MSD is available
pub available: bool,
/// Operating mode: "none", "image", "drive"
pub mode: String,
/// Whether storage is connected to target
pub connected: bool,
/// Currently mounted image ID
pub image_id: Option<String>,
/// Error message if any, None if OK
pub error: Option<String>,
}
/// ATX device information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AtxDeviceInfo {
/// Whether ATX controller is available
pub available: bool,
/// Backend type: "gpio", "usb_relay", "none"
pub backend: String,
/// Whether backend is initialized
pub initialized: bool,
/// Whether power is currently on
pub power_on: bool,
/// Error message if any, None if OK
pub error: Option<String>,
}
/// Audio device information
///
/// Note: Sample rate is fixed at 48000Hz and channels at 2 (stereo).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioDeviceInfo {
/// Whether audio is enabled/available
pub available: bool,
/// Whether audio is currently streaming
pub streaming: bool,
/// Current audio device name
pub device: Option<String>,
/// Quality preset: "voice", "balanced", "high"
pub quality: String,
/// Error message if any, None if OK
pub error: Option<String>,
}
/// Per-client statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TtydDeviceInfo {
pub available: bool,
pub running: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientStats {
/// Client ID
pub id: String,
/// Current FPS for this client (frames sent in last second)
pub fps: u32,
/// Connected duration (seconds)
pub connected_secs: u64,
}
/// System event enumeration
///
/// All events are tagged with their event name for serialization.
/// The `serde(tag = "event", content = "data")` attribute creates a
/// JSON structure like:
/// ```json
/// {
/// "event": "stream.state_changed",
/// "data": { "state": "streaming", "device": "/dev/video0" }
/// }
/// ```
/// Video vs audio source for [`SystemEvent::StreamDeviceLost`] (WebSocket `stream.device_lost`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StreamDeviceLostKind {
Video,
Audio,
}
/// JSON: `{"event": "<name>", "data": { ... }}`.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "event", content = "data")]
#[allow(clippy::large_enum_variant)]
pub enum SystemEvent {
// ============================================================================
// Video Stream Events
// ============================================================================
/// Stream mode switching started (transactional, correlates all following events)
///
/// Sent immediately after a mode switch request is accepted.
/// Clients can use `transition_id` to correlate subsequent `stream.*` events.
#[serde(rename = "stream.mode_switching")]
StreamModeSwitching {
/// Unique transition ID for this mode switch transaction
transition_id: String,
/// Target mode: "mjpeg", "h264", "h265", "vp8", "vp9"
to_mode: String,
/// Previous mode: "mjpeg", "h264", "h265", "vp8", "vp9"
from_mode: String,
},
/// Stream state changed (e.g., started, stopped, error)
#[serde(rename = "stream.state_changed")]
StreamStateChanged {
/// Current state: "uninitialized", "ready", "streaming", "no_signal", "error"
state: String,
/// Device path if available
device: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
next_retry_ms: Option<u64>,
},
/// Stream configuration is being changed
///
/// Sent before applying new configuration to notify clients that
/// the stream will be interrupted temporarily.
#[serde(rename = "stream.config_changing")]
StreamConfigChanging {
/// Optional transition ID if this config change is part of a mode switch transaction
#[serde(skip_serializing_if = "Option::is_none")]
transition_id: Option<String>,
/// Reason for change: "device_switch", "resolution_change", "format_change"
reason: String,
},
/// Stream configuration has been applied successfully
///
/// Sent after new configuration is active. Clients can reconnect now.
#[serde(rename = "stream.config_applied")]
StreamConfigApplied {
/// Optional transition ID if this config change is part of a mode switch transaction
#[serde(skip_serializing_if = "Option::is_none")]
transition_id: Option<String>,
/// Device path
device: String,
/// Resolution (width, height)
resolution: (u32, u32),
/// Pixel format: "mjpeg", "yuyv", etc.
format: String,
/// Frames per second
fps: u32,
},
/// Stream device was lost (disconnected or error)
#[serde(rename = "stream.device_lost")]
StreamDeviceLost {
/// Device path that was lost
kind: StreamDeviceLostKind,
device: String,
/// Reason for loss
reason: String,
},
/// Stream device is reconnecting
#[serde(rename = "stream.reconnecting")]
StreamReconnecting {
/// Device path being reconnected
device: String,
/// Retry attempt number
attempt: u32,
},
StreamReconnecting { device: String, attempt: u32 },
/// Stream device has recovered
#[serde(rename = "stream.recovered")]
StreamRecovered {
/// Device path that was recovered
device: String,
},
StreamRecovered { device: String },
/// WebRTC is ready to accept connections
///
/// Sent after video frame source is connected to WebRTC pipeline.
/// Clients should wait for this event before attempting to create WebRTC sessions.
#[serde(rename = "stream.webrtc_ready")]
WebRTCReady {
/// Optional transition ID if this readiness is part of a mode switch transaction
#[serde(skip_serializing_if = "Option::is_none")]
transition_id: Option<String>,
/// Current video codec
codec: String,
/// Whether hardware encoding is being used
hardware: bool,
},
/// WebRTC ICE candidate (server -> client trickle)
#[serde(rename = "webrtc.ice_candidate")]
WebRTCIceCandidate {
/// WebRTC session ID
session_id: String,
/// ICE candidate data
candidate: crate::webrtc::signaling::IceCandidate,
candidate: serde_json::Value,
},
/// WebRTC ICE gathering complete (server -> client)
#[serde(rename = "webrtc.ice_complete")]
WebRTCIceComplete {
/// WebRTC session ID
session_id: String,
},
WebRTCIceComplete { session_id: String },
/// Stream statistics update (sent periodically for client stats)
#[serde(rename = "stream.stats_update")]
StreamStatsUpdate {
/// Number of connected clients
clients: u64,
/// Per-client statistics (client_id -> client stats)
/// Each client's FPS reflects the actual frames sent in the last second
clients_stat: HashMap<String, ClientStats>,
},
/// Stream mode changed (MJPEG <-> WebRTC)
///
/// Sent when the streaming mode is switched. Clients should disconnect
/// from the current stream and reconnect using the new mode.
#[serde(rename = "stream.mode_changed")]
StreamModeChanged {
/// Optional transition ID if this change is part of a mode switch transaction
#[serde(skip_serializing_if = "Option::is_none")]
transition_id: Option<String>,
/// New mode: "mjpeg", "h264", "h265", "vp8", or "vp9"
mode: String,
/// Previous mode: "mjpeg", "h264", "h265", "vp8", or "vp9"
previous_mode: String,
},
/// Stream mode switching completed (transactional end marker)
///
/// Sent when the backend considers the new mode ready for clients to connect.
#[serde(rename = "stream.mode_ready")]
StreamModeReady {
/// Unique transition ID for this mode switch transaction
transition_id: String,
/// Active mode after switch: "mjpeg", "h264", "h265", "vp8", "vp9"
mode: String,
},
StreamModeReady { transition_id: String, 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 {
/// Upload operation ID
upload_id: String,
/// Filename being uploaded
filename: String,
/// Bytes uploaded so far
bytes_uploaded: u64,
/// Total file size
total_bytes: u64,
/// Progress percentage (0.0 - 100.0)
progress_pct: f32,
},
/// Image download progress (for URL downloads)
#[serde(rename = "msd.download_progress")]
MsdDownloadProgress {
/// Download operation ID
download_id: String,
/// Source URL
url: String,
/// Target filename
filename: String,
/// Bytes downloaded so far
bytes_downloaded: u64,
/// Total file size (None if unknown)
total_bytes: Option<u64>,
/// Progress percentage (0.0 - 100.0, None if total unknown)
progress_pct: Option<f32>,
/// Download status: "started", "in_progress", "completed", "failed"
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 {
/// Video device information
video: VideoDeviceInfo,
/// HID device information
hid: HidDeviceInfo,
/// MSD device information (None if MSD not enabled)
msd: Option<MsdDeviceInfo>,
/// ATX device information (None if ATX not enabled)
atx: Option<AtxDeviceInfo>,
/// Audio device information (None if audio not enabled)
audio: Option<AudioDeviceInfo>,
ttyd: TtydDeviceInfo,
},
/// WebSocket error notification (for connection-level errors like lag)
#[serde(rename = "error")]
Error {
/// Error message
message: String,
},
Error { message: String },
}
/// One entry per [`SystemEvent::event_name`]. `EventBus` builds `*.`-wildcard channels from the first segment; names without `.` (e.g. `error`) have no wildcard channel.
pub(crate) const EXACT_EVENT_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",
];
impl SystemEvent {
/// Get the event name (for filtering/routing)
pub fn event_name(&self) -> &'static str {
match self {
Self::StreamModeSwitching { .. } => "stream.mode_switching",
@@ -557,55 +244,12 @@ 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",
}
}
/// Check if event name matches a topic pattern
///
/// Supports wildcards:
/// - `*` matches all events
/// - `stream.*` matches all stream events
/// - `stream.state_changed` matches exact event
pub fn matches_topic(&self, topic: &str) -> bool {
if topic == "*" {
return true;
}
let event_name = self.event_name();
if topic.ends_with(".*") {
let prefix = topic.trim_end_matches(".*");
event_name.starts_with(prefix)
} else {
event_name == topic
}
}
}
#[cfg(test)]
@@ -617,30 +261,145 @@ mod tests {
let event = SystemEvent::StreamStateChanged {
state: "streaming".to_string(),
device: Some("/dev/video0".to_string()),
reason: None,
next_retry_ms: None,
};
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]
fn test_matches_topic() {
let event = SystemEvent::StreamStateChanged {
state: "streaming".to_string(),
device: None,
fn stream_device_lost_json_snake_case_kind() {
let event = SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Audio,
device: "hw:0,0".to_string(),
reason: "test".to_string(),
};
let v = serde_json::to_value(&event).unwrap();
let data = v.get("data").unwrap();
assert_eq!(data.get("kind").and_then(|x| x.as_str()), Some("audio"));
assert_eq!(data.get("device").and_then(|x| x.as_str()), Some("hw:0,0"));
}
assert!(event.matches_topic("*"));
assert!(event.matches_topic("stream.*"));
assert!(event.matches_topic("stream.state_changed"));
assert!(!event.matches_topic("msd.*"));
assert!(!event.matches_topic("stream.config_changed"));
#[test]
fn exact_topics_covers_all_variants() {
use std::collections::HashSet;
let samples = vec![
SystemEvent::StreamModeSwitching {
transition_id: String::new(),
to_mode: String::new(),
from_mode: String::new(),
},
SystemEvent::StreamStateChanged {
state: String::new(),
device: None,
reason: None,
next_retry_ms: None,
},
SystemEvent::StreamConfigChanging {
transition_id: None,
reason: String::new(),
},
SystemEvent::StreamConfigApplied {
transition_id: None,
device: String::new(),
resolution: (0, 0),
format: String::new(),
fps: 0,
},
SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Video,
device: String::new(),
reason: String::new(),
},
SystemEvent::StreamReconnecting {
device: String::new(),
attempt: 0,
},
SystemEvent::StreamRecovered {
device: String::new(),
},
SystemEvent::WebRTCReady {
transition_id: None,
codec: String::new(),
hardware: false,
},
SystemEvent::StreamStatsUpdate {
clients: 0,
clients_stat: HashMap::new(),
},
SystemEvent::StreamModeChanged {
transition_id: None,
mode: String::new(),
previous_mode: String::new(),
},
SystemEvent::StreamModeReady {
transition_id: String::new(),
mode: String::new(),
},
SystemEvent::WebRTCIceCandidate {
session_id: String::new(),
candidate: serde_json::Value::Null,
},
SystemEvent::WebRTCIceComplete {
session_id: String::new(),
},
SystemEvent::MsdUploadProgress {
upload_id: String::new(),
filename: String::new(),
bytes_uploaded: 0,
total_bytes: 0,
progress_pct: 0.0,
},
SystemEvent::MsdDownloadProgress {
download_id: String::new(),
url: String::new(),
filename: String::new(),
bytes_downloaded: 0,
total_bytes: None,
progress_pct: None,
status: String::new(),
},
SystemEvent::DeviceInfo {
video: VideoDeviceInfo {
available: false,
device: None,
format: None,
resolution: None,
fps: 0,
online: false,
stream_mode: String::new(),
config_changing: false,
error: None,
},
hid: HidDeviceInfo {
available: false,
backend: String::new(),
initialized: false,
online: false,
supports_absolute_mouse: false,
keyboard_leds_enabled: false,
led_state: LedState::default(),
device: None,
error: None,
error_code: None,
},
msd: None,
atx: None,
audio: None,
ttyd: TtydDeviceInfo {
available: false,
running: false,
},
},
SystemEvent::Error {
message: String::new(),
},
];
let from_enum: HashSet<_> = samples.iter().map(|e| e.event_name()).collect();
let from_const: HashSet<_> = super::EXACT_EVENT_TOPICS.iter().copied().collect();
assert_eq!(from_enum, from_const);
}
#[test]

View File

@@ -1,5 +1,3 @@
//! Extension process manager
use std::collections::{HashMap, VecDeque};
use std::path::Path;
use std::process::Stdio;
@@ -10,27 +8,22 @@ 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;
/// Number of log lines to buffer before flushing to shared storage
const LOG_BATCH_SIZE: usize = 16;
/// Unix socket path for ttyd
pub const TTYD_SOCKET_PATH: &str = "/var/run/one-kvm/ttyd.sock";
/// Extension process with log buffer
struct ExtensionProcess {
child: Child,
logs: Arc<RwLock<VecDeque<String>>>,
}
/// Extension manager handles lifecycle of external processes
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 {
@@ -40,9 +33,7 @@ impl Default for ExtensionManager {
}
impl ExtensionManager {
/// Create a new extension manager with cached availability
pub fn new() -> Self {
// Check availability once at startup
let availability = ExtensionId::all()
.iter()
.map(|id| (*id, Path::new(id.binary_path()).exists()))
@@ -51,54 +42,81 @@ impl ExtensionManager {
Self {
processes: RwLock::new(HashMap::new()),
availability,
event_bus: RwLock::new(None),
}
}
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();
}
}
/// Check if the binary for an extension is available (cached)
pub fn check_available(&self, id: ExtensionId) -> bool {
*self.availability.get(&id).unwrap_or(&false)
}
/// Get the current status of an extension
pub async fn status(&self, id: ExtensionId) -> ExtensionStatus {
if !self.check_available(id) {
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
pub async fn start(&self, id: ExtensionId, config: &ExtensionsConfig) -> Result<(), String> {
if !self.check_available(id) {
return Err(format!(
"{} not found at {}",
id.display_name(),
id.binary_path()
));
return Err(format!("{} not found at {}", id, id.binary_path()));
}
// Stop existing process first
self.stop(id).await.ok();
// Build command arguments
let args = self.build_args(id, config).await?;
tracing::info!(
"Starting extension {}: {} {}",
id,
id.binary_path(),
args.join(" ")
Self::redact_args_for_log(&args).join(" ")
);
let mut child = Command::new(id.binary_path())
@@ -107,11 +125,10 @@ impl ExtensionManager {
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| format!("Failed to start {}: {}", id.display_name(), e))?;
.map_err(|e| format!("Failed to start {}: {}", id, e))?;
let logs = Arc::new(RwLock::new(VecDeque::with_capacity(LOG_BUFFER_SIZE)));
// Spawn log collector for stdout
if let Some(stdout) = child.stdout.take() {
let logs_clone = logs.clone();
let id_clone = id;
@@ -120,7 +137,6 @@ impl ExtensionManager {
});
}
// Spawn log collector for stderr
if let Some(stderr) = child.stderr.take() {
let logs_clone = logs.clone();
let id_clone = id;
@@ -134,11 +150,12 @@ 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(())
}
/// Stop an extension
pub async fn stop(&self, id: ExtensionId) -> Result<(), String> {
let mut processes = self.processes.write().await;
if let Some(mut proc) = processes.remove(&id) {
@@ -146,11 +163,12 @@ 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(())
}
/// Get recent logs for an extension
pub async fn logs(&self, id: ExtensionId, lines: usize) -> Vec<String> {
let processes = self.processes.read().await;
if let Some(proc) = processes.get(&id) {
@@ -162,7 +180,6 @@ impl ExtensionManager {
}
}
/// Collect logs from a stream with batched writes to reduce lock contention
async fn collect_logs<R: tokio::io::AsyncRead + Unpin>(
id: ExtensionId,
reader: R,
@@ -175,16 +192,14 @@ impl ExtensionManager {
loop {
match lines.next_line().await {
Ok(Some(line)) => {
tracing::debug!("[{}] {}", id, line);
tracing::info!("[{}] {}", id, line);
local_buffer.push(line);
// Flush when batch is full
if local_buffer.len() >= LOG_BATCH_SIZE {
Self::flush_logs(&logs, &mut local_buffer).await;
}
}
Ok(None) => {
// Stream ended, flush remaining logs
if !local_buffer.is_empty() {
Self::flush_logs(&logs, &mut local_buffer).await;
}
@@ -198,7 +213,6 @@ impl ExtensionManager {
}
}
/// Flush buffered logs to shared storage
async fn flush_logs(logs: &RwLock<VecDeque<String>>, buffer: &mut Vec<String>) {
let mut logs = logs.write().await;
for line in buffer.drain(..) {
@@ -209,7 +223,6 @@ impl ExtensionManager {
}
}
/// Build command arguments for an extension
async fn build_args(
&self,
id: ExtensionId,
@@ -219,48 +232,37 @@ impl ExtensionManager {
ExtensionId::Ttyd => {
let c = &config.ttyd;
// Prepare socket directory and clean up old socket (async)
Self::prepare_ttyd_socket().await?;
let mut args = vec![
"-i".to_string(),
TTYD_SOCKET_PATH.to_string(), // Unix socket
TTYD_SOCKET_PATH.to_string(),
"-b".to_string(),
"/api/terminal".to_string(), // Base path for reverse proxy
"-W".to_string(), // Writable (allow input)
"/api/terminal".to_string(),
"-W".to_string(),
];
// Add credential if set (still useful for additional security layer)
if let Some(ref cred) = c.credential {
if !cred.is_empty() {
args.extend(["-c".to_string(), cred.clone()]);
}
}
// Add shell as last argument
args.push(c.shell.clone());
Ok(args)
}
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());
}
let mut args = Vec::new();
// Add TLS flag
if c.tls {
args.push("--tls=true".to_string());
}
// Add server address
if !c.addr.is_empty() {
args.extend(["-addr".to_string(), c.addr.clone()]);
}
args.extend(["-addr".to_string(), c.addr.trim().to_string()]);
// Add client key
args.extend(["-key".to_string(), c.key.clone()]);
Ok(args)
@@ -279,24 +281,19 @@ impl ExtensionManager {
c.network_secret.clone(),
];
// Add peer URLs
for peer in &c.peer_urls {
if !peer.is_empty() {
args.extend(["--peers".to_string(), peer.clone()]);
}
}
// Add virtual IP: use -d for DHCP if empty, or -i for specific IP
if let Some(ref ip) = c.virtual_ip {
if !ip.is_empty() {
// Use specific IP with -i (must include CIDR, e.g., 10.0.0.1/24)
args.extend(["-i".to_string(), ip.clone()]);
} else {
// Empty string means use DHCP
args.push("-d".to_string());
}
} else {
// None means use DHCP
args.push("-d".to_string());
}
@@ -305,11 +302,37 @@ impl ExtensionManager {
}
}
/// Prepare ttyd socket directory and clean up old socket file
fn redact_args_for_log(args: &[String]) -> Vec<String> {
let mut redacted = Vec::with_capacity(args.len());
let mut redact_next = false;
for arg in args {
if redact_next {
redacted.push("****".to_string());
redact_next = false;
continue;
}
if arg == "-key" || arg == "--key" {
redacted.push(arg.clone());
redact_next = true;
} else if let Some((flag, _)) = arg.split_once('=') {
if flag == "-key" || flag == "--key" {
redacted.push(format!("{}=****", flag));
} else {
redacted.push(arg.clone());
}
} else {
redacted.push(arg.clone());
}
}
redacted
}
async fn prepare_ttyd_socket() -> Result<(), String> {
let socket_path = Path::new(TTYD_SOCKET_PATH);
// Ensure socket directory exists
if let Some(socket_dir) = socket_path.parent() {
if !socket_dir.exists() {
tokio::fs::create_dir_all(socket_dir)
@@ -318,7 +341,6 @@ impl ExtensionManager {
}
}
// Remove old socket file if exists
if tokio::fs::try_exists(TTYD_SOCKET_PATH)
.await
.unwrap_or(false)
@@ -331,15 +353,17 @@ impl ExtensionManager {
Ok(())
}
/// Health check - restart crashed processes that should be running
pub async fn health_check(&self, config: &ExtensionsConfig) {
// Collect extensions that need restart check
let checks: Vec<_> = ExtensionId::all()
.iter()
.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()
}
@@ -352,7 +376,6 @@ impl ExtensionManager {
})
.collect();
// Check which ones need restart (single read lock)
let needs_restart: Vec<_> = {
let processes = self.processes.read().await;
checks
@@ -367,7 +390,6 @@ impl ExtensionManager {
.collect()
};
// Restart all crashed extensions in parallel
let restart_futures: Vec<_> = needs_restart
.into_iter()
.map(|id| async move {
@@ -381,14 +403,12 @@ impl ExtensionManager {
futures::future::join_all(restart_futures).await;
}
/// Start all enabled extensions in parallel
pub async fn start_enabled(&self, config: &ExtensionsConfig) {
use futures::Future;
use std::pin::Pin;
let mut start_futures: Vec<Pin<Box<dyn Future<Output = ()> + Send + '_>>> = Vec::new();
// Collect enabled extensions
if config.ttyd.enabled && self.check_available(ExtensionId::Ttyd) {
start_futures.push(Box::pin(async {
if let Err(e) = self.start(ExtensionId::Ttyd, config).await {
@@ -399,6 +419,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 {
@@ -419,11 +440,9 @@ impl ExtensionManager {
}));
}
// Start all in parallel
futures::future::join_all(start_futures).await;
}
/// Stop all running extensions in parallel
pub async fn stop_all(&self) {
let stop_futures: Vec<_> = ExtensionId::all().iter().map(|id| self.stop(*id)).collect();
futures::future::join_all(stop_futures).await;

View File

@@ -1,5 +1,3 @@
//! Extensions module - manage external processes like ttyd, gostc, easytier
mod manager;
mod types;

View File

@@ -1,23 +1,16 @@
//! Extension types and configurations
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
/// Extension identifier (fixed set of supported extensions)
#[typeshare]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ExtensionId {
/// Web terminal (ttyd)
Ttyd,
/// NAT traversal client (gostc)
Gostc,
/// P2P VPN (easytier)
Easytier,
}
impl ExtensionId {
/// Get the binary path for this extension
pub fn binary_path(&self) -> &'static str {
match self {
Self::Ttyd => "/usr/bin/ttyd",
@@ -26,16 +19,6 @@ impl ExtensionId {
}
}
/// Get the display name for this extension
pub fn display_name(&self) -> &'static str {
match self {
Self::Ttyd => "Web Terminal",
Self::Gostc => "GOSTC Tunnel",
Self::Easytier => "EasyTier VPN",
}
}
/// Get all extension IDs
pub fn all() -> &'static [ExtensionId] {
&[Self::Ttyd, Self::Gostc, Self::Easytier]
}
@@ -64,25 +47,14 @@ impl std::str::FromStr for ExtensionId {
}
}
/// Extension running status
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "state", content = "data", rename_all = "lowercase")]
pub enum ExtensionStatus {
/// Binary not found at expected path
Unavailable,
/// Extension is stopped
Stopped,
/// Extension is running
Running {
/// Process ID
pid: u32,
},
/// Extension failed to start
Failed {
/// Error message
error: String,
},
Running { pid: u32 },
Failed { error: String },
}
impl ExtensionStatus {
@@ -91,46 +63,31 @@ impl ExtensionStatus {
}
}
/// ttyd configuration (Web Terminal)
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TtydConfig {
/// Enable auto-start
pub enabled: bool,
/// Port to listen on
pub port: u16,
/// Shell to execute
pub shell: String,
/// Credential in format "user:password" (optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub credential: Option<String>,
}
impl Default for TtydConfig {
fn default() -> Self {
Self {
enabled: false,
port: 7681,
shell: "/bin/bash".to_string(),
credential: None,
}
}
}
/// gostc configuration (NAT traversal based on FRP)
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GostcConfig {
/// Enable auto-start
pub enabled: bool,
/// Server address (e.g., gostc.mofeng.run)
pub addr: String,
/// Client key from GOSTC management panel
#[serde(skip_serializing_if = "String::is_empty")]
pub key: String,
/// Enable TLS
pub tls: bool,
}
@@ -138,46 +95,28 @@ impl Default for GostcConfig {
fn default() -> Self {
Self {
enabled: false,
addr: "gostc.mofeng.run".to_string(),
addr: String::new(),
key: String::new(),
tls: true,
}
}
}
/// EasyTier configuration (P2P VPN)
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct EasytierConfig {
/// Enable auto-start
pub enabled: bool,
/// Network name
pub network_name: String,
/// Network secret/password
#[serde(skip_serializing_if = "String::is_empty")]
pub network_secret: String,
/// Peer node URLs
#[serde(skip_serializing_if = "Vec::is_empty")]
pub peer_urls: Vec<String>,
/// Virtual IP address (optional, auto-assigned if not set)
#[serde(skip_serializing_if = "Option::is_none")]
pub virtual_ip: Option<String>,
}
impl Default for EasytierConfig {
fn default() -> Self {
Self {
enabled: false,
network_name: String::new(),
network_secret: String::new(),
peer_urls: Vec::new(),
virtual_ip: None,
}
}
}
/// Combined extensions configuration
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
@@ -187,53 +126,37 @@ pub struct ExtensionsConfig {
pub easytier: EasytierConfig,
}
/// Extension info with status and config
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionInfo {
/// Whether binary exists
pub available: bool,
/// Current status
pub status: ExtensionStatus,
}
/// ttyd extension info
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TtydInfo {
/// Whether binary exists
pub available: bool,
/// Current status
pub status: ExtensionStatus,
/// Configuration
pub config: TtydConfig,
}
/// gostc extension info
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GostcInfo {
/// Whether binary exists
pub available: bool,
/// Current status
pub status: ExtensionStatus,
/// Configuration
pub config: GostcConfig,
}
/// easytier extension info
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EasytierInfo {
/// Whether binary exists
pub available: bool,
/// Current status
pub status: ExtensionStatus,
/// Configuration
pub config: EasytierConfig,
}
/// All extensions status response
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionsStatus {
@@ -242,7 +165,6 @@ pub struct ExtensionsStatus {
pub easytier: EasytierInfo,
}
/// Extension logs response
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionLogs {

View File

@@ -1,75 +1,32 @@
//! HID backend trait definition
//! `HidBackend` trait plus serde `HidBackendType` (OTG | CH9329 | disabled).
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use super::types::{ConsumerEvent, KeyboardEvent, MouseEvent};
use crate::error::Result;
use crate::events::LedState;
/// Default CH9329 baud rate
fn default_ch9329_baud_rate() -> u32 {
9600
}
/// HID backend type
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
#[derive(Default)]
pub enum HidBackendType {
/// USB OTG gadget mode
Otg,
/// CH9329 serial HID controller
Ch9329 {
/// Serial port path
port: String,
/// Baud rate (default: 9600)
#[serde(default = "default_ch9329_baud_rate")]
baud_rate: u32,
},
/// No HID backend (disabled)
#[default]
None,
}
impl Default for HidBackendType {
fn default() -> Self {
Self::None
}
}
impl HidBackendType {
/// Check if OTG backend is available on this system
pub fn otg_available() -> bool {
// Check for USB gadget support
std::path::Path::new("/sys/class/udc").exists()
}
/// Detect the best available backend
pub fn detect() -> Self {
// Check for OTG gadget support
if Self::otg_available() {
return Self::Otg;
}
// Check for common CH9329 serial ports
let common_ports = [
"/dev/ttyUSB0",
"/dev/ttyUSB1",
"/dev/ttyAMA0",
"/dev/serial0",
];
for port in &common_ports {
if std::path::Path::new(port).exists() {
return Self::Ch9329 {
port: port.to_string(),
baud_rate: 9600, // Use default baud rate for auto-detection
};
}
}
Self::None
}
/// Get backend name as string
pub fn name_str(&self) -> &str {
match self {
Self::Otg => "otg",
@@ -79,60 +36,40 @@ impl HidBackendType {
}
}
/// HID backend trait
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HidBackendRuntimeSnapshot {
pub initialized: bool,
pub online: bool,
pub supports_absolute_mouse: bool,
pub keyboard_leds_enabled: bool,
pub led_state: LedState,
pub screen_resolution: Option<(u32, u32)>,
pub device: Option<String>,
pub error: Option<String>,
pub error_code: Option<String>,
}
#[async_trait]
pub trait HidBackend: Send + Sync {
/// Get backend name
fn name(&self) -> &'static str;
/// Initialize the backend
async fn init(&self) -> Result<()>;
/// Send a keyboard event
async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()>;
/// Send a mouse event
async fn send_mouse(&self, event: MouseEvent) -> Result<()>;
/// Send a consumer control event (multimedia keys)
/// Default implementation returns an error (not supported)
async fn send_consumer(&self, _event: ConsumerEvent) -> Result<()> {
Err(crate::error::AppError::BadRequest(
"Consumer control not supported by this backend".to_string(),
))
}
/// Reset all inputs (release all keys/buttons)
async fn reset(&self) -> Result<()>;
/// Shutdown the backend
async fn shutdown(&self) -> Result<()>;
/// Check if backend supports absolute mouse positioning
fn supports_absolute_mouse(&self) -> bool {
false
}
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot;
/// Get screen resolution (for absolute mouse)
fn screen_resolution(&self) -> Option<(u32, u32)> {
None
}
fn subscribe_runtime(&self) -> watch::Receiver<()>;
/// Set screen resolution (for absolute mouse)
fn set_screen_resolution(&mut self, _width: u32, _height: u32) {}
}
/// HID backend information
#[derive(Debug, Clone, Serialize)]
pub struct HidBackendInfo {
/// Backend name
pub name: String,
/// Backend type
pub backend_type: String,
/// Is initialized
pub initialized: bool,
/// Supports absolute mouse
pub absolute_mouse: bool,
/// Screen resolution (if absolute mouse)
pub resolution: Option<(u32, u32)>,
fn set_screen_resolution(&self, _width: u32, _height: u32) {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,21 +2,17 @@
//!
//! Reference: USB HID Usage Tables 1.12, Section 15 (Consumer Page 0x0C)
/// Consumer Control Usage codes for multimedia keys
pub mod usage {
// Transport Controls
pub const PLAY_PAUSE: u16 = 0x00CD;
pub const STOP: u16 = 0x00B7;
pub const NEXT_TRACK: u16 = 0x00B5;
pub const PREV_TRACK: u16 = 0x00B6;
// Volume Controls
pub const MUTE: u16 = 0x00E2;
pub const VOLUME_UP: u16 = 0x00E9;
pub const VOLUME_DOWN: u16 = 0x00EA;
}
/// Check if a usage code is valid
pub fn is_valid_usage(usage: u16) -> bool {
matches!(
usage,

View File

@@ -9,7 +9,7 @@
//!
//! Keyboard event (type 0x01):
//! - Byte 1: Event type (0x00 = down, 0x01 = up)
//! - Byte 2: Key code (USB HID usage code or JS keyCode)
//! - 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,26 +38,23 @@ use tracing::warn;
use super::types::ConsumerEvent;
use super::{
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent, MouseEventType,
CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent,
MouseEventType,
};
/// Message types
pub const MSG_KEYBOARD: u8 = 0x01;
pub const MSG_MOUSE: u8 = 0x02;
pub const MSG_CONSUMER: u8 = 0x03;
/// Keyboard event types
pub const KB_EVENT_DOWN: u8 = 0x00;
pub const KB_EVENT_UP: u8 = 0x01;
/// Mouse event types
pub const MS_EVENT_MOVE: u8 = 0x00;
pub const MS_EVENT_MOVE_ABS: u8 = 0x01;
pub const MS_EVENT_DOWN: u8 = 0x02;
pub const MS_EVENT_UP: u8 = 0x03;
pub const MS_EVENT_SCROLL: u8 = 0x04;
/// Parsed HID event from DataChannel
#[derive(Debug, Clone)]
pub enum HidChannelEvent {
Keyboard(KeyboardEvent),
@@ -65,7 +62,6 @@ pub enum HidChannelEvent {
Consumer(ConsumerEvent),
}
/// Parse a binary HID message from DataChannel
pub fn parse_hid_message(data: &[u8]) -> Option<HidChannelEvent> {
if data.is_empty() {
warn!("Empty HID message");
@@ -85,7 +81,6 @@ pub fn parse_hid_message(data: &[u8]) -> Option<HidChannelEvent> {
}
}
/// Parse keyboard message payload
fn parse_keyboard_message(data: &[u8]) -> Option<HidChannelEvent> {
if data.len() < 3 {
warn!("Keyboard message too short: {} bytes", data.len());
@@ -101,7 +96,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,11 +120,9 @@ fn parse_keyboard_message(data: &[u8]) -> Option<HidChannelEvent> {
event_type,
key,
modifiers,
is_usb_hid: false, // WebRTC datachannel sends JS keycodes
}))
}
/// Parse mouse message payload
fn parse_mouse_message(data: &[u8]) -> Option<HidChannelEvent> {
if data.len() < 6 {
warn!("Mouse message too short: {} bytes", data.len());
@@ -142,11 +141,9 @@ fn parse_mouse_message(data: &[u8]) -> Option<HidChannelEvent> {
}
};
// Parse coordinates as i16 LE (works for both relative and absolute)
let x = i16::from_le_bytes([data[1], data[2]]) as i32;
let y = i16::from_le_bytes([data[3], data[4]]) as i32;
// Button or scroll delta
let (button, scroll) = match event_type {
MouseEventType::Down | MouseEventType::Up => {
let btn = match data[5] {
@@ -172,7 +169,6 @@ fn parse_mouse_message(data: &[u8]) -> Option<HidChannelEvent> {
}))
}
/// Parse consumer control message payload
fn parse_consumer_message(data: &[u8]) -> Option<HidChannelEvent> {
if data.len() < 2 {
warn!("Consumer message too short: {} bytes", data.len());
@@ -184,7 +180,6 @@ fn parse_consumer_message(data: &[u8]) -> Option<HidChannelEvent> {
Some(HidChannelEvent::Consumer(ConsumerEvent { usage }))
}
/// Encode a keyboard event to binary format (for sending to client if needed)
pub fn encode_keyboard_event(event: &KeyboardEvent) -> Vec<u8> {
let event_type = match event.event_type {
KeyEventType::Down => KB_EVENT_DOWN,
@@ -193,40 +188,11 @@ pub fn encode_keyboard_event(event: &KeyboardEvent) -> Vec<u8> {
let modifiers = event.modifiers.to_hid_byte();
vec![MSG_KEYBOARD, event_type, event.key, modifiers]
}
/// Encode a mouse event to binary format (for sending to client if needed)
pub fn encode_mouse_event(event: &MouseEvent) -> Vec<u8> {
let event_type = match event.event_type {
MouseEventType::Move => MS_EVENT_MOVE,
MouseEventType::MoveAbs => MS_EVENT_MOVE_ABS,
MouseEventType::Down => MS_EVENT_DOWN,
MouseEventType::Up => MS_EVENT_UP,
MouseEventType::Scroll => MS_EVENT_SCROLL,
};
let x_bytes = (event.x as i16).to_le_bytes();
let y_bytes = (event.y as i16).to_le_bytes();
let extra = match event.event_type {
MouseEventType::Down | MouseEventType::Up => event
.button
.as_ref()
.map(|b| match b {
MouseButton::Left => 0u8,
MouseButton::Middle => 1u8,
MouseButton::Right => 2u8,
MouseButton::Back => 3u8,
MouseButton::Forward => 4u8,
})
.unwrap_or(0),
MouseEventType::Scroll => event.scroll as u8,
_ => 0,
};
vec![
MSG_MOUSE, event_type, x_bytes[0], x_bytes[1], y_bytes[0], y_bytes[1], extra,
MSG_KEYBOARD,
event_type,
event.key.to_hid_usage(),
modifiers,
]
}
@@ -242,7 +208,7 @@ 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);
}
@@ -269,7 +235,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,
@@ -280,7 +246,6 @@ mod tests {
right_alt: false,
right_meta: false,
},
is_usb_hid: false,
};
let encoded = encode_keyboard_event(&event);

399
src/hid/keyboard.rs Normal file
View File

@@ -0,0 +1,399 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
#[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 {
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,
}
}
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,
}
}
}

View File

@@ -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));
}
}

View File

@@ -1,139 +1,169 @@
//! HID (Human Interface Device) control module
//!
//! This module provides keyboard and mouse control for remote KVM:
//! - USB OTG gadget mode (native Linux USB gadget)
//! - CH9329 serial HID controller
//!
//! Architecture:
//! ```text
//! Web Client --> WebSocket/DataChannel --> HID Events --> Backend --> Target PC
//! |
//! [OTG | CH9329]
//! ```
//! HID path: browser (WebSocket or WebRTC DataChannel) → queue → OTG gadget or CH9329.
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 otg::LedState;
pub use crate::events::LedState;
pub use backend::{HidBackend, HidBackendRuntimeSnapshot, HidBackendType};
pub use keyboard::CanonicalKey;
pub use types::{
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent,
MouseEventType,
};
/// HID backend information
#[derive(Debug, Clone)]
pub struct HidInfo {
/// Backend name
pub name: &'static str,
/// Whether backend is initialized
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HidRuntimeState {
pub available: bool,
pub backend: String,
pub initialized: bool,
/// Whether absolute mouse positioning is supported
pub online: bool,
pub supports_absolute_mouse: bool,
/// Screen resolution for absolute mouse
pub keyboard_leds_enabled: bool,
pub led_state: LedState,
pub screen_resolution: Option<(u32, u32)>,
pub device: Option<String>,
pub error: Option<String>,
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::Arc;
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 tokio::sync::mpsc;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use std::time::Duration;
const HID_EVENT_QUEUE_CAPACITY: usize = 64;
const HID_EVENT_SEND_TIMEOUT_MS: u64 = 30;
#[derive(Debug)]
enum HidEvent {
enum QueuedHidEvent {
Keyboard(KeyboardEvent),
Mouse(MouseEvent),
Consumer(ConsumerEvent),
Reset,
}
/// HID controller managing keyboard and mouse input
pub struct HidController {
/// OTG Service reference (only used when backend is OTG)
otg_service: Option<Arc<OtgService>>,
/// Active backend
backend: Arc<RwLock<Option<Arc<dyn HidBackend>>>>,
/// 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>,
/// HID event queue sender (non-blocking)
hid_tx: mpsc::Sender<HidEvent>,
/// HID event queue receiver (moved into worker on first start)
hid_rx: Mutex<Option<mpsc::Receiver<HidEvent>>>,
/// Coalesced mouse move (latest)
events: Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
runtime_state: Arc<RwLock<HidRuntimeState>>,
hid_tx: mpsc::Sender<QueuedHidEvent>,
hid_rx: Mutex<Option<mpsc::Receiver<QueuedHidEvent>>>,
pending_move: Arc<parking_lot::Mutex<Option<MouseEvent>>>,
/// Pending move flag (fast path)
pending_move_flag: Arc<AtomicBool>,
/// Worker task handle
hid_worker: Mutex<Option<JoinHandle<()>>>,
/// Backend availability fast flag
backend_available: AtomicBool,
runtime_worker: Mutex<Option<JoinHandle<()>>>,
backend_available: Arc<AtomicBool>,
}
impl HidController {
/// Create a new HID controller with specified backend
///
/// For OTG backend, otg_service should be provided to support hot-reload
pub fn new(backend_type: HidBackendType, otg_service: Option<Arc<OtgService>>) -> Self {
let (hid_tx, hid_rx) = mpsc::channel(HID_EVENT_QUEUE_CAPACITY);
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),
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
pub async fn init(&self) -> Result<()> {
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)?)
}
@@ -153,49 +183,65 @@ impl HidController {
}
};
backend.init().await?;
*self.backend.write().await = Some(backend);
self.backend_available.store(true, Ordering::Release);
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,
&current,
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.sync_runtime_state_from_backend().await;
// Start HID event worker (once)
self.start_event_worker().await;
self.restart_runtime_worker().await;
info!("HID backend initialized: {:?}", backend_type);
Ok(())
}
/// Shutdown the HID backend and release resources
pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down HID controller");
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(())
}
/// Send keyboard event
pub async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
if !self.backend_available.load(Ordering::Acquire) {
return Err(AppError::BadRequest(
"HID backend not available".to_string(),
));
}
self.enqueue_event(HidEvent::Keyboard(event)).await
self.enqueue_event(QueuedHidEvent::Keyboard(event)).await
}
/// Send mouse event
pub async fn send_mouse(&self, event: MouseEvent) -> Result<()> {
if !self.backend_available.load(Ordering::Acquire) {
return Err(AppError::BadRequest(
@@ -203,112 +249,59 @@ impl HidController {
));
}
if matches!(event.event_type, MouseEventType::Move | MouseEventType::MoveAbs) {
// Best-effort: drop/merge move events if queue is full
if matches!(
event.event_type,
MouseEventType::Move | MouseEventType::MoveAbs
) {
self.enqueue_mouse_move(event)
} else {
self.enqueue_event(HidEvent::Mouse(event)).await
self.enqueue_event(QueuedHidEvent::Mouse(event)).await
}
}
/// Send consumer control event (multimedia keys)
pub async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
if !self.backend_available.load(Ordering::Acquire) {
return Err(AppError::BadRequest(
"HID backend not available".to_string(),
));
}
self.enqueue_event(HidEvent::Consumer(event)).await
self.enqueue_event(QueuedHidEvent::Consumer(event)).await
}
/// Reset all keys (release all pressed keys)
pub async fn reset(&self) -> Result<()> {
if !self.backend_available.load(Ordering::Acquire) {
return Ok(());
}
// Reset is important but best-effort; enqueue to avoid blocking
self.enqueue_event(HidEvent::Reset).await
self.enqueue_event(QueuedHidEvent::Reset).await
}
/// 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
pub async fn backend_type(&self) -> HidBackendType {
self.backend_type.read().await.clone()
}
/// 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(),
})
pub async fn snapshot(&self) -> HidRuntimeState {
self.runtime_state.read().await.clone()
}
/// 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
}
/// 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_runtime_worker().await;
// Shutdown existing backend first
if let Some(backend) = self.backend.write().await.take() {
if let Err(e) = backend.shutdown().await {
warn!("Error shutting down old HID backend: {}", e);
}
}
// Create and initialize new backend
let new_backend: Option<Arc<dyn HidBackend>> = match new_backend_type {
HidBackendType::Otg => {
info!("Initializing OTG HID backend");
// Get OtgService reference
let otg_service = match self.otg_service.as_ref() {
Some(svc) => svc,
None => {
@@ -319,43 +312,28 @@ impl HidController {
}
};
// Request HID functions from OtgService
match otg_service.enable_hid().await {
Ok(handles) => {
// Create OtgBackend from handles
match otg::OtgBackend::from_handles(handles) {
Ok(backend) => {
let backend = Arc::new(backend);
match backend.init().await {
Ok(_) => {
info!("OTG backend initialized successfully");
Some(backend)
}
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
}
match otg_service.hid_device_paths().await {
Some(handles) => match otg::OtgBackend::from_handles(handles) {
Ok(backend) => {
let backend = Arc::new(backend);
match backend.init().await {
Ok(_) => {
info!("OTG backend initialized successfully");
Some(backend)
}
}
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);
Err(e) => {
warn!("Failed to initialize OTG backend: {}", e);
None
}
None
}
}
}
Err(e) => {
warn!("Failed to enable HID in OtgService: {}", e);
Err(e) => {
warn!("Failed to create OTG backend: {}", e);
None
}
},
None => {
warn!("OTG HID paths are not available");
None
}
}
@@ -393,43 +371,37 @@ 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;
// 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 {
warn!("HID backend reload resulted in no active backend");
self.backend_available.store(false, Ordering::Release);
// 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,
&current,
"Failed to initialize HID backend",
"init_failed",
);
self.apply_runtime_state(error_state).await;
Err(AppError::Internal(
"Failed to reload HID backend".to_string(),
@@ -437,11 +409,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) {
@@ -457,8 +438,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();
@@ -470,25 +449,12 @@ 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(QueuedHidEvent::Mouse(move_event), &backend).await;
}
}
}
@@ -497,29 +463,70 @@ impl HidController {
*worker_guard = Some(handle);
}
async fn restart_runtime_worker(&self) {
self.stop_runtime_worker().await;
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 handle = tokio::spawn(async move {
loop {
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;
}
});
*self.runtime_worker.lock().await = Some(handle);
}
async fn stop_runtime_worker(&self) {
if let Some(handle) = self.runtime_worker.lock().await.take() {
handle.abort();
}
}
fn enqueue_mouse_move(&self, event: MouseEvent) -> Result<()> {
match self.hid_tx.try_send(HidEvent::Mouse(event.clone())) {
match self.hid_tx.try_send(QueuedHidEvent::Mouse(event.clone())) {
Ok(_) => Ok(()),
Err(mpsc::error::TrySendError::Full(_)) => {
*self.pending_move.lock() = Some(event);
self.pending_move_flag.store(true, Ordering::Release);
Ok(())
}
Err(mpsc::error::TrySendError::Closed(_)) => Err(AppError::BadRequest(
"HID event queue closed".to_string(),
)),
Err(mpsc::error::TrySendError::Closed(_)) => {
Err(AppError::BadRequest("HID event queue closed".to_string()))
}
}
}
async fn enqueue_event(&self, event: HidEvent) -> Result<()> {
async fn enqueue_event(&self, event: QueuedHidEvent) -> Result<()> {
match self.hid_tx.try_send(event) {
Ok(_) => Ok(()),
Err(mpsc::error::TrySendError::Full(ev)) => {
// For non-move events, wait briefly to avoid dropping critical input
let tx = self.hid_tx.clone();
let send_result =
tokio::time::timeout(Duration::from_millis(HID_EVENT_SEND_TIMEOUT_MS), tx.send(ev))
.await;
let send_result = tokio::time::timeout(
Duration::from_millis(HID_EVENT_SEND_TIMEOUT_MS),
tx.send(ev),
)
.await;
if send_result.is_ok() {
Ok(())
} else {
@@ -527,18 +534,32 @@ impl HidController {
Ok(())
}
}
Err(mpsc::error::TrySendError::Closed(_)) => Err(AppError::BadRequest(
"HID event queue closed".to_string(),
)),
Err(mpsc::error::TrySendError::Closed(_)) => {
Err(AppError::BadRequest("HID event queue closed".to_string()))
}
}
}
}
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: QueuedHidEvent,
backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>,
) {
let backend_opt = backend.read().await.clone();
let backend = match backend_opt {
@@ -546,13 +567,14 @@ async fn process_hid_event(
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,
QueuedHidEvent::Keyboard(ev) => backend_for_send.send_keyboard(ev).await,
QueuedHidEvent::Mouse(ev) => backend_for_send.send_mouse(ev).await,
QueuedHidEvent::Consumer(ev) => backend_for_send.send_consumer(ev).await,
QueuedHidEvent::Reset => backend_for_send.reset().await,
}
})
})
@@ -564,31 +586,40 @@ 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);
}
}
}
impl Default for HidController {
fn default() -> Self {
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();
}
}

View File

@@ -1,421 +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)]
pub enum HidHealthStatus {
/// Device is healthy and operational
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,
}
impl Default for HidHealthStatus {
fn default() -> Self {
Self::Healthy
}
}
/// 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 % 5 == 0 {
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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,37 @@
//! HID event types for keyboard and mouse
//! Keyboard/mouse/consumer structs (`KeyboardEvent`, `MouseEvent`, …).
use serde::{Deserialize, Serialize};
/// Keyboard event type
use super::keyboard::CanonicalKey;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum KeyEventType {
/// Key pressed down
Down,
/// Key released
Up,
}
/// Keyboard modifier flags
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeyboardModifiers {
/// Left Control
#[serde(default)]
pub left_ctrl: bool,
/// Left Shift
#[serde(default)]
pub left_shift: bool,
/// Left Alt
#[serde(default)]
pub left_alt: bool,
/// Left Meta (Windows/Super key)
#[serde(default)]
pub left_meta: bool,
/// Right Control
#[serde(default)]
pub right_ctrl: bool,
/// Right Shift
#[serde(default)]
pub right_shift: bool,
/// Right Alt (AltGr)
#[serde(default)]
pub right_alt: bool,
/// Right Meta
#[serde(default)]
pub right_meta: bool,
}
impl KeyboardModifiers {
/// Convert to USB HID modifier byte
pub fn to_hid_byte(&self) -> u8 {
let mut byte = 0u8;
if self.left_ctrl {
@@ -72,7 +61,6 @@ impl KeyboardModifiers {
byte
}
/// Create from USB HID modifier byte
pub fn from_hid_byte(byte: u8) -> Self {
Self {
left_ctrl: byte & 0x01 != 0,
@@ -86,7 +74,6 @@ impl KeyboardModifiers {
}
}
/// Check if any modifier is active
pub fn any(&self) -> bool {
self.left_ctrl
|| self.left_shift
@@ -99,45 +86,33 @@ impl KeyboardModifiers {
}
}
/// Keyboard event
#[derive(Debug, Clone, Serialize, Deserialize)]
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,
/// Modifier keys state
pub key: CanonicalKey,
#[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 {
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 {
pub fn key_up(key: CanonicalKey, modifiers: KeyboardModifiers) -> Self {
Self {
event_type: KeyEventType::Up,
key,
modifiers,
is_usb_hid: false,
}
}
}
/// Mouse button
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MouseButton {
@@ -149,7 +124,6 @@ pub enum MouseButton {
}
impl MouseButton {
/// Convert to USB HID button bit
pub fn to_hid_bit(&self) -> u8 {
match self {
MouseButton::Left => 0x01,
@@ -161,44 +135,31 @@ impl MouseButton {
}
}
/// Mouse event type
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MouseEventType {
/// Mouse moved (relative movement)
Move,
/// Mouse moved (absolute position)
MoveAbs,
/// Button pressed
Down,
/// Button released
Up,
/// Mouse wheel scroll
Scroll,
}
/// Mouse event
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MouseEvent {
/// Event type
#[serde(rename = "type")]
pub event_type: MouseEventType,
/// X coordinate or delta
#[serde(default)]
pub x: i32,
/// Y coordinate or delta
#[serde(default)]
pub y: i32,
/// Button (for down/up events)
#[serde(default)]
pub button: Option<MouseButton>,
/// Scroll delta (for scroll events)
#[serde(default)]
pub scroll: i8,
}
impl MouseEvent {
/// Create a relative move event
pub fn move_rel(dx: i32, dy: i32) -> Self {
Self {
event_type: MouseEventType::Move,
@@ -209,7 +170,6 @@ impl MouseEvent {
}
}
/// Create an absolute move event
pub fn move_abs(x: i32, y: i32) -> Self {
Self {
event_type: MouseEventType::MoveAbs,
@@ -220,7 +180,6 @@ impl MouseEvent {
}
}
/// Create a button down event
pub fn button_down(button: MouseButton) -> Self {
Self {
event_type: MouseEventType::Down,
@@ -231,7 +190,6 @@ impl MouseEvent {
}
}
/// Create a button up event
pub fn button_up(button: MouseButton) -> Self {
Self {
event_type: MouseEventType::Up,
@@ -242,7 +200,6 @@ impl MouseEvent {
}
}
/// Create a scroll event
pub fn scroll(delta: i8) -> Self {
Self {
event_type: MouseEventType::Scroll,
@@ -254,35 +211,19 @@ impl MouseEvent {
}
}
/// Combined HID event (keyboard or mouse)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "device", rename_all = "lowercase")]
pub enum HidEvent {
Keyboard(KeyboardEvent),
Mouse(MouseEvent),
Consumer(ConsumerEvent),
}
/// Consumer control event (multimedia keys)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsumerEvent {
/// Consumer control usage code (e.g., 0x00CD for Play/Pause)
pub usage: u16,
}
/// USB HID keyboard report (8 bytes)
#[derive(Debug, Clone, Default)]
pub struct KeyboardReport {
/// Modifier byte
pub modifiers: u8,
/// Reserved byte
pub reserved: u8,
/// Key codes (up to 6 simultaneous keys)
pub keys: [u8; 6],
}
impl KeyboardReport {
/// Convert to bytes for USB HID
pub fn to_bytes(&self) -> [u8; 8] {
[
self.modifiers,
@@ -296,7 +237,6 @@ impl KeyboardReport {
]
}
/// Add a key to the report
pub fn add_key(&mut self, key: u8) -> bool {
for slot in &mut self.keys {
if *slot == 0 {
@@ -307,56 +247,21 @@ impl KeyboardReport {
false // All slots full
}
/// Remove a key from the report
pub fn remove_key(&mut self, key: u8) {
for slot in &mut self.keys {
if *slot == key {
*slot = 0;
}
}
// Compact the array
self.keys.sort_by(|a, b| b.cmp(a));
}
/// Clear all keys
pub fn clear(&mut self) {
self.modifiers = 0;
self.keys = [0; 6];
}
}
/// USB HID mouse report
#[derive(Debug, Clone, Default)]
pub struct MouseReport {
/// Button state
pub buttons: u8,
/// X movement (-127 to 127)
pub x: i8,
/// Y movement (-127 to 127)
pub y: i8,
/// Wheel movement (-127 to 127)
pub wheel: i8,
}
impl MouseReport {
/// Convert to bytes for USB HID (relative mouse)
pub fn to_bytes_relative(&self) -> [u8; 4] {
[self.buttons, self.x as u8, self.y as u8, self.wheel as u8]
}
/// Convert to bytes for USB HID (absolute mouse)
pub fn to_bytes_absolute(&self, x: u16, y: u16) -> [u8; 6] {
[
self.buttons,
(x & 0xFF) as u8,
(x >> 8) as u8,
(y & 0xFF) as u8,
(y >> 8) as u8,
self.wheel as u8,
]
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -1,13 +1,4 @@
//! WebSocket HID channel for HTTP/MJPEG mode
//!
//! This provides an alternative to WebRTC DataChannel for HID input
//! when using MJPEG streaming mode.
//!
//! Uses binary protocol only (same format as DataChannel):
//! - Keyboard: [0x01, event_type, key, modifiers] (4 bytes)
//! - Mouse: [0x02, event_type, x_lo, x_hi, y_lo, y_hi, button/scroll] (7 bytes)
//!
//! See datachannel.rs for detailed protocol specification.
//! MJPEG mode: HID over WebSocket — same binary framing as [`super::datachannel`] (`0x01`/`0x02`/`0x03`; layout detailed there).
use axum::{
extract::{
@@ -24,25 +15,20 @@ use super::datachannel::{parse_hid_message, HidChannelEvent};
use crate::state::AppState;
use crate::utils::LogThrottler;
/// Binary response codes
const RESP_OK: u8 = 0x00;
const RESP_ERR_HID_UNAVAILABLE: u8 = 0x01;
const RESP_ERR_INVALID_MESSAGE: u8 = 0x02;
/// WebSocket HID upgrade handler
pub async fn ws_hid_handler(ws: WebSocketUpgrade, State(state): State<Arc<AppState>>) -> Response {
ws.on_upgrade(move |socket| handle_hid_socket(socket, state))
}
/// Handle HID WebSocket connection
async fn handle_hid_socket(socket: WebSocket, state: Arc<AppState>) {
let (mut sender, mut receiver) = socket.split();
// Log throttler for error messages (5 second interval)
let log_throttler = LogThrottler::with_secs(5);
info!("WebSocket HID connection established (binary protocol)");
// Check if HID controller is available and send initial status
let hid_available = state.hid.is_available().await;
let initial_response = if hid_available {
vec![RESP_OK]
@@ -59,17 +45,14 @@ async fn handle_hid_socket(socket: WebSocket, state: Arc<AppState>) {
return;
}
// Process incoming messages (binary only)
while let Some(msg) = receiver.next().await {
match msg {
Ok(Message::Binary(data)) => {
// Check HID availability before processing each message
let hid_available = state.hid.is_available().await;
if !hid_available {
if log_throttler.should_log("hid_unavailable") {
warn!("HID controller not available, ignoring message");
}
// Send error response (optional, for client awareness)
let _ = sender
.send(Message::Binary(vec![RESP_ERR_HID_UNAVAILABLE].into()))
.await;
@@ -77,15 +60,12 @@ async fn handle_hid_socket(socket: WebSocket, state: Arc<AppState>) {
}
if let Err(e) = handle_binary_message(&data, &state).await {
// Log with throttling to avoid spam
if log_throttler.should_log("binary_hid_error") {
warn!("Binary HID message error: {}", e);
}
// Don't send error response for every failed message to reduce overhead
}
}
Ok(Message::Text(text)) => {
// Text messages are no longer supported
if log_throttler.should_log("text_message_rejected") {
debug!(
"Received text message (not supported): {} bytes",
@@ -111,7 +91,6 @@ async fn handle_hid_socket(socket: WebSocket, state: Arc<AppState>) {
}
}
// Reset HID state to release any held keys/buttons
if let Err(e) = state.hid.reset().await {
warn!("Failed to reset HID on WebSocket disconnect: {}", e);
}
@@ -119,7 +98,6 @@ async fn handle_hid_socket(socket: WebSocket, state: Arc<AppState>) {
info!("WebSocket HID connection ended");
}
/// Handle binary HID message (same format as DataChannel)
async fn handle_binary_message(data: &[u8], state: &AppState) -> Result<(), String> {
let event = parse_hid_message(data).ok_or("Invalid binary HID message")?;
@@ -160,12 +138,10 @@ mod tests {
assert_eq!(RESP_OK, 0x00);
assert_eq!(RESP_ERR_HID_UNAVAILABLE, 0x01);
assert_eq!(RESP_ERR_INVALID_MESSAGE, 0x02);
// assert_eq!(RESP_ERR_SEND_FAILED, 0x03); // TODO: fix test
}
#[test]
fn test_keyboard_message_format() {
// Keyboard message: [0x01, event_type, key, modifiers]
let data = [MSG_KEYBOARD, KB_EVENT_DOWN, 0x04, 0x01]; // 'A' key with left ctrl
let event = parse_hid_message(&data);
assert!(event.is_some());
@@ -173,7 +149,6 @@ mod tests {
#[test]
fn test_mouse_message_format() {
// Mouse message: [0x02, event_type, x_lo, x_hi, y_lo, y_hi, extra]
let data = [MSG_MOUSE, MS_EVENT_MOVE, 0x0A, 0x00, 0xF6, 0xFF, 0x00]; // x=10, y=-10
let event = parse_hid_message(&data);
assert!(event.is_some());

View File

@@ -1,28 +1,27 @@
//! One-KVM - Lightweight IP-KVM solution
//!
//! This crate provides the core functionality for One-KVM,
//! a remote KVM (Keyboard, Video, Mouse) solution written in Rust.
//! Core library for One-KVM (IPKVM: capture, HID, OTG, streaming, Web UI glue).
pub mod atx;
pub mod audio;
pub mod auth;
pub mod config;
pub mod db;
pub mod error;
pub mod events;
pub mod extensions;
pub mod hid;
pub mod modules;
pub mod msd;
pub mod otg;
pub mod rtsp;
pub mod rustdesk;
pub mod state;
pub mod stream;
pub mod stream_encoder;
pub mod update;
pub mod utils;
pub mod video;
pub mod web;
pub mod webrtc;
/// Auto-generated secrets module (from secrets.toml at compile time)
pub mod secrets {
include!(concat!(env!("OUT_DIR"), "/secrets_generated.rs"));
}

Some files were not shown because too many files have changed in this diff Show More