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

+ + + + +
+ Name:
+

Loading ...

-
- - - - -
- Server:
-

+


+

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

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 @@ One-KVM IPMI Info - - - - - + + + + + - - - - - - - - + + +
-
  ←   [ 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 - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - + + +
-
  • ATX +
  • ATX
  • -
  • Drive +
  • Drive
  • -
  • Macro +
  • Macro
  • -
  • Text -
  • Text +