Compare commits

..

No commits in common. "main" and "v250527" have entirely different histories.

251 changed files with 8780 additions and 14951 deletions

View File

@ -1,7 +1,7 @@
[bumpversion]
commit = True
tag = True
current_version = 4.94
current_version = 4.49
parse = (?P<major>\d+)\.(?P<minor>\d+)(\.(?P<patch>\d+)(\-(?P<release>[a-z]+))?)?
serialize =
{major}.{minor}

View File

@ -1,36 +0,0 @@
---
name: BUG 反馈
about: 反馈你所遇到的软件 BUG 或其他错误
title: "[BUG]"
labels: BUG
assignees: ''
---
### **Bug 反馈**
**问题描述**
请清晰描述您遇到的问题。例如:软件无法启动、特定功能报错或表现异常等。
**复现步骤**
请提供可复现此问题的详细步骤:
1. 前往 '...'
2. 点击 '....'
3. 滚动到 '....'
4. 发现错误
**日志信息**
如果程序崩溃或报错,请在此处粘贴相关的日志。
- **整合包镜像**: `systemctl status kvmd``journalctl -xeu kvmd`
- **Docker 镜像**: `docker logs kvmd`
**系统环境**
- **运行方式**: (例如:整合包镜像 / Docker)
- **镜像版本**: (Docker 镜像请提供版本号)
- **操作系统**: (例如Debian 12)
**尝试过的解决方法**
请简要描述您为解决此问题已尝试过的方法及其结果。如果未尝试,可留空。
**补充信息**
可以附加截图、录屏或其他有助于理解问题的信息。

View File

@ -1,25 +0,0 @@
---
name: 功能请求与设备适配
about: 请求新的功能或适配新的平台
title: "[功能/适配]"
labels: 特性
assignees: ''
---
**功能描述**
请详细描述您期望的新功能应该是什么样子。
- **对于新功能**:它应该如何工作?有哪些关键特性?
- **对于新平台适配**:请提供该平台的具体信息(如设备型号、系统版本、相关链接等)。
**期望的效果**
当该功能实现或平台适配完成后,您期望达到怎样的理想效果?可以像下面这样列出关键点:
- [ ] 用户可以...
- [ ] 系统能够...
- [ ] 解决了之前的...问题
**我能提供的帮助**
为了让这个想法更快成为现实,您可以提供哪些帮助?没有则填写无。
- [ ] 我可以参与后续的功能测试
- [ ] 我可以提供(临时的)远程调试环境(如 SSH、远程桌面
- [ ] 其他:...

View File

@ -4,34 +4,17 @@ on:
workflow_dispatch:
inputs:
device_target:
description: 'Target device to build'
description: 'Target device name'
required: true
type: choice
options:
- onecloud
- onecloud-pro
- cumebox2
- chainedbox
- vm
- e900v22c
- octopus-flanet
- orangepi-zero
- oec-turbo
- all
create_release:
description: 'Create GitHub Release'
required: false
default: true
type: boolean
release_name:
description: 'Custom release name (optional)'
required: false
type: string
env:
BUILD_DATE: ""
GIT_SHA: ""
RELEASE_TAG: ""
jobs:
build:
@ -43,58 +26,11 @@ jobs:
TZ: Asia/Shanghai
volumes:
- /dev:/dev
- /mnt/nfs/lfs/:/mnt/nfs/lfs/
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Inject TURN config (optional)
if: ${{ env.TURN_HOST != '' }}
run: |
mkdir -p configs/kvmd/override.d
cat > configs/kvmd/override.d/turn.yaml <<EOF
janus:
stun:
host: ${TURN_HOST}
port: ${TURN_PORT}
local_ice_servers:
- urls:
- "stun:${TURN_HOST}:${TURN_PORT}"
- "turn:${TURN_HOST}:${TURN_PORT}?transport=udp"
- "turn:${TURN_HOST}:${TURN_PORT}?transport=tcp"
username: "${TURN_USER}"
credential: "${TURN_PASS}"
EOF
env:
TURN_HOST: ${{ secrets.TURN_HOST }}
TURN_PORT: ${{ secrets.TURN_PORT }}
TURN_USER: ${{ secrets.TURN_USER }}
TURN_PASS: ${{ secrets.TURN_PASS }}
- name: Set build environment
id: build_env
shell: bash
run: |
BUILD_DATE=$(date +%y%m%d-%H%M)
# 使用 GitHub 提供的环境变量避免 Git 权限问题
GIT_SHA="${GITHUB_SHA:0:7}"
GIT_BRANCH="${GITHUB_REF_NAME}"
echo "BUILD_DATE=$BUILD_DATE" >> $GITHUB_ENV
echo "GIT_SHA=$GIT_SHA" >> $GITHUB_ENV
echo "GIT_BRANCH=$GIT_BRANCH" >> $GITHUB_ENV
# 生成唯一但不创建新分支的标识符
RELEASE_TAG="build-$BUILD_DATE-${{ github.event.inputs.device_target }}-$GIT_SHA"
echo "RELEASE_TAG=$RELEASE_TAG" >> $GITHUB_ENV
echo "Build environment:"
echo "- Date: $BUILD_DATE"
echo "- Git SHA: $GIT_SHA"
echo "- Git Branch: $GIT_BRANCH"
echo "- Release Tag: $RELEASE_TAG"
uses: actions/checkout@v3
- name: Install dependencies
run: |
@ -102,8 +38,7 @@ jobs:
export DEBIAN_FRONTEND=noninteractive
apt-get install -y --no-install-recommends \
sudo tzdata docker.io qemu-utils qemu-user-static binfmt-support parted e2fsprogs \
curl tar python3 python3-pip rsync git android-sdk-libsparse-utils coreutils zerofree wget \
file tree
curl tar python3 python3-pip rsync git android-sdk-libsparse-utils coreutils zerofree
apt-get clean
rm -rf /var/lib/apt/lists/*
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime
@ -113,98 +48,27 @@ jobs:
DEBIAN_FRONTEND: noninteractive
- name: Build image
id: build
shell: bash
run: |
set -eo pipefail
echo "=== Build Configuration ==="
echo "Target: ${{ github.event.inputs.device_target }}"
echo "Build Date: $BUILD_DATE"
echo "Git SHA: $GIT_SHA"
echo "Git Branch: $GIT_BRANCH"
echo "Output Directory: ${{ github.workspace }}/output"
echo "=========================="
mkdir -p "${{ github.workspace }}/output"
echo "BUILD_DATE=$(date +%y%m%d)" >> $GITHUB_ENV
chmod +x build/build_img.sh
echo "Starting build process..."
if bash build/build_img.sh ${{ github.event.inputs.device_target }}; then
echo "BUILD_SUCCESS=true" >> $GITHUB_OUTPUT
echo "Build completed successfully!"
else
echo "BUILD_SUCCESS=false" >> $GITHUB_OUTPUT
echo "Build failed!" >&2
exit 1
fi
echo "Starting build for target: ${{ github.event.inputs.device_target }}"
bash build/build_img.sh ${{ github.event.inputs.device_target }}
echo "Build script finished."
env:
CI_PROJECT_DIR: ${{ github.workspace }}
GITHUB_ACTIONS: true
OUTPUTDIR: ${{ github.workspace }}/output
- name: Collect build artifacts
id: artifacts
run: |
cd "${{ github.workspace }}/output"
echo "=== Build Artifacts ==="
if [ -d "${{ github.workspace }}/output" ]; then
find . -name "*.xz" | head -20
# 统计xz文件信息
ARTIFACT_COUNT=$(find . -name "*.xz" | wc -l)
TOTAL_SIZE=$(du -sh . | cut -f1)
echo "ARTIFACT_COUNT=$ARTIFACT_COUNT" >> $GITHUB_OUTPUT
echo "TOTAL_SIZE=$TOTAL_SIZE" >> $GITHUB_OUTPUT
else
echo "No output directory found!"
echo "ARTIFACT_COUNT=0" >> $GITHUB_OUTPUT
echo "TOTAL_SIZE=0" >> $GITHUB_OUTPUT
fi
echo "======================"
- name: Create GitHub Release
if: steps.build.outputs.BUILD_SUCCESS == 'true' && github.event.inputs.create_release == 'true'
id: release
uses: softprops/action-gh-release@v1
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
tag_name: ${{ env.RELEASE_TAG }}
name: ${{ github.event.inputs.release_name || format('One-KVM {0} 构建镜像 ({1})', github.event.inputs.device_target, env.BUILD_DATE) }}
body: |
## 📦 GitHub Actions 镜像构建
### 构建信息
- **目标设备**: `${{ github.event.inputs.device_target }}`
- **构建时间**: `${{ env.BUILD_DATE }}`
- **Git 提交**: `${{ env.GIT_SHA }}` (分支: `${{ env.GIT_BRANCH }}`)
- **构建环境**: GitHub Actions (Ubuntu 22.04)
- **工作流ID**: `${{ github.run_id }}`
files: ${{ github.workspace }}/output/*.xz
prerelease: true
make_latest: false
generate_release_notes: false
name: onekvm-image-${{ github.event.inputs.device_target }}-${{ env.BUILD_DATE }}
path: |
${{ github.workspace }}/output/*.img
${{ github.workspace }}/output/*.vmdk
${{ github.workspace }}/output/*.vdi
${{ github.workspace }}/output/*.burn.img
if-no-files-found: ignore
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build summary
if: always()
run: |
echo "## 📋 构建摘要" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| 项目 | 值 |" >> $GITHUB_STEP_SUMMARY
echo "|------|-----|" >> $GITHUB_STEP_SUMMARY
echo "| **目标设备** | \`${{ github.event.inputs.device_target }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **构建时间** | \`${{ env.BUILD_DATE }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Git SHA** | \`${{ env.GIT_SHA }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Git 分支** | \`${{ env.GIT_BRANCH }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **构建状态** | ${{ steps.build.outputs.BUILD_SUCCESS == 'true' && '✅ 成功' || '❌ 失败' }} |" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.build.outputs.BUILD_SUCCESS }}" = "true" ]; then
echo "| **构建产物** | ${{ steps.artifacts.outputs.ARTIFACT_COUNT || '0' }} 个文件 (${{ steps.artifacts.outputs.TOTAL_SIZE || '0' }}) |" >> $GITHUB_STEP_SUMMARY
if [ "${{ github.event.inputs.create_release }}" = "true" ]; then
echo "| **Release** | [${{ env.RELEASE_TAG }}](${{ steps.release.outputs.url }}) |" >> $GITHUB_STEP_SUMMARY
fi
fi
CI_PROJECT_DIR: ${{ github.workspace }}

View File

@ -3,238 +3,81 @@ name: Build and Push Docker Image
on:
workflow_dispatch:
inputs:
build_type:
description: 'Build type'
version:
description: 'Version'
required: true
type: choice
options:
- stage-0
- dev
- release
version:
description: 'Version tag (for main image)'
required: false
default: 'latest'
type: string
platforms:
description: 'Target platforms'
required: false
default: 'linux/amd64,linux/arm64,linux/arm/v7'
type: string
enable_aliyun:
description: 'Push to Aliyun Registry'
required: false
default: true
type: boolean
env:
DOCKERHUB_REGISTRY: docker.io
ALIYUN_REGISTRY: registry.cn-hangzhou.aliyuncs.com
STAGE0_IMAGE: kvmd-stage-0
MAIN_IMAGE: kvmd
- latest
jobs:
build-stage-0:
build:
runs-on: ubuntu-22.04
if: github.event.inputs.build_type == 'stage-0'
permissions:
contents: read
packages: write
container:
image: node:18
env:
TZ: Asia/Shanghai
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
uses: actions/checkout@v3
- name: Inject TURN config (optional)
if: ${{ env.TURN_HOST != '' }}
- name: Install dependencies
run: |
mkdir -p configs/kvmd/override.d
cat > configs/kvmd/override.d/turn.yaml <<EOF
janus:
stun:
host: ${TURN_HOST}
port: ${TURN_PORT}
local_ice_servers:
- urls:
- "stun:${TURN_HOST}:${TURN_PORT}"
- "turn:${TURN_HOST}:${TURN_PORT}?transport=udp"
- "turn:${TURN_HOST}:${TURN_PORT}?transport=tcp"
username: "${TURN_USER}"
credential: "${TURN_PASS}"
EOF
apt-get update
export DEBIAN_FRONTEND=noninteractive
apt-get install -y --no-install-recommends \
sudo tzdata docker.io qemu-utils qemu-user-static binfmt-support parted e2fsprogs \
curl tar python3 python3-pip rsync git android-sdk-libsparse-utils coreutils zerofree
apt-get clean
rm -rf /var/lib/apt/lists/*
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime
echo $TZ > /etc/timezone
update-binfmts --enable
env:
TURN_HOST: ${{ secrets.TURN_HOST }}
TURN_PORT: ${{ secrets.TURN_PORT }}
TURN_USER: ${{ secrets.TURN_USER }}
TURN_PASS: ${{ secrets.TURN_PASS }}
DEBIAN_FRONTEND: noninteractive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
platforms: ${{ github.event.inputs.platforms }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: all
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKERHUB_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Aliyun Registry
if: github.event.inputs.enable_aliyun == 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.ALIYUN_REGISTRY }}
username: ${{ secrets.ALIYUN_USERNAME }}
password: ${{ secrets.ALIYUN_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
silentwind0/${{ env.STAGE0_IMAGE }}
${{ github.event.inputs.enable_aliyun == 'true' && format('{0}/silentwind/{1}', env.ALIYUN_REGISTRY, env.STAGE0_IMAGE) || '' }}
tags: |
type=raw,value=latest
type=raw,value=latest-{{date 'YYYYMMDD-HHmmss'}}
type=sha,prefix={{branch}}-
labels: |
org.opencontainers.image.title=One-KVM Stage-0 Base Image
org.opencontainers.image.description=Base image for One-KVM build environment
org.opencontainers.image.vendor=One-KVM Project
- name: Build and push stage-0 image
uses: docker/build-push-action@v5
with:
context: .
file: ./build/Dockerfile-stage-0
platforms: ${{ github.event.inputs.platforms }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=stage-0
cache-to: type=gha,mode=max,scope=stage-0
provenance: false
sbom: false
allow: security.insecure
build-main:
runs-on: ubuntu-22.04
if: github.event.inputs.build_type != 'stage-0'
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Inject TURN config (optional)
if: ${{ env.TURN_HOST != '' }}
- name: Install Docker Buildx
run: |
mkdir -p configs/kvmd/override.d
cat > configs/kvmd/override.d/turn.yaml <<EOF
janus:
stun:
host: ${TURN_HOST}
port: ${TURN_PORT}
local_ice_servers:
- urls:
- "stun:${TURN_HOST}:${TURN_PORT}"
- "turn:${TURN_HOST}:${TURN_PORT}?transport=udp"
- "turn:${TURN_HOST}:${TURN_PORT}?transport=tcp"
username: "${TURN_USER}"
credential: "${TURN_PASS}"
EOF
env:
TURN_HOST: ${{ secrets.TURN_HOST }}
TURN_PORT: ${{ secrets.TURN_PORT }}
TURN_USER: ${{ secrets.TURN_USER }}
TURN_PASS: ${{ secrets.TURN_PASS }}
# 创建插件目录
mkdir -p ~/.docker/cli-plugins
# 下载 buildx 二进制文件
BUILDX_VERSION="v0.11.2"
curl -L "https://github.com/docker/buildx/releases/download/${BUILDX_VERSION}/buildx-${BUILDX_VERSION}.linux-amd64" -o ~/.docker/cli-plugins/docker-buildx
chmod +x ~/.docker/cli-plugins/docker-buildx
# 验证安装
docker buildx version
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
platforms: ${{ github.event.inputs.platforms }}
#- name: Install QEMU
# run: |
# 安装 QEMU 模拟器
#docker run --privileged --rm tonistiigi/binfmt --install all
# 验证 QEMU 安装
#docker buildx inspect --bootstrap
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: all
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKERHUB_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Aliyun Registry
if: github.event.inputs.enable_aliyun == 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.ALIYUN_REGISTRY }}
username: ${{ secrets.ALIYUN_USERNAME }}
password: ${{ secrets.ALIYUN_PASSWORD }}
- name: Set version tag
id: version
- name: Create and use new builder instance
run: |
if [[ "${{ github.event.inputs.build_type }}" == "dev" ]]; then
echo "tag=dev" >> $GITHUB_OUTPUT
elif [[ "${{ github.event.inputs.build_type }}" == "release" ]]; then
echo "tag=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
fi
# 创建新的 builder 实例
docker buildx create --name mybuilder --driver docker-container --bootstrap
# 使用新创建的 builder
docker buildx use mybuilder
# 验证支持的平台
docker buildx inspect --bootstrap
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
silentwind0/${{ env.MAIN_IMAGE }}
${{ github.event.inputs.enable_aliyun == 'true' && format('{0}/silentwind/{1}', env.ALIYUN_REGISTRY, env.MAIN_IMAGE) || '' }}
tags: |
type=raw,value=${{ steps.version.outputs.tag }}
type=raw,value=${{ steps.version.outputs.tag }}-{{date 'YYYYMMDD-HHmmss'}}
type=sha,prefix={{branch}}-
labels: |
org.opencontainers.image.title=One-KVM
org.opencontainers.image.description=DIY IP-KVM solution based on PiKVM
org.opencontainers.image.vendor=One-KVM Project
org.opencontainers.image.version=${{ steps.version.outputs.tag }}
- name: Build and push main image
uses: docker/build-push-action@v5
with:
context: .
file: ./build/Dockerfile
platforms: ${{ github.event.inputs.platforms }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=main
cache-to: type=gha,mode=max,scope=main
provenance: false
sbom: false
- name: Build summary
- name: Build multi-arch image
run: |
echo "## Build Summary" >> $GITHUB_STEP_SUMMARY
echo "- **Build Type**: ${{ github.event.inputs.build_type }}" >> $GITHUB_STEP_SUMMARY
echo "- **Version Tag**: ${{ steps.version.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
echo "- **Platforms**: ${{ github.event.inputs.platforms }}" >> $GITHUB_STEP_SUMMARY
echo "- **Aliyun Enabled**: ${{ github.event.inputs.enable_aliyun }}" >> $GITHUB_STEP_SUMMARY
echo "- **Tags**:" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" | sed 's/^/ - /' >> $GITHUB_STEP_SUMMARY
# 构建多架构镜像
docker buildx build \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--file ./build/Dockerfile \
--tag silentwind/kvmd:${{ github.event.inputs.version }} \
.
#- name: Login to DockerHub
# uses: docker/login-action@v2
# with:
# username: ${{ secrets.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}

1
.gitignore vendored
View File

@ -21,4 +21,3 @@
/venv/
.vscode/settings.j/son
kvmd_config/
CLAUDE.md

View File

@ -4,8 +4,7 @@ TESTENV_IMAGE ?= kvmd-testenv
TESTENV_HID ?= /dev/ttyS10
TESTENV_VIDEO ?= /dev/video0
TESTENV_GPIO ?= /dev/gpiochip0
TESTENV_RELAY ?=
#TESTENV_RELAY ?= $(if $(shell ls /dev/hidraw0 2>/dev/null || true),/dev/hidraw0,)
TESTENV_RELAY ?= $(if $(shell ls /dev/hidraw0 2>/dev/null || true),/dev/hidraw0,)
LIBGPIOD_VERSION ?= 1.6.3
@ -29,8 +28,6 @@ all:
@ echo " make testenv # Build test environment"
@ echo " make tox # Run tests and linters"
@ echo " make tox E=pytest # Run selected test environment"
@ echo " make tox-local # Run tests and linters locally (no Docker)"
@ echo " make tox-local E=flake8 # Run selected test locally"
@ echo " make gpio # Create gpio mockup"
@ echo " make run # Run kvmd"
@ echo " make run CMD=... # Run specified command inside kvmd environment"
@ -99,13 +96,9 @@ tox: testenv
"
tox-local:
@./check-code.sh $(if $(E),$(E),all)
$(TESTENV_GPIO):
test ! -e $(TESTENV_GPIO)
sudo modprobe gpio_mockup gpio_mockup_ranges=0,40
sudo modprobe gpio-mockup gpio_mockup_ranges=0,40
test -c $(TESTENV_GPIO)

View File

@ -39,7 +39,7 @@ for _variant in "${_variants[@]}"; do
pkgname+=(kvmd-platform-$_platform-$_board)
done
pkgbase=kvmd
pkgver=4.94
pkgver=4.49
pkgrel=1
pkgdesc="The main PiKVM daemon"
url="https://github.com/pikvm/kvmd"
@ -53,8 +53,6 @@ depends=(
python-aiofiles
python-async-lru
python-passlib
# python-bcrypt is needed for passlib
python-bcrypt
python-pyotp
python-qrcode
python-periphery
@ -68,7 +66,7 @@ depends=(
python-dbus
python-dbus-next
python-pygments
"python-pyghmi>=1.6.0-2"
python-pyghmi
python-pam
python-pillow
python-xlib
@ -82,7 +80,6 @@ depends=(
python-luma-oled
python-pyusb
python-pyudev
python-evdev
"libgpiod>=2.1"
freetype2
"v4l-utils>=1.22.1-1"
@ -97,7 +94,7 @@ depends=(
certbot
platform-io-access
raspberrypi-utils
"ustreamer>=6.37"
"ustreamer>=6.26"
# Systemd UDEV bug
"systemd>=248.3-2"
@ -123,7 +120,7 @@ depends=(
# fsck for /boot
dosfstools
# pgrep for kvmd-udev-restart-pass, sysctl for kvmd-otgnet
# pgrep for kvmd-udev-restart-pass
procps-ng
# Misc
@ -166,9 +163,7 @@ package_kvmd() {
install -Dm755 -t "$pkgdir/usr/bin" scripts/kvmd-{bootconfig,gencert,certbot}
install -dm755 "$pkgdir/usr/lib/systemd/system"
cp -rd configs/os/services -T "$pkgdir/usr/lib/systemd/system"
install -Dm644 -t "$pkgdir/usr/lib/systemd/system" configs/os/services/*
install -DTm644 configs/os/sysusers.conf "$pkgdir/usr/lib/sysusers.d/kvmd.conf"
install -DTm644 configs/os/tmpfiles.conf "$pkgdir/usr/lib/tmpfiles.d/kvmd.conf"
@ -203,7 +198,6 @@ package_kvmd() {
mkdir -p "$pkgdir/etc/kvmd/override.d"
mkdir -p "$pkgdir/var/lib/kvmd/"{msd,pst}
chmod 1775 "$pkgdir/var/lib/kvmd/pst"
}
@ -216,7 +210,7 @@ for _variant in "${_variants[@]}"; do
cd \"kvmd-\$pkgver\"
pkgdesc=\"PiKVM platform configs - $_platform for $_board\"
depends=(kvmd=$pkgver-$pkgrel \"linux-rpi-pikvm>=6.6.45-13\" \"raspberrypi-bootloader-pikvm>=20240818-1\")
depends=(kvmd=$pkgver-$pkgrel \"linux-rpi-pikvm>=6.6.45-10\" \"raspberrypi-bootloader-pikvm>=20240818-1\")
backup=(
etc/sysctl.d/99-kvmd.conf

View File

@ -1,307 +0,0 @@
<div align="center">
<img src="https://github.com/mofeng-git/Build-Armbian/assets/62919083/add9743a-0987-4e8a-b2cb-62121f236582" alt="One-KVM Logo" width="300">
<h1>One-KVM</h1>
<p><strong>DIY IP-KVM solution based on PiKVM</strong></p>
[![GitHub stars](https://img.shields.io/github/stars/mofeng-git/One-KVM?style=social)](https://github.com/mofeng-git/One-KVM/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/mofeng-git/One-KVM?style=social)](https://github.com/mofeng-git/One-KVM/network/members)
[![GitHub issues](https://img.shields.io/github/issues/mofeng-git/One-KVM)](https://github.com/mofeng-git/One-KVM/issues)
[![GitHub license](https://img.shields.io/github/license/mofeng-git/One-KVM)](https://github.com/mofeng-git/One-KVM/blob/master/LICENSE)
<p>
<a href="https://one-kvm.mofeng.run">📖 Documentation</a>
<a href="https://kvmd-demo.mofeng.run">🚀 Live Demo</a>
<a href="#quick-start">⚡ Quick Start</a>
<a href="#features">📊 Features</a>
</p>
</div>
[简体中文](README.md) | English
---
## 📋 Table of Contents
- [Overview](#project-overview)
- [Features](#features)
- [Quick Start](#quick-start)
- [Contributing](#contributing)
- [Others](#others)
## 📖 Project Overview
**One-KVM** is a DIY IP-KVM solution built upon the open-source [PiKVM](https://github.com/pikvm/pikvm) project. It uses cost-effective hardware to provide BIOS-level remote management for servers and workstations.
### Use Cases
- **Home lab management** Remotely manage servers and development devices
- **Server maintenance** Perform system maintenance without physical access
- **System recovery** Troubleshoot boot and BIOS/UEFI issues remotely
![One-KVM UI Screenshot](https://github.com/user-attachments/assets/a7848bca-e43c-434e-b812-27a45fad7910)
## 📊 Features
### Core Capabilities
| Feature | Description | Benefit |
|------|------|------|
| **Non-intrusive** | No software/driver required on the target machine | OS-agnostic; access BIOS/UEFI |
| **Cost-effective** | Leverages affordable hardware (TV boxes, dev boards) | Lower cost for KVM-over-IP |
| **Extendable** | Added utilities on top of PiKVM | Docker, recording, Chinese UI |
| **Deployment** | Supports Docker and prebuilt images | Preconfigured images for specific devices |
### Limitations
This project is maintained by an individual with limited resources and no commercial plan.
- No built-in free NAT punching/tunneling service
- No 24×7 technical support
- No guarantee on stability/compliance; use at your own risk
- User experience is optimized, but basic technical skills are still required
### Feature Comparison
> 💡 **Note:** The table below compares One-KVM with other PiKVM-based projects for reference only. If there are omissions or inaccuracies, please open an issue to help improve it.
| Feature | One-KVM | PiKVM | ArmKVM | BLIKVM |
|:--------:|:-------:|:-----:|:------:|:------:|
| Simplified Chinese WebUI | ✅ | ❌ | ✅ | ✅ |
| Remote video stream | MJPEG/H.264 | MJPEG/H.264 | MJPEG/H.264 | MJPEG/H.264 |
| H.264 encoding | CPU | GPU | Unknown | GPU |
| Remote audio | ✅ | ✅ | ✅ | ✅ |
| Remote mouse/keyboard | OTG/CH9329 | OTG/CH9329/Pico/Bluetooth | OTG | OTG |
| VNC control | ✅ | ✅ | ✅ | ✅ |
| ATX power control | GPIO/USB relay | GPIO | GPIO | GPIO |
| Virtual drive mounting | ✅ | ✅ | ✅ | ✅ |
| Web terminal | ✅ | ✅ | ✅ | ✅ |
| Docker deployment | ✅ | ❌ | ❌ | ❌ |
| Commercial offering | ❌ | ✅ | ✅ | ✅ |
## ⚡ Quick Start
### Method 1: Docker (Recommended)
The Docker variant supports OTG or CH9329 as virtual HID and runs on Linux for amd64/arm64/armv7.
#### One-liner Script
```bash
curl -sSL https://one-kvm.mofeng.run/quick_start.sh -o quick_start.sh && bash quick_start.sh
```
#### Manual Deployment
It is recommended to use the `--net=host` network mode for better WOL functionality and WebRTC communication support.
Docker host network mode:
Port 8080: HTTP Web service
Port 4430: HTTPS Web service
Port 5900: VNC service
Port 623: IPMI service
Ports 20000-40000: WebRTC communication port range for low-latency video
Port 9 (UDP): Wake-on-LAN (WOL)
Docker host mode:
**Using OTG as virtual HID:**
```bash
sudo docker run --name kvmd -itd --privileged=true \
-v /lib/modules:/lib/modules:ro -v /dev:/dev \
-v /sys/kernel/config:/sys/kernel/config -e OTG=1 \
--net=host \
silentwind0/kvmd
```
**Using CH9329 as virtual HID:**
```bash
sudo docker run --name kvmd -itd \
--device /dev/video0:/dev/video0 \
--device /dev/ttyUSB0:/dev/ttyUSB0 \
--net=host \
silentwind0/kvmd
```
Docker bridge mode:
**Using OTG as virtual HID:**
```bash
sudo docker run --name kvmd -itd --privileged=true \
-v /lib/modules:/lib/modules:ro -v /dev:/dev \
-v /sys/kernel/config:/sys/kernel/config -e OTG=1 \
-p 8080:8080 -p 4430:4430 -p 5900:5900 -p 623:623 \
silentwind0/kvmd
```
**Using CH9329 as virtual HID:**
```bash
sudo docker run --name kvmd -itd \
--device /dev/video0:/dev/video0 \
--device /dev/ttyUSB0:/dev/ttyUSB0 \
-p 8080:8080 -p 4430:4430 -p 5900:5900 -p 623:623 \
silentwind0/kvmd
```
### Method 2: Flash Prebuilt One-KVM Images
Preconfigured images are provided for specific hardware platforms to simplify deployment and enable out-of-the-box experience.
#### Download
**GitHub:**
- **GitHub Releases:** [https://github.com/mofeng-git/One-KVM/releases](https://github.com/mofeng-git/One-KVM/releases)
**Other mirrors:**
- **No-login mirror:** [https://pan.huang1111.cn/s/mxkx3T1](https://pan.huang1111.cn/s/mxkx3T1)
- **Baidu Netdisk:** [https://pan.baidu.com/s/166-2Y8PBF4SbHXFkGmFJYg?pwd=o9aj](https://pan.baidu.com/s/166-2Y8PBF4SbHXFkGmFJYg?pwd=o9aj) (code: o9aj)
#### Supported Hardware Platforms
| Firmware | Codename | Hardware | Latest | Status |
|:--------:|:--------:|:--------:|:------:|:----:|
| OneCloud | Onecloud | USB capture card, OTG | 241018 | ✅ |
| CumeBox 2 | Cumebox2 | USB capture card, OTG | 241004 | ✅ |
| Vmare | Vmare-uefi | USB capture card, CH9329 | 241004 | ✅ |
| VirtualBox | Virtualbox-uefi | USB capture card, CH9329 | 241004 | ✅ |
| s905l3a Generic | E900v22c | USB capture card, OTG | 241004 | ✅ |
| Chainedbox | Chainedbox | USB capture card, OTG | 241004 | ✅ |
| Loongson 2K0300 | 2k0300 | USB capture card, CH9329 | 241025 | ✅ |
## 🤝 Contributing
Contributions of all kinds are welcome!
### How to Contribute
1. **Fork this repo**
2. **Create a feature branch:** `git checkout -b feature/AmazingFeature`
3. **Commit your changes:** `git commit -m 'Add some AmazingFeature'`
4. **Push to the branch:** `git push origin feature/AmazingFeature`
5. **Open a Pull Request**
### Report Issues
If you find bugs or have suggestions:
1. Open an issue via [GitHub Issues](https://github.com/mofeng-git/One-KVM/issues)
2. Provide detailed error logs and reproduction steps
3. Include your hardware and system information
### Sponsorship
This project builds upon many great open-source projects and requires considerable time for testing and maintenance. If you find it helpful, consider supporting via **[Afdian](https://afdian.com/a/silentwind)**.
#### Thanks
<details>
<summary><strong>Click to view the thank-you list</strong></summary>
- 浩龙的电子嵌入式之路
- Tsuki
- H_xiaoming
- 0蓝蓝0
- fairybl
- Will
- 浩龙的电子嵌入式之路
- 自.知
- 观棋不语٩ ི۶
- 爱发电用户_a57a4
- 爱发电用户_2c769
- 霜序
- 远方(闲鱼用户名:小远技术店铺)
- 爱发电用户_399fc
- 斐斐の
- 爱发电用户_09451
- 超高校级的錆鱼
- 爱发电用户_08cff
- guoke
- mgt
- 姜沢掵
- ui_beam
- 爱发电用户_c0dd7
- 爱发电用户_dnjK
- 忍者胖猪
- 永遠の願い
- 爱发电用户_GBrF
- 爱发电用户_fd65c
- 爱发电用户_vhNa
- 爱发电用户_Xu6S
- moss
- woshididi
- 爱发电用户_a0fd1
- 爱发电用户_f6bH
- 码农
- 爱发电用户_6639f
- jeron
- 爱发电用户_CN7y
- 爱发电用户_Up6w
- 爱发电用户_e3202
- ......
</details>
#### Sponsors
This project is supported by the following sponsors:
**CDN & Security:**
- **[Tencent EdgeOne](https://edgeone.ai/zh?from=github)** CDN acceleration and security protection
![Tencent EdgeOne](https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png)
**File Storage:**
- **[Huang1111公益计划](https://pan.huang1111.cn/s/mxkx3T1)** No-login download service
## 📚 Others
### Open-source Projects Used
This project is built upon the following excellent open-source projects:
- [PiKVM](https://github.com/pikvm/pikvm) Open-source DIY IP-KVM solution

344
README.md
View File

@ -1,136 +1,71 @@
<div align="center">
<img src="https://github.com/mofeng-git/Build-Armbian/assets/62919083/add9743a-0987-4e8a-b2cb-62121f236582" alt="One-KVM Logo" width="300">
<h1>One-KVM</h1>
<p><strong>基于 PiKVM 的 DIY IP-KVM 解决方案</strong></p>
<p><a href="README.md">简体中文</a> | <a href="README.en.md">English</a></p>
[![GitHub stars](https://img.shields.io/github/stars/mofeng-git/One-KVM?style=social)](https://github.com/mofeng-git/One-KVM/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/mofeng-git/One-KVM?style=social)](https://github.com/mofeng-git/One-KVM/network/members)
[![GitHub issues](https://img.shields.io/github/issues/mofeng-git/One-KVM)](https://github.com/mofeng-git/One-KVM/issues)
[![GitHub license](https://img.shields.io/github/license/mofeng-git/One-KVM)](https://github.com/mofeng-git/One-KVM/blob/master/LICENSE)
<p>
<a href="https://docs.one-kvm.cn">📖 详细文档</a>
<a href="https://demo.one-kvm.cn/">🚀 在线演示</a>
<a href="#快速开始">⚡ 快速开始</a>
<a href="#功能介绍">📊 功能介绍</a>
</p>
</div>
<h3 align=center><img src="https://github.com/mofeng-git/Build-Armbian/assets/62919083/add9743a-0987-4e8a-b2cb-62121f236582" alt="logo" width="300"><br></h3>
<h3 align=center><a href="https://github.com/mofeng-git/One-KVM/blob/master/README.md">简体中文</a> </h3>
<p align=right>&nbsp;</p>
---
### 项目介绍
## 📋 目录
**One-KVM** 是一款基于经济实惠的硬件和强大的开源 [PiKVM](https://github.com/pikvm/pikvm) 软件进行二次开发的 DIY IP-KVM 解决方案。它旨在为您提供**BIOS 级别**的远程服务器或工作站管理能力,如同您亲身坐在屏幕前操作一般。
- [项目概述](#项目概述)
- [功能介绍](#功能介绍)
- [快速开始](#快速开始)
- [贡献指南](#贡献指南)
- [其他](#其他)
**核心优势:**
## 📖 项目概述
* **完全无侵入:** 无需在目标机器上安装任何软件或驱动,不依赖操作系统,可远程访问 BIOS/UEFI 设置、进行系统安装或故障排查。
* **低成本实现:** 利用常见的廉价硬件(如旧安卓盒子、开发板等)即可搭建,大幅降低 KVM over IP 的门槛。
* **功能丰富:** 在 PiKVM 基础上,增加了 Docker 部署、视频录制、简体中文界面优化等多项实用功能 (详见下方功能对比)。
* **部署灵活:** 支持 Docker 快速部署,并为特定硬件平台(如玩客云、我家云等)提供开箱即用的整合包。
**One-KVM** 是基于开源 [PiKVM](https://github.com/pikvm/pikvm) 项目进行二次开发的 DIY IP-KVM 解决方案。该方案利用成本较低的硬件设备,实现 BIOS 级别的远程服务器或工作站管理功能。
无论您是需要管理家庭实验室、办公室服务器还是希望为特定嵌入式设备添加远程管理能力One-KVM 都提供了一个高性价比且功能强大的选择。
> 本项目目前并无适配树莓派的计划。这是因为树莓派平台本质上属于 PiKVM 官方硬件生态和盈利的一部分。我们非常尊重和感谢上游项目 PiKVM ,因此 One-KVM 的设备适配主要聚焦于补充性场景,尽量避免与 PiKVM 官方产品产生重叠,以支持其可持续发展。
**快速访问:**
### 应用场景
- **家庭实验室主机管理** - 远程管理服务器和开发设备
- **服务器远程维护** - 无需物理接触即可进行系统维护
- **系统故障处理** - 远程解决系统启动和 BIOS 相关问题
* **详细使用文档:** [https://one-kvm.mofeng.run](https://one-kvm.mofeng.run)
* **在线演示:** [https://kvmd-demo.mofeng.run](https://kvmd-demo.mofeng.run)
![One-KVM 界面截图](https://github.com/user-attachments/assets/a7848bca-e43c-434e-b812-27a45fad7910)
## 📊 功能介绍
### 软件功能
### 核心特性
表格仅为 One-KVM 与其他基于 PiKVM 的项目的功能对比,无不良导向,如有错漏请联系更正。
| 特性 | 描述 | 优势 |
|------|------|------|
| **无侵入性** | 无需在目标机器上安装软件或驱动 | 不依赖操作系统,可访问 BIOS/UEFI 设置 |
| **成本效益** | 利用常见硬件设备(如电视盒子、开发板等) | 降低 KVM over IP 的实现成本 |
| **功能扩展** | 在 PiKVM 基础上增加实用功能 | Docker 部署、视频录制、中文界面 |
| **部署方式** | 支持 Docker 部署和硬件整合包 | 为特定硬件平台提供预配置方案 |
| 功能 | One-KVM | PiKVM | ArmKVM | BLIKVM |
| :-------------------: | :-------------: | :-----------------------: | :---------: | :---------: |
| 系统开源 | √ | √ | √ | √ |
| 简体中文 WebUI | √ | x | √ | √ |
| 远程视频流 | MJPEG/H.264 | MJPEG/H.264 | MJPEG/H.264 | MJPEG/H.264 |
| H.264 视频编码 | CPU | GPU | 未知 | GPU |
| 远程音频流 | √ | √ | √ | √ |
| 远程鼠键控制 | OTG/CH9329 | OTG/CH9329/Pico/Bluetooth | OTG | OTG |
| VNC 控制 | √ | √ | √ | √ |
| ATX 电源控制 | GPIO/USB 继电器 | GPIO | GPIO | GPIO |
| 虚拟存储驱动器挂载 | √ | √ | √ | √ |
| 2.2G 以上 CD-ROM 挂载 | x | x | √ | √ |
| WOL 远程唤醒 | √ | √ | √ | √ |
| 网页剪切板 | √ | √ | √ | √ |
| OCR 文字识别 | √ | √ | √ | √ |
| 网页终端 | √ | √ | √ | √ |
| 网络串口终端 | x | x | √ | √ |
| HDMI 切换器支持 | √ | √ | √ | √ |
| 视频录制 | √ | x | x | x |
| Docker 部署 | √ | x | x | x |
| 官方商业化成品 | x | √ | √ | √ |
| 技术支持 | √ | √ | √ | √ |
### 项目限制
### 快速开始
本项目为个人维护的开源项目,资源有限,无商业运营计划
更多详细内容可以查阅 [One-KVM文档](https://one-kvm.mofeng.run/)。
- 不提供内置免费内网穿透服务,相关问题请自行解决
- 不提供24×7小时技术支持服务
- 不承诺系统稳定性和合规性,使用风险需自行承担
- 尽力优化用户体验,但仍需要一定的技术基础
**方式一Docker 镜像部署(推荐)**
### 功能对比
Docker 版本可以使用 OTG 或 CH9329 作为虚拟 HID ,支持 amd64、arm64、armv7 架构的 Linux 系统安装。
> 💡 **说明:** 以下表格展示了 One-KVM 与其他基于 PiKVM 项目的功能对比,仅供参考。如有遗漏或错误,欢迎联系更正。
| 功能特性 | One-KVM | PiKVM | ArmKVM | BLIKVM |
|:--------:|:-------:|:-----:|:------:|:------:|
| 简体中文 WebUI | ✅ | ❌ | ✅ | ✅ |
| 远程视频流 | MJPEG/H.264 | MJPEG/H.264 | MJPEG/H.264 | MJPEG/H.264 |
| H.264 视频编码 | CPU/GPU | GPU | 未知 | GPU |
| 远程音频流 | ✅ | ✅ | ✅ | ✅ |
| 远程鼠键控制 | OTG/CH9329 | OTG/CH9329/Pico/Bluetooth | OTG | OTG |
| VNC 控制 | ✅ | ✅ | ✅ | ✅ |
| ATX 电源控制 | GPIO/USB 继电器 | GPIO | GPIO | GPIO |
| 虚拟存储驱动器挂载 | ✅ | ✅ | ✅ | ✅ |
| 网页终端 | ✅ | ✅ | ✅ | ✅ |
| Docker 部署 | ✅ | ❌ | ❌ | ❌ |
| 商业化运营 | ❌ | ✅ | ✅ | ✅ |
## ⚡ 快速开始
### 方式一Docker 镜像部署(推荐)
Docker 版本支持 OTG 或 CH9329 作为虚拟 HID兼容 amd64、arm64、armv7 架构的 Linux 系统。
#### 一键脚本部署
**脚本部署**
```bash
curl -sSL https://docs.one-kvm.cn/quick_start.sh -o quick_start.sh && bash quick_start.sh
curl -sSL https://one-kvm.mofeng.run/quick_start.sh -o quick_start.sh && bash quick_start.sh
```
#### 手动部署
推荐使用 --net=host 网络模式以获得更好的 wol 功能和 webrtc 通信支持。
docker host 网络模式:
端口 8080HTTP Web 服务
端口 4430HTTPS Web 服务
端口 5900VNC 服务
端口 623IPMI 服务
端口 20000-40000WebRTC 通信端口范围,用于低延迟视频传输
端口 9UDPWake-on-LANWOL唤醒功能
docker host 模式:
**使用 OTG 作为虚拟 HID**
```bash
sudo docker run --name kvmd -itd --privileged=true \
-v /lib/modules:/lib/modules:ro -v /dev:/dev \
-v /sys/kernel/config:/sys/kernel/config -e OTG=1 \
--net=host \
silentwind0/kvmd
```
**使用 CH9329 作为虚拟 HID**
```bash
sudo docker run --name kvmd -itd \
--device /dev/video0:/dev/video0 \
--device /dev/ttyUSB0:/dev/ttyUSB0 \
--net=host \
silentwind0/kvmd
```
docker bridge 模式:
**使用 OTG 作为虚拟 HID**
**手动部署**
如果使用 OTG 作为虚拟 HID可以使用如下部署命令
```bash
sudo docker run --name kvmd -itd --privileged=true \
-v /lib/modules:/lib/modules:ro -v /dev:/dev \
@ -139,190 +74,125 @@ sudo docker run --name kvmd -itd --privileged=true \
silentwind0/kvmd
```
**使用 CH9329 作为虚拟 HID**
如果使用 CH9329 作为虚拟 HID可以使用如下部署命令
```bash
sudo docker run --name kvmd -itd \
--device /dev/video0:/dev/video0 \
--device /dev/ttyUSB0:/dev/ttyUSB0 \
--device /dev/snd:/dev/snd \
-p 8080:8080 -p 4430:4430 -p 5900:5900 -p 623:623 \
silentwind0/kvmd
```
### 方式二:直刷 One-KVM 整合包
**方式二:直刷 One-KVM 整合包**
针对特定硬件平台,提供了预配置的 One-KVM 打包镜像,简化部署流程,实现开箱即用
对于部分平台硬件,本项目制作了深度适配的 One-KVM 打包镜像,开箱即用,刷好后启动设备就可以开始使用 One-KVM。免费 One-KVM 整合包也可以在本项目 Releases 页可以找到
#### 固件下载
| 整合包适配概况 | | | |
| :-------------: | :-------------: | :-------------: | :-------------: |
| **固件型号** | **固件代号** | **硬件情况** | **最新版本** |
| 玩客云 | Onecloud | USB 采集卡、OTG | 241018 |
| 私家云二代 | Cumebox2 | USB 采集卡、OTG | 241004 |
| Vmare | Vmare-uefi | USB 采集卡、CH9329 | 241004 |
| Virtualbox | Virtualbox-uefi | USB 采集卡、CH9329 | 241004 |
| s905l3a 通用包 | E900v22c | USB 采集卡、OTG | 241004 |
| 我家云 | Chainedbox | USB 采集卡、OTG | 241004 |
| 龙芯久久派 | 2k0300 | USB 采集卡、CH9329 | 241025 |
**GitHub 下载:**
- **GitHub Releases** [https://github.com/mofeng-git/One-KVM/releases](https://github.com/mofeng-git/One-KVM/releases)
### 文件下载
**其他下载方式:**
- **免登录高速下载:** [http://sd1.files.one-kvm.cn/](http://sd1.files.one-kvm.cn/)(由群友赞助,支持直链,接入 EdgeOne CDN建议使用多线程下载工具下载获取最高速度
- **免登录下载:** [https://pan.huang1111.cn/s/mxkx3T1](https://pan.huang1111.cn/s/mxkx3T1) (由 Huang1111公益计划 提供)
- **百度网盘:** [https://pan.baidu.com/s/166-2Y8PBF4SbHXFkGmFJYg?pwd=o9aj](https://pan.baidu.com/s/166-2Y8PBF4SbHXFkGmFJYg?pwd=o9aj) 提取码o9aj
Githubhttps://github.com/mofeng-git/One-KVM/releases
#### 支持的硬件平台
免登录高速下载地址https://pan.huang1111.cn/s/mxkx3T1 (由 Huang1111公益计划 赞助)
| 固件型号 | 固件代号 | 硬件配置 | 最新版本 | 状态 |
|:--------:|:--------:|:--------:|:--------:|:----:|
| 玩客云 | Onecloud | USB 采集卡、OTG | 241018 | ✅ |
| 私家云二代 | Cumebox2 | USB 采集卡、OTG | 241004 | ✅ |
| Vmare | Vmare-uefi | USB 采集卡、CH9329 | 241004 | ✅ |
| Virtualbox | Virtualbox-uefi | USB 采集卡、CH9329 | 241004 | ✅ |
| s905l3a 通用包 | E900v22c | USB 采集卡、OTG | 241004 | ✅ |
| 我家云 | Chainedbox | USB 采集卡、OTG | 241004 | ✅ |
| 龙芯久久派 | 2k0300 | USB 采集卡、CH9329 | 241025 | ❌ |
百度网盘需登录https://pan.baidu.com/s/166-2Y8PBF4SbHXFkGmFJYg?pwd=o9aj
### 报告问题
### 赞助方式
如果您发现了问题,请:
1. 使用 [GitHub Issues](https://github.com/mofeng-git/One-KVM/issues) 报告
2. 提供详细的错误信息和复现步骤
3. 包含您的硬件配置和系统信息
这个项目基于众多开源项目二次开发,作者为此花费了大量的时间和精力进行测试和维护。若此项目对您有用,您可以考虑通过 **[为爱发电](https://afdian.com/a/silentwind)** 赞助一笔小钱支持作者。作者将能有更多的金钱来测试和维护 One-KVM 的各种配置,并在项目上投入更多的时间和精力。
### 赞助支持
本项目基于多个优秀开源项目进行二次开发,作者投入了大量时间进行测试和维护。如果您觉得这个项目有价值,欢迎通过 **[为爱发电](https://afdian.com/a/silentwind)** 支持项目发展。
#### 感谢名单
**感谢名单**
<details>
<summary><strong>点击查看感谢名单</strong></summary>
- 浩龙的电子嵌入式之路
浩龙的电子嵌入式之路(赞助)
- Tsuki
Tsuki(赞助)
- H_xiaoming
H_xiaoming
- 0蓝蓝0
0蓝蓝0
- fairybl
fairybl
- Will
Will
- 浩龙的电子嵌入式之路
浩龙的电子嵌入式之路
- 自.知
自.知
- 观棋不语٩ ི۶
观棋不语٩ ི۶
- 爱发电用户_a57a4
爱发电用户_a57a4
- 爱发电用户_2c769
爱发电用户_2c769
- 霜序
霜序
- 远方(闲鱼用户名:小远技术店铺)
[远方](https://runyf.cn/)(闲鱼用户名:小远技术店铺)
- 爱发电用户_399fc
爱发电用户_399fc
- 斐斐の
[斐斐の](https://www.mmuaa.com/)
- 爱发电用户_09451
爱发电用户_09451
- 超高校级的錆鱼
超高校级的錆鱼
- 爱发电用户_08cff
爱发电用户_08cff
- guoke
guoke
- mgt
mgt
- 姜沢掵
姜沢掵
- ui_beam
ui_beam
- 爱发电用户_c0dd7
爱发电用户_c0dd7
- 爱发电用户_dnjK
爱发电用户_dnjK
- 忍者胖猪
忍者胖猪
- 永遠の願い
永遠の願い
- 爱发电用户_GBrF
爱发电用户_GBrF
- 爱发电用户_fd65c
爱发电用户_fd65c
- 爱发电用户_vhNa
爱发电用户_vhNa
- 爱发电用户_Xu6S
爱发电用户_Xu6S
- moss
moss
- woshididi
woshididi
- 爱发电用户_a0fd1
爱发电用户_a0fd1
- 爱发电用户_f6bH
- 码农
- 爱发电用户_6639f
- jeron
- 爱发电用户_CN7y
- 爱发电用户_Up6w
- 爱发电用户_e3202
- 一语念白
- 云边
- 爱发电用户_5a711
- 爱发电用户_9a706
- T0m9ir1SUKI
- 爱发电用户_56d52
- 爱发电用户_3N6F
- DUSK
- 飘零
- .
- 饭太稀
- 葱
- ......
爱发电用户_f6bH
......
</details>
#### 赞助商
本项目使用了下列开源项目:
1. [pikvm/pikvm: Open and inexpensive DIY IP-KVM based on Raspberry Pi (github.com)](https://github.com/pikvm/pikvm)
本项目得到以下赞助商的支持:
### 项目状态
**CDN 加速及安全防护:**
- **[Tencent EdgeOne](https://edgeone.ai/zh?from=github)** - 提供 CDN 加速及安全防护服务
[![Star History Chart](https://api.star-history.com/svg?repos=mofeng-git/One-KVM&type=Date)](https://star-history.com/#mofeng-git/One-KVM&Date)
![Tencent EdgeOne](https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png)
![Github](https://repobeats.axiom.co/api/embed/7cfaab47e31073107771a7179078aa2a6c3f1108.svg "Repobeats analytics image")
**文件存储服务:**
- **[Huang1111公益计划](https://pan.huang1111.cn/s/mxkx3T1)** - 提供免登录下载服务
**云服务商**
- **[林枫云](https://www.dkdun.cn)** - 赞助了本项目宁波大带宽服务器
![林枫云](./img/36076FEFF0898A80EBD5756D28F4076C.png)
林枫云主营国内外地域的精品线路业务服务器、高主频游戏服务器和大带宽服务器。
## 📚 其他
### 使用的开源项目
本项目基于以下优秀开源项目进行二次开发:
- [PiKVM](https://github.com/pikvm/pikvm) - 开源的 DIY IP-KVM 解决方案

View File

@ -4,17 +4,13 @@ FROM python:3.11.11-slim-bookworm
LABEL maintainer="mofeng654321@hotmail.com"
ARG TARGETARCH
COPY --from=builder /tmp/lib/* /tmp/lib/
COPY --from=builder /tmp/ustreamer/ustreamer /tmp/ustreamer/ustreamer-dump /usr/bin/janus /usr/bin/
COPY --from=builder /tmp/wheel/*.whl /tmp/wheel/
COPY --from=builder /tmp/ustreamer/libjanus_ustreamer.so /usr/lib/ustreamer/janus/
COPY --from=builder /usr/lib/janus/transports/* /usr/lib/janus/transports/
COPY --from=builder /tmp/arm64-libs.tar.gz* /tmp/
RUN if [ ${TARGETARCH} = arm64 ] && [ -f /tmp/arm64-libs.tar.gz ]; then \
cd / && tar -xzf /tmp/arm64-libs.tar.gz && rm -f /tmp/arm64-libs.tar.gz; \
fi
ARG TARGETARCH
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
@ -45,39 +41,7 @@ RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/' /etc/apt/sources.lis
libwebsockets17 \
libnss3 \
libasound2 \
libdrm2 \
libx264-164 \
libyuv0 \
nano \
unzip \
&& case ${TARGETARCH} in \
amd64) \
apt-get install -y --no-install-recommends \
libavcodec59 libavformat59 libavutil57 \
libswscale6 libavfilter8 libavdevice59 \
ffmpeg vainfo \
libva2 libva-drm2 libva-x11-2 \
mesa-va-drivers mesa-vdpau-drivers \
intel-media-va-driver i965-va-driver \
;; \
arm) \
apt-get install -y --no-install-recommends \
libavcodec59 libavformat59 libavutil57 \
libswscale6 libavfilter8 libavdevice59 \
v4l-utils libv4l-0 \
;; \
arm64) \
apt-get install -y --no-install-recommends \
v4l-utils libv4l-0 libavutil57 \
libstdc++6 libavcodec59 libavformat59 \
libswscale6 libavfilter8 libavdevice59 \
libva2 libva-drm2 libva-x11-2 \
libvdpau1 ocl-icd-libopencl1 \
;; \
*) \
echo "Unsupported architecture: ${TARGETARCH}" && exit 1 \
;; \
esac \
&& cp /tmp/lib/* /lib/*-linux-*/ \
&& pip install --no-cache-dir --root-user-action=ignore --disable-pip-version-check /tmp/wheel/*.whl \
&& pip install --no-cache-dir --root-user-action=ignore --disable-pip-version-check pyfatfs \
@ -87,18 +51,6 @@ RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/' /etc/apt/sources.lis
fi \
&& curl https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.$ARCH -L -o /usr/local/bin/ttyd \
&& chmod +x /usr/local/bin/ttyd \
&& mkdir -p /tmp/gostc && cd /tmp/gostc \
&& case ${TARGETARCH} in \
amd64) GOSTC_ARCH=amd64_v1 ;; \
arm) GOSTC_ARCH=arm_7 ;; \
arm64) GOSTC_ARCH=arm64_v8.0 ;; \
*) echo "Unsupported architecture for gostc: ${TARGETARCH}" && exit 1 ;; \
esac \
&& curl -L https://github.com/mofeng-git/gostc-open/releases/download/v2.0.8-beta.2/gostc_linux_${GOSTC_ARCH}.tar.gz -o gostc.tar.gz \
&& tar -xzf gostc.tar.gz \
&& mv gostc /usr/bin/ \
&& chmod +x /usr/bin/gostc \
&& cd / && rm -rf /tmp/gostc \
&& adduser kvmd --gecos "" --disabled-password \
&& ln -sf /usr/share/tesseract-ocr/*/tessdata /usr/share/tessdata \
&& mkdir -p /etc/kvmd_backup/override.d \
@ -110,12 +62,9 @@ RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/' /etc/apt/sources.lis
/run/kvmd \
/tmp/kvmd-nginx \
&& touch /run/kvmd/ustreamer.sock \
&& groupadd kvmd-selfauth \
&& usermod -a -G kvmd-selfauth root \
&& apt clean \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /tmp/lib /tmp/wheel \
&& ustreamer -v
&& rm -rf /tmp/lib /tmp/wheel
COPY testenv/fakes/vcgencmd scripts/kvmd* /usr/bin/
COPY extras/ /usr/share/kvmd/extras/

View File

@ -47,35 +47,6 @@ RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/' /etc/apt/sources.lis
libspeex-dev \
libspeexdsp-dev \
libusb-1.0-0-dev \
libldap2-dev \
libsasl2-dev \
libdrm-dev \
mesa-va-drivers \
mesa-vdpau-drivers \
v4l-utils \
libv4l-dev \
ffmpeg \
libavcodec-dev \
libavformat-dev \
libavutil-dev \
libswscale-dev \
libavfilter-dev \
libavdevice-dev \
&& if [ ${TARGETARCH} != arm ] && [ ${TARGETARCH} != arm64 ]; then \
apt-get install -y --no-install-recommends \
vainfo \
libva-dev \
libva-drm2 \
libva-x11-2 \
intel-media-va-driver \
i965-va-driver; \
fi \
&& if [ ${TARGETARCH} = arm64 ]; then \
apt-get install -y --no-install-recommends \
ninja-build \
zlib1g-dev \
libswresample-dev; \
fi \
&& apt clean \
&& rm -rf /var/lib/apt/lists/*
@ -99,104 +70,50 @@ RUN --security=insecure pip config set global.index-url https://pypi.tuna.tsingh
more-itertools multidict netifaces packaging passlib pillow ply psutil \
pycparser pyelftools pyghmi pygments pyparsing pyotp qrcode requests \
semantic-version setproctitle six spidev tabulate urllib3 wrapt xlib \
yarl pyserial pyyaml zstandard supervisor pyfatfs pyserial python-periphery \
python-ldap python-pam pyrad pyudev pyusb luma.oled pyserial-asyncio \
&& rm -rf /root/.cache/pip/* /tmp/pip-* \
&& if [ ${TARGETARCH} = arm ]; then \
umount /root/.cargo 2>/dev/null || true \
&& rm -rf /root/.cargo /root/rustup-init.sh; \
fi
yarl pyserial pyyaml zstandard supervisor pyfatfs
# 编译 python evdev库
RUN git clone --depth=1 https://github.com/gvalkov/python-evdev.git /tmp/python-evdev \
&& cd /tmp/python-evdev \
&& python3 setup.py bdist_wheel --dist-dir /tmp/wheel/ \
&& rm -rf /tmp/python-evdev
# 编译安装 libnice、libsrtp、libwebsockets 和 janus-gateway显式 Release 与按架构优化)
RUN export COMMON_CFLAGS='-O2 -pipe -fPIC -fstack-protector-strong -D_FORTIFY_SOURCE=2' \
&& if [ "${TARGETARCH}" = arm64 ]; then export CFLAGS="$COMMON_CFLAGS -march=armv8-a"; \
elif [ "${TARGETARCH}" = arm ]; then export CFLAGS="$COMMON_CFLAGS -march=armv7-a -mfpu=neon-vfpv4 -mfloat-abi=hard -mtune=cortex-a7"; \
else export CFLAGS="$COMMON_CFLAGS -march=x86-64 -mtune=generic"; fi \
&& export CXXFLAGS="$CFLAGS" LDFLAGS="-Wl,-O1 -Wl,--as-needed" \
&& git clone --depth=1 https://gitlab.freedesktop.org/libnice/libnice /tmp/libnice \
# 编译安装 libnice、libsrtp、libwebsockets 和 janus-gateway
RUN git clone --depth=1 https://gitlab.freedesktop.org/libnice/libnice /tmp/libnice \
&& cd /tmp/libnice \
&& meson setup build --prefix=/usr --buildtype=release -Doptimization=2 -Dc_args="$CFLAGS" -Dcpp_args="$CXXFLAGS" \
&& ninja -C build && ninja -C build install \
&& meson --prefix=/usr build && ninja -C build && ninja -C build install \
&& rm -rf /tmp/libnice \
&& curl https://github.com/cisco/libsrtp/archive/v2.2.0.tar.gz -L -o /tmp/libsrtp-2.2.0.tar.gz \
&& cd /tmp \
&& tar xf libsrtp-2.2.0.tar.gz \
&& cd libsrtp-2.2.0 \
&& CFLAGS="$CFLAGS" CXXFLAGS="$CXXFLAGS" ./configure --prefix=/usr --enable-openssl \
&& make shared_library -j$(nproc) && make install \
&& ./configure --prefix=/usr --enable-openssl \
&& make shared_library -j && make install \
&& cd /tmp \
&& rm -rf /tmp/libsrtp* \
&& git clone --depth=1 https://github.com/warmcat/libwebsockets /tmp/libwebsockets \
&& git clone --depth=1 https://libwebsockets.org/repo/libwebsockets /tmp/libwebsockets \
&& cd /tmp/libwebsockets \
&& mkdir build && cd build \
&& cmake -DLWS_MAX_SMP=1 -DLWS_WITHOUT_EXTENSIONS=0 -DCMAKE_INSTALL_PREFIX:PATH=/usr \
-DCMAKE_BUILD_TYPE=Release -DCMAKE_C_FLAGS_RELEASE="$CFLAGS -fPIC" -DCMAKE_CXX_FLAGS_RELEASE="$CXXFLAGS -fPIC" .. \
&& make -j$(nproc) && make install \
&& cmake -DLWS_MAX_SMP=1 -DLWS_WITHOUT_EXTENSIONS=0 -DCMAKE_INSTALL_PREFIX:PATH=/usr -DCMAKE_C_FLAGS="-fpic" .. \
&& make -j && make install \
&& cd /tmp \
&& rm -rf /tmp/libwebsockets \
&& git clone --depth=1 https://github.com/meetecho/janus-gateway.git /tmp/janus-gateway \
&& cd /tmp/janus-gateway \
&& sh autogen.sh \
&& CFLAGS="$CFLAGS" CXXFLAGS="$CXXFLAGS" ./configure --enable-static --enable-websockets --enable-plugin-audiobridge \
&& ./configure --enable-static --enable-websockets --enable-plugin-audiobridge \
--disable-data-channels --disable-rabbitmq --disable-mqtt --disable-all-plugins \
--disable-all-loggers --prefix=/usr \
&& make -j$(nproc) && make install \
&& make -j && make install \
&& cd /tmp \
&& rm -rf /tmp/janus-gateway
# 编译 Rockchip MPP、RGA仅 arm64显式 Release 与按架构优化)
RUN if [ ${TARGETARCH} = arm64 ]; then \
export COMMON_CFLAGS='-O2 -pipe -fPIC -fstack-protector-strong -D_FORTIFY_SOURCE=2' \
&& export CFLAGS="$COMMON_CFLAGS -march=armv8-a" \
&& export CXXFLAGS="$CFLAGS" \
&& git clone --depth=1 https://github.com/rockchip-linux/mpp.git /tmp/rkmpp \
&& mkdir -p /tmp/rkmpp/rkmpp_build && cd /tmp/rkmpp/rkmpp_build \
&& cmake -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON -DBUILD_TEST=OFF \
-DCMAKE_C_FLAGS_RELEASE="$CFLAGS" -DCMAKE_CXX_FLAGS_RELEASE="$CXXFLAGS" .. \
&& make -j$(nproc) \
&& make install \
&& git clone -b jellyfin-rga --depth=1 https://github.com/nyanmisaka/rk-mirrors.git /tmp/rkrga \
&& cd /tmp/ \
&& meson setup rkrga rkrga_build --prefix=/usr --libdir=lib --buildtype=release -Doptimization=2 \
-Dc_args="$CFLAGS" -Dcpp_args="$CXXFLAGS -fpermissive" -Dlibdrm=false -Dlibrga_demo=false \
&& meson configure rkrga_build > /dev/null \
&& ninja -C rkrga_build install \
&& rm -rf /tmp/rkmpp /tmp/rkrga; \
fi
# 编译 ustreamer按架构优化
# 编译 ustreamer
RUN sed --in-place --expression 's|^#include "refcount.h"$|#include "../refcount.h"|g' /usr/include/janus/plugins/plugin.h \
&& git clone --depth=1 https://github.com/mofeng-git/ustreamer /tmp/ustreamer \
&& export COMMON_CFLAGS='-O2 -pipe -fPIC -fstack-protector-strong -D_FORTIFY_SOURCE=2' \
&& if [ "${TARGETARCH}" = arm64 ]; then export CFLAGS="$COMMON_CFLAGS -march=armv8-a"; \
elif [ "${TARGETARCH}" = arm ]; then export CFLAGS="$COMMON_CFLAGS -march=armv7-a -mfpu=neon-vfpv4 -mfloat-abi=hard -mtune=cortex-a7"; \
else export CFLAGS="$COMMON_CFLAGS -march=x86-64 -mtune=generic"; fi \
&& export CXXFLAGS="$CFLAGS" \
&& if [ ${TARGETARCH} = arm64 ]; then \
make -j$(nproc) CFLAGS="$CFLAGS" WITH_PYTHON=1 WITH_JANUS=1 WITH_FFMPEG=1 WITH_MPP=1 WITH_DRM=1 -C /tmp/ustreamer; \
else \
make -j$(nproc) CFLAGS="$CFLAGS" WITH_PYTHON=1 WITH_JANUS=1 WITH_FFMPEG=1 WITH_DRM=1 -C /tmp/ustreamer; \
fi \
&& sed -i '68s/-Wl,-Bstatic//' /tmp/ustreamer/src/Makefile \
&& make -j WITH_PYTHON=1 WITH_JANUS=1 WITH_LIBX264=1 -C /tmp/ustreamer \
&& /tmp/ustreamer/ustreamer -v \
&& /tmp/ustreamer/ustreamer-dump -v \
&& cp /tmp/ustreamer/python/dist/*.whl /tmp/wheel/
# 复制必要的库文件
RUN mkdir /tmp/lib \
&& cd /lib/*-linux-*/ \
&& cp libevent_core-*.so.* libbsd.so.* libevent_pthreads-*.so.* libspeexdsp.so.* \
libevent-*.so.* libjpeg.so.* libyuv.so.* libnice.so.* \
/tmp/lib/ \
&& find /usr/lib -name "libsrtp2.so.*" -exec cp {} /tmp/lib/ \; \
&& find /usr/lib -name "libwebsockets.so.*" -exec cp {} /tmp/lib/ \; \
&& [ "${TARGETARCH}" = "arm64" ] && \
find /usr/lib -name "libsw*.so.*" -exec cp {} /tmp/lib/ \; && \
find /usr/lib -name "libpostproc.so.*" -exec cp {} /tmp/lib/ \; && \
find /usr/lib -name "librockchip*" -exec cp {} /tmp/lib/ \; && \
find /usr/lib -name "librga.so.*" -exec cp {} /tmp/lib/ \; || true
&& cp libevent_core-*.so.7 libbsd.so.0 libevent_pthreads-*.so.7 libspeexdsp.so.1 \
libevent-*.so.7 libjpeg.so.62 libx264.so.164 libyuv.so.0 libnice.so.10 \
/usr/lib/libsrtp2.so.1 /usr/lib/libwebsockets.so.19 \
/tmp/lib/

View File

@ -2,15 +2,12 @@
# --- 配置 ---
# 允许通过环境变量覆盖默认路径
SRCPATH="${SRCPATH:-/mnt/src}"
SRCPATH="${SRCPATH:-/mnt/nfs/lfs/src}"
BOOTFS="${BOOTFS:-/tmp/bootfs}"
ROOTFS="${ROOTFS:-/tmp/rootfs}"
OUTPUTDIR="${OUTPUTDIR:-/mnt/output}"
OUTPUTDIR="${OUTPUTDIR:-/mnt/nfs/lfs/src/output}"
TMPDIR="${TMPDIR:-$SRCPATH/tmp}"
# 远程文件下载配置
REMOTE_PREFIX="${REMOTE_PREFIX:-https://files.mofeng.run/src}"
export LC_ALL=C
# 全局变量
@ -59,7 +56,7 @@ build_target() {
onecloud)
onecloud_rootfs
local arch="armhf"
local device_type="gpio-onecloud"
local device_type="gpio"
local network_type="systemd-networkd"
;;
cumebox2)
@ -97,27 +94,6 @@ build_target() {
local network_type=""
NEED_PREPARE_DNS=true
;;
onecloud-pro)
onecloud_pro_rootfs
local arch="aarch64"
local device_type="gpio-onecloud-pro video1"
local network_type="systemd-networkd"
NEED_PREPARE_DNS=true
;;
orangepi-zero)
orangepizero_rootfs
local arch="armhf"
local device_type=""
local network_type=""
NEED_PREPARE_DNS=true
;;
oec-turbo)
oec_turbo_rootfs
local arch="aarch64"
local device_type="vpu"
local network_type=""
NEED_PREPARE_DNS=true
;;
*)
echo "错误:未知或不支持的目标 '$target'" >&2
exit 1
@ -145,29 +121,17 @@ build_target() {
chainedbox)
pack_img "Chainedbox"
;;
e900v22c)
e900v22c)
pack_img "E900v22c"
;;
octopus-flanet)
octopus-flanet)
pack_img "Octopus-Flanet"
;;
onecloud-pro)
pack_img "Onecloud-Pro"
;;
orangepi-zero)
pack_img "Orangepi-Zero"
;;
oec-turbo)
pack_img "OEC-Turbo"
;;
*)
echo "错误:未知的打包类型 for '$target'" >&2
;;
esac
# 在 GitHub Actions 环境中清理下载的文件
cleanup_downloaded_files
echo "=================================================="
echo "信息:目标 $target 构建完成!"
echo "=================================================="
@ -178,7 +142,7 @@ build_target() {
# 检查是否提供了目标参数
if [ -z "$1" ]; then
echo "用法: $0 <target|all>"
echo "可用目标: onecloud, cumebox2, chainedbox, vm, e900v22c, octopus-flanet, onecloud-pro, orangepi-zero, oec-turbo"
echo "可用目标: onecloud, cumebox2, chainedbox, vm, e900v22c, octopus-flanet"
exit 1
fi
@ -197,9 +161,6 @@ if [ "$1" = "all" ]; then
build_target "vm"
build_target "e900v22c"
build_target "octopus-flanet"
build_target "onecloud-pro"
build_target "orangepi-zero"
build_target "oec-turbo"
echo "信息:所有目标构建完成。"
else
build_target "$1"

View File

@ -172,127 +172,9 @@ write_meta() {
run_in_chroot "sed -i 's/localhost.localdomain/$hostname/g' /etc/kvmd/meta.yaml"
}
# 检测是否在 GitHub Actions 环境中
is_github_actions() {
[[ -n "$GITHUB_ACTIONS" ]]
}
# 记录下载的文件列表(仅在 GitHub Actions 环境中)
DOWNLOADED_FILES_LIST="/tmp/downloaded_files.txt"
# 自动下载文件函数
download_file_if_missing() {
local file_path="$1"
local relative_path=""
# 如果文件已存在,直接返回
if [[ -f "$file_path" ]]; then
echo "信息:文件已存在: $file_path"
return 0
fi
# 计算相对于 SRCPATH 的路径
if [[ "$file_path" == "$SRCPATH"/* ]]; then
relative_path="${file_path#$SRCPATH/}"
else
echo "错误:文件路径 $file_path 不在 SRCPATH ($SRCPATH) 下" >&2
return 1
fi
echo "信息:文件不存在,尝试下载: $file_path"
echo "信息:相对路径: $relative_path"
# 确保目标目录存在
local target_dir="$(dirname "$file_path")"
ensure_dir "$target_dir"
# 首先尝试直接下载
local remote_url="${REMOTE_PREFIX}/${relative_path}"
echo "信息:尝试下载: $remote_url"
if curl -f -L -o "$file_path" "$remote_url" 2>/dev/null; then
echo "信息:下载成功: $file_path"
# 在 GitHub Actions 环境中记录下载的文件
if is_github_actions; then
echo "$file_path" >> "$DOWNLOADED_FILES_LIST"
fi
return 0
fi
# 如果直接下载失败,尝试添加 .xz 后缀
echo "信息:直接下载失败,尝试 .xz 压缩版本..."
local xz_url="${remote_url}.xz"
local xz_file="${file_path}.xz"
if curl -f -L -o "$xz_file" "$xz_url" 2>/dev/null; then
echo "信息:下载 .xz 文件成功,正在解压..."
if xz -d "$xz_file"; then
echo "信息:解压成功: $file_path"
# 在 GitHub Actions 环境中记录下载的文件
if is_github_actions; then
echo "$file_path" >> "$DOWNLOADED_FILES_LIST"
fi
return 0
else
echo "错误:解压 .xz 文件失败" >&2
rm -f "$xz_file"
return 1
fi
fi
echo "错误:无法下载文件 $file_path (尝试了原始版本和 .xz 版本)" >&2
return 1
}
# 下载 rc.local 文件
download_rc_local() {
local platform_id="$1"
local rc_local_path="$SRCPATH/image/$platform_id/rc.local"
local relative_path="image/$platform_id/rc.local"
local remote_url="$REMOTE_PREFIX/$relative_path"
echo "信息:检查是否需要下载 rc.local 文件 ($platform_id)..."
# 如果本地文件不存在,尝试下载
if [ ! -f "$rc_local_path" ]; then
echo "信息:本地 rc.local 文件不存在,尝试从远程下载..."
ensure_dir "$(dirname "$rc_local_path")"
if curl -sSL --fail "$remote_url" -o "$rc_local_path"; then
echo "信息:成功下载 rc.local 文件:$remote_url"
# 在 GitHub Actions 环境中记录下载的文件
if is_github_actions; then
echo "$rc_local_path" >> "$DOWNLOADED_FILES_LIST"
fi
return 0
else
echo "信息:远程 rc.local 文件不存在或下载失败:$remote_url"
return 1
fi
else
echo "信息:使用本地 rc.local 文件:$rc_local_path"
return 0
fi
}
# 清理下载的文件(仅在 GitHub Actions 环境中)
cleanup_downloaded_files() {
if is_github_actions && [[ -f "$DOWNLOADED_FILES_LIST" ]]; then
echo "信息:清理 GitHub Actions 环境中下载的文件..."
while IFS= read -r file_path; do
if [[ -f "$file_path" ]]; then
echo "信息:删除下载的文件: $file_path"
rm -f "$file_path"
fi
done < "$DOWNLOADED_FILES_LIST"
rm -f "$DOWNLOADED_FILES_LIST"
echo "信息:下载文件清理完成"
fi
}
# 检查必要的外部工具
check_required_tools() {
local required_tools="sudo docker losetup mount umount parted e2fsck resize2fs qemu-img curl tar python3 pip3 rsync git simg2img img2simg dd cat rm mkdir mv cp sed chmod chown ln grep printf id xz"
local required_tools="sudo docker losetup mount umount parted e2fsck resize2fs qemu-img curl tar python3 pip3 rsync git simg2img img2simg dd cat rm mkdir mv cp sed chmod chown ln grep printf id"
for cmd in $required_tools; do
if ! command -v "$cmd" &> /dev/null; then

View File

@ -4,25 +4,18 @@
onecloud_rootfs() {
local unpacker="$SRCPATH/image/onecloud/AmlImg_v0.3.1_linux_amd64"
local source_image="$SRCPATH/image/onecloud/Armbian_by-SilentWind_24.5.0-trunk_Onecloud_bookworm_legacy_5.9.0-rc7_minimal_support-dvd-emulation.burn.img"
local source_image="$SRCPATH/image/onecloud/Armbian_by-SilentWind_24.5.0-trunk_Onecloud_bookworm_legacy_5.9.0-rc7_minimal.burn.img"
local bootfs_img="$TMPDIR/bootfs.img"
local rootfs_img="$TMPDIR/rootfs.img"
local bootfs_sparse="$TMPDIR/6.boot.PARTITION.sparse"
local rootfs_sparse="$TMPDIR/7.rootfs.PARTITION.sparse"
local bootfs_loopdev="" # 存储 bootfs 使用的 loop 设备
local add_size_mb=600
local add_size_mb=400
echo "信息:准备 Onecloud Rootfs..."
ensure_dir "$TMPDIR"
ensure_dir "$BOOTFS"
# 自动下载 AmlImg 工具(如果不存在)
download_file_if_missing "$unpacker" || { echo "错误:下载 AmlImg 工具失败" >&2; exit 1; }
sudo chmod +x "$unpacker" || { echo "错误:设置 AmlImg 工具执行权限失败" >&2; exit 1; }
# 自动下载源镜像文件(如果不存在)
download_file_if_missing "$source_image" || { echo "错误:下载 Onecloud 原始镜像失败" >&2; exit 1; }
echo "信息:解包 Onecloud burn 镜像..."
sudo "$unpacker" unpack "$source_image" "$TMPDIR" || { echo "错误:解包失败" >&2; exit 1; }
@ -37,12 +30,7 @@ onecloud_rootfs() {
sudo losetup "$bootfs_loopdev" "$bootfs_img" || { echo "错误:关联 bootfs 镜像到 $bootfs_loopdev 失败" >&2; exit 1; }
sudo mount "$bootfs_loopdev" "$BOOTFS" || { echo "错误:挂载 bootfs ($bootfs_loopdev) 失败" >&2; exit 1; }
BOOTFS_MOUNTED=1
# 自动下载 DTB 文件(如果不存在)
local dtb_file="$SRCPATH/image/onecloud/meson8b-onecloud-fix.dtb"
download_file_if_missing "$dtb_file" || { echo "错误:下载 Onecloud DTB 文件失败" >&2; exit 1; }
sudo cp "$dtb_file" "$BOOTFS/dtb/meson8b-onecloud.dtb" || { echo "错误:复制修复后的 DTB 文件失败" >&2; exit 1; }
sudo cp "$SRCPATH/image/onecloud/meson8b-onecloud-fix.dtb" "$BOOTFS/dtb/meson8b-onecloud.dtb" || { echo "错误:复制修复后的 DTB 文件失败" >&2; exit 1; }
sudo umount "$BOOTFS" || { echo "警告:卸载 bootfs ($BOOTFS) 失败" >&2; BOOTFS_MOUNTED=0; } # 卸载失败不应中断流程
BOOTFS_MOUNTED=0
echo "信息:分离 bootfs loop 设备 $bootfs_loopdev..."
@ -66,22 +54,14 @@ onecloud_rootfs() {
}
cumebox2_rootfs() {
local source_image="$SRCPATH/image/cumebox2/Armbian_24.8.1_Khadas-vim1_bookworm_current_6.6.47_minimal.img"
local source_image="$SRCPATH/image/cumebox2/Armbian_25.2.2_Khadas-vim1_bookworm_current_6.12.17_minimal.img"
local target_image="$TMPDIR/rootfs.img"
local offset=$((8192 * 512))
local add_size_mb=900
echo "信息:准备 Cumebox2 Rootfs..."
ensure_dir "$TMPDIR"
# 自动下载源镜像文件(如果不存在)
download_file_if_missing "$source_image" || { echo "错误:下载 Cumebox2 原始镜像失败" >&2; exit 1; }
cp "$source_image" "$target_image" || { echo "错误:复制 Cumebox2 原始镜像失败" >&2; exit 1; }
echo "信息:扩展镜像文件 (${add_size_mb}MB)..."
sudo dd if=/dev/zero bs=1M count="$add_size_mb" >> "$target_image" || { echo "错误:扩展镜像文件失败" >&2; exit 1; }
echo "信息:调整镜像分区大小..."
sudo parted -s "$target_image" resizepart 1 100% || { echo "错误:使用 parted 调整分区大小失败" >&2; exit 1; }
@ -106,10 +86,6 @@ chainedbox_rootfs_and_fix_dtb() {
echo "信息:准备 Chainedbox Rootfs 并修复 DTB..."
ensure_dir "$TMPDIR"; ensure_dir "$BOOTFS"
# 自动下载源镜像文件(如果不存在)
download_file_if_missing "$source_image" || { echo "错误:下载 Chainedbox 原始镜像失败" >&2; exit 1; }
cp "$source_image" "$target_image" || { echo "错误:复制 Chainedbox 原始镜像失败" >&2; exit 1; }
echo "信息:挂载 boot 分区并修复 DTB..."
@ -119,12 +95,7 @@ chainedbox_rootfs_and_fix_dtb() {
sudo losetup --offset "$boot_offset" "$bootfs_loopdev" "$target_image" || { echo "错误:设置 boot 分区 loop 设备 $bootfs_loopdev 失败" >&2; exit 1; }
sudo mount "$bootfs_loopdev" "$BOOTFS" || { echo "错误:挂载 boot 分区 ($bootfs_loopdev) 失败" >&2; exit 1; }
BOOTFS_MOUNTED=1
# 自动下载 DTB 文件(如果不存在)
local dtb_file="$SRCPATH/image/chainedbox/rk3328-l1pro-1296mhz-fix.dtb"
download_file_if_missing "$dtb_file" || { echo "错误:下载 Chainedbox DTB 文件失败" >&2; exit 1; }
sudo cp "$dtb_file" "$BOOTFS/dtb/rockchip/rk3328-l1pro-1296mhz.dtb" || { echo "错误:复制修复后的 DTB 文件失败" >&2; exit 1; }
sudo cp "$SRCPATH/image/chainedbox/rk3328-l1pro-1296mhz-fix.dtb" "$BOOTFS/dtb/rockchip/rk3328-l1pro-1296mhz.dtb" || { echo "错误:复制修复后的 DTB 文件失败" >&2; exit 1; }
sudo umount "$BOOTFS" || { echo "警告:卸载 boot 分区 ($BOOTFS) 失败" >&2; BOOTFS_MOUNTED=0; }
BOOTFS_MOUNTED=0
echo "信息:分离 boot loop 设备 $bootfs_loopdev..."
@ -145,10 +116,6 @@ vm_rootfs() {
echo "信息:准备 Vm Rootfs..."
ensure_dir "$TMPDIR"
# 自动下载源镜像文件(如果不存在)
download_file_if_missing "$source_image" || { echo "错误:下载 Vm 原始镜像失败" >&2; exit 1; }
cp "$source_image" "$target_image" || { echo "错误:复制 Vm 原始镜像失败" >&2; exit 1; }
echo "信息:设置带偏移量的 loop 设备..."
@ -163,14 +130,10 @@ e900v22c_rootfs() {
local source_image="$SRCPATH/image/e900v22c/Armbian_23.08.0_amlogic_s905l3a_bookworm_5.15.123_server_2023.08.01.img"
local target_image="$TMPDIR/rootfs.img"
local offset=$((532480 * 512))
local add_size_mb=600
local add_size_mb=400
echo "信息:准备 E900V22C Rootfs..."
ensure_dir "$TMPDIR"
# 自动下载源镜像文件(如果不存在)
download_file_if_missing "$source_image" || { echo "错误:下载 E900V22C 原始镜像失败" >&2; exit 1; }
cp "$source_image" "$target_image" || { echo "错误:复制 E900V22C 原始镜像失败" >&2; exit 1; }
echo "信息:扩展镜像文件 (${add_size_mb}MB)..."
@ -192,19 +155,15 @@ e900v22c_rootfs() {
}
octopus_flanet_rootfs() {
local source_image="$SRCPATH/image/octopus-flanet/Armbian_25.05.0_amlogic_s912_bookworm_6.1.129_server_2025.03.02.img"
local source_image="$SRCPATH/image/octopus-flanet/Armbian_24.11.0_amlogic_s912_bookworm_6.1.114_server_2024.11.01.img"
local target_image="$TMPDIR/rootfs.img"
local boot_offset=$((8192 * 512))
local rootfs_offset=$((1056768 * 512))
local add_size_mb=600
local add_size_mb=400
local bootfs_loopdev=""
echo "信息:准备 Octopus-Planet Rootfs..."
ensure_dir "$TMPDIR"; ensure_dir "$BOOTFS"
# 自动下载源镜像文件(如果不存在)
download_file_if_missing "$source_image" || { echo "错误:下载 Octopus-Planet 原始镜像失败" >&2; exit 1; }
cp "$source_image" "$target_image" || { echo "错误:复制 Octopus-Planet 原始镜像失败" >&2; exit 1; }
echo "信息:挂载 boot 分区并修改 uEnv.txt (使用 VIM2 DTB)..."
@ -214,12 +173,6 @@ octopus_flanet_rootfs() {
sudo losetup --offset "$boot_offset" "$bootfs_loopdev" "$target_image" || { echo "错误:设置 boot 分区 loop 设备 $bootfs_loopdev 失败" >&2; exit 1; }
sudo mount "$bootfs_loopdev" "$BOOTFS" || { echo "错误:挂载 boot 分区 ($bootfs_loopdev) 失败" >&2; exit 1; }
BOOTFS_MOUNTED=1
# 自动下载 Octopus-Planet 相关文件
local dtb_file="$SRCPATH/image/octopus-flanet/meson-gxm-octopus-planet.dtb"
download_file_if_missing "$dtb_file" || echo "警告:下载 Octopus-Planet DTB 失败"
sudo cp "$dtb_file" "$BOOTFS/dtb/amlogic/meson-gxm-octopus-planet.dtb" || echo "警告:复制 Octopus-Planet DTB 失败"
sudo sed -i "s/meson-gxm-octopus-planet.dtb/meson-gxm-khadas-vim2.dtb/g" "$BOOTFS/uEnv.txt" || { echo "错误:修改 uEnv.txt 失败" >&2; exit 1; }
sudo umount "$BOOTFS" || { echo "警告:卸载 boot 分区 ($BOOTFS) 失败" >&2; BOOTFS_MOUNTED=0; }
BOOTFS_MOUNTED=0
@ -241,213 +194,19 @@ octopus_flanet_rootfs() {
echo "信息Octopus-Planet Rootfs 准备完成loop 设备 $LOOPDEV 已就绪。"
}
onecloud_pro_rootfs() {
local source_image="$SRCPATH/image/onecloud-pro/Armbian-by-SilentWind_24.5.0_amlogic_Onecloud-Pro_jammy_6.6.28_server.img"
local target_image="$TMPDIR/rootfs.img"
local boot_offset=$((8192 * 512))
local rootfs_offset=$((1056768 * 512))
local add_size_mb=600
local bootfs_loopdev=""
echo "信息:准备 Octopus-Planet Rootfs..."
ensure_dir "$TMPDIR"; ensure_dir "$BOOTFS"
# 自动下载源镜像文件(如果不存在)
download_file_if_missing "$source_image" || { echo "错误:下载 Octopus-Planet 原始镜像失败" >&2; exit 1; }
cp "$source_image" "$target_image" || { echo "错误:复制 Octopus-Planet 原始镜像失败" >&2; exit 1; }
echo "信息:调整镜像分区大小 (分区 2)..."
sudo parted -s "$target_image" resizepart 2 100% || { echo "错误:使用 parted 调整分区 2 大小失败" >&2; exit 1; }
echo "信息:设置 rootfs 分区的 loop 设备..."
find_loop_device # 找 loop 给 rootfs
echo "信息:将 $target_image (偏移 $rootfs_offset) 关联到 $LOOPDEV..."
sudo losetup --offset "$rootfs_offset" "$LOOPDEV" "$target_image" || { echo "错误:设置 rootfs 分区 loop 设备 $LOOPDEV 失败" >&2; exit 1; }
echo "信息:检查并调整文件系统大小 (在 loop 设备上)..."
sudo e2fsck -f -y "$LOOPDEV" || { echo "警告e2fsck 检查 $LOOPDEV 失败" >&2; exit 1; }
sudo resize2fs "$LOOPDEV" || { echo "错误resize2fs 调整 $LOOPDEV 大小失败" >&2; exit 1; }
echo "信息Octopus-Planet Rootfs 准备完成loop 设备 $LOOPDEV 已就绪。"
}
orangepizero_rootfs() {
local source_image="$SRCPATH/image/orangepi-zero/Armbian_community_25.11.0-trunk.208_Orangepizero_bookworm_current_6.12.47_minimal.img"
local target_image="$TMPDIR/rootfs.img"
local offset=$((8192 * 512))
local add_size_mb=600
echo "信息:准备 Orange Pi Zero Rootfs..."
ensure_dir "$TMPDIR"
echo "信息:下载或使用本地 Orange Pi Zero 原始镜像..."
download_file_if_missing "$source_image" || { echo "错误:下载 Orange Pi Zero 原始镜像失败" >&2; exit 1; }
cp "$source_image" "$target_image" || { echo "错误:复制 Orange Pi Zero 原始镜像失败" >&2; exit 1; }
echo "信息:扩展镜像文件 (${add_size_mb}MB)..."
sudo dd if=/dev/zero bs=1M count="$add_size_mb" >> "$target_image" || { echo "错误:扩展镜像文件失败" >&2; exit 1; }
echo "信息:调整镜像分区大小..."
sudo parted -s "$target_image" resizepart 1 100% || { echo "错误:使用 parted 调整分区大小失败" >&2; exit 1; }
find_loop_device
sudo losetup -P "$LOOPDEV" "$target_image" || { echo "错误:设置 loop 设备失败" >&2; exit 1; }
echo "信息:检查并调整文件系统大小..."
sudo e2fsck -y -f "${LOOPDEV}p1" || { echo "错误:文件系统检查失败" >&2; exit 1; }
sudo resize2fs "${LOOPDEV}p1" || { echo "错误:调整文件系统大小失败" >&2; exit 1; }
# 重新设置 LOOPDEV 为分区
sudo losetup -d "$LOOPDEV"
sudo losetup "$LOOPDEV" "$target_image" -o "$offset" || { echo "错误:重新设置 loop 设备失败" >&2; exit 1; }
echo "信息Orange Pi Zero Rootfs 准备完成。"
}
# --- 特定设备的文件配置函数 ---
config_cumebox2_files() {
echo "信息:为 Cumebox2 配置特定文件 (OLED, DTB)..."
ensure_dir "$ROOTFS/etc/oled"
# 自动下载 Cumebox2 相关文件(如果不存在)
local dtb_file="$SRCPATH/image/cumebox2/v-fix.dtb"
local ssd_file="$SRCPATH/image/cumebox2/ssd"
local config_file="$SRCPATH/image/cumebox2/config.json"
download_file_if_missing "$dtb_file" || echo "警告:下载 Cumebox2 DTB 失败"
download_file_if_missing "$ssd_file" || echo "警告:下载 Cumebox2 ssd 脚本失败"
download_file_if_missing "$config_file" || echo "警告:下载 Cumebox2 配置文件失败"
sudo cp "$dtb_file" "$ROOTFS/boot/dtb/amlogic/meson-gxl-s905x-khadas-vim.dtb" || echo "警告:复制 Cumebox2 DTB 失败"
sudo cp "$ssd_file" "$ROOTFS/usr/bin/" || echo "警告:复制 Cumebox2 ssd 脚本失败"
# 注意 DTB 路径可能需要根据实际 Armbian 版本调整
sudo cp "$SRCPATH/image/cumebox2/v-fix.dtb" "$ROOTFS/boot/dtb/amlogic/meson-gxl-s905x-khadas-vim.dtb" || echo "警告:复制 Cumebox2 DTB 失败"
sudo cp "$SRCPATH/image/cumebox2/ssd" "$ROOTFS/usr/bin/" || echo "警告:复制 Cumebox2 ssd 脚本失败"
sudo chmod +x "$ROOTFS/usr/bin/ssd" || echo "警告:设置 ssd 脚本执行权限失败"
sudo cp "$config_file" "$ROOTFS/etc/oled/config.json" || echo "警告:复制 OLED 配置文件失败"
sudo cp "$SRCPATH/image/cumebox2/config.json" "$ROOTFS/etc/oled/config.json" || echo "警告:复制 OLED 配置文件失败"
}
config_octopus_flanet_files() {
echo "信息:为 Octopus-Planet 配置特定文件 (model_database.conf)..."
# 自动下载 Octopus-Planet 相关文件(如果不存在)
local config_file="$SRCPATH/image/octopus-flanet/model_database.conf"
download_file_if_missing "$config_file" || echo "警告:下载 Octopus-Planet 配置文件失败"
sudo cp "$config_file" "$ROOTFS/etc/model_database.conf" || echo "警告:复制 model_database.conf 失败"
echo "信息:为 Octopus-Planet 添加 DRM 设备支持..."
run_in_chroot "sed -i \"/--device=\\/dev\\/video0/a\\ - \\\"--drm-device=/dev/dri/card0\\\"\" /etc/kvmd/override.yaml"
}
config_orangepi_zero_files() {
echo "信息:配置 Orange Pi Zero 特定文件..."
# 清空 modules.conf 文件,避免加载不必要的模块
run_in_chroot "echo 'libcomposite' > /etc/modules-load.d/modules.conf"
echo "信息Orange Pi Zero 特定配置完成。"
}
config_onecloud_pro_files() {
echo "信息:配置 Onecloud Pro 特定文件..."
echo "信息:为 Onecloud Pro 添加 DRM 设备支持..."
run_in_chroot "sed -i \"/--device=\\/dev\\/video0/a\\ - \\\"--drm-device=/dev/dri/card0\\\"\" /etc/kvmd/override.yaml"
}
config_onecloud_files() {
echo "信息:配置 Onecloud 特定文件..."
echo "信息:为 Onecloud 添加 DRM 设备支持..."
run_in_chroot "sed -i \"/--device=\\/dev\\/video0/a\\ - \\\"--drm-device=/dev/dri/card1\\\"\" /etc/kvmd/override.yaml"
echo "信息Onecloud 特定配置完成。"
}
oec_turbo_rootfs() {
local source_image="$SRCPATH/image/oec-turbo/Flash_Armbian_25.05.0_rockchip_efused-wxy-oec_bookworm_6.1.99_server_2025.03.20.img"
local target_image="$TMPDIR/rootfs.img"
local rootfs_offset=$((1409024 * 512)) # 根据分区7的起始扇区计算
echo "信息:准备 OEC-Turbo Rootfs (Debian 12)..."
ensure_dir "$TMPDIR"
echo "信息:下载或使用本地 OEC-Turbo 原始镜像..."
download_file_if_missing "$source_image" || { echo "错误:下载 OEC-Turbo 原始镜像失败" >&2; exit 1; }
cp "$source_image" "$target_image" || { echo "错误:复制 OEC-Turbo 原始镜像失败" >&2; exit 1; }
find_loop_device
# 设置 loop 设备指向 rootfs 分区 (分区7)
sudo losetup "$LOOPDEV" "$target_image" -o "$rootfs_offset" || { echo "错误:设置 loop 设备失败" >&2; exit 1; }
echo "信息OEC-Turbo Rootfs 准备完成loop 设备 $LOOPDEV 已就绪。"
}
config_oec_turbo_files() {
echo "信息:配置 OEC-Turbo 特定文件..."
# 替换 override.yaml 中的硬件编码配置,启用 RK MPP 硬件编码
echo "信息:配置 VPU 硬件编码支持..."
run_in_chroot "sed -i 's/--h264-hwenc=disabled/--h264-hwenc=rkmpp/g' /etc/kvmd/override.yaml"
echo "信息:配置 udev 规则以授权 kvmd 组访问硬件设备..."
run_in_chroot "cat > /etc/udev/rules.d/99-kvmd-hw-access.rules <<'EOF'
# Generic hardware access for kvmd
# Safe on all platforms — rules only apply if device exists
# Rockchip MPP (rkmpp)
KERNEL==\"mpp_service\", GROUP=\"kvmd\", MODE=\"0660\"
# DMA-Heap (used by modern MPP)
SUBSYSTEM==\"dma_heap\", KERNEL==\"system\", GROUP=\"kvmd\", MODE=\"0660\"
SUBSYSTEM==\"dma_heap\", KERNEL==\"system-uncached\", GROUP=\"kvmd\", MODE=\"0660\"
SUBSYSTEM==\"dma_heap\", KERNEL==\"reserved\", GROUP=\"kvmd\", MODE=\"0660\"
# Optional legacy Rockchip devices
KERNEL==\"rkvdec\", GROUP=\"kvmd\", MODE=\"0660\"
KERNEL==\"rkvenc\", GROUP=\"kvmd\", MODE=\"0660\"
KERNEL==\"rga\", GROUP=\"kvmd\", MODE=\"0660\"
EOF"
# 替换 DTB 文件
replace_oec_turbo_dtb
echo "信息OEC-Turbo 特定配置完成。"
}
replace_oec_turbo_dtb() {
local dtb_source="$SRCPATH/image/oec-turbo/rk3566-onething-oec-box.dtb"
local target_image="$TMPDIR/rootfs.img"
local boot_offset=$((360448 * 512)) # boot 分区6的偏移
local boot_mount="$TMPDIR/oec_boot_mount"
local dtb_target_path="dtb/rockchip/rk3566-onething-oec-box.dtb"
local boot_loopdev=""
echo "信息:替换 OEC-Turbo DTB 文件..."
if [ ! -f "$dtb_source" ]; then
echo "信息:尝试下载 DTB 文件..."
download_file_if_missing "$dtb_source"
fi
echo "信息:为 boot 分区查找独立的 loop 设备..."
# 查找一个新的loop设备用于boot分区
boot_loopdev=$(losetup -f)
ensure_dir "$boot_mount"
losetup -o "$boot_offset" "$boot_loopdev" "$target_image"
mount "$boot_loopdev" "$boot_mount"
# 确保目标目录存在并复制 DTB 文件
mkdir -p "$boot_mount/$(dirname "$dtb_target_path")"
cp "$dtb_source" "$boot_mount/$dtb_target_path"
echo "信息DTB 文件替换成功: $dtb_target_path"
umount "$boot_mount"
losetup -d "$boot_loopdev"
rmdir "$boot_mount"
}
sudo cp "$SRCPATH/image/octopus-flanet/model_database.conf" "$ROOTFS/etc/model_database.conf" || echo "警告:复制 model_database.conf 失败"
}

View File

@ -10,7 +10,7 @@ prepare_dns_and_mirrors() {
&& printf '%s\\n' 'nameserver 1.1.1.1' 'nameserver 1.0.0.1' > /etc/resolv.conf \\
&& echo '信息:尝试更换镜像源...' \\
&& bash <(curl -sSL https://gitee.com/SuperManito/LinuxMirrors/raw/main/ChangeMirrors.sh) \\
--source mirrors.ustc.edu.cn --upgrade-software false --web-protocol http || echo '警告:更换镜像源脚本执行失败,可能网络不通或脚本已更改'
--source mirrors.tuna.tsinghua.edu.cn --upgrade-software false --web-protocol http || echo '警告:更换镜像源脚本执行失败,可能网络不通或脚本已更改'
"
}
@ -21,12 +21,7 @@ delete_armbian_verify(){
prepare_external_binaries() {
local platform="$1" # linux/armhf or linux/amd64 or linux/aarch64
# 如果在 GitHub Actions 环境下,使用 silentwind0/kvmd-stage-0否则用阿里云镜像
if is_github_actions; then
local docker_image="silentwind0/kvmd-stage-0"
else
local docker_image="registry.cn-hangzhou.aliyuncs.com/silentwind/kvmd-stage-0"
fi
local docker_image="registry.cn-hangzhou.aliyuncs.com/silentwind/kvmd-stage-0"
echo "信息:准备外部预编译二进制文件 (平台: $platform)..."
ensure_dir "$PREBUILT_DIR"
@ -77,8 +72,6 @@ config_base_files() {
sudo cp scripts/kvmd-gencert scripts/kvmd-bootconfig scripts/kvmd-certbot scripts/kvmd-udev-hdmiusb-check scripts/kvmd-udev-restart-pass build/scripts/kvmd-firstrun.sh "$ROOTFS/usr/bin/"
sudo chmod +x "$ROOTFS/usr/bin/kvmd-gencert" "$ROOTFS/usr/bin/kvmd-bootconfig" "$ROOTFS/usr/bin/kvmd-certbot" "$ROOTFS/usr/bin/kvmd-udev-hdmiusb-check" "$ROOTFS/usr/bin/kvmd-udev-restart-pass" "$ROOTFS/usr/bin/kvmd-firstrun.sh"
# 尝试下载或使用本地 rc.local 文件
download_rc_local "$platform_id" || echo "信息rc.local 文件不存在,跳过"
if [ -f "$SRCPATH/image/$platform_id/rc.local" ]; then
echo "信息:复制设备特定的 rc.local 文件..."
sudo cp "$SRCPATH/image/$platform_id/rc.local" "$ROOTFS/etc/"
@ -109,8 +102,7 @@ install_base_packages() {
libxkbcommon-x11-0 nginx tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim \\
iptables network-manager curl kmod libmicrohttpd12 libjansson4 libssl3 \\
libsofia-sip-ua0 libglib2.0-0 libopus0 libogg0 libcurl4 libconfig9 \\
python3-pip net-tools libavcodec59 libavformat59 libavutil57 libswscale6 \\
libavfilter8 libavdevice59 v4l-utils libv4l-0 nano unzip dnsmasq python3-systemd && \\
python3-pip net-tools && \\
apt clean && \\
rm -rf /var/lib/apt/lists/*
"
@ -121,9 +113,9 @@ configure_network() {
if [ "$network_type" = "systemd-networkd" ]; then
echo "信息:在 chroot 环境中配置 systemd-networkd..."
# onecloud 与 onecloud-pro 均启用基于 SN 的 MAC 地址生成
if [ "$TARGET_DEVICE_NAME" = "onecloud" ] || [ "$TARGET_DEVICE_NAME" = "onecloud-pro" ]; then
echo "信息:为 ${TARGET_DEVICE_NAME} 平台配置基于 SN 的 MAC 地址生成机制..."
# 检查是否为onecloud平台如果是则使用随机MAC地址生成机制
if [ "$TARGET_DEVICE_NAME" = "onecloud" ]; then
echo "信息为onecloud平台配置随机MAC地址生成机制..."
# 复制MAC地址生成脚本
sudo cp "$SCRIPT_DIR/scripts/generate-random-mac.sh" "$ROOTFS/usr/local/bin/"
@ -140,7 +132,7 @@ configure_network() {
systemctl enable systemd-networkd systemd-resolved && \\
systemctl enable kvmd-generate-mac.service
"
echo "信息:${TARGET_DEVICE_NAME} 基于 SN 的 MAC 地址生成机制配置完成"
echo "信息onecloud随机MAC地址生成机制配置完成"
fi
else
echo "信息:使用默认的网络管理器 (NetworkManager)..."
@ -181,10 +173,10 @@ configure_system() {
cat /One-KVM/configs/os/sudoers/v2-hdmiusb >> /etc/sudoers && \\
cat /One-KVM/configs/os/udev/v2-hdmiusb-rpi4.rules > /etc/udev/rules.d/99-kvmd.rules && \\
echo 'libcomposite' >> /etc/modules && \\
echo 'net.ipv4.ip_forward = 1' > /etc/sysctl.d/99-kvmd-extra.conf && \\
mv /usr/local/bin/kvmd* /usr/bin/ || echo '信息:/usr/local/bin/kvmd* 未找到或移动失败,可能已在/usr/bin' && \\
cp -r /One-KVM/configs/os/services/* /etc/systemd/system/ && \\
cp /One-KVM/configs/os/services/* /etc/systemd/system/ && \\
cp /One-KVM/configs/os/tmpfiles.conf /usr/lib/tmpfiles.d/ && \\
mv /etc/kvmd/supervisord.conf /etc/supervisord.conf && \\
chmod +x /etc/update-motd.d/* || echo '警告chmod /etc/update-motd.d/* 失败' && \\
echo 'kvmd ALL=(ALL) NOPASSWD: /etc/kvmd/custom_atx/gpio.sh' >> /etc/sudoers && \\
echo 'kvmd ALL=(ALL) NOPASSWD: /etc/kvmd/custom_atx/usbrelay_hid.sh' >> /etc/sudoers && \\
@ -194,10 +186,8 @@ configure_system() {
sed -i 's/8080/80/g' /etc/kvmd/override.yaml && \\
sed -i 's/4430/443/g' /etc/kvmd/override.yaml && \\
chown kvmd -R /var/lib/kvmd/msd/ && \\
rm /etc/resolv.conf && \\
printf '%s\\n' 'nameserver 1.1.1.1' 'nameserver 1.0.0.1' > /etc/resolv.conf && \
systemctl enable dnsmasq kvmd kvmd-otg kvmd-nginx kvmd-vnc kvmd-ipmi kvmd-webterm kvmd-janus kvmd-media kvmd-gostc && \\
systemctl disable nginx systemd-resolved && \\
systemctl enable kvmd kvmd-otg kvmd-nginx kvmd-vnc kvmd-ipmi kvmd-webterm kvmd-janus kvmd-media && \\
systemctl disable nginx && \\
rm -rf /One-KVM
"
}
@ -207,70 +197,20 @@ install_webterm() {
local ttyd_arch="$arch"
if [ "$arch" = "armhf" ]; then
ttyd_arch="armhf"
ttyd_arch="armv7"
elif [ "$arch" = "amd64" ]; then
ttyd_arch="x86_64"
elif [ "$arch" = "aarch64" ]; then
ttyd_arch="aarch64"
ttyd_arch="x86_64" # ttyd 通常用 x86_64
fi
echo "信息:在 chroot 环境中下载并安装 ttyd ($ttyd_arch)..."
run_in_chroot "
curl -L https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.${ttyd_arch} -o /usr/bin/ttyd && \\
curl -L https://gh.llkk.cc/https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.${ttyd_arch} -o /usr/bin/ttyd && \\
chmod +x /usr/bin/ttyd && \\
mkdir -p /home/kvmd-webterm && \\
chown kvmd-webterm /home/kvmd-webterm
"
}
install_gostc() {
local arch="$1" # armhf, aarch64, x86_64
local gostc_arch="$arch"
local gostc_version="v2.0.8-beta.2"
# 根据架构映射下载文件名
case "$arch" in
armhf) gostc_arch="arm_7" ;;
aarch64) gostc_arch="arm64_v8.0" ;;
x86_64|amd64) gostc_arch="amd64_v1" ;;
*) echo "错误:不支持的架构 $arch"; exit 1 ;;
esac
echo "信息:在 chroot 环境中下载并安装 gostc ($gostc_arch)..."
run_in_chroot "
mkdir -p /tmp/gostc && cd /tmp/gostc && \\
curl -L https://github.com/mofeng-git/gostc-open/releases/download/${gostc_version}/gostc_linux_${gostc_arch}.tar.gz -o gostc.tar.gz && \\
tar -xzf gostc.tar.gz && \\
mv gostc /usr/bin/ && \\
chmod +x /usr/bin/gostc && \\
cd / && rm -rf /tmp/gostc
"
echo "信息:创建 gostc systemd 服务文件..."
run_in_chroot "
cat > /etc/systemd/system/kvmd-gostc.service << 'EOF'
[Unit]
Description=基于FRP开发的内网穿透 客户端/节点
ConditionFileIsExecutable=/usr/bin/gostc
After=network.target
[Service]
StartLimitInterval=5
StartLimitBurst=10
ExecStart=/usr/bin/gostc \"-web-addr\" \"0.0.0.0:18080\"
WorkingDirectory=/usr/bin
Restart=always
RestartSec=10
EnvironmentFile=-/etc/sysconfig/gostc
[Install]
WantedBy=multi-user.target
EOF
"
echo "信息gostc 安装和配置完成"
}
apply_kvmd_tweaks() {
local arch="$1" # armhf, aarch64, x86_64
local device_type="$2" # "gpio" or "video1" or other
@ -299,15 +239,7 @@ apply_kvmd_tweaks() {
# 根据 device_type 配置 ATX
if [[ "$device_type" == *"gpio-onecloud-pro"* ]]; then
echo "信息:电源控制设备类型为 gpio设置 ATX 为 GPIO 并配置引脚..."
atx_setting="GPIO"
run_in_chroot "
sed -i 's/^ATX=.*/ATX=GPIO/' /etc/kvmd/atx.sh && \\
sed -i 's/SHUTDOWNPIN/gpiochip0 7/g' /etc/kvmd/custom_atx/gpio.sh && \\
sed -i 's/REBOOTPIN/gpiochip0 11/g' /etc/kvmd/custom_atx/gpio.sh
"
elif [[ "$device_type" == *"gpio-onecloud"* ]]; then
if [ "$device_type" = "gpio" ]; then
echo "信息:电源控制设备类型为 gpio设置 ATX 为 GPIO 并配置引脚..."
atx_setting="GPIO"
run_in_chroot "
@ -322,10 +254,10 @@ apply_kvmd_tweaks() {
fi
# 配置视频设备
if [[ "$device_type" == *"video1"* ]]; then
if [ "$device_type" = "video1" ]; then
echo "信息:视频设备类型为 video1设置视频设备为 /dev/video1..."
run_in_chroot "sed -i 's|/dev/video0|/dev/video1|g' /etc/kvmd/override.yaml"
elif [[ "$device_type" == *"video1"* ]]; then
elif [ "$device_type" = "kvmd-video" ]; then
echo "信息:视频设备类型为 kvmd-video设置视频设备为 /dev/kvmd-video..."
run_in_chroot "sed -i 's|/dev/video0|/dev/kvmd-video|g' /etc/kvmd/override.yaml"
else
@ -333,8 +265,6 @@ apply_kvmd_tweaks() {
fi
fi
echo "信息KVMD 配置调整完成。"
run_in_chroot "apt remove -y --purge systemd-resolved"
}
# --- 整体安装流程 ---
@ -357,11 +287,9 @@ install_and_configure_kvmd() {
config_base_files "$TARGET_DEVICE_NAME" # 使用全局变量传递设备名
# 特定设备的额外文件配置 (如果存在)
# 将设备名中的连字符转换为下划线以匹配函数名
local device_func_name="${TARGET_DEVICE_NAME//-/_}"
if declare -f "config_${device_func_name}_files" > /dev/null; then
echo "信息:执行特定设备的文件配置函数 config_${device_func_name}_files ..."
"config_${device_func_name}_files"
if declare -f "config_${TARGET_DEVICE_NAME}_files" > /dev/null; then
echo "信息:执行特定设备的文件配置函数 config_${TARGET_DEVICE_NAME}_files ..."
"config_${TARGET_DEVICE_NAME}_files"
fi
# 某些镜像可能需要准备DNS和换源
@ -376,7 +304,6 @@ install_and_configure_kvmd() {
configure_network "$network_type"
install_python_deps
configure_kvmd_core
install_gostc "$arch" # 安装 gostc
configure_system
install_webterm "$arch" # 传递原始架构名给ttyd下载
apply_kvmd_tweaks "$arch" "$device_type"

View File

@ -1,21 +1,5 @@
#!/bin/bash
# --- 压缩函数 ---
# 压缩镜像文件(仅在 GitHub Actions 环境中)
compress_image_file() {
local file_path="$1"
if is_github_actions && [[ -f "$file_path" ]]; then
echo "信息:压缩镜像文件: $file_path"
if xz -9 -vv "$file_path"; then
echo "信息:压缩完成: ${file_path}.xz"
else
echo "警告:压缩文件 $file_path 失败"
fi
fi
}
# --- 打包函数 ---
pack_img() {
@ -45,22 +29,7 @@ pack_img() {
sudo qemu-img convert -f raw -O vmdk "$raw_img" "$vmdk_img" || echo "警告:转换为 VMDK 失败"
echo "信息:转换为 VDI..."
sudo qemu-img convert -f raw -O vdi "$raw_img" "$vdi_img" || echo "警告:转换为 VDI 失败"
# 在 GitHub Actions 环境中压缩 VM 镜像文件
if is_github_actions; then
echo "信息:在 GitHub Actions 环境中压缩 VM 镜像文件..."
compress_image_file "$raw_img"
compress_image_file "$vmdk_img"
compress_image_file "$vdi_img"
fi
else
# 在 GitHub Actions 环境中压缩镜像文件
if is_github_actions; then
echo "信息:在 GitHub Actions 环境中压缩镜像文件..."
compress_image_file "$OUTPUTDIR/$target_img_name"
fi
fi
echo "信息:镜像打包完成: $OUTPUTDIR/$target_img_name"
}
@ -79,10 +48,6 @@ pack_img_onecloud() {
unmount_all
fi
# 自动下载 AmlImg 工具(如果不存在)
download_file_if_missing "$aml_packer" || { echo "错误:下载 AmlImg 工具失败" >&2; exit 1; }
sudo chmod +x "$aml_packer" || { echo "错误:设置 AmlImg 工具执行权限失败" >&2; exit 1; }
echo "信息:将 raw rootfs 转换为 sparse image..."
# 先删除可能存在的旧 sparse 文件
sudo rm -f "$rootfs_sparse_img"
@ -90,16 +55,11 @@ pack_img_onecloud() {
sudo rm "$rootfs_raw_img" # 删除 raw 文件,因为它已被转换
echo "信息:使用 AmlImg 工具打包..."
sudo chmod +x "$aml_packer"
sudo "$aml_packer" pack "$OUTPUTDIR/$target_img_name" "$TMPDIR/" || { echo "错误AmlImg 打包失败" >&2; exit 1; }
echo "信息:清理 Onecloud 临时文件..."
sudo rm -f "$TMPDIR/6.boot.PARTITION.sparse" "$TMPDIR/7.rootfs.PARTITION.sparse" "$TMPDIR/dts.img"
# 在 GitHub Actions 环境中压缩 Onecloud 镜像文件
if is_github_actions; then
echo "信息:在 GitHub Actions 环境中压缩 Onecloud 镜像文件..."
compress_image_file "$OUTPUTDIR/$target_img_name"
fi
echo "信息Onecloud burn 镜像打包完成: $OUTPUTDIR/$target_img_name"
}

View File

@ -71,9 +71,8 @@ if [ ! -f /etc/kvmd/.init_flag ]; then
# 设置用户名和密码
if [ ! -z "$USERNAME" ] && [ ! -z "$PASSWORD" ]; then
# 设置自定义用户名和密码
if python -m kvmd.apps.htpasswd del admin \
&& echo "$PASSWORD" | python -m kvmd.apps.htpasswd add -i "$USERNAME" \
&& echo "$PASSWORD" | python -m kvmd.apps.htpasswd set -i "$USERNAME" \
&& echo "$PASSWORD -> $USERNAME:$PASSWORD" > /etc/kvmd/vncpasswd \
&& echo "$USERNAME:$PASSWORD -> $USERNAME:$PASSWORD" > /etc/kvmd/ipmipasswd; then
log_info "用户凭据设置成功"
@ -81,16 +80,6 @@ if [ ! -f /etc/kvmd/.init_flag ]; then
log_error "用户凭据设置失败"
exit 1
fi
elif [ ! -z "$PASSWORD" ] && [ -z "$USERNAME" ]; then
# 只设置密码保持admin用户名
if echo "$PASSWORD" | python -m kvmd.apps.htpasswd set -i "admin" \
&& echo "$PASSWORD -> admin:$PASSWORD" > /etc/kvmd/vncpasswd \
&& echo "admin:$PASSWORD -> admin:$PASSWORD" > /etc/kvmd/ipmipasswd; then
log_info "admin 用户密码设置成功"
else
log_error "admin 用户密码设置失败"
exit 1
fi
else
log_warn "未设置 USERNAME 和 PASSWORD 环境变量,使用默认值(admin/admin)"
fi
@ -159,7 +148,7 @@ EOF
fi
if [ "$NOIPMI" == "1" ]; then
log_info "已禁用 IPMI 功能"
log_info "已禁用IPMI功能"
rm -r /usr/share/kvmd/extras/ipmi
else
cat >> /etc/kvmd/supervisord.conf << EOF
@ -177,30 +166,11 @@ redirect_stderr=true
EOF
fi
if [ "$NOGOSTC" == "1" ]; then
log_info "已禁用 GOSTC 功能"
rm -rf /usr/share/kvmd/extras/gostc
else
cat >> /etc/kvmd/supervisord.conf << EOF
[program:kvmd-gostc]
command=/usr/bin/gostc -web-addr 127.0.0.1:18080
autostart=true
autorestart=true
startsecs=5
priority=300
stopasgroup=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes = 0
redirect_stderr=true
EOF
fi
#switch OTG config
if [ "$OTG" == "1" ]; then
log_info "已启用 OTG 功能"
sed -i "s/ch9329/otg/g" /etc/kvmd/override.yaml
sed -i "s|device: /dev/ttyUSB0||g" /etc/kvmd/override.yaml
sed -i "s/device: \/dev\/ttyUSB0//g" /etc/kvmd/override.yaml
if [ "$NOMSD" == 1 ]; then
log_info "已禁用 MSD 功能"
else
@ -209,8 +179,8 @@ EOF
fi
if [ ! -z "$VIDEONUM" ]; then
if sed -i "s|/dev/video0|/dev/video$VIDEONUM|g" /etc/kvmd/override.yaml && \
sed -i "s|/dev/video0|/dev/video$VIDEONUM|g" /etc/kvmd/janus/janus.plugin.ustreamer.jcfg; then
if sed -i "s/\/dev\/video0/\/dev\/video$VIDEONUM/g" /etc/kvmd/override.yaml && \
sed -i "s/\/dev\/video0/\/dev\/video$VIDEONUM/g" /etc/kvmd/janus/janus.plugin.ustreamer.jcfg; then
log_info "视频设备已设置为 /dev/video$VIDEONUM"
fi
fi
@ -227,12 +197,6 @@ EOF
fi
fi
if [ ! -z "$CH9329NUM" ]; then
if sed -i "s|/dev/ttyUSB0|/dev/ttyUSB$CH9329NUM|g" /etc/kvmd/override.yaml; then
log_info "CH9329 串口设备已设置为 $CH9329NUM"
fi
fi
if [ ! -z "$CH9329TIMEOUT" ]; then
if sed -i "s/read_timeout: 0.3/read_timeout: $CH9329TIMEOUT/g" /etc/kvmd/override.yaml; then
log_info "CH9329 超时已设置为 $CH9329TIMEOUT"
@ -246,31 +210,11 @@ EOF
fi
if [ ! -z "$VIDEOFORMAT" ]; then
if sed -i "s/--format=mjpeg/--format=$VIDEOFORMAT/g" /etc/kvmd/override.yaml; then
log_info "视频输入格式已设置为 $VIDEOFORMAT"
if sed -i "s/format=mjpeg/format=$VIDFORMAT/g" /etc/kvmd/override.yaml; then
log_info "视频输入格式已设置为 $VIDFORMAT"
fi
fi
if [ ! -z "$HWENCODER" ]; then
if sed -i "s/--h264-hwenc=disabled/--h264-hwenc=$HWENCODER/g" /etc/kvmd/override.yaml; then
log_info "硬件编码器已设置为 $HWENCODER"
fi
fi
# 设置WEB端口
if [ ! -z "$HTTPPORT" ]; then
if sed -i "s/port: 8080/port: $HTTPPORT/g" /etc/kvmd/override.yaml; then
log_info "HTTP 端口已设置为 $HTTPPORT"
fi
fi
if [ ! -z "$HTTPSPORT" ]; then
if sed -i "s/port: 4430/port: $HTTPSPORT/g" /etc/kvmd/override.yaml; then
log_info "HTTPS 端口已设置为 $HTTPSPORT"
fi
fi
touch /etc/kvmd/.init_flag
log_info "初始化配置完成"
fi
@ -297,4 +241,4 @@ if [ "$OTG" == "1" ]; then
fi
log_info "One-KVM 配置文件准备完成,正在启动服务..."
exec supervisord -c /etc/kvmd/supervisord.conf
exec supervisord -c /etc/kvmd/supervisord.conf

View File

@ -1,3 +0,0 @@
PIKVM_MODEL=v2_model
PIKVM_VIDEO=usb_video
PIKVM_BOARD=oec-turbo

View File

@ -1,3 +0,0 @@
PIKVM_MODEL=v2_model
PIKVM_VIDEO=usb_video
PIKVM_BOARD=onecloud-pro

View File

@ -1,3 +0,0 @@
PIKVM_MODEL=v2_model
PIKVM_VIDEO=usb_video
PIKVM_BOARD=orangepi-zero

View File

@ -1,21 +0,0 @@
wget https://github.com/hzyitc/AmlImg/releases/download/v0.3.1/AmlImg_v0.3.1_linux_amd64 -O /mnt/src/image/onecloud/AmlImg_v0.3.1_linux_amd64
chmod +x /mnt/src/image/onecloud/AmlImg_v0.3.1_linux_amd64
#!/bin/bash
# 文件映射脚本
# 本地目录前缀:/mnt
# 远程URL前缀https://files.mofeng.run
LOCAL_PREFIX="/mnt"
REMOTE_PREFIX="https://files.mofeng.run"
# 文件相对路径
REL_PATH="src/image/onecloud/Armbian_by-SilentWind_24.5.0-trunk_Onecloud_bookworm_legacy_5.9.0-rc7_minimal_support-dvd-emulation.burn.img"
LOCAL_FILE="$LOCAL_PREFIX/$REL_PATH"
REMOTE_URL="$REMOTE_PREFIX/$REL_PATH"
echo "下载 $REMOTE_URL 到 $LOCAL_FILE"
mkdir -p "$(dirname "$LOCAL_FILE")"
wget -O "$LOCAL_FILE" "$REMOTE_URL"

View File

@ -1,45 +1,12 @@
#!/bin/bash
# 为玩客云/玩客云Pro 平台生成 MAC 地址的一次性脚本
# 此脚本在首次开机时执行,为 eth0 网卡生成并应用基于 SN 的 MAC 地址,失败时回退到随机 MAC
# 为onecloud平台生成随机MAC地址的一次性脚本
# 此脚本在首次开机时执行,为eth0网卡生成并应用随机MAC地址
set -e
NETWORK_CONFIG="/etc/systemd/network/99-eth0.network"
LOCK_FILE="/var/lib/kvmd/.mac-generated"
PLATFORM_FILE="/usr/share/kvmd/platform"
EFUSE_SYSFS_PATH=""
SN_PREFIX=""
SN_EXPECTED_LENGTH=13
# 按平台设置 EFUSE 与 SN 参数;未知平台时按 efuse 路径探测
detect_platform_params() {
local platform=""
if [ -f "$PLATFORM_FILE" ]; then
platform=$(tr -d '\n' < "$PLATFORM_FILE")
fi
case "$platform" in
onecloud)
EFUSE_SYSFS_PATH="/sys/bus/nvmem/devices/meson8b-efuse0/nvmem"
SN_PREFIX="OCP"
;;
onecloud-pro)
EFUSE_SYSFS_PATH="/sys/devices/platform/efuse/efuse0/nvmem"
SN_PREFIX="ODC"
;;
esac
if [ -z "$EFUSE_SYSFS_PATH" ] || [ -z "$SN_PREFIX" ]; then
if [ -e "/sys/devices/platform/efuse/efuse0/nvmem" ]; then
EFUSE_SYSFS_PATH="/sys/devices/platform/efuse/efuse0/nvmem"
SN_PREFIX="ODC"
elif [ -e "/sys/bus/nvmem/devices/meson8b-efuse0/nvmem" ]; then
EFUSE_SYSFS_PATH="/sys/bus/nvmem/devices/meson8b-efuse0/nvmem"
SN_PREFIX="OCP"
fi
fi
}
# 检查是否已经执行过
if [ -f "$LOCK_FILE" ]; then
@ -47,29 +14,10 @@ if [ -f "$LOCK_FILE" ]; then
exit 0
fi
# 生成MAC地址函数
# 生成随机MAC地址 (使用本地管理的MAC地址前缀)
generate_random_mac() {
detect_platform_params
# 尝试根据 SN 生成唯一 MAC 地址
if [ -f "$EFUSE_SYSFS_PATH" ]; then
sn_offset=$(grep --binary-files=text -boP "$SN_PREFIX" "$EFUSE_SYSFS_PATH" | head -n1 | cut -d: -f1)
if [ -n "$sn_offset" ]; then
sn=$(cat "$EFUSE_SYSFS_PATH" | dd bs=1 skip="$sn_offset" count="$SN_EXPECTED_LENGTH" 2>/dev/null)
if [ ${#sn} -eq $SN_EXPECTED_LENGTH ]; then
echo "S/N: $sn" >&2 # 输出到 stderr避免干扰返回值
# 使用 SN 的 SHA-256 哈希生成后 5 字节(避免多余管道)
sn_hash=$(printf %s "$sn" | sha256sum | cut -d' ' -f1)
# 直接用 Bash 子串获取哈希末 10 个字符并插入分隔符
mac_hex=${sn_hash: -10}
mac_suffix=$(printf "%s:%s:%s:%s:%s" "${mac_hex:0:2}" "${mac_hex:2:2}" "${mac_hex:4:2}" "${mac_hex:6:2}" "${mac_hex:8:2}")
printf "02:%s\n" "$mac_suffix"
return 0
fi
fi
fi
# 若 SN 获取失败,回退到随机逻辑
echo "警告: 无法获取 SN回退到随机 MAC 生成" >&2
# 使用本地管理的MAC地址前缀 (第二位设为2、6、A、E中的一个)
# 这样可以避免与真实硬件MAC地址冲突
printf "02:%02x:%02x:%02x:%02x:%02x\n" \
$((RANDOM % 256)) \
$((RANDOM % 256)) \
@ -78,18 +26,12 @@ generate_random_mac() {
$((RANDOM % 256))
}
echo "正在生成基于 SN 的 MAC 地址..."
echo "正在为onecloud生成随机MAC地址..."
# 生成新的MAC地址
NEW_MAC=$(generate_random_mac)
echo "生成的MAC地址: $NEW_MAC"
# 验证 MAC 地址格式
if ! [[ $NEW_MAC =~ ^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$ ]]; then
echo "错误: 生成的 MAC 地址格式无效: $NEW_MAC"
exit 1
fi
# 备份原配置文件
if [ -f "$NETWORK_CONFIG" ]; then
cp "$NETWORK_CONFIG" "${NETWORK_CONFIG}.backup"
@ -116,7 +58,7 @@ echo "MAC地址生成时间: $(date)" > "$LOCK_FILE"
# 禁用此服务,确保只运行一次
systemctl disable kvmd-generate-mac.service
echo "MAC地址生成完成: $NEW_MAC"
echo "随机MAC地址生成完成: $NEW_MAC"
echo "服务已自动禁用,下次开机不会再执行"
exit 0
exit 0

View File

@ -1,82 +0,0 @@
#!/bin/bash
# 本地代码质量检查脚本
set -e
cd "$(dirname "$0")"
echo "🔍 运行代码质量检查..."
# 检查参数,如果有参数则只运行指定的检查
CHECK_TYPE="${1:-all}"
run_flake8() {
echo "📝 运行 flake8 代码风格检查..."
flake8 --config=testenv/linters/flake8.ini kvmd testenv/tests *.py
}
run_pylint() {
echo "🔎 运行 pylint 代码质量分析..."
pylint -j0 --rcfile=testenv/linters/pylint.ini --output-format=colorized --reports=no kvmd testenv/tests *.py || true
}
run_mypy() {
echo "🔧 运行 mypy 类型检查..."
mypy --config-file=testenv/linters/mypy.ini --cache-dir=testenv/.mypy_cache kvmd testenv/tests *.py || true
}
run_vulture() {
echo "💀 运行 vulture 死代码检测..."
vulture --ignore-names=_format_P,Plugin --ignore-decorators=@exposed_http,@exposed_ws,@pytest.fixture kvmd testenv/tests *.py testenv/linters/vulture-wl.py || true
}
run_eslint() {
echo "📜 运行 eslint JavaScript检查..."
if command -v eslint >/dev/null 2>&1; then
eslint --cache-location=/tmp --config=testenv/linters/eslintrc.js --color web/share/js || true
else
echo "⚠️ eslint 未安装,跳过"
fi
}
run_htmlhint() {
echo "📄 运行 htmlhint HTML检查..."
if command -v htmlhint >/dev/null 2>&1; then
htmlhint --config=testenv/linters/htmlhint.json web/*.html web/*/*.html || true
else
echo "⚠️ htmlhint 未安装,跳过"
fi
}
run_shellcheck() {
echo "🐚 运行 shellcheck Shell脚本检查..."
if command -v shellcheck >/dev/null 2>&1; then
shellcheck --color=always kvmd.install scripts/* || true
else
echo "⚠️ shellcheck 未安装,跳过"
fi
}
case "$CHECK_TYPE" in
flake8) run_flake8 ;;
pylint) run_pylint ;;
mypy) run_mypy ;;
vulture) run_vulture ;;
eslint) run_eslint ;;
htmlhint) run_htmlhint ;;
shellcheck) run_shellcheck ;;
all)
run_flake8
run_pylint
run_mypy
run_vulture
run_eslint
run_htmlhint
run_shellcheck
;;
*)
echo "用法: $0 [flake8|pylint|mypy|vulture|eslint|htmlhint|shellcheck|all]"
exit 1
;;
esac
echo "✅ 代码质量检查完成!"

View File

@ -2,6 +2,6 @@ video: {
sink = "kvmd::ustreamer::h264"
}
acap: {
device = "hw:0,0"
device = "hw:0"
tc358743 = "/dev/video0"
}

View File

@ -20,7 +20,6 @@
# #
# ========================================================================== #
ATX=USBRELAY_HID
echo $ATX
case $ATX in
GPIO)
@ -32,4 +31,4 @@ case $ATX in
*)
echo "No thing."
exit -1
esac
esac

View File

@ -1 +1 @@
admin:{SSHA512}3zSmw/L9zIkpQdX5bcy6HntTxltAzTuGNP6NjHRRgOcNZkA0K+Lsrj3QplO9Gr3BA5MYVVki9rAVnFNCcIdtYC6FkLJWCmHs
admin:$apr1$.6mu9N8n$xOuGesr4JZZkdiZo/j318.

View File

@ -1,11 +1,14 @@
# This file describes the credentials for IPMI users in format "login:password",
# one per line. The passwords are NOT encrypted.
# This file describes the credentials for IPMI users. The first pair separated by colon
# is the login and password with which the user can access to IPMI. The second pair
# is the name and password with which the user can access to KVMD API. The arrow is used
# as a separator and shows the direction of user registration in the system.
#
# WARNING! IPMI protocol is completely unsafe by design. In short, the authentication
# process for IPMI 2.0 mandates that the server send a salted SHA1 or MD5 hash of the
# requested user's password to the client, prior to the client authenticating.
# requested user's password to the client, prior to the client authenticating. Never use
# the same passwords for KVMD and IPMI users. This default configuration is shown here
# for example only.
#
# NEVER use the same passwords for KVMD and IPMI users.
# This default configuration is shown here just for the example only.
# And even better not to use IPMI. Instead, you can directly use KVMD API via curl.
admin:admin
admin:admin -> admin:admin

View File

@ -1,97 +0,0 @@
# Don't touch this file otherwise your device may stop working.
# Use override.yaml to modify required settings.
# You can find a working configuration in /usr/share/kvmd/configs.default/kvmd.
override: !include [override.d, override.yaml]
logging: !include logging.yaml
kvmd:
auth: !include auth.yaml
info:
hw:
ignore_past: true
fan:
unix: /run/kvmd/fan.sock
hid:
type: otg
atx:
type: gpio
power_led_pin: 4
hdd_led_pin: 5
power_switch_pin: 23
reset_switch_pin: 27
msd:
type: otg
streamer:
h264_bitrate:
default: 5000
cmd:
- "/usr/bin/ustreamer"
- "--device=/dev/kvmd-video"
- "--persistent"
- "--dv-timings"
- "--format=uyvy"
- "--buffers=6"
- "--encoder=m2m-image"
- "--workers=3"
- "--quality={quality}"
- "--desired-fps={desired_fps}"
- "--drop-same-frames=30"
- "--unix={unix}"
- "--unix-rm"
- "--unix-mode=0660"
- "--exit-on-parent-death"
- "--process-name-prefix={process_name_prefix}"
- "--notify-parent"
- "--no-log-colors"
- "--jpeg-sink=kvmd::ustreamer::jpeg"
- "--jpeg-sink-mode=0660"
- "--h264-sink=kvmd::ustreamer::h264"
- "--h264-sink-mode=0660"
- "--h264-bitrate={h264_bitrate}"
- "--h264-gop={h264_gop}"
gpio:
drivers:
__v4_locator__:
type: locator
scheme:
__v3_usb_breaker__:
pin: 22
mode: output
initial: true
pulse: false
__v4_locator__:
driver: __v4_locator__
pin: 12
mode: output
pulse: false
__v4_const1__:
pin: 6
mode: output
initial: false
switch: false
pulse: false
media:
memsink:
h264:
sink: "kvmd::ustreamer::h264"
vnc:
memsink:
jpeg:
sink: "kvmd::ustreamer::jpeg"
h264:
sink: "kvmd::ustreamer::h264"

View File

@ -17,6 +17,8 @@ kvmd:
hid:
type: otg
mouse_alt:
device: /dev/kvmd-hid-mouse-alt
atx:
type: gpio

View File

@ -4,11 +4,11 @@
# will be displayed in the web interface.
server:
host: "@auto"
host: localhost.localdomain
kvm: {
base_on: "PiKVM",
app_name: "One-KVM",
main_version: "241204",
author: "SilentWind"
base_on: PiKVM,
app_name: One-KVM,
main_version: 241204,
author: SilentWind
}

View File

@ -48,7 +48,7 @@ kvmd:
- "--device=/dev/video0"
- "--persistent"
- "--format=mjpeg"
- "--encoder=FFMPEG-VIDEO"
- "--encoder=LIBX264-VIDEO"
- "--resolution={resolution}"
- "--desired-fps={desired_fps}"
- "--drop-same-frames=30"
@ -66,7 +66,7 @@ kvmd:
- "--jpeg-sink-mode=0660"
- "--h264-bitrate={h264_bitrate}"
- "--h264-gop={h264_gop}"
- "--h264-hwenc=disabled"
- "--h264-preset=ultrafast"
- "--slowdown"
gpio:
drivers:
@ -157,6 +157,10 @@ media:
jpeg:
sink: 'kvmd::ustreamer::jpeg'
janus:
stun:
host: stun.cloudflare.com
port: 3478
otgnet:
commands:
@ -164,9 +168,6 @@ otgnet:
- "/bin/true"
pre_stop_cmd:
- "/bin/true"
sysctl_cmd:
#- "/usr/sbin/sysctl"
- "/bin/true"
nginx:
http:

View File

@ -63,3 +63,4 @@ stopasgroup=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes = 0
redirect_stderr=true

View File

@ -1,9 +1,12 @@
# This file contains passwords for the legacy VNCAuth, one per line.
# The passwords are NOT encrypted.
# This file describes the credentials for VNCAuth. The left part before arrow is a passphrase
# for VNCAuth. The right part is username and password with which the user can access to KVMD API.
# The arrow is used as a separator and shows the relationship of user registrations on the system.
#
# WARNING! The VNCAuth method is NOT secure and should not be used at all.
# But we support it for compatibility with some clients.
# Never use the same passwords for VNC and IPMI users. This default configuration is shown here
# for example only.
#
# NEVER use the same passwords for KVMD, IPMI and VNCAuth users.
# If this file does not contain any entries, VNCAuth will be disabled and you will only be able
# to login in using your KVMD username and password using VeNCrypt methods.
# pa$$phr@se -> admin:password
admin -> admin:admin

View File

@ -24,7 +24,6 @@ location @login {
location /login {
root /usr/share/kvmd/web;
include /etc/kvmd/nginx/loc-nocache.conf;
auth_request off;
}
@ -66,7 +65,6 @@ location /api/hid/print {
proxy_pass http://kvmd;
include /etc/kvmd/nginx/loc-proxy.conf;
include /etc/kvmd/nginx/loc-bigpost.conf;
proxy_read_timeout 7d;
auth_request off;
}

View File

@ -1,2 +1,4 @@
limit_rate 6250k;
limit_rate_after 50k;
client_max_body_size 0;
proxy_request_buffering off;

View File

@ -39,9 +39,9 @@ http {
% if https_enabled:
server {
listen ${http_ipv4}:${http_port};
listen ${http_port};
% if ipv6_enabled:
listen [${http_ipv6}]:${http_port};
listen [::]:${http_port};
% endif
include /etc/kvmd/nginx/certbot.ctx-server.conf;
location / {
@ -54,9 +54,9 @@ http {
}
server {
listen ${https_ipv4}:${https_port} ssl;
listen ${https_port} ssl http2;
% if ipv6_enabled:
listen [${https_ipv6}]:${https_port} ssl;
listen [::]:${https_port} ssl http2;
% endif
include /etc/kvmd/nginx/ssl.conf;
include /etc/kvmd/nginx/kvmd.ctx-server.conf;
@ -66,9 +66,9 @@ http {
% else:
server {
listen ${http_ipv4}:${http_port};
listen ${http_port};
% if ipv6_enabled:
listen [${http_ipv6}]:${http_port};
listen [::]:${http_port};
% endif
include /etc/kvmd/nginx/certbot.ctx-server.conf;
include /etc/kvmd/nginx/kvmd.ctx-server.conf;

View File

@ -3,7 +3,7 @@
initramfs initramfs-linux.img followkernel
hdmi_force_hotplug=1
gpu_mem=192
gpu_mem=128
enable_uart=1
dtoverlay=disable-bt

View File

@ -1 +1 @@
s/rootwait/rootwait cma=192M/g
s/rootwait/rootwait cma=128M/g

View File

@ -1,16 +0,0 @@
[Unit]
Description=PiKVM - Local HID to KVMD proxy
After=kvmd.service systemd-udevd.service
[Service]
User=kvmd-localhid
Group=kvmd-localhid
Type=simple
Restart=always
RestartSec=3
ExecStart=/usr/bin/kvmd-localhid --run
TimeoutStopSec=3
[Install]
WantedBy=multi-user.target

View File

@ -1,6 +1,6 @@
[Unit]
Description=One-KVM - The main daemon
After=network.target network-online.target nss-lookup.target rc-local.service
After=network.target network-online.target nss-lookup.target
[Service]
User=kvmd

View File

@ -1,8 +0,0 @@
# Fix https://github.com/pikvm/pikvm/issues/1514:
# Wait for any single network interface, not all configured ones
# (Rationale: when user configures Wi-Fi via pikvm.txt or otherwise,
# we do not delete the Ethernet config, which means it will remain active
# regardless of whether the user ever intended to use Ethernet.)
[Service]
ExecStart=
ExecStart=/usr/lib/systemd/systemd-networkd-wait-online --any

View File

@ -1,10 +1,8 @@
g kvmd - -
g kvmd-selfauth - -
g kvmd-media - -
g kvmd-pst - -
g kvmd-ipmi - -
g kvmd-vnc - -
g kvmd-localhid - -
g kvmd-nginx - -
g kvmd-janus - -
g kvmd-certbot - -
@ -14,7 +12,6 @@ u kvmd-media - "PiKVM - The media proxy"
u kvmd-pst - "PiKVM - Persistent storage" -
u kvmd-ipmi - "PiKVM - IPMI to KVMD proxy" -
u kvmd-vnc - "PiKVM - VNC to KVMD/Streamer proxy" -
u kvmd-localhid - "PiKVM - Local HID to KVMD proxy" -
u kvmd-nginx - "PiKVM - HTTP entrypoint" -
u kvmd-janus - "PiKVM - Janus WebRTC Gateway" -
u kvmd-certbot - "PiKVM - Certbot-Renew for KVMD-Nginx"
@ -32,16 +29,10 @@ m kvmd-media kvmd
m kvmd-pst kvmd
m kvmd-ipmi kvmd
m kvmd-ipmi kvmd-selfauth
m kvmd-vnc kvmd
m kvmd-vnc kvmd-selfauth
m kvmd-vnc kvmd-certbot
m kvmd-localhid input
m kvmd-localhid kvmd
m kvmd-localhid kvmd-selfauth
m kvmd-janus kvmd
m kvmd-janus audio

View File

@ -1,15 +1,4 @@
# Here are described some bindings for PiKVM devices.
# Do not edit this file.
ACTION!="remove", KERNEL=="ttyACM[0-9]*", SUBSYSTEM=="tty", SUBSYSTEMS=="usb", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="eda3", SYMLINK+="kvmd-hid-bridge"
ACTION!="remove", KERNEL=="ttyACM[0-9]*", SUBSYSTEM=="tty", SUBSYSTEMS=="usb", ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="1080", SYMLINK+="kvmd-switch"
# Disable USB autosuspend for critical devices
ACTION!="remove", SUBSYSTEM=="usb", ATTR{idVendor}=="1209", ATTR{idProduct}=="eda3", GOTO="kvmd-usb"
ACTION!="remove", SUBSYSTEM=="usb", ATTR{idVendor}=="2e8a", ATTR{idProduct}=="1080", GOTO="kvmd-usb"
GOTO="end"
LABEL="kvmd-usb"
ATTR{power/control}="on", ATTR{power/autosuspend_delay_ms}="-1"
LABEL="end"
KERNEL=="ttyACM[0-9]*", SUBSYSTEM=="tty", SUBSYSTEMS=="usb", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="eda3", SYMLINK+="kvmd-hid-bridge"
KERNEL=="ttyACM[0-9]*", SUBSYSTEM=="tty", SUBSYSTEMS=="usb", ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="1080", SYMLINK+="kvmd-switch"

View File

@ -4,4 +4,3 @@ KERNEL=="video[0-9]*", SUBSYSTEM=="video4linux", PROGRAM="/usr/bin/kvmd-udev-hdm
KERNEL=="hidg0", GROUP="kvmd", SYMLINK+="kvmd-hid-keyboard"
KERNEL=="hidg1", GROUP="kvmd", SYMLINK+="kvmd-hid-mouse"
KERNEL=="hidg2", GROUP="kvmd", SYMLINK+="kvmd-hid-mouse-alt"
KERNEL=="ttyUSB0", GROUP="kvmd", SYMLINK+="kvmd-hid"

File diff suppressed because it is too large Load Diff

View File

@ -49,15 +49,13 @@ oneeighth 0x03 shift altgr
quotedbl 0x04
3 0x04 shift
numbersign 0x04 altgr
# KVMD
#sterling 0x04 shift altgr
sterling 0x04 shift altgr
# evdev 5 (0x5), QKeyCode "4", number 0x5
apostrophe 0x05
4 0x05 shift
braceleft 0x05 altgr
# KVMD
#dollar 0x05 shift altgr
dollar 0x05 shift altgr
# evdev 6 (0x6), QKeyCode "5", number 0x6
parenleft 0x06
@ -93,8 +91,7 @@ plusminus 0x0a shift altgr
agrave 0x0b
0 0x0b shift
at 0x0b altgr
# KVMD
#degree 0x0b shift altgr
degree 0x0b shift altgr
# evdev 12 (0xc), QKeyCode "minus", number 0xc
parenright 0x0c
@ -125,8 +122,7 @@ AE 0x10 shift altgr
z 0x11
Z 0x11 shift
guillemotleft 0x11 altgr
#KVMD
#less 0x11 shift altgr
less 0x11 shift altgr
# evdev 18 (0x12), QKeyCode "e", number 0x12
e 0x12
@ -204,8 +200,7 @@ Greek_OMEGA 0x1e shift altgr
s 0x1f
S 0x1f shift
ssharp 0x1f altgr
# KVMD
#section 0x1f shift altgr
section 0x1f shift altgr
# evdev 32 (0x20), QKeyCode "d", number 0x20
d 0x20
@ -252,8 +247,7 @@ Lstroke 0x26 shift altgr
# evdev 39 (0x27), QKeyCode "semicolon", number 0x27
m 0x27
M 0x27 shift
# KVMD
#mu 0x27 altgr
mu 0x27 altgr
masculine 0x27 shift altgr
# evdev 40 (0x28), QKeyCode "apostrophe", number 0x28
@ -286,8 +280,7 @@ Lstroke 0x2c shift altgr
x 0x2d
X 0x2d shift
guillemotright 0x2d altgr
# KVMD
#greater 0x2d shift altgr
greater 0x2d shift altgr
# evdev 46 (0x2e), QKeyCode "c", number 0x2e
c 0x2e

View File

@ -1,6 +0,0 @@
name: GOSTC
description: GOSTC Server
icon: share/svg/gostc.svg
path: extras/gostc
daemon: kvmd-gostc
place: 11

View File

@ -1,7 +0,0 @@
location /extras/gostc {
proxy_pass http://127.0.0.1:18080;
include /etc/kvmd/nginx/loc-proxy.conf;
include /etc/kvmd/nginx/loc-websocket.conf;
include /etc/kvmd/nginx/loc-login.conf;
include /etc/kvmd/nginx/loc-nocache.conf;
}

View File

@ -69,10 +69,9 @@ class _X11Key:
@dataclasses.dataclass(frozen=True)
class _KeyMapping:
web_name: str
evdev_name: str
mcu_code: int
usb_key: _UsbKey
ps2_key: (_Ps2Key | None)
ps2_key: _Ps2Key
at1_code: int
x11_keys: set[_X11Key]
@ -108,9 +107,7 @@ def _parse_usb_key(key: str) -> _UsbKey:
return _UsbKey(code, is_modifier)
def _parse_ps2_key(key: str) -> (_Ps2Key | None):
if ":" not in key:
return None
def _parse_ps2_key(key: str) -> _Ps2Key:
(code_type, raw_code) = key.split(":")
return _Ps2Key(
code=int(raw_code, 16),
@ -125,7 +122,6 @@ def _read_keymap_csv(path: str) -> list[_KeyMapping]:
if len(row) >= 6:
keymap.append(_KeyMapping(
web_name=row["web_name"],
evdev_name=row["evdev_name"],
mcu_code=int(row["mcu_code"]),
usb_key=_parse_usb_key(row["usb_key"]),
ps2_key=_parse_ps2_key(row["ps2_key"]),
@ -154,7 +150,6 @@ def main() -> None:
# Fields list:
# - Web
# - Linux/evdev
# - MCU code
# - USB code (^ for the modifier mask)
# - PS/2 key

View File

@ -24,8 +24,8 @@ upload:
bash -ex -c " \
current=`cat .current`; \
if [ '$($@_CURRENT)' == 'spi' ] || [ '$($@_CURRENT)' == 'aum' ]; then \
gpioset -c gpiochip0 -t 30ms,0 25=1; \
gpioset -c gpiochip0 -t 30ms,0 25=0; \
gpioset 0 25=1; \
gpioset 0 25=0; \
fi \
"
platformio run --environment '$($@_CURRENT)' --project-conf 'platformio-$($@_CONFIG).ini' --target upload

View File

@ -2,7 +2,6 @@ programmer
id = "rpi";
desc = "RPi SPI programmer";
type = "linuxspi";
prog_modes = PM_ISP;
reset = 25;
baudrate = 400000;
;

View File

@ -148,8 +148,5 @@ void keymapPs2(uint8_t code, Ps2KeyType *ps2_type, uint8_t *ps2_code) {
case 109: *ps2_type = PS2_KEY_TYPE_REG; *ps2_code = 19; return; // KanaMode
case 110: *ps2_type = PS2_KEY_TYPE_REG; *ps2_code = 100; return; // Convert
case 111: *ps2_type = PS2_KEY_TYPE_REG; *ps2_code = 103; return; // NonConvert
case 112: *ps2_type = PS2_KEY_TYPE_SPEC; *ps2_code = 35; return; // AudioVolumeMute
case 113: *ps2_type = PS2_KEY_TYPE_SPEC; *ps2_code = 50; return; // AudioVolumeUp
case 114: *ps2_type = PS2_KEY_TYPE_SPEC; *ps2_code = 33; return; // AudioVolumeDown
}
}

View File

@ -38,9 +38,7 @@ void keymapPs2(uint8_t code, Ps2KeyType *ps2_type, uint8_t *ps2_code) {
switch (code) {
% for km in sorted(keymap, key=operator.attrgetter("mcu_code")):
% if km.ps2_key is not None:
case ${km.mcu_code}: *ps2_type = PS2_KEY_TYPE_${km.ps2_key.type.upper()}; *ps2_code = ${km.ps2_key.code}; return; // ${km.web_name}
% endif
% endfor
}
}

View File

@ -136,10 +136,6 @@ uint8_t keymapUsb(uint8_t code) {
case 109: return 136; // KanaMode
case 110: return 138; // Convert
case 111: return 139; // NonConvert
case 112: return 127; // AudioVolumeMute
case 113: return 128; // AudioVolumeUp
case 114: return 129; // AudioVolumeDown
case 115: return 111; // F20
default: return 0;
}
}

View File

@ -82,6 +82,8 @@ build_flags =
-DCDC_DISABLED
upload_protocol = custom
upload_flags =
-C
$PROJECT_PACKAGES_DIR/tool-avrdude/avrdude.conf
-C
+avrdude-rpi.conf
-P

View File

@ -28,14 +28,11 @@ define libdep
endef
.pico-sdk:
$(call libdep,pico-sdk,raspberrypi/pico-sdk,6a7db34ff63345a7badec79ebea3aaef1712f374)
.pico-sdk.patches: .pico-sdk
patch -d .pico-sdk -p1 < patches/pico-sdk.patch
touch .pico-sdk.patches
.tinyusb:
$(call libdep,tinyusb,hathach/tinyusb,d713571cd44f05d2fc72efc09c670787b74106e0)
.ps2x2pico:
$(call libdep,ps2x2pico,No0ne/ps2x2pico,26ce89d597e598bb0ac636622e064202d91a9efc)
deps: .pico-sdk .pico-sdk.patches .tinyusb .ps2x2pico
deps: .pico-sdk .tinyusb .ps2x2pico
.PHONY: deps

View File

@ -1,10 +0,0 @@
diff --git a/tools/pioasm/CMakeLists.txt b/tools/pioasm/CMakeLists.txt
index 322408a..fc8e4b8 100644
--- a/tools/pioasm/CMakeLists.txt
+++ b/tools/pioasm/CMakeLists.txt
@@ -1,4 +1,4 @@
-cmake_minimum_required(VERSION 3.4)
+cmake_minimum_required(VERSION 3.5)
project(pioasm CXX)
set(CMAKE_CXX_STANDARD 11)

View File

@ -138,10 +138,6 @@ inline u8 ph_usb_keymap(u8 key) {
case 109: return 136; // KanaMode
case 110: return 138; // Convert
case 111: return 139; // NonConvert
case 112: return 127; // AudioVolumeMute
case 113: return 128; // AudioVolumeUp
case 114: return 129; // AudioVolumeDown
case 115: return 111; // F20
}
return 0;
}

View File

@ -1,116 +1,112 @@
web_name,evdev_name,mcu_code,usb_key,ps2_key,at1_code,x11_names
KeyA,KEY_A,1,0x04,reg:0x1c,0x1e,"^XK_A,XK_a"
KeyB,KEY_B,2,0x05,reg:0x32,0x30,"^XK_B,XK_b"
KeyC,KEY_C,3,0x06,reg:0x21,0x2e,"^XK_C,XK_c"
KeyD,KEY_D,4,0x07,reg:0x23,0x20,"^XK_D,XK_d"
KeyE,KEY_E,5,0x08,reg:0x24,0x12,"^XK_E,XK_e"
KeyF,KEY_F,6,0x09,reg:0x2b,0x21,"^XK_F,XK_f"
KeyG,KEY_G,7,0x0a,reg:0x34,0x22,"^XK_G,XK_g"
KeyH,KEY_H,8,0x0b,reg:0x33,0x23,"^XK_H,XK_h"
KeyI,KEY_I,9,0x0c,reg:0x43,0x17,"^XK_I,XK_i"
KeyJ,KEY_J,10,0x0d,reg:0x3b,0x24,"^XK_J,XK_j"
KeyK,KEY_K,11,0x0e,reg:0x42,0x25,"^XK_K,XK_k"
KeyL,KEY_L,12,0x0f,reg:0x4b,0x26,"^XK_L,XK_l"
KeyM,KEY_M,13,0x10,reg:0x3a,0x32,"^XK_M,XK_m"
KeyN,KEY_N,14,0x11,reg:0x31,0x31,"^XK_N,XK_n"
KeyO,KEY_O,15,0x12,reg:0x44,0x18,"^XK_O,XK_o"
KeyP,KEY_P,16,0x13,reg:0x4d,0x19,"^XK_P,XK_p"
KeyQ,KEY_Q,17,0x14,reg:0x15,0x10,"^XK_Q,XK_q"
KeyR,KEY_R,18,0x15,reg:0x2d,0x13,"^XK_R,XK_r"
KeyS,KEY_S,19,0x16,reg:0x1b,0x1f,"^XK_S,XK_s"
KeyT,KEY_T,20,0x17,reg:0x2c,0x14,"^XK_T,XK_t"
KeyU,KEY_U,21,0x18,reg:0x3c,0x16,"^XK_U,XK_u"
KeyV,KEY_V,22,0x19,reg:0x2a,0x2f,"^XK_V,XK_v"
KeyW,KEY_W,23,0x1a,reg:0x1d,0x11,"^XK_W,XK_w"
KeyX,KEY_X,24,0x1b,reg:0x22,0x2d,"^XK_X,XK_x"
KeyY,KEY_Y,25,0x1c,reg:0x35,0x15,"^XK_Y,XK_y"
KeyZ,KEY_Z,26,0x1d,reg:0x1a,0x2c,"^XK_Z,XK_z"
Digit1,KEY_1,27,0x1e,reg:0x16,0x02,"XK_1,^XK_exclam"
Digit2,KEY_2,28,0x1f,reg:0x1e,0x03,"XK_2,^XK_at"
Digit3,KEY_3,29,0x20,reg:0x26,0x04,"XK_3,^XK_numbersign"
Digit4,KEY_4,30,0x21,reg:0x25,0x05,"XK_4,^XK_dollar"
Digit5,KEY_5,31,0x22,reg:0x2e,0x06,"XK_5,^XK_percent"
Digit6,KEY_6,32,0x23,reg:0x36,0x07,"XK_6,^XK_asciicircum"
Digit7,KEY_7,33,0x24,reg:0x3d,0x08,"XK_7,^XK_ampersand"
Digit8,KEY_8,34,0x25,reg:0x3e,0x09,"XK_8,^XK_asterisk"
Digit9,KEY_9,35,0x26,reg:0x46,0x0a,"XK_9,^XK_parenleft"
Digit0,KEY_0,36,0x27,reg:0x45,0x0b,"XK_0,^XK_parenright"
Enter,KEY_ENTER,37,0x28,reg:0x5a,0x1c,XK_Return
Escape,KEY_ESC,38,0x29,reg:0x76,0x01,XK_Escape
Backspace,KEY_BACKSPACE,39,0x2a,reg:0x66,0x0e,XK_BackSpace
Tab,KEY_TAB,40,0x2b,reg:0x0d,0x0f,XK_Tab
Space,KEY_SPACE,41,0x2c,reg:0x29,0x39,XK_space
Minus,KEY_MINUS,42,0x2d,reg:0x4e,0x0c,"XK_minus,^XK_underscore"
Equal,KEY_EQUAL,43,0x2e,reg:0x55,0x0d,"XK_equal,^XK_plus"
BracketLeft,KEY_LEFTBRACE,44,0x2f,reg:0x54,0x1a,"XK_bracketleft,^XK_braceleft"
BracketRight,KEY_RIGHTBRACE,45,0x30,reg:0x5b,0x1b,"XK_bracketright,^XK_braceright"
Backslash,KEY_BACKSLASH,46,0x31,reg:0x5d,0x2b,"XK_backslash,^XK_bar"
Semicolon,KEY_SEMICOLON,47,0x33,reg:0x4c,0x27,"XK_semicolon,^XK_colon"
Quote,KEY_APOSTROPHE,48,0x34,reg:0x52,0x28,"XK_apostrophe,^XK_quotedbl"
Backquote,KEY_GRAVE,49,0x35,reg:0x0e,0x29,"XK_grave,^XK_asciitilde"
Comma,KEY_COMMA,50,0x36,reg:0x41,0x33,"XK_comma,^XK_less"
Period,KEY_DOT,51,0x37,reg:0x49,0x34,"XK_period,^XK_greater"
Slash,KEY_SLASH,52,0x38,reg:0x4a,0x35,"XK_slash,^XK_question"
CapsLock,KEY_CAPSLOCK,53,0x39,reg:0x58,0x3a,XK_Caps_Lock
F1,KEY_F1,54,0x3a,reg:0x05,0x3b,XK_F1
F2,KEY_F2,55,0x3b,reg:0x06,0x3c,XK_F2
F3,KEY_F3,56,0x3c,reg:0x04,0x3d,XK_F3
F4,KEY_F4,57,0x3d,reg:0x0c,0x3e,XK_F4
F5,KEY_F5,58,0x3e,reg:0x03,0x3f,XK_F5
F6,KEY_F6,59,0x3f,reg:0x0b,0x40,XK_F6
F7,KEY_F7,60,0x40,reg:0x83,0x41,XK_F7
F8,KEY_F8,61,0x41,reg:0x0a,0x42,XK_F8
F9,KEY_F9,62,0x42,reg:0x01,0x43,XK_F9
F10,KEY_F10,63,0x43,reg:0x09,0x44,XK_F10
F11,KEY_F11,64,0x44,reg:0x78,0x57,XK_F11
F12,KEY_F12,65,0x45,reg:0x07,0x58,XK_F12
PrintScreen,KEY_SYSRQ,66,0x46,print:0xff,0x54,XK_Sys_Req
Insert,KEY_INSERT,67,0x49,spec:0x70,0xe052,XK_Insert
Home,KEY_HOME,68,0x4a,spec:0x6c,0xe047,XK_Home
PageUp,KEY_PAGEUP,69,0x4b,spec:0x7d,0xe049,XK_Page_Up
Delete,KEY_DELETE,70,0x4c,spec:0x71,0xe053,XK_Delete
End,KEY_END,71,0x4d,spec:0x69,0xe04f,XK_End
PageDown,KEY_PAGEDOWN,72,0x4e,spec:0x7a,0xe051,XK_Page_Down
ArrowRight,KEY_RIGHT,73,0x4f,spec:0x74,0xe04d,XK_Right
ArrowLeft,KEY_LEFT,74,0x50,spec:0x6b,0xe04b,XK_Left
ArrowDown,KEY_DOWN,75,0x51,spec:0x72,0xe050,XK_Down
ArrowUp,KEY_UP,76,0x52,spec:0x75,0xe048,XK_Up
ControlLeft,KEY_LEFTCTRL,77,^0x01,reg:0x14,0x1d,XK_Control_L
ShiftLeft,KEY_LEFTSHIFT,78,^0x02,reg:0x12,0x2a,XK_Shift_L
AltLeft,KEY_LEFTALT,79,^0x04,reg:0x11,0x38,XK_Alt_L
MetaLeft,KEY_LEFTMETA,80,^0x08,spec:0x1f,0xe05b,"XK_Meta_L,XK_Super_L"
ControlRight,KEY_RIGHTCTRL,81,^0x10,spec:0x14,0xe01d,XK_Control_R
ShiftRight,KEY_RIGHTSHIFT,82,^0x20,reg:0x59,0x36,XK_Shift_R
AltRight,KEY_RIGHTALT,83,^0x40,spec:0x11,0xe038,"XK_Alt_R,XK_ISO_Level3_Shift"
MetaRight,KEY_RIGHTMETA,84,^0x80,spec:0x27,0xe05c,"XK_Meta_R,XK_Super_R"
Pause,KEY_PAUSE,85,0x48,pause:0xff,0xe046,XK_Pause
ScrollLock,KEY_SCROLLLOCK,86,0x47,reg:0x7e,0x46,XK_Scroll_Lock
NumLock,KEY_NUMLOCK,87,0x53,reg:0x77,0x45,XK_Num_Lock
ContextMenu,KEY_CONTEXT_MENU,88,0x65,spec:0x2f,0xe05d,XK_Menu
NumpadDivide,KEY_KPSLASH,89,0x54,spec:0x4a,0xe035,XK_KP_Divide
NumpadMultiply,KEY_KPASTERISK,90,0x55,reg:0x7c,0x37,XK_multiply
NumpadSubtract,KEY_KPMINUS,91,0x56,reg:0x7b,0x4a,XK_KP_Subtract
NumpadAdd,KEY_KPPLUS,92,0x57,reg:0x79,0x4e,XK_KP_Add
NumpadEnter,KEY_KPENTER,93,0x58,spec:0x5a,0xe01c,XK_KP_Enter
Numpad1,KEY_KP1,94,0x59,reg:0x69,0x4f,XK_KP_1
Numpad2,KEY_KP2,95,0x5a,reg:0x72,0x50,XK_KP_2
Numpad3,KEY_KP3,96,0x5b,reg:0x7a,0x51,XK_KP_3
Numpad4,KEY_KP4,97,0x5c,reg:0x6b,0x4b,XK_KP_4
Numpad5,KEY_KP5,98,0x5d,reg:0x73,0x4c,XK_KP_5
Numpad6,KEY_KP6,99,0x5e,reg:0x74,0x4d,XK_KP_6
Numpad7,KEY_KP7,100,0x5f,reg:0x6c,0x47,XK_KP_7
Numpad8,KEY_KP8,101,0x60,reg:0x75,0x48,XK_KP_8
Numpad9,KEY_KP9,102,0x61,reg:0x7d,0x49,XK_KP_9
Numpad0,KEY_KP0,103,0x62,reg:0x70,0x52,XK_KP_0
NumpadDecimal,KEY_KPDOT,104,0x63,reg:0x71,0x53,XK_KP_Decimal
Power,KEY_POWER,105,0x66,spec:0x5e,0xe05e,XK_XF86_Sleep
IntlBackslash,KEY_102ND,106,0x64,reg:0x61,0x56,
IntlYen,KEY_YEN,107,0x89,reg:0x6a,0x7d,
IntlRo,KEY_RO,108,0x87,reg:0x51,0x73,
KanaMode,KEY_KATAKANA,109,0x88,reg:0x13,0x70,
Convert,KEY_HENKAN,110,0x8a,reg:0x64,0x79,
NonConvert,KEY_MUHENKAN,111,0x8b,reg:0x67,0x7b,
AudioVolumeMute,KEY_MUTE,112,0x7f,spec:0x23,0xe020,
AudioVolumeUp,KEY_VOLUMEUP,113,0x80,spec:0x32,0xe030,
AudioVolumeDown,KEY_VOLUMEDOWN,114,0x81,spec:0x21,0xe02e,
F20,KEY_F20,115,0x6f,,0x5a,
web_name,mcu_code,usb_key,ps2_key,at1_code,x11_names
KeyA,1,0x04,reg:0x1c,0x1e,"^XK_A,XK_a"
KeyB,2,0x05,reg:0x32,0x30,"^XK_B,XK_b"
KeyC,3,0x06,reg:0x21,0x2e,"^XK_C,XK_c"
KeyD,4,0x07,reg:0x23,0x20,"^XK_D,XK_d"
KeyE,5,0x08,reg:0x24,0x12,"^XK_E,XK_e"
KeyF,6,0x09,reg:0x2b,0x21,"^XK_F,XK_f"
KeyG,7,0x0a,reg:0x34,0x22,"^XK_G,XK_g"
KeyH,8,0x0b,reg:0x33,0x23,"^XK_H,XK_h"
KeyI,9,0x0c,reg:0x43,0x17,"^XK_I,XK_i"
KeyJ,10,0x0d,reg:0x3b,0x24,"^XK_J,XK_j"
KeyK,11,0x0e,reg:0x42,0x25,"^XK_K,XK_k"
KeyL,12,0x0f,reg:0x4b,0x26,"^XK_L,XK_l"
KeyM,13,0x10,reg:0x3a,0x32,"^XK_M,XK_m"
KeyN,14,0x11,reg:0x31,0x31,"^XK_N,XK_n"
KeyO,15,0x12,reg:0x44,0x18,"^XK_O,XK_o"
KeyP,16,0x13,reg:0x4d,0x19,"^XK_P,XK_p"
KeyQ,17,0x14,reg:0x15,0x10,"^XK_Q,XK_q"
KeyR,18,0x15,reg:0x2d,0x13,"^XK_R,XK_r"
KeyS,19,0x16,reg:0x1b,0x1f,"^XK_S,XK_s"
KeyT,20,0x17,reg:0x2c,0x14,"^XK_T,XK_t"
KeyU,21,0x18,reg:0x3c,0x16,"^XK_U,XK_u"
KeyV,22,0x19,reg:0x2a,0x2f,"^XK_V,XK_v"
KeyW,23,0x1a,reg:0x1d,0x11,"^XK_W,XK_w"
KeyX,24,0x1b,reg:0x22,0x2d,"^XK_X,XK_x"
KeyY,25,0x1c,reg:0x35,0x15,"^XK_Y,XK_y"
KeyZ,26,0x1d,reg:0x1a,0x2c,"^XK_Z,XK_z"
Digit1,27,0x1e,reg:0x16,0x02,"XK_1,^XK_exclam"
Digit2,28,0x1f,reg:0x1e,0x03,"XK_2,^XK_at"
Digit3,29,0x20,reg:0x26,0x04,"XK_3,^XK_numbersign"
Digit4,30,0x21,reg:0x25,0x05,"XK_4,^XK_dollar"
Digit5,31,0x22,reg:0x2e,0x06,"XK_5,^XK_percent"
Digit6,32,0x23,reg:0x36,0x07,"XK_6,^XK_asciicircum"
Digit7,33,0x24,reg:0x3d,0x08,"XK_7,^XK_ampersand"
Digit8,34,0x25,reg:0x3e,0x09,"XK_8,^XK_asterisk"
Digit9,35,0x26,reg:0x46,0x0a,"XK_9,^XK_parenleft"
Digit0,36,0x27,reg:0x45,0x0b,"XK_0,^XK_parenright"
Enter,37,0x28,reg:0x5a,0x1c,XK_Return
Escape,38,0x29,reg:0x76,0x01,XK_Escape
Backspace,39,0x2a,reg:0x66,0x0e,XK_BackSpace
Tab,40,0x2b,reg:0x0d,0x0f,XK_Tab
Space,41,0x2c,reg:0x29,0x39,XK_space
Minus,42,0x2d,reg:0x4e,0x0c,"XK_minus,^XK_underscore"
Equal,43,0x2e,reg:0x55,0x0d,"XK_equal,^XK_plus"
BracketLeft,44,0x2f,reg:0x54,0x1a,"XK_bracketleft,^XK_braceleft"
BracketRight,45,0x30,reg:0x5b,0x1b,"XK_bracketright,^XK_braceright"
Backslash,46,0x31,reg:0x5d,0x2b,"XK_backslash,^XK_bar"
Semicolon,47,0x33,reg:0x4c,0x27,"XK_semicolon,^XK_colon"
Quote,48,0x34,reg:0x52,0x28,"XK_apostrophe,^XK_quotedbl"
Backquote,49,0x35,reg:0x0e,0x29,"XK_grave,^XK_asciitilde"
Comma,50,0x36,reg:0x41,0x33,"XK_comma,^XK_less"
Period,51,0x37,reg:0x49,0x34,"XK_period,^XK_greater"
Slash,52,0x38,reg:0x4a,0x35,"XK_slash,^XK_question"
CapsLock,53,0x39,reg:0x58,0x3a,XK_Caps_Lock
F1,54,0x3a,reg:0x05,0x3b,XK_F1
F2,55,0x3b,reg:0x06,0x3c,XK_F2
F3,56,0x3c,reg:0x04,0x3d,XK_F3
F4,57,0x3d,reg:0x0c,0x3e,XK_F4
F5,58,0x3e,reg:0x03,0x3f,XK_F5
F6,59,0x3f,reg:0x0b,0x40,XK_F6
F7,60,0x40,reg:0x83,0x41,XK_F7
F8,61,0x41,reg:0x0a,0x42,XK_F8
F9,62,0x42,reg:0x01,0x43,XK_F9
F10,63,0x43,reg:0x09,0x44,XK_F10
F11,64,0x44,reg:0x78,0x57,XK_F11
F12,65,0x45,reg:0x07,0x58,XK_F12
PrintScreen,66,0x46,print:0xff,0x54,XK_Sys_Req
Insert,67,0x49,spec:0x70,0xe052,XK_Insert
Home,68,0x4a,spec:0x6c,0xe047,XK_Home
PageUp,69,0x4b,spec:0x7d,0xe049,XK_Page_Up
Delete,70,0x4c,spec:0x71,0xe053,XK_Delete
End,71,0x4d,spec:0x69,0xe04f,XK_End
PageDown,72,0x4e,spec:0x7a,0xe051,XK_Page_Down
ArrowRight,73,0x4f,spec:0x74,0xe04d,XK_Right
ArrowLeft,74,0x50,spec:0x6b,0xe04b,XK_Left
ArrowDown,75,0x51,spec:0x72,0xe050,XK_Down
ArrowUp,76,0x52,spec:0x75,0xe048,XK_Up
ControlLeft,77,^0x01,reg:0x14,0x1d,XK_Control_L
ShiftLeft,78,^0x02,reg:0x12,0x2a,XK_Shift_L
AltLeft,79,^0x04,reg:0x11,0x38,XK_Alt_L
MetaLeft,80,^0x08,spec:0x1f,0xe05b,"XK_Meta_L,XK_Super_L"
ControlRight,81,^0x10,spec:0x14,0xe01d,XK_Control_R
ShiftRight,82,^0x20,reg:0x59,0x36,XK_Shift_R
AltRight,83,^0x40,spec:0x11,0xe038,"XK_Alt_R,XK_ISO_Level3_Shift"
MetaRight,84,^0x80,spec:0x27,0xe05c,"XK_Meta_R,XK_Super_R"
Pause,85,0x48,pause:0xff,0xe046,XK_Pause
ScrollLock,86,0x47,reg:0x7e,0x46,XK_Scroll_Lock
NumLock,87,0x53,reg:0x77,0x45,XK_Num_Lock
ContextMenu,88,0x65,spec:0x2f,0xe05d,XK_Menu
NumpadDivide,89,0x54,spec:0x4a,0xe035,XK_KP_Divide
NumpadMultiply,90,0x55,reg:0x7c,0x37,XK_multiply
NumpadSubtract,91,0x56,reg:0x7b,0x4a,XK_KP_Subtract
NumpadAdd,92,0x57,reg:0x79,0x4e,XK_KP_Add
NumpadEnter,93,0x58,spec:0x5a,0xe01c,XK_KP_Enter
Numpad1,94,0x59,reg:0x69,0x4f,XK_KP_1
Numpad2,95,0x5a,reg:0x72,0x50,XK_KP_2
Numpad3,96,0x5b,reg:0x7a,0x51,XK_KP_3
Numpad4,97,0x5c,reg:0x6b,0x4b,XK_KP_4
Numpad5,98,0x5d,reg:0x73,0x4c,XK_KP_5
Numpad6,99,0x5e,reg:0x74,0x4d,XK_KP_6
Numpad7,100,0x5f,reg:0x6c,0x47,XK_KP_7
Numpad8,101,0x60,reg:0x75,0x48,XK_KP_8
Numpad9,102,0x61,reg:0x7d,0x49,XK_KP_9
Numpad0,103,0x62,reg:0x70,0x52,XK_KP_0
NumpadDecimal,104,0x63,reg:0x71,0x53,XK_KP_Decimal
Power,105,0x66,spec:0x5e,0xe05e,XK_XF86_Sleep
IntlBackslash,106,0x64,reg:0x61,0x56,""
IntlYen,107,0x89,reg:0x6a,0x7d,""
IntlRo,108,0x87,reg:0x51,0x73,""
KanaMode,109,0x88,reg:0x13,0x70,""
Convert,110,0x8a,reg:0x64,0x79,""
NonConvert,111,0x8b,reg:0x67,0x7b,""

1 web_name evdev_name mcu_code usb_key ps2_key at1_code x11_names
2 KeyA KEY_A 1 0x04 reg:0x1c 0x1e ^XK_A,XK_a
3 KeyB KEY_B 2 0x05 reg:0x32 0x30 ^XK_B,XK_b
4 KeyC KEY_C 3 0x06 reg:0x21 0x2e ^XK_C,XK_c
5 KeyD KEY_D 4 0x07 reg:0x23 0x20 ^XK_D,XK_d
6 KeyE KEY_E 5 0x08 reg:0x24 0x12 ^XK_E,XK_e
7 KeyF KEY_F 6 0x09 reg:0x2b 0x21 ^XK_F,XK_f
8 KeyG KEY_G 7 0x0a reg:0x34 0x22 ^XK_G,XK_g
9 KeyH KEY_H 8 0x0b reg:0x33 0x23 ^XK_H,XK_h
10 KeyI KEY_I 9 0x0c reg:0x43 0x17 ^XK_I,XK_i
11 KeyJ KEY_J 10 0x0d reg:0x3b 0x24 ^XK_J,XK_j
12 KeyK KEY_K 11 0x0e reg:0x42 0x25 ^XK_K,XK_k
13 KeyL KEY_L 12 0x0f reg:0x4b 0x26 ^XK_L,XK_l
14 KeyM KEY_M 13 0x10 reg:0x3a 0x32 ^XK_M,XK_m
15 KeyN KEY_N 14 0x11 reg:0x31 0x31 ^XK_N,XK_n
16 KeyO KEY_O 15 0x12 reg:0x44 0x18 ^XK_O,XK_o
17 KeyP KEY_P 16 0x13 reg:0x4d 0x19 ^XK_P,XK_p
18 KeyQ KEY_Q 17 0x14 reg:0x15 0x10 ^XK_Q,XK_q
19 KeyR KEY_R 18 0x15 reg:0x2d 0x13 ^XK_R,XK_r
20 KeyS KEY_S 19 0x16 reg:0x1b 0x1f ^XK_S,XK_s
21 KeyT KEY_T 20 0x17 reg:0x2c 0x14 ^XK_T,XK_t
22 KeyU KEY_U 21 0x18 reg:0x3c 0x16 ^XK_U,XK_u
23 KeyV KEY_V 22 0x19 reg:0x2a 0x2f ^XK_V,XK_v
24 KeyW KEY_W 23 0x1a reg:0x1d 0x11 ^XK_W,XK_w
25 KeyX KEY_X 24 0x1b reg:0x22 0x2d ^XK_X,XK_x
26 KeyY KEY_Y 25 0x1c reg:0x35 0x15 ^XK_Y,XK_y
27 KeyZ KEY_Z 26 0x1d reg:0x1a 0x2c ^XK_Z,XK_z
28 Digit1 KEY_1 27 0x1e reg:0x16 0x02 XK_1,^XK_exclam
29 Digit2 KEY_2 28 0x1f reg:0x1e 0x03 XK_2,^XK_at
30 Digit3 KEY_3 29 0x20 reg:0x26 0x04 XK_3,^XK_numbersign
31 Digit4 KEY_4 30 0x21 reg:0x25 0x05 XK_4,^XK_dollar
32 Digit5 KEY_5 31 0x22 reg:0x2e 0x06 XK_5,^XK_percent
33 Digit6 KEY_6 32 0x23 reg:0x36 0x07 XK_6,^XK_asciicircum
34 Digit7 KEY_7 33 0x24 reg:0x3d 0x08 XK_7,^XK_ampersand
35 Digit8 KEY_8 34 0x25 reg:0x3e 0x09 XK_8,^XK_asterisk
36 Digit9 KEY_9 35 0x26 reg:0x46 0x0a XK_9,^XK_parenleft
37 Digit0 KEY_0 36 0x27 reg:0x45 0x0b XK_0,^XK_parenright
38 Enter KEY_ENTER 37 0x28 reg:0x5a 0x1c XK_Return
39 Escape KEY_ESC 38 0x29 reg:0x76 0x01 XK_Escape
40 Backspace KEY_BACKSPACE 39 0x2a reg:0x66 0x0e XK_BackSpace
41 Tab KEY_TAB 40 0x2b reg:0x0d 0x0f XK_Tab
42 Space KEY_SPACE 41 0x2c reg:0x29 0x39 XK_space
43 Minus KEY_MINUS 42 0x2d reg:0x4e 0x0c XK_minus,^XK_underscore
44 Equal KEY_EQUAL 43 0x2e reg:0x55 0x0d XK_equal,^XK_plus
45 BracketLeft KEY_LEFTBRACE 44 0x2f reg:0x54 0x1a XK_bracketleft,^XK_braceleft
46 BracketRight KEY_RIGHTBRACE 45 0x30 reg:0x5b 0x1b XK_bracketright,^XK_braceright
47 Backslash KEY_BACKSLASH 46 0x31 reg:0x5d 0x2b XK_backslash,^XK_bar
48 Semicolon KEY_SEMICOLON 47 0x33 reg:0x4c 0x27 XK_semicolon,^XK_colon
49 Quote KEY_APOSTROPHE 48 0x34 reg:0x52 0x28 XK_apostrophe,^XK_quotedbl
50 Backquote KEY_GRAVE 49 0x35 reg:0x0e 0x29 XK_grave,^XK_asciitilde
51 Comma KEY_COMMA 50 0x36 reg:0x41 0x33 XK_comma,^XK_less
52 Period KEY_DOT 51 0x37 reg:0x49 0x34 XK_period,^XK_greater
53 Slash KEY_SLASH 52 0x38 reg:0x4a 0x35 XK_slash,^XK_question
54 CapsLock KEY_CAPSLOCK 53 0x39 reg:0x58 0x3a XK_Caps_Lock
55 F1 KEY_F1 54 0x3a reg:0x05 0x3b XK_F1
56 F2 KEY_F2 55 0x3b reg:0x06 0x3c XK_F2
57 F3 KEY_F3 56 0x3c reg:0x04 0x3d XK_F3
58 F4 KEY_F4 57 0x3d reg:0x0c 0x3e XK_F4
59 F5 KEY_F5 58 0x3e reg:0x03 0x3f XK_F5
60 F6 KEY_F6 59 0x3f reg:0x0b 0x40 XK_F6
61 F7 KEY_F7 60 0x40 reg:0x83 0x41 XK_F7
62 F8 KEY_F8 61 0x41 reg:0x0a 0x42 XK_F8
63 F9 KEY_F9 62 0x42 reg:0x01 0x43 XK_F9
64 F10 KEY_F10 63 0x43 reg:0x09 0x44 XK_F10
65 F11 KEY_F11 64 0x44 reg:0x78 0x57 XK_F11
66 F12 KEY_F12 65 0x45 reg:0x07 0x58 XK_F12
67 PrintScreen KEY_SYSRQ 66 0x46 print:0xff 0x54 XK_Sys_Req
68 Insert KEY_INSERT 67 0x49 spec:0x70 0xe052 XK_Insert
69 Home KEY_HOME 68 0x4a spec:0x6c 0xe047 XK_Home
70 PageUp KEY_PAGEUP 69 0x4b spec:0x7d 0xe049 XK_Page_Up
71 Delete KEY_DELETE 70 0x4c spec:0x71 0xe053 XK_Delete
72 End KEY_END 71 0x4d spec:0x69 0xe04f XK_End
73 PageDown KEY_PAGEDOWN 72 0x4e spec:0x7a 0xe051 XK_Page_Down
74 ArrowRight KEY_RIGHT 73 0x4f spec:0x74 0xe04d XK_Right
75 ArrowLeft KEY_LEFT 74 0x50 spec:0x6b 0xe04b XK_Left
76 ArrowDown KEY_DOWN 75 0x51 spec:0x72 0xe050 XK_Down
77 ArrowUp KEY_UP 76 0x52 spec:0x75 0xe048 XK_Up
78 ControlLeft KEY_LEFTCTRL 77 ^0x01 reg:0x14 0x1d XK_Control_L
79 ShiftLeft KEY_LEFTSHIFT 78 ^0x02 reg:0x12 0x2a XK_Shift_L
80 AltLeft KEY_LEFTALT 79 ^0x04 reg:0x11 0x38 XK_Alt_L
81 MetaLeft KEY_LEFTMETA 80 ^0x08 spec:0x1f 0xe05b XK_Meta_L,XK_Super_L
82 ControlRight KEY_RIGHTCTRL 81 ^0x10 spec:0x14 0xe01d XK_Control_R
83 ShiftRight KEY_RIGHTSHIFT 82 ^0x20 reg:0x59 0x36 XK_Shift_R
84 AltRight KEY_RIGHTALT 83 ^0x40 spec:0x11 0xe038 XK_Alt_R,XK_ISO_Level3_Shift
85 MetaRight KEY_RIGHTMETA 84 ^0x80 spec:0x27 0xe05c XK_Meta_R,XK_Super_R
86 Pause KEY_PAUSE 85 0x48 pause:0xff 0xe046 XK_Pause
87 ScrollLock KEY_SCROLLLOCK 86 0x47 reg:0x7e 0x46 XK_Scroll_Lock
88 NumLock KEY_NUMLOCK 87 0x53 reg:0x77 0x45 XK_Num_Lock
89 ContextMenu KEY_CONTEXT_MENU 88 0x65 spec:0x2f 0xe05d XK_Menu
90 NumpadDivide KEY_KPSLASH 89 0x54 spec:0x4a 0xe035 XK_KP_Divide
91 NumpadMultiply KEY_KPASTERISK 90 0x55 reg:0x7c 0x37 XK_multiply
92 NumpadSubtract KEY_KPMINUS 91 0x56 reg:0x7b 0x4a XK_KP_Subtract
93 NumpadAdd KEY_KPPLUS 92 0x57 reg:0x79 0x4e XK_KP_Add
94 NumpadEnter KEY_KPENTER 93 0x58 spec:0x5a 0xe01c XK_KP_Enter
95 Numpad1 KEY_KP1 94 0x59 reg:0x69 0x4f XK_KP_1
96 Numpad2 KEY_KP2 95 0x5a reg:0x72 0x50 XK_KP_2
97 Numpad3 KEY_KP3 96 0x5b reg:0x7a 0x51 XK_KP_3
98 Numpad4 KEY_KP4 97 0x5c reg:0x6b 0x4b XK_KP_4
99 Numpad5 KEY_KP5 98 0x5d reg:0x73 0x4c XK_KP_5
100 Numpad6 KEY_KP6 99 0x5e reg:0x74 0x4d XK_KP_6
101 Numpad7 KEY_KP7 100 0x5f reg:0x6c 0x47 XK_KP_7
102 Numpad8 KEY_KP8 101 0x60 reg:0x75 0x48 XK_KP_8
103 Numpad9 KEY_KP9 102 0x61 reg:0x7d 0x49 XK_KP_9
104 Numpad0 KEY_KP0 103 0x62 reg:0x70 0x52 XK_KP_0
105 NumpadDecimal KEY_KPDOT 104 0x63 reg:0x71 0x53 XK_KP_Decimal
106 Power KEY_POWER 105 0x66 spec:0x5e 0xe05e XK_XF86_Sleep
107 IntlBackslash KEY_102ND 106 0x64 reg:0x61 0x56
108 IntlYen KEY_YEN 107 0x89 reg:0x6a 0x7d
109 IntlRo KEY_RO 108 0x87 reg:0x51 0x73
110 KanaMode KEY_KATAKANA 109 0x88 reg:0x13 0x70
111 Convert KEY_HENKAN 110 0x8a reg:0x64 0x79
112 NonConvert KEY_MUHENKAN 111 0x8b reg:0x67 0x7b
AudioVolumeMute KEY_MUTE 112 0x7f spec:0x23 0xe020
AudioVolumeUp KEY_VOLUMEUP 113 0x80 spec:0x32 0xe030
AudioVolumeDown KEY_VOLUMEDOWN 114 0x81 spec:0x21 0xe02e
F20 KEY_F20 115 0x6f 0x5a

View File

@ -112,13 +112,6 @@ EOF
cp /usr/share/kvmd/configs.default/janus/janus.plugin.ustreamer.jcfg /etc/kvmd/janus || true
fi
if [[ "$(vercmp "$2" 4.60)" -lt 0 ]]; then
if grep -q "^dtoverlay=vc4-kms-v3d" /boot/config.txt; then
sed -i -e "s/cma=128M/cma=192M/g" /boot/cmdline.txt || true
sed -i -e "s/^gpu_mem=128/gpu_mem=192/g" /boot/config.txt || true
fi
fi
# Some update deletes /etc/motd, WTF
# shellcheck disable=SC2015,SC2166
[ ! -f /etc/motd -a -f /etc/motd.pacsave ] && mv /etc/motd.pacsave /etc/motd || true

View File

@ -20,4 +20,4 @@
# ========================================================================== #
__version__ = "4.94"
__version__ = "4.49"

View File

@ -23,7 +23,6 @@
import asyncio
import threading
import dataclasses
import typing
import gpiod
@ -102,10 +101,10 @@ class AioReader: # pylint: disable=too-many-instance-attributes
if line_req.wait_edge_events(1):
new: dict[int, bool] = {}
for event in line_req.read_edge_events():
(pin, state) = self.__parse_event(event)
new[pin] = state
for (pin, state) in new.items():
self.__values[pin].set(state)
(pin, value) = self.__parse_event(event)
new[pin] = value
for (pin, value) in new.items():
self.__values[pin].set(value)
else: # Timeout
# XXX: Лимит был актуален для 1.6. Надо проверить, поменялось ли это в 2.x.
# Размер буфера ядра - 16 эвентов на линии. При превышении этого числа,
@ -115,12 +114,11 @@ class AioReader: # pylint: disable=too-many-instance-attributes
self.__values[pin].set(bool(value.value)) # type: ignore
def __parse_event(self, event: gpiod.EdgeEvent) -> tuple[int, bool]:
match event.event_type:
case event.Type.RISING_EDGE:
return (event.line_offset, True)
case event.Type.FALLING_EDGE:
return (event.line_offset, False)
typing.assert_never(event.event_type)
if event.event_type == event.Type.RISING_EDGE:
return (event.line_offset, True)
elif event.event_type == event.Type.FALLING_EDGE:
return (event.line_offset, False)
raise RuntimeError(f"Invalid event {event} type: {event.type}")
class _DebouncedValue:

View File

@ -211,18 +211,6 @@ async def wait_first(*aws: asyncio.Task) -> tuple[set[asyncio.Task], set[asyncio
return (await asyncio.wait(list(aws), return_when=asyncio.FIRST_COMPLETED))
# =====
async def spawn_and_follow(*coros: Coroutine) -> None:
tasks: list[asyncio.Task] = list(map(asyncio.create_task, coros))
try:
await asyncio.gather(*tasks)
except Exception:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
raise
# =====
async def close_writer(writer: asyncio.StreamWriter) -> bool:
closing = writer.is_closing()

View File

@ -65,7 +65,6 @@ from ..validators.basic import valid_string_list
from ..validators.auth import valid_user
from ..validators.auth import valid_users_list
from ..validators.auth import valid_expire
from ..validators.os import valid_abs_path
from ..validators.os import valid_abs_file
@ -74,14 +73,12 @@ from ..validators.os import valid_unix_mode
from ..validators.os import valid_options
from ..validators.os import valid_command
from ..validators.net import valid_ip
from ..validators.net import valid_ip_or_host
from ..validators.net import valid_net
from ..validators.net import valid_port
from ..validators.net import valid_ports_list
from ..validators.net import valid_mac
from ..validators.net import valid_ssl_ciphers
from ..validators.net import valid_ice_servers
from ..validators.hid import valid_hid_key
from ..validators.hid import valid_hid_mouse_output
@ -193,14 +190,6 @@ def _init_config(config_path: str, override_options: list[str], **load_flags: bo
def _patch_raw(raw_config: dict) -> None: # pylint: disable=too-many-branches
for (sub, cmd) in [("iface", "ip_cmd"), ("firewall", "iptables_cmd")]:
if isinstance(raw_config.get("otgnet"), dict):
if isinstance(raw_config["otgnet"].get(sub), dict):
if raw_config["otgnet"][sub].get(cmd):
raw_config["otgnet"].setdefault("commands", {})
raw_config["otgnet"]["commands"][cmd] = raw_config["otgnet"][sub][cmd]
del raw_config["otgnet"][sub][cmd]
if isinstance(raw_config.get("otg"), dict):
for (old, new) in [
("msd", "msd"),
@ -368,12 +357,6 @@ def _get_config_scheme() -> dict:
"auth": {
"enabled": Option(True, type=valid_bool),
"expire": Option(0, type=valid_expire),
"usc": {
"users": Option([], type=valid_users_list), # PiKVM username has a same regex as a UNIX username
"groups": Option(["kvmd-selfauth"], type=valid_users_list), # groupname has a same regex as a username
},
"internal": {
"type": Option("htpasswd"),
@ -474,7 +457,7 @@ def _get_config_scheme() -> dict:
"unix": Option("/run/kvmd/ustreamer.sock", type=valid_abs_path, unpack_as="unix_path"),
"timeout": Option(2.0, type=valid_float_f01),
"snapshot_timeout": Option(5.0, type=valid_float_f01), # error_delay * 3 + 1
"snapshot_timeout": Option(1.0, type=valid_float_f01), # error_delay * 3 + 1
"process_name_prefix": Option("kvmd/streamer"),
@ -521,9 +504,8 @@ def _get_config_scheme() -> dict:
},
"switch": {
"device": Option("/dev/kvmd-switch", type=valid_abs_path, unpack_as="device_path"),
"default_edid": Option("/etc/kvmd/switch-edid.hex", type=valid_abs_path, unpack_as="default_edid_path"),
"ignore_hpd_on_top": Option(False, type=valid_bool),
"device": Option("/dev/kvmd-switch", type=valid_abs_path, unpack_as="device_path"),
"default_edid": Option("/etc/kvmd/switch-edid.hex", type=valid_abs_path, unpack_as="default_edid_path"),
},
},
@ -576,15 +558,15 @@ def _get_config_scheme() -> dict:
"vendor_id": Option(0x1D6B, type=valid_otg_id), # Linux Foundation
"product_id": Option(0x0104, type=valid_otg_id), # Multifunction Composite Gadget
"manufacturer": Option("PiKVM", type=valid_stripped_string),
"product": Option("PiKVM Composite Device", type=valid_stripped_string),
"product": Option("Composite KVM Device", type=valid_stripped_string),
"serial": Option("CAFEBABE", type=valid_stripped_string, if_none=None),
"config": Option("", type=valid_stripped_string),
"device_version": Option(-1, type=functools.partial(valid_number, min=-1, max=0xFFFF)),
"usb_version": Option(0x0200, type=valid_otg_id),
"max_power": Option(250, type=functools.partial(valid_number, min=50, max=500)),
"remote_wakeup": Option(True, type=valid_bool),
"gadget": Option("kvmd", type=valid_otg_gadget),
"config": Option("PiKVM device", type=valid_stripped_string_not_empty),
"udc": Option("", type=valid_stripped_string),
"endpoints": Option(9, type=valid_int_f0),
"init_delay": Option(3.0, type=valid_float_f01),
@ -675,7 +657,8 @@ def _get_config_scheme() -> dict:
"otgnet": {
"iface": {
"net": Option("172.30.30.0/24", type=functools.partial(valid_net, v6=False)),
"net": Option("172.30.30.0/24", type=functools.partial(valid_net, v6=False)),
"ip_cmd": Option(["/usr/bin/ip"], type=valid_command),
},
"firewall": {
@ -683,13 +666,10 @@ def _get_config_scheme() -> dict:
"allow_tcp": Option([], type=valid_ports_list),
"allow_udp": Option([67], type=valid_ports_list),
"forward_iface": Option("", type=valid_stripped_string),
"iptables_cmd": Option(["/usr/sbin/iptables", "--wait=5"], type=valid_command),
},
"commands": {
"ip_cmd": Option(["/usr/bin/ip"], type=valid_command),
"iptables_cmd": Option(["/usr/sbin/iptables", "--wait=5"], type=valid_command),
"sysctl_cmd": Option(["/usr/sbin/sysctl"], type=valid_command),
"pre_start_cmd": Option(["/bin/true", "pre-start"], type=valid_command),
"pre_start_cmd_remove": Option([], type=valid_options),
"pre_start_cmd_append": Option([], type=valid_options),
@ -754,7 +734,7 @@ def _get_config_scheme() -> dict:
"desired_fps": Option(30, type=valid_stream_fps),
"mouse_output": Option("usb", type=valid_hid_mouse_output),
"keymap": Option("/usr/share/kvmd/keymaps/en-us", type=valid_abs_file),
"scroll_rate": Option(4, type=functools.partial(valid_number, min=1, max=30)),
"allow_cut_after": Option(3.0, type=valid_float_f0),
"server": {
"host": Option("", type=valid_ip_or_host, if_empty=""),
@ -806,8 +786,8 @@ def _get_config_scheme() -> dict:
"auth": {
"vncauth": {
"enabled": Option(False, type=valid_bool, unpack_as="vncpass_enabled"),
"file": Option("/etc/kvmd/vncpasswd", type=valid_abs_file, unpack_as="vncpass_path"),
"enabled": Option(False, type=valid_bool),
"file": Option("/etc/kvmd/vncpasswd", type=valid_abs_file, unpack_as="path"),
},
"vencrypt": {
"enabled": Option(True, type=valid_bool, unpack_as="vencrypt_enabled"),
@ -815,24 +795,13 @@ def _get_config_scheme() -> dict:
},
},
"localhid": {
"kvmd": {
"unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"),
"timeout": Option(5.0, type=valid_float_f01),
},
},
"nginx": {
"http": {
"ipv4": Option("0.0.0.0", type=functools.partial(valid_ip, v6=False)),
"ipv6": Option("::", type=functools.partial(valid_ip, v4=False)),
"port": Option(80, type=valid_port),
"port": Option(80, type=valid_port),
},
"https": {
"enabled": Option(True, type=valid_bool),
"ipv4": Option("0.0.0.0", type=functools.partial(valid_ip, v6=False)),
"ipv6": Option("::", type=functools.partial(valid_ip, v4=False)),
"port": Option(443, type=valid_port),
"enabled": Option(True, type=valid_bool),
"port": Option(443, type=valid_port),
},
},
@ -861,7 +830,6 @@ def _get_config_scheme() -> dict:
], type=valid_command),
"cmd_remove": Option([], type=valid_options),
"cmd_append": Option([], type=valid_options),
"local_ice_servers": Option([], type=valid_ice_servers, unpack_as="ice_servers"),
},
"watchdog": {

View File

@ -61,33 +61,6 @@ def _print_edid(edid: Edid) -> None:
pass
def _find_out2_edid_path() -> str:
card = os.path.basename(os.readlink("/dev/dri/by-path/platform-gpu-card"))
path = f"/sys/devices/platform/gpu/drm/{card}/{card}-HDMI-A-2"
with open(os.path.join(path, "status")) as file:
if file.read().startswith("d"):
raise SystemExit("No display found")
return os.path.join(path, "edid")
def _adopt_out2_ids(dest: Edid) -> None:
src = Edid.from_file(_find_out2_edid_path())
dest.set_monitor_name(src.get_monitor_name())
try:
dest.get_monitor_serial()
except EdidNoBlockError:
pass
else:
try:
ser = src.get_monitor_serial()
except EdidNoBlockError:
ser = "{:08X}".format(src.get_serial())
dest.set_monitor_serial(ser)
dest.set_mfc_id(src.get_mfc_id())
dest.set_product_id(src.get_product_id())
dest.set_serial(src.get_serial())
# =====
def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-branches,too-many-statements
# (parent_parser, argv, _) = init(
@ -116,10 +89,6 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra
help="Import the specified bin/hex EDID to the [--edid] file as a hex text", metavar="<file>")
parser.add_argument("--import-preset", choices=presets,
help="Restore default EDID or choose the preset", metavar=f"{{ {' | '.join(presets)} }}",)
parser.add_argument("--import-display-ids", action="store_true",
help="On PiKVM V4, import and adopt IDs from a physical display connected to the OUT2 port")
parser.add_argument("--import-display", action="store_true",
help="On PiKVM V4, import full EDID from a physical display connected to the OUT2 port")
parser.add_argument("--set-audio", type=valid_bool,
help="Enable or disable audio", metavar="<yes|no>")
parser.add_argument("--set-mfc-id",
@ -151,9 +120,6 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra
imp = f"_{imp}"
options.imp = os.path.join(options.presets_path, f"{imp}.hex")
if options.import_display:
options.imp = _find_out2_edid_path()
orig_edid_path = options.edid_path
if options.imp:
options.export_hex = options.edid_path
@ -162,10 +128,6 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra
edid = Edid.from_file(options.edid_path)
changed = False
if options.import_display_ids:
_adopt_out2_ids(edid)
changed = True
for cmd in dir(Edid):
if cmd.startswith("set_"):
value = getattr(options, cmd)

View File

@ -30,27 +30,27 @@ import argparse
from typing import Generator
import passlib.apache
from ...yamlconf import Section
from ...validators import ValidatorError
from ...validators.auth import valid_user
from ...validators.auth import valid_passwd
from ...crypto import KvmdHtpasswdFile
from .. import init
# =====
def _get_htpasswd_path(config: Section) -> str:
if config.kvmd.auth.internal.type != "htpasswd":
raise SystemExit(f"Error: KVMD internal auth does not use 'htpasswd'"
raise SystemExit(f"Error: KVMD internal auth not using 'htpasswd'"
f" (now configured {config.kvmd.auth.internal.type!r})")
return config.kvmd.auth.internal.file
@contextlib.contextmanager
def _get_htpasswd_for_write(config: Section) -> Generator[KvmdHtpasswdFile, None, None]:
def _get_htpasswd_for_write(config: Section) -> Generator[passlib.apache.HtpasswdFile, None, None]:
path = _get_htpasswd_path(config)
(tmp_fd, tmp_path) = tempfile.mkstemp(
prefix=f".{os.path.basename(path)}.",
@ -65,7 +65,7 @@ def _get_htpasswd_for_write(config: Section) -> Generator[KvmdHtpasswdFile, None
os.fchmod(tmp_fd, st.st_mode)
finally:
os.close(tmp_fd)
htpasswd = KvmdHtpasswdFile(tmp_path)
htpasswd = passlib.apache.HtpasswdFile(tmp_path)
yield htpasswd
htpasswd.save()
os.rename(tmp_path, path)
@ -96,55 +96,28 @@ def _print_invalidate_tip(prepend_nl: bool) -> None:
# ====
def _cmd_list(config: Section, _: argparse.Namespace) -> None:
for user in sorted(KvmdHtpasswdFile(_get_htpasswd_path(config)).users()):
for user in sorted(passlib.apache.HtpasswdFile(_get_htpasswd_path(config)).users()):
print(user)
def _change_user(config: Section, options: argparse.Namespace, create: bool) -> None:
def _cmd_set(config: Section, options: argparse.Namespace) -> None:
with _get_htpasswd_for_write(config) as htpasswd:
assert options.user == options.user.strip()
assert options.user
has_user = (options.user in htpasswd.users())
if create:
if has_user:
raise SystemExit(f"The user {options.user!r} is already exists")
else:
if not has_user:
raise SystemExit(f"The user {options.user!r} is not exist")
if options.read_stdin:
passwd = valid_passwd(input())
else:
passwd = valid_passwd(getpass.getpass("Password: ", stream=sys.stderr))
if valid_passwd(getpass.getpass("Repeat: ", stream=sys.stderr)) != passwd:
raise SystemExit("Sorry, passwords do not match")
htpasswd.set_password(options.user, passwd)
if has_user and not options.quiet:
_print_invalidate_tip(True)
def _cmd_add(config: Section, options: argparse.Namespace) -> None:
_change_user(config, options, create=True)
def _cmd_set(config: Section, options: argparse.Namespace) -> None:
_change_user(config, options, create=False)
def _cmd_delete(config: Section, options: argparse.Namespace) -> None:
with _get_htpasswd_for_write(config) as htpasswd:
assert options.user == options.user.strip()
assert options.user
has_user = (options.user in htpasswd.users())
if not has_user:
raise SystemExit(f"The user {options.user!r} is not exist")
htpasswd.delete(options.user)
if has_user and not options.quiet:
_print_invalidate_tip(False)
@ -165,25 +138,19 @@ def main(argv: (list[str] | None)=None) -> None:
parser.set_defaults(cmd=(lambda *_: parser.print_help()))
subparsers = parser.add_subparsers()
sub = subparsers.add_parser("list", help="List users")
sub.set_defaults(cmd=_cmd_list)
cmd_list_parser = subparsers.add_parser("list", help="List users")
cmd_list_parser.set_defaults(cmd=_cmd_list)
sub = subparsers.add_parser("add", help="Add user")
sub.add_argument("user", type=valid_user)
sub.add_argument("-i", "--read-stdin", action="store_true", help="Read password from stdin")
sub.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note")
sub.set_defaults(cmd=_cmd_add)
cmd_set_parser = subparsers.add_parser("set", help="Create user or change password")
cmd_set_parser.add_argument("user", type=valid_user)
cmd_set_parser.add_argument("-i", "--read-stdin", action="store_true", help="Read password from stdin")
cmd_set_parser.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note")
cmd_set_parser.set_defaults(cmd=_cmd_set)
sub = subparsers.add_parser("set", help="Change user's password")
sub.add_argument("user", type=valid_user)
sub.add_argument("-i", "--read-stdin", action="store_true", help="Read password from stdin")
sub.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note")
sub.set_defaults(cmd=_cmd_set)
sub = subparsers.add_parser("del", help="Delete user")
sub.add_argument("user", type=valid_user)
sub.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note")
sub.set_defaults(cmd=_cmd_delete)
cmd_delete_parser = subparsers.add_parser("del", help="Delete user")
cmd_delete_parser.add_argument("user", type=valid_user)
cmd_delete_parser.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note")
cmd_delete_parser.set_defaults(cmd=_cmd_delete)
options = parser.parse_args(argv[1:])
try:

View File

@ -20,13 +20,7 @@
# ========================================================================== #
import threading
import functools
import time
from ...logging import get_logger
from ... import tools
import dataclasses
# =====
@ -35,42 +29,60 @@ class IpmiPasswdError(Exception):
super().__init__(f"Syntax error at {path}:{lineno}: {msg}")
@dataclasses.dataclass(frozen=True)
class IpmiUserCredentials:
ipmi_user: str
ipmi_passwd: str
kvmd_user: str
kvmd_passwd: str
class IpmiAuthManager:
def __init__(self, path: str) -> None:
self.__path = path
self.__lock = threading.Lock()
with open(path) as file:
self.__credentials = self.__parse_passwd_file(file.read().split("\n"))
def get(self, user: str) -> (str | None):
creds = self.__get_credentials(int(time.time()))
return creds.get(user)
def __contains__(self, ipmi_user: str) -> bool:
return (ipmi_user in self.__credentials)
@functools.lru_cache(maxsize=1)
def __get_credentials(self, ts: int) -> dict[str, str]:
_ = ts
with self.__lock:
try:
return self.__read_credentials()
except Exception as ex:
get_logger().error("%s", tools.efmt(ex))
return {}
def __getitem__(self, ipmi_user: str) -> str:
return self.__credentials[ipmi_user].ipmi_passwd
def __read_credentials(self) -> dict[str, str]:
with open(self.__path) as file:
creds: dict[str, str] = {}
for (lineno, line) in tools.passwds_splitted(file.read()):
if " -> " in line: # Compatibility with old ipmipasswd file format
line = line.split(" -> ", 1)[0]
def get_credentials(self, ipmi_user: str) -> IpmiUserCredentials:
return self.__credentials[ipmi_user]
if ":" not in line:
raise IpmiPasswdError(self.__path, lineno, "Missing ':' operator")
def __parse_passwd_file(self, lines: list[str]) -> dict[str, IpmiUserCredentials]:
credentials: dict[str, IpmiUserCredentials] = {}
for (lineno, line) in enumerate(lines):
if len(line.strip()) == 0 or line.lstrip().startswith("#"):
continue
(user, passwd) = line.split(":", 1)
user = user.strip()
if len(user) == 0:
raise IpmiPasswdError(self.__path, lineno, "Empty IPMI user")
if " -> " not in line:
raise IpmiPasswdError(self.__path, lineno, "Missing ' -> ' operator")
if user in creds:
raise IpmiPasswdError(self.__path, lineno, f"Found duplicating user {user!r}")
(left, right) = map(str.lstrip, line.split(" -> ", 1))
for (name, pair) in [("left", left), ("right", right)]:
if ":" not in pair:
raise IpmiPasswdError(self.__path, lineno, f"Missing ':' operator in {name} credentials")
creds[user] = passwd
return creds
(ipmi_user, ipmi_passwd) = left.split(":")
ipmi_user = ipmi_user.strip()
if len(ipmi_user) == 0:
raise IpmiPasswdError(self.__path, lineno, "Empty IPMI user (left)")
(kvmd_user, kvmd_passwd) = right.split(":")
kvmd_user = kvmd_user.strip()
if len(kvmd_user) == 0:
raise IpmiPasswdError(self.__path, lineno, "Empty KVMD user (left)")
if ipmi_user in credentials:
raise IpmiPasswdError(self.__path, lineno, f"Found duplicating user {ipmi_user!r} (left)")
credentials[ipmi_user] = IpmiUserCredentials(
ipmi_user=ipmi_user,
ipmi_passwd=ipmi_passwd,
kvmd_user=kvmd_user,
kvmd_passwd=kvmd_passwd,
)
return credentials

View File

@ -70,6 +70,7 @@ class IpmiServer(BaseIpmiServer): # pylint: disable=too-many-instance-attribute
super().__init__(authdata=auth_manager, address=host, port=port)
self.__auth_manager = auth_manager
self.__kvmd = kvmd
self.__host = host
@ -164,10 +165,11 @@ class IpmiServer(BaseIpmiServer): # pylint: disable=too-many-instance-attribute
def __make_request(self, session: IpmiServerSession, name: str, func_path: str, **kwargs): # type: ignore
async def runner(): # type: ignore
logger = get_logger(0)
logger.info("[%s]: Performing request %s from IPMI user %r ...",
session.sockaddr[0], name, session.username.decode())
credentials = self.__auth_manager.get_credentials(session.username.decode())
logger.info("[%s]: Performing request %s from user %r (IPMI) as %r (KVMD)",
session.sockaddr[0], name, credentials.ipmi_user, credentials.kvmd_user)
try:
async with self.__kvmd.make_session() as kvmd_session:
async with self.__kvmd.make_session(credentials.kvmd_user, credentials.kvmd_passwd) as kvmd_session:
func = functools.reduce(getattr, func_path.split("."), kvmd_session)
return (await func(**kwargs))
except (aiohttp.ClientError, asyncio.TimeoutError) as ex:

View File

@ -2,8 +2,6 @@ import asyncio
import asyncio.subprocess
import socket
import dataclasses
import json
from typing import Any
import netifaces
@ -23,7 +21,6 @@ class _Netcfg:
nat_type: StunNatType = dataclasses.field(default=StunNatType.ERROR)
src_ip: str = dataclasses.field(default="")
ext_ip: str = dataclasses.field(default="")
stun_host: str = dataclasses.field(default="")
stun_ip: str = dataclasses.field(default="")
stun_port: int = dataclasses.field(default=0)
@ -45,7 +42,6 @@ class JanusRunner: # pylint: disable=too-many-instance-attributes
cmd: list[str],
cmd_remove: list[str],
cmd_append: list[str],
ice_servers: list[dict[str, Any]],
) -> None:
self.__stun = Stun(stun_host, stun_port, stun_timeout, stun_retries, stun_retries_delay)
@ -55,7 +51,6 @@ class JanusRunner: # pylint: disable=too-many-instance-attributes
self.__check_retries_delay = check_retries_delay
self.__cmd = tools.build_cmd(cmd, cmd_remove, cmd_append)
self.__ice_servers = ice_servers
self.__janus_task: (asyncio.Task | None) = None
self.__janus_proc: (asyncio.subprocess.Process | None) = None # pylint: disable=no-member
@ -177,25 +172,10 @@ class JanusRunner: # pylint: disable=too-many-instance-attributes
part.format(**placeholders)
for part in cmd
]
env = {}
ice_payload = self.__build_ice_payload(netcfg)
if ice_payload:
env["JANUS_USTREAMER_WEB_ICE_URL"] = ice_payload
self.__janus_proc = await aioproc.run_process(cmd=cmd, env=env or None)
self.__janus_proc = await aioproc.run_process(cmd)
get_logger(0).info("Started Janus pid=%d: %s", self.__janus_proc.pid, tools.cmdfmt(cmd))
async def __kill_janus_proc(self) -> None:
if self.__janus_proc:
await aioproc.kill_process(self.__janus_proc, 5, get_logger(0))
self.__janus_proc = None
def __build_ice_payload(self, netcfg: _Netcfg) -> (str | None):
if self.__ice_servers:
try:
return f"json:{json.dumps(self.__ice_servers, ensure_ascii=False)}"
except Exception as ex: # pragma: no cover
get_logger(0).error("Can't encode ICE servers: %s", tools.efmt(ex))
return None
if netcfg.stun_host and netcfg.stun_port:
return f"stun:{netcfg.stun_host}:{netcfg.stun_port}"
return None

View File

@ -30,7 +30,6 @@ class StunInfo:
nat_type: StunNatType
src_ip: str
ext_ip: str
stun_host: str
stun_ip: str
stun_port: int
@ -103,7 +102,6 @@ class Stun:
nat_type=nat_type,
src_ip=src_ip,
ext_ip=ext_ip,
stun_host=self.__host,
stun_ip=self.__stun_ip,
stun_port=self.__port,
)
@ -136,12 +134,7 @@ class Stun:
return (StunNatType.FULL_CONE_NAT, resp)
if first.changed is None:
get_logger(0).warning(
"STUN server %s:%d responded without CHANGED-ADDRESS; skipping NAT type detection",
self.__host,
self.__port,
)
return (StunNatType.ERROR, first)
raise RuntimeError(f"Changed addr is None: {first}")
resp = await self.__make_request("Change request [ext_ip != src_ip]", first.changed, b"")
if not resp.ok:
return (StunNatType.CHANGED_ADDR_ERROR, resp)

View File

@ -76,17 +76,14 @@ def main(argv: (list[str] | None)=None) -> None:
KvmdServer(
auth_manager=AuthManager(
enabled=config.auth.enabled,
expire=config.auth.expire,
usc_users=config.auth.usc.users,
usc_groups=config.auth.usc.groups,
unauth_paths=([] if config.prometheus.auth.enabled else ["/export/prometheus/metrics"]),
int_type=config.auth.internal.type,
int_kwargs=config.auth.internal._unpack(ignore=["type", "force_users"]),
force_int_users=config.auth.internal.force_users,
internal_type=config.auth.internal.type,
internal_kwargs=config.auth.internal._unpack(ignore=["type", "force_users"]),
force_internal_users=config.auth.internal.force_users,
ext_type=config.auth.external.type,
ext_kwargs=(config.auth.external._unpack(ignore=["type"]) if config.auth.external.type else {}),
external_type=config.auth.external.type,
external_kwargs=(config.auth.external._unpack(ignore=["type"]) if config.auth.external.type else {}),
totp_secret_path=config.auth.totp.secret.file,
),

View File

@ -31,11 +31,9 @@ from ....htserver import HttpExposed
from ....htserver import exposed_http
from ....htserver import make_json_response
from ....htserver import set_request_auth_info
from ....htserver import get_request_unix_credentials
from ....validators.auth import valid_user
from ....validators.auth import valid_passwd
from ....validators.auth import valid_expire
from ....validators.auth import valid_auth_token
from ..auth import AuthManager
@ -45,64 +43,39 @@ from ..auth import AuthManager
_COOKIE_AUTH_TOKEN = "auth_token"
async def _check_xhdr(auth_manager: AuthManager, _: HttpExposed, req: Request) -> bool:
user = req.headers.get("X-KVMD-User", "")
if user:
user = valid_user(user)
passwd = req.headers.get("X-KVMD-Passwd", "")
set_request_auth_info(req, f"{user} (xhdr)")
if (await auth_manager.authorize(user, valid_passwd(passwd))):
return True
raise ForbiddenError()
return False
async def _check_token(auth_manager: AuthManager, _: HttpExposed, req: Request) -> bool:
token = req.cookies.get(_COOKIE_AUTH_TOKEN, "")
if token:
user = auth_manager.check(valid_auth_token(token))
if user:
set_request_auth_info(req, f"{user} (token)")
return True
set_request_auth_info(req, "- (token)")
raise ForbiddenError()
return False
async def _check_basic(auth_manager: AuthManager, _: HttpExposed, req: Request) -> bool:
basic_auth = req.headers.get("Authorization", "")
if basic_auth and basic_auth[:6].lower() == "basic ":
try:
(user, passwd) = base64.b64decode(basic_auth[6:]).decode("utf-8").split(":")
except Exception:
raise UnauthorizedError()
user = valid_user(user)
set_request_auth_info(req, f"{user} (basic)")
if (await auth_manager.authorize(user, valid_passwd(passwd))):
return True
raise ForbiddenError()
return False
async def _check_usc(auth_manager: AuthManager, exposed: HttpExposed, req: Request) -> bool:
if exposed.allow_usc:
creds = get_request_unix_credentials(req)
if creds is not None:
user = auth_manager.check_unix_credentials(creds)
if user:
set_request_auth_info(req, f"{user}[{creds.uid}] (unix)")
return True
raise UnauthorizedError()
return False
async def check_request_auth(auth_manager: AuthManager, exposed: HttpExposed, req: Request) -> None:
if not auth_manager.is_auth_required(exposed):
return
for checker in [_check_xhdr, _check_token, _check_basic, _check_usc]:
if (await checker(auth_manager, exposed, req)):
if auth_manager.is_auth_required(exposed):
user = req.headers.get("X-KVMD-User", "")
if user:
user = valid_user(user)
passwd = req.headers.get("X-KVMD-Passwd", "")
set_request_auth_info(req, f"{user} (xhdr)")
if not (await auth_manager.authorize(user, valid_passwd(passwd))):
raise ForbiddenError()
return
raise UnauthorizedError()
token = req.cookies.get(_COOKIE_AUTH_TOKEN, "")
if token:
user = auth_manager.check(valid_auth_token(token)) # type: ignore
if not user:
set_request_auth_info(req, "- (token)")
raise ForbiddenError()
set_request_auth_info(req, f"{user} (token)")
return
basic_auth = req.headers.get("Authorization", "")
if basic_auth and basic_auth[:6].lower() == "basic ":
try:
(user, passwd) = base64.b64decode(basic_auth[6:]).decode("utf-8").split(":")
except Exception:
raise UnauthorizedError()
user = valid_user(user)
set_request_auth_info(req, f"{user} (basic)")
if not (await auth_manager.authorize(user, valid_passwd(passwd))):
raise ForbiddenError()
return
raise UnauthorizedError()
class AuthApi:
@ -111,28 +84,26 @@ class AuthApi:
# =====
@exposed_http("POST", "/auth/login", auth_required=False, allow_usc=False)
@exposed_http("POST", "/auth/login", auth_required=False)
async def __login_handler(self, req: Request) -> Response:
if self.__auth_manager.is_auth_enabled():
credentials = await req.post()
token = await self.__auth_manager.login(
user=valid_user(credentials.get("user", "")),
passwd=valid_passwd(credentials.get("passwd", "")),
expire=valid_expire(credentials.get("expire", "0")),
)
if token:
return make_json_response(set_cookies={_COOKIE_AUTH_TOKEN: token})
raise ForbiddenError()
return make_json_response()
@exposed_http("POST", "/auth/logout", allow_usc=False)
@exposed_http("POST", "/auth/logout")
async def __logout_handler(self, req: Request) -> Response:
if self.__auth_manager.is_auth_enabled():
token = valid_auth_token(req.cookies.get(_COOKIE_AUTH_TOKEN, ""))
self.__auth_manager.logout(token)
return make_json_response()
# XXX: This handle is used for access control so it should NEVER allow access by socket credentials
@exposed_http("GET", "/auth/check", allow_usc=False)
@exposed_http("GET", "/auth/check")
async def __check_handler(self, _: Request) -> Response:
return make_json_response()

View File

@ -21,7 +21,6 @@
import asyncio
import re
from typing import Any
@ -58,7 +57,7 @@ class ExportApi:
async def __get_prometheus_metrics(self) -> str:
(atx_state, info_state, gpio_state) = await asyncio.gather(*[
self.__atx.get_state(),
self.__info_manager.get_state(["health", "fan"]),
self.__info_manager.get_state(["hw", "fan"]),
self.__user_gpio.get_state(),
])
rows: list[str] = []
@ -69,11 +68,10 @@ class ExportApi:
for mode in sorted(UserGpioModes.ALL):
for (channel, ch_state) in gpio_state["state"][f"{mode}s"].items(): # type: ignore
if not channel.startswith("__"): # Hide special GPIOs
channel = re.sub(r"[^\w]", "_", channel)
for key in ["online", "state"]:
self.__append_prometheus_rows(rows, ch_state["state"], f"pikvm_gpio_{mode}_{key}_{channel}")
self.__append_prometheus_rows(rows, info_state["health"], "pikvm_hw") # type: ignore
self.__append_prometheus_rows(rows, info_state["hw"]["health"], "pikvm_hw") # type: ignore
self.__append_prometheus_rows(rows, info_state["fan"], "pikvm_fan")
return "\n".join(rows)

View File

@ -23,7 +23,6 @@
import os
import stat
import functools
import itertools
import struct
from typing import Iterable
@ -32,11 +31,8 @@ from typing import Callable
from aiohttp.web import Request
from aiohttp.web import Response
from ....keyboard.mappings import WEB_TO_EVDEV
from ....keyboard.keysym import build_symmap
from ....keyboard.printer import text_to_evdev_keys
from ....mouse import MOUSE_TO_EVDEV
from ....keyboard.printer import text_to_web_keys
from ....htserver import exposed_http
from ....htserver import exposed_ws
@ -47,9 +43,7 @@ from ....plugins.hid import BaseHid
from ....validators import raise_error
from ....validators.basic import valid_bool
from ....validators.basic import valid_number
from ....validators.basic import valid_int_f0
from ....validators.basic import valid_string_list
from ....validators.os import valid_printable_filename
from ....validators.hid import valid_hid_keyboard_output
from ....validators.hid import valid_hid_mouse_output
@ -103,11 +97,6 @@ class HidApi:
await self.__hid.reset()
return make_json_response()
@exposed_http("GET", "/hid/inactivity")
async def __inactivity_handler(self, _: Request) -> Response:
secs = self.__hid.get_inactivity_seconds()
return make_json_response({"inactivity": secs})
# =====
async def get_keymaps(self) -> dict: # Ugly hack to generate hid_keymaps_state (see server.py)
@ -130,26 +119,15 @@ class HidApi:
@exposed_http("POST", "/hid/print")
async def __print_handler(self, req: Request) -> Response:
text = await req.text()
limit = valid_int_f0(req.query.get("limit", 1024))
limit = int(valid_int_f0(req.query.get("limit", 1024)))
if limit > 0:
text = text[:limit]
symmap = self.__ensure_symmap(req.query.get("keymap", self.__default_keymap_name))
slow = valid_bool(req.query.get("slow", False))
delay = float(valid_number(
arg=req.query.get("delay", (0.02 if slow else 0)),
min=0,
max=5,
type=float,
name="keys delay",
))
await self.__hid.send_key_events(
keys=text_to_evdev_keys(text, symmap),
no_ignore_keys=True,
delay=delay,
)
await self.__hid.send_key_events(text_to_web_keys(text, symmap), no_ignore_keys=True, slow=slow)
return make_json_response()
def __ensure_symmap(self, keymap_name: str) -> dict[int, dict[int, int]]:
def __ensure_symmap(self, keymap_name: str) -> dict[int, dict[int, str]]:
keymap_name = valid_printable_filename(keymap_name, "keymap")
path = os.path.join(self.__keymaps_dir_path, keymap_name)
try:
@ -161,7 +139,7 @@ class HidApi:
return self.__inner_ensure_symmap(path, st.st_mtime)
@functools.lru_cache(maxsize=10)
def __inner_ensure_symmap(self, path: str, mod_ts: int) -> dict[int, dict[int, int]]:
def __inner_ensure_symmap(self, path: str, mod_ts: int) -> dict[int, dict[int, str]]:
_ = mod_ts # For LRU
return build_symmap(path)
@ -170,12 +148,9 @@ class HidApi:
@exposed_ws(1)
async def __ws_bin_key_handler(self, _: WsSession, data: bytes) -> None:
try:
key = valid_hid_key(data[1:].decode("ascii"))
state = bool(data[0] & 0b01)
finish = bool(data[0] & 0b10)
if data[0] & 0b10000000:
key = struct.unpack(">H", data[1:])[0]
else:
key = WEB_TO_EVDEV[valid_hid_key(data[1:33].decode("ascii"))]
except Exception:
return
self.__hid.send_key_event(key, state, finish)
@ -183,11 +158,7 @@ class HidApi:
@exposed_ws(2)
async def __ws_bin_mouse_button_handler(self, _: WsSession, data: bytes) -> None:
try:
state = bool(data[0] & 0b01)
if data[0] & 0b10000000:
button = struct.unpack(">H", data[1:])[0]
else:
button = MOUSE_TO_EVDEV[valid_hid_mouse_button(data[1:33].decode("ascii"))]
button = valid_hid_mouse_button(data[1:].decode("ascii"))
state = bool(data[0] & 0b01)
except Exception:
return
@ -228,7 +199,7 @@ class HidApi:
@exposed_ws("key")
async def __ws_key_handler(self, _: WsSession, event: dict) -> None:
try:
key = WEB_TO_EVDEV[valid_hid_key(event["key"])]
key = valid_hid_key(event["key"])
state = valid_bool(event["state"])
finish = valid_bool(event.get("finish", False))
except Exception:
@ -238,7 +209,7 @@ class HidApi:
@exposed_ws("mouse_button")
async def __ws_mouse_button_handler(self, _: WsSession, event: dict) -> None:
try:
button = MOUSE_TO_EVDEV[valid_hid_mouse_button(event["button"])]
button = valid_hid_mouse_button(event["button"])
state = valid_bool(event["state"])
except Exception:
return
@ -275,22 +246,9 @@ class HidApi:
# =====
@exposed_http("POST", "/hid/events/send_shortcut")
async def __events_send_shortcut_handler(self, req: Request) -> Response:
shortcut = valid_string_list(req.query.get("keys"), subval=valid_hid_key)
if shortcut:
press = [WEB_TO_EVDEV[key] for key in shortcut]
release = list(reversed(press))
seq = [
*zip(press, itertools.repeat(True)),
*zip(release, itertools.repeat(False)),
]
await self.__hid.send_key_events(seq, no_ignore_keys=True, delay=0.05)
return make_json_response()
@exposed_http("POST", "/hid/events/send_key")
async def __events_send_key_handler(self, req: Request) -> Response:
key = WEB_TO_EVDEV[valid_hid_key(req.query.get("key"))]
key = valid_hid_key(req.query.get("key"))
if "state" in req.query:
state = valid_bool(req.query["state"])
finish = valid_bool(req.query.get("finish", False))
@ -301,7 +259,7 @@ class HidApi:
@exposed_http("POST", "/hid/events/send_mouse_button")
async def __events_send_mouse_button_handler(self, req: Request) -> Response:
button = MOUSE_TO_EVDEV[valid_hid_mouse_button(req.query.get("button"))]
button = valid_hid_mouse_button(req.query.get("button"))
if "state" in req.query:
state = valid_bool(req.query["state"])
self.__hid.send_mouse_button_event(button, state)

View File

@ -45,10 +45,7 @@ class InfoApi:
def __valid_info_fields(self, req: Request) -> list[str]:
available = self.__info_manager.get_subs()
available.add("hw")
default = set(available)
default.remove("health")
return sorted(valid_info_fields(
arg=req.query.get("fields", ",".join(default)),
variants=(available),
arg=req.query.get("fields", ",".join(available)),
variants=available,
) or available)

View File

@ -52,15 +52,17 @@ class LogApi:
raise LogReaderDisabledError()
seek = valid_log_seek(req.query.get("seek", 0))
follow = valid_bool(req.query.get("follow", False))
resp = await start_streaming(req, "text/plain")
response = await start_streaming(req, "text/plain")
try:
async for record in self.__log_reader.poll_log(seek, follow):
await resp.write(("[%s %s] --- %s" % (
await response.write(("[%s %s] --- %s" % (
record["dt"].strftime("%Y-%m-%d %H:%M:%S"),
record["service"],
record["msg"],
)).encode("utf-8") + b"\r\n")
except Exception as exception:
await resp.write(f"Module systemd.journal is unavailable.\n{exception}".encode("utf-8"))
return resp
return resp
except Exception as e:
if record is None:
record = e
await response.write(f"Module systemd.journal is unavailable.\n{record}".encode("utf-8"))
return response
return response

View File

@ -84,7 +84,7 @@ class MsdApi:
async def __set_connected_handler(self, req: Request) -> Response:
await self.__msd.set_connected(valid_bool(req.query.get("connected")))
return make_json_response()
@exposed_http("POST", "/msd/make_image")
async def __set_zipped_handler(self, req: Request) -> Response:
await self.__msd.make_image(valid_bool(req.query.get("zipped")))
@ -133,10 +133,10 @@ class MsdApi:
src = compressed()
size = -1
resp = await start_streaming(req, "application/octet-stream", size, name + suffix)
response = await start_streaming(req, "application/octet-stream", size, name + suffix)
async for chunk in src:
await resp.write(chunk)
return resp
await response.write(chunk)
return response
# =====
@ -166,11 +166,11 @@ class MsdApi:
name = ""
size = written = 0
resp: (StreamResponse | None) = None
response: (StreamResponse | None) = None
async def stream_write_info() -> None:
assert resp is not None
await stream_json(resp, self.__make_write_info(name, size, written))
assert response is not None
await stream_json(response, self.__make_write_info(name, size, written))
try:
async with htclient.download(
@ -190,7 +190,7 @@ class MsdApi:
get_logger(0).info("Downloading image %r as %r to MSD ...", url, name)
async with self.__msd.write_image(name, size, remove_incomplete) as writer:
chunk_size = writer.get_chunk_size()
resp = await start_streaming(req, "application/x-ndjson")
response = await start_streaming(req, "application/x-ndjson")
await stream_write_info()
last_report_ts = 0
async for chunk in remote.content.iter_chunked(chunk_size):
@ -201,12 +201,12 @@ class MsdApi:
last_report_ts = now
await stream_write_info()
return resp
return response
except Exception as ex:
if resp is not None:
if response is not None:
await stream_write_info()
await stream_json_exception(resp, ex)
await stream_json_exception(response, ex)
elif isinstance(ex, aiohttp.ClientError):
return make_json_exception(ex, 400)
raise

View File

@ -102,26 +102,14 @@ class RedfishApi:
"Actions": {
"#ComputerSystem.Reset": {
"ResetType@Redfish.AllowableValues": list(self.__actions),
"target": "/redfish/v1/Systems/0/Actions/ComputerSystem.Reset",
},
"#ComputerSystem.SetDefaultBootOrder": { # https://github.com/pikvm/pikvm/issues/1525
"target": "/redfish/v1/Systems/0/Actions/ComputerSystem.SetDefaultBootOrder",
"target": "/redfish/v1/Systems/0/Actions/ComputerSystem.Reset"
},
},
"Id": "0",
"HostName": host,
"PowerState": ("On" if atx_state["leds"]["power"] else "Off"), # type: ignore
"Boot": {
"BootSourceOverrideEnabled": "Disabled",
"BootSourceOverrideTarget": None,
},
}, wrap_result=False)
@exposed_http("PATCH", "/redfish/v1/Systems/0")
async def __patch_handler(self, _: Request) -> Response:
# https://github.com/pikvm/pikvm/issues/1525
return Response(body=None, status=204)
@exposed_http("POST", "/redfish/v1/Systems/0/Actions/ComputerSystem.Reset")
async def __power_handler(self, req: Request) -> Response:
try:

View File

@ -28,7 +28,6 @@ from ....htserver import make_json_response
from ....validators.basic import valid_bool
from ....validators.basic import valid_int_f0
from ....validators.basic import valid_float_f0
from ....validators.basic import valid_stripped_string_not_empty
from ....validators.kvm import valid_atx_power_action
from ....validators.kvm import valid_atx_button
@ -53,19 +52,9 @@ class SwitchApi:
async def __state_handler(self, _: Request) -> Response:
return make_json_response(await self.__switch.get_state())
@exposed_http("POST", "/switch/set_active_prev")
async def __set_active_prev_handler(self, _: Request) -> Response:
await self.__switch.set_active_prev()
return make_json_response()
@exposed_http("POST", "/switch/set_active_next")
async def __set_active_next_handler(self, _: Request) -> Response:
await self.__switch.set_active_next()
return make_json_response()
@exposed_http("POST", "/switch/set_active")
async def __set_active_port_handler(self, req: Request) -> Response:
port = valid_float_f0(req.query.get("port"))
port = valid_int_f0(req.query.get("port"))
await self.__switch.set_active_port(port)
return make_json_response()
@ -73,7 +62,7 @@ class SwitchApi:
async def __set_beacon_handler(self, req: Request) -> Response:
on = valid_bool(req.query.get("state"))
if "port" in req.query:
port = valid_float_f0(req.query.get("port"))
port = valid_int_f0(req.query.get("port"))
await self.__switch.set_port_beacon(port, on)
elif "uplink" in req.query:
unit = valid_int_f0(req.query.get("uplink"))
@ -85,12 +74,11 @@ class SwitchApi:
@exposed_http("POST", "/switch/set_port_params")
async def __set_port_params(self, req: Request) -> Response:
port = valid_float_f0(req.query.get("port"))
port = valid_int_f0(req.query.get("port"))
params = {
param: validator(req.query.get(param))
for (param, validator) in [
("edid_id", (lambda arg: valid_switch_edid_id(arg, allow_default=True))),
("dummy", valid_bool),
("name", valid_switch_port_name),
("atx_click_power_delay", valid_switch_atx_click_delay),
("atx_click_power_long_delay", valid_switch_atx_click_delay),
@ -154,7 +142,7 @@ class SwitchApi:
@exposed_http("POST", "/switch/atx/power")
async def __power_handler(self, req: Request) -> Response:
port = valid_float_f0(req.query.get("port"))
port = valid_int_f0(req.query.get("port"))
action = valid_atx_power_action(req.query.get("action"))
await ({
"on": self.__switch.atx_power_on,
@ -166,7 +154,7 @@ class SwitchApi:
@exposed_http("POST", "/switch/atx/click")
async def __click_handler(self, req: Request) -> Response:
port = valid_float_f0(req.query.get("port"))
port = valid_int_f0(req.query.get("port"))
button = valid_atx_button(req.query.get("button"))
await ({
"power": self.__switch.atx_click_power,

View File

@ -20,12 +20,6 @@
# ========================================================================== #
import pwd
import grp
import dataclasses
import time
import datetime
import secrets
import pyotp
@ -37,79 +31,48 @@ from ...plugins.auth import BaseAuthService
from ...plugins.auth import get_auth_service_class
from ...htserver import HttpExposed
from ...htserver import RequestUnixCredentials
# =====
@dataclasses.dataclass(frozen=True)
class _Session:
user: str
expire_ts: int
def __post_init__(self) -> None:
assert self.user == self.user.strip()
assert self.user
assert self.expire_ts >= 0
class AuthManager: # pylint: disable=too-many-arguments,too-many-instance-attributes
class AuthManager:
def __init__(
self,
enabled: bool,
expire: int,
usc_users: list[str],
usc_groups: list[str],
unauth_paths: list[str],
int_type: str,
int_kwargs: dict,
force_int_users: list[str],
internal_type: str,
internal_kwargs: dict,
force_internal_users: list[str],
ext_type: str,
ext_kwargs: dict,
external_type: str,
external_kwargs: dict,
totp_secret_path: str,
) -> None:
logger = get_logger(0)
self.__enabled = enabled
if not enabled:
logger.warning("AUTHORIZATION IS DISABLED")
assert expire >= 0
self.__expire = expire
if expire > 0:
logger.info("Maximum user session time is limited: %s",
self.__format_seconds(expire))
self.__usc_uids = self.__load_usc_uids(usc_users, usc_groups)
if self.__usc_uids:
logger.info("Selfauth UNIX socket access is allowed for users: %s",
list(self.__usc_uids.values()))
get_logger().warning("AUTHORIZATION IS DISABLED")
self.__unauth_paths = frozenset(unauth_paths) # To speed up
if self.__unauth_paths:
logger.info("Authorization is disabled for APIs: %s",
list(self.__unauth_paths))
for path in self.__unauth_paths:
get_logger().warning("Authorization is disabled for API %r", path)
self.__int_service: (BaseAuthService | None) = None
self.__internal_service: (BaseAuthService | None) = None
if enabled:
self.__int_service = get_auth_service_class(int_type)(**int_kwargs)
logger.info("Using internal auth service %r",
self.__int_service.get_plugin_name())
self.__internal_service = get_auth_service_class(internal_type)(**internal_kwargs)
get_logger().info("Using internal auth service %r", self.__internal_service.get_plugin_name())
self.__force_int_users = force_int_users
self.__force_internal_users = force_internal_users
self.__ext_service: (BaseAuthService | None) = None
if enabled and ext_type:
self.__ext_service = get_auth_service_class(ext_type)(**ext_kwargs)
logger.info("Using external auth service %r",
self.__ext_service.get_plugin_name())
self.__external_service: (BaseAuthService | None) = None
if enabled and external_type:
self.__external_service = get_auth_service_class(external_type)(**external_kwargs)
get_logger().info("Using external auth service %r", self.__external_service.get_plugin_name())
self.__totp_secret_path = totp_secret_path
self.__sessions: dict[str, _Session] = {} # {token: session}
self.__tokens: dict[str, str] = {} # {token: user}
def is_auth_enabled(self) -> bool:
return self.__enabled
@ -125,8 +88,7 @@ class AuthManager: # pylint: disable=too-many-arguments,too-many-instance-attri
assert user == user.strip()
assert user
assert self.__enabled
assert self.__int_service
logger = get_logger(0)
assert self.__internal_service
if self.__totp_secret_path:
with open(self.__totp_secret_path) as file:
@ -134,150 +96,60 @@ class AuthManager: # pylint: disable=too-many-arguments,too-many-instance-attri
if secret:
code = passwd[-6:]
if not pyotp.TOTP(secret).verify(code, valid_window=1):
logger.error("Got access denied for user %r by TOTP", user)
get_logger().error("Got access denied for user %r by TOTP", user)
return False
passwd = passwd[:-6]
if user not in self.__force_int_users and self.__ext_service:
service = self.__ext_service
if user not in self.__force_internal_users and self.__external_service:
service = self.__external_service
else:
service = self.__int_service
service = self.__internal_service
pname = service.get_plugin_name()
ok = (await service.authorize(user, passwd))
if ok:
logger.info("Authorized user %r via auth service %r", user, pname)
get_logger().info("Authorized user %r via auth service %r", user, service.get_plugin_name())
else:
logger.error("Got access denied for user %r from auth service %r", user, pname)
get_logger().error("Got access denied for user %r from auth service %r", user, service.get_plugin_name())
return ok
async def login(self, user: str, passwd: str, expire: int) -> (str | None):
async def login(self, user: str, passwd: str) -> (str | None):
assert user == user.strip()
assert user
assert expire >= 0
assert self.__enabled
if (await self.authorize(user, passwd)):
token = self.__make_new_token()
session = _Session(
user=user,
expire_ts=self.__make_expire_ts(expire),
)
self.__sessions[token] = session
get_logger(0).info("Logged in user %r; expire=%s, sessions_now=%d",
session.user,
self.__format_expire_ts(session.expire_ts),
self.__get_sessions_number(session.user))
self.__tokens[token] = user
get_logger().info("Logged in user %r", user)
return token
return None
else:
return None
def __make_new_token(self) -> str:
for _ in range(10):
token = secrets.token_hex(32)
if token not in self.__sessions:
if token not in self.__tokens:
return token
raise RuntimeError("Can't generate new unique token")
def __make_expire_ts(self, expire: int) -> int:
assert expire >= 0
assert self.__expire >= 0
if expire == 0:
# The user requested infinite session: apply global expire.
# It will allow this (0) or set a limit.
expire = self.__expire
else:
# The user wants a limited session
if self.__expire > 0:
# If we have a global limit, override the user limit
assert expire > 0
expire = min(expire, self.__expire)
if expire > 0:
return (self.__get_now_ts() + expire)
assert expire == 0
return 0
def __get_now_ts(self) -> int:
return int(time.monotonic())
def __format_expire_ts(self, expire_ts: int) -> str:
if expire_ts > 0:
seconds = expire_ts - self.__get_now_ts()
return f"[{self.__format_seconds(seconds)}]"
return "INF"
def __format_seconds(self, seconds: int) -> str:
return str(datetime.timedelta(seconds=seconds))
def __get_sessions_number(self, user: str) -> int:
return sum(
1
for session in self.__sessions.values()
if session.user == user
)
raise AssertionError("Can't generate new unique token")
def logout(self, token: str) -> None:
assert self.__enabled
if token in self.__sessions:
user = self.__sessions[token].user
if token in self.__tokens:
user = self.__tokens[token]
count = 0
for (key_t, session) in list(self.__sessions.items()):
if session.user == user:
for (r_token, r_user) in list(self.__tokens.items()):
if r_user == user:
count += 1
del self.__sessions[key_t]
get_logger(0).info("Logged out user %r; sessions_closed=%d", user, count)
del self.__tokens[r_token]
get_logger().info("Logged out user %r (%d)", user, count)
def check(self, token: str) -> (str | None):
assert self.__enabled
session = self.__sessions.get(token)
if session is not None:
if session.expire_ts <= 0:
# Infinite session
return session.user
else:
# Limited session
if self.__get_now_ts() < session.expire_ts:
return session.user
else:
del self.__sessions[token]
get_logger(0).info("The session of user %r is expired; sessions_left=%d",
session.user,
self.__get_sessions_number(session.user))
return None
return self.__tokens.get(token)
@aiotools.atomic_fg
async def cleanup(self) -> None:
if self.__enabled:
assert self.__int_service
await self.__int_service.cleanup()
if self.__ext_service:
await self.__ext_service.cleanup()
# =====
def __load_usc_uids(self, users: list[str], groups: list[str]) -> dict[int, str]:
uids: dict[int, str] = {}
pwds: dict[str, int] = {}
for pw in pwd.getpwall():
assert pw.pw_name == pw.pw_name.strip()
assert pw.pw_name
pwds[pw.pw_name] = pw.pw_uid
if pw.pw_name in users:
uids[pw.pw_uid] = pw.pw_name
for gr in grp.getgrall():
if gr.gr_name in groups:
for member in gr.gr_mem:
if member in pwds:
uid = pwds[member]
uids[uid] = member
return uids
def check_unix_credentials(self, creds: RequestUnixCredentials) -> (str | None):
assert self.__enabled
return self.__usc_uids.get(creds.uid)
assert self.__internal_service
await self.__internal_service.cleanup()
if self.__external_service:
await self.__external_service.cleanup()

View File

@ -31,7 +31,7 @@ from .auth import AuthInfoSubmanager
from .system import SystemInfoSubmanager
from .meta import MetaInfoSubmanager
from .extras import ExtrasInfoSubmanager
from .health import HealthInfoSubmanager
from .hw import HwInfoSubmanager
from .fan import FanInfoSubmanager
@ -39,11 +39,11 @@ from .fan import FanInfoSubmanager
class InfoManager:
def __init__(self, config: Section) -> None:
self.__subs: dict[str, BaseInfoSubmanager] = {
"system": SystemInfoSubmanager(config.kvmd.info.hw.platform, config.kvmd.streamer.cmd),
"system": SystemInfoSubmanager(config.kvmd.streamer.cmd),
"auth": AuthInfoSubmanager(config.kvmd.auth.enabled),
"meta": MetaInfoSubmanager(config.kvmd.info.meta),
"extras": ExtrasInfoSubmanager(config),
"health": HealthInfoSubmanager(**config.kvmd.info.hw._unpack(ignore="platform")),
"hw": HwInfoSubmanager(**config.kvmd.info.hw._unpack()),
"fan": FanInfoSubmanager(**config.kvmd.info.fan._unpack()),
}
self.__queue: "asyncio.Queue[tuple[str, (dict | None)]]" = asyncio.Queue()
@ -52,29 +52,12 @@ class InfoManager:
return set(self.__subs)
async def get_state(self, fields: (list[str] | None)=None) -> dict:
fields_set = set(fields or list(self.__subs))
hw = ("hw" in fields_set) # Old for compatible
system = ("system" in fields_set)
if hw:
fields_set.remove("hw")
fields_set.add("health")
fields_set.add("system")
state = dict(zip(fields_set, await asyncio.gather(*[
fields = (fields or list(self.__subs))
return dict(zip(fields, await asyncio.gather(*[
self.__subs[field].get_state()
for field in fields_set
for field in fields
])))
if hw:
state["hw"] = {
"health": state.pop("health"),
"platform": (state["system"] or {}).pop("platform"), # {} makes mypy happy
}
if not system:
state.pop("system")
return state
async def trigger_state(self) -> None:
await asyncio.gather(*[
sub.trigger_state()
@ -87,7 +70,7 @@ class InfoManager:
# - auth -- Partial
# - meta -- Partial, nullable
# - extras -- Partial, nullable
# - health -- Partial
# - hw -- Partial
# - fan -- Partial
# ===========================

View File

@ -34,6 +34,7 @@ from ....yamlconf.loader import load_yaml_file
from .... import tools
from .... import aiotools
from .... import env
from .. import sysunit

View File

@ -99,9 +99,9 @@ class FanInfoSubmanager(BaseInfoSubmanager):
async def __get_fan_state(self) -> (dict | None):
try:
async with self.__make_http_session() as session:
async with session.get("http://localhost/state") as resp:
htclient.raise_not_200(resp)
return (await resp.json())["result"]
async with session.get("http://localhost/state") as response:
htclient.raise_not_200(response)
return (await response.json())["result"]
except Exception as ex:
get_logger(0).error("Can't read fan state: %s", ex)
return None

View File

@ -20,6 +20,7 @@
# ========================================================================== #
import os
import asyncio
import copy
@ -44,41 +45,59 @@ _RetvalT = TypeVar("_RetvalT")
# =====
class HealthInfoSubmanager(BaseInfoSubmanager):
class HwInfoSubmanager(BaseInfoSubmanager):
def __init__(
self,
platform_path: str,
vcgencmd_cmd: list[str],
ignore_past: bool,
state_poll: float,
) -> None:
self.__platform_path = platform_path
self.__vcgencmd_cmd = vcgencmd_cmd
self.__ignore_past = ignore_past
self.__state_poll = state_poll
self.__dt_cache: dict[str, str] = {}
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> dict:
(
base,
serial,
platform,
throttling,
cpu_percent,
cpu_temp,
mem,
) = await asyncio.gather(
self.__read_dt_file("model", upper=False),
self.__read_dt_file("serial-number", upper=True),
self.__read_platform_file(),
self.__get_throttling(),
self.__get_cpu_percent(),
self.__get_cpu_temp(),
self.__get_mem(),
)
return {
"temp": {
"cpu": cpu_temp,
"platform": {
"type": "rpi",
"base": base,
"serial": serial,
**platform, # type: ignore
},
"cpu": {
"percent": cpu_percent,
"health": {
"temp": {
"cpu": cpu_temp,
},
"cpu": {
"percent": cpu_percent,
},
"mem": mem,
"throttling": throttling,
},
"mem": mem,
"throttling": throttling,
}
async def trigger_state(self) -> None:
@ -96,12 +115,42 @@ class HealthInfoSubmanager(BaseInfoSubmanager):
# =====
async def __read_dt_file(self, name: str, upper: bool) -> (str | None):
if name not in self.__dt_cache:
path = os.path.join(f"{env.PROCFS_PREFIX}/proc/device-tree", name)
if not os.path.exists(path):
path = os.path.join(f"{env.PROCFS_PREFIX}/etc/kvmd/hw_info/", name)
try:
self.__dt_cache[name] = (await aiotools.read_file(path)).strip(" \t\r\n\0")
except Exception as err:
#get_logger(0).warn("Can't read DT %s from %s: %s", name, path, err)
return None
return self.__dt_cache[name]
async def __read_platform_file(self) -> dict:
try:
text = await aiotools.read_file(self.__platform_path)
parsed: dict[str, str] = {}
for row in text.split("\n"):
row = row.strip()
if row:
(key, value) = row.split("=", 1)
parsed[key.strip()] = value.strip()
return {
"model": parsed["PIKVM_MODEL"],
"video": parsed["PIKVM_VIDEO"],
"board": parsed["PIKVM_BOARD"],
}
except Exception:
get_logger(0).exception("Can't read device model")
return {"model": None, "video": None, "board": None}
async def __get_cpu_temp(self) -> (float | None):
temp_path = f"{env.SYSFS_PREFIX}/sys/class/thermal/thermal_zone0/temp"
try:
return int((await aiotools.read_file(temp_path)).strip()) / 1000
except Exception:
# get_logger(0).warn("Can't read CPU temp from %s: %s", temp_path, err)
except Exception as err:
#get_logger(0).warn("Can't read CPU temp from %s: %s", temp_path, err)
return None
async def __get_cpu_percent(self) -> (float | None):

View File

@ -20,8 +20,6 @@
# ========================================================================== #
import socket
from typing import AsyncGenerator
from ....logging import get_logger
@ -41,10 +39,7 @@ class MetaInfoSubmanager(BaseInfoSubmanager):
async def get_state(self) -> (dict | None):
try:
meta = ((await aiotools.run_async(load_yaml_file, self.__meta_path)) or {})
if meta["server"]["host"] == "@auto":
meta["server"]["host"] = socket.getfqdn()
return meta
return ((await aiotools.run_async(load_yaml_file, self.__meta_path)) or {})
except Exception:
get_logger(0).exception("Can't parse meta")
return None

View File

@ -28,7 +28,6 @@ from typing import AsyncGenerator
from ....logging import get_logger
from .... import env
from .... import aiotools
from .... import aioproc
@ -39,30 +38,12 @@ from .base import BaseInfoSubmanager
# =====
class SystemInfoSubmanager(BaseInfoSubmanager):
def __init__(
self,
platform_path: str,
streamer_cmd: list[str],
) -> None:
self.__platform_path = platform_path
def __init__(self, streamer_cmd: list[str]) -> None:
self.__streamer_cmd = streamer_cmd
self.__dt_cache: dict[str, str] = {}
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> dict:
(
base,
serial,
pl,
streamer_info,
) = await asyncio.gather(
self.__read_dt_file("model", upper=False),
self.__read_dt_file("serial-number", upper=True),
self.__read_platform_file(),
self.__get_streamer_info(),
)
streamer_info = await self.__get_streamer_info()
uname_info = platform.uname() # Uname using the internal cache
return {
"kvmd": {"version": __version__},
@ -71,12 +52,6 @@ class SystemInfoSubmanager(BaseInfoSubmanager):
field: getattr(uname_info, field)
for field in ["system", "release", "version", "machine"]
},
"platform": {
"type": "rpi",
"base": base,
"serial": serial,
**pl, # type: ignore
},
}
async def trigger_state(self) -> None:
@ -89,35 +64,6 @@ class SystemInfoSubmanager(BaseInfoSubmanager):
# =====
async def __read_dt_file(self, name: str, upper: bool) -> (str | None):
if name not in self.__dt_cache:
path = os.path.join(f"{env.PROCFS_PREFIX}/proc/device-tree", name)
try:
value = (await aiotools.read_file(path)).strip(" \t\r\n\0")
self.__dt_cache[name] = (value.upper() if upper else value)
except Exception as ex:
get_logger(0).error("Can't read DT %s from %s: %s", name, path, ex)
return None
return self.__dt_cache[name]
async def __read_platform_file(self) -> dict:
try:
text = await aiotools.read_file(self.__platform_path)
parsed: dict[str, str] = {}
for row in text.split("\n"):
row = row.strip()
if row:
(key, value) = row.split("=", 1)
parsed[key.strip()] = value.strip()
return {
"model": parsed["PIKVM_MODEL"],
"video": parsed["PIKVM_VIDEO"],
"board": parsed["PIKVM_BOARD"],
}
except Exception:
get_logger(0).exception("Can't read device model")
return {"model": None, "video": None, "board": None}
async def __get_streamer_info(self) -> dict:
version = ""
features: dict[str, bool] = {}

View File

@ -29,11 +29,13 @@ import time
from typing import AsyncGenerator
from xmlrpc.client import ServerProxy
from ...logging import get_logger
us_systemd_journal = True
try:
import systemd.journal
except ImportError:
import supervisor.xmlrpc
us_systemd_journal = False
@ -41,14 +43,14 @@ except ImportError:
class LogReader:
async def poll_log(self, seek: int, follow: bool) -> AsyncGenerator[dict, None]:
if us_systemd_journal:
reader = systemd.journal.Reader() # type: ignore
reader = systemd.journal.Reader() # type: ignore
reader.this_boot()
# XXX: Из-за смены ID машины в bootconfig это не работает при первой загрузке.
# reader.this_machine()
reader.log_level(systemd.journal.LOG_DEBUG) # type: ignore
reader.log_level(systemd.journal.LOG_DEBUG) # type: ignore
services = set(
service
for service in systemd.journal.Reader().query_unique("_SYSTEMD_UNIT") # type: ignore
for service in systemd.journal.Reader().query_unique("_SYSTEMD_UNIT") # type: ignore
if re.match(r"kvmd(-\w+)*\.service", service)
).union(["kvmd.service"])
@ -67,15 +69,10 @@ class LogReader:
else:
await asyncio.sleep(1)
else:
import supervisor.xmlrpc # pylint: disable=import-outside-toplevel
server_transport = supervisor.xmlrpc.SupervisorTransport(None, None, serverurl="unix:///tmp/supervisor.sock")
server = ServerProxy("http://127.0.0.1", transport=server_transport)
log_entries = server.supervisor.readLog(0, 0)
yield {
"dt": int(time.time()),
"service": "kvmd.service",
"msg": str(log_entries).rstrip()
}
server = ServerProxy('http://127.0.0.1',transport=supervisor.xmlrpc.SupervisorTransport(None, None, serverurl='unix:///tmp/supervisor.sock'))
log_entries = server.supervisor.readLog(0,0)
yield log_entries
def __entry_to_record(self, entry: dict) -> dict[str, dict]:
return {

View File

@ -254,10 +254,6 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
async def __ws_ping_handler(self, ws: WsSession, _: dict) -> None:
await ws.send_event("pong", {})
@exposed_ws(0)
async def __ws_bin_ping_handler(self, ws: WsSession, _: bytes) -> None:
await ws.send_bin(255, b"") # Ping-pong
# ===== SYSTEM STUFF
def run(self, **kwargs: Any) -> None: # type: ignore # pylint: disable=arguments-differ
@ -322,17 +318,18 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
while True:
cur = (self.__has_stream_clients() or self.__snapshoter.snapshoting() or self.__stream_forever)
if not prev and cur:
await self.__streamer.ensure_start()
await self.__streamer.ensure_start(reset=False)
elif prev and not cur:
await self.__streamer.ensure_stop()
await self.__streamer.ensure_stop(immediately=False)
if self.__new_streamer_params:
self.__streamer.set_params(self.__new_streamer_params)
self.__new_streamer_params = {}
self.__reset_streamer = True
if self.__reset_streamer:
await self.__streamer.ensure_restart()
if self.__reset_streamer or self.__new_streamer_params:
start = self.__streamer.is_working()
await self.__streamer.ensure_stop(immediately=True)
if self.__new_streamer_params:
self.__streamer.set_params(self.__new_streamer_params)
self.__new_streamer_params = {}
if start:
await self.__streamer.ensure_start(reset=self.__reset_streamer)
self.__reset_streamer = False
prev = cur

View File

@ -31,8 +31,6 @@ from ... import aiotools
from ...plugins.hid import BaseHid
from ...keyboard.mappings import WEB_TO_EVDEV
from .streamer import Streamer
@ -65,7 +63,7 @@ class Snapshoter: # pylint: disable=too-many-instance-attributes
else:
self.__idle_interval = self.__live_interval = 0.0
self.__wakeup_key = WEB_TO_EVDEV.get(wakeup_key, 0)
self.__wakeup_key = wakeup_key
self.__wakeup_move = wakeup_move
self.__online_delay = online_delay
@ -123,8 +121,8 @@ class Snapshoter: # pylint: disable=too-many-instance-attributes
async def __wakeup(self) -> None:
logger = get_logger(0)
if self.__wakeup_key > 0:
logger.info("Waking up using keyboard ...")
if self.__wakeup_key:
logger.info("Waking up using key %r ...", self.__wakeup_key)
await self.__hid.send_key_events(
keys=[(self.__wakeup_key, True), (self.__wakeup_key, False)],
no_ignore_keys=True,

456
kvmd/apps/kvmd/streamer.py Normal file
View File

@ -0,0 +1,456 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import signal
import asyncio
import asyncio.subprocess
import dataclasses
import copy
from typing import AsyncGenerator
from typing import Any
import aiohttp
from ...logging import get_logger
from ...clients.streamer import StreamerSnapshot
from ...clients.streamer import HttpStreamerClient
from ...clients.streamer import HttpStreamerClientSession
from ... import tools
from ... import aiotools
from ... import aioproc
from ... import htclient
# =====
class _StreamerParams:
__DESIRED_FPS = "desired_fps"
__QUALITY = "quality"
__RESOLUTION = "resolution"
__AVAILABLE_RESOLUTIONS = "available_resolutions"
__H264_BITRATE = "h264_bitrate"
__H264_GOP = "h264_gop"
def __init__( # pylint: disable=too-many-arguments
self,
quality: int,
resolution: str,
available_resolutions: list[str],
desired_fps: int,
desired_fps_min: int,
desired_fps_max: int,
h264_bitrate: int,
h264_bitrate_min: int,
h264_bitrate_max: int,
h264_gop: int,
h264_gop_min: int,
h264_gop_max: int,
) -> None:
self.__has_quality = bool(quality)
self.__has_resolution = bool(resolution)
self.__has_h264 = bool(h264_bitrate)
self.__params: dict = {self.__DESIRED_FPS: min(max(desired_fps, desired_fps_min), desired_fps_max)}
self.__limits: dict = {self.__DESIRED_FPS: {"min": desired_fps_min, "max": desired_fps_max}}
if self.__has_quality:
self.__params[self.__QUALITY] = quality
if self.__has_resolution:
self.__params[self.__RESOLUTION] = resolution
self.__limits[self.__AVAILABLE_RESOLUTIONS] = available_resolutions
if self.__has_h264:
self.__params[self.__H264_BITRATE] = min(max(h264_bitrate, h264_bitrate_min), h264_bitrate_max)
self.__limits[self.__H264_BITRATE] = {"min": h264_bitrate_min, "max": h264_bitrate_max}
self.__params[self.__H264_GOP] = min(max(h264_gop, h264_gop_min), h264_gop_max)
self.__limits[self.__H264_GOP] = {"min": h264_gop_min, "max": h264_gop_max}
def get_features(self) -> dict:
return {
self.__QUALITY: self.__has_quality,
self.__RESOLUTION: self.__has_resolution,
"h264": self.__has_h264,
}
def get_limits(self) -> dict:
limits = copy.deepcopy(self.__limits)
if self.__has_resolution:
limits[self.__AVAILABLE_RESOLUTIONS] = list(limits[self.__AVAILABLE_RESOLUTIONS])
return limits
def get_params(self) -> dict:
return dict(self.__params)
def set_params(self, params: dict) -> None:
new_params = dict(self.__params)
if self.__QUALITY in params and self.__has_quality:
new_params[self.__QUALITY] = min(max(params[self.__QUALITY], 1), 100)
if self.__RESOLUTION in params and self.__has_resolution:
if params[self.__RESOLUTION] in self.__limits[self.__AVAILABLE_RESOLUTIONS]:
new_params[self.__RESOLUTION] = params[self.__RESOLUTION]
for (key, enabled) in [
(self.__DESIRED_FPS, True),
(self.__H264_BITRATE, self.__has_h264),
(self.__H264_GOP, self.__has_h264),
]:
if key in params and enabled:
if self.__check_limits_min_max(key, params[key]):
new_params[key] = params[key]
self.__params = new_params
def __check_limits_min_max(self, key: str, value: int) -> bool:
return (self.__limits[key]["min"] <= value <= self.__limits[key]["max"])
class Streamer: # pylint: disable=too-many-instance-attributes
__ST_FULL = 0xFF
__ST_PARAMS = 0x01
__ST_STREAMER = 0x02
__ST_SNAPSHOT = 0x04
def __init__( # pylint: disable=too-many-arguments,too-many-locals
self,
reset_delay: float,
shutdown_delay: float,
state_poll: float,
unix_path: str,
timeout: float,
snapshot_timeout: float,
process_name_prefix: str,
pre_start_cmd: list[str],
pre_start_cmd_remove: list[str],
pre_start_cmd_append: list[str],
cmd: list[str],
cmd_remove: list[str],
cmd_append: list[str],
post_stop_cmd: list[str],
post_stop_cmd_remove: list[str],
post_stop_cmd_append: list[str],
**params_kwargs: Any,
) -> None:
self.__reset_delay = reset_delay
self.__shutdown_delay = shutdown_delay
self.__state_poll = state_poll
self.__unix_path = unix_path
self.__snapshot_timeout = snapshot_timeout
self.__process_name_prefix = process_name_prefix
self.__pre_start_cmd = tools.build_cmd(pre_start_cmd, pre_start_cmd_remove, pre_start_cmd_append)
self.__cmd = tools.build_cmd(cmd, cmd_remove, cmd_append)
self.__post_stop_cmd = tools.build_cmd(post_stop_cmd, post_stop_cmd_remove, post_stop_cmd_append)
self.__params = _StreamerParams(**params_kwargs)
self.__stop_task: (asyncio.Task | None) = None
self.__stop_wip = False
self.__streamer_task: (asyncio.Task | None) = None
self.__streamer_proc: (asyncio.subprocess.Process | None) = None # pylint: disable=no-member
self.__client = HttpStreamerClient(
name="jpeg",
unix_path=self.__unix_path,
timeout=timeout,
user_agent=htclient.make_user_agent("KVMD"),
)
self.__client_session: (HttpStreamerClientSession | None) = None
self.__snapshot: (StreamerSnapshot | None) = None
self.__notifier = aiotools.AioNotifier()
# =====
@aiotools.atomic_fg
async def ensure_start(self, reset: bool) -> None:
if not self.__streamer_task or self.__stop_task:
logger = get_logger(0)
if self.__stop_task:
if not self.__stop_wip:
self.__stop_task.cancel()
await asyncio.gather(self.__stop_task, return_exceptions=True)
logger.info("Streamer stop cancelled")
return
else:
await asyncio.gather(self.__stop_task, return_exceptions=True)
if reset and self.__reset_delay > 0:
logger.info("Waiting %.2f seconds for reset delay ...", self.__reset_delay)
await asyncio.sleep(self.__reset_delay)
logger.info("Starting streamer ...")
await self.__inner_start()
@aiotools.atomic_fg
async def ensure_stop(self, immediately: bool) -> None:
if self.__streamer_task:
logger = get_logger(0)
if immediately:
if self.__stop_task:
if not self.__stop_wip:
self.__stop_task.cancel()
await asyncio.gather(self.__stop_task, return_exceptions=True)
logger.info("Stopping streamer immediately ...")
await self.__inner_stop()
else:
await asyncio.gather(self.__stop_task, return_exceptions=True)
else:
logger.info("Stopping streamer immediately ...")
await self.__inner_stop()
elif not self.__stop_task:
async def delayed_stop() -> None:
try:
await asyncio.sleep(self.__shutdown_delay)
self.__stop_wip = True
logger.info("Stopping streamer after delay ...")
await self.__inner_stop()
finally:
self.__stop_task = None
self.__stop_wip = False
logger.info("Planning to stop streamer in %.2f seconds ...", self.__shutdown_delay)
self.__stop_task = asyncio.create_task(delayed_stop())
def is_working(self) -> bool:
# Запущено и не планирует останавливаться
return bool(self.__streamer_task and not self.__stop_task)
# =====
def set_params(self, params: dict) -> None:
assert not self.__streamer_task
self.__notifier.notify(self.__ST_PARAMS)
return self.__params.set_params(params)
def get_params(self) -> dict:
return self.__params.get_params()
# =====
async def get_state(self) -> dict:
return {
"features": self.__params.get_features(),
"limits": self.__params.get_limits(),
"params": self.__params.get_params(),
"streamer": (await self.__get_streamer_state()),
"snapshot": self.__get_snapshot_state(),
}
async def trigger_state(self) -> None:
self.__notifier.notify(self.__ST_FULL)
async def poll_state(self) -> AsyncGenerator[dict, None]:
# ==== Granularity table ====
# - features -- Full
# - limits -- Partial, paired with params
# - params -- Partial, paired with limits
# - streamer -- Partial, nullable
# - snapshot -- Partial
# ===========================
def signal_handler(*_: Any) -> None:
get_logger(0).info("Got SIGUSR2, checking the stream state ...")
self.__notifier.notify(self.__ST_STREAMER)
get_logger(0).info("Installing SIGUSR2 streamer handler ...")
asyncio.get_event_loop().add_signal_handler(signal.SIGUSR2, signal_handler)
prev: dict = {}
while True:
new: dict = {}
mask = await self.__notifier.wait(timeout=self.__state_poll)
if mask == self.__ST_FULL:
new = await self.get_state()
prev = copy.deepcopy(new)
yield new
continue
if mask < 0:
mask = self.__ST_STREAMER
def check_update(key: str, value: (dict | None)) -> None:
if prev.get(key) != value:
new[key] = value
if mask & self.__ST_PARAMS:
check_update("params", self.__params.get_params())
if mask & self.__ST_STREAMER:
check_update("streamer", await self.__get_streamer_state())
if mask & self.__ST_SNAPSHOT:
check_update("snapshot", self.__get_snapshot_state())
if new and prev != new:
prev.update(copy.deepcopy(new))
yield new
async def __get_streamer_state(self) -> (dict | None):
if self.__streamer_task:
session = self.__ensure_client_session()
try:
return (await session.get_state())
except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError):
pass
except Exception:
get_logger().exception("Invalid streamer response from /state")
return None
def __get_snapshot_state(self) -> dict:
if self.__snapshot:
snapshot = dataclasses.asdict(self.__snapshot)
del snapshot["headers"]
del snapshot["data"]
return {"saved": snapshot}
return {"saved": None}
# =====
async def take_snapshot(self, save: bool, load: bool, allow_offline: bool) -> (StreamerSnapshot | None):
if load:
return self.__snapshot
logger = get_logger()
session = self.__ensure_client_session()
try:
snapshot = await session.take_snapshot(self.__snapshot_timeout)
if snapshot.online or allow_offline:
if save:
self.__snapshot = snapshot
self.__notifier.notify(self.__ST_SNAPSHOT)
return snapshot
logger.error("Stream is offline, no signal or so")
except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError) as ex:
logger.error("Can't connect to streamer: %s", tools.efmt(ex))
except Exception:
logger.exception("Invalid streamer response from /snapshot")
return None
def remove_snapshot(self) -> None:
self.__snapshot = None
# =====
@aiotools.atomic_fg
async def cleanup(self) -> None:
await self.ensure_stop(immediately=True)
if self.__client_session:
await self.__client_session.close()
self.__client_session = None
def __ensure_client_session(self) -> HttpStreamerClientSession:
if not self.__client_session:
self.__client_session = self.__client.make_session()
return self.__client_session
# =====
@aiotools.atomic_fg
async def __inner_start(self) -> None:
assert not self.__streamer_task
await self.__run_hook("PRE-START-CMD", self.__pre_start_cmd)
self.__streamer_task = asyncio.create_task(self.__streamer_task_loop())
@aiotools.atomic_fg
async def __inner_stop(self) -> None:
assert self.__streamer_task
self.__streamer_task.cancel()
await asyncio.gather(self.__streamer_task, return_exceptions=True)
await self.__kill_streamer_proc()
await self.__run_hook("POST-STOP-CMD", self.__post_stop_cmd)
self.__streamer_task = None
# =====
async def __streamer_task_loop(self) -> None: # pylint: disable=too-many-branches
logger = get_logger(0)
while True: # pylint: disable=too-many-nested-blocks
try:
await self.__start_streamer_proc()
assert self.__streamer_proc is not None
await aioproc.log_stdout_infinite(self.__streamer_proc, logger)
raise RuntimeError("Streamer unexpectedly died")
except asyncio.CancelledError:
break
except Exception:
if self.__streamer_proc:
logger.exception("Unexpected streamer error: pid=%d", self.__streamer_proc.pid)
else:
logger.exception("Can't start streamer")
await self.__kill_streamer_proc()
await asyncio.sleep(1)
def __make_cmd(self, cmd: list[str]) -> list[str]:
return [
part.format(
unix=self.__unix_path,
process_name_prefix=self.__process_name_prefix,
**self.__params.get_params(),
)
for part in cmd
]
async def __run_hook(self, name: str, cmd: list[str]) -> None:
logger = get_logger()
cmd = self.__make_cmd(cmd)
logger.info("%s: %s", name, tools.cmdfmt(cmd))
try:
await aioproc.log_process(cmd, logger, prefix=name)
except Exception as ex:
logger.exception("Can't execute command: %s", ex)
async def __start_streamer_proc(self) -> None:
assert self.__streamer_proc is None
cmd = self.__make_cmd(self.__cmd)
self.__streamer_proc = await aioproc.run_process(cmd)
get_logger(0).info("Started streamer pid=%d: %s", self.__streamer_proc.pid, tools.cmdfmt(cmd))
async def __kill_streamer_proc(self) -> None:
if self.__streamer_proc:
await aioproc.kill_process(self.__streamer_proc, 1, get_logger(0))
self.__streamer_proc = None

View File

@ -1,254 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import signal
import asyncio
import dataclasses
import copy
from typing import AsyncGenerator
from typing import Any
import aiohttp
from ....logging import get_logger
from ....clients.streamer import StreamerSnapshot
from ....clients.streamer import HttpStreamerClient
from ....clients.streamer import HttpStreamerClientSession
from .... import tools
from .... import aiotools
from .... import htclient
from .params import Params
from .runner import Runner
# =====
class Streamer: # pylint: disable=too-many-instance-attributes
__ST_FULL = 0xFF
__ST_PARAMS = 0x01
__ST_STREAMER = 0x02
__ST_SNAPSHOT = 0x04
def __init__( # pylint: disable=too-many-arguments,too-many-locals
self,
reset_delay: float,
shutdown_delay: float,
state_poll: float,
unix_path: str,
timeout: float,
snapshot_timeout: float,
process_name_prefix: str,
pre_start_cmd: list[str],
pre_start_cmd_remove: list[str],
pre_start_cmd_append: list[str],
cmd: list[str],
cmd_remove: list[str],
cmd_append: list[str],
post_stop_cmd: list[str],
post_stop_cmd_remove: list[str],
post_stop_cmd_append: list[str],
**params_kwargs: Any,
) -> None:
self.__state_poll = state_poll
self.__unix_path = unix_path
self.__snapshot_timeout = snapshot_timeout
self.__process_name_prefix = process_name_prefix
self.__params = Params(**params_kwargs)
self.__runner = Runner(
reset_delay=reset_delay,
shutdown_delay=shutdown_delay,
pre_start_cmd=tools.build_cmd(pre_start_cmd, pre_start_cmd_remove, pre_start_cmd_append),
cmd=tools.build_cmd(cmd, cmd_remove, cmd_append),
post_stop_cmd=tools.build_cmd(post_stop_cmd, post_stop_cmd_remove, post_stop_cmd_append),
)
self.__client = HttpStreamerClient(
name="jpeg",
unix_path=self.__unix_path,
timeout=timeout,
user_agent=htclient.make_user_agent("KVMD"),
)
self.__client_session: (HttpStreamerClientSession | None) = None
self.__snapshot: (StreamerSnapshot | None) = None
self.__notifier = aiotools.AioNotifier()
# =====
@aiotools.atomic_fg
async def ensure_start(self) -> None:
await self.__runner.ensure_start(self.__make_params())
@aiotools.atomic_fg
async def ensure_restart(self) -> None:
await self.__runner.ensure_restart(self.__make_params())
def __make_params(self) -> dict:
return {
"unix": self.__unix_path,
"process_name_prefix": self.__process_name_prefix,
**self.__params.get_params(),
}
@aiotools.atomic_fg
async def ensure_stop(self) -> None:
await self.__runner.ensure_stop(immediately=False)
# =====
def set_params(self, params: dict) -> None:
self.__notifier.notify(self.__ST_PARAMS)
return self.__params.set_params(params)
def get_params(self) -> dict:
return self.__params.get_params()
# =====
async def get_state(self) -> dict:
return {
"features": self.__params.get_features(),
"limits": self.__params.get_limits(),
"params": self.__params.get_params(),
"streamer": (await self.__get_streamer_state()),
"snapshot": self.__get_snapshot_state(),
}
async def trigger_state(self) -> None:
self.__notifier.notify(self.__ST_FULL)
async def poll_state(self) -> AsyncGenerator[dict, None]:
# ==== Granularity table ====
# - features -- Full
# - limits -- Partial, paired with params
# - params -- Partial, paired with limits
# - streamer -- Partial, nullable
# - snapshot -- Partial
# ===========================
def signal_handler(*_: Any) -> None:
get_logger(0).info("Got SIGUSR2, checking the stream state ...")
self.__notifier.notify(self.__ST_STREAMER)
get_logger(0).info("Installing SIGUSR2 streamer handler ...")
asyncio.get_event_loop().add_signal_handler(signal.SIGUSR2, signal_handler)
prev: dict = {}
while True:
new: dict = {}
mask = await self.__notifier.wait(timeout=self.__state_poll)
if mask == self.__ST_FULL:
new = await self.get_state()
prev = copy.deepcopy(new)
yield new
continue
if mask < 0:
mask = self.__ST_STREAMER
def check_update(key: str, value: (dict | None)) -> None:
if prev.get(key) != value:
new[key] = value
if mask & self.__ST_PARAMS:
check_update("params", self.__params.get_params())
if mask & self.__ST_STREAMER:
check_update("streamer", await self.__get_streamer_state())
if mask & self.__ST_SNAPSHOT:
check_update("snapshot", self.__get_snapshot_state())
if new and prev != new:
prev.update(copy.deepcopy(new))
yield new
async def __get_streamer_state(self) -> (dict | None):
if self.__runner.is_running():
session = self.__ensure_client_session()
try:
return (await session.get_state())
except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError):
pass
except Exception:
get_logger().exception("Invalid streamer response from /state")
return None
def __get_snapshot_state(self) -> dict:
if self.__snapshot:
snapshot = dataclasses.asdict(self.__snapshot)
del snapshot["headers"]
del snapshot["data"]
return {"saved": snapshot}
return {"saved": None}
# =====
async def take_snapshot(self, save: bool, load: bool, allow_offline: bool) -> (StreamerSnapshot | None):
if load:
return self.__snapshot
logger = get_logger()
session = self.__ensure_client_session()
try:
snapshot = await session.take_snapshot(self.__snapshot_timeout)
if snapshot.online or allow_offline:
if save:
self.__snapshot = snapshot
self.__notifier.notify(self.__ST_SNAPSHOT)
return snapshot
logger.error("Stream is offline, no signal or so")
except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError) as ex:
logger.error("Can't connect to streamer: %s", tools.efmt(ex))
except Exception:
logger.exception("Invalid streamer response from /snapshot")
return None
def remove_snapshot(self) -> None:
self.__snapshot = None
# =====
@aiotools.atomic_fg
async def cleanup(self) -> None:
await self.__runner.ensure_stop(immediately=True)
if self.__client_session:
await self.__client_session.close()
self.__client_session = None
def __ensure_client_session(self) -> HttpStreamerClientSession:
if not self.__client_session:
self.__client_session = self.__client.make_session()
return self.__client_session

View File

@ -1,117 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import copy
# =====
class Params:
__DESIRED_FPS = "desired_fps"
__QUALITY = "quality"
__RESOLUTION = "resolution"
__AVAILABLE_RESOLUTIONS = "available_resolutions"
__H264 = "h264"
__H264_BITRATE = "h264_bitrate"
__H264_GOP = "h264_gop"
def __init__( # pylint: disable=too-many-arguments
self,
quality: int,
resolution: str,
available_resolutions: list[str],
desired_fps: int,
desired_fps_min: int,
desired_fps_max: int,
h264_bitrate: int,
h264_bitrate_min: int,
h264_bitrate_max: int,
h264_gop: int,
h264_gop_min: int,
h264_gop_max: int,
) -> None:
self.__has_quality = bool(quality)
self.__has_resolution = bool(resolution)
self.__has_h264 = bool(h264_bitrate)
self.__params: dict = {self.__DESIRED_FPS: min(max(desired_fps, desired_fps_min), desired_fps_max)}
self.__limits: dict = {self.__DESIRED_FPS: {"min": desired_fps_min, "max": desired_fps_max}}
if self.__has_quality:
self.__params[self.__QUALITY] = quality
if self.__has_resolution:
self.__params[self.__RESOLUTION] = resolution
self.__limits[self.__AVAILABLE_RESOLUTIONS] = available_resolutions
if self.__has_h264:
self.__params[self.__H264_BITRATE] = min(max(h264_bitrate, h264_bitrate_min), h264_bitrate_max)
self.__limits[self.__H264_BITRATE] = {"min": h264_bitrate_min, "max": h264_bitrate_max}
self.__params[self.__H264_GOP] = min(max(h264_gop, h264_gop_min), h264_gop_max)
self.__limits[self.__H264_GOP] = {"min": h264_gop_min, "max": h264_gop_max}
def get_features(self) -> dict:
return {
self.__QUALITY: self.__has_quality,
self.__RESOLUTION: self.__has_resolution,
self.__H264: self.__has_h264,
}
def get_limits(self) -> dict:
limits = copy.deepcopy(self.__limits)
if self.__has_resolution:
limits[self.__AVAILABLE_RESOLUTIONS] = list(limits[self.__AVAILABLE_RESOLUTIONS])
return limits
def get_params(self) -> dict:
return dict(self.__params)
def set_params(self, params: dict) -> None:
new = dict(self.__params)
if self.__QUALITY in params and self.__has_quality:
new[self.__QUALITY] = min(max(params[self.__QUALITY], 1), 100)
if self.__RESOLUTION in params and self.__has_resolution:
if params[self.__RESOLUTION] in self.__limits[self.__AVAILABLE_RESOLUTIONS]:
new[self.__RESOLUTION] = params[self.__RESOLUTION]
for (key, enabled) in [
(self.__DESIRED_FPS, True),
(self.__H264_BITRATE, self.__has_h264),
(self.__H264_GOP, self.__has_h264),
]:
if key in params and enabled:
if self.__check_limits_min_max(key, params[key]):
new[key] = params[key]
self.__params = new
def __check_limits_min_max(self, key: str, value: int) -> bool:
return (self.__limits[key]["min"] <= value <= self.__limits[key]["max"])

View File

@ -1,182 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import asyncio
import asyncio.subprocess
from ....logging import get_logger
from .... import tools
from .... import aiotools
from .... import aioproc
# =====
class Runner: # pylint: disable=too-many-instance-attributes
def __init__(
self,
reset_delay: float,
shutdown_delay: float,
pre_start_cmd: list[str],
cmd: list[str],
post_stop_cmd: list[str],
) -> None:
self.__reset_delay = reset_delay
self.__shutdown_delay = shutdown_delay
self.__pre_start_cmd: list[str] = pre_start_cmd
self.__cmd: list[str] = cmd
self.__post_stop_cmd: list[str] = post_stop_cmd
self.__proc_params: dict = {}
self.__proc_task: (asyncio.Task | None) = None
self.__proc: (asyncio.subprocess.Process | None) = None # pylint: disable=no-member
self.__stopper_task: (asyncio.Task | None) = None
self.__stopper_wip = False
@aiotools.atomic_fg
async def ensure_start(self, params: dict) -> None:
if not self.__proc_task or self.__stopper_task:
logger = get_logger(0)
if self.__stopper_task:
if not self.__stopper_wip:
self.__stopper_task.cancel()
await asyncio.gather(self.__stopper_task, return_exceptions=True)
logger.info("Streamer stop cancelled")
return
else:
await asyncio.gather(self.__stopper_task, return_exceptions=True)
logger.info("Starting streamer ...")
await self.__inner_start(params)
@aiotools.atomic_fg
async def ensure_restart(self, params: dict) -> None:
logger = get_logger(0)
start = bool(self.__proc_task and not self.__stopper_task) # Если запущено и не планирует останавливаться
await self.ensure_stop(immediately=True)
if self.__reset_delay > 0:
logger.info("Waiting %.2f seconds for reset delay ...", self.__reset_delay)
await asyncio.sleep(self.__reset_delay)
if start:
await self.ensure_start(params)
@aiotools.atomic_fg
async def ensure_stop(self, immediately: bool) -> None:
if self.__proc_task:
logger = get_logger(0)
if immediately:
if self.__stopper_task:
if not self.__stopper_wip:
self.__stopper_task.cancel()
await asyncio.gather(self.__stopper_task, return_exceptions=True)
logger.info("Stopping streamer immediately ...")
await self.__inner_stop()
else:
await asyncio.gather(self.__stopper_task, return_exceptions=True)
else:
logger.info("Stopping streamer immediately ...")
await self.__inner_stop()
elif not self.__stopper_task:
async def delayed_stop() -> None:
try:
await asyncio.sleep(self.__shutdown_delay)
self.__stopper_wip = True
logger.info("Stopping streamer after delay ...")
await self.__inner_stop()
finally:
self.__stopper_task = None
self.__stopper_wip = False
logger.info("Planning to stop streamer in %.2f seconds ...", self.__shutdown_delay)
self.__stopper_task = asyncio.create_task(delayed_stop())
def is_running(self) -> bool:
return bool(self.__proc_task)
# =====
@aiotools.atomic_fg
async def __inner_start(self, params: dict) -> None:
assert not self.__proc_task
self.__proc_params = params
await self.__run_hook("PRE-START-CMD", self.__pre_start_cmd)
self.__proc_task = asyncio.create_task(self.__process_task_loop())
@aiotools.atomic_fg
async def __inner_stop(self) -> None:
assert self.__proc_task
self.__proc_task.cancel()
await asyncio.gather(self.__proc_task, return_exceptions=True)
await self.__kill_process()
await self.__run_hook("POST-STOP-CMD", self.__post_stop_cmd)
self.__proc_task = None
# =====
async def __process_task_loop(self) -> None: # pylint: disable=too-many-branches
logger = get_logger(0)
while True: # pylint: disable=too-many-nested-blocks
try:
await self.__start_process()
assert self.__proc is not None
await aioproc.log_stdout_infinite(self.__proc, logger)
raise RuntimeError("Streamer unexpectedly died")
except asyncio.CancelledError:
break
except Exception:
if self.__proc:
logger.exception("Unexpected streamer error: pid=%d", self.__proc.pid)
else:
logger.exception("Can't start streamer")
await self.__kill_process()
await asyncio.sleep(1)
def __make_cmd(self, cmd: list[str]) -> list[str]:
return [part.format(**self.__proc_params) for part in cmd]
async def __run_hook(self, name: str, cmd: list[str]) -> None:
logger = get_logger()
cmd = self.__make_cmd(cmd)
logger.info("%s: %s", name, tools.cmdfmt(cmd))
try:
await aioproc.log_process(cmd, logger, prefix=name)
except Exception:
logger.exception("Can't execute %s hook: %s", name, tools.cmdfmt(cmd))
async def __start_process(self) -> None:
assert self.__proc is None
cmd = self.__make_cmd(self.__cmd)
self.__proc = await aioproc.run_process(cmd)
get_logger(0).info("Started streamer pid=%d: %s", self.__proc.pid, tools.cmdfmt(cmd))
async def __kill_process(self) -> None:
if self.__proc:
await aioproc.kill_process(self.__proc, 1, get_logger(0))
self.__proc = None

View File

@ -32,7 +32,6 @@ from .lib import Inotify
from .types import Edid
from .types import Edids
from .types import Dummies
from .types import Color
from .types import Colors
from .types import PortNames
@ -69,7 +68,6 @@ class SwitchUnknownEdidError(SwitchOperationError):
# =====
class Switch: # pylint: disable=too-many-public-methods
__X_EDIDS = "edids"
__X_DUMMIES = "dummies"
__X_COLORS = "colors"
__X_PORT_NAMES = "port_names"
__X_ATX_CP_DELAYS = "atx_cp_delays"
@ -77,7 +75,7 @@ class Switch: # pylint: disable=too-many-public-methods
__X_ATX_CR_DELAYS = "atx_cr_delays"
__X_ALL = frozenset([
__X_EDIDS, __X_DUMMIES, __X_COLORS, __X_PORT_NAMES,
__X_EDIDS, __X_COLORS, __X_PORT_NAMES,
__X_ATX_CP_DELAYS, __X_ATX_CPL_DELAYS, __X_ATX_CR_DELAYS,
])
@ -86,12 +84,11 @@ class Switch: # pylint: disable=too-many-public-methods
device_path: str,
default_edid_path: str,
pst_unix_path: str,
ignore_hpd_on_top: bool,
) -> None:
self.__default_edid_path = default_edid_path
self.__chain = Chain(device_path, ignore_hpd_on_top)
self.__chain = Chain(device_path)
self.__cache = StateCache()
self.__storage = Storage(pst_unix_path)
@ -107,12 +104,6 @@ class Switch: # pylint: disable=too-many-public-methods
if save:
self.__save_notifier.notify()
def __x_set_dummies(self, dummies: Dummies, save: bool=True) -> None:
self.__chain.set_dummies(dummies)
self.__cache.set_dummies(dummies)
if save:
self.__save_notifier.notify()
def __x_set_colors(self, colors: Colors, save: bool=True) -> None:
self.__chain.set_colors(colors)
self.__cache.set_colors(colors)
@ -141,19 +132,13 @@ class Switch: # pylint: disable=too-many-public-methods
# =====
async def set_active_prev(self) -> None:
self.__chain.set_active_prev()
async def set_active_next(self) -> None:
self.__chain.set_active_next()
async def set_active_port(self, port: float) -> None:
self.__chain.set_active_port(self.__chain.translate_port(port))
async def set_active_port(self, port: int) -> None:
self.__chain.set_active_port(port)
# =====
async def set_port_beacon(self, port: float, on: bool) -> None:
self.__chain.set_port_beacon(self.__chain.translate_port(port), on)
async def set_port_beacon(self, port: int, on: bool) -> None:
self.__chain.set_port_beacon(port, on)
async def set_uplink_beacon(self, unit: int, on: bool) -> None:
self.__chain.set_uplink_beacon(unit, on)
@ -163,35 +148,33 @@ class Switch: # pylint: disable=too-many-public-methods
# =====
async def atx_power_on(self, port: float) -> None:
async def atx_power_on(self, port: int) -> None:
self.__inner_atx_cp(port, False, self.__X_ATX_CP_DELAYS)
async def atx_power_off(self, port: float) -> None:
async def atx_power_off(self, port: int) -> None:
self.__inner_atx_cp(port, True, self.__X_ATX_CP_DELAYS)
async def atx_power_off_hard(self, port: float) -> None:
async def atx_power_off_hard(self, port: int) -> None:
self.__inner_atx_cp(port, True, self.__X_ATX_CPL_DELAYS)
async def atx_power_reset_hard(self, port: float) -> None:
async def atx_power_reset_hard(self, port: int) -> None:
self.__inner_atx_cr(port, True)
async def atx_click_power(self, port: float) -> None:
async def atx_click_power(self, port: int) -> None:
self.__inner_atx_cp(port, None, self.__X_ATX_CP_DELAYS)
async def atx_click_power_long(self, port: float) -> None:
async def atx_click_power_long(self, port: int) -> None:
self.__inner_atx_cp(port, None, self.__X_ATX_CPL_DELAYS)
async def atx_click_reset(self, port: float) -> None:
async def atx_click_reset(self, port: int) -> None:
self.__inner_atx_cr(port, None)
def __inner_atx_cp(self, port: float, if_powered: (bool | None), x_delay: str) -> None:
def __inner_atx_cp(self, port: int, if_powered: (bool | None), x_delay: str) -> None:
assert x_delay in [self.__X_ATX_CP_DELAYS, self.__X_ATX_CPL_DELAYS]
port = self.__chain.translate_port(port)
delay = getattr(self.__cache, f"get_{x_delay}")()[port]
self.__chain.click_power(port, delay, if_powered)
def __inner_atx_cr(self, port: float, if_powered: (bool | None)) -> None:
port = self.__chain.translate_port(port)
def __inner_atx_cr(self, port: int, if_powered: (bool | None)) -> None:
delay = self.__cache.get_atx_cr_delays()[port]
self.__chain.click_reset(port, delay, if_powered)
@ -252,14 +235,12 @@ class Switch: # pylint: disable=too-many-public-methods
self,
port: int,
edid_id: (str | None)=None,
dummy: (bool | None)=None,
name: (str | None)=None,
atx_click_power_delay: (float | None)=None,
atx_click_power_long_delay: (float | None)=None,
atx_click_reset_delay: (float | None)=None,
) -> None:
port = self.__chain.translate_port(port)
async with self.__lock:
if edid_id is not None:
edids = self.__cache.get_edids()
@ -268,16 +249,15 @@ class Switch: # pylint: disable=too-many-public-methods
edids.assign(port, edid_id)
self.__x_set_edids(edids)
for (reset, key, value) in [
(None, self.__X_DUMMIES, dummy), # None can't be used now
("", self.__X_PORT_NAMES, name),
(0, self.__X_ATX_CP_DELAYS, atx_click_power_delay),
(0, self.__X_ATX_CPL_DELAYS, atx_click_power_long_delay),
(0, self.__X_ATX_CR_DELAYS, atx_click_reset_delay),
for (key, value) in [
(self.__X_PORT_NAMES, name),
(self.__X_ATX_CP_DELAYS, atx_click_power_delay),
(self.__X_ATX_CPL_DELAYS, atx_click_power_long_delay),
(self.__X_ATX_CR_DELAYS, atx_click_reset_delay),
]:
if value is not None:
new = getattr(self.__cache, f"get_{key}")()
new[port] = (None if value == reset else value) # Value or reset default
new[port] = (value or None) # None == reset to default
getattr(self, f"_Switch__x_set_{key}")(new)
# =====
@ -394,7 +374,7 @@ class Switch: # pylint: disable=too-many-public-methods
prevs = dict.fromkeys(self.__X_ALL)
while True:
await self.__save_notifier.wait()
while not (await self.__save_notifier.wait(5)):
while (await self.__save_notifier.wait(5)):
pass
while True:
try:

View File

@ -34,7 +34,6 @@ from .lib import aiotools
from .lib import aioproc
from .types import Edids
from .types import Dummies
from .types import Colors
from .proto import Response
@ -55,14 +54,6 @@ class _CmdSetActual(_BaseCmd):
actual: bool
class _CmdSetActivePrev(_BaseCmd):
pass
class _CmdSetActiveNext(_BaseCmd):
pass
@dataclasses.dataclass(frozen=True)
class _CmdSetActivePort(_BaseCmd):
port: int
@ -89,11 +80,6 @@ class _CmdSetEdids(_BaseCmd):
edids: Edids
@dataclasses.dataclass(frozen=True)
class _CmdSetDummies(_BaseCmd):
dummies: Dummies
@dataclasses.dataclass(frozen=True)
class _CmdSetColors(_BaseCmd):
colors: Colors
@ -191,19 +177,13 @@ class UnitAtxLedsEvent(BaseEvent):
# =====
class Chain: # pylint: disable=too-many-instance-attributes
def __init__(
self,
device_path: str,
ignore_hpd_on_top: bool,
) -> None:
def __init__(self, device_path: str) -> None:
self.__device = Device(device_path)
self.__ignore_hpd_on_top = ignore_hpd_on_top
self.__actual = False
self.__edids = Edids()
self.__dummies = Dummies({})
self.__colors = Colors()
self.__units: list[_UnitContext] = []
@ -220,24 +200,6 @@ class Chain: # pylint: disable=too-many-instance-attributes
# =====
def translate_port(self, port: float) -> int:
assert port >= 0
if int(port) == port:
return int(port)
(unit, ch) = map(int, str(port).split("."))
unit = min(max(unit, 1), 5)
ch = min(max(ch, 1), 4)
port = min((unit - 1) * 4 + (ch - 1), 19)
return port
# =====
def set_active_prev(self) -> None:
self.__queue_cmd(_CmdSetActivePrev())
def set_active_next(self) -> None:
self.__queue_cmd(_CmdSetActiveNext())
def set_active_port(self, port: int) -> None:
self.__queue_cmd(_CmdSetActivePort(port))
@ -257,9 +219,6 @@ class Chain: # pylint: disable=too-many-instance-attributes
def set_edids(self, edids: Edids) -> None:
self.__queue_cmd(_CmdSetEdids(edids)) # Will be copied because of multiprocessing.Queue()
def set_dummies(self, dummies: Dummies) -> None:
self.__queue_cmd(_CmdSetDummies(dummies))
def set_colors(self, colors: Colors) -> None:
self.__queue_cmd(_CmdSetColors(colors))
@ -331,21 +290,12 @@ class Chain: # pylint: disable=too-many-instance-attributes
self.__device.request_state()
self.__device.request_atx_leds()
while not self.__stop_event.is_set():
count = 0
if self.__select():
count = 0
for resp in self.__device.read_all():
self.__update_units(resp)
self.__adjust_quirks()
self.__adjust_start_port()
self.__finish_changing_request(resp)
self.__consume_commands()
else:
count += 1
if count >= 5:
# Heartbeat
self.__device.request_state()
count = 0
self.__ensure_config()
def __select(self) -> bool:
@ -364,29 +314,10 @@ class Chain: # pylint: disable=too-many-instance-attributes
case _CmdSetActual():
self.__actual = cmd.actual
case _CmdSetActivePrev():
if len(self.__units) > 0:
port = self.__active_port
port -= 1
if port >= 0:
self.__active_port = port
self.__queue_event(PortActivatedEvent(self.__active_port))
case _CmdSetActiveNext():
port = self.__active_port
if port < 0:
port = 0
else:
port += 1
if port < len(self.__units) * 4:
self.__active_port = port
self.__queue_event(PortActivatedEvent(self.__active_port))
case _CmdSetActivePort():
# Может быть вызвано изнутри при синхронизации
if cmd.port < len(self.__units) * 4:
self.__active_port = cmd.port
self.__queue_event(PortActivatedEvent(self.__active_port))
self.__active_port = cmd.port
self.__queue_event(PortActivatedEvent(self.__active_port))
case _CmdSetPortBeacon():
(unit, ch) = self.get_real_unit_channel(cmd.port)
@ -410,9 +341,6 @@ class Chain: # pylint: disable=too-many-instance-attributes
case _CmdSetEdids():
self.__edids = cmd.edids
case _CmdSetDummies():
self.__dummies = cmd.dummies
case _CmdSetColors():
self.__colors = cmd.colors
@ -436,15 +364,6 @@ class Chain: # pylint: disable=too-many-instance-attributes
self.__units[resp.header.unit].atx_leds = resp.body
self.__queue_event(UnitAtxLedsEvent(resp.header.unit, resp.body))
def __adjust_quirks(self) -> None:
for (unit, ctx) in enumerate(self.__units):
if ctx.state is not None and ctx.state.version.is_fresh(7):
ignore_hpd = (unit == 0 and self.__ignore_hpd_on_top)
if ctx.state.quirks.ignore_hpd != ignore_hpd:
get_logger().info("Applying quirk ignore_hpd=%s to [%d] ...",
ignore_hpd, unit)
self.__device.request_set_quirks(unit, ignore_hpd)
def __adjust_start_port(self) -> None:
if self.__active_port < 0:
for (unit, ctx) in enumerate(self.__units):
@ -468,7 +387,6 @@ class Chain: # pylint: disable=too-many-instance-attributes
self.__ensure_config_port(unit, ctx)
if self.__actual:
self.__ensure_config_edids(unit, ctx)
self.__ensure_config_dummies(unit, ctx)
self.__ensure_config_colors(unit, ctx)
def __ensure_config_port(self, unit: int, ctx: _UnitContext) -> None:
@ -495,19 +413,6 @@ class Chain: # pylint: disable=too-many-instance-attributes
ctx.changing_rid = self.__device.request_set_edid(unit, ch, edid)
break # Busy globally
def __ensure_config_dummies(self, unit: int, ctx: _UnitContext) -> None:
assert ctx.state is not None
if ctx.state.version.is_fresh(8) and ctx.can_be_changed():
for ch in range(4):
port = self.get_virtual_port(unit, ch)
dummy = self.__dummies[port]
if ctx.state.video_dummies[ch] != dummy:
get_logger().info("Changing dummy flag on port %d on [%d:%d]: %d -> %d ...",
port, unit, ch,
ctx.state.video_dummies[ch], dummy)
ctx.changing_rid = self.__device.request_set_dummy(unit, ch, dummy)
break # Busy globally (actually not but it can be changed in the firmware)
def __ensure_config_colors(self, unit: int, ctx: _UnitContext) -> None:
assert self.__actual
assert ctx.state is not None

View File

@ -41,9 +41,7 @@ from .proto import BodySetBeacon
from .proto import BodyAtxClick
from .proto import BodySetEdid
from .proto import BodyClearEdid
from .proto import BodySetDummy
from .proto import BodySetColors
from .proto import BodySetQuirks
# =====
@ -165,15 +163,9 @@ class Device:
return self.__send_request(Header.SET_EDID, unit, BodySetEdid(ch, edid))
return self.__send_request(Header.CLEAR_EDID, unit, BodyClearEdid(ch))
def request_set_dummy(self, unit: int, ch: int, on: bool) -> int:
return self.__send_request(Header.SET_DUMMY, unit, BodySetDummy(ch, on))
def request_set_colors(self, unit: int, ch: int, colors: Colors) -> int:
return self.__send_request(Header.SET_COLORS, unit, BodySetColors(ch, colors))
def request_set_quirks(self, unit: int, ignore_hpd: bool) -> int:
return self.__send_request(Header.SET_QUIRKS, unit, BodySetQuirks(ignore_hpd))
def __send_request(self, op: int, unit: int, body: (Packable | None)) -> int:
assert self.__tty is not None
req = Request(Header(

View File

@ -60,8 +60,6 @@ class Header(Packable, Unpackable):
SET_EDID = 9
CLEAR_EDID = 10
SET_COLORS = 12
SET_QUIRKS = 13
SET_DUMMY = 14
__struct = struct.Struct("<BHBB")
@ -91,32 +89,17 @@ class Nak(Unpackable):
return Nak(*cls.__struct.unpack_from(data, offset=offset))
@dataclasses.dataclass(frozen=True)
class UnitVersion:
hw: int
sw: int
sw_dev: bool
def is_fresh(self, version: int) -> bool:
return (self.sw_dev or (self.sw >= version))
@dataclasses.dataclass(frozen=True)
class UnitFlags:
changing_busy: bool
flashing_busy: bool
has_downlink: bool
has_hpd: bool
@dataclasses.dataclass(frozen=True)
class UnitQuirks:
ignore_hpd: bool
@dataclasses.dataclass(frozen=True)
class UnitState(Unpackable): # pylint: disable=too-many-instance-attributes
version: UnitVersion
sw_version: int
hw_version: int
flags: UnitFlags
ch: int
beacons: tuple[bool, bool, bool, bool, bool, bool]
@ -125,12 +108,10 @@ class UnitState(Unpackable): # pylint: disable=too-many-instance-attributes
video_hpd: tuple[bool, bool, bool, bool, bool]
video_edid: tuple[bool, bool, bool, bool]
video_crc: tuple[int, int, int, int]
video_dummies: tuple[bool, bool, bool, bool]
usb_5v_sens: tuple[bool, bool, bool, bool]
atx_busy: tuple[bool, bool, bool, bool]
quirks: UnitQuirks
__struct = struct.Struct("<HHHBBHHHHHHBBBHHHHBxBBB28x")
__struct = struct.Struct("<HHHBBHHHHHHBBBHHHHBxB30x")
def compare_edid(self, ch: int, edid: Optional["Edid"]) -> bool:
if edid is None:
@ -147,19 +128,15 @@ class UnitState(Unpackable): # pylint: disable=too-many-instance-attributes
sw_version, hw_version, flags, ch,
beacons, nc0, nc1, nc2, nc3, nc4, nc5,
video_5v_sens, video_hpd, video_edid, vc0, vc1, vc2, vc3,
usb_5v_sens, atx_busy, quirks, video_dummies,
usb_5v_sens, atx_busy,
) = cls.__struct.unpack_from(data, offset=offset)
return UnitState(
version=UnitVersion(
hw=hw_version,
sw=(sw_version & 0x7FFF),
sw_dev=bool(sw_version & 0x8000),
),
sw_version,
hw_version,
flags=UnitFlags(
changing_busy=bool(flags & 0x80),
flashing_busy=bool(flags & 0x40),
has_downlink=bool(flags & 0x02),
has_hpd=bool(flags & 0x04),
),
ch=ch,
beacons=cls.__make_flags6(beacons),
@ -168,10 +145,8 @@ class UnitState(Unpackable): # pylint: disable=too-many-instance-attributes
video_hpd=cls.__make_flags5(video_hpd),
video_edid=cls.__make_flags4(video_edid),
video_crc=(vc0, vc1, vc2, vc3),
video_dummies=cls.__make_flags4(video_dummies),
usb_5v_sens=cls.__make_flags4(usb_5v_sens),
atx_busy=cls.__make_flags4(atx_busy),
quirks=UnitQuirks(ignore_hpd=bool(quirks & 0x01)),
)
@classmethod
@ -276,18 +251,6 @@ class BodyClearEdid(Packable):
return self.ch.to_bytes()
@dataclasses.dataclass(frozen=True)
class BodySetDummy(Packable):
ch: int
on: bool
def __post_init__(self) -> None:
assert 0 <= self.ch <= 3
def pack(self) -> bytes:
return self.ch.to_bytes() + self.on.to_bytes()
@dataclasses.dataclass(frozen=True)
class BodySetColors(Packable):
ch: int
@ -300,14 +263,6 @@ class BodySetColors(Packable):
return self.ch.to_bytes() + self.colors.pack()
@dataclasses.dataclass(frozen=True)
class BodySetQuirks(Packable):
ignore_hpd: bool
def pack(self) -> bytes:
return self.ignore_hpd.to_bytes()
# =====
@dataclasses.dataclass(frozen=True)
class Request:

View File

@ -27,7 +27,6 @@ import time
from typing import AsyncGenerator
from .types import Edids
from .types import Dummies
from .types import Color
from .types import Colors
from .types import PortNames
@ -49,8 +48,8 @@ class _UnitInfo:
# =====
class StateCache: # pylint: disable=too-many-instance-attributes,too-many-public-methods
__FW_VERSION = 8
class StateCache: # pylint: disable=too-many-instance-attributes
__FW_VERSION = 5
__FULL = 0xFFFF
__SUMMARY = 0x01
@ -63,7 +62,6 @@ class StateCache: # pylint: disable=too-many-instance-attributes,too-many-publi
def __init__(self) -> None:
self.__edids = Edids()
self.__dummies = Dummies({})
self.__colors = Colors()
self.__port_names = PortNames({})
self.__atx_cp_delays = AtxClickPowerDelays({})
@ -79,9 +77,6 @@ class StateCache: # pylint: disable=too-many-instance-attributes,too-many-publi
def get_edids(self) -> Edids:
return self.__edids.copy()
def get_dummies(self) -> Dummies:
return self.__dummies.copy()
def get_colors(self) -> Colors:
return self.__colors
@ -163,17 +158,7 @@ class StateCache: # pylint: disable=too-many-instance-attributes,too-many-publi
},
}
if x_summary:
state["summary"] = {
"active_port": self.__active_port,
"active_id": (
"" if self.__active_port < 0 else (
f"{self.__active_port // 4 + 1}.{self.__active_port % 4 + 1}"
if len(self.__units) > 1 else
f"{self.__active_port + 1}"
)
),
"synced": self.__synced,
}
state["summary"] = {"active_port": self.__active_port, "synced": self.__synced}
if x_edids:
state["edids"] = {
"all": {
@ -210,10 +195,7 @@ class StateCache: # pylint: disable=too-many-instance-attributes,too-many-publi
assert ui.state is not None
assert ui.atx_leds is not None
if x_model:
state["model"]["units"].append({"firmware": {
"version": ui.state.version.sw,
"devbuild": ui.state.version.sw_dev,
}})
state["model"]["units"].append({"firmware": {"version": ui.state.sw_version}})
if x_video:
state["video"]["links"].extend(ui.state.video_5v_sens[:4])
if x_usb:
@ -234,7 +216,6 @@ class StateCache: # pylint: disable=too-many-instance-attributes,too-many-publi
"unit": unit,
"channel": ch,
"name": self.__port_names[port],
"id": (f"{unit + 1}.{ch + 1}" if len(self.__units) > 1 else f"{ch + 1}"),
"atx": {
"click_delays": {
"power": self.__atx_cp_delays[port],
@ -242,9 +223,6 @@ class StateCache: # pylint: disable=too-many-instance-attributes,too-many-publi
"reset": self.__atx_cr_delays[port],
},
},
"video": {
"dummy": self.__dummies[port],
},
})
if x_edids:
state["edids"]["used"].append(self.__edids.get_id_for_port(port))
@ -346,12 +324,6 @@ class StateCache: # pylint: disable=too-many-instance-attributes,too-many-publi
if changed:
self.__bump_state(self.__EDIDS)
def set_dummies(self, dummies: Dummies) -> None:
changed = (not self.__dummies.compare_on_ports(dummies, self.__get_ports()))
self.__dummies = dummies.copy()
if changed:
self.__bump_state(self.__FULL)
def set_colors(self, colors: Colors) -> None:
changed = (self.__colors != colors)
self.__colors = colors

View File

@ -39,7 +39,6 @@ from .lib import get_logger
from .types import Edid
from .types import Edids
from .types import Dummies
from .types import Color
from .types import Colors
from .types import PortNames
@ -53,8 +52,6 @@ class StorageContext:
__F_EDIDS_ALL = "edids_all.json"
__F_EDIDS_PORT = "edids_port.json"
__F_DUMMIES = "dummies.json"
__F_COLORS = "colors.json"
__F_PORT_NAMES = "port_names.json"
@ -77,9 +74,6 @@ class StorageContext:
})
await self.__write_json_keyvals(self.__F_EDIDS_PORT, edids.port)
async def write_dummies(self, dummies: Dummies) -> None:
await self.__write_json_keyvals(self.__F_DUMMIES, dummies.kvs)
async def write_colors(self, colors: Colors) -> None:
await self.__write_json_keyvals(self.__F_COLORS, {
role: {
@ -122,10 +116,6 @@ class StorageContext:
port_edids = await self.__read_json_keyvals_int(self.__F_EDIDS_PORT)
return Edids(all_edids, port_edids)
async def read_dummies(self) -> Dummies:
kvs = await self.__read_json_keyvals_int(self.__F_DUMMIES)
return Dummies({key: bool(value) for (key, value) in kvs.items()})
async def read_colors(self) -> Colors:
raw = await self.__read_json_keyvals(self.__F_COLORS)
return Colors(**{ # type: ignore

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