Compare commits

...

28 Commits

Author SHA1 Message Date
mofeng-git
198552014b chore: bump version to v0.2.2 2026-05-24 14:04:03 +00:00
mofeng-git
3e3937605e fix:修复在线升级错误 2026-05-24 14:03:49 +00:00
mofeng-git
4a85fbfab8 ci: 统一 Android APK 产物命名 2026-05-24 13:21:34 +00:00
mofeng-git
87d1110a87 ci: 调整 GitHub Actions 构建与发布流程 2026-05-24 09:58:30 +00:00
mofeng-git
b31aae284d feat: 新增安卓平台支持 2026-05-24 09:44:41 +00:00
mofeng-git
dc6475776e fix: 修复 rtsp 和 RustDesk 扩展启停错误;修改部分参数描述文本 2026-05-23 15:16:39 +00:00
mofeng-git
3de72677e6 fix: 优化 RustDesk 中继服务器推导策略;修复鼠键输入异常 2026-05-23 12:21:13 +00:00
mofeng-git
6a1616c32a chore: vendor trimmed v4l2r capture crate 2026-05-23 02:36:40 +00:00
mofeng-git
032f47a891 fix: 完善晶晨视频设备探测逻辑,避免内核缺陷引起软件崩溃 #262 2026-05-21 14:45:47 +00:00
mofeng-git
2e0ca89943 chore: bump version to v0.2.1 2026-05-20 00:09:31 +08:00
mofeng-git
1f7cfb373c fix: 修复设置页滚动和 HID 继电器识别 #252 2026-05-19 22:17:50 +08:00
mofeng-git
da05656a89 fix(web): 设置页按菜单加载并优化错误提示 2026-05-19 21:38:56 +08:00
mofeng-git
265852b312 fix: 避免 CH9329 配置保存时误触发 OTG reconcile 2026-05-19 20:50:33 +08:00
mofeng-git
02bf04ed7f fix: 修复 MSD 状态卡片 i18n 键名 2026-05-19 20:44:12 +08:00
mofeng-git
8915d36bcf fix: 升级 Vite、Rollup、PostCSS 等依赖清除 Github 安全漏洞提示 2026-05-19 11:45:45 +00:00
mofeng-git
3ea15e37a4 feat: 增加 CHINAMIRRO 构建环境变量 2026-05-19 11:22:04 +00:00
mofeng-git
cb0c66af96 ci: 调整 GitHub Actions 构建与发布流程 2026-05-19 18:01:53 +08:00
SilentWind
a3ebcded34 Merge pull request #261 from fcsha/fix/issue-260-msd-endpoint-budget
fix: 关闭 MSD 后保存 HID 配置时端点预算校验误判超限
2026-05-19 09:58:03 +08:00
mofeng-git
f7c2cd1b90 ci: 支持 GitHub Actions 构建 2026-05-19 09:54:54 +08:00
mofeng-git
e774210ae3 fix: 修复构建错误并清理未使用导入 2026-05-18 15:23:42 +00:00
mofeng-git
935fa823f2 feat: 初步增加 Windows 支持 2026-05-18 22:44:59 +08:00
Fucheng Sha
dd3f73ae54 fix: 关闭 MSD 后保存 HID 配置时先更新 MSD 状态再校验端点预算
saveConfig 中调换 updateMsd 和 updateHid 的调用顺序,确保 HID
校验端点预算时 MSD enabled 状态已是最新值,避免被误判为超限。

Fixes mofeng-git/One-KVM#260
2026-05-16 13:00:49 +08:00
SilentWind
0b9d94f53f docs: Update README with 贝塔网络 sponsorship 2026-05-13 22:38:58 +08:00
SilentWind
e5d6279a54 Merge pull request #257 from btzen/redfish
feat: 实现 Redfish API 标准接口;支持通过前端开关控制 Redfish 服务
2026-05-13 13:47:07 +08:00
Fucheng Sha
57d4091497 fix: 恢复被误删的 MSD Section 注释 2026-05-12 14:53:04 +08:00
Fucheng Sha
4e8c342905 feat: 实现 Redfish API 标准接口;支持通过前端开关控制 Redfish 服务 2026-05-12 10:53:26 +08:00
SilentWind
17cd74f64c Merge pull request #250 from arounyf/pr/audio-fix
fix: 修复 WebRTC 音频/视频接收器重启时破音问题
2026-05-05 12:12:17 +08:00
arounyf
9923670426 fix: 修复 WebRTC 音频/视频接收器重启时破音问题
start_audio_from_opus 和 start_from_video_frames 替换旧 handle 时先
abort 旧任务,防止新旧两个任务同时向同一个 track 写数据导致破音。
2026-05-05 05:11:04 +08:00
271 changed files with 28152 additions and 8944 deletions

301
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,301 @@
name: Build
on:
pull_request:
workflow_dispatch:
inputs:
publish_release:
description: Publish GitHub Release
required: false
default: false
type: boolean
release_tag:
description: Release tag name when publishing
required: false
default: ""
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
jobs:
frontend:
runs-on: ubuntu-22.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: web/package-lock.json
- name: Build frontend
working-directory: web
run: |
npm ci
npm run build
- name: Upload frontend dist
uses: actions/upload-artifact@v4
with:
name: web-dist
path: web/dist
if-no-files-found: error
retention-days: 7
deb:
runs-on: ubuntu-22.04
needs: frontend
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
name: web-dist
path: web/dist
- uses: dtolnay/rust-toolchain@stable
- name: Install cross
run: cargo install cross --locked
- name: Build linux binary
run: bash build/build-images.sh
- name: Package deb
run: bash build/package-deb.sh
- name: Upload deb
uses: actions/upload-artifact@v4
with:
name: one-kvm-deb
path: target/debian/*.deb
if-no-files-found: error
retention-days: 7
windows:
runs-on: windows-2022
needs: frontend
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
name: web-dist
path: web/dist
- uses: dtolnay/rust-toolchain@stable
- name: Set up MSVC
uses: ilammy/msvc-dev-cmd@v1
- name: Prepare vcpkg and dependencies
shell: pwsh
run: |
$env:VCPKG_ROOT = "C:\vcpkg"
$env:VCPKG_DEFAULT_TRIPLET = "x64-windows-static"
$env:VCPKG_INSTALLED_DIR = Join-Path $pwd "vcpkg_installed"
if (-not (Test-Path $env:VCPKG_ROOT)) {
git clone https://github.com/microsoft/vcpkg $env:VCPKG_ROOT
}
& "$env:VCPKG_ROOT\bootstrap-vcpkg.bat" -disableMetrics
& "$env:VCPKG_ROOT\vcpkg.exe" install --triplet $env:VCPKG_DEFAULT_TRIPLET --x-install-root="$env:VCPKG_INSTALLED_DIR"
$tripletRoot = Join-Path $env:VCPKG_INSTALLED_DIR $env:VCPKG_DEFAULT_TRIPLET
$env:TURBOJPEG_SOURCE = "explicit"
$env:TURBOJPEG_LIB_DIR = Join-Path $tripletRoot "lib"
$env:TURBOJPEG_INCLUDE_DIR = Join-Path $tripletRoot "include"
"VCPKG_ROOT=$env:VCPKG_ROOT" | Out-File -FilePath $env:GITHUB_ENV -Append
"VCPKG_DEFAULT_TRIPLET=$env:VCPKG_DEFAULT_TRIPLET" | Out-File -FilePath $env:GITHUB_ENV -Append
"VCPKG_INSTALLED_DIR=$env:VCPKG_INSTALLED_DIR" | Out-File -FilePath $env:GITHUB_ENV -Append
"TURBOJPEG_SOURCE=$env:TURBOJPEG_SOURCE" | Out-File -FilePath $env:GITHUB_ENV -Append
"TURBOJPEG_LIB_DIR=$env:TURBOJPEG_LIB_DIR" | Out-File -FilePath $env:GITHUB_ENV -Append
"TURBOJPEG_INCLUDE_DIR=$env:TURBOJPEG_INCLUDE_DIR" | Out-File -FilePath $env:GITHUB_ENV -Append
- name: Build Windows exe
shell: pwsh
run: .\build\windows\build.ps1 -Configuration release -Package
- name: Upload exe
uses: actions/upload-artifact@v4
with:
name: one-kvm-windows-exe
path: target/x86_64-pc-windows-msvc/release/one-kvm_*.exe
if-no-files-found: error
retention-days: 7
android:
runs-on: ubuntu-22.04
needs: frontend
timeout-minutes: 240
steps:
- uses: actions/checkout@v4
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
name: web-dist
path: web/dist
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Android Docker layers
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache/android
key: android-buildx-${{ runner.os }}-${{ hashFiles('build/cross/Dockerfile.android') }}
restore-keys: |
android-buildx-${{ runner.os }}-
- name: Build Android Docker image
uses: docker/build-push-action@v6
with:
context: build/cross
file: build/cross/Dockerfile.android
tags: one-kvm-android-build:ci
load: true
cache-from: type=local,src=/tmp/.buildx-cache/android
cache-to: type=local,dest=/tmp/.buildx-cache/android-new,mode=max
- name: Rotate Android Docker layer cache
run: |
rm -rf /tmp/.buildx-cache/android
mv /tmp/.buildx-cache/android-new /tmp/.buildx-cache/android
- name: Cache Android build dependencies
uses: actions/cache@v4
with:
path: |
.github/android-cache/gradle
.github/android-cache/cargo-registry
.github/android-cache/cargo-git
.tmp/android-ffmpeg-check
.tmp/android-turbojpeg-src
.tmp/android-libyuv-src
.tmp/android-alsa-src
.tmp/android-opus-src
dist/android-ffmpeg-mediacodec
dist/android-turbojpeg
dist/android-libyuv
dist/android-alsa
dist/android-opus
key: android-deps-${{ runner.os }}-${{ hashFiles('android/**/*.gradle.kts', 'android/gradle/wrapper/gradle-wrapper.properties', 'android/native/Cargo.lock', 'Cargo.lock', 'scripts/build-android-*.sh') }}
restore-keys: |
android-deps-${{ runner.os }}-
- name: Prepare Android FFmpeg source
run: |
chmod +x android/gradlew
if [ ! -x .tmp/android-ffmpeg-check/src/ffmpeg-rockchip/configure ]; then
rm -rf .tmp/android-ffmpeg-check/src
mkdir -p .tmp/android-ffmpeg-check/src
wget -q https://files.mofeng.run/src/image/other/ffmpeg.tar.gz -O .tmp/android-ffmpeg-check/ffmpeg.tar.gz
tar -xzf .tmp/android-ffmpeg-check/ffmpeg.tar.gz -C .tmp/android-ffmpeg-check/src --strip-components=1
fi
- name: Build Android APK
env:
ONE_KVM_ANDROID_DOCKER_IMAGE: one-kvm-android-build:ci
ONE_KVM_ANDROID_SKIP_DOCKER_BUILD: "1"
ONE_KVM_ANDROID_GRADLE_CACHE_DIR: ${{ github.workspace }}/.github/android-cache/gradle
ONE_KVM_ANDROID_CARGO_REGISTRY_CACHE_DIR: ${{ github.workspace }}/.github/android-cache/cargo-registry
ONE_KVM_ANDROID_CARGO_GIT_CACHE_DIR: ${{ github.workspace }}/.github/android-cache/cargo-git
run: bash build/build-android.sh all
- name: Fix Android build permissions
if: ${{ always() }}
run: |
paths=(
.github/android-cache
.tmp/android-ffmpeg-check
.tmp/android-turbojpeg-src
.tmp/android-libyuv-src
.tmp/android-alsa-src
.tmp/android-opus-src
dist/android-ffmpeg-mediacodec
dist/android-turbojpeg
dist/android-libyuv
dist/android-alsa
dist/android-opus
target/android
android/.gradle
android/app/build
android/native/target
)
existing=()
for path in "${paths[@]}"; do
if [ -e "$path" ]; then
existing+=("$path")
fi
done
if [ "${#existing[@]}" -gt 0 ]; then
sudo chown -R "$USER:$USER" "${existing[@]}"
fi
- name: Upload Android APK
uses: actions/upload-artifact@v4
with:
name: one-kvm-android-apk
path: target/android/one-kvm_*.apk
if-no-files-found: error
retention-days: 7
release:
runs-on: ubuntu-22.04
needs: [deb, windows, android]
if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_release }}
timeout-minutes: 30
permissions:
contents: write
steps:
- name: Validate release tag
run: |
if [ -z "${{ inputs.release_tag }}" ]; then
echo "release_tag is required when publish_release is true"
exit 1
fi
- name: Download deb artifact
uses: actions/download-artifact@v4
with:
name: one-kvm-deb
path: release-artifacts/deb
- name: Download exe artifact
uses: actions/download-artifact@v4
with:
name: one-kvm-windows-exe
path: release-artifacts/windows
- name: Download Android artifact
uses: actions/download-artifact@v4
with:
name: one-kvm-android-apk
path: release-artifacts/android
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.release_tag }}
prerelease: true
generate_release_notes: true
files: |
release-artifacts/deb/*.deb
release-artifacts/windows/*.exe
release-artifacts/android/*.apk

3
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# Rust
/target/
target/
Cargo.lock
# IDE
@@ -31,6 +31,7 @@ Thumbs.db
# Build artifacts
/dist/
/build-staging
/.tmp/
# Frontend (built files)
/web/node_modules/

View File

@@ -1,6 +1,6 @@
[package]
name = "one-kvm"
version = "0.2.0"
version = "0.2.2"
edition = "2021"
authors = ["SilentWind"]
description = "A open and lightweight IP-KVM solution written in Rust"
@@ -9,118 +9,253 @@ repository = "https://github.com/mofeng-git/One-KVM"
keywords = ["kvm", "ipkvm", "remote-management", "embedded"]
categories = ["embedded", "network-programming"]
[features]
default = ["desktop"]
desktop = [
"dep:tokio",
"dep:tokio-util",
"dep:axum",
"dep:axum-extra",
"dep:tower-http",
"dep:sqlx",
"dep:serde",
"dep:serde_json",
"dep:tracing",
"dep:tracing-subscriber",
"dep:thiserror",
"dep:anyhow",
"dep:argon2",
"dep:rand",
"dep:uuid",
"dep:base64",
"dep:nix",
"dep:reqwest",
"dep:urlencoding",
"dep:rust-embed",
"dep:mime_guess",
"dep:rustls",
"dep:rcgen",
"dep:axum-server",
"dep:clap",
"dep:time",
"dep:bytes",
"dep:bytemuck",
"dep:xxhash-rust",
"dep:async-stream",
"dep:futures",
"dep:tokio-tungstenite",
"dep:parking_lot",
"dep:arc-swap",
"dep:webrtc",
"dep:rtp",
"dep:rtsp-types",
"dep:sdp-types",
"dep:serialport",
"dep:async-trait",
"dep:libc",
"dep:ventoy-img",
"dep:protobuf",
"dep:sodiumoxide",
"dep:sha2",
"dep:typeshare",
"dep:hwcodec",
"dep:libyuv",
"dep:turbojpeg",
"dep:audiopus",
"dep:v4l2r",
"dep:alsa",
"dep:gpio-cdev",
"dep:cpal",
"dep:windows-sys",
]
android = [
"dep:anyhow",
"dep:argon2",
"dep:arc-swap",
"dep:async-stream",
"dep:async-trait",
"dep:axum",
"dep:axum-extra",
"dep:base64",
"dep:bytemuck",
"dep:bytes",
"dep:futures",
"dep:gpio-cdev",
"dep:hwcodec",
"dep:libc",
"dep:libyuv",
"dep:mime_guess",
"dep:nix",
"dep:parking_lot",
"dep:protobuf",
"dep:rand",
"dep:rcgen",
"dep:reqwest",
"dep:rtp",
"dep:rtsp-types",
"dep:rust-embed",
"dep:rustls",
"dep:sdp-types",
"dep:serde",
"dep:serde_json",
"dep:serialport",
"dep:sha2",
"dep:sodiumoxide",
"dep:sqlx",
"dep:alsa",
"dep:audiopus",
"dep:thiserror",
"dep:time",
"dep:tokio",
"dep:tokio-tungstenite",
"dep:tokio-util",
"dep:axum-server",
"dep:tower-http",
"dep:tracing",
"dep:tracing-log",
"dep:tracing-subscriber",
"dep:turbojpeg",
"dep:typeshare",
"dep:urlencoding",
"dep:uuid",
"dep:ventoy-img",
"dep:v4l2r",
"dep:webrtc",
"dep:xxhash-rust",
]
android-mediacodec = [
"android",
]
[dependencies]
# Async runtime
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
tokio = { version = "1", features = ["full"], optional = true }
tokio-util = { version = "0.7", features = ["rt"], optional = true }
# Web framework
axum = { version = "0.8", features = ["ws", "multipart", "tokio"] }
axum-extra = { version = "0.12", features = ["cookie"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
axum = { version = "0.8", features = ["ws", "multipart", "tokio"], optional = true }
axum-extra = { version = "0.12", features = ["cookie"], optional = true }
tower-http = { version = "0.6", features = ["cors", "trace", "set-header"], optional = true }
# Database - Use bundled SQLite for static linking
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"], optional = true }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "tracing-log"] }
tracing = { version = "0.1", optional = true }
tracing-log = { version = "0.2", optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "tracing-log"], optional = true }
# Error handling
thiserror = "2"
anyhow = "1"
thiserror = { version = "2", optional = true }
anyhow = { version = "1", optional = true }
# Authentication
argon2 = "0.5"
rand = "0.9"
argon2 = { version = "0.5", optional = true }
rand = { version = "0.9", optional = true }
# Utilities
uuid = { version = "1", features = ["v4", "serde"] }
base64 = "0.22"
nix = { version = "0.30", features = ["fs", "net", "hostname", "poll"] }
uuid = { version = "1", features = ["v4", "serde"], optional = true }
base64 = { version = "0.22", optional = true }
# 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", "json"], default-features = false }
urlencoding = "2"
reqwest = { version = "0.13", features = ["stream", "rustls", "json"], default-features = false, optional = true }
urlencoding = { version = "2", optional = true }
# Static file embedding
rust-embed = { version = "8", features = ["compression", "debug-embed"] }
mime_guess = "2"
rust-embed = { version = "8", features = ["compression", "debug-embed"], optional = true }
mime_guess = { version = "2", optional = true }
# TLS/HTTPS
rustls = { version = "0.23", features = ["ring"] }
rcgen = "0.14"
axum-server = { version = "0.8", features = ["tls-rustls"] }
rustls = { version = "0.23", features = ["ring"], optional = true }
rcgen = { version = "0.14", optional = true }
axum-server = { version = "0.8", features = ["tls-rustls"], optional = true }
# CLI argument parsing
clap = { version = "4", features = ["derive"] }
clap = { version = "4", features = ["derive"], optional = true }
# Time (cookie max_age + RFC3339 timestamps)
time = { version = "0.3", features = ["serde", "formatting", "parsing"] }
# Video capture (V4L2)
v4l2r = "0.0.7"
# JPEG encoding (libjpeg-turbo, SIMD accelerated)
turbojpeg = "1.3"
time = { version = "0.3", features = ["serde", "formatting", "parsing"], optional = true }
# Bytes handling
bytes = "1"
bytemuck = { version = "1.24", features = ["derive"] }
bytes = { version = "1", optional = true }
bytemuck = { version = "1.24", features = ["derive"], optional = true }
# Frame deduplication (hash-based comparison)
xxhash-rust = { version = "0.8", features = ["xxh64"] }
xxhash-rust = { version = "0.8", features = ["xxh64"], optional = true }
# Async channels
async-stream = "0.3"
futures = "0.3"
async-stream = { version = "0.3", optional = true }
futures = { version = "0.3", optional = true }
# WebSocket client (for ttyd proxy)
tokio-tungstenite = "0.28"
tokio-tungstenite = { version = "0.28", optional = true }
# High-performance synchronization
parking_lot = "0.12"
arc-swap = "1.8"
parking_lot = { version = "0.12", optional = true }
arc-swap = { version = "1.8", optional = true }
# 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)
alsa = "0.11"
audiopus = "0.2"
webrtc = { version = "0.14", optional = true }
rtp = { version = "0.14", optional = true }
rtsp-types = { version = "0.1", optional = true }
sdp-types = { version = "0.1", optional = true }
# HID (serial port for CH9329)
serialport = "4"
async-trait = "0.1"
libc = "0.2"
serialport = { version = "4", optional = true }
async-trait = { version = "0.1", optional = true }
libc = { version = "0.2", optional = true }
# Ventoy bootable image support
ventoy-img = { path = "libs/ventoy-img-rs" }
# ATX (GPIO control)
gpio-cdev = "0.6"
# H264 hardware/software encoding (hwcodec from rustdesk)
hwcodec = { path = "libs/hwcodec" }
ventoy-img = { path = "libs/ventoy-img-rs", optional = true }
# RustDesk protocol support
protobuf = { version = "3.7", features = ["with-bytes"] }
sodiumoxide = "0.2"
sha2 = "0.10"
# High-performance pixel format conversion (libyuv)
libyuv = { path = "res/vcpkg/libyuv" }
protobuf = { version = "3.7", features = ["with-bytes"], optional = true }
sodiumoxide = { version = "0.2", optional = true }
sha2 = { version = "0.10", optional = true }
# TypeScript type generation
typeshare = "1.0"
typeshare = { version = "1.0", optional = true }
[target.'cfg(any(unix, windows))'.dependencies]
# Video encoding/decoding (FFmpeg/libjpeg-turbo/libyuv; available on Windows and Linux)
hwcodec = { path = "libs/hwcodec", features = ["bytes"], optional = true }
libyuv = { path = "res/vcpkg/libyuv", optional = true }
turbojpeg = { version = "1.3", optional = true }
# Note: audiopus links to libopus.so (unavoidable for audio support)
audiopus = { version = "0.2", optional = true }
[target.'cfg(all(unix, not(target_os = "android")))'.dependencies]
# Utilities
nix = { version = "0.30", default-features = false, features = ["fs", "socket", "net", "hostname", "poll"], optional = true }
[target.'cfg(target_os = "android")'.dependencies]
# Utilities
nix = { version = "0.30", default-features = false, features = ["fs", "socket", "hostname", "poll"], optional = true }
[target.'cfg(unix)'.dependencies]
# Video capture (V4L2)
v4l2r = { path = "libs/v4l2r", optional = true }
# Audio (ALSA capture)
alsa = { version = "0.11", optional = true }
# ATX (GPIO control)
gpio-cdev = { version = "0.6", optional = true }
[target.'cfg(windows)'.dependencies]
cpal = { version = "0.17", default-features = false, optional = true }
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_NetworkManagement_IpHelper",
"Win32_NetworkManagement_Ndis",
"Win32_Networking_WinSock",
"Win32_System_SystemInformation",
"Win32_System_Threading",
], optional = true }
[dev-dependencies]
tempfile = "3"

View File

@@ -228,6 +228,12 @@ One-KVM 已上架飞牛 **应用市场**,在 NAS 上直接搜索安装即可
- **[林枫云](https://www.dkdun.cn)** - 赞助了本项目服务器
![林枫云](https://docs.one-kvm.cn/img/36076FEFF0898A80EBD5756D28F4076C.png)
<img height="128" alt="林枫云" src="https://docs.one-kvm.cn/img/36076FEFF0898A80EBD5756D28F4076C.png" />
林枫云主营国内外地域的精品线路业务服务器、高主频游戏服务器和大带宽服务器。
林枫云主营国内外地域的精品线路业务服务器、高主频游戏服务器和大带宽服务器。
- **[贝塔网络](https://my.beita.cc/?ref=github_onekvm)** - 赞助了本项目服务器
<img height="128" alt="BTBT" src="https://github.com/user-attachments/assets/c442d5f5-d72f-4a07-b9f4-400a6a0c3f1e" />
远程电脑、消费级GPU服务器、独服物理机全自动在线交付。

14
agents.md Normal file
View File

@@ -0,0 +1,14 @@
# Agents Notes
## Windows MSVC Build
Run from the repository root in PowerShell:
```powershell
$env:VCPKG_ROOT='C:\Users\mofen\code\vcpkg'
$env:TURBOJPEG_SOURCE='explicit'
$env:TURBOJPEG_LIB_DIR='C:\Users\mofen\code\vcpkg\installed\x64-windows-static\lib'
$env:TURBOJPEG_INCLUDE_DIR='C:\Users\mofen\code\vcpkg\installed\x64-windows-static\include'
cargo build --target x86_64-pc-windows-msvc
```

7
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.gradle/
.kotlin/
build/
local.properties
app/build/
app/src/main/jniLibs/
native/target/

View File

@@ -0,0 +1,571 @@
import org.gradle.api.tasks.Exec
import java.security.MessageDigest
import java.util.Properties
plugins {
id("com.android.application")
}
val androidNdkVersion = "27.3.13750724"
val androidApiLevel = 21
val nativeCrateDir = layout.projectDirectory.dir("../native")
val rootCrateDir = layout.projectDirectory.dir("../..")
val nativeCargoOutputDir = layout.buildDirectory.dir("generated/oneKvm/cargoJniLibs")
val nativeOutputRoot = layout.buildDirectory.dir("generated/oneKvm/jniLibs")
val nativeAssetRoot = layout.buildDirectory.dir("generated/oneKvm/assets")
val defaultAndroidFfmpegRoot = rootProject.layout.projectDirectory.dir("../dist/android-ffmpeg-mediacodec")
val defaultAndroidLibyuvRoot = rootProject.layout.projectDirectory.dir("../dist/android-libyuv")
val defaultAndroidTurbojpegRoot = rootProject.layout.projectDirectory.dir("../dist/android-turbojpeg")
val defaultAndroidAlsaRoot = rootProject.layout.projectDirectory.dir("../dist/android-alsa")
val defaultAndroidOpusRoot = rootProject.layout.projectDirectory.dir("../dist/android-opus")
val androidFfmpegRoot = providers.environmentVariable("ONE_KVM_ANDROID_FFMPEG_ROOT")
.orElse(defaultAndroidFfmpegRoot.asFile.absolutePath)
val androidLibyuvRoot = providers.environmentVariable("ONE_KVM_ANDROID_LIBYUV_ROOT")
.orElse(defaultAndroidLibyuvRoot.asFile.absolutePath)
val androidTurbojpegRoot = providers.environmentVariable("ONE_KVM_ANDROID_TURBOJPEG_ROOT")
.orElse(defaultAndroidTurbojpegRoot.asFile.absolutePath)
val androidAlsaRoot = providers.environmentVariable("ONE_KVM_ANDROID_ALSA_ROOT")
.orElse(defaultAndroidAlsaRoot.asFile.absolutePath)
val androidOpusRoot = providers.environmentVariable("ONE_KVM_ANDROID_OPUS_ROOT")
.orElse(defaultAndroidOpusRoot.asFile.absolutePath)
val selectedAndroidAbis = providers.environmentVariable("ONE_KVM_ANDROID_ABIS")
.orElse("arm64-v8a,armeabi-v7a")
.get()
.split(',', ' ', ';')
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
val androidBuildProfile = providers.environmentVariable("ONE_KVM_ANDROID_PROFILE")
.orElse("debug")
.get()
.lowercase()
val oneKvmVersion = Regex("""(?m)^version\s*=\s*"([^"]+)"""")
.find(rootCrateDir.file("Cargo.toml").asFile.readText())
?.groupValues
?.get(1)
?: throw GradleException("Failed to resolve version from root Cargo.toml")
val androidFfmpegSourceDir = rootProject.layout.projectDirectory
.dir("../.tmp/android-ffmpeg-check/src/ffmpeg-rockchip")
val localProperties = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) {
file.inputStream().use { load(it) }
}
}
val androidSdkDir = file(
providers.environmentVariable("ANDROID_HOME")
.orElse(providers.environmentVariable("ANDROID_SDK_ROOT"))
.orElse(localProperties.getProperty("sdk.dir") ?: "/root/android-sdk")
.get(),
)
val androidNdkDir = androidSdkDir.resolve("ndk/$androidNdkVersion")
val androidFfmpegBuildScript = rootProject.layout.projectDirectory
.dir("..")
.file("scripts/build-android-ffmpeg-mediacodec.sh")
val androidLibyuvBuildScript = rootProject.layout.projectDirectory
.dir("..")
.file("scripts/build-android-libyuv.sh")
val androidTurbojpegBuildScript = rootProject.layout.projectDirectory
.dir("..")
.file("scripts/build-android-turbojpeg.sh")
val androidAlsaBuildScript = rootProject.layout.projectDirectory
.dir("..")
.file("scripts/build-android-alsa.sh")
val androidOpusBuildScript = rootProject.layout.projectDirectory
.dir("..")
.file("scripts/build-android-opus.sh")
val androidAbiTargets = mapOf(
"arm64-v8a" to Triple("arm64", "aarch64-linux-android", "aarch64-linux-android"),
"armeabi-v7a" to Triple("arm32", "armv7-linux-androideabi", "arm-linux-androideabi"),
)
val selectedAndroidAbiTargets = selectedAndroidAbis.associateWith { abi ->
androidAbiTargets[abi] ?: throw GradleException(
"Unsupported ONE_KVM_ANDROID_ABIS entry: $abi. Supported values: ${androidAbiTargets.keys.joinToString(", ")}",
)
}
if (androidBuildProfile != "debug" && androidBuildProfile != "release") {
throw GradleException("Unsupported ONE_KVM_ANDROID_PROFILE: $androidBuildProfile. Use debug or release.")
}
fun androidFfmpegBuildStamp(script: File): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(script.readBytes())
.joinToString("") { "%02x".format(it) }
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest"
}
fun androidFfmpegRequiredFiles(root: File): List<File> = listOf(
"include/libavcodec/avcodec.h",
"lib/libavcodec.a",
"lib/libavutil.a",
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
fun androidLibyuvBuildStamp(script: File): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(script.readBytes())
.joinToString("") { "%02x".format(it) }
val turbojpegScriptDigest = MessageDigest.getInstance("SHA-256")
.digest(androidTurbojpegBuildScript.asFile.readBytes())
.joinToString("") { "%02x".format(it) }
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest;turbojpegScript=$turbojpegScriptDigest"
}
fun androidLibyuvRequiredFiles(root: File): List<File> = listOf(
"include/libyuv.h",
"lib/libyuv.a",
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
fun androidTurbojpegBuildStamp(script: File): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(script.readBytes())
.joinToString("") { "%02x".format(it) }
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest"
}
fun androidAlsaBuildStamp(script: File): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(script.readBytes())
.joinToString("") { "%02x".format(it) }
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest"
}
fun androidOpusBuildStamp(script: File): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(script.readBytes())
.joinToString("") { "%02x".format(it) }
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest"
}
fun androidTurbojpegRequiredFiles(root: File): List<File> = listOf(
"include/turbojpeg.h",
"include/jpeglib.h",
"lib/libjpeg.a",
"lib/libturbojpeg.a",
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
fun androidAlsaRequiredFiles(root: File): List<File> = listOf(
"include/alsa/asoundlib.h",
"lib/libasound.so",
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
fun androidOpusRequiredFiles(root: File): List<File> = listOf(
"include/opus/opus.h",
"lib/libopus.so",
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
android {
namespace = "cn.one_kvm.androidhost"
compileSdk = 36
ndkVersion = androidNdkVersion
flavorDimensions += "abi"
defaultConfig {
applicationId = "cn.one_kvm.androidhost"
minSdk = androidApiLevel
targetSdk = 36
versionCode = 1
versionName = oneKvmVersion
}
productFlavors {
create("arm32") {
dimension = "abi"
ndk {
abiFilters += "armeabi-v7a"
}
}
create("arm64") {
dimension = "abi"
ndk {
abiFilters += "arm64-v8a"
}
}
}
sourceSets {
getByName("main") {
assets.directories.clear()
jniLibs.directories.clear()
}
getByName("arm32") {
assets.directories.add("build/generated/oneKvm/assets/arm32")
jniLibs.directories.add("build/generated/oneKvm/jniLibs/arm32")
}
getByName("arm64") {
assets.directories.add("build/generated/oneKvm/assets/arm64")
jniLibs.directories.add("build/generated/oneKvm/jniLibs/arm64")
}
}
}
tasks.register<Exec>("buildAndroidFfmpegMediaCodec") {
description = "Builds the default Android FFmpeg MediaCodec static libraries."
group = "build"
val ffmpegRoot = file(androidFfmpegRoot.get())
val sourceDir = androidFfmpegSourceDir.asFile
val scriptFile = androidFfmpegBuildScript.asFile
val stampFile = ffmpegRoot.resolve(".one-kvm-android-ffmpeg.stamp")
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
commandLine(
"bash",
scriptFile.absolutePath,
"--source",
sourceDir.absolutePath,
"--output",
ffmpegRoot.absolutePath,
"--ndk",
androidNdkDir.absolutePath,
"--api",
androidApiLevel.toString(),
"--abis",
selectedAndroidAbis.joinToString(","),
)
inputs.dir(sourceDir)
inputs.file(scriptFile)
outputs.dir(ffmpegRoot)
onlyIf {
val hasAndroidFfmpeg = androidFfmpegRequiredFiles(ffmpegRoot).all { it.exists() }
val hasCurrentBuildStamp =
stampFile.exists() && stampFile.readText() == androidFfmpegBuildStamp(scriptFile)
if (!hasAndroidFfmpeg && !sourceDir.resolve("configure").exists()) {
throw GradleException(
"Missing Android FFmpeg MediaCodec build at ${ffmpegRoot.absolutePath}, " +
"and source was not found at ${sourceDir.absolutePath}",
)
}
!hasAndroidFfmpeg || !hasCurrentBuildStamp
}
doLast {
stampFile.writeText(androidFfmpegBuildStamp(scriptFile))
}
}
tasks.register<Exec>("buildAndroidLibyuv") {
description = "Builds Android libyuv static libraries."
group = "build"
val libyuvRoot = file(androidLibyuvRoot.get())
val turbojpegRoot = file(androidTurbojpegRoot.get())
val scriptFile = androidLibyuvBuildScript.asFile
val stampFile = libyuvRoot.resolve(".one-kvm-android-libyuv.stamp")
dependsOn("buildAndroidTurbojpeg")
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
commandLine(
"bash",
scriptFile.absolutePath,
"--output",
libyuvRoot.absolutePath,
"--ndk",
androidNdkDir.absolutePath,
"--api",
androidApiLevel.toString(),
"--abis",
selectedAndroidAbis.joinToString(","),
"--jpeg-root",
turbojpegRoot.absolutePath,
)
inputs.file(scriptFile)
outputs.dir(libyuvRoot)
onlyIf {
val hasAndroidLibyuv = androidLibyuvRequiredFiles(libyuvRoot).all { it.exists() }
val hasCurrentBuildStamp =
stampFile.exists() && stampFile.readText() == androidLibyuvBuildStamp(scriptFile)
!hasAndroidLibyuv || !hasCurrentBuildStamp
}
doLast {
stampFile.writeText(androidLibyuvBuildStamp(scriptFile))
}
}
tasks.register<Exec>("buildAndroidTurbojpeg") {
description = "Builds Android TurboJPEG static libraries."
group = "build"
val turbojpegRoot = file(androidTurbojpegRoot.get())
val scriptFile = androidTurbojpegBuildScript.asFile
val stampFile = turbojpegRoot.resolve(".one-kvm-android-turbojpeg.stamp")
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
commandLine(
"bash",
scriptFile.absolutePath,
"--output",
turbojpegRoot.absolutePath,
"--ndk",
androidNdkDir.absolutePath,
"--api",
androidApiLevel.toString(),
"--abis",
selectedAndroidAbis.joinToString(","),
)
inputs.file(scriptFile)
outputs.dir(turbojpegRoot)
onlyIf {
val hasAndroidTurbojpeg = androidTurbojpegRequiredFiles(turbojpegRoot).all { it.exists() }
val hasCurrentBuildStamp =
stampFile.exists() && stampFile.readText() == androidTurbojpegBuildStamp(scriptFile)
!hasAndroidTurbojpeg || !hasCurrentBuildStamp
}
doLast {
stampFile.writeText(androidTurbojpegBuildStamp(scriptFile))
}
}
tasks.register<Exec>("buildAndroidAlsa") {
description = "Builds Android ALSA shared libraries."
group = "build"
val alsaRoot = file(androidAlsaRoot.get())
val scriptFile = androidAlsaBuildScript.asFile
val stampFile = alsaRoot.resolve(".one-kvm-android-alsa.stamp")
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
commandLine(
"bash",
scriptFile.absolutePath,
"--output",
alsaRoot.absolutePath,
"--ndk",
androidNdkDir.absolutePath,
"--api",
androidApiLevel.toString(),
"--abis",
selectedAndroidAbis.joinToString(","),
)
inputs.file(scriptFile)
outputs.dir(alsaRoot)
onlyIf {
val hasAndroidAlsa = androidAlsaRequiredFiles(alsaRoot).all { it.exists() }
val hasCurrentBuildStamp =
stampFile.exists() && stampFile.readText() == androidAlsaBuildStamp(scriptFile)
!hasAndroidAlsa || !hasCurrentBuildStamp
}
doLast {
stampFile.writeText(androidAlsaBuildStamp(scriptFile))
}
}
tasks.register<Exec>("buildAndroidOpus") {
description = "Builds Android Opus shared libraries."
group = "build"
val opusRoot = file(androidOpusRoot.get())
val scriptFile = androidOpusBuildScript.asFile
val stampFile = opusRoot.resolve(".one-kvm-android-opus.stamp")
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
commandLine(
"bash",
scriptFile.absolutePath,
"--output",
opusRoot.absolutePath,
"--ndk",
androidNdkDir.absolutePath,
"--api",
androidApiLevel.toString(),
"--abis",
selectedAndroidAbis.joinToString(","),
)
inputs.file(scriptFile)
outputs.dir(opusRoot)
onlyIf {
val hasAndroidOpus = androidOpusRequiredFiles(opusRoot).all { it.exists() }
val hasCurrentBuildStamp =
stampFile.exists() && stampFile.readText() == androidOpusBuildStamp(scriptFile)
!hasAndroidOpus || !hasCurrentBuildStamp
}
doLast {
stampFile.writeText(androidOpusBuildStamp(scriptFile))
}
}
val cargoBuildAndroidAbiTaskNames = selectedAndroidAbiTargets.map { (abi, targets) ->
val (flavor, _, _) = targets
val taskName = "cargoBuildAndroid" + flavor.replaceFirstChar {
if (it.isLowerCase()) it.titlecase() else it.toString()
}
tasks.register<Exec>(taskName) {
description = "Builds the Android Rust bootstrap libraries for $abi."
group = "build"
dependsOn(
"buildAndroidFfmpegMediaCodec",
"buildAndroidLibyuv",
"buildAndroidTurbojpeg",
"buildAndroidAlsa",
"buildAndroidOpus",
)
val cargoCommand = mutableListOf(
"cargo",
"ndk",
"-t",
abi,
"-P",
androidApiLevel.toString(),
"-o",
nativeCargoOutputDir.get().asFile.absolutePath,
"build",
"--lib",
"--bins",
)
if (androidBuildProfile == "release") {
cargoCommand.add("--release")
}
workingDir = nativeCrateDir.asFile
commandLine(cargoCommand)
args("--features", "android-mediacodec")
environment("ONE_KVM_ANDROID_FFMPEG_ROOT", androidFfmpegRoot.get())
environment("ONE_KVM_ANDROID_LIBYUV_ROOT", androidLibyuvRoot.get())
environment("ONE_KVM_ANDROID_LIBYUV_STATIC", "1")
environment("TURBOJPEG_SOURCE", "explicit")
environment("TURBOJPEG_STATIC", "1")
environment(
"TURBOJPEG_LIB_DIR",
file(androidTurbojpegRoot.get()).resolve("$abi/lib").absolutePath,
)
environment(
"TURBOJPEG_INCLUDE_DIR",
file(androidTurbojpegRoot.get()).resolve("$abi/include").absolutePath,
)
environment("PKG_CONFIG_ALLOW_CROSS", "1")
environment(
"PKG_CONFIG_LIBDIR",
file(androidAlsaRoot.get()).resolve("$abi/lib/pkgconfig").absolutePath,
)
environment("PKG_CONFIG_SYSROOT_DIR", "")
environment("LIBOPUS_NO_PKG", "1")
environment("LIBOPUS_LIB_DIR", file(androidOpusRoot.get()).resolve("$abi/lib").absolutePath)
environment("ANDROID_HOME", androidSdkDir.absolutePath)
environment("ANDROID_SDK_ROOT", androidSdkDir.absolutePath)
environment("ANDROID_NDK_HOME", androidNdkDir.absolutePath)
environment("ANDROID_NDK", androidNdkDir.absolutePath)
environment("ANDROID_NDK_ROOT", androidNdkDir.absolutePath)
inputs.files(
nativeCrateDir.file("Cargo.toml"),
nativeCrateDir.dir("src"),
rootCrateDir.file("Cargo.lock"),
rootCrateDir.file("Cargo.toml"),
rootCrateDir.file("build.rs"),
rootCrateDir.dir("libs"),
rootCrateDir.dir("res/vcpkg/libyuv"),
rootCrateDir.dir("src"),
)
outputs.dir(nativeCargoOutputDir)
outputs.dir(file(androidFfmpegRoot.get()))
outputs.dir(file(androidLibyuvRoot.get()))
outputs.dir(file(androidTurbojpegRoot.get()))
outputs.dir(file(androidAlsaRoot.get()))
outputs.dir(file(androidOpusRoot.get()))
}
taskName
}
tasks.register("cargoBuildAndroid") {
description = "Builds the Android Rust bootstrap libraries."
group = "build"
dependsOn(cargoBuildAndroidAbiTaskNames)
outputs.dir(nativeOutputRoot)
outputs.dir(nativeAssetRoot)
doLast {
selectedAndroidAbiTargets.forEach { (abi, targets) ->
val (flavor, rustTriple, ndkTriple) = targets
val nativeLibSource = nativeCargoOutputDir.get().file("$abi/libone_kvm_android_bootstrap.so").asFile
if (!nativeLibSource.exists()) {
throw GradleException("Missing Android JNI library: ${nativeLibSource.absolutePath}")
}
copy {
from(nativeLibSource)
into(nativeOutputRoot.get().dir(flavor).dir(abi))
}
val source = nativeCrateDir.file("target/$rustTriple/$androidBuildProfile/one-kvm-android-host").asFile
if (!source.exists()) {
throw GradleException("Missing Android host binary: ${source.absolutePath}")
}
copy {
from(source)
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi"))
rename { "one-kvm-android-host" }
}
val cxxShared = androidNdkDir
.resolve("toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/$ndkTriple/libc++_shared.so")
if (!cxxShared.exists()) {
throw GradleException("Missing NDK libc++_shared.so: ${cxxShared.absolutePath}")
}
copy {
from(cxxShared)
into(nativeOutputRoot.get().dir(flavor).dir(abi))
}
copy {
from(cxxShared)
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi"))
}
val alsaShared = file(androidAlsaRoot.get()).resolve("$abi/lib/libasound.so")
if (!alsaShared.exists()) {
throw GradleException("Missing Android ALSA library: ${alsaShared.absolutePath}")
}
copy {
from(alsaShared)
into(nativeOutputRoot.get().dir(flavor).dir(abi))
}
copy {
from(alsaShared)
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi"))
}
copy {
from(file(androidAlsaRoot.get()).resolve("$abi/share/alsa"))
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi/alsa"))
}
val opusShared = file(androidOpusRoot.get()).resolve("$abi/lib/libopus.so")
if (!opusShared.exists()) {
throw GradleException("Missing Android Opus library: ${opusShared.absolutePath}")
}
copy {
from(opusShared)
into(nativeOutputRoot.get().dir(flavor).dir(abi))
}
copy {
from(opusShared)
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi"))
}
}
}
}
tasks.named("preBuild") {
dependsOn("cargoBuildAndroid")
}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:allowBackup="false"
android:icon="@drawable/ic_launcher_one_kvm"
android:label="@string/app_name"
android:theme="@style/AppTheme">
<service
android:name=".OneKvmService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
<receiver
android:name=".BootReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,13 @@
package cn.one_kvm.androidhost
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED && HostSettings.getAutoStart(context)) {
OneKvmService.start(context)
}
}
}

View File

@@ -0,0 +1,33 @@
package cn.one_kvm.androidhost
import android.content.Context
object HostSettings {
private const val PREFS = "one_kvm_android"
private const val KEY_AUTO_START = "auto_start"
private const val KEY_CLEAR_EXISTING_OTG = "clear_existing_otg"
fun getAutoStart(context: Context): Boolean {
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getBoolean(KEY_AUTO_START, false)
}
fun setAutoStart(context: Context, enabled: Boolean) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_AUTO_START, enabled)
.apply()
}
fun getClearExistingOtg(context: Context): Boolean {
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getBoolean(KEY_CLEAR_EXISTING_OTG, false)
}
fun setClearExistingOtg(context: Context, enabled: Boolean) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_CLEAR_EXISTING_OTG, enabled)
.apply()
}
}

View File

@@ -0,0 +1,30 @@
package cn.one_kvm.androidhost
import android.content.Context
object LogConfig {
private const val PREFS = "one_kvm_android"
private const val KEY_LOG_LEVEL = "log_level"
const val DEFAULT_LEVEL = "info"
val LEVELS = arrayOf("error", "warn", "info", "debug", "trace")
fun getLevel(context: Context): String {
val value = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_LOG_LEVEL, DEFAULT_LEVEL)
?: DEFAULT_LEVEL
return if (LEVELS.contains(value)) value else DEFAULT_LEVEL
}
fun setLevel(context: Context, level: String) {
val safeLevel = if (LEVELS.contains(level)) level else DEFAULT_LEVEL
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(KEY_LOG_LEVEL, safeLevel)
.apply()
}
fun rustLogFilter(level: String): String {
val safeLevel = if (LEVELS.contains(level)) level else DEFAULT_LEVEL
return "one_kvm=$safeLevel,hwcodec=$safeLevel,tower_http=$safeLevel,webrtc_sctp=warn"
}
}

View File

@@ -0,0 +1,71 @@
package cn.one_kvm.androidhost
import android.content.Context
import java.io.File
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
object LogStore {
private const val FLUSH_DELAY_MS = 250L
private const val MAX_BUFFER_CHARS = 64 * 1024
private val lock = Any()
private val buffer = StringBuilder()
private val executor = Executors.newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "OneKvmLogStore").apply { isDaemon = true }
}
private var logFile: File? = null
private var flushScheduled = false
fun defaultLogFile(context: Context): File {
return File(File(context.getExternalFilesDir(null), "runtime"), "one-kvm.log")
}
fun configure(file: File) {
synchronized(lock) {
flushLocked()
file.parentFile?.mkdirs()
file.writeText("")
buffer.clear()
logFile = file
flushScheduled = false
}
}
fun append(line: String) {
synchronized(lock) {
if (logFile == null) return
buffer.append(line).append('\n')
if (buffer.length >= MAX_BUFFER_CHARS) {
flushLocked()
return
}
if (!flushScheduled) {
flushScheduled = true
executor.schedule({ flush() }, FLUSH_DELAY_MS, TimeUnit.MILLISECONDS)
}
}
}
fun flush() {
synchronized(lock) {
flushLocked()
}
}
private fun flushLocked() {
val file = logFile ?: return
if (buffer.isEmpty()) {
flushScheduled = false
return
}
val text = buffer.toString()
buffer.clear()
flushScheduled = false
file.appendText(text)
}
}

View File

@@ -0,0 +1,452 @@
package cn.one_kvm.androidhost
import android.app.Activity
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.CompoundButton
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.Spinner
import android.widget.Switch
import android.widget.TextView
import java.net.Inet4Address
import java.net.InetSocketAddress
import java.net.NetworkInterface
import java.net.Socket
import java.util.Collections
class MainActivity : Activity() {
private lateinit var statusValue: TextView
private lateinit var hostActionButton: Button
private lateinit var logLevelSpinner: Spinner
private lateinit var autoStartSwitch: Switch
private lateinit var clearOtgSwitch: Switch
private val statusHandler = Handler(Looper.getMainLooper())
private var statusPollsRemaining = 0
private val statusPoller = object : Runnable {
override fun run() {
refreshStatus()
statusPollsRemaining -= 1
if (statusPollsRemaining > 0) {
statusHandler.postDelayed(this, STATUS_POLL_INTERVAL_MS)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.statusBarColor = color("#F8FAFC")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
window.navigationBarColor = color("#F8FAFC")
}
val content = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(20.dp(), 24.dp(), 20.dp(), 28.dp())
background = solid("#F8FAFC")
}
content.addView(startCard())
content.addView(settingsCard())
content.addView(infoCard())
setContentView(ScrollView(this).apply {
isFillViewport = true
setBackgroundColor(color("#F8FAFC"))
addView(content)
})
}
override fun onResume() {
super.onResume()
reconcilePersistedStatus()
refreshStatus()
autoStartSwitch.isChecked = HostSettings.getAutoStart(this)
clearOtgSwitch.isChecked = HostSettings.getClearExistingOtg(this)
}
override fun onPause() {
statusHandler.removeCallbacks(statusPoller)
super.onPause()
}
private fun startCard(): View {
return card {
addView(sectionTitle("启动管理"))
addView(TextView(this@MainActivity).apply {
text = "管理本机 One-KVM 服务进程。暂停会停止前台服务并释放运行资源。"
textSize = 14f
setTextColor(color("#64748B"))
setPadding(0, 6.dp(), 0, 14.dp())
})
statusValue = TextView(this@MainActivity).apply {
textSize = 14f
typeface = Typeface.DEFAULT_BOLD
setTextColor(color("#0F172A"))
background = rounded("#EFF6FF", "#BFDBFE", 8)
setPadding(12.dp(), 8.dp(), 12.dp(), 8.dp())
}
addView(statusValue, matchWrap())
addView(LinearLayout(this@MainActivity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(0, 14.dp(), 0, 0)
hostActionButton = actionButton("启动", primary = true) { toggleHost() }
addView(hostActionButton, matchButton())
})
refreshStatus()
}
}
private fun settingsCard(): View {
return card {
addView(sectionTitle("运行设置"))
val (autoStartRow, autoStartControl) = settingSwitchRow(
title = "开机自启动",
subtitle = "系统启动完成后自动拉起 One-KVM 前台服务。",
checked = HostSettings.getAutoStart(this@MainActivity),
) { _, checked ->
HostSettings.setAutoStart(this@MainActivity, checked)
LogStore.append("Boot auto-start ${if (checked) "enabled" else "disabled"}")
}
autoStartSwitch = autoStartControl
addView(autoStartRow)
addView(divider())
val (clearOtgRow, clearOtgControl) = settingSwitchRow(
title = "清除已有 OTG Gadget",
subtitle = "启动 root host 前尝试解绑并删除 configfs 中已有的 USB gadget。",
checked = HostSettings.getClearExistingOtg(this@MainActivity),
) { _, checked ->
HostSettings.setClearExistingOtg(this@MainActivity, checked)
LogStore.append("Clear existing OTG gadget ${if (checked) "enabled" else "disabled"}")
}
clearOtgSwitch = clearOtgControl
addView(clearOtgRow)
addView(divider())
addView(logLevelRow())
}
}
private fun infoCard(): View {
return card {
addView(sectionTitle("应用信息"))
addView(infoRow("软件内核版本", kernelVersion()))
addView(infoRow("访问地址", accessAddresses(), selectable = true))
addView(infoRow("日志文件", LogStore.defaultLogFile(this@MainActivity).absolutePath, selectable = true))
}
}
private fun settingSwitchRow(
title: String,
subtitle: String,
checked: Boolean,
listener: CompoundButton.OnCheckedChangeListener,
): Pair<View, Switch> {
val switch = Switch(this).apply {
isChecked = checked
setOnCheckedChangeListener(listener)
}
val row = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(0, 12.dp(), 0, 12.dp())
addView(LinearLayout(this@MainActivity).apply {
orientation = LinearLayout.VERTICAL
addView(TextView(this@MainActivity).apply {
text = title
textSize = 15f
typeface = Typeface.DEFAULT_BOLD
setTextColor(color("#0F172A"))
})
addView(TextView(this@MainActivity).apply {
text = subtitle
textSize = 13f
setTextColor(color("#64748B"))
setPadding(0, 4.dp(), 12.dp(), 0)
})
}, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
addView(switch)
}
return row to switch
}
private fun infoRow(label: String, value: String, selectable: Boolean = false): View {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, 12.dp(), 0, 12.dp())
addView(TextView(this@MainActivity).apply {
text = label
textSize = 13f
setTextColor(color("#64748B"))
})
addView(TextView(this@MainActivity).apply {
text = value
textSize = 15f
setTextColor(color("#0F172A"))
setPadding(0, 4.dp(), 0, 0)
setTextIsSelectable(selectable)
})
addView(divider())
}
}
private fun logLevelRow(): View {
return LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(0, 12.dp(), 0, 0)
addView(TextView(this@MainActivity).apply {
text = "日志级别"
textSize = 15f
typeface = Typeface.DEFAULT_BOLD
setTextColor(color("#0F172A"))
}, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
logLevelSpinner = Spinner(this@MainActivity).apply {
adapter = ArrayAdapter(
this@MainActivity,
android.R.layout.simple_spinner_dropdown_item,
LogConfig.LEVELS,
)
setSelection(LogConfig.LEVELS.indexOf(LogConfig.getLevel(this@MainActivity)).coerceAtLeast(0))
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
val level = LogConfig.LEVELS[position]
if (level != LogConfig.getLevel(this@MainActivity)) {
LogConfig.setLevel(this@MainActivity, level)
LogStore.append("Log level set to $level; restart service to apply")
}
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}
}
addView(logLevelSpinner)
}
}
private fun card(build: LinearLayout.() -> Unit): View {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(16.dp(), 16.dp(), 16.dp(), 16.dp())
background = rounded("#FFFFFF", "#E2E8F0", 10)
elevation = 1.5f.dpFloat()
build()
}.also {
it.layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
).apply { setMargins(0, 0, 0, 14.dp()) }
}
}
private fun sectionTitle(text: String): View {
return TextView(this).apply {
this.text = text
textSize = 17f
typeface = Typeface.DEFAULT_BOLD
setTextColor(color("#0F172A"))
}
}
private fun actionButton(text: String, primary: Boolean, action: () -> Unit): Button {
return Button(this).apply {
this.text = text
textSize = 15f
isAllCaps = false
minHeight = 44.dp()
setTextColor(color(if (primary) "#FFFFFF" else "#0F172A"))
background = if (primary) rounded("#2563EB", "#2563EB", 8) else rounded("#FFFFFF", "#CBD5E1", 8)
setOnClickListener { action() }
}
}
private fun toggleHost() {
when (ServiceStatusStore.snapshot(this).state) {
ServiceStatusStore.STATE_RUNNING -> pauseHost()
ServiceStatusStore.STATE_STOPPED, ServiceStatusStore.STATE_ERROR -> startHost()
}
}
private fun startHost() {
ServiceStatusStore.setStarting(this)
refreshStatus()
OneKvmService.start(this)
LogStore.append("Start requested from app UI")
pollStatusForAWhile()
}
private fun pauseHost() {
ServiceStatusStore.setStopping(this)
refreshStatus()
OneKvmService.stop(this)
LogStore.append("Pause requested from app UI")
pollStatusForAWhile()
}
private fun refreshStatus() {
if (::statusValue.isInitialized) {
statusValue.text = "状态:${hostStatusSummary()}"
}
updateHostActionButton()
}
private fun hostStatusSummary(): String {
val serviceStatus = ServiceStatusStore.snapshot(this)
if (serviceStatus.state != ServiceStatusStore.STATE_STOPPED) {
return serviceStatus.labelText()
}
val nativeRunning = runCatching {
NativeBridge.hostStatus().contains("running", ignoreCase = true)
}.getOrDefault(false)
return if (nativeRunning) "运行中" else "已停止"
}
private fun reconcilePersistedStatus() {
val serviceStatus = ServiceStatusStore.snapshot(this)
if (serviceStatus.state == ServiceStatusStore.STATE_STOPPED) return
if (
serviceStatus.state == ServiceStatusStore.STATE_STARTING &&
System.currentTimeMillis() - serviceStatus.updatedAt < STARTING_RECONCILE_GRACE_MS
) {
return
}
Thread {
val portOpen = isLocalWebPortOpen()
val nativeRunning = runCatching { NativeBridge.hostStatus().contains("running", ignoreCase = true) }
.getOrDefault(false)
if (!portOpen && !nativeRunning) {
ServiceStatusStore.setStopped(this, "服务未运行")
runOnUiThread { refreshStatus() }
}
}.start()
}
private fun isLocalWebPortOpen(): Boolean {
return runCatching {
Socket().use { socket ->
socket.connect(InetSocketAddress("127.0.0.1", 8080), 250)
}
true
}.getOrDefault(false)
}
private fun updateHostActionButton() {
if (!::hostActionButton.isInitialized) return
when (ServiceStatusStore.snapshot(this).state) {
ServiceStatusStore.STATE_STARTING -> setHostActionButton("启动中...", enabled = false, primary = true)
ServiceStatusStore.STATE_RUNNING -> setHostActionButton("停止", enabled = true, primary = false)
ServiceStatusStore.STATE_STOPPING -> setHostActionButton("停止中...", enabled = false, primary = false)
else -> setHostActionButton("启动", enabled = true, primary = true)
}
}
private fun setHostActionButton(text: String, enabled: Boolean, primary: Boolean) {
hostActionButton.text = text
hostActionButton.isEnabled = enabled
hostActionButton.alpha = if (enabled) 1f else 0.65f
hostActionButton.setTextColor(color(if (primary) "#FFFFFF" else "#0F172A"))
hostActionButton.background = if (primary) {
rounded("#2563EB", "#2563EB", 8)
} else {
rounded("#FFFFFF", "#CBD5E1", 8)
}
}
private fun pollStatusForAWhile() {
statusPollsRemaining = 20
statusHandler.removeCallbacks(statusPoller)
statusHandler.postDelayed(statusPoller, STATUS_POLL_INTERVAL_MS)
}
private fun kernelVersion(): String {
return runCatching { NativeBridge.kernelVersion() }
.getOrElse { "unknown" }
}
private fun accessAddresses(): String {
val addresses = runCatching {
Collections.list(NetworkInterface.getNetworkInterfaces())
.filter { it.isUp && !it.isLoopback }
.flatMap { iface -> Collections.list(iface.inetAddresses) }
.filterIsInstance<Inet4Address>()
.filter { !it.isLoopbackAddress }
.map { "http://${it.hostAddress}:8080" }
.distinct()
}.getOrDefault(emptyList())
return (addresses.ifEmpty { listOf("http://127.0.0.1:8080") }).joinToString("\n")
}
private fun divider(): View {
return View(this).apply {
setBackgroundColor(color("#E2E8F0"))
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
1,
).apply { setMargins(0, 0, 0, 0) }
}
}
private fun matchWrap(): LinearLayout.LayoutParams {
return LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
)
}
private fun matchButton(): LinearLayout.LayoutParams {
return LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
48.dp(),
)
}
private fun solid(hex: String): GradientDrawable = GradientDrawable().apply {
setColor(color(hex))
}
private fun rounded(fill: String, stroke: String, radiusDp: Int): GradientDrawable {
return GradientDrawable().apply {
setColor(color(fill))
cornerRadius = radiusDp.dpFloat()
setStroke(1.dp(), color(stroke))
}
}
private fun color(hex: String): Int = Color.parseColor(hex)
private fun Int.dp(): Int = (this * resources.displayMetrics.density + 0.5f).toInt()
private fun Int.dpFloat(): Float = this * resources.displayMetrics.density
private fun Float.dpFloat(): Float = this * resources.displayMetrics.density
companion object {
private const val STATUS_POLL_INTERVAL_MS = 500L
private const val STARTING_RECONCILE_GRACE_MS = 15_000L
}
}

View File

@@ -0,0 +1,21 @@
package cn.one_kvm.androidhost
import android.content.Context
object NativeBridge {
init {
System.loadLibrary("one_kvm_android_bootstrap")
}
external fun initTlsVerifier(context: Context): Int
external fun setEnv(name: String, value: String): Int
external fun startHost(dataDir: String, bindAddress: String, port: Int): String
external fun stopHost(): String
external fun hostStatus(): String
external fun kernelVersion(): String
}

View File

@@ -0,0 +1,413 @@
package cn.one_kvm.androidhost
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import java.io.InterruptedIOException
import java.util.concurrent.Executors
class OneKvmService : Service() {
private var rootProcess: Process? = null
private val commandExecutor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "OneKvmServiceCommand")
}
override fun onCreate() {
super.onCreate()
ensureNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action ?: ACTION_START) {
ACTION_STOP -> {
ServiceStatusStore.setStopping(this)
commandExecutor.execute {
stopHostRuntime()
stopSelfResult(startId)
}
return START_NOT_STICKY
}
ACTION_START -> {
ServiceStatusStore.setStarting(this)
startForegroundCompat(NOTIFICATION_ID, notification("启动中"))
commandExecutor.execute {
val currentState = ServiceStatusStore.snapshot(this).state
if (currentState == ServiceStatusStore.STATE_RUNNING && isPortOpen(8080, 100)) {
return@execute
}
val dataDir = File(getExternalFilesDir(null), "runtime")
if (!dataDir.exists()) dataDir.mkdirs()
val result = startRustHost(dataDir)
if (result.startsWith("Running") && !result.contains("start failed", ignoreCase = true)) {
ServiceStatusStore.setRunning(this, "服务已启动")
notificationManager().notify(NOTIFICATION_ID, notification("运行中"))
} else {
ServiceStatusStore.setError(this, "启动失败")
notificationManager().notify(NOTIFICATION_ID, notification("启动失败"))
}
}
}
}
return START_STICKY
}
override fun onDestroy() {
stopHostRuntime(updateNotification = false)
commandExecutor.shutdownNow()
ServiceStatusStore.setStopped(this)
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun notification(state: String): Notification {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = createContentIntent(intent)
val builder = createNotificationBuilder()
return builder
.setSmallIcon(R.drawable.ic_stat_one_kvm)
.setContentTitle("One-KVM Android Host")
.setContentText(state)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
CHANNEL_ID,
"One-KVM Host",
NotificationManager.IMPORTANCE_LOW,
)
notificationManager().createNotificationChannel(channel)
}
@Suppress("DEPRECATION")
private fun createNotificationBuilder(): Notification.Builder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
Notification.Builder(this)
}
}
private fun createContentIntent(intent: Intent): PendingIntent {
val flags = pendingIntentFlags()
return PendingIntent.getActivity(this, 0, intent, flags)
}
private fun pendingIntentFlags(): Int {
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags = flags or pendingIntentImmutableFlag()
}
return flags
}
private fun pendingIntentImmutableFlag(): Int {
return try {
PendingIntent::class.java.getField("FLAG_IMMUTABLE").getInt(null)
} catch (_: ReflectiveOperationException) {
0
}
}
private fun notificationManager(): NotificationManager {
return getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
private fun stopHostRuntime(updateNotification: Boolean = true) {
stopRootHost()
NativeBridge.stopHost()
waitForPortRelease(8080, 2_000)
LogStore.flush()
ServiceStatusStore.setStopped(this)
if (updateNotification) {
notificationManager().notify(NOTIFICATION_ID, notification("已停止"))
}
}
private fun startRustHost(dataDir: File): String {
val logLevel = LogConfig.getLevel(this)
val rustLog = LogConfig.rustLogFilter(logLevel)
val appLogFile = LogStore.defaultLogFile(this)
LogStore.configure(appLogFile)
val rustLogFile = appLogFile
LogStore.append("Starting One-KVM Rust host, data_dir=${dataDir.absolutePath}, log_level=$logLevel")
val executable = extractHostBinary()
return runCatching {
val tlsInit = NativeBridge.initTlsVerifier(this)
if (tlsInit != 0) {
throw IllegalStateException("rustls platform verifier init failed with code $tlsInit")
}
stopRootHost(executable)
clearExistingOtgGadgetsIfEnabled()
startRootHost(executable, dataDir, rustLog, rustLogFile, logLevel)
LogStore.append("Rust host running as root on port 8080")
"Running as root on port 8080"
}.getOrElse { rootError ->
LogStore.append("Root host unavailable: ${rootError.message ?: rootError::class.java.simpleName}")
configureAlsaEnvironment(executable)
NativeBridge.setEnv("RUST_LOG", rustLog)
NativeBridge.setEnv("ONE_KVM_FFMPEG_LOG", ffmpegLogLevel(logLevel))
NativeBridge.setEnv("ONE_KVM_ANDROID_LOG_FILE", rustLogFile.absolutePath)
val jniResult = NativeBridge.startHost(dataDir.absolutePath, "0.0.0.0", 8080)
LogStore.append("Rust host running in app process on port 8080: $jniResult")
"Running in app process on port 8080 (${rootError.message ?: "root unavailable"}; $jniResult)"
}
}
private fun clearExistingOtgGadgetsIfEnabled() {
if (!HostSettings.getClearExistingOtg(this)) return
val command = """
root=/sys/kernel/config/usb_gadget
[ -d "${'$'}root" ] || exit 0
for gadget in "${'$'}root"/*; do
[ -d "${'$'}gadget" ] || continue
[ -w "${'$'}gadget/UDC" ] && echo "" > "${'$'}gadget/UDC" 2>/dev/null || true
find "${'$'}gadget/configs" -type l -delete 2>/dev/null || true
rm -rf "${'$'}gadget" 2>/dev/null || true
done
""".trimIndent()
runCatching {
ProcessBuilder("/system/xbin/su", "0", "sh", "-c", command)
.redirectErrorStream(true)
.start()
.waitFor()
}.onSuccess { exit ->
LogStore.append("Existing OTG gadget cleanup finished with exit code $exit")
}.onFailure { err ->
LogStore.append("Existing OTG gadget cleanup failed: ${err.message ?: err::class.java.simpleName}")
}
}
private fun configureAlsaEnvironment(executable: File) {
val binDir = executable.parentFile
?: throw IllegalStateException("host binary has no parent directory")
val alsaConfigDir = File(binDir, "alsa")
val alsaConfigPath = File(alsaConfigDir, "alsa.conf")
NativeBridge.setEnv("ALSA_CONFIG_DIR", alsaConfigDir.absolutePath)
NativeBridge.setEnv("ALSA_CONFIG_PATH", alsaConfigPath.absolutePath)
}
private fun extractHostBinary(): File {
val abi = Build.SUPPORTED_ABIS.firstOrNull { it == "arm64-v8a" || it == "armeabi-v7a" }
?: throw IllegalStateException("unsupported ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
val binDir = File(filesDir, "bin/$abi")
val target = File(binDir, "one-kvm-android-host")
copyAssetIfChanged("bin/$abi/one-kvm-android-host", target)
copyAssetIfChanged("bin/$abi/libc++_shared.so", File(binDir, "libc++_shared.so"))
copyAssetIfChanged("bin/$abi/libasound.so", File(binDir, "libasound.so"))
copyAssetIfChanged("bin/$abi/libopus.so", File(binDir, "libopus.so"))
copyAssetDirectoryIfChanged("bin/$abi/alsa", File(binDir, "alsa"))
if (!target.setExecutable(true, false)) {
throw IllegalStateException("cannot mark host binary executable")
}
return target
}
private fun copyAssetIfChanged(assetPath: String, target: File) {
val stamp = File(target.parentFile, "${target.name}.stamp")
@Suppress("DEPRECATION")
val packageInfo = packageManager.getPackageInfo(packageName, 0)
val expectedStamp = "${packageInfo.lastUpdateTime}:$assetPath"
if (target.exists() && stamp.exists() && stamp.readText() == expectedStamp) return
target.parentFile?.mkdirs()
assets.open(assetPath).use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
stamp.writeText(expectedStamp)
}
private fun copyAssetDirectoryIfChanged(assetDir: String, targetDir: File) {
@Suppress("DEPRECATION")
val packageInfo = packageManager.getPackageInfo(packageName, 0)
val stamp = File(targetDir, ".stamp")
val expectedStamp = "${packageInfo.lastUpdateTime}:$assetDir"
if (targetDir.exists() && stamp.exists() && stamp.readText() == expectedStamp) return
if (targetDir.exists()) targetDir.deleteRecursively()
copyAssetDirectory(assetDir, targetDir)
stamp.writeText(expectedStamp)
}
private fun copyAssetDirectory(assetDir: String, targetDir: File) {
targetDir.mkdirs()
val children = assets.list(assetDir)?.filter { it.isNotEmpty() }.orEmpty()
for (child in children) {
val childAsset = "$assetDir/$child"
val childTarget = File(targetDir, child)
val grandChildren = assets.list(childAsset)?.filter { it.isNotEmpty() }.orEmpty()
if (grandChildren.isEmpty()) {
copyAssetIfChanged(childAsset, childTarget)
} else {
copyAssetDirectory(childAsset, childTarget)
}
}
}
private fun startRootHost(
executable: File,
dataDir: File,
rustLog: String,
rustLogFile: File,
logLevel: String,
) {
stopRootHost(executable)
waitForPortRelease(8080, 2_000)
val libDir = executable.parentFile?.absolutePath
?: throw IllegalStateException("host binary has no parent directory")
val alsaConfigDir = File(executable.parentFile, "alsa")
val alsaConfigPath = File(alsaConfigDir, "alsa.conf")
val command =
"export LD_LIBRARY_PATH=${shellQuote(libDir)}:\${LD_LIBRARY_PATH:-}; " +
"export ALSA_CONFIG_DIR=${shellQuote(alsaConfigDir.absolutePath)}; " +
"export ALSA_CONFIG_PATH=${shellQuote(alsaConfigPath.absolutePath)}; " +
"export RUST_LOG=${shellQuote(rustLog)}; " +
"export ONE_KVM_FFMPEG_LOG=${shellQuote(ffmpegLogLevel(logLevel))}; " +
"export ONE_KVM_ANDROID_LOG_FILE=${shellQuote(rustLogFile.absolutePath)}; " +
"${shellQuote(executable.absolutePath)} ${shellQuote(dataDir.absolutePath)} 0.0.0.0 8080"
val process = ProcessBuilder("/system/xbin/su", "0", "sh", "-c", command)
.redirectErrorStream(true)
.start()
rootProcess = process
Thread {
val readError = runCatching {
BufferedReader(InputStreamReader(process.inputStream)).useLines { lines ->
lines.forEach {
android.util.Log.i("OneKvmService", it)
}
}
}.exceptionOrNull()
if (readError != null && readError !is InterruptedIOException) {
android.util.Log.w("OneKvmService", "Root host log reader stopped", readError)
LogStore.append("Root host log reader stopped: ${readError.message ?: readError::class.java.simpleName}")
}
val exit = runCatching { process.waitFor() }.getOrNull()
if (rootProcess === process && exit != null) {
rootProcess = null
ServiceStatusStore.setError(this, "Root host exited with code $exit")
}
}.start()
Thread.sleep(500)
val exit = runCatching { process.exitValue() }.getOrNull()
if (exit != null) {
rootProcess = null
throw IllegalStateException("root host exited immediately: $exit")
}
}
private fun startForegroundCompat(id: Int, notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val invoked = runCatching {
val method = Service::class.java.getMethod(
"startForeground",
Int::class.javaPrimitiveType,
Notification::class.java,
Int::class.javaPrimitiveType,
)
method.invoke(this, id, notification, foregroundServiceTypeConnectedDevice())
}.isSuccess
if (invoked) return
}
super.startForeground(id, notification)
}
private fun foregroundServiceTypeConnectedDevice(): Int {
return try {
Service::class.java.getField("FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE").getInt(null)
} catch (_: ReflectiveOperationException) {
0
}
}
private fun stopRootHost(executable: File? = null) {
rootProcess?.destroy()
rootProcess = null
stopRootHostProcess(executable)
}
private fun stopRootHostProcess(executable: File? = null) {
val command = buildString {
append("pkill -TERM -f '[o]ne-kvm-android-host' 2>/dev/null || true; ")
append("for pid in $(pidof one-kvm-android-host 2>/dev/null); do kill -TERM \"${'$'}pid\" 2>/dev/null || true; done; ")
append("sleep 0.2; ")
append("pkill -KILL -f '[o]ne-kvm-android-host' 2>/dev/null || true; ")
append("for pid in $(pidof one-kvm-android-host 2>/dev/null); do kill -KILL \"${'$'}pid\" 2>/dev/null || true; done; ")
}
runCatching {
ProcessBuilder("/system/xbin/su", "0", "sh", "-c", command)
.redirectErrorStream(true)
.start()
.waitFor()
}.onFailure { err ->
LogStore.append("Failed to stop stale root host: ${err.message ?: err::class.java.simpleName}")
}
}
private fun waitForPortRelease(port: Int, timeoutMs: Long) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val inUse = isPortOpen(port, 100)
if (!inUse) return
Thread.sleep(100)
}
}
private fun isPortOpen(port: Int, timeoutMs: Int): Boolean {
return runCatching {
java.net.Socket().use { socket ->
socket.connect(java.net.InetSocketAddress("127.0.0.1", port), timeoutMs)
}
true
}.getOrDefault(false)
}
private fun shellQuote(value: String): String {
return "'" + value.replace("'", "'\\''") + "'"
}
private fun ffmpegLogLevel(level: String): String {
return when (level) {
"trace" -> "trace"
"debug" -> "debug"
"info" -> "info"
"warn" -> "warning"
else -> "error"
}
}
companion object {
private const val CHANNEL_ID = "one_kvm_host"
private const val NOTIFICATION_ID = 1001
const val ACTION_START = "cn.one_kvm.androidhost.START"
const val ACTION_STOP = "cn.one_kvm.androidhost.STOP"
fun start(context: Context) {
val intent = Intent(context, OneKvmService::class.java).setAction(ACTION_START)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.startService(Intent(context, OneKvmService::class.java).setAction(ACTION_STOP))
}
}
}

View File

@@ -0,0 +1,75 @@
package cn.one_kvm.androidhost
import android.content.Context
object ServiceStatusStore {
private const val PREFS = "one_kvm_android_status"
private const val KEY_STATE = "state"
private const val KEY_MESSAGE = "message"
private const val KEY_UPDATED_AT = "updated_at"
const val STATE_STOPPED = "stopped"
const val STATE_STARTING = "starting"
const val STATE_RUNNING = "running"
const val STATE_STOPPING = "stopping"
const val STATE_ERROR = "error"
data class Snapshot(
val state: String,
val message: String,
val updatedAt: Long,
) {
fun labelText(): String {
return when (state) {
STATE_STARTING -> "启动中"
STATE_RUNNING -> "运行中"
STATE_STOPPING -> "停止中"
STATE_ERROR -> "错误"
else -> "已停止"
}
}
fun displayText(): String {
val label = labelText()
return if (message.isBlank()) label else "$label$message"
}
}
fun setStarting(context: Context, message: String = "正在启动服务") {
write(context, STATE_STARTING, message)
}
fun setRunning(context: Context, message: String) {
write(context, STATE_RUNNING, message)
}
fun setStopping(context: Context, message: String = "正在停止服务") {
write(context, STATE_STOPPING, message)
}
fun setStopped(context: Context, message: String = "服务已停止") {
write(context, STATE_STOPPED, message)
}
fun setError(context: Context, message: String) {
write(context, STATE_ERROR, message)
}
fun snapshot(context: Context): Snapshot {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return Snapshot(
state = prefs.getString(KEY_STATE, STATE_STOPPED) ?: STATE_STOPPED,
message = prefs.getString(KEY_MESSAGE, "") ?: "",
updatedAt = prefs.getLong(KEY_UPDATED_AT, 0L),
)
}
private fun write(context: Context, state: String, message: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(KEY_STATE, state)
.putString(KEY_MESSAGE, message)
.putLong(KEY_UPDATED_AT, System.currentTimeMillis())
.apply()
}
}

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1D7BF2"
android:pathData="M24,0h60a24,24 0,0 1,24 24v60a24,24 0,0 1,-24 24H24a24,24 0,0 1,-24 -24V24a24,24 0,0 1,24 -24z" />
<path
android:fillColor="#AED8E8"
android:pathData="M29,25h50a3,3 0,0 1,3 3v31a3,3 0,0 1,-3 3H29a3,3 0,0 1,-3 -3V28a3,3 0,0 1,3 -3z" />
<path
android:fillColor="#3F3F3D"
android:pathData="M31,30h46v27H31z" />
<path
android:fillColor="#E7F1F4"
android:pathData="M31,26h10a1.4,1.4 0,0 1,0 2.8H31a1.4,1.4 0,0 1,0 -2.8z" />
<path
android:fillColor="#8BBFD1"
android:pathData="M49,62h10l1.5,8h-13z" />
<path
android:fillColor="#9FCFE0"
android:pathData="M40,70a14,4.5 0,1 0,28 0a14,4.5 0,1 0,-28 0z" />
<path
android:fillColor="#E8F5F8"
android:pathData="M45,70a7,1.8 0,1 0,14 0a7,1.8 0,1 0,-14 0z" />
<path
android:fillColor="#BFE6F1"
android:pathData="M32,76h38l5,8H27z" />
<path
android:fillColor="#76ADC2"
android:pathData="M28,84h47v2H28z" />
<path
android:fillColor="#FFFFFF"
android:fillAlpha="0.82"
android:pathData="M37,79h6v2h-6zM46,79h5v2h-5zM54,79h5v2h-5zM62,79h6v2h-6zM34,82h7v2h-7zM44,82h6v2h-6zM53,82h11v2H53zM67,82h4v2h-4z" />
</vector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4,5h16v10H4z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9,17h6v2h3v2H6v-2h3z" />
</vector>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">One-KVM Android Host</string>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#2563EB</item>
</style>
</resources>

3
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,3 @@
plugins {
id("com.android.application") version "9.0.0" apply false
}

View File

@@ -0,0 +1,3 @@
android.useAndroidX=true
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
android/gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

21
android/native/Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "one-kvm-android-bootstrap"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "one_kvm_android_bootstrap"
crate-type = ["cdylib"]
[[bin]]
name = "one-kvm-android-host"
path = "src/bin/one-kvm-android-host.rs"
[dependencies]
jni = "0.22.4"
one-kvm = { path = "../..", default-features = false, features = ["android", "android-mediacodec"] }
rustls-platform-verifier = "0.7"
[features]
android-mediacodec = ["one-kvm/android-mediacodec"]

View File

@@ -0,0 +1,24 @@
use one_kvm::runtime::android::{self, AndroidRuntimeConfig};
fn main() {
let mut args = std::env::args().skip(1);
let data_dir = args
.next()
.unwrap_or_else(|| "/data/local/tmp/one-kvm".to_string());
let bind_address = args.next().unwrap_or_else(|| "0.0.0.0".to_string());
let port = args
.next()
.and_then(|value| value.parse::<u16>().ok())
.unwrap_or(8080);
one_kvm::runtime::android::init_rustls_provider();
if let Err(err) = android::run_foreground(AndroidRuntimeConfig {
data_dir,
bind_address,
port,
}) {
eprintln!("one-kvm android host failed: {err}");
std::process::exit(1);
}
}

182
android/native/src/lib.rs Normal file
View File

@@ -0,0 +1,182 @@
use jni::errors::{ErrorPolicy, ThrowRuntimeExAndDefault};
use jni::objects::{JClass, JObject, JString};
use jni::sys::{jint, jstring};
use jni::{Env, EnvOutcome, EnvUnowned};
use one_kvm::runtime::android::{self, AndroidRuntimeConfig};
#[derive(Debug)]
struct BridgeError(String);
impl From<jni::errors::Error> for BridgeError {
fn from(err: jni::errors::Error) -> Self {
Self(err.to_string())
}
}
impl From<String> for BridgeError {
fn from(err: String) -> Self {
Self(err)
}
}
impl std::fmt::Display for BridgeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Default)]
struct StatusPolicy;
impl ErrorPolicy<jint, BridgeError> for StatusPolicy {
type Captures<'unowned_env_local: 'native_method, 'native_method> = ();
fn on_error<'unowned_env_local: 'native_method, 'native_method>(
_env: &mut Env<'unowned_env_local>,
_cap: &mut Self::Captures<'unowned_env_local, 'native_method>,
_err: BridgeError,
) -> jni::errors::Result<jint> {
Ok(-1)
}
fn on_panic<'unowned_env_local: 'native_method, 'native_method>(
_env: &mut Env<'unowned_env_local>,
_cap: &mut Self::Captures<'unowned_env_local, 'native_method>,
_payload: Box<dyn std::any::Any + Send + 'static>,
) -> jni::errors::Result<jint> {
Ok(-1)
}
}
#[derive(Debug, Default)]
struct StringResultPolicy;
impl ErrorPolicy<String, BridgeError> for StringResultPolicy {
type Captures<'unowned_env_local: 'native_method, 'native_method> = ();
fn on_error<'unowned_env_local: 'native_method, 'native_method>(
_env: &mut Env<'unowned_env_local>,
_cap: &mut Self::Captures<'unowned_env_local, 'native_method>,
err: BridgeError,
) -> jni::errors::Result<String> {
Ok(format!("start failed: {err}"))
}
fn on_panic<'unowned_env_local: 'native_method, 'native_method>(
_env: &mut Env<'unowned_env_local>,
_cap: &mut Self::Captures<'unowned_env_local, 'native_method>,
_payload: Box<dyn std::any::Any + Send + 'static>,
) -> jni::errors::Result<String> {
Ok("start failed: panic in native bridge".to_string())
}
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_setEnv<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
name: JString<'local>,
value: JString<'local>,
) -> jint {
let outcome: EnvOutcome<'local, jint, BridgeError> = env.with_env_no_catch(|env| {
let name = name
.try_to_string(env)
.map_err(|err| BridgeError(format!("invalid env name: {err}")))?;
let value = value
.try_to_string(env)
.map_err(|err| BridgeError(format!("invalid env value: {err}")))?;
if name.contains('\0') || value.contains('\0') {
return Err(BridgeError("env contains NUL".to_string()));
}
std::env::set_var(name, value);
Ok(0)
});
outcome.resolve_with::<StatusPolicy, _>(|| ())
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_initTlsVerifier<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
context: JObject<'local>,
) -> jint {
let outcome: EnvOutcome<'local, jint, BridgeError> =
env.with_env_no_catch(|env| init_tls_verifier(env, context));
outcome.resolve_with::<StatusPolicy, _>(|| ())
}
#[cfg(target_os = "android")]
fn init_tls_verifier(env: &mut Env<'_>, context: JObject<'_>) -> Result<jint, BridgeError> {
rustls_platform_verifier::android::init_with_env(env, context)
.map_err(|err| BridgeError(format!("failed to initialize rustls platform verifier: {err}")))?;
Ok(0)
}
#[cfg(not(target_os = "android"))]
fn init_tls_verifier(_env: &mut Env<'_>, _context: JObject<'_>) -> Result<jint, BridgeError> {
Ok(0)
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_startHost<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
data_dir: JString<'local>,
bind_address: JString<'local>,
port: i32,
) -> jstring {
let outcome: EnvOutcome<'local, String, BridgeError> = env.with_env_no_catch(|env| {
let data_dir = data_dir
.try_to_string(env)
.map_err(|err| BridgeError(format!("invalid data dir: {err}")))?;
let bind_address = bind_address
.try_to_string(env)
.map_err(|err| BridgeError(format!("invalid bind address: {err}")))?;
let port = u16::try_from(port).map_err(|_| BridgeError("invalid port".to_string()))?;
android::start(AndroidRuntimeConfig {
data_dir,
bind_address,
port,
})
.map_err(BridgeError)
});
let result = outcome.resolve_with::<StringResultPolicy, _>(|| ());
env.with_env_no_catch(|env| env.new_string(result))
.resolve_with::<ThrowRuntimeExAndDefault, _>(|| ())
.into_raw()
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_stopHost<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
) -> jstring {
env.with_env_no_catch(|env| env.new_string(android::stop()))
.resolve_with::<ThrowRuntimeExAndDefault, _>(|| ())
.into_raw()
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_hostStatus<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
) -> jstring {
env.with_env_no_catch(|env| env.new_string(android::status()))
.resolve_with::<ThrowRuntimeExAndDefault, _>(|| ())
.into_raw()
}
#[no_mangle]
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_kernelVersion<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
) -> jstring {
env.with_env_no_catch(|env| env.new_string(env!("CARGO_PKG_VERSION")))
.resolve_with::<ThrowRuntimeExAndDefault, _>(|| ())
.into_raw()
}

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "OneKvmAndroidHost"
include(":app")

117
build/build-android.sh Normal file
View File

@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Build Android APKs using the Docker build image.
# Usage: ./build/build-android.sh [arm64|armv7|all|help]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DOCKERFILE="$PROJECT_ROOT/build/cross/Dockerfile.android"
IMAGE_NAME="${ONE_KVM_ANDROID_DOCKER_IMAGE:-one-kvm-android-build:cn}"
fail() {
echo "Error: $*" >&2
exit 1
}
build_android() {
local arch="$1"
local docker_build_args=()
local docker_mount_args=()
local gradle_distribution_url="${ONE_KVM_GRADLE_DISTRIBUTION_URL:-}"
local gradle_distribution_url_cn="${ONE_KVM_GRADLE_DISTRIBUTION_URL_CN:-https://mirrors.cloud.tencent.com/gradle/gradle-9.1.0-bin.zip}"
local gradle_network_timeout="${ONE_KVM_GRADLE_NETWORK_TIMEOUT:-120000}"
local gradle_cache="${ONE_KVM_ANDROID_GRADLE_CACHE_DIR:-one-kvm-android-gradle-cache}"
local cargo_registry_cache="${ONE_KVM_ANDROID_CARGO_REGISTRY_CACHE_DIR:-one-kvm-android-cargo-registry}"
local cargo_git_cache="${ONE_KVM_ANDROID_CARGO_GIT_CACHE_DIR:-one-kvm-android-cargo-git}"
add_cache_mount() {
local source="$1"
local target="$2"
if [[ "$source" == /* || "$source" == ./* || "$source" == ../* ]]; then
mkdir -p "$source"
source="$(cd "$source" && pwd)"
fi
docker_mount_args+=("-v" "$source:$target")
}
if [[ "${CHINAMIRRO:-}" == "1" ]]; then
docker_build_args+=("--build-arg" "CHINAMIRRO=1")
if [[ -z "$gradle_distribution_url" ]]; then
gradle_distribution_url="$gradle_distribution_url_cn"
fi
fi
if [[ "${ONE_KVM_ANDROID_SKIP_DOCKER_BUILD:-0}" == "1" ]]; then
echo "=== Skipping Android image build: $IMAGE_NAME ==="
else
echo "=== Building Android image: $IMAGE_NAME ==="
docker build \
-f "$DOCKERFILE" \
-t "$IMAGE_NAME" \
"${docker_build_args[@]}" \
"$PROJECT_ROOT/build/cross"
fi
add_cache_mount "$gradle_cache" "/root/.gradle"
add_cache_mount "$cargo_registry_cache" "/root/.cargo/registry"
add_cache_mount "$cargo_git_cache" "/root/.cargo/git"
echo "=== Building Android APK: $arch ==="
docker run --rm \
-v "$PROJECT_ROOT:/workspace" \
"${docker_mount_args[@]}" \
-w /workspace \
-e "CHINAMIRRO=${CHINAMIRRO:-0}" \
-e "ONE_KVM_GRADLE_DISTRIBUTION_URL=$gradle_distribution_url" \
-e "ONE_KVM_GRADLE_DISTRIBUTION_URL_CN=$gradle_distribution_url_cn" \
-e "ONE_KVM_GRADLE_NETWORK_TIMEOUT=$gradle_network_timeout" \
"$IMAGE_NAME" \
"$arch"
}
[[ -f "$DOCKERFILE" ]] || fail "Android Dockerfile not found: $DOCKERFILE"
command -v docker >/dev/null 2>&1 || fail "docker is required"
case "${1:-all}" in
all)
build_android all
;;
arm64)
build_android arm64
;;
armv7)
build_android armv7
;;
help | --help | -h)
cat <<'EOF'
Usage: build/build-android.sh [arch|help]
Commands:
all (default) Build arm64 and armv7 APKs
arm64 Build only arm64 APK
armv7 Build only ARMv7 APK
help Show this help
Examples:
build/build-android.sh
build/build-android.sh arm64
CHINAMIRRO=1 build/build-android.sh all
CHINAMIRRO=1 ONE_KVM_GRADLE_DISTRIBUTION_URL=https://mirrors.aliyun.com/macports/distfiles/gradle/gradle-9.1.0-bin.zip build/build-android.sh all
Environment:
ONE_KVM_ANDROID_GRADLE_CACHE_DIR Host Gradle cache path or Docker volume name
ONE_KVM_ANDROID_CARGO_REGISTRY_CACHE_DIR Host Cargo registry cache path or Docker volume name
ONE_KVM_ANDROID_CARGO_GIT_CACHE_DIR Host Cargo git cache path or Docker volume name
ONE_KVM_ANDROID_SKIP_DOCKER_BUILD=1 Reuse an already loaded Docker image
APK output:
target/android/one-kvm_<version>_<arm32|arm64>.apk
EOF
;;
*)
fail "Unknown argument: $1"
;;
esac

View File

@@ -19,6 +19,23 @@ ARCH_MAP=(
build_arch() {
local rust_target="$1"
case "${CHINAMIRRO:-}" in
1|true|TRUE|yes|YES|on|ON)
local cross_build_opts="${CROSS_BUILD_OPTS:+$CROSS_BUILD_OPTS }--build-arg CHINAMIRRO=1"
echo "=== China mirror acceleration: enabled (Tsinghua) ==="
echo "=== Building: $rust_target (via cross with custom Dockerfile) ==="
env \
CROSS_BUILD_OPTS="$cross_build_opts" \
CARGO_SOURCE_CRATES_IO_REPLACE_WITH=tuna \
CARGO_SOURCE_TUNA_REGISTRY=sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/ \
CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
RUSTUP_DIST_SERVER=https://mirrors.tuna.tsinghua.edu.cn/rustup \
RUSTUP_UPDATE_ROOT=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup \
cross build --release --target "$rust_target"
return
;;
esac
echo "=== Building: $rust_target (via cross with custom Dockerfile) ==="
cross build --release --target "$rust_target"
}
@@ -49,6 +66,7 @@ case "${1:-all}" in
echo "Examples:"
echo " $0 # Build all"
echo " $0 x86_64 # Build x86_64 only"
echo " CHINAMIRRO=1 $0 arm64 # Build with Tsinghua mirrors"
exit 0
;;
*)

View File

@@ -0,0 +1,309 @@
# Android build image for One-KVM
# Based on Debian 11 for stable toolchain/runtime compatibility
FROM debian:11
ARG CHINAMIRRO=0
ARG ANDROID_SDK_ROOT=/root/android-sdk
ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708_latest
ARG ANDROID_NDK_VERSION=27.3.13750724
ARG ANDROID_PLATFORM=36
ARG ANDROID_BUILD_TOOLS=36.0.0
ARG CARGO_NDK_VERSION=4.1.2
ARG RUSTUP_DIST_SERVER_CN=https://mirrors.tuna.tsinghua.edu.cn/rustup
ARG RUSTUP_UPDATE_ROOT_CN=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup
ARG CARGO_REGISTRY_CN=sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/
ARG MAVEN_REPOSITORY_CN=https://maven.aliyun.com/repository/public
ARG GOOGLE_MAVEN_REPOSITORY_CN=https://maven.aliyun.com/repository/google
ARG GRADLE_DISTRIBUTION_URL_CN=https://mirrors.cloud.tencent.com/gradle/gradle-9.1.0-bin.zip
ARG ANDROID_CMDLINE_TOOLS_URL=
ENV DEBIAN_FRONTEND=noninteractive
ENV ANDROID_HOME=${ANDROID_SDK_ROOT}
ENV ANDROID_SDK_ROOT=${ANDROID_SDK_ROOT}
ENV ANDROID_NDK_HOME=${ANDROID_SDK_ROOT}/ndk/${ANDROID_NDK_VERSION}
ENV ANDROID_NDK_ROOT=${ANDROID_SDK_ROOT}/ndk/${ANDROID_NDK_VERSION}
ENV ANDROID_BUILD_TOOLS=${ANDROID_BUILD_TOOLS}
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
ENV PATH=/root/.cargo/bin:${PATH}
ENV ONE_KVM_GRADLE_DISTRIBUTION_URL_CN=${GRADLE_DISTRIBUTION_URL_CN}
RUN if [ "$CHINAMIRRO" = "1" ]; then \
sed -i \
-e 's|http://deb.debian.org/debian|http://mirrors.tuna.tsinghua.edu.cn/debian|g' \
-e 's|http://security.debian.org/debian-security|http://mirrors.tuna.tsinghua.edu.cn/debian-security|g' \
/etc/apt/sources.list; \
fi
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
wget \
unzip \
zip \
git \
bash \
build-essential \
pkg-config \
cmake \
ninja-build \
autoconf \
automake \
libtool \
nasm \
yasm \
python3 \
openjdk-17-jdk-headless \
libstdc++6 \
&& rm -rf /var/lib/apt/lists/*
RUN if [ "$CHINAMIRRO" = "1" ]; then \
export RUSTUP_DIST_SERVER=${RUSTUP_DIST_SERVER_CN}; \
export RUSTUP_UPDATE_ROOT=${RUSTUP_UPDATE_ROOT_CN}; \
mkdir -p /root/.cargo; \
printf '%s\n' \
'[source.crates-io]' \
"replace-with = 'tuna'" \
'[source.tuna]' \
"registry = '${CARGO_REGISTRY_CN}'" \
'[registries.tuna]' \
"index = '${CARGO_REGISTRY_CN}'" \
> /root/.cargo/config.toml; \
fi \
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable \
&& cargo install cargo-ndk --version ${CARGO_NDK_VERSION} --locked \
&& rustup target add armv7-linux-androideabi aarch64-linux-android
RUN mkdir -p /opt/android-cmdline-tools \
&& cd /tmp \
&& if [ -n "$ANDROID_CMDLINE_TOOLS_URL" ]; then \
wget -q "$ANDROID_CMDLINE_TOOLS_URL" -O cmdline-tools.zip; \
else \
wget -q https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}.zip -O cmdline-tools.zip; \
fi \
&& unzip -q cmdline-tools.zip -d /opt/android-cmdline-tools \
&& mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools/latest \
&& mv /opt/android-cmdline-tools/cmdline-tools/* ${ANDROID_SDK_ROOT}/cmdline-tools/latest/ \
&& rm -rf /tmp/cmdline-tools.zip /opt/android-cmdline-tools
RUN mkdir -p ${ANDROID_SDK_ROOT}/licenses \
&& yes | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --sdk_root=${ANDROID_SDK_ROOT} --licenses >/dev/null \
&& ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --sdk_root=${ANDROID_SDK_ROOT} \
"platform-tools" \
"platforms;android-${ANDROID_PLATFORM}" \
"build-tools;${ANDROID_BUILD_TOOLS}" \
"ndk;${ANDROID_NDK_VERSION}" \
"cmake;3.22.1" \
&& mkdir -p ${ANDROID_NDK_HOME}
RUN if [ "$CHINAMIRRO" = "1" ]; then \
mkdir -p /root/.gradle; \
printf '%s\n' \
"beforeSettings { settings ->" \
" settings.pluginManagement.repositories.maven { url = uri('${GOOGLE_MAVEN_REPOSITORY_CN}') }" \
" settings.pluginManagement.repositories.maven { url = uri('${MAVEN_REPOSITORY_CN}') }" \
" settings.dependencyResolutionManagement.repositories.maven { url = uri('${GOOGLE_MAVEN_REPOSITORY_CN}') }" \
" settings.dependencyResolutionManagement.repositories.maven { url = uri('${MAVEN_REPOSITORY_CN}') }" \
"}" \
"allprojects {" \
" buildscript.repositories.maven { url = uri('${GOOGLE_MAVEN_REPOSITORY_CN}') }" \
" buildscript.repositories.maven { url = uri('${MAVEN_REPOSITORY_CN}') }" \
"}" \
> /root/.gradle/init.gradle; \
fi
RUN apt-get update && apt-get install -y --no-install-recommends \
libclang-dev \
llvm \
&& rm -rf /var/lib/apt/lists/*
ENV LIBCLANG_PATH=/usr/lib/llvm-11/lib
RUN printf '%s\n' \
'#!/usr/bin/env bash' \
'set -euo pipefail' \
'' \
'PROJECT_ROOT="${ONE_KVM_ANDROID_PROJECT_ROOT:-/workspace}"' \
'ANDROID_DIR="${PROJECT_ROOT}/android"' \
'BUILD_TYPE="release"' \
'ARCH="${1:-all}"' \
'FFMPEG_ROOT="${ONE_KVM_ANDROID_FFMPEG_ROOT:-${PROJECT_ROOT}/dist/android-ffmpeg-mediacodec}"' \
'OUTPUT_DIR="${PROJECT_ROOT}/target/android"' \
'SIGNING_DIR="${PROJECT_ROOT}/target/android-signing"' \
'KEYSTORE_PATH="${SIGNING_DIR}/one-kvm-release.jks"' \
'KEY_ALIAS="one-kvm-release"' \
'KEY_PASSWORD="one-kvm-release"' \
'ANDROID_BUILD_TOOLS_DIR="${ANDROID_SDK_ROOT}/build-tools/${ANDROID_BUILD_TOOLS}"' \
'WRAPPER_PROPERTIES="$ANDROID_DIR/gradle/wrapper/gradle-wrapper.properties"' \
'GRADLE_DISTRIBUTION_URL="${ONE_KVM_GRADLE_DISTRIBUTION_URL:-}"' \
'GRADLE_DISTRIBUTION_URL_CN="${ONE_KVM_GRADLE_DISTRIBUTION_URL_CN:-https://mirrors.cloud.tencent.com/gradle/gradle-9.1.0-bin.zip}"' \
'GRADLE_NETWORK_TIMEOUT="${ONE_KVM_GRADLE_NETWORK_TIMEOUT:-120000}"' \
'' \
'usage() {' \
' cat <<EOF' \
'Usage:' \
' docker run --rm -v "$PWD:/workspace" one-kvm-android-build:cn [arm64|armv7|all|help]' \
'' \
'Commands:' \
' all Build arm64 and armv7 APKs. Default.' \
' arm64 Build only arm64 APK.' \
' armv7 Build only ARMv7 APK.' \
' help Show this help.' \
'' \
'APK output:' \
' target/android/one-kvm_<version>_<arm32|arm64>.apk' \
'EOF' \
'}' \
'' \
'fail() {' \
' echo "Error: $*" >&2' \
' exit 1' \
'}' \
'' \
'read_project_version() {' \
' local version' \
' version="$(awk -F "\"" '"'"'/^version[[:space:]]*=/ { print $2; exit }'"'"' "$PROJECT_ROOT/Cargo.toml")"' \
' [[ -n "$version" ]] || fail "Failed to resolve version from $PROJECT_ROOT/Cargo.toml"' \
' printf "%s\n" "$version"' \
'}' \
'' \
'copy_apks() {' \
' local flavor="$1"' \
' local src_dir="$ANDROID_DIR/app/build/outputs/apk/$flavor/$BUILD_TYPE"' \
' local found=0' \
' mkdir -p "$OUTPUT_DIR"' \
' for apk in "$src_dir"/*.apk; do' \
' [[ -f "$apk" ]] || continue' \
' sign_apk "$apk" "$OUTPUT_DIR/one-kvm_${PROJECT_VERSION}_${flavor}.apk"' \
' found=1' \
' done' \
' [[ "$found" == "1" ]] || fail "No APK files found in: $src_dir"' \
'}' \
'' \
'ensure_keystore() {' \
' if [[ -f "$KEYSTORE_PATH" ]]; then' \
' return' \
' fi' \
' mkdir -p "$SIGNING_DIR"' \
' keytool -genkeypair -noprompt -keystore "$KEYSTORE_PATH" -storetype PKCS12 -alias "$KEY_ALIAS" -keyalg RSA -keysize 2048 -validity 10000 -storepass "$KEY_PASSWORD" -keypass "$KEY_PASSWORD" -dname "CN=One-KVM, OU=One-KVM, O=One-KVM, L=Local, S=Local, C=US" >/dev/null' \
'}' \
'' \
'sign_apk() {' \
' local input_apk="$1"' \
' local output_apk="$2"' \
' local aligned_apk' \
' aligned_apk="$(mktemp --suffix=.apk)"' \
' "$ANDROID_BUILD_TOOLS_DIR/zipalign" -f -p 4 "$input_apk" "$aligned_apk"' \
' "$ANDROID_BUILD_TOOLS_DIR/apksigner" sign --ks "$KEYSTORE_PATH" --ks-key-alias "$KEY_ALIAS" --ks-pass "pass:$KEY_PASSWORD" --key-pass "pass:$KEY_PASSWORD" --out "$output_apk" "$aligned_apk"' \
' "$ANDROID_BUILD_TOOLS_DIR/apksigner" verify --verbose "$output_apk" >/dev/null' \
' rm -f "$aligned_apk"' \
'}' \
'' \
'cd "$PROJECT_ROOT"' \
'' \
'case "$ARCH" in' \
'help | --help | -h)' \
' usage' \
' exit 0' \
' ;;' \
'esac' \
'' \
'[[ -d "$ANDROID_DIR" ]] || fail "Android project not found: $ANDROID_DIR"' \
'[[ -x "$ANDROID_DIR/gradlew" ]] || fail "Gradle wrapper is not executable: $ANDROID_DIR/gradlew"' \
'[[ -f "$WRAPPER_PROPERTIES" ]] || fail "Gradle wrapper properties not found: $WRAPPER_PROPERTIES"' \
'' \
'ORIGINAL_WRAPPER_PROPERTIES="$(mktemp)"' \
'cp "$WRAPPER_PROPERTIES" "$ORIGINAL_WRAPPER_PROPERTIES"' \
'cleanup_wrapper_properties() {' \
' cp "$ORIGINAL_WRAPPER_PROPERTIES" "$WRAPPER_PROPERTIES"' \
' rm -f "$ORIGINAL_WRAPPER_PROPERTIES"' \
'}' \
'trap cleanup_wrapper_properties EXIT' \
'' \
'if [[ "${CHINAMIRRO:-0}" == "1" && -z "$GRADLE_DISTRIBUTION_URL" ]]; then' \
' GRADLE_DISTRIBUTION_URL="$GRADLE_DISTRIBUTION_URL_CN"' \
'fi' \
'' \
'if [[ -n "$GRADLE_DISTRIBUTION_URL" ]]; then' \
' WRAPPER_PROPERTIES_TMP="$(mktemp)"' \
' awk -v url="$GRADLE_DISTRIBUTION_URL" -v timeout="$GRADLE_NETWORK_TIMEOUT" '"'"'' \
' BEGIN { seen_url = 0; seen_timeout = 0 }' \
' /^distributionUrl=/ { print "distributionUrl=" url; seen_url = 1; next }' \
' /^networkTimeout=/ { print "networkTimeout=" timeout; seen_timeout = 1; next }' \
' { print }' \
' END {' \
' if (!seen_url) print "distributionUrl=" url;' \
' if (!seen_timeout) print "networkTimeout=" timeout;' \
' }' \
' '"'"' "$WRAPPER_PROPERTIES" > "$WRAPPER_PROPERTIES_TMP"' \
' cp "$WRAPPER_PROPERTIES_TMP" "$WRAPPER_PROPERTIES"' \
' rm -f "$WRAPPER_PROPERTIES_TMP"' \
' find /root/.gradle/wrapper/dists -name "*.lck" -o -name "*.part" 2>/dev/null | xargs -r rm -f' \
'fi' \
'' \
'ensure_keystore' \
'' \
'case "$ARCH" in' \
'arm64)' \
' ANDROID_ABIS="arm64-v8a"' \
' GRADLE_TASK=":app:assembleArm64Release"' \
' APK_FLAVORS="arm64"' \
' ;;' \
'armv7)' \
' ANDROID_ABIS="armeabi-v7a"' \
' GRADLE_TASK=":app:assembleArm32Release"' \
' APK_FLAVORS="arm32"' \
' ;;' \
'all)' \
' ANDROID_ABIS="arm64-v8a,armeabi-v7a"' \
' GRADLE_TASK=":app:assembleRelease"' \
' APK_FLAVORS="arm64 arm32"' \
' ;;' \
'*) fail "Unsupported architecture: $ARCH (expected arm64, armv7, or all)" ;;' \
'esac' \
'' \
'printf "sdk.dir=%s\n" "$ANDROID_HOME" > "$ANDROID_DIR/local.properties"' \
'mkdir -p "$OUTPUT_DIR"' \
'PROJECT_VERSION="$(read_project_version)"' \
'' \
'export ONE_KVM_ANDROID_PROFILE="$BUILD_TYPE"' \
'export ONE_KVM_ANDROID_ABIS="$ANDROID_ABIS"' \
'export ONE_KVM_ANDROID_FFMPEG_ROOT="$FFMPEG_ROOT"' \
'export ANDROID_HOME' \
'export ANDROID_SDK_ROOT' \
'export ANDROID_NDK_HOME' \
'export ANDROID_NDK_ROOT' \
'' \
'echo "Building Android APK"' \
'echo " task: $GRADLE_TASK"' \
'echo " profile: $ONE_KVM_ANDROID_PROFILE"' \
'echo " version: $PROJECT_VERSION"' \
'echo " abis: $ONE_KVM_ANDROID_ABIS"' \
'echo " output: $OUTPUT_DIR"' \
'echo " sdk: $ANDROID_HOME"' \
'echo " ndk: $ANDROID_NDK_HOME"' \
'echo " build tools: $ANDROID_BUILD_TOOLS_DIR"' \
'echo " ffmpeg root: $ONE_KVM_ANDROID_FFMPEG_ROOT"' \
'if [[ -n "$GRADLE_DISTRIBUTION_URL" ]]; then' \
' echo " gradle distribution: $GRADLE_DISTRIBUTION_URL"' \
'fi' \
'' \
'(' \
' cd "$ANDROID_DIR"' \
' ./gradlew "$GRADLE_TASK"' \
')' \
'' \
'for flavor in $APK_FLAVORS; do' \
' copy_apks "$flavor"' \
'done' \
'' \
'echo' \
'echo "APK output:"' \
'ls -1 "$OUTPUT_DIR"' \
> /usr/local/bin/build-one-kvm-android \
&& chmod +x /usr/local/bin/build-one-kvm-android
WORKDIR /workspace
ENTRYPOINT ["/usr/local/bin/build-one-kvm-android"]
CMD ["all"]

View File

@@ -6,16 +6,36 @@ FROM debian:11
# Linux headers used by v4l2r bindgen
ARG LINUX_HEADERS_VERSION=6.6
ARG LINUX_HEADERS_SHA256=
ARG CHINAMIRRO=0
# Set Rustup mirrors (Aliyun)
#ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \
# RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup
# Optionally use Tsinghua mirrors for builds in China.
RUN if [ "$CHINAMIRRO" = "1" ]; then \
sed -i \
-e 's|http://deb.debian.org/debian|http://mirrors.tuna.tsinghua.edu.cn/debian|g' \
-e 's|http://security.debian.org/debian-security|http://mirrors.tuna.tsinghua.edu.cn/debian-security|g' \
/etc/apt/sources.list; \
fi
# Install Rust toolchain
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
&& if [ "$CHINAMIRRO" = "1" ]; then \
export RUSTUP_DIST_SERVER=https://mirrors.tuna.tsinghua.edu.cn/rustup; \
export RUSTUP_UPDATE_ROOT=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup; \
fi \
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable \
&& if [ "$CHINAMIRRO" = "1" ]; then \
mkdir -p /root/.cargo; \
printf '%s\n' \
'[source.crates-io]' \
"replace-with = 'tuna'" \
'[source.tuna]' \
'registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"' \
'[registries.tuna]' \
'index = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"' \
> /root/.cargo/config.toml; \
fi \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="/root/.cargo/bin:${PATH}"
@@ -75,6 +95,7 @@ RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libj
&& mkdir build && cd build \
&& cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DCMAKE_INSTALL_PREFIX=/usr/aarch64-linux-gnu \
-DCMAKE_INSTALL_LIBDIR=lib \
-DCMAKE_SYSTEM_NAME=Linux \
-DCMAKE_SYSTEM_PROCESSOR=aarch64 \
-DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc \
@@ -93,6 +114,11 @@ RUN git clone --depth 1 https://github.com/lemenkov/libyuv /tmp/libyuv \
-DCMAKE_SYSTEM_PROCESSOR=aarch64 \
-DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc \
-DCMAKE_CXX_COMPILER=aarch64-linux-gnu-g++ \
-DJPEG_FOUND=TRUE \
-DJPEG_INCLUDE_DIR=/usr/aarch64-linux-gnu/include \
-DJPEG_LIBRARY=/usr/aarch64-linux-gnu/lib/libjpeg.a \
-DCMAKE_C_FLAGS="-DHAVE_JPEG -I/usr/aarch64-linux-gnu/include" \
-DCMAKE_CXX_FLAGS="-DHAVE_JPEG -I/usr/aarch64-linux-gnu/include" \
&& make -j$(nproc) \
&& make install \
&& rm -rf /tmp/libyuv
@@ -327,7 +353,11 @@ RUN mkdir -p /tmp/ffmpeg-build && cd /tmp/ffmpeg-build \
&& rm -rf /tmp/ffmpeg-build /tmp/aarch64-cross.txt /tmp/aarch64-pkg-config
# Add Rust target
RUN rustup target add aarch64-unknown-linux-gnu
RUN if [ "$CHINAMIRRO" = "1" ]; then \
export RUSTUP_DIST_SERVER=https://mirrors.tuna.tsinghua.edu.cn/rustup; \
export RUSTUP_UPDATE_ROOT=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup; \
fi \
&& rustup target add aarch64-unknown-linux-gnu
# Configure environment for cross-compilation
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \

View File

@@ -6,16 +6,36 @@ FROM debian:11
# Linux headers used by v4l2r bindgen
ARG LINUX_HEADERS_VERSION=6.6
ARG LINUX_HEADERS_SHA256=
ARG CHINAMIRRO=0
# Set Rustup mirrors (Aliyun)
#ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \
# RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup
# Optionally use Tsinghua mirrors for builds in China.
RUN if [ "$CHINAMIRRO" = "1" ]; then \
sed -i \
-e 's|http://deb.debian.org/debian|http://mirrors.tuna.tsinghua.edu.cn/debian|g' \
-e 's|http://security.debian.org/debian-security|http://mirrors.tuna.tsinghua.edu.cn/debian-security|g' \
/etc/apt/sources.list; \
fi
# Install Rust toolchain
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
&& if [ "$CHINAMIRRO" = "1" ]; then \
export RUSTUP_DIST_SERVER=https://mirrors.tuna.tsinghua.edu.cn/rustup; \
export RUSTUP_UPDATE_ROOT=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup; \
fi \
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable \
&& if [ "$CHINAMIRRO" = "1" ]; then \
mkdir -p /root/.cargo; \
printf '%s\n' \
'[source.crates-io]' \
"replace-with = 'tuna'" \
'[source.tuna]' \
'registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"' \
'[registries.tuna]' \
'index = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"' \
> /root/.cargo/config.toml; \
fi \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="/root/.cargo/bin:${PATH}"
@@ -74,6 +94,7 @@ RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libj
&& mkdir build && cd build \
&& cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DCMAKE_INSTALL_PREFIX=/usr/arm-linux-gnueabihf \
-DCMAKE_INSTALL_LIBDIR=lib \
-DCMAKE_SYSTEM_NAME=Linux \
-DCMAKE_SYSTEM_PROCESSOR=arm \
-DCMAKE_C_COMPILER=arm-linux-gnueabihf-gcc \
@@ -92,6 +113,11 @@ RUN git clone --depth 1 https://github.com/lemenkov/libyuv /tmp/libyuv \
-DCMAKE_SYSTEM_PROCESSOR=arm \
-DCMAKE_C_COMPILER=arm-linux-gnueabihf-gcc \
-DCMAKE_CXX_COMPILER=arm-linux-gnueabihf-g++ \
-DJPEG_FOUND=TRUE \
-DJPEG_INCLUDE_DIR=/usr/arm-linux-gnueabihf/include \
-DJPEG_LIBRARY=/usr/arm-linux-gnueabihf/lib/libjpeg.a \
-DCMAKE_C_FLAGS="-DHAVE_JPEG -I/usr/arm-linux-gnueabihf/include" \
-DCMAKE_CXX_FLAGS="-DHAVE_JPEG -I/usr/arm-linux-gnueabihf/include" \
&& make -j$(nproc) \
&& make install \
&& rm -rf /tmp/libyuv
@@ -316,7 +342,11 @@ RUN mkdir -p /tmp/ffmpeg-build && cd /tmp/ffmpeg-build \
&& rm -rf /tmp/ffmpeg-build /tmp/armhf-cross.txt /tmp/armhf-pkg-config
# Add Rust target
RUN rustup target add armv7-unknown-linux-gnueabihf
RUN if [ "$CHINAMIRRO" = "1" ]; then \
export RUSTUP_DIST_SERVER=https://mirrors.tuna.tsinghua.edu.cn/rustup; \
export RUSTUP_UPDATE_ROOT=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup; \
fi \
&& rustup target add armv7-unknown-linux-gnueabihf
# Configure environment for cross-compilation
ENV CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc \

View File

@@ -6,16 +6,36 @@ FROM debian:11
# Linux headers used by v4l2r bindgen
ARG LINUX_HEADERS_VERSION=6.6
ARG LINUX_HEADERS_SHA256=
ARG CHINAMIRRO=0
# Set Rustup mirrors (Aliyun)
#ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \
# RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup
# Optionally use Tsinghua mirrors for builds in China.
RUN if [ "$CHINAMIRRO" = "1" ]; then \
sed -i \
-e 's|http://deb.debian.org/debian|http://mirrors.tuna.tsinghua.edu.cn/debian|g' \
-e 's|http://security.debian.org/debian-security|http://mirrors.tuna.tsinghua.edu.cn/debian-security|g' \
/etc/apt/sources.list; \
fi
# Install Rust toolchain
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
&& if [ "$CHINAMIRRO" = "1" ]; then \
export RUSTUP_DIST_SERVER=https://mirrors.tuna.tsinghua.edu.cn/rustup; \
export RUSTUP_UPDATE_ROOT=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup; \
fi \
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable \
&& if [ "$CHINAMIRRO" = "1" ]; then \
mkdir -p /root/.cargo; \
printf '%s\n' \
'[source.crates-io]' \
"replace-with = 'tuna'" \
'[source.tuna]' \
'registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"' \
'[registries.tuna]' \
'index = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"' \
> /root/.cargo/config.toml; \
fi \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="/root/.cargo/bin:${PATH}"
@@ -72,6 +92,8 @@ RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libj
&& cd /tmp/libjpeg-turbo \
&& mkdir build && cd build \
&& cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DCMAKE_INSTALL_PREFIX=/usr/local \
-DCMAKE_INSTALL_LIBDIR=lib \
-DENABLE_SHARED=OFF -DENABLE_STATIC=ON \
&& make -j$(nproc) \
&& make install \
@@ -82,6 +104,11 @@ RUN git clone --depth 1 https://github.com/lemenkov/libyuv /tmp/libyuv \
&& cd /tmp/libyuv \
&& mkdir build && cd build \
&& cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DJPEG_FOUND=TRUE \
-DJPEG_INCLUDE_DIR=/usr/local/include \
-DJPEG_LIBRARY=/usr/local/lib/libjpeg.a \
-DCMAKE_C_FLAGS="-DHAVE_JPEG -I/usr/local/include" \
-DCMAKE_CXX_FLAGS="-DHAVE_JPEG -I/usr/local/include" \
&& make -j$(nproc) \
&& make install \
&& rm -rf /tmp/libyuv
@@ -221,7 +248,11 @@ RUN mkdir -p /tmp/ffmpeg-build && cd /tmp/ffmpeg-build \
&& rm -rf /tmp/ffmpeg-build
# Add Rust target
RUN rustup target add x86_64-unknown-linux-gnu
RUN if [ "$CHINAMIRRO" = "1" ]; then \
export RUSTUP_DIST_SERVER=https://mirrors.tuna.tsinghua.edu.cn/rustup; \
export RUSTUP_UPDATE_ROOT=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup; \
fi \
&& rustup target add x86_64-unknown-linux-gnu
# Configure environment for static linking
ENV PKG_CONFIG_ALLOW_CROSS=1\

87
build/windows/build.ps1 Normal file
View File

@@ -0,0 +1,87 @@
param(
[string]$Configuration = "debug",
[string]$Target = "x86_64-pc-windows-msvc",
[string]$Triplet = "x64-windows-static",
[string]$VcpkgRoot = $env:VCPKG_ROOT,
[string]$VcpkgInstalledRoot = $env:VCPKG_INSTALLED_DIR,
[switch]$NoDefaultFeatures,
[string[]]$Features = @(),
[switch]$Package,
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$CargoArgs = @()
)
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..")
Set-Location $repoRoot
if ([string]::IsNullOrWhiteSpace($VcpkgRoot)) {
$VcpkgRoot = Join-Path (Split-Path $repoRoot -Parent) "vcpkg"
}
$VcpkgRoot = [System.IO.Path]::GetFullPath($VcpkgRoot)
if ([string]::IsNullOrWhiteSpace($VcpkgInstalledRoot)) {
$VcpkgInstalledRoot = Join-Path $VcpkgRoot "installed"
}
$VcpkgInstalledRoot = [System.IO.Path]::GetFullPath($VcpkgInstalledRoot)
$vcpkgTripletRoot = Join-Path $VcpkgInstalledRoot $Triplet
$turbojpegLibDir = Join-Path $vcpkgTripletRoot "lib"
$turbojpegIncludeDir = Join-Path $vcpkgTripletRoot "include"
if (-not (Test-Path $VcpkgRoot)) {
throw "VCPKG_ROOT does not exist: $VcpkgRoot. Run build/windows/bootstrap-vcpkg.ps1 first."
}
if (-not (Test-Path $turbojpegLibDir) -or -not (Test-Path $turbojpegIncludeDir)) {
throw "vcpkg triplet is not installed at $vcpkgTripletRoot. Run build/windows/bootstrap-vcpkg.ps1 first."
}
$env:VCPKG_ROOT = $VcpkgRoot
$env:VCPKG_DEFAULT_TRIPLET = $Triplet
$env:VCPKG_INSTALLED_DIR = $VcpkgInstalledRoot
$env:TURBOJPEG_SOURCE = "explicit"
$env:TURBOJPEG_LIB_DIR = $turbojpegLibDir
$env:TURBOJPEG_INCLUDE_DIR = $turbojpegIncludeDir
$cargoCommand = @("build", "--target", $Target)
if ($Configuration -eq "release") {
$cargoCommand += "--release"
} elseif ($Configuration -ne "debug") {
throw "Unsupported configuration '$Configuration'. Use 'debug' or 'release'."
}
if ($NoDefaultFeatures) {
$cargoCommand += "--no-default-features"
}
if ($Features.Count -gt 0) {
$cargoCommand += "--features"
$cargoCommand += ($Features -join ",")
}
$cargoCommand += $CargoArgs
cargo @cargoCommand
if ($Package) {
$metadata = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json
$packageInfo = $metadata.packages | Where-Object { $_.name -eq "one-kvm" } | Select-Object -First 1
if ($null -eq $packageInfo -or [string]::IsNullOrWhiteSpace($packageInfo.version)) {
throw "Failed to resolve version from Cargo metadata"
}
$sourcePath = Join-Path $repoRoot "target/$Target/release/one-kvm.exe"
$targetName = "one-kvm_{0}_amd64.exe" -f $packageInfo.version
$targetPath = Join-Path $repoRoot "target/$Target/release/$targetName"
if (-not (Test-Path $sourcePath)) {
throw "Windows binary not found: $sourcePath"
}
Copy-Item $sourcePath $targetPath
Write-Host $targetPath
}

View File

@@ -9,14 +9,16 @@ ignored = ["serde"]
[features]
default = []
bytes = ["dep:bytes"]
rkmpp = []
[dependencies]
log = "0.4"
bytes = { version = "1", optional = true }
serde_derive = "1.0"
serde = "1.0"
serde_json = "1.0"
[build-dependencies]
cc = "1.0"
bindgen = "0.59"
bindgen = "0.70.1"

View File

@@ -21,11 +21,16 @@ fn build_common(builder: &mut Build) {
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
let common_dir = manifest_dir.join("cpp").join("common");
bindgen::builder()
let mut bindings = bindgen::builder()
.header(common_dir.join("common.h").to_string_lossy().to_string())
.header(common_dir.join("callback.h").to_string_lossy().to_string())
.rustified_enum("*")
.parse_callbacks(Box::new(CommonCallbacks))
.rustified_enum(".*")
.parse_callbacks(Box::new(CommonCallbacks));
if target_os == "android" {
print_android_bindgen_env();
bindings = bindings.clang_args(android_clang_args());
}
bindings
.generate()
.unwrap()
.write_to_file(Path::new(&env::var_os("OUT_DIR").unwrap()).join("common_ffi.rs"))
@@ -34,7 +39,9 @@ fn build_common(builder: &mut Build) {
// system
#[cfg(windows)]
{
["d3d11", "dxgi"].map(|lib| println!("cargo:rustc-link-lib={}", lib));
for lib in ["d3d11", "dxgi"] {
println!("cargo:rustc-link-lib={}", lib);
}
}
builder.include(&common_dir);
@@ -55,9 +62,9 @@ fn build_common(builder: &mut Build) {
}
// Unsupported platforms
if target_os != "windows" && target_os != "linux" {
if target_os != "windows" && target_os != "linux" && target_os != "android" {
panic!(
"Unsupported OS: {}. Only Windows and Linux are supported.",
"Unsupported OS: {}. Only Windows, Linux, and Android are supported.",
target_os
);
}
@@ -69,9 +76,9 @@ fn build_common(builder: &mut Build) {
#[derive(Debug)]
struct CommonCallbacks;
impl bindgen::callbacks::ParseCallbacks for CommonCallbacks {
fn add_derives(&self, name: &str) -> Vec<String> {
fn add_derives(&self, info: &bindgen::callbacks::DeriveInfo<'_>) -> Vec<String> {
let names = vec!["DataFormat", "SurfaceFormat", "API"];
if names.contains(&name) {
if names.contains(&info.name) {
vec!["Serialize", "Deserialize"]
.drain(..)
.map(|s| s.to_string())
@@ -82,15 +89,126 @@ impl bindgen::callbacks::ParseCallbacks for CommonCallbacks {
}
}
fn print_android_bindgen_env() {
println!("cargo:rerun-if-env-changed=ANDROID_NDK_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_NDK_ROOT");
println!("cargo:rerun-if-env-changed=NDK_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_SDK_ROOT");
println!("cargo:rerun-if-env-changed=CARGO_NDK_PLATFORM");
}
fn android_clang_args() -> Vec<String> {
let ndk = android_ndk_home();
let target = env::var("TARGET").unwrap_or_default();
let toolchain = ndk.join("toolchains/llvm/prebuilt").join(host_tag());
let sysroot = toolchain.join("sysroot");
let clang_include = toolchain
.join("lib/clang")
.join(clang_version(&toolchain))
.join("include");
let api = env::var("CARGO_NDK_PLATFORM")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(21);
let clang_target = android_clang_target(&target);
vec![
format!("--target={clang_target}"),
format!("--sysroot={}", sysroot.display()),
format!("-D__ANDROID_API__={api}"),
format!("-isystem{}", clang_include.display()),
format!("-isystem{}", sysroot.join("usr/include").display()),
format!(
"-isystem{}",
sysroot.join("usr/include").join(clang_target).display()
),
]
}
fn android_clang_target(target: &str) -> &'static str {
match target {
"aarch64-linux-android" => "aarch64-linux-android",
"armv7-linux-androideabi" => "armv7a-linux-androideabi",
"i686-linux-android" => "i686-linux-android",
"x86_64-linux-android" => "x86_64-linux-android",
other => panic!("unsupported Android target for hwcodec bindgen: {other}"),
}
}
fn android_ndk_home() -> PathBuf {
for key in ["ANDROID_NDK_HOME", "ANDROID_NDK_ROOT", "NDK_HOME"] {
if let Ok(value) = env::var(key) {
return PathBuf::from(value);
}
}
for key in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
if let Ok(value) = env::var(key) {
let ndk_dir = PathBuf::from(value).join("ndk");
if let Some(newest) = newest_child_dir(&ndk_dir) {
return newest;
}
}
}
panic!(
"hwcodec Android bindgen requires ANDROID_NDK_HOME, ANDROID_NDK_ROOT, NDK_HOME, \
or ANDROID_HOME/ANDROID_SDK_ROOT with an ndk directory"
);
}
fn newest_child_dir(path: &Path) -> Option<PathBuf> {
let mut entries = std::fs::read_dir(path)
.ok()?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>();
entries.sort();
entries.pop()
}
fn host_tag() -> &'static str {
if cfg!(target_os = "linux") {
"linux-x86_64"
} else if cfg!(target_os = "macos") {
"darwin-x86_64"
} else if cfg!(target_os = "windows") {
"windows-x86_64"
} else {
panic!("unsupported host OS for Android NDK");
}
}
fn clang_version(toolchain: &Path) -> String {
let clang_dir = toolchain.join("lib/clang");
let mut entries = std::fs::read_dir(&clang_dir)
.unwrap_or_else(|_| panic!("missing NDK clang directory: {}", clang_dir.display()))
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
entries.sort();
entries
.pop()
.unwrap_or_else(|| panic!("no clang versions found under: {}", clang_dir.display()))
}
mod ffmpeg {
use super::*;
pub fn build_ffmpeg(builder: &mut Build) {
ffmpeg_ffi();
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") {
link_android_ffmpeg(builder);
build_ffmpeg_ram(builder);
return;
}
// Try VCPKG first, fallback to system FFmpeg via pkg-config
if let Ok(vcpkg_root) = std::env::var("VCPKG_ROOT") {
link_vcpkg(builder, vcpkg_root.into());
if let Some(vcpkg_installed) = vcpkg_installed_root() {
link_vcpkg(builder, vcpkg_installed);
} else {
// Use system FFmpeg via pkg-config
link_system_ffmpeg(builder);
@@ -99,6 +217,84 @@ mod ffmpeg {
link_os();
build_ffmpeg_ram(builder);
build_ffmpeg_hw(builder);
build_ffmpeg_capture(builder);
}
fn link_android_ffmpeg(builder: &mut Build) {
let root = std::env::var("ONE_KVM_ANDROID_FFMPEG_ROOT").unwrap_or_else(|_| {
panic!(
"ONE_KVM_ANDROID_FFMPEG_ROOT is required when building hwcodec for Android. \
It must point to an FFmpeg Android build with MediaCodec enabled."
)
});
let root = PathBuf::from(root);
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let abi = match target_arch.as_str() {
"aarch64" => "arm64-v8a",
"arm" => "armeabi-v7a",
"x86" => "x86",
"x86_64" => "x86_64",
_ => target_arch.as_str(),
};
let abi_root = root.join(abi);
let lib_dir = if abi_root.join("lib").exists() {
abi_root.join("lib")
} else {
root.join("lib")
};
let include_dir = if abi_root.join("include").exists() {
abi_root.join("include")
} else {
root.join("include")
};
if !include_dir.exists() || !lib_dir.exists() {
panic!(
"Invalid ONE_KVM_ANDROID_FFMPEG_ROOT: include/lib not found for ABI {} under {}",
abi,
root.display()
);
}
println!("cargo:rustc-link-search=native={}", lib_dir.display());
builder.include(&include_dir);
let use_static = std::env::var("ONE_KVM_ANDROID_FFMPEG_STATIC")
.map(|value| value != "0")
.unwrap_or(true);
for lib in ["avcodec", "avutil"] {
if use_static {
println!("cargo:rustc-link-lib=static={}", lib);
} else {
println!("cargo:rustc-link-lib={}", lib);
}
}
println!("cargo:rustc-link-lib=log");
println!("cargo:rustc-link-lib=mediandk");
println!("cargo:rustc-link-lib=android");
println!("cargo:rustc-link-lib=dl");
println!("cargo:rustc-link-lib=m");
println!("cargo:rustc-link-lib=z");
println!("cargo:rustc-link-lib=c++_shared");
println!("cargo:info=Using Android FFmpeg from {}", root.display());
}
fn vcpkg_installed_root() -> Option<PathBuf> {
println!("cargo:rerun-if-env-changed=VCPKG_INSTALLED_DIR");
println!("cargo:rerun-if-env-changed=VCPKG_ROOT");
if let Ok(path) = std::env::var("VCPKG_INSTALLED_DIR") {
if !path.trim().is_empty() {
return Some(PathBuf::from(path));
}
}
std::env::var("VCPKG_ROOT")
.ok()
.filter(|path| !path.trim().is_empty())
.map(|path| PathBuf::from(path).join("installed"))
}
/// Link system FFmpeg using pkg-config or custom path
@@ -271,7 +467,6 @@ mod ffmpeg {
target = target.replace("x64", "x86");
}
println!("cargo:info={}", target);
path.push("installed");
path.push(target);
println!(
@@ -282,15 +477,26 @@ mod ffmpeg {
)
);
{
// Only need avcodec and avutil for encoding
// avdevice/avformat are needed by the Windows DirectShow capture bridge.
let mut static_libs = vec!["avcodec", "avutil"];
if target_os == "windows" {
static_libs.push("libmfx");
static_libs.extend([
"avformat",
"avdevice",
"avfilter",
"swresample",
"swscale",
"vpx",
"libx264",
"x265-static",
]);
}
for lib in static_libs {
println!("cargo:rustc-link-lib=static={}", lib);
}
if target_os == "windows" {
link_windows_qsv_lib(&path.join("lib"));
}
static_libs
.iter()
.map(|lib| println!("cargo:rustc-link-lib=static={}", lib))
.count();
}
let include = path.join("include");
@@ -299,12 +505,28 @@ mod ffmpeg {
include
}
fn link_windows_qsv_lib(lib_dir: &Path) {
if lib_dir.join("libmfx.lib").exists() {
println!("cargo:rustc-link-lib=static=libmfx");
println!("cargo:info=Using Windows QSV support library libmfx.lib");
return;
}
println!(
"cargo:warning=Windows QSV support library not found in {}",
lib_dir.display()
);
}
fn link_os() {
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap();
let dyn_libs: Vec<&str> = if target_os == "windows" {
["User32", "bcrypt", "ole32", "advapi32"].to_vec()
[
"User32", "bcrypt", "ole32", "advapi32", "mfuuid", "strmiids",
]
.to_vec()
} else if target_os == "linux" {
// Base libraries for all Linux platforms
let mut v = vec!["drm", "stdc++"];
@@ -316,9 +538,11 @@ mod ffmpeg {
}
// ARM (aarch64, arm): no X11 needed, uses RKMPP/V4L2
v
} else if target_os == "android" {
Vec::new()
} else {
panic!(
"Unsupported OS: {}. Only Windows and Linux are supported.",
"Unsupported OS: {}. Only Windows, Linux, and Android are supported.",
target_os
);
};
@@ -334,9 +558,14 @@ mod ffmpeg {
let ffi_header_path = ffmpeg_ram_dir.join("ffmpeg_ffi.h");
println!("cargo:rerun-if-changed={}", ffi_header_path.display());
let ffi_header = ffi_header_path.to_string_lossy().to_string();
bindgen::builder()
let mut bindings = bindgen::builder()
.header(ffi_header)
.rustified_enum("*")
.rustified_enum(".*");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") {
print_android_bindgen_env();
bindings = bindings.clang_args(android_clang_args());
}
bindings
.generate()
.unwrap()
.write_to_file(Path::new(&env::var_os("OUT_DIR").unwrap()).join("ffmpeg_ffi.rs"))
@@ -350,9 +579,14 @@ mod ffmpeg {
.join("ffmpeg_ram_ffi.h")
.to_string_lossy()
.to_string();
bindgen::builder()
let mut bindings = bindgen::builder()
.header(ffi_header)
.rustified_enum("*")
.rustified_enum(".*");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") {
print_android_bindgen_env();
bindings = bindings.clang_args(android_clang_args());
}
bindings
.generate()
.unwrap()
.write_to_file(Path::new(&env::var_os("OUT_DIR").unwrap()).join("ffmpeg_ram_ffi.rs"))
@@ -363,7 +597,9 @@ mod ffmpeg {
// RKMPP decode only exists on ARM builds where FFmpeg is compiled with RKMPP support.
// Avoid compiling this file on x86/x64 where `AV_HWDEVICE_TYPE_RKMPP` doesn't exist.
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let enable_rkmpp = matches!(target_arch.as_str(), "aarch64" | "arm")
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
let enable_rkmpp = target_os != "android"
&& matches!(target_arch.as_str(), "aarch64" | "arm")
|| std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
if enable_rkmpp {
builder.file(ffmpeg_ram_dir.join("ffmpeg_ram_decode.cpp"));
@@ -375,6 +611,34 @@ mod ffmpeg {
}
}
fn build_ffmpeg_capture(builder: &mut Build) {
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os != "windows" {
return;
}
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let capture_header = manifest_dir
.join("cpp")
.join("ffmpeg_capture_ffi.h")
.to_string_lossy()
.to_string();
bindgen::builder()
.header(capture_header)
.rustified_enum(".*")
.generate()
.unwrap()
.write_to_file(
Path::new(&env::var_os("OUT_DIR").unwrap()).join("ffmpeg_capture_ffi.rs"),
)
.unwrap();
builder.file(manifest_dir.join("cpp").join("ffmpeg_capture.cpp"));
println!("cargo:rustc-link-lib=strmiids");
println!("cargo:rustc-link-lib=oleaut32");
println!("cargo:rustc-link-lib=quartz");
}
fn build_ffmpeg_hw(builder: &mut Build) {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let ffmpeg_hw_dir = manifest_dir.join("cpp").join("ffmpeg_hw");
@@ -384,14 +648,16 @@ mod ffmpeg {
.to_string();
bindgen::builder()
.header(ffi_header)
.rustified_enum("*")
.rustified_enum(".*")
.generate()
.unwrap()
.write_to_file(Path::new(&env::var_os("OUT_DIR").unwrap()).join("ffmpeg_hw_ffi.rs"))
.unwrap();
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let enable_rkmpp = matches!(target_arch.as_str(), "aarch64" | "arm")
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
let enable_rkmpp = target_os != "android"
&& matches!(target_arch.as_str(), "aarch64" | "arm")
|| std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
if enable_rkmpp {
// Include RGA headers for NV16->NV12 conversion (RGA im2d API)

View File

@@ -122,12 +122,12 @@ int linux_support_v4l2m2m() {
if (!file.is_open()) {
return false;
}
std::getline(file, *out);
std::getline(file, *out, '\0');
return !out->empty();
};
auto allow_video0_probe = []() -> bool {
const char *env = std::getenv("ONE_KVM_V4L2M2M_ALLOW_VIDEO0");
auto v4l2m2m_allowed = []() -> bool {
const char *env = std::getenv("ONE_KVM_V4L2M2M_ALLOW");
if (env == nullptr) {
return false;
}
@@ -137,30 +137,90 @@ int linux_support_v4l2m2m() {
return std::strcmp(env, "0") != 0;
};
auto is_amlogic_vdec = [&]() -> bool {
std::string name;
std::string modalias;
if (read_text_file("/sys/class/video4linux/video0/name", &name)) {
const std::string lowered = to_lower(name);
if (lowered.find("meson") != std::string::npos ||
lowered.find("vdec") != std::string::npos ||
lowered.find("decoder") != std::string::npos ||
lowered.find("video-decoder") != std::string::npos) {
return true;
}
}
if (read_text_file("/sys/class/video4linux/video0/device/modalias", &modalias)) {
const std::string lowered = to_lower(modalias);
if (lowered.find("amlogic") != std::string::npos ||
lowered.find("meson") != std::string::npos ||
lowered.find("gxl-vdec") != std::string::npos ||
lowered.find("gx-vdec") != std::string::npos) {
auto contains_any = [](const std::string &value, const char *const *needles, size_t len) -> bool {
for (size_t i = 0; i < len; i++) {
if (value.find(needles[i]) != std::string::npos) {
return true;
}
}
return false;
};
auto is_amlogic_platform = [&]() -> bool {
const char *platform_hints[] = {
"amlogic",
"meson",
"gxl",
"gxbb",
"gxm",
"g12a",
"g12b",
"sm1",
};
const char *platform_files[] = {
"/proc/device-tree/compatible",
"/proc/device-tree/model",
"/sys/firmware/devicetree/base/compatible",
"/sys/firmware/devicetree/base/model",
};
for (size_t i = 0; i < sizeof(platform_files) / sizeof(platform_files[0]); i++) {
std::string value;
if (read_text_file(platform_files[i], &value) &&
contains_any(to_lower(value), platform_hints,
sizeof(platform_hints) / sizeof(platform_hints[0]))) {
return true;
}
}
const char *video_nodes[] = {
"video0",
"video1",
"video2",
"video10",
"video11",
"video32",
};
const char *vdec_hints[] = {
"meson",
"amlogic",
"vdec",
"decoder",
"video-decoder",
"gxl-vdec",
"gx-vdec",
};
for (size_t i = 0; i < sizeof(video_nodes) / sizeof(video_nodes[0]); i++) {
std::string name;
std::string modalias;
const std::string base = std::string("/sys/class/video4linux/") + video_nodes[i];
if (read_text_file((base + "/name").c_str(), &name) &&
contains_any(to_lower(name), vdec_hints, sizeof(vdec_hints) / sizeof(vdec_hints[0]))) {
return true;
}
if (read_text_file((base + "/device/modalias").c_str(), &modalias) &&
contains_any(to_lower(modalias), vdec_hints,
sizeof(vdec_hints) / sizeof(vdec_hints[0]))) {
return true;
}
}
return false;
};
const bool amlogic_platform = is_amlogic_platform();
if (amlogic_platform && !v4l2m2m_allowed()) {
LOG_WARN(std::string(
"V4L2 M2M: skipped probe on Amlogic platform; set ONE_KVM_V4L2M2M_ALLOW=1 to enable"));
return -1;
}
if (amlogic_platform) {
LOG_WARN(std::string("V4L2 M2M: ONE_KVM_V4L2M2M_ALLOW is set; probing Amlogic video nodes"));
}
// 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)
@@ -179,13 +239,6 @@ int linux_support_v4l2m2m() {
for (size_t i = 0; i < sizeof(m2m_devices) / sizeof(m2m_devices[0]); i++) {
if (access(m2m_devices[i], F_OK) == 0) {
if (std::strcmp(m2m_devices[i], "/dev/video0") == 0) {
if (!allow_video0_probe() && is_amlogic_vdec()) {
LOG_TRACE(std::string("V4L2 M2M: Skipping /dev/video0 (Amlogic vdec)"));
continue;
}
}
// Device exists, check if it's an M2M device by trying to open it
int fd = open(m2m_devices[i], O_RDWR | O_NONBLOCK);
if (fd >= 0) {

View File

@@ -1,4 +1,5 @@
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/opt.h>
}
@@ -99,13 +100,17 @@ void set_av_codec_ctx(AVCodecContext *c, const std::string &name, int kbs,
c->color_primaries = AVCOL_PRI_SMPTE170M;
c->color_trc = AVCOL_TRC_SMPTE170M;
// Profile selection: use BASELINE for software H264 (faster, simpler)
if (is_software_h264(name)) {
c->profile = FF_PROFILE_H264_BASELINE; // Simpler profile for real-time
} else if (name.find("h264") != std::string::npos) {
c->profile = FF_PROFILE_H264_HIGH;
// WebRTC SDP advertises constrained baseline. Keep most hardware and software
// encoders on the same browser-friendly H264 profile. Android MediaCodec is
// deliberately excluded because older vendor OMX encoders can reject explicit
// profile/level combinations during configure().
if (name.find("mediacodec") != std::string::npos) {
return;
}
if (name.find("h264") != std::string::npos) {
c->profile = AV_PROFILE_H264_CONSTRAINED_BASELINE;
} else if (name.find("hevc") != std::string::npos) {
c->profile = FF_PROFILE_HEVC_MAIN;
c->profile = AV_PROFILE_HEVC_MAIN;
}
}
@@ -120,8 +125,7 @@ bool set_lantency_free(void *priv_data, const std::string &name) {
}
if (name.find("amf") != std::string::npos) {
if ((ret = av_opt_set(priv_data, "query_timeout", "1000", 0)) < 0) {
LOG_ERROR(std::string("amf set_lantency_free failed, ret = ") + av_err2str(ret));
return false;
LOG_WARN(std::string("amf query_timeout option is unavailable, ret = ") + av_err2str(ret));
}
}
if (name.find("qsv") != std::string::npos) {
@@ -306,23 +310,9 @@ bool set_quality(void *priv_data, const std::string &name, int quality) {
break;
}
}
if (name.find("mediacodec") != std::string::npos) {
if (name.find("h264") != std::string::npos) {
if ((ret = av_opt_set(priv_data, "level", "5.1", 0)) < 0) {
LOG_ERROR(std::string("mediacodec set opt level 5.1 failed, ret = ") +
av_err2str(ret));
return false;
}
}
if (name.find("hevc") != std::string::npos) {
// https:en.wikipedia.org/wiki/High_Efficiency_Video_Coding_tiers_and_levels
if ((ret = av_opt_set(priv_data, "level", "h5.1", 0)) < 0) {
LOG_ERROR(std::string("mediacodec set opt level h5.1 failed, ret = ") +
av_err2str(ret));
return false;
}
}
}
// Do not force MediaCodec level here. Some Android TV vendor encoders,
// including older Amlogic OMX implementations, reject explicit level values
// even when they support the requested resolution and bitrate.
// libx264 software encoder presets
if (is_software_h264(name)) {
const char* preset = nullptr;
@@ -458,6 +448,13 @@ bool set_others(void *priv_data, const std::string &name) {
return false;
}
}
if (name.find("mediacodec") != std::string::npos) {
if ((ret = av_opt_set_int(priv_data, "ndk_codec", 1, 0)) < 0) {
LOG_ERROR(std::string("mediacodec set ndk_codec failed, ret = ") +
av_err2str(ret));
return false;
}
}
// NOTE: Removed idr_interval = INT_MAX for VAAPI.
// This was disabling automatic keyframe generation.
// The encoder should respect c->gop_size for keyframe interval.

View File

@@ -0,0 +1,879 @@
#define NOMINMAX
#include "ffmpeg_capture_ffi.h"
#include <Windows.h>
#include <dshow.h>
#include <dvdmedia.h>
extern "C" {
#include <libavcodec/codec_id.h>
#include <libavdevice/avdevice.h>
#include <libavformat/avformat.h>
#include <libavutil/avutil.h>
#include <libavutil/error.h>
#include <libavutil/pixfmt.h>
}
#include <atomic>
#include <algorithm>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#pragma comment(lib, "strmiids")
thread_local std::string g_last_error;
struct HwcodecDshowCaptureContext {
AVFormatContext* format_ctx = nullptr;
int stream_index = -1;
int width = 0;
int height = 0;
int pixel_format = HWCODEC_CAPTURE_FMT_UNKNOWN;
int stride = 0;
int timeout_ms = 2000;
std::atomic<long long> deadline_ms{0};
std::atomic<int> timed_out{0};
uint64_t sequence = 0;
};
namespace {
struct DshowCapabilityEntry {
std::string format;
int width = 0;
int height = 0;
std::vector<int> fps;
};
const char* requested_pixel_format_name(int requested_format);
void set_last_error(const std::string& message) {
g_last_error = message;
}
std::string ffmpeg_error(int errnum) {
char buffer[AV_ERROR_MAX_STRING_SIZE] = {0};
av_make_error_string(buffer, sizeof(buffer), errnum);
return std::string(buffer);
}
long long now_ms() {
return static_cast<long long>(GetTickCount64());
}
std::string wide_to_utf8(const wchar_t* value) {
if (!value) {
return std::string();
}
int size = WideCharToMultiByte(CP_UTF8, 0, value, -1, nullptr, 0, nullptr, nullptr);
if (size <= 1) {
return std::string();
}
std::string result(static_cast<size_t>(size - 1), '\0');
WideCharToMultiByte(
CP_UTF8,
0,
value,
-1,
result.empty() ? nullptr : &result[0],
size,
nullptr,
nullptr);
return result;
}
void add_fps_candidate(std::vector<int>* fps, LONGLONG interval_100ns) {
if (!fps || interval_100ns <= 0) {
return;
}
double fps_value = 10000000.0 / static_cast<double>(interval_100ns);
int rounded = static_cast<int>(fps_value + 0.5);
if (rounded <= 0) {
return;
}
if (std::find(fps->begin(), fps->end(), rounded) == fps->end()) {
fps->push_back(rounded);
}
}
void normalize_fps(std::vector<int>* fps) {
if (!fps) {
return;
}
std::sort(fps->begin(), fps->end(), std::greater<int>());
fps->erase(std::unique(fps->begin(), fps->end()), fps->end());
}
const char* media_subtype_to_format(const GUID& subtype) {
if (subtype == MEDIASUBTYPE_MJPG) {
return "MJPEG";
}
if (subtype == MEDIASUBTYPE_YUY2) {
return "YUYV";
}
if (subtype == MEDIASUBTYPE_UYVY) {
return "UYVY";
}
if (subtype == MEDIASUBTYPE_YVYU) {
return "YVYU";
}
if (subtype == MEDIASUBTYPE_NV12) {
return "NV12";
}
if (subtype == MEDIASUBTYPE_RGB24) {
return "RGB24";
}
if (subtype == MEDIASUBTYPE_RGB32) {
return "BGR24";
}
if (subtype == MEDIASUBTYPE_IYUV) {
return "YUV420";
}
if (subtype == MEDIASUBTYPE_YV12) {
return "YVU420";
}
return nullptr;
}
void free_media_type(AM_MEDIA_TYPE* media_type) {
if (!media_type) {
return;
}
if (media_type->cbFormat != 0) {
CoTaskMemFree(media_type->pbFormat);
media_type->cbFormat = 0;
media_type->pbFormat = nullptr;
}
if (media_type->pUnk != nullptr) {
media_type->pUnk->Release();
media_type->pUnk = nullptr;
}
CoTaskMemFree(media_type);
}
bool fill_capability_entry(
const AM_MEDIA_TYPE* media_type,
const VIDEO_STREAM_CONFIG_CAPS* caps,
DshowCapabilityEntry* out_entry) {
if (!media_type || !out_entry) {
return false;
}
const char* format = media_subtype_to_format(media_type->subtype);
if (!format) {
return false;
}
LONG width = 0;
LONG height = 0;
REFERENCE_TIME avg_time_per_frame = 0;
if (media_type->formattype == FORMAT_VideoInfo && media_type->pbFormat &&
media_type->cbFormat >= sizeof(VIDEOINFOHEADER)) {
const auto* info = reinterpret_cast<const VIDEOINFOHEADER*>(media_type->pbFormat);
width = info->bmiHeader.biWidth;
height = std::abs(info->bmiHeader.biHeight);
avg_time_per_frame = info->AvgTimePerFrame;
} else if (media_type->formattype == FORMAT_VideoInfo2 && media_type->pbFormat &&
media_type->cbFormat >= sizeof(VIDEOINFOHEADER2)) {
const auto* info = reinterpret_cast<const VIDEOINFOHEADER2*>(media_type->pbFormat);
width = info->bmiHeader.biWidth;
height = std::abs(info->bmiHeader.biHeight);
avg_time_per_frame = info->AvgTimePerFrame;
}
if ((width <= 0 || height <= 0) && caps) {
width = std::max<LONG>(caps->InputSize.cx, caps->MinOutputSize.cx);
height = std::max<LONG>(caps->InputSize.cy, caps->MinOutputSize.cy);
if (width <= 0 || height <= 0) {
width = caps->MaxOutputSize.cx;
height = caps->MaxOutputSize.cy;
}
}
if (width <= 0 || height <= 0) {
return false;
}
out_entry->format = format;
out_entry->width = static_cast<int>(width);
out_entry->height = static_cast<int>(height);
out_entry->fps.clear();
add_fps_candidate(&out_entry->fps, avg_time_per_frame);
if (caps) {
add_fps_candidate(&out_entry->fps, caps->MinFrameInterval);
add_fps_candidate(&out_entry->fps, caps->MaxFrameInterval);
}
normalize_fps(&out_entry->fps);
return true;
}
void append_stream_capabilities(IAMStreamConfig* stream_config, std::vector<DshowCapabilityEntry>* entries) {
if (!stream_config || !entries) {
return;
}
int cap_count = 0;
int cap_size = 0;
HRESULT hr = stream_config->GetNumberOfCapabilities(&cap_count, &cap_size);
if (FAILED(hr) || cap_count <= 0 || cap_size < static_cast<int>(sizeof(VIDEO_STREAM_CONFIG_CAPS))) {
return;
}
std::vector<BYTE> caps_buffer(static_cast<size_t>(cap_size));
for (int index = 0; index < cap_count; ++index) {
AM_MEDIA_TYPE* media_type = nullptr;
hr = stream_config->GetStreamCaps(index, &media_type, caps_buffer.data());
if (FAILED(hr) || !media_type) {
continue;
}
DshowCapabilityEntry entry;
const auto* caps = reinterpret_cast<const VIDEO_STREAM_CONFIG_CAPS*>(caps_buffer.data());
if (fill_capability_entry(media_type, caps, &entry)) {
entries->push_back(std::move(entry));
}
free_media_type(media_type);
}
}
bool find_device_filter(const std::string& device_name, IBaseFilter** out_filter) {
if (!out_filter) {
return false;
}
*out_filter = nullptr;
ICreateDevEnum* dev_enum = nullptr;
IEnumMoniker* enum_moniker = nullptr;
HRESULT hr = CoCreateInstance(
CLSID_SystemDeviceEnum,
nullptr,
CLSCTX_INPROC_SERVER,
IID_ICreateDevEnum,
reinterpret_cast<void**>(&dev_enum));
if (FAILED(hr) || !dev_enum) {
return false;
}
hr = dev_enum->CreateClassEnumerator(CLSID_VideoInputDeviceCategory, &enum_moniker, 0);
dev_enum->Release();
if (hr != S_OK || !enum_moniker) {
return false;
}
bool found = false;
IMoniker* moniker = nullptr;
ULONG fetched = 0;
while (!found && enum_moniker->Next(1, &moniker, &fetched) == S_OK) {
IPropertyBag* bag = nullptr;
hr = moniker->BindToStorage(nullptr, nullptr, IID_IPropertyBag, reinterpret_cast<void**>(&bag));
if (SUCCEEDED(hr) && bag) {
VARIANT name;
VariantInit(&name);
if (SUCCEEDED(bag->Read(L"FriendlyName", &name, nullptr)) && name.vt == VT_BSTR) {
auto utf8_name = wide_to_utf8(name.bstrVal);
if (utf8_name == device_name) {
hr = moniker->BindToObject(nullptr, nullptr, IID_IBaseFilter, reinterpret_cast<void**>(out_filter));
found = SUCCEEDED(hr) && *out_filter != nullptr;
}
}
VariantClear(&name);
bag->Release();
}
moniker->Release();
}
enum_moniker->Release();
return found;
}
std::string build_capabilities_payload(const std::vector<DshowCapabilityEntry>& entries) {
std::string payload;
for (size_t i = 0; i < entries.size(); ++i) {
const auto& entry = entries[i];
payload += entry.format;
payload.push_back('|');
payload += std::to_string(entry.width);
payload.push_back('|');
payload += std::to_string(entry.height);
payload.push_back('|');
for (size_t fps_index = 0; fps_index < entry.fps.size(); ++fps_index) {
payload += std::to_string(entry.fps[fps_index]);
if (fps_index + 1 < entry.fps.size()) {
payload.push_back(',');
}
}
if (i + 1 < entries.size()) {
payload.push_back('\n');
}
}
return payload;
}
char* copy_payload(const std::string& payload) {
char* out = reinterpret_cast<char*>(std::malloc(payload.size() + 1));
if (!out) {
set_last_error("Failed to allocate capture payload buffer");
return nullptr;
}
std::memcpy(out, payload.c_str(), payload.size() + 1);
return out;
}
int open_dshow_input_with_options(
AVFormatContext** format_ctx,
const AVInputFormat* input,
const std::string& device_name,
int width,
int height,
int fps,
int requested_format,
bool use_video_size,
bool use_framerate,
bool use_pixel_format,
std::string* attempt_desc) {
if (!format_ctx || !input) {
return AVERROR(EINVAL);
}
AVDictionary* options = nullptr;
std::vector<std::string> parts;
if (use_video_size && width > 0 && height > 0) {
std::string video_size = std::to_string(width) + "x" + std::to_string(height);
av_dict_set(&options, "video_size", video_size.c_str(), 0);
parts.push_back("video_size=" + video_size);
}
if (use_framerate && fps > 0) {
std::string framerate = std::to_string(fps);
av_dict_set(&options, "framerate", framerate.c_str(), 0);
parts.push_back("framerate=" + framerate);
}
av_dict_set(&options, "rtbufsize", "64M", 0);
parts.push_back("rtbufsize=64M");
const char* pixel_format_name = requested_pixel_format_name(requested_format);
if (use_pixel_format && pixel_format_name) {
av_dict_set(&options, "pixel_format", pixel_format_name, 0);
parts.push_back(std::string("pixel_format=") + pixel_format_name);
}
if (attempt_desc) {
*attempt_desc = parts.empty() ? "default options" : "options{";
if (!parts.empty()) {
for (size_t i = 0; i < parts.size(); ++i) {
if (i > 0) {
attempt_desc->append(", ");
}
attempt_desc->append(parts[i]);
}
attempt_desc->append("}");
}
}
std::string input_name = "video=" + device_name;
int ret = avformat_open_input(format_ctx, input_name.c_str(), input, &options);
av_dict_free(&options);
return ret;
}
class ScopedComInit {
public:
ScopedComInit() {
HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
initialized_ = hr == S_OK || hr == S_FALSE;
}
~ScopedComInit() {
if (initialized_) {
CoUninitialize();
}
}
private:
bool initialized_ = false;
};
int capture_stride(int pixel_format, int width) {
switch (pixel_format) {
case HWCODEC_CAPTURE_FMT_YUYV:
case HWCODEC_CAPTURE_FMT_YVYU:
case HWCODEC_CAPTURE_FMT_UYVY:
return width * 2;
case HWCODEC_CAPTURE_FMT_RGB24:
case HWCODEC_CAPTURE_FMT_BGR24:
return width * 3;
case HWCODEC_CAPTURE_FMT_NV24:
return width * 2;
case HWCODEC_CAPTURE_FMT_NV12:
case HWCODEC_CAPTURE_FMT_NV21:
case HWCODEC_CAPTURE_FMT_NV16:
case HWCODEC_CAPTURE_FMT_YUV420:
case HWCODEC_CAPTURE_FMT_YVU420:
case HWCODEC_CAPTURE_FMT_GREY:
case HWCODEC_CAPTURE_FMT_MJPEG:
case HWCODEC_CAPTURE_FMT_JPEG:
default:
return width;
}
}
int map_raw_pixfmt(int format) {
switch (format) {
case AV_PIX_FMT_YUYV422:
return HWCODEC_CAPTURE_FMT_YUYV;
case AV_PIX_FMT_UYVY422:
return HWCODEC_CAPTURE_FMT_UYVY;
#ifdef AV_PIX_FMT_YVYU422
case AV_PIX_FMT_YVYU422:
return HWCODEC_CAPTURE_FMT_YVYU;
#endif
case AV_PIX_FMT_NV12:
return HWCODEC_CAPTURE_FMT_NV12;
case AV_PIX_FMT_NV21:
return HWCODEC_CAPTURE_FMT_NV21;
#ifdef AV_PIX_FMT_NV16
case AV_PIX_FMT_NV16:
return HWCODEC_CAPTURE_FMT_NV16;
#endif
#ifdef AV_PIX_FMT_NV24
case AV_PIX_FMT_NV24:
return HWCODEC_CAPTURE_FMT_NV24;
#endif
case AV_PIX_FMT_YUV420P:
return HWCODEC_CAPTURE_FMT_YUV420;
#ifdef AV_PIX_FMT_YVU420P
case AV_PIX_FMT_YVU420P:
return HWCODEC_CAPTURE_FMT_YVU420;
#endif
case AV_PIX_FMT_RGB24:
return HWCODEC_CAPTURE_FMT_RGB24;
case AV_PIX_FMT_BGR24:
return HWCODEC_CAPTURE_FMT_BGR24;
case AV_PIX_FMT_GRAY8:
return HWCODEC_CAPTURE_FMT_GREY;
default:
return HWCODEC_CAPTURE_FMT_UNKNOWN;
}
}
int map_codec_to_capture_format(const AVCodecParameters* codecpar) {
if (!codecpar) {
return HWCODEC_CAPTURE_FMT_UNKNOWN;
}
switch (codecpar->codec_id) {
case AV_CODEC_ID_MJPEG:
return HWCODEC_CAPTURE_FMT_MJPEG;
case AV_CODEC_ID_JPEG2000:
return HWCODEC_CAPTURE_FMT_JPEG;
case AV_CODEC_ID_RAWVIDEO:
return map_raw_pixfmt(codecpar->format);
default:
return HWCODEC_CAPTURE_FMT_UNKNOWN;
}
}
int interrupt_callback(void* opaque) {
auto* ctx = reinterpret_cast<HwcodecDshowCaptureContext*>(opaque);
if (!ctx) {
return 0;
}
auto deadline = ctx->deadline_ms.load();
if (deadline <= 0) {
return 0;
}
if (now_ms() > deadline) {
ctx->timed_out.store(1);
return 1;
}
return 0;
}
const char* requested_pixel_format_name(int requested_format) {
switch (requested_format) {
case HWCODEC_CAPTURE_FMT_YUYV:
return "yuyv422";
case HWCODEC_CAPTURE_FMT_UYVY:
return "uyvy422";
case HWCODEC_CAPTURE_FMT_NV12:
return "nv12";
case HWCODEC_CAPTURE_FMT_NV21:
return "nv21";
case HWCODEC_CAPTURE_FMT_RGB24:
return "rgb24";
case HWCODEC_CAPTURE_FMT_BGR24:
return "bgr24";
case HWCODEC_CAPTURE_FMT_GREY:
return "gray";
default:
return nullptr;
}
}
} // namespace
extern "C" const char* hwcodec_capture_last_error(void) {
return g_last_error.c_str();
}
extern "C" char* hwcodec_dshow_list_video_devices(void) {
ScopedComInit com;
ICreateDevEnum* dev_enum = nullptr;
IEnumMoniker* enum_moniker = nullptr;
HRESULT hr = CoCreateInstance(
CLSID_SystemDeviceEnum,
nullptr,
CLSCTX_INPROC_SERVER,
IID_ICreateDevEnum,
reinterpret_cast<void**>(&dev_enum));
if (FAILED(hr)) {
set_last_error("Failed to create DirectShow device enumerator");
return nullptr;
}
hr = dev_enum->CreateClassEnumerator(CLSID_VideoInputDeviceCategory, &enum_moniker, 0);
dev_enum->Release();
if (hr != S_OK || !enum_moniker) {
char* out = reinterpret_cast<char*>(std::malloc(1));
if (out) {
out[0] = '\0';
}
return out;
}
std::vector<std::string> devices;
IMoniker* moniker = nullptr;
ULONG fetched = 0;
while (enum_moniker->Next(1, &moniker, &fetched) == S_OK) {
IPropertyBag* bag = nullptr;
hr = moniker->BindToStorage(nullptr, nullptr, IID_IPropertyBag, reinterpret_cast<void**>(&bag));
if (SUCCEEDED(hr) && bag) {
VARIANT name;
VariantInit(&name);
if (SUCCEEDED(bag->Read(L"FriendlyName", &name, nullptr)) && name.vt == VT_BSTR) {
auto utf8_name = wide_to_utf8(name.bstrVal);
if (!utf8_name.empty()) {
devices.push_back(utf8_name);
}
}
VariantClear(&name);
bag->Release();
}
moniker->Release();
}
enum_moniker->Release();
std::string payload;
for (size_t i = 0; i < devices.size(); ++i) {
payload += devices[i];
if (i + 1 < devices.size()) {
payload.push_back('\n');
}
}
return copy_payload(payload);
}
extern "C" char* hwcodec_dshow_list_device_capabilities(const char* device_name) {
if (!device_name || device_name[0] == '\0') {
set_last_error("DirectShow device name is empty");
return nullptr;
}
ScopedComInit com;
IBaseFilter* filter = nullptr;
if (!find_device_filter(device_name, &filter) || !filter) {
set_last_error("Failed to find DirectShow device filter");
return nullptr;
}
std::vector<DshowCapabilityEntry> entries;
IEnumPins* enum_pins = nullptr;
HRESULT hr = filter->EnumPins(&enum_pins);
if (SUCCEEDED(hr) && enum_pins) {
IPin* pin = nullptr;
ULONG fetched = 0;
while (enum_pins->Next(1, &pin, &fetched) == S_OK) {
PIN_DIRECTION direction = PINDIR_INPUT;
if (SUCCEEDED(pin->QueryDirection(&direction)) && direction == PINDIR_OUTPUT) {
IAMStreamConfig* stream_config = nullptr;
if (SUCCEEDED(pin->QueryInterface(IID_IAMStreamConfig, reinterpret_cast<void**>(&stream_config))) &&
stream_config) {
append_stream_capabilities(stream_config, &entries);
stream_config->Release();
}
}
pin->Release();
}
enum_pins->Release();
}
filter->Release();
std::sort(entries.begin(), entries.end(), [](const DshowCapabilityEntry& left, const DshowCapabilityEntry& right) {
if (left.format != right.format) {
return left.format < right.format;
}
if (left.width != right.width) {
return left.width < right.width;
}
if (left.height != right.height) {
return left.height < right.height;
}
return left.fps > right.fps;
});
entries.erase(
std::unique(entries.begin(), entries.end(), [](const DshowCapabilityEntry& left, const DshowCapabilityEntry& right) {
return left.format == right.format && left.width == right.width && left.height == right.height && left.fps == right.fps;
}),
entries.end());
return copy_payload(build_capabilities_payload(entries));
}
extern "C" void hwcodec_capture_string_free(char* ptr) {
if (ptr) {
std::free(ptr);
}
}
extern "C" HwcodecDshowCaptureContext* hwcodec_dshow_capture_open(
const char* device_name,
int width,
int height,
int fps,
int requested_format,
int timeout_ms) {
if (!device_name || device_name[0] == '\0') {
set_last_error("Device name is empty");
return nullptr;
}
avdevice_register_all();
const AVInputFormat* input = av_find_input_format("dshow");
if (!input) {
set_last_error("FFmpeg dshow input format is unavailable");
return nullptr;
}
auto* ctx = new HwcodecDshowCaptureContext();
ctx->timeout_ms = timeout_ms > 0 ? timeout_ms : 2000;
ctx->format_ctx = avformat_alloc_context();
if (!ctx->format_ctx) {
delete ctx;
set_last_error("Failed to allocate FFmpeg format context");
return nullptr;
}
ctx->format_ctx->interrupt_callback.callback = interrupt_callback;
ctx->format_ctx->interrupt_callback.opaque = ctx;
std::string open_attempt;
int ret = open_dshow_input_with_options(
&ctx->format_ctx,
input,
device_name,
width,
height,
fps,
requested_format,
true,
true,
true,
&open_attempt);
if (ret < 0) {
avformat_free_context(ctx->format_ctx);
ctx->format_ctx = avformat_alloc_context();
if (!ctx->format_ctx) {
delete ctx;
set_last_error("Failed to allocate FFmpeg format context for fallback open");
return nullptr;
}
ctx->format_ctx->interrupt_callback.callback = interrupt_callback;
ctx->format_ctx->interrupt_callback.opaque = ctx;
std::string fallback_attempt;
ret = open_dshow_input_with_options(
&ctx->format_ctx,
input,
device_name,
width,
height,
fps,
requested_format,
true,
false,
true,
&fallback_attempt);
if (ret >= 0) {
open_attempt = fallback_attempt;
}
}
if (ret < 0) {
avformat_free_context(ctx->format_ctx);
ctx->format_ctx = avformat_alloc_context();
if (!ctx->format_ctx) {
delete ctx;
set_last_error("Failed to allocate FFmpeg format context for final fallback open");
return nullptr;
}
ctx->format_ctx->interrupt_callback.callback = interrupt_callback;
ctx->format_ctx->interrupt_callback.opaque = ctx;
std::string fallback_attempt;
ret = open_dshow_input_with_options(
&ctx->format_ctx,
input,
device_name,
width,
height,
fps,
requested_format,
false,
false,
false,
&fallback_attempt);
if (ret >= 0) {
open_attempt = fallback_attempt;
}
}
if (ret < 0) {
set_last_error("Failed to open dshow input (" + open_attempt + "): " + ffmpeg_error(ret));
avformat_free_context(ctx->format_ctx);
delete ctx;
return nullptr;
}
ret = avformat_find_stream_info(ctx->format_ctx, nullptr);
if (ret < 0) {
set_last_error("Failed to read stream info: " + ffmpeg_error(ret));
avformat_close_input(&ctx->format_ctx);
delete ctx;
return nullptr;
}
for (unsigned int i = 0; i < ctx->format_ctx->nb_streams; ++i) {
AVStream* stream = ctx->format_ctx->streams[i];
if (stream && stream->codecpar && stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
ctx->stream_index = static_cast<int>(i);
ctx->width = stream->codecpar->width > 0 ? stream->codecpar->width : width;
ctx->height = stream->codecpar->height > 0 ? stream->codecpar->height : height;
ctx->pixel_format = map_codec_to_capture_format(stream->codecpar);
ctx->stride = capture_stride(ctx->pixel_format, ctx->width);
break;
}
}
if (ctx->stream_index < 0) {
set_last_error("No video stream found on DirectShow device");
avformat_close_input(&ctx->format_ctx);
delete ctx;
return nullptr;
}
if (ctx->pixel_format == HWCODEC_CAPTURE_FMT_UNKNOWN) {
set_last_error("DirectShow stream format is unsupported in current Windows backend");
avformat_close_input(&ctx->format_ctx);
delete ctx;
return nullptr;
}
return ctx;
}
extern "C" int hwcodec_dshow_capture_info(
HwcodecDshowCaptureContext* ctx,
HwcodecCaptureStreamInfo* out_info) {
if (!ctx || !out_info) {
set_last_error("Invalid capture context");
return -1;
}
out_info->width = ctx->width;
out_info->height = ctx->height;
out_info->pixel_format = ctx->pixel_format;
out_info->stride = ctx->stride;
return 0;
}
extern "C" int hwcodec_dshow_capture_read(
HwcodecDshowCaptureContext* ctx,
uint8_t** out_data,
int* out_len,
uint64_t* out_sequence) {
if (!ctx || !out_data || !out_len || !out_sequence) {
set_last_error("Invalid capture read arguments");
return -1;
}
*out_data = nullptr;
*out_len = 0;
*out_sequence = 0;
AVPacket packet;
av_init_packet(&packet);
packet.data = nullptr;
packet.size = 0;
while (true) {
ctx->timed_out.store(0);
ctx->deadline_ms.store(now_ms() + ctx->timeout_ms);
int ret = av_read_frame(ctx->format_ctx, &packet);
ctx->deadline_ms.store(0);
if (ret < 0) {
if (ctx->timed_out.load() != 0) {
set_last_error("Timed out waiting for frame");
return -110;
}
set_last_error("Failed to read frame: " + ffmpeg_error(ret));
return ret;
}
if (packet.stream_index != ctx->stream_index) {
av_packet_unref(&packet);
continue;
}
if (packet.size <= 0 || !packet.data) {
av_packet_unref(&packet);
continue;
}
auto* buffer = reinterpret_cast<uint8_t*>(std::malloc(static_cast<size_t>(packet.size)));
if (!buffer) {
av_packet_unref(&packet);
set_last_error("Failed to allocate packet buffer");
return -12;
}
std::memcpy(buffer, packet.data, static_cast<size_t>(packet.size));
*out_data = buffer;
*out_len = packet.size;
*out_sequence = ctx->sequence++;
av_packet_unref(&packet);
return 0;
}
}
extern "C" void hwcodec_dshow_capture_packet_free(uint8_t* data) {
if (data) {
std::free(data);
}
}
extern "C" void hwcodec_dshow_capture_close(HwcodecDshowCaptureContext* ctx) {
if (!ctx) {
return;
}
if (ctx->format_ctx) {
avformat_close_input(&ctx->format_ctx);
}
delete ctx;
}

View File

@@ -0,0 +1,64 @@
#ifndef HWCODEC_FFMPEG_CAPTURE_FFI_H
#define HWCODEC_FFMPEG_CAPTURE_FFI_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct HwcodecDshowCaptureContext HwcodecDshowCaptureContext;
enum HwcodecCapturePixelFormat {
HWCODEC_CAPTURE_FMT_UNKNOWN = 0,
HWCODEC_CAPTURE_FMT_MJPEG = 1,
HWCODEC_CAPTURE_FMT_JPEG = 2,
HWCODEC_CAPTURE_FMT_YUYV = 3,
HWCODEC_CAPTURE_FMT_YVYU = 4,
HWCODEC_CAPTURE_FMT_UYVY = 5,
HWCODEC_CAPTURE_FMT_NV12 = 6,
HWCODEC_CAPTURE_FMT_NV21 = 7,
HWCODEC_CAPTURE_FMT_NV16 = 8,
HWCODEC_CAPTURE_FMT_NV24 = 9,
HWCODEC_CAPTURE_FMT_YUV420 = 10,
HWCODEC_CAPTURE_FMT_YVU420 = 11,
HWCODEC_CAPTURE_FMT_RGB24 = 12,
HWCODEC_CAPTURE_FMT_BGR24 = 13,
HWCODEC_CAPTURE_FMT_GREY = 14,
};
typedef struct HwcodecCaptureStreamInfo {
int width;
int height;
int pixel_format;
int stride;
} HwcodecCaptureStreamInfo;
const char* hwcodec_capture_last_error(void);
char* hwcodec_dshow_list_video_devices(void);
char* hwcodec_dshow_list_device_capabilities(const char* device_name);
void hwcodec_capture_string_free(char* ptr);
HwcodecDshowCaptureContext* hwcodec_dshow_capture_open(
const char* device_name,
int width,
int height,
int fps,
int requested_format,
int timeout_ms);
int hwcodec_dshow_capture_info(
HwcodecDshowCaptureContext* ctx,
HwcodecCaptureStreamInfo* out_info);
int hwcodec_dshow_capture_read(
HwcodecDshowCaptureContext* ctx,
uint8_t** out_data,
int* out_len,
uint64_t* out_sequence);
void hwcodec_dshow_capture_packet_free(uint8_t* data);
void hwcodec_dshow_capture_close(HwcodecDshowCaptureContext* ctx);
#ifdef __cplusplus
}
#endif
#endif

View File

@@ -137,6 +137,13 @@ public:
av_buffer_unref(&frames_ref);
}
if (name_.find("mediacodec") != std::string::npos && c_->priv_data) {
if ((ret = av_opt_set_int(c_->priv_data, "ndk_codec", 1, 0)) < 0) {
LOG_WARN(std::string("mediacodec decoder ndk_codec option failed, ret = ") +
av_err2str(ret));
}
}
if ((ret = avcodec_open2(c_, codec, NULL)) < 0) {
set_last_error(std::string("avcodec_open2 failed, ret = ") + av_err2str(ret));
return false;

View File

@@ -112,6 +112,9 @@ _exit:
namespace {
typedef void (*RamEncodeCallback)(const uint8_t *data, int len, int64_t pts,
int key, const void *obj);
typedef void (*RamEncodePacketCallback)(void *packet, const uint8_t *data,
int len, int64_t pts, int key,
const void *obj);
class FFmpegRamEncoder {
public:
@@ -134,6 +137,7 @@ public:
int thread_count_ = 1;
int gpu_ = 0;
RamEncodeCallback callback_ = NULL;
RamEncodePacketCallback packet_callback_ = NULL;
int offset_[AV_NUM_DATA_POINTERS] = {0};
bool force_keyframe_ = false; // Force next frame to be a keyframe
@@ -141,6 +145,7 @@ public:
AVPixelFormat hw_pixfmt_ = AV_PIX_FMT_NONE;
AVBufferRef *hw_device_ctx_ = NULL;
AVFrame *hw_frame_ = NULL;
AVFrame *borrowed_frame_ = NULL;
FFmpegRamEncoder(const char *name, const char *mc_name, int width, int height,
int pixfmt, int align, int fps, int gop, int rc, int quality,
@@ -247,6 +252,11 @@ public:
LOG_ERROR(std::string("Could not allocate video packet"));
return false;
}
borrowed_frame_ = av_frame_alloc();
if (!borrowed_frame_) {
LOG_ERROR(std::string("Could not allocate borrowed video frame"));
return false;
}
/* resolution must be a multiple of two */
c_->width = width_;
@@ -297,11 +307,19 @@ public:
int encode(const uint8_t *data, int length, const void *obj, uint64_t ms) {
int ret;
if (can_borrow_input(length)) {
AVFrame *borrowed = wrap_borrowed_frame(data, length);
if (!borrowed) {
return -1;
}
return do_encode(borrowed, obj, ms);
}
if ((ret = av_frame_make_writable(frame_)) != 0) {
LOG_ERROR(std::string("av_frame_make_writable failed, ret = ") + av_err2str(ret));
return ret;
}
if ((ret = fill_frame(frame_, (uint8_t *)data, length, offset_)) != 0)
if ((ret = fill_frame(frame_, data, length, offset_)) != 0)
return ret;
AVFrame *tmp_frame;
if (hw_device_type_ != AV_HWDEVICE_TYPE_NONE) {
@@ -317,6 +335,14 @@ public:
return do_encode(tmp_frame, obj, ms);
}
int encode_packet(const uint8_t *data, int length, const void *obj,
uint64_t ms, RamEncodePacketCallback callback) {
packet_callback_ = callback;
int ret = encode(data, length, obj, ms);
packet_callback_ = NULL;
return ret;
}
void free_encoder() {
if (pkt_)
av_packet_free(&pkt_);
@@ -324,6 +350,8 @@ public:
av_frame_free(&frame_);
if (hw_frame_)
av_frame_free(&hw_frame_);
if (borrowed_frame_)
av_frame_free(&borrowed_frame_);
if (hw_device_ctx_)
av_buffer_unref(&hw_device_ctx_);
if (c_)
@@ -376,101 +404,203 @@ private:
frame->pict_type = AV_PICTURE_TYPE_NONE;
}
if ((ret = avcodec_send_frame(c_, frame)) < 0) {
ret = avcodec_send_frame(c_, frame);
if (ret == AVERROR(EAGAIN)) {
int drain_ret = receive_available_packets(obj, encoded);
if (drain_ret < 0) {
return drain_ret;
}
ret = avcodec_send_frame(c_, frame);
}
if (ret == AVERROR(EAGAIN)) {
return encoded ? 0 : AVERROR(EAGAIN);
}
if (ret < 0) {
LOG_ERROR(std::string("avcodec_send_frame failed, ret = ") + av_err2str(ret));
return ret;
}
auto start = util::now();
while (ret >= 0 && util::elapsed_ms(start) < DECODE_TIMEOUT_MS) {
if ((ret = avcodec_receive_packet(c_, pkt_)) < 0) {
if (ret != AVERROR(EAGAIN)) {
LOG_ERROR(std::string("avcodec_receive_packet failed, ret = ") + av_err2str(ret));
}
goto _exit;
}
if (!pkt_->data || !pkt_->size) {
LOG_ERROR(std::string("avcodec_receive_packet failed, pkt size is 0"));
goto _exit;
}
encoded = true;
callback_(pkt_->data, pkt_->size, pkt_->pts,
pkt_->flags & AV_PKT_FLAG_KEY, obj);
ret = receive_available_packets(obj, encoded);
if (ret < 0) {
return ret;
}
_exit:
av_packet_unref(pkt_);
// If no packet is produced for this input frame, treat it as EAGAIN.
// This is not a fatal error: encoders may buffer internally (e.g., startup delay).
return encoded ? 0 : AVERROR(EAGAIN);
}
int fill_frame(AVFrame *frame, uint8_t *data, int data_length,
const int *const offset) {
int receive_available_packets(const void *obj, bool &encoded) {
int ret = 0;
auto start = util::now();
while (util::elapsed_ms(start) < DECODE_TIMEOUT_MS) {
ret = avcodec_receive_packet(c_, pkt_);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
return 0;
}
if (ret < 0) {
LOG_ERROR(std::string("avcodec_receive_packet failed, ret = ") + av_err2str(ret));
av_packet_unref(pkt_);
return ret;
}
if (!pkt_->data || !pkt_->size) {
LOG_WARN(std::string("avcodec_receive_packet returned empty packet"));
av_packet_unref(pkt_);
continue;
}
encoded = true;
if (packet_callback_) {
AVPacket *owned_pkt = av_packet_clone(pkt_);
if (!owned_pkt) {
LOG_ERROR("av_packet_clone failed");
av_packet_unref(pkt_);
return AVERROR(ENOMEM);
}
packet_callback_(owned_pkt, owned_pkt->data, owned_pkt->size,
owned_pkt->pts, owned_pkt->flags & AV_PKT_FLAG_KEY,
obj);
} else {
callback_(pkt_->data, pkt_->size, pkt_->pts,
pkt_->flags & AV_PKT_FLAG_KEY, obj);
}
av_packet_unref(pkt_);
}
return 0;
}
int copy_plane(uint8_t *dst, int dst_stride, const uint8_t *src,
int src_stride, int row_bytes, int rows) {
if (!dst || !src || dst_stride < row_bytes || src_stride < row_bytes) {
return -1;
}
if (rows <= 0 || row_bytes <= 0) {
return 0;
}
if (dst_stride == row_bytes && src_stride == row_bytes) {
memcpy(dst, src, static_cast<size_t>(row_bytes) * rows);
return 0;
}
for (int y = 0; y < rows; y++) {
memcpy(dst + y * dst_stride, src + y * src_stride, row_bytes);
}
return 0;
}
int fill_frame(AVFrame *frame, const uint8_t *data, int data_length,
const int *const) {
const int src_y_stride = width_;
const int src_packed_stride = width_ * bytes_per_pixel(frame->format);
const int src_uv_stride = width_;
const int src_y_size = width_ * frame->height;
const int src_420_chroma_size = (width_ / 2) * (frame->height / 2);
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)) {
frame->height * src_y_stride + frame->height / 2 * src_uv_stride) {
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]));
", width:" + std::to_string(width_) +
", height:" + std::to_string(frame->height));
return -1;
}
if (copy_plane(frame->data[0], frame->linesize[0], data, src_y_stride,
width_, frame->height) != 0 ||
copy_plane(frame->data[1], frame->linesize[1], data + src_y_size,
src_uv_stride, width_, frame->height / 2) != 0) {
LOG_ERROR("fill_frame: NV12/NV21 copy failed");
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:") +
frame->height * src_y_stride + frame->height * src_uv_stride) {
LOG_ERROR(std::string("fill_frame: NV16 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]));
", width:" + std::to_string(width_) +
", height:" + std::to_string(frame->height));
return -1;
}
if (copy_plane(frame->data[0], frame->linesize[0], data, src_y_stride,
width_, frame->height) != 0 ||
copy_plane(frame->data[1], frame->linesize[1], data + src_y_size,
src_uv_stride, width_, frame->height) != 0) {
LOG_ERROR("fill_frame: NV16 copy failed");
return -1;
}
frame->data[0] = data;
frame->data[1] = data + offset[0];
break;
case AV_PIX_FMT_NV24: {
const int src_nv24_uv_stride = width_ * 2;
if (data_length <
frame->height * src_y_stride + frame->height * src_nv24_uv_stride) {
LOG_ERROR(std::string("fill_frame: NV24 data length error. data_length:") +
std::to_string(data_length) +
", width:" + std::to_string(width_) +
", height:" + std::to_string(frame->height));
return -1;
}
if (copy_plane(frame->data[0], frame->linesize[0], data, src_y_stride,
width_, frame->height) != 0 ||
copy_plane(frame->data[1], frame->linesize[1], data + src_y_size,
src_nv24_uv_stride, width_ * 2, frame->height) != 0) {
LOG_ERROR("fill_frame: NV24 copy failed");
return -1;
}
break;
}
case AV_PIX_FMT_YUV420P:
if (data_length <
frame->height * (frame->linesize[0] + frame->linesize[1] / 2 +
frame->linesize[2] / 2)) {
width_ * frame->height + (width_ / 2) * (frame->height / 2) * 2) {
LOG_ERROR(std::string("fill_frame: 420P 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]) +
", linesize[2]:" + std::to_string(frame->linesize[2]));
", width:" + std::to_string(width_) +
", height:" + std::to_string(frame->height));
return -1;
}
if (copy_plane(frame->data[0], frame->linesize[0], data, width_,
width_, frame->height) != 0 ||
copy_plane(frame->data[1], frame->linesize[1], data + src_y_size,
width_ / 2, width_ / 2, frame->height / 2) != 0 ||
copy_plane(frame->data[2], frame->linesize[2],
data + src_y_size + src_420_chroma_size,
width_ / 2, width_ / 2, frame->height / 2) != 0) {
LOG_ERROR("fill_frame: 420P copy failed");
return -1;
}
frame->data[0] = data;
frame->data[1] = data + offset[0];
frame->data[2] = data + offset[1];
break;
case AV_PIX_FMT_YUYV422:
case AV_PIX_FMT_YVYU422:
case AV_PIX_FMT_UYVY422:
// Packed YUV 4:2:2 formats: single plane, linesize[0] = width * 2
if (data_length < frame->height * frame->linesize[0]) {
if (data_length < frame->height * src_packed_stride) {
LOG_ERROR(std::string("fill_frame: YUYV422 data length error. data_length:") +
std::to_string(data_length) +
", linesize[0]:" + std::to_string(frame->linesize[0]) +
", stride:" + std::to_string(src_packed_stride) +
", height:" + std::to_string(frame->height));
return -1;
}
frame->data[0] = data;
if (copy_plane(frame->data[0], frame->linesize[0], data,
src_packed_stride, src_packed_stride, frame->height) != 0) {
LOG_ERROR("fill_frame: YUYV422 copy failed");
return -1;
}
break;
case AV_PIX_FMT_RGB24:
case AV_PIX_FMT_BGR24:
if (data_length < frame->height * frame->linesize[0]) {
if (data_length < frame->height * src_packed_stride) {
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]) +
", stride:" + std::to_string(src_packed_stride) +
", height:" + std::to_string(frame->height));
return -1;
}
frame->data[0] = data;
if (copy_plane(frame->data[0], frame->linesize[0], data,
src_packed_stride, src_packed_stride, frame->height) != 0) {
LOG_ERROR("fill_frame: RGB24/BGR24 copy failed");
return -1;
}
break;
default:
LOG_ERROR(std::string("fill_frame: unsupported format, ") +
@@ -479,6 +609,79 @@ private:
}
return 0;
}
bool can_borrow_input(int data_length) const {
if (hw_device_type_ != AV_HWDEVICE_TYPE_NONE) {
return false;
}
if (name_.find("mediacodec") == std::string::npos) {
return false;
}
switch (pixfmt_) {
case AV_PIX_FMT_NV12:
case AV_PIX_FMT_NV21:
return data_length >= width_ * height_ * 3 / 2;
case AV_PIX_FMT_YUV420P:
return data_length >= width_ * height_ * 3 / 2;
default:
return false;
}
}
AVFrame *wrap_borrowed_frame(const uint8_t *data, int data_length) {
if (!borrowed_frame_) {
return NULL;
}
av_frame_unref(borrowed_frame_);
borrowed_frame_->format = pixfmt_;
borrowed_frame_->width = width_;
borrowed_frame_->height = height_;
const int y_size = width_ * height_;
const int uv_size = y_size / 4;
switch (pixfmt_) {
case AV_PIX_FMT_NV12:
case AV_PIX_FMT_NV21:
if (data_length < y_size + y_size / 2) {
LOG_ERROR("wrap_borrowed_frame: NV12/NV21 data length error");
return NULL;
}
borrowed_frame_->data[0] = const_cast<uint8_t *>(data);
borrowed_frame_->data[1] = const_cast<uint8_t *>(data + y_size);
borrowed_frame_->linesize[0] = width_;
borrowed_frame_->linesize[1] = width_;
break;
case AV_PIX_FMT_YUV420P:
if (data_length < y_size + uv_size * 2) {
LOG_ERROR("wrap_borrowed_frame: YUV420P data length error");
return NULL;
}
borrowed_frame_->data[0] = const_cast<uint8_t *>(data);
borrowed_frame_->data[1] = const_cast<uint8_t *>(data + y_size);
borrowed_frame_->data[2] = const_cast<uint8_t *>(data + y_size + uv_size);
borrowed_frame_->linesize[0] = width_;
borrowed_frame_->linesize[1] = width_ / 2;
borrowed_frame_->linesize[2] = width_ / 2;
break;
default:
return NULL;
}
return borrowed_frame_;
}
int bytes_per_pixel(int pix_fmt) {
switch (pix_fmt) {
case AV_PIX_FMT_YUYV422:
case AV_PIX_FMT_YVYU422:
case AV_PIX_FMT_UYVY422:
return 2;
case AV_PIX_FMT_RGB24:
case AV_PIX_FMT_BGR24:
return 3;
default:
return 1;
}
}
};
} // namespace
@@ -532,6 +735,25 @@ extern "C" void ffmpeg_ram_free_encoder(FFmpegRamEncoder *encoder) {
}
}
extern "C" int ffmpeg_ram_encode_packet(FFmpegRamEncoder *encoder,
const uint8_t *data, int length,
const void *obj, uint64_t ms,
RamEncodePacketCallback callback) {
try {
return encoder->encode_packet(data, length, obj, ms, callback);
} catch (const std::exception &e) {
LOG_ERROR(std::string("encode_packet failed, ") + std::string(e.what()));
return -1;
}
}
extern "C" void ffmpeg_ram_free_packet(void *packet) {
AVPacket *pkt = reinterpret_cast<AVPacket *>(packet);
if (pkt) {
av_packet_free(&pkt);
}
}
extern "C" int ffmpeg_ram_set_bitrate(FFmpegRamEncoder *encoder, int kbs) {
try {
return encoder->set_bitrate(kbs);

View File

@@ -7,6 +7,9 @@
typedef void (*RamEncodeCallback)(const uint8_t *data, int len, int64_t pts,
int key, const void *obj);
typedef void (*RamEncodePacketCallback)(void *packet, const uint8_t *data,
int len, int64_t pts, int key,
const void *obj);
typedef void (*RamDecodeCallback)(const uint8_t *data, int len, int width,
int height, int pixfmt, const void *obj);
@@ -18,7 +21,11 @@ void *ffmpeg_ram_new_encoder(const char *name, const char *mc_name, int width,
RamEncodeCallback callback);
int ffmpeg_ram_encode(void *encoder, const uint8_t *data, int length,
const void *obj, int64_t ms);
int ffmpeg_ram_encode_packet(void *encoder, const uint8_t *data, int length,
const void *obj, int64_t ms,
RamEncodePacketCallback callback);
void ffmpeg_ram_free_encoder(void *encoder);
void ffmpeg_ram_free_packet(void *packet);
int ffmpeg_ram_get_linesize_offset_length(int pix_fmt, int width, int height,
int align, int *linesize, int *offset,
int *length);

297
libs/hwcodec/src/capture.rs Normal file
View File

@@ -0,0 +1,297 @@
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
use std::ffi::{CStr, CString};
use std::os::raw::c_int;
include!(concat!(env!("OUT_DIR"), "/ffmpeg_capture_ffi.rs"));
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CapturePixelFormat {
Unknown,
Mjpeg,
Jpeg,
Yuyv,
Yvyu,
Uyvy,
Nv12,
Nv21,
Nv16,
Nv24,
Yuv420,
Yvu420,
Rgb24,
Bgr24,
Grey,
}
impl CapturePixelFormat {
pub fn to_ffi(self) -> c_int {
match self {
Self::Unknown => HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_UNKNOWN as c_int,
Self::Mjpeg => HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_MJPEG as c_int,
Self::Jpeg => HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_JPEG as c_int,
Self::Yuyv => HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_YUYV as c_int,
Self::Yvyu => HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_YVYU as c_int,
Self::Uyvy => HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_UYVY as c_int,
Self::Nv12 => HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_NV12 as c_int,
Self::Nv21 => HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_NV21 as c_int,
Self::Nv16 => HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_NV16 as c_int,
Self::Nv24 => HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_NV24 as c_int,
Self::Yuv420 => HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_YUV420 as c_int,
Self::Yvu420 => HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_YVU420 as c_int,
Self::Rgb24 => HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_RGB24 as c_int,
Self::Bgr24 => HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_BGR24 as c_int,
Self::Grey => HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_GREY as c_int,
}
}
pub fn from_ffi(value: c_int) -> Self {
match value {
x if x == HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_MJPEG as c_int => Self::Mjpeg,
x if x == HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_JPEG as c_int => Self::Jpeg,
x if x == HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_YUYV as c_int => Self::Yuyv,
x if x == HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_YVYU as c_int => Self::Yvyu,
x if x == HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_UYVY as c_int => Self::Uyvy,
x if x == HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_NV12 as c_int => Self::Nv12,
x if x == HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_NV21 as c_int => Self::Nv21,
x if x == HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_NV16 as c_int => Self::Nv16,
x if x == HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_NV24 as c_int => Self::Nv24,
x if x == HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_YUV420 as c_int => {
Self::Yuv420
}
x if x == HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_YVU420 as c_int => {
Self::Yvu420
}
x if x == HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_RGB24 as c_int => Self::Rgb24,
x if x == HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_BGR24 as c_int => Self::Bgr24,
x if x == HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_GREY as c_int => Self::Grey,
_ => Self::Unknown,
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name.trim().to_ascii_uppercase().as_str() {
"MJPEG" | "MJPG" => Some(Self::Mjpeg),
"JPEG" => Some(Self::Jpeg),
"YUYV" => Some(Self::Yuyv),
"YVYU" => Some(Self::Yvyu),
"UYVY" => Some(Self::Uyvy),
"NV12" => Some(Self::Nv12),
"NV21" => Some(Self::Nv21),
"NV16" => Some(Self::Nv16),
"NV24" => Some(Self::Nv24),
"YUV420" | "I420" | "IYUV" => Some(Self::Yuv420),
"YVU420" | "YV12" => Some(Self::Yvu420),
"RGB24" => Some(Self::Rgb24),
"BGR24" => Some(Self::Bgr24),
"GREY" | "GRAY" | "Y800" => Some(Self::Grey),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct DshowCapability {
pub format: CapturePixelFormat,
pub width: u32,
pub height: u32,
pub fps: Vec<u32>,
}
#[derive(Debug, Clone, Copy)]
pub struct CaptureStreamInfo {
pub width: i32,
pub height: i32,
pub pixel_format: CapturePixelFormat,
pub stride: i32,
}
#[derive(Debug)]
pub struct CaptureError {
pub code: i32,
pub message: String,
}
impl std::fmt::Display for CaptureError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for CaptureError {}
fn last_error_message() -> String {
unsafe {
let ptr = hwcodec_capture_last_error();
if ptr.is_null() {
return String::new();
}
CStr::from_ptr(ptr).to_string_lossy().to_string()
}
}
pub fn list_dshow_video_devices() -> Result<Vec<String>, CaptureError> {
unsafe {
let ptr = hwcodec_dshow_list_video_devices();
if ptr.is_null() {
return Err(CaptureError {
code: -1,
message: last_error_message(),
});
}
let payload = CStr::from_ptr(ptr).to_string_lossy().to_string();
hwcodec_capture_string_free(ptr as *mut _);
Ok(payload
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect())
}
}
pub fn list_dshow_device_capabilities(device_name: &str) -> Result<Vec<DshowCapability>, CaptureError> {
let device_name = CString::new(device_name).map_err(|_| CaptureError {
code: -1,
message: "device name contains NUL byte".to_string(),
})?;
unsafe {
let ptr = hwcodec_dshow_list_device_capabilities(device_name.as_ptr());
if ptr.is_null() {
return Err(CaptureError {
code: -1,
message: last_error_message(),
});
}
let payload = CStr::from_ptr(ptr).to_string_lossy().to_string();
hwcodec_capture_string_free(ptr as *mut _);
let capabilities = payload
.lines()
.filter_map(parse_dshow_capability_line)
.collect();
Ok(capabilities)
}
}
fn parse_dshow_capability_line(line: &str) -> Option<DshowCapability> {
let mut parts = line.split('|');
let format = CapturePixelFormat::from_name(parts.next()?.trim())?;
let width = parts.next()?.trim().parse::<u32>().ok()?;
let height = parts.next()?.trim().parse::<u32>().ok()?;
let fps = parts
.next()
.unwrap_or_default()
.split(',')
.filter_map(|value| value.trim().parse::<u32>().ok())
.filter(|value| *value > 0)
.collect::<Vec<_>>();
Some(DshowCapability {
format,
width,
height,
fps,
})
}
pub struct DshowCapture {
ctx: *mut HwcodecDshowCaptureContext,
}
unsafe impl Send for DshowCapture {}
impl DshowCapture {
pub fn open(
device_name: &str,
width: i32,
height: i32,
fps: i32,
requested_format: CapturePixelFormat,
timeout_ms: i32,
) -> Result<Self, CaptureError> {
let device_name = CString::new(device_name).map_err(|_| CaptureError {
code: -1,
message: "device name contains NUL byte".to_string(),
})?;
unsafe {
let ctx = hwcodec_dshow_capture_open(
device_name.as_ptr(),
width,
height,
fps,
requested_format.to_ffi(),
timeout_ms,
);
if ctx.is_null() {
return Err(CaptureError {
code: -1,
message: last_error_message(),
});
}
Ok(Self { ctx })
}
}
pub fn info(&self) -> Result<CaptureStreamInfo, CaptureError> {
unsafe {
let mut info = HwcodecCaptureStreamInfo {
width: 0,
height: 0,
pixel_format: HwcodecCapturePixelFormat::HWCODEC_CAPTURE_FMT_UNKNOWN as c_int,
stride: 0,
};
let ret = hwcodec_dshow_capture_info(self.ctx, &mut info);
if ret != 0 {
return Err(CaptureError {
code: ret,
message: last_error_message(),
});
}
Ok(CaptureStreamInfo {
width: info.width,
height: info.height,
pixel_format: CapturePixelFormat::from_ffi(info.pixel_format),
stride: info.stride,
})
}
}
pub fn read_packet(&mut self) -> Result<(Vec<u8>, u64), CaptureError> {
unsafe {
let mut data = std::ptr::null_mut();
let mut len = 0;
let mut sequence = 0u64;
let ret = hwcodec_dshow_capture_read(self.ctx, &mut data, &mut len, &mut sequence);
if ret != 0 {
return Err(CaptureError {
code: ret,
message: last_error_message(),
});
}
if data.is_null() || len <= 0 {
return Err(CaptureError {
code: -1,
message: "empty packet returned by capture backend".to_string(),
});
}
let slice = std::slice::from_raw_parts(data, len as usize);
let vec = slice.to_vec();
hwcodec_dshow_capture_packet_free(data);
Ok((vec, sequence))
}
}
}
impl Drop for DshowCapture {
fn drop(&mut self) {
unsafe {
hwcodec_dshow_capture_close(self.ctx);
}
self.ctx = std::ptr::null_mut();
}
}

View File

@@ -13,7 +13,7 @@ pub enum Driver {
FFMPEG,
}
#[cfg(any(windows, target_os = "linux"))]
#[cfg(any(windows, target_os = "linux", target_os = "android"))]
pub(crate) fn supported_gpu(_encode: bool) -> (bool, bool, bool) {
#[cfg(target_os = "linux")]
use std::ffi::c_int;
@@ -39,6 +39,8 @@ pub(crate) fn supported_gpu(_encode: bool) -> (bool, bool, bool) {
linux_support_amd() == 0,
linux_support_intel() == 0,
);
#[cfg(target_os = "android")]
return (false, false, false);
#[allow(unreachable_code)]
(false, false, false)
}

View File

@@ -2,11 +2,13 @@ use crate::{
common::DataFormat::{self, *},
ffmpeg::{init_av_log, AVPixelFormat},
ffmpeg_ram::{
ffmpeg_linesize_offset_length, ffmpeg_ram_encode, ffmpeg_ram_free_encoder,
ffmpeg_ram_new_encoder, ffmpeg_ram_request_keyframe, ffmpeg_ram_set_bitrate, CodecInfo,
AV_NUM_DATA_POINTERS,
ffmpeg_linesize_offset_length, ffmpeg_ram_encode, ffmpeg_ram_encode_packet,
ffmpeg_ram_free_encoder, ffmpeg_ram_free_packet, ffmpeg_ram_new_encoder,
ffmpeg_ram_request_keyframe, ffmpeg_ram_set_bitrate, CodecInfo, AV_NUM_DATA_POINTERS,
},
};
#[cfg(feature = "bytes")]
use bytes::Bytes;
use log::trace;
use std::{
ffi::{c_void, CString},
@@ -15,7 +17,7 @@ use std::{
slice,
};
#[cfg(any(windows, target_os = "linux"))]
#[cfg(any(windows, target_os = "linux", target_os = "android"))]
use crate::common::Driver;
/// Timeout for encoder test in milliseconds
@@ -26,6 +28,7 @@ const PRIORITY_AMF: i32 = 2;
const PRIORITY_RKMPP: i32 = 3;
const PRIORITY_VAAPI: i32 = 4;
const PRIORITY_V4L2M2M: i32 = 5;
const PRIORITY_MEDIACODEC: i32 = 2;
#[derive(Clone, Copy)]
struct CandidateCodecSpec {
@@ -92,11 +95,32 @@ fn linux_support_v4l2m2m() -> bool {
false
}
#[cfg(any(windows, target_os = "linux"))]
#[cfg(any(windows, target_os = "linux", target_os = "android"))]
fn enumerate_candidate_codecs(ctx: &EncodeContext) -> Vec<CodecInfo> {
use log::debug;
let mut codecs = Vec::new();
if cfg!(target_os = "android") {
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "h264_mediacodec",
format: H264,
priority: PRIORITY_MEDIACODEC,
},
);
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "hevc_mediacodec",
format: H265,
priority: PRIORITY_MEDIACODEC,
},
);
return codecs;
}
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.
@@ -257,7 +281,19 @@ struct ProbePolicy {
impl ProbePolicy {
fn for_codec(codec_name: &str) -> Self {
if codec_name.contains("v4l2m2m") {
if codec_name.contains("mediacodec") {
Self {
max_attempts: 30,
request_keyframe: true,
accept_any_output: true,
}
} else if codec_name.contains("amf") {
Self {
max_attempts: 5,
request_keyframe: true,
accept_any_output: true,
}
} else if codec_name.contains("v4l2m2m") {
Self {
max_attempts: 5,
request_keyframe: true,
@@ -298,11 +334,11 @@ fn log_failed_probe_attempt(
frames: &[EncodeFrame],
elapsed_ms: u128,
) {
use log::debug;
use log::{debug, trace};
if policy.accept_any_output {
if frames.is_empty() {
debug!(
trace!(
"Encoder {} test produced no output on attempt {}",
codec_name, attempt
);
@@ -331,7 +367,7 @@ fn log_failed_probe_attempt(
}
fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> bool {
use log::debug;
use log::{debug, warn};
debug!("Testing encoder: {}", codec.name);
@@ -382,13 +418,13 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo
);
}
}
Err(err) => {
last_err = Some(err);
debug!(
"Encoder {} test attempt {} returned error: {}",
codec.name, attempt_no, err
);
}
Err(err) => {
last_err = Some(err);
warn!(
"Encoder {} test attempt {} returned error: {}",
codec.name, attempt_no, err
);
}
}
}
@@ -401,16 +437,20 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo
);
false
}
Err(_) => {
debug!("Failed to create encoder {}", codec.name);
false
}
}
Err(_) => {
warn!("Failed to create encoder {}", codec.name);
false
}
}
}
fn add_software_fallback(codecs: &mut Vec<CodecInfo>) {
use log::debug;
if cfg!(target_os = "android") {
return;
}
for fallback in CodecInfo::soft().into_vec() {
if !codecs.iter().any(|codec| codec.format == fallback.format) {
debug!(
@@ -445,6 +485,39 @@ pub struct EncodeFrame {
pub key: i32,
}
#[cfg(feature = "bytes")]
pub struct EncodeBytesFrame {
pub data: Bytes,
pub pts: i64,
pub key: i32,
}
#[cfg(feature = "bytes")]
struct FfmpegPacketOwner {
packet: *mut c_void,
data: *const u8,
len: usize,
}
#[cfg(feature = "bytes")]
unsafe impl Send for FfmpegPacketOwner {}
#[cfg(feature = "bytes")]
impl AsRef<[u8]> for FfmpegPacketOwner {
fn as_ref(&self) -> &[u8] {
unsafe { slice::from_raw_parts(self.data, self.len) }
}
}
#[cfg(feature = "bytes")]
impl Drop for FfmpegPacketOwner {
fn drop(&mut self) {
unsafe {
ffmpeg_ram_free_packet(self.packet);
}
}
}
impl Display for EncodeFrame {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "encode len:{}, pts:{}", self.data.len(), self.pts)
@@ -537,6 +610,25 @@ impl Encoder {
}
}
#[cfg(feature = "bytes")]
pub fn encode_bytes(&mut self, data: &[u8], ms: i64) -> Result<Vec<EncodeBytesFrame>, i32> {
unsafe {
let mut frames = Vec::<EncodeBytesFrame>::new();
let result = ffmpeg_ram_encode_packet(
self.codec,
data.as_ptr(),
data.len() as _,
&mut frames as *mut _ as *const c_void,
ms,
Some(Encoder::packet_callback),
);
if result == -11 || result == 0 {
return Ok(frames);
}
Err(result)
}
}
extern "C" fn callback(data: *const u8, size: c_int, pts: i64, key: i32, obj: *const c_void) {
unsafe {
let frames = &mut *(obj as *mut Vec<EncodeFrame>);
@@ -548,6 +640,30 @@ impl Encoder {
}
}
#[cfg(feature = "bytes")]
extern "C" fn packet_callback(
packet: *mut c_void,
data: *const u8,
size: c_int,
pts: i64,
key: i32,
obj: *const c_void,
) {
unsafe {
let frames = &mut *(obj as *mut Vec<EncodeBytesFrame>);
let owner = FfmpegPacketOwner {
packet,
data,
len: size as usize,
};
frames.push(EncodeBytesFrame {
data: Bytes::from_owner(owner),
pts,
key,
});
}
}
pub fn set_bitrate(&mut self, kbs: i32) -> Result<(), ()> {
let ret = unsafe { ffmpeg_ram_set_bitrate(self.codec, kbs) };
if ret == 0 {
@@ -582,11 +698,11 @@ impl Encoder {
pub fn available_encoders(ctx: EncodeContext, _sdk: Option<String>) -> Vec<CodecInfo> {
use log::debug;
if !(cfg!(windows) || cfg!(target_os = "linux")) {
if !(cfg!(windows) || cfg!(target_os = "linux") || cfg!(target_os = "android")) {
return vec![];
}
let mut res = vec![];
#[cfg(any(windows, target_os = "linux"))]
#[cfg(any(windows, target_os = "linux", target_os = "android"))]
let codecs = enumerate_candidate_codecs(&ctx);
if let Ok(yuv) = Encoder::dummy_yuv(ctx.clone()) {

View File

@@ -9,12 +9,18 @@ use std::ffi::c_int;
include!(concat!(env!("OUT_DIR"), "/ffmpeg_ram_ffi.rs"));
#[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))]
#[cfg(all(
any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"),
not(target_os = "android")
))]
pub mod decode;
// Provide a small stub on non-ARM builds so dependents can still compile, but decoder
// construction will fail (since the C++ RKMPP decoder isn't built/linked).
#[cfg(not(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp")))]
#[cfg(any(
not(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp")),
target_os = "android"
))]
pub mod decode {
use crate::ffmpeg::AVPixelFormat;

View File

@@ -1,6 +1,11 @@
#[cfg(windows)]
pub mod capture;
pub mod common;
pub mod ffmpeg;
#[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))]
#[cfg(all(
any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"),
not(target_os = "android")
))]
pub mod ffmpeg_hw;
pub mod ffmpeg_ram;

1
libs/v4l2r/.cargo-ok Normal file
View File

@@ -0,0 +1 @@
{"v":1}

View File

@@ -0,0 +1,6 @@
{
"git": {
"sha1": "7b441383125ae583017a1c18b3fc9ec6c88ddbe8"
},
"path_in_vcs": "lib"
}

52
libs/v4l2r/Android.bp Normal file
View File

@@ -0,0 +1,52 @@
// This file is generated by cargo_embargo.
// Do not modify this file because the changes will be overridden on upgrade.
package {
default_applicable_licenses: ["external_rust_crates_v4l2r_license"],
}
rust_library {
name: "libv4l2r",
crate_name: "v4l2r",
cargo_env_compat: true,
cargo_pkg_version: "0.0.7",
crate_root: "src/lib.rs",
edition: "2021",
rustlibs: [
"libbitflags",
"liblog_rust",
"libnix",
"libthiserror",
],
proc_macros: ["libenumn"],
apex_available: [
"//apex_available:platform",
"//apex_available:anyapex",
],
product_available: true,
vendor_available: true,
// Bindgen-generated bindings of our local videodev2.h.
srcs: [":libv4l2r_bindgen"],
}
rust_test {
name: "v4l2r_test_src_lib",
crate_name: "v4l2r",
cargo_env_compat: true,
cargo_pkg_version: "0.0.7",
crate_root: "src/lib.rs",
test_suites: ["general-tests"],
auto_gen_config: true,
edition: "2021",
rustlibs: [
"libbitflags",
"liblog_rust",
"libnix",
"libthiserror",
],
proc_macros: ["libenumn"],
// Bindgen-generated bindings of our local videodev2.h.
srcs: [":libv4l2r_bindgen"],
}

65
libs/v4l2r/Cargo.toml Normal file
View File

@@ -0,0 +1,65 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2021"
name = "v4l2r"
version = "0.0.7"
authors = ["Alexandre Courbot <gnurou@gmail.com>"]
build = "build.rs"
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Safe and flexible abstraction over V4L2"
readme = "README.md"
keywords = [
"v4l2",
"video",
"linux",
]
categories = ["os"]
license-file = "LICENSE"
repository = "https://github.com/Gnurou/v4l2r"
[features]
arch32 = []
arch64 = []
[lib]
name = "v4l2r"
path = "src/lib.rs"
[dependencies.bitflags]
version = "2.4"
[dependencies.enumn]
version = "0.1.6"
[dependencies.log]
version = "0.4.14"
[dependencies.nix]
version = "0.28"
features = [
"ioctl",
"mman",
"poll",
"fs",
"event",
]
[dependencies.thiserror]
version = "1.0"
[build-dependencies.bindgen]
version = "0.70.1"

28
libs/v4l2r/Cargo.toml.orig generated Normal file
View File

@@ -0,0 +1,28 @@
[package]
name = "v4l2r"
version = "0.0.7"
authors = ["Alexandre Courbot <gnurou@gmail.com>"]
edition = "2021"
description = "Safe and flexible abstraction over V4L2"
repository = "https://github.com/Gnurou/v4l2r"
categories = ["os"]
keywords = ["v4l2", "video", "linux"]
license-file.workspace = true
readme.workspace = true
[features]
# Generate the bindings for 64-bit even if the host is 32-bit.
arch64 = []
# Generate the bindings for 32-bit even if the host is 64-bit.
arch32 = []
[dependencies]
nix = { version = "0.28", features = ["ioctl", "mman", "poll", "fs", "event"] }
bitflags = "2.4"
thiserror = "1.0"
log = "0.4.14"
enumn = "0.1.6"
[build-dependencies]
bindgen = "0.70.1"

23
libs/v4l2r/LICENSE Normal file
View File

@@ -0,0 +1,23 @@
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

24
libs/v4l2r/README.md Normal file
View File

@@ -0,0 +1,24 @@
# Rust bindings for V4L2
This is a vendored, One-KVM-specific subset of `v4l2r`.
It keeps only the pieces needed for video capture:
- generated Linux V4L2 bindings,
- safe low-level ioctl wrappers used by capture and device probing,
- memory handle helpers for `MMAP`, `USERPTR`, and `DMABUF`,
- core V4L2 types such as `Format`, `PixelFormat`, and `QueueType`.
The upstream crate also contains high-level device abstractions, stateful
decoder/encoder helpers, stateless codec controls, examples, and C FFI. Those
parts are intentionally removed here so this dependency stays scoped to capture.
## Build options
`cargo build` generates V4L2 bindings from `/usr/include/linux/videodev2.h` by
default. Set `V4L2R_VIDEODEV2_H_PATH` to a directory containing `videodev2.h` to
generate bindings from a different header.
For Android targets, the build script uses the Android NDK sysroot. Set one of
`ANDROID_NDK_HOME`, `ANDROID_NDK_ROOT`, `NDK_HOME`, `ANDROID_HOME`, or
`ANDROID_SDK_ROOT` if the NDK cannot be found automatically.

20
libs/v4l2r/bindgen.rs Normal file
View File

@@ -0,0 +1,20 @@
// This file defines the customizations to the bindgen builder used to generate the v4l2r
// bindings.
//
// It is meant to be included from `lib/build.rs` and `android/build.rs`.
#[derive(Debug)]
/// Workaround for https://github.com/rust-lang/rust-bindgen/issues/753.
pub struct Fix753;
impl bindgen::callbacks::ParseCallbacks for Fix753 {
fn item_name(&self, original_item_name: &str) -> Option<String> {
Some(original_item_name.trim_start_matches("Fix753_").to_owned())
}
}
fn v4l2r_bindgen_builder(builder: bindgen::Builder) -> bindgen::Builder {
builder
.parse_callbacks(Box::new(Fix753))
.derive_default(true)
}

180
libs/v4l2r/build.rs Normal file
View File

@@ -0,0 +1,180 @@
use std::env::{self, VarError};
use std::path::PathBuf;
include!("bindgen.rs");
/// Environment variable that can be set to point to the directory containing the `videodev2.h`
/// file to use to generate the bindings.
const V4L2R_VIDEODEV_ENV: &str = "V4L2R_VIDEODEV2_H_PATH";
/// Default header file to parse if the `V4L2R_VIDEODEV2_H_PATH` environment variable is not set.
const DEFAULT_VIDEODEV2_H_PATH: &str = "/usr/include/linux";
/// Wrapper file to use as input of bindgen.
const WRAPPER_H: &str = "v4l2r_wrapper.h";
// Fix for https://github.com/rust-lang/rust-bindgen/issues/753
const FIX753_H: &str = "fix753.h";
fn main() {
let target = env::var("TARGET").unwrap_or_default();
let is_android = target.contains("android");
let default_videodev2_h_path = if is_android {
android_sysroot().join("usr/include").display().to_string()
} else {
DEFAULT_VIDEODEV2_H_PATH.to_string()
};
let videodev2_h_path = env::var(V4L2R_VIDEODEV_ENV)
.or_else(|e| {
if let VarError::NotPresent = e {
Ok(default_videodev2_h_path.clone())
} else {
Err(e)
}
})
.expect("invalid `V4L2R_VIDEODEV2_H_PATH` environment variable");
let videodev2_h = PathBuf::from(videodev2_h_path.clone()).join(if is_android {
"linux/videodev2.h"
} else {
"videodev2.h"
});
println!("cargo::rerun-if-env-changed={}", V4L2R_VIDEODEV_ENV);
println!("cargo::rerun-if-env-changed=ANDROID_NDK_HOME");
println!("cargo::rerun-if-env-changed=ANDROID_NDK_ROOT");
println!("cargo::rerun-if-env-changed=NDK_HOME");
println!("cargo::rerun-if-env-changed=ANDROID_HOME");
println!("cargo::rerun-if-env-changed=ANDROID_SDK_ROOT");
println!("cargo::rerun-if-env-changed=CARGO_NDK_PLATFORM");
println!("cargo::rerun-if-changed={}", videodev2_h.display());
println!("cargo::rerun-if-changed={}", FIX753_H);
println!("cargo::rerun-if-changed={}", WRAPPER_H);
let mut clang_args = vec![
format!("-I{videodev2_h_path}"),
#[cfg(all(feature = "arch64", not(feature = "arch32")))]
"--target=x86_64-linux-gnu".into(),
#[cfg(all(feature = "arch32", not(feature = "arch64")))]
"--target=i686-linux-gnu".into(),
];
if is_android {
clang_args.extend(android_clang_args(&target));
}
let bindings = v4l2r_bindgen_builder(bindgen::Builder::default())
.header(WRAPPER_H)
.clang_args(clang_args)
.generate()
.expect("unable to generate bindings");
let out_path = PathBuf::from(env::var("OUT_DIR").expect("`OUT_DIR` is not set"));
bindings
.write_to_file(out_path.join("bindings.rs"))
.expect("Couldn't write bindings!");
}
fn android_clang_args(target: &str) -> Vec<String> {
let ndk = android_ndk_home();
let toolchain = ndk.join("toolchains/llvm/prebuilt").join(host_tag());
let sysroot = toolchain.join("sysroot");
let clang_include = toolchain
.join("lib/clang")
.join(clang_version(&toolchain))
.join("include");
let api = env::var("CARGO_NDK_PLATFORM")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(21);
let clang_target = android_clang_target(target);
vec![
format!("--target={clang_target}"),
format!("--sysroot={}", sysroot.display()),
format!("-D__ANDROID_API__={api}"),
format!("-isystem{}", clang_include.display()),
format!("-isystem{}", sysroot.join("usr/include").display()),
format!(
"-isystem{}",
sysroot.join("usr/include").join(clang_target).display()
),
]
}
fn android_clang_target(target: &str) -> &'static str {
match target {
"aarch64-linux-android" => "aarch64-linux-android",
"armv7-linux-androideabi" => "armv7a-linux-androideabi",
"i686-linux-android" => "i686-linux-android",
"x86_64-linux-android" => "x86_64-linux-android",
other => panic!("unsupported Android target for v4l2r bindgen: {other}"),
}
}
fn android_sysroot() -> PathBuf {
android_ndk_home()
.join("toolchains/llvm/prebuilt")
.join(host_tag())
.join("sysroot")
}
fn android_ndk_home() -> PathBuf {
for key in ["ANDROID_NDK_HOME", "ANDROID_NDK_ROOT", "NDK_HOME"] {
if let Ok(value) = env::var(key) {
return PathBuf::from(value);
}
}
for key in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
if let Ok(value) = env::var(key) {
let ndk_dir = PathBuf::from(value).join("ndk");
if let Some(newest) = newest_child_dir(&ndk_dir) {
return newest;
}
}
}
panic!(
"v4l2r Android bindgen requires ANDROID_NDK_HOME, ANDROID_NDK_ROOT, NDK_HOME, \
or ANDROID_HOME/ANDROID_SDK_ROOT with an ndk directory"
);
}
fn newest_child_dir(path: &PathBuf) -> Option<PathBuf> {
let mut entries = std::fs::read_dir(path)
.ok()?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>();
entries.sort();
entries.pop()
}
fn host_tag() -> &'static str {
if cfg!(target_os = "linux") {
"linux-x86_64"
} else if cfg!(target_os = "macos") {
"darwin-x86_64"
} else if cfg!(target_os = "windows") {
"windows-x86_64"
} else {
panic!("unsupported host OS for Android NDK");
}
}
fn clang_version(toolchain: &PathBuf) -> String {
let clang_dir = toolchain.join("lib/clang");
let mut entries = std::fs::read_dir(&clang_dir)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", clang_dir.display()))
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
entries.sort();
entries
.pop()
.unwrap_or_else(|| panic!("no clang resource directory in {}", clang_dir.display()))
}

55
libs/v4l2r/fix753.h Normal file
View File

@@ -0,0 +1,55 @@
#undef V4L2_FWHT_FL_COMPONENTS_NUM_MSK
#undef V4L2_FWHT_FL_PIXENC_MSK
#ifdef V4L2_FWHT_FL_IS_INTERLACED
MARK_FIX_753(V4L2_FWHT_FL_IS_INTERLACED);
#endif
#ifdef V4L2_FWHT_FL_IS_BOTTOM_FIRST
MARK_FIX_753(V4L2_FWHT_FL_IS_BOTTOM_FIRST);
#endif
#ifdef V4L2_FWHT_FL_IS_ALTERNATE
MARK_FIX_753(V4L2_FWHT_FL_IS_ALTERNATE);
#endif
#ifdef V4L2_FWHT_FL_IS_BOTTOM_FIELD
MARK_FIX_753(V4L2_FWHT_FL_IS_BOTTOM_FIELD);
#endif
#ifdef V4L2_FWHT_FL_LUMA_IS_UNCOMPRESSED
MARK_FIX_753(V4L2_FWHT_FL_LUMA_IS_UNCOMPRESSED);
#endif
#ifdef V4L2_FWHT_FL_CB_IS_UNCOMPRESSED
MARK_FIX_753(V4L2_FWHT_FL_CB_IS_UNCOMPRESSED);
#endif
#ifdef V4L2_FWHT_FL_CR_IS_UNCOMPRESSED
MARK_FIX_753(V4L2_FWHT_FL_CR_IS_UNCOMPRESSED);
#endif
#ifdef V4L2_FWHT_FL_CHROMA_FULL_HEIGHT
MARK_FIX_753(V4L2_FWHT_FL_CHROMA_FULL_HEIGHT);
#endif
#ifdef V4L2_FWHT_FL_CHROMA_FULL_WIDTH
MARK_FIX_753(V4L2_FWHT_FL_CHROMA_FULL_WIDTH);
#endif
#ifdef V4L2_FWHT_FL_ALPHA_IS_UNCOMPRESSED
MARK_FIX_753(V4L2_FWHT_FL_ALPHA_IS_UNCOMPRESSED);
#endif
#ifdef V4L2_FWHT_FL_I_FRAME
MARK_FIX_753(V4L2_FWHT_FL_I_FRAME);
#endif
#ifdef V4L2_FWHT_FL_COMPONENTS_NUM_OFFSET
#define V4L2_FWHT_FL_COMPONENTS_NUM_MSK \
(7 << V4L2_FWHT_FL_COMPONENTS_NUM_OFFSET)
#endif
#ifdef V4L2_FWHT_FL_PIXENC_OFFSET
#define V4L2_FWHT_FL_PIXENC_MSK (3 << V4L2_FWHT_FL_PIXENC_OFFSET)
#endif

View File

@@ -0,0 +1,8 @@
#![allow(dead_code)]
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#![allow(deref_nullptr)]
#![allow(clippy::all)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));

1177
libs/v4l2r/src/ioctl.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
use crate::ioctl::ioctl_and_convert;
use crate::ioctl::IoctlConvertError;
use crate::ioctl::IoctlConvertResult;
use crate::ioctl::UncheckedV4l2Buffer;
use crate::QueueType;
use std::convert::TryFrom;
use std::fmt::Debug;
use std::os::unix::io::AsRawFd;
use nix::errno::Errno;
use thiserror::Error;
#[doc(hidden)]
mod ioctl {
use crate::bindings::v4l2_buffer;
nix::ioctl_readwrite!(vidioc_dqbuf, b'V', 17, v4l2_buffer);
}
#[derive(Debug, Error)]
pub enum DqBufIoctlError {
#[error("end-of-stream reached")]
Eos,
#[error("no buffer ready for dequeue")]
NotReady,
#[error("unexpected ioctl error: {0}")]
Other(Errno),
}
impl From<Errno> for DqBufIoctlError {
fn from(error: Errno) -> Self {
match error {
Errno::EAGAIN => Self::NotReady,
Errno::EPIPE => Self::Eos,
error => Self::Other(error),
}
}
}
impl From<DqBufIoctlError> for Errno {
fn from(err: DqBufIoctlError) -> Self {
match err {
DqBufIoctlError::Eos => Errno::EPIPE,
DqBufIoctlError::NotReady => Errno::EAGAIN,
DqBufIoctlError::Other(e) => e,
}
}
}
pub type DqBufError<CE> = IoctlConvertError<DqBufIoctlError, CE>;
pub type DqBufResult<O, CE> = IoctlConvertResult<O, DqBufIoctlError, CE>;
/// Safe wrapper around the `VIDIOC_DQBUF` ioctl.
pub fn dqbuf<O>(fd: &impl AsRawFd, queue: QueueType) -> DqBufResult<O, O::Error>
where
O: TryFrom<UncheckedV4l2Buffer>,
O::Error: std::fmt::Debug,
{
let mut v4l2_buf = UncheckedV4l2Buffer::new_for_querybuf(queue, None);
ioctl_and_convert(
unsafe { ioctl::vidioc_dqbuf(fd.as_raw_fd(), v4l2_buf.as_mut()) }
.map(|_| v4l2_buf)
.map_err(Into::into),
)
}

View File

@@ -0,0 +1,137 @@
//! Safe wrapper for the `VIDIOC_ENUM_FMT` ioctl.
use super::string_from_cstr;
use crate::bindings;
use crate::bindings::v4l2_fmtdesc;
use crate::{PixelFormat, QueueType};
use bitflags::bitflags;
use log::error;
use nix::errno::Errno;
use std::fmt;
use std::os::unix::io::AsRawFd;
use thiserror::Error;
bitflags! {
/// Flags returned by the `VIDIOC_ENUM_FMT` ioctl into the `flags` field of
/// `struct v4l2_fmtdesc`.
#[derive(Clone, Copy, Debug)]
pub struct FormatFlags: u32 {
const COMPRESSED = bindings::V4L2_FMT_FLAG_COMPRESSED;
const EMULATED = bindings::V4L2_FMT_FLAG_EMULATED;
}
}
/// Quickly get the Fourcc code of a format.
impl From<v4l2_fmtdesc> for PixelFormat {
fn from(fmtdesc: v4l2_fmtdesc) -> Self {
fmtdesc.pixelformat.into()
}
}
/// Safe variant of the `v4l2_fmtdesc` struct, to be used with `enum_fmt`.
#[derive(Debug)]
pub struct FmtDesc {
pub flags: FormatFlags,
pub description: String,
pub pixelformat: PixelFormat,
}
impl fmt::Display for FmtDesc {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"{}: {} {}",
self.pixelformat,
self.description,
if self.flags.is_empty() {
"".into()
} else {
format!("({:?})", self.flags)
}
)
}
}
impl From<v4l2_fmtdesc> for FmtDesc {
fn from(fmtdesc: v4l2_fmtdesc) -> Self {
FmtDesc {
flags: FormatFlags::from_bits_truncate(fmtdesc.flags),
description: string_from_cstr(&fmtdesc.description).unwrap_or_else(|_| "".into()),
pixelformat: fmtdesc.pixelformat.into(),
}
}
}
#[doc(hidden)]
mod ioctl {
use crate::bindings::v4l2_fmtdesc;
nix::ioctl_readwrite!(vidioc_enum_fmt, b'V', 2, v4l2_fmtdesc);
}
#[derive(Debug, Error)]
pub enum EnumFmtError {
#[error("ioctl error: {0}")]
IoctlError(#[from] nix::Error),
}
impl From<EnumFmtError> for Errno {
fn from(err: EnumFmtError) -> Self {
match err {
EnumFmtError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_ENUM_FMT` ioctl.
pub fn enum_fmt<T: From<v4l2_fmtdesc>>(
fd: &impl AsRawFd,
queue: QueueType,
index: u32,
) -> Result<T, EnumFmtError> {
let mut fmtdesc = v4l2_fmtdesc {
type_: queue as u32,
index,
..Default::default()
};
unsafe { ioctl::vidioc_enum_fmt(fd.as_raw_fd(), &mut fmtdesc) }?;
Ok(T::from(fmtdesc))
}
/// Iterator over the formats of the given queue. This takes a reference to the
/// device's file descriptor so no operation that could affect the format
/// enumeration can take place while the iterator exists.
pub struct FormatIterator<'a, F: AsRawFd> {
fd: &'a F,
queue: QueueType,
index: u32,
}
impl<'a, F: AsRawFd> FormatIterator<'a, F> {
/// Create a new iterator listing all the currently valid formats on
/// `queue`.
pub fn new(fd: &'a F, queue: QueueType) -> Self {
FormatIterator {
fd,
queue,
index: 0,
}
}
}
impl<'a, F: AsRawFd> Iterator for FormatIterator<'a, F> {
type Item = FmtDesc;
fn next(&mut self) -> Option<Self::Item> {
match enum_fmt(self.fd, self.queue, self.index) {
Ok(fmtdesc) => {
self.index += 1;
Some(fmtdesc)
}
// EINVAL means we have reached the last format.
Err(EnumFmtError::IoctlError(Errno::EINVAL)) => None,
_ => {
error!("Unexpected return value for VIDIOC_ENUM_FMT!");
None
}
}
}
}

View File

@@ -0,0 +1,61 @@
//! Safe wrapper for the `VIDIOC_EXPBUF` ioctl.
use bitflags::bitflags;
use nix::errno::Errno;
use nix::fcntl::OFlag;
use std::os::unix::io::{AsRawFd, FromRawFd};
use thiserror::Error;
use crate::bindings::v4l2_exportbuffer;
use crate::QueueType;
bitflags! {
/// Flags that can be passed when exporting the buffer.
#[derive(Clone, Copy, Debug)]
pub struct ExpbufFlags: u32 {
const CLOEXEC = OFlag::O_CLOEXEC.bits() as u32;
const RDONLY = OFlag::O_RDONLY.bits() as u32;
const WRONLY = OFlag::O_WRONLY.bits() as u32;
const RDWR = OFlag::O_RDWR.bits() as u32;
}
}
#[doc(hidden)]
mod ioctl {
use crate::bindings::v4l2_exportbuffer;
nix::ioctl_readwrite!(vidioc_expbuf, b'V', 16, v4l2_exportbuffer);
}
#[derive(Debug, Error)]
pub enum ExpbufError {
#[error("ioctl error: {0}")]
IoctlError(#[from] Errno),
}
impl From<ExpbufError> for Errno {
fn from(err: ExpbufError) -> Self {
match err {
ExpbufError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_EXPBUF` ioctl.
pub fn expbuf<R: FromRawFd>(
fd: &impl AsRawFd,
queue: QueueType,
index: usize,
plane: usize,
flags: ExpbufFlags,
) -> Result<R, ExpbufError> {
let mut v4l2_expbuf = v4l2_exportbuffer {
type_: queue as u32,
index: index as u32,
plane: plane as u32,
flags: flags.bits(),
..Default::default()
};
unsafe { ioctl::vidioc_expbuf(fd.as_raw_fd(), &mut v4l2_expbuf) }?;
Ok(unsafe { R::from_raw_fd(v4l2_expbuf.fd) })
}

View File

@@ -0,0 +1,82 @@
use nix::errno::Errno;
use std::os::unix::io::AsRawFd;
use thiserror::Error;
use crate::bindings;
use crate::bindings::v4l2_frmivalenum;
use crate::PixelFormat;
/// A wrapper for the 'v4l2_frmivalenum' union member types
#[derive(Debug)]
pub enum FrmIvalTypes<'a> {
Discrete(&'a bindings::v4l2_fract),
StepWise(&'a bindings::v4l2_frmival_stepwise),
}
impl v4l2_frmivalenum {
/// Safely access the intervals member of the struct based on the
/// returned type.
pub fn intervals(&self) -> Option<FrmIvalTypes<'_>> {
match self.type_ {
// SAFETY: the member of the union that gets used by the driver
// is determined by the type
bindings::v4l2_frmivaltypes_V4L2_FRMIVAL_TYPE_DISCRETE => {
Some(FrmIvalTypes::Discrete(unsafe {
&self.__bindgen_anon_1.discrete
}))
}
// SAFETY: the member of the union that gets used by the driver
// is determined by the type
bindings::v4l2_frmivaltypes_V4L2_FRMIVAL_TYPE_CONTINUOUS
| bindings::v4l2_frmivaltypes_V4L2_FRMIVAL_TYPE_STEPWISE => {
Some(FrmIvalTypes::StepWise(unsafe {
&self.__bindgen_anon_1.stepwise
}))
}
_ => None,
}
}
}
#[doc(hidden)]
mod ioctl {
use crate::bindings::v4l2_frmivalenum;
nix::ioctl_readwrite!(vidioc_enum_frameintervals, b'V', 75, v4l2_frmivalenum);
}
#[derive(Debug, Error)]
pub enum FrameIntervalsError {
#[error("Unexpected ioctl error: {0}")]
IoctlError(nix::Error),
}
impl From<FrameIntervalsError> for Errno {
fn from(err: FrameIntervalsError) -> Self {
match err {
FrameIntervalsError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_ENUM_FRAMEINTERVALS` ioctl.
pub fn enum_frame_intervals<O: From<v4l2_frmivalenum>>(
fd: &impl AsRawFd,
index: u32,
pixel_format: PixelFormat,
width: u32,
height: u32,
) -> Result<O, FrameIntervalsError> {
let mut frame_interval = v4l2_frmivalenum {
index,
pixel_format: pixel_format.into(),
width,
height,
..Default::default()
};
match unsafe { ioctl::vidioc_enum_frameintervals(fd.as_raw_fd(), &mut frame_interval) } {
Ok(_) => Ok(O::from(frame_interval)),
Err(e) => Err(FrameIntervalsError::IoctlError(e)),
}
}

View File

@@ -0,0 +1,79 @@
use nix::errno::Errno;
use std::os::unix::io::AsRawFd;
use thiserror::Error;
use crate::bindings;
use crate::bindings::v4l2_frmsizeenum;
use crate::PixelFormat;
/// A wrapper for the 'v4l2_frmsizeenum' union member types
#[derive(Debug)]
pub enum FrmSizeTypes<'a> {
Discrete(&'a bindings::v4l2_frmsize_discrete),
StepWise(&'a bindings::v4l2_frmsize_stepwise),
}
impl v4l2_frmsizeenum {
/// Safely access the size member of the struct based on the
/// returned type.
pub fn size(&self) -> Option<FrmSizeTypes<'_>> {
match self.type_ {
// SAFETY: the member of the union that gets used by the driver
// is determined by the type
bindings::v4l2_frmsizetypes_V4L2_FRMSIZE_TYPE_DISCRETE => {
Some(FrmSizeTypes::Discrete(unsafe {
&self.__bindgen_anon_1.discrete
}))
}
// SAFETY: the member of the union that gets used by the driver
// is determined by the type
bindings::v4l2_frmsizetypes_V4L2_FRMSIZE_TYPE_CONTINUOUS
| bindings::v4l2_frmsizetypes_V4L2_FRMSIZE_TYPE_STEPWISE => {
Some(FrmSizeTypes::StepWise(unsafe {
&self.__bindgen_anon_1.stepwise
}))
}
_ => None,
}
}
}
#[doc(hidden)]
mod ioctl {
use crate::bindings::v4l2_frmsizeenum;
nix::ioctl_readwrite!(vidioc_enum_framesizes, b'V', 74, v4l2_frmsizeenum);
}
#[derive(Debug, Error)]
pub enum FrameSizeError {
#[error("Unexpected ioctl error: {0}")]
IoctlError(nix::Error),
}
impl From<FrameSizeError> for Errno {
fn from(err: FrameSizeError) -> Self {
match err {
FrameSizeError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_ENUM_FRAMESIZES` ioctl.
pub fn enum_frame_sizes<O: From<v4l2_frmsizeenum>>(
fd: &impl AsRawFd,
index: u32,
pixel_format: PixelFormat,
) -> Result<O, FrameSizeError> {
let mut frame_size = v4l2_frmsizeenum {
index,
pixel_format: pixel_format.into(),
..Default::default()
};
match unsafe { ioctl::vidioc_enum_framesizes(fd.as_raw_fd(), &mut frame_size) } {
Ok(_) => Ok(O::from(frame_size)),
Err(e) => Err(FrameSizeError::IoctlError(e)),
}
}

View File

@@ -0,0 +1,189 @@
use std::os::unix::io::AsRawFd;
use enumn::N;
use nix::errno::Errno;
use thiserror::Error;
use crate::bindings;
use crate::bindings::v4l2_dv_timings;
use crate::bindings::v4l2_dv_timings_cap;
use crate::bindings::v4l2_enum_dv_timings;
#[doc(hidden)]
mod ioctl {
use crate::bindings::v4l2_dv_timings;
use crate::bindings::v4l2_dv_timings_cap;
use crate::bindings::v4l2_enum_dv_timings;
nix::ioctl_readwrite!(vidioc_s_dv_timings, b'V', 87, v4l2_dv_timings);
nix::ioctl_readwrite!(vidioc_g_dv_timings, b'V', 88, v4l2_dv_timings);
nix::ioctl_readwrite!(vidioc_enum_dv_timings, b'V', 98, v4l2_enum_dv_timings);
nix::ioctl_read!(vidioc_query_dv_timings, b'V', 99, v4l2_dv_timings);
nix::ioctl_readwrite!(vidioc_dv_timings_cap, b'V', 100, v4l2_dv_timings_cap);
}
#[derive(Debug, N)]
#[repr(u32)]
pub enum DvTimingsType {
Bt6561120 = bindings::V4L2_DV_BT_656_1120,
}
#[derive(Debug, Error)]
pub enum GDvTimingsError {
#[error("ioctl not supported or invalid parameters")]
Invalid,
#[error("Digital video timings are not supported on this input or output")]
Unsupported,
#[error("Device is busy and cannot change timings")]
Busy,
#[error("ioctl error: {0}")]
IoctlError(Errno),
}
impl From<GDvTimingsError> for Errno {
fn from(err: GDvTimingsError) -> Self {
match err {
GDvTimingsError::Invalid => Errno::EINVAL,
GDvTimingsError::Unsupported => Errno::ENODATA,
GDvTimingsError::Busy => Errno::EBUSY,
GDvTimingsError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_S_DV_TIMINGS` ioctl.
pub fn s_dv_timings<I: Into<v4l2_dv_timings>, O: From<v4l2_dv_timings>>(
fd: &impl AsRawFd,
timings: I,
) -> Result<O, GDvTimingsError> {
let mut timings: v4l2_dv_timings = timings.into();
match unsafe { ioctl::vidioc_s_dv_timings(fd.as_raw_fd(), &mut timings) } {
Ok(_) => Ok(O::from(timings)),
Err(Errno::EINVAL) => Err(GDvTimingsError::Invalid),
Err(Errno::ENODATA) => Err(GDvTimingsError::Unsupported),
Err(Errno::EBUSY) => Err(GDvTimingsError::Busy),
Err(e) => Err(GDvTimingsError::IoctlError(e)),
}
}
/// Safe wrapper around the `VIDIOC_G_DV_TIMINGS` ioctl.
pub fn g_dv_timings<O: From<v4l2_dv_timings>>(fd: &impl AsRawFd) -> Result<O, GDvTimingsError> {
let mut timings = v4l2_dv_timings {
..Default::default()
};
match unsafe { ioctl::vidioc_g_dv_timings(fd.as_raw_fd(), &mut timings) } {
Ok(_) => Ok(O::from(timings)),
Err(Errno::EINVAL) => Err(GDvTimingsError::Invalid),
Err(Errno::ENODATA) => Err(GDvTimingsError::Unsupported),
Err(Errno::EBUSY) => Err(GDvTimingsError::Busy),
Err(e) => Err(GDvTimingsError::IoctlError(e)),
}
}
#[derive(Debug, Error)]
pub enum EnumDvTimingsError {
#[error("timing index is out of bounds")]
Invalid,
#[error("Digital video timings are not supported on this input or output")]
Unsupported,
#[error("ioctl error: {0}")]
IoctlError(Errno),
}
impl From<EnumDvTimingsError> for Errno {
fn from(err: EnumDvTimingsError) -> Self {
match err {
EnumDvTimingsError::Invalid => Errno::EINVAL,
EnumDvTimingsError::Unsupported => Errno::ENODATA,
EnumDvTimingsError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_ENUM_DV_TIMINGS` ioctl.
pub fn enum_dv_timings<O: From<v4l2_dv_timings>>(
fd: &impl AsRawFd,
index: u32,
) -> Result<O, EnumDvTimingsError> {
let mut timings = v4l2_enum_dv_timings {
index,
..Default::default()
};
match unsafe { ioctl::vidioc_enum_dv_timings(fd.as_raw_fd(), &mut timings) } {
Ok(_) => Ok(O::from(timings.timings)),
Err(Errno::EINVAL) => Err(EnumDvTimingsError::Invalid),
Err(Errno::ENODATA) => Err(EnumDvTimingsError::Unsupported),
Err(e) => Err(EnumDvTimingsError::IoctlError(e)),
}
}
#[derive(Debug, Error)]
pub enum QueryDvTimingsError {
#[error("Digital video timings are not supported on this input or output")]
Unsupported,
#[error("No timings could be detected because no signal was found")]
NoLink,
#[error("Unstable signal")]
UnstableSignal,
#[error("ioctl error: {0}")]
IoctlError(Errno),
}
impl From<QueryDvTimingsError> for Errno {
fn from(err: QueryDvTimingsError) -> Self {
match err {
QueryDvTimingsError::Unsupported => Errno::ENODATA,
QueryDvTimingsError::NoLink => Errno::ENOLINK,
QueryDvTimingsError::UnstableSignal => Errno::ENOLCK,
QueryDvTimingsError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_QUERY_DV_TIMINGS` ioctl.
pub fn query_dv_timings<O: From<v4l2_dv_timings>>(
fd: &impl AsRawFd,
) -> Result<O, QueryDvTimingsError> {
let mut timings = v4l2_dv_timings {
..Default::default()
};
match unsafe { ioctl::vidioc_query_dv_timings(fd.as_raw_fd(), &mut timings) } {
Ok(_) => Ok(O::from(timings)),
Err(Errno::ENODATA) => Err(QueryDvTimingsError::Unsupported),
Err(Errno::ENOLINK) => Err(QueryDvTimingsError::NoLink),
Err(Errno::ENOLCK) => Err(QueryDvTimingsError::UnstableSignal),
Err(e) => Err(QueryDvTimingsError::IoctlError(e)),
}
}
#[derive(Debug, Error)]
pub enum DvTimingsCapError {
#[error("ioctl error: {0}")]
IoctlError(Errno),
}
impl From<DvTimingsCapError> for Errno {
fn from(err: DvTimingsCapError) -> Self {
match err {
DvTimingsCapError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_DV_TIMINGS_CAP` ioctl.
pub fn dv_timings_cap<O: From<v4l2_dv_timings_cap>>(
fd: &impl AsRawFd,
) -> Result<O, DvTimingsCapError> {
let mut caps = v4l2_dv_timings_cap {
..Default::default()
};
match unsafe { ioctl::vidioc_dv_timings_cap(fd.as_raw_fd(), &mut caps) } {
Ok(_) => Ok(O::from(caps)),
Err(e) => Err(DvTimingsCapError::IoctlError(e)),
}
}

View File

@@ -0,0 +1,300 @@
//! Safe wrapper for the `VIDIOC_(G|S|TRY)_FMT` ioctls.
use nix::errno::Errno;
use std::convert::{From, Into, TryFrom, TryInto};
use std::default::Default;
use std::os::unix::io::AsRawFd;
use thiserror::Error;
use crate::bindings;
use crate::bindings::v4l2_format;
use crate::Format;
use crate::FormatConversionError;
use crate::PlaneLayout;
use crate::QueueType;
impl TryFrom<(QueueType, &Format)> for v4l2_format {
type Error = FormatConversionError;
fn try_from((queue, format): (QueueType, &Format)) -> Result<Self, Self::Error> {
Ok(v4l2_format {
type_: queue as u32,
fmt: match queue {
QueueType::VideoCaptureMplane | QueueType::VideoOutputMplane => {
bindings::v4l2_format__bindgen_ty_1 {
pix_mp: {
if format.plane_fmt.len() > bindings::VIDEO_MAX_PLANES as usize {
return Err(Self::Error::TooManyPlanes(format.plane_fmt.len()));
}
let mut pix_mp = bindings::v4l2_pix_format_mplane {
width: format.width,
height: format.height,
pixelformat: format.pixelformat.into(),
num_planes: format.plane_fmt.len() as u8,
plane_fmt: Default::default(),
..Default::default()
};
for (plane, v4l2_plane) in
format.plane_fmt.iter().zip(pix_mp.plane_fmt.iter_mut())
{
*v4l2_plane = plane.into();
}
pix_mp
},
}
}
_ => bindings::v4l2_format__bindgen_ty_1 {
pix: {
if format.plane_fmt.len() > 1 {
return Err(Self::Error::TooManyPlanes(format.plane_fmt.len()));
}
let (bytesperline, sizeimage) = if !format.plane_fmt.is_empty() {
(
format.plane_fmt[0].bytesperline,
format.plane_fmt[0].sizeimage,
)
} else {
Default::default()
};
bindings::v4l2_pix_format {
width: format.width,
height: format.height,
pixelformat: format.pixelformat.into(),
bytesperline,
sizeimage,
..Default::default()
}
},
},
},
})
}
}
impl From<&PlaneLayout> for bindings::v4l2_plane_pix_format {
fn from(plane: &PlaneLayout) -> Self {
bindings::v4l2_plane_pix_format {
sizeimage: plane.sizeimage,
bytesperline: plane.bytesperline,
..Default::default()
}
}
}
#[doc(hidden)]
mod ioctl {
use crate::bindings::v4l2_format;
nix::ioctl_readwrite!(vidioc_g_fmt, b'V', 4, v4l2_format);
nix::ioctl_readwrite!(vidioc_s_fmt, b'V', 5, v4l2_format);
nix::ioctl_readwrite!(vidioc_try_fmt, b'V', 64, v4l2_format);
}
#[derive(Debug, Error)]
pub enum GFmtError {
#[error("error while converting from V4L2 format")]
FromV4L2FormatConversionError,
#[error("invalid buffer type requested")]
InvalidBufferType,
#[error("unexpected ioctl error: {0}")]
IoctlError(nix::Error),
}
impl From<GFmtError> for Errno {
fn from(err: GFmtError) -> Self {
match err {
GFmtError::FromV4L2FormatConversionError => Errno::EINVAL,
GFmtError::InvalidBufferType => Errno::EINVAL,
GFmtError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_G_FMT` ioctl.
pub fn g_fmt<O: TryFrom<v4l2_format>>(fd: &impl AsRawFd, queue: QueueType) -> Result<O, GFmtError> {
let mut fmt = v4l2_format {
type_: queue as u32,
..Default::default()
};
match unsafe { ioctl::vidioc_g_fmt(fd.as_raw_fd(), &mut fmt) } {
Ok(_) => Ok(fmt
.try_into()
.map_err(|_| GFmtError::FromV4L2FormatConversionError)?),
Err(Errno::EINVAL) => Err(GFmtError::InvalidBufferType),
Err(e) => Err(GFmtError::IoctlError(e)),
}
}
#[derive(Debug, Error)]
pub enum SFmtError {
#[error("error while converting from V4L2 format")]
FromV4L2FormatConversionError,
#[error("error while converting to V4L2 format")]
ToV4L2FormatConversionError,
#[error("invalid buffer type requested")]
InvalidBufferType,
#[error("device currently busy")]
DeviceBusy,
#[error("ioctl error: {0}")]
IoctlError(nix::Error),
}
impl From<SFmtError> for Errno {
fn from(err: SFmtError) -> Self {
match err {
SFmtError::FromV4L2FormatConversionError => Errno::EINVAL,
SFmtError::ToV4L2FormatConversionError => Errno::EINVAL,
SFmtError::InvalidBufferType => Errno::EINVAL,
SFmtError::DeviceBusy => Errno::EBUSY,
SFmtError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_S_FMT` ioctl.
pub fn s_fmt<I: TryInto<v4l2_format>, O: TryFrom<v4l2_format>>(
fd: &mut impl AsRawFd,
format: I,
) -> Result<O, SFmtError> {
let mut fmt: v4l2_format = format
.try_into()
.map_err(|_| SFmtError::ToV4L2FormatConversionError)?;
match unsafe { ioctl::vidioc_s_fmt(fd.as_raw_fd(), &mut fmt) } {
Ok(_) => Ok(fmt
.try_into()
.map_err(|_| SFmtError::FromV4L2FormatConversionError)?),
Err(Errno::EINVAL) => Err(SFmtError::InvalidBufferType),
Err(Errno::EBUSY) => Err(SFmtError::DeviceBusy),
Err(e) => Err(SFmtError::IoctlError(e)),
}
}
#[derive(Debug, Error)]
pub enum TryFmtError {
#[error("error while converting from V4L2 format")]
FromV4L2FormatConversionError,
#[error("error while converting to V4L2 format")]
ToV4L2FormatConversionError,
#[error("invalid buffer type requested")]
InvalidBufferType,
#[error("ioctl error: {0}")]
IoctlError(nix::Error),
}
impl From<TryFmtError> for Errno {
fn from(err: TryFmtError) -> Self {
match err {
TryFmtError::FromV4L2FormatConversionError => Errno::EINVAL,
TryFmtError::ToV4L2FormatConversionError => Errno::EINVAL,
TryFmtError::InvalidBufferType => Errno::EINVAL,
TryFmtError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_TRY_FMT` ioctl.
pub fn try_fmt<I: TryInto<v4l2_format>, O: TryFrom<v4l2_format>>(
fd: &impl AsRawFd,
format: I,
) -> Result<O, TryFmtError> {
let mut fmt: v4l2_format = format
.try_into()
.map_err(|_| TryFmtError::ToV4L2FormatConversionError)?;
match unsafe { ioctl::vidioc_try_fmt(fd.as_raw_fd(), &mut fmt) } {
Ok(_) => Ok(fmt
.try_into()
.map_err(|_| TryFmtError::FromV4L2FormatConversionError)?),
Err(Errno::EINVAL) => Err(TryFmtError::InvalidBufferType),
Err(e) => Err(TryFmtError::IoctlError(e)),
}
}
#[cfg(test)]
mod test {
use super::*;
use std::convert::TryInto;
#[test]
// Convert from Format to multi-planar v4l2_format and back.
fn mplane_to_v4l2_format() {
// This is not a real format but let us use unique values per field.
let mplane = Format {
width: 632,
height: 480,
pixelformat: b"NM12".into(),
plane_fmt: vec![
PlaneLayout {
sizeimage: 307200,
bytesperline: 640,
},
PlaneLayout {
sizeimage: 153600,
bytesperline: 320,
},
PlaneLayout {
sizeimage: 76800,
bytesperline: 160,
},
],
};
let v4l2_format = v4l2_format {
..(QueueType::VideoCaptureMplane, &mplane).try_into().unwrap()
};
let mplane2: Format = v4l2_format.try_into().unwrap();
assert_eq!(mplane, mplane2);
}
#[test]
// Convert from Format to single-planar v4l2_format and back.
fn splane_to_v4l2_format() {
// This is not a real format but let us use unique values per field.
let splane = Format {
width: 632,
height: 480,
pixelformat: b"NV12".into(),
plane_fmt: vec![PlaneLayout {
sizeimage: 307200,
bytesperline: 640,
}],
};
// Conversion to/from single-planar format.
let v4l2_format = v4l2_format {
..(QueueType::VideoCapture, &splane).try_into().unwrap()
};
let splane2: Format = v4l2_format.try_into().unwrap();
assert_eq!(splane, splane2);
// Trying to use a multi-planar format with the single-planar API should
// fail.
let mplane = Format {
width: 632,
height: 480,
pixelformat: b"NM12".into(),
// This is not a real format but let us use unique values per field.
plane_fmt: vec![
PlaneLayout {
sizeimage: 307200,
bytesperline: 640,
},
PlaneLayout {
sizeimage: 153600,
bytesperline: 320,
},
PlaneLayout {
sizeimage: 76800,
bytesperline: 160,
},
],
};
assert_eq!(
TryInto::<v4l2_format>::try_into((QueueType::VideoCapture, &mplane)).err(),
Some(FormatConversionError::TooManyPlanes(3))
);
}
}

View File

@@ -0,0 +1,149 @@
use std::os::unix::io::AsRawFd;
use nix::errno::Errno;
use thiserror::Error;
use crate::bindings::v4l2_standard;
use crate::bindings::v4l2_std_id;
use crate::bindings::v4l2_streamparm;
use crate::QueueType;
#[doc(hidden)]
mod ioctl {
use crate::bindings::v4l2_standard;
use crate::bindings::v4l2_std_id;
use crate::bindings::v4l2_streamparm;
nix::ioctl_readwrite!(vidioc_g_parm, b'V', 21, v4l2_streamparm);
nix::ioctl_readwrite!(vidioc_s_parm, b'V', 22, v4l2_streamparm);
nix::ioctl_read!(vidioc_g_std, b'V', 23, v4l2_std_id);
nix::ioctl_write_ptr!(vidioc_s_std, b'V', 24, v4l2_std_id);
nix::ioctl_readwrite!(vidioc_enumstd, b'V', 25, v4l2_standard);
nix::ioctl_read!(vidioc_querystd, b'V', 63, v4l2_std_id);
}
#[derive(Debug, Error)]
pub enum GParmError {
#[error("ioctl error: {0}")]
IoctlError(Errno),
}
impl From<GParmError> for Errno {
fn from(err: GParmError) -> Self {
match err {
GParmError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_G_PARM` ioctl.
pub fn g_parm<O: From<v4l2_streamparm>>(
fd: &impl AsRawFd,
queue: QueueType,
) -> Result<O, GParmError> {
let mut parm = v4l2_streamparm {
type_: queue as u32,
..Default::default()
};
match unsafe { ioctl::vidioc_g_parm(fd.as_raw_fd(), &mut parm) } {
Ok(_) => Ok(O::from(parm)),
Err(e) => Err(GParmError::IoctlError(e)),
}
}
/// Safe wrapper around the `VIDIOC_S_PARM` ioctl.
pub fn s_parm<I: Into<v4l2_streamparm>, O: From<v4l2_streamparm>>(
fd: &impl AsRawFd,
parm: I,
) -> Result<O, GParmError> {
let mut parm = parm.into();
match unsafe { ioctl::vidioc_s_parm(fd.as_raw_fd(), &mut parm) } {
Ok(_) => Ok(O::from(parm)),
Err(e) => Err(GParmError::IoctlError(e)),
}
}
/// Safe wrapper around the `VIDIOC_G_STD` ioctl.
pub fn g_std<O: From<v4l2_std_id>>(fd: &impl AsRawFd) -> Result<O, GParmError> {
let mut std_id: v4l2_std_id = 0;
match unsafe { ioctl::vidioc_g_std(fd.as_raw_fd(), &mut std_id) } {
Ok(_) => Ok(O::from(std_id)),
Err(e) => Err(GParmError::IoctlError(e)),
}
}
#[derive(Debug, Error)]
pub enum SStdError {
#[error("unsupported standard requested")]
Unsupported,
#[error("ioctl error: {0}")]
IoctlError(Errno),
}
impl From<SStdError> for Errno {
fn from(err: SStdError) -> Self {
match err {
SStdError::Unsupported => Errno::EINVAL,
SStdError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_S_STD` ioctl.
pub fn s_std<I: Into<v4l2_std_id>>(fd: &impl AsRawFd, std_id: I) -> Result<(), SStdError> {
let std_id = std_id.into();
match unsafe { ioctl::vidioc_s_std(fd.as_raw_fd(), &std_id) } {
Ok(_) => Ok(()),
Err(Errno::EINVAL) => Err(SStdError::Unsupported),
Err(e) => Err(SStdError::IoctlError(e)),
}
}
#[derive(Debug, Error)]
pub enum EnumStdError {
#[error("requested index is out of bounds")]
OutOfBounds,
#[error("standard video timings are not supported for this input or output")]
Unsupported,
#[error("ioctl error: {0}")]
IoctlError(Errno),
}
impl From<EnumStdError> for Errno {
fn from(err: EnumStdError) -> Self {
match err {
EnumStdError::OutOfBounds => Errno::EINVAL,
EnumStdError::Unsupported => Errno::ENODATA,
EnumStdError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_ENUMSTD` ioctl.
pub fn enumstd<O: From<v4l2_standard>>(fd: &impl AsRawFd, index: u32) -> Result<O, EnumStdError> {
let mut standard = v4l2_standard {
index,
..Default::default()
};
match unsafe { ioctl::vidioc_enumstd(fd.as_raw_fd(), &mut standard) } {
Ok(_) => Ok(O::from(standard)),
Err(Errno::EINVAL) => Err(EnumStdError::OutOfBounds),
Err(Errno::ENODATA) => Err(EnumStdError::Unsupported),
Err(e) => Err(EnumStdError::IoctlError(e)),
}
}
/// Safe wrapper around the `VIDIOC_QUERYSTD` ioctl.
pub fn querystd<O: From<v4l2_std_id>>(fd: &impl AsRawFd) -> Result<O, GParmError> {
let mut std_id: v4l2_std_id = 0;
match unsafe { ioctl::vidioc_querystd(fd.as_raw_fd(), &mut std_id) } {
Ok(_) => Ok(O::from(std_id)),
Err(e) => Err(GParmError::IoctlError(e)),
}
}

View File

@@ -0,0 +1,130 @@
use std::os::unix::io::AsRawFd;
use bitflags::bitflags;
use enumn::N;
use nix::errno::Errno;
use thiserror::Error;
use crate::bindings;
use crate::bindings::v4l2_rect;
use crate::bindings::v4l2_selection;
#[derive(Debug, N, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum SelectionType {
Capture = bindings::v4l2_buf_type_V4L2_BUF_TYPE_VIDEO_CAPTURE,
Output = bindings::v4l2_buf_type_V4L2_BUF_TYPE_VIDEO_OUTPUT,
}
#[derive(Debug, N, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum SelectionTarget {
Crop = bindings::V4L2_SEL_TGT_CROP,
CropDefault = bindings::V4L2_SEL_TGT_CROP_DEFAULT,
CropBounds = bindings::V4L2_SEL_TGT_CROP_BOUNDS,
NativeSize = bindings::V4L2_SEL_TGT_NATIVE_SIZE,
Compose = bindings::V4L2_SEL_TGT_COMPOSE,
ComposeDefault = bindings::V4L2_SEL_TGT_COMPOSE_DEFAULT,
ComposeBounds = bindings::V4L2_SEL_TGT_COMPOSE_BOUNDS,
ComposePadded = bindings::V4L2_SEL_TGT_COMPOSE_PADDED,
}
bitflags! {
#[derive(Clone, Copy, Debug)]
pub struct SelectionFlags: u32 {
const GE = bindings::V4L2_SEL_FLAG_GE;
const LE = bindings::V4L2_SEL_FLAG_LE;
const KEEP_CONFIG = bindings::V4L2_SEL_FLAG_KEEP_CONFIG;
}
}
#[doc(hidden)]
mod ioctl {
use crate::bindings::v4l2_selection;
nix::ioctl_readwrite!(vidioc_g_selection, b'V', 94, v4l2_selection);
nix::ioctl_readwrite!(vidioc_s_selection, b'V', 95, v4l2_selection);
}
#[derive(Debug, Error)]
pub enum GSelectionError {
#[error("invalid type or target requested")]
Invalid,
#[error("ioctl error: {0}")]
IoctlError(Errno),
}
impl From<GSelectionError> for Errno {
fn from(err: GSelectionError) -> Self {
match err {
GSelectionError::Invalid => Errno::EINVAL,
GSelectionError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_G_SELECTION` ioctl.
pub fn g_selection<R: From<v4l2_rect>>(
fd: &impl AsRawFd,
selection: SelectionType,
target: SelectionTarget,
) -> Result<R, GSelectionError> {
let mut sel = v4l2_selection {
type_: selection as u32,
target: target as u32,
..Default::default()
};
match unsafe { ioctl::vidioc_g_selection(fd.as_raw_fd(), &mut sel) } {
Ok(_) => Ok(R::from(sel.r)),
Err(Errno::EINVAL) => Err(GSelectionError::Invalid),
Err(e) => Err(GSelectionError::IoctlError(e)),
}
}
#[derive(Debug, Error)]
pub enum SSelectionError {
#[error("invalid type or target requested")]
Invalid,
#[error("invalid range requested")]
InvalidRange,
#[error("cannot change selection rectangle currently")]
Busy,
#[error("ioctl error: {0}")]
IoctlError(nix::Error),
}
impl From<SSelectionError> for Errno {
fn from(err: SSelectionError) -> Self {
match err {
SSelectionError::Invalid => Errno::EINVAL,
SSelectionError::InvalidRange => Errno::ERANGE,
SSelectionError::Busy => Errno::EBUSY,
SSelectionError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_S_SELECTION` ioctl.
pub fn s_selection<RI: Into<v4l2_rect>, RO: From<v4l2_rect>>(
fd: &impl AsRawFd,
selection: SelectionType,
target: SelectionTarget,
rect: RI,
flags: SelectionFlags,
) -> Result<RO, SSelectionError> {
let mut sel = v4l2_selection {
type_: selection as u32,
target: target as u32,
flags: flags.bits(),
r: rect.into(),
..Default::default()
};
match unsafe { ioctl::vidioc_s_selection(fd.as_raw_fd(), &mut sel) } {
Ok(_) => Ok(RO::from(sel.r)),
Err(Errno::EINVAL) => Err(SSelectionError::Invalid),
Err(Errno::ERANGE) => Err(SSelectionError::InvalidRange),
Err(Errno::EBUSY) => Err(SSelectionError::Busy),
Err(e) => Err(SSelectionError::IoctlError(e)),
}
}

View File

@@ -0,0 +1,117 @@
use core::num::NonZeroUsize;
use std::{
cmp::{max, min},
ops::Deref,
ptr::NonNull,
slice,
};
use std::{ops::DerefMut, os::unix::io::AsFd};
use log::error;
use nix::{errno::Errno, libc::off_t, sys::mman};
use thiserror::Error;
pub struct PlaneMapping {
// A mapping remains valid until we munmap it, that is, until the
// PlaneMapping object is deleted. Hence the static lifetime.
pub data: &'static mut [u8],
start: usize,
end: usize,
}
impl PlaneMapping {
pub fn size(&self) -> usize {
self.end - self.start
}
pub fn restrict(mut self, start: usize, end: usize) -> Self {
self.start = max(self.start, start);
self.end = min(self.end, end);
self
}
}
impl AsRef<[u8]> for PlaneMapping {
fn as_ref(&self) -> &[u8] {
&self.data[self.start..self.end]
}
}
impl AsMut<[u8]> for PlaneMapping {
fn as_mut(&mut self) -> &mut [u8] {
&mut self.data[self.start..self.end]
}
}
impl Deref for PlaneMapping {
type Target = [u8];
fn deref(&self) -> &Self::Target {
&self.data[self.start..self.end]
}
}
impl DerefMut for PlaneMapping {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.data[self.start..self.end]
}
}
impl Drop for PlaneMapping {
fn drop(&mut self) {
// Safe because the pointer and length were constructed in mmap() and
// are always valid.
unsafe {
mman::munmap(
NonNull::new_unchecked(self.data.as_mut_ptr().cast()),
self.data.len(),
)
}
.unwrap_or_else(|e| {
error!("Error while unmapping plane: {}", e);
});
}
}
#[derive(Debug, Error)]
pub enum MmapError {
#[error("provided length was 0")]
ZeroLength,
#[error("ioctl error: {0}")]
IoctlError(#[from] Errno),
}
impl From<MmapError> for Errno {
fn from(err: MmapError) -> Self {
match err {
MmapError::ZeroLength => Errno::EINVAL,
MmapError::IoctlError(e) => e,
}
}
}
// TODO should be unsafe because the mapping can be used after a buffer is queued?
// Or not, since this cannot cause a crash...
pub fn mmap(fd: &impl AsFd, mem_offset: u32, length: u32) -> Result<PlaneMapping, MmapError> {
let non_zero_length = NonZeroUsize::new(length as usize).ok_or(MmapError::ZeroLength)?;
let data = unsafe {
mman::mmap(
None,
non_zero_length,
mman::ProtFlags::PROT_READ | mman::ProtFlags::PROT_WRITE,
mman::MapFlags::MAP_SHARED,
fd,
mem_offset as off_t,
)
}?;
Ok(PlaneMapping {
// Safe because we know the pointer is valid and has enough data mapped
// to cover the length.
data: unsafe { slice::from_raw_parts_mut(data.as_ptr().cast(), length as usize) },
start: 0,
end: length as usize,
})
}

View File

@@ -0,0 +1,197 @@
//! Safe wrapper for the VIDIOC_(D)QBUF and VIDIOC_QUERYBUF ioctls.
use nix::errno::Errno;
use nix::libc::{suseconds_t, time_t};
use nix::sys::time::{TimeVal, TimeValLike};
use std::convert::TryFrom;
use std::fmt::Debug;
use std::os::unix::io::AsRawFd;
use thiserror::Error;
use crate::bindings;
use crate::ioctl::ioctl_and_convert;
use crate::ioctl::BufferFlags;
use crate::ioctl::IoctlConvertError;
use crate::ioctl::IoctlConvertResult;
use crate::ioctl::UncheckedV4l2Buffer;
use crate::memory::Memory;
use crate::memory::PlaneHandle;
use crate::QueueType;
#[derive(Debug, Error)]
pub enum QBufIoctlError {
#[error("invalid number of planes specified for the buffer: got {0}, expected {1}")]
NumPlanesMismatch(usize, usize),
#[error("data offset specified while using the single-planar API")]
DataOffsetNotSupported,
#[error("unexpected ioctl error: {0}")]
Other(Errno),
}
impl From<Errno> for QBufIoctlError {
fn from(errno: Errno) -> Self {
Self::Other(errno)
}
}
impl From<QBufIoctlError> for Errno {
fn from(err: QBufIoctlError) -> Self {
match err {
QBufIoctlError::NumPlanesMismatch(_, _) => Errno::EINVAL,
QBufIoctlError::DataOffsetNotSupported => Errno::EINVAL,
QBufIoctlError::Other(e) => e,
}
}
}
/// Representation of a single plane of a V4L2 buffer.
pub struct QBufPlane(pub bindings::v4l2_plane);
impl QBufPlane {
// TODO remove as this is not safe - we should always specify a handle.
pub fn new(bytes_used: usize) -> Self {
QBufPlane(bindings::v4l2_plane {
bytesused: bytes_used as u32,
data_offset: 0,
..Default::default()
})
}
pub fn new_from_handle<H: PlaneHandle>(handle: &H, bytes_used: usize) -> Self {
let mut plane = Self::new(bytes_used);
handle.fill_v4l2_plane(&mut plane.0);
plane
}
}
impl Debug for QBufPlane {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("QBufPlane")
.field("bytesused", &self.0.bytesused)
.field("data_offset", &self.0.data_offset)
.finish()
}
}
/// Contains all the information that can be passed to the `qbuf` ioctl.
// TODO Change this to contain a v4l2_buffer, and create constructors/methods
// to change it? Then during qbuf we just need to set m.planes to planes
// (after resizing it to 8) and we are good to use it as-is.
// We could even turn the trait into AsRef<v4l2_buffer> for good measure.
#[derive(Debug)]
pub struct QBuffer<H: PlaneHandle> {
index: u32,
queue: QueueType,
pub flags: BufferFlags,
pub field: u32,
pub sequence: u32,
pub timestamp: TimeVal,
pub planes: Vec<QBufPlane>,
pub _h: std::marker::PhantomData<H>,
}
impl<H: PlaneHandle> QBuffer<H> {
pub fn new(queue: QueueType, index: u32) -> Self {
QBuffer {
index,
queue,
flags: Default::default(),
field: Default::default(),
sequence: Default::default(),
timestamp: TimeVal::zero(),
planes: Vec::new(),
_h: std::marker::PhantomData,
}
}
}
impl<H: PlaneHandle> QBuffer<H> {
pub fn set_timestamp(mut self, sec: time_t, usec: suseconds_t) -> Self {
self.timestamp = TimeVal::new(sec, usec);
self
}
}
impl<H: PlaneHandle> From<QBuffer<H>> for UncheckedV4l2Buffer {
fn from(qbuf: QBuffer<H>) -> Self {
let mut v4l2_buf = UncheckedV4l2Buffer::new_for_querybuf(qbuf.queue, Some(qbuf.index));
v4l2_buf.0.index = qbuf.index;
v4l2_buf.0.type_ = qbuf.queue as u32;
v4l2_buf.0.memory = H::Memory::MEMORY_TYPE as u32;
v4l2_buf.0.flags = qbuf.flags.bits();
v4l2_buf.0.field = qbuf.field;
v4l2_buf.0.sequence = qbuf.sequence;
v4l2_buf.0.timestamp.tv_sec = qbuf.timestamp.tv_sec();
v4l2_buf.0.timestamp.tv_usec = qbuf.timestamp.tv_usec();
if let Some(planes) = &mut v4l2_buf.1 {
for (dst_plane, src_plane) in planes.iter_mut().zip(qbuf.planes.into_iter()) {
*dst_plane = src_plane.0;
}
} else {
let plane = &qbuf.planes[0];
v4l2_buf.0.length = plane.0.length;
v4l2_buf.0.bytesused = plane.0.bytesused;
v4l2_buf.0.m = (&plane.0.m, H::Memory::MEMORY_TYPE).into();
}
v4l2_buf
}
}
#[doc(hidden)]
mod ioctl {
use crate::bindings::v4l2_buffer;
nix::ioctl_readwrite!(vidioc_querybuf, b'V', 9, v4l2_buffer);
nix::ioctl_readwrite!(vidioc_qbuf, b'V', 15, v4l2_buffer);
nix::ioctl_readwrite!(vidioc_dqbuf, b'V', 17, v4l2_buffer);
nix::ioctl_readwrite!(vidioc_prepare_buf, b'V', 93, v4l2_buffer);
}
pub type QBufError<CE> = IoctlConvertError<QBufIoctlError, CE>;
pub type QBufResult<O, CE> = IoctlConvertResult<O, QBufIoctlError, CE>;
/// Safe wrapper around the `VIDIOC_QBUF` ioctl.
///
/// TODO: `qbuf` should be unsafe! The following invariants need to be guaranteed
/// by the caller:
///
/// For MMAP buffers, any mapping must not be accessed by the caller (or any
/// mapping must be unmapped before queueing?). Also if the buffer has been
/// DMABUF-exported, its consumers must likewise not access it.
///
/// For DMABUF buffers, the FD must not be duplicated and accessed anywhere else.
///
/// For USERPTR buffers, things are most tricky. Not only must the data not be
/// accessed by anyone else, the caller also needs to guarantee that the backing
/// memory won't be freed until the corresponding buffer is returned by either
/// `dqbuf` or `streamoff`.
pub fn qbuf<I, O>(fd: &impl AsRawFd, buffer: I) -> QBufResult<O, O::Error>
where
I: Into<UncheckedV4l2Buffer>,
O: TryFrom<UncheckedV4l2Buffer>,
O::Error: std::fmt::Debug,
{
let mut v4l2_buf: UncheckedV4l2Buffer = buffer.into();
ioctl_and_convert(
unsafe { ioctl::vidioc_qbuf(fd.as_raw_fd(), v4l2_buf.as_mut()) }
.map(|_| v4l2_buf)
.map_err(Into::into),
)
}
/// Safe wrapper around the `VIDIOC_PREPARE_BUF` ioctl.
pub fn prepare_buf<I, O>(fd: &impl AsRawFd, buffer: I) -> QBufResult<O, O::Error>
where
I: Into<UncheckedV4l2Buffer>,
O: TryFrom<UncheckedV4l2Buffer>,
O::Error: std::fmt::Debug,
{
let mut v4l2_buf: UncheckedV4l2Buffer = buffer.into();
ioctl_and_convert(
unsafe { ioctl::vidioc_prepare_buf(fd.as_raw_fd(), v4l2_buf.as_mut()) }
.map(|_| v4l2_buf)
.map_err(Into::into),
)
}

View File

@@ -0,0 +1,113 @@
use std::convert::Infallible;
use std::convert::TryFrom;
use std::os::unix::io::AsRawFd;
use nix::errno::Errno;
use thiserror::Error;
use crate::ioctl::ioctl_and_convert;
use crate::ioctl::BufferFlags;
use crate::ioctl::IoctlConvertError;
use crate::ioctl::IoctlConvertResult;
use crate::ioctl::UncheckedV4l2Buffer;
use crate::QueueType;
#[derive(Debug)]
pub struct QueryBufPlane {
/// Offset to pass to `mmap()` in order to obtain a mapping for this plane.
pub mem_offset: u32,
/// Length of this plane.
pub length: u32,
}
/// Contains information about a buffer's layout, as obtained from [`crate::ioctl::querybuf`].
///
/// It is a subset of [`crate::ioctl::V4l2Buffer`], only more convenient on occasion because its
/// conversion from an unchecked v4l2_buffer cannot fail.
///
/// Single-planar buffers have one entry in [`planes`] representing the layout of their unique
/// plane.
#[derive(Debug)]
pub struct QueryBuffer {
pub index: usize,
pub flags: BufferFlags,
pub planes: Vec<QueryBufPlane>,
}
impl TryFrom<UncheckedV4l2Buffer> for QueryBuffer {
type Error = Infallible;
fn try_from(buffer: UncheckedV4l2Buffer) -> Result<Self, Self::Error> {
let v4l2_buf = buffer.0;
let planes = match buffer.1 {
None => vec![QueryBufPlane {
mem_offset: unsafe { v4l2_buf.m.offset },
length: v4l2_buf.length,
}],
Some(v4l2_planes) => v4l2_planes
.iter()
.take(v4l2_buf.length as usize)
.map(|v4l2_plane| QueryBufPlane {
mem_offset: unsafe { v4l2_plane.m.mem_offset },
length: v4l2_plane.length,
})
.collect(),
};
Ok(QueryBuffer {
index: v4l2_buf.index as usize,
flags: BufferFlags::from_bits_truncate(v4l2_buf.flags),
planes,
})
}
}
#[doc(hidden)]
mod ioctl {
use crate::bindings::v4l2_buffer;
nix::ioctl_readwrite!(vidioc_querybuf, b'V', 9, v4l2_buffer);
}
#[derive(Debug, Error)]
pub enum QueryBufIoctlError {
#[error("unsupported queue or out-of-bounds index")]
InvalidInput,
#[error("unexpected ioctl error: {0}")]
Other(Errno),
}
impl From<Errno> for QueryBufIoctlError {
fn from(err: Errno) -> Self {
match err {
Errno::EINVAL => QueryBufIoctlError::InvalidInput,
e => QueryBufIoctlError::Other(e),
}
}
}
impl From<QueryBufIoctlError> for Errno {
fn from(err: QueryBufIoctlError) -> Self {
match err {
QueryBufIoctlError::InvalidInput => Errno::EINVAL,
QueryBufIoctlError::Other(e) => e,
}
}
}
pub type QueryBufError<CE> = IoctlConvertError<QueryBufIoctlError, CE>;
pub type QueryBufResult<O, CE> = IoctlConvertResult<O, QueryBufIoctlError, CE>;
/// Safe wrapper around the `VIDIOC_QUERYBUF` ioctl.
pub fn querybuf<O>(fd: &impl AsRawFd, queue: QueueType, index: usize) -> QueryBufResult<O, O::Error>
where
O: TryFrom<UncheckedV4l2Buffer>,
O::Error: std::fmt::Debug,
{
let mut v4l2_buf = UncheckedV4l2Buffer::new_for_querybuf(queue, Some(index as u32));
ioctl_and_convert(
unsafe { ioctl::vidioc_querybuf(fd.as_raw_fd(), v4l2_buf.as_mut()) }
.map(|_| v4l2_buf)
.map_err(Into::into),
)
}

View File

@@ -0,0 +1,136 @@
//! Safe wrapper for the `VIDIOC_QUERYCAP` ioctl.
use super::string_from_cstr;
use crate::bindings;
use crate::bindings::v4l2_capability;
use bitflags::bitflags;
use nix::errno::Errno;
use std::fmt;
use std::os::unix::io::AsRawFd;
use thiserror::Error;
bitflags! {
/// Flags returned by the `VIDIOC_QUERYCAP` ioctl into the `capabilities`
/// or `device_capabilities` field of `v4l2_capability`.
#[derive(Clone, Copy, Debug)]
pub struct Capabilities: u32 {
const VIDEO_CAPTURE = bindings::V4L2_CAP_VIDEO_CAPTURE;
const VIDEO_OUTPUT = bindings::V4L2_CAP_VIDEO_OUTPUT;
const VIDEO_OVERLAY = bindings::V4L2_CAP_VIDEO_OVERLAY;
const VBI_CAPTURE = bindings::V4L2_CAP_VBI_CAPTURE;
const VBI_OUTPUT = bindings::V4L2_CAP_VBI_OUTPUT;
const SLICED_VBI_CAPTURE = bindings::V4L2_CAP_SLICED_VBI_CAPTURE;
const SLICED_VBI_OUTPUT = bindings::V4L2_CAP_SLICED_VBI_OUTPUT;
const RDS_CAPTURE = bindings::V4L2_CAP_RDS_CAPTURE;
const VIDEO_OUTPUT_OVERLAY = bindings::V4L2_CAP_VIDEO_OUTPUT_OVERLAY;
const HW_FREQ_SEEK = bindings::V4L2_CAP_HW_FREQ_SEEK;
const RDS_OUTPUT = bindings::V4L2_CAP_RDS_OUTPUT;
const VIDEO_CAPTURE_MPLANE = bindings::V4L2_CAP_VIDEO_CAPTURE_MPLANE;
const VIDEO_OUTPUT_MPLANE = bindings::V4L2_CAP_VIDEO_OUTPUT_MPLANE;
const VIDEO_M2M_MPLANE = bindings::V4L2_CAP_VIDEO_M2M_MPLANE;
const VIDEO_M2M = bindings::V4L2_CAP_VIDEO_M2M;
const TUNER = bindings::V4L2_CAP_TUNER;
const AUDIO = bindings::V4L2_CAP_AUDIO;
const RADIO = bindings::V4L2_CAP_RADIO;
const MODULATOR = bindings::V4L2_CAP_MODULATOR;
const SDR_CAPTURE = bindings::V4L2_CAP_SDR_CAPTURE;
const EXT_PIX_FORMAT = bindings::V4L2_CAP_EXT_PIX_FORMAT;
const SDR_OUTPUT = bindings::V4L2_CAP_SDR_OUTPUT;
const META_CAPTURE = bindings::V4L2_CAP_META_CAPTURE;
const READWRITE = bindings::V4L2_CAP_READWRITE;
const ASYNCIO = bindings::V4L2_CAP_ASYNCIO;
const STREAMING = bindings::V4L2_CAP_STREAMING;
const META_OUTPUT = bindings::V4L2_CAP_META_OUTPUT;
const TOUCH = bindings::V4L2_CAP_TOUCH;
const DEVICE_CAPS = bindings::V4L2_CAP_DEVICE_CAPS;
}
}
impl fmt::Display for Capabilities {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
fmt::Debug::fmt(self, f)
}
}
/// Used to get the capability flags from a `VIDIOC_QUERYCAP` ioctl.
impl From<v4l2_capability> for Capabilities {
fn from(qcap: v4l2_capability) -> Self {
Capabilities::from_bits_truncate(qcap.capabilities)
}
}
/// Safe variant of the `v4l2_capability` struct, to be used with `querycap`.
#[derive(Debug)]
pub struct Capability {
pub driver: String,
pub card: String,
pub bus_info: String,
pub version: u32,
pub capabilities: Capabilities,
pub device_caps: Option<Capabilities>,
}
impl Capability {
/// Returns the set of capabilities of the hardware as a whole.
pub fn capabilities(&self) -> Capabilities {
self.capabilities
}
/// Returns the capabilities that apply to the currently opened V4L2 node.
pub fn device_caps(&self) -> Capabilities {
self.device_caps
.unwrap_or_else(|| self.capabilities.difference(Capabilities::DEVICE_CAPS))
}
}
impl From<v4l2_capability> for Capability {
fn from(qcap: v4l2_capability) -> Self {
Capability {
driver: string_from_cstr(&qcap.driver).unwrap_or_else(|_| "".into()),
card: string_from_cstr(&qcap.card).unwrap_or_else(|_| "".into()),
bus_info: string_from_cstr(&qcap.bus_info).unwrap_or_else(|_| "".into()),
version: qcap.version,
capabilities: Capabilities::from_bits_truncate(qcap.capabilities),
device_caps: if qcap.capabilities & bindings::V4L2_CAP_DEVICE_CAPS != 0 {
Some(Capabilities::from_bits_truncate(qcap.device_caps))
} else {
None
},
}
}
}
#[doc(hidden)]
mod ioctl {
use crate::bindings::v4l2_capability;
nix::ioctl_read!(vidioc_querycap, b'V', 0, v4l2_capability);
}
#[derive(Debug, Error)]
pub enum QueryCapError {
#[error("ioctl error: {0}")]
IoctlError(Errno),
}
impl From<QueryCapError> for Errno {
fn from(err: QueryCapError) -> Self {
match err {
QueryCapError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_QUERYCAP` ioctl.
pub fn querycap<T: From<v4l2_capability>>(fd: &impl AsRawFd) -> Result<T, QueryCapError> {
let mut qcap: v4l2_capability = Default::default();
match unsafe { ioctl::vidioc_querycap(fd.as_raw_fd(), &mut qcap) } {
Ok(_) => Ok(T::from(qcap)),
Err(e) => Err(QueryCapError::IoctlError(e)),
}
}

View File

@@ -0,0 +1,162 @@
//! Safe wrapper for the `VIDIOC_REQBUFS` ioctl.
use crate::bindings;
use crate::bindings::v4l2_create_buffers;
use crate::bindings::v4l2_format;
use crate::bindings::v4l2_requestbuffers;
use crate::memory::MemoryType;
use crate::QueueType;
use bitflags::bitflags;
use nix::{self, errno::Errno};
use std::os::unix::io::AsRawFd;
use thiserror::Error;
bitflags! {
/// Flags returned by the `VIDIOC_REQBUFS` ioctl into the `capabilities`
/// field of `struct v4l2_requestbuffers`.
#[derive(Clone, Copy, Debug)]
pub struct BufferCapabilities: u32 {
const SUPPORTS_MMAP = bindings::V4L2_BUF_CAP_SUPPORTS_MMAP;
const SUPPORTS_USERPTR = bindings::V4L2_BUF_CAP_SUPPORTS_USERPTR;
const SUPPORTS_DMABUF = bindings::V4L2_BUF_CAP_SUPPORTS_DMABUF;
const SUPPORTS_REQUESTS = bindings::V4L2_BUF_CAP_SUPPORTS_REQUESTS;
const SUPPORTS_ORPHANED_BUFS = bindings::V4L2_BUF_CAP_SUPPORTS_ORPHANED_BUFS;
const SUPPORTS_M2M_HOLD_CAPTURE_BUF = bindings::V4L2_BUF_CAP_SUPPORTS_M2M_HOLD_CAPTURE_BUF;
const SUPPORTS_MMAP_CACHE_HINTS = bindings::V4L2_BUF_CAP_SUPPORTS_MMAP_CACHE_HINTS;
}
}
bitflags! {
/// Memory Consistency Flags passed to the `VIDIOC_REQBUFS` ioctl in the `flags`
/// field of `struct v4l2_requestbuffers`.
#[derive(Clone, Copy, Debug)]
pub struct MemoryConsistency: u8 {
const MEMORY_FLAG_NON_COHERENT = bindings::V4L2_MEMORY_FLAG_NON_COHERENT as u8;
}
}
impl From<v4l2_requestbuffers> for () {
fn from(_reqbufs: v4l2_requestbuffers) -> Self {}
}
/// In case we are just interested in the number of buffers that `reqbufs`
/// created.
impl From<v4l2_requestbuffers> for usize {
fn from(reqbufs: v4l2_requestbuffers) -> Self {
reqbufs.count as usize
}
}
/// If we just want to query the buffer capabilities.
impl From<v4l2_requestbuffers> for BufferCapabilities {
fn from(reqbufs: v4l2_requestbuffers) -> Self {
BufferCapabilities::from_bits_truncate(reqbufs.capabilities)
}
}
/// Full result of the `reqbufs` ioctl.
pub struct RequestBuffers {
pub count: u32,
pub capabilities: BufferCapabilities,
pub flags: MemoryConsistency,
}
impl From<v4l2_requestbuffers> for RequestBuffers {
fn from(reqbufs: v4l2_requestbuffers) -> Self {
RequestBuffers {
count: reqbufs.count,
capabilities: BufferCapabilities::from_bits_truncate(reqbufs.capabilities),
flags: MemoryConsistency::from_bits_truncate(reqbufs.flags),
}
}
}
#[doc(hidden)]
mod ioctl {
use crate::bindings::v4l2_create_buffers;
use crate::bindings::v4l2_requestbuffers;
nix::ioctl_readwrite!(vidioc_reqbufs, b'V', 8, v4l2_requestbuffers);
nix::ioctl_readwrite!(vidioc_create_bufs, b'V', 92, v4l2_create_buffers);
}
#[derive(Debug, Error)]
pub enum ReqbufsError {
#[error("invalid buffer ({0}) or memory type ({1:?}) requested")]
InvalidBufferType(QueueType, MemoryType),
#[error("ioctl error: {0}")]
IoctlError(nix::Error),
}
impl From<ReqbufsError> for Errno {
fn from(err: ReqbufsError) -> Self {
match err {
ReqbufsError::InvalidBufferType(_, _) => Errno::EINVAL,
ReqbufsError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_REQBUFS` ioctl.
pub fn reqbufs<O: From<v4l2_requestbuffers>>(
fd: &impl AsRawFd,
queue: QueueType,
memory: MemoryType,
count: u32,
flags: MemoryConsistency,
) -> Result<O, ReqbufsError> {
let mut reqbufs = v4l2_requestbuffers {
count,
type_: queue as u32,
memory: memory as u32,
flags: flags.bits(),
..Default::default()
};
match unsafe { ioctl::vidioc_reqbufs(fd.as_raw_fd(), &mut reqbufs) } {
Ok(_) => Ok(O::from(reqbufs)),
Err(Errno::EINVAL) => Err(ReqbufsError::InvalidBufferType(queue, memory)),
Err(e) => Err(ReqbufsError::IoctlError(e)),
}
}
#[derive(Debug, Error)]
pub enum CreateBufsError {
#[error("no memory available to allocate MMAP buffers")]
NoMem,
#[error("invalid format or memory type requested")]
Invalid,
#[error("ioctl error: {0}")]
IoctlError(nix::Error),
}
impl From<CreateBufsError> for Errno {
fn from(err: CreateBufsError) -> Self {
match err {
CreateBufsError::NoMem => Errno::ENOMEM,
CreateBufsError::Invalid => Errno::EINVAL,
CreateBufsError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_CREATE_BUFS` ioctl.
pub fn create_bufs<F: Into<v4l2_format>, O: From<v4l2_create_buffers>>(
fd: &impl AsRawFd,
count: u32,
memory: MemoryType,
format: F,
) -> Result<O, CreateBufsError> {
let mut create_bufs = v4l2_create_buffers {
count,
memory: memory as u32,
format: format.into(),
..Default::default()
};
match unsafe { ioctl::vidioc_create_bufs(fd.as_raw_fd(), &mut create_bufs) } {
Ok(_) => Ok(O::from(create_bufs)),
Err(Errno::ENOMEM) => Err(CreateBufsError::NoMem),
Err(Errno::EINVAL) => Err(CreateBufsError::Invalid),
Err(e) => Err(CreateBufsError::IoctlError(e)),
}
}

View File

@@ -0,0 +1,71 @@
//! Safe wrapper for the `VIDIOC_STREAM(ON|OFF)` ioctls.
use crate::QueueType;
use nix::errno::Errno;
use std::os::unix::io::AsRawFd;
use thiserror::Error;
#[doc(hidden)]
mod ioctl {
nix::ioctl_write_ptr!(vidioc_streamon, b'V', 18, u32);
nix::ioctl_write_ptr!(vidioc_streamoff, b'V', 19, u32);
}
#[derive(Debug, Error)]
pub enum StreamOnError {
#[error("queue type ({0}) not supported, or no buffers allocated or enqueued")]
InvalidQueue(QueueType),
#[error("invalid pad configuration")]
InvalidPadConfig,
#[error("invalid pipeline link configuration")]
InvalidPipelineConfig,
#[error("ioctl error: {0}")]
IoctlError(Errno),
}
impl From<StreamOnError> for Errno {
fn from(err: StreamOnError) -> Self {
match err {
StreamOnError::InvalidQueue(_) => Errno::EINVAL,
StreamOnError::InvalidPadConfig => Errno::EPIPE,
StreamOnError::InvalidPipelineConfig => Errno::ENOLINK,
StreamOnError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_STREAMON` ioctl.
pub fn streamon(fd: &impl AsRawFd, queue: QueueType) -> Result<(), StreamOnError> {
match unsafe { ioctl::vidioc_streamon(fd.as_raw_fd(), &(queue as u32)) } {
Ok(_) => Ok(()),
Err(Errno::EINVAL) => Err(StreamOnError::InvalidQueue(queue)),
Err(Errno::EPIPE) => Err(StreamOnError::InvalidPadConfig),
Err(Errno::ENOLINK) => Err(StreamOnError::InvalidPipelineConfig),
Err(e) => Err(StreamOnError::IoctlError(e)),
}
}
#[derive(Debug, Error)]
pub enum StreamOffError {
#[error("queue type not supported")]
InvalidQueue,
#[error("ioctl error: {0}")]
IoctlError(Errno),
}
impl From<StreamOffError> for Errno {
fn from(err: StreamOffError) -> Self {
match err {
StreamOffError::InvalidQueue => Errno::EINVAL,
StreamOffError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_STREAMOFF` ioctl.
pub fn streamoff(fd: &impl AsRawFd, queue: QueueType) -> Result<(), StreamOffError> {
match unsafe { ioctl::vidioc_streamoff(fd.as_raw_fd(), &(queue as u32)) } {
Ok(_) => Ok(()),
Err(Errno::EINVAL) => Err(StreamOffError::InvalidQueue),
Err(e) => Err(StreamOffError::IoctlError(e)),
}
}

View File

@@ -0,0 +1,210 @@
//! Safe wrapper for the `VIDIOC_SUBSCRIBE_EVENT` and `VIDIOC_UNSUBSCRIBE_EVENT
//! ioctls.
use std::convert::TryFrom;
use std::convert::TryInto;
use std::os::unix::io::AsRawFd;
use bitflags::bitflags;
use nix::errno::Errno;
use thiserror::Error;
use crate::bindings;
use crate::bindings::v4l2_event;
use crate::bindings::v4l2_event_subscription;
bitflags! {
#[derive(Clone, Copy, Debug)]
pub struct SubscribeEventFlags: u32 {
const SEND_INITIAL = bindings::V4L2_EVENT_SUB_FL_SEND_INITIAL;
const ALLOW_FEEDBACK = bindings::V4L2_EVENT_SUB_FL_ALLOW_FEEDBACK;
}
}
#[derive(Debug)]
pub enum EventType {
VSync,
Eos,
Ctrl(u32),
FrameSync,
SourceChange(u32),
MotionDet,
}
#[derive(Debug, Error)]
pub enum EventConversionError {
#[error("unrecognized event {0}")]
UnrecognizedEvent(u32),
#[error("unrecognized source change {0}")]
UnrecognizedSourceChange(u32),
}
impl TryFrom<&v4l2_event_subscription> for EventType {
type Error = EventConversionError;
fn try_from(event: &v4l2_event_subscription) -> Result<Self, Self::Error> {
Ok(match event.type_ {
bindings::V4L2_EVENT_VSYNC => EventType::VSync,
bindings::V4L2_EVENT_EOS => EventType::Eos,
bindings::V4L2_EVENT_CTRL => EventType::Ctrl(event.id),
bindings::V4L2_EVENT_FRAME_SYNC => EventType::FrameSync,
bindings::V4L2_EVENT_SOURCE_CHANGE => EventType::SourceChange(event.id),
bindings::V4L2_EVENT_MOTION_DET => EventType::MotionDet,
e => return Err(EventConversionError::UnrecognizedEvent(e)),
})
}
}
bitflags! {
#[derive(Clone, Copy, Debug)]
pub struct SrcChanges: u32 {
const RESOLUTION = bindings::V4L2_EVENT_SRC_CH_RESOLUTION;
}
}
#[derive(Debug)]
pub enum Event {
SrcChangeEvent(SrcChanges),
Eos,
}
impl TryFrom<v4l2_event> for Event {
type Error = EventConversionError;
fn try_from(value: v4l2_event) -> Result<Self, Self::Error> {
Ok(match value.type_ {
bindings::V4L2_EVENT_VSYNC => todo!(),
bindings::V4L2_EVENT_EOS => Event::Eos,
bindings::V4L2_EVENT_CTRL => todo!(),
bindings::V4L2_EVENT_FRAME_SYNC => todo!(),
bindings::V4L2_EVENT_SOURCE_CHANGE => {
let changes = unsafe { value.u.src_change.changes };
Event::SrcChangeEvent(
SrcChanges::from_bits(changes)
.ok_or(EventConversionError::UnrecognizedSourceChange(changes))?,
)
}
bindings::V4L2_EVENT_MOTION_DET => todo!(),
t => return Err(EventConversionError::UnrecognizedEvent(t)),
})
}
}
fn build_v4l2_event_subscription(
event: EventType,
flags: SubscribeEventFlags,
) -> v4l2_event_subscription {
v4l2_event_subscription {
type_: match event {
EventType::VSync => bindings::V4L2_EVENT_VSYNC,
EventType::Eos => bindings::V4L2_EVENT_EOS,
EventType::Ctrl(_) => bindings::V4L2_EVENT_CTRL,
EventType::FrameSync => bindings::V4L2_EVENT_FRAME_SYNC,
EventType::SourceChange(_) => bindings::V4L2_EVENT_SOURCE_CHANGE,
EventType::MotionDet => bindings::V4L2_EVENT_MOTION_DET,
},
id: match event {
EventType::Ctrl(id) => id,
EventType::SourceChange(id) => id,
_ => 0,
},
flags: flags.bits(),
..Default::default()
}
}
#[doc(hidden)]
mod ioctl {
use crate::bindings::{v4l2_event, v4l2_event_subscription};
nix::ioctl_read!(vidioc_dqevent, b'V', 89, v4l2_event);
nix::ioctl_write_ptr!(vidioc_subscribe_event, b'V', 90, v4l2_event_subscription);
nix::ioctl_write_ptr!(vidioc_unsubscribe_event, b'V', 91, v4l2_event_subscription);
}
#[derive(Debug, Error)]
pub enum SubscribeEventError {
#[error("ioctl error: {0}")]
IoctlError(#[from] Errno),
}
impl From<SubscribeEventError> for Errno {
fn from(err: SubscribeEventError) -> Self {
match err {
SubscribeEventError::IoctlError(e) => e,
}
}
}
/// Safe wrapper around the `VIDIOC_SUBSCRIBE_EVENT` ioctl.
pub fn subscribe_event(
fd: &impl AsRawFd,
event: EventType,
flags: SubscribeEventFlags,
) -> Result<(), SubscribeEventError> {
let subscription = build_v4l2_event_subscription(event, flags);
unsafe { ioctl::vidioc_subscribe_event(fd.as_raw_fd(), &subscription) }?;
Ok(())
}
/// Safe wrapper around the `VIDIOC_UNSUBSCRIBE_EVENT` ioctl.
pub fn unsubscribe_event(fd: &impl AsRawFd, event: EventType) -> Result<(), SubscribeEventError> {
let subscription = build_v4l2_event_subscription(event, SubscribeEventFlags::empty());
unsafe { ioctl::vidioc_unsubscribe_event(fd.as_raw_fd(), &subscription) }?;
Ok(())
}
/// Safe wrapper around the `VIDIOC_UNSUBSCRIBE_EVENT` ioctl to unsubscribe from all events.
pub fn unsubscribe_all_events(fd: &impl AsRawFd) -> Result<(), SubscribeEventError> {
let subscription = v4l2_event_subscription {
type_: bindings::V4L2_EVENT_ALL,
..Default::default()
};
unsafe { ioctl::vidioc_unsubscribe_event(fd.as_raw_fd(), &subscription) }?;
Ok(())
}
#[derive(Debug, Error)]
pub enum DqEventError {
#[error("no event ready for dequeue")]
NotReady,
#[error("error while converting event")]
EventConversionError,
#[error("unexpected ioctl error: {0}")]
IoctlError(Errno),
}
impl From<Errno> for DqEventError {
fn from(error: Errno) -> Self {
match error {
Errno::ENOENT => Self::NotReady,
error => Self::IoctlError(error),
}
}
}
impl From<DqEventError> for Errno {
fn from(err: DqEventError) -> Self {
match err {
DqEventError::NotReady => Errno::ENOENT,
DqEventError::EventConversionError => Errno::EINVAL,
DqEventError::IoctlError(e) => e,
}
}
}
pub fn dqevent<O: TryFrom<v4l2_event>>(fd: &impl AsRawFd) -> Result<O, DqEventError> {
let mut event: v4l2_event = Default::default();
match unsafe { ioctl::vidioc_dqevent(fd.as_raw_fd(), &mut event) } {
Ok(_) => Ok(event
.try_into()
.map_err(|_| DqEventError::EventConversionError)?),
Err(Errno::ENOENT) => Err(DqEventError::NotReady),
Err(e) => Err(DqEventError::IoctlError(e)),
}
}

496
libs/v4l2r/src/lib.rs Normal file
View File

@@ -0,0 +1,496 @@
//! This library provides the V4L2 pieces One-KVM needs for video capture:
//!
//! * The `ioctl` module provides direct, thin wrappers over the V4L2 ioctls
//! with added safety. Note that "safety" here is in terms of memory safety:
//! this layer won't guard against passing invalid data that the ioctls will
//! reject - it just makes sure that data passed from and to the kernel can
//! be accessed safely. Since this is a 1:1 mapping over the V4L2 ioctls,
//! working at this level is a bit laborious, although more comfortable than
//! doing the same in C.
//!
//! The upstream v4l2r crate also contains high-level decoder/encoder and C FFI
//! layers. This vendored copy intentionally excludes those pieces and keeps the
//! capture-oriented ioctl/memory surface only.
//!
#[doc(hidden)]
pub mod bindings;
pub mod ioctl;
pub mod memory;
// This can be needed to match nix errors that we expose.
pub use nix;
use std::convert::TryFrom;
use std::fmt;
use std::fmt::{Debug, Display};
use enumn::N;
use thiserror::Error;
// The goal of this library is to provide two layers of abstraction:
// ioctl: direct, safe counterparts of the V4L2 ioctls.
// device/queue/buffer: higher abstraction, still mapping to core V4L2 mechanics.
/// Possible directions for the queue
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum QueueDirection {
Output,
Capture,
}
/// Possible classes for this queue.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum QueueClass {
Video,
Vbi,
SlicedVbi,
VideoOverlay,
VideoMplane,
Sdr,
Meta,
}
/// Types of queues currently supported by this library.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, N)]
#[repr(u32)]
pub enum QueueType {
VideoCapture = bindings::v4l2_buf_type_V4L2_BUF_TYPE_VIDEO_CAPTURE,
VideoOutput = bindings::v4l2_buf_type_V4L2_BUF_TYPE_VIDEO_OUTPUT,
VideoOverlay = bindings::v4l2_buf_type_V4L2_BUF_TYPE_VIDEO_OVERLAY,
VbiCapture = bindings::v4l2_buf_type_V4L2_BUF_TYPE_VBI_CAPTURE,
VbiOutput = bindings::v4l2_buf_type_V4L2_BUF_TYPE_VBI_OUTPUT,
SlicedVbiCapture = bindings::v4l2_buf_type_V4L2_BUF_TYPE_SLICED_VBI_CAPTURE,
SlicedVbiOutput = bindings::v4l2_buf_type_V4L2_BUF_TYPE_SLICED_VBI_OUTPUT,
VideoOutputOverlay = bindings::v4l2_buf_type_V4L2_BUF_TYPE_VIDEO_OUTPUT_OVERLAY,
VideoCaptureMplane = bindings::v4l2_buf_type_V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE,
VideoOutputMplane = bindings::v4l2_buf_type_V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE,
SdrCapture = bindings::v4l2_buf_type_V4L2_BUF_TYPE_SDR_CAPTURE,
SdrOutput = bindings::v4l2_buf_type_V4L2_BUF_TYPE_SDR_OUTPUT,
MetaCapture = bindings::v4l2_buf_type_V4L2_BUF_TYPE_META_CAPTURE,
MetaOutput = bindings::v4l2_buf_type_V4L2_BUF_TYPE_META_OUTPUT,
}
impl QueueType {
/// Returns the queue corresponding to the passed `direction` and `class`.
pub fn from_dir_and_class(direction: QueueDirection, class: QueueClass) -> Self {
match (direction, class) {
(QueueDirection::Capture, QueueClass::Video) => Self::VideoCapture,
(QueueDirection::Output, QueueClass::Video) => Self::VideoOutput,
(QueueDirection::Capture, QueueClass::VideoOverlay) => Self::VideoOverlay,
(QueueDirection::Output, QueueClass::VideoOverlay) => Self::VideoOutputOverlay,
(QueueDirection::Capture, QueueClass::Vbi) => Self::VbiCapture,
(QueueDirection::Output, QueueClass::Vbi) => Self::VbiOutput,
(QueueDirection::Capture, QueueClass::SlicedVbi) => Self::SlicedVbiCapture,
(QueueDirection::Output, QueueClass::SlicedVbi) => Self::SlicedVbiOutput,
(QueueDirection::Capture, QueueClass::VideoMplane) => Self::VideoCaptureMplane,
(QueueDirection::Output, QueueClass::VideoMplane) => Self::VideoOutputMplane,
(QueueDirection::Capture, QueueClass::Sdr) => Self::SdrCapture,
(QueueDirection::Output, QueueClass::Sdr) => Self::SdrOutput,
(QueueDirection::Capture, QueueClass::Meta) => Self::MetaCapture,
(QueueDirection::Output, QueueClass::Meta) => Self::MetaOutput,
}
}
/// Returns whether the queue type is multiplanar.
pub fn is_multiplanar(&self) -> bool {
matches!(
self,
QueueType::VideoCaptureMplane | QueueType::VideoOutputMplane
)
}
/// Returns the direction of the queue type (Output or Capture).
pub fn direction(&self) -> QueueDirection {
match self {
QueueType::VideoOutput
| QueueType::VideoOutputMplane
| QueueType::VideoOverlay
| QueueType::VideoOutputOverlay
| QueueType::VbiOutput
| QueueType::SlicedVbiOutput
| QueueType::SdrOutput
| QueueType::MetaOutput => QueueDirection::Output,
QueueType::VideoCapture
| QueueType::VbiCapture
| QueueType::SlicedVbiCapture
| QueueType::VideoCaptureMplane
| QueueType::SdrCapture
| QueueType::MetaCapture => QueueDirection::Capture,
}
}
pub fn class(&self) -> QueueClass {
match self {
QueueType::VideoCapture | QueueType::VideoOutput => QueueClass::Video,
QueueType::VideoOverlay | QueueType::VideoOutputOverlay => QueueClass::VideoOverlay,
QueueType::VbiCapture | QueueType::VbiOutput => QueueClass::Vbi,
QueueType::SlicedVbiCapture | QueueType::SlicedVbiOutput => QueueClass::SlicedVbi,
QueueType::VideoCaptureMplane | QueueType::VideoOutputMplane => QueueClass::VideoMplane,
QueueType::SdrCapture | QueueType::SdrOutput => QueueClass::Sdr,
QueueType::MetaCapture | QueueType::MetaOutput => QueueClass::Meta,
}
}
}
impl Display for QueueType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
Debug::fmt(self, f)
}
}
/// A Fourcc pixel format, used to pass formats to V4L2. It can be converted
/// back and forth from a 32-bit integer, or a 4-bytes string.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub struct PixelFormat(u32);
impl PixelFormat {
pub const fn from_u32(v: u32) -> Self {
Self(v)
}
pub const fn to_u32(self) -> u32 {
self.0
}
pub const fn from_fourcc(n: &[u8; 4]) -> Self {
Self(n[0] as u32 | (n[1] as u32) << 8 | (n[2] as u32) << 16 | (n[3] as u32) << 24)
}
pub const fn to_fourcc(self) -> [u8; 4] {
self.0.to_le_bytes()
}
}
/// Converts a Fourcc in 32-bit integer format (like the ones passed in V4L2
/// structures) into the matching pixel format.
///
/// # Examples
///
/// ```
/// # use v4l2r::PixelFormat;
/// // Fourcc representation of NV12.
/// let nv12 = u32::from_le(0x3231564e);
/// let f = PixelFormat::from(nv12);
/// assert_eq!(u32::from(f), nv12);
/// ```
impl From<u32> for PixelFormat {
fn from(i: u32) -> Self {
Self::from_u32(i)
}
}
/// Converts a pixel format back to its 32-bit representation.
///
/// # Examples
///
/// ```
/// # use v4l2r::PixelFormat;
/// // Fourcc representation of NV12.
/// let nv12 = u32::from_le(0x3231564e);
/// let f = PixelFormat::from(nv12);
/// assert_eq!(u32::from(f), nv12);
/// ```
impl From<PixelFormat> for u32 {
fn from(format: PixelFormat) -> Self {
format.to_u32()
}
}
/// Simple way to convert a string litteral (e.g. b"NV12") into a pixel
/// format that can be passed to V4L2.
///
/// # Examples
///
/// ```
/// # use v4l2r::PixelFormat;
/// let nv12 = b"NV12";
/// let f = PixelFormat::from(nv12);
/// assert_eq!(&<[u8; 4]>::from(f), nv12);
/// ```
impl From<&[u8; 4]> for PixelFormat {
fn from(n: &[u8; 4]) -> Self {
Self::from_fourcc(n)
}
}
/// Convert a pixel format back to its 4-character representation.
///
/// # Examples
///
/// ```
/// # use v4l2r::PixelFormat;
/// let nv12 = b"NV12";
/// let f = PixelFormat::from(nv12);
/// assert_eq!(&<[u8; 4]>::from(f), nv12);
/// ```
impl From<PixelFormat> for [u8; 4] {
fn from(format: PixelFormat) -> Self {
format.to_fourcc()
}
}
/// Produces a debug string for this PixelFormat, including its hexadecimal
/// and string representation.
///
/// # Examples
///
/// ```
/// # use v4l2r::PixelFormat;
/// // Fourcc representation of NV12.
/// let nv12 = u32::from_le(0x3231564e);
/// let f = PixelFormat::from(nv12);
/// assert_eq!(format!("{:?}", f), "0x3231564e (NV12)");
/// ```
impl fmt::Debug for PixelFormat {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_fmt(format_args!("0x{:08x} ({})", self.0, self))
}
}
/// Produces a displayable form of this PixelFormat.
///
/// # Examples
///
/// ```
/// # use v4l2r::PixelFormat;
/// // Fourcc representation of NV12.
/// let nv12 = u32::from_le(0x3231564e);
/// let f = PixelFormat::from(nv12);
/// assert_eq!(f.to_string(), "NV12");
/// ```
impl fmt::Display for PixelFormat {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let fourcc = self
.0
.to_le_bytes()
.iter()
.map(|&x| x as char)
.collect::<String>();
f.write_str(fourcc.as_str())
}
}
/// Description of a single plane in a format.
#[derive(Debug, PartialEq, Clone, Default)]
pub struct PlaneLayout {
/// Useful size of the plane ; the backing memory must be at least that large.
pub sizeimage: u32,
/// Bytes per line of data. Only meaningful for image formats.
pub bytesperline: u32,
}
/// Unified representation of a V4L2 format capable of handling both single
/// and multi-planar formats. When the single-planar API is used, only
/// one plane shall be used - attempts to have more will be rejected by the
/// ioctl wrappers.
#[derive(Debug, PartialEq, Clone, Default)]
pub struct Format {
/// Width of the image in pixels.
pub width: u32,
/// Height of the image in pixels.
pub height: u32,
/// Format each pixel is encoded in.
pub pixelformat: PixelFormat,
/// Individual layout of each plane in this format. The exact number of planes
/// is defined by `pixelformat`.
pub plane_fmt: Vec<PlaneLayout>,
}
#[derive(Debug, Error, PartialEq)]
pub enum FormatConversionError {
#[error("too many planes ({0}) specified,")]
TooManyPlanes(usize),
#[error("invalid buffer type requested")]
InvalidBufferType(u32),
}
impl TryFrom<bindings::v4l2_format> for Format {
type Error = FormatConversionError;
fn try_from(fmt: bindings::v4l2_format) -> std::result::Result<Self, Self::Error> {
match fmt.type_ {
bindings::v4l2_buf_type_V4L2_BUF_TYPE_VIDEO_CAPTURE
| bindings::v4l2_buf_type_V4L2_BUF_TYPE_VIDEO_OUTPUT => {
let pix = unsafe { &fmt.fmt.pix };
Ok(Format {
width: pix.width,
height: pix.height,
pixelformat: PixelFormat::from(pix.pixelformat),
plane_fmt: vec![PlaneLayout {
bytesperline: pix.bytesperline,
sizeimage: pix.sizeimage,
}],
})
}
bindings::v4l2_buf_type_V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE
| bindings::v4l2_buf_type_V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE => {
let pix_mp = unsafe { &fmt.fmt.pix_mp };
// Can only happen if we passed a malformed v4l2_format.
if pix_mp.num_planes as usize > pix_mp.plane_fmt.len() {
return Err(Self::Error::TooManyPlanes(pix_mp.num_planes as usize));
}
let mut plane_fmt = Vec::new();
for i in 0..pix_mp.num_planes as usize {
let plane = &pix_mp.plane_fmt[i];
plane_fmt.push(PlaneLayout {
sizeimage: plane.sizeimage,
bytesperline: plane.bytesperline,
});
}
Ok(Format {
width: pix_mp.width,
height: pix_mp.height,
pixelformat: PixelFormat::from(pix_mp.pixelformat),
plane_fmt,
})
}
t => Err(Self::Error::InvalidBufferType(t)),
}
}
}
/// Quickly build a usable `Format` from a pixel format and resolution.
///
/// # Examples
///
/// ```
/// # use v4l2r::Format;
/// let f = Format::from((b"NV12", (640, 480)));
/// assert_eq!(f.width, 640);
/// assert_eq!(f.height, 480);
/// assert_eq!(f.pixelformat.to_string(), "NV12");
/// assert_eq!(f.plane_fmt.len(), 0);
/// ```
impl<T: Into<PixelFormat>> From<(T, (usize, usize))> for Format {
fn from((pixel_format, (width, height)): (T, (usize, usize))) -> Self {
Format {
width: width as u32,
height: height as u32,
pixelformat: pixel_format.into(),
..Default::default()
}
}
}
/// A more elegant representation for `v4l2_rect`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rect {
pub left: i32,
pub top: i32,
pub width: u32,
pub height: u32,
}
impl Rect {
pub fn new(left: i32, top: i32, width: u32, height: u32) -> Rect {
Rect {
left,
top,
width,
height,
}
}
}
impl From<bindings::v4l2_rect> for Rect {
fn from(rect: bindings::v4l2_rect) -> Self {
Rect {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
}
}
}
impl From<bindings::v4l2_selection> for Rect {
fn from(selection: bindings::v4l2_selection) -> Self {
Self::from(selection.r)
}
}
impl From<Rect> for bindings::v4l2_rect {
fn from(rect: Rect) -> Self {
bindings::v4l2_rect {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
}
}
}
impl Display for Rect {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"({}, {}), {}x{}",
self.left, self.top, self.width, self.height
)
}
}
/// Equivalent of `enum v4l2_colorspace`.
#[repr(u32)]
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, N)]
pub enum Colorspace {
#[default]
Default = bindings::v4l2_colorspace_V4L2_COLORSPACE_DEFAULT,
Smpte170M = bindings::v4l2_colorspace_V4L2_COLORSPACE_SMPTE170M,
Smpte240M = bindings::v4l2_colorspace_V4L2_COLORSPACE_SMPTE240M,
Rec709 = bindings::v4l2_colorspace_V4L2_COLORSPACE_REC709,
Bt878 = bindings::v4l2_colorspace_V4L2_COLORSPACE_BT878,
SystemM470 = bindings::v4l2_colorspace_V4L2_COLORSPACE_470_SYSTEM_M,
SystemBG470 = bindings::v4l2_colorspace_V4L2_COLORSPACE_470_SYSTEM_BG,
Jpeg = bindings::v4l2_colorspace_V4L2_COLORSPACE_JPEG,
Srgb = bindings::v4l2_colorspace_V4L2_COLORSPACE_SRGB,
OpRgb = bindings::v4l2_colorspace_V4L2_COLORSPACE_OPRGB,
Bt2020 = bindings::v4l2_colorspace_V4L2_COLORSPACE_BT2020,
Raw = bindings::v4l2_colorspace_V4L2_COLORSPACE_RAW,
DciP3 = bindings::v4l2_colorspace_V4L2_COLORSPACE_DCI_P3,
}
/// Equivalent of `enum v4l2_xfer_func`.
#[repr(u32)]
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, N)]
pub enum XferFunc {
#[default]
Default = bindings::v4l2_xfer_func_V4L2_XFER_FUNC_DEFAULT,
F709 = bindings::v4l2_xfer_func_V4L2_XFER_FUNC_709,
Srgb = bindings::v4l2_xfer_func_V4L2_XFER_FUNC_SRGB,
OpRgb = bindings::v4l2_xfer_func_V4L2_XFER_FUNC_OPRGB,
Smpte240M = bindings::v4l2_xfer_func_V4L2_XFER_FUNC_SMPTE240M,
None = bindings::v4l2_xfer_func_V4L2_XFER_FUNC_NONE,
DciP3 = bindings::v4l2_xfer_func_V4L2_XFER_FUNC_DCI_P3,
Smpte2084 = bindings::v4l2_xfer_func_V4L2_XFER_FUNC_SMPTE2084,
}
/// Equivalent of `enum v4l2_ycbcr_encoding`.
#[repr(u32)]
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, N)]
pub enum YCbCrEncoding {
#[default]
Default = bindings::v4l2_ycbcr_encoding_V4L2_YCBCR_ENC_DEFAULT,
E601 = bindings::v4l2_ycbcr_encoding_V4L2_YCBCR_ENC_601,
E709 = bindings::v4l2_ycbcr_encoding_V4L2_YCBCR_ENC_709,
Xv601 = bindings::v4l2_ycbcr_encoding_V4L2_YCBCR_ENC_XV601,
Xv709 = bindings::v4l2_ycbcr_encoding_V4L2_YCBCR_ENC_XV709,
Sycc = bindings::v4l2_ycbcr_encoding_V4L2_YCBCR_ENC_SYCC,
Bt2020 = bindings::v4l2_ycbcr_encoding_V4L2_YCBCR_ENC_BT2020,
Bt2020ConstLum = bindings::v4l2_ycbcr_encoding_V4L2_YCBCR_ENC_BT2020_CONST_LUM,
Smpte240M = bindings::v4l2_ycbcr_encoding_V4L2_YCBCR_ENC_SMPTE240M,
}
/// Equivalent of `enum v4l2_quantization`.
#[repr(u32)]
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, N)]
pub enum Quantization {
#[default]
Default = bindings::v4l2_quantization_V4L2_QUANTIZATION_DEFAULT,
FullRange = bindings::v4l2_quantization_V4L2_QUANTIZATION_FULL_RANGE,
LimRange = bindings::v4l2_quantization_V4L2_QUANTIZATION_LIM_RANGE,
}

279
libs/v4l2r/src/memory.rs Normal file
View File

@@ -0,0 +1,279 @@
//! Abstracts the different kinds of backing memory (`MMAP`, `USERPTR`,
//! `DMABUF`) supported by V4L2.
//!
//! V4L2 allows to use either memory that is provided by the device itself
//! (MMAP) or memory imported via user allocation (USERPTR) or the dma-buf
//! subsystem (DMABUF). This results in 2 very different behaviors and 3 memory
//! types that we need to model.
//!
//! The `Memory` trait represents these memory types and is thus implemented
//! by exacly 3 types: `MMAP`, `UserPtr`, and `DMABuf`. These types do very
//! little apart from providing a constant with the corresponding V4L2 memory
//! type they model, and implement the `SelfBacked` (for MMAP) or `Imported`
//! (for `UserPtr` and `DMABuf`) traits to indicate where their memory comes
//! from.
//!
//! The `PlaneHandle` trait is used by types which can bind to one of these
//! memory types, i.e. a type that can represent a single memory plane of a
//! buffer. For `MMAP` memory this is a void type (since `MMAP` provides its
//! own memory). `UserPtr`, a `Vec<u8>` can adequately be used as backing
//! memory, and for `DMABuf` we will use a file descriptor. For handles that
//! can be mapped into the user address-space (and indeed for `MMAP` this is
//! the only way to access the memory), the `Mappable` trait can be implemented.
//!
//! The set of handles that make all the planes for a given buffer is
//! represented by the `BufferHandles` trait. This trait is more abstract since
//! we may want to decide at runtime the kind of memory we want to use ;
//! therefore this trait does not have any particular kind of memory attached to
//! it. `PrimitiveBufferHandles` is used to represent plane handles which memory
//! type is known at compilation time, and thus includes a reference to a
//! `PlaneHandle` type and by transition its `Memory` type.
mod dmabuf;
mod mmap;
mod userptr;
pub use dmabuf::*;
pub use mmap::*;
pub use userptr::*;
use crate::{
bindings::{self, v4l2_buffer__bindgen_ty_1, v4l2_plane__bindgen_ty_1},
ioctl::{PlaneMapping, QueryBufPlane},
};
use enumn::N;
use std::os::unix::io::AsFd;
use std::{fmt::Debug, ops::Deref};
/// All the supported V4L2 memory types.
#[derive(Debug, Clone, Copy, PartialEq, Eq, N)]
#[repr(u32)]
pub enum MemoryType {
Mmap = bindings::v4l2_memory_V4L2_MEMORY_MMAP,
UserPtr = bindings::v4l2_memory_V4L2_MEMORY_USERPTR,
Overlay = bindings::v4l2_memory_V4L2_MEMORY_OVERLAY,
DmaBuf = bindings::v4l2_memory_V4L2_MEMORY_DMABUF,
}
/// Trait describing a memory type that can be used to back V4L2 buffers.
pub trait Memory: 'static {
/// The memory type represented.
const MEMORY_TYPE: MemoryType;
/// The final type of the memory backing information in `struct v4l2_buffer` or `struct
/// v4l2_plane`.
type RawBacking;
/// Returns a reference to the memory backing information for `m` that is relevant for this
/// memory type.
///
/// # Safety
///
/// The caller must ensure that `m` indeed belongs to a buffer of this memory type.
unsafe fn get_plane_buffer_backing(m: &bindings::v4l2_plane__bindgen_ty_1)
-> &Self::RawBacking;
/// Returns a reference to the memory backing information for `m` that is relevant for this memory type.
///
/// # Safety
///
/// The caller must ensure that `m` indeed belongs to a buffer of this memory type.
unsafe fn get_single_planar_buffer_backing(
m: &bindings::v4l2_buffer__bindgen_ty_1,
) -> &Self::RawBacking;
/// Returns a mutable reference to the memory backing information for `m` that is relevant for
/// this memory type.
///
/// # Safety
///
/// The caller must ensure that `m` indeed belongs to a buffer of this memory type.
unsafe fn get_plane_buffer_backing_mut(
m: &mut bindings::v4l2_plane__bindgen_ty_1,
) -> &mut Self::RawBacking;
/// Returns a mutable reference to the memory backing information for `m` that is relevant for
/// this memory type.
///
/// # Safety
///
/// The caller must ensure that `m` indeed belongs to a buffer of this memory type.
unsafe fn get_single_planar_buffer_backing_mut(
m: &mut bindings::v4l2_buffer__bindgen_ty_1,
) -> &mut Self::RawBacking;
}
/// Trait for memory types that provide their own memory, i.e. MMAP.
pub trait SelfBacked: Memory + Default {}
/// Trait for memory types to which external memory must be attached to, i.e. UserPtr and
/// DMABuf.
pub trait Imported: Memory {}
/// Trait for a handle that represents actual data for a single place. A buffer
/// will have as many of these as it has planes.
pub trait PlaneHandle: Debug + Send + 'static {
/// The kind of memory the handle attaches to.
type Memory: Memory;
/// Fill a plane of a multi-planar V4L2 buffer with the handle's information.
fn fill_v4l2_plane(&self, plane: &mut bindings::v4l2_plane);
}
// Trait for plane handles that provide access to their content through a map()
// method (typically, MMAP buffers).
pub trait Mappable: PlaneHandle {
/// Return a `PlaneMapping` enabling access to the memory of this handle.
fn map<D: AsFd>(device: &D, plane_info: &QueryBufPlane) -> Option<PlaneMapping>;
}
/// Trait for structures providing all the handles of a single buffer.
pub trait BufferHandles: Send + Debug + 'static {
/// Enumeration of all the `MemoryType` supported by this type. Typically
/// a subset of `MemoryType` or `MemoryType` itself.
type SupportedMemoryType: Into<MemoryType> + Send + Clone + Copy;
/// Number of planes.
fn len(&self) -> usize;
/// Fill a plane of a multi-planar V4L2 buffer with the `index` handle's information.
fn fill_v4l2_plane(&self, index: usize, plane: &mut bindings::v4l2_plane);
/// Returns true if there are no handles here (unlikely).
fn is_empty(&self) -> bool {
self.len() == 0
}
}
/// Implementation of `BufferHandles` for all indexables of `PlaneHandle` (e.g. [`std::vec::Vec`]).
///
/// This is The simplest way to use primitive handles.
impl<P, Q> BufferHandles for Q
where
P: PlaneHandle,
Q: Send + Debug + 'static + Deref<Target = [P]>,
{
type SupportedMemoryType = MemoryType;
fn len(&self) -> usize {
self.deref().len()
}
fn fill_v4l2_plane(&self, index: usize, plane: &mut bindings::v4l2_plane) {
self.deref()[index].fill_v4l2_plane(plane);
}
}
/// Trait for plane handles for which the final memory type is known at compile
/// time.
pub trait PrimitiveBufferHandles: BufferHandles {
type HandleType: PlaneHandle;
const MEMORY_TYPE: Self::SupportedMemoryType;
}
/// Implementation of `PrimitiveBufferHandles` for all indexables of `PlaneHandle` (e.g.
/// [`std::vec::Vec`]).
impl<P, Q> PrimitiveBufferHandles for Q
where
P: PlaneHandle,
Q: Send + Debug + 'static + Deref<Target = [P]>,
{
type HandleType = P;
const MEMORY_TYPE: Self::SupportedMemoryType = P::Memory::MEMORY_TYPE;
}
/// Conversion from `v4l2_buffer`'s backing information to `v4l2_plane`'s.
impl From<(&v4l2_buffer__bindgen_ty_1, MemoryType)> for v4l2_plane__bindgen_ty_1 {
fn from((m, memory): (&v4l2_buffer__bindgen_ty_1, MemoryType)) -> Self {
match memory {
MemoryType::Mmap => v4l2_plane__bindgen_ty_1 {
// Safe because the buffer type is determined to be MMAP.
mem_offset: unsafe { m.offset },
},
MemoryType::UserPtr => v4l2_plane__bindgen_ty_1 {
// Safe because the buffer type is determined to be USERPTR.
userptr: unsafe { m.userptr },
},
MemoryType::DmaBuf => v4l2_plane__bindgen_ty_1 {
// Safe because the buffer type is determined to be DMABUF.
fd: unsafe { m.fd },
},
MemoryType::Overlay => Default::default(),
}
}
}
/// Conversion from `v4l2_plane`'s backing information to `v4l2_buffer`'s.
impl From<(&v4l2_plane__bindgen_ty_1, MemoryType)> for v4l2_buffer__bindgen_ty_1 {
fn from((m, memory): (&v4l2_plane__bindgen_ty_1, MemoryType)) -> Self {
match memory {
MemoryType::Mmap => v4l2_buffer__bindgen_ty_1 {
// Safe because the buffer type is determined to be MMAP.
offset: unsafe { m.mem_offset },
},
MemoryType::UserPtr => v4l2_buffer__bindgen_ty_1 {
// Safe because the buffer type is determined to be USERPTR.
userptr: unsafe { m.userptr },
},
MemoryType::DmaBuf => v4l2_buffer__bindgen_ty_1 {
// Safe because the buffer type is determined to be DMABUF.
fd: unsafe { m.fd },
},
MemoryType::Overlay => Default::default(),
}
}
}
#[cfg(test)]
mod tests {
use crate::bindings::v4l2_buffer__bindgen_ty_1;
use crate::bindings::v4l2_plane__bindgen_ty_1;
use crate::memory::MemoryType;
#[test]
// Purpose of this test is dubious as the members are overlapping anyway.
fn plane_m_to_buffer_m() {
let plane_m = v4l2_plane__bindgen_ty_1 {
mem_offset: 0xfeedc0fe,
};
assert_eq!(
unsafe { v4l2_buffer__bindgen_ty_1::from((&plane_m, MemoryType::Mmap)).offset },
0xfeedc0fe
);
let plane_m = v4l2_plane__bindgen_ty_1 {
userptr: 0xfeedc0fe,
};
assert_eq!(
unsafe { v4l2_buffer__bindgen_ty_1::from((&plane_m, MemoryType::UserPtr)).userptr },
0xfeedc0fe
);
let plane_m = v4l2_plane__bindgen_ty_1 { fd: 0x76543210 };
assert_eq!(
unsafe { v4l2_buffer__bindgen_ty_1::from((&plane_m, MemoryType::DmaBuf)).fd },
0x76543210
);
}
#[test]
// Purpose of this test is dubious as the members are overlapping anyway.
fn buffer_m_to_plane_m() {
let buffer_m = v4l2_buffer__bindgen_ty_1 { offset: 0xfeedc0fe };
assert_eq!(
unsafe { v4l2_plane__bindgen_ty_1::from((&buffer_m, MemoryType::Mmap)).mem_offset },
0xfeedc0fe
);
let buffer_m = v4l2_buffer__bindgen_ty_1 {
userptr: 0xfeedc0fe,
};
assert_eq!(
unsafe { v4l2_plane__bindgen_ty_1::from((&buffer_m, MemoryType::UserPtr)).userptr },
0xfeedc0fe
);
let buffer_m = v4l2_buffer__bindgen_ty_1 { fd: 0x76543210 };
assert_eq!(
unsafe { v4l2_plane__bindgen_ty_1::from((&buffer_m, MemoryType::DmaBuf)).fd },
0x76543210
);
}
}

View File

@@ -0,0 +1,91 @@
//! Operations specific to DMABuf-type buffers.
use log::warn;
use super::*;
use crate::{bindings, ioctl};
use std::os::fd::RawFd;
use std::os::unix::io::{AsFd, AsRawFd};
pub struct DmaBuf;
pub type DmaBufferHandles<T> = Vec<DmaBufHandle<T>>;
impl Memory for DmaBuf {
const MEMORY_TYPE: MemoryType = MemoryType::DmaBuf;
type RawBacking = RawFd;
unsafe fn get_plane_buffer_backing(
m: &bindings::v4l2_plane__bindgen_ty_1,
) -> &Self::RawBacking {
&m.fd
}
unsafe fn get_single_planar_buffer_backing(
m: &bindings::v4l2_buffer__bindgen_ty_1,
) -> &Self::RawBacking {
&m.fd
}
unsafe fn get_plane_buffer_backing_mut(
m: &mut bindings::v4l2_plane__bindgen_ty_1,
) -> &mut Self::RawBacking {
&mut m.fd
}
unsafe fn get_single_planar_buffer_backing_mut(
m: &mut bindings::v4l2_buffer__bindgen_ty_1,
) -> &mut Self::RawBacking {
&mut m.fd
}
}
impl Imported for DmaBuf {}
pub trait DmaBufSource: AsRawFd + AsFd + Debug + Send {
fn len(&self) -> u64;
/// Make Clippy happy.
fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl DmaBufSource for std::fs::File {
fn len(&self) -> u64 {
match self.metadata() {
Err(_) => {
warn!("Failed to compute File size for use as DMABuf, using 0...");
0
}
Ok(m) => m.len(),
}
}
}
/// Handle for a DMABUF plane. Any type that can provide a file descriptor is
/// valid.
#[derive(Debug)]
pub struct DmaBufHandle<T: DmaBufSource>(pub T);
impl<T: DmaBufSource> From<T> for DmaBufHandle<T> {
fn from(dmabuf: T) -> Self {
DmaBufHandle(dmabuf)
}
}
impl<T: DmaBufSource + 'static> PlaneHandle for DmaBufHandle<T> {
type Memory = DmaBuf;
fn fill_v4l2_plane(&self, plane: &mut bindings::v4l2_plane) {
plane.m.fd = self.0.as_raw_fd();
plane.length = self.0.len() as u32;
}
}
impl<T: DmaBufSource> DmaBufHandle<T> {
pub fn map(&self) -> Result<PlaneMapping, ioctl::MmapError> {
let len = self.0.len();
ioctl::mmap(&self.0, 0, len as u32)
}
}

View File

@@ -0,0 +1,58 @@
//! Operations specific to MMAP-type buffers.
use super::*;
use crate::{bindings, ioctl};
use std::fmt::Debug;
use std::os::fd::AsFd;
#[derive(Default)]
pub struct Mmap;
impl Memory for Mmap {
const MEMORY_TYPE: MemoryType = MemoryType::Mmap;
type RawBacking = u32;
unsafe fn get_plane_buffer_backing(
m: &bindings::v4l2_plane__bindgen_ty_1,
) -> &Self::RawBacking {
&m.mem_offset
}
unsafe fn get_single_planar_buffer_backing(
m: &bindings::v4l2_buffer__bindgen_ty_1,
) -> &Self::RawBacking {
&m.offset
}
unsafe fn get_plane_buffer_backing_mut(
m: &mut bindings::v4l2_plane__bindgen_ty_1,
) -> &mut Self::RawBacking {
&mut m.mem_offset
}
unsafe fn get_single_planar_buffer_backing_mut(
m: &mut bindings::v4l2_buffer__bindgen_ty_1,
) -> &mut Self::RawBacking {
&mut m.offset
}
}
impl SelfBacked for Mmap {}
/// Dummy handle for a MMAP plane, to use with APIs that require handles. MMAP
/// buffers are backed by the device, and thus we don't need to attach any extra
/// information to them.
#[derive(Default, Debug, Clone)]
pub struct MmapHandle;
// There is no information to fill with MMAP buffers ; the index is enough.
impl PlaneHandle for MmapHandle {
type Memory = Mmap;
fn fill_v4l2_plane(&self, _plane: &mut bindings::v4l2_plane) {}
}
impl Mappable for MmapHandle {
fn map<D: AsFd>(device: &D, plane_info: &QueryBufPlane) -> Option<PlaneMapping> {
ioctl::mmap(device, plane_info.mem_offset, plane_info.length).ok()
}
}

View File

@@ -0,0 +1,77 @@
//! Operations specific to UserPtr-type buffers.
use super::*;
use crate::bindings;
pub struct UserPtr;
impl Memory for UserPtr {
const MEMORY_TYPE: MemoryType = MemoryType::UserPtr;
type RawBacking = core::ffi::c_ulong;
unsafe fn get_plane_buffer_backing(
m: &bindings::v4l2_plane__bindgen_ty_1,
) -> &Self::RawBacking {
&m.userptr
}
unsafe fn get_single_planar_buffer_backing(
m: &bindings::v4l2_buffer__bindgen_ty_1,
) -> &Self::RawBacking {
&m.userptr
}
unsafe fn get_plane_buffer_backing_mut(
m: &mut bindings::v4l2_plane__bindgen_ty_1,
) -> &mut Self::RawBacking {
&mut m.userptr
}
unsafe fn get_single_planar_buffer_backing_mut(
m: &mut bindings::v4l2_buffer__bindgen_ty_1,
) -> &mut Self::RawBacking {
&mut m.userptr
}
}
impl Imported for UserPtr {}
/// Handle for a USERPTR plane. These buffers are backed by userspace-allocated
/// memory, which translates well into Rust's slice of `u8`s. Since slices also
/// carry size information, we know that we are not passing unallocated areas
/// of the address-space to the kernel.
///
/// USERPTR buffers have the particularity that the `length` field of `struct
/// v4l2_buffer` must be set before doing a `QBUF` ioctl. This handle struct
/// also takes care of that.
#[derive(Debug)]
pub struct UserPtrHandle<T: AsRef<[u8]> + Debug + Send + 'static>(pub T);
impl<T: AsRef<[u8]> + Debug + Send + Clone> Clone for UserPtrHandle<T> {
fn clone(&self) -> Self {
UserPtrHandle(self.0.clone())
}
}
impl<T: AsRef<[u8]> + Debug + Send + 'static> AsRef<[u8]> for UserPtrHandle<T> {
fn as_ref(&self) -> &[u8] {
self.0.as_ref()
}
}
impl<T: AsRef<[u8]> + Debug + Send> From<T> for UserPtrHandle<T> {
fn from(buffer: T) -> Self {
UserPtrHandle(buffer)
}
}
impl<T: AsRef<[u8]> + Debug + Send + 'static> PlaneHandle for UserPtrHandle<T> {
type Memory = UserPtr;
fn fill_v4l2_plane(&self, plane: &mut bindings::v4l2_plane) {
let slice = AsRef::<[u8]>::as_ref(&self.0);
plane.m.userptr = slice.as_ptr() as std::os::raw::c_ulong;
plane.length = slice.len() as u32;
}
}

View File

@@ -0,0 +1,10 @@
#ifdef __ANDROID__
#include <stddef.h>
#include <stdint.h>
#include <sys/types.h>
#endif
#include <linux/videodev2.h>
#define MARK_FIX_753(name) const unsigned long int Fix753_##name = name;
#include "fix753.h"

View File

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

View File

@@ -19,9 +19,19 @@ fn main() {
fn generate_bindings(cpp_dir: &Path) {
let ffi_header = cpp_dir.join("yuv_ffi.h");
let mut builder = bindgen::builder().header(ffi_header.to_string_lossy().to_string());
bindgen::builder()
.header(ffi_header.to_string_lossy().to_string())
if env::var("CARGO_CFG_TARGET_OS").ok().as_deref() == Some("android") {
println!("cargo:rerun-if-env-changed=ANDROID_NDK_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_NDK_ROOT");
println!("cargo:rerun-if-env-changed=NDK_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_SDK_ROOT");
println!("cargo:rerun-if-env-changed=CARGO_NDK_PLATFORM");
builder = builder.clang_args(android_clang_args());
}
builder
// YUYV conversions
.allowlist_function("YUY2ToI420")
.allowlist_function("YUY2ToNV12")
@@ -38,6 +48,7 @@ fn generate_bindings(cpp_dir: &Path) {
// NV12/NV21 conversions
.allowlist_function("NV12ToI420")
.allowlist_function("NV21ToI420")
.allowlist_function("NV21ToNV12")
.allowlist_function("NV12Copy")
.allowlist_function("SplitUVPlane")
// ARGB/BGRA conversions
@@ -49,6 +60,7 @@ fn generate_bindings(cpp_dir: &Path) {
.allowlist_function("ABGRToARGB")
// RGB24/BGR24 conversions
.allowlist_function("RGB24ToI420")
.allowlist_function("RGB24ToNV12")
.allowlist_function("RAWToI420")
.allowlist_function("RGB24ToARGB")
.allowlist_function("RAWToARGB")
@@ -62,6 +74,9 @@ fn generate_bindings(cpp_dir: &Path) {
.allowlist_function("UYVYToARGB")
.allowlist_function("ARGBToRGB24")
.allowlist_function("ARGBToRAW")
// MJPEG decoding
.allowlist_function("MJPGToNV12")
.allowlist_function("MJPGSize")
// Scaling
.allowlist_function("I420Scale")
.allowlist_function("NV12Scale")
@@ -81,9 +96,33 @@ fn generate_bindings(cpp_dir: &Path) {
}
fn link_libyuv() {
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os == "android" {
if link_android_libyuv() {
return;
}
if let Some(vcpkg_installed) = vcpkg_installed_root() {
if link_vcpkg(vcpkg_installed) {
return;
}
}
panic!(
"Android libyuv not found!\n\
\n\
Build it with scripts/build-android-libyuv.sh and set:\n\
export ONE_KVM_ANDROID_LIBYUV_ROOT=/path/to/android-libyuv\n\
\n\
Expected layout:\n\
$ONE_KVM_ANDROID_LIBYUV_ROOT/<abi>/include\n\
$ONE_KVM_ANDROID_LIBYUV_ROOT/<abi>/lib/libyuv.a"
);
}
// Try vcpkg first
if let Ok(vcpkg_root) = env::var("VCPKG_ROOT") {
if link_vcpkg(vcpkg_root.into()) {
if let Some(vcpkg_installed) = vcpkg_installed_root() {
if link_vcpkg(vcpkg_installed) {
return;
}
}
@@ -109,6 +148,233 @@ fn link_libyuv() {
);
}
fn link_android_libyuv() -> bool {
println!("cargo:rerun-if-env-changed=ONE_KVM_ANDROID_LIBYUV_ROOT");
println!("cargo:rerun-if-env-changed=ONE_KVM_ANDROID_LIBYUV_STATIC");
let root = match env::var("ONE_KVM_ANDROID_LIBYUV_ROOT")
.ok()
.filter(|path| !path.trim().is_empty())
{
Some(path) => PathBuf::from(path),
None => return false,
};
let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let abi = android_abi(&target_arch);
let abi_root = root.join(abi);
let lib_dir = if abi_root.join("lib").exists() {
abi_root.join("lib")
} else {
root.join("lib")
};
let include_dir = if abi_root.join("include").exists() {
abi_root.join("include")
} else {
root.join("include")
};
let static_lib = lib_dir.join("libyuv.a");
let shared_lib = lib_dir.join("libyuv.so");
let use_static = env::var("ONE_KVM_ANDROID_LIBYUV_STATIC")
.or_else(|_| env::var("LIBYUV_STATIC"))
.map(|value| value != "0")
.unwrap_or(true);
if use_static && static_lib.exists() {
println!("cargo:rustc-link-search=native={}", lib_dir.display());
println!("cargo:rustc-link-lib=static=yuv");
link_android_libjpeg(&root, abi);
println!("cargo:rustc-link-lib=c++_shared");
println!(
"cargo:info=Using Android libyuv from {} (static linking)",
root.display()
);
return true;
}
if shared_lib.exists() {
println!("cargo:rustc-link-search=native={}", lib_dir.display());
println!("cargo:rustc-link-lib=yuv");
println!("cargo:rustc-link-lib=c++_shared");
println!(
"cargo:info=Using Android libyuv from {} (dynamic linking)",
root.display()
);
return true;
}
println!(
"cargo:warning=Android libyuv not found under {} for ABI {} (checked {}, {})",
root.display(),
abi,
static_lib.display(),
shared_lib.display()
);
if !include_dir.exists() {
println!(
"cargo:warning=Android libyuv include directory not found: {}",
include_dir.display()
);
}
false
}
fn link_android_libjpeg(libyuv_root: &Path, abi: &str) {
println!("cargo:rerun-if-env-changed=ONE_KVM_ANDROID_TURBOJPEG_ROOT");
let mut roots = Vec::new();
if let Ok(root) = env::var("ONE_KVM_ANDROID_TURBOJPEG_ROOT") {
if !root.trim().is_empty() {
roots.push(PathBuf::from(root));
}
}
roots.push(libyuv_root.with_file_name("android-turbojpeg"));
for root in roots {
let abi_lib_dir = root.join(abi).join("lib");
let lib_dir = if abi_lib_dir.exists() {
abi_lib_dir
} else {
root.join("lib")
};
let jpeg_lib = lib_dir.join("libjpeg.a");
if jpeg_lib.exists() {
println!("cargo:rustc-link-search=native={}", lib_dir.display());
println!("cargo:rustc-link-lib=static=jpeg");
println!(
"cargo:info=Using Android libjpeg for libyuv MJPEG from {}",
root.display()
);
return;
}
}
println!("cargo:warning=Android libjpeg.a not found; libyuv MJPEG symbols may fail to link");
}
fn android_abi(target_arch: &str) -> &'static str {
match target_arch {
"aarch64" => "arm64-v8a",
"arm" => "armeabi-v7a",
"x86" => "x86",
"x86_64" => "x86_64",
_ => "unknown",
}
}
fn android_clang_args() -> Vec<String> {
let ndk = android_ndk_home();
let target = env::var("TARGET").unwrap_or_default();
let toolchain = ndk.join("toolchains/llvm/prebuilt").join(host_tag());
let sysroot = toolchain.join("sysroot");
let clang_include = toolchain
.join("lib/clang")
.join(clang_version(&toolchain))
.join("include");
let api = env::var("CARGO_NDK_PLATFORM")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(21);
let clang_target = android_clang_target(&target);
vec![
format!("--target={clang_target}"),
format!("--sysroot={}", sysroot.display()),
format!("-D__ANDROID_API__={api}"),
format!("-isystem{}", clang_include.display()),
format!("-isystem{}", sysroot.join("usr/include").display()),
format!(
"-isystem{}",
sysroot.join("usr/include").join(clang_target).display()
),
]
}
fn android_clang_target(target: &str) -> &'static str {
match target {
"aarch64-linux-android" => "aarch64-linux-android",
"armv7-linux-androideabi" => "armv7a-linux-androideabi",
"i686-linux-android" => "i686-linux-android",
"x86_64-linux-android" => "x86_64-linux-android",
other => panic!("unsupported Android target for libyuv bindgen: {other}"),
}
}
fn android_ndk_home() -> PathBuf {
for key in ["ANDROID_NDK_HOME", "ANDROID_NDK_ROOT", "NDK_HOME"] {
if let Ok(value) = env::var(key) {
return PathBuf::from(value);
}
}
for key in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
if let Ok(value) = env::var(key) {
let ndk_dir = PathBuf::from(value).join("ndk");
if let Some(newest) = newest_child_dir(&ndk_dir) {
return newest;
}
}
}
panic!(
"libyuv Android bindgen requires ANDROID_NDK_HOME, ANDROID_NDK_ROOT, NDK_HOME, \
or ANDROID_HOME/ANDROID_SDK_ROOT with an ndk directory"
);
}
fn newest_child_dir(path: &Path) -> Option<PathBuf> {
let mut entries = std::fs::read_dir(path)
.ok()?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>();
entries.sort();
entries.pop()
}
fn host_tag() -> &'static str {
if cfg!(target_os = "linux") {
"linux-x86_64"
} else if cfg!(target_os = "macos") {
"darwin-x86_64"
} else if cfg!(target_os = "windows") {
"windows-x86_64"
} else {
panic!("unsupported host OS for Android NDK");
}
}
fn clang_version(toolchain: &Path) -> String {
let clang_dir = toolchain.join("lib/clang");
let mut entries = std::fs::read_dir(&clang_dir)
.unwrap_or_else(|_| panic!("missing NDK clang directory: {}", clang_dir.display()))
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
entries.sort();
entries
.pop()
.unwrap_or_else(|| panic!("no clang versions found under: {}", clang_dir.display()))
}
fn vcpkg_installed_root() -> Option<PathBuf> {
println!("cargo:rerun-if-env-changed=VCPKG_INSTALLED_DIR");
println!("cargo:rerun-if-env-changed=VCPKG_ROOT");
if let Ok(path) = env::var("VCPKG_INSTALLED_DIR") {
if !path.trim().is_empty() {
return Some(PathBuf::from(path));
}
}
env::var("VCPKG_ROOT")
.ok()
.filter(|path| !path.trim().is_empty())
.map(|path| PathBuf::from(path).join("installed"))
}
fn link_vcpkg(mut path: PathBuf) -> bool {
let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
@@ -117,6 +383,10 @@ fn link_vcpkg(mut path: PathBuf) -> bool {
("linux", "x86_64") => "x64-linux",
("linux", "aarch64") => "arm64-linux",
("linux", "arm") => "arm-linux",
("android", "x86_64") => "x64-android",
("android", "x86") => "x86-android",
("android", "aarch64") => "arm64-android",
("android", "arm") => "arm-neon-android",
("windows", "x86_64") => "x64-windows-static",
("windows", "x86") => "x86-windows-static",
("macos", "x86_64") => "x64-osx",
@@ -130,7 +400,6 @@ fn link_vcpkg(mut path: PathBuf) -> bool {
}
};
path.push("installed");
path.push(triplet);
let include_path = path.join("include");
@@ -154,12 +423,21 @@ fn link_vcpkg(mut path: PathBuf) -> bool {
if use_static && static_lib.exists() {
// Static linking (for deb packaging)
println!("cargo:rustc-link-lib=static=yuv");
println!("cargo:rustc-link-lib=stdc++");
link_libjpeg_for_static_libyuv(&[lib_path.clone()], &target_os);
if target_os == "linux" {
println!("cargo:rustc-link-lib=stdc++");
} else if target_os == "android" {
println!("cargo:rustc-link-lib=c++_shared");
}
println!("cargo:info=Using libyuv from vcpkg (static linking)");
} else {
// Dynamic linking (default for development)
println!("cargo:rustc-link-lib=yuv");
println!("cargo:rustc-link-lib=stdc++");
if target_os == "linux" {
println!("cargo:rustc-link-lib=stdc++");
} else if target_os == "android" {
println!("cargo:rustc-link-lib=c++_shared");
}
println!("cargo:info=Using libyuv from vcpkg (dynamic linking)");
}
@@ -251,6 +529,7 @@ fn link_system() -> bool {
if use_static && libyuv_static.exists() {
println!("cargo:rustc-link-search=native={}", path);
println!("cargo:rustc-link-lib=static=yuv");
link_libjpeg_for_static_libyuv(&[lib_path.to_path_buf()], "linux");
println!("cargo:rustc-link-lib=stdc++");
println!(
"cargo:info=Using system libyuv from {} (static linking)",
@@ -277,3 +556,58 @@ fn link_system() -> bool {
false
}
fn link_libjpeg_for_static_libyuv(preferred_lib_dirs: &[PathBuf], target_os: &str) {
if target_os != "linux" {
return;
}
println!("cargo:rerun-if-env-changed=ONE_KVM_LIBJPEG_DIR");
let mut lib_dirs = Vec::new();
if let Ok(path) = env::var("ONE_KVM_LIBJPEG_DIR") {
if !path.trim().is_empty() {
lib_dirs.push(PathBuf::from(path));
}
}
lib_dirs.extend(preferred_lib_dirs.iter().cloned());
lib_dirs.extend(
[
"/usr/local/lib",
"/usr/local/lib64",
"/usr/lib",
"/usr/lib64",
"/usr/lib/x86_64-linux-gnu",
"/usr/lib/aarch64-linux-gnu",
"/usr/lib/arm-linux-gnueabihf",
"/usr/aarch64-linux-gnu/lib",
"/usr/arm-linux-gnueabihf/lib",
]
.iter()
.map(PathBuf::from),
);
for lib_dir in dedupe_paths(lib_dirs) {
if lib_dir.join("libjpeg.a").exists() {
println!("cargo:rustc-link-search=native={}", lib_dir.display());
println!("cargo:rustc-link-lib=static=jpeg");
println!(
"cargo:info=Using libjpeg for static libyuv MJPEG from {}",
lib_dir.display()
);
return;
}
}
println!("cargo:warning=libjpeg.a not found; static libyuv built with MJPEG may fail to link");
}
fn dedupe_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut deduped = Vec::new();
for path in paths {
if !deduped.iter().any(|existing| existing == &path) {
deduped.push(path);
}
}
deduped
}

View File

@@ -103,6 +103,13 @@ int NV21ToI420(const uint8_t* src_y, int src_stride_y,
uint8_t* dst_v, int dst_stride_v,
int width, int height);
// NV21 -> NV12
int NV21ToNV12(const uint8_t* src_y, int src_stride_y,
const uint8_t* src_vu, int src_stride_vu,
uint8_t* dst_y, int dst_stride_y,
uint8_t* dst_uv, int dst_stride_uv,
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,
@@ -167,6 +174,12 @@ int RAWToI420(const uint8_t* src_raw, int src_stride_raw,
uint8_t* dst_v, int dst_stride_v,
int width, int height);
// BGR24 -> NV12
int RGB24ToNV12(const uint8_t* src_rgb24, int src_stride_rgb24,
uint8_t* dst_y, int dst_stride_y,
uint8_t* dst_uv, int dst_stride_uv,
int width, int height);
// RGB24 -> ARGB
int RGB24ToARGB(const uint8_t* src_rgb24, int src_stride_rgb24,
uint8_t* dst_argb, int dst_stride_argb,
@@ -253,12 +266,6 @@ int MJPGToNV12(const uint8_t* sample, size_t sample_size,
int src_width, int src_height,
int dst_width, int dst_height);
// MJPEG -> ARGB
int MJPGToARGB(const uint8_t* sample, size_t sample_size,
uint8_t* dst_argb, int dst_stride_argb,
int src_width, int src_height,
int dst_width, int dst_height);
// Get MJPEG dimensions without decoding
int MJPGSize(const uint8_t* sample, size_t sample_size,
int* width, int* height);

View File

@@ -32,17 +32,9 @@ use std::fmt;
// Include auto-generated FFI bindings
include!(concat!(env!("OUT_DIR"), "/yuv_ffi.rs"));
// Type alias for C's size_t - adapts to platform pointer width
#[cfg(target_pointer_width = "32")]
type SizeT = u32;
#[cfg(target_pointer_width = "64")]
type SizeT = u64;
// Helper function to convert usize to C's size_t type
#[inline]
fn usize_to_size_t(val: usize) -> SizeT {
val as SizeT
fn usize_to_size_t(val: usize) -> usize {
val
}
// ============================================================================
@@ -522,6 +514,34 @@ pub fn nv21_to_i420(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Resu
))
}
/// Convert NV21 to NV12 by swapping interleaved chroma bytes.
pub fn nv21_to_nv12(src: &[u8], 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;
if src.len() < nv12_size(w, h) || dst.len() < nv12_size(w, h) {
return Err(YuvError::BufferTooSmall);
}
call_yuv!(NV21ToNV12(
src.as_ptr(),
width,
src[y_size..].as_ptr(),
width,
dst.as_mut_ptr(),
width,
dst[y_size..].as_mut_ptr(),
width,
width,
height,
))
}
// ============================================================================
// ARGB/BGRA conversions (32-bit)
// Note: libyuv ARGB = BGRA in memory on little-endian systems
@@ -1046,7 +1066,7 @@ pub fn rgb24_to_nv12(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Res
i420_to_nv12(&i420_buffer, dst, width, height)
}
/// Convert BGR24 to NV12 (via two-step conversion: BGR24 → I420 → NV12)
/// Convert BGR24 to NV12.
pub fn bgr24_to_nv12(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Result<()> {
if width % 2 != 0 || height % 2 != 0 {
return Err(YuvError::InvalidDimensions);
@@ -1059,10 +1079,71 @@ pub fn bgr24_to_nv12(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Res
return Err(YuvError::BufferTooSmall);
}
// Two-step conversion: BGR24 → I420 → NV12
let mut i420_buffer = vec![0u8; i420_size(w, h)];
bgr24_to_i420(src, &mut i420_buffer, width, height)?;
i420_to_nv12(&i420_buffer, dst, width, height)
#[cfg(windows)]
{
let mut i420_buffer = vec![0u8; i420_size(w, h)];
bgr24_to_i420(src, &mut i420_buffer, width, height)?;
return i420_to_nv12(&i420_buffer, dst, width, height);
}
#[cfg(not(windows))]
{
let y_size = w * h;
call_yuv!(RGB24ToNV12(
src.as_ptr(),
width * 3,
dst.as_mut_ptr(),
width,
dst[y_size..].as_mut_ptr(),
width,
width,
height,
))
}
}
/// Read MJPEG dimensions without decoding the frame.
pub fn mjpg_size(src: &[u8]) -> Result<(i32, i32)> {
let mut width = 0;
let mut height = 0;
call_yuv!(MJPGSize(
src.as_ptr(),
usize_to_size_t(src.len()),
&mut width,
&mut height,
))?;
Ok((width, height))
}
/// Decode MJPEG directly to NV12.
pub fn mjpg_to_nv12(src: &[u8], 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;
if dst.len() < nv12_size(w, h) {
return Err(YuvError::BufferTooSmall);
}
let y_size = w * h;
let (dst_y, dst_uv) = dst.split_at_mut(y_size);
call_yuv!(MJPGToNV12(
src.as_ptr(),
usize_to_size_t(src.len()),
dst_y.as_mut_ptr(),
width,
dst_uv.as_mut_ptr(),
width,
width,
height,
width,
height,
))
}
// ============================================================================

View File

@@ -0,0 +1,219 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
SOURCE_DIR=""
OUTPUT_DIR="${PROJECT_ROOT}/dist/android-alsa"
ANDROID_API="${ANDROID_API:-21}"
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
BUILD_ABIS="arm64-v8a armeabi-v7a"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
ALSA_REPO="${ALSA_REPO:-https://github.com/alsa-project/alsa-lib.git}"
usage() {
cat <<'EOF'
Usage:
scripts/build-android-alsa.sh [options]
Options:
--source <dir> Existing alsa-lib source checkout. If omitted, the
script clones it into .tmp/android-alsa-src.
--output <dir> Output root. Default: dist/android-alsa
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
--api <level> Android API level. Default: 21.
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
-h, --help Show this help.
The output layout is compatible with ONE_KVM_ANDROID_ALSA_ROOT:
<output>/arm64-v8a/include/alsa/asoundlib.h
<output>/arm64-v8a/lib/libasound.so
<output>/arm64-v8a/lib/pkgconfig/alsa.pc
<output>/armeabi-v7a/include/alsa/asoundlib.h
<output>/armeabi-v7a/lib/libasound.so
<output>/armeabi-v7a/lib/pkgconfig/alsa.pc
EOF
}
fail() {
echo "Error: $*" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--source)
SOURCE_DIR="${2:-}"
shift 2
;;
--output)
OUTPUT_DIR="${2:-}"
shift 2
;;
--ndk)
NDK_ROOT="${2:-}"
shift 2
;;
--api)
ANDROID_API="${2:-}"
shift 2
;;
--abis)
BUILD_ABIS="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$NDK_ROOT" ]] || fail "--ndk or ANDROID_NDK_HOME/ANDROID_NDK_ROOT is required"
[[ -d "$NDK_ROOT/toolchains/llvm/prebuilt" ]] || fail "Invalid NDK root: $NDK_ROOT"
if [[ -z "$SOURCE_DIR" ]]; then
SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-alsa-src"
if [[ ! -d "$SOURCE_DIR/.git" ]]; then
rm -rf "$SOURCE_DIR"
git clone --depth 1 "$ALSA_REPO" "$SOURCE_DIR"
fi
fi
[[ -d "$SOURCE_DIR" ]] || fail "alsa-lib source not found: $SOURCE_DIR"
[[ -f "$SOURCE_DIR/configure.ac" || -f "$SOURCE_DIR/configure" ]] || fail "alsa-lib source layout not recognized under: $SOURCE_DIR"
SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
HOST_TAG="$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64"
TOOLCHAIN="${NDK_ROOT}/toolchains/llvm/prebuilt/${HOST_TAG}"
ANDROID_TOOLCHAIN_FILE="${NDK_ROOT}/build/cmake/android.toolchain.cmake"
[[ -d "$TOOLCHAIN/bin" ]] || fail "NDK LLVM toolchain not found: $TOOLCHAIN"
[[ -f "$ANDROID_TOOLCHAIN_FILE" ]] || fail "NDK CMake toolchain not found: $ANDROID_TOOLCHAIN_FILE"
command -v cmake >/dev/null 2>&1 || fail "cmake is required"
command -v autoreconf >/dev/null 2>&1 || fail "autoreconf is required"
normalize_abis() {
printf '%s\n' "$BUILD_ABIS" | tr ',' ' '
}
clean_generated_source_headers() {
rm -f \
"$SOURCE_DIR/include/asoundlib.h" \
"$SOURCE_DIR/include/version.h" \
"$SOURCE_DIR/include/stamp-vh" \
"$SOURCE_DIR/include/alsa"
}
build_one() {
local abi="$1"
local prefix build_dir
case "$abi" in
arm64-v8a | armeabi-v7a) ;;
*) fail "Unsupported ABI: $abi" ;;
esac
prefix="${OUTPUT_DIR}/${abi}"
build_dir="${PROJECT_ROOT}/.tmp/alsa-android-build/${abi}"
rm -rf "$build_dir"
mkdir -p "$build_dir" "$prefix"
case "$abi" in
arm64-v8a)
export CC="${TOOLCHAIN}/bin/aarch64-linux-android${ANDROID_API}-clang"
export CXX="${TOOLCHAIN}/bin/aarch64-linux-android${ANDROID_API}-clang++"
export HOST_TRIPLE="aarch64-linux-android"
;;
armeabi-v7a)
export CC="${TOOLCHAIN}/bin/armv7a-linux-androideabi${ANDROID_API}-clang"
export CXX="${TOOLCHAIN}/bin/armv7a-linux-androideabi${ANDROID_API}-clang++"
export HOST_TRIPLE="arm-linux-androideabi"
;;
esac
export AR="${TOOLCHAIN}/bin/llvm-ar"
export RANLIB="${TOOLCHAIN}/bin/llvm-ranlib"
export STRIP="${TOOLCHAIN}/bin/llvm-strip"
export CFLAGS="-fPIC"
export CXXFLAGS="-fPIC"
clean_generated_source_headers
if [[ -f "$SOURCE_DIR/config.status" || -f "$SOURCE_DIR/Makefile" ]]; then
(
cd "$SOURCE_DIR"
make distclean >/dev/null 2>&1 || true
)
clean_generated_source_headers
fi
if [[ ! -x "$SOURCE_DIR/configure" ]]; then
(
cd "$SOURCE_DIR"
autoreconf -fi
)
fi
(
cd "$build_dir"
pcm_plugins="copy linear route mulaw alaw adpcm rate plug multi file null empty meter hooks lfloat ladspa asym iec958 softvol extplug ioplug mmap_emul"
ctl_plugins="remap ext"
ac_cv_header_sys_shm_h=no \
"$SOURCE_DIR/configure" \
--host="$HOST_TRIPLE" \
--prefix="$prefix" \
--enable-shared \
--disable-static \
--disable-python \
--with-pcm-plugins="$pcm_plugins" \
--with-ctl-plugins="$ctl_plugins" \
--disable-doc \
--disable-oss \
--disable-seq \
--disable-rawmidi \
--disable-hwdep \
--disable-usb \
--disable-firewire \
--disable-instr \
--disable-alisp
make -j"$JOBS"
make install
)
mkdir -p "$prefix/lib/pkgconfig"
cat > "$prefix/lib/pkgconfig/alsa.pc" <<EOF
prefix=\${pcfiledir}/../..
exec_prefix=\${prefix}
libdir=\${exec_prefix}/lib
includedir=\${prefix}/include
Name: alsa
Description: ALSA sound library
Version: 1.2.15
Libs: -L\${libdir} -lasound
Cflags: -I\${includedir}
EOF
echo "Built ALSA for ${abi}: ${prefix}"
}
for abi in $(normalize_abis); do
build_one "$abi"
done
cat <<EOF
Done.
Use this when building the Android APK:
export ONE_KVM_ANDROID_ALSA_ROOT="${OUTPUT_DIR}"
cd android && ./gradlew :app:assembleDebug
EOF

View File

@@ -0,0 +1,296 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
SOURCE_DIR=""
OUTPUT_DIR="${PROJECT_ROOT}/dist/android-ffmpeg-mediacodec"
ANDROID_API="${ANDROID_API:-21}"
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
BUILD_ABIS="arm64-v8a armeabi-v7a"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
usage() {
cat <<'EOF'
Usage:
scripts/build-android-ffmpeg-mediacodec.sh --source <ffmpeg-source-dir> [options]
Required:
--source <dir> FFmpeg source directory. For the downloaded package,
use the extracted ffmpeg-rockchip directory.
Options:
--output <dir> Output root. Default: dist/android-ffmpeg-mediacodec
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
--api <level> Android API level. Default: 21.
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
-h, --help Show this help.
The output layout is compatible with ONE_KVM_ANDROID_FFMPEG_ROOT:
<output>/arm64-v8a/include
<output>/arm64-v8a/lib
<output>/armeabi-v7a/include
<output>/armeabi-v7a/lib
Example:
scripts/build-android-ffmpeg-mediacodec.sh \
--source .tmp/android-ffmpeg-check/src/ffmpeg-rockchip \
--output /opt/one-kvm/android-ffmpeg
export ONE_KVM_ANDROID_FFMPEG_ROOT=/opt/one-kvm/android-ffmpeg
cd android && ./gradlew :app:assembleDebug
EOF
}
fail() {
echo "Error: $*" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--source)
SOURCE_DIR="${2:-}"
shift 2
;;
--output)
OUTPUT_DIR="${2:-}"
shift 2
;;
--ndk)
NDK_ROOT="${2:-}"
shift 2
;;
--api)
ANDROID_API="${2:-}"
shift 2
;;
--abis)
BUILD_ABIS="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$SOURCE_DIR" ]] || fail "--source is required"
[[ -d "$SOURCE_DIR" ]] || fail "FFmpeg source not found: $SOURCE_DIR"
[[ -x "$SOURCE_DIR/configure" ]] || fail "FFmpeg configure script not found under: $SOURCE_DIR"
[[ -n "$NDK_ROOT" ]] || fail "--ndk or ANDROID_NDK_HOME/ANDROID_NDK_ROOT is required"
[[ -d "$NDK_ROOT/toolchains/llvm/prebuilt" ]] || fail "Invalid NDK root: $NDK_ROOT"
SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
HOST_TAG="$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64"
TOOLCHAIN="${NDK_ROOT}/toolchains/llvm/prebuilt/${HOST_TAG}"
[[ -d "$TOOLCHAIN/bin" ]] || fail "NDK LLVM toolchain not found: $TOOLCHAIN"
normalize_abis() {
printf '%s\n' "$BUILD_ABIS" | tr ',' ' '
}
patch_android_ffmpeg_mjpeg_mediacodec() {
local avcodec_dir="${SOURCE_DIR}/libavcodec"
local configure_file="${SOURCE_DIR}/configure"
local mediacodecdec="${avcodec_dir}/mediacodecdec.c"
local allcodecs="${avcodec_dir}/allcodecs.c"
local makefile="${avcodec_dir}/Makefile"
[[ -f "$mediacodecdec" ]] || fail "FFmpeg mediacodecdec.c not found: $mediacodecdec"
[[ -f "$allcodecs" ]] || fail "FFmpeg allcodecs.c not found: $allcodecs"
[[ -f "$makefile" ]] || fail "FFmpeg libavcodec Makefile not found: $makefile"
[[ -f "$configure_file" ]] || fail "FFmpeg configure not found: $configure_file"
python3 - "$mediacodecdec" "$allcodecs" "$configure_file" "$makefile" <<'PY'
from pathlib import Path
import sys
mediacodecdec, allcodecs, configure_file, makefile = map(Path, sys.argv[1:])
def replace_once(path: Path, old: str, new: str) -> None:
text = path.read_text()
if new in text:
return
if old not in text:
raise SystemExit(f"patch anchor not found in {path}: {old!r}")
path.write_text(text.replace(old, new, 1))
replace_once(
mediacodecdec,
"CONFIG_MPEG2_MEDIACODEC_DECODER || \\\n",
"CONFIG_MJPEG_MEDIACODEC_DECODER || \\\n"
" CONFIG_MPEG2_MEDIACODEC_DECODER || \\\n",
)
replace_once(
mediacodecdec,
"#if CONFIG_MPEG2_MEDIACODEC_DECODER\n"
" case AV_CODEC_ID_MPEG2VIDEO:",
"#if CONFIG_MJPEG_MEDIACODEC_DECODER\n"
" case AV_CODEC_ID_MJPEG:\n"
" codec_mime = \"video/mjpeg\";\n\n"
" ret = common_set_extradata(avctx, format);\n"
" if (ret < 0)\n"
" goto done;\n"
" break;\n"
"#endif\n"
"#if CONFIG_MPEG2_MEDIACODEC_DECODER\n"
" case AV_CODEC_ID_MPEG2VIDEO:",
)
replace_once(
mediacodecdec,
"#if CONFIG_MPEG2_MEDIACODEC_DECODER\n"
"DECLARE_MEDIACODEC_VDEC(mpeg2, \"MPEG-2\", AV_CODEC_ID_MPEG2VIDEO, NULL)",
"#if CONFIG_MJPEG_MEDIACODEC_DECODER\n"
"DECLARE_MEDIACODEC_VDEC(mjpeg, \"MJPEG\", AV_CODEC_ID_MJPEG, NULL)\n"
"#endif\n\n"
"#if CONFIG_MPEG2_MEDIACODEC_DECODER\n"
"DECLARE_MEDIACODEC_VDEC(mpeg2, \"MPEG-2\", AV_CODEC_ID_MPEG2VIDEO, NULL)",
)
replace_once(
allcodecs,
"extern const FFCodec ff_mjpeg_cuvid_decoder;",
"extern const FFCodec ff_mjpeg_cuvid_decoder;\n"
"extern const FFCodec ff_mjpeg_mediacodec_decoder;",
)
replace_once(
configure_file,
'mjpeg_cuvid_decoder_deps="cuvid"',
'mjpeg_cuvid_decoder_deps="cuvid"\n'
'mjpeg_mediacodec_decoder_deps="mediacodec"',
)
replace_once(
makefile,
"OBJS-$(CONFIG_MJPEG_RKMPP_DECODER)",
"OBJS-$(CONFIG_MJPEG_MEDIACODEC_DECODER) += mediacodecdec.o\n"
"OBJS-$(CONFIG_MJPEG_RKMPP_DECODER)",
)
PY
}
abi_arch() {
case "$1" in
arm64-v8a) echo "aarch64" ;;
armeabi-v7a) echo "arm" ;;
*) fail "Unsupported ABI: $1" ;;
esac
}
abi_cpu() {
case "$1" in
arm64-v8a) echo "armv8-a" ;;
armeabi-v7a) echo "armv7-a" ;;
*) fail "Unsupported ABI: $1" ;;
esac
}
abi_target() {
case "$1" in
arm64-v8a) echo "aarch64-linux-android" ;;
armeabi-v7a) echo "armv7a-linux-androideabi" ;;
*) fail "Unsupported ABI: $1" ;;
esac
}
build_one() {
local abi="$1"
local arch cpu target prefix build_dir cc cxx ar ranlib strip extra_cflags extra_ldflags
arch="$(abi_arch "$abi")"
cpu="$(abi_cpu "$abi")"
target="$(abi_target "$abi")"
prefix="${OUTPUT_DIR}/${abi}"
build_dir="${PROJECT_ROOT}/.tmp/ffmpeg-android-build/${abi}"
cc="${TOOLCHAIN}/bin/${target}${ANDROID_API}-clang"
cxx="${TOOLCHAIN}/bin/${target}${ANDROID_API}-clang++"
ar="${TOOLCHAIN}/bin/llvm-ar"
ranlib="${TOOLCHAIN}/bin/llvm-ranlib"
strip="${TOOLCHAIN}/bin/llvm-strip"
extra_cflags="-fPIC"
extra_ldflags=""
[[ -x "$cc" ]] || fail "Missing compiler: $cc"
if [[ "$abi" == "armeabi-v7a" ]]; then
extra_cflags="${extra_cflags} -march=armv7-a -mfloat-abi=softfp -mfpu=neon"
extra_ldflags="${extra_ldflags} -Wl,--fix-cortex-a8"
fi
rm -rf "$build_dir"
mkdir -p "$build_dir" "$prefix"
(
cd "$build_dir"
"${SOURCE_DIR}/configure" \
--prefix="$prefix" \
--target-os=android \
--arch="$arch" \
--cpu="$cpu" \
--cc="$cc" \
--cxx="$cxx" \
--ar="$ar" \
--ranlib="$ranlib" \
--strip="$strip" \
--cross-prefix="${TOOLCHAIN}/bin/llvm-" \
--sysroot="${TOOLCHAIN}/sysroot" \
--enable-cross-compile \
--enable-static \
--disable-shared \
--disable-programs \
--disable-doc \
--disable-avdevice \
--disable-avformat \
--disable-avfilter \
--disable-swscale \
--disable-swresample \
--disable-postproc \
--disable-network \
--disable-everything \
--disable-hwaccels \
--disable-cuda-llvm \
--disable-v4l2-m2m \
--disable-vulkan \
--enable-pthreads \
--enable-jni \
--enable-mediacodec \
--enable-decoder=mjpeg_mediacodec \
--enable-decoder=mjpeg \
--enable-encoder=h264_mediacodec \
--enable-encoder=hevc_mediacodec \
--enable-parser=mjpeg \
--enable-bsf=h264_metadata \
--enable-bsf=hevc_metadata \
--enable-protocol=file \
--extra-cflags="$extra_cflags" \
--extra-ldflags="$extra_ldflags"
make -j"$JOBS"
make install
)
echo "Built FFmpeg MediaCodec for ${abi}: ${prefix}"
}
patch_android_ffmpeg_mjpeg_mediacodec
for abi in $(normalize_abis); do
build_one "$abi"
done
cat <<EOF
Done.
Use this when building the Android APK:
export ONE_KVM_ANDROID_FFMPEG_ROOT="${OUTPUT_DIR}"
cd android && ./gradlew :app:assembleDebug
EOF

View File

@@ -0,0 +1,189 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
SOURCE_DIR=""
OUTPUT_DIR="${PROJECT_ROOT}/dist/android-libyuv"
JPEG_ROOT="${ONE_KVM_ANDROID_TURBOJPEG_ROOT:-${PROJECT_ROOT}/dist/android-turbojpeg}"
ANDROID_API="${ANDROID_API:-21}"
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
BUILD_ABIS="arm64-v8a armeabi-v7a"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
LIBYUV_REPO="${LIBYUV_REPO:-https://github.com/lemenkov/libyuv.git}"
usage() {
cat <<'EOF'
Usage:
scripts/build-android-libyuv.sh [options]
Options:
--source <dir> Existing libyuv source checkout. If omitted, the script
clones libyuv into .tmp/android-libyuv-src.
--output <dir> Output root. Default: dist/android-libyuv
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
--api <level> Android API level. Default: 21.
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
--jpeg-root <dir> Android libjpeg root. Defaults to ONE_KVM_ANDROID_TURBOJPEG_ROOT
or dist/android-turbojpeg when present. Enables libyuv HAVE_JPEG.
-h, --help Show this help.
The output layout is compatible with ONE_KVM_ANDROID_LIBYUV_ROOT:
<output>/arm64-v8a/include
<output>/arm64-v8a/lib/libyuv.a
<output>/armeabi-v7a/include
<output>/armeabi-v7a/lib/libyuv.a
Example:
scripts/build-android-libyuv.sh --output /opt/one-kvm/android-libyuv
export ONE_KVM_ANDROID_LIBYUV_ROOT=/opt/one-kvm/android-libyuv
cd android && ./gradlew :app:assembleDebug
EOF
}
fail() {
echo "Error: $*" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--source)
SOURCE_DIR="${2:-}"
shift 2
;;
--output)
OUTPUT_DIR="${2:-}"
shift 2
;;
--ndk)
NDK_ROOT="${2:-}"
shift 2
;;
--api)
ANDROID_API="${2:-}"
shift 2
;;
--abis)
BUILD_ABIS="${2:-}"
shift 2
;;
--jpeg-root)
JPEG_ROOT="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$NDK_ROOT" ]] || fail "--ndk or ANDROID_NDK_HOME/ANDROID_NDK_ROOT is required"
[[ -d "$NDK_ROOT/toolchains/llvm/prebuilt" ]] || fail "Invalid NDK root: $NDK_ROOT"
if [[ -z "$SOURCE_DIR" ]]; then
SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-libyuv-src"
if [[ ! -d "$SOURCE_DIR/.git" ]]; then
rm -rf "$SOURCE_DIR"
git clone --depth 1 "$LIBYUV_REPO" "$SOURCE_DIR"
fi
fi
[[ -d "$SOURCE_DIR" ]] || fail "libyuv source not found: $SOURCE_DIR"
[[ -f "$SOURCE_DIR/CMakeLists.txt" ]] || fail "libyuv CMakeLists.txt not found under: $SOURCE_DIR"
SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
HOST_TAG="$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64"
TOOLCHAIN="${NDK_ROOT}/toolchains/llvm/prebuilt/${HOST_TAG}"
ANDROID_TOOLCHAIN_FILE="${NDK_ROOT}/build/cmake/android.toolchain.cmake"
[[ -d "$TOOLCHAIN/bin" ]] || fail "NDK LLVM toolchain not found: $TOOLCHAIN"
[[ -f "$ANDROID_TOOLCHAIN_FILE" ]] || fail "NDK CMake toolchain not found: $ANDROID_TOOLCHAIN_FILE"
command -v cmake >/dev/null 2>&1 || fail "cmake is required"
normalize_abis() {
printf '%s\n' "$BUILD_ABIS" | tr ',' ' '
}
build_one() {
local abi="$1"
local prefix build_dir jpeg_include jpeg_library
local -a jpeg_args
case "$abi" in
arm64-v8a | armeabi-v7a | x86 | x86_64) ;;
*) fail "Unsupported ABI: $abi" ;;
esac
prefix="${OUTPUT_DIR}/${abi}"
build_dir="${PROJECT_ROOT}/.tmp/libyuv-android-build/${abi}"
rm -rf "$build_dir"
mkdir -p "$build_dir" "$prefix"
jpeg_include="$JPEG_ROOT/$abi/include"
jpeg_library="$JPEG_ROOT/$abi/lib/libjpeg.a"
jpeg_args=()
if [[ -f "$jpeg_library" && -f "$jpeg_include/jpeglib.h" ]]; then
jpeg_args=(
-DJPEG_FOUND=TRUE
-DJPEG_INCLUDE_DIR="$jpeg_include"
-DJPEG_LIBRARY="$jpeg_library"
-DCMAKE_C_FLAGS="-DHAVE_JPEG"
-DCMAKE_CXX_FLAGS="-DHAVE_JPEG"
)
else
echo "Warning: Android libjpeg not found for ${abi}; libyuv MJPEG APIs will be disabled." >&2
echo " Checked: $jpeg_library and $jpeg_include/jpeglib.h" >&2
fi
cmake -S "$SOURCE_DIR" -B "$build_dir" \
-DCMAKE_TOOLCHAIN_FILE="$ANDROID_TOOLCHAIN_FILE" \
-DANDROID_ABI="$abi" \
-DANDROID_PLATFORM="android-${ANDROID_API}" \
-DANDROID_STL=c++_shared \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$prefix" \
-DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DBUILD_SHARED_LIBS=OFF \
-DUNIT_TEST=OFF \
-DTEST=OFF \
"${jpeg_args[@]}"
cmake --build "$build_dir" --target yuv --parallel "$JOBS"
mkdir -p "$prefix/lib" "$prefix/include"
if [[ -f "$build_dir/libyuv.a" ]]; then
cp "$build_dir/libyuv.a" "$prefix/lib/libyuv.a"
elif [[ -f "$build_dir/lib/libyuv.a" ]]; then
cp "$build_dir/lib/libyuv.a" "$prefix/lib/libyuv.a"
else
fail "Built libyuv.a was not found under: $build_dir"
fi
cp -R "$SOURCE_DIR/include/." "$prefix/include/"
echo "Built libyuv for ${abi}: ${prefix}"
}
for abi in $(normalize_abis); do
build_one "$abi"
done
cat <<EOF
Done.
Use this when building the Android APK:
export ONE_KVM_ANDROID_LIBYUV_ROOT="${OUTPUT_DIR}"
export ONE_KVM_ANDROID_TURBOJPEG_ROOT="${JPEG_ROOT}"
cd android && ./gradlew :app:assembleDebug
EOF

View File

@@ -0,0 +1,186 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
SOURCE_DIR=""
OUTPUT_DIR="${PROJECT_ROOT}/dist/android-opus"
ANDROID_API="${ANDROID_API:-21}"
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
BUILD_ABIS="arm64-v8a armeabi-v7a"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
OPUS_VERSION="${OPUS_VERSION:-1.5.2}"
OPUS_TARBALL_URL="${OPUS_TARBALL_URL:-https://downloads.xiph.org/releases/opus/opus-${OPUS_VERSION}.tar.gz}"
OPUS_TARBALL_SHA256="${OPUS_TARBALL_SHA256:-65c1d2f78b9f2fb20082c38cbe47c951ad5839345876e46941612ee87f9a7ce1}"
LOCAL_OPUS_TARBALL="${LOCAL_OPUS_TARBALL:-${PROJECT_ROOT}/opus-${OPUS_VERSION}.tar.gz}"
usage() {
cat <<'EOF'
Usage:
scripts/build-android-opus.sh [options]
Options:
--source <dir> Existing opus source checkout. If omitted, the script
downloads and extracts the official source tarball
into .tmp/android-opus-src.
--output <dir> Output root. Default: dist/android-opus
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
--api <level> Android API level. Default: 21.
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
-h, --help Show this help.
The output layout is compatible with ONE_KVM_ANDROID_OPUS_ROOT:
<output>/arm64-v8a/include/opus/opus.h
<output>/arm64-v8a/lib/libopus.so
<output>/armeabi-v7a/include/opus/opus.h
<output>/armeabi-v7a/lib/libopus.so
EOF
}
fail() {
echo "Error: $*" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--source)
SOURCE_DIR="${2:-}"
shift 2
;;
--output)
OUTPUT_DIR="${2:-}"
shift 2
;;
--ndk)
NDK_ROOT="${2:-}"
shift 2
;;
--api)
ANDROID_API="${2:-}"
shift 2
;;
--abis)
BUILD_ABIS="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$NDK_ROOT" ]] || fail "--ndk or ANDROID_NDK_HOME/ANDROID_NDK_ROOT is required"
[[ -d "$NDK_ROOT/toolchains/llvm/prebuilt" ]] || fail "Invalid NDK root: $NDK_ROOT"
if [[ -z "$SOURCE_DIR" ]]; then
SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-opus-src"
if [[ ! -f "$SOURCE_DIR/configure" ]]; then
rm -rf "$SOURCE_DIR"
mkdir -p "$SOURCE_DIR"
tarball="${PROJECT_ROOT}/.tmp/opus-${OPUS_VERSION}.tar.gz"
if [[ -f "$LOCAL_OPUS_TARBALL" ]]; then
cp "$LOCAL_OPUS_TARBALL" "$tarball"
else
command -v curl >/dev/null 2>&1 || fail "curl is required to download opus source"
curl -fsSL "$OPUS_TARBALL_URL" -o "$tarball"
fi
echo "${OPUS_TARBALL_SHA256} ${tarball}" | sha256sum -c -
tar -xzf "$tarball" -C "$SOURCE_DIR" --strip-components=1
fi
fi
[[ -d "$SOURCE_DIR" ]] || fail "opus source not found: $SOURCE_DIR"
[[ -x "$SOURCE_DIR/configure" || -f "$SOURCE_DIR/configure.ac" ]] || fail "opus source layout not recognized under: $SOURCE_DIR"
SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
HOST_TAG="$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64"
TOOLCHAIN="${NDK_ROOT}/toolchains/llvm/prebuilt/${HOST_TAG}"
ANDROID_TOOLCHAIN_FILE="${NDK_ROOT}/build/cmake/android.toolchain.cmake"
[[ -d "$TOOLCHAIN/bin" ]] || fail "NDK LLVM toolchain not found: $TOOLCHAIN"
[[ -f "$ANDROID_TOOLCHAIN_FILE" ]] || fail "NDK CMake toolchain not found: $ANDROID_TOOLCHAIN_FILE"
command -v cmake >/dev/null 2>&1 || fail "cmake is required"
normalize_abis() {
printf '%s\n' "$BUILD_ABIS" | tr ',' ' '
}
build_one() {
local abi="$1"
local prefix build_dir
case "$abi" in
arm64-v8a | armeabi-v7a) ;;
*) fail "Unsupported ABI: $abi" ;;
esac
prefix="${OUTPUT_DIR}/${abi}"
build_dir="${PROJECT_ROOT}/.tmp/opus-android-build/${abi}"
rm -rf "$build_dir"
mkdir -p "$build_dir" "$prefix"
(
cd "$build_dir"
case "$abi" in
arm64-v8a)
export CC="${TOOLCHAIN}/bin/aarch64-linux-android${ANDROID_API}-clang"
export CXX="${TOOLCHAIN}/bin/aarch64-linux-android${ANDROID_API}-clang++"
export HOST_TRIPLE="aarch64-linux-android"
;;
armeabi-v7a)
export CC="${TOOLCHAIN}/bin/armv7a-linux-androideabi${ANDROID_API}-clang"
export CXX="${TOOLCHAIN}/bin/armv7a-linux-androideabi${ANDROID_API}-clang++"
export HOST_TRIPLE="arm-linux-androideabi"
;;
esac
export AR="${TOOLCHAIN}/bin/llvm-ar"
export RANLIB="${TOOLCHAIN}/bin/llvm-ranlib"
export STRIP="${TOOLCHAIN}/bin/llvm-strip"
export CFLAGS="-fPIC"
export CXXFLAGS="-fPIC"
export LDFLAGS=""
"$SOURCE_DIR/configure" \
--prefix="$prefix" \
--host="$HOST_TRIPLE" \
--disable-static \
--enable-shared \
--disable-doc \
--disable-extra-programs \
--with-pic
make -j"$JOBS"
make install
)
mkdir -p "$prefix/lib" "$prefix/include"
if [[ -f "$prefix/include/opus/opus.h" ]]; then
:
elif [[ -f "$SOURCE_DIR/include/opus/opus.h" ]]; then
mkdir -p "$prefix/include/opus"
cp "$SOURCE_DIR/include/opus/opus.h" "$prefix/include/opus/opus.h"
fi
echo "Built Opus for ${abi}: ${prefix}"
}
for abi in $(normalize_abis); do
build_one "$abi"
done
cat <<EOF
Done.
Use this when building the Android APK:
export ONE_KVM_ANDROID_OPUS_ROOT="${OUTPUT_DIR}"
cd android && ./gradlew :app:assembleDebug
EOF

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
SOURCE_DIR=""
OUTPUT_DIR="${PROJECT_ROOT}/dist/android-turbojpeg"
ANDROID_API="${ANDROID_API:-21}"
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
BUILD_ABIS="arm64-v8a armeabi-v7a"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
LIBJPEG_TURBO_REPO="${LIBJPEG_TURBO_REPO:-https://github.com/libjpeg-turbo/libjpeg-turbo.git}"
usage() {
cat <<'EOF'
Usage:
scripts/build-android-turbojpeg.sh [options]
Options:
--source <dir> Existing libjpeg-turbo source checkout. If omitted,
the script clones it into .tmp/android-turbojpeg-src.
--output <dir> Output root. Default: dist/android-turbojpeg
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
--api <level> Android API level. Default: 21.
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
-h, --help Show this help.
The output layout is compatible with ONE_KVM_ANDROID_TURBOJPEG_ROOT:
<output>/arm64-v8a/include/turbojpeg.h
<output>/arm64-v8a/lib/libturbojpeg.a
<output>/arm64-v8a/include/jpeglib.h
<output>/arm64-v8a/lib/libjpeg.a
<output>/armeabi-v7a/include/turbojpeg.h
<output>/armeabi-v7a/lib/libturbojpeg.a
<output>/armeabi-v7a/include/jpeglib.h
<output>/armeabi-v7a/lib/libjpeg.a
EOF
}
fail() {
echo "Error: $*" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--source)
SOURCE_DIR="${2:-}"
shift 2
;;
--output)
OUTPUT_DIR="${2:-}"
shift 2
;;
--ndk)
NDK_ROOT="${2:-}"
shift 2
;;
--api)
ANDROID_API="${2:-}"
shift 2
;;
--abis)
BUILD_ABIS="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$NDK_ROOT" ]] || fail "--ndk or ANDROID_NDK_HOME/ANDROID_NDK_ROOT is required"
[[ -d "$NDK_ROOT/toolchains/llvm/prebuilt" ]] || fail "Invalid NDK root: $NDK_ROOT"
if [[ -z "$SOURCE_DIR" ]]; then
SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-turbojpeg-src"
if [[ ! -d "$SOURCE_DIR/.git" ]]; then
rm -rf "$SOURCE_DIR"
git clone --depth 1 "$LIBJPEG_TURBO_REPO" "$SOURCE_DIR"
fi
fi
[[ -d "$SOURCE_DIR" ]] || fail "libjpeg-turbo source not found: $SOURCE_DIR"
[[ -f "$SOURCE_DIR/CMakeLists.txt" ]] || fail "libjpeg-turbo CMakeLists.txt not found under: $SOURCE_DIR"
SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
HOST_TAG="$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64"
TOOLCHAIN="${NDK_ROOT}/toolchains/llvm/prebuilt/${HOST_TAG}"
ANDROID_TOOLCHAIN_FILE="${NDK_ROOT}/build/cmake/android.toolchain.cmake"
[[ -d "$TOOLCHAIN/bin" ]] || fail "NDK LLVM toolchain not found: $TOOLCHAIN"
[[ -f "$ANDROID_TOOLCHAIN_FILE" ]] || fail "NDK CMake toolchain not found: $ANDROID_TOOLCHAIN_FILE"
command -v cmake >/dev/null 2>&1 || fail "cmake is required"
normalize_abis() {
printf '%s\n' "$BUILD_ABIS" | tr ',' ' '
}
build_one() {
local abi="$1"
local prefix build_dir lib_path
case "$abi" in
arm64-v8a | armeabi-v7a | x86 | x86_64) ;;
*) fail "Unsupported ABI: $abi" ;;
esac
prefix="${OUTPUT_DIR}/${abi}"
build_dir="${PROJECT_ROOT}/.tmp/turbojpeg-android-build/${abi}"
rm -rf "$build_dir"
mkdir -p "$build_dir" "$prefix"
cmake -S "$SOURCE_DIR" -B "$build_dir" \
-DCMAKE_TOOLCHAIN_FILE="$ANDROID_TOOLCHAIN_FILE" \
-DANDROID_ABI="$abi" \
-DANDROID_PLATFORM="android-${ANDROID_API}" \
-DANDROID_STL=c++_shared \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$prefix" \
-DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DCMAKE_C_FLAGS="-DANDROID -Dstderr=__sF+2" \
-DCMAKE_CXX_FLAGS="-DANDROID -Dstderr=__sF+2" \
-DENABLE_SHARED=OFF \
-DENABLE_STATIC=ON \
-DWITH_TURBOJPEG=ON \
-DWITH_JAVA=OFF \
-DWITH_12BIT=OFF \
-DWITH_ARITH_DEC=ON \
-DWITH_ARITH_ENC=ON
cmake --build "$build_dir" --target turbojpeg-static jpeg-static --parallel "$JOBS"
mkdir -p "$prefix/lib" "$prefix/include"
lib_path="$build_dir/libturbojpeg.a"
if [[ ! -f "$lib_path" ]]; then
lib_path="$build_dir/lib/libturbojpeg.a"
fi
[[ -f "$lib_path" ]] || fail "Built libturbojpeg.a was not found under: $build_dir"
cp "$lib_path" "$prefix/lib/libturbojpeg.a"
lib_path="$build_dir/libjpeg.a"
if [[ ! -f "$lib_path" ]]; then
lib_path="$build_dir/lib/libjpeg.a"
fi
[[ -f "$lib_path" ]] || fail "Built libjpeg.a was not found under: $build_dir"
cp "$lib_path" "$prefix/lib/libjpeg.a"
cp "$SOURCE_DIR/src/turbojpeg.h" "$prefix/include/turbojpeg.h"
cp "$SOURCE_DIR/src/jerror.h" "$prefix/include/jerror.h"
cp "$SOURCE_DIR/src/jmorecfg.h" "$prefix/include/jmorecfg.h"
cp "$SOURCE_DIR/src/jpeglib.h" "$prefix/include/jpeglib.h"
cp "$build_dir/jconfig.h" "$prefix/include/jconfig.h"
echo "Built TurboJPEG for ${abi}: ${prefix}"
}
for abi in $(normalize_abis); do
build_one "$abi"
done
cat <<EOF
Done.
Use this when building the Android APK:
export ONE_KVM_ANDROID_TURBOJPEG_ROOT="${OUTPUT_DIR}"
cd android && ./gradlew :app:assembleDebug
EOF

View File

@@ -11,20 +11,14 @@ use super::led::LedSensor;
use super::types::{AtxAction, AtxKeyConfig, AtxLedConfig, AtxState, PowerStatus};
use crate::error::{AppError, Result};
/// ATX power control configuration
#[derive(Debug, Clone, Default)]
pub struct AtxControllerConfig {
/// Whether ATX is enabled
pub enabled: bool,
/// Power button configuration (used for both short and long press)
pub power: AtxKeyConfig,
/// Reset button configuration
pub reset: AtxKeyConfig,
/// LED sensing configuration
pub led: AtxLedConfig,
}
/// Internal state holding all ATX components
/// Grouped together to reduce lock acquisitions
struct AtxInner {
config: AtxControllerConfig,
@@ -33,12 +27,9 @@ struct AtxInner {
led_sensor: Option<LedSensor>,
}
/// ATX Controller
///
/// Manages ATX power control through independent executors for each action.
/// Supports hot-reload of configuration.
pub struct AtxController {
/// Single lock for all internal state to reduce lock contention
inner: RwLock<AtxInner>,
}
@@ -53,6 +44,24 @@ impl AtxController {
&& power.baud_rate == reset.baud_rate
}
async fn init_key_executor(
warn_label: &str,
info_label: &str,
config: AtxKeyConfig,
mut executor: AtxKeyExecutor,
) -> Option<AtxKeyExecutor> {
if let Err(e) = executor.init().await {
warn!("Failed to initialize {} executor: {}", warn_label, e);
return None;
}
info!(
"{} executor initialized: {:?} on {} pin {}",
info_label, config.driver, config.device, config.pin
);
Some(executor)
}
async fn init_components(inner: &mut AtxInner) {
if Self::should_share_serial_device(&inner.config.power, &inner.config.reset) {
match AtxKeyExecutor::open_shared_serial(
@@ -60,36 +69,26 @@ impl AtxController {
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);
for (slot, warn_label, info_label, config, serial) in [
(
&mut inner.power_executor,
"power",
"Power",
inner.config.power.clone(),
shared_serial.clone(),
),
(
&mut inner.reset_executor,
"reset",
"Reset",
inner.config.reset.clone(),
shared_serial,
),
] {
let executor =
AtxKeyExecutor::new_with_shared_serial(config.clone(), serial);
*slot =
Self::init_key_executor(warn_label, info_label, config, executor).await;
}
}
Err(e) => {
@@ -100,40 +99,27 @@ impl AtxController {
}
}
} 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);
for (slot, warn_label, info_label, config) in [
(
&mut inner.power_executor,
"power",
"Power",
inner.config.power.clone(),
),
(
&mut inner.reset_executor,
"reset",
"Reset",
inner.config.reset.clone(),
),
] {
if config.is_configured() {
let executor = AtxKeyExecutor::new(config.clone());
*slot = Self::init_key_executor(warn_label, info_label, config, executor).await;
}
}
}
// 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 {
@@ -149,19 +135,17 @@ impl AtxController {
}
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);
for (slot, label) in [
(&mut inner.power_executor, "power"),
(&mut inner.reset_executor, "reset"),
] {
if let Some(executor) = slot.as_mut() {
if let Err(e) = executor.shutdown().await {
warn!("Failed to shutdown {} executor: {}", label, e);
}
}
*slot = None;
}
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 {
@@ -171,7 +155,20 @@ impl AtxController {
inner.led_sensor = None;
}
/// Create a new ATX controller with the specified configuration
async fn read_power_status(sensor: Option<&LedSensor>) -> PowerStatus {
let Some(sensor) = sensor else {
return PowerStatus::Unknown;
};
match sensor.read().await {
Ok(status) => status,
Err(e) => {
debug!("Failed to read ATX LED sensor: {}", e);
PowerStatus::Unknown
}
}
}
pub fn new(config: AtxControllerConfig) -> Self {
Self {
inner: RwLock::new(AtxInner {
@@ -183,12 +180,10 @@ impl AtxController {
}
}
/// Create a disabled ATX controller
pub fn disabled() -> Self {
Self::new(AtxControllerConfig::default())
}
/// Initialize the ATX controller and its executors
pub async fn init(&self) -> Result<()> {
let mut inner = self.inner.write().await;
@@ -204,7 +199,6 @@ impl AtxController {
Ok(())
}
/// Reload ATX controller configuration
pub async fn reload(&self, config: AtxControllerConfig) -> Result<()> {
let mut inner = self.inner.write().await;
@@ -225,7 +219,6 @@ impl AtxController {
Ok(())
}
/// 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;
@@ -233,86 +226,50 @@ impl AtxController {
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(),
));
}
}
}
let (executor, duration) = match action {
AtxAction::Short => (inner.power_executor.as_ref(), timing::SHORT_PRESS),
AtxAction::Long => (inner.power_executor.as_ref(), timing::LONG_PRESS),
AtxAction::Reset => (inner.reset_executor.as_ref(), timing::RESET_PRESS),
};
let Some(executor) = executor else {
return Err(AppError::Config(
match action {
AtxAction::Reset => "Reset button not configured for ATX controller",
_ => "Power button not configured for ATX controller",
}
.to_string(),
));
};
executor.pulse(duration).await?;
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
}
Self::read_power_status(inner.led_sensor.as_ref()).await
}
/// 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 {
match sensor.read().await {
Ok(status) => status,
Err(e) => {
debug!("Failed to read ATX LED sensor: {}", e);
PowerStatus::Unknown
}
}
} else {
PowerStatus::Unknown
};
let power_status = Self::read_power_status(inner.led_sensor.as_ref()).await;
AtxState {
available: inner.config.enabled,

34
src/atx/disabled_key.rs Normal file
View File

@@ -0,0 +1,34 @@
use async_trait::async_trait;
use std::time::Duration;
use super::traits::AtxKeyBackend;
use crate::error::{AppError, Result};
pub struct DisabledAtxKeyBackend {
reason: &'static str,
}
impl DisabledAtxKeyBackend {
pub fn new(reason: &'static str) -> Self {
Self { reason }
}
}
#[async_trait]
impl AtxKeyBackend for DisabledAtxKeyBackend {
async fn init(&mut self) -> Result<()> {
Err(AppError::Internal(self.reason.to_string()))
}
async fn pulse(&self, _duration: Duration) -> Result<()> {
Err(AppError::Internal(self.reason.to_string()))
}
async fn shutdown(&mut self) -> Result<()> {
Ok(())
}
fn is_initialized(&self) -> bool {
false
}
}

34
src/atx/disabled_led.rs Normal file
View File

@@ -0,0 +1,34 @@
#![allow(dead_code)]
use super::types::{AtxLedConfig, PowerStatus};
use crate::error::Result;
pub struct LedSensor {
config: AtxLedConfig,
}
impl LedSensor {
pub fn new(config: AtxLedConfig) -> Self {
Self { config }
}
pub fn is_configured(&self) -> bool {
self.config.is_configured()
}
pub fn is_initialized(&self) -> bool {
false
}
pub async fn init(&mut self) -> Result<()> {
Ok(())
}
pub async fn read(&self) -> Result<PowerStatus> {
Ok(PowerStatus::Unknown)
}
pub async fn shutdown(&mut self) -> Result<()> {
Ok(())
}
}

View File

@@ -1,497 +1,150 @@
//! ATX Key Executor
//!
//! Lightweight executor for a single ATX key operation.
//! Each executor handles one button (power or reset) with its own hardware binding.
//! ATX key executor backend selector.
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::{Arc, Mutex};
use std::time::Duration;
use tokio::time::sleep;
use tracing::{debug, info};
use tracing::debug;
use super::types::{ActiveLevel, AtxDriverType, AtxKeyConfig};
use super::serial_relay::SerialRelayBackend;
use super::traits::{AtxKeyBackend, AtxKeyBackendContext, SharedSerialHandle};
use super::types::{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;
/// Short press duration (power on/graceful shutdown)
pub const SHORT_PRESS: Duration = Duration::from_millis(500);
/// Long press duration (force power off)
pub const LONG_PRESS: Duration = Duration::from_millis(5000);
/// Reset press duration
pub const RESET_PRESS: Duration = Duration::from_millis(500);
}
/// Executor for a single ATX key operation
///
/// Each executor manages one hardware button (power or reset).
/// It handles both GPIO and USB relay backends.
pub struct AtxKeyExecutor {
config: AtxKeyConfig,
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,
backend: Option<Box<dyn AtxKeyBackend>>,
}
impl AtxKeyExecutor {
/// Create a new executor with the given configuration
pub fn new(config: AtxKeyConfig) -> Self {
Self {
config,
gpio_handle: Mutex::new(None),
usb_relay_handle: Mutex::new(None),
serial_handle: Mutex::new(None),
initialized: AtomicBool::new(false),
}
Self::with_context(config, AtxKeyBackendContext::Standalone)
}
/// 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),
}
Self::with_context(config, AtxKeyBackendContext::SharedSerial(serial_handle))
}
/// 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)))
SerialRelayBackend::open_shared_serial(device, baud_rate)
}
fn with_context(config: AtxKeyConfig, context: AtxKeyBackendContext) -> Self {
let backend = build_backend(&config, context);
Self { config, backend }
}
/// Check if this executor is configured
pub fn is_configured(&self) -> bool {
self.config.is_configured()
}
/// Check if this executor is initialized
pub fn is_initialized(&self) -> bool {
self.initialized.load(Ordering::Relaxed)
}
/// Initialize the executor
pub async fn init(&mut self) -> Result<()> {
if !self.config.is_configured() {
debug!("ATX key executor not configured, skipping init");
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 => {}
}
self.initialized.store(true, Ordering::Relaxed);
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!(
"Initializing GPIO ATX executor on {} pin {}",
self.config.device, self.config.pin
);
let mut chip = Chip::new(&self.config.device)
.map_err(|e| AppError::Internal(format!("GPIO chip open failed: {}", e)))?;
let line = chip.get_line(self.config.pin).map_err(|e| {
AppError::Internal(format!("GPIO line {} failed: {}", self.config.pin, e))
let backend = self.backend.as_mut().ok_or_else(|| {
AppError::Internal(format!(
"ATX backend {:?} is unsupported on this platform",
self.config.driver
))
})?;
// Initial value depends on active level (start in inactive state)
let initial_value = match self.config.active_level {
ActiveLevel::High => 0, // Inactive = low
ActiveLevel::Low => 1, // Inactive = high
};
let handle = line
.request(LineRequestFlags::OUTPUT, initial_value, "one-kvm-atx")
.map_err(|e| AppError::Internal(format!("GPIO request failed: {}", e)))?;
*self.gpio_handle.lock().unwrap() = Some(handle);
debug!("GPIO pin {} configured successfully", self.config.pin);
Ok(())
backend.init().await
}
/// Initialize USB relay backend
async fn init_usb_relay(&self) -> Result<()> {
info!(
"Initializing USB relay ATX executor on {} channel {}",
self.config.device, self.config.pin
);
// Open and cache the device handle
let device = OpenOptions::new()
.read(true)
.write(true)
.open(&self.config.device)
.map_err(|e| AppError::Internal(format!("USB relay device open failed: {}", e)))?;
*self.usb_relay_handle.lock().unwrap() = Some(device);
// Ensure relay is off initially
self.send_usb_relay_command(false)?;
debug!(
"USB relay channel {} configured successfully",
self.config.pin
);
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() {
return Err(AppError::Internal("ATX key not configured".to_string()));
}
if !self.is_initialized() {
let backend = self.backend.as_ref().ok_or_else(|| {
AppError::Internal(format!(
"ATX backend {:?} is unsupported on this platform",
self.config.driver
))
})?;
if !backend.is_initialized() {
return Err(AppError::Internal("ATX key not initialized".to_string()));
}
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(()),
}
backend.pulse(duration).await
}
/// Pulse GPIO pin
async fn pulse_gpio(&self, duration: Duration) -> Result<()> {
let (active, inactive) = match self.config.active_level {
ActiveLevel::High => (1u8, 0u8),
ActiveLevel::Low => (0u8, 1u8),
};
// Set to active state
{
let guard = self.gpio_handle.lock().unwrap();
let handle = guard
.as_ref()
.ok_or_else(|| AppError::Internal("GPIO not initialized".to_string()))?;
handle
.set_value(active)
.map_err(|e| AppError::Internal(format!("GPIO set failed: {}", e)))?;
}
// Wait for duration (no lock held)
sleep(duration).await;
// Set to inactive state
{
let guard = self.gpio_handle.lock().unwrap();
if let Some(handle) = guard.as_ref() {
handle.set_value(inactive).ok();
}
}
Ok(())
}
/// Pulse USB relay
async fn pulse_usb_relay(&self, duration: Duration) -> Result<()> {
// Turn relay on
self.send_usb_relay_command(true)?;
// Wait for duration
sleep(duration).await;
// Turn relay off
self.send_usb_relay_command(false)?;
Ok(())
}
/// Send USB relay command using cached handle
fn send_usb_relay_command(&self, on: bool) -> Result<()> {
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
)));
}
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()))?;
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(())
}
/// Shutdown the executor
pub async fn shutdown(&mut self) -> Result<()> {
if !self.is_initialized() {
return Ok(());
if let Some(backend) = self.backend.as_mut() {
backend.shutdown().await?;
}
match self.config.driver {
AtxDriverType::Gpio => {
// Release GPIO handle
*self.gpio_handle.lock().unwrap() = None;
}
AtxDriverType::UsbRelay => {
// Ensure relay is off before closing handle
let _ = self.send_usb_relay_command(false);
// 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 => {}
}
self.initialized.store(false, Ordering::Relaxed);
debug!("ATX key executor shutdown complete");
Ok(())
}
}
impl Drop for AtxKeyExecutor {
fn drop(&mut self) {
// Ensure GPIO lines are released
*self.gpio_handle.lock().unwrap() = None;
// Ensure USB relay is off and handle released
if self.config.driver == AtxDriverType::UsbRelay && self.is_initialized() {
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;
fn build_backend(
config: &AtxKeyConfig,
context: AtxKeyBackendContext,
) -> Option<Box<dyn AtxKeyBackend>> {
match config.driver {
AtxDriverType::Serial => Some(match context {
AtxKeyBackendContext::Standalone => Box::new(SerialRelayBackend::new(config.clone())),
AtxKeyBackendContext::SharedSerial(handle) => Box::new(
SerialRelayBackend::new_with_shared_serial(config.clone(), handle),
),
}),
AtxDriverType::Gpio => build_gpio_backend(config),
AtxDriverType::UsbRelay => build_hidraw_backend(config),
AtxDriverType::None => None,
}
}
#[cfg(unix)]
fn build_gpio_backend(config: &AtxKeyConfig) -> Option<Box<dyn AtxKeyBackend>> {
Some(Box::new(super::gpio_linux::GpioLinuxBackend::new(
config.clone(),
)))
}
#[cfg(not(unix))]
fn build_gpio_backend(_config: &AtxKeyConfig) -> Option<Box<dyn AtxKeyBackend>> {
Some(Box::new(super::disabled_key::DisabledAtxKeyBackend::new(
"GPIO ATX backend is only available on Linux",
)))
}
#[cfg(unix)]
fn build_hidraw_backend(config: &AtxKeyConfig) -> Option<Box<dyn AtxKeyBackend>> {
Some(Box::new(super::hidraw_linux::HidrawLinuxRelayBackend::new(
config.clone(),
)))
}
#[cfg(not(unix))]
fn build_hidraw_backend(_config: &AtxKeyConfig) -> Option<Box<dyn AtxKeyBackend>> {
Some(Box::new(super::disabled_key::DisabledAtxKeyBackend::new(
"USB hidraw relay backend is only available on Linux",
)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::atx::ActiveLevel;
#[test]
fn test_executor_creation() {
fn executor_creation() {
let config = AtxKeyConfig::default();
let executor = AtxKeyExecutor::new(config);
assert!(!executor.is_configured());
assert!(!executor.is_initialized());
}
#[test]
fn test_executor_with_gpio_config() {
fn executor_with_gpio_config() {
let config = AtxKeyConfig {
driver: AtxDriverType::Gpio,
device: "/dev/gpiochip0".to_string(),
@@ -501,16 +154,15 @@ mod tests {
};
let executor = AtxKeyExecutor::new(config);
assert!(executor.is_configured());
assert!(!executor.is_initialized());
}
#[test]
fn test_executor_with_usb_relay_config() {
fn executor_with_usb_relay_config() {
let config = AtxKeyConfig {
driver: AtxDriverType::UsbRelay,
device: "/dev/hidraw0".to_string(),
pin: 1,
active_level: ActiveLevel::High, // Ignored for USB relay
active_level: ActiveLevel::High,
baud_rate: 9600,
};
let executor = AtxKeyExecutor::new(config);
@@ -518,12 +170,12 @@ mod tests {
}
#[test]
fn test_executor_with_serial_config() {
fn executor_with_serial_config() {
let config = AtxKeyConfig {
driver: AtxDriverType::Serial,
device: "/dev/ttyUSB0".to_string(),
pin: 1,
active_level: ActiveLevel::High, // Ignored
active_level: ActiveLevel::High,
baud_rate: 9600,
};
let executor = AtxKeyExecutor::new(config);
@@ -531,91 +183,9 @@ mod tests {
}
#[test]
fn test_timing_constants() {
fn timing_constants() {
assert_eq!(timing::SHORT_PRESS.as_millis(), 500);
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(_)));
}
}

106
src/atx/gpio_linux.rs Normal file
View File

@@ -0,0 +1,106 @@
use async_trait::async_trait;
use gpio_cdev::{Chip, LineHandle, LineRequestFlags};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use tokio::time::sleep;
use tracing::{debug, info};
use super::traits::AtxKeyBackend;
use super::types::{ActiveLevel, AtxKeyConfig};
use crate::error::{AppError, Result};
pub struct GpioLinuxBackend {
config: AtxKeyConfig,
handle: Mutex<Option<LineHandle>>,
initialized: AtomicBool,
}
impl GpioLinuxBackend {
pub fn new(config: AtxKeyConfig) -> Self {
Self {
config,
handle: Mutex::new(None),
initialized: AtomicBool::new(false),
}
}
}
#[async_trait]
impl AtxKeyBackend for GpioLinuxBackend {
async fn init(&mut self) -> Result<()> {
info!(
"Initializing GPIO ATX backend on {} pin {}",
self.config.device, self.config.pin
);
let mut chip = Chip::new(&self.config.device)
.map_err(|e| AppError::Internal(format!("GPIO chip open failed: {}", e)))?;
let line = chip.get_line(self.config.pin).map_err(|e| {
AppError::Internal(format!("GPIO line {} failed: {}", self.config.pin, e))
})?;
let initial_value = match self.config.active_level {
ActiveLevel::High => 0,
ActiveLevel::Low => 1,
};
let handle = line
.request(LineRequestFlags::OUTPUT, initial_value, "one-kvm-atx")
.map_err(|e| AppError::Internal(format!("GPIO request failed: {}", e)))?;
*self.handle.lock().unwrap() = Some(handle);
self.initialized.store(true, Ordering::Relaxed);
debug!("GPIO pin {} configured successfully", self.config.pin);
Ok(())
}
async fn pulse(&self, duration: Duration) -> Result<()> {
if !self.is_initialized() {
return Err(AppError::Internal("GPIO not initialized".to_string()));
}
let (active, inactive) = match self.config.active_level {
ActiveLevel::High => (1u8, 0u8),
ActiveLevel::Low => (0u8, 1u8),
};
{
let guard = self.handle.lock().unwrap();
let handle = guard
.as_ref()
.ok_or_else(|| AppError::Internal("GPIO not initialized".to_string()))?;
handle
.set_value(active)
.map_err(|e| AppError::Internal(format!("GPIO set failed: {}", e)))?;
}
sleep(duration).await;
{
let guard = self.handle.lock().unwrap();
if let Some(handle) = guard.as_ref() {
handle.set_value(inactive).ok();
}
}
Ok(())
}
async fn shutdown(&mut self) -> Result<()> {
*self.handle.lock().unwrap() = None;
self.initialized.store(false, Ordering::Relaxed);
Ok(())
}
fn is_initialized(&self) -> bool {
self.initialized.load(Ordering::Relaxed)
}
}
impl Drop for GpioLinuxBackend {
fn drop(&mut self) {
*self.handle.lock().unwrap() = None;
}
}

190
src/atx/hidraw_linux.rs Normal file
View File

@@ -0,0 +1,190 @@
use async_trait::async_trait;
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::time::Duration;
use tokio::time::sleep;
use tracing::{debug, info};
use super::traits::AtxKeyBackend;
use super::types::AtxKeyConfig;
use crate::error::{AppError, Result};
const USB_RELAY_MAX_CHANNEL: u8 = 8;
const USB_RELAY_REPORT_LEN: usize = 9;
const HIDIOCSFEATURE_9: libc::c_ulong = 0xC009_4806;
pub struct HidrawLinuxRelayBackend {
config: AtxKeyConfig,
handle: Mutex<Option<File>>,
initialized: AtomicBool,
}
impl HidrawLinuxRelayBackend {
pub fn new(config: AtxKeyConfig) -> Self {
Self {
config,
handle: Mutex::new(None),
initialized: AtomicBool::new(false),
}
}
fn validate_config(&self) -> Result<()> {
if self.config.pin == 0 {
return Err(AppError::Config(
"USB relay channel must be 1-based (>= 1)".to_string(),
));
}
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
)));
}
Ok(())
}
fn send_command(&self, on: bool) -> Result<()> {
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
)));
}
let cmd = Self::build_command(channel, on);
let mut guard = self.handle.lock().unwrap();
let device = guard
.as_mut()
.ok_or_else(|| AppError::Internal("USB relay not initialized".to_string()))?;
if let Err(feature_err) = Self::send_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(())
}
pub fn build_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_feature_report(
device: &File,
report: &[u8; USB_RELAY_REPORT_LEN],
) -> std::io::Result<()> {
let rc = unsafe { libc::ioctl(device.as_raw_fd(), HIDIOCSFEATURE_9 as _, report.as_ptr()) };
if rc < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
}
}
#[async_trait]
impl AtxKeyBackend for HidrawLinuxRelayBackend {
async fn init(&mut self) -> Result<()> {
self.validate_config()?;
info!(
"Initializing USB relay ATX backend on {} channel {}",
self.config.device, self.config.pin
);
let device = OpenOptions::new()
.read(true)
.write(true)
.open(&self.config.device)
.map_err(|e| AppError::Internal(format!("USB relay device open failed: {}", e)))?;
*self.handle.lock().unwrap() = Some(device);
self.send_command(false)?;
self.initialized.store(true, Ordering::Relaxed);
debug!(
"USB relay channel {} configured successfully",
self.config.pin
);
Ok(())
}
async fn pulse(&self, duration: Duration) -> Result<()> {
if !self.is_initialized() {
return Err(AppError::Internal("USB relay not initialized".to_string()));
}
self.send_command(true)?;
sleep(duration).await;
self.send_command(false)?;
Ok(())
}
async fn shutdown(&mut self) -> Result<()> {
if self.is_initialized() {
let _ = self.send_command(false);
}
*self.handle.lock().unwrap() = None;
self.initialized.store(false, Ordering::Relaxed);
Ok(())
}
fn is_initialized(&self) -> bool {
self.initialized.load(Ordering::Relaxed)
}
}
impl Drop for HidrawLinuxRelayBackend {
fn drop(&mut self) {
if self.is_initialized() {
let _ = self.send_command(false);
}
*self.handle.lock().unwrap() = None;
}
}
#[cfg(test)]
mod tests {
use super::HidrawLinuxRelayBackend;
#[test]
fn usb_relay_command_format() {
assert_eq!(
HidrawLinuxRelayBackend::build_command(1, true),
[0x00, 0xFF, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
);
assert_eq!(
HidrawLinuxRelayBackend::build_command(1, false),
[0x00, 0xFD, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
);
}
}

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