mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-03-15 23:46:51 +08:00
Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dd117711d | ||
|
|
ed68449274 | ||
|
|
a32dcd2e00 | ||
|
|
666f0b694a | ||
|
|
b8ddf7c2da | ||
|
|
5450d7297c | ||
|
|
506d8a4a64 | ||
|
|
eec64ef57c | ||
|
|
baa0f7e226 | ||
|
|
3ec872878e | ||
|
|
6928fab16c | ||
|
|
8fdb7d7cd6 | ||
|
|
433232c845 | ||
|
|
b419641251 | ||
|
|
50819d0a35 | ||
|
|
a37b818039 | ||
|
|
8a81996e52 | ||
|
|
69cb9ac950 | ||
|
|
30378211b5 | ||
|
|
e9e7f9bd05 | ||
|
|
72dce4de89 | ||
|
|
f1503d69e0 | ||
|
|
de5cb73b93 | ||
|
|
0751b519c2 | ||
|
|
0010dd1d11 | ||
|
|
7ef2e16b51 | ||
|
|
1a13760df0 | ||
|
|
d93639ba8d | ||
|
|
1e277c0f06 | ||
|
|
95597b15e4 | ||
|
|
6fbfc2b343 | ||
|
|
b893f27285 | ||
|
|
28167c4b45 | ||
|
|
5aef0a2193 | ||
|
|
0fd1174bc5 | ||
|
|
d4fb640418 | ||
|
|
d6b61cb407 | ||
|
|
8192b1fa95 | ||
|
|
deba110cdf | ||
|
|
936cc21c40 | ||
|
|
47778bc48c | ||
|
|
c02bc53bc4 | ||
|
|
546ac24b93 | ||
|
|
2195acf2ff | ||
|
|
60f413c1f4 | ||
|
|
a84242c9bc | ||
|
|
efa865ec9c | ||
|
|
399712c684 | ||
|
|
1ebc08eae8 | ||
|
|
684b9f629e | ||
|
|
76d70d0838 | ||
|
|
a26aee3543 | ||
|
|
0e4a70e7b9 | ||
|
|
cda32a083f | ||
|
|
11d8f26874 | ||
|
|
2929a925a2 | ||
|
|
b67a232584 | ||
|
|
90d8e745e3 | ||
|
|
3852d0a456 | ||
|
|
f5bebbc43f | ||
|
|
6707cb9932 | ||
|
|
87c887a62b | ||
|
|
40505e7e00 | ||
|
|
c1f408ea1a | ||
|
|
5b0ca351d7 | ||
|
|
b6869cfbec | ||
|
|
1e11678260 | ||
|
|
8c0953aafc | ||
|
|
073f67ca1b | ||
|
|
cb5c1e9e6d | ||
|
|
8ce27dca3f | ||
|
|
f4ba4210e1 | ||
|
|
4e1d9815cd | ||
|
|
8209ee2eb0 | ||
|
|
5ed368769c | ||
|
|
1217144ecd | ||
|
|
842ddc91a1 | ||
|
|
7a53f14456 | ||
|
|
45270a09d7 | ||
|
|
f03ac695bd | ||
|
|
b3e836e553 | ||
|
|
c57334f214 | ||
|
|
b779c18530 | ||
|
|
6ccd91a8d1 | ||
|
|
bd127c3fd3 | ||
|
|
4bc2ca3c90 | ||
|
|
445e2e04e2 | ||
|
|
489601bb96 | ||
|
|
56da910ebe | ||
|
|
40393acf67 | ||
|
|
2123799e51 | ||
|
|
0bb35806ff | ||
|
|
bbbc908af1 | ||
|
|
8113c5748b | ||
|
|
aa1ca3b329 | ||
|
|
508d5fe606 | ||
|
|
bc22a28022 | ||
|
|
80aa9de4cc | ||
|
|
572a75d27b | ||
|
|
864a2af45e | ||
|
|
5f26fa4072 | ||
|
|
af9023e8aa | ||
|
|
5c3ac4c9c1 | ||
|
|
fb9d860cf2 | ||
|
|
5045d8b3d7 | ||
|
|
cc66fbf1df | ||
|
|
9dc2af0356 | ||
|
|
99fcbdda05 | ||
|
|
308911191a | ||
|
|
0c213add4a | ||
|
|
3837e1a1c8 | ||
|
|
8569ed406a | ||
|
|
4772c2b6c3 | ||
|
|
e6b775089f | ||
|
|
721a80ef03 | ||
|
|
a55948bf8e | ||
|
|
39422f37ac | ||
|
|
06b69d3dde | ||
|
|
c9405efa05 | ||
|
|
abedace4b3 |
@@ -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
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
/pkg/
|
||||
/src/
|
||||
/src/**/*.img
|
||||
/src/tmp
|
||||
/site/
|
||||
/dist/
|
||||
/kvmd.egg-info/
|
||||
|
||||
14
Makefile
14
Makefile
@@ -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 \
|
||||
|
||||
9
PKGBUILD
9
PKGBUILD
@@ -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
|
||||
|
||||
78
README.md
78
README.md
@@ -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)
|
||||
|
||||

|
||||
|
||||
### 软件功能
|
||||
|
||||
表格仅为 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)
|
||||
|
||||
**状态**
|
||||
### 项目状态
|
||||
|
||||
[](https://star-history.com/#mofeng-git/One-KVM&Date)
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
3
build/platform/chainedbox
Normal file
3
build/platform/chainedbox
Normal file
@@ -0,0 +1,3 @@
|
||||
PIKVM_MODEL=v2_model
|
||||
PIKVM_VIDEO=usb_video
|
||||
PIKVM_BOARD=chainedbox
|
||||
3
build/platform/cumebox2
Normal file
3
build/platform/cumebox2
Normal file
@@ -0,0 +1,3 @@
|
||||
PIKVM_MODEL=v2_model
|
||||
PIKVM_VIDEO=usb_video
|
||||
PIKVM_BOARD=cumebox2
|
||||
3
build/platform/e900v22c
Normal file
3
build/platform/e900v22c
Normal file
@@ -0,0 +1,3 @@
|
||||
PIKVM_MODEL=v2_model
|
||||
PIKVM_VIDEO=usb_video
|
||||
PIKVM_BOARD=e900v22c
|
||||
3
build/platform/vm
Normal file
3
build/platform/vm
Normal file
@@ -0,0 +1,3 @@
|
||||
PIKVM_MODEL=v2_model
|
||||
PIKVM_VIDEO=usb_video
|
||||
PIKVM_BOARD=vm
|
||||
@@ -1,4 +1,7 @@
|
||||
video: {
|
||||
sink = "kvmd::ustreamer::h264"
|
||||
}
|
||||
|
||||
audio: {
|
||||
device = "hw:0"
|
||||
tc358743 = "/dev/video0"
|
||||
}
|
||||
|
||||
98
configs/kvmd/main/v4plus-hdmi-rpi4.yaml
Normal file
98
configs/kvmd/main/v4plus-hdmi-rpi4.yaml
Normal 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
|
||||
@@ -6,4 +6,9 @@
|
||||
server:
|
||||
host: localhost.localdomain
|
||||
|
||||
kvm: {}
|
||||
kvm: {
|
||||
base_on: PiKVM,
|
||||
app_name: One-KVM,
|
||||
majaro_version: 241204,
|
||||
author: SilentWind
|
||||
}
|
||||
|
||||
@@ -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
|
||||
12
configs/os/services/kvmd-oled-reboot.service
Normal file
12
configs/os/services/kvmd-oled-reboot.service
Normal 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
|
||||
14
configs/os/services/kvmd-oled-shutdown.service
Normal file
14
configs/os/services/kvmd-oled-shutdown.service
Normal 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
|
||||
15
configs/os/services/kvmd-oled.service
Normal file
15
configs/os/services/kvmd-oled.service
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
12
kvmd.install
12
kvmd.install
@@ -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
|
||||
|
||||
@@ -20,4 +20,4 @@
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
__version__ = "4.3"
|
||||
__version__ = "4.20"
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# =====
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" % (
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# =====
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
184
kvmd/apps/oled/__init__.py
Normal 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("")
|
||||
@@ -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()
|
||||
BIN
kvmd/apps/oled/fonts/ProggySquare.ttf
Normal file
BIN
kvmd/apps/oled/fonts/ProggySquare.ttf
Normal file
Binary file not shown.
BIN
kvmd/apps/oled/pics/hello.ppm
Normal file
BIN
kvmd/apps/oled/pics/hello.ppm
Normal file
Binary file not shown.
BIN
kvmd/apps/oled/pics/pikvm.ppm
Normal file
BIN
kvmd/apps/oled/pics/pikvm.ppm
Normal file
Binary file not shown.
54
kvmd/apps/oled/screen.py
Normal file
54
kvmd/apps/oled/screen.py
Normal 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
126
kvmd/apps/oled/sensors.py
Normal 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)}%"
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
269
kvmd/edid.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# =====
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
@@ -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=%d;reason=%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 "启动此服务"
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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:]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
# =====
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user