Compare commits

...

100 Commits

Author SHA1 Message Date
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
160 changed files with 6228 additions and 2051 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}

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

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 && 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 && 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 && 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/

View File

@@ -1,11 +1,31 @@
#!/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/>. #
# #
# ========================================================================== #
SRCPATH=/mnt/sda1/src
SRCPATH=/mnt/nas/src
BOOTFS=/tmp/bootfs
ROOTFS=/tmp/rootfs
OUTPUTDIR=/mnt/sda1/output
OUTPUTDIR=/mnt/nas/src/output
LOOPDEV=/dev/loop10
DATE=241018
DATE=240303
export LC_ALL=C
write_meta() {
@@ -13,7 +33,7 @@ write_meta() {
}
mount_rootfs() {
mkdir $ROOTFS
mkdir $ROOTFS $SRCPATH/tmp/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
@@ -25,7 +45,10 @@ umount_rootfs() {
sudo umount $ROOTFS/dev
sudo umount $ROOTFS/proc
sudo umount $ROOTFS
sudo losetup -d $LOOPDEV
sudo zerofree $LOOPDEV
sudo losetup -d $LOOPDEV
sudo docker rm to_build_rootfs
sudo rm -rf $SRCPATH/tmp/rootfs/*
}
parpare_dns() {
@@ -37,9 +60,15 @@ parpare_dns() {
--source mirrors.tuna.tsinghua.edu.cn --updata-software false --web-protocol http "
}
delete_armbain_verify(){
sudo chroot --userspec "root:root" $ROOTFS bash -c "echo 'deb http://mirrors.ustc.edu.cn/armbian bullseye main bullseye-utils bullseye-desktop' > /etc/apt/sources.list.d/armbian.list "
}
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
$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 \
$ROOTFS/tmp/wheel/ $ROOTFS/usr/lib/janus/transports/ $ROOTFS/usr/lib/janus/loggers
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
@@ -49,6 +78,17 @@ config_file() {
if [ -f "$SRCPATH/image/$1/rc.local" ]; then
sudo cp $SRCPATH/image/$1/rc.local $ROOTFS/etc/
fi
sudo docker pull --platform linux/$2 registry.cn-hangzhou.aliyuncs.com/silentwind/kvmd-stage-0
sudo docker create --name to_build_rootfs registry.cn-hangzhou.aliyuncs.com/silentwind/kvmd-stage-0
sudo docker export to_build_rootfs | sudo tar -xvf - -C $SRCPATH/tmp/rootfs
sudo cp $SRCPATH/tmp/rootfs/tmp/lib/* $ROOTFS/lib/*-linux-*/
sudo cp $SRCPATH/tmp/rootfs/tmp/ustreamer/ustreamer $SRCPATH/tmp/rootfs/tmp/ustreamer/ustreamer-dump $SRCPATH/tmp/rootfs/usr/bin/janus $ROOTFS/usr/bin/
sudo cp $SRCPATH/tmp/rootfs/tmp/ustreamer/janus/libjanus_ustreamer.so $ROOTFS/usr/lib/ustreamer/janus/
sudo cp $SRCPATH/tmp/rootfs/tmp/wheel/*.whl $ROOTFS/tmp/wheel/
sudo cp $SRCPATH/tmp/rootfs/usr/lib/janus/transports/* $ROOTFS/usr/lib/janus/transports/
sudo mv $ROOTFS/etc/apt/apt.conf.d/50apt-file.conf{,.disabled}
}
pack_img() {
@@ -61,8 +101,15 @@ pack_img() {
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/6.boot.PARTITION.sparse $SRCPATH/tmp/bootfs.img
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
mkdir $BOOTFS
sudo losetup $LOOPDEV $SRCPATH/tmp/bootfs.img || exit -1
sudo mount $LOOPDEV $BOOTFS
sudo cp $SRCPATH/image/onecloud/meson8b-onecloud-fix.dtb $BOOTFS/dtb/meson8b-onecloud.dtb
sudo umount $BOOTFS
sudo losetup -d $LOOPDEV
dd if=/dev/zero of=/tmp/add.img bs=1M count=256 && 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
}
@@ -100,6 +147,21 @@ e900v22c_rootfs() {
}
octopus-flanet_rootfs() {
cp $SRCPATH/image/octopus-flanet/Armbian_24.11.0_amlogic_s912_bookworm_6.1.114_server_2024.11.01.img $SRCPATH/tmp/rootfs.img
mkdir $BOOTFS
sudo losetup --offset $((8192*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img || exit -1
sudo mount $LOOPDEV $BOOTFS
sudo sed -i "s/meson-gxm-octopus-planet.dtb/meson-gxm-khadas-vim2.dtb/g" $BOOTFS/uEnv.txt
sudo umount $BOOTFS
sudo losetup -d $LOOPDEV
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 $((1056768*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
@@ -107,23 +169,19 @@ config_cumebox2_file() {
sudo cp $SRCPATH/image/cumebox2/config.json $ROOTFS/etc/oled/config.json
}
config_octopus-flanet_file() {
sudo cp $SRCPATH/image/octopus-flanet/model_database.conf $ROOTFS/etc/model_database.conf
}
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/* "
&& apt-get update \
&& apt install -y --no-install-recommends libxkbcommon-x11-0 nginx tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim iptables \
curl kmod libmicrohttpd12 libjansson4 libssl3 libsofia-sip-ua0 libglib2.0-0 libopus0 libogg0 libcurl4 libconfig9 python3-pip \
&& apt clean \
&& rm -rf /var/lib/apt/lists/* "
if [ "$3" = "systemd-networkd" ]; then
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
@@ -133,20 +191,11 @@ instal_one-kvm() {
&& 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 "
pip3 install --no-cache-dir --break-system-packages /tmp/wheel/*.whl \
&& pip3 cache purge \
&& rm -r /tmp/wheel "
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 "
#pip3 install --target=/usr/lib/python3/dist-packages --break-system-packages pyfatfs -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
cd /One-KVM \
@@ -158,11 +207,12 @@ instal_one-kvm() {
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 \
&& 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 \
&& 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 '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 \
@@ -172,12 +222,12 @@ instal_one-kvm() {
&& 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 \
&& systemctl enable kvmd kvmd-otg kvmd-nginx kvmd-vnc kvmd-ipmi kvmd-webterm kvmd-janus kvmd-media \
&& systemctl disable nginx \
&& 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 \
curl https://gh.llkk.cc/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 "
@@ -205,6 +255,8 @@ instal_one-kvm() {
&& sed -i 's/device: \/dev\/ttyUSB0//g' /etc/kvmd/override.yaml \
&& sed -i 's/#type: otg/type: otg/g' /etc/kvmd/override.yaml "
fi
sudo chroot --userspec "root:root" $ROOTFS bash -c "df -h"
}
pack_img_onecloud() {
@@ -214,57 +266,103 @@ pack_img_onecloud() {
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
#build function
onecloud() {
onecloud_rootfs
mount_rootfs
config_file "onecloud" "arm"
instal_one-kvm armhf gpio systemd-networkd
write_meta "onecloud"
umount_rootfs
pack_img_onecloud
}
cumebox2() {
cumebox2_rootfs
mount_rootfs
config_file "cumebox2" "aarch64"
config_cumebox2_file
parpare_dns
instal_one-kvm aarch64 video1
write_meta "cumebox2"
umount_rootfs
pack_img "Cumebox2"
}
chainedbox() {
chainedbox_rootfs_and_fix_dtb
mount_rootfs
config_file "chainedbox" "aarch64"
parpare_dns
instal_one-kvm aarch64 video1
write_meta "chainedbox"
umount_rootfs
pack_img "Chainedbox"
}
vm() {
vm_rootfs
mount_rootfs
config_file "vm" "amd64"
parpare_dns
instal_one-kvm x86_64
write_meta "vm"
umount_rootfs
pack_img "Vm"
}
e900v22c() {
e900v22c_rootfs
mount_rootfs
config_file "e900v22c" "aarch64"
instal_one-kvm aarch64 video1
write_meta "e900v22c"
umount_rootfs
pack_img "E900v22c"
}
octopus_flanet() {
octopus-flanet_rootfs
mount_rootfs
config_file "octopus-flanet" "aarch64"
config_octopus-flanet_file
parpare_dns
instal_one-kvm aarch64 video1
write_meta "octopus-flanet"
umount_rootfs
pack_img "Octopus-Flanet"
}
if [ "$1" = "all" ]; then
onecloud
cumebox2
chainedbox
vm
e900v22c
octopus_flanet
else
case $1 in
onecloud)
onecloud
;;
cumebox2)
cumebox2
;;
chainedbox)
chainedbox
;;
vm)
vm
;;
e900v22c)
e900v22c
;;
octopus-flanet)
octopus_flanet
;;
*)
echo "Do no thing."
;;
esac
fi

View File

@@ -1,44 +1,115 @@
#!/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
cat >> /etc/supervisord.conf << EOF
[program:kvmd-webterm]
command=/usr/local/bin/ttyd --interface=/run/kvmd/ttyd.sock --port=0 --writable /bin/bash -c '/etc/kvmd/armbain-motd; bash'
@@ -54,14 +125,14 @@ EOF
fi
if [ "$NOWEBTERMWRITE" == "1" ]; then
sed -i "s/--writable//g" /etc/kvmd/supervisord.conf
sed -i "s/--writable//g" /etc/supervisord.conf
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
cat >> /etc/supervisord.conf << EOF
[program:kvmd-vnc]
command=python -m kvmd.apps.vnc --run
@@ -77,10 +148,10 @@ 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
cat >> /etc/supervisord.conf << EOF
[program:kvmd-ipmi]
command=python -m kvmd.apps.ipmi --run
@@ -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}"
exec supervisord -c /etc/kvmd/supervisord.conf
log_info "One-KVM 配置文件准备完成,正在启动服务..."
exec supervisord -c /etc/supervisord.conf

View File

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

View File

@@ -1,5 +1,5 @@
general: {
debug_level = 4
debug_level = 2
}
nat: {
nice_debug = false

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

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

View File

@@ -20,6 +20,7 @@
# ========================================================================== #
import asyncio
import operator
import functools
import multiprocessing.queues
@@ -64,11 +65,11 @@ def swapped_kvs(dct: dict[_DictKeyT, _DictValueT]) -> dict[_DictValueT, _DictKey
# =====
def clear_queue(q: multiprocessing.queues.Queue) -> None: # pylint: disable=invalid-name
def clear_queue(q: (multiprocessing.queues.Queue | asyncio.Queue)) -> None: # pylint: disable=invalid-name
for _ in range(q.qsize()):
try:
q.get_nowait()
except queue.Empty:
except (queue.Empty, asyncio.QueueEmpty):
break

View File

@@ -55,3 +55,11 @@ G_PROFILE = f"configs/{G_PROFILE_NAME}"
def get_gadget_path(gadget: str, *parts: str) -> str:
return os.path.join(f"{env.SYSFS_PREFIX}/sys/kernel/config/usb_gadget", gadget, *parts)
# =====
def make_inquiry_string(vendor: str, product: str, revision: str) -> str:
# Vendor: 8 ASCII chars
# Product: 16
# Revision: 4
return "%-8.8s%-16.16s%-4.4s" % (vendor, product, revision)

View File

@@ -99,3 +99,11 @@ def check_any(arg: Any, name: str, validators: list[Callable[[Any], Any]]) -> An
except Exception:
pass
raise_error(arg, name)
# =====
def filter_printable(arg: str, replace: str, limit: int) -> str:
return "".join(
(ch if ch.isprintable() else replace)
for ch in arg[:limit]
)

View File

@@ -25,6 +25,7 @@ from typing import Any
from ..keyboard.mappings import KEYMAP
from ..mouse import MouseRange
from ..mouse import MouseDelta
from . import check_string_in_list
@@ -46,7 +47,7 @@ def valid_hid_key(arg: Any) -> str:
def valid_hid_mouse_move(arg: Any) -> int:
arg = valid_number(arg, name="Mouse move")
return min(max(MouseRange.MIN, arg), MouseRange.MAX)
return MouseRange.normalize(arg)
def valid_hid_mouse_button(arg: Any) -> str:
@@ -55,4 +56,4 @@ def valid_hid_mouse_button(arg: Any) -> str:
def valid_hid_mouse_delta(arg: Any) -> int:
arg = valid_number(arg, name="Mouse delta")
return min(max(-127, arg), 127)
return MouseDelta.normalize(arg)

View File

@@ -26,6 +26,7 @@ import stat
from typing import Any
from . import raise_error
from . import filter_printable
from .basic import valid_number
from .basic import valid_string_list
@@ -75,9 +76,7 @@ def valid_abs_dir(arg: Any, name: str="") -> str:
def valid_printable_filename(arg: Any, name: str="") -> str:
if not name:
name = "printable filename"
arg = valid_stripped_string_not_empty(arg, name)
if (
"/" in arg
or "\0" in arg
@@ -85,12 +84,7 @@ def valid_printable_filename(arg: Any, name: str="") -> str:
or arg == "lost+found"
):
raise_error(arg, name)
arg = "".join(
(ch if ch.isprintable() else "_")
for ch in arg[:255]
)
return arg
return filter_printable(arg, "_", 255)
# =====

67
kvmd/validators/switch.py Normal file
View File

@@ -0,0 +1,67 @@
# ========================================================================== #
# #
# 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
from typing import Any
from . import filter_printable
from . import check_re_match
from .basic import valid_stripped_string
from .basic import valid_number
# =====
def valid_switch_port_name(arg: Any) -> str:
arg = valid_stripped_string(arg, name="switch port name")
arg = filter_printable(arg, " ", 255)
arg = re.sub(r"\s+", " ", arg)
return arg.strip()
def valid_switch_edid_id(arg: Any, allow_default: bool) -> str:
pattern = "(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
if allow_default:
pattern += "|^default$"
return check_re_match(arg, "switch EDID ID", pattern).lower()
def valid_switch_edid_data(arg: Any) -> str:
name = "switch EDID data"
arg = valid_stripped_string(arg, name=name)
arg = re.sub(r"\s", "", arg)
return check_re_match(arg, name, "(?i)^[0-9a-f]{512}$").upper()
def valid_switch_color(arg: Any, allow_default: bool) -> str:
pattern = "(?i)^[0-9a-f]{6}:[0-9a-f]{2}:[0-9a-f]{4}$"
if allow_default:
pattern += "|^default$"
arg = check_re_match(arg, "switch color", pattern).upper()
if arg == "DEFAULT":
arg = "default"
return arg
def valid_switch_atx_click_delay(arg: Any) -> float:
return valid_number(arg, min=0, max=10, type=float, name="ATX delay")

View File

@@ -50,10 +50,12 @@ function delete_kvmd_container(){
function check_otg_device(){
$sudo_command modprobe libcomposite > /dev/null|| echo -e "${YELLOW}libcomposite 内核模块加载失败${NC}"
if [[ "$architecture" != "amd64" ]] && [[ -d "/sys/class/udc" ]] && [[ "$(ls -A /sys/class/udc)" ]]; then
otg_devices=$(ls -A /sys/class/udc)
otg_status=$(cat /sys/class/usb_role/*/role 2>/dev/null | head -n 1)
echo -e "${GREEN}当前系统支持 OTG$otg_devices OTG 状态:$otg_status${NC}"
if [[ "$architecture" != "amd64" ]] && [[ -d "/sys/class/udc" ]]; then
if [[ "$(ls -A /sys/class/udc)" ]] || [[ "$(ls -A /sys/class/usb_role)" ]]; then
otg_devices=$(ls -A /sys/class/udc)
otg_status=$(cat /sys/class/usb_role/*/role 2>/dev/null | head -n 1)
echo -e "${GREEN}当前系统支持 OTG$otg_devices OTG 状态:$otg_status${NC}"
fi
else
echo -e "${RED}当前系统不支持 OTG退出程序${NC}"
exit 1

View File

@@ -1,3 +1,23 @@
#!/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/>. #
# #
# ========================================================================== #
cd / && python3 -m kvmd.apps.kvmd "$@"

View File

@@ -256,7 +256,16 @@ if [ -n "$WIFI_ESSID" ]; then
else
make_dhcp_iface "$WIFI_IFACE" 50
fi
wpa_passphrase "$WIFI_ESSID" "$WIFI_PASSWD" > "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf"
if [ "${#WIFI_PASSWD}" -ge 8 ];then
wpa_passphrase "$WIFI_ESSID" "$WIFI_PASSWD" > "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf"
else
cat <<end_of_file > "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf"
network={
ssid=$(printf '"%q"' "$WIFI_ESSID")
key_mgmt=NONE
}
end_of_file
fi
chmod 640 "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf"
if [ -n "$WIFI_HIDDEN" ]; then
sed -i -e 's/^}/\tscan_ssid=1\n}/g' "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf"

View File

@@ -4,6 +4,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 #
@@ -55,7 +56,7 @@ cd "$path"
# - https://msol.io/blog/tech/create-a-self-signed-ecc-certificate
openssl ecparam -out server.key -name prime256v1 -genkey
openssl req -new -x509 -sha256 -nodes -key server.key -out server.crt -days 3650 \
-subj "/C=US/O=PiKVM/OU=PiKVM/CN=localhost"
-subj "/C=CN/O=One-KVM/OU=One-KVM/CN=localhost"
#chown "root:kvmd-$target" "$path"/*
chmod 440 "$path/server.key"

View File

@@ -1,3 +1,23 @@
#!/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/>. #
# #
# ========================================================================== #
cd / && python3 -m kvmd.apps.htpasswd "$@"

23
scripts/kvmd-media Executable file
View File

@@ -0,0 +1,23 @@
#!/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/>. #
# #
# ========================================================================== #
cd / && python3 -m kvmd.apps.media "$@"

View File

@@ -1,3 +1,23 @@
#!/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/>. #
# #
# ========================================================================== #
cd / && python3 -m kvmd.apps.otg "$@"

View File

@@ -1,3 +1,23 @@
#!/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/>. #
# #
# ========================================================================== #
cd / && python3 -m kvmd.apps.otgconf "$@"

View File

@@ -1,3 +1,23 @@
#!/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/>. #
# #
# ========================================================================== #
cd / && python3 -m kvmd.apps.totp "$@"

View File

@@ -1,3 +1,23 @@
#!/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/>. #
# #
# ========================================================================== #
cd / && python3 -m kvmd.apps.vnc "$@"

View File

@@ -4,6 +4,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,39 +21,34 @@
# #
# ========================================================================== #
import textwrap
import setuptools.command.easy_install
from setuptools import setup, find_packages
# =====
class _Template(str):
def __init__(self, text: str) -> None:
self.__text = textwrap.dedent(text).strip()
def __mod__(self, kv: dict) -> str:
kv = {"module_name": kv["ep"].module_name, **kv}
return (self.__text % (kv))
class _ScriptWriter(setuptools.command.easy_install.ScriptWriter):
template = _Template("""
# EASY-INSTALL-ENTRY-SCRIPT: %(spec)r,%(group)r,%(name)r
__requires__ = %(spec)r
from %(module_name)s import main
if __name__ == '__main__':
main()
""")
# =====
def main() -> None:
setuptools.command.easy_install.ScriptWriter = _ScriptWriter
# Define entry points manually with specific import paths
entry_points = {
"console_scripts": [
"kvmd = kvmd.apps.kvmd:main",
"kvmd-media = kvmd.apps.media:main",
"kvmd-pst = kvmd.apps.pst:main",
"kvmd-pstrun = kvmd.apps.pstrun:main",
"kvmd-otg = kvmd.apps.otg:main",
"kvmd-otgnet = kvmd.apps.otgnet:main",
"kvmd-otgmsd = kvmd.apps.otgmsd:main",
"kvmd-otgconf = kvmd.apps.otgconf:main",
"kvmd-htpasswd = kvmd.apps.htpasswd:main",
"kvmd-totp = kvmd.apps.totp:main",
"kvmd-edidconf = kvmd.apps.edidconf:main",
"kvmd-ipmi = kvmd.apps.ipmi:main",
"kvmd-vnc = kvmd.apps.vnc:main",
"kvmd-nginx-mkconf = kvmd.apps.ngxmkconf:main",
"kvmd-janus = kvmd.apps.janus:main",
"kvmd-watchdog = kvmd.apps.watchdog:main",
"kvmd-oled = kvmd.apps.oled:main",
"kvmd-helper-pst-remount = kvmd.helpers.remount:main",
"kvmd-helper-otgmsd-remount = kvmd.helpers.remount:main",
"kvmd-helper-swapfiles = kvmd.helpers.swapfiles:main",
]
}
setup(
name="kvmd",
@@ -83,8 +79,10 @@ def main() -> None:
"kvmd.clients",
"kvmd.apps",
"kvmd.apps.kvmd",
"kvmd.apps.kvmd.switch",
"kvmd.apps.kvmd.info",
"kvmd.apps.kvmd.api",
"kvmd.apps.media",
"kvmd.apps.pst",
"kvmd.apps.pstrun",
"kvmd.apps.otg",
@@ -92,6 +90,7 @@ def main() -> None:
"kvmd.apps.otgnet",
"kvmd.apps.otgmsd",
"kvmd.apps.otgconf",
"kvmd.apps.swctl",
"kvmd.apps.htpasswd",
"kvmd.apps.totp",
"kvmd.apps.edidconf",
@@ -113,34 +112,12 @@ def main() -> None:
"kvmd": ["i18n/zh/LC_MESSAGES/*.mo"],
},
entry_points={
"console_scripts": [
"kvmd = kvmd.apps.kvmd:main",
"kvmd-pst = kvmd.apps.pst:main",
"kvmd-pstrun = kvmd.apps.pstrun:main",
"kvmd-otg = kvmd.apps.otg:main",
"kvmd-otgnet = kvmd.apps.otgnet:main",
"kvmd-otgmsd = kvmd.apps.otgmsd:main",
"kvmd-otgconf = kvmd.apps.otgconf:main",
"kvmd-htpasswd = kvmd.apps.htpasswd:main",
"kvmd-totp = kvmd.apps.totp:main",
"kvmd-edidconf = kvmd.apps.edidconf:main",
"kvmd-ipmi = kvmd.apps.ipmi:main",
"kvmd-vnc = kvmd.apps.vnc:main",
"kvmd-nginx-mkconf = kvmd.apps.ngxmkconf:main",
"kvmd-janus = kvmd.apps.janus:main",
"kvmd-watchdog = kvmd.apps.watchdog:main",
"kvmd-oled = kvmd.apps.oled:main",
"kvmd-helper-pst-remount = kvmd.helpers.remount:main",
"kvmd-helper-otgmsd-remount = kvmd.helpers.remount:main",
"kvmd-helper-swapfiles = kvmd.helpers.swapfiles:main",
],
},
entry_points=entry_points,
classifiers=[
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
"Development Status :: 5 - Production/Stable",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: System :: Systems Administration",
"Operating System :: POSIX :: Linux",
"Intended Audience :: System Administrators",

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