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] [bumpversion]
commit = True commit = True
tag = 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]+))?)? parse = (?P<major>\d+)\.(?P<minor>\d+)(\.(?P<patch>\d+)(\-(?P<release>[a-z]+))?)?
serialize = serialize =
{major}.{minor} {major}.{minor}

16
LICENSE
View File

@@ -1,11 +1,7 @@
GNU GENERAL PUBLIC LICENSE GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007 Version 3, 29 June 2007
<<<<<<< HEAD
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> 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 Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed. 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. GNU General Public License for more details.
You should have received a copy of the GNU General Public License 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 <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. 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, 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. 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 For more information on this, and how to apply and follow the GNU GPL, see
<<<<<<< HEAD
<https://www.gnu.org/licenses/>. <https://www.gnu.org/licenses/>.
=======
<http://www.gnu.org/licenses/>.
>>>>>>> origin/dev
The GNU General Public License does not permit incorporating your program The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with 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 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 Public License instead of this License. But first, please read
<<<<<<< HEAD
<https://www.gnu.org/licenses/why-not-lgpl.html>. <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/*.yaml /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/*passwd /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/*.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 \ && cp /usr/share/kvmd/configs.default/kvmd/main.yaml /etc/kvmd/main.yaml \
&& mkdir -p /etc/kvmd/override.d \ && mkdir -p /etc/kvmd/override.d \
&& cp /src/testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \ && cp /src/testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \
@@ -102,6 +104,7 @@ $(TESTENV_GPIO):
run: testenv $(TESTENV_GPIO) run: testenv $(TESTENV_GPIO)
- $(DOCKER) run --rm --name kvmd \ - $(DOCKER) run --rm --name kvmd \
--ipc=shareable \
--privileged \ --privileged \
--volume `pwd`/testenv/run:/run/kvmd:rw \ --volume `pwd`/testenv/run:/run/kvmd:rw \
--volume `pwd`/testenv:/testenv:ro \ --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/*.yaml /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/*passwd /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/*.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/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \
&& ln -s /testenv/web.css /etc/kvmd/web.css \ && ln -s /testenv/web.css /etc/kvmd/web.css \
&& mkdir -p /etc/kvmd/override.d \ && 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/*.yaml /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/*passwd /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/*.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 \ && cp /usr/share/kvmd/configs.default/kvmd/main.yaml /etc/kvmd/main.yaml \
&& mkdir -p /etc/kvmd/override.d \ && mkdir -p /etc/kvmd/override.d \
&& cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \ && 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/*.yaml /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/*passwd /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/*.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 \ && mkdir -p /etc/kvmd/override.d \
&& cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \ && cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \
&& $(if $(CMD),$(CMD),python -m kvmd.apps.ipmi --run) \ && $(if $(CMD),$(CMD),python -m kvmd.apps.ipmi --run) \
@@ -187,6 +194,7 @@ run-ipmi: testenv
run-vnc: testenv run-vnc: testenv
- $(DOCKER) run --rm --name kvmd-vnc \ - $(DOCKER) run --rm --name kvmd-vnc \
--ipc=container:kvmd \
--volume `pwd`/testenv/run:/run/kvmd:rw \ --volume `pwd`/testenv/run:/run/kvmd:rw \
--volume `pwd`/testenv:/testenv:ro \ --volume `pwd`/testenv:/testenv:ro \
--volume `pwd`/kvmd:/kvmd: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/*.yaml /etc/kvmd \
&& cp /usr/share/kvmd/configs.default/kvmd/*passwd /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/*.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 \ && mkdir -p /etc/kvmd/override.d \
&& cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \ && cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \
&& $(if $(CMD),$(CMD),python -m kvmd.apps.vnc --run) \ && $(if $(CMD),$(CMD),python -m kvmd.apps.vnc --run) \
@@ -271,37 +280,21 @@ clean-all: testenv clean
.PHONY: testenv .PHONY: testenv
run-stage-0: run-stage-0:
$(DOCKER) buildx build -t registry.cn-hangzhou.aliyuncs.com/silentwind/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
$(DOCKER) buildx build -t silentwind0/kvmd-stage-0 \
--allow security.insecure --progress plain \ --allow security.insecure --progress plain \
--platform linux/amd64,linux/arm64,linux/arm/v7 \ --platform linux/amd64,linux/arm64,linux/arm/v7 \
-f build/Dockerfile-stage-0 . \ -f build/Dockerfile-stage-0 . \
--push --push
run-build-dev: run-build-dev:
$(DOCKER) buildx build -t registry.cn-hangzhou.aliyuncs.com/silentwind/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
$(DOCKER) buildx build -t silentwind0/kvmd:dev \
--platform linux/amd64,linux/arm64,linux/arm/v7 \ --platform linux/amd64,linux/arm64,linux/arm/v7 \
--build-arg CACHEBUST=$(date +%s) \ --build-arg CACHEBUST=$(date +%s) \
-f build/Dockerfile . \ -f build/Dockerfile . \
--push --push
run-build-release: run-build-release:
$(DOCKER) buildx build -t registry.cn-hangzhou.aliyuncs.com/silentwind/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) \
-f build/Dockerfile . \
--push
$(DOCKER) buildx build -t silentwind0/kvmd \
--progress plain \ --progress plain \
--platform linux/amd64,linux/arm64,linux/arm/v7 \ --platform linux/amd64,linux/arm64,linux/arm/v7 \
--build-arg CACHEBUST=$(date +%s) \ --build-arg CACHEBUST=$(date +%s) \

View File

@@ -39,15 +39,15 @@ for _variant in "${_variants[@]}"; do
pkgname+=(kvmd-platform-$_platform-$_board) pkgname+=(kvmd-platform-$_platform-$_board)
done done
pkgbase=kvmd pkgbase=kvmd
pkgver=4.20 pkgver=4.49
pkgrel=1 pkgrel=1
pkgdesc="The main PiKVM daemon" pkgdesc="The main PiKVM daemon"
url="https://github.com/pikvm/kvmd" url="https://github.com/pikvm/kvmd"
license=(GPL) license=(GPL)
arch=(any) arch=(any)
depends=( depends=(
"python>=3.12" "python>=3.13"
"python<3.13" "python<3.14"
python-yaml python-yaml
python-aiohttp python-aiohttp
python-aiofiles python-aiofiles
@@ -79,6 +79,7 @@ depends=(
python-mako python-mako
python-luma-oled python-luma-oled
python-pyusb python-pyusb
python-pyudev
"libgpiod>=2.1" "libgpiod>=2.1"
freetype2 freetype2
"v4l-utils>=1.22.1-1" "v4l-utils>=1.22.1-1"
@@ -89,11 +90,11 @@ depends=(
iproute2 iproute2
dnsmasq dnsmasq
ipmitool ipmitool
"janus-gateway-pikvm>=0.14.2-3" "janus-gateway-pikvm>=1.3.0"
certbot certbot
platform-io-access platform-io-access
raspberrypi-utils raspberrypi-utils
"ustreamer>=6.16" "ustreamer>=6.26"
# Systemd UDEV bug # Systemd UDEV bug
"systemd>=248.3-2" "systemd>=248.3-2"
@@ -167,7 +168,7 @@ package_kvmd() {
install -DTm644 configs/os/tmpfiles.conf "$pkgdir/usr/lib/tmpfiles.d/kvmd.conf" install -DTm644 configs/os/tmpfiles.conf "$pkgdir/usr/lib/tmpfiles.d/kvmd.conf"
mkdir -p "$pkgdir/usr/share/kvmd" 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 '{}' \; find "$pkgdir/usr/share/kvmd/web" -name '*.pug' -exec rm -f '{}' \;
local _cfg_default="$pkgdir/usr/share/kvmd/configs.default" local _cfg_default="$pkgdir/usr/share/kvmd/configs.default"
@@ -209,7 +210,7 @@ for _variant in "${_variants[@]}"; do
cd \"kvmd-\$pkgver\" cd \"kvmd-\$pkgver\"
pkgdesc=\"PiKVM platform configs - $_platform for $_board\" 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=( backup=(
etc/sysctl.d/99-kvmd.conf etc/sysctl.d/99-kvmd.conf
@@ -253,8 +254,12 @@ for _variant in "${_variants[@]}"; do
fi fi
if [[ $_platform =~ ^.*-hdmi$ ]]; then 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\" 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 fi
mkdir -p \"\$pkgdir/usr/share/kvmd\" 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" LABEL maintainer="mofeng654321@hotmail.com"
@@ -12,29 +12,59 @@ COPY --from=builder /usr/lib/janus/transports/* /usr/lib/janus/transports/
ARG TARGETARCH ARG TARGETARCH
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1 \
ENV PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1 \
ENV TZ=Asia/Shanghai 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
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/' /etc/apt/sources.list.d/debian.sources \ RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/' /etc/apt/sources.list.d/debian.sources \
&& apt-get update \ && 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 \ && apt-get install -y --no-install-recommends \
libmicrohttpd12 libjansson4 libssl3 libsofia-sip-ua0 libglib2.0-0 libopus0 libogg0 libcurl4 libconfig9 libusrsctp2 libwebsockets17 libnss3 libasound2 nano \ libxkbcommon-x11-0 \
&& rm -rf /var/lib/apt/lists/* nginx \
tesseract-ocr \
RUN if [ ${TARGETARCH} = arm ]; then ARCH=armhf; elif [ ${TARGETARCH} = arm64 ]; then ARCH=aarch64; elif [ ${TARGETARCH} = amd64 ]; then ARCH=x86_64; fi \ 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 \ && 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 \ && chmod +x /usr/local/bin/ttyd \
&& adduser kvmd --gecos "" --disabled-password \ && adduser kvmd --gecos "" --disabled-password \
&& ln -sf /usr/share/tesseract-ocr/*/tessdata /usr/share/tessdata \ && 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 \ && mkdir -p /etc/kvmd_backup/override.d \
&& touch /run/kvmd/ustreamer.sock /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 testenv/fakes/vcgencmd scripts/kvmd* /usr/bin/
COPY extras/ /usr/share/kvmd/extras/ COPY extras/ /usr/share/kvmd/extras/

View File

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

View File

@@ -1,11 +1,31 @@
#!/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/>. #
# #
# ========================================================================== #
SRCPATH=/mnt/sda1/src SRCPATH=/mnt/nas/src
BOOTFS=/tmp/bootfs BOOTFS=/tmp/bootfs
ROOTFS=/tmp/rootfs ROOTFS=/tmp/rootfs
OUTPUTDIR=/mnt/sda1/output OUTPUTDIR=/mnt/nas/src/output
LOOPDEV=/dev/loop10 LOOPDEV=/dev/loop10
DATE=241018 DATE=240303
export LC_ALL=C export LC_ALL=C
write_meta() { write_meta() {
@@ -13,7 +33,7 @@ write_meta() {
} }
mount_rootfs() { mount_rootfs() {
mkdir $ROOTFS mkdir $ROOTFS $SRCPATH/tmp/rootfs
sudo mount $LOOPDEV $ROOTFS || exit -1 sudo mount $LOOPDEV $ROOTFS || exit -1
sudo mount -t proc proc $ROOTFS/proc || exit -1 sudo mount -t proc proc $ROOTFS/proc || exit -1
sudo mount -t sysfs sys $ROOTFS/sys || exit -1 sudo mount -t sysfs sys $ROOTFS/sys || exit -1
@@ -25,7 +45,10 @@ umount_rootfs() {
sudo umount $ROOTFS/dev sudo umount $ROOTFS/dev
sudo umount $ROOTFS/proc sudo umount $ROOTFS/proc
sudo umount $ROOTFS 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() { parpare_dns() {
@@ -37,9 +60,15 @@ parpare_dns() {
--source mirrors.tuna.tsinghua.edu.cn --updata-software false --web-protocol http " --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() { 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 \ 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 rsync -a --exclude={src,.github} . $ROOTFS/One-KVM
sudo cp -r configs/kvmd/* configs/nginx configs/janus $ROOTFS/etc/kvmd sudo cp -r configs/kvmd/* configs/nginx configs/janus $ROOTFS/etc/kvmd
sudo cp -r web extras contrib/keymaps $ROOTFS/usr/share/kvmd sudo cp -r web extras contrib/keymaps $ROOTFS/usr/share/kvmd
@@ -49,6 +78,17 @@ config_file() {
if [ -f "$SRCPATH/image/$1/rc.local" ]; then if [ -f "$SRCPATH/image/$1/rc.local" ]; then
sudo cp $SRCPATH/image/$1/rc.local $ROOTFS/etc/ sudo cp $SRCPATH/image/$1/rc.local $ROOTFS/etc/
fi 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() { pack_img() {
@@ -61,8 +101,15 @@ pack_img() {
onecloud_rootfs() { 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 $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 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 e2fsck -f $SRCPATH/tmp/rootfs.img && resize2fs $SRCPATH/tmp/rootfs.img
sudo losetup $LOOPDEV $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() { config_cumebox2_file() {
sudo mkdir $ROOTFS/etc/oled sudo mkdir $ROOTFS/etc/oled
sudo cp $SRCPATH/image/cumebox2/v-fix.dtb $ROOTFS/boot/dtb/amlogic/meson-gxl-s905x-khadas-vim.dtb sudo cp $SRCPATH/image/cumebox2/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 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() { instal_one-kvm() {
#$1 arch; $2 deivce: "gpio" or "video1"; $3 network: "systemd-networkd",default is network-manager #$1 arch; $2 deivce: "gpio" or "video1"; $3 network: "systemd-networkd",default is network-manager
sudo chroot --userspec "root:root" $ROOTFS bash -c " \ sudo chroot --userspec "root:root" $ROOTFS bash -c " \
df -h \ df -h \
&& apt update \ && apt-get update \
&& apt install -y python3-aiofiles python3-aiohttp python3-appdirs python3-asn1crypto python3-async-timeout \ && apt install -y --no-install-recommends libxkbcommon-x11-0 nginx tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim iptables \
python3-bottle python3-cffi python3-chardet python3-click python3-colorama python3-cryptography python3-dateutil \ curl kmod libmicrohttpd12 libjansson4 libssl3 libsofia-sip-ua0 libglib2.0-0 libopus0 libogg0 libcurl4 libconfig9 python3-pip \
python3-dbus python3-dev python3-hidapi python3-hid python3-idna python3-libgpiod python3-mako python3-marshmallow python3-more-itertools \ && apt clean \
python3-multidict python3-netifaces python3-packaging python3-passlib python3-pillow python3-ply python3-psutil \ && rm -rf /var/lib/apt/lists/* "
python3-pycparser python3-pyelftools python3-pyghmi python3-pygments python3-pyparsing python3-requests \
python3-semantic-version python3-setproctitle python3-setuptools python3-six python3-spidev python3-systemd \
python3-tabulate python3-urllib3 python3-wrapt python3-xlib python3-yaml python3-yarl python3-pyotp python3-qrcode \
python3-serial python3-zstandard python3-dbus-next \
&& apt install -y nginx python3-pip python3-dev python3-build net-tools tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim \
git gpiod libxkbcommon0 build-essential janus-dev libssl-dev libffi-dev libevent-dev libjpeg-dev libbsd-dev libudev-dev \
pkg-config libx264-dev libyuv-dev libasound2-dev libsndfile-dev libspeexdsp-dev cpufrequtils iptables network-manager \
&& rm -rf /var/lib/apt/lists/* "
if [ "$3" = "systemd-networkd" ]; then if [ "$3" = "systemd-networkd" ]; then
sudo chroot --userspec "root:root" $ROOTFS bash -c " \ sudo chroot --userspec "root:root" $ROOTFS bash -c " \
@@ -133,20 +191,11 @@ instal_one-kvm() {
&& systemctl enable systemd-networkd systemd-resolved " && systemctl enable systemd-networkd systemd-resolved "
fi fi
sudo chroot --userspec "root:root" $ROOTFS bash -c " \ 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 --no-cache-dir --break-system-packages /tmp/wheel/*.whl \
&& pip3 install --target=/usr/lib/python3/dist-packages --break-system-packages async-lru gpiod \ && pip3 cache purge \
&& 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 #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 " \
git clone --depth=1 https://github.com/mofeng-git/ustreamer /tmp/ustreamer \
&& make -j WITH_PYTHON=1 WITH_JANUS=1 WITH_LIBX264=1 -C /tmp/ustreamer \
&& mv /tmp/ustreamer/src/ustreamer.bin /usr/bin/ustreamer \
&& mv /tmp/ustreamer/src/ustreamer-dump.bin /usr/bin/ustreamer-dump \
&& chmod +x /usr/bin/ustreamer /usr/bin/ustreamer-dump \
&& mv /tmp/ustreamer/janus/libjanus_ustreamer.so /usr/lib/ustreamer/janus \
&& pip3 install --target=/usr/lib/python3/dist-packages --break-system-packages /tmp/ustreamer/python/dist/*.whl "
sudo chroot --userspec "root:root" $ROOTFS bash -c " \ sudo chroot --userspec "root:root" $ROOTFS bash -c " \
cd /One-KVM \ cd /One-KVM \
@@ -158,11 +207,12 @@ instal_one-kvm() {
sudo chroot --userspec "root:root" $ROOTFS bash -c " \ sudo chroot --userspec "root:root" $ROOTFS bash -c " \
cat /One-KVM/configs/os/sudoers/v2-hdmiusb >> /etc/sudoers \ 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 \ && echo 'libcomposite' >> /etc/modules \
&& mv /usr/local/bin/kvmd* /usr/bin \ && mv /usr/local/bin/kvmd* /usr/bin \
&& cp /One-KVM/configs/os/services/* /etc/systemd/system/ \ && cp /One-KVM/configs/os/services/* /etc/systemd/system/ \
&& cp /One-KVM/configs/os/tmpfiles.conf /usr/lib/tmpfiles.d/ \ && 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/* \ && 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/gpio.sh' >> /etc/sudoers \
&& echo 'kvmd ALL=(ALL) NOPASSWD: /etc/kvmd/custom_atx/usbrelay_hid.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/8080/80/g' /etc/kvmd/override.yaml \
&& sed -i 's/4430/443/g' /etc/kvmd/override.yaml \ && sed -i 's/4430/443/g' /etc/kvmd/override.yaml \
&& chown kvmd -R /var/lib/kvmd/msd/ \ && chown kvmd -R /var/lib/kvmd/msd/ \
&& systemctl enable kvmd kvmd-otg kvmd-nginx kvmd-vnc kvmd-ipmi kvmd-webterm kvmd-janus \ && systemctl enable kvmd kvmd-otg kvmd-nginx kvmd-vnc kvmd-ipmi kvmd-webterm kvmd-janus kvmd-media \
&& systemctl disable nginx janus \ && systemctl disable nginx \
&& rm -r /One-KVM " && rm -r /One-KVM "
sudo chroot --userspec "root:root" $ROOTFS bash -c " \ 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 \ && chmod +x /usr/bin/ttyd \
&& mkdir -p /home/kvmd-webterm \ && mkdir -p /home/kvmd-webterm \
&& chown kvmd-webterm /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/device: \/dev\/ttyUSB0//g' /etc/kvmd/override.yaml \
&& sed -i 's/#type: otg/type: otg/g' /etc/kvmd/override.yaml " && sed -i 's/#type: otg/type: otg/g' /etc/kvmd/override.yaml "
fi fi
sudo chroot --userspec "root:root" $ROOTFS bash -c "df -h"
} }
pack_img_onecloud() { pack_img_onecloud() {
@@ -214,57 +266,103 @@ pack_img_onecloud() {
sudo rm $SRCPATH/tmp/* sudo rm $SRCPATH/tmp/*
} }
case $1 in #build function
onecloud)
onecloud_rootfs onecloud() {
mount_rootfs onecloud_rootfs
config_file $1 mount_rootfs
instal_one-kvm armhf gpio systemd-networkd config_file "onecloud" "arm"
write_meta $1 instal_one-kvm armhf gpio systemd-networkd
umount_rootfs write_meta "onecloud"
pack_img_onecloud umount_rootfs
;; pack_img_onecloud
cumebox2) }
cumebox2_rootfs
mount_rootfs cumebox2() {
config_file $1 cumebox2_rootfs
config_cumebox2_file mount_rootfs
parpare_dns config_file "cumebox2" "aarch64"
instal_one-kvm aarch64 video1 config_cumebox2_file
write_meta $1 parpare_dns
umount_rootfs instal_one-kvm aarch64 video1
pack_img Cumebox2 write_meta "cumebox2"
;; umount_rootfs
chainedbox) pack_img "Cumebox2"
chainedbox_rootfs_and_fix_dtb }
mount_rootfs
config_file $1 chainedbox() {
parpare_dns chainedbox_rootfs_and_fix_dtb
instal_one-kvm aarch64 video1 mount_rootfs
write_meta $1 config_file "chainedbox" "aarch64"
umount_rootfs parpare_dns
pack_img Chainedbox instal_one-kvm aarch64 video1
;; write_meta "chainedbox"
vm) umount_rootfs
vm_rootfs pack_img "Chainedbox"
mount_rootfs }
config_file $1
parpare_dns vm() {
instal_one-kvm x86_64 vm_rootfs
write_meta $1 mount_rootfs
umount_rootfs config_file "vm" "amd64"
pack_img Vm parpare_dns
;; instal_one-kvm x86_64
e900v22c) write_meta "vm"
e900v22c_rootfs umount_rootfs
mount_rootfs pack_img "Vm"
config_file $1 }
instal_one-kvm aarch64 video1
write_meta $1 e900v22c() {
umount_rootfs e900v22c_rootfs
pack_img E900v22c mount_rootfs
;; config_file "e900v22c" "aarch64"
*) instal_one-kvm aarch64 video1
echo "Do no thing." write_meta "e900v22c"
;; umount_rootfs
esac 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 #!/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' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
YELLOW='\033[0;33m' YELLOW='\033[0;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
NC='\033[0m' 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 if [ ! -f /etc/kvmd/.init_flag ]; then
echo -e "${GREEN}One-KVM is initializing first...${NC}" \ log_info "首次初始化配置..."
&& mkdir -p /etc/kvmd/ \
&& mv /etc/kvmd_backup/* /etc/kvmd/ \ # 创建必要目录并移动配置文件
&& touch /etc/kvmd/.docker_flag \ if mkdir -p /etc/kvmd/ && \
&& sed -i 's/localhost.localdomain/docker/g' /etc/kvmd/meta.yaml \ mv /etc/kvmd_backup/* /etc/kvmd/ && \
&& sed -i 's/localhost/localhost:4430/g' /etc/kvmd/kvm_input.sh \ touch /etc/kvmd/.docker_flag && \
&& /usr/share/kvmd/kvmd-gencert --do-the-thing \ sed -i 's/localhost.localdomain/docker/g' /etc/kvmd/meta.yaml && \
&& /usr/share/kvmd/kvmd-gencert --do-the-thing --vnc \ sed -i 's/localhost/localhost:4430/g' /etc/kvmd/kvm_input.sh; then
|| echo -e "${RED}One-KVM config moving and self-signed SSL certificates init failed.${NC}" log_info "移动配置文件完成"
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}"
else else
python -m kvmd.apps.ngxmkconf /etc/kvmd/nginx/nginx.conf.mako /etc/kvmd/nginx/nginx.conf \ log_error "移动配置文件失败"
|| echo -e "${RED}One-KVM nginx config init failed.${NC}" exit 1
fi 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 if [ "$NOAUTH" == "1" ]; then
sed -i "s/enabled: true/enabled: false/g" /etc/kvmd/override.yaml \ sed -i "s/enabled: true/enabled: false/g" /etc/kvmd/override.yaml
&& echo -e "${GREEN}One-KVM auth is disabled.${NC}" log_info "已禁用认证"
fi fi
#add supervisord conf #add supervisord conf
if [ "$NOWEBTERM" == "1" ]; then if [ "$NOWEBTERM" == "1" ]; then
echo -e "${GREEN}One-KVM webterm is disabled.${NC}" log_info "已禁用 WebTerm 功能"
rm -r /usr/share/kvmd/extras/webterm rm -r /usr/share/kvmd/extras/webterm
else else
cat >> /etc/kvmd/supervisord.conf << EOF cat >> /etc/supervisord.conf << EOF
[program:kvmd-webterm] [program:kvmd-webterm]
command=/usr/local/bin/ttyd --interface=/run/kvmd/ttyd.sock --port=0 --writable /bin/bash -c '/etc/kvmd/armbain-motd; bash' 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 fi
if [ "$NOWEBTERMWRITE" == "1" ]; then if [ "$NOWEBTERMWRITE" == "1" ]; then
sed -i "s/--writable//g" /etc/kvmd/supervisord.conf sed -i "s/--writable//g" /etc/supervisord.conf
fi fi
if [ "$NOVNC" == "1" ]; then if [ "$NOVNC" == "1" ]; then
echo -e "${GREEN}One-KVM VNC is disabled.${NC}" log_info "已禁用 VNC 功能"
rm -r /usr/share/kvmd/extras/vnc rm -r /usr/share/kvmd/extras/vnc
else else
cat >> /etc/kvmd/supervisord.conf << EOF cat >> /etc/supervisord.conf << EOF
[program:kvmd-vnc] [program:kvmd-vnc]
command=python -m kvmd.apps.vnc --run command=python -m kvmd.apps.vnc --run
@@ -77,10 +148,10 @@ EOF
fi fi
if [ "$NOIPMI" == "1" ]; then if [ "$NOIPMI" == "1" ]; then
echo -e "${GREEN}One-KVM IPMI is disabled.${NC}" log_info "已禁用IPMI功能"
rm -r /usr/share/kvmd/extras/ipmi rm -r /usr/share/kvmd/extras/ipmi
else else
cat >> /etc/kvmd/supervisord.conf << EOF cat >> /etc/supervisord.conf << EOF
[program:kvmd-ipmi] [program:kvmd-ipmi]
command=python -m kvmd.apps.ipmi --run command=python -m kvmd.apps.ipmi --run
@@ -97,70 +168,77 @@ EOF
#switch OTG config #switch OTG config
if [ "$OTG" == "1" ]; then 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/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 if [ "$NOMSD" == 1 ]; then
echo -e "${GREEN}One-KVM MSD is disabled.${NC}" log_info "已禁用 MSD 功能"
else else
sed -i "s/#type: otg/type: otg/g" /etc/kvmd/override.yaml sed -i "s/#type: otg/type: otg/g" /etc/kvmd/override.yaml
fi fi
fi fi
#if [ ! -z "$SHUTDOWNPIN" ! -z "$REBOOTPIN" ]; then
if [ ! -z "$VIDEONUM" ]; then if [ ! -z "$VIDEONUM" ]; then
sed -i "s/\/dev\/video0/\/dev\/video$VIDEONUM/g" /etc/kvmd/override.yaml \ 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 \ sed -i "s/\/dev\/video0/\/dev\/video$VIDEONUM/g" /etc/kvmd/janus/janus.plugin.ustreamer.jcfg; then
&& echo -e "${GREEN}One-KVM video device is set to /dev/video$VIDEONUM.${NC}" log_info "视频设备已设置为 /dev/video$VIDEONUM"
fi
fi fi
if [ ! -z "$AUDIONUM" ]; then if [ ! -z "$AUDIONUM" ]; then
sed -i "s/hw:0/hw:$AUDIONUM/g" /etc/kvmd/janus/janus.plugin.ustreamer.jcfg \ if sed -i "s/hw:0/hw:$AUDIONUM/g" /etc/kvmd/janus/janus.plugin.ustreamer.jcfg; then
&& echo -e "${GREEN}One-KVM audio device is set to hw:$VIDEONUM.${NC}" log_info "音频设备已设置为 hw:$AUDIONUM"
fi
fi fi
if [ ! -z "$CH9329SPEED" ]; then if [ ! -z "$CH9329SPEED" ]; then
sed -i "s/speed: 9600/speed: $CH9329SPEED/g" /etc/kvmd/override.yaml \ if sed -i "s/speed: 9600/speed: $CH9329SPEED/g" /etc/kvmd/override.yaml; then
&& echo -e "${GREEN}One-KVM CH9329 serial speed is set to $CH9329SPEED.${NC}" log_info "CH9329 串口速率已设置为 $CH9329SPEED"
fi
fi fi
if [ ! -z "$CH9329TIMEOUT" ]; then if [ ! -z "$CH9329TIMEOUT" ]; then
sed -i "s/read_timeout: 0.3/read_timeout: $CH9329TIMEOUT/g" /etc/kvmd/override.yaml \ if sed -i "s/read_timeout: 0.3/read_timeout: $CH9329TIMEOUT/g" /etc/kvmd/override.yaml; then
&& echo -e "${GREEN}One-KVM CH9329 timeout is set to $CH9329TIMEOUT s.${NC}" log_info "CH9329 超时已设置为 $CH9329TIMEOUT "
fi
fi fi
#set htpasswd if [ ! -z "$H264PRESET" ]; then
if [ ! -z "$USERNAME" ] && [ ! -z "$PASSWORD" ]; then if sed -i "s/ultrafast/$H264PRESET/g" /etc/kvmd/override.yaml; then
python -m kvmd.apps.htpasswd del admin \ log_info "H264 预设已设置为 $H264PRESET"
&& echo $PASSWORD | python -m kvmd.apps.htpasswd set -i "$USERNAME" \ fi
&& 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}"
fi fi
if [ ! -z "$VIDEOFORMAT" ]; then if [ ! -z "$VIDEOFORMAT" ]; then
sed -i "s/format=mjpeg/format=$VIDFORMAT/g" /etc/kvmd/override.yaml \ if sed -i "s/format=mjpeg/format=$VIDFORMAT/g" /etc/kvmd/override.yaml; then
&& echo -e "${GREEN}One-KVM input video format is set to $VIDFORMAT.${NC}" log_info "视频输入格式已设置为 $VIDFORMAT"
fi
fi fi
touch /etc/kvmd/.init_flag touch /etc/kvmd/.init_flag
log_info "初始化配置完成"
fi fi
# OTG设备配置
#Trying usb_gadget
if [ "$OTG" == "1" ]; then if [ "$OTG" == "1" ]; then
echo "Trying OTG Port..." log_info "正在配置 OTG 设备..."
rm -r /run/kvmd/otg &> /dev/null rm -r /run/kvmd/otg &> /dev/null
modprobe libcomposite || echo -e "${RED}Linux libcomposite module modprobe failed.${NC}"
python -m kvmd.apps.otg start \ if ! modprobe libcomposite; then
&& ln -s /dev/hidg1 /dev/kvmd-hid-mouse \ log_error "加载 libcomposite 模块失败"
&& ln -s /dev/hidg0 /dev/kvmd-hid-keyboard \ exit 1
|| echo -e "${RED}OTG Port mount failed.${NC}" fi
ln -s /dev/hidg2 /dev/kvmd-hid-mouse-alt
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 fi
echo -e "${GREEN}One-KVM starting...${NC}" log_info "One-KVM 配置文件准备完成,正在启动服务..."
exec supervisord -c /etc/kvmd/supervisord.conf 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: { general: {
debug_level = 4 debug_level = 2
} }
nat: { nat: {
nice_debug = false 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/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 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 * One-KVMhttps://github.com/mofeng-git/One-KVM

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/>. #
# #
# ========================================================================== #
echo $ATX echo $ATX
case $ATX in case $ATX in

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/>. #
# #
# ========================================================================== #
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'

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/>. #
# #
# ========================================================================== #
case $1 in case $1 in
short) short)
gpioset -m time -s 1 SHUTDOWNPIN=0 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 sys
import hid import hid

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/>. #
# #
# ========================================================================== #
case $1 in case $1 in
short) short)
python3 /etc/kvmd/custom_atx/usbrelay_hid.py 1 on python3 /etc/kvmd/custom_atx/usbrelay_hid.py 1 on

View File

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

View File

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

View File

@@ -66,6 +66,7 @@ kvmd:
- "--jpeg-sink-mode=0660" - "--jpeg-sink-mode=0660"
- "--h264-bitrate={h264_bitrate}" - "--h264-bitrate={h264_bitrate}"
- "--h264-gop={h264_gop}" - "--h264-gop={h264_gop}"
- "--h264-preset=ultrafast"
- "--slowdown" - "--slowdown"
gpio: gpio:
drivers: drivers:
@@ -149,6 +150,18 @@ vnc:
h264: h264:
sink: "kvmd::ustreamer::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: otgnet:
commands: commands:
post_start_cmd: post_start_cmd:

View File

@@ -32,6 +32,16 @@ stdout_logfile=/dev/stdout
stdout_logfile_maxbytes = 0 stdout_logfile_maxbytes = 0
redirect_stderr=true 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] [program:kvmd-nginx]
command=nginx -c /etc/kvmd/nginx/nginx.conf -g 'daemon off;user root; error_log stderr;' command=nginx -c /etc/kvmd/nginx/nginx.conf -g 'daemon off;user root; error_log stderr;'
autostart=true 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 - -
g kvmd-media - -
g kvmd-pst - - g kvmd-pst - -
g kvmd-ipmi - - g kvmd-ipmi - -
g kvmd-vnc - - g kvmd-vnc - -
@@ -7,6 +8,7 @@ g kvmd-janus - -
g kvmd-certbot - - g kvmd-certbot - -
u kvmd - "PiKVM - The main daemon" - u kvmd - "PiKVM - The main daemon" -
u kvmd-media - "PiKVM - The media proxy"
u kvmd-pst - "PiKVM - Persistent storage" - u kvmd-pst - "PiKVM - Persistent storage" -
u kvmd-ipmi - "PiKVM - IPMI to KVMD proxy" - u kvmd-ipmi - "PiKVM - IPMI to KVMD proxy" -
u kvmd-vnc - "PiKVM - VNC to KVMD/Streamer proxy" - u kvmd-vnc - "PiKVM - VNC to KVMD/Streamer proxy" -
@@ -19,8 +21,11 @@ m kvmd gpio
m kvmd uucp m kvmd uucp
m kvmd spi m kvmd spi
m kvmd systemd-journal m kvmd systemd-journal
m kvmd kvmd-media
m kvmd kvmd-pst m kvmd kvmd-pst
m kvmd-media kvmd
m kvmd-pst kvmd m kvmd-pst kvmd
m kvmd-ipmi kvmd m kvmd-ipmi kvmd
@@ -32,6 +37,7 @@ m kvmd-janus kvmd
m kvmd-janus audio m kvmd-janus audio
m kvmd-nginx kvmd m kvmd-nginx kvmd
m kvmd-nginx kvmd-media
m kvmd-nginx kvmd-janus m kvmd-nginx kvmd-janus
m kvmd-nginx kvmd-certbot m kvmd-nginx kvmd-certbot

View File

@@ -1,3 +1,4 @@
# Here are described some bindings for PiKVM devices. # Here are described some bindings for PiKVM devices.
# Do not edit this file. # 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}=="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: .tinyusb:
$(call libdep,tinyusb,hathach/tinyusb,d713571cd44f05d2fc72efc09c670787b74106e0) $(call libdep,tinyusb,hathach/tinyusb,d713571cd44f05d2fc72efc09c670787b74106e0)
.ps2x2pico: .ps2x2pico:
$(call libdep,ps2x2pico,No0ne/ps2x2pico,404aaf02949d5bee8013e3b5d0b3239abf6e13bd) $(call libdep,ps2x2pico,No0ne/ps2x2pico,26ce89d597e598bb0ac636622e064202d91a9efc)
deps: .pico-sdk .tinyusb .ps2x2pico deps: .pico-sdk .tinyusb .ps2x2pico

View File

@@ -19,7 +19,7 @@ target_sources(${target_name} PRIVATE
${PS2_PATH}/ps2in.c ${PS2_PATH}/ps2in.c
${PS2_PATH}/ps2kb.c ${PS2_PATH}/ps2kb.c
${PS2_PATH}/ps2ms.c ${PS2_PATH}/ps2ms.c
${PS2_PATH}/scancodesets.c ${PS2_PATH}/scancodes.c
) )
target_link_options(${target_name} PRIVATE -Xlinker --print-memory-usage) target_link_options(${target_name} PRIVATE -Xlinker --print-memory-usage)
target_compile_options(${target_name} PRIVATE -Wall -Wextra) 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 u8 _mouse_buttons = 0;
static s16 _mouse_abs_x = 0; static s16 _mouse_abs_x = 0;
static s16 _mouse_abs_y = 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); static void _kbd_sync_report(bool new);
@@ -193,7 +193,7 @@ void ph_usb_send_clear(void) {
if (PH_O_IS_MOUSE_USB) { if (PH_O_IS_MOUSE_USB) {
_MOUSE_CLEAR; _MOUSE_CLEAR;
if (PH_O_IS_MOUSE_USB_ABS) { 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 } else { // PH_O_IS_MOUSE_USB_REL
_mouse_rel_send_report(0, 0, 0, 0); _mouse_rel_send_report(0, 0, 0, 0);
} }

View File

@@ -102,6 +102,16 @@ EOF
touch -t 200701011000 /etc/fstab touch -t 200701011000 /etc/fstab
fi 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 # Some update deletes /etc/motd, WTF
# shellcheck disable=SC2015,SC2166 # shellcheck disable=SC2015,SC2166
[ ! -f /etc/motd -a -f /etc/motd.pacsave ] && mv /etc/motd.pacsave /etc/motd || true [ ! -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()) 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: def run(coro: Coroutine, final: (Coroutine | None)=None) -> None:
# https://github.com/aio-libs/aiohttp/blob/a1d4dac1d/aiohttp/web.py#L515 # 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: except asyncio.CancelledError:
pass pass
except Exception: 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() pid = os.getpid()
if pid == 1: if pid == 1:
os._exit(1) # Docker workaround # pylint: disable=protected-access 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), "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": { "pst": {
@@ -532,11 +563,12 @@ def _get_config_scheme() -> dict:
"device_version": Option(-1, type=functools.partial(valid_number, min=-1, max=0xFFFF)), "device_version": Option(-1, type=functools.partial(valid_number, min=-1, max=0xFFFF)),
"usb_version": Option(0x0200, type=valid_otg_id), "usb_version": Option(0x0200, type=valid_otg_id),
"max_power": Option(250, type=functools.partial(valid_number, min=50, max=500)), "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), "gadget": Option("kvmd", type=valid_otg_gadget),
"config": Option("PiKVM device", type=valid_stripped_string_not_empty), "config": Option("PiKVM device", type=valid_stripped_string_not_empty),
"udc": Option("", type=valid_stripped_string), "udc": Option("", type=valid_stripped_string),
"endpoints": Option(9, type=valid_int_f0),
"init_delay": Option(3.0, type=valid_float_f01), "init_delay": Option(3.0, type=valid_float_f01),
"user": Option("kvmd", type=valid_user), "user": Option("kvmd", type=valid_user),
@@ -550,6 +582,9 @@ def _get_config_scheme() -> dict:
"mouse": { "mouse": {
"start": Option(True, type=valid_bool), "start": Option(True, type=valid_bool),
}, },
"mouse_alt": {
"start": Option(True, type=valid_bool),
},
}, },
"msd": { "msd": {
@@ -560,6 +595,18 @@ def _get_config_scheme() -> dict:
"rw": Option(False, type=valid_bool), "rw": Option(False, type=valid_bool),
"removable": Option(True, type=valid_bool), "removable": Option(True, type=valid_bool),
"fua": 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=""), "kvm_mac": Option("", type=valid_mac, if_empty=""),
}, },
"audio": {
"enabled": Option(False, type=valid_bool),
"start": Option(True, type=valid_bool),
},
"drives": { "drives": {
"enabled": Option(False, type=valid_bool), "enabled": Option(False, type=valid_bool),
"start": Option(True, type=valid_bool), "start": Option(True, type=valid_bool),
@@ -586,6 +638,18 @@ def _get_config_scheme() -> dict:
"rw": Option(True, type=valid_bool), "rw": Option(True, type=valid_bool),
"removable": Option(True, type=valid_bool), "removable": Option(True, type=valid_bool),
"fua": 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 .streamer import Streamer
from .snapshoter import Snapshoter from .snapshoter import Snapshoter
from .ocr import Ocr from .ocr import Ocr
from .switch import Switch
from .server import KvmdServer 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), log_reader=(LogReader() if config.log_reader.enabled else None),
user_gpio=UserGpio(config.gpio, global_config.otg), user_gpio=UserGpio(config.gpio, global_config.otg),
ocr=Ocr(**config.ocr._unpack()), ocr=Ocr(**config.ocr._unpack()),
switch=Switch(
pst_unix_path=global_config.pst.server.unix,
**config.switch._unpack(),
),
hid=hid, hid=hid,
atx=get_atx_class(config.atx.type)(**config.atx._unpack(ignore=["type"])), 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 self.__append_prometheus_rows(rows, atx_state["leds"]["power"], "pikvm_atx_power") # type: ignore
for mode in sorted(UserGpioModes.ALL): 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 if not channel.startswith("__"): # Hide special GPIOs
for key in ["online", "state"]: for key in ["online", "state"]:
self.__append_prometheus_rows(rows, ch_state["state"], f"pikvm_gpio_{mode}_{key}_{channel}") 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: if limit > 0:
text = text[:limit] text = text[:limit]
symmap = self.__ensure_symmap(req.query.get("keymap", self.__default_keymap_name)) 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() return make_json_response()
def __ensure_symmap(self, keymap_name: str) -> dict[int, dict[int, str]]: 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: async def __ws_bin_key_handler(self, _: WsSession, data: bytes) -> None:
try: try:
key = valid_hid_key(data[1:].decode("ascii")) 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: except Exception:
return return
self.__hid.send_key_event(key, state) self.__hid.send_key_event(key, state, finish)
@exposed_ws(2) @exposed_ws(2)
async def __ws_bin_mouse_button_handler(self, _: WsSession, data: bytes) -> None: async def __ws_bin_mouse_button_handler(self, _: WsSession, data: bytes) -> None:
try: try:
button = valid_hid_mouse_button(data[1:].decode("ascii")) button = valid_hid_mouse_button(data[1:].decode("ascii"))
state = valid_bool(data[0]) state = bool(data[0] & 0b01)
except Exception: except Exception:
return return
self.__hid.send_mouse_button_event(button, state) 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: def __process_ws_bin_delta_request(self, data: bytes, handler: Callable[[Iterable[tuple[int, int]], bool], None]) -> None:
try: try:
squash = valid_bool(data[0]) squash = bool(data[0] & 0b01)
data = data[1:] data = data[1:]
deltas: list[tuple[int, int]] = [] deltas: list[tuple[int, int]] = []
for index in range(0, len(data), 2): for index in range(0, len(data), 2):
@@ -199,9 +201,10 @@ class HidApi:
try: try:
key = valid_hid_key(event["key"]) key = valid_hid_key(event["key"])
state = valid_bool(event["state"]) state = valid_bool(event["state"])
finish = valid_bool(event.get("finish", False))
except Exception: except Exception:
return return
self.__hid.send_key_event(key, state) self.__hid.send_key_event(key, state, finish)
@exposed_ws("mouse_button") @exposed_ws("mouse_button")
async def __ws_mouse_button_handler(self, _: WsSession, event: dict) -> None: 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")) key = valid_hid_key(req.query.get("key"))
if "state" in req.query: if "state" in req.query:
state = valid_bool(req.query["state"]) 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: else:
self.__hid.send_key_events([(key, True), (key, False)]) self.__hid.send_key_event(key, True, True)
return make_json_response() return make_json_response()
@exposed_http("POST", "/hid/events/send_mouse_button") @exposed_http("POST", "/hid/events/send_mouse_button")

View File

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

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. # # KVMD - The main PiKVM daemon. #
# # # #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> # # 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 # # 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 # # it under the terms of the GNU General Public License as published by #
@@ -63,11 +64,7 @@ class MsdApi:
@exposed_http("GET", "/msd") @exposed_http("GET", "/msd")
async def __state_handler(self, _: Request) -> Response: async def __state_handler(self, _: Request) -> Response:
state = await self.__msd.get_state() return make_json_response(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)
@exposed_http("POST", "/msd/set_params") @exposed_http("POST", "/msd/set_params")
async def __set_params_handler(self, req: Request) -> Response: 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() secret = file.read().strip()
if secret: if secret:
code = passwd[-6:] 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) get_logger().error("Got access denied for user %r by TOTP", user)
return False return False
passwd = passwd[:-6] passwd = passwd[:-6]

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. # # KVMD - The main PiKVM daemon. #
# # # #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> # # 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 # # 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 # # it under the terms of the GNU General Public License as published by #

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. # # KVMD - The main PiKVM daemon. #
# # # #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> # # 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 # # 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 # # 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 .streamer import Streamer
from .snapshoter import Snapshoter from .snapshoter import Snapshoter
from .ocr import Ocr from .ocr import Ocr
from .switch import Switch
from .api.auth import AuthApi from .api.auth import AuthApi
from .api.auth import check_request_auth from .api.auth import check_request_auth
@@ -77,6 +78,7 @@ from .api.hid import HidApi
from .api.atx import AtxApi from .api.atx import AtxApi
from .api.msd import MsdApi from .api.msd import MsdApi
from .api.streamer import StreamerApi from .api.streamer import StreamerApi
from .api.switch import SwitchApi
from .api.export import ExportApi from .api.export import ExportApi
from .api.redfish import RedfishApi from .api.redfish import RedfishApi
@@ -125,18 +127,19 @@ class _Subsystem:
cleanup=getattr(obj, "cleanup", None), cleanup=getattr(obj, "cleanup", None),
trigger_state=getattr(obj, "trigger_state", None), trigger_state=getattr(obj, "trigger_state", None),
poll_state=getattr(obj, "poll_state", None), poll_state=getattr(obj, "poll_state", None),
) )
class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-instance-attributes class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-instance-attributes
__EV_GPIO_STATE = "gpio_state" __EV_GPIO_STATE = "gpio"
__EV_HID_STATE = "hid_state" __EV_HID_STATE = "hid"
__EV_ATX_STATE = "atx_state" __EV_HID_KEYMAPS_STATE = "hid_keymaps" # FIXME
__EV_MSD_STATE = "msd_state" __EV_ATX_STATE = "atx"
__EV_STREAMER_STATE = "streamer_state" __EV_MSD_STATE = "msd"
__EV_OCR_STATE = "ocr_state" __EV_STREAMER_STATE = "streamer"
__EV_INFO_STATE = "info_state" __EV_OCR_STATE = "ocr"
__EV_INFO_STATE = "info"
__EV_SWITCH_STATE = "switch"
def __init__( # pylint: disable=too-many-arguments,too-many-locals def __init__( # pylint: disable=too-many-arguments,too-many-locals
self, self,
@@ -145,6 +148,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
log_reader: (LogReader | None), log_reader: (LogReader | None),
user_gpio: UserGpio, user_gpio: UserGpio,
ocr: Ocr, ocr: Ocr,
switch: Switch,
hid: BaseHid, hid: BaseHid,
atx: BaseAtx, atx: BaseAtx,
@@ -177,6 +181,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
AtxApi(atx), AtxApi(atx),
MsdApi(msd), MsdApi(msd),
StreamerApi(streamer, ocr), StreamerApi(streamer, ocr),
SwitchApi(switch),
ExportApi(info_manager, atx, user_gpio), ExportApi(info_manager, atx, user_gpio),
RedfishApi(info_manager, atx), 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(streamer, "Streamer", self.__EV_STREAMER_STATE),
_Subsystem.make(ocr, "OCR", self.__EV_OCR_STATE), _Subsystem.make(ocr, "OCR", self.__EV_OCR_STATE),
_Subsystem.make(info_manager, "Info manager", self.__EV_INFO_STATE), _Subsystem.make(info_manager, "Info manager", self.__EV_INFO_STATE),
_Subsystem.make(switch, "Switch", self.__EV_SWITCH_STATE),
] ]
self.__streamer_notifier = aiotools.AioNotifier() self.__streamer_notifier = aiotools.AioNotifier()
@@ -229,8 +235,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
@exposed_http("GET", "/ws") @exposed_http("GET", "/ws")
async def __ws_handler(self, req: Request) -> WebSocketResponse: async def __ws_handler(self, req: Request) -> WebSocketResponse:
stream = valid_bool(req.query.get("stream", True)) stream = valid_bool(req.query.get("stream", True))
legacy = valid_bool(req.query.get("legacy", True)) async with self._ws_session(req, stream=stream) as ws:
async with self._ws_session(req, stream=stream, legacy=legacy) as ws:
(major, minor) = __version__.split(".") (major, minor) = __version__.split(".")
await ws.send_event("loop", { await ws.send_event("loop", {
"version": { "version": {
@@ -242,7 +247,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
if sub.event_type: if sub.event_type:
assert sub.trigger_state assert sub.trigger_state
await 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)) return (await self._ws_loop(ws))
@exposed_ws("ping") @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.exception("Cleanup error on %s", sub.name)
logger.info("On-Cleanup complete") logger.info("On-Cleanup complete")
async def _on_ws_opened(self) -> None: async def _on_ws_opened(self, _: WsSession) -> None:
self.__streamer_notifier.notify() self.__streamer_notifier.notify()
async def _on_ws_closed(self) -> None: async def _on_ws_closed(self, _: WsSession) -> None:
self.__hid.clear_events() self.__hid.clear_events()
self.__streamer_notifier.notify() 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: 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: async for state in poller:
await self._broadcast_ws_event(self.__EV_GPIO_STATE, state, legacy=False) await self._broadcast_ws_event(event_type, state)
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)

View File

@@ -123,10 +123,10 @@ class Snapshoter: # pylint: disable=too-many-instance-attributes
if self.__wakeup_key: if self.__wakeup_key:
logger.info("Waking up using key %r ...", self.__wakeup_key) logger.info("Waking up using key %r ...", self.__wakeup_key)
self.__hid.send_key_events([ await self.__hid.send_key_events(
(self.__wakeup_key, True), keys=[(self.__wakeup_key, True), (self.__wakeup_key, False)],
(self.__wakeup_key, False), no_ignore_keys=True,
]) )
if self.__wakeup_move: if self.__wakeup_move:
logger.info("Waking up using mouse move for %d units ...", 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: def __make_item_input(self, parts: list[str]) -> dict:
assert len(parts) >= 1 assert len(parts) >= 1
color = (parts[1] if len(parts) > 1 else None) 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" color = "green"
return { return {
"type": UserGpioModes.INPUT, "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. # # KVMD - The main PiKVM daemon. #
# # # #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> # # 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 # # 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 # # 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: def _mkdir(path: str) -> None:
get_logger().info("MKDIR --- %s", path) 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: def _chown(path: str, user: str, optional: bool=False) -> None:
@@ -106,31 +108,45 @@ def _check_config(config: Section) -> None:
# ===== # =====
class _GadgetConfig: 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.__gadget_path = gadget_path
self.__profile_path = profile_path self.__profile_path = profile_path
self.__meta_path = meta_path self.__meta_path = meta_path
self.__eps_max = eps
self.__eps_used = 0
self.__hid_instance = 0 self.__hid_instance = 0
self.__msd_instance = 0 self.__msd_instance = 0
_mkdir(meta_path) _mkdir(meta_path)
def add_serial(self, start: bool) -> None: def add_audio_mic(self, start: bool) -> None:
func = "acm.usb0" eps = 2
func_path = join(self.__gadget_path, "functions", func) func = "uac2.usb0"
_mkdir(func_path) 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: if start:
_symlink(func_path, join(self.__profile_path, func)) self.__start_function(func, eps)
self.__create_meta(func, "Serial Port") 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: 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: if host_mac and kvm_mac and host_mac == kvm_mac:
raise RuntimeError("Ethernet host_mac should not be equal to kvm_mac") raise RuntimeError("Ethernet host_mac should not be equal to kvm_mac")
real_driver = driver real_driver = driver
if driver == "rndis5": if driver == "rndis5":
real_driver = "rndis" real_driver = "rndis"
func = f"{real_driver}.usb0" func = f"{real_driver}.usb0"
func_path = join(self.__gadget_path, "functions", func) func_path = self.__create_function(func)
_mkdir(func_path)
if host_mac: if host_mac:
_write(join(func_path, "host_addr"), host_mac) _write(join(func_path, "host_addr"), host_mac)
if kvm_mac: if kvm_mac:
@@ -150,20 +166,20 @@ class _GadgetConfig:
_write(join(func_path, "os_desc/interface.rndis/sub_compatible_id"), "5162001") _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)) _symlink(self.__profile_path, join(self.__gadget_path, "os_desc", usb.G_PROFILE_NAME))
if start: if start:
_symlink(func_path, join(self.__profile_path, func)) self.__start_function(func, eps)
self.__create_meta(func, "Ethernet") self.__create_meta(func, "Ethernet", eps)
def add_keyboard(self, start: bool, remote_wakeup: bool) -> None: def add_keyboard(self, start: bool, remote_wakeup: bool) -> None:
self.__add_hid("Keyboard", start, remote_wakeup, make_keyboard_hid()) 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: def add_mouse(self, start: bool, remote_wakeup: bool, absolute: bool, horizontal_wheel: bool) -> None:
name = ("Absolute" if absolute else "Relative") + " Mouse" desc = ("Absolute" if absolute else "Relative") + " Mouse"
self.__add_hid(name, start, remote_wakeup, make_mouse_hid(absolute, horizontal_wheel)) 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 = f"hid.usb{self.__hid_instance}"
func_path = join(self.__gadget_path, "functions", func) func_path = self.__create_function(func)
_mkdir(func_path)
_write(join(func_path, "no_out_endpoint"), "1", optional=True) _write(join(func_path, "no_out_endpoint"), "1", optional=True)
if remote_wakeup: if remote_wakeup:
_write(join(func_path, "wakeup_on_write"), "1", optional=True) _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(join(func_path, "report_length"), hid.report_length)
_write_bytes(join(func_path, "report_desc"), hid.report_descriptor) _write_bytes(join(func_path, "report_desc"), hid.report_descriptor)
if start: if start:
_symlink(func_path, join(self.__profile_path, func)) self.__start_function(func, eps)
self.__create_meta(func, name) self.__create_meta(func, desc, eps)
self.__hid_instance += 1 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 = f"mass_storage.usb{self.__msd_instance}"
func_path = join(self.__gadget_path, "functions", func) func_path = self.__create_function(func)
_mkdir(func_path)
_write(join(func_path, "stall"), int(stall)) _write(join(func_path, "stall"), int(stall))
_write(join(func_path, "lun.0/cdrom"), int(cdrom)) _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/ro"), int(not rw))
_write(join(func_path, "lun.0/removable"), int(removable)) _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/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": if user != "root":
_chown(join(func_path, "lun.0/cdrom"), user) _chown(join(func_path, "lun.0/cdrom"), user)
_chown(join(func_path, "lun.0/ro"), user) _chown(join(func_path, "lun.0/ro"), user)
_chown(join(func_path, "lun.0/file"), user) _chown(join(func_path, "lun.0/file"), user)
_chown(join(func_path, "lun.0/forced_eject"), user, optional=True) _chown(join(func_path, "lun.0/forced_eject"), user, optional=True)
if start: if start:
_symlink(func_path, join(self.__profile_path, func)) self.__start_function(func, eps)
name = ("Mass Storage Drive" if self.__msd_instance == 0 else f"Extra Drive #{self.__msd_instance}") desc = ("Mass Storage Drive" if self.__msd_instance == 0 else f"Extra Drive #{self.__msd_instance}")
self.__create_meta(func, name) self.__create_meta(func, desc, eps)
self.__msd_instance += 1 self.__msd_instance += 1
def __create_meta(self, func: str, name: str) -> None: def __create_function(self, func: str) -> str:
_write(join(self.__meta_path, f"{func}@meta.json"), json.dumps({"func": func, "name": name})) 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 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? # XXX: Should we use MaxPower=100 with Remote Wakeup?
_write(join(profile_path, "bmAttributes"), "0xA0") _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 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": if config.kvmd.hid.type == "otg":
logger.info("===== HID-Keyboard =====") logger.info("===== HID-Keyboard =====")
gc.add_keyboard(cod.hid.keyboard.start, config.otg.remote_wakeup) gc.add_keyboard(cod.hid.keyboard.start, config.otg.remote_wakeup)
logger.info("===== HID-Mouse =====") 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: if config.kvmd.hid.mouse_alt.device:
logger.info("===== HID-Mouse-Alt =====") 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": if config.kvmd.msd.type == "otg":
logger.info("===== MSD =====") 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: if cod.drives.enabled:
for count in range(cod.drives.count): for count in range(cod.drives.count):
logger.info("===== MSD Extra: %d =====", count + 1) 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 =====") logger.info("===== Preparing complete =====")

View File

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

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. # # KVMD - The main PiKVM daemon. #
# # # #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> # # 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 # # 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 # # it under the terms of the GNU General Public License as published by #
@@ -20,13 +21,12 @@
# ========================================================================== # # ========================================================================== #
import os
import errno import errno
import argparse import argparse
from ...validators.basic import valid_bool from ...validators.basic import valid_bool
from ...validators.basic import valid_int_f0 from ...validators.basic import valid_int_f0
from ...validators.os import valid_abs_file from ...validators.os import valid_abs_path
from ... import usb 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, parser.add_argument("-i", "--instance", default=0, type=valid_int_f0,
metavar="<N>", help="Drive instance (0 for KVMD drive)") metavar="<N>", help="Drive instance (0 for KVMD drive)")
parser.add_argument("--set-cdrom", default=None, type=valid_bool, 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, parser.add_argument("--set-rw", default=None, type=valid_bool,
metavar="<1|0|yes|no>", help="Set RW flag") 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") metavar="<path>", help="Set the image file")
parser.add_argument("--eject", action="store_true", parser.add_argument("--eject", action="store_true",
help="Eject the image") 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))) set_param("ro", str(int(not options.set_rw)))
if options.set_image: if options.set_image:
if not os.path.isfile(options.set_image): # if not os.path.isfile(options.set_image):
raise SystemExit(f"Not a file: {options.set_image}") # raise SystemExit(f"Not a file: {options.set_image}")
set_param("file", options.set_image) set_param("file", options.set_image)
print("Image file: ", (get_param("file") or "<none>")) 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")) print("RW flag: ", ("no" if int(get_param("ro")) else "yes"))

View File

@@ -24,6 +24,7 @@ import os
import asyncio import asyncio
from aiohttp.web import Request from aiohttp.web import Request
from aiohttp.web import Response
from aiohttp.web import WebSocketResponse from aiohttp.web import WebSocketResponse
from ...logging import get_logger from ...logging import get_logger
@@ -35,6 +36,7 @@ from ... import fstab
from ...htserver import exposed_http from ...htserver import exposed_http
from ...htserver import exposed_ws from ...htserver import exposed_ws
from ...htserver import make_json_response
from ...htserver import WsSession from ...htserver import WsSession
from ...htserver import HttpServer from ...htserver import HttpServer
@@ -65,6 +67,16 @@ class PstServer(HttpServer): # pylint: disable=too-many-arguments,too-many-inst
await ws.send_event("loop", {}) await ws.send_event("loop", {})
return (await self._ws_loop(ws)) 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") @exposed_ws("ping")
async def __ws_ping_handler(self, ws: WsSession, _: dict) -> None: async def __ws_ping_handler(self, ws: WsSession, _: dict) -> None:
await ws.send_event("pong", {}) 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) await self.__remount_storage(rw=False)
logger.info("On-Cleanup complete") logger.info("On-Cleanup complete")
async def _on_ws_opened(self) -> None: async def _on_ws_opened(self, _: WsSession) -> None:
self.__notifier.notify() self.__notifier.notify()
async def _on_ws_closed(self) -> None: async def _on_ws_closed(self, _: WsSession) -> None:
self.__notifier.notify() self.__notifier.notify()
# ===== SYSTEM TASKS # ===== SYSTEM TASKS
@@ -117,7 +129,7 @@ class PstServer(HttpServer): # pylint: disable=too-many-arguments,too-many-inst
await self.__notifier.wait() await self.__notifier.wait()
async def __broadcast_storage_state(self, clients: int, write_allowed: bool) -> None: 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, "clients": clients,
"data": { "data": {
"path": self.__data_path, "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() msg = receive_task.result()
if msg.type == aiohttp.WSMsgType.TEXT: if msg.type == aiohttp.WSMsgType.TEXT:
(event_type, event) = htserver.parse_ws_event(msg.data) (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: if event["data"]["write_allowed"] and proc is None:
logger.info("PST write is allowed: %s", event["data"]["path"]) logger.info("PST write is allowed: %s", event["data"]["path"])
logger.info("Running the process ...") 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 if self._encodings.has_ext_keys: # Preferred method
await self._write_fb_update("ExtKeys FBUR", 0, 0, RfbEncodings.EXT_KEYS, drain=True) 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() await self._on_set_encodings()
async def __handle_fb_update_request(self) -> None: 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: async def __handle_pointer_event(self) -> None:
(buttons, to_x, to_y) = await self._read_struct("pointer event", "B HH") (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( await self._on_pointer_event(
buttons={ buttons={
"left": bool(buttons & 0x1), "left": bool(buttons & 0x1),
"right": bool(buttons & 0x4), "right": bool(buttons & 0x4),
"middle": bool(buttons & 0x2), "middle": bool(buttons & 0x2),
"up": bool(ext_buttons & 0x2),
"down": bool(ext_buttons & 0x1),
}, },
wheel={ wheel={
"x": (-4 if buttons & 0x40 else (4 if buttons & 0x20 else 0)), "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 RENAME = -307 # DesktopName Pseudo-encoding
LEDS_STATE = -261 # QEMU LED State Pseudo-encoding LEDS_STATE = -261 # QEMU LED State Pseudo-encoding
EXT_KEYS = -258 # QEMU Extended Key Events Pseudo-encoding EXT_KEYS = -258 # QEMU Extended Key Events Pseudo-encoding
EXT_MOUSE = -316 # ExtendedMouseButtons Pseudo-encoding
CONT_UPDATES = -313 # ContinuousUpdates Pseudo-encoding CONT_UPDATES = -313 # ContinuousUpdates Pseudo-encoding
TIGHT = 7 TIGHT = 7
@@ -50,16 +51,17 @@ def _make_meta(variants: (int | frozenset[int])) -> dict:
class RfbClientEncodings: # pylint: disable=too-many-instance-attributes class RfbClientEncodings: # pylint: disable=too-many-instance-attributes
encodings: frozenset[int] encodings: frozenset[int]
has_resize: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.RESIZE)) # 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_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_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_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_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 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 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]: def get_summary(self) -> list[str]:
summary: list[str] = [f"encodings -- {sorted(self.encodings)}"] summary: list[str] = [f"encodings -- {sorted(self.encodings)}"]

View File

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

View File

@@ -183,10 +183,12 @@ class KvmdClientWs:
self.__communicated = False self.__communicated = False
async def send_key_event(self, key: str, state: bool) -> None: 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: 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: 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)) 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 H264 = 875967048 # V4L2_PIX_FMT_H264
_MJPEG = 1196444237 # V4L2_PIX_FMT_MJPEG _MJPEG = 1196444237 # V4L2_PIX_FMT_MJPEG
@classmethod
def is_diff(cls, fmt: int) -> bool:
return (fmt == cls.H264)
class BaseStreamerClient: class BaseStreamerClient:
def get_format(self) -> int: def get_format(self) -> int:

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. # # KVMD - The main PiKVM daemon. #
# # # #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> # # 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 # # 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 # # 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]: def parse_ws_event(msg: str) -> tuple[str, dict]:
data = json.loads(msg) data = json.loads(msg)
if not isinstance(data, dict): if not isinstance(data, dict):
@@ -264,14 +274,24 @@ def set_request_auth_info(req: BaseRequest, info: str) -> None:
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class WsSession: class WsSession:
wsr: WebSocketResponse wsr: WebSocketResponse
kwargs: dict[str, Any] kwargs: dict[str, Any] = dataclasses.field(hash=False)
def __str__(self) -> str: def __str__(self) -> str:
return f"WsSession(id={id(self)}, {self.kwargs})" 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: async def send_event(self, event_type: str, event: (dict | None)) -> None:
await send_ws_event(self.wsr, event_type, event) 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: class HttpServer:
def __init__(self) -> None: 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)) get_logger(2).info("Registered new client session: %s; clients now: %d", ws, len(self.__ws_sessions))
try: try:
await self._on_ws_opened() await self._on_ws_opened(ws)
yield ws yield ws
finally: finally:
await aiotools.shield_fg(self.__close_ws(ws)) await aiotools.shield_fg(self.__close_ws(ws))
@@ -384,17 +404,12 @@ class HttpServer:
break break
return ws.wsr 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: if self.__ws_sessions:
await asyncio.gather(*[ await asyncio.gather(*[
ws.send_event(event_type, event) ws.send_event(event_type, event)
for ws in self.__ws_sessions for ws in self.__ws_sessions
if ( if ws.is_alive()
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)
)
], return_exceptions=True) ], return_exceptions=True)
async def _close_all_wss(self) -> bool: async def _close_all_wss(self) -> bool:
@@ -414,7 +429,7 @@ class HttpServer:
await ws.wsr.close() await ws.wsr.close()
except Exception: except Exception:
pass pass
await self._on_ws_closed() await self._on_ws_closed(ws)
# ===== # =====
@@ -430,10 +445,10 @@ class HttpServer:
async def _on_cleanup(self) -> None: async def _on_cleanup(self) -> None:
pass pass
async def _on_ws_opened(self) -> None: async def _on_ws_opened(self, ws: WsSession) -> None:
pass pass
async def _on_ws_closed(self) -> None: async def _on_ws_closed(self, ws: WsSession) -> None:
pass pass
# ===== # =====

View File

@@ -168,7 +168,13 @@ class WebModifiers:
CTRL_LEFT = "ControlLeft" CTRL_LEFT = "ControlLeft"
CTRL_RIGHT = "ControlRight" 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: class X11Modifiers:

View File

@@ -60,7 +60,13 @@ class WebModifiers:
CTRL_LEFT = "ControlLeft" CTRL_LEFT = "ControlLeft"
CTRL_RIGHT = "ControlRight" 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: class X11Modifiers:

View File

@@ -32,3 +32,17 @@ class MouseRange:
@classmethod @classmethod
def remap(cls, value: int, out_min: int, out_max: int) -> int: def remap(cls, value: int, out_min: int, out_max: int) -> int:
return tools.remap(value, cls.MIN, cls.MAX, out_min, out_max) 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_key
from ...validators.hid import valid_hid_mouse_move from ...validators.hid import valid_hid_mouse_move
from ...keyboard.mappings import WebModifiers
from ...mouse import MouseRange from ...mouse import MouseRange
from .. import BasePlugin 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_x_range = (mouse_x_min, mouse_x_max)
self.__mouse_y_range = (mouse_y_min, mouse_y_max) self.__mouse_y_range = (mouse_y_min, mouse_y_max)
self.__jiggler_enabled = jiggler_enabled self.__j_enabled = jiggler_enabled
self.__jiggler_active = jiggler_active self.__j_active = jiggler_active
self.__jiggler_interval = jiggler_interval self.__j_interval = jiggler_interval
self.__jiggler_absolute = True self.__j_absolute = True
self.__activity_ts = 0 self.__j_activity_ts = 0
self.__j_last_x = 0
self.__j_last_y = 0
@classmethod @classmethod
def _get_base_options(cls) -> dict[str, Any]: 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"), "max": Option(MouseRange.MAX, type=valid_hid_mouse_move, unpack_as="mouse_y_max"),
}, },
"jiggler": { "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"), "active": Option(False, type=valid_bool, unpack_as="jiggler_active"),
"interval": Option(60, type=valid_int_f1, unpack_as="jiggler_interval"), "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: for (key, state) in keys:
if no_ignore_keys or key not in self.__ignore_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) 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() self.__bump_activity()
def _send_key_event(self, key: str, state: bool) -> None: 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: 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: if self.__mouse_x_range != MouseRange.RANGE:
to_x = MouseRange.remap(to_x, *self.__mouse_x_range) to_x = MouseRange.remap(to_x, *self.__mouse_x_range)
if self.__mouse_y_range != MouseRange.RANGE: if self.__mouse_y_range != MouseRange.RANGE:
@@ -229,37 +246,38 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes
handler(*xy) handler(*xy)
def __bump_activity(self) -> None: 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: def _set_jiggler_absolute(self, absolute: bool) -> None:
self.__jiggler_absolute = absolute self.__j_absolute = absolute
def _set_jiggler_active(self, active: bool) -> None: def _set_jiggler_active(self, active: bool) -> None:
if self.__jiggler_enabled: if self.__j_enabled:
self.__jiggler_active = active self.__j_active = active
def _get_jiggler_state(self) -> dict: def _get_jiggler_state(self) -> dict:
return { return {
"jiggler": { "jiggler": {
"enabled": self.__jiggler_enabled, "enabled": self.__j_enabled,
"active": self.__jiggler_active, "active": self.__j_active,
"interval": self.__jiggler_interval, "interval": self.__j_interval,
}, },
} }
# ===== # =====
async def systask(self) -> None: async def systask(self) -> None:
factor = 1
while True: while True:
if self.__jiggler_active and (self.__activity_ts + self.__jiggler_interval < int(time.monotonic())): if self.__j_active and (self.__j_activity_ts + self.__j_interval < int(time.monotonic())):
for _ in range(5): if self.__j_absolute:
if self.__jiggler_absolute: (x, y) = (self.__j_last_x, self.__j_last_y)
self.send_mouse_move_event(100 * factor, 100 * factor) for move in [100, -100, 100, -100, 0]:
else: self.send_mouse_move_event(MouseRange.normalize(x + move), MouseRange.normalize(y + move))
self.send_mouse_relative_event(10 * factor, 10 * factor) await asyncio.sleep(0.1)
factor *= -1 else:
await asyncio.sleep(0.1) for move in [10, -10, 10, -10]:
self.send_mouse_relative_event(move, move)
await asyncio.sleep(0.1)
await asyncio.sleep(1) await asyncio.sleep(1)

View File

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

View File

@@ -23,6 +23,7 @@
import math import math
from ....mouse import MouseRange 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: def process_wheel(self, delta_x: int, delta_y: int) -> bytes:
_ = delta_x _ = delta_x
assert -127 <= delta_y <= 127 assert MouseDelta.MIN <= delta_y <= MouseDelta.MAX
self.__wheel_y = (1 if delta_y > 0 else 255) self.__wheel_y = (1 if delta_y > 0 else 255)
if not self.__absolute: if not self.__absolute:
return self.__make_relative_cmd() return self.__make_relative_cmd()
@@ -110,6 +111,6 @@ class Mouse: # pylint: disable=too-many-instance-attributes
]) ])
def __fix_relative(self, value: int) -> int: def __fix_relative(self, value: int) -> int:
assert -127 <= value <= 127 assert MouseDelta.MIN <= value <= MouseDelta.MAX
value = math.ceil(value / 3) value = math.ceil(value / 3)
return (value if value >= 0 else (255 + value)) 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 ....keyboard.mappings import KEYMAP
from ....mouse import MouseRange from ....mouse import MouseRange
from ....mouse import MouseDelta
# ===== # =====
@@ -144,8 +145,8 @@ class MouseRelativeEvent(BaseEvent):
delta_y: int delta_y: int
def __post_init__(self) -> None: def __post_init__(self) -> None:
assert -127 <= self.delta_x <= 127 assert MouseDelta.MIN <= self.delta_x <= MouseDelta.MAX
assert -127 <= self.delta_y <= 127 assert MouseDelta.MIN <= self.delta_y <= MouseDelta.MAX
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
@@ -154,8 +155,8 @@ class MouseWheelEvent(BaseEvent):
delta_y: int delta_y: int
def __post_init__(self) -> None: def __post_init__(self) -> None:
assert -127 <= self.delta_x <= 127 assert MouseDelta.MIN <= self.delta_x <= MouseDelta.MAX
assert -127 <= self.delta_y <= 127 assert MouseDelta.MIN <= self.delta_y <= MouseDelta.MAX
def make_mouse_report( def make_mouse_report(

View File

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

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. # # KVMD - The main PiKVM daemon. #
# # # #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> # # 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 # # 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 # # it under the terms of the GNU General Public License as published by #

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. # # KVMD - The main PiKVM daemon. #
# # # #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> # # 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 # # 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 # # it under the terms of the GNU General Public License as published by #

View File

@@ -3,6 +3,7 @@
# KVMD - The main PiKVM daemon. # # KVMD - The main PiKVM daemon. #
# # # #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> # # 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 # # 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 # # it under the terms of the GNU General Public License as published by #

View File

@@ -20,6 +20,7 @@
# ========================================================================== # # ========================================================================== #
import asyncio
import operator import operator
import functools import functools
import multiprocessing.queues 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()): for _ in range(q.qsize()):
try: try:
q.get_nowait() q.get_nowait()
except queue.Empty: except (queue.Empty, asyncio.QueueEmpty):
break break

View File

@@ -55,3 +55,11 @@ G_PROFILE = f"configs/{G_PROFILE_NAME}"
def get_gadget_path(gadget: str, *parts: str) -> str: def get_gadget_path(gadget: str, *parts: str) -> str:
return os.path.join(f"{env.SYSFS_PREFIX}/sys/kernel/config/usb_gadget", gadget, *parts) 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: except Exception:
pass pass
raise_error(arg, name) 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 ..keyboard.mappings import KEYMAP
from ..mouse import MouseRange from ..mouse import MouseRange
from ..mouse import MouseDelta
from . import check_string_in_list 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: def valid_hid_mouse_move(arg: Any) -> int:
arg = valid_number(arg, name="Mouse move") 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: 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: def valid_hid_mouse_delta(arg: Any) -> int:
arg = valid_number(arg, name="Mouse delta") 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 typing import Any
from . import raise_error from . import raise_error
from . import filter_printable
from .basic import valid_number from .basic import valid_number
from .basic import valid_string_list 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: def valid_printable_filename(arg: Any, name: str="") -> str:
if not name: if not name:
name = "printable filename" name = "printable filename"
arg = valid_stripped_string_not_empty(arg, name) arg = valid_stripped_string_not_empty(arg, name)
if ( if (
"/" in arg "/" in arg
or "\0" in arg or "\0" in arg
@@ -85,12 +84,7 @@ def valid_printable_filename(arg: Any, name: str="") -> str:
or arg == "lost+found" or arg == "lost+found"
): ):
raise_error(arg, name) raise_error(arg, name)
return filter_printable(arg, "_", 255)
arg = "".join(
(ch if ch.isprintable() else "_")
for ch in arg[:255]
)
return arg
# ===== # =====

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

View File

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

View File

@@ -256,7 +256,16 @@ if [ -n "$WIFI_ESSID" ]; then
else else
make_dhcp_iface "$WIFI_IFACE" 50 make_dhcp_iface "$WIFI_IFACE" 50
fi 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" chmod 640 "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf"
if [ -n "$WIFI_HIDDEN" ]; then if [ -n "$WIFI_HIDDEN" ]; then
sed -i -e 's/^}/\tscan_ssid=1\n}/g' "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf" 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. # # KVMD - The main PiKVM daemon. #
# # # #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> # # 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 # # 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 # # 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 # - https://msol.io/blog/tech/create-a-self-signed-ecc-certificate
openssl ecparam -out server.key -name prime256v1 -genkey openssl ecparam -out server.key -name prime256v1 -genkey
openssl req -new -x509 -sha256 -nodes -key server.key -out server.crt -days 3650 \ 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"/* #chown "root:kvmd-$target" "$path"/*
chmod 440 "$path/server.key" chmod 440 "$path/server.key"

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@
# KVMD - The main PiKVM daemon. # # KVMD - The main PiKVM daemon. #
# # # #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> # # 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 # # 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 # # 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 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: 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( setup(
name="kvmd", name="kvmd",
@@ -83,8 +79,10 @@ def main() -> None:
"kvmd.clients", "kvmd.clients",
"kvmd.apps", "kvmd.apps",
"kvmd.apps.kvmd", "kvmd.apps.kvmd",
"kvmd.apps.kvmd.switch",
"kvmd.apps.kvmd.info", "kvmd.apps.kvmd.info",
"kvmd.apps.kvmd.api", "kvmd.apps.kvmd.api",
"kvmd.apps.media",
"kvmd.apps.pst", "kvmd.apps.pst",
"kvmd.apps.pstrun", "kvmd.apps.pstrun",
"kvmd.apps.otg", "kvmd.apps.otg",
@@ -92,6 +90,7 @@ def main() -> None:
"kvmd.apps.otgnet", "kvmd.apps.otgnet",
"kvmd.apps.otgmsd", "kvmd.apps.otgmsd",
"kvmd.apps.otgconf", "kvmd.apps.otgconf",
"kvmd.apps.swctl",
"kvmd.apps.htpasswd", "kvmd.apps.htpasswd",
"kvmd.apps.totp", "kvmd.apps.totp",
"kvmd.apps.edidconf", "kvmd.apps.edidconf",
@@ -113,34 +112,12 @@ def main() -> None:
"kvmd": ["i18n/zh/LC_MESSAGES/*.mo"], "kvmd": ["i18n/zh/LC_MESSAGES/*.mo"],
}, },
entry_points={ entry_points=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",
],
},
classifiers=[ classifiers=[
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13",
"Topic :: System :: Systems Administration", "Topic :: System :: Systems Administration",
"Operating System :: POSIX :: Linux", "Operating System :: POSIX :: Linux",
"Intended Audience :: System Administrators", "Intended Audience :: System Administrators",

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