Compare commits

...

117 Commits

Author SHA1 Message Date
mofeng-git
6002dfd9c7 更新说明文档 2025-07-30 00:58:17 +08:00
SilentWind
cf6addeb0f Update README.md
更新 CDN 加速赞助信息
2025-07-15 19:28:35 +08:00
SilentWind
8560a46f17 Update issue templates 2025-06-12 00:21:04 +08:00
SilentWind
d4b4cdc492 Update issue templates 2025-06-12 00:12:06 +08:00
SilentWind
687cea3658 Update issue templates 2025-06-12 00:11:17 +08:00
SilentWind
12c7566581 Update issue templates 2025-06-12 00:09:20 +08:00
SilentWind
10fb78abe6 Delete .github/workflows/docker-build-stage-0.yaml 2025-06-01 10:59:34 +08:00
mofeng-git
1642ce73a0 一些更新
玩客云首次启动生成随机 mac 地址 #113
自签 SSL 证书修改为首次启动时生成,增强安全性
优化整合包镜像构建脚本和  Dockerfile
修复 docker 启动脚本错误
2025-05-27 12:43:53 +08:00
mofeng-git
64c83be0a4 修改 janus 日志等级为4,显示更多janus日志 2025-05-26 23:25:50 +08:00
mofeng-git
76ca81bbfd 修复音频错误
更新 janus 配置文件,将音频部分由 audio 重命名为 acap,与 ustreamer janus 插源代码保持一致。
2025-05-11 21:38:32 +08:00
mofeng-git
ed7b2e5b33 添加初步的 gitea actions 构建支持 2025-05-11 20:21:18 +08:00
mofeng-git
a92a6f2811 更新百度网盘链接的密码 2025-04-17 03:24:46 +00:00
mofeng-git
3d58f6dd21 更新 README.md 文件 2025-04-17 03:22:50 +00:00
mofeng-git
50022e7353 重构镜像构建脚本,增强了灵活性和可维护性。主要改动包括:
1. 允许通过环境变量覆盖默认路径。
2. 添加清理函数以确保资源释放。
3. 优化了挂载和卸载逻辑,确保在脚本退出时自动清理。
4. 增强了错误处理和日志输出,提供更清晰的信息反馈。
5. 重新组织了函数结构,提升代码可读性。
2025-04-17 03:12:47 +00:00
mofeng-git
ff4f04d936 更新说明 2025-03-28 04:30:18 +00:00
mofeng-git
413fce72ec 更新镜像构建脚本 2025-03-15 12:51:23 +00:00
mofeng-git
842238009e 将默认采集帧率由 30fps 修改为 60fps 2025-03-15 11:47:46 +00:00
mofeng-git
7116da2511 添加 otg 文件夹创建判断,避免文件夹存在导致程序退出 2025-03-11 12:52:19 +00:00
mofeng-git
a59fe84e26 修改 supervisord 配置文件为默认路径 2025-03-11 12:50:10 +00:00
mofeng-git
48fe790897 大幅优化镜像体积
1. 使用 docker 预构建的必需文件,而不是安装全量依赖
2. 使用 zerofree 清除镜像无效数据
将 janus 日志级别调整为2
2025-03-10 10:56:16 +00:00
mofeng-git
e375e41fb6 更新赞助信息 2025-03-06 14:41:59 +00:00
mofeng-git
8c8bf35d0b fix 2025-02-02 07:08:40 +00:00
mofeng-git
f032b8c798 fix 2025-02-01 14:40:28 +00:00
mofeng-git
c711683c63 som fix 2025-02-01 12:19:02 +00:00
mofeng-git
06a64725be 修改源文件版权声明 2025-02-01 10:55:41 +00:00
mofeng-git
94897ab8c9 Merge branch 'main' of https://github.com/mofeng-git/One-KVM 2025-02-01 09:13:29 +00:00
mofeng-git
c6a5ffa0cf Merge remote-tracking branch 'upstream/master' 2025-02-01 09:12:03 +00:00
mofeng-git
9da06d3f58 修复 HTTP H.264 模式
其他修改
2025-02-01 08:31:27 +00:00
mofeng-git
7b3335ea94 Add support for PiKVM Switch and related features
This commit introduces several new components and improvements:
- Added Switch module with firmware update and configuration support
- Implemented new media streaming capabilities
- Updated various UI elements and CSS styles
- Enhanced keyboard and mouse event handling
- Added new validators and configuration options
- Updated Python version support to 3.13
- Improved error handling and logging
2025-02-01 01:08:36 +00:00
Warfront1
15dbe6265f feat: fix cause on switch pop-up (#185) 2025-01-25 08:40:48 +02:00
Maxim Devaev
b2c8ed6818 Bump version: 4.48 → 4.49 2025-01-24 05:33:43 +02:00
Maxim Devaev
2acd613a38 dvd support 2025-01-24 05:24:40 +02:00
Maxim Devaev
0202a3c2d1 Bump version: 4.47 → 4.48 2025-01-21 05:41:57 +02:00
Maxim Devaev
be3e97178d moved to python-3.13 2025-01-21 05:41:25 +02:00
Maxim Devaev
dafc8e3941 Bump version: 4.46 → 4.47 2025-01-20 16:45:49 +02:00
Maxim Devaev
6dcc41601e janus: check file for aplay 2025-01-20 16:43:32 +02:00
Maxim Devaev
b9af5f8825 Bump version: 4.45 → 4.46 2025-01-20 02:17:26 +02:00
Maxim Devaev
00ed5197b0 add some otg info 2025-01-20 02:16:55 +02:00
Maxim Devaev
b2c5305564 Bump version: 4.44 → 4.45 2025-01-19 18:24:21 +02:00
Maxim Devaev
e9443119ec required ustreamer 6.24 at least 2025-01-19 18:20:39 +02:00
Maxim Devaev
ab5608e3e0 Bump version: 4.43 → 4.44 2025-01-18 22:01:39 +02:00
Maxim Devaev
78557b0c47 Merge branch 'mic' 2025-01-18 21:57:48 +02:00
Maxim Devaev
f042ed38e0 usb microphone 2025-01-18 20:28:24 +02:00
Maxim Devaev
e1e3605630 Bump version: 4.42 → 4.43 2025-01-16 15:01:01 +02:00
Maxim Devaev
3f3a834c0c pikvm/pikvm#1459: Extended TOTP window with a single step (+30sec) 2025-01-16 14:57:05 +02:00
Maxim Devaev
8631ee8555 web: fixed gray icon on http/h264 2025-01-16 14:40:04 +02:00
Maxim Devaev
da4da975ef Revert "pikvm/pikvm#1459: TOTP valid_window=5"
This reverts commit b6c73aceb7.
2025-01-15 02:49:10 +02:00
Maxim Devaev
b6c73aceb7 pikvm/pikvm#1459: TOTP valid_window=5 2025-01-15 02:06:01 +02:00
Maxim Devaev
d3549ab52b Bump version: 4.41 → 4.42 2025-01-11 22:21:39 +02:00
Maxim Devaev
965e649f8c switch update notification 2025-01-11 22:20:56 +02:00
Maxim Devaev
b49107ff6c Bump version: 4.40 → 4.41 2025-01-11 21:27:08 +02:00
Maxim Devaev
e9cbf04ba5 kvmd-otgmsd: allow to connect all file types 2025-01-11 21:26:26 +02:00
Maxim Devaev
3cf543a13e switch binary 2025-01-11 21:22:17 +02:00
Maxim Devaev
4d89d6b222 Bump version: 4.39 → 4.40 2025-01-10 23:23:49 +02:00
Maxim Devaev
e7c06643b4 refactoring 2025-01-10 23:04:12 +02:00
Maxim Devaev
72c9ae3aa0 improved jiggler logic 2025-01-10 22:56:28 +02:00
Maxim Devaev
05bced1461 Bump version: 4.38 → 4.39 2025-01-10 14:26:11 +02:00
Maxim Devaev
464672d1a0 enabled jiggler by default 2025-01-10 14:24:43 +02:00
SilentWind
1061a6ba01 Merge pull request #70 from soulteary/fix/license-conflict
fix: license conflict
2025-01-06 10:37:15 +08:00
Maxim Devaev
be6843a486 Bump version: 4.37 → 4.38 2025-01-05 20:49:38 +02:00
Maxim Devaev
f5de6a0f2e moving to janus 1.x 2025-01-05 20:48:35 +02:00
Su Yang
21b7429ffe fix: license conflict 2025-01-05 22:19:31 +08:00
Maxim Devaev
9ef1a3665a Bump version: 4.36 → 4.37 2025-01-05 15:24:20 +02:00
Maxim Devaev
10a7ca978b Bump version: 4.35 → 4.36 2025-01-05 15:19:54 +02:00
Maxim Devaev
4488365dfb removed _state suffix from all ws events 2025-01-05 15:19:04 +02:00
Maxim Devaev
5a61ddecd3 Removed ws legacy mode and some msd legacy 2025-01-05 15:11:27 +02:00
Maxim Devaev
a12163a797 kvmd-media: renamed kind to type 2025-01-05 14:43:20 +02:00
Maxim Devaev
43e6cd3e26 usb: kvmd-otgconf now calculates endpoints before operation 2025-01-05 14:17:52 +02:00
Maxim Devaev
57518468ad usb: max endpoints is 9 2025-01-05 14:14:17 +02:00
Maxim Devaev
5973b9e773 kvmd-otgconf: Ignore some errors 2025-01-05 02:34:11 +02:00
Maxim Devaev
e120b50f50 usb: max endpoints is 10, not 8 2025-01-05 02:02:21 +02:00
Maxim Devaev
f1256ee74a Bump version: 4.34 → 4.35 2025-01-04 22:36:13 +02:00
Maxim Devaev
9aef70c43f lint fixes 2025-01-04 22:17:55 +02:00
Maxim Devaev
f9584929e3 usb: endpoints calculation 2025-01-04 18:27:17 +02:00
Maxim Devaev
7aa963330c Bump version: 4.33 → 4.34 2025-01-02 19:18:28 +02:00
Maxim Devaev
5d8633556e fixed missing modifiers mapping 2025-01-02 19:17:50 +02:00
Maxim Devaev
ebda7ea03d Bump version: 4.32 → 4.33 2024-12-30 18:56:38 +02:00
Maxim Devaev
fed3bf1efd pikvm/pikvm#1334: Bad link mode for keyboard events 2024-12-30 18:55:59 +02:00
Maxim Devaev
d52bb34bb9 Bump version: 4.31 → 4.32 2024-12-27 05:44:44 +02:00
Maxim Devaev
6c5f0bf09f janus: use symbolic soundcard name 2024-12-27 05:44:01 +02:00
Maxim Devaev
aae529f40b split otg mouse start options 2024-12-27 05:42:23 +02:00
Maxim Devaev
253231adac enabled remote wakeup by default 2024-12-27 03:01:18 +02:00
Maxim Devaev
e491057891 Bump version: 4.30 → 4.31 2024-12-26 16:57:40 +02:00
Maxim Devaev
3b5d62dd98 enable kvmd-media when kvmd-janus or kvmd-janus-static enabled 2024-12-26 16:57:05 +02:00
Maxim Devaev
38346bece1 improved media js 2024-12-26 16:56:30 +02:00
Maxim Devaev
647d3f3961 Bump version: 4.29 → 4.30 2024-12-26 05:05:41 +02:00
Maxim Devaev
287244d376 kvmd: disabled legacy API by default 2024-12-26 05:05:03 +02:00
Maxim Devaev
56438a372e Bump version: 4.28 → 4.29 2024-12-25 09:17:40 +02:00
Maxim Devaev
ab08d823c4 pikvm/pikvm#1440: Websocket-based transport and decoding for H.264 2024-12-25 09:16:59 +02:00
mofeng-git
5db37797ea 适配章鱼星球 2024-12-19 15:24:07 +00:00
Maxim Devaev
eda7ab3a49 Bump version: 4.27 → 4.28 2024-12-18 06:42:17 +02:00
Maxim Devaev
af2ee26a2f kvmd-media server 2024-12-18 06:39:18 +02:00
Maxim Devaev
596334735e removed legacy generic configs 2024-12-18 06:00:13 +02:00
Maxim Devaev
c8385213cc Bump version: 4.26 → 4.27 2024-12-17 18:28:52 +02:00
Maxim Devaev
c009985247 build fix 2024-12-17 18:28:17 +02:00
Maxim Devaev
7caa695d79 Bump version: 4.25 → 4.26 2024-12-17 18:21:13 +02:00
Maxim Devaev
630610bc53 switch 2024-12-17 18:20:04 +02:00
Maxim Devaev
e0bbf6968e testenv: Use memsink for VNC 2024-12-16 19:19:31 +02:00
Maxim Devaev
ada1c39eef Bump version: 4.24 → 4.25 2024-12-11 21:10:24 +02:00
Maxim Devaev
e014cbcedf pikvm/pikvm#858, pikvm/pikvm#1249: Added slow typing mode for /api/hid/print 2024-12-11 21:09:49 +02:00
Maxim Devaev
adbd4f242b pikvm/pikvm#1437: Don't reset absolute mouse position on clear 2024-12-11 17:56:54 +02:00
Maxim Devaev
2649a2fa01 web: Enabled secure paste text for Firefox 2024-12-11 17:56:54 +02:00
No0ne
8cca5a8cc7 Bump version: ps2x2pico-2.0 (#184) 2024-12-05 13:41:54 +02:00
Maxim Devaev
70452f048b Bump version: 4.23 → 4.24 2024-12-03 19:25:50 +02:00
Maxim Devaev
be21a420a0 fix 2024-12-03 19:25:13 +02:00
Maxim Devaev
e337e8d45c switch: Added udev rule for /dev/kvmd-switch 2024-12-03 19:23:38 +02:00
Maxim Devaev
8a09505baf pikvm/pikvm#1432: web: Fixed OCR region 2024-12-03 19:15:00 +02:00
Maxim Devaev
870af902a1 fix 2024-12-03 19:08:18 +02:00
Maxim Devaev
85a2f2367d Bump version: 4.22 → 4.23 2024-11-26 19:12:40 +02:00
Maxim Devaev
7fd4dae3c6 pikvm/pikvm#1408: Additional colors for GPIO 2024-11-26 19:11:59 +02:00
Maxim Devaev
0cf5f8de9e Bump version: 4.21 → 4.22 2024-11-25 05:29:49 +02:00
Maxim Devaev
7394588279 fixed prometheus metrics 2024-11-25 05:26:03 +02:00
Maxim Devaev
1b9b27660a Bump version: 4.20 → 4.21 2024-11-22 16:32:05 +02:00
Maxim Devaev
7c453b8b49 new sponsors 2024-11-22 16:29:59 +02:00
Maxim Devaev
8929d0f311 pikvm/pikvm#1415: kvmd-bootconfig: Supported open wifi network 2024-11-20 21:35:25 +02:00
Maxim Devaev
d25e43c934 pikvm/pikvm#1415: Allow autoconnecting to open wifi 2024-11-20 18:53:10 +02:00
Maxim Devaev
3cbeabe2e8 VNC: Supported ExtendedMouseButtons 2024-11-20 17:50:27 +02:00
178 changed files with 7569 additions and 2400 deletions

View File

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

36
.github/ISSUE_TEMPLATE/bug-反馈.md vendored Normal file
View File

@@ -0,0 +1,36 @@
---
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

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

View File

@@ -1,23 +0,0 @@
name: Arduino HID CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
container:
image: python
steps:
- uses: actions/checkout@v3
- name: Prepare platformio
run: pip install platformio
- name: Build all
run: make -C hid/arduino _build_all

74
.github/workflows/build_img.yaml vendored Normal file
View File

@@ -0,0 +1,74 @@
name: Build One-KVM Image
on:
workflow_dispatch:
inputs:
device_target:
description: 'Target device name'
required: true
type: choice
options:
- onecloud
- cumebox2
- chainedbox
- vm
- e900v22c
- octopus-flanet
- all
jobs:
build:
runs-on: ubuntu-22.04
container:
image: node:18
options: --user root --privileged
env:
TZ: Asia/Shanghai
volumes:
- /dev:/dev
- /mnt/nfs/lfs/:/mnt/nfs/lfs/
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install dependencies
run: |
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:
DEBIAN_FRONTEND: noninteractive
- name: Build image
run: |
echo "BUILD_DATE=$(date +%y%m%d)" >> $GITHUB_ENV
chmod +x build/build_img.sh
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 }}
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
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:
CI_PROJECT_DIR: ${{ github.workspace }}

83
.github/workflows/docker-build.yaml vendored Normal file
View File

@@ -0,0 +1,83 @@
name: Build and Push Docker Image
on:
workflow_dispatch:
inputs:
version:
description: 'Version'
required: true
type: choice
options:
- dev
- latest
jobs:
build:
runs-on: ubuntu-22.04
container:
image: node:18
env:
TZ: Asia/Shanghai
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install dependencies
run: |
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:
DEBIAN_FRONTEND: noninteractive
- name: Install Docker Buildx
run: |
# 创建插件目录
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: Install QEMU
# run: |
# 安装 QEMU 模拟器
#docker run --privileged --rm tonistiigi/binfmt --install all
# 验证 QEMU 安装
#docker buildx inspect --bootstrap
- name: Create and use new builder instance
run: |
# 创建新的 builder 实例
docker buildx create --name mybuilder --driver docker-container --bootstrap
# 使用新创建的 builder
docker buildx use mybuilder
# 验证支持的平台
docker buildx inspect --bootstrap
- name: Build multi-arch image
run: |
# 构建多架构镜像
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 }}

View File

@@ -1,41 +0,0 @@
name: Pico HID Release
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Installing deps ...
run: sudo apt-get install cmake gcc-arm-none-eabi build-essential
- name: Building ...
run: make -C hid/pico all
- name: Releasing ...
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: false
- name: Uploading firmware ...
id: upload-release-asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./hid/pico/hid.uf2
asset_name: pico-hid.uf2
asset_content_type: application/octet-stream

View File

@@ -1,20 +0,0 @@
name: Pico HID CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Installing deps ...
run: sudo apt-get install cmake gcc-arm-none-eabi build-essential
- name: Running tests ...
run: make -C hid/pico all

View File

@@ -1,20 +0,0 @@
name: TOX CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Building testenv ...
run: make testenv
- name: Running tests ...
run: make tox CMD="tox -c testenv/tox.ini"

16
LICENSE
View File

@@ -1,11 +1,7 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
<<<<<<< HEAD
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
=======
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
>>>>>>> origin/dev
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
@@ -649,11 +645,7 @@ the "copyright" line and a pointer to where the full notice is found.
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
<<<<<<< HEAD
along with this program. If not, see <https://www.gnu.org/licenses/>.
=======
along with this program. If not, see <http://www.gnu.org/licenses/>.
>>>>>>> origin/dev
Also add information on how to contact you by electronic and paper mail.
@@ -672,19 +664,11 @@ might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<<<<<<< HEAD
<https://www.gnu.org/licenses/>.
=======
<http://www.gnu.org/licenses/>.
>>>>>>> origin/dev
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<<<<<<< HEAD
<https://www.gnu.org/licenses/why-not-lgpl.html>.
=======
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
>>>>>>> origin/dev

View File

@@ -86,6 +86,8 @@ tox: testenv
&& cp /usr/share/kvmd/configs.default/kvmd/*.yaml /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/*passwd /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/*.secret /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/edid/v2.hex /etc/kvmd/switch-edid.hex \
&& cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \
&& cp /usr/share/kvmd/configs.default/kvmd/main.yaml /etc/kvmd/main.yaml \
&& mkdir -p /etc/kvmd/override.d \
&& cp /src/testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \
@@ -102,6 +104,7 @@ $(TESTENV_GPIO):
run: testenv $(TESTENV_GPIO)
- $(DOCKER) run --rm --name kvmd \
--ipc=shareable \
--privileged \
--volume `pwd`/testenv/run:/run/kvmd:rw \
--volume `pwd`/testenv:/testenv:ro \
@@ -128,6 +131,7 @@ run: testenv $(TESTENV_GPIO)
&& cp /usr/share/kvmd/configs.default/kvmd/*.yaml /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/*passwd /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/*.secret /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/edid/v2.hex /etc/kvmd/switch-edid.hex \
&& cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \
&& ln -s /testenv/web.css /etc/kvmd/web.css \
&& mkdir -p /etc/kvmd/override.d \
@@ -155,6 +159,8 @@ run-cfg: testenv
&& cp /usr/share/kvmd/configs.default/kvmd/*.yaml /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/*passwd /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/*.secret /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/edid/v2.hex /etc/kvmd/switch-edid.hex \
&& cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \
&& cp /usr/share/kvmd/configs.default/kvmd/main.yaml /etc/kvmd/main.yaml \
&& mkdir -p /etc/kvmd/override.d \
&& cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \
@@ -178,7 +184,8 @@ run-ipmi: testenv
&& cp /usr/share/kvmd/configs.default/kvmd/*.yaml /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/*passwd /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/*.secret /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/main.yaml /etc/kvmd/main.yaml \
&& cp /usr/share/kvmd/configs.default/kvmd/edid/v2.hex /etc/kvmd/switch-edid.hex \
&& cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \
&& mkdir -p /etc/kvmd/override.d \
&& cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \
&& $(if $(CMD),$(CMD),python -m kvmd.apps.ipmi --run) \
@@ -187,6 +194,7 @@ run-ipmi: testenv
run-vnc: testenv
- $(DOCKER) run --rm --name kvmd-vnc \
--ipc=container:kvmd \
--volume `pwd`/testenv/run:/run/kvmd:rw \
--volume `pwd`/testenv:/testenv:ro \
--volume `pwd`/kvmd:/kvmd:ro \
@@ -201,7 +209,8 @@ run-vnc: testenv
&& cp /usr/share/kvmd/configs.default/kvmd/*.yaml /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/*passwd /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/*.secret /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/main.yaml /etc/kvmd/main.yaml \
&& cp /usr/share/kvmd/configs.default/kvmd/edid/v2.hex /etc/kvmd/switch-edid.hex \
&& cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \
&& mkdir -p /etc/kvmd/override.d \
&& cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \
&& $(if $(CMD),$(CMD),python -m kvmd.apps.vnc --run) \
@@ -271,37 +280,21 @@ clean-all: testenv clean
.PHONY: testenv
run-stage-0:
$(DOCKER) buildx build -t registry.cn-hangzhou.aliyuncs.com/silentwind/kvmd-stage-0 \
--allow security.insecure --progress plain \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
-f build/Dockerfile-stage-0 . \
--push
$(DOCKER) buildx build -t silentwind0/kvmd-stage-0 \
$(DOCKER) buildx build -t registry.cn-hangzhou.aliyuncs.com/silentwind/kvmd-stage-0 -t silentwind0/kvmd-stage-0 \
--allow security.insecure --progress plain \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
-f build/Dockerfile-stage-0 . \
--push
run-build-dev:
$(DOCKER) buildx build -t registry.cn-hangzhou.aliyuncs.com/silentwind/kvmd:dev \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--build-arg CACHEBUST=$(date +%s) \
-f build/Dockerfile . \
--push
$(DOCKER) buildx build -t silentwind0/kvmd:dev \
$(DOCKER) buildx build -t registry.cn-hangzhou.aliyuncs.com/silentwind/kvmd:dev -t silentwind0/kvmd:dev \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--build-arg CACHEBUST=$(date +%s) \
-f build/Dockerfile . \
--push
run-build-release:
$(DOCKER) buildx build -t registry.cn-hangzhou.aliyuncs.com/silentwind/kvmd \
--progress plain \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--build-arg CACHEBUST=$(date +%s) \
-f build/Dockerfile . \
--push
$(DOCKER) buildx build -t silentwind0/kvmd \
$(DOCKER) buildx build -t registry.cn-hangzhou.aliyuncs.com/silentwind/kvmd -t silentwind0/kvmd \
--progress plain \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--build-arg CACHEBUST=$(date +%s) \

View File

@@ -39,15 +39,15 @@ for _variant in "${_variants[@]}"; do
pkgname+=(kvmd-platform-$_platform-$_board)
done
pkgbase=kvmd
pkgver=4.20
pkgver=4.49
pkgrel=1
pkgdesc="The main PiKVM daemon"
url="https://github.com/pikvm/kvmd"
license=(GPL)
arch=(any)
depends=(
"python>=3.12"
"python<3.13"
"python>=3.13"
"python<3.14"
python-yaml
python-aiohttp
python-aiofiles
@@ -79,6 +79,7 @@ depends=(
python-mako
python-luma-oled
python-pyusb
python-pyudev
"libgpiod>=2.1"
freetype2
"v4l-utils>=1.22.1-1"
@@ -89,11 +90,11 @@ depends=(
iproute2
dnsmasq
ipmitool
"janus-gateway-pikvm>=0.14.2-3"
"janus-gateway-pikvm>=1.3.0"
certbot
platform-io-access
raspberrypi-utils
"ustreamer>=6.16"
"ustreamer>=6.26"
# Systemd UDEV bug
"systemd>=248.3-2"
@@ -167,7 +168,7 @@ package_kvmd() {
install -DTm644 configs/os/tmpfiles.conf "$pkgdir/usr/lib/tmpfiles.d/kvmd.conf"
mkdir -p "$pkgdir/usr/share/kvmd"
cp -r {hid,web,extras,contrib/keymaps} "$pkgdir/usr/share/kvmd"
cp -r {switch,hid,web,extras,contrib/keymaps} "$pkgdir/usr/share/kvmd"
find "$pkgdir/usr/share/kvmd/web" -name '*.pug' -exec rm -f '{}' \;
local _cfg_default="$pkgdir/usr/share/kvmd/configs.default"
@@ -209,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-1\" \"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
@@ -253,8 +254,12 @@ for _variant in "${_variants[@]}"; do
fi
if [[ $_platform =~ ^.*-hdmi$ ]]; then
backup=(\"\${backup[@]}\" etc/kvmd/tc358743-edid.hex)
backup=(\"\${backup[@]}\" etc/kvmd/tc358743-edid.hex etc/kvmd/switch-edid.hex)
install -DTm444 configs/kvmd/edid/$_base.hex \"\$pkgdir/etc/kvmd/tc358743-edid.hex\"
ln -s tc358743-edid.hex \"\$pkgdir/etc/kvmd/switch-edid.hex\"
else
backup=(\"\${backup[@]}\" etc/kvmd/switch-edid.hex)
install -DTm444 configs/kvmd/edid/_no-1920x1200.hex \"\$pkgdir/etc/kvmd/switch-edid.hex\"
fi
mkdir -p \"\$pkgdir/usr/share/kvmd\"

279
README.md
View File

@@ -1,61 +1,97 @@
<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>
<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>
[![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">📖 详细文档</a>
<a href="https://kvmd-demo.mofeng.run">🚀 在线演示</a>
<a href="#快速开始">⚡ 快速开始</a>
<a href="#功能介绍">📊 功能介绍</a>
</p>
</div>
### 介绍
---
One-KVM 是基于廉价计算机硬件和 [PiKVM]((https://github.com/pikvm/pikvm)) 软件二次开发的 BIOS 级远程控制项目。可以实现远程管理服务器或工作站,无需在被控机安装软件调整设置,实现无侵入式控制,适用范围广泛。
## 📋 目录
使用文档:[https://one-kvm.mofeng.run](https://one-kvm.mofeng.run)
- [项目概述](#项目概述)
- [功能介绍](#功能介绍)
- [快速开始](#快速开始)
- [贡献指南](#贡献指南)
- [其他](#其他)
演示网站:[https://kvmd-demo.mofeng.run](https://kvmd-demo.mofeng.run)
## 📖 项目概述
![image-20240926220156381](https://github.com/user-attachments/assets/a7848bca-e43c-434e-b812-27a45fad7910)
**One-KVM** 是基于开源 [PiKVM](https://github.com/pikvm/pikvm) 项目进行二次开发的 DIY IP-KVM 解决方案。该方案利用成本较低的硬件设备,实现 BIOS 级别的远程服务器或工作站管理功能。
### 软件功能
### 应用场景
表格仅为 One-KVM 与其他基于 PiKVM 的项目的功能对比,无不良导向,如有错漏请联系更正。
- **家庭实验室主机管理** - 远程管理服务器和开发设备
- **服务器远程维护** - 无需物理接触即可进行系统维护
- **系统故障处理** - 远程解决系统启动和 BIOS 相关问题
| 功能 | 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://github.com/user-attachments/assets/a7848bca-e43c-434e-b812-27a45fad7910)
### 快速开始
## 📊 功能介绍
更多详细内容可以查阅 [One-KVM文档](https://one-kvm.mofeng.run/)。
### 核心特性
**方式一Docker 镜像部署(推荐)**
| 特性 | 描述 | 优势 |
|------|------|------|
| **无侵入性** | 无需在目标机器上安装软件或驱动 | 不依赖操作系统,可访问 BIOS/UEFI 设置 |
| **成本效益** | 利用常见硬件设备(如电视盒子、开发板等) | 降低 KVM over IP 的实现成本 |
| **功能扩展** | 在 PiKVM 基础上增加实用功能 | Docker 部署、视频录制、中文界面 |
| **部署方式** | 支持 Docker 部署和硬件整合包 | 为特定硬件平台提供预配置方案 |
Docker 版本可以使用 OTG 或 CH9329 作为虚拟 HID ,支持 amd64、arm64、armv7 架构的 Linux 系统安装。
### 项目限制
**脚本部署**
本项目为个人维护的开源项目,资源有限,无商业运营计划
- 不提供内置免费内网穿透服务,相关问题请自行解决
- 不提供24×7小时技术支持服务
- 不承诺系统稳定性和合规性,使用风险需自行承担
- 尽力优化用户体验,但仍需要一定的技术基础
### 功能对比
> 💡 **说明:** 以下表格展示了 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 |
| 远程音频流 | ✅ | ✅ | ✅ | ✅ |
| 远程鼠键控制 | 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://one-kvm.mofeng.run/quick_start.sh -o quick_start.sh && bash quick_start.sh
```
**手动部署**
#### 手动部署
**使用 OTG 作为虚拟 HID**
如果使用 OTG 作为虚拟 HID可以使用如下部署命令
```bash
sudo docker run --name kvmd -itd --privileged=true \
-v /lib/modules:/lib/modules:ro -v /dev:/dev \
@@ -64,7 +100,8 @@ 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 \
@@ -74,79 +111,159 @@ sudo docker run --name kvmd -itd \
silentwind0/kvmd
```
**方式二:直刷 One-KVM 整合包**
### 方式二:直刷 One-KVM 整合包
对于部分平台硬件,本项目制作了深度适配的 One-KVM 打包镜像,开箱即用,刷好后启动设备就可以开始使用 One-KVM。免费 One-KVM 整合包也可以在本项目 Releases 页可以找到
针对特定硬件平台,提供了预配置的 One-KVM 打包镜像,简化部署流程,实现开箱即用
| 整合包适配概况 | | | |
| :-------------: | :-------------: | :-------------: | :-------------: |
| **固件型号** | **固件代号** | **硬件情况** | **最新版本** |
| 玩客云 | 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)
这个项目基于众多开源项目二次开发,作者为此花费了大量的时间和精力进行测试和维护。若此项目对您有用,您可以考虑通过 **[为爱发电](https://afdian.com/a/silentwind)** 赞助一笔小钱支持作者。作者将能有更多的金钱来测试和维护 One-KVM 的各种配置,并在项目上投入更多的时间和精力。
**其他下载方式:**
- **免登录下载:** [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
**感谢名单**
#### 支持的硬件平台
| 固件型号 | 固件代号 | 硬件配置 | 最新版本 | 状态 |
|:--------:|:--------:|:--------:|:--------:|:----:|
| 玩客云 | 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 | ✅ |
## 🤝 贡献指南
欢迎各种形式的贡献!
### 如何贡献
1. **Fork 本仓库**
2. **创建功能分支:** `git checkout -b feature/AmazingFeature`
3. **提交更改:** `git commit -m 'Add some AmazingFeature'`
4. **推送到分支:** `git push origin feature/AmazingFeature`
5. **提交 Pull Request**
### 报告问题
如果您发现了问题,请:
1. 使用 [GitHub Issues](https://github.com/mofeng-git/One-KVM/issues) 报告
2. 提供详细的错误信息和复现步骤
3. 包含您的硬件配置和系统信息
### 赞助支持
本项目基于多个优秀开源项目进行二次开发,作者投入了大量时间进行测试和维护。如果您觉得这个项目有价值,欢迎通过 **[为爱发电](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
- 爱发电用户_c0dd7
- 爱发电用户_dnjK
- 忍者胖猪
- 永遠の願い
- 爱发电用户_GBrF
- 爱发电用户_fd65c
- 爱发电用户_vhNa
- 爱发电用户_Xu6S
- moss
- woshididi
- 爱发电用户_a0fd1
- 爱发电用户_f6bH
- 码农
- 爱发电用户_6639f
- jeron
- 爱发电用户_CN7y
- 爱发电用户_Up6w
- 爱发电用户_e3202
- ......
......
</details>
本项目使用了下列开源项目:
1. [pikvm/pikvm: Open and inexpensive DIY IP-KVM based on Raspberry Pi (github.com)](https://github.com/pikvm/pikvm)
#### 赞助商
### 项目状态
本项目得到以下赞助商的支持:
[![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)
**CDN 加速及安全防护:**
- **[Tencent EdgeOne](https://edgeone.ai/zh?from=github)** - 提供 CDN 加速及安全防护服务
![Github](https://repobeats.axiom.co/api/embed/7cfaab47e31073107771a7179078aa2a6c3f1108.svg "Repobeats analytics image")
![Tencent EdgeOne](https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png)
**文件存储服务:**
- **[Huang1111公益计划](https://pan.huang1111.cn/s/mxkx3T1)** - 提供免登录下载服务
## 📚 其他
### 使用的开源项目
本项目基于以下优秀开源项目进行二次开发:
- [PiKVM](https://github.com/pikvm/pikvm) - 开源的 DIY IP-KVM 解决方案

View File

@@ -1,6 +1,6 @@
FROM silentwind0/kvmd-stage-0 AS builder
FROM registry.cn-hangzhou.aliyuncs.com/silentwind/kvmd-stage-0 AS builder
FROM python:3.12.0rc2-slim-bookworm
FROM python:3.11.11-slim-bookworm
LABEL maintainer="mofeng654321@hotmail.com"
@@ -12,29 +12,59 @@ COPY --from=builder /usr/lib/janus/transports/* /usr/lib/janus/transports/
ARG TARGETARCH
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV TZ=Asia/Shanghai
RUN 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 \
&& rm -rf /tmp/lib /tmp/wheel
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
TZ=Asia/Shanghai
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/' /etc/apt/sources.list.d/debian.sources \
&& apt-get update \
&& apt-get install -y --no-install-recommends libxkbcommon-x11-0 nginx tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim iptables sudo curl kmod \
libmicrohttpd12 libjansson4 libssl3 libsofia-sip-ua0 libglib2.0-0 libopus0 libogg0 libcurl4 libconfig9 libusrsctp2 libwebsockets17 libnss3 libasound2 nano \
&& rm -rf /var/lib/apt/lists/*
RUN if [ ${TARGETARCH} = arm ]; then ARCH=armhf; elif [ ${TARGETARCH} = arm64 ]; then ARCH=aarch64; elif [ ${TARGETARCH} = amd64 ]; then ARCH=x86_64; fi \
&& apt-get install -y --no-install-recommends \
libxkbcommon-x11-0 \
nginx \
tesseract-ocr \
tesseract-ocr-eng \
tesseract-ocr-chi-sim \
iptables \
sudo \
curl \
kmod \
libmicrohttpd12 \
libjansson4 \
libssl3 \
libsofia-sip-ua0 \
libglib2.0-0 \
libopus0 \
libogg0 \
libcurl4 \
libconfig9 \
libusrsctp2 \
libwebsockets17 \
libnss3 \
libasound2 \
nano \
&& 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 \
&& if [ ${TARGETARCH} = arm ]; then ARCH=armhf; \
elif [ ${TARGETARCH} = arm64 ]; then ARCH=aarch64; \
elif [ ${TARGETARCH} = amd64 ]; then ARCH=x86_64; \
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 \
&& adduser kvmd --gecos "" --disabled-password \
&& ln -sf /usr/share/tesseract-ocr/*/tessdata /usr/share/tessdata \
&& mkdir -p /etc/kvmd_backup/override.d /var/lib/kvmd/msd/images /var/lib/kvmd/msd/meta /var/lib/kvmd/pst/data /var/lib/kvmd/msd/NormalFiles /opt/vc/bin /run/kvmd /tmp/kvmd-nginx \
&& touch /run/kvmd/ustreamer.sock
&& mkdir -p /etc/kvmd_backup/override.d \
/var/lib/kvmd/msd/images \
/var/lib/kvmd/msd/meta \
/var/lib/kvmd/pst/data \
/var/lib/kvmd/msd/NormalFiles \
/opt/vc/bin \
/run/kvmd \
/tmp/kvmd-nginx \
&& touch /run/kvmd/ustreamer.sock \
&& apt clean \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /tmp/lib /tmp/wheel
COPY testenv/fakes/vcgencmd scripts/kvmd* /usr/bin/
COPY extras/ /usr/share/kvmd/extras/

View File

@@ -1,70 +1,119 @@
# syntax = docker/dockerfile:experimental
FROM python:3.12.0rc2-slim-bookworm AS builder
FROM debian:bookworm-slim AS builder
ARG TARGETARCH
# 设置环境变量
ENV DEBIAN_FRONTEND=noninteractive \
PIP_NO_CACHE_DIR=1 \
RUSTUP_DIST_SERVER="https://mirrors.tuna.tsinghua.edu.cn/rustup"
# 更新源并安装依赖
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/' /etc/apt/sources.list.d/debian.sources \
&& apt-get update \
&& apt-get install -y --no-install-recommends build-essential libssl-dev libffi-dev python3-dev libevent-dev libjpeg-dev \
libbsd-dev libudev-dev git pkg-config wget curl libmicrohttpd-dev libjansson-dev libssl-dev libsofia-sip-ua-dev libglib2.0-dev \
libopus-dev libogg-dev libcurl4-openssl-dev liblua5.3-dev libconfig-dev libopus-dev libtool automake autoconf meson cmake \
libx264-dev libyuv-dev libasound2-dev libspeex-dev libspeexdsp-dev libopus-dev \
&& apt-get install -y --no-install-recommends \
python3-full \
python3-pip \
python3-dev \
build-essential \
libssl-dev \
libffi-dev \
python3-dev \
libevent-dev \
libjpeg-dev \
libbsd-dev \
libudev-dev \
git \
pkg-config \
wget \
curl \
libmicrohttpd-dev \
libjansson-dev \
libsofia-sip-ua-dev \
libglib2.0-dev \
libopus-dev \
libogg-dev \
libcurl4-openssl-dev \
liblua5.3-dev \
libconfig-dev \
libtool \
automake \
autoconf \
meson \
cmake \
libx264-dev \
libyuv-dev \
libasound2-dev \
libspeex-dev \
libspeexdsp-dev \
libusb-1.0-0-dev \
&& apt clean \
&& rm -rf /var/lib/apt/lists/*
COPY build/cargo_config /tmp/config
# 配置 pip 源并安装 Python 依赖
RUN --security=insecure pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple \
&& if [ ${TARGETARCH} = arm ]; then \
mkdir -p /root/.cargo \
&& chmod 777 /root/.cargo && mount -t tmpfs none /root/.cargo \
&& export RUSTUP_DIST_SERVER="https://mirrors.tuna.tsinghua.edu.cn/rustup" \
#&& export RUSTUP_UPDATE_ROOT="https://mirrors.ustc.edu.cn/rust-static/rustup" \
&& wget https://sh.rustup.rs -O /root/rustup-init.sh \
&& wget https://sh.rustup.rs -O /root/rustup-init.sh \
&& sh /root/rustup-init.sh -y \
&& export PATH=$PATH:/root/.cargo/bin \
&& cp /tmp/config /root/.cargo/config.toml; \
fi \
&& pip wheel --wheel-dir=/tmp/wheel/ cryptography
RUN pip install --no-cache-dir --root-user-action=ignore --disable-pip-version-check build \
&& pip wheel --wheel-dir=/tmp/wheel/ aiofiles aiohttp appdirs asn1crypto async_lru async-timeout bottle cffi chardet click colorama \
dbus_next gpiod hidapi idna mako marshmallow more-itertools multidict netifaces packaging passlib pillow ply psutil pycparser \
pyelftools pyghmi pygments pyparsing pyotp qrcode requests semantic-version setproctitle setuptools six spidev \
tabulate urllib3 wrapt xlib yarl pyserial pyyaml zstandard supervisor
&& pip install --root-user-action=ignore --disable-pip-version-check --upgrade --break-system-packages build setuptools pip \
&& pip wheel --wheel-dir=/tmp/wheel/ cryptography \
&& pip wheel --wheel-dir=/tmp/wheel/ \
aiofiles aiohttp appdirs asn1crypto async_lru async-timeout bottle cffi \
chardet click colorama dbus_next gpiod hidapi idna mako marshmallow \
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
# 编译安装 libnice、libsrtp、libwebsockets 和 janus-gateway
RUN git clone --depth=1 https://gitlab.freedesktop.org/libnice/libnice /tmp/libnice \
&& cd /tmp/libnice \
&& meson --prefix=/usr build && ninja -C build && ninja -C build install
RUN curl https://github.com/cisco/libsrtp/archive/v2.2.0.tar.gz -L -o /tmp/libsrtp-2.2.0.tar.gz \
&& 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 xfv libsrtp-2.2.0.tar.gz \
&& tar xf libsrtp-2.2.0.tar.gz \
&& cd libsrtp-2.2.0 \
&& ./configure --prefix=/usr --enable-openssl \
&& make shared_library && make install
RUN git clone --depth=1 https://libwebsockets.org/repo/libwebsockets /tmp/libwebsockets \
&& make shared_library -j && make install \
&& cd /tmp \
&& rm -rf /tmp/libsrtp* \
&& 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_C_FLAGS="-fpic" .. \
&& make && make install
RUN git clone --depth=1 https://github.com/meetecho/janus-gateway.git /tmp/janus-gateway \
&& 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 \
&& ./configure --enable-static --enable-websockets --enable-plugin-audiobridge \
--disable-data-channels --disable-rabbitmq --disable-mqtt --disable-all-plugins --disable-all-loggers \
--prefix=/usr \
&& make && make install
&& ./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 && make install \
&& cd /tmp \
&& rm -rf /tmp/janus-gateway
# 编译 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 \
&& 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 -v \
&& cp /tmp/ustreamer/python/dist/*.whl /tmp/wheel/
# 复制必要的库文件
RUN mkdir /tmp/lib \
&& cd /lib/*-linux-*/ \
&& 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/ \
&& cp /tmp/ustreamer/python/dist/*.whl /tmp/wheel/
&& 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/

417
build/build_img.sh Normal file → Executable file
View File

@@ -1,270 +1,169 @@
#!/bin/bash
SRCPATH=/mnt/sda1/src
BOOTFS=/tmp/bootfs
ROOTFS=/tmp/rootfs
OUTPUTDIR=/mnt/sda1/output
LOOPDEV=/dev/loop10
DATE=241018
# --- 配置 ---
# 允许通过环境变量覆盖默认路径
SRCPATH="${SRCPATH:-/mnt/nfs/lfs/src}"
BOOTFS="${BOOTFS:-/tmp/bootfs}"
ROOTFS="${ROOTFS:-/tmp/rootfs}"
OUTPUTDIR="${OUTPUTDIR:-/mnt/nfs/lfs/src/output}"
TMPDIR="${TMPDIR:-$SRCPATH/tmp}"
export LC_ALL=C
write_meta() {
sudo chroot --userspec "root:root" $ROOTFS bash -c "sed -i 's/localhost.localdomain/$1/g' /etc/kvmd/meta.yaml"
# 全局变量
LOOPDEV=""
ROOTFS_MOUNTED=0
BOOTFS_MOUNTED=0
PROC_MOUNTED=0
SYS_MOUNTED=0
DEV_MOUNTED=0
DOCKER_CONTAINER_NAME="to_build_rootfs_$$"
PREBUILT_DIR="/tmp/prebuilt_binaries"
# --- 引入模块化脚本 ---
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
source "$SCRIPT_DIR/functions/common.sh"
source "$SCRIPT_DIR/functions/devices.sh"
source "$SCRIPT_DIR/functions/install.sh"
source "$SCRIPT_DIR/functions/packaging.sh"
# 获取日期与Git版本
GIT_COMMIT_ID=$(get_git_commit_id)
DATE=$(date +%y%m%d)
if [ -n "$GIT_COMMIT_ID" ]; then
DATE="${DATE}-${GIT_COMMIT_ID}"
fi
# --- 注册清理函数 ---
# 在脚本退出、收到错误信号、中断信号、终止信号时执行 cleanup
trap cleanup EXIT ERR INT TERM
# --- 构建流程函数 ---
build_target() {
local target="$1"
local build_time=$(date "+%Y-%m-%d %H:%M:%S")
echo "=================================================="
echo "信息:构建目标: $target"
echo "信息:构建时间: $build_time"
echo "=================================================="
# 设置全局变量,供后续函数使用
TARGET_DEVICE_NAME="$target"
NEED_PREPARE_DNS=false # 默认不需要准备 DNS
case "$target" in
onecloud)
onecloud_rootfs
local arch="armhf"
local device_type="gpio"
local network_type="systemd-networkd"
;;
cumebox2)
cumebox2_rootfs
local arch="aarch64"
local device_type="video1"
local network_type="" # 默认 NetworkManager
NEED_PREPARE_DNS=true
;;
chainedbox)
chainedbox_rootfs_and_fix_dtb
local arch="aarch64"
local device_type="video1"
local network_type=""
NEED_PREPARE_DNS=true
;;
vm)
vm_rootfs
local arch="amd64"
local device_type=""
local network_type=""
NEED_PREPARE_DNS=true
;;
e900v22c)
e900v22c_rootfs
local arch="aarch64"
local device_type="video1"
local network_type=""
NEED_PREPARE_DNS=true
;;
octopus-flanet)
octopus_flanet_rootfs
local arch="aarch64"
local device_type="video1"
local network_type=""
NEED_PREPARE_DNS=true
;;
*)
echo "错误:未知或不支持的目标 '$target'" >&2
exit 1
;;
esac
mount_rootfs
install_and_configure_kvmd "$arch" "$device_type" "$network_type"
write_meta "$target"
unmount_all
case "$target" in
onecloud)
pack_img_onecloud
;;
vm)
pack_img "Vm"
;;
cumebox2)
pack_img "Cumebox2"
;;
chainedbox)
pack_img "Chainedbox"
;;
e900v22c)
pack_img "E900v22c"
;;
octopus-flanet)
pack_img "Octopus-Flanet"
;;
*)
echo "错误:未知的打包类型 for '$target'" >&2
;;
esac
echo "=================================================="
echo "信息:目标 $target 构建完成!"
echo "=================================================="
}
mount_rootfs() {
mkdir $ROOTFS
sudo mount $LOOPDEV $ROOTFS || exit -1
sudo mount -t proc proc $ROOTFS/proc || exit -1
sudo mount -t sysfs sys $ROOTFS/sys || exit -1
sudo mount -o bind /dev $ROOTFS/dev || exit -1
}
# --- 主逻辑 ---
umount_rootfs() {
sudo umount $ROOTFS/sys
sudo umount $ROOTFS/dev
sudo umount $ROOTFS/proc
sudo umount $ROOTFS
sudo losetup -d $LOOPDEV
}
# 检查是否提供了目标参数
if [ -z "$1" ]; then
echo "用法: $0 <target|all>"
echo "可用目标: onecloud, cumebox2, chainedbox, vm, e900v22c, octopus-flanet"
exit 1
fi
parpare_dns() {
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
mkdir -p /run/systemd/resolve/ \
&& touch /run/systemd/resolve/stub-resolv.conf \
&& printf '%s\n' 'nameserver 1.1.1.1' 'nameserver 1.0.0.1' > /etc/resolv.conf \
&& bash <(curl -sSL https://gitee.com/SuperManito/LinuxMirrors/raw/main/ChangeMirrors.sh) \
--source mirrors.tuna.tsinghua.edu.cn --updata-software false --web-protocol http "
}
# 设置脚本立即退出模式
set -eo pipefail
config_file() {
sudo mkdir -p $ROOTFS/etc/kvmd/override.d $ROOTFS/etc/kvmd/vnc $ROOTFS/var/lib/kvmd/msd $ROOTFS/opt/vc/bin $ROOTFS/usr/share/kvmd $ROOTFS/One-KVM \
$ROOTFS/usr/share/janus/javascript $ROOTFS/usr/lib/ustreamer/janus $ROOTFS/run/kvmd $ROOTFS/var/lib/kvmd/msd/images $ROOTFS/var/lib/kvmd/msd/meta
sudo rsync -a --exclude={src,.github} . $ROOTFS/One-KVM
sudo cp -r configs/kvmd/* configs/nginx configs/janus $ROOTFS/etc/kvmd
sudo cp -r web extras contrib/keymaps $ROOTFS/usr/share/kvmd
sudo cp testenv/fakes/vcgencmd $ROOTFS/usr/bin/
sudo cp -r testenv/js/* $ROOTFS/usr/share/janus/javascript/
sudo cp build/platform/$1 $ROOTFS/usr/share/kvmd/platform
if [ -f "$SRCPATH/image/$1/rc.local" ]; then
sudo cp $SRCPATH/image/$1/rc.local $ROOTFS/etc/
fi
}
# 检查必要的外部工具
check_required_tools "$1"
pack_img() {
sudo mv $SRCPATH/tmp/rootfs.img $OUTPUTDIR/One-KVM_by-SilentWind_$1_$DATE.img
if [ "$1" = "Vm" ]; then
sudo qemu-img convert -f raw -O vmdk $OUTPUTDIR/One-KVM_by-SilentWind_Vm_$DATE.img $OUTPUTDIR/One-KVM_by-SilentWind_Vmare-uefi_$DATE.vmdk
sudo qemu-img convert -f raw -O vdi $OUTPUTDIR/One-KVM_by-SilentWind_Vm_$DATE.img $OUTPUTDIR/One-KVM_by-SilentWind_Virtualbox-uefi_$DATE.vdi
fi
}
# 执行构建
if [ "$1" = "all" ]; then
echo "信息:开始构建所有目标..."
build_target "onecloud"
build_target "cumebox2"
build_target "chainedbox"
build_target "vm"
build_target "e900v22c"
build_target "octopus-flanet"
echo "信息:所有目标构建完成。"
else
build_target "$1"
fi
onecloud_rootfs() {
$SRCPATH/image/onecloud/AmlImg_v0.3.1_linux_amd64 unpack $SRCPATH/image/onecloud/Armbian_by-SilentWind_24.5.0-trunk_Onecloud_bookworm_legacy_5.9.0-rc7_minimal.burn.img $SRCPATH/tmp
simg2img $SRCPATH/tmp/7.rootfs.PARTITION.sparse $SRCPATH/tmp/rootfs.img
dd if=/dev/zero of=/tmp/add.img bs=1M count=1024 && cat /tmp/add.img >> $SRCPATH/tmp/rootfs.img && rm /tmp/add.img
e2fsck -f $SRCPATH/tmp/rootfs.img && resize2fs $SRCPATH/tmp/rootfs.img
sudo losetup $LOOPDEV $SRCPATH/tmp/rootfs.img
}
cumebox2_rootfs() {
cp $SRCPATH/image/cumebox2/Armbian_24.8.1_Khadas-vim1_bookworm_current_6.6.47_minimal.img $SRCPATH/tmp/rootfs.img
dd if=/dev/zero of=/tmp/add.img bs=1M count=1500 && cat /tmp/add.img >> $SRCPATH/tmp/rootfs.img && rm /tmp/add.img
sudo parted -s $SRCPATH/tmp/rootfs.img resizepart 1 100% || exit -1
sudo losetup --offset $((8192*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img || exit -1
sudo e2fsck -f $LOOPDEV && sudo resize2fs $LOOPDEV
}
chainedbox_rootfs_and_fix_dtb() {
cp $SRCPATH/image/chainedbox/Armbian_24.11.0_rockchip_chainedbox_bookworm_6.1.112_server_2024.10.02_add800m.img $SRCPATH/tmp/rootfs.img
mkdir $BOOTFS
sudo losetup --offset $((32768*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img || exit -1
sudo mount $LOOPDEV $BOOTFS
sudo cp $SRCPATH/image/chainedbox/rk3328-l1pro-1296mhz-fix.dtb $BOOTFS/dtb/rockchip/rk3328-l1pro-1296mhz.dtb
sudo umount $BOOTFS
sudo losetup -d $LOOPDEV
sudo losetup --offset $((1081344*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img
}
vm_rootfs() {
cp $SRCPATH/image/vm/Armbian_24.8.1_Uefi-x86_bookworm_current_6.6.47_minimal_add1g.img $SRCPATH/tmp/rootfs.img
sudo losetup --offset $((540672*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img || exit -1
}
e900v22c_rootfs() {
cp $SRCPATH/image/e900v22c/Armbian_23.08.0_amlogic_s905l3a_bookworm_5.15.123_server_2023.08.01.img $SRCPATH/tmp/rootfs.img
dd if=/dev/zero of=/tmp/add.img bs=1M count=400 && cat /tmp/add.img >> $SRCPATH/tmp/rootfs.img && rm /tmp/add.img
sudo parted -s $SRCPATH/tmp/rootfs.img resizepart 2 100% || exit -1
sudo losetup --offset $((532480*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img || exit -1
sudo e2fsck -f $LOOPDEV && sudo resize2fs $LOOPDEV
}
config_cumebox2_file() {
sudo mkdir $ROOTFS/etc/oled
sudo cp $SRCPATH/image/cumebox2/v-fix.dtb $ROOTFS/boot/dtb/amlogic/meson-gxl-s905x-khadas-vim.dtb
sudo cp $SRCPATH/image/cumebox2/ssd $ROOTFS/usr/bin/
sudo cp $SRCPATH/image/cumebox2/config.json $ROOTFS/etc/oled/config.json
}
instal_one-kvm() {
#$1 arch; $2 deivce: "gpio" or "video1"; $3 network: "systemd-networkd",default is network-manager
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
df -h \
&& apt update \
&& apt install -y python3-aiofiles python3-aiohttp python3-appdirs python3-asn1crypto python3-async-timeout \
python3-bottle python3-cffi python3-chardet python3-click python3-colorama python3-cryptography python3-dateutil \
python3-dbus python3-dev python3-hidapi python3-hid python3-idna python3-libgpiod python3-mako python3-marshmallow python3-more-itertools \
python3-multidict python3-netifaces python3-packaging python3-passlib python3-pillow python3-ply python3-psutil \
python3-pycparser python3-pyelftools python3-pyghmi python3-pygments python3-pyparsing python3-requests \
python3-semantic-version python3-setproctitle python3-setuptools python3-six python3-spidev python3-systemd \
python3-tabulate python3-urllib3 python3-wrapt python3-xlib python3-yaml python3-yarl python3-pyotp python3-qrcode \
python3-serial python3-zstandard python3-dbus-next \
&& apt install -y nginx python3-pip python3-dev python3-build net-tools tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim \
git gpiod libxkbcommon0 build-essential janus-dev libssl-dev libffi-dev libevent-dev libjpeg-dev libbsd-dev libudev-dev \
pkg-config libx264-dev libyuv-dev libasound2-dev libsndfile-dev libspeexdsp-dev cpufrequtils iptables network-manager \
&& rm -rf /var/lib/apt/lists/* "
if [ "$3" = "systemd-networkd" ]; then
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
echo -e '[Match]\nName=eth0\n\n[Network]\nDHCP=yes\n\n[Link]\nMACAddress=B6:AE:B3:21:42:0C' > /etc/systemd/network/99-eth0.network \
&& systemctl mask NetworkManager \
&& systemctl unmask systemd-networkd \
&& systemctl enable systemd-networkd systemd-resolved "
fi
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
pip3 config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple \
&& pip3 install --target=/usr/lib/python3/dist-packages --break-system-packages async-lru gpiod \
&& pip3 cache purge "
sudo chroot --userspec "root:root" $ROOTFS sed --in-place --expression 's|^#include "refcount.h"$|#include "../refcount.h"|g' /usr/include/janus/plugins/plugin.h
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
git clone --depth=1 https://github.com/mofeng-git/ustreamer /tmp/ustreamer \
&& make -j WITH_PYTHON=1 WITH_JANUS=1 WITH_LIBX264=1 -C /tmp/ustreamer \
&& mv /tmp/ustreamer/src/ustreamer.bin /usr/bin/ustreamer \
&& mv /tmp/ustreamer/src/ustreamer-dump.bin /usr/bin/ustreamer-dump \
&& chmod +x /usr/bin/ustreamer /usr/bin/ustreamer-dump \
&& mv /tmp/ustreamer/janus/libjanus_ustreamer.so /usr/lib/ustreamer/janus \
&& pip3 install --target=/usr/lib/python3/dist-packages --break-system-packages /tmp/ustreamer/python/dist/*.whl "
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
cd /One-KVM \
&& python3 setup.py install \
&& bash scripts/kvmd-gencert --do-the-thing \
&& bash scripts/kvmd-gencert --do-the-thing --vnc \
&& kvmd-nginx-mkconf /etc/kvmd/nginx/nginx.conf.mako /etc/kvmd/nginx/nginx.conf \
&& kvmd -m "
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
cat /One-KVM/configs/os/sudoers/v2-hdmiusb >> /etc/sudoers \
&& cat /One-KVM/configs/os/udev/v2-hdmiusb-generic.rules > /etc/udev/rules.d/99-kvmd.rules \
&& echo 'libcomposite' >> /etc/modules \
&& mv /usr/local/bin/kvmd* /usr/bin \
&& cp /One-KVM/configs/os/services/* /etc/systemd/system/ \
&& cp /One-KVM/configs/os/tmpfiles.conf /usr/lib/tmpfiles.d/ \
&& chmod +x /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 \
&& systemd-sysusers /One-KVM/configs/os/sysusers.conf \
&& systemd-sysusers /One-KVM/configs/os/kvmd-webterm.conf \
&& ln -sf /usr/share/tesseract-ocr/*/tessdata /usr/share/tessdata \
&& 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/ \
&& systemctl enable kvmd kvmd-otg kvmd-nginx kvmd-vnc kvmd-ipmi kvmd-webterm kvmd-janus \
&& systemctl disable nginx janus \
&& rm -r /One-KVM "
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
curl https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.$1 -L -o /usr/bin/ttyd \
&& chmod +x /usr/bin/ttyd \
&& mkdir -p /home/kvmd-webterm \
&& chown kvmd-webterm /home/kvmd-webterm "
if [ "$1" = "x86_64" ]; then
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
systemctl disable kvmd-otg \
&& sed -i '2c ATX=USBRELAY_HID' /etc/kvmd/atx.sh \
&& sed -i 's/device: \/dev\/ttyUSB0/device: \/dev\/kvmd-hid/g' /etc/kvmd/override.yaml "
else
if [ "$2" = "gpio" ]; then
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
sed -i '2c ATX=GPIO' /etc/kvmd/atx.sh \
&& sed -i 's/SHUTDOWNPIN/gpiochip1 7/g' /etc/kvmd/custom_atx/gpio.sh \
&& sed -i 's/REBOOTPIN/gpiochip0 11/g' /etc/kvmd/custom_atx/gpio.sh "
else
sudo chroot --userspec "root:root" $ROOTFS sed -i '2c ATX=USBRELAY_HID' /etc/kvmd/atx.sh
fi
if [ "$2" = "video1" ]; then
sudo chroot --userspec "root:root" $ROOTFS sed -i 's/\/dev\/video0/\/dev\/video1/g' /etc/kvmd/override.yaml
fi
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
sed -i 's/ch9329/otg/g' /etc/kvmd/override.yaml \
&& sed -i 's/device: \/dev\/ttyUSB0//g' /etc/kvmd/override.yaml \
&& sed -i 's/#type: otg/type: otg/g' /etc/kvmd/override.yaml "
fi
}
pack_img_onecloud() {
sudo rm $SRCPATH/tmp/7.rootfs.PARTITION.sparse
sudo img2simg $SRCPATH/tmp/rootfs.img $SRCPATH/tmp/7.rootfs.PARTITION.sparse
sudo $SRCPATH/image/onecloud/AmlImg_v0.3.1_linux_amd64 pack $OUTPUTDIR/One-KVM_by-SilentWind_Onecloud_$DATE.burn.img $SRCPATH/tmp/
sudo rm $SRCPATH/tmp/*
}
case $1 in
onecloud)
onecloud_rootfs
mount_rootfs
config_file $1
instal_one-kvm armhf gpio systemd-networkd
write_meta $1
umount_rootfs
pack_img_onecloud
;;
cumebox2)
cumebox2_rootfs
mount_rootfs
config_file $1
config_cumebox2_file
parpare_dns
instal_one-kvm aarch64 video1
write_meta $1
umount_rootfs
pack_img Cumebox2
;;
chainedbox)
chainedbox_rootfs_and_fix_dtb
mount_rootfs
config_file $1
parpare_dns
instal_one-kvm aarch64 video1
write_meta $1
umount_rootfs
pack_img Chainedbox
;;
vm)
vm_rootfs
mount_rootfs
config_file $1
parpare_dns
instal_one-kvm x86_64
write_meta $1
umount_rootfs
pack_img Vm
;;
e900v22c)
e900v22c_rootfs
mount_rootfs
config_file $1
instal_one-kvm aarch64 video1
write_meta $1
umount_rootfs
pack_img E900v22c
;;
*)
echo "Do no thing."
;;
esac
exit 0

195
build/functions/common.sh Executable file
View File

@@ -0,0 +1,195 @@
#!/bin/bash
# --- 辅助函数 ---
# 获取 Git 提交 ID
get_git_commit_id() {
if git rev-parse --is-inside-work-tree &>/dev/null; then
git rev-parse --short HEAD 2>/dev/null || echo ""
else
echo ""
fi
}
# 查找并设置一个可用的 loop 设备
find_loop_device() {
echo "信息:查找可用的 loop 设备..."
# 只使用 --find 来获取设备名
LOOPDEV=$(sudo losetup --find)
if [[ -z "$LOOPDEV" || ! -e "$LOOPDEV" ]]; then
echo "错误:再次尝试后仍无法找到可用的 loop 设备。" >&2
exit 1
fi
echo "信息:找到可用 loop 设备名:$LOOPDEV"
}
# 检查并创建目录
ensure_dir() {
if [[ ! -d "$1" ]]; then
echo "信息:创建目录 $1 ..."
sudo mkdir -p "$1" || { echo "错误:创建目录 $1 失败" >&2; exit 1; }
fi
}
# 执行 chroot 命令
run_in_chroot() {
echo "信息:在 chroot 环境 ($ROOTFS) 中执行命令..."
sudo chroot --userspec "root:root" "$ROOTFS" bash -ec "$1" || { echo "错误:在 chroot 环境中执行命令失败" >&2; exit 1; }
echo "信息chroot 命令执行完成。"
}
# --- 清理函数 ---
cleanup() {
echo "信息:执行清理操作..."
# 尝试卸载 chroot 环境下的挂载点
if [[ "$DEV_MOUNTED" -eq 1 ]]; then
echo "信息:卸载 $ROOTFS/dev ..."
sudo umount "$ROOTFS/dev" || echo "警告:卸载 $ROOTFS/dev 失败,可能已被卸载"
DEV_MOUNTED=0
fi
if [[ "$SYS_MOUNTED" -eq 1 ]]; then
echo "信息:卸载 $ROOTFS/sys ..."
sudo umount "$ROOTFS/sys" || echo "警告:卸载 $ROOTFS/sys 失败,可能已被卸载"
SYS_MOUNTED=0
fi
if [[ "$PROC_MOUNTED" -eq 1 ]]; then
echo "信息:卸载 $ROOTFS/proc ..."
sudo umount "$ROOTFS/proc" || echo "警告:卸载 $ROOTFS/proc 失败,可能已被卸载"
PROC_MOUNTED=0
fi
# 尝试卸载主根文件系统
if [[ "$ROOTFS_MOUNTED" -eq 1 && -d "$ROOTFS" ]]; then
echo "信息:卸载 $ROOTFS ..."
sudo umount "$ROOTFS" || sudo umount -l "$ROOTFS" || echo "警告:卸载 $ROOTFS 失败"
ROOTFS_MOUNTED=0
fi
# 尝试卸载引导文件系统 (如果使用)
if [[ "$BOOTFS_MOUNTED" -eq 1 && -d "$BOOTFS" ]]; then
echo "信息:卸载 $BOOTFS ..."
sudo umount "$BOOTFS" || sudo umount -l "$BOOTFS" || echo "警告:卸载 $BOOTFS 失败"
BOOTFS_MOUNTED=0
fi
# 尝试分离 loop 设备
if [[ -n "$LOOPDEV" && -b "$LOOPDEV" ]]; then
echo "信息:尝试 zerofree $LOOPDEV ..."
sudo zerofree "$LOOPDEV" || echo "警告zerofree $LOOPDEV 失败,可能文件系统不支持或未干净卸载"
echo "信息:分离 loop 设备 $LOOPDEV ..."
sudo losetup -d "$LOOPDEV" || echo "警告:分离 $LOOPDEV 失败"
LOOPDEV=""
fi
# 尝试删除 Docker 容器
echo "信息:检查并删除 Docker 容器 $DOCKER_CONTAINER_NAME ..."
if sudo docker ps -a --format '{{.Names}}' | grep -q "^${DOCKER_CONTAINER_NAME}$"; then
sudo docker rm -f "$DOCKER_CONTAINER_NAME" || echo "警告:删除 Docker 容器 $DOCKER_CONTAINER_NAME 失败"
else
echo "信息Docker 容器 $DOCKER_CONTAINER_NAME 不存在或已被删除。"
fi
# 清理临时目录和挂载点目录
echo "信息:清理临时文件和目录..."
sudo rm -rf "$PREBUILT_DIR"
# 只删除挂载点目录本身
if [[ -d "$ROOTFS" ]]; then
sudo rmdir "$ROOTFS" || echo "警告:删除目录 $ROOTFS 失败,可能非空"
fi
if [[ -d "$BOOTFS" ]]; then
sudo rmdir "$BOOTFS" || echo "警告:删除目录 $BOOTFS 失败,可能非空"
fi
echo "信息:清理完成。"
}
# 在打包镜像前调用此函数确保干净卸载所有挂载点和loop设备
unmount_all() {
echo "信息:执行卸载操作,准备打包..."
# 卸载 chroot 环境下的挂载点
if [[ "$DEV_MOUNTED" -eq 1 ]]; then
echo "信息:卸载 $ROOTFS/dev ..."
sudo umount "$ROOTFS/dev" || echo "警告:卸载 $ROOTFS/dev 失败,可能已被卸载"
DEV_MOUNTED=0
fi
if [[ "$SYS_MOUNTED" -eq 1 ]]; then
echo "信息:卸载 $ROOTFS/sys ..."
sudo umount "$ROOTFS/sys" || echo "警告:卸载 $ROOTFS/sys 失败,可能已被卸载"
SYS_MOUNTED=0
fi
if [[ "$PROC_MOUNTED" -eq 1 ]]; then
echo "信息:卸载 $ROOTFS/proc ..."
sudo umount "$ROOTFS/proc" || echo "警告:卸载 $ROOTFS/proc 失败,可能已被卸载"
PROC_MOUNTED=0
fi
# 卸载主根文件系统
if [[ "$ROOTFS_MOUNTED" -eq 1 && -d "$ROOTFS" ]]; then
echo "信息:卸载 $ROOTFS ..."
sudo umount "$ROOTFS" || sudo umount -l "$ROOTFS" || echo "警告:卸载 $ROOTFS 失败"
ROOTFS_MOUNTED=0
fi
# 尝试分离 loop 设备前执行 zerofree如果文件系统支持
if [[ -n "$LOOPDEV" && -b "$LOOPDEV" ]]; then
echo "信息:尝试 zerofree $LOOPDEV ..."
sudo zerofree "$LOOPDEV" || echo "警告zerofree $LOOPDEV 失败,可能文件系统不支持或未干净卸载"
echo "信息:分离 loop 设备 $LOOPDEV ..."
sudo losetup -d "$LOOPDEV" || echo "警告:分离 $LOOPDEV 失败"
LOOPDEV=""
fi
sudo rm -rf "$PREBUILT_DIR"
echo "信息:卸载操作完成,可以安全打包镜像。"
}
# 挂载根文件系统
mount_rootfs() {
echo "信息:挂载根文件系统到 $ROOTFS ..."
ensure_dir "$ROOTFS"
sudo mount "$LOOPDEV" "$ROOTFS" || { echo "错误:挂载 $LOOPDEV$ROOTFS 失败" >&2; exit 1; }
ROOTFS_MOUNTED=1
echo "信息:挂载 proc, sys, dev 到 chroot 环境..."
ensure_dir "$ROOTFS/proc"
sudo mount -t proc proc "$ROOTFS/proc" || { echo "错误:挂载 proc 到 $ROOTFS/proc 失败" >&2; exit 1; }
PROC_MOUNTED=1
ensure_dir "$ROOTFS/sys"
sudo mount -t sysfs sys "$ROOTFS/sys" || { echo "错误:挂载 sys 到 $ROOTFS/sys 失败" >&2; exit 1; }
SYS_MOUNTED=1
ensure_dir "$ROOTFS/dev"
sudo mount -o bind /dev "$ROOTFS/dev" || { echo "错误:绑定挂载 /dev 到 $ROOTFS/dev 失败" >&2; exit 1; }
DEV_MOUNTED=1
echo "信息:根文件系统及虚拟文件系统挂载完成。"
}
# 设置元数据
write_meta() {
local hostname="$1"
echo "信息:在 chroot 环境中设置主机名/元数据为 $hostname ..."
run_in_chroot "sed -i 's/localhost.localdomain/$hostname/g' /etc/kvmd/meta.yaml"
}
# 检查必要的外部工具
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"
for cmd in $required_tools; do
if ! command -v "$cmd" &> /dev/null; then
echo "错误:必需的命令 '$cmd' 未找到。请安装相应软件包。" >&2
exit 1
fi
done
# 检查特定工具 (如果脚本中使用了)
if ! command -v "$SRCPATH/image/onecloud/AmlImg_v0.3.1_linux_amd64" &> /dev/null && [[ "$1" == "onecloud" || "$1" == "all" ]]; then
if [ -f "$SRCPATH/image/onecloud/AmlImg_v0.3.1_linux_amd64" ]; then
echo "信息:找到 AmlImg 工具,尝试设置执行权限..."
sudo chmod +x "$SRCPATH/image/onecloud/AmlImg_v0.3.1_linux_amd64" || echo "警告:设置 AmlImg 执行权限失败"
else
echo "错误:构建 onecloud 需要 '$SRCPATH/image/onecloud/AmlImg_v0.3.1_linux_amd64',但未找到。" >&2
fi
fi
}

212
build/functions/devices.sh Executable file
View File

@@ -0,0 +1,212 @@
#!/bin/bash
# --- 设备特定的 Rootfs 准备函数 ---
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.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=400
echo "信息:准备 Onecloud Rootfs..."
ensure_dir "$TMPDIR"
ensure_dir "$BOOTFS"
echo "信息:解包 Onecloud burn 镜像..."
sudo "$unpacker" unpack "$source_image" "$TMPDIR" || { echo "错误:解包失败" >&2; exit 1; }
echo "信息:转换 bootfs 和 rootfs sparse 镜像到 raw 格式..."
sudo simg2img "$bootfs_sparse" "$bootfs_img" || { echo "错误:转换 bootfs sparse 镜像失败" >&2; exit 1; }
sudo simg2img "$rootfs_sparse" "$rootfs_img" || { echo "错误:转换 rootfs sparse 镜像失败" >&2; exit 1; }
echo "信息:挂载 bootfs 并修复 DTB..."
find_loop_device # 查找一个 loop 设备给 bootfs
bootfs_loopdev="$LOOPDEV" # 保存这个设备名
echo "信息:将 $bootfs_img 关联到 $bootfs_loopdev..."
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
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..."
sudo losetup -d "$bootfs_loopdev" || { echo "警告:分离 bootfs loop 设备 $bootfs_loopdev 失败" >&2; }
# bootfs_loopdev 对应的设备现在是空闲的
echo "信息:扩展 rootfs 镜像 (${add_size_mb}MB)..."
sudo dd if=/dev/zero bs=1M count="$add_size_mb" >> "$rootfs_img" || { echo "错误:扩展 rootfs 镜像失败" >&2; exit 1; }
echo "信息:检查并调整 rootfs 文件系统大小 (在文件上)..."
# 注意e2fsck/resize2fs 现在直接操作镜像文件,而不是 loop 设备
sudo e2fsck -f -y "$rootfs_img" || { echo "警告e2fsck 检查 rootfs 镜像文件失败" >&2; exit 1; }
sudo resize2fs "$rootfs_img" || { echo "错误resize2fs 调整 rootfs 镜像文件大小失败" >&2; exit 1; }
echo "信息:设置 rootfs loop 设备..."
find_loop_device # 重新查找一个可用的 loop 设备 (可能是刚才释放的那个)
echo "信息:将 $rootfs_img 关联到 $LOOPDEV..."
sudo losetup "$LOOPDEV" "$rootfs_img" || { echo "错误:关联 rootfs 镜像到 $LOOPDEV 失败" >&2; exit 1; }
echo "信息Onecloud Rootfs 准备完成。 Loop 设备 $LOOPDEV 已关联 $rootfs_img"
}
cumebox2_rootfs() {
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))
echo "信息:准备 Cumebox2 Rootfs..."
ensure_dir "$TMPDIR"
cp "$source_image" "$target_image" || { echo "错误:复制 Cumebox2 原始镜像失败" >&2; exit 1; }
echo "信息:调整镜像分区大小..."
sudo parted -s "$target_image" resizepart 1 100% || { echo "错误:使用 parted 调整分区大小失败" >&2; exit 1; }
echo "信息:设置带偏移量的 loop 设备..."
find_loop_device # 查找设备名
echo "信息:将 $target_image (偏移 $offset) 关联到 $LOOPDEV..."
sudo losetup --offset "$offset" "$LOOPDEV" "$target_image" || { echo "错误:设置带偏移量的 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 "信息Cumebox2 Rootfs 准备完成loop 设备 $LOOPDEV 已就绪。"
}
chainedbox_rootfs_and_fix_dtb() {
local source_image="$SRCPATH/image/chainedbox/Armbian_24.11.0_rockchip_chainedbox_bookworm_6.1.112_server_2024.10.02_add800m.img"
local target_image="$TMPDIR/rootfs.img"
local boot_offset=$((32768 * 512))
local rootfs_offset=$((1081344 * 512))
local bootfs_loopdev=""
echo "信息:准备 Chainedbox Rootfs 并修复 DTB..."
ensure_dir "$TMPDIR"; ensure_dir "$BOOTFS"
cp "$source_image" "$target_image" || { echo "错误:复制 Chainedbox 原始镜像失败" >&2; exit 1; }
echo "信息:挂载 boot 分区并修复 DTB..."
find_loop_device # 找 loop 给 boot
bootfs_loopdev="$LOOPDEV"
echo "信息:将 $target_image (偏移 $boot_offset) 关联到 $bootfs_loopdev..."
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
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..."
sudo losetup -d "$bootfs_loopdev" || { echo "警告:分离 boot 分区 loop 设备 $bootfs_loopdev 失败" >&2; }
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 "信息Chainedbox Rootfs 准备完成loop 设备 $LOOPDEV 已就绪。"
}
vm_rootfs() {
local source_image="$SRCPATH/image/vm/Armbian_25.2.1_Uefi-x86_bookworm_current_6.12.13_minimal.img"
local target_image="$TMPDIR/rootfs.img"
local offset=$((540672 * 512))
echo "信息:准备 Vm Rootfs..."
ensure_dir "$TMPDIR"
cp "$source_image" "$target_image" || { echo "错误:复制 Vm 原始镜像失败" >&2; exit 1; }
echo "信息:设置带偏移量的 loop 设备..."
find_loop_device # 查找设备名
echo "信息:将 $target_image (偏移 $offset) 关联到 $LOOPDEV..."
sudo losetup --offset "$offset" "$LOOPDEV" "$target_image" || { echo "错误:设置带偏移量的 loop 设备 $LOOPDEV 失败" >&2; exit 1; }
echo "信息Vm Rootfs 准备完成loop 设备 $LOOPDEV 已就绪。"
}
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=400
echo "信息:准备 E900V22C Rootfs..."
ensure_dir "$TMPDIR"
cp "$source_image" "$target_image" || { echo "错误:复制 E900V22C 原始镜像失败" >&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 "信息:调整镜像分区大小 (分区 2)..."
sudo parted -s "$target_image" resizepart 2 100% || { echo "错误:使用 parted 调整分区 2 大小失败" >&2; exit 1; }
echo "信息:设置带偏移量的 loop 设备..."
find_loop_device # 查找设备名
echo "信息:将 $target_image (偏移 $offset) 关联到 $LOOPDEV..."
sudo losetup --offset "$offset" "$LOOPDEV" "$target_image" || { echo "错误:设置带偏移量的 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 "信息E900V22C Rootfs 准备完成loop 设备 $LOOPDEV 已就绪。"
}
octopus_flanet_rootfs() {
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=400
local bootfs_loopdev=""
echo "信息:准备 Octopus-Planet Rootfs..."
ensure_dir "$TMPDIR"; ensure_dir "$BOOTFS"
cp "$source_image" "$target_image" || { echo "错误:复制 Octopus-Planet 原始镜像失败" >&2; exit 1; }
echo "信息:挂载 boot 分区并修改 uEnv.txt (使用 VIM2 DTB)..."
find_loop_device # 找 loop 给 boot
bootfs_loopdev="$LOOPDEV"
echo "信息:将 $target_image (偏移 $boot_offset) 关联到 $bootfs_loopdev..."
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
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
echo "信息:分离 boot loop 设备 $bootfs_loopdev..."
sudo losetup -d "$bootfs_loopdev" || { echo "警告:分离 boot 分区 loop 设备 $bootfs_loopdev 失败" >&2; }
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 已就绪。"
}
# --- 特定设备的文件配置函数 ---
config_cumebox2_files() {
echo "信息:为 Cumebox2 配置特定文件 (OLED, DTB)..."
ensure_dir "$ROOTFS/etc/oled"
# 注意 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 "$SRCPATH/image/cumebox2/config.json" "$ROOTFS/etc/oled/config.json" || echo "警告:复制 OLED 配置文件失败"
}
config_octopus_flanet_files() {
echo "信息:为 Octopus-Planet 配置特定文件 (model_database.conf)..."
sudo cp "$SRCPATH/image/octopus-flanet/model_database.conf" "$ROOTFS/etc/model_database.conf" || echo "警告:复制 model_database.conf 失败"
}

313
build/functions/install.sh Executable file
View File

@@ -0,0 +1,313 @@
#!/bin/bash
# --- 预准备 ---
prepare_dns_and_mirrors() {
echo "信息:在 chroot 环境中准备 DNS 和更换软件源..."
run_in_chroot "
mkdir -p /run/systemd/resolve/ \\
&& touch /run/systemd/resolve/stub-resolv.conf \\
&& 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.tuna.tsinghua.edu.cn --upgrade-software false --web-protocol http || echo '警告:更换镜像源脚本执行失败,可能网络不通或脚本已更改'
"
}
delete_armbian_verify(){
echo "信息:在 chroot 环境中修改 Armbian 软件源..."
run_in_chroot "echo 'deb http://mirrors.ustc.edu.cn/armbian bullseye main bullseye-utils bullseye-desktop' > /etc/apt/sources.list.d/armbian.list"
}
prepare_external_binaries() {
local platform="$1" # linux/armhf or linux/amd64 or linux/aarch64
local docker_image="registry.cn-hangzhou.aliyuncs.com/silentwind/kvmd-stage-0"
echo "信息:准备外部预编译二进制文件 (平台: $platform)..."
ensure_dir "$PREBUILT_DIR"
echo "信息:拉取 Docker 镜像 $docker_image (平台: $platform)..."
sudo docker pull --platform "$platform" "$docker_image" || { echo "错误:拉取 Docker 镜像 $docker_image 失败" >&2; exit 1; }
echo "信息:创建 Docker 容器 $DOCKER_CONTAINER_NAME ..."
sudo docker create --name "$DOCKER_CONTAINER_NAME" "$docker_image" || { echo "错误:创建 Docker 容器 $DOCKER_CONTAINER_NAME 失败" >&2; exit 1; }
echo "信息:从 Docker 容器导出文件到 $PREBUILT_DIR ..."
sudo docker export "$DOCKER_CONTAINER_NAME" | sudo tar -xf - -C "$PREBUILT_DIR" || { echo "错误:导出并解压 Docker 容器内容失败" >&2; exit 1; }
echo "信息:预编译二进制文件准备完成,存放于 $PREBUILT_DIR"
# 删除 Docker 容器
sudo docker rm -f "$DOCKER_CONTAINER_NAME" || { echo "错误:删除 Docker 容器 $DOCKER_CONTAINER_NAME 失败" >&2; exit 1; }
}
config_base_files() {
local platform_id="$1" # e.g., "onecloud", "cumebox2"
echo "信息:配置基础文件和目录结构 ($platform_id)..."
echo "信息:创建 KVMD 相关目录..."
ensure_dir "$ROOTFS/etc/kvmd/override.d"
ensure_dir "$ROOTFS/etc/kvmd/vnc"
ensure_dir "$ROOTFS/var/lib/kvmd/msd/images"
ensure_dir "$ROOTFS/var/lib/kvmd/msd/meta"
ensure_dir "$ROOTFS/opt/vc/bin"
ensure_dir "$ROOTFS/usr/share/kvmd"
ensure_dir "$ROOTFS/One-KVM"
ensure_dir "$ROOTFS/usr/share/janus/javascript"
ensure_dir "$ROOTFS/usr/lib/ustreamer/janus"
ensure_dir "$ROOTFS/run/kvmd"
ensure_dir "$ROOTFS/tmp/wheel/"
ensure_dir "$ROOTFS/usr/lib/janus/transports/"
ensure_dir "$ROOTFS/usr/lib/janus/loggers"
echo "信息:复制 One-KVM 源码..."
sudo rsync -a --exclude={.git,.github,output,tmp} . "$ROOTFS/One-KVM/" || { echo "错误:复制 One-KVM 源码失败" >&2; exit 1; }
echo "信息:复制配置文件..."
sudo cp -r configs/kvmd/* configs/nginx configs/janus "$ROOTFS/etc/kvmd/"
sudo cp -r web extras contrib/keymaps "$ROOTFS/usr/share/kvmd/"
sudo cp testenv/fakes/vcgencmd "$ROOTFS/usr/bin/"
sudo cp -r testenv/js/* "$ROOTFS/usr/share/janus/javascript/"
sudo cp "build/platform/$platform_id" "$ROOTFS/usr/share/kvmd/platform" || { echo "错误:复制平台文件 build/platform/$platform_id 失败" >&2; exit 1; }
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"
if [ -f "$SRCPATH/image/$platform_id/rc.local" ]; then
echo "信息:复制设备特定的 rc.local 文件..."
sudo cp "$SRCPATH/image/$platform_id/rc.local" "$ROOTFS/etc/"
fi
echo "信息:从预编译目录复制二进制文件和库..."
sudo cp "$PREBUILT_DIR/tmp/lib/"* "$ROOTFS/lib/"*-linux-*/ || echo "警告:复制 /tmp/lib/* 失败,可能源目录或目标目录不存在或不匹配"
sudo cp "$PREBUILT_DIR/tmp/ustreamer/ustreamer" "$PREBUILT_DIR/tmp/ustreamer/ustreamer-dump" "$PREBUILT_DIR/usr/bin/janus" "$ROOTFS/usr/bin/" || { echo "错误:复制 ustreamer/janus 二进制文件失败" >&2; exit 1; }
sudo cp "$PREBUILT_DIR/tmp/ustreamer/janus/libjanus_ustreamer.so" "$ROOTFS/usr/lib/ustreamer/janus/" || { echo "错误:复制 libjanus_ustreamer.so 失败" >&2; exit 1; }
sudo cp "$PREBUILT_DIR/tmp/wheel/"*.whl "$ROOTFS/tmp/wheel/" || { echo "错误:复制 Python wheel 文件失败" >&2; exit 1; }
sudo cp "$PREBUILT_DIR/usr/lib/janus/transports/"* "$ROOTFS/usr/lib/janus/transports/" || { echo "错误:复制 Janus transports 失败" >&2; exit 1; }
# 禁用 apt-file
if [ -f "$ROOTFS/etc/apt/apt.conf.d/50apt-file.conf" ]; then
echo "信息:禁用 apt-file 配置..."
sudo mv "$ROOTFS/etc/apt/apt.conf.d/50apt-file.conf" "$ROOTFS/etc/apt/apt.conf.d/50apt-file.conf.disabled"
fi
echo "信息:基础文件配置完成。"
}
# --- KVMD 安装与配置 ---
install_base_packages() {
echo "信息:在 chroot 环境中更新源并安装基础软件包..."
run_in_chroot "
apt-get update && \\
apt install -y --no-install-recommends \\
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 && \\
apt clean && \\
rm -rf /var/lib/apt/lists/*
"
}
configure_network() {
local network_type="$1" # "systemd-networkd" or others (default network-manager)
if [ "$network_type" = "systemd-networkd" ]; then
echo "信息:在 chroot 环境中配置 systemd-networkd..."
# 检查是否为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/"
sudo chmod +x "$ROOTFS/usr/local/bin/generate-random-mac.sh"
# 复制systemd服务文件
sudo cp "$SCRIPT_DIR/services/kvmd-generate-mac.service" "$ROOTFS/etc/systemd/system/"
# 创建初始网络配置文件不包含MAC地址将由脚本生成
run_in_chroot "
echo -e '[Match]\\nName=eth0\\n\\n[Network]\\nDHCP=yes' > /etc/systemd/network/99-eth0.network && \\
systemctl mask NetworkManager && \\
systemctl unmask systemd-networkd && \\
systemctl enable systemd-networkd systemd-resolved && \\
systemctl enable kvmd-generate-mac.service
"
echo "信息onecloud随机MAC地址生成机制配置完成"
fi
else
echo "信息:使用默认的网络管理器 (NetworkManager)..."
# 可能需要确保 NetworkManager 是启用的 (通常默认是)
run_in_chroot "systemctl enable NetworkManager"
fi
}
install_python_deps() {
echo "信息:在 chroot 环境中安装 Python 依赖 (wheels)..."
run_in_chroot "
pip3 install --no-cache-dir --break-system-packages /tmp/wheel/*.whl && \\
pip3 cache purge && \\
rm -rf /tmp/wheel
"
}
configure_kvmd_core() {
echo "信息:在 chroot 环境中安装和配置 KVMD 核心..."
# 复制KVMD首次运行脚本和服务
echo "信息配置KVMD首次运行初始化服务..."
sudo cp "build/services/kvmd-firstrun.service" "$ROOTFS/etc/systemd/system/"
# 安装KVMD但不执行需要在首次运行时完成的操作
run_in_chroot "
cd /One-KVM && \\
python3 setup.py install && \\
systemctl enable kvmd-firstrun.service
"
echo "信息KVMD核心安装完成证书生成等初始化操作将在首次开机时执行"
}
configure_system() {
echo "信息:在 chroot 环境中配置系统级设置 (sudoers, udev, services)..."
run_in_chroot "
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 && \\
mv /usr/local/bin/kvmd* /usr/bin/ || echo '信息:/usr/local/bin/kvmd* 未找到或移动失败,可能已在/usr/bin' && \\
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 && \\
systemd-sysusers /One-KVM/configs/os/sysusers.conf && \\
systemd-sysusers /One-KVM/configs/os/kvmd-webterm.conf && \\
ln -sf /usr/share/tesseract-ocr/*/tessdata /usr/share/tessdata || echo '警告:创建 tesseract 链接失败' && \\
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/ && \\
systemctl enable kvmd kvmd-otg kvmd-nginx kvmd-vnc kvmd-ipmi kvmd-webterm kvmd-janus kvmd-media && \\
systemctl disable nginx && \\
rm -rf /One-KVM
"
}
install_webterm() {
local arch="$1" # armhf, aarch64, x86_64
local ttyd_arch="$arch"
if [ "$arch" = "armhf" ]; then
ttyd_arch="armv7"
elif [ "$arch" = "amd64" ]; then
ttyd_arch="x86_64" # ttyd 通常用 x86_64
fi
echo "信息:在 chroot 环境中下载并安装 ttyd ($ttyd_arch)..."
run_in_chroot "
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
"
}
apply_kvmd_tweaks() {
local arch="$1" # armhf, aarch64, x86_64
local device_type="$2" # "gpio" or "video1" or other
local atx_setting=""
local hid_setting=""
echo "信息:根据架构 ($arch) 和设备类型 ($device_type) 调整 KVMD 配置..."
if [ "$arch" = "x86_64" ] || [ "$arch" = "amd64" ]; then
echo "信息:目标平台为 x86_64/amd64 架构,禁用 OTG设置 ATX 为 USBRELAY_HID..."
run_in_chroot "
systemctl disable kvmd-otg && \\
sed -i 's/^ATX=.*/ATX=USBRELAY_HID/' /etc/kvmd/atx.sh && \\
sed -i 's/device: \/dev\/ttyUSB0/device: \/dev\/kvmd-hid/g' /etc/kvmd/override.yaml
"
else
echo "信息::目标平台为 ARM 架构 ($arch)..."
# ARM 架构,配置 HID 为 OTG
hid_setting="otg"
run_in_chroot "
sed -i 's/#type: otg/type: otg/g' /etc/kvmd/override.yaml && \\
sed -i 's/device: \/dev\/ttyUSB0/#device: \/dev\/ttyUSB0/g' /etc/kvmd/override.yaml # 注释掉 ttyUSB0
"
echo "信息:设置 HID 为 $hid_setting"
run_in_chroot "sed -i 's/type: ch9329/type: $hid_setting/g' /etc/kvmd/override.yaml"
# 根据 device_type 配置 ATX
if [ "$device_type" = "gpio" ]; 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/gpiochip1 7/g' /etc/kvmd/custom_atx/gpio.sh && \\
sed -i 's/REBOOTPIN/gpiochip0 11/g' /etc/kvmd/custom_atx/gpio.sh
"
else
echo "信息:电源控制设备类型不是 gpio ($device_type),设置 ATX 为 USBRELAY_HID..."
atx_setting="USBRELAY_HID"
run_in_chroot "sed -i 's/^ATX=.*/ATX=USBRELAY_HID/' /etc/kvmd/atx.sh"
fi
# 配置视频设备
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" = "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
echo "信息:使用默认视频设备 /dev/video0..."
fi
fi
echo "信息KVMD 配置调整完成。"
}
# --- 整体安装流程 ---
install_and_configure_kvmd() {
local arch="$1" # 架构: armhf, aarch64, x86_64/amd64
local device_type="$2" # 设备特性: "gpio", "video1", "" (空或其他)
local network_type="$3" # 网络配置: "systemd-networkd", "" (默认 network-manager)
local host_arch="" # Docker 平台架构: arm, aarch64, amd64
# 映射架构名称
case "$arch" in
armhf) host_arch="arm" ;;
aarch64) host_arch="arm64" ;; # docker aarch64 平台名是 arm64
x86_64|amd64) host_arch="amd64"; arch="x86_64" ;; # 统一内部使用 x86_64
*) echo "错误:不支持的架构 $arch"; exit 1 ;;
esac
prepare_external_binaries "linux/$host_arch"
config_base_files "$TARGET_DEVICE_NAME" # 使用全局变量传递设备名
# 特定设备的额外文件配置 (如果存在)
if declare -f "config_${TARGET_DEVICE_NAME}_files" > /dev/null; then
echo "信息:执行特定设备的文件配置函数 config_${TARGET_DEVICE_NAME}_files ..."
"config_${TARGET_DEVICE_NAME}_files"
fi
# 某些镜像可能需要准备DNS和换源
if [[ "$NEED_PREPARE_DNS" = true ]]; then
prepare_dns_and_mirrors
fi
# 可选强制使用特定armbian源
# delete_armbian_verify
# 执行安装步骤
install_base_packages
configure_network "$network_type"
install_python_deps
configure_kvmd_core
configure_system
install_webterm "$arch" # 传递原始架构名给ttyd下载
apply_kvmd_tweaks "$arch" "$device_type"
run_in_chroot "df -h" # 显示最终磁盘使用情况
echo "信息One-KVM 安装和配置完成。"
}

65
build/functions/packaging.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/bin/bash
# --- 打包函数 ---
pack_img() {
local device_name_friendly="$1" # e.g., "Vm", "Cumebox2"
local target_img_name="One-KVM_by-SilentWind_${device_name_friendly}_${DATE}.img"
local source_img="$TMPDIR/rootfs.img"
echo "信息:开始打包镜像 ($device_name_friendly)..."
ensure_dir "$OUTPUTDIR"
# 确保在打包前已经正确卸载了所有挂载点和loop设备
if [[ "$ROOTFS_MOUNTED" -eq 1 || "$DEV_MOUNTED" -eq 1 || "$SYS_MOUNTED" -eq 1 || "$PROC_MOUNTED" -eq 1 || -n "$LOOPDEV" && -b "$LOOPDEV" ]]; then
echo "警告发现未卸载的挂载点或loop设备尝试再次卸载..."
unmount_all
fi
echo "信息:移动镜像文件 $source_img$OUTPUTDIR/$target_img_name ..."
sudo mv "$source_img" "$OUTPUTDIR/$target_img_name" || { echo "错误:移动镜像文件失败" >&2; exit 1; }
if [ "$device_name_friendly" = "Vm" ]; then
echo "信息:为 Vm 目标转换镜像格式 (vmdk, vdi)..."
local raw_img="$OUTPUTDIR/$target_img_name"
local vmdk_img="$OUTPUTDIR/One-KVM_by-SilentWind_Vmare-uefi_${DATE}.vmdk"
local vdi_img="$OUTPUTDIR/One-KVM_by-SilentWind_Virtualbox-uefi_${DATE}.vdi"
echo "信息:转换为 VMDK..."
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 失败"
fi
echo "信息:镜像打包完成: $OUTPUTDIR/$target_img_name"
}
pack_img_onecloud() {
local target_img_name="One-KVM_by-SilentWind_Onecloud_${DATE}.burn.img"
local rootfs_raw_img="$TMPDIR/rootfs.img"
local rootfs_sparse_img="$TMPDIR/7.rootfs.PARTITION.sparse"
local aml_packer="$SRCPATH/image/onecloud/AmlImg_v0.3.1_linux_amd64"
echo "信息:开始为 Onecloud 打包 burn 镜像..."
ensure_dir "$OUTPUTDIR"
# 确保在打包前已经正确卸载了所有挂载点和loop设备
if [[ "$ROOTFS_MOUNTED" -eq 1 || "$DEV_MOUNTED" -eq 1 || "$SYS_MOUNTED" -eq 1 || "$PROC_MOUNTED" -eq 1 || -n "$LOOPDEV" && -b "$LOOPDEV" ]]; then
echo "警告发现未卸载的挂载点或loop设备尝试再次卸载..."
unmount_all
fi
echo "信息:将 raw rootfs 转换为 sparse image..."
# 先删除可能存在的旧 sparse 文件
sudo rm -f "$rootfs_sparse_img"
sudo img2simg "$rootfs_raw_img" "$rootfs_sparse_img" || { echo "错误img2simg 转换失败" >&2; exit 1; }
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"
echo "信息Onecloud burn 镜像打包完成: $OUTPUTDIR/$target_img_name"
}

View File

@@ -1,41 +1,112 @@
#!/bin/bash
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2023-2025 SilentWind <mofeng654321@hotmail.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/>. #
# #
# ========================================================================== #
# 定义颜色代码
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${GREEN}One-KVM pre-starting...${NC}"
# 输出日志的函数
log_info() {
echo -e "${GREEN}[INFO] $1${NC}"
}
log_warn() {
echo -e "${YELLOW}[WARN] $1${NC}"
}
log_error() {
echo -e "${RED}[ERROR] $1${NC}"
}
# 初始化检查
log_info "One-KVM 正在启动..."
# 首次初始化配置
if [ ! -f /etc/kvmd/.init_flag ]; then
echo -e "${GREEN}One-KVM is initializing first...${NC}" \
&& mkdir -p /etc/kvmd/ \
&& mv /etc/kvmd_backup/* /etc/kvmd/ \
&& touch /etc/kvmd/.docker_flag \
&& sed -i 's/localhost.localdomain/docker/g' /etc/kvmd/meta.yaml \
&& sed -i 's/localhost/localhost:4430/g' /etc/kvmd/kvm_input.sh \
&& /usr/share/kvmd/kvmd-gencert --do-the-thing \
&& /usr/share/kvmd/kvmd-gencert --do-the-thing --vnc \
|| echo -e "${RED}One-KVM config moving and self-signed SSL certificates init failed.${NC}"
if [ "$NOSSL" == 1 ]; then
echo -e "${GREEN}One-KVM self-signed SSL is disabled.${NC}" \
&& python -m kvmd.apps.ngxmkconf /etc/kvmd/nginx/nginx.conf.mako /etc/kvmd/nginx/nginx.conf -o nginx/https/enabled=false \
|| echo -e "${RED}One-KVM nginx config init failed.${NC}"
log_info "首次初始化配置..."
# 创建必要目录并移动配置文件
if mkdir -p /etc/kvmd/ && \
mv /etc/kvmd_backup/* /etc/kvmd/ && \
touch /etc/kvmd/.docker_flag && \
sed -i 's/localhost.localdomain/docker/g' /etc/kvmd/meta.yaml && \
sed -i 's/localhost/localhost:4430/g' /etc/kvmd/kvm_input.sh; then
log_info "移动配置文件完成"
else
python -m kvmd.apps.ngxmkconf /etc/kvmd/nginx/nginx.conf.mako /etc/kvmd/nginx/nginx.conf \
|| echo -e "${RED}One-KVM nginx config init failed.${NC}"
log_error "移动配置文件失败"
exit 1
fi
# SSL证书配置
if ! /usr/share/kvmd/kvmd-gencert --do-the-thing; then
log_error "Nginx SSL 证书生成失败"
exit 1
fi
if ! /usr/share/kvmd/kvmd-gencert --do-the-thing --vnc; then
log_error "VNC SSL 证书生成失败"
exit 1
fi
# 设置用户名和密码
if [ ! -z "$USERNAME" ] && [ ! -z "$PASSWORD" ]; then
if python -m kvmd.apps.htpasswd del admin \
&& 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 "用户凭据设置成功"
else
log_error "用户凭据设置失败"
exit 1
fi
else
log_warn "未设置 USERNAME 和 PASSWORD 环境变量,使用默认值(admin/admin)"
fi
# SSL开关配置
if [ "$NOSSL" == 1 ]; then
log_info "已禁用SSL"
if ! python -m kvmd.apps.ngxmkconf /etc/kvmd/nginx/nginx.conf.mako /etc/kvmd/nginx/nginx.conf -o nginx/https/enabled=false; then
log_error "Nginx 配置失败"
exit 1
fi
else
if ! python -m kvmd.apps.ngxmkconf /etc/kvmd/nginx/nginx.conf.mako /etc/kvmd/nginx/nginx.conf; then
log_error "Nginx 配置失败"
exit 1
fi
fi
# 认证配置
if [ "$NOAUTH" == "1" ]; then
sed -i "s/enabled: true/enabled: false/g" /etc/kvmd/override.yaml \
&& echo -e "${GREEN}One-KVM auth is disabled.${NC}"
sed -i "s/enabled: true/enabled: false/g" /etc/kvmd/override.yaml
log_info "已禁用认证"
fi
#add supervisord conf
if [ "$NOWEBTERM" == "1" ]; then
echo -e "${GREEN}One-KVM webterm is disabled.${NC}"
log_info "已禁用 WebTerm 功能"
rm -r /usr/share/kvmd/extras/webterm
else
cat >> /etc/kvmd/supervisord.conf << EOF
@@ -58,7 +129,7 @@ EOF
fi
if [ "$NOVNC" == "1" ]; then
echo -e "${GREEN}One-KVM VNC is disabled.${NC}"
log_info "已禁用 VNC 功能"
rm -r /usr/share/kvmd/extras/vnc
else
cat >> /etc/kvmd/supervisord.conf << EOF
@@ -77,7 +148,7 @@ EOF
fi
if [ "$NOIPMI" == "1" ]; then
echo -e "${GREEN}One-KVM IPMI is disabled.${NC}"
log_info "已禁用IPMI功能"
rm -r /usr/share/kvmd/extras/ipmi
else
cat >> /etc/kvmd/supervisord.conf << EOF
@@ -97,70 +168,77 @@ EOF
#switch OTG config
if [ "$OTG" == "1" ]; then
echo -e "${GREEN}One-KVM OTG is enabled.${NC}"
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
echo -e "${GREEN}One-KVM MSD is disabled.${NC}"
log_info "已禁用 MSD 功能"
else
sed -i "s/#type: otg/type: otg/g" /etc/kvmd/override.yaml
fi
fi
#if [ ! -z "$SHUTDOWNPIN" ! -z "$REBOOTPIN" ]; then
if [ ! -z "$VIDEONUM" ]; then
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 \
&& echo -e "${GREEN}One-KVM video device is set to /dev/video$VIDEONUM.${NC}"
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
if [ ! -z "$AUDIONUM" ]; then
sed -i "s/hw:0/hw:$AUDIONUM/g" /etc/kvmd/janus/janus.plugin.ustreamer.jcfg \
&& echo -e "${GREEN}One-KVM audio device is set to hw:$VIDEONUM.${NC}"
if sed -i "s/hw:0/hw:$AUDIONUM/g" /etc/kvmd/janus/janus.plugin.ustreamer.jcfg; then
log_info "音频设备已设置为 hw:$AUDIONUM"
fi
fi
if [ ! -z "$CH9329SPEED" ]; then
sed -i "s/speed: 9600/speed: $CH9329SPEED/g" /etc/kvmd/override.yaml \
&& echo -e "${GREEN}One-KVM CH9329 serial speed is set to $CH9329SPEED.${NC}"
if sed -i "s/speed: 9600/speed: $CH9329SPEED/g" /etc/kvmd/override.yaml; then
log_info "CH9329 串口速率已设置为 $CH9329SPEED"
fi
fi
if [ ! -z "$CH9329TIMEOUT" ]; then
sed -i "s/read_timeout: 0.3/read_timeout: $CH9329TIMEOUT/g" /etc/kvmd/override.yaml \
&& echo -e "${GREEN}One-KVM CH9329 timeout is set to $CH9329TIMEOUT s.${NC}"
if sed -i "s/read_timeout: 0.3/read_timeout: $CH9329TIMEOUT/g" /etc/kvmd/override.yaml; then
log_info "CH9329 超时已设置为 $CH9329TIMEOUT "
fi
fi
#set htpasswd
if [ ! -z "$USERNAME" ] && [ ! -z "$PASSWORD" ]; then
python -m kvmd.apps.htpasswd del admin \
&& 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 \
|| echo -e "${RED}One-KVM htpasswd init failed.${NC}"
else
echo -e "${YELLOW} USERNAME and PASSWORD environment variables are not set, using defalut(admin/admin).${NC}"
if [ ! -z "$H264PRESET" ]; then
if sed -i "s/ultrafast/$H264PRESET/g" /etc/kvmd/override.yaml; then
log_info "H264 预设已设置为 $H264PRESET"
fi
fi
if [ ! -z "$VIDEOFORMAT" ]; then
sed -i "s/format=mjpeg/format=$VIDFORMAT/g" /etc/kvmd/override.yaml \
&& echo -e "${GREEN}One-KVM input video format is set to $VIDFORMAT.${NC}"
if sed -i "s/format=mjpeg/format=$VIDFORMAT/g" /etc/kvmd/override.yaml; then
log_info "视频输入格式已设置为 $VIDFORMAT"
fi
fi
touch /etc/kvmd/.init_flag
log_info "初始化配置完成"
fi
#Trying usb_gadget
# OTG设备配置
if [ "$OTG" == "1" ]; then
echo "Trying OTG Port..."
log_info "正在配置 OTG 设备..."
rm -r /run/kvmd/otg &> /dev/null
modprobe libcomposite || echo -e "${RED}Linux libcomposite module modprobe failed.${NC}"
python -m kvmd.apps.otg start \
&& ln -s /dev/hidg1 /dev/kvmd-hid-mouse \
&& ln -s /dev/hidg0 /dev/kvmd-hid-keyboard \
|| echo -e "${RED}OTG Port mount failed.${NC}"
ln -s /dev/hidg2 /dev/kvmd-hid-mouse-alt
if ! modprobe libcomposite; then
log_error "加载 libcomposite 模块失败"
exit 1
fi
if python -m kvmd.apps.otg start; then
ln -s /dev/hidg1 /dev/kvmd-hid-mouse
ln -s /dev/hidg0 /dev/kvmd-hid-keyboard
ln -s /dev/hidg2 /dev/kvmd-hid-mouse-alt
log_info "OTG 设备配置完成"
else
log_warn "OTG 设备挂载失败"
#exit 1
fi
fi
echo -e "${GREEN}One-KVM starting...${NC}"
log_info "One-KVM 配置文件准备完成,正在启动服务..."
exec supervisord -c /etc/kvmd/supervisord.conf

View File

@@ -0,0 +1,3 @@
PIKVM_MODEL=v2_model
PIKVM_VIDEO=usb_video
PIKVM_BOARD=octopus-flanet

View File

@@ -0,0 +1,64 @@
#!/bin/bash
# 为onecloud平台生成随机MAC地址的一次性脚本
# 此脚本在首次开机时执行为eth0网卡生成并应用随机MAC地址
set -e
NETWORK_CONFIG="/etc/systemd/network/99-eth0.network"
LOCK_FILE="/var/lib/kvmd/.mac-generated"
# 检查是否已经执行过
if [ -f "$LOCK_FILE" ]; then
echo "MAC地址已经生成过跳过执行"
exit 0
fi
# 生成随机MAC地址 (使用本地管理的MAC地址前缀)
generate_random_mac() {
# 使用本地管理的MAC地址前缀 (第二位设为2、6、A、E中的一个)
# 这样可以避免与真实硬件MAC地址冲突
printf "02:%02x:%02x:%02x:%02x:%02x\n" \
$((RANDOM % 256)) \
$((RANDOM % 256)) \
$((RANDOM % 256)) \
$((RANDOM % 256)) \
$((RANDOM % 256))
}
echo "正在为onecloud生成随机MAC地址..."
# 生成新的MAC地址
NEW_MAC=$(generate_random_mac)
echo "生成的MAC地址: $NEW_MAC"
# 备份原配置文件
if [ -f "$NETWORK_CONFIG" ]; then
cp "$NETWORK_CONFIG" "${NETWORK_CONFIG}.backup"
fi
# 更新网络配置文件
cat > "$NETWORK_CONFIG" << EOF
[Match]
Name=eth0
[Network]
DHCP=yes
[Link]
MACAddress=$NEW_MAC
EOF
echo "已更新网络配置文件: $NETWORK_CONFIG"
# 创建锁定文件,防止重复执行
mkdir -p "$(dirname "$LOCK_FILE")"
echo "MAC地址生成时间: $(date)" > "$LOCK_FILE"
# 禁用此服务,确保只运行一次
systemctl disable kvmd-generate-mac.service
echo "随机MAC地址生成完成: $NEW_MAC"
echo "服务已自动禁用,下次开机不会再执行"
exit 0

View File

@@ -0,0 +1,34 @@
#!/bin/bash
# KVMD首次运行初始化脚本
# 在首次开机时执行KVMD服务启动前的必要初始化操作
set -e
LOCK_FILE="/var/lib/kvmd/.kvmd-firstrun-completed"
# 检查是否已经执行过
[ -f "$LOCK_FILE" ] && { echo "[KVMD-FirstRun] 初始化已完成,跳过执行"; exit 0; }
echo "[KVMD-FirstRun] 开始KVMD首次运行初始化..."
# 1. 生成KVMD主证书
echo "[KVMD-FirstRun] 生成KVMD主证书..."
kvmd-gencert --do-the-thing
# 2. 生成VNC证书
echo "[KVMD-FirstRun] 生成VNC证书..."
kvmd-gencert --do-the-thing --vnc
# 3. 生成nginx配置文件
echo "[KVMD-FirstRun] 生成nginx配置文件..."
kvmd-nginx-mkconf /etc/kvmd/nginx/nginx.conf.mako /etc/kvmd/nginx/nginx.conf || echo "[KVMD-FirstRun] 警告: nginx配置生成失败"
# 创建锁定文件
mkdir -p "$(dirname "$LOCK_FILE")"
echo "KVMD首次运行初始化完成 - $(date)" > "$LOCK_FILE"
# 禁用服务
systemctl disable kvmd-firstrun.service || echo "[KVMD-FirstRun] 警告: 服务禁用失败"
echo "[KVMD-FirstRun] 初始化完成!"

View File

@@ -0,0 +1,26 @@
[Unit]
Description=KVMD First Run Initialization (One-time)
Documentation=https://github.com/your-repo/One-KVM
Before=kvmd.service
Before=kvmd-nginx.service
Before=kvmd-otg.service
Before=kvmd-vnc.service
Before=kvmd-ipmi.service
Before=kvmd-webterm.service
Before=kvmd-janus.service
Before=kvmd-media.service
After=local-fs.target
After=network.target
Wants=local-fs.target
ConditionPathExists=!/var/lib/kvmd/.kvmd-firstrun-completed
[Service]
Type=oneshot
ExecStart=/usr/bin/kvmd-firstrun.sh
RemainAfterExit=yes
StandardOutput=journal
StandardError=journal
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,18 @@
[Unit]
Description=Generate Random MAC Address for OneCloud (One-time)
Documentation=https://github.com/your-repo/One-KVM
Before=systemd-networkd.service
Before=network-pre.target
Wants=network-pre.target
After=local-fs.target
ConditionPathExists=!/var/lib/kvmd/.mac-generated
[Service]
Type=oneshot
ExecStart=/usr/local/bin/generate-random-mac.sh
RemainAfterExit=yes
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

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

View File

@@ -1,4 +1,24 @@
#!/bin/bash
#!/bin/bash
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2023-2025 SilentWind <mofeng654321@hotmail.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/>. #
# #
# ========================================================================== #
if [ -e /etc/update-motd.d/10-armbian-header ]; then /etc/update-motd.d/10-armbian-header; fi
if [ -e /etc/update-motd.d/30-armbian-sysinfo ]; then /etc/update-motd.d/30-armbian-sysinfo; fi
@@ -15,8 +35,6 @@ printf "
____________________________________________________________________________
欢迎使用 One-KVM基于开源程序 PiKVM 的 IP-KVM 应用
项目链接:
* One-KVMhttps://github.com/mofeng-git/One-KVM

View File

@@ -1,4 +1,24 @@
#!/bin/bash
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2023-2025 SilentWind <mofeng654321@hotmail.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/>. #
# #
# ========================================================================== #
echo $ATX
case $ATX in

View File

@@ -1,4 +1,24 @@
#!/bin/bash
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2023-2025 SilentWind <mofeng654321@hotmail.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/>. #
# #
# ========================================================================== #
RED='\033[0;31m'
GREEN='\033[0;32m'

View File

@@ -1,4 +1,24 @@
#!/bin/bash
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2023-2025 SilentWind <mofeng654321@hotmail.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/>. #
# #
# ========================================================================== #
case $1 in
short)
gpioset -m time -s 1 SHUTDOWNPIN=0

View File

@@ -1,3 +1,24 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2023-2025 SilentWind <mofeng654321@hotmail.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 sys
import hid

View File

@@ -1,4 +1,24 @@
#!/bin/bash
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2023-2025 SilentWind <mofeng654321@hotmail.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/>. #
# #
# ========================================================================== #
case $1 in
short)
python3 /etc/kvmd/custom_atx/usbrelay_hid.py 1 on

View File

@@ -86,13 +86,15 @@ kvmd:
pulse: false
media:
memsink:
h264:
sink: "kvmd::ustreamer::h264"
vnc:
memsink:
jpeg:
sink: "kvmd::ustreamer::jpeg"
h264:
sink: "kvmd::ustreamer::h264"
otg:
remote_wakeup: true

View File

@@ -9,6 +9,6 @@ server:
kvm: {
base_on: PiKVM,
app_name: One-KVM,
majaro_version: 241204,
main_version: 241204,
author: SilentWind
}

View File

@@ -37,7 +37,7 @@ kvmd:
forever: true
desired_fps:
default: 30
default: 60
max: 60
h264_bitrate:
@@ -66,6 +66,7 @@ kvmd:
- "--jpeg-sink-mode=0660"
- "--h264-bitrate={h264_bitrate}"
- "--h264-gop={h264_gop}"
- "--h264-preset=ultrafast"
- "--slowdown"
gpio:
drivers:
@@ -149,6 +150,18 @@ vnc:
h264:
sink: "kvmd::ustreamer::h264"
media:
memsink:
h264:
sink: 'kvmd::ustreamer::h264'
jpeg:
sink: 'kvmd::ustreamer::jpeg'
janus:
stun:
host: stun.cloudflare.com
port: 3478
otgnet:
commands:
post_start_cmd:

View File

@@ -32,6 +32,16 @@ stdout_logfile=/dev/stdout
stdout_logfile_maxbytes = 0
redirect_stderr=true
[program:kvmd-media]
command=python -m kvmd.apps.media --run
autostart=true
autorestart=true
priority=13
stopasgroup=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes = 0
redirect_stderr=true
[program:kvmd-nginx]
command=nginx -c /etc/kvmd/nginx/nginx.conf -g 'daemon off;user root; error_log stderr;'
autostart=true

View File

@@ -0,0 +1,16 @@
[Unit]
Description=PiKVM - Media proxy server
After=kvmd.service
[Service]
User=kvmd-media
Group=kvmd-media
Type=simple
Restart=always
RestartSec=3
ExecStart=/usr/bin/kvmd-media --run
TimeoutStopSec=3
[Install]
WantedBy=multi-user.target

View File

@@ -1,4 +1,5 @@
g kvmd - -
g kvmd-media - -
g kvmd-pst - -
g kvmd-ipmi - -
g kvmd-vnc - -
@@ -7,6 +8,7 @@ g kvmd-janus - -
g kvmd-certbot - -
u kvmd - "PiKVM - The main daemon" -
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" -
@@ -19,8 +21,11 @@ m kvmd gpio
m kvmd uucp
m kvmd spi
m kvmd systemd-journal
m kvmd kvmd-media
m kvmd kvmd-pst
m kvmd-media kvmd
m kvmd-pst kvmd
m kvmd-ipmi kvmd
@@ -32,6 +37,7 @@ m kvmd-janus kvmd
m kvmd-janus audio
m kvmd-nginx kvmd
m kvmd-nginx kvmd-media
m kvmd-nginx kvmd-janus
m kvmd-nginx kvmd-certbot

View File

@@ -1,3 +1,4 @@
# Here are described some bindings for PiKVM devices.
# Do not edit this file.
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

@@ -1,7 +0,0 @@
# https://unix.stackexchange.com/questions/66901/how-to-bind-usb-device-under-a-static-name
# https://wiki.archlinux.org/index.php/Udev#Setting_static_device_names
KERNEL=="video[0-9]*", SUBSYSTEM=="video4linux", SUBSYSTEMS=="usb", ATTR{index}=="0", GROUP="kvmd", SYMLINK+="kvmd-video"
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"

View File

@@ -0,0 +1,5 @@
name: Media
description: KVMD Media Proxy
path: media
daemon: kvmd-media
place: -1

View File

@@ -0,0 +1,3 @@
upstream media {
server unix:/run/kvmd/media.sock fail_timeout=0s max_fails=0;
}

View File

@@ -0,0 +1,7 @@
location /api/media/ws {
rewrite ^/api/media/ws$ /ws break;
rewrite ^/api/media/ws\?(.*)$ /ws?$1 break;
proxy_pass http://media;
include /etc/kvmd/nginx/loc-proxy.conf;
include /etc/kvmd/nginx/loc-websocket.conf;
}

View File

@@ -31,7 +31,7 @@ endef
.tinyusb:
$(call libdep,tinyusb,hathach/tinyusb,d713571cd44f05d2fc72efc09c670787b74106e0)
.ps2x2pico:
$(call libdep,ps2x2pico,No0ne/ps2x2pico,404aaf02949d5bee8013e3b5d0b3239abf6e13bd)
$(call libdep,ps2x2pico,No0ne/ps2x2pico,26ce89d597e598bb0ac636622e064202d91a9efc)
deps: .pico-sdk .tinyusb .ps2x2pico

View File

@@ -19,7 +19,7 @@ target_sources(${target_name} PRIVATE
${PS2_PATH}/ps2in.c
${PS2_PATH}/ps2kb.c
${PS2_PATH}/ps2ms.c
${PS2_PATH}/scancodesets.c
${PS2_PATH}/scancodes.c
)
target_link_options(${target_name} PRIVATE -Xlinker --print-memory-usage)
target_compile_options(${target_name} PRIVATE -Wall -Wextra)

View File

@@ -53,7 +53,7 @@ static u8 _kbd_keys[6] = {0};
static u8 _mouse_buttons = 0;
static s16 _mouse_abs_x = 0;
static s16 _mouse_abs_y = 0;
#define _MOUSE_CLEAR { _mouse_buttons = 0; _mouse_abs_x = 0; _mouse_abs_y = 0; }
#define _MOUSE_CLEAR { _mouse_buttons = 0; }
static void _kbd_sync_report(bool new);
@@ -193,7 +193,7 @@ void ph_usb_send_clear(void) {
if (PH_O_IS_MOUSE_USB) {
_MOUSE_CLEAR;
if (PH_O_IS_MOUSE_USB_ABS) {
_mouse_abs_send_report(0, 0);
_mouse_abs_send_report(_mouse_abs_x, _mouse_abs_y);
} else { // PH_O_IS_MOUSE_USB_REL
_mouse_rel_send_report(0, 0, 0, 0);
}

View File

@@ -102,6 +102,16 @@ EOF
touch -t 200701011000 /etc/fstab
fi
if [[ "$(vercmp "$2" 4.31)" -lt 0 ]]; then
if [[ "$(systemctl is-enabled kvmd-janus || true)" = enabled || "$(systemctl is-enabled kvmd-janus-static || true)" = enabled ]]; then
systemctl enable kvmd-media || true
fi
fi
if [[ "$(vercmp "$2" 4.47)" -lt 0 ]]; then
cp /usr/share/kvmd/configs.default/janus/janus.plugin.ustreamer.jcfg /etc/kvmd/janus || true
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.20"
__version__ = "4.49"

View File

@@ -45,6 +45,11 @@ async def read_file(path: str) -> str:
return (await file.read())
async def write_file(path: str, text: str) -> None:
async with aiofiles.open(path, "w") as file:
await file.write(text)
# =====
def run(coro: Coroutine, final: (Coroutine | None)=None) -> None:
# https://github.com/aio-libs/aiohttp/blob/a1d4dac1d/aiohttp/web.py#L515
@@ -166,7 +171,7 @@ def create_deadly_task(name: str, coro: Coroutine) -> asyncio.Task:
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Unhandled exception in deadly task, killing myself ...")
logger.exception("Unhandled exception in deadly task %r, killing myself ...", name)
pid = os.getpid()
if pid == 1:
os._exit(1) # Docker workaround # pylint: disable=protected-access

View File

@@ -502,6 +502,37 @@ def _get_config_scheme() -> dict:
"table": Option([], type=valid_ugpio_view_table),
},
},
"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"),
},
},
"media": {
"server": {
"unix": Option("/run/kvmd/media.sock", type=valid_abs_path, unpack_as="unix_path"),
"unix_rm": Option(True, type=valid_bool),
"unix_mode": Option(0o660, type=valid_unix_mode),
"heartbeat": Option(15.0, type=valid_float_f01),
"access_log_format": Option("[%P / %{X-Real-IP}i] '%r' => %s; size=%b ---"
" referer='%{Referer}i'; user_agent='%{User-Agent}i'"),
},
"memsink": {
"jpeg": {
"sink": Option("", unpack_as="obj"),
"lock_timeout": Option(1.0, type=valid_float_f01),
"wait_timeout": Option(1.0, type=valid_float_f01),
"drop_same_frames": Option(0.0, type=valid_float_f0),
},
"h264": {
"sink": Option("", unpack_as="obj"),
"lock_timeout": Option(1.0, type=valid_float_f01),
"wait_timeout": Option(1.0, type=valid_float_f01),
"drop_same_frames": Option(0.0, type=valid_float_f0),
},
},
},
"pst": {
@@ -532,11 +563,12 @@ def _get_config_scheme() -> dict:
"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(False, type=valid_bool),
"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),
"user": Option("kvmd", type=valid_user),
@@ -550,6 +582,9 @@ def _get_config_scheme() -> dict:
"mouse": {
"start": Option(True, type=valid_bool),
},
"mouse_alt": {
"start": Option(True, type=valid_bool),
},
},
"msd": {
@@ -560,6 +595,18 @@ def _get_config_scheme() -> dict:
"rw": Option(False, type=valid_bool),
"removable": Option(True, type=valid_bool),
"fua": Option(True, type=valid_bool),
"inquiry_string": {
"cdrom": {
"vendor": Option("PiKVM", type=valid_stripped_string),
"product": Option("Optical Drive", type=valid_stripped_string),
"revision": Option("1.00", type=valid_stripped_string),
},
"flash": {
"vendor": Option("PiKVM", type=valid_stripped_string),
"product": Option("Flash Drive", type=valid_stripped_string),
"revision": Option("1.00", type=valid_stripped_string),
},
},
},
},
@@ -576,6 +623,11 @@ def _get_config_scheme() -> dict:
"kvm_mac": Option("", type=valid_mac, if_empty=""),
},
"audio": {
"enabled": Option(False, type=valid_bool),
"start": Option(True, type=valid_bool),
},
"drives": {
"enabled": Option(False, type=valid_bool),
"start": Option(True, type=valid_bool),
@@ -586,6 +638,18 @@ def _get_config_scheme() -> dict:
"rw": Option(True, type=valid_bool),
"removable": Option(True, type=valid_bool),
"fua": Option(True, type=valid_bool),
"inquiry_string": {
"cdrom": {
"vendor": Option("PiKVM", type=valid_stripped_string),
"product": Option("Optical Drive", type=valid_stripped_string),
"revision": Option("1.00", type=valid_stripped_string),
},
"flash": {
"vendor": Option("PiKVM", type=valid_stripped_string),
"product": Option("Flash Drive", type=valid_stripped_string),
"revision": Option("1.00", type=valid_stripped_string),
},
},
},
},
},

View File

@@ -35,6 +35,7 @@ from .ugpio import UserGpio
from .streamer import Streamer
from .snapshoter import Snapshoter
from .ocr import Ocr
from .switch import Switch
from .server import KvmdServer
@@ -90,6 +91,10 @@ def main(argv: (list[str] | None)=None) -> None:
log_reader=(LogReader() if config.log_reader.enabled else None),
user_gpio=UserGpio(config.gpio, global_config.otg),
ocr=Ocr(**config.ocr._unpack()),
switch=Switch(
pst_unix_path=global_config.pst.server.unix,
**config.switch._unpack(),
),
hid=hid,
atx=get_atx_class(config.atx.type)(**config.atx._unpack(ignore=["type"])),

View File

@@ -66,7 +66,7 @@ class ExportApi:
self.__append_prometheus_rows(rows, atx_state["leds"]["power"], "pikvm_atx_power") # type: ignore
for mode in sorted(UserGpioModes.ALL):
for (channel, ch_state) in gpio_state[f"{mode}s"].items(): # type: ignore
for (channel, ch_state) in gpio_state["state"][f"{mode}s"].items(): # type: ignore
if not channel.startswith("__"): # Hide special GPIOs
for key in ["online", "state"]:
self.__append_prometheus_rows(rows, ch_state["state"], f"pikvm_gpio_{mode}_{key}_{channel}")

View File

@@ -123,7 +123,8 @@ class HidApi:
if limit > 0:
text = text[:limit]
symmap = self.__ensure_symmap(req.query.get("keymap", self.__default_keymap_name))
self.__hid.send_key_events(text_to_web_keys(text, symmap), no_ignore_keys=True)
slow = valid_bool(req.query.get("slow", False))
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, str]]:
@@ -148,16 +149,17 @@ class HidApi:
async def __ws_bin_key_handler(self, _: WsSession, data: bytes) -> None:
try:
key = valid_hid_key(data[1:].decode("ascii"))
state = valid_bool(data[0])
state = bool(data[0] & 0b01)
finish = bool(data[0] & 0b10)
except Exception:
return
self.__hid.send_key_event(key, state)
self.__hid.send_key_event(key, state, finish)
@exposed_ws(2)
async def __ws_bin_mouse_button_handler(self, _: WsSession, data: bytes) -> None:
try:
button = valid_hid_mouse_button(data[1:].decode("ascii"))
state = valid_bool(data[0])
state = bool(data[0] & 0b01)
except Exception:
return
self.__hid.send_mouse_button_event(button, state)
@@ -182,7 +184,7 @@ class HidApi:
def __process_ws_bin_delta_request(self, data: bytes, handler: Callable[[Iterable[tuple[int, int]], bool], None]) -> None:
try:
squash = valid_bool(data[0])
squash = bool(data[0] & 0b01)
data = data[1:]
deltas: list[tuple[int, int]] = []
for index in range(0, len(data), 2):
@@ -199,9 +201,10 @@ class HidApi:
try:
key = valid_hid_key(event["key"])
state = valid_bool(event["state"])
finish = valid_bool(event.get("finish", False))
except Exception:
return
self.__hid.send_key_event(key, state)
self.__hid.send_key_event(key, state, finish)
@exposed_ws("mouse_button")
async def __ws_mouse_button_handler(self, _: WsSession, event: dict) -> None:
@@ -248,9 +251,10 @@ class HidApi:
key = valid_hid_key(req.query.get("key"))
if "state" in req.query:
state = valid_bool(req.query["state"])
self.__hid.send_key_event(key, state)
finish = valid_bool(req.query.get("finish", False))
self.__hid.send_key_event(key, state, finish)
else:
self.__hid.send_key_events([(key, True), (key, False)])
self.__hid.send_key_event(key, True, True)
return make_json_response()
@exposed_http("POST", "/hid/events/send_mouse_button")

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# Copyright (C) 2023-2025 SilentWind <mofeng654321@hotmail.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 #
@@ -19,7 +20,6 @@
# #
# ========================================================================== #
from aiohttp.web import Request
from aiohttp.web import StreamResponse

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# Copyright (C) 2023-2025 SilentWind <mofeng654321@hotmail.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 #
@@ -63,11 +64,7 @@ class MsdApi:
@exposed_http("GET", "/msd")
async def __state_handler(self, _: Request) -> Response:
state = await self.__msd.get_state()
if state["storage"] and state["storage"]["parts"]:
state["storage"]["size"] = state["storage"]["parts"][""]["size"] # Legacy API
state["storage"]["free"] = state["storage"]["parts"][""]["free"] # Legacy API
return make_json_response(state)
return make_json_response(await self.__msd.get_state())
@exposed_http("POST", "/msd/set_params")
async def __set_params_handler(self, req: Request) -> Response:

View File

@@ -0,0 +1,164 @@
# ========================================================================== #
# #
# 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/>. #
# #
# ========================================================================== #
from aiohttp.web import Request
from aiohttp.web import Response
from ....htserver import exposed_http
from ....htserver import make_json_response
from ....validators.basic import valid_bool
from ....validators.basic import valid_int_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
from ....validators.switch import valid_switch_port_name
from ....validators.switch import valid_switch_edid_id
from ....validators.switch import valid_switch_edid_data
from ....validators.switch import valid_switch_color
from ....validators.switch import valid_switch_atx_click_delay
from ..switch import Switch
from ..switch import Colors
# =====
class SwitchApi:
def __init__(self, switch: Switch) -> None:
self.__switch = switch
# =====
@exposed_http("GET", "/switch")
async def __state_handler(self, _: Request) -> Response:
return make_json_response(await self.__switch.get_state())
@exposed_http("POST", "/switch/set_active")
async def __set_active_port_handler(self, req: Request) -> Response:
port = valid_int_f0(req.query.get("port"))
await self.__switch.set_active_port(port)
return make_json_response()
@exposed_http("POST", "/switch/set_beacon")
async def __set_beacon_handler(self, req: Request) -> Response:
on = valid_bool(req.query.get("state"))
if "port" in req.query:
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"))
await self.__switch.set_uplink_beacon(unit, on)
else: # Downlink
unit = valid_int_f0(req.query.get("downlink"))
await self.__switch.set_downlink_beacon(unit, on)
return make_json_response()
@exposed_http("POST", "/switch/set_port_params")
async def __set_port_params(self, req: Request) -> Response:
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))),
("name", valid_switch_port_name),
("atx_click_power_delay", valid_switch_atx_click_delay),
("atx_click_power_long_delay", valid_switch_atx_click_delay),
("atx_click_reset_delay", valid_switch_atx_click_delay),
]
if req.query.get(param) is not None
}
await self.__switch.set_port_params(port, **params) # type: ignore
return make_json_response()
@exposed_http("POST", "/switch/set_colors")
async def __set_colors(self, req: Request) -> Response:
params = {
param: valid_switch_color(req.query.get(param), allow_default=True)
for param in Colors.ROLES
if req.query.get(param) is not None
}
await self.__switch.set_colors(**params)
return make_json_response()
# =====
@exposed_http("POST", "/switch/reset")
async def __reset(self, req: Request) -> Response:
unit = valid_int_f0(req.query.get("unit"))
bootloader = valid_bool(req.query.get("bootloader", False))
await self.__switch.reboot_unit(unit, bootloader)
return make_json_response()
# =====
@exposed_http("POST", "/switch/edids/create")
async def __create_edid(self, req: Request) -> Response:
name = valid_stripped_string_not_empty(req.query.get("name"))
data_hex = valid_switch_edid_data(req.query.get("data"))
edid_id = await self.__switch.create_edid(name, data_hex)
return make_json_response({"id": edid_id})
@exposed_http("POST", "/switch/edids/change")
async def __change_edid(self, req: Request) -> Response:
edid_id = valid_switch_edid_id(req.query.get("id"), allow_default=False)
params = {
param: validator(req.query.get(param))
for (param, validator) in [
("name", valid_switch_port_name),
("data", valid_switch_edid_data),
]
if req.query.get(param) is not None
}
if params:
await self.__switch.change_edid(edid_id, **params)
return make_json_response()
@exposed_http("POST", "/switch/edids/remove")
async def __remove_edid(self, req: Request) -> Response:
edid_id = valid_switch_edid_id(req.query.get("id"), allow_default=False)
await self.__switch.remove_edid(edid_id)
return make_json_response()
# =====
@exposed_http("POST", "/switch/atx/power")
async def __power_handler(self, req: Request) -> Response:
port = valid_int_f0(req.query.get("port"))
action = valid_atx_power_action(req.query.get("action"))
await ({
"on": self.__switch.atx_power_on,
"off": self.__switch.atx_power_off,
"off_hard": self.__switch.atx_power_off_hard,
"reset_hard": self.__switch.atx_power_reset_hard,
}[action])(port)
return make_json_response()
@exposed_http("POST", "/switch/atx/click")
async def __click_handler(self, req: Request) -> Response:
port = valid_int_f0(req.query.get("port"))
button = valid_atx_button(req.query.get("button"))
await ({
"power": self.__switch.atx_click_power,
"power_long": self.__switch.atx_click_power_long,
"reset": self.__switch.atx_click_reset,
}[button])(port)
return make_json_response()

View File

@@ -95,7 +95,7 @@ class AuthManager:
secret = file.read().strip()
if secret:
code = passwd[-6:]
if not pyotp.TOTP(secret).verify(code):
if not pyotp.TOTP(secret).verify(code, valid_window=1):
get_logger().error("Got access denied for user %r by TOTP", user)
return False
passwd = passwd[:-6]

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# Copyright (C) 2023-2025 SilentWind <mofeng654321@hotmail.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 #

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# Copyright (C) 2023-2025 SilentWind <mofeng654321@hotmail.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 #

View File

@@ -66,6 +66,7 @@ from .ugpio import UserGpio
from .streamer import Streamer
from .snapshoter import Snapshoter
from .ocr import Ocr
from .switch import Switch
from .api.auth import AuthApi
from .api.auth import check_request_auth
@@ -77,6 +78,7 @@ from .api.hid import HidApi
from .api.atx import AtxApi
from .api.msd import MsdApi
from .api.streamer import StreamerApi
from .api.switch import SwitchApi
from .api.export import ExportApi
from .api.redfish import RedfishApi
@@ -125,18 +127,19 @@ class _Subsystem:
cleanup=getattr(obj, "cleanup", None),
trigger_state=getattr(obj, "trigger_state", None),
poll_state=getattr(obj, "poll_state", None),
)
class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-instance-attributes
__EV_GPIO_STATE = "gpio_state"
__EV_HID_STATE = "hid_state"
__EV_ATX_STATE = "atx_state"
__EV_MSD_STATE = "msd_state"
__EV_STREAMER_STATE = "streamer_state"
__EV_OCR_STATE = "ocr_state"
__EV_INFO_STATE = "info_state"
__EV_GPIO_STATE = "gpio"
__EV_HID_STATE = "hid"
__EV_HID_KEYMAPS_STATE = "hid_keymaps" # FIXME
__EV_ATX_STATE = "atx"
__EV_MSD_STATE = "msd"
__EV_STREAMER_STATE = "streamer"
__EV_OCR_STATE = "ocr"
__EV_INFO_STATE = "info"
__EV_SWITCH_STATE = "switch"
def __init__( # pylint: disable=too-many-arguments,too-many-locals
self,
@@ -145,6 +148,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
log_reader: (LogReader | None),
user_gpio: UserGpio,
ocr: Ocr,
switch: Switch,
hid: BaseHid,
atx: BaseAtx,
@@ -177,6 +181,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
AtxApi(atx),
MsdApi(msd),
StreamerApi(streamer, ocr),
SwitchApi(switch),
ExportApi(info_manager, atx, user_gpio),
RedfishApi(info_manager, atx),
]
@@ -189,6 +194,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
_Subsystem.make(streamer, "Streamer", self.__EV_STREAMER_STATE),
_Subsystem.make(ocr, "OCR", self.__EV_OCR_STATE),
_Subsystem.make(info_manager, "Info manager", self.__EV_INFO_STATE),
_Subsystem.make(switch, "Switch", self.__EV_SWITCH_STATE),
]
self.__streamer_notifier = aiotools.AioNotifier()
@@ -229,8 +235,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
@exposed_http("GET", "/ws")
async def __ws_handler(self, req: Request) -> WebSocketResponse:
stream = valid_bool(req.query.get("stream", True))
legacy = valid_bool(req.query.get("legacy", True))
async with self._ws_session(req, stream=stream, legacy=legacy) as ws:
async with self._ws_session(req, stream=stream) as ws:
(major, minor) = __version__.split(".")
await ws.send_event("loop", {
"version": {
@@ -242,7 +247,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
if sub.event_type:
assert sub.trigger_state
await sub.trigger_state()
await self._broadcast_ws_event("hid_keymaps_state", await self.__hid_api.get_keymaps()) # FIXME
await self._broadcast_ws_event(self.__EV_HID_KEYMAPS_STATE, await self.__hid_api.get_keymaps()) # FIXME
return (await self._ws_loop(ws))
@exposed_ws("ping")
@@ -293,10 +298,10 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
logger.exception("Cleanup error on %s", sub.name)
logger.info("On-Cleanup complete")
async def _on_ws_opened(self) -> None:
async def _on_ws_opened(self, _: WsSession) -> None:
self.__streamer_notifier.notify()
async def _on_ws_closed(self) -> None:
async def _on_ws_closed(self, _: WsSession) -> None:
self.__hid.clear_events()
self.__streamer_notifier.notify()
@@ -337,60 +342,5 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
)
async def __poll_state(self, event_type: str, poller: AsyncGenerator[dict, None]) -> None:
match event_type:
case self.__EV_GPIO_STATE:
await self.__poll_gpio_state(poller)
case self.__EV_INFO_STATE:
await self.__poll_info_state(poller)
case self.__EV_MSD_STATE:
await self.__poll_msd_state(poller)
case self.__EV_STREAMER_STATE:
await self.__poll_streamer_state(poller)
case self.__EV_OCR_STATE:
await self.__poll_ocr_state(poller)
case _:
async for state in poller:
await self._broadcast_ws_event(event_type, state)
async def __poll_gpio_state(self, poller: AsyncGenerator[dict, None]) -> None:
prev: dict = {"state": {"inputs": {}, "outputs": {}}}
async for state in poller:
await self._broadcast_ws_event(self.__EV_GPIO_STATE, state, legacy=False)
if "model" in state: # We have only "model"+"state" or "model" event
prev = state
await self._broadcast_ws_event("gpio_model_state", prev["model"], legacy=True)
else:
prev["state"]["inputs"].update(state["state"].get("inputs", {}))
prev["state"]["outputs"].update(state["state"].get("outputs", {}))
await self._broadcast_ws_event(self.__EV_GPIO_STATE, prev["state"], legacy=True)
async def __poll_info_state(self, poller: AsyncGenerator[dict, None]) -> None:
async for state in poller:
await self._broadcast_ws_event(self.__EV_INFO_STATE, state, legacy=False)
for (key, value) in state.items():
await self._broadcast_ws_event(f"info_{key}_state", value, legacy=True)
async def __poll_msd_state(self, poller: AsyncGenerator[dict, None]) -> None:
prev: dict = {"storage": None}
async for state in poller:
await self._broadcast_ws_event(self.__EV_MSD_STATE, state, legacy=False)
prev_storage = prev["storage"]
prev.update(state)
if prev["storage"] is not None and prev_storage is not None:
prev_storage.update(prev["storage"])
prev["storage"] = prev_storage
if "online" in prev: # Complete/Full
await self._broadcast_ws_event(self.__EV_MSD_STATE, prev, legacy=True)
async def __poll_streamer_state(self, poller: AsyncGenerator[dict, None]) -> None:
prev: dict = {}
async for state in poller:
await self._broadcast_ws_event(self.__EV_STREAMER_STATE, state, legacy=False)
prev.update(state)
if "features" in prev: # Complete/Full
await self._broadcast_ws_event(self.__EV_STREAMER_STATE, prev, legacy=True)
async def __poll_ocr_state(self, poller: AsyncGenerator[dict, None]) -> None:
async for state in poller:
await self._broadcast_ws_event(self.__EV_OCR_STATE, state, legacy=False)
await self._broadcast_ws_event("streamer_ocr_state", {"ocr": state}, legacy=True)
await self._broadcast_ws_event(event_type, state)

View File

@@ -123,10 +123,10 @@ class Snapshoter: # pylint: disable=too-many-instance-attributes
if self.__wakeup_key:
logger.info("Waking up using key %r ...", self.__wakeup_key)
self.__hid.send_key_events([
(self.__wakeup_key, True),
(self.__wakeup_key, False),
])
await self.__hid.send_key_events(
keys=[(self.__wakeup_key, True), (self.__wakeup_key, False)],
no_ignore_keys=True,
)
if self.__wakeup_move:
logger.info("Waking up using mouse move for %d units ...", self.__wakeup_move)

View File

@@ -0,0 +1,400 @@
# ========================================================================== #
# #
# 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 os
import asyncio
from typing import AsyncGenerator
from .lib import OperationError
from .lib import get_logger
from .lib import aiotools
from .lib import Inotify
from .types import Edid
from .types import Edids
from .types import Color
from .types import Colors
from .types import PortNames
from .types import AtxClickPowerDelays
from .types import AtxClickPowerLongDelays
from .types import AtxClickResetDelays
from .chain import DeviceFoundEvent
from .chain import ChainTruncatedEvent
from .chain import PortActivatedEvent
from .chain import UnitStateEvent
from .chain import UnitAtxLedsEvent
from .chain import Chain
from .state import StateCache
from .storage import Storage
# =====
class SwitchError(Exception):
pass
class SwitchOperationError(OperationError, SwitchError):
pass
class SwitchUnknownEdidError(SwitchOperationError):
def __init__(self) -> None:
super().__init__("No specified EDID ID found")
# =====
class Switch: # pylint: disable=too-many-public-methods
__X_EDIDS = "edids"
__X_COLORS = "colors"
__X_PORT_NAMES = "port_names"
__X_ATX_CP_DELAYS = "atx_cp_delays"
__X_ATX_CPL_DELAYS = "atx_cpl_delays"
__X_ATX_CR_DELAYS = "atx_cr_delays"
__X_ALL = frozenset([
__X_EDIDS, __X_COLORS, __X_PORT_NAMES,
__X_ATX_CP_DELAYS, __X_ATX_CPL_DELAYS, __X_ATX_CR_DELAYS,
])
def __init__(
self,
device_path: str,
default_edid_path: str,
pst_unix_path: str,
) -> None:
self.__default_edid_path = default_edid_path
self.__chain = Chain(device_path)
self.__cache = StateCache()
self.__storage = Storage(pst_unix_path)
self.__lock = asyncio.Lock()
self.__save_notifier = aiotools.AioNotifier()
# =====
def __x_set_edids(self, edids: Edids, save: bool=True) -> None:
self.__chain.set_edids(edids)
self.__cache.set_edids(edids)
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)
if save:
self.__save_notifier.notify()
def __x_set_port_names(self, port_names: PortNames, save: bool=True) -> None:
self.__cache.set_port_names(port_names)
if save:
self.__save_notifier.notify()
def __x_set_atx_cp_delays(self, delays: AtxClickPowerDelays, save: bool=True) -> None:
self.__cache.set_atx_cp_delays(delays)
if save:
self.__save_notifier.notify()
def __x_set_atx_cpl_delays(self, delays: AtxClickPowerLongDelays, save: bool=True) -> None:
self.__cache.set_atx_cpl_delays(delays)
if save:
self.__save_notifier.notify()
def __x_set_atx_cr_delays(self, delays: AtxClickResetDelays, save: bool=True) -> None:
self.__cache.set_atx_cr_delays(delays)
if save:
self.__save_notifier.notify()
# =====
async def set_active_port(self, port: int) -> None:
self.__chain.set_active_port(port)
# =====
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)
async def set_downlink_beacon(self, unit: int, on: bool) -> None:
self.__chain.set_downlink_beacon(unit, on)
# =====
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: int) -> None:
self.__inner_atx_cp(port, True, self.__X_ATX_CP_DELAYS)
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: int) -> None:
self.__inner_atx_cr(port, True)
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: int) -> None:
self.__inner_atx_cp(port, None, self.__X_ATX_CPL_DELAYS)
async def atx_click_reset(self, port: int) -> None:
self.__inner_atx_cr(port, 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]
delay = getattr(self.__cache, f"get_{x_delay}")()[port]
self.__chain.click_power(port, delay, if_powered)
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)
# =====
async def create_edid(self, name: str, data_hex: str) -> str:
async with self.__lock:
edids = self.__cache.get_edids()
edid_id = edids.add(Edid.from_data(name, data_hex))
self.__x_set_edids(edids)
return edid_id
async def change_edid(
self,
edid_id: str,
name: (str | None)=None,
data_hex: (str | None)=None,
) -> None:
assert edid_id != Edids.DEFAULT_ID
async with self.__lock:
edids = self.__cache.get_edids()
if not edids.has_id(edid_id):
raise SwitchUnknownEdidError()
old = edids.get(edid_id)
name = (name or old.name)
data_hex = (data_hex or old.as_text())
edids.set(edid_id, Edid.from_data(name, data_hex))
self.__x_set_edids(edids)
async def remove_edid(self, edid_id: str) -> None:
assert edid_id != Edids.DEFAULT_ID
async with self.__lock:
edids = self.__cache.get_edids()
if not edids.has_id(edid_id):
raise SwitchUnknownEdidError()
edids.remove(edid_id)
self.__x_set_edids(edids)
# =====
async def set_colors(self, **values: str) -> None:
async with self.__lock:
old = self.__cache.get_colors()
new = {}
for role in Colors.ROLES:
if role in values:
if values[role] != "default":
new[role] = Color.from_text(values[role])
# else reset to default
else:
new[role] = getattr(old, role)
self.__x_set_colors(Colors(**new)) # type: ignore
# =====
async def set_port_params(
self,
port: int,
edid_id: (str | 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:
async with self.__lock:
if edid_id is not None:
edids = self.__cache.get_edids()
if not edids.has_id(edid_id):
raise SwitchUnknownEdidError()
edids.assign(port, edid_id)
self.__x_set_edids(edids)
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] = (value or None) # None == reset to default
getattr(self, f"_Switch__x_set_{key}")(new)
# =====
async def reboot_unit(self, unit: int, bootloader: bool) -> None:
self.__chain.reboot_unit(unit, bootloader)
# =====
async def get_state(self) -> dict:
return self.__cache.get_state()
async def trigger_state(self) -> None:
await self.__cache.trigger_state()
async def poll_state(self) -> AsyncGenerator[dict, None]:
async for state in self.__cache.poll_state():
yield state
# =====
async def systask(self) -> None:
tasks = [
asyncio.create_task(self.__systask_events()),
asyncio.create_task(self.__systask_default_edid()),
asyncio.create_task(self.__systask_storage()),
]
try:
await asyncio.gather(*tasks)
except Exception:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
raise
async def __systask_events(self) -> None:
async for event in self.__chain.poll_events():
match event:
case DeviceFoundEvent():
await self.__load_configs()
case ChainTruncatedEvent():
self.__cache.truncate(event.units)
case PortActivatedEvent():
self.__cache.update_active_port(event.port)
case UnitStateEvent():
self.__cache.update_unit_state(event.unit, event.state)
case UnitAtxLedsEvent():
self.__cache.update_unit_atx_leds(event.unit, event.atx_leds)
async def __load_configs(self) -> None:
async with self.__lock:
try:
async with self.__storage.readable() as ctx:
values = {
key: await getattr(ctx, f"read_{key}")()
for key in self.__X_ALL
}
data_hex = await aiotools.read_file(self.__default_edid_path)
values["edids"].set_default(data_hex)
except Exception:
get_logger(0).exception("Can't load configs")
else:
for (key, value) in values.items():
func = getattr(self, f"_Switch__x_set_{key}")
if isinstance(value, tuple):
func(*value, save=False)
else:
func(value, save=False)
self.__chain.set_actual(True)
async def __systask_default_edid(self) -> None:
logger = get_logger(0)
async for _ in self.__poll_default_edid():
async with self.__lock:
edids = self.__cache.get_edids()
try:
data_hex = await aiotools.read_file(self.__default_edid_path)
edids.set_default(data_hex)
except Exception:
logger.exception("Can't read default EDID, ignoring ...")
else:
self.__x_set_edids(edids, save=False)
async def __poll_default_edid(self) -> AsyncGenerator[None, None]:
logger = get_logger(0)
while True:
while not os.path.exists(self.__default_edid_path):
await asyncio.sleep(5)
try:
with Inotify() as inotify:
await inotify.watch_all_changes(self.__default_edid_path)
if os.path.islink(self.__default_edid_path):
await inotify.watch_all_changes(os.path.realpath(self.__default_edid_path))
yield None
while True:
need_restart = False
need_notify = False
for event in (await inotify.get_series(timeout=1)):
need_notify = True
if event.restart:
logger.warning("Got fatal inotify event: %s; reinitializing ...", event)
need_restart = True
break
if need_restart:
break
if need_notify:
yield None
except Exception:
logger.exception("Unexpected watcher error")
await asyncio.sleep(1)
async def __systask_storage(self) -> None:
# При остановке KVMD можем не успеть записать, ну да пофиг
prevs = dict.fromkeys(self.__X_ALL)
while True:
await self.__save_notifier.wait()
while (await self.__save_notifier.wait(5)):
pass
while True:
try:
async with self.__lock:
write = {
key: new
for (key, old) in prevs.items()
if (new := getattr(self.__cache, f"get_{key}")()) != old
}
if write:
async with self.__storage.writable() as ctx:
for (key, new) in write.items():
func = getattr(ctx, f"write_{key}")
if isinstance(new, tuple):
await func(*new)
else:
await func(new)
prevs[key] = new
except Exception:
get_logger(0).exception("Unexpected storage error")
await asyncio.sleep(5)
else:
break

View File

@@ -0,0 +1,440 @@
# ========================================================================== #
# #
# 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 multiprocessing
import queue
import select
import dataclasses
import time
from typing import AsyncGenerator
from .lib import get_logger
from .lib import tools
from .lib import aiotools
from .lib import aioproc
from .types import Edids
from .types import Colors
from .proto import Response
from .proto import UnitState
from .proto import UnitAtxLeds
from .device import Device
from .device import DeviceError
# =====
class _BaseCmd:
pass
@dataclasses.dataclass(frozen=True)
class _CmdSetActual(_BaseCmd):
actual: bool
@dataclasses.dataclass(frozen=True)
class _CmdSetActivePort(_BaseCmd):
port: int
def __post_init__(self) -> None:
assert self.port >= 0
@dataclasses.dataclass(frozen=True)
class _CmdSetPortBeacon(_BaseCmd):
port: int
on: bool
@dataclasses.dataclass(frozen=True)
class _CmdSetUnitBeacon(_BaseCmd):
unit: int
on: bool
downlink: bool
@dataclasses.dataclass(frozen=True)
class _CmdSetEdids(_BaseCmd):
edids: Edids
@dataclasses.dataclass(frozen=True)
class _CmdSetColors(_BaseCmd):
colors: Colors
@dataclasses.dataclass(frozen=True)
class _CmdAtxClick(_BaseCmd):
port: int
delay: float
reset: bool
if_powered: (bool | None)
def __post_init__(self) -> None:
assert self.port >= 0
assert 0.001 <= self.delay <= (0xFFFF / 1000)
@dataclasses.dataclass(frozen=True)
class _CmdRebootUnit(_BaseCmd):
unit: int
bootloader: bool
def __post_init__(self) -> None:
assert self.unit >= 0
class _UnitContext:
__TIMEOUT = 5.0
def __init__(self) -> None:
self.state: (UnitState | None) = None
self.atx_leds: (UnitAtxLeds | None) = None
self.__rid = -1
self.__deadline_ts = -1.0
def can_be_changed(self) -> bool:
return (
self.state is not None
and not self.state.flags.changing_busy
and self.changing_rid < 0
)
# =====
@property
def changing_rid(self) -> int:
if self.__deadline_ts >= 0 and self.__deadline_ts < time.monotonic():
self.__rid = -1
self.__deadline_ts = -1
return self.__rid
@changing_rid.setter
def changing_rid(self, rid: int) -> None:
self.__rid = rid
self.__deadline_ts = ((time.monotonic() + self.__TIMEOUT) if rid >= 0 else -1)
# =====
def is_atx_allowed(self, ch: int) -> tuple[bool, bool]: # (allowed, power_led)
if self.state is None or self.atx_leds is None:
return (False, False)
return ((not self.state.atx_busy[ch]), self.atx_leds.power[ch])
# =====
class BaseEvent:
pass
class DeviceFoundEvent(BaseEvent):
pass
@dataclasses.dataclass(frozen=True)
class ChainTruncatedEvent(BaseEvent):
units: int
@dataclasses.dataclass(frozen=True)
class PortActivatedEvent(BaseEvent):
port: int
@dataclasses.dataclass(frozen=True)
class UnitStateEvent(BaseEvent):
unit: int
state: UnitState
@dataclasses.dataclass(frozen=True)
class UnitAtxLedsEvent(BaseEvent):
unit: int
atx_leds: UnitAtxLeds
# =====
class Chain: # pylint: disable=too-many-instance-attributes
def __init__(self, device_path: str) -> None:
self.__device = Device(device_path)
self.__actual = False
self.__edids = Edids()
self.__colors = Colors()
self.__units: list[_UnitContext] = []
self.__active_port = -1
self.__cmd_queue: "multiprocessing.Queue[_BaseCmd]" = multiprocessing.Queue()
self.__events_queue: "multiprocessing.Queue[BaseEvent]" = multiprocessing.Queue()
self.__stop_event = multiprocessing.Event()
def set_actual(self, actual: bool) -> None:
# Флаг разрешения синхронизации EDID и прочих чувствительных вещей
self.__queue_cmd(_CmdSetActual(actual))
# =====
def set_active_port(self, port: int) -> None:
self.__queue_cmd(_CmdSetActivePort(port))
# =====
def set_port_beacon(self, port: int, on: bool) -> None:
self.__queue_cmd(_CmdSetPortBeacon(port, on))
def set_uplink_beacon(self, unit: int, on: bool) -> None:
self.__queue_cmd(_CmdSetUnitBeacon(unit, on, downlink=False))
def set_downlink_beacon(self, unit: int, on: bool) -> None:
self.__queue_cmd(_CmdSetUnitBeacon(unit, on, downlink=True))
# =====
def set_edids(self, edids: Edids) -> None:
self.__queue_cmd(_CmdSetEdids(edids)) # Will be copied because of multiprocessing.Queue()
def set_colors(self, colors: Colors) -> None:
self.__queue_cmd(_CmdSetColors(colors))
# =====
def click_power(self, port: int, delay: float, if_powered: (bool | None)) -> None:
self.__queue_cmd(_CmdAtxClick(port, delay, reset=False, if_powered=if_powered))
def click_reset(self, port: int, delay: float, if_powered: (bool | None)) -> None:
self.__queue_cmd(_CmdAtxClick(port, delay, reset=True, if_powered=if_powered))
# =====
def reboot_unit(self, unit: int, bootloader: bool) -> None:
self.__queue_cmd(_CmdRebootUnit(unit, bootloader))
# =====
async def poll_events(self) -> AsyncGenerator[BaseEvent, None]:
proc = multiprocessing.Process(target=self.__subprocess, daemon=True)
try:
proc.start()
while True:
try:
yield (await aiotools.run_async(self.__events_queue.get, True, 0.1))
except queue.Empty:
pass
finally:
if proc.is_alive():
self.__stop_event.set()
if proc.is_alive() or proc.exitcode is not None:
await aiotools.run_async(proc.join)
# =====
def __queue_cmd(self, cmd: _BaseCmd) -> None:
if not self.__stop_event.is_set():
self.__cmd_queue.put_nowait(cmd)
def __queue_event(self, event: BaseEvent) -> None:
if not self.__stop_event.is_set():
self.__events_queue.put_nowait(event)
def __subprocess(self) -> None:
logger = aioproc.settle("Switch", "switch")
no_device_reported = False
while True:
try:
if self.__device.has_device():
no_device_reported = False
with self.__device:
logger.info("Switch found")
self.__queue_event(DeviceFoundEvent())
self.__main_loop()
elif not no_device_reported:
self.__queue_event(ChainTruncatedEvent(0))
logger.info("Switch is missing")
no_device_reported = True
except DeviceError as ex:
logger.error("%s", tools.efmt(ex))
except Exception:
logger.exception("Unexpected error in the Switch loop")
tools.clear_queue(self.__cmd_queue)
if self.__stop_event.is_set():
break
time.sleep(1)
def __main_loop(self) -> None:
self.__device.request_state()
self.__device.request_atx_leds()
while not self.__stop_event.is_set():
if self.__select():
for resp in self.__device.read_all():
self.__update_units(resp)
self.__adjust_start_port()
self.__finish_changing_request(resp)
self.__consume_commands()
self.__ensure_config()
def __select(self) -> bool:
try:
return bool(select.select([
self.__device.get_fd(),
self.__cmd_queue._reader, # type: ignore # pylint: disable=protected-access
], [], [], 1)[0])
except Exception as ex:
raise DeviceError(ex)
def __consume_commands(self) -> None:
while not self.__cmd_queue.empty():
cmd = self.__cmd_queue.get()
match cmd:
case _CmdSetActual():
self.__actual = cmd.actual
case _CmdSetActivePort():
# Может быть вызвано изнутри при синхронизации
self.__active_port = cmd.port
self.__queue_event(PortActivatedEvent(self.__active_port))
case _CmdSetPortBeacon():
(unit, ch) = self.get_real_unit_channel(cmd.port)
self.__device.request_beacon(unit, ch, cmd.on)
case _CmdSetUnitBeacon():
ch = (4 if cmd.downlink else 5)
self.__device.request_beacon(cmd.unit, ch, cmd.on)
case _CmdAtxClick():
(unit, ch) = self.get_real_unit_channel(cmd.port)
if unit < len(self.__units):
(allowed, powered) = self.__units[unit].is_atx_allowed(ch)
if allowed and (cmd.if_powered is None or cmd.if_powered == powered):
delay_ms = min(int(cmd.delay * 1000), 0xFFFF)
if cmd.reset:
self.__device.request_atx_cr(unit, ch, delay_ms)
else:
self.__device.request_atx_cp(unit, ch, delay_ms)
case _CmdSetEdids():
self.__edids = cmd.edids
case _CmdSetColors():
self.__colors = cmd.colors
case _CmdRebootUnit():
self.__device.request_reboot(cmd.unit, cmd.bootloader)
def __update_units(self, resp: Response) -> None:
units = resp.header.unit + 1
while len(self.__units) < units:
self.__units.append(_UnitContext())
match resp.body:
case UnitState():
if not resp.body.flags.has_downlink and len(self.__units) > units:
del self.__units[units:]
self.__queue_event(ChainTruncatedEvent(units))
self.__units[resp.header.unit].state = resp.body
self.__queue_event(UnitStateEvent(resp.header.unit, resp.body))
case UnitAtxLeds():
self.__units[resp.header.unit].atx_leds = resp.body
self.__queue_event(UnitAtxLedsEvent(resp.header.unit, resp.body))
def __adjust_start_port(self) -> None:
if self.__active_port < 0:
for (unit, ctx) in enumerate(self.__units):
if ctx.state is not None and ctx.state.ch < 4:
# Trigger queue select()
port = self.get_virtual_port(unit, ctx.state.ch)
get_logger().info("Found an active port %d on [%d:%d]: Syncing ...",
port, unit, ctx.state.ch)
self.set_active_port(port)
break
def __finish_changing_request(self, resp: Response) -> None:
if self.__units[resp.header.unit].changing_rid == resp.header.rid:
self.__units[resp.header.unit].changing_rid = -1
# =====
def __ensure_config(self) -> None:
for (unit, ctx) in enumerate(self.__units):
if ctx.state is not None:
self.__ensure_config_port(unit, ctx)
if self.__actual:
self.__ensure_config_edids(unit, ctx)
self.__ensure_config_colors(unit, ctx)
def __ensure_config_port(self, unit: int, ctx: _UnitContext) -> None:
assert ctx.state is not None
if self.__active_port >= 0 and ctx.can_be_changed():
ch = self.get_unit_target_channel(unit, self.__active_port)
if ctx.state.ch != ch:
get_logger().info("Switching for active port %d: [%d:%d] -> [%d:%d] ...",
self.__active_port, unit, ctx.state.ch, unit, ch)
ctx.changing_rid = self.__device.request_switch(unit, ch)
def __ensure_config_edids(self, unit: int, ctx: _UnitContext) -> None:
assert self.__actual
assert ctx.state is not None
if ctx.can_be_changed():
for ch in range(4):
port = self.get_virtual_port(unit, ch)
edid = self.__edids.get_edid_for_port(port)
if not ctx.state.compare_edid(ch, edid):
get_logger().info("Changing EDID on port %d on [%d:%d]: %d/%d -> %d/%d (%s) ...",
port, unit, ch,
ctx.state.video_crc[ch], ctx.state.video_edid[ch],
edid.crc, edid.valid, edid.name)
ctx.changing_rid = self.__device.request_set_edid(unit, ch, edid)
break # Busy globally
def __ensure_config_colors(self, unit: int, ctx: _UnitContext) -> None:
assert self.__actual
assert ctx.state is not None
for np in range(6):
if self.__colors.crc != ctx.state.np_crc[np]:
# get_logger().info("Changing colors on NP [%d:%d]: %d -> %d ...",
# unit, np, ctx.state.np_crc[np], self.__colors.crc)
self.__device.request_set_colors(unit, np, self.__colors)
# =====
@classmethod
def get_real_unit_channel(cls, port: int) -> tuple[int, int]:
return (port // 4, port % 4)
@classmethod
def get_unit_target_channel(cls, unit: int, port: int) -> int:
(t_unit, t_ch) = cls.get_real_unit_channel(port)
if unit != t_unit:
t_ch = 4
return t_ch
@classmethod
def get_virtual_port(cls, unit: int, ch: int) -> int:
return (unit * 4) + ch

View File

@@ -0,0 +1,196 @@
# ========================================================================== #
# #
# 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 os
import random
import types
import serial
from .lib import tools
from .types import Edid
from .types import Colors
from .proto import Packable
from .proto import Request
from .proto import Response
from .proto import Header
from .proto import BodySwitch
from .proto import BodySetBeacon
from .proto import BodyAtxClick
from .proto import BodySetEdid
from .proto import BodyClearEdid
from .proto import BodySetColors
# =====
class DeviceError(Exception):
def __init__(self, ex: Exception):
super().__init__(tools.efmt(ex))
class Device:
__SPEED = 115200
__TIMEOUT = 5.0
def __init__(self, device_path: str) -> None:
self.__device_path = device_path
self.__rid = random.randint(1, 0xFFFF)
self.__tty: (serial.Serial | None) = None
self.__buf: bytes = b""
def __enter__(self) -> "Device":
try:
self.__tty = serial.Serial(
self.__device_path,
baudrate=self.__SPEED,
timeout=self.__TIMEOUT,
)
except Exception as ex:
raise DeviceError(ex)
return self
def __exit__(
self,
_exc_type: type[BaseException],
_exc: BaseException,
_tb: types.TracebackType,
) -> None:
if self.__tty is not None:
try:
self.__tty.close()
except Exception:
pass
self.__tty = None
def has_device(self) -> bool:
return os.path.exists(self.__device_path)
def get_fd(self) -> int:
assert self.__tty is not None
return self.__tty.fd
def read_all(self) -> list[Response]:
assert self.__tty is not None
try:
if not self.__tty.in_waiting:
return []
self.__buf += self.__tty.read_all()
except Exception as ex:
raise DeviceError(ex)
results: list[Response] = []
while True:
try:
begin = self.__buf.index(0xF1)
except ValueError:
break
try:
end = self.__buf.index(0xF2, begin)
except ValueError:
break
msg = self.__buf[begin + 1:end]
if 0xF1 in msg:
# raise RuntimeError(f"Found 0xF1 inside the message: {msg!r}")
break
self.__buf = self.__buf[end + 1:]
msg = self.__unescape(msg)
resp = Response.unpack(msg)
if resp is not None:
results.append(resp)
return results
def __unescape(self, msg: bytes) -> bytes:
if 0xF0 not in msg:
return msg
unesc: list[int] = []
esc = False
for ch in msg:
if ch == 0xF0:
esc = True
else:
if esc:
ch ^= 0xFF
esc = False
unesc.append(ch)
return bytes(unesc)
def request_reboot(self, unit: int, bootloader: bool) -> int:
return self.__send_request((Header.BOOTLOADER if bootloader else Header.REBOOT), unit, None)
def request_state(self) -> int:
return self.__send_request(Header.STATE, 0xFF, None)
def request_switch(self, unit: int, ch: int) -> int:
return self.__send_request(Header.SWITCH, unit, BodySwitch(ch))
def request_beacon(self, unit: int, ch: int, on: bool) -> int:
return self.__send_request(Header.BEACON, unit, BodySetBeacon(ch, on))
def request_atx_leds(self) -> int:
return self.__send_request(Header.ATX_LEDS, 0xFF, None)
def request_atx_cp(self, unit: int, ch: int, delay_ms: int) -> int:
return self.__send_request(Header.ATX_CLICK, unit, BodyAtxClick(ch, BodyAtxClick.POWER, delay_ms))
def request_atx_cr(self, unit: int, ch: int, delay_ms: int) -> int:
return self.__send_request(Header.ATX_CLICK, unit, BodyAtxClick(ch, BodyAtxClick.RESET, delay_ms))
def request_set_edid(self, unit: int, ch: int, edid: Edid) -> int:
if edid.valid:
return self.__send_request(Header.SET_EDID, unit, BodySetEdid(ch, edid))
return self.__send_request(Header.CLEAR_EDID, unit, BodyClearEdid(ch))
def request_set_colors(self, unit: int, ch: int, colors: Colors) -> int:
return self.__send_request(Header.SET_COLORS, unit, BodySetColors(ch, colors))
def __send_request(self, op: int, unit: int, body: (Packable | None)) -> int:
assert self.__tty is not None
req = Request(Header(
proto=1,
rid=self.__get_next_rid(),
op=op,
unit=unit,
), body)
data: list[int] = [0xF1]
for ch in req.pack():
if 0xF0 <= ch <= 0xF2:
data.append(0xF0)
ch ^= 0xFF
data.append(ch)
data.append(0xF2)
try:
self.__tty.write(bytes(data))
self.__tty.flush()
except Exception as ex:
raise DeviceError(ex)
return req.header.rid
def __get_next_rid(self) -> int:
rid = self.__rid
self.__rid += 1
if self.__rid > 0xFFFF:
self.__rid = 1
return rid

View File

@@ -0,0 +1,35 @@
# ========================================================================== #
# #
# 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/>. #
# #
# ========================================================================== #
# pylint: disable=unused-import
from ....logging import get_logger # noqa: F401
from .... import tools # noqa: F401
from .... import aiotools # noqa: F401
from .... import aioproc # noqa: F401
from .... import bitbang # noqa: F401
from .... import htclient # noqa: F401
from ....inotify import Inotify # noqa: F401
from ....errors import OperationError # noqa: F401
from ....edid import EdidNoBlockError as ParsedEdidNoBlockError # noqa: F401
from ....edid import Edid as ParsedEdid # noqa: F401

View File

@@ -0,0 +1,295 @@
# ========================================================================== #
# #
# 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 struct
import dataclasses
from typing import Optional
from .types import Edid
from .types import Colors
# =====
class Packable:
def pack(self) -> bytes:
raise NotImplementedError()
class Unpackable:
@classmethod
def unpack(cls, data: bytes, offset: int=0) -> "Unpackable":
raise NotImplementedError()
# =====
@dataclasses.dataclass(frozen=True)
class Header(Packable, Unpackable):
proto: int
rid: int
op: int
unit: int
NAK = 0
BOOTLOADER = 2
REBOOT = 3
STATE = 4
SWITCH = 5
BEACON = 6
ATX_LEDS = 7
ATX_CLICK = 8
SET_EDID = 9
CLEAR_EDID = 10
SET_COLORS = 12
__struct = struct.Struct("<BHBB")
SIZE = __struct.size
def pack(self) -> bytes:
return self.__struct.pack(self.proto, self.rid, self.op, self.unit)
@classmethod
def unpack(cls, data: bytes, offset: int=0) -> "Header":
return Header(*cls.__struct.unpack_from(data, offset=offset))
@dataclasses.dataclass(frozen=True)
class Nak(Unpackable):
reason: int
INVALID_COMMAND = 0
BUSY = 1
NO_DOWNLINK = 2
DOWNLINK_OVERFLOW = 3
__struct = struct.Struct("<B")
@classmethod
def unpack(cls, data: bytes, offset: int=0) -> "Nak":
return Nak(*cls.__struct.unpack_from(data, offset=offset))
@dataclasses.dataclass(frozen=True)
class UnitFlags:
changing_busy: bool
flashing_busy: bool
has_downlink: bool
@dataclasses.dataclass(frozen=True)
class UnitState(Unpackable): # pylint: disable=too-many-instance-attributes
sw_version: int
hw_version: int
flags: UnitFlags
ch: int
beacons: tuple[bool, bool, bool, bool, bool, bool]
np_crc: tuple[int, int, int, int, int, int]
video_5v_sens: tuple[bool, bool, bool, bool, bool]
video_hpd: tuple[bool, bool, bool, bool, bool]
video_edid: tuple[bool, bool, bool, bool]
video_crc: tuple[int, int, int, int]
usb_5v_sens: tuple[bool, bool, bool, bool]
atx_busy: tuple[bool, bool, bool, bool]
__struct = struct.Struct("<HHHBBHHHHHHBBBHHHHBxB30x")
def compare_edid(self, ch: int, edid: Optional["Edid"]) -> bool:
if edid is None:
# Сойдет любой невалидный EDID
return (not self.video_edid[ch])
return (
self.video_edid[ch] == edid.valid
and self.video_crc[ch] == edid.crc
)
@classmethod
def unpack(cls, data: bytes, offset: int=0) -> "UnitState": # pylint: disable=too-many-locals
(
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,
) = cls.__struct.unpack_from(data, offset=offset)
return UnitState(
sw_version,
hw_version,
flags=UnitFlags(
changing_busy=bool(flags & 0x80),
flashing_busy=bool(flags & 0x40),
has_downlink=bool(flags & 0x02),
),
ch=ch,
beacons=cls.__make_flags6(beacons),
np_crc=(nc0, nc1, nc2, nc3, nc4, nc5),
video_5v_sens=cls.__make_flags5(video_5v_sens),
video_hpd=cls.__make_flags5(video_hpd),
video_edid=cls.__make_flags4(video_edid),
video_crc=(vc0, vc1, vc2, vc3),
usb_5v_sens=cls.__make_flags4(usb_5v_sens),
atx_busy=cls.__make_flags4(atx_busy),
)
@classmethod
def __make_flags6(cls, mask: int) -> tuple[bool, bool, bool, bool, bool, bool]:
return (
bool(mask & 0x01), bool(mask & 0x02), bool(mask & 0x04),
bool(mask & 0x08), bool(mask & 0x10), bool(mask & 0x20),
)
@classmethod
def __make_flags5(cls, mask: int) -> tuple[bool, bool, bool, bool, bool]:
return (
bool(mask & 0x01), bool(mask & 0x02), bool(mask & 0x04),
bool(mask & 0x08), bool(mask & 0x10),
)
@classmethod
def __make_flags4(cls, mask: int) -> tuple[bool, bool, bool, bool]:
return (bool(mask & 0x01), bool(mask & 0x02), bool(mask & 0x04), bool(mask & 0x08))
@dataclasses.dataclass(frozen=True)
class UnitAtxLeds(Unpackable):
power: tuple[bool, bool, bool, bool]
hdd: tuple[bool, bool, bool, bool]
__struct = struct.Struct("<B")
@classmethod
def unpack(cls, data: bytes, offset: int=0) -> "UnitAtxLeds":
(mask,) = cls.__struct.unpack_from(data, offset=offset)
return UnitAtxLeds(
power=(bool(mask & 0x01), bool(mask & 0x02), bool(mask & 0x04), bool(mask & 0x08)),
hdd=(bool(mask & 0x10), bool(mask & 0x20), bool(mask & 0x40), bool(mask & 0x80)),
)
# =====
@dataclasses.dataclass(frozen=True)
class BodySwitch(Packable):
ch: int
def __post_init__(self) -> None:
assert 0 <= self.ch <= 4
def pack(self) -> bytes:
return self.ch.to_bytes()
@dataclasses.dataclass(frozen=True)
class BodySetBeacon(Packable):
ch: int
on: bool
def __post_init__(self) -> None:
assert 0 <= self.ch <= 5
def pack(self) -> bytes:
return self.ch.to_bytes() + self.on.to_bytes()
@dataclasses.dataclass(frozen=True)
class BodyAtxClick(Packable):
ch: int
action: int
delay_ms: int
POWER = 0
RESET = 1
__struct = struct.Struct("<BBH")
def __post_init__(self) -> None:
assert 0 <= self.ch <= 3
assert self.action in [self.POWER, self.RESET]
assert 1 <= self.delay_ms <= 0xFFFF
def pack(self) -> bytes:
return self.__struct.pack(self.ch, self.action, self.delay_ms)
@dataclasses.dataclass(frozen=True)
class BodySetEdid(Packable):
ch: int
edid: Edid
def __post_init__(self) -> None:
assert 0 <= self.ch <= 3
def pack(self) -> bytes:
return self.ch.to_bytes() + self.edid.pack()
@dataclasses.dataclass(frozen=True)
class BodyClearEdid(Packable):
ch: int
def __post_init__(self) -> None:
assert 0 <= self.ch <= 3
def pack(self) -> bytes:
return self.ch.to_bytes()
@dataclasses.dataclass(frozen=True)
class BodySetColors(Packable):
ch: int
colors: Colors
def __post_init__(self) -> None:
assert 0 <= self.ch <= 5
def pack(self) -> bytes:
return self.ch.to_bytes() + self.colors.pack()
# =====
@dataclasses.dataclass(frozen=True)
class Request:
header: Header
body: (Packable | None) = dataclasses.field(default=None)
def pack(self) -> bytes:
msg = self.header.pack()
if self.body is not None:
msg += self.body.pack()
return msg
@dataclasses.dataclass(frozen=True)
class Response:
header: Header
body: Unpackable
@classmethod
def unpack(cls, msg: bytes) -> Optional["Response"]:
header = Header.unpack(msg)
match header.op:
case Header.NAK:
return Response(header, Nak.unpack(msg, Header.SIZE))
case Header.STATE:
return Response(header, UnitState.unpack(msg, Header.SIZE))
case Header.ATX_LEDS:
return Response(header, UnitAtxLeds.unpack(msg, Header.SIZE))
# raise RuntimeError(f"Unknown OP in the header: {header!r}")
return None

View File

@@ -0,0 +1,358 @@
# ========================================================================== #
# #
# 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 dataclasses
import time
from typing import AsyncGenerator
from .types import Edids
from .types import Color
from .types import Colors
from .types import PortNames
from .types import AtxClickPowerDelays
from .types import AtxClickPowerLongDelays
from .types import AtxClickResetDelays
from .proto import UnitState
from .proto import UnitAtxLeds
from .chain import Chain
# =====
@dataclasses.dataclass
class _UnitInfo:
state: (UnitState | None) = dataclasses.field(default=None)
atx_leds: (UnitAtxLeds | None) = dataclasses.field(default=None)
# =====
class StateCache: # pylint: disable=too-many-instance-attributes
__FW_VERSION = 5
__FULL = 0xFFFF
__SUMMARY = 0x01
__EDIDS = 0x02
__COLORS = 0x04
__VIDEO = 0x08
__USB = 0x10
__BEACONS = 0x20
__ATX = 0x40
def __init__(self) -> None:
self.__edids = Edids()
self.__colors = Colors()
self.__port_names = PortNames({})
self.__atx_cp_delays = AtxClickPowerDelays({})
self.__atx_cpl_delays = AtxClickPowerLongDelays({})
self.__atx_cr_delays = AtxClickResetDelays({})
self.__units: list[_UnitInfo] = []
self.__active_port = -1
self.__synced = True
self.__queue: "asyncio.Queue[int]" = asyncio.Queue()
def get_edids(self) -> Edids:
return self.__edids.copy()
def get_colors(self) -> Colors:
return self.__colors
def get_port_names(self) -> PortNames:
return self.__port_names.copy()
def get_atx_cp_delays(self) -> AtxClickPowerDelays:
return self.__atx_cp_delays.copy()
def get_atx_cpl_delays(self) -> AtxClickPowerLongDelays:
return self.__atx_cpl_delays.copy()
def get_atx_cr_delays(self) -> AtxClickResetDelays:
return self.__atx_cr_delays.copy()
# =====
def get_state(self) -> dict:
return self.__inner_get_state(self.__FULL)
async def trigger_state(self) -> None:
self.__bump_state(self.__FULL)
async def poll_state(self) -> AsyncGenerator[dict, None]:
atx_ts: float = 0
while True:
try:
mask = await asyncio.wait_for(self.__queue.get(), timeout=0.1)
except TimeoutError:
mask = 0
if mask == self.__ATX:
# Откладываем единичное новое событие ATX, чтобы аккумулировать с нескольких свичей
if atx_ts == 0:
atx_ts = time.monotonic() + 0.2
continue
elif atx_ts >= time.monotonic():
continue
# ... Ну или разрешаем отправить, если оно уже достаточно мариновалось
elif mask == 0 and atx_ts > time.monotonic():
# Разрешаем отправить отложенное
mask = self.__ATX
atx_ts = 0
elif mask & self.__ATX:
# Комплексное событие всегда должно обрабатываться сразу
atx_ts = 0
if mask != 0:
yield self.__inner_get_state(mask)
def __inner_get_state(self, mask: int) -> dict: # pylint: disable=too-many-branches,too-many-statements,too-many-locals
assert mask != 0
x_model = (mask == self.__FULL)
x_summary = (mask & self.__SUMMARY)
x_edids = (mask & self.__EDIDS)
x_colors = (mask & self.__COLORS)
x_video = (mask & self.__VIDEO)
x_usb = (mask & self.__USB)
x_beacons = (mask & self.__BEACONS)
x_atx = (mask & self.__ATX)
state: dict = {}
if x_model:
state["model"] = {
"firmware": {"version": self.__FW_VERSION},
"units": [],
"ports": [],
"limits": {
"atx": {
"click_delays": {
key: {"default": value, "min": 0, "max": 10}
for (key, value) in [
("power", self.__atx_cp_delays.default),
("power_long", self.__atx_cpl_delays.default),
("reset", self.__atx_cr_delays.default),
]
},
},
},
}
if x_summary:
state["summary"] = {"active_port": self.__active_port, "synced": self.__synced}
if x_edids:
state["edids"] = {
"all": {
edid_id: {
"name": edid.name,
"data": edid.as_text(),
"parsed": (dataclasses.asdict(edid.info) if edid.info is not None else None),
}
for (edid_id, edid) in self.__edids.all.items()
},
"used": [],
}
if x_colors:
state["colors"] = {
role: {
comp: getattr(getattr(self.__colors, role), comp)
for comp in Color.COMPONENTS
}
for role in Colors.ROLES
}
if x_video:
state["video"] = {"links": []}
if x_usb:
state["usb"] = {"links": []}
if x_beacons:
state["beacons"] = {"uplinks": [], "downlinks": [], "ports": []}
if x_atx:
state["atx"] = {"busy": [], "leds": {"power": [], "hdd": []}}
if not self.__is_units_ready():
return state
for (unit, ui) in enumerate(self.__units):
assert ui.state is not None
assert ui.atx_leds is not None
if x_model:
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:
state["usb"]["links"].extend(ui.state.usb_5v_sens)
if x_beacons:
state["beacons"]["uplinks"].append(ui.state.beacons[5])
state["beacons"]["downlinks"].append(ui.state.beacons[4])
state["beacons"]["ports"].extend(ui.state.beacons[:4])
if x_atx:
state["atx"]["busy"].extend(ui.state.atx_busy)
state["atx"]["leds"]["power"].extend(ui.atx_leds.power)
state["atx"]["leds"]["hdd"].extend(ui.atx_leds.hdd)
if x_model or x_edids:
for ch in range(4):
port = Chain.get_virtual_port(unit, ch)
if x_model:
state["model"]["ports"].append({
"unit": unit,
"channel": ch,
"name": self.__port_names[port],
"atx": {
"click_delays": {
"power": self.__atx_cp_delays[port],
"power_long": self.__atx_cpl_delays[port],
"reset": self.__atx_cr_delays[port],
},
},
})
if x_edids:
state["edids"]["used"].append(self.__edids.get_id_for_port(port))
return state
def __inner_check_synced(self) -> bool:
for (unit, ui) in enumerate(self.__units):
if ui.state is None or ui.state.flags.changing_busy:
return False
if (
self.__active_port >= 0
and ui.state.ch != Chain.get_unit_target_channel(unit, self.__active_port)
):
return False
for ch in range(4):
port = Chain.get_virtual_port(unit, ch)
edid = self.__edids.get_edid_for_port(port)
if not ui.state.compare_edid(ch, edid):
return False
for ch in range(6):
if ui.state.np_crc[ch] != self.__colors.crc:
return False
return True
def __recache_synced(self) -> bool:
synced = self.__inner_check_synced()
if self.__synced != synced:
self.__synced = synced
return True
return False
def truncate(self, units: int) -> None:
if len(self.__units) > units:
del self.__units[units:]
self.__bump_state(self.__FULL)
def update_active_port(self, port: int) -> None:
changed = (self.__active_port != port)
self.__active_port = port
changed = (self.__recache_synced() or changed)
if changed:
self.__bump_state(self.__SUMMARY)
def update_unit_state(self, unit: int, new: UnitState) -> None:
ui = self.__ensure_unit(unit)
(prev, ui.state) = (ui.state, new)
if not self.__is_units_ready():
return
mask = 0
if prev is None:
mask = self.__FULL
else:
if self.__recache_synced():
mask |= self.__SUMMARY
if prev.video_5v_sens != new.video_5v_sens:
mask |= self.__VIDEO
if prev.usb_5v_sens != new.usb_5v_sens:
mask |= self.__USB
if prev.beacons != new.beacons:
mask |= self.__BEACONS
if prev.atx_busy != new.atx_busy:
mask |= self.__ATX
if mask:
self.__bump_state(mask)
def update_unit_atx_leds(self, unit: int, new: UnitAtxLeds) -> None:
ui = self.__ensure_unit(unit)
(prev, ui.atx_leds) = (ui.atx_leds, new)
if not self.__is_units_ready():
return
if prev is None:
self.__bump_state(self.__FULL)
elif prev != new:
self.__bump_state(self.__ATX)
def __is_units_ready(self) -> bool:
for ui in self.__units:
if ui.state is None or ui.atx_leds is None:
return False
return True
def __ensure_unit(self, unit: int) -> _UnitInfo:
while len(self.__units) < unit + 1:
self.__units.append(_UnitInfo())
return self.__units[unit]
def __bump_state(self, mask: int) -> None:
assert mask != 0
self.__queue.put_nowait(mask)
# =====
def set_edids(self, edids: Edids) -> None:
changed = (
self.__edids.all != edids.all
or not self.__edids.compare_on_ports(edids, self.__get_ports())
)
self.__edids = edids.copy()
if changed:
self.__bump_state(self.__EDIDS)
def set_colors(self, colors: Colors) -> None:
changed = (self.__colors != colors)
self.__colors = colors
if changed:
self.__bump_state(self.__COLORS)
def set_port_names(self, port_names: PortNames) -> None:
changed = (not self.__port_names.compare_on_ports(port_names, self.__get_ports()))
self.__port_names = port_names.copy()
if changed:
self.__bump_state(self.__FULL)
def set_atx_cp_delays(self, delays: AtxClickPowerDelays) -> None:
changed = (not self.__atx_cp_delays.compare_on_ports(delays, self.__get_ports()))
self.__atx_cp_delays = delays.copy()
if changed:
self.__bump_state(self.__FULL)
def set_atx_cpl_delays(self, delays: AtxClickPowerLongDelays) -> None:
changed = (not self.__atx_cpl_delays.compare_on_ports(delays, self.__get_ports()))
self.__atx_cpl_delays = delays.copy()
if changed:
self.__bump_state(self.__FULL)
def set_atx_cr_delays(self, delays: AtxClickResetDelays) -> None:
changed = (not self.__atx_cr_delays.compare_on_ports(delays, self.__get_ports()))
self.__atx_cr_delays = delays.copy()
if changed:
self.__bump_state(self.__FULL)
def __get_ports(self) -> int:
return (len(self.__units) * 4)

View File

@@ -0,0 +1,186 @@
# ========================================================================== #
# #
# 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 os
import asyncio
import json
import contextlib
from typing import AsyncGenerator
try:
from ....clients.pst import PstClient
except ImportError:
PstClient = None # type: ignore
# from .lib import get_logger
from .lib import aiotools
from .lib import htclient
from .lib import get_logger
from .types import Edid
from .types import Edids
from .types import Color
from .types import Colors
from .types import PortNames
from .types import AtxClickPowerDelays
from .types import AtxClickPowerLongDelays
from .types import AtxClickResetDelays
# =====
class StorageContext:
__F_EDIDS_ALL = "edids_all.json"
__F_EDIDS_PORT = "edids_port.json"
__F_COLORS = "colors.json"
__F_PORT_NAMES = "port_names.json"
__F_ATX_CP_DELAYS = "atx_click_power_delays.json"
__F_ATX_CPL_DELAYS = "atx_click_power_long_delays.json"
__F_ATX_CR_DELAYS = "atx_click_reset_delays.json"
def __init__(self, path: str, rw: bool) -> None:
self.__path = path
self.__rw = rw
# =====
async def write_edids(self, edids: Edids) -> None:
await self.__write_json_keyvals(self.__F_EDIDS_ALL, {
edid_id.lower(): {"name": edid.name, "data": edid.as_text()}
for (edid_id, edid) in edids.all.items()
if edid_id != Edids.DEFAULT_ID
})
await self.__write_json_keyvals(self.__F_EDIDS_PORT, edids.port)
async def write_colors(self, colors: Colors) -> None:
await self.__write_json_keyvals(self.__F_COLORS, {
role: {
comp: getattr(getattr(colors, role), comp)
for comp in Color.COMPONENTS
}
for role in Colors.ROLES
})
async def write_port_names(self, port_names: PortNames) -> None:
await self.__write_json_keyvals(self.__F_PORT_NAMES, port_names.kvs)
async def write_atx_cp_delays(self, delays: AtxClickPowerDelays) -> None:
await self.__write_json_keyvals(self.__F_ATX_CP_DELAYS, delays.kvs)
async def write_atx_cpl_delays(self, delays: AtxClickPowerLongDelays) -> None:
await self.__write_json_keyvals(self.__F_ATX_CPL_DELAYS, delays.kvs)
async def write_atx_cr_delays(self, delays: AtxClickResetDelays) -> None:
await self.__write_json_keyvals(self.__F_ATX_CR_DELAYS, delays.kvs)
async def __write_json_keyvals(self, name: str, kvs: dict) -> None:
if len(self.__path) == 0:
return
assert self.__rw
kvs = {str(key): value for (key, value) in kvs.items()}
if (await self.__read_json_keyvals(name)) == kvs:
return # Don't write the same data
path = os.path.join(self.__path, name)
get_logger(0).info("Writing '%s' ...", name)
await aiotools.write_file(path, json.dumps(kvs))
# =====
async def read_edids(self) -> Edids:
all_edids = {
edid_id.lower(): Edid.from_data(edid["name"], edid["data"])
for (edid_id, edid) in (await self.__read_json_keyvals(self.__F_EDIDS_ALL)).items()
}
port_edids = await self.__read_json_keyvals_int(self.__F_EDIDS_PORT)
return Edids(all_edids, port_edids)
async def read_colors(self) -> Colors:
raw = await self.__read_json_keyvals(self.__F_COLORS)
return Colors(**{ # type: ignore
role: Color(**{comp: raw[role][comp] for comp in Color.COMPONENTS})
for role in Colors.ROLES
if role in raw
})
async def read_port_names(self) -> PortNames:
return PortNames(await self.__read_json_keyvals_int(self.__F_PORT_NAMES))
async def read_atx_cp_delays(self) -> AtxClickPowerDelays:
return AtxClickPowerDelays(await self.__read_json_keyvals_int(self.__F_ATX_CP_DELAYS))
async def read_atx_cpl_delays(self) -> AtxClickPowerLongDelays:
return AtxClickPowerLongDelays(await self.__read_json_keyvals_int(self.__F_ATX_CPL_DELAYS))
async def read_atx_cr_delays(self) -> AtxClickResetDelays:
return AtxClickResetDelays(await self.__read_json_keyvals_int(self.__F_ATX_CR_DELAYS))
async def __read_json_keyvals_int(self, name: str) -> dict:
return (await self.__read_json_keyvals(name, int_keys=True))
async def __read_json_keyvals(self, name: str, int_keys: bool=False) -> dict:
if len(self.__path) == 0:
return {}
path = os.path.join(self.__path, name)
try:
kvs: dict = json.loads(await aiotools.read_file(path))
except FileNotFoundError:
kvs = {}
if int_keys:
kvs = {int(key): value for (key, value) in kvs.items()}
return kvs
class Storage:
__SUBDIR = "__switch__"
__TIMEOUT = 5.0
def __init__(self, unix_path: str) -> None:
self.__pst: (PstClient | None) = None
if len(unix_path) > 0 and PstClient is not None:
self.__pst = PstClient(
subdir=self.__SUBDIR,
unix_path=unix_path,
timeout=self.__TIMEOUT,
user_agent=htclient.make_user_agent("KVMD"),
)
self.__lock = asyncio.Lock()
@contextlib.asynccontextmanager
async def readable(self) -> AsyncGenerator[StorageContext, None]:
async with self.__lock:
if self.__pst is None:
yield StorageContext("", False)
else:
path = await self.__pst.get_path()
yield StorageContext(path, False)
@contextlib.asynccontextmanager
async def writable(self) -> AsyncGenerator[StorageContext, None]:
async with self.__lock:
if self.__pst is None:
yield StorageContext("", True)
else:
async with self.__pst.writable() as path:
yield StorageContext(path, True)

View File

@@ -0,0 +1,308 @@
# ========================================================================== #
# #
# 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 re
import struct
import uuid
import dataclasses
from typing import TypeVar
from typing import Generic
from .lib import bitbang
from .lib import ParsedEdidNoBlockError
from .lib import ParsedEdid
# =====
@dataclasses.dataclass(frozen=True)
class EdidInfo:
mfc_id: str
product_id: int
serial: int
monitor_name: (str | None)
monitor_serial: (str | None)
audio: bool
@classmethod
def from_data(cls, data: bytes) -> "EdidInfo":
parsed = ParsedEdid(data)
monitor_name: (str | None) = None
try:
monitor_name = parsed.get_monitor_name()
except ParsedEdidNoBlockError:
pass
monitor_serial: (str | None) = None
try:
monitor_serial = parsed.get_monitor_serial()
except ParsedEdidNoBlockError:
pass
return EdidInfo(
mfc_id=parsed.get_mfc_id(),
product_id=parsed.get_product_id(),
serial=parsed.get_serial(),
monitor_name=monitor_name,
monitor_serial=monitor_serial,
audio=parsed.get_audio(),
)
@dataclasses.dataclass(frozen=True)
class Edid:
name: str
data: bytes
crc: int = dataclasses.field(default=0)
valid: bool = dataclasses.field(default=False)
info: (EdidInfo | None) = dataclasses.field(default=None)
__HEADER = b"\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00"
def __post_init__(self) -> None:
assert len(self.name) > 0
assert len(self.data) == 256
object.__setattr__(self, "crc", bitbang.make_crc16(self.data))
object.__setattr__(self, "valid", self.data.startswith(self.__HEADER))
try:
object.__setattr__(self, "info", EdidInfo.from_data(self.data))
except Exception:
pass
def as_text(self) -> str:
return "".join(f"{item:0{2}X}" for item in self.data)
def pack(self) -> bytes:
return self.data
@classmethod
def from_data(cls, name: str, data: (str | bytes | None)) -> "Edid":
if data is None: # Пустой едид
return Edid(name, b"\x00" * 256)
if isinstance(data, bytes):
if data.startswith(cls.__HEADER):
return Edid(name, data) # Бинарный едид
data_hex = data.decode() # Текстовый едид, прочитанный как бинарный из файла
else: # isinstance(data, str)
data_hex = str(data) # Текстовый едид
data_hex = re.sub(r"\s", "", data_hex)
assert len(data_hex) == 512
data = bytes([
int(data_hex[index:index + 2], 16)
for index in range(0, len(data_hex), 2)
])
return Edid(name, data)
@dataclasses.dataclass
class Edids:
DEFAULT_NAME = "Default"
DEFAULT_ID = "default"
all: dict[str, Edid] = dataclasses.field(default_factory=dict)
port: dict[int, str] = dataclasses.field(default_factory=dict)
def __post_init__(self) -> None:
if self.DEFAULT_ID not in self.all:
self.set_default(None)
def set_default(self, data: (str | bytes | None)) -> None:
self.all[self.DEFAULT_ID] = Edid.from_data(self.DEFAULT_NAME, data)
def copy(self) -> "Edids":
return Edids(dict(self.all), dict(self.port))
def compare_on_ports(self, other: "Edids", ports: int) -> bool:
for port in range(ports):
if self.get_id_for_port(port) != other.get_id_for_port(port):
return False
return True
def add(self, edid: Edid) -> str:
edid_id = str(uuid.uuid4()).lower()
self.all[edid_id] = edid
return edid_id
def set(self, edid_id: str, edid: Edid) -> None:
assert edid_id in self.all
self.all[edid_id] = edid
def get(self, edid_id: str) -> Edid:
return self.all[edid_id]
def remove(self, edid_id: str) -> None:
assert edid_id in self.all
self.all.pop(edid_id)
for port in list(self.port):
if self.port[port] == edid_id:
self.port.pop(port)
def has_id(self, edid_id: str) -> bool:
return (edid_id in self.all)
def assign(self, port: int, edid_id: str) -> None:
assert edid_id in self.all
if edid_id == Edids.DEFAULT_ID:
self.port.pop(port, None)
else:
self.port[port] = edid_id
def get_id_for_port(self, port: int) -> str:
return self.port.get(port, self.DEFAULT_ID)
def get_edid_for_port(self, port: int) -> Edid:
return self.all[self.get_id_for_port(port)]
# =====
@dataclasses.dataclass(frozen=True)
class Color:
COMPONENTS = frozenset(["red", "green", "blue", "brightness", "blink_ms"])
red: int
green: int
blue: int
brightness: int
blink_ms: int
crc: int = dataclasses.field(default=0)
_packed: bytes = dataclasses.field(default=b"")
__struct = struct.Struct("<BBBBH")
__rx = re.compile(r"^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{4})$")
def __post_init__(self) -> None:
assert 0 <= self.red <= 0xFF
assert 0 <= self.green <= 0xFF
assert 0 <= self.blue <= 0xFF
assert 0 <= self.brightness <= 0xFF
assert 0 <= self.blink_ms <= 0xFFFF
data = self.__struct.pack(self.red, self.green, self.blue, self.brightness, self.blink_ms)
object.__setattr__(self, "crc", bitbang.make_crc16(data))
object.__setattr__(self, "_packed", data)
def pack(self) -> bytes:
return self._packed
@classmethod
def from_text(cls, text: str) -> "Color":
match = cls.__rx.match(text)
assert match is not None, text
return Color(
red=int(match.group(1), 16),
green=int(match.group(2), 16),
blue=int(match.group(3), 16),
brightness=int(match.group(4), 16),
blink_ms=int(match.group(5), 16),
)
@dataclasses.dataclass(frozen=True)
class Colors:
ROLES = frozenset(["inactive", "active", "flashing", "beacon", "bootloader"])
inactive: Color = dataclasses.field(default=Color(255, 0, 0, 64, 0))
active: Color = dataclasses.field(default=Color(0, 255, 0, 128, 0))
flashing: Color = dataclasses.field(default=Color(0, 170, 255, 128, 0))
beacon: Color = dataclasses.field(default=Color(228, 44, 156, 255, 250))
bootloader: Color = dataclasses.field(default=Color(255, 170, 0, 128, 0))
crc: int = dataclasses.field(default=0)
_packed: bytes = dataclasses.field(default=b"")
__crc_struct = struct.Struct("<HHHHH")
def __post_init__(self) -> None:
crcs: list[int] = []
packed: bytes = b""
for color in [self.inactive, self.active, self.flashing, self.beacon, self.bootloader]:
crcs.append(color.crc)
packed += color.pack()
object.__setattr__(self, "crc", bitbang.make_crc16(self.__crc_struct.pack(*crcs)))
object.__setattr__(self, "_packed", packed)
def pack(self) -> bytes:
return self._packed
# =====
_T = TypeVar("_T")
class _PortsDict(Generic[_T]):
def __init__(self, default: _T, kvs: dict[int, _T]) -> None:
self.default = default
self.kvs = {
port: value
for (port, value) in kvs.items()
if value != default
}
def compare_on_ports(self, other: "_PortsDict[_T]", ports: int) -> bool:
for port in range(ports):
if self[port] != other[port]:
return False
return True
def __getitem__(self, port: int) -> _T:
return self.kvs.get(port, self.default)
def __setitem__(self, port: int, value: (_T | None)) -> None:
if value is None:
value = self.default
if value == self.default:
self.kvs.pop(port, None)
else:
self.kvs[port] = value
class PortNames(_PortsDict[str]):
def __init__(self, kvs: dict[int, str]) -> None:
super().__init__("", kvs)
def copy(self) -> "PortNames":
return PortNames(self.kvs)
class AtxClickPowerDelays(_PortsDict[float]):
def __init__(self, kvs: dict[int, float]) -> None:
super().__init__(0.5, kvs)
def copy(self) -> "AtxClickPowerDelays":
return AtxClickPowerDelays(self.kvs)
class AtxClickPowerLongDelays(_PortsDict[float]):
def __init__(self, kvs: dict[int, float]) -> None:
super().__init__(5.5, kvs)
def copy(self) -> "AtxClickPowerLongDelays":
return AtxClickPowerLongDelays(self.kvs)
class AtxClickResetDelays(_PortsDict[float]):
def __init__(self, kvs: dict[int, float]) -> None:
super().__init__(0.5, kvs)
def copy(self) -> "AtxClickResetDelays":
return AtxClickResetDelays(self.kvs)

View File

@@ -408,7 +408,7 @@ class UserGpio:
def __make_item_input(self, parts: list[str]) -> dict:
assert len(parts) >= 1
color = (parts[1] if len(parts) > 1 else None)
if color not in ["green", "yellow", "red"]:
if color not in ["green", "yellow", "red", "blue", "cyan", "magenta", "pink", "white"]:
color = "green"
return {
"type": UserGpioModes.INPUT,

View File

@@ -0,0 +1,48 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2020 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/>. #
# #
# ========================================================================== #
from ...clients.streamer import StreamerFormats
from ...clients.streamer import MemsinkStreamerClient
from .. import init
from .server import MediaServer
# =====
def main(argv: (list[str] | None)=None) -> None:
config = init(
prog="kvmd-media",
description="The media proxy",
check_run=True,
argv=argv,
)[2].media
def make_streamer(name: str, fmt: int) -> (MemsinkStreamerClient | None):
if getattr(config.memsink, name).sink:
return MemsinkStreamerClient(name.upper(), fmt, **getattr(config.memsink, name)._unpack())
return None
MediaServer(
h264_streamer=make_streamer("h264", StreamerFormats.H264),
jpeg_streamer=make_streamer("jpeg", StreamerFormats.JPEG),
).run(**config.server._unpack())

View File

@@ -0,0 +1,24 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2020 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/>. #
# #
# ========================================================================== #
from . import main
main()

190
kvmd/apps/media/server.py Normal file
View File

@@ -0,0 +1,190 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2020 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 dataclasses
from aiohttp.web import Request
from aiohttp.web import WebSocketResponse
from ...logging import get_logger
from ... import tools
from ... import aiotools
from ...htserver import exposed_http
from ...htserver import exposed_ws
from ...htserver import WsSession
from ...htserver import HttpServer
from ...clients.streamer import StreamerError
from ...clients.streamer import StreamerPermError
from ...clients.streamer import StreamerFormats
from ...clients.streamer import BaseStreamerClient
# =====
@dataclasses.dataclass
class _Source:
type: str
fmt: str
streamer: BaseStreamerClient
meta: dict = dataclasses.field(default_factory=dict)
clients: dict[WsSession, "_Client"] = dataclasses.field(default_factory=dict)
key_required: bool = dataclasses.field(default=False)
@dataclasses.dataclass
class _Client:
ws: WsSession
src: _Source
queue: asyncio.Queue[dict]
sender: (asyncio.Task | None) = dataclasses.field(default=None)
class MediaServer(HttpServer):
__K_VIDEO = "video"
__F_H264 = "h264"
__F_JPEG = "jpeg"
__Q_SIZE = 32
def __init__(
self,
h264_streamer: (BaseStreamerClient | None),
jpeg_streamer: (BaseStreamerClient | None),
) -> None:
super().__init__()
self.__srcs: list[_Source] = []
if h264_streamer:
self.__srcs.append(_Source(self.__K_VIDEO, self.__F_H264, h264_streamer, {"profile_level_id": "42E01F"}))
if jpeg_streamer:
self.__srcs.append(_Source(self.__K_VIDEO, self.__F_JPEG, jpeg_streamer))
# =====
@exposed_http("GET", "/ws")
async def __ws_handler(self, req: Request) -> WebSocketResponse:
async with self._ws_session(req) as ws:
media: dict = {self.__K_VIDEO: {}}
for src in self.__srcs:
media[src.type][src.fmt] = src.meta
await ws.send_event("media", media)
return (await self._ws_loop(ws))
@exposed_ws(0)
async def __ws_bin_ping_handler(self, ws: WsSession, _: bytes) -> None:
await ws.send_bin(255, b"") # Ping-pong
@exposed_ws("start")
async def __ws_start_handler(self, ws: WsSession, event: dict) -> None:
try:
req_type = str(event.get("type"))
req_fmt = str(event.get("format"))
except Exception:
return
src: (_Source | None) = None
for cand in self.__srcs:
if ws in cand.clients:
return # Don't allow any double streaming
if (cand.type, cand.fmt) == (req_type, req_fmt):
src = cand
if src:
client = _Client(ws, src, asyncio.Queue(self.__Q_SIZE))
client.sender = aiotools.create_deadly_task(str(ws), self.__sender(client))
src.clients[ws] = client
get_logger(0).info("Streaming %s to %s ...", src.streamer, ws)
# =====
async def _init_app(self) -> None:
logger = get_logger(0)
for src in self.__srcs:
logger.info("Starting streamer %s ...", src.streamer)
aiotools.create_deadly_task(str(src.streamer), self.__streamer(src))
self._add_exposed(self)
async def _on_shutdown(self) -> None:
logger = get_logger(0)
logger.info("Stopping system tasks ...")
await aiotools.stop_all_deadly_tasks()
logger.info("Disconnecting clients ...")
await self._close_all_wss()
logger.info("On-Shutdown complete")
async def _on_ws_closed(self, ws: WsSession) -> None:
for src in self.__srcs:
client = src.clients.pop(ws, None)
if client and client.sender:
get_logger(0).info("Closed stream for %s", ws)
client.sender.cancel()
return
# =====
async def __sender(self, client: _Client) -> None:
need_key = StreamerFormats.is_diff(client.src.streamer.get_format())
if need_key:
client.src.key_required = True
has_key = False
while True:
frame = await client.queue.get()
has_key = (not need_key or has_key or frame["key"])
if has_key:
try:
await client.ws.send_bin(1, frame["key"].to_bytes() + frame["data"])
except Exception:
pass
async def __streamer(self, src: _Source) -> None:
logger = get_logger(0)
while True:
if len(src.clients) == 0:
await asyncio.sleep(1)
continue
try:
async with src.streamer.reading() as read_frame:
while len(src.clients) > 0:
frame = await read_frame(src.key_required)
if frame["key"]:
src.key_required = False
for client in src.clients.values():
try:
client.queue.put_nowait(frame)
except asyncio.QueueFull:
# Если какой-то из клиентов не справляется, очищаем ему очередь и запрашиваем кейфрейм.
# Я вижу у такой логики кучу минусов, хз как себя покажет, но лучше пока ничего не придумал.
tools.clear_queue(client.queue)
src.key_required = True
except Exception:
pass
except StreamerError as ex:
if isinstance(ex, StreamerPermError):
logger.exception("Streamer failed: %s", src.streamer)
else:
logger.error("Streamer error: %s: %s", src.streamer, tools.efmt(ex))
except Exception:
get_logger(0).exception("Unexpected streamer error: %s", src.streamer)
await asyncio.sleep(1)

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# Copyright (C) 2023-2025 SilentWind <mofeng654321@hotmail.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 #
@@ -47,7 +48,8 @@ from .hid.mouse import make_mouse_hid
# =====
def _mkdir(path: str) -> None:
get_logger().info("MKDIR --- %s", path)
os.mkdir(path)
if not os.path.isdir(path):
os.makedirs(path, exist_ok=True)
def _chown(path: str, user: str, optional: bool=False) -> None:
@@ -106,31 +108,45 @@ def _check_config(config: Section) -> None:
# =====
class _GadgetConfig:
def __init__(self, gadget_path: str, profile_path: str, meta_path: str) -> None:
def __init__(self, gadget_path: str, profile_path: str, meta_path: str, eps: int) -> None:
self.__gadget_path = gadget_path
self.__profile_path = profile_path
self.__meta_path = meta_path
self.__eps_max = eps
self.__eps_used = 0
self.__hid_instance = 0
self.__msd_instance = 0
_mkdir(meta_path)
def add_serial(self, start: bool) -> None:
func = "acm.usb0"
func_path = join(self.__gadget_path, "functions", func)
_mkdir(func_path)
def add_audio_mic(self, start: bool) -> None:
eps = 2
func = "uac2.usb0"
func_path = self.__create_function(func)
_write(join(func_path, "c_chmask"), 0)
_write(join(func_path, "p_chmask"), 0b11)
_write(join(func_path, "p_srate"), 48000)
_write(join(func_path, "p_ssize"), 2)
if start:
_symlink(func_path, join(self.__profile_path, func))
self.__create_meta(func, "Serial Port")
self.__start_function(func, eps)
self.__create_meta(func, "Microphone", eps)
def add_serial(self, start: bool) -> None:
eps = 3
func = "acm.usb0"
self.__create_function(func)
if start:
self.__start_function(func, eps)
self.__create_meta(func, "Serial Port", eps)
def add_ethernet(self, start: bool, driver: str, host_mac: str, kvm_mac: str) -> None:
eps = 3
if host_mac and kvm_mac and host_mac == kvm_mac:
raise RuntimeError("Ethernet host_mac should not be equal to kvm_mac")
real_driver = driver
if driver == "rndis5":
real_driver = "rndis"
func = f"{real_driver}.usb0"
func_path = join(self.__gadget_path, "functions", func)
_mkdir(func_path)
func_path = self.__create_function(func)
if host_mac:
_write(join(func_path, "host_addr"), host_mac)
if kvm_mac:
@@ -150,20 +166,20 @@ class _GadgetConfig:
_write(join(func_path, "os_desc/interface.rndis/sub_compatible_id"), "5162001")
_symlink(self.__profile_path, join(self.__gadget_path, "os_desc", usb.G_PROFILE_NAME))
if start:
_symlink(func_path, join(self.__profile_path, func))
self.__create_meta(func, "Ethernet")
self.__start_function(func, eps)
self.__create_meta(func, "Ethernet", eps)
def add_keyboard(self, start: bool, remote_wakeup: bool) -> None:
self.__add_hid("Keyboard", start, remote_wakeup, make_keyboard_hid())
def add_mouse(self, start: bool, remote_wakeup: bool, absolute: bool, horizontal_wheel: bool) -> None:
name = ("Absolute" if absolute else "Relative") + " Mouse"
self.__add_hid(name, start, remote_wakeup, make_mouse_hid(absolute, horizontal_wheel))
desc = ("Absolute" if absolute else "Relative") + " Mouse"
self.__add_hid(desc, start, remote_wakeup, make_mouse_hid(absolute, horizontal_wheel))
def __add_hid(self, name: str, start: bool, remote_wakeup: bool, hid: Hid) -> None:
def __add_hid(self, desc: str, start: bool, remote_wakeup: bool, hid: Hid) -> None:
eps = 1
func = f"hid.usb{self.__hid_instance}"
func_path = join(self.__gadget_path, "functions", func)
_mkdir(func_path)
func_path = self.__create_function(func)
_write(join(func_path, "no_out_endpoint"), "1", optional=True)
if remote_wakeup:
_write(join(func_path, "wakeup_on_write"), "1", optional=True)
@@ -172,32 +188,66 @@ class _GadgetConfig:
_write(join(func_path, "report_length"), hid.report_length)
_write_bytes(join(func_path, "report_desc"), hid.report_descriptor)
if start:
_symlink(func_path, join(self.__profile_path, func))
self.__create_meta(func, name)
self.__start_function(func, eps)
self.__create_meta(func, desc, eps)
self.__hid_instance += 1
def add_msd(self, start: bool, user: str, stall: bool, cdrom: bool, rw: bool, removable: bool, fua: bool) -> None:
def add_msd(
self,
start: bool,
user: str,
stall: bool,
cdrom: bool,
rw: bool,
removable: bool,
fua: bool,
inquiry_string_cdrom: str,
inquiry_string_flash: str,
) -> None:
# Endpoints number depends on transport_type but we can consider that this is 2
# because transport_type is always USB_PR_BULK by default if CONFIG_USB_FILE_STORAGE_TEST
# is not defined. See drivers/usb/gadget/function/storage_common.c
eps = 2
func = f"mass_storage.usb{self.__msd_instance}"
func_path = join(self.__gadget_path, "functions", func)
_mkdir(func_path)
func_path = self.__create_function(func)
_write(join(func_path, "stall"), int(stall))
_write(join(func_path, "lun.0/cdrom"), int(cdrom))
_write(join(func_path, "lun.0/ro"), int(not rw))
_write(join(func_path, "lun.0/removable"), int(removable))
_write(join(func_path, "lun.0/nofua"), int(not fua))
#_write(join(func_path, "lun.0/inquiry_string_cdrom"), inquiry_string_cdrom)
#_write(join(func_path, "lun.0/inquiry_string"), inquiry_string_flash)
if user != "root":
_chown(join(func_path, "lun.0/cdrom"), user)
_chown(join(func_path, "lun.0/ro"), user)
_chown(join(func_path, "lun.0/file"), user)
_chown(join(func_path, "lun.0/forced_eject"), user, optional=True)
if start:
_symlink(func_path, join(self.__profile_path, func))
name = ("Mass Storage Drive" if self.__msd_instance == 0 else f"Extra Drive #{self.__msd_instance}")
self.__create_meta(func, name)
self.__start_function(func, eps)
desc = ("Mass Storage Drive" if self.__msd_instance == 0 else f"Extra Drive #{self.__msd_instance}")
self.__create_meta(func, desc, eps)
self.__msd_instance += 1
def __create_meta(self, func: str, name: str) -> None:
_write(join(self.__meta_path, f"{func}@meta.json"), json.dumps({"func": func, "name": name}))
def __create_function(self, func: str) -> str:
func_path = join(self.__gadget_path, "functions", func)
_mkdir(func_path)
return func_path
def __start_function(self, func: str, eps: int) -> None:
func_path = join(self.__gadget_path, "functions", func)
if self.__eps_max - self.__eps_used >= eps:
_symlink(func_path, join(self.__profile_path, func))
self.__eps_used += eps
else:
get_logger().info("Will not be started: No available endpoints")
def __create_meta(self, func: str, desc: str, eps: int) -> None:
_write(join(self.__meta_path, f"{func}@meta.json"), json.dumps({
"function": func,
"description": desc,
"endpoints": eps,
}))
def _cmd_start(config: Section) -> None: # pylint: disable=too-many-statements,too-many-branches
@@ -248,33 +298,50 @@ def _cmd_start(config: Section) -> None: # pylint: disable=too-many-statements,
# XXX: Should we use MaxPower=100 with Remote Wakeup?
_write(join(profile_path, "bmAttributes"), "0xA0")
gc = _GadgetConfig(gadget_path, profile_path, config.otg.meta)
gc = _GadgetConfig(gadget_path, profile_path, config.otg.meta, config.otg.endpoints)
cod = config.otg.devices
if cod.serial.enabled:
logger.info("===== Serial =====")
gc.add_serial(cod.serial.start)
if cod.ethernet.enabled:
logger.info("===== Ethernet =====")
gc.add_ethernet(**cod.ethernet._unpack(ignore=["enabled"]))
if config.kvmd.hid.type == "otg":
logger.info("===== HID-Keyboard =====")
gc.add_keyboard(cod.hid.keyboard.start, config.otg.remote_wakeup)
logger.info("===== HID-Mouse =====")
gc.add_mouse(cod.hid.mouse.start, config.otg.remote_wakeup, config.kvmd.hid.mouse.absolute, config.kvmd.hid.mouse.horizontal_wheel)
ckhm = config.kvmd.hid.mouse
gc.add_mouse(cod.hid.mouse.start, config.otg.remote_wakeup, ckhm.absolute, ckhm.horizontal_wheel)
if config.kvmd.hid.mouse_alt.device:
logger.info("===== HID-Mouse-Alt =====")
gc.add_mouse(cod.hid.mouse.start, config.otg.remote_wakeup, (not config.kvmd.hid.mouse.absolute), config.kvmd.hid.mouse.horizontal_wheel)
gc.add_mouse(cod.hid.mouse_alt.start, config.otg.remote_wakeup, (not ckhm.absolute), ckhm.horizontal_wheel)
if config.kvmd.msd.type == "otg":
logger.info("===== MSD =====")
gc.add_msd(cod.msd.start, config.otg.user, **cod.msd.default._unpack())
gc.add_msd(
start=cod.msd.start,
user=config.otg.user,
inquiry_string_cdrom=usb.make_inquiry_string(**cod.msd.default.inquiry_string.cdrom._unpack()),
inquiry_string_flash=usb.make_inquiry_string(**cod.msd.default.inquiry_string.flash._unpack()),
**cod.msd.default._unpack(ignore="inquiry_string"),
)
if cod.drives.enabled:
for count in range(cod.drives.count):
logger.info("===== MSD Extra: %d =====", count + 1)
gc.add_msd(cod.drives.start, "root", **cod.drives.default._unpack())
gc.add_msd(
start=cod.drives.start,
user="root",
inquiry_string_cdrom=usb.make_inquiry_string(**cod.drives.default.inquiry_string.cdrom._unpack()),
inquiry_string_flash=usb.make_inquiry_string(**cod.drives.default.inquiry_string.flash._unpack()),
**cod.drives.default._unpack(ignore="inquiry_string"),
)
if cod.ethernet.enabled:
logger.info("===== Ethernet =====")
gc.add_ethernet(**cod.ethernet._unpack(ignore=["enabled"]))
if cod.serial.enabled:
logger.info("===== Serial =====")
gc.add_serial(cod.serial.start)
if cod.audio.enabled:
logger.info("===== Microphone =====")
gc.add_audio_mic(cod.audio.start)
logger.info("===== Preparing complete =====")

View File

@@ -23,6 +23,7 @@
import os
import json
import contextlib
import dataclasses
import argparse
import time
@@ -38,11 +39,28 @@ from .. import init
# =====
@dataclasses.dataclass(frozen=True)
class _Function:
name: str
desc: str
eps: int
enabled: bool
class _GadgetControl:
def __init__(self, meta_path: str, gadget: str, udc: str, init_delay: float) -> None:
def __init__(
self,
meta_path: str,
gadget: str,
udc: str,
eps: int,
init_delay: float,
) -> None:
self.__meta_path = meta_path
self.__gadget = gadget
self.__udc = udc
self.__eps = eps
self.__init_delay = init_delay
@contextlib.contextmanager
@@ -57,12 +75,12 @@ class _GadgetControl:
try:
yield
finally:
self.__recreate_profile()
self.__clear_profile(recreate=True)
time.sleep(self.__init_delay)
with open(udc_path, "w") as file:
file.write(udc)
def __recreate_profile(self) -> None:
def __clear_profile(self, recreate: bool) -> None:
# XXX: See pikvm/pikvm#1235
# After unbind and bind, the gadgets stop working,
# unless we recreate their links in the profile.
@@ -72,14 +90,22 @@ class _GadgetControl:
if os.path.islink(path):
try:
os.unlink(path)
os.symlink(self.__get_fsrc_path(func), path)
if recreate:
os.symlink(self.__get_fsrc_path(func), path)
except (FileNotFoundError, FileExistsError):
pass
def __read_metas(self) -> Generator[dict, None, None]:
for meta_name in sorted(os.listdir(self.__meta_path)):
with open(os.path.join(self.__meta_path, meta_name)) as file:
yield json.loads(file.read())
def __read_metas(self) -> Generator[_Function, None, None]:
for name in sorted(os.listdir(self.__meta_path)):
with open(os.path.join(self.__meta_path, name)) as file:
meta = json.loads(file.read())
enabled = os.path.exists(self.__get_fdest_path(meta["function"]))
yield _Function(
name=meta["function"],
desc=meta["description"],
eps=meta["endpoints"],
enabled=enabled,
)
def __get_fsrc_path(self, func: str) -> str:
return usb.get_gadget_path(self.__gadget, usb.G_FUNCTIONS, func)
@@ -89,20 +115,28 @@ class _GadgetControl:
return usb.get_gadget_path(self.__gadget, usb.G_PROFILE)
return usb.get_gadget_path(self.__gadget, usb.G_PROFILE, func)
def enable_functions(self, funcs: list[str]) -> None:
def change_functions(self, enable: set[str], disable: set[str]) -> None:
funcs = list(self.__read_metas())
new: set[str] = set(func.name for func in funcs if func.enabled)
new = (new - disable) | enable
eps_req = sum(func.eps for func in funcs if func.name in new)
if eps_req > self.__eps:
raise RuntimeError(f"No available endpoints for this config: {eps_req} required, {self.__eps} is maximum")
with self.__udc_stopped():
for func in funcs:
os.symlink(self.__get_fsrc_path(func), self.__get_fdest_path(func))
def disable_functions(self, funcs: list[str]) -> None:
with self.__udc_stopped():
for func in funcs:
os.unlink(self.__get_fdest_path(func))
self.__clear_profile(recreate=False)
for func in new:
try:
os.symlink(self.__get_fsrc_path(func), self.__get_fdest_path(func))
except FileExistsError:
pass
def list_functions(self) -> None:
for meta in self.__read_metas():
enabled = os.path.exists(self.__get_fdest_path(meta["func"]))
print(f"{'+' if enabled else '-'} {meta['func']} # {meta['name']}")
funcs = list(self.__read_metas())
eps_used = sum(func.eps for func in funcs if func.enabled)
print(f"# Endpoints used: {eps_used} of {self.__eps}")
print(f"# Endpoints free: {self.__eps - eps_used}")
for func in funcs:
print(f"{'+' if func.enabled else '-'} {func.name} # [{func.eps}] {func.desc}")
def make_gpio_config(self) -> None:
class Dumper(yaml.Dumper):
@@ -127,17 +161,17 @@ class _GadgetControl:
"scheme": {},
"view": {"table": []},
}
for meta in self.__read_metas():
config["scheme"][meta["func"]] = { # type: ignore
for func in self.__read_metas():
config["scheme"][func.name] = { # type: ignore
"driver": "otgconf",
"pin": meta["func"],
"pin": func.name,
"mode": "output",
"pulse": False,
}
config["view"]["table"].append(InlineList([ # type: ignore
"#" + meta["name"],
"#" + meta["func"],
meta["func"],
"#" + func.desc,
"#" + func.name,
func.name,
]))
print(yaml.dump({"kvmd": {"gpio": config}}, indent=4, Dumper=Dumper))
@@ -159,25 +193,21 @@ def main(argv: (list[str] | None)=None) -> None:
parents=[parent_parser],
)
parser.add_argument("-l", "--list-functions", action="store_true", help="List functions")
parser.add_argument("-e", "--enable-function", nargs="+", metavar="<name>", help="Enable function(s)")
parser.add_argument("-d", "--disable-function", nargs="+", metavar="<name>", help="Disable function(s)")
parser.add_argument("-e", "--enable-function", nargs="+", default=[], metavar="<name>", help="Enable function(s)")
parser.add_argument("-d", "--disable-function", nargs="+", default=[], metavar="<name>", help="Disable function(s)")
parser.add_argument("-r", "--reset-gadget", action="store_true", help="Reset gadget")
parser.add_argument("--make-gpio-config", action="store_true")
options = parser.parse_args(argv[1:])
gc = _GadgetControl(config.otg.meta, config.otg.gadget, config.otg.udc, config.otg.init_delay)
gc = _GadgetControl(config.otg.meta, config.otg.gadget, config.otg.udc, config.otg.endpoints, config.otg.init_delay)
if options.list_functions:
gc.list_functions()
elif options.enable_function:
funcs = list(map(valid_stripped_string_not_empty, options.enable_function))
gc.enable_functions(funcs)
gc.list_functions()
elif options.disable_function:
funcs = list(map(valid_stripped_string_not_empty, options.disable_function))
gc.disable_functions(funcs)
elif options.enable_function or options.disable_function:
enable = set(map(valid_stripped_string_not_empty, options.enable_function))
disable = set(map(valid_stripped_string_not_empty, options.disable_function))
gc.change_functions(enable, disable)
gc.list_functions()
elif options.reset_gadget:

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# Copyright (C) 2023-2025 SilentWind <mofeng654321@hotmail.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 #
@@ -20,13 +21,12 @@
# ========================================================================== #
import os
import errno
import argparse
from ...validators.basic import valid_bool
from ...validators.basic import valid_int_f0
from ...validators.os import valid_abs_file
from ...validators.os import valid_abs_path
from ... import usb
@@ -72,10 +72,10 @@ def main(argv: (list[str] | None)=None) -> None:
parser.add_argument("-i", "--instance", default=0, type=valid_int_f0,
metavar="<N>", help="Drive instance (0 for KVMD drive)")
parser.add_argument("--set-cdrom", default=None, type=valid_bool,
metavar="<1|0|yes|no>", help="Set CD-ROM flag")
metavar="<1|0|yes|no>", help="Set CD/DVD flag")
parser.add_argument("--set-rw", default=None, type=valid_bool,
metavar="<1|0|yes|no>", help="Set RW flag")
parser.add_argument("--set-image", default=None, type=valid_abs_file,
parser.add_argument("--set-image", default=None, type=valid_abs_path,
metavar="<path>", help="Set the image file")
parser.add_argument("--eject", action="store_true",
help="Eject the image")
@@ -103,10 +103,10 @@ def main(argv: (list[str] | None)=None) -> None:
set_param("ro", str(int(not options.set_rw)))
if options.set_image:
if not os.path.isfile(options.set_image):
raise SystemExit(f"Not a file: {options.set_image}")
# if not os.path.isfile(options.set_image):
# raise SystemExit(f"Not a file: {options.set_image}")
set_param("file", options.set_image)
print("Image file: ", (get_param("file") or "<none>"))
print("CD-ROM flag:", ("yes" if int(get_param("cdrom")) else "no"))
print("CD/DVD flag:", ("yes" if int(get_param("cdrom")) else "no"))
print("RW flag: ", ("no" if int(get_param("ro")) else "yes"))

View File

@@ -24,6 +24,7 @@ import os
import asyncio
from aiohttp.web import Request
from aiohttp.web import Response
from aiohttp.web import WebSocketResponse
from ...logging import get_logger
@@ -35,6 +36,7 @@ from ... import fstab
from ...htserver import exposed_http
from ...htserver import exposed_ws
from ...htserver import make_json_response
from ...htserver import WsSession
from ...htserver import HttpServer
@@ -65,6 +67,16 @@ class PstServer(HttpServer): # pylint: disable=too-many-arguments,too-many-inst
await ws.send_event("loop", {})
return (await self._ws_loop(ws))
@exposed_http("GET", "/state")
async def __state_handler(self, _: Request) -> Response:
return make_json_response({
"clients": len(self._get_wss()),
"data": {
"path": self.__data_path,
"write_allowed": self.__is_write_available(),
},
})
@exposed_ws("ping")
async def __ws_ping_handler(self, ws: WsSession, _: dict) -> None:
await ws.send_event("pong", {})
@@ -92,10 +104,10 @@ class PstServer(HttpServer): # pylint: disable=too-many-arguments,too-many-inst
await self.__remount_storage(rw=False)
logger.info("On-Cleanup complete")
async def _on_ws_opened(self) -> None:
async def _on_ws_opened(self, _: WsSession) -> None:
self.__notifier.notify()
async def _on_ws_closed(self) -> None:
async def _on_ws_closed(self, _: WsSession) -> None:
self.__notifier.notify()
# ===== SYSTEM TASKS
@@ -117,7 +129,7 @@ class PstServer(HttpServer): # pylint: disable=too-many-arguments,too-many-inst
await self.__notifier.wait()
async def __broadcast_storage_state(self, clients: int, write_allowed: bool) -> None:
await self._broadcast_ws_event("storage_state", {
await self._broadcast_ws_event("storage", {
"clients": clients,
"data": {
"path": self.__data_path,

View File

@@ -84,7 +84,7 @@ async def _run_cmd_ws(cmd: list[str], ws: aiohttp.ClientWebSocketResponse) -> in
msg = receive_task.result()
if msg.type == aiohttp.WSMsgType.TEXT:
(event_type, event) = htserver.parse_ws_event(msg.data)
if event_type == "storage_state":
if event_type == "storage":
if event["data"]["write_allowed"] and proc is None:
logger.info("PST write is allowed: %s", event["data"]["path"])
logger.info("Running the process ...")

167
kvmd/apps/swctl/__init__.py Normal file
View File

@@ -0,0 +1,167 @@
# ========================================================================== #
# #
# 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 os
import argparse
import pprint
import time
import pyudev
from ..kvmd.switch.device import Device
from ..kvmd.switch.proto import Edid
# =====
def _find_serial_device() -> str:
ctx = pyudev.Context()
for device in ctx.list_devices(subsystem="tty"):
if (
str(device.properties.get("ID_VENDOR_ID")).upper() == "2E8A"
and str(device.properties.get("ID_MODEL_ID")).upper() == "1080"
):
path = device.properties["DEVNAME"]
assert path.startswith("/dev/")
return path
return ""
def _wait_boot_device() -> str:
stop_ts = time.time() + 5
ctx = pyudev.Context()
while time.time() < stop_ts:
for device in ctx.list_devices(subsystem="block", DEVTYPE="partition"):
if (
str(device.properties.get("ID_VENDOR_ID")).upper() == "2E8A"
and str(device.properties.get("ID_MODEL_ID")).upper() == "0003"
):
path = device.properties["DEVNAME"]
assert path.startswith("/dev/")
return path
time.sleep(0.2)
return ""
def _create_edid(arg: str) -> Edid:
if arg == "@":
return Edid.from_data("Empty", None)
with open(arg) as file:
return Edid.from_data(os.path.basename(arg), file.read())
# =====
def main() -> None: # pylint: disable=too-many-statements
parser = argparse.ArgumentParser()
parser.add_argument("-d", "--device", default="")
parser.set_defaults(cmd="")
subs = parser.add_subparsers()
def add_command(name: str) -> argparse.ArgumentParser:
cmd = subs.add_parser(name)
cmd.set_defaults(cmd=name)
return cmd
add_command("poll")
add_command("state")
cmd = add_command("bootloader")
cmd.add_argument("unit", type=int)
cmd = add_command("reboot")
cmd.add_argument("unit", type=int)
cmd = add_command("switch")
cmd.add_argument("unit", type=int)
cmd.add_argument("port", type=int, choices=list(range(5)))
cmd = add_command("beacon")
cmd.add_argument("unit", type=int)
cmd.add_argument("port", type=int, choices=list(range(6)))
cmd.add_argument("on", choices=["on", "off"])
add_command("leds")
cmd = add_command("click")
cmd.add_argument("button", choices=["power", "reset"])
cmd.add_argument("unit", type=int)
cmd.add_argument("port", type=int, choices=list(range(4)))
cmd.add_argument("delay_ms", type=int)
cmd = add_command("set-edid")
cmd.add_argument("unit", type=int)
cmd.add_argument("port", type=int, choices=list(range(4)))
cmd.add_argument("edid", type=_create_edid)
opts = parser.parse_args()
if not opts.device:
opts.device = _find_serial_device()
if opts.cmd == "bootloader" and opts.unit == 0:
if opts.device:
with Device(opts.device) as device:
device.request_reboot(opts.unit, bootloader=True)
found = _wait_boot_device()
if found:
print(found)
raise SystemExit()
raise SystemExit("Error: No switch found")
if not opts.device:
raise SystemExit("Error: No switch found")
with Device(opts.device) as device:
wait_rid: (int | None) = None
match opts.cmd:
case "poll":
device.request_state()
device.request_atx_leds()
case "state":
wait_rid = device.request_state()
case "bootloader" | "reboot":
device.request_reboot(opts.unit, (opts.cmd == "bootloader"))
raise SystemExit()
case "switch":
wait_rid = device.request_switch(opts.unit, opts.port)
case "leds":
wait_rid = device.request_atx_leds()
case "click":
match opts.button:
case "power":
wait_rid = device.request_atx_cp(opts.unit, opts.port, opts.delay_ms)
case "reset":
wait_rid = device.request_atx_cr(opts.unit, opts.port, opts.delay_ms)
case "beacon":
wait_rid = device.request_beacon(opts.unit, opts.port, (opts.on == "on"))
case "set-edid":
wait_rid = device.request_set_edid(opts.unit, opts.port, opts.edid)
error_ts = time.monotonic() + 1
while True:
for resp in device.read_all():
pprint.pprint((int(time.time()), resp))
print()
if resp.header.rid == wait_rid:
raise SystemExit()
if wait_rid is not None and time.monotonic() > error_ts:
raise SystemExit("No answer from unit")

View File

@@ -0,0 +1,24 @@
# ========================================================================== #
# #
# 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/>. #
# #
# ========================================================================== #
from . import main
main()

View File

@@ -464,6 +464,10 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
if self._encodings.has_ext_keys: # Preferred method
await self._write_fb_update("ExtKeys FBUR", 0, 0, RfbEncodings.EXT_KEYS, drain=True)
if self._encodings.has_ext_mouse: # Preferred too
await self._write_fb_update("ExtMouse FBUR", 0, 0, RfbEncodings.EXT_MOUSE, drain=True)
await self._on_set_encodings()
async def __handle_fb_update_request(self) -> None:
@@ -486,11 +490,16 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
async def __handle_pointer_event(self) -> None:
(buttons, to_x, to_y) = await self._read_struct("pointer event", "B HH")
ext_buttons = 0
if self._encodings.has_ext_mouse and (buttons & 0x80): # Marker bit 7 for ext event
ext_buttons = await self._read_number("ext pointer event buttons", "B")
await self._on_pointer_event(
buttons={
"left": bool(buttons & 0x1),
"right": bool(buttons & 0x4),
"middle": bool(buttons & 0x2),
"up": bool(ext_buttons & 0x2),
"down": bool(ext_buttons & 0x1),
},
wheel={
"x": (-4 if buttons & 0x40 else (4 if buttons & 0x20 else 0)),

View File

@@ -31,6 +31,7 @@ class RfbEncodings:
RENAME = -307 # DesktopName Pseudo-encoding
LEDS_STATE = -261 # QEMU LED State Pseudo-encoding
EXT_KEYS = -258 # QEMU Extended Key Events Pseudo-encoding
EXT_MOUSE = -316 # ExtendedMouseButtons Pseudo-encoding
CONT_UPDATES = -313 # ContinuousUpdates Pseudo-encoding
TIGHT = 7
@@ -50,16 +51,17 @@ def _make_meta(variants: (int | frozenset[int])) -> dict:
class RfbClientEncodings: # pylint: disable=too-many-instance-attributes
encodings: frozenset[int]
has_resize: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.RESIZE)) # noqa: E224
has_rename: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.RENAME)) # noqa: E224
has_leds_state: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.LEDS_STATE)) # noqa: E224
has_ext_keys: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.EXT_KEYS)) # noqa: E224
has_cont_updates: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.CONT_UPDATES)) # noqa: E224
has_resize: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.RESIZE)) # noqa: E224
has_rename: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.RENAME)) # noqa: E224
has_leds_state: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.LEDS_STATE)) # noqa: E224
has_ext_keys: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.EXT_KEYS)) # noqa: E224
has_ext_mouse: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.EXT_MOUSE)) # noqa: E224
has_cont_updates: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.CONT_UPDATES)) # noqa: E224
has_tight: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.TIGHT)) # noqa: E224
tight_jpeg_quality: int = dataclasses.field(default=0, metadata=_make_meta(frozenset(RfbEncodings.TIGHT_JPEG_QUALITIES))) # noqa: E224
has_tight: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.TIGHT)) # noqa: E224
tight_jpeg_quality: int = dataclasses.field(default=0, metadata=_make_meta(frozenset(RfbEncodings.TIGHT_JPEG_QUALITIES))) # noqa: E224
has_h264: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.H264)) # noqa: E224
has_h264: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.H264)) # noqa: E224
def get_summary(self) -> list[str]:
summary: list[str] = [f"encodings -- {sorted(self.encodings)}"]

View File

@@ -130,7 +130,7 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
# Эти состояния шарить не обязательно - бекенд исключает дублирующиеся события.
# Все это нужно только чтобы не посылать лишние жсоны в сокет KVMD
self.__mouse_buttons: dict[str, (bool | None)] = dict.fromkeys(["left", "right", "middle"], None)
self.__mouse_buttons: dict[str, (bool | None)] = dict.fromkeys(["left", "right", "middle", "up", "down"], None)
self.__mouse_move = {"x": -1, "y": -1}
self.__modifiers = 0
@@ -177,7 +177,7 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
self.__kvmd_ws = None
async def __process_ws_event(self, event_type: str, event: dict) -> None:
if event_type == "info_state":
if event_type == "info":
if "meta" in event:
try:
host = event["meta"]["server"]["host"]
@@ -190,7 +190,7 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
await self._send_rename(name)
self.__shared_params.name = name
elif event_type == "hid_state":
elif event_type == "hid":
if (
self._encodings.has_leds_state
and ("keyboard" in event)

View File

@@ -183,10 +183,12 @@ class KvmdClientWs:
self.__communicated = False
async def send_key_event(self, key: str, state: bool) -> None:
await self.__writer_queue.put(bytes([1, state]) + key.encode("ascii"))
mask = (0b01 if state else 0)
await self.__writer_queue.put(bytes([1, mask]) + key.encode("ascii"))
async def send_mouse_button_event(self, button: str, state: bool) -> None:
await self.__writer_queue.put(bytes([2, state]) + button.encode("ascii"))
mask = (0b01 if state else 0)
await self.__writer_queue.put(bytes([2, mask]) + button.encode("ascii"))
async def send_mouse_move_event(self, to_x: int, to_y: int) -> None:
await self.__writer_queue.put(struct.pack(">bhh", 3, to_x, to_y))

93
kvmd/clients/pst.py Normal file
View File

@@ -0,0 +1,93 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2020 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 os
import contextlib
from typing import AsyncGenerator
import aiohttp
from .. import htclient
from .. import htserver
# =====
class PstError(Exception):
pass
# =====
class PstClient:
def __init__(
self,
subdir: str,
unix_path: str,
timeout: float,
user_agent: str,
) -> None:
self.__subdir = subdir
self.__unix_path = unix_path
self.__timeout = timeout
self.__user_agent = user_agent
async def get_path(self) -> str:
async with self.__make_http_session() as session:
async with session.get("http://localhost:0/state") as resp:
htclient.raise_not_200(resp)
path = (await resp.json())["result"]["data"]["path"]
return os.path.join(path, self.__subdir)
@contextlib.asynccontextmanager
async def writable(self) -> AsyncGenerator[str, None]:
async with self.__inner_writable() as path:
path = os.path.join(path, self.__subdir)
if not os.path.exists(path):
os.mkdir(path)
yield path
@contextlib.asynccontextmanager
async def __inner_writable(self) -> AsyncGenerator[str, None]:
async with self.__make_http_session() as session:
async with session.ws_connect("http://localhost:0/ws") as ws:
path = ""
async for msg in ws:
if msg.type != aiohttp.WSMsgType.TEXT:
raise PstError(f"Unexpected message type: {msg!r}")
(event_type, event) = htserver.parse_ws_event(msg.data)
if event_type == "storage":
if not event["data"]["write_allowed"]:
raise PstError("Write is not allowed")
path = event["data"]["path"]
break
if not path:
raise PstError("WS loop broken without write_allowed=True flag")
# TODO: Actually we should follow ws events, but for fast writing we can safely ignore them
yield path
def __make_http_session(self) -> aiohttp.ClientSession:
return aiohttp.ClientSession(
headers={"User-Agent": self.__user_agent},
connector=aiohttp.UnixConnector(path=self.__unix_path),
timeout=aiohttp.ClientTimeout(total=self.__timeout),
)

View File

@@ -63,6 +63,10 @@ class StreamerFormats:
H264 = 875967048 # V4L2_PIX_FMT_H264
_MJPEG = 1196444237 # V4L2_PIX_FMT_MJPEG
@classmethod
def is_diff(cls, fmt: int) -> bool:
return (fmt == cls.H264)
class BaseStreamerClient:
def get_format(self) -> int:

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# Copyright (C) 2023-2025 SilentWind <mofeng654321@hotmail.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 #

View File

@@ -232,6 +232,16 @@ async def send_ws_event(
}))
async def send_ws_bin(
wsr: (ClientWebSocketResponse | WebSocketResponse),
op: int,
data: bytes,
) -> None:
assert 0 <= op <= 255
await wsr.send_bytes(op.to_bytes() + data)
def parse_ws_event(msg: str) -> tuple[str, dict]:
data = json.loads(msg)
if not isinstance(data, dict):
@@ -264,14 +274,24 @@ def set_request_auth_info(req: BaseRequest, info: str) -> None:
@dataclasses.dataclass(frozen=True)
class WsSession:
wsr: WebSocketResponse
kwargs: dict[str, Any]
kwargs: dict[str, Any] = dataclasses.field(hash=False)
def __str__(self) -> str:
return f"WsSession(id={id(self)}, {self.kwargs})"
def is_alive(self) -> bool:
return (
not self.wsr.closed
and self.wsr._req is not None # pylint: disable=protected-access
and self.wsr._req.transport is not None # pylint: disable=protected-access
)
async def send_event(self, event_type: str, event: (dict | None)) -> None:
await send_ws_event(self.wsr, event_type, event)
async def send_bin(self, op: int, data: bytes) -> None:
await send_ws_bin(self.wsr, op, data)
class HttpServer:
def __init__(self) -> None:
@@ -353,7 +373,7 @@ class HttpServer:
get_logger(2).info("Registered new client session: %s; clients now: %d", ws, len(self.__ws_sessions))
try:
await self._on_ws_opened()
await self._on_ws_opened(ws)
yield ws
finally:
await aiotools.shield_fg(self.__close_ws(ws))
@@ -384,17 +404,12 @@ class HttpServer:
break
return ws.wsr
async def _broadcast_ws_event(self, event_type: str, event: (dict | None), legacy: (bool | None)=None) -> None:
async def _broadcast_ws_event(self, event_type: str, event: (dict | None)) -> None:
if self.__ws_sessions:
await asyncio.gather(*[
ws.send_event(event_type, event)
for ws in self.__ws_sessions
if (
not ws.wsr.closed
and ws.wsr._req is not None # pylint: disable=protected-access
and ws.wsr._req.transport is not None # pylint: disable=protected-access
and (legacy is None or ws.kwargs.get("legacy") == legacy)
)
if ws.is_alive()
], return_exceptions=True)
async def _close_all_wss(self) -> bool:
@@ -414,7 +429,7 @@ class HttpServer:
await ws.wsr.close()
except Exception:
pass
await self._on_ws_closed()
await self._on_ws_closed(ws)
# =====
@@ -430,10 +445,10 @@ class HttpServer:
async def _on_cleanup(self) -> None:
pass
async def _on_ws_opened(self) -> None:
async def _on_ws_opened(self, ws: WsSession) -> None:
pass
async def _on_ws_closed(self) -> None:
async def _on_ws_closed(self, ws: WsSession) -> None:
pass
# =====

View File

@@ -168,7 +168,13 @@ class WebModifiers:
CTRL_LEFT = "ControlLeft"
CTRL_RIGHT = "ControlRight"
CTRLS = set([CTRL_RIGHT, CTRL_RIGHT])
CTRLS = set([CTRL_LEFT, CTRL_RIGHT])
META_LEFT = "MetaLeft"
META_RIGHT = "MetaRight"
METAS = set([META_LEFT, META_RIGHT])
ALL = (SHIFTS | ALTS | CTRLS | METAS)
class X11Modifiers:

View File

@@ -60,7 +60,13 @@ class WebModifiers:
CTRL_LEFT = "ControlLeft"
CTRL_RIGHT = "ControlRight"
CTRLS = set([CTRL_RIGHT, CTRL_RIGHT])
CTRLS = set([CTRL_LEFT, CTRL_RIGHT])
META_LEFT = "MetaLeft"
META_RIGHT = "MetaRight"
METAS = set([META_LEFT, META_RIGHT])
ALL = (SHIFTS | ALTS | CTRLS | METAS)
class X11Modifiers:

View File

@@ -32,3 +32,17 @@ class MouseRange:
@classmethod
def remap(cls, value: int, out_min: int, out_max: int) -> int:
return tools.remap(value, cls.MIN, cls.MAX, out_min, out_max)
@classmethod
def normalize(cls, value: int) -> int:
return min(max(cls.MIN, value), cls.MAX)
class MouseDelta:
MIN = -127
MAX = 127
RANGE = (MIN, MAX)
@classmethod
def normalize(cls, value: int) -> int:
return min(max(cls.MIN, value), cls.MAX)

View File

@@ -37,6 +37,7 @@ from ...validators.basic import valid_string_list
from ...validators.hid import valid_hid_key
from ...validators.hid import valid_hid_mouse_move
from ...keyboard.mappings import WebModifiers
from ...mouse import MouseRange
from .. import BasePlugin
@@ -64,11 +65,13 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes
self.__mouse_x_range = (mouse_x_min, mouse_x_max)
self.__mouse_y_range = (mouse_y_min, mouse_y_max)
self.__jiggler_enabled = jiggler_enabled
self.__jiggler_active = jiggler_active
self.__jiggler_interval = jiggler_interval
self.__jiggler_absolute = True
self.__activity_ts = 0
self.__j_enabled = jiggler_enabled
self.__j_active = jiggler_active
self.__j_interval = jiggler_interval
self.__j_absolute = True
self.__j_activity_ts = 0
self.__j_last_x = 0
self.__j_last_y = 0
@classmethod
def _get_base_options(cls) -> dict[str, Any]:
@@ -83,7 +86,7 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes
"max": Option(MouseRange.MAX, type=valid_hid_mouse_move, unpack_as="mouse_y_max"),
},
"jiggler": {
"enabled": Option(False, type=valid_bool, unpack_as="jiggler_enabled"),
"enabled": Option(True, type=valid_bool, unpack_as="jiggler_enabled"),
"active": Option(False, type=valid_bool, unpack_as="jiggler_active"),
"interval": Option(60, type=valid_int_f1, unpack_as="jiggler_interval"),
},
@@ -137,13 +140,25 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes
# =====
def send_key_events(self, keys: Iterable[tuple[str, bool]], no_ignore_keys: bool=False) -> None:
async def send_key_events(
self,
keys: Iterable[tuple[str, bool]],
no_ignore_keys: bool=False,
slow: bool=False,
) -> None:
for (key, state) in keys:
if no_ignore_keys or key not in self.__ignore_keys:
self.send_key_event(key, state)
if slow:
await asyncio.sleep(0.02)
self.send_key_event(key, state, False)
def send_key_event(self, key: str, state: bool) -> None:
def send_key_event(self, key: str, state: bool, finish: bool) -> None:
self._send_key_event(key, state)
if state and finish and (key not in WebModifiers.ALL and key != "PrintScreen"):
# Считаем что PrintScreen это модификатор для Alt+SysRq+...
# По-хорошему надо учитывать факт нажатия на Alt, но можно и забить.
self._send_key_event(key, False)
self.__bump_activity()
def _send_key_event(self, key: str, state: bool) -> None:
@@ -161,6 +176,8 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes
# =====
def send_mouse_move_event(self, to_x: int, to_y: int) -> None:
self.__j_last_x = to_x
self.__j_last_y = to_y
if self.__mouse_x_range != MouseRange.RANGE:
to_x = MouseRange.remap(to_x, *self.__mouse_x_range)
if self.__mouse_y_range != MouseRange.RANGE:
@@ -229,37 +246,38 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes
handler(*xy)
def __bump_activity(self) -> None:
self.__activity_ts = int(time.monotonic())
self.__j_activity_ts = int(time.monotonic())
def _set_jiggler_absolute(self, absolute: bool) -> None:
self.__jiggler_absolute = absolute
self.__j_absolute = absolute
def _set_jiggler_active(self, active: bool) -> None:
if self.__jiggler_enabled:
self.__jiggler_active = active
if self.__j_enabled:
self.__j_active = active
def _get_jiggler_state(self) -> dict:
return {
"jiggler": {
"enabled": self.__jiggler_enabled,
"active": self.__jiggler_active,
"interval": self.__jiggler_interval,
"enabled": self.__j_enabled,
"active": self.__j_active,
"interval": self.__j_interval,
},
}
# =====
async def systask(self) -> None:
factor = 1
while True:
if self.__jiggler_active and (self.__activity_ts + self.__jiggler_interval < int(time.monotonic())):
for _ in range(5):
if self.__jiggler_absolute:
self.send_mouse_move_event(100 * factor, 100 * factor)
else:
self.send_mouse_relative_event(10 * factor, 10 * factor)
factor *= -1
await asyncio.sleep(0.1)
if self.__j_active and (self.__j_activity_ts + self.__j_interval < int(time.monotonic())):
if self.__j_absolute:
(x, y) = (self.__j_last_x, self.__j_last_y)
for move in [100, -100, 100, -100, 0]:
self.send_mouse_move_event(MouseRange.normalize(x + move), MouseRange.normalize(y + move))
await asyncio.sleep(0.1)
else:
for move in [10, -10, 10, -10]:
self.send_mouse_relative_event(move, move)
await asyncio.sleep(0.1)
await asyncio.sleep(1)

View File

@@ -26,6 +26,7 @@ import struct
from ....keyboard.mappings import KEYMAP
from ....mouse import MouseRange
from ....mouse import MouseDelta
from .... import tools
from .... import bitbang
@@ -162,8 +163,8 @@ class MouseRelativeEvent(BaseEvent):
delta_y: int
def __post_init__(self) -> None:
assert -127 <= self.delta_x <= 127
assert -127 <= self.delta_y <= 127
assert MouseDelta.MIN <= self.delta_x <= MouseDelta.MAX
assert MouseDelta.MIN <= self.delta_y <= MouseDelta.MAX
def make_request(self) -> bytes:
return _make_request(struct.pack(">Bbbxx", 0x15, self.delta_x, self.delta_y))
@@ -175,8 +176,8 @@ class MouseWheelEvent(BaseEvent):
delta_y: int
def __post_init__(self) -> None:
assert -127 <= self.delta_x <= 127
assert -127 <= self.delta_y <= 127
assert MouseDelta.MIN <= self.delta_x <= MouseDelta.MAX
assert MouseDelta.MIN <= self.delta_y <= MouseDelta.MAX
def make_request(self) -> bytes:
# Горизонтальная прокрутка пока не поддерживается

View File

@@ -23,6 +23,7 @@
import math
from ....mouse import MouseRange
from ....mouse import MouseDelta
# =====
@@ -79,7 +80,7 @@ class Mouse: # pylint: disable=too-many-instance-attributes
def process_wheel(self, delta_x: int, delta_y: int) -> bytes:
_ = delta_x
assert -127 <= delta_y <= 127
assert MouseDelta.MIN <= delta_y <= MouseDelta.MAX
self.__wheel_y = (1 if delta_y > 0 else 255)
if not self.__absolute:
return self.__make_relative_cmd()
@@ -110,6 +111,6 @@ class Mouse: # pylint: disable=too-many-instance-attributes
])
def __fix_relative(self, value: int) -> int:
assert -127 <= value <= 127
assert MouseDelta.MIN <= value <= MouseDelta.MAX
value = math.ceil(value / 3)
return (value if value >= 0 else (255 + value))

View File

@@ -27,6 +27,7 @@ from ....keyboard.mappings import UsbKey
from ....keyboard.mappings import KEYMAP
from ....mouse import MouseRange
from ....mouse import MouseDelta
# =====
@@ -144,8 +145,8 @@ class MouseRelativeEvent(BaseEvent):
delta_y: int
def __post_init__(self) -> None:
assert -127 <= self.delta_x <= 127
assert -127 <= self.delta_y <= 127
assert MouseDelta.MIN <= self.delta_x <= MouseDelta.MAX
assert MouseDelta.MIN <= self.delta_y <= MouseDelta.MAX
@dataclasses.dataclass(frozen=True)
@@ -154,8 +155,8 @@ class MouseWheelEvent(BaseEvent):
delta_y: int
def __post_init__(self) -> None:
assert -127 <= self.delta_x <= 127
assert -127 <= self.delta_y <= 127
assert MouseDelta.MIN <= self.delta_x <= MouseDelta.MAX
assert MouseDelta.MIN <= self.delta_y <= MouseDelta.MAX
def make_mouse_report(

View File

@@ -153,7 +153,6 @@ class MouseProcess(BaseDeviceProcess):
move_x = self.__x
move_y = self.__y
else:
assert self.__x == self.__y == 0
if relative_event is not None:
move_x = relative_event.delta_x
move_y = relative_event.delta_y
@@ -177,5 +176,3 @@ class MouseProcess(BaseDeviceProcess):
def __clear_state(self) -> None:
self.__pressed_buttons = 0
self.__x = 0
self.__y = 0

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# Copyright (C) 2023-2025 SilentWind <mofeng654321@hotmail.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 #

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# Copyright (C) 2023-2025 SilentWind <mofeng654321@hotmail.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 #

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# Copyright (C) 2023-2025 SilentWind <mofeng654321@hotmail.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 #

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