Compare commits

..

120 Commits

Author SHA1 Message Date
mofeng-git
0dd117711d 一些样式修改 2024-11-29 05:29:28 +00:00
mofeng-git
ed68449274 修复相对模式鼠标不可用
此问题有合并上游更新冲突所致
2024-11-27 13:33:49 +00:00
mofeng-git
a32dcd2e00 修复前端 wol 类名错误
修复前端 meta 块错误
2024-11-26 05:42:18 +00:00
mofeng-git
666f0b694a a fix 2024-11-23 02:45:01 +00:00
mofeng-git
b8ddf7c2da 增加快速文件互传功能(基于 MSD)
为 MSD 路径添加配置选项
为 文件镜像名称添加配置选项
修复 make 测试环境
2024-11-22 17:40:55 +00:00
mofeng-git
5450d7297c 为 docker 版本添加 nano 文本编辑器 2024-11-21 04:52:46 +00:00
mofeng-git
506d8a4a64 为 Docker 容器添加 kvmd 系列管理命令 2024-11-21 02:52:36 +00:00
mofeng-git
eec64ef57c Merge remote-tracking branch 'upstream/master' 2024-11-20 15:18:34 +00:00
mofeng-git
baa0f7e226 取消中文日志
中文日志没有必要,减低代码耦合
2024-11-20 12:44:59 +00:00
mofeng-git
3ec872878e 修复 make run-nogpi 测试环境 2024-11-20 12:44:59 +00:00
mofeng-git
6928fab16c Revert "初步的 kvmd 国际化(汉化)支持"
This reverts commit 20927c7226.
2024-11-20 12:44:59 +00:00
mofeng-git
8fdb7d7cd6 Revert "修复 kvmd 国际化带来的错误"
This reverts commit 4fc188dbc8.
2024-11-20 12:44:59 +00:00
mofeng-git
433232c845 Revert "进一步的 kvmd 国际化(汉化)支持"
This reverts commit 5b25b3661f.
2024-11-20 12:44:59 +00:00
mofeng-git
b419641251 Revert "进一步的 kvmd 国际化(汉化)支持,添加配置入口"
This reverts commit 35397c5414.
2024-11-20 12:44:59 +00:00
mofeng-git
50819d0a35 更换logo,增大默认分辨率和 h.264 比特率 2024-11-18 14:53:26 +00:00
mofeng-git
a37b818039 更新脚本 2024-11-16 08:42:08 +00:00
mofeng-git
8a81996e52 更新说明 2024-11-16 06:09:48 +00:00
mofeng-git
69cb9ac950 更新一键脚本 2024-11-16 05:50:08 +00:00
mofeng-git
30378211b5 更新说明 2024-11-12 11:12:41 +00:00
Maxim Devaev
e9e7f9bd05 pikvm/pikvm#1341: Web: Switch to maximize tab by default 2024-11-07 00:34:24 +02:00
mofeng-git
72dce4de89 #46 修复 CD-ROM 与 FLASH 模式网页无法切换
挂载 MSD 时重启 UDC 确保模式生效
2024-11-05 17:54:38 +00:00
Maxim Devaev
f1503d69e0 pikvm/pikvm#1207: Draw UI tips via meta.yaml 2024-11-05 18:17:04 +02:00
mofeng-git
de5cb73b93 更新说明 2024-11-05 02:15:03 +00:00
mofeng-git
0751b519c2 #44 添加 docker 网页音频支持
使用作者修改版 ustreamer
H.264/WebRTC 模式下音频可用
H.264/WebRTC 模式下网页录制视频包含音频
2024-11-05 02:12:00 +00:00
Maxim Devaev
0010dd1d11 pikvm/pikvm#1420: VNC: Ignore CUT event 3 seconds after connection 2024-11-04 18:59:50 +02:00
Maxim Devaev
7ef2e16b51 minor partial state fixes 2024-11-04 18:06:16 +02:00
mofeng-git
1a13760df0 #44 添加视频录制支持
使用浏览器前端 API
支持 mjpeg 和 h.264 模式下的视频录制
录制格式为wbem(vp8)
2024-11-04 13:25:18 +00:00
Maxim Devaev
d93639ba8d hid with granularity prototype 2024-11-03 18:28:28 +02:00
Maxim Devaev
1e277c0f06 lint fix 2024-11-02 21:04:57 +02:00
Maxim Devaev
95597b15e4 fix 2024-11-02 20:03:00 +02:00
mofeng-git
6fbfc2b343 43 修复 docker 相对鼠标设备模式无法使用的问题 2024-11-02 17:11:18 +00:00
mofeng-git
b893f27285 #43 修复 docker 相对鼠标设备模式无法使用的问题 2024-11-02 17:02:37 +00:00
Maxim Devaev
28167c4b45 fixed ocr null event handling 2024-11-02 18:48:14 +02:00
Maxim Devaev
5aef0a2193 refactoring 2024-11-02 18:47:59 +02:00
Maxim Devaev
0fd1174bc5 granularity info and minor fixes 2024-11-02 18:06:52 +02:00
Maxim Devaev
d4fb640418 refactoring 2024-11-02 14:46:48 +02:00
Maxim Devaev
d6b61cb407 refactoring 2024-11-02 14:26:39 +02:00
Maxim Devaev
8192b1fa95 simplified stream js logic 2024-11-02 10:39:43 +02:00
Maxim Devaev
deba110cdf partial msd events 2024-11-02 10:39:15 +02:00
Maxim Devaev
936cc21c40 Using disablePictureInPicture="true" 2024-10-30 11:30:45 +02:00
Maxim Devaev
47778bc48c msd: ftruncate() for uploading 2024-10-29 19:50:27 +02:00
Maxim Devaev
c02bc53bc4 msd: reload parts from inotify loop 2024-10-29 13:35:39 +02:00
Maxim Devaev
546ac24b93 msd reset now leads to inotify restart 2024-10-29 11:01:18 +02:00
Maxim Devaev
2195acf2ff Don't watch inotify modify events because they fires on every write() 2024-10-28 17:20:13 +02:00
Maxim Devaev
60f413c1f4 refactoring 2024-10-28 10:46:12 +02:00
Maxim Devaev
a84242c9bc AioExclusiveRegion API is sync now 2024-10-26 15:51:33 +03:00
mofeng-git
efa865ec9c 更新说明 2024-10-25 05:41:24 +00:00
Maxim Devaev
399712c684 refactoring 2024-10-24 03:05:46 +03:00
Maxim Devaev
1ebc08eae8 fix 2024-10-23 23:12:34 +03:00
Maxim Devaev
684b9f629e send kvmd version to ws 2024-10-23 23:02:25 +03:00
Maxim Devaev
76d70d0838 new ocr event format 2024-10-23 22:14:47 +03:00
Maxim Devaev
a26aee3543 partial streamer events 2024-10-23 19:31:39 +03:00
Maxim Devaev
0e4a70e7b9 refactoring 2024-10-22 05:39:18 +03:00
Maxim Devaev
cda32a083f new events model 2024-10-21 17:46:59 +03:00
mofeng-git
11d8f26874 更新说明 2024-10-20 13:08:49 +00:00
mofeng-git
2929a925a2 为玩客云替换网络服务程序为 systemd-networkd
修复修改 mac 地址无法自动获取 ip 问题
2024-10-20 11:01:22 +00:00
Maxim Devaev
b67a232584 copy some msd dicts to avoid changing 2024-10-19 09:25:20 +03:00
Maxim Devaev
90d8e745e3 gpio diff events mode 2024-10-19 08:59:52 +03:00
Maxim Devaev
3852d0a456 refactoring 2024-10-18 13:25:03 +03:00
mofeng-git
f5bebbc43f 整合包适配我家云、虚拟机和中兴 B863AV3.2M 2024-10-13 22:54:51 +00:00
mofeng-git
6707cb9932 为整合包补全 python3-hid 依赖
修复 amd64 架构 docker 错误启用 MSD功能
2024-10-11 11:49:07 +00:00
mofeng-git
87c887a62b 深度适配私家云二代 2024-10-11 11:28:36 +00:00
mofeng-git
40505e7e00 添加私家云二代整合包制作脚本 2024-10-07 08:57:25 +00:00
Maxim Devaev
c1f408ea1a Bump version: 4.19 → 4.20 2024-10-06 21:04:17 +03:00
Maxim Devaev
5b0ca351d7 fixed platform gpio again 2024-10-06 21:03:39 +03:00
Maxim Devaev
b6869cfbec Bump version: 4.18 → 4.19 2024-10-06 20:14:42 +03:00
Maxim Devaev
1e11678260 fixed gpio platform-specific switches 2024-10-06 20:14:07 +03:00
Maxim Devaev
8c0953aafc Bump version: 4.17 → 4.18 2024-10-02 22:17:56 +03:00
Maxim Devaev
073f67ca1b pikvm/pikvm#1410: Fixed EDID file loader 2024-10-02 22:17:15 +03:00
Maxim Devaev
cb5c1e9e6d Bump version: 4.16 → 4.17 2024-10-02 03:37:29 +03:00
Maxim Devaev
8ce27dca3f pikvm/pikvm#1405: Fixed behaviour on duplicating gpio leds 2024-10-02 03:35:57 +03:00
Maxim Devaev
f4ba4210e1 fixed post params 2024-10-02 03:32:54 +03:00
Maxim Devaev
4e1d9815cd pikvm/pikvm#1407: Save keymap on macro recording 2024-10-02 02:45:59 +03:00
Maxim Devaev
8209ee2eb0 improved wm dialogs 2024-09-23 02:32:38 +03:00
Maxim Devaev
5ed368769c refactoring 2024-09-23 02:32:23 +03:00
Maxim Devaev
1217144ecd refactoring + some tools 2024-09-22 05:20:01 +03:00
Maxim Devaev
842ddc91a1 refactoring 2024-09-20 01:11:22 +03:00
Maxim Devaev
7a53f14456 refactoring 2024-09-18 04:37:43 +03:00
Maxim Devaev
45270a09d7 Bump version: 4.15 → 4.16 2024-09-17 17:59:19 +03:00
Maxim Devaev
f03ac695bd refactoring 2024-09-17 17:58:31 +03:00
Maxim Devaev
b3e836e553 pikvm/pikvm#1386: Setup STUN by IP 2024-09-17 17:53:55 +03:00
Maxim Devaev
c57334f214 refactoring 2024-09-16 23:07:38 +03:00
Maxim Devaev
b779c18530 Bump version: 4.14 → 4.15 2024-09-13 22:08:43 +03:00
Maxim Devaev
6ccd91a8d1 removed print() 2024-09-13 22:07:59 +03:00
Maxim Devaev
bd127c3fd3 Bump version: 4.13 → 4.14 2024-09-13 19:34:39 +03:00
Maxim Devaev
4bc2ca3c90 refactoring 2024-09-13 19:33:49 +03:00
Maxim Devaev
445e2e04e2 oled: sensors class 2024-09-12 17:05:35 +03:00
Maxim Devaev
489601bb96 Bump version: 4.12 → 4.13 2024-09-11 20:23:24 +03:00
Maxim Devaev
56da910ebe moved kvmd-oled to this repo 2024-09-11 20:22:49 +03:00
Maxim Devaev
40393acf67 Bump version: 4.11 → 4.12 2024-09-11 01:16:16 +03:00
Maxim Devaev
2123799e51 required ustreamer 6.16 2024-09-11 01:14:43 +03:00
Maxim Devaev
0bb35806ff Janus: Fixed OPUS mono audio in Chrome 2024-09-11 00:48:47 +03:00
Maxim Devaev
bbbc908af1 Bump version: 4.10 → 4.11 2024-09-08 01:59:50 +03:00
Maxim Devaev
8113c5748b new sponsors 2024-09-08 01:57:30 +03:00
Maxim Devaev
aa1ca3b329 Serial number to uppercase, more info in Avahi 2024-09-08 01:35:11 +03:00
Maxim Devaev
508d5fe606 Bump version: 4.9 → 4.10 2024-09-04 21:53:01 +03:00
Maxim Devaev
bc22a28022 removed avahi from deps 2024-09-04 21:52:20 +03:00
Maxim Devaev
80aa9de4cc Bump version: 4.8 → 4.9 2024-09-04 18:49:21 +03:00
Maxim Devaev
572a75d27b kvmd-gencert: US is a new default 2024-09-04 14:08:00 +03:00
Maxim Devaev
864a2af45e kvmd-bootconfig: ensure avahi service on ENABLE_AVAHI 2024-09-04 04:47:43 +03:00
Maxim Devaev
5f26fa4072 added avahi to deps 2024-09-04 04:42:17 +03:00
Maxim Devaev
af9023e8aa kvmd-bootconfig: provide ENABLE_AVAHI 2024-09-04 04:39:56 +03:00
Maxim Devaev
5c3ac4c9c1 pikvm/kvmd#170: alternative implementation 2024-09-04 03:03:48 +03:00
Maxim Devaev
fb9d860cf2 pikvm/kvmd#182: improved dbus_next fix 2024-08-30 19:52:11 +03:00
czo
5045d8b3d7 silence the systemd/dbus exception if there are no matching services (#182) 2024-08-30 19:30:31 +03:00
Maxim Devaev
cc66fbf1df Bump version: 4.7 → 4.8 2024-08-27 15:51:43 +03:00
Maxim Devaev
9dc2af0356 kvmd-edidconf: removed --fix-edid-checksums 2024-08-27 15:51:07 +03:00
Maxim Devaev
99fcbdda05 lint fix 2024-08-27 01:49:17 +03:00
Maxim Devaev
308911191a testenv: restored eslint 2024-08-27 01:48:52 +03:00
Maxim Devaev
0c213add4a pst: changed data root to /var/lib/kvmd/pst 2024-08-27 01:48:30 +03:00
Maxim Devaev
3837e1a1c8 Simplified inotify API 2024-08-25 01:24:12 +03:00
Maxim Devaev
8569ed406a Bump version: 4.6 → 4.7 2024-08-24 23:07:05 +03:00
Maxim Devaev
4772c2b6c3 Since 1.28.1, v4l2-ctl deprecated --fix-edid-checksums and made thid behaviour default 2024-08-24 23:05:49 +03:00
Maxim Devaev
e6b775089f Bump version: 4.5 → 4.6 2024-08-20 07:15:03 +03:00
Maxim Devaev
721a80ef03 fixed pst chgrp and chmod 2024-08-20 07:14:28 +03:00
Maxim Devaev
a55948bf8e Bump version: 4.4 → 4.5 2024-08-20 05:45:00 +03:00
Maxim Devaev
39422f37ac sticky pst 2024-08-20 05:43:47 +03:00
Maxim Devaev
06b69d3dde Bump version: 4.3 → 4.4 2024-08-19 01:06:34 +03:00
Maxim Devaev
c9405efa05 lint fix 2024-08-19 01:06:00 +03:00
Maxim Devaev
abedace4b3 enable v4p by default 2024-08-19 00:43:32 +03:00
205 changed files with 6738 additions and 4895 deletions

View File

@@ -1,7 +1,7 @@
[bumpversion] [bumpversion]
commit = True commit = True
tag = True tag = True
current_version = 4.3 current_version = 4.20
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}

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
/pkg/ /pkg/
/src/ /src/**/*.img
/src/tmp
/site/ /site/
/dist/ /dist/
/kvmd.egg-info/ /kvmd.egg-info/

View File

@@ -86,7 +86,7 @@ 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/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \ && cp /usr/share/kvmd/configs.default/kvmd/main.yaml /etc/kvmd/main.yaml \
&& mkdir -p /etc/kvmd/override.d \ && 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 \
&& cd /src \ && cd /src \
@@ -155,7 +155,7 @@ 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/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \ && cp /usr/share/kvmd/configs.default/kvmd/main.yaml /etc/kvmd/main.yaml \
&& mkdir -p /etc/kvmd/override.d \ && 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.kvmd -m) \ && $(if $(CMD),$(CMD),python -m kvmd.apps.kvmd -m) \
@@ -178,7 +178,7 @@ 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/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \ && cp /usr/share/kvmd/configs.default/kvmd/main.yaml /etc/kvmd/main.yaml \
&& mkdir -p /etc/kvmd/override.d \ && 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) \
@@ -201,7 +201,7 @@ 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/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \ && cp /usr/share/kvmd/configs.default/kvmd/main.yaml /etc/kvmd/main.yaml \
&& mkdir -p /etc/kvmd/override.d \ && 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) \
@@ -285,10 +285,12 @@ run-stage-0:
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 \
--platform linux/amd64,linux/arm64,linux/arm/v7 \ --platform linux/amd64,linux/arm64,linux/arm/v7 \
--build-arg CACHEBUST=$(date +%s) \
-f build/Dockerfile . \ -f build/Dockerfile . \
--push --push
$(DOCKER) buildx build -t silentwind0/kvmd:dev \ $(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) \
-f build/Dockerfile . \ -f build/Dockerfile . \
--push --push
@@ -296,11 +298,13 @@ 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 \
--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) \
-f build/Dockerfile . \ -f build/Dockerfile . \
--push --push
$(DOCKER) buildx build -t silentwind0/kvmd \ $(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) \
-f build/Dockerfile . \ -f build/Dockerfile . \
--push --push
@@ -331,7 +335,7 @@ run-nogpio: 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/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \ && cp /usr/share/kvmd/configs.default/kvmd/main.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 \
&& 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 \

View File

@@ -39,7 +39,7 @@ for _variant in "${_variants[@]}"; do
pkgname+=(kvmd-platform-$_platform-$_board) pkgname+=(kvmd-platform-$_platform-$_board)
done done
pkgbase=kvmd pkgbase=kvmd
pkgver=4.3 pkgver=4.20
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"
@@ -77,6 +77,8 @@ depends=(
python-ldap python-ldap
python-zstandard python-zstandard
python-mako python-mako
python-luma-oled
python-pyusb
"libgpiod>=2.1" "libgpiod>=2.1"
freetype2 freetype2
"v4l-utils>=1.22.1-1" "v4l-utils>=1.22.1-1"
@@ -91,7 +93,7 @@ depends=(
certbot certbot
platform-io-access platform-io-access
raspberrypi-utils raspberrypi-utils
"ustreamer>=6.11" "ustreamer>=6.16"
# Systemd UDEV bug # Systemd UDEV bug
"systemd>=248.3-2" "systemd>=248.3-2"
@@ -131,6 +133,7 @@ conflicts=(
python-aiohttp-pikvm python-aiohttp-pikvm
platformio platformio
avrdude-pikvm avrdude-pikvm
kvmd-oled
) )
makedepends=( makedepends=(
python-setuptools python-setuptools
@@ -206,7 +209,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.21-3\") depends=(kvmd=$pkgver-$pkgrel \"linux-rpi-pikvm>=6.6.45-1\" \"raspberrypi-bootloader-pikvm>=20240818-1\")
backup=( backup=(
etc/sysctl.d/99-kvmd.conf etc/sysctl.d/99-kvmd.conf

View File

@@ -6,17 +6,54 @@
One-KVM 是基于廉价计算机硬件和 [PiKVM]((https://github.com/pikvm/pikvm)) 软件二次开发的 BIOS 级远程控制项目。可以实现远程管理服务器或工作站,无需在被控机安装软件调整设置,实现无侵入式控制,适用范围广泛。 One-KVM 是基于廉价计算机硬件和 [PiKVM]((https://github.com/pikvm/pikvm)) 软件二次开发的 BIOS 级远程控制项目。可以实现远程管理服务器或工作站,无需在被控机安装软件调整设置,实现无侵入式控制,适用范围广泛。
使用文档:[https://one-kvm.mofeng.run](https://one-kvm.mofeng.run)
演示网站:[https://kvmd-demo.mofeng.run](https://kvmd-demo.mofeng.run) 演示网站:[https://kvmd-demo.mofeng.run](https://kvmd-demo.mofeng.run)
![image-20240926220156381](https://github.com/user-attachments/assets/a7848bca-e43c-434e-b812-27a45fad7910) ![image-20240926220156381](https://github.com/user-attachments/assets/a7848bca-e43c-434e-b812-27a45fad7910)
### 软件功能
表格仅为 One-KVM 与其他基于 PiKVM 的项目的功能对比,无不良导向,如有错漏请联系更正。
| 功能 | One-KVM | PiKVM | ArmKVM | BLIKVM |
| :-------------------: | :-------------: | :-----------------------: | :---------: | :---------: |
| 系统开源 | √ | √ | √ | √ |
| 简体中文 WebUI | √ | x | √ | √ |
| 远程视频流 | MJPEG/H.264 | MJPEG/H.264 | MJPEG/H.264 | MJPEG/H.264 |
| H.264 视频编码 | CPU | GPU | 未知 | GPU |
| 远程音频流 | √ | √ | √ | √ |
| 远程鼠键控制 | OTG/CH9329 | OTG/CH9329/Pico/Bluetooth | OTG | OTG |
| VNC 控制 | √ | √ | √ | √ |
| ATX 电源控制 | GPIO/USB 继电器 | GPIO | GPIO | GPIO |
| 虚拟存储驱动器挂载 | √ | √ | √ | √ |
| 2.2G 以上 CD-ROM 挂载 | x | x | √ | √ |
| WOL 远程唤醒 | √ | √ | √ | √ |
| 网页剪切板 | √ | √ | √ | √ |
| OCR 文字识别 | √ | √ | √ | √ |
| 网页终端 | √ | √ | √ | √ |
| 网络串口终端 | x | x | √ | √ |
| HDMI 切换器支持 | √ | √ | √ | √ |
| 视频录制 | √ | x | x | x |
| Docker 部署 | √ | x | x | x |
| 官方商业化成品 | x | √ | √ | √ |
| 技术支持 | √ | √ | √ | √ |
### 快速开始 ### 快速开始
更多详细内容可以查阅 [One-KVM文档](https://one-kvm.mofeng.run/)。
**方式一Docker 镜像部署(推荐)** **方式一Docker 镜像部署(推荐)**
Docker 版本可以使用 OTG 或 CH9329 作为虚拟 HID ,支持 amd64、arm64、armv7 架构的 Linux 系统安装。 Docker 版本可以使用 OTG 或 CH9329 作为虚拟 HID ,支持 amd64、arm64、armv7 架构的 Linux 系统安装。
**脚本部署**
```bash
curl -sSL https://one-kvm.mofeng.run/quick_start.sh -o quick_start.sh && bash quick_start.sh
```
**手动部署**
如果使用 OTG 作为虚拟 HID可以使用如下部署命令 如果使用 OTG 作为虚拟 HID可以使用如下部署命令
```bash ```bash
@@ -27,29 +64,34 @@ sudo docker run --name kvmd -itd --privileged=true \
silentwind0/kvmd silentwind0/kvmd
``` ```
如果使用 CH9329可以使用如下部署命令 如果使用 CH9329 作为虚拟 HID,可以使用如下部署命令:
```bash ```bash
sudo docker run --name kvmd -itd \ sudo docker run --name kvmd -itd \
--device /dev/video0:/dev/video0 \ --device /dev/video0:/dev/video0 \
--device /dev/ttyUSB0:/dev/ttyUSB0 \ --device /dev/ttyUSB0:/dev/ttyUSB0 \
--device /dev/snd:/dev/snd \
-p 8080:8080 -p 4430:4430 -p 5900:5900 -p 623:623 \ -p 8080:8080 -p 4430:4430 -p 5900:5900 -p 623:623 \
silentwind0/kvmd silentwind0/kvmd
``` ```
部署完成访问 https://IP:4430 ,点击信任自签证书即可开始使用默认账号密码admin/admin。 **方式二:直刷 One-KVM 整合包**
如无法访问可以使用 `sudo docker logs kvmd` 命令查看日志尝试修复、提交 issue 或在 QQ 群内寻求帮助 对于部分平台硬件,本项目制作了深度适配的 One-KVM 打包镜像,开箱即用,刷好后启动设备就可以开始使用 One-KVM。免费 One-KVM 整合包也可以在本项目 Releases 页可以找到
详细内容可以查阅 [One-KVM文档](https://one-kvm.mofeng.run/)。 | 整合包适配概况 | | | |
| :-------------: | :-------------: | :-------------: | :-------------: |
| **固件型号** | **固件代号** | **硬件情况** | **最新版本** |
| 玩客云 | Onecloud | USB 采集卡、OTG | 241018 |
| 私家云二代 | Cumebox2 | USB 采集卡、OTG | 241004 |
| Vmare | Vmare-uefi | USB 采集卡、CH9329 | 241004 |
| Virtualbox | Virtualbox-uefi | USB 采集卡、CH9329 | 241004 |
| s905l3a 通用包 | E900v22c | USB 采集卡、OTG | 241004 |
| 我家云 | Chainedbox | USB 采集卡、OTG | 241004 |
| 龙芯久久派 | 2k0300 | USB 采集卡、CH9329 | 241025 |
**方式二:直刷 One-KVM 镜像** ### 赞助方式
对于玩客云设备,本项目 Releases 页可以找到适配玩客云的 One-KVM 预编译镜像。镜像名称带 One-KVM 前缀、burn 后缀的为线刷镜像,可使用 USB_Burning_Tool 软件线刷至玩客云。预编译线刷镜像为开箱即用,刷好后启动设备就可以开始使用 One-KVM 这个项目基于众多开源项目二次开发,作者为此花费了大量的时间和精力进行测试和维护。若此项目对您有用,您可以考虑通过 **[为爱发电](https://afdian.com/a/silentwind)** 赞助一笔小钱支持作者。作者将能有更多的金钱来测试和维护 One-KVM 的各种配置,并在项目上投入更多的时间和精力
**赞助**
这个项目基于众多开源项目二次开发,作者为此花费了大量的时间和精力进行测试和维护。若此项目对您有用,您可以考虑通过 [为爱发电](https://afdian.com/a/silentwind) 赞助一笔小钱支持作者。作者将能够购买新的硬件(玩客云和周边设备)来测试和维护 One-KVM 的各种配置,并在项目上投入更多的时间。
**感谢名单** **感谢名单**
@@ -79,19 +121,29 @@ Will
霜序 霜序
[远方](https://runyf.cn/) [远方](https://runyf.cn/)(闲鱼用户名:小远技术店铺)
爱发电用户_399fc 爱发电用户_399fc
[斐斐の](https://www.mmuaa.com/) [斐斐の](https://www.mmuaa.com/)
爱发电用户_09451
超高校级的錆鱼
爱发电用户_08cff
guoke
mgt
...... ......
</details> </details>
本项目使用了下列开源项目: 本项目使用了下列开源项目:
1. [pikvm/pikvm: Open and inexpensive DIY IP-KVM based on Raspberry Pi (github.com)](https://github.com/pikvm/pikvm) 1. [pikvm/pikvm: Open and inexpensive DIY IP-KVM based on Raspberry Pi (github.com)](https://github.com/pikvm/pikvm)
**状态** ### 项目状态
[![Star History Chart](https://api.star-history.com/svg?repos=mofeng-git/One-KVM&type=Date)](https://star-history.com/#mofeng-git/One-KVM&Date) [![Star History Chart](https://api.star-history.com/svg?repos=mofeng-git/One-KVM&type=Date)](https://star-history.com/#mofeng-git/One-KVM&Date)

View File

@@ -1 +0,0 @@
[python: kvmd/**.py]

View File

@@ -18,12 +18,13 @@ ENV TZ=Asia/Shanghai
RUN cp /tmp/lib/* /lib/*-linux-*/ \ 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 /tmp/wheel/*.whl \
&& pip install --no-cache-dir --root-user-action=ignore --disable-pip-version-check pyfatfs \
&& rm -rf /tmp/lib /tmp/wheel && 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 libxkbcommon-x11-0 nginx tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim iptables sudo curl kmod \
libmicrohttpd12 libjansson4 libssl3 libsofia-sip-ua0 libglib2.0-0 libopus0 libogg0 libcurl4 libconfig9 libusrsctp2 libwebsockets17 libnss3 libasound2 \ libmicrohttpd12 libjansson4 libssl3 libsofia-sip-ua0 libglib2.0-0 libopus0 libogg0 libcurl4 libconfig9 libusrsctp2 libwebsockets17 libnss3 libasound2 nano \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN if [ ${TARGETARCH} = arm ]; then ARCH=armhf; elif [ ${TARGETARCH} = arm64 ]; then ARCH=aarch64; elif [ ${TARGETARCH} = amd64 ]; then ARCH=x86_64; fi \ RUN if [ ${TARGETARCH} = arm ]; then ARCH=armhf; elif [ ${TARGETARCH} = arm64 ]; then ARCH=aarch64; elif [ ${TARGETARCH} = amd64 ]; then ARCH=x86_64; fi \
@@ -31,11 +32,11 @@ RUN if [ ${TARGETARCH} = arm ]; then ARCH=armhf; elif [ ${TARGETARCH} = arm64 ];
&& 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 /opt/vc/bin /run/kvmd /tmp/kvmd-nginx \ && mkdir -p /etc/kvmd_backup/override.d /var/lib/kvmd/msd/images /var/lib/kvmd/msd/meta /var/lib/kvmd/pst/data /var/lib/kvmd/msd/NormalFiles /opt/vc/bin /run/kvmd /tmp/kvmd-nginx \
&& touch /run/kvmd/ustreamer.sock && touch /run/kvmd/ustreamer.sock
COPY testenv/fakes/vcgencmd /usr/bin/ COPY testenv/fakes/vcgencmd scripts/kvmd* /usr/bin/
COPY extras/ /usr/share/kvmd/extras/ COPY extras/ /usr/share/kvmd/extras/
COPY web/ /usr/share/kvmd/web/ COPY web/ /usr/share/kvmd/web/
COPY scripts/kvmd-gencert /usr/share/kvmd/ COPY scripts/kvmd-gencert /usr/share/kvmd/

View File

@@ -1,129 +1,270 @@
#!/bin/bash #!/bin/bash
#File List SRCPATH=/mnt/sda1/src
#src BOOTFS=/tmp/bootfs
#└── image
# ├── cumebox2
# │ └── Armbian_24.8.1_Khadas-vim1_bookworm_current_6.6.47_minimal.img
# └── onecloud
# ├── AmlImg_v0.3.1_linux_amd64
# ├── Armbian_by-SilentWind_24.5.0-trunk_Onecloud_bookworm_legacy_5.9.0-rc7_minimal.burn.img
# └── rc.local
#预处理镜像文件
SRCPATH=../src
ROOTFS=/tmp/rootfs ROOTFS=/tmp/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 OUTPUTDIR=/mnt/sda1/output
simg2img $SRCPATH/tmp/7.rootfs.PARTITION.sparse $SRCPATH/tmp/rootfs.img LOOPDEV=/dev/loop10
dd if=/dev/zero of=/tmp/add.img bs=1M count=800 && cat /tmp/add.img >> $SRCPATH/tmp/rootfs.img && rm /tmp/add.img DATE=241018
e2fsck -f $SRCPATH/tmp/rootfs.img && resize2fs $SRCPATH/tmp/rootfs.img export LC_ALL=C
#挂载镜像文件 write_meta() {
mkdir $ROOTFS sudo chroot --userspec "root:root" $ROOTFS bash -c "sed -i 's/localhost.localdomain/$1/g' /etc/kvmd/meta.yaml"
sudo mount $SRCPATH/tmp/rootfs.img $ROOTFS || exit -1 }
sudo mount -t proc proc $ROOTFS/proc || exit -1
sudo mount -t sysfs sys $ROOTFS/sys || exit -1
sudo mount -o bind /dev $ROOTFS/dev || exit -1
#准备文件 mount_rootfs() {
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 \ mkdir $ROOTFS
$ROOTFS/usr/share/janus/javascript $ROOTFS/usr/lib/ustreamer/janus $ROOTFS/run/kvmd $ROOTFS/var/lib/kvmd/msd/images $ROOTFS/var/lib/kvmd/msd/meta sudo mount $LOOPDEV $ROOTFS || exit -1
sudo cp -r ../One-KVM $ROOTFS/ sudo mount -t proc proc $ROOTFS/proc || exit -1
sudo cp $SRCPATH/image/onecloud/rc.local $ROOTFS/etc/ sudo mount -t sysfs sys $ROOTFS/sys || exit -1
sudo cp -r $ROOTFS/One-KVM/configs/kvmd/* $ROOTFS/One-KVM/configs/nginx $ROOTFS/One-KVM/configs/janus \ sudo mount -o bind /dev $ROOTFS/dev || exit -1
$ROOTFS/etc/kvmd }
sudo cp -r $ROOTFS/One-KVM/web $ROOTFS/One-KVM/extras $ROOTFS/One-KVM/contrib/keymaps $ROOTFS/usr/share/kvmd
sudo cp $ROOTFS/One-KVM/build/platform/onecloud $ROOTFS/usr/share/kvmd/platform
sudo cp $ROOTFS/One-KVM/testenv/fakes/vcgencmd $ROOTFS/usr/bin/
sudo cp -r $ROOTFS/One-KVM/testenv/js/* $ROOTFS/usr/share/janus/javascript/
#安装依赖 umount_rootfs() {
sudo chroot --userspec "root:root" $ROOTFS bash -c " \ sudo umount $ROOTFS/sys
apt update \ sudo umount $ROOTFS/dev
&& apt install -y python3-aiofiles python3-aiohttp python3-appdirs python3-asn1crypto python3-async-timeout \ sudo umount $ROOTFS/proc
python3-bottle python3-cffi python3-chardet python3-click python3-colorama python3-cryptography python3-dateutil \ sudo umount $ROOTFS
python3-dbus python3-dev python3-hidapi python3-idna python3-libgpiod python3-mako python3-marshmallow python3-more-itertools \ sudo losetup -d $LOOPDEV
python3-multidict python3-netifaces python3-packaging python3-passlib python3-pillow python3-ply python3-psutil \ }
python3-pycparser python3-pyelftools python3-pyghmi python3-pygments python3-pyparsing python3-requests \
python3-semantic-version python3-setproctitle python3-setuptools python3-six python3-spidev python3-systemd \
python3-tabulate python3-urllib3 python3-wrapt python3-xlib python3-yaml python3-yarl python3-pyotp python3-qrcode \
python3-serial python3-zstandard python3-dbus-next \
&& apt install -y nginx python3-pip python3-dev python3-build net-tools tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim \
git gpiod libxkbcommon0 build-essential janus-dev libssl-dev libffi-dev libevent-dev libjpeg-dev libbsd-dev libudev-dev \
pkg-config libx264-dev libyuv-dev libasound2-dev libsndfile-dev libspeexdsp-dev cpufrequtils iptables\
&& apt clean "
sudo chroot --userspec "root:root" $ROOTFS bash -c " \ parpare_dns() {
pip3 config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple \ sudo chroot --userspec "root:root" $ROOTFS bash -c " \
&& pip3 install --target=/usr/lib/python3/dist-packages --break-system-packages async-lru gpiod \ mkdir -p /run/systemd/resolve/ \
&& pip3 cache purge " && touch /run/systemd/resolve/stub-resolv.conf \
&& printf '%s\n' 'nameserver 1.1.1.1' 'nameserver 1.0.0.1' > /etc/resolv.conf \
&& bash <(curl -sSL https://gitee.com/SuperManito/LinuxMirrors/raw/main/ChangeMirrors.sh) \
--source mirrors.tuna.tsinghua.edu.cn --updata-software false --web-protocol http "
}
sudo chroot --userspec "root:root" $ROOTFS sed --in-place --expression 's|^#include "refcount.h"$|#include "../refcount.h"|g' /usr/include/janus/plugins/plugin.h config_file() {
sudo mkdir -p $ROOTFS/etc/kvmd/override.d $ROOTFS/etc/kvmd/vnc $ROOTFS/var/lib/kvmd/msd $ROOTFS/opt/vc/bin $ROOTFS/usr/share/kvmd $ROOTFS/One-KVM \
$ROOTFS/usr/share/janus/javascript $ROOTFS/usr/lib/ustreamer/janus $ROOTFS/run/kvmd $ROOTFS/var/lib/kvmd/msd/images $ROOTFS/var/lib/kvmd/msd/meta
sudo rsync -a --exclude={src,.github} . $ROOTFS/One-KVM
sudo cp -r configs/kvmd/* configs/nginx configs/janus $ROOTFS/etc/kvmd
sudo cp -r web extras contrib/keymaps $ROOTFS/usr/share/kvmd
sudo cp testenv/fakes/vcgencmd $ROOTFS/usr/bin/
sudo cp -r testenv/js/* $ROOTFS/usr/share/janus/javascript/
sudo cp build/platform/$1 $ROOTFS/usr/share/kvmd/platform
if [ -f "$SRCPATH/image/$1/rc.local" ]; then
sudo cp $SRCPATH/image/$1/rc.local $ROOTFS/etc/
fi
}
sudo chroot --userspec "root:root" $ROOTFS bash -c " \ pack_img() {
git clone --depth=1 https://github.com/mofeng-git/ustreamer /tmp/ustreamer \ sudo mv $SRCPATH/tmp/rootfs.img $OUTPUTDIR/One-KVM_by-SilentWind_$1_$DATE.img
&& make -j WITH_PYTHON=1 WITH_JANUS=1 WITH_LIBX264=1 -C /tmp/ustreamer \ if [ "$1" = "Vm" ]; then
&& mv /tmp/ustreamer/src/ustreamer.bin /usr/bin/ustreamer \ sudo qemu-img convert -f raw -O vmdk $OUTPUTDIR/One-KVM_by-SilentWind_Vm_$DATE.img $OUTPUTDIR/One-KVM_by-SilentWind_Vmare-uefi_$DATE.vmdk
&& mv /tmp/ustreamer/src/ustreamer-dump.bin /usr/bin/ustreamer-dump \ sudo qemu-img convert -f raw -O vdi $OUTPUTDIR/One-KVM_by-SilentWind_Vm_$DATE.img $OUTPUTDIR/One-KVM_by-SilentWind_Virtualbox-uefi_$DATE.vdi
&& chmod +x /usr/bin/ustreamer /usr/bin/ustreamer-dump \ fi
&& 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 "
#安装 kvmd 主程序 onecloud_rootfs() {
sudo chroot --userspec "root:root" $ROOTFS bash -c " \ $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
cd /One-KVM \ simg2img $SRCPATH/tmp/7.rootfs.PARTITION.sparse $SRCPATH/tmp/rootfs.img
&& python3 setup.py install \ dd if=/dev/zero of=/tmp/add.img bs=1M count=1024 && cat /tmp/add.img >> $SRCPATH/tmp/rootfs.img && rm /tmp/add.img
&& bash scripts/kvmd-gencert --do-the-thing \ e2fsck -f $SRCPATH/tmp/rootfs.img && resize2fs $SRCPATH/tmp/rootfs.img
&& bash scripts/kvmd-gencert --do-the-thing --vnc \ sudo losetup $LOOPDEV $SRCPATH/tmp/rootfs.img
&& kvmd-nginx-mkconf /etc/kvmd/nginx/nginx.conf.mako /etc/kvmd/nginx/nginx.conf \ }
&& kvmd -m "
sudo chroot --userspec "root:root" $ROOTFS bash -c " \ cumebox2_rootfs() {
curl https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.armhf -L -o /usr/bin/ttyd \ cp $SRCPATH/image/cumebox2/Armbian_24.8.1_Khadas-vim1_bookworm_current_6.6.47_minimal.img $SRCPATH/tmp/rootfs.img
&& chmod +x /usr/bin/ttyd \ dd if=/dev/zero of=/tmp/add.img bs=1M count=1500 && cat /tmp/add.img >> $SRCPATH/tmp/rootfs.img && rm /tmp/add.img
&& systemd-sysusers /One-KVM/configs/os/kvmd-webterm.conf \ sudo parted -s $SRCPATH/tmp/rootfs.img resizepart 1 100% || exit -1
&& mkdir -p /home/kvmd-webterm \ sudo losetup --offset $((8192*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img || exit -1
&& chown kvmd-webterm /home/kvmd-webterm " sudo e2fsck -f $LOOPDEV && sudo resize2fs $LOOPDEV
}
chainedbox_rootfs_and_fix_dtb() {
cp $SRCPATH/image/chainedbox/Armbian_24.11.0_rockchip_chainedbox_bookworm_6.1.112_server_2024.10.02_add800m.img $SRCPATH/tmp/rootfs.img
mkdir $BOOTFS
sudo losetup --offset $((32768*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img || exit -1
sudo mount $LOOPDEV $BOOTFS
sudo cp $SRCPATH/image/chainedbox/rk3328-l1pro-1296mhz-fix.dtb $BOOTFS/dtb/rockchip/rk3328-l1pro-1296mhz.dtb
sudo umount $BOOTFS
sudo losetup -d $LOOPDEV
sudo losetup --offset $((1081344*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img
}
vm_rootfs() {
cp $SRCPATH/image/vm/Armbian_24.8.1_Uefi-x86_bookworm_current_6.6.47_minimal_add1g.img $SRCPATH/tmp/rootfs.img
sudo losetup --offset $((540672*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img || exit -1
}
e900v22c_rootfs() {
cp $SRCPATH/image/e900v22c/Armbian_23.08.0_amlogic_s905l3a_bookworm_5.15.123_server_2023.08.01.img $SRCPATH/tmp/rootfs.img
dd if=/dev/zero of=/tmp/add.img bs=1M count=400 && cat /tmp/add.img >> $SRCPATH/tmp/rootfs.img && rm /tmp/add.img
sudo parted -s $SRCPATH/tmp/rootfs.img resizepart 2 100% || exit -1
sudo losetup --offset $((532480*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img || exit -1
sudo e2fsck -f $LOOPDEV && sudo resize2fs $LOOPDEV
}
#服务自启 config_cumebox2_file() {
sudo chroot --userspec "root:root" $ROOTFS bash -c " \ sudo mkdir $ROOTFS/etc/oled
cat /One-KVM/configs/os/sudoers/v2-hdmiusb >> /etc/sudoers \ sudo cp $SRCPATH/image/cumebox2/v-fix.dtb $ROOTFS/boot/dtb/amlogic/meson-gxl-s905x-khadas-vim.dtb
&& cat /One-KVM/configs/os/udev/v2-hdmiusb-generic.rules > /etc/udev/rules.d/99-kvmd.rules \ sudo cp $SRCPATH/image/cumebox2/ssd $ROOTFS/usr/bin/
&& echo 'libcomposite' >> /etc/modules \ sudo cp $SRCPATH/image/cumebox2/config.json $ROOTFS/etc/oled/config.json
&& mv /usr/local/bin/kvmd* /usr/bin \ }
&& cp /One-KVM/configs/os/services/* /etc/systemd/system/ \
&& cp /One-KVM/configs/os/tmpfiles.conf /usr/lib/tmpfiles.d/ \
&& chmod +x /etc/update-motd.d/* \
&& echo 'kvmd ALL=(ALL) NOPASSWD: /etc/kvmd/custom_atx/gpio.sh' >> /etc/sudoers \
&& echo 'kvmd ALL=(ALL) NOPASSWD: /etc/kvmd/custom_atx/usbrelay_hid.sh' >> /etc/sudoers \
&& systemd-sysusers /One-KVM/configs/os/sysusers.conf \
&& ln -sf /usr/share/tesseract-ocr/*/tessdata /usr/share/tessdata \
&& sed -i 's/ch9329/otg/g' /etc/kvmd/override.yaml \
&& sed -i 's/device: \/dev\/ttyUSB0//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/#type: otg/type: otg/g' /etc/kvmd/override.yaml \
&& chown kvmd -R /var/lib/kvmd/msd/ \
&& sed -i 's/localhost.localdomain/onecloud/g' /etc/kvmd/meta.yaml \
&& systemctl enable kvmd kvmd-otg kvmd-nginx kvmd-vnc kvmd-ipmi kvmd-webterm kvmd-janus \
&& systemctl disable nginx janus \
&& rm -r /One-KVM "
instal_one-kvm() {
#$1 arch; $2 deivce: "gpio" or "video1"; $3 network: "systemd-networkd",default is network-manager
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
df -h \
&& apt update \
&& apt install -y python3-aiofiles python3-aiohttp python3-appdirs python3-asn1crypto python3-async-timeout \
python3-bottle python3-cffi python3-chardet python3-click python3-colorama python3-cryptography python3-dateutil \
python3-dbus python3-dev python3-hidapi python3-hid python3-idna python3-libgpiod python3-mako python3-marshmallow python3-more-itertools \
python3-multidict python3-netifaces python3-packaging python3-passlib python3-pillow python3-ply python3-psutil \
python3-pycparser python3-pyelftools python3-pyghmi python3-pygments python3-pyparsing python3-requests \
python3-semantic-version python3-setproctitle python3-setuptools python3-six python3-spidev python3-systemd \
python3-tabulate python3-urllib3 python3-wrapt python3-xlib python3-yaml python3-yarl python3-pyotp python3-qrcode \
python3-serial python3-zstandard python3-dbus-next \
&& apt install -y nginx python3-pip python3-dev python3-build net-tools tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim \
git gpiod libxkbcommon0 build-essential janus-dev libssl-dev libffi-dev libevent-dev libjpeg-dev libbsd-dev libudev-dev \
pkg-config libx264-dev libyuv-dev libasound2-dev libsndfile-dev libspeexdsp-dev cpufrequtils iptables network-manager \
&& rm -rf /var/lib/apt/lists/* "
sudo chroot --userspec "root:root" $ROOTFS bash -c " \ if [ "$3" = "systemd-networkd" ]; then
sed -i '2c ATX=GPIO' /etc/kvmd/atx.sh \ sudo chroot --userspec "root:root" $ROOTFS bash -c " \
&& sed -i 's/SHUTDOWNPIN/gpiochip1 7/g' /etc/kvmd/custom_atx/gpio.sh \ echo -e '[Match]\nName=eth0\n\n[Network]\nDHCP=yes\n\n[Link]\nMACAddress=B6:AE:B3:21:42:0C' > /etc/systemd/network/99-eth0.network \
&& sed -i 's/REBOOTPIN/gpiochip0 11/g' /etc/kvmd/custom_atx/gpio.sh " && systemctl mask NetworkManager \
&& systemctl unmask systemd-networkd \
&& systemctl enable systemd-networkd systemd-resolved "
fi
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
pip3 config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple \
&& pip3 install --target=/usr/lib/python3/dist-packages --break-system-packages async-lru gpiod \
&& pip3 cache purge "
#卸载镜像 sudo chroot --userspec "root:root" $ROOTFS sed --in-place --expression 's|^#include "refcount.h"$|#include "../refcount.h"|g' /usr/include/janus/plugins/plugin.h
sudo umount $ROOTFS/sys
sudo umount $ROOTFS/dev
sudo umount $ROOTFS/proc
sudo umount $ROOTFS
#打包镜像 sudo chroot --userspec "root:root" $ROOTFS bash -c " \
sudo rm $SRCPATH/tmp/7.rootfs.PARTITION.sparse git clone --depth=1 https://github.com/mofeng-git/ustreamer /tmp/ustreamer \
sudo img2simg $SRCPATH/tmp/rootfs.img $SRCPATH/tmp/7.rootfs.PARTITION.sparse && make -j WITH_PYTHON=1 WITH_JANUS=1 WITH_LIBX264=1 -C /tmp/ustreamer \
sudo $SRCPATH/image/onecloud/AmlImg_v0.3.1_linux_amd64 pack $SRCPATH/output/One-KVM_by-SilentWind_Onecloud_241004.burn.img $SRCPATH/tmp/ && mv /tmp/ustreamer/src/ustreamer.bin /usr/bin/ustreamer \
sudo rm $SRCPATH/tmp/* && mv /tmp/ustreamer/src/ustreamer-dump.bin /usr/bin/ustreamer-dump \
&& chmod +x /usr/bin/ustreamer /usr/bin/ustreamer-dump \
&& mv /tmp/ustreamer/janus/libjanus_ustreamer.so /usr/lib/ustreamer/janus \
&& pip3 install --target=/usr/lib/python3/dist-packages --break-system-packages /tmp/ustreamer/python/dist/*.whl "
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
cd /One-KVM \
&& python3 setup.py install \
&& bash scripts/kvmd-gencert --do-the-thing \
&& bash scripts/kvmd-gencert --do-the-thing --vnc \
&& kvmd-nginx-mkconf /etc/kvmd/nginx/nginx.conf.mako /etc/kvmd/nginx/nginx.conf \
&& kvmd -m "
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
cat /One-KVM/configs/os/sudoers/v2-hdmiusb >> /etc/sudoers \
&& cat /One-KVM/configs/os/udev/v2-hdmiusb-generic.rules > /etc/udev/rules.d/99-kvmd.rules \
&& echo 'libcomposite' >> /etc/modules \
&& mv /usr/local/bin/kvmd* /usr/bin \
&& cp /One-KVM/configs/os/services/* /etc/systemd/system/ \
&& cp /One-KVM/configs/os/tmpfiles.conf /usr/lib/tmpfiles.d/ \
&& chmod +x /etc/update-motd.d/* \
&& echo 'kvmd ALL=(ALL) NOPASSWD: /etc/kvmd/custom_atx/gpio.sh' >> /etc/sudoers \
&& echo 'kvmd ALL=(ALL) NOPASSWD: /etc/kvmd/custom_atx/usbrelay_hid.sh' >> /etc/sudoers \
&& systemd-sysusers /One-KVM/configs/os/sysusers.conf \
&& systemd-sysusers /One-KVM/configs/os/kvmd-webterm.conf \
&& ln -sf /usr/share/tesseract-ocr/*/tessdata /usr/share/tessdata \
&& sed -i 's/8080/80/g' /etc/kvmd/override.yaml \
&& sed -i 's/4430/443/g' /etc/kvmd/override.yaml \
&& chown kvmd -R /var/lib/kvmd/msd/ \
&& systemctl enable kvmd kvmd-otg kvmd-nginx kvmd-vnc kvmd-ipmi kvmd-webterm kvmd-janus \
&& systemctl disable nginx janus \
&& rm -r /One-KVM "
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
curl https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.$1 -L -o /usr/bin/ttyd \
&& chmod +x /usr/bin/ttyd \
&& mkdir -p /home/kvmd-webterm \
&& chown kvmd-webterm /home/kvmd-webterm "
if [ "$1" = "x86_64" ]; then
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
systemctl disable kvmd-otg \
&& sed -i '2c ATX=USBRELAY_HID' /etc/kvmd/atx.sh \
&& sed -i 's/device: \/dev\/ttyUSB0/device: \/dev\/kvmd-hid/g' /etc/kvmd/override.yaml "
else
if [ "$2" = "gpio" ]; then
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
sed -i '2c ATX=GPIO' /etc/kvmd/atx.sh \
&& sed -i 's/SHUTDOWNPIN/gpiochip1 7/g' /etc/kvmd/custom_atx/gpio.sh \
&& sed -i 's/REBOOTPIN/gpiochip0 11/g' /etc/kvmd/custom_atx/gpio.sh "
else
sudo chroot --userspec "root:root" $ROOTFS sed -i '2c ATX=USBRELAY_HID' /etc/kvmd/atx.sh
fi
if [ "$2" = "video1" ]; then
sudo chroot --userspec "root:root" $ROOTFS sed -i 's/\/dev\/video0/\/dev\/video1/g' /etc/kvmd/override.yaml
fi
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
sed -i 's/ch9329/otg/g' /etc/kvmd/override.yaml \
&& sed -i 's/device: \/dev\/ttyUSB0//g' /etc/kvmd/override.yaml \
&& sed -i 's/#type: otg/type: otg/g' /etc/kvmd/override.yaml "
fi
}
pack_img_onecloud() {
sudo rm $SRCPATH/tmp/7.rootfs.PARTITION.sparse
sudo img2simg $SRCPATH/tmp/rootfs.img $SRCPATH/tmp/7.rootfs.PARTITION.sparse
sudo $SRCPATH/image/onecloud/AmlImg_v0.3.1_linux_amd64 pack $OUTPUTDIR/One-KVM_by-SilentWind_Onecloud_$DATE.burn.img $SRCPATH/tmp/
sudo rm $SRCPATH/tmp/*
}
case $1 in
onecloud)
onecloud_rootfs
mount_rootfs
config_file $1
instal_one-kvm armhf gpio systemd-networkd
write_meta $1
umount_rootfs
pack_img_onecloud
;;
cumebox2)
cumebox2_rootfs
mount_rootfs
config_file $1
config_cumebox2_file
parpare_dns
instal_one-kvm aarch64 video1
write_meta $1
umount_rootfs
pack_img Cumebox2
;;
chainedbox)
chainedbox_rootfs_and_fix_dtb
mount_rootfs
config_file $1
parpare_dns
instal_one-kvm aarch64 video1
write_meta $1
umount_rootfs
pack_img Chainedbox
;;
vm)
vm_rootfs
mount_rootfs
config_file $1
parpare_dns
instal_one-kvm x86_64
write_meta $1
umount_rootfs
pack_img Vm
;;
e900v22c)
e900v22c_rootfs
mount_rootfs
config_file $1
instal_one-kvm aarch64 video1
write_meta $1
umount_rootfs
pack_img E900v22c
;;
*)
echo "Do no thing."
;;
esac

View File

@@ -100,15 +100,36 @@ EOF
echo -e "${GREEN}One-KVM OTG is enabled.${NC}" echo -e "${GREEN}One-KVM OTG is enabled.${NC}"
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
echo -e "${GREEN}One-KVM MSD is disabled.${NC}"
else
sed -i "s/#type: otg/type: otg/g" /etc/kvmd/override.yaml
fi
fi fi
#if [ ! -z "$SHUTDOWNPIN" ! -z "$REBOOTPIN" ]; then #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 \ sed -i "s/\/dev\/video0/\/dev\/video$VIDEONUM/g" /etc/kvmd/override.yaml \
&& sed -i "s/\/dev\/video0/\/dev\/video$VIDEONUM/g" /etc/kvmd/janus/janus.plugin.ustreamer.jcfg \
&& echo -e "${GREEN}One-KVM video device is set to /dev/video$VIDEONUM.${NC}" && echo -e "${GREEN}One-KVM video device is set to /dev/video$VIDEONUM.${NC}"
fi fi
if [ ! -z "$AUDIONUM" ]; then
sed -i "s/hw:0/hw:$AUDIONUM/g" /etc/kvmd/janus/janus.plugin.ustreamer.jcfg \
&& echo -e "${GREEN}One-KVM audio device is set to hw:$VIDEONUM.${NC}"
fi
if [ ! -z "$CH9329SPEED" ]; then
sed -i "s/speed: 9600/speed: $CH9329SPEED/g" /etc/kvmd/override.yaml \
&& echo -e "${GREEN}One-KVM CH9329 serial speed is set to $CH9329SPEED.${NC}"
fi
if [ ! -z "$CH9329TIMEOUT" ]; then
sed -i "s/read_timeout: 0.3/read_timeout: $CH9329TIMEOUT/g" /etc/kvmd/override.yaml \
&& echo -e "${GREEN}One-KVM CH9329 timeout is set to $CH9329TIMEOUT s.${NC}"
fi
#set htpasswd #set htpasswd
if [ ! -z "$USERNAME" ] && [ ! -z "$PASSWORD" ]; then if [ ! -z "$USERNAME" ] && [ ! -z "$PASSWORD" ]; then
python -m kvmd.apps.htpasswd del admin \ python -m kvmd.apps.htpasswd del admin \
@@ -117,18 +138,18 @@ EOF
&& echo "$USERNAME:$PASSWORD -> $USERNAME:$PASSWORD" > /etc/kvmd/ipmipasswd \ && echo "$USERNAME:$PASSWORD -> $USERNAME:$PASSWORD" > /etc/kvmd/ipmipasswd \
|| echo -e "${RED}One-KVM htpasswd init failed.${NC}" || echo -e "${RED}One-KVM htpasswd init failed.${NC}"
else else
echo -e "${YELLOW} USERNAME and PASSWORD environment variables is not set, using defalut(admin/admin).${NC}" echo -e "${YELLOW} USERNAME and PASSWORD environment variables are not set, using defalut(admin/admin).${NC}"
fi fi
if [ "$NOMSD" == 1 ]; then if [ ! -z "$VIDEOFORMAT" ]; then
echo -e "${GREEN}One-KVM MSD is disabled.${NC}" sed -i "s/format=mjpeg/format=$VIDFORMAT/g" /etc/kvmd/override.yaml \
else && echo -e "${GREEN}One-KVM input video format is set to $VIDFORMAT.${NC}"
sed -i "s/#type: otg/type: otg/g" /etc/kvmd/override.yaml
fi fi
touch /etc/kvmd/.init_flag touch /etc/kvmd/.init_flag
fi fi
#Trying usb_gadget #Trying usb_gadget
if [ "$OTG" == "1" ]; then if [ "$OTG" == "1" ]; then
echo "Trying OTG Port..." echo "Trying OTG Port..."
@@ -138,6 +159,7 @@ if [ "$OTG" == "1" ]; then
&& ln -s /dev/hidg1 /dev/kvmd-hid-mouse \ && ln -s /dev/hidg1 /dev/kvmd-hid-mouse \
&& ln -s /dev/hidg0 /dev/kvmd-hid-keyboard \ && ln -s /dev/hidg0 /dev/kvmd-hid-keyboard \
|| echo -e "${RED}OTG Port mount failed.${NC}" || echo -e "${RED}OTG Port mount failed.${NC}"
ln -s /dev/hidg2 /dev/kvmd-hid-mouse-alt
fi fi
echo -e "${GREEN}One-KVM starting...${NC}" echo -e "${GREEN}One-KVM starting...${NC}"

View File

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

3
build/platform/cumebox2 Normal file
View File

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

3
build/platform/e900v22c Normal file
View File

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

3
build/platform/vm Normal file
View File

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

View File

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

View File

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

View File

@@ -6,4 +6,9 @@
server: server:
host: localhost.localdomain host: localhost.localdomain
kvm: {} kvm: {
base_on: PiKVM,
app_name: One-KVM,
majaro_version: 241204,
author: SilentWind
}

View File

@@ -2,16 +2,14 @@ kvmd:
auth: auth:
enabled: true enabled: true
server:
unix_mode: 0666
access_log_format: '[%P / %{X-Real-IP}i] ''%r'' => 响应:%s大小%b来源''%{Referer}i'';用户代理:''%{User-Agent}i'''
atx: atx:
type: disabled type: disabled
hid: hid:
type: ch9329 type: ch9329
device: /dev/ttyUSB0 device: /dev/ttyUSB0
speed: 9600
read_timeout: 0.3
jiggler: jiggler:
active: false active: false
@@ -23,6 +21,9 @@ kvmd:
msd: msd:
#type: otg #type: otg
remount_cmd: /bin/true remount_cmd: /bin/true
msd_path: /var/lib/kvmd/msd
normalfiles_path: NormalFiles
normalfiles_size: 256
ocr: ocr:
langs: langs:
@@ -31,7 +32,7 @@ kvmd:
streamer: streamer:
resolution: resolution:
default: 1280x720 default: 1920x1080
forever: true forever: true
@@ -40,7 +41,7 @@ kvmd:
max: 60 max: 60
h264_bitrate: h264_bitrate:
default: 2000 default: 8000
cmd: cmd:
- "/usr/bin/ustreamer" - "/usr/bin/ustreamer"
@@ -160,8 +161,3 @@ nginx:
port: 8080 port: 8080
https: https:
port: 4430 port: 4430
languages:
console: zh
web: zh

View File

@@ -0,0 +1,12 @@
[Unit]
Description=PiKVM - Display reboot message on the OLED
DefaultDependencies=no
[Service]
Type=oneshot
ExecStart=/bin/bash -c "kill -USR1 `systemctl show -P MainPID kvmd-oled`"
ExecStop=/bin/true
RemainAfterExit=yes
[Install]
WantedBy=reboot.target

View File

@@ -0,0 +1,14 @@
[Unit]
Description=PiKVM - Display shutdown message on the OLED
Conflicts=reboot.target
Before=shutdown.target poweroff.target halt.target
DefaultDependencies=no
[Service]
Type=oneshot
ExecStart=/bin/bash -c "kill -USR2 `systemctl show -P MainPID kvmd-oled`"
ExecStop=/bin/true
RemainAfterExit=yes
[Install]
WantedBy=shutdown.target

View File

@@ -0,0 +1,15 @@
[Unit]
Description=PiKVM - A small OLED daemon
After=systemd-modules-load.service
ConditionPathExists=/dev/i2c-1
[Service]
Type=simple
Restart=always
RestartSec=3
ExecStartPre=/usr/bin/kvmd-oled --interval=3 --clear-on-exit --image=@hello.ppm
ExecStart=/usr/bin/kvmd-oled
TimeoutStopSec=3
[Install]
WantedBy=multi-user.target

View File

@@ -1,15 +0,0 @@
[Unit]
Description=PiKVM - Video Passthrough on V4 Plus
Wants=dev-kvmd\x2dvideo.device
After=dev-kvmd\x2dvideo.device systemd-modules-load.service
[Service]
Type=simple
Restart=always
RestartSec=3
ExecStart=/usr/bin/ustreamer-v4p --unix-follow /run/kvmd/ustreamer.sock
TimeoutStopSec=10
[Install]
WantedBy=multi-user.target

View File

@@ -2,11 +2,11 @@
Description=PiKVM - EDID loader for TC358743 Description=PiKVM - EDID loader for TC358743
Wants=dev-kvmd\x2dvideo.device Wants=dev-kvmd\x2dvideo.device
After=dev-kvmd\x2dvideo.device systemd-modules-load.service After=dev-kvmd\x2dvideo.device systemd-modules-load.service
Before=kvmd.service kvmd-pass.service Before=kvmd.service
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/usr/bin/v4l2-ctl --device=/dev/kvmd-video --set-edid=file=/etc/kvmd/tc358743-edid.hex --fix-edid-checksums --info-edid ExecStart=/usr/bin/v4l2-ctl --device=/dev/kvmd-video --set-edid=file=/etc/kvmd/tc358743-edid.hex --info-edid
ExecStop=/usr/bin/v4l2-ctl --device=/dev/kvmd-video --clear-edid ExecStop=/usr/bin/v4l2-ctl --device=/dev/kvmd-video --clear-edid
RemainAfterExit=true RemainAfterExit=true

View File

@@ -19,6 +19,7 @@ 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-pst
m kvmd-pst kvmd m kvmd-pst kvmd

View File

@@ -27,7 +27,8 @@ post_upgrade() {
done done
chown kvmd /var/lib/kvmd/msd 2>/dev/null || true chown kvmd /var/lib/kvmd/msd 2>/dev/null || true
chown kvmd-pst /var/lib/kvmd/pst 2>/dev/null || true chown kvmd-pst:kvmd-pst /var/lib/kvmd/pst 2>/dev/null || true
chmod 1775 /var/lib/kvmd/pst 2>/dev/null || true
if [ ! -e /etc/kvmd/nginx/ssl/server.crt ]; then if [ ! -e /etc/kvmd/nginx/ssl/server.crt ]; then
echo "==> Generating KVMD-Nginx certificate ..." echo "==> Generating KVMD-Nginx certificate ..."
@@ -92,6 +93,15 @@ disable_overscan=1
EOF EOF
fi fi
if [[ "$(vercmp "$2" 4.4)" -lt 0 ]]; then
systemctl disable kvmd-pass || true
fi
if [[ "$(vercmp "$2" 4.5)" -lt 0 ]]; then
sed -i 's/X-kvmd\.pst-user=kvmd-pst/X-kvmd.pst-user=kvmd-pst,X-kvmd.pst-group=kvmd-pst/g' /etc/fstab
touch -t 200701011000 /etc/fstab
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.3" __version__ = "4.20"

View File

@@ -83,9 +83,9 @@ class AioReader: # pylint: disable=too-many-instance-attributes
self.__path, self.__path,
consumer=self.__consumer, consumer=self.__consumer,
config={tuple(pins): gpiod.LineSettings(edge_detection=gpiod.line.Edge.BOTH)}, config={tuple(pins): gpiod.LineSettings(edge_detection=gpiod.line.Edge.BOTH)},
) as line_request: ) as line_req:
line_request.wait_edge_events(0.1) line_req.wait_edge_events(0.1)
self.__values = { self.__values = {
pin: _DebouncedValue( pin: _DebouncedValue(
initial=bool(value.value), initial=bool(value.value),
@@ -93,14 +93,14 @@ class AioReader: # pylint: disable=too-many-instance-attributes
notifier=self.__notifier, notifier=self.__notifier,
loop=self.__loop, loop=self.__loop,
) )
for (pin, value) in zip(pins, line_request.get_values(pins)) for (pin, value) in zip(pins, line_req.get_values(pins))
} }
self.__loop.call_soon_threadsafe(self.__notifier.notify) self.__loop.call_soon_threadsafe(self.__notifier.notify)
while not self.__stop_event.is_set(): while not self.__stop_event.is_set():
if line_request.wait_edge_events(1): if line_req.wait_edge_events(1):
new: dict[int, bool] = {} new: dict[int, bool] = {}
for event in line_request.read_edge_events(): for event in line_req.read_edge_events():
(pin, value) = self.__parse_event(event) (pin, value) = self.__parse_event(event)
new[pin] = value new[pin] = value
for (pin, value) in new.items(): for (pin, value) in new.items():
@@ -110,7 +110,7 @@ class AioReader: # pylint: disable=too-many-instance-attributes
# Размер буфера ядра - 16 эвентов на линии. При превышении этого числа, # Размер буфера ядра - 16 эвентов на линии. При превышении этого числа,
# новые эвенты потеряются. Это не баг, это фича, как мне объяснили в LKML. # новые эвенты потеряются. Это не баг, это фича, как мне объяснили в LKML.
# Штош. Будем с этим жить и синхронизировать состояния при таймауте. # Штош. Будем с этим жить и синхронизировать состояния при таймауте.
for (pin, value) in zip(pins, line_request.get_values(pins)): for (pin, value) in zip(pins, line_req.get_values(pins)):
self.__values[pin].set(bool(value.value)) # type: ignore self.__values[pin].set(bool(value.value)) # type: ignore
def __parse_event(self, event: gpiod.EdgeEvent) -> tuple[int, bool]: def __parse_event(self, event: gpiod.EdgeEvent) -> tuple[int, bool]:

View File

@@ -22,8 +22,6 @@
import subprocess import subprocess
from .languages import Languages
from .logging import get_logger from .logging import get_logger
from . import tools from . import tools
@@ -38,13 +36,13 @@ async def remount(name: str, base_cmd: list[str], rw: bool) -> bool:
part.format(mode=mode) part.format(mode=mode)
for part in base_cmd for part in base_cmd
] ]
logger.info(Languages().gettext("Remounting %s storage to %s: %s ..."), name, mode.upper(), tools.cmdfmt(cmd)) logger.info("Remounting %s storage to %s: %s ...", name, mode.upper(), tools.cmdfmt(cmd))
try: try:
proc = await aioproc.log_process(cmd, logger) proc = await aioproc.log_process(cmd, logger)
if proc.returncode != 0: if proc.returncode != 0:
assert proc.returncode is not None assert proc.returncode is not None
raise subprocess.CalledProcessError(proc.returncode, cmd) raise subprocess.CalledProcessError(proc.returncode, cmd)
except Exception as err: except Exception as ex:
logger.error(Languages().gettext("Can't remount %s storage: %s"), name, tools.efmt(err)) logger.error("Can't remount %s storage: %s", name, tools.efmt(ex))
return False return False
return True return True

View File

@@ -59,14 +59,25 @@ def queue_get_last_sync( # pylint: disable=invalid-name
# ===== # =====
class AioProcessNotifier: class AioProcessNotifier:
def __init__(self) -> None: def __init__(self) -> None:
self.__queue: "multiprocessing.Queue[None]" = multiprocessing.Queue() self.__queue: "multiprocessing.Queue[int]" = multiprocessing.Queue()
def notify(self) -> None: def notify(self, mask: int=0) -> None:
self.__queue.put_nowait(None) self.__queue.put_nowait(mask)
async def wait(self) -> None: async def wait(self) -> int:
while not (await queue_get_last(self.__queue, 0.1))[0]: while True:
pass mask = await aiotools.run_async(self.__get)
if mask >= 0:
return mask
def __get(self) -> int:
try:
mask = self.__queue.get(timeout=0.1)
while not self.__queue.empty():
mask |= self.__queue.get()
return mask
except queue.Empty:
return -1
# ===== # =====

View File

@@ -26,7 +26,6 @@ import asyncio
import asyncio.subprocess import asyncio.subprocess
import logging import logging
from .languages import Languages
import setproctitle import setproctitle
from .logging import get_logger from .logging import get_logger
@@ -86,7 +85,7 @@ async def log_stdout_infinite(proc: asyncio.subprocess.Process, logger: logging.
else: else:
empty += 1 empty += 1
if empty == 100: # asyncio bug if empty == 100: # asyncio bug
raise RuntimeError(Languages().gettext("Asyncio process: too many empty lines")) raise RuntimeError("Asyncio process: too many empty lines")
async def kill_process(proc: asyncio.subprocess.Process, wait: float, logger: logging.Logger) -> None: # pylint: disable=no-member async def kill_process(proc: asyncio.subprocess.Process, wait: float, logger: logging.Logger) -> None: # pylint: disable=no-member
@@ -101,14 +100,14 @@ async def kill_process(proc: asyncio.subprocess.Process, wait: float, logger: lo
if proc.returncode is not None: if proc.returncode is not None:
raise raise
await proc.wait() await proc.wait()
logger.info(Languages().gettext("Process killed: retcode=%d"), proc.returncode) logger.info("Process killed: retcode=%d", proc.returncode)
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except Exception: except Exception:
if proc.returncode is None: if proc.returncode is None:
logger.exception(Languages().gettext("Can't kill process pid=%d"), proc.pid) logger.exception("Can't kill process pid=%d", proc.pid)
else: else:
logger.info(Languages().gettext("Process killed: retcode=%d"), proc.returncode) logger.info("Process killed: retcode=%d", proc.returncode)
def rename_process(suffix: str, prefix: str="kvmd") -> None: def rename_process(suffix: str, prefix: str="kvmd") -> None:
@@ -117,7 +116,7 @@ def rename_process(suffix: str, prefix: str="kvmd") -> None:
def settle(name: str, suffix: str, prefix: str="kvmd") -> logging.Logger: def settle(name: str, suffix: str, prefix: str="kvmd") -> logging.Logger:
logger = get_logger(1) logger = get_logger(1)
logger.info(Languages().gettext("Started %s pid=%d"), name, os.getpid()) logger.info("Started %s pid=%d", name, os.getpid())
os.setpgrp() os.setpgrp()
rename_process(suffix, prefix) rename_process(suffix, prefix)
return logger return logger

View File

@@ -112,9 +112,9 @@ def shield_fg(aw: Awaitable): # type: ignore
if inner.cancelled(): if inner.cancelled():
outer.forced_cancel() outer.forced_cancel()
else: else:
err = inner.exception() ex = inner.exception()
if err is not None: if ex is not None:
outer.set_exception(err) outer.set_exception(ex)
else: else:
outer.set_result(inner.result()) outer.set_result(inner.result())
@@ -232,25 +232,26 @@ async def close_writer(writer: asyncio.StreamWriter) -> bool:
# ===== # =====
class AioNotifier: class AioNotifier:
def __init__(self) -> None: def __init__(self) -> None:
self.__queue: "asyncio.Queue[None]" = asyncio.Queue() self.__queue: "asyncio.Queue[int]" = asyncio.Queue()
def notify(self) -> None: def notify(self, mask: int=0) -> None:
self.__queue.put_nowait(None) self.__queue.put_nowait(mask)
async def wait(self, timeout: (float | None)=None) -> None: async def wait(self, timeout: (float | None)=None) -> int:
mask = 0
if timeout is None: if timeout is None:
await self.__queue.get() mask = await self.__queue.get()
else: else:
try: try:
await asyncio.wait_for( mask = await asyncio.wait_for(
asyncio.ensure_future(self.__queue.get()), asyncio.ensure_future(self.__queue.get()),
timeout=timeout, timeout=timeout,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
return # False return -1
while not self.__queue.empty(): while not self.__queue.empty():
await self.__queue.get() mask |= await self.__queue.get()
# return True return mask
# ===== # =====
@@ -296,7 +297,7 @@ class AioExclusiveRegion:
def is_busy(self) -> bool: def is_busy(self) -> bool:
return self.__busy return self.__busy
async def enter(self) -> None: def enter(self) -> None:
if not self.__busy: if not self.__busy:
self.__busy = True self.__busy = True
try: try:
@@ -308,22 +309,22 @@ class AioExclusiveRegion:
return return
raise self.__exc_type() raise self.__exc_type()
async def exit(self) -> None: def exit(self) -> None:
self.__busy = False self.__busy = False
if self.__notifier: if self.__notifier:
self.__notifier.notify() self.__notifier.notify()
async def __aenter__(self) -> None: def __enter__(self) -> None:
await self.enter() self.enter()
async def __aexit__( def __exit__(
self, self,
_exc_type: type[BaseException], _exc_type: type[BaseException],
_exc: BaseException, _exc: BaseException,
_tb: types.TracebackType, _tb: types.TracebackType,
) -> None: ) -> None:
await self.exit() self.exit()
async def run_region_task( async def run_region_task(
@@ -338,7 +339,7 @@ async def run_region_task(
async def wrapper() -> None: async def wrapper() -> None:
try: try:
async with region: with region:
entered.set_result(None) entered.set_result(None)
await func(*args, **kwargs) await func(*args, **kwargs)
except region.get_exc_type(): except region.get_exc_type():

View File

@@ -31,12 +31,8 @@ import pygments
import pygments.lexers.data import pygments.lexers.data
import pygments.formatters import pygments.formatters
from gettext import translation
from .. import tools from .. import tools
from ..mouse import MouseRange
from ..plugins import UnknownPluginError from ..plugins import UnknownPluginError
from ..plugins.auth import get_auth_service_class from ..plugins.auth import get_auth_service_class
from ..plugins.hid import get_hid_class from ..plugins.hid import get_hid_class
@@ -105,9 +101,6 @@ from ..validators.hw import valid_otg_gadget
from ..validators.hw import valid_otg_id from ..validators.hw import valid_otg_id
from ..validators.hw import valid_otg_ethernet from ..validators.hw import valid_otg_ethernet
from ..validators.languages import valid_languages
from ..languages import Languages
# ===== # =====
def init( def init(
@@ -129,7 +122,6 @@ def init(
add_help=add_help, add_help=add_help,
formatter_class=argparse.ArgumentDefaultsHelpFormatter, formatter_class=argparse.ArgumentDefaultsHelpFormatter,
) )
parser.add_argument("-c", "--config", default="/etc/kvmd/main.yaml", type=valid_abs_file, parser.add_argument("-c", "--config", default="/etc/kvmd/main.yaml", type=valid_abs_file,
help="Set config file path", metavar="<file>") help="Set config file path", metavar="<file>")
parser.add_argument("-o", "--set-options", default=[], nargs="+", parser.add_argument("-o", "--set-options", default=[], nargs="+",
@@ -153,18 +145,9 @@ def init(
)) ))
raise SystemExit() raise SystemExit()
config = _init_config(options.config, options.set_options, **load) config = _init_config(options.config, options.set_options, **load)
logging.captureWarnings(True) logging.captureWarnings(True)
logging.config.dictConfig(config.logging) logging.config.dictConfig(config.logging)
if isinstance(config.get("languages"), dict) and isinstance(config["languages"].get("console"), str):
i18n_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))+"/i18n"
Languages.init("message", i18n_path, config["languages"]["console"])
gettext = Languages().gettext
logging.addLevelName(20, gettext("INFO"))
logging.addLevelName(30, gettext("WARNING"))
logging.addLevelName(40, gettext("ERROR"))
if cli_logging: if cli_logging:
logging.getLogger().handlers[0].setFormatter(logging.Formatter( logging.getLogger().handlers[0].setFormatter(logging.Formatter(
"-- {levelname:>7} -- {message}", "-- {levelname:>7} -- {message}",
@@ -173,7 +156,10 @@ def init(
if check_run and not options.run: if check_run and not options.run:
raise SystemExit( raise SystemExit(
gettext("To prevent accidental startup, you must specify the --run option to start.\n")+gettext("Try the --help option to find out what this service does.\n")+gettext("Make sure you understand exactly what you are doing!")) "To prevent accidental startup, you must specify the --run option to start.\n"
"Try the --help option to find out what this service does.\n"
"Make sure you understand exactly what you are doing!"
)
return (parser, remaining, config) return (parser, remaining, config)
@@ -183,8 +169,8 @@ def _init_config(config_path: str, override_options: list[str], **load_flags: bo
config_path = os.path.expanduser(config_path) config_path = os.path.expanduser(config_path)
try: try:
raw_config: dict = load_yaml_file(config_path) raw_config: dict = load_yaml_file(config_path)
except Exception as err: except Exception as ex:
raise SystemExit(f"ConfigError: Can't read config file {config_path!r}:\n{tools.efmt(err)}") raise SystemExit(f"ConfigError: Can't read config file {config_path!r}:\n{tools.efmt(ex)}")
if not isinstance(raw_config, dict): if not isinstance(raw_config, dict):
raise SystemExit(f"ConfigError: Top-level of the file {config_path!r} must be a dictionary") raise SystemExit(f"ConfigError: Top-level of the file {config_path!r} must be a dictionary")
@@ -199,8 +185,8 @@ def _init_config(config_path: str, override_options: list[str], **load_flags: bo
config = make_config(raw_config, scheme) config = make_config(raw_config, scheme)
return config return config
except (ConfigError, UnknownPluginError) as err: except (ConfigError, UnknownPluginError) as ex:
raise SystemExit(f"ConfigError: {err}") raise SystemExit(f"ConfigError: {ex}")
def _patch_raw(raw_config: dict) -> None: # pylint: disable=too-many-branches def _patch_raw(raw_config: dict) -> None: # pylint: disable=too-many-branches
@@ -419,19 +405,7 @@ def _get_config_scheme() -> dict:
"hid": { "hid": {
"type": Option("", type=valid_stripped_string_not_empty), "type": Option("", type=valid_stripped_string_not_empty),
"keymap": Option("/usr/share/kvmd/keymaps/en-us", type=valid_abs_file),
"keymap": Option("/usr/share/kvmd/keymaps/en-us", type=valid_abs_file),
"ignore_keys": Option([], type=functools.partial(valid_string_list, subval=valid_hid_key)),
"mouse_x_range": {
"min": Option(MouseRange.MIN, type=valid_hid_mouse_move),
"max": Option(MouseRange.MAX, type=valid_hid_mouse_move),
},
"mouse_y_range": {
"min": Option(MouseRange.MIN, type=valid_hid_mouse_move),
"max": Option(MouseRange.MAX, type=valid_hid_mouse_move),
},
# Dynamic content # Dynamic content
}, },
@@ -693,9 +667,10 @@ def _get_config_scheme() -> dict:
}, },
"vnc": { "vnc": {
"desired_fps": Option(30, type=valid_stream_fps), "desired_fps": Option(30, type=valid_stream_fps),
"mouse_output": Option("usb", type=valid_hid_mouse_output), "mouse_output": Option("usb", type=valid_hid_mouse_output),
"keymap": Option("/usr/share/kvmd/keymaps/en-us", type=valid_abs_file), "keymap": Option("/usr/share/kvmd/keymaps/en-us", type=valid_abs_file),
"allow_cut_after": Option(3.0, type=valid_float_f0),
"server": { "server": {
"host": Option("", type=valid_ip_or_host, if_empty=""), "host": Option("", type=valid_ip_or_host, if_empty=""),
@@ -798,9 +773,4 @@ def _get_config_scheme() -> dict:
"timeout": Option(300, type=valid_int_f1), "timeout": Option(300, type=valid_int_f1),
"interval": Option(30, type=valid_int_f1), "interval": Option(30, type=valid_int_f1),
}, },
"languages": {
"console": Option("default", type=valid_languages),
"web": Option("default", type=valid_languages),
},
} }

View File

@@ -22,259 +22,22 @@
import sys import sys
import os import os
import re
import dataclasses
import contextlib
import subprocess import subprocess
import argparse import argparse
import time import time
from typing import IO
from typing import Generator
from typing import Callable from typing import Callable
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 ...edid import EdidNoBlockError
from ...edid import Edid
# from .. import init # from .. import init
# ===== # =====
class NoBlockError(Exception):
pass
@contextlib.contextmanager
def _smart_open(path: str, mode: str) -> Generator[IO, None, None]:
fd = (0 if "r" in mode else 1)
with (os.fdopen(fd, mode, closefd=False) if path == "-" else open(path, mode)) as file:
yield file
if "w" in mode:
file.flush()
@dataclasses.dataclass(frozen=True)
class _CeaBlock:
tag: int
data: bytes
def __post_init__(self) -> None:
assert 0 < self.tag <= 0b111
assert 0 < len(self.data) <= 0b11111
@property
def size(self) -> int:
return len(self.data) + 1
def pack(self) -> bytes:
header = (self.tag << 5) | len(self.data)
return header.to_bytes() + self.data
@classmethod
def first_from_raw(cls, raw: (bytes | list[int])) -> "_CeaBlock":
assert 0 < raw[0] <= 0xFF
tag = (raw[0] & 0b11100000) >> 5
data_size = (raw[0] & 0b00011111)
data = bytes(raw[1:data_size + 1])
return _CeaBlock(tag, data)
_CEA = 128
_CEA_AUDIO = 1
_CEA_SPEAKERS = 4
class _Edid:
# https://en.wikipedia.org/wiki/Extended_Display_Identification_Data
def __init__(self, path: str) -> None:
with _smart_open(path, "rb") as file:
data = file.read()
if data.startswith(b"\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00"):
self.__data = list(data)
else:
text = re.sub(r"\s", "", data.decode())
self.__data = [
int(text[index:index + 2], 16)
for index in range(0, len(text), 2)
]
assert len(self.__data) == 256, f"Invalid EDID length: {len(self.__data)}, should be 256 bytes"
assert self.__data[126] == 1, "Zero extensions number"
assert (self.__data[_CEA + 0], self.__data[_CEA + 1]) == (0x02, 0x03), "Can't find CEA extension"
def write_hex(self, path: str) -> None:
self.__update_checksums()
text = "\n".join(
"".join(
f"{item:0{2}X}"
for item in self.__data[index:index + 16]
)
for index in range(0, len(self.__data), 16)
) + "\n"
with _smart_open(path, "w") as file:
file.write(text)
def write_bin(self, path: str) -> None:
self.__update_checksums()
with _smart_open(path, "wb") as file:
file.write(bytes(self.__data))
def __update_checksums(self) -> None:
self.__data[127] = 256 - (sum(self.__data[:127]) % 256)
self.__data[255] = 256 - (sum(self.__data[128:255]) % 256)
# =====
def get_mfc_id(self) -> str:
raw = self.__data[8] << 8 | self.__data[9]
return bytes([
((raw >> 10) & 0b11111) + 0x40,
((raw >> 5) & 0b11111) + 0x40,
(raw & 0b11111) + 0x40,
]).decode("ascii")
def set_mfc_id(self, mfc_id: str) -> None:
assert len(mfc_id) == 3, "Mfc ID must be 3 characters long"
data = mfc_id.upper().encode("ascii")
for ch in data:
assert 0x41 <= ch <= 0x5A, "Mfc ID must contain only A-Z characters"
raw = (
(data[2] - 0x40)
| ((data[1] - 0x40) << 5)
| ((data[0] - 0x40) << 10)
)
self.__data[8] = (raw >> 8) & 0xFF
self.__data[9] = raw & 0xFF
# =====
def get_product_id(self) -> int:
return (self.__data[10] | self.__data[11] << 8)
def set_product_id(self, product_id: int) -> None:
assert 0 <= product_id <= 0xFFFF, f"Product ID should be from 0 to {0xFFFF}"
self.__data[10] = product_id & 0xFF
self.__data[11] = (product_id >> 8) & 0xFF
# =====
def get_serial(self) -> int:
return (
self.__data[12]
| self.__data[13] << 8
| self.__data[14] << 16
| self.__data[15] << 24
)
def set_serial(self, serial: int) -> None:
assert 0 <= serial <= 0xFFFFFFFF, f"Serial should be from 0 to {0xFFFFFFFF}"
self.__data[12] = serial & 0xFF
self.__data[13] = (serial >> 8) & 0xFF
self.__data[14] = (serial >> 16) & 0xFF
self.__data[15] = (serial >> 24) & 0xFF
# =====
def get_monitor_name(self) -> str:
return self.__get_dtd_text(0xFC, "Monitor Name")
def set_monitor_name(self, text: str) -> None:
self.__set_dtd_text(0xFC, "Monitor Name", text)
def get_monitor_serial(self) -> str:
return self.__get_dtd_text(0xFF, "Monitor Serial")
def set_monitor_serial(self, text: str) -> None:
self.__set_dtd_text(0xFF, "Monitor Serial", text)
def __get_dtd_text(self, d_type: int, name: str) -> str:
index = self.__find_dtd_text(d_type, name)
return bytes(self.__data[index:index + 13]).decode("cp437").strip()
def __set_dtd_text(self, d_type: int, name: str, text: str) -> None:
index = self.__find_dtd_text(d_type, name)
encoded = (text[:13] + "\n" + " " * 12)[:13].encode("cp437")
for (offset, ch) in enumerate(encoded):
self.__data[index + offset] = ch
def __find_dtd_text(self, d_type: int, name: str) -> int:
for index in [54, 72, 90, 108]:
if self.__data[index + 3] == d_type:
return index + 5
raise NoBlockError(f"Can't find DTD {name}")
# ===== CEA =====
def get_audio(self) -> bool:
(cbs, _) = self.__parse_cea()
audio = False
speakers = False
for cb in cbs:
if cb.tag == _CEA_AUDIO:
audio = True
elif cb.tag == _CEA_SPEAKERS:
speakers = True
return (audio and speakers and self.__get_basic_audio())
def set_audio(self, enabled: bool) -> None:
(cbs, dtds) = self.__parse_cea()
cbs = [cb for cb in cbs if cb.tag not in [_CEA_AUDIO, _CEA_SPEAKERS]]
if enabled:
cbs.append(_CeaBlock(_CEA_AUDIO, b"\x09\x7f\x07"))
cbs.append(_CeaBlock(_CEA_SPEAKERS, b"\x01\x00\x00"))
self.__replace_cea(cbs, dtds)
self.__set_basic_audio(enabled)
def __get_basic_audio(self) -> bool:
return bool(self.__data[_CEA + 3] & 0b01000000)
def __set_basic_audio(self, enabled: bool) -> None:
if enabled:
self.__data[_CEA + 3] |= 0b01000000
else:
self.__data[_CEA + 3] &= (0xFF - 0b01000000) # ~X
def __parse_cea(self) -> tuple[list[_CeaBlock], bytes]:
cea = self.__data[_CEA:]
dtd_begin = cea[2]
if dtd_begin == 0:
return ([], b"")
cbs: list[_CeaBlock] = []
if dtd_begin > 4:
raw = cea[4:dtd_begin]
while len(raw) != 0:
cb = _CeaBlock.first_from_raw(raw)
cbs.append(cb)
raw = raw[cb.size:]
dtds = b""
assert dtd_begin >= 4
raw = cea[dtd_begin:]
while len(raw) > (18 + 1) and raw[0] != 0:
dtds += bytes(raw[:18])
raw = raw[18:]
return (cbs, dtds)
def __replace_cea(self, cbs: list[_CeaBlock], dtds: bytes) -> None:
cbs_packed = b""
for cb in cbs:
cbs_packed += cb.pack()
raw = cbs_packed + dtds
assert len(raw) <= (128 - 4 - 1), "Too many CEA blocks or DTDs"
self.__data[_CEA + 2] = (0 if len(raw) == 0 else (len(cbs_packed) + 4))
for index in range(4, 127):
try:
ch = raw[index - 4]
except IndexError:
ch = 0
self.__data[_CEA + index] = ch
def _format_bool(value: bool) -> str: def _format_bool(value: bool) -> str:
return ("yes" if value else "no") return ("yes" if value else "no")
@@ -283,7 +46,7 @@ def _make_format_hex(size: int) -> Callable[[int], str]:
return (lambda value: ("0x{:0%dX} ({})" % (size * 2)).format(value, value)) return (lambda value: ("0x{:0%dX} ({})" % (size * 2)).format(value, value))
def _print_edid(edid: _Edid) -> None: def _print_edid(edid: Edid) -> None:
for (key, get, fmt) in [ for (key, get, fmt) in [
("Manufacturer ID:", edid.get_mfc_id, str), ("Manufacturer ID:", edid.get_mfc_id, str),
("Product ID: ", edid.get_product_id, _make_format_hex(2)), ("Product ID: ", edid.get_product_id, _make_format_hex(2)),
@@ -294,7 +57,7 @@ def _print_edid(edid: _Edid) -> None:
]: ]:
try: try:
print(key, fmt(get()), file=sys.stderr) # type: ignore print(key, fmt(get()), file=sys.stderr) # type: ignore
except NoBlockError: except EdidNoBlockError:
pass pass
@@ -348,12 +111,12 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra
help="Presets directory", metavar="<dir>") help="Presets directory", metavar="<dir>")
options = parser.parse_args(argv[1:]) options = parser.parse_args(argv[1:])
base: (_Edid | None) = None base: (Edid | None) = None
if options.import_preset: if options.import_preset:
imp = options.import_preset imp = options.import_preset
if "." in imp: if "." in imp:
(base_name, imp) = imp.split(".", 1) # v3.1080p-by-default (base_name, imp) = imp.split(".", 1) # v3.1080p-by-default
base = _Edid(os.path.join(options.presets_path, f"{base_name}.hex")) base = Edid.from_file(os.path.join(options.presets_path, f"{base_name}.hex"))
imp = f"_{imp}" imp = f"_{imp}"
options.imp = os.path.join(options.presets_path, f"{imp}.hex") options.imp = os.path.join(options.presets_path, f"{imp}.hex")
@@ -362,16 +125,16 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra
options.export_hex = options.edid_path options.export_hex = options.edid_path
options.edid_path = options.imp options.edid_path = options.imp
edid = _Edid(options.edid_path) edid = Edid.from_file(options.edid_path)
changed = False changed = False
for cmd in dir(_Edid): for cmd in dir(Edid):
if cmd.startswith("set_"): if cmd.startswith("set_"):
value = getattr(options, cmd) value = getattr(options, cmd)
if value is None and base is not None: if value is None and base is not None:
try: try:
value = getattr(base, cmd.replace("set_", "get_"))() value = getattr(base, cmd.replace("set_", "get_"))()
except NoBlockError: except EdidNoBlockError:
pass pass
if value is not None: if value is not None:
getattr(edid, cmd)(value) getattr(edid, cmd)(value)
@@ -400,8 +163,7 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra
"/usr/bin/v4l2-ctl", "/usr/bin/v4l2-ctl",
f"--device={options.device_path}", f"--device={options.device_path}",
f"--set-edid=file={orig_edid_path}", f"--set-edid=file={orig_edid_path}",
"--fix-edid-checksums",
"--info-edid", "--info-edid",
], stdout=sys.stderr, check=True) ], stdout=sys.stderr, check=True)
except subprocess.CalledProcessError as err: except subprocess.CalledProcessError as ex:
raise SystemExit(str(err)) raise SystemExit(str(ex))

View File

@@ -155,5 +155,5 @@ def main(argv: (list[str] | None)=None) -> None:
options = parser.parse_args(argv[1:]) options = parser.parse_args(argv[1:])
try: try:
options.cmd(config, options) options.cmd(config, options)
except ValidatorError as err: except ValidatorError as ex:
raise SystemExit(str(err)) raise SystemExit(str(ex))

View File

@@ -101,6 +101,7 @@ class IpmiServer(BaseIpmiServer): # pylint: disable=too-many-instance-attribute
# ===== # =====
def handle_raw_request(self, request: dict, session: IpmiServerSession) -> None: def handle_raw_request(self, request: dict, session: IpmiServerSession) -> None:
# Parameter 'request' has been renamed to 'req' in overriding method
handler = { handler = {
(6, 1): (lambda _, session: self.send_device_id(session)), # Get device ID (6, 1): (lambda _, session: self.send_device_id(session)), # Get device ID
(6, 7): self.__get_power_state_handler, # Power state (6, 7): self.__get_power_state_handler, # Power state
@@ -145,13 +146,13 @@ class IpmiServer(BaseIpmiServer): # pylint: disable=too-many-instance-attribute
data = [int(result["leds"]["power"]), 0, 0] data = [int(result["leds"]["power"]), 0, 0]
session.send_ipmi_response(data=data) session.send_ipmi_response(data=data)
def __chassis_control_handler(self, request: dict, session: IpmiServerSession) -> None: def __chassis_control_handler(self, req: dict, session: IpmiServerSession) -> None:
action = { action = {
0: "off_hard", 0: "off_hard",
1: "on", 1: "on",
3: "reset_hard", 3: "reset_hard",
5: "off", 5: "off",
}.get(request["data"][0], "") }.get(req["data"][0], "")
if action: if action:
if not self.__make_request(session, f"atx.switch_power({action})", "atx.switch_power", action=action): if not self.__make_request(session, f"atx.switch_power({action})", "atx.switch_power", action=action):
code = 0xC0 # Try again later code = 0xC0 # Try again later
@@ -171,8 +172,8 @@ class IpmiServer(BaseIpmiServer): # pylint: disable=too-many-instance-attribute
async with self.__kvmd.make_session(credentials.kvmd_user, credentials.kvmd_passwd) as kvmd_session: async with self.__kvmd.make_session(credentials.kvmd_user, credentials.kvmd_passwd) as kvmd_session:
func = functools.reduce(getattr, func_path.split("."), kvmd_session) func = functools.reduce(getattr, func_path.split("."), kvmd_session)
return (await func(**kwargs)) return (await func(**kwargs))
except (aiohttp.ClientError, asyncio.TimeoutError) as err: except (aiohttp.ClientError, asyncio.TimeoutError) as ex:
logger.error("[%s]: Can't perform request %s: %s", session.sockaddr[0], name, err) logger.error("[%s]: Can't perform request %s: %s", session.sockaddr[0], name, ex)
raise raise
return aiotools.run_sync(runner()) return aiotools.run_sync(runner())

View File

@@ -11,16 +11,17 @@ from ... import aioproc
from ...logging import get_logger from ...logging import get_logger
from .stun import StunNatType
from .stun import Stun from .stun import Stun
# ===== # =====
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class _Netcfg: class _Netcfg:
nat_type: str = dataclasses.field(default="") nat_type: StunNatType = dataclasses.field(default=StunNatType.ERROR)
src_ip: str = dataclasses.field(default="") src_ip: str = dataclasses.field(default="")
ext_ip: str = dataclasses.field(default="") ext_ip: str = dataclasses.field(default="")
stun_host: str = dataclasses.field(default="") stun_ip: str = dataclasses.field(default="")
stun_port: int = dataclasses.field(default=0) stun_port: int = dataclasses.field(default=0)
@@ -92,8 +93,9 @@ class JanusRunner: # pylint: disable=too-many-instance-attributes
async def __get_netcfg(self) -> _Netcfg: async def __get_netcfg(self) -> _Netcfg:
src_ip = (self.__get_default_ip() or "0.0.0.0") src_ip = (self.__get_default_ip() or "0.0.0.0")
(stun, (nat_type, ext_ip)) = await self.__get_stun_info(src_ip) info = await self.__stun.get_info(src_ip, 0)
return _Netcfg(nat_type, src_ip, ext_ip, stun.host, stun.port) # В текущей реализации _Netcfg() это копия StunInfo()
return _Netcfg(**dataclasses.asdict(info))
def __get_default_ip(self) -> str: def __get_default_ip(self) -> str:
try: try:
@@ -111,17 +113,10 @@ class JanusRunner: # pylint: disable=too-many-instance-attributes
for proto in [socket.AF_INET, socket.AF_INET6]: for proto in [socket.AF_INET, socket.AF_INET6]:
if proto in addrs: if proto in addrs:
return addrs[proto][0]["addr"] return addrs[proto][0]["addr"]
except Exception as err: except Exception as ex:
get_logger().error("Can't get default IP: %s", tools.efmt(err)) get_logger().error("Can't get default IP: %s", tools.efmt(ex))
return "" return ""
async def __get_stun_info(self, src_ip: str) -> tuple[Stun, tuple[str, str]]:
try:
return (self.__stun, (await self.__stun.get_info(src_ip, 0)))
except Exception as err:
get_logger().error("Can't get STUN info: %s", tools.efmt(err))
return (self.__stun, ("", ""))
# ===== # =====
@aiotools.atomic_fg @aiotools.atomic_fg
@@ -162,7 +157,7 @@ class JanusRunner: # pylint: disable=too-many-instance-attributes
async def __start_janus_proc(self, netcfg: _Netcfg) -> None: async def __start_janus_proc(self, netcfg: _Netcfg) -> None:
assert self.__janus_proc is None assert self.__janus_proc is None
placeholders = { placeholders = {
"o_stun_server": f"--stun-server={netcfg.stun_host}:{netcfg.stun_port}", "o_stun_server": f"--stun-server={netcfg.stun_ip}:{netcfg.stun_port}",
**{ **{
key: str(value) key: str(value)
for (key, value) in dataclasses.asdict(netcfg).items() for (key, value) in dataclasses.asdict(netcfg).items()

View File

@@ -4,6 +4,7 @@ import ipaddress
import struct import struct
import secrets import secrets
import dataclasses import dataclasses
import enum
from ... import tools from ... import tools
from ... import aiotools from ... import aiotools
@@ -12,29 +13,39 @@ from ...logging import get_logger
# ===== # =====
class StunNatType(enum.Enum):
ERROR = ""
BLOCKED = "Blocked"
OPEN_INTERNET = "Open Internet"
SYMMETRIC_UDP_FW = "Symmetric UDP Firewall"
FULL_CONE_NAT = "Full Cone NAT"
RESTRICTED_NAT = "Restricted NAT"
RESTRICTED_PORT_NAT = "Restricted Port NAT"
SYMMETRIC_NAT = "Symmetric NAT"
CHANGED_ADDR_ERROR = "Error when testing on Changed-IP and Port"
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class StunAddress: class StunInfo:
ip: str nat_type: StunNatType
src_ip: str
ext_ip: str
stun_ip: str
stun_port: int
@dataclasses.dataclass(frozen=True)
class _StunAddress:
ip: str
port: int port: int
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class StunResponse: class _StunResponse:
ok: bool ok: bool
ext: (StunAddress | None) = dataclasses.field(default=None) ext: (_StunAddress | None) = dataclasses.field(default=None)
src: (StunAddress | None) = dataclasses.field(default=None) src: (_StunAddress | None) = dataclasses.field(default=None)
changed: (StunAddress | None) = dataclasses.field(default=None) changed: (_StunAddress | None) = dataclasses.field(default=None)
class StunNatType:
BLOCKED = "Blocked"
OPEN_INTERNET = "Open Internet"
SYMMETRIC_UDP_FW = "Symmetric UDP Firewall"
FULL_CONE_NAT = "Full Cone NAT"
RESTRICTED_NAT = "Restricted NAT"
RESTRICTED_PORT_NAT = "Restricted Port NAT"
SYMMETRIC_NAT = "Symmetric NAT"
CHANGED_ADDR_ERROR = "Error when testing on Changed-IP and Port"
# ===== # =====
@@ -50,58 +61,94 @@ class Stun:
retries_delay: float, retries_delay: float,
) -> None: ) -> None:
self.host = host self.__host = host
self.port = port self.__port = port
self.__timeout = timeout self.__timeout = timeout
self.__retries = retries self.__retries = retries
self.__retries_delay = retries_delay self.__retries_delay = retries_delay
self.__stun_ip = ""
self.__sock: (socket.socket | None) = None self.__sock: (socket.socket | None) = None
async def get_info(self, src_ip: str, src_port: int) -> tuple[str, str]: async def get_info(self, src_ip: str, src_port: int) -> StunInfo:
(family, _, _, _, addr) = socket.getaddrinfo(src_ip, src_port, type=socket.SOCK_DGRAM)[0] nat_type = StunNatType.ERROR
ext_ip = ""
try: try:
with socket.socket(family, socket.SOCK_DGRAM) as self.__sock: (src_fam, _, _, _, src_addr) = (await self.__retried_getaddrinfo_udp(src_ip, src_port))[0]
stun_ips = [
stun_addr[0]
for (stun_fam, _, _, _, stun_addr) in (await self.__retried_getaddrinfo_udp(self.__host, self.__port))
if stun_fam == src_fam
]
if not stun_ips:
raise RuntimeError(f"Can't resolve {src_fam.name} address for STUN")
if not self.__stun_ip or self.__stun_ip not in stun_ips:
# On new IP, changed family, etc.
self.__stun_ip = stun_ips[0]
with socket.socket(src_fam, socket.SOCK_DGRAM) as self.__sock:
self.__sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.__sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.__sock.settimeout(self.__timeout) self.__sock.settimeout(self.__timeout)
self.__sock.bind(addr) self.__sock.bind(src_addr)
(nat_type, response) = await self.__get_nat_type(src_ip) (nat_type, resp) = await self.__get_nat_type(src_ip)
return (nat_type, (response.ext.ip if response.ext is not None else "")) ext_ip = (resp.ext.ip if resp.ext is not None else "")
except Exception as ex:
get_logger(0).error("Can't get STUN info: %s", tools.efmt(ex))
finally: finally:
self.__sock = None self.__sock = None
async def __get_nat_type(self, src_ip: str) -> tuple[str, StunResponse]: # pylint: disable=too-many-return-statements return StunInfo(
first = await self.__make_request("First probe") nat_type=nat_type,
src_ip=src_ip,
ext_ip=ext_ip,
stun_ip=self.__stun_ip,
stun_port=self.__port,
)
async def __retried_getaddrinfo_udp(self, host: str, port: int) -> list:
retries = self.__retries
while True:
try:
return socket.getaddrinfo(host, port, type=socket.SOCK_DGRAM)
except Exception:
retries -= 1
if retries == 0:
raise
await asyncio.sleep(self.__retries_delay)
async def __get_nat_type(self, src_ip: str) -> tuple[StunNatType, _StunResponse]: # pylint: disable=too-many-return-statements
first = await self.__make_request("First probe", self.__stun_ip, b"")
if not first.ok: if not first.ok:
return (StunNatType.BLOCKED, first) return (StunNatType.BLOCKED, first)
request = struct.pack(">HHI", 0x0003, 0x0004, 0x00000006) # Change-Request req = struct.pack(">HHI", 0x0003, 0x0004, 0x00000006) # Change-Request
response = await self.__make_request("Change request [ext_ip == src_ip]", request) resp = await self.__make_request("Change request [ext_ip == src_ip]", self.__stun_ip, req)
if first.ext is not None and first.ext.ip == src_ip: if first.ext is not None and first.ext.ip == src_ip:
if response.ok: if resp.ok:
return (StunNatType.OPEN_INTERNET, response) return (StunNatType.OPEN_INTERNET, resp)
return (StunNatType.SYMMETRIC_UDP_FW, response) return (StunNatType.SYMMETRIC_UDP_FW, resp)
if response.ok: if resp.ok:
return (StunNatType.FULL_CONE_NAT, response) return (StunNatType.FULL_CONE_NAT, resp)
if first.changed is None: if first.changed is None:
raise RuntimeError(f"Changed addr is None: {first}") raise RuntimeError(f"Changed addr is None: {first}")
response = await self.__make_request("Change request [ext_ip != src_ip]", addr=first.changed) resp = await self.__make_request("Change request [ext_ip != src_ip]", first.changed, b"")
if not response.ok: if not resp.ok:
return (StunNatType.CHANGED_ADDR_ERROR, response) return (StunNatType.CHANGED_ADDR_ERROR, resp)
if response.ext == first.ext: if resp.ext == first.ext:
request = struct.pack(">HHI", 0x0003, 0x0004, 0x00000002) req = struct.pack(">HHI", 0x0003, 0x0004, 0x00000002)
response = await self.__make_request("Change port", request, addr=first.changed.ip) resp = await self.__make_request("Change port", first.changed.ip, req)
if response.ok: if resp.ok:
return (StunNatType.RESTRICTED_NAT, response) return (StunNatType.RESTRICTED_NAT, resp)
return (StunNatType.RESTRICTED_PORT_NAT, response) return (StunNatType.RESTRICTED_PORT_NAT, resp)
return (StunNatType.SYMMETRIC_NAT, response) return (StunNatType.SYMMETRIC_NAT, resp)
async def __make_request(self, ctx: str, request: bytes=b"", addr: (StunAddress | str | None)=None) -> StunResponse: async def __make_request(self, ctx: str, addr: (_StunAddress | str), req: bytes) -> _StunResponse:
# TODO: Support IPv6 and RFC 5389 # TODO: Support IPv6 and RFC 5389
# The first 4 bytes of the response are the Type (2) and Length (2) # The first 4 bytes of the response are the Type (2) and Length (2)
# The 5th byte is Reserved # The 5th byte is Reserved
@@ -111,32 +158,29 @@ class Stun:
# More info at: https://tools.ietf.org/html/rfc3489#section-11.2.1 # More info at: https://tools.ietf.org/html/rfc3489#section-11.2.1
# And at: https://tools.ietf.org/html/rfc5389#section-15.1 # And at: https://tools.ietf.org/html/rfc5389#section-15.1
if isinstance(addr, StunAddress): if isinstance(addr, _StunAddress):
addr_t = (addr.ip, addr.port) addr_t = (addr.ip, addr.port)
elif isinstance(addr, str): else: # str
addr_t = (addr, self.port) addr_t = (addr, self.__port)
else:
assert addr is None
addr_t = (self.host, self.port)
# https://datatracker.ietf.org/doc/html/rfc5389#section-6 # https://datatracker.ietf.org/doc/html/rfc5389#section-6
trans_id = b"\x21\x12\xA4\x42" + secrets.token_bytes(12) trans_id = b"\x21\x12\xA4\x42" + secrets.token_bytes(12)
(response, error) = (b"", "") (resp, error) = (b"", "")
for _ in range(self.__retries): for _ in range(self.__retries):
(response, error) = await self.__inner_make_request(trans_id, request, addr_t) (resp, error) = await self.__inner_make_request(trans_id, req, addr_t)
if not error: if not error:
break break
await asyncio.sleep(self.__retries_delay) await asyncio.sleep(self.__retries_delay)
if error: if error:
get_logger(0).error("%s: Can't perform STUN request after %d retries; last error: %s", get_logger(0).error("%s: Can't perform STUN request after %d retries; last error: %s",
ctx, self.__retries, error) ctx, self.__retries, error)
return StunResponse(ok=False) return _StunResponse(ok=False)
parsed: dict[str, StunAddress] = {} parsed: dict[str, _StunAddress] = {}
offset = 0 offset = 0
remaining = len(response) remaining = len(resp)
while remaining > 0: while remaining > 0:
(attr_type, attr_len) = struct.unpack(">HH", response[offset : offset + 4]) # noqa: E203 (attr_type, attr_len) = struct.unpack(">HH", resp[offset : offset + 4]) # noqa: E203
offset += 4 offset += 4
field = { field = {
0x0001: "ext", # MAPPED-ADDRESS 0x0001: "ext", # MAPPED-ADDRESS
@@ -145,40 +189,40 @@ class Stun:
0x0005: "changed", # CHANGED-ADDRESS 0x0005: "changed", # CHANGED-ADDRESS
}.get(attr_type) }.get(attr_type)
if field is not None: if field is not None:
parsed[field] = self.__parse_address(response[offset:], (trans_id if attr_type == 0x0020 else b"")) parsed[field] = self.__parse_address(resp[offset:], (trans_id if attr_type == 0x0020 else b""))
offset += attr_len offset += attr_len
remaining -= (4 + attr_len) remaining -= (4 + attr_len)
return StunResponse(ok=True, **parsed) return _StunResponse(ok=True, **parsed)
async def __inner_make_request(self, trans_id: bytes, request: bytes, addr: tuple[str, int]) -> tuple[bytes, str]: async def __inner_make_request(self, trans_id: bytes, req: bytes, addr: tuple[str, int]) -> tuple[bytes, str]:
assert self.__sock is not None assert self.__sock is not None
request = struct.pack(">HH", 0x0001, len(request)) + trans_id + request # Bind Request req = struct.pack(">HH", 0x0001, len(req)) + trans_id + req # Bind Request
try: try:
await aiotools.run_async(self.__sock.sendto, request, addr) await aiotools.run_async(self.__sock.sendto, req, addr)
except Exception as err: except Exception as ex:
return (b"", f"Send error: {tools.efmt(err)}") return (b"", f"Send error: {tools.efmt(ex)}")
try: try:
response = (await aiotools.run_async(self.__sock.recvfrom, 2048))[0] resp = (await aiotools.run_async(self.__sock.recvfrom, 2048))[0]
except Exception as err: except Exception as ex:
return (b"", f"Recv error: {tools.efmt(err)}") return (b"", f"Recv error: {tools.efmt(ex)}")
(response_type, payload_len) = struct.unpack(">HH", response[:4]) (resp_type, payload_len) = struct.unpack(">HH", resp[:4])
if response_type != 0x0101: if resp_type != 0x0101:
return (b"", f"Invalid response type: {response_type:#06x}") return (b"", f"Invalid response type: {resp_type:#06x}")
if trans_id != response[4:20]: if trans_id != resp[4:20]:
return (b"", "Transaction ID mismatch") return (b"", "Transaction ID mismatch")
return (response[20 : 20 + payload_len], "") # noqa: E203 return (resp[20 : 20 + payload_len], "") # noqa: E203
def __parse_address(self, data: bytes, trans_id: bytes) -> StunAddress: def __parse_address(self, data: bytes, trans_id: bytes) -> _StunAddress:
family = data[1] family = data[1]
port = struct.unpack(">H", self.__trans_xor(data[2:4], trans_id))[0] port = struct.unpack(">H", self.__trans_xor(data[2:4], trans_id))[0]
if family == 0x01: if family == 0x01:
return StunAddress(str(ipaddress.IPv4Address(self.__trans_xor(data[4:8], trans_id))), port) return _StunAddress(str(ipaddress.IPv4Address(self.__trans_xor(data[4:8], trans_id))), port)
elif family == 0x02: elif family == 0x02:
return StunAddress(str(ipaddress.IPv6Address(self.__trans_xor(data[4:20], trans_id))), port) return _StunAddress(str(ipaddress.IPv6Address(self.__trans_xor(data[4:20], trans_id))), port)
raise RuntimeError(f"Unknown family; received: {family}") raise RuntimeError(f"Unknown family; received: {family}")
def __trans_xor(self, data: bytes, trans_id: bytes) -> bytes: def __trans_xor(self, data: bytes, trans_id: bytes) -> bytes:

View File

@@ -26,8 +26,6 @@ from ...plugins.hid import get_hid_class
from ...plugins.atx import get_atx_class from ...plugins.atx import get_atx_class
from ...plugins.msd import get_msd_class from ...plugins.msd import get_msd_class
from ...languages import Languages
from .. import init from .. import init
from .auth import AuthManager from .auth import AuthManager
@@ -58,7 +56,7 @@ def main(argv: (list[str] | None)=None) -> None:
if config.kvmd.msd.type == "otg": if config.kvmd.msd.type == "otg":
msd_kwargs["gadget"] = config.otg.gadget # XXX: Small crutch to pass gadget name to the plugin msd_kwargs["gadget"] = config.otg.gadget # XXX: Small crutch to pass gadget name to the plugin
hid_kwargs = config.kvmd.hid._unpack(ignore=["type", "keymap", "ignore_keys", "mouse_x_range", "mouse_y_range"]) hid_kwargs = config.kvmd.hid._unpack(ignore=["type", "keymap"])
if config.kvmd.hid.type == "otg": if config.kvmd.hid.type == "otg":
hid_kwargs["udc"] = config.otg.udc # XXX: Small crutch to pass UDC to the plugin hid_kwargs["udc"] = config.otg.udc # XXX: Small crutch to pass UDC to the plugin
@@ -105,11 +103,8 @@ def main(argv: (list[str] | None)=None) -> None:
), ),
keymap_path=config.hid.keymap, keymap_path=config.hid.keymap,
ignore_keys=config.hid.ignore_keys,
mouse_x_range=(config.hid.mouse_x_range.min, config.hid.mouse_x_range.max),
mouse_y_range=(config.hid.mouse_y_range.min, config.hid.mouse_y_range.max),
stream_forever=config.streamer.forever, stream_forever=config.streamer.forever,
).run(**config.server._unpack()) ).run(**config.server._unpack())
get_logger(0).info(Languages().gettext("Bye-bye")) get_logger(0).info("Bye-bye")

View File

@@ -45,9 +45,9 @@ class AtxApi:
return make_json_response(await self.__atx.get_state()) return make_json_response(await self.__atx.get_state())
@exposed_http("POST", "/atx/power") @exposed_http("POST", "/atx/power")
async def __power_handler(self, request: Request) -> Response: async def __power_handler(self, req: Request) -> Response:
action = valid_atx_power_action(request.query.get("action")) action = valid_atx_power_action(req.query.get("action"))
wait = valid_bool(request.query.get("wait", False)) wait = valid_bool(req.query.get("wait", False))
await ({ await ({
"on": self.__atx.power_on, "on": self.__atx.power_on,
"off": self.__atx.power_off, "off": self.__atx.power_off,
@@ -57,9 +57,9 @@ class AtxApi:
return make_json_response() return make_json_response()
@exposed_http("POST", "/atx/click") @exposed_http("POST", "/atx/click")
async def __click_handler(self, request: Request) -> Response: async def __click_handler(self, req: Request) -> Response:
button = valid_atx_button(request.query.get("button")) button = valid_atx_button(req.query.get("button"))
wait = valid_bool(request.query.get("wait", False)) wait = valid_bool(req.query.get("wait", False))
await ({ await ({
"power": self.__atx.click_power, "power": self.__atx.click_power,
"power_long": self.__atx.click_power_long, "power_long": self.__atx.click_power_long,

View File

@@ -43,34 +43,34 @@ from ..auth import AuthManager
_COOKIE_AUTH_TOKEN = "auth_token" _COOKIE_AUTH_TOKEN = "auth_token"
async def check_request_auth(auth_manager: AuthManager, exposed: HttpExposed, request: Request) -> None: async def check_request_auth(auth_manager: AuthManager, exposed: HttpExposed, req: Request) -> None:
if auth_manager.is_auth_required(exposed): if auth_manager.is_auth_required(exposed):
user = request.headers.get("X-KVMD-User", "") user = req.headers.get("X-KVMD-User", "")
if user: if user:
user = valid_user(user) user = valid_user(user)
passwd = request.headers.get("X-KVMD-Passwd", "") passwd = req.headers.get("X-KVMD-Passwd", "")
set_request_auth_info(request, f"{user} (xhdr)") set_request_auth_info(req, f"{user} (xhdr)")
if not (await auth_manager.authorize(user, valid_passwd(passwd))): if not (await auth_manager.authorize(user, valid_passwd(passwd))):
raise ForbiddenError() raise ForbiddenError()
return return
token = request.cookies.get(_COOKIE_AUTH_TOKEN, "") token = req.cookies.get(_COOKIE_AUTH_TOKEN, "")
if token: if token:
user = auth_manager.check(valid_auth_token(token)) # type: ignore user = auth_manager.check(valid_auth_token(token)) # type: ignore
if not user: if not user:
set_request_auth_info(request, "- (token)") set_request_auth_info(req, "- (token)")
raise ForbiddenError() raise ForbiddenError()
set_request_auth_info(request, f"{user} (token)") set_request_auth_info(req, f"{user} (token)")
return return
basic_auth = request.headers.get("Authorization", "") basic_auth = req.headers.get("Authorization", "")
if basic_auth and basic_auth[:6].lower() == "basic ": if basic_auth and basic_auth[:6].lower() == "basic ":
try: try:
(user, passwd) = base64.b64decode(basic_auth[6:]).decode("utf-8").split(":") (user, passwd) = base64.b64decode(basic_auth[6:]).decode("utf-8").split(":")
except Exception: except Exception:
raise UnauthorizedError() raise UnauthorizedError()
user = valid_user(user) user = valid_user(user)
set_request_auth_info(request, f"{user} (basic)") set_request_auth_info(req, f"{user} (basic)")
if not (await auth_manager.authorize(user, valid_passwd(passwd))): if not (await auth_manager.authorize(user, valid_passwd(passwd))):
raise ForbiddenError() raise ForbiddenError()
return return
@@ -85,9 +85,9 @@ class AuthApi:
# ===== # =====
@exposed_http("POST", "/auth/login", auth_required=False) @exposed_http("POST", "/auth/login", auth_required=False)
async def __login_handler(self, request: Request) -> Response: async def __login_handler(self, req: Request) -> Response:
if self.__auth_manager.is_auth_enabled(): if self.__auth_manager.is_auth_enabled():
credentials = await request.post() credentials = await req.post()
token = await self.__auth_manager.login( token = await self.__auth_manager.login(
user=valid_user(credentials.get("user", "")), user=valid_user(credentials.get("user", "")),
passwd=valid_passwd(credentials.get("passwd", "")), passwd=valid_passwd(credentials.get("passwd", "")),
@@ -98,9 +98,9 @@ class AuthApi:
return make_json_response() return make_json_response()
@exposed_http("POST", "/auth/logout") @exposed_http("POST", "/auth/logout")
async def __logout_handler(self, request: Request) -> Response: async def __logout_handler(self, req: Request) -> Response:
if self.__auth_manager.is_auth_enabled(): if self.__auth_manager.is_auth_enabled():
token = valid_auth_token(request.cookies.get(_COOKIE_AUTH_TOKEN, "")) token = valid_auth_token(req.cookies.get(_COOKIE_AUTH_TOKEN, ""))
self.__auth_manager.logout(token) self.__auth_manager.logout(token)
return make_json_response() return make_json_response()

View File

@@ -55,10 +55,9 @@ class ExportApi:
@async_lru.alru_cache(maxsize=1, ttl=5) @async_lru.alru_cache(maxsize=1, ttl=5)
async def __get_prometheus_metrics(self) -> str: async def __get_prometheus_metrics(self) -> str:
(atx_state, hw_state, fan_state, gpio_state) = await asyncio.gather(*[ (atx_state, info_state, gpio_state) = await asyncio.gather(*[
self.__atx.get_state(), self.__atx.get_state(),
self.__info_manager.get_submanager("hw").get_state(), self.__info_manager.get_state(["hw", "fan"]),
self.__info_manager.get_submanager("fan").get_state(),
self.__user_gpio.get_state(), self.__user_gpio.get_state(),
]) ])
rows: list[str] = [] rows: list[str] = []
@@ -72,8 +71,8 @@ class ExportApi:
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}")
self.__append_prometheus_rows(rows, hw_state["health"], "pikvm_hw") # type: ignore self.__append_prometheus_rows(rows, info_state["hw"]["health"], "pikvm_hw") # type: ignore
self.__append_prometheus_rows(rows, fan_state, "pikvm_fan") self.__append_prometheus_rows(rows, info_state["fan"], "pikvm_fan")
return "\n".join(rows) return "\n".join(rows)

View File

@@ -25,13 +25,12 @@ import stat
import functools import functools
import struct import struct
from typing import Iterable
from typing import Callable from typing import Callable
from aiohttp.web import Request from aiohttp.web import Request
from aiohttp.web import Response from aiohttp.web import Response
from ....mouse import MouseRange
from ....keyboard.keysym import build_symmap from ....keyboard.keysym import build_symmap
from ....keyboard.printer import text_to_web_keys from ....keyboard.printer import text_to_web_keys
@@ -59,12 +58,7 @@ class HidApi:
def __init__( def __init__(
self, self,
hid: BaseHid, hid: BaseHid,
keymap_path: str, keymap_path: str,
ignore_keys: list[str],
mouse_x_range: tuple[int, int],
mouse_y_range: tuple[int, int],
) -> None: ) -> None:
self.__hid = hid self.__hid = hid
@@ -73,11 +67,6 @@ class HidApi:
self.__default_keymap_name = os.path.basename(keymap_path) self.__default_keymap_name = os.path.basename(keymap_path)
self.__ensure_symmap(self.__default_keymap_name) self.__ensure_symmap(self.__default_keymap_name)
self.__ignore_keys = ignore_keys
self.__mouse_x_range = mouse_x_range
self.__mouse_y_range = mouse_y_range
# ===== # =====
@exposed_http("GET", "/hid") @exposed_http("GET", "/hid")
@@ -85,22 +74,22 @@ class HidApi:
return make_json_response(await self.__hid.get_state()) return make_json_response(await self.__hid.get_state())
@exposed_http("POST", "/hid/set_params") @exposed_http("POST", "/hid/set_params")
async def __set_params_handler(self, request: Request) -> Response: async def __set_params_handler(self, req: Request) -> Response:
params = { params = {
key: validator(request.query.get(key)) key: validator(req.query.get(key))
for (key, validator) in [ for (key, validator) in [
("keyboard_output", valid_hid_keyboard_output), ("keyboard_output", valid_hid_keyboard_output),
("mouse_output", valid_hid_mouse_output), ("mouse_output", valid_hid_mouse_output),
("jiggler", valid_bool), ("jiggler", valid_bool),
] ]
if request.query.get(key) is not None if req.query.get(key) is not None
} }
self.__hid.set_params(**params) # type: ignore self.__hid.set_params(**params) # type: ignore
return make_json_response() return make_json_response()
@exposed_http("POST", "/hid/set_connected") @exposed_http("POST", "/hid/set_connected")
async def __set_connected_handler(self, request: Request) -> Response: async def __set_connected_handler(self, req: Request) -> Response:
self.__hid.set_connected(valid_bool(request.query.get("connected"))) self.__hid.set_connected(valid_bool(req.query.get("connected")))
return make_json_response() return make_json_response()
@exposed_http("POST", "/hid/reset") @exposed_http("POST", "/hid/reset")
@@ -128,13 +117,13 @@ class HidApi:
return make_json_response(await self.get_keymaps()) return make_json_response(await self.get_keymaps())
@exposed_http("POST", "/hid/print") @exposed_http("POST", "/hid/print")
async def __print_handler(self, request: Request) -> Response: async def __print_handler(self, req: Request) -> Response:
text = await request.text() text = await req.text()
limit = int(valid_int_f0(request.query.get("limit", 1024))) limit = int(valid_int_f0(req.query.get("limit", 1024)))
if limit > 0: if limit > 0:
text = text[:limit] text = text[:limit]
symmap = self.__ensure_symmap(request.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)) self.__hid.send_key_events(text_to_web_keys(text, symmap), no_ignore_keys=True)
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]]:
@@ -162,8 +151,7 @@ class HidApi:
state = valid_bool(data[0]) state = valid_bool(data[0])
except Exception: except Exception:
return return
if key not in self.__ignore_keys: self.__hid.send_key_event(key, state)
self.__hid.send_key_events([(key, state)])
@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:
@@ -182,17 +170,17 @@ class HidApi:
to_y = valid_hid_mouse_move(to_y) to_y = valid_hid_mouse_move(to_y)
except Exception: except Exception:
return return
self.__send_mouse_move_event(to_x, to_y) self.__hid.send_mouse_move_event(to_x, to_y)
@exposed_ws(4) @exposed_ws(4)
async def __ws_bin_mouse_relative_handler(self, _: WsSession, data: bytes) -> None: async def __ws_bin_mouse_relative_handler(self, _: WsSession, data: bytes) -> None:
self.__process_ws_bin_delta_request(data, self.__hid.send_mouse_relative_event) self.__process_ws_bin_delta_request(data, self.__hid.send_mouse_relative_events)
@exposed_ws(5) @exposed_ws(5)
async def __ws_bin_mouse_wheel_handler(self, _: WsSession, data: bytes) -> None: async def __ws_bin_mouse_wheel_handler(self, _: WsSession, data: bytes) -> None:
self.__process_ws_bin_delta_request(data, self.__hid.send_mouse_wheel_event) self.__process_ws_bin_delta_request(data, self.__hid.send_mouse_wheel_events)
def __process_ws_bin_delta_request(self, data: bytes, handler: Callable[[int, int], 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 = valid_bool(data[0])
data = data[1:] data = data[1:]
@@ -202,7 +190,7 @@ class HidApi:
deltas.append((valid_hid_mouse_delta(delta_x), valid_hid_mouse_delta(delta_y))) deltas.append((valid_hid_mouse_delta(delta_x), valid_hid_mouse_delta(delta_y)))
except Exception: except Exception:
return return
self.__send_mouse_delta_event(deltas, squash, handler) handler(deltas, squash)
# ===== # =====
@@ -213,8 +201,7 @@ class HidApi:
state = valid_bool(event["state"]) state = valid_bool(event["state"])
except Exception: except Exception:
return return
if key not in self.__ignore_keys: self.__hid.send_key_event(key, state)
self.__hid.send_key_events([(key, state)])
@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:
@@ -232,17 +219,17 @@ class HidApi:
to_y = valid_hid_mouse_move(event["to"]["y"]) to_y = valid_hid_mouse_move(event["to"]["y"])
except Exception: except Exception:
return return
self.__send_mouse_move_event(to_x, to_y) self.__hid.send_mouse_move_event(to_x, to_y)
@exposed_ws("mouse_relative") @exposed_ws("mouse_relative")
async def __ws_mouse_relative_handler(self, _: WsSession, event: dict) -> None: async def __ws_mouse_relative_handler(self, _: WsSession, event: dict) -> None:
self.__process_ws_delta_event(event, self.__hid.send_mouse_relative_event) self.__process_ws_delta_event(event, self.__hid.send_mouse_relative_events)
@exposed_ws("mouse_wheel") @exposed_ws("mouse_wheel")
async def __ws_mouse_wheel_handler(self, _: WsSession, event: dict) -> None: async def __ws_mouse_wheel_handler(self, _: WsSession, event: dict) -> None:
self.__process_ws_delta_event(event, self.__hid.send_mouse_wheel_event) self.__process_ws_delta_event(event, self.__hid.send_mouse_wheel_events)
def __process_ws_delta_event(self, event: dict, handler: Callable[[int, int], None]) -> None: def __process_ws_delta_event(self, event: dict, handler: Callable[[Iterable[tuple[int, int]], bool], None]) -> None:
try: try:
raw_delta = event["delta"] raw_delta = event["delta"]
deltas = [ deltas = [
@@ -252,26 +239,25 @@ class HidApi:
squash = valid_bool(event.get("squash", False)) squash = valid_bool(event.get("squash", False))
except Exception: except Exception:
return return
self.__send_mouse_delta_event(deltas, squash, handler) handler(deltas, squash)
# ===== # =====
@exposed_http("POST", "/hid/events/send_key") @exposed_http("POST", "/hid/events/send_key")
async def __events_send_key_handler(self, request: Request) -> Response: async def __events_send_key_handler(self, req: Request) -> Response:
key = valid_hid_key(request.query.get("key")) key = valid_hid_key(req.query.get("key"))
if key not in self.__ignore_keys: if "state" in req.query:
if "state" in request.query: state = valid_bool(req.query["state"])
state = valid_bool(request.query["state"]) self.__hid.send_key_event(key, state)
self.__hid.send_key_events([(key, state)]) else:
else: self.__hid.send_key_events([(key, True), (key, False)])
self.__hid.send_key_events([(key, True), (key, False)])
return make_json_response() return make_json_response()
@exposed_http("POST", "/hid/events/send_mouse_button") @exposed_http("POST", "/hid/events/send_mouse_button")
async def __events_send_mouse_button_handler(self, request: Request) -> Response: async def __events_send_mouse_button_handler(self, req: Request) -> Response:
button = valid_hid_mouse_button(request.query.get("button")) button = valid_hid_mouse_button(req.query.get("button"))
if "state" in request.query: if "state" in req.query:
state = valid_bool(request.query["state"]) state = valid_bool(req.query["state"])
self.__hid.send_mouse_button_event(button, state) self.__hid.send_mouse_button_event(button, state)
else: else:
self.__hid.send_mouse_button_event(button, True) self.__hid.send_mouse_button_event(button, True)
@@ -279,52 +265,22 @@ class HidApi:
return make_json_response() return make_json_response()
@exposed_http("POST", "/hid/events/send_mouse_move") @exposed_http("POST", "/hid/events/send_mouse_move")
async def __events_send_mouse_move_handler(self, request: Request) -> Response: async def __events_send_mouse_move_handler(self, req: Request) -> Response:
to_x = valid_hid_mouse_move(request.query.get("to_x")) to_x = valid_hid_mouse_move(req.query.get("to_x"))
to_y = valid_hid_mouse_move(request.query.get("to_y")) to_y = valid_hid_mouse_move(req.query.get("to_y"))
self.__send_mouse_move_event(to_x, to_y) self.__hid.send_mouse_move_event(to_x, to_y)
return make_json_response() return make_json_response()
@exposed_http("POST", "/hid/events/send_mouse_relative") @exposed_http("POST", "/hid/events/send_mouse_relative")
async def __events_send_mouse_relative_handler(self, request: Request) -> Response: async def __events_send_mouse_relative_handler(self, req: Request) -> Response:
return self.__process_http_delta_event(request, self.__hid.send_mouse_relative_event) return self.__process_http_delta_event(req, self.__hid.send_mouse_relative_event)
@exposed_http("POST", "/hid/events/send_mouse_wheel") @exposed_http("POST", "/hid/events/send_mouse_wheel")
async def __events_send_mouse_wheel_handler(self, request: Request) -> Response: async def __events_send_mouse_wheel_handler(self, req: Request) -> Response:
return self.__process_http_delta_event(request, self.__hid.send_mouse_wheel_event) return self.__process_http_delta_event(req, self.__hid.send_mouse_wheel_event)
def __process_http_delta_event(self, request: Request, handler: Callable[[int, int], None]) -> Response: def __process_http_delta_event(self, req: Request, handler: Callable[[int, int], None]) -> Response:
delta_x = valid_hid_mouse_delta(request.query.get("delta_x")) delta_x = valid_hid_mouse_delta(req.query.get("delta_x"))
delta_y = valid_hid_mouse_delta(request.query.get("delta_y")) delta_y = valid_hid_mouse_delta(req.query.get("delta_y"))
handler(delta_x, delta_y) handler(delta_x, delta_y)
return make_json_response() return make_json_response()
# =====
def __send_mouse_move_event(self, to_x: int, to_y: int) -> None:
if self.__mouse_x_range != MouseRange.RANGE:
to_x = MouseRange.remap(to_x, *self.__mouse_x_range)
if self.__mouse_y_range != MouseRange.RANGE:
to_y = MouseRange.remap(to_y, *self.__mouse_y_range)
self.__hid.send_mouse_move_event(to_x, to_y)
def __send_mouse_delta_event(
self,
deltas: list[tuple[int, int]],
squash: bool,
handler: Callable[[int, int], None],
) -> None:
if squash:
prev = (0, 0)
for cur in deltas:
if abs(prev[0] + cur[0]) > 127 or abs(prev[1] + cur[1]) > 127:
handler(*prev)
prev = cur
else:
prev = (prev[0] + cur[0], prev[1] + cur[1])
if prev[0] or prev[1]:
handler(*prev)
else:
for xy in deltas:
handler(*xy)

View File

@@ -20,8 +20,6 @@
# ========================================================================== # # ========================================================================== #
import asyncio
from aiohttp.web import Request from aiohttp.web import Request
from aiohttp.web import Response from aiohttp.web import Response
@@ -41,17 +39,13 @@ class InfoApi:
# ===== # =====
@exposed_http("GET", "/info") @exposed_http("GET", "/info")
async def __common_state_handler(self, request: Request) -> Response: async def __common_state_handler(self, req: Request) -> Response:
fields = self.__valid_info_fields(request) fields = self.__valid_info_fields(req)
results = dict(zip(fields, await asyncio.gather(*[ return make_json_response(await self.__info_manager.get_state(fields))
self.__info_manager.get_submanager(field).get_state()
for field in fields
])))
return make_json_response(results)
def __valid_info_fields(self, request: Request) -> list[str]: def __valid_info_fields(self, req: Request) -> list[str]:
subs = self.__info_manager.get_subs() available = self.__info_manager.get_subs()
return sorted(valid_info_fields( return sorted(valid_info_fields(
arg=request.query.get("fields", ",".join(subs)), arg=req.query.get("fields", ",".join(available)),
variants=subs, variants=available,
) or subs) ) or available)

View File

@@ -47,12 +47,12 @@ class LogApi:
# ===== # =====
@exposed_http("GET", "/log") @exposed_http("GET", "/log")
async def __log_handler(self, request: Request) -> StreamResponse: async def __log_handler(self, req: Request) -> StreamResponse:
if self.__log_reader is None: if self.__log_reader is None:
raise LogReaderDisabledError() raise LogReaderDisabledError()
seek = valid_log_seek(request.query.get("seek", 0)) seek = valid_log_seek(req.query.get("seek", 0))
follow = valid_bool(request.query.get("follow", False)) follow = valid_bool(req.query.get("follow", False))
response = await start_streaming(request, "text/plain") response = await start_streaming(req, "text/plain")
try: try:
async for record in self.__log_reader.poll_log(seek, follow): async for record in self.__log_reader.poll_log(seek, follow):
await response.write(("[%s %s] --- %s" % ( await response.write(("[%s %s] --- %s" % (

View File

@@ -63,32 +63,41 @@ class MsdApi:
@exposed_http("GET", "/msd") @exposed_http("GET", "/msd")
async def __state_handler(self, _: Request) -> Response: async def __state_handler(self, _: Request) -> Response:
return make_json_response(await self.__msd.get_state()) state = await self.__msd.get_state()
if state["storage"] and state["storage"]["parts"]:
state["storage"]["size"] = state["storage"]["parts"][""]["size"] # Legacy API
state["storage"]["free"] = state["storage"]["parts"][""]["free"] # Legacy API
return make_json_response(state)
@exposed_http("POST", "/msd/set_params") @exposed_http("POST", "/msd/set_params")
async def __set_params_handler(self, request: Request) -> Response: async def __set_params_handler(self, req: Request) -> Response:
params = { params = {
key: validator(request.query.get(param)) key: validator(req.query.get(param))
for (param, key, validator) in [ for (param, key, validator) in [
("image", "name", (lambda arg: str(arg).strip() and valid_msd_image_name(arg))), ("image", "name", (lambda arg: str(arg).strip() and valid_msd_image_name(arg))),
("cdrom", "cdrom", valid_bool), ("cdrom", "cdrom", valid_bool),
("rw", "rw", valid_bool), ("rw", "rw", valid_bool),
] ]
if request.query.get(param) is not None if req.query.get(param) is not None
} }
await self.__msd.set_params(**params) # type: ignore await self.__msd.set_params(**params) # type: ignore
return make_json_response() return make_json_response()
@exposed_http("POST", "/msd/set_connected") @exposed_http("POST", "/msd/set_connected")
async def __set_connected_handler(self, request: Request) -> Response: async def __set_connected_handler(self, req: Request) -> Response:
await self.__msd.set_connected(valid_bool(request.query.get("connected"))) await self.__msd.set_connected(valid_bool(req.query.get("connected")))
return make_json_response()
@exposed_http("POST", "/msd/make_image")
async def __set_zipped_handler(self, req: Request) -> Response:
await self.__msd.make_image(valid_bool(req.query.get("zipped")))
return make_json_response() return make_json_response()
# ===== # =====
@exposed_http("GET", "/msd/read") @exposed_http("GET", "/msd/read")
async def __read_handler(self, request: Request) -> StreamResponse: async def __read_handler(self, req: Request) -> StreamResponse:
name = valid_msd_image_name(request.query.get("image")) name = valid_msd_image_name(req.query.get("image"))
compressors = { compressors = {
"": ("", None), "": ("", None),
"none": ("", None), "none": ("", None),
@@ -96,7 +105,7 @@ class MsdApi:
"zstd": (".zst", (lambda: zstandard.ZstdCompressor().compressobj())), # pylint: disable=unnecessary-lambda "zstd": (".zst", (lambda: zstandard.ZstdCompressor().compressobj())), # pylint: disable=unnecessary-lambda
} }
(suffix, make_compressor) = compressors[check_string_in_list( (suffix, make_compressor) = compressors[check_string_in_list(
arg=request.query.get("compress", ""), arg=req.query.get("compress", ""),
name="Compression mode", name="Compression mode",
variants=set(compressors), variants=set(compressors),
)] )]
@@ -127,7 +136,7 @@ class MsdApi:
src = compressed() src = compressed()
size = -1 size = -1
response = await start_streaming(request, "application/octet-stream", size, name + suffix) response = await start_streaming(req, "application/octet-stream", size, name + suffix)
async for chunk in src: async for chunk in src:
await response.write(chunk) await response.write(chunk)
return response return response
@@ -135,28 +144,28 @@ class MsdApi:
# ===== # =====
@exposed_http("POST", "/msd/write") @exposed_http("POST", "/msd/write")
async def __write_handler(self, request: Request) -> Response: async def __write_handler(self, req: Request) -> Response:
unsafe_prefix = request.query.get("prefix", "") + "/" unsafe_prefix = req.query.get("prefix", "") + "/"
name = valid_msd_image_name(unsafe_prefix + request.query.get("image", "")) name = valid_msd_image_name(unsafe_prefix + req.query.get("image", ""))
size = valid_int_f0(request.content_length) size = valid_int_f0(req.content_length)
remove_incomplete = self.__get_remove_incomplete(request) remove_incomplete = self.__get_remove_incomplete(req)
written = 0 written = 0
async with self.__msd.write_image(name, size, remove_incomplete) as writer: async with self.__msd.write_image(name, size, remove_incomplete) as writer:
chunk_size = writer.get_chunk_size() chunk_size = writer.get_chunk_size()
while True: while True:
chunk = await request.content.read(chunk_size) chunk = await req.content.read(chunk_size)
if not chunk: if not chunk:
break break
written = await writer.write_chunk(chunk) written = await writer.write_chunk(chunk)
return make_json_response(self.__make_write_info(name, size, written)) return make_json_response(self.__make_write_info(name, size, written))
@exposed_http("POST", "/msd/write_remote") @exposed_http("POST", "/msd/write_remote")
async def __write_remote_handler(self, request: Request) -> (Response | StreamResponse): # pylint: disable=too-many-locals async def __write_remote_handler(self, req: Request) -> (Response | StreamResponse): # pylint: disable=too-many-locals
unsafe_prefix = request.query.get("prefix", "") + "/" unsafe_prefix = req.query.get("prefix", "") + "/"
url = valid_url(request.query.get("url")) url = valid_url(req.query.get("url"))
insecure = valid_bool(request.query.get("insecure", False)) insecure = valid_bool(req.query.get("insecure", False))
timeout = valid_float_f01(request.query.get("timeout", 10.0)) timeout = valid_float_f01(req.query.get("timeout", 10.0))
remove_incomplete = self.__get_remove_incomplete(request) remove_incomplete = self.__get_remove_incomplete(req)
name = "" name = ""
size = written = 0 size = written = 0
@@ -174,7 +183,7 @@ class MsdApi:
read_timeout=(7 * 24 * 3600), read_timeout=(7 * 24 * 3600),
) as remote: ) as remote:
name = str(request.query.get("image", "")).strip() name = str(req.query.get("image", "")).strip()
if len(name) == 0: if len(name) == 0:
name = htclient.get_filename(remote) name = htclient.get_filename(remote)
name = valid_msd_image_name(unsafe_prefix + name) name = valid_msd_image_name(unsafe_prefix + name)
@@ -184,7 +193,7 @@ class MsdApi:
get_logger(0).info("Downloading image %r as %r to MSD ...", url, name) get_logger(0).info("Downloading image %r as %r to MSD ...", url, name)
async with self.__msd.write_image(name, size, remove_incomplete) as writer: async with self.__msd.write_image(name, size, remove_incomplete) as writer:
chunk_size = writer.get_chunk_size() chunk_size = writer.get_chunk_size()
response = await start_streaming(request, "application/x-ndjson") response = await start_streaming(req, "application/x-ndjson")
await stream_write_info() await stream_write_info()
last_report_ts = 0 last_report_ts = 0
async for chunk in remote.content.iter_chunked(chunk_size): async for chunk in remote.content.iter_chunked(chunk_size):
@@ -197,16 +206,16 @@ class MsdApi:
await stream_write_info() await stream_write_info()
return response return response
except Exception as err: except Exception as ex:
if response is not None: if response is not None:
await stream_write_info() await stream_write_info()
await stream_json_exception(response, err) await stream_json_exception(response, ex)
elif isinstance(err, aiohttp.ClientError): elif isinstance(ex, aiohttp.ClientError):
return make_json_exception(err, 400) return make_json_exception(ex, 400)
raise raise
def __get_remove_incomplete(self, request: Request) -> (bool | None): def __get_remove_incomplete(self, req: Request) -> (bool | None):
flag: (str | None) = request.query.get("remove_incomplete") flag: (str | None) = req.query.get("remove_incomplete")
return (valid_bool(flag) if flag is not None else None) return (valid_bool(flag) if flag is not None else None)
def __make_write_info(self, name: str, size: int, written: int) -> dict: def __make_write_info(self, name: str, size: int, written: int) -> dict:
@@ -215,8 +224,8 @@ class MsdApi:
# ===== # =====
@exposed_http("POST", "/msd/remove") @exposed_http("POST", "/msd/remove")
async def __remove_handler(self, request: Request) -> Response: async def __remove_handler(self, req: Request) -> Response:
await self.__msd.remove(valid_msd_image_name(request.query.get("image"))) await self.__msd.remove(valid_msd_image_name(req.query.get("image")))
return make_json_response() return make_json_response()
@exposed_http("POST", "/msd/reset") @exposed_http("POST", "/msd/reset")

View File

@@ -88,12 +88,12 @@ class RedfishApi:
@exposed_http("GET", "/redfish/v1/Systems/0") @exposed_http("GET", "/redfish/v1/Systems/0")
async def __server_handler(self, _: Request) -> Response: async def __server_handler(self, _: Request) -> Response:
(atx_state, meta_state) = await asyncio.gather(*[ (atx_state, info_state) = await asyncio.gather(*[
self.__atx.get_state(), self.__atx.get_state(),
self.__info_manager.get_submanager("meta").get_state(), self.__info_manager.get_state(["meta"]),
]) ])
try: try:
host = str(meta_state.get("server", {})["host"]) # type: ignore host = str(info_state["meta"].get("server", {})["host"]) # type: ignore
except Exception: except Exception:
host = "" host = ""
return make_json_response({ return make_json_response({
@@ -111,10 +111,10 @@ class RedfishApi:
}, wrap_result=False) }, wrap_result=False)
@exposed_http("POST", "/redfish/v1/Systems/0/Actions/ComputerSystem.Reset") @exposed_http("POST", "/redfish/v1/Systems/0/Actions/ComputerSystem.Reset")
async def __power_handler(self, request: Request) -> Response: async def __power_handler(self, req: Request) -> Response:
try: try:
action = check_string_in_list( action = check_string_in_list(
arg=(await request.json())["ResetType"], arg=(await req.json()).get("ResetType"),
name="Redfish ResetType", name="Redfish ResetType",
variants=set(self.__actions), variants=set(self.__actions),
lower=False, lower=False,

View File

@@ -52,36 +52,36 @@ class StreamerApi:
return make_json_response(await self.__streamer.get_state()) return make_json_response(await self.__streamer.get_state())
@exposed_http("GET", "/streamer/snapshot") @exposed_http("GET", "/streamer/snapshot")
async def __take_snapshot_handler(self, request: Request) -> Response: async def __take_snapshot_handler(self, req: Request) -> Response:
snapshot = await self.__streamer.take_snapshot( snapshot = await self.__streamer.take_snapshot(
save=valid_bool(request.query.get("save", False)), save=valid_bool(req.query.get("save", False)),
load=valid_bool(request.query.get("load", False)), load=valid_bool(req.query.get("load", False)),
allow_offline=valid_bool(request.query.get("allow_offline", False)), allow_offline=valid_bool(req.query.get("allow_offline", False)),
) )
if snapshot: if snapshot:
if valid_bool(request.query.get("ocr", False)): if valid_bool(req.query.get("ocr", False)):
langs = self.__ocr.get_available_langs() langs = self.__ocr.get_available_langs()
return Response( return Response(
body=(await self.__ocr.recognize( body=(await self.__ocr.recognize(
data=snapshot.data, data=snapshot.data,
langs=valid_string_list( langs=valid_string_list(
arg=str(request.query.get("ocr_langs", "")).strip(), arg=str(req.query.get("ocr_langs", "")).strip(),
subval=(lambda lang: check_string_in_list(lang, "OCR lang", langs)), subval=(lambda lang: check_string_in_list(lang, "OCR lang", langs)),
name="OCR langs list", name="OCR langs list",
), ),
left=int(valid_number(request.query.get("ocr_left", -1))), left=int(valid_number(req.query.get("ocr_left", -1))),
top=int(valid_number(request.query.get("ocr_top", -1))), top=int(valid_number(req.query.get("ocr_top", -1))),
right=int(valid_number(request.query.get("ocr_right", -1))), right=int(valid_number(req.query.get("ocr_right", -1))),
bottom=int(valid_number(request.query.get("ocr_bottom", -1))), bottom=int(valid_number(req.query.get("ocr_bottom", -1))),
)), )),
headers=dict(snapshot.headers), headers=dict(snapshot.headers),
content_type="text/plain", content_type="text/plain",
) )
elif valid_bool(request.query.get("preview", False)): elif valid_bool(req.query.get("preview", False)):
data = await snapshot.make_preview( data = await snapshot.make_preview(
max_width=valid_int_f0(request.query.get("preview_max_width", 0)), max_width=valid_int_f0(req.query.get("preview_max_width", 0)),
max_height=valid_int_f0(request.query.get("preview_max_height", 0)), max_height=valid_int_f0(req.query.get("preview_max_height", 0)),
quality=valid_stream_quality(request.query.get("preview_quality", 80)), quality=valid_stream_quality(req.query.get("preview_quality", 80)),
) )
else: else:
data = snapshot.data data = snapshot.data
@@ -97,25 +97,6 @@ class StreamerApi:
self.__streamer.remove_snapshot() self.__streamer.remove_snapshot()
return make_json_response() return make_json_response()
# =====
async def get_ocr(self) -> dict: # XXX: Ugly hack
enabled = self.__ocr.is_available()
default: list[str] = []
available: list[str] = []
if enabled:
default = self.__ocr.get_default_langs()
available = self.__ocr.get_available_langs()
return {
"ocr": {
"enabled": enabled,
"langs": {
"default": default,
"available": available,
},
},
}
@exposed_http("GET", "/streamer/ocr") @exposed_http("GET", "/streamer/ocr")
async def __ocr_handler(self, _: Request) -> Response: async def __ocr_handler(self, _: Request) -> Response:
return make_json_response(await self.get_ocr()) return make_json_response({"ocr": (await self.__ocr.get_state())})

View File

@@ -42,23 +42,20 @@ class UserGpioApi:
@exposed_http("GET", "/gpio") @exposed_http("GET", "/gpio")
async def __state_handler(self, _: Request) -> Response: async def __state_handler(self, _: Request) -> Response:
return make_json_response({ return make_json_response(await self.__user_gpio.get_state())
"model": (await self.__user_gpio.get_model()),
"state": (await self.__user_gpio.get_state()),
})
@exposed_http("POST", "/gpio/switch") @exposed_http("POST", "/gpio/switch")
async def __switch_handler(self, request: Request) -> Response: async def __switch_handler(self, req: Request) -> Response:
channel = valid_ugpio_channel(request.query.get("channel")) channel = valid_ugpio_channel(req.query.get("channel"))
state = valid_bool(request.query.get("state")) state = valid_bool(req.query.get("state"))
wait = valid_bool(request.query.get("wait", False)) wait = valid_bool(req.query.get("wait", False))
await self.__user_gpio.switch(channel, state, wait) await self.__user_gpio.switch(channel, state, wait)
return make_json_response() return make_json_response()
@exposed_http("POST", "/gpio/pulse") @exposed_http("POST", "/gpio/pulse")
async def __pulse_handler(self, request: Request) -> Response: async def __pulse_handler(self, req: Request) -> Response:
channel = valid_ugpio_channel(request.query.get("channel")) channel = valid_ugpio_channel(req.query.get("channel"))
delay = valid_float_f0(request.query.get("delay", 0.0)) delay = valid_float_f0(req.query.get("delay", 0.0))
wait = valid_bool(request.query.get("wait", False)) wait = valid_bool(req.query.get("wait", False))
await self.__user_gpio.pulse(channel, delay, wait) await self.__user_gpio.pulse(channel, delay, wait)
return make_json_response() return make_json_response()

View File

@@ -23,8 +23,6 @@
import secrets import secrets
import pyotp import pyotp
from gettext import translation
from ...logging import get_logger from ...logging import get_logger
from ... import aiotools from ... import aiotools
@@ -34,7 +32,6 @@ from ...plugins.auth import get_auth_service_class
from ...htserver import HttpExposed from ...htserver import HttpExposed
from ...languages import Languages
# ===== # =====
class AuthManager: class AuthManager:
@@ -52,32 +49,31 @@ class AuthManager:
totp_secret_path: str, totp_secret_path: str,
) -> None: ) -> None:
self.gettext=Languages().gettext
self.__enabled = enabled self.__enabled = enabled
if not enabled: if not enabled:
get_logger().warning(self.gettext("AUTHORIZATION IS DISABLED")) get_logger().warning("AUTHORIZATION IS DISABLED")
self.__unauth_paths = frozenset(unauth_paths) # To speed up self.__unauth_paths = frozenset(unauth_paths) # To speed up
for path in self.__unauth_paths: for path in self.__unauth_paths:
get_logger().warning(self.gettext("Authorization is disabled for API %r"), path) get_logger().warning("Authorization is disabled for API %r", path)
self.__internal_service: (BaseAuthService | None) = None self.__internal_service: (BaseAuthService | None) = None
if enabled: if enabled:
self.__internal_service = get_auth_service_class(internal_type)(**internal_kwargs) self.__internal_service = get_auth_service_class(internal_type)(**internal_kwargs)
get_logger().info(self.gettext("Using internal auth service %r"), self.__internal_service.get_plugin_name()) get_logger().info("Using internal auth service %r", self.__internal_service.get_plugin_name())
self.__force_internal_users = force_internal_users self.__force_internal_users = force_internal_users
self.__external_service: (BaseAuthService | None) = None self.__external_service: (BaseAuthService | None) = None
if enabled and external_type: if enabled and external_type:
self.__external_service = get_auth_service_class(external_type)(**external_kwargs) self.__external_service = get_auth_service_class(external_type)(**external_kwargs)
get_logger().info(self.gettext("Using external auth service %r"), self.__external_service.get_plugin_name()) get_logger().info("Using external auth service %r", self.__external_service.get_plugin_name())
self.__totp_secret_path = totp_secret_path self.__totp_secret_path = totp_secret_path
self.__tokens: dict[str, str] = {} # {token: user} self.__tokens: dict[str, str] = {} # {token: user}
def is_auth_enabled(self) -> bool: def is_auth_enabled(self) -> bool:
return self.__enabled return self.__enabled
@@ -100,7 +96,7 @@ class AuthManager:
if secret: if secret:
code = passwd[-6:] code = passwd[-6:]
if not pyotp.TOTP(secret).verify(code): if not pyotp.TOTP(secret).verify(code):
get_logger().error(self.gettext("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]
@@ -111,9 +107,9 @@ class AuthManager:
ok = (await service.authorize(user, passwd)) ok = (await service.authorize(user, passwd))
if ok: if ok:
get_logger().info(self.gettext("Authorized user %r via auth service %r"), user, service.get_plugin_name()) get_logger().info("Authorized user %r via auth service %r", user, service.get_plugin_name())
else: else:
get_logger().error(self.gettext("Got access denied for user %r from auth service %r"), user, service.get_plugin_name()) get_logger().error("Got access denied for user %r from auth service %r", user, service.get_plugin_name())
return ok return ok
async def login(self, user: str, passwd: str) -> (str | None): async def login(self, user: str, passwd: str) -> (str | None):
@@ -123,7 +119,7 @@ class AuthManager:
if (await self.authorize(user, passwd)): if (await self.authorize(user, passwd)):
token = self.__make_new_token() token = self.__make_new_token()
self.__tokens[token] = user self.__tokens[token] = user
get_logger().info(self.gettext("Logged in user %r"), user) get_logger().info("Logged in user %r", user)
return token return token
else: else:
return None return None
@@ -133,7 +129,7 @@ class AuthManager:
token = secrets.token_hex(32) token = secrets.token_hex(32)
if token not in self.__tokens: if token not in self.__tokens:
return token return token
raise AssertionError(self.gettext("Can't generate new unique token")) raise AssertionError("Can't generate new unique token")
def logout(self, token: str) -> None: def logout(self, token: str) -> None:
assert self.__enabled assert self.__enabled
@@ -144,7 +140,7 @@ class AuthManager:
if r_user == user: if r_user == user:
count += 1 count += 1
del self.__tokens[r_token] del self.__tokens[r_token]
get_logger().info(self.gettext("Logged out user %r (%d)"), user, count) get_logger().info("Logged out user %r (%d)", user, count)
def check(self, token: str) -> (str | None): def check(self, token: str) -> (str | None):
assert self.__enabled assert self.__enabled

View File

@@ -20,6 +20,10 @@
# ========================================================================== # # ========================================================================== #
import asyncio
from typing import AsyncGenerator
from ....yamlconf import Section from ....yamlconf import Section
from .base import BaseInfoSubmanager from .base import BaseInfoSubmanager
@@ -34,17 +38,59 @@ from .fan import FanInfoSubmanager
# ===== # =====
class InfoManager: class InfoManager:
def __init__(self, config: Section) -> None: def __init__(self, config: Section) -> None:
self.__subs = { self.__subs: dict[str, BaseInfoSubmanager] = {
"system": SystemInfoSubmanager(config.kvmd.streamer.cmd), "system": SystemInfoSubmanager(config.kvmd.streamer.cmd),
"auth": AuthInfoSubmanager(config.kvmd.auth.enabled), "auth": AuthInfoSubmanager(config.kvmd.auth.enabled),
"meta": MetaInfoSubmanager(config.kvmd.info.meta), "meta": MetaInfoSubmanager(config.kvmd.info.meta),
"extras": ExtrasInfoSubmanager(config), "extras": ExtrasInfoSubmanager(config),
"hw": HwInfoSubmanager(**config.kvmd.info.hw._unpack()), "hw": HwInfoSubmanager(**config.kvmd.info.hw._unpack()),
"fan": FanInfoSubmanager(**config.kvmd.info.fan._unpack()), "fan": FanInfoSubmanager(**config.kvmd.info.fan._unpack()),
} }
self.__queue: "asyncio.Queue[tuple[str, (dict | None)]]" = asyncio.Queue()
def get_subs(self) -> set[str]: def get_subs(self) -> set[str]:
return set(self.__subs) return set(self.__subs)
def get_submanager(self, name: str) -> BaseInfoSubmanager: async def get_state(self, fields: (list[str] | None)=None) -> dict:
return self.__subs[name] fields = (fields or list(self.__subs))
return dict(zip(fields, await asyncio.gather(*[
self.__subs[field].get_state()
for field in fields
])))
async def trigger_state(self) -> None:
await asyncio.gather(*[
sub.trigger_state()
for sub in self.__subs.values()
])
async def poll_state(self) -> AsyncGenerator[dict, None]:
# ==== Granularity table ====
# - system -- Partial
# - auth -- Partial
# - meta -- Partial, nullable
# - extras -- Partial, nullable
# - hw -- Partial
# - fan -- Partial
# ===========================
while True:
(field, value) = await self.__queue.get()
yield {field: value}
async def systask(self) -> None:
tasks = [
asyncio.create_task(self.__poller(field))
for field in self.__subs
]
try:
await asyncio.gather(*tasks)
except Exception:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
raise
async def __poller(self, field: str) -> None:
async for state in self.__subs[field].poll_state():
self.__queue.put_nowait((field, state))

View File

@@ -20,6 +20,10 @@
# ========================================================================== # # ========================================================================== #
from typing import AsyncGenerator
from .... import aiotools
from .base import BaseInfoSubmanager from .base import BaseInfoSubmanager
@@ -27,6 +31,15 @@ from .base import BaseInfoSubmanager
class AuthInfoSubmanager(BaseInfoSubmanager): class AuthInfoSubmanager(BaseInfoSubmanager):
def __init__(self, enabled: bool) -> None: def __init__(self, enabled: bool) -> None:
self.__enabled = enabled self.__enabled = enabled
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> dict: async def get_state(self) -> dict:
return {"enabled": self.__enabled} return {"enabled": self.__enabled}
async def trigger_state(self) -> None:
self.__notifier.notify()
async def poll_state(self) -> AsyncGenerator[(dict | None), None]:
while True:
await self.__notifier.wait()
yield (await self.get_state())

View File

@@ -20,7 +20,17 @@
# ========================================================================== # # ========================================================================== #
from typing import AsyncGenerator
# ===== # =====
class BaseInfoSubmanager: class BaseInfoSubmanager:
async def get_state(self) -> (dict | None): async def get_state(self) -> (dict | None):
raise NotImplementedError raise NotImplementedError
async def trigger_state(self) -> None:
raise NotImplementedError
async def poll_state(self) -> AsyncGenerator[(dict | None), None]:
yield None
raise NotImplementedError

View File

@@ -24,6 +24,8 @@ import os
import re import re
import asyncio import asyncio
from typing import AsyncGenerator
from ....logging import get_logger from ....logging import get_logger
from ....yamlconf import Section from ....yamlconf import Section
@@ -42,13 +44,15 @@ from .base import BaseInfoSubmanager
class ExtrasInfoSubmanager(BaseInfoSubmanager): class ExtrasInfoSubmanager(BaseInfoSubmanager):
def __init__(self, global_config: Section) -> None: def __init__(self, global_config: Section) -> None:
self.__global_config = global_config self.__global_config = global_config
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> (dict | None): async def get_state(self) -> (dict | None):
try: try:
sui = sysunit.SystemdUnitInfo() sui = sysunit.SystemdUnitInfo()
await sui.open() await sui.open()
except Exception as err: except Exception as ex:
get_logger(0).error("Can't open systemd bus to get extras state: %s", tools.efmt(err)) if not os.path.exists("/etc/kvmd/.docker_flag"):
get_logger(0).error("Can't open systemd bus to get extras state: %s", tools.efmt(ex))
sui = None sui = None
try: try:
extras: dict[str, dict] = {} extras: dict[str, dict] = {}
@@ -66,6 +70,14 @@ class ExtrasInfoSubmanager(BaseInfoSubmanager):
if sui is not None: if sui is not None:
await aiotools.shield_fg(sui.close()) await aiotools.shield_fg(sui.close())
async def trigger_state(self) -> None:
self.__notifier.notify()
async def poll_state(self) -> AsyncGenerator[(dict | None), None]:
while True:
await self.__notifier.wait()
yield (await self.get_state())
def __get_extras_path(self, *parts: str) -> str: def __get_extras_path(self, *parts: str) -> str:
return os.path.join(self.__global_config.kvmd.info.extras, *parts) return os.path.join(self.__global_config.kvmd.info.extras, *parts)

View File

@@ -21,7 +21,6 @@
import copy import copy
import asyncio
from typing import AsyncGenerator from typing import AsyncGenerator
@@ -53,6 +52,8 @@ class FanInfoSubmanager(BaseInfoSubmanager):
self.__timeout = timeout self.__timeout = timeout
self.__state_poll = state_poll self.__state_poll = state_poll
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> dict: async def get_state(self) -> dict:
monitored = await self.__get_monitored() monitored = await self.__get_monitored()
return { return {
@@ -60,24 +61,28 @@ class FanInfoSubmanager(BaseInfoSubmanager):
"state": ((await self.__get_fan_state() if monitored else None)), "state": ((await self.__get_fan_state() if monitored else None)),
} }
async def poll_state(self) -> AsyncGenerator[dict, None]: async def trigger_state(self) -> None:
prev_state: dict = {} self.__notifier.notify(1)
async def poll_state(self) -> AsyncGenerator[(dict | None), None]:
prev: dict = {}
while True: while True:
if self.__unix_path: if self.__unix_path:
pure = state = await self.get_state() if (await self.__notifier.wait(timeout=self.__state_poll)) > 0:
prev = {}
new = await self.get_state()
pure = copy.deepcopy(new)
if pure["state"] is not None: if pure["state"] is not None:
try: try:
pure = copy.deepcopy(state)
pure["state"]["service"]["now_ts"] = 0 pure["state"]["service"]["now_ts"] = 0
except Exception: except Exception:
pass pass
if pure != prev_state: if pure != prev:
yield state prev = pure
prev_state = pure yield new
await asyncio.sleep(self.__state_poll)
else: else:
await self.__notifier.wait()
yield (await self.get_state()) yield (await self.get_state())
await aiotools.wait_infinite()
# ===== # =====
@@ -87,8 +92,8 @@ class FanInfoSubmanager(BaseInfoSubmanager):
async with sysunit.SystemdUnitInfo() as sui: async with sysunit.SystemdUnitInfo() as sui:
status = await sui.get_status(self.__daemon) status = await sui.get_status(self.__daemon)
return (status[0] or status[1]) return (status[0] or status[1])
except Exception as err: except Exception as ex:
get_logger(0).error("Can't get info about the service %r: %s", self.__daemon, tools.efmt(err)) get_logger(0).error("Can't get info about the service %r: %s", self.__daemon, tools.efmt(ex))
return False return False
async def __get_fan_state(self) -> (dict | None): async def __get_fan_state(self) -> (dict | None):
@@ -97,8 +102,8 @@ class FanInfoSubmanager(BaseInfoSubmanager):
async with session.get("http://localhost/state") as response: async with session.get("http://localhost/state") as response:
htclient.raise_not_200(response) htclient.raise_not_200(response)
return (await response.json())["result"] return (await response.json())["result"]
except Exception as err: except Exception as ex:
get_logger(0).error("Can't read fan state: %s", err) get_logger(0).error("Can't read fan state: %s", ex)
return None return None
def __make_http_session(self) -> aiohttp.ClientSession: def __make_http_session(self) -> aiohttp.ClientSession:

View File

@@ -22,6 +22,7 @@
import os import os
import asyncio import asyncio
import copy
from typing import Callable from typing import Callable
from typing import AsyncGenerator from typing import AsyncGenerator
@@ -60,6 +61,8 @@ class HwInfoSubmanager(BaseInfoSubmanager):
self.__dt_cache: dict[str, str] = {} self.__dt_cache: dict[str, str] = {}
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> dict: async def get_state(self) -> dict:
( (
base, base,
@@ -70,8 +73,8 @@ class HwInfoSubmanager(BaseInfoSubmanager):
cpu_temp, cpu_temp,
mem, mem,
) = await asyncio.gather( ) = await asyncio.gather(
self.__read_dt_file("model"), self.__read_dt_file("model", upper=False),
self.__read_dt_file("serial-number"), self.__read_dt_file("serial-number", upper=True),
self.__read_platform_file(), self.__read_platform_file(),
self.__get_throttling(), self.__get_throttling(),
self.__get_cpu_percent(), self.__get_cpu_percent(),
@@ -97,18 +100,22 @@ class HwInfoSubmanager(BaseInfoSubmanager):
}, },
} }
async def trigger_state(self) -> None:
self.__notifier.notify(1)
async def poll_state(self) -> AsyncGenerator[dict, None]: async def poll_state(self) -> AsyncGenerator[dict, None]:
prev_state: dict = {} prev: dict = {}
while True: while True:
state = await self.get_state() if (await self.__notifier.wait(timeout=self.__state_poll)) > 0:
if state != prev_state: prev = {}
yield state new = await self.get_state()
prev_state = state if new != prev:
await asyncio.sleep(self.__state_poll) prev = copy.deepcopy(new)
yield new
# ===== # =====
async def __read_dt_file(self, name: str) -> (str | None): async def __read_dt_file(self, name: str, upper: bool) -> (str | None):
if name not in self.__dt_cache: if name not in self.__dt_cache:
path = os.path.join(f"{env.PROCFS_PREFIX}/proc/device-tree", name) path = os.path.join(f"{env.PROCFS_PREFIX}/proc/device-tree", name)
if not os.path.exists(path): if not os.path.exists(path):
@@ -161,8 +168,8 @@ class HwInfoSubmanager(BaseInfoSubmanager):
+ system_all / total * 100 + system_all / total * 100
+ (st.steal + st.guest) / total * 100 + (st.steal + st.guest) / total * 100
) )
except Exception as err: except Exception as ex:
get_logger(0).error("Can't get CPU percent: %s", err) get_logger(0).error("Can't get CPU percent: %s", ex)
return None return None
async def __get_mem(self) -> dict: async def __get_mem(self) -> dict:
@@ -173,8 +180,8 @@ class HwInfoSubmanager(BaseInfoSubmanager):
"total": st.total, "total": st.total,
"available": st.available, "available": st.available,
} }
except Exception as err: except Exception as ex:
get_logger(0).error("Can't get memory info: %s", err) get_logger(0).error("Can't get memory info: %s", ex)
return { return {
"percent": None, "percent": None,
"total": None, "total": None,
@@ -217,6 +224,6 @@ class HwInfoSubmanager(BaseInfoSubmanager):
return None return None
try: try:
return parser(text) return parser(text)
except Exception as err: except Exception as ex:
get_logger(0).error("Can't parse [ %s ] output: %r: %s", tools.cmdfmt(cmd), text, tools.efmt(err)) get_logger(0).error("Can't parse [ %s ] output: %r: %s", tools.cmdfmt(cmd), text, tools.efmt(ex))
return None return None

View File

@@ -20,6 +20,8 @@
# ========================================================================== # # ========================================================================== #
from typing import AsyncGenerator
from ....logging import get_logger from ....logging import get_logger
from ....yamlconf.loader import load_yaml_file from ....yamlconf.loader import load_yaml_file
@@ -33,6 +35,7 @@ from .base import BaseInfoSubmanager
class MetaInfoSubmanager(BaseInfoSubmanager): class MetaInfoSubmanager(BaseInfoSubmanager):
def __init__(self, meta_path: str) -> None: def __init__(self, meta_path: str) -> None:
self.__meta_path = meta_path self.__meta_path = meta_path
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> (dict | None): async def get_state(self) -> (dict | None):
try: try:
@@ -40,3 +43,11 @@ class MetaInfoSubmanager(BaseInfoSubmanager):
except Exception: except Exception:
get_logger(0).exception("Can't parse meta") get_logger(0).exception("Can't parse meta")
return None return None
async def trigger_state(self) -> None:
self.__notifier.notify()
async def poll_state(self) -> AsyncGenerator[(dict | None), None]:
while True:
await self.__notifier.wait()
yield (await self.get_state())

View File

@@ -24,8 +24,11 @@ import os
import asyncio import asyncio
import platform import platform
from typing import AsyncGenerator
from ....logging import get_logger from ....logging import get_logger
from .... import aiotools
from .... import aioproc from .... import aioproc
from .... import __version__ from .... import __version__
@@ -37,6 +40,7 @@ from .base import BaseInfoSubmanager
class SystemInfoSubmanager(BaseInfoSubmanager): class SystemInfoSubmanager(BaseInfoSubmanager):
def __init__(self, streamer_cmd: list[str]) -> None: def __init__(self, streamer_cmd: list[str]) -> None:
self.__streamer_cmd = streamer_cmd self.__streamer_cmd = streamer_cmd
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> dict: async def get_state(self) -> dict:
streamer_info = await self.__get_streamer_info() streamer_info = await self.__get_streamer_info()
@@ -50,6 +54,14 @@ class SystemInfoSubmanager(BaseInfoSubmanager):
}, },
} }
async def trigger_state(self) -> None:
self.__notifier.notify()
async def poll_state(self) -> AsyncGenerator[(dict | None), None]:
while True:
await self.__notifier.wait()
yield (await self.get_state())
# ===== # =====
async def __get_streamer_info(self) -> dict: async def __get_streamer_info(self) -> dict:

View File

@@ -30,17 +30,12 @@ from xmlrpc.client import ServerProxy
from ...logging import get_logger from ...logging import get_logger
us_systemd_journal = True
try: try:
import systemd.journal import systemd.journal
except ImportError as e: except ImportError:
get_logger(0).error("Failed to import module: %s", "systemd.journal")
us_systemd_journal = False
try:
import supervisor.xmlrpc import supervisor.xmlrpc
except ImportError as e: us_systemd_journal = False
get_logger(0).info("Failed to import module: %s", "supervisor.xmlrpc")
us_systemd_journal = True
# ===== # =====

View File

@@ -37,6 +37,7 @@ from ctypes import c_void_p
from ctypes import c_char from ctypes import c_char
from typing import Generator from typing import Generator
from typing import AsyncGenerator
from PIL import ImageOps from PIL import ImageOps
from PIL import Image as PilImage from PIL import Image as PilImage
@@ -76,8 +77,8 @@ def _load_libtesseract() -> (ctypes.CDLL | None):
setattr(func, "restype", restype) setattr(func, "restype", restype)
setattr(func, "argtypes", argtypes) setattr(func, "argtypes", argtypes)
return lib return lib
except Exception as err: except Exception as ex:
warnings.warn(f"Can't load libtesseract: {err}", RuntimeWarning) warnings.warn(f"Can't load libtesseract: {ex}", RuntimeWarning)
return None return None
@@ -107,9 +108,37 @@ class Ocr:
def __init__(self, data_dir_path: str, default_langs: list[str]) -> None: def __init__(self, data_dir_path: str, default_langs: list[str]) -> None:
self.__data_dir_path = data_dir_path self.__data_dir_path = data_dir_path
self.__default_langs = default_langs self.__default_langs = default_langs
self.__notifier = aiotools.AioNotifier()
def is_available(self) -> bool: async def get_state(self) -> dict:
return bool(_libtess) enabled = bool(_libtess)
default: list[str] = []
available: list[str] = []
if enabled:
default = self.get_default_langs()
available = self.get_available_langs()
return {
"enabled": enabled,
"langs": {
"default": default,
"available": available,
},
}
async def trigger_state(self) -> None:
self.__notifier.notify()
async def poll_state(self) -> AsyncGenerator[dict, None]:
# ===== Granularity table =====
# - enabled -- Full
# - langs -- Partial
# =============================
while True:
await self.__notifier.wait()
yield (await self.get_state())
# =====
def get_default_langs(self) -> list[str]: def get_default_langs(self) -> list[str]:
return list(self.__default_langs) return list(self.__default_langs)

View File

@@ -20,8 +20,6 @@
# ========================================================================== # # ========================================================================== #
import asyncio
import operator
import dataclasses import dataclasses
from typing import Callable from typing import Callable
@@ -33,7 +31,7 @@ from aiohttp.web import Request
from aiohttp.web import Response from aiohttp.web import Response
from aiohttp.web import WebSocketResponse from aiohttp.web import WebSocketResponse
from ...languages import Languages from ... import __version__
from ...logging import get_logger from ...logging import get_logger
@@ -86,68 +84,60 @@ from .api.redfish import RedfishApi
# ===== # =====
class StreamerQualityNotSupported(OperationError): class StreamerQualityNotSupported(OperationError):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(Languages().gettext("This streamer does not support quality settings")) super().__init__("This streamer does not support quality settings")
class StreamerResolutionNotSupported(OperationError): class StreamerResolutionNotSupported(OperationError):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(Languages().gettext("This streamer does not support resolution settings")) super().__init__("This streamer does not support resolution settings")
class StreamerH264NotSupported(OperationError): class StreamerH264NotSupported(OperationError):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(Languages().gettext("This streamer does not support H264")) super().__init__("This streamer does not support H264")
# ===== # =====
@dataclasses.dataclass(frozen=True)
class _SubsystemEventSource:
get_state: (Callable[[], Coroutine[Any, Any, dict]] | None) = None
poll_state: (Callable[[], AsyncGenerator[dict, None]] | None) = None
@dataclasses.dataclass @dataclasses.dataclass
class _Subsystem: class _Subsystem:
name: str name: str
sysprep: (Callable[[], None] | None) event_type: str
systask: (Callable[[], Coroutine[Any, Any, None]] | None) sysprep: (Callable[[], None] | None)
cleanup: (Callable[[], Coroutine[Any, Any, dict]] | None) systask: (Callable[[], Coroutine[Any, Any, None]] | None)
sources: dict[str, _SubsystemEventSource] cleanup: (Callable[[], Coroutine[Any, Any, dict]] | None)
trigger_state: (Callable[[], Coroutine[Any, Any, None]] | None) = None
poll_state: (Callable[[], AsyncGenerator[dict, None]] | None) = None
def __post_init__(self) -> None:
if self.event_type:
assert self.trigger_state
assert self.poll_state
@classmethod @classmethod
def make(cls, obj: object, name: str, event_type: str="") -> "_Subsystem": def make(cls, obj: object, name: str, event_type: str="") -> "_Subsystem":
if isinstance(obj, BasePlugin): if isinstance(obj, BasePlugin):
name = f"{name} ({obj.get_plugin_name()})" name = f"{name} ({obj.get_plugin_name()})"
sub = _Subsystem( return _Subsystem(
name=name, name=name,
event_type=event_type,
sysprep=getattr(obj, "sysprep", None), sysprep=getattr(obj, "sysprep", None),
systask=getattr(obj, "systask", None), systask=getattr(obj, "systask", None),
cleanup=getattr(obj, "cleanup", None), cleanup=getattr(obj, "cleanup", None),
sources={}, trigger_state=getattr(obj, "trigger_state", None),
poll_state=getattr(obj, "poll_state", None),
) )
if event_type:
sub.add_source(
event_type=event_type,
get_state=getattr(obj, "get_state", None),
poll_state=getattr(obj, "poll_state", None),
)
return sub
def add_source(
self,
event_type: str,
get_state: (Callable[[], Coroutine[Any, Any, dict]] | None),
poll_state: (Callable[[], AsyncGenerator[dict, None]] | None),
) -> "_Subsystem":
assert event_type
assert event_type not in self.sources, (self, event_type)
assert get_state or poll_state, (self, event_type)
self.sources[event_type] = _SubsystemEventSource(get_state, poll_state)
return self
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_HID_STATE = "hid_state"
__EV_ATX_STATE = "atx_state"
__EV_MSD_STATE = "msd_state"
__EV_STREAMER_STATE = "streamer_state"
__EV_OCR_STATE = "ocr_state"
__EV_INFO_STATE = "info_state"
def __init__( # pylint: disable=too-many-arguments,too-many-locals def __init__( # pylint: disable=too-many-arguments,too-many-locals
self, self,
auth_manager: AuthManager, auth_manager: AuthManager,
@@ -163,9 +153,6 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
snapshoter: Snapshoter, snapshoter: Snapshoter,
keymap_path: str, keymap_path: str,
ignore_keys: list[str],
mouse_x_range: tuple[int, int],
mouse_y_range: tuple[int, int],
stream_forever: bool, stream_forever: bool,
) -> None: ) -> None:
@@ -179,8 +166,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
self.__stream_forever = stream_forever self.__stream_forever = stream_forever
self.__hid_api = HidApi(hid, keymap_path, ignore_keys, mouse_x_range, mouse_y_range) # Ugly hack to get keymaps state self.__hid_api = HidApi(hid, keymap_path) # Ugly hack to get keymaps state
self.__streamer_api = StreamerApi(streamer, ocr) # Same hack to get ocr langs state
self.__apis: list[object] = [ self.__apis: list[object] = [
self, self,
AuthApi(auth_manager), AuthApi(auth_manager),
@@ -190,43 +176,38 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
self.__hid_api, self.__hid_api,
AtxApi(atx), AtxApi(atx),
MsdApi(msd), MsdApi(msd),
self.__streamer_api, StreamerApi(streamer, ocr),
ExportApi(info_manager, atx, user_gpio), ExportApi(info_manager, atx, user_gpio),
RedfishApi(info_manager, atx), RedfishApi(info_manager, atx),
] ]
self.__subsystems = [ self.__subsystems = [
_Subsystem.make(auth_manager, "Auth manager"), _Subsystem.make(auth_manager, "Auth manager"),
_Subsystem.make(user_gpio, "User-GPIO", "gpio_state").add_source("gpio_model_state", user_gpio.get_model, None), _Subsystem.make(user_gpio, "User-GPIO", self.__EV_GPIO_STATE),
_Subsystem.make(hid, "HID", "hid_state").add_source("hid_keymaps_state", self.__hid_api.get_keymaps, None), _Subsystem.make(hid, "HID", self.__EV_HID_STATE),
_Subsystem.make(atx, "ATX", "atx_state"), _Subsystem.make(atx, "ATX", self.__EV_ATX_STATE),
_Subsystem.make(msd, "MSD", "msd_state"), _Subsystem.make(msd, "MSD", self.__EV_MSD_STATE),
_Subsystem.make(streamer, "Streamer", "streamer_state").add_source("streamer_ocr_state", self.__streamer_api.get_ocr, None), _Subsystem.make(streamer, "Streamer", self.__EV_STREAMER_STATE),
*[ _Subsystem.make(ocr, "OCR", self.__EV_OCR_STATE),
_Subsystem.make(info_manager.get_submanager(sub), f"Info manager ({sub})", f"info_{sub}_state",) _Subsystem.make(info_manager, "Info manager", self.__EV_INFO_STATE),
for sub in sorted(info_manager.get_subs())
],
] ]
self.__streamer_notifier = aiotools.AioNotifier() self.__streamer_notifier = aiotools.AioNotifier()
self.__reset_streamer = False self.__reset_streamer = False
self.__new_streamer_params: dict = {} self.__new_streamer_params: dict = {}
self.gettext=Languages().gettext
# ===== STREAMER CONTROLLER # ===== STREAMER CONTROLLER
@exposed_http("POST", "/streamer/set_params") @exposed_http("POST", "/streamer/set_params")
async def __streamer_set_params_handler(self, request: Request) -> Response: async def __streamer_set_params_handler(self, req: Request) -> Response:
current_params = self.__streamer.get_params() current_params = self.__streamer.get_params()
for (name, validator, exc_cls) in [ for (name, validator, exc_cls) in [
("quality", valid_stream_quality, StreamerQualityNotSupported), ("quality", valid_stream_quality, StreamerQualityNotSupported),
("desired_fps", valid_stream_fps, None), ("desired_fps", valid_stream_fps, None),
("resolution", valid_stream_resolution, StreamerResolutionNotSupported), ("resolution", valid_stream_resolution, StreamerResolutionNotSupported),
("h264_bitrate", valid_stream_h264_bitrate, StreamerH264NotSupported), ("h264_bitrate", valid_stream_h264_bitrate, StreamerH264NotSupported),
("h264_gop", valid_stream_h264_gop, StreamerH264NotSupported), ("h264_gop", valid_stream_h264_gop, StreamerH264NotSupported),
]: ]:
value = request.query.get(name) value = req.query.get(name)
if value: if value:
if name not in current_params: if name not in current_params:
assert exc_cls is not None, name assert exc_cls is not None, name
@@ -246,24 +227,22 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
# ===== WEBSOCKET # ===== WEBSOCKET
@exposed_http("GET", "/ws") @exposed_http("GET", "/ws")
async def __ws_handler(self, request: Request) -> WebSocketResponse: async def __ws_handler(self, req: Request) -> WebSocketResponse:
stream = valid_bool(request.query.get("stream", True)) stream = valid_bool(req.query.get("stream", True))
async with self._ws_session(request, stream=stream) as ws: legacy = valid_bool(req.query.get("legacy", True))
states = [ async with self._ws_session(req, stream=stream, legacy=legacy) as ws:
(event_type, src.get_state()) (major, minor) = __version__.split(".")
for sub in self.__subsystems await ws.send_event("loop", {
for (event_type, src) in sub.sources.items() "version": {
if src.get_state "major": int(major),
] "minor": int(minor),
events = dict(zip( },
map(operator.itemgetter(0), states), })
await asyncio.gather(*map(operator.itemgetter(1), states)), for sub in self.__subsystems:
)) if sub.event_type:
await asyncio.gather(*[ assert sub.trigger_state
ws.send_event(event_type, events.pop(event_type)) await sub.trigger_state()
for (event_type, _) in states await self._broadcast_ws_event("hid_keymaps_state", await self.__hid_api.get_keymaps()) # FIXME
])
await ws.send_event("loop", {})
return (await self._ws_loop(ws)) return (await self._ws_loop(ws))
@exposed_ws("ping") @exposed_ws("ping")
@@ -279,40 +258,40 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
aioproc.rename_process("main") aioproc.rename_process("main")
super().run(**kwargs) super().run(**kwargs)
async def _check_request_auth(self, exposed: HttpExposed, request: Request) -> None: async def _check_request_auth(self, exposed: HttpExposed, req: Request) -> None:
await check_request_auth(self.__auth_manager, exposed, request) await check_request_auth(self.__auth_manager, exposed, req)
async def _init_app(self) -> None: async def _init_app(self) -> None:
aiotools.create_deadly_task("Stream controller", self.__stream_controller()) aiotools.create_deadly_task("Stream controller", self.__stream_controller())
for sub in self.__subsystems: for sub in self.__subsystems:
if sub.systask: if sub.systask:
aiotools.create_deadly_task(sub.name, sub.systask()) aiotools.create_deadly_task(sub.name, sub.systask())
for (event_type, src) in sub.sources.items(): if sub.event_type:
if src.poll_state: assert sub.poll_state
aiotools.create_deadly_task(f"{sub.name} [poller]", self.__poll_state(event_type, src.poll_state())) aiotools.create_deadly_task(f"{sub.name} [poller]", self.__poll_state(sub.event_type, sub.poll_state()))
aiotools.create_deadly_task("Stream snapshoter", self.__stream_snapshoter()) aiotools.create_deadly_task("Stream snapshoter", self.__stream_snapshoter())
self._add_exposed(*self.__apis) self._add_exposed(*self.__apis)
async def _on_shutdown(self) -> None: async def _on_shutdown(self) -> None:
logger = get_logger(0) logger = get_logger(0)
logger.info(self.gettext("Waiting short tasks ...")) logger.info("Waiting short tasks ...")
await aiotools.wait_all_short_tasks() await aiotools.wait_all_short_tasks()
logger.info(self.gettext("Stopping system tasks ...")) logger.info("Stopping system tasks ...")
await aiotools.stop_all_deadly_tasks() await aiotools.stop_all_deadly_tasks()
logger.info(self.gettext("Disconnecting clients ...")) logger.info("Disconnecting clients ...")
await self._close_all_wss() await self._close_all_wss()
logger.info(self.gettext("On-Shutdown complete")) logger.info("On-Shutdown complete")
async def _on_cleanup(self) -> None: async def _on_cleanup(self) -> None:
logger = get_logger(0) logger = get_logger(0)
for sub in self.__subsystems: for sub in self.__subsystems:
if sub.cleanup: if sub.cleanup:
logger.info(self.gettext("Cleaning up %s ..."), sub.name) logger.info("Cleaning up %s ...", sub.name)
try: try:
await sub.cleanup() # type: ignore await sub.cleanup() # type: ignore
except Exception: except Exception:
logger.exception(self.gettext("Cleanup error on %s"), sub.name) logger.exception("Cleanup error on %s", sub.name)
logger.info(self.gettext("On-Cleanup complete")) logger.info("On-Cleanup complete")
async def _on_ws_opened(self) -> None: async def _on_ws_opened(self) -> None:
self.__streamer_notifier.notify() self.__streamer_notifier.notify()
@@ -351,12 +330,67 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
prev = cur prev = cur
await self.__streamer_notifier.wait() await self.__streamer_notifier.wait()
async def __poll_state(self, event_type: str, poller: AsyncGenerator[dict, None]) -> None:
async for state in poller:
await self._broadcast_ws_event(event_type, state)
async def __stream_snapshoter(self) -> None: async def __stream_snapshoter(self) -> None:
await self.__snapshoter.run( await self.__snapshoter.run(
is_live=self.__has_stream_clients, is_live=self.__has_stream_clients,
notifier=self.__streamer_notifier, notifier=self.__streamer_notifier,
) )
async def __poll_state(self, event_type: str, poller: AsyncGenerator[dict, None]) -> None:
match event_type:
case self.__EV_GPIO_STATE:
await self.__poll_gpio_state(poller)
case self.__EV_INFO_STATE:
await self.__poll_info_state(poller)
case self.__EV_MSD_STATE:
await self.__poll_msd_state(poller)
case self.__EV_STREAMER_STATE:
await self.__poll_streamer_state(poller)
case self.__EV_OCR_STATE:
await self.__poll_ocr_state(poller)
case _:
async for state in poller:
await self._broadcast_ws_event(event_type, state)
async def __poll_gpio_state(self, poller: AsyncGenerator[dict, None]) -> None:
prev: dict = {"state": {"inputs": {}, "outputs": {}}}
async for state in poller:
await self._broadcast_ws_event(self.__EV_GPIO_STATE, state, legacy=False)
if "model" in state: # We have only "model"+"state" or "model" event
prev = state
await self._broadcast_ws_event("gpio_model_state", prev["model"], legacy=True)
else:
prev["state"]["inputs"].update(state["state"].get("inputs", {}))
prev["state"]["outputs"].update(state["state"].get("outputs", {}))
await self._broadcast_ws_event(self.__EV_GPIO_STATE, prev["state"], legacy=True)
async def __poll_info_state(self, poller: AsyncGenerator[dict, None]) -> None:
async for state in poller:
await self._broadcast_ws_event(self.__EV_INFO_STATE, state, legacy=False)
for (key, value) in state.items():
await self._broadcast_ws_event(f"info_{key}_state", value, legacy=True)
async def __poll_msd_state(self, poller: AsyncGenerator[dict, None]) -> None:
prev: dict = {"storage": None}
async for state in poller:
await self._broadcast_ws_event(self.__EV_MSD_STATE, state, legacy=False)
prev_storage = prev["storage"]
prev.update(state)
if prev["storage"] is not None and prev_storage is not None:
prev_storage.update(prev["storage"])
prev["storage"] = prev_storage
if "online" in prev: # Complete/Full
await self._broadcast_ws_event(self.__EV_MSD_STATE, prev, legacy=True)
async def __poll_streamer_state(self, poller: AsyncGenerator[dict, None]) -> None:
prev: dict = {}
async for state in poller:
await self._broadcast_ws_event(self.__EV_STREAMER_STATE, state, legacy=False)
prev.update(state)
if "features" in prev: # Complete/Full
await self._broadcast_ws_event(self.__EV_STREAMER_STATE, prev, legacy=True)
async def __poll_ocr_state(self, poller: AsyncGenerator[dict, None]) -> None:
async for state in poller:
await self._broadcast_ws_event(self.__EV_OCR_STATE, state, legacy=False)
await self._broadcast_ws_event("streamer_ocr_state", {"ocr": state}, legacy=True)

View File

@@ -20,24 +20,23 @@
# ========================================================================== # # ========================================================================== #
import io
import signal import signal
import asyncio import asyncio
import asyncio.subprocess import asyncio.subprocess
import dataclasses import dataclasses
import functools import copy
from typing import AsyncGenerator from typing import AsyncGenerator
from typing import Any from typing import Any
import aiohttp import aiohttp
from PIL import Image as PilImage
from ...languages import Languages
from ...logging import get_logger from ...logging import get_logger
from ...clients.streamer import StreamerSnapshot
from ...clients.streamer import HttpStreamerClient
from ...clients.streamer import HttpStreamerClientSession
from ... import tools from ... import tools
from ... import aiotools from ... import aiotools
from ... import aioproc from ... import aioproc
@@ -45,40 +44,6 @@ from ... import htclient
# ===== # =====
@dataclasses.dataclass(frozen=True)
class StreamerSnapshot:
online: bool
width: int
height: int
headers: tuple[tuple[str, str], ...]
data: bytes
async def make_preview(self, max_width: int, max_height: int, quality: int) -> bytes:
assert max_width >= 0
assert max_height >= 0
assert quality > 0
if max_width == 0 and max_height == 0:
max_width = self.width // 5
max_height = self.height // 5
else:
max_width = min((max_width or self.width), self.width)
max_height = min((max_height or self.height), self.height)
if (max_width, max_height) == (self.width, self.height):
return self.data
return (await aiotools.run_async(self.__inner_make_preview, max_width, max_height, quality))
@functools.lru_cache(maxsize=1)
def __inner_make_preview(self, max_width: int, max_height: int, quality: int) -> bytes:
with io.BytesIO(self.data) as snapshot_bio:
with io.BytesIO() as preview_bio:
with PilImage.open(snapshot_bio) as image:
image.thumbnail((max_width, max_height), PilImage.Resampling.LANCZOS)
image.save(preview_bio, format="jpeg", quality=quality)
return preview_bio.getvalue()
class _StreamerParams: class _StreamerParams:
__DESIRED_FPS = "desired_fps" __DESIRED_FPS = "desired_fps"
@@ -138,7 +103,7 @@ class _StreamerParams:
} }
def get_limits(self) -> dict: def get_limits(self) -> dict:
limits = dict(self.__limits) limits = copy.deepcopy(self.__limits)
if self.__has_resolution: if self.__has_resolution:
limits[self.__AVAILABLE_RESOLUTIONS] = list(limits[self.__AVAILABLE_RESOLUTIONS]) limits[self.__AVAILABLE_RESOLUTIONS] = list(limits[self.__AVAILABLE_RESOLUTIONS])
return limits return limits
@@ -172,6 +137,11 @@ class _StreamerParams:
class Streamer: # pylint: disable=too-many-instance-attributes class Streamer: # pylint: disable=too-many-instance-attributes
__ST_FULL = 0xFF
__ST_PARAMS = 0x01
__ST_STREAMER = 0x02
__ST_SNAPSHOT = 0x04
def __init__( # pylint: disable=too-many-arguments,too-many-locals def __init__( # pylint: disable=too-many-arguments,too-many-locals
self, self,
@@ -205,7 +175,6 @@ class Streamer: # pylint: disable=too-many-instance-attributes
self.__state_poll = state_poll self.__state_poll = state_poll
self.__unix_path = unix_path self.__unix_path = unix_path
self.__timeout = timeout
self.__snapshot_timeout = snapshot_timeout self.__snapshot_timeout = snapshot_timeout
self.__process_name_prefix = process_name_prefix self.__process_name_prefix = process_name_prefix
@@ -222,15 +191,18 @@ class Streamer: # pylint: disable=too-many-instance-attributes
self.__streamer_task: (asyncio.Task | None) = None self.__streamer_task: (asyncio.Task | None) = None
self.__streamer_proc: (asyncio.subprocess.Process | None) = None # pylint: disable=no-member self.__streamer_proc: (asyncio.subprocess.Process | None) = None # pylint: disable=no-member
self.__http_session: (aiohttp.ClientSession | None) = None self.__client = HttpStreamerClient(
name="jpeg",
unix_path=self.__unix_path,
timeout=timeout,
user_agent=htclient.make_user_agent("KVMD"),
)
self.__client_session: (HttpStreamerClientSession | None) = None
self.__snapshot: (StreamerSnapshot | None) = None self.__snapshot: (StreamerSnapshot | None) = None
self.__notifier = aiotools.AioNotifier() self.__notifier = aiotools.AioNotifier()
self.gettext=Languages().gettext
# ===== # =====
@aiotools.atomic_fg @aiotools.atomic_fg
@@ -242,15 +214,15 @@ class Streamer: # pylint: disable=too-many-instance-attributes
if not self.__stop_wip: if not self.__stop_wip:
self.__stop_task.cancel() self.__stop_task.cancel()
await asyncio.gather(self.__stop_task, return_exceptions=True) await asyncio.gather(self.__stop_task, return_exceptions=True)
logger.info(self.gettext("Streamer stop cancelled")) logger.info("Streamer stop cancelled")
return return
else: else:
await asyncio.gather(self.__stop_task, return_exceptions=True) await asyncio.gather(self.__stop_task, return_exceptions=True)
if reset and self.__reset_delay > 0: if reset and self.__reset_delay > 0:
logger.info(self.gettext("Waiting %.2f seconds for reset delay ..."), self.__reset_delay) logger.info("Waiting %.2f seconds for reset delay ...", self.__reset_delay)
await asyncio.sleep(self.__reset_delay) await asyncio.sleep(self.__reset_delay)
logger.info(self.gettext("Starting streamer ...")) logger.info("Starting streamer ...")
await self.__inner_start() await self.__inner_start()
@aiotools.atomic_fg @aiotools.atomic_fg
@@ -263,12 +235,12 @@ class Streamer: # pylint: disable=too-many-instance-attributes
if not self.__stop_wip: if not self.__stop_wip:
self.__stop_task.cancel() self.__stop_task.cancel()
await asyncio.gather(self.__stop_task, return_exceptions=True) await asyncio.gather(self.__stop_task, return_exceptions=True)
logger.info(self.gettext("Stopping streamer immediately ...")) logger.info("Stopping streamer immediately ...")
await self.__inner_stop() await self.__inner_stop()
else: else:
await asyncio.gather(self.__stop_task, return_exceptions=True) await asyncio.gather(self.__stop_task, return_exceptions=True)
else: else:
logger.info(self.gettext("Stopping streamer immediately ...")) logger.info("Stopping streamer immediately ...")
await self.__inner_stop() await self.__inner_stop()
elif not self.__stop_task: elif not self.__stop_task:
@@ -277,13 +249,13 @@ class Streamer: # pylint: disable=too-many-instance-attributes
try: try:
await asyncio.sleep(self.__shutdown_delay) await asyncio.sleep(self.__shutdown_delay)
self.__stop_wip = True self.__stop_wip = True
logger.info(self.gettext("Stopping streamer after delay ...")) logger.info("Stopping streamer after delay ...")
await self.__inner_stop() await self.__inner_stop()
finally: finally:
self.__stop_task = None self.__stop_task = None
self.__stop_wip = False self.__stop_wip = False
logger.info(self.gettext("Planning to stop streamer in %.2f seconds ..."), self.__shutdown_delay) logger.info("Planning to stop streamer in %.2f seconds ...", self.__shutdown_delay)
self.__stop_task = asyncio.create_task(delayed_stop()) self.__stop_task = asyncio.create_task(delayed_stop())
def is_working(self) -> bool: def is_working(self) -> bool:
@@ -294,6 +266,7 @@ class Streamer: # pylint: disable=too-many-instance-attributes
def set_params(self, params: dict) -> None: def set_params(self, params: dict) -> None:
assert not self.__streamer_task assert not self.__streamer_task
self.__notifier.notify(self.__ST_PARAMS)
return self.__params.set_params(params) return self.__params.set_params(params)
def get_params(self) -> dict: def get_params(self) -> dict:
@@ -302,55 +275,80 @@ class Streamer: # pylint: disable=too-many-instance-attributes
# ===== # =====
async def get_state(self) -> dict: async def get_state(self) -> dict:
streamer_state = None return {
"features": self.__params.get_features(),
"limits": self.__params.get_limits(),
"params": self.__params.get_params(),
"streamer": (await self.__get_streamer_state()),
"snapshot": self.__get_snapshot_state(),
}
async def trigger_state(self) -> None:
self.__notifier.notify(self.__ST_FULL)
async def poll_state(self) -> AsyncGenerator[dict, None]:
# ==== Granularity table ====
# - features -- Full
# - limits -- Partial, paired with params
# - params -- Partial, paired with limits
# - streamer -- Partial, nullable
# - snapshot -- Partial
# ===========================
def signal_handler(*_: Any) -> None:
get_logger(0).info("Got SIGUSR2, checking the stream state ...")
self.__notifier.notify(self.__ST_STREAMER)
get_logger(0).info("Installing SIGUSR2 streamer handler ...")
asyncio.get_event_loop().add_signal_handler(signal.SIGUSR2, signal_handler)
prev: dict = {}
while True:
new: dict = {}
mask = await self.__notifier.wait(timeout=self.__state_poll)
if mask == self.__ST_FULL:
new = await self.get_state()
prev = copy.deepcopy(new)
yield new
continue
if mask < 0:
mask = self.__ST_STREAMER
def check_update(key: str, value: (dict | None)) -> None:
if prev.get(key) != value:
new[key] = value
if mask & self.__ST_PARAMS:
check_update("params", self.__params.get_params())
if mask & self.__ST_STREAMER:
check_update("streamer", await self.__get_streamer_state())
if mask & self.__ST_SNAPSHOT:
check_update("snapshot", self.__get_snapshot_state())
if new and prev != new:
prev.update(copy.deepcopy(new))
yield new
async def __get_streamer_state(self) -> (dict | None):
if self.__streamer_task: if self.__streamer_task:
session = self.__ensure_http_session() session = self.__ensure_client_session()
try: try:
async with session.get(self.__make_url("state")) as response: return (await session.get_state())
htclient.raise_not_200(response)
streamer_state = (await response.json())["result"]
except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError): except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError):
pass pass
except Exception: except Exception:
get_logger().exception(self.gettext("Invalid streamer response from /state")) get_logger().exception("Invalid streamer response from /state")
return None
snapshot: (dict | None) = None def __get_snapshot_state(self) -> dict:
if self.__snapshot: if self.__snapshot:
snapshot = dataclasses.asdict(self.__snapshot) snapshot = dataclasses.asdict(self.__snapshot)
del snapshot["headers"] del snapshot["headers"]
del snapshot["data"] del snapshot["data"]
return {"saved": snapshot}
return { return {"saved": None}
"limits": self.__params.get_limits(),
"params": self.__params.get_params(),
"snapshot": {"saved": snapshot},
"streamer": streamer_state,
"features": self.__params.get_features(),
}
async def poll_state(self) -> AsyncGenerator[dict, None]:
def signal_handler(*_: Any) -> None:
get_logger(0).info(self.gettext("Got SIGUSR2, checking the stream state ..."))
self.__notifier.notify()
get_logger(0).info(self.gettext("Installing SIGUSR2 streamer handler ..."))
asyncio.get_event_loop().add_signal_handler(signal.SIGUSR2, signal_handler)
waiter_task: (asyncio.Task | None) = None
prev_state: dict = {}
while True:
state = await self.get_state()
if state != prev_state:
yield state
prev_state = state
if waiter_task is None:
waiter_task = asyncio.create_task(self.__notifier.wait())
if waiter_task in (await aiotools.wait_first(
asyncio.ensure_future(asyncio.sleep(self.__state_poll)),
waiter_task,
))[0]:
waiter_task = None
# ===== # =====
@@ -358,43 +356,19 @@ class Streamer: # pylint: disable=too-many-instance-attributes
if load: if load:
return self.__snapshot return self.__snapshot
logger = get_logger() logger = get_logger()
session = self.__ensure_http_session() session = self.__ensure_client_session()
try: try:
async with session.get( snapshot = await session.take_snapshot(self.__snapshot_timeout)
self.__make_url("snapshot"), if snapshot.online or allow_offline:
timeout=self.__snapshot_timeout, if save:
) as response: self.__snapshot = snapshot
self.__notifier.notify(self.__ST_SNAPSHOT)
htclient.raise_not_200(response) return snapshot
online = (response.headers["X-UStreamer-Online"] == "true") logger.error("Stream is offline, no signal or so")
if online or allow_offline: except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError) as ex:
snapshot = StreamerSnapshot( logger.error("Can't connect to streamer: %s", tools.efmt(ex))
online=online,
width=int(response.headers["X-UStreamer-Width"]),
height=int(response.headers["X-UStreamer-Height"]),
headers=tuple(
(key, value)
for (key, value) in tools.sorted_kvs(dict(response.headers))
if key.lower().startswith("x-ustreamer-") or key.lower() in [
"x-timestamp",
"access-control-allow-origin",
"cache-control",
"pragma",
"expires",
]
),
data=bytes(await response.read()),
)
if save:
self.__snapshot = snapshot
self.__notifier.notify()
return snapshot
logger.error(self.gettext("Stream is offline, no signal or so"))
except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError) as err:
logger.error(self.gettext("Can't connect to streamer: %s"), tools.efmt(err))
except Exception: except Exception:
logger.exception(self.gettext("Invalid streamer response from /snapshot")) logger.exception("Invalid streamer response from /snapshot")
return None return None
def remove_snapshot(self) -> None: def remove_snapshot(self) -> None:
@@ -405,25 +379,14 @@ class Streamer: # pylint: disable=too-many-instance-attributes
@aiotools.atomic_fg @aiotools.atomic_fg
async def cleanup(self) -> None: async def cleanup(self) -> None:
await self.ensure_stop(immediately=True) await self.ensure_stop(immediately=True)
if self.__http_session: if self.__client_session:
await self.__http_session.close() await self.__client_session.close()
self.__http_session = None self.__client_session = None
# ===== def __ensure_client_session(self) -> HttpStreamerClientSession:
if not self.__client_session:
def __ensure_http_session(self) -> aiohttp.ClientSession: self.__client_session = self.__client.make_session()
if not self.__http_session: return self.__client_session
kwargs: dict = {
"headers": {"User-Agent": htclient.make_user_agent("KVMD")},
"connector": aiohttp.UnixConnector(path=self.__unix_path),
"timeout": aiohttp.ClientTimeout(total=self.__timeout),
}
self.__http_session = aiohttp.ClientSession(**kwargs)
return self.__http_session
def __make_url(self, handle: str) -> str:
assert not handle.startswith("/"), handle
return f"http://localhost:0/{handle}"
# ===== # =====
@@ -451,14 +414,14 @@ class Streamer: # pylint: disable=too-many-instance-attributes
await self.__start_streamer_proc() await self.__start_streamer_proc()
assert self.__streamer_proc is not None assert self.__streamer_proc is not None
await aioproc.log_stdout_infinite(self.__streamer_proc, logger) await aioproc.log_stdout_infinite(self.__streamer_proc, logger)
raise RuntimeError(self.gettext("Streamer unexpectedly died")) raise RuntimeError("Streamer unexpectedly died")
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception: except Exception:
if self.__streamer_proc: if self.__streamer_proc:
logger.exception(self.gettext("Unexpected streamer error: pid=%d"), self.__streamer_proc.pid) logger.exception("Unexpected streamer error: pid=%d", self.__streamer_proc.pid)
else: else:
logger.exception(self.gettext("Can't start streamer")) logger.exception("Can't start streamer")
await self.__kill_streamer_proc() await self.__kill_streamer_proc()
await asyncio.sleep(1) await asyncio.sleep(1)
@@ -478,14 +441,14 @@ class Streamer: # pylint: disable=too-many-instance-attributes
logger.info("%s: %s", name, tools.cmdfmt(cmd)) logger.info("%s: %s", name, tools.cmdfmt(cmd))
try: try:
await aioproc.log_process(cmd, logger, prefix=name) await aioproc.log_process(cmd, logger, prefix=name)
except Exception as err: except Exception as ex:
logger.exception(self.gettext("Can't execute command: %s"), err) logger.exception("Can't execute command: %s", ex)
async def __start_streamer_proc(self) -> None: async def __start_streamer_proc(self) -> None:
assert self.__streamer_proc is None assert self.__streamer_proc is None
cmd = self.__make_cmd(self.__cmd) cmd = self.__make_cmd(self.__cmd)
self.__streamer_proc = await aioproc.run_process(cmd) self.__streamer_proc = await aioproc.run_process(cmd)
get_logger(0).info(self.gettext("Started streamer pid=%d: %s"), self.__streamer_proc.pid, tools.cmdfmt(cmd)) get_logger(0).info("Started streamer pid=%d: %s", self.__streamer_proc.pid, tools.cmdfmt(cmd))
async def __kill_streamer_proc(self) -> None: async def __kill_streamer_proc(self) -> None:
if self.__streamer_proc: if self.__streamer_proc:

View File

@@ -35,6 +35,7 @@ class SystemdUnitInfo:
self.__bus: (dbus_next.aio.MessageBus | None) = None self.__bus: (dbus_next.aio.MessageBus | None) = None
self.__intr: (dbus_next.introspection.Node | None) = None self.__intr: (dbus_next.introspection.Node | None) = None
self.__manager: (dbus_next.aio.proxy_object.ProxyInterface | None) = None self.__manager: (dbus_next.aio.proxy_object.ProxyInterface | None) = None
self.__requested = False
async def get_status(self, name: str) -> tuple[bool, bool]: async def get_status(self, name: str) -> tuple[bool, bool]:
assert self.__bus is not None assert self.__bus is not None
@@ -49,8 +50,9 @@ class SystemdUnitInfo:
unit = self.__bus.get_proxy_object("org.freedesktop.systemd1", unit_p, self.__intr) unit = self.__bus.get_proxy_object("org.freedesktop.systemd1", unit_p, self.__intr)
unit_props = unit.get_interface("org.freedesktop.DBus.Properties") unit_props = unit.get_interface("org.freedesktop.DBus.Properties")
started = ((await unit_props.call_get("org.freedesktop.systemd1.Unit", "ActiveState")).value == "active") # type: ignore started = ((await unit_props.call_get("org.freedesktop.systemd1.Unit", "ActiveState")).value == "active") # type: ignore
except dbus_next.errors.DBusError as err: self.__requested = True
if err.type != "org.freedesktop.systemd1.NoSuchUnit": except dbus_next.errors.DBusError as ex:
if ex.type != "org.freedesktop.systemd1.NoSuchUnit":
raise raise
started = False started = False
enabled = ((await self.__manager.call_get_unit_file_state(name)) in [ # type: ignore enabled = ((await self.__manager.call_get_unit_file_state(name)) in [ # type: ignore
@@ -75,8 +77,13 @@ class SystemdUnitInfo:
async def close(self) -> None: async def close(self) -> None:
try: try:
if self.__bus is not None: if self.__bus is not None:
self.__bus.disconnect() try:
await self.__bus.wait_for_disconnect() # XXX: Workaround for dbus_next bug: https://github.com/pikvm/kvmd/pull/182
if not self.__requested:
await self.__manager.call_get_default_target() # type: ignore
finally:
self.__bus.disconnect()
await self.__bus.wait_for_disconnect()
except Exception: except Exception:
pass pass
self.__manager = None self.__manager = None

View File

@@ -21,13 +21,12 @@
import asyncio import asyncio
import copy
from typing import AsyncGenerator from typing import AsyncGenerator
from typing import Callable from typing import Callable
from typing import Any from typing import Any
from ...languages import Languages
from ...logging import get_logger from ...logging import get_logger
from ...errors import IsBusyError from ...errors import IsBusyError
@@ -48,42 +47,40 @@ from ...yamlconf import Section
# ===== # =====
class GpioChannelNotFoundError(GpioOperationError): class GpioChannelNotFoundError(GpioOperationError):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(Languages().gettext("GPIO channel is not found")) super().__init__("GPIO channel is not found")
class GpioSwitchNotSupported(GpioOperationError): class GpioSwitchNotSupported(GpioOperationError):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(Languages().gettext("This GPIO channel does not support switching")) super().__init__("This GPIO channel does not support switching")
class GpioPulseNotSupported(GpioOperationError): class GpioPulseNotSupported(GpioOperationError):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(Languages().gettext("This GPIO channel does not support pulsing")) super().__init__("This GPIO channel does not support pulsing")
class GpioChannelIsBusyError(IsBusyError, GpioError): class GpioChannelIsBusyError(IsBusyError, GpioError):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(Languages().gettext("Performing another GPIO operation on this channel, please try again later")) super().__init__("Performing another GPIO operation on this channel, please try again later")
# ===== # =====
class _GpioInput: class _GpioInput:
def __init__( def __init__(
self, self,
channel: str, ch: str,
config: Section, config: Section,
driver: BaseUserGpioDriver, driver: BaseUserGpioDriver,
) -> None: ) -> None:
self.__channel = channel self.__ch = ch
self.__pin: str = str(config.pin) self.__pin: str = str(config.pin)
self.__inverted: bool = config.inverted self.__inverted: bool = config.inverted
self.__driver = driver self.__driver = driver
self.__driver.register_input(self.__pin, config.debounce) self.__driver.register_input(self.__pin, config.debounce)
self.gettext=Languages().gettext
def get_scheme(self) -> dict: def get_scheme(self) -> dict:
return { return {
"hw": { "hw": {
@@ -104,7 +101,7 @@ class _GpioInput:
} }
def __str__(self) -> str: def __str__(self) -> str:
return f"Input({self.__channel}, driver={self.__driver}, pin={self.__pin})" return f"Input({self.__ch}, driver={self.__driver}, pin={self.__pin})"
__repr__ = __str__ __repr__ = __str__
@@ -112,13 +109,13 @@ class _GpioInput:
class _GpioOutput: # pylint: disable=too-many-instance-attributes class _GpioOutput: # pylint: disable=too-many-instance-attributes
def __init__( def __init__(
self, self,
channel: str, ch: str,
config: Section, config: Section,
driver: BaseUserGpioDriver, driver: BaseUserGpioDriver,
notifier: aiotools.AioNotifier, notifier: aiotools.AioNotifier,
) -> None: ) -> None:
self.__channel = channel self.__ch = ch
self.__pin: str = str(config.pin) self.__pin: str = str(config.pin)
self.__inverted: bool = config.inverted self.__inverted: bool = config.inverted
@@ -140,8 +137,6 @@ class _GpioOutput: # pylint: disable=too-many-instance-attributes
self.__region = aiotools.AioExclusiveRegion(GpioChannelIsBusyError, notifier) self.__region = aiotools.AioExclusiveRegion(GpioChannelIsBusyError, notifier)
self.gettext=Languages().gettext
def is_const(self) -> bool: def is_const(self) -> bool:
return (not self.__switch and not self.__pulse_delay) return (not self.__switch and not self.__pulse_delay)
@@ -190,11 +185,11 @@ class _GpioOutput: # pylint: disable=too-many-instance-attributes
@aiotools.atomic_fg @aiotools.atomic_fg
async def __run_action(self, wait: bool, name: str, func: Callable, *args: Any) -> None: async def __run_action(self, wait: bool, name: str, func: Callable, *args: Any) -> None:
if wait: if wait:
async with self.__region: with self.__region:
await func(*args) await func(*args)
else: else:
await aiotools.run_region_task( await aiotools.run_region_task(
self.gettext(f"Can't perform {name} of {self} or operation was not completed"), f"Can't perform {name} of {self} or operation was not completed",
self.__region, self.__action_task_wrapper, name, func, *args, self.__region, self.__action_task_wrapper, name, func, *args,
) )
@@ -203,12 +198,12 @@ class _GpioOutput: # pylint: disable=too-many-instance-attributes
try: try:
return (await func(*args)) return (await func(*args))
except GpioDriverOfflineError: except GpioDriverOfflineError:
get_logger(0).error(self.gettext("Can't perform %s of %s or operation was not completed: driver offline"), name, self) get_logger(0).error("Can't perform %s of %s or operation was not completed: driver offline", name, self)
@aiotools.atomic_fg @aiotools.atomic_fg
async def __inner_switch(self, state: bool) -> None: async def __inner_switch(self, state: bool) -> None:
await self.__write(state) await self.__write(state)
get_logger(0).info(self.gettext("Ensured switch %s to state=%d"), self, state) get_logger(0).info("Ensured switch %s to state=%d", self, state)
await asyncio.sleep(self.__busy_delay) await asyncio.sleep(self.__busy_delay)
@aiotools.atomic_fg @aiotools.atomic_fg
@@ -219,7 +214,7 @@ class _GpioOutput: # pylint: disable=too-many-instance-attributes
finally: finally:
await self.__write(False) await self.__write(False)
await asyncio.sleep(self.__busy_delay) await asyncio.sleep(self.__busy_delay)
get_logger(0).info(self.gettext("Pulsed %s with delay=%.2f"), self, delay) get_logger(0).info("Pulsed %s with delay=%.2f", self, delay)
# ===== # =====
@@ -230,7 +225,7 @@ class _GpioOutput: # pylint: disable=too-many-instance-attributes
await self.__driver.write(self.__pin, (state ^ self.__inverted)) await self.__driver.write(self.__pin, (state ^ self.__inverted))
def __str__(self) -> str: def __str__(self) -> str:
return self.gettext(f"Output({self.__channel}, driver={self.__driver}, pin={self.__pin})") return f"Output({self.__ch}, driver={self.__driver}, pin={self.__pin})"
__repr__ = __str__ __repr__ = __str__
@@ -238,8 +233,6 @@ class _GpioOutput: # pylint: disable=too-many-instance-attributes
# ===== # =====
class UserGpio: class UserGpio:
def __init__(self, config: Section, otg_config: Section) -> None: def __init__(self, config: Section, otg_config: Section) -> None:
self.__view = config.view
self.__notifier = aiotools.AioNotifier() self.__notifier = aiotools.AioNotifier()
self.__drivers = { self.__drivers = {
@@ -255,54 +248,74 @@ class UserGpio:
self.__inputs: dict[str, _GpioInput] = {} self.__inputs: dict[str, _GpioInput] = {}
self.__outputs: dict[str, _GpioOutput] = {} self.__outputs: dict[str, _GpioOutput] = {}
self.gettext=Languages().gettext for (ch, ch_config) in tools.sorted_kvs(config.scheme):
for (channel, ch_config) in tools.sorted_kvs(config.scheme):
driver = self.__drivers[ch_config.driver] driver = self.__drivers[ch_config.driver]
if ch_config.mode == UserGpioModes.INPUT: if ch_config.mode == UserGpioModes.INPUT:
self.__inputs[channel] = _GpioInput(channel, ch_config, driver) self.__inputs[ch] = _GpioInput(ch, ch_config, driver)
else: # output: else: # output:
self.__outputs[channel] = _GpioOutput(channel, ch_config, driver, self.__notifier) self.__outputs[ch] = _GpioOutput(ch, ch_config, driver, self.__notifier)
async def get_model(self) -> dict: self.__scheme = self.__make_scheme()
return { self.__view = self.__make_view(config.view)
"scheme": {
"inputs": {channel: gin.get_scheme() for (channel, gin) in self.__inputs.items()},
"outputs": {
channel: gout.get_scheme()
for (channel, gout) in self.__outputs.items()
if not gout.is_const()
},
},
"view": self.__make_view(),
}
async def get_state(self) -> dict: async def get_state(self) -> dict:
return { return {
"inputs": {channel: await gin.get_state() for (channel, gin) in self.__inputs.items()}, "model": {
"scheme": copy.deepcopy(self.__scheme),
"view": copy.deepcopy(self.__view),
},
"state": (await self.__get_io_state()),
}
async def trigger_state(self) -> None:
self.__notifier.notify(1)
async def poll_state(self) -> AsyncGenerator[dict, None]:
# ==== Granularity table ====
# - model -- Full
# - state.inputs -- Partial
# - state.outputs -- Partial
# ===========================
prev: dict = {"inputs": {}, "outputs": {}}
while True: # pylint: disable=too-many-nested-blocks
if (await self.__notifier.wait()) > 0:
full = await self.get_state()
prev = copy.deepcopy(full["state"])
yield full
else:
new = await self.__get_io_state()
diff: dict = {}
for sub in ["inputs", "outputs"]:
for ch in new[sub]:
if new[sub][ch] != prev[sub].get(ch):
if sub not in diff:
diff[sub] = {}
diff[sub][ch] = new[sub][ch]
if diff:
prev = copy.deepcopy(new)
yield {"state": diff}
async def __get_io_state(self) -> dict:
return {
"inputs": {
ch: (await gin.get_state())
for (ch, gin) in self.__inputs.items()
},
"outputs": { "outputs": {
channel: await gout.get_state() ch: (await gout.get_state())
for (channel, gout) in self.__outputs.items() for (ch, gout) in self.__outputs.items()
if not gout.is_const() if not gout.is_const()
}, },
} }
async def poll_state(self) -> AsyncGenerator[dict, None]:
prev_state: dict = {}
while True:
state = await self.get_state()
if state != prev_state:
yield state
prev_state = state
await self.__notifier.wait()
def sysprep(self) -> None: def sysprep(self) -> None:
get_logger(0).info(self.gettext("Preparing User-GPIO drivers ...")) get_logger(0).info("Preparing User-GPIO drivers ...")
for (_, driver) in tools.sorted_kvs(self.__drivers): for (_, driver) in tools.sorted_kvs(self.__drivers):
driver.prepare() driver.prepare()
async def systask(self) -> None: async def systask(self) -> None:
get_logger(0).info(self.gettext("Running User-GPIO drivers ...")) get_logger(0).info("Running User-GPIO drivers ...")
await asyncio.gather(*[ await asyncio.gather(*[
driver.run() driver.run()
for (_, driver) in tools.sorted_kvs(self.__drivers) for (_, driver) in tools.sorted_kvs(self.__drivers)
@@ -313,30 +326,45 @@ class UserGpio:
try: try:
await driver.cleanup() await driver.cleanup()
except Exception: except Exception:
get_logger().exception(self.gettext("Can't cleanup driver %s"), driver) get_logger().exception("Can't cleanup driver %s", driver)
async def switch(self, channel: str, state: bool, wait: bool) -> None: async def switch(self, ch: str, state: bool, wait: bool) -> None:
gout = self.__outputs.get(channel) gout = self.__outputs.get(ch)
if gout is None: if gout is None:
raise GpioChannelNotFoundError() raise GpioChannelNotFoundError()
await gout.switch(state, wait) await gout.switch(state, wait)
async def pulse(self, channel: str, delay: float, wait: bool) -> None: async def pulse(self, ch: str, delay: float, wait: bool) -> None:
gout = self.__outputs.get(channel) gout = self.__outputs.get(ch)
if gout is None: if gout is None:
raise GpioChannelNotFoundError() raise GpioChannelNotFoundError()
await gout.pulse(delay, wait) await gout.pulse(delay, wait)
# ===== # =====
def __make_view(self) -> dict: def __make_scheme(self) -> dict:
return { return {
"header": {"title": self.__make_view_title()}, "inputs": {
"table": self.__make_view_table(), ch: gin.get_scheme()
for (ch, gin) in self.__inputs.items()
},
"outputs": {
ch: gout.get_scheme()
for (ch, gout) in self.__outputs.items()
if not gout.is_const()
},
} }
def __make_view_title(self) -> list[dict]: # =====
raw_title = self.__view["header"]["title"]
def __make_view(self, view: dict) -> dict:
return {
"header": {"title": self.__make_view_title(view)},
"table": self.__make_view_table(view),
}
def __make_view_title(self, view: dict) -> list[dict]:
raw_title = view["header"]["title"]
title: list[dict] = [] title: list[dict] = []
if isinstance(raw_title, list): if isinstance(raw_title, list):
for item in raw_title: for item in raw_title:
@@ -350,9 +378,9 @@ class UserGpio:
title.append(self.__make_item_label(f"#{raw_title}")) title.append(self.__make_item_label(f"#{raw_title}"))
return title return title
def __make_view_table(self) -> list[list[dict] | None]: def __make_view_table(self, view: dict) -> list[list[dict] | None]:
table: list[list[dict] | None] = [] table: list[list[dict] | None] = []
for row in self.__view["table"]: for row in view["table"]:
if len(row) == 0: if len(row) == 0:
table.append(None) table.append(None)
continue continue

184
kvmd/apps/oled/__init__.py Normal file
View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
# ========================================================================== #
# #
# KVMD-OLED - A small OLED daemon for PiKVM. #
# #
# 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 sys
import os
import signal
import itertools
import logging
import time
import usb.core
from luma.core import cmdline as luma_cmdline
from PIL import ImageFont
from .screen import Screen
from .sensors import Sensors
# =====
_logger = logging.getLogger("oled")
# =====
def _detect_geometry() -> dict:
with open("/proc/device-tree/model") as file:
is_cm4 = ("Compute Module 4" in file.read())
has_usb = bool(list(usb.core.find(find_all=True)))
if is_cm4 and has_usb:
return {"height": 64, "rotate": 2}
return {"height": 32, "rotate": 0}
def _get_data_path(subdir: str, name: str) -> str:
if not name.startswith("@"):
return name # Just a regular system path
name = name[1:]
module_path = sys.modules[__name__].__file__
assert module_path is not None
return os.path.join(os.path.dirname(module_path), subdir, name)
# =====
def main() -> None: # pylint: disable=too-many-locals,too-many-branches,too-many-statements
logging.basicConfig(level=logging.INFO, format="%(message)s")
logging.getLogger("PIL").setLevel(logging.ERROR)
parser = luma_cmdline.create_parser(description="Display FQDN and IP on the OLED")
parser.set_defaults(**_detect_geometry())
parser.add_argument("--font", default="@ProggySquare.ttf", type=(lambda arg: _get_data_path("fonts", arg)), help="Font path")
parser.add_argument("--font-size", default=16, type=int, help="Font size")
parser.add_argument("--font-spacing", default=2, type=int, help="Font line spacing")
parser.add_argument("--offset-x", default=0, type=int, help="Horizontal offset")
parser.add_argument("--offset-y", default=0, type=int, help="Vertical offset")
parser.add_argument("--interval", default=5, type=int, help="Screens interval")
parser.add_argument("--image", default="", type=(lambda arg: _get_data_path("pics", arg)), help="Display some image, wait a single interval and exit")
parser.add_argument("--text", default="", help="Display some text, wait a single interval and exit")
parser.add_argument("--pipe", action="store_true", help="Read and display lines from stdin until EOF, wait a single interval and exit")
parser.add_argument("--clear-on-exit", action="store_true", help="Clear display on exit")
parser.add_argument("--contrast", default=64, type=int, help="Set OLED contrast, values from 0 to 255")
parser.add_argument("--fahrenheit", action="store_true", help="Display temperature in Fahrenheit instead of Celsius")
options = parser.parse_args(sys.argv[1:])
if options.config:
config = luma_cmdline.load_config(options.config)
options = parser.parse_args(config + sys.argv[1:])
device = luma_cmdline.create_device(options)
device.cleanup = (lambda _: None)
screen = Screen(
device=device,
font=ImageFont.truetype(options.font, options.font_size),
font_spacing=options.font_spacing,
offset=(options.offset_x, options.offset_y),
)
if options.display not in luma_cmdline.get_display_types()["emulator"]:
_logger.info("Iface: %s", options.interface)
_logger.info("Display: %s", options.display)
_logger.info("Size: %dx%d", device.width, device.height)
options.contrast = min(max(options.contrast, 0), 255)
_logger.info("Contrast: %d", options.contrast)
device.contrast(options.contrast)
try:
if options.image:
screen.draw_image(options.image)
time.sleep(options.interval)
elif options.text:
screen.draw_text(options.text.replace("\\n", "\n"))
time.sleep(options.interval)
elif options.pipe:
text = ""
for line in sys.stdin:
text += line
if "\0" in text:
screen.draw_text(text.replace("\0", ""))
text = ""
time.sleep(options.interval)
else:
stop_reason: (str | None) = None
def sigusr_handler(signum: int, _) -> None: # type: ignore
nonlocal stop_reason
if signum in (signal.SIGINT, signal.SIGTERM):
stop_reason = ""
elif signum == signal.SIGUSR1:
stop_reason = "Rebooting...\nPlease wait"
elif signum == signal.SIGUSR2:
stop_reason = "Halted"
for signum in [signal.SIGTERM, signal.SIGINT, signal.SIGUSR1, signal.SIGUSR2]:
signal.signal(signum, sigusr_handler)
hb = itertools.cycle(r"/-\|") # Heartbeat
swim = 0
def draw(text: str) -> None:
nonlocal swim
count = 0
while (count < max(options.interval, 1) * 2) and stop_reason is None:
screen.draw_text(
text=text.replace("__hb__", next(hb)),
offset_x=(3 if swim < 0 else 0),
)
count += 1
if swim >= 1200:
swim = -1200
else:
swim += 1
time.sleep(0.5)
sensors = Sensors(options.fahrenheit)
if device.height >= 64:
while stop_reason is None:
text = "{fqdn}\n{ip}\niface: {iface}\ntemp: {temp}\ncpu: {cpu} mem: {mem}\n(__hb__) {uptime}"
draw(sensors.render(text))
else:
summary = True
while stop_reason is None:
if summary:
text = "{fqdn}\n(__hb__) {uptime}\ntemp: {temp}"
else:
text = "{ip}\n(__hb__) iface: {iface}\ncpu: {cpu} mem: {mem}"
draw(sensors.render(text))
summary = (not summary)
if stop_reason is not None:
if len(stop_reason) > 0:
options.clear_on_exit = False
screen.draw_text(stop_reason)
while len(stop_reason) > 0:
time.sleep(0.1)
except (SystemExit, KeyboardInterrupt):
pass
if options.clear_on_exit:
screen.draw_text("")

View File

@@ -20,15 +20,5 @@
# ========================================================================== # # ========================================================================== #
from typing import Any from . import main
main()
from . import check_string_in_list
from .basic import valid_number
# =====
def valid_languages(arg: Any) -> str:
return check_string_in_list(arg, "Languages", ["zh", "en", "default"])

Binary file not shown.

Binary file not shown.

Binary file not shown.

54
kvmd/apps/oled/screen.py Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
# ========================================================================== #
# #
# KVMD-OLED - A small OLED daemon for PiKVM. #
# #
# 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 luma.core.device import device as luma_device
from luma.core.render import canvas as luma_canvas
from PIL import Image
from PIL import ImageFont
# =====
class Screen:
def __init__(
self,
device: luma_device,
font: ImageFont.FreeTypeFont,
font_spacing: int,
offset: tuple[int, int],
) -> None:
self.__device = device
self.__font = font
self.__font_spacing = font_spacing
self.__offset = offset
def draw_text(self, text: str, offset_x: int=0) -> None:
with luma_canvas(self.__device) as draw:
offset = list(self.__offset)
offset[0] += offset_x
draw.multiline_text(offset, text, font=self.__font, spacing=self.__font_spacing, fill="white")
def draw_image(self, image_path: str) -> None:
with luma_canvas(self.__device) as draw:
draw.bitmap(self.__offset, Image.open(image_path).convert("1"), fill="white")

126
kvmd/apps/oled/sensors.py Normal file
View File

@@ -0,0 +1,126 @@
#!/usr/bin/env python3
# ========================================================================== #
# #
# KVMD-OLED - A small OLED daemon for PiKVM. #
# #
# 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 socket
import functools
import datetime
import time
import netifaces
import psutil
# =====
class Sensors:
def __init__(self, fahrenheit: bool) -> None:
self.__fahrenheit = fahrenheit
self.__sensors = {
"fqdn": socket.getfqdn,
"iface": self.__get_iface,
"ip": self.__get_ip,
"uptime": self.__get_uptime,
"temp": self.__get_temp,
"cpu": self.__get_cpu,
"mem": self.__get_mem,
}
def render(self, text: str) -> str:
return text.format_map(self)
def __getitem__(self, key: str) -> str:
return self.__sensors[key]() # type: ignore
# =====
def __get_iface(self) -> str:
return self.__get_netconf(round(time.monotonic() / 0.3))[0]
def __get_ip(self) -> str:
return self.__get_netconf(round(time.monotonic() / 0.3))[1]
@functools.lru_cache(maxsize=1)
def __get_netconf(self, ts: int) -> tuple[str, str]:
_ = ts
try:
gws = netifaces.gateways()
if "default" in gws:
for proto in [socket.AF_INET, socket.AF_INET6]:
if proto in gws["default"]:
iface = gws["default"][proto][1]
addrs = netifaces.ifaddresses(iface)
return (iface, addrs[proto][0]["addr"])
for iface in netifaces.interfaces():
if not iface.startswith(("lo", "docker")):
addrs = netifaces.ifaddresses(iface)
for proto in [socket.AF_INET, socket.AF_INET6]:
if proto in addrs:
return (iface, addrs[proto][0]["addr"])
except Exception:
# _logger.exception("Can't get iface/IP")
pass
return ("<no-iface>", "<no-ip>")
# =====
def __get_uptime(self) -> str:
uptime = datetime.timedelta(seconds=int(time.time() - psutil.boot_time()))
pl = {"days": uptime.days}
(pl["hours"], rem) = divmod(uptime.seconds, 3600)
(pl["mins"], pl["secs"]) = divmod(rem, 60)
return "{days}d {hours}h {mins}m".format(**pl)
# =====
def __get_temp(self) -> str:
try:
with open("/sys/class/thermal/thermal_zone0/temp") as file:
temp = int(file.read().strip()) / 1000
if self.__fahrenheit:
temp = temp * 9 / 5 + 32
return f"{temp:.1f}\u00b0F"
return f"{temp:.1f}\u00b0C"
except Exception:
# _logger.exception("Can't read temp")
return "<no-temp>"
# =====
def __get_cpu(self) -> str:
st = psutil.cpu_times_percent()
user = st.user - st.guest
nice = st.nice - st.guest_nice
idle_all = st.idle + st.iowait
system_all = st.system + st.irq + st.softirq
virtual = st.guest + st.guest_nice
total = max(1, user + nice + system_all + idle_all + st.steal + virtual)
percent = int(
st.nice / total * 100
+ st.user / total * 100
+ system_all / total * 100
+ (st.steal + st.guest) / total * 100
)
return f"{percent}%"
def __get_mem(self) -> str:
return f"{int(psutil.virtual_memory().percent)}%"

View File

@@ -27,9 +27,7 @@ import json
import time import time
import argparse import argparse
from os.path import join from os.path import join # pylint: disable=ungrouped-imports
from ...languages import Languages
from ...logging import get_logger from ...logging import get_logger
@@ -207,14 +205,13 @@ def _cmd_start(config: Section) -> None: # pylint: disable=too-many-statements,
# https://www.isticktoit.net/?p=1383 # https://www.isticktoit.net/?p=1383
logger = get_logger() logger = get_logger()
gettext=Languages().gettext
_check_config(config) _check_config(config)
udc = usb.find_udc(config.otg.udc) udc = usb.find_udc(config.otg.udc)
logger.info(gettext("Using UDC %s"), udc) logger.info("Using UDC %s", udc)
logger.info(gettext("Creating gadget %r ..."), config.otg.gadget) logger.info("Creating gadget %r ...", config.otg.gadget)
gadget_path = usb.get_gadget_path(config.otg.gadget) gadget_path = usb.get_gadget_path(config.otg.gadget)
_mkdir(gadget_path) _mkdir(gadget_path)
@@ -255,39 +252,39 @@ def _cmd_start(config: Section) -> None: # pylint: disable=too-many-statements,
cod = config.otg.devices cod = config.otg.devices
if cod.serial.enabled: if cod.serial.enabled:
logger.info(gettext("===== Serial =====")) logger.info("===== Serial =====")
gc.add_serial(cod.serial.start) gc.add_serial(cod.serial.start)
if cod.ethernet.enabled: if cod.ethernet.enabled:
logger.info(gettext("===== Ethernet =====")) logger.info("===== Ethernet =====")
gc.add_ethernet(**cod.ethernet._unpack(ignore=["enabled"])) gc.add_ethernet(**cod.ethernet._unpack(ignore=["enabled"]))
if config.kvmd.hid.type == "otg": if config.kvmd.hid.type == "otg":
logger.info(gettext("===== 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(gettext("===== 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) gc.add_mouse(cod.hid.mouse.start, config.otg.remote_wakeup, config.kvmd.hid.mouse.absolute, config.kvmd.hid.mouse.horizontal_wheel)
if config.kvmd.hid.mouse_alt.device: if config.kvmd.hid.mouse_alt.device:
logger.info(gettext("===== 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.start, config.otg.remote_wakeup, (not config.kvmd.hid.mouse.absolute), config.kvmd.hid.mouse.horizontal_wheel)
if config.kvmd.msd.type == "otg": if config.kvmd.msd.type == "otg":
logger.info(gettext("===== MSD =====")) logger.info("===== MSD =====")
gc.add_msd(cod.msd.start, config.otg.user, **cod.msd.default._unpack()) gc.add_msd(cod.msd.start, config.otg.user, **cod.msd.default._unpack())
if cod.drives.enabled: if cod.drives.enabled:
for count in range(cod.drives.count): for count in range(cod.drives.count):
logger.info(gettext("===== 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(cod.drives.start, "root", **cod.drives.default._unpack())
logger.info(gettext("===== Preparing complete =====")) logger.info("===== Preparing complete =====")
logger.info(gettext("Enabling the gadget ...")) logger.info("Enabling the gadget ...")
_write(join(gadget_path, "UDC"), udc) _write(join(gadget_path, "UDC"), udc)
time.sleep(config.otg.init_delay) time.sleep(config.otg.init_delay)
_chown(join(gadget_path, "UDC"), config.otg.user) _chown(join(gadget_path, "UDC"), config.otg.user)
_chown(profile_path, config.otg.user) _chown(profile_path, config.otg.user)
logger.info(gettext("Ready to work")) logger.info("Ready to work")
# ===== # =====
@@ -300,7 +297,7 @@ def _cmd_stop(config: Section) -> None:
gadget_path = usb.get_gadget_path(config.otg.gadget) gadget_path = usb.get_gadget_path(config.otg.gadget)
logger.info(Languages().gettext("Disabling gadget %r ..."), config.otg.gadget) logger.info("Disabling gadget %r ...", config.otg.gadget)
_write(join(gadget_path, "UDC"), "\n") _write(join(gadget_path, "UDC"), "\n")
_unlink(join(gadget_path, "os_desc", usb.G_PROFILE_NAME), optional=True) _unlink(join(gadget_path, "os_desc", usb.G_PROFILE_NAME), optional=True)
@@ -353,5 +350,5 @@ def main(argv: (list[str] | None)=None) -> None:
options = parser.parse_args(argv[1:]) options = parser.parse_args(argv[1:])
try: try:
options.cmd(config) options.cmd(config)
except ValidatorError as err: except ValidatorError as ex:
raise SystemExit(str(err)) raise SystemExit(str(ex))

View File

@@ -50,9 +50,9 @@ def _set_param(gadget: str, instance: int, param: str, value: str) -> None:
try: try:
with open(_get_param_path(gadget, instance, param), "w") as file: with open(_get_param_path(gadget, instance, param), "w") as file:
file.write(value + "\n") file.write(value + "\n")
except OSError as err: except OSError as ex:
if err.errno == errno.EBUSY: if ex.errno == errno.EBUSY:
raise SystemExit(f"Can't change {param!r} value because device is locked: {err}") raise SystemExit(f"Can't change {param!r} value because device is locked: {ex}")
raise raise

View File

@@ -26,8 +26,6 @@ import dataclasses
import itertools import itertools
import argparse import argparse
from ...languages import Languages
from ...logging import get_logger from ...logging import get_logger
from ...yamlconf import Section from ...yamlconf import Section
@@ -89,8 +87,6 @@ class _Service: # pylint: disable=too-many-instance-attributes
self.__gadget: str = config.otg.gadget self.__gadget: str = config.otg.gadget
self.__driver: str = config.otg.devices.ethernet.driver self.__driver: str = config.otg.devices.ethernet.driver
self.gettext=Languages().gettext
def start(self) -> None: def start(self) -> None:
asyncio.run(self.__run(True)) asyncio.run(self.__run(True))
@@ -125,20 +121,20 @@ class _Service: # pylint: disable=too-many-instance-attributes
for ctl in ctls: for ctl in ctls:
if not (await self.__run_ctl(ctl, True)): if not (await self.__run_ctl(ctl, True)):
raise SystemExit(1) raise SystemExit(1)
get_logger(0).info(self.gettext("Ready to work")) get_logger(0).info("Ready to work")
else: else:
for ctl in reversed(ctls): for ctl in reversed(ctls):
await self.__run_ctl(ctl, False) await self.__run_ctl(ctl, False)
get_logger(0).info(self.gettext("Bye-bye")) get_logger(0).info("Bye-bye")
async def __run_ctl(self, ctl: BaseCtl, direct: bool) -> bool: async def __run_ctl(self, ctl: BaseCtl, direct: bool) -> bool:
logger = get_logger() logger = get_logger()
cmd = ctl.get_command(direct) cmd = ctl.get_command(direct)
logger.info(self.gettext("CMD: %s"), tools.cmdfmt(cmd)) logger.info("CMD: %s", tools.cmdfmt(cmd))
try: try:
return (not (await aioproc.log_process(cmd, logger)).returncode) return (not (await aioproc.log_process(cmd, logger)).returncode)
except Exception as err: except Exception as ex:
logger.exception(self.gettext("Can't execute command: %s"), err) logger.exception("Can't execute command: %s", ex)
return False return False
# ===== # =====
@@ -147,10 +143,10 @@ class _Service: # pylint: disable=too-many-instance-attributes
iface = self.__find_iface() iface = self.__find_iface()
logger = get_logger() logger = get_logger()
logger.info(self.gettext("Using IPv4 network %s ..."), self.__iface_net) logger.info("Using IPv4 network %s ...", self.__iface_net)
net = ipaddress.IPv4Network(self.__iface_net) net = ipaddress.IPv4Network(self.__iface_net)
if net.prefixlen > 31: if net.prefixlen > 31:
raise RuntimeError(self.gettext("Too small network, required at least /31")) raise RuntimeError("Too small network, required at least /31")
if net.prefixlen == 31: if net.prefixlen == 31:
iface_ip = str(net[0]) iface_ip = str(net[0])
@@ -170,7 +166,7 @@ class _Service: # pylint: disable=too-many-instance-attributes
dhcp_ip_end=dhcp_ip_end, dhcp_ip_end=dhcp_ip_end,
dhcp_option_3=(f"3,{iface_ip}" if self.__forward_iface else "3"), dhcp_option_3=(f"3,{iface_ip}" if self.__forward_iface else "3"),
) )
logger.info(self.gettext("Calculated %r address is %s/%d"), iface, iface_ip, netcfg.net_prefix) logger.info("Calculated %r address is %s/%d", iface, iface_ip, netcfg.net_prefix)
return netcfg return netcfg
def __find_iface(self) -> str: def __find_iface(self) -> str:
@@ -179,10 +175,10 @@ class _Service: # pylint: disable=too-many-instance-attributes
if self.__driver == "rndis5": if self.__driver == "rndis5":
real_driver = "rndis" real_driver = "rndis"
path = usb.get_gadget_path(self.__gadget, usb.G_FUNCTIONS, f"{real_driver}.usb0/ifname") path = usb.get_gadget_path(self.__gadget, usb.G_FUNCTIONS, f"{real_driver}.usb0/ifname")
logger.info(self.gettext("Using OTG gadget %r ..."), self.__gadget) logger.info("Using OTG gadget %r ...", self.__gadget)
with open(path) as file: with open(path) as file:
iface = file.read().strip() iface = file.read().strip()
logger.info(self.gettext("Using OTG Ethernet interface %r ..."), iface) logger.info("Using OTG Ethernet interface %r ...", iface)
assert iface assert iface
return iface return iface

View File

@@ -50,7 +50,7 @@ class PstServer(HttpServer): # pylint: disable=too-many-arguments,too-many-inst
super().__init__() super().__init__()
self.__data_path = os.path.join(fstab.find_pst().root_path, "data") self.__data_path = fstab.find_pst().root_path
self.__ro_retries_delay = ro_retries_delay self.__ro_retries_delay = ro_retries_delay
self.__ro_cleanup_delay = ro_cleanup_delay self.__ro_cleanup_delay = ro_cleanup_delay
self.__remount_cmd = remount_cmd self.__remount_cmd = remount_cmd
@@ -60,8 +60,8 @@ class PstServer(HttpServer): # pylint: disable=too-many-arguments,too-many-inst
# ===== WEBSOCKET # ===== WEBSOCKET
@exposed_http("GET", "/ws") @exposed_http("GET", "/ws")
async def __ws_handler(self, request: Request) -> WebSocketResponse: async def __ws_handler(self, req: Request) -> WebSocketResponse:
async with self._ws_session(request) as ws: async with self._ws_session(req) as ws:
await ws.send_event("loop", {}) await ws.send_event("loop", {})
return (await self._ws_loop(ws)) return (await self._ws_loop(ws))
@@ -128,9 +128,9 @@ class PstServer(HttpServer): # pylint: disable=too-many-arguments,too-many-inst
def __is_write_available(self) -> bool: def __is_write_available(self) -> bool:
try: try:
return (not (os.statvfs(self.__data_path).f_flag & os.ST_RDONLY)) return (not (os.statvfs(self.__data_path).f_flag & os.ST_RDONLY))
except Exception as err: except Exception as ex:
get_logger(0).info("Can't get filesystem state of PST (%s): %s", get_logger(0).info("Can't get filesystem state of PST (%s): %s",
self.__data_path, tools.efmt(err)) self.__data_path, tools.efmt(ex))
return False return False
async def __remount_storage(self, rw: bool) -> bool: async def __remount_storage(self, rw: bool) -> bool:

View File

@@ -46,8 +46,8 @@ def _preexec() -> None:
if os.isatty(0): if os.isatty(0):
try: try:
os.tcsetpgrp(0, os.getpgid(0)) os.tcsetpgrp(0, os.getpgid(0))
except Exception as err: except Exception as ex:
get_logger(0).info("Can't perform tcsetpgrp(0): %s", tools.efmt(err)) get_logger(0).info("Can't perform tcsetpgrp(0): %s", tools.efmt(ex))
async def _run_process(cmd: list[str], data_path: str) -> asyncio.subprocess.Process: # pylint: disable=no-member async def _run_process(cmd: list[str], data_path: str) -> asyncio.subprocess.Process: # pylint: disable=no-member

View File

@@ -21,7 +21,7 @@
from ...clients.kvmd import KvmdClient from ...clients.kvmd import KvmdClient
from ...clients.streamer import StreamFormats from ...clients.streamer import StreamerFormats
from ...clients.streamer import BaseStreamerClient from ...clients.streamer import BaseStreamerClient
from ...clients.streamer import HttpStreamerClient from ...clients.streamer import HttpStreamerClient
from ...clients.streamer import MemsinkStreamerClient from ...clients.streamer import MemsinkStreamerClient
@@ -51,8 +51,8 @@ def main(argv: (list[str] | None)=None) -> None:
return None return None
streamers: list[BaseStreamerClient] = list(filter(None, [ streamers: list[BaseStreamerClient] = list(filter(None, [
make_memsink_streamer("h264", StreamFormats.H264), make_memsink_streamer("h264", StreamerFormats.H264),
make_memsink_streamer("jpeg", StreamFormats.JPEG), make_memsink_streamer("jpeg", StreamerFormats.JPEG),
HttpStreamerClient(name="JPEG", user_agent=user_agent, **config.streamer._unpack()), HttpStreamerClient(name="JPEG", user_agent=user_agent, **config.streamer._unpack()),
])) ]))
@@ -71,6 +71,7 @@ def main(argv: (list[str] | None)=None) -> None:
desired_fps=config.desired_fps, desired_fps=config.desired_fps,
mouse_output=config.mouse_output, mouse_output=config.mouse_output,
keymap_path=config.keymap, keymap_path=config.keymap,
allow_cut_after=config.allow_cut_after,
kvmd=KvmdClient(user_agent=user_agent, **config.kvmd._unpack()), kvmd=KvmdClient(user_agent=user_agent, **config.kvmd._unpack()),
streamers=streamers, streamers=streamers,

View File

@@ -22,6 +22,7 @@
import asyncio import asyncio
import ssl import ssl
import time
from typing import Callable from typing import Callable
from typing import Coroutine from typing import Coroutine
@@ -64,6 +65,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
width: int, width: int,
height: int, height: int,
name: str, name: str,
allow_cut_after: float,
vnc_passwds: list[str], vnc_passwds: list[str],
vencrypt: bool, vencrypt: bool,
none_auth_only: bool, none_auth_only: bool,
@@ -79,6 +81,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
self._width = width self._width = width
self._height = height self._height = height
self.__name = name self.__name = name
self.__allow_cut_after = allow_cut_after
self.__vnc_passwds = vnc_passwds self.__vnc_passwds = vnc_passwds
self.__vencrypt = vencrypt self.__vencrypt = vencrypt
self.__none_auth_only = none_auth_only self.__none_auth_only = none_auth_only
@@ -90,6 +93,8 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
self.__fb_cont_updates = False self.__fb_cont_updates = False
self.__fb_reset_h264 = False self.__fb_reset_h264 = False
self.__allow_cut_since_ts = 0.0
self.__lock = asyncio.Lock() self.__lock = asyncio.Lock()
# ===== # =====
@@ -120,10 +125,10 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("%s [%s]: Cancelling subtask ...", self._remote, name) logger.info("%s [%s]: Cancelling subtask ...", self._remote, name)
raise raise
except RfbConnectionError as err: except RfbConnectionError as ex:
logger.info("%s [%s]: Gone: %s", self._remote, name, err) logger.info("%s [%s]: Gone: %s", self._remote, name, ex)
except (RfbError, ssl.SSLError) as err: except (RfbError, ssl.SSLError) as ex:
logger.error("%s [%s]: Error: %s", self._remote, name, err) logger.error("%s [%s]: Error: %s", self._remote, name, ex)
except Exception: except Exception:
logger.exception("%s [%s]: Unhandled exception", self._remote, name) logger.exception("%s [%s]: Unhandled exception", self._remote, name)
@@ -414,6 +419,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
# ===== # =====
async def __main_loop(self) -> None: async def __main_loop(self) -> None:
self.__allow_cut_since_ts = time.monotonic() + self.__allow_cut_after
handlers = { handlers = {
0: self.__handle_set_pixel_format, 0: self.__handle_set_pixel_format,
2: self.__handle_set_encodings, 2: self.__handle_set_encodings,
@@ -499,7 +505,12 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
async def __handle_client_cut_text(self) -> None: async def __handle_client_cut_text(self) -> None:
length = (await self._read_struct("cut text length", "xxx L"))[0] length = (await self._read_struct("cut text length", "xxx L"))[0]
text = await self._read_text("cut text data", length) text = await self._read_text("cut text data", length)
await self._on_cut_event(text) if self.__allow_cut_since_ts > 0 and time.monotonic() >= self.__allow_cut_since_ts:
# We should ignore cut event a few seconds after handshake
# because bVNC, AVNC and maybe some other clients perform
# it right after the connection automatically.
# - https://github.com/pikvm/pikvm/issues/1420
await self._on_cut_event(text)
async def __handle_enable_cont_updates(self) -> None: async def __handle_enable_cont_updates(self) -> None:
enabled = bool((await self._read_struct("enabled ContUpdates", "B HH HH"))[0]) enabled = bool((await self._read_struct("enabled ContUpdates", "B HH HH"))[0])

View File

@@ -29,5 +29,5 @@ class RfbError(Exception):
class RfbConnectionError(RfbError): class RfbConnectionError(RfbError):
def __init__(self, msg: str, err: Exception) -> None: def __init__(self, msg: str, ex: Exception) -> None:
super().__init__(f"{msg}: {tools.efmt(err)}") super().__init__(f"{msg}: {tools.efmt(ex)}")

View File

@@ -51,22 +51,22 @@ class RfbClientStream:
else: else:
fmt = f">{fmt}" fmt = f">{fmt}"
return struct.unpack(fmt, await self.__reader.readexactly(struct.calcsize(fmt)))[0] return struct.unpack(fmt, await self.__reader.readexactly(struct.calcsize(fmt)))[0]
except (ConnectionError, asyncio.IncompleteReadError) as err: except (ConnectionError, asyncio.IncompleteReadError) as ex:
raise RfbConnectionError(f"Can't read {msg}", err) raise RfbConnectionError(f"Can't read {msg}", ex)
async def _read_struct(self, msg: str, fmt: str) -> tuple[int, ...]: async def _read_struct(self, msg: str, fmt: str) -> tuple[int, ...]:
assert len(fmt) > 1 assert len(fmt) > 1
try: try:
fmt = f">{fmt}" fmt = f">{fmt}"
return struct.unpack(fmt, (await self.__reader.readexactly(struct.calcsize(fmt)))) return struct.unpack(fmt, (await self.__reader.readexactly(struct.calcsize(fmt))))
except (ConnectionError, asyncio.IncompleteReadError) as err: except (ConnectionError, asyncio.IncompleteReadError) as ex:
raise RfbConnectionError(f"Can't read {msg}", err) raise RfbConnectionError(f"Can't read {msg}", ex)
async def _read_text(self, msg: str, length: int) -> str: async def _read_text(self, msg: str, length: int) -> str:
try: try:
return (await self.__reader.readexactly(length)).decode("utf-8", errors="ignore") return (await self.__reader.readexactly(length)).decode("utf-8", errors="ignore")
except (ConnectionError, asyncio.IncompleteReadError) as err: except (ConnectionError, asyncio.IncompleteReadError) as ex:
raise RfbConnectionError(f"Can't read {msg}", err) raise RfbConnectionError(f"Can't read {msg}", ex)
# ===== # =====
@@ -84,8 +84,8 @@ class RfbClientStream:
self.__writer.write(struct.pack(f">{fmt}", *values)) self.__writer.write(struct.pack(f">{fmt}", *values))
if drain: if drain:
await self.__writer.drain() await self.__writer.drain()
except ConnectionError as err: except ConnectionError as ex:
raise RfbConnectionError(f"Can't write {msg}", err) raise RfbConnectionError(f"Can't write {msg}", ex)
async def _write_reason(self, msg: str, text: str, drain: bool=True) -> None: async def _write_reason(self, msg: str, text: str, drain: bool=True) -> None:
encoded = text.encode("utf-8", errors="ignore") encoded = text.encode("utf-8", errors="ignore")
@@ -94,8 +94,8 @@ class RfbClientStream:
self.__writer.write(encoded) self.__writer.write(encoded)
if drain: if drain:
await self.__writer.drain() await self.__writer.drain()
except ConnectionError as err: except ConnectionError as ex:
raise RfbConnectionError(f"Can't write {msg}", err) raise RfbConnectionError(f"Can't write {msg}", ex)
async def _write_fb_update(self, msg: str, width: int, height: int, encoding: int, drain: bool=True) -> None: async def _write_fb_update(self, msg: str, width: int, height: int, encoding: int, drain: bool=True) -> None:
await self._write_struct( await self._write_struct(
@@ -123,8 +123,8 @@ class RfbClientStream:
server_side=True, server_side=True,
ssl_handshake_timeout=ssl_timeout, ssl_handshake_timeout=ssl_timeout,
) )
except ConnectionError as err: except ConnectionError as ex:
raise RfbConnectionError("Can't start TLS", err) raise RfbConnectionError("Can't start TLS", ex)
ssl_reader.set_transport(transport) # type: ignore ssl_reader.set_transport(transport) # type: ignore
ssl_writer = asyncio.StreamWriter( ssl_writer = asyncio.StreamWriter(

View File

@@ -28,8 +28,6 @@ import contextlib
import aiohttp import aiohttp
from ...languages import Languages
from ...logging import get_logger from ...logging import get_logger
from ...keyboard.keysym import SymmapModifiers from ...keyboard.keysym import SymmapModifiers
@@ -44,7 +42,7 @@ from ...clients.kvmd import KvmdClient
from ...clients.streamer import StreamerError from ...clients.streamer import StreamerError
from ...clients.streamer import StreamerPermError from ...clients.streamer import StreamerPermError
from ...clients.streamer import StreamFormats from ...clients.streamer import StreamerFormats
from ...clients.streamer import BaseStreamerClient from ...clients.streamer import BaseStreamerClient
from ... import tools from ... import tools
@@ -83,6 +81,7 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
mouse_output: str, mouse_output: str,
keymap_name: str, keymap_name: str,
symmap: dict[int, dict[int, str]], symmap: dict[int, dict[int, str]],
allow_cut_after: float,
kvmd: KvmdClient, kvmd: KvmdClient,
streamers: list[BaseStreamerClient], streamers: list[BaseStreamerClient],
@@ -102,6 +101,7 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
tls_timeout=tls_timeout, tls_timeout=tls_timeout,
x509_cert_path=x509_cert_path, x509_cert_path=x509_cert_path,
x509_key_path=x509_key_path, x509_key_path=x509_key_path,
allow_cut_after=allow_cut_after,
vnc_passwds=list(vnc_credentials), vnc_passwds=list(vnc_credentials),
vencrypt=vencrypt, vencrypt=vencrypt,
none_auth_only=none_auth_only, none_auth_only=none_auth_only,
@@ -135,8 +135,6 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
self.__modifiers = 0 self.__modifiers = 0
self.gettext=Languages().gettext
# ===== # =====
async def run(self) -> None: async def run(self) -> None:
@@ -160,13 +158,13 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
logger = get_logger(0) logger = get_logger(0)
await self.__stage1_authorized.wait_passed() await self.__stage1_authorized.wait_passed()
logger.info(self.gettext("%s [kvmd]: Waiting for the SetEncodings message ..."), self._remote) logger.info("%s [kvmd]: Waiting for the SetEncodings message ...", self._remote)
if not (await self.__stage2_encodings_accepted.wait_passed(timeout=5)): if not (await self.__stage2_encodings_accepted.wait_passed(timeout=5)):
raise RfbError(self.gettext("No SetEncodings message recieved from the client in 5 secs")) raise RfbError("No SetEncodings message recieved from the client in 5 secs")
assert self.__kvmd_session assert self.__kvmd_session
try: try:
logger.info(self.gettext("%s [kvmd]: Applying HID params: mouse_output=%s ..."), self._remote, self.__mouse_output) logger.info("%s [kvmd]: Applying HID params: mouse_output=%s ...", self._remote, self.__mouse_output)
await self.__kvmd_session.hid.set_params(mouse_output=self.__mouse_output) await self.__kvmd_session.hid.set_params(mouse_output=self.__mouse_output)
async with self.__kvmd_session.ws() as self.__kvmd_ws: async with self.__kvmd_session.ws() as self.__kvmd_ws:
@@ -174,25 +172,30 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
self.__stage3_ws_connected.set_passed() self.__stage3_ws_connected.set_passed()
async for (event_type, event) in self.__kvmd_ws.communicate(): async for (event_type, event) in self.__kvmd_ws.communicate():
await self.__process_ws_event(event_type, event) await self.__process_ws_event(event_type, event)
raise RfbError(self.gettext("KVMD closed the websocket (the server may have been stopped)")) raise RfbError("KVMD closed the websocket (the server may have been stopped)")
finally: finally:
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_meta_state": if event_type == "info_state":
try: if "meta" in event:
host = event["server"]["host"] try:
except Exception: host = event["meta"]["server"]["host"]
host = None except Exception:
else: host = None
if isinstance(host, str): else:
name = f"PiKVM: {host}" if isinstance(host, str):
if self._encodings.has_rename: name = f"PiKVM: {host}"
await self._send_rename(name) if self._encodings.has_rename:
self.__shared_params.name = name await self._send_rename(name)
self.__shared_params.name = name
elif event_type == "hid_state": elif event_type == "hid_state":
if self._encodings.has_leds_state: if (
self._encodings.has_leds_state
and ("keyboard" in event)
and ("leds" in event["keyboard"])
):
await self._send_leds_state(**event["keyboard"]["leds"]) await self._send_leds_state(**event["keyboard"]["leds"])
# ===== # =====
@@ -208,36 +211,36 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
while True: while True:
frame = await read_frame(not self.__fb_has_key) frame = await read_frame(not self.__fb_has_key)
if not streaming: if not streaming:
logger.info(self.gettext("%s [streamer]: Streaming ..."), self._remote) logger.info("%s [streamer]: Streaming ...", self._remote)
streaming = True streaming = True
if frame["online"]: if frame["online"]:
await self.__queue_frame(frame) await self.__queue_frame(frame)
else: else:
await self.__queue_frame(self.gettext("No signal")) await self.__queue_frame("No signal")
except StreamerError as err: except StreamerError as ex:
if isinstance(err, StreamerPermError): if isinstance(ex, StreamerPermError):
streamer = self.__get_default_streamer() streamer = self.__get_default_streamer()
logger.info(self.gettext("%s [streamer]: Permanent error: %s; switching to %s ..."), self._remote, err, streamer) logger.info("%s [streamer]: Permanent error: %s; switching to %s ...", self._remote, ex, streamer)
else: else:
logger.info(self.gettext("%s [streamer]: Waiting for stream: %s"), self._remote, err) logger.info("%s [streamer]: Waiting for stream: %s", self._remote, ex)
await self.__queue_frame(self.gettext("Waiting for stream ...")) await self.__queue_frame("Waiting for stream ...")
await asyncio.sleep(1) await asyncio.sleep(1)
def __get_preferred_streamer(self) -> BaseStreamerClient: def __get_preferred_streamer(self) -> BaseStreamerClient:
formats = { formats = {
StreamFormats.JPEG: "has_tight", StreamerFormats.JPEG: "has_tight",
StreamFormats.H264: "has_h264", StreamerFormats.H264: "has_h264",
} }
streamer: (BaseStreamerClient | None) = None streamer: (BaseStreamerClient | None) = None
for streamer in self.__streamers: for streamer in self.__streamers:
if getattr(self._encodings, formats[streamer.get_format()]): if getattr(self._encodings, formats[streamer.get_format()]):
get_logger(0).info(self.gettext("%s [streamer]: Using preferred %s"), self._remote, streamer) get_logger(0).info("%s [streamer]: Using preferred %s", self._remote, streamer)
return streamer return streamer
raise RuntimeError("No streamers found") raise RuntimeError("No streamers found")
def __get_default_streamer(self) -> BaseStreamerClient: def __get_default_streamer(self) -> BaseStreamerClient:
streamer = self.__streamers[-1] streamer = self.__streamers[-1]
get_logger(0).info(self.gettext("%s [streamer]: Using default %s"), self._remote, streamer) get_logger(0).info("%s [streamer]: Using default %s", self._remote, streamer)
return streamer return streamer
async def __queue_frame(self, frame: (dict | str)) -> None: async def __queue_frame(self, frame: (dict | str)) -> None:
@@ -252,7 +255,7 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
"data": (await make_text_jpeg(self._width, self._height, self._encodings.tight_jpeg_quality, text)), "data": (await make_text_jpeg(self._width, self._height, self._encodings.tight_jpeg_quality, text)),
"width": self._width, "width": self._width,
"height": self._height, "height": self._height,
"format": StreamFormats.JPEG, "format": StreamerFormats.JPEG,
} }
async def __fb_sender_task_loop(self) -> None: # pylint: disable=too-many-branches async def __fb_sender_task_loop(self) -> None: # pylint: disable=too-many-branches
@@ -262,21 +265,21 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
frame = await self.__fb_queue.get() frame = await self.__fb_queue.get()
if ( if (
last is None # pylint: disable=too-many-boolean-expressions last is None # pylint: disable=too-many-boolean-expressions
or frame["format"] == StreamFormats.JPEG or frame["format"] == StreamerFormats.JPEG
or last["format"] != frame["format"] or last["format"] != frame["format"]
or (frame["format"] == StreamFormats.H264 and ( or (frame["format"] == StreamerFormats.H264 and (
frame["key"] frame["key"]
or last["width"] != frame["width"] or last["width"] != frame["width"]
or last["height"] != frame["height"] or last["height"] != frame["height"]
or len(last["data"]) + len(frame["data"]) > 4194304 or len(last["data"]) + len(frame["data"]) > 4194304
)) ))
): ):
self.__fb_has_key = (frame["format"] == StreamFormats.H264 and frame["key"]) self.__fb_has_key = (frame["format"] == StreamerFormats.H264 and frame["key"])
last = frame last = frame
if self.__fb_queue.qsize() == 0: if self.__fb_queue.qsize() == 0:
break break
continue continue
assert frame["format"] == StreamFormats.H264 assert frame["format"] == StreamerFormats.H264
last["data"] += frame["data"] last["data"] += frame["data"]
if self.__fb_queue.qsize() == 0: if self.__fb_queue.qsize() == 0:
break break
@@ -298,17 +301,17 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
await self._send_fb_allow_again() await self._send_fb_allow_again()
continue continue
if last["format"] == StreamFormats.JPEG: if last["format"] == StreamerFormats.JPEG:
await self._send_fb_jpeg(last["data"]) await self._send_fb_jpeg(last["data"])
elif last["format"] == StreamFormats.H264: elif last["format"] == StreamerFormats.H264:
if not self._encodings.has_h264: if not self._encodings.has_h264:
raise RfbError(self.gettext("The client doesn't want to accept H264 anymore")) raise RfbError("The client doesn't want to accept H264 anymore")
if self.__fb_has_key: if self.__fb_has_key:
await self._send_fb_h264(last["data"]) await self._send_fb_h264(last["data"])
else: else:
await self._send_fb_allow_again() await self._send_fb_allow_again()
else: else:
raise RuntimeError(self.gettext(f"Unknown format: {last['format']}")) raise RuntimeError(f"Unknown format: {last['format']}")
last["data"] = b"" last["data"] = b""
# ===== # =====
@@ -414,7 +417,7 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
has_quality = (await self.__kvmd_session.streamer.get_state())["features"]["quality"] has_quality = (await self.__kvmd_session.streamer.get_state())["features"]["quality"]
quality = (self._encodings.tight_jpeg_quality if has_quality else None) quality = (self._encodings.tight_jpeg_quality if has_quality else None)
get_logger(0).info(self.gettext("%s [main]: Applying streamer params: jpeg_quality=%s; desired_fps=%d ..."), get_logger(0).info("%s [main]: Applying streamer params: jpeg_quality=%s; desired_fps=%d ...",
self._remote, quality, self.__desired_fps) self._remote, quality, self.__desired_fps)
await self.__kvmd_session.streamer.set_params(quality, self.__desired_fps) await self.__kvmd_session.streamer.set_params(quality, self.__desired_fps)
@@ -443,6 +446,7 @@ class VncServer: # pylint: disable=too-many-instance-attributes
desired_fps: int, desired_fps: int,
mouse_output: str, mouse_output: str,
keymap_path: str, keymap_path: str,
allow_cut_after: float,
kvmd: KvmdClient, kvmd: KvmdClient,
streamers: list[BaseStreamerClient], streamers: list[BaseStreamerClient],
@@ -460,16 +464,14 @@ class VncServer: # pylint: disable=too-many-instance-attributes
shared_params = _SharedParams() shared_params = _SharedParams()
self.gettext=Languages().gettext
async def cleanup_client(writer: asyncio.StreamWriter) -> None: async def cleanup_client(writer: asyncio.StreamWriter) -> None:
if (await aiotools.close_writer(writer)): if (await aiotools.close_writer(writer)):
get_logger(0).info(self.gettext("%s [entry]: Connection is closed in an emergency"), rfb_format_remote(writer)) get_logger(0).info("%s [entry]: Connection is closed in an emergency", rfb_format_remote(writer))
async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
logger = get_logger(0) logger = get_logger(0)
remote = rfb_format_remote(writer) remote = rfb_format_remote(writer)
logger.info(self.gettext("%s [entry]: Connected client"), remote) logger.info("%s [entry]: Connected client", remote)
try: try:
sock = writer.get_extra_info("socket") sock = writer.get_extra_info("socket")
if no_delay: if no_delay:
@@ -487,8 +489,8 @@ class VncServer: # pylint: disable=too-many-instance-attributes
try: try:
async with kvmd.make_session("", "") as kvmd_session: async with kvmd.make_session("", "") as kvmd_session:
none_auth_only = await kvmd_session.auth.check() none_auth_only = await kvmd_session.auth.check()
except (aiohttp.ClientError, asyncio.TimeoutError) as err: except (aiohttp.ClientError, asyncio.TimeoutError) as ex:
logger.error(self.gettext("%s [entry]: Can't check KVMD auth mode: %s"), remote, tools.efmt(err)) logger.error("%s [entry]: Can't check KVMD auth mode: %s", remote, tools.efmt(ex))
return return
await _Client( await _Client(
@@ -502,6 +504,7 @@ class VncServer: # pylint: disable=too-many-instance-attributes
mouse_output=mouse_output, mouse_output=mouse_output,
keymap_name=keymap_name, keymap_name=keymap_name,
symmap=symmap, symmap=symmap,
allow_cut_after=allow_cut_after,
kvmd=kvmd, kvmd=kvmd,
streamers=streamers, streamers=streamers,
vnc_credentials=(await self.__vnc_auth_manager.read_credentials())[0], vnc_credentials=(await self.__vnc_auth_manager.read_credentials())[0],
@@ -510,7 +513,7 @@ class VncServer: # pylint: disable=too-many-instance-attributes
shared_params=shared_params, shared_params=shared_params,
).run() ).run()
except Exception: except Exception:
logger.exception(self.gettext("%s [entry]: Unhandled exception in client task"), remote) logger.exception("%s [entry]: Unhandled exception in client task", remote)
finally: finally:
await aiotools.shield_fg(cleanup_client(writer)) await aiotools.shield_fg(cleanup_client(writer))
@@ -520,7 +523,7 @@ class VncServer: # pylint: disable=too-many-instance-attributes
if not (await self.__vnc_auth_manager.read_credentials())[1]: if not (await self.__vnc_auth_manager.read_credentials())[1]:
raise SystemExit(1) raise SystemExit(1)
get_logger(0).info(self.gettext("Listening VNC on TCP [%s]:%d ..."), self.__host, self.__port) get_logger(0).info("Listening VNC on TCP [%s]:%d ...", self.__host, self.__port)
(family, _, _, _, addr) = socket.getaddrinfo(self.__host, self.__port, type=socket.SOCK_STREAM)[0] (family, _, _, _, addr) = socket.getaddrinfo(self.__host, self.__port, type=socket.SOCK_STREAM)[0]
with contextlib.closing(socket.socket(family, socket.SOCK_STREAM)) as sock: with contextlib.closing(socket.socket(family, socket.SOCK_STREAM)) as sock:
if family == socket.AF_INET6: if family == socket.AF_INET6:
@@ -538,4 +541,4 @@ class VncServer: # pylint: disable=too-many-instance-attributes
def run(self) -> None: def run(self) -> None:
aiotools.run(self.__inner_run()) aiotools.run(self.__inner_run())
get_logger().info(self.gettext("Bye-bye")) get_logger().info("Bye-bye")

View File

@@ -22,8 +22,6 @@
import dataclasses import dataclasses
from ...languages import Languages
from ...logging import get_logger from ...logging import get_logger
from ... import aiotools from ... import aiotools
@@ -32,7 +30,7 @@ from ... import aiotools
# ===== # =====
class VncAuthError(Exception): class VncAuthError(Exception):
def __init__(self, path: str, lineno: int, msg: str) -> None: def __init__(self, path: str, lineno: int, msg: str) -> None:
super().__init__(Languages().gettext(f"Syntax error at {path}:{lineno}: {msg}")) super().__init__(f"Syntax error at {path}:{lineno}: {msg}")
# ===== # =====
@@ -51,16 +49,15 @@ class VncAuthManager:
self.__path = path self.__path = path
self.__enabled = enabled self.__enabled = enabled
self.gettext=Languages().gettext
async def read_credentials(self) -> tuple[dict[str, VncAuthKvmdCredentials], bool]: async def read_credentials(self) -> tuple[dict[str, VncAuthKvmdCredentials], bool]:
if self.__enabled: if self.__enabled:
try: try:
return (await self.__inner_read_credentials(), True) return (await self.__inner_read_credentials(), True)
except VncAuthError as err: except VncAuthError as ex:
get_logger(0).error(str(err)) get_logger(0).error(str(ex))
except Exception: except Exception:
get_logger(0).exception(self.gettext("Unhandled exception while reading VNCAuth passwd file")) get_logger(0).exception("Unhandled exception while reading VNCAuth passwd file")
return ({}, (not self.__enabled)) return ({}, (not self.__enabled))
async def __inner_read_credentials(self) -> dict[str, VncAuthKvmdCredentials]: async def __inner_read_credentials(self) -> dict[str, VncAuthKvmdCredentials]:
@@ -71,19 +68,19 @@ class VncAuthManager:
continue continue
if " -> " not in line: if " -> " not in line:
raise VncAuthError(self.__path, lineno, self.gettext("Missing ' -> ' operator")) raise VncAuthError(self.__path, lineno, "Missing ' -> ' operator")
(vnc_passwd, kvmd_userpass) = map(str.lstrip, line.split(" -> ", 1)) (vnc_passwd, kvmd_userpass) = map(str.lstrip, line.split(" -> ", 1))
if ":" not in kvmd_userpass: if ":" not in kvmd_userpass:
raise VncAuthError(self.__path, lineno, self.gettext("Missing ':' operator in KVMD credentials (right part)")) raise VncAuthError(self.__path, lineno, "Missing ':' operator in KVMD credentials (right part)")
(kvmd_user, kvmd_passwd) = kvmd_userpass.split(":") (kvmd_user, kvmd_passwd) = kvmd_userpass.split(":")
kvmd_user = kvmd_user.strip() kvmd_user = kvmd_user.strip()
if len(kvmd_user) == 0: if len(kvmd_user) == 0:
raise VncAuthError(self.__path, lineno, self.gettext("Empty KVMD user (right part)")) raise VncAuthError(self.__path, lineno, "Empty KVMD user (right part)")
if vnc_passwd in credentials: if vnc_passwd in credentials:
raise VncAuthError(self.__path, lineno, self.gettext("Duplicating VNC password (left part)")) raise VncAuthError(self.__path, lineno, "Duplicating VNC password (left part)")
credentials[vnc_passwd] = VncAuthKvmdCredentials(kvmd_user, kvmd_passwd) credentials[vnc_passwd] = VncAuthKvmdCredentials(kvmd_user, kvmd_passwd)
return credentials return credentials

View File

@@ -56,8 +56,8 @@ def _write_int(rtc: int, key: str, value: int) -> None:
def _reset_alarm(rtc: int, timeout: int) -> None: def _reset_alarm(rtc: int, timeout: int) -> None:
try: try:
now = _read_int(rtc, "since_epoch") now = _read_int(rtc, "since_epoch")
except OSError as err: except OSError as ex:
if err.errno != errno.EINVAL: if ex.errno != errno.EINVAL:
raise raise
raise RtcIsNotAvailableError("Can't read since_epoch right now") raise RtcIsNotAvailableError("Can't read since_epoch right now")
if now == 0: if now == 0:
@@ -65,8 +65,8 @@ def _reset_alarm(rtc: int, timeout: int) -> None:
try: try:
for wake in [0, now + timeout]: for wake in [0, now + timeout]:
_write_int(rtc, "wakealarm", wake) _write_int(rtc, "wakealarm", wake)
except OSError as err: except OSError as ex:
if err.errno != errno.EIO: if ex.errno != errno.EIO:
raise raise
raise RtcIsNotAvailableError("IO error, probably the supercapacitor is not charged") raise RtcIsNotAvailableError("IO error, probably the supercapacitor is not charged")
@@ -80,9 +80,9 @@ def _cmd_run(config: Section) -> None:
while True: while True:
try: try:
_reset_alarm(config.rtc, config.timeout) _reset_alarm(config.rtc, config.timeout)
except RtcIsNotAvailableError as err: except RtcIsNotAvailableError as ex:
if not fail: if not fail:
logger.error("RTC%d is not available now: %s; waiting ...", config.rtc, err) logger.error("RTC%d is not available now: %s; waiting ...", config.rtc, ex)
fail = True fail = True
else: else:
if fail: if fail:

View File

@@ -18,3 +18,67 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # # along with this program. If not, see <https://www.gnu.org/licenses/>. #
# # # #
# ========================================================================== # # ========================================================================== #
import types
from typing import Callable
from typing import Self
import aiohttp
# =====
class BaseHttpClientSession:
def __init__(self, make_http_session: Callable[[], aiohttp.ClientSession]) -> None:
self._make_http_session = make_http_session
self.__http_session: (aiohttp.ClientSession | None) = None
def _ensure_http_session(self) -> aiohttp.ClientSession:
if not self.__http_session:
self.__http_session = self._make_http_session()
return self.__http_session
async def close(self) -> None:
if self.__http_session:
await self.__http_session.close()
self.__http_session = None
async def __aenter__(self) -> Self:
return self
async def __aexit__(
self,
_exc_type: type[BaseException],
_exc: BaseException,
_tb: types.TracebackType,
) -> None:
await self.close()
class BaseHttpClient:
def __init__(
self,
unix_path: str,
timeout: float,
user_agent: str,
) -> None:
self.__unix_path = unix_path
self.__timeout = timeout
self.__user_agent = user_agent
def make_session(self) -> BaseHttpClientSession:
raise NotImplementedError
def _make_http_session(self, headers: (dict[str, str] | None)=None) -> aiohttp.ClientSession:
return aiohttp.ClientSession(
base_url="http://localhost:0",
headers={
"User-Agent": self.__user_agent,
**(headers or {}),
},
connector=aiohttp.UnixConnector(path=self.__unix_path),
timeout=aiohttp.ClientTimeout(total=self.__timeout),
)

View File

@@ -23,7 +23,6 @@
import asyncio import asyncio
import contextlib import contextlib
import struct import struct
import types
from typing import Callable from typing import Callable
from typing import AsyncGenerator from typing import AsyncGenerator
@@ -34,22 +33,19 @@ from .. import aiotools
from .. import htclient from .. import htclient
from .. import htserver from .. import htserver
from . import BaseHttpClient
from . import BaseHttpClientSession
# ===== # =====
class _BaseApiPart: class _BaseApiPart:
def __init__( def __init__(self, ensure_http_session: Callable[[], aiohttp.ClientSession]) -> None:
self,
ensure_http_session: Callable[[], aiohttp.ClientSession],
make_url: Callable[[str], str],
) -> None:
self._ensure_http_session = ensure_http_session self._ensure_http_session = ensure_http_session
self._make_url = make_url
async def _set_params(self, handle: str, **params: (int | str | None)) -> None: async def _set_params(self, handle: str, **params: (int | str | None)) -> None:
session = self._ensure_http_session() session = self._ensure_http_session()
async with session.post( async with session.post(
url=self._make_url(handle), url=handle,
params={ params={
key: value key: value
for (key, value) in params.items() for (key, value) in params.items()
@@ -63,11 +59,11 @@ class _AuthApiPart(_BaseApiPart):
async def check(self) -> bool: async def check(self) -> bool:
session = self._ensure_http_session() session = self._ensure_http_session()
try: try:
async with session.get(self._make_url("auth/check")) as response: async with session.get("/auth/check") as response:
htclient.raise_not_200(response) htclient.raise_not_200(response)
return True return True
except aiohttp.ClientResponseError as err: except aiohttp.ClientResponseError as ex:
if err.status in [400, 401, 403]: if ex.status in [400, 401, 403]:
return False return False
raise raise
@@ -75,13 +71,13 @@ class _AuthApiPart(_BaseApiPart):
class _StreamerApiPart(_BaseApiPart): class _StreamerApiPart(_BaseApiPart):
async def get_state(self) -> dict: async def get_state(self) -> dict:
session = self._ensure_http_session() session = self._ensure_http_session()
async with session.get(self._make_url("streamer")) as response: async with session.get("/streamer") as response:
htclient.raise_not_200(response) htclient.raise_not_200(response)
return (await response.json())["result"] return (await response.json())["result"]
async def set_params(self, quality: (int | None)=None, desired_fps: (int | None)=None) -> None: async def set_params(self, quality: (int | None)=None, desired_fps: (int | None)=None) -> None:
await self._set_params( await self._set_params(
"streamer/set_params", "/streamer/set_params",
quality=quality, quality=quality,
desired_fps=desired_fps, desired_fps=desired_fps,
) )
@@ -90,7 +86,7 @@ class _StreamerApiPart(_BaseApiPart):
class _HidApiPart(_BaseApiPart): class _HidApiPart(_BaseApiPart):
async def get_keymaps(self) -> tuple[str, set[str]]: async def get_keymaps(self) -> tuple[str, set[str]]:
session = self._ensure_http_session() session = self._ensure_http_session()
async with session.get(self._make_url("hid/keymaps")) as response: async with session.get("/hid/keymaps") as response:
htclient.raise_not_200(response) htclient.raise_not_200(response)
result = (await response.json())["result"] result = (await response.json())["result"]
return (result["keymaps"]["default"], set(result["keymaps"]["available"])) return (result["keymaps"]["default"], set(result["keymaps"]["available"]))
@@ -98,7 +94,7 @@ class _HidApiPart(_BaseApiPart):
async def print(self, text: str, limit: int, keymap_name: str) -> None: async def print(self, text: str, limit: int, keymap_name: str) -> None:
session = self._ensure_http_session() session = self._ensure_http_session()
async with session.post( async with session.post(
url=self._make_url("hid/print"), url="/hid/print",
params={"limit": limit, "keymap": keymap_name}, params={"limit": limit, "keymap": keymap_name},
data=text, data=text,
) as response: ) as response:
@@ -106,7 +102,7 @@ class _HidApiPart(_BaseApiPart):
async def set_params(self, keyboard_output: (str | None)=None, mouse_output: (str | None)=None) -> None: async def set_params(self, keyboard_output: (str | None)=None, mouse_output: (str | None)=None) -> None:
await self._set_params( await self._set_params(
"hid/set_params", "/hid/set_params",
keyboard_output=keyboard_output, keyboard_output=keyboard_output,
mouse_output=mouse_output, mouse_output=mouse_output,
) )
@@ -115,7 +111,7 @@ class _HidApiPart(_BaseApiPart):
class _AtxApiPart(_BaseApiPart): class _AtxApiPart(_BaseApiPart):
async def get_state(self) -> dict: async def get_state(self) -> dict:
session = self._ensure_http_session() session = self._ensure_http_session()
async with session.get(self._make_url("atx")) as response: async with session.get("/atx") as response:
htclient.raise_not_200(response) htclient.raise_not_200(response)
return (await response.json())["result"] return (await response.json())["result"]
@@ -123,13 +119,13 @@ class _AtxApiPart(_BaseApiPart):
session = self._ensure_http_session() session = self._ensure_http_session()
try: try:
async with session.post( async with session.post(
url=self._make_url("atx/power"), url="/atx/power",
params={"action": action}, params={"action": action},
) as response: ) as response:
htclient.raise_not_200(response) htclient.raise_not_200(response)
return True return True
except aiohttp.ClientResponseError as err: except aiohttp.ClientResponseError as ex:
if err.status == 409: if ex.status == 409:
return False return False
raise raise
@@ -138,7 +134,6 @@ class _AtxApiPart(_BaseApiPart):
class KvmdClientWs: class KvmdClientWs:
def __init__(self, ws: aiohttp.ClientWebSocketResponse) -> None: def __init__(self, ws: aiohttp.ClientWebSocketResponse) -> None:
self.__ws = ws self.__ws = ws
self.__writer_queue: "asyncio.Queue[tuple[str, dict] | bytes]" = asyncio.Queue() self.__writer_queue: "asyncio.Queue[tuple[str, dict] | bytes]" = asyncio.Queue()
self.__communicated = False self.__communicated = False
@@ -200,84 +195,25 @@ class KvmdClientWs:
await self.__writer_queue.put(struct.pack(">bbbb", 5, 0, delta_x, delta_y)) await self.__writer_queue.put(struct.pack(">bbbb", 5, 0, delta_x, delta_y))
class KvmdClientSession: class KvmdClientSession(BaseHttpClientSession):
def __init__( def __init__(self, make_http_session: Callable[[], aiohttp.ClientSession]) -> None:
self, super().__init__(make_http_session)
make_http_session: Callable[[], aiohttp.ClientSession], self.auth = _AuthApiPart(self._ensure_http_session)
make_url: Callable[[str], str], self.streamer = _StreamerApiPart(self._ensure_http_session)
) -> None: self.hid = _HidApiPart(self._ensure_http_session)
self.atx = _AtxApiPart(self._ensure_http_session)
self.__make_http_session = make_http_session
self.__make_url = make_url
self.__http_session: (aiohttp.ClientSession | None) = None
args = (self.__ensure_http_session, make_url)
self.auth = _AuthApiPart(*args)
self.streamer = _StreamerApiPart(*args)
self.hid = _HidApiPart(*args)
self.atx = _AtxApiPart(*args)
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def ws(self) -> AsyncGenerator[KvmdClientWs, None]: async def ws(self) -> AsyncGenerator[KvmdClientWs, None]:
session = self.__ensure_http_session() session = self._ensure_http_session()
async with session.ws_connect(self.__make_url("ws")) as ws: async with session.ws_connect("/ws", params={"legacy": "0"}) as ws:
yield KvmdClientWs(ws) yield KvmdClientWs(ws)
def __ensure_http_session(self) -> aiohttp.ClientSession:
if not self.__http_session:
self.__http_session = self.__make_http_session()
return self.__http_session
async def close(self) -> None: class KvmdClient(BaseHttpClient):
if self.__http_session: def make_session(self, user: str="", passwd: str="") -> KvmdClientSession:
await self.__http_session.close() headers = {
self.__http_session = None "X-KVMD-User": user,
"X-KVMD-Passwd": passwd,
async def __aenter__(self) -> "KvmdClientSession":
return self
async def __aexit__(
self,
_exc_type: type[BaseException],
_exc: BaseException,
_tb: types.TracebackType,
) -> None:
await self.close()
class KvmdClient:
def __init__(
self,
unix_path: str,
timeout: float,
user_agent: str,
) -> None:
self.__unix_path = unix_path
self.__timeout = timeout
self.__user_agent = user_agent
def make_session(self, user: str, passwd: str) -> KvmdClientSession:
return KvmdClientSession(
make_http_session=(lambda: self.__make_http_session(user, passwd)),
make_url=self.__make_url,
)
def __make_http_session(self, user: str, passwd: str) -> aiohttp.ClientSession:
kwargs: dict = {
"headers": {
"X-KVMD-User": user,
"X-KVMD-Passwd": passwd,
"User-Agent": self.__user_agent,
},
"connector": aiohttp.UnixConnector(path=self.__unix_path),
"timeout": aiohttp.ClientTimeout(total=self.__timeout),
} }
return aiohttp.ClientSession(**kwargs) return KvmdClientSession(lambda: self._make_http_session(headers))
def __make_url(self, handle: str) -> str:
assert not handle.startswith("/"), handle
return f"http://localhost:0/{handle}"

View File

@@ -20,7 +20,10 @@
# ========================================================================== # # ========================================================================== #
import io
import contextlib import contextlib
import dataclasses
import functools
import types import types
from typing import Callable from typing import Callable
@@ -31,10 +34,15 @@ from typing import AsyncGenerator
import aiohttp import aiohttp
import ustreamer import ustreamer
from PIL import Image as PilImage
from .. import tools from .. import tools
from .. import aiotools from .. import aiotools
from .. import htclient from .. import htclient
from . import BaseHttpClient
from . import BaseHttpClientSession
# ===== # =====
class StreamerError(Exception): class StreamerError(Exception):
@@ -50,7 +58,7 @@ class StreamerPermError(StreamerError):
# ===== # =====
class StreamFormats: class StreamerFormats:
JPEG = 1195724874 # V4L2_PIX_FMT_JPEG JPEG = 1195724874 # V4L2_PIX_FMT_JPEG
H264 = 875967048 # V4L2_PIX_FMT_H264 H264 = 875967048 # V4L2_PIX_FMT_H264
_MJPEG = 1196444237 # V4L2_PIX_FMT_MJPEG _MJPEG = 1196444237 # V4L2_PIX_FMT_MJPEG
@@ -68,17 +76,85 @@ class BaseStreamerClient:
# ===== # =====
@dataclasses.dataclass(frozen=True)
class StreamerSnapshot:
online: bool
width: int
height: int
headers: tuple[tuple[str, str], ...]
data: bytes
async def make_preview(self, max_width: int, max_height: int, quality: int) -> bytes:
assert max_width >= 0
assert max_height >= 0
assert quality > 0
if max_width == 0 and max_height == 0:
max_width = self.width // 5
max_height = self.height // 5
else:
max_width = min((max_width or self.width), self.width)
max_height = min((max_height or self.height), self.height)
if (max_width, max_height) == (self.width, self.height):
return self.data
return (await aiotools.run_async(self.__inner_make_preview, max_width, max_height, quality))
@functools.lru_cache(maxsize=1)
def __inner_make_preview(self, max_width: int, max_height: int, quality: int) -> bytes:
with io.BytesIO(self.data) as snapshot_bio:
with io.BytesIO() as preview_bio:
with PilImage.open(snapshot_bio) as image:
image.thumbnail((max_width, max_height), PilImage.Resampling.LANCZOS)
image.save(preview_bio, format="jpeg", quality=quality)
return preview_bio.getvalue()
class HttpStreamerClientSession(BaseHttpClientSession):
async def get_state(self) -> dict:
session = self._ensure_http_session()
async with session.get("/state") as response:
htclient.raise_not_200(response)
return (await response.json())["result"]
async def take_snapshot(self, timeout: float) -> StreamerSnapshot:
session = self._ensure_http_session()
async with session.get(
url="/snapshot",
timeout=aiohttp.ClientTimeout(total=timeout),
) as response:
htclient.raise_not_200(response)
return StreamerSnapshot(
online=(response.headers["X-UStreamer-Online"] == "true"),
width=int(response.headers["X-UStreamer-Width"]),
height=int(response.headers["X-UStreamer-Height"]),
headers=tuple(
(key, value)
for (key, value) in tools.sorted_kvs(dict(response.headers))
if key.lower().startswith("x-ustreamer-") or key.lower() in [
"x-timestamp",
"access-control-allow-origin",
"cache-control",
"pragma",
"expires",
]
),
data=bytes(await response.read()),
)
@contextlib.contextmanager @contextlib.contextmanager
def _http_handle_errors() -> Generator[None, None, None]: def _http_reading_handle_errors() -> Generator[None, None, None]:
try: try:
yield yield
except Exception as err: # Тут бывают и ассерты, и KeyError, и прочая херня except Exception as ex: # Тут бывают и ассерты, и KeyError, и прочая херня
if isinstance(err, StreamerTempError): if isinstance(ex, StreamerTempError):
raise raise
raise StreamerTempError(tools.efmt(err)) raise StreamerTempError(tools.efmt(ex))
class HttpStreamerClient(BaseStreamerClient): class HttpStreamerClient(BaseHttpClient, BaseStreamerClient):
def __init__( def __init__(
self, self,
name: str, name: str,
@@ -87,29 +163,35 @@ class HttpStreamerClient(BaseStreamerClient):
user_agent: str, user_agent: str,
) -> None: ) -> None:
super().__init__(unix_path, timeout, user_agent)
self.__name = name self.__name = name
self.__unix_path = unix_path
self.__timeout = timeout def make_session(self) -> HttpStreamerClientSession:
self.__user_agent = user_agent return HttpStreamerClientSession(self._make_http_session)
def get_format(self) -> int: def get_format(self) -> int:
return StreamFormats.JPEG return StreamerFormats.JPEG
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def reading(self) -> AsyncGenerator[Callable[[bool], Awaitable[dict]], None]: async def reading(self) -> AsyncGenerator[Callable[[bool], Awaitable[dict]], None]:
with _http_handle_errors(): with _http_reading_handle_errors():
async with self.__make_http_session() as session: async with self._make_http_session() as session:
async with session.get( async with session.get(
url=self.__make_url("stream"), url="/stream",
params={"extra_headers": "1"}, params={"extra_headers": "1"},
timeout=aiohttp.ClientTimeout(
connect=session.timeout.total,
sock_read=session.timeout.total,
),
) as response: ) as response:
htclient.raise_not_200(response) htclient.raise_not_200(response)
reader = aiohttp.MultipartReader.from_response(response) reader = aiohttp.MultipartReader.from_response(response)
self.__patch_stream_reader(reader.resp.content) self.__patch_stream_reader(reader.resp.content)
async def read_frame(key_required: bool) -> dict: async def read_frame(key_required: bool) -> dict:
_ = key_required _ = key_required
with _http_handle_errors(): with _http_reading_handle_errors():
frame = await reader.next() # pylint: disable=not-callable frame = await reader.next() # pylint: disable=not-callable
if not isinstance(frame, aiohttp.BodyPartReader): if not isinstance(frame, aiohttp.BodyPartReader):
raise StreamerTempError("Expected body part") raise StreamerTempError("Expected body part")
@@ -123,26 +205,11 @@ class HttpStreamerClient(BaseStreamerClient):
"width": int(frame.headers["X-UStreamer-Width"]), "width": int(frame.headers["X-UStreamer-Width"]),
"height": int(frame.headers["X-UStreamer-Height"]), "height": int(frame.headers["X-UStreamer-Height"]),
"data": data, "data": data,
"format": StreamFormats.JPEG, "format": StreamerFormats.JPEG,
} }
yield read_frame yield read_frame
def __make_http_session(self) -> aiohttp.ClientSession:
kwargs: dict = {
"headers": {"User-Agent": self.__user_agent},
"connector": aiohttp.UnixConnector(path=self.__unix_path),
"timeout": aiohttp.ClientTimeout(
connect=self.__timeout,
sock_read=self.__timeout,
),
}
return aiohttp.ClientSession(**kwargs)
def __make_url(self, handle: str) -> str:
assert not handle.startswith("/"), handle
return f"http://localhost:0/{handle}"
def __patch_stream_reader(self, reader: aiohttp.StreamReader) -> None: def __patch_stream_reader(self, reader: aiohttp.StreamReader) -> None:
# https://github.com/pikvm/pikvm/issues/92 # https://github.com/pikvm/pikvm/issues/92
# Infinite looping in BodyPartReader.read() because _at_eof flag. # Infinite looping in BodyPartReader.read() because _at_eof flag.
@@ -162,15 +229,15 @@ class HttpStreamerClient(BaseStreamerClient):
# ===== # =====
@contextlib.contextmanager @contextlib.contextmanager
def _memsink_handle_errors() -> Generator[None, None, None]: def _memsink_reading_handle_errors() -> Generator[None, None, None]:
try: try:
yield yield
except StreamerPermError: except StreamerPermError:
raise raise
except FileNotFoundError as err: except FileNotFoundError as ex:
raise StreamerTempError(tools.efmt(err)) raise StreamerTempError(tools.efmt(ex))
except Exception as err: except Exception as ex:
raise StreamerPermError(tools.efmt(err)) raise StreamerPermError(tools.efmt(ex))
class MemsinkStreamerClient(BaseStreamerClient): class MemsinkStreamerClient(BaseStreamerClient):
@@ -198,11 +265,11 @@ class MemsinkStreamerClient(BaseStreamerClient):
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def reading(self) -> AsyncGenerator[Callable[[bool], Awaitable[dict]], None]: async def reading(self) -> AsyncGenerator[Callable[[bool], Awaitable[dict]], None]:
with _memsink_handle_errors(): with _memsink_reading_handle_errors():
with ustreamer.Memsink(**self.__kwargs) as sink: with ustreamer.Memsink(**self.__kwargs) as sink:
async def read_frame(key_required: bool) -> dict: async def read_frame(key_required: bool) -> dict:
key_required = (key_required and self.__fmt == StreamFormats.H264) key_required = (key_required and self.__fmt == StreamerFormats.H264)
with _memsink_handle_errors(): with _memsink_reading_handle_errors():
while True: while True:
frame = await aiotools.run_async(sink.wait_frame, key_required) frame = await aiotools.run_async(sink.wait_frame, key_required)
if frame is not None: if frame is not None:
@@ -211,8 +278,8 @@ class MemsinkStreamerClient(BaseStreamerClient):
yield read_frame yield read_frame
def __check_format(self, fmt: int) -> None: def __check_format(self, fmt: int) -> None:
if fmt == StreamFormats._MJPEG: # pylint: disable=protected-access if fmt == StreamerFormats._MJPEG: # pylint: disable=protected-access
fmt = StreamFormats.JPEG fmt = StreamerFormats.JPEG
if fmt != self.__fmt: if fmt != self.__fmt:
raise StreamerPermError("Invalid sink format") raise StreamerPermError("Invalid sink format")

269
kvmd/edid.py Normal file
View File

@@ -0,0 +1,269 @@
# ========================================================================== #
# #
# 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 re
import dataclasses
import contextlib
from typing import IO
from typing import Generator
# =====
class EdidNoBlockError(Exception):
pass
@contextlib.contextmanager
def _smart_open(path: str, mode: str) -> Generator[IO, None, None]:
fd = (0 if "r" in mode else 1)
with (os.fdopen(fd, mode, closefd=False) if path == "-" else open(path, mode)) as file:
yield file
if "w" in mode:
file.flush()
@dataclasses.dataclass(frozen=True)
class _CeaBlock:
tag: int
data: bytes
def __post_init__(self) -> None:
assert 0 < self.tag <= 0b111
assert 0 < len(self.data) <= 0b11111
@property
def size(self) -> int:
return len(self.data) + 1
def pack(self) -> bytes:
header = (self.tag << 5) | len(self.data)
return header.to_bytes() + self.data
@classmethod
def first_from_raw(cls, raw: (bytes | list[int])) -> "_CeaBlock":
assert 0 < raw[0] <= 0xFF
tag = (raw[0] & 0b11100000) >> 5
data_size = (raw[0] & 0b00011111)
data = bytes(raw[1:data_size + 1])
return _CeaBlock(tag, data)
_CEA = 128
_CEA_AUDIO = 1
_CEA_SPEAKERS = 4
class Edid:
# https://en.wikipedia.org/wiki/Extended_Display_Identification_Data
def __init__(self, data: bytes) -> None:
assert len(data) == 256
self.__data = list(data)
@classmethod
def from_file(cls, path: str) -> "Edid":
with _smart_open(path, "rb") as file:
data = file.read()
if not data.startswith(b"\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00"):
text = re.sub(r"\s", "", data.decode())
data = bytes([
int(text[index:index + 2], 16)
for index in range(0, len(text), 2)
])
assert len(data) == 256, f"Invalid EDID length: {len(data)}, should be 256 bytes"
assert data[126] == 1, "Zero extensions number"
assert (data[_CEA + 0], data[_CEA + 1]) == (0x02, 0x03), "Can't find CEA extension"
return Edid(data)
def write_hex(self, path: str) -> None:
self.__update_checksums()
text = "\n".join(
"".join(
f"{item:0{2}X}"
for item in self.__data[index:index + 16]
)
for index in range(0, len(self.__data), 16)
) + "\n"
with _smart_open(path, "w") as file:
file.write(text)
def write_bin(self, path: str) -> None:
self.__update_checksums()
with _smart_open(path, "wb") as file:
file.write(bytes(self.__data))
def __update_checksums(self) -> None:
self.__data[127] = 256 - (sum(self.__data[:127]) % 256)
self.__data[255] = 256 - (sum(self.__data[128:255]) % 256)
# =====
def get_mfc_id(self) -> str:
raw = self.__data[8] << 8 | self.__data[9]
return bytes([
((raw >> 10) & 0b11111) + 0x40,
((raw >> 5) & 0b11111) + 0x40,
(raw & 0b11111) + 0x40,
]).decode("ascii")
def set_mfc_id(self, mfc_id: str) -> None:
assert len(mfc_id) == 3, "Mfc ID must be 3 characters long"
data = mfc_id.upper().encode("ascii")
for ch in data:
assert 0x41 <= ch <= 0x5A, "Mfc ID must contain only A-Z characters"
raw = (
(data[2] - 0x40)
| ((data[1] - 0x40) << 5)
| ((data[0] - 0x40) << 10)
)
self.__data[8] = (raw >> 8) & 0xFF
self.__data[9] = raw & 0xFF
# =====
def get_product_id(self) -> int:
return (self.__data[10] | self.__data[11] << 8)
def set_product_id(self, product_id: int) -> None:
assert 0 <= product_id <= 0xFFFF, f"Product ID should be from 0 to {0xFFFF}"
self.__data[10] = product_id & 0xFF
self.__data[11] = (product_id >> 8) & 0xFF
# =====
def get_serial(self) -> int:
return (
self.__data[12]
| self.__data[13] << 8
| self.__data[14] << 16
| self.__data[15] << 24
)
def set_serial(self, serial: int) -> None:
assert 0 <= serial <= 0xFFFFFFFF, f"Serial should be from 0 to {0xFFFFFFFF}"
self.__data[12] = serial & 0xFF
self.__data[13] = (serial >> 8) & 0xFF
self.__data[14] = (serial >> 16) & 0xFF
self.__data[15] = (serial >> 24) & 0xFF
# =====
def get_monitor_name(self) -> str:
return self.__get_dtd_text(0xFC, "Monitor Name")
def set_monitor_name(self, text: str) -> None:
self.__set_dtd_text(0xFC, "Monitor Name", text)
def get_monitor_serial(self) -> str:
return self.__get_dtd_text(0xFF, "Monitor Serial")
def set_monitor_serial(self, text: str) -> None:
self.__set_dtd_text(0xFF, "Monitor Serial", text)
def __get_dtd_text(self, d_type: int, name: str) -> str:
index = self.__find_dtd_text(d_type, name)
return bytes(self.__data[index:index + 13]).decode("cp437").strip()
def __set_dtd_text(self, d_type: int, name: str, text: str) -> None:
index = self.__find_dtd_text(d_type, name)
encoded = (text[:13] + "\n" + " " * 12)[:13].encode("cp437")
for (offset, ch) in enumerate(encoded):
self.__data[index + offset] = ch
def __find_dtd_text(self, d_type: int, name: str) -> int:
for index in [54, 72, 90, 108]:
if self.__data[index + 3] == d_type:
return index + 5
raise EdidNoBlockError(f"Can't find DTD {name}")
# ===== CEA =====
def get_audio(self) -> bool:
(cbs, _) = self.__parse_cea()
audio = False
speakers = False
for cb in cbs:
if cb.tag == _CEA_AUDIO:
audio = True
elif cb.tag == _CEA_SPEAKERS:
speakers = True
return (audio and speakers and self.__get_basic_audio())
def set_audio(self, enabled: bool) -> None:
(cbs, dtds) = self.__parse_cea()
cbs = [cb for cb in cbs if cb.tag not in [_CEA_AUDIO, _CEA_SPEAKERS]]
if enabled:
cbs.append(_CeaBlock(_CEA_AUDIO, b"\x09\x7f\x07"))
cbs.append(_CeaBlock(_CEA_SPEAKERS, b"\x01\x00\x00"))
self.__replace_cea(cbs, dtds)
self.__set_basic_audio(enabled)
def __get_basic_audio(self) -> bool:
return bool(self.__data[_CEA + 3] & 0b01000000)
def __set_basic_audio(self, enabled: bool) -> None:
if enabled:
self.__data[_CEA + 3] |= 0b01000000
else:
self.__data[_CEA + 3] &= (0xFF - 0b01000000) # ~X
def __parse_cea(self) -> tuple[list[_CeaBlock], bytes]:
cea = self.__data[_CEA:]
dtd_begin = cea[2]
if dtd_begin == 0:
return ([], b"")
cbs: list[_CeaBlock] = []
if dtd_begin > 4:
raw = cea[4:dtd_begin]
while len(raw) != 0:
cb = _CeaBlock.first_from_raw(raw)
cbs.append(cb)
raw = raw[cb.size:]
dtds = b""
assert dtd_begin >= 4
raw = cea[dtd_begin:]
while len(raw) > (18 + 1) and raw[0] != 0:
dtds += bytes(raw[:18])
raw = raw[18:]
return (cbs, dtds)
def __replace_cea(self, cbs: list[_CeaBlock], dtds: bytes) -> None:
cbs_packed = b""
for cb in cbs:
cbs_packed += cb.pack()
raw = cbs_packed + dtds
assert len(raw) <= (128 - 4 - 1), "Too many CEA blocks or DTDs"
self.__data[_CEA + 2] = (0 if len(raw) == 0 else (len(cbs_packed) + 4))
for index in range(4, 127):
try:
ch = raw[index - 4]
except IndexError:
ch = 0
self.__data[_CEA + index] = ch

View File

@@ -33,11 +33,12 @@ class Partition:
mount_path: str mount_path: str
root_path: str root_path: str
user: str user: str
group: str
# ===== # =====
def find_msd() -> Partition: def find_msd(msd_directory_path) -> Partition:
return _find_single("otgmsd") return _find_single("otgmsd", msd_directory_path)
def find_pst() -> Partition: def find_pst() -> Partition:
@@ -45,12 +46,12 @@ def find_pst() -> Partition:
# ===== # =====
def _find_single(part_type: str) -> Partition: def _find_single(part_type: str, msd_directory_path: str) -> Partition:
parts = _find_partitions(part_type, True) parts = _find_partitions(part_type, True)
if len(parts) == 0: if len(parts) == 0:
if os.path.exists('/var/lib/kvmd/msd'): if os.path.exists(msd_directory_path):
#set default value #set default value
parts = [Partition(mount_path='/var/lib/kvmd/msd', root_path='/var/lib/kvmd/msd', user='kvmd')] parts = [Partition(mount_path = msd_directory_path, root_path = msd_directory_path, group = 'kvmd', user = 'kvmd')]
else: else:
raise RuntimeError(f"Can't find {part_type!r} mountpoint") raise RuntimeError(f"Can't find {part_type!r} mountpoint")
return parts[0] return parts[0]
@@ -64,12 +65,13 @@ def _find_partitions(part_type: str, single: bool) -> list[Partition]:
if line and not line.startswith("#"): if line and not line.startswith("#"):
fields = line.split() fields = line.split()
if len(fields) == 6: if len(fields) == 6:
options = dict(re.findall(r"X-kvmd\.%s-(root|user)(?:=([^,]+))?" % (part_type), fields[3])) options = dict(re.findall(r"X-kvmd\.%s-(root|user|group)(?:=([^,]+))?" % (part_type), fields[3]))
if options: if options:
parts.append(Partition( parts.append(Partition(
mount_path=os.path.normpath(fields[1]), mount_path=os.path.normpath(fields[1]),
root_path=os.path.normpath(options.get("root", "") or fields[1]), root_path=os.path.normpath(options.get("root", "") or fields[1]),
user=options.get("user", ""), user=options.get("user", ""),
group=options.get("group", ""),
)) ))
if single: if single:
break break

View File

@@ -22,7 +22,9 @@
import sys import sys
import os import os
import stat
import pwd import pwd
import grp
import shutil import shutil
import subprocess import subprocess
@@ -44,8 +46,8 @@ def _remount(path: str, rw: bool) -> None:
_log(f"Remounting {path} to {mode.upper()}-mode ...") _log(f"Remounting {path} to {mode.upper()}-mode ...")
try: try:
subprocess.check_call(["/bin/mount", "--options", f"remount,{mode}", path]) subprocess.check_call(["/bin/mount", "--options", f"remount,{mode}", path])
except subprocess.CalledProcessError as err: except subprocess.CalledProcessError as ex:
raise SystemExit(f"Can't remount: {err}") raise SystemExit(f"Can't remount: {ex}")
def _mkdir(path: str) -> None: def _mkdir(path: str) -> None:
@@ -53,8 +55,8 @@ def _mkdir(path: str) -> None:
_log(f"MKDIR --- {path}") _log(f"MKDIR --- {path}")
try: try:
os.mkdir(path) os.mkdir(path)
except Exception as err: except Exception as ex:
raise SystemExit(f"Can't create directory: {err}") raise SystemExit(f"Can't create directory: {ex}")
def _rmtree(path: str) -> None: def _rmtree(path: str) -> None:
@@ -62,8 +64,8 @@ def _rmtree(path: str) -> None:
_log(f"RMALL --- {path}") _log(f"RMALL --- {path}")
try: try:
shutil.rmtree(path) shutil.rmtree(path)
except Exception as err: except Exception as ex:
raise SystemExit(f"Can't remove directory: {err}") raise SystemExit(f"Can't remove directory: {ex}")
def _rm(path: str) -> None: def _rm(path: str) -> None:
@@ -71,25 +73,43 @@ def _rm(path: str) -> None:
_log(f"RM --- {path}") _log(f"RM --- {path}")
try: try:
os.remove(path) os.remove(path)
except Exception as err: except Exception as ex:
raise SystemExit(f"Can't remove file: {err}") raise SystemExit(f"Can't remove file: {ex}")
def _move(src: str, dest: str) -> None: def _move(src: str, dest: str) -> None:
_log(f"MOVE --- {src} --> {dest}") _log(f"MOVE --- {src} --> {dest}")
try: try:
os.rename(src, dest) os.rename(src, dest)
except Exception as err: except Exception as ex:
raise SystemExit(f"Can't move file: {err}") raise SystemExit(f"Can't move file: {ex}")
def _chown(path: str, user: str) -> None: def _chown(path: str, user: str) -> None:
if pwd.getpwuid(os.stat(path).st_uid).pw_name != user: if pwd.getpwuid(os.stat(path).st_uid).pw_name != user:
_log(f"CHOWN --- {user} - {path}") _log(f"CHOWN --- {user} - {path}")
try: try:
shutil.chown(path, user) shutil.chown(path, user=user)
except Exception as err: except Exception as ex:
raise SystemExit(f"Can't change ownership: {err}") raise SystemExit(f"Can't change ownership: {ex}")
def _chgrp(path: str, group: str) -> None:
if grp.getgrgid(os.stat(path).st_gid).gr_name != group:
_log(f"CHGRP --- {group} - {path}")
try:
shutil.chown(path, group=group)
except Exception as ex:
raise SystemExit(f"Can't change group: {ex}")
def _chmod(path: str, mode: int) -> None:
if stat.S_IMODE(os.stat(path).st_mode) != mode:
_log(f"CHMOD --- 0o{mode:o} - {path}")
try:
os.chmod(path, mode)
except Exception as ex:
raise SystemExit(f"Can't change permissions: {ex}")
# ===== # =====
@@ -112,13 +132,21 @@ def _fix_msd(part: Partition) -> None:
if part.user: if part.user:
_chown(part.root_path, part.user) _chown(part.root_path, part.user)
if part.group:
_chgrp(part.root_path, part.group)
def _fix_pst(part: Partition) -> None: def _fix_pst(part: Partition) -> None:
path = os.path.join(part.root_path, "data") path = os.path.join(part.root_path, "data")
_mkdir(path) _mkdir(path)
if part.user: if part.user:
_chown(part.root_path, part.user)
_chown(path, part.user) _chown(path, part.user)
if part.group:
_chgrp(part.root_path, part.group)
_chgrp(path, part.group)
if part.user and part.group:
_chmod(part.root_path, 0o1775)
# ===== # =====

View File

@@ -28,8 +28,6 @@ from typing import AsyncGenerator
import aiohttp import aiohttp
import aiohttp.multipart import aiohttp.multipart
from .languages import Languages
from . import __version__ from . import __version__
@@ -38,29 +36,29 @@ def make_user_agent(app: str) -> str:
return f"{app}/{__version__}" return f"{app}/{__version__}"
def raise_not_200(response: aiohttp.ClientResponse) -> None: def raise_not_200(resp: aiohttp.ClientResponse) -> None:
if response.status != 200: if resp.status != 200:
assert response.reason is not None assert resp.reason is not None
response.release() resp.release()
raise aiohttp.ClientResponseError( raise aiohttp.ClientResponseError(
response.request_info, resp.request_info,
response.history, resp.history,
status=response.status, status=resp.status,
message=response.reason, message=resp.reason,
headers=response.headers, headers=resp.headers,
) )
def get_filename(response: aiohttp.ClientResponse) -> str: def get_filename(resp: aiohttp.ClientResponse) -> str:
try: try:
disp = response.headers["Content-Disposition"] disp = resp.headers["Content-Disposition"]
parsed = aiohttp.multipart.parse_content_disposition(disp) parsed = aiohttp.multipart.parse_content_disposition(disp)
return str(parsed[1]["filename"]) return str(parsed[1]["filename"])
except Exception: except Exception:
try: try:
return os.path.basename(response.url.path) return os.path.basename(resp.url.path)
except Exception: except Exception:
raise aiohttp.ClientError(Languages().gettext("Can't determine filename")) raise aiohttp.ClientError("Can't determine filename")
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
@@ -81,6 +79,6 @@ async def download(
), ),
} }
async with aiohttp.ClientSession(**kwargs) as session: async with aiohttp.ClientSession(**kwargs) as session:
async with session.get(url, verify_ssl=verify) as response: async with session.get(url, verify_ssl=verify) as resp: # type: ignore
raise_not_200(response) raise_not_200(resp)
yield response yield resp

View File

@@ -52,8 +52,6 @@ from .errors import IsBusyError
from .validators import ValidatorError from .validators import ValidatorError
from .languages import Languages
from . import aiotools from . import aiotools
@@ -159,7 +157,7 @@ def make_json_response(
wrap_result: bool=True, wrap_result: bool=True,
) -> Response: ) -> Response:
response = Response( resp = Response(
text=json.dumps(({ text=json.dumps(({
"ok": (status == 200), "ok": (status == 200),
"result": (result or {}), "result": (result or {}),
@@ -169,18 +167,18 @@ def make_json_response(
) )
if set_cookies: if set_cookies:
for (key, value) in set_cookies.items(): for (key, value) in set_cookies.items():
response.set_cookie(key, value, httponly=True, samesite="Strict") resp.set_cookie(key, value, httponly=True, samesite="Strict")
return response return resp
def make_json_exception(err: Exception, status: (int | None)=None) -> Response: def make_json_exception(ex: Exception, status: (int | None)=None) -> Response:
name = type(err).__name__ name = type(ex).__name__
msg = str(err) msg = str(ex)
if isinstance(err, HttpError): if isinstance(ex, HttpError):
status = err.status status = ex.status
else: else:
get_logger().error("API error: %s: %s", name, msg) get_logger().error("API error: %s: %s", name, msg)
assert status is not None, err assert status is not None, ex
return make_json_response({ return make_json_response({
"error": name, "error": name,
"error_msg": msg, "error_msg": msg,
@@ -188,35 +186,35 @@ def make_json_exception(err: Exception, status: (int | None)=None) -> Response:
async def start_streaming( async def start_streaming(
request: Request, req: Request,
content_type: str, content_type: str,
content_length: int=-1, content_length: int=-1,
file_name: str="", file_name: str="",
) -> StreamResponse: ) -> StreamResponse:
response = StreamResponse(status=200, reason="OK") resp = StreamResponse(status=200, reason="OK")
response.content_type = content_type resp.content_type = content_type
if content_length >= 0: # pylint: disable=consider-using-min-builtin if content_length >= 0: # pylint: disable=consider-using-min-builtin
response.content_length = content_length resp.content_length = content_length
if file_name: if file_name:
file_name = urllib.parse.quote(file_name, safe="") file_name = urllib.parse.quote(file_name, safe="")
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{file_name}" resp.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{file_name}"
await response.prepare(request) await resp.prepare(req)
return response return resp
async def stream_json(response: StreamResponse, result: dict, ok: bool=True) -> None: async def stream_json(resp: StreamResponse, result: dict, ok: bool=True) -> None:
await response.write(json.dumps({ await resp.write(json.dumps({
"ok": ok, "ok": ok,
"result": result, "result": result,
}).encode("utf-8") + b"\r\n") }).encode("utf-8") + b"\r\n")
async def stream_json_exception(response: StreamResponse, err: Exception) -> None: async def stream_json_exception(resp: StreamResponse, ex: Exception) -> None:
name = type(err).__name__ name = type(ex).__name__
msg = str(err) msg = str(ex)
get_logger().error("API error: %s: %s", name, msg) get_logger().error("API error: %s: %s", name, msg)
await stream_json(response, { await stream_json(resp, {
"error": name, "error": name,
"error_msg": msg, "error_msg": msg,
}, False) }, False)
@@ -251,15 +249,15 @@ def parse_ws_event(msg: str) -> tuple[str, dict]:
_REQUEST_AUTH_INFO = "_kvmd_auth_info" _REQUEST_AUTH_INFO = "_kvmd_auth_info"
def _format_P(request: BaseRequest, *_, **__) -> str: # type: ignore # pylint: disable=invalid-name def _format_P(req: BaseRequest, *_, **__) -> str: # type: ignore # pylint: disable=invalid-name
return (getattr(request, _REQUEST_AUTH_INFO, None) or "-") return (getattr(req, _REQUEST_AUTH_INFO, None) or "-")
AccessLogger._format_P = staticmethod(_format_P) # type: ignore # pylint: disable=protected-access AccessLogger._format_P = staticmethod(_format_P) # type: ignore # pylint: disable=protected-access
def set_request_auth_info(request: BaseRequest, info: str) -> None: def set_request_auth_info(req: BaseRequest, info: str) -> None:
setattr(request, _REQUEST_AUTH_INFO, info) setattr(req, _REQUEST_AUTH_INFO, info)
# ===== # =====
@@ -282,7 +280,6 @@ class HttpServer:
self.__ws_bin_handlers: dict[int, Callable] = {} self.__ws_bin_handlers: dict[int, Callable] = {}
self.__ws_sessions: list[WsSession] = [] self.__ws_sessions: list[WsSession] = []
self.__ws_sessions_lock = asyncio.Lock() self.__ws_sessions_lock = asyncio.Lock()
self.gettext=Languages().gettext
def run( def run(
self, self,
@@ -321,16 +318,16 @@ class HttpServer:
self.__add_exposed_ws(ws_exposed) self.__add_exposed_ws(ws_exposed)
def __add_exposed_http(self, exposed: HttpExposed) -> None: def __add_exposed_http(self, exposed: HttpExposed) -> None:
async def wrapper(request: Request) -> Response: async def wrapper(req: Request) -> Response:
try: try:
await self._check_request_auth(exposed, request) await self._check_request_auth(exposed, req)
return (await exposed.handler(request)) return (await exposed.handler(req))
except IsBusyError as err: except IsBusyError as ex:
return make_json_exception(err, 409) return make_json_exception(ex, 409)
except (ValidatorError, OperationError) as err: except (ValidatorError, OperationError) as ex:
return make_json_exception(err, 400) return make_json_exception(ex, 400)
except HttpError as err: except HttpError as ex:
return make_json_exception(err) return make_json_exception(ex)
self.__app.router.add_route(exposed.method, exposed.path, wrapper) self.__app.router.add_route(exposed.method, exposed.path, wrapper)
def __add_exposed_ws(self, exposed: WsExposed) -> None: def __add_exposed_ws(self, exposed: WsExposed) -> None:
@@ -345,15 +342,15 @@ class HttpServer:
# ===== # =====
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def _ws_session(self, request: Request, **kwargs: Any) -> AsyncGenerator[WsSession, None]: async def _ws_session(self, req: Request, **kwargs: Any) -> AsyncGenerator[WsSession, None]:
assert self.__ws_heartbeat is not None assert self.__ws_heartbeat is not None
wsr = WebSocketResponse(heartbeat=self.__ws_heartbeat) wsr = WebSocketResponse(heartbeat=self.__ws_heartbeat)
await wsr.prepare(request) await wsr.prepare(req)
ws = WsSession(wsr, kwargs) ws = WsSession(wsr, kwargs)
async with self.__ws_sessions_lock: async with self.__ws_sessions_lock:
self.__ws_sessions.append(ws) self.__ws_sessions.append(ws)
get_logger(2).info(self.gettext("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()
@@ -367,27 +364,27 @@ class HttpServer:
if msg.type == WSMsgType.TEXT: if msg.type == WSMsgType.TEXT:
try: try:
(event_type, event) = parse_ws_event(msg.data) (event_type, event) = parse_ws_event(msg.data)
except Exception as err: except Exception as ex:
logger.error(self.gettext("Can't parse JSON event from websocket: %r"), err) logger.error("Can't parse JSON event from websocket: %r", ex)
else: else:
handler = self.__ws_handlers.get(event_type) handler = self.__ws_handlers.get(event_type)
if handler: if handler:
await handler(ws, event) await handler(ws, event)
else: else:
logger.error(self.gettext("Unknown websocket event: %r"), msg.data) logger.error("Unknown websocket event: %r", msg.data)
elif msg.type == WSMsgType.BINARY and len(msg.data) >= 1: elif msg.type == WSMsgType.BINARY and len(msg.data) >= 1:
handler = self.__ws_bin_handlers.get(msg.data[0]) handler = self.__ws_bin_handlers.get(msg.data[0])
if handler: if handler:
await handler(ws, msg.data[1:]) await handler(ws, msg.data[1:])
else: else:
logger.error(self.gettext("Unknown websocket binary event: %r"), msg.data) logger.error("Unknown websocket binary event: %r", msg.data)
else: else:
break break
return ws.wsr return ws.wsr
async def _broadcast_ws_event(self, event_type: str, event: (dict | None)) -> None: async def _broadcast_ws_event(self, event_type: str, event: (dict | None), legacy: (bool | None)=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)
@@ -396,6 +393,7 @@ class HttpServer:
not ws.wsr.closed not ws.wsr.closed
and ws.wsr._req is not None # pylint: disable=protected-access and ws.wsr._req is not None # pylint: disable=protected-access
and ws.wsr._req.transport 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)
@@ -412,7 +410,7 @@ class HttpServer:
async with self.__ws_sessions_lock: async with self.__ws_sessions_lock:
try: try:
self.__ws_sessions.remove(ws) self.__ws_sessions.remove(ws)
get_logger(3).info(self.gettext("Removed client socket: %s; clients now: %d"), ws, len(self.__ws_sessions)) get_logger(3).info("Removed client socket: %s; clients now: %d", ws, len(self.__ws_sessions))
await ws.wsr.close() await ws.wsr.close()
except Exception: except Exception:
pass pass
@@ -420,7 +418,7 @@ class HttpServer:
# ===== # =====
async def _check_request_auth(self, exposed: HttpExposed, request: Request) -> None: async def _check_request_auth(self, exposed: HttpExposed, req: Request) -> None:
pass pass
async def _init_app(self) -> None: async def _init_app(self) -> None:

Binary file not shown.

View File

@@ -1,830 +0,0 @@
# Chinese translations for PROJECT.
# Copyright (C) 2024 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2024.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2024-08-14 22:40+0800\n"
"PO-Revision-Date: 2024-08-14 22:40+0800\n"
"Last-Translator: \n"
"Language: zh\n"
"Language-Team: zh <LL@li.org>\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.15.0\n"
#: kvmd/aiohelpers.py:41
#, python-format
msgid "Remounting %s storage to %s: %s ..."
msgstr "重新挂载 %s 存储到 %s: %s ......"
#: kvmd/aiohelpers.py:48
#, python-format
msgid "Can't remount %s storage: %s"
msgstr "无法重新挂载 %s 存储:%s"
#: kvmd/aioproc.py:89
msgid "Asyncio process: too many empty lines"
msgstr "Asyncio 进程:空行过多"
#: kvmd/aioproc.py:104 kvmd/aioproc.py:111
#, python-format
msgid "Process killed: retcode=%d"
msgstr "进程被杀死retcode=%d"
#: kvmd/aioproc.py:109
#, python-format
msgid "Can't kill process pid=%d"
msgstr "无法杀死进程 pid=%d"
#: kvmd/aioproc.py:120
#, python-format
msgid "Started %s pid=%d"
msgstr "已启动 %s pid=%d"
#: kvmd/htclient.py:63
msgid "Can't determine filename"
msgstr "无法确定文件名"
#: kvmd/htserver.py:356
#, python-format
msgid "Registered new client session: %s; clients now: %d"
msgstr "已注册新客户端会话:%s现在的客户%d"
#: kvmd/htserver.py:371
#, python-format
msgid "Can't parse JSON event from websocket: %r"
msgstr "无法解析来自 websocket 的 JSON 事件:%r"
#: kvmd/htserver.py:377
#, python-format
msgid "Unknown websocket event: %r"
msgstr "未知 websocket 事件:%r"
#: kvmd/htserver.py:384
#, python-format
msgid "Unknown websocket binary event: %r"
msgstr "未知 websocket 二进制事件:%r"
#: kvmd/htserver.py:415
#, python-format
msgid "Removed client socket: %s; clients now: %d"
msgstr "已移除客户端套接字:%s现在的客户端%d"
#: kvmd/inotify.py:199
#, python-format
msgid "Watching for %s"
msgstr "监视 %s"
#: kvmd/inotify.py:258
#, python-format
msgid "Unwatching %s because IGNORED was received"
msgstr "因收到忽略标识而取消监视 %s"
#: kvmd/usb.py:36
msgid "Can't find any UDC"
msgstr "未找到任何 UDC"
#: kvmd/apps/__init__.py:164
msgid "INFO"
msgstr "消息"
#: kvmd/apps/__init__.py:165
msgid "WARNING"
msgstr "警告"
#: kvmd/apps/__init__.py:166
msgid "ERROR"
msgstr "错误"
#: kvmd/apps/__init__.py:176
msgid ""
"To prevent accidental startup, you must specify the --run option to "
"start.\n"
msgstr "为了防止意外启动,必须在启动时指定 --run 选项。\n"
#: kvmd/apps/__init__.py:176
msgid "Try the --help option to find out what this service does.\n"
msgstr "尝试使用 --help 选项来了解某项服务的功能。\n"
#: kvmd/apps/__init__.py:176
msgid "Make sure you understand exactly what you are doing!"
msgstr "请确定你自己在做什么!"
#: kvmd/apps/kvmd/__init__.py:115 kvmd/apps/otgnet/__init__.py:132
#: kvmd/apps/vnc/server.py:541
msgid "Bye-bye"
msgstr "再见"
#: kvmd/apps/kvmd/auth.py:58
msgid "AUTHORIZATION IS DISABLED"
msgstr "身份验证服务已被禁用"
#: kvmd/apps/kvmd/auth.py:62
#, python-format
msgid "Authorization is disabled for API %r"
msgstr "由于 API %r 身份验证服务已被禁用"
#: kvmd/apps/kvmd/auth.py:67
#, python-format
msgid "Using internal auth service %r"
msgstr "使用内部身份验证服务 %r"
#: kvmd/apps/kvmd/auth.py:74
#, python-format
msgid "Using external auth service %r"
msgstr "使用外部身份验证服务 %r"
#: kvmd/apps/kvmd/auth.py:103
#, python-format
msgid "Got access denied for user %r by TOTP"
msgstr "用户 %r 被 TOTP 拒绝访问"
#: kvmd/apps/kvmd/auth.py:114
#, python-format
msgid "Authorized user %r via auth service %r"
msgstr "用户 %r 已通过身份认证服务 %r 授权"
#: kvmd/apps/kvmd/auth.py:116
#, python-format
msgid "Got access denied for user %r from auth service %r"
msgstr "身份验证服务 %r 拒绝了用户 %r 的访问请求"
#: kvmd/apps/kvmd/auth.py:126
#, python-format
msgid "Logged in user %r"
msgstr "已登录用户 %r"
#: kvmd/apps/kvmd/auth.py:136
msgid "Can't generate new unique token"
msgstr "无法生成新的唯一令牌"
#: kvmd/apps/kvmd/auth.py:147
#, python-format
msgid "Logged out user %r (%d)"
msgstr "已注销用户 %r (%d)"
#: kvmd/apps/kvmd/server.py:89
msgid "This streamer does not support quality settings"
msgstr "该 streamer 不支持质量设置"
#: kvmd/apps/kvmd/server.py:94
msgid "This streamer does not support resolution settings"
msgstr "该 streamer 不支持分辨率设置"
#: kvmd/apps/kvmd/server.py:99
msgid "This streamer does not support H264"
msgstr "该 streamer 不支持 H264 设置"
#: kvmd/apps/kvmd/server.py:298
msgid "Waiting short tasks ..."
msgstr "正在等待短时任务结束......"
#: kvmd/apps/kvmd/server.py:300
msgid "Stopping system tasks ..."
msgstr "正在停止系统任务 ......"
#: kvmd/apps/kvmd/server.py:302
msgid "Disconnecting clients ..."
msgstr "断开客户端连接 ......"
#: kvmd/apps/kvmd/server.py:304
msgid "On-Shutdown complete"
msgstr "全部服务关闭完成"
#: kvmd/apps/kvmd/server.py:310
#, python-format
msgid "Cleaning up %s ..."
msgstr "正在清理 %s ......"
#: kvmd/apps/kvmd/server.py:314
#, python-format
msgid "Cleanup error on %s"
msgstr "在 %s 上发生清理错误"
#: kvmd/apps/kvmd/server.py:315
msgid "On-Cleanup complete"
msgstr "全部清理完毕"
#: kvmd/apps/kvmd/streamer.py:245
msgid "Streamer stop cancelled"
msgstr "Streamer 停止已取消"
#: kvmd/apps/kvmd/streamer.py:251
#, python-format
msgid "Waiting %.2f seconds for reset delay ..."
msgstr "等待 %.2f 秒的重置延迟 ......"
#: kvmd/apps/kvmd/streamer.py:253
msgid "Starting streamer ..."
msgstr "正在启动 streamer......"
#: kvmd/apps/kvmd/streamer.py:266 kvmd/apps/kvmd/streamer.py:271
msgid "Stopping streamer immediately ..."
msgstr "正在停止 streamer......"
#: kvmd/apps/kvmd/streamer.py:280
msgid "Stopping streamer after delay ..."
msgstr "在延迟时间到后停止 streamer......"
#: kvmd/apps/kvmd/streamer.py:286
#, python-format
msgid "Planning to stop streamer in %.2f seconds ..."
msgstr "计划在 %.2f 秒后停止streamer......"
#: kvmd/apps/kvmd/streamer.py:315
msgid "Invalid streamer response from /state"
msgstr "来自 /state 的无效 streamer 响应"
#: kvmd/apps/kvmd/streamer.py:333
msgid "Got SIGUSR2, checking the stream state ..."
msgstr "收到 SIGUSR2 信号,正在检查数据流状态 ..."
#: kvmd/apps/kvmd/streamer.py:336
msgid "Installing SIGUSR2 streamer handler ..."
msgstr "安装 SIGUSR2 streamer 处理程序 ..."
#: kvmd/apps/kvmd/streamer.py:392
msgid "Stream is offline, no signal or so"
msgstr "流媒体离线,没有信号或其他原因"
#: kvmd/apps/kvmd/streamer.py:395
#, python-format
msgid "Can't connect to streamer: %s"
msgstr "无法连接 streamer%s"
#: kvmd/apps/kvmd/streamer.py:397
msgid "Invalid streamer response from /snapshot"
msgstr "来自 /snapshot 的无效 streamer 响应"
#: kvmd/apps/kvmd/streamer.py:454
msgid "Streamer unexpectedly died"
msgstr "Streamer 意外停止"
#: kvmd/apps/kvmd/streamer.py:459
#, python-format
msgid "Unexpected streamer error: pid=%d"
msgstr "Streamer 意外错误pid=%d"
#: kvmd/apps/kvmd/streamer.py:461
msgid "Can't start streamer"
msgstr "无法启动 streamer"
#: kvmd/apps/kvmd/streamer.py:482 kvmd/apps/otgnet/__init__.py:141
#, python-format
msgid "Can't execute command: %s"
msgstr "无法执行命令: %s"
#: kvmd/apps/kvmd/streamer.py:488
#, python-format
msgid "Started streamer pid=%d: %s"
msgstr "已启动 streamer pid=%d: %s"
#: kvmd/apps/kvmd/ugpio.py:51
msgid "GPIO channel is not found"
msgstr "GPIO 未找到"
#: kvmd/apps/kvmd/ugpio.py:56
msgid "This GPIO channel does not support switching"
msgstr "该 GPIO 通道不支持切换"
#: kvmd/apps/kvmd/ugpio.py:61
msgid "This GPIO channel does not support pulsing"
msgstr "该 GPIO 通道不支持脉冲信号"
#: kvmd/apps/kvmd/ugpio.py:66
msgid "Performing another GPIO operation on this channel, please try again later"
msgstr "在此通道上正在执行另一个 GPIO 操作,请稍后再试"
#: kvmd/apps/kvmd/ugpio.py:204
#, python-format
msgid "Can't perform %s of %s or operation was not completed: driver offline"
msgstr "无法执行 %s 的 %s 或操作未完成:驱动程序离线"
#: kvmd/apps/kvmd/ugpio.py:209
#, python-format
msgid "Ensured switch %s to state=%d"
msgstr "确保将 %s 切换到状态=%d"
#: kvmd/apps/kvmd/ugpio.py:220
#, python-format
msgid "Pulsed %s with delay=%.2f"
msgstr "脉冲%s延迟=%.2f"
#: kvmd/apps/kvmd/ugpio.py:298
msgid "Preparing User-GPIO drivers ..."
msgstr "准备 User-GPIO 驱动程序 ......"
#: kvmd/apps/kvmd/ugpio.py:303
msgid "Running User-GPIO drivers ..."
msgstr "运行 User-GPIO 驱动程序 ......"
#: kvmd/apps/kvmd/ugpio.py:314
#, python-format
msgid "Can't cleanup driver %s"
msgstr "无法清理驱动程序 %s"
#: kvmd/apps/otg/__init__.py:211 kvmd/plugins/hid/otg/__init__.py:123
#, python-format
msgid "Using UDC %s"
msgstr "使用 UDC %s"
#: kvmd/apps/otg/__init__.py:213
#, python-format
msgid "Creating gadget %r ..."
msgstr "新建 gadget %r ......"
#: kvmd/apps/otg/__init__.py:254
msgid "===== Serial ====="
msgstr "===== 串口 ====="
#: kvmd/apps/otg/__init__.py:258
msgid "===== Ethernet ====="
msgstr "===== 以太网 ====="
#: kvmd/apps/otg/__init__.py:262
msgid "===== HID-Keyboard ====="
msgstr "===== HID-键盘 ====="
#: kvmd/apps/otg/__init__.py:264
msgid "===== HID-Mouse ====="
msgstr "===== HID-鼠标 ====="
#: kvmd/apps/otg/__init__.py:267
msgid "===== HID-Mouse-Alt ====="
msgstr "===== HID-绝对鼠标 ====="
#: kvmd/apps/otg/__init__.py:271
msgid "===== MSD ====="
msgstr "===== MSD ====="
#: kvmd/apps/otg/__init__.py:275
#, python-format
msgid "===== MSD Extra: %d ====="
msgstr "===== MSD 扩展: %d ====="
#: kvmd/apps/otg/__init__.py:278
msgid "===== Preparing complete ====="
msgstr "===== 准备完成 ====="
#: kvmd/apps/otg/__init__.py:280
msgid "Enabling the gadget ..."
msgstr "启用此 gadget ......"
#: kvmd/apps/otg/__init__.py:286 kvmd/apps/otgnet/__init__.py:128
msgid "Ready to work"
msgstr "准备就绪"
#: kvmd/apps/otg/__init__.py:299
#, python-format
msgid "Disabling gadget %r ..."
msgstr "禁用 gadget %r ......"
#: kvmd/apps/otgnet/__init__.py:137
#, python-format
msgid "CMD: %s"
msgstr "命令行: %s"
#: kvmd/apps/otgnet/__init__.py:150
#, python-format
msgid "Using IPv4 network %s ..."
msgstr "使用 IPv4 网络 %s ......"
#: kvmd/apps/otgnet/__init__.py:153
msgid "Too small network, required at least /31"
msgstr "网络段太小,至少需要 /31"
#: kvmd/apps/otgnet/__init__.py:173
#, python-format
msgid "Calculated %r address is %s/%d"
msgstr "计算出的 %r 地址为 %s/%d"
#: kvmd/apps/otgnet/__init__.py:182
#, python-format
msgid "Using OTG gadget %r ..."
msgstr "使用 OTG gadget %r ......"
#: kvmd/apps/otgnet/__init__.py:185
#, python-format
msgid "Using OTG Ethernet interface %r ..."
msgstr "使用 OTG 以太网接口 %r ......"
#: kvmd/apps/vnc/server.py:163
#, python-format
msgid "%s [kvmd]: Waiting for the SetEncodings message ..."
msgstr "%s [kvmd] 等待 SetEncodings 信息 ......"
#: kvmd/apps/vnc/server.py:165
msgid "No SetEncodings message recieved from the client in 5 secs"
msgstr "5 秒内未收到客户端的 SetEncodings 信息"
#: kvmd/apps/vnc/server.py:169
#, python-format
msgid "%s [kvmd]: Applying HID params: mouse_output=%s ..."
msgstr "%s [kvmd] 应用 HID 参数mouse_output=%s ......"
#: kvmd/apps/vnc/server.py:177
msgid "KVMD closed the websocket (the server may have been stopped)"
msgstr "KVMD 关闭了 websocket服务器可能已停止运行"
#: kvmd/apps/vnc/server.py:211
#, python-format
msgid "%s [streamer]: Streaming ..."
msgstr "%s [streamer]:获取视频流中 ......"
#: kvmd/apps/vnc/server.py:216
msgid "No signal"
msgstr "无信号"
#: kvmd/apps/vnc/server.py:220
#, python-format
msgid "%s [streamer]: Permanent error: %s; switching to %s ..."
msgstr "%s [streamer] 持续错误: %s; 切换到 %s ......"
#: kvmd/apps/vnc/server.py:222
#, python-format
msgid "%s [streamer]: Waiting for stream: %s"
msgstr "%s [streamer] 正在等待数据流:%s"
#: kvmd/apps/vnc/server.py:223
msgid "Waiting for stream ..."
msgstr "正在启动 streamer ......"
#: kvmd/apps/vnc/server.py:234
#, python-format
msgid "%s [streamer]: Using preferred %s"
msgstr "%s [streamer] 使用首选 %s"
#: kvmd/apps/vnc/server.py:240
#, python-format
msgid "%s [streamer]: Using default %s"
msgstr "%s [streamer] 使用默认 %s"
#: kvmd/apps/vnc/server.py:305
msgid "The client doesn't want to accept H264 anymore"
msgstr "客户端不接受 H264 视频"
#: kvmd/apps/vnc/server.py:311
msgid "format"
msgstr "格式"
#: kvmd/apps/vnc/server.py:417
#, python-format
msgid "%s [main]: Applying streamer params: jpeg_quality=%s; desired_fps=%d ..."
msgstr "%s [main] 应用流媒体参数: jpeg_quality=%s; desired_fps=%d ..."
#: kvmd/apps/vnc/server.py:467
#, python-format
msgid "%s [entry]: Connection is closed in an emergency"
msgstr "%s [entry] 连接因紧急情况关闭"
#: kvmd/apps/vnc/server.py:472
#, python-format
msgid "%s [entry]: Connected client"
msgstr "%s [entry] 已连接客户端"
#: kvmd/apps/vnc/server.py:491
#, python-format
msgid "%s [entry]: Can't check KVMD auth mode: %s"
msgstr "%s [entry] 无法检查 KVMD 身份验证模式: %s"
#: kvmd/apps/vnc/server.py:513
#, python-format
msgid "%s [entry]: Unhandled exception in client task"
msgstr "%s [entry] 客户端任务中出现无法处理的异常"
#: kvmd/apps/vnc/server.py:523
#, python-format
msgid "Listening VNC on TCP [%s]:%d ..."
msgstr "正在监听 TCP [%s]:%d 上的 VNC 服务 ......"
#: kvmd/apps/vnc/vncauth.py:63
msgid "Unhandled exception while reading VNCAuth passwd file"
msgstr "读取 VNCAuth 密码文件时出现无法处理的异常"
#: kvmd/apps/vnc/vncauth.py:74
msgid "Missing ' -> ' operator"
msgstr "缺少\"->\"运算符"
#: kvmd/apps/vnc/vncauth.py:78
msgid "Missing ':' operator in KVMD credentials (right part)"
msgstr "KVMD 证书中缺少\": \"运算符(右侧部分)"
#: kvmd/apps/vnc/vncauth.py:83
msgid "Empty KVMD user (right part)"
msgstr "空的 KVMD 用户(右侧部分)"
#: kvmd/apps/vnc/vncauth.py:86
msgid "Duplicating VNC password (left part)"
msgstr "复制 VNC 密码(左侧部分)"
#: kvmd/keyboard/keysym.py:69
#, python-format
msgid "Invalid modifier key at mapping %s: %s / %s"
msgstr "映射 %s 处的修改键无效: %s / %s"
#: kvmd/keyboard/keysym.py:122
#, python-format
msgid "Reading keyboard layout %s ..."
msgstr "读取键盘布局 %s ......"
#: kvmd/plugins/atx/__init__.py:45
msgid "Performing another ATX operation, please try again later"
msgstr "正在处理另一个 ATX 动作,请稍等"
#: kvmd/plugins/atx/gpio.py:209
#, python-format
msgid "Clicked ATX button %r"
msgstr "ATX 按钮 %r 被点击"
#: kvmd/plugins/auth/http.py:94
#, python-format
msgid "Failed HTTP auth request for user %r"
msgstr "用户 %r 的 HTTP 验证请求失败"
#: kvmd/plugins/auth/ldap.py:106
#, python-format
msgid "LDAP server is down: %s"
msgstr "LDAP 服务已下线: %s"
#: kvmd/plugins/auth/ldap.py:108
#, python-format
msgid "Unexpected LDAP error: %s"
msgstr "LDAP 未知错误: %s"
#: kvmd/plugins/auth/pam.py:91
#, python-format
msgid "Unallowed UID of user %r: uid=%d < allow_uids_at=%d"
msgstr "未授权 UID user %r: uid=%d < allow_uids_at=%d"
#: kvmd/plugins/auth/pam.py:97
#, python-format
msgid "Can't authorize user %r using PAM: code=%d; reason=%s"
msgstr "无法使用 PAM 验证用户 %r code=%dreason=%s"
#: kvmd/plugins/auth/radius.py:445
#, python-format
msgid "Failed RADIUS auth request for user %r"
msgstr "用户 %r 的 RADIUS 验证请求失败"
#: kvmd/plugins/hid/bt/__init__.py:137 kvmd/plugins/hid/ch9329/__init__.py:99
msgid "Starting HID daemon ..."
msgstr "正在启动 HID 守护程序......"
#: kvmd/plugins/hid/bt/__init__.py:182 kvmd/plugins/hid/ch9329/__init__.py:141
msgid "Stopping HID daemon ..."
msgstr "正在停止 HID 守护程序......"
#: kvmd/plugins/hid/bt/__init__.py:231
msgid "Unexpected HID error"
msgstr "未知 HID 错误"
#: kvmd/plugins/hid/bt/server.py:153
#, python-format
msgid "Listening [%s]:%d for %s ..."
msgstr "监听 [%s]:%d for %s ......"
#: kvmd/plugins/hid/bt/server.py:190
#, python-format
msgid "CTL socket error on %s: %s"
msgstr "CTL 套接字错误 %s: %s"
#: kvmd/plugins/hid/bt/server.py:204
#, python-format
msgid "INT socket error on %s: %s"
msgstr "INT 套接字错误 %s: %s"
#: kvmd/plugins/hid/bt/server.py:287
#, python-format
msgid "Can't send %s report to %s: %s"
msgstr "无法向 %s 发送 %s 报告: %s"
#: kvmd/plugins/hid/bt/server.py:314
#, python-format
msgid "Can't accept %s client"
msgstr "无法接受 %s 客户端"
#: kvmd/plugins/hid/bt/server.py:319
#, python-format
msgid "Refused %s client: %s: max clients reached"
msgstr "拒绝 %s 客户端:%s已到达最大客户端数量"
#: kvmd/plugins/hid/bt/server.py:328
#, python-format
msgid "Accepted %s client: %s"
msgstr "已接受 %s 客户端: %s"
#: kvmd/plugins/hid/bt/server.py:340
#, python-format
msgid "Closed %s client %s"
msgstr "关闭 %s 客户端 %s"
#: kvmd/plugins/hid/bt/server.py:375
msgid "Publishing ..."
msgstr "广播中......"
#: kvmd/plugins/hid/bt/server.py:375
msgid "Unpublishing ..."
msgstr "取消广播......"
#: kvmd/plugins/hid/bt/server.py:379
#, python-format
msgid "Can't change public mode: %s"
msgstr "无法更改公共模式:%s"
#: kvmd/plugins/hid/bt/server.py:383
#, python-format
msgid "Unpairing %s ..."
msgstr "正在取消配对 %s ......"
#: kvmd/plugins/hid/bt/server.py:387
#, python-format
msgid "Can't unpair %s: %s"
msgstr "无法取消配对 %s: %s"
#: kvmd/plugins/hid/ch9329/__init__.py:178
#, python-format
msgid "HID : mouse output = %s"
msgstr "HID鼠标输出 = %s"
#: kvmd/plugins/hid/ch9329/__init__.py:208
msgid "Unexpected error in the run loop"
msgstr "运行循环中出现意外错误"
#: kvmd/plugins/hid/ch9329/__init__.py:231
msgid "Unexpected error in the HID loop"
msgstr "HID 循环中出现意外错误"
#: kvmd/plugins/hid/ch9329/chip.py:58
msgid "Too short response, HID might be disconnected"
msgstr "响应时间太短HID 可能已断开"
#: kvmd/plugins/hid/ch9329/chip.py:64
msgid "Invalid response checksum"
msgstr "响应校验和无效"
#: kvmd/plugins/hid/otg/device.py:125
#, python-format
msgid "Unexpected HID-%s error"
msgstr "HID-%s 意外错误"
#: kvmd/plugins/hid/otg/device.py:152
#, python-format
msgid "Stopping HID-%s daemon ..."
msgstr "清除 HID-%s 鼠标事件 ......"
#: kvmd/plugins/hid/otg/device.py:197
#, python-format
msgid "HID-%s write() error: written (%s) != report length (%d)"
msgstr "HID-%s write() 错误:写入 (%s) != 报告长度 (%d)"
#: kvmd/plugins/hid/otg/device.py:205
#, python-format
msgid "HID-%s busy/unplugged (write): %s"
msgstr "HID-%s 忙碌/已拔(写入): %s"
#: kvmd/plugins/hid/otg/device.py:207
#, python-format
msgid "Can't write report to HID-%s"
msgstr "无法将内容写入 HID-%s"
#: kvmd/plugins/hid/otg/device.py:224
#, python-format
msgid "Can't select() for read HID-%s: %s"
msgstr "读取 HID-%s 时无法选择 %s"
#: kvmd/plugins/hid/otg/device.py:232
#, python-format
msgid "HID-%s busy/unplugged (read): %s"
msgstr "HID-%s 忙碌/已拔(读取): %s"
#: kvmd/plugins/hid/otg/device.py:234
#, python-format
msgid "Can't read report from HID-%s"
msgstr "无法读取 HID-%s 的回复"
#: kvmd/plugins/hid/otg/device.py:251
#, python-format
msgid "Missing HID-%s device: %s"
msgstr "丢失 HID-%s 设备:%s"
#: kvmd/plugins/hid/otg/device.py:263
#, python-format
msgid "Can't open HID-%s device %s: %s"
msgstr "无法打开 HID-%s 设备 %s: %s"
#: kvmd/plugins/hid/otg/device.py:274
#, python-format
msgid "HID-%s is busy/unplugged (write select)"
msgstr "HID-%s 忙碌/已拔(写入切换)"
#: kvmd/plugins/hid/otg/device.py:276
#, python-format
msgid "Can't select() for write HID-%s: %s"
msgstr "写 HID-%s 时无法选择 %s"
#: kvmd/plugins/hid/otg/keyboard.py:62
msgid "Clearing HID-keyboard events ..."
msgstr "清除 HID 键盘事件 ......"
#: kvmd/plugins/hid/otg/mouse.py:71
msgid "Clearing HID-mouse events ..."
msgstr "清除 HID 鼠标事件 ......"
#: kvmd/plugins/msd/__init__.py:57
msgid "Performing another MSD operation, please try again later"
msgstr "正在执行另一项虚拟存储驱动器操作,请稍后再试"
#: kvmd/plugins/msd/__init__.py:63
msgid "MSD is not found"
msgstr "虚拟存储驱动器不存在"
#: kvmd/plugins/msd/__init__.py:69
msgid "MSD is connected to Server, but shouldn't for this operation"
msgstr "虚拟存储驱动器已连接到服务器,但本操作不应连接到服务器"
#: kvmd/plugins/msd/__init__.py:75
msgid "MSD is disconnected from Server, but should be for this operation"
msgstr "虚拟存储驱动器与服务器断开连接,但进行此操作时应断开连接"
#: kvmd/plugins/msd/__init__.py:81
msgid "The image is not selected"
msgstr "没有选中任何镜像"
#: kvmd/plugins/msd/__init__.py:87
msgid "The image is not found in the storage"
msgstr "存储区中没有找到镜像"
#: kvmd/plugins/msd/__init__.py:93
msgid "This image is already exists"
msgstr "此镜像已存在"
#: kvmd/plugins/msd/disabled.py:40
msgid "MSD is disabled"
msgstr "虚拟存储驱动器已被禁用"
#: kvmd/plugins/msd/otg/__init__.py:148
#, python-format
msgid "Using OTG gadget %r as MSD"
msgstr "使用 OTG gadget %r 作为 MSD"
#: kvmd/plugins/msd/otg/__init__.py:223
msgid "Can't reset MSD properly"
msgstr "无法正确重置虚拟存储驱动器"
#: kvmd/plugins/msd/otg/__init__.py:442
msgid "Unexpected MSD watcher error"
msgstr "虚拟存储驱动器监视器意外出错"
#: kvmd/plugins/msd/otg/__init__.py:461
msgid "Probing to remount storage ..."
msgstr "探测以重新挂载存储 ......"
#: kvmd/plugins/msd/otg/__init__.py:467
msgid "Error while reloading MSD state; switching to offline"
msgstr "重新加载 MSD 状态时出错;切换到离线状态"
#: kvmd/plugins/msd/otg/__init__.py:495
#, python-format
msgid "Setting up initial image %r ..."
msgstr "设置初始镜像 %r ......"
#: kvmd/plugins/msd/otg/__init__.py:501
msgid "Can't setup initial image: ignored"
msgstr "无法设置初始镜像 %r: 已忽略"
#: kvmd/plugins/msd/otg/__init__.py:503
#, python-format
msgid "Can't find initial image %r: ignored"
msgstr "找不到初始镜像 %r: 已忽略"
#: kvmd/plugins/msd/otg/drive.py:36
msgid "MSD drive is locked on IO operation"
msgstr "虚拟存储驱动器在 IO 操作时被锁定"
#: kvmd/plugins/msd/otg/storage.py:297
msgid "Can't execute remount helper"
msgstr "无法执行重新挂载辅助程序"
#: kvmd/plugins/ugpio/anelpwr.py:152
#, python-format
msgid "Failed ANELPWR POST request to pin %s: %s"
msgstr "向引脚 %s 发送 ANELPWR POST 请求失败:%s"
#~ msgid "Set config file path"
#~ msgstr "设置配置文件路径"
#~ msgid "Override config options list (like sec/sub/opt=value)"
#~ msgstr "覆盖配置文件选项列表(如 sec/sub/opt=value"
#~ msgid "View current configuration (include all overrides)"
#~ msgstr "查看当前配置(包括所有覆盖配置文件)"
#~ msgid "Run the service"
#~ msgstr "启动此服务"

View File

@@ -34,8 +34,6 @@ from typing import Generator
from .logging import get_logger from .logging import get_logger
from .languages import Languages
from . import aiotools from . import aiotools
from . import libc from . import libc
@@ -132,18 +130,25 @@ class InotifyMask:
# | OPEN # | OPEN
# ) # )
# Helper for all modify events # Helper for all changes events except MODIFY, because it fires on each write()
ALL_MODIFY_EVENTS = ( ALL_CHANGES_EVENTS = (
CLOSE_WRITE CLOSE_WRITE
| CREATE | CREATE
| DELETE | DELETE
| DELETE_SELF | DELETE_SELF
| MODIFY
| MOVE_SELF | MOVE_SELF
| MOVED_FROM | MOVED_FROM
| MOVED_TO | MOVED_TO
) )
# Helper for typicals events when we need to restart watcher
ALL_RESTART_EVENTS = (
DELETE_SELF
| MOVE_SELF
| UNMOUNT
| ISDIR
)
# Special flags for watch() # Special flags for watch()
# DONT_FOLLOW = 0x02000000 # Don't follow a symbolic link # DONT_FOLLOW = 0x02000000 # Don't follow a symbolic link
# EXCL_UNLINK = 0x04000000 # Exclude events on unlinked objects # EXCL_UNLINK = 0x04000000 # Exclude events on unlinked objects
@@ -174,6 +179,10 @@ class InotifyEvent:
name: str name: str
path: str path: str
@property
def restart(self) -> bool:
return bool(self.mask & InotifyMask.ALL_RESTART_EVENTS)
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
f"<InotifyEvent: wd={self.wd}, mask={InotifyMask.to_string(self.mask)}," f"<InotifyEvent: wd={self.wd}, mask={InotifyMask.to_string(self.mask)},"
@@ -192,11 +201,14 @@ class Inotify:
self.__events_queue: "asyncio.Queue[InotifyEvent]" = asyncio.Queue() self.__events_queue: "asyncio.Queue[InotifyEvent]" = asyncio.Queue()
async def watch_all_changes(self, *paths: str) -> None:
await self.watch(InotifyMask.ALL_CHANGES_EVENTS, *paths)
async def watch(self, mask: int, *paths: str) -> None: async def watch(self, mask: int, *paths: str) -> None:
for path in paths: for path in paths:
path = os.path.normpath(path) path = os.path.normpath(path)
assert path not in self.__wd_by_path, path assert path not in self.__wd_by_path, path
get_logger().info(Languages().gettext("Watching for %s"), path) get_logger().info("Watching for %s", path)
# Асинхронно, чтобы не висло на NFS # Асинхронно, чтобы не висло на NFS
wd = _inotify_check(await aiotools.run_async(libc.inotify_add_watch, self.__fd, _fs_encode(path), mask)) wd = _inotify_check(await aiotools.run_async(libc.inotify_add_watch, self.__fd, _fs_encode(path), mask))
self.__wd_by_path[path] = wd self.__wd_by_path[path] = wd
@@ -224,7 +236,7 @@ class Inotify:
except asyncio.TimeoutError: except asyncio.TimeoutError:
return None return None
async def get_series(self, timeout: float) -> list[InotifyEvent]: async def get_series(self, timeout: float, max_series: int=64) -> list[InotifyEvent]:
series: list[InotifyEvent] = [] series: list[InotifyEvent] = []
event = await self.get_event(timeout) event = await self.get_event(timeout)
if event: if event:
@@ -233,6 +245,8 @@ class Inotify:
event = await self.get_event(timeout) event = await self.get_event(timeout)
if event: if event:
series.append(event) series.append(event)
if len(series) >= max_series:
break
return series return series
def __read_and_queue_events(self) -> None: def __read_and_queue_events(self) -> None:
@@ -255,7 +269,7 @@ class Inotify:
if event.mask & InotifyMask.IGNORED: if event.mask & InotifyMask.IGNORED:
ignored_path = self.__path_by_wd[event.wd] ignored_path = self.__path_by_wd[event.wd]
if self.__wd_by_path[ignored_path] == event.wd: if self.__wd_by_path[ignored_path] == event.wd:
logger.info(Languages().gettext("Unwatching %s because IGNORED was received"), ignored_path) logger.info("Unwatching %s because IGNORED was received", ignored_path)
del self.__wd_by_path[ignored_path] del self.__wd_by_path[ignored_path]
continue continue
@@ -273,8 +287,8 @@ class Inotify:
while True: while True:
try: try:
return os.read(self.__fd, _EVENTS_BUFFER_LENGTH) return os.read(self.__fd, _EVENTS_BUFFER_LENGTH)
except OSError as err: except OSError as ex:
if err.errno == errno.EINTR: if ex.errno == errno.EINTR:
pass pass
def __enter__(self) -> "Inotify": def __enter__(self) -> "Inotify":

View File

@@ -27,8 +27,6 @@ import importlib.machinery
import Xlib.keysymdef import Xlib.keysymdef
from ..languages import Languages
from ..logging import get_logger from ..logging import get_logger
from .mappings import At1Key from .mappings import At1Key
@@ -66,7 +64,7 @@ def build_symmap(path: str) -> dict[int, dict[int, str]]: # x11 keysym -> [(mod
or (web_name in WebModifiers.ALTS and key.altgr) or (web_name in WebModifiers.ALTS and key.altgr)
or (web_name in WebModifiers.CTRLS and key.ctrl) or (web_name in WebModifiers.CTRLS and key.ctrl)
): ):
logger.error(Languages().gettext("Invalid modifier key at mapping %s: %s / %s"), src, web_name, key) logger.error("Invalid modifier key at mapping %s: %s / %s", src, web_name, key)
continue continue
modifiers = ( modifiers = (
@@ -119,7 +117,7 @@ def _resolve_keysym(name: str) -> int:
def _read_keyboard_layout(path: str) -> dict[int, list[At1Key]]: # Keysym to evdev (at1) def _read_keyboard_layout(path: str) -> dict[int, list[At1Key]]: # Keysym to evdev (at1)
logger = get_logger(0) logger = get_logger(0)
logger.info(Languages().gettext("Reading keyboard layout %s ..."), path) logger.info("Reading keyboard layout %s ...", path)
with open(path) as file: with open(path) as file:
lines = list(map(str.strip, file.read().split("\n"))) lines = list(map(str.strip, file.read().split("\n")))
@@ -137,8 +135,8 @@ def _read_keyboard_layout(path: str) -> dict[int, list[At1Key]]: # Keysym to ev
try: try:
at1_code = int(parts[1], 16) at1_code = int(parts[1], 16)
except ValueError as err: except ValueError as ex:
logger.error("Syntax error at %s:%d: %s", path, lineno, err) logger.error("Syntax error at %s:%d: %s", path, lineno, ex)
continue continue
rest = parts[2:] rest = parts[2:]

View File

@@ -1,18 +0,0 @@
from gettext import translation
class Languages:
use_ttranslation = None
languages = "default"
@classmethod
def gettext(cls, string: str) -> str:
if cls.languages == "default" or cls.languages == "en" :
return string
else:
return cls.use_ttranslation(string)
@classmethod
def init(cls, domain:str, localedir: str, languages: str) -> None:
cls.languages = languages
cls.use_ttranslation = translation(domain=domain, localedir=localedir, languages=[cls.languages]).gettext

View File

@@ -34,10 +34,10 @@ def is_ipv6_enabled() -> bool:
with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as sock: with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as sock:
sock.bind(("::1", 0)) sock.bind(("::1", 0))
return True return True
except OSError as err: except OSError as ex:
if err.errno in [errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT]: if ex.errno in [errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT]:
return False return False
if err.errno == errno.EADDRINUSE: if ex.errno == errno.EADDRINUSE:
return True return True
raise raise

View File

@@ -25,8 +25,6 @@ from typing import AsyncGenerator
from ...errors import OperationError from ...errors import OperationError
from ...errors import IsBusyError from ...errors import IsBusyError
from ...languages import Languages
from .. import BasePlugin from .. import BasePlugin
from .. import get_plugin_class from .. import get_plugin_class
@@ -42,7 +40,7 @@ class AtxOperationError(OperationError, AtxError):
class AtxIsBusyError(IsBusyError, AtxError): class AtxIsBusyError(IsBusyError, AtxError):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(Languages().gettext("Performing another ATX operation, please try again later")) super().__init__("Performing another ATX operation, please try again later")
# ===== # =====
@@ -50,7 +48,16 @@ class BaseAtx(BasePlugin):
async def get_state(self) -> dict: async def get_state(self) -> dict:
raise NotImplementedError raise NotImplementedError
async def trigger_state(self) -> None:
raise NotImplementedError
async def poll_state(self) -> AsyncGenerator[dict, None]: async def poll_state(self) -> AsyncGenerator[dict, None]:
# ==== Granularity table ====
# - enabled -- Full
# - busy -- Partial
# - leds -- Partial
# ===========================
yield {} yield {}
raise NotImplementedError raise NotImplementedError

View File

@@ -36,6 +36,9 @@ class AtxDisabledError(AtxOperationError):
# ===== # =====
class Plugin(BaseAtx): class Plugin(BaseAtx):
def __init__(self) -> None:
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> dict: async def get_state(self) -> dict:
return { return {
"enabled": False, "enabled": False,
@@ -46,10 +49,13 @@ class Plugin(BaseAtx):
}, },
} }
async def trigger_state(self) -> None:
self.__notifier.notify()
async def poll_state(self) -> AsyncGenerator[dict, None]: async def poll_state(self) -> AsyncGenerator[dict, None]:
while True: while True:
await self.__notifier.wait()
yield (await self.get_state()) yield (await self.get_state())
await aiotools.wait_infinite()
# ===== # =====

View File

@@ -21,6 +21,7 @@
import asyncio import asyncio
import copy
from typing import AsyncGenerator from typing import AsyncGenerator
@@ -39,8 +40,6 @@ from ...validators.basic import valid_float_f01
from ...validators.os import valid_abs_path from ...validators.os import valid_abs_path
from ...validators.hw import valid_gpio_pin from ...validators.hw import valid_gpio_pin
from ...languages import Languages
from . import AtxIsBusyError from . import AtxIsBusyError
from . import BaseAtx from . import BaseAtx
@@ -78,7 +77,7 @@ class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
self.__notifier = aiotools.AioNotifier() self.__notifier = aiotools.AioNotifier()
self.__region = aiotools.AioExclusiveRegion(AtxIsBusyError, self.__notifier) self.__region = aiotools.AioExclusiveRegion(AtxIsBusyError, self.__notifier)
self.__line_request: (gpiod.LineRequest | None) = None self.__line_req: (gpiod.LineRequest | None) = None
self.__reader = aiogp.AioReader( self.__reader = aiogp.AioReader(
path=self.__device_path, path=self.__device_path,
@@ -90,7 +89,6 @@ class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
notifier=self.__notifier, notifier=self.__notifier,
) )
@classmethod @classmethod
def get_plugin_options(cls) -> dict: def get_plugin_options(cls) -> dict:
return { return {
@@ -111,8 +109,8 @@ class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
} }
def sysprep(self) -> None: def sysprep(self) -> None:
assert self.__line_request is None assert self.__line_req is None
self.__line_request = gpiod.request_lines( self.__line_req = gpiod.request_lines(
self.__device_path, self.__device_path,
consumer="kvmd::atx", consumer="kvmd::atx",
config={ config={
@@ -133,22 +131,26 @@ class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
}, },
} }
async def trigger_state(self) -> None:
self.__notifier.notify(1)
async def poll_state(self) -> AsyncGenerator[dict, None]: async def poll_state(self) -> AsyncGenerator[dict, None]:
prev_state: dict = {} prev: dict = {}
while True: while True:
state = await self.get_state() if (await self.__notifier.wait()) > 0:
if state != prev_state: prev = {}
yield state new = await self.get_state()
prev_state = state if new != prev:
await self.__notifier.wait() prev = copy.deepcopy(new)
yield new
async def systask(self) -> None: async def systask(self) -> None:
await self.__reader.poll() await self.__reader.poll()
async def cleanup(self) -> None: async def cleanup(self) -> None:
if self.__line_request: if self.__line_req:
try: try:
self.__line_request.release() self.__line_req.release()
except Exception: except Exception:
pass pass
@@ -189,21 +191,21 @@ class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
@aiotools.atomic_fg @aiotools.atomic_fg
async def __click(self, name: str, pin: int, delay: float, wait: bool) -> None: async def __click(self, name: str, pin: int, delay: float, wait: bool) -> None:
if wait: if wait:
async with self.__region: with self.__region:
await self.__inner_click(name, pin, delay) await self.__inner_click(name, pin, delay)
else: else:
await aiotools.run_region_task( await aiotools.run_region_task(
Languages().gettext(f"Can't perform ATX {name} click or operation was not completed"), f"Can't perform ATX {name} click or operation was not completed",
self.__region, self.__inner_click, name, pin, delay, self.__region, self.__inner_click, name, pin, delay,
) )
@aiotools.atomic_fg @aiotools.atomic_fg
async def __inner_click(self, name: str, pin: int, delay: float) -> None: async def __inner_click(self, name: str, pin: int, delay: float) -> None:
assert self.__line_request assert self.__line_req
try: try:
self.__line_request.set_value(pin, gpiod.line.Value(True)) self.__line_req.set_value(pin, gpiod.line.Value(True))
await asyncio.sleep(delay) await asyncio.sleep(delay)
finally: finally:
self.__line_request.set_value(pin, gpiod.line.Value(False)) self.__line_req.set_value(pin, gpiod.line.Value(False))
await asyncio.sleep(1) await asyncio.sleep(1)
get_logger(0).info(Languages().gettext("Clicked ATX button %r"), name) get_logger(0).info("Clicked ATX button %r", name)

View File

@@ -32,8 +32,6 @@ from ...logging import get_logger
from ... import htclient from ... import htclient
from ...languages import Languages
from . import BaseAuthService from . import BaseAuthService
@@ -77,7 +75,7 @@ class Plugin(BaseAuthService):
async with session.request( async with session.request(
method="POST", method="POST",
url=self.__url, url=self.__url,
timeout=self.__timeout, timeout=aiohttp.ClientTimeout(total=self.__timeout),
json={ json={
"user": user, "user": user,
"passwd": passwd, "passwd": passwd,
@@ -87,11 +85,11 @@ class Plugin(BaseAuthService):
"User-Agent": htclient.make_user_agent("KVMD"), "User-Agent": htclient.make_user_agent("KVMD"),
"X-KVMD-User": user, "X-KVMD-User": user,
}, },
) as response: ) as resp:
htclient.raise_not_200(response) htclient.raise_not_200(resp)
return True return True
except Exception: except Exception:
get_logger().exception(Languages().gettext("Failed HTTP auth request for user %r"), user) get_logger().exception("Failed HTTP auth request for user %r", user)
return False return False
async def cleanup(self) -> None: async def cleanup(self) -> None:

View File

@@ -33,8 +33,6 @@ from ...logging import get_logger
from ... import tools from ... import tools
from ... import aiotools from ... import aiotools
from ...languages import Languages
from . import BaseAuthService from . import BaseAuthService
@@ -102,10 +100,10 @@ class Plugin(BaseAuthService):
return True return True
except ldap.INVALID_CREDENTIALS: except ldap.INVALID_CREDENTIALS:
pass pass
except ldap.SERVER_DOWN as err: except ldap.SERVER_DOWN as ex:
get_logger().error(Languages().gettext("LDAP server is down: %s"), tools.efmt(err)) get_logger().error("LDAP server is down: %s", tools.efmt(ex))
except Exception as err: except Exception as ex:
get_logger().error(Languages().gettext("Unexpected LDAP error: %s"), tools.efmt(err)) get_logger().error("Unexpected LDAP error: %s", tools.efmt(ex))
finally: finally:
if conn is not None: if conn is not None:
try: try:

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