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]
commit = 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]+))?)?
serialize =
{major}.{minor}

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
/pkg/
/src/
/src/**/*.img
/src/tmp
/site/
/dist/
/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/*passwd /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 \
&& cp /src/testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \
&& 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/*passwd /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 \
&& cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \
&& $(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/*passwd /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 \
&& cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \
&& $(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/*passwd /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 \
&& cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \
&& $(if $(CMD),$(CMD),python -m kvmd.apps.vnc --run) \
@@ -285,10 +285,12 @@ run-stage-0:
run-build-dev:
$(DOCKER) buildx build -t registry.cn-hangzhou.aliyuncs.com/silentwind/kvmd:dev \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--build-arg CACHEBUST=$(date +%s) \
-f build/Dockerfile . \
--push
$(DOCKER) buildx build -t silentwind0/kvmd:dev \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--build-arg CACHEBUST=$(date +%s) \
-f build/Dockerfile . \
--push
@@ -296,11 +298,13 @@ run-build-release:
$(DOCKER) buildx build -t registry.cn-hangzhou.aliyuncs.com/silentwind/kvmd \
--progress plain \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--build-arg CACHEBUST=$(date +%s) \
-f build/Dockerfile . \
--push
$(DOCKER) buildx build -t silentwind0/kvmd \
--progress plain \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--build-arg CACHEBUST=$(date +%s) \
-f build/Dockerfile . \
--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/*passwd /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 \
&& mkdir -p /etc/kvmd/override.d \
&& 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)
done
pkgbase=kvmd
pkgver=4.3
pkgver=4.20
pkgrel=1
pkgdesc="The main PiKVM daemon"
url="https://github.com/pikvm/kvmd"
@@ -77,6 +77,8 @@ depends=(
python-ldap
python-zstandard
python-mako
python-luma-oled
python-pyusb
"libgpiod>=2.1"
freetype2
"v4l-utils>=1.22.1-1"
@@ -91,7 +93,7 @@ depends=(
certbot
platform-io-access
raspberrypi-utils
"ustreamer>=6.11"
"ustreamer>=6.16"
# Systemd UDEV bug
"systemd>=248.3-2"
@@ -131,6 +133,7 @@ conflicts=(
python-aiohttp-pikvm
platformio
avrdude-pikvm
kvmd-oled
)
makedepends=(
python-setuptools
@@ -206,7 +209,7 @@ for _variant in "${_variants[@]}"; do
cd \"kvmd-\$pkgver\"
pkgdesc=\"PiKVM platform configs - $_platform for $_board\"
depends=(kvmd=$pkgver-$pkgrel \"linux-rpi-pikvm>=6.6.21-3\")
depends=(kvmd=$pkgver-$pkgrel \"linux-rpi-pikvm>=6.6.45-1\" \"raspberrypi-bootloader-pikvm>=20240818-1\")
backup=(
etc/sysctl.d/99-kvmd.conf

View File

@@ -6,17 +6,54 @@
One-KVM 是基于廉价计算机硬件和 [PiKVM]((https://github.com/pikvm/pikvm)) 软件二次开发的 BIOS 级远程控制项目。可以实现远程管理服务器或工作站,无需在被控机安装软件调整设置,实现无侵入式控制,适用范围广泛。
使用文档:[https://one-kvm.mofeng.run](https://one-kvm.mofeng.run)
演示网站:[https://kvmd-demo.mofeng.run](https://kvmd-demo.mofeng.run)
![image-20240926220156381](https://github.com/user-attachments/assets/a7848bca-e43c-434e-b812-27a45fad7910)
### 软件功能
表格仅为 One-KVM 与其他基于 PiKVM 的项目的功能对比,无不良导向,如有错漏请联系更正。
| 功能 | 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 版本可以使用 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可以使用如下部署命令
```bash
@@ -27,29 +64,34 @@ sudo docker run --name kvmd -itd --privileged=true \
silentwind0/kvmd
```
如果使用 CH9329可以使用如下部署命令
如果使用 CH9329 作为虚拟 HID,可以使用如下部署命令:
```bash
sudo docker run --name kvmd -itd \
--device /dev/video0:/dev/video0 \
--device /dev/ttyUSB0:/dev/ttyUSB0 \
--device /dev/snd:/dev/snd \
-p 8080:8080 -p 4430:4430 -p 5900:5900 -p 623:623 \
silentwind0/kvmd
```
部署完成访问 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
[斐斐の](https://www.mmuaa.com/)
爱发电用户_09451
超高校级的錆鱼
爱发电用户_08cff
guoke
mgt
......
</details>
本项目使用了下列开源项目:
1. [pikvm/pikvm: Open and inexpensive DIY IP-KVM based on Raspberry Pi (github.com)](https://github.com/pikvm/pikvm)
**状态**
### 项目状态
[![Star History Chart](https://api.star-history.com/svg?repos=mofeng-git/One-KVM&type=Date)](https://star-history.com/#mofeng-git/One-KVM&Date)

View File

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

View File

@@ -18,12 +18,13 @@ ENV TZ=Asia/Shanghai
RUN cp /tmp/lib/* /lib/*-linux-*/ \
&& pip install --no-cache-dir --root-user-action=ignore --disable-pip-version-check /tmp/wheel/*.whl \
&& pip install --no-cache-dir --root-user-action=ignore --disable-pip-version-check pyfatfs \
&& rm -rf /tmp/lib /tmp/wheel
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/' /etc/apt/sources.list.d/debian.sources \
&& apt-get update \
&& apt-get install -y --no-install-recommends libxkbcommon-x11-0 nginx tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim iptables sudo curl kmod \
libmicrohttpd12 libjansson4 libssl3 libsofia-sip-ua0 libglib2.0-0 libopus0 libogg0 libcurl4 libconfig9 libusrsctp2 libwebsockets17 libnss3 libasound2 \
libmicrohttpd12 libjansson4 libssl3 libsofia-sip-ua0 libglib2.0-0 libopus0 libogg0 libcurl4 libconfig9 libusrsctp2 libwebsockets17 libnss3 libasound2 nano \
&& rm -rf /var/lib/apt/lists/*
RUN if [ ${TARGETARCH} = arm ]; then ARCH=armhf; elif [ ${TARGETARCH} = arm64 ]; then ARCH=aarch64; elif [ ${TARGETARCH} = amd64 ]; then ARCH=x86_64; fi \
@@ -31,11 +32,11 @@ RUN if [ ${TARGETARCH} = arm ]; then ARCH=armhf; elif [ ${TARGETARCH} = arm64 ];
&& chmod +x /usr/local/bin/ttyd \
&& adduser kvmd --gecos "" --disabled-password \
&& ln -sf /usr/share/tesseract-ocr/*/tessdata /usr/share/tessdata \
&& mkdir -p /etc/kvmd_backup/override.d /var/lib/kvmd/msd/images /var/lib/kvmd/msd/meta /var/lib/kvmd/pst/data /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
COPY testenv/fakes/vcgencmd /usr/bin/
COPY testenv/fakes/vcgencmd scripts/kvmd* /usr/bin/
COPY extras/ /usr/share/kvmd/extras/
COPY web/ /usr/share/kvmd/web/
COPY scripts/kvmd-gencert /usr/share/kvmd/

View File

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

View File

@@ -100,15 +100,36 @@ EOF
echo -e "${GREEN}One-KVM OTG is enabled.${NC}"
sed -i "s/ch9329/otg/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
#if [ ! -z "$SHUTDOWNPIN" ! -z "$REBOOTPIN" ]; then
if [ ! -z "$VIDEONUM" ]; then
sed -i "s/\/dev\/video0/\/dev\/video$VIDEONUM/g" /etc/kvmd/override.yaml \
&& sed -i "s/\/dev\/video0/\/dev\/video$VIDEONUM/g" /etc/kvmd/janus/janus.plugin.ustreamer.jcfg \
&& echo -e "${GREEN}One-KVM video device is set to /dev/video$VIDEONUM.${NC}"
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
if [ ! -z "$USERNAME" ] && [ ! -z "$PASSWORD" ]; then
python -m kvmd.apps.htpasswd del admin \
@@ -117,18 +138,18 @@ EOF
&& echo "$USERNAME:$PASSWORD -> $USERNAME:$PASSWORD" > /etc/kvmd/ipmipasswd \
|| echo -e "${RED}One-KVM htpasswd init failed.${NC}"
else
echo -e "${YELLOW} USERNAME and PASSWORD environment variables 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
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
if [ ! -z "$VIDEOFORMAT" ]; then
sed -i "s/format=mjpeg/format=$VIDFORMAT/g" /etc/kvmd/override.yaml \
&& echo -e "${GREEN}One-KVM input video format is set to $VIDFORMAT.${NC}"
fi
touch /etc/kvmd/.init_flag
fi
#Trying usb_gadget
if [ "$OTG" == "1" ]; then
echo "Trying OTG Port..."
@@ -138,6 +159,7 @@ if [ "$OTG" == "1" ]; then
&& ln -s /dev/hidg1 /dev/kvmd-hid-mouse \
&& ln -s /dev/hidg0 /dev/kvmd-hid-keyboard \
|| echo -e "${RED}OTG Port mount failed.${NC}"
ln -s /dev/hidg2 /dev/kvmd-hid-mouse-alt
fi
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: {
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:
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:
enabled: true
server:
unix_mode: 0666
access_log_format: '[%P / %{X-Real-IP}i] ''%r'' => 响应:%s大小%b来源''%{Referer}i'';用户代理:''%{User-Agent}i'''
atx:
type: disabled
hid:
type: ch9329
device: /dev/ttyUSB0
speed: 9600
read_timeout: 0.3
jiggler:
active: false
@@ -23,6 +21,9 @@ kvmd:
msd:
#type: otg
remount_cmd: /bin/true
msd_path: /var/lib/kvmd/msd
normalfiles_path: NormalFiles
normalfiles_size: 256
ocr:
langs:
@@ -31,7 +32,7 @@ kvmd:
streamer:
resolution:
default: 1280x720
default: 1920x1080
forever: true
@@ -40,7 +41,7 @@ kvmd:
max: 60
h264_bitrate:
default: 2000
default: 8000
cmd:
- "/usr/bin/ustreamer"
@@ -159,9 +160,4 @@ nginx:
http:
port: 8080
https:
port: 4430
languages:
console: zh
web: zh
port: 4430

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
Wants=dev-kvmd\x2dvideo.device
After=dev-kvmd\x2dvideo.device systemd-modules-load.service
Before=kvmd.service kvmd-pass.service
Before=kvmd.service
[Service]
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
RemainAfterExit=true

View File

@@ -19,6 +19,7 @@ m kvmd gpio
m kvmd uucp
m kvmd spi
m kvmd systemd-journal
m kvmd kvmd-pst
m kvmd-pst kvmd

View File

@@ -27,7 +27,8 @@ post_upgrade() {
done
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
echo "==> Generating KVMD-Nginx certificate ..."
@@ -92,6 +93,15 @@ disable_overscan=1
EOF
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
# shellcheck disable=SC2015,SC2166
[ ! -f /etc/motd -a -f /etc/motd.pacsave ] && mv /etc/motd.pacsave /etc/motd || true

View File

@@ -20,4 +20,4 @@
# ========================================================================== #
__version__ = "4.3"
__version__ = "4.20"

View File

@@ -83,9 +83,9 @@ class AioReader: # pylint: disable=too-many-instance-attributes
self.__path,
consumer=self.__consumer,
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 = {
pin: _DebouncedValue(
initial=bool(value.value),
@@ -93,14 +93,14 @@ class AioReader: # pylint: disable=too-many-instance-attributes
notifier=self.__notifier,
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)
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] = {}
for event in line_request.read_edge_events():
for event in line_req.read_edge_events():
(pin, value) = self.__parse_event(event)
new[pin] = value
for (pin, value) in new.items():
@@ -110,7 +110,7 @@ class AioReader: # pylint: disable=too-many-instance-attributes
# Размер буфера ядра - 16 эвентов на линии. При превышении этого числа,
# новые эвенты потеряются. Это не баг, это фича, как мне объяснили в 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
def __parse_event(self, event: gpiod.EdgeEvent) -> tuple[int, bool]:

View File

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

View File

@@ -59,14 +59,25 @@ def queue_get_last_sync( # pylint: disable=invalid-name
# =====
class AioProcessNotifier:
def __init__(self) -> None:
self.__queue: "multiprocessing.Queue[None]" = multiprocessing.Queue()
self.__queue: "multiprocessing.Queue[int]" = multiprocessing.Queue()
def notify(self) -> None:
self.__queue.put_nowait(None)
def notify(self, mask: int=0) -> None:
self.__queue.put_nowait(mask)
async def wait(self) -> None:
while not (await queue_get_last(self.__queue, 0.1))[0]:
pass
async def wait(self) -> int:
while True:
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 logging
from .languages import Languages
import setproctitle
from .logging import get_logger
@@ -86,7 +85,7 @@ async def log_stdout_infinite(proc: asyncio.subprocess.Process, logger: logging.
else:
empty += 1
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
@@ -101,14 +100,14 @@ async def kill_process(proc: asyncio.subprocess.Process, wait: float, logger: lo
if proc.returncode is not None:
raise
await proc.wait()
logger.info(Languages().gettext("Process killed: retcode=%d"), proc.returncode)
logger.info("Process killed: retcode=%d", proc.returncode)
except asyncio.CancelledError:
pass
except Exception:
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:
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:
@@ -117,7 +116,7 @@ def rename_process(suffix: str, prefix: str="kvmd") -> None:
def settle(name: str, suffix: str, prefix: str="kvmd") -> logging.Logger:
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()
rename_process(suffix, prefix)
return logger

View File

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

View File

@@ -31,12 +31,8 @@ import pygments
import pygments.lexers.data
import pygments.formatters
from gettext import translation
from .. import tools
from ..mouse import MouseRange
from ..plugins import UnknownPluginError
from ..plugins.auth import get_auth_service_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_ethernet
from ..validators.languages import valid_languages
from ..languages import Languages
# =====
def init(
@@ -129,7 +122,6 @@ def init(
add_help=add_help,
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("-c", "--config", default="/etc/kvmd/main.yaml", type=valid_abs_file,
help="Set config file path", metavar="<file>")
parser.add_argument("-o", "--set-options", default=[], nargs="+",
@@ -153,18 +145,9 @@ def init(
))
raise SystemExit()
config = _init_config(options.config, options.set_options, **load)
logging.captureWarnings(True)
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:
logging.getLogger().handlers[0].setFormatter(logging.Formatter(
"-- {levelname:>7} -- {message}",
@@ -173,7 +156,10 @@ def init(
if check_run and not options.run:
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)
@@ -183,8 +169,8 @@ def _init_config(config_path: str, override_options: list[str], **load_flags: bo
config_path = os.path.expanduser(config_path)
try:
raw_config: dict = load_yaml_file(config_path)
except Exception as err:
raise SystemExit(f"ConfigError: Can't read config file {config_path!r}:\n{tools.efmt(err)}")
except Exception as ex:
raise SystemExit(f"ConfigError: Can't read config file {config_path!r}:\n{tools.efmt(ex)}")
if not isinstance(raw_config, dict):
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)
return config
except (ConfigError, UnknownPluginError) as err:
raise SystemExit(f"ConfigError: {err}")
except (ConfigError, UnknownPluginError) as ex:
raise SystemExit(f"ConfigError: {ex}")
def _patch_raw(raw_config: dict) -> None: # pylint: disable=too-many-branches
@@ -419,19 +405,7 @@ def _get_config_scheme() -> dict:
"hid": {
"type": Option("", type=valid_stripped_string_not_empty),
"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),
},
"keymap": Option("/usr/share/kvmd/keymaps/en-us", type=valid_abs_file),
# Dynamic content
},
@@ -693,9 +667,10 @@ def _get_config_scheme() -> dict:
},
"vnc": {
"desired_fps": Option(30, type=valid_stream_fps),
"mouse_output": Option("usb", type=valid_hid_mouse_output),
"keymap": Option("/usr/share/kvmd/keymaps/en-us", type=valid_abs_file),
"desired_fps": Option(30, type=valid_stream_fps),
"mouse_output": Option("usb", type=valid_hid_mouse_output),
"keymap": Option("/usr/share/kvmd/keymaps/en-us", type=valid_abs_file),
"allow_cut_after": Option(3.0, type=valid_float_f0),
"server": {
"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),
"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 os
import re
import dataclasses
import contextlib
import subprocess
import argparse
import time
from typing import IO
from typing import Generator
from typing import Callable
from ...validators.basic import valid_bool
from ...validators.basic import valid_int_f0
from ...edid import EdidNoBlockError
from ...edid import Edid
# 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:
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))
def _print_edid(edid: _Edid) -> None:
def _print_edid(edid: Edid) -> None:
for (key, get, fmt) in [
("Manufacturer ID:", edid.get_mfc_id, str),
("Product ID: ", edid.get_product_id, _make_format_hex(2)),
@@ -294,7 +57,7 @@ def _print_edid(edid: _Edid) -> None:
]:
try:
print(key, fmt(get()), file=sys.stderr) # type: ignore
except NoBlockError:
except EdidNoBlockError:
pass
@@ -348,12 +111,12 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra
help="Presets directory", metavar="<dir>")
options = parser.parse_args(argv[1:])
base: (_Edid | None) = None
base: (Edid | None) = None
if options.import_preset:
imp = options.import_preset
if "." in imp:
(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}"
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.edid_path = options.imp
edid = _Edid(options.edid_path)
edid = Edid.from_file(options.edid_path)
changed = False
for cmd in dir(_Edid):
for cmd in dir(Edid):
if cmd.startswith("set_"):
value = getattr(options, cmd)
if value is None and base is not None:
try:
value = getattr(base, cmd.replace("set_", "get_"))()
except NoBlockError:
except EdidNoBlockError:
pass
if value is not None:
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",
f"--device={options.device_path}",
f"--set-edid=file={orig_edid_path}",
"--fix-edid-checksums",
"--info-edid",
], stdout=sys.stderr, check=True)
except subprocess.CalledProcessError as err:
raise SystemExit(str(err))
except subprocess.CalledProcessError as ex:
raise SystemExit(str(ex))

View File

@@ -155,5 +155,5 @@ def main(argv: (list[str] | None)=None) -> None:
options = parser.parse_args(argv[1:])
try:
options.cmd(config, options)
except ValidatorError as err:
raise SystemExit(str(err))
except ValidatorError as ex:
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:
# Parameter 'request' has been renamed to 'req' in overriding method
handler = {
(6, 1): (lambda _, session: self.send_device_id(session)), # Get device ID
(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]
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 = {
0: "off_hard",
1: "on",
3: "reset_hard",
5: "off",
}.get(request["data"][0], "")
}.get(req["data"][0], "")
if action:
if not self.__make_request(session, f"atx.switch_power({action})", "atx.switch_power", action=action):
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:
func = functools.reduce(getattr, func_path.split("."), kvmd_session)
return (await func(**kwargs))
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
logger.error("[%s]: Can't perform request %s: %s", session.sockaddr[0], name, err)
except (aiohttp.ClientError, asyncio.TimeoutError) as ex:
logger.error("[%s]: Can't perform request %s: %s", session.sockaddr[0], name, ex)
raise
return aiotools.run_sync(runner())

View File

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

View File

@@ -4,6 +4,7 @@ import ipaddress
import struct
import secrets
import dataclasses
import enum
from ... import tools
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)
class StunAddress:
ip: str
class StunInfo:
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
@dataclasses.dataclass(frozen=True)
class StunResponse:
ok: bool
ext: (StunAddress | None) = dataclasses.field(default=None)
src: (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"
class _StunResponse:
ok: bool
ext: (_StunAddress | None) = dataclasses.field(default=None)
src: (_StunAddress | None) = dataclasses.field(default=None)
changed: (_StunAddress | None) = dataclasses.field(default=None)
# =====
@@ -50,58 +61,94 @@ class Stun:
retries_delay: float,
) -> None:
self.host = host
self.port = port
self.__host = host
self.__port = port
self.__timeout = timeout
self.__retries = retries
self.__retries_delay = retries_delay
self.__stun_ip = ""
self.__sock: (socket.socket | None) = None
async def get_info(self, src_ip: str, src_port: int) -> tuple[str, str]:
(family, _, _, _, addr) = socket.getaddrinfo(src_ip, src_port, type=socket.SOCK_DGRAM)[0]
async def get_info(self, src_ip: str, src_port: int) -> StunInfo:
nat_type = StunNatType.ERROR
ext_ip = ""
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.settimeout(self.__timeout)
self.__sock.bind(addr)
(nat_type, response) = await self.__get_nat_type(src_ip)
return (nat_type, (response.ext.ip if response.ext is not None else ""))
self.__sock.bind(src_addr)
(nat_type, resp) = await self.__get_nat_type(src_ip)
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:
self.__sock = None
async def __get_nat_type(self, src_ip: str) -> tuple[str, StunResponse]: # pylint: disable=too-many-return-statements
first = await self.__make_request("First probe")
return StunInfo(
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:
return (StunNatType.BLOCKED, first)
request = struct.pack(">HHI", 0x0003, 0x0004, 0x00000006) # Change-Request
response = await self.__make_request("Change request [ext_ip == src_ip]", request)
req = struct.pack(">HHI", 0x0003, 0x0004, 0x00000006) # Change-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 response.ok:
return (StunNatType.OPEN_INTERNET, response)
return (StunNatType.SYMMETRIC_UDP_FW, response)
if resp.ok:
return (StunNatType.OPEN_INTERNET, resp)
return (StunNatType.SYMMETRIC_UDP_FW, resp)
if response.ok:
return (StunNatType.FULL_CONE_NAT, response)
if resp.ok:
return (StunNatType.FULL_CONE_NAT, resp)
if first.changed is None:
raise RuntimeError(f"Changed addr is None: {first}")
response = await self.__make_request("Change request [ext_ip != src_ip]", addr=first.changed)
if not response.ok:
return (StunNatType.CHANGED_ADDR_ERROR, response)
resp = await self.__make_request("Change request [ext_ip != src_ip]", first.changed, b"")
if not resp.ok:
return (StunNatType.CHANGED_ADDR_ERROR, resp)
if response.ext == first.ext:
request = struct.pack(">HHI", 0x0003, 0x0004, 0x00000002)
response = await self.__make_request("Change port", request, addr=first.changed.ip)
if response.ok:
return (StunNatType.RESTRICTED_NAT, response)
return (StunNatType.RESTRICTED_PORT_NAT, response)
if resp.ext == first.ext:
req = struct.pack(">HHI", 0x0003, 0x0004, 0x00000002)
resp = await self.__make_request("Change port", first.changed.ip, req)
if resp.ok:
return (StunNatType.RESTRICTED_NAT, resp)
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
# The first 4 bytes of the response are the Type (2) and Length (2)
# The 5th byte is Reserved
@@ -111,32 +158,29 @@ class Stun:
# More info at: https://tools.ietf.org/html/rfc3489#section-11.2.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)
elif isinstance(addr, str):
addr_t = (addr, self.port)
else:
assert addr is None
addr_t = (self.host, self.port)
else: # str
addr_t = (addr, self.__port)
# https://datatracker.ietf.org/doc/html/rfc5389#section-6
trans_id = b"\x21\x12\xA4\x42" + secrets.token_bytes(12)
(response, error) = (b"", "")
(resp, error) = (b"", "")
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:
break
await asyncio.sleep(self.__retries_delay)
if error:
get_logger(0).error("%s: Can't perform STUN request after %d retries; last error: %s",
ctx, self.__retries, error)
return StunResponse(ok=False)
return _StunResponse(ok=False)
parsed: dict[str, StunAddress] = {}
parsed: dict[str, _StunAddress] = {}
offset = 0
remaining = len(response)
remaining = len(resp)
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
field = {
0x0001: "ext", # MAPPED-ADDRESS
@@ -145,40 +189,40 @@ class Stun:
0x0005: "changed", # CHANGED-ADDRESS
}.get(attr_type)
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
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
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:
await aiotools.run_async(self.__sock.sendto, request, addr)
except Exception as err:
return (b"", f"Send error: {tools.efmt(err)}")
await aiotools.run_async(self.__sock.sendto, req, addr)
except Exception as ex:
return (b"", f"Send error: {tools.efmt(ex)}")
try:
response = (await aiotools.run_async(self.__sock.recvfrom, 2048))[0]
except Exception as err:
return (b"", f"Recv error: {tools.efmt(err)}")
resp = (await aiotools.run_async(self.__sock.recvfrom, 2048))[0]
except Exception as ex:
return (b"", f"Recv error: {tools.efmt(ex)}")
(response_type, payload_len) = struct.unpack(">HH", response[:4])
if response_type != 0x0101:
return (b"", f"Invalid response type: {response_type:#06x}")
if trans_id != response[4:20]:
(resp_type, payload_len) = struct.unpack(">HH", resp[:4])
if resp_type != 0x0101:
return (b"", f"Invalid response type: {resp_type:#06x}")
if trans_id != resp[4:20]:
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]
port = struct.unpack(">H", self.__trans_xor(data[2:4], trans_id))[0]
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:
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}")
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.msd import get_msd_class
from ...languages import Languages
from .. import init
from .auth import AuthManager
@@ -58,7 +56,7 @@ def main(argv: (list[str] | None)=None) -> None:
if config.kvmd.msd.type == "otg":
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":
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,
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,
).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())
@exposed_http("POST", "/atx/power")
async def __power_handler(self, request: Request) -> Response:
action = valid_atx_power_action(request.query.get("action"))
wait = valid_bool(request.query.get("wait", False))
async def __power_handler(self, req: Request) -> Response:
action = valid_atx_power_action(req.query.get("action"))
wait = valid_bool(req.query.get("wait", False))
await ({
"on": self.__atx.power_on,
"off": self.__atx.power_off,
@@ -57,9 +57,9 @@ class AtxApi:
return make_json_response()
@exposed_http("POST", "/atx/click")
async def __click_handler(self, request: Request) -> Response:
button = valid_atx_button(request.query.get("button"))
wait = valid_bool(request.query.get("wait", False))
async def __click_handler(self, req: Request) -> Response:
button = valid_atx_button(req.query.get("button"))
wait = valid_bool(req.query.get("wait", False))
await ({
"power": self.__atx.click_power,
"power_long": self.__atx.click_power_long,

View File

@@ -43,34 +43,34 @@ from ..auth import AuthManager
_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):
user = request.headers.get("X-KVMD-User", "")
user = req.headers.get("X-KVMD-User", "")
if user:
user = valid_user(user)
passwd = request.headers.get("X-KVMD-Passwd", "")
set_request_auth_info(request, f"{user} (xhdr)")
passwd = req.headers.get("X-KVMD-Passwd", "")
set_request_auth_info(req, f"{user} (xhdr)")
if not (await auth_manager.authorize(user, valid_passwd(passwd))):
raise ForbiddenError()
return
token = request.cookies.get(_COOKIE_AUTH_TOKEN, "")
token = req.cookies.get(_COOKIE_AUTH_TOKEN, "")
if token:
user = auth_manager.check(valid_auth_token(token)) # type: ignore
if not user:
set_request_auth_info(request, "- (token)")
set_request_auth_info(req, "- (token)")
raise ForbiddenError()
set_request_auth_info(request, f"{user} (token)")
set_request_auth_info(req, f"{user} (token)")
return
basic_auth = request.headers.get("Authorization", "")
basic_auth = req.headers.get("Authorization", "")
if basic_auth and basic_auth[:6].lower() == "basic ":
try:
(user, passwd) = base64.b64decode(basic_auth[6:]).decode("utf-8").split(":")
except Exception:
raise UnauthorizedError()
user = valid_user(user)
set_request_auth_info(request, f"{user} (basic)")
set_request_auth_info(req, f"{user} (basic)")
if not (await auth_manager.authorize(user, valid_passwd(passwd))):
raise ForbiddenError()
return
@@ -85,9 +85,9 @@ class AuthApi:
# =====
@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():
credentials = await request.post()
credentials = await req.post()
token = await self.__auth_manager.login(
user=valid_user(credentials.get("user", "")),
passwd=valid_passwd(credentials.get("passwd", "")),
@@ -98,9 +98,9 @@ class AuthApi:
return make_json_response()
@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():
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)
return make_json_response()

View File

@@ -55,10 +55,9 @@ class ExportApi:
@async_lru.alru_cache(maxsize=1, ttl=5)
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.__info_manager.get_submanager("hw").get_state(),
self.__info_manager.get_submanager("fan").get_state(),
self.__info_manager.get_state(["hw", "fan"]),
self.__user_gpio.get_state(),
])
rows: list[str] = []
@@ -72,8 +71,8 @@ class ExportApi:
for key in ["online", "state"]:
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, fan_state, "pikvm_fan")
self.__append_prometheus_rows(rows, info_state["hw"]["health"], "pikvm_hw") # type: ignore
self.__append_prometheus_rows(rows, info_state["fan"], "pikvm_fan")
return "\n".join(rows)

View File

@@ -25,13 +25,12 @@ import stat
import functools
import struct
from typing import Iterable
from typing import Callable
from aiohttp.web import Request
from aiohttp.web import Response
from ....mouse import MouseRange
from ....keyboard.keysym import build_symmap
from ....keyboard.printer import text_to_web_keys
@@ -59,12 +58,7 @@ class HidApi:
def __init__(
self,
hid: BaseHid,
keymap_path: str,
ignore_keys: list[str],
mouse_x_range: tuple[int, int],
mouse_y_range: tuple[int, int],
) -> None:
self.__hid = hid
@@ -73,11 +67,6 @@ class HidApi:
self.__default_keymap_name = os.path.basename(keymap_path)
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")
@@ -85,22 +74,22 @@ class HidApi:
return make_json_response(await self.__hid.get_state())
@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 = {
key: validator(request.query.get(key))
key: validator(req.query.get(key))
for (key, validator) in [
("keyboard_output", valid_hid_keyboard_output),
("mouse_output", valid_hid_mouse_output),
("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
return make_json_response()
@exposed_http("POST", "/hid/set_connected")
async def __set_connected_handler(self, request: Request) -> Response:
self.__hid.set_connected(valid_bool(request.query.get("connected")))
async def __set_connected_handler(self, req: Request) -> Response:
self.__hid.set_connected(valid_bool(req.query.get("connected")))
return make_json_response()
@exposed_http("POST", "/hid/reset")
@@ -128,13 +117,13 @@ class HidApi:
return make_json_response(await self.get_keymaps())
@exposed_http("POST", "/hid/print")
async def __print_handler(self, request: Request) -> Response:
text = await request.text()
limit = int(valid_int_f0(request.query.get("limit", 1024)))
async def __print_handler(self, req: Request) -> Response:
text = await req.text()
limit = int(valid_int_f0(req.query.get("limit", 1024)))
if limit > 0:
text = text[:limit]
symmap = self.__ensure_symmap(request.query.get("keymap", self.__default_keymap_name))
self.__hid.send_key_events(text_to_web_keys(text, symmap))
symmap = self.__ensure_symmap(req.query.get("keymap", self.__default_keymap_name))
self.__hid.send_key_events(text_to_web_keys(text, symmap), no_ignore_keys=True)
return make_json_response()
def __ensure_symmap(self, keymap_name: str) -> dict[int, dict[int, str]]:
@@ -162,8 +151,7 @@ class HidApi:
state = valid_bool(data[0])
except Exception:
return
if key not in self.__ignore_keys:
self.__hid.send_key_events([(key, state)])
self.__hid.send_key_event(key, state)
@exposed_ws(2)
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)
except Exception:
return
self.__send_mouse_move_event(to_x, to_y)
self.__hid.send_mouse_move_event(to_x, to_y)
@exposed_ws(4)
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)
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:
squash = valid_bool(data[0])
data = data[1:]
@@ -202,7 +190,7 @@ class HidApi:
deltas.append((valid_hid_mouse_delta(delta_x), valid_hid_mouse_delta(delta_y)))
except Exception:
return
self.__send_mouse_delta_event(deltas, squash, handler)
handler(deltas, squash)
# =====
@@ -213,8 +201,7 @@ class HidApi:
state = valid_bool(event["state"])
except Exception:
return
if key not in self.__ignore_keys:
self.__hid.send_key_events([(key, state)])
self.__hid.send_key_event(key, state)
@exposed_ws("mouse_button")
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"])
except Exception:
return
self.__send_mouse_move_event(to_x, to_y)
self.__hid.send_mouse_move_event(to_x, to_y)
@exposed_ws("mouse_relative")
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")
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:
raw_delta = event["delta"]
deltas = [
@@ -252,26 +239,25 @@ class HidApi:
squash = valid_bool(event.get("squash", False))
except Exception:
return
self.__send_mouse_delta_event(deltas, squash, handler)
handler(deltas, squash)
# =====
@exposed_http("POST", "/hid/events/send_key")
async def __events_send_key_handler(self, request: Request) -> Response:
key = valid_hid_key(request.query.get("key"))
if key not in self.__ignore_keys:
if "state" in request.query:
state = valid_bool(request.query["state"])
self.__hid.send_key_events([(key, state)])
else:
self.__hid.send_key_events([(key, True), (key, False)])
async def __events_send_key_handler(self, req: Request) -> Response:
key = valid_hid_key(req.query.get("key"))
if "state" in req.query:
state = valid_bool(req.query["state"])
self.__hid.send_key_event(key, state)
else:
self.__hid.send_key_events([(key, True), (key, False)])
return make_json_response()
@exposed_http("POST", "/hid/events/send_mouse_button")
async def __events_send_mouse_button_handler(self, request: Request) -> Response:
button = valid_hid_mouse_button(request.query.get("button"))
if "state" in request.query:
state = valid_bool(request.query["state"])
async def __events_send_mouse_button_handler(self, req: Request) -> Response:
button = valid_hid_mouse_button(req.query.get("button"))
if "state" in req.query:
state = valid_bool(req.query["state"])
self.__hid.send_mouse_button_event(button, state)
else:
self.__hid.send_mouse_button_event(button, True)
@@ -279,52 +265,22 @@ class HidApi:
return make_json_response()
@exposed_http("POST", "/hid/events/send_mouse_move")
async def __events_send_mouse_move_handler(self, request: Request) -> Response:
to_x = valid_hid_mouse_move(request.query.get("to_x"))
to_y = valid_hid_mouse_move(request.query.get("to_y"))
self.__send_mouse_move_event(to_x, to_y)
async def __events_send_mouse_move_handler(self, req: Request) -> Response:
to_x = valid_hid_mouse_move(req.query.get("to_x"))
to_y = valid_hid_mouse_move(req.query.get("to_y"))
self.__hid.send_mouse_move_event(to_x, to_y)
return make_json_response()
@exposed_http("POST", "/hid/events/send_mouse_relative")
async def __events_send_mouse_relative_handler(self, request: Request) -> Response:
return self.__process_http_delta_event(request, self.__hid.send_mouse_relative_event)
async def __events_send_mouse_relative_handler(self, req: Request) -> Response:
return self.__process_http_delta_event(req, self.__hid.send_mouse_relative_event)
@exposed_http("POST", "/hid/events/send_mouse_wheel")
async def __events_send_mouse_wheel_handler(self, request: Request) -> Response:
return self.__process_http_delta_event(request, self.__hid.send_mouse_wheel_event)
async def __events_send_mouse_wheel_handler(self, req: Request) -> Response:
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:
delta_x = valid_hid_mouse_delta(request.query.get("delta_x"))
delta_y = valid_hid_mouse_delta(request.query.get("delta_y"))
def __process_http_delta_event(self, req: Request, handler: Callable[[int, int], None]) -> Response:
delta_x = valid_hid_mouse_delta(req.query.get("delta_x"))
delta_y = valid_hid_mouse_delta(req.query.get("delta_y"))
handler(delta_x, delta_y)
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 Response
@@ -41,17 +39,13 @@ class InfoApi:
# =====
@exposed_http("GET", "/info")
async def __common_state_handler(self, request: Request) -> Response:
fields = self.__valid_info_fields(request)
results = dict(zip(fields, await asyncio.gather(*[
self.__info_manager.get_submanager(field).get_state()
for field in fields
])))
return make_json_response(results)
async def __common_state_handler(self, req: Request) -> Response:
fields = self.__valid_info_fields(req)
return make_json_response(await self.__info_manager.get_state(fields))
def __valid_info_fields(self, request: Request) -> list[str]:
subs = self.__info_manager.get_subs()
def __valid_info_fields(self, req: Request) -> list[str]:
available = self.__info_manager.get_subs()
return sorted(valid_info_fields(
arg=request.query.get("fields", ",".join(subs)),
variants=subs,
) or subs)
arg=req.query.get("fields", ",".join(available)),
variants=available,
) or available)

View File

@@ -47,12 +47,12 @@ class LogApi:
# =====
@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:
raise LogReaderDisabledError()
seek = valid_log_seek(request.query.get("seek", 0))
follow = valid_bool(request.query.get("follow", False))
response = await start_streaming(request, "text/plain")
seek = valid_log_seek(req.query.get("seek", 0))
follow = valid_bool(req.query.get("follow", False))
response = await start_streaming(req, "text/plain")
try:
async for record in self.__log_reader.poll_log(seek, follow):
await response.write(("[%s %s] --- %s" % (

View File

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

View File

@@ -88,12 +88,12 @@ class RedfishApi:
@exposed_http("GET", "/redfish/v1/Systems/0")
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.__info_manager.get_submanager("meta").get_state(),
self.__info_manager.get_state(["meta"]),
])
try:
host = str(meta_state.get("server", {})["host"]) # type: ignore
host = str(info_state["meta"].get("server", {})["host"]) # type: ignore
except Exception:
host = ""
return make_json_response({
@@ -111,10 +111,10 @@ class RedfishApi:
}, wrap_result=False)
@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:
action = check_string_in_list(
arg=(await request.json())["ResetType"],
arg=(await req.json()).get("ResetType"),
name="Redfish ResetType",
variants=set(self.__actions),
lower=False,

View File

@@ -52,36 +52,36 @@ class StreamerApi:
return make_json_response(await self.__streamer.get_state())
@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(
save=valid_bool(request.query.get("save", False)),
load=valid_bool(request.query.get("load", False)),
allow_offline=valid_bool(request.query.get("allow_offline", False)),
save=valid_bool(req.query.get("save", False)),
load=valid_bool(req.query.get("load", False)),
allow_offline=valid_bool(req.query.get("allow_offline", False)),
)
if snapshot:
if valid_bool(request.query.get("ocr", False)):
if valid_bool(req.query.get("ocr", False)):
langs = self.__ocr.get_available_langs()
return Response(
body=(await self.__ocr.recognize(
data=snapshot.data,
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)),
name="OCR langs list",
),
left=int(valid_number(request.query.get("ocr_left", -1))),
top=int(valid_number(request.query.get("ocr_top", -1))),
right=int(valid_number(request.query.get("ocr_right", -1))),
bottom=int(valid_number(request.query.get("ocr_bottom", -1))),
left=int(valid_number(req.query.get("ocr_left", -1))),
top=int(valid_number(req.query.get("ocr_top", -1))),
right=int(valid_number(req.query.get("ocr_right", -1))),
bottom=int(valid_number(req.query.get("ocr_bottom", -1))),
)),
headers=dict(snapshot.headers),
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(
max_width=valid_int_f0(request.query.get("preview_max_width", 0)),
max_height=valid_int_f0(request.query.get("preview_max_height", 0)),
quality=valid_stream_quality(request.query.get("preview_quality", 80)),
max_width=valid_int_f0(req.query.get("preview_max_width", 0)),
max_height=valid_int_f0(req.query.get("preview_max_height", 0)),
quality=valid_stream_quality(req.query.get("preview_quality", 80)),
)
else:
data = snapshot.data
@@ -97,25 +97,6 @@ class StreamerApi:
self.__streamer.remove_snapshot()
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")
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")
async def __state_handler(self, _: Request) -> Response:
return make_json_response({
"model": (await self.__user_gpio.get_model()),
"state": (await self.__user_gpio.get_state()),
})
return make_json_response(await self.__user_gpio.get_state())
@exposed_http("POST", "/gpio/switch")
async def __switch_handler(self, request: Request) -> Response:
channel = valid_ugpio_channel(request.query.get("channel"))
state = valid_bool(request.query.get("state"))
wait = valid_bool(request.query.get("wait", False))
async def __switch_handler(self, req: Request) -> Response:
channel = valid_ugpio_channel(req.query.get("channel"))
state = valid_bool(req.query.get("state"))
wait = valid_bool(req.query.get("wait", False))
await self.__user_gpio.switch(channel, state, wait)
return make_json_response()
@exposed_http("POST", "/gpio/pulse")
async def __pulse_handler(self, request: Request) -> Response:
channel = valid_ugpio_channel(request.query.get("channel"))
delay = valid_float_f0(request.query.get("delay", 0.0))
wait = valid_bool(request.query.get("wait", False))
async def __pulse_handler(self, req: Request) -> Response:
channel = valid_ugpio_channel(req.query.get("channel"))
delay = valid_float_f0(req.query.get("delay", 0.0))
wait = valid_bool(req.query.get("wait", False))
await self.__user_gpio.pulse(channel, delay, wait)
return make_json_response()

View File

@@ -23,8 +23,6 @@
import secrets
import pyotp
from gettext import translation
from ...logging import get_logger
from ... import aiotools
@@ -34,7 +32,6 @@ from ...plugins.auth import get_auth_service_class
from ...htserver import HttpExposed
from ...languages import Languages
# =====
class AuthManager:
@@ -52,32 +49,31 @@ class AuthManager:
totp_secret_path: str,
) -> None:
self.gettext=Languages().gettext
self.__enabled = 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
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
if enabled:
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.__external_service: (BaseAuthService | None) = None
if enabled and external_type:
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.__tokens: dict[str, str] = {} # {token: user}
def is_auth_enabled(self) -> bool:
return self.__enabled
@@ -100,7 +96,7 @@ class AuthManager:
if secret:
code = passwd[-6:]
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
passwd = passwd[:-6]
@@ -111,9 +107,9 @@ class AuthManager:
ok = (await service.authorize(user, passwd))
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:
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
async def login(self, user: str, passwd: str) -> (str | None):
@@ -123,7 +119,7 @@ class AuthManager:
if (await self.authorize(user, passwd)):
token = self.__make_new_token()
self.__tokens[token] = user
get_logger().info(self.gettext("Logged in user %r"), user)
get_logger().info("Logged in user %r", user)
return token
else:
return None
@@ -133,7 +129,7 @@ class AuthManager:
token = secrets.token_hex(32)
if token not in self.__tokens:
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:
assert self.__enabled
@@ -144,7 +140,7 @@ class AuthManager:
if r_user == user:
count += 1
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):
assert self.__enabled

View File

@@ -20,6 +20,10 @@
# ========================================================================== #
import asyncio
from typing import AsyncGenerator
from ....yamlconf import Section
from .base import BaseInfoSubmanager
@@ -34,17 +38,59 @@ from .fan import FanInfoSubmanager
# =====
class InfoManager:
def __init__(self, config: Section) -> None:
self.__subs = {
self.__subs: dict[str, BaseInfoSubmanager] = {
"system": SystemInfoSubmanager(config.kvmd.streamer.cmd),
"auth": AuthInfoSubmanager(config.kvmd.auth.enabled),
"meta": MetaInfoSubmanager(config.kvmd.info.meta),
"auth": AuthInfoSubmanager(config.kvmd.auth.enabled),
"meta": MetaInfoSubmanager(config.kvmd.info.meta),
"extras": ExtrasInfoSubmanager(config),
"hw": HwInfoSubmanager(**config.kvmd.info.hw._unpack()),
"fan": FanInfoSubmanager(**config.kvmd.info.fan._unpack()),
"hw": HwInfoSubmanager(**config.kvmd.info.hw._unpack()),
"fan": FanInfoSubmanager(**config.kvmd.info.fan._unpack()),
}
self.__queue: "asyncio.Queue[tuple[str, (dict | None)]]" = asyncio.Queue()
def get_subs(self) -> set[str]:
return set(self.__subs)
def get_submanager(self, name: str) -> BaseInfoSubmanager:
return self.__subs[name]
async def get_state(self, fields: (list[str] | None)=None) -> dict:
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
@@ -27,6 +31,15 @@ from .base import BaseInfoSubmanager
class AuthInfoSubmanager(BaseInfoSubmanager):
def __init__(self, enabled: bool) -> None:
self.__enabled = enabled
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> dict:
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:
async def get_state(self) -> (dict | None):
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 asyncio
from typing import AsyncGenerator
from ....logging import get_logger
from ....yamlconf import Section
@@ -42,13 +44,15 @@ from .base import BaseInfoSubmanager
class ExtrasInfoSubmanager(BaseInfoSubmanager):
def __init__(self, global_config: Section) -> None:
self.__global_config = global_config
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> (dict | None):
try:
sui = sysunit.SystemdUnitInfo()
await sui.open()
except Exception as err:
get_logger(0).error("Can't open systemd bus to get extras state: %s", tools.efmt(err))
except Exception as ex:
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
try:
extras: dict[str, dict] = {}
@@ -66,6 +70,14 @@ class ExtrasInfoSubmanager(BaseInfoSubmanager):
if sui is not None:
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:
return os.path.join(self.__global_config.kvmd.info.extras, *parts)

View File

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

View File

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

View File

@@ -20,6 +20,8 @@
# ========================================================================== #
from typing import AsyncGenerator
from ....logging import get_logger
from ....yamlconf.loader import load_yaml_file
@@ -33,6 +35,7 @@ from .base import BaseInfoSubmanager
class MetaInfoSubmanager(BaseInfoSubmanager):
def __init__(self, meta_path: str) -> None:
self.__meta_path = meta_path
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> (dict | None):
try:
@@ -40,3 +43,11 @@ class MetaInfoSubmanager(BaseInfoSubmanager):
except Exception:
get_logger(0).exception("Can't parse meta")
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 platform
from typing import AsyncGenerator
from ....logging import get_logger
from .... import aiotools
from .... import aioproc
from .... import __version__
@@ -37,6 +40,7 @@ from .base import BaseInfoSubmanager
class SystemInfoSubmanager(BaseInfoSubmanager):
def __init__(self, streamer_cmd: list[str]) -> None:
self.__streamer_cmd = streamer_cmd
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> dict:
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:

View File

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

View File

@@ -37,6 +37,7 @@ from ctypes import c_void_p
from ctypes import c_char
from typing import Generator
from typing import AsyncGenerator
from PIL import ImageOps
from PIL import Image as PilImage
@@ -76,8 +77,8 @@ def _load_libtesseract() -> (ctypes.CDLL | None):
setattr(func, "restype", restype)
setattr(func, "argtypes", argtypes)
return lib
except Exception as err:
warnings.warn(f"Can't load libtesseract: {err}", RuntimeWarning)
except Exception as ex:
warnings.warn(f"Can't load libtesseract: {ex}", RuntimeWarning)
return None
@@ -107,9 +108,37 @@ class Ocr:
def __init__(self, data_dir_path: str, default_langs: list[str]) -> None:
self.__data_dir_path = data_dir_path
self.__default_langs = default_langs
self.__notifier = aiotools.AioNotifier()
def is_available(self) -> bool:
return bool(_libtess)
async def get_state(self) -> dict:
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]:
return list(self.__default_langs)

View File

@@ -20,8 +20,6 @@
# ========================================================================== #
import asyncio
import operator
import dataclasses
from typing import Callable
@@ -33,7 +31,7 @@ from aiohttp.web import Request
from aiohttp.web import Response
from aiohttp.web import WebSocketResponse
from ...languages import Languages
from ... import __version__
from ...logging import get_logger
@@ -86,68 +84,60 @@ from .api.redfish import RedfishApi
# =====
class StreamerQualityNotSupported(OperationError):
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):
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):
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
class _Subsystem:
name: str
sysprep: (Callable[[], None] | None)
systask: (Callable[[], Coroutine[Any, Any, None]] | None)
cleanup: (Callable[[], Coroutine[Any, Any, dict]] | None)
sources: dict[str, _SubsystemEventSource]
name: str
event_type: str
sysprep: (Callable[[], None] | None)
systask: (Callable[[], Coroutine[Any, Any, None]] | None)
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
def make(cls, obj: object, name: str, event_type: str="") -> "_Subsystem":
if isinstance(obj, BasePlugin):
name = f"{name} ({obj.get_plugin_name()})"
sub = _Subsystem(
return _Subsystem(
name=name,
event_type=event_type,
sysprep=getattr(obj, "sysprep", None),
systask=getattr(obj, "systask", 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
__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
self,
auth_manager: AuthManager,
@@ -163,9 +153,6 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
snapshoter: Snapshoter,
keymap_path: str,
ignore_keys: list[str],
mouse_x_range: tuple[int, int],
mouse_y_range: tuple[int, int],
stream_forever: bool,
) -> None:
@@ -179,8 +166,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
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.__streamer_api = StreamerApi(streamer, ocr) # Same hack to get ocr langs state
self.__hid_api = HidApi(hid, keymap_path) # Ugly hack to get keymaps state
self.__apis: list[object] = [
self,
AuthApi(auth_manager),
@@ -190,43 +176,38 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
self.__hid_api,
AtxApi(atx),
MsdApi(msd),
self.__streamer_api,
StreamerApi(streamer, ocr),
ExportApi(info_manager, atx, user_gpio),
RedfishApi(info_manager, atx),
]
self.__subsystems = [
_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(hid, "HID", "hid_state").add_source("hid_keymaps_state", self.__hid_api.get_keymaps, None),
_Subsystem.make(atx, "ATX", "atx_state"),
_Subsystem.make(msd, "MSD", "msd_state"),
_Subsystem.make(streamer, "Streamer", "streamer_state").add_source("streamer_ocr_state", self.__streamer_api.get_ocr, None),
*[
_Subsystem.make(info_manager.get_submanager(sub), f"Info manager ({sub})", f"info_{sub}_state",)
for sub in sorted(info_manager.get_subs())
],
_Subsystem.make(user_gpio, "User-GPIO", self.__EV_GPIO_STATE),
_Subsystem.make(hid, "HID", self.__EV_HID_STATE),
_Subsystem.make(atx, "ATX", self.__EV_ATX_STATE),
_Subsystem.make(msd, "MSD", self.__EV_MSD_STATE),
_Subsystem.make(streamer, "Streamer", self.__EV_STREAMER_STATE),
_Subsystem.make(ocr, "OCR", self.__EV_OCR_STATE),
_Subsystem.make(info_manager, "Info manager", self.__EV_INFO_STATE),
]
self.__streamer_notifier = aiotools.AioNotifier()
self.__reset_streamer = False
self.__new_streamer_params: dict = {}
self.gettext=Languages().gettext
# ===== STREAMER CONTROLLER
@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()
for (name, validator, exc_cls) in [
("quality", valid_stream_quality, StreamerQualityNotSupported),
("desired_fps", valid_stream_fps, None),
("resolution", valid_stream_resolution, StreamerResolutionNotSupported),
("quality", valid_stream_quality, StreamerQualityNotSupported),
("desired_fps", valid_stream_fps, None),
("resolution", valid_stream_resolution, StreamerResolutionNotSupported),
("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 name not in current_params:
assert exc_cls is not None, name
@@ -246,24 +227,22 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
# ===== WEBSOCKET
@exposed_http("GET", "/ws")
async def __ws_handler(self, request: Request) -> WebSocketResponse:
stream = valid_bool(request.query.get("stream", True))
async with self._ws_session(request, stream=stream) as ws:
states = [
(event_type, src.get_state())
for sub in self.__subsystems
for (event_type, src) in sub.sources.items()
if src.get_state
]
events = dict(zip(
map(operator.itemgetter(0), states),
await asyncio.gather(*map(operator.itemgetter(1), states)),
))
await asyncio.gather(*[
ws.send_event(event_type, events.pop(event_type))
for (event_type, _) in states
])
await ws.send_event("loop", {})
async def __ws_handler(self, req: Request) -> WebSocketResponse:
stream = valid_bool(req.query.get("stream", True))
legacy = valid_bool(req.query.get("legacy", True))
async with self._ws_session(req, stream=stream, legacy=legacy) as ws:
(major, minor) = __version__.split(".")
await ws.send_event("loop", {
"version": {
"major": int(major),
"minor": int(minor),
},
})
for sub in self.__subsystems:
if sub.event_type:
assert sub.trigger_state
await sub.trigger_state()
await self._broadcast_ws_event("hid_keymaps_state", await self.__hid_api.get_keymaps()) # FIXME
return (await self._ws_loop(ws))
@exposed_ws("ping")
@@ -279,40 +258,40 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
aioproc.rename_process("main")
super().run(**kwargs)
async def _check_request_auth(self, exposed: HttpExposed, request: Request) -> None:
await check_request_auth(self.__auth_manager, exposed, request)
async def _check_request_auth(self, exposed: HttpExposed, req: Request) -> None:
await check_request_auth(self.__auth_manager, exposed, req)
async def _init_app(self) -> None:
aiotools.create_deadly_task("Stream controller", self.__stream_controller())
for sub in self.__subsystems:
if sub.systask:
aiotools.create_deadly_task(sub.name, sub.systask())
for (event_type, src) in sub.sources.items():
if src.poll_state:
aiotools.create_deadly_task(f"{sub.name} [poller]", self.__poll_state(event_type, src.poll_state()))
if sub.event_type:
assert sub.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())
self._add_exposed(*self.__apis)
async def _on_shutdown(self) -> None:
logger = get_logger(0)
logger.info(self.gettext("Waiting short tasks ..."))
logger.info("Waiting 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()
logger.info(self.gettext("Disconnecting clients ..."))
logger.info("Disconnecting clients ...")
await self._close_all_wss()
logger.info(self.gettext("On-Shutdown complete"))
logger.info("On-Shutdown complete")
async def _on_cleanup(self) -> None:
logger = get_logger(0)
for sub in self.__subsystems:
if sub.cleanup:
logger.info(self.gettext("Cleaning up %s ..."), sub.name)
logger.info("Cleaning up %s ...", sub.name)
try:
await sub.cleanup() # type: ignore
except Exception:
logger.exception(self.gettext("Cleanup error on %s"), sub.name)
logger.info(self.gettext("On-Cleanup complete"))
logger.exception("Cleanup error on %s", sub.name)
logger.info("On-Cleanup complete")
async def _on_ws_opened(self) -> None:
self.__streamer_notifier.notify()
@@ -351,12 +330,67 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
prev = cur
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:
await self.__snapshoter.run(
is_live=self.__has_stream_clients,
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 asyncio
import asyncio.subprocess
import dataclasses
import functools
import copy
from typing import AsyncGenerator
from typing import Any
import aiohttp
from PIL import Image as PilImage
from ...languages import Languages
from ...logging import get_logger
from ...clients.streamer import StreamerSnapshot
from ...clients.streamer import HttpStreamerClient
from ...clients.streamer import HttpStreamerClientSession
from ... import tools
from ... import aiotools
from ... import aioproc
@@ -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:
__DESIRED_FPS = "desired_fps"
@@ -138,7 +103,7 @@ class _StreamerParams:
}
def get_limits(self) -> dict:
limits = dict(self.__limits)
limits = copy.deepcopy(self.__limits)
if self.__has_resolution:
limits[self.__AVAILABLE_RESOLUTIONS] = list(limits[self.__AVAILABLE_RESOLUTIONS])
return limits
@@ -172,6 +137,11 @@ class _StreamerParams:
class Streamer: # pylint: disable=too-many-instance-attributes
__ST_FULL = 0xFF
__ST_PARAMS = 0x01
__ST_STREAMER = 0x02
__ST_SNAPSHOT = 0x04
def __init__( # pylint: disable=too-many-arguments,too-many-locals
self,
@@ -205,7 +175,6 @@ class Streamer: # pylint: disable=too-many-instance-attributes
self.__state_poll = state_poll
self.__unix_path = unix_path
self.__timeout = timeout
self.__snapshot_timeout = snapshot_timeout
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_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.__notifier = aiotools.AioNotifier()
self.gettext=Languages().gettext
# =====
@aiotools.atomic_fg
@@ -242,15 +214,15 @@ class Streamer: # pylint: disable=too-many-instance-attributes
if not self.__stop_wip:
self.__stop_task.cancel()
await asyncio.gather(self.__stop_task, return_exceptions=True)
logger.info(self.gettext("Streamer stop cancelled"))
logger.info("Streamer stop cancelled")
return
else:
await asyncio.gather(self.__stop_task, return_exceptions=True)
if reset and self.__reset_delay > 0:
logger.info(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)
logger.info(self.gettext("Starting streamer ..."))
logger.info("Starting streamer ...")
await self.__inner_start()
@aiotools.atomic_fg
@@ -263,12 +235,12 @@ class Streamer: # pylint: disable=too-many-instance-attributes
if not self.__stop_wip:
self.__stop_task.cancel()
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()
else:
await asyncio.gather(self.__stop_task, return_exceptions=True)
else:
logger.info(self.gettext("Stopping streamer immediately ..."))
logger.info("Stopping streamer immediately ...")
await self.__inner_stop()
elif not self.__stop_task:
@@ -277,13 +249,13 @@ class Streamer: # pylint: disable=too-many-instance-attributes
try:
await asyncio.sleep(self.__shutdown_delay)
self.__stop_wip = True
logger.info(self.gettext("Stopping streamer after delay ..."))
logger.info("Stopping streamer after delay ...")
await self.__inner_stop()
finally:
self.__stop_task = None
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())
def is_working(self) -> bool:
@@ -294,6 +266,7 @@ class Streamer: # pylint: disable=too-many-instance-attributes
def set_params(self, params: dict) -> None:
assert not self.__streamer_task
self.__notifier.notify(self.__ST_PARAMS)
return self.__params.set_params(params)
def get_params(self) -> dict:
@@ -302,55 +275,80 @@ class Streamer: # pylint: disable=too-many-instance-attributes
# =====
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:
session = self.__ensure_http_session()
session = self.__ensure_client_session()
try:
async with session.get(self.__make_url("state")) as response:
htclient.raise_not_200(response)
streamer_state = (await response.json())["result"]
return (await session.get_state())
except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError):
pass
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:
snapshot = dataclasses.asdict(self.__snapshot)
del snapshot["headers"]
del snapshot["data"]
return {
"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
return {"saved": snapshot}
return {"saved": None}
# =====
@@ -358,43 +356,19 @@ class Streamer: # pylint: disable=too-many-instance-attributes
if load:
return self.__snapshot
logger = get_logger()
session = self.__ensure_http_session()
session = self.__ensure_client_session()
try:
async with session.get(
self.__make_url("snapshot"),
timeout=self.__snapshot_timeout,
) as response:
htclient.raise_not_200(response)
online = (response.headers["X-UStreamer-Online"] == "true")
if online or allow_offline:
snapshot = StreamerSnapshot(
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))
snapshot = await session.take_snapshot(self.__snapshot_timeout)
if snapshot.online or allow_offline:
if save:
self.__snapshot = snapshot
self.__notifier.notify(self.__ST_SNAPSHOT)
return snapshot
logger.error("Stream is offline, no signal or so")
except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError) as ex:
logger.error("Can't connect to streamer: %s", tools.efmt(ex))
except Exception:
logger.exception(self.gettext("Invalid streamer response from /snapshot"))
logger.exception("Invalid streamer response from /snapshot")
return None
def remove_snapshot(self) -> None:
@@ -405,25 +379,14 @@ class Streamer: # pylint: disable=too-many-instance-attributes
@aiotools.atomic_fg
async def cleanup(self) -> None:
await self.ensure_stop(immediately=True)
if self.__http_session:
await self.__http_session.close()
self.__http_session = None
if self.__client_session:
await self.__client_session.close()
self.__client_session = None
# =====
def __ensure_http_session(self) -> aiohttp.ClientSession:
if not self.__http_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}"
def __ensure_client_session(self) -> HttpStreamerClientSession:
if not self.__client_session:
self.__client_session = self.__client.make_session()
return self.__client_session
# =====
@@ -451,14 +414,14 @@ class Streamer: # pylint: disable=too-many-instance-attributes
await self.__start_streamer_proc()
assert self.__streamer_proc is not None
await aioproc.log_stdout_infinite(self.__streamer_proc, logger)
raise RuntimeError(self.gettext("Streamer unexpectedly died"))
raise RuntimeError("Streamer unexpectedly died")
except asyncio.CancelledError:
break
except Exception:
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:
logger.exception(self.gettext("Can't start streamer"))
logger.exception("Can't start streamer")
await self.__kill_streamer_proc()
await asyncio.sleep(1)
@@ -478,14 +441,14 @@ class Streamer: # pylint: disable=too-many-instance-attributes
logger.info("%s: %s", name, tools.cmdfmt(cmd))
try:
await aioproc.log_process(cmd, logger, prefix=name)
except Exception as err:
logger.exception(self.gettext("Can't execute command: %s"), err)
except Exception as ex:
logger.exception("Can't execute command: %s", ex)
async def __start_streamer_proc(self) -> None:
assert self.__streamer_proc is None
cmd = self.__make_cmd(self.__cmd)
self.__streamer_proc = await aioproc.run_process(cmd)
get_logger(0).info(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:
if self.__streamer_proc:

View File

@@ -35,6 +35,7 @@ class SystemdUnitInfo:
self.__bus: (dbus_next.aio.MessageBus | None) = None
self.__intr: (dbus_next.introspection.Node | 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]:
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_props = unit.get_interface("org.freedesktop.DBus.Properties")
started = ((await unit_props.call_get("org.freedesktop.systemd1.Unit", "ActiveState")).value == "active") # type: ignore
except dbus_next.errors.DBusError as err:
if err.type != "org.freedesktop.systemd1.NoSuchUnit":
self.__requested = True
except dbus_next.errors.DBusError as ex:
if ex.type != "org.freedesktop.systemd1.NoSuchUnit":
raise
started = False
enabled = ((await self.__manager.call_get_unit_file_state(name)) in [ # type: ignore
@@ -75,8 +77,13 @@ class SystemdUnitInfo:
async def close(self) -> None:
try:
if self.__bus is not None:
self.__bus.disconnect()
await self.__bus.wait_for_disconnect()
try:
# 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:
pass
self.__manager = None

View File

@@ -21,13 +21,12 @@
import asyncio
import copy
from typing import AsyncGenerator
from typing import Callable
from typing import Any
from ...languages import Languages
from ...logging import get_logger
from ...errors import IsBusyError
@@ -48,42 +47,40 @@ from ...yamlconf import Section
# =====
class GpioChannelNotFoundError(GpioOperationError):
def __init__(self) -> None:
super().__init__(Languages().gettext("GPIO channel is not found"))
super().__init__("GPIO channel is not found")
class GpioSwitchNotSupported(GpioOperationError):
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):
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):
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:
def __init__(
self,
channel: str,
ch: str,
config: Section,
driver: BaseUserGpioDriver,
) -> None:
self.__channel = channel
self.__ch = ch
self.__pin: str = str(config.pin)
self.__inverted: bool = config.inverted
self.__driver = driver
self.__driver.register_input(self.__pin, config.debounce)
self.gettext=Languages().gettext
def get_scheme(self) -> dict:
return {
"hw": {
@@ -104,7 +101,7 @@ class _GpioInput:
}
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__
@@ -112,13 +109,13 @@ class _GpioInput:
class _GpioOutput: # pylint: disable=too-many-instance-attributes
def __init__(
self,
channel: str,
ch: str,
config: Section,
driver: BaseUserGpioDriver,
notifier: aiotools.AioNotifier,
) -> None:
self.__channel = channel
self.__ch = ch
self.__pin: str = str(config.pin)
self.__inverted: bool = config.inverted
@@ -140,8 +137,6 @@ class _GpioOutput: # pylint: disable=too-many-instance-attributes
self.__region = aiotools.AioExclusiveRegion(GpioChannelIsBusyError, notifier)
self.gettext=Languages().gettext
def is_const(self) -> bool:
return (not self.__switch and not self.__pulse_delay)
@@ -190,11 +185,11 @@ class _GpioOutput: # pylint: disable=too-many-instance-attributes
@aiotools.atomic_fg
async def __run_action(self, wait: bool, name: str, func: Callable, *args: Any) -> None:
if wait:
async with self.__region:
with self.__region:
await func(*args)
else:
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,
)
@@ -203,12 +198,12 @@ class _GpioOutput: # pylint: disable=too-many-instance-attributes
try:
return (await func(*args))
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
async def __inner_switch(self, state: bool) -> None:
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)
@aiotools.atomic_fg
@@ -219,7 +214,7 @@ class _GpioOutput: # pylint: disable=too-many-instance-attributes
finally:
await self.__write(False)
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))
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__
@@ -238,8 +233,6 @@ class _GpioOutput: # pylint: disable=too-many-instance-attributes
# =====
class UserGpio:
def __init__(self, config: Section, otg_config: Section) -> None:
self.__view = config.view
self.__notifier = aiotools.AioNotifier()
self.__drivers = {
@@ -255,54 +248,74 @@ class UserGpio:
self.__inputs: dict[str, _GpioInput] = {}
self.__outputs: dict[str, _GpioOutput] = {}
self.gettext=Languages().gettext
for (channel, ch_config) in tools.sorted_kvs(config.scheme):
for (ch, ch_config) in tools.sorted_kvs(config.scheme):
driver = self.__drivers[ch_config.driver]
if ch_config.mode == UserGpioModes.INPUT:
self.__inputs[channel] = _GpioInput(channel, ch_config, driver)
self.__inputs[ch] = _GpioInput(ch, ch_config, driver)
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:
return {
"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(),
}
self.__scheme = self.__make_scheme()
self.__view = self.__make_view(config.view)
async def get_state(self) -> dict:
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": {
channel: await gout.get_state()
for (channel, gout) in self.__outputs.items()
ch: (await gout.get_state())
for (ch, gout) in self.__outputs.items()
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:
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):
driver.prepare()
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(*[
driver.run()
for (_, driver) in tools.sorted_kvs(self.__drivers)
@@ -313,30 +326,45 @@ class UserGpio:
try:
await driver.cleanup()
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:
gout = self.__outputs.get(channel)
async def switch(self, ch: str, state: bool, wait: bool) -> None:
gout = self.__outputs.get(ch)
if gout is None:
raise GpioChannelNotFoundError()
await gout.switch(state, wait)
async def pulse(self, channel: str, delay: float, wait: bool) -> None:
gout = self.__outputs.get(channel)
async def pulse(self, ch: str, delay: float, wait: bool) -> None:
gout = self.__outputs.get(ch)
if gout is None:
raise GpioChannelNotFoundError()
await gout.pulse(delay, wait)
# =====
def __make_view(self) -> dict:
def __make_scheme(self) -> dict:
return {
"header": {"title": self.__make_view_title()},
"table": self.__make_view_table(),
"inputs": {
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] = []
if isinstance(raw_title, list):
for item in raw_title:
@@ -350,9 +378,9 @@ class UserGpio:
title.append(self.__make_item_label(f"#{raw_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] = []
for row in self.__view["table"]:
for row in view["table"]:
if len(row) == 0:
table.append(None)
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 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"])
from . import main
main()

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 argparse
from os.path import join
from ...languages import Languages
from os.path import join # pylint: disable=ungrouped-imports
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
logger = get_logger()
gettext=Languages().gettext
_check_config(config)
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)
_mkdir(gadget_path)
@@ -255,39 +252,39 @@ def _cmd_start(config: Section) -> None: # pylint: disable=too-many-statements,
cod = config.otg.devices
if cod.serial.enabled:
logger.info(gettext("===== Serial ====="))
logger.info("===== Serial =====")
gc.add_serial(cod.serial.start)
if cod.ethernet.enabled:
logger.info(gettext("===== Ethernet ====="))
logger.info("===== Ethernet =====")
gc.add_ethernet(**cod.ethernet._unpack(ignore=["enabled"]))
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)
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)
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)
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())
if cod.drives.enabled:
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())
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)
time.sleep(config.otg.init_delay)
_chown(join(gadget_path, "UDC"), 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)
logger.info(Languages().gettext("Disabling gadget %r ..."), config.otg.gadget)
logger.info("Disabling gadget %r ...", config.otg.gadget)
_write(join(gadget_path, "UDC"), "\n")
_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:])
try:
options.cmd(config)
except ValidatorError as err:
raise SystemExit(str(err))
except ValidatorError as ex:
raise SystemExit(str(ex))

View File

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

View File

@@ -26,8 +26,6 @@ import dataclasses
import itertools
import argparse
from ...languages import Languages
from ...logging import get_logger
from ...yamlconf import Section
@@ -89,8 +87,6 @@ class _Service: # pylint: disable=too-many-instance-attributes
self.__gadget: str = config.otg.gadget
self.__driver: str = config.otg.devices.ethernet.driver
self.gettext=Languages().gettext
def start(self) -> None:
asyncio.run(self.__run(True))
@@ -125,20 +121,20 @@ class _Service: # pylint: disable=too-many-instance-attributes
for ctl in ctls:
if not (await self.__run_ctl(ctl, True)):
raise SystemExit(1)
get_logger(0).info(self.gettext("Ready to work"))
get_logger(0).info("Ready to work")
else:
for ctl in reversed(ctls):
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:
logger = get_logger()
cmd = ctl.get_command(direct)
logger.info(self.gettext("CMD: %s"), tools.cmdfmt(cmd))
logger.info("CMD: %s", tools.cmdfmt(cmd))
try:
return (not (await aioproc.log_process(cmd, logger)).returncode)
except Exception as err:
logger.exception(self.gettext("Can't execute command: %s"), err)
except Exception as ex:
logger.exception("Can't execute command: %s", ex)
return False
# =====
@@ -147,10 +143,10 @@ class _Service: # pylint: disable=too-many-instance-attributes
iface = self.__find_iface()
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)
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:
iface_ip = str(net[0])
@@ -170,7 +166,7 @@ class _Service: # pylint: disable=too-many-instance-attributes
dhcp_ip_end=dhcp_ip_end,
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
def __find_iface(self) -> str:
@@ -179,10 +175,10 @@ class _Service: # pylint: disable=too-many-instance-attributes
if self.__driver == "rndis5":
real_driver = "rndis"
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:
iface = file.read().strip()
logger.info(self.gettext("Using OTG Ethernet interface %r ..."), iface)
logger.info("Using OTG Ethernet interface %r ...", iface)
assert iface
return iface

View File

@@ -50,7 +50,7 @@ class PstServer(HttpServer): # pylint: disable=too-many-arguments,too-many-inst
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_cleanup_delay = ro_cleanup_delay
self.__remount_cmd = remount_cmd
@@ -60,8 +60,8 @@ class PstServer(HttpServer): # pylint: disable=too-many-arguments,too-many-inst
# ===== WEBSOCKET
@exposed_http("GET", "/ws")
async def __ws_handler(self, request: Request) -> WebSocketResponse:
async with self._ws_session(request) as ws:
async def __ws_handler(self, req: Request) -> WebSocketResponse:
async with self._ws_session(req) as ws:
await ws.send_event("loop", {})
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:
try:
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",
self.__data_path, tools.efmt(err))
self.__data_path, tools.efmt(ex))
return False
async def __remount_storage(self, rw: bool) -> bool:

View File

@@ -46,8 +46,8 @@ def _preexec() -> None:
if os.isatty(0):
try:
os.tcsetpgrp(0, os.getpgid(0))
except Exception as err:
get_logger(0).info("Can't perform tcsetpgrp(0): %s", tools.efmt(err))
except Exception as ex:
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

View File

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

View File

@@ -22,6 +22,7 @@
import asyncio
import ssl
import time
from typing import Callable
from typing import Coroutine
@@ -64,6 +65,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
width: int,
height: int,
name: str,
allow_cut_after: float,
vnc_passwds: list[str],
vencrypt: bool,
none_auth_only: bool,
@@ -79,6 +81,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
self._width = width
self._height = height
self.__name = name
self.__allow_cut_after = allow_cut_after
self.__vnc_passwds = vnc_passwds
self.__vencrypt = vencrypt
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_reset_h264 = False
self.__allow_cut_since_ts = 0.0
self.__lock = asyncio.Lock()
# =====
@@ -120,10 +125,10 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
except asyncio.CancelledError:
logger.info("%s [%s]: Cancelling subtask ...", self._remote, name)
raise
except RfbConnectionError as err:
logger.info("%s [%s]: Gone: %s", self._remote, name, err)
except (RfbError, ssl.SSLError) as err:
logger.error("%s [%s]: Error: %s", self._remote, name, err)
except RfbConnectionError as ex:
logger.info("%s [%s]: Gone: %s", self._remote, name, ex)
except (RfbError, ssl.SSLError) as ex:
logger.error("%s [%s]: Error: %s", self._remote, name, ex)
except Exception:
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:
self.__allow_cut_since_ts = time.monotonic() + self.__allow_cut_after
handlers = {
0: self.__handle_set_pixel_format,
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:
length = (await self._read_struct("cut text length", "xxx L"))[0]
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:
enabled = bool((await self._read_struct("enabled ContUpdates", "B HH HH"))[0])

View File

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

View File

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

View File

@@ -28,8 +28,6 @@ import contextlib
import aiohttp
from ...languages import Languages
from ...logging import get_logger
from ...keyboard.keysym import SymmapModifiers
@@ -44,7 +42,7 @@ from ...clients.kvmd import KvmdClient
from ...clients.streamer import StreamerError
from ...clients.streamer import StreamerPermError
from ...clients.streamer import StreamFormats
from ...clients.streamer import StreamerFormats
from ...clients.streamer import BaseStreamerClient
from ... import tools
@@ -83,6 +81,7 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
mouse_output: str,
keymap_name: str,
symmap: dict[int, dict[int, str]],
allow_cut_after: float,
kvmd: KvmdClient,
streamers: list[BaseStreamerClient],
@@ -102,6 +101,7 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
tls_timeout=tls_timeout,
x509_cert_path=x509_cert_path,
x509_key_path=x509_key_path,
allow_cut_after=allow_cut_after,
vnc_passwds=list(vnc_credentials),
vencrypt=vencrypt,
none_auth_only=none_auth_only,
@@ -135,8 +135,6 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
self.__modifiers = 0
self.gettext=Languages().gettext
# =====
async def run(self) -> None:
@@ -160,13 +158,13 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
logger = get_logger(0)
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)):
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
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)
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()
async for (event_type, event) in self.__kvmd_ws.communicate():
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:
self.__kvmd_ws = None
async def __process_ws_event(self, event_type: str, event: dict) -> None:
if event_type == "info_meta_state":
try:
host = event["server"]["host"]
except Exception:
host = None
else:
if isinstance(host, str):
name = f"PiKVM: {host}"
if self._encodings.has_rename:
await self._send_rename(name)
self.__shared_params.name = name
if event_type == "info_state":
if "meta" in event:
try:
host = event["meta"]["server"]["host"]
except Exception:
host = None
else:
if isinstance(host, str):
name = f"PiKVM: {host}"
if self._encodings.has_rename:
await self._send_rename(name)
self.__shared_params.name = name
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"])
# =====
@@ -208,36 +211,36 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
while True:
frame = await read_frame(not self.__fb_has_key)
if not streaming:
logger.info(self.gettext("%s [streamer]: Streaming ..."), self._remote)
logger.info("%s [streamer]: Streaming ...", self._remote)
streaming = True
if frame["online"]:
await self.__queue_frame(frame)
else:
await self.__queue_frame(self.gettext("No signal"))
except StreamerError as err:
if isinstance(err, StreamerPermError):
await self.__queue_frame("No signal")
except StreamerError as ex:
if isinstance(ex, StreamerPermError):
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:
logger.info(self.gettext("%s [streamer]: Waiting for stream: %s"), self._remote, err)
await self.__queue_frame(self.gettext("Waiting for stream ..."))
logger.info("%s [streamer]: Waiting for stream: %s", self._remote, ex)
await self.__queue_frame("Waiting for stream ...")
await asyncio.sleep(1)
def __get_preferred_streamer(self) -> BaseStreamerClient:
formats = {
StreamFormats.JPEG: "has_tight",
StreamFormats.H264: "has_h264",
StreamerFormats.JPEG: "has_tight",
StreamerFormats.H264: "has_h264",
}
streamer: (BaseStreamerClient | None) = None
for streamer in self.__streamers:
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
raise RuntimeError("No streamers found")
def __get_default_streamer(self) -> BaseStreamerClient:
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
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)),
"width": self._width,
"height": self._height,
"format": StreamFormats.JPEG,
"format": StreamerFormats.JPEG,
}
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()
if (
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 (frame["format"] == StreamFormats.H264 and (
or (frame["format"] == StreamerFormats.H264 and (
frame["key"]
or last["width"] != frame["width"]
or last["height"] != frame["height"]
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
if self.__fb_queue.qsize() == 0:
break
continue
assert frame["format"] == StreamFormats.H264
assert frame["format"] == StreamerFormats.H264
last["data"] += frame["data"]
if self.__fb_queue.qsize() == 0:
break
@@ -298,17 +301,17 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
await self._send_fb_allow_again()
continue
if last["format"] == StreamFormats.JPEG:
if last["format"] == StreamerFormats.JPEG:
await self._send_fb_jpeg(last["data"])
elif last["format"] == StreamFormats.H264:
elif last["format"] == StreamerFormats.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:
await self._send_fb_h264(last["data"])
else:
await self._send_fb_allow_again()
else:
raise RuntimeError(self.gettext(f"Unknown format: {last['format']}"))
raise RuntimeError(f"Unknown format: {last['format']}")
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"]
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)
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,
mouse_output: str,
keymap_path: str,
allow_cut_after: float,
kvmd: KvmdClient,
streamers: list[BaseStreamerClient],
@@ -460,16 +464,14 @@ class VncServer: # pylint: disable=too-many-instance-attributes
shared_params = _SharedParams()
self.gettext=Languages().gettext
async def cleanup_client(writer: asyncio.StreamWriter) -> None:
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:
logger = get_logger(0)
remote = rfb_format_remote(writer)
logger.info(self.gettext("%s [entry]: Connected client"), remote)
logger.info("%s [entry]: Connected client", remote)
try:
sock = writer.get_extra_info("socket")
if no_delay:
@@ -487,8 +489,8 @@ class VncServer: # pylint: disable=too-many-instance-attributes
try:
async with kvmd.make_session("", "") as kvmd_session:
none_auth_only = await kvmd_session.auth.check()
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
logger.error(self.gettext("%s [entry]: Can't check KVMD auth mode: %s"), remote, tools.efmt(err))
except (aiohttp.ClientError, asyncio.TimeoutError) as ex:
logger.error("%s [entry]: Can't check KVMD auth mode: %s", remote, tools.efmt(ex))
return
await _Client(
@@ -502,6 +504,7 @@ class VncServer: # pylint: disable=too-many-instance-attributes
mouse_output=mouse_output,
keymap_name=keymap_name,
symmap=symmap,
allow_cut_after=allow_cut_after,
kvmd=kvmd,
streamers=streamers,
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,
).run()
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:
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]:
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]
with contextlib.closing(socket.socket(family, socket.SOCK_STREAM)) as sock:
if family == socket.AF_INET6:
@@ -538,4 +541,4 @@ class VncServer: # pylint: disable=too-many-instance-attributes
def run(self) -> None:
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
from ...languages import Languages
from ...logging import get_logger
from ... import aiotools
@@ -32,7 +30,7 @@ from ... import aiotools
# =====
class VncAuthError(Exception):
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.__enabled = enabled
self.gettext=Languages().gettext
async def read_credentials(self) -> tuple[dict[str, VncAuthKvmdCredentials], bool]:
if self.__enabled:
try:
return (await self.__inner_read_credentials(), True)
except VncAuthError as err:
get_logger(0).error(str(err))
except VncAuthError as ex:
get_logger(0).error(str(ex))
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))
async def __inner_read_credentials(self) -> dict[str, VncAuthKvmdCredentials]:
@@ -71,19 +68,19 @@ class VncAuthManager:
continue
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))
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_user.strip()
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:
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)
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:
try:
now = _read_int(rtc, "since_epoch")
except OSError as err:
if err.errno != errno.EINVAL:
except OSError as ex:
if ex.errno != errno.EINVAL:
raise
raise RtcIsNotAvailableError("Can't read since_epoch right now")
if now == 0:
@@ -65,8 +65,8 @@ def _reset_alarm(rtc: int, timeout: int) -> None:
try:
for wake in [0, now + timeout]:
_write_int(rtc, "wakealarm", wake)
except OSError as err:
if err.errno != errno.EIO:
except OSError as ex:
if ex.errno != errno.EIO:
raise
raise RtcIsNotAvailableError("IO error, probably the supercapacitor is not charged")
@@ -80,9 +80,9 @@ def _cmd_run(config: Section) -> None:
while True:
try:
_reset_alarm(config.rtc, config.timeout)
except RtcIsNotAvailableError as err:
except RtcIsNotAvailableError as ex:
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
else:
if fail:

View File

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

View File

@@ -20,7 +20,10 @@
# ========================================================================== #
import io
import contextlib
import dataclasses
import functools
import types
from typing import Callable
@@ -31,10 +34,15 @@ from typing import AsyncGenerator
import aiohttp
import ustreamer
from PIL import Image as PilImage
from .. import tools
from .. import aiotools
from .. import htclient
from . import BaseHttpClient
from . import BaseHttpClientSession
# =====
class StreamerError(Exception):
@@ -50,7 +58,7 @@ class StreamerPermError(StreamerError):
# =====
class StreamFormats:
class StreamerFormats:
JPEG = 1195724874 # V4L2_PIX_FMT_JPEG
H264 = 875967048 # V4L2_PIX_FMT_H264
_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
def _http_handle_errors() -> Generator[None, None, None]:
def _http_reading_handle_errors() -> Generator[None, None, None]:
try:
yield
except Exception as err: # Тут бывают и ассерты, и KeyError, и прочая херня
if isinstance(err, StreamerTempError):
except Exception as ex: # Тут бывают и ассерты, и KeyError, и прочая херня
if isinstance(ex, StreamerTempError):
raise
raise StreamerTempError(tools.efmt(err))
raise StreamerTempError(tools.efmt(ex))
class HttpStreamerClient(BaseStreamerClient):
class HttpStreamerClient(BaseHttpClient, BaseStreamerClient):
def __init__(
self,
name: str,
@@ -87,29 +163,35 @@ class HttpStreamerClient(BaseStreamerClient):
user_agent: str,
) -> None:
super().__init__(unix_path, timeout, user_agent)
self.__name = name
self.__unix_path = unix_path
self.__timeout = timeout
self.__user_agent = user_agent
def make_session(self) -> HttpStreamerClientSession:
return HttpStreamerClientSession(self._make_http_session)
def get_format(self) -> int:
return StreamFormats.JPEG
return StreamerFormats.JPEG
@contextlib.asynccontextmanager
async def reading(self) -> AsyncGenerator[Callable[[bool], Awaitable[dict]], None]:
with _http_handle_errors():
async with self.__make_http_session() as session:
with _http_reading_handle_errors():
async with self._make_http_session() as session:
async with session.get(
url=self.__make_url("stream"),
url="/stream",
params={"extra_headers": "1"},
timeout=aiohttp.ClientTimeout(
connect=session.timeout.total,
sock_read=session.timeout.total,
),
) as response:
htclient.raise_not_200(response)
reader = aiohttp.MultipartReader.from_response(response)
self.__patch_stream_reader(reader.resp.content)
async def read_frame(key_required: bool) -> dict:
_ = key_required
with _http_handle_errors():
with _http_reading_handle_errors():
frame = await reader.next() # pylint: disable=not-callable
if not isinstance(frame, aiohttp.BodyPartReader):
raise StreamerTempError("Expected body part")
@@ -123,26 +205,11 @@ class HttpStreamerClient(BaseStreamerClient):
"width": int(frame.headers["X-UStreamer-Width"]),
"height": int(frame.headers["X-UStreamer-Height"]),
"data": data,
"format": StreamFormats.JPEG,
"format": StreamerFormats.JPEG,
}
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:
# https://github.com/pikvm/pikvm/issues/92
# Infinite looping in BodyPartReader.read() because _at_eof flag.
@@ -162,15 +229,15 @@ class HttpStreamerClient(BaseStreamerClient):
# =====
@contextlib.contextmanager
def _memsink_handle_errors() -> Generator[None, None, None]:
def _memsink_reading_handle_errors() -> Generator[None, None, None]:
try:
yield
except StreamerPermError:
raise
except FileNotFoundError as err:
raise StreamerTempError(tools.efmt(err))
except Exception as err:
raise StreamerPermError(tools.efmt(err))
except FileNotFoundError as ex:
raise StreamerTempError(tools.efmt(ex))
except Exception as ex:
raise StreamerPermError(tools.efmt(ex))
class MemsinkStreamerClient(BaseStreamerClient):
@@ -198,11 +265,11 @@ class MemsinkStreamerClient(BaseStreamerClient):
@contextlib.asynccontextmanager
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:
async def read_frame(key_required: bool) -> dict:
key_required = (key_required and self.__fmt == StreamFormats.H264)
with _memsink_handle_errors():
key_required = (key_required and self.__fmt == StreamerFormats.H264)
with _memsink_reading_handle_errors():
while True:
frame = await aiotools.run_async(sink.wait_frame, key_required)
if frame is not None:
@@ -211,8 +278,8 @@ class MemsinkStreamerClient(BaseStreamerClient):
yield read_frame
def __check_format(self, fmt: int) -> None:
if fmt == StreamFormats._MJPEG: # pylint: disable=protected-access
fmt = StreamFormats.JPEG
if fmt == StreamerFormats._MJPEG: # pylint: disable=protected-access
fmt = StreamerFormats.JPEG
if fmt != self.__fmt:
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
root_path: str
user: str
group: str
# =====
def find_msd() -> Partition:
return _find_single("otgmsd")
def find_msd(msd_directory_path) -> Partition:
return _find_single("otgmsd", msd_directory_path)
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)
if len(parts) == 0:
if os.path.exists('/var/lib/kvmd/msd'):
if os.path.exists(msd_directory_path):
#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:
raise RuntimeError(f"Can't find {part_type!r} mountpoint")
return parts[0]
@@ -64,12 +65,13 @@ def _find_partitions(part_type: str, single: bool) -> list[Partition]:
if line and not line.startswith("#"):
fields = line.split()
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:
parts.append(Partition(
mount_path=os.path.normpath(fields[1]),
root_path=os.path.normpath(options.get("root", "") or fields[1]),
user=options.get("user", ""),
group=options.get("group", ""),
))
if single:
break

View File

@@ -22,7 +22,9 @@
import sys
import os
import stat
import pwd
import grp
import shutil
import subprocess
@@ -44,8 +46,8 @@ def _remount(path: str, rw: bool) -> None:
_log(f"Remounting {path} to {mode.upper()}-mode ...")
try:
subprocess.check_call(["/bin/mount", "--options", f"remount,{mode}", path])
except subprocess.CalledProcessError as err:
raise SystemExit(f"Can't remount: {err}")
except subprocess.CalledProcessError as ex:
raise SystemExit(f"Can't remount: {ex}")
def _mkdir(path: str) -> None:
@@ -53,8 +55,8 @@ def _mkdir(path: str) -> None:
_log(f"MKDIR --- {path}")
try:
os.mkdir(path)
except Exception as err:
raise SystemExit(f"Can't create directory: {err}")
except Exception as ex:
raise SystemExit(f"Can't create directory: {ex}")
def _rmtree(path: str) -> None:
@@ -62,8 +64,8 @@ def _rmtree(path: str) -> None:
_log(f"RMALL --- {path}")
try:
shutil.rmtree(path)
except Exception as err:
raise SystemExit(f"Can't remove directory: {err}")
except Exception as ex:
raise SystemExit(f"Can't remove directory: {ex}")
def _rm(path: str) -> None:
@@ -71,25 +73,43 @@ def _rm(path: str) -> None:
_log(f"RM --- {path}")
try:
os.remove(path)
except Exception as err:
raise SystemExit(f"Can't remove file: {err}")
except Exception as ex:
raise SystemExit(f"Can't remove file: {ex}")
def _move(src: str, dest: str) -> None:
_log(f"MOVE --- {src} --> {dest}")
try:
os.rename(src, dest)
except Exception as err:
raise SystemExit(f"Can't move file: {err}")
except Exception as ex:
raise SystemExit(f"Can't move file: {ex}")
def _chown(path: str, user: str) -> None:
if pwd.getpwuid(os.stat(path).st_uid).pw_name != user:
_log(f"CHOWN --- {user} - {path}")
try:
shutil.chown(path, user)
except Exception as err:
raise SystemExit(f"Can't change ownership: {err}")
shutil.chown(path, user=user)
except Exception as ex:
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:
_chown(part.root_path, part.user)
if part.group:
_chgrp(part.root_path, part.group)
def _fix_pst(part: Partition) -> None:
path = os.path.join(part.root_path, "data")
_mkdir(path)
if part.user:
_chown(part.root_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.multipart
from .languages import Languages
from . import __version__
@@ -38,29 +36,29 @@ def make_user_agent(app: str) -> str:
return f"{app}/{__version__}"
def raise_not_200(response: aiohttp.ClientResponse) -> None:
if response.status != 200:
assert response.reason is not None
response.release()
def raise_not_200(resp: aiohttp.ClientResponse) -> None:
if resp.status != 200:
assert resp.reason is not None
resp.release()
raise aiohttp.ClientResponseError(
response.request_info,
response.history,
status=response.status,
message=response.reason,
headers=response.headers,
resp.request_info,
resp.history,
status=resp.status,
message=resp.reason,
headers=resp.headers,
)
def get_filename(response: aiohttp.ClientResponse) -> str:
def get_filename(resp: aiohttp.ClientResponse) -> str:
try:
disp = response.headers["Content-Disposition"]
disp = resp.headers["Content-Disposition"]
parsed = aiohttp.multipart.parse_content_disposition(disp)
return str(parsed[1]["filename"])
except Exception:
try:
return os.path.basename(response.url.path)
return os.path.basename(resp.url.path)
except Exception:
raise aiohttp.ClientError(Languages().gettext("Can't determine filename"))
raise aiohttp.ClientError("Can't determine filename")
@contextlib.asynccontextmanager
@@ -81,6 +79,6 @@ async def download(
),
}
async with aiohttp.ClientSession(**kwargs) as session:
async with session.get(url, verify_ssl=verify) as response:
raise_not_200(response)
yield response
async with session.get(url, verify_ssl=verify) as resp: # type: ignore
raise_not_200(resp)
yield resp

View File

@@ -52,8 +52,6 @@ from .errors import IsBusyError
from .validators import ValidatorError
from .languages import Languages
from . import aiotools
@@ -159,7 +157,7 @@ def make_json_response(
wrap_result: bool=True,
) -> Response:
response = Response(
resp = Response(
text=json.dumps(({
"ok": (status == 200),
"result": (result or {}),
@@ -169,18 +167,18 @@ def make_json_response(
)
if set_cookies:
for (key, value) in set_cookies.items():
response.set_cookie(key, value, httponly=True, samesite="Strict")
return response
resp.set_cookie(key, value, httponly=True, samesite="Strict")
return resp
def make_json_exception(err: Exception, status: (int | None)=None) -> Response:
name = type(err).__name__
msg = str(err)
if isinstance(err, HttpError):
status = err.status
def make_json_exception(ex: Exception, status: (int | None)=None) -> Response:
name = type(ex).__name__
msg = str(ex)
if isinstance(ex, HttpError):
status = ex.status
else:
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({
"error": name,
"error_msg": msg,
@@ -188,35 +186,35 @@ def make_json_exception(err: Exception, status: (int | None)=None) -> Response:
async def start_streaming(
request: Request,
req: Request,
content_type: str,
content_length: int=-1,
file_name: str="",
) -> StreamResponse:
response = StreamResponse(status=200, reason="OK")
response.content_type = content_type
resp = StreamResponse(status=200, reason="OK")
resp.content_type = content_type
if content_length >= 0: # pylint: disable=consider-using-min-builtin
response.content_length = content_length
resp.content_length = content_length
if file_name:
file_name = urllib.parse.quote(file_name, safe="")
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{file_name}"
await response.prepare(request)
return response
resp.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{file_name}"
await resp.prepare(req)
return resp
async def stream_json(response: StreamResponse, result: dict, ok: bool=True) -> None:
await response.write(json.dumps({
async def stream_json(resp: StreamResponse, result: dict, ok: bool=True) -> None:
await resp.write(json.dumps({
"ok": ok,
"result": result,
}).encode("utf-8") + b"\r\n")
async def stream_json_exception(response: StreamResponse, err: Exception) -> None:
name = type(err).__name__
msg = str(err)
async def stream_json_exception(resp: StreamResponse, ex: Exception) -> None:
name = type(ex).__name__
msg = str(ex)
get_logger().error("API error: %s: %s", name, msg)
await stream_json(response, {
await stream_json(resp, {
"error": name,
"error_msg": msg,
}, False)
@@ -251,15 +249,15 @@ def parse_ws_event(msg: str) -> tuple[str, dict]:
_REQUEST_AUTH_INFO = "_kvmd_auth_info"
def _format_P(request: BaseRequest, *_, **__) -> str: # type: ignore # pylint: disable=invalid-name
return (getattr(request, _REQUEST_AUTH_INFO, None) or "-")
def _format_P(req: BaseRequest, *_, **__) -> str: # type: ignore # pylint: disable=invalid-name
return (getattr(req, _REQUEST_AUTH_INFO, None) or "-")
AccessLogger._format_P = staticmethod(_format_P) # type: ignore # pylint: disable=protected-access
def set_request_auth_info(request: BaseRequest, info: str) -> None:
setattr(request, _REQUEST_AUTH_INFO, info)
def set_request_auth_info(req: BaseRequest, info: str) -> None:
setattr(req, _REQUEST_AUTH_INFO, info)
# =====
@@ -282,7 +280,6 @@ class HttpServer:
self.__ws_bin_handlers: dict[int, Callable] = {}
self.__ws_sessions: list[WsSession] = []
self.__ws_sessions_lock = asyncio.Lock()
self.gettext=Languages().gettext
def run(
self,
@@ -321,16 +318,16 @@ class HttpServer:
self.__add_exposed_ws(ws_exposed)
def __add_exposed_http(self, exposed: HttpExposed) -> None:
async def wrapper(request: Request) -> Response:
async def wrapper(req: Request) -> Response:
try:
await self._check_request_auth(exposed, request)
return (await exposed.handler(request))
except IsBusyError as err:
return make_json_exception(err, 409)
except (ValidatorError, OperationError) as err:
return make_json_exception(err, 400)
except HttpError as err:
return make_json_exception(err)
await self._check_request_auth(exposed, req)
return (await exposed.handler(req))
except IsBusyError as ex:
return make_json_exception(ex, 409)
except (ValidatorError, OperationError) as ex:
return make_json_exception(ex, 400)
except HttpError as ex:
return make_json_exception(ex)
self.__app.router.add_route(exposed.method, exposed.path, wrapper)
def __add_exposed_ws(self, exposed: WsExposed) -> None:
@@ -345,15 +342,15 @@ class HttpServer:
# =====
@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
wsr = WebSocketResponse(heartbeat=self.__ws_heartbeat)
await wsr.prepare(request)
await wsr.prepare(req)
ws = WsSession(wsr, kwargs)
async with self.__ws_sessions_lock:
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:
await self._on_ws_opened()
@@ -367,27 +364,27 @@ class HttpServer:
if msg.type == WSMsgType.TEXT:
try:
(event_type, event) = parse_ws_event(msg.data)
except Exception as err:
logger.error(self.gettext("Can't parse JSON event from websocket: %r"), err)
except Exception as ex:
logger.error("Can't parse JSON event from websocket: %r", ex)
else:
handler = self.__ws_handlers.get(event_type)
if handler:
await handler(ws, event)
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:
handler = self.__ws_bin_handlers.get(msg.data[0])
if handler:
await handler(ws, msg.data[1:])
else:
logger.error(self.gettext("Unknown websocket binary event: %r"), msg.data)
logger.error("Unknown websocket binary event: %r", msg.data)
else:
break
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:
await asyncio.gather(*[
ws.send_event(event_type, event)
@@ -396,6 +393,7 @@ class HttpServer:
not ws.wsr.closed
and ws.wsr._req is not None # pylint: disable=protected-access
and ws.wsr._req.transport is not None # pylint: disable=protected-access
and (legacy is None or ws.kwargs.get("legacy") == legacy)
)
], return_exceptions=True)
@@ -412,7 +410,7 @@ class HttpServer:
async with self.__ws_sessions_lock:
try:
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()
except Exception:
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
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 .languages import Languages
from . import aiotools
from . import libc
@@ -132,18 +130,25 @@ class InotifyMask:
# | OPEN
# )
# Helper for all modify events
ALL_MODIFY_EVENTS = (
# Helper for all changes events except MODIFY, because it fires on each write()
ALL_CHANGES_EVENTS = (
CLOSE_WRITE
| CREATE
| DELETE
| DELETE_SELF
| MODIFY
| MOVE_SELF
| MOVED_FROM
| 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()
# DONT_FOLLOW = 0x02000000 # Don't follow a symbolic link
# EXCL_UNLINK = 0x04000000 # Exclude events on unlinked objects
@@ -174,6 +179,10 @@ class InotifyEvent:
name: str
path: str
@property
def restart(self) -> bool:
return bool(self.mask & InotifyMask.ALL_RESTART_EVENTS)
def __repr__(self) -> str:
return (
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()
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:
for path in paths:
path = os.path.normpath(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
wd = _inotify_check(await aiotools.run_async(libc.inotify_add_watch, self.__fd, _fs_encode(path), mask))
self.__wd_by_path[path] = wd
@@ -224,7 +236,7 @@ class Inotify:
except asyncio.TimeoutError:
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] = []
event = await self.get_event(timeout)
if event:
@@ -233,6 +245,8 @@ class Inotify:
event = await self.get_event(timeout)
if event:
series.append(event)
if len(series) >= max_series:
break
return series
def __read_and_queue_events(self) -> None:
@@ -255,7 +269,7 @@ class Inotify:
if event.mask & InotifyMask.IGNORED:
ignored_path = self.__path_by_wd[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]
continue
@@ -273,8 +287,8 @@ class Inotify:
while True:
try:
return os.read(self.__fd, _EVENTS_BUFFER_LENGTH)
except OSError as err:
if err.errno == errno.EINTR:
except OSError as ex:
if ex.errno == errno.EINTR:
pass
def __enter__(self) -> "Inotify":

View File

@@ -27,8 +27,6 @@ import importlib.machinery
import Xlib.keysymdef
from ..languages import Languages
from ..logging import get_logger
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.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
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)
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:
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:
at1_code = int(parts[1], 16)
except ValueError as err:
logger.error("Syntax error at %s:%d: %s", path, lineno, err)
except ValueError as ex:
logger.error("Syntax error at %s:%d: %s", path, lineno, ex)
continue
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:
sock.bind(("::1", 0))
return True
except OSError as err:
if err.errno in [errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT]:
except OSError as ex:
if ex.errno in [errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT]:
return False
if err.errno == errno.EADDRINUSE:
if ex.errno == errno.EADDRINUSE:
return True
raise

View File

@@ -25,8 +25,6 @@ from typing import AsyncGenerator
from ...errors import OperationError
from ...errors import IsBusyError
from ...languages import Languages
from .. import BasePlugin
from .. import get_plugin_class
@@ -42,7 +40,7 @@ class AtxOperationError(OperationError, AtxError):
class AtxIsBusyError(IsBusyError, AtxError):
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:
raise NotImplementedError
async def trigger_state(self) -> None:
raise NotImplementedError
async def poll_state(self) -> AsyncGenerator[dict, None]:
# ==== Granularity table ====
# - enabled -- Full
# - busy -- Partial
# - leds -- Partial
# ===========================
yield {}
raise NotImplementedError

View File

@@ -36,6 +36,9 @@ class AtxDisabledError(AtxOperationError):
# =====
class Plugin(BaseAtx):
def __init__(self) -> None:
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> dict:
return {
"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]:
while True:
await self.__notifier.wait()
yield (await self.get_state())
await aiotools.wait_infinite()
# =====

View File

@@ -21,6 +21,7 @@
import asyncio
import copy
from typing import AsyncGenerator
@@ -39,8 +40,6 @@ from ...validators.basic import valid_float_f01
from ...validators.os import valid_abs_path
from ...validators.hw import valid_gpio_pin
from ...languages import Languages
from . import AtxIsBusyError
from . import BaseAtx
@@ -78,7 +77,7 @@ class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
self.__notifier = aiotools.AioNotifier()
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(
path=self.__device_path,
@@ -90,7 +89,6 @@ class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
notifier=self.__notifier,
)
@classmethod
def get_plugin_options(cls) -> dict:
return {
@@ -111,8 +109,8 @@ class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
}
def sysprep(self) -> None:
assert self.__line_request is None
self.__line_request = gpiod.request_lines(
assert self.__line_req is None
self.__line_req = gpiod.request_lines(
self.__device_path,
consumer="kvmd::atx",
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]:
prev_state: dict = {}
prev: dict = {}
while True:
state = await self.get_state()
if state != prev_state:
yield state
prev_state = state
await self.__notifier.wait()
if (await self.__notifier.wait()) > 0:
prev = {}
new = await self.get_state()
if new != prev:
prev = copy.deepcopy(new)
yield new
async def systask(self) -> None:
await self.__reader.poll()
async def cleanup(self) -> None:
if self.__line_request:
if self.__line_req:
try:
self.__line_request.release()
self.__line_req.release()
except Exception:
pass
@@ -189,21 +191,21 @@ class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
@aiotools.atomic_fg
async def __click(self, name: str, pin: int, delay: float, wait: bool) -> None:
if wait:
async with self.__region:
with self.__region:
await self.__inner_click(name, pin, delay)
else:
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,
)
@aiotools.atomic_fg
async def __inner_click(self, name: str, pin: int, delay: float) -> None:
assert self.__line_request
assert self.__line_req
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)
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)
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 ...languages import Languages
from . import BaseAuthService
@@ -77,7 +75,7 @@ class Plugin(BaseAuthService):
async with session.request(
method="POST",
url=self.__url,
timeout=self.__timeout,
timeout=aiohttp.ClientTimeout(total=self.__timeout),
json={
"user": user,
"passwd": passwd,
@@ -87,11 +85,11 @@ class Plugin(BaseAuthService):
"User-Agent": htclient.make_user_agent("KVMD"),
"X-KVMD-User": user,
},
) as response:
htclient.raise_not_200(response)
) as resp:
htclient.raise_not_200(resp)
return True
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
async def cleanup(self) -> None:

View File

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

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