diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index dfb25c31..90eec8b0 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,7 +1,7 @@
[bumpversion]
commit = True
tag = True
-current_version = 4.49
+current_version = 4.94
parse = (?P\d+)\.(?P\d+)(\.(?P\d+)(\-(?P[a-z]+))?)?
serialize =
{major}.{minor}
diff --git a/.github/workflows/build_img.yaml b/.github/workflows/build_img.yaml
index 34be748a..97b25d46 100644
--- a/.github/workflows/build_img.yaml
+++ b/.github/workflows/build_img.yaml
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
device_target:
- description: 'Target device name'
+ description: 'Target device to build'
required: true
type: choice
options:
@@ -15,6 +15,20 @@ on:
- e900v22c
- octopus-flanet
- all
+ create_release:
+ description: 'Create GitHub Release'
+ required: false
+ default: true
+ type: boolean
+ release_name:
+ description: 'Custom release name (optional)'
+ required: false
+ type: string
+
+env:
+ BUILD_DATE: ""
+ GIT_SHA: ""
+ RELEASE_TAG: ""
jobs:
build:
@@ -26,11 +40,35 @@ jobs:
TZ: Asia/Shanghai
volumes:
- /dev:/dev
- - /mnt/nfs/lfs/:/mnt/nfs/lfs/
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set build environment
+ id: build_env
+ shell: bash
+ run: |
+ BUILD_DATE=$(date +%y%m%d-%H%M)
+ # 使用 GitHub 提供的环境变量避免 Git 权限问题
+ GIT_SHA="${GITHUB_SHA:0:7}"
+ GIT_BRANCH="${GITHUB_REF_NAME}"
+
+ echo "BUILD_DATE=$BUILD_DATE" >> $GITHUB_ENV
+ echo "GIT_SHA=$GIT_SHA" >> $GITHUB_ENV
+ echo "GIT_BRANCH=$GIT_BRANCH" >> $GITHUB_ENV
+
+ # 生成唯一但不创建新分支的标识符
+ RELEASE_TAG="build-$BUILD_DATE-${{ github.event.inputs.device_target }}-$GIT_SHA"
+ echo "RELEASE_TAG=$RELEASE_TAG" >> $GITHUB_ENV
+
+ echo "Build environment:"
+ echo "- Date: $BUILD_DATE"
+ echo "- Git SHA: $GIT_SHA"
+ echo "- Git Branch: $GIT_BRANCH"
+ echo "- Release Tag: $RELEASE_TAG"
- name: Install dependencies
run: |
@@ -38,7 +76,8 @@ jobs:
export DEBIAN_FRONTEND=noninteractive
apt-get install -y --no-install-recommends \
sudo tzdata docker.io qemu-utils qemu-user-static binfmt-support parted e2fsprogs \
- curl tar python3 python3-pip rsync git android-sdk-libsparse-utils coreutils zerofree
+ curl tar python3 python3-pip rsync git android-sdk-libsparse-utils coreutils zerofree wget \
+ file tree
apt-get clean
rm -rf /var/lib/apt/lists/*
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime
@@ -48,27 +87,98 @@ jobs:
DEBIAN_FRONTEND: noninteractive
- name: Build image
+ id: build
+ shell: bash
run: |
- echo "BUILD_DATE=$(date +%y%m%d)" >> $GITHUB_ENV
-
+ set -eo pipefail
+
+ echo "=== Build Configuration ==="
+ echo "Target: ${{ github.event.inputs.device_target }}"
+ echo "Build Date: $BUILD_DATE"
+ echo "Git SHA: $GIT_SHA"
+ echo "Git Branch: $GIT_BRANCH"
+ echo "Output Directory: ${{ github.workspace }}/output"
+ echo "=========================="
+
+ mkdir -p "${{ github.workspace }}/output"
chmod +x build/build_img.sh
-
- echo "Starting build for target: ${{ github.event.inputs.device_target }}"
- bash build/build_img.sh ${{ github.event.inputs.device_target }}
-
- echo "Build script finished."
+
+ echo "Starting build process..."
+ if bash build/build_img.sh ${{ github.event.inputs.device_target }}; then
+ echo "BUILD_SUCCESS=true" >> $GITHUB_OUTPUT
+ echo "Build completed successfully!"
+ else
+ echo "BUILD_SUCCESS=false" >> $GITHUB_OUTPUT
+ echo "Build failed!" >&2
+ exit 1
+ fi
env:
CI_PROJECT_DIR: ${{ github.workspace }}
+ GITHUB_ACTIONS: true
+ OUTPUTDIR: ${{ github.workspace }}/output
- - name: Upload artifact
- uses: actions/upload-artifact@v3
+ - name: Collect build artifacts
+ id: artifacts
+ run: |
+ cd "${{ github.workspace }}/output"
+
+ echo "=== Build Artifacts ==="
+ if [ -d "${{ github.workspace }}/output" ]; then
+ find . -name "*.xz" | head -20
+
+ # 统计xz文件信息
+ ARTIFACT_COUNT=$(find . -name "*.xz" | wc -l)
+ TOTAL_SIZE=$(du -sh . | cut -f1)
+
+ echo "ARTIFACT_COUNT=$ARTIFACT_COUNT" >> $GITHUB_OUTPUT
+ echo "TOTAL_SIZE=$TOTAL_SIZE" >> $GITHUB_OUTPUT
+ else
+ echo "No output directory found!"
+ echo "ARTIFACT_COUNT=0" >> $GITHUB_OUTPUT
+ echo "TOTAL_SIZE=0" >> $GITHUB_OUTPUT
+ fi
+ echo "======================"
+
+ - name: Create GitHub Release
+ if: steps.build.outputs.BUILD_SUCCESS == 'true' && github.event.inputs.create_release == 'true'
+ id: release
+ uses: softprops/action-gh-release@v1
with:
- name: onekvm-image-${{ github.event.inputs.device_target }}-${{ env.BUILD_DATE }}
- path: |
- ${{ github.workspace }}/output/*.img
- ${{ github.workspace }}/output/*.vmdk
- ${{ github.workspace }}/output/*.vdi
- ${{ github.workspace }}/output/*.burn.img
- if-no-files-found: ignore
+ tag_name: ${{ env.RELEASE_TAG }}
+ name: ${{ github.event.inputs.release_name || format('One-KVM {0} 构建镜像 ({1})', github.event.inputs.device_target, env.BUILD_DATE) }}
+ body: |
+ ## 📦 GitHub Actions 镜像构建
+
+ ### 构建信息
+ - **目标设备**: `${{ github.event.inputs.device_target }}`
+ - **构建时间**: `${{ env.BUILD_DATE }}`
+ - **Git 提交**: `${{ env.GIT_SHA }}` (分支: `${{ env.GIT_BRANCH }}`)
+ - **构建环境**: GitHub Actions (Ubuntu 22.04)
+ - **工作流ID**: `${{ github.run_id }}`
+
+ files: ${{ github.workspace }}/output/*.xz
+ prerelease: true
+ make_latest: false
+ generate_release_notes: false
env:
- CI_PROJECT_DIR: ${{ github.workspace }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build summary
+ if: always()
+ run: |
+ echo "## 📋 构建摘要" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| 项目 | 值 |" >> $GITHUB_STEP_SUMMARY
+ echo "|------|-----|" >> $GITHUB_STEP_SUMMARY
+ echo "| **目标设备** | \`${{ github.event.inputs.device_target }}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| **构建时间** | \`${{ env.BUILD_DATE }}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| **Git SHA** | \`${{ env.GIT_SHA }}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| **Git 分支** | \`${{ env.GIT_BRANCH }}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| **构建状态** | ${{ steps.build.outputs.BUILD_SUCCESS == 'true' && '✅ 成功' || '❌ 失败' }} |" >> $GITHUB_STEP_SUMMARY
+
+ if [ "${{ steps.build.outputs.BUILD_SUCCESS }}" = "true" ]; then
+ echo "| **构建产物** | ${{ steps.artifacts.outputs.ARTIFACT_COUNT || '0' }} 个文件 (${{ steps.artifacts.outputs.TOTAL_SIZE || '0' }}) |" >> $GITHUB_STEP_SUMMARY
+ if [ "${{ github.event.inputs.create_release }}" = "true" ]; then
+ echo "| **Release** | [${{ env.RELEASE_TAG }}](${{ steps.release.outputs.url }}) |" >> $GITHUB_STEP_SUMMARY
+ fi
+ fi
\ No newline at end of file
diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml
index d1fe8d7d..717b537d 100644
--- a/.github/workflows/docker-build.yaml
+++ b/.github/workflows/docker-build.yaml
@@ -3,81 +3,192 @@ name: Build and Push Docker Image
on:
workflow_dispatch:
inputs:
- version:
- description: 'Version'
+ build_type:
+ description: 'Build type'
required: true
type: choice
options:
+ - stage-0
- dev
- - latest
+ - release
+ version:
+ description: 'Version tag (for main image)'
+ required: false
+ default: 'latest'
+ type: string
+ platforms:
+ description: 'Target platforms'
+ required: false
+ default: 'linux/amd64,linux/arm64,linux/arm/v7'
+ type: string
+ enable_aliyun:
+ description: 'Push to Aliyun Registry'
+ required: false
+ default: true
+ type: boolean
+
+env:
+ DOCKERHUB_REGISTRY: docker.io
+ ALIYUN_REGISTRY: registry.cn-hangzhou.aliyuncs.com
+ STAGE0_IMAGE: kvmd-stage-0
+ MAIN_IMAGE: kvmd
jobs:
- build:
+ build-stage-0:
runs-on: ubuntu-22.04
- container:
- image: node:18
- env:
- TZ: Asia/Shanghai
+ if: github.event.inputs.build_type == 'stage-0'
+ permissions:
+ contents: read
+ packages: write
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
- - name: Install dependencies
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+ with:
+ driver: docker-container
+ platforms: ${{ github.event.inputs.platforms }}
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+ with:
+ platforms: all
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.DOCKERHUB_REGISTRY }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Login to Aliyun Registry
+ if: github.event.inputs.enable_aliyun == 'true'
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.ALIYUN_REGISTRY }}
+ username: ${{ secrets.ALIYUN_USERNAME }}
+ password: ${{ secrets.ALIYUN_PASSWORD }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: |
+ silentwind0/${{ env.STAGE0_IMAGE }}
+ ${{ github.event.inputs.enable_aliyun == 'true' && format('{0}/silentwind/{1}', env.ALIYUN_REGISTRY, env.STAGE0_IMAGE) || '' }}
+ tags: |
+ type=raw,value=latest
+ type=raw,value=latest-{{date 'YYYYMMDD-HHmmss'}}
+ type=sha,prefix={{branch}}-
+ labels: |
+ org.opencontainers.image.title=One-KVM Stage-0 Base Image
+ org.opencontainers.image.description=Base image for One-KVM build environment
+ org.opencontainers.image.vendor=One-KVM Project
+
+ - name: Build and push stage-0 image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ./build/Dockerfile-stage-0
+ platforms: ${{ github.event.inputs.platforms }}
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha,scope=stage-0
+ cache-to: type=gha,mode=max,scope=stage-0
+ provenance: false
+ sbom: false
+ allow: security.insecure
+
+ build-main:
+ runs-on: ubuntu-22.04
+ if: github.event.inputs.build_type != 'stage-0'
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+ with:
+ driver: docker-container
+ platforms: ${{ github.event.inputs.platforms }}
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+ with:
+ platforms: all
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.DOCKERHUB_REGISTRY }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Login to Aliyun Registry
+ if: github.event.inputs.enable_aliyun == 'true'
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.ALIYUN_REGISTRY }}
+ username: ${{ secrets.ALIYUN_USERNAME }}
+ password: ${{ secrets.ALIYUN_PASSWORD }}
+
+ - name: Set version tag
+ id: version
run: |
- apt-get update
- export DEBIAN_FRONTEND=noninteractive
- apt-get install -y --no-install-recommends \
- sudo tzdata docker.io qemu-utils qemu-user-static binfmt-support parted e2fsprogs \
- curl tar python3 python3-pip rsync git android-sdk-libsparse-utils coreutils zerofree
- apt-get clean
- rm -rf /var/lib/apt/lists/*
- ln -snf /usr/share/zoneinfo/$TZ /etc/localtime
- echo $TZ > /etc/timezone
- update-binfmts --enable
- env:
- DEBIAN_FRONTEND: noninteractive
+ if [[ "${{ github.event.inputs.build_type }}" == "dev" ]]; then
+ echo "tag=dev" >> $GITHUB_OUTPUT
+ elif [[ "${{ github.event.inputs.build_type }}" == "release" ]]; then
+ echo "tag=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
+ fi
- - name: Install Docker Buildx
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: |
+ silentwind0/${{ env.MAIN_IMAGE }}
+ ${{ github.event.inputs.enable_aliyun == 'true' && format('{0}/silentwind/{1}', env.ALIYUN_REGISTRY, env.MAIN_IMAGE) || '' }}
+ tags: |
+ type=raw,value=${{ steps.version.outputs.tag }}
+ type=raw,value=${{ steps.version.outputs.tag }}-{{date 'YYYYMMDD-HHmmss'}}
+ type=sha,prefix={{branch}}-
+ labels: |
+ org.opencontainers.image.title=One-KVM
+ org.opencontainers.image.description=DIY IP-KVM solution based on PiKVM
+ org.opencontainers.image.vendor=One-KVM Project
+ org.opencontainers.image.version=${{ steps.version.outputs.tag }}
+
+ - name: Build and push main image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ./build/Dockerfile
+ platforms: ${{ github.event.inputs.platforms }}
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha,scope=main
+ cache-to: type=gha,mode=max,scope=main
+ provenance: false
+ sbom: false
+
+ - name: Build summary
run: |
- # 创建插件目录
- mkdir -p ~/.docker/cli-plugins
- # 下载 buildx 二进制文件
- BUILDX_VERSION="v0.11.2"
- curl -L "https://github.com/docker/buildx/releases/download/${BUILDX_VERSION}/buildx-${BUILDX_VERSION}.linux-amd64" -o ~/.docker/cli-plugins/docker-buildx
- chmod +x ~/.docker/cli-plugins/docker-buildx
- # 验证安装
- docker buildx version
-
- #- name: Install QEMU
- # run: |
- # 安装 QEMU 模拟器
- #docker run --privileged --rm tonistiigi/binfmt --install all
- # 验证 QEMU 安装
- #docker buildx inspect --bootstrap
-
- - name: Create and use new builder instance
- run: |
- # 创建新的 builder 实例
- docker buildx create --name mybuilder --driver docker-container --bootstrap
- # 使用新创建的 builder
- docker buildx use mybuilder
- # 验证支持的平台
- docker buildx inspect --bootstrap
-
- - name: Build multi-arch image
- run: |
- # 构建多架构镜像
- docker buildx build \
- --platform linux/amd64,linux/arm64,linux/arm/v7 \
- --file ./build/Dockerfile \
- --tag silentwind/kvmd:${{ github.event.inputs.version }} \
- .
-
- #- name: Login to DockerHub
- # uses: docker/login-action@v2
- # with:
- # username: ${{ secrets.DOCKERHUB_USERNAME }}
- # password: ${{ secrets.DOCKERHUB_TOKEN }}
-
-
\ No newline at end of file
+ echo "## Build Summary" >> $GITHUB_STEP_SUMMARY
+ echo "- **Build Type**: ${{ github.event.inputs.build_type }}" >> $GITHUB_STEP_SUMMARY
+ echo "- **Version Tag**: ${{ steps.version.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
+ echo "- **Platforms**: ${{ github.event.inputs.platforms }}" >> $GITHUB_STEP_SUMMARY
+ echo "- **Aliyun Enabled**: ${{ github.event.inputs.enable_aliyun }}" >> $GITHUB_STEP_SUMMARY
+ echo "- **Tags**:" >> $GITHUB_STEP_SUMMARY
+ echo "${{ steps.meta.outputs.tags }}" | sed 's/^/ - /' >> $GITHUB_STEP_SUMMARY
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index e3fcca5e..712f1a76 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,4 @@
/venv/
.vscode/settings.j/son
kvmd_config/
+CLAUDE.md
diff --git a/Makefile b/Makefile
index cc352ffc..50962314 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,8 @@ TESTENV_IMAGE ?= kvmd-testenv
TESTENV_HID ?= /dev/ttyS10
TESTENV_VIDEO ?= /dev/video0
TESTENV_GPIO ?= /dev/gpiochip0
-TESTENV_RELAY ?= $(if $(shell ls /dev/hidraw0 2>/dev/null || true),/dev/hidraw0,)
+TESTENV_RELAY ?=
+#TESTENV_RELAY ?= $(if $(shell ls /dev/hidraw0 2>/dev/null || true),/dev/hidraw0,)
LIBGPIOD_VERSION ?= 1.6.3
@@ -28,6 +29,8 @@ all:
@ echo " make testenv # Build test environment"
@ echo " make tox # Run tests and linters"
@ echo " make tox E=pytest # Run selected test environment"
+ @ echo " make tox-local # Run tests and linters locally (no Docker)"
+ @ echo " make tox-local E=flake8 # Run selected test locally"
@ echo " make gpio # Create gpio mockup"
@ echo " make run # Run kvmd"
@ echo " make run CMD=... # Run specified command inside kvmd environment"
@@ -96,9 +99,13 @@ tox: testenv
"
+tox-local:
+ @./check-code.sh $(if $(E),$(E),all)
+
+
$(TESTENV_GPIO):
test ! -e $(TESTENV_GPIO)
- sudo modprobe gpio-mockup gpio_mockup_ranges=0,40
+ sudo modprobe gpio_mockup gpio_mockup_ranges=0,40
test -c $(TESTENV_GPIO)
diff --git a/PKGBUILD b/PKGBUILD
index c56da0f9..e3837d6d 100644
--- a/PKGBUILD
+++ b/PKGBUILD
@@ -39,7 +39,7 @@ for _variant in "${_variants[@]}"; do
pkgname+=(kvmd-platform-$_platform-$_board)
done
pkgbase=kvmd
-pkgver=4.49
+pkgver=4.94
pkgrel=1
pkgdesc="The main PiKVM daemon"
url="https://github.com/pikvm/kvmd"
@@ -53,6 +53,8 @@ depends=(
python-aiofiles
python-async-lru
python-passlib
+ # python-bcrypt is needed for passlib
+ python-bcrypt
python-pyotp
python-qrcode
python-periphery
@@ -66,7 +68,7 @@ depends=(
python-dbus
python-dbus-next
python-pygments
- python-pyghmi
+ "python-pyghmi>=1.6.0-2"
python-pam
python-pillow
python-xlib
@@ -80,6 +82,7 @@ depends=(
python-luma-oled
python-pyusb
python-pyudev
+ python-evdev
"libgpiod>=2.1"
freetype2
"v4l-utils>=1.22.1-1"
@@ -94,7 +97,7 @@ depends=(
certbot
platform-io-access
raspberrypi-utils
- "ustreamer>=6.26"
+ "ustreamer>=6.37"
# Systemd UDEV bug
"systemd>=248.3-2"
@@ -120,7 +123,7 @@ depends=(
# fsck for /boot
dosfstools
- # pgrep for kvmd-udev-restart-pass
+ # pgrep for kvmd-udev-restart-pass, sysctl for kvmd-otgnet
procps-ng
# Misc
@@ -163,7 +166,9 @@ package_kvmd() {
install -Dm755 -t "$pkgdir/usr/bin" scripts/kvmd-{bootconfig,gencert,certbot}
- install -Dm644 -t "$pkgdir/usr/lib/systemd/system" configs/os/services/*
+ install -dm755 "$pkgdir/usr/lib/systemd/system"
+ cp -rd configs/os/services -T "$pkgdir/usr/lib/systemd/system"
+
install -DTm644 configs/os/sysusers.conf "$pkgdir/usr/lib/sysusers.d/kvmd.conf"
install -DTm644 configs/os/tmpfiles.conf "$pkgdir/usr/lib/tmpfiles.d/kvmd.conf"
@@ -198,6 +203,7 @@ package_kvmd() {
mkdir -p "$pkgdir/etc/kvmd/override.d"
mkdir -p "$pkgdir/var/lib/kvmd/"{msd,pst}
+ chmod 1775 "$pkgdir/var/lib/kvmd/pst"
}
@@ -210,7 +216,7 @@ for _variant in "${_variants[@]}"; do
cd \"kvmd-\$pkgver\"
pkgdesc=\"PiKVM platform configs - $_platform for $_board\"
- depends=(kvmd=$pkgver-$pkgrel \"linux-rpi-pikvm>=6.6.45-10\" \"raspberrypi-bootloader-pikvm>=20240818-1\")
+ depends=(kvmd=$pkgver-$pkgrel \"linux-rpi-pikvm>=6.6.45-13\" \"raspberrypi-bootloader-pikvm>=20240818-1\")
backup=(
etc/sysctl.d/99-kvmd.conf
diff --git a/build/Dockerfile b/build/Dockerfile
index f40af840..0f25a274 100644
--- a/build/Dockerfile
+++ b/build/Dockerfile
@@ -42,6 +42,31 @@ RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/' /etc/apt/sources.lis
libnss3 \
libasound2 \
nano \
+ unzip \
+ libavcodec59 \
+ libavformat59 \
+ libavutil57 \
+ libswscale6 \
+ libavfilter8 \
+ libavdevice59 \
+ && if [ ${TARGETARCH} != arm ] && [ ${TARGETARCH} != arm64 ]; then \
+ apt-get install -y --no-install-recommends \
+ ffmpeg \
+ vainfo \
+ libva2 \
+ libva-drm2 \
+ libva-x11-2 \
+ libdrm2 \
+ mesa-va-drivers \
+ mesa-vdpau-drivers \
+ intel-media-va-driver \
+ i965-va-driver; \
+ fi \
+ && if [ ${TARGETARCH} = arm ] || [ ${TARGETARCH} = arm64 ]; then \
+ apt-get install -y --no-install-recommends \
+ v4l-utils \
+ libv4l-0; \
+ fi \
&& 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 \
diff --git a/build/Dockerfile-stage-0 b/build/Dockerfile-stage-0
index e1296e98..278e5712 100644
--- a/build/Dockerfile-stage-0
+++ b/build/Dockerfile-stage-0
@@ -47,6 +47,29 @@ RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/' /etc/apt/sources.lis
libspeex-dev \
libspeexdsp-dev \
libusb-1.0-0-dev \
+ libldap2-dev \
+ libsasl2-dev \
+ ffmpeg \
+ libavcodec-dev \
+ libavformat-dev \
+ libavutil-dev \
+ libswscale-dev \
+ libavfilter-dev \
+ libavdevice-dev \
+ vainfo \
+ libva-dev \
+ libva-drm2 \
+ libva-x11-2 \
+ libdrm-dev \
+ mesa-va-drivers \
+ mesa-vdpau-drivers \
+ v4l-utils \
+ libv4l-dev \
+ && if [ ${TARGETARCH} != arm ] && [ ${TARGETARCH} != arm64 ]; then \
+ apt-get install -y --no-install-recommends \
+ intel-media-va-driver \
+ i965-va-driver; \
+ fi \
&& apt clean \
&& rm -rf /var/lib/apt/lists/*
@@ -70,7 +93,13 @@ RUN --security=insecure pip config set global.index-url https://pypi.tuna.tsingh
more-itertools multidict netifaces packaging passlib pillow ply psutil \
pycparser pyelftools pyghmi pygments pyparsing pyotp qrcode requests \
semantic-version setproctitle six spidev tabulate urllib3 wrapt xlib \
- yarl pyserial pyyaml zstandard supervisor pyfatfs
+ yarl pyserial pyyaml zstandard supervisor pyfatfs pyserial python-periphery \
+ python-ldap python-pam pyrad pyudev pyusb luma.oled pyserial-asyncio
+
+# 编译 python vedev库
+RUN git clone --depth=1 https://github.com/gvalkov/python-evdev.git /tmp/python-evdev \
+ && cd /tmp/python-evdev \
+ && python3 setup.py bdist_wheel --dist-dir /tmp/wheel/
# 编译安装 libnice、libsrtp、libwebsockets 和 janus-gateway
RUN git clone --depth=1 https://gitlab.freedesktop.org/libnice/libnice /tmp/libnice \
@@ -103,17 +132,20 @@ RUN git clone --depth=1 https://gitlab.freedesktop.org/libnice/libnice /tmp/libn
&& rm -rf /tmp/janus-gateway
# 编译 ustreamer
-RUN sed --in-place --expression 's|^#include "refcount.h"$|#include "../refcount.h"|g' /usr/include/janus/plugins/plugin.h \
- && git clone --depth=1 https://github.com/mofeng-git/ustreamer /tmp/ustreamer \
- && sed -i '68s/-Wl,-Bstatic//' /tmp/ustreamer/src/Makefile \
- && make -j WITH_PYTHON=1 WITH_JANUS=1 WITH_LIBX264=1 -C /tmp/ustreamer \
- && /tmp/ustreamer/ustreamer -v \
- && cp /tmp/ustreamer/python/dist/*.whl /tmp/wheel/
+RUN echo "Building ustreamer with timestamp cache bust" \
+ && sed --in-place --expression 's|^#include "refcount.h"$|#include "../refcount.h"|g' /usr/include/janus/plugins/plugin.h \
+ && TIMESTAMP=$(date +%s%N) \
+ && git clone --depth=1 https://github.com/mofeng-git/ustreamer /tmp/ustreamer-${TIMESTAMP} \
+ && make -j WITH_PYTHON=1 WITH_JANUS=1 WITH_FFMPEG=1 -C /tmp/ustreamer-${TIMESTAMP} \
+ && /tmp/ustreamer-${TIMESTAMP}/ustreamer -v \
+ && cp /tmp/ustreamer-${TIMESTAMP}/python/dist/*.whl /tmp/wheel/ \
+ && mv /tmp/ustreamer-${TIMESTAMP} /tmp/ustreamer
# 复制必要的库文件
RUN mkdir /tmp/lib \
&& cd /lib/*-linux-*/ \
- && cp libevent_core-*.so.7 libbsd.so.0 libevent_pthreads-*.so.7 libspeexdsp.so.1 \
- libevent-*.so.7 libjpeg.so.62 libx264.so.164 libyuv.so.0 libnice.so.10 \
- /usr/lib/libsrtp2.so.1 /usr/lib/libwebsockets.so.19 \
- /tmp/lib/
+ && cp libevent_core-*.so.* libbsd.so.* libevent_pthreads-*.so.* libspeexdsp.so.* \
+ libevent-*.so.* libjpeg.so.* libyuv.so.* libnice.so.* \
+ /tmp/lib/ \
+ && find /usr/lib -name "libsrtp2.so.*" -exec cp {} /tmp/lib/ \; \
+ && find /usr/lib -name "libwebsockets.so.*" -exec cp {} /tmp/lib/ \;
diff --git a/build/build_img.sh b/build/build_img.sh
index 958356cd..6ef488ab 100755
--- a/build/build_img.sh
+++ b/build/build_img.sh
@@ -2,12 +2,15 @@
# --- 配置 ---
# 允许通过环境变量覆盖默认路径
-SRCPATH="${SRCPATH:-/mnt/nfs/lfs/src}"
+SRCPATH="${SRCPATH:-/mnt/src}"
BOOTFS="${BOOTFS:-/tmp/bootfs}"
ROOTFS="${ROOTFS:-/tmp/rootfs}"
-OUTPUTDIR="${OUTPUTDIR:-/mnt/nfs/lfs/src/output}"
+OUTPUTDIR="${OUTPUTDIR:-/mnt/output}"
TMPDIR="${TMPDIR:-$SRCPATH/tmp}"
+# 远程文件下载配置
+REMOTE_PREFIX="${REMOTE_PREFIX:-https://files.mofeng.run/src}"
+
export LC_ALL=C
# 全局变量
@@ -132,6 +135,9 @@ build_target() {
;;
esac
+ # 在 GitHub Actions 环境中清理下载的文件
+ cleanup_downloaded_files
+
echo "=================================================="
echo "信息:目标 $target 构建完成!"
echo "=================================================="
diff --git a/build/functions/common.sh b/build/functions/common.sh
index d9efc048..321570f7 100755
--- a/build/functions/common.sh
+++ b/build/functions/common.sh
@@ -172,9 +172,96 @@ write_meta() {
run_in_chroot "sed -i 's/localhost.localdomain/$hostname/g' /etc/kvmd/meta.yaml"
}
+# 检测是否在 GitHub Actions 环境中
+is_github_actions() {
+ [[ -n "$GITHUB_ACTIONS" ]]
+}
+
+# 记录下载的文件列表(仅在 GitHub Actions 环境中)
+DOWNLOADED_FILES_LIST="/tmp/downloaded_files.txt"
+
+# 自动下载文件函数
+download_file_if_missing() {
+ local file_path="$1"
+ local relative_path=""
+
+ # 如果文件已存在,直接返回
+ if [[ -f "$file_path" ]]; then
+ echo "信息:文件已存在: $file_path"
+ return 0
+ fi
+
+ # 计算相对于 SRCPATH 的路径
+ if [[ "$file_path" == "$SRCPATH"/* ]]; then
+ relative_path="${file_path#$SRCPATH/}"
+ else
+ echo "错误:文件路径 $file_path 不在 SRCPATH ($SRCPATH) 下" >&2
+ return 1
+ fi
+
+ echo "信息:文件不存在,尝试下载: $file_path"
+ echo "信息:相对路径: $relative_path"
+
+ # 确保目标目录存在
+ local target_dir="$(dirname "$file_path")"
+ ensure_dir "$target_dir"
+
+ # 首先尝试直接下载
+ local remote_url="${REMOTE_PREFIX}/${relative_path}"
+ echo "信息:尝试下载: $remote_url"
+
+ if curl -f -L -o "$file_path" "$remote_url" 2>/dev/null; then
+ echo "信息:下载成功: $file_path"
+ # 在 GitHub Actions 环境中记录下载的文件
+ if is_github_actions; then
+ echo "$file_path" >> "$DOWNLOADED_FILES_LIST"
+ fi
+ return 0
+ fi
+
+ # 如果直接下载失败,尝试添加 .xz 后缀
+ echo "信息:直接下载失败,尝试 .xz 压缩版本..."
+ local xz_url="${remote_url}.xz"
+ local xz_file="${file_path}.xz"
+
+ if curl -f -L -o "$xz_file" "$xz_url" 2>/dev/null; then
+ echo "信息:下载 .xz 文件成功,正在解压..."
+ if xz -d "$xz_file"; then
+ echo "信息:解压成功: $file_path"
+ # 在 GitHub Actions 环境中记录下载的文件
+ if is_github_actions; then
+ echo "$file_path" >> "$DOWNLOADED_FILES_LIST"
+ fi
+ return 0
+ else
+ echo "错误:解压 .xz 文件失败" >&2
+ rm -f "$xz_file"
+ return 1
+ fi
+ fi
+
+ echo "错误:无法下载文件 $file_path (尝试了原始版本和 .xz 版本)" >&2
+ return 1
+}
+
+# 清理下载的文件(仅在 GitHub Actions 环境中)
+cleanup_downloaded_files() {
+ if is_github_actions && [[ -f "$DOWNLOADED_FILES_LIST" ]]; then
+ echo "信息:清理 GitHub Actions 环境中下载的文件..."
+ while IFS= read -r file_path; do
+ if [[ -f "$file_path" ]]; then
+ echo "信息:删除下载的文件: $file_path"
+ rm -f "$file_path"
+ fi
+ done < "$DOWNLOADED_FILES_LIST"
+ rm -f "$DOWNLOADED_FILES_LIST"
+ echo "信息:下载文件清理完成"
+ fi
+}
+
# 检查必要的外部工具
check_required_tools() {
- local required_tools="sudo docker losetup mount umount parted e2fsck resize2fs qemu-img curl tar python3 pip3 rsync git simg2img img2simg dd cat rm mkdir mv cp sed chmod chown ln grep printf id"
+ local required_tools="sudo docker losetup mount umount parted e2fsck resize2fs qemu-img curl tar python3 pip3 rsync git simg2img img2simg dd cat rm mkdir mv cp sed chmod chown ln grep printf id xz"
for cmd in $required_tools; do
if ! command -v "$cmd" &> /dev/null; then
diff --git a/build/functions/devices.sh b/build/functions/devices.sh
index 337c7351..f2cc9750 100755
--- a/build/functions/devices.sh
+++ b/build/functions/devices.sh
@@ -4,7 +4,7 @@
onecloud_rootfs() {
local unpacker="$SRCPATH/image/onecloud/AmlImg_v0.3.1_linux_amd64"
- local source_image="$SRCPATH/image/onecloud/Armbian_by-SilentWind_24.5.0-trunk_Onecloud_bookworm_legacy_5.9.0-rc7_minimal.burn.img"
+ local source_image="$SRCPATH/image/onecloud/Armbian_by-SilentWind_24.5.0-trunk_Onecloud_bookworm_legacy_5.9.0-rc7_minimal_support-dvd-emulation.burn.img"
local bootfs_img="$TMPDIR/bootfs.img"
local rootfs_img="$TMPDIR/rootfs.img"
local bootfs_sparse="$TMPDIR/6.boot.PARTITION.sparse"
@@ -16,6 +16,13 @@ onecloud_rootfs() {
ensure_dir "$TMPDIR"
ensure_dir "$BOOTFS"
+ # 自动下载 AmlImg 工具(如果不存在)
+ download_file_if_missing "$unpacker" || { echo "错误:下载 AmlImg 工具失败" >&2; exit 1; }
+ sudo chmod +x "$unpacker" || { echo "错误:设置 AmlImg 工具执行权限失败" >&2; exit 1; }
+
+ # 自动下载源镜像文件(如果不存在)
+ download_file_if_missing "$source_image" || { echo "错误:下载 Onecloud 原始镜像失败" >&2; exit 1; }
+
echo "信息:解包 Onecloud burn 镜像..."
sudo "$unpacker" unpack "$source_image" "$TMPDIR" || { echo "错误:解包失败" >&2; exit 1; }
@@ -30,7 +37,12 @@ onecloud_rootfs() {
sudo losetup "$bootfs_loopdev" "$bootfs_img" || { echo "错误:关联 bootfs 镜像到 $bootfs_loopdev 失败" >&2; exit 1; }
sudo mount "$bootfs_loopdev" "$BOOTFS" || { echo "错误:挂载 bootfs ($bootfs_loopdev) 失败" >&2; exit 1; }
BOOTFS_MOUNTED=1
- sudo cp "$SRCPATH/image/onecloud/meson8b-onecloud-fix.dtb" "$BOOTFS/dtb/meson8b-onecloud.dtb" || { echo "错误:复制修复后的 DTB 文件失败" >&2; exit 1; }
+
+ # 自动下载 DTB 文件(如果不存在)
+ local dtb_file="$SRCPATH/image/onecloud/meson8b-onecloud-fix.dtb"
+ download_file_if_missing "$dtb_file" || { echo "错误:下载 Onecloud DTB 文件失败" >&2; exit 1; }
+
+ sudo cp "$dtb_file" "$BOOTFS/dtb/meson8b-onecloud.dtb" || { echo "错误:复制修复后的 DTB 文件失败" >&2; exit 1; }
sudo umount "$BOOTFS" || { echo "警告:卸载 bootfs ($BOOTFS) 失败" >&2; BOOTFS_MOUNTED=0; } # 卸载失败不应中断流程
BOOTFS_MOUNTED=0
echo "信息:分离 bootfs loop 设备 $bootfs_loopdev..."
@@ -60,6 +72,10 @@ cumebox2_rootfs() {
echo "信息:准备 Cumebox2 Rootfs..."
ensure_dir "$TMPDIR"
+
+ # 自动下载源镜像文件(如果不存在)
+ download_file_if_missing "$source_image" || { echo "错误:下载 Cumebox2 原始镜像失败" >&2; exit 1; }
+
cp "$source_image" "$target_image" || { echo "错误:复制 Cumebox2 原始镜像失败" >&2; exit 1; }
echo "信息:调整镜像分区大小..."
@@ -86,6 +102,10 @@ chainedbox_rootfs_and_fix_dtb() {
echo "信息:准备 Chainedbox Rootfs 并修复 DTB..."
ensure_dir "$TMPDIR"; ensure_dir "$BOOTFS"
+
+ # 自动下载源镜像文件(如果不存在)
+ download_file_if_missing "$source_image" || { echo "错误:下载 Chainedbox 原始镜像失败" >&2; exit 1; }
+
cp "$source_image" "$target_image" || { echo "错误:复制 Chainedbox 原始镜像失败" >&2; exit 1; }
echo "信息:挂载 boot 分区并修复 DTB..."
@@ -95,7 +115,12 @@ chainedbox_rootfs_and_fix_dtb() {
sudo losetup --offset "$boot_offset" "$bootfs_loopdev" "$target_image" || { echo "错误:设置 boot 分区 loop 设备 $bootfs_loopdev 失败" >&2; exit 1; }
sudo mount "$bootfs_loopdev" "$BOOTFS" || { echo "错误:挂载 boot 分区 ($bootfs_loopdev) 失败" >&2; exit 1; }
BOOTFS_MOUNTED=1
- sudo cp "$SRCPATH/image/chainedbox/rk3328-l1pro-1296mhz-fix.dtb" "$BOOTFS/dtb/rockchip/rk3328-l1pro-1296mhz.dtb" || { echo "错误:复制修复后的 DTB 文件失败" >&2; exit 1; }
+
+ # 自动下载 DTB 文件(如果不存在)
+ local dtb_file="$SRCPATH/image/chainedbox/rk3328-l1pro-1296mhz-fix.dtb"
+ download_file_if_missing "$dtb_file" || { echo "错误:下载 Chainedbox DTB 文件失败" >&2; exit 1; }
+
+ sudo cp "$dtb_file" "$BOOTFS/dtb/rockchip/rk3328-l1pro-1296mhz.dtb" || { echo "错误:复制修复后的 DTB 文件失败" >&2; exit 1; }
sudo umount "$BOOTFS" || { echo "警告:卸载 boot 分区 ($BOOTFS) 失败" >&2; BOOTFS_MOUNTED=0; }
BOOTFS_MOUNTED=0
echo "信息:分离 boot loop 设备 $bootfs_loopdev..."
@@ -116,6 +141,10 @@ vm_rootfs() {
echo "信息:准备 Vm Rootfs..."
ensure_dir "$TMPDIR"
+
+ # 自动下载源镜像文件(如果不存在)
+ download_file_if_missing "$source_image" || { echo "错误:下载 Vm 原始镜像失败" >&2; exit 1; }
+
cp "$source_image" "$target_image" || { echo "错误:复制 Vm 原始镜像失败" >&2; exit 1; }
echo "信息:设置带偏移量的 loop 设备..."
@@ -134,6 +163,10 @@ e900v22c_rootfs() {
echo "信息:准备 E900V22C Rootfs..."
ensure_dir "$TMPDIR"
+
+ # 自动下载源镜像文件(如果不存在)
+ download_file_if_missing "$source_image" || { echo "错误:下载 E900V22C 原始镜像失败" >&2; exit 1; }
+
cp "$source_image" "$target_image" || { echo "错误:复制 E900V22C 原始镜像失败" >&2; exit 1; }
echo "信息:扩展镜像文件 (${add_size_mb}MB)..."
@@ -164,6 +197,10 @@ octopus_flanet_rootfs() {
echo "信息:准备 Octopus-Planet Rootfs..."
ensure_dir "$TMPDIR"; ensure_dir "$BOOTFS"
+
+ # 自动下载源镜像文件(如果不存在)
+ download_file_if_missing "$source_image" || { echo "错误:下载 Octopus-Planet 原始镜像失败" >&2; exit 1; }
+
cp "$source_image" "$target_image" || { echo "错误:复制 Octopus-Planet 原始镜像失败" >&2; exit 1; }
echo "信息:挂载 boot 分区并修改 uEnv.txt (使用 VIM2 DTB)..."
@@ -199,14 +236,30 @@ octopus_flanet_rootfs() {
config_cumebox2_files() {
echo "信息:为 Cumebox2 配置特定文件 (OLED, DTB)..."
ensure_dir "$ROOTFS/etc/oled"
+
+ # 自动下载 Cumebox2 相关文件(如果不存在)
+ local dtb_file="$SRCPATH/image/cumebox2/v-fix.dtb"
+ local ssd_file="$SRCPATH/image/cumebox2/ssd"
+ local config_file="$SRCPATH/image/cumebox2/config.json"
+
+ download_file_if_missing "$dtb_file" || echo "警告:下载 Cumebox2 DTB 失败"
+ download_file_if_missing "$ssd_file" || echo "警告:下载 Cumebox2 ssd 脚本失败"
+ download_file_if_missing "$config_file" || echo "警告:下载 Cumebox2 配置文件失败"
+
# 注意 DTB 路径可能需要根据实际 Armbian 版本调整
- sudo cp "$SRCPATH/image/cumebox2/v-fix.dtb" "$ROOTFS/boot/dtb/amlogic/meson-gxl-s905x-khadas-vim.dtb" || echo "警告:复制 Cumebox2 DTB 失败"
- sudo cp "$SRCPATH/image/cumebox2/ssd" "$ROOTFS/usr/bin/" || echo "警告:复制 Cumebox2 ssd 脚本失败"
+ sudo cp "$dtb_file" "$ROOTFS/boot/dtb/amlogic/meson-gxl-s905x-khadas-vim.dtb" || echo "警告:复制 Cumebox2 DTB 失败"
+ sudo cp "$ssd_file" "$ROOTFS/usr/bin/" || echo "警告:复制 Cumebox2 ssd 脚本失败"
sudo chmod +x "$ROOTFS/usr/bin/ssd" || echo "警告:设置 ssd 脚本执行权限失败"
- sudo cp "$SRCPATH/image/cumebox2/config.json" "$ROOTFS/etc/oled/config.json" || echo "警告:复制 OLED 配置文件失败"
+ sudo cp "$config_file" "$ROOTFS/etc/oled/config.json" || echo "警告:复制 OLED 配置文件失败"
}
config_octopus_flanet_files() {
echo "信息:为 Octopus-Planet 配置特定文件 (model_database.conf)..."
- sudo cp "$SRCPATH/image/octopus-flanet/model_database.conf" "$ROOTFS/etc/model_database.conf" || echo "警告:复制 model_database.conf 失败"
+
+ # 自动下载 Octopus-Planet 相关文件(如果不存在)
+ local config_file="$SRCPATH/image/octopus-flanet/model_database.conf"
+
+ download_file_if_missing "$config_file" || echo "警告:下载 Octopus-Planet 配置文件失败"
+
+ sudo cp "$config_file" "$ROOTFS/etc/model_database.conf" || echo "警告:复制 model_database.conf 失败"
}
\ No newline at end of file
diff --git a/build/functions/install.sh b/build/functions/install.sh
index b22494b3..ba4acb0d 100755
--- a/build/functions/install.sh
+++ b/build/functions/install.sh
@@ -21,7 +21,12 @@ delete_armbian_verify(){
prepare_external_binaries() {
local platform="$1" # linux/armhf or linux/amd64 or linux/aarch64
- local docker_image="registry.cn-hangzhou.aliyuncs.com/silentwind/kvmd-stage-0"
+ # 如果在 GitHub Actions 环境下,使用 silentwind0/kvmd-stage-0,否则用阿里云镜像
+ if is_github_actions; then
+ local docker_image="silentwind0/kvmd-stage-0"
+ else
+ local docker_image="registry.cn-hangzhou.aliyuncs.com/silentwind/kvmd-stage-0"
+ fi
echo "信息:准备外部预编译二进制文件 (平台: $platform)..."
ensure_dir "$PREBUILT_DIR"
@@ -102,7 +107,8 @@ install_base_packages() {
libxkbcommon-x11-0 nginx tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim \\
iptables network-manager curl kmod libmicrohttpd12 libjansson4 libssl3 \\
libsofia-sip-ua0 libglib2.0-0 libopus0 libogg0 libcurl4 libconfig9 \\
- python3-pip net-tools && \\
+ python3-pip net-tools libavcodec59 libavformat59 libavutil57 libswscale6 \\
+ libavfilter8 libavdevice59 v4l-utils libv4l-0 && \\
apt clean && \\
rm -rf /var/lib/apt/lists/*
"
diff --git a/build/functions/packaging.sh b/build/functions/packaging.sh
index 4fb9cb70..4305e7d8 100755
--- a/build/functions/packaging.sh
+++ b/build/functions/packaging.sh
@@ -1,5 +1,21 @@
#!/bin/bash
+# --- 压缩函数 ---
+
+# 压缩镜像文件(仅在 GitHub Actions 环境中)
+compress_image_file() {
+ local file_path="$1"
+
+ if is_github_actions && [[ -f "$file_path" ]]; then
+ echo "信息:压缩镜像文件: $file_path"
+ if xz -9 -vv "$file_path"; then
+ echo "信息:压缩完成: ${file_path}.xz"
+ else
+ echo "警告:压缩文件 $file_path 失败"
+ fi
+ fi
+}
+
# --- 打包函数 ---
pack_img() {
@@ -29,7 +45,22 @@ pack_img() {
sudo qemu-img convert -f raw -O vmdk "$raw_img" "$vmdk_img" || echo "警告:转换为 VMDK 失败"
echo "信息:转换为 VDI..."
sudo qemu-img convert -f raw -O vdi "$raw_img" "$vdi_img" || echo "警告:转换为 VDI 失败"
+
+ # 在 GitHub Actions 环境中压缩 VM 镜像文件
+ if is_github_actions; then
+ echo "信息:在 GitHub Actions 环境中压缩 VM 镜像文件..."
+ compress_image_file "$raw_img"
+ compress_image_file "$vmdk_img"
+ compress_image_file "$vdi_img"
+ fi
+ else
+ # 在 GitHub Actions 环境中压缩镜像文件
+ if is_github_actions; then
+ echo "信息:在 GitHub Actions 环境中压缩镜像文件..."
+ compress_image_file "$OUTPUTDIR/$target_img_name"
+ fi
fi
+
echo "信息:镜像打包完成: $OUTPUTDIR/$target_img_name"
}
@@ -48,6 +79,10 @@ pack_img_onecloud() {
unmount_all
fi
+ # 自动下载 AmlImg 工具(如果不存在)
+ download_file_if_missing "$aml_packer" || { echo "错误:下载 AmlImg 工具失败" >&2; exit 1; }
+ sudo chmod +x "$aml_packer" || { echo "错误:设置 AmlImg 工具执行权限失败" >&2; exit 1; }
+
echo "信息:将 raw rootfs 转换为 sparse image..."
# 先删除可能存在的旧 sparse 文件
sudo rm -f "$rootfs_sparse_img"
@@ -55,11 +90,16 @@ pack_img_onecloud() {
sudo rm "$rootfs_raw_img" # 删除 raw 文件,因为它已被转换
echo "信息:使用 AmlImg 工具打包..."
- sudo chmod +x "$aml_packer"
sudo "$aml_packer" pack "$OUTPUTDIR/$target_img_name" "$TMPDIR/" || { echo "错误:AmlImg 打包失败" >&2; exit 1; }
echo "信息:清理 Onecloud 临时文件..."
sudo rm -f "$TMPDIR/6.boot.PARTITION.sparse" "$TMPDIR/7.rootfs.PARTITION.sparse" "$TMPDIR/dts.img"
+ # 在 GitHub Actions 环境中压缩 Onecloud 镜像文件
+ if is_github_actions; then
+ echo "信息:在 GitHub Actions 环境中压缩 Onecloud 镜像文件..."
+ compress_image_file "$OUTPUTDIR/$target_img_name"
+ fi
+
echo "信息:Onecloud burn 镜像打包完成: $OUTPUTDIR/$target_img_name"
}
\ No newline at end of file
diff --git a/build/init.sh b/build/init.sh
index 00331471..2b57af74 100755
--- a/build/init.sh
+++ b/build/init.sh
@@ -214,7 +214,14 @@ EOF
log_info "视频输入格式已设置为 $VIDFORMAT"
fi
fi
-
+
+ if [ ! -z "$HWENCODER" ]; then
+ if sed -i "s/--h264-hwenc=disabled/--h264-hwenc=$HWENCODER/g" /etc/kvmd/override.yaml; then
+ log_info "硬件编码器已设置为 $HWENCODER"
+ fi
+ fi
+
+
touch /etc/kvmd/.init_flag
log_info "初始化配置完成"
fi
diff --git a/build/record.txt b/build/record.txt
new file mode 100644
index 00000000..77a1e841
--- /dev/null
+++ b/build/record.txt
@@ -0,0 +1,21 @@
+wget https://github.com/hzyitc/AmlImg/releases/download/v0.3.1/AmlImg_v0.3.1_linux_amd64 -O /mnt/src/image/onecloud/AmlImg_v0.3.1_linux_amd64
+chmod +x /mnt/src/image/onecloud/AmlImg_v0.3.1_linux_amd64
+
+
+#!/bin/bash
+# 文件映射脚本
+# 本地目录前缀:/mnt
+# 远程URL前缀:https://files.mofeng.run
+
+LOCAL_PREFIX="/mnt"
+REMOTE_PREFIX="https://files.mofeng.run"
+
+# 文件相对路径
+REL_PATH="src/image/onecloud/Armbian_by-SilentWind_24.5.0-trunk_Onecloud_bookworm_legacy_5.9.0-rc7_minimal_support-dvd-emulation.burn.img"
+
+LOCAL_FILE="$LOCAL_PREFIX/$REL_PATH"
+REMOTE_URL="$REMOTE_PREFIX/$REL_PATH"
+
+echo "下载 $REMOTE_URL 到 $LOCAL_FILE"
+mkdir -p "$(dirname "$LOCAL_FILE")"
+wget -O "$LOCAL_FILE" "$REMOTE_URL"
\ No newline at end of file
diff --git a/check-code.sh b/check-code.sh
new file mode 100755
index 00000000..c76a1f2f
--- /dev/null
+++ b/check-code.sh
@@ -0,0 +1,82 @@
+#!/bin/bash
+# 本地代码质量检查脚本
+
+set -e
+cd "$(dirname "$0")"
+
+echo "🔍 运行代码质量检查..."
+
+# 检查参数,如果有参数则只运行指定的检查
+CHECK_TYPE="${1:-all}"
+
+run_flake8() {
+ echo "📝 运行 flake8 代码风格检查..."
+ flake8 --config=testenv/linters/flake8.ini kvmd testenv/tests *.py
+}
+
+run_pylint() {
+ echo "🔎 运行 pylint 代码质量分析..."
+ pylint -j0 --rcfile=testenv/linters/pylint.ini --output-format=colorized --reports=no kvmd testenv/tests *.py || true
+}
+
+run_mypy() {
+ echo "🔧 运行 mypy 类型检查..."
+ mypy --config-file=testenv/linters/mypy.ini --cache-dir=testenv/.mypy_cache kvmd testenv/tests *.py || true
+}
+
+run_vulture() {
+ echo "💀 运行 vulture 死代码检测..."
+ vulture --ignore-names=_format_P,Plugin --ignore-decorators=@exposed_http,@exposed_ws,@pytest.fixture kvmd testenv/tests *.py testenv/linters/vulture-wl.py || true
+}
+
+run_eslint() {
+ echo "📜 运行 eslint JavaScript检查..."
+ if command -v eslint >/dev/null 2>&1; then
+ eslint --cache-location=/tmp --config=testenv/linters/eslintrc.js --color web/share/js || true
+ else
+ echo "⚠️ eslint 未安装,跳过"
+ fi
+}
+
+run_htmlhint() {
+ echo "📄 运行 htmlhint HTML检查..."
+ if command -v htmlhint >/dev/null 2>&1; then
+ htmlhint --config=testenv/linters/htmlhint.json web/*.html web/*/*.html || true
+ else
+ echo "⚠️ htmlhint 未安装,跳过"
+ fi
+}
+
+run_shellcheck() {
+ echo "🐚 运行 shellcheck Shell脚本检查..."
+ if command -v shellcheck >/dev/null 2>&1; then
+ shellcheck --color=always kvmd.install scripts/* || true
+ else
+ echo "⚠️ shellcheck 未安装,跳过"
+ fi
+}
+
+case "$CHECK_TYPE" in
+ flake8) run_flake8 ;;
+ pylint) run_pylint ;;
+ mypy) run_mypy ;;
+ vulture) run_vulture ;;
+ eslint) run_eslint ;;
+ htmlhint) run_htmlhint ;;
+ shellcheck) run_shellcheck ;;
+ all)
+ run_flake8
+ run_pylint
+ run_mypy
+ run_vulture
+ run_eslint
+ run_htmlhint
+ run_shellcheck
+ ;;
+ *)
+ echo "用法: $0 [flake8|pylint|mypy|vulture|eslint|htmlhint|shellcheck|all]"
+ exit 1
+ ;;
+esac
+
+echo "✅ 代码质量检查完成!"
\ No newline at end of file
diff --git a/configs/kvmd/htpasswd b/configs/kvmd/htpasswd
index a6cbfca9..fce6127b 100644
--- a/configs/kvmd/htpasswd
+++ b/configs/kvmd/htpasswd
@@ -1 +1 @@
-admin:$apr1$.6mu9N8n$xOuGesr4JZZkdiZo/j318.
+admin:{SSHA512}3zSmw/L9zIkpQdX5bcy6HntTxltAzTuGNP6NjHRRgOcNZkA0K+Lsrj3QplO9Gr3BA5MYVVki9rAVnFNCcIdtYC6FkLJWCmHs
diff --git a/configs/kvmd/ipmipasswd b/configs/kvmd/ipmipasswd
index d95fdfe1..f358fa13 100644
--- a/configs/kvmd/ipmipasswd
+++ b/configs/kvmd/ipmipasswd
@@ -1,14 +1,11 @@
-# This file describes the credentials for IPMI users. The first pair separated by colon
-# is the login and password with which the user can access to IPMI. The second pair
-# is the name and password with which the user can access to KVMD API. The arrow is used
-# as a separator and shows the direction of user registration in the system.
+# This file describes the credentials for IPMI users in format "login:password",
+# one per line. The passwords are NOT encrypted.
#
# WARNING! IPMI protocol is completely unsafe by design. In short, the authentication
# process for IPMI 2.0 mandates that the server send a salted SHA1 or MD5 hash of the
-# requested user's password to the client, prior to the client authenticating. Never use
-# the same passwords for KVMD and IPMI users. This default configuration is shown here
-# for example only.
+# requested user's password to the client, prior to the client authenticating.
#
-# And even better not to use IPMI. Instead, you can directly use KVMD API via curl.
+# NEVER use the same passwords for KVMD and IPMI users.
+# This default configuration is shown here just for the example only.
-admin:admin -> admin:admin
+admin:admin
diff --git a/configs/kvmd/main/v4mini-hdmi-rpi4.yaml b/configs/kvmd/main/v4mini-hdmi-rpi4.yaml
new file mode 100644
index 00000000..fe301711
--- /dev/null
+++ b/configs/kvmd/main/v4mini-hdmi-rpi4.yaml
@@ -0,0 +1,97 @@
+# 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
+
+ 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"
+ - "--buffers=6"
+ - "--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
+
+
+media:
+ memsink:
+ h264:
+ sink: "kvmd::ustreamer::h264"
+
+
+vnc:
+ memsink:
+ jpeg:
+ sink: "kvmd::ustreamer::jpeg"
+ h264:
+ sink: "kvmd::ustreamer::h264"
diff --git a/configs/kvmd/main/v4plus-hdmi-rpi4.yaml b/configs/kvmd/main/v4plus-hdmi-rpi4.yaml
index 4b0a9bdd..35533a05 100644
--- a/configs/kvmd/main/v4plus-hdmi-rpi4.yaml
+++ b/configs/kvmd/main/v4plus-hdmi-rpi4.yaml
@@ -17,8 +17,6 @@ kvmd:
hid:
type: otg
- mouse_alt:
- device: /dev/kvmd-hid-mouse-alt
atx:
type: gpio
diff --git a/configs/kvmd/meta.yaml b/configs/kvmd/meta.yaml
index 4127ffd0..4e158310 100644
--- a/configs/kvmd/meta.yaml
+++ b/configs/kvmd/meta.yaml
@@ -4,11 +4,11 @@
# will be displayed in the web interface.
server:
- host: localhost.localdomain
+ host: "@auto"
kvm: {
- base_on: PiKVM,
- app_name: One-KVM,
- main_version: 241204,
- author: SilentWind
+ base_on: "PiKVM",
+ app_name: "One-KVM",
+ main_version: "241204",
+ author: "SilentWind"
}
diff --git a/configs/kvmd/override.yaml b/configs/kvmd/override.yaml
index ba02fce6..a6f31391 100644
--- a/configs/kvmd/override.yaml
+++ b/configs/kvmd/override.yaml
@@ -48,7 +48,7 @@ kvmd:
- "--device=/dev/video0"
- "--persistent"
- "--format=mjpeg"
- - "--encoder=LIBX264-VIDEO"
+ - "--encoder=FFMPEG-VIDEO"
- "--resolution={resolution}"
- "--desired-fps={desired_fps}"
- "--drop-same-frames=30"
@@ -67,6 +67,8 @@ kvmd:
- "--h264-bitrate={h264_bitrate}"
- "--h264-gop={h264_gop}"
- "--h264-preset=ultrafast"
+ - "--h264-hwenc=disabled"
+ - "--h264-hwenc-fallback"
- "--slowdown"
gpio:
drivers:
@@ -168,6 +170,9 @@ otgnet:
- "/bin/true"
pre_stop_cmd:
- "/bin/true"
+ sysctl_cmd:
+ #- "/usr/sbin/sysctl"
+ - "/bin/true"
nginx:
http:
diff --git a/configs/kvmd/vncpasswd b/configs/kvmd/vncpasswd
index 28c2a19d..6c1967a0 100644
--- a/configs/kvmd/vncpasswd
+++ b/configs/kvmd/vncpasswd
@@ -1,12 +1,9 @@
-# This file describes the credentials for VNCAuth. The left part before arrow is a passphrase
-# for VNCAuth. The right part is username and password with which the user can access to KVMD API.
-# The arrow is used as a separator and shows the relationship of user registrations on the system.
+# This file contains passwords for the legacy VNCAuth, one per line.
+# The passwords are NOT encrypted.
#
-# Never use the same passwords for VNC and IPMI users. This default configuration is shown here
-# for example only.
+# WARNING! The VNCAuth method is NOT secure and should not be used at all.
+# But we support it for compatibility with some clients.
#
-# If this file does not contain any entries, VNCAuth will be disabled and you will only be able
-# to login in using your KVMD username and password using VeNCrypt methods.
+# NEVER use the same passwords for KVMD, IPMI and VNCAuth users.
-# pa$$phr@se -> admin:password
-admin -> admin:admin
+admin
diff --git a/configs/nginx/kvmd.ctx-server.conf b/configs/nginx/kvmd.ctx-server.conf
index 335849bb..cd375218 100644
--- a/configs/nginx/kvmd.ctx-server.conf
+++ b/configs/nginx/kvmd.ctx-server.conf
@@ -24,6 +24,7 @@ location @login {
location /login {
root /usr/share/kvmd/web;
+ include /etc/kvmd/nginx/loc-nocache.conf;
auth_request off;
}
@@ -65,6 +66,7 @@ location /api/hid/print {
proxy_pass http://kvmd;
include /etc/kvmd/nginx/loc-proxy.conf;
include /etc/kvmd/nginx/loc-bigpost.conf;
+ proxy_read_timeout 7d;
auth_request off;
}
diff --git a/configs/nginx/loc-bigpost.conf b/configs/nginx/loc-bigpost.conf
index ebd37a6b..7125ecc7 100644
--- a/configs/nginx/loc-bigpost.conf
+++ b/configs/nginx/loc-bigpost.conf
@@ -1,4 +1,2 @@
-limit_rate 6250k;
-limit_rate_after 50k;
client_max_body_size 0;
proxy_request_buffering off;
diff --git a/configs/nginx/nginx.conf.mako b/configs/nginx/nginx.conf.mako
index 9158eda2..8faf5646 100644
--- a/configs/nginx/nginx.conf.mako
+++ b/configs/nginx/nginx.conf.mako
@@ -39,9 +39,9 @@ http {
% if https_enabled:
server {
- listen ${http_port};
+ listen ${http_ipv4}:${http_port};
% if ipv6_enabled:
- listen [::]:${http_port};
+ listen [${http_ipv6}]:${http_port};
% endif
include /etc/kvmd/nginx/certbot.ctx-server.conf;
location / {
@@ -54,9 +54,9 @@ http {
}
server {
- listen ${https_port} ssl http2;
+ listen ${https_ipv4}:${https_port} ssl;
% if ipv6_enabled:
- listen [::]:${https_port} ssl http2;
+ listen [${https_ipv6}]:${https_port} ssl;
% endif
include /etc/kvmd/nginx/ssl.conf;
include /etc/kvmd/nginx/kvmd.ctx-server.conf;
@@ -66,9 +66,9 @@ http {
% else:
server {
- listen ${http_port};
+ listen ${http_ipv4}:${http_port};
% if ipv6_enabled:
- listen [::]:${http_port};
+ listen [${http_ipv6}]:${http_port};
% endif
include /etc/kvmd/nginx/certbot.ctx-server.conf;
include /etc/kvmd/nginx/kvmd.ctx-server.conf;
diff --git a/configs/os/boot-config/v4plus-hdmi-rpi4.txt b/configs/os/boot-config/v4plus-hdmi-rpi4.txt
index 05821ea4..81299abf 100644
--- a/configs/os/boot-config/v4plus-hdmi-rpi4.txt
+++ b/configs/os/boot-config/v4plus-hdmi-rpi4.txt
@@ -3,7 +3,7 @@
initramfs initramfs-linux.img followkernel
hdmi_force_hotplug=1
-gpu_mem=128
+gpu_mem=192
enable_uart=1
dtoverlay=disable-bt
diff --git a/configs/os/cmdline/v4plus-hdmi-rpi4.sed b/configs/os/cmdline/v4plus-hdmi-rpi4.sed
index ee1a5540..e26eb0c5 100644
--- a/configs/os/cmdline/v4plus-hdmi-rpi4.sed
+++ b/configs/os/cmdline/v4plus-hdmi-rpi4.sed
@@ -1 +1 @@
-s/rootwait/rootwait cma=128M/g
+s/rootwait/rootwait cma=192M/g
diff --git a/configs/os/services/kvmd-localhid.service b/configs/os/services/kvmd-localhid.service
new file mode 100644
index 00000000..792ea94e
--- /dev/null
+++ b/configs/os/services/kvmd-localhid.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=PiKVM - Local HID to KVMD proxy
+After=kvmd.service systemd-udevd.service
+
+[Service]
+User=kvmd-localhid
+Group=kvmd-localhid
+Type=simple
+Restart=always
+RestartSec=3
+
+ExecStart=/usr/bin/kvmd-localhid --run
+TimeoutStopSec=3
+
+[Install]
+WantedBy=multi-user.target
diff --git a/configs/os/services/systemd-networkd-wait-online.service.d/11-pikvm-wait-any.conf b/configs/os/services/systemd-networkd-wait-online.service.d/11-pikvm-wait-any.conf
new file mode 100644
index 00000000..fae09255
--- /dev/null
+++ b/configs/os/services/systemd-networkd-wait-online.service.d/11-pikvm-wait-any.conf
@@ -0,0 +1,8 @@
+# Fix https://github.com/pikvm/pikvm/issues/1514:
+# Wait for any single network interface, not all configured ones
+# (Rationale: when user configures Wi-Fi via pikvm.txt or otherwise,
+# we do not delete the Ethernet config, which means it will remain active
+# regardless of whether the user ever intended to use Ethernet.)
+[Service]
+ExecStart=
+ExecStart=/usr/lib/systemd/systemd-networkd-wait-online --any
diff --git a/configs/os/sysusers.conf b/configs/os/sysusers.conf
index 4ab263b5..c7919304 100644
--- a/configs/os/sysusers.conf
+++ b/configs/os/sysusers.conf
@@ -1,8 +1,10 @@
g kvmd - -
+g kvmd-selfauth - -
g kvmd-media - -
g kvmd-pst - -
g kvmd-ipmi - -
g kvmd-vnc - -
+g kvmd-localhid - -
g kvmd-nginx - -
g kvmd-janus - -
g kvmd-certbot - -
@@ -12,6 +14,7 @@ u kvmd-media - "PiKVM - The media proxy"
u kvmd-pst - "PiKVM - Persistent storage" -
u kvmd-ipmi - "PiKVM - IPMI to KVMD proxy" -
u kvmd-vnc - "PiKVM - VNC to KVMD/Streamer proxy" -
+u kvmd-localhid - "PiKVM - Local HID to KVMD proxy" -
u kvmd-nginx - "PiKVM - HTTP entrypoint" -
u kvmd-janus - "PiKVM - Janus WebRTC Gateway" -
u kvmd-certbot - "PiKVM - Certbot-Renew for KVMD-Nginx"
@@ -29,10 +32,16 @@ m kvmd-media kvmd
m kvmd-pst kvmd
m kvmd-ipmi kvmd
+m kvmd-ipmi kvmd-selfauth
m kvmd-vnc kvmd
+m kvmd-vnc kvmd-selfauth
m kvmd-vnc kvmd-certbot
+m kvmd-localhid input
+m kvmd-localhid kvmd
+m kvmd-localhid kvmd-selfauth
+
m kvmd-janus kvmd
m kvmd-janus audio
diff --git a/configs/os/udev/common.rules b/configs/os/udev/common.rules
index 5abb1aa7..97525df5 100644
--- a/configs/os/udev/common.rules
+++ b/configs/os/udev/common.rules
@@ -1,4 +1,15 @@
# Here are described some bindings for PiKVM devices.
# Do not edit this file.
-KERNEL=="ttyACM[0-9]*", SUBSYSTEM=="tty", SUBSYSTEMS=="usb", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="eda3", SYMLINK+="kvmd-hid-bridge"
-KERNEL=="ttyACM[0-9]*", SUBSYSTEM=="tty", SUBSYSTEMS=="usb", ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="1080", SYMLINK+="kvmd-switch"
+
+ACTION!="remove", KERNEL=="ttyACM[0-9]*", SUBSYSTEM=="tty", SUBSYSTEMS=="usb", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="eda3", SYMLINK+="kvmd-hid-bridge"
+ACTION!="remove", KERNEL=="ttyACM[0-9]*", SUBSYSTEM=="tty", SUBSYSTEMS=="usb", ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="1080", SYMLINK+="kvmd-switch"
+
+# Disable USB autosuspend for critical devices
+ACTION!="remove", SUBSYSTEM=="usb", ATTR{idVendor}=="1209", ATTR{idProduct}=="eda3", GOTO="kvmd-usb"
+ACTION!="remove", SUBSYSTEM=="usb", ATTR{idVendor}=="2e8a", ATTR{idProduct}=="1080", GOTO="kvmd-usb"
+GOTO="end"
+
+LABEL="kvmd-usb"
+ATTR{power/control}="on", ATTR{power/autosuspend_delay_ms}="-1"
+
+LABEL="end"
diff --git a/contrib/keymaps/en-us-colemak b/contrib/keymaps/en-us-colemak
new file mode 100644
index 00000000..40b33d47
--- /dev/null
+++ b/contrib/keymaps/en-us-colemak
@@ -0,0 +1,1663 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# generated by qemu-keymap
+# model : pc105
+# layout : us
+# variant : colemak
+# options : -
+
+# name: "English (Colemak)"
+
+# modifiers
+# 0: Shift
+# 1: Lock
+# 2: Control
+# 3: Mod1
+# 4: Mod2
+# 5: Mod3
+# 6: Mod4
+# 7: Mod5
+# 8: NumLock
+# 9: Alt
+# 10: LevelThree
+# 11: Super
+# 12: LevelFive
+# 13: Meta
+# 14: Hyper
+# 15: ScrollLock
+
+# evdev 1 (0x1), QKeyCode "esc", number 0x1
+Escape 0x01
+
+# evdev 2 (0x2), QKeyCode "1", number 0x2
+1 0x02
+exclam 0x02 shift
+
+# evdev 3 (0x3), QKeyCode "2", number 0x3
+2 0x03
+at 0x03 shift
+
+# evdev 4 (0x4), QKeyCode "3", number 0x4
+3 0x04
+numbersign 0x04 shift
+
+# evdev 5 (0x5), QKeyCode "4", number 0x5
+4 0x05
+dollar 0x05 shift
+
+# evdev 6 (0x6), QKeyCode "5", number 0x6
+5 0x06
+percent 0x06 shift
+
+# evdev 7 (0x7), QKeyCode "6", number 0x7
+6 0x07
+asciicircum 0x07 shift
+
+# evdev 8 (0x8), QKeyCode "7", number 0x8
+7 0x08
+ampersand 0x08 shift
+
+# evdev 9 (0x9), QKeyCode "8", number 0x9
+8 0x09
+asterisk 0x09 shift
+
+# evdev 10 (0xa), QKeyCode "9", number 0xa
+9 0x0a
+parenleft 0x0a shift
+
+# evdev 11 (0xb), QKeyCode "0", number 0xb
+0 0x0b
+parenright 0x0b shift
+
+# evdev 12 (0xc), QKeyCode "minus", number 0xc
+minus 0x0c
+underscore 0x0c shift
+
+# evdev 13 (0xd), QKeyCode "equal", number 0xd
+equal 0x0d
+plus 0x0d shift
+
+# evdev 14 (0xe), QKeyCode "backspace", number 0xe
+BackSpace 0x0e
+
+# evdev 15 (0xf), QKeyCode "tab", number 0xf
+Tab 0x0f
+ISO_Left_Tab 0x0f shift
+
+# evdev 16 (0x10), QKeyCode "q", number 0x10
+q 0x10
+Q 0x10 shift
+
+# evdev 17 (0x11), QKeyCode "w", number 0x11
+w 0x11
+W 0x11 shift
+
+# evdev 18 (0x12), QKeyCode "e", number 0x12
+f 0x12
+F 0x12 shift
+
+# evdev 19 (0x13), QKeyCode "r", number 0x13
+p 0x13
+P 0x13 shift
+
+# evdev 20 (0x14), QKeyCode "t", number 0x14
+g 0x14
+G 0x14 shift
+
+# evdev 21 (0x15), QKeyCode "y", number 0x15
+j 0x15
+J 0x15 shift
+
+# evdev 22 (0x16), QKeyCode "u", number 0x16
+l 0x16
+L 0x16 shift
+
+# evdev 23 (0x17), QKeyCode "i", number 0x17
+u 0x17
+U 0x17 shift
+
+# evdev 24 (0x18), QKeyCode "o", number 0x18
+y 0x18
+Y 0x18 shift
+
+# evdev 25 (0x19), QKeyCode "p", number 0x19
+semicolon 0x19
+colon 0x19 shift
+
+# evdev 26 (0x1a), QKeyCode "bracket_left", number 0x1a
+bracketleft 0x1a
+braceleft 0x1a shift
+
+# evdev 27 (0x1b), QKeyCode "bracket_right", number 0x1b
+bracketright 0x1b
+braceright 0x1b shift
+
+# evdev 28 (0x1c), QKeyCode "ret", number 0x1c
+Return 0x1c
+
+# evdev 29 (0x1d), QKeyCode "ctrl", number 0x1d
+Control_L 0x1d
+
+# evdev 30 (0x1e), QKeyCode "a", number 0x1e
+a 0x1e
+A 0x1e shift
+
+# evdev 31 (0x1f), QKeyCode "s", number 0x1f
+r 0x1f
+R 0x1f shift
+
+# evdev 32 (0x20), QKeyCode "d", number 0x20
+s 0x20
+S 0x20 shift
+
+# evdev 33 (0x21), QKeyCode "f", number 0x21
+t 0x21
+T 0x21 shift
+
+# evdev 34 (0x22), QKeyCode "g", number 0x22
+d 0x22
+D 0x22 shift
+
+# evdev 35 (0x23), QKeyCode "h", number 0x23
+h 0x23
+H 0x23 shift
+
+# evdev 36 (0x24), QKeyCode "j", number 0x24
+n 0x24
+N 0x24 shift
+
+# evdev 37 (0x25), QKeyCode "k", number 0x25
+e 0x25
+E 0x25 shift
+
+# evdev 38 (0x26), QKeyCode "l", number 0x26
+i 0x26
+I 0x26 shift
+
+# evdev 39 (0x27), QKeyCode "semicolon", number 0x27
+o 0x27
+O 0x27 shift
+
+# evdev 40 (0x28), QKeyCode "apostrophe", number 0x28
+apostrophe 0x28
+quotedbl 0x28 shift
+
+# evdev 41 (0x29), QKeyCode "grave_accent", number 0x29
+grave 0x29
+asciitilde 0x29 shift
+
+# evdev 42 (0x2a), QKeyCode "shift", number 0x2a
+Shift_L 0x2a
+
+# evdev 43 (0x2b), QKeyCode "backslash", number 0x2b
+backslash 0x2b
+bar 0x2b shift
+
+# evdev 44 (0x2c), QKeyCode "z", number 0x2c
+z 0x2c
+Z 0x2c shift
+
+# evdev 45 (0x2d), QKeyCode "x", number 0x2d
+x 0x2d
+X 0x2d shift
+
+# evdev 46 (0x2e), QKeyCode "c", number 0x2e
+c 0x2e
+C 0x2e shift
+
+# evdev 47 (0x2f), QKeyCode "v", number 0x2f
+v 0x2f
+V 0x2f shift
+
+# evdev 48 (0x30), QKeyCode "b", number 0x30
+b 0x30
+B 0x30 shift
+
+# evdev 49 (0x31), QKeyCode "n", number 0x31
+k 0x31
+K 0x31 shift
+
+# evdev 50 (0x32), QKeyCode "m", number 0x32
+m 0x32
+M 0x32 shift
+
+# evdev 51 (0x33), QKeyCode "comma", number 0x33
+comma 0x33
+less 0x33 shift
+
+# evdev 52 (0x34), QKeyCode "dot", number 0x34
+period 0x34
+greater 0x34 shift
+
+# evdev 53 (0x35), QKeyCode "slash", number 0x35
+slash 0x35
+question 0x35 shift
+
+# evdev 54 (0x36), QKeyCode "shift_r", number 0x36
+Shift_R 0x36
+
+# evdev 55 (0x37), QKeyCode "kp_multiply", number 0x37
+KP_Multiply 0x37
+
+# evdev 56 (0x38), QKeyCode "alt", number 0x38
+Alt_L 0x38
+
+# evdev 57 (0x39), QKeyCode "spc", number 0x39
+space 0x39
+
+# evdev 58 (0x3a), QKeyCode "caps_lock", number 0x3a
+BackSpace 0x3a
+
+# evdev 59 (0x3b), QKeyCode "f1", number 0x3b
+F1 0x3b
+
+# evdev 60 (0x3c), QKeyCode "f2", number 0x3c
+F2 0x3c
+
+# evdev 61 (0x3d), QKeyCode "f3", number 0x3d
+F3 0x3d
+
+# evdev 62 (0x3e), QKeyCode "f4", number 0x3e
+F4 0x3e
+
+# evdev 63 (0x3f), QKeyCode "f5", number 0x3f
+F5 0x3f
+
+# evdev 64 (0x40), QKeyCode "f6", number 0x40
+F6 0x40
+
+# evdev 65 (0x41), QKeyCode "f7", number 0x41
+F7 0x41
+
+# evdev 66 (0x42), QKeyCode "f8", number 0x42
+F8 0x42
+
+# evdev 67 (0x43), QKeyCode "f9", number 0x43
+F9 0x43
+
+# evdev 68 (0x44), QKeyCode "f10", number 0x44
+F10 0x44
+
+# evdev 69 (0x45), QKeyCode "num_lock", number 0x45
+Num_Lock 0x45
+
+# evdev 70 (0x46), QKeyCode "scroll_lock", number 0x46
+Scroll_Lock 0x46
+
+# evdev 71 (0x47), QKeyCode "kp_7", number 0x47
+KP_Home 0x47
+KP_7 0x47 numlock
+
+# evdev 72 (0x48), QKeyCode "kp_8", number 0x48
+KP_Up 0x48
+KP_8 0x48 numlock
+
+# evdev 73 (0x49), QKeyCode "kp_9", number 0x49
+KP_Prior 0x49
+KP_9 0x49 numlock
+
+# evdev 74 (0x4a), QKeyCode "kp_subtract", number 0x4a
+KP_Subtract 0x4a
+
+# evdev 75 (0x4b), QKeyCode "kp_4", number 0x4b
+KP_Left 0x4b
+KP_4 0x4b numlock
+
+# evdev 76 (0x4c), QKeyCode "kp_5", number 0x4c
+KP_Begin 0x4c
+KP_5 0x4c numlock
+
+# evdev 77 (0x4d), QKeyCode "kp_6", number 0x4d
+KP_Right 0x4d
+KP_6 0x4d numlock
+
+# evdev 78 (0x4e), QKeyCode "kp_add", number 0x4e
+KP_Add 0x4e
+
+# evdev 79 (0x4f), QKeyCode "kp_1", number 0x4f
+KP_End 0x4f
+KP_1 0x4f numlock
+
+# evdev 80 (0x50), QKeyCode "kp_2", number 0x50
+KP_Down 0x50
+KP_2 0x50 numlock
+
+# evdev 81 (0x51), QKeyCode "kp_3", number 0x51
+KP_Next 0x51
+KP_3 0x51 numlock
+
+# evdev 82 (0x52), QKeyCode "kp_0", number 0x52
+KP_Insert 0x52
+KP_0 0x52 numlock
+
+# evdev 83 (0x53), QKeyCode "kp_decimal", number 0x53
+KP_Delete 0x53
+KP_Decimal 0x53 numlock
+
+# evdev 84 (0x54): no evdev -> QKeyCode mapping (xkb keysym ISO_Level3_Shift)
+
+# evdev 85 (0x55): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 86 (0x56), QKeyCode "less", number 0x56
+minus 0x56
+underscore 0x56 shift
+
+# evdev 87 (0x57), QKeyCode "f11", number 0x57
+F11 0x57
+
+# evdev 88 (0x58), QKeyCode "f12", number 0x58
+F12 0x58
+
+# evdev 89 (0x59), QKeyCode "ro", number 0x73
+
+# evdev 90 (0x5a): no evdev -> QKeyCode mapping (xkb keysym Katakana)
+
+# evdev 91 (0x5b), QKeyCode "hiragana", number 0x77
+Hiragana 0x77
+
+# evdev 92 (0x5c), QKeyCode "henkan", number 0x79
+Henkan_Mode 0x79
+
+# evdev 93 (0x5d), QKeyCode "katakanahiragana", number 0x70
+Hiragana_Katakana 0x70
+
+# evdev 94 (0x5e), QKeyCode "muhenkan", number 0x7b
+Muhenkan 0x7b
+
+# evdev 95 (0x5f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 96 (0x60), QKeyCode "kp_enter", number 0x9c
+KP_Enter 0x9c
+
+# evdev 97 (0x61), QKeyCode "ctrl_r", number 0x9d
+Control_R 0x9d
+
+# evdev 98 (0x62), QKeyCode "kp_divide", number 0xb5
+KP_Divide 0xb5
+
+# evdev 99 (0x63), QKeyCode "sysrq", number 0x54
+Print 0x54
+
+# evdev 100 (0x64), QKeyCode "alt_r", number 0xb8
+ISO_Level3_Shift 0xb8
+
+# evdev 101 (0x65), QKeyCode "lf", number 0x5b
+Linefeed 0x5b
+
+# evdev 102 (0x66), QKeyCode "home", number 0xc7
+Home 0xc7
+
+# evdev 103 (0x67), QKeyCode "up", number 0xc8
+Up 0xc8
+
+# evdev 104 (0x68), QKeyCode "pgup", number 0xc9
+Prior 0xc9
+
+# evdev 105 (0x69), QKeyCode "left", number 0xcb
+Left 0xcb
+
+# evdev 106 (0x6a), QKeyCode "right", number 0xcd
+Right 0xcd
+
+# evdev 107 (0x6b), QKeyCode "end", number 0xcf
+End 0xcf
+
+# evdev 108 (0x6c), QKeyCode "down", number 0xd0
+Down 0xd0
+
+# evdev 109 (0x6d), QKeyCode "pgdn", number 0xd1
+Next 0xd1
+
+# evdev 110 (0x6e), QKeyCode "insert", number 0xd2
+Insert 0xd2
+
+# evdev 111 (0x6f), QKeyCode "delete", number 0xd3
+Delete 0xd3
+
+# evdev 112 (0x70): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 113 (0x71), QKeyCode "audiomute", number 0xa0
+XF86AudioMute 0xa0
+
+# evdev 114 (0x72), QKeyCode "volumedown", number 0xae
+XF86AudioLowerVolume 0xae
+
+# evdev 115 (0x73), QKeyCode "volumeup", number 0xb0
+XF86AudioRaiseVolume 0xb0
+
+# evdev 116 (0x74), QKeyCode "power", number 0xde
+XF86PowerOff 0xde
+
+# evdev 117 (0x75), QKeyCode "kp_equals", number 0x59
+KP_Equal 0x59
+
+# evdev 118 (0x76): no evdev -> QKeyCode mapping (xkb keysym plusminus)
+
+# evdev 119 (0x77), QKeyCode "pause", number 0xc6
+Pause 0xc6
+
+# evdev 120 (0x78): no evdev -> QKeyCode mapping (xkb keysym XF86LaunchA)
+
+# evdev 121 (0x79), QKeyCode "kp_comma", number 0x7e
+KP_Decimal 0x7e
+
+# evdev 122 (0x7a), QKeyCode "lang1", number 0x72
+Hangul 0x72
+
+# evdev 123 (0x7b), QKeyCode "lang2", number 0x71
+Hangul_Hanja 0x71
+
+# evdev 124 (0x7c), QKeyCode "yen", number 0x7d
+
+# evdev 125 (0x7d), QKeyCode "meta_l", number 0xdb
+Super_L 0xdb
+
+# evdev 126 (0x7e), QKeyCode "meta_r", number 0xdc
+Super_R 0xdc
+
+# evdev 127 (0x7f), QKeyCode "compose", number 0xdd
+Menu 0xdd
+
+# evdev 128 (0x80), QKeyCode "stop", number 0xe8
+Cancel 0xe8
+
+# evdev 129 (0x81), QKeyCode "again", number 0x85
+Redo 0x85
+
+# evdev 130 (0x82), QKeyCode "props", number 0x86
+SunProps 0x86
+
+# evdev 131 (0x83), QKeyCode "undo", number 0x87
+Undo 0x87
+
+# evdev 132 (0x84), QKeyCode "front", number 0x8c
+SunFront 0x8c
+
+# evdev 133 (0x85), QKeyCode "copy", number 0xf8
+XF86Copy 0xf8
+
+# evdev 134 (0x86), QKeyCode "open", number 0x64
+XF86Open 0x64
+
+# evdev 135 (0x87), QKeyCode "paste", number 0x65
+XF86Paste 0x65
+
+# evdev 136 (0x88), QKeyCode "find", number 0xc1
+Find 0xc1
+
+# evdev 137 (0x89), QKeyCode "cut", number 0xbc
+XF86Cut 0xbc
+
+# evdev 138 (0x8a), QKeyCode "help", number 0xf5
+Help 0xf5
+
+# evdev 139 (0x8b), QKeyCode "menu", number 0x9e
+XF86MenuKB 0x9e
+
+# evdev 140 (0x8c), QKeyCode "calculator", number 0xa1
+XF86Calculator 0xa1
+
+# evdev 141 (0x8d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 142 (0x8e), QKeyCode "sleep", number 0xdf
+XF86Sleep 0xdf
+
+# evdev 143 (0x8f), QKeyCode "wake", number 0xe3
+XF86WakeUp 0xe3
+
+# evdev 144 (0x90): no evdev -> QKeyCode mapping (xkb keysym XF86Explorer)
+
+# evdev 145 (0x91): no evdev -> QKeyCode mapping (xkb keysym XF86Send)
+
+# evdev 146 (0x92): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 147 (0x93): no evdev -> QKeyCode mapping (xkb keysym XF86Xfer)
+
+# evdev 148 (0x94): no evdev -> QKeyCode mapping (xkb keysym XF86Launch1)
+
+# evdev 149 (0x95): no evdev -> QKeyCode mapping (xkb keysym XF86Launch2)
+
+# evdev 150 (0x96): no evdev -> QKeyCode mapping (xkb keysym XF86WWW)
+
+# evdev 151 (0x97): no evdev -> QKeyCode mapping (xkb keysym XF86DOS)
+
+# evdev 152 (0x98): no evdev -> QKeyCode mapping (xkb keysym XF86ScreenSaver)
+
+# evdev 153 (0x99): no evdev -> QKeyCode mapping (xkb keysym XF86RotateWindows)
+
+# evdev 154 (0x9a): no evdev -> QKeyCode mapping (xkb keysym XF86TaskPane)
+
+# evdev 155 (0x9b), QKeyCode "mail", number 0xec
+XF86Mail 0xec
+
+# evdev 156 (0x9c), QKeyCode "ac_bookmarks", number 0xe6
+XF86Favorites 0xe6
+
+# evdev 157 (0x9d), QKeyCode "computer", number 0xeb
+XF86MyComputer 0xeb
+
+# evdev 158 (0x9e), QKeyCode "ac_back", number 0xea
+XF86Back 0xea
+
+# evdev 159 (0x9f), QKeyCode "ac_forward", number 0xe9
+XF86Forward 0xe9
+
+# evdev 160 (0xa0): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 161 (0xa1): no evdev -> QKeyCode mapping (xkb keysym XF86Eject)
+
+# evdev 162 (0xa2): no evdev -> QKeyCode mapping (xkb keysym XF86Eject)
+
+# evdev 163 (0xa3), QKeyCode "audionext", number 0x99
+XF86AudioNext 0x99
+
+# evdev 164 (0xa4), QKeyCode "audioplay", number 0xa2
+XF86AudioPlay 0xa2
+XF86AudioPause 0xa2 shift
+
+# evdev 165 (0xa5), QKeyCode "audioprev", number 0x90
+XF86AudioPrev 0x90
+
+# evdev 166 (0xa6), QKeyCode "audiostop", number 0xa4
+XF86AudioStop 0xa4
+XF86Eject 0xa4 shift
+
+# evdev 167 (0xa7): no evdev -> QKeyCode mapping (xkb keysym XF86AudioRecord)
+
+# evdev 168 (0xa8): no evdev -> QKeyCode mapping (xkb keysym XF86AudioRewind)
+
+# evdev 169 (0xa9): no evdev -> QKeyCode mapping (xkb keysym XF86Phone)
+
+# evdev 170 (0xaa): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 171 (0xab): no evdev -> QKeyCode mapping (xkb keysym XF86Tools)
+
+# evdev 172 (0xac), QKeyCode "ac_home", number 0xb2
+XF86HomePage 0xb2
+
+# evdev 173 (0xad), QKeyCode "ac_refresh", number 0xe7
+XF86Reload 0xe7
+
+# evdev 174 (0xae): no evdev -> QKeyCode mapping (xkb keysym XF86Close)
+
+# evdev 175 (0xaf): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 176 (0xb0): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 177 (0xb1): no evdev -> QKeyCode mapping (xkb keysym XF86ScrollUp)
+
+# evdev 178 (0xb2): no evdev -> QKeyCode mapping (xkb keysym XF86ScrollDown)
+
+# evdev 179 (0xb3): no evdev -> QKeyCode mapping (xkb keysym parenleft)
+
+# evdev 180 (0xb4): no evdev -> QKeyCode mapping (xkb keysym parenright)
+
+# evdev 181 (0xb5): no evdev -> QKeyCode mapping (xkb keysym XF86New)
+
+# evdev 182 (0xb6): no evdev -> QKeyCode mapping (xkb keysym Redo)
+
+# evdev 183 (0xb7), QKeyCode "f13", number 0x5d
+XF86Tools 0x5d
+
+# evdev 184 (0xb8), QKeyCode "f14", number 0x5e
+XF86Launch5 0x5e
+
+# evdev 185 (0xb9), QKeyCode "f15", number 0x5f
+XF86Launch6 0x5f
+
+# evdev 186 (0xba), QKeyCode "f16", number 0x55
+XF86Launch7 0x55
+
+# evdev 187 (0xbb), QKeyCode "f17", number 0x83
+XF86Launch8 0x83
+
+# evdev 188 (0xbc), QKeyCode "f18", number 0xf7
+XF86Launch9 0xf7
+
+# evdev 189 (0xbd), QKeyCode "f19", number 0x84
+
+# evdev 190 (0xbe), QKeyCode "f20", number 0x5a
+XF86AudioMicMute 0x5a
+
+# evdev 191 (0xbf), QKeyCode "f21", number 0x74
+XF86TouchpadToggle 0x74
+
+# evdev 192 (0xc0), QKeyCode "f22", number 0xf9
+XF86TouchpadOn 0xf9
+
+# evdev 193 (0xc1), QKeyCode "f23", number 0x6d
+XF86TouchpadOff 0x6d
+
+# evdev 194 (0xc2), QKeyCode "f24", number 0x6f
+
+# evdev 195 (0xc3): no evdev -> QKeyCode mapping (xkb keysym ISO_Level5_Shift)
+
+# evdev 196 (0xc4): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 197 (0xc5): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 198 (0xc6): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 199 (0xc7): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 200 (0xc8): no evdev -> QKeyCode mapping (xkb keysym XF86AudioPlay)
+
+# evdev 201 (0xc9): no evdev -> QKeyCode mapping (xkb keysym XF86AudioPause)
+
+# evdev 202 (0xca): no evdev -> QKeyCode mapping (xkb keysym XF86Launch3)
+
+# evdev 203 (0xcb): no evdev -> QKeyCode mapping (xkb keysym XF86Launch4)
+
+# evdev 204 (0xcc): no evdev -> QKeyCode mapping (xkb keysym XF86LaunchB)
+
+# evdev 205 (0xcd): no evdev -> QKeyCode mapping (xkb keysym XF86Suspend)
+
+# evdev 206 (0xce): no evdev -> QKeyCode mapping (xkb keysym XF86Close)
+
+# evdev 207 (0xcf): no evdev -> QKeyCode mapping (xkb keysym XF86AudioPlay)
+
+# evdev 208 (0xd0): no evdev -> QKeyCode mapping (xkb keysym XF86AudioForward)
+
+# evdev 209 (0xd1): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 210 (0xd2): no evdev -> QKeyCode mapping (xkb keysym Print)
+
+# evdev 211 (0xd3): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 212 (0xd4): no evdev -> QKeyCode mapping (xkb keysym XF86WebCam)
+
+# evdev 213 (0xd5): no evdev -> QKeyCode mapping (xkb keysym XF86AudioPreset)
+
+# evdev 214 (0xd6): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 215 (0xd7): no evdev -> QKeyCode mapping (xkb keysym XF86Mail)
+
+# evdev 216 (0xd8): no evdev -> QKeyCode mapping (xkb keysym XF86Messenger)
+
+# evdev 217 (0xd9): no evdev -> QKeyCode mapping (xkb keysym XF86Search)
+
+# evdev 218 (0xda): no evdev -> QKeyCode mapping (xkb keysym XF86Go)
+
+# evdev 219 (0xdb): no evdev -> QKeyCode mapping (xkb keysym XF86Finance)
+
+# evdev 220 (0xdc): no evdev -> QKeyCode mapping (xkb keysym XF86Game)
+
+# evdev 221 (0xdd): no evdev -> QKeyCode mapping (xkb keysym XF86Shop)
+
+# evdev 222 (0xde): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 223 (0xdf): no evdev -> QKeyCode mapping (xkb keysym Cancel)
+
+# evdev 224 (0xe0): no evdev -> QKeyCode mapping (xkb keysym XF86MonBrightnessDown)
+
+# evdev 225 (0xe1): no evdev -> QKeyCode mapping (xkb keysym XF86MonBrightnessUp)
+
+# evdev 226 (0xe2), QKeyCode "mediaselect", number 0xed
+XF86AudioMedia 0xed
+
+# evdev 227 (0xe3): no evdev -> QKeyCode mapping (xkb keysym XF86Display)
+
+# evdev 228 (0xe4): no evdev -> QKeyCode mapping (xkb keysym XF86KbdLightOnOff)
+
+# evdev 229 (0xe5): no evdev -> QKeyCode mapping (xkb keysym XF86KbdBrightnessDown)
+
+# evdev 230 (0xe6): no evdev -> QKeyCode mapping (xkb keysym XF86KbdBrightnessUp)
+
+# evdev 231 (0xe7): no evdev -> QKeyCode mapping (xkb keysym XF86Send)
+
+# evdev 232 (0xe8): no evdev -> QKeyCode mapping (xkb keysym XF86Reply)
+
+# evdev 233 (0xe9): no evdev -> QKeyCode mapping (xkb keysym XF86MailForward)
+
+# evdev 234 (0xea): no evdev -> QKeyCode mapping (xkb keysym XF86Save)
+
+# evdev 235 (0xeb): no evdev -> QKeyCode mapping (xkb keysym XF86Documents)
+
+# evdev 236 (0xec): no evdev -> QKeyCode mapping (xkb keysym XF86Battery)
+
+# evdev 237 (0xed): no evdev -> QKeyCode mapping (xkb keysym XF86Bluetooth)
+
+# evdev 238 (0xee): no evdev -> QKeyCode mapping (xkb keysym XF86WLAN)
+
+# evdev 239 (0xef): no evdev -> QKeyCode mapping (xkb keysym XF86UWB)
+
+# evdev 240 (0xf0): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 241 (0xf1): no evdev -> QKeyCode mapping (xkb keysym XF86Next_VMode)
+
+# evdev 242 (0xf2): no evdev -> QKeyCode mapping (xkb keysym XF86Prev_VMode)
+
+# evdev 243 (0xf3): no evdev -> QKeyCode mapping (xkb keysym XF86MonBrightnessCycle)
+
+# evdev 244 (0xf4): no evdev -> QKeyCode mapping (xkb keysym XF86BrightnessAuto)
+
+# evdev 245 (0xf5): no evdev -> QKeyCode mapping (xkb keysym XF86DisplayOff)
+
+# evdev 246 (0xf6): no evdev -> QKeyCode mapping (xkb keysym XF86WWAN)
+
+# evdev 247 (0xf7): no evdev -> QKeyCode mapping (xkb keysym XF86RFKill)
+
+# evdev 248 (0xf8): no evdev -> QKeyCode mapping (xkb keysym XF86AudioMicMute)
+
+# evdev 249 (0xf9): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 250 (0xfa): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 251 (0xfb): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 252 (0xfc): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 253 (0xfd): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 254 (0xfe): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 255 (0xff): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 256 (0x100): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 257 (0x101): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 258 (0x102): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 259 (0x103): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 260 (0x104): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 261 (0x105): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 262 (0x106): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 263 (0x107): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 264 (0x108): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 265 (0x109): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 266 (0x10a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 267 (0x10b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 268 (0x10c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 269 (0x10d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 270 (0x10e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 271 (0x10f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 272 (0x110): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 273 (0x111): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 274 (0x112): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 275 (0x113): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 276 (0x114): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 277 (0x115): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 278 (0x116): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 279 (0x117): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 280 (0x118): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 281 (0x119): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 282 (0x11a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 283 (0x11b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 284 (0x11c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 285 (0x11d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 286 (0x11e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 287 (0x11f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 288 (0x120): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 289 (0x121): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 290 (0x122): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 291 (0x123): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 292 (0x124): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 293 (0x125): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 294 (0x126): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 295 (0x127): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 296 (0x128): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 297 (0x129): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 298 (0x12a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 299 (0x12b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 300 (0x12c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 301 (0x12d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 302 (0x12e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 303 (0x12f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 304 (0x130): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 305 (0x131): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 306 (0x132): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 307 (0x133): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 308 (0x134): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 309 (0x135): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 310 (0x136): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 311 (0x137): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 312 (0x138): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 313 (0x139): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 314 (0x13a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 315 (0x13b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 316 (0x13c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 317 (0x13d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 318 (0x13e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 319 (0x13f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 320 (0x140): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 321 (0x141): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 322 (0x142): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 323 (0x143): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 324 (0x144): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 325 (0x145): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 326 (0x146): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 327 (0x147): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 328 (0x148): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 329 (0x149): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 330 (0x14a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 331 (0x14b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 332 (0x14c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 333 (0x14d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 334 (0x14e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 335 (0x14f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 336 (0x150): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 337 (0x151): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 338 (0x152): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 339 (0x153): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 340 (0x154): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 341 (0x155): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 342 (0x156): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 343 (0x157): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 344 (0x158): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 345 (0x159): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 346 (0x15a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 347 (0x15b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 348 (0x15c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 349 (0x15d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 350 (0x15e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 351 (0x15f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 352 (0x160): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 353 (0x161): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 354 (0x162): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 355 (0x163): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 356 (0x164): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 357 (0x165): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 358 (0x166): no evdev -> QKeyCode mapping (xkb keysym XF86Info)
+
+# evdev 359 (0x167): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 360 (0x168): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 361 (0x169): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 362 (0x16a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 363 (0x16b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 364 (0x16c): no evdev -> QKeyCode mapping (xkb keysym XF86Favorites)
+
+# evdev 365 (0x16d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 366 (0x16e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 367 (0x16f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 368 (0x170): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 369 (0x171): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 370 (0x172): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 371 (0x173): no evdev -> QKeyCode mapping (xkb keysym XF86CycleAngle)
+
+# evdev 372 (0x174): no evdev -> QKeyCode mapping (xkb keysym XF86FullScreen)
+
+# evdev 373 (0x175): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 374 (0x176): no evdev -> QKeyCode mapping (xkb keysym XF86Keyboard)
+
+# evdev 375 (0x177): no evdev -> QKeyCode mapping (xkb keysym XF86AspectRatio)
+
+# evdev 376 (0x178): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 377 (0x179): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 378 (0x17a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 379 (0x17b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 380 (0x17c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 381 (0x17d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 382 (0x17e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 383 (0x17f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 384 (0x180): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 385 (0x181): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 386 (0x182): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 387 (0x183): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 388 (0x184): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 389 (0x185): no evdev -> QKeyCode mapping (xkb keysym XF86DVD)
+
+# evdev 390 (0x186): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 391 (0x187): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 392 (0x188): no evdev -> QKeyCode mapping (xkb keysym XF86Audio)
+
+# evdev 393 (0x189): no evdev -> QKeyCode mapping (xkb keysym XF86Video)
+
+# evdev 394 (0x18a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 395 (0x18b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 396 (0x18c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 397 (0x18d): no evdev -> QKeyCode mapping (xkb keysym XF86Calendar)
+
+# evdev 398 (0x18e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 399 (0x18f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 400 (0x190): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 401 (0x191): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 402 (0x192): no evdev -> QKeyCode mapping (xkb keysym XF86ChannelUp)
+
+# evdev 403 (0x193): no evdev -> QKeyCode mapping (xkb keysym XF86ChannelDown)
+
+# evdev 404 (0x194): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 405 (0x195): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 406 (0x196): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 407 (0x197): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 408 (0x198): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 409 (0x199): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 410 (0x19a): no evdev -> QKeyCode mapping (xkb keysym XF86AudioRandomPlay)
+
+# evdev 411 (0x19b): no evdev -> QKeyCode mapping (xkb keysym XF86Break)
+
+# evdev 412 (0x19c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 413 (0x19d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 414 (0x19e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 415 (0x19f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 416 (0x1a0): no evdev -> QKeyCode mapping (xkb keysym XF86VideoPhone)
+
+# evdev 417 (0x1a1): no evdev -> QKeyCode mapping (xkb keysym XF86Game)
+
+# evdev 418 (0x1a2): no evdev -> QKeyCode mapping (xkb keysym XF86ZoomIn)
+
+# evdev 419 (0x1a3): no evdev -> QKeyCode mapping (xkb keysym XF86ZoomOut)
+
+# evdev 420 (0x1a4): no evdev -> QKeyCode mapping (xkb keysym XF86ZoomReset)
+
+# evdev 421 (0x1a5): no evdev -> QKeyCode mapping (xkb keysym XF86Word)
+
+# evdev 422 (0x1a6): no evdev -> QKeyCode mapping (xkb keysym XF86Editor)
+
+# evdev 423 (0x1a7): no evdev -> QKeyCode mapping (xkb keysym XF86Excel)
+
+# evdev 424 (0x1a8): no evdev -> QKeyCode mapping (xkb keysym XF86GraphicsEditor)
+
+# evdev 425 (0x1a9): no evdev -> QKeyCode mapping (xkb keysym XF86Presentation)
+
+# evdev 426 (0x1aa): no evdev -> QKeyCode mapping (xkb keysym XF86Database)
+
+# evdev 427 (0x1ab): no evdev -> QKeyCode mapping (xkb keysym XF86News)
+
+# evdev 428 (0x1ac): no evdev -> QKeyCode mapping (xkb keysym XF86Voicemail)
+
+# evdev 429 (0x1ad): no evdev -> QKeyCode mapping (xkb keysym XF86Addressbook)
+
+# evdev 430 (0x1ae): no evdev -> QKeyCode mapping (xkb keysym XF86Messenger)
+
+# evdev 431 (0x1af): no evdev -> QKeyCode mapping (xkb keysym XF86DisplayToggle)
+
+# evdev 432 (0x1b0): no evdev -> QKeyCode mapping (xkb keysym XF86SpellCheck)
+
+# evdev 433 (0x1b1): no evdev -> QKeyCode mapping (xkb keysym XF86LogOff)
+
+# evdev 434 (0x1b2): no evdev -> QKeyCode mapping (xkb keysym dollar)
+
+# evdev 435 (0x1b3): no evdev -> QKeyCode mapping (xkb keysym EuroSign)
+
+# evdev 436 (0x1b4): no evdev -> QKeyCode mapping (xkb keysym XF86FrameBack)
+
+# evdev 437 (0x1b5): no evdev -> QKeyCode mapping (xkb keysym XF86FrameForward)
+
+# evdev 438 (0x1b6): no evdev -> QKeyCode mapping (xkb keysym XF86ContextMenu)
+
+# evdev 439 (0x1b7): no evdev -> QKeyCode mapping (xkb keysym XF86MediaRepeat)
+
+# evdev 440 (0x1b8): no evdev -> QKeyCode mapping (xkb keysym XF8610ChannelsUp)
+
+# evdev 441 (0x1b9): no evdev -> QKeyCode mapping (xkb keysym XF8610ChannelsDown)
+
+# evdev 442 (0x1ba): no evdev -> QKeyCode mapping (xkb keysym XF86Images)
+
+# evdev 443 (0x1bb): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 444 (0x1bc): no evdev -> QKeyCode mapping (xkb keysym XF86NotificationCenter)
+
+# evdev 445 (0x1bd): no evdev -> QKeyCode mapping (xkb keysym XF86PickupPhone)
+
+# evdev 446 (0x1be): no evdev -> QKeyCode mapping (xkb keysym XF86HangupPhone)
+
+# evdev 447 (0x1bf): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 448 (0x1c0): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 449 (0x1c1): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 450 (0x1c2): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 451 (0x1c3): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 452 (0x1c4): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 453 (0x1c5): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 454 (0x1c6): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 455 (0x1c7): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 456 (0x1c8): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 457 (0x1c9): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 458 (0x1ca): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 459 (0x1cb): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 460 (0x1cc): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 461 (0x1cd): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 462 (0x1ce): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 463 (0x1cf): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 464 (0x1d0): no evdev -> QKeyCode mapping (xkb keysym XF86Fn)
+
+# evdev 465 (0x1d1): no evdev -> QKeyCode mapping (xkb keysym XF86Fn_Esc)
+
+# evdev 466 (0x1d2): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 467 (0x1d3): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 468 (0x1d4): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 469 (0x1d5): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 470 (0x1d6): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 471 (0x1d7): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 472 (0x1d8): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 473 (0x1d9): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 474 (0x1da): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 475 (0x1db): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 476 (0x1dc): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 477 (0x1dd): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 478 (0x1de): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 479 (0x1df): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 480 (0x1e0): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 481 (0x1e1): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 482 (0x1e2): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 483 (0x1e3): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 484 (0x1e4): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 485 (0x1e5): no evdev -> QKeyCode mapping (xkb keysym XF86FnRightShift)
+
+# evdev 486 (0x1e6): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 487 (0x1e7): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 488 (0x1e8): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 489 (0x1e9): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 490 (0x1ea): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 491 (0x1eb): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 492 (0x1ec): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 493 (0x1ed): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 494 (0x1ee): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 495 (0x1ef): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 496 (0x1f0): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 497 (0x1f1): no evdev -> QKeyCode mapping (xkb keysym braille_dot_1)
+
+# evdev 498 (0x1f2): no evdev -> QKeyCode mapping (xkb keysym braille_dot_2)
+
+# evdev 499 (0x1f3): no evdev -> QKeyCode mapping (xkb keysym braille_dot_3)
+
+# evdev 500 (0x1f4): no evdev -> QKeyCode mapping (xkb keysym braille_dot_4)
+
+# evdev 501 (0x1f5): no evdev -> QKeyCode mapping (xkb keysym braille_dot_5)
+
+# evdev 502 (0x1f6): no evdev -> QKeyCode mapping (xkb keysym braille_dot_6)
+
+# evdev 503 (0x1f7): no evdev -> QKeyCode mapping (xkb keysym braille_dot_7)
+
+# evdev 504 (0x1f8): no evdev -> QKeyCode mapping (xkb keysym braille_dot_8)
+
+# evdev 505 (0x1f9): no evdev -> QKeyCode mapping (xkb keysym braille_dot_9)
+
+# evdev 506 (0x1fa): no evdev -> QKeyCode mapping (xkb keysym braille_dot_1)
+
+# evdev 507 (0x1fb): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 508 (0x1fc): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 509 (0x1fd): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 510 (0x1fe): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 511 (0x1ff): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 512 (0x200): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric0)
+
+# evdev 513 (0x201): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric1)
+
+# evdev 514 (0x202): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric2)
+
+# evdev 515 (0x203): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric3)
+
+# evdev 516 (0x204): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric4)
+
+# evdev 517 (0x205): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric5)
+
+# evdev 518 (0x206): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric6)
+
+# evdev 519 (0x207): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric7)
+
+# evdev 520 (0x208): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric8)
+
+# evdev 521 (0x209): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric9)
+
+# evdev 522 (0x20a): no evdev -> QKeyCode mapping (xkb keysym XF86NumericStar)
+
+# evdev 523 (0x20b): no evdev -> QKeyCode mapping (xkb keysym XF86NumericPound)
+
+# evdev 524 (0x20c): no evdev -> QKeyCode mapping (xkb keysym XF86NumericA)
+
+# evdev 525 (0x20d): no evdev -> QKeyCode mapping (xkb keysym XF86NumericB)
+
+# evdev 526 (0x20e): no evdev -> QKeyCode mapping (xkb keysym XF86NumericC)
+
+# evdev 527 (0x20f): no evdev -> QKeyCode mapping (xkb keysym XF86NumericD)
+
+# evdev 528 (0x210): no evdev -> QKeyCode mapping (xkb keysym XF86CameraFocus)
+
+# evdev 529 (0x211): no evdev -> QKeyCode mapping (xkb keysym XF86WPSButton)
+
+# evdev 530 (0x212): no evdev -> QKeyCode mapping (xkb keysym XF86TouchpadToggle)
+
+# evdev 531 (0x213): no evdev -> QKeyCode mapping (xkb keysym XF86TouchpadOn)
+
+# evdev 532 (0x214): no evdev -> QKeyCode mapping (xkb keysym XF86TouchpadOff)
+
+# evdev 533 (0x215): no evdev -> QKeyCode mapping (xkb keysym XF86CameraZoomIn)
+
+# evdev 534 (0x216): no evdev -> QKeyCode mapping (xkb keysym XF86CameraZoomOut)
+
+# evdev 535 (0x217): no evdev -> QKeyCode mapping (xkb keysym XF86CameraUp)
+
+# evdev 536 (0x218): no evdev -> QKeyCode mapping (xkb keysym XF86CameraDown)
+
+# evdev 537 (0x219): no evdev -> QKeyCode mapping (xkb keysym XF86CameraLeft)
+
+# evdev 538 (0x21a): no evdev -> QKeyCode mapping (xkb keysym XF86CameraRight)
+
+# evdev 539 (0x21b): no evdev -> QKeyCode mapping (xkb keysym XF86AttendantOn)
+
+# evdev 540 (0x21c): no evdev -> QKeyCode mapping (xkb keysym XF86AttendantOff)
+
+# evdev 541 (0x21d): no evdev -> QKeyCode mapping (xkb keysym XF86AttendantToggle)
+
+# evdev 542 (0x21e): no evdev -> QKeyCode mapping (xkb keysym XF86LightsToggle)
+
+# evdev 543 (0x21f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 544 (0x220): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 545 (0x221): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 546 (0x222): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 547 (0x223): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 548 (0x224): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 549 (0x225): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 550 (0x226): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 551 (0x227): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 552 (0x228): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 553 (0x229): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 554 (0x22a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 555 (0x22b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 556 (0x22c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 557 (0x22d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 558 (0x22e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 559 (0x22f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 560 (0x230): no evdev -> QKeyCode mapping (xkb keysym XF86ALSToggle)
+
+# evdev 561 (0x231): no evdev -> QKeyCode mapping (xkb keysym XF86RotationLockToggle)
+
+# evdev 562 (0x232): no evdev -> QKeyCode mapping (xkb keysym XF86RefreshRateToggle)
+
+# evdev 563 (0x233): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 564 (0x234): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 565 (0x235): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 566 (0x236): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 567 (0x237): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 568 (0x238): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 569 (0x239): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 570 (0x23a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 571 (0x23b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 572 (0x23c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 573 (0x23d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 574 (0x23e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 575 (0x23f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 576 (0x240): no evdev -> QKeyCode mapping (xkb keysym XF86Buttonconfig)
+
+# evdev 577 (0x241): no evdev -> QKeyCode mapping (xkb keysym XF86Taskmanager)
+
+# evdev 578 (0x242): no evdev -> QKeyCode mapping (xkb keysym XF86Journal)
+
+# evdev 579 (0x243): no evdev -> QKeyCode mapping (xkb keysym XF86ControlPanel)
+
+# evdev 580 (0x244): no evdev -> QKeyCode mapping (xkb keysym XF86AppSelect)
+
+# evdev 581 (0x245): no evdev -> QKeyCode mapping (xkb keysym XF86Screensaver)
+
+# evdev 582 (0x246): no evdev -> QKeyCode mapping (xkb keysym XF86VoiceCommand)
+
+# evdev 583 (0x247): no evdev -> QKeyCode mapping (xkb keysym XF86Assistant)
+
+# evdev 584 (0x248): no evdev -> QKeyCode mapping (xkb keysym ISO_Next_Group)
+
+# evdev 585 (0x249): no evdev -> QKeyCode mapping (xkb keysym XF86EmojiPicker)
+
+# evdev 586 (0x24a): no evdev -> QKeyCode mapping (xkb keysym XF86Dictate)
+
+# evdev 587 (0x24b): no evdev -> QKeyCode mapping (xkb keysym XF86CameraAccessEnable)
+
+# evdev 588 (0x24c): no evdev -> QKeyCode mapping (xkb keysym XF86CameraAccessDisable)
+
+# evdev 589 (0x24d): no evdev -> QKeyCode mapping (xkb keysym XF86CameraAccessToggle)
+
+# evdev 590 (0x24e): no evdev -> QKeyCode mapping (xkb keysym XF86Accessibility)
+
+# evdev 591 (0x24f): no evdev -> QKeyCode mapping (xkb keysym XF86DoNotDisturb)
+
+# evdev 592 (0x250): no evdev -> QKeyCode mapping (xkb keysym XF86BrightnessMin)
+
+# evdev 593 (0x251): no evdev -> QKeyCode mapping (xkb keysym XF86BrightnessMax)
+
+# evdev 594 (0x252): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 595 (0x253): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 596 (0x254): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 597 (0x255): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 598 (0x256): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 599 (0x257): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 600 (0x258): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 601 (0x259): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 602 (0x25a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 603 (0x25b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 604 (0x25c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 605 (0x25d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 606 (0x25e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 607 (0x25f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 608 (0x260): no evdev -> QKeyCode mapping (xkb keysym XF86KbdInputAssistPrev)
+
+# evdev 609 (0x261): no evdev -> QKeyCode mapping (xkb keysym XF86KbdInputAssistNext)
+
+# evdev 610 (0x262): no evdev -> QKeyCode mapping (xkb keysym XF86KbdInputAssistPrevgroup)
+
+# evdev 611 (0x263): no evdev -> QKeyCode mapping (xkb keysym XF86KbdInputAssistNextgroup)
+
+# evdev 612 (0x264): no evdev -> QKeyCode mapping (xkb keysym XF86KbdInputAssistAccept)
+
+# evdev 613 (0x265): no evdev -> QKeyCode mapping (xkb keysym XF86KbdInputAssistCancel)
+
+# evdev 614 (0x266): no evdev -> QKeyCode mapping (xkb keysym XF86RightUp)
+
+# evdev 615 (0x267): no evdev -> QKeyCode mapping (xkb keysym XF86RightDown)
+
+# evdev 616 (0x268): no evdev -> QKeyCode mapping (xkb keysym XF86LeftUp)
+
+# evdev 617 (0x269): no evdev -> QKeyCode mapping (xkb keysym XF86LeftDown)
+
+# evdev 618 (0x26a): no evdev -> QKeyCode mapping (xkb keysym XF86RootMenu)
+
+# evdev 619 (0x26b): no evdev -> QKeyCode mapping (xkb keysym XF86MediaTopMenu)
+
+# evdev 620 (0x26c): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric11)
+
+# evdev 621 (0x26d): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric12)
+
+# evdev 622 (0x26e): no evdev -> QKeyCode mapping (xkb keysym XF86AudioDesc)
+
+# evdev 623 (0x26f): no evdev -> QKeyCode mapping (xkb keysym XF863DMode)
+
+# evdev 624 (0x270): no evdev -> QKeyCode mapping (xkb keysym XF86NextFavorite)
+
+# evdev 625 (0x271): no evdev -> QKeyCode mapping (xkb keysym XF86StopRecord)
+
+# evdev 626 (0x272): no evdev -> QKeyCode mapping (xkb keysym XF86PauseRecord)
+
+# evdev 627 (0x273): no evdev -> QKeyCode mapping (xkb keysym XF86VOD)
+
+# evdev 628 (0x274): no evdev -> QKeyCode mapping (xkb keysym XF86Unmute)
+
+# evdev 629 (0x275): no evdev -> QKeyCode mapping (xkb keysym XF86FastReverse)
+
+# evdev 630 (0x276): no evdev -> QKeyCode mapping (xkb keysym XF86SlowReverse)
+
+# evdev 631 (0x277): no evdev -> QKeyCode mapping (xkb keysym XF86Data)
+
+# evdev 632 (0x278): no evdev -> QKeyCode mapping (xkb keysym XF86OnScreenKeyboard)
+
+# evdev 633 (0x279): no evdev -> QKeyCode mapping (xkb keysym XF86PrivacyScreenToggle)
+
+# evdev 634 (0x27a): no evdev -> QKeyCode mapping (xkb keysym XF86SelectiveScreenshot)
+
+# evdev 635 (0x27b): no evdev -> QKeyCode mapping (xkb keysym XF86NextElement)
+
+# evdev 636 (0x27c): no evdev -> QKeyCode mapping (xkb keysym XF86PreviousElement)
+
+# evdev 637 (0x27d): no evdev -> QKeyCode mapping (xkb keysym XF86AutopilotEngageToggle)
+
+# evdev 638 (0x27e): no evdev -> QKeyCode mapping (xkb keysym XF86MarkWaypoint)
+
+# evdev 639 (0x27f): no evdev -> QKeyCode mapping (xkb keysym XF86Sos)
+
+# evdev 640 (0x280): no evdev -> QKeyCode mapping (xkb keysym XF86NavChart)
+
+# evdev 641 (0x281): no evdev -> QKeyCode mapping (xkb keysym XF86FishingChart)
+
+# evdev 642 (0x282): no evdev -> QKeyCode mapping (xkb keysym XF86SingleRangeRadar)
+
+# evdev 643 (0x283): no evdev -> QKeyCode mapping (xkb keysym XF86DualRangeRadar)
+
+# evdev 644 (0x284): no evdev -> QKeyCode mapping (xkb keysym XF86RadarOverlay)
+
+# evdev 645 (0x285): no evdev -> QKeyCode mapping (xkb keysym XF86TraditionalSonar)
+
+# evdev 646 (0x286): no evdev -> QKeyCode mapping (xkb keysym XF86ClearvuSonar)
+
+# evdev 647 (0x287): no evdev -> QKeyCode mapping (xkb keysym XF86SidevuSonar)
+
+# evdev 648 (0x288): no evdev -> QKeyCode mapping (xkb keysym XF86NavInfo)
+
+# evdev 649 (0x289): no evdev -> QKeyCode mapping (xkb keysym XF86BrightnessAdjust)
+
+# evdev 650 (0x28a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 651 (0x28b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 652 (0x28c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 653 (0x28d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 654 (0x28e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 655 (0x28f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 656 (0x290): no evdev -> QKeyCode mapping (xkb keysym XF86Macro1)
+
+# evdev 657 (0x291): no evdev -> QKeyCode mapping (xkb keysym XF86Macro2)
+
+# evdev 658 (0x292): no evdev -> QKeyCode mapping (xkb keysym XF86Macro3)
+
+# evdev 659 (0x293): no evdev -> QKeyCode mapping (xkb keysym XF86Macro4)
+
+# evdev 660 (0x294): no evdev -> QKeyCode mapping (xkb keysym XF86Macro5)
+
+# evdev 661 (0x295): no evdev -> QKeyCode mapping (xkb keysym XF86Macro6)
+
+# evdev 662 (0x296): no evdev -> QKeyCode mapping (xkb keysym XF86Macro7)
+
+# evdev 663 (0x297): no evdev -> QKeyCode mapping (xkb keysym XF86Macro8)
+
+# evdev 664 (0x298): no evdev -> QKeyCode mapping (xkb keysym XF86Macro9)
+
+# evdev 665 (0x299): no evdev -> QKeyCode mapping (xkb keysym XF86Macro10)
+
+# evdev 666 (0x29a): no evdev -> QKeyCode mapping (xkb keysym XF86Macro11)
+
+# evdev 667 (0x29b): no evdev -> QKeyCode mapping (xkb keysym XF86Macro12)
+
+# evdev 668 (0x29c): no evdev -> QKeyCode mapping (xkb keysym XF86Macro13)
+
+# evdev 669 (0x29d): no evdev -> QKeyCode mapping (xkb keysym XF86Macro14)
+
+# evdev 670 (0x29e): no evdev -> QKeyCode mapping (xkb keysym XF86Macro15)
+
+# evdev 671 (0x29f): no evdev -> QKeyCode mapping (xkb keysym XF86Macro16)
+
+# evdev 672 (0x2a0): no evdev -> QKeyCode mapping (xkb keysym XF86Macro17)
+
+# evdev 673 (0x2a1): no evdev -> QKeyCode mapping (xkb keysym XF86Macro18)
+
+# evdev 674 (0x2a2): no evdev -> QKeyCode mapping (xkb keysym XF86Macro19)
+
+# evdev 675 (0x2a3): no evdev -> QKeyCode mapping (xkb keysym XF86Macro20)
+
+# evdev 676 (0x2a4): no evdev -> QKeyCode mapping (xkb keysym XF86Macro21)
+
+# evdev 677 (0x2a5): no evdev -> QKeyCode mapping (xkb keysym XF86Macro22)
+
+# evdev 678 (0x2a6): no evdev -> QKeyCode mapping (xkb keysym XF86Macro23)
+
+# evdev 679 (0x2a7): no evdev -> QKeyCode mapping (xkb keysym XF86Macro24)
+
+# evdev 680 (0x2a8): no evdev -> QKeyCode mapping (xkb keysym XF86Macro25)
+
+# evdev 681 (0x2a9): no evdev -> QKeyCode mapping (xkb keysym XF86Macro26)
+
+# evdev 682 (0x2aa): no evdev -> QKeyCode mapping (xkb keysym XF86Macro27)
+
+# evdev 683 (0x2ab): no evdev -> QKeyCode mapping (xkb keysym XF86Macro28)
+
+# evdev 684 (0x2ac): no evdev -> QKeyCode mapping (xkb keysym XF86Macro29)
+
+# evdev 685 (0x2ad): no evdev -> QKeyCode mapping (xkb keysym XF86Macro30)
+
+# evdev 686 (0x2ae): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 687 (0x2af): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 688 (0x2b0): no evdev -> QKeyCode mapping (xkb keysym XF86MacroRecordStart)
+
+# evdev 689 (0x2b1): no evdev -> QKeyCode mapping (xkb keysym XF86MacroRecordStop)
+
+# evdev 690 (0x2b2): no evdev -> QKeyCode mapping (xkb keysym XF86MacroPresetCycle)
+
+# evdev 691 (0x2b3): no evdev -> QKeyCode mapping (xkb keysym XF86MacroPreset1)
+
+# evdev 692 (0x2b4): no evdev -> QKeyCode mapping (xkb keysym XF86MacroPreset2)
+
+# evdev 693 (0x2b5): no evdev -> QKeyCode mapping (xkb keysym XF86MacroPreset3)
+
+# evdev 694 (0x2b6): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 695 (0x2b7): no evdev -> QKeyCode mapping (xkb keysym NoSymbol)
+
+# evdev 696 (0x2b8): no evdev -> QKeyCode mapping (xkb keysym XF86KbdLcdMenu1)
+
+# evdev 697 (0x2b9): no evdev -> QKeyCode mapping (xkb keysym XF86KbdLcdMenu2)
+
+# evdev 698 (0x2ba): no evdev -> QKeyCode mapping (xkb keysym XF86KbdLcdMenu3)
+
+# evdev 699 (0x2bb): no evdev -> QKeyCode mapping (xkb keysym XF86KbdLcdMenu4)
+
+# evdev 700 (0x2bc): no evdev -> QKeyCode mapping (xkb keysym XF86KbdLcdMenu5)
+
+#
+# quirks section start
+#
+# Sometimes multiple keysyms map to the same keycodes.
+# The keycode -> keysym lookup finds only one of the
+# keysyms. So append them here.
+#
+
+Print 0x54
+Sys_Req 0x54
+Execute 0x54
+KP_Decimal 0x53 numlock
+KP_Separator 0x53 numlock
+Alt_R 0xb8
+ISO_Level3_Shift 0xb8
+Mode_switch 0xb8
+
+# quirks section end
diff --git a/contrib/keymaps/fr b/contrib/keymaps/fr
index 82ca812c..00211f10 100644
--- a/contrib/keymaps/fr
+++ b/contrib/keymaps/fr
@@ -49,13 +49,15 @@ oneeighth 0x03 shift altgr
quotedbl 0x04
3 0x04 shift
numbersign 0x04 altgr
-sterling 0x04 shift altgr
+# KVMD
+#sterling 0x04 shift altgr
# evdev 5 (0x5), QKeyCode "4", number 0x5
apostrophe 0x05
4 0x05 shift
braceleft 0x05 altgr
-dollar 0x05 shift altgr
+# KVMD
+#dollar 0x05 shift altgr
# evdev 6 (0x6), QKeyCode "5", number 0x6
parenleft 0x06
@@ -91,7 +93,8 @@ plusminus 0x0a shift altgr
agrave 0x0b
0 0x0b shift
at 0x0b altgr
-degree 0x0b shift altgr
+# KVMD
+#degree 0x0b shift altgr
# evdev 12 (0xc), QKeyCode "minus", number 0xc
parenright 0x0c
@@ -122,7 +125,8 @@ AE 0x10 shift altgr
z 0x11
Z 0x11 shift
guillemotleft 0x11 altgr
-less 0x11 shift altgr
+#KVMD
+#less 0x11 shift altgr
# evdev 18 (0x12), QKeyCode "e", number 0x12
e 0x12
@@ -200,7 +204,8 @@ Greek_OMEGA 0x1e shift altgr
s 0x1f
S 0x1f shift
ssharp 0x1f altgr
-section 0x1f shift altgr
+# KVMD
+#section 0x1f shift altgr
# evdev 32 (0x20), QKeyCode "d", number 0x20
d 0x20
@@ -247,7 +252,8 @@ Lstroke 0x26 shift altgr
# evdev 39 (0x27), QKeyCode "semicolon", number 0x27
m 0x27
M 0x27 shift
-mu 0x27 altgr
+# KVMD
+#mu 0x27 altgr
masculine 0x27 shift altgr
# evdev 40 (0x28), QKeyCode "apostrophe", number 0x28
@@ -280,7 +286,8 @@ Lstroke 0x2c shift altgr
x 0x2d
X 0x2d shift
guillemotright 0x2d altgr
-greater 0x2d shift altgr
+# KVMD
+#greater 0x2d shift altgr
# evdev 46 (0x2e), QKeyCode "c", number 0x2e
c 0x2e
diff --git a/genmap.py b/genmap.py
index 61952c9e..50767257 100755
--- a/genmap.py
+++ b/genmap.py
@@ -69,9 +69,10 @@ class _X11Key:
@dataclasses.dataclass(frozen=True)
class _KeyMapping:
web_name: str
+ evdev_name: str
mcu_code: int
usb_key: _UsbKey
- ps2_key: _Ps2Key
+ ps2_key: (_Ps2Key | None)
at1_code: int
x11_keys: set[_X11Key]
@@ -107,7 +108,9 @@ def _parse_usb_key(key: str) -> _UsbKey:
return _UsbKey(code, is_modifier)
-def _parse_ps2_key(key: str) -> _Ps2Key:
+def _parse_ps2_key(key: str) -> (_Ps2Key | None):
+ if ":" not in key:
+ return None
(code_type, raw_code) = key.split(":")
return _Ps2Key(
code=int(raw_code, 16),
@@ -122,6 +125,7 @@ def _read_keymap_csv(path: str) -> list[_KeyMapping]:
if len(row) >= 6:
keymap.append(_KeyMapping(
web_name=row["web_name"],
+ evdev_name=row["evdev_name"],
mcu_code=int(row["mcu_code"]),
usb_key=_parse_usb_key(row["usb_key"]),
ps2_key=_parse_ps2_key(row["ps2_key"]),
@@ -150,6 +154,7 @@ def main() -> None:
# Fields list:
# - Web
+ # - Linux/evdev
# - MCU code
# - USB code (^ for the modifier mask)
# - PS/2 key
diff --git a/hid/arduino/Makefile b/hid/arduino/Makefile
index 652ddd6e..265a0c79 100644
--- a/hid/arduino/Makefile
+++ b/hid/arduino/Makefile
@@ -24,8 +24,8 @@ upload:
bash -ex -c " \
current=`cat .current`; \
if [ '$($@_CURRENT)' == 'spi' ] || [ '$($@_CURRENT)' == 'aum' ]; then \
- gpioset 0 25=1; \
- gpioset 0 25=0; \
+ gpioset -c gpiochip0 -t 30ms,0 25=1; \
+ gpioset -c gpiochip0 -t 30ms,0 25=0; \
fi \
"
platformio run --environment '$($@_CURRENT)' --project-conf 'platformio-$($@_CONFIG).ini' --target upload
diff --git a/hid/arduino/avrdude-rpi.conf b/hid/arduino/avrdude-rpi.conf
index 8a6f5460..210380a8 100644
--- a/hid/arduino/avrdude-rpi.conf
+++ b/hid/arduino/avrdude-rpi.conf
@@ -2,6 +2,7 @@ programmer
id = "rpi";
desc = "RPi SPI programmer";
type = "linuxspi";
+ prog_modes = PM_ISP;
reset = 25;
baudrate = 400000;
;
diff --git a/hid/arduino/lib/drivers-avr/ps2/keymap.h b/hid/arduino/lib/drivers-avr/ps2/keymap.h
index b864c8c1..e2187208 100644
--- a/hid/arduino/lib/drivers-avr/ps2/keymap.h
+++ b/hid/arduino/lib/drivers-avr/ps2/keymap.h
@@ -148,5 +148,8 @@ void keymapPs2(uint8_t code, Ps2KeyType *ps2_type, uint8_t *ps2_code) {
case 109: *ps2_type = PS2_KEY_TYPE_REG; *ps2_code = 19; return; // KanaMode
case 110: *ps2_type = PS2_KEY_TYPE_REG; *ps2_code = 100; return; // Convert
case 111: *ps2_type = PS2_KEY_TYPE_REG; *ps2_code = 103; return; // NonConvert
+ case 112: *ps2_type = PS2_KEY_TYPE_SPEC; *ps2_code = 35; return; // AudioVolumeMute
+ case 113: *ps2_type = PS2_KEY_TYPE_SPEC; *ps2_code = 50; return; // AudioVolumeUp
+ case 114: *ps2_type = PS2_KEY_TYPE_SPEC; *ps2_code = 33; return; // AudioVolumeDown
}
}
diff --git a/hid/arduino/lib/drivers-avr/ps2/keymap.h.mako b/hid/arduino/lib/drivers-avr/ps2/keymap.h.mako
index 8e31e44d..6a1cd543 100644
--- a/hid/arduino/lib/drivers-avr/ps2/keymap.h.mako
+++ b/hid/arduino/lib/drivers-avr/ps2/keymap.h.mako
@@ -38,7 +38,9 @@ void keymapPs2(uint8_t code, Ps2KeyType *ps2_type, uint8_t *ps2_code) {
switch (code) {
% for km in sorted(keymap, key=operator.attrgetter("mcu_code")):
+ % if km.ps2_key is not None:
case ${km.mcu_code}: *ps2_type = PS2_KEY_TYPE_${km.ps2_key.type.upper()}; *ps2_code = ${km.ps2_key.code}; return; // ${km.web_name}
+ % endif
% endfor
}
}
diff --git a/hid/arduino/lib/drivers/usb-keymap.h b/hid/arduino/lib/drivers/usb-keymap.h
index 95134453..8c1421cf 100644
--- a/hid/arduino/lib/drivers/usb-keymap.h
+++ b/hid/arduino/lib/drivers/usb-keymap.h
@@ -136,6 +136,10 @@ uint8_t keymapUsb(uint8_t code) {
case 109: return 136; // KanaMode
case 110: return 138; // Convert
case 111: return 139; // NonConvert
+ case 112: return 127; // AudioVolumeMute
+ case 113: return 128; // AudioVolumeUp
+ case 114: return 129; // AudioVolumeDown
+ case 115: return 111; // F20
default: return 0;
}
}
diff --git a/hid/arduino/platformio-avr.ini b/hid/arduino/platformio-avr.ini
index 8484ef50..4c8c9144 100644
--- a/hid/arduino/platformio-avr.ini
+++ b/hid/arduino/platformio-avr.ini
@@ -82,8 +82,6 @@ build_flags =
-DCDC_DISABLED
upload_protocol = custom
upload_flags =
- -C
- $PROJECT_PACKAGES_DIR/tool-avrdude/avrdude.conf
-C
+avrdude-rpi.conf
-P
diff --git a/hid/pico/Makefile b/hid/pico/Makefile
index 86b79b0f..e76b6cef 100644
--- a/hid/pico/Makefile
+++ b/hid/pico/Makefile
@@ -28,11 +28,14 @@ define libdep
endef
.pico-sdk:
$(call libdep,pico-sdk,raspberrypi/pico-sdk,6a7db34ff63345a7badec79ebea3aaef1712f374)
+.pico-sdk.patches: .pico-sdk
+ patch -d .pico-sdk -p1 < patches/pico-sdk.patch
+ touch .pico-sdk.patches
.tinyusb:
$(call libdep,tinyusb,hathach/tinyusb,d713571cd44f05d2fc72efc09c670787b74106e0)
.ps2x2pico:
$(call libdep,ps2x2pico,No0ne/ps2x2pico,26ce89d597e598bb0ac636622e064202d91a9efc)
-deps: .pico-sdk .tinyusb .ps2x2pico
+deps: .pico-sdk .pico-sdk.patches .tinyusb .ps2x2pico
.PHONY: deps
diff --git a/hid/pico/patches/pico-sdk.patch b/hid/pico/patches/pico-sdk.patch
new file mode 100644
index 00000000..03502e1e
--- /dev/null
+++ b/hid/pico/patches/pico-sdk.patch
@@ -0,0 +1,10 @@
+diff --git a/tools/pioasm/CMakeLists.txt b/tools/pioasm/CMakeLists.txt
+index 322408a..fc8e4b8 100644
+--- a/tools/pioasm/CMakeLists.txt
++++ b/tools/pioasm/CMakeLists.txt
+@@ -1,4 +1,4 @@
+-cmake_minimum_required(VERSION 3.4)
++cmake_minimum_required(VERSION 3.5)
+ project(pioasm CXX)
+
+ set(CMAKE_CXX_STANDARD 11)
diff --git a/hid/pico/src/ph_usb_keymap.h b/hid/pico/src/ph_usb_keymap.h
index f2481e41..c7d98ef8 100644
--- a/hid/pico/src/ph_usb_keymap.h
+++ b/hid/pico/src/ph_usb_keymap.h
@@ -138,6 +138,10 @@ inline u8 ph_usb_keymap(u8 key) {
case 109: return 136; // KanaMode
case 110: return 138; // Convert
case 111: return 139; // NonConvert
+ case 112: return 127; // AudioVolumeMute
+ case 113: return 128; // AudioVolumeUp
+ case 114: return 129; // AudioVolumeDown
+ case 115: return 111; // F20
}
return 0;
}
diff --git a/keymap.csv b/keymap.csv
index daf33c24..93454eb7 100644
--- a/keymap.csv
+++ b/keymap.csv
@@ -1,112 +1,116 @@
-web_name,mcu_code,usb_key,ps2_key,at1_code,x11_names
-KeyA,1,0x04,reg:0x1c,0x1e,"^XK_A,XK_a"
-KeyB,2,0x05,reg:0x32,0x30,"^XK_B,XK_b"
-KeyC,3,0x06,reg:0x21,0x2e,"^XK_C,XK_c"
-KeyD,4,0x07,reg:0x23,0x20,"^XK_D,XK_d"
-KeyE,5,0x08,reg:0x24,0x12,"^XK_E,XK_e"
-KeyF,6,0x09,reg:0x2b,0x21,"^XK_F,XK_f"
-KeyG,7,0x0a,reg:0x34,0x22,"^XK_G,XK_g"
-KeyH,8,0x0b,reg:0x33,0x23,"^XK_H,XK_h"
-KeyI,9,0x0c,reg:0x43,0x17,"^XK_I,XK_i"
-KeyJ,10,0x0d,reg:0x3b,0x24,"^XK_J,XK_j"
-KeyK,11,0x0e,reg:0x42,0x25,"^XK_K,XK_k"
-KeyL,12,0x0f,reg:0x4b,0x26,"^XK_L,XK_l"
-KeyM,13,0x10,reg:0x3a,0x32,"^XK_M,XK_m"
-KeyN,14,0x11,reg:0x31,0x31,"^XK_N,XK_n"
-KeyO,15,0x12,reg:0x44,0x18,"^XK_O,XK_o"
-KeyP,16,0x13,reg:0x4d,0x19,"^XK_P,XK_p"
-KeyQ,17,0x14,reg:0x15,0x10,"^XK_Q,XK_q"
-KeyR,18,0x15,reg:0x2d,0x13,"^XK_R,XK_r"
-KeyS,19,0x16,reg:0x1b,0x1f,"^XK_S,XK_s"
-KeyT,20,0x17,reg:0x2c,0x14,"^XK_T,XK_t"
-KeyU,21,0x18,reg:0x3c,0x16,"^XK_U,XK_u"
-KeyV,22,0x19,reg:0x2a,0x2f,"^XK_V,XK_v"
-KeyW,23,0x1a,reg:0x1d,0x11,"^XK_W,XK_w"
-KeyX,24,0x1b,reg:0x22,0x2d,"^XK_X,XK_x"
-KeyY,25,0x1c,reg:0x35,0x15,"^XK_Y,XK_y"
-KeyZ,26,0x1d,reg:0x1a,0x2c,"^XK_Z,XK_z"
-Digit1,27,0x1e,reg:0x16,0x02,"XK_1,^XK_exclam"
-Digit2,28,0x1f,reg:0x1e,0x03,"XK_2,^XK_at"
-Digit3,29,0x20,reg:0x26,0x04,"XK_3,^XK_numbersign"
-Digit4,30,0x21,reg:0x25,0x05,"XK_4,^XK_dollar"
-Digit5,31,0x22,reg:0x2e,0x06,"XK_5,^XK_percent"
-Digit6,32,0x23,reg:0x36,0x07,"XK_6,^XK_asciicircum"
-Digit7,33,0x24,reg:0x3d,0x08,"XK_7,^XK_ampersand"
-Digit8,34,0x25,reg:0x3e,0x09,"XK_8,^XK_asterisk"
-Digit9,35,0x26,reg:0x46,0x0a,"XK_9,^XK_parenleft"
-Digit0,36,0x27,reg:0x45,0x0b,"XK_0,^XK_parenright"
-Enter,37,0x28,reg:0x5a,0x1c,XK_Return
-Escape,38,0x29,reg:0x76,0x01,XK_Escape
-Backspace,39,0x2a,reg:0x66,0x0e,XK_BackSpace
-Tab,40,0x2b,reg:0x0d,0x0f,XK_Tab
-Space,41,0x2c,reg:0x29,0x39,XK_space
-Minus,42,0x2d,reg:0x4e,0x0c,"XK_minus,^XK_underscore"
-Equal,43,0x2e,reg:0x55,0x0d,"XK_equal,^XK_plus"
-BracketLeft,44,0x2f,reg:0x54,0x1a,"XK_bracketleft,^XK_braceleft"
-BracketRight,45,0x30,reg:0x5b,0x1b,"XK_bracketright,^XK_braceright"
-Backslash,46,0x31,reg:0x5d,0x2b,"XK_backslash,^XK_bar"
-Semicolon,47,0x33,reg:0x4c,0x27,"XK_semicolon,^XK_colon"
-Quote,48,0x34,reg:0x52,0x28,"XK_apostrophe,^XK_quotedbl"
-Backquote,49,0x35,reg:0x0e,0x29,"XK_grave,^XK_asciitilde"
-Comma,50,0x36,reg:0x41,0x33,"XK_comma,^XK_less"
-Period,51,0x37,reg:0x49,0x34,"XK_period,^XK_greater"
-Slash,52,0x38,reg:0x4a,0x35,"XK_slash,^XK_question"
-CapsLock,53,0x39,reg:0x58,0x3a,XK_Caps_Lock
-F1,54,0x3a,reg:0x05,0x3b,XK_F1
-F2,55,0x3b,reg:0x06,0x3c,XK_F2
-F3,56,0x3c,reg:0x04,0x3d,XK_F3
-F4,57,0x3d,reg:0x0c,0x3e,XK_F4
-F5,58,0x3e,reg:0x03,0x3f,XK_F5
-F6,59,0x3f,reg:0x0b,0x40,XK_F6
-F7,60,0x40,reg:0x83,0x41,XK_F7
-F8,61,0x41,reg:0x0a,0x42,XK_F8
-F9,62,0x42,reg:0x01,0x43,XK_F9
-F10,63,0x43,reg:0x09,0x44,XK_F10
-F11,64,0x44,reg:0x78,0x57,XK_F11
-F12,65,0x45,reg:0x07,0x58,XK_F12
-PrintScreen,66,0x46,print:0xff,0x54,XK_Sys_Req
-Insert,67,0x49,spec:0x70,0xe052,XK_Insert
-Home,68,0x4a,spec:0x6c,0xe047,XK_Home
-PageUp,69,0x4b,spec:0x7d,0xe049,XK_Page_Up
-Delete,70,0x4c,spec:0x71,0xe053,XK_Delete
-End,71,0x4d,spec:0x69,0xe04f,XK_End
-PageDown,72,0x4e,spec:0x7a,0xe051,XK_Page_Down
-ArrowRight,73,0x4f,spec:0x74,0xe04d,XK_Right
-ArrowLeft,74,0x50,spec:0x6b,0xe04b,XK_Left
-ArrowDown,75,0x51,spec:0x72,0xe050,XK_Down
-ArrowUp,76,0x52,spec:0x75,0xe048,XK_Up
-ControlLeft,77,^0x01,reg:0x14,0x1d,XK_Control_L
-ShiftLeft,78,^0x02,reg:0x12,0x2a,XK_Shift_L
-AltLeft,79,^0x04,reg:0x11,0x38,XK_Alt_L
-MetaLeft,80,^0x08,spec:0x1f,0xe05b,"XK_Meta_L,XK_Super_L"
-ControlRight,81,^0x10,spec:0x14,0xe01d,XK_Control_R
-ShiftRight,82,^0x20,reg:0x59,0x36,XK_Shift_R
-AltRight,83,^0x40,spec:0x11,0xe038,"XK_Alt_R,XK_ISO_Level3_Shift"
-MetaRight,84,^0x80,spec:0x27,0xe05c,"XK_Meta_R,XK_Super_R"
-Pause,85,0x48,pause:0xff,0xe046,XK_Pause
-ScrollLock,86,0x47,reg:0x7e,0x46,XK_Scroll_Lock
-NumLock,87,0x53,reg:0x77,0x45,XK_Num_Lock
-ContextMenu,88,0x65,spec:0x2f,0xe05d,XK_Menu
-NumpadDivide,89,0x54,spec:0x4a,0xe035,XK_KP_Divide
-NumpadMultiply,90,0x55,reg:0x7c,0x37,XK_multiply
-NumpadSubtract,91,0x56,reg:0x7b,0x4a,XK_KP_Subtract
-NumpadAdd,92,0x57,reg:0x79,0x4e,XK_KP_Add
-NumpadEnter,93,0x58,spec:0x5a,0xe01c,XK_KP_Enter
-Numpad1,94,0x59,reg:0x69,0x4f,XK_KP_1
-Numpad2,95,0x5a,reg:0x72,0x50,XK_KP_2
-Numpad3,96,0x5b,reg:0x7a,0x51,XK_KP_3
-Numpad4,97,0x5c,reg:0x6b,0x4b,XK_KP_4
-Numpad5,98,0x5d,reg:0x73,0x4c,XK_KP_5
-Numpad6,99,0x5e,reg:0x74,0x4d,XK_KP_6
-Numpad7,100,0x5f,reg:0x6c,0x47,XK_KP_7
-Numpad8,101,0x60,reg:0x75,0x48,XK_KP_8
-Numpad9,102,0x61,reg:0x7d,0x49,XK_KP_9
-Numpad0,103,0x62,reg:0x70,0x52,XK_KP_0
-NumpadDecimal,104,0x63,reg:0x71,0x53,XK_KP_Decimal
-Power,105,0x66,spec:0x5e,0xe05e,XK_XF86_Sleep
-IntlBackslash,106,0x64,reg:0x61,0x56,""
-IntlYen,107,0x89,reg:0x6a,0x7d,""
-IntlRo,108,0x87,reg:0x51,0x73,""
-KanaMode,109,0x88,reg:0x13,0x70,""
-Convert,110,0x8a,reg:0x64,0x79,""
-NonConvert,111,0x8b,reg:0x67,0x7b,""
+web_name,evdev_name,mcu_code,usb_key,ps2_key,at1_code,x11_names
+KeyA,KEY_A,1,0x04,reg:0x1c,0x1e,"^XK_A,XK_a"
+KeyB,KEY_B,2,0x05,reg:0x32,0x30,"^XK_B,XK_b"
+KeyC,KEY_C,3,0x06,reg:0x21,0x2e,"^XK_C,XK_c"
+KeyD,KEY_D,4,0x07,reg:0x23,0x20,"^XK_D,XK_d"
+KeyE,KEY_E,5,0x08,reg:0x24,0x12,"^XK_E,XK_e"
+KeyF,KEY_F,6,0x09,reg:0x2b,0x21,"^XK_F,XK_f"
+KeyG,KEY_G,7,0x0a,reg:0x34,0x22,"^XK_G,XK_g"
+KeyH,KEY_H,8,0x0b,reg:0x33,0x23,"^XK_H,XK_h"
+KeyI,KEY_I,9,0x0c,reg:0x43,0x17,"^XK_I,XK_i"
+KeyJ,KEY_J,10,0x0d,reg:0x3b,0x24,"^XK_J,XK_j"
+KeyK,KEY_K,11,0x0e,reg:0x42,0x25,"^XK_K,XK_k"
+KeyL,KEY_L,12,0x0f,reg:0x4b,0x26,"^XK_L,XK_l"
+KeyM,KEY_M,13,0x10,reg:0x3a,0x32,"^XK_M,XK_m"
+KeyN,KEY_N,14,0x11,reg:0x31,0x31,"^XK_N,XK_n"
+KeyO,KEY_O,15,0x12,reg:0x44,0x18,"^XK_O,XK_o"
+KeyP,KEY_P,16,0x13,reg:0x4d,0x19,"^XK_P,XK_p"
+KeyQ,KEY_Q,17,0x14,reg:0x15,0x10,"^XK_Q,XK_q"
+KeyR,KEY_R,18,0x15,reg:0x2d,0x13,"^XK_R,XK_r"
+KeyS,KEY_S,19,0x16,reg:0x1b,0x1f,"^XK_S,XK_s"
+KeyT,KEY_T,20,0x17,reg:0x2c,0x14,"^XK_T,XK_t"
+KeyU,KEY_U,21,0x18,reg:0x3c,0x16,"^XK_U,XK_u"
+KeyV,KEY_V,22,0x19,reg:0x2a,0x2f,"^XK_V,XK_v"
+KeyW,KEY_W,23,0x1a,reg:0x1d,0x11,"^XK_W,XK_w"
+KeyX,KEY_X,24,0x1b,reg:0x22,0x2d,"^XK_X,XK_x"
+KeyY,KEY_Y,25,0x1c,reg:0x35,0x15,"^XK_Y,XK_y"
+KeyZ,KEY_Z,26,0x1d,reg:0x1a,0x2c,"^XK_Z,XK_z"
+Digit1,KEY_1,27,0x1e,reg:0x16,0x02,"XK_1,^XK_exclam"
+Digit2,KEY_2,28,0x1f,reg:0x1e,0x03,"XK_2,^XK_at"
+Digit3,KEY_3,29,0x20,reg:0x26,0x04,"XK_3,^XK_numbersign"
+Digit4,KEY_4,30,0x21,reg:0x25,0x05,"XK_4,^XK_dollar"
+Digit5,KEY_5,31,0x22,reg:0x2e,0x06,"XK_5,^XK_percent"
+Digit6,KEY_6,32,0x23,reg:0x36,0x07,"XK_6,^XK_asciicircum"
+Digit7,KEY_7,33,0x24,reg:0x3d,0x08,"XK_7,^XK_ampersand"
+Digit8,KEY_8,34,0x25,reg:0x3e,0x09,"XK_8,^XK_asterisk"
+Digit9,KEY_9,35,0x26,reg:0x46,0x0a,"XK_9,^XK_parenleft"
+Digit0,KEY_0,36,0x27,reg:0x45,0x0b,"XK_0,^XK_parenright"
+Enter,KEY_ENTER,37,0x28,reg:0x5a,0x1c,XK_Return
+Escape,KEY_ESC,38,0x29,reg:0x76,0x01,XK_Escape
+Backspace,KEY_BACKSPACE,39,0x2a,reg:0x66,0x0e,XK_BackSpace
+Tab,KEY_TAB,40,0x2b,reg:0x0d,0x0f,XK_Tab
+Space,KEY_SPACE,41,0x2c,reg:0x29,0x39,XK_space
+Minus,KEY_MINUS,42,0x2d,reg:0x4e,0x0c,"XK_minus,^XK_underscore"
+Equal,KEY_EQUAL,43,0x2e,reg:0x55,0x0d,"XK_equal,^XK_plus"
+BracketLeft,KEY_LEFTBRACE,44,0x2f,reg:0x54,0x1a,"XK_bracketleft,^XK_braceleft"
+BracketRight,KEY_RIGHTBRACE,45,0x30,reg:0x5b,0x1b,"XK_bracketright,^XK_braceright"
+Backslash,KEY_BACKSLASH,46,0x31,reg:0x5d,0x2b,"XK_backslash,^XK_bar"
+Semicolon,KEY_SEMICOLON,47,0x33,reg:0x4c,0x27,"XK_semicolon,^XK_colon"
+Quote,KEY_APOSTROPHE,48,0x34,reg:0x52,0x28,"XK_apostrophe,^XK_quotedbl"
+Backquote,KEY_GRAVE,49,0x35,reg:0x0e,0x29,"XK_grave,^XK_asciitilde"
+Comma,KEY_COMMA,50,0x36,reg:0x41,0x33,"XK_comma,^XK_less"
+Period,KEY_DOT,51,0x37,reg:0x49,0x34,"XK_period,^XK_greater"
+Slash,KEY_SLASH,52,0x38,reg:0x4a,0x35,"XK_slash,^XK_question"
+CapsLock,KEY_CAPSLOCK,53,0x39,reg:0x58,0x3a,XK_Caps_Lock
+F1,KEY_F1,54,0x3a,reg:0x05,0x3b,XK_F1
+F2,KEY_F2,55,0x3b,reg:0x06,0x3c,XK_F2
+F3,KEY_F3,56,0x3c,reg:0x04,0x3d,XK_F3
+F4,KEY_F4,57,0x3d,reg:0x0c,0x3e,XK_F4
+F5,KEY_F5,58,0x3e,reg:0x03,0x3f,XK_F5
+F6,KEY_F6,59,0x3f,reg:0x0b,0x40,XK_F6
+F7,KEY_F7,60,0x40,reg:0x83,0x41,XK_F7
+F8,KEY_F8,61,0x41,reg:0x0a,0x42,XK_F8
+F9,KEY_F9,62,0x42,reg:0x01,0x43,XK_F9
+F10,KEY_F10,63,0x43,reg:0x09,0x44,XK_F10
+F11,KEY_F11,64,0x44,reg:0x78,0x57,XK_F11
+F12,KEY_F12,65,0x45,reg:0x07,0x58,XK_F12
+PrintScreen,KEY_SYSRQ,66,0x46,print:0xff,0x54,XK_Sys_Req
+Insert,KEY_INSERT,67,0x49,spec:0x70,0xe052,XK_Insert
+Home,KEY_HOME,68,0x4a,spec:0x6c,0xe047,XK_Home
+PageUp,KEY_PAGEUP,69,0x4b,spec:0x7d,0xe049,XK_Page_Up
+Delete,KEY_DELETE,70,0x4c,spec:0x71,0xe053,XK_Delete
+End,KEY_END,71,0x4d,spec:0x69,0xe04f,XK_End
+PageDown,KEY_PAGEDOWN,72,0x4e,spec:0x7a,0xe051,XK_Page_Down
+ArrowRight,KEY_RIGHT,73,0x4f,spec:0x74,0xe04d,XK_Right
+ArrowLeft,KEY_LEFT,74,0x50,spec:0x6b,0xe04b,XK_Left
+ArrowDown,KEY_DOWN,75,0x51,spec:0x72,0xe050,XK_Down
+ArrowUp,KEY_UP,76,0x52,spec:0x75,0xe048,XK_Up
+ControlLeft,KEY_LEFTCTRL,77,^0x01,reg:0x14,0x1d,XK_Control_L
+ShiftLeft,KEY_LEFTSHIFT,78,^0x02,reg:0x12,0x2a,XK_Shift_L
+AltLeft,KEY_LEFTALT,79,^0x04,reg:0x11,0x38,XK_Alt_L
+MetaLeft,KEY_LEFTMETA,80,^0x08,spec:0x1f,0xe05b,"XK_Meta_L,XK_Super_L"
+ControlRight,KEY_RIGHTCTRL,81,^0x10,spec:0x14,0xe01d,XK_Control_R
+ShiftRight,KEY_RIGHTSHIFT,82,^0x20,reg:0x59,0x36,XK_Shift_R
+AltRight,KEY_RIGHTALT,83,^0x40,spec:0x11,0xe038,"XK_Alt_R,XK_ISO_Level3_Shift"
+MetaRight,KEY_RIGHTMETA,84,^0x80,spec:0x27,0xe05c,"XK_Meta_R,XK_Super_R"
+Pause,KEY_PAUSE,85,0x48,pause:0xff,0xe046,XK_Pause
+ScrollLock,KEY_SCROLLLOCK,86,0x47,reg:0x7e,0x46,XK_Scroll_Lock
+NumLock,KEY_NUMLOCK,87,0x53,reg:0x77,0x45,XK_Num_Lock
+ContextMenu,KEY_CONTEXT_MENU,88,0x65,spec:0x2f,0xe05d,XK_Menu
+NumpadDivide,KEY_KPSLASH,89,0x54,spec:0x4a,0xe035,XK_KP_Divide
+NumpadMultiply,KEY_KPASTERISK,90,0x55,reg:0x7c,0x37,XK_multiply
+NumpadSubtract,KEY_KPMINUS,91,0x56,reg:0x7b,0x4a,XK_KP_Subtract
+NumpadAdd,KEY_KPPLUS,92,0x57,reg:0x79,0x4e,XK_KP_Add
+NumpadEnter,KEY_KPENTER,93,0x58,spec:0x5a,0xe01c,XK_KP_Enter
+Numpad1,KEY_KP1,94,0x59,reg:0x69,0x4f,XK_KP_1
+Numpad2,KEY_KP2,95,0x5a,reg:0x72,0x50,XK_KP_2
+Numpad3,KEY_KP3,96,0x5b,reg:0x7a,0x51,XK_KP_3
+Numpad4,KEY_KP4,97,0x5c,reg:0x6b,0x4b,XK_KP_4
+Numpad5,KEY_KP5,98,0x5d,reg:0x73,0x4c,XK_KP_5
+Numpad6,KEY_KP6,99,0x5e,reg:0x74,0x4d,XK_KP_6
+Numpad7,KEY_KP7,100,0x5f,reg:0x6c,0x47,XK_KP_7
+Numpad8,KEY_KP8,101,0x60,reg:0x75,0x48,XK_KP_8
+Numpad9,KEY_KP9,102,0x61,reg:0x7d,0x49,XK_KP_9
+Numpad0,KEY_KP0,103,0x62,reg:0x70,0x52,XK_KP_0
+NumpadDecimal,KEY_KPDOT,104,0x63,reg:0x71,0x53,XK_KP_Decimal
+Power,KEY_POWER,105,0x66,spec:0x5e,0xe05e,XK_XF86_Sleep
+IntlBackslash,KEY_102ND,106,0x64,reg:0x61,0x56,
+IntlYen,KEY_YEN,107,0x89,reg:0x6a,0x7d,
+IntlRo,KEY_RO,108,0x87,reg:0x51,0x73,
+KanaMode,KEY_KATAKANA,109,0x88,reg:0x13,0x70,
+Convert,KEY_HENKAN,110,0x8a,reg:0x64,0x79,
+NonConvert,KEY_MUHENKAN,111,0x8b,reg:0x67,0x7b,
+AudioVolumeMute,KEY_MUTE,112,0x7f,spec:0x23,0xe020,
+AudioVolumeUp,KEY_VOLUMEUP,113,0x80,spec:0x32,0xe030,
+AudioVolumeDown,KEY_VOLUMEDOWN,114,0x81,spec:0x21,0xe02e,
+F20,KEY_F20,115,0x6f,,0x5a,
diff --git a/kvmd.install b/kvmd.install
index 15ee2378..1a7e2b03 100644
--- a/kvmd.install
+++ b/kvmd.install
@@ -112,6 +112,13 @@ EOF
cp /usr/share/kvmd/configs.default/janus/janus.plugin.ustreamer.jcfg /etc/kvmd/janus || true
fi
+ if [[ "$(vercmp "$2" 4.60)" -lt 0 ]]; then
+ if grep -q "^dtoverlay=vc4-kms-v3d" /boot/config.txt; then
+ sed -i -e "s/cma=128M/cma=192M/g" /boot/cmdline.txt || true
+ sed -i -e "s/^gpu_mem=128/gpu_mem=192/g" /boot/config.txt || true
+ fi
+ 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
diff --git a/kvmd/__init__.py b/kvmd/__init__.py
index 1dac1011..2c40c79e 100644
--- a/kvmd/__init__.py
+++ b/kvmd/__init__.py
@@ -20,4 +20,4 @@
# ========================================================================== #
-__version__ = "4.49"
+__version__ = "4.94"
diff --git a/kvmd/aiogp.py b/kvmd/aiogp.py
index 3ee0ea2b..1b5ca16f 100644
--- a/kvmd/aiogp.py
+++ b/kvmd/aiogp.py
@@ -23,6 +23,7 @@
import asyncio
import threading
import dataclasses
+import typing
import gpiod
@@ -101,10 +102,10 @@ class AioReader: # pylint: disable=too-many-instance-attributes
if line_req.wait_edge_events(1):
new: dict[int, bool] = {}
for event in line_req.read_edge_events():
- (pin, value) = self.__parse_event(event)
- new[pin] = value
- for (pin, value) in new.items():
- self.__values[pin].set(value)
+ (pin, state) = self.__parse_event(event)
+ new[pin] = state
+ for (pin, state) in new.items():
+ self.__values[pin].set(state)
else: # Timeout
# XXX: Лимит был актуален для 1.6. Надо проверить, поменялось ли это в 2.x.
# Размер буфера ядра - 16 эвентов на линии. При превышении этого числа,
@@ -114,11 +115,12 @@ class AioReader: # pylint: disable=too-many-instance-attributes
self.__values[pin].set(bool(value.value)) # type: ignore
def __parse_event(self, event: gpiod.EdgeEvent) -> tuple[int, bool]:
- if event.event_type == event.Type.RISING_EDGE:
- return (event.line_offset, True)
- elif event.event_type == event.Type.FALLING_EDGE:
- return (event.line_offset, False)
- raise RuntimeError(f"Invalid event {event} type: {event.type}")
+ match event.event_type:
+ case event.Type.RISING_EDGE:
+ return (event.line_offset, True)
+ case event.Type.FALLING_EDGE:
+ return (event.line_offset, False)
+ typing.assert_never(event.event_type)
class _DebouncedValue:
diff --git a/kvmd/aiotools.py b/kvmd/aiotools.py
index f400ad3c..656a10e6 100644
--- a/kvmd/aiotools.py
+++ b/kvmd/aiotools.py
@@ -211,6 +211,18 @@ async def wait_first(*aws: asyncio.Task) -> tuple[set[asyncio.Task], set[asyncio
return (await asyncio.wait(list(aws), return_when=asyncio.FIRST_COMPLETED))
+# =====
+async def spawn_and_follow(*coros: Coroutine) -> None:
+ tasks: list[asyncio.Task] = list(map(asyncio.create_task, coros))
+ try:
+ await asyncio.gather(*tasks)
+ except Exception:
+ for task in tasks:
+ task.cancel()
+ await asyncio.gather(*tasks, return_exceptions=True)
+ raise
+
+
# =====
async def close_writer(writer: asyncio.StreamWriter) -> bool:
closing = writer.is_closing()
diff --git a/kvmd/apps/__init__.py b/kvmd/apps/__init__.py
index 7c587c3f..75f5607e 100644
--- a/kvmd/apps/__init__.py
+++ b/kvmd/apps/__init__.py
@@ -65,6 +65,7 @@ from ..validators.basic import valid_string_list
from ..validators.auth import valid_user
from ..validators.auth import valid_users_list
+from ..validators.auth import valid_expire
from ..validators.os import valid_abs_path
from ..validators.os import valid_abs_file
@@ -73,6 +74,7 @@ from ..validators.os import valid_unix_mode
from ..validators.os import valid_options
from ..validators.os import valid_command
+from ..validators.net import valid_ip
from ..validators.net import valid_ip_or_host
from ..validators.net import valid_net
from ..validators.net import valid_port
@@ -190,6 +192,14 @@ def _init_config(config_path: str, override_options: list[str], **load_flags: bo
def _patch_raw(raw_config: dict) -> None: # pylint: disable=too-many-branches
+ for (sub, cmd) in [("iface", "ip_cmd"), ("firewall", "iptables_cmd")]:
+ if isinstance(raw_config.get("otgnet"), dict):
+ if isinstance(raw_config["otgnet"].get(sub), dict):
+ if raw_config["otgnet"][sub].get(cmd):
+ raw_config["otgnet"].setdefault("commands", {})
+ raw_config["otgnet"]["commands"][cmd] = raw_config["otgnet"][sub][cmd]
+ del raw_config["otgnet"][sub][cmd]
+
if isinstance(raw_config.get("otg"), dict):
for (old, new) in [
("msd", "msd"),
@@ -357,6 +367,12 @@ def _get_config_scheme() -> dict:
"auth": {
"enabled": Option(True, type=valid_bool),
+ "expire": Option(0, type=valid_expire),
+
+ "usc": {
+ "users": Option([], type=valid_users_list), # PiKVM username has a same regex as a UNIX username
+ "groups": Option(["kvmd-selfauth"], type=valid_users_list), # groupname has a same regex as a username
+ },
"internal": {
"type": Option("htpasswd"),
@@ -457,7 +473,7 @@ def _get_config_scheme() -> dict:
"unix": Option("/run/kvmd/ustreamer.sock", type=valid_abs_path, unpack_as="unix_path"),
"timeout": Option(2.0, type=valid_float_f01),
- "snapshot_timeout": Option(1.0, type=valid_float_f01), # error_delay * 3 + 1
+ "snapshot_timeout": Option(5.0, type=valid_float_f01), # error_delay * 3 + 1
"process_name_prefix": Option("kvmd/streamer"),
@@ -504,8 +520,9 @@ def _get_config_scheme() -> dict:
},
"switch": {
- "device": Option("/dev/kvmd-switch", type=valid_abs_path, unpack_as="device_path"),
- "default_edid": Option("/etc/kvmd/switch-edid.hex", type=valid_abs_path, unpack_as="default_edid_path"),
+ "device": Option("/dev/kvmd-switch", type=valid_abs_path, unpack_as="device_path"),
+ "default_edid": Option("/etc/kvmd/switch-edid.hex", type=valid_abs_path, unpack_as="default_edid_path"),
+ "ignore_hpd_on_top": Option(False, type=valid_bool),
},
},
@@ -558,15 +575,15 @@ def _get_config_scheme() -> dict:
"vendor_id": Option(0x1D6B, type=valid_otg_id), # Linux Foundation
"product_id": Option(0x0104, type=valid_otg_id), # Multifunction Composite Gadget
"manufacturer": Option("PiKVM", type=valid_stripped_string),
- "product": Option("Composite KVM Device", type=valid_stripped_string),
+ "product": Option("PiKVM Composite Device", type=valid_stripped_string),
"serial": Option("CAFEBABE", type=valid_stripped_string, if_none=None),
+ "config": Option("", type=valid_stripped_string),
"device_version": Option(-1, type=functools.partial(valid_number, min=-1, max=0xFFFF)),
"usb_version": Option(0x0200, type=valid_otg_id),
"max_power": Option(250, type=functools.partial(valid_number, min=50, max=500)),
"remote_wakeup": Option(True, type=valid_bool),
"gadget": Option("kvmd", type=valid_otg_gadget),
- "config": Option("PiKVM device", type=valid_stripped_string_not_empty),
"udc": Option("", type=valid_stripped_string),
"endpoints": Option(9, type=valid_int_f0),
"init_delay": Option(3.0, type=valid_float_f01),
@@ -657,8 +674,7 @@ def _get_config_scheme() -> dict:
"otgnet": {
"iface": {
- "net": Option("172.30.30.0/24", type=functools.partial(valid_net, v6=False)),
- "ip_cmd": Option(["/usr/bin/ip"], type=valid_command),
+ "net": Option("172.30.30.0/24", type=functools.partial(valid_net, v6=False)),
},
"firewall": {
@@ -666,10 +682,13 @@ def _get_config_scheme() -> dict:
"allow_tcp": Option([], type=valid_ports_list),
"allow_udp": Option([67], type=valid_ports_list),
"forward_iface": Option("", type=valid_stripped_string),
- "iptables_cmd": Option(["/usr/sbin/iptables", "--wait=5"], type=valid_command),
},
"commands": {
+ "ip_cmd": Option(["/usr/bin/ip"], type=valid_command),
+ "iptables_cmd": Option(["/usr/sbin/iptables", "--wait=5"], type=valid_command),
+ "sysctl_cmd": Option(["/usr/sbin/sysctl"], type=valid_command),
+
"pre_start_cmd": Option(["/bin/true", "pre-start"], type=valid_command),
"pre_start_cmd_remove": Option([], type=valid_options),
"pre_start_cmd_append": Option([], type=valid_options),
@@ -734,7 +753,7 @@ def _get_config_scheme() -> dict:
"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),
+ "scroll_rate": Option(4, type=functools.partial(valid_number, min=1, max=30)),
"server": {
"host": Option("", type=valid_ip_or_host, if_empty=""),
@@ -786,8 +805,8 @@ def _get_config_scheme() -> dict:
"auth": {
"vncauth": {
- "enabled": Option(False, type=valid_bool),
- "file": Option("/etc/kvmd/vncpasswd", type=valid_abs_file, unpack_as="path"),
+ "enabled": Option(False, type=valid_bool, unpack_as="vncpass_enabled"),
+ "file": Option("/etc/kvmd/vncpasswd", type=valid_abs_file, unpack_as="vncpass_path"),
},
"vencrypt": {
"enabled": Option(True, type=valid_bool, unpack_as="vencrypt_enabled"),
@@ -795,13 +814,24 @@ def _get_config_scheme() -> dict:
},
},
+ "localhid": {
+ "kvmd": {
+ "unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"),
+ "timeout": Option(5.0, type=valid_float_f01),
+ },
+ },
+
"nginx": {
"http": {
- "port": Option(80, type=valid_port),
+ "ipv4": Option("0.0.0.0", type=functools.partial(valid_ip, v6=False)),
+ "ipv6": Option("::", type=functools.partial(valid_ip, v4=False)),
+ "port": Option(80, type=valid_port),
},
"https": {
- "enabled": Option(True, type=valid_bool),
- "port": Option(443, type=valid_port),
+ "enabled": Option(True, type=valid_bool),
+ "ipv4": Option("0.0.0.0", type=functools.partial(valid_ip, v6=False)),
+ "ipv6": Option("::", type=functools.partial(valid_ip, v4=False)),
+ "port": Option(443, type=valid_port),
},
},
diff --git a/kvmd/apps/edidconf/__init__.py b/kvmd/apps/edidconf/__init__.py
index e21f797b..f7ea93f4 100644
--- a/kvmd/apps/edidconf/__init__.py
+++ b/kvmd/apps/edidconf/__init__.py
@@ -61,6 +61,33 @@ def _print_edid(edid: Edid) -> None:
pass
+def _find_out2_edid_path() -> str:
+ card = os.path.basename(os.readlink("/dev/dri/by-path/platform-gpu-card"))
+ path = f"/sys/devices/platform/gpu/drm/{card}/{card}-HDMI-A-2"
+ with open(os.path.join(path, "status")) as file:
+ if file.read().startswith("d"):
+ raise SystemExit("No display found")
+ return os.path.join(path, "edid")
+
+
+def _adopt_out2_ids(dest: Edid) -> None:
+ src = Edid.from_file(_find_out2_edid_path())
+ dest.set_monitor_name(src.get_monitor_name())
+ try:
+ dest.get_monitor_serial()
+ except EdidNoBlockError:
+ pass
+ else:
+ try:
+ ser = src.get_monitor_serial()
+ except EdidNoBlockError:
+ ser = "{:08X}".format(src.get_serial())
+ dest.set_monitor_serial(ser)
+ dest.set_mfc_id(src.get_mfc_id())
+ dest.set_product_id(src.get_product_id())
+ dest.set_serial(src.get_serial())
+
+
# =====
def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-branches,too-many-statements
# (parent_parser, argv, _) = init(
@@ -89,6 +116,10 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra
help="Import the specified bin/hex EDID to the [--edid] file as a hex text", metavar="")
parser.add_argument("--import-preset", choices=presets,
help="Restore default EDID or choose the preset", metavar=f"{{ {' | '.join(presets)} }}",)
+ parser.add_argument("--import-display-ids", action="store_true",
+ help="On PiKVM V4, import and adopt IDs from a physical display connected to the OUT2 port")
+ parser.add_argument("--import-display", action="store_true",
+ help="On PiKVM V4, import full EDID from a physical display connected to the OUT2 port")
parser.add_argument("--set-audio", type=valid_bool,
help="Enable or disable audio", metavar="")
parser.add_argument("--set-mfc-id",
@@ -120,6 +151,9 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra
imp = f"_{imp}"
options.imp = os.path.join(options.presets_path, f"{imp}.hex")
+ if options.import_display:
+ options.imp = _find_out2_edid_path()
+
orig_edid_path = options.edid_path
if options.imp:
options.export_hex = options.edid_path
@@ -128,6 +162,10 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra
edid = Edid.from_file(options.edid_path)
changed = False
+ if options.import_display_ids:
+ _adopt_out2_ids(edid)
+ changed = True
+
for cmd in dir(Edid):
if cmd.startswith("set_"):
value = getattr(options, cmd)
diff --git a/kvmd/apps/htpasswd/__init__.py b/kvmd/apps/htpasswd/__init__.py
index 9e857abc..244c30f4 100644
--- a/kvmd/apps/htpasswd/__init__.py
+++ b/kvmd/apps/htpasswd/__init__.py
@@ -30,27 +30,27 @@ import argparse
from typing import Generator
-import passlib.apache
-
from ...yamlconf import Section
from ...validators import ValidatorError
from ...validators.auth import valid_user
from ...validators.auth import valid_passwd
+from ...crypto import KvmdHtpasswdFile
+
from .. import init
# =====
def _get_htpasswd_path(config: Section) -> str:
if config.kvmd.auth.internal.type != "htpasswd":
- raise SystemExit(f"Error: KVMD internal auth not using 'htpasswd'"
+ raise SystemExit(f"Error: KVMD internal auth does not use 'htpasswd'"
f" (now configured {config.kvmd.auth.internal.type!r})")
return config.kvmd.auth.internal.file
@contextlib.contextmanager
-def _get_htpasswd_for_write(config: Section) -> Generator[passlib.apache.HtpasswdFile, None, None]:
+def _get_htpasswd_for_write(config: Section) -> Generator[KvmdHtpasswdFile, None, None]:
path = _get_htpasswd_path(config)
(tmp_fd, tmp_path) = tempfile.mkstemp(
prefix=f".{os.path.basename(path)}.",
@@ -65,7 +65,7 @@ def _get_htpasswd_for_write(config: Section) -> Generator[passlib.apache.Htpassw
os.fchmod(tmp_fd, st.st_mode)
finally:
os.close(tmp_fd)
- htpasswd = passlib.apache.HtpasswdFile(tmp_path)
+ htpasswd = KvmdHtpasswdFile(tmp_path)
yield htpasswd
htpasswd.save()
os.rename(tmp_path, path)
@@ -96,28 +96,55 @@ def _print_invalidate_tip(prepend_nl: bool) -> None:
# ====
def _cmd_list(config: Section, _: argparse.Namespace) -> None:
- for user in sorted(passlib.apache.HtpasswdFile(_get_htpasswd_path(config)).users()):
+ for user in sorted(KvmdHtpasswdFile(_get_htpasswd_path(config)).users()):
print(user)
-def _cmd_set(config: Section, options: argparse.Namespace) -> None:
+def _change_user(config: Section, options: argparse.Namespace, create: bool) -> None:
with _get_htpasswd_for_write(config) as htpasswd:
+ assert options.user == options.user.strip()
+ assert options.user
+
has_user = (options.user in htpasswd.users())
+ if create:
+ if has_user:
+ raise SystemExit(f"The user {options.user!r} is already exists")
+ else:
+ if not has_user:
+ raise SystemExit(f"The user {options.user!r} is not exist")
+
if options.read_stdin:
passwd = valid_passwd(input())
else:
passwd = valid_passwd(getpass.getpass("Password: ", stream=sys.stderr))
if valid_passwd(getpass.getpass("Repeat: ", stream=sys.stderr)) != passwd:
raise SystemExit("Sorry, passwords do not match")
+
htpasswd.set_password(options.user, passwd)
+
if has_user and not options.quiet:
_print_invalidate_tip(True)
+def _cmd_add(config: Section, options: argparse.Namespace) -> None:
+ _change_user(config, options, create=True)
+
+
+def _cmd_set(config: Section, options: argparse.Namespace) -> None:
+ _change_user(config, options, create=False)
+
+
def _cmd_delete(config: Section, options: argparse.Namespace) -> None:
with _get_htpasswd_for_write(config) as htpasswd:
+ assert options.user == options.user.strip()
+ assert options.user
+
has_user = (options.user in htpasswd.users())
+ if not has_user:
+ raise SystemExit(f"The user {options.user!r} is not exist")
+
htpasswd.delete(options.user)
+
if has_user and not options.quiet:
_print_invalidate_tip(False)
@@ -138,19 +165,25 @@ def main(argv: (list[str] | None)=None) -> None:
parser.set_defaults(cmd=(lambda *_: parser.print_help()))
subparsers = parser.add_subparsers()
- cmd_list_parser = subparsers.add_parser("list", help="List users")
- cmd_list_parser.set_defaults(cmd=_cmd_list)
+ sub = subparsers.add_parser("list", help="List users")
+ sub.set_defaults(cmd=_cmd_list)
- cmd_set_parser = subparsers.add_parser("set", help="Create user or change password")
- cmd_set_parser.add_argument("user", type=valid_user)
- cmd_set_parser.add_argument("-i", "--read-stdin", action="store_true", help="Read password from stdin")
- cmd_set_parser.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note")
- cmd_set_parser.set_defaults(cmd=_cmd_set)
+ sub = subparsers.add_parser("add", help="Add user")
+ sub.add_argument("user", type=valid_user)
+ sub.add_argument("-i", "--read-stdin", action="store_true", help="Read password from stdin")
+ sub.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note")
+ sub.set_defaults(cmd=_cmd_add)
- cmd_delete_parser = subparsers.add_parser("del", help="Delete user")
- cmd_delete_parser.add_argument("user", type=valid_user)
- cmd_delete_parser.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note")
- cmd_delete_parser.set_defaults(cmd=_cmd_delete)
+ sub = subparsers.add_parser("set", help="Change user's password")
+ sub.add_argument("user", type=valid_user)
+ sub.add_argument("-i", "--read-stdin", action="store_true", help="Read password from stdin")
+ sub.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note")
+ sub.set_defaults(cmd=_cmd_set)
+
+ sub = subparsers.add_parser("del", help="Delete user")
+ sub.add_argument("user", type=valid_user)
+ sub.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note")
+ sub.set_defaults(cmd=_cmd_delete)
options = parser.parse_args(argv[1:])
try:
diff --git a/kvmd/apps/ipmi/auth.py b/kvmd/apps/ipmi/auth.py
index 71a13fe7..01d1ea0c 100644
--- a/kvmd/apps/ipmi/auth.py
+++ b/kvmd/apps/ipmi/auth.py
@@ -20,7 +20,13 @@
# ========================================================================== #
-import dataclasses
+import threading
+import functools
+import time
+
+from ...logging import get_logger
+
+from ... import tools
# =====
@@ -29,60 +35,42 @@ class IpmiPasswdError(Exception):
super().__init__(f"Syntax error at {path}:{lineno}: {msg}")
-@dataclasses.dataclass(frozen=True)
-class IpmiUserCredentials:
- ipmi_user: str
- ipmi_passwd: str
- kvmd_user: str
- kvmd_passwd: str
-
-
class IpmiAuthManager:
def __init__(self, path: str) -> None:
self.__path = path
- with open(path) as file:
- self.__credentials = self.__parse_passwd_file(file.read().split("\n"))
+ self.__lock = threading.Lock()
- def __contains__(self, ipmi_user: str) -> bool:
- return (ipmi_user in self.__credentials)
+ def get(self, user: str) -> (str | None):
+ creds = self.__get_credentials(int(time.time()))
+ return creds.get(user)
- def __getitem__(self, ipmi_user: str) -> str:
- return self.__credentials[ipmi_user].ipmi_passwd
+ @functools.lru_cache(maxsize=1)
+ def __get_credentials(self, ts: int) -> dict[str, str]:
+ _ = ts
+ with self.__lock:
+ try:
+ return self.__read_credentials()
+ except Exception as ex:
+ get_logger().error("%s", tools.efmt(ex))
+ return {}
- def get_credentials(self, ipmi_user: str) -> IpmiUserCredentials:
- return self.__credentials[ipmi_user]
+ def __read_credentials(self) -> dict[str, str]:
+ with open(self.__path) as file:
+ creds: dict[str, str] = {}
+ for (lineno, line) in tools.passwds_splitted(file.read()):
+ if " -> " in line: # Compatibility with old ipmipasswd file format
+ line = line.split(" -> ", 1)[0]
- def __parse_passwd_file(self, lines: list[str]) -> dict[str, IpmiUserCredentials]:
- credentials: dict[str, IpmiUserCredentials] = {}
- for (lineno, line) in enumerate(lines):
- if len(line.strip()) == 0 or line.lstrip().startswith("#"):
- continue
+ if ":" not in line:
+ raise IpmiPasswdError(self.__path, lineno, "Missing ':' operator")
- if " -> " not in line:
- raise IpmiPasswdError(self.__path, lineno, "Missing ' -> ' operator")
+ (user, passwd) = line.split(":", 1)
+ user = user.strip()
+ if len(user) == 0:
+ raise IpmiPasswdError(self.__path, lineno, "Empty IPMI user")
- (left, right) = map(str.lstrip, line.split(" -> ", 1))
- for (name, pair) in [("left", left), ("right", right)]:
- if ":" not in pair:
- raise IpmiPasswdError(self.__path, lineno, f"Missing ':' operator in {name} credentials")
+ if user in creds:
+ raise IpmiPasswdError(self.__path, lineno, f"Found duplicating user {user!r}")
- (ipmi_user, ipmi_passwd) = left.split(":")
- ipmi_user = ipmi_user.strip()
- if len(ipmi_user) == 0:
- raise IpmiPasswdError(self.__path, lineno, "Empty IPMI user (left)")
-
- (kvmd_user, kvmd_passwd) = right.split(":")
- kvmd_user = kvmd_user.strip()
- if len(kvmd_user) == 0:
- raise IpmiPasswdError(self.__path, lineno, "Empty KVMD user (left)")
-
- if ipmi_user in credentials:
- raise IpmiPasswdError(self.__path, lineno, f"Found duplicating user {ipmi_user!r} (left)")
-
- credentials[ipmi_user] = IpmiUserCredentials(
- ipmi_user=ipmi_user,
- ipmi_passwd=ipmi_passwd,
- kvmd_user=kvmd_user,
- kvmd_passwd=kvmd_passwd,
- )
- return credentials
+ creds[user] = passwd
+ return creds
diff --git a/kvmd/apps/ipmi/server.py b/kvmd/apps/ipmi/server.py
index 2fc897f9..391dbdcc 100644
--- a/kvmd/apps/ipmi/server.py
+++ b/kvmd/apps/ipmi/server.py
@@ -70,7 +70,6 @@ class IpmiServer(BaseIpmiServer): # pylint: disable=too-many-instance-attribute
super().__init__(authdata=auth_manager, address=host, port=port)
- self.__auth_manager = auth_manager
self.__kvmd = kvmd
self.__host = host
@@ -165,11 +164,10 @@ class IpmiServer(BaseIpmiServer): # pylint: disable=too-many-instance-attribute
def __make_request(self, session: IpmiServerSession, name: str, func_path: str, **kwargs): # type: ignore
async def runner(): # type: ignore
logger = get_logger(0)
- credentials = self.__auth_manager.get_credentials(session.username.decode())
- logger.info("[%s]: Performing request %s from user %r (IPMI) as %r (KVMD)",
- session.sockaddr[0], name, credentials.ipmi_user, credentials.kvmd_user)
+ logger.info("[%s]: Performing request %s from IPMI user %r ...",
+ session.sockaddr[0], name, session.username.decode())
try:
- async with self.__kvmd.make_session(credentials.kvmd_user, credentials.kvmd_passwd) as kvmd_session:
+ async with self.__kvmd.make_session() as kvmd_session:
func = functools.reduce(getattr, func_path.split("."), kvmd_session)
return (await func(**kwargs))
except (aiohttp.ClientError, asyncio.TimeoutError) as ex:
diff --git a/kvmd/apps/janus/runner.py b/kvmd/apps/janus/runner.py
index 9e426021..f1a99489 100644
--- a/kvmd/apps/janus/runner.py
+++ b/kvmd/apps/janus/runner.py
@@ -21,6 +21,7 @@ class _Netcfg:
nat_type: StunNatType = dataclasses.field(default=StunNatType.ERROR)
src_ip: str = dataclasses.field(default="")
ext_ip: str = dataclasses.field(default="")
+ stun_host: str = dataclasses.field(default="")
stun_ip: str = dataclasses.field(default="")
stun_port: int = dataclasses.field(default=0)
@@ -172,7 +173,10 @@ class JanusRunner: # pylint: disable=too-many-instance-attributes
part.format(**placeholders)
for part in cmd
]
- self.__janus_proc = await aioproc.run_process(cmd)
+ self.__janus_proc = await aioproc.run_process(
+ cmd=cmd,
+ env={"JANUS_USTREAMER_WEB_ICE_URL": f"stun:{netcfg.stun_host}:{netcfg.stun_port}"},
+ )
get_logger(0).info("Started Janus pid=%d: %s", self.__janus_proc.pid, tools.cmdfmt(cmd))
async def __kill_janus_proc(self) -> None:
diff --git a/kvmd/apps/janus/stun.py b/kvmd/apps/janus/stun.py
index 41cd86e7..7026ec2b 100644
--- a/kvmd/apps/janus/stun.py
+++ b/kvmd/apps/janus/stun.py
@@ -30,6 +30,7 @@ class StunInfo:
nat_type: StunNatType
src_ip: str
ext_ip: str
+ stun_host: str
stun_ip: str
stun_port: int
@@ -102,6 +103,7 @@ class Stun:
nat_type=nat_type,
src_ip=src_ip,
ext_ip=ext_ip,
+ stun_host=self.__host,
stun_ip=self.__stun_ip,
stun_port=self.__port,
)
diff --git a/kvmd/apps/kvmd/__init__.py b/kvmd/apps/kvmd/__init__.py
index 088a62ef..bb784e30 100644
--- a/kvmd/apps/kvmd/__init__.py
+++ b/kvmd/apps/kvmd/__init__.py
@@ -76,14 +76,17 @@ def main(argv: (list[str] | None)=None) -> None:
KvmdServer(
auth_manager=AuthManager(
enabled=config.auth.enabled,
+ expire=config.auth.expire,
+ usc_users=config.auth.usc.users,
+ usc_groups=config.auth.usc.groups,
unauth_paths=([] if config.prometheus.auth.enabled else ["/export/prometheus/metrics"]),
- internal_type=config.auth.internal.type,
- internal_kwargs=config.auth.internal._unpack(ignore=["type", "force_users"]),
- force_internal_users=config.auth.internal.force_users,
+ int_type=config.auth.internal.type,
+ int_kwargs=config.auth.internal._unpack(ignore=["type", "force_users"]),
+ force_int_users=config.auth.internal.force_users,
- external_type=config.auth.external.type,
- external_kwargs=(config.auth.external._unpack(ignore=["type"]) if config.auth.external.type else {}),
+ ext_type=config.auth.external.type,
+ ext_kwargs=(config.auth.external._unpack(ignore=["type"]) if config.auth.external.type else {}),
totp_secret_path=config.auth.totp.secret.file,
),
diff --git a/kvmd/apps/kvmd/api/auth.py b/kvmd/apps/kvmd/api/auth.py
index dee4a85d..c2833bf8 100644
--- a/kvmd/apps/kvmd/api/auth.py
+++ b/kvmd/apps/kvmd/api/auth.py
@@ -31,9 +31,11 @@ from ....htserver import HttpExposed
from ....htserver import exposed_http
from ....htserver import make_json_response
from ....htserver import set_request_auth_info
+from ....htserver import get_request_unix_credentials
from ....validators.auth import valid_user
from ....validators.auth import valid_passwd
+from ....validators.auth import valid_expire
from ....validators.auth import valid_auth_token
from ..auth import AuthManager
@@ -43,39 +45,64 @@ from ..auth import AuthManager
_COOKIE_AUTH_TOKEN = "auth_token"
-async def check_request_auth(auth_manager: AuthManager, exposed: HttpExposed, req: Request) -> None:
- if auth_manager.is_auth_required(exposed):
- user = req.headers.get("X-KVMD-User", "")
+async def _check_xhdr(auth_manager: AuthManager, _: HttpExposed, req: Request) -> bool:
+ user = req.headers.get("X-KVMD-User", "")
+ if user:
+ user = valid_user(user)
+ passwd = req.headers.get("X-KVMD-Passwd", "")
+ set_request_auth_info(req, f"{user} (xhdr)")
+ if (await auth_manager.authorize(user, valid_passwd(passwd))):
+ return True
+ raise ForbiddenError()
+ return False
+
+
+async def _check_token(auth_manager: AuthManager, _: HttpExposed, req: Request) -> bool:
+ token = req.cookies.get(_COOKIE_AUTH_TOKEN, "")
+ if token:
+ user = auth_manager.check(valid_auth_token(token))
if user:
- user = valid_user(user)
- 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 = 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(req, "- (token)")
- raise ForbiddenError()
set_request_auth_info(req, f"{user} (token)")
- return
+ return True
+ set_request_auth_info(req, "- (token)")
+ raise ForbiddenError()
+ return False
- 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(req, f"{user} (basic)")
- if not (await auth_manager.authorize(user, valid_passwd(passwd))):
- raise ForbiddenError()
- return
+async def _check_basic(auth_manager: AuthManager, _: HttpExposed, req: Request) -> bool:
+ 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(req, f"{user} (basic)")
+ if (await auth_manager.authorize(user, valid_passwd(passwd))):
+ return True
+ raise ForbiddenError()
+ return False
+
+
+async def _check_usc(auth_manager: AuthManager, exposed: HttpExposed, req: Request) -> bool:
+ if exposed.allow_usc:
+ creds = get_request_unix_credentials(req)
+ if creds is not None:
+ user = auth_manager.check_unix_credentials(creds)
+ if user:
+ set_request_auth_info(req, f"{user}[{creds.uid}] (unix)")
+ return True
raise UnauthorizedError()
+ return False
+
+
+async def check_request_auth(auth_manager: AuthManager, exposed: HttpExposed, req: Request) -> None:
+ if not auth_manager.is_auth_required(exposed):
+ return
+ for checker in [_check_xhdr, _check_token, _check_basic, _check_usc]:
+ if (await checker(auth_manager, exposed, req)):
+ return
+ raise UnauthorizedError()
class AuthApi:
@@ -84,26 +111,28 @@ class AuthApi:
# =====
- @exposed_http("POST", "/auth/login", auth_required=False)
+ @exposed_http("POST", "/auth/login", auth_required=False, allow_usc=False)
async def __login_handler(self, req: Request) -> Response:
if self.__auth_manager.is_auth_enabled():
credentials = await req.post()
token = await self.__auth_manager.login(
user=valid_user(credentials.get("user", "")),
passwd=valid_passwd(credentials.get("passwd", "")),
+ expire=valid_expire(credentials.get("expire", "0")),
)
if token:
return make_json_response(set_cookies={_COOKIE_AUTH_TOKEN: token})
raise ForbiddenError()
return make_json_response()
- @exposed_http("POST", "/auth/logout")
+ @exposed_http("POST", "/auth/logout", allow_usc=False)
async def __logout_handler(self, req: Request) -> Response:
if self.__auth_manager.is_auth_enabled():
token = valid_auth_token(req.cookies.get(_COOKIE_AUTH_TOKEN, ""))
self.__auth_manager.logout(token)
return make_json_response()
- @exposed_http("GET", "/auth/check")
+ # XXX: This handle is used for access control so it should NEVER allow access by socket credentials
+ @exposed_http("GET", "/auth/check", allow_usc=False)
async def __check_handler(self, _: Request) -> Response:
return make_json_response()
diff --git a/kvmd/apps/kvmd/api/export.py b/kvmd/apps/kvmd/api/export.py
index fd672f7b..f3e48c36 100644
--- a/kvmd/apps/kvmd/api/export.py
+++ b/kvmd/apps/kvmd/api/export.py
@@ -21,6 +21,7 @@
import asyncio
+import re
from typing import Any
@@ -57,7 +58,7 @@ class ExportApi:
async def __get_prometheus_metrics(self) -> str:
(atx_state, info_state, gpio_state) = await asyncio.gather(*[
self.__atx.get_state(),
- self.__info_manager.get_state(["hw", "fan"]),
+ self.__info_manager.get_state(["health", "fan"]),
self.__user_gpio.get_state(),
])
rows: list[str] = []
@@ -68,10 +69,11 @@ class ExportApi:
for mode in sorted(UserGpioModes.ALL):
for (channel, ch_state) in gpio_state["state"][f"{mode}s"].items(): # type: ignore
if not channel.startswith("__"): # Hide special GPIOs
+ channel = re.sub(r"[^\w]", "_", channel)
for key in ["online", "state"]:
self.__append_prometheus_rows(rows, ch_state["state"], f"pikvm_gpio_{mode}_{key}_{channel}")
- self.__append_prometheus_rows(rows, info_state["hw"]["health"], "pikvm_hw") # type: ignore
+ self.__append_prometheus_rows(rows, info_state["health"], "pikvm_hw") # type: ignore
self.__append_prometheus_rows(rows, info_state["fan"], "pikvm_fan")
return "\n".join(rows)
diff --git a/kvmd/apps/kvmd/api/hid.py b/kvmd/apps/kvmd/api/hid.py
index 98b96313..071880fd 100644
--- a/kvmd/apps/kvmd/api/hid.py
+++ b/kvmd/apps/kvmd/api/hid.py
@@ -23,6 +23,7 @@
import os
import stat
import functools
+import itertools
import struct
from typing import Iterable
@@ -31,8 +32,11 @@ from typing import Callable
from aiohttp.web import Request
from aiohttp.web import Response
+from ....keyboard.mappings import WEB_TO_EVDEV
from ....keyboard.keysym import build_symmap
-from ....keyboard.printer import text_to_web_keys
+from ....keyboard.printer import text_to_evdev_keys
+
+from ....mouse import MOUSE_TO_EVDEV
from ....htserver import exposed_http
from ....htserver import exposed_ws
@@ -43,7 +47,9 @@ from ....plugins.hid import BaseHid
from ....validators import raise_error
from ....validators.basic import valid_bool
+from ....validators.basic import valid_number
from ....validators.basic import valid_int_f0
+from ....validators.basic import valid_string_list
from ....validators.os import valid_printable_filename
from ....validators.hid import valid_hid_keyboard_output
from ....validators.hid import valid_hid_mouse_output
@@ -97,6 +103,11 @@ class HidApi:
await self.__hid.reset()
return make_json_response()
+ @exposed_http("GET", "/hid/inactivity")
+ async def __inactivity_handler(self, _: Request) -> Response:
+ secs = self.__hid.get_inactivity_seconds()
+ return make_json_response({"inactivity": secs})
+
# =====
async def get_keymaps(self) -> dict: # Ugly hack to generate hid_keymaps_state (see server.py)
@@ -119,15 +130,26 @@ class HidApi:
@exposed_http("POST", "/hid/print")
async def __print_handler(self, req: Request) -> Response:
text = await req.text()
- limit = int(valid_int_f0(req.query.get("limit", 1024)))
+ limit = valid_int_f0(req.query.get("limit", 1024))
if limit > 0:
text = text[:limit]
symmap = self.__ensure_symmap(req.query.get("keymap", self.__default_keymap_name))
slow = valid_bool(req.query.get("slow", False))
- await self.__hid.send_key_events(text_to_web_keys(text, symmap), no_ignore_keys=True, slow=slow)
+ delay = float(valid_number(
+ arg=req.query.get("delay", (0.02 if slow else 0)),
+ min=0,
+ max=5,
+ type=float,
+ name="keys delay",
+ ))
+ await self.__hid.send_key_events(
+ keys=text_to_evdev_keys(text, symmap),
+ no_ignore_keys=True,
+ delay=delay,
+ )
return make_json_response()
- def __ensure_symmap(self, keymap_name: str) -> dict[int, dict[int, str]]:
+ def __ensure_symmap(self, keymap_name: str) -> dict[int, dict[int, int]]:
keymap_name = valid_printable_filename(keymap_name, "keymap")
path = os.path.join(self.__keymaps_dir_path, keymap_name)
try:
@@ -139,7 +161,7 @@ class HidApi:
return self.__inner_ensure_symmap(path, st.st_mtime)
@functools.lru_cache(maxsize=10)
- def __inner_ensure_symmap(self, path: str, mod_ts: int) -> dict[int, dict[int, str]]:
+ def __inner_ensure_symmap(self, path: str, mod_ts: int) -> dict[int, dict[int, int]]:
_ = mod_ts # For LRU
return build_symmap(path)
@@ -148,9 +170,12 @@ class HidApi:
@exposed_ws(1)
async def __ws_bin_key_handler(self, _: WsSession, data: bytes) -> None:
try:
- key = valid_hid_key(data[1:].decode("ascii"))
state = bool(data[0] & 0b01)
finish = bool(data[0] & 0b10)
+ if data[0] & 0b10000000:
+ key = struct.unpack(">H", data[1:])[0]
+ else:
+ key = WEB_TO_EVDEV[valid_hid_key(data[1:33].decode("ascii"))]
except Exception:
return
self.__hid.send_key_event(key, state, finish)
@@ -158,7 +183,11 @@ class HidApi:
@exposed_ws(2)
async def __ws_bin_mouse_button_handler(self, _: WsSession, data: bytes) -> None:
try:
- button = valid_hid_mouse_button(data[1:].decode("ascii"))
+ state = bool(data[0] & 0b01)
+ if data[0] & 0b10000000:
+ button = struct.unpack(">H", data[1:])[0]
+ else:
+ button = MOUSE_TO_EVDEV[valid_hid_mouse_button(data[1:33].decode("ascii"))]
state = bool(data[0] & 0b01)
except Exception:
return
@@ -199,7 +228,7 @@ class HidApi:
@exposed_ws("key")
async def __ws_key_handler(self, _: WsSession, event: dict) -> None:
try:
- key = valid_hid_key(event["key"])
+ key = WEB_TO_EVDEV[valid_hid_key(event["key"])]
state = valid_bool(event["state"])
finish = valid_bool(event.get("finish", False))
except Exception:
@@ -209,7 +238,7 @@ class HidApi:
@exposed_ws("mouse_button")
async def __ws_mouse_button_handler(self, _: WsSession, event: dict) -> None:
try:
- button = valid_hid_mouse_button(event["button"])
+ button = MOUSE_TO_EVDEV[valid_hid_mouse_button(event["button"])]
state = valid_bool(event["state"])
except Exception:
return
@@ -246,9 +275,22 @@ class HidApi:
# =====
+ @exposed_http("POST", "/hid/events/send_shortcut")
+ async def __events_send_shortcut_handler(self, req: Request) -> Response:
+ shortcut = valid_string_list(req.query.get("keys"), subval=valid_hid_key)
+ if shortcut:
+ press = [WEB_TO_EVDEV[key] for key in shortcut]
+ release = list(reversed(press))
+ seq = [
+ *zip(press, itertools.repeat(True)),
+ *zip(release, itertools.repeat(False)),
+ ]
+ await self.__hid.send_key_events(seq, no_ignore_keys=True, delay=0.05)
+ return make_json_response()
+
@exposed_http("POST", "/hid/events/send_key")
async def __events_send_key_handler(self, req: Request) -> Response:
- key = valid_hid_key(req.query.get("key"))
+ key = WEB_TO_EVDEV[valid_hid_key(req.query.get("key"))]
if "state" in req.query:
state = valid_bool(req.query["state"])
finish = valid_bool(req.query.get("finish", False))
@@ -259,7 +301,7 @@ class HidApi:
@exposed_http("POST", "/hid/events/send_mouse_button")
async def __events_send_mouse_button_handler(self, req: Request) -> Response:
- button = valid_hid_mouse_button(req.query.get("button"))
+ button = MOUSE_TO_EVDEV[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)
diff --git a/kvmd/apps/kvmd/api/info.py b/kvmd/apps/kvmd/api/info.py
index 89d45a84..8116a3fa 100644
--- a/kvmd/apps/kvmd/api/info.py
+++ b/kvmd/apps/kvmd/api/info.py
@@ -45,7 +45,10 @@ class InfoApi:
def __valid_info_fields(self, req: Request) -> list[str]:
available = self.__info_manager.get_subs()
+ available.add("hw")
+ default = set(available)
+ default.remove("health")
return sorted(valid_info_fields(
- arg=req.query.get("fields", ",".join(available)),
- variants=available,
+ arg=req.query.get("fields", ",".join(default)),
+ variants=(available),
) or available)
diff --git a/kvmd/apps/kvmd/api/log.py b/kvmd/apps/kvmd/api/log.py
index 1be9c7ce..c2feafa5 100644
--- a/kvmd/apps/kvmd/api/log.py
+++ b/kvmd/apps/kvmd/api/log.py
@@ -52,17 +52,15 @@ class LogApi:
raise LogReaderDisabledError()
seek = valid_log_seek(req.query.get("seek", 0))
follow = valid_bool(req.query.get("follow", False))
- response = await start_streaming(req, "text/plain")
+ resp = await start_streaming(req, "text/plain")
try:
async for record in self.__log_reader.poll_log(seek, follow):
- await response.write(("[%s %s] --- %s" % (
+ await resp.write(("[%s %s] --- %s" % (
record["dt"].strftime("%Y-%m-%d %H:%M:%S"),
record["service"],
record["msg"],
)).encode("utf-8") + b"\r\n")
- except Exception as e:
- if record is None:
- record = e
- await response.write(f"Module systemd.journal is unavailable.\n{record}".encode("utf-8"))
- return response
- return response
+ except Exception as exception:
+ await resp.write(f"Module systemd.journal is unavailable.\n{exception}".encode("utf-8"))
+ return resp
+ return resp
diff --git a/kvmd/apps/kvmd/api/msd.py b/kvmd/apps/kvmd/api/msd.py
index 2bb8042e..b634f79f 100644
--- a/kvmd/apps/kvmd/api/msd.py
+++ b/kvmd/apps/kvmd/api/msd.py
@@ -84,7 +84,7 @@ class MsdApi:
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")))
@@ -133,10 +133,10 @@ class MsdApi:
src = compressed()
size = -1
- response = await start_streaming(req, "application/octet-stream", size, name + suffix)
+ resp = await start_streaming(req, "application/octet-stream", size, name + suffix)
async for chunk in src:
- await response.write(chunk)
- return response
+ await resp.write(chunk)
+ return resp
# =====
@@ -166,11 +166,11 @@ class MsdApi:
name = ""
size = written = 0
- response: (StreamResponse | None) = None
+ resp: (StreamResponse | None) = None
async def stream_write_info() -> None:
- assert response is not None
- await stream_json(response, self.__make_write_info(name, size, written))
+ assert resp is not None
+ await stream_json(resp, self.__make_write_info(name, size, written))
try:
async with htclient.download(
@@ -190,7 +190,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(req, "application/x-ndjson")
+ resp = 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):
@@ -201,12 +201,12 @@ class MsdApi:
last_report_ts = now
await stream_write_info()
- return response
+ return resp
except Exception as ex:
- if response is not None:
+ if resp is not None:
await stream_write_info()
- await stream_json_exception(response, ex)
+ await stream_json_exception(resp, ex)
elif isinstance(ex, aiohttp.ClientError):
return make_json_exception(ex, 400)
raise
diff --git a/kvmd/apps/kvmd/api/redfish.py b/kvmd/apps/kvmd/api/redfish.py
index 3b248685..6cf2c1f3 100644
--- a/kvmd/apps/kvmd/api/redfish.py
+++ b/kvmd/apps/kvmd/api/redfish.py
@@ -102,14 +102,26 @@ class RedfishApi:
"Actions": {
"#ComputerSystem.Reset": {
"ResetType@Redfish.AllowableValues": list(self.__actions),
- "target": "/redfish/v1/Systems/0/Actions/ComputerSystem.Reset"
+ "target": "/redfish/v1/Systems/0/Actions/ComputerSystem.Reset",
+ },
+ "#ComputerSystem.SetDefaultBootOrder": { # https://github.com/pikvm/pikvm/issues/1525
+ "target": "/redfish/v1/Systems/0/Actions/ComputerSystem.SetDefaultBootOrder",
},
},
"Id": "0",
"HostName": host,
"PowerState": ("On" if atx_state["leds"]["power"] else "Off"), # type: ignore
+ "Boot": {
+ "BootSourceOverrideEnabled": "Disabled",
+ "BootSourceOverrideTarget": None,
+ },
}, wrap_result=False)
+ @exposed_http("PATCH", "/redfish/v1/Systems/0")
+ async def __patch_handler(self, _: Request) -> Response:
+ # https://github.com/pikvm/pikvm/issues/1525
+ return Response(body=None, status=204)
+
@exposed_http("POST", "/redfish/v1/Systems/0/Actions/ComputerSystem.Reset")
async def __power_handler(self, req: Request) -> Response:
try:
diff --git a/kvmd/apps/kvmd/api/switch.py b/kvmd/apps/kvmd/api/switch.py
index bf91b83e..f6019611 100644
--- a/kvmd/apps/kvmd/api/switch.py
+++ b/kvmd/apps/kvmd/api/switch.py
@@ -28,6 +28,7 @@ from ....htserver import make_json_response
from ....validators.basic import valid_bool
from ....validators.basic import valid_int_f0
+from ....validators.basic import valid_float_f0
from ....validators.basic import valid_stripped_string_not_empty
from ....validators.kvm import valid_atx_power_action
from ....validators.kvm import valid_atx_button
@@ -52,9 +53,19 @@ class SwitchApi:
async def __state_handler(self, _: Request) -> Response:
return make_json_response(await self.__switch.get_state())
+ @exposed_http("POST", "/switch/set_active_prev")
+ async def __set_active_prev_handler(self, _: Request) -> Response:
+ await self.__switch.set_active_prev()
+ return make_json_response()
+
+ @exposed_http("POST", "/switch/set_active_next")
+ async def __set_active_next_handler(self, _: Request) -> Response:
+ await self.__switch.set_active_next()
+ return make_json_response()
+
@exposed_http("POST", "/switch/set_active")
async def __set_active_port_handler(self, req: Request) -> Response:
- port = valid_int_f0(req.query.get("port"))
+ port = valid_float_f0(req.query.get("port"))
await self.__switch.set_active_port(port)
return make_json_response()
@@ -62,7 +73,7 @@ class SwitchApi:
async def __set_beacon_handler(self, req: Request) -> Response:
on = valid_bool(req.query.get("state"))
if "port" in req.query:
- port = valid_int_f0(req.query.get("port"))
+ port = valid_float_f0(req.query.get("port"))
await self.__switch.set_port_beacon(port, on)
elif "uplink" in req.query:
unit = valid_int_f0(req.query.get("uplink"))
@@ -74,11 +85,12 @@ class SwitchApi:
@exposed_http("POST", "/switch/set_port_params")
async def __set_port_params(self, req: Request) -> Response:
- port = valid_int_f0(req.query.get("port"))
+ port = valid_float_f0(req.query.get("port"))
params = {
param: validator(req.query.get(param))
for (param, validator) in [
("edid_id", (lambda arg: valid_switch_edid_id(arg, allow_default=True))),
+ ("dummy", valid_bool),
("name", valid_switch_port_name),
("atx_click_power_delay", valid_switch_atx_click_delay),
("atx_click_power_long_delay", valid_switch_atx_click_delay),
@@ -142,7 +154,7 @@ class SwitchApi:
@exposed_http("POST", "/switch/atx/power")
async def __power_handler(self, req: Request) -> Response:
- port = valid_int_f0(req.query.get("port"))
+ port = valid_float_f0(req.query.get("port"))
action = valid_atx_power_action(req.query.get("action"))
await ({
"on": self.__switch.atx_power_on,
@@ -154,7 +166,7 @@ class SwitchApi:
@exposed_http("POST", "/switch/atx/click")
async def __click_handler(self, req: Request) -> Response:
- port = valid_int_f0(req.query.get("port"))
+ port = valid_float_f0(req.query.get("port"))
button = valid_atx_button(req.query.get("button"))
await ({
"power": self.__switch.atx_click_power,
diff --git a/kvmd/apps/kvmd/auth.py b/kvmd/apps/kvmd/auth.py
index bf979836..bcdc5ed1 100644
--- a/kvmd/apps/kvmd/auth.py
+++ b/kvmd/apps/kvmd/auth.py
@@ -20,6 +20,12 @@
# ========================================================================== #
+import pwd
+import grp
+import dataclasses
+import time
+import datetime
+
import secrets
import pyotp
@@ -31,48 +37,79 @@ from ...plugins.auth import BaseAuthService
from ...plugins.auth import get_auth_service_class
from ...htserver import HttpExposed
+from ...htserver import RequestUnixCredentials
# =====
-class AuthManager:
+@dataclasses.dataclass(frozen=True)
+class _Session:
+ user: str
+ expire_ts: int
+
+ def __post_init__(self) -> None:
+ assert self.user == self.user.strip()
+ assert self.user
+ assert self.expire_ts >= 0
+
+
+class AuthManager: # pylint: disable=too-many-arguments,too-many-instance-attributes
def __init__(
self,
enabled: bool,
+ expire: int,
+ usc_users: list[str],
+ usc_groups: list[str],
unauth_paths: list[str],
- internal_type: str,
- internal_kwargs: dict,
- force_internal_users: list[str],
+ int_type: str,
+ int_kwargs: dict,
+ force_int_users: list[str],
- external_type: str,
- external_kwargs: dict,
+ ext_type: str,
+ ext_kwargs: dict,
totp_secret_path: str,
) -> None:
+ logger = get_logger(0)
+
self.__enabled = enabled
if not enabled:
- get_logger().warning("AUTHORIZATION IS DISABLED")
+ logger.warning("AUTHORIZATION IS DISABLED")
+
+ assert expire >= 0
+ self.__expire = expire
+ if expire > 0:
+ logger.info("Maximum user session time is limited: %s",
+ self.__format_seconds(expire))
+
+ self.__usc_uids = self.__load_usc_uids(usc_users, usc_groups)
+ if self.__usc_uids:
+ logger.info("Selfauth UNIX socket access is allowed for users: %s",
+ list(self.__usc_uids.values()))
self.__unauth_paths = frozenset(unauth_paths) # To speed up
- for path in self.__unauth_paths:
- get_logger().warning("Authorization is disabled for API %r", path)
+ if self.__unauth_paths:
+ logger.info("Authorization is disabled for APIs: %s",
+ list(self.__unauth_paths))
- self.__internal_service: (BaseAuthService | None) = None
+ self.__int_service: (BaseAuthService | None) = None
if enabled:
- self.__internal_service = get_auth_service_class(internal_type)(**internal_kwargs)
- get_logger().info("Using internal auth service %r", self.__internal_service.get_plugin_name())
+ self.__int_service = get_auth_service_class(int_type)(**int_kwargs)
+ logger.info("Using internal auth service %r",
+ self.__int_service.get_plugin_name())
- self.__force_internal_users = force_internal_users
+ self.__force_int_users = force_int_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("Using external auth service %r", self.__external_service.get_plugin_name())
+ self.__ext_service: (BaseAuthService | None) = None
+ if enabled and ext_type:
+ self.__ext_service = get_auth_service_class(ext_type)(**ext_kwargs)
+ logger.info("Using external auth service %r",
+ self.__ext_service.get_plugin_name())
self.__totp_secret_path = totp_secret_path
- self.__tokens: dict[str, str] = {} # {token: user}
+ self.__sessions: dict[str, _Session] = {} # {token: session}
def is_auth_enabled(self) -> bool:
return self.__enabled
@@ -88,7 +125,8 @@ class AuthManager:
assert user == user.strip()
assert user
assert self.__enabled
- assert self.__internal_service
+ assert self.__int_service
+ logger = get_logger(0)
if self.__totp_secret_path:
with open(self.__totp_secret_path) as file:
@@ -96,60 +134,150 @@ class AuthManager:
if secret:
code = passwd[-6:]
if not pyotp.TOTP(secret).verify(code, valid_window=1):
- get_logger().error("Got access denied for user %r by TOTP", user)
+ logger.error("Got access denied for user %r by TOTP", user)
return False
passwd = passwd[:-6]
- if user not in self.__force_internal_users and self.__external_service:
- service = self.__external_service
+ if user not in self.__force_int_users and self.__ext_service:
+ service = self.__ext_service
else:
- service = self.__internal_service
+ service = self.__int_service
+ pname = service.get_plugin_name()
ok = (await service.authorize(user, passwd))
if ok:
- get_logger().info("Authorized user %r via auth service %r", user, service.get_plugin_name())
+ logger.info("Authorized user %r via auth service %r", user, pname)
else:
- get_logger().error("Got access denied for user %r from auth service %r", user, service.get_plugin_name())
+ logger.error("Got access denied for user %r from auth service %r", user, pname)
return ok
- async def login(self, user: str, passwd: str) -> (str | None):
+ async def login(self, user: str, passwd: str, expire: int) -> (str | None):
assert user == user.strip()
assert user
+ assert expire >= 0
assert self.__enabled
+
if (await self.authorize(user, passwd)):
token = self.__make_new_token()
- self.__tokens[token] = user
- get_logger().info("Logged in user %r", user)
+ session = _Session(
+ user=user,
+ expire_ts=self.__make_expire_ts(expire),
+ )
+ self.__sessions[token] = session
+ get_logger(0).info("Logged in user %r; expire=%s, sessions_now=%d",
+ session.user,
+ self.__format_expire_ts(session.expire_ts),
+ self.__get_sessions_number(session.user))
return token
- else:
- return None
+
+ return None
def __make_new_token(self) -> str:
for _ in range(10):
token = secrets.token_hex(32)
- if token not in self.__tokens:
+ if token not in self.__sessions:
return token
- raise AssertionError("Can't generate new unique token")
+ raise RuntimeError("Can't generate new unique token")
+
+ def __make_expire_ts(self, expire: int) -> int:
+ assert expire >= 0
+ assert self.__expire >= 0
+
+ if expire == 0:
+ # The user requested infinite session: apply global expire.
+ # It will allow this (0) or set a limit.
+ expire = self.__expire
+ else:
+ # The user wants a limited session
+ if self.__expire > 0:
+ # If we have a global limit, override the user limit
+ assert expire > 0
+ expire = min(expire, self.__expire)
+
+ if expire > 0:
+ return (self.__get_now_ts() + expire)
+
+ assert expire == 0
+ return 0
+
+ def __get_now_ts(self) -> int:
+ return int(time.monotonic())
+
+ def __format_expire_ts(self, expire_ts: int) -> str:
+ if expire_ts > 0:
+ seconds = expire_ts - self.__get_now_ts()
+ return f"[{self.__format_seconds(seconds)}]"
+ return "INF"
+
+ def __format_seconds(self, seconds: int) -> str:
+ return str(datetime.timedelta(seconds=seconds))
+
+ def __get_sessions_number(self, user: str) -> int:
+ return sum(
+ 1
+ for session in self.__sessions.values()
+ if session.user == user
+ )
def logout(self, token: str) -> None:
assert self.__enabled
- if token in self.__tokens:
- user = self.__tokens[token]
+ if token in self.__sessions:
+ user = self.__sessions[token].user
count = 0
- for (r_token, r_user) in list(self.__tokens.items()):
- if r_user == user:
+ for (key_t, session) in list(self.__sessions.items()):
+ if session.user == user:
count += 1
- del self.__tokens[r_token]
- get_logger().info("Logged out user %r (%d)", user, count)
+ del self.__sessions[key_t]
+ get_logger(0).info("Logged out user %r; sessions_closed=%d", user, count)
def check(self, token: str) -> (str | None):
assert self.__enabled
- return self.__tokens.get(token)
+ session = self.__sessions.get(token)
+ if session is not None:
+ if session.expire_ts <= 0:
+ # Infinite session
+ return session.user
+ else:
+ # Limited session
+ if self.__get_now_ts() < session.expire_ts:
+ return session.user
+ else:
+ del self.__sessions[token]
+ get_logger(0).info("The session of user %r is expired; sessions_left=%d",
+ session.user,
+ self.__get_sessions_number(session.user))
+ return None
@aiotools.atomic_fg
async def cleanup(self) -> None:
if self.__enabled:
- assert self.__internal_service
- await self.__internal_service.cleanup()
- if self.__external_service:
- await self.__external_service.cleanup()
+ assert self.__int_service
+ await self.__int_service.cleanup()
+ if self.__ext_service:
+ await self.__ext_service.cleanup()
+
+ # =====
+
+ def __load_usc_uids(self, users: list[str], groups: list[str]) -> dict[int, str]:
+ uids: dict[int, str] = {}
+
+ pwds: dict[str, int] = {}
+ for pw in pwd.getpwall():
+ assert pw.pw_name == pw.pw_name.strip()
+ assert pw.pw_name
+ pwds[pw.pw_name] = pw.pw_uid
+ if pw.pw_name in users:
+ uids[pw.pw_uid] = pw.pw_name
+
+ for gr in grp.getgrall():
+ if gr.gr_name in groups:
+ for member in gr.gr_mem:
+ if member in pwds:
+ uid = pwds[member]
+ uids[uid] = member
+
+ return uids
+
+ def check_unix_credentials(self, creds: RequestUnixCredentials) -> (str | None):
+ assert self.__enabled
+ return self.__usc_uids.get(creds.uid)
diff --git a/kvmd/apps/kvmd/info/__init__.py b/kvmd/apps/kvmd/info/__init__.py
index 9ede5489..eec16c42 100644
--- a/kvmd/apps/kvmd/info/__init__.py
+++ b/kvmd/apps/kvmd/info/__init__.py
@@ -31,7 +31,7 @@ from .auth import AuthInfoSubmanager
from .system import SystemInfoSubmanager
from .meta import MetaInfoSubmanager
from .extras import ExtrasInfoSubmanager
-from .hw import HwInfoSubmanager
+from .health import HealthInfoSubmanager
from .fan import FanInfoSubmanager
@@ -39,11 +39,11 @@ from .fan import FanInfoSubmanager
class InfoManager:
def __init__(self, config: Section) -> None:
self.__subs: dict[str, BaseInfoSubmanager] = {
- "system": SystemInfoSubmanager(config.kvmd.streamer.cmd),
+ "system": SystemInfoSubmanager(config.kvmd.info.hw.platform, config.kvmd.streamer.cmd),
"auth": AuthInfoSubmanager(config.kvmd.auth.enabled),
"meta": MetaInfoSubmanager(config.kvmd.info.meta),
"extras": ExtrasInfoSubmanager(config),
- "hw": HwInfoSubmanager(**config.kvmd.info.hw._unpack()),
+ "health": HealthInfoSubmanager(**config.kvmd.info.hw._unpack(ignore="platform")),
"fan": FanInfoSubmanager(**config.kvmd.info.fan._unpack()),
}
self.__queue: "asyncio.Queue[tuple[str, (dict | None)]]" = asyncio.Queue()
@@ -52,12 +52,29 @@ class InfoManager:
return set(self.__subs)
async def get_state(self, fields: (list[str] | None)=None) -> dict:
- fields = (fields or list(self.__subs))
- return dict(zip(fields, await asyncio.gather(*[
+ fields_set = set(fields or list(self.__subs))
+
+ hw = ("hw" in fields_set) # Old for compatible
+ system = ("system" in fields_set)
+ if hw:
+ fields_set.remove("hw")
+ fields_set.add("health")
+ fields_set.add("system")
+
+ state = dict(zip(fields_set, await asyncio.gather(*[
self.__subs[field].get_state()
- for field in fields
+ for field in fields_set
])))
+ if hw:
+ state["hw"] = {
+ "health": state.pop("health"),
+ "platform": (state["system"] or {}).pop("platform"), # {} makes mypy happy
+ }
+ if not system:
+ state.pop("system")
+ return state
+
async def trigger_state(self) -> None:
await asyncio.gather(*[
sub.trigger_state()
@@ -70,7 +87,7 @@ class InfoManager:
# - auth -- Partial
# - meta -- Partial, nullable
# - extras -- Partial, nullable
- # - hw -- Partial
+ # - health -- Partial
# - fan -- Partial
# ===========================
diff --git a/kvmd/apps/kvmd/info/extras.py b/kvmd/apps/kvmd/info/extras.py
index 11c47a52..532189c4 100644
--- a/kvmd/apps/kvmd/info/extras.py
+++ b/kvmd/apps/kvmd/info/extras.py
@@ -34,7 +34,6 @@ from ....yamlconf.loader import load_yaml_file
from .... import tools
from .... import aiotools
-from .... import env
from .. import sysunit
diff --git a/kvmd/apps/kvmd/info/fan.py b/kvmd/apps/kvmd/info/fan.py
index 8f3f69c8..a662831e 100644
--- a/kvmd/apps/kvmd/info/fan.py
+++ b/kvmd/apps/kvmd/info/fan.py
@@ -99,9 +99,9 @@ class FanInfoSubmanager(BaseInfoSubmanager):
async def __get_fan_state(self) -> (dict | None):
try:
async with self.__make_http_session() as session:
- async with session.get("http://localhost/state") as response:
- htclient.raise_not_200(response)
- return (await response.json())["result"]
+ async with session.get("http://localhost/state") as resp:
+ htclient.raise_not_200(resp)
+ return (await resp.json())["result"]
except Exception as ex:
get_logger(0).error("Can't read fan state: %s", ex)
return None
diff --git a/kvmd/apps/kvmd/info/hw.py b/kvmd/apps/kvmd/info/health.py
similarity index 73%
rename from kvmd/apps/kvmd/info/hw.py
rename to kvmd/apps/kvmd/info/health.py
index 3c444760..3c9f5f08 100644
--- a/kvmd/apps/kvmd/info/hw.py
+++ b/kvmd/apps/kvmd/info/health.py
@@ -20,7 +20,6 @@
# ========================================================================== #
-import os
import asyncio
import copy
@@ -45,59 +44,41 @@ _RetvalT = TypeVar("_RetvalT")
# =====
-class HwInfoSubmanager(BaseInfoSubmanager):
+class HealthInfoSubmanager(BaseInfoSubmanager):
def __init__(
self,
- platform_path: str,
vcgencmd_cmd: list[str],
ignore_past: bool,
state_poll: float,
) -> None:
- self.__platform_path = platform_path
self.__vcgencmd_cmd = vcgencmd_cmd
self.__ignore_past = ignore_past
self.__state_poll = state_poll
- self.__dt_cache: dict[str, str] = {}
-
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> dict:
(
- base,
- serial,
- platform,
throttling,
cpu_percent,
cpu_temp,
mem,
) = await asyncio.gather(
- 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(),
self.__get_cpu_temp(),
self.__get_mem(),
)
return {
- "platform": {
- "type": "rpi",
- "base": base,
- "serial": serial,
- **platform, # type: ignore
+ "temp": {
+ "cpu": cpu_temp,
},
- "health": {
- "temp": {
- "cpu": cpu_temp,
- },
- "cpu": {
- "percent": cpu_percent,
- },
- "mem": mem,
- "throttling": throttling,
+ "cpu": {
+ "percent": cpu_percent,
},
+ "mem": mem,
+ "throttling": throttling,
}
async def trigger_state(self) -> None:
@@ -115,42 +96,12 @@ class HwInfoSubmanager(BaseInfoSubmanager):
# =====
- 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):
- path = os.path.join(f"{env.PROCFS_PREFIX}/etc/kvmd/hw_info/", name)
- try:
- self.__dt_cache[name] = (await aiotools.read_file(path)).strip(" \t\r\n\0")
- except Exception as err:
- #get_logger(0).warn("Can't read DT %s from %s: %s", name, path, err)
- return None
- return self.__dt_cache[name]
-
- async def __read_platform_file(self) -> dict:
- try:
- text = await aiotools.read_file(self.__platform_path)
- parsed: dict[str, str] = {}
- for row in text.split("\n"):
- row = row.strip()
- if row:
- (key, value) = row.split("=", 1)
- parsed[key.strip()] = value.strip()
- return {
- "model": parsed["PIKVM_MODEL"],
- "video": parsed["PIKVM_VIDEO"],
- "board": parsed["PIKVM_BOARD"],
- }
- except Exception:
- get_logger(0).exception("Can't read device model")
- return {"model": None, "video": None, "board": None}
-
async def __get_cpu_temp(self) -> (float | None):
temp_path = f"{env.SYSFS_PREFIX}/sys/class/thermal/thermal_zone0/temp"
try:
return int((await aiotools.read_file(temp_path)).strip()) / 1000
- except Exception as err:
- #get_logger(0).warn("Can't read CPU temp from %s: %s", temp_path, err)
+ except Exception:
+ # get_logger(0).warn("Can't read CPU temp from %s: %s", temp_path, err)
return None
async def __get_cpu_percent(self) -> (float | None):
diff --git a/kvmd/apps/kvmd/info/meta.py b/kvmd/apps/kvmd/info/meta.py
index 996e648a..320796bf 100644
--- a/kvmd/apps/kvmd/info/meta.py
+++ b/kvmd/apps/kvmd/info/meta.py
@@ -20,6 +20,8 @@
# ========================================================================== #
+import socket
+
from typing import AsyncGenerator
from ....logging import get_logger
@@ -39,7 +41,10 @@ class MetaInfoSubmanager(BaseInfoSubmanager):
async def get_state(self) -> (dict | None):
try:
- return ((await aiotools.run_async(load_yaml_file, self.__meta_path)) or {})
+ meta = ((await aiotools.run_async(load_yaml_file, self.__meta_path)) or {})
+ if meta["server"]["host"] == "@auto":
+ meta["server"]["host"] = socket.getfqdn()
+ return meta
except Exception:
get_logger(0).exception("Can't parse meta")
return None
diff --git a/kvmd/apps/kvmd/info/system.py b/kvmd/apps/kvmd/info/system.py
index d4a450de..85b46673 100644
--- a/kvmd/apps/kvmd/info/system.py
+++ b/kvmd/apps/kvmd/info/system.py
@@ -28,6 +28,7 @@ from typing import AsyncGenerator
from ....logging import get_logger
+from .... import env
from .... import aiotools
from .... import aioproc
@@ -38,12 +39,30 @@ from .base import BaseInfoSubmanager
# =====
class SystemInfoSubmanager(BaseInfoSubmanager):
- def __init__(self, streamer_cmd: list[str]) -> None:
+ def __init__(
+ self,
+ platform_path: str,
+ streamer_cmd: list[str],
+ ) -> None:
+
+ self.__platform_path = platform_path
self.__streamer_cmd = streamer_cmd
+
+ self.__dt_cache: dict[str, str] = {}
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> dict:
- streamer_info = await self.__get_streamer_info()
+ (
+ base,
+ serial,
+ pl,
+ streamer_info,
+ ) = await asyncio.gather(
+ self.__read_dt_file("model", upper=False),
+ self.__read_dt_file("serial-number", upper=True),
+ self.__read_platform_file(),
+ self.__get_streamer_info(),
+ )
uname_info = platform.uname() # Uname using the internal cache
return {
"kvmd": {"version": __version__},
@@ -52,6 +71,12 @@ class SystemInfoSubmanager(BaseInfoSubmanager):
field: getattr(uname_info, field)
for field in ["system", "release", "version", "machine"]
},
+ "platform": {
+ "type": "rpi",
+ "base": base,
+ "serial": serial,
+ **pl, # type: ignore
+ },
}
async def trigger_state(self) -> None:
@@ -64,6 +89,35 @@ class SystemInfoSubmanager(BaseInfoSubmanager):
# =====
+ 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)
+ try:
+ value = (await aiotools.read_file(path)).strip(" \t\r\n\0")
+ self.__dt_cache[name] = (value.upper() if upper else value)
+ except Exception as ex:
+ get_logger(0).error("Can't read DT %s from %s: %s", name, path, ex)
+ return None
+ return self.__dt_cache[name]
+
+ async def __read_platform_file(self) -> dict:
+ try:
+ text = await aiotools.read_file(self.__platform_path)
+ parsed: dict[str, str] = {}
+ for row in text.split("\n"):
+ row = row.strip()
+ if row:
+ (key, value) = row.split("=", 1)
+ parsed[key.strip()] = value.strip()
+ return {
+ "model": parsed["PIKVM_MODEL"],
+ "video": parsed["PIKVM_VIDEO"],
+ "board": parsed["PIKVM_BOARD"],
+ }
+ except Exception:
+ get_logger(0).exception("Can't read device model")
+ return {"model": None, "video": None, "board": None}
+
async def __get_streamer_info(self) -> dict:
version = ""
features: dict[str, bool] = {}
diff --git a/kvmd/apps/kvmd/logreader.py b/kvmd/apps/kvmd/logreader.py
index c500756d..50130848 100644
--- a/kvmd/apps/kvmd/logreader.py
+++ b/kvmd/apps/kvmd/logreader.py
@@ -29,13 +29,11 @@ import time
from typing import AsyncGenerator
from xmlrpc.client import ServerProxy
-from ...logging import get_logger
us_systemd_journal = True
try:
import systemd.journal
except ImportError:
- import supervisor.xmlrpc
us_systemd_journal = False
@@ -43,14 +41,14 @@ except ImportError:
class LogReader:
async def poll_log(self, seek: int, follow: bool) -> AsyncGenerator[dict, None]:
if us_systemd_journal:
- reader = systemd.journal.Reader() # type: ignore
+ reader = systemd.journal.Reader() # type: ignore
reader.this_boot()
# XXX: Из-за смены ID машины в bootconfig это не работает при первой загрузке.
# reader.this_machine()
- reader.log_level(systemd.journal.LOG_DEBUG) # type: ignore
+ reader.log_level(systemd.journal.LOG_DEBUG) # type: ignore
services = set(
service
- for service in systemd.journal.Reader().query_unique("_SYSTEMD_UNIT") # type: ignore
+ for service in systemd.journal.Reader().query_unique("_SYSTEMD_UNIT") # type: ignore
if re.match(r"kvmd(-\w+)*\.service", service)
).union(["kvmd.service"])
@@ -69,10 +67,15 @@ class LogReader:
else:
await asyncio.sleep(1)
else:
- server = ServerProxy('http://127.0.0.1',transport=supervisor.xmlrpc.SupervisorTransport(None, None, serverurl='unix:///tmp/supervisor.sock'))
- log_entries = server.supervisor.readLog(0,0)
- yield log_entries
-
+ import supervisor.xmlrpc # pylint: disable=import-outside-toplevel
+ server_transport = supervisor.xmlrpc.SupervisorTransport(None, None, serverurl="unix:///tmp/supervisor.sock")
+ server = ServerProxy("http://127.0.0.1", transport=server_transport)
+ log_entries = server.supervisor.readLog(0, 0)
+ yield {
+ "dt": int(time.time()),
+ "service": "kvmd.service",
+ "msg": str(log_entries).rstrip()
+ }
def __entry_to_record(self, entry: dict) -> dict[str, dict]:
return {
diff --git a/kvmd/apps/kvmd/server.py b/kvmd/apps/kvmd/server.py
index 858ba1b6..f53fc796 100644
--- a/kvmd/apps/kvmd/server.py
+++ b/kvmd/apps/kvmd/server.py
@@ -254,6 +254,10 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
async def __ws_ping_handler(self, ws: WsSession, _: dict) -> None:
await ws.send_event("pong", {})
+ @exposed_ws(0)
+ async def __ws_bin_ping_handler(self, ws: WsSession, _: bytes) -> None:
+ await ws.send_bin(255, b"") # Ping-pong
+
# ===== SYSTEM STUFF
def run(self, **kwargs: Any) -> None: # type: ignore # pylint: disable=arguments-differ
@@ -318,18 +322,17 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
while True:
cur = (self.__has_stream_clients() or self.__snapshoter.snapshoting() or self.__stream_forever)
if not prev and cur:
- await self.__streamer.ensure_start(reset=False)
+ await self.__streamer.ensure_start()
elif prev and not cur:
- await self.__streamer.ensure_stop(immediately=False)
+ await self.__streamer.ensure_stop()
- if self.__reset_streamer or self.__new_streamer_params:
- start = self.__streamer.is_working()
- await self.__streamer.ensure_stop(immediately=True)
- if self.__new_streamer_params:
- self.__streamer.set_params(self.__new_streamer_params)
- self.__new_streamer_params = {}
- if start:
- await self.__streamer.ensure_start(reset=self.__reset_streamer)
+ if self.__new_streamer_params:
+ self.__streamer.set_params(self.__new_streamer_params)
+ self.__new_streamer_params = {}
+ self.__reset_streamer = True
+
+ if self.__reset_streamer:
+ await self.__streamer.ensure_restart()
self.__reset_streamer = False
prev = cur
diff --git a/kvmd/apps/kvmd/snapshoter.py b/kvmd/apps/kvmd/snapshoter.py
index e9391306..3799b281 100644
--- a/kvmd/apps/kvmd/snapshoter.py
+++ b/kvmd/apps/kvmd/snapshoter.py
@@ -31,6 +31,8 @@ from ... import aiotools
from ...plugins.hid import BaseHid
+from ...keyboard.mappings import WEB_TO_EVDEV
+
from .streamer import Streamer
@@ -63,7 +65,7 @@ class Snapshoter: # pylint: disable=too-many-instance-attributes
else:
self.__idle_interval = self.__live_interval = 0.0
- self.__wakeup_key = wakeup_key
+ self.__wakeup_key = WEB_TO_EVDEV.get(wakeup_key, 0)
self.__wakeup_move = wakeup_move
self.__online_delay = online_delay
@@ -121,8 +123,8 @@ class Snapshoter: # pylint: disable=too-many-instance-attributes
async def __wakeup(self) -> None:
logger = get_logger(0)
- if self.__wakeup_key:
- logger.info("Waking up using key %r ...", self.__wakeup_key)
+ if self.__wakeup_key > 0:
+ logger.info("Waking up using keyboard ...")
await self.__hid.send_key_events(
keys=[(self.__wakeup_key, True), (self.__wakeup_key, False)],
no_ignore_keys=True,
diff --git a/kvmd/apps/kvmd/streamer.py b/kvmd/apps/kvmd/streamer.py
deleted file mode 100644
index 08c48eb1..00000000
--- a/kvmd/apps/kvmd/streamer.py
+++ /dev/null
@@ -1,456 +0,0 @@
-# ========================================================================== #
-# #
-# KVMD - The main PiKVM daemon. #
-# #
-# Copyright (C) 2018-2024 Maxim Devaev #
-# #
-# 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 . #
-# #
-# ========================================================================== #
-
-
-import signal
-import asyncio
-import asyncio.subprocess
-import dataclasses
-import copy
-
-from typing import AsyncGenerator
-from typing import Any
-
-import aiohttp
-
-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
-from ... import htclient
-
-
-# =====
-class _StreamerParams:
- __DESIRED_FPS = "desired_fps"
-
- __QUALITY = "quality"
-
- __RESOLUTION = "resolution"
- __AVAILABLE_RESOLUTIONS = "available_resolutions"
-
- __H264_BITRATE = "h264_bitrate"
- __H264_GOP = "h264_gop"
-
- def __init__( # pylint: disable=too-many-arguments
- self,
- quality: int,
-
- resolution: str,
- available_resolutions: list[str],
-
- desired_fps: int,
- desired_fps_min: int,
- desired_fps_max: int,
-
- h264_bitrate: int,
- h264_bitrate_min: int,
- h264_bitrate_max: int,
-
- h264_gop: int,
- h264_gop_min: int,
- h264_gop_max: int,
- ) -> None:
-
- self.__has_quality = bool(quality)
- self.__has_resolution = bool(resolution)
- self.__has_h264 = bool(h264_bitrate)
-
- self.__params: dict = {self.__DESIRED_FPS: min(max(desired_fps, desired_fps_min), desired_fps_max)}
- self.__limits: dict = {self.__DESIRED_FPS: {"min": desired_fps_min, "max": desired_fps_max}}
-
- if self.__has_quality:
- self.__params[self.__QUALITY] = quality
-
- if self.__has_resolution:
- self.__params[self.__RESOLUTION] = resolution
- self.__limits[self.__AVAILABLE_RESOLUTIONS] = available_resolutions
-
- if self.__has_h264:
- self.__params[self.__H264_BITRATE] = min(max(h264_bitrate, h264_bitrate_min), h264_bitrate_max)
- self.__limits[self.__H264_BITRATE] = {"min": h264_bitrate_min, "max": h264_bitrate_max}
- self.__params[self.__H264_GOP] = min(max(h264_gop, h264_gop_min), h264_gop_max)
- self.__limits[self.__H264_GOP] = {"min": h264_gop_min, "max": h264_gop_max}
-
- def get_features(self) -> dict:
- return {
- self.__QUALITY: self.__has_quality,
- self.__RESOLUTION: self.__has_resolution,
- "h264": self.__has_h264,
- }
-
- def get_limits(self) -> dict:
- limits = copy.deepcopy(self.__limits)
- if self.__has_resolution:
- limits[self.__AVAILABLE_RESOLUTIONS] = list(limits[self.__AVAILABLE_RESOLUTIONS])
- return limits
-
- def get_params(self) -> dict:
- return dict(self.__params)
-
- def set_params(self, params: dict) -> None:
- new_params = dict(self.__params)
-
- if self.__QUALITY in params and self.__has_quality:
- new_params[self.__QUALITY] = min(max(params[self.__QUALITY], 1), 100)
-
- if self.__RESOLUTION in params and self.__has_resolution:
- if params[self.__RESOLUTION] in self.__limits[self.__AVAILABLE_RESOLUTIONS]:
- new_params[self.__RESOLUTION] = params[self.__RESOLUTION]
-
- for (key, enabled) in [
- (self.__DESIRED_FPS, True),
- (self.__H264_BITRATE, self.__has_h264),
- (self.__H264_GOP, self.__has_h264),
- ]:
- if key in params and enabled:
- if self.__check_limits_min_max(key, params[key]):
- new_params[key] = params[key]
-
- self.__params = new_params
-
- def __check_limits_min_max(self, key: str, value: int) -> bool:
- return (self.__limits[key]["min"] <= value <= self.__limits[key]["max"])
-
-
-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,
-
- reset_delay: float,
- shutdown_delay: float,
- state_poll: float,
-
- unix_path: str,
- timeout: float,
- snapshot_timeout: float,
-
- process_name_prefix: str,
-
- pre_start_cmd: list[str],
- pre_start_cmd_remove: list[str],
- pre_start_cmd_append: list[str],
-
- cmd: list[str],
- cmd_remove: list[str],
- cmd_append: list[str],
-
- post_stop_cmd: list[str],
- post_stop_cmd_remove: list[str],
- post_stop_cmd_append: list[str],
-
- **params_kwargs: Any,
- ) -> None:
-
- self.__reset_delay = reset_delay
- self.__shutdown_delay = shutdown_delay
- self.__state_poll = state_poll
-
- self.__unix_path = unix_path
- self.__snapshot_timeout = snapshot_timeout
-
- self.__process_name_prefix = process_name_prefix
-
- self.__pre_start_cmd = tools.build_cmd(pre_start_cmd, pre_start_cmd_remove, pre_start_cmd_append)
- self.__cmd = tools.build_cmd(cmd, cmd_remove, cmd_append)
- self.__post_stop_cmd = tools.build_cmd(post_stop_cmd, post_stop_cmd_remove, post_stop_cmd_append)
-
- self.__params = _StreamerParams(**params_kwargs)
-
- self.__stop_task: (asyncio.Task | None) = None
- self.__stop_wip = False
-
- self.__streamer_task: (asyncio.Task | None) = None
- self.__streamer_proc: (asyncio.subprocess.Process | None) = None # pylint: disable=no-member
-
- 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()
-
- # =====
-
- @aiotools.atomic_fg
- async def ensure_start(self, reset: bool) -> None:
- if not self.__streamer_task or self.__stop_task:
- logger = get_logger(0)
-
- if self.__stop_task:
- if not self.__stop_wip:
- self.__stop_task.cancel()
- await asyncio.gather(self.__stop_task, return_exceptions=True)
- 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("Waiting %.2f seconds for reset delay ...", self.__reset_delay)
- await asyncio.sleep(self.__reset_delay)
- logger.info("Starting streamer ...")
- await self.__inner_start()
-
- @aiotools.atomic_fg
- async def ensure_stop(self, immediately: bool) -> None:
- if self.__streamer_task:
- logger = get_logger(0)
-
- if immediately:
- if self.__stop_task:
- if not self.__stop_wip:
- self.__stop_task.cancel()
- await asyncio.gather(self.__stop_task, return_exceptions=True)
- logger.info("Stopping streamer immediately ...")
- await self.__inner_stop()
- else:
- await asyncio.gather(self.__stop_task, return_exceptions=True)
- else:
- logger.info("Stopping streamer immediately ...")
- await self.__inner_stop()
-
- elif not self.__stop_task:
-
- async def delayed_stop() -> None:
- try:
- await asyncio.sleep(self.__shutdown_delay)
- self.__stop_wip = True
- logger.info("Stopping streamer after delay ...")
- await self.__inner_stop()
- finally:
- self.__stop_task = None
- self.__stop_wip = False
-
- 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:
- # Запущено и не планирует останавливаться
- return bool(self.__streamer_task and not self.__stop_task)
-
- # =====
-
- 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:
- return self.__params.get_params()
-
- # =====
-
- async def get_state(self) -> dict:
- 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_client_session()
- try:
- return (await session.get_state())
- except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError):
- pass
- except Exception:
- get_logger().exception("Invalid streamer response from /state")
- return None
-
- def __get_snapshot_state(self) -> dict:
- if self.__snapshot:
- snapshot = dataclasses.asdict(self.__snapshot)
- del snapshot["headers"]
- del snapshot["data"]
- return {"saved": snapshot}
- return {"saved": None}
-
- # =====
-
- async def take_snapshot(self, save: bool, load: bool, allow_offline: bool) -> (StreamerSnapshot | None):
- if load:
- return self.__snapshot
- logger = get_logger()
- session = self.__ensure_client_session()
- try:
- 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("Invalid streamer response from /snapshot")
- return None
-
- def remove_snapshot(self) -> None:
- self.__snapshot = None
-
- # =====
-
- @aiotools.atomic_fg
- async def cleanup(self) -> None:
- await self.ensure_stop(immediately=True)
- if self.__client_session:
- await self.__client_session.close()
- self.__client_session = None
-
- def __ensure_client_session(self) -> HttpStreamerClientSession:
- if not self.__client_session:
- self.__client_session = self.__client.make_session()
- return self.__client_session
-
- # =====
-
- @aiotools.atomic_fg
- async def __inner_start(self) -> None:
- assert not self.__streamer_task
- await self.__run_hook("PRE-START-CMD", self.__pre_start_cmd)
- self.__streamer_task = asyncio.create_task(self.__streamer_task_loop())
-
- @aiotools.atomic_fg
- async def __inner_stop(self) -> None:
- assert self.__streamer_task
- self.__streamer_task.cancel()
- await asyncio.gather(self.__streamer_task, return_exceptions=True)
- await self.__kill_streamer_proc()
- await self.__run_hook("POST-STOP-CMD", self.__post_stop_cmd)
- self.__streamer_task = None
-
- # =====
-
- async def __streamer_task_loop(self) -> None: # pylint: disable=too-many-branches
- logger = get_logger(0)
- while True: # pylint: disable=too-many-nested-blocks
- try:
- await self.__start_streamer_proc()
- assert self.__streamer_proc is not None
- await aioproc.log_stdout_infinite(self.__streamer_proc, logger)
- raise RuntimeError("Streamer unexpectedly died")
- except asyncio.CancelledError:
- break
- except Exception:
- if self.__streamer_proc:
- logger.exception("Unexpected streamer error: pid=%d", self.__streamer_proc.pid)
- else:
- logger.exception("Can't start streamer")
- await self.__kill_streamer_proc()
- await asyncio.sleep(1)
-
- def __make_cmd(self, cmd: list[str]) -> list[str]:
- return [
- part.format(
- unix=self.__unix_path,
- process_name_prefix=self.__process_name_prefix,
- **self.__params.get_params(),
- )
- for part in cmd
- ]
-
- async def __run_hook(self, name: str, cmd: list[str]) -> None:
- logger = get_logger()
- cmd = self.__make_cmd(cmd)
- logger.info("%s: %s", name, tools.cmdfmt(cmd))
- try:
- await aioproc.log_process(cmd, logger, prefix=name)
- 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("Started streamer pid=%d: %s", self.__streamer_proc.pid, tools.cmdfmt(cmd))
-
- async def __kill_streamer_proc(self) -> None:
- if self.__streamer_proc:
- await aioproc.kill_process(self.__streamer_proc, 1, get_logger(0))
- self.__streamer_proc = None
diff --git a/kvmd/apps/kvmd/streamer/__init__.py b/kvmd/apps/kvmd/streamer/__init__.py
new file mode 100644
index 00000000..5262546d
--- /dev/null
+++ b/kvmd/apps/kvmd/streamer/__init__.py
@@ -0,0 +1,254 @@
+# ========================================================================== #
+# #
+# KVMD - The main PiKVM daemon. #
+# #
+# Copyright (C) 2018-2024 Maxim Devaev #
+# #
+# 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 . #
+# #
+# ========================================================================== #
+
+
+import signal
+import asyncio
+import dataclasses
+import copy
+
+from typing import AsyncGenerator
+from typing import Any
+
+import aiohttp
+
+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 htclient
+
+from .params import Params
+from .runner import Runner
+
+
+# =====
+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,
+
+ reset_delay: float,
+ shutdown_delay: float,
+ state_poll: float,
+
+ unix_path: str,
+ timeout: float,
+ snapshot_timeout: float,
+
+ process_name_prefix: str,
+
+ pre_start_cmd: list[str],
+ pre_start_cmd_remove: list[str],
+ pre_start_cmd_append: list[str],
+
+ cmd: list[str],
+ cmd_remove: list[str],
+ cmd_append: list[str],
+
+ post_stop_cmd: list[str],
+ post_stop_cmd_remove: list[str],
+ post_stop_cmd_append: list[str],
+
+ **params_kwargs: Any,
+ ) -> None:
+
+ self.__state_poll = state_poll
+
+ self.__unix_path = unix_path
+ self.__snapshot_timeout = snapshot_timeout
+ self.__process_name_prefix = process_name_prefix
+
+ self.__params = Params(**params_kwargs)
+
+ self.__runner = Runner(
+ reset_delay=reset_delay,
+ shutdown_delay=shutdown_delay,
+ pre_start_cmd=tools.build_cmd(pre_start_cmd, pre_start_cmd_remove, pre_start_cmd_append),
+ cmd=tools.build_cmd(cmd, cmd_remove, cmd_append),
+ post_stop_cmd=tools.build_cmd(post_stop_cmd, post_stop_cmd_remove, post_stop_cmd_append),
+ )
+
+ 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()
+
+ # =====
+
+ @aiotools.atomic_fg
+ async def ensure_start(self) -> None:
+ await self.__runner.ensure_start(self.__make_params())
+
+ @aiotools.atomic_fg
+ async def ensure_restart(self) -> None:
+ await self.__runner.ensure_restart(self.__make_params())
+
+ def __make_params(self) -> dict:
+ return {
+ "unix": self.__unix_path,
+ "process_name_prefix": self.__process_name_prefix,
+ **self.__params.get_params(),
+ }
+
+ @aiotools.atomic_fg
+ async def ensure_stop(self) -> None:
+ await self.__runner.ensure_stop(immediately=False)
+
+ # =====
+
+ def set_params(self, params: dict) -> None:
+ self.__notifier.notify(self.__ST_PARAMS)
+ return self.__params.set_params(params)
+
+ def get_params(self) -> dict:
+ return self.__params.get_params()
+
+ # =====
+
+ async def get_state(self) -> dict:
+ 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.__runner.is_running():
+ session = self.__ensure_client_session()
+ try:
+ return (await session.get_state())
+ except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError):
+ pass
+ except Exception:
+ get_logger().exception("Invalid streamer response from /state")
+ return None
+
+ def __get_snapshot_state(self) -> dict:
+ if self.__snapshot:
+ snapshot = dataclasses.asdict(self.__snapshot)
+ del snapshot["headers"]
+ del snapshot["data"]
+ return {"saved": snapshot}
+ return {"saved": None}
+
+ # =====
+
+ async def take_snapshot(self, save: bool, load: bool, allow_offline: bool) -> (StreamerSnapshot | None):
+ if load:
+ return self.__snapshot
+ logger = get_logger()
+ session = self.__ensure_client_session()
+ try:
+ 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("Invalid streamer response from /snapshot")
+ return None
+
+ def remove_snapshot(self) -> None:
+ self.__snapshot = None
+
+ # =====
+
+ @aiotools.atomic_fg
+ async def cleanup(self) -> None:
+ await self.__runner.ensure_stop(immediately=True)
+ if self.__client_session:
+ await self.__client_session.close()
+ self.__client_session = None
+
+ def __ensure_client_session(self) -> HttpStreamerClientSession:
+ if not self.__client_session:
+ self.__client_session = self.__client.make_session()
+ return self.__client_session
diff --git a/kvmd/apps/kvmd/streamer/params.py b/kvmd/apps/kvmd/streamer/params.py
new file mode 100644
index 00000000..472e233a
--- /dev/null
+++ b/kvmd/apps/kvmd/streamer/params.py
@@ -0,0 +1,117 @@
+# ========================================================================== #
+# #
+# KVMD - The main PiKVM daemon. #
+# #
+# Copyright (C) 2018-2024 Maxim Devaev #
+# #
+# 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 . #
+# #
+# ========================================================================== #
+
+
+import copy
+
+
+# =====
+class Params:
+ __DESIRED_FPS = "desired_fps"
+
+ __QUALITY = "quality"
+
+ __RESOLUTION = "resolution"
+ __AVAILABLE_RESOLUTIONS = "available_resolutions"
+
+ __H264 = "h264"
+ __H264_BITRATE = "h264_bitrate"
+ __H264_GOP = "h264_gop"
+
+ def __init__( # pylint: disable=too-many-arguments
+ self,
+ quality: int,
+
+ resolution: str,
+ available_resolutions: list[str],
+
+ desired_fps: int,
+ desired_fps_min: int,
+ desired_fps_max: int,
+
+ h264_bitrate: int,
+ h264_bitrate_min: int,
+ h264_bitrate_max: int,
+
+ h264_gop: int,
+ h264_gop_min: int,
+ h264_gop_max: int,
+ ) -> None:
+
+ self.__has_quality = bool(quality)
+ self.__has_resolution = bool(resolution)
+ self.__has_h264 = bool(h264_bitrate)
+
+ self.__params: dict = {self.__DESIRED_FPS: min(max(desired_fps, desired_fps_min), desired_fps_max)}
+ self.__limits: dict = {self.__DESIRED_FPS: {"min": desired_fps_min, "max": desired_fps_max}}
+
+ if self.__has_quality:
+ self.__params[self.__QUALITY] = quality
+
+ if self.__has_resolution:
+ self.__params[self.__RESOLUTION] = resolution
+ self.__limits[self.__AVAILABLE_RESOLUTIONS] = available_resolutions
+
+ if self.__has_h264:
+ self.__params[self.__H264_BITRATE] = min(max(h264_bitrate, h264_bitrate_min), h264_bitrate_max)
+ self.__limits[self.__H264_BITRATE] = {"min": h264_bitrate_min, "max": h264_bitrate_max}
+ self.__params[self.__H264_GOP] = min(max(h264_gop, h264_gop_min), h264_gop_max)
+ self.__limits[self.__H264_GOP] = {"min": h264_gop_min, "max": h264_gop_max}
+
+ def get_features(self) -> dict:
+ return {
+ self.__QUALITY: self.__has_quality,
+ self.__RESOLUTION: self.__has_resolution,
+ self.__H264: self.__has_h264,
+ }
+
+ def get_limits(self) -> dict:
+ limits = copy.deepcopy(self.__limits)
+ if self.__has_resolution:
+ limits[self.__AVAILABLE_RESOLUTIONS] = list(limits[self.__AVAILABLE_RESOLUTIONS])
+ return limits
+
+ def get_params(self) -> dict:
+ return dict(self.__params)
+
+ def set_params(self, params: dict) -> None:
+ new = dict(self.__params)
+
+ if self.__QUALITY in params and self.__has_quality:
+ new[self.__QUALITY] = min(max(params[self.__QUALITY], 1), 100)
+
+ if self.__RESOLUTION in params and self.__has_resolution:
+ if params[self.__RESOLUTION] in self.__limits[self.__AVAILABLE_RESOLUTIONS]:
+ new[self.__RESOLUTION] = params[self.__RESOLUTION]
+
+ for (key, enabled) in [
+ (self.__DESIRED_FPS, True),
+ (self.__H264_BITRATE, self.__has_h264),
+ (self.__H264_GOP, self.__has_h264),
+ ]:
+ if key in params and enabled:
+ if self.__check_limits_min_max(key, params[key]):
+ new[key] = params[key]
+
+ self.__params = new
+
+ def __check_limits_min_max(self, key: str, value: int) -> bool:
+ return (self.__limits[key]["min"] <= value <= self.__limits[key]["max"])
diff --git a/kvmd/apps/kvmd/streamer/runner.py b/kvmd/apps/kvmd/streamer/runner.py
new file mode 100644
index 00000000..fef5a5e1
--- /dev/null
+++ b/kvmd/apps/kvmd/streamer/runner.py
@@ -0,0 +1,182 @@
+# ========================================================================== #
+# #
+# KVMD - The main PiKVM daemon. #
+# #
+# Copyright (C) 2018-2024 Maxim Devaev #
+# #
+# 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 . #
+# #
+# ========================================================================== #
+
+
+import asyncio
+import asyncio.subprocess
+
+from ....logging import get_logger
+
+from .... import tools
+from .... import aiotools
+from .... import aioproc
+
+
+# =====
+class Runner: # pylint: disable=too-many-instance-attributes
+ def __init__(
+ self,
+ reset_delay: float,
+ shutdown_delay: float,
+
+ pre_start_cmd: list[str],
+ cmd: list[str],
+ post_stop_cmd: list[str],
+ ) -> None:
+
+ self.__reset_delay = reset_delay
+ self.__shutdown_delay = shutdown_delay
+
+ self.__pre_start_cmd: list[str] = pre_start_cmd
+ self.__cmd: list[str] = cmd
+ self.__post_stop_cmd: list[str] = post_stop_cmd
+
+ self.__proc_params: dict = {}
+ self.__proc_task: (asyncio.Task | None) = None
+ self.__proc: (asyncio.subprocess.Process | None) = None # pylint: disable=no-member
+
+ self.__stopper_task: (asyncio.Task | None) = None
+ self.__stopper_wip = False
+
+ @aiotools.atomic_fg
+ async def ensure_start(self, params: dict) -> None:
+ if not self.__proc_task or self.__stopper_task:
+ logger = get_logger(0)
+
+ if self.__stopper_task:
+ if not self.__stopper_wip:
+ self.__stopper_task.cancel()
+ await asyncio.gather(self.__stopper_task, return_exceptions=True)
+ logger.info("Streamer stop cancelled")
+ return
+ else:
+ await asyncio.gather(self.__stopper_task, return_exceptions=True)
+
+ logger.info("Starting streamer ...")
+ await self.__inner_start(params)
+
+ @aiotools.atomic_fg
+ async def ensure_restart(self, params: dict) -> None:
+ logger = get_logger(0)
+ start = bool(self.__proc_task and not self.__stopper_task) # Если запущено и не планирует останавливаться
+ await self.ensure_stop(immediately=True)
+ if self.__reset_delay > 0:
+ logger.info("Waiting %.2f seconds for reset delay ...", self.__reset_delay)
+ await asyncio.sleep(self.__reset_delay)
+ if start:
+ await self.ensure_start(params)
+
+ @aiotools.atomic_fg
+ async def ensure_stop(self, immediately: bool) -> None:
+ if self.__proc_task:
+ logger = get_logger(0)
+
+ if immediately:
+ if self.__stopper_task:
+ if not self.__stopper_wip:
+ self.__stopper_task.cancel()
+ await asyncio.gather(self.__stopper_task, return_exceptions=True)
+ logger.info("Stopping streamer immediately ...")
+ await self.__inner_stop()
+ else:
+ await asyncio.gather(self.__stopper_task, return_exceptions=True)
+ else:
+ logger.info("Stopping streamer immediately ...")
+ await self.__inner_stop()
+
+ elif not self.__stopper_task:
+
+ async def delayed_stop() -> None:
+ try:
+ await asyncio.sleep(self.__shutdown_delay)
+ self.__stopper_wip = True
+ logger.info("Stopping streamer after delay ...")
+ await self.__inner_stop()
+ finally:
+ self.__stopper_task = None
+ self.__stopper_wip = False
+
+ logger.info("Planning to stop streamer in %.2f seconds ...", self.__shutdown_delay)
+ self.__stopper_task = asyncio.create_task(delayed_stop())
+
+ def is_running(self) -> bool:
+ return bool(self.__proc_task)
+
+ # =====
+
+ @aiotools.atomic_fg
+ async def __inner_start(self, params: dict) -> None:
+ assert not self.__proc_task
+ self.__proc_params = params
+ await self.__run_hook("PRE-START-CMD", self.__pre_start_cmd)
+ self.__proc_task = asyncio.create_task(self.__process_task_loop())
+
+ @aiotools.atomic_fg
+ async def __inner_stop(self) -> None:
+ assert self.__proc_task
+ self.__proc_task.cancel()
+ await asyncio.gather(self.__proc_task, return_exceptions=True)
+ await self.__kill_process()
+ await self.__run_hook("POST-STOP-CMD", self.__post_stop_cmd)
+ self.__proc_task = None
+
+ # =====
+
+ async def __process_task_loop(self) -> None: # pylint: disable=too-many-branches
+ logger = get_logger(0)
+ while True: # pylint: disable=too-many-nested-blocks
+ try:
+ await self.__start_process()
+ assert self.__proc is not None
+ await aioproc.log_stdout_infinite(self.__proc, logger)
+ raise RuntimeError("Streamer unexpectedly died")
+ except asyncio.CancelledError:
+ break
+ except Exception:
+ if self.__proc:
+ logger.exception("Unexpected streamer error: pid=%d", self.__proc.pid)
+ else:
+ logger.exception("Can't start streamer")
+ await self.__kill_process()
+ await asyncio.sleep(1)
+
+ def __make_cmd(self, cmd: list[str]) -> list[str]:
+ return [part.format(**self.__proc_params) for part in cmd]
+
+ async def __run_hook(self, name: str, cmd: list[str]) -> None:
+ logger = get_logger()
+ cmd = self.__make_cmd(cmd)
+ logger.info("%s: %s", name, tools.cmdfmt(cmd))
+ try:
+ await aioproc.log_process(cmd, logger, prefix=name)
+ except Exception:
+ logger.exception("Can't execute %s hook: %s", name, tools.cmdfmt(cmd))
+
+ async def __start_process(self) -> None:
+ assert self.__proc is None
+ cmd = self.__make_cmd(self.__cmd)
+ self.__proc = await aioproc.run_process(cmd)
+ get_logger(0).info("Started streamer pid=%d: %s", self.__proc.pid, tools.cmdfmt(cmd))
+
+ async def __kill_process(self) -> None:
+ if self.__proc:
+ await aioproc.kill_process(self.__proc, 1, get_logger(0))
+ self.__proc = None
diff --git a/kvmd/apps/kvmd/switch/__init__.py b/kvmd/apps/kvmd/switch/__init__.py
index 49bfbd7d..85eca48a 100644
--- a/kvmd/apps/kvmd/switch/__init__.py
+++ b/kvmd/apps/kvmd/switch/__init__.py
@@ -32,6 +32,7 @@ from .lib import Inotify
from .types import Edid
from .types import Edids
+from .types import Dummies
from .types import Color
from .types import Colors
from .types import PortNames
@@ -68,6 +69,7 @@ class SwitchUnknownEdidError(SwitchOperationError):
# =====
class Switch: # pylint: disable=too-many-public-methods
__X_EDIDS = "edids"
+ __X_DUMMIES = "dummies"
__X_COLORS = "colors"
__X_PORT_NAMES = "port_names"
__X_ATX_CP_DELAYS = "atx_cp_delays"
@@ -75,7 +77,7 @@ class Switch: # pylint: disable=too-many-public-methods
__X_ATX_CR_DELAYS = "atx_cr_delays"
__X_ALL = frozenset([
- __X_EDIDS, __X_COLORS, __X_PORT_NAMES,
+ __X_EDIDS, __X_DUMMIES, __X_COLORS, __X_PORT_NAMES,
__X_ATX_CP_DELAYS, __X_ATX_CPL_DELAYS, __X_ATX_CR_DELAYS,
])
@@ -84,11 +86,12 @@ class Switch: # pylint: disable=too-many-public-methods
device_path: str,
default_edid_path: str,
pst_unix_path: str,
+ ignore_hpd_on_top: bool,
) -> None:
self.__default_edid_path = default_edid_path
- self.__chain = Chain(device_path)
+ self.__chain = Chain(device_path, ignore_hpd_on_top)
self.__cache = StateCache()
self.__storage = Storage(pst_unix_path)
@@ -104,6 +107,12 @@ class Switch: # pylint: disable=too-many-public-methods
if save:
self.__save_notifier.notify()
+ def __x_set_dummies(self, dummies: Dummies, save: bool=True) -> None:
+ self.__chain.set_dummies(dummies)
+ self.__cache.set_dummies(dummies)
+ if save:
+ self.__save_notifier.notify()
+
def __x_set_colors(self, colors: Colors, save: bool=True) -> None:
self.__chain.set_colors(colors)
self.__cache.set_colors(colors)
@@ -132,13 +141,19 @@ class Switch: # pylint: disable=too-many-public-methods
# =====
- async def set_active_port(self, port: int) -> None:
- self.__chain.set_active_port(port)
+ async def set_active_prev(self) -> None:
+ self.__chain.set_active_prev()
+
+ async def set_active_next(self) -> None:
+ self.__chain.set_active_next()
+
+ async def set_active_port(self, port: float) -> None:
+ self.__chain.set_active_port(self.__chain.translate_port(port))
# =====
- async def set_port_beacon(self, port: int, on: bool) -> None:
- self.__chain.set_port_beacon(port, on)
+ async def set_port_beacon(self, port: float, on: bool) -> None:
+ self.__chain.set_port_beacon(self.__chain.translate_port(port), on)
async def set_uplink_beacon(self, unit: int, on: bool) -> None:
self.__chain.set_uplink_beacon(unit, on)
@@ -148,33 +163,35 @@ class Switch: # pylint: disable=too-many-public-methods
# =====
- async def atx_power_on(self, port: int) -> None:
+ async def atx_power_on(self, port: float) -> None:
self.__inner_atx_cp(port, False, self.__X_ATX_CP_DELAYS)
- async def atx_power_off(self, port: int) -> None:
+ async def atx_power_off(self, port: float) -> None:
self.__inner_atx_cp(port, True, self.__X_ATX_CP_DELAYS)
- async def atx_power_off_hard(self, port: int) -> None:
+ async def atx_power_off_hard(self, port: float) -> None:
self.__inner_atx_cp(port, True, self.__X_ATX_CPL_DELAYS)
- async def atx_power_reset_hard(self, port: int) -> None:
+ async def atx_power_reset_hard(self, port: float) -> None:
self.__inner_atx_cr(port, True)
- async def atx_click_power(self, port: int) -> None:
+ async def atx_click_power(self, port: float) -> None:
self.__inner_atx_cp(port, None, self.__X_ATX_CP_DELAYS)
- async def atx_click_power_long(self, port: int) -> None:
+ async def atx_click_power_long(self, port: float) -> None:
self.__inner_atx_cp(port, None, self.__X_ATX_CPL_DELAYS)
- async def atx_click_reset(self, port: int) -> None:
+ async def atx_click_reset(self, port: float) -> None:
self.__inner_atx_cr(port, None)
- def __inner_atx_cp(self, port: int, if_powered: (bool | None), x_delay: str) -> None:
+ def __inner_atx_cp(self, port: float, if_powered: (bool | None), x_delay: str) -> None:
assert x_delay in [self.__X_ATX_CP_DELAYS, self.__X_ATX_CPL_DELAYS]
+ port = self.__chain.translate_port(port)
delay = getattr(self.__cache, f"get_{x_delay}")()[port]
self.__chain.click_power(port, delay, if_powered)
- def __inner_atx_cr(self, port: int, if_powered: (bool | None)) -> None:
+ def __inner_atx_cr(self, port: float, if_powered: (bool | None)) -> None:
+ port = self.__chain.translate_port(port)
delay = self.__cache.get_atx_cr_delays()[port]
self.__chain.click_reset(port, delay, if_powered)
@@ -235,12 +252,14 @@ class Switch: # pylint: disable=too-many-public-methods
self,
port: int,
edid_id: (str | None)=None,
+ dummy: (bool | None)=None,
name: (str | None)=None,
atx_click_power_delay: (float | None)=None,
atx_click_power_long_delay: (float | None)=None,
atx_click_reset_delay: (float | None)=None,
) -> None:
+ port = self.__chain.translate_port(port)
async with self.__lock:
if edid_id is not None:
edids = self.__cache.get_edids()
@@ -249,15 +268,16 @@ class Switch: # pylint: disable=too-many-public-methods
edids.assign(port, edid_id)
self.__x_set_edids(edids)
- for (key, value) in [
- (self.__X_PORT_NAMES, name),
- (self.__X_ATX_CP_DELAYS, atx_click_power_delay),
- (self.__X_ATX_CPL_DELAYS, atx_click_power_long_delay),
- (self.__X_ATX_CR_DELAYS, atx_click_reset_delay),
+ for (reset, key, value) in [
+ (None, self.__X_DUMMIES, dummy), # None can't be used now
+ ("", self.__X_PORT_NAMES, name),
+ (0, self.__X_ATX_CP_DELAYS, atx_click_power_delay),
+ (0, self.__X_ATX_CPL_DELAYS, atx_click_power_long_delay),
+ (0, self.__X_ATX_CR_DELAYS, atx_click_reset_delay),
]:
if value is not None:
new = getattr(self.__cache, f"get_{key}")()
- new[port] = (value or None) # None == reset to default
+ new[port] = (None if value == reset else value) # Value or reset default
getattr(self, f"_Switch__x_set_{key}")(new)
# =====
@@ -374,7 +394,7 @@ class Switch: # pylint: disable=too-many-public-methods
prevs = dict.fromkeys(self.__X_ALL)
while True:
await self.__save_notifier.wait()
- while (await self.__save_notifier.wait(5)):
+ while not (await self.__save_notifier.wait(5)):
pass
while True:
try:
diff --git a/kvmd/apps/kvmd/switch/chain.py b/kvmd/apps/kvmd/switch/chain.py
index 8e4d94eb..f17777d2 100644
--- a/kvmd/apps/kvmd/switch/chain.py
+++ b/kvmd/apps/kvmd/switch/chain.py
@@ -34,6 +34,7 @@ from .lib import aiotools
from .lib import aioproc
from .types import Edids
+from .types import Dummies
from .types import Colors
from .proto import Response
@@ -54,6 +55,14 @@ class _CmdSetActual(_BaseCmd):
actual: bool
+class _CmdSetActivePrev(_BaseCmd):
+ pass
+
+
+class _CmdSetActiveNext(_BaseCmd):
+ pass
+
+
@dataclasses.dataclass(frozen=True)
class _CmdSetActivePort(_BaseCmd):
port: int
@@ -80,6 +89,11 @@ class _CmdSetEdids(_BaseCmd):
edids: Edids
+@dataclasses.dataclass(frozen=True)
+class _CmdSetDummies(_BaseCmd):
+ dummies: Dummies
+
+
@dataclasses.dataclass(frozen=True)
class _CmdSetColors(_BaseCmd):
colors: Colors
@@ -177,13 +191,19 @@ class UnitAtxLedsEvent(BaseEvent):
# =====
class Chain: # pylint: disable=too-many-instance-attributes
- def __init__(self, device_path: str) -> None:
+ def __init__(
+ self,
+ device_path: str,
+ ignore_hpd_on_top: bool,
+ ) -> None:
+
self.__device = Device(device_path)
+ self.__ignore_hpd_on_top = ignore_hpd_on_top
self.__actual = False
self.__edids = Edids()
-
+ self.__dummies = Dummies({})
self.__colors = Colors()
self.__units: list[_UnitContext] = []
@@ -200,6 +220,24 @@ class Chain: # pylint: disable=too-many-instance-attributes
# =====
+ def translate_port(self, port: float) -> int:
+ assert port >= 0
+ if int(port) == port:
+ return int(port)
+ (unit, ch) = map(int, str(port).split("."))
+ unit = min(max(unit, 1), 5)
+ ch = min(max(ch, 1), 4)
+ port = min((unit - 1) * 4 + (ch - 1), 19)
+ return port
+
+ # =====
+
+ def set_active_prev(self) -> None:
+ self.__queue_cmd(_CmdSetActivePrev())
+
+ def set_active_next(self) -> None:
+ self.__queue_cmd(_CmdSetActiveNext())
+
def set_active_port(self, port: int) -> None:
self.__queue_cmd(_CmdSetActivePort(port))
@@ -219,6 +257,9 @@ class Chain: # pylint: disable=too-many-instance-attributes
def set_edids(self, edids: Edids) -> None:
self.__queue_cmd(_CmdSetEdids(edids)) # Will be copied because of multiprocessing.Queue()
+ def set_dummies(self, dummies: Dummies) -> None:
+ self.__queue_cmd(_CmdSetDummies(dummies))
+
def set_colors(self, colors: Colors) -> None:
self.__queue_cmd(_CmdSetColors(colors))
@@ -290,12 +331,21 @@ class Chain: # pylint: disable=too-many-instance-attributes
self.__device.request_state()
self.__device.request_atx_leds()
while not self.__stop_event.is_set():
+ count = 0
if self.__select():
+ count = 0
for resp in self.__device.read_all():
self.__update_units(resp)
+ self.__adjust_quirks()
self.__adjust_start_port()
self.__finish_changing_request(resp)
self.__consume_commands()
+ else:
+ count += 1
+ if count >= 5:
+ # Heartbeat
+ self.__device.request_state()
+ count = 0
self.__ensure_config()
def __select(self) -> bool:
@@ -314,10 +364,29 @@ class Chain: # pylint: disable=too-many-instance-attributes
case _CmdSetActual():
self.__actual = cmd.actual
+ case _CmdSetActivePrev():
+ if len(self.__units) > 0:
+ port = self.__active_port
+ port -= 1
+ if port >= 0:
+ self.__active_port = port
+ self.__queue_event(PortActivatedEvent(self.__active_port))
+
+ case _CmdSetActiveNext():
+ port = self.__active_port
+ if port < 0:
+ port = 0
+ else:
+ port += 1
+ if port < len(self.__units) * 4:
+ self.__active_port = port
+ self.__queue_event(PortActivatedEvent(self.__active_port))
+
case _CmdSetActivePort():
# Может быть вызвано изнутри при синхронизации
- self.__active_port = cmd.port
- self.__queue_event(PortActivatedEvent(self.__active_port))
+ if cmd.port < len(self.__units) * 4:
+ self.__active_port = cmd.port
+ self.__queue_event(PortActivatedEvent(self.__active_port))
case _CmdSetPortBeacon():
(unit, ch) = self.get_real_unit_channel(cmd.port)
@@ -341,6 +410,9 @@ class Chain: # pylint: disable=too-many-instance-attributes
case _CmdSetEdids():
self.__edids = cmd.edids
+ case _CmdSetDummies():
+ self.__dummies = cmd.dummies
+
case _CmdSetColors():
self.__colors = cmd.colors
@@ -364,6 +436,15 @@ class Chain: # pylint: disable=too-many-instance-attributes
self.__units[resp.header.unit].atx_leds = resp.body
self.__queue_event(UnitAtxLedsEvent(resp.header.unit, resp.body))
+ def __adjust_quirks(self) -> None:
+ for (unit, ctx) in enumerate(self.__units):
+ if ctx.state is not None and ctx.state.version.is_fresh(7):
+ ignore_hpd = (unit == 0 and self.__ignore_hpd_on_top)
+ if ctx.state.quirks.ignore_hpd != ignore_hpd:
+ get_logger().info("Applying quirk ignore_hpd=%s to [%d] ...",
+ ignore_hpd, unit)
+ self.__device.request_set_quirks(unit, ignore_hpd)
+
def __adjust_start_port(self) -> None:
if self.__active_port < 0:
for (unit, ctx) in enumerate(self.__units):
@@ -387,6 +468,7 @@ class Chain: # pylint: disable=too-many-instance-attributes
self.__ensure_config_port(unit, ctx)
if self.__actual:
self.__ensure_config_edids(unit, ctx)
+ self.__ensure_config_dummies(unit, ctx)
self.__ensure_config_colors(unit, ctx)
def __ensure_config_port(self, unit: int, ctx: _UnitContext) -> None:
@@ -413,6 +495,19 @@ class Chain: # pylint: disable=too-many-instance-attributes
ctx.changing_rid = self.__device.request_set_edid(unit, ch, edid)
break # Busy globally
+ def __ensure_config_dummies(self, unit: int, ctx: _UnitContext) -> None:
+ assert ctx.state is not None
+ if ctx.state.version.is_fresh(8) and ctx.can_be_changed():
+ for ch in range(4):
+ port = self.get_virtual_port(unit, ch)
+ dummy = self.__dummies[port]
+ if ctx.state.video_dummies[ch] != dummy:
+ get_logger().info("Changing dummy flag on port %d on [%d:%d]: %d -> %d ...",
+ port, unit, ch,
+ ctx.state.video_dummies[ch], dummy)
+ ctx.changing_rid = self.__device.request_set_dummy(unit, ch, dummy)
+ break # Busy globally (actually not but it can be changed in the firmware)
+
def __ensure_config_colors(self, unit: int, ctx: _UnitContext) -> None:
assert self.__actual
assert ctx.state is not None
diff --git a/kvmd/apps/kvmd/switch/device.py b/kvmd/apps/kvmd/switch/device.py
index b56cc406..df532804 100644
--- a/kvmd/apps/kvmd/switch/device.py
+++ b/kvmd/apps/kvmd/switch/device.py
@@ -41,7 +41,9 @@ from .proto import BodySetBeacon
from .proto import BodyAtxClick
from .proto import BodySetEdid
from .proto import BodyClearEdid
+from .proto import BodySetDummy
from .proto import BodySetColors
+from .proto import BodySetQuirks
# =====
@@ -163,9 +165,15 @@ class Device:
return self.__send_request(Header.SET_EDID, unit, BodySetEdid(ch, edid))
return self.__send_request(Header.CLEAR_EDID, unit, BodyClearEdid(ch))
+ def request_set_dummy(self, unit: int, ch: int, on: bool) -> int:
+ return self.__send_request(Header.SET_DUMMY, unit, BodySetDummy(ch, on))
+
def request_set_colors(self, unit: int, ch: int, colors: Colors) -> int:
return self.__send_request(Header.SET_COLORS, unit, BodySetColors(ch, colors))
+ def request_set_quirks(self, unit: int, ignore_hpd: bool) -> int:
+ return self.__send_request(Header.SET_QUIRKS, unit, BodySetQuirks(ignore_hpd))
+
def __send_request(self, op: int, unit: int, body: (Packable | None)) -> int:
assert self.__tty is not None
req = Request(Header(
diff --git a/kvmd/apps/kvmd/switch/proto.py b/kvmd/apps/kvmd/switch/proto.py
index d4f43f84..3c39d238 100644
--- a/kvmd/apps/kvmd/switch/proto.py
+++ b/kvmd/apps/kvmd/switch/proto.py
@@ -60,6 +60,8 @@ class Header(Packable, Unpackable):
SET_EDID = 9
CLEAR_EDID = 10
SET_COLORS = 12
+ SET_QUIRKS = 13
+ SET_DUMMY = 14
__struct = struct.Struct(" bool:
+ return (self.sw_dev or (self.sw >= version))
+
+
@dataclasses.dataclass(frozen=True)
class UnitFlags:
changing_busy: bool
flashing_busy: bool
has_downlink: bool
+ has_hpd: bool
+
+
+@dataclasses.dataclass(frozen=True)
+class UnitQuirks:
+ ignore_hpd: bool
@dataclasses.dataclass(frozen=True)
class UnitState(Unpackable): # pylint: disable=too-many-instance-attributes
- sw_version: int
- hw_version: int
+ version: UnitVersion
flags: UnitFlags
ch: int
beacons: tuple[bool, bool, bool, bool, bool, bool]
@@ -108,10 +125,12 @@ class UnitState(Unpackable): # pylint: disable=too-many-instance-attributes
video_hpd: tuple[bool, bool, bool, bool, bool]
video_edid: tuple[bool, bool, bool, bool]
video_crc: tuple[int, int, int, int]
+ video_dummies: tuple[bool, bool, bool, bool]
usb_5v_sens: tuple[bool, bool, bool, bool]
atx_busy: tuple[bool, bool, bool, bool]
+ quirks: UnitQuirks
- __struct = struct.Struct(" bool:
if edid is None:
@@ -128,15 +147,19 @@ class UnitState(Unpackable): # pylint: disable=too-many-instance-attributes
sw_version, hw_version, flags, ch,
beacons, nc0, nc1, nc2, nc3, nc4, nc5,
video_5v_sens, video_hpd, video_edid, vc0, vc1, vc2, vc3,
- usb_5v_sens, atx_busy,
+ usb_5v_sens, atx_busy, quirks, video_dummies,
) = cls.__struct.unpack_from(data, offset=offset)
return UnitState(
- sw_version,
- hw_version,
+ version=UnitVersion(
+ hw=hw_version,
+ sw=(sw_version & 0x7FFF),
+ sw_dev=bool(sw_version & 0x8000),
+ ),
flags=UnitFlags(
changing_busy=bool(flags & 0x80),
flashing_busy=bool(flags & 0x40),
has_downlink=bool(flags & 0x02),
+ has_hpd=bool(flags & 0x04),
),
ch=ch,
beacons=cls.__make_flags6(beacons),
@@ -145,8 +168,10 @@ class UnitState(Unpackable): # pylint: disable=too-many-instance-attributes
video_hpd=cls.__make_flags5(video_hpd),
video_edid=cls.__make_flags4(video_edid),
video_crc=(vc0, vc1, vc2, vc3),
+ video_dummies=cls.__make_flags4(video_dummies),
usb_5v_sens=cls.__make_flags4(usb_5v_sens),
atx_busy=cls.__make_flags4(atx_busy),
+ quirks=UnitQuirks(ignore_hpd=bool(quirks & 0x01)),
)
@classmethod
@@ -251,6 +276,18 @@ class BodyClearEdid(Packable):
return self.ch.to_bytes()
+@dataclasses.dataclass(frozen=True)
+class BodySetDummy(Packable):
+ ch: int
+ on: bool
+
+ def __post_init__(self) -> None:
+ assert 0 <= self.ch <= 3
+
+ def pack(self) -> bytes:
+ return self.ch.to_bytes() + self.on.to_bytes()
+
+
@dataclasses.dataclass(frozen=True)
class BodySetColors(Packable):
ch: int
@@ -263,6 +300,14 @@ class BodySetColors(Packable):
return self.ch.to_bytes() + self.colors.pack()
+@dataclasses.dataclass(frozen=True)
+class BodySetQuirks(Packable):
+ ignore_hpd: bool
+
+ def pack(self) -> bytes:
+ return self.ignore_hpd.to_bytes()
+
+
# =====
@dataclasses.dataclass(frozen=True)
class Request:
diff --git a/kvmd/apps/kvmd/switch/state.py b/kvmd/apps/kvmd/switch/state.py
index e49d0062..d68d1383 100644
--- a/kvmd/apps/kvmd/switch/state.py
+++ b/kvmd/apps/kvmd/switch/state.py
@@ -27,6 +27,7 @@ import time
from typing import AsyncGenerator
from .types import Edids
+from .types import Dummies
from .types import Color
from .types import Colors
from .types import PortNames
@@ -48,8 +49,8 @@ class _UnitInfo:
# =====
-class StateCache: # pylint: disable=too-many-instance-attributes
- __FW_VERSION = 5
+class StateCache: # pylint: disable=too-many-instance-attributes,too-many-public-methods
+ __FW_VERSION = 8
__FULL = 0xFFFF
__SUMMARY = 0x01
@@ -62,6 +63,7 @@ class StateCache: # pylint: disable=too-many-instance-attributes
def __init__(self) -> None:
self.__edids = Edids()
+ self.__dummies = Dummies({})
self.__colors = Colors()
self.__port_names = PortNames({})
self.__atx_cp_delays = AtxClickPowerDelays({})
@@ -77,6 +79,9 @@ class StateCache: # pylint: disable=too-many-instance-attributes
def get_edids(self) -> Edids:
return self.__edids.copy()
+ def get_dummies(self) -> Dummies:
+ return self.__dummies.copy()
+
def get_colors(self) -> Colors:
return self.__colors
@@ -158,7 +163,17 @@ class StateCache: # pylint: disable=too-many-instance-attributes
},
}
if x_summary:
- state["summary"] = {"active_port": self.__active_port, "synced": self.__synced}
+ state["summary"] = {
+ "active_port": self.__active_port,
+ "active_id": (
+ "" if self.__active_port < 0 else (
+ f"{self.__active_port // 4 + 1}.{self.__active_port % 4 + 1}"
+ if len(self.__units) > 1 else
+ f"{self.__active_port + 1}"
+ )
+ ),
+ "synced": self.__synced,
+ }
if x_edids:
state["edids"] = {
"all": {
@@ -195,7 +210,10 @@ class StateCache: # pylint: disable=too-many-instance-attributes
assert ui.state is not None
assert ui.atx_leds is not None
if x_model:
- state["model"]["units"].append({"firmware": {"version": ui.state.sw_version}})
+ state["model"]["units"].append({"firmware": {
+ "version": ui.state.version.sw,
+ "devbuild": ui.state.version.sw_dev,
+ }})
if x_video:
state["video"]["links"].extend(ui.state.video_5v_sens[:4])
if x_usb:
@@ -216,6 +234,7 @@ class StateCache: # pylint: disable=too-many-instance-attributes
"unit": unit,
"channel": ch,
"name": self.__port_names[port],
+ "id": (f"{unit + 1}.{ch + 1}" if len(self.__units) > 1 else f"{ch + 1}"),
"atx": {
"click_delays": {
"power": self.__atx_cp_delays[port],
@@ -223,6 +242,9 @@ class StateCache: # pylint: disable=too-many-instance-attributes
"reset": self.__atx_cr_delays[port],
},
},
+ "video": {
+ "dummy": self.__dummies[port],
+ },
})
if x_edids:
state["edids"]["used"].append(self.__edids.get_id_for_port(port))
@@ -324,6 +346,12 @@ class StateCache: # pylint: disable=too-many-instance-attributes
if changed:
self.__bump_state(self.__EDIDS)
+ def set_dummies(self, dummies: Dummies) -> None:
+ changed = (not self.__dummies.compare_on_ports(dummies, self.__get_ports()))
+ self.__dummies = dummies.copy()
+ if changed:
+ self.__bump_state(self.__FULL)
+
def set_colors(self, colors: Colors) -> None:
changed = (self.__colors != colors)
self.__colors = colors
diff --git a/kvmd/apps/kvmd/switch/storage.py b/kvmd/apps/kvmd/switch/storage.py
index 6e3a0a76..f8348009 100644
--- a/kvmd/apps/kvmd/switch/storage.py
+++ b/kvmd/apps/kvmd/switch/storage.py
@@ -39,6 +39,7 @@ from .lib import get_logger
from .types import Edid
from .types import Edids
+from .types import Dummies
from .types import Color
from .types import Colors
from .types import PortNames
@@ -52,6 +53,8 @@ class StorageContext:
__F_EDIDS_ALL = "edids_all.json"
__F_EDIDS_PORT = "edids_port.json"
+ __F_DUMMIES = "dummies.json"
+
__F_COLORS = "colors.json"
__F_PORT_NAMES = "port_names.json"
@@ -74,6 +77,9 @@ class StorageContext:
})
await self.__write_json_keyvals(self.__F_EDIDS_PORT, edids.port)
+ async def write_dummies(self, dummies: Dummies) -> None:
+ await self.__write_json_keyvals(self.__F_DUMMIES, dummies.kvs)
+
async def write_colors(self, colors: Colors) -> None:
await self.__write_json_keyvals(self.__F_COLORS, {
role: {
@@ -116,6 +122,10 @@ class StorageContext:
port_edids = await self.__read_json_keyvals_int(self.__F_EDIDS_PORT)
return Edids(all_edids, port_edids)
+ async def read_dummies(self) -> Dummies:
+ kvs = await self.__read_json_keyvals_int(self.__F_DUMMIES)
+ return Dummies({key: bool(value) for (key, value) in kvs.items()})
+
async def read_colors(self) -> Colors:
raw = await self.__read_json_keyvals(self.__F_COLORS)
return Colors(**{ # type: ignore
diff --git a/kvmd/apps/kvmd/switch/types.py b/kvmd/apps/kvmd/switch/types.py
index 32225f06..3d948eb3 100644
--- a/kvmd/apps/kvmd/switch/types.py
+++ b/kvmd/apps/kvmd/switch/types.py
@@ -59,31 +59,37 @@ class EdidInfo:
except ParsedEdidNoBlockError:
pass
+ audio: bool = False
+ try:
+ audio = parsed.get_audio()
+ except ParsedEdidNoBlockError:
+ pass
+
return EdidInfo(
mfc_id=parsed.get_mfc_id(),
product_id=parsed.get_product_id(),
serial=parsed.get_serial(),
monitor_name=monitor_name,
monitor_serial=monitor_serial,
- audio=parsed.get_audio(),
+ audio=audio,
)
@dataclasses.dataclass(frozen=True)
class Edid:
- name: str
- data: bytes
- crc: int = dataclasses.field(default=0)
- valid: bool = dataclasses.field(default=False)
- info: (EdidInfo | None) = dataclasses.field(default=None)
-
- __HEADER = b"\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00"
+ name: str
+ data: bytes
+ crc: int = dataclasses.field(default=0)
+ valid: bool = dataclasses.field(default=False)
+ info: (EdidInfo | None) = dataclasses.field(default=None)
+ _packed: bytes = dataclasses.field(default=b"")
def __post_init__(self) -> None:
assert len(self.name) > 0
- assert len(self.data) == 256
- object.__setattr__(self, "crc", bitbang.make_crc16(self.data))
- object.__setattr__(self, "valid", self.data.startswith(self.__HEADER))
+ assert len(self.data) in [128, 256]
+ object.__setattr__(self, "_packed", (self.data + (b"\x00" * 128))[:256])
+ object.__setattr__(self, "crc", bitbang.make_crc16(self._packed)) # Calculate CRC for filled data
+ object.__setattr__(self, "valid", ParsedEdid.is_header_valid(self.data))
try:
object.__setattr__(self, "info", EdidInfo.from_data(self.data))
except Exception:
@@ -93,7 +99,7 @@ class Edid:
return "".join(f"{item:0{2}X}" for item in self.data)
def pack(self) -> bytes:
- return self.data
+ return self._packed
@classmethod
def from_data(cls, name: str, data: (str | bytes | None)) -> "Edid":
@@ -101,14 +107,14 @@ class Edid:
return Edid(name, b"\x00" * 256)
if isinstance(data, bytes):
- if data.startswith(cls.__HEADER):
+ if ParsedEdid.is_header_valid(cls.data):
return Edid(name, data) # Бинарный едид
data_hex = data.decode() # Текстовый едид, прочитанный как бинарный из файла
else: # isinstance(data, str)
data_hex = str(data) # Текстовый едид
data_hex = re.sub(r"\s", "", data_hex)
- assert len(data_hex) == 512
+ assert len(data_hex) in [256, 512]
data = bytes([
int(data_hex[index:index + 2], 16)
for index in range(0, len(data_hex), 2)
@@ -275,6 +281,19 @@ class _PortsDict(Generic[_T]):
else:
self.kvs[port] = value
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, self.__class__):
+ return False
+ return (self.kvs == other.kvs)
+
+
+class Dummies(_PortsDict[bool]):
+ def __init__(self, kvs: dict[int, bool]) -> None:
+ super().__init__(True, kvs)
+
+ def copy(self) -> "Dummies":
+ return Dummies(self.kvs)
+
class PortNames(_PortsDict[str]):
def __init__(self, kvs: dict[int, str]) -> None:
diff --git a/kvmd/apps/localhid/__init__.py b/kvmd/apps/localhid/__init__.py
new file mode 100644
index 00000000..01b4d8b0
--- /dev/null
+++ b/kvmd/apps/localhid/__init__.py
@@ -0,0 +1,45 @@
+# ========================================================================== #
+# #
+# KVMD - The main PiKVM daemon. #
+# #
+# Copyright (C) 2020 Maxim Devaev #
+# #
+# 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 . #
+# #
+# ========================================================================== #
+
+
+from ...clients.kvmd import KvmdClient
+
+from ... import htclient
+
+from .. import init
+
+from .server import LocalHidServer
+
+
+# =====
+def main(argv: (list[str] | None)=None) -> None:
+ config = init(
+ prog="kvmd-localhid",
+ description=" Local HID to KVMD proxy",
+ check_run=True,
+ argv=argv,
+ )[2].localhid
+
+ user_agent = htclient.make_user_agent("KVMD-LocalHID")
+
+ LocalHidServer(
+ kvmd=KvmdClient(user_agent=user_agent, **config.kvmd._unpack()),
+ ).run()
diff --git a/kvmd/apps/localhid/__main__.py b/kvmd/apps/localhid/__main__.py
new file mode 100644
index 00000000..4827fc49
--- /dev/null
+++ b/kvmd/apps/localhid/__main__.py
@@ -0,0 +1,24 @@
+# ========================================================================== #
+# #
+# KVMD - The main PiKVM daemon. #
+# #
+# Copyright (C) 2018-2024 Maxim Devaev #
+# #
+# 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 . #
+# #
+# ========================================================================== #
+
+
+from . import main
+main()
diff --git a/kvmd/apps/localhid/hid.py b/kvmd/apps/localhid/hid.py
new file mode 100644
index 00000000..fceb019d
--- /dev/null
+++ b/kvmd/apps/localhid/hid.py
@@ -0,0 +1,152 @@
+# ========================================================================== #
+# #
+# KVMD - The main PiKVM daemon. #
+# #
+# Copyright (C) 2018-2024 Maxim Devaev #
+# #
+# 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 . #
+# #
+# ========================================================================== #
+
+
+import asyncio
+
+from typing import Final
+from typing import Generator
+
+import evdev
+from evdev import ecodes
+
+
+# =====
+class Hid: # pylint: disable=too-many-instance-attributes
+ KEY: Final[int] = 0
+ MOUSE_BUTTON: Final[int] = 1
+ MOUSE_REL: Final[int] = 2
+ MOUSE_WHEEL: Final[int] = 3
+
+ def __init__(self, path: str) -> None:
+ self.__device = evdev.InputDevice(path)
+
+ caps = self.__device.capabilities(absinfo=False)
+
+ syns = caps.get(ecodes.EV_SYN, [])
+ self.__has_syn = (ecodes.SYN_REPORT in syns)
+
+ leds = caps.get(ecodes.EV_LED, [])
+ self.__has_caps = (ecodes.LED_CAPSL in leds)
+ self.__has_scroll = (ecodes.LED_SCROLLL in leds)
+ self.__has_num = (ecodes.LED_NUML in leds)
+
+ keys = caps.get(ecodes.EV_KEY, [])
+ self.__has_keyboard = (
+ ecodes.KEY_LEFTCTRL in keys
+ or ecodes.KEY_RIGHTCTRL in keys
+ or ecodes.KEY_LEFTSHIFT in keys
+ or ecodes.KEY_RIGHTSHIFT in keys
+ )
+
+ rels = caps.get(ecodes.EV_REL, [])
+ self.__has_mouse_rel = (
+ ecodes.BTN_LEFT in keys
+ and ecodes.REL_X in rels
+ )
+
+ self.__grabbed = False
+
+ def is_suitable(self) -> bool:
+ return (self.__has_keyboard or self.__has_mouse_rel)
+
+ def set_leds(self, caps: bool, scroll: bool, num: bool) -> None:
+ if self.__grabbed:
+ if self.__has_caps:
+ self.__device.set_led(ecodes.LED_CAPSL, caps)
+ if self.__has_scroll:
+ self.__device.set_led(ecodes.LED_SCROLLL, scroll)
+ if self.__has_num:
+ self.__device.set_led(ecodes.LED_NUML, num)
+
+ def set_grabbed(self, grabbed: bool) -> None:
+ if self.__grabbed != grabbed:
+ getattr(self.__device, ("grab" if grabbed else "ungrab"))()
+ self.__grabbed = grabbed
+
+ def close(self) -> None:
+ try:
+ self.__device.close()
+ except Exception:
+ pass
+
+ async def poll_to_queue(self, queue: asyncio.Queue[tuple[int, tuple]]) -> None:
+ def put(event: int, args: tuple) -> None:
+ queue.put_nowait((event, args))
+
+ move_x = move_y = 0
+ wheel_x = wheel_y = 0
+ async for event in self.__device.async_read_loop():
+ if not self.__grabbed:
+ # Клавиши перехватываются всегда для обработки хоткеев,
+ # всё остальное пропускается для экономии ресурсов.
+ if event.type == ecodes.EV_KEY and event.value != 2 and (event.code in ecodes.KEY):
+ put(self.KEY, (event.code, bool(event.value)))
+ continue
+
+ if event.type == ecodes.EV_REL:
+ match event.code:
+ case ecodes.REL_X:
+ move_x += event.value
+ case ecodes.REL_Y:
+ move_y += event.value
+ case ecodes.REL_HWHEEL:
+ wheel_x += event.value
+ case ecodes.REL_WHEEL:
+ wheel_y += event.value
+
+ if not self.__has_syn or event.type == ecodes.SYN_REPORT:
+ if move_x or move_y:
+ for xy in self.__splitted_deltas(move_x, move_y):
+ put(self.MOUSE_REL, xy)
+ move_x = move_y = 0
+ if wheel_x or wheel_y:
+ for xy in self.__splitted_deltas(wheel_x, wheel_y):
+ put(self.MOUSE_WHEEL, xy)
+ wheel_x = wheel_y = 0
+
+ elif event.type == ecodes.EV_KEY and event.value != 2:
+ if event.code in ecodes.KEY:
+ put(self.KEY, (event.code, bool(event.value)))
+ elif event.code in ecodes.BTN:
+ put(self.MOUSE_BUTTON, (event.code, bool(event.value)))
+
+ def __splitted_deltas(self, delta_x: int, delta_y: int) -> Generator[tuple[int, int], None, None]:
+ sign_x = (-1 if delta_x < 0 else 1)
+ sign_y = (-1 if delta_y < 0 else 1)
+ delta_x = abs(delta_x)
+ delta_y = abs(delta_y)
+ while delta_x > 0 or delta_y > 0:
+ dx = sign_x * max(min(delta_x, 127), 0)
+ dy = sign_y * max(min(delta_y, 127), 0)
+ yield (dx, dy)
+ delta_x -= 127
+ delta_y -= 127
+
+ def __str__(self) -> str:
+ info: list[str] = []
+ if self.__has_syn:
+ info.append("syn")
+ if self.__has_keyboard:
+ info.append("keyboard")
+ if self.__has_mouse_rel:
+ info.append("mouse_rel")
+ return f"Hid({self.__device.path!r}, {self.__device.name!r}, {self.__device.phys!r}, {', '.join(info)})"
diff --git a/kvmd/apps/localhid/multi.py b/kvmd/apps/localhid/multi.py
new file mode 100644
index 00000000..f131bc26
--- /dev/null
+++ b/kvmd/apps/localhid/multi.py
@@ -0,0 +1,178 @@
+# ========================================================================== #
+# #
+# KVMD - The main PiKVM daemon. #
+# #
+# Copyright (C) 2020 Maxim Devaev #
+# #
+# 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 . #
+# #
+# ========================================================================== #
+
+
+import asyncio
+import dataclasses
+import errno
+
+from typing import AsyncGenerator
+
+import pyudev
+
+from ...logging import get_logger
+
+from ... import aiotools
+
+from .hid import Hid
+
+
+# =====
+def _udev_check(device: pyudev.Device) -> str:
+ props = device.properties
+ if props.get("ID_INPUT") == "1":
+ path = props.get("DEVNAME")
+ if isinstance(path, str) and path.startswith("/dev/input/event"):
+ return path
+ return ""
+
+
+async def _follow_udev_hids() -> AsyncGenerator[tuple[bool, str], None]:
+ ctx = pyudev.Context()
+
+ monitor = pyudev.Monitor.from_netlink(pyudev.Context())
+ monitor.filter_by(subsystem="input")
+ monitor.start()
+ fd = monitor.fileno()
+
+ read_event = asyncio.Event()
+ loop = asyncio.get_event_loop()
+ loop.add_reader(fd, read_event.set)
+
+ try:
+ for device in ctx.list_devices(subsystem="input"):
+ path = _udev_check(device)
+ if path:
+ yield (True, path)
+
+ while True:
+ await read_event.wait()
+ while True:
+ device = monitor.poll(0)
+ if device is None:
+ read_event.clear()
+ break
+ path = _udev_check(device)
+ if path:
+ if device.action == "add":
+ yield (True, path)
+ elif device.action == "remove":
+ yield (False, path)
+ finally:
+ loop.remove_reader(fd)
+
+
+@dataclasses.dataclass
+class _Worker:
+ task: asyncio.Task
+ hid: (Hid | None)
+
+
+class MultiHid:
+ def __init__(self, queue: asyncio.Queue[tuple[int, tuple]]) -> None:
+ self.__queue = queue
+ self.__workers: dict[str, _Worker] = {}
+ self.__grabbed = True
+ self.__leds = (False, False, False)
+
+ async def run(self) -> None:
+ logger = get_logger(0)
+ logger.info("Starting UDEV loop ...")
+ try:
+ async for (added, path) in _follow_udev_hids():
+ if added:
+ await self.__add_worker(path)
+ else:
+ await self.__remove_worker(path)
+ finally:
+ logger.info("Cleanup ...")
+ await aiotools.shield_fg(self.__cleanup())
+
+ async def __cleanup(self) -> None:
+ for path in list(self.__workers):
+ await self.__remove_worker(path)
+
+ async def __add_worker(self, path: str) -> None:
+ if path in self.__workers:
+ await self.__remove_worker(path)
+ self.__workers[path] = _Worker(asyncio.create_task(self.__worker_task_loop(path)), None)
+
+ async def __remove_worker(self, path: str) -> None:
+ if path not in self.__workers:
+ return
+ try:
+ worker = self.__workers[path]
+ worker.task.cancel()
+ await asyncio.gather(worker.task, return_exceptions=True)
+ except Exception:
+ pass
+ finally:
+ self.__workers.pop(path, None)
+
+ async def __worker_task_loop(self, path: str) -> None:
+ logger = get_logger(0)
+ while True:
+ hid: (Hid | None) = None
+ try:
+ hid = Hid(path)
+ if not hid.is_suitable():
+ break
+ logger.info("Opened: %s", hid)
+ if self.__grabbed:
+ hid.set_grabbed(True)
+ hid.set_leds(*self.__leds)
+ self.__workers[path].hid = hid
+ await hid.poll_to_queue(self.__queue)
+ except Exception as ex:
+ if isinstance(ex, OSError) and ex.errno == errno.ENODEV: # pylint: disable=no-member
+ logger.info("Closed: %s", hid)
+ break
+ logger.exception("Unhandled exception while polling %s", hid)
+ await asyncio.sleep(5)
+ finally:
+ self.__workers[path].hid = None
+ if hid:
+ hid.close()
+
+ def is_grabbed(self) -> bool:
+ return self.__grabbed
+
+ async def set_grabbed(self, grabbed: bool) -> None:
+ await aiotools.run_async(self.__inner_set_grabbed, grabbed)
+
+ def __inner_set_grabbed(self, grabbed: bool) -> None:
+ if self.__grabbed != grabbed:
+ get_logger(0).info("Grabbing ..." if grabbed else "Ungrabbing ...")
+ self.__grabbed = grabbed
+ for worker in self.__workers.values():
+ if worker.hid:
+ worker.hid.set_grabbed(grabbed)
+ self.__inner_set_leds(*self.__leds)
+
+ async def set_leds(self, caps: bool, scroll: bool, num: bool) -> None:
+ await aiotools.run_async(self.__inner_set_leds, caps, scroll, num)
+
+ def __inner_set_leds(self, caps: bool, scroll: bool, num: bool) -> None:
+ self.__leds = (caps, scroll, num)
+ if self.__grabbed:
+ for worker in self.__workers.values():
+ if worker.hid:
+ worker.hid.set_leds(*self.__leds)
diff --git a/kvmd/apps/localhid/server.py b/kvmd/apps/localhid/server.py
new file mode 100644
index 00000000..da7196c4
--- /dev/null
+++ b/kvmd/apps/localhid/server.py
@@ -0,0 +1,192 @@
+# ========================================================================== #
+# #
+# KVMD - The main PiKVM daemon. #
+# #
+# Copyright (C) 2020 Maxim Devaev #
+# #
+# 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 . #
+# #
+# ========================================================================== #
+
+
+import asyncio
+import errno
+
+from typing import Callable
+from typing import Coroutine
+
+import aiohttp
+import async_lru
+
+from evdev import ecodes
+
+from ...logging import get_logger
+
+from ... import tools
+from ... import aiotools
+
+from ...keyboard.magic import MagicHandler
+
+from ...clients.kvmd import KvmdClient
+from ...clients.kvmd import KvmdClientSession
+from ...clients.kvmd import KvmdClientWs
+
+from .hid import Hid
+from .multi import MultiHid
+
+
+# =====
+class LocalHidServer: # pylint: disable=too-many-instance-attributes
+ def __init__(self, kvmd: KvmdClient) -> None:
+ self.__kvmd = kvmd
+
+ self.__kvmd_session: (KvmdClientSession | None) = None
+ self.__kvmd_ws: (KvmdClientWs | None) = None
+
+ self.__queue: asyncio.Queue[tuple[int, tuple]] = asyncio.Queue()
+ self.__hid = MultiHid(self.__queue)
+
+ self.__info_switch_units = 0
+ self.__info_switch_active = ""
+ self.__info_mouse_absolute = True
+ self.__info_mouse_outputs: list[str] = []
+
+ self.__magic = MagicHandler(
+ proxy_handler=self.__on_magic_key_proxy,
+ key_handlers={
+ ecodes.KEY_H: self.__on_magic_grab,
+ ecodes.KEY_K: self.__on_magic_ungrab,
+ ecodes.KEY_UP: self.__on_magic_switch_prev,
+ ecodes.KEY_LEFT: self.__on_magic_switch_prev,
+ ecodes.KEY_DOWN: self.__on_magic_switch_next,
+ ecodes.KEY_RIGHT: self.__on_magic_switch_next,
+ },
+ numeric_handler=self.__on_magic_switch_port,
+ )
+
+ def run(self) -> None:
+ try:
+ aiotools.run(self.__inner_run())
+ finally:
+ get_logger(0).info("Bye-bye")
+
+ async def __inner_run(self) -> None:
+ await aiotools.spawn_and_follow(
+ self.__create_loop(self.__hid.run),
+ self.__create_loop(self.__queue_worker),
+ self.__create_loop(self.__api_worker),
+ )
+
+ async def __create_loop(self, func: Callable[[], Coroutine]) -> None:
+ while True:
+ try:
+ await func()
+ except Exception as ex:
+ if isinstance(ex, OSError) and ex.errno == errno.ENODEV: # pylint: disable=no-member
+ pass # Device disconnected
+ elif isinstance(ex, aiohttp.ClientError):
+ get_logger(0).error("KVMD client error: %s", tools.efmt(ex))
+ else:
+ get_logger(0).exception("Unhandled exception in the loop: %s", func)
+ await asyncio.sleep(5)
+
+ async def __queue_worker(self) -> None:
+ while True:
+ (event, args) = await self.__queue.get()
+ if event == Hid.KEY:
+ await self.__magic.handle_key(*args)
+ continue
+ elif self.__hid.is_grabbed() and self.__kvmd_session and self.__kvmd_ws:
+ match event:
+ case Hid.MOUSE_BUTTON:
+ await self.__kvmd_ws.send_mouse_button_event(*args)
+ case Hid.MOUSE_REL:
+ await self.__ensure_mouse_relative()
+ await self.__kvmd_ws.send_mouse_relative_event(*args)
+ case Hid.MOUSE_WHEEL:
+ await self.__kvmd_ws.send_mouse_wheel_event(*args)
+
+ async def __api_worker(self) -> None:
+ logger = get_logger(0)
+ async with self.__kvmd.make_session() as session:
+ async with session.ws(stream=False) as ws:
+ logger.info("KVMD session opened")
+ self.__kvmd_session = session
+ self.__kvmd_ws = ws
+ try:
+ async for (event_type, event) in ws.communicate():
+ if event_type == "hid":
+ if "leds" in event.get("keyboard", {}):
+ await self.__hid.set_leds(**event["keyboard"]["leds"])
+ if "absolute" in event.get("mouse", {}):
+ self.__info_mouse_outputs = event["mouse"]["outputs"]["available"]
+ self.__info_mouse_absolute = event["mouse"]["absolute"]
+ elif event_type == "switch":
+ if "model" in event:
+ self.__info_switch_units = len(event["model"]["units"])
+ if "summary" in event:
+ self.__info_switch_active = event["summary"]["active_id"]
+ finally:
+ logger.info("KVMD session closed")
+ self.__kvmd_session = None
+ self.__kvmd_ws = None
+
+ # =====
+
+ async def __ensure_mouse_relative(self) -> None:
+ if self.__info_mouse_absolute:
+ # Avoid unnecessary LRU checks, just to speed up a bit
+ await self.__inner_ensure_mouse_relative()
+
+ @async_lru.alru_cache(maxsize=1, ttl=1)
+ async def __inner_ensure_mouse_relative(self) -> None:
+ if self.__kvmd_session and self.__info_mouse_absolute:
+ for output in ["usb_rel", "ps2"]:
+ if output in self.__info_mouse_outputs:
+ await self.__kvmd_session.hid.set_params(mouse_output=output)
+
+ async def __on_magic_key_proxy(self, key: int, state: bool) -> None:
+ if self.__hid.is_grabbed() and self.__kvmd_ws:
+ await self.__kvmd_ws.send_key_event(key, state)
+
+ async def __on_magic_grab(self) -> None:
+ await self.__hid.set_grabbed(True)
+
+ async def __on_magic_ungrab(self) -> None:
+ await self.__hid.set_grabbed(False)
+
+ async def __on_magic_switch_prev(self) -> None:
+ if self.__kvmd_session and self.__info_switch_units > 0:
+ get_logger(0).info("Switching port to the previous one ...")
+ await self.__kvmd_session.switch.set_active_prev()
+
+ async def __on_magic_switch_next(self) -> None:
+ if self.__kvmd_session and self.__info_switch_units > 0:
+ get_logger(0).info("Switching port to the next one ...")
+ await self.__kvmd_session.switch.set_active_next()
+
+ async def __on_magic_switch_port(self, codes: list[int]) -> bool:
+ assert len(codes) > 0
+ if self.__info_switch_units <= 0:
+ return True
+ elif 1 <= self.__info_switch_units <= 2:
+ port = float(codes[0])
+ else: # self.__info_switch_units > 2:
+ if len(codes) == 1:
+ return False # Wait for the second key
+ port = (codes[0] + 1) + (codes[1] + 1) / 10
+ if self.__kvmd_session:
+ get_logger(0).info("Switching port to %s ...", port)
+ await self.__kvmd_session.switch.set_active(port)
+ return True
diff --git a/kvmd/apps/media/server.py b/kvmd/apps/media/server.py
index 1f96b353..347b0f3e 100644
--- a/kvmd/apps/media/server.py
+++ b/kvmd/apps/media/server.py
@@ -52,6 +52,9 @@ class _Source:
clients: dict[WsSession, "_Client"] = dataclasses.field(default_factory=dict)
key_required: bool = dataclasses.field(default=False)
+ def is_diff(self) -> bool:
+ return StreamerFormats.is_diff(self.streamer.get_format())
+
@dataclasses.dataclass
class _Client:
@@ -98,6 +101,14 @@ class MediaServer(HttpServer):
async def __ws_bin_ping_handler(self, ws: WsSession, _: bytes) -> None:
await ws.send_bin(255, b"") # Ping-pong
+ @exposed_ws(1)
+ async def __ws_bin_key_handler(self, ws: WsSession, _: bytes) -> None:
+ for src in self.__srcs:
+ if ws in src.clients:
+ if src.is_diff():
+ src.key_required = True
+ break
+
@exposed_ws("start")
async def __ws_start_handler(self, ws: WsSession, event: dict) -> None:
try:
@@ -145,7 +156,7 @@ class MediaServer(HttpServer):
# =====
async def __sender(self, client: _Client) -> None:
- need_key = StreamerFormats.is_diff(client.src.streamer.get_format())
+ need_key = client.src.is_diff()
if need_key:
client.src.key_required = True
has_key = False
diff --git a/kvmd/apps/ngxmkconf/__init__.py b/kvmd/apps/ngxmkconf/__init__.py
index 67c25bcb..7cb188db 100644
--- a/kvmd/apps/ngxmkconf/__init__.py
+++ b/kvmd/apps/ngxmkconf/__init__.py
@@ -50,8 +50,12 @@ def main(argv: (list[str] | None)=None) -> None:
template = in_file.read()
rendered = mako.template.Template(template).render(
+ http_ipv4=config.nginx.http.ipv4,
+ http_ipv6=config.nginx.http.ipv6,
http_port=config.nginx.http.port,
https_enabled=config.nginx.https.enabled,
+ https_ipv4=config.nginx.https.ipv4,
+ https_ipv6=config.nginx.https.ipv6,
https_port=config.nginx.https.port,
ipv6_enabled=network.is_ipv6_enabled(),
)
diff --git a/kvmd/apps/oled/__init__.py b/kvmd/apps/oled/__init__.py
index db96ca2c..5e23d3b7 100644
--- a/kvmd/apps/oled/__init__.py
+++ b/kvmd/apps/oled/__init__.py
@@ -78,6 +78,7 @@ def main() -> None: # pylint: disable=too-many-locals,too-many-branches,too-man
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("--fill", action="store_true", help="Fill the display with 0xFF")
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")
@@ -121,6 +122,9 @@ def main() -> None: # pylint: disable=too-many-locals,too-many-branches,too-man
text = ""
time.sleep(options.interval)
+ elif options.fill:
+ screen.draw_white()
+
else:
stop_reason: (str | None) = None
diff --git a/kvmd/apps/oled/screen.py b/kvmd/apps/oled/screen.py
index 0e3301bb..5f0e0613 100644
--- a/kvmd/apps/oled/screen.py
+++ b/kvmd/apps/oled/screen.py
@@ -52,3 +52,7 @@ class Screen:
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")
+
+ def draw_white(self) -> None:
+ with luma_canvas(self.__device) as draw:
+ draw.rectangle((0, 0, self.__device.width, self.__device.height), fill="white")
diff --git a/kvmd/apps/otg/__init__.py b/kvmd/apps/otg/__init__.py
index 0bec4eb6..05d74c9b 100644
--- a/kvmd/apps/otg/__init__.py
+++ b/kvmd/apps/otg/__init__.py
@@ -201,8 +201,8 @@ class _GadgetConfig:
rw: bool,
removable: bool,
fua: bool,
- inquiry_string_cdrom: str,
- inquiry_string_flash: str,
+ _inquiry_string_cdrom: str,
+ _inquiry_string_flash: str,
) -> None:
# Endpoints number depends on transport_type but we can consider that this is 2
@@ -216,8 +216,8 @@ class _GadgetConfig:
_write(join(func_path, "lun.0/ro"), int(not rw))
_write(join(func_path, "lun.0/removable"), int(removable))
_write(join(func_path, "lun.0/nofua"), int(not fua))
- #_write(join(func_path, "lun.0/inquiry_string_cdrom"), inquiry_string_cdrom)
- #_write(join(func_path, "lun.0/inquiry_string"), inquiry_string_flash)
+ # _write(join(func_path, "lun.0/inquiry_string_cdrom"), inquiry_string_cdrom)
+ # _write(join(func_path, "lun.0/inquiry_string"), inquiry_string_flash)
if user != "root":
_chown(join(func_path, "lun.0/cdrom"), user)
_chown(join(func_path, "lun.0/ro"), user)
@@ -291,8 +291,9 @@ def _cmd_start(config: Section) -> None: # pylint: disable=too-many-statements,
profile_path = join(gadget_path, usb.G_PROFILE)
_mkdir(profile_path)
- _mkdir(join(profile_path, "strings/0x409"))
- _write(join(profile_path, "strings/0x409/configuration"), f"Config 1: {config.otg.config}")
+ if config.otg.config:
+ _mkdir(join(profile_path, "strings/0x409"))
+ _write(join(profile_path, "strings/0x409/configuration"), config.otg.config)
_write(join(profile_path, "MaxPower"), config.otg.max_power)
if config.otg.remote_wakeup:
# XXX: Should we use MaxPower=100 with Remote Wakeup?
@@ -316,8 +317,8 @@ def _cmd_start(config: Section) -> None: # pylint: disable=too-many-statements,
gc.add_msd(
start=cod.msd.start,
user=config.otg.user,
- inquiry_string_cdrom=usb.make_inquiry_string(**cod.msd.default.inquiry_string.cdrom._unpack()),
- inquiry_string_flash=usb.make_inquiry_string(**cod.msd.default.inquiry_string.flash._unpack()),
+ _inquiry_string_cdrom=usb.make_inquiry_string(**cod.msd.default.inquiry_string.cdrom._unpack()),
+ _inquiry_string_flash=usb.make_inquiry_string(**cod.msd.default.inquiry_string.flash._unpack()),
**cod.msd.default._unpack(ignore="inquiry_string"),
)
if cod.drives.enabled:
@@ -326,8 +327,8 @@ def _cmd_start(config: Section) -> None: # pylint: disable=too-many-statements,
gc.add_msd(
start=cod.drives.start,
user="root",
- inquiry_string_cdrom=usb.make_inquiry_string(**cod.drives.default.inquiry_string.cdrom._unpack()),
- inquiry_string_flash=usb.make_inquiry_string(**cod.drives.default.inquiry_string.flash._unpack()),
+ _inquiry_string_cdrom=usb.make_inquiry_string(**cod.drives.default.inquiry_string.cdrom._unpack()),
+ _inquiry_string_flash=usb.make_inquiry_string(**cod.drives.default.inquiry_string.flash._unpack()),
**cod.drives.default._unpack(ignore="inquiry_string"),
)
diff --git a/kvmd/apps/otgmsd/__init__.py b/kvmd/apps/otgmsd/__init__.py
index d1a6cb13..1034f686 100644
--- a/kvmd/apps/otgmsd/__init__.py
+++ b/kvmd/apps/otgmsd/__init__.py
@@ -23,6 +23,7 @@
import errno
import argparse
+import os
from ...validators.basic import valid_bool
from ...validators.basic import valid_int_f0
@@ -37,6 +38,7 @@ from .. import init
def _has_param(gadget: str, instance: int, param: str) -> bool:
return os.access(_get_param_path(gadget, instance, param), os.F_OK)
+
def _get_param_path(gadget: str, instance: int, param: str) -> str:
return usb.get_gadget_path(gadget, usb.G_FUNCTIONS, f"mass_storage.usb{instance}/lun.0", param)
diff --git a/kvmd/apps/otgnet/__init__.py b/kvmd/apps/otgnet/__init__.py
index 35c0bc45..81e797a6 100644
--- a/kvmd/apps/otgnet/__init__.py
+++ b/kvmd/apps/otgnet/__init__.py
@@ -45,6 +45,7 @@ from .netctl import IptablesAllowIcmpCtl
from .netctl import IptablesAllowPortCtl
from .netctl import IptablesForwardOut
from .netctl import IptablesForwardIn
+from .netctl import SysctlIpv4ForwardCtl
from .netctl import CustomCtl
@@ -63,14 +64,16 @@ class _Netcfg: # pylint: disable=too-many-instance-attributes
class _Service: # pylint: disable=too-many-instance-attributes
def __init__(self, config: Section) -> None:
+ self.__ip_cmd: list[str] = config.otgnet.commands.ip_cmd
+ self.__iptables_cmd: list[str] = config.otgnet.commands.iptables_cmd
+ self.__sysctl_cmd: list[str] = config.otgnet.commands.sysctl_cmd
+
self.__iface_net: str = config.otgnet.iface.net
- self.__ip_cmd: list[str] = config.otgnet.iface.ip_cmd
self.__allow_icmp: bool = config.otgnet.firewall.allow_icmp
self.__allow_tcp: list[int] = sorted(set(config.otgnet.firewall.allow_tcp))
self.__allow_udp: list[int] = sorted(set(config.otgnet.firewall.allow_udp))
self.__forward_iface: str = config.otgnet.firewall.forward_iface
- self.__iptables_cmd: list[str] = config.otgnet.firewall.iptables_cmd
def build_cmd(key: str) -> list[str]:
return tools.build_cmd(
@@ -115,6 +118,7 @@ class _Service: # pylint: disable=too-many-instance-attributes
*([IptablesForwardIn(self.__iptables_cmd, netcfg.iface)] if self.__forward_iface else []),
IptablesDropAllCtl(self.__iptables_cmd, netcfg.iface),
IfaceAddIpCtl(self.__ip_cmd, netcfg.iface, f"{netcfg.iface_ip}/{netcfg.net_prefix}"),
+ *([SysctlIpv4ForwardCtl(self.__sysctl_cmd)] if self.__forward_iface else []),
CustomCtl(self.__post_start_cmd, self.__pre_stop_cmd, placeholders),
]
if direct:
@@ -130,6 +134,8 @@ class _Service: # pylint: disable=too-many-instance-attributes
async def __run_ctl(self, ctl: BaseCtl, direct: bool) -> bool:
logger = get_logger()
cmd = ctl.get_command(direct)
+ if not cmd:
+ return True
logger.info("CMD: %s", tools.cmdfmt(cmd))
try:
return (not (await aioproc.log_process(cmd, logger)).returncode)
diff --git a/kvmd/apps/otgnet/netctl.py b/kvmd/apps/otgnet/netctl.py
index 13de1f00..127dc5ee 100644
--- a/kvmd/apps/otgnet/netctl.py
+++ b/kvmd/apps/otgnet/netctl.py
@@ -121,6 +121,16 @@ class IptablesForwardIn(BaseCtl):
]
+class SysctlIpv4ForwardCtl(BaseCtl):
+ def __init__(self, base_cmd: list[str]) -> None:
+ self.__base_cmd = base_cmd
+
+ def get_command(self, direct: bool) -> list[str]:
+ if direct:
+ return [*self.__base_cmd, "net.ipv4.ip_forward=1"]
+ return [] # Don't revert the command because some services can require it too
+
+
class CustomCtl(BaseCtl):
def __init__(
self,
diff --git a/kvmd/apps/pstrun/__init__.py b/kvmd/apps/pstrun/__init__.py
index d55835fd..a0e5b702 100644
--- a/kvmd/apps/pstrun/__init__.py
+++ b/kvmd/apps/pstrun/__init__.py
@@ -66,22 +66,22 @@ async def _run_process(cmd: list[str], data_path: str) -> asyncio.subprocess.Pro
async def _run_cmd_ws(cmd: list[str], ws: aiohttp.ClientWebSocketResponse) -> int: # pylint: disable=too-many-branches
logger = get_logger(0)
- receive_task: (asyncio.Task | None) = None
+ recv_task: (asyncio.Task | None) = None
proc_task: (asyncio.Task | None) = None
proc: (asyncio.subprocess.Process | None) = None # pylint: disable=no-member
try: # pylint: disable=too-many-nested-blocks
while True:
- if receive_task is None:
- receive_task = asyncio.create_task(ws.receive())
+ if recv_task is None:
+ recv_task = asyncio.create_task(ws.receive())
if proc_task is None and proc is not None:
proc_task = asyncio.create_task(proc.wait())
- tasks = list(filter(None, [receive_task, proc_task]))
+ tasks = list(filter(None, [recv_task, proc_task]))
done = (await aiotools.wait_first(*tasks))[0]
- if receive_task in done:
- msg = receive_task.result()
+ if recv_task in done:
+ msg = recv_task.result()
if msg.type == aiohttp.WSMsgType.TEXT:
(event_type, event) = htserver.parse_ws_event(msg.data)
if event_type == "storage":
@@ -98,15 +98,15 @@ async def _run_cmd_ws(cmd: list[str], ws: aiohttp.ClientWebSocketResponse) -> in
else:
logger.error("Unknown PST message type: %r", msg)
break
- receive_task = None
+ recv_task = None
if proc_task in done:
break
except Exception:
logger.exception("Unhandled exception")
- if receive_task is not None:
- receive_task.cancel()
+ if recv_task is not None:
+ recv_task.cancel()
if proc_task is not None:
proc_task.cancel()
if proc is not None:
diff --git a/kvmd/apps/vnc/__init__.py b/kvmd/apps/vnc/__init__.py
index 1e2c486a..c1371cfe 100644
--- a/kvmd/apps/vnc/__init__.py
+++ b/kvmd/apps/vnc/__init__.py
@@ -30,7 +30,6 @@ from ... import htclient
from .. import init
-from .vncauth import VncAuthManager
from .server import VncServer
@@ -71,12 +70,12 @@ 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,
+ scroll_rate=config.scroll_rate,
kvmd=KvmdClient(user_agent=user_agent, **config.kvmd._unpack()),
streamers=streamers,
- vnc_auth_manager=VncAuthManager(**config.auth.vncauth._unpack()),
**config.server.keepalive._unpack(),
+ **config.auth.vncauth._unpack(),
**config.auth.vencrypt._unpack(),
).run()
diff --git a/kvmd/apps/vnc/rfb/__init__.py b/kvmd/apps/vnc/rfb/__init__.py
index fc6e435c..2b8ed499 100644
--- a/kvmd/apps/vnc/rfb/__init__.py
+++ b/kvmd/apps/vnc/rfb/__init__.py
@@ -22,17 +22,22 @@
import asyncio
import ssl
-import time
from typing import Callable
from typing import Coroutine
from typing import AsyncGenerator
+from evdev import ecodes
+
from ....logging import get_logger
from .... import tools
from .... import aiotools
+from ....keyboard.keysym import SymmapModifiers
+from ....keyboard.mappings import EvdevModifiers
+from ....keyboard.mappings import X11Modifiers
+from ....keyboard.mappings import AT1_TO_EVDEV
from ....mouse import MouseRange
from .errors import RfbError
@@ -47,6 +52,11 @@ from .crypto import rfb_encrypt_challenge
from .stream import RfbClientStream
+# =====
+class _SecurityError(Exception):
+ pass
+
+
# =====
class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attributes
# https://github.com/rfbproto/rfbproto/blob/master/rfbproto.rst
@@ -65,8 +75,10 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
width: int,
height: int,
name: str,
- allow_cut_after: float,
- vnc_passwds: list[str],
+ symmap: dict[int, dict[int, int]],
+ scroll_rate: int,
+
+ vncpasses: set[str],
vencrypt: bool,
none_auth_only: bool,
) -> None:
@@ -81,8 +93,10 @@ 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.__scroll_rate = scroll_rate
+ self.__symmap = symmap
+
+ self.__vncpasses = vncpasses
self.__vencrypt = vencrypt
self.__none_auth_only = none_auth_only
@@ -93,10 +107,16 @@ 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.__authorized = False
self.__lock = asyncio.Lock()
+ # Эти состояния шарить не обязательно - бекенд исключает дублирующиеся события.
+ # Все это нужно только чтобы не посылать лишние события в сокет KVMD
+ self.__modifiers = 0
+ self.__mouse_buttons: dict[int, bool] = {}
+ self.__mouse_move = (-1, -1, -1, -1) # (width, height, X, Y)
+
# =====
async def _run(self, **coros: Coroutine) -> None:
@@ -135,6 +155,8 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
async def __main_task_loop(self) -> None:
await self.__handshake_version()
await self.__handshake_security()
+ if not self.__authorized:
+ raise _SecurityError()
await self.__handshake_init()
await self.__main_loop()
@@ -143,21 +165,24 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
async def _authorize_userpass(self, user: str, passwd: str) -> bool:
raise NotImplementedError
- async def _on_authorized_vnc_passwd(self, passwd: str) -> str:
+ async def _on_authorized_vncpass(self) -> None:
raise NotImplementedError
- async def _on_authorized_none(self) -> bool:
+ async def _authorize_none(self) -> bool:
raise NotImplementedError
# =====
- async def _on_key_event(self, code: int, state: bool) -> None:
+ async def _on_key_event(self, key: int, state: bool) -> None:
raise NotImplementedError
- async def _on_ext_key_event(self, code: int, state: bool) -> None:
+ async def _on_mouse_button_event(self, button: int, state: bool) -> None:
raise NotImplementedError
- async def _on_pointer_event(self, buttons: dict[str, bool], wheel: dict[str, int], move: dict[str, int]) -> None:
+ async def _on_mouse_move_event(self, to_x: int, to_y: int) -> None:
+ raise NotImplementedError
+
+ async def _on_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None:
raise NotImplementedError
async def _on_cut_event(self, text: str) -> None:
@@ -235,18 +260,18 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
await self._write_struct("handshake server version", "", b"RFB 003.008\n")
- response = await self._read_text("handshake client version", 12)
+ resp = await self._read_text("handshake client version", 12)
if (
- not response.startswith("RFB 003.00")
- or not response.endswith("\n")
- or response[-2] not in ["3", "5", "7", "8"]
+ not resp.startswith("RFB 003.00")
+ or not resp.endswith("\n")
+ or resp[-2] not in ["3", "5", "7", "8"]
):
- raise RfbError(f"Invalid version response: {response!r}")
+ raise RfbError(f"Invalid version response: {resp!r}")
try:
- version = int(response[-2])
+ version = int(resp[-2])
except ValueError:
- raise RfbError(f"Invalid version response: {response!r}")
+ raise RfbError(f"Invalid version response: {resp!r}")
self.__rfb_version = (3 if version == 5 else version)
get_logger(0).info("%s [main]: Using RFB version 3.%d", self._remote, self.__rfb_version)
@@ -258,7 +283,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
sec_types[19] = ("VeNCrypt", self.__handshake_security_vencrypt)
if self.__none_auth_only:
sec_types[1] = ("None", self.__handshake_security_none)
- elif self.__vnc_passwds:
+ elif self.__vncpasses:
sec_types[2] = ("VNCAuth", self.__handshake_security_vnc_auth)
if not sec_types:
@@ -304,7 +329,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
if self.__x509_cert_path:
auth_types[262] = ("VeNCrypt/X509Plain", 2, self.__handshake_security_vencrypt_userpass)
auth_types[259] = ("VeNCrypt/TLSPlain", 1, self.__handshake_security_vencrypt_userpass)
- if self.__vnc_passwds:
+ if self.__vncpasses:
# Некоторые клиенты не умеют работать с нешифрованными соединениями внутри VeNCrypt:
# - https://github.com/LibVNC/libvncserver/issues/458
# - https://bugzilla.redhat.com/show_bug.cgi?id=692048
@@ -354,7 +379,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
)
async def __handshake_security_none(self) -> None:
- allow = await self._on_authorized_none()
+ allow = await self._authorize_none()
await self.__handshake_security_send_result(
allow=allow,
allow_msg="NoneAuth access granted",
@@ -366,20 +391,19 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
challenge = rfb_make_challenge()
await self._write_struct("VNCAuth challenge request", "", challenge)
- user = ""
+ allow = False
response = (await self._read_struct("VNCAuth challenge response", "16s"))[0]
- for passwd in self.__vnc_passwds:
+ for passwd in self.__vncpasses:
passwd_bytes = passwd.encode("utf-8", errors="ignore")
if rfb_encrypt_challenge(challenge, passwd_bytes) == response:
- user = await self._on_authorized_vnc_passwd(passwd)
- if user:
- assert user == user.strip()
+ await self._on_authorized_vncpass()
+ allow = True
break
await self.__handshake_security_send_result(
- allow=bool(user),
- allow_msg=f"VNCAuth access granted for user {user!r}",
- deny_msg="VNCAuth access denied (user not found)",
+ allow=allow,
+ allow_msg="VNCAuth access granted",
+ deny_msg="VNCAuth access denied (passwd not found)",
deny_reason="Invalid password",
)
@@ -387,6 +411,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
if allow:
get_logger(0).info("%s [main]: %s", self._remote, allow_msg)
await self._write_struct("access OK", "L", 0)
+ self.__authorized = True
else:
await self._write_struct("access denial flag", "L", 1, drain=(self.__rfb_version < 8))
if self.__rfb_version >= 8:
@@ -396,6 +421,9 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
# =====
async def __handshake_init(self) -> None:
+ if not self.__authorized:
+ raise _SecurityError()
+
await self._read_number("initial shared flag", "B") # Shared flag, ignored
await self._write_struct("initial FB size", "HH", self._width, self._height, drain=False)
@@ -419,7 +447,8 @@ 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
+ if not self.__authorized:
+ raise _SecurityError()
handlers = {
0: self.__handle_set_pixel_format,
2: self.__handle_set_encodings,
@@ -486,40 +515,101 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
async def __handle_key_event(self) -> None:
(state, code) = await self._read_struct("key event", "? xx L")
- await self._on_key_event(code, state) # type: ignore
+ state = bool(state)
+
+ is_modifier = self.__switch_modifiers_x11(code, state)
+ variants = self.__symmap.get(code)
+ fake_shift = False
+
+ if variants:
+ if is_modifier:
+ key = variants.get(0)
+ else:
+ key = variants.get(self.__modifiers)
+ if key is None:
+ key = variants.get(0)
+
+ if key is None and self.__modifiers == 0 and SymmapModifiers.SHIFT in variants:
+ # JUMP doesn't send shift events:
+ # - https://github.com/pikvm/pikvm/issues/820
+ key = variants[SymmapModifiers.SHIFT]
+ fake_shift = True
+
+ if key:
+ if fake_shift:
+ await self._on_key_event(EvdevModifiers.SHIFT_LEFT, True)
+ await self._on_key_event(key, state)
+ if fake_shift:
+ await self._on_key_event(EvdevModifiers.SHIFT_LEFT, False)
+
+ def __switch_modifiers_x11(self, code: int, state: bool) -> bool:
+ mod = 0
+ if code in X11Modifiers.SHIFTS:
+ mod = SymmapModifiers.SHIFT
+ elif code == X11Modifiers.ALTGR:
+ mod = SymmapModifiers.ALTGR
+ elif code in X11Modifiers.CTRLS:
+ mod = SymmapModifiers.CTRL
+ if mod == 0:
+ return False
+ if state:
+ self.__modifiers |= mod
+ else:
+ self.__modifiers &= ~mod
+ return True
+
+ def __switch_modifiers_evdev(self, key: int, state: bool) -> bool:
+ mod = 0
+ if key in EvdevModifiers.SHIFTS:
+ mod = SymmapModifiers.SHIFT
+ elif key == EvdevModifiers.ALT_RIGHT:
+ mod = SymmapModifiers.ALTGR
+ elif key in EvdevModifiers.CTRLS:
+ mod = SymmapModifiers.CTRL
+ if mod == 0:
+ return False
+ if state:
+ self.__modifiers |= mod
+ else:
+ self.__modifiers &= ~mod
+ return True
async def __handle_pointer_event(self) -> None:
(buttons, to_x, to_y) = await self._read_struct("pointer event", "B HH")
ext_buttons = 0
if self._encodings.has_ext_mouse and (buttons & 0x80): # Marker bit 7 for ext event
ext_buttons = await self._read_number("ext pointer event buttons", "B")
- await self._on_pointer_event(
- buttons={
- "left": bool(buttons & 0x1),
- "right": bool(buttons & 0x4),
- "middle": bool(buttons & 0x2),
- "up": bool(ext_buttons & 0x2),
- "down": bool(ext_buttons & 0x1),
- },
- wheel={
- "x": (-4 if buttons & 0x40 else (4 if buttons & 0x20 else 0)),
- "y": (-4 if buttons & 0x10 else (4 if buttons & 0x8 else 0)),
- },
- move={
- "x": tools.remap(to_x, 0, self._width, *MouseRange.RANGE),
- "y": tools.remap(to_y, 0, self._height, *MouseRange.RANGE),
- },
- )
+
+ if buttons & (0x40 | 0x20 | 0x10 | 0x08):
+ sr = self.__scroll_rate
+ await self._on_mouse_wheel_event(
+ (-sr if buttons & 0x40 else (sr if buttons & 0x20 else 0)),
+ (-sr if buttons & 0x10 else (sr if buttons & 0x08 else 0)),
+ )
+
+ move = (self._width, self._height, to_x, to_y)
+ if self.__mouse_move != move:
+ await self._on_mouse_move_event(
+ tools.remap(to_x, 0, self._width - 1, *MouseRange.RANGE),
+ tools.remap(to_y, 0, self._height - 1, *MouseRange.RANGE),
+ )
+ self.__mouse_move = move
+
+ for (code, state) in [
+ (ecodes.BTN_LEFT, bool(buttons & 0x1)),
+ (ecodes.BTN_RIGHT, bool(buttons & 0x4)),
+ (ecodes.BTN_MIDDLE, bool(buttons & 0x2)),
+ (ecodes.BTN_BACK, bool(ext_buttons & 0x2)),
+ (ecodes.BTN_FORWARD, bool(ext_buttons & 0x1)),
+ ]:
+ if self.__mouse_buttons.get(code) != state:
+ await self._on_mouse_button_event(code, state)
+ self.__mouse_buttons[code] = state
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)
- 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)
+ 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])
@@ -532,6 +622,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
async def __handle_qemu_event(self) -> None:
(sub_type, state, code) = await self._read_struct("QEMU event (key?)", "B H xxxx L")
+ state = bool(state)
if sub_type != 0:
raise RfbError(f"Invalid QEMU sub-message type: {sub_type}")
if code == 0xB7:
@@ -539,4 +630,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
code = 0x54
if code & 0x80:
code = (0xE0 << 8) | (code & ~0x80)
- await self._on_ext_key_event(code, bool(state))
+ key = AT1_TO_EVDEV.get(code, 0)
+ if key:
+ self.__switch_modifiers_evdev(key, state) # Предполагаем, что модификаторы всегда известны
+ await self._on_key_event(key, state)
diff --git a/kvmd/apps/vnc/rfb/stream.py b/kvmd/apps/vnc/rfb/stream.py
index dc3ceb1b..49f53f3c 100644
--- a/kvmd/apps/vnc/rfb/stream.py
+++ b/kvmd/apps/vnc/rfb/stream.py
@@ -110,32 +110,13 @@ class RfbClientStream:
# =====
async def _start_tls(self, ssl_context: ssl.SSLContext, ssl_timeout: float) -> None:
- loop = asyncio.get_event_loop()
-
- ssl_reader = asyncio.StreamReader()
- protocol = asyncio.StreamReaderProtocol(ssl_reader)
-
try:
- transport = await loop.start_tls(
- self.__writer.transport,
- protocol,
+ await self.__writer.start_tls(
ssl_context,
- server_side=True,
ssl_handshake_timeout=ssl_timeout,
)
except ConnectionError as ex:
raise RfbConnectionError("Can't start TLS", ex)
- ssl_reader.set_transport(transport) # type: ignore
- ssl_writer = asyncio.StreamWriter(
- transport=transport, # type: ignore
- protocol=protocol,
- reader=ssl_reader,
- loop=loop,
- )
-
- self.__reader = ssl_reader
- self.__writer = ssl_writer
-
async def _close(self) -> None:
await aiotools.close_writer(self.__writer)
diff --git a/kvmd/apps/vnc/server.py b/kvmd/apps/vnc/server.py
index b2ae71fa..a2b90736 100644
--- a/kvmd/apps/vnc/server.py
+++ b/kvmd/apps/vnc/server.py
@@ -27,14 +27,14 @@ import dataclasses
import contextlib
import aiohttp
+import async_lru
+
+from evdev import ecodes
from ...logging import get_logger
-from ...keyboard.keysym import SymmapModifiers
from ...keyboard.keysym import build_symmap
-from ...keyboard.mappings import WebModifiers
-from ...keyboard.mappings import X11Modifiers
-from ...keyboard.mappings import AT1_TO_WEB
+from ...keyboard.magic import MagicHandler
from ...clients.kvmd import KvmdClientWs
from ...clients.kvmd import KvmdClientSession
@@ -53,9 +53,6 @@ from .rfb import RfbClient
from .rfb.stream import rfb_format_remote
from .rfb.errors import RfbError
-from .vncauth import VncAuthKvmdCredentials
-from .vncauth import VncAuthManager
-
from .render import make_text_jpeg
@@ -80,29 +77,30 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
desired_fps: int,
mouse_output: str,
keymap_name: str,
- symmap: dict[int, dict[int, str]],
- allow_cut_after: float,
+ symmap: dict[int, dict[int, int]],
+ scroll_rate: int,
kvmd: KvmdClient,
streamers: list[BaseStreamerClient],
- vnc_credentials: dict[str, VncAuthKvmdCredentials],
+ vncpasses: set[str],
vencrypt: bool,
none_auth_only: bool,
+
shared_params: _SharedParams,
) -> None:
- self.__vnc_credentials = vnc_credentials
-
- super().__init__(
+ RfbClient.__init__(
+ self,
reader=reader,
writer=writer,
tls_ciphers=tls_ciphers,
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),
+ symmap=symmap,
+ scroll_rate=scroll_rate,
+ vncpasses=vncpasses,
vencrypt=vencrypt,
none_auth_only=none_auth_only,
**dataclasses.asdict(shared_params),
@@ -111,7 +109,6 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
self.__desired_fps = desired_fps
self.__mouse_output = mouse_output
self.__keymap_name = keymap_name
- self.__symmap = symmap
self.__kvmd = kvmd
self.__streamers = streamers
@@ -128,12 +125,23 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
self.__fb_queue: "asyncio.Queue[dict]" = asyncio.Queue()
self.__fb_has_key = False
- # Эти состояния шарить не обязательно - бекенд исключает дублирующиеся события.
- # Все это нужно только чтобы не посылать лишние жсоны в сокет KVMD
- self.__mouse_buttons: dict[str, (bool | None)] = dict.fromkeys(["left", "right", "middle", "up", "down"], None)
- self.__mouse_move = {"x": -1, "y": -1}
+ self.__clipboard = ""
- self.__modifiers = 0
+ self.__info_host = ""
+ self.__info_switch_units = 0
+ self.__info_switch_active = ""
+
+ self.__magic = MagicHandler(
+ proxy_handler=self.__on_magic_key_proxy,
+ key_handlers={
+ ecodes.KEY_P: self.__on_magic_clipboard_print,
+ ecodes.KEY_UP: self.__on_magic_switch_prev,
+ ecodes.KEY_LEFT: self.__on_magic_switch_prev,
+ ecodes.KEY_DOWN: self.__on_magic_switch_next,
+ ecodes.KEY_RIGHT: self.__on_magic_switch_next,
+ },
+ numeric_handler=self.__on_magic_switch_port,
+ )
# =====
@@ -179,16 +187,22 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
async def __process_ws_event(self, event_type: str, event: dict) -> None:
if event_type == "info":
if "meta" in event:
+ host = ""
try:
- host = event["meta"]["server"]["host"]
+ if isinstance(event["meta"]["server"]["host"], str):
+ host = event["meta"]["server"]["host"].strip()
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
+ pass
+ self.__info_host = host
+ await self.__update_info()
+
+ elif event_type == "switch":
+ if "model" in event:
+ self.__info_switch_units = len(event["model"]["units"])
+ if "summary" in event:
+ self.__info_switch_active = event["summary"]["active_id"]
+ if "model" in event or "summary" in event:
+ await self.__update_info()
elif event_type == "hid":
if (
@@ -198,6 +212,17 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
):
await self._send_leds_state(**event["keyboard"]["leds"])
+ async def __update_info(self) -> None:
+ info: list[str] = []
+ if self.__info_switch_units > 0:
+ info.append("Port " + (self.__info_switch_active or "not selected"))
+ if self.__info_host:
+ info.append(self.__info_host)
+ info.append("PiKVM")
+ self.__shared_params.name = " | ".join(info)
+ if self._encodings.has_rename:
+ await self._send_rename(self.__shared_params.name)
+
# =====
async def __streamer_task_loop(self) -> None:
@@ -213,10 +238,7 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
if not streaming:
logger.info("%s [streamer]: Streaming ...", self._remote)
streaming = True
- if frame["online"]:
- await self.__queue_frame(frame)
- else:
- await self.__queue_frame("No signal")
+ await self.__queue_frame(frame)
except StreamerError as ex:
if isinstance(ex, StreamerPermError):
streamer = self.__get_default_streamer()
@@ -317,98 +339,91 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
# =====
async def _authorize_userpass(self, user: str, passwd: str) -> bool:
- self.__kvmd_session = self.__kvmd.make_session(user, passwd)
- if (await self.__kvmd_session.auth.check()):
+ self.__kvmd_session = self.__kvmd.make_session()
+ if (await self.__kvmd_session.auth.check(user, passwd)):
self.__stage1_authorized.set_passed()
return True
return False
- async def _on_authorized_vnc_passwd(self, passwd: str) -> str:
- kc = self.__vnc_credentials[passwd]
- if (await self._authorize_userpass(kc.user, kc.passwd)):
- return kc.user
- return ""
+ async def _on_authorized_vncpass(self) -> None:
+ self.__kvmd_session = self.__kvmd.make_session()
+ self.__stage1_authorized.set_passed()
- async def _on_authorized_none(self) -> bool:
+ async def _authorize_none(self) -> bool:
return (await self._authorize_userpass("", ""))
# =====
- async def _on_key_event(self, code: int, state: bool) -> None:
- is_modifier = self.__switch_modifiers(code, state)
- variants = self.__symmap.get(code)
- fake_shift = False
+ async def _on_key_event(self, key: int, state: bool) -> None:
+ assert self.__stage1_authorized.is_passed()
+ await self.__magic.handle_key(key, state)
- if variants:
- if is_modifier:
- web_key = variants.get(0)
- else:
- web_key = variants.get(self.__modifiers)
- if web_key is None:
- web_key = variants.get(0)
+ async def __on_magic_switch_prev(self) -> None:
+ assert self.__kvmd_session
+ if self.__info_switch_units > 0:
+ get_logger(0).info("%s [main]: Switching port to the previous one ...", self._remote)
+ await self.__kvmd_session.switch.set_active_prev()
- if web_key is None and self.__modifiers == 0 and SymmapModifiers.SHIFT in variants:
- # JUMP doesn't send shift events:
- # - https://github.com/pikvm/pikvm/issues/820
- web_key = variants[SymmapModifiers.SHIFT]
- fake_shift = True
+ async def __on_magic_switch_next(self) -> None:
+ assert self.__kvmd_session
+ if self.__info_switch_units > 0:
+ get_logger(0).info("%s [main]: Switching port to the next one ...", self._remote)
+ await self.__kvmd_session.switch.set_active_next()
- if web_key and self.__kvmd_ws:
- if fake_shift:
- await self.__kvmd_ws.send_key_event(WebModifiers.SHIFT_LEFT, True)
- await self.__kvmd_ws.send_key_event(web_key, state)
- if fake_shift:
- await self.__kvmd_ws.send_key_event(WebModifiers.SHIFT_LEFT, False)
-
- async def _on_ext_key_event(self, code: int, state: bool) -> None:
- web_key = AT1_TO_WEB.get(code)
- if web_key:
- self.__switch_modifiers(web_key, state) # Предполагаем, что модификаторы всегда известны
- if self.__kvmd_ws:
- await self.__kvmd_ws.send_key_event(web_key, state)
-
- def __switch_modifiers(self, key: (int | str), state: bool) -> bool:
- mod = 0
- if key in X11Modifiers.SHIFTS or key in WebModifiers.SHIFTS:
- mod = SymmapModifiers.SHIFT
- elif key == X11Modifiers.ALTGR or key == WebModifiers.ALT_RIGHT:
- mod = SymmapModifiers.ALTGR
- elif key in X11Modifiers.CTRLS or key in WebModifiers.CTRLS:
- mod = SymmapModifiers.CTRL
- if mod == 0:
- return False
- if state:
- self.__modifiers |= mod
- else:
- self.__modifiers &= ~mod
+ async def __on_magic_switch_port(self, codes: list[int]) -> bool:
+ assert self.__kvmd_session
+ assert len(codes) > 0
+ if self.__info_switch_units <= 0:
+ return True
+ elif 1 <= self.__info_switch_units <= 2:
+ port = float(codes[0])
+ else: # self.__info_switch_units > 2:
+ if len(codes) == 1:
+ return False # Wait for the second key
+ port = (codes[0] + 1) + (codes[1] + 1) / 10
+ get_logger(0).info("%s [main]: Switching port to %s ...", self._remote, port)
+ await self.__kvmd_session.switch.set_active(port)
return True
- async def _on_pointer_event(self, buttons: dict[str, bool], wheel: dict[str, int], move: dict[str, int]) -> None:
+ async def __on_magic_clipboard_print(self) -> None:
+ assert self.__kvmd_session
+ if self.__clipboard:
+ logger = get_logger(0)
+ logger.info("%s [main]: Printing %d characters ...", self._remote, len(self.__clipboard))
+ try:
+ (keymap_name, available) = await self.__kvmd_session.hid.get_keymaps()
+ if self.__keymap_name in available:
+ keymap_name = self.__keymap_name
+ await self.__kvmd_session.hid.print(self.__clipboard, 0, keymap_name)
+ except Exception:
+ logger.exception("%s [main]: Can't print characters", self._remote)
+
+ async def __on_magic_key_proxy(self, key: int, state: bool) -> None:
if self.__kvmd_ws:
- if wheel["x"] or wheel["y"]:
- await self.__kvmd_ws.send_mouse_wheel_event(wheel["x"], wheel["y"])
+ await self.__kvmd_ws.send_key_event(key, state)
- if self.__mouse_move != move:
- await self.__kvmd_ws.send_mouse_move_event(move["x"], move["y"])
- self.__mouse_move = move
+ # =====
- for (button, state) in buttons.items():
- if self.__mouse_buttons[button] != state:
- await self.__kvmd_ws.send_mouse_button_event(button, state)
- self.__mouse_buttons[button] = state
+ async def _on_mouse_button_event(self, button: int, state: bool) -> None:
+ assert self.__stage1_authorized.is_passed()
+ if self.__kvmd_ws:
+ await self.__kvmd_ws.send_mouse_button_event(button, state)
+
+ async def _on_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None:
+ assert self.__stage1_authorized.is_passed()
+ if self.__kvmd_ws:
+ await self.__kvmd_ws.send_mouse_wheel_event(delta_x, delta_y)
+
+ async def _on_mouse_move_event(self, to_x: int, to_y: int) -> None:
+ assert self.__stage1_authorized.is_passed()
+ if self.__kvmd_ws:
+ await self.__kvmd_ws.send_mouse_move_event(to_x, to_y)
+
+ # =====
async def _on_cut_event(self, text: str) -> None:
assert self.__stage1_authorized.is_passed()
- assert self.__kvmd_session
- logger = get_logger(0)
- logger.info("%s [main]: Printing %d characters ...", self._remote, len(text))
- try:
- (keymap_name, available) = await self.__kvmd_session.hid.get_keymaps()
- if self.__keymap_name in available:
- keymap_name = self.__keymap_name
- await self.__kvmd_session.hid.print(text, 0, keymap_name)
- except Exception:
- logger.exception("%s [main]: Can't print characters", self._remote)
+ self.__clipboard = text
async def _on_set_encodings(self) -> None:
assert self.__stage1_authorized.is_passed()
@@ -441,16 +456,17 @@ class VncServer: # pylint: disable=too-many-instance-attributes
x509_cert_path: str,
x509_key_path: str,
+ vncpass_enabled: bool,
+ vncpass_path: str,
vencrypt_enabled: bool,
desired_fps: int,
mouse_output: str,
keymap_path: str,
- allow_cut_after: float,
+ scroll_rate: int,
kvmd: KvmdClient,
streamers: list[BaseStreamerClient],
- vnc_auth_manager: VncAuthManager,
) -> None:
self.__host = network.get_listen_host(host)
@@ -460,7 +476,8 @@ class VncServer: # pylint: disable=too-many-instance-attributes
keymap_name = os.path.basename(keymap_path)
symmap = build_symmap(keymap_path)
- self.__vnc_auth_manager = vnc_auth_manager
+ self.__vncpass_enabled = vncpass_enabled
+ self.__vncpass_path = vncpass_path
shared_params = _SharedParams()
@@ -487,8 +504,8 @@ class VncServer: # pylint: disable=too-many-instance-attributes
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT, timeout) # type: ignore
try:
- async with kvmd.make_session("", "") as kvmd_session:
- none_auth_only = await kvmd_session.auth.check()
+ async with kvmd.make_session() as kvmd_session:
+ none_auth_only = await kvmd_session.auth.check("", "")
except (aiohttp.ClientError, asyncio.TimeoutError) as ex:
logger.error("%s [entry]: Can't check KVMD auth mode: %s", remote, tools.efmt(ex))
return
@@ -504,12 +521,12 @@ class VncServer: # pylint: disable=too-many-instance-attributes
mouse_output=mouse_output,
keymap_name=keymap_name,
symmap=symmap,
- allow_cut_after=allow_cut_after,
+ scroll_rate=scroll_rate,
kvmd=kvmd,
streamers=streamers,
- vnc_credentials=(await self.__vnc_auth_manager.read_credentials())[0],
- none_auth_only=none_auth_only,
+ vncpasses=(await self.__read_vncpasses()),
vencrypt=vencrypt_enabled,
+ none_auth_only=none_auth_only,
shared_params=shared_params,
).run()
except Exception:
@@ -520,9 +537,6 @@ class VncServer: # pylint: disable=too-many-instance-attributes
self.__handle_client = handle_client
async def __inner_run(self) -> None:
- if not (await self.__vnc_auth_manager.read_credentials())[1]:
- raise SystemExit(1)
-
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:
@@ -539,6 +553,21 @@ class VncServer: # pylint: disable=too-many-instance-attributes
async with server:
await server.serve_forever()
+ @async_lru.alru_cache(maxsize=1, ttl=1)
+ async def __read_vncpasses(self) -> set[str]:
+ if self.__vncpass_enabled:
+ try:
+ vncpasses: set[str] = set()
+ for (_, line) in tools.passwds_splitted(await aiotools.read_file(self.__vncpass_path)):
+ if " -> " in line: # Compatibility with old ipmipasswd file format
+ line = line.split(" -> ", 1)[0]
+ if len(line.strip()) > 0:
+ vncpasses.add(line)
+ return vncpasses
+ except Exception:
+ get_logger(0).exception("Unhandled exception while reading VNCAuth passwd file")
+ return set()
+
def run(self) -> None:
aiotools.run(self.__inner_run())
get_logger().info("Bye-bye")
diff --git a/kvmd/apps/vnc/vncauth.py b/kvmd/apps/vnc/vncauth.py
deleted file mode 100644
index 46c1a77d..00000000
--- a/kvmd/apps/vnc/vncauth.py
+++ /dev/null
@@ -1,86 +0,0 @@
-# ========================================================================== #
-# #
-# KVMD - The main PiKVM daemon. #
-# #
-# Copyright (C) 2020 Maxim Devaev #
-# #
-# 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 . #
-# #
-# ========================================================================== #
-
-
-import dataclasses
-
-from ...logging import get_logger
-
-from ... import aiotools
-
-
-# =====
-class VncAuthError(Exception):
- def __init__(self, path: str, lineno: int, msg: str) -> None:
- super().__init__(f"Syntax error at {path}:{lineno}: {msg}")
-
-
-# =====
-@dataclasses.dataclass(frozen=True)
-class VncAuthKvmdCredentials:
- user: str
- passwd: str
-
-
-class VncAuthManager:
- def __init__(
- self,
- path: str,
- enabled: bool,
- ) -> None:
-
- self.__path = path
- self.__enabled = enabled
-
- async def read_credentials(self) -> tuple[dict[str, VncAuthKvmdCredentials], bool]:
- if self.__enabled:
- try:
- return (await self.__inner_read_credentials(), True)
- except VncAuthError as ex:
- get_logger(0).error(str(ex))
- except Exception:
- get_logger(0).exception("Unhandled exception while reading VNCAuth passwd file")
- return ({}, (not self.__enabled))
-
- async def __inner_read_credentials(self) -> dict[str, VncAuthKvmdCredentials]:
- lines = (await aiotools.read_file(self.__path)).split("\n")
- credentials: dict[str, VncAuthKvmdCredentials] = {}
- for (lineno, line) in enumerate(lines):
- if len(line.strip()) == 0 or line.lstrip().startswith("#"):
- continue
-
- if " -> " not in line:
- 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, "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, "Empty KVMD user (right part)")
-
- if vnc_passwd in credentials:
- raise VncAuthError(self.__path, lineno, "Duplicating VNC password (left part)")
-
- credentials[vnc_passwd] = VncAuthKvmdCredentials(kvmd_user, kvmd_passwd)
- return credentials
diff --git a/kvmd/clients/__init__.py b/kvmd/clients/__init__.py
index e917c9f6..c3f9ac13 100644
--- a/kvmd/clients/__init__.py
+++ b/kvmd/clients/__init__.py
@@ -23,7 +23,11 @@
import types
from typing import Callable
-from typing import Self
+
+try:
+ from typing import Self
+except ImportError:
+ from typing_extensions import Self
import aiohttp
diff --git a/kvmd/clients/kvmd.py b/kvmd/clients/kvmd.py
index d9b38339..ce07953c 100644
--- a/kvmd/clients/kvmd.py
+++ b/kvmd/clients/kvmd.py
@@ -20,10 +20,10 @@
# ========================================================================== #
-import asyncio
import contextlib
import struct
+import typing
from typing import Callable
from typing import AsyncGenerator
@@ -51,29 +51,35 @@ class _BaseApiPart:
for (key, value) in params.items()
if value is not None
},
- ) as response:
- htclient.raise_not_200(response)
+ ) as resp:
+ htclient.raise_not_200(resp)
class _AuthApiPart(_BaseApiPart):
- async def check(self) -> bool:
+ async def check(self, user: str, passwd: str) -> bool:
session = self._ensure_http_session()
try:
- async with session.get("/auth/check") as response:
- htclient.raise_not_200(response)
- return True
+ async with session.get("/auth/check", headers={
+ "X-KVMD-User": user,
+ "X-KVMD-Passwd": passwd,
+ }) as resp:
+
+ htclient.raise_not_200(resp)
+ return (resp.status == 200) # Just for my paranoia
+
except aiohttp.ClientResponseError as ex:
if ex.status in [400, 401, 403]:
return False
raise
+ typing.assert_never("We should't be here")
class _StreamerApiPart(_BaseApiPart):
async def get_state(self) -> dict:
session = self._ensure_http_session()
- async with session.get("/streamer") as response:
- htclient.raise_not_200(response)
- return (await response.json())["result"]
+ async with session.get("/streamer") as resp:
+ htclient.raise_not_200(resp)
+ return (await resp.json())["result"]
async def set_params(self, quality: (int | None)=None, desired_fps: (int | None)=None) -> None:
await self._set_params(
@@ -86,9 +92,9 @@ class _StreamerApiPart(_BaseApiPart):
class _HidApiPart(_BaseApiPart):
async def get_keymaps(self) -> tuple[str, set[str]]:
session = self._ensure_http_session()
- async with session.get("/hid/keymaps") as response:
- htclient.raise_not_200(response)
- result = (await response.json())["result"]
+ async with session.get("/hid/keymaps") as resp:
+ htclient.raise_not_200(resp)
+ result = (await resp.json())["result"]
return (result["keymaps"]["default"], set(result["keymaps"]["available"]))
async def print(self, text: str, limit: int, keymap_name: str) -> None:
@@ -97,8 +103,8 @@ class _HidApiPart(_BaseApiPart):
url="/hid/print",
params={"limit": limit, "keymap": keymap_name},
data=text,
- ) as response:
- htclient.raise_not_200(response)
+ ) as resp:
+ htclient.raise_not_200(resp)
async def set_params(self, keyboard_output: (str | None)=None, mouse_output: (str | None)=None) -> None:
await self._set_params(
@@ -111,9 +117,9 @@ class _HidApiPart(_BaseApiPart):
class _AtxApiPart(_BaseApiPart):
async def get_state(self) -> dict:
session = self._ensure_http_session()
- async with session.get("/atx") as response:
- htclient.raise_not_200(response)
- return (await response.json())["result"]
+ async with session.get("/atx") as resp:
+ htclient.raise_not_200(resp)
+ return (await resp.json())["result"]
async def switch_power(self, action: str) -> bool:
session = self._ensure_http_session()
@@ -121,8 +127,8 @@ class _AtxApiPart(_BaseApiPart):
async with session.post(
url="/atx/power",
params={"action": action},
- ) as response:
- htclient.raise_not_200(response)
+ ) as resp:
+ htclient.raise_not_200(resp)
return True
except aiohttp.ClientResponseError as ex:
if ex.status == 409:
@@ -130,51 +136,47 @@ class _AtxApiPart(_BaseApiPart):
raise
+class _SwitchApiPart(_BaseApiPart):
+ async def set_active_prev(self) -> None:
+ session = self._ensure_http_session()
+ async with session.post("/switch/set_active_prev") as resp:
+ htclient.raise_not_200(resp)
+
+ async def set_active_next(self) -> None:
+ session = self._ensure_http_session()
+ async with session.post("/switch/set_active_next") as resp:
+ htclient.raise_not_200(resp)
+
+ async def set_active(self, port: float) -> None:
+ session = self._ensure_http_session()
+ async with session.post(
+ url="/switch/set_active",
+ params={"port": port},
+ ) as resp:
+ htclient.raise_not_200(resp)
+
+
# =====
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
async def communicate(self) -> AsyncGenerator[tuple[str, dict], None]: # pylint: disable=too-many-branches
assert not self.__communicated
self.__communicated = True
- receive_task: (asyncio.Task | None) = None
- writer_task: (asyncio.Task | None) = None
try:
- while True:
- if receive_task is None:
- receive_task = asyncio.create_task(self.__ws.receive())
- if writer_task is None:
- writer_task = asyncio.create_task(self.__writer_queue.get())
-
- done = (await aiotools.wait_first(receive_task, writer_task))[0]
-
- if receive_task in done:
- msg = receive_task.result()
- if msg.type == aiohttp.WSMsgType.TEXT:
+ async for msg in self.__ws:
+ match msg.type:
+ case aiohttp.WSMsgType.TEXT:
yield htserver.parse_ws_event(msg.data)
- elif msg.type == aiohttp.WSMsgType.CLOSE:
+ case aiohttp.WSMsgType.CLOSE:
await self.__ws.close()
- elif msg.type == aiohttp.WSMsgType.CLOSED:
+ case aiohttp.WSMsgType.CLOSED:
break
- else:
+ case _:
raise RuntimeError(f"Unhandled WS message type: {msg!r}")
- receive_task = None
-
- if writer_task in done:
- payload = writer_task.result()
- if isinstance(payload, bytes):
- await self.__ws.send_bytes(payload)
- else:
- await htserver.send_ws_event(self.__ws, *payload)
- writer_task = None
finally:
- if receive_task:
- receive_task.cancel()
- if writer_task:
- writer_task.cancel()
try:
await aiotools.shield_fg(self.__ws.close())
except Exception:
@@ -182,19 +184,33 @@ class KvmdClientWs:
finally:
self.__communicated = False
- async def send_key_event(self, key: str, state: bool) -> None:
- mask = (0b01 if state else 0)
- await self.__writer_queue.put(bytes([1, mask]) + key.encode("ascii"))
+ async def send_key_event(self, key: int, state: bool) -> None:
+ mask = (0b10000000 | int(bool(state)))
+ await self.__send_struct(">BBH", 1, mask, key)
- async def send_mouse_button_event(self, button: str, state: bool) -> None:
- mask = (0b01 if state else 0)
- await self.__writer_queue.put(bytes([2, mask]) + button.encode("ascii"))
+ async def send_mouse_button_event(self, button: int, state: bool) -> None:
+ mask = (0b10000000 | int(bool(state)))
+ await self.__send_struct(">BBH", 2, mask, button)
async def send_mouse_move_event(self, to_x: int, to_y: int) -> None:
- await self.__writer_queue.put(struct.pack(">bhh", 3, to_x, to_y))
+ await self.__send_struct(">Bhh", 3, to_x, to_y)
+
+ async def send_mouse_relative_event(self, delta_x: int, delta_y: int) -> None:
+ await self.__send_struct(">BBbb", 4, 0, delta_x, delta_y)
async def send_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None:
- await self.__writer_queue.put(struct.pack(">bbbb", 5, 0, delta_x, delta_y))
+ await self.__send_struct(">BBbb", 5, 0, delta_x, delta_y)
+
+ async def __send_struct(self, fmt: str, *values: int) -> None:
+ if not self.__communicated:
+ return
+ data = struct.pack(fmt, *values)
+ try:
+ await self.__ws.send_bytes(data)
+ except Exception:
+ # XXX: We don't care about any connection errors
+ # since they will be handled with communication()
+ pass
class KvmdClientSession(BaseHttpClientSession):
@@ -204,18 +220,15 @@ class KvmdClientSession(BaseHttpClientSession):
self.streamer = _StreamerApiPart(self._ensure_http_session)
self.hid = _HidApiPart(self._ensure_http_session)
self.atx = _AtxApiPart(self._ensure_http_session)
+ self.switch = _SwitchApiPart(self._ensure_http_session)
@contextlib.asynccontextmanager
- async def ws(self) -> AsyncGenerator[KvmdClientWs, None]:
+ async def ws(self, stream: bool=True) -> AsyncGenerator[KvmdClientWs, None]:
session = self._ensure_http_session()
- async with session.ws_connect("/ws", params={"legacy": "0"}) as ws:
+ async with session.ws_connect("/ws", params={"stream": int(stream)}) as ws:
yield KvmdClientWs(ws)
class KvmdClient(BaseHttpClient):
- def make_session(self, user: str="", passwd: str="") -> KvmdClientSession:
- headers = {
- "X-KVMD-User": user,
- "X-KVMD-Passwd": passwd,
- }
- return KvmdClientSession(lambda: self._make_http_session(headers))
+ def make_session(self) -> KvmdClientSession:
+ return KvmdClientSession(self._make_http_session)
diff --git a/kvmd/clients/streamer.py b/kvmd/clients/streamer.py
index cd5f03ec..e7047e3f 100644
--- a/kvmd/clients/streamer.py
+++ b/kvmd/clients/streamer.py
@@ -117,25 +117,25 @@ class StreamerSnapshot:
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 with session.get("/state") as resp:
+ htclient.raise_not_200(resp)
+ return (await resp.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:
+ ) as resp:
- htclient.raise_not_200(response)
+ htclient.raise_not_200(resp)
return StreamerSnapshot(
- online=(response.headers["X-UStreamer-Online"] == "true"),
- width=int(response.headers["X-UStreamer-Width"]),
- height=int(response.headers["X-UStreamer-Height"]),
+ online=(resp.headers["X-UStreamer-Online"] == "true"),
+ width=int(resp.headers["X-UStreamer-Width"]),
+ height=int(resp.headers["X-UStreamer-Height"]),
headers=tuple(
(key, value)
- for (key, value) in tools.sorted_kvs(dict(response.headers))
+ for (key, value) in tools.sorted_kvs(dict(resp.headers))
if key.lower().startswith("x-ustreamer-") or key.lower() in [
"x-timestamp",
"access-control-allow-origin",
@@ -144,7 +144,7 @@ class HttpStreamerClientSession(BaseHttpClientSession):
"expires",
]
),
- data=bytes(await response.read()),
+ data=bytes(await resp.read()),
)
@@ -187,10 +187,10 @@ class HttpStreamerClient(BaseHttpClient, BaseStreamerClient):
connect=session.timeout.total,
sock_read=session.timeout.total,
),
- ) as response:
+ ) as resp:
- htclient.raise_not_200(response)
- reader = aiohttp.MultipartReader.from_response(response)
+ htclient.raise_not_200(resp)
+ reader = aiohttp.MultipartReader.from_response(resp)
self.__patch_stream_reader(reader.resp.content)
async def read_frame(key_required: bool) -> dict:
diff --git a/kvmd/crypto.py b/kvmd/crypto.py
new file mode 100644
index 00000000..855c3c18
--- /dev/null
+++ b/kvmd/crypto.py
@@ -0,0 +1,58 @@
+# ========================================================================== #
+# #
+# KVMD - The main PiKVM daemon. #
+# #
+# Copyright (C) 2018-2024 Maxim Devaev #
+# #
+# 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 . #
+# #
+# ========================================================================== #
+
+
+from passlib.context import CryptContext
+from passlib.apache import HtpasswdFile as _ApacheHtpasswdFile
+from passlib.apache import htpasswd_context as _apache_htpasswd_ctx
+
+
+# =====
+_SHA512 = "ldap_salted_sha512"
+_SHA256 = "ldap_salted_sha256"
+
+
+def _make_kvmd_htpasswd_context() -> CryptContext:
+ schemes = list(_apache_htpasswd_ctx.schemes())
+ for alg in [_SHA256, _SHA512]:
+ if alg in schemes:
+ schemes.remove(alg)
+ schemes.insert(0, alg)
+ assert schemes[0] == _SHA512
+ return CryptContext(
+ schemes=schemes,
+ default=_SHA512,
+ bcrypt__ident="2y", # See note in the passlib.apache
+ )
+
+
+_kvmd_htpasswd_ctx = _make_kvmd_htpasswd_context()
+
+
+# =====
+class KvmdHtpasswdFile(_ApacheHtpasswdFile):
+ def __init__(self, path: str, new: bool=False) -> None:
+ super().__init__(
+ path=path,
+ default_scheme=_SHA512,
+ context=_kvmd_htpasswd_ctx,
+ new=new,
+ )
diff --git a/kvmd/edid.py b/kvmd/edid.py
index bfa62e3c..8e8701a7 100644
--- a/kvmd/edid.py
+++ b/kvmd/edid.py
@@ -69,6 +69,9 @@ class _CeaBlock:
return _CeaBlock(tag, data)
+_LONG = 256
+_SHORT = 128
+
_CEA = 128
_CEA_AUDIO = 1
_CEA_SPEAKERS = 4
@@ -78,22 +81,27 @@ class Edid:
# https://en.wikipedia.org/wiki/Extended_Display_Identification_Data
def __init__(self, data: bytes) -> None:
- assert len(data) == 256
+ assert len(data) in [_SHORT, _LONG], f"Invalid EDID length: {len(data)}, should be {_SHORT} or {_LONG} bytes"
+ self.__long = (len(data) == _LONG)
+ if self.__long:
+ assert data[126] == 1, "Zero extensions number"
+ assert (data[_CEA + 0], data[_CEA + 1]) == (0x02, 0x03), "Can't find CEA extension"
self.__data = list(data)
+ @classmethod
+ def is_header_valid(cls, data: bytes) -> bool:
+ return data.startswith(b"\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00")
+
@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"):
+ if not cls.is_header_valid(data):
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:
@@ -115,7 +123,8 @@ class Edid:
def __update_checksums(self) -> None:
self.__data[127] = 256 - (sum(self.__data[:127]) % 256)
- self.__data[255] = 256 - (sum(self.__data[128:255]) % 256)
+ if self.__long:
+ self.__data[255] = 256 - (sum(self.__data[128:255]) % 256)
# =====
@@ -229,6 +238,9 @@ class Edid:
self.__data[_CEA + 3] &= (0xFF - 0b01000000) # ~X
def __parse_cea(self) -> tuple[list[_CeaBlock], bytes]:
+ if not self.__long:
+ raise EdidNoBlockError("This EDID does not contain any CEA blocks")
+
cea = self.__data[_CEA:]
dtd_begin = cea[2]
if dtd_begin == 0:
diff --git a/kvmd/fstab.py b/kvmd/fstab.py
index 5d42b123..f1c50bec 100644
--- a/kvmd/fstab.py
+++ b/kvmd/fstab.py
@@ -38,12 +38,12 @@ class Partition:
# =====
-def find_msd(msd_directory_path) -> Partition:
+def find_msd(msd_directory_path: str = "/var/lib/kvmd/msd") -> Partition:
return _find_single("otgmsd", msd_directory_path)
-def find_pst() -> Partition:
- return _find_single("pst")
+def find_pst(msd_directory_path: str = "/var/lib/kvmd/msd") -> Partition:
+ return _find_single("pst", msd_directory_path)
# =====
@@ -51,8 +51,8 @@ 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(msd_directory_path):
- #set default value
- parts = [Partition(mount_path = msd_directory_path, root_path = msd_directory_path, group = 'kvmd', user = 'kvmd')]
+ # set default value
+ 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]
diff --git a/kvmd/htserver.py b/kvmd/htserver.py
index 1ef3cc48..9cac2d6c 100644
--- a/kvmd/htserver.py
+++ b/kvmd/htserver.py
@@ -22,6 +22,7 @@
import os
import socket
+import struct
import asyncio
import contextlib
import dataclasses
@@ -83,6 +84,7 @@ class HttpExposed:
method: str
path: str
auth_required: bool
+ allow_usc: bool
handler: Callable
@@ -90,14 +92,22 @@ _HTTP_EXPOSED = "_http_exposed"
_HTTP_METHOD = "_http_method"
_HTTP_PATH = "_http_path"
_HTTP_AUTH_REQUIRED = "_http_auth_required"
+_HTTP_ALLOW_USC = "_http_allow_usc"
-def exposed_http(http_method: str, path: str, auth_required: bool=True) -> Callable:
+def exposed_http(
+ http_method: str,
+ path: str,
+ auth_required: bool=True,
+ allow_usc: bool=True,
+) -> Callable:
+
def set_attrs(handler: Callable) -> Callable:
setattr(handler, _HTTP_EXPOSED, True)
setattr(handler, _HTTP_METHOD, http_method)
setattr(handler, _HTTP_PATH, path)
setattr(handler, _HTTP_AUTH_REQUIRED, auth_required)
+ setattr(handler, _HTTP_ALLOW_USC, allow_usc)
return handler
return set_attrs
@@ -108,6 +118,7 @@ def _get_exposed_http(obj: object) -> list[HttpExposed]:
method=getattr(handler, _HTTP_METHOD),
path=getattr(handler, _HTTP_PATH),
auth_required=getattr(handler, _HTTP_AUTH_REQUIRED),
+ allow_usc=getattr(handler, _HTTP_ALLOW_USC),
handler=handler,
)
for handler in [getattr(obj, name) for name in dir(obj)]
@@ -270,6 +281,35 @@ def set_request_auth_info(req: BaseRequest, info: str) -> None:
setattr(req, _REQUEST_AUTH_INFO, info)
+@dataclasses.dataclass(frozen=True)
+class RequestUnixCredentials:
+ pid: int
+ uid: int
+ gid: int
+
+ def __post_init__(self) -> None:
+ assert self.pid >= 0
+ assert self.uid >= 0
+ assert self.gid >= 0
+
+
+def get_request_unix_credentials(req: BaseRequest) -> (RequestUnixCredentials | None):
+ if req.transport is None:
+ return None
+ sock = req.transport.get_extra_info("socket")
+ if sock is None:
+ return None
+ try:
+ data = sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("iii"))
+ except Exception:
+ return None
+ (pid, uid, gid) = struct.unpack("iii", data)
+ if pid < 0 or uid < 0 or gid < 0:
+ # PID == 0 when the client is outside of server's PID namespace, e.g. when kvmd runs in a container
+ return None
+ return RequestUnixCredentials(pid=pid, uid=uid, gid=gid)
+
+
# =====
@dataclasses.dataclass(frozen=True)
class WsSession:
@@ -314,13 +354,14 @@ class HttpServer:
if unix_rm and os.path.exists(unix_path):
os.remove(unix_path)
- server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
- server_socket.bind(unix_path)
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_PASSCRED, 1)
+ sock.bind(unix_path)
if unix_mode:
os.chmod(unix_path, unix_mode)
run_app(
- sock=server_socket,
+ sock=sock,
app=self.__make_app(),
shutdown_timeout=1,
access_log_format=access_log_format,
diff --git a/kvmd/keyboard/keysym.py b/kvmd/keyboard/keysym.py
index 2896ca6e..46165512 100644
--- a/kvmd/keyboard/keysym.py
+++ b/kvmd/keyboard/keysym.py
@@ -30,9 +30,9 @@ import Xlib.keysymdef
from ..logging import get_logger
from .mappings import At1Key
-from .mappings import WebModifiers
+from .mappings import EvdevModifiers
from .mappings import X11_TO_AT1
-from .mappings import AT1_TO_WEB
+from .mappings import AT1_TO_EVDEV
# =====
@@ -42,11 +42,11 @@ class SymmapModifiers:
CTRL: int = 0x4
-def build_symmap(path: str) -> dict[int, dict[int, str]]: # x11 keysym -> [(modifiers, webkey), ...]
+def build_symmap(path: str) -> dict[int, dict[int, int]]: # x11 keysym -> [(symmap_modifiers, evdev_code), ...]
# https://github.com/qemu/qemu/blob/95a9457fd44ad97c518858a4e1586a5498f9773c/ui/keymaps.c
logger = get_logger()
- symmap: dict[int, dict[int, str]] = {}
+ symmap: dict[int, dict[int, int]] = {}
for (src, items) in [
(path, list(_read_keyboard_layout(path).items())),
("", list(X11_TO_AT1.items())),
@@ -57,14 +57,14 @@ def build_symmap(path: str) -> dict[int, dict[int, str]]: # x11 keysym -> [(mod
for (code, keys) in items:
for key in keys:
- web_name = AT1_TO_WEB.get(key.code)
- if web_name is not None:
+ evdev_code = AT1_TO_EVDEV.get(key.code)
+ if evdev_code is not None:
if (
- (web_name in WebModifiers.SHIFTS and key.shift) # pylint: disable=too-many-boolean-expressions
- or (web_name in WebModifiers.ALTS and key.altgr)
- or (web_name in WebModifiers.CTRLS and key.ctrl)
+ (evdev_code in EvdevModifiers.SHIFTS and key.shift) # pylint: disable=too-many-boolean-expressions
+ or (evdev_code in EvdevModifiers.ALTS and key.altgr)
+ or (evdev_code in EvdevModifiers.CTRLS and key.ctrl)
):
- logger.error("Invalid modifier key at mapping %s: %s / %s", src, web_name, key)
+ logger.error("Invalid modifier key at mapping %s: %s / %s", src, evdev_code, key)
continue
modifiers = (
@@ -75,7 +75,7 @@ def build_symmap(path: str) -> dict[int, dict[int, str]]: # x11 keysym -> [(mod
)
if code not in symmap:
symmap[code] = {}
- symmap[code].setdefault(modifiers, web_name)
+ symmap[code].setdefault(modifiers, evdev_code)
return symmap
diff --git a/kvmd/keyboard/magic.py b/kvmd/keyboard/magic.py
new file mode 100644
index 00000000..915ef8e0
--- /dev/null
+++ b/kvmd/keyboard/magic.py
@@ -0,0 +1,82 @@
+# ========================================================================== #
+# #
+# KVMD - The main PiKVM daemon. #
+# #
+# Copyright (C) 2020 Maxim Devaev #
+# #
+# 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 . #
+# #
+# ========================================================================== #
+
+
+import time
+
+from typing import Callable
+from typing import Awaitable
+
+from evdev import ecodes
+
+
+# =====
+class MagicHandler:
+ __MAGIC_KEY = ecodes.KEY_LEFTALT
+ __MAGIC_TIMEOUT = 2
+ __MAGIC_TRIGGER = 2
+
+ def __init__(
+ self,
+ proxy_handler: Callable[[int, bool], Awaitable[None]],
+ key_handlers: (dict[int, Callable[[], Awaitable[None]]] | None)=None,
+ numeric_handler: (Callable[[list[int]], Awaitable[bool]] | None)=None,
+ ) -> None:
+
+ self.__proxy_handler = proxy_handler
+ self.__key_handlers = (key_handlers or {})
+ self.__numeric_handler = numeric_handler
+
+ self.__taps = 0
+ self.__ts = 0.0
+ self.__codes: list[int] = []
+
+ async def handle_key(self, key: int, state: bool) -> None: # pylint: disable=too-many-branches
+ if self.__ts + self.__MAGIC_TIMEOUT < time.monotonic():
+ self.__taps = 0
+ self.__ts = 0
+ self.__codes = []
+
+ if key == self.__MAGIC_KEY:
+ if not state:
+ self.__taps += 1
+ self.__ts = time.monotonic()
+ elif state:
+ taps = self.__taps
+ codes = self.__codes
+ self.__taps = 0
+ self.__ts = 0
+ self.__codes = []
+ if taps >= self.__MAGIC_TRIGGER:
+ if key in self.__key_handlers:
+ await self.__key_handlers[key]()
+ return
+ elif self.__numeric_handler is not None and (ecodes.KEY_1 <= key <= ecodes.KEY_8):
+ codes.append(key - ecodes.KEY_1)
+ if not (await self.__numeric_handler(list(codes))):
+ # Если хандлер хочет код большей длины, он возвращает False,
+ # и мы ждем следующую цифру.
+ self.__taps = taps
+ self.__ts = time.monotonic()
+ self.__codes = codes
+ return
+
+ await self.__proxy_handler(key, state)
diff --git a/kvmd/keyboard/mappings.py b/kvmd/keyboard/mappings.py
index 57744cea..5a66f030 100644
--- a/kvmd/keyboard/mappings.py
+++ b/kvmd/keyboard/mappings.py
@@ -22,6 +22,8 @@
import dataclasses
+from evdev import ecodes
+
# =====
@dataclasses.dataclass(frozen=True)
@@ -31,7 +33,7 @@ class McuKey:
@dataclasses.dataclass(frozen=True)
class UsbKey:
- code: int
+ code: int
is_modifier: bool
@@ -41,137 +43,260 @@ class Key:
usb: UsbKey
-KEYMAP: dict[str, Key] = {
- "KeyA": Key(mcu=McuKey(code=1), usb=UsbKey(code=4, is_modifier=False)),
- "KeyB": Key(mcu=McuKey(code=2), usb=UsbKey(code=5, is_modifier=False)),
- "KeyC": Key(mcu=McuKey(code=3), usb=UsbKey(code=6, is_modifier=False)),
- "KeyD": Key(mcu=McuKey(code=4), usb=UsbKey(code=7, is_modifier=False)),
- "KeyE": Key(mcu=McuKey(code=5), usb=UsbKey(code=8, is_modifier=False)),
- "KeyF": Key(mcu=McuKey(code=6), usb=UsbKey(code=9, is_modifier=False)),
- "KeyG": Key(mcu=McuKey(code=7), usb=UsbKey(code=10, is_modifier=False)),
- "KeyH": Key(mcu=McuKey(code=8), usb=UsbKey(code=11, is_modifier=False)),
- "KeyI": Key(mcu=McuKey(code=9), usb=UsbKey(code=12, is_modifier=False)),
- "KeyJ": Key(mcu=McuKey(code=10), usb=UsbKey(code=13, is_modifier=False)),
- "KeyK": Key(mcu=McuKey(code=11), usb=UsbKey(code=14, is_modifier=False)),
- "KeyL": Key(mcu=McuKey(code=12), usb=UsbKey(code=15, is_modifier=False)),
- "KeyM": Key(mcu=McuKey(code=13), usb=UsbKey(code=16, is_modifier=False)),
- "KeyN": Key(mcu=McuKey(code=14), usb=UsbKey(code=17, is_modifier=False)),
- "KeyO": Key(mcu=McuKey(code=15), usb=UsbKey(code=18, is_modifier=False)),
- "KeyP": Key(mcu=McuKey(code=16), usb=UsbKey(code=19, is_modifier=False)),
- "KeyQ": Key(mcu=McuKey(code=17), usb=UsbKey(code=20, is_modifier=False)),
- "KeyR": Key(mcu=McuKey(code=18), usb=UsbKey(code=21, is_modifier=False)),
- "KeyS": Key(mcu=McuKey(code=19), usb=UsbKey(code=22, is_modifier=False)),
- "KeyT": Key(mcu=McuKey(code=20), usb=UsbKey(code=23, is_modifier=False)),
- "KeyU": Key(mcu=McuKey(code=21), usb=UsbKey(code=24, is_modifier=False)),
- "KeyV": Key(mcu=McuKey(code=22), usb=UsbKey(code=25, is_modifier=False)),
- "KeyW": Key(mcu=McuKey(code=23), usb=UsbKey(code=26, is_modifier=False)),
- "KeyX": Key(mcu=McuKey(code=24), usb=UsbKey(code=27, is_modifier=False)),
- "KeyY": Key(mcu=McuKey(code=25), usb=UsbKey(code=28, is_modifier=False)),
- "KeyZ": Key(mcu=McuKey(code=26), usb=UsbKey(code=29, is_modifier=False)),
- "Digit1": Key(mcu=McuKey(code=27), usb=UsbKey(code=30, is_modifier=False)),
- "Digit2": Key(mcu=McuKey(code=28), usb=UsbKey(code=31, is_modifier=False)),
- "Digit3": Key(mcu=McuKey(code=29), usb=UsbKey(code=32, is_modifier=False)),
- "Digit4": Key(mcu=McuKey(code=30), usb=UsbKey(code=33, is_modifier=False)),
- "Digit5": Key(mcu=McuKey(code=31), usb=UsbKey(code=34, is_modifier=False)),
- "Digit6": Key(mcu=McuKey(code=32), usb=UsbKey(code=35, is_modifier=False)),
- "Digit7": Key(mcu=McuKey(code=33), usb=UsbKey(code=36, is_modifier=False)),
- "Digit8": Key(mcu=McuKey(code=34), usb=UsbKey(code=37, is_modifier=False)),
- "Digit9": Key(mcu=McuKey(code=35), usb=UsbKey(code=38, is_modifier=False)),
- "Digit0": Key(mcu=McuKey(code=36), usb=UsbKey(code=39, is_modifier=False)),
- "Enter": Key(mcu=McuKey(code=37), usb=UsbKey(code=40, is_modifier=False)),
- "Escape": Key(mcu=McuKey(code=38), usb=UsbKey(code=41, is_modifier=False)),
- "Backspace": Key(mcu=McuKey(code=39), usb=UsbKey(code=42, is_modifier=False)),
- "Tab": Key(mcu=McuKey(code=40), usb=UsbKey(code=43, is_modifier=False)),
- "Space": Key(mcu=McuKey(code=41), usb=UsbKey(code=44, is_modifier=False)),
- "Minus": Key(mcu=McuKey(code=42), usb=UsbKey(code=45, is_modifier=False)),
- "Equal": Key(mcu=McuKey(code=43), usb=UsbKey(code=46, is_modifier=False)),
- "BracketLeft": Key(mcu=McuKey(code=44), usb=UsbKey(code=47, is_modifier=False)),
- "BracketRight": Key(mcu=McuKey(code=45), usb=UsbKey(code=48, is_modifier=False)),
- "Backslash": Key(mcu=McuKey(code=46), usb=UsbKey(code=49, is_modifier=False)),
- "Semicolon": Key(mcu=McuKey(code=47), usb=UsbKey(code=51, is_modifier=False)),
- "Quote": Key(mcu=McuKey(code=48), usb=UsbKey(code=52, is_modifier=False)),
- "Backquote": Key(mcu=McuKey(code=49), usb=UsbKey(code=53, is_modifier=False)),
- "Comma": Key(mcu=McuKey(code=50), usb=UsbKey(code=54, is_modifier=False)),
- "Period": Key(mcu=McuKey(code=51), usb=UsbKey(code=55, is_modifier=False)),
- "Slash": Key(mcu=McuKey(code=52), usb=UsbKey(code=56, is_modifier=False)),
- "CapsLock": Key(mcu=McuKey(code=53), usb=UsbKey(code=57, is_modifier=False)),
- "F1": Key(mcu=McuKey(code=54), usb=UsbKey(code=58, is_modifier=False)),
- "F2": Key(mcu=McuKey(code=55), usb=UsbKey(code=59, is_modifier=False)),
- "F3": Key(mcu=McuKey(code=56), usb=UsbKey(code=60, is_modifier=False)),
- "F4": Key(mcu=McuKey(code=57), usb=UsbKey(code=61, is_modifier=False)),
- "F5": Key(mcu=McuKey(code=58), usb=UsbKey(code=62, is_modifier=False)),
- "F6": Key(mcu=McuKey(code=59), usb=UsbKey(code=63, is_modifier=False)),
- "F7": Key(mcu=McuKey(code=60), usb=UsbKey(code=64, is_modifier=False)),
- "F8": Key(mcu=McuKey(code=61), usb=UsbKey(code=65, is_modifier=False)),
- "F9": Key(mcu=McuKey(code=62), usb=UsbKey(code=66, is_modifier=False)),
- "F10": Key(mcu=McuKey(code=63), usb=UsbKey(code=67, is_modifier=False)),
- "F11": Key(mcu=McuKey(code=64), usb=UsbKey(code=68, is_modifier=False)),
- "F12": Key(mcu=McuKey(code=65), usb=UsbKey(code=69, is_modifier=False)),
- "PrintScreen": Key(mcu=McuKey(code=66), usb=UsbKey(code=70, is_modifier=False)),
- "Insert": Key(mcu=McuKey(code=67), usb=UsbKey(code=73, is_modifier=False)),
- "Home": Key(mcu=McuKey(code=68), usb=UsbKey(code=74, is_modifier=False)),
- "PageUp": Key(mcu=McuKey(code=69), usb=UsbKey(code=75, is_modifier=False)),
- "Delete": Key(mcu=McuKey(code=70), usb=UsbKey(code=76, is_modifier=False)),
- "End": Key(mcu=McuKey(code=71), usb=UsbKey(code=77, is_modifier=False)),
- "PageDown": Key(mcu=McuKey(code=72), usb=UsbKey(code=78, is_modifier=False)),
- "ArrowRight": Key(mcu=McuKey(code=73), usb=UsbKey(code=79, is_modifier=False)),
- "ArrowLeft": Key(mcu=McuKey(code=74), usb=UsbKey(code=80, is_modifier=False)),
- "ArrowDown": Key(mcu=McuKey(code=75), usb=UsbKey(code=81, is_modifier=False)),
- "ArrowUp": Key(mcu=McuKey(code=76), usb=UsbKey(code=82, is_modifier=False)),
- "ControlLeft": Key(mcu=McuKey(code=77), usb=UsbKey(code=1, is_modifier=True)),
- "ShiftLeft": Key(mcu=McuKey(code=78), usb=UsbKey(code=2, is_modifier=True)),
- "AltLeft": Key(mcu=McuKey(code=79), usb=UsbKey(code=4, is_modifier=True)),
- "MetaLeft": Key(mcu=McuKey(code=80), usb=UsbKey(code=8, is_modifier=True)),
- "ControlRight": Key(mcu=McuKey(code=81), usb=UsbKey(code=16, is_modifier=True)),
- "ShiftRight": Key(mcu=McuKey(code=82), usb=UsbKey(code=32, is_modifier=True)),
- "AltRight": Key(mcu=McuKey(code=83), usb=UsbKey(code=64, is_modifier=True)),
- "MetaRight": Key(mcu=McuKey(code=84), usb=UsbKey(code=128, is_modifier=True)),
- "Pause": Key(mcu=McuKey(code=85), usb=UsbKey(code=72, is_modifier=False)),
- "ScrollLock": Key(mcu=McuKey(code=86), usb=UsbKey(code=71, is_modifier=False)),
- "NumLock": Key(mcu=McuKey(code=87), usb=UsbKey(code=83, is_modifier=False)),
- "ContextMenu": Key(mcu=McuKey(code=88), usb=UsbKey(code=101, is_modifier=False)),
- "NumpadDivide": Key(mcu=McuKey(code=89), usb=UsbKey(code=84, is_modifier=False)),
- "NumpadMultiply": Key(mcu=McuKey(code=90), usb=UsbKey(code=85, is_modifier=False)),
- "NumpadSubtract": Key(mcu=McuKey(code=91), usb=UsbKey(code=86, is_modifier=False)),
- "NumpadAdd": Key(mcu=McuKey(code=92), usb=UsbKey(code=87, is_modifier=False)),
- "NumpadEnter": Key(mcu=McuKey(code=93), usb=UsbKey(code=88, is_modifier=False)),
- "Numpad1": Key(mcu=McuKey(code=94), usb=UsbKey(code=89, is_modifier=False)),
- "Numpad2": Key(mcu=McuKey(code=95), usb=UsbKey(code=90, is_modifier=False)),
- "Numpad3": Key(mcu=McuKey(code=96), usb=UsbKey(code=91, is_modifier=False)),
- "Numpad4": Key(mcu=McuKey(code=97), usb=UsbKey(code=92, is_modifier=False)),
- "Numpad5": Key(mcu=McuKey(code=98), usb=UsbKey(code=93, is_modifier=False)),
- "Numpad6": Key(mcu=McuKey(code=99), usb=UsbKey(code=94, is_modifier=False)),
- "Numpad7": Key(mcu=McuKey(code=100), usb=UsbKey(code=95, is_modifier=False)),
- "Numpad8": Key(mcu=McuKey(code=101), usb=UsbKey(code=96, is_modifier=False)),
- "Numpad9": Key(mcu=McuKey(code=102), usb=UsbKey(code=97, is_modifier=False)),
- "Numpad0": Key(mcu=McuKey(code=103), usb=UsbKey(code=98, is_modifier=False)),
- "NumpadDecimal": Key(mcu=McuKey(code=104), usb=UsbKey(code=99, is_modifier=False)),
- "Power": Key(mcu=McuKey(code=105), usb=UsbKey(code=102, is_modifier=False)),
- "IntlBackslash": Key(mcu=McuKey(code=106), usb=UsbKey(code=100, is_modifier=False)),
- "IntlYen": Key(mcu=McuKey(code=107), usb=UsbKey(code=137, is_modifier=False)),
- "IntlRo": Key(mcu=McuKey(code=108), usb=UsbKey(code=135, is_modifier=False)),
- "KanaMode": Key(mcu=McuKey(code=109), usb=UsbKey(code=136, is_modifier=False)),
- "Convert": Key(mcu=McuKey(code=110), usb=UsbKey(code=138, is_modifier=False)),
- "NonConvert": Key(mcu=McuKey(code=111), usb=UsbKey(code=139, is_modifier=False)),
+KEYMAP: dict[int, Key] = {
+ ecodes.KEY_A: Key(mcu=McuKey(code=1), usb=UsbKey(code=4, is_modifier=False)),
+ ecodes.KEY_B: Key(mcu=McuKey(code=2), usb=UsbKey(code=5, is_modifier=False)),
+ ecodes.KEY_C: Key(mcu=McuKey(code=3), usb=UsbKey(code=6, is_modifier=False)),
+ ecodes.KEY_D: Key(mcu=McuKey(code=4), usb=UsbKey(code=7, is_modifier=False)),
+ ecodes.KEY_E: Key(mcu=McuKey(code=5), usb=UsbKey(code=8, is_modifier=False)),
+ ecodes.KEY_F: Key(mcu=McuKey(code=6), usb=UsbKey(code=9, is_modifier=False)),
+ ecodes.KEY_G: Key(mcu=McuKey(code=7), usb=UsbKey(code=10, is_modifier=False)),
+ ecodes.KEY_H: Key(mcu=McuKey(code=8), usb=UsbKey(code=11, is_modifier=False)),
+ ecodes.KEY_I: Key(mcu=McuKey(code=9), usb=UsbKey(code=12, is_modifier=False)),
+ ecodes.KEY_J: Key(mcu=McuKey(code=10), usb=UsbKey(code=13, is_modifier=False)),
+ ecodes.KEY_K: Key(mcu=McuKey(code=11), usb=UsbKey(code=14, is_modifier=False)),
+ ecodes.KEY_L: Key(mcu=McuKey(code=12), usb=UsbKey(code=15, is_modifier=False)),
+ ecodes.KEY_M: Key(mcu=McuKey(code=13), usb=UsbKey(code=16, is_modifier=False)),
+ ecodes.KEY_N: Key(mcu=McuKey(code=14), usb=UsbKey(code=17, is_modifier=False)),
+ ecodes.KEY_O: Key(mcu=McuKey(code=15), usb=UsbKey(code=18, is_modifier=False)),
+ ecodes.KEY_P: Key(mcu=McuKey(code=16), usb=UsbKey(code=19, is_modifier=False)),
+ ecodes.KEY_Q: Key(mcu=McuKey(code=17), usb=UsbKey(code=20, is_modifier=False)),
+ ecodes.KEY_R: Key(mcu=McuKey(code=18), usb=UsbKey(code=21, is_modifier=False)),
+ ecodes.KEY_S: Key(mcu=McuKey(code=19), usb=UsbKey(code=22, is_modifier=False)),
+ ecodes.KEY_T: Key(mcu=McuKey(code=20), usb=UsbKey(code=23, is_modifier=False)),
+ ecodes.KEY_U: Key(mcu=McuKey(code=21), usb=UsbKey(code=24, is_modifier=False)),
+ ecodes.KEY_V: Key(mcu=McuKey(code=22), usb=UsbKey(code=25, is_modifier=False)),
+ ecodes.KEY_W: Key(mcu=McuKey(code=23), usb=UsbKey(code=26, is_modifier=False)),
+ ecodes.KEY_X: Key(mcu=McuKey(code=24), usb=UsbKey(code=27, is_modifier=False)),
+ ecodes.KEY_Y: Key(mcu=McuKey(code=25), usb=UsbKey(code=28, is_modifier=False)),
+ ecodes.KEY_Z: Key(mcu=McuKey(code=26), usb=UsbKey(code=29, is_modifier=False)),
+ ecodes.KEY_1: Key(mcu=McuKey(code=27), usb=UsbKey(code=30, is_modifier=False)),
+ ecodes.KEY_2: Key(mcu=McuKey(code=28), usb=UsbKey(code=31, is_modifier=False)),
+ ecodes.KEY_3: Key(mcu=McuKey(code=29), usb=UsbKey(code=32, is_modifier=False)),
+ ecodes.KEY_4: Key(mcu=McuKey(code=30), usb=UsbKey(code=33, is_modifier=False)),
+ ecodes.KEY_5: Key(mcu=McuKey(code=31), usb=UsbKey(code=34, is_modifier=False)),
+ ecodes.KEY_6: Key(mcu=McuKey(code=32), usb=UsbKey(code=35, is_modifier=False)),
+ ecodes.KEY_7: Key(mcu=McuKey(code=33), usb=UsbKey(code=36, is_modifier=False)),
+ ecodes.KEY_8: Key(mcu=McuKey(code=34), usb=UsbKey(code=37, is_modifier=False)),
+ ecodes.KEY_9: Key(mcu=McuKey(code=35), usb=UsbKey(code=38, is_modifier=False)),
+ ecodes.KEY_0: Key(mcu=McuKey(code=36), usb=UsbKey(code=39, is_modifier=False)),
+ ecodes.KEY_ENTER: Key(mcu=McuKey(code=37), usb=UsbKey(code=40, is_modifier=False)),
+ ecodes.KEY_ESC: Key(mcu=McuKey(code=38), usb=UsbKey(code=41, is_modifier=False)),
+ ecodes.KEY_BACKSPACE: Key(mcu=McuKey(code=39), usb=UsbKey(code=42, is_modifier=False)),
+ ecodes.KEY_TAB: Key(mcu=McuKey(code=40), usb=UsbKey(code=43, is_modifier=False)),
+ ecodes.KEY_SPACE: Key(mcu=McuKey(code=41), usb=UsbKey(code=44, is_modifier=False)),
+ ecodes.KEY_MINUS: Key(mcu=McuKey(code=42), usb=UsbKey(code=45, is_modifier=False)),
+ ecodes.KEY_EQUAL: Key(mcu=McuKey(code=43), usb=UsbKey(code=46, is_modifier=False)),
+ ecodes.KEY_LEFTBRACE: Key(mcu=McuKey(code=44), usb=UsbKey(code=47, is_modifier=False)),
+ ecodes.KEY_RIGHTBRACE: Key(mcu=McuKey(code=45), usb=UsbKey(code=48, is_modifier=False)),
+ ecodes.KEY_BACKSLASH: Key(mcu=McuKey(code=46), usb=UsbKey(code=49, is_modifier=False)),
+ ecodes.KEY_SEMICOLON: Key(mcu=McuKey(code=47), usb=UsbKey(code=51, is_modifier=False)),
+ ecodes.KEY_APOSTROPHE: Key(mcu=McuKey(code=48), usb=UsbKey(code=52, is_modifier=False)),
+ ecodes.KEY_GRAVE: Key(mcu=McuKey(code=49), usb=UsbKey(code=53, is_modifier=False)),
+ ecodes.KEY_COMMA: Key(mcu=McuKey(code=50), usb=UsbKey(code=54, is_modifier=False)),
+ ecodes.KEY_DOT: Key(mcu=McuKey(code=51), usb=UsbKey(code=55, is_modifier=False)),
+ ecodes.KEY_SLASH: Key(mcu=McuKey(code=52), usb=UsbKey(code=56, is_modifier=False)),
+ ecodes.KEY_CAPSLOCK: Key(mcu=McuKey(code=53), usb=UsbKey(code=57, is_modifier=False)),
+ ecodes.KEY_F1: Key(mcu=McuKey(code=54), usb=UsbKey(code=58, is_modifier=False)),
+ ecodes.KEY_F2: Key(mcu=McuKey(code=55), usb=UsbKey(code=59, is_modifier=False)),
+ ecodes.KEY_F3: Key(mcu=McuKey(code=56), usb=UsbKey(code=60, is_modifier=False)),
+ ecodes.KEY_F4: Key(mcu=McuKey(code=57), usb=UsbKey(code=61, is_modifier=False)),
+ ecodes.KEY_F5: Key(mcu=McuKey(code=58), usb=UsbKey(code=62, is_modifier=False)),
+ ecodes.KEY_F6: Key(mcu=McuKey(code=59), usb=UsbKey(code=63, is_modifier=False)),
+ ecodes.KEY_F7: Key(mcu=McuKey(code=60), usb=UsbKey(code=64, is_modifier=False)),
+ ecodes.KEY_F8: Key(mcu=McuKey(code=61), usb=UsbKey(code=65, is_modifier=False)),
+ ecodes.KEY_F9: Key(mcu=McuKey(code=62), usb=UsbKey(code=66, is_modifier=False)),
+ ecodes.KEY_F10: Key(mcu=McuKey(code=63), usb=UsbKey(code=67, is_modifier=False)),
+ ecodes.KEY_F11: Key(mcu=McuKey(code=64), usb=UsbKey(code=68, is_modifier=False)),
+ ecodes.KEY_F12: Key(mcu=McuKey(code=65), usb=UsbKey(code=69, is_modifier=False)),
+ ecodes.KEY_SYSRQ: Key(mcu=McuKey(code=66), usb=UsbKey(code=70, is_modifier=False)),
+ ecodes.KEY_INSERT: Key(mcu=McuKey(code=67), usb=UsbKey(code=73, is_modifier=False)),
+ ecodes.KEY_HOME: Key(mcu=McuKey(code=68), usb=UsbKey(code=74, is_modifier=False)),
+ ecodes.KEY_PAGEUP: Key(mcu=McuKey(code=69), usb=UsbKey(code=75, is_modifier=False)),
+ ecodes.KEY_DELETE: Key(mcu=McuKey(code=70), usb=UsbKey(code=76, is_modifier=False)),
+ ecodes.KEY_END: Key(mcu=McuKey(code=71), usb=UsbKey(code=77, is_modifier=False)),
+ ecodes.KEY_PAGEDOWN: Key(mcu=McuKey(code=72), usb=UsbKey(code=78, is_modifier=False)),
+ ecodes.KEY_RIGHT: Key(mcu=McuKey(code=73), usb=UsbKey(code=79, is_modifier=False)),
+ ecodes.KEY_LEFT: Key(mcu=McuKey(code=74), usb=UsbKey(code=80, is_modifier=False)),
+ ecodes.KEY_DOWN: Key(mcu=McuKey(code=75), usb=UsbKey(code=81, is_modifier=False)),
+ ecodes.KEY_UP: Key(mcu=McuKey(code=76), usb=UsbKey(code=82, is_modifier=False)),
+ ecodes.KEY_LEFTCTRL: Key(mcu=McuKey(code=77), usb=UsbKey(code=1, is_modifier=True)),
+ ecodes.KEY_LEFTSHIFT: Key(mcu=McuKey(code=78), usb=UsbKey(code=2, is_modifier=True)),
+ ecodes.KEY_LEFTALT: Key(mcu=McuKey(code=79), usb=UsbKey(code=4, is_modifier=True)),
+ ecodes.KEY_LEFTMETA: Key(mcu=McuKey(code=80), usb=UsbKey(code=8, is_modifier=True)),
+ ecodes.KEY_RIGHTCTRL: Key(mcu=McuKey(code=81), usb=UsbKey(code=16, is_modifier=True)),
+ ecodes.KEY_RIGHTSHIFT: Key(mcu=McuKey(code=82), usb=UsbKey(code=32, is_modifier=True)),
+ ecodes.KEY_RIGHTALT: Key(mcu=McuKey(code=83), usb=UsbKey(code=64, is_modifier=True)),
+ ecodes.KEY_RIGHTMETA: Key(mcu=McuKey(code=84), usb=UsbKey(code=128, is_modifier=True)),
+ ecodes.KEY_PAUSE: Key(mcu=McuKey(code=85), usb=UsbKey(code=72, is_modifier=False)),
+ ecodes.KEY_SCROLLLOCK: Key(mcu=McuKey(code=86), usb=UsbKey(code=71, is_modifier=False)),
+ ecodes.KEY_NUMLOCK: Key(mcu=McuKey(code=87), usb=UsbKey(code=83, is_modifier=False)),
+ ecodes.KEY_CONTEXT_MENU: Key(mcu=McuKey(code=88), usb=UsbKey(code=101, is_modifier=False)),
+ ecodes.KEY_KPSLASH: Key(mcu=McuKey(code=89), usb=UsbKey(code=84, is_modifier=False)),
+ ecodes.KEY_KPASTERISK: Key(mcu=McuKey(code=90), usb=UsbKey(code=85, is_modifier=False)),
+ ecodes.KEY_KPMINUS: Key(mcu=McuKey(code=91), usb=UsbKey(code=86, is_modifier=False)),
+ ecodes.KEY_KPPLUS: Key(mcu=McuKey(code=92), usb=UsbKey(code=87, is_modifier=False)),
+ ecodes.KEY_KPENTER: Key(mcu=McuKey(code=93), usb=UsbKey(code=88, is_modifier=False)),
+ ecodes.KEY_KP1: Key(mcu=McuKey(code=94), usb=UsbKey(code=89, is_modifier=False)),
+ ecodes.KEY_KP2: Key(mcu=McuKey(code=95), usb=UsbKey(code=90, is_modifier=False)),
+ ecodes.KEY_KP3: Key(mcu=McuKey(code=96), usb=UsbKey(code=91, is_modifier=False)),
+ ecodes.KEY_KP4: Key(mcu=McuKey(code=97), usb=UsbKey(code=92, is_modifier=False)),
+ ecodes.KEY_KP5: Key(mcu=McuKey(code=98), usb=UsbKey(code=93, is_modifier=False)),
+ ecodes.KEY_KP6: Key(mcu=McuKey(code=99), usb=UsbKey(code=94, is_modifier=False)),
+ ecodes.KEY_KP7: Key(mcu=McuKey(code=100), usb=UsbKey(code=95, is_modifier=False)),
+ ecodes.KEY_KP8: Key(mcu=McuKey(code=101), usb=UsbKey(code=96, is_modifier=False)),
+ ecodes.KEY_KP9: Key(mcu=McuKey(code=102), usb=UsbKey(code=97, is_modifier=False)),
+ ecodes.KEY_KP0: Key(mcu=McuKey(code=103), usb=UsbKey(code=98, is_modifier=False)),
+ ecodes.KEY_KPDOT: Key(mcu=McuKey(code=104), usb=UsbKey(code=99, is_modifier=False)),
+ ecodes.KEY_POWER: Key(mcu=McuKey(code=105), usb=UsbKey(code=102, is_modifier=False)),
+ ecodes.KEY_102ND: Key(mcu=McuKey(code=106), usb=UsbKey(code=100, is_modifier=False)),
+ ecodes.KEY_YEN: Key(mcu=McuKey(code=107), usb=UsbKey(code=137, is_modifier=False)),
+ ecodes.KEY_RO: Key(mcu=McuKey(code=108), usb=UsbKey(code=135, is_modifier=False)),
+ ecodes.KEY_KATAKANA: Key(mcu=McuKey(code=109), usb=UsbKey(code=136, is_modifier=False)),
+ ecodes.KEY_HENKAN: Key(mcu=McuKey(code=110), usb=UsbKey(code=138, is_modifier=False)),
+ ecodes.KEY_MUHENKAN: Key(mcu=McuKey(code=111), usb=UsbKey(code=139, is_modifier=False)),
+ ecodes.KEY_MUTE: Key(mcu=McuKey(code=112), usb=UsbKey(code=127, is_modifier=False)),
+ ecodes.KEY_VOLUMEUP: Key(mcu=McuKey(code=113), usb=UsbKey(code=128, is_modifier=False)),
+ ecodes.KEY_VOLUMEDOWN: Key(mcu=McuKey(code=114), usb=UsbKey(code=129, is_modifier=False)),
+ ecodes.KEY_F20: Key(mcu=McuKey(code=115), usb=UsbKey(code=111, is_modifier=False)),
+}
+
+
+WEB_TO_EVDEV = {
+ "KeyA": ecodes.KEY_A,
+ "KeyB": ecodes.KEY_B,
+ "KeyC": ecodes.KEY_C,
+ "KeyD": ecodes.KEY_D,
+ "KeyE": ecodes.KEY_E,
+ "KeyF": ecodes.KEY_F,
+ "KeyG": ecodes.KEY_G,
+ "KeyH": ecodes.KEY_H,
+ "KeyI": ecodes.KEY_I,
+ "KeyJ": ecodes.KEY_J,
+ "KeyK": ecodes.KEY_K,
+ "KeyL": ecodes.KEY_L,
+ "KeyM": ecodes.KEY_M,
+ "KeyN": ecodes.KEY_N,
+ "KeyO": ecodes.KEY_O,
+ "KeyP": ecodes.KEY_P,
+ "KeyQ": ecodes.KEY_Q,
+ "KeyR": ecodes.KEY_R,
+ "KeyS": ecodes.KEY_S,
+ "KeyT": ecodes.KEY_T,
+ "KeyU": ecodes.KEY_U,
+ "KeyV": ecodes.KEY_V,
+ "KeyW": ecodes.KEY_W,
+ "KeyX": ecodes.KEY_X,
+ "KeyY": ecodes.KEY_Y,
+ "KeyZ": ecodes.KEY_Z,
+ "Digit1": ecodes.KEY_1,
+ "Digit2": ecodes.KEY_2,
+ "Digit3": ecodes.KEY_3,
+ "Digit4": ecodes.KEY_4,
+ "Digit5": ecodes.KEY_5,
+ "Digit6": ecodes.KEY_6,
+ "Digit7": ecodes.KEY_7,
+ "Digit8": ecodes.KEY_8,
+ "Digit9": ecodes.KEY_9,
+ "Digit0": ecodes.KEY_0,
+ "Enter": ecodes.KEY_ENTER,
+ "Escape": ecodes.KEY_ESC,
+ "Backspace": ecodes.KEY_BACKSPACE,
+ "Tab": ecodes.KEY_TAB,
+ "Space": ecodes.KEY_SPACE,
+ "Minus": ecodes.KEY_MINUS,
+ "Equal": ecodes.KEY_EQUAL,
+ "BracketLeft": ecodes.KEY_LEFTBRACE,
+ "BracketRight": ecodes.KEY_RIGHTBRACE,
+ "Backslash": ecodes.KEY_BACKSLASH,
+ "Semicolon": ecodes.KEY_SEMICOLON,
+ "Quote": ecodes.KEY_APOSTROPHE,
+ "Backquote": ecodes.KEY_GRAVE,
+ "Comma": ecodes.KEY_COMMA,
+ "Period": ecodes.KEY_DOT,
+ "Slash": ecodes.KEY_SLASH,
+ "CapsLock": ecodes.KEY_CAPSLOCK,
+ "F1": ecodes.KEY_F1,
+ "F2": ecodes.KEY_F2,
+ "F3": ecodes.KEY_F3,
+ "F4": ecodes.KEY_F4,
+ "F5": ecodes.KEY_F5,
+ "F6": ecodes.KEY_F6,
+ "F7": ecodes.KEY_F7,
+ "F8": ecodes.KEY_F8,
+ "F9": ecodes.KEY_F9,
+ "F10": ecodes.KEY_F10,
+ "F11": ecodes.KEY_F11,
+ "F12": ecodes.KEY_F12,
+ "PrintScreen": ecodes.KEY_SYSRQ,
+ "Insert": ecodes.KEY_INSERT,
+ "Home": ecodes.KEY_HOME,
+ "PageUp": ecodes.KEY_PAGEUP,
+ "Delete": ecodes.KEY_DELETE,
+ "End": ecodes.KEY_END,
+ "PageDown": ecodes.KEY_PAGEDOWN,
+ "ArrowRight": ecodes.KEY_RIGHT,
+ "ArrowLeft": ecodes.KEY_LEFT,
+ "ArrowDown": ecodes.KEY_DOWN,
+ "ArrowUp": ecodes.KEY_UP,
+ "ControlLeft": ecodes.KEY_LEFTCTRL,
+ "ShiftLeft": ecodes.KEY_LEFTSHIFT,
+ "AltLeft": ecodes.KEY_LEFTALT,
+ "MetaLeft": ecodes.KEY_LEFTMETA,
+ "ControlRight": ecodes.KEY_RIGHTCTRL,
+ "ShiftRight": ecodes.KEY_RIGHTSHIFT,
+ "AltRight": ecodes.KEY_RIGHTALT,
+ "MetaRight": ecodes.KEY_RIGHTMETA,
+ "Pause": ecodes.KEY_PAUSE,
+ "ScrollLock": ecodes.KEY_SCROLLLOCK,
+ "NumLock": ecodes.KEY_NUMLOCK,
+ "ContextMenu": ecodes.KEY_CONTEXT_MENU,
+ "NumpadDivide": ecodes.KEY_KPSLASH,
+ "NumpadMultiply": ecodes.KEY_KPASTERISK,
+ "NumpadSubtract": ecodes.KEY_KPMINUS,
+ "NumpadAdd": ecodes.KEY_KPPLUS,
+ "NumpadEnter": ecodes.KEY_KPENTER,
+ "Numpad1": ecodes.KEY_KP1,
+ "Numpad2": ecodes.KEY_KP2,
+ "Numpad3": ecodes.KEY_KP3,
+ "Numpad4": ecodes.KEY_KP4,
+ "Numpad5": ecodes.KEY_KP5,
+ "Numpad6": ecodes.KEY_KP6,
+ "Numpad7": ecodes.KEY_KP7,
+ "Numpad8": ecodes.KEY_KP8,
+ "Numpad9": ecodes.KEY_KP9,
+ "Numpad0": ecodes.KEY_KP0,
+ "NumpadDecimal": ecodes.KEY_KPDOT,
+ "Power": ecodes.KEY_POWER,
+ "IntlBackslash": ecodes.KEY_102ND,
+ "IntlYen": ecodes.KEY_YEN,
+ "IntlRo": ecodes.KEY_RO,
+ "KanaMode": ecodes.KEY_KATAKANA,
+ "Convert": ecodes.KEY_HENKAN,
+ "NonConvert": ecodes.KEY_MUHENKAN,
+ "AudioVolumeMute": ecodes.KEY_MUTE,
+ "AudioVolumeUp": ecodes.KEY_VOLUMEUP,
+ "AudioVolumeDown": ecodes.KEY_VOLUMEDOWN,
+ "F20": ecodes.KEY_F20,
}
# =====
-class WebModifiers:
- SHIFT_LEFT = "ShiftLeft"
- SHIFT_RIGHT = "ShiftRight"
+class EvdevModifiers:
+ SHIFT_LEFT = ecodes.KEY_LEFTSHIFT
+ SHIFT_RIGHT = ecodes.KEY_RIGHTSHIFT
SHIFTS = set([SHIFT_LEFT, SHIFT_RIGHT])
- ALT_LEFT = "AltLeft"
- ALT_RIGHT = "AltRight"
+ ALT_LEFT = ecodes.KEY_LEFTALT
+ ALT_RIGHT = ecodes.KEY_RIGHTALT
ALTS = set([ALT_LEFT, ALT_RIGHT])
- CTRL_LEFT = "ControlLeft"
- CTRL_RIGHT = "ControlRight"
+ CTRL_LEFT = ecodes.KEY_LEFTCTRL
+ CTRL_RIGHT = ecodes.KEY_RIGHTCTRL
CTRLS = set([CTRL_LEFT, CTRL_RIGHT])
- META_LEFT = "MetaLeft"
- META_RIGHT = "MetaRight"
+ META_LEFT = ecodes.KEY_LEFTMETA
+ META_RIGHT = ecodes.KEY_RIGHTMETA
METAS = set([META_LEFT, META_RIGHT])
ALL = (SHIFTS | ALTS | CTRLS | METAS)
@@ -192,10 +317,10 @@ class X11Modifiers:
# =====
@dataclasses.dataclass(frozen=True)
class At1Key:
- code: int
+ code: int
shift: bool
altgr: bool = False
- ctrl: bool = False
+ ctrl: bool = False
X11_TO_AT1 = {
@@ -357,116 +482,120 @@ X11_TO_AT1 = {
}
-AT1_TO_WEB = {
- 1: "Escape",
- 2: "Digit1",
- 3: "Digit2",
- 4: "Digit3",
- 5: "Digit4",
- 6: "Digit5",
- 7: "Digit6",
- 8: "Digit7",
- 9: "Digit8",
- 10: "Digit9",
- 11: "Digit0",
- 12: "Minus",
- 13: "Equal",
- 14: "Backspace",
- 15: "Tab",
- 16: "KeyQ",
- 17: "KeyW",
- 18: "KeyE",
- 19: "KeyR",
- 20: "KeyT",
- 21: "KeyY",
- 22: "KeyU",
- 23: "KeyI",
- 24: "KeyO",
- 25: "KeyP",
- 26: "BracketLeft",
- 27: "BracketRight",
- 28: "Enter",
- 29: "ControlLeft",
- 30: "KeyA",
- 31: "KeyS",
- 32: "KeyD",
- 33: "KeyF",
- 34: "KeyG",
- 35: "KeyH",
- 36: "KeyJ",
- 37: "KeyK",
- 38: "KeyL",
- 39: "Semicolon",
- 40: "Quote",
- 41: "Backquote",
- 42: "ShiftLeft",
- 43: "Backslash",
- 44: "KeyZ",
- 45: "KeyX",
- 46: "KeyC",
- 47: "KeyV",
- 48: "KeyB",
- 49: "KeyN",
- 50: "KeyM",
- 51: "Comma",
- 52: "Period",
- 53: "Slash",
- 54: "ShiftRight",
- 55: "NumpadMultiply",
- 56: "AltLeft",
- 57: "Space",
- 58: "CapsLock",
- 59: "F1",
- 60: "F2",
- 61: "F3",
- 62: "F4",
- 63: "F5",
- 64: "F6",
- 65: "F7",
- 66: "F8",
- 67: "F9",
- 68: "F10",
- 69: "NumLock",
- 70: "ScrollLock",
- 71: "Numpad7",
- 72: "Numpad8",
- 73: "Numpad9",
- 74: "NumpadSubtract",
- 75: "Numpad4",
- 76: "Numpad5",
- 77: "Numpad6",
- 78: "NumpadAdd",
- 79: "Numpad1",
- 80: "Numpad2",
- 81: "Numpad3",
- 82: "Numpad0",
- 83: "NumpadDecimal",
- 84: "PrintScreen",
- 86: "IntlBackslash",
- 87: "F11",
- 88: "F12",
- 112: "KanaMode",
- 115: "IntlRo",
- 121: "Convert",
- 123: "NonConvert",
- 125: "IntlYen",
- 57372: "NumpadEnter",
- 57373: "ControlRight",
- 57397: "NumpadDivide",
- 57400: "AltRight",
- 57414: "Pause",
- 57415: "Home",
- 57416: "ArrowUp",
- 57417: "PageUp",
- 57419: "ArrowLeft",
- 57421: "ArrowRight",
- 57423: "End",
- 57424: "ArrowDown",
- 57425: "PageDown",
- 57426: "Insert",
- 57427: "Delete",
- 57435: "MetaLeft",
- 57436: "MetaRight",
- 57437: "ContextMenu",
- 57438: "Power",
+AT1_TO_EVDEV = {
+ 1: ecodes.KEY_ESC,
+ 2: ecodes.KEY_1,
+ 3: ecodes.KEY_2,
+ 4: ecodes.KEY_3,
+ 5: ecodes.KEY_4,
+ 6: ecodes.KEY_5,
+ 7: ecodes.KEY_6,
+ 8: ecodes.KEY_7,
+ 9: ecodes.KEY_8,
+ 10: ecodes.KEY_9,
+ 11: ecodes.KEY_0,
+ 12: ecodes.KEY_MINUS,
+ 13: ecodes.KEY_EQUAL,
+ 14: ecodes.KEY_BACKSPACE,
+ 15: ecodes.KEY_TAB,
+ 16: ecodes.KEY_Q,
+ 17: ecodes.KEY_W,
+ 18: ecodes.KEY_E,
+ 19: ecodes.KEY_R,
+ 20: ecodes.KEY_T,
+ 21: ecodes.KEY_Y,
+ 22: ecodes.KEY_U,
+ 23: ecodes.KEY_I,
+ 24: ecodes.KEY_O,
+ 25: ecodes.KEY_P,
+ 26: ecodes.KEY_LEFTBRACE,
+ 27: ecodes.KEY_RIGHTBRACE,
+ 28: ecodes.KEY_ENTER,
+ 29: ecodes.KEY_LEFTCTRL,
+ 30: ecodes.KEY_A,
+ 31: ecodes.KEY_S,
+ 32: ecodes.KEY_D,
+ 33: ecodes.KEY_F,
+ 34: ecodes.KEY_G,
+ 35: ecodes.KEY_H,
+ 36: ecodes.KEY_J,
+ 37: ecodes.KEY_K,
+ 38: ecodes.KEY_L,
+ 39: ecodes.KEY_SEMICOLON,
+ 40: ecodes.KEY_APOSTROPHE,
+ 41: ecodes.KEY_GRAVE,
+ 42: ecodes.KEY_LEFTSHIFT,
+ 43: ecodes.KEY_BACKSLASH,
+ 44: ecodes.KEY_Z,
+ 45: ecodes.KEY_X,
+ 46: ecodes.KEY_C,
+ 47: ecodes.KEY_V,
+ 48: ecodes.KEY_B,
+ 49: ecodes.KEY_N,
+ 50: ecodes.KEY_M,
+ 51: ecodes.KEY_COMMA,
+ 52: ecodes.KEY_DOT,
+ 53: ecodes.KEY_SLASH,
+ 54: ecodes.KEY_RIGHTSHIFT,
+ 55: ecodes.KEY_KPASTERISK,
+ 56: ecodes.KEY_LEFTALT,
+ 57: ecodes.KEY_SPACE,
+ 58: ecodes.KEY_CAPSLOCK,
+ 59: ecodes.KEY_F1,
+ 60: ecodes.KEY_F2,
+ 61: ecodes.KEY_F3,
+ 62: ecodes.KEY_F4,
+ 63: ecodes.KEY_F5,
+ 64: ecodes.KEY_F6,
+ 65: ecodes.KEY_F7,
+ 66: ecodes.KEY_F8,
+ 67: ecodes.KEY_F9,
+ 68: ecodes.KEY_F10,
+ 69: ecodes.KEY_NUMLOCK,
+ 70: ecodes.KEY_SCROLLLOCK,
+ 71: ecodes.KEY_KP7,
+ 72: ecodes.KEY_KP8,
+ 73: ecodes.KEY_KP9,
+ 74: ecodes.KEY_KPMINUS,
+ 75: ecodes.KEY_KP4,
+ 76: ecodes.KEY_KP5,
+ 77: ecodes.KEY_KP6,
+ 78: ecodes.KEY_KPPLUS,
+ 79: ecodes.KEY_KP1,
+ 80: ecodes.KEY_KP2,
+ 81: ecodes.KEY_KP3,
+ 82: ecodes.KEY_KP0,
+ 83: ecodes.KEY_KPDOT,
+ 84: ecodes.KEY_SYSRQ,
+ 86: ecodes.KEY_102ND,
+ 87: ecodes.KEY_F11,
+ 88: ecodes.KEY_F12,
+ 90: ecodes.KEY_F20,
+ 112: ecodes.KEY_KATAKANA,
+ 115: ecodes.KEY_RO,
+ 121: ecodes.KEY_HENKAN,
+ 123: ecodes.KEY_MUHENKAN,
+ 125: ecodes.KEY_YEN,
+ 57372: ecodes.KEY_KPENTER,
+ 57373: ecodes.KEY_RIGHTCTRL,
+ 57376: ecodes.KEY_MUTE,
+ 57390: ecodes.KEY_VOLUMEDOWN,
+ 57392: ecodes.KEY_VOLUMEUP,
+ 57397: ecodes.KEY_KPSLASH,
+ 57400: ecodes.KEY_RIGHTALT,
+ 57414: ecodes.KEY_PAUSE,
+ 57415: ecodes.KEY_HOME,
+ 57416: ecodes.KEY_UP,
+ 57417: ecodes.KEY_PAGEUP,
+ 57419: ecodes.KEY_LEFT,
+ 57421: ecodes.KEY_RIGHT,
+ 57423: ecodes.KEY_END,
+ 57424: ecodes.KEY_DOWN,
+ 57425: ecodes.KEY_PAGEDOWN,
+ 57426: ecodes.KEY_INSERT,
+ 57427: ecodes.KEY_DELETE,
+ 57435: ecodes.KEY_LEFTMETA,
+ 57436: ecodes.KEY_RIGHTMETA,
+ 57437: ecodes.KEY_CONTEXT_MENU,
+ 57438: ecodes.KEY_POWER,
}
diff --git a/kvmd/keyboard/mappings.py.mako b/kvmd/keyboard/mappings.py.mako
index 1be41854..30397891 100644
--- a/kvmd/keyboard/mappings.py.mako
+++ b/kvmd/keyboard/mappings.py.mako
@@ -22,6 +22,8 @@
import dataclasses
+from evdev import ecodes
+
# =====
@dataclasses.dataclass(frozen=True)
@@ -31,7 +33,7 @@ class McuKey:
@dataclasses.dataclass(frozen=True)
class UsbKey:
- code: int
+ code: int
is_modifier: bool
@@ -41,29 +43,36 @@ class Key:
usb: UsbKey
<%! import operator %>
-KEYMAP: dict[str, Key] = {
+KEYMAP: dict[int, Key] = {
% for km in sorted(keymap, key=operator.attrgetter("mcu_code")):
- "${km.web_name}": Key(mcu=McuKey(code=${km.mcu_code}), usb=UsbKey(code=${km.usb_key.code}, is_modifier=${km.usb_key.is_modifier})),
+ ecodes.${km.evdev_name}: Key(mcu=McuKey(code=${km.mcu_code}), usb=UsbKey(code=${km.usb_key.code}, is_modifier=${km.usb_key.is_modifier})),
+% endfor
+}
+
+
+WEB_TO_EVDEV = {
+% for km in sorted(keymap, key=operator.attrgetter("mcu_code")):
+ "${km.web_name}": ecodes.${km.evdev_name},
% endfor
}
# =====
-class WebModifiers:
- SHIFT_LEFT = "ShiftLeft"
- SHIFT_RIGHT = "ShiftRight"
+class EvdevModifiers:
+ SHIFT_LEFT = ecodes.KEY_LEFTSHIFT
+ SHIFT_RIGHT = ecodes.KEY_RIGHTSHIFT
SHIFTS = set([SHIFT_LEFT, SHIFT_RIGHT])
- ALT_LEFT = "AltLeft"
- ALT_RIGHT = "AltRight"
+ ALT_LEFT = ecodes.KEY_LEFTALT
+ ALT_RIGHT = ecodes.KEY_RIGHTALT
ALTS = set([ALT_LEFT, ALT_RIGHT])
- CTRL_LEFT = "ControlLeft"
- CTRL_RIGHT = "ControlRight"
+ CTRL_LEFT = ecodes.KEY_LEFTCTRL
+ CTRL_RIGHT = ecodes.KEY_RIGHTCTRL
CTRLS = set([CTRL_LEFT, CTRL_RIGHT])
- META_LEFT = "MetaLeft"
- META_RIGHT = "MetaRight"
+ META_LEFT = ecodes.KEY_LEFTMETA
+ META_RIGHT = ecodes.KEY_RIGHTMETA
METAS = set([META_LEFT, META_RIGHT])
ALL = (SHIFTS | ALTS | CTRLS | METAS)
@@ -84,10 +93,10 @@ class X11Modifiers:
# =====
@dataclasses.dataclass(frozen=True)
class At1Key:
- code: int
+ code: int
shift: bool
altgr: bool = False
- ctrl: bool = False
+ ctrl: bool = False
X11_TO_AT1 = {
@@ -99,8 +108,8 @@ X11_TO_AT1 = {
}
-AT1_TO_WEB = {
+AT1_TO_EVDEV = {
% for km in sorted(keymap, key=operator.attrgetter("at1_code")):
- ${km.at1_code}: "${km.web_name}",
+ ${km.at1_code}: ecodes.${km.evdev_name},
% endfor
}
diff --git a/kvmd/keyboard/printer.py b/kvmd/keyboard/printer.py
index efee6d44..35814264 100644
--- a/kvmd/keyboard/printer.py
+++ b/kvmd/keyboard/printer.py
@@ -25,8 +25,9 @@ import ctypes.util
from typing import Generator
+from evdev import ecodes
+
from .keysym import SymmapModifiers
-from .mappings import WebModifiers
# =====
@@ -56,10 +57,10 @@ def _ch_to_keysym(ch: str) -> int:
# =====
-def text_to_web_keys( # pylint: disable=too-many-branches
+def text_to_evdev_keys( # pylint: disable=too-many-branches
text: str,
- symmap: dict[int, dict[int, str]],
-) -> Generator[tuple[str, bool], None, None]:
+ symmap: dict[int, dict[int, int]],
+) -> Generator[tuple[int, bool], None, None]:
shift = False
altgr = False
@@ -68,11 +69,11 @@ def text_to_web_keys( # pylint: disable=too-many-branches
# https://stackoverflow.com/questions/12343987/convert-ascii-character-to-x11-keycode
# https://www.ascii-code.com
if ch == "\n":
- keys = {0: "Enter"}
+ keys = {0: ecodes.KEY_ENTER}
elif ch == "\t":
- keys = {0: "Tab"}
+ keys = {0: ecodes.KEY_TAB}
elif ch == " ":
- keys = {0: "Space"}
+ keys = {0: ecodes.KEY_SPACE}
else:
if ch in ["‚", "‘", "’"]:
ch = "'"
@@ -95,17 +96,17 @@ def text_to_web_keys( # pylint: disable=too-many-branches
continue
if modifiers & SymmapModifiers.SHIFT and not shift:
- yield (WebModifiers.SHIFT_LEFT, True)
+ yield (ecodes.KEY_LEFTSHIFT, True)
shift = True
elif not (modifiers & SymmapModifiers.SHIFT) and shift:
- yield (WebModifiers.SHIFT_LEFT, False)
+ yield (ecodes.KEY_LEFTSHIFT, False)
shift = False
if modifiers & SymmapModifiers.ALTGR and not altgr:
- yield (WebModifiers.ALT_RIGHT, True)
+ yield (ecodes.KEY_RIGHTALT, True)
altgr = True
elif not (modifiers & SymmapModifiers.ALTGR) and altgr:
- yield (WebModifiers.ALT_RIGHT, False)
+ yield (ecodes.KEY_RIGHTALT, False)
altgr = False
yield (key, True)
@@ -113,6 +114,6 @@ def text_to_web_keys( # pylint: disable=too-many-branches
break
if shift:
- yield (WebModifiers.SHIFT_LEFT, False)
+ yield (ecodes.KEY_LEFTSHIFT, False)
if altgr:
- yield (WebModifiers.ALT_RIGHT, False)
+ yield (ecodes.KEY_RIGHTALT, False)
diff --git a/kvmd/mouse.py b/kvmd/mouse.py
index 399c6a33..c02ed1c6 100644
--- a/kvmd/mouse.py
+++ b/kvmd/mouse.py
@@ -20,6 +20,8 @@
# ========================================================================== #
+from evdev import ecodes
+
from . import tools
@@ -46,3 +48,13 @@ class MouseDelta:
@classmethod
def normalize(cls, value: int) -> int:
return min(max(cls.MIN, value), cls.MAX)
+
+
+# =====
+MOUSE_TO_EVDEV = {
+ "left": ecodes.BTN_LEFT,
+ "right": ecodes.BTN_RIGHT,
+ "middle": ecodes.BTN_MIDDLE,
+ "up": ecodes.BTN_BACK,
+ "down": ecodes.BTN_FORWARD,
+}
diff --git a/kvmd/plugins/atx/__init__.py b/kvmd/plugins/atx/__init__.py
index d8bea96d..5f09f29a 100644
--- a/kvmd/plugins/atx/__init__.py
+++ b/kvmd/plugins/atx/__init__.py
@@ -54,7 +54,8 @@ class BaseAtx(BasePlugin):
async def poll_state(self) -> AsyncGenerator[dict, None]:
# ==== Granularity table ====
# - enabled -- Full
- # - busy -- Partial
+ # - busy -- Partial, follows with acts
+ # - acts -- Partial, follows with busy
# - leds -- Partial
# ===========================
diff --git a/kvmd/plugins/atx/disabled.py b/kvmd/plugins/atx/disabled.py
index 60c2fa5b..a829acca 100644
--- a/kvmd/plugins/atx/disabled.py
+++ b/kvmd/plugins/atx/disabled.py
@@ -43,6 +43,10 @@ class Plugin(BaseAtx):
return {
"enabled": False,
"busy": False,
+ "acts": {
+ "power": False,
+ "reset": False,
+ },
"leds": {
"power": False,
"hdd": False,
diff --git a/kvmd/plugins/atx/gpio.py b/kvmd/plugins/atx/gpio.py
index 578d2717..cfa7660f 100644
--- a/kvmd/plugins/atx/gpio.py
+++ b/kvmd/plugins/atx/gpio.py
@@ -75,7 +75,8 @@ class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
self.__long_click_delay = long_click_delay
self.__notifier = aiotools.AioNotifier()
- self.__region = aiotools.AioExclusiveRegion(AtxIsBusyError, self.__notifier)
+ self.__power_region = aiotools.AioExclusiveRegion(AtxIsBusyError, self.__notifier)
+ self.__reset_region = aiotools.AioExclusiveRegion(AtxIsBusyError, self.__notifier)
self.__line_req: (gpiod.LineRequest | None) = None
@@ -122,9 +123,15 @@ class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
)
async def get_state(self) -> dict:
+ power_busy = self.__power_region.is_busy()
+ reset_busy = self.__reset_region.is_busy()
return {
"enabled": True,
- "busy": self.__region.is_busy(),
+ "busy": (power_busy or reset_busy),
+ "acts": {
+ "power": power_busy,
+ "reset": reset_busy,
+ },
"leds": {
"power": self.__reader.get(self.__power_led_pin),
"hdd": self.__reader.get(self.__hdd_led_pin),
@@ -175,13 +182,13 @@ class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
# =====
async def click_power(self, wait: bool) -> None:
- await self.__click("power", self.__power_switch_pin, self.__click_delay, wait)
+ await self.__click("power", self.__power_region, self.__power_switch_pin, self.__click_delay, wait)
async def click_power_long(self, wait: bool) -> None:
- await self.__click("power_long", self.__power_switch_pin, self.__long_click_delay, wait)
+ await self.__click("power_long", self.__power_region, self.__power_switch_pin, self.__long_click_delay, wait)
async def click_reset(self, wait: bool) -> None:
- await self.__click("reset", self.__reset_switch_pin, self.__click_delay, wait)
+ await self.__click("reset", self.__reset_region, self.__reset_switch_pin, self.__click_delay, wait)
# =====
@@ -189,14 +196,14 @@ class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
return (await self.get_state())["leds"]["power"]
@aiotools.atomic_fg
- async def __click(self, name: str, pin: int, delay: float, wait: bool) -> None:
+ async def __click(self, name: str, region: aiotools.AioExclusiveRegion, pin: int, delay: float, wait: bool) -> None:
if wait:
- with self.__region:
+ with region:
await self.__inner_click(name, pin, delay)
else:
await aiotools.run_region_task(
f"Can't perform ATX {name} click or operation was not completed",
- self.__region, self.__inner_click, name, pin, delay,
+ region, self.__inner_click, name, pin, delay,
)
@aiotools.atomic_fg
diff --git a/testenv/tests/keyboard/test_keymap.py b/kvmd/plugins/auth/forbidden.py
similarity index 87%
rename from testenv/tests/keyboard/test_keymap.py
rename to kvmd/plugins/auth/forbidden.py
index 8c5b3312..c4830c7c 100644
--- a/testenv/tests/keyboard/test_keymap.py
+++ b/kvmd/plugins/auth/forbidden.py
@@ -20,16 +20,12 @@
# ========================================================================== #
-import pytest
-
-from kvmd.keyboard.mappings import KEYMAP
+from . import BaseAuthService
# =====
-def test_ok__keymap() -> None:
- assert KEYMAP["KeyA"].mcu.code == 1
-
-
-def test_fail__keymap() -> None:
- with pytest.raises(KeyError):
- print(KEYMAP["keya"])
+class Plugin(BaseAuthService):
+ async def authorize(self, user: str, passwd: str) -> bool:
+ _ = user
+ _ = passwd
+ return False
diff --git a/kvmd/plugins/auth/htpasswd.py b/kvmd/plugins/auth/htpasswd.py
index 64fe2d3f..1c54060a 100644
--- a/kvmd/plugins/auth/htpasswd.py
+++ b/kvmd/plugins/auth/htpasswd.py
@@ -20,12 +20,12 @@
# ========================================================================== #
-import passlib.apache
-
from ...yamlconf import Option
from ...validators.os import valid_abs_file
+from ...crypto import KvmdHtpasswdFile
+
from . import BaseAuthService
@@ -43,5 +43,5 @@ class Plugin(BaseAuthService):
async def authorize(self, user: str, passwd: str) -> bool:
assert user == user.strip()
assert user
- htpasswd = passlib.apache.HtpasswdFile(self.__path)
+ htpasswd = KvmdHtpasswdFile(self.__path)
return htpasswd.check_password(user, passwd)
diff --git a/kvmd/plugins/hid/__init__.py b/kvmd/plugins/hid/__init__.py
index a385023a..c376cbc0 100644
--- a/kvmd/plugins/hid/__init__.py
+++ b/kvmd/plugins/hid/__init__.py
@@ -29,6 +29,8 @@ from typing import Callable
from typing import AsyncGenerator
from typing import Any
+from evdev import ecodes
+
from ...yamlconf import Option
from ...validators.basic import valid_bool
@@ -37,7 +39,8 @@ from ...validators.basic import valid_string_list
from ...validators.hid import valid_hid_key
from ...validators.hid import valid_hid_mouse_move
-from ...keyboard.mappings import WebModifiers
+from ...keyboard.mappings import WEB_TO_EVDEV
+from ...keyboard.mappings import EvdevModifiers
from ...mouse import MouseRange
from .. import BasePlugin
@@ -60,7 +63,7 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes
jiggler_interval: int,
) -> None:
- self.__ignore_keys = ignore_keys
+ self.__ignore_keys = [WEB_TO_EVDEV[key] for key in ignore_keys]
self.__mouse_x_range = (mouse_x_min, mouse_x_max)
self.__mouse_y_range = (mouse_y_min, mouse_y_max)
@@ -69,7 +72,7 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes
self.__j_active = jiggler_active
self.__j_interval = jiggler_interval
self.__j_absolute = True
- self.__j_activity_ts = 0
+ self.__j_activity_ts = self.__get_monotonic_seconds()
self.__j_last_x = 0
self.__j_last_y = 0
@@ -140,37 +143,42 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes
# =====
+ def get_inactivity_seconds(self) -> int:
+ return (self.__get_monotonic_seconds() - self.__j_activity_ts)
+
+ # =====
+
async def send_key_events(
self,
- keys: Iterable[tuple[str, bool]],
+ keys: Iterable[tuple[int, bool]],
no_ignore_keys: bool=False,
- slow: bool=False,
+ delay: float=0.0,
) -> None:
for (key, state) in keys:
if no_ignore_keys or key not in self.__ignore_keys:
- if slow:
- await asyncio.sleep(0.02)
+ if delay > 0:
+ await asyncio.sleep(delay)
self.send_key_event(key, state, False)
- def send_key_event(self, key: str, state: bool, finish: bool) -> None:
+ def send_key_event(self, key: int, state: bool, finish: bool) -> None:
self._send_key_event(key, state)
- if state and finish and (key not in WebModifiers.ALL and key != "PrintScreen"):
+ if state and finish and (key not in EvdevModifiers.ALL and key != ecodes.KEY_SYSRQ):
# Считаем что PrintScreen это модификатор для Alt+SysRq+...
# По-хорошему надо учитывать факт нажатия на Alt, но можно и забить.
self._send_key_event(key, False)
self.__bump_activity()
- def _send_key_event(self, key: str, state: bool) -> None:
+ def _send_key_event(self, key: int, state: bool) -> None:
raise NotImplementedError
# =====
- def send_mouse_button_event(self, button: str, state: bool) -> None:
+ def send_mouse_button_event(self, button: int, state: bool) -> None:
self._send_mouse_button_event(button, state)
self.__bump_activity()
- def _send_mouse_button_event(self, button: str, state: bool) -> None:
+ def _send_mouse_button_event(self, button: int, state: bool) -> None:
raise NotImplementedError
# =====
@@ -246,7 +254,10 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes
handler(*xy)
def __bump_activity(self) -> None:
- self.__j_activity_ts = int(time.monotonic())
+ self.__j_activity_ts = self.__get_monotonic_seconds()
+
+ def __get_monotonic_seconds(self) -> int:
+ return int(time.monotonic())
def _set_jiggler_absolute(self, absolute: bool) -> None:
self.__j_absolute = absolute
@@ -268,14 +279,14 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes
async def systask(self) -> None:
while True:
- if self.__j_active and (self.__j_activity_ts + self.__j_interval < int(time.monotonic())):
+ if self.__j_active and (self.__j_activity_ts + self.__j_interval < self.__get_monotonic_seconds()):
if self.__j_absolute:
(x, y) = (self.__j_last_x, self.__j_last_y)
- for move in [100, -100, 100, -100, 0]:
+ for move in (([100, -100] * 5) + [0]):
self.send_mouse_move_event(MouseRange.normalize(x + move), MouseRange.normalize(y + move))
await asyncio.sleep(0.1)
else:
- for move in [10, -10, 10, -10]:
+ for move in ([10, -10] * 5):
self.send_mouse_relative_event(move, move)
await asyncio.sleep(0.1)
await asyncio.sleep(1)
diff --git a/kvmd/plugins/hid/_mcu/__init__.py b/kvmd/plugins/hid/_mcu/__init__.py
index d6f04f76..dc681b68 100644
--- a/kvmd/plugins/hid/_mcu/__init__.py
+++ b/kvmd/plugins/hid/_mcu/__init__.py
@@ -285,10 +285,10 @@ class BaseMcuHid(BaseHid, multiprocessing.Process): # pylint: disable=too-many-
def set_connected(self, connected: bool) -> None:
self.__queue_event(SetConnectedEvent(connected), clear=True)
- def _send_key_event(self, key: str, state: bool) -> None:
+ def _send_key_event(self, key: int, state: bool) -> None:
self.__queue_event(KeyEvent(key, state))
- def _send_mouse_button_event(self, button: str, state: bool) -> None:
+ def _send_mouse_button_event(self, button: int, state: bool) -> None:
self.__queue_event(MouseButtonEvent(button, state))
def _send_mouse_move_event(self, to_x: int, to_y: int) -> None:
diff --git a/kvmd/plugins/hid/_mcu/gpio.py b/kvmd/plugins/hid/_mcu/gpio.py
index ce1d678b..ab401582 100644
--- a/kvmd/plugins/hid/_mcu/gpio.py
+++ b/kvmd/plugins/hid/_mcu/gpio.py
@@ -68,7 +68,7 @@ class Gpio: # pylint: disable=too-many-instance-attributes
self.__line_req = gpiod.request_lines(
self.__device_path,
consumer="kvmd::hid",
- config=config,
+ config=config, # type: ignore
)
def __exit__(
diff --git a/kvmd/plugins/hid/_mcu/proto.py b/kvmd/plugins/hid/_mcu/proto.py
index 315c1f89..2ae03b83 100644
--- a/kvmd/plugins/hid/_mcu/proto.py
+++ b/kvmd/plugins/hid/_mcu/proto.py
@@ -23,6 +23,8 @@
import dataclasses
import struct
+from evdev import ecodes
+
from ....keyboard.mappings import KEYMAP
from ....mouse import MouseRange
@@ -106,33 +108,36 @@ class ClearEvent(BaseEvent):
@dataclasses.dataclass(frozen=True)
class KeyEvent(BaseEvent):
- name: str
+ code: int
state: bool
def __post_init__(self) -> None:
- assert self.name in KEYMAP
+ assert self.code in KEYMAP
def make_request(self) -> bytes:
- code = KEYMAP[self.name].mcu.code
+ code = KEYMAP[self.code].mcu.code
return _make_request(struct.pack(">BBBxx", 0x11, code, int(self.state)))
@dataclasses.dataclass(frozen=True)
class MouseButtonEvent(BaseEvent):
- name: str
+ code: int
state: bool
def __post_init__(self) -> None:
- assert self.name in ["left", "right", "middle", "up", "down"]
+ assert self.code in [
+ ecodes.BTN_LEFT, ecodes.BTN_RIGHT, ecodes.BTN_MIDDLE,
+ ecodes.BTN_BACK, ecodes.BTN_FORWARD,
+ ]
def make_request(self) -> bytes:
(code, state_pressed, is_main) = {
- "left": (0b10000000, 0b00001000, True),
- "right": (0b01000000, 0b00000100, True),
- "middle": (0b00100000, 0b00000010, True),
- "up": (0b10000000, 0b00001000, False), # Back
- "down": (0b01000000, 0b00000100, False), # Forward
- }[self.name]
+ ecodes.BTN_LEFT: (0b10000000, 0b00001000, True),
+ ecodes.BTN_RIGHT: (0b01000000, 0b00000100, True),
+ ecodes.BTN_MIDDLE: (0b00100000, 0b00000010, True),
+ ecodes.BTN_BACK: (0b10000000, 0b00001000, False), # Up
+ ecodes.BTN_FORWARD: (0b01000000, 0b00000100, False), # Down
+ }[self.code]
if self.state:
code |= state_pressed
if is_main:
diff --git a/kvmd/plugins/hid/bt/__init__.py b/kvmd/plugins/hid/bt/__init__.py
index 0c95a6d5..246e3a8a 100644
--- a/kvmd/plugins/hid/bt/__init__.py
+++ b/kvmd/plugins/hid/bt/__init__.py
@@ -203,10 +203,10 @@ class Plugin(BaseHid): # pylint: disable=too-many-instance-attributes
self._set_jiggler_active(jiggler)
self.__notifier.notify()
- def _send_key_event(self, key: str, state: bool) -> None:
+ def _send_key_event(self, key: int, state: bool) -> None:
self.__server.queue_event(make_keyboard_event(key, state))
- def _send_mouse_button_event(self, button: str, state: bool) -> None:
+ def _send_mouse_button_event(self, button: int, state: bool) -> None:
self.__server.queue_event(MouseButtonEvent(button, state))
def _send_mouse_relative_event(self, delta_x: int, delta_y: int) -> None:
diff --git a/kvmd/plugins/hid/ch9329/__init__.py b/kvmd/plugins/hid/ch9329/__init__.py
index 1b235090..c90ebeba 100644
--- a/kvmd/plugins/hid/ch9329/__init__.py
+++ b/kvmd/plugins/hid/ch9329/__init__.py
@@ -168,10 +168,10 @@ class Plugin(BaseHid, multiprocessing.Process): # pylint: disable=too-many-inst
self._set_jiggler_active(jiggler)
self.__notifier.notify()
- def _send_key_event(self, key: str, state: bool) -> None:
+ def _send_key_event(self, key: int, state: bool) -> None:
self.__queue_cmd(self.__keyboard.process_key(key, state))
- def _send_mouse_button_event(self, button: str, state: bool) -> None:
+ def _send_mouse_button_event(self, button: int, state: bool) -> None:
self.__queue_cmd(self.__mouse.process_button(button, state))
def _send_mouse_move_event(self, to_x: int, to_y: int) -> None:
@@ -232,7 +232,11 @@ class Plugin(BaseHid, multiprocessing.Process): # pylint: disable=too-many-inst
led_byte = conn.xfer(cmd)
except ChipResponseError as ex:
self.__set_state_online(False)
- get_logger(0).error("Invalid chip response: %s", tools.efmt(ex))
+ get_logger(0).error("Invalid chip response: %s,%s", self.__device_path, tools.efmt(ex))
+ try:
+ conn.xfer(b"\x00\x0F\x00")
+ except Exception:
+ return False
time.sleep(2)
else:
if led_byte >= 0:
diff --git a/kvmd/plugins/hid/ch9329/keyboard.py b/kvmd/plugins/hid/ch9329/keyboard.py
index 9aa16d9d..6d56aab6 100644
--- a/kvmd/plugins/hid/ch9329/keyboard.py
+++ b/kvmd/plugins/hid/ch9329/keyboard.py
@@ -46,7 +46,7 @@ class Keyboard:
async def get_leds(self) -> dict[str, bool]:
return (await self.__leds.get())
- def process_key(self, key: str, state: bool) -> bytes:
+ def process_key(self, key: int, state: bool) -> bytes:
code = KEYMAP[key].usb.code
is_modifier = KEYMAP[key].usb.is_modifier
if state:
diff --git a/kvmd/plugins/hid/ch9329/mouse.py b/kvmd/plugins/hid/ch9329/mouse.py
index d61bab4e..fb620f8a 100644
--- a/kvmd/plugins/hid/ch9329/mouse.py
+++ b/kvmd/plugins/hid/ch9329/mouse.py
@@ -22,6 +22,8 @@
import math
+from evdev import ecodes
+
from ....mouse import MouseRange
from ....mouse import MouseDelta
@@ -43,18 +45,18 @@ class Mouse: # pylint: disable=too-many-instance-attributes
def is_absolute(self) -> bool:
return self.__absolute
- def process_button(self, button: str, state: bool) -> bytes:
+ def process_button(self, button: int, state: bool) -> bytes:
code = 0x00
match button:
- case "left":
+ case ecodes.BTN_LEFT:
code = 0x01
- case "right":
+ case ecodes.BTN_RIGHT:
code = 0x02
- case "middle":
+ case ecodes.BTN_MIDDLE:
code = 0x04
- case "up":
+ case ecodes.BTN_BACK:
code = 0x08
- case "down":
+ case ecodes.BTN_FORWARD:
code = 0x10
if code:
if state:
diff --git a/kvmd/plugins/hid/otg/__init__.py b/kvmd/plugins/hid/otg/__init__.py
index 25424257..bf615fdf 100644
--- a/kvmd/plugins/hid/otg/__init__.py
+++ b/kvmd/plugins/hid/otg/__init__.py
@@ -110,7 +110,7 @@ class Plugin(BaseHid): # pylint: disable=too-many-instance-attributes
"horizontal_wheel": Option(True, type=valid_bool),
},
"mouse_alt": {
- "device": Option("", type=valid_abs_path, if_empty="", unpack_as="device_path"),
+ "device": Option("/dev/kvmd-hid-mouse-alt", type=valid_abs_path, if_empty="", unpack_as="device_path"),
"select_timeout": Option(0.1, type=valid_float_f01),
"queue_timeout": Option(0.1, type=valid_float_f01),
"write_retries": Option(150, type=valid_int_f1),
@@ -206,10 +206,10 @@ class Plugin(BaseHid): # pylint: disable=too-many-instance-attributes
self._set_jiggler_active(jiggler)
self.__notifier.notify()
- def _send_key_event(self, key: str, state: bool) -> None:
+ def _send_key_event(self, key: int, state: bool) -> None:
self.__keyboard_proc.send_key_event(key, state)
- def _send_mouse_button_event(self, button: str, state: bool) -> None:
+ def _send_mouse_button_event(self, button: int, state: bool) -> None:
self.__mouse_current.send_button_event(button, state)
def _send_mouse_move_event(self, to_x: int, to_y: int) -> None:
diff --git a/kvmd/plugins/hid/otg/events.py b/kvmd/plugins/hid/otg/events.py
index 44f5e373..6ee23c67 100644
--- a/kvmd/plugins/hid/otg/events.py
+++ b/kvmd/plugins/hid/otg/events.py
@@ -23,6 +23,8 @@
import struct
import dataclasses
+from evdev import ecodes
+
from ....keyboard.mappings import UsbKey
from ....keyboard.mappings import KEYMAP
@@ -46,7 +48,7 @@ class ResetEvent(BaseEvent):
# =====
@dataclasses.dataclass(frozen=True)
class KeyEvent(BaseEvent):
- key: UsbKey
+ key: UsbKey
state: bool
def __post_init__(self) -> None:
@@ -56,13 +58,13 @@ class KeyEvent(BaseEvent):
@dataclasses.dataclass(frozen=True)
class ModifierEvent(BaseEvent):
modifier: UsbKey
- state: bool
+ state: bool
def __post_init__(self) -> None:
assert self.modifier.is_modifier
-def make_keyboard_event(key: str, state: bool) -> (KeyEvent | ModifierEvent):
+def make_keyboard_event(key: int, state: bool) -> (KeyEvent | ModifierEvent):
usb_key = KEYMAP[key].usb
if usb_key.is_modifier:
return ModifierEvent(usb_key, state)
@@ -102,17 +104,17 @@ def make_keyboard_report(
# =====
@dataclasses.dataclass(frozen=True)
class MouseButtonEvent(BaseEvent):
- button: str
- state: bool
- code: int = 0
+ button: int
+ state: bool
+ code: int = 0
def __post_init__(self) -> None:
object.__setattr__(self, "code", {
- "left": 0x1,
- "right": 0x2,
- "middle": 0x4,
- "up": 0x8, # Back
- "down": 0x10, # Forward
+ ecodes.BTN_LEFT: 0x1,
+ ecodes.BTN_RIGHT: 0x2,
+ ecodes.BTN_MIDDLE: 0x4,
+ ecodes.BTN_BACK: 0x8, # Back/Up
+ ecodes.BTN_FORWARD: 0x10, # Forward/Down
}[self.button])
diff --git a/kvmd/plugins/hid/otg/keyboard.py b/kvmd/plugins/hid/otg/keyboard.py
index e82d95a3..23dc05d7 100644
--- a/kvmd/plugins/hid/otg/keyboard.py
+++ b/kvmd/plugins/hid/otg/keyboard.py
@@ -67,7 +67,7 @@ class KeyboardProcess(BaseDeviceProcess):
self._clear_queue()
self._queue_event(ResetEvent())
- def send_key_event(self, key: str, state: bool) -> None:
+ def send_key_event(self, key: int, state: bool) -> None:
self._queue_event(make_keyboard_event(key, state))
# =====
diff --git a/kvmd/plugins/hid/otg/mouse.py b/kvmd/plugins/hid/otg/mouse.py
index ae8d53b0..24dc9bf1 100644
--- a/kvmd/plugins/hid/otg/mouse.py
+++ b/kvmd/plugins/hid/otg/mouse.py
@@ -85,7 +85,7 @@ class MouseProcess(BaseDeviceProcess):
self._clear_queue()
self._queue_event(ResetEvent())
- def send_button_event(self, button: str, state: bool) -> None:
+ def send_button_event(self, button: int, state: bool) -> None:
self._queue_event(MouseButtonEvent(button, state))
def send_move_event(self, to_x: int, to_y: int) -> None:
diff --git a/kvmd/plugins/hid/spi.py b/kvmd/plugins/hid/spi.py
index cf58b5de..6580ba37 100644
--- a/kvmd/plugins/hid/spi.py
+++ b/kvmd/plugins/hid/spi.py
@@ -153,7 +153,7 @@ class _SpiPhy(BasePhy): # pylint: disable=too-many-instance-attributes
)
@contextlib.contextmanager
- def __sw_cs_connected(self) -> Generator[(Callable[[bool], bool] | None), None, None]:
+ def __sw_cs_connected(self) -> Generator[(Callable[[bool], None] | None), None, None]:
if self.__sw_cs_pin > 0:
with gpiod.request_lines(
self.__gpio_device_path,
diff --git a/kvmd/plugins/msd/__init__.py b/kvmd/plugins/msd/__init__.py
index 0b750275..f01a4563 100644
--- a/kvmd/plugins/msd/__init__.py
+++ b/kvmd/plugins/msd/__init__.py
@@ -157,7 +157,7 @@ class BaseMsd(BasePlugin):
async def set_connected(self, connected: bool) -> None:
raise NotImplementedError()
-
+
async def make_image(self, zipped: bool) -> None:
raise NotImplementedError()
diff --git a/kvmd/plugins/msd/disabled.py b/kvmd/plugins/msd/disabled.py
index b9f14f6e..5f424709 100644
--- a/kvmd/plugins/msd/disabled.py
+++ b/kvmd/plugins/msd/disabled.py
@@ -77,6 +77,9 @@ class Plugin(BaseMsd):
async def set_connected(self, connected: bool) -> None:
raise MsdDisabledError()
+ async def make_image(self, zipped: bool) -> None:
+ raise MsdDisabledError()
+
@contextlib.asynccontextmanager
async def read_image(self, name: str) -> AsyncGenerator[BaseMsdReader, None]:
if self is not None: # XXX: Vulture and pylint hack
diff --git a/kvmd/plugins/msd/otg/__init__.py b/kvmd/plugins/msd/otg/__init__.py
index defe0dd3..9749894e 100644
--- a/kvmd/plugins/msd/otg/__init__.py
+++ b/kvmd/plugins/msd/otg/__init__.py
@@ -25,7 +25,6 @@ import asyncio
import contextlib
import dataclasses
import functools
-import time
import os
import copy
import pyfatfs
@@ -303,7 +302,7 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
self.__drive.set_rw_flag(self.__state.vd.rw)
self.__drive.set_cdrom_flag(self.__state.vd.cdrom)
- #reset UDC to fix otg cd-rom and flash switch
+ # reset UDC to fix otg cd-rom and flash switch
try:
udc_path = self.__drive.get_udc_path()
with open(udc_path) as file:
@@ -313,7 +312,7 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
file.write("\n")
with open(udc_path, "w") as file:
file.write(sorted(os.listdir("/sys/class/udc"))[0])
- except:
+ except Exception:
logger = get_logger(0)
logger.error("Can't reset UDC")
if self.__state.vd.rw:
@@ -329,69 +328,7 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
@aiotools.atomic_fg
async def make_image(self, zipped: bool) -> None:
- #Note: img size >= 64M
- def create_fat_image(img_size: int, file_img_path: str, source_dir: str, fat_type: int = 32, label: str = 'One-KVM'):
- def add_directory_to_fat(fat: str, src_path: str, dst_path: str):
- for item in os.listdir(src_path):
- src_item_path = os.path.join(src_path, item)
- dst_item_path = os.path.join(dst_path, item)
-
- if os.path.isdir(src_item_path):
- fat.makedir(dst_item_path)
- add_directory_to_fat(fat, src_item_path, dst_item_path)
- elif os.path.isfile(src_item_path):
- with open(src_item_path, 'rb') as src_file:
- fat.create(dst_item_path)
- with fat.open(dst_item_path, 'wb') as dst_file:
- dst_file.write(src_file.read())
- print(file_img_path)
- with open(file_img_path, 'wb') as f:
- f.seek(img_size * 1024 *1024 - 1)
- f.write(b'\0')
- fat_file = pyfatfs.PyFat.PyFat()
- try:
- fat_file.mkfs(file_img_path, fat_type = fat_type, label = label)
- except Exception as e:
- get_logger(0).exception(f"Error making FAT Filesystem: {e}")
- finally:
- fat_file.close()
- fat_handle = pyfatfs.PyFatFS.PyFatFS(file_img_path)
- try:
- add_directory_to_fat(fat_handle, source_dir, '/')
- except Exception as e:
- get_logger(0).exception(f"Error adding directory to FAT image: {e}")
- finally:
- fat_handle.close()
-
- def extract_fat_image(file_img_path: str, output_dir: str):
- try:
- for root, dirs, files in os.walk(output_dir, topdown=False):
- for name in files:
- os.remove(os.path.join(root, name))
- for name in dirs:
- os.rmdir(os.path.join(root, name))
- except Exception as e:
- get_logger(0).exception(f"Error removing normal file or directory: {e}")
- fat_handle = pyfatfs.PyFatFS.PyFatFS(file_img_path)
- try:
- def extract_directory(fat_handle, src_path: str, dst_path: str):
- for entry in fat_handle.listdir(src_path):
- src_item_path = os.path.join(src_path, entry)
- dst_item_path = os.path.join(dst_path, entry)
-
- if fat_handle.gettype(src_item_path) is pyfatfs.PyFatFS.ResourceType.directory:
- os.makedirs(dst_item_path, exist_ok=True)
- extract_directory(fat_handle, src_item_path, dst_item_path)
- else:
- with fat_handle.open(src_item_path, 'rb') as src_file:
- with open(dst_item_path, 'wb') as dst_file:
- dst_file.write(src_file.read())
- extract_directory(fat_handle, '/', output_dir)
- except Exception as e:
- get_logger(0).exception(f"Error extracting FAT image: {e}")
- finally:
- fat_handle.close()
-
+ # Note: img size >= 64M
async with self.__state.busy():
msd_path = self.__msd_path
file_storage_path = os.path.join(msd_path, self.__normalfiles_path)
@@ -402,10 +339,74 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
os.makedirs(file_storage_path)
if os.path.exists(file_img_path):
os.remove(file_img_path)
- create_fat_image(img_size, file_img_path, file_storage_path)
+ self._create_fat_image(img_size, file_img_path, file_storage_path)
else:
if os.path.exists(file_img_path):
- extract_fat_image(file_img_path, file_storage_path)
+ self._extract_fat_image(file_img_path, file_storage_path)
+
+ def _create_fat_image(self, img_size: int, file_img_path: str, source_dir: str, fat_type: int = 32, label: str = "One-KVM") -> None:
+ print(file_img_path)
+ with open(file_img_path, "wb") as file_handle:
+ file_handle.seek(img_size * 1024 * 1024 - 1)
+ file_handle.write(b"\0")
+ fat_file = pyfatfs.PyFat.PyFat()
+ try:
+ fat_file.mkfs(file_img_path, fat_type=fat_type, label=label)
+ except Exception as exception:
+ get_logger(0).exception("Error making FAT Filesystem: %s", exception)
+ finally:
+ fat_file.close()
+ fat_handle = pyfatfs.PyFatFS.PyFatFS(file_img_path)
+ try:
+ self._add_directory_to_fat(fat_handle, source_dir, "/")
+ except Exception as exception:
+ get_logger(0).exception("Error adding directory to FAT image: %s", exception)
+ finally:
+ fat_handle.close()
+
+ def _add_directory_to_fat(self, fat, src_path: str, dst_path: str) -> None: # type: ignore
+ for item in os.listdir(src_path):
+ src_item_path = os.path.join(src_path, item)
+ dst_item_path = os.path.join(dst_path, item)
+
+ if os.path.isdir(src_item_path):
+ fat.makedir(dst_item_path) # type: ignore
+ self._add_directory_to_fat(fat, src_item_path, dst_item_path)
+ elif os.path.isfile(src_item_path):
+ with open(src_item_path, "rb") as src_file:
+ fat.create(dst_item_path) # type: ignore
+ with fat.open(dst_item_path, "wb") as dst_file: # type: ignore
+ dst_file.write(src_file.read())
+
+ def _extract_fat_image(self, file_img_path: str, output_dir: str) -> None:
+ try:
+ for root, dirs, files in os.walk(output_dir, topdown=False):
+ for name in files:
+ os.remove(os.path.join(root, name))
+ for name in dirs:
+ os.rmdir(os.path.join(root, name))
+ except Exception as exception:
+ get_logger(0).exception("Error removing normal file or directory: %s", exception)
+ fat_handle = pyfatfs.PyFatFS.PyFatFS(file_img_path)
+ try:
+ self._extract_directory(fat_handle, "/", output_dir)
+ except Exception as exception:
+ get_logger(0).exception("Error extracting FAT image: %s", exception)
+ finally:
+ fat_handle.close()
+
+ def _extract_directory(self, fat_handle, src_path: str, dst_path: str) -> None: # type: ignore
+ for entry in fat_handle.listdir(src_path): # type: ignore
+ src_item_path = os.path.join(src_path, entry)
+ dst_item_path = os.path.join(dst_path, entry)
+
+ if fat_handle.gettype(src_item_path) is pyfatfs.PyFatFS.ResourceType.directory: # type: ignore
+ os.makedirs(dst_item_path, exist_ok=True)
+ self._extract_directory(fat_handle, src_item_path, dst_item_path)
+ else:
+ with fat_handle.open(src_item_path, "rb") as src_file: # type: ignore
+ with open(dst_item_path, "wb") as dst_file:
+ dst_file.write(src_file.read())
@contextlib.asynccontextmanager
async def read_image(self, name: str) -> AsyncGenerator[MsdFileReader, None]:
diff --git a/kvmd/plugins/msd/otg/drive.py b/kvmd/plugins/msd/otg/drive.py
index 1d93593b..d4c75aad 100644
--- a/kvmd/plugins/msd/otg/drive.py
+++ b/kvmd/plugins/msd/otg/drive.py
@@ -39,7 +39,7 @@ class MsdDriveLockedError(MsdOperationError):
class Drive:
def __init__(self, gadget: str, instance: int, lun: int) -> None:
func = f"mass_storage.usb{instance}"
- self.__udc_path = os.path.join(f"/sys/kernel/config/usb_gadget", gadget, usb.G_UDC)
+ self.__udc_path = os.path.join("/sys/kernel/config/usb_gadget", gadget, usb.G_UDC)
self.__profile_func_path = usb.get_gadget_path(gadget, usb.G_PROFILE, func)
self.__profile_path = usb.get_gadget_path(gadget, usb.G_PROFILE)
self.__lun_path = usb.get_gadget_path(gadget, usb.G_FUNCTIONS, func, f"lun.{lun}")
@@ -49,7 +49,7 @@ class Drive:
def get_watchable_paths(self) -> list[str]:
return [self.__lun_path, self.__profile_path]
-
+
def get_udc_path(self) -> str:
return self.__udc_path
@@ -80,7 +80,7 @@ class Drive:
# =====
def __has_param(self, param: str) -> bool:
return os.access(os.path.join(self.__lun_path, param), os.F_OK)
-
+
def __get_param(self, param: str) -> str:
with open(os.path.join(self.__lun_path, param)) as file:
return file.read().strip()
diff --git a/kvmd/plugins/ugpio/otgconf.py b/kvmd/plugins/ugpio/otgconf.py
index 4c85b0e8..6624cfd4 100644
--- a/kvmd/plugins/ugpio/otgconf.py
+++ b/kvmd/plugins/ugpio/otgconf.py
@@ -108,7 +108,7 @@ class Plugin(BaseUserGpioDriver):
async def write(self, pin: str, state: bool) -> None:
async with self.__lock:
- if self.read(pin) == state:
+ if (await self.read(pin)) == state:
return
if pin == "udc":
if state:
diff --git a/kvmd/tools.py b/kvmd/tools.py
index 6dd7d2f9..6cc477d7 100644
--- a/kvmd/tools.py
+++ b/kvmd/tools.py
@@ -27,12 +27,14 @@ import multiprocessing.queues
import queue
import shlex
+from typing import Generator
from typing import TypeVar
# =====
def remap(value: int, in_min: int, in_max: int, out_min: int, out_max: int) -> int:
- return int((value - in_min) * (out_max - out_min) // (in_max - in_min) + out_min)
+ result = int((value - in_min) * (out_max - out_min) // ((in_max - in_min) or 1) + out_min)
+ return min(max(result, out_min), out_max)
# =====
@@ -81,3 +83,13 @@ def build_cmd(cmd: list[str], cmd_remove: list[str], cmd_append: list[str]) -> l
*filter((lambda item: item not in cmd_remove), cmd[1:]),
*cmd_append,
]
+
+
+# =====
+def passwds_splitted(text: str) -> Generator[tuple[int, str], None, None]:
+ for (lineno, line) in enumerate(text.split("\n")):
+ line = line.rstrip("\r")
+ ls = line.strip()
+ if len(ls) == 0 or ls.startswith("#"):
+ continue
+ yield (lineno, line)
diff --git a/kvmd/validators/auth.py b/kvmd/validators/auth.py
index 33cad456..d07a3d63 100644
--- a/kvmd/validators/auth.py
+++ b/kvmd/validators/auth.py
@@ -23,6 +23,7 @@
from typing import Any
from .basic import valid_string_list
+from .basic import valid_number
from . import check_re_match
@@ -40,5 +41,9 @@ def valid_passwd(arg: Any) -> str:
return check_re_match(arg, "passwd characters", r"^[\x20-\x7e]*\Z$", strip=False, hide=True)
+def valid_expire(arg: Any) -> int:
+ return int(valid_number(arg, min=0, name="expiration time"))
+
+
def valid_auth_token(arg: Any) -> str:
return check_re_match(arg, "auth token", r"^[0-9a-f]{64}$", hide=True)
diff --git a/kvmd/validators/basic.py b/kvmd/validators/basic.py
index eae844d9..85863ac6 100644
--- a/kvmd/validators/basic.py
+++ b/kvmd/validators/basic.py
@@ -70,7 +70,13 @@ def valid_number(
arg = valid_stripped_string_not_empty(arg, name)
try:
- arg = type(arg)
+ if type == int:
+ if arg.startswith(("0x", "0X")):
+ arg = int(arg[2:], 16)
+ else:
+ arg = int(arg)
+ else:
+ arg = type(arg)
except Exception:
raise_error(arg, name)
diff --git a/kvmd/validators/hid.py b/kvmd/validators/hid.py
index 0f74a6eb..c09ad37f 100644
--- a/kvmd/validators/hid.py
+++ b/kvmd/validators/hid.py
@@ -22,7 +22,8 @@
from typing import Any
-from ..keyboard.mappings import KEYMAP
+from ..keyboard.mappings import WEB_TO_EVDEV
+from ..mouse import MOUSE_TO_EVDEV
from ..mouse import MouseRange
from ..mouse import MouseDelta
@@ -42,7 +43,7 @@ def valid_hid_mouse_output(arg: Any) -> str:
def valid_hid_key(arg: Any) -> str:
- return check_string_in_list(arg, "Keyboard key", KEYMAP, lower=False)
+ return check_string_in_list(arg, "Keyboard key", WEB_TO_EVDEV, lower=False)
def valid_hid_mouse_move(arg: Any) -> int:
@@ -51,7 +52,7 @@ def valid_hid_mouse_move(arg: Any) -> int:
def valid_hid_mouse_button(arg: Any) -> str:
- return check_string_in_list(arg, "Mouse button", ["left", "right", "middle", "up", "down"])
+ return check_string_in_list(arg, "Mouse button", MOUSE_TO_EVDEV)
def valid_hid_mouse_delta(arg: Any) -> int:
diff --git a/kvmd/validators/switch.py b/kvmd/validators/switch.py
index d4f3ab2f..7b4d13d7 100644
--- a/kvmd/validators/switch.py
+++ b/kvmd/validators/switch.py
@@ -50,7 +50,7 @@ def valid_switch_edid_data(arg: Any) -> str:
name = "switch EDID data"
arg = valid_stripped_string(arg, name=name)
arg = re.sub(r"\s", "", arg)
- return check_re_match(arg, name, "(?i)^[0-9a-f]{512}$").upper()
+ return check_re_match(arg, name, "(?i)^([0-9a-f]{256}|[0-9a-f]{512})$").upper()
def valid_switch_color(arg: Any, allow_default: bool) -> str:
diff --git a/scripts/kvmd-bootconfig b/scripts/kvmd-bootconfig
index ad98e21c..816896e2 100755
--- a/scripts/kvmd-bootconfig
+++ b/scripts/kvmd-bootconfig
@@ -55,6 +55,10 @@ source /usr/share/kvmd/platform || true
rw
+need_reboot() {
+ touch /boot/pikvm-reboot.txt
+}
+
# ========== First boot and/or Avahi configuration ==========
@@ -148,7 +152,7 @@ if [ -n "$ENABLE_AVAHI" ]; then
make_avahi_service
fi
systemctl enable avahi-daemon || true
- touch /boot/pikvm-reboot.txt
+ need_reboot
fi
@@ -171,7 +175,7 @@ TTYVHangup=no
TTYVTDisallocate=no
end_of_file
systemctl enable getty@ttyGS0.service
- touch /boot/pikvm-reboot.txt
+ need_reboot
fi
@@ -256,22 +260,26 @@ if [ -n "$WIFI_ESSID" ]; then
else
make_dhcp_iface "$WIFI_IFACE" 50
fi
+ _wpa="/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf"
if [ "${#WIFI_PASSWD}" -ge 8 ];then
- wpa_passphrase "$WIFI_ESSID" "$WIFI_PASSWD" > "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf"
+ wpa_passphrase "$WIFI_ESSID" "$WIFI_PASSWD" > "$_wpa"
else
- cat < "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf"
+ cat < "$_wpa"
network={
ssid=$(printf '"%q"' "$WIFI_ESSID")
key_mgmt=NONE
}
end_of_file
fi
- chmod 640 "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf"
+ chmod 640 "$_wpa"
if [ -n "$WIFI_HIDDEN" ]; then
- sed -i -e 's/^}/\tscan_ssid=1\n}/g' "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf"
+ sed -i -e 's/^}/\tscan_ssid=1\n}/g' "$_wpa"
+ fi
+ if [ -n "$WIFI_WPA23" ]; then
+ sed -i -e 's/^}/\tkey_mgmt=WPA-PSK-SHA256 WPA-PSK\n\tieee80211w=1\n}/g' "$_wpa"
fi
systemctl enable "wpa_supplicant@$WIFI_IFACE.service" || true
- touch /boot/pikvm-reboot.txt
+ need_reboot
fi
diff --git a/setup.py b/setup.py
index e2ff6b85..3f7a1627 100755
--- a/setup.py
+++ b/setup.py
@@ -21,7 +21,8 @@
# #
# ========================================================================== #
-from setuptools import setup, find_packages
+from setuptools import setup
+
def main() -> None:
# Define entry points manually with specific import paths
@@ -52,7 +53,7 @@ def main() -> None:
setup(
name="kvmd",
- version="4.20",
+ version="4.94",
url="https://github.com/pikvm/kvmd",
license="GPLv3",
author="Maxim Devaev",
@@ -79,6 +80,7 @@ def main() -> None:
"kvmd.clients",
"kvmd.apps",
"kvmd.apps.kvmd",
+ "kvmd.apps.kvmd.streamer",
"kvmd.apps.kvmd.switch",
"kvmd.apps.kvmd.info",
"kvmd.apps.kvmd.api",
@@ -97,6 +99,7 @@ def main() -> None:
"kvmd.apps.ipmi",
"kvmd.apps.vnc",
"kvmd.apps.vnc.rfb",
+ "kvmd.apps.localhid",
"kvmd.apps.ngxmkconf",
"kvmd.apps.janus",
"kvmd.apps.watchdog",
@@ -112,7 +115,31 @@ def main() -> None:
"kvmd": ["i18n/zh/LC_MESSAGES/*.mo"],
},
- entry_points=entry_points,
+ entry_points={
+ "console_scripts": [
+ "kvmd = kvmd.apps.kvmd:main",
+ "kvmd-media = kvmd.apps.media:main",
+ "kvmd-pst = kvmd.apps.pst:main",
+ "kvmd-pstrun = kvmd.apps.pstrun:main",
+ "kvmd-otg = kvmd.apps.otg:main",
+ "kvmd-otgnet = kvmd.apps.otgnet:main",
+ "kvmd-otgmsd = kvmd.apps.otgmsd:main",
+ "kvmd-otgconf = kvmd.apps.otgconf:main",
+ "kvmd-htpasswd = kvmd.apps.htpasswd:main",
+ "kvmd-totp = kvmd.apps.totp:main",
+ "kvmd-edidconf = kvmd.apps.edidconf:main",
+ "kvmd-ipmi = kvmd.apps.ipmi:main",
+ "kvmd-vnc = kvmd.apps.vnc:main",
+ "kvmd-localhid = kvmd.apps.localhid:main",
+ "kvmd-nginx-mkconf = kvmd.apps.ngxmkconf:main",
+ "kvmd-janus = kvmd.apps.janus:main",
+ "kvmd-watchdog = kvmd.apps.watchdog:main",
+ "kvmd-oled = kvmd.apps.oled:main",
+ "kvmd-helper-pst-remount = kvmd.helpers.remount:main",
+ "kvmd-helper-otgmsd-remount = kvmd.helpers.remount:main",
+ "kvmd-helper-swapfiles = kvmd.helpers.swapfiles:main",
+ ],
+ },
classifiers=[
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
diff --git a/switch/switch.uf2 b/switch/switch.uf2
index c3f37491..6e1346ce 100644
Binary files a/switch/switch.uf2 and b/switch/switch.uf2 differ
diff --git a/testenv/Dockerfile b/testenv/Dockerfile
index 3164b4d8..742cdf8c 100644
--- a/testenv/Dockerfile
+++ b/testenv/Dockerfile
@@ -7,23 +7,24 @@ RUN echo 'Server = https://mirrors.tuna.tsinghua.edu.cn/archlinux/$repo/os/$arch
&& pacman-key --init \
&& pacman-key --populate archlinux
-RUN pacman --noconfirm --ask=4 -Syy \
- && pacman --needed --noconfirm --ask=4 -S \
+RUN \
+ --mount=type=cache,id=kvmd-pacman-pkg,target=/var/cache/pacman/pkg \
+ --mount=type=cache,id=kvmd-pacman-db,target=/var/lib/pacman/sync \
+ PACMAN="pacman --noconfirm --ask=4 --needed" \
+ && $PACMAN -Syy \
archlinux-keyring \
- && pacman --needed --noconfirm --ask=4 -S \
+ && $PACMAN -S \
glibc \
pacman \
openssl \
openssl-1.1 \
&& pacman-db-upgrade \
- && pacman --noconfirm --ask=4 -Syu \
- && pacman --needed --noconfirm --ask=4 -S \
+ && $PACMAN -Syu \
p11-kit \
ca-certificates \
ca-certificates-mozilla \
ca-certificates-utils \
- && pacman -Syu --noconfirm --ask=4 \
- && pacman -S --needed --noconfirm --ask=4 \
+ && $PACMAN -Syu \
base-devel \
autoconf-archive \
help2man \
@@ -46,10 +47,13 @@ RUN pacman --noconfirm --ask=4 -Syy \
python-aiofiles \
python-async-lru \
python-passlib \
+ python-bcrypt \
python-pyotp \
python-qrcode \
python-pyserial \
+ python-pyusb \
python-pyudev \
+ python-evdev \
python-setproctitle \
python-psutil \
python-netifaces \
@@ -76,8 +80,7 @@ RUN pacman --noconfirm --ask=4 -Syy \
eslint \
npm \
shellcheck \
- && (pacman -Sc --noconfirm || true) \
- && rm -rf /var/cache/pacman/pkg/*
+ && :
COPY testenv/requirements.txt requirements.txt
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple \
@@ -97,11 +100,12 @@ WORKDIR /
ARG USTREAMER_MIN_VERSION
ENV USTREAMER_MIN_VERSION $USTREAMER_MIN_VERSION
RUN echo $USTREAMER_MIN_VERSION
-RUN git clone https://github.com/pikvm/ustreamer \
+RUN \
+ --mount=type=tmpfs,target=/tmp \
+ cd /tmp \
+ && git clone --depth=1 https://github.com/pikvm/ustreamer \
&& cd ustreamer \
- && make WITH_PYTHON=1 PREFIX=/usr DESTDIR=/ install \
- && cd - \
- && rm -rf ustreamer
+ && make WITH_PYTHON=1 PREFIX=/usr DESTDIR=/ install
RUN mkdir -p \
/etc/kvmd/{nginx,vnc} \
@@ -114,4 +118,4 @@ COPY testenv/fakes/sys /fake_sysfs/sys
COPY testenv/fakes/proc /fake_procfs/proc
COPY testenv/fakes/etc /fake_etc/etc
-CMD /bin/bash
+CMD ["/bin/bash"]
diff --git a/testenv/js/janus.js b/testenv/js/janus.js
index 4544ccfc..9acd7400 100644
--- a/testenv/js/janus.js
+++ b/testenv/js/janus.js
@@ -1,4 +1,3 @@
-import "./adapter.js"
"use strict";
/*
@@ -25,1185 +24,1315 @@ import "./adapter.js"
OTHER DEALINGS IN THE SOFTWARE.
*/
-// List of sessions
-Janus.sessions = {};
+import "./adapter.js";
-Janus.isExtensionEnabled = function() {
- if(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia) {
- // No need for the extension, getDisplayMedia is supported
- return true;
+// eslint-disable-next-line no-unused-vars
+export var Janus = (function (factory) {
+ if (typeof define === 'function' && define.amd) {
+ define(factory);
+ } else if (typeof module === 'object' && module.exports) {
+ module.exports = factory();
+ } else if (typeof window === 'object') {
+ return factory();
}
- if(window.navigator.userAgent.match('Chrome')) {
- var chromever = parseInt(window.navigator.userAgent.match(/Chrome\/(.*) /)[1], 10);
- var maxver = 33;
- if(window.navigator.userAgent.match('Linux'))
- maxver = 35; // "known" crash in chrome 34 and 35 on linux
- if(chromever >= 26 && chromever <= maxver) {
- // Older versions of Chrome don't support this extension-based approach, so lie
+}(function () {
+
+ // List of sessions
+ Janus.sessions = new Map();
+
+ Janus.isExtensionEnabled = function() {
+ if(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia) {
+ // No need for the extension, getDisplayMedia is supported
return true;
}
- return Janus.extension.isInstalled();
- } else {
- // Firefox and others, no need for the extension (but this doesn't mean it will work)
- return true;
- }
-};
+ if(window.navigator.userAgent.match('Chrome')) {
+ let chromever = parseInt(window.navigator.userAgent.match(/Chrome\/(.*) /)[1], 10);
+ let maxver = 33;
+ if(window.navigator.userAgent.match('Linux'))
+ maxver = 35; // "known" crash in chrome 34 and 35 on linux
+ if(chromever >= 26 && chromever <= maxver) {
+ // Older versions of Chrome don't support this extension-based approach, so lie
+ return true;
+ }
+ return Janus.extension.isInstalled();
+ } else {
+ // Firefox and others, no need for the extension (but this doesn't mean it will work)
+ return true;
+ }
+ };
-var defaultExtension = {
- // Screensharing Chrome Extension ID
- extensionId: 'hapfgfdkleiggjjpfpenajgdnfckjpaj',
- isInstalled: function() { return document.querySelector('#janus-extension-installed') !== null; },
- getScreen: function (callback) {
- var pending = window.setTimeout(function () {
- var error = new Error('NavigatorUserMediaError');
- error.name = 'The required Chrome extension is not installed: click here to install it. (NOTE: this will need you to refresh the page)';
- return callback(error);
- }, 1000);
- this.cache[pending] = callback;
- window.postMessage({ type: 'janusGetScreen', id: pending }, '*');
- },
- init: function () {
- var cache = {};
- this.cache = cache;
- // Wait for events from the Chrome Extension
- window.addEventListener('message', function (event) {
- if(event.origin != window.location.origin)
- return;
- if(event.data.type == 'janusGotScreen' && cache[event.data.id]) {
- var callback = cache[event.data.id];
- delete cache[event.data.id];
-
- if (event.data.sourceId === '') {
- // user canceled
- var error = new Error('NavigatorUserMediaError');
- error.name = 'You cancelled the request for permission, giving up...';
- callback(error);
- } else {
- callback(null, event.data.sourceId);
+ var defaultExtension = {
+ // Screensharing Chrome Extension ID
+ extensionId: 'hapfgfdkleiggjjpfpenajgdnfckjpaj',
+ isInstalled: function() { return document.querySelector('#janus-extension-installed') !== null; },
+ getScreen: function (callback) {
+ let pending = window.setTimeout(function () {
+ let error = new Error('NavigatorUserMediaError');
+ error.name = 'The required Chrome extension is not installed: click here to install it. (NOTE: this will need you to refresh the page)';
+ return callback(error);
+ }, 1000);
+ this.cache[pending] = callback;
+ window.postMessage({ type: 'janusGetScreen', id: pending }, '*');
+ },
+ init: function () {
+ let cache = {};
+ this.cache = cache;
+ // Wait for events from the Chrome Extension
+ window.addEventListener('message', function (event) {
+ if(event.origin != window.location.origin)
+ return;
+ if(event.data.type == 'janusGotScreen' && cache[event.data.id]) {
+ let callback = cache[event.data.id];
+ delete cache[event.data.id];
+ if(event.data.sourceId === '') {
+ // user canceled
+ let error = new Error('NavigatorUserMediaError');
+ error.name = 'You cancelled the request for permission, giving up...';
+ callback(error);
+ } else {
+ callback(null, event.data.sourceId);
+ }
+ } else if(event.data.type == 'janusGetScreenPending') {
+ console.log('clearing ', event.data.id);
+ window.clearTimeout(event.data.id);
}
- } else if (event.data.type == 'janusGetScreenPending') {
- console.log('clearing ', event.data.id);
- window.clearTimeout(event.data.id);
- }
- });
- }
-};
-
-Janus.useDefaultDependencies = function (deps) {
- var f = (deps && deps.fetch) || fetch;
- var p = (deps && deps.Promise) || Promise;
- var socketCls = (deps && deps.WebSocket) || WebSocket;
-
- return {
- newWebSocket: function(server, proto) { return new socketCls(server, proto); },
- extension: (deps && deps.extension) || defaultExtension,
- isArray: function(arr) { return Array.isArray(arr); },
- webRTCAdapter: (deps && deps.adapter) || adapter,
- httpAPICall: function(url, options) {
- var fetchOptions = {
- method: options.verb,
- headers: {
- 'Accept': 'application/json, text/plain, */*'
- },
- cache: 'no-cache'
- };
- if(options.verb === "POST") {
- fetchOptions.headers['Content-Type'] = 'application/json';
- }
- if(options.withCredentials !== undefined) {
- fetchOptions.credentials = options.withCredentials === true ? 'include' : (options.withCredentials ? options.withCredentials : 'omit');
- }
- if(options.body) {
- fetchOptions.body = JSON.stringify(options.body);
- }
-
- var fetching = f(url, fetchOptions).catch(function(error) {
- return p.reject({message: 'Probably a network error, is the server down?', error: error});
});
+ }
+ };
- /*
+ Janus.useDefaultDependencies = function (deps) {
+ let f = (deps && deps.fetch) || fetch;
+ let p = (deps && deps.Promise) || Promise;
+ let socketCls = (deps && deps.WebSocket) || WebSocket;
+
+ return {
+ newWebSocket: function(server, proto) { return new socketCls(server, proto); },
+ extension: (deps && deps.extension) || defaultExtension,
+ isArray: function(arr) { return Array.isArray(arr); },
+ webRTCAdapter: (deps && deps.adapter) || adapter,
+ httpAPICall: function(url, options) {
+ let fetchOptions = {
+ method: options.verb,
+ headers: {
+ 'Accept': 'application/json, text/plain, */*'
+ },
+ cache: 'no-cache'
+ };
+ if(options.verb === "POST") {
+ fetchOptions.headers['Content-Type'] = 'application/json';
+ }
+ if(typeof options.withCredentials !== 'undefined') {
+ fetchOptions.credentials = options.withCredentials === true ? 'include' : (options.withCredentials ? options.withCredentials : 'omit');
+ }
+ if(options.body) {
+ fetchOptions.body = JSON.stringify(options.body);
+ }
+
+ let fetching = f(url, fetchOptions).catch(function(error) {
+ return p.reject({message: 'Probably a network error, is the server down?', error: error});
+ });
+
+ /*
* fetch() does not natively support timeouts.
* Work around this by starting a timeout manually, and racing it agains the fetch() to see which thing resolves first.
*/
- if(options.timeout) {
- var timeout = new p(function(resolve, reject) {
- var timerId = setTimeout(function() {
- clearTimeout(timerId);
- return reject({message: 'Request timed out', timeout: options.timeout});
- }, options.timeout);
- });
- fetching = p.race([fetching, timeout]);
- }
+ if(options.timeout) {
+ // eslint-disable-next-line no-unused-vars
+ let timeout = new p(function(resolve, reject) {
+ let timerId = setTimeout(function() {
+ clearTimeout(timerId);
+ return reject({message: 'Request timed out', timeout: options.timeout});
+ }, options.timeout);
+ });
+ fetching = p.race([fetching, timeout]);
+ }
- fetching.then(function(response) {
- if(response.ok) {
- if(typeof(options.success) === typeof(Janus.noop)) {
- return response.json().then(function(parsed) {
- try {
- options.success(parsed);
- } catch(error) {
- Janus.error('Unhandled httpAPICall success callback error', error);
- }
- }, function(error) {
- return p.reject({message: 'Failed to parse response body', error: error, response: response});
- });
+ fetching.then(function(response) {
+ if(response.ok) {
+ if(typeof(options.success) === typeof(Janus.noop)) {
+ return response.json().then(function(parsed) {
+ try {
+ options.success(parsed);
+ } catch(error) {
+ Janus.error('Unhandled httpAPICall success callback error', error);
+ }
+ }, function(error) {
+ return p.reject({message: 'Failed to parse response body', error: error, response: response});
+ });
+ }
}
- }
- else {
- return p.reject({message: 'API call failed', response: response});
- }
- }).catch(function(error) {
- if(typeof(options.error) === typeof(Janus.noop)) {
- options.error(error.message || '<< internal error >>', error);
- }
- });
-
- return fetching;
- }
- }
-};
-
-Janus.useOldDependencies = function (deps) {
- var jq = (deps && deps.jQuery) || jQuery;
- var socketCls = (deps && deps.WebSocket) || WebSocket;
- return {
- newWebSocket: function(server, proto) { return new socketCls(server, proto); },
- isArray: function(arr) { return jq.isArray(arr); },
- extension: (deps && deps.extension) || defaultExtension,
- webRTCAdapter: (deps && deps.adapter) || adapter,
- httpAPICall: function(url, options) {
- var payload = options.body !== undefined ? {
- contentType: 'application/json',
- data: JSON.stringify(options.body)
- } : {};
- var credentials = options.withCredentials !== undefined ? {xhrFields: {withCredentials: options.withCredentials}} : {};
-
- return jq.ajax(jq.extend(payload, credentials, {
- url: url,
- type: options.verb,
- cache: false,
- dataType: 'json',
- async: options.async,
- timeout: options.timeout,
- success: function(result) {
- if(typeof(options.success) === typeof(Janus.noop)) {
- options.success(result);
+ else {
+ return p.reject({message: 'API call failed', response: response});
}
- },
- error: function(xhr, status, err) {
+ }).catch(function(error) {
if(typeof(options.error) === typeof(Janus.noop)) {
- options.error(status, err);
+ options.error(error.message || '<< internal error >>', error);
}
- }
- }));
+ });
+
+ return fetching;
+ }
}
};
-};
-Janus.noop = function() {};
+ Janus.useOldDependencies = function (deps) {
+ let jq = (deps && deps.jQuery) || jQuery;
+ let socketCls = (deps && deps.WebSocket) || WebSocket;
+ return {
+ newWebSocket: function(server, proto) { return new socketCls(server, proto); },
+ isArray: function(arr) { return jq.isArray(arr); },
+ extension: (deps && deps.extension) || defaultExtension,
+ webRTCAdapter: (deps && deps.adapter) || adapter,
+ httpAPICall: function(url, options) {
+ let payload = (typeof options.body !== 'undefined') ? {
+ contentType: 'application/json',
+ data: JSON.stringify(options.body)
+ } : {};
+ let credentials = (typeof options.withCredentials !== 'undefined') ? {xhrFields: {withCredentials: options.withCredentials}} : {};
-Janus.dataChanDefaultLabel = "JanusDataChannel";
-
-// Note: in the future we may want to change this, e.g., as was
-// attempted in https://github.com/meetecho/janus-gateway/issues/1670
-Janus.endOfCandidates = null;
-
-// Stop all tracks from a given stream
-Janus.stopAllTracks = function(stream) {
- try {
- // Try a MediaStreamTrack.stop() for each track
- var tracks = stream.getTracks();
- for(var mst of tracks) {
- Janus.log(mst);
- if(mst) {
- mst.stop();
+ return jq.ajax(jq.extend(payload, credentials, {
+ url: url,
+ type: options.verb,
+ cache: false,
+ dataType: 'json',
+ async: options.async,
+ timeout: options.timeout,
+ success: function(result) {
+ if(typeof(options.success) === typeof(Janus.noop)) {
+ options.success(result);
+ }
+ },
+ // eslint-disable-next-line no-unused-vars
+ error: function(xhr, status, err) {
+ if(typeof(options.error) === typeof(Janus.noop)) {
+ options.error(status, err);
+ }
+ }
+ }));
}
- }
- } catch(e) {
- // Do nothing if this fails
- }
-}
+ };
+ };
-// Initialization
-Janus.init = function(options) {
- options = options || {};
- options.callback = (typeof options.callback == "function") ? options.callback : Janus.noop;
- if(Janus.initDone) {
- // Already initialized
- options.callback();
- } else {
- if(typeof console.log == "undefined") {
- console.log = function() {};
- }
- // Console logging (all debugging disabled by default)
- Janus.trace = Janus.noop;
- Janus.debug = Janus.noop;
- Janus.vdebug = Janus.noop;
- Janus.log = Janus.noop;
- Janus.warn = Janus.noop;
- Janus.error = Janus.noop;
- if(options.debug === true || options.debug === "all") {
- // Enable all debugging levels
- Janus.trace = console.trace.bind(console);
- Janus.debug = console.debug.bind(console);
- Janus.vdebug = console.debug.bind(console);
- Janus.log = console.log.bind(console);
- Janus.warn = console.warn.bind(console);
- Janus.error = console.error.bind(console);
- } else if(Array.isArray(options.debug)) {
- for(var d of options.debug) {
- switch(d) {
- case "trace":
- Janus.trace = console.trace.bind(console);
- break;
- case "debug":
- Janus.debug = console.debug.bind(console);
- break;
- case "vdebug":
- Janus.vdebug = console.debug.bind(console);
- break;
- case "log":
- Janus.log = console.log.bind(console);
- break;
- case "warn":
- Janus.warn = console.warn.bind(console);
- break;
- case "error":
- Janus.error = console.error.bind(console);
- break;
- default:
- console.error("Unknown debugging option '" + d + "' (supported: 'trace', 'debug', 'vdebug', 'log', warn', 'error')");
- break;
+ // Helper function to convert a deprecated media object to a tracks array
+ Janus.mediaToTracks = function(media) {
+ let tracks = [];
+ if(!media) {
+ // Default is bidirectional audio and video, using default devices
+ tracks.push({ type: 'audio', capture: true, recv: true });
+ tracks.push({ type: 'video', capture: true, recv: true });
+ } else {
+ if(!media.keepAudio && media.audio !== false && ((typeof media.audio === 'undefined') || media.audio || media.audioSend || media.audioRecv ||
+ media.addAudio || media.replaceAudio || media.removeAudio)) {
+ // We may need an audio track
+ let track = { type: 'audio' };
+ if(media.removeAudio) {
+ track.remove = true;
+ } else {
+ if(media.addAudio)
+ track.add = true;
+ else if(media.replaceAudio)
+ track.replace = true;
+ // Check if we need to capture an audio device
+ if(media.audioSend !== false)
+ track.capture = media.audio || true;
+ // Check if we need to receive audio
+ if(media.audioRecv !== false)
+ track.recv = true;
}
+ // Add an audio track if needed
+ if(track.remove || track.capture || track.recv)
+ tracks.push(track);
+ }
+ if(!media.keepVideo && media.video !== false && ((typeof media.video === 'undefined') || media.video || media.videoSend || media.videoRecv ||
+ media.addVideo || media.replaceVideo || media.removeVideo)) {
+ // We may need a video track
+ let track = { type: 'video' };
+ if(media.removeVideo) {
+ track.remove = true;
+ } else {
+ if(media.addVideo)
+ track.add = true;
+ else if(media.replaceVideo)
+ track.replace = true;
+ // Check if we need to capture a video device
+ if(media.videoSend !== false) {
+ track.capture = media.video || true;
+ if(['screen', 'window', 'desktop'].includes(track.capture)) {
+ // Change the type to 'screen'
+ track.type = 'screen';
+ track.capture = { video: {} };
+ // Check if there's constraints
+ if(media.screenshareFrameRate)
+ track.capture.frameRate = media.screenshareFrameRate;
+ if(media.screenshareHeight)
+ track.capture.height = media.screenshareHeight;
+ if(media.screenshareWidth)
+ track.capture.width = media.screenshareWidth;
+ }
+ }
+ // Check if we need to receive video
+ if(media.videoRecv !== false)
+ track.recv = true;
+ }
+ // Add a video track if needed
+ if(track.remove || track.capture || track.recv)
+ tracks.push(track);
+ }
+ if(media.data) {
+ // We need a data channel
+ tracks.push({ type: 'data' });
}
}
- Janus.log("Initializing library");
+ // Done
+ return tracks;
+ };
- var usedDependencies = options.dependencies || Janus.useDefaultDependencies();
- Janus.isArray = usedDependencies.isArray;
- Janus.webRTCAdapter = usedDependencies.webRTCAdapter;
- Janus.httpAPICall = usedDependencies.httpAPICall;
- Janus.newWebSocket = usedDependencies.newWebSocket;
- Janus.extension = usedDependencies.extension;
- Janus.extension.init();
-
- // Helper method to enumerate devices
- Janus.listDevices = function(callback, config) {
- callback = (typeof callback == "function") ? callback : Janus.noop;
- if (config == null) config = { audio: true, video: true };
- if(Janus.isGetUserMediaAvailable()) {
- navigator.mediaDevices.getUserMedia(config)
- .then(function(stream) {
- navigator.mediaDevices.enumerateDevices().then(function(devices) {
- Janus.debug(devices);
- callback(devices);
- // Get rid of the now useless stream
- Janus.stopAllTracks(stream)
- });
- })
- .catch(function(err) {
- Janus.error(err);
- callback([]);
- });
+ // Helper function to convert a track object to a set of constraints
+ Janus.trackConstraints = function(track) {
+ let constraints = {};
+ if(!track || !track.capture)
+ return constraints;
+ if(track.type === 'audio') {
+ // Just put the capture part in the constraints
+ constraints.audio = track.capture;
+ } else if(track.type === 'video') {
+ // Check if one of the keywords was passed
+ if((track.simulcast || track.svc) && track.capture === true)
+ track.capture = 'hires';
+ if(track.capture === true || typeof track.capture === 'object') {
+ // Use the provided capture object as video constraint
+ constraints.video = track.capture;
} else {
- Janus.warn("navigator.mediaDevices unavailable");
- callback([]);
+ let width = 0;
+ let height = 0;
+ if(track.capture === 'lowres') {
+ // Small resolution, 4:3
+ width = 320;
+ height = 240;
+ } else if(track.capture === 'lowres-16:9') {
+ // Small resolution, 16:9
+ width = 320;
+ height = 180;
+ } else if(track.capture === 'hires' || track.capture === 'hires-16:9' || track.capture === 'hdres') {
+ // High(HD) resolution is only 16:9
+ width = 1280;
+ height = 720;
+ } else if(track.capture === 'fhdres') {
+ // Full HD resolution is only 16:9
+ width = 1920;
+ height = 1080;
+ } else if(track.capture === '4kres') {
+ // 4K resolution is only 16:9
+ width = 3840;
+ height = 2160;
+ } else if(track.capture === 'stdres') {
+ // Normal resolution, 4:3
+ width = 640;
+ height = 480;
+ } else if(track.capture === 'stdres-16:9') {
+ // Normal resolution, 16:9
+ width = 640;
+ height = 360;
+ } else {
+ Janus.log('Default video setting is stdres 4:3');
+ width = 640;
+ height = 480;
+ }
+ constraints.video = {
+ width: { ideal: width },
+ height: { ideal: height }
+ };
}
- };
- // Helper methods to attach/reattach a stream to a video element (previously part of adapter.js)
- Janus.attachMediaStream = function(element, stream) {
- try {
- element.srcObject = stream;
- } catch (e) {
- try {
- element.src = URL.createObjectURL(stream);
- } catch (e) {
- Janus.error("Error attaching stream to element");
+ } else if(track.type === 'screen') {
+ // Use the provided capture object as video constraint
+ constraints.video = track.capture;
+ }
+ return constraints;
+ };
+
+ Janus.noop = function() {};
+
+ Janus.dataChanDefaultLabel = "JanusDataChannel";
+
+ // Note: in the future we may want to change this, e.g., as was
+ // attempted in https://github.com/meetecho/janus-gateway/issues/1670
+ Janus.endOfCandidates = null;
+
+ // Stop all tracks from a given stream
+ Janus.stopAllTracks = function(stream) {
+ try {
+ // Try a MediaStreamTrack.stop() for each track
+ let tracks = stream.getTracks();
+ for(let mst of tracks) {
+ Janus.log(mst);
+ if(mst && mst.dontStop !== true) {
+ mst.stop();
}
}
- };
- Janus.reattachMediaStream = function(to, from) {
- try {
- to.srcObject = from.srcObject;
- } catch (e) {
- try {
- to.src = from.src;
- } catch (e) {
- Janus.error("Error reattaching stream to element");
- }
+ // eslint-disable-next-line no-unused-vars
+ } catch(e) {
+ // Do nothing if this fails
+ }
+ }
+
+ // Initialization
+ Janus.init = function(options) {
+ options = options || {};
+ options.callback = (typeof options.callback == "function") ? options.callback : Janus.noop;
+ if(Janus.initDone) {
+ // Already initialized
+ options.callback();
+ } else {
+ if(typeof console.log == "undefined") {
+ console.log = function() {};
}
- };
- // Detect tab close: make sure we don't loose existing onbeforeunload handlers
- // (note: for iOS we need to subscribe to a different event, 'pagehide', see
- // https://gist.github.com/thehunmonkgroup/6bee8941a49b86be31a787fe8f4b8cfe)
- var iOS = ['iPad', 'iPhone', 'iPod'].indexOf(navigator.platform) >= 0;
- var eventName = iOS ? 'pagehide' : 'beforeunload';
- var oldOBF = window["on" + eventName];
- window.addEventListener(eventName, function() {
- Janus.log("Closing window");
- for(var s in Janus.sessions) {
- if(Janus.sessions[s] && Janus.sessions[s].destroyOnUnload) {
- Janus.log("Destroying session " + s);
- Janus.sessions[s].destroy({unload: true, notifyDestroyed: false});
- }
- }
- if(oldOBF && typeof oldOBF == "function") {
- oldOBF();
- }
- });
- // If this is a Safari, check if VP8 or VP9 are supported
- Janus.safariVp8 = false;
- Janus.safariVp9 = false;
- if(Janus.webRTCAdapter.browserDetails.browser === 'safari' &&
- Janus.webRTCAdapter.browserDetails.version >= 605) {
- // Let's see if RTCRtpSender.getCapabilities() is there
- if(RTCRtpSender && RTCRtpSender.getCapabilities && RTCRtpSender.getCapabilities("video") &&
- RTCRtpSender.getCapabilities("video").codecs && RTCRtpSender.getCapabilities("video").codecs.length) {
- for(var codec of RTCRtpSender.getCapabilities("video").codecs) {
- if(codec && codec.mimeType && codec.mimeType.toLowerCase() === "video/vp8") {
- Janus.safariVp8 = true;
- } else if(codec && codec.mimeType && codec.mimeType.toLowerCase() === "video/vp9") {
- Janus.safariVp9 = true;
+ // Console logging (all debugging disabled by default)
+ Janus.trace = Janus.noop;
+ Janus.debug = Janus.noop;
+ Janus.vdebug = Janus.noop;
+ Janus.log = Janus.noop;
+ Janus.warn = Janus.noop;
+ Janus.error = Janus.noop;
+ if(options.debug === true || options.debug === "all") {
+ // Enable all debugging levels
+ Janus.trace = console.trace.bind(console);
+ Janus.debug = console.debug.bind(console);
+ Janus.vdebug = console.debug.bind(console);
+ Janus.log = console.log.bind(console);
+ Janus.warn = console.warn.bind(console);
+ Janus.error = console.error.bind(console);
+ } else if(Array.isArray(options.debug)) {
+ for(let d of options.debug) {
+ switch(d) {
+ case "trace":
+ Janus.trace = console.trace.bind(console);
+ break;
+ case "debug":
+ Janus.debug = console.debug.bind(console);
+ break;
+ case "vdebug":
+ Janus.vdebug = console.debug.bind(console);
+ break;
+ case "log":
+ Janus.log = console.log.bind(console);
+ break;
+ case "warn":
+ Janus.warn = console.warn.bind(console);
+ break;
+ case "error":
+ Janus.error = console.error.bind(console);
+ break;
+ default:
+ console.error("Unknown debugging option '" + d + "' (supported: 'trace', 'debug', 'vdebug', 'log', warn', 'error')");
+ break;
}
}
- if(Janus.safariVp8) {
- Janus.log("This version of Safari supports VP8");
+ }
+ Janus.log("Initializing library");
+
+ let usedDependencies = options.dependencies || Janus.useDefaultDependencies();
+ Janus.isArray = usedDependencies.isArray;
+ Janus.webRTCAdapter = usedDependencies.webRTCAdapter;
+ Janus.httpAPICall = usedDependencies.httpAPICall;
+ Janus.newWebSocket = usedDependencies.newWebSocket;
+ Janus.extension = usedDependencies.extension;
+ Janus.extension.init();
+
+ // Helper method to enumerate devices
+ Janus.listDevices = function(callback, config) {
+ callback = (typeof callback == "function") ? callback : Janus.noop;
+ if(!config)
+ config = { audio: true, video: true };
+ if(Janus.isGetUserMediaAvailable()) {
+ navigator.mediaDevices.getUserMedia(config)
+ .then(function(stream) {
+ navigator.mediaDevices.enumerateDevices().then(function(devices) {
+ Janus.debug(devices);
+ callback(devices);
+ // Get rid of the now useless stream
+ Janus.stopAllTracks(stream)
+ });
+ })
+ .catch(function(err) {
+ Janus.error(err);
+ callback([]);
+ });
} else {
- Janus.warn("This version of Safari does NOT support VP8: if you're using a Technology Preview, " +
- "try enabling the 'WebRTC VP8 codec' setting in the 'Experimental Features' Develop menu");
+ Janus.warn("navigator.mediaDevices unavailable");
+ callback([]);
}
- } else {
- // We do it in a very ugly way, as there's no alternative...
- // We create a PeerConnection to see if VP8 is in an offer
- var testpc = new RTCPeerConnection({});
- testpc.createOffer({offerToReceiveVideo: true}).then(function(offer) {
- Janus.safariVp8 = offer.sdp.indexOf("VP8") !== -1;
- Janus.safariVp9 = offer.sdp.indexOf("VP9") !== -1;
+ };
+ // Helper methods to attach/reattach a stream to a video element (previously part of adapter.js)
+ Janus.attachMediaStream = function(element, stream) {
+ try {
+ element.srcObject = stream;
+ // eslint-disable-next-line no-unused-vars
+ } catch (e) {
+ try {
+ element.src = URL.createObjectURL(stream);
+ } catch (e) {
+ Janus.error("Error attaching stream to element", e);
+ }
+ }
+ };
+ Janus.reattachMediaStream = function(to, from) {
+ try {
+ to.srcObject = from.srcObject;
+ // eslint-disable-next-line no-unused-vars
+ } catch (e) {
+ try {
+ to.src = from.src;
+ } catch (e) {
+ Janus.error("Error reattaching stream to element", e);
+ }
+ }
+ };
+ // Detect tab close: make sure we don't loose existing onbeforeunload handlers
+ // (note: for iOS we need to subscribe to a different event, 'pagehide', see
+ // https://gist.github.com/thehunmonkgroup/6bee8941a49b86be31a787fe8f4b8cfe)
+ let iOS = ['iPad', 'iPhone', 'iPod'].indexOf(navigator.platform) >= 0;
+ let eventName = iOS ? 'pagehide' : 'beforeunload';
+ let oldOBF = window["on" + eventName];
+ window.addEventListener(eventName, function() {
+ Janus.log("Closing window");
+ for(const [sessionId, session] of Janus.sessions) {
+ if(session && session.destroyOnUnload) {
+ Janus.log("Destroying session " + sessionId);
+ session.destroy({unload: true, notifyDestroyed: false});
+ }
+ }
+ if(oldOBF && typeof oldOBF == "function") {
+ oldOBF();
+ }
+ });
+ // If this is a Safari, check if VP8 or VP9 are supported
+ Janus.safariVp8 = false;
+ Janus.safariVp9 = false;
+ if(Janus.webRTCAdapter.browserDetails.browser === 'safari' &&
+ Janus.webRTCAdapter.browserDetails.version >= 605) {
+ // Let's see if RTCRtpSender.getCapabilities() is there
+ if(RTCRtpSender && RTCRtpSender.getCapabilities && RTCRtpSender.getCapabilities("video") &&
+ RTCRtpSender.getCapabilities("video").codecs && RTCRtpSender.getCapabilities("video").codecs.length) {
+ for(let codec of RTCRtpSender.getCapabilities("video").codecs) {
+ if(codec && codec.mimeType && codec.mimeType.toLowerCase() === "video/vp8") {
+ Janus.safariVp8 = true;
+ } else if(codec && codec.mimeType && codec.mimeType.toLowerCase() === "video/vp9") {
+ Janus.safariVp9 = true;
+ }
+ }
if(Janus.safariVp8) {
Janus.log("This version of Safari supports VP8");
} else {
Janus.warn("This version of Safari does NOT support VP8: if you're using a Technology Preview, " +
- "try enabling the 'WebRTC VP8 codec' setting in the 'Experimental Features' Develop menu");
+ "try enabling the 'WebRTC VP8 codec' setting in the 'Experimental Features' Develop menu");
}
- testpc.close();
- testpc = null;
- });
- }
- }
- // Check if this browser supports Unified Plan and transceivers
- // Based on https://codepen.io/anon/pen/ZqLwWV?editors=0010
- Janus.unifiedPlan = false;
- if(Janus.webRTCAdapter.browserDetails.browser === 'firefox' &&
- Janus.webRTCAdapter.browserDetails.version >= 59) {
- // Firefox definitely does, starting from version 59
- Janus.unifiedPlan = true;
- } else if(Janus.webRTCAdapter.browserDetails.browser === 'chrome' &&
- Janus.webRTCAdapter.browserDetails.version >= 72) {
- // Chrome does, but it's only usable from version 72 on
- Janus.unifiedPlan = true;
- } else if(!window.RTCRtpTransceiver || !('currentDirection' in RTCRtpTransceiver.prototype)) {
- // Safari supports addTransceiver() but not Unified Plan when
- // currentDirection is not defined (see codepen above).
- Janus.unifiedPlan = false;
- } else {
- // Check if addTransceiver() throws an exception
- var tempPc = new RTCPeerConnection();
- try {
- tempPc.addTransceiver('audio');
- Janus.unifiedPlan = true;
- } catch (e) {}
- tempPc.close();
- }
- Janus.initDone = true;
- options.callback();
- }
-};
-
-// Helper method to check whether WebRTC is supported by this browser
-Janus.isWebrtcSupported = function() {
- return !!window.RTCPeerConnection;
-};
-// Helper method to check whether devices can be accessed by this browser (e.g., not possible via plain HTTP)
-Janus.isGetUserMediaAvailable = function() {
- return navigator.mediaDevices && navigator.mediaDevices.getUserMedia;
-};
-
-// Helper method to create random identifiers (e.g., transaction)
-Janus.randomString = function(len) {
- var charSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
- var randomString = '';
- for (var i = 0; i < len; i++) {
- var randomPoz = Math.floor(Math.random() * charSet.length);
- randomString += charSet.substring(randomPoz,randomPoz+1);
- }
- return randomString;
-};
-
-export function Janus(gatewayCallbacks) {
- gatewayCallbacks = gatewayCallbacks || {};
- gatewayCallbacks.success = (typeof gatewayCallbacks.success == "function") ? gatewayCallbacks.success : Janus.noop;
- gatewayCallbacks.error = (typeof gatewayCallbacks.error == "function") ? gatewayCallbacks.error : Janus.noop;
- gatewayCallbacks.destroyed = (typeof gatewayCallbacks.destroyed == "function") ? gatewayCallbacks.destroyed : Janus.noop;
- if(!Janus.initDone) {
- gatewayCallbacks.error("Library not initialized");
- return {};
- }
- if(!Janus.isWebrtcSupported()) {
- gatewayCallbacks.error("WebRTC not supported by this browser");
- return {};
- }
- Janus.log("Library initialized: " + Janus.initDone);
- if(!gatewayCallbacks.server) {
- gatewayCallbacks.error("Invalid server url");
- return {};
- }
- var websockets = false;
- var ws = null;
- var wsHandlers = {};
- var wsKeepaliveTimeoutId = null;
- var servers = null;
- var serversIndex = 0;
- var server = gatewayCallbacks.server;
- if(Janus.isArray(server)) {
- Janus.log("Multiple servers provided (" + server.length + "), will use the first that works");
- server = null;
- servers = gatewayCallbacks.server;
- Janus.debug(servers);
- } else {
- if(server.indexOf("ws") === 0) {
- websockets = true;
- Janus.log("Using WebSockets to contact Janus: " + server);
- } else {
- websockets = false;
- Janus.log("Using REST API to contact Janus: " + server);
- }
- }
- var iceServers = gatewayCallbacks.iceServers || [{urls: "stun:stun.l.google.com:19302"}];
- var iceTransportPolicy = gatewayCallbacks.iceTransportPolicy;
- var bundlePolicy = gatewayCallbacks.bundlePolicy;
- // Whether IPv6 candidates should be gathered
- var ipv6Support = (gatewayCallbacks.ipv6 === true);
- // Whether we should enable the withCredentials flag for XHR requests
- var withCredentials = false;
- if(gatewayCallbacks.withCredentials !== undefined && gatewayCallbacks.withCredentials !== null)
- withCredentials = gatewayCallbacks.withCredentials === true;
- // Optional max events
- var maxev = 10;
- if(gatewayCallbacks.max_poll_events !== undefined && gatewayCallbacks.max_poll_events !== null)
- maxev = gatewayCallbacks.max_poll_events;
- if(maxev < 1)
- maxev = 1;
- // Token to use (only if the token based authentication mechanism is enabled)
- var token = null;
- if(gatewayCallbacks.token !== undefined && gatewayCallbacks.token !== null)
- token = gatewayCallbacks.token;
- // API secret to use (only if the shared API secret is enabled)
- var apisecret = null;
- if(gatewayCallbacks.apisecret !== undefined && gatewayCallbacks.apisecret !== null)
- apisecret = gatewayCallbacks.apisecret;
- // Whether we should destroy this session when onbeforeunload is called
- this.destroyOnUnload = true;
- if(gatewayCallbacks.destroyOnUnload !== undefined && gatewayCallbacks.destroyOnUnload !== null)
- this.destroyOnUnload = (gatewayCallbacks.destroyOnUnload === true);
- // Some timeout-related values
- var keepAlivePeriod = 25000;
- if(gatewayCallbacks.keepAlivePeriod !== undefined && gatewayCallbacks.keepAlivePeriod !== null)
- keepAlivePeriod = gatewayCallbacks.keepAlivePeriod;
- if(isNaN(keepAlivePeriod))
- keepAlivePeriod = 25000;
- var longPollTimeout = 60000;
- if(gatewayCallbacks.longPollTimeout !== undefined && gatewayCallbacks.longPollTimeout !== null)
- longPollTimeout = gatewayCallbacks.longPollTimeout;
- if(isNaN(longPollTimeout))
- longPollTimeout = 60000;
-
- // overrides for default maxBitrate values for simulcasting
- function getMaxBitrates(simulcastMaxBitrates) {
- var maxBitrates = {
- high: 900000,
- medium: 300000,
- low: 100000,
- };
-
- if (simulcastMaxBitrates !== undefined && simulcastMaxBitrates !== null) {
- if (simulcastMaxBitrates.high)
- maxBitrates.high = simulcastMaxBitrates.high;
- if (simulcastMaxBitrates.medium)
- maxBitrates.medium = simulcastMaxBitrates.medium;
- if (simulcastMaxBitrates.low)
- maxBitrates.low = simulcastMaxBitrates.low;
- }
-
- return maxBitrates;
- }
-
- var connected = false;
- var sessionId = null;
- var pluginHandles = {};
- var that = this;
- var retries = 0;
- var transactions = {};
- createSession(gatewayCallbacks);
-
- // Public methods
- this.getServer = function() { return server; };
- this.isConnected = function() { return connected; };
- this.reconnect = function(callbacks) {
- callbacks = callbacks || {};
- callbacks.success = (typeof callbacks.success == "function") ? callbacks.success : Janus.noop;
- callbacks.error = (typeof callbacks.error == "function") ? callbacks.error : Janus.noop;
- callbacks["reconnect"] = true;
- createSession(callbacks);
- };
- this.getSessionId = function() { return sessionId; };
- this.getInfo = function(callbacks) { getInfo(callbacks); };
- this.destroy = function(callbacks) { destroySession(callbacks); };
- this.attach = function(callbacks) { createHandle(callbacks); };
-
- function eventHandler() {
- if(sessionId == null)
- return;
- Janus.debug('Long poll...');
- if(!connected) {
- Janus.warn("Is the server down? (connected=false)");
- return;
- }
- var longpoll = server + "/" + sessionId + "?rid=" + new Date().getTime();
- if(maxev)
- longpoll = longpoll + "&maxev=" + maxev;
- if(token)
- longpoll = longpoll + "&token=" + encodeURIComponent(token);
- if(apisecret)
- longpoll = longpoll + "&apisecret=" + encodeURIComponent(apisecret);
- Janus.httpAPICall(longpoll, {
- verb: 'GET',
- withCredentials: withCredentials,
- success: handleEvent,
- timeout: longPollTimeout,
- error: function(textStatus, errorThrown) {
- Janus.error(textStatus + ":", errorThrown);
- retries++;
- if(retries > 3) {
- // Did we just lose the server? :-(
- connected = false;
- gatewayCallbacks.error("Lost connection to the server (is it down?)");
- return;
- }
- eventHandler();
- }
- });
- }
-
- // Private event handler: this will trigger plugin callbacks, if set
- function handleEvent(json, skipTimeout) {
- retries = 0;
- if(!websockets && sessionId !== undefined && sessionId !== null && skipTimeout !== true)
- eventHandler();
- if(!websockets && Janus.isArray(json)) {
- // We got an array: it means we passed a maxev > 1, iterate on all objects
- for(var i=0; i 3) {
+ // Did we just lose the server? :-(
+ connected = false;
+ gatewayCallbacks.error("Lost connection to the server (is it down?)");
+ return;
+ }
+ eventHandler();
+ }
+ });
+ }
+
+ // Private event handler: this will trigger plugin callbacks, if set
+ function handleEvent(json, skipTimeout) {
+ retries = 0;
+ if(!websockets && typeof sessionId !== 'undefined' && sessionId !== null && skipTimeout !== true)
+ eventHandler();
+ if(!websockets && Janus.isArray(json)) {
+ // We got an array: it means we passed a maxev > 1, iterate on all objects
+ for(let i=0; i data channel: ' + dcState);
- if(dcState === 'open') {
- // Any pending messages to send?
- if(config.dataChannel[label].pending && config.dataChannel[label].pending.length > 0) {
- Janus.log("Sending pending messages on <" + label + ">:", config.dataChannel[label].pending.length);
- for(var data of config.dataChannel[label].pending) {
- Janus.log("Sending data on data channel <" + label + ">");
- Janus.debug(data);
- config.dataChannel[label].send(data);
- }
- config.dataChannel[label].pending = [];
- }
- // Notify the open data channel
- pluginHandle.ondataopen(label, protocol);
- }
- };
- var onDataChannelError = function(error) {
- Janus.error('Got error on data channel:', error);
- // TODO
- };
- if(!incoming) {
- // FIXME Add options (ordered, maxRetransmits, etc.)
- var dcoptions = config.dataChannelOptions;
- if(dcprotocol)
- dcoptions.protocol = dcprotocol;
- config.dataChannel[dclabel] = config.pc.createDataChannel(dclabel, dcoptions);
- } else {
- // The channel was created by Janus
- config.dataChannel[dclabel] = incoming;
- }
- config.dataChannel[dclabel].onmessage = onDataChannelMessage;
- config.dataChannel[dclabel].onopen = onDataChannelStateChange;
- config.dataChannel[dclabel].onclose = onDataChannelStateChange;
- config.dataChannel[dclabel].onerror = onDataChannelError;
- config.dataChannel[dclabel].pending = [];
- if(pendingData)
- config.dataChannel[dclabel].pending.push(pendingData);
- }
-
- // Private method to send a data channel message
- function sendData(handleId, callbacks) {
- callbacks = callbacks || {};
- callbacks.success = (typeof callbacks.success == "function") ? callbacks.success : Janus.noop;
- callbacks.error = (typeof callbacks.error == "function") ? callbacks.error : Janus.noop;
- var pluginHandle = pluginHandles[handleId];
- if(!pluginHandle || !pluginHandle.webrtcStuff) {
- Janus.warn("Invalid handle");
- callbacks.error("Invalid handle");
- return;
- }
- var config = pluginHandle.webrtcStuff;
- var data = callbacks.text || callbacks.data;
- if(!data) {
- Janus.warn("Invalid data");
- callbacks.error("Invalid data");
- return;
- }
- var label = callbacks.label ? callbacks.label : Janus.dataChanDefaultLabel;
- if(!config.dataChannel[label]) {
- // Create new data channel and wait for it to open
- createDataChannel(handleId, label, callbacks.protocol, false, data, callbacks.protocol);
- callbacks.success();
- return;
- }
- if(config.dataChannel[label].readyState !== "open") {
- config.dataChannel[label].pending.push(data);
- callbacks.success();
- return;
- }
- Janus.log("Sending data on data channel <" + label + ">");
- Janus.debug(data);
- config.dataChannel[label].send(data);
- callbacks.success();
- }
-
- // Private method to send a DTMF tone
- function sendDtmf(handleId, callbacks) {
- callbacks = callbacks || {};
- callbacks.success = (typeof callbacks.success == "function") ? callbacks.success : Janus.noop;
- callbacks.error = (typeof callbacks.error == "function") ? callbacks.error : Janus.noop;
- var pluginHandle = pluginHandles[handleId];
- if(!pluginHandle || !pluginHandle.webrtcStuff) {
- Janus.warn("Invalid handle");
- callbacks.error("Invalid handle");
- return;
- }
- var config = pluginHandle.webrtcStuff;
- if(!config.dtmfSender) {
- // Create the DTMF sender the proper way, if possible
- if(config.pc) {
- var senders = config.pc.getSenders();
- var audioSender = senders.find(function(sender) {
- return sender.track && sender.track.kind === 'audio';
- });
- if(!audioSender) {
- Janus.warn("Invalid DTMF configuration (no audio track)");
- callbacks.error("Invalid DTMF configuration (no audio track)");
- return;
- }
- config.dtmfSender = audioSender.dtmf;
- if(config.dtmfSender) {
- Janus.log("Created DTMF Sender");
- config.dtmfSender.ontonechange = function(tone) { Janus.debug("Sent DTMF tone: " + tone.tone); };
- }
- }
- if(!config.dtmfSender) {
- Janus.warn("Invalid DTMF configuration");
- callbacks.error("Invalid DTMF configuration");
+ // Private method to send a message
+ function sendMessage(handleId, callbacks) {
+ callbacks = callbacks || {};
+ callbacks.success = (typeof callbacks.success == "function") ? callbacks.success : Janus.noop;
+ callbacks.error = (typeof callbacks.error == "function") ? callbacks.error : Janus.noop;
+ if(!connected) {
+ Janus.warn("Is the server down? (connected=false)");
+ callbacks.error("Is the server down? (connected=false)");
return;
}
- }
- var dtmf = callbacks.dtmf;
- if(!dtmf) {
- Janus.warn("Invalid DTMF parameters");
- callbacks.error("Invalid DTMF parameters");
- return;
- }
- var tones = dtmf.tones;
- if(!tones) {
- Janus.warn("Invalid DTMF string");
- callbacks.error("Invalid DTMF string");
- return;
- }
- var duration = (typeof dtmf.duration === 'number') ? dtmf.duration : 500; // We choose 500ms as the default duration for a tone
- var gap = (typeof dtmf.gap === 'number') ? dtmf.gap : 50; // We choose 50ms as the default gap between tones
- Janus.debug("Sending DTMF string " + tones + " (duration " + duration + "ms, gap " + gap + "ms)");
- config.dtmfSender.insertDTMF(tones, duration, gap);
- callbacks.success();
- }
-
- // Private method to destroy a plugin handle
- function destroyHandle(handleId, callbacks) {
- callbacks = callbacks || {};
- callbacks.success = (typeof callbacks.success == "function") ? callbacks.success : Janus.noop;
- callbacks.error = (typeof callbacks.error == "function") ? callbacks.error : Janus.noop;
- var noRequest = (callbacks.noRequest === true);
- Janus.log("Destroying handle " + handleId + " (only-locally=" + noRequest + ")");
- cleanupWebrtc(handleId);
- var pluginHandle = pluginHandles[handleId];
- if(!pluginHandle || pluginHandle.detached) {
- // Plugin was already detached by Janus, calling detach again will return a handle not found error, so just exit here
- delete pluginHandles[handleId];
- callbacks.success();
- return;
- }
- pluginHandle.detached = true;
- if(noRequest) {
- // We're only removing the handle locally
- delete pluginHandles[handleId];
- callbacks.success();
- return;
- }
- if(!connected) {
- Janus.warn("Is the server down? (connected=false)");
- callbacks.error("Is the server down? (connected=false)");
- return;
- }
- var request = { "janus": "detach", "transaction": Janus.randomString(12) };
- if(pluginHandle.token)
- request["token"] = pluginHandle.token;
- if(apisecret)
- request["apisecret"] = apisecret;
- if(websockets) {
- request["session_id"] = sessionId;
- request["handle_id"] = handleId;
- ws.send(JSON.stringify(request));
- delete pluginHandles[handleId];
- callbacks.success();
- return;
- }
- Janus.httpAPICall(server + "/" + sessionId + "/" + handleId, {
- verb: 'POST',
- withCredentials: withCredentials,
- body: request,
- success: function(json) {
- Janus.log("Destroyed handle:");
- Janus.debug(json);
- if(json["janus"] !== "success") {
- Janus.error("Ooops: " + json["error"].code + " " + json["error"].reason); // FIXME
- }
- delete pluginHandles[handleId];
- callbacks.success();
- },
- error: function(textStatus, errorThrown) {
- Janus.error(textStatus + ":", errorThrown); // FIXME
- // We cleanup anyway
- delete pluginHandles[handleId];
- callbacks.success();
+ let pluginHandle = pluginHandles.get(handleId);
+ if(!pluginHandle || !pluginHandle.webrtcStuff) {
+ Janus.warn("Invalid handle");
+ callbacks.error("Invalid handle");
+ return;
}
- });
- }
-
- // WebRTC stuff
- function streamsDone(handleId, jsep, media, callbacks, stream) {
- var pluginHandle = pluginHandles[handleId];
- if(!pluginHandle || !pluginHandle.webrtcStuff) {
- Janus.warn("Invalid handle");
- // Close all tracks if the given stream has been created internally
- if(!callbacks.stream) {
- Janus.stopAllTracks(stream);
- }
- callbacks.error("Invalid handle");
- return;
- }
- var config = pluginHandle.webrtcStuff;
- Janus.debug("streamsDone:", stream);
- if(stream) {
- Janus.debug(" -- Audio tracks:", stream.getAudioTracks());
- Janus.debug(" -- Video tracks:", stream.getVideoTracks());
- }
- // We're now capturing the new stream: check if we're updating or if it's a new thing
- var addTracks = false;
- if(!config.myStream || !media.update || (config.streamExternal && !media.replaceAudio && !media.replaceVideo)) {
- config.myStream = stream;
- addTracks = true;
- } else {
- // We only need to update the existing stream
- if(((!media.update && isAudioSendEnabled(media)) || (media.update && (media.addAudio || media.replaceAudio))) &&
- stream.getAudioTracks() && stream.getAudioTracks().length) {
- config.myStream.addTrack(stream.getAudioTracks()[0]);
- if(Janus.unifiedPlan) {
- // Use Transceivers
- Janus.log((media.replaceAudio ? "Replacing" : "Adding") + " audio track:", stream.getAudioTracks()[0]);
- var audioTransceiver = null;
- const transceivers = config.pc.getTransceivers();
+ let message = callbacks.message;
+ let jsep = callbacks.jsep;
+ let transaction = Janus.randomString(12);
+ let request = { "janus": "message", "body": message, "transaction": transaction };
+ if(pluginHandle.token)
+ request["token"] = pluginHandle.token;
+ if(apisecret)
+ request["apisecret"] = apisecret;
+ if(jsep) {
+ request.jsep = {
+ type: jsep.type,
+ sdp: jsep.sdp
+ };
+ if(jsep.e2ee)
+ request.jsep.e2ee = true;
+ if(jsep.rid_order === "hml" || jsep.rid_order === "lmh")
+ request.jsep.rid_order = jsep.rid_order;
+ if(jsep.force_relay)
+ request.jsep.force_relay = true;
+ // Check if there's SVC video streams to tell Janus about
+ let svc = null;
+ let config = pluginHandle.webrtcStuff;
+ if(config.pc) {
+ let transceivers = config.pc.getTransceivers();
if(transceivers && transceivers.length > 0) {
- for(const t of transceivers) {
- if((t.sender && t.sender.track && t.sender.track.kind === "audio") ||
- (t.receiver && t.receiver.track && t.receiver.track.kind === "audio")) {
- audioTransceiver = t;
- break;
+ for(let mindex in transceivers) {
+ let tr = transceivers[mindex];
+ if(tr && tr.sender && tr.sender.track && tr.sender.track.kind === 'video') {
+ let params = tr.sender.getParameters();
+ if(params && params.encodings && params.encodings.length === 1 &&
+ params.encodings[0] && params.encodings[0].scalabilityMode) {
+ // This video stream uses SVC
+ if(!svc)
+ svc = [];
+ svc.push({
+ mindex: parseInt(mindex),
+ mid: tr.mid,
+ svc: params.encodings[0].scalabilityMode
+ });
+ }
}
}
}
- if(audioTransceiver && audioTransceiver.sender) {
- audioTransceiver.sender.replaceTrack(stream.getAudioTracks()[0]);
- } else {
- config.pc.addTrack(stream.getAudioTracks()[0], stream);
- }
- } else {
- Janus.log((media.replaceAudio ? "Replacing" : "Adding") + " audio track:", stream.getAudioTracks()[0]);
- config.pc.addTrack(stream.getAudioTracks()[0], stream);
}
+ if(svc)
+ request.jsep.svc = svc;
}
- if(((!media.update && isVideoSendEnabled(media)) || (media.update && (media.addVideo || media.replaceVideo))) &&
- stream.getVideoTracks() && stream.getVideoTracks().length) {
- config.myStream.addTrack(stream.getVideoTracks()[0]);
- if(Janus.unifiedPlan) {
- // Use Transceivers
- Janus.log((media.replaceVideo ? "Replacing" : "Adding") + " video track:", stream.getVideoTracks()[0]);
- var videoTransceiver = null;
- const transceivers = config.pc.getTransceivers();
- if(transceivers && transceivers.length > 0) {
- for(const t of transceivers) {
- if((t.sender && t.sender.track && t.sender.track.kind === "video") ||
- (t.receiver && t.receiver.track && t.receiver.track.kind === "video")) {
- videoTransceiver = t;
- break;
- }
+ Janus.debug("Sending message to plugin (handle=" + handleId + "):");
+ Janus.debug(request);
+ if(websockets) {
+ request["session_id"] = sessionId;
+ request["handle_id"] = handleId;
+ transactions.set(transaction, function(json) {
+ Janus.debug("Message sent!");
+ Janus.debug(json);
+ if(json["janus"] === "success") {
+ // We got a success, must have been a synchronous transaction
+ let plugindata = json["plugindata"];
+ if(!plugindata) {
+ Janus.warn("Request succeeded, but missing plugindata...");
+ callbacks.success();
+ return;
}
+ Janus.log("Synchronous transaction successful (" + plugindata["plugin"] + ")");
+ let data = plugindata["data"];
+ Janus.debug(data);
+ callbacks.success(data);
+ return;
+ } else if(json["janus"] !== "ack") {
+ // Not a success and not an ack, must be an error
+ if(json["error"]) {
+ Janus.error("Ooops: " + json["error"].code + " " + json["error"].reason); // FIXME
+ callbacks.error(json["error"].code + " " + json["error"].reason);
+ } else {
+ Janus.error("Unknown error"); // FIXME
+ callbacks.error("Unknown error");
+ }
+ return;
}
- if(videoTransceiver && videoTransceiver.sender) {
- videoTransceiver.sender.replaceTrack(stream.getVideoTracks()[0]);
- } else {
- config.pc.addTrack(stream.getVideoTracks()[0], stream);
+ // If we got here, the plugin decided to handle the request asynchronously
+ callbacks.success();
+ });
+ ws.send(JSON.stringify(request));
+ return;
+ }
+ Janus.httpAPICall(server + "/" + sessionId + "/" + handleId, {
+ verb: 'POST',
+ withCredentials: withCredentials,
+ body: request,
+ success: function(json) {
+ Janus.debug("Message sent!");
+ Janus.debug(json);
+ if(json["janus"] === "success") {
+ // We got a success, must have been a synchronous transaction
+ let plugindata = json["plugindata"];
+ if(!plugindata) {
+ Janus.warn("Request succeeded, but missing plugindata...");
+ callbacks.success();
+ return;
+ }
+ Janus.log("Synchronous transaction successful (" + plugindata["plugin"] + ")");
+ let data = plugindata["data"];
+ Janus.debug(data);
+ callbacks.success(data);
+ return;
+ } else if(json["janus"] !== "ack") {
+ // Not a success and not an ack, must be an error
+ if(json["error"]) {
+ Janus.error("Ooops: " + json["error"].code + " " + json["error"].reason); // FIXME
+ callbacks.error(json["error"].code + " " + json["error"].reason);
+ } else {
+ Janus.error("Unknown error"); // FIXME
+ callbacks.error("Unknown error");
+ }
+ return;
}
- } else {
- Janus.log((media.replaceVideo ? "Replacing" : "Adding") + " video track:", stream.getVideoTracks()[0]);
- config.pc.addTrack(stream.getVideoTracks()[0], stream);
+ // If we got here, the plugin decided to handle the request asynchronously
+ callbacks.success();
+ },
+ error: function(textStatus, errorThrown) {
+ Janus.error(textStatus + ":", errorThrown); // FIXME
+ callbacks.error(textStatus + ": " + errorThrown);
+ }
+ });
+ }
+
+ // Private method to send a trickle candidate
+ function sendTrickleCandidate(handleId, candidate) {
+ if(!connected) {
+ Janus.warn("Is the server down? (connected=false)");
+ return;
+ }
+ let pluginHandle = pluginHandles.get(handleId);
+ if(!pluginHandle || !pluginHandle.webrtcStuff) {
+ Janus.warn("Invalid handle");
+ return;
+ }
+ let request = { "janus": "trickle", "candidate": candidate, "transaction": Janus.randomString(12) };
+ if(pluginHandle.token)
+ request["token"] = pluginHandle.token;
+ if(apisecret)
+ request["apisecret"] = apisecret;
+ Janus.vdebug("Sending trickle candidate (handle=" + handleId + "):");
+ Janus.vdebug(request);
+ if(websockets) {
+ request["session_id"] = sessionId;
+ request["handle_id"] = handleId;
+ ws.send(JSON.stringify(request));
+ return;
+ }
+ Janus.httpAPICall(server + "/" + sessionId + "/" + handleId, {
+ verb: 'POST',
+ withCredentials: withCredentials,
+ body: request,
+ success: function(json) {
+ Janus.vdebug("Candidate sent!");
+ Janus.vdebug(json);
+ if(json["janus"] !== "ack") {
+ Janus.error("Ooops: " + json["error"].code + " " + json["error"].reason); // FIXME
+ return;
+ }
+ },
+ error: function(textStatus, errorThrown) {
+ Janus.error(textStatus + ":", errorThrown); // FIXME
+ }
+ });
+ }
+
+ // Private method to create a data channel
+ function createDataChannel(handleId, dclabel, dcprotocol, incoming, pendingData) {
+ let pluginHandle = pluginHandles.get(handleId);
+ if(!pluginHandle || !pluginHandle.webrtcStuff) {
+ Janus.warn("Invalid handle");
+ return;
+ }
+ let config = pluginHandle.webrtcStuff;
+ if(!config.pc) {
+ Janus.warn("Invalid PeerConnection");
+ return;
+ }
+ let onDataChannelMessage = function(event) {
+ Janus.log('Received message on data channel:', event);
+ let label = event.target.label;
+ pluginHandle.ondata(event.data, label);
+ };
+ let onDataChannelStateChange = function(event) {
+ Janus.log('Received state change on data channel:', event);
+ let label = event.target.label;
+ let protocol = event.target.protocol;
+ let dcState = config.dataChannel[label] ? config.dataChannel[label].readyState : "null";
+ Janus.log('State change on <' + label + '> data channel: ' + dcState);
+ if(dcState === 'open') {
+ // Any pending messages to send?
+ if(config.dataChannel[label].pending && config.dataChannel[label].pending.length > 0) {
+ Janus.log("Sending pending messages on <" + label + ">:", config.dataChannel[label].pending.length);
+ for(let data of config.dataChannel[label].pending) {
+ Janus.log("Sending data on data channel <" + label + ">");
+ Janus.debug(data);
+ config.dataChannel[label].send(data);
+ }
+ config.dataChannel[label].pending = [];
+ }
+ // Notify the open data channel
+ pluginHandle.ondataopen(label, protocol);
+ }
+ };
+ let onDataChannelError = function(error) {
+ Janus.error('Got error on data channel:', error);
+ // TODO
+ };
+ if(!incoming) {
+ // FIXME Add options (ordered, maxRetransmits, etc.)
+ let dcoptions = config.dataChannelOptions;
+ if(dcprotocol)
+ dcoptions.protocol = dcprotocol;
+ config.dataChannel[dclabel] = config.pc.createDataChannel(dclabel, dcoptions);
+ } else {
+ // The channel was created by Janus
+ config.dataChannel[dclabel] = incoming;
+ }
+ config.dataChannel[dclabel].onmessage = onDataChannelMessage;
+ config.dataChannel[dclabel].onopen = onDataChannelStateChange;
+ config.dataChannel[dclabel].onclose = onDataChannelStateChange;
+ config.dataChannel[dclabel].onerror = onDataChannelError;
+ config.dataChannel[dclabel].pending = [];
+ if(pendingData)
+ config.dataChannel[dclabel].pending.push(pendingData);
+ }
+
+ // Private method to send a data channel message
+ function sendData(handleId, callbacks) {
+ callbacks = callbacks || {};
+ callbacks.success = (typeof callbacks.success == "function") ? callbacks.success : Janus.noop;
+ callbacks.error = (typeof callbacks.error == "function") ? callbacks.error : Janus.noop;
+ let pluginHandle = pluginHandles.get(handleId);
+ if(!pluginHandle || !pluginHandle.webrtcStuff) {
+ Janus.warn("Invalid handle");
+ callbacks.error("Invalid handle");
+ return;
+ }
+ let config = pluginHandle.webrtcStuff;
+ let data = callbacks.text || callbacks.data;
+ if(!data) {
+ Janus.warn("Invalid data");
+ callbacks.error("Invalid data");
+ return;
+ }
+ let label = callbacks.label ? callbacks.label : Janus.dataChanDefaultLabel;
+ if(!config.dataChannel[label]) {
+ // Create new data channel and wait for it to open
+ createDataChannel(handleId, label, callbacks.protocol, false, data, callbacks.protocol);
+ callbacks.success();
+ return;
+ }
+ if(config.dataChannel[label].readyState !== "open") {
+ config.dataChannel[label].pending.push(data);
+ callbacks.success();
+ return;
+ }
+ Janus.log("Sending data on data channel <" + label + ">");
+ Janus.debug(data);
+ config.dataChannel[label].send(data);
+ callbacks.success();
+ }
+
+ // Private method to send a DTMF tone
+ function sendDtmf(handleId, callbacks) {
+ callbacks = callbacks || {};
+ callbacks.success = (typeof callbacks.success == "function") ? callbacks.success : Janus.noop;
+ callbacks.error = (typeof callbacks.error == "function") ? callbacks.error : Janus.noop;
+ let pluginHandle = pluginHandles.get(handleId);
+ if(!pluginHandle || !pluginHandle.webrtcStuff) {
+ Janus.warn("Invalid handle");
+ callbacks.error("Invalid handle");
+ return;
+ }
+ let config = pluginHandle.webrtcStuff;
+ if(!config.dtmfSender) {
+ // Create the DTMF sender the proper way, if possible
+ if(config.pc) {
+ let senders = config.pc.getSenders();
+ let audioSender = senders.find(function(sender) {
+ return sender.track && sender.track.kind === 'audio';
+ });
+ if(!audioSender) {
+ Janus.warn("Invalid DTMF configuration (no audio track)");
+ callbacks.error("Invalid DTMF configuration (no audio track)");
+ return;
+ }
+ config.dtmfSender = audioSender.dtmf;
+ if(config.dtmfSender) {
+ Janus.log("Created DTMF Sender");
+ config.dtmfSender.ontonechange = function(tone) { Janus.debug("Sent DTMF tone: " + tone.tone); };
+ }
+ }
+ if(!config.dtmfSender) {
+ Janus.warn("Invalid DTMF configuration");
+ callbacks.error("Invalid DTMF configuration");
+ return;
}
}
+ let dtmf = callbacks.dtmf;
+ if(!dtmf) {
+ Janus.warn("Invalid DTMF parameters");
+ callbacks.error("Invalid DTMF parameters");
+ return;
+ }
+ let tones = dtmf.tones;
+ if(!tones) {
+ Janus.warn("Invalid DTMF string");
+ callbacks.error("Invalid DTMF string");
+ return;
+ }
+ let duration = (typeof dtmf.duration === 'number') ? dtmf.duration : 500; // We choose 500ms as the default duration for a tone
+ let gap = (typeof dtmf.gap === 'number') ? dtmf.gap : 50; // We choose 50ms as the default gap between tones
+ Janus.debug("Sending DTMF string " + tones + " (duration " + duration + "ms, gap " + gap + "ms)");
+ config.dtmfSender.insertDTMF(tones, duration, gap);
+ callbacks.success();
}
- // If we still need to create a PeerConnection, let's do that
- if(!config.pc) {
- var pc_config = {"iceServers": iceServers, "iceTransportPolicy": iceTransportPolicy, "bundlePolicy": bundlePolicy};
- if(Janus.webRTCAdapter.browserDetails.browser === "chrome") {
- // For Chrome versions before 72, we force a plan-b semantic, and unified-plan otherwise
- pc_config["sdpSemantics"] = (Janus.webRTCAdapter.browserDetails.version < 72) ? "plan-b" : "unified-plan";
+
+ // Private method to destroy a plugin handle
+ function destroyHandle(handleId, callbacks) {
+ callbacks = callbacks || {};
+ callbacks.success = (typeof callbacks.success == "function") ? callbacks.success : Janus.noop;
+ callbacks.error = (typeof callbacks.error == "function") ? callbacks.error : Janus.noop;
+ let noRequest = (callbacks.noRequest === true);
+ Janus.log("Destroying handle " + handleId + " (only-locally=" + noRequest + ")");
+ cleanupWebrtc(handleId);
+ let pluginHandle = pluginHandles.get(handleId);
+ if(!pluginHandle || pluginHandle.detached) {
+ // Plugin was already detached by Janus, calling detach again will return a handle not found error, so just exit here
+ pluginHandles.delete(handleId);
+ callbacks.success();
+ return;
}
- var pc_constraints = {};
- if(Janus.webRTCAdapter.browserDetails.browser === "edge") {
- // This is Edge, enable BUNDLE explicitly
- pc_config.bundlePolicy = "max-bundle";
+ pluginHandle.detached = true;
+ if(noRequest) {
+ // We're only removing the handle locally
+ pluginHandles.delete(handleId);
+ callbacks.success();
+ return;
}
+ if(!connected) {
+ Janus.warn("Is the server down? (connected=false)");
+ callbacks.error("Is the server down? (connected=false)");
+ return;
+ }
+ let request = { "janus": "detach", "transaction": Janus.randomString(12) };
+ if(pluginHandle.token)
+ request["token"] = pluginHandle.token;
+ if(apisecret)
+ request["apisecret"] = apisecret;
+ if(websockets) {
+ request["session_id"] = sessionId;
+ request["handle_id"] = handleId;
+ ws.send(JSON.stringify(request));
+ pluginHandles.delete(handleId);
+ callbacks.success();
+ return;
+ }
+ Janus.httpAPICall(server + "/" + sessionId + "/" + handleId, {
+ verb: 'POST',
+ withCredentials: withCredentials,
+ body: request,
+ success: function(json) {
+ Janus.log("Destroyed handle:");
+ Janus.debug(json);
+ if(json["janus"] !== "success") {
+ Janus.error("Ooops: " + json["error"].code + " " + json["error"].reason); // FIXME
+ }
+ pluginHandles.delete(handleId);
+ callbacks.success();
+ },
+ error: function(textStatus, errorThrown) {
+ Janus.error(textStatus + ":", errorThrown); // FIXME
+ // We cleanup anyway
+ pluginHandles.delete(handleId);
+ callbacks.success();
+ }
+ });
+ }
+
+ // WebRTC stuff
+ // Helper function to create a new PeerConnection, if we need one
+ function createPeerconnectionIfNeeded(handleId, callbacks) {
+ let pluginHandle = pluginHandles.get(handleId);
+ if(!pluginHandle || !pluginHandle.webrtcStuff) {
+ Janus.warn("Invalid handle");
+ throw "Invalid handle";
+ }
+ let config = pluginHandle.webrtcStuff;
+ if(config.pc) {
+ // Nothing to do, we have a PeerConnection already
+ return;
+ }
+ let pc_config = {
+ iceServers: (typeof iceServers === "function" ? iceServers() : iceServers),
+ iceTransportPolicy: iceTransportPolicy,
+ bundlePolicy: bundlePolicy
+ };
+ pc_config.sdpSemantics = 'unified-plan';
// Check if a sender or receiver transform has been provided
- if(RTCRtpSender && (RTCRtpSender.prototype.createEncodedStreams ||
- (RTCRtpSender.prototype.createEncodedAudioStreams &&
- RTCRtpSender.prototype.createEncodedVideoStreams)) &&
- (callbacks.senderTransforms || callbacks.receiverTransforms)) {
- config.senderTransforms = callbacks.senderTransforms;
- config.receiverTransforms = callbacks.receiverTransforms;
- pc_config["forceEncodedAudioInsertableStreams"] = true;
- pc_config["forceEncodedVideoInsertableStreams"] = true;
- pc_config["encodedInsertableStreams"] = true;
+ let insertableStreams = false;
+ if(callbacks.tracks) {
+ for(let track of callbacks.tracks) {
+ if(track.transforms && (track.transforms.sender || track.transforms.receiver)) {
+ insertableStreams = true;
+ break;
+ }
+ }
}
- Janus.log("Creating PeerConnection");
- Janus.debug(pc_constraints);
- config.pc = new RTCPeerConnection(pc_config, pc_constraints);
+ if(callbacks.externalEncryption) {
+ insertableStreams = true;
+ config.externalEncryption = true;
+ }
+ if(RTCRtpSender && (RTCRtpSender.prototype.createEncodedStreams ||
+ (RTCRtpSender.prototype.createEncodedAudioStreams &&
+ RTCRtpSender.prototype.createEncodedVideoStreams)) && insertableStreams) {
+ config.insertableStreams = true;
+ pc_config.forceEncodedAudioInsertableStreams = true;
+ pc_config.forceEncodedVideoInsertableStreams = true;
+ pc_config.encodedInsertableStreams = true;
+ }
+ Janus.log('Creating PeerConnection');
+ config.pc = new RTCPeerConnection(pc_config);
Janus.debug(config.pc);
if(config.pc.getStats) { // FIXME
config.volume = {};
- config.bitrate.value = "0 kbits/sec";
+ config.bitrate.value = '0 kbits/sec';
}
- Janus.log("Preparing local SDP and gathering candidates (trickle=" + config.trickle + ")");
+ Janus.log('Preparing local SDP and gathering candidates (trickle=' + config.trickle + ')');
+ config.pc.onconnectionstatechange = function() {
+ if(config.pc)
+ pluginHandle.connectionState(config.pc.connectionState);
+ };
config.pc.oniceconnectionstatechange = function() {
if(config.pc)
pluginHandle.iceState(config.pc.iceConnectionState);
};
config.pc.onicecandidate = function(event) {
- if (!event.candidate ||
- (Janus.webRTCAdapter.browserDetails.browser === 'edge' && event.candidate.candidate.indexOf('endOfCandidates') > 0)) {
- Janus.log("End of candidates.");
+ if(!event.candidate || (event.candidate.candidate && event.candidate.candidate.indexOf('endOfCandidates') > 0)) {
+ Janus.log('End of candidates.');
config.iceDone = true;
if(config.trickle === true) {
// Notify end of candidates
- sendTrickleCandidate(handleId, {"completed": true});
+ sendTrickleCandidate(handleId, { completed : true });
} else {
// No trickle, time to send the complete SDP (including all candidates)
sendSDP(handleId, callbacks);
@@ -1870,10 +1952,10 @@ export function Janus(gatewayCallbacks) {
} else {
// JSON.stringify doesn't work on some WebRTC objects anymore
// See https://code.google.com/p/chromium/issues/detail?id=467366
- var candidate = {
- "candidate": event.candidate.candidate,
- "sdpMid": event.candidate.sdpMid,
- "sdpMLineIndex": event.candidate.sdpMLineIndex
+ let candidate = {
+ candidate: event.candidate.candidate,
+ sdpMid: event.candidate.sdpMid,
+ sdpMLineIndex: event.candidate.sdpMLineIndex
};
if(config.trickle === true) {
// Send candidate
@@ -1882,1770 +1964,1245 @@ export function Janus(gatewayCallbacks) {
}
};
config.pc.ontrack = function(event) {
- Janus.log("Handling Remote Track");
- Janus.debug(event);
+ Janus.log('Handling Remote Track', event);
if(!event.streams)
return;
- config.remoteStream = event.streams[0];
- pluginHandle.onremotestream(config.remoteStream);
+ if(!event.track)
+ return;
+ // Notify about the new track event
+ let mid = event.transceiver ? event.transceiver.mid : event.track.id;
+ try {
+ if(event.transceiver && event.transceiver.mid && event.track.id) {
+ // Keep track of the mapping between track ID and mid, since
+ // when a track is removed the transceiver may be gone already
+ if(!pluginHandle.mids)
+ pluginHandle.mids = {};
+ pluginHandle.mids[event.track.id] = event.transceiver.mid;
+ }
+ pluginHandle.onremotetrack(event.track, mid, true, { reason: 'created' });
+ } catch(e) {
+ Janus.error("Error calling onremotetrack", e);
+ }
if(event.track.onended)
return;
- if(config.receiverTransforms) {
- var receiverStreams = null;
- if(RTCRtpSender.prototype.createEncodedStreams) {
- receiverStreams = event.receiver.createEncodedStreams();
- } else if(RTCRtpSender.prototype.createAudioEncodedStreams || RTCRtpSender.prototype.createEncodedVideoStreams) {
- if(event.track.kind === "audio" && config.receiverTransforms["audio"]) {
- receiverStreams = event.receiver.createEncodedAudioStreams();
- } else if(event.track.kind === "video" && config.receiverTransforms["video"]) {
- receiverStreams = event.receiver.createEncodedVideoStreams();
- }
- }
- if(receiverStreams) {
- console.log(receiverStreams);
- if(receiverStreams.readableStream && receiverStreams.writableStream) {
- receiverStreams.readableStream
- .pipeThrough(config.receiverTransforms[event.track.kind])
- .pipeTo(receiverStreams.writableStream);
- } else if(receiverStreams.readable && receiverStreams.writable) {
- receiverStreams.readable
- .pipeThrough(config.receiverTransforms[event.track.kind])
- .pipeTo(receiverStreams.writable);
- }
- }
- }
- var trackMutedTimeoutId = null;
- Janus.log("Adding onended callback to track:", event.track);
+ let trackMutedTimeoutId = null;
+ Janus.log('Adding onended callback to track:', event.track);
event.track.onended = function(ev) {
- Janus.log("Remote track removed:", ev);
- if(config.remoteStream) {
- clearTimeout(trackMutedTimeoutId);
- config.remoteStream.removeTrack(ev.target);
- pluginHandle.onremotestream(config.remoteStream);
+ Janus.log('Remote track removed:', ev);
+ clearTimeout(trackMutedTimeoutId);
+ // Notify the application
+ let transceivers = config.pc ? config.pc.getTransceivers() : null;
+ let transceiver = transceivers ? transceivers.find(
+ t => t.receiver.track === ev.target) : null;
+ let mid = transceiver ? transceiver.mid : ev.target.id;
+ if(mid === ev.target.id && pluginHandle.mids && pluginHandle.mids[event.track.id])
+ mid = pluginHandle.mids[event.track.id];
+ try {
+ pluginHandle.onremotetrack(ev.target, mid, false, { reason: 'ended' });
+ } catch(e) {
+ Janus.error("Error calling onremotetrack on removal", e);
}
+ delete pluginHandle.mids[event.track.id];
};
event.track.onmute = function(ev) {
- Janus.log("Remote track muted:", ev);
- if(config.remoteStream && trackMutedTimeoutId == null) {
+ Janus.log('Remote track muted:', ev);
+ if(!trackMutedTimeoutId) {
trackMutedTimeoutId = setTimeout(function() {
- Janus.log("Removing remote track");
- if (config.remoteStream) {
- config.remoteStream.removeTrack(ev.target);
- pluginHandle.onremotestream(config.remoteStream);
+ Janus.log('Removing remote track');
+ // Notify the application the track is gone
+ let transceivers = config.pc ? config.pc.getTransceivers() : null;
+ let transceiver = transceivers ? transceivers.find(
+ t => t.receiver.track === ev.target) : null;
+ let mid = transceiver ? transceiver.mid : ev.target.id;
+ if(mid === ev.target.id && pluginHandle.mids && pluginHandle.mids[event.track.id])
+ mid = pluginHandle.mids[event.track.id];
+ try {
+ pluginHandle.onremotetrack(ev.target, mid, false, { reason: 'mute' } );
+ } catch(e) {
+ Janus.error("Error calling onremotetrack on mute", e);
}
trackMutedTimeoutId = null;
- // Chrome seems to raise mute events only at multiples of 834ms;
- // we set the timeout to three times this value (rounded to 840ms)
+ // Chrome seems to raise mute events only at multiples of 834ms;
+ // we set the timeout to three times this value (rounded to 840ms)
}, 3 * 840);
}
};
event.track.onunmute = function(ev) {
- Janus.log("Remote track flowing again:", ev);
+ Janus.log('Remote track flowing again:', ev);
if(trackMutedTimeoutId != null) {
clearTimeout(trackMutedTimeoutId);
trackMutedTimeoutId = null;
} else {
try {
- config.remoteStream.addTrack(ev.target);
- pluginHandle.onremotestream(config.remoteStream);
+ // Notify the application the track is back
+ let transceivers = config.pc ? config.pc.getTransceivers() : null;
+ let transceiver = transceivers ? transceivers.find(
+ t => t.receiver.track === ev.target) : null;
+ let mid = transceiver ? transceiver.mid : ev.target.id;
+ pluginHandle.onremotetrack(ev.target, mid, true, { reason: 'unmute' });
} catch(e) {
- Janus.error(e);
+ Janus.error("Error calling onremotetrack on unmute", e);
}
}
};
};
}
- if(addTracks && stream) {
- Janus.log('Adding local stream');
- var simulcast = (callbacks.simulcast === true || callbacks.simulcast2 === true) && Janus.unifiedPlan;
- var svc = callbacks.svc;
- stream.getTracks().forEach(function(track) {
- Janus.log('Adding local track:', track);
- var sender = null;
- if((!simulcast && !svc) || track.kind === 'audio') {
- sender = config.pc.addTrack(track, stream);
- } else if(simulcast) {
- Janus.log('Enabling rid-based simulcasting:', track);
- let maxBitrates = getMaxBitrates(callbacks.simulcastMaxBitrates);
- let tr = config.pc.addTransceiver(track, {
- direction: "sendrecv",
- streams: [stream],
- sendEncodings: callbacks.sendEncodings || [
- { rid: "h", active: true, maxBitrate: maxBitrates.high },
- { rid: "m", active: true, maxBitrate: maxBitrates.medium, scaleResolutionDownBy: 2 },
- { rid: "l", active: true, maxBitrate: maxBitrates.low, scaleResolutionDownBy: 4 }
- ]
- });
- if(tr)
- sender = tr.sender;
- } else {
- Janus.log('Enabling SVC (' + svc + '):', track);
- let tr = config.pc.addTransceiver(track, {
- direction: "sendrecv",
- streams: [stream],
- sendEncodings: [
- { scalabilityMode: svc }
- ]
- });
- if(tr)
- sender = tr.sender;
- }
- // Check if insertable streams are involved
- if(sender && config.senderTransforms) {
- var senderStreams = null;
- if(RTCRtpSender.prototype.createEncodedStreams) {
- senderStreams = sender.createEncodedStreams();
- } else if(RTCRtpSender.prototype.createAudioEncodedStreams || RTCRtpSender.prototype.createEncodedVideoStreams) {
- if(sender.track.kind === "audio" && config.senderTransforms["audio"]) {
- senderStreams = sender.createEncodedAudioStreams();
- } else if(sender.track.kind === "video" && config.senderTransforms["video"]) {
- senderStreams = sender.createEncodedVideoStreams();
- }
- }
- if(senderStreams) {
- console.log(senderStreams);
- if(senderStreams.readableStream && senderStreams.writableStream) {
- senderStreams.readableStream
- .pipeThrough(config.senderTransforms[sender.track.kind])
- .pipeTo(senderStreams.writableStream);
- } else if(senderStreams.readable && senderStreams.writable) {
- senderStreams.readable
- .pipeThrough(config.senderTransforms[sender.track.kind])
- .pipeTo(senderStreams.writable);
- }
- }
- }
- });
- }
- // Any data channel to create?
- if(isDataEnabled(media) && !config.dataChannel[Janus.dataChanDefaultLabel]) {
- Janus.log("Creating default data channel");
- createDataChannel(handleId, Janus.dataChanDefaultLabel, null, false);
- config.pc.ondatachannel = function(event) {
- Janus.log("Data channel created by Janus:", event);
- createDataChannel(handleId, event.channel.label, event.channel.protocol, event.channel);
- };
- }
- // If there's a new local stream, let's notify the application
- if(config.myStream) {
- pluginHandle.onlocalstream(config.myStream);
- }
- // Create offer/answer now
- if(!jsep) {
- createOffer(handleId, media, callbacks);
- } else {
- config.pc.setRemoteDescription(jsep)
- .then(function() {
- Janus.log("Remote description accepted!");
- config.remoteSdp = jsep.sdp;
- // Any trickle candidate we cached?
- if(config.candidates && config.candidates.length > 0) {
- for(var i = 0; i< config.candidates.length; i++) {
- var candidate = config.candidates[i];
- Janus.debug("Adding remote candidate:", candidate);
- if(!candidate || candidate.completed === true) {
- // end-of-candidates
- config.pc.addIceCandidate(Janus.endOfCandidates);
- } else {
- // New candidate
- config.pc.addIceCandidate(candidate);
- }
- }
- config.candidates = [];
- }
- // Create the answer now
- createAnswer(handleId, media, callbacks);
- }, callbacks.error);
- }
- }
- function prepareWebrtc(handleId, offer, callbacks) {
- callbacks = callbacks || {};
- callbacks.success = (typeof callbacks.success == "function") ? callbacks.success : Janus.noop;
- callbacks.error = (typeof callbacks.error == "function") ? callbacks.error : webrtcError;
- var jsep = callbacks.jsep;
- if(offer && jsep) {
- Janus.error("Provided a JSEP to a createOffer");
- callbacks.error("Provided a JSEP to a createOffer");
- return;
- } else if(!offer && (!jsep || !jsep.type || !jsep.sdp)) {
- Janus.error("A valid JSEP is required for createAnswer");
- callbacks.error("A valid JSEP is required for createAnswer");
- return;
- }
- /* Check that callbacks.media is a (not null) Object */
- callbacks.media = (typeof callbacks.media === 'object' && callbacks.media) ? callbacks.media : { audio: true, video: true };
- var media = callbacks.media;
- var pluginHandle = pluginHandles[handleId];
- if(!pluginHandle || !pluginHandle.webrtcStuff) {
- Janus.warn("Invalid handle");
- callbacks.error("Invalid handle");
- return;
- }
- var config = pluginHandle.webrtcStuff;
- config.trickle = isTrickleEnabled(callbacks.trickle);
- // Are we updating a session?
- if(!config.pc) {
- // Nope, new PeerConnection
- media.update = false;
- media.keepAudio = false;
- media.keepVideo = false;
- } else {
- Janus.log("Updating existing media session");
- media.update = true;
- // Check if there's anything to add/remove/replace, or if we
- // can go directly to preparing the new SDP offer or answer
- if(callbacks.stream) {
- // External stream: is this the same as the one we were using before?
- if(callbacks.stream !== config.myStream) {
- Janus.log("Renegotiation involves a new external stream");
- }
- } else {
- // Check if there are changes on audio
- if(media.addAudio) {
- media.keepAudio = false;
- media.replaceAudio = false;
- media.removeAudio = false;
- media.audioSend = true;
- if(config.myStream && config.myStream.getAudioTracks() && config.myStream.getAudioTracks().length) {
- Janus.error("Can't add audio stream, there already is one");
- callbacks.error("Can't add audio stream, there already is one");
- return;
- }
- } else if(media.removeAudio) {
- media.keepAudio = false;
- media.replaceAudio = false;
- media.addAudio = false;
- media.audioSend = false;
- } else if(media.replaceAudio) {
- media.keepAudio = false;
- media.addAudio = false;
- media.removeAudio = false;
- media.audioSend = true;
- }
- if(!config.myStream) {
- // No media stream: if we were asked to replace, it's actually an "add"
- if(media.replaceAudio) {
- media.keepAudio = false;
- media.replaceAudio = false;
- media.addAudio = true;
- media.audioSend = true;
- }
- if(isAudioSendEnabled(media)) {
- media.keepAudio = false;
- media.addAudio = true;
- }
- } else {
- if(!config.myStream.getAudioTracks() || config.myStream.getAudioTracks().length === 0) {
- // No audio track: if we were asked to replace, it's actually an "add"
- if(media.replaceAudio) {
- media.keepAudio = false;
- media.replaceAudio = false;
- media.addAudio = true;
- media.audioSend = true;
- }
- if(isAudioSendEnabled(media)) {
- media.keepAudio = false;
- media.addAudio = true;
- }
- } else {
- // We have an audio track: should we keep it as it is?
- if(isAudioSendEnabled(media) &&
- !media.removeAudio && !media.replaceAudio) {
- media.keepAudio = true;
- }
- }
- }
- // Check if there are changes on video
- if(media.addVideo) {
- media.keepVideo = false;
- media.replaceVideo = false;
- media.removeVideo = false;
- media.videoSend = true;
- if(config.myStream && config.myStream.getVideoTracks() && config.myStream.getVideoTracks().length) {
- Janus.error("Can't add video stream, there already is one");
- callbacks.error("Can't add video stream, there already is one");
- return;
- }
- } else if(media.removeVideo) {
- media.keepVideo = false;
- media.replaceVideo = false;
- media.addVideo = false;
- media.videoSend = false;
- } else if(media.replaceVideo) {
- media.keepVideo = false;
- media.addVideo = false;
- media.removeVideo = false;
- media.videoSend = true;
- }
- if(!config.myStream) {
- // No media stream: if we were asked to replace, it's actually an "add"
- if(media.replaceVideo) {
- media.keepVideo = false;
- media.replaceVideo = false;
- media.addVideo = true;
- media.videoSend = true;
- }
- if(isVideoSendEnabled(media)) {
- media.keepVideo = false;
- media.addVideo = true;
- }
- } else {
- if(!config.myStream.getVideoTracks() || config.myStream.getVideoTracks().length === 0) {
- // No video track: if we were asked to replace, it's actually an "add"
- if(media.replaceVideo) {
- media.keepVideo = false;
- media.replaceVideo = false;
- media.addVideo = true;
- media.videoSend = true;
- }
- if(isVideoSendEnabled(media)) {
- media.keepVideo = false;
- media.addVideo = true;
- }
- } else {
- // We have a video track: should we keep it as it is?
- if(isVideoSendEnabled(media) && !media.removeVideo && !media.replaceVideo) {
- media.keepVideo = true;
- }
- }
- }
- // Data channels can only be added
- if(media.addData) {
- media.data = true;
- }
- }
- // If we're updating and keeping all tracks, let's skip the getUserMedia part
- if((isAudioSendEnabled(media) && media.keepAudio) &&
- (isVideoSendEnabled(media) && media.keepVideo)) {
- pluginHandle.consentDialog(false);
- streamsDone(handleId, jsep, media, callbacks, config.myStream);
+ // Helper function used when creating either an offer or answer: it
+ // prepares what needs to be prepared, including creating a new
+ // PeerConnection (if needed) and updating the tracks configuration,
+ // before invoking the function to actually generate the offer/answer
+ async function prepareWebrtc(handleId, offer, callbacks) {
+ callbacks = callbacks || {};
+ callbacks.success = (typeof callbacks.success == "function") ? callbacks.success : Janus.noop;
+ callbacks.error = (typeof callbacks.error == "function") ? callbacks.error : webrtcError;
+ let jsep = callbacks.jsep;
+ if(offer && jsep) {
+ Janus.error("Provided a JSEP to a createOffer");
+ callbacks.error("Provided a JSEP to a createOffer");
+ return;
+ } else if(!offer && (!jsep || !jsep.type || !jsep.sdp)) {
+ Janus.error("A valid JSEP is required for createAnswer");
+ callbacks.error("A valid JSEP is required for createAnswer");
return;
}
- }
- // If we're updating, check if we need to remove/replace one of the tracks
- if(media.update && (!config.streamExternal || (config.streamExternal && (media.replaceAudio || media.replaceVideo)))) {
- if(media.removeAudio || media.replaceAudio) {
- if(config.myStream && config.myStream.getAudioTracks() && config.myStream.getAudioTracks().length) {
- var at = config.myStream.getAudioTracks()[0];
- Janus.log("Removing audio track:", at);
- config.myStream.removeTrack(at);
- try {
- at.stop();
- } catch(e) {}
- }
- if(config.pc.getSenders() && config.pc.getSenders().length) {
- var ra = true;
- if(media.replaceAudio && Janus.unifiedPlan) {
- // We can use replaceTrack
- ra = false;
- }
- if(ra) {
- for(var asnd of config.pc.getSenders()) {
- if(asnd && asnd.track && asnd.track.kind === "audio") {
- Janus.log("Removing audio sender:", asnd);
- config.pc.removeTrack(asnd);
- }
+ // If the deprecated media was provided instead of tracks, translate it
+ if(callbacks.media && !callbacks.tracks) {
+ callbacks.tracks = Janus.mediaToTracks(callbacks.media);
+ if(callbacks.simulcast === true || callbacks.simulcast2 === true || callbacks.svc) {
+ // Find the video track and add simulcast/SVC info there
+ for(let track of callbacks.tracks) {
+ if(track.type === 'video') {
+ if(callbacks.simulcast === true || callbacks.simulcast2 === true)
+ track.simulcast = true;
+ else if(callbacks.svc)
+ track.svc = callbacks.svc;
+ break;
}
}
}
+ Janus.warn('Deprecated media object passed, use tracks instead. Automatically translated to:', callbacks.tracks);
}
- if(media.removeVideo || media.replaceVideo) {
- if(config.myStream && config.myStream.getVideoTracks() && config.myStream.getVideoTracks().length) {
- var vt = config.myStream.getVideoTracks()[0];
- Janus.log("Removing video track:", vt);
- config.myStream.removeTrack(vt);
- try {
- vt.stop();
- } catch(e) {}
- }
- if(config.pc.getSenders() && config.pc.getSenders().length) {
- var rv = true;
- if(media.replaceVideo && Janus.unifiedPlan) {
- // We can use replaceTrack
- rv = false;
- }
- if(rv) {
- for(var vsnd of config.pc.getSenders()) {
- if(vsnd && vsnd.track && vsnd.track.kind === "video") {
- Janus.log("Removing video sender:", vsnd);
- config.pc.removeTrack(vsnd);
- }
- }
- }
- }
- }
- }
- // Was a MediaStream object passed, or do we need to take care of that?
- if(callbacks.stream) {
- var stream = callbacks.stream;
- Janus.log("MediaStream provided by the application");
- Janus.debug(stream);
- // If this is an update, let's check if we need to release the previous stream
- if(media.update && config.myStream && config.myStream !== callbacks.stream && !config.streamExternal && !media.replaceAudio && !media.replaceVideo) {
- // We're replacing a stream we captured ourselves with an external one
- Janus.stopAllTracks(config.myStream);
- config.myStream = null;
- }
- // Skip the getUserMedia part
- config.streamExternal = true;
- pluginHandle.consentDialog(false);
- streamsDone(handleId, jsep, media, callbacks, stream);
- return;
- }
- if(isAudioSendEnabled(media) || isVideoSendEnabled(media)) {
- if(!Janus.isGetUserMediaAvailable()) {
- callbacks.error("getUserMedia not available");
+ // Check that callbacks.array is a valid array
+ if(callbacks.tracks && !Array.isArray(callbacks.tracks)) {
+ Janus.error("Tracks must be an array");
+ callbacks.error("Tracks must be an array");
return;
}
- var constraints = { mandatory: {}, optional: []};
- pluginHandle.consentDialog(true);
- var audioSupport = isAudioSendEnabled(media);
- if(audioSupport && media && typeof media.audio === 'object')
- audioSupport = media.audio;
- var videoSupport = isVideoSendEnabled(media);
- if(videoSupport && media) {
- var simulcast = (callbacks.simulcast === true || callbacks.simulcast2 === true);
- var svc = callbacks.svc;
- if((simulcast || svc) && !jsep && !media.video)
- media.video = "hires";
- if(media.video && media.video != 'screen' && media.video != 'window') {
- if(typeof media.video === 'object') {
- videoSupport = media.video;
- } else {
- var width = 0;
- var height = 0;
- if(media.video === 'lowres') {
- // Small resolution, 4:3
- height = 240;
- width = 320;
- } else if(media.video === 'lowres-16:9') {
- // Small resolution, 16:9
- height = 180;
- width = 320;
- } else if(media.video === 'hires' || media.video === 'hires-16:9' || media.video === 'hdres') {
- // High(HD) resolution is only 16:9
- height = 720;
- width = 1280;
- } else if(media.video === 'fhdres') {
- // Full HD resolution is only 16:9
- height = 1080;
- width = 1920;
- } else if(media.video === '4kres') {
- // 4K resolution is only 16:9
- height = 2160;
- width = 3840;
- } else if(media.video === 'stdres') {
- // Normal resolution, 4:3
- height = 480;
- width = 640;
- } else if(media.video === 'stdres-16:9') {
- // Normal resolution, 16:9
- height = 360;
- width = 640;
- } else {
- Janus.log("Default video setting is stdres 4:3");
- height = 480;
- width = 640;
- }
- Janus.log("Adding media constraint:", media.video);
- videoSupport = {
- 'height': {'ideal': height},
- 'width': {'ideal': width}
- };
- Janus.log("Adding video constraint:", videoSupport);
- }
- } else if(media.video === 'screen' || media.video === 'window') {
- if(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia) {
- // The new experimental getDisplayMedia API is available, let's use that
- // https://groups.google.com/forum/#!topic/discuss-webrtc/Uf0SrR4uxzk
- // https://webrtchacks.com/chrome-screensharing-getdisplaymedia/
- constraints.video = {};
- if(media.screenshareFrameRate) {
- constraints.video.frameRate = media.screenshareFrameRate;
- }
- if(media.screenshareHeight) {
- constraints.video.height = media.screenshareHeight;
- }
- if(media.screenshareWidth) {
- constraints.video.width = media.screenshareWidth;
- }
- constraints.audio = media.captureDesktopAudio;
- navigator.mediaDevices.getDisplayMedia(constraints)
- .then(function(stream) {
- pluginHandle.consentDialog(false);
- if(isAudioSendEnabled(media) && !media.keepAudio) {
- navigator.mediaDevices.getUserMedia({ audio: true, video: false })
- .then(function (audioStream) {
- stream.addTrack(audioStream.getAudioTracks()[0]);
- streamsDone(handleId, jsep, media, callbacks, stream);
- })
- } else {
- streamsDone(handleId, jsep, media, callbacks, stream);
- }
- }, function (error) {
- pluginHandle.consentDialog(false);
- callbacks.error(error);
- });
- return;
- }
- // We're going to try and use the extension for Chrome 34+, the old approach
- // for older versions of Chrome, or the experimental support in Firefox 33+
- const callbackUserMedia = function(error, stream) {
- pluginHandle.consentDialog(false);
- if(error) {
- callbacks.error(error);
- } else {
- streamsDone(handleId, jsep, media, callbacks, stream);
- }
- }
- const getScreenMedia = function(constraints, gsmCallback, useAudio) {
- Janus.log("Adding media constraint (screen capture)");
- Janus.debug(constraints);
- navigator.mediaDevices.getUserMedia(constraints)
- .then(function(stream) {
- if(useAudio) {
- navigator.mediaDevices.getUserMedia({ audio: true, video: false })
- .then(function (audioStream) {
- stream.addTrack(audioStream.getAudioTracks()[0]);
- gsmCallback(null, stream);
- })
- } else {
- gsmCallback(null, stream);
- }
- })
- .catch(function(error) { pluginHandle.consentDialog(false); gsmCallback(error); });
- }
- if(Janus.webRTCAdapter.browserDetails.browser === 'chrome') {
- var chromever = Janus.webRTCAdapter.browserDetails.version;
- var maxver = 33;
- if(window.navigator.userAgent.match('Linux'))
- maxver = 35; // "known" crash in chrome 34 and 35 on linux
- if(chromever >= 26 && chromever <= maxver) {
- // Chrome 26->33 requires some awkward chrome://flags manipulation
- constraints = {
- video: {
- mandatory: {
- googLeakyBucket: true,
- maxWidth: window.screen.width,
- maxHeight: window.screen.height,
- minFrameRate: media.screenshareFrameRate,
- maxFrameRate: media.screenshareFrameRate,
- chromeMediaSource: 'screen'
- }
- },
- audio: isAudioSendEnabled(media) && !media.keepAudio
- };
- getScreenMedia(constraints, callbackUserMedia);
- } else {
- // Chrome 34+ requires an extension
- Janus.extension.getScreen(function (error, sourceId) {
- if (error) {
- pluginHandle.consentDialog(false);
- return callbacks.error(error);
- }
- constraints = {
- audio: false,
- video: {
- mandatory: {
- chromeMediaSource: 'desktop',
- maxWidth: window.screen.width,
- maxHeight: window.screen.height,
- minFrameRate: media.screenshareFrameRate,
- maxFrameRate: media.screenshareFrameRate,
- },
- optional: [
- {googLeakyBucket: true},
- {googTemporalLayeredScreencast: true}
- ]
- }
- };
- constraints.video.mandatory.chromeMediaSourceId = sourceId;
- getScreenMedia(constraints, callbackUserMedia,
- isAudioSendEnabled(media) && !media.keepAudio);
- });
- }
- } else if(Janus.webRTCAdapter.browserDetails.browser === 'firefox') {
- if(Janus.webRTCAdapter.browserDetails.version >= 33) {
- // Firefox 33+ has experimental support for screen sharing
- constraints = {
- video: {
- mozMediaSource: media.video,
- mediaSource: media.video
- },
- audio: isAudioSendEnabled(media) && !media.keepAudio
- };
- getScreenMedia(constraints, function (err, stream) {
- callbackUserMedia(err, stream);
- // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1045810
- if (!err) {
- var lastTime = stream.currentTime;
- var polly = window.setInterval(function () {
- if(!stream)
- window.clearInterval(polly);
- if(stream.currentTime == lastTime) {
- window.clearInterval(polly);
- if(stream.onended) {
- stream.onended();
- }
- }
- lastTime = stream.currentTime;
- }, 500);
- }
- });
- } else {
- var error = new Error('NavigatorUserMediaError');
- error.name = 'Your version of Firefox does not support screen sharing, please install Firefox 33 (or more recent versions)';
- pluginHandle.consentDialog(false);
- callbacks.error(error);
- return;
- }
- }
- return;
- }
- }
- // If we got here, we're not screensharing
- if(!media || media.video !== 'screen') {
- // Check whether all media sources are actually available or not
- navigator.mediaDevices.enumerateDevices().then(function(devices) {
- var audioExist = devices.some(function(device) {
- return device.kind === 'audioinput';
- }),
- videoExist = isScreenSendEnabled(media) || devices.some(function(device) {
- return device.kind === 'videoinput';
- });
-
- // Check whether a missing device is really a problem
- var audioSend = isAudioSendEnabled(media);
- var videoSend = isVideoSendEnabled(media);
- var needAudioDevice = isAudioSendRequired(media);
- var needVideoDevice = isVideoSendRequired(media);
- if(audioSend || videoSend || needAudioDevice || needVideoDevice) {
- // We need to send either audio or video
- var haveAudioDevice = audioSend ? audioExist : false;
- var haveVideoDevice = videoSend ? videoExist : false;
- if(!haveAudioDevice && !haveVideoDevice) {
- // FIXME Should we really give up, or just assume recvonly for both?
- pluginHandle.consentDialog(false);
- callbacks.error('No capture device found');
- return false;
- } else if(!haveAudioDevice && needAudioDevice) {
- pluginHandle.consentDialog(false);
- callbacks.error('Audio capture is required, but no capture device found');
- return false;
- } else if(!haveVideoDevice && needVideoDevice) {
- pluginHandle.consentDialog(false);
- callbacks.error('Video capture is required, but no capture device found');
- return false;
- }
- }
-
- var gumConstraints = {
- audio: (audioExist && !media.keepAudio) ? audioSupport : false,
- video: (videoExist && !media.keepVideo) ? videoSupport : false
- };
- Janus.debug("getUserMedia constraints", gumConstraints);
- if (!gumConstraints.audio && !gumConstraints.video) {
- pluginHandle.consentDialog(false);
- streamsDone(handleId, jsep, media, callbacks, stream);
- } else {
- navigator.mediaDevices.getUserMedia(gumConstraints)
- .then(function(stream) {
- pluginHandle.consentDialog(false);
- streamsDone(handleId, jsep, media, callbacks, stream);
- }).catch(function(error) {
- pluginHandle.consentDialog(false);
- callbacks.error({code: error.code, name: error.name, message: error.message});
- });
- }
- })
- .catch(function(error) {
- pluginHandle.consentDialog(false);
- callbacks.error(error);
- });
- }
- } else {
- // No need to do a getUserMedia, create offer/answer right away
- streamsDone(handleId, jsep, media, callbacks);
- }
- }
-
- function prepareWebrtcPeer(handleId, callbacks) {
- callbacks = callbacks || {};
- callbacks.success = (typeof callbacks.success == "function") ? callbacks.success : Janus.noop;
- callbacks.error = (typeof callbacks.error == "function") ? callbacks.error : webrtcError;
- callbacks.customizeSdp = (typeof callbacks.customizeSdp == "function") ? callbacks.customizeSdp : Janus.noop;
- var jsep = callbacks.jsep;
- var pluginHandle = pluginHandles[handleId];
- if(!pluginHandle || !pluginHandle.webrtcStuff) {
- Janus.warn("Invalid handle");
- callbacks.error("Invalid handle");
- return;
- }
- var config = pluginHandle.webrtcStuff;
- if(jsep) {
- if(!config.pc) {
- Janus.warn("Wait, no PeerConnection?? if this is an answer, use createAnswer and not handleRemoteJsep");
- callbacks.error("No PeerConnection: if this is an answer, use createAnswer and not handleRemoteJsep");
+ // Get the plugin handle
+ let pluginHandle = pluginHandles.get(handleId);
+ if(!pluginHandle || !pluginHandle.webrtcStuff) {
+ Janus.warn("Invalid handle");
+ callbacks.error("Invalid handle");
return;
}
- callbacks.customizeSdp(jsep);
- config.pc.setRemoteDescription(jsep)
- .then(function() {
- Janus.log("Remote description accepted!");
- config.remoteSdp = jsep.sdp;
- // Any trickle candidate we cached?
- if(config.candidates && config.candidates.length > 0) {
- for(var i = 0; i< config.candidates.length; i++) {
- var candidate = config.candidates[i];
- Janus.debug("Adding remote candidate:", candidate);
- if(!candidate || candidate.completed === true) {
- // end-of-candidates
- config.pc.addIceCandidate(Janus.endOfCandidates);
- } else {
- // New candidate
- config.pc.addIceCandidate(candidate);
- }
- }
- config.candidates = [];
- }
- // Done
- callbacks.success();
- }, callbacks.error);
- } else {
- callbacks.error("Invalid JSEP");
- }
- }
-
- function createOffer(handleId, media, callbacks) {
- callbacks = callbacks || {};
- callbacks.success = (typeof callbacks.success == "function") ? callbacks.success : Janus.noop;
- callbacks.error = (typeof callbacks.error == "function") ? callbacks.error : Janus.noop;
- callbacks.customizeSdp = (typeof callbacks.customizeSdp == "function") ? callbacks.customizeSdp : Janus.noop;
- var pluginHandle = pluginHandles[handleId];
- if(!pluginHandle || !pluginHandle.webrtcStuff) {
- Janus.warn("Invalid handle");
- callbacks.error("Invalid handle");
- return;
- }
- var config = pluginHandle.webrtcStuff;
- var simulcast = (callbacks.simulcast === true);
- if(!simulcast) {
- Janus.log("Creating offer (iceDone=" + config.iceDone + ")");
- } else {
- Janus.log("Creating offer (iceDone=" + config.iceDone + ", simulcast=" + simulcast + ")");
- }
- // https://code.google.com/p/webrtc/issues/detail?id=3508
- var mediaConstraints = {};
- if(Janus.unifiedPlan) {
- // We can use Transceivers
- var audioTransceiver = null, videoTransceiver = null;
- var transceivers = config.pc.getTransceivers();
- if(transceivers && transceivers.length > 0) {
- for(var t of transceivers) {
- if((t.sender && t.sender.track && t.sender.track.kind === "audio") ||
- (t.receiver && t.receiver.track && t.receiver.track.kind === "audio")) {
- if(!audioTransceiver) {
- audioTransceiver = t;
- }
- continue;
- }
- if((t.sender && t.sender.track && t.sender.track.kind === "video") ||
- (t.receiver && t.receiver.track && t.receiver.track.kind === "video")) {
- if(!videoTransceiver) {
- videoTransceiver = t;
- }
- continue;
- }
- }
- }
- // Handle audio (and related changes, if any)
- var audioSend = isAudioSendEnabled(media);
- var audioRecv = isAudioRecvEnabled(media);
- if(!audioSend && !audioRecv) {
- // Audio disabled: have we removed it?
- if(media.removeAudio && audioTransceiver) {
- if (audioTransceiver.setDirection) {
- audioTransceiver.setDirection("inactive");
- } else {
- audioTransceiver.direction = "inactive";
- }
- Janus.log("Setting audio transceiver to inactive:", audioTransceiver);
- }
- } else {
- // Take care of audio m-line
- if(audioSend && audioRecv) {
- if(audioTransceiver) {
- if (audioTransceiver.setDirection) {
- audioTransceiver.setDirection("sendrecv");
- } else {
- audioTransceiver.direction = "sendrecv";
- }
- Janus.log("Setting audio transceiver to sendrecv:", audioTransceiver);
- }
- } else if(audioSend && !audioRecv) {
- if(audioTransceiver) {
- if (audioTransceiver.setDirection) {
- audioTransceiver.setDirection("sendonly");
- } else {
- audioTransceiver.direction = "sendonly";
- }
- Janus.log("Setting audio transceiver to sendonly:", audioTransceiver);
- }
- } else if(!audioSend && audioRecv) {
- if(audioTransceiver) {
- if (audioTransceiver.setDirection) {
- audioTransceiver.setDirection("recvonly");
- } else {
- audioTransceiver.direction = "recvonly";
- }
- Janus.log("Setting audio transceiver to recvonly:", audioTransceiver);
- } else {
- // In theory, this is the only case where we might not have a transceiver yet
- audioTransceiver = config.pc.addTransceiver("audio", { direction: "recvonly" });
- Janus.log("Adding recvonly audio transceiver:", audioTransceiver);
- }
- }
- }
- // Handle video (and related changes, if any)
- var videoSend = isVideoSendEnabled(media);
- var videoRecv = isVideoRecvEnabled(media);
- if(!videoSend && !videoRecv) {
- // Video disabled: have we removed it?
- if(media.removeVideo && videoTransceiver) {
- if (videoTransceiver.setDirection) {
- videoTransceiver.setDirection("inactive");
- } else {
- videoTransceiver.direction = "inactive";
- }
- Janus.log("Setting video transceiver to inactive:", videoTransceiver);
- }
- } else {
- // Take care of video m-line
- if(videoSend && videoRecv) {
- if(videoTransceiver) {
- if (videoTransceiver.setDirection) {
- videoTransceiver.setDirection("sendrecv");
- } else {
- videoTransceiver.direction = "sendrecv";
- }
- Janus.log("Setting video transceiver to sendrecv:", videoTransceiver);
- }
- } else if(videoSend && !videoRecv) {
- if(videoTransceiver) {
- if (videoTransceiver.setDirection) {
- videoTransceiver.setDirection("sendonly");
- } else {
- videoTransceiver.direction = "sendonly";
- }
- Janus.log("Setting video transceiver to sendonly:", videoTransceiver);
- }
- } else if(!videoSend && videoRecv) {
- if(videoTransceiver) {
- if (videoTransceiver.setDirection) {
- videoTransceiver.setDirection("recvonly");
- } else {
- videoTransceiver.direction = "recvonly";
- }
- Janus.log("Setting video transceiver to recvonly:", videoTransceiver);
- } else {
- // In theory, this is the only case where we might not have a transceiver yet
- videoTransceiver = config.pc.addTransceiver("video", { direction: "recvonly" });
- Janus.log("Adding recvonly video transceiver:", videoTransceiver);
- }
- }
- }
- } else {
- mediaConstraints["offerToReceiveAudio"] = isAudioRecvEnabled(media);
- mediaConstraints["offerToReceiveVideo"] = isVideoRecvEnabled(media);
- }
- var iceRestart = (callbacks.iceRestart === true);
- if(iceRestart) {
- mediaConstraints["iceRestart"] = true;
- }
- Janus.debug(mediaConstraints);
- // Check if this is Firefox and we've been asked to do simulcasting
- var sendVideo = isVideoSendEnabled(media);
- if(sendVideo && simulcast && Janus.webRTCAdapter.browserDetails.browser === "firefox") {
- // FIXME Based on https://gist.github.com/voluntas/088bc3cc62094730647b
- Janus.log("Enabling Simulcasting for Firefox (RID)");
- var sender = config.pc.getSenders().find(function(s) {return s.track && s.track.kind === "video"});
- if(sender) {
- var parameters = sender.getParameters();
- if(!parameters) {
- parameters = {};
- }
- var maxBitrates = getMaxBitrates(callbacks.simulcastMaxBitrates);
- parameters.encodings = callbacks.sendEncodings || [
- { rid: "h", active: true, maxBitrate: maxBitrates.high },
- { rid: "m", active: true, maxBitrate: maxBitrates.medium, scaleResolutionDownBy: 2 },
- { rid: "l", active: true, maxBitrate: maxBitrates.low, scaleResolutionDownBy: 4 }
- ];
- sender.setParameters(parameters);
- }
- }
- config.pc.createOffer(mediaConstraints)
- .then(function(offer) {
- Janus.debug(offer);
- // JSON.stringify doesn't work on some WebRTC objects anymore
- // See https://code.google.com/p/chromium/issues/detail?id=467366
- var jsep = {
- "type": offer.type,
- "sdp": offer.sdp
- };
- callbacks.customizeSdp(jsep);
- offer.sdp = jsep.sdp;
- Janus.log("Setting local description");
- if(sendVideo && simulcast && !Janus.unifiedPlan) {
- // We only do simulcast via SDP munging on older versions of Chrome and Safari
- if(Janus.webRTCAdapter.browserDetails.browser === "chrome" ||
- Janus.webRTCAdapter.browserDetails.browser === "safari") {
- Janus.log("Enabling Simulcasting for Chrome (SDP munging)");
- offer.sdp = mungeSdpForSimulcasting(offer.sdp);
- }
- }
- config.mySdp = {
- type: "offer",
- sdp: offer.sdp
- };
- config.pc.setLocalDescription(offer)
- .catch(callbacks.error);
- config.mediaConstraints = mediaConstraints;
- if(!config.iceDone && !config.trickle) {
- // Don't do anything until we have all candidates
- Janus.log("Waiting for all candidates...");
- return;
- }
- // If transforms are present, notify Janus that the media is end-to-end encrypted
- if(config.senderTransforms || config.receiverTransforms) {
- offer["e2ee"] = true;
- }
- callbacks.success(offer);
- }, callbacks.error);
- }
-
- function createAnswer(handleId, media, callbacks) {
- callbacks = callbacks || {};
- callbacks.success = (typeof callbacks.success == "function") ? callbacks.success : Janus.noop;
- callbacks.error = (typeof callbacks.error == "function") ? callbacks.error : Janus.noop;
- callbacks.customizeSdp = (typeof callbacks.customizeSdp == "function") ? callbacks.customizeSdp : Janus.noop;
- var pluginHandle = pluginHandles[handleId];
- if(!pluginHandle || !pluginHandle.webrtcStuff) {
- Janus.warn("Invalid handle");
- callbacks.error("Invalid handle");
- return;
- }
- var config = pluginHandle.webrtcStuff;
- var simulcast = (callbacks.simulcast === true || callbacks.simulcast2 === true);
- if(!simulcast) {
- Janus.log("Creating answer (iceDone=" + config.iceDone + ")");
- } else {
- Janus.log("Creating answer (iceDone=" + config.iceDone + ", simulcast=" + simulcast + ")");
- }
- var mediaConstraints = null;
- if(Janus.unifiedPlan) {
- // We can use Transceivers
- mediaConstraints = {};
- var audioTransceiver = null, videoTransceiver = null;
- var transceivers = config.pc.getTransceivers();
- if(transceivers && transceivers.length > 0) {
- for(var t of transceivers) {
- if((t.sender && t.sender.track && t.sender.track.kind === "audio") ||
- (t.receiver && t.receiver.track && t.receiver.track.kind === "audio")) {
- if(!audioTransceiver)
- audioTransceiver = t;
- continue;
- }
- if((t.sender && t.sender.track && t.sender.track.kind === "video") ||
- (t.receiver && t.receiver.track && t.receiver.track.kind === "video")) {
- if(!videoTransceiver)
- videoTransceiver = t;
- continue;
- }
- }
- }
- // Handle audio (and related changes, if any)
- var audioSend = isAudioSendEnabled(media);
- var audioRecv = isAudioRecvEnabled(media);
- if(!audioSend && !audioRecv) {
- // Audio disabled: have we removed it?
- if(media.removeAudio && audioTransceiver) {
- try {
- if (audioTransceiver.setDirection) {
- audioTransceiver.setDirection("inactive");
- } else {
- audioTransceiver.direction = "inactive";
- }
- Janus.log("Setting audio transceiver to inactive:", audioTransceiver);
- } catch(e) {
- Janus.error(e);
- }
- }
- } else {
- // Take care of audio m-line
- if(audioSend && audioRecv) {
- if(audioTransceiver) {
- try {
- if (audioTransceiver.setDirection) {
- audioTransceiver.setDirection("sendrecv");
- } else {
- audioTransceiver.direction = "sendrecv";
- }
- Janus.log("Setting audio transceiver to sendrecv:", audioTransceiver);
- } catch(e) {
- Janus.error(e);
- }
- }
- } else if(audioSend && !audioRecv) {
- try {
- if(audioTransceiver) {
- if (audioTransceiver.setDirection) {
- audioTransceiver.setDirection("sendonly");
- } else {
- audioTransceiver.direction = "sendonly";
- }
- Janus.log("Setting audio transceiver to sendonly:", audioTransceiver);
- }
- } catch(e) {
- Janus.error(e);
- }
- } else if(!audioSend && audioRecv) {
- if(audioTransceiver) {
- try {
- if (audioTransceiver.setDirection) {
- audioTransceiver.setDirection("recvonly");
- } else {
- audioTransceiver.direction = "recvonly";
- }
- Janus.log("Setting audio transceiver to recvonly:", audioTransceiver);
- } catch(e) {
- Janus.error(e);
- }
- } else {
- // In theory, this is the only case where we might not have a transceiver yet
- audioTransceiver = config.pc.addTransceiver("audio", { direction: "recvonly" });
- Janus.log("Adding recvonly audio transceiver:", audioTransceiver);
- }
- }
- }
- // Handle video (and related changes, if any)
- var videoSend = isVideoSendEnabled(media);
- var videoRecv = isVideoRecvEnabled(media);
- if(!videoSend && !videoRecv) {
- // Video disabled: have we removed it?
- if(media.removeVideo && videoTransceiver) {
- try {
- if (videoTransceiver.setDirection) {
- videoTransceiver.setDirection("inactive");
- } else {
- videoTransceiver.direction = "inactive";
- }
- Janus.log("Setting video transceiver to inactive:", videoTransceiver);
- } catch(e) {
- Janus.error(e);
- }
- }
- } else {
- // Take care of video m-line
- if(videoSend && videoRecv) {
- if(videoTransceiver) {
- try {
- if (videoTransceiver.setDirection) {
- videoTransceiver.setDirection("sendrecv");
- } else {
- videoTransceiver.direction = "sendrecv";
- }
- Janus.log("Setting video transceiver to sendrecv:", videoTransceiver);
- } catch(e) {
- Janus.error(e);
- }
- }
- } else if(videoSend && !videoRecv) {
- if(videoTransceiver) {
- try {
- if (videoTransceiver.setDirection) {
- videoTransceiver.setDirection("sendonly");
- } else {
- videoTransceiver.direction = "sendonly";
- }
- Janus.log("Setting video transceiver to sendonly:", videoTransceiver);
- } catch(e) {
- Janus.error(e);
- }
- }
- } else if(!videoSend && videoRecv) {
- if(videoTransceiver) {
- try {
- if (videoTransceiver.setDirection) {
- videoTransceiver.setDirection("recvonly");
- } else {
- videoTransceiver.direction = "recvonly";
- }
- Janus.log("Setting video transceiver to recvonly:", videoTransceiver);
- } catch(e) {
- Janus.error(e);
- }
- } else {
- // In theory, this is the only case where we might not have a transceiver yet
- videoTransceiver = config.pc.addTransceiver("video", { direction: "recvonly" });
- Janus.log("Adding recvonly video transceiver:", videoTransceiver);
- }
- }
- }
- } else {
- if(Janus.webRTCAdapter.browserDetails.browser === "firefox" || Janus.webRTCAdapter.browserDetails.browser === "edge") {
- mediaConstraints = {
- offerToReceiveAudio: isAudioRecvEnabled(media),
- offerToReceiveVideo: isVideoRecvEnabled(media)
- };
- } else {
- mediaConstraints = {
- mandatory: {
- OfferToReceiveAudio: isAudioRecvEnabled(media),
- OfferToReceiveVideo: isVideoRecvEnabled(media)
- }
- };
- }
- }
- Janus.debug(mediaConstraints);
- // Check if this is Firefox and we've been asked to do simulcasting
- var sendVideo = isVideoSendEnabled(media);
- if(sendVideo && simulcast && Janus.webRTCAdapter.browserDetails.browser === "firefox") {
- // FIXME Based on https://gist.github.com/voluntas/088bc3cc62094730647b
- Janus.log("Enabling Simulcasting for Firefox (RID)");
- var sender = config.pc.getSenders()[1];
- Janus.log(sender);
- var parameters = sender.getParameters();
- Janus.log(parameters);
-
- var maxBitrates = getMaxBitrates(callbacks.simulcastMaxBitrates);
- sender.setParameters({encodings: callbacks.sendEncodings || [
- { rid: "h", active: true, maxBitrate: maxBitrates.high },
- { rid: "m", active: true, maxBitrate: maxBitrates.medium, scaleResolutionDownBy: 2},
- { rid: "l", active: true, maxBitrate: maxBitrates.low, scaleResolutionDownBy: 4}
- ]});
- }
- config.pc.createAnswer(mediaConstraints)
- .then(function(answer) {
- Janus.debug(answer);
- // JSON.stringify doesn't work on some WebRTC objects anymore
- // See https://code.google.com/p/chromium/issues/detail?id=467366
- var jsep = {
- "type": answer.type,
- "sdp": answer.sdp
- };
- callbacks.customizeSdp(jsep);
- answer.sdp = jsep.sdp;
- Janus.log("Setting local description");
- if(sendVideo && simulcast && !Janus.unifiedPlan) {
- // We only do simulcast via SDP munging on older versions of Chrome and Safari
- if(Janus.webRTCAdapter.browserDetails.browser === "chrome") {
- // FIXME Apparently trying to simulcast when answering breaks video in Chrome...
- //~ Janus.log("Enabling Simulcasting for Chrome (SDP munging)");
- //~ answer.sdp = mungeSdpForSimulcasting(answer.sdp);
- Janus.warn("simulcast=true, but this is an answer, and video breaks in Chrome if we enable it");
- }
- }
- config.mySdp = {
- type: "answer",
- sdp: answer.sdp
- };
- config.pc.setLocalDescription(answer)
- .catch(callbacks.error);
- config.mediaConstraints = mediaConstraints;
- if(!config.iceDone && !config.trickle) {
- // Don't do anything until we have all candidates
- Janus.log("Waiting for all candidates...");
- return;
- }
- // If transforms are present, notify Janus that the media is end-to-end encrypted
- if(config.senderTransforms || config.receiverTransforms) {
- answer["e2ee"] = true;
- }
- callbacks.success(answer);
- }, callbacks.error);
- }
-
- function sendSDP(handleId, callbacks) {
- callbacks = callbacks || {};
- callbacks.success = (typeof callbacks.success == "function") ? callbacks.success : Janus.noop;
- callbacks.error = (typeof callbacks.error == "function") ? callbacks.error : Janus.noop;
- var pluginHandle = pluginHandles[handleId];
- if(!pluginHandle || !pluginHandle.webrtcStuff) {
- Janus.warn("Invalid handle, not sending anything");
- return;
- }
- var config = pluginHandle.webrtcStuff;
- Janus.log("Sending offer/answer SDP...");
- if(!config.mySdp) {
- Janus.warn("Local SDP instance is invalid, not sending anything...");
- return;
- }
- config.mySdp = {
- "type": config.pc.localDescription.type,
- "sdp": config.pc.localDescription.sdp
- };
- if(config.trickle === false)
- config.mySdp["trickle"] = false;
- Janus.debug(callbacks);
- config.sdpSent = true;
- callbacks.success(config.mySdp);
- }
-
- function getVolume(handleId, remote) {
- var pluginHandle = pluginHandles[handleId];
- if(!pluginHandle || !pluginHandle.webrtcStuff) {
- Janus.warn("Invalid handle");
- return 0;
- }
- var stream = remote ? "remote" : "local";
- var config = pluginHandle.webrtcStuff;
- if(!config.volume[stream])
- config.volume[stream] = { value: 0 };
- // Start getting the volume, if audioLevel in getStats is supported (apparently
- // they're only available in Chrome/Safari right now: https://webrtc-stats.callstats.io/)
- if(config.pc.getStats && (Janus.webRTCAdapter.browserDetails.browser === "chrome" ||
- Janus.webRTCAdapter.browserDetails.browser === "safari")) {
- if(remote && !config.remoteStream) {
- Janus.warn("Remote stream unavailable");
- return 0;
- } else if(!remote && !config.myStream) {
- Janus.warn("Local stream unavailable");
- return 0;
- }
- if(!config.volume[stream].timer) {
- Janus.log("Starting " + stream + " volume monitor");
- config.volume[stream].timer = setInterval(function() {
- config.pc.getStats()
- .then(function(stats) {
- stats.forEach(function (res) {
- if(!res || res.kind !== "audio")
- return;
- if((remote && !res.remoteSource) || (!remote && res.type !== "media-source"))
- return;
- config.volume[stream].value = (res.audioLevel ? res.audioLevel : 0);
- });
- });
- }, 200);
- return 0; // We don't have a volume to return yet
- }
- return config.volume[stream].value;
- } else {
- // audioInputLevel and audioOutputLevel seem only available in Chrome? audioLevel
- // seems to be available on Chrome and Firefox, but they don't seem to work
- Janus.warn("Getting the " + stream + " volume unsupported by browser");
- return 0;
- }
- }
-
- function isMuted(handleId, video) {
- var pluginHandle = pluginHandles[handleId];
- if(!pluginHandle || !pluginHandle.webrtcStuff) {
- Janus.warn("Invalid handle");
- return true;
- }
- var config = pluginHandle.webrtcStuff;
- if(!config.pc) {
- Janus.warn("Invalid PeerConnection");
- return true;
- }
- if(!config.myStream) {
- Janus.warn("Invalid local MediaStream");
- return true;
- }
- if(video) {
- // Check video track
- if(!config.myStream.getVideoTracks() || config.myStream.getVideoTracks().length === 0) {
- Janus.warn("No video track");
- return true;
- }
- return !config.myStream.getVideoTracks()[0].enabled;
- } else {
- // Check audio track
- if(!config.myStream.getAudioTracks() || config.myStream.getAudioTracks().length === 0) {
- Janus.warn("No audio track");
- return true;
- }
- return !config.myStream.getAudioTracks()[0].enabled;
- }
- }
-
- function mute(handleId, video, mute) {
- var pluginHandle = pluginHandles[handleId];
- if(!pluginHandle || !pluginHandle.webrtcStuff) {
- Janus.warn("Invalid handle");
- return false;
- }
- var config = pluginHandle.webrtcStuff;
- if(!config.pc) {
- Janus.warn("Invalid PeerConnection");
- return false;
- }
- if(!config.myStream) {
- Janus.warn("Invalid local MediaStream");
- return false;
- }
- if(video) {
- // Mute/unmute video track
- if(!config.myStream.getVideoTracks() || config.myStream.getVideoTracks().length === 0) {
- Janus.warn("No video track");
- return false;
- }
- config.myStream.getVideoTracks()[0].enabled = !mute;
- return true;
- } else {
- // Mute/unmute audio track
- if(!config.myStream.getAudioTracks() || config.myStream.getAudioTracks().length === 0) {
- Janus.warn("No audio track");
- return false;
- }
- config.myStream.getAudioTracks()[0].enabled = !mute;
- return true;
- }
- }
-
- function getBitrate(handleId) {
- var pluginHandle = pluginHandles[handleId];
- if(!pluginHandle || !pluginHandle.webrtcStuff) {
- Janus.warn("Invalid handle");
- return "Invalid handle";
- }
- var config = pluginHandle.webrtcStuff;
- if(!config.pc)
- return "Invalid PeerConnection";
- // Start getting the bitrate, if getStats is supported
- if(config.pc.getStats) {
- if(!config.bitrate.timer) {
- Janus.log("Starting bitrate timer (via getStats)");
- config.bitrate.timer = setInterval(function() {
- config.pc.getStats()
- .then(function(stats) {
- stats.forEach(function (res) {
- if(!res)
- return;
- var inStats = false;
- // Check if these are statistics on incoming media
- if((res.mediaType === "video" || res.id.toLowerCase().indexOf("video") > -1) &&
- res.type === "inbound-rtp" && res.id.indexOf("rtcp") < 0) {
- // New stats
- inStats = true;
- } else if(res.type == 'ssrc' && res.bytesReceived &&
- (res.googCodecName === "VP8" || res.googCodecName === "")) {
- // Older Chromer versions
- inStats = true;
- }
- // Parse stats now
- if(inStats) {
- config.bitrate.bsnow = res.bytesReceived;
- config.bitrate.tsnow = res.timestamp;
- if(config.bitrate.bsbefore === null || config.bitrate.tsbefore === null) {
- // Skip this round
- config.bitrate.bsbefore = config.bitrate.bsnow;
- config.bitrate.tsbefore = config.bitrate.tsnow;
- } else {
- // Calculate bitrate
- var timePassed = config.bitrate.tsnow - config.bitrate.tsbefore;
- if(Janus.webRTCAdapter.browserDetails.browser === "safari")
- timePassed = timePassed/1000; // Apparently the timestamp is in microseconds, in Safari
- var bitRate = Math.round((config.bitrate.bsnow - config.bitrate.bsbefore) * 8 / timePassed);
- if(Janus.webRTCAdapter.browserDetails.browser === "safari")
- bitRate = parseInt(bitRate/1000);
- config.bitrate.value = bitRate + ' kbits/sec';
- //~ Janus.log("Estimated bitrate is " + config.bitrate.value);
- config.bitrate.bsbefore = config.bitrate.bsnow;
- config.bitrate.tsbefore = config.bitrate.tsnow;
- }
- }
- });
- });
- }, 1000);
- return "0 kbits/sec"; // We don't have a bitrate value yet
- }
- return config.bitrate.value;
- } else {
- Janus.warn("Getting the video bitrate unsupported by browser");
- return "Feature unsupported by browser";
- }
- }
-
- function webrtcError(error) {
- Janus.error("WebRTC error:", error);
- }
-
- function cleanupWebrtc(handleId, hangupRequest) {
- Janus.log("Cleaning WebRTC stuff");
- var pluginHandle = pluginHandles[handleId];
- if(!pluginHandle) {
- // Nothing to clean
- return;
- }
- var config = pluginHandle.webrtcStuff;
- if(config) {
- if(hangupRequest === true) {
- // Send a hangup request (we don't really care about the response)
- var request = { "janus": "hangup", "transaction": Janus.randomString(12) };
- if(pluginHandle.token)
- request["token"] = pluginHandle.token;
- if(apisecret)
- request["apisecret"] = apisecret;
- Janus.debug("Sending hangup request (handle=" + handleId + "):");
- Janus.debug(request);
- if(websockets) {
- request["session_id"] = sessionId;
- request["handle_id"] = handleId;
- ws.send(JSON.stringify(request));
- } else {
- Janus.httpAPICall(server + "/" + sessionId + "/" + handleId, {
- verb: 'POST',
- withCredentials: withCredentials,
- body: request
- });
- }
- }
- // Cleanup stack
- config.remoteStream = null;
- if(config.volume) {
- if(config.volume["local"] && config.volume["local"].timer)
- clearInterval(config.volume["local"].timer);
- if(config.volume["remote"] && config.volume["remote"].timer)
- clearInterval(config.volume["remote"].timer);
- }
- config.volume = {};
- if(config.bitrate.timer)
- clearInterval(config.bitrate.timer);
- config.bitrate.timer = null;
- config.bitrate.bsnow = null;
- config.bitrate.bsbefore = null;
- config.bitrate.tsnow = null;
- config.bitrate.tsbefore = null;
- config.bitrate.value = null;
- if(!config.streamExternal && config.myStream) {
- Janus.log("Stopping local stream tracks");
- Janus.stopAllTracks(config.myStream);
- }
- config.streamExternal = false;
- config.myStream = null;
- // Close PeerConnection
+ let config = pluginHandle.webrtcStuff;
+ config.trickle = isTrickleEnabled(callbacks.trickle);
try {
- config.pc.close();
- } catch(e) {
- // Do nothing
- }
- config.pc = null;
- config.candidates = null;
- config.mySdp = null;
- config.remoteSdp = null;
- config.iceDone = false;
- config.dataChannel = {};
- config.dtmfSender = null;
- config.senderTransforms = null;
- config.receiverTransforms = null;
- }
- pluginHandle.oncleanup();
- }
-
- // Helper method to munge an SDP to enable simulcasting (Chrome only)
- function mungeSdpForSimulcasting(sdp) {
- // Let's munge the SDP to add the attributes for enabling simulcasting
- // (based on https://gist.github.com/ggarber/a19b4c33510028b9c657)
- var lines = sdp.split("\r\n");
- var video = false;
- var ssrc = [ -1 ], ssrc_fid = [ -1 ];
- var cname = null, msid = null, mslabel = null, label = null;
- var insertAt = -1;
- for(let i=0; i -1) {
- // We're done, let's add the new attributes here
- insertAt = i;
- break;
+ await config.pc.setRemoteDescription(jsep);
+ Janus.log("Remote description accepted!");
+ config.remoteSdp = jsep.sdp;
+ // Any trickle candidate we cached?
+ if(config.candidates && config.candidates.length > 0) {
+ for(let i=0; i 0) {
+ for(let i=0; i (t.mid === track.mid && t.receiver.track.kind === kind));
+ } else if(!track.add) {
+ // Find the first track of this type
+ transceiver = config.pc.getTransceivers()
+ .find(t => (t.receiver.track.kind === kind));
+ }
+ if(track.replace || track.remove) {
+ if(!transceiver) {
+ Janus.warn("Couldn't find a transceiver for track:", track);
+ continue;
+ }
+ if(!transceiver.sender) {
+ Janus.warn('No sender in the transceiver for track:', track);
+ continue;
+ }
+ sender = transceiver.sender;
+ }
+ if(answer && !transceiver) {
+ transceiver = config.pc.getTransceivers()
+ .find(t => (t.receiver.track.kind === kind));
+ if(!transceiver) {
+ Janus.warn("Couldn't find a transceiver for track:", track);
+ continue;
+ }
+ }
+ // Capture the new track, if we need to
+ let nt = null, trackId = null;
+ if(track.remove || track.replace) {
+ Janus.log('Removing track from PeerConnection', track);
+ trackId = sender.track ? sender.track.id : null;
+ await sender.replaceTrack(null);
+ // Get rid of the old track
+ if(trackId && config.myStream) {
+ let rt = null;
+ if(kind === 'audio' && config.myStream.getAudioTracks() && config.myStream.getAudioTracks().length) {
+ for(let t of config.myStream.getAudioTracks()) {
+ if(t.id === trackId) {
+ rt = t;
+ Janus.log('Removing audio track:', rt);
+ }
+ }
+ } else if(kind === 'video' && config.myStream.getVideoTracks() && config.myStream.getVideoTracks().length) {
+ for(let t of config.myStream.getVideoTracks()) {
+ if(t.id === trackId) {
+ rt = t;
+ Janus.log('Removing video track:', rt);
+ }
+ }
+ }
+ if(rt) {
+ // Remove the track and notify the application
+ try {
+ config.myStream.removeTrack(rt);
+ pluginHandle.onlocaltrack(rt, false);
+ } catch(e) {
+ Janus.error("Error calling onlocaltrack on removal for renegotiation", e);
+ }
+ // Close the old track (unless we've been asked not to)
+ if(rt.dontStop !== true) {
+ try {
+ rt.stop();
+ // eslint-disable-next-line no-unused-vars
+ } catch(e) {}
+ }
+ }
+ }
+ }
+ if(track.capture) {
+ if(track.gumGroup && groups[track.gumGroup] && groups[track.gumGroup].stream) {
+ // We did a getUserMedia before already
+ let stream = groups[track.gumGroup].stream;
+ nt = (track.type === 'audio' ? stream.getAudioTracks()[0] : stream.getVideoTracks()[0]);
+ delete groups[track.gumGroup].stream;
+ delete groups[track.gumGroup];
+ delete track.gumGroup;
+ } else if(track.capture instanceof MediaStreamTrack) {
+ // An external track was provided, use that
+ nt = track.capture;
+ } else {
+ if(!openedConsentDialog) {
+ openedConsentDialog = true;
+ pluginHandle.consentDialog(true);
+ }
+ let constraints = Janus.trackConstraints(track), stream = null;
+ if(track.type === 'audio' || track.type === 'video') {
+ // Use getUserMedia: check if we need to group audio and video together
+ if(track.gumGroup) {
+ let otherType = (track.type === 'audio' ? 'video' : 'audio');
+ if(groups[track.gumGroup] && groups[track.gumGroup][otherType]) {
+ let otherTrack = groups[track.gumGroup][otherType];
+ let otherConstraints = Janus.trackConstraints(otherTrack);
+ constraints[otherType] = otherConstraints[otherType];
+ }
+ }
+ stream = await navigator.mediaDevices.getUserMedia(constraints);
+ if(track.gumGroup && constraints.audio && constraints.video) {
+ // We just performed a grouped getUserMedia, keep track of the
+ // stream so that we can immediately assign the track later
+ groups[track.gumGroup].stream = stream;
+ delete track.gumGroup;
+ }
} else {
- // We're done, let's add the new attributes here
- insertAt = i;
- break;
+ // Use getDisplayMedia
+ stream = await navigator.mediaDevices.getDisplayMedia(constraints);
}
+ nt = (track.type === 'audio' ? stream.getAudioTracks()[0] : stream.getVideoTracks()[0]);
+ }
+ if(track.replace) {
+ // Replace the track
+ await sender.replaceTrack(nt);
+ // Update the transceiver direction
+ let newDirection = 'sendrecv';
+ if(track.recv === false || transceiver.direction === 'inactive' || transceiver.direction === 'sendonly')
+ newDirection = 'sendonly';
+ if(transceiver.setDirection)
+ transceiver.setDirection(newDirection);
+ else
+ transceiver.direction = newDirection;
} else {
- // New non-video m-line: do we have what we were looking for?
- if(ssrc[0] > -1) {
- // We're done, let's add the new attributes here
- insertAt = i;
- break;
+ // FIXME Add as a new track
+ if(!config.myStream)
+ config.myStream = new MediaStream();
+ if(kind === 'audio' || (!track.simulcast && !track.svc)) {
+ sender = config.pc.addTrack(nt, config.myStream);
+ transceiver = config.pc.getTransceivers()
+ .find(t => (t.sender === sender));
+ } else if(track.simulcast) {
+ // Standard RID
+ Janus.log('Enabling rid-based simulcasting:', nt);
+ let maxBitrates = getMaxBitrates(track.simulcastMaxBitrates);
+ transceiver = config.pc.addTransceiver(nt, {
+ direction: 'sendrecv',
+ streams: [config.myStream],
+ sendEncodings: track.sendEncodings || [
+ { rid: 'h', active: true, scalabilityMode: 'L1T2', maxBitrate: maxBitrates.high },
+ { rid: 'm', active: true, scalabilityMode: 'L1T2', maxBitrate: maxBitrates.medium, scaleResolutionDownBy: 2 },
+ { rid: 'l', active: true, scalabilityMode: 'L1T2', maxBitrate: maxBitrates.low, scaleResolutionDownBy: 4 }
+ ]
+ });
+ } else {
+ Janus.log('Enabling SVC (' + track.svc + '):', nt);
+ transceiver = config.pc.addTransceiver(nt, {
+ direction: 'sendrecv',
+ streams: [config.myStream],
+ sendEncodings: [
+ { scalabilityMode: track.svc }
+ ]
+ });
+ }
+ if(!sender)
+ sender = transceiver ? transceiver.sender : null;
+ // Check if we need to override some settings
+ if(track.codec) {
+ if(Janus.webRTCAdapter.browserDetails.browser === 'firefox') {
+ Janus.warn('setCodecPreferences not supported in Firefox, ignoring codec for track:', track);
+ } else if(typeof track.codec !== 'string') {
+ Janus.warn('Invalid codec value, ignoring for track:', track);
+ } else {
+ let mimeType = kind + '/' + track.codec.toLowerCase();
+ let codecs = RTCRtpReceiver.getCapabilities(kind).codecs.filter(function(codec) {
+ return codec.mimeType.toLowerCase() === mimeType;
+ });
+ if(!codecs || codecs.length === 0) {
+ Janus.warn('Codec not supported in this browser for this track, ignoring:', track);
+ } else if(transceiver) {
+ try {
+ transceiver.setCodecPreferences(codecs);
+ } catch(err) {
+ Janus.warn('Failed enforcing codec for this ' + kind + ' track:', err);
+ }
+ }
+ }
+ }
+ if(track.bitrate) {
+ // Override maximum bitrate
+ if(track.simulcast || track.svc) {
+ Janus.warn('Ignoring bitrate for simulcast/SVC track, use sendEncodings for that');
+ } else if(isNaN(track.bitrate) || track.bitrate < 0) {
+ Janus.warn('Ignoring invalid bitrate for track:', track);
+ } else if(sender) {
+ let params = sender.getParameters();
+ if(!params || !params.encodings || params.encodings.length === 0) {
+ Janus.warn('No encodings in the sender parameters, ignoring bitrate for track:', track);
+ } else {
+ params.encodings[0].maxBitrate = track.bitrate;
+ await sender.setParameters(params);
+ }
+ }
+ }
+ if(kind === 'video' && track.framerate) {
+ // Override maximum framerate
+ if(track.simulcast || track.svc) {
+ Janus.warn('Ignoring framerate for simulcast/SVC track, use sendEncodings for that');
+ } else if(isNaN(track.framerate) || track.framerate < 0) {
+ Janus.warn('Ignoring invalid framerate for track:', track);
+ } else if(sender) {
+ let params = sender.getParameters();
+ if(!params || !params.encodings || params.encodings.length === 0) {
+ Janus.warn('No encodings in the sender parameters, ignoring framerate for track:', track);
+ } else {
+ params.encodings[0].maxFramerate = track.framerate;
+ await sender.setParameters(params);
+ }
+ }
+ }
+ // Check if insertable streams are involved
+ if(track.transforms) {
+ if(sender && track.transforms.sender) {
+ // There's a sender transform, set it on the transceiver sender
+ let senderStreams = null;
+ if(RTCRtpSender.prototype.createEncodedStreams) {
+ senderStreams = sender.createEncodedStreams();
+ } else if(RTCRtpSender.prototype.createAudioEncodedStreams || RTCRtpSender.prototype.createEncodedVideoStreams) {
+ if(kind === 'audio') {
+ senderStreams = sender.createEncodedAudioStreams();
+ } else if(kind === 'video') {
+ senderStreams = sender.createEncodedVideoStreams();
+ }
+ }
+ if(senderStreams) {
+ console.log('Insertable Streams sender transform:', senderStreams);
+ if(senderStreams.readableStream && senderStreams.writableStream) {
+ senderStreams.readableStream
+ .pipeThrough(track.transforms.sender)
+ .pipeTo(senderStreams.writableStream);
+ } else if(senderStreams.readable && senderStreams.writable) {
+ senderStreams.readable
+ .pipeThrough(track.transforms.sender)
+ .pipeTo(senderStreams.writable);
+ }
+ }
+ }
+ if(transceiver && transceiver.receiver && track.transforms.receiver) {
+ // There's a receiver transform, set it on the transceiver receiver
+ let receiverStreams = null;
+ if(RTCRtpReceiver.prototype.createEncodedStreams) {
+ receiverStreams = transceiver.receiver.createEncodedStreams();
+ } else if(RTCRtpReceiver.prototype.createAudioEncodedStreams || RTCRtpReceiver.prototype.createEncodedVideoStreams) {
+ if(kind === 'audio') {
+ receiverStreams = transceiver.receiver.createEncodedAudioStreams();
+ } else if(kind === 'video') {
+ receiverStreams = transceiver.receiver.createEncodedVideoStreams();
+ }
+ }
+ if(receiverStreams) {
+ console.log('Insertable Streams receiver transform:', receiverStreams);
+ if(receiverStreams.readableStream && receiverStreams.writableStream) {
+ receiverStreams.readableStream
+ .pipeThrough(track.transforms.receiver)
+ .pipeTo(receiverStreams.writableStream);
+ } else if(receiverStreams.readable && receiverStreams.writable) {
+ receiverStreams.readable
+ .pipeThrough(track.transforms.receiver)
+ .pipeTo(receiverStreams.writable);
+ }
+ }
+ }
}
}
- continue;
- }
- if(!video)
- continue;
- if(ssrc[0] < 0) {
- var value = lines[i].match(/a=ssrc:(\d+)/);
- if(value) {
- ssrc[0] = value[1];
- lines.splice(i, 1); i--;
- continue;
+ if(nt && track.dontStop === true)
+ nt.dontStop = true;
+ } else if(track.recv) {
+ // Maybe a new recvonly track
+ if(!transceiver)
+ transceiver = config.pc.addTransceiver(kind);
+ if(transceiver) {
+ // Check if we need to override some settings
+ if(track.codec) {
+ if(Janus.webRTCAdapter.browserDetails.browser === 'firefox') {
+ Janus.warn('setCodecPreferences not supported in Firefox, ignoring codec for track:', track);
+ } else if(typeof track.codec !== 'string') {
+ Janus.warn('Invalid codec value, ignoring for track:', track);
+ } else {
+ let mimeType = kind + '/' + track.codec.toLowerCase();
+ let codecs = RTCRtpReceiver.getCapabilities(kind).codecs.filter(function(codec) {
+ return codec.mimeType.toLowerCase() === mimeType;
+ });
+ if(!codecs || codecs.length === 0) {
+ Janus.warn('Codec not supported in this browser for this track, ignoring:', track);
+ } else {
+ try {
+ transceiver.setCodecPreferences(codecs);
+ } catch(err) {
+ Janus.warn('Failed enforcing codec for this ' + kind + ' track:', err);
+ }
+ }
+ }
+ }
+ // Check if insertable streams are involved
+ if(transceiver.receiver && track.transforms && track.transforms.receiver) {
+ // There's a receiver transform, set it on the transceiver receiver
+ let receiverStreams = null;
+ if(RTCRtpReceiver.prototype.createEncodedStreams) {
+ receiverStreams = transceiver.receiver.createEncodedStreams();
+ } else if(RTCRtpReceiver.prototype.createAudioEncodedStreams || RTCRtpReceiver.prototype.createEncodedVideoStreams) {
+ if(kind === 'audio') {
+ receiverStreams = transceiver.receiver.createEncodedAudioStreams();
+ } else if(kind === 'video') {
+ receiverStreams = transceiver.receiver.createEncodedVideoStreams();
+ }
+ }
+ if(receiverStreams) {
+ console.log('Insertable Streams receiver transform:', receiverStreams);
+ if(receiverStreams.readableStream && receiverStreams.writableStream) {
+ receiverStreams.readableStream
+ .pipeThrough(track.transforms.receiver)
+ .pipeTo(receiverStreams.writableStream);
+ } else if(receiverStreams.readable && receiverStreams.writable) {
+ receiverStreams.readable
+ .pipeThrough(track.transforms.receiver)
+ .pipeTo(receiverStreams.writable);
+ }
+ }
+ }
}
+ }
+ if(nt) {
+ // FIXME Add the new track locally
+ config.myStream.addTrack(nt);
+ // Notify the application about the new local track, if any
+ nt.onended = function(ev) {
+ Janus.log('Local track removed:', ev);
+ try {
+ pluginHandle.onlocaltrack(ev.target, false);
+ } catch(e) {
+ Janus.error("Error calling onlocaltrack following end", e);
+ }
+ }
+ try {
+ pluginHandle.onlocaltrack(nt, true);
+ } catch(e) {
+ Janus.error("Error calling onlocaltrack for track add", e);
+ }
+ }
+ // Update the direction of the transceiver
+ if(transceiver) {
+ let curdir = transceiver.direction, newdir = null;
+ let send = (nt && transceiver.sender.track),
+ recv = (track.recv !== false && transceiver.receiver.track);
+ if(send && recv)
+ newdir = 'sendrecv';
+ else if(send && !recv)
+ newdir = 'sendonly';
+ else if(!send && recv)
+ newdir = 'recvonly';
+ else if(!send && !recv)
+ newdir = 'inactive';
+ if(newdir && newdir !== curdir) {
+ Janus.warn('Changing direction of transceiver to ' + newdir + ' (was ' + curdir + ')', track);
+ if(transceiver.setDirection)
+ transceiver.setDirection(newdir);
+ else
+ transceiver.direction = newdir;
+ }
+ }
+ }
+ if(openedConsentDialog)
+ pluginHandle.consentDialog(false);
+ }
+
+ function getLocalTracks(handleId) {
+ let pluginHandle = pluginHandles.get(handleId);
+ if(!pluginHandle || !pluginHandle.webrtcStuff) {
+ Janus.warn('Invalid handle');
+ return null;
+ }
+ let config = pluginHandle.webrtcStuff;
+ if(!config.pc) {
+ Janus.warn('Invalid PeerConnection');
+ return null;
+ }
+ let tracks = [];
+ let transceivers = config.pc.getTransceivers();
+ for(let tr of transceivers) {
+ let track = null;
+ if(tr.sender && tr.sender.track) {
+ track = { mid: tr.mid };
+ track.type = tr.sender.track.kind;
+ track.id = tr.sender.track.id;
+ track.label = tr.sender.track.label;
+ }
+ if(track)
+ tracks.push(track);
+ }
+ return tracks;
+ }
+
+ function getRemoteTracks(handleId) {
+ let pluginHandle = pluginHandles.get(handleId);
+ if(!pluginHandle || !pluginHandle.webrtcStuff) {
+ Janus.warn('Invalid handle');
+ return null;
+ }
+ let config = pluginHandle.webrtcStuff;
+ if(!config.pc) {
+ Janus.warn('Invalid PeerConnection');
+ return null;
+ }
+ let tracks = [];
+ let transceivers = config.pc.getTransceivers();
+ for(let tr of transceivers) {
+ let track = null;
+ if(tr.receiver && tr.receiver.track) {
+ track = { mid: tr.mid };
+ track.type = tr.receiver.track.kind;
+ track.id = tr.receiver.track.id;
+ track.label = tr.receiver.track.label;
+ }
+ if(track)
+ tracks.push(track);
+ }
+ return tracks;
+ }
+
+ function getVolume(handleId, mid, remote, result) {
+ result = (typeof result == "function") ? result : Janus.noop;
+ let pluginHandle = pluginHandles.get(handleId);
+ if(!pluginHandle || !pluginHandle.webrtcStuff) {
+ Janus.warn("Invalid handle");
+ result(0);
+ return;
+ }
+ let stream = remote ? "remote" : "local";
+ let config = pluginHandle.webrtcStuff;
+ if(!config.volume[stream])
+ config.volume[stream] = { value: 0 };
+ // Start getting the volume, if audioLevel in getStats is supported (apparently
+ // they're only available in Chrome/Safari right now: https://webrtc-stats.callstats.io/)
+ if(config.pc && config.pc.getStats && (Janus.webRTCAdapter.browserDetails.browser === "chrome" ||
+ Janus.webRTCAdapter.browserDetails.browser === "safari")) {
+ // Are we interested in a mid in particular?
+ let query = config.pc;
+ if(mid) {
+ let transceiver = config.pc.getTransceivers()
+ .find(t => (t.mid === mid && t.receiver.track.kind === "audio"));
+ if(!transceiver) {
+ Janus.warn("No audio transceiver with mid " + mid);
+ result(0);
+ return;
+ }
+ if(remote && !transceiver.receiver) {
+ Janus.warn("Remote transceiver track unavailable");
+ result(0);
+ return;
+ } else if(!remote && !transceiver.sender) {
+ Janus.warn("Local transceiver track unavailable");
+ result(0);
+ return;
+ }
+ query = remote ? transceiver.receiver : transceiver.sender;
+ }
+ query.getStats()
+ .then(function(stats) {
+ stats.forEach(function (res) {
+ if(!res || res.kind !== "audio")
+ return;
+ if((remote && !res.remoteSource) || (!remote && res.type !== "media-source"))
+ return;
+ result(res.audioLevel ? res.audioLevel : 0);
+ });
+ });
+ return config.volume[stream].value;
+ } else {
+ // audioInputLevel and audioOutputLevel seem only available in Chrome? audioLevel
+ // seems to be available on Chrome and Firefox, but they don't seem to work
+ Janus.warn("Getting the " + stream + " volume unsupported by browser");
+ result(0);
+ return;
+ }
+ }
+
+ function isMuted(handleId, mid, video) {
+ let pluginHandle = pluginHandles.get(handleId);
+ if(!pluginHandle || !pluginHandle.webrtcStuff) {
+ Janus.warn("Invalid handle");
+ return true;
+ }
+ let config = pluginHandle.webrtcStuff;
+ if(!config.pc) {
+ Janus.warn("Invalid PeerConnection");
+ return true;
+ }
+ if(!config.myStream) {
+ Janus.warn("Invalid local MediaStream");
+ return true;
+ }
+ if(video) {
+ // Check video track
+ if(!config.myStream.getVideoTracks() || config.myStream.getVideoTracks().length === 0) {
+ Janus.warn("No video track");
+ return true;
+ }
+ if(mid) {
+ let transceiver = config.pc.getTransceivers()
+ .find(t => (t.mid === mid && t.receiver.track.kind === "video"));
+ if(!transceiver) {
+ Janus.warn("No video transceiver with mid " + mid);
+ return true;
+ }
+ if(!transceiver.sender || !transceiver.sender.track) {
+ Janus.warn("No video sender with mid " + mid);
+ return true;
+ }
+ return !transceiver.sender.track.enabled;
} else {
- let match = lines[i].match('a=ssrc:' + ssrc[0] + ' cname:(.+)')
- if(match) {
- cname = match[1];
- }
- match = lines[i].match('a=ssrc:' + ssrc[0] + ' msid:(.+)')
- if(match) {
- msid = match[1];
- }
- match = lines[i].match('a=ssrc:' + ssrc[0] + ' mslabel:(.+)')
- if(match) {
- mslabel = match[1];
- }
- match = lines[i].match('a=ssrc:' + ssrc[0] + ' label:(.+)')
- if(match) {
- label = match[1];
- }
- if(lines[i].indexOf('a=ssrc:' + ssrc_fid[0]) === 0) {
- lines.splice(i, 1); i--;
- continue;
- }
- if(lines[i].indexOf('a=ssrc:' + ssrc[0]) === 0) {
- lines.splice(i, 1); i--;
- continue;
- }
+ return !config.myStream.getVideoTracks()[0].enabled;
}
- if(lines[i].length === 0) {
- lines.splice(i, 1); i--;
- continue;
+ } else {
+ // Check audio track
+ if(!config.myStream.getAudioTracks() || config.myStream.getAudioTracks().length === 0) {
+ Janus.warn("No audio track");
+ return true;
+ }
+ if(mid) {
+ let transceiver = config.pc.getTransceivers()
+ .find(t => (t.mid === mid && t.receiver.track.kind === "audio"));
+ if(!transceiver) {
+ Janus.warn("No audio transceiver with mid " + mid);
+ return true;
+ }
+ if(!transceiver.sender || !transceiver.sender.track) {
+ Janus.warn("No audio sender with mid " + mid);
+ return true;
+ }
+ return !transceiver.sender.track.enabled;
+ } else {
+ return !config.myStream.getAudioTracks()[0].enabled;
}
}
}
- if(ssrc[0] < 0) {
- // Still nothing, let's just return the SDP we were asked to munge
- Janus.warn("Couldn't find the video SSRC, simulcasting NOT enabled");
- return sdp;
+
+ function mute(handleId, mid, video, mute) {
+ let pluginHandle = pluginHandles.get(handleId);
+ if(!pluginHandle || !pluginHandle.webrtcStuff) {
+ Janus.warn("Invalid handle");
+ return false;
+ }
+ let config = pluginHandle.webrtcStuff;
+ if(!config.pc) {
+ Janus.warn("Invalid PeerConnection");
+ return false;
+ }
+ if(!config.myStream) {
+ Janus.warn("Invalid local MediaStream");
+ return false;
+ }
+ if(video) {
+ // Mute/unmute video track
+ if(!config.myStream.getVideoTracks() || config.myStream.getVideoTracks().length === 0) {
+ Janus.warn("No video track");
+ return false;
+ }
+ if(mid) {
+ let transceiver = config.pc.getTransceivers()
+ .find(t => (t.mid === mid && t.receiver.track.kind === "video"));
+ if(!transceiver) {
+ Janus.warn("No video transceiver with mid " + mid);
+ return false;
+ }
+ if(!transceiver.sender || !transceiver.sender.track) {
+ Janus.warn("No video sender with mid " + mid);
+ return false;
+ }
+ transceiver.sender.track.enabled = mute ? false : true;
+ } else {
+ for(const videostream of config.myStream.getVideoTracks()) {
+ videostream.enabled = !mute
+ }
+ }
+ } else {
+ // Mute/unmute audio track
+ if(!config.myStream.getAudioTracks() || config.myStream.getAudioTracks().length === 0) {
+ Janus.warn("No audio track");
+ return false;
+ }
+ if(mid) {
+ let transceiver = config.pc.getTransceivers()
+ .find(t => (t.mid === mid && t.receiver.track.kind === "audio"));
+ if(!transceiver) {
+ Janus.warn("No audio transceiver with mid " + mid);
+ return false;
+ }
+ if(!transceiver.sender || !transceiver.sender.track) {
+ Janus.warn("No audio sender with mid " + mid);
+ return false;
+ }
+ transceiver.sender.track.enabled = mute ? false : true;
+ } else {
+ for(const audiostream of config.myStream.getAudioTracks()) {
+ audiostream.enabled = !mute
+ }
+ }
+ }
+ return true;
}
- if(insertAt < 0) {
- // Append at the end
- insertAt = lines.length;
- }
- // Generate a couple of SSRCs (for retransmissions too)
- // Note: should we check if there are conflicts, here?
- ssrc[1] = Math.floor(Math.random()*0xFFFFFFFF);
- ssrc[2] = Math.floor(Math.random()*0xFFFFFFFF);
- ssrc_fid[1] = Math.floor(Math.random()*0xFFFFFFFF);
- ssrc_fid[2] = Math.floor(Math.random()*0xFFFFFFFF);
- // Add attributes to the SDP
- for(var i=0; i (t.mid === mid && t.receiver.track.kind === "video"));
+ if(!transceiver) {
+ Janus.warn("No video transceiver with mid " + mid);
+ return ("No video transceiver with mid " + mid);
+ }
+ if(!transceiver.receiver) {
+ Janus.warn("No video receiver with mid " + mid);
+ return ("No video receiver with mid " + mid);
+ }
+ query = transceiver.receiver;
+ }
+ if(!config.bitrate[target]) {
+ config.bitrate[target] = {
+ timer: null,
+ bsnow: null,
+ bsbefore: null,
+ tsnow: null,
+ tsbefore: null,
+ value: "0 kbits/sec"
+ };
+ }
+ if(!config.bitrate[target].timer) {
+ Janus.log("Starting bitrate timer" + (mid ? (" for mid " + mid) : "") + " (via getStats)");
+ config.bitrate[target].timer = setInterval(function() {
+ query.getStats()
+ .then(function(stats) {
+ stats.forEach(function (res) {
+ if(!res)
+ return;
+ let inStats = false;
+ // Check if these are statistics on incoming media
+ if((res.mediaType === "video" || res.kind === "video" || res.id.toLowerCase().indexOf("video") > -1) &&
+ res.type === "inbound-rtp" && res.id.indexOf("rtcp") < 0) {
+ // New stats
+ inStats = true;
+ } else if(res.type == 'ssrc' && res.bytesReceived &&
+ (res.googCodecName === "VP8" || res.googCodecName === "")) {
+ // Older Chromer versions
+ inStats = true;
+ }
+ // Parse stats now
+ if(inStats) {
+ config.bitrate[target].bsnow = res.bytesReceived;
+ config.bitrate[target].tsnow = res.timestamp;
+ if(config.bitrate[target].bsbefore === null || config.bitrate[target].tsbefore === null) {
+ // Skip this round
+ config.bitrate[target].bsbefore = config.bitrate[target].bsnow;
+ config.bitrate[target].tsbefore = config.bitrate[target].tsnow;
+ } else {
+ // Calculate bitrate
+ let timePassed = config.bitrate[target].tsnow - config.bitrate[target].tsbefore;
+ if(Janus.webRTCAdapter.browserDetails.browser === "safari")
+ timePassed = timePassed/1000; // Apparently the timestamp is in microseconds, in Safari
+ let bitRate = Math.round((config.bitrate[target].bsnow - config.bitrate[target].bsbefore) * 8 / timePassed);
+ if(Janus.webRTCAdapter.browserDetails.browser === "safari")
+ bitRate = parseInt(bitRate/1000);
+ config.bitrate[target].value = bitRate + ' kbits/sec';
+ //~ Janus.log("Estimated bitrate is " + config.bitrate.value);
+ config.bitrate[target].bsbefore = config.bitrate[target].bsnow;
+ config.bitrate[target].tsbefore = config.bitrate[target].tsnow;
+ }
+ }
+ });
+ });
+ }, 1000);
+ return "0 kbits/sec"; // We don't have a bitrate value yet
+ }
+ return config.bitrate[target].value;
+ } else {
+ Janus.warn("Getting the video bitrate unsupported by browser");
+ return "Feature unsupported by browser";
}
}
- lines.splice(insertAt, 0, 'a=ssrc-group:FID ' + ssrc[2] + ' ' + ssrc_fid[2]);
- lines.splice(insertAt, 0, 'a=ssrc-group:FID ' + ssrc[1] + ' ' + ssrc_fid[1]);
- lines.splice(insertAt, 0, 'a=ssrc-group:FID ' + ssrc[0] + ' ' + ssrc_fid[0]);
- lines.splice(insertAt, 0, 'a=ssrc-group:SIM ' + ssrc[0] + ' ' + ssrc[1] + ' ' + ssrc[2]);
- sdp = lines.join("\r\n");
- if(!sdp.endsWith("\r\n"))
- sdp += "\r\n";
- return sdp;
- }
- // Helper methods to parse a media object
- function isAudioSendEnabled(media) {
- Janus.debug("isAudioSendEnabled:", media);
- if(!media)
- return true; // Default
- if(media.audio === false)
- return false; // Generic audio has precedence
- if(media.audioSend === undefined || media.audioSend === null)
- return true; // Default
- return (media.audioSend === true);
- }
-
- function isAudioSendRequired(media) {
- Janus.debug("isAudioSendRequired:", media);
- if(!media)
- return false; // Default
- if(media.audio === false || media.audioSend === false)
- return false; // If we're not asking to capture audio, it's not required
- if(media.failIfNoAudio === undefined || media.failIfNoAudio === null)
- return false; // Default
- return (media.failIfNoAudio === true);
- }
-
- function isAudioRecvEnabled(media) {
- Janus.debug("isAudioRecvEnabled:", media);
- if(!media)
- return true; // Default
- if(media.audio === false)
- return false; // Generic audio has precedence
- if(media.audioRecv === undefined || media.audioRecv === null)
- return true; // Default
- return (media.audioRecv === true);
- }
-
- function isVideoSendEnabled(media) {
- Janus.debug("isVideoSendEnabled:", media);
- if(!media)
- return true; // Default
- if(media.video === false)
- return false; // Generic video has precedence
- if(media.videoSend === undefined || media.videoSend === null)
- return true; // Default
- return (media.videoSend === true);
- }
-
- function isVideoSendRequired(media) {
- Janus.debug("isVideoSendRequired:", media);
- if(!media)
- return false; // Default
- if(media.video === false || media.videoSend === false)
- return false; // If we're not asking to capture video, it's not required
- if(media.failIfNoVideo === undefined || media.failIfNoVideo === null)
- return false; // Default
- return (media.failIfNoVideo === true);
- }
-
- function isVideoRecvEnabled(media) {
- Janus.debug("isVideoRecvEnabled:", media);
- if(!media)
- return true; // Default
- if(media.video === false)
- return false; // Generic video has precedence
- if(media.videoRecv === undefined || media.videoRecv === null)
- return true; // Default
- return (media.videoRecv === true);
- }
-
- function isScreenSendEnabled(media) {
- Janus.debug("isScreenSendEnabled:", media);
- if (!media)
- return false;
- if (typeof media.video !== 'object' || typeof media.video.mandatory !== 'object')
- return false;
- var constraints = media.video.mandatory;
- if (constraints.chromeMediaSource)
- return constraints.chromeMediaSource === 'desktop' || constraints.chromeMediaSource === 'screen';
- else if (constraints.mozMediaSource)
- return constraints.mozMediaSource === 'window' || constraints.mozMediaSource === 'screen';
- else if (constraints.mediaSource)
- return constraints.mediaSource === 'window' || constraints.mediaSource === 'screen';
- return false;
- }
-
- function isDataEnabled(media) {
- Janus.debug("isDataEnabled:", media);
- if(Janus.webRTCAdapter.browserDetails.browser === "edge") {
- Janus.warn("Edge doesn't support data channels yet");
- return false;
+ function setBitrate(handleId, mid, bitrate) {
+ let pluginHandle = pluginHandles.get(handleId);
+ if(!pluginHandle || !pluginHandle.webrtcStuff) {
+ Janus.warn('Invalid handle');
+ return;
+ }
+ let config = pluginHandle.webrtcStuff;
+ if(!config.pc) {
+ Janus.warn('Invalid PeerConnection');
+ return;
+ }
+ let transceiver = config.pc.getTransceivers().find(t => (t.mid === mid));
+ if(!transceiver) {
+ Janus.warn('No transceiver with mid', mid);
+ return;
+ }
+ if(!transceiver.sender) {
+ Janus.warn('No sender for transceiver with mid', mid);
+ return;
+ }
+ let params = transceiver.sender.getParameters();
+ if(!params || !params.encodings || params.encodings.length === 0) {
+ Janus.warn('No parameters encodings');
+ } else if(params.encodings.length > 1) {
+ Janus.warn('Ignoring bitrate for simulcast track, use sendEncodings for that');
+ } else if(isNaN(bitrate) || bitrate < 0) {
+ Janus.warn('Invalid bitrate (must be a positive integer)');
+ } else {
+ params.encodings[0].maxBitrate = bitrate;
+ transceiver.sender.setParameters(params);
+ }
+ }
+
+ function webrtcError(error) {
+ Janus.error("WebRTC error:", error);
+ }
+
+ function cleanupWebrtc(handleId, hangupRequest) {
+ Janus.log("Cleaning WebRTC stuff");
+ let pluginHandle = pluginHandles.get(handleId);
+ if(!pluginHandle) {
+ // Nothing to clean
+ return;
+ }
+ let config = pluginHandle.webrtcStuff;
+ if(config) {
+ if(hangupRequest === true) {
+ // Send a hangup request (we don't really care about the response)
+ let request = { "janus": "hangup", "transaction": Janus.randomString(12) };
+ if(pluginHandle.token)
+ request["token"] = pluginHandle.token;
+ if(apisecret)
+ request["apisecret"] = apisecret;
+ Janus.debug("Sending hangup request (handle=" + handleId + "):");
+ Janus.debug(request);
+ if(websockets) {
+ request["session_id"] = sessionId;
+ request["handle_id"] = handleId;
+ ws.send(JSON.stringify(request));
+ } else {
+ Janus.httpAPICall(server + "/" + sessionId + "/" + handleId, {
+ verb: 'POST',
+ withCredentials: withCredentials,
+ body: request
+ });
+ }
+ }
+ // Cleanup stack
+ if(config.volume) {
+ if(config.volume["local"] && config.volume["local"].timer)
+ clearInterval(config.volume["local"].timer);
+ if(config.volume["remote"] && config.volume["remote"].timer)
+ clearInterval(config.volume["remote"].timer);
+ }
+ for(let i in config.bitrate) {
+ if(config.bitrate[i].timer)
+ clearInterval(config.bitrate[i].timer);
+ }
+ config.bitrate = {};
+ if(!config.streamExternal && config.myStream) {
+ Janus.log("Stopping local stream tracks");
+ Janus.stopAllTracks(config.myStream);
+ }
+ config.streamExternal = false;
+ config.myStream = null;
+ // Close PeerConnection
+ try {
+ config.pc.close();
+ // eslint-disable-next-line no-unused-vars
+ } catch(e) {
+ // Do nothing
+ }
+ config.pc = null;
+ config.candidates = null;
+ config.mySdp = null;
+ config.remoteSdp = null;
+ config.iceDone = false;
+ config.dataChannel = {};
+ config.dtmfSender = null;
+ config.insertableStreams = false;
+ config.externalEncryption = false;
+ }
+ pluginHandle.oncleanup();
+ }
+
+ function isTrickleEnabled(trickle) {
+ Janus.debug("isTrickleEnabled:", trickle);
+ return (trickle === false) ? false : true;
}
- if(media === undefined || media === null)
- return false; // Default
- return (media.data === true);
}
- function isTrickleEnabled(trickle) {
- Janus.debug("isTrickleEnabled:", trickle);
- return (trickle === false) ? false : true;
- }
-}
+ return Janus;
+
+}));
diff --git a/testenv/linters/mypy.ini b/testenv/linters/mypy.ini
index d436fd2c..f96d8307 100644
--- a/testenv/linters/mypy.ini
+++ b/testenv/linters/mypy.ini
@@ -1,5 +1,5 @@
[mypy]
-python_version = 3.11
+python_version = 3.13
ignore_missing_imports = true
disallow_untyped_defs = true
strict_optional = true
diff --git a/testenv/linters/vulture-wl.py b/testenv/linters/vulture-wl.py
index 32cfdf88..ac4d9ec4 100644
--- a/testenv/linters/vulture-wl.py
+++ b/testenv/linters/vulture-wl.py
@@ -29,6 +29,7 @@ _AtxApiPart.switch_power
_UsbKey.arduino_modifier_code
_KeyMapping.web_name
+_KeyMapping.evdev_name
_KeyMapping.mcu_code
_KeyMapping.usb_key
_KeyMapping.ps2_key
@@ -58,6 +59,7 @@ Dumper.ignore_aliases
_auth_server_port_fixture
_test_user
+Switch.__x_set_dummies
Switch.__x_set_port_names
Switch.__x_set_atx_cp_delays
Switch.__x_set_atx_cpl_delays
@@ -67,18 +69,27 @@ Nak.BUSY
Nak.NO_DOWNLINK
Nak.DOWNLINK_OVERFLOW
UnitFlags.flashing_busy
+UnitFlags.has_hpd
+StateCache.get_dummies
StateCache.get_port_names
StateCache.get_atx_cp_delays
StateCache.get_atx_cpl_delays
StorageContext.write_edids
+StorageContext.write_dummies
StorageContext.write_colors
StorageContext.write_port_names
StorageContext.write_atx_cp_delays
StorageContext.write_atx_cpl_delays
StorageContext.write_atx_cr_delays
StorageContext.read_edids
+StorageContext.read_dummies
StorageContext.read_colors
StorageContext.read_port_names
StorageContext.read_atx_cp_delays
StorageContext.read_atx_cpl_delays
StorageContext.read_atx_cr_delays
+
+RequestUnixCredentials.pid
+RequestUnixCredentials.gid
+
+KvmdClientWs.send_mouse_relative_event
diff --git a/testenv/requirements-core.txt b/testenv/requirements-core.txt
new file mode 100644
index 00000000..8e7a4248
--- /dev/null
+++ b/testenv/requirements-core.txt
@@ -0,0 +1,6 @@
+# Core dependencies for code quality checks (hardware dependencies excluded)
+python-dateutil>=2.8.1
+pyserial
+pyserial-asyncio
+types-PyYAML
+types-aiofiles
\ No newline at end of file
diff --git a/testenv/requirements.txt b/testenv/requirements.txt
index ba60982e..90ad5eda 100644
--- a/testenv/requirements.txt
+++ b/testenv/requirements.txt
@@ -1,9 +1,10 @@
python-periphery
pyserial-asyncio
-pyghmi
+git+https://opendev.org/x/pyghmi.git#33cff21882b6782c20b054e6e8adcf94b5e09561
spidev
pyrad
types-PyYAML
types-aiofiles
luma.oled
pyfatfs
+gpiod>=2.3
diff --git a/testenv/tests/apps/htpasswd/test_main.py b/testenv/tests/apps/htpasswd/test_main.py
index ba45110b..be48a3ed 100644
--- a/testenv/tests/apps/htpasswd/test_main.py
+++ b/testenv/tests/apps/htpasswd/test_main.py
@@ -29,12 +29,12 @@ import getpass
from typing import Generator
from typing import Any
-import passlib.apache
-
import pytest
from kvmd.apps.htpasswd import main
+from kvmd.crypto import KvmdHtpasswdFile
+
# =====
def _make_passwd(user: str) -> str:
@@ -42,28 +42,30 @@ def _make_passwd(user: str) -> str:
@pytest.fixture(name="htpasswd", params=[[], ["admin"], ["admin", "user"]])
-def _htpasswd_fixture(request) -> Generator[passlib.apache.HtpasswdFile, None, None]: # type: ignore
+def _htpasswd_fixture(request) -> Generator[KvmdHtpasswdFile, None, None]: # type: ignore
(fd, path) = tempfile.mkstemp()
os.close(fd)
- htpasswd = passlib.apache.HtpasswdFile(path)
+ htpasswd = KvmdHtpasswdFile(path)
for user in request.param:
htpasswd.set_password(user, _make_passwd(user))
htpasswd.save()
- yield htpasswd
- os.remove(path)
+ try:
+ yield htpasswd
+ finally:
+ os.remove(path)
-def _run_htpasswd(cmd: list[str], htpasswd_path: str, internal_type: str="htpasswd") -> None:
+def _run_htpasswd(cmd: list[str], htpasswd_path: str, int_type: str="htpasswd") -> None:
cmd = ["kvmd-htpasswd", *cmd, "--set-options"]
- if internal_type != "htpasswd": # By default
- cmd.append("kvmd/auth/internal/type=" + internal_type)
+ if int_type != "htpasswd": # By default
+ cmd.append("kvmd/auth/internal/type=" + int_type)
if htpasswd_path:
cmd.append("kvmd/auth/internal/file=" + htpasswd_path)
main(cmd)
# =====
-def test_ok__list(htpasswd: passlib.apache.HtpasswdFile, capsys) -> None: # type: ignore
+def test_ok__list(htpasswd: KvmdHtpasswdFile, capsys) -> None: # type: ignore
_run_htpasswd(["list"], htpasswd.path)
(out, err) = capsys.readouterr()
assert len(err) == 0
@@ -71,24 +73,32 @@ def test_ok__list(htpasswd: passlib.apache.HtpasswdFile, capsys) -> None: # typ
# =====
-def test_ok__set_change_stdin(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore
+def test_ok__set_stdin(htpasswd: KvmdHtpasswdFile, mocker) -> None: # type: ignore
old_users = set(htpasswd.users())
if old_users:
assert htpasswd.check_password("admin", _make_passwd("admin"))
mocker.patch.object(builtins, "input", (lambda: " test "))
+
_run_htpasswd(["set", "admin", "--read-stdin"], htpasswd.path)
+ with pytest.raises(SystemExit, match="The user 'new' is not exist"):
+ _run_htpasswd(["set", "new", "--read-stdin"], htpasswd.path)
+
htpasswd.load(force=True)
assert htpasswd.check_password("admin", " test ")
assert old_users == set(htpasswd.users())
-def test_ok__set_add_stdin(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore
+def test_ok__add_stdin(htpasswd: KvmdHtpasswdFile, mocker) -> None: # type: ignore
old_users = set(htpasswd.users())
if old_users:
mocker.patch.object(builtins, "input", (lambda: " test "))
- _run_htpasswd(["set", "new", "--read-stdin"], htpasswd.path)
+
+ _run_htpasswd(["add", "new", "--read-stdin"], htpasswd.path)
+
+ with pytest.raises(SystemExit, match="The user 'new' is already exists"):
+ _run_htpasswd(["add", "new", "--read-stdin"], htpasswd.path)
htpasswd.load(force=True)
assert htpasswd.check_password("new", " test ")
@@ -96,20 +106,24 @@ def test_ok__set_add_stdin(htpasswd: passlib.apache.HtpasswdFile, mocker) -> Non
# =====
-def test_ok__set_change_getpass(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore
+def test_ok__set_getpass(htpasswd: KvmdHtpasswdFile, mocker) -> None: # type: ignore
old_users = set(htpasswd.users())
if old_users:
assert htpasswd.check_password("admin", _make_passwd("admin"))
mocker.patch.object(getpass, "getpass", (lambda *_, **__: " test "))
+
_run_htpasswd(["set", "admin"], htpasswd.path)
+ with pytest.raises(SystemExit, match="The user 'new' is not exist"):
+ _run_htpasswd(["set", "new"], htpasswd.path)
+
htpasswd.load(force=True)
assert htpasswd.check_password("admin", " test ")
assert old_users == set(htpasswd.users())
-def test_fail__set_change_getpass(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore
+def test_fail__set_getpass(htpasswd: KvmdHtpasswdFile, mocker) -> None: # type: ignore
old_users = set(htpasswd.users())
if old_users:
assert htpasswd.check_password("admin", _make_passwd("admin"))
@@ -137,13 +151,15 @@ def test_fail__set_change_getpass(htpasswd: passlib.apache.HtpasswdFile, mocker)
# =====
-def test_ok__del(htpasswd: passlib.apache.HtpasswdFile) -> None:
+def test_ok__del(htpasswd: KvmdHtpasswdFile) -> None:
old_users = set(htpasswd.users())
if old_users:
assert htpasswd.check_password("admin", _make_passwd("admin"))
+ _run_htpasswd(["del", "admin"], htpasswd.path)
- _run_htpasswd(["del", "admin"], htpasswd.path)
+ with pytest.raises(SystemExit, match="The user 'admin' is not exist"):
+ _run_htpasswd(["del", "admin"], htpasswd.path)
htpasswd.load(force=True)
assert not htpasswd.check_password("admin", _make_passwd("admin"))
@@ -152,13 +168,13 @@ def test_ok__del(htpasswd: passlib.apache.HtpasswdFile) -> None:
# =====
def test_fail__not_htpasswd() -> None:
- with pytest.raises(SystemExit, match="Error: KVMD internal auth not using 'htpasswd'"):
- _run_htpasswd(["list"], "", internal_type="http")
+ with pytest.raises(SystemExit, match="Error: KVMD internal auth does not use 'htpasswd'"):
+ _run_htpasswd(["list"], "", int_type="http")
def test_fail__unknown_plugin() -> None:
with pytest.raises(SystemExit, match="ConfigError: Unknown plugin 'auth/foobar'"):
- _run_htpasswd(["list"], "", internal_type="foobar")
+ _run_htpasswd(["list"], "", int_type="foobar")
def test_fail__invalid_passwd(mocker, tmpdir) -> None: # type: ignore
@@ -166,4 +182,4 @@ def test_fail__invalid_passwd(mocker, tmpdir) -> None: # type: ignore
open(path, "w").close() # pylint: disable=consider-using-with
mocker.patch.object(builtins, "input", (lambda: "\n"))
with pytest.raises(SystemExit, match="The argument is not a valid passwd characters"):
- _run_htpasswd(["set", "admin", "--read-stdin"], path)
+ _run_htpasswd(["add", "admin", "--read-stdin"], path)
diff --git a/testenv/tests/apps/kvmd/test_auth.py b/testenv/tests/apps/kvmd/test_auth.py
index 4fa1c8ae..b5c8dfbb 100644
--- a/testenv/tests/apps/kvmd/test_auth.py
+++ b/testenv/tests/apps/kvmd/test_auth.py
@@ -21,27 +21,37 @@
import os
+import asyncio
+import base64
import contextlib
from typing import AsyncGenerator
-import passlib.apache
+from aiohttp.test_utils import make_mocked_request
import pytest
+from kvmd.validators import ValidatorError
+
from kvmd.yamlconf import make_config
from kvmd.apps.kvmd.auth import AuthManager
+from kvmd.apps.kvmd.api.auth import check_request_auth
+
+from kvmd.htserver import UnauthorizedError
+from kvmd.htserver import ForbiddenError
from kvmd.plugins.auth import get_auth_service_class
from kvmd.htserver import HttpExposed
+from kvmd.crypto import KvmdHtpasswdFile
+
# =====
-_E_AUTH = HttpExposed("GET", "/foo_auth", True, (lambda: None))
-_E_UNAUTH = HttpExposed("GET", "/bar_unauth", True, (lambda: None))
-_E_FREE = HttpExposed("GET", "/baz_free", False, (lambda: None))
+_E_AUTH = HttpExposed("GET", "/foo_auth", auth_required=True, allow_usc=True, handler=(lambda: None))
+_E_UNAUTH = HttpExposed("GET", "/bar_unauth", auth_required=True, allow_usc=True, handler=(lambda: None))
+_E_FREE = HttpExposed("GET", "/baz_free", auth_required=False, allow_usc=True, handler=(lambda: None))
def _make_service_kwargs(path: str) -> dict:
@@ -53,21 +63,24 @@ def _make_service_kwargs(path: str) -> dict:
@contextlib.asynccontextmanager
async def _get_configured_manager(
unauth_paths: list[str],
- internal_path: str,
- external_path: str="",
- force_internal_users: (list[str] | None)=None,
+ int_path: str,
+ ext_path: str="",
+ force_int_users: (list[str] | None)=None,
) -> AsyncGenerator[AuthManager, None]:
manager = AuthManager(
enabled=True,
+ expire=0,
+ usc_users=[],
+ usc_groups=[],
unauth_paths=unauth_paths,
- internal_type="htpasswd",
- internal_kwargs=_make_service_kwargs(internal_path),
- force_internal_users=(force_internal_users or []),
+ int_type="htpasswd",
+ int_kwargs=_make_service_kwargs(int_path),
+ force_int_users=(force_int_users or []),
- external_type=("htpasswd" if external_path else ""),
- external_kwargs=(_make_service_kwargs(external_path) if external_path else {}),
+ ext_type=("htpasswd" if ext_path else ""),
+ ext_kwargs=(_make_service_kwargs(ext_path) if ext_path else {}),
totp_secret_path="",
)
@@ -80,10 +93,61 @@ async def _get_configured_manager(
# =====
@pytest.mark.asyncio
-async def test_ok__internal(tmpdir) -> None: # type: ignore
+async def test_ok__request(tmpdir) -> None: # type: ignore
path = os.path.abspath(str(tmpdir.join("htpasswd")))
- htpasswd = passlib.apache.HtpasswdFile(path, new=True)
+ htpasswd = KvmdHtpasswdFile(path, new=True)
+ htpasswd.set_password("admin", "pass")
+ htpasswd.save()
+
+ async with _get_configured_manager([], path) as manager:
+ async def check(exposed: HttpExposed, **kwargs) -> None: # type: ignore
+ await check_request_auth(manager, exposed, make_mocked_request(exposed.method, exposed.path, **kwargs))
+
+ await check(_E_FREE)
+ with pytest.raises(UnauthorizedError):
+ await check(_E_AUTH)
+
+ # ===
+
+ with pytest.raises(ForbiddenError):
+ await check(_E_AUTH, headers={"X-KVMD-User": "admin", "X-KVMD-Passwd": "foo"})
+ with pytest.raises(ForbiddenError):
+ await check(_E_AUTH, headers={"X-KVMD-User": "adminx", "X-KVMD-Passwd": "pass"})
+
+ await check(_E_AUTH, headers={"X-KVMD-User": "admin", "X-KVMD-Passwd": "pass"})
+
+ # ===
+
+ with pytest.raises(UnauthorizedError):
+ await check(_E_AUTH, headers={"Cookie": "auth_token="})
+ with pytest.raises(ValidatorError):
+ await check(_E_AUTH, headers={"Cookie": "auth_token=0"})
+ with pytest.raises(ForbiddenError):
+ await check(_E_AUTH, headers={"Cookie": f"auth_token={'0' * 64}"})
+
+ token = await manager.login("admin", "pass", 0)
+ assert token
+ await check(_E_AUTH, headers={"Cookie": f"auth_token={token}"})
+ manager.logout(token)
+ with pytest.raises(ForbiddenError):
+ await check(_E_AUTH, headers={"Cookie": f"auth_token={token}"})
+
+ # ===
+
+ with pytest.raises(ForbiddenError):
+ await check(_E_AUTH, headers={"Authorization": "basic " + base64.b64encode(b"admin:foo").decode()})
+ with pytest.raises(ForbiddenError):
+ await check(_E_AUTH, headers={"Authorization": "basic " + base64.b64encode(b"adminx:pass").decode()})
+
+ await check(_E_AUTH, headers={"Authorization": "basic " + base64.b64encode(b"admin:pass").decode()})
+
+
+@pytest.mark.asyncio
+async def test_ok__expire(tmpdir) -> None: # type: ignore
+ path = os.path.abspath(str(tmpdir.join("htpasswd")))
+
+ htpasswd = KvmdHtpasswdFile(path, new=True)
htpasswd.set_password("admin", "pass")
htpasswd.save()
@@ -96,15 +160,15 @@ async def test_ok__internal(tmpdir) -> None: # type: ignore
assert manager.check("xxx") is None
manager.logout("xxx")
- assert (await manager.login("user", "foo")) is None
- assert (await manager.login("admin", "foo")) is None
- assert (await manager.login("user", "pass")) is None
+ assert (await manager.login("user", "foo", 3)) is None
+ assert (await manager.login("admin", "foo", 3)) is None
+ assert (await manager.login("user", "pass", 3)) is None
- token1 = await manager.login("admin", "pass")
+ token1 = await manager.login("admin", "pass", 3)
assert isinstance(token1, str)
assert len(token1) == 64
- token2 = await manager.login("admin", "pass")
+ token2 = await manager.login("admin", "pass", 3)
assert isinstance(token2, str)
assert len(token2) == 64
assert token1 != token2
@@ -119,7 +183,75 @@ async def test_ok__internal(tmpdir) -> None: # type: ignore
assert manager.check(token2) is None
assert manager.check("foobar") is None
- token3 = await manager.login("admin", "pass")
+ token3 = await manager.login("admin", "pass", 3)
+ assert isinstance(token3, str)
+ assert len(token3) == 64
+ assert token1 != token3
+ assert token2 != token3
+
+ token4 = await manager.login("admin", "pass", 6)
+ assert isinstance(token4, str)
+ assert len(token4) == 64
+ assert token1 != token4
+ assert token2 != token4
+ assert token3 != token4
+
+ await asyncio.sleep(4)
+
+ assert manager.check(token1) is None
+ assert manager.check(token2) is None
+ assert manager.check(token3) is None
+ assert manager.check(token4) == "admin"
+
+ await asyncio.sleep(3)
+
+ assert manager.check(token1) is None
+ assert manager.check(token2) is None
+ assert manager.check(token3) is None
+ assert manager.check(token4) is None
+
+
+@pytest.mark.asyncio
+async def test_ok__internal(tmpdir) -> None: # type: ignore
+ path = os.path.abspath(str(tmpdir.join("htpasswd")))
+
+ htpasswd = KvmdHtpasswdFile(path, new=True)
+ htpasswd.set_password("admin", "pass")
+ htpasswd.save()
+
+ async with _get_configured_manager([], path) as manager:
+ assert manager.is_auth_enabled()
+ assert manager.is_auth_required(_E_AUTH)
+ assert manager.is_auth_required(_E_UNAUTH)
+ assert not manager.is_auth_required(_E_FREE)
+
+ assert manager.check("xxx") is None
+ manager.logout("xxx")
+
+ assert (await manager.login("user", "foo", 0)) is None
+ assert (await manager.login("admin", "foo", 0)) is None
+ assert (await manager.login("user", "pass", 0)) is None
+
+ token1 = await manager.login("admin", "pass", 0)
+ assert isinstance(token1, str)
+ assert len(token1) == 64
+
+ token2 = await manager.login("admin", "pass", 0)
+ assert isinstance(token2, str)
+ assert len(token2) == 64
+ assert token1 != token2
+
+ assert manager.check(token1) == "admin"
+ assert manager.check(token2) == "admin"
+ assert manager.check("foobar") is None
+
+ manager.logout(token1)
+
+ assert manager.check(token1) is None
+ assert manager.check(token2) is None
+ assert manager.check("foobar") is None
+
+ token3 = await manager.login("admin", "pass", 0)
assert isinstance(token3, str)
assert len(token3) == 64
assert token1 != token3
@@ -131,12 +263,12 @@ async def test_ok__external(tmpdir) -> None: # type: ignore
path1 = os.path.abspath(str(tmpdir.join("htpasswd1")))
path2 = os.path.abspath(str(tmpdir.join("htpasswd2")))
- htpasswd1 = passlib.apache.HtpasswdFile(path1, new=True)
+ htpasswd1 = KvmdHtpasswdFile(path1, new=True)
htpasswd1.set_password("admin", "pass1")
htpasswd1.set_password("local", "foobar")
htpasswd1.save()
- htpasswd2 = passlib.apache.HtpasswdFile(path2, new=True)
+ htpasswd2 = KvmdHtpasswdFile(path2, new=True)
htpasswd2.set_password("admin", "pass2")
htpasswd2.set_password("user", "foobar")
htpasswd2.save()
@@ -147,17 +279,17 @@ async def test_ok__external(tmpdir) -> None: # type: ignore
assert manager.is_auth_required(_E_UNAUTH)
assert not manager.is_auth_required(_E_FREE)
- assert (await manager.login("local", "foobar")) is None
- assert (await manager.login("admin", "pass2")) is None
+ assert (await manager.login("local", "foobar", 0)) is None
+ assert (await manager.login("admin", "pass2", 0)) is None
- token = await manager.login("admin", "pass1")
+ token = await manager.login("admin", "pass1", 0)
assert token is not None
assert manager.check(token) == "admin"
manager.logout(token)
assert manager.check(token) is None
- token = await manager.login("user", "foobar")
+ token = await manager.login("user", "foobar", 0)
assert token is not None
assert manager.check(token) == "user"
@@ -169,7 +301,7 @@ async def test_ok__external(tmpdir) -> None: # type: ignore
async def test_ok__unauth(tmpdir) -> None: # type: ignore
path = os.path.abspath(str(tmpdir.join("htpasswd")))
- htpasswd = passlib.apache.HtpasswdFile(path, new=True)
+ htpasswd = KvmdHtpasswdFile(path, new=True)
htpasswd.set_password("admin", "pass")
htpasswd.save()
@@ -191,14 +323,17 @@ async def test_ok__disabled() -> None:
try:
manager = AuthManager(
enabled=False,
+ expire=0,
+ usc_users=[],
+ usc_groups=[],
unauth_paths=[],
- internal_type="foobar",
- internal_kwargs={},
- force_internal_users=[],
+ int_type="foobar",
+ int_kwargs={},
+ force_int_users=[],
- external_type="",
- external_kwargs={},
+ ext_type="",
+ ext_kwargs={},
totp_secret_path="",
)
@@ -212,7 +347,7 @@ async def test_ok__disabled() -> None:
await manager.authorize("admin", "admin")
with pytest.raises(AssertionError):
- await manager.login("admin", "admin")
+ await manager.login("admin", "admin", 0)
with pytest.raises(AssertionError):
manager.logout("xxx")
diff --git a/testenv/tests/plugins/auth/test_forbidden.py b/testenv/tests/plugins/auth/test_forbidden.py
new file mode 100644
index 00000000..a6b351a1
--- /dev/null
+++ b/testenv/tests/plugins/auth/test_forbidden.py
@@ -0,0 +1,38 @@
+# ========================================================================== #
+# #
+# KVMD - The main PiKVM daemon. #
+# #
+# Copyright (C) 2018-2024 Maxim Devaev #
+# #
+# 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 . #
+# #
+# ========================================================================== #
+
+
+import pytest
+
+from . import get_configured_auth_service
+
+
+# =====
+@pytest.mark.asyncio
+async def test_ok__forbidden_service() -> None: # type: ignore
+ async with get_configured_auth_service("forbidden") as service:
+ assert not (await service.authorize("user", "foo"))
+ assert not (await service.authorize("admin", "foo"))
+ assert not (await service.authorize("user", "pass"))
+ assert not (await service.authorize("admin", "pass"))
+ assert not (await service.authorize("admin", "admin"))
+ assert not (await service.authorize("admin", ""))
+ assert not (await service.authorize("", ""))
diff --git a/testenv/tests/plugins/auth/test_htpasswd.py b/testenv/tests/plugins/auth/test_htpasswd.py
index 12d40b23..398f05d3 100644
--- a/testenv/tests/plugins/auth/test_htpasswd.py
+++ b/testenv/tests/plugins/auth/test_htpasswd.py
@@ -22,10 +22,10 @@
import os
-import passlib.apache
-
import pytest
+from kvmd.crypto import KvmdHtpasswdFile
+
from . import get_configured_auth_service
@@ -34,7 +34,7 @@ from . import get_configured_auth_service
async def test_ok__htpasswd_service(tmpdir) -> None: # type: ignore
path = os.path.abspath(str(tmpdir.join("htpasswd")))
- htpasswd = passlib.apache.HtpasswdFile(path, new=True)
+ htpasswd = KvmdHtpasswdFile(path, new=True)
htpasswd.set_password("admin", "pass")
htpasswd.save()
diff --git a/testenv/tests/validators/test_auth.py b/testenv/tests/validators/test_auth.py
index d84e029b..0f57889c 100644
--- a/testenv/tests/validators/test_auth.py
+++ b/testenv/tests/validators/test_auth.py
@@ -28,6 +28,7 @@ from kvmd.validators import ValidatorError
from kvmd.validators.auth import valid_user
from kvmd.validators.auth import valid_users_list
from kvmd.validators.auth import valid_passwd
+from kvmd.validators.auth import valid_expire
from kvmd.validators.auth import valid_auth_token
@@ -109,6 +110,20 @@ def test_fail__valid_passwd(arg: Any) -> None:
print(valid_passwd(arg))
+# =====
+@pytest.mark.parametrize("arg", ["0 ", 0, 1, 13])
+def test_ok__valid_expire(arg: Any) -> None:
+ value = valid_expire(arg)
+ assert type(value) is int # pylint: disable=unidiomatic-typecheck
+ assert value == int(str(arg).strip())
+
+
+@pytest.mark.parametrize("arg", ["test", "", None, -1, -13, 1.1])
+def test_fail__valid_expire(arg: Any) -> None:
+ with pytest.raises(ValidatorError):
+ print(valid_expire(arg))
+
+
# =====
@pytest.mark.parametrize("arg", [
("0" * 64) + " ",
diff --git a/testenv/tests/validators/test_basic.py b/testenv/tests/validators/test_basic.py
index 7551e4bb..d88accc3 100644
--- a/testenv/tests/validators/test_basic.py
+++ b/testenv/tests/validators/test_basic.py
@@ -34,6 +34,13 @@ from kvmd.validators.basic import valid_float_f01
from kvmd.validators.basic import valid_string_list
+# =====
+def _to_int(arg: Any) -> int:
+ if isinstance(arg, str) and arg.strip().startswith(("0x", "0X")):
+ arg = int(arg.strip()[2:], 16)
+ return int(str(arg).strip())
+
+
# =====
@pytest.mark.parametrize("arg, retval", [
("1", True),
@@ -60,34 +67,34 @@ def test_fail__valid_bool(arg: Any) -> None:
# =====
-@pytest.mark.parametrize("arg", ["1 ", "-1", 1, -1, 0, 100500])
+@pytest.mark.parametrize("arg", ["1 ", "-1", 1, -1, 0, 100500, " 0xff"])
def test_ok__valid_number(arg: Any) -> None:
- assert valid_number(arg) == int(str(arg).strip())
+ assert valid_number(arg) == _to_int(arg)
-@pytest.mark.parametrize("arg", ["test", "", None, "1x", 100500.0])
+@pytest.mark.parametrize("arg", ["test", "", None, "1x", 100500.0, "ff"])
def test_fail__valid_number(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_number(arg))
-@pytest.mark.parametrize("arg", [-5, 0, 5, "-5 ", "0 ", "5 "])
+@pytest.mark.parametrize("arg", [-5, 0, 5, "-5 ", "0 ", "5 ", " 0x05"])
def test_ok__valid_number__min_max(arg: Any) -> None:
- assert valid_number(arg, -5, 5) == int(str(arg).strip())
+ assert valid_number(arg, -5, 5) == _to_int(arg)
-@pytest.mark.parametrize("arg", ["test", "", None, -6, 6, "-6 ", "6 "])
+@pytest.mark.parametrize("arg", ["test", "", None, -6, 6, "-6 ", "6 ", "0x06"])
def test_fail__valid_number__min_max(arg: Any) -> None: # pylint: disable=invalid-name
with pytest.raises(ValidatorError):
print(valid_number(arg, -5, 5))
# =====
-@pytest.mark.parametrize("arg", [0, 1, 5, "5 "])
+@pytest.mark.parametrize("arg", [0, 1, 5, "5 ", " 0x05"])
def test_ok__valid_int_f0(arg: Any) -> None:
value = valid_int_f0(arg)
assert type(value) is int # pylint: disable=unidiomatic-typecheck
- assert value == int(str(arg).strip())
+ assert value == _to_int(arg)
@pytest.mark.parametrize("arg", ["test", "", None, -6, "-6 ", "5.0"])
@@ -97,14 +104,14 @@ def test_fail__valid_int_f0(arg: Any) -> None:
# =====
-@pytest.mark.parametrize("arg", [1, 5, "5 "])
+@pytest.mark.parametrize("arg", [1, 5, "5 ", " 0x05"])
def test_ok__valid_int_f1(arg: Any) -> None:
value = valid_int_f1(arg)
assert type(value) is int # pylint: disable=unidiomatic-typecheck
- assert value == int(str(arg).strip())
+ assert value == _to_int(arg)
-@pytest.mark.parametrize("arg", ["test", "", None, -6, "-6 ", 0, "0 ", "5.0"])
+@pytest.mark.parametrize("arg", ["test", "", None, -6, "-6 ", 0, "0 ", "5.0", "0x0"])
def test_fail__valid_int_f1(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_int_f1(arg))
diff --git a/testenv/tests/validators/test_hid.py b/testenv/tests/validators/test_hid.py
index a1031a13..f3fc32e1 100644
--- a/testenv/tests/validators/test_hid.py
+++ b/testenv/tests/validators/test_hid.py
@@ -24,7 +24,7 @@ from typing import Any
import pytest
-from kvmd.keyboard.mappings import KEYMAP
+from kvmd.keyboard.mappings import WEB_TO_EVDEV
from kvmd.validators import ValidatorError
from kvmd.validators.hid import valid_hid_key
@@ -35,7 +35,7 @@ from kvmd.validators.hid import valid_hid_mouse_delta
# =====
def test_ok__valid_hid_key() -> None:
- for key in KEYMAP:
+ for key in WEB_TO_EVDEV:
print(valid_hid_key(key))
print(valid_hid_key(key + " "))
diff --git a/testenv/tests/validators/test_switch.py b/testenv/tests/validators/test_switch.py
index 6f41c6cf..01dd2062 100644
--- a/testenv/tests/validators/test_switch.py
+++ b/testenv/tests/validators/test_switch.py
@@ -94,6 +94,9 @@ def test_fail__valid_switch_edid_id__allowed_default(arg: Any) -> None:
# =====
@pytest.mark.parametrize("arg", [
+ "f" * 256,
+ "0" * 256,
+ "1a" * 128,
"f" * 512,
"0" * 512,
"1a" * 256,
diff --git a/testenv/tox.ini b/testenv/tox.ini
index 8ebbdb2e..7681b062 100644
--- a/testenv/tox.ini
+++ b/testenv/tox.ini
@@ -3,46 +3,50 @@ envlist = flake8, pylint, mypy, vulture, pytest, eslint, htmlhint, shellcheck
skipsdist = true
[testenv]
-basepython = python3.13
+basepython = python3.10
sitepackages = true
-changedir = /src
+changedir = {toxinidir}
[testenv:flake8]
allowlist_externals = bash
-commands = bash -c 'flake8 --config=testenv/linters/flake8.ini kvmd testenv/tests *.py'
+commands = bash -c 'flake8 --config={toxinidir}/testenv/linters/flake8.ini {toxinidir}/kvmd {toxinidir}/testenv/tests {toxinidir}/*.py'
deps =
flake8
flake8-quotes
- -rrequirements.txt
+ types-PyYAML
+ types-aiofiles
[testenv:pylint]
allowlist_externals = bash
-commands = bash -c 'pylint -j0 --rcfile=testenv/linters/pylint.ini --output-format=colorized --reports=no kvmd testenv/tests *.py'
+commands = bash -c 'pylint -j0 --rcfile={toxinidir}/testenv/linters/pylint.ini --output-format=colorized --reports=no {toxinidir}/kvmd {toxinidir}/testenv/tests {toxinidir}/*.py'
deps =
pylint
pytest
pytest-asyncio
aiohttp-basicauth
- -rrequirements.txt
+ types-PyYAML
+ types-aiofiles
[testenv:mypy]
allowlist_externals = bash
-commands = bash -c 'mypy --config-file=testenv/linters/mypy.ini --cache-dir=testenv/.mypy_cache kvmd testenv/tests *.py'
+commands = bash -c 'mypy --config-file={toxinidir}/testenv/linters/mypy.ini --cache-dir={toxinidir}/testenv/.mypy_cache {toxinidir}/kvmd {toxinidir}/testenv/tests {toxinidir}/*.py'
deps =
mypy
- -rrequirements.txt
+ types-PyYAML
+ types-aiofiles
[testenv:vulture]
allowlist_externals = bash
-commands = bash -c 'vulture --ignore-names=_format_P,Plugin --ignore-decorators=@exposed_http,@exposed_ws,@pytest.fixture kvmd testenv/tests *.py testenv/linters/vulture-wl.py'
+commands = bash -c 'vulture --ignore-names=_format_P,Plugin --ignore-decorators=@exposed_http,@exposed_ws,@pytest.fixture {toxinidir}/kvmd {toxinidir}/testenv/tests {toxinidir}/*.py {toxinidir}/testenv/linters/vulture-wl.py'
deps =
vulture
- -rrequirements.txt
+ types-PyYAML
+ types-aiofiles
[testenv:pytest]
-commands = py.test -vv --cov-config=testenv/linters/coverage.ini --cov-report=term-missing --cov=kvmd testenv/tests
+commands = py.test -vv --cov-config={toxinidir}/testenv/linters/coverage.ini --cov-report=term-missing --cov=kvmd {toxinidir}/testenv/tests
setenv =
- PYTHONPATH=/src
+ PYTHONPATH={toxinidir}
deps =
pytest
pytest-cov
@@ -50,16 +54,17 @@ deps =
pytest-asyncio
pytest-aiohttp
aiohttp-basicauth
- -rrequirements.txt
+ types-PyYAML
+ types-aiofiles
[testenv:eslint]
allowlist_externals = eslint
-commands = eslint --cache-location=/tmp --config=testenv/linters/eslintrc.js --color web/share/js
+commands = eslint --cache-location=/tmp --config={toxinidir}/testenv/linters/eslintrc.js --color {toxinidir}/web/share/js
[testenv:htmlhint]
allowlist_externals = htmlhint
-commands = htmlhint --config=testenv/linters/htmlhint.json web/*.html web/*/*.html
+commands = htmlhint --config={toxinidir}/testenv/linters/htmlhint.json {toxinidir}/web/*.html {toxinidir}/web/*/*.html
[testenv:shellcheck]
allowlist_externals = bash
-commands = bash -c 'shellcheck --color=always kvmd.install scripts/*'
+commands = bash -c 'shellcheck --color=always {toxinidir}/kvmd.install {toxinidir}/scripts/*'
diff --git a/testenv/v2-hdmi-rpi4.override.yaml b/testenv/v2-hdmi-rpi4.override.yaml
index 5de7f63c..90e51282 100644
--- a/testenv/v2-hdmi-rpi4.override.yaml
+++ b/testenv/v2-hdmi-rpi4.override.yaml
@@ -1,4 +1,8 @@
kvmd:
+ auth:
+ usc:
+ users: [root]
+
server:
unix_mode: 0666
@@ -11,8 +15,8 @@ kvmd:
mouse:
device: /dev/null
# absolute_win98_fix: true
-# mouse_alt:
-# device: /dev/null
+ mouse_alt:
+ device: /dev/null
noop: true
msd:
@@ -45,9 +49,9 @@ kvmd:
__v4_locator__:
type: locator
device: /dev/kvmd-gpio
- relay:
- type: hidrelay
- device: /dev/hidraw0
+# relay:
+# type: hidrelay
+# device: /dev/hidraw0
cmd1:
type: cmd
cmd: [/bin/sleep, 5]
@@ -94,20 +98,20 @@ kvmd:
mode: output
switch: false
- relay1:
- pin: 0
- mode: output
- initial: null
- driver: relay
-
- relay2:
- pin: 1
- mode: output
- initial: null
- driver: relay
- pulse:
- delay: 2
- max_delay: 5
+# relay1:
+# pin: 0
+# mode: output
+# initial: null
+# driver: relay
+#
+# relay2:
+# pin: 1
+# mode: output
+# initial: null
+# driver: relay
+# pulse:
+# delay: 2
+# max_delay: 5
cmd1:
pin: 0
diff --git a/testenv/v2-hdmiusb-rpi4.override.yaml b/testenv/v2-hdmiusb-rpi4.override.yaml
index cb18b1aa..672712c9 100644
--- a/testenv/v2-hdmiusb-rpi4.override.yaml
+++ b/testenv/v2-hdmiusb-rpi4.override.yaml
@@ -1,4 +1,8 @@
kvmd:
+ auth:
+ usc:
+ users: [root]
+
server:
unix_mode: 0666
@@ -10,6 +14,8 @@ kvmd:
device: /dev/null
mouse:
device: /dev/null
+ mouse_alt:
+ device: /dev/null
noop: true
mouse_alt:
diff --git a/web/base.pug b/web/base.pug
index a6f26b41..d12e6a31 100644
--- a/web/base.pug
+++ b/web/base.pug
@@ -23,34 +23,42 @@ doctype html
# #
==============================================================================
-- var css_dir = "/share/css"
-- var js_dir = "/share/js"
-- var svg_dir = "/share/svg"
-- var png_dir = "/share/png"
+-
+ var root_prefix = "./"
+
+ title = ""
+ main_js = ""
+ body_class = ""
+ css_list = []
-- var title = ""
-- var main_js = ""
-- var body_class = ""
-- var css_list = ["vars", "main"]
block vars
+
+block _vars_dynamic
+ -
+ share_dir = `${root_prefix}share`
+ css_dir = `${share_dir}/css`
+ js_dir = `${share_dir}/js`
+ svg_dir = `${share_dir}/svg`
+ png_dir = `${share_dir}/png`
+
+
html(lang="en")
head
meta(charset="utf-8")
title #{title}
- link(rel="apple-touch-icon" sizes="180x180" href="/share/apple-touch-icon.png")
- link(rel="icon" type="image/png" sizes="32x32" href="/share/favicon-32x32.png")
- link(rel="icon" type="image/png" sizes="16x16" href="/share/favicon-16x16.png")
- link(rel="manifest" href="/share/site.webmanifest")
- link(rel="mask-icon" href="/share/safari-pinned-tab.svg" color="#5bbad5")
+ link(rel="apple-touch-icon" sizes="180x180" href=`${share_dir}/apple-touch-icon.png`)
+ link(rel="icon" type="image/png" sizes="32x32" href=`${share_dir}/favicon-32x32.png`)
+ link(rel="icon" type="image/png" sizes="16x16" href=`${share_dir}/favicon-16x16.png`)
+ link(rel="manifest" href=`${share_dir}/site.webmanifest`)
+ link(rel="mask-icon" href=`${share_dir}/safari-pinned-tab.svg` color="#5bbad5")
meta(name="msapplication-TileColor" content="#2b5797")
meta(name="theme-color" content="#ffffff")
- each name in css_list
+ each name in ["vars", "main"].concat(css_list).concat(["user"])
link(rel="stylesheet" href=`${css_dir}/${name}.css`)
- link(rel="stylesheet" href=`${css_dir}/user.css`)
script(src=`${js_dir}/i18n/jquery-3.7.1.min.js`)
script(src=`${js_dir}/i18n/jquery.i18n.min.js`)
@@ -58,6 +66,8 @@ html(lang="en")
if main_js
script(type="module")
+ | import {setRootPrefix} from "#{js_dir}/vars.js";
+ | setRootPrefix("#{root_prefix}");
| import {main} from "#{js_dir}/#{main_js}.js";
| main();
diff --git a/web/index.html b/web/index.html
index 254347c8..c36ed6cd 100644
--- a/web/index.html
+++ b/web/index.html
@@ -27,24 +27,26 @@
One-KVM Index
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
@@ -53,41 +55,43 @@
-
+
The Open Source KVM over IP
- Copyright © 2018-2024 Maxim Devaev | Modified by SilentWind
+ Copyright © 2018-2025 Maxim Devaev | Modified by SilentWind
+
+
Loading ...
-
-
-
+
+
Please note that when you are working with a KVM session or another application that captures the keyboard,
you can't use some keyboard shortcuts such as Ctrl+Alt+Del (which will be caught by your OS) or Ctrl+W (caught by your browser).
-
To override this limitation you can use Google Chrome
+
To override this limitation you can use Google Chrome
or Chromium in application mode.
+
-
One-KVM Project | One-KVM Documentation
+
One-KVM Project | One-KVM Documentation
-
← [ One-KVM Index ]
+
← [ One-KVM Index ]
-
This PiKVM device has running kvmd-ipmi daemon and provides IPMI 2.0 interface for some basic
+
This PiKVM device has running kvmd-ipmi daemon and provides IPMI 2.0 interface for some basic
BMC operations like on/off/reset the server.
-
WARNING! We strongly don't recommend you to use IPMI in untrusted networks because
+
WARNING! We strongly don't recommend you to use IPMI in untrusted networks because
this protocol is completely unsafe by design. In short, the authentication process for IPMI mandates
that the server send a salted SHA1 or MD5 hash of the requested user's password to the client,
prior to the client authenticating.
-
NEVER use the same passwords for KVMD and IPMI users. And even better not to use IPMI.
+
NEVER use the same passwords for KVMD and IPMI users. And even better not to use IPMI.
Instead, you can directly use KVMD API via curl. Here some examples:
diff --git a/web/ipmi/index.pug b/web/ipmi/index.pug
index 0fb840ae..918e0f4e 100644
--- a/web/ipmi/index.pug
+++ b/web/ipmi/index.pug
@@ -1,20 +1,23 @@
extends ../start.pug
+
append vars
- - title = "One-KVM IPMI Info"
- - main_js = "ipmi/main"
- - index_link = true
+ -
+ root_prefix = "../"
+ title = "One-KVM IPMI Info"
+ main_js = "ipmi/main"
+ index_link = true
block start
- p(class="text" i18n="ipmi_text1")
+ p.text(i18n="ipmi_daemon_running")
| This PiKVM device has running #[b kvmd-ipmi] daemon and provides IPMI 2.0 interface for some basic
| BMC operations like on/off/reset the server.
- p(class="text" i18n="ipmi_text2")
+ p.text(i18n="ipmi_security_warning")
| #[b WARNING!] We strongly don't recommend you to use IPMI in untrusted networks because
| this protocol is completely unsafe by design. In short, the authentication process for IPMI mandates
| that the server send a salted SHA1 or MD5 hash of the requested user's password to the client,
| prior to the client authenticating.
- p(class="text" i18n="ipmi_text3")
+ p.text(i18n="ipmi_password_recommendation")
| #[b NEVER] use the same passwords for KVMD and IPMI users. And even better not to use IPMI.
| Instead, you can directly use KVMD API via curl. Here some examples:
- div(id="ipmi-text" class="code" style="max-height:200px")
+ .code#ipmi-text(style="max-height:200px")
diff --git a/web/kvm/index.html b/web/kvm/index.html
index d85733e4..263f1ae4 100644
--- a/web/kvm/index.html
+++ b/web/kvm/index.html
@@ -27,49 +27,50 @@
One-KVM Session
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
- ←
+ ←
-
+
diff --git a/web/index.pug b/web/index.pug
index 769ee260..4ea905f1 100644
--- a/web/index.pug
+++ b/web/index.pug
@@ -1,44 +1,45 @@
extends start.pug
+
append vars
- title = "One-KVM Index"
- main_js = "index/main"
- - css_list = css_list.concat(["window", "modal", "index/index"])
+ - css_list.push("window", "modal", "index/index")
block start
table
tr
- td(class="logo")
+ td.logo
a(href="https://pikvm.org" target="_blank")
- img(class="svg-gray" src=`${svg_dir}/logo.svg` alt="PiKVM" height="40")
+ img.svg-gray(src=`${svg_dir}/logo.svg` alt="PiKVM" height="40")
td
table
- tr #[td(colspan="2" class="title" i18n="index_title") The Open Source KVM over IP]
+ tr #[td.title(colspan="2" i18n="index_title") The Open Source KVM over IP]
tr
- td(colspan="2" class="copyright" i18n="copyright")
- | Copyright © 2018-2024 Maxim Devaev | Modified by SilentWind
-
- hr
-
- div(id="apps-box")
- h4 Loading ...
+ td.copyright(colspan="2" i18n="copyright")
+ | Copyright © 2018-2025 Maxim Devaev | Modified by SilentWind
hr
table
td(class="server")
- td(i18n="serve_name") Server:
- td #[a(id="kvmd-meta-server-host" target="_blank" href="/api/info")]
+ td(i18n="serve_name") Name:
+ td #[a#kvmd-meta-server-host(target="_blank" href=`${root_prefix}api/info`)]
+ hr
+ #apps-box
+ h4 Loading ...
- div(id="app-keyboard-warning")
- p(class="text" i18n="index_text_1")
+ #app-keyboard-warning
+ hr
+ p.text(i18n="keyboard_limitation_note")
| Please note that when you are working with a KVM session or another application that captures the keyboard,
| you can't use some keyboard shortcuts such as Ctrl+Alt+Del (which will be caught by your OS) or Ctrl+W (caught by your browser).
- p(class="text" i18n="index_text_2")
+ p.text(i18n="browser_solution_note")
| To override this limitation you can use #[a(target="_blank" href="https://google.com/chrome") Google Chrome]
| or #[a(target="_blank" href="https://chromium.org/Home") Chromium] in application mode.
+ .code#app-text.hidden
hr
- p(class="text credits")
- a(target="_blank" href="https://github.com/mofeng-git/One-KVM" i18n="index_text_12") One-KVM Project
+ p.text.credits
+ a(target="_blank" href="https://github.com/mofeng-git/One-KVM" i18n="onekvm_project_link") One-KVM Project
| |
- a(target="_blank" href="https://one-kvm.mofeng.run" i18n="index_text_13") One-KVM Documentation
\ No newline at end of file
+ a(target="_blank" href="https://one-kvm.mofeng.run" i18n="onekvm_documentation_link") One-KVM Documentation
\ No newline at end of file
diff --git a/web/ipmi/index.html b/web/ipmi/index.html
index 0acd8949..1846bdb6 100644
--- a/web/ipmi/index.html
+++ b/web/ipmi/index.html
@@ -27,37 +27,39 @@